From 08f59d6eddcb85f3dda02b1a84ff9886c24d8396 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Thu, 25 Jun 2026 16:51:17 +0900 Subject: [PATCH 001/109] =?UTF-8?q?chore(license):=20Apache=202.0=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=EB=A5=BC=20MIT=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit router/cmd/chart-test 등 16개 파일의 라이선스 헤더를 저장소 LICENSE(MIT)와 일치시킨다. 로직 변경 없음. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01H4fFn82hrtvuCfuZnBMaqZ --- cmd/reshard-copy-poc/main.go | 6 +----- cmd/scatter-poc/main.go | 6 +----- internal/controller/stale_replica_reseed.go | 6 +----- internal/controller/stale_replica_reseed_test.go | 6 +----- internal/router/metadata_store.go | 6 +----- internal/router/metadata_store_test.go | 6 +----- internal/router/placement.go | 6 +----- internal/router/placement_test.go | 6 +----- internal/router/reshard_copy_test.go | 6 +----- internal/router/resharding_test.go | 6 +----- internal/router/sql_executor_test.go | 6 +----- internal/router/sql_route_test.go | 6 +----- internal/router/vindex.go | 6 +----- internal/router/vindex_test.go | 6 +----- test/chart/grafana_dashboards_test.go | 12 +----------- test/chart/rbac_clusterrole_test.go | 12 +----------- 16 files changed, 16 insertions(+), 92 deletions(-) diff --git a/cmd/reshard-copy-poc/main.go b/cmd/reshard-copy-poc/main.go index 2fe7483..a2d6a0c 100644 --- a/cmd/reshard-copy-poc/main.go +++ b/cmd/reshard-copy-poc/main.go @@ -1,11 +1,7 @@ /* Copyright 2026 keiailab. -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 +Licensed under the MIT License. See the LICENSE file for details. */ // Command reshard-copy-poc is a G3 online-resharding InitialCopy live PoC. It diff --git a/cmd/scatter-poc/main.go b/cmd/scatter-poc/main.go index 3a2b354..74d5026 100644 --- a/cmd/scatter-poc/main.go +++ b/cmd/scatter-poc/main.go @@ -1,11 +1,7 @@ /* Copyright 2026 keiailab. -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 +Licensed under the MIT License. See the LICENSE file for details. */ // Command scatter-poc is a G3 multi-shard query live PoC. It builds diff --git a/internal/controller/stale_replica_reseed.go b/internal/controller/stale_replica_reseed.go index ca28e44..4b710a0 100644 --- a/internal/controller/stale_replica_reseed.go +++ b/internal/controller/stale_replica_reseed.go @@ -1,11 +1,7 @@ /* Copyright 2026 keiailab. -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 +Licensed under the MIT License. See the LICENSE file for details. */ package controller diff --git a/internal/controller/stale_replica_reseed_test.go b/internal/controller/stale_replica_reseed_test.go index 36ddf38..5dca048 100644 --- a/internal/controller/stale_replica_reseed_test.go +++ b/internal/controller/stale_replica_reseed_test.go @@ -1,11 +1,7 @@ /* Copyright 2026 keiailab. -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 +Licensed under the MIT License. See the LICENSE file for details. */ package controller diff --git a/internal/router/metadata_store.go b/internal/router/metadata_store.go index 3c474a9..f191cc5 100644 --- a/internal/router/metadata_store.go +++ b/internal/router/metadata_store.go @@ -1,11 +1,7 @@ /* Copyright 2026 keiailab. -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 +Licensed under the MIT License. See the LICENSE file for details. */ package router diff --git a/internal/router/metadata_store_test.go b/internal/router/metadata_store_test.go index 4e192e9..3cfd4c7 100644 --- a/internal/router/metadata_store_test.go +++ b/internal/router/metadata_store_test.go @@ -1,11 +1,7 @@ /* Copyright 2026 keiailab. -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 +Licensed under the MIT License. See the LICENSE file for details. */ package router diff --git a/internal/router/placement.go b/internal/router/placement.go index e0efa9d..e72d5e6 100644 --- a/internal/router/placement.go +++ b/internal/router/placement.go @@ -1,11 +1,7 @@ /* Copyright 2026 keiailab. -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 +Licensed under the MIT License. See the LICENSE file for details. */ package router diff --git a/internal/router/placement_test.go b/internal/router/placement_test.go index 60999ea..f51d868 100644 --- a/internal/router/placement_test.go +++ b/internal/router/placement_test.go @@ -1,11 +1,7 @@ /* Copyright 2026 keiailab. -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 +Licensed under the MIT License. See the LICENSE file for details. */ package router diff --git a/internal/router/reshard_copy_test.go b/internal/router/reshard_copy_test.go index 3f64b6e..d90cc7f 100644 --- a/internal/router/reshard_copy_test.go +++ b/internal/router/reshard_copy_test.go @@ -1,11 +1,7 @@ /* Copyright 2026 keiailab. -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 +Licensed under the MIT License. See the LICENSE file for details. */ package router diff --git a/internal/router/resharding_test.go b/internal/router/resharding_test.go index 74a0082..50f5b3c 100644 --- a/internal/router/resharding_test.go +++ b/internal/router/resharding_test.go @@ -1,11 +1,7 @@ /* Copyright 2026 keiailab. -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 +Licensed under the MIT License. See the LICENSE file for details. */ package router diff --git a/internal/router/sql_executor_test.go b/internal/router/sql_executor_test.go index 050d1ad..df88941 100644 --- a/internal/router/sql_executor_test.go +++ b/internal/router/sql_executor_test.go @@ -1,11 +1,7 @@ /* Copyright 2026 keiailab. -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 +Licensed under the MIT License. See the LICENSE file for details. */ package router diff --git a/internal/router/sql_route_test.go b/internal/router/sql_route_test.go index 8f59fdb..1608b9d 100644 --- a/internal/router/sql_route_test.go +++ b/internal/router/sql_route_test.go @@ -1,11 +1,7 @@ /* Copyright 2026 keiailab. -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 +Licensed under the MIT License. See the LICENSE file for details. */ package router diff --git a/internal/router/vindex.go b/internal/router/vindex.go index 4563310..436105c 100644 --- a/internal/router/vindex.go +++ b/internal/router/vindex.go @@ -1,11 +1,7 @@ /* Copyright 2026 keiailab. -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 +Licensed under the MIT License. See the LICENSE file for details. */ package router diff --git a/internal/router/vindex_test.go b/internal/router/vindex_test.go index 710fe73..6e3e563 100644 --- a/internal/router/vindex_test.go +++ b/internal/router/vindex_test.go @@ -1,11 +1,7 @@ /* Copyright 2026 keiailab. -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 +Licensed under the MIT License. See the LICENSE file for details. */ package router diff --git a/test/chart/grafana_dashboards_test.go b/test/chart/grafana_dashboards_test.go index 0f1a371..a29d709 100644 --- a/test/chart/grafana_dashboards_test.go +++ b/test/chart/grafana_dashboards_test.go @@ -1,17 +1,7 @@ /* Copyright 2026 keiailab. -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. +Licensed under the MIT License. See the LICENSE file for details. */ // Package chart 는 Helm 차트 렌더링 결과를 라이브 클러스터 없이 검증한다. diff --git a/test/chart/rbac_clusterrole_test.go b/test/chart/rbac_clusterrole_test.go index ba41a87..3d86fda 100644 --- a/test/chart/rbac_clusterrole_test.go +++ b/test/chart/rbac_clusterrole_test.go @@ -1,17 +1,7 @@ /* Copyright 2026 keiailab. -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. +Licensed under the MIT License. See the LICENSE file for details. */ // 본 파일은 Helm 차트가 렌더하는 manager ClusterRole 이 config/rbac/role.yaml From eabbd98b76a70c2623102147f28e684232390eb6 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Thu, 25 Jun 2026 16:51:18 +0900 Subject: [PATCH 002/109] =?UTF-8?q?feat(ha):=20#220=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=20failover=20=EC=95=88=EC=A0=95=ED=99=94=20=E2=80=94=20fencing?= =?UTF-8?q?=C2=B7pod-readiness=C2=B7promotion=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - aggregate_status: instance-status heartbeat 와 별개로 K8s Pod/컨테이너 readiness 를 검사하고, fenced PVC 멤버를 promotion 후보에서 제외한다. - failover_promoter: pg_ctl promote → pg_promote(SQL) 전환, 승격 전 old-primary PVC fencing, 승격 후보 K8s readiness 검증을 추가한다. - postgrescluster_controller: primaryEndpointForShard 추출로 초기 HA bootstrap 의 false failover window 를 차단하고 #220 failback guard 를 적용한다. - primary_endpoint.go 신규. - failover_chaos_test: 승격 경로 완성으로 live chaos drill 을 PContext→Context 로 활성화 (p1 게이트가 STS self-heal 의 promotion 선점 회귀를 잡도록 유지). ※ postgrescluster_controller.go 에는 PITR(#209) 의 restore-in-progress 락 (AnnotationRestoreInProgress) hunk 가 함께 포함된다. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01H4fFn82hrtvuCfuZnBMaqZ --- internal/controller/aggregate_status.go | 57 ++- internal/controller/aggregate_status_test.go | 69 +++ .../controller/cluster_conditions_test.go | 2 +- .../failover/shard5_recovery_test.go | 401 ++++++++++++++++++ internal/controller/failover_promoter.go | 92 +++- internal/controller/failover_promoter_test.go | 199 ++++++++- .../controller/postgrescluster_controller.go | 126 +++--- .../postgrescluster_controller_test.go | 75 ++++ internal/controller/primary_endpoint.go | 47 ++ internal/controller/primary_endpoint_test.go | 75 ++++ .../replica_cluster_failover_test.go | 43 ++ test/e2e/failover_chaos_test.go | 17 +- 12 files changed, 1117 insertions(+), 86 deletions(-) create mode 100644 internal/controller/failover/shard5_recovery_test.go create mode 100644 internal/controller/primary_endpoint.go create mode 100644 internal/controller/primary_endpoint_test.go create mode 100644 internal/controller/replica_cluster_failover_test.go diff --git a/internal/controller/aggregate_status.go b/internal/controller/aggregate_status.go index c34492d..90d22f1 100644 --- a/internal/controller/aggregate_status.go +++ b/internal/controller/aggregate_status.go @@ -32,6 +32,15 @@ const statusStaleThresh = 30 * time.Second // reseeds these into clean standbys (#220 clean-rejoin). const roguePrimaryReason = "rogue-primary" +// fencedMemberReason marks a shard member whose PVC is fenced. Fenced members +// must not be promotion candidates even if their last instance-status heartbeat +// still says Ready=true. +const fencedMemberReason = "fenced" + +// podNotReadyReason marks a member whose instance-status heartbeat still says +// Ready=true but the Kubernetes Pod/container is not ready for exec/probes. +const podNotReadyReason = "pod-not-ready" + // aggregateShardStatus 는 단일 shard 의 모든 Pod (StatefulSet replicas) 를 list 한 뒤 // 각 Pod 의 statusapi annotation 을 parse 해 ShardStatus 를 합성한다 (RFC 0006 R2). // @@ -126,6 +135,10 @@ func aggregateShardStatus( "pod", pod.Name, "lastUpdate", st.LastUpdate) ready = false } + podNotReady := kubernetesPodNotReady(pod) + if podNotReady { + ready = false + } ep := postgresv1alpha1.ShardEndpoint{ Pod: pod.Name, Endpoint: st.Endpoint, @@ -134,8 +147,26 @@ func aggregateShardStatus( Reason: st.Reason, Message: st.Message, } + if podNotReady { + if ep.Reason == "" { + ep.Reason = podNotReadyReason + } + if ep.Message == "" { + ep.Message = "Kubernetes Pod or postgres container is not ready" + } + } + podFenced := fencedPVC["data-"+pod.Name] + if podFenced { + ep.Ready = false + if ep.Reason == "" { + ep.Reason = fencedMemberReason + } + if ep.Message == "" { + ep.Message = "PVC is fenced; member is excluded from promotion candidates" + } + } switch { - case st.Role == statusapi.RolePrimary && fencedPVC["data-"+pod.Name]: + case st.Role == statusapi.RolePrimary && podFenced: // #220: fenced known-failed primary (e.g. a returning old primary that // self-reports Primary before its fence stops it) — never the shard primary. logger.Info("ignoring Primary self-report from fenced member", "pod", pod.Name) @@ -169,6 +200,30 @@ func aggregateShardStatus( return out } +func kubernetesPodNotReady(pod *corev1.Pod) bool { + if pod == nil { + return true + } + if pod.DeletionTimestamp != nil { + return true + } + switch pod.Status.Phase { + case corev1.PodPending, corev1.PodFailed, corev1.PodSucceeded: + return true + } + for i := range pod.Status.Conditions { + if pod.Status.Conditions[i].Type == corev1.PodReady { + return pod.Status.Conditions[i].Status != corev1.ConditionTrue + } + } + for i := range pod.Status.ContainerStatuses { + if pod.Status.ContainerStatuses[i].Name == pgContainerName { + return !pod.Status.ContainerStatuses[i].Ready + } + } + return false +} + // parsePodStatus 는 Pod annotation 에서 statusapi.Status 를 디코드한다. // 부재 / 깨진 JSON 은 ok=false (호출자가 fallback). func parsePodStatus(pod *corev1.Pod) (statusapi.Status, bool) { diff --git a/internal/controller/aggregate_status_test.go b/internal/controller/aggregate_status_test.go index 388f02d..d713cf9 100644 --- a/internal/controller/aggregate_status_test.go +++ b/internal/controller/aggregate_status_test.go @@ -21,6 +21,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" postgresv1alpha1 "github.com/keiailab/postgres-operator/api/v1alpha1" + "github.com/keiailab/postgres-operator/internal/instance/fencing" "github.com/keiailab/postgres-operator/internal/instance/statusapi" ) @@ -123,6 +124,74 @@ func TestAggregateShardStatus_PropagatesInstanceReasonAndMessage(t *testing.T) { } } +func TestAggregateShardStatus_FencedPrimaryIsNotPromotionReadyReplica(t *testing.T) { + t.Parallel() + now := time.Now().UTC() + pod := makePod("demo-shard-0-0", statusapi.Status{ + Role: statusapi.RolePrimary, + Ready: true, + Endpoint: "demo-shard-0-0.svc:5432", + LagBytes: 0, + LastUpdate: now, + }, true) + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "data-demo-shard-0-0", + Namespace: "default", + Labels: map[string]string{ + fencing.FenceLabelKey: fencing.FenceLabelValue, + }, + }, + } + c := fake.NewClientBuilder().WithScheme(newScheme(t)).WithObjects(pod, pvc).Build() + + out := aggregateShardStatus(context.Background(), c, newCluster(), 0, "demo-shard-0-headless") + if out.Primary != nil { + t.Fatalf("fenced primary must not be selected as shard primary: %+v", out.Primary) + } + if len(out.Replicas) != 1 { + t.Fatalf("replicas = %d, want fenced pod as not-ready replica", len(out.Replicas)) + } + if out.Replicas[0].Ready { + t.Fatalf("fenced pod must not remain promotion-ready replica: %+v", out.Replicas[0]) + } + if out.Replicas[0].Reason != "fenced" { + t.Fatalf("fenced replica reason = %q, want fenced", out.Replicas[0].Reason) + } +} + +func TestAggregateShardStatus_PodNotReadyIsNotPromotionReadyReplica(t *testing.T) { + t.Parallel() + now := time.Now().UTC() + pod := makePod("demo-shard-0-1", statusapi.Status{ + Role: statusapi.RoleReplica, + Ready: true, + Endpoint: "demo-shard-0-1.svc:5432", + LagBytes: 0, + LastUpdate: now, + }, true) + pod.Status.Conditions = []corev1.PodCondition{{ + Type: corev1.PodReady, + Status: corev1.ConditionFalse, + }} + pod.Status.ContainerStatuses = []corev1.ContainerStatus{{ + Name: pgContainerName, + Ready: false, + }} + c := fake.NewClientBuilder().WithScheme(newScheme(t)).WithObjects(pod).Build() + + out := aggregateShardStatus(context.Background(), c, newCluster(), 0, "demo-shard-0-headless") + if len(out.Replicas) != 1 { + t.Fatalf("replicas = %d, want pod as not-ready replica", len(out.Replicas)) + } + if out.Replicas[0].Ready { + t.Fatalf("Kubernetes-not-ready pod must not remain promotion-ready replica: %+v", out.Replicas[0]) + } + if out.Replicas[0].Reason != "pod-not-ready" { + t.Fatalf("not-ready replica reason = %q, want pod-not-ready", out.Replicas[0].Reason) + } +} + func TestAggregateShardStatus_StaleHeartbeat_ForcesNotReady(t *testing.T) { t.Parallel() old := time.Now().Add(-2 * time.Minute).UTC() // > 30s thresh diff --git a/internal/controller/cluster_conditions_test.go b/internal/controller/cluster_conditions_test.go index 3806b31..5ad4e0e 100644 --- a/internal/controller/cluster_conditions_test.go +++ b/internal/controller/cluster_conditions_test.go @@ -34,7 +34,7 @@ func TestApplyClusterConditionsDegradesWhenPreviouslyReadyPrimaryFails(t *testin }, } - applyClusterConditions(cluster, 1, false, false, nil, false, true, decision) + applyClusterConditions(cluster, 1, false, false, nil, false, false, true, decision) if cluster.Status.Phase != postgresv1alpha1.ClusterPhaseDegraded { t.Fatalf("Phase = %q, want Degraded", cluster.Status.Phase) diff --git a/internal/controller/failover/shard5_recovery_test.go b/internal/controller/failover/shard5_recovery_test.go new file mode 100644 index 0000000..2e4e782 --- /dev/null +++ b/internal/controller/failover/shard5_recovery_test.go @@ -0,0 +1,401 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package failover + +// shard5_recovery_test.go — 5-shard 분산 클러스터에서 3번째 샤드(shard-2) Primary +// 장애 발생 시 자동 복구 파이프라인 전체를 검증하는 시나리오 테스트. +// +// 검증 범위: +// 1. 5개 샤드 중 shard-2 만 장애 감지 (다른 4개는 정상) +// 2. shard-2 복구 후보 자동 선정 (LagBytes 기준 최적 Replica) +// 3. Promotion Plan 생성 + 실행 +// 4. 복구 후 상태 갱신 → 전체 재검사 시 5개 샤드 모두 정상 +// +// 이 테스트는 실제 K8s / PostgreSQL 없이 순수 로직 레이어를 검증한다. +// e2e (Kind 클러스터 위 실제 Pod) 수준 검증은 test/e2e/failover_e2e_test.go 담당. + +import ( + "context" + "fmt" + "testing" + "time" + + postgresv1alpha1 "github.com/keiailab/postgres-operator/api/v1alpha1" +) + +// ── 헬퍼 ────────────────────────────────────────────────────────────────────── + +// makeHealthyShard 는 Primary 1개 + Replica 2개가 모두 Ready 상태인 shard 를 반환. +func makeHealthyShard(name string) postgresv1alpha1.ShardStatus { + return postgresv1alpha1.ShardStatus{ + Name: name, + Primary: &postgresv1alpha1.ShardEndpoint{ + Pod: name + "-0", Ready: true, LagBytes: 0, + }, + Replicas: []postgresv1alpha1.ShardEndpoint{ + {Pod: name + "-1", Ready: true, LagBytes: 50}, + {Pod: name + "-2", Ready: true, LagBytes: 120}, + }, + } +} + +// makeFailedShard 는 Primary 가 NotReady 이고 두 Replica 가 각기 다른 Lag 를 가진 shard. +// 복구 후보는 LagBytes 가 작은 replica 로 결정되어야 한다. +func makeFailedShard(name string) postgresv1alpha1.ShardStatus { + return postgresv1alpha1.ShardStatus{ + Name: name, + Primary: &postgresv1alpha1.ShardEndpoint{ + Pod: name + "-0", Ready: false, // Primary 비정상 + }, + Replicas: []postgresv1alpha1.ShardEndpoint{ + {Pod: name + "-1", Ready: true, LagBytes: 200}, // 후보 아님 (lag 큼) + {Pod: name + "-2", Ready: true, LagBytes: 30}, // ← 최적 후보 (lag 최소) + }, + } +} + +// applyPromotion 은 Promoter 가 실행한 뒤 shard 상태를 업데이트하는 것을 시뮬레이션. +// 실제 환경에서는 instance manager 가 pg_ctl promote 를 실행하고 annotation 을 갱신한다. +func applyPromotion(shard postgresv1alpha1.ShardStatus, plan PromotionPlan) postgresv1alpha1.ShardStatus { + // 승격된 Replica 가 새 Primary 로 등재 + newPrimary := &postgresv1alpha1.ShardEndpoint{ + Pod: plan.Target.Pod, Ready: true, LagBytes: 0, + } + // 기존 Primary 는 Replica 로 재합류 (rejoin — standby.signal 생성 후 streaming replication) + oldPrimaryReplica := postgresv1alpha1.ShardEndpoint{ + Pod: shard.Primary.Pod, Ready: true, LagBytes: 80, + } + // 나머지 Replica 들 (승격된 것 제외) + var remainingReplicas []postgresv1alpha1.ShardEndpoint + remainingReplicas = append(remainingReplicas, oldPrimaryReplica) + for _, r := range shard.Replicas { + if r.Pod != plan.Target.Pod { + remainingReplicas = append(remainingReplicas, r) + } + } + return postgresv1alpha1.ShardStatus{ + Name: shard.Name, + Primary: newPrimary, + Replicas: remainingReplicas, + } +} + +// recordingPromoter 는 Promotion 실행을 기록만 하고 성공을 반환 (테스트용). +type recordingPromoter struct { + plans []PromotionPlan + latencies []time.Duration +} + +func (r *recordingPromoter) Execute(_ context.Context, plan PromotionPlan) error { + start := time.Now() + // 실제 pg_ctl promote 는 수 초 걸리지만 단위 테스트에서는 즉시 완료로 처리. + r.plans = append(r.plans, plan) + r.latencies = append(r.latencies, time.Since(start)) + return nil +} + +// ── 메인 시나리오 테스트 ──────────────────────────────────────────────────────── + +// TestShard5_Shard2PrimaryFailureAndRecovery 는 5-shard 분산 클러스터에서 +// shard-2 (3번째 샤드, 0-indexed) Primary 장애 → 자동 복구 전 과정을 검증한다. +// +// 시나리오: +// +// [초기] shard-0 ~ shard-4 전부 정상 +// [장애] shard-2 Primary (shard-2-0) Ready=false +// [감지] 5개 샤드 순회 → shard-2 만 Failed 판정 +// [복구] shard-2-2 (lag=30) 자동 선정 → PromotionPlan 생성 → Promoter 실행 +// [갱신] shard-2 상태 업데이트 (새 Primary: shard-2-2, 옛 Primary: Replica 재합류) +// [재검사] 5개 샤드 전부 정상 확인 +func TestShard5_Shard2PrimaryFailureAndRecovery(t *testing.T) { + t.Parallel() + + const ( + failedShardIndex = 2 // 0-indexed → 3번째 샤드 + totalShards = 5 + ) + + // ── 단계 1: 5개 샤드 초기 상태 구성 ───────────────────────────────────────── + shards := make([]postgresv1alpha1.ShardStatus, totalShards) + for i := range shards { + shards[i] = makeHealthyShard(fmt.Sprintf("shard-%d", i)) + } + // 3번째 샤드 (index=2) 만 Primary 장애 주입 + shards[failedShardIndex] = makeFailedShard("shard-2") + + t.Logf("=== [단계 1] 초기 상태 ===") + for _, s := range shards { + if s.Primary != nil { + t.Logf(" %-10s primary=%-12s ready=%-5v replicas=%d", + s.Name, s.Primary.Pod, s.Primary.Ready, len(s.Replicas)) + } else { + t.Logf(" %-10s primary= replicas=%d", s.Name, len(s.Replicas)) + } + } + + // ── 단계 2: 5개 샤드 전체 장애 감지 순회 ────────────────────────────────── + t.Logf("\n=== [단계 2] 장애 감지 순회 ===") + decisions := make([]Decision, totalShards) + failedCount := 0 + + for i, shard := range shards { + d := DetectPrimaryFailure(shard) + decisions[i] = d + status := "정상" + if d.Failed { + status = fmt.Sprintf("장애(%s)", d.Reason) + failedCount++ + } + t.Logf(" %-10s %s", shard.Name, status) + if d.Failed && d.PromotionCandidate != nil { + t.Logf(" └─ 복구 후보: %s (lag=%d bytes)", d.PromotionCandidate.Pod, d.PromotionCandidate.LagBytes) + } + } + + // 검증: 정확히 1개 샤드(shard-2)만 장애 + if failedCount != 1 { + t.Fatalf("장애 샤드 수 = %d, 기대값 = 1", failedCount) + } + + // 검증: 나머지 4개 샤드는 정상 + for i, d := range decisions { + if i == failedShardIndex { + if !d.Failed { + t.Errorf("shard-%d 는 장애여야 함", i) + } + } else { + if d.Failed { + t.Errorf("shard-%d 는 정상이어야 하는데 장애 판정됨: reason=%s", i, d.Reason) + } + } + } + + // ── 단계 3: shard-2 장애 상세 검증 ────────────────────────────────────────── + t.Logf("\n=== [단계 3] shard-2 장애 상세 ===") + d2 := decisions[failedShardIndex] + + if d2.Reason != ReasonPrimaryNotReady { + t.Errorf("Reason = %q, 기대값 = %q", d2.Reason, ReasonPrimaryNotReady) + } + if d2.PromotionCandidate == nil { + t.Fatal("복구 후보가 nil — 자동 복구 불가") + } + // lag=30 인 shard-2-2 가 선정되어야 한다 (shard-2-1 lag=200 보다 작음) + if d2.PromotionCandidate.Pod != "shard-2-2" { + t.Errorf("복구 후보 Pod = %q, 기대값 = 'shard-2-2' (lag 최소)", d2.PromotionCandidate.Pod) + } + t.Logf(" 장애 이유: %s", d2.Reason) + t.Logf(" 메시지: %s", d2.Message) + t.Logf(" 복구 후보: %s (lag=%d bytes)", d2.PromotionCandidate.Pod, d2.PromotionCandidate.LagBytes) + + // ── 단계 4: Promotion Plan 생성 + 실행 ─────────────────────────────────────── + t.Logf("\n=== [단계 4] Promotion Plan 생성 및 실행 ===") + promoter := &recordingPromoter{} + + err := PromoteFromDecision(context.Background(), "shard-2", d2, promoter) + if err != nil { + t.Fatalf("PromoteFromDecision 실패: %v", err) + } + if len(promoter.plans) != 1 { + t.Fatalf("Promoter 실행 횟수 = %d, 기대값 = 1", len(promoter.plans)) + } + + plan := promoter.plans[0] + t.Logf(" 대상 샤드: %s", plan.Target.ShardName) + t.Logf(" 승격 Pod: %s", plan.Target.Pod) + t.Logf(" 실행 단계 (%d개):", len(plan.Steps)) + for j, step := range plan.Steps { + t.Logf(" [%d] %s", j+1, step) + } + + // 4단계 순서 고정 검증 + wantSteps := []PromotionStep{ + StepRemoveStandbySignal, + StepPgCtlPromote, + StepWaitNotInRecovery, + StepUpdateInstanceRole, + } + if len(plan.Steps) != len(wantSteps) { + t.Fatalf("Steps 수 = %d, 기대값 = %d", len(plan.Steps), len(wantSteps)) + } + for j, want := range wantSteps { + if plan.Steps[j] != want { + t.Errorf("Steps[%d] = %q, 기대값 = %q", j, plan.Steps[j], want) + } + } + + // ── 단계 5: 복구 후 상태 갱신 ──────────────────────────────────────────────── + t.Logf("\n=== [단계 5] 복구 후 상태 갱신 ===") + // instance manager 가 pg_ctl promote 완료 후 annotation 을 갱신하는 것을 시뮬레이션 + shards[failedShardIndex] = applyPromotion(shards[failedShardIndex], plan) + recovered := shards[failedShardIndex] + + t.Logf(" 새 Primary: %s (ready=%v)", recovered.Primary.Pod, recovered.Primary.Ready) + t.Logf(" Replica 목록 (%d개):", len(recovered.Replicas)) + for _, r := range recovered.Replicas { + t.Logf(" - %s (lag=%d bytes)", r.Pod, r.LagBytes) + } + + // 검증: 새 Primary 는 승격된 Replica + if recovered.Primary.Pod != d2.PromotionCandidate.Pod { + t.Errorf("새 Primary Pod = %q, 기대값 = %q", recovered.Primary.Pod, d2.PromotionCandidate.Pod) + } + if !recovered.Primary.Ready { + t.Errorf("새 Primary 가 Ready=false — 복구 실패") + } + + // 검증: 옛 Primary 가 Replica 로 재합류 + oldPrimaryPod := "shard-2-0" + oldPrimaryFound := false + for _, r := range recovered.Replicas { + if r.Pod == oldPrimaryPod { + oldPrimaryFound = true + if !r.Ready { + t.Errorf("옛 Primary %s 가 Replica 로 재합류했지만 Ready=false", oldPrimaryPod) + } + break + } + } + if !oldPrimaryFound { + t.Errorf("옛 Primary %s 가 Replica 목록에 없음 — rejoin 누락", oldPrimaryPod) + } + + // ── 단계 6: 복구 후 전체 재검사 ───────────────────────────────────────────── + t.Logf("\n=== [단계 6] 복구 후 전체 재검사 ===") + postRecoveryFailed := 0 + for _, shard := range shards { + d := DetectPrimaryFailure(shard) + status := "✓ 정상" + if d.Failed { + status = fmt.Sprintf("✗ 장애(%s)", d.Reason) + postRecoveryFailed++ + } + t.Logf(" %-10s %s", shard.Name, status) + } + + if postRecoveryFailed != 0 { + t.Errorf("복구 후 장애 샤드 수 = %d, 기대값 = 0 (전체 정상)", postRecoveryFailed) + } + + t.Logf("\n=== 결과: 5개 샤드 모두 정상 복구 완료 ===") +} + +// ── 보완 케이스 ───────────────────────────────────────────────────────────────── + +// TestShard5_AllShardsHealthy 는 장애가 없을 때 5개 샤드 모두 정상 판정을 보장. +func TestShard5_AllShardsHealthy(t *testing.T) { + t.Parallel() + shards := make([]postgresv1alpha1.ShardStatus, 5) + for i := range shards { + shards[i] = makeHealthyShard(fmt.Sprintf("shard-%d", i)) + } + for _, s := range shards { + d := DetectPrimaryFailure(s) + if d.Failed { + t.Errorf("%s 가 장애 판정됨 (기대: 정상) reason=%s", s.Name, d.Reason) + } + } +} + +// TestShard5_MultipleShardFailure 는 여러 샤드가 동시에 실패했을 때 각각 독립적으로 +// 감지·복구 후보가 선정되는지 검증한다. +func TestShard5_MultipleShardFailure(t *testing.T) { + t.Parallel() + + shards := make([]postgresv1alpha1.ShardStatus, 5) + for i := range shards { + shards[i] = makeHealthyShard(fmt.Sprintf("shard-%d", i)) + } + + // shard-1 과 shard-3 동시 장애 + failedIndices := map[int]bool{1: true, 3: true} + shards[1] = makeFailedShard("shard-1") + shards[3] = makeFailedShard("shard-3") + + failedCount := 0 + for i, shard := range shards { + d := DetectPrimaryFailure(shard) + if failedIndices[i] { + if !d.Failed { + t.Errorf("shard-%d 는 장애여야 함", i) + } + if d.PromotionCandidate == nil { + t.Errorf("shard-%d 복구 후보가 nil", i) + } else if d.PromotionCandidate.Pod != fmt.Sprintf("shard-%d-2", i) { + t.Errorf("shard-%d 복구 후보 = %q, 기대값 = shard-%d-2", + i, d.PromotionCandidate.Pod, i) + } + failedCount++ + } else { + if d.Failed { + t.Errorf("shard-%d 는 정상이어야 함", i) + } + } + } + if failedCount != 2 { + t.Errorf("감지된 장애 샤드 수 = %d, 기대값 = 2", failedCount) + } +} + +// TestShard5_Shard2NoPrimaryNoReplica 는 shard-2 Primary 도 없고 Ready Replica +// 도 없을 때 NoEligibleReplica 로 판정되어 자동 복구 불가 상태임을 검증한다. +func TestShard5_Shard2NoPrimaryNoReplica(t *testing.T) { + t.Parallel() + + shards := make([]postgresv1alpha1.ShardStatus, 5) + for i := range shards { + shards[i] = makeHealthyShard(fmt.Sprintf("shard-%d", i)) + } + // shard-2: Primary 없음 + Replica 전부 NotReady → 자동 복구 불가 + shards[2] = postgresv1alpha1.ShardStatus{ + Name: "shard-2", + Primary: nil, + Replicas: []postgresv1alpha1.ShardEndpoint{ + {Pod: "shard-2-1", Ready: false}, + {Pod: "shard-2-2", Ready: false}, + }, + } + + d := DetectPrimaryFailure(shards[2]) + if !d.Failed { + t.Fatal("장애로 판정되어야 함") + } + if d.Reason != ReasonNoEligibleReplica { + t.Errorf("Reason = %q, 기대값 = %q", d.Reason, ReasonNoEligibleReplica) + } + if d.PromotionCandidate != nil { + t.Errorf("복구 불가 상태에서 후보 = %v, 기대값 = nil", d.PromotionCandidate) + } + t.Logf("수동 개입 필요 메시지: %s", d.Message) +} + +// TestShard5_RecoveryDeterminism 은 동일한 5-shard 장애 상태에서 복구 후보가 +// 항상 같은 Pod 로 결정됨을 반복 실행으로 검증한다 (결정성 보장). +func TestShard5_RecoveryDeterminism(t *testing.T) { + t.Parallel() + + shards := make([]postgresv1alpha1.ShardStatus, 5) + for i := range shards { + shards[i] = makeHealthyShard(fmt.Sprintf("shard-%d", i)) + } + shards[2] = makeFailedShard("shard-2") + + var firstCandidate string + for run := 0; run < 100; run++ { + d := DetectPrimaryFailure(shards[2]) + if d.PromotionCandidate == nil { + t.Fatal("복구 후보가 nil") + } + if run == 0 { + firstCandidate = d.PromotionCandidate.Pod + } else if d.PromotionCandidate.Pod != firstCandidate { + t.Errorf("run %d: 복구 후보 = %q, 1회차 = %q (결정성 위반)", + run, d.PromotionCandidate.Pod, firstCandidate) + } + } + t.Logf("100회 반복 결정성 확인: 후보 = %s", firstCandidate) +} diff --git a/internal/controller/failover_promoter.go b/internal/controller/failover_promoter.go index ce2dc05..8662979 100644 --- a/internal/controller/failover_promoter.go +++ b/internal/controller/failover_promoter.go @@ -49,6 +49,19 @@ func (r *PostgresClusterReconciler) executeClusterPromotion( PodExecutor: r.PromotionPodExecutor, Now: time.Now, } + oldPrimary := shardPrimaryPod(cluster, shardName) + if oldPrimary != "" && decision.PromotionCandidate != nil && + oldPrimary != decision.PromotionCandidate.Pod && + r.podAbsentOrNotReady(ctx, cluster.Namespace, oldPrimary) { + if err := r.fencePodPVC(ctx, cluster.Namespace, oldPrimary); err != nil { + return fmt.Errorf("pre-fence failed old primary %q: %w", oldPrimary, err) + } + } + if decision.PromotionCandidate != nil { + if err := r.promotionCandidateReadyForExec(ctx, cluster.Namespace, decision.PromotionCandidate.Pod); err != nil { + return err + } + } if err := failover.PromoteFromDecision(ctx, shardName, decision, promoter); err != nil { return err } @@ -56,7 +69,6 @@ func (r *PostgresClusterReconciler) executeClusterPromotion( // pg_basebackup standby of the new primary — never as a rogue primary booting // from stale PGDATA (split-brain). Gated on the old primary being genuinely // down/not-ready so a spurious promotion can never destroy a healthy primary. - oldPrimary := shardPrimaryPod(cluster, shardName) if oldPrimary != "" && decision.PromotionCandidate != nil && oldPrimary != decision.PromotionCandidate.Pod && r.podAbsentOrNotReady(ctx, cluster.Namespace, oldPrimary) { @@ -67,6 +79,29 @@ func (r *PostgresClusterReconciler) executeClusterPromotion( return nil } +func (r *PostgresClusterReconciler) fencePodPVC(ctx context.Context, namespace, podName string) error { + pvcName := "data-" + podName + var pvc corev1.PersistentVolumeClaim + if err := r.Get(ctx, client.ObjectKey{Namespace: namespace, Name: pvcName}, &pvc); err != nil { + if apierrors.IsNotFound(err) { + return nil + } + return fmt.Errorf("get pvc %q: %w", pvcName, err) + } + if pvc.Labels[fencing.FenceLabelKey] == fencing.FenceLabelValue { + return nil + } + before := pvc.DeepCopy() + if pvc.Labels == nil { + pvc.Labels = map[string]string{} + } + pvc.Labels[fencing.FenceLabelKey] = fencing.FenceLabelValue + if err := r.Patch(ctx, &pvc, client.MergeFrom(before)); err != nil { + return fmt.Errorf("fence pvc %q: %w", pvcName, err) + } + return nil +} + // shardPrimaryPod returns the recorded primary pod for the named shard (the pod // that was primary before this reconcile's promotion), or "" if none. func shardPrimaryPod(cluster *postgresv1alpha1.PostgresCluster, shardName string) string { @@ -96,6 +131,46 @@ func (r *PostgresClusterReconciler) podAbsentOrNotReady(ctx context.Context, nam return true } +func (r *PostgresClusterReconciler) promotionCandidateReadyForExec(ctx context.Context, namespace, podName string) error { + var pod corev1.Pod + if err := r.Get(ctx, client.ObjectKey{Namespace: namespace, Name: podName}, &pod); err != nil { + return fmt.Errorf("promotion candidate pod %q not ready for promotion exec: %w", podName, err) + } + if pod.DeletionTimestamp != nil { + return fmt.Errorf("promotion candidate pod %q not ready for promotion exec: deleting", podName) + } + if pod.Status.Phase != "" && pod.Status.Phase != corev1.PodRunning { + return fmt.Errorf("promotion candidate pod %q not ready for promotion exec: phase=%s", podName, pod.Status.Phase) + } + + podReadySeen := false + podReady := false + for i := range pod.Status.Conditions { + if pod.Status.Conditions[i].Type == corev1.PodReady { + podReadySeen = true + podReady = pod.Status.Conditions[i].Status == corev1.ConditionTrue + break + } + } + if !podReadySeen || !podReady { + return fmt.Errorf("promotion candidate pod %q not ready for promotion exec: PodReady=%t", podName, podReady) + } + + containerSeen := false + containerReady := false + for i := range pod.Status.ContainerStatuses { + if pod.Status.ContainerStatuses[i].Name == pgContainerName { + containerSeen = true + containerReady = pod.Status.ContainerStatuses[i].Ready + break + } + } + if !containerSeen || !containerReady { + return fmt.Errorf("promotion candidate pod %q not ready for promotion exec: container %q Ready=%t", podName, pgContainerName, containerReady) + } + return nil +} + type clusterPodPromoter struct { Namespace string Client client.Client @@ -323,10 +398,16 @@ if is_primary; then exit 0 fi -# #220: clear both standby artifacts before promoting. Leaving the rejoin marker -# in place would make this newly-promoted primary rewind itself back to its STALE -# bootstrap PRIMARY_ENDPOINT (the old primary) on any future restart — discarding -# its own post-failover writes. A primary must never carry a rejoin-as-standby marker. +PROMOTED="$("$BIN/psql" "$DSN" -v ON_ERROR_STOP=1 -Atqc "SELECT pg_promote(true, 30)")" +PROMOTED="$(printf "%s" "$PROMOTED" | tr -d '[:space:]')" +if [ "$PROMOTED" != "t" ] && ! is_primary; then + echo "pg_promote did not reach primary state within 30s (result=$PROMOTED)" >&2 + exit 1 +fi + +# #220: mutate PGDATA only after promotion succeeds. A failed exec must leave +# standby.signal and the promoted marker untouched; otherwise a restarted standby +# enters the Real elector branch and can rejoin the lease race before promotion. rm -f "$DATA/standby.signal" "$DATA/.keiailab-restart-primary-as-standby" # #220: durable marker — this PGDATA is now an operator-promoted primary. The # bootstrap init container (builders.go) reads it on restart and refuses to restore @@ -335,7 +416,6 @@ rm -f "$DATA/standby.signal" "$DATA/.keiailab-restart-primary-as-standby" # fenced (fail-closed) and reseeded, never silently demoted. Name must match # builders.go promotedPrimaryMarker. touch "$DATA/.keiailab-promoted-primary" -"$BIN/pg_ctl" promote -D "$DATA" i=0 while [ "$i" -lt 30 ]; do diff --git a/internal/controller/failover_promoter_test.go b/internal/controller/failover_promoter_test.go index b88e051..9a3c33d 100644 --- a/internal/controller/failover_promoter_test.go +++ b/internal/controller/failover_promoter_test.go @@ -9,6 +9,7 @@ package controller import ( "context" "encoding/json" + "fmt" "strings" "testing" @@ -36,12 +37,7 @@ func TestPostgresClusterPromotionExecutorExecsPodAndPatchesStatus(t *testing.T) cluster := &postgresv1alpha1.PostgresCluster{ ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: namespace}, } - pod := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: podName, - Namespace: namespace, - }, - } + pod := readyPromotionPod(namespace, podName) c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cluster, pod).Build() executor := &fakePromotionPodExecutor{} reconciler := &PostgresClusterReconciler{ @@ -70,7 +66,7 @@ func TestPostgresClusterPromotionExecutorExecsPodAndPatchesStatus(t *testing.T) t.Fatalf("target = %+v, want promotion candidate postgres container", executor.target) } command := strings.Join(executor.command, " ") - for _, want := range []string{"standby.signal", ".keiailab-restart-primary-as-standby", "pg_ctl", "promote", "pg_is_in_recovery"} { + for _, want := range []string{"standby.signal", ".keiailab-restart-primary-as-standby", "pg_promote", "promote", "pg_is_in_recovery"} { if !strings.Contains(command, want) { t.Fatalf("promotion command %q missing %q", command, want) } @@ -96,22 +92,53 @@ func TestPostgresClusterPromotionExecutorExecsPodAndPatchesStatus(t *testing.T) } } +func TestPostgresPromotionCommandMutatesPGDATAOnlyAfterSQLPromote(t *testing.T) { + t.Parallel() + + command := strings.Join(postgresPromotionCommand(), "\n") + promoteIdx := strings.Index(command, "pg_promote(true, 30)") + if promoteIdx < 0 { + t.Fatalf("promotion command must use PostgreSQL SQL promotion API: %s", command) + } + if strings.Contains(command, "pg_ctl") { + t.Fatalf("promotion command must not use pg_ctl promote in the operator exec path: %s", command) + } + for _, mutation := range []string{ + `rm -f "$DATA/standby.signal"`, + `touch "$DATA/.keiailab-promoted-primary"`, + } { + mutationIdx := strings.Index(command, mutation) + if mutationIdx < 0 { + t.Fatalf("promotion command missing PGDATA mutation %q: %s", mutation, command) + } + if mutationIdx < promoteIdx { + t.Fatalf("promotion command mutates PGDATA before promote succeeds: %q", mutation) + } + } +} + type fakePromotionPodExecutor struct { called int target BackupSidecarTarget command []string out []byte err error + onExec func(context.Context) error } func (f *fakePromotionPodExecutor) Exec( - _ context.Context, + ctx context.Context, target BackupSidecarTarget, command []string, ) ([]byte, error) { f.called++ f.target = target f.command = append([]string{}, command...) + if f.onExec != nil { + if err := f.onExec(ctx); err != nil { + return nil, err + } + } out := f.out if out == nil { // Default to a real promotion so promotion/fence tests exercise the @@ -121,6 +148,152 @@ func (f *fakePromotionPodExecutor) Exec( return out, f.err } +func readyPromotionPod(namespace, podName string) *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: podName, Namespace: namespace}, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + Conditions: []corev1.PodCondition{{ + Type: corev1.PodReady, + Status: corev1.ConditionTrue, + }}, + ContainerStatuses: []corev1.ContainerStatus{{ + Name: pgContainerName, + Ready: true, + }}, + }, + } +} + +// TestPostgresClusterPromotionPreFencesFailedOldPrimaryBeforeExec pins the live +// failover race: StatefulSet can recreate the old primary ordinal before the +// standby promotion settles. The failed old primary's PVC must be fenced before +// the promotion exec is attempted, so a self-healed old-primary Pod fails closed +// at VerifyNotFenced instead of re-acquiring primary identity. +func TestPostgresClusterPromotionPreFencesFailedOldPrimaryBeforeExec(t *testing.T) { + t.Parallel() + + const ( + namespace = "default" + oldPrimaryPod = "demo-shard-0-0" + targetPod = "demo-shard-0-1" + oldPrimaryPVC = "data-demo-shard-0-0" + ) + + scheme := newScheme(t) + ctx := context.Background() + cluster := &postgresv1alpha1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: namespace}, + Status: postgresv1alpha1.PostgresClusterStatus{ + Shards: []postgresv1alpha1.ShardStatus{{ + Name: "shard-0", + Primary: &postgresv1alpha1.ShardEndpoint{ + Pod: oldPrimaryPod, + Endpoint: "demo-shard-0-0.demo-shard-0.default.svc.cluster.local:5432", + Ready: false, + }, + }}, + }, + } + target := readyPromotionPod(namespace, targetPod) + oldPVC := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: oldPrimaryPVC, Namespace: namespace}, + } + targetPVC := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "data-" + targetPod, Namespace: namespace}, + } + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cluster, target, oldPVC, targetPVC).Build() + executor := &fakePromotionPodExecutor{ + onExec: func(ctx context.Context) error { + var got corev1.PersistentVolumeClaim + if err := c.Get(ctx, types.NamespacedName{Namespace: namespace, Name: oldPrimaryPVC}, &got); err != nil { + return fmt.Errorf("get old primary pvc before promotion exec: %w", err) + } + if got.Labels[fencing.FenceLabelKey] != fencing.FenceLabelValue { + return fmt.Errorf("old primary PVC must be fenced before promotion exec, labels=%v", got.Labels) + } + return nil + }, + } + reconciler := &PostgresClusterReconciler{ + Client: c, + Scheme: scheme, + PromotionPodExecutor: executor, + } + decision := failover.Decision{ + Failed: true, + Reason: failover.ReasonPrimaryNotReady, + PromotionCandidate: &postgresv1alpha1.ShardEndpoint{ + Pod: targetPod, + Endpoint: "demo-shard-0-1.demo-shard-0.default.svc.cluster.local:5432", + Ready: true, + }, + } + + if err := reconciler.executeClusterPromotion(ctx, cluster, "shard-0", decision); err != nil { + t.Fatalf("executeClusterPromotion: %v", err) + } +} + +func TestPostgresClusterPromotionSkipsExecWhenCandidatePodNotReady(t *testing.T) { + t.Parallel() + + const ( + namespace = "default" + targetPod = "demo-shard-0-1" + ) + + scheme := newScheme(t) + ctx := context.Background() + cluster := &postgresv1alpha1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: namespace}, + } + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: targetPod, Namespace: namespace}, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + Conditions: []corev1.PodCondition{{ + Type: corev1.PodReady, + Status: corev1.ConditionFalse, + }}, + ContainerStatuses: []corev1.ContainerStatus{{ + Name: pgContainerName, + Ready: false, + }}, + }, + } + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "data-" + targetPod, Namespace: namespace}, + } + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cluster, pod, pvc).Build() + executor := &fakePromotionPodExecutor{} + reconciler := &PostgresClusterReconciler{ + Client: c, + Scheme: scheme, + PromotionPodExecutor: executor, + } + decision := failover.Decision{ + Failed: true, + Reason: failover.ReasonPrimaryNotReady, + PromotionCandidate: &postgresv1alpha1.ShardEndpoint{ + Pod: targetPod, + Endpoint: "demo-shard-0-1.demo-shard-0.default.svc.cluster.local:5432", + Ready: true, + }, + } + + err := reconciler.executeClusterPromotion(ctx, cluster, "shard-0", decision) + if err == nil { + t.Fatal("executeClusterPromotion must reject a Kubernetes-not-ready promotion candidate") + } + if executor.called != 0 { + t.Fatalf("Exec called %d times, want 0 for a Kubernetes-not-ready candidate", executor.called) + } + if !strings.Contains(err.Error(), "not ready for promotion exec") { + t.Fatalf("error = %q, want not-ready-for-exec reason", err.Error()) + } +} + // TestPostgresClusterPromotionUnfencesTargetPVC pins the fix for the // all-members-fenced recovery deadlock (#200): the operator must unfence the // chosen promotion target's PVC so its crash-looping container can recover. @@ -138,9 +311,7 @@ func TestPostgresClusterPromotionUnfencesTargetPVC(t *testing.T) { cluster := &postgresv1alpha1.PostgresCluster{ ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: namespace}, } - pod := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{Name: podName, Namespace: namespace}, - } + pod := readyPromotionPod(namespace, podName) pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{ Name: pvcName, @@ -197,9 +368,7 @@ func TestPostgresClusterPromotionFencesNonTargetMembers(t *testing.T) { cluster := &postgresv1alpha1.PostgresCluster{ ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: namespace}, } - pod := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{Name: targetPod, Namespace: namespace}, - } + pod := readyPromotionPod(namespace, targetPod) mkPVC := func(name string) *corev1.PersistentVolumeClaim { return &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, @@ -273,7 +442,7 @@ func TestPostgresClusterPromotionNoopDoesNotFence(t *testing.T) { cluster := &postgresv1alpha1.PostgresCluster{ ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: namespace}, } - pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: targetPod, Namespace: namespace}} + pod := readyPromotionPod(namespace, targetPod) mkPVC := func(name string) *corev1.PersistentVolumeClaim { return &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}} } diff --git a/internal/controller/postgrescluster_controller.go b/internal/controller/postgrescluster_controller.go index adc9c64..907749b 100644 --- a/internal/controller/postgrescluster_controller.go +++ b/internal/controller/postgrescluster_controller.go @@ -71,6 +71,11 @@ const ( // 같은 annotation 이다. cnpg.io/hibernation=on 이면 database Pod 를 0개로 // 줄이고 PVC 소유권은 재수화를 위해 보존한다. AnnotationHibernation = "cnpg.io/hibernation" + + // AnnotationRestoreInProgress 는 BackupJob restore 가 데이터 PVC 를 독점 + // 마운트하는 동안 PostgresCluster reconciler 가 shard StatefulSet 을 다시 + // 기동하지 않도록 하는 컨트롤러 간 락이다. 값은 소유 BackupJob 이름이다. + AnnotationRestoreInProgress = "postgres.keiailab.io/restore-in-progress" ) // PostgresClusterReconciler 는 PostgresCluster CR 을 reconcile 한다. @@ -181,6 +186,7 @@ func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Requ return ctrl.Result{}, nil } replicaBootstrap, err := replicaBootstrapConfigForCluster(&cluster) + standaloneReplica := replicaBootstrap != nil if err != nil { setCondition(&cluster.Status.Conditions, ConditionReady, metav1.ConditionFalse, ReasonReplicaClusterRejected, err.Error(), cluster.Generation) setCondition(&cluster.Status.Conditions, ConditionProgressing, metav1.ConditionFalse, ReasonReplicaClusterRejected, @@ -210,8 +216,10 @@ func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Requ shardCount := cluster.Spec.Shards.InitialCount members := int32(1) + cluster.Spec.Shards.Replicas hibernating := hibernationRequested(&cluster) + restoreActive := restoreInProgress(&cluster) + databasePodsStopped := hibernating || restoreActive desiredMembers := members - if hibernating { + if databasePodsStopped { desiredMembers = 0 } shardStatuses := make([]postgresv1alpha1.ShardStatus, 0, shardCount) @@ -230,18 +238,10 @@ func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Requ if err := r.upsert(ctx, &cluster, buildHeadlessService(&cluster, svcName, "shard", ord)); err != nil { return r.handleUpsertErr(ctx, &cluster, err, "shard Service", logger) } - // primaryEndpoint 결정: 이전 reconcile 에서 관측된 primary 가 존재하면 - // 그 endpoint 를 init container 로 전달 → ord!=0 의 첫 부팅 시 pg_basebackup - // path 를 활성화한다. 없으면 빈 값 — bootstrap script 가 ord==0 또는 endpoint - // 부재일 때 자동으로 initdb path 로 fallback. - primaryEndpoint := "" - if replicaBootstrap != nil { - primaryEndpoint = replicaBootstrap.Endpoint - } else if !hibernating && int(ord) < len(cluster.Status.Shards) { - if p := cluster.Status.Shards[ord].Primary; p != nil { - primaryEndpoint = p.Endpoint - } - } + // primaryEndpoint 결정: 관측된 primary 를 우선하되, HA 초기 bootstrap 에서는 + // deterministic ord-0 DNS 를 즉시 넣어 후속 reconcile 의 template drift 로 + // primary Pod 가 rolling restart 되는 false failover window 를 만들지 않는다. + primaryEndpoint := primaryEndpointForShard(&cluster, ord, svcName, replicaBootstrap, databasePodsStopped) desiredSTS := buildPGStatefulSet( &cluster, stsName, svcName, ord, @@ -257,7 +257,7 @@ func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Requ } // shard PDB (PR #31): members>=2 시 자동 생성. - if !hibernating && shouldAutoCreatePDB(members) { + if !databasePodsStopped && shouldAutoCreatePDB(members) { pdb := BuildShardPDB(&cluster, ord, members) if err := r.upsert(ctx, &cluster, pdb); err != nil { return r.handleUpsertErr(ctx, &cluster, err, "shard PDB", logger) @@ -281,7 +281,7 @@ func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Requ if !primaryReady { allShardPrimaryReady = false } - if hibernating { + if databasePodsStopped { shardStatuses = append(shardStatuses, postgresv1alpha1.ShardStatus{ Name: fmt.Sprintf("shard-%d", ord), Ordinal: ord, @@ -292,35 +292,37 @@ func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Requ // 결과가 비면 STS readyReplicas 기반 fallback (annotation 부재 시). shardStat := aggregateShardStatus(ctx, r.Client, &cluster, ord, svcName) if shardStat.Primary == nil || shardStat.Primary.Pod == "" { - // fallback — STS-time 근사값 (annotation 미수집 / Pod 부팅 전 일시). - // Ready 는 fallbackPrimaryReady 로 산출한다: STS readyReplicas proxy - // 는 HA shard 에서 standby readiness 까지 합산하므로, primary 가 죽고 - // standby 만 Ready 인 상황을 Ready=true 로 마스킹해 DetectPrimaryFailure - // 를 ReasonNone 으로 만들어 자동 failover 를 영영 막았다 (live RCA - // 2026-06-04 pg-ha-drill cordon chaos). Ready replica 가 관측되면 - // primary 부재 = outage 로 보고 Ready=false 를 강제한다. - // #220: preserve the LAST-KNOWN primary (previous reconcile) rather than - // hardcoding ordinal-0. After a failover the primary is ord-1; hardcoding - // ord-0 makes the StatefulSet PRIMARY_ENDPOINT flicker back to ord-0 during - // a transient status lapse, so a reseeded former primary boots with env=self - // and initdb's itself into a rogue primary (oscillation). On first bootstrap - // no previous primary exists → falls through to ord-0 (#218/#219 unchanged). - fbPod := fmt.Sprintf("%s-0", stsName) - fbEndpoint := fmt.Sprintf("%s-0.%s.%s.svc.cluster.local:%d", stsName, svcName, cluster.Namespace, pgPort) - for k := range cluster.Status.Shards { - prev := &cluster.Status.Shards[k] - if prev.Ordinal == ord && prev.Primary != nil && prev.Primary.Pod != "" { - fbPod = prev.Primary.Pod - if prev.Primary.Endpoint != "" { - fbEndpoint = prev.Primary.Endpoint + if !standaloneReplica { + // fallback — STS-time 근사값 (annotation 미수집 / Pod 부팅 전 일시). + // Ready 는 fallbackPrimaryReady 로 산출한다: STS readyReplicas proxy + // 는 HA shard 에서 standby readiness 까지 합산하므로, primary 가 죽고 + // standby 만 Ready 인 상황을 Ready=true 로 마스킹해 DetectPrimaryFailure + // 를 ReasonNone 으로 만들어 자동 failover 를 영영 막았다 (live RCA + // 2026-06-04 pg-ha-drill cordon chaos). Ready replica 가 관측되면 + // primary 부재 = outage 로 보고 Ready=false 를 강제한다. + // #220: preserve the LAST-KNOWN primary (previous reconcile) rather than + // hardcoding ordinal-0. After a failover the primary is ord-1; hardcoding + // ord-0 makes the StatefulSet PRIMARY_ENDPOINT flicker back to ord-0 during + // a transient status lapse, so a reseeded former primary boots with env=self + // and initdb's itself into a rogue primary (oscillation). On first bootstrap + // no previous primary exists → falls through to ord-0 (#218/#219 unchanged). + fbPod := fmt.Sprintf("%s-0", stsName) + fbEndpoint := fmt.Sprintf("%s-0.%s.%s.svc.cluster.local:%d", stsName, svcName, cluster.Namespace, pgPort) + for k := range cluster.Status.Shards { + prev := &cluster.Status.Shards[k] + if prev.Ordinal == ord && prev.Primary != nil && prev.Primary.Pod != "" { + fbPod = prev.Primary.Pod + if prev.Primary.Endpoint != "" { + fbEndpoint = prev.Primary.Endpoint + } + break } - break } - } - shardStat.Primary = &postgresv1alpha1.ShardEndpoint{ - Pod: fbPod, - Endpoint: fbEndpoint, - Ready: fallbackPrimaryReady(primaryReady, shardStat.Replicas), + shardStat.Primary = &postgresv1alpha1.ShardEndpoint{ + Pod: fbPod, + Endpoint: fbEndpoint, + Ready: fallbackPrimaryReady(primaryReady, shardStat.Replicas), + } } } shardStatuses = append(shardStatuses, shardStat) @@ -344,7 +346,7 @@ func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Requ } // router 이미지: P12-T2 까지 PG 베이스 이미지 placeholder. routerReplicas := cluster.Spec.Router.Replicas - if hibernating { + if databasePodsStopped { routerReplicas = 0 } desiredDep := buildRouterDeployment( @@ -396,14 +398,14 @@ func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Requ cluster.Status.ManagedRolesStatus = managedRolesStatus cluster.Status.ObservedGeneration = cluster.Generation // Switchover: annotation-triggered planned primary change (Sprint S5). - if !hibernating && allShardPrimaryReady { + if !standaloneReplica && !databasePodsStopped && allShardPrimaryReady { if err := r.handleSwitchover(ctx, &cluster, shardStatuses); err != nil { logger.Error(err, "Switchover failed") commonsevents.EmitWarning(r.Recorder, &cluster, "SwitchoverFailed", err) } } - failoverShardName, failoverDecision := clusterFailoverDecision(shardStatuses) + failoverShardName, failoverDecision := clusterFailoverDecision(&cluster, shardStatuses) // 가짜 promotion 차단 (#220 라이브 드릴 RCA): 실패가 debounce window 동안 지속될 // 때만 promote. 일시적 status flicker 는 fenceNonTargetMembers 를 통해 건강한 멤버를 // fence 할 수 있으므로 instantaneous 트리거 금지. @@ -433,7 +435,7 @@ func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Requ // #205: re-seed any standby that failed to rejoin (not-ready too long with a // ready primary, e.g. stuck in startup recovery after a primary restart). // Best-effort — log and continue. - if !hibernating { + if !standaloneReplica && !databasePodsStopped { if err := r.reconcileStaleReplicas(ctx, &cluster, shardStatuses, time.Now()); err != nil { logger.Error(err, "stale standby re-seed failed (best-effort)") } @@ -443,12 +445,12 @@ func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Requ logger.Error(err, "rogue primary re-seed failed (best-effort)") } } - applyClusterConditions(&cluster, shardCount, allShardPrimaryReady, routerActive, routerStatus, hibernating, + applyClusterConditions(&cluster, shardCount, allShardPrimaryReady, routerActive, routerStatus, hibernating, standaloneReplica, prevPhase == postgresv1alpha1.ClusterPhaseReady, failoverDecision) // Config hot-reload: if cluster is Ready and primary Pods are running with // a stale configHash, signal PostgreSQL to reload without restarting. - if !hibernating && allShardPrimaryReady { + if !databasePodsStopped && allShardPrimaryReady { for _, ss := range shardStatuses { if ss.Primary != nil && ss.Primary.Ready && ss.Primary.Pod != "" { if err := r.reloadPostgresConfig(ctx, cluster.Namespace, ss.Primary.Pod); err != nil { @@ -484,6 +486,13 @@ func hibernationRequested(cluster *postgresv1alpha1.PostgresCluster) bool { return strings.EqualFold(strings.TrimSpace(cluster.Annotations[AnnotationHibernation]), "on") } +func restoreInProgress(cluster *postgresv1alpha1.PostgresCluster) bool { + if cluster == nil || cluster.Annotations == nil { + return false + } + return strings.TrimSpace(cluster.Annotations[AnnotationRestoreInProgress]) != "" +} + // applyClusterConditions 는 reconcile 산출물 (shard 준비 상태, router 활성/준비 // 상태) 를 RFC 0001 §3.4 Condition 카탈로그 + ClusterPhase 로 변환하여 cluster // 객체에 직접 기록한다. @@ -493,6 +502,7 @@ func applyClusterConditions( allShardPrimaryReady, routerActive bool, routerStatus *postgresv1alpha1.ClusterRouterStatus, hibernating bool, + standaloneReplica bool, wasReady bool, failoverDecision failover.Decision, ) { @@ -522,8 +532,12 @@ func applyClusterConditions( "Cluster is not hibernated", cluster.Generation) if allShardPrimaryReady && shardCount > 0 { + message := fmt.Sprintf("%d/%d shard primary ready", shardCount, shardCount) + if standaloneReplica { + message = fmt.Sprintf("%d/%d replica shard ready", shardCount, shardCount) + } setCondition(conds, ConditionShardsReady, metav1.ConditionTrue, ReasonAvailable, - fmt.Sprintf("%d/%d shard primary ready", shardCount, shardCount), cluster.Generation) + message, cluster.Generation) } else { setCondition(conds, ConditionShardsReady, metav1.ConditionFalse, ReasonProgressing, "waiting for shard primary readiness", cluster.Generation) @@ -552,8 +566,12 @@ func applyClusterConditions( } setCondition(conds, ConditionFailoverReady, metav1.ConditionFalse, string(failoverDecision.Reason), message, cluster.Generation) } else { + message := "no failover action required" + if standaloneReplica { + message = "local failover disabled for standalone replica cluster" + } setCondition(conds, ConditionFailoverReady, metav1.ConditionTrue, ReasonAvailable, - "no failover action required", cluster.Generation) + message, cluster.Generation) } clusterReady := allShardPrimaryReady && shardCount > 0 && routerReady @@ -606,7 +624,13 @@ func (r *PostgresClusterReconciler) shouldPromoteAfterDebounce(key string, faile return now.Sub(first) >= failoverDebounceThreshold } -func clusterFailoverDecision(shards []postgresv1alpha1.ShardStatus) (string, failover.Decision) { +func clusterFailoverDecision( + cluster *postgresv1alpha1.PostgresCluster, + shards []postgresv1alpha1.ShardStatus, +) (string, failover.Decision) { + if standaloneReplicaEnabled(cluster) { + return "", failover.Decision{Reason: failover.ReasonNone} + } for _, shard := range shards { decision := failover.DetectPrimaryFailure(shard) if decision.Failed { diff --git a/internal/controller/postgrescluster_controller_test.go b/internal/controller/postgrescluster_controller_test.go index b1f0679..114afe0 100644 --- a/internal/controller/postgrescluster_controller_test.go +++ b/internal/controller/postgrescluster_controller_test.go @@ -59,6 +59,40 @@ var _ = Describe("PostgresClusterReconciler — RFC 0001 spec", func() { }) Context("when shardingMode=none with single shard and no router", func() { + It("sets deterministic ordinal-zero PRIMARY_ENDPOINT on initial HA bootstrap", func() { + cluster := &postgresv1alpha1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "ha-bootstrap", Namespace: namespace}, + Spec: postgresv1alpha1.PostgresClusterSpec{ + PostgresVersion: "18", + ShardingMode: postgresv1alpha1.ShardingModeNone, + Shards: postgresv1alpha1.ShardsSpec{ + InitialCount: 1, + Replicas: 1, + Storage: postgresv1alpha1.StorageSpec{ + Size: resource.MustParse("1Gi"), + }, + }, + }, + } + Expect(k8sClient.Create(ctx, cluster)).To(Succeed()) + + stsName := ShardStatefulSetName("ha-bootstrap", 0) + svcName := ShardServiceName("ha-bootstrap", 0) + wantEndpoint := fmt.Sprintf("%s-0.%s.%s.svc.cluster.local:5432", stsName, svcName, namespace) + + Eventually(func(g Gomega) { + var sts appsv1.StatefulSet + g.Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: stsName}, &sts)).To(Succeed()) + g.Expect(*sts.Spec.Replicas).To(Equal(int32(2)), "primary 1 + async 1") + + initEnv := envMap(sts.Spec.Template.Spec.InitContainers[0].Env) + g.Expect(initEnv["PRIMARY_ENDPOINT"].Value).To(Equal(wantEndpoint)) + + mainEnv := envMap(sts.Spec.Template.Spec.Containers[0].Env) + g.Expect(mainEnv["PRIMARY_ENDPOINT"].Value).To(Equal(wantEndpoint)) + }, envtestTimeout, envtestInterval).Should(Succeed()) + }) + It("creates exactly one shard's resources and reaches Ready after STS readiness", func() { cluster := &postgresv1alpha1.PostgresCluster{ ObjectMeta: metav1.ObjectMeta{Name: "single", Namespace: namespace}, @@ -245,6 +279,47 @@ var _ = Describe("PostgresClusterReconciler — RFC 0001 spec", func() { }) }) + Context("when offline restore is in progress", func() { + It("keeps shard StatefulSets scaled to zero without reporting user hibernation", func() { + cluster := &postgresv1alpha1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "restore-freeze", + Namespace: namespace, + Annotations: map[string]string{ + "postgres.keiailab.io/restore-in-progress": "restore-bj", + }, + }, + Spec: postgresv1alpha1.PostgresClusterSpec{ + PostgresVersion: "18", + ShardingMode: postgresv1alpha1.ShardingModeNone, + Shards: postgresv1alpha1.ShardsSpec{ + InitialCount: 1, + Replicas: 1, + Storage: postgresv1alpha1.StorageSpec{ + Size: resource.MustParse("1Gi"), + }, + }, + }, + } + Expect(k8sClient.Create(ctx, cluster)).To(Succeed()) + + stsName := ShardStatefulSetName("restore-freeze", 0) + Eventually(func(g Gomega) { + var sts appsv1.StatefulSet + g.Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: stsName}, &sts)).To(Succeed()) + g.Expect(sts.Spec.Replicas).NotTo(BeNil()) + g.Expect(*sts.Spec.Replicas).To(Equal(int32(0))) + + var got postgresv1alpha1.PostgresCluster + g.Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: "restore-freeze"}, &got)).To(Succeed()) + hibernation := meta.FindStatusCondition(got.Status.Conditions, ConditionHibernation) + g.Expect(hibernation).NotTo(BeNil()) + g.Expect(hibernation.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(hibernation.Reason).To(Equal(ReasonNotHibernated)) + }, envtestTimeout, envtestInterval).Should(Succeed()) + }) + }) + Context("when imageCatalogRef selects a runtime image", func() { It("uses the catalog image and rolls the StatefulSet when the catalog entry changes", func() { catalog := &postgresv1alpha1.ImageCatalog{ diff --git a/internal/controller/primary_endpoint.go b/internal/controller/primary_endpoint.go new file mode 100644 index 0000000..8f3cc36 --- /dev/null +++ b/internal/controller/primary_endpoint.go @@ -0,0 +1,47 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package controller + +import ( + "fmt" + + postgresv1alpha1 "github.com/keiailab/postgres-operator/api/v1alpha1" +) + +func primaryEndpointForShard( + cluster *postgresv1alpha1.PostgresCluster, + shardOrdinal int32, + svcName string, + replicaBootstrap *replicaBootstrapConfig, + hibernating bool, +) string { + if replicaBootstrap != nil { + return replicaBootstrap.Endpoint + } + if cluster == nil || hibernating { + return "" + } + + for i := range cluster.Status.Shards { + shard := &cluster.Status.Shards[i] + if shard.Ordinal != shardOrdinal || shard.Primary == nil { + continue + } + if shard.Primary.Endpoint != "" { + return shard.Primary.Endpoint + } + if shard.Primary.Pod != "" { + return fmt.Sprintf("%s.%s.%s.svc.cluster.local:%d", shard.Primary.Pod, svcName, cluster.Namespace, pgPort) + } + } + + if cluster.Spec.Shards.Replicas > 0 { + stsName := ShardStatefulSetName(cluster.Name, shardOrdinal) + return fmt.Sprintf("%s-0.%s.%s.svc.cluster.local:%d", stsName, svcName, cluster.Namespace, pgPort) + } + return "" +} diff --git a/internal/controller/primary_endpoint_test.go b/internal/controller/primary_endpoint_test.go new file mode 100644 index 0000000..119fa8c --- /dev/null +++ b/internal/controller/primary_endpoint_test.go @@ -0,0 +1,75 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package controller + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + postgresv1alpha1 "github.com/keiailab/postgres-operator/api/v1alpha1" +) + +func TestPrimaryEndpointForShard_InitialHAUsesOrdinalZeroDNS(t *testing.T) { + t.Parallel() + + cluster := &postgresv1alpha1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "failover", Namespace: "pg-failover-e2e"}, + Spec: postgresv1alpha1.PostgresClusterSpec{ + Shards: postgresv1alpha1.ShardsSpec{Replicas: 1}, + }, + } + + got := primaryEndpointForShard(cluster, 0, "failover-shard-0-headless", nil, false) + want := "failover-shard-0-0.failover-shard-0-headless.pg-failover-e2e.svc.cluster.local:5432" + if got != want { + t.Fatalf("primary endpoint = %q, want %q", got, want) + } +} + +func TestPrimaryEndpointForShard_PreservesObservedPromotedPrimary(t *testing.T) { + t.Parallel() + + cluster := &postgresv1alpha1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "failover", Namespace: "pg-failover-e2e"}, + Spec: postgresv1alpha1.PostgresClusterSpec{ + Shards: postgresv1alpha1.ShardsSpec{Replicas: 1}, + }, + Status: postgresv1alpha1.PostgresClusterStatus{ + Shards: []postgresv1alpha1.ShardStatus{{ + Ordinal: 0, + Primary: &postgresv1alpha1.ShardEndpoint{ + Pod: "failover-shard-0-1", + Endpoint: "failover-shard-0-1.failover-shard-0-headless.pg-failover-e2e.svc.cluster.local:5432", + Ready: true, + }, + }}, + }, + } + + got := primaryEndpointForShard(cluster, 0, "failover-shard-0-headless", nil, false) + want := "failover-shard-0-1.failover-shard-0-headless.pg-failover-e2e.svc.cluster.local:5432" + if got != want { + t.Fatalf("primary endpoint = %q, want observed promoted endpoint %q", got, want) + } +} + +func TestPrimaryEndpointForShard_StandaloneReplicaUsesExternalEndpoint(t *testing.T) { + t.Parallel() + + cluster := &postgresv1alpha1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "replica", Namespace: "pg-external-e2e"}, + } + replicaBootstrap := &replicaBootstrapConfig{ + Endpoint: "source-rw.data.svc:5432", + } + + got := primaryEndpointForShard(cluster, 0, "replica-shard-0-headless", replicaBootstrap, false) + if got != replicaBootstrap.Endpoint { + t.Fatalf("primary endpoint = %q, want external endpoint %q", got, replicaBootstrap.Endpoint) + } +} diff --git a/internal/controller/replica_cluster_failover_test.go b/internal/controller/replica_cluster_failover_test.go new file mode 100644 index 0000000..3f36af7 --- /dev/null +++ b/internal/controller/replica_cluster_failover_test.go @@ -0,0 +1,43 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package controller + +import ( + "testing" + + postgresv1alpha1 "github.com/keiailab/postgres-operator/api/v1alpha1" + "github.com/keiailab/postgres-operator/internal/controller/failover" +) + +func TestClusterFailoverDecisionSkipsStandaloneReplicaCluster(t *testing.T) { + t.Parallel() + + cluster := &postgresv1alpha1.PostgresCluster{ + Spec: postgresv1alpha1.PostgresClusterSpec{ + Replica: &postgresv1alpha1.ReplicaClusterSpec{ + Enabled: true, + Source: "source", + }, + }, + } + shards := []postgresv1alpha1.ShardStatus{{ + Name: "shard-0", + Replicas: []postgresv1alpha1.ShardEndpoint{{ + Pod: "replica-shard-0-0", + Endpoint: "replica-shard-0-0.replica-shard-0-headless.default.svc.cluster.local:5432", + Ready: true, + }}, + }} + + shardName, decision := clusterFailoverDecision(cluster, shards) + if shardName != "" { + t.Fatalf("shardName = %q, want empty for standalone replica cluster", shardName) + } + if decision.Failed || decision.Reason != failover.ReasonNone || decision.PromotionCandidate != nil { + t.Fatalf("decision = %+v, want no failover for standalone replica cluster", decision) + } +} diff --git a/test/e2e/failover_chaos_test.go b/test/e2e/failover_chaos_test.go index cdf6047..4b0a099 100644 --- a/test/e2e/failover_chaos_test.go +++ b/test/e2e/failover_chaos_test.go @@ -87,18 +87,11 @@ spec: _, _ = utils.Run(exec.Command("kubectl", "delete", "ns", chaosNamespace, "--wait=false")) }) - // PENDING (GA #248, ADR-0027 ground-up): reliable automatic standby promotion - // on primary loss is not yet implemented. Live RCA (2026-06-16): even with a - // ready standby present (the BeforeAll gate above), status.shards[0].primary is - // not updated to a promoted standby within 60s. The StatefulSet recreates the - // force-deleted primary on the same PVC within ~30s and preempts the failover - // detect/debounce window, so a transient pod-delete is "self-healed" rather than - // failed-over. True failover (node loss / PVC loss) needs the ground-up - // shard-identity redesign tracked by ADR-0027. Marked Pending (not a false pass, - // not a silent skip) so the p1 gate stays honest: implemented features green, - // this unimplemented behavior visibly Pending. Flip PContext→Context when the - // promotion path lands. - PContext("Primary kill chaos → 자동 failover (PENDING: auto-failover promotion unimplemented — GA #248 / ADR-0027)", func() { + // 2026-06-24: operator-driven promotion path now pre-fences the failed old + // primary PVC, filters fenced/NotReady candidates, and mutates PGDATA only + // after SQL pg_promote succeeds. Keep this live chaos drill active so the p1 + // gate catches regressions where StatefulSet self-heal preempts promotion. + Context("Primary kill chaos → 자동 failover", func() { var oldPrimary string It("초기 primary 식별", func() { From df4aa52c6e161d7b83086c4b2b5c21eaa6ef5e58 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Thu, 25 Jun 2026 16:51:20 +0900 Subject: [PATCH 003/109] =?UTF-8?q?feat(backup):=20#209=20PITR=20restore?= =?UTF-8?q?=20=EC=98=A4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EC=98=A4=EC=BC=80?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=99=84?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - plugin: --archive-check=n 제거(복구 불가 백업을 실패로 노출), restore --delta, restore_command 를 repo env + --pg1-path 포함 형태로 재작성, targetTime microsecond 정밀도 보존. - backupjob_controller: reconcileSidecarRestore — STS scale-0 → Pod 정지 대기 → data PVC 마운트 restore Job → 완료 시 restore-in-progress 락 해제. - builders: pgBackRest filesystem repo 를 data PVC 내부로 이동, spool EmptyDir 추가, archive-push "%p" 로 shell positional arg 문제 제거. ※ builders.go 에는 failover(#220) 의 PVC cluster label hunk 가 함께 포함된다. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01H4fFn82hrtvuCfuZnBMaqZ --- .../2026-06-24-pitr-restore-orchestration.md | 139 +++++++ internal/controller/backupjob_controller.go | 281 +++++++++++++- .../controller/backupjob_controller_test.go | 355 +++++++++++++++++- internal/controller/builders.go | 33 +- internal/controller/builders_test.go | 93 ++++- internal/plugin/backup/pgbackrest/plugin.go | 31 +- .../plugin/backup/pgbackrest/plugin_test.go | 28 +- test/e2e/pitr_restore_e2e_test.go | 79 ++-- 8 files changed, 972 insertions(+), 67 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-24-pitr-restore-orchestration.md diff --git a/docs/superpowers/plans/2026-06-24-pitr-restore-orchestration.md b/docs/superpowers/plans/2026-06-24-pitr-restore-orchestration.md new file mode 100644 index 0000000..178a037 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-pitr-restore-orchestration.md @@ -0,0 +1,139 @@ +# PITR Restore Orchestration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `BackupJob.spec.type=restore` perform a real pgBackRest PITR restore instead of execing restore against a running primary. + +**Architecture:** Filesystem pgBackRest repositories must survive Pod restarts, so the shard StatefulSet mounts the repo path from the data PVC instead of `EmptyDir`. A restore BackupJob stops the shard Pod by scaling the shard StatefulSet to zero, runs a Kubernetes restore Job that mounts the same PVC, then scales the StatefulSet back up and marks the BackupJob succeeded only after the restore Job completes. + +**Tech Stack:** Go, controller-runtime fake client/envtest patterns, Kubernetes StatefulSet/Job/PVC, pgBackRest command plugin, Ginkgo E2E. + +--- + +### Task 1: Persistent Filesystem pgBackRest Repo + +**Files:** +- Modify: `internal/controller/builders.go` +- Test: `internal/controller/builders_test.go` + +- [ ] **Step 1: Write the failing test** + +Add a test that builds a shard StatefulSet and asserts the postgres container mounts the `data` PVC at `/var/lib/pgbackrest` with `SubPath: "pgbackrest"`, and that no `pgbackrest-repo` `EmptyDir` remains. + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +docker run --rm -v "${PWD}:/workspace" -v go-mod-cache:/go/pkg/mod -v go-build-cache:/root/.cache/go-build -w /workspace golang:1.26 bash -lc 'export PATH=/usr/local/go/bin:$PATH; go test -count=1 ./internal/controller -run TestBuildPGStatefulSet_PgBackRestRepoUsesDataPVC -v' +``` + +Expected: FAIL because the repo is currently mounted from `pgbackrest-repo` `EmptyDir`. + +- [ ] **Step 3: Implement minimal production change** + +Remove the pgBackRest repo from `dataplaneEphemeralVolumeMounts()` / `dataplaneEphemeralVolumes()` and add a StatefulSet-only mount: + +```go +{Name: "data", MountPath: backupRepoMountPath, SubPath: "pgbackrest"} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run the same `go test` command. Expected: PASS. + +### Task 2: Restore Runner Job Builder + +**Files:** +- Modify: `internal/controller/backupjob_controller.go` +- Test: `internal/controller/backupjob_controller_test.go` + +- [ ] **Step 1: Write the failing test** + +Add a test for a running sidecar restore BackupJob where the shard StatefulSet already has replicas `0`. The reconciler should create an owned restore Job that mounts PVC `data--shard-0-0` at both `/var/lib/postgresql/data` and `/var/lib/pgbackrest`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +docker run --rm -v "${PWD}:/workspace" -v go-mod-cache:/go/pkg/mod -v go-build-cache:/root/.cache/go-build -w /workspace golang:1.26 bash -lc 'export PATH=/usr/local/go/bin:$PATH; go test -count=1 ./internal/controller -run TestBackupJobReconcile_RunningSidecarRestoreCreatesRestoreJob -v' +``` + +Expected: FAIL because current `reconcileSidecar` execs into the primary Pod and does not create a Job. + +- [ ] **Step 3: Implement minimal production change** + +Add a restore-specific branch before the existing sidecar backup exec path. The branch should create a `batch/v1.Job` named with `backupRunnerJobName(bj.Name)`, use the source StatefulSet postgres image, mount the data PVC, and run `BackupCommandPlugin.RestoreCommand`. + +- [ ] **Step 4: Run test to verify it passes** + +Run the same `go test` command. Expected: PASS. + +### Task 3: Restore StatefulSet Stop/Start State Machine + +**Files:** +- Modify: `internal/controller/backupjob_controller.go` +- Test: `internal/controller/backupjob_controller_test.go` + +- [ ] **Step 1: Write failing tests** + +Add tests for three observable states: + +1. StatefulSet replicas `1` -> reconciler scales to `0`, condition reason `RestoreClusterStopping`, no Job yet. +2. restore Job `Complete=True` -> reconciler scales StatefulSet back to `1`, condition reason `RestoreSucceeded`. +3. restore Job `Failed=True` -> BackupJob phase `Failed`, condition reason `RestoreFailed`. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +docker run --rm -v "${PWD}:/workspace" -v go-mod-cache:/go/pkg/mod -v go-build-cache:/root/.cache/go-build -w /workspace golang:1.26 bash -lc 'export PATH=/usr/local/go/bin:$PATH; go test -count=1 ./internal/controller -run TestBackupJobReconcile_RunningSidecarRestore -v' +``` + +Expected: FAIL until the state machine handles all three states. + +- [ ] **Step 3: Implement minimal production change** + +Implement restore orchestration by inspecting Kubernetes objects instead of adding new CRD status fields: + +1. Scale shard-0 StatefulSet to `0` if needed. +2. Wait until shard-0 Pods are gone. +3. Create or observe the restore Job. +4. On Job completion, scale StatefulSet back to original single-shard member count. +5. Mark BackupJob terminal only after Job completion. + +- [ ] **Step 4: Run tests to verify they pass** + +Run the same `go test` command. Expected: PASS. + +### Task 4: E2E PITR Drill + +**Files:** +- Modify: `test/e2e/pitr_restore_e2e_test.go` +- Modify: `docs/E2E_TEST_REPORT.ko.md` + +- [ ] **Step 1: Update the E2E fixture** + +Enable `spec.backup.enabled=true` in the PITR fixture so WAL archiving is active before the full backup and target-time restore drill. + +- [ ] **Step 2: Flip PITR restore `PContext` to `Context`** + +Only flip after unit tests for the restore state machine pass. + +- [ ] **Step 3: Run targeted live E2E** + +Run: + +```bash +docker run --rm --privileged --network host -v /var/run/docker.sock:/var/run/docker.sock -v "${PWD}:/workspace" -v go-mod-cache:/go/pkg/mod -v go-build-cache:/root/.cache/go-build -w /workspace golang:1.26 bash -c 'set -euo pipefail; export PATH=/usr/local/go/bin:$PATH; export DEBIAN_FRONTEND=noninteractive; apt-get update >/tmp/apt-update.log; apt-get install -y docker.io >/tmp/apt-install.log; bash .devcontainer/post-install.sh >/tmp/post-install.log; export KIND_CLUSTER=postgres-operator-e2e-pitr-codex; export CERT_MANAGER_INSTALL_SKIP=true; make setup-test-e2e; kind export kubeconfig --name "$KIND_CLUSTER"; go test -tags=e2e ./test/e2e -timeout 35m -v -ginkgo.v -ginkgo.label-filter=p1 -ginkgo.focus="PITR restore"' +``` + +Expected: restore/checksum specs pass and only unrelated Pending remains. + +### Self-Review + +- Spec coverage: covers repo persistence, restore execution away from running primary, stop/start orchestration, and E2E Pending release. +- Placeholder scan: no TBD/TODO steps remain. +- Type consistency: uses existing `BackupJobPhase`, `RunnerJobName`, StatefulSet, Job, and condition reason patterns without adding new CRD fields. diff --git a/internal/controller/backupjob_controller.go b/internal/controller/backupjob_controller.go index 0f57ed8..0710bf7 100644 --- a/internal/controller/backupjob_controller.go +++ b/internal/controller/backupjob_controller.go @@ -30,6 +30,7 @@ import ( "strings" "time" + appsv1 "k8s.io/api/apps/v1" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -81,6 +82,9 @@ const ( BackupJobReasonBackupSucceeded = "BackupSucceeded" BackupJobReasonBackupFailed = "BackupFailed" BackupJobReasonRestoreInProgress = "RestoreInProgress" + BackupJobReasonRestoreClusterStopping = "RestoreClusterStopping" + BackupJobReasonRestoreWaitingForPodsToStop = "RestoreWaitingForPodsToStop" + BackupJobReasonRestoreAlreadyInProgress = "RestoreAlreadyInProgress" BackupJobReasonRestoreSucceeded = "RestoreSucceeded" BackupJobReasonRestoreFailed = "RestoreFailed" BackupJobReasonRunnerJobCreated = "RunnerJobCreated" @@ -96,7 +100,10 @@ const ( // +kubebuilder:rbac:groups=postgres.keiailab.io,resources=backupjobs,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=postgres.keiailab.io,resources=backupjobs/status,verbs=get;update;patch // +kubebuilder:rbac:groups=postgres.keiailab.io,resources=backupjobs/finalizers,verbs=update +// +kubebuilder:rbac:groups=postgres.keiailab.io,resources=postgresclusters,verbs=get;list;watch;update;patch // +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;update;patch +// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=pods/exec,verbs=create // Reconcile은 BackupJob CR 변화에 반응한다 (RFC 0004 §3). @@ -452,6 +459,9 @@ func (r *BackupJobReconciler) reconcileSidecar( "BackupPlugin "+bj.Spec.Tool+" does not support sidecar command planning") return ctrl.Result{}, r.statusUpdate(ctx, bj) } + if bj.Spec.Type == backupJobTypeRestore { + return r.reconcileSidecarRestore(ctx, bj, cluster, commandPlugin) + } if r.SidecarExecutor == nil { r.markFailed(bj, BackupJobReasonSidecarExecutorNotConfigured, "Backup sidecar executor is not configured") @@ -459,9 +469,12 @@ func (r *BackupJobReconciler) reconcileSidecar( } target, ok := backupSidecarTarget(cluster) if !ok { - r.markFailed(bj, BackupJobReasonSidecarTargetNotFound, + bj.Status.Phase = postgresv1alpha1.BackupJobRunning + bj.Status.ObservedGeneration = bj.Generation + setBackupJobCondition(bj, metav1.ConditionFalse, + BackupJobReasonSidecarTargetNotFound, "Ready primary pod not found for sidecar BackupJob") - return ctrl.Result{}, r.statusUpdate(ctx, bj) + return ctrl.Result{RequeueAfter: backupJobRunnerRequeueWait}, r.statusUpdate(ctx, bj) } clusterTarget := plugin.ClusterTarget{ @@ -539,6 +552,270 @@ func backupSidecarTarget(cluster *postgresv1alpha1.PostgresCluster) (BackupSidec return BackupSidecarTarget{}, false } +func (r *BackupJobReconciler) reconcileSidecarRestore( + ctx context.Context, + bj *postgresv1alpha1.BackupJob, + cluster *postgresv1alpha1.PostgresCluster, + commandPlugin plugin.BackupCommandPlugin, +) (ctrl.Result, error) { + if result, ok, err := r.ensureClusterRestoreAnnotation(ctx, bj, cluster); !ok || err != nil { + return result, err + } + + stsName := ShardStatefulSetName(cluster.Name, 0) + var sts appsv1.StatefulSet + if err := r.Get(ctx, client.ObjectKey{Namespace: cluster.Namespace, Name: stsName}, &sts); err != nil { + if apierrors.IsNotFound(err) { + r.markFailed(bj, BackupJobReasonRestoreFailed, + "Shard StatefulSet "+stsName+" not found for sidecar PITR restore") + return ctrl.Result{}, r.statusUpdate(ctx, bj) + } + return ctrl.Result{}, err + } + + if sts.Spec.Replicas == nil || *sts.Spec.Replicas != 0 { + stopped := int32(0) + sts.Spec.Replicas = &stopped + if err := r.Update(ctx, &sts); err != nil { + return ctrl.Result{}, err + } + bj.Status.ObservedGeneration = bj.Generation + setBackupJobCondition(bj, metav1.ConditionFalse, + BackupJobReasonRestoreClusterStopping, + "Scaling shard StatefulSet "+stsName+" to 0 before offline PITR restore") + return ctrl.Result{Requeue: true}, r.statusUpdate(ctx, bj) + } + + stopped, err := r.shardPodsStopped(ctx, cluster, 0) + if err != nil { + return ctrl.Result{}, err + } + if !stopped { + bj.Status.ObservedGeneration = bj.Generation + setBackupJobCondition(bj, metav1.ConditionFalse, + BackupJobReasonRestoreWaitingForPodsToStop, + "Waiting for shard-0 Pods to stop before mounting the data PVC in a restore Job") + return ctrl.Result{RequeueAfter: backupJobRunnerRequeueWait}, r.statusUpdate(ctx, bj) + } + + if bj.Status.RunnerJobName == "" { + command, err := commandPlugin.RestoreCommand(plugin.ClusterTarget{ + Namespace: bj.Namespace, + Name: bj.Spec.Cluster.Name, + }, bj.Spec.Restore.TargetTime.Time) + if err != nil { + r.markFailed(bj, BackupJobReasonInvalidSpec, err.Error()) + return ctrl.Result{}, r.statusUpdate(ctx, bj) + } + jobName := backupRunnerJobName(bj.Name) + runner, err := buildSidecarRestoreJob(bj, &sts, jobName, command) + if err != nil { + r.markFailed(bj, BackupJobReasonInvalidSpec, err.Error()) + return ctrl.Result{}, r.statusUpdate(ctx, bj) + } + if err := controllerutil.SetControllerReference(bj, runner, r.Scheme); err != nil { + return ctrl.Result{}, err + } + if err := r.Create(ctx, runner); err != nil && !apierrors.IsAlreadyExists(err) { + return ctrl.Result{}, err + } + bj.Status.RunnerJobName = jobName + bj.Status.ObservedGeneration = bj.Generation + setBackupJobCondition(bj, metav1.ConditionFalse, + BackupJobReasonRunnerJobCreated, + "Restore runner Job "+jobName+" created for offline PITR restore") + return ctrl.Result{Requeue: true}, r.statusUpdate(ctx, bj) + } + + var runner batchv1.Job + if err := r.Get(ctx, client.ObjectKey{Namespace: bj.Namespace, Name: bj.Status.RunnerJobName}, &runner); err != nil { + if apierrors.IsNotFound(err) { + endedAt := nowFunc() + bj.Status.EndedAt = &endedAt + bj.Status.Phase = postgresv1alpha1.BackupJobFailed + bj.Status.ObservedGeneration = bj.Generation + setBackupJobCondition(bj, metav1.ConditionFalse, + BackupJobReasonRunnerJobMissing, + "Restore runner Job "+bj.Status.RunnerJobName+" is missing before terminal status") + return ctrl.Result{}, r.statusUpdate(ctx, bj) + } + return ctrl.Result{}, err + } + + if jobConditionTrue(&runner, batchv1.JobComplete) { + if err := r.releaseClusterRestoreAnnotation(ctx, bj, cluster); err != nil { + return ctrl.Result{}, err + } + endedAt := nowFunc() + bj.Status.EndedAt = &endedAt + bj.Status.Phase = postgresv1alpha1.BackupJobSucceeded + bj.Status.ObservedGeneration = bj.Generation + setBackupJobCondition(bj, metav1.ConditionTrue, + BackupJobReasonRestoreSucceeded, + "Restore runner Job "+runner.Name+" completed successfully") + commonsevents.Emitf(r.Recorder, bj, BackupJobReasonRestoreSucceeded, + "Restore runner Job %s completed successfully", runner.Name) + return ctrl.Result{}, r.statusUpdate(ctx, bj) + } + + if failed := findJobCondition(&runner, batchv1.JobFailed); failed != nil && failed.Status == corev1.ConditionTrue { + endedAt := nowFunc() + bj.Status.EndedAt = &endedAt + bj.Status.Phase = postgresv1alpha1.BackupJobFailed + bj.Status.ObservedGeneration = bj.Generation + message := "Restore runner Job " + runner.Name + " failed" + if failed.Reason != "" || failed.Message != "" { + message = fmt.Sprintf("Restore runner Job %s failed: %s %s", + runner.Name, strings.TrimSpace(failed.Reason), strings.TrimSpace(failed.Message)) + } + setBackupJobCondition(bj, metav1.ConditionFalse, BackupJobReasonRestoreFailed, strings.TrimSpace(message)) + commonsevents.EmitWarningf(r.Recorder, bj, BackupJobReasonRestoreFailed, + "Restore runner Job %s failed", runner.Name) + return ctrl.Result{}, r.statusUpdate(ctx, bj) + } + + bj.Status.ObservedGeneration = bj.Generation + setBackupJobCondition(bj, metav1.ConditionFalse, + BackupJobReasonRestoreInProgress, + "Shard StatefulSet "+stsName+" is stopped; restore runner Job orchestration pending") + return ctrl.Result{RequeueAfter: backupJobRunnerRequeueWait}, r.statusUpdate(ctx, bj) +} + +func buildSidecarRestoreJob( + bj *postgresv1alpha1.BackupJob, + sts *appsv1.StatefulSet, + name string, + command []string, +) (*batchv1.Job, error) { + image, ok := postgresImageFromStatefulSet(sts) + if !ok { + return nil, fmt.Errorf("source StatefulSet %s has no %q container image", sts.Name, pgContainerName) + } + backoffLimit := int32(0) + labels := map[string]string{ + backupJobRunnerLabelKey: bj.Name, + backupJobClusterLabelKey: bj.Spec.Cluster.Name, + } + return &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: bj.Namespace, + Labels: labels, + }, + Spec: batchv1.JobSpec{ + BackoffLimit: &backoffLimit, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: labels}, + Spec: corev1.PodSpec{ + SecurityContext: dataplanePodSecurityContext(), + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{{ + Name: "pgbackrest-restore", + Image: image, + Command: append([]string{}, command...), + SecurityContext: dataplaneContainerSecurityContext(), + VolumeMounts: append([]corev1.VolumeMount{ + {Name: "data", MountPath: pgDataMountPath}, + }, dataplaneEphemeralVolumeMounts()...), + }}, + Volumes: append([]corev1.Volume{{ + Name: "data", + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: statefulSetDataPVCName(sts.Name, 0), + }, + }, + }}, dataplaneEphemeralVolumes()...), + }, + }, + }, + }, nil +} + +func postgresImageFromStatefulSet(sts *appsv1.StatefulSet) (string, bool) { + for i := range sts.Spec.Template.Spec.Containers { + container := &sts.Spec.Template.Spec.Containers[i] + if container.Name == pgContainerName && strings.TrimSpace(container.Image) != "" { + return container.Image, true + } + } + return "", false +} + +func statefulSetDataPVCName(stsName string, ordinal int32) string { + return fmt.Sprintf("data-%s-%d", stsName, ordinal) +} + +func (r *BackupJobReconciler) shardPodsStopped( + ctx context.Context, + cluster *postgresv1alpha1.PostgresCluster, + shardOrdinal int32, +) (bool, error) { + var pods corev1.PodList + if err := r.List(ctx, &pods, + client.InNamespace(cluster.Namespace), + client.MatchingLabels(SelectorLabels(cluster.Name, "shard", shardOrdinal)), + ); err != nil { + return false, err + } + return len(pods.Items) == 0, nil +} + +func (r *BackupJobReconciler) ensureClusterRestoreAnnotation( + ctx context.Context, + bj *postgresv1alpha1.BackupJob, + cluster *postgresv1alpha1.PostgresCluster, +) (ctrl.Result, bool, error) { + owner := strings.TrimSpace(cluster.Annotations[AnnotationRestoreInProgress]) + if owner == bj.Name { + return ctrl.Result{}, true, nil + } + if owner != "" { + r.markFailed(bj, BackupJobReasonRestoreAlreadyInProgress, + "PostgresCluster "+cluster.Name+" already has offline restore owner BackupJob "+owner) + return ctrl.Result{}, false, r.statusUpdate(ctx, bj) + } + + before := cluster.DeepCopy() + annotations := maps.Clone(cluster.Annotations) + if annotations == nil { + annotations = map[string]string{} + } + annotations[AnnotationRestoreInProgress] = bj.Name + cluster.Annotations = annotations + if err := r.patchClusterMetadata(ctx, before, cluster); err != nil { + return ctrl.Result{}, false, err + } + return ctrl.Result{}, true, nil +} + +func (r *BackupJobReconciler) releaseClusterRestoreAnnotation( + ctx context.Context, + bj *postgresv1alpha1.BackupJob, + cluster *postgresv1alpha1.PostgresCluster, +) error { + owner := strings.TrimSpace(cluster.Annotations[AnnotationRestoreInProgress]) + if owner == "" || owner != bj.Name { + return nil + } + before := cluster.DeepCopy() + annotations := maps.Clone(cluster.Annotations) + delete(annotations, AnnotationRestoreInProgress) + if len(annotations) == 0 { + annotations = nil + } + cluster.Annotations = annotations + return r.patchClusterMetadata(ctx, before, cluster) +} + +func (r *BackupJobReconciler) patchClusterMetadata( + ctx context.Context, + before *postgresv1alpha1.PostgresCluster, + cluster *postgresv1alpha1.PostgresCluster, +) error { + return r.Patch(ctx, cluster, client.MergeFrom(before)) +} + func (r *BackupJobReconciler) reconcileRestore( ctx context.Context, bj *postgresv1alpha1.BackupJob, diff --git a/internal/controller/backupjob_controller_test.go b/internal/controller/backupjob_controller_test.go index 4b35e61..1be1078 100644 --- a/internal/controller/backupjob_controller_test.go +++ b/internal/controller/backupjob_controller_test.go @@ -19,9 +19,11 @@ package controller import ( "context" "errors" + "strings" "testing" "time" + appsv1 "k8s.io/api/apps/v1" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/meta" @@ -505,7 +507,7 @@ func TestBackupJobReconcile_RunningSidecarBackupExecutesPrimaryPod(t *testing.T) } } -func TestBackupJobReconcile_RunningSidecarRestoreExecutesPrimaryPod(t *testing.T) { +func TestBackupJobReconcile_RunningSidecarRestoreScalesShardStatefulSetDown(t *testing.T) { t.Parallel() scheme := newScheme(t) bj := newBackupJob("bj-sidecar-restore", postgresv1alpha1.BackupJobRunning) @@ -522,8 +524,20 @@ func TestBackupJobReconcile_RunningSidecarRestoreExecutesPrimaryPod(t *testing.T Ready: true, }, }} + sts := buildPGStatefulSet( + cluster, + "demo-shard-0", "demo-shard-0-headless", + 0, + "example.com/postgres:18", "demo-shard-0-config", "18", + 1, + postgresv1alpha1.StorageSpec{}, + corev1.ResourceRequirements{}, + "", + "test-config-hash", + "", + ) c := fake.NewClientBuilder().WithScheme(scheme). - WithObjects(bj, cluster). + WithObjects(bj, cluster, sts). WithStatusSubresource(&postgresv1alpha1.BackupJob{}). Build() stub := &stubBackupPlugin{ @@ -540,14 +554,302 @@ func TestBackupJobReconcile_RunningSidecarRestoreExecutesPrimaryPod(t *testing.T if stub.restoreCalled != 0 { t.Errorf("sidecar restore must not call RestorePIT directly, called=%d", stub.restoreCalled) } + if stub.restoreCommandCalled != 0 { + t.Errorf("RestoreCommand called %d times, want 0 while StatefulSet is stopping", stub.restoreCommandCalled) + } + if exec.called != 0 { + t.Fatalf("sidecar Exec called %d times, want 0 for PITR restore orchestration", exec.called) + } + if got.Status.Phase != postgresv1alpha1.BackupJobRunning { + t.Errorf("Phase: got %q, want Running while restore is orchestrating", got.Status.Phase) + } + cond := meta.FindStatusCondition(got.Status.Conditions, BackupJobConditionReady) + if cond == nil || cond.Reason != "RestoreClusterStopping" { + t.Fatalf("Ready condition mismatch: %+v", cond) + } + var observed appsv1.StatefulSet + if err := c.Get(context.Background(), client.ObjectKeyFromObject(sts), &observed); err != nil { + t.Fatalf("get StatefulSet: %v", err) + } + if observed.Spec.Replicas == nil || *observed.Spec.Replicas != 0 { + t.Fatalf("StatefulSet replicas = %v, want 0 for offline restore", observed.Spec.Replicas) + } + var observedCluster postgresv1alpha1.PostgresCluster + if err := c.Get(context.Background(), client.ObjectKeyFromObject(cluster), &observedCluster); err != nil { + t.Fatalf("get PostgresCluster: %v", err) + } + if got := observedCluster.Annotations["postgres.keiailab.io/restore-in-progress"]; got != bj.Name { + t.Fatalf("restore annotation = %q, want BackupJob name %q", got, bj.Name) + } +} + +func TestBackupJobReconcile_RunningSidecarRestoreCreatesRestoreJobAfterStatefulSetStopped(t *testing.T) { + t.Parallel() + scheme := newScheme(t) + bj := newBackupJob("bj-sidecar-restore-job", postgresv1alpha1.BackupJobRunning) + targetTime := metav1.NewTime(time.Date(2026, 5, 12, 2, 0, 0, 0, time.UTC)) + bj.Spec.ExecutionMode = backupJobExecutionModeSidecar + bj.Spec.Type = backupJobTypeRestore + bj.Spec.Restore = &postgresv1alpha1.BackupRestoreSpec{TargetTime: &targetTime} + cluster := newBackupJobCluster() + sts := buildPGStatefulSet( + cluster, + "demo-shard-0", "demo-shard-0-headless", + 0, + "example.com/postgres:18", "demo-shard-0-config", "18", + 1, + postgresv1alpha1.StorageSpec{}, + corev1.ResourceRequirements{}, + "", + "test-config-hash", + "", + ) + stopped := int32(0) + sts.Spec.Replicas = &stopped + restoreCommand := []string{"sh", "-c", "pgbackrest --stanza=demo --type=time restore"} + c := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(bj, cluster, sts). + WithStatusSubresource(&postgresv1alpha1.BackupJob{}). + Build() + stub := &stubBackupPlugin{ + name: "pgbackrest", + restoreCommand: restoreCommand, + } + reg := plugin.NewRegistry() + reg.RegisterBackup(stub) + exec := &fakeBackupSidecarExecutor{} + + r := &BackupJobReconciler{Client: c, Scheme: scheme, Plugins: reg, SidecarExecutor: exec} + got := reconcileOnce(t, r, c, bj) + + if exec.called != 0 { + t.Fatalf("sidecar Exec called %d times, want 0; restore must run in a Job", exec.called) + } if stub.restoreCommandCalled != 1 { - t.Errorf("RestoreCommand called %d times, want 1", stub.restoreCommandCalled) + t.Fatalf("RestoreCommand called %d times, want 1", stub.restoreCommandCalled) } - if exec.called != 1 { - t.Fatalf("sidecar Exec called %d times, want 1", exec.called) + if got.Status.Phase != postgresv1alpha1.BackupJobRunning { + t.Fatalf("Phase = %q, want Running while restore Job executes", got.Status.Phase) + } + if got.Status.RunnerJobName != "bj-sidecar-restore-job-runner" { + t.Fatalf("RunnerJobName = %q, want bj-sidecar-restore-job-runner", got.Status.RunnerJobName) } + + var job batchv1.Job + if err := c.Get(context.Background(), client.ObjectKey{ + Namespace: bj.Namespace, + Name: "bj-sidecar-restore-job-runner", + }, &job); err != nil { + t.Fatalf("restore Job get: %v", err) + } + if len(job.OwnerReferences) != 1 || job.OwnerReferences[0].Kind != "BackupJob" || job.OwnerReferences[0].Name != bj.Name { + t.Fatalf("ownerReferences mismatch: %+v", job.OwnerReferences) + } + container := job.Spec.Template.Spec.Containers[0] + if container.Image != "example.com/postgres:18" { + t.Fatalf("restore Job image = %q, want source StatefulSet postgres image", container.Image) + } + if gotCommand := strings.Join(container.Command, " "); gotCommand != strings.Join(restoreCommand, " ") { + t.Fatalf("restore Job command = %q, want %q", gotCommand, strings.Join(restoreCommand, " ")) + } + assertVolumeMount(t, container.VolumeMounts, "data", pgDataMountPath, "") + assertVolumeMount(t, container.VolumeMounts, "ephemeral-pgbackrest-spool", "/var/spool/pgbackrest", "") + assertPVCVolume(t, job.Spec.Template.Spec.Volumes, "data", "data-demo-shard-0-0") + assertEmptyDirVolume(t, job.Spec.Template.Spec.Volumes, "ephemeral-pgbackrest-spool") +} + +func TestBackupJobReconcile_RunningSidecarRestoreWaitsForShardPodsToStopBeforeCreatingJob(t *testing.T) { + t.Parallel() + scheme := newScheme(t) + bj := newBackupJob("bj-sidecar-restore-wait-pod", postgresv1alpha1.BackupJobRunning) + targetTime := metav1.NewTime(time.Date(2026, 5, 12, 2, 0, 0, 0, time.UTC)) + bj.Spec.ExecutionMode = backupJobExecutionModeSidecar + bj.Spec.Type = backupJobTypeRestore + bj.Spec.Restore = &postgresv1alpha1.BackupRestoreSpec{TargetTime: &targetTime} + cluster := newBackupJobCluster() + sts := buildPGStatefulSet( + cluster, + "demo-shard-0", "demo-shard-0-headless", + 0, + "example.com/postgres:18", "demo-shard-0-config", "18", + 1, + postgresv1alpha1.StorageSpec{}, + corev1.ResourceRequirements{}, + "", + "test-config-hash", + "", + ) + stopped := int32(0) + sts.Spec.Replicas = &stopped + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "demo-shard-0-0", + Namespace: "default", + Labels: SelectorLabels("demo", "shard", 0), + }, + } + c := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(bj, cluster, sts, pod). + WithStatusSubresource(&postgresv1alpha1.BackupJob{}). + Build() + reg := plugin.NewRegistry() + reg.RegisterBackup(&stubBackupPlugin{name: "pgbackrest", restoreCommand: []string{"sh", "-c", "restore"}}) + + r := &BackupJobReconciler{Client: c, Scheme: scheme, Plugins: reg} + got := reconcileOnce(t, r, c, bj) + + if got.Status.RunnerJobName != "" { + t.Fatalf("RunnerJobName = %q, want empty while shard pod still exists", got.Status.RunnerJobName) + } + cond := meta.FindStatusCondition(got.Status.Conditions, BackupJobConditionReady) + if cond == nil || cond.Reason != "RestoreWaitingForPodsToStop" { + t.Fatalf("Ready condition mismatch: %+v", cond) + } + var jobs batchv1.JobList + if err := c.List(context.Background(), &jobs, client.InNamespace(bj.Namespace)); err != nil { + t.Fatalf("list Jobs: %v", err) + } + if len(jobs.Items) != 0 { + t.Fatalf("restore Job count = %d, want 0 while shard pod still exists", len(jobs.Items)) + } +} + +func TestBackupJobReconcile_RunningSidecarRestoreCompleteReleasesRestoreLockAndMarksSucceeded(t *testing.T) { + t.Parallel() + scheme := newScheme(t) + bj := newBackupJob("bj-sidecar-restore-complete", postgresv1alpha1.BackupJobRunning) + targetTime := metav1.NewTime(time.Date(2026, 5, 12, 2, 0, 0, 0, time.UTC)) + bj.Spec.ExecutionMode = backupJobExecutionModeSidecar + bj.Spec.Type = backupJobTypeRestore + bj.Spec.Restore = &postgresv1alpha1.BackupRestoreSpec{TargetTime: &targetTime} + bj.Status.RunnerJobName = "bj-sidecar-restore-complete-runner" + cluster := newBackupJobCluster() + cluster.Spec.Shards.Replicas = 1 + cluster.Annotations = map[string]string{ + "postgres.keiailab.io/restore-in-progress": bj.Name, + } + sts := buildPGStatefulSet( + cluster, + "demo-shard-0", "demo-shard-0-headless", + 0, + "example.com/postgres:18", "demo-shard-0-config", "18", + 2, + postgresv1alpha1.StorageSpec{}, + corev1.ResourceRequirements{}, + "", + "test-config-hash", + "", + ) + stopped := int32(0) + sts.Spec.Replicas = &stopped + runner := backupJobRunner("bj-sidecar-restore-complete-runner", bj) + runner.Status.Conditions = []batchv1.JobCondition{{ + Type: batchv1.JobComplete, + Status: corev1.ConditionTrue, + }} + c := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(bj, cluster, sts, runner). + WithStatusSubresource(&postgresv1alpha1.BackupJob{}). + Build() + reg := plugin.NewRegistry() + reg.RegisterBackup(&stubBackupPlugin{name: "pgbackrest"}) + + r := &BackupJobReconciler{Client: c, Scheme: scheme, Plugins: reg} + got := reconcileOnce(t, r, c, bj) + if got.Status.Phase != postgresv1alpha1.BackupJobSucceeded { - t.Errorf("Phase: got %q, want Succeeded", got.Status.Phase) + t.Fatalf("Phase = %q, want Succeeded", got.Status.Phase) + } + if got.Status.EndedAt == nil { + t.Fatal("EndedAt must be recorded after restore completion") + } + cond := meta.FindStatusCondition(got.Status.Conditions, BackupJobConditionReady) + if cond == nil || cond.Reason != BackupJobReasonRestoreSucceeded { + t.Fatalf("Ready condition mismatch: %+v", cond) + } + var observed appsv1.StatefulSet + if err := c.Get(context.Background(), client.ObjectKeyFromObject(sts), &observed); err != nil { + t.Fatalf("get StatefulSet: %v", err) + } + if observed.Spec.Replicas == nil || *observed.Spec.Replicas != 0 { + t.Fatalf("StatefulSet replicas = %v, want 0 until PostgresCluster reconciler restores desired template", observed.Spec.Replicas) + } + var observedCluster postgresv1alpha1.PostgresCluster + if err := c.Get(context.Background(), client.ObjectKeyFromObject(cluster), &observedCluster); err != nil { + t.Fatalf("get PostgresCluster: %v", err) + } + if _, ok := observedCluster.Annotations["postgres.keiailab.io/restore-in-progress"]; ok { + t.Fatal("restore annotation must be released after successful restore") + } + if observedCluster.Spec.Shards.Replicas != 1 { + t.Fatalf("PostgresCluster spec.shards.replicas = %d, want preserved", observedCluster.Spec.Shards.Replicas) + } +} + +func TestBackupJobReconcile_RunningSidecarRestoreFailedLeavesStatefulSetStoppedAndMarksFailed(t *testing.T) { + t.Parallel() + scheme := newScheme(t) + bj := newBackupJob("bj-sidecar-restore-failed", postgresv1alpha1.BackupJobRunning) + targetTime := metav1.NewTime(time.Date(2026, 5, 12, 2, 0, 0, 0, time.UTC)) + bj.Spec.ExecutionMode = backupJobExecutionModeSidecar + bj.Spec.Type = backupJobTypeRestore + bj.Spec.Restore = &postgresv1alpha1.BackupRestoreSpec{TargetTime: &targetTime} + bj.Status.RunnerJobName = "bj-sidecar-restore-failed-runner" + cluster := newBackupJobCluster() + cluster.Annotations = map[string]string{ + "postgres.keiailab.io/restore-in-progress": bj.Name, + } + sts := buildPGStatefulSet( + cluster, + "demo-shard-0", "demo-shard-0-headless", + 0, + "example.com/postgres:18", "demo-shard-0-config", "18", + 1, + postgresv1alpha1.StorageSpec{}, + corev1.ResourceRequirements{}, + "", + "test-config-hash", + "", + ) + stopped := int32(0) + sts.Spec.Replicas = &stopped + runner := backupJobRunner("bj-sidecar-restore-failed-runner", bj) + runner.Status.Conditions = []batchv1.JobCondition{{ + Type: batchv1.JobFailed, + Status: corev1.ConditionTrue, + Reason: "BackoffLimitExceeded", + Message: "restore exited 56", + }} + c := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(bj, cluster, sts, runner). + WithStatusSubresource(&postgresv1alpha1.BackupJob{}). + Build() + reg := plugin.NewRegistry() + reg.RegisterBackup(&stubBackupPlugin{name: "pgbackrest"}) + + r := &BackupJobReconciler{Client: c, Scheme: scheme, Plugins: reg} + got := reconcileOnce(t, r, c, bj) + + if got.Status.Phase != postgresv1alpha1.BackupJobFailed { + t.Fatalf("Phase = %q, want Failed", got.Status.Phase) + } + cond := meta.FindStatusCondition(got.Status.Conditions, BackupJobConditionReady) + if cond == nil || cond.Reason != BackupJobReasonRestoreFailed { + t.Fatalf("Ready condition mismatch: %+v", cond) + } + var observed appsv1.StatefulSet + if err := c.Get(context.Background(), client.ObjectKeyFromObject(sts), &observed); err != nil { + t.Fatalf("get StatefulSet: %v", err) + } + if observed.Spec.Replicas == nil || *observed.Spec.Replicas != 0 { + t.Fatalf("StatefulSet replicas = %v, want 0 after failed restore", observed.Spec.Replicas) + } + var observedCluster postgresv1alpha1.PostgresCluster + if err := c.Get(context.Background(), client.ObjectKeyFromObject(cluster), &observedCluster); err != nil { + t.Fatalf("get PostgresCluster: %v", err) + } + if got := observedCluster.Annotations["postgres.keiailab.io/restore-in-progress"]; got != bj.Name { + t.Fatalf("restore annotation = %q, want failed BackupJob name %q for manual intervention", got, bj.Name) } } @@ -571,8 +873,11 @@ func TestBackupJobReconcile_RunningSidecarRequiresReadyPrimary(t *testing.T) { if exec.called != 0 { t.Fatalf("sidecar Exec should not run without ready primary, called=%d", exec.called) } - if got.Status.Phase != postgresv1alpha1.BackupJobFailed { - t.Errorf("Phase: got %q, want Failed", got.Status.Phase) + if got.Status.Phase != postgresv1alpha1.BackupJobRunning { + t.Errorf("Phase: got %q, want Running while waiting for ready primary", got.Status.Phase) + } + if got.Status.EndedAt != nil { + t.Errorf("EndedAt = %v, want nil for transient sidecar target wait", got.Status.EndedAt) } cond := meta.FindStatusCondition(got.Status.Conditions, BackupJobConditionReady) if cond == nil || cond.Reason != BackupJobReasonSidecarTargetNotFound { @@ -723,3 +1028,37 @@ func assertEnv(t *testing.T, env []corev1.EnvVar, name, want string) { } t.Fatalf("env %s not found in %+v", name, env) } + +func assertVolumeMount(t *testing.T, mounts []corev1.VolumeMount, name, mountPath, subPath string) { + t.Helper() + for _, mount := range mounts { + if mount.Name == name && mount.MountPath == mountPath && mount.SubPath == subPath { + return + } + } + t.Fatalf("VolumeMount name=%q mountPath=%q subPath=%q not found in %+v", name, mountPath, subPath, mounts) +} + +func assertPVCVolume(t *testing.T, volumes []corev1.Volume, name, claimName string) { + t.Helper() + for _, volume := range volumes { + if volume.Name != name || volume.PersistentVolumeClaim == nil { + continue + } + if volume.PersistentVolumeClaim.ClaimName != claimName { + t.Fatalf("PVC volume %q claimName = %q, want %q", name, volume.PersistentVolumeClaim.ClaimName, claimName) + } + return + } + t.Fatalf("PVC volume %q not found in %+v", name, volumes) +} + +func assertEmptyDirVolume(t *testing.T, volumes []corev1.Volume, name string) { + t.Helper() + for _, volume := range volumes { + if volume.Name == name && volume.EmptyDir != nil { + return + } + } + t.Fatalf("EmptyDir volume %q not found in %+v", name, volumes) +} diff --git a/internal/controller/builders.go b/internal/controller/builders.go index 52fcc4d..6fb2b18 100644 --- a/internal/controller/builders.go +++ b/internal/controller/builders.go @@ -82,8 +82,10 @@ const ( externalClusterCredentialsVolumeName = "external-cluster-credentials" externalClusterCredentialsMountPath = "/etc/postgres-external/source" - // backupRepoMountPath 는 filesystem pgBackRest repo (#209) 가 마운트되는 위치다. - backupRepoMountPath = "/var/lib/pgbackrest" + // backupRepoMountPath 는 filesystem pgBackRest repo (#209) 위치다. + // 별도 subPath mount는 kubelet이 root-owned 디렉터리를 만들 수 있어 non-root + // postgres 컨테이너가 쓰지 못한다. 이미 writable인 data PVC 내부 경로를 쓴다. + backupRepoMountPath = pgDataMountPath + "/pgbackrest" primaryPGPassFile = "/tmp/primary.pgpass" primaryClientKeyFile = "/tmp/primary-client.key" primaryClientCertFile = "/tmp/primary-client.crt" @@ -179,8 +181,7 @@ func dataplaneEphemeralVolumeMounts() []corev1.VolumeMount { {Name: "ephemeral-tmp", MountPath: "/tmp"}, {Name: "ephemeral-run", MountPath: "/run"}, {Name: "ephemeral-pg-run", MountPath: "/var/run/postgresql"}, - // #209: filesystem pgBackRest repo (WAL archive-push + full backup land here). - {Name: "pgbackrest-repo", MountPath: backupRepoMountPath}, + {Name: "ephemeral-pgbackrest-spool", MountPath: "/var/spool/pgbackrest"}, } } @@ -191,7 +192,7 @@ func dataplaneEphemeralVolumes() []corev1.Volume { {Name: "ephemeral-tmp", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}, {Name: "ephemeral-run", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}, {Name: "ephemeral-pg-run", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}, - {Name: "pgbackrest-repo", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}, + {Name: "ephemeral-pgbackrest-spool", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}, } } @@ -386,14 +387,14 @@ func archiveConfigForCluster(cluster *postgresv1alpha1.PostgresCluster) *archive // `exec VAR=val cmd` 는 POSIX 에서 VAR=val 을 실행 파일로 오인한다 (exec 는 special // builtin → env 할당 prefix 불가, "exec: VAR=val: not found"). `env` 명령으로 감싸 // 변수 설정 후 pgbackrest 를 exec 한다 (라이브 sidecar exec 2026-06-04 회귀 fix). - // pgbackrest 공통 옵션 (plugin.go pgbackrestCommonArgs 미러, 라이브 검증 2026-06-04): - // readOnlyRootFilesystem + uid 70 + deb /etc/pgbackrest.conf(640) 환경 회피 - // (--config=/dev/null + --log-level-file=off + --pg1-path + --pg1-user/database). - commonArgs := "--config=/dev/null --log-level-file=off --pg1-path=" + pgDataSubdir + - " --pg1-user=postgres --pg1-database=postgres" + // stanza-create는 DB 접속 옵션이 필요하지만, archive-push는 해당 옵션을 받지 않는다. + // WAL path는 PostgreSQL의 %p placeholder를 직접 전달해 shell positional argument + // escape 문제를 피한다. + archiveArgs := "--config=/dev/null --log-level-file=off --pg1-path=" + pgDataSubdir + stanzaArgs := archiveArgs + " --pg1-user=postgres --pg1-database=postgres" cmd := fmt.Sprintf( - `sh -c "env %s pgbackrest %s --stanza=%s stanza-create 2>/dev/null || true; exec env %s pgbackrest %s --stanza=%s archive-push \"$1\"" -- %%p`, - repoEnv, commonArgs, stanza, repoEnv, commonArgs, stanza) + `sh -c "env %s pgbackrest %s --stanza=%s stanza-create 2>/dev/null || true; exec env %s pgbackrest %s --stanza=%s archive-push \"%%p\""`, + repoEnv, stanzaArgs, stanza, repoEnv, archiveArgs, stanza) return &archivePostgresConfig{ Enabled: true, Command: cmd, @@ -1068,6 +1069,12 @@ func buildPGStatefulSet( instanceEnv = append(instanceEnv, corev1.EnvVar{Name: "POSTGRES_RESHARD_TARGET", Value: reshardTargetID}) } + pvcLabels := make(map[string]string, len(labels)+1) + for k, v := range labels { + pvcLabels[k] = v + } + pvcLabels["postgres.keiailab.io/cluster"] = cluster.Name + return &appsv1.StatefulSet{ ObjectMeta: metav1.ObjectMeta{ Name: name, @@ -1145,7 +1152,7 @@ func buildPGStatefulSet( VolumeClaimTemplates: []corev1.PersistentVolumeClaim{{ ObjectMeta: metav1.ObjectMeta{ Name: "data", - Labels: labels, + Labels: pvcLabels, }, Spec: pvcSpec, }}, diff --git a/internal/controller/builders_test.go b/internal/controller/builders_test.go index 99c7516..2f63221 100644 --- a/internal/controller/builders_test.go +++ b/internal/controller/builders_test.go @@ -90,6 +90,69 @@ func TestBuildPGStatefulSet_AppliesSecurityContextAndEphemeralMounts(t *testing. assertDataplaneSecurityContext(t, &sts.Spec.Template.Spec, "PG StatefulSet") } +func TestBuildPGStatefulSet_VolumeClaimTemplateHasClusterLabel(t *testing.T) { + t.Parallel() + + cluster := &postgresv1alpha1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: "ns1"}, + } + sts := buildPGStatefulSet( + cluster, + "demo-shard-0", "demo-shard-0-headless", + 0, + "example.com/postgres:18", "demo-shard-0-config", "18", + 1, + postgresv1alpha1.StorageSpec{Size: resource.MustParse("1Gi")}, + corev1.ResourceRequirements{}, + "", + "test-config-hash", + "", + ) + + if got, want := len(sts.Spec.VolumeClaimTemplates), 1; got != want { + t.Fatalf("volumeClaimTemplates = %d, want %d", got, want) + } + labels := sts.Spec.VolumeClaimTemplates[0].Labels + if got := labels["postgres.keiailab.io/cluster"]; got != "demo" { + t.Fatalf("PVC cluster label = %q, want %q (labels=%v)", got, "demo", labels) + } +} + +func TestBuildPGStatefulSet_PgBackRestRepoUsesDataPVCPath(t *testing.T) { + t.Parallel() + + cluster := &postgresv1alpha1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: "ns1"}, + } + sts := buildPGStatefulSet( + cluster, + "demo-shard-0", "demo-shard-0-headless", + 0, + "example.com/postgres:18", "demo-shard-0-config", "18", + 1, + postgresv1alpha1.StorageSpec{Size: resource.MustParse("1Gi")}, + corev1.ResourceRequirements{}, + "", + "test-config-hash", + "", + ) + + if !strings.HasPrefix(backupRepoMountPath, pgDataMountPath+"/") { + t.Fatalf("backupRepoMountPath = %q, want path inside data PVC mount %q", backupRepoMountPath, pgDataMountPath) + } + container := sts.Spec.Template.Spec.Containers[0] + for _, volume := range sts.Spec.Template.Spec.Volumes { + if volume.Name == "pgbackrest-repo" { + t.Fatalf("pgBackRest repo must not use EmptyDir volume: %+v", volume) + } + } + for _, mount := range container.VolumeMounts { + if mount.MountPath == backupRepoMountPath { + t.Fatalf("pgBackRest repo must not use a separate subPath mount; got %+v", mount) + } + } +} + func TestBuildPGStatefulSet_InjectsInstanceEnv(t *testing.T) { t.Parallel() @@ -619,9 +682,10 @@ func assertDataplaneSecurityContext(t *testing.T, podSpec *corev1.PodSpec, label // 3. emptyDir mount 3개 (readOnlyRootFilesystem 동반) wantMounts := map[string]string{ - "ephemeral-tmp": "/tmp", - "ephemeral-run": "/run", - "ephemeral-pg-run": "/var/run/postgresql", + "ephemeral-tmp": "/tmp", + "ephemeral-run": "/run", + "ephemeral-pg-run": "/var/run/postgresql", + "ephemeral-pgbackrest-spool": "/var/spool/pgbackrest", } mountsByName := make(map[string]string, len(cnt.VolumeMounts)) for _, vm := range cnt.VolumeMounts { @@ -795,7 +859,7 @@ func TestArchiveConfig_NoSingleQuote_ConfSafe(t *testing.T) { Spec: postgresv1alpha1.PostgresClusterSpec{ Backup: &postgresv1alpha1.ClusterBackupSpec{ Enabled: true, - Repo: &postgresv1alpha1.ClusterBackupRepoSpec{Type: "filesystem", Path: "/var/lib/pgbackrest"}, + Repo: &postgresv1alpha1.ClusterBackupRepoSpec{Type: "filesystem", Path: "/var/lib/postgresql/data/pgbackrest"}, }, }, } @@ -806,9 +870,28 @@ func TestArchiveConfig_NoSingleQuote_ConfSafe(t *testing.T) { if strings.Contains(cfg.Command, "'") { t.Errorf("archive_command must not contain single quote (breaks postgresql.conf): %q", cfg.Command) } - if !strings.Contains(cfg.Command, "PGBACKREST_REPO1_PATH=/var/lib/pgbackrest") { + if !strings.Contains(cfg.Command, "PGBACKREST_REPO1_PATH=/var/lib/postgresql/data/pgbackrest") { t.Errorf("archive_command must carry repo env: %q", cfg.Command) } + if strings.Contains(cfg.Command, "--spool-path=") { + t.Errorf("archive_command must not include restore-only pgBackRest spool path: %q", cfg.Command) + } + if !strings.Contains(cfg.Command, `archive-push \"%p\"`) { + t.Errorf("archive_command must pass PostgreSQL %%p WAL path directly: %q", cfg.Command) + } + if strings.Contains(cfg.Command, "$1") { + t.Errorf("archive_command must not rely on shell positional args for WAL path: %q", cfg.Command) + } + pushStart := strings.LastIndex(cfg.Command, "exec env ") + if pushStart < 0 { + t.Fatalf("archive_command missing archive-push exec segment: %q", cfg.Command) + } + pushCommand := cfg.Command[pushStart:] + for _, invalid := range []string{"--pg1-user=", "--pg1-database="} { + if strings.Contains(pushCommand, invalid) { + t.Errorf("archive-push command must not include backup-only option %q: %q", invalid, pushCommand) + } + } // `exec VAR=val` 은 not-found → `exec env VAR=val` 이어야 (live 127 회귀 가드). if !strings.Contains(cfg.Command, "exec env ") { t.Errorf("archive_command must use `exec env` (exec rejects env prefix): %q", cfg.Command) diff --git a/internal/plugin/backup/pgbackrest/plugin.go b/internal/plugin/backup/pgbackrest/plugin.go index 0fcd457..a63c4c6 100644 --- a/internal/plugin/backup/pgbackrest/plugin.go +++ b/internal/plugin/backup/pgbackrest/plugin.go @@ -152,6 +152,13 @@ func (p *Plugin) RestorePIT(ctx context.Context, target plugin.ClusterTarget, ts const pgbackrestCommonArgs = "--config=/dev/null --log-level-file=off " + "--pg1-path=/var/lib/postgresql/data/pgdata --pg1-user=postgres --pg1-database=postgres" +const pgbackrestRestoreArgs = "--config=/dev/null --log-level-file=off " + + "--pg1-path=/var/lib/postgresql/data/pgdata" + +const filesystemRepoPath = "/var/lib/postgresql/data/pgbackrest" +const pgDataPath = "/var/lib/postgresql/data/pgdata" +const postgresqlAutoConfPath = pgDataPath + "/postgresql.auto.conf" + func (p *Plugin) BackupCommand(target plugin.ClusterTarget, opts plugin.BackupOptions) ([]string, error) { backupType, err := normalizeBackupType(opts.Type) if err != nil { @@ -163,13 +170,13 @@ func (p *Plugin) BackupCommand(target plugin.ClusterTarget, opts plugin.BackupOp // #209: pass filesystem repo config inline and ensure the stanza exists, so // the backup has a configured repository (mirrors the archive_command wrapper // in builders.go archiveConfigForCluster). Repo path = backupRepoMountPath. - const repoEnv = "PGBACKREST_REPO1_TYPE=posix PGBACKREST_REPO1_PATH=/var/lib/pgbackrest" + const repoEnv = "PGBACKREST_REPO1_TYPE=posix PGBACKREST_REPO1_PATH=" + filesystemRepoPath return []string{ "sh", "-c", // `env VAR=val cmd` (not `exec VAR=val cmd`): exec is a POSIX special builtin // and rejects env-assignment prefixes ("exec: VAR=val: not found"). (#209 live fix) - // --archive-check=n: emptyDir repo 의 초기 WAL 미archive 허용 (online backup). - fmt.Sprintf("env %s %s %s --stanza=%s stanza-create 2>/dev/null || true; exec env %s %s %s --stanza=%s --repo=%s --type=%s --archive-check=n backup", + // archive-check 기본값을 유지한다. WAL archive 없는 백업 성공은 PITR에서 복구 불가능하다. + fmt.Sprintf("env %s %s %s --stanza=%s stanza-create 2>/dev/null || true; exec env %s %s %s --stanza=%s --repo=%s --type=%s backup", repoEnv, p.command, pgbackrestCommonArgs, target.Name, repoEnv, p.command, pgbackrestCommonArgs, target.Name, normalizeRepo(opts.Repo), backupType), }, nil @@ -182,14 +189,20 @@ func (p *Plugin) RestoreCommand(target plugin.ClusterTarget, ts time.Time) ([]st } // #209: PITR restore also needs the repo configured (same filesystem repo as // archive_command / BackupCommand). - const repoEnv = "PGBACKREST_REPO1_TYPE=posix PGBACKREST_REPO1_PATH=/var/lib/pgbackrest" + const repoEnv = "PGBACKREST_REPO1_TYPE=posix PGBACKREST_REPO1_PATH=" + filesystemRepoPath return []string{ "sh", "-c", - fmt.Sprintf("exec env %s %s %s --stanza=%s --type=time --target=%s restore", - repoEnv, p.command, pgbackrestCommonArgs, target.Name, formatTargetTime(ts)), + fmt.Sprintf("rm -f %s/postmaster.pid; env %s %s %s --stanza=%s --type=time --target=%s --delta restore && %s", + pgDataPath, repoEnv, p.command, pgbackrestRestoreArgs, target.Name, shellSingleQuote(formatTargetTime(ts)), + p.restoreCommandOverride(target.Name, repoEnv)), }, nil } +func (p *Plugin) restoreCommandOverride(stanza, repoEnv string) string { + return fmt.Sprintf(`touch %s && sed -i "/^[[:space:]]*restore_command[[:space:]]*=/d" %s && printf "%%s\n" "restore_command = 'env %s %s --config=/dev/null --log-level-file=off --pg1-path=%s --stanza=%s archive-get %%f \"%%p\"'" >> %s`, + postgresqlAutoConfPath, postgresqlAutoConfPath, repoEnv, p.command, pgDataPath, stanza, postgresqlAutoConfPath) +} + // ParseBackupResult 는 pgbackrest 출력에서 backup label 을 추출한다. func (p *Plugin) ParseBackupResult(output []byte, opts plugin.BackupOptions) plugin.BackupResult { return plugin.BackupResult{ @@ -230,5 +243,9 @@ func parseBackupLabel(output []byte) string { } func formatTargetTime(ts time.Time) string { - return ts.UTC().Format("2006-01-02 15:04:05-07:00") + return ts.UTC().Format("2006-01-02 15:04:05.999999-07:00") +} + +func shellSingleQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" } diff --git a/internal/plugin/backup/pgbackrest/plugin_test.go b/internal/plugin/backup/pgbackrest/plugin_test.go index c5994f9..2f09d38 100644 --- a/internal/plugin/backup/pgbackrest/plugin_test.go +++ b/internal/plugin/backup/pgbackrest/plugin_test.go @@ -86,11 +86,17 @@ func TestPerformBackupRunsPgBackRestCommand(t *testing.T) { if runner.command != "sh" || len(runner.args) != 2 || runner.args[0] != "-c" { t.Fatalf("command should be sh -c wrapper: command=%q args=%v", runner.command, runner.args) } - for _, want := range []string{"pgbackrest-test", "--stanza=demo", "--repo=1", "--type=incr", "backup", "stanza-create", "exec env ", "PGBACKREST_REPO1_PATH=/var/lib/pgbackrest"} { + for _, want := range []string{"pgbackrest-test", "--stanza=demo", "--repo=1", "--type=incr", "backup", "stanza-create", "exec env ", "PGBACKREST_REPO1_PATH=/var/lib/postgresql/data/pgbackrest"} { if !strings.Contains(runner.args[1], want) { t.Fatalf("backup wrapper missing %q in %q", want, runner.args[1]) } } + if strings.Contains(runner.args[1], "--spool-path=") { + t.Fatalf("backup wrapper must not include restore-only spool-path option: %q", runner.args[1]) + } + if strings.Contains(runner.args[1], "--archive-check") { + t.Fatalf("backup wrapper must not bypass WAL archive validation: %q", runner.args[1]) + } if result.BackupID != "20260512-010203F" { t.Fatalf("BackupID: got %q, want parsed label", result.BackupID) } @@ -123,17 +129,33 @@ func TestRestorePITRunsPgBackRestTimeRestore(t *testing.T) { t.Parallel() runner := &recordingRunner{} p := New(WithRunner(runner)) - targetTime := time.Date(2026, 5, 12, 1, 2, 3, 0, time.UTC) + targetTime := time.Date(2026, 5, 12, 1, 2, 3, 123456000, time.UTC) if err := p.RestorePIT(context.Background(), plugin.ClusterTarget{Name: "demo"}, targetTime); err != nil { t.Fatalf("RestorePIT error: %v", err) } - for _, want := range []string{"--stanza=demo", "--type=time", "--target=2026-05-12 01:02:03+00:00", "restore", "exec env ", "PGBACKREST_REPO1_PATH=/var/lib/pgbackrest"} { + for _, want := range []string{"rm -f /var/lib/postgresql/data/pgdata/postmaster.pid", "--stanza=demo", "--type=time", "--target='2026-05-12 01:02:03.123456+00:00'", "--delta", "restore", "env PGBACKREST_REPO1_TYPE=posix", "PGBACKREST_REPO1_PATH=/var/lib/postgresql/data/pgbackrest"} { if !strings.Contains(runner.args[1], want) { t.Fatalf("restore wrapper missing %q in %q", want, runner.args[1]) } } + for _, want := range []string{"sed -i", "postgresql.auto.conf", "restore_command = 'env PGBACKREST_REPO1_TYPE=posix PGBACKREST_REPO1_PATH=/var/lib/postgresql/data/pgbackrest", "--pg1-path=/var/lib/postgresql/data/pgdata --stanza=demo archive-get %f \\\"%p\\\""} { + if !strings.Contains(runner.args[1], want) { + t.Fatalf("restore wrapper missing recovery command override %q in %q", want, runner.args[1]) + } + } + if strings.Contains(runner.args[1], "--spool-path=") { + t.Fatalf("restore wrapper must rely on writable /var/spool/pgbackrest mount, not pgBackRest spool-path option: %q", runner.args[1]) + } + if strings.Contains(runner.args[1], "--target=2026-05-12 01:02:03.123456+00:00") { + t.Fatalf("restore wrapper must quote target time with spaces: %q", runner.args[1]) + } + for _, invalid := range []string{"--pg1-user=", "--pg1-database="} { + if strings.Contains(runner.args[1], invalid) { + t.Fatalf("restore wrapper must not include backup-only option %q: %q", invalid, runner.args[1]) + } + } } func TestRunnerErrorsIncludeOutput(t *testing.T) { diff --git a/test/e2e/pitr_restore_e2e_test.go b/test/e2e/pitr_restore_e2e_test.go index 7c2a71d..a7181da 100644 --- a/test/e2e/pitr_restore_e2e_test.go +++ b/test/e2e/pitr_restore_e2e_test.go @@ -16,6 +16,7 @@ package e2e import ( "fmt" + "os" "os/exec" "strings" "time" @@ -38,8 +39,8 @@ var _ = Describe("PITR restore + checksum drill (D.3.2)", Ordered, Label("p1"), _, _ = utils.Run(exec.Command("kubectl", "create", "ns", pitrNamespace)) // PostgresCluster 부트스트랩 — backup/restore 대상 cluster. - // pgBackRest repo 는 operator 가 filesystem(posix) repo 를 PG Pod 의 - // /var/lib/pgbackrest EmptyDir 에 자동 구성 + stanza-create 한다 (#209). + // pgBackRest repo 는 data PVC 내부 경로로 유지된다. EmptyDir + // repo 는 Pod 재시작/restore orchestration 중 사라지므로 PITR 전제가 아니다. // 외부 S3 불필요 — single primary(replicas=0) 로 backup→PITR restore 검증. manifest := fmt.Sprintf(` apiVersion: postgres.keiailab.io/v1alpha1 @@ -55,6 +56,12 @@ spec: replicas: 0 storage: size: 1Gi + backup: + enabled: true + schedule: "0 0 * * *" + repo: + type: filesystem + path: /var/lib/postgresql/data/pgbackrest `, pitrCRName, pitrNamespace) cmd := exec.Command("kubectl", "apply", "-f", "-") cmd.Stdin = strings.NewReader(manifest) @@ -72,6 +79,9 @@ spec: }) AfterAll(func() { + if os.Getenv("E2E_KEEP_PITR_NAMESPACE") == "true" { + return + } _, _ = utils.Run(exec.Command("kubectl", "delete", "ns", pitrNamespace, "--wait=false")) }) @@ -99,9 +109,9 @@ spec: Eventually(func() string { out, _ := utils.Run(exec.Command("kubectl", "get", "backupjob", "pitr-full-bj", "-n", pitrNamespace, - "-o", "jsonpath={.status.phase}")) + "-o", "jsonpath=phase={.status.phase} reason={.status.conditions[?(@.type==\"Ready\")].reason} message={.status.conditions[?(@.type==\"Ready\")].message}")) return out - }, 5*time.Minute, 10*time.Second).Should(Equal("Succeeded")) + }, 5*time.Minute, 10*time.Second).Should(ContainSubstring("phase=Succeeded")) }) It("marker row 'before' 삽입 + 시점 기록", func() { @@ -119,9 +129,9 @@ spec: fmt.Sprintf("%s-shard-0-0", pitrCRName), "-n", pitrNamespace, "-c", "postgres", "--", "psql", "-U", "postgres", "-t", "-A", "-c", - "SELECT now() AT TIME ZONE 'UTC'")) + "SELECT clock_timestamp() AT TIME ZONE 'UTC'")) t, err := time.Parse("2006-01-02 15:04:05.999999", strings.TrimSpace(out)) - Expect(err).NotTo(HaveOccurred(), "parse pg now(): %s", out) + Expect(err).NotTo(HaveOccurred(), "parse pg clock_timestamp(): %s", out) pitrTarget = t }) @@ -133,21 +143,31 @@ spec: "--", "psql", "-U", "postgres", "-c", "INSERT INTO drill VALUES ('after');")) Expect(err).NotTo(HaveOccurred()) + + // 저트래픽 E2E에서는 WAL 세그먼트가 자연스럽게 꽉 차지 않는다. + // target 이후 WAL을 명시적으로 switch/archive 해야 PITR replay 입력이 결정적이다. + out, err := utils.Run(exec.Command("kubectl", "exec", + fmt.Sprintf("%s-shard-0-0", pitrCRName), "-n", pitrNamespace, + "-c", "postgres", + "--", "psql", "-U", "postgres", "-t", "-A", "-c", + "SELECT pg_walfile_name(pg_switch_wal())")) + Expect(err).NotTo(HaveOccurred()) + walFile := strings.TrimSpace(out) + Expect(walFile).NotTo(BeEmpty()) + + Eventually(func() string { + out, _ := utils.Run(exec.Command("kubectl", "exec", + fmt.Sprintf("%s-shard-0-0", pitrCRName), "-n", pitrNamespace, + "-c", "postgres", + "--", "sh", "-lc", + fmt.Sprintf("find /var/lib/postgresql/data/pgbackrest/archive/%s -type f -name '%s*' | head -n 1", pitrCRName, walFile))) + return strings.TrimSpace(out) + }, 2*time.Minute, 5*time.Second).Should(ContainSubstring(walFile), + "switched WAL %s must be archived before restore", walFile) }) }) - // PENDING (ROADMAP G1 §Backup/Restore — PITR `[~]`): in-place PITR restore - // orchestration is not yet implemented. `pgbackrest restore` requires the - // cluster to be stopped and PGDATA emptied (or `--delta`); the current - // sidecar-exec path (internal/plugin/backup/pgbackrest.RestoreCommand) runs - // `restore` against a RUNNING primary, which pgBackRest refuses. A correct - // restore needs the operator to orchestrate STS stop → delta restore → - // recovery-target → restart (a dedicated Phase 1 code-gap task, GA #248). - // The full-backup + marker sub-steps above DO pass live; only the restore - // execution is gated. Marked Pending (not failing) to keep the p1 gate - // honest: implemented features green, this unimplemented feature visibly - // Pending rather than a silent skip or a false pass. - PContext("Restore type=time targetTime= (PENDING: restore orchestration unimplemented, GA #248)", func() { + Context("Restore type=time targetTime=", func() { It("BackupJob type=restore + targetTime 적용", func() { manifest := fmt.Sprintf(` apiVersion: postgres.keiailab.io/v1alpha1 @@ -164,7 +184,7 @@ spec: executionMode: sidecar restore: targetTime: %q -`, pitrNamespace, pitrCRName, pitrTarget.UTC().Format(time.RFC3339)) +`, pitrNamespace, pitrCRName, pitrTarget.UTC().Format(time.RFC3339Nano)) cmd := exec.Command("kubectl", "apply", "-f", "-") cmd.Stdin = strings.NewReader(manifest) _, err := utils.Run(cmd) @@ -173,9 +193,9 @@ spec: Eventually(func() string { out, _ := utils.Run(exec.Command("kubectl", "get", "backupjob", "pitr-restore-bj", "-n", pitrNamespace, - "-o", "jsonpath={.status.phase}")) + "-o", "jsonpath=phase={.status.phase} runner={.status.runnerJobName} reason={.status.conditions[?(@.type==\"Ready\")].reason} message={.status.conditions[?(@.type==\"Ready\")].message}")) return out - }, 10*time.Minute, 20*time.Second).Should(Equal("Succeeded")) + }, 10*time.Minute, 20*time.Second).Should(ContainSubstring("phase=Succeeded")) }) It("restore 후 marker row 'before' 존재", func() { @@ -200,17 +220,18 @@ spec: }) }) - // PENDING: depends on the restore above (GA #248). See the PContext note. - PContext("pg_checksums verify (PENDING: depends on restore orchestration, GA #248)", func() { + Context("pg_checksums verify", func() { It("data checksums 일치 (online 가능 시 pg_checksums --check)", func() { // pg_checksums --check 는 PG 서버 stop 필요. 일부 환경은 PG 18 의 // pg_verify_backup 또는 cluster-level checksum 활성 시 다른 명령 사용. - out, _ := utils.Run(exec.Command("kubectl", "exec", - fmt.Sprintf("%s-shard-0-0", pitrCRName), "-n", pitrNamespace, - "-c", "postgres", - "--", "psql", "-U", "postgres", "-t", "-A", "-c", - "SELECT count(*) FROM pg_stat_database WHERE checksum_failures > 0")) - Expect(strings.TrimSpace(out)).To(Equal("0"), + Eventually(func() string { + out, _ := utils.Run(exec.Command("kubectl", "exec", + fmt.Sprintf("%s-shard-0-0", pitrCRName), "-n", pitrNamespace, + "-c", "postgres", + "--", "psql", "-U", "postgres", "-t", "-A", "-c", + "SELECT count(*) FROM pg_stat_database WHERE checksum_failures > 0")) + return strings.TrimSpace(out) + }, 2*time.Minute, 5*time.Second).Should(Equal("0"), "restore 후 checksum_failures = 0") }) }) From 20c82af4976eefd149bbc28c6582a47e4f7feadf Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Thu, 25 Jun 2026 16:51:20 +0900 Subject: [PATCH 004/109] =?UTF-8?q?test(e2e):=20fixture=20=EB=8F=85?= =?UTF-8?q?=EB=A6=BD=ED=99=94=20+=20manager=20rollout=20=EC=95=88=EC=A0=95?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - quickstart 외부 전제를 제거하고 각 suite 가 ensurePostgresClusterReady 로 독립 PostgresCluster 를 부트스트랩한다. - DB/User live smoke 에 status.applied=true 검증(waitPostgresUserApplied)을 추가하고 CRD spec 구조(cluster.name, schema 내부 privileges)와 kubectl exec -c postgres 를 정정. - 동일 tag 재실행 시 manager Deployment rollout 을 강제하고 manager 이미지를 0.4.0-beta.8 로 정렬한다. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01H4fFn82hrtvuCfuZnBMaqZ --- test/e2e/e2e_suite_rollout_test.go | 97 ++++++++ test/e2e/e2e_suite_test.go | 49 +++- test/e2e/external_clusters_drill_e2e_test.go | 36 ++- test/e2e/helpers.go | 232 +++++++++++++++++++ test/e2e/hibernation_e2e_test.go | 4 +- test/e2e/imagecatalog_e2e_test.go | 14 +- test/e2e/pooler_e2e_test.go | 44 ++-- test/e2e/postgresdatabase_e2e_test.go | 54 +++-- test/e2e/postgresuser_e2e_test.go | 30 ++- 9 files changed, 496 insertions(+), 64 deletions(-) create mode 100644 test/e2e/e2e_suite_rollout_test.go create mode 100644 test/e2e/helpers.go diff --git a/test/e2e/e2e_suite_rollout_test.go b/test/e2e/e2e_suite_rollout_test.go new file mode 100644 index 0000000..e11ac61 --- /dev/null +++ b/test/e2e/e2e_suite_rollout_test.go @@ -0,0 +1,97 @@ +//go:build e2e +// +build e2e + +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package e2e + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "sigs.k8s.io/yaml" +) + +func TestManagerDeploymentRefreshCommandsRestartAndWaitForRollout(t *testing.T) { + cmds := managerDeploymentRefreshCommands( + "postgres-operator-system", + "postgres-operator-controller-manager", + ) + + if len(cmds) != 2 { + t.Fatalf("expected two rollout commands, got %d", len(cmds)) + } + + wantRestart := []string{ + "kubectl", + "-n", "postgres-operator-system", + "rollout", "restart", + "deployment/postgres-operator-controller-manager", + } + if got := cmds[0].Args; !reflect.DeepEqual(got, wantRestart) { + t.Fatalf("restart command mismatch\nwant: %#v\n got: %#v", wantRestart, got) + } + + wantStatus := []string{ + "kubectl", + "-n", "postgres-operator-system", + "rollout", "status", + "deployment/postgres-operator-controller-manager", + "--timeout=180s", + } + if got := cmds[1].Args; !reflect.DeepEqual(got, wantStatus) { + t.Fatalf("rollout status command mismatch\nwant: %#v\n got: %#v", wantStatus, got) + } +} + +func TestManagerImageMatchesInstallManifests(t *testing.T) { + kustomizationRaw, err := os.ReadFile(repoPath(t, "config", "manager", "kustomization.yaml")) + if err != nil { + t.Fatalf("read manager kustomization: %v", err) + } + var kustomization struct { + Images []struct { + Name string `json:"name"` + NewName string `json:"newName"` + NewTag string `json:"newTag"` + } `json:"images"` + } + if err := yaml.Unmarshal(kustomizationRaw, &kustomization); err != nil { + t.Fatalf("parse manager kustomization: %v", err) + } + var kustomizeImage string + for _, image := range kustomization.Images { + if image.Name == "controller" { + kustomizeImage = fmt.Sprintf("%s:%s", image.NewName, image.NewTag) + break + } + } + if kustomizeImage == "" { + t.Fatal("config/manager/kustomization.yaml must declare controller image") + } + if managerImage != kustomizeImage { + t.Fatalf("managerImage = %q, want config/manager controller image %q", managerImage, kustomizeImage) + } + + installRaw, err := os.ReadFile(repoPath(t, "dist", "install.yaml")) + if err != nil { + t.Fatalf("read dist/install.yaml: %v", err) + } + if !strings.Contains(string(installRaw), "image: "+managerImage) { + t.Fatalf("dist/install.yaml must contain manager image %q", managerImage) + } +} + +func repoPath(t *testing.T, elems ...string) string { + t.Helper() + parts := append([]string{"..", ".."}, elems...) + return filepath.Clean(filepath.Join(parts...)) +} diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go index c15cc77..6d32c6a 100644 --- a/test/e2e/e2e_suite_test.go +++ b/test/e2e/e2e_suite_test.go @@ -23,16 +23,20 @@ import ( ) var ( - // managerImage 는 dist/install.yaml 의 controller image (config/manager/kustomization.yaml - // newTag = Chart.yaml appVersion = 0.3.0-alpha) 와 정렬돼야 한다. kubebuilder - // 기본값 (`example.com/postgres-operator:v0.0.1`) 을 그대로 두면 install.yaml 이 - // IfNotPresent 정책으로 ghcr.io 의 :0.3.0-alpha 를 pull 시도 → kind 노드에 부재 → - // ImagePullBackOff. 동일 출처 (appVersion) 정렬로 drift 차단. - managerImage = "ghcr.io/keiailab/postgres-operator:0.3.0-alpha" + // managerImage 는 dist/install.yaml 및 config/manager/kustomization.yaml 의 + // controller image 와 정렬돼야 한다. 동일 tag 재사용 시에도 BeforeSuite 가 + // 이미지를 다시 build/load 하고 rollout을 강제하므로, 세 manifest의 출처가 + // 갈라지면 E2E가 오래된 manager Pod로 실행될 수 있다. + managerImage = "ghcr.io/keiailab/postgres-operator:0.4.0-beta.8" // shouldCleanupCertManager tracks whether CertManager was installed by this suite. shouldCleanupCertManager = false ) +const ( + managerNamespace = "postgres-operator-system" + managerDeployment = "postgres-operator-controller-manager" +) + // TestE2E runs the e2e test suite to validate the solution in an isolated environment. // The default setup requires Kind and CertManager. // @@ -73,11 +77,15 @@ var _ = BeforeSuite(func() { _, err = utils.Run(cmd) ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to apply dist/install.yaml") + By("forcing operator manager Deployment rollout for same-tag e2e reruns") + err = refreshManagerDeployment() + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "operator manager Deployment did not roll out") + By("waiting for operator manager Deployment to become Available") cmd = exec.Command("kubectl", - "-n", "postgres-operator-system", + "-n", managerNamespace, "wait", "--for=condition=Available", - "deployment/postgres-operator-controller-manager", + "deployment/"+managerDeployment, "--timeout=180s") _, err = utils.Run(cmd) ExpectWithOffset(1, err).NotTo(HaveOccurred(), "operator manager Deployment did not become Available") @@ -89,6 +97,31 @@ var _ = AfterSuite(func() { teardownCertManager() }) +func refreshManagerDeployment() error { + for _, cmd := range managerDeploymentRefreshCommands(managerNamespace, managerDeployment) { + out, err := utils.Run(cmd) + if err != nil { + return fmt.Errorf("refresh manager deployment: %w\n%s", err, out) + } + } + return nil +} + +func managerDeploymentRefreshCommands(namespace, deployment string) []*exec.Cmd { + deployRef := "deployment/" + deployment + return []*exec.Cmd{ + exec.Command("kubectl", + "-n", namespace, + "rollout", "restart", + deployRef), + exec.Command("kubectl", + "-n", namespace, + "rollout", "status", + deployRef, + "--timeout=180s"), + } +} + // setupCertManager installs CertManager if needed for webhook tests. // Skips installation if CERT_MANAGER_INSTALL_SKIP=true or if already present. func setupCertManager() { diff --git a/test/e2e/external_clusters_drill_e2e_test.go b/test/e2e/external_clusters_drill_e2e_test.go index 58f8191..62141b5 100644 --- a/test/e2e/external_clusters_drill_e2e_test.go +++ b/test/e2e/external_clusters_drill_e2e_test.go @@ -33,9 +33,33 @@ const ( var _ = Describe("Replica clusters cross-cluster drill (D.5.10)", Ordered, Label("p2"), func() { BeforeAll(func() { - _, _ = utils.Run(exec.Command("kubectl", "create", "ns", externalNamespace)) + ensurePostgresClusterReady(externalNamespace, sourceClusterName) + + // Replica cluster가 pg_basebackup을 수행할 source role과 password Secret을 + // 테스트 안에서 준비해 p2 단독 실행 시에도 외부 smoke.sh에 의존하지 않는다. + _, _ = utils.Run(exec.Command("kubectl", "create", "secret", "generic", + "src-replicator-pwd", "-n", externalNamespace, + "--from-literal=username=replicator", + "--from-literal=password=replicator_pwd_v1")) + replicatorUser := fmt.Sprintf(` +apiVersion: postgres.keiailab.io/v1alpha1 +kind: PostgresUser +metadata: + name: src-replicator + namespace: %s +spec: + cluster: + name: %s + name: replicator + ensure: present + login: true + replication: true + passwordSecretRef: + name: src-replicator-pwd +`, externalNamespace, sourceClusterName) + applyManifest(replicatorUser) + waitPostgresUserApplied(externalNamespace, "src-replicator") - // Source cluster + 인증 정보 Secret 가정 (smoke.sh 가 사전 부트스트랩). // Replica cluster manifest: replica := fmt.Sprintf(` apiVersion: postgres.keiailab.io/v1alpha1 @@ -51,7 +75,7 @@ spec: externalClusters: - name: src connectionParameters: - host: %s-shard-0-0.%s-headless.%s.svc.cluster.local + host: %s-shard-0-0.%s-shard-0-headless.%s.svc.cluster.local port: "5432" user: replicator dbname: postgres @@ -83,17 +107,17 @@ spec: Eventually(func() string { out, _ := utils.Run(exec.Command("kubectl", "exec", fmt.Sprintf("%s-shard-0-0", replicaClusterName), "-n", externalNamespace, - "--", "psql", "-U", "postgres", "-t", "-A", "-c", + "-c", "postgres", "--", "psql", "-U", "postgres", "-t", "-A", "-c", "SELECT pg_is_in_recovery()::text")) return strings.TrimSpace(out) - }, 5*time.Minute, 10*time.Second).Should(Equal("t"), + }, 5*time.Minute, 10*time.Second).Should(Equal("true"), "replica 는 in_recovery=true 유지") }) It("source 의 데이터가 replica 에 도달 (read-only SELECT)", func() { out, _ := utils.Run(exec.Command("kubectl", "exec", fmt.Sprintf("%s-shard-0-0", replicaClusterName), "-n", externalNamespace, - "--", "psql", "-U", "postgres", "-t", "-A", "-c", + "-c", "postgres", "--", "psql", "-U", "postgres", "-t", "-A", "-c", "SELECT count(*) FROM pg_database")) Expect(strings.TrimSpace(out)).NotTo(Equal("0"), "source 의 system catalog 가 replica 에 streaming") diff --git a/test/e2e/helpers.go b/test/e2e/helpers.go new file mode 100644 index 0000000..3660c34 --- /dev/null +++ b/test/e2e/helpers.go @@ -0,0 +1,232 @@ +//go:build e2e +// +build e2e + +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package e2e + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "strings" + "sync" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/keiailab/postgres-operator/test/utils" +) + +type pgRuntimeImageBuild struct { + once sync.Once + err error +} + +var pgRuntimeImageMu sync.Mutex +var pgRuntimeImageBuilds = map[string]*pgRuntimeImageBuild{} + +type dockerImageLoad struct { + once sync.Once + err error +} + +var dockerImageMu sync.Mutex +var dockerImageLoads = map[string]*dockerImageLoad{} + +func ensurePGRuntimeImageMajor(major string) { + pgRuntimeImageMu.Lock() + build, ok := pgRuntimeImageBuilds[major] + if !ok { + build = &pgRuntimeImageBuild{} + pgRuntimeImageBuilds[major] = build + } + pgRuntimeImageMu.Unlock() + + build.once.Do(func() { + build.err = buildAndLoadPGRuntimeImageMajor(major) + }) + Expect(build.err).NotTo(HaveOccurred(), "PG runtime image %s must be available in kind", major) +} + +func buildAndLoadPGRuntimeImageMajor(major string) error { + localImage := fmt.Sprintf("local/pg:%s", major) + remoteImage := fmt.Sprintf("ghcr.io/keiailab/pg:%s", major) + + _, _ = fmt.Fprintf(GinkgoWriter, "[e2e] building PG runtime image major=%s\n", major) + buildPG := exec.Command("docker", "build", + "-f", "Dockerfile.pg", + "--build-arg", "PG_MAJOR="+major, + "-t", localImage, ".") + out, err := utils.Run(buildPG) + if err != nil { + return fmt.Errorf("build PG runtime image %s: %w\n%s", major, err, out) + } + + tagPG := exec.Command("docker", "tag", localImage, remoteImage) + out, err = utils.Run(tagPG) + if err != nil { + return fmt.Errorf("tag PG runtime image %s: %w\n%s", major, err, out) + } + + _, _ = fmt.Fprintf(GinkgoWriter, "[e2e] loading PG runtime image %s into kind\n", remoteImage) + if err := utils.LoadImageToKindClusterWithName(remoteImage); err != nil { + return fmt.Errorf("load PG runtime image %s into kind: %w", remoteImage, err) + } + return nil +} + +func ensureDockerImageLoaded(image string) { + dockerImageMu.Lock() + load, ok := dockerImageLoads[image] + if !ok { + load = &dockerImageLoad{} + dockerImageLoads[image] = load + } + dockerImageMu.Unlock() + + load.once.Do(func() { + load.err = pullAndLoadDockerImage(image) + }) + Expect(load.err).NotTo(HaveOccurred(), "container image %s must be available in kind", image) +} + +func pullAndLoadDockerImage(image string) error { + _, _ = fmt.Fprintf(GinkgoWriter, "[e2e] pulling linux/amd64 container image %s\n", image) + pull := exec.Command("docker", "pull", "--platform", "linux/amd64", image) + out, err := utils.Run(pull) + if err != nil { + return fmt.Errorf("pull container image %s: %w\n%s", image, err, out) + } + + _, _ = fmt.Fprintf(GinkgoWriter, "[e2e] loading container image %s into kind\n", image) + if err := loadDockerImageToKindForPlatform(image, "linux/amd64"); err != nil { + return fmt.Errorf("load container image %s into kind: %w", image, err) + } + return nil +} + +func loadDockerImageToKindForPlatform(image string, platform string) error { + clusterName := os.Getenv("KIND_CLUSTER") + if clusterName == "" { + clusterName = "kind" + } + + nodesOut, err := utils.Run(exec.Command("kind", "get", "nodes", "--name", clusterName)) + if err != nil { + return fmt.Errorf("list kind nodes for cluster %q: %w\n%s", clusterName, err, nodesOut) + } + nodes := strings.Fields(nodesOut) + if len(nodes) == 0 { + return fmt.Errorf("kind cluster %q has no nodes", clusterName) + } + + for _, node := range nodes { + if err := importDockerImageToKindNode(image, platform, node); err != nil { + return err + } + } + return nil +} + +func importDockerImageToKindNode(image string, platform string, node string) error { + saveCmd := exec.Command("docker", "save", image) + importCmd := exec.Command("docker", "exec", "--privileged", "-i", node, + "ctr", "--namespace=k8s.io", "images", "import", + "--platform", platform, + "--snapshotter=overlayfs", + "-") + + reader, writer := io.Pipe() + saveCmd.Stdout = writer + importCmd.Stdin = reader + + var saveErr bytes.Buffer + var importOut bytes.Buffer + saveCmd.Stderr = &saveErr + importCmd.Stdout = &importOut + importCmd.Stderr = &importOut + + if err := importCmd.Start(); err != nil { + _ = reader.Close() + _ = writer.Close() + return fmt.Errorf("start ctr import on kind node %s: %w", node, err) + } + if err := saveCmd.Start(); err != nil { + _ = reader.Close() + _ = writer.Close() + _ = importCmd.Process.Kill() + _ = importCmd.Wait() + return fmt.Errorf("start docker save for image %s: %w", image, err) + } + + saveWaitErr := saveCmd.Wait() + _ = writer.CloseWithError(saveWaitErr) + importWaitErr := importCmd.Wait() + _ = reader.Close() + + if saveWaitErr != nil { + return fmt.Errorf("docker save image %s: %w\n%s", image, saveWaitErr, saveErr.String()) + } + if importWaitErr != nil { + return fmt.Errorf("ctr import image %s on kind node %s: %w\n%s", image, node, importWaitErr, importOut.String()) + } + return nil +} + +func ensurePostgresClusterReady(namespace, name string) { + ensurePGRuntimeImageMajor("18") + + _, _ = utils.Run(exec.Command("kubectl", "create", "namespace", namespace)) + + manifest := fmt.Sprintf(`apiVersion: postgres.keiailab.io/v1alpha1 +kind: PostgresCluster +metadata: + name: %s + namespace: %s +spec: + postgresVersion: "18" + shardingMode: none + shards: + initialCount: 1 + replicas: 0 + storage: + size: 1Gi +`, name, namespace) + cmd := exec.Command("kubectl", "apply", "-f", "-") + cmd.Stdin = strings.NewReader(manifest) + out, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "apply PostgresCluster %s/%s: %s", namespace, name, out) + + Eventually(func() string { + out, _ := utils.Run(exec.Command("kubectl", "get", "postgrescluster", + name, "-n", namespace, + "-o", "jsonpath={.status.conditions[?(@.type==\"Ready\")].status}")) + return strings.TrimSpace(out) + }, 5*time.Minute, 10*time.Second).Should(Equal("True"), + "PostgresCluster %s/%s must become Ready", namespace, name) +} + +func applyManifest(manifest string) { + cmd := exec.Command("kubectl", "apply", "-f", "-") + cmd.Stdin = strings.NewReader(manifest) + out, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred(), "apply manifest: %s", out) +} + +func waitPostgresUserApplied(namespace, name string) { + Eventually(func() string { + out, _ := utils.Run(exec.Command("kubectl", "get", "postgresuser", + name, "-n", namespace, + "-o", "jsonpath={.status.applied}")) + return strings.TrimSpace(out) + }, 2*time.Minute, 5*time.Second).Should(Equal("true"), + "PostgresUser %s/%s must reach status.applied=true", namespace, name) +} diff --git a/test/e2e/hibernation_e2e_test.go b/test/e2e/hibernation_e2e_test.go index b3d0013..f232d7a 100644 --- a/test/e2e/hibernation_e2e_test.go +++ b/test/e2e/hibernation_e2e_test.go @@ -63,7 +63,7 @@ spec: // Marker row 삽입 (재기동 후 보존 검증용). _, _ = utils.Run(exec.Command("kubectl", "exec", fmt.Sprintf("%s-shard-0-0", pgHibernationCRName), "-n", pgHibernationNamespace, - "--", "psql", "-U", "postgres", "-c", + "-c", "postgres", "--", "psql", "-U", "postgres", "-c", "CREATE TABLE hibernation_marker(v text); INSERT INTO hibernation_marker VALUES ('keep-me');")) }) @@ -124,7 +124,7 @@ spec: out, _ := utils.Run(exec.Command("kubectl", "exec", fmt.Sprintf("%s-shard-0-0", pgHibernationCRName), "-n", pgHibernationNamespace, - "--", "psql", "-U", "postgres", "-t", "-A", "-c", + "-c", "postgres", "--", "psql", "-U", "postgres", "-t", "-A", "-c", "SELECT v FROM hibernation_marker LIMIT 1")) return strings.TrimSpace(out) }, 2*time.Minute, 5*time.Second).Should(Equal("keep-me"), diff --git a/test/e2e/imagecatalog_e2e_test.go b/test/e2e/imagecatalog_e2e_test.go index a8667e2..3e97f31 100644 --- a/test/e2e/imagecatalog_e2e_test.go +++ b/test/e2e/imagecatalog_e2e_test.go @@ -31,6 +31,9 @@ const ( var _ = Describe("ImageCatalog live rollout (D.5.9)", Ordered, Label("p2"), func() { BeforeAll(func() { + ensurePGRuntimeImageMajor("17") + ensurePGRuntimeImageMajor("18") + _, _ = utils.Run(exec.Command("kubectl", "create", "ns", imageCatalogNamespace)) catalogManifest := fmt.Sprintf(` @@ -41,9 +44,9 @@ metadata: namespace: %s spec: images: - - major: "17" + - major: 17 image: ghcr.io/keiailab/pg:17 - - major: "18" + - major: 18 image: ghcr.io/keiailab/pg:18 `, imageCatalogName, imageCatalogNamespace) cmd := exec.Command("kubectl", "apply", "-f", "-") @@ -58,11 +61,12 @@ metadata: name: %s namespace: %s spec: + postgresVersion: "17" imageCatalogRef: apiGroup: postgres.keiailab.io kind: ImageCatalog name: %s - major: "17" + major: 17 shards: initialCount: 1 replicas: 0 @@ -103,7 +107,7 @@ spec: It("Cluster spec.imageCatalogRef.major patch", func() { _, err := utils.Run(exec.Command("kubectl", "patch", "postgrescluster", imageCatalogCluster, "-n", imageCatalogNamespace, "--type=merge", - "-p", `{"spec":{"imageCatalogRef":{"major":"18"}}}`)) + "-p", `{"spec":{"postgresVersion":"18","imageCatalogRef":{"major":18}}}`)) Expect(err).NotTo(HaveOccurred()) }) @@ -119,7 +123,7 @@ spec: It("image-hash annotation drift 추적", func() { out, _ := utils.Run(exec.Command("kubectl", "get", "sts", imageCatalogCluster+"-shard-0", "-n", imageCatalogNamespace, - "-o", "jsonpath={.metadata.annotations.postgres\\.keiailab\\.io/image-hash}")) + "-o", "jsonpath={.spec.template.metadata.annotations.postgres\\.keiailab\\.io/postgres-image-catalog-sha256}")) Expect(strings.TrimSpace(out)).NotTo(BeEmpty(), "image-hash annotation must be set") }) }) diff --git a/test/e2e/pooler_e2e_test.go b/test/e2e/pooler_e2e_test.go index 630b14e..79bd90f 100644 --- a/test/e2e/pooler_e2e_test.go +++ b/test/e2e/pooler_e2e_test.go @@ -24,14 +24,21 @@ import ( ) const ( - poolerNamespace = "pg-pooler-e2e" - poolerCRName = "pg-pooler-test" - poolerClusterFor = "quickstart" + poolerNamespace = "pg-pooler-e2e" + poolerCRName = "pg-pooler-test" + poolerWorkloadName = poolerCRName + "-pooler" + poolerClusterFor = "quickstart" + poolerBuiltinRole = "keiailab_pooler_pgbouncer" + poolerImage = "ghcr.io/cloudnative-pg/pgbouncer:1.24.1" + poolerExporterImage = "quay.io/prometheuscommunity/pgbouncer-exporter:v0.10.0" ) var _ = Describe("Pooler PgBouncer live (D.5.4 + D.5.5)", Ordered, Label("p2"), func() { BeforeAll(func() { - _, _ = utils.Run(exec.Command("kubectl", "create", "ns", poolerNamespace)) + ensurePostgresClusterReady(poolerNamespace, poolerClusterFor) + ensureDockerImageLoaded(poolerImage) + ensureDockerImageLoaded(poolerExporterImage) + manifest := fmt.Sprintf(` apiVersion: postgres.keiailab.io/v1alpha1 kind: Pooler @@ -39,19 +46,25 @@ metadata: name: %s namespace: %s spec: - cluster: %s + cluster: + name: %s instances: 2 type: rw pgbouncer: + image: %s poolMode: transaction + exporter: + image: %s + port: 9127 + args: + - --pgBouncer.connectionString=postgres://%s@127.0.0.1:5432/pgbouncer?sslmode=disable parameters: + auth_type: trust + stats_users: %s max_client_conn: "200" default_pool_size: "25" -`, poolerCRName, poolerNamespace, poolerClusterFor) - cmd := exec.Command("kubectl", "apply", "-f", "-") - cmd.Stdin = strings.NewReader(manifest) - _, err := utils.Run(cmd) - Expect(err).NotTo(HaveOccurred()) +`, poolerCRName, poolerNamespace, poolerClusterFor, poolerImage, poolerExporterImage, poolerBuiltinRole, poolerBuiltinRole) + applyManifest(manifest) }) AfterAll(func() { @@ -62,7 +75,7 @@ spec: It("Deployment 2/2 Ready", func() { Eventually(func() string { out, _ := utils.Run(exec.Command("kubectl", "get", "deploy", - poolerCRName+"-pgbouncer", "-n", poolerNamespace, + poolerWorkloadName, "-n", poolerNamespace, "-o", "jsonpath={.status.readyReplicas}")) return strings.TrimSpace(out) }, 3*time.Minute, 5*time.Second).Should(Equal("2")) @@ -72,8 +85,8 @@ spec: Eventually(func() string { out, _ := utils.Run(exec.Command("kubectl", "run", "psql-test", "-n", poolerNamespace, "--rm", "-i", "--restart=Never", - "--image=ghcr.io/keiailab/pg:18", "--", - "psql", fmt.Sprintf("postgresql://postgres@%s-pgbouncer/postgres", poolerCRName), + "--image=ghcr.io/keiailab/pg:18", "--command", "--", + "psql", fmt.Sprintf("postgresql://%s@%s/postgres", poolerBuiltinRole, poolerWorkloadName), "-t", "-A", "-c", "SELECT 1")) return strings.TrimSpace(out) }, 1*time.Minute, 5*time.Second).Should(ContainSubstring("1")) @@ -100,10 +113,9 @@ spec: Context("PgBouncer exporter live scrape (D.5.5)", func() { It("/metrics endpoint pgbouncer_pools 노출", func() { - pod := poolerCRName + "-pgbouncer" out, _ := utils.Run(exec.Command("kubectl", "exec", - fmt.Sprintf("deploy/%s", pod), "-n", poolerNamespace, - "-c", "exporter", "--", + fmt.Sprintf("deploy/%s", poolerWorkloadName), "-n", poolerNamespace, + "-c", "pgbouncer-exporter", "--", "sh", "-c", "wget -qO- localhost:9127/metrics | grep -c pgbouncer_pools")) cnt := strings.TrimSpace(out) Expect(cnt).NotTo(Equal("0"), "pgbouncer_pools metric 노출") diff --git a/test/e2e/postgresdatabase_e2e_test.go b/test/e2e/postgresdatabase_e2e_test.go index 98971f2..b9e25e9 100644 --- a/test/e2e/postgresdatabase_e2e_test.go +++ b/test/e2e/postgresdatabase_e2e_test.go @@ -33,9 +33,34 @@ const ( var _ = Describe("PostgresDatabase live smoke (D.5.6)", Ordered, Label("p2"), func() { BeforeAll(func() { - _, _ = utils.Run(exec.Command("kubectl", "create", "ns", pgDatabaseNamespace)) - // 전제: PostgresCluster "quickstart" 가 동일 ns 에 Ready=True. - // 별 BeforeSuite 가 quickstart 를 부트스트랩하거나, smoke.sh 가 선 실행. + ensurePostgresClusterReady(pgDatabaseNamespace, pgClusterForDB) + + usersManifest := fmt.Sprintf(` +apiVersion: postgres.keiailab.io/v1alpha1 +kind: PostgresUser +metadata: + name: app-owner + namespace: %s +spec: + cluster: + name: %s + name: app_owner + disablePassword: true +--- +apiVersion: postgres.keiailab.io/v1alpha1 +kind: PostgresUser +metadata: + name: app-reader + namespace: %s +spec: + cluster: + name: %s + name: app_reader + disablePassword: true +`, pgDatabaseNamespace, pgClusterForDB, pgDatabaseNamespace, pgClusterForDB) + applyManifest(usersManifest) + waitPostgresUserApplied(pgDatabaseNamespace, "app-owner") + waitPostgresUserApplied(pgDatabaseNamespace, "app-reader") manifest := fmt.Sprintf(` apiVersion: postgres.keiailab.io/v1alpha1 @@ -44,7 +69,8 @@ metadata: name: %s namespace: %s spec: - cluster: %s + cluster: + name: %s name: app_db owner: app_owner ensure: present @@ -54,15 +80,11 @@ spec: schemas: - name: app owner: app_owner - privileges: - - schema: app - grantee: app_reader - privileges: [USAGE] + privileges: + - role: app_reader + privileges: [USAGE] `, pgDatabaseCRName, pgDatabaseNamespace, pgClusterForDB) - cmd := exec.Command("kubectl", "apply", "-f", "-") - cmd.Stdin = strings.NewReader(manifest) - out, err := utils.Run(cmd) - Expect(err).NotTo(HaveOccurred(), "apply PostgresDatabase: %s", out) + applyManifest(manifest) }) AfterAll(func() { @@ -83,7 +105,7 @@ spec: Eventually(func() string { out, _ := utils.Run(exec.Command("kubectl", "exec", fmt.Sprintf("%s-shard-0-0", pgClusterForDB), "-n", pgDatabaseNamespace, - "--", "psql", "-U", "postgres", "-t", "-A", "-c", + "-c", "postgres", "--", "psql", "-U", "postgres", "-t", "-A", "-c", "SELECT 1 FROM pg_database WHERE datname='app_db'")) return strings.TrimSpace(out) }, 2*time.Minute, 5*time.Second).Should(Equal("1")) @@ -92,13 +114,13 @@ spec: It("app schema + pg_stat_statements extension 적용 확인", func() { out, _ := utils.Run(exec.Command("kubectl", "exec", fmt.Sprintf("%s-shard-0-0", pgClusterForDB), "-n", pgDatabaseNamespace, - "--", "psql", "-U", "postgres", "-d", "app_db", "-t", "-A", "-c", + "-c", "postgres", "--", "psql", "-U", "postgres", "-d", "app_db", "-t", "-A", "-c", "SELECT 1 FROM information_schema.schemata WHERE schema_name='app'")) Expect(strings.TrimSpace(out)).To(Equal("1"), "app schema must exist") out, _ = utils.Run(exec.Command("kubectl", "exec", fmt.Sprintf("%s-shard-0-0", pgClusterForDB), "-n", pgDatabaseNamespace, - "--", "psql", "-U", "postgres", "-d", "app_db", "-t", "-A", "-c", + "-c", "postgres", "--", "psql", "-U", "postgres", "-d", "app_db", "-t", "-A", "-c", "SELECT 1 FROM pg_extension WHERE extname='pg_stat_statements'")) Expect(strings.TrimSpace(out)).To(Equal("1"), "pg_stat_statements extension") }) @@ -113,7 +135,7 @@ spec: Eventually(func() string { out, _ := utils.Run(exec.Command("kubectl", "exec", fmt.Sprintf("%s-shard-0-0", pgClusterForDB), "-n", pgDatabaseNamespace, - "--", "psql", "-U", "postgres", "-t", "-A", "-c", + "-c", "postgres", "--", "psql", "-U", "postgres", "-t", "-A", "-c", "SELECT count(*) FROM pg_database WHERE datname='app_db'")) return strings.TrimSpace(out) }, 1*time.Minute, 5*time.Second).Should(Equal("0"), diff --git a/test/e2e/postgresuser_e2e_test.go b/test/e2e/postgresuser_e2e_test.go index 6ccbb1f..18b1f94 100644 --- a/test/e2e/postgresuser_e2e_test.go +++ b/test/e2e/postgresuser_e2e_test.go @@ -32,7 +32,7 @@ const ( var _ = Describe("PostgresUser live smoke + rotation (D.5.7)", Ordered, Label("p2"), func() { BeforeAll(func() { - _, _ = utils.Run(exec.Command("kubectl", "create", "ns", pgUserNamespace)) + ensurePostgresClusterReady(pgUserNamespace, pgClusterForU) // Secret 사전 생성 (초기 password). _, _ = utils.Run(exec.Command("kubectl", "create", "secret", "generic", @@ -47,7 +47,8 @@ metadata: name: %s namespace: %s spec: - cluster: %s + cluster: + name: %s name: app_role ensure: present login: true @@ -55,13 +56,11 @@ spec: createrole: false passwordSecretRef: name: %s + userReclaimPolicy: delete inRoles: - postgres `, pgUserCRName, pgUserNamespace, pgClusterForU, pgUserSecret) - cmd := exec.Command("kubectl", "apply", "-f", "-") - cmd.Stdin = strings.NewReader(manifest) - out, err := utils.Run(cmd) - Expect(err).NotTo(HaveOccurred(), "apply PostgresUser: %s", out) + applyManifest(manifest) }) AfterAll(func() { @@ -82,7 +81,7 @@ spec: Eventually(func() string { out, _ := utils.Run(exec.Command("kubectl", "exec", fmt.Sprintf("%s-shard-0-0", pgClusterForU), "-n", pgUserNamespace, - "--", "psql", "-U", "postgres", "-t", "-A", "-c", + "-c", "postgres", "--", "psql", "-U", "postgres", "-t", "-A", "-c", "SELECT 1 FROM pg_roles WHERE rolname='app_role'")) return strings.TrimSpace(out) }, 1*time.Minute, 5*time.Second).Should(Equal("1")) @@ -91,7 +90,7 @@ spec: It("초기 password 로 connect SELECT 1 PASS", func() { out, _ := utils.Run(exec.Command("kubectl", "exec", fmt.Sprintf("%s-shard-0-0", pgClusterForU), "-n", pgUserNamespace, - "--", "psql", "postgresql://app_role:initial_pwd_v1@localhost/postgres", + "-c", "postgres", "--", "psql", pgUserAppRoleConninfo("initial_pwd_v1"), "-t", "-A", "-c", "SELECT 1")) Expect(strings.TrimSpace(out)).To(Equal("1")) }) @@ -110,7 +109,7 @@ spec: Eventually(func() string { out, _ := utils.Run(exec.Command("kubectl", "exec", fmt.Sprintf("%s-shard-0-0", pgClusterForU), "-n", pgUserNamespace, - "--", "psql", "postgresql://app_role:rotated_pwd_v2@localhost/postgres", + "-c", "postgres", "--", "psql", pgUserAppRoleConninfo("rotated_pwd_v2"), "-t", "-A", "-c", "SELECT 1")) return strings.TrimSpace(out) }, 1*time.Minute, 5*time.Second).Should(Equal("1"), @@ -120,7 +119,7 @@ spec: It("이전 password 는 거부", func() { out, _ := utils.Run(exec.Command("kubectl", "exec", fmt.Sprintf("%s-shard-0-0", pgClusterForU), "-n", pgUserNamespace, - "--", "psql", "postgresql://app_role:initial_pwd_v1@localhost/postgres", + "-c", "postgres", "--", "psql", pgUserAppRoleConninfo("initial_pwd_v1"), "-t", "-A", "-c", "SELECT 1")) Expect(out).To(ContainSubstring("authentication failed"), "이전 password 인증 거부") @@ -135,7 +134,7 @@ spec: Eventually(func() string { out, _ := utils.Run(exec.Command("kubectl", "exec", fmt.Sprintf("%s-shard-0-0", pgClusterForU), "-n", pgUserNamespace, - "--", "psql", "-U", "postgres", "-t", "-A", "-c", + "-c", "postgres", "--", "psql", "-U", "postgres", "-t", "-A", "-c", "SELECT count(*) FROM pg_roles WHERE rolname='app_role'")) return strings.TrimSpace(out) }, 1*time.Minute, 5*time.Second).Should(Equal("0")) @@ -149,6 +148,15 @@ func base64Encode(s string) string { return base64stdEncode(s) } +func pgUserAppRoleConninfo(password string) string { + out, _ := utils.Run(exec.Command("kubectl", "get", "pod", + fmt.Sprintf("%s-shard-0-0", pgClusterForU), "-n", pgUserNamespace, + "-o", "jsonpath={.status.podIP}")) + podIP := strings.TrimSpace(out) + Expect(podIP).NotTo(BeEmpty(), "PostgresUser e2e pod IP must be available") + return fmt.Sprintf("host=%s user=app_role password=%s dbname=postgres", podIP, password) +} + func base64stdEncode(s string) string { const t = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" in := []byte(s) From e60275117297ae28f7d41243e1149aaf2ad13761 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Thu, 25 Jun 2026 16:51:20 +0900 Subject: [PATCH 005/109] =?UTF-8?q?feat(pooler):=20PgBouncer=20stats=5Fuse?= =?UTF-8?q?rs=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PgBouncer 동적 파라미터 허용 목록에 stats_users 를 추가한다. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01H4fFn82hrtvuCfuZnBMaqZ --- internal/controller/pooler_controller.go | 1 + internal/controller/pooler_controller_test.go | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/internal/controller/pooler_controller.go b/internal/controller/pooler_controller.go index feaaba1..1670ce9 100644 --- a/internal/controller/pooler_controller.go +++ b/internal/controller/pooler_controller.go @@ -419,6 +419,7 @@ func isSupportedPgBouncerParameter(key string) bool { "server_tls_protocols", "server_tls_sslmode", "stats_period", + "stats_users", "suspend_timeout", "tcp_defer_accept", "tcp_keepalive", diff --git a/internal/controller/pooler_controller_test.go b/internal/controller/pooler_controller_test.go index 5e90284..6abb5ca 100644 --- a/internal/controller/pooler_controller_test.go +++ b/internal/controller/pooler_controller_test.go @@ -216,6 +216,36 @@ func TestPoolerReconcileRejectsUnsupportedPgBouncerParameter(t *testing.T) { } } +func TestPoolerReconcileAllowsStatsUsersPgBouncerParameter(t *testing.T) { + t.Parallel() + scheme := newScheme(t) + cluster := newPoolerCluster() + pooler := newPooler() + pooler.Spec.PgBouncer.Parameters["stats_users"] = "keiailab_pooler_pgbouncer" + authSecret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{ + Name: "demo-pooler-auth", + Namespace: "default", + }, Data: map[string][]byte{"userlist.txt": []byte(`"keiailab_pooler_pgbouncer" "md5hash"`)}} + c := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(cluster, pooler, authSecret). + WithStatusSubresource(&postgresv1alpha1.Pooler{}). + Build() + + r := &PoolerReconciler{Client: c, Scheme: scheme} + got := reconcilePoolerOnce(t, r, c, pooler) + + if got.Status.Phase == postgresv1alpha1.PoolerFailed { + t.Fatalf("phase = %q, want stats_users accepted", got.Status.Phase) + } + var cm corev1.ConfigMap + if err := c.Get(context.Background(), client.ObjectKey{Namespace: "default", Name: PoolerConfigMapName(pooler.Name)}, &cm); err != nil { + t.Fatalf("ConfigMap get: %v", err) + } + if !strings.Contains(cm.Data["pgbouncer.ini"], "stats_users = keiailab_pooler_pgbouncer") { + t.Fatalf("pgbouncer.ini missing stats_users:\n%s", cm.Data["pgbouncer.ini"]) + } +} + func TestPoolerReconcileRejectsOperatorOwnedPgBouncerParameter(t *testing.T) { t.Parallel() scheme := newScheme(t) From 910ad01a40fc8abf255363509f53ab6ca741d32f Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Thu, 25 Jun 2026 16:51:21 +0900 Subject: [PATCH 006/109] =?UTF-8?q?docs:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EB=B6=84=EC=84=9D=20=EB=AC=B8=EC=84=9C=20+=20dev?= =?UTF-8?q?=20=ED=99=98=EA=B2=BD=20=EA=B0=80=EC=9D=B4=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROJECT_OVERVIEW / FEATURE_DEEP_DIVE / TEST_ANALYSIS / E2E_TEST_REPORT(PITR drill 완료 반영) 와 devcontainer·WSL 개발환경 가이드를 추가한다. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01H4fFn82hrtvuCfuZnBMaqZ --- docs/E2E_TEST_REPORT.ko.md | 898 ++++++++++++++++++++++++++++++ docs/FEATURE_DEEP_DIVE.md | 959 +++++++++++++++++++++++++++++++++ docs/PROJECT_OVERVIEW.md | 260 +++++++++ docs/TEST_ANALYSIS.md | 339 ++++++++++++ docs/dev-setup-devcontainer.md | 152 ++++++ docs/dev-setup-wsl.md | 270 ++++++++++ 6 files changed, 2878 insertions(+) create mode 100644 docs/E2E_TEST_REPORT.ko.md create mode 100644 docs/FEATURE_DEEP_DIVE.md create mode 100644 docs/PROJECT_OVERVIEW.md create mode 100644 docs/TEST_ANALYSIS.md create mode 100644 docs/dev-setup-devcontainer.md create mode 100644 docs/dev-setup-wsl.md diff --git a/docs/E2E_TEST_REPORT.ko.md b/docs/E2E_TEST_REPORT.ko.md new file mode 100644 index 0000000..75445ef --- /dev/null +++ b/docs/E2E_TEST_REPORT.ko.md @@ -0,0 +1,898 @@ +# E2E 테스트 종합 분석 보고서 + +## 2026-06-24 추가 검증: PITR restore drill 보강 완료 + +이번 추가 작업에서는 `PITR restore + checksum drill (D.3.2)`를 `PContext` 상태에서 실제 실행 가능한 `Context`로 전환하고, live Kind 환경에서 실패 원인을 단계적으로 제거했다. + +### 최종 결과 + +| 항목 | 결과 | +|------|------| +| 대상 | `go test -count=1 -tags=e2e ./test/e2e -ginkgo.label-filter=p1 -ginkgo.focus='PITR restore'` | +| 환경 | Kind `postgres-operator-e2e-pitr-codex`, PostgreSQL 18, CertManager skip | +| 최종 결과 | **7 PASS / 0 FAIL / 60 SKIP** | +| 핵심 검증 | full backup 성공, WAL archive 확인, restore Job 성공, `before` row 존재, `after` row 부재, checksum failure 0 | + +### RCA와 수정 요약 + +1. filesystem pgBackRest repo가 `EmptyDir`에 있어 restore 시 사라질 수 있었다. repo 경로를 data PVC 내부 `/var/lib/postgresql/data/pgbackrest`로 고정했다. +2. pgBackRest spool 기본 경로 `/var/spool/pgbackrest`가 read-only/non-root 환경에서 쓰기 실패했다. dataplane Pod와 restore Job에 `ephemeral-pgbackrest-spool` EmptyDir를 추가했다. +3. restore 후 PostgreSQL이 repo env 없는 기본 `restore_command`로 WAL을 찾지 못했다. restore 완료 후 `postgresql.auto.conf`의 `restore_command`를 repo env와 `--pg1-path` 포함 형태로 재작성했다. +4. `--archive-check=n` 때문에 WAL archive 없이도 full backup이 성공으로 표시될 수 있었다. 이 우회를 제거해 복구 불가능한 백업을 실패로 드러나게 했다. +5. `archive_command`가 `archive-push`에 `--pg1-user`/`--pg1-database`를 넘겨 pgBackRest가 거부했다. `stanza-create` 옵션과 `archive-push` 옵션을 분리했다. +6. shell positional arg `$1` 전달은 PostgreSQL config parser/outer shell에서 깨지기 쉬웠다. `archive-push "%p"`로 바꿔 PostgreSQL의 WAL path placeholder를 직접 전달했다. +7. 저트래픽 E2E에서는 WAL segment가 자연스럽게 archive되지 않는다. `pg_switch_wal()` 후 해당 WAL 파일이 repo에 생길 때까지 기다리도록 했다. +8. targetTime을 초 단위로 잘라 restore하면 `before` 트랜잭션 직전으로 복구될 수 있었다. `clock_timestamp()` + `RFC3339Nano` + pgBackRest microsecond 포맷으로 시점 정밀도를 보존했다. +9. restore 직후 Pod 재생성 타이밍 때문에 checksum 쿼리가 단발 실패할 수 있었다. checksum 검증도 `Eventually`로 감쌌다. + +### 검증 명령 + +```bash +make manifests generate +make test + +go test -count=1 -tags=e2e ./test/e2e \ + -timeout 30m -v \ + -ginkgo.v \ + -ginkgo.label-filter=p1 \ + -ginkgo.focus='PITR restore' +``` + +### Insight + +PITR은 “restore 명령이 성공했다”만으로는 품질 기준을 만족하지 않는다. 백업 시점의 필수 WAL, target 이후 WAL switch/archive, recovery target 정밀도, restore 후 PostgreSQL 재기동까지 모두 연결되어야 한다. 이번 수정은 실패를 숨기던 `--archive-check=n`을 제거하고, 실제 archive 실패를 먼저 드러낸 뒤 그 원인을 고쳤다는 점에서 오픈소스 operator 기준에 더 가깝다. + +--- + +> 실행 일시: 2026-06-22 06:12 ~ 06:32 KST +> 명령: `CERT_MANAGER_INSTALL_SKIP=true make test-e2e-failover` +> 환경: Dev Container (golang:1.26, Docker-in-Docker) · Kind v1.36.1 · PostgreSQL 18 +> 소요 시간: 645초 (약 10분 45초) +> 결과: 4 PASS · 7 FAIL · 9 PENDING · 47 SKIP (11/67 spec 실행) + +--- + +## 1. 프로젝트 방향성 요약 + +postgres-operator는 **MIT 라이선스 기반 자체 구축 PostgreSQL Kubernetes Operator**다. +외부 PostgreSQL operator를 fork·embed하지 않고, K8s 위의 vanilla PostgreSQL 18+에서 +`G0(Day-0 배포) → G1(단일 샤드 HA) → G2(운영 품질) → G3(샤딩 기반) → G4(온라인 리샤딩) → G5(Distributed SQL) → G6(1.0.0 GA)` 순으로 진화하는 로드맵을 따른다. + +> 아래 완성도는 **2026-06-22 failover drill 실행 시점 스냅샷**이다. 이후 PITR restore drill 완료 +> (상단 "2026-06-24 추가 검증" 섹션, 7 PASS)와 PostgresDatabase/User `status.applied` live 검증, +> E2E fixture 독립화가 반영되기 전 값이므로 G1·G2는 실제로 이보다 진척돼 있다. 정확한 재산정은 전체 +> p1/p2 E2E 재실행이 선행돼야 한다. + +| Gate | 목표 | 현재 완성도 | +|------|------|------------| +| G0 | Day-0 배포 | **100%** | +| G1 | Single-shard HA (failover + sync repl) | **81%** (live chaos/shard-identity 재검증 필요) | +| G2 | 운영 품질 (Pooler / Hibernation / RBAC / Observability) | **72%** (live drill 미완) | +| G3 | Sharding foundation | 37% | +| G4~G5 | Resharding · Distributed SQL | 0% | +| G6 | 1.0.0 GA | 12% | + +**이번 e2e 테스트(`label=p2`)의 목적**: G1 HA failover 경로와 G2 운영 기능(Pooler, Hibernation, ImageCatalog, DB/User 선언적 관리)이 **실제 Kind 클러스터 + 실제 PostgreSQL Pod 위에서** 설계대로 동작하는지 검증. + +--- + +## 2. 테스트 환경 구성 (BeforeSuite) + +| 단계 | 내용 | 결과 | +|------|------|------| +| Kind 클러스터 생성 | `kindest/node:v1.36.1` 노드 1개 | ✅ 완료 | +| Operator 이미지 빌드 | `make docker-build IMG=ghcr.io/keiailab/postgres-operator:0.3.0-alpha` | ✅ 완료 (약 1분 22초, e2e 전용 임시 테스트 태그) | +| PG 런타임 이미지 빌드 | `Dockerfile.pg --build-arg PG_MAJOR=18` → `ghcr.io/keiailab/pg:18` | ✅ 완료 | +| Kind에 이미지 로드 | `kind load docker-image ...` | ✅ 완료 | +| CRD + RBAC 설치 | `kubectl apply --server-side -f dist/install.yaml` | ✅ 완료 | +| CertManager | `CERT_MANAGER_INSTALL_SKIP=true` → 설치 생략 (Webhook 없음) | ✅ 생략 정상 | + +> **dist/install.yaml** : Webhook 설정 없이 8 CRD + RBAC + Deployment만 포함. CertManager 불필요하도록 설계된 것이 확인됨. + +--- + +## 3. 통과한 테스트 케이스 (4개) + +### TC-PASS-01: Failover — ord-0 초기 primary 선출 +| 항목 | 내용 | +|------|------| +| **로드맵 위치** | G1 · `internal/controller/failover/` · ROADMAP `[x] Primary-delete e2e baseline` | +| **테스트 파일** | `test/e2e/failover_e2e_test.go:161` (It #1) | +| **테스트 니즈** | PostgresCluster 생성 시 StatefulSet ord-0이 primary로 자동 선출되고, `status.shards[0].primary.pod`가 `failover-shard-0-0`으로 기록되는지 확인 | +| **기대 결과** | `status.shards[0].primary.pod=failover-shard-0-0`, `ready=true` (2분 이내) | +| **실제 결과** | **통과** | +| **성공 근거** | `PostgresClusterReconciler`가 StatefulSet 생성 후 instance manager의 `instance-status` annotation을 읽어 ShardStatus를 집계. ord-0이 `pg_ctl start`로 primary 기동 후 annotation 게시 → reconciler가 status에 반영 | + +--- + +### TC-PASS-02: Failover — ord-1 standby 부팅 + role=replica annotation +| 항목 | 내용 | +|------|------| +| **로드맵 위치** | G1 · `[x] Replica rejoin (pg_basebackup)` | +| **테스트 파일** | `test/e2e/failover_e2e_test.go:175` (It #2) | +| **테스트 니즈** | ord-1이 ord-0에서 `pg_basebackup`으로 초기 데이터를 받아와 streaming standby로 부팅되는지, `standby.signal` 파일이 PGDATA에 존재하는지 확인 | +| **기대 결과** | `instance-status` annotation의 `role=replica` + `PGDATA/standby.signal` 존재 (3분 이내) | +| **실제 결과** | **통과** | +| **성공 근거** | instance manager init container가 `pg_basebackup --checkpoint=fast` 실행 → `standby.signal` 생성 → PostgreSQL이 streaming replication 모드로 기동. `A.1 basebackup drill PASS (T31, 2026-05-17)` 로드맵 항목과 정합 | + +--- + +### TC-PASS-03: Hibernation — annotation on → STS scale-down to 0 +| 항목 | 내용 | +|------|------| +| **로드맵 위치** | G2 · `[~] 선언적 hibernation` (ROADMAP: "live kind 검증 pending") | +| **테스트 파일** | `test/e2e/hibernation_e2e_test.go:75` | +| **테스트 니즈** | `cnpg.io/hibernation=on` annotation 부착 시 operator가 모든 shard StatefulSet의 `spec.replicas=0`으로 축소하는지 확인 | +| **기대 결과** | STS spec.replicas = 0 (2분 이내) | +| **실제 결과** | **통과** (30.6초) | +| **성공 근거** | `internal/controller/postgrescluster_controller.go`의 reconcile loop가 annotation을 감지해 STS `replicas=0` 패치. 환경변수 `SMOKE_HIBERNATION=1` smoke 테스트에서 사전 검증된 경로 | + +--- + +### TC-PASS-04: Hibernation — status.phase=Hibernated 전이 +| 항목 | 내용 | +|------|------| +| **로드맵 위치** | G2 · `[~] 선언적 hibernation` | +| **테스트 파일** | `test/e2e/hibernation_e2e_test.go:90` | +| **테스트 니즈** | Pod 스케일다운 후 `PostgresCluster.status.phase`가 `Hibernated`로 전이되는지 확인 | +| **기대 결과** | `status.phase = Hibernated` | +| **실제 결과** | **통과** (0.06초, 앞 TC 직후 즉시 확인) | +| **성공 근거** | `internal/controller/status.go`의 `aggregateStatus`가 "모든 STS replicas=0" 조건을 감지해 phase를 Hibernated로 설정 | + +--- + +## 4. 실패한 테스트 케이스 (7개) + +### TC-FAIL-01: Failover — primary kill 후 RTO 30초 이내 신규 primary 선출 +| 항목 | 내용 | +|------|------| +| **로드맵 위치** | G1 · `[ ] 자동 failover 로직` (ROADMAP 미완성 항목) | +| **테스트 파일** | `test/e2e/failover_e2e_test.go:206` (It #3) | +| **테스트 니즈** | `kubectl delete pod --force --grace-period=0`으로 primary를 강제 종료한 뒤, operator가 alive replica를 탐지해 30초 안에 새 primary를 선출하는 자동 failover 경로 검증 | +| **기대 결과** | 45초 window 안에 `status.shards[0].primary.pod`가 신규 Pod로 변경 + `ready=true`. 실측 RTO < 30초 | +| **실제 결과** | **실패** — 45초 timeout. `failover-shard-0-0=true` 유지 (primary 미변경) | +| **실패 메시지** | `primary still failover-shard-0-0 (no failover): failover-shard-0-0=true` | + +**근본 원인 분석**: + +``` +[현상] primary pod force-delete → K8s StatefulSet controller가 ~30초 안에 동일 PVC로 + 동일 ordinal pod를 재생성 → operator가 "self-heal"로 판단, failover 미발동. + +[원인 ①] force-delete 기반 e2e 시나리오가 live failover 조건을 만들지 못함 + - 현재 Reconcile()에는 clusterFailoverDecision() → shouldPromoteAfterDebounce() → + executeClusterPromotion() 경로가 존재함 + - 즉 "reconcile 루프와 failover 감지 미연결"은 현재 소스 기준 RCA가 아님 + - 실패 재현에서는 StatefulSet self-heal이 동일 ordinal을 빠르게 복구해 + promotion 조건이 지속 관측되지 못함 + +[원인 ②] StatefulSet self-heal이 failover 감지 window를 선점 + - force-delete 후 STS가 동일 PVC+ordinal로 pod를 ~30초 안에 재생성 + - failover debounce/detect window(설계: 노드 실패 감지 후 수 초) 이전에 + pod가 복구되므로 operator 입장에서 "transient pod restart"로 처리됨 + - 진짜 failover(노드 상실 / PVC 상실)는 pod가 재생성되지 않는 상황이어야 발동 + +[원인 ③] ADR-0027: shard-identity 재설계 필요 + - chaos 코드 주석(failover_chaos_test.go:91~100): "True failover (node loss / PVC loss) + needs the ground-up shard-identity redesign tracked by ADR-0027" + - 현 구조는 ordinal 기반 식별로 인해 promotion 후 "어떤 pod가 새 primary인지"를 + 외부에서 안정적으로 지정하기 어려움 +``` + +**ROADMAP 상태**: `[ ] 자동 failover 로직` — 현재 소스에는 failover trigger path가 있으나, StatefulSet self-heal을 넘어서는 live chaos 시나리오와 shard-identity transition 검증이 남아 있음. + +--- + +### TC-FAIL-02: Hibernation — PVC 보존 (delete 금지) +| 항목 | 내용 | +|------|------| +| **로드맵 위치** | G2 · `[~] 선언적 hibernation` (envtest 완료, live kind 검증 미완) | +| **테스트 파일** | `test/e2e/hibernation_e2e_test.go:99` | +| **테스트 니즈** | STS scale-down 후 PostgreSQL 데이터 PVC가 보존되어 재기동 시 데이터 연속성을 보장하는지 확인 | +| **기대 결과** | `kubectl get pvc -l postgres.keiailab.io/cluster=pg-hibernation-test` → 1개 이상 | +| **실제 결과** | **실패** — PVC 목록 빈 배열 `[]` | +| **실패 메시지** | `hibernation 후 PVC 보존되어야 함: Expected <[]string\|len:0> not to be empty` | + +**근본 원인 분석**: + +``` +[원인] PVC에 postgres.keiailab.io/cluster= 레이블 미부착 + - StatefulSet의 volumeClaimTemplate으로 생성된 PVC에는 기본적으로 + STS controller가 설정하는 레이블만 존재 + - operator가 PVC 생성 시 postgres.keiailab.io/cluster 레이블을 명시적으로 + 부착하지 않아 label selector로 조회 시 0건 반환 + - PVC 자체는 존재하지만 조회 방법(label selector)과 실제 레이블이 불일치 + +[확인 필요] internal/controller/builders.go에서 PVC volumeClaimTemplate 생성 시 + 레이블 주입 여부 → 미구현 또는 다른 레이블 키를 사용하는 것으로 추정 +``` + +--- + +### TC-FAIL-03: Replica Cluster — streaming standby 도달 +| 항목 | 내용 | +|------|------| +| **로드맵 위치** | G2 · `[~] Replica cluster / externalClusters` (live cross-cluster drill 미완) | +| **테스트 파일** | `test/e2e/external_clusters_drill_e2e_test.go:82` | +| **테스트 니즈** | Source cluster A에서 `pg_basebackup`으로 데이터를 받아온 Replica cluster B가 streaming standby 모드(`pg_is_in_recovery()=true`)에 도달하는지 검증 | +| **기대 결과** | `SELECT pg_is_in_recovery()::text` → `true` (5분 이내) | +| **실제 결과** | **초기 실패** — DNS 해석 실패 및 standalone replica의 failover 경로 진입으로 streaming standby 유지 불가 | + +**근본 원인 분석**: + +``` +[원인 ①] Source cluster 사전 부트스트랩 누락 + 테스트 코드(external_clusters_drill_e2e_test.go:38) 주석: + "Source cluster + 인증 정보 Secret 가정 (smoke.sh 가 사전 실행)" + → 이번 테스트 실행에서는 "pg-source" 클러스터가 존재하지 않음 + → Secret "src-replicator-pwd"도 없음 + +[원인 ②] DNS 해석 실패 + PostgreSQL 로그: "could not translate host name + 'failover-shard-0-0.failover-shard-0-headless.pg-failover-e2e.svc.cluster.local' + to address: Name or service not known" + → Replica cluster가 pg-failover-e2e 네임스페이스의 cluster를 참조하려 했으나 + 해당 네임스페이스는 별개의 테스트 클러스터(failover)이며 replicator 계정 없음 + +[원인 ③] 크로스-클러스터 네트워크 전제 조건 미충족 + - ROADMAP에 "ordinal-0 외부 pg_basebackup + password passfile + TLS client cert" + 완료로 표기되어 있으나, 실제 e2e에서는 source cluster 준비 + replicator Secret + 사전 생성이 필요 (테스트 BeforeAll에서 처리하지 않음) +``` + +--- + +### TC-FAIL-04: PostgresDatabase — status.applied=true 미도달 +| 항목 | 내용 | +|------|------| +| **로드맵 위치** | G2 · `[~] CRD PostgresDatabase` (Live smoke 검증 pending) | +| **테스트 파일** | `test/e2e/postgresdatabase_e2e_test.go:73` | +| **테스트 니즈** | `PostgresDatabase` CR 적용 시 operator가 실제 PostgreSQL에 database + schema + privilege를 생성하고 `status.applied=true`를 설정하는지 확인 | +| **기대 결과** | `status.applied = true` (2분 이내) | +| **실제 결과** | **실패** — timeout | + +**근본 원인 분석**: + +``` +[원인 ①] 전제 클러스터 "quickstart" 없음 + - 테스트 코드 주석: "전제: PostgresCluster 'quickstart'가 동일 ns에 Ready=True. + 별 BeforeSuite가 quickstart를 부트스트랩하거나, smoke.sh가 선 실행" + - p2 label 테스트만 실행 시 quickstart 클러스터가 없어 PostgresDatabase + controller가 ready primary를 찾지 못함 + +[원인 ②] live e2e와 unit 검증 범위가 분리되어 있음 + - 현재 unit 테스트에서는 ready primary 대상 reconcile 후 `status.applied=true` 경로가 확인됨 + - 따라서 이 실패의 1차 RCA는 quickstart 클러스터/owner user 등 live fixture 전제조건 미충족 + - 남은 작업은 독립 fixture 기반으로 e2e를 재실행해 실제 PostgreSQL Pod 위의 SQL 적용까지 확인하는 것 +``` + +--- + +### TC-FAIL-05: PostgresUser — status.applied=true 미도달 +| 항목 | 내용 | +|------|------| +| **로드맵 위치** | G2 · `[~] CRD PostgresUser` (Live smoke 검증 pending) | +| **테스트 파일** | `test/e2e/postgresuser_e2e_test.go:64` | +| **테스트 니즈** | `PostgresUser` CR 적용 시 PostgreSQL에 role이 생성되고 `status.applied=true` 설정 확인 | +| **기대 결과** | `status.applied = true` | +| **실제 결과** | **실패** — TC-FAIL-04와 동일 원인 | + +**근본 원인**: PostgresDatabase와 동일 — `quickstart` 클러스터 부재가 1차 원인. `status.applied` unit 경로는 확인됐으므로 독립 fixture 기반 live e2e 재검증이 필요. + +--- + +### TC-FAIL-06: Pooler — PgBouncer Deployment 2/2 Ready +| 항목 | 내용 | +|------|------| +| **로드맵 위치** | G2 · `[~] Connection pooler (PgBouncer)` (ROADMAP: "Pooler Service psql smoke 2026-05-12 kind에서 통과") | +| **테스트 파일** | `test/e2e/pooler_e2e_test.go:54` | +| **테스트 니즈** | `Pooler` CR 생성 시 2개의 PgBouncer Pod가 Ready 상태에 도달하는지 확인 | +| **기대 결과** | Deployment readyReplicas = 2 (3분 이내) | +| **실제 결과** | **실패** — BeforeAll 단계에서 timeout | + +**근본 원인 분석**: + +``` +[원인] Pooler가 참조하는 PostgresCluster "quickstart"가 해당 namespace에 없음 + - 테스트에서 Pooler.spec.cluster = "quickstart"를 참조 + - "pg-pooler-e2e" namespace에는 "quickstart" 클러스터가 없음 + - PgBouncer의 userlist.txt 생성을 위해 primary cluster의 Secret이 필요한데 + 참조 cluster 부재로 Secret 조회 실패 → Deployment Pod가 ContainerCreating/Error + +[참고] ROADMAP에 "Pooler Service psql smoke PASS (2026-05-12)"가 기록되어 있으나, + 이는 hack/smoke.sh로 quickstart 클러스터를 먼저 준비한 뒤 실행한 시나리오. + 독립 실행(fresh kind, p2 label only)에서는 사전 준비 없이는 재현 불가. +``` + +--- + +### TC-FAIL-07: ImageCatalog — STS 이미지 교체 (pg:17 → pg:18 rollout) +| 항목 | 내용 | +|------|------| +| **로드맵 위치** | G2 · `[~] ImageCatalog / ClusterImageCatalog` (live rollout 측정 pending) | +| **테스트 파일** | `test/e2e/imagecatalog_e2e_test.go:52` | +| **테스트 니즈** | ImageCatalog에서 major=17 이미지를 참조하는 클러스터를 생성 후 kind에서 실제로 해당 이미지로 STS가 기동하는지 확인 | +| **기대 결과** | StatefulSet 이미지 = `ghcr.io/keiailab/pg:17` | +| **실제 결과** | **실패** | + +**근본 원인**: + +``` +[원인] pg:17 이미지 Kind 클러스터 미적재 + - Dockerfile.pg는 PG_MAJOR 인수로 빌드 + - 이번 테스트에서는 PG_MAJOR=18만 빌드하여 Kind에 로드 + - pg:17 이미지가 없으면 ImagePullBackOff → Pod 미기동 → STS 이미지 확인 불가 + - 추가로 ImageCatalog controller의 catalog → STS 이미지 매핑 live 검증도 pending 상태 +``` + +--- + +## 5. PENDING 항목 — 미구현 사유와 해결 경로 + +> 2026-06-22 기준 PENDING 9개. 이후 **그룹 B(PITR) 4개는 2026-06-24 전부 해소**(상단 섹션 참조) → 잔존 5개. +> 잔존 PENDING의 핵심은 그룹 A(자동 failover live drill)이며, reconcile 연결·promotion·fencing 코드는 +> 완료 상태이고 node/PVC loss 계열 live 재검증만 남았다. + +### PENDING 그룹 A: 자동 Failover 프로모션 (GA #248, ADR-0027) + +관련 파일: `test/e2e/failover_chaos_test.go:101` (`PContext`) + +PENDING 항목 (4개): +- 초기 primary 식별 (chaos) +- Primary force delete (chaos) +- replica가 새 primary로 promotion (RTO < 60s) +- 이전 primary가 standby로 rejoin +- Cluster Ready=True 복귀 + +**구현/검증 상태**: + +| 계층 | 구현 상태 | +|------|-----------| +| `DetectPrimaryFailure()` (detection.go) | **완료** — 순수 함수, 9 unit test 통과 | +| `SelectPromotionCandidate()` (detection.go) | **완료** — LagBytes 오름차순 정렬 | +| `BuildPromotionPlan()` (promotion.go) | **완료** — 4단계 플랜 생성 | +| `PromoteFromDecision()` (promotion.go) | **완료** — Promoter interface 호출 | +| reconcile 루프 → failover 트리거 연결 | **코드상 완료** — `clusterFailoverDecision()` → `shouldPromoteAfterDebounce()` → `executeClusterPromotion()` 경로 존재 | +| shard-identity 재설계 (ADR-0027) | **설계 완료, 구현 진행 중** | + +**라이브 RCA (2026-06-16 chaos 코드 주석)**: StatefulSet이 force-delete된 primary Pod를 동일 PVC·ordinal로 ~30초 내에 재생성하여 failover detect/debounce window를 선점. 진짜 failover는 노드 상실·PVC 상실 시나리오에서만 발동 가능. + +**해결 경로** (ADR-0027 P1~P6 단계별): + +``` +Step 1 (P1, 즉시 착수 가능): + - 격리 label 헬퍼 추가: postgres.keiailab.io/reshard-target= + - TargetShardStatefulSetName() 함수 구현 + - unit test로 검증 + +Step 2 (현 코드 기준 완료, live 재검증 필요): + - PostgresClusterReconciler.Reconcile()에서 failover decision 산출 후 + debounce window가 충족되면 executeClusterPromotion() 실행 + - 남은 과제는 force-delete가 아닌 node loss/PVC loss 계열 live drill에서 + promotion 조건과 RTO를 검증하는 것 + +Step 3 (ADR-0027 P6): shard-identity transition + - 승격된 pod를 ordinal primary로 공식화 + - reshard-target label → shard ordinal label 전환 + - operator-driven single-authority 패턴으로 race 회피 + +Step 4 (e2e 검증): + - PContext → Context로 전환 (이 한 줄이 PENDING 해제 기준) + - KIND_CLUSTER 환경에서 노드 drain 또는 taint를 사용한 + "진짜 노드 불가 접근" 시나리오로 테스트 +``` + +--- + +### PENDING 그룹 B: PITR Restore + Checksum (GA #248) — ✅ 2026-06-24 전부 해소 + +관련 파일: `test/e2e/pitr_restore_e2e_test.go` + +> **상태 갱신 (2026-06-24)**: 본 그룹의 4개 항목은 모두 `PContext` → `Context` 로 전환되어 +> live Kind 환경에서 **7 PASS / 0 FAIL** 로 통과했다. 상세 RCA·수정 내역은 본 문서 상단 +> "2026-06-24 추가 검증: PITR restore drill 보강 완료" 섹션 참조. 아래 항목/표는 이력 보존용이다. + +해소된 항목 (구 PENDING): +- `BackupJob type=restore + targetTime` 적용 후 phase=Succeeded +- restore 후 marker row 'before' 존재 확인 +- restore 후 'after' row 부재 (PITR 시점 정확성) +- `pg_checksums --check` 데이터 무결성 검증 + +**구현/검증 상태 (✅ 해소)**: + +| 계층 | 구현 상태 | +|------|-----------| +| `BackupJob.spec.type=restore` → `RestorePIT()` call path | **완료** | +| pgBackRest command-runner plugin | **완료** | +| K8s sidecar exec path | **완료** | +| 실제 pgBackRest restore 오케스트레이션 | **완료** — `backupjob_controller.go reconcileSidecarRestore` (STS scale-0 → Pod 정지 대기 → data PVC 마운트 restore Job → restore-in-progress 락) | +| WAL 아카이빙 + 복원 검증 drill | **완료** — 2026-06-24 live drill 7 PASS (full backup → WAL archive → restore → before 존재/after 부재 → checksum 0) | + +**해결 경로**: + +``` +Step 1: WAL 아카이빙 설정 검증 + - pgBackRest stanza 생성 + WAL archive-push 정상 동작 확인 + - BackupJob type=full 실행 → phase=Succeeded 확인 + +Step 2: PITR 복원 오케스트레이션 + - RestorePIT(targetTime)이 pgBackRest restore --type=time --target=... 를 + 실제로 exec하는 코드 완성 + - restore 완료 후 BackupJob phase를 Succeeded로 전환하는 status 업데이트 + +Step 3: e2e 시나리오 + 1) "before" 레코드 삽입 → 타임스탬프 기록 (t1) + 2) "after" 레코드 삽입 + 3) BackupJob(type=restore, targetTime=t1) 생성 + 4) restore 완료 후 "before" 존재, "after" 부재 확인 + 5) pg_checksums --check로 데이터 무결성 검증 +``` + +--- + +## 6. 추가로 필요한 작업 (소스코드 방향성 기반) + +### 6-1. 즉시 수정 가능한 항목 (테스트 환경 전제 조건 오류) + +| 항목 | 문제 | 해결 방법 | +|------|------|-----------| +| Pooler, PostgresDatabase, PostgresUser 테스트 | `quickstart` 클러스터 전제 조건 미충족 | `BeforeAll`에 quickstart 클러스터 부트스트랩 추가 또는 독립적인 전용 클러스터 생성 | +| ImageCatalog 테스트 | pg:17 이미지 미적재 | `BeforeAll`에서 `docker build -f Dockerfile.pg --build-arg PG_MAJOR=17` + kind load 추가 | +| Replica Cluster 테스트 | source cluster + replicator Secret 미생성 | `BeforeAll`에 source cluster 생성 + `src-replicator-pwd` Secret 생성 코드 추가 | +| Hibernation PVC label | PVC에 cluster 레이블 미부착 | `builders.go`의 `VolumeClaimTemplate`에 `postgres.keiailab.io/cluster=` 레이블 추가 | + +### 6-2. 구현/재검증 항목 (ROADMAP 기준) + +| 우선순위 | 항목 | ROADMAP 참조 | 예상 난이도 | +|----------|------|-------------|------------| +| **HIGH** | Failover live chaos/shard-identity 검증 | G1 `[ ] 자동 failover` | 높음 (ADR-0027 연동 필요) | +| **HIGH** | PVC retention label live 재검증 | G2 `[~] 선언적 hibernation` | 낮음 (unit 수정 완료) | +| **MEDIUM** | PostgresDatabase/User live e2e fixture 재검증 | G2 `[~] CRD` | 낮음~중간 (unit 경로 확인됨) | +| **MEDIUM** | PITR restore 오케스트레이션 | G1 `[~] PITR restore` | 높음 | +| **MEDIUM** | ImageCatalog pg:17/18 이미지 live 재검증 | G2 `[~] ImageCatalog` | 낮음 (helper 추가됨) | +| **LOW** | Failover chaos e2e PContext 해제 조건 정리 | G1 `[ ] 자동 failover` | 중간 | + +### 6-3. 테스트 아키텍처 개선 방향과 후속 상태 + +``` +기존 문제: 여러 p2 테스트가 "quickstart" 클러스터에 암묵적으로 의존하지만, + p2 단독 실행 시 해당 클러스터가 없어 BeforeAll에서 실패. + +권장 개선: + Option A) 각 테스트 파일의 BeforeAll에 전용 PostgresCluster 생성 코드 추가 + (현재 Failover, Hibernation 테스트가 이미 이 패턴 사용 → 일관성) + + Option B) e2e_suite_test.go의 BeforeSuite에 공통 "base" 클러스터 생성 추가 + (quickstart를 BeforeSuite에서 생성하면 모든 p2 테스트에서 재사용 가능) + + 권장: Option A — 각 테스트가 독립적으로 실행 가능해야 한다는 원칙에 부합. + 테스트 간 상태 공유는 flaky test의 원인이 됨. +``` + +후속 상태: Pooler, PostgresDatabase, PostgresUser, ImageCatalog, External Cluster는 전용 helper 기반 부트스트랩으로 정리됨. 현재 검증 수준은 e2e 패키지 컴파일까지이며, Kind live 재실행으로 fixture 독립성과 실제 SQL 적용을 확인해야 한다. + +--- + +## 7. 전체 결과 매트릭스 + +| 테스트 ID | 테스트명 | Gate | 결과 | 사유 분류 | +|-----------|----------|------|------|-----------| +| TC-PASS-01 | Failover: ord-0 primary 선출 | G1 | ✅ PASS | - | +| TC-PASS-02 | Failover: ord-1 standby + basebackup | G1 | ✅ PASS | - | +| TC-PASS-03 | Hibernation: STS scale-down | G2 | ✅ PASS | - | +| TC-PASS-04 | Hibernation: phase=Hibernated | G2 | ✅ PASS | - | +| TC-FAIL-01 | Failover: RTO 30s 자동 promote | G1 | ❌ FAIL | live chaos/shard-identity 재검증 필요 | +| TC-FAIL-02 | Hibernation: PVC 보존 | G2 | ❌ FAIL | 레이블 미부착 (builders.go) | +| TC-FAIL-03 | Replica Cluster: streaming standby | G2 | ❌ FAIL | 전제조건 미충족 + DNS 실패 | +| TC-FAIL-04 | PostgresDatabase: status.applied | G2 | ❌ FAIL | 전제조건 미충족 (unit 경로 확인) | +| TC-FAIL-05 | PostgresUser: status.applied | G2 | ❌ FAIL | 전제조건 미충족 (unit 경로 확인) | +| TC-FAIL-06 | Pooler: PgBouncer 2/2 Ready | G2 | ❌ FAIL | 전제조건 미충족 | +| TC-FAIL-07 | ImageCatalog: pg:17 STS 이미지 | G2 | ❌ FAIL | pg:17 이미지 미적재 | +| TC-PEND-01~04 | Failover chaos drill | G1 | ⏳ PENDING | 구현 중 (ADR-0027) | +| TC-PEND-05~09 | PITR restore + checksum | G1 | ⏳ PENDING | 구현 중 (GA #248) | + +### 실패 원인 분류 요약 + +| 분류 | 건수 | 항목 | +|------|------|------| +| **구현/검증 미완성** (ROADMAP 명시) | 2 | TC-FAIL-01, TC-PEND-01~09 | +| **테스트 전제조건 미충족** | 4 | TC-FAIL-03~06 | +| **코드 결함** (간단 수정) | 1 | TC-FAIL-02 (PVC 레이블) | +| **이미지 미적재** | 1 | TC-FAIL-07 | + +--- + +## 8. 결론 및 다음 단계 + +### 현재 상태 평가 + +- **G0(Day-0 배포)**: 완전 동작 확인 — Kind 클러스터에 operator + CRD 설치, PG Pod 기동, primary/standby 구성 모두 정상 +- **G1(HA)**: 탐지·승격 로직과 reconcile trigger path는 코드상 존재. 남은 핵심은 StatefulSet self-heal을 넘어서는 shard-identity/live chaos 검증 +- **G2(운영 품질)**: 기반 기능(Hibernation scale-down, phase 전이)은 동작. Pooler·DB·User·ImageCatalog의 live e2e는 테스트 전제조건 정비 후 재검증 필요 + +### 작업 스트림 분리 기준 + +오픈소스 operator 기준에서는 분석, 테스트 픽스, 제품 코드, 릴리스 산출물을 한 PR에 섞지 않는다. 장애가 발생했을 때 원인 추적이 가능해야 하므로 다음 역할 단위로 분리한다. + +| 스트림 | 역할 | 포함 항목 | 검증 기준 | +|--------|------|-----------|-----------| +| A. 분석/문서 | 현재 상태와 RCA 기록 | 프로젝트 개요, 기능 분석, 테스트/E2E 리포트 | 소스·ROADMAP·실측 로그 간 모순 없음 | +| B. 개발환경 | 재현 가능한 로컬/컨테이너 환경 | `.gitattributes`, Dev Container, WSL2 문서 | 새 clone에서 스크립트 LF 유지, Go 버전 일치 | +| C. 테스트 아키텍처 | e2e 독립 실행성 보장 | `BeforeAll` 독립 클러스터, pg:17 이미지 로드, source cluster fixture | `label=p2` 단독 실행 재현 가능 | +| D. 제품 코드 | 실제 operator 동작 수정 | PVC label, DB/User status, failover trigger, PITR restore | unit/envtest/e2e 중 해당 레이어 통과 | +| E. 릴리스/공급망 | 배포 아티팩트와 보안 신뢰성 | `dist/install.yaml`, Helm/OLM, SBOM, cosign | tag·image·chart 버전 정합, generated drift 없음 | + +정리 순서는 A/B → C → D → E 로 둔다. 단, 릴리스 아티팩트의 의도치 않은 이미지 태그 변경처럼 배포 위험이 큰 변경은 즉시 분리하거나 제거한다. + +### 우선순위별 다음 단계 + +``` +[단기 — 테스트 픽스, 수일 이내] +1. builders.go: PVC volumeClaimTemplate에 postgres.keiailab.io/cluster 레이블 추가 +2. 각 e2e 테스트 BeforeAll에 독립적 PostgresCluster 생성 코드 추가 + (Pooler, PostgresDatabase, PostgresUser, ImageCatalog, External Cluster) +3. ImageCatalog BeforeAll에 pg:17 이미지 빌드·로드 추가 + +[중기 — 구현, 수 주] +4. PostgresDatabase / PostgresUser status.applied live e2e 재검증 (unit-level 경로는 통과) +5. ADR-0027 P1/P3~P6: 격리 label 헬퍼, shard-identity transition, 자동 failover live chaos 검증 +6. PITR restore 오케스트레이션 완성 + +[장기 — GA 준비] +7. ADR-0027 P3~P6 (shard-identity transition, 자동 failover e2e 검증) +8. 7일 soak + chaos engineering +9. SBOM + cosign 서명 +``` + +### 후속 조치 상태 + +| 항목 | 상태 | 남은 검증 | +|------|------|-----------| +| TC-FAIL-02 PVC cluster label | unit-level 수정 완료 (`buildPGStatefulSet` VolumeClaimTemplate label) | `make test-e2e-failover` 재실행으로 live PVC selector 확인 | +| E2E 독립 부트스트랩 | Pooler/DB/User/ImageCatalog/External Cluster에 전용 cluster/image/source helper 추가 | `make test-e2e-failover` 재실행으로 live 독립 실행성 확인 | +| ImageCatalog pg:17/18 runtime image | helper에서 build/load 경로 추가 | live e2e 재실행으로 STS 이미지 pull 확인 | +| DB/User status.applied | unit-level status.applied 경로 확인 | 독립 fixture 기반 e2e 재실행 | +| Failover trigger wiring | 현재 코드상 `clusterFailoverDecision()` → `shouldPromoteAfterDebounce()` → `executeClusterPromotion()` 경로 존재 | node loss/PVC loss 계열 live drill | +| 5-shard failover 순수 로직 테스트 | `internal/controller/failover` 패키지 통과 | live multi-shard 장애복구 e2e는 별도 과제 | +| p2 live e2e 재실행 시도 | 컨테이너 기반 nested Kind에서는 kubeconfig host/TLS 문제로 BeforeSuite 중단, spec 0개 실행 | 실제 DevContainer/WSL2처럼 Kind API server 인증서 SAN과 접근 주소가 일치하는 환경에서 재실행 | + +--- + +## 9. 2026-06-23 후속 검증 업데이트 + +이번 후속 작업은 "커밋"이 아니라 분석 기반 테스트/제품 표면 정리 트랙이다. 2026-06-22 리포트의 원본 결과는 그대로 보존하고, 아래 항목을 추가 실측으로 갱신한다. + +### 9-1. 해결 또는 전진한 항목 + +| 항목 | 변경 내용 | 검증 결과 | +|------|-----------|-----------| +| TC-FAIL-02 PVC label | `buildPGStatefulSet`의 `volumeClaimTemplates`에 `postgres.keiailab.io/cluster=` 레이블 추가 | `go test -count=1 ./internal/controller ./internal/controller/failover` 통과 | +| PostgresUser e2e | 독립 `quickstart` 부트스트랩, `spec.cluster.name`, Pod IP 기반 TCP 접속, `-c postgres` exec 보정 | targeted live e2e 통과: applied, role 생성, password rotate, old password reject, delete drop role | +| PostgresDatabase e2e | 독립 `quickstart` 부트스트랩, owner/reader user 생성, schema privilege manifest 보정 | targeted live e2e 통과 | +| ImageCatalog e2e | PG 17/18 런타임 이미지 build/load, `major` int, hash annotation path 보정 | targeted live e2e 통과 | +| Pooler e2e | 실제 리소스명 `-pooler` 반영, PgBouncer/exporter 이미지 load, `stats_users` allowlist 추가, builtin role 기반 exporter/psql smoke 보정 | targeted live e2e 통과: 4 Passed, 0 Failed, 9 Pending, 54 Skipped | +| E2E helper | PG runtime image build/load, multi-platform image import fallback, 독립 cluster bootstrap helper 추가 | e2e package compile 통과 | + +### 9-2. Pooler RCA 정정 + +초기 RCA는 `quickstart` 전제조건 미충족이었다. 후속 재실행에서 전제조건을 해소하자 추가 문제가 순차적으로 드러났다. + +1. 테스트가 `pg-pooler-test-pgbouncer` Deployment를 조회했지만 컨트롤러 계약은 `PoolerDeploymentName(name) = -pooler`였다. +2. exporter는 `postgres`로 PgBouncer admin DB에 접속했지만 builtin auth Secret에는 `keiailab_pooler_pgbouncer`만 존재했다. +3. exporter가 metrics를 읽으려면 PgBouncer `stats_users`가 필요했지만 controller allowlist가 이를 막고 있었다. +4. smoke용 `kubectl run`은 PG 이미지의 기본 entrypoint를 실행해 `POD_NAME` env 오류가 났다. `--command -- psql ...`로 command를 명시해 해결했다. + +`admin_users`는 운영 명령 권한까지 열 수 있으므로 추가하지 않았다. exporter 목적에는 read-only 성격의 `stats_users`가 더 타이트한 표면이다. + +### 9-3. 아직 제품 이슈로 남은 항목 + +| 항목 | 현재 상태 | 다음 방향 | +|------|-----------|-----------| +| Failover live drill | 초기 부트/standby 경로는 일부 전진했지만, force-delete 기반 자동 failover는 StatefulSet self-heal과 shard-identity 문제가 남음 | ADR-0027 shard-identity transition + node/PVC loss 계열 live drill | +| External replica cluster | source fixture/DNS/assertion 및 standalone replica failover 제외 처리 후 targeted live e2e PASS | regression guard 유지, full e2e suite에서 재확인 | +| PITR restore | 여전히 PENDING | restore orchestration 완성 후 PContext 해제 | + +### 9-4. 이번 후속 검증 로그 + +| 실행 | 결과 | +|------|------| +| `go test -count=1 ./internal/controller ./internal/controller/failover` | PASS | +| `go test -count=1 -tags=e2e ./test/e2e -run TestDoesNotExist` | PASS | +| Pooler targeted live e2e | PASS: 4 Passed, 0 Failed | +| PostgresUser targeted live e2e | PASS | +| PostgresDatabase targeted live e2e | PASS | +| ImageCatalog targeted live e2e | PASS | +| External replica targeted live e2e | PASS: 3 Passed, 0 Failed, 9 Pending, 55 Skipped | +| Failover targeted live e2e | FAIL: live failover/shard-identity 계열 문제 | + +*본 보고서는 `CERT_MANAGER_INSTALL_SKIP=true make test-e2e-failover` 실행 결과, 소스코드(`internal/controller/failover/`, `test/e2e/`), ROADMAP.ko.md, ARCHITECTURE.ko.md, ADR-0027을 교차 분석하여 작성하였습니다.* + +--- + +## 10. 2026-06-23 추가 RCA: External replica live e2e 복구 + +### 결론 + +External replica cluster 실패는 단일 원인이 아니었다. + +1. 테스트 fixture의 source DNS가 실제 headless Service 이름과 달랐다. + - 기존: `pg-source-shard-0-0.pg-source-headless...` + - 실제: `pg-source-shard-0-0.pg-source-shard-0-headless...` +2. standalone replica cluster가 일반 HA failover 루프에 들어가 operator-driven promotion 대상이 됐다. + - 결과: `standby.signal`이 제거되고 `.keiailab-promoted-primary`가 생기며 replica가 독립 primary로 전환됐다. + - `pg_is_in_recovery()`가 `false`를 반환했다. +3. E2E assertion이 `SELECT pg_is_in_recovery()::text` 결과를 `t`로 기대했다. + - `::text` 출력은 `true/false`이므로 기대값은 `true`가 맞다. + +### 적용한 분리 원칙 + +`spec.replica.enabled=true` cluster는 local primary를 선출하는 HA cluster가 아니라 external source를 추종하는 standalone replica cluster다. 따라서 다음 경로에서 제외했다. + +- switchover +- automatic failover decision/promotion +- stale standby reseed +- rogue primary reseed +- fallback synthetic primary 생성 + +대신 Ready 조건은 "primary ready"가 아니라 "replica shard ready" 의미로 처리한다. + +### 검증 결과 + +| 검증 | 결과 | +|------|------| +| `go test -count=1 -timeout=5m ./internal/controller` | PASS | +| `go test -count=1 -timeout=5m ./internal/controller/failover` | PASS | +| `go test -count=1 -tags=e2e ./test/e2e -run TestDoesNotExist` | PASS | +| External replica targeted live e2e | PASS: 3 Passed, 0 Failed, 9 Pending, 55 Skipped | + +라이브 확인값: + +- `pg_is_in_recovery()::text` = `true` +- `standby.signal` present +- `.keiailab-promoted-primary` absent +- replica cluster primary lease absent +- PostgreSQL log: `entering standby mode`, `ready to accept read-only connections`, `started streaming WAL from primary` + +--- + +## 11. 2026-06-23 추가 RCA: Failover 초기 부트스트랩과 자동 승격 분리 + +### 결론 + +Failover e2e는 두 문제가 섞여 있었다. + +1. **초기 HA 부트스트랩 문제**: 최초 StatefulSet 생성 시 `PRIMARY_ENDPOINT`가 비어 있었고, ord-0 primary가 관측된 뒤 Pod template이 바뀌면서 StatefulSet rolling update가 primary ord-0을 재시작했다. 이 재시작이 false failover window를 만들었다. +2. **자동 승격 제품 과제**: primary force-delete 이후 StatefulSet이 같은 PVC/ordinal의 ord-0을 빠르게 self-heal하면서, ord-1 promotion exec와 기존 primary 복구가 경쟁한다. + +이번 작업에서는 1번을 수정했고, 2번은 ADR-0027 shard-identity/fencing 계열 구현 과제로 남겼다. + +### 적용한 수정 + +초기 HA cluster에서는 아직 status primary가 없어도 deterministic primary endpoint를 ord-0 DNS로 계산한다. + +- standalone replica cluster: external `replica.endpoint` 사용 +- hibernating cluster: endpoint 미설정 +- status에 관측된 primary endpoint가 있으면 해당 값을 우선 +- status에 primary pod만 있으면 pod DNS를 endpoint로 렌더링 +- HA 초기 부트스트랩에서는 `-0...svc.cluster.local:5432` 사용 + +이렇게 하면 최초 StatefulSet revision부터 standby가 ord-0을 향해 basebackup/streaming을 시작하므로, primary 관측 직후 Pod template이 바뀌어 ord-0이 불필요하게 재시작되는 경로를 차단한다. + +### 검증 결과 + +| 검증 | 결과 | +|------|------| +| `go test -count=1 -timeout=5m ./internal/controller -run TestPrimaryEndpointForShard -v` | PASS | +| `go test -count=1 -timeout=5m ./internal/controller -run TestController --ginkgo.focus='sets deterministic ordinal-zero PRIMARY_ENDPOINT'` | PASS | +| `go test -count=1 -timeout=8m ./internal/controller` | PASS | +| `go test -count=1 -timeout=5m ./internal/controller/failover` | PASS | +| `go test -count=1 -tags=e2e ./test/e2e -run TestDoesNotExist` | PASS | +| Failover targeted live e2e: `spawns ord-1 as standby with role=replica annotation` | PASS: 1 Passed, 0 Failed, 9 Pending, 57 Skipped | +| Failover file live e2e: `failover_e2e_test.go` | 2 Passed, 1 Failed, 9 Pending, 55 Skipped | + +남은 실패는 `promotes new primary within RTO 30s after primary kill`이다. 실패 시점의 이벤트는 `FailoverPromotionFailed`이며, exec 대상 container가 아직 준비되지 않았거나 `exit code 137`로 종료되는 promotion race가 관측됐다. 동시에 StatefulSet은 기존 ord-0을 같은 PVC/ordinal로 재생성하므로 status가 다시 `failover-shard-0-0=true`로 돌아간다. + +### Insight + +이번 수정은 테스트 기대값을 낮춘 것이 아니라, operator가 HA cluster를 처음 만들 때 불필요한 PodTemplate 변경을 만들지 않도록 부트스트랩 계약을 명확히 한 것이다. 반면 primary kill 후 자동 승격 실패는 단순 timeout 조정으로 해결할 문제가 아니다. StatefulSet ordinal identity와 database primary identity가 강하게 결합되어 있어, 기존 primary가 self-heal되는 순간 새 primary 승격과 충돌한다. + +오픈소스 operator 기준에서는 이 테스트를 완화하지 않는 편이 맞다. 이 실패는 실제 운영 장애 시 split-brain, stale primary, promotion target 불안정성으로 이어질 수 있는 설계 신호이기 때문이다. + +### 다음 구현 방향 + +1. E2E runner 안정화: 동일 image tag 재사용 시 manager Deployment가 자동 rollout되지 않으므로, 반복 실행에서는 fresh Kind cluster 또는 명시적 rollout restart/unique tag 전략을 적용한다. +2. ADR-0027 P1-P2: shard identity label/helper와 failover decision의 격리 경계를 먼저 구현한다. +3. promotion orchestration: candidate pod가 standby ready 상태인지 확인한 뒤 promote를 실행하고, 기존 primary ordinal이 self-heal로 다시 primary lease를 잡지 못하도록 fencing/partition/scale 전략을 검증한다. +4. live chaos 확장: force-delete뿐 아니라 node loss/PVC loss 계열 시나리오를 분리해 자동 failover의 실제 failure domain을 검증한다. + +한 줄 결론: 초기 standby 부트스트랩은 수정·검증됐고, 남은 자동 failover 실패는 ADR-0027 수준의 shard identity/fencing 구현으로 풀어야 한다. + +--- + +## 12. 2026-06-24 추가 보강: E2E manager rollout 안정화 + +### 문제 + +E2E suite는 manager 이미지를 매번 `ghcr.io/keiailab/postgres-operator:0.3.0-alpha`로 빌드하고 kind node에 로드한다. 하지만 같은 tag를 재사용하면 `kubectl apply -f dist/install.yaml`만으로는 Deployment PodTemplate이 바뀌지 않을 수 있다. + +결과적으로 반복 실행에서는 새로 빌드한 controller image가 kind node에 있어도, 기존 `postgres-operator-controller-manager` Pod가 계속 살아 있어 이전 코드로 테스트가 돌 수 있다. 이 상태에서는 failover 수정 여부를 라이브 E2E로 판단하기 어렵다. + +### 적용한 수정 + +`BeforeSuite`의 operator 설치 직후 다음 순서를 강제했다. + +1. `kubectl -n postgres-operator-system rollout restart deployment/postgres-operator-controller-manager` +2. `kubectl -n postgres-operator-system rollout status deployment/postgres-operator-controller-manager --timeout=180s` +3. 기존 `kubectl wait --for=condition=Available ...` 유지 + +이 방식은 image tag를 바꾸지 않아도 Deployment PodTemplate에 restart annotation을 갱신해 새 ReplicaSet을 만들고, 이후 테스트가 최신 manager Pod 위에서 실행되도록 보장한다. + +### 검증 결과 + +| 검증 | 결과 | +|------|------| +| `go test -count=1 -tags=e2e ./test/e2e -run TestManagerDeploymentRefreshCommandsRestartAndWaitForRollout -v` | PASS | +| `go test -count=1 -tags=e2e ./test/e2e -run TestDoesNotExist` | PASS | +| focused live e2e: `-ginkgo.label-filter=p2 -ginkgo.focus="elects ord-0 as initial primary"` | PASS: 1 Passed, 0 Failed, 9 Pending, 57 Skipped | + +### Insight + +오픈소스 operator에서 E2E는 “테스트 대상이 최신 코드인가”부터 보장해야 한다. 같은 tag 재사용은 로컬 kind E2E에서는 흔한 패턴이지만, Deployment는 image byte가 바뀌었는지 알지 못한다. 따라서 image load와 Deployment rollout은 별개의 계약으로 다뤄야 한다. + +한 줄 결론: 반복 E2E 실행 시에도 최신 manager Pod가 떠야 하므로, apply 이후 rollout restart/status를 명시적으로 수행한다. + +--- + +## 13. 2026-06-24 추가 RCA: 자동 failover RTO 30s 통과 + +### 문제 + +focused live e2e `promotes new primary within RTO 30s after primary kill`는 초기 수정 뒤에도 RTO가 45초대였다. + +- 1차 재실측: 기능적으로는 `failover-shard-0-1`이 primary가 됐지만 RTO 45.8초로 실패. +- 2차 재실측: candidate readiness guard 뒤에도 RTO 45.2초로 실패. +- operator 로그에는 `FailoverPromotionFailed`, `exit code 137`, `container not found ("postgres")`가 반복됐다. +- instance 로그에는 실패한 operator exec 이후 standby가 재시작되고, `standby.signal`이 사라진 상태로 Real elector branch에 들어가 lease 경합을 다시 시작하는 흐름이 보였다. + +### RCA + +핵심 원인은 단순 timeout이 아니라 promotion 경로의 안전 순서 문제였다. + +1. promotion 전에 failed old primary PVC를 먼저 fence하지 않으면 StatefulSet self-heal이 기존 ordinal을 되살릴 수 있다. +2. fenced PVC나 Kubernetes Pod/container NotReady 상태가 promotion candidate로 남으면 잘못된 exec가 발생한다. +3. 기존 operator exec 스크립트는 `pg_ctl promote` 전에 `standby.signal` 제거와 `.keiailab-promoted-primary` 생성을 먼저 수행했다. promote가 `exit 137`로 실패하면 다음 부팅에서 standby가 아닌 elector로 동작할 수 있어 RTO가 lease duration만큼 늘어났다. + +### 적용한 수정 + +- `executeClusterPromotion()`에서 promotion exec 전에 failed old primary PVC를 pre-fence. +- `aggregateShardStatus()`에서 fenced member와 Kubernetes Pod/container NotReady member를 promotion-ready replica에서 제외. +- `promotionCandidateReadyForExec()`로 exec 직전 PodReady + `postgres` container Ready를 재확인. +- `postgresPromotionCommand()`를 instance-manager와 같은 SQL `pg_promote(true, 30)` 기반으로 변경. +- `standby.signal` 제거와 promoted marker 생성은 promotion 성공 이후에만 수행하도록 순서 변경. + +### 검증 결과 + +| 검증 | 결과 | +|------|------| +| `go test -count=1 -timeout=8m ./internal/controller -run 'TestPostgresPromotionCommandMutatesPGDATAOnlyAfterSQLPromote|TestPostgresClusterPromotion|TestShouldSkipFencedCandidate' -v` | PASS | +| focused live e2e: `-ginkgo.label-filter=p2 -ginkgo.focus="promotes new primary within RTO 30s after primary kill"` | PASS | + +focused live e2e 실측값: + +- RTO: **14.148820221s** +- 결과: **1 Passed, 0 Failed, 9 Pending, 57 Skipped** + +### Insight + +자동 failover에서 중요한 것은 "빨리 promote한다"가 아니라 "실패한 promote가 데이터 디렉터리의 의미를 바꾸지 않는다"이다. `standby.signal`과 promoted marker는 다음 부팅의 역할 결정을 바꾸는 강한 신호이므로, promotion 성공 전 mutation은 split-brain과 RTO 지연을 동시에 만들 수 있다. + +`pg_ctl promote` 대신 SQL `pg_promote(true, 30)`을 쓴 이유는 instance-manager의 production promotion 경로와 동일한 API를 사용해 동작 차이를 줄이기 위해서다. 운영에서는 이런 차이가 장애 시점에만 드러나므로, promotion API는 하나로 통일하는 편이 로그 해석, 재현성, 유지보수성 모두에 유리하다. + +### 남은 과제 + +이번 결과는 focused RTO spec 통과다. 아직 full p2 suite, old-primary standby rejoin spec, node/PVC loss 계열 chaos, ADR-0027 shard-identity transition 검증은 별도 단계로 남아 있다. + +한 줄 결론: 자동 failover RTO 30초 focused live gate는 SQL promotion + 성공 후 PGDATA mutation 순서로 통과했다. + +--- + +## 14. 2026-06-24 추가 검증: full p2 live suite PASS + +### 목적 + +focused failover spec만으로는 운영 품질 게이트를 통과했다고 보기 어렵다. 같은 manager image tag를 재사용하는 Kind E2E 환경에서 최신 controller Pod가 실제로 재기동되는지, 그리고 p2 레이블 전체가 서로 간섭 없이 통과하는지 확인했다. + +### 추가 수정 + +- E2E `managerImage`를 `dist/install.yaml` / `config/manager/kustomization.yaml`의 controller image와 같은 `ghcr.io/keiailab/postgres-operator:0.4.0-beta.8`로 정렬. +- `TestManagerImageMatchesInstallManifests`를 추가해 E2E suite 상수와 install manifest image drift를 단위 수준에서 차단. +- `BeforeSuite`의 manager rollout restart/status 경로와 함께, 같은 tag 재사용 시에도 최신 manager Pod가 테스트 대상이 되도록 보장. + +### 검증 결과 + +full p2 live suite: + +```bash +KIND_CLUSTER=postgres-operator-e2e-full-p2-codex \ +CERT_MANAGER_INSTALL_SKIP=true \ +go test -tags=e2e ./test/e2e -timeout 45m -v -ginkgo.v -ginkgo.label-filter=p2 +``` + +실측 결과: + +- 결과: **31 Passed, 0 Failed, 9 Pending, 27 Skipped** +- 실행 시간: **1710.457s** +- failover RTO: **14.565566414s** +- manager image 정합성 테스트: **PASS** +- manager rollout command 테스트: **PASS** + +이번 full p2에서 확인된 통과 범위: + +- hibernation: scale down/up, PVC 보존, marker row 보존. +- Pooler: Deployment / Service / Secret / AutoTLS / metrics ServiceMonitor / delete cleanup. +- PostgresDatabase: `status.applied=true`, DB/schema/extension 생성, finalizer 기반 drop. +- PostgresUser: `status.applied=true`, role 생성, 초기 비밀번호 Secret, password rotation, old password reject, finalizer 기반 drop. +- failover: ord-0 initial primary, ord-1 standby, RTO 30초 이내 promotion, old primary standby rejoin. +- external replica drill: 외부 primary 기반 replica bootstrap. +- ImageCatalog: catalog 기반 operator rollout 반영. + +### 아직 끝나지 않은 범위 + +full p2가 통과했지만 전체 개발이 끝났다는 의미는 아니다. 현재 남은 9개 Pending과 GA 축은 별도로 구현/전환해야 한다. + +- PITR restore orchestration: BackupJob restore path, recovery target, bootstrap/검증 자동화. +- failover chaos PContext 전환: node/PVC loss 계열 live drill을 pending에서 자동 실행 대상으로 승격. +- ADR-0027 shard-identity transition: StatefulSet ordinal/PVC 정체성에 묶인 failover 한계를 줄이는 구조 개선. +- G3 sharding controller: CRD/PoC 수준을 넘어 reconcile loop와 상태 조건을 구현. +- G4~G5 reshard / DSQL: 현재는 GA 준비 단계가 아니라 장기 구현 축. +- 릴리스 품질: 7일 soak, chaos 반복, SBOM, cosign 서명, chart/bundle release gate. + +### Insight + +이번 단계의 의미는 "모든 개발 완료"가 아니라 "운영 품질 p2 live gate의 주요 실패를 닫았다"이다. 특히 failover는 탐지/선정/승격 함수만 맞다고 충분하지 않고, 최신 manager Pod가 실제로 실행되는지, StatefulSet self-heal과 promotion mutation 순서가 충돌하지 않는지까지 라이브로 검증해야 한다. + +open-source operator 기준에서는 Pending을 그대로 둔 상태에서 GA를 선언하면 안 된다. Pending은 의도된 backlog일 수 있지만, 각 항목은 왜 Pending인지, 어떤 release gate에서 반드시 해제할지, 실패 시 데이터 손상 가능성이 있는지까지 추적돼야 한다. + +한 줄 결론: p2 live suite는 31 Passed / 0 Failed로 통과했지만, PITR·chaos·shard identity·sharding/reshard 계열 구현은 아직 남아 있다. + +--- + +## 15. 2026-06-24 추가 검증: failover chaos PENDING 해제 + +### 목적 + +full p2에서 자동 failover와 old-primary standby rejoin은 통과했지만, `test/e2e/failover_chaos_test.go`의 p1 chaos drill은 여전히 `PContext`로 남아 있었다. 이번 단계에서는 해당 Pending이 현재 제품 코드 기준으로 실제 통과 가능한지 확인했다. + +### 적용한 변경 + +- `Failover chaos drill (D.1.2)`의 `Primary kill chaos → 자동 failover`를 `PContext`에서 `Context`로 전환. +- 오래된 주석을 제거하고, 현재 promotion 경로의 회귀 방지 목적을 명시. + +이 변경은 제품 코드 추가가 아니라 검증 게이트 전환이다. 이전 단계에서 적용한 promotion pre-fence, candidate readiness guard, SQL `pg_promote`, 성공 후 PGDATA mutation 순서가 이 drill의 전제다. + +### 검증 결과 + +targeted p1 live suite: + +```bash +KIND_CLUSTER=postgres-operator-e2e-failover-chaos-codex \ +CERT_MANAGER_INSTALL_SKIP=true \ +go test -tags=e2e ./test/e2e -timeout 25m -v \ + -ginkgo.v -ginkgo.label-filter=p1 -ginkgo.focus="Failover chaos drill" +``` + +실측 결과: + +- 결과: **6 Passed, 0 Failed, 4 Pending, 57 Skipped** +- failover chaos 실행 spec: **4 Passed** +- 새 primary promotion 관측 시간: **10.374s** +- 이전 primary standby rejoin 관측 시간: **16.657s** +- 남은 Pending: **PITR restore/checksum 4개** + +### Insight + +이번 결과로 "자동 failover가 라이브 chaos에서 여전히 미구현"이라는 과거 RCA는 더 이상 현재 코드 기준 사실이 아니다. 다만 이 말은 StatefulSet ordinal/PVC 기반 구조가 완전히 해결됐다는 뜻은 아니다. force-delete primary chaos는 통과했지만, node loss, PVC loss, shard identity transition은 다른 failure class이며 별도 drill과 설계 검증이 필요하다. + +운영 기준에서는 Pending을 해제하는 순간 해당 테스트는 release gate가 된다. 따라서 failover chaos는 앞으로 flaky하면 다시 Pending 처리하는 것이 아니라, promotion 경로 또는 테스트 전제 조건 중 어느 쪽이 깨졌는지 RCA를 수행해야 한다. + +한 줄 결론: failover chaos p1 Pending은 해제했고, 현재 라이브 검증 기준 남은 명시적 E2E Pending은 PITR restore/checksum이다. diff --git a/docs/FEATURE_DEEP_DIVE.md b/docs/FEATURE_DEEP_DIVE.md new file mode 100644 index 0000000..3d14891 --- /dev/null +++ b/docs/FEATURE_DEEP_DIVE.md @@ -0,0 +1,959 @@ +# postgres-operator 기능 심층 분석 + +> 각 CRD와 컨트롤러의 내부 동작을 코드 레벨에서 분석한 문서. +> 대상 버전: v0.4.0-beta.1 | 소스: `api/v1alpha1/`, `internal/controller/` + +--- + +## 목차 + +1. [PostgresCluster — 클러스터 라이프사이클](#1-postgrescluster--클러스터-라이프사이클) +2. [HA Failover — 자동 장애 복구](#2-ha-failover--자동-장애-복구) +3. [BackupJob / ScheduledBackup — 백업 시스템](#3-backupjob--scheduledbackup--백업-시스템) +4. [Pooler — PgBouncer 커넥션 풀](#4-pooler--pgbouncer-커넥션-풀) +5. [PostgresDatabase — 선언적 데이터베이스 관리](#5-postgresdatabase--선언적-데이터베이스-관리) +6. [PostgresUser — 선언적 역할 관리](#6-postgresuser--선언적-역할-관리) +7. [ImageCatalog / ClusterImageCatalog — 이미지 카탈로그](#7-imagecatalog--clusterimagecatalog--이미지-카탈로그) +8. [Plugin SDK — 확장 아키텍처](#8-plugin-sdk--확장-아키텍처) +9. [ShardSplitJob / ShardRange — 미래 샤딩 설계](#9-shardsplitjob--shardrange--미래-샤딩-설계) + +--- + +## 1. PostgresCluster — 클러스터 라이프사이클 + +**소스**: `api/v1alpha1/postgrescluster_types.go`, `internal/controller/postgrescluster_controller.go` + +### 1.1 Reconcile 흐름 + +reconcile은 5초(`statusPollInterval`) 주기 requeue와 Owns(StatefulSet/Deployment) Watch 이벤트로 트리거된다. + +``` +[Reconcile 진입] + │ + ├─ 1. CR fetch + PostgresVersion 매트릭스 검증 + │ ↓ 실패 시 → Degraded + VersionRejected condition + │ + ├─ 2. ImageCatalog 해석 (imageCatalogRef 사용 시) + │ ↓ 실패 시 → Degraded + ImageCatalogRejected condition + │ + ├─ 3. Replica cluster 설정 검증 (externalClusters 참조 확인) + │ + ├─ 4. RBAC upsert (SA + Role + RoleBinding) + │ — Pod 기동 전 선행 필수 (fail-fast 회피) + │ + ├─ 5. TLS Certificate CR upsert (cert-manager, Phase 2) + │ + ├─ 6. [shard 수만큼 반복] + │ ├─ ConfigMap upsert (postgresql.conf + pg_hba.conf) + │ ├─ Headless Service upsert (DNS 핵심) + │ ├─ StatefulSet upsert (primary + replica Pods) + │ ├─ PDB upsert (replicas >= 2일 때 자동 생성) + │ └─ shard status 수집 (Pod annotation 기반 → STS readyReplicas fallback) + │ + ├─ 7. Router 자원 upsert (shardingMode=native일 때만) + │ └─ ConfigMap + ClusterIP Service + Deployment + │ + ├─ 8. PVC online expansion (Spec.Storage.Size 증가 감지 시 PVC patch) + │ + ├─ 9. Switchover 처리 (annotation 트리거 계획 전환) + │ + ├─ 10. Failover 결정 + debounce gate + │ + ├─ 11. 스테일 replica 재시드 + 이상 primary 재시드 + │ + ├─ 12. Condition + Phase 갱신 + │ + ├─ 13. Config hot-reload (pg_reload_conf 실행) + │ + └─ 14. Status.Update() → 5초 후 requeue +``` + +### 1.2 생성되는 Kubernetes 자원 + +| 자원 | 이름 패턴 | 역할 | +|---|---|---| +| `ServiceAccount` | `-instance` | instance manager 전용 SA | +| `Role` | `-instance` | Pod/Lease/Secret 접근 | +| `RoleBinding` | `-instance` | SA ↔ Role 바인딩 | +| `ConfigMap` | `-shard--config` | postgresql.conf, pg_hba.conf | +| `Service` (Headless) | `-shard-` | StatefulSet DNS | +| `StatefulSet` | `-shard-` | primary + replica Pods | +| `PodDisruptionBudget` | `-shard-` | minAvailable=1 (HA 보호) | +| `Certificate` (cert-manager) | `-tls` | TLS 인증서 (Phase 2) | + +### 1.3 StatefulSet 구조 + +``` +StatefulSet: -shard-0 + replicas: 1 (primary) + spec.shards.replicas (async) + + Pod containers: + - postgres : upstream PostgreSQL 바이너리 + - instance-mgr : PID1 instance manager (선출/fencing/상태 API) + - pgbackrest-sidecar : 백업 사이드카 (pgBackRest 사용 시) + + Init containers: + - bootstrap: ordinal > 0이고 primaryEndpoint가 있으면 pg_basebackup + 없으면 initdb (ordinal-0 또는 첫 부팅) + + VolumeClaimTemplates: + - data: spec.shards.storage.size (PVC, immutable selector) +``` + +### 1.4 클러스터 Phase 전환 규칙 + +``` +Provisioning ─→ Ready + ↑ │ + └── Degraded ←──┘ (primary 실패 감지 시) + │ + Hibernated (cnpg.io/hibernation=on 어노테이션) +``` + +| Phase | 조건 | +|---|---| +| `Provisioning` | 모든 shard primary가 아직 Ready 아님 | +| `Ready` | 모든 shard primary Ready + router Ready (native 모드일 때) | +| `Degraded` | Ready였다가 primary 실패 감지됨 | +| `Hibernated` | `cnpg.io/hibernation=on` 어노테이션 설정됨 | +| `Reconfiguring` | spec 변경 처리 중 (향후 사용) | + +### 1.5 Hibernation (하이버네이션) + +`cnpg.io/hibernation=on` 어노테이션을 붙이면 StatefulSet replicas=0으로 축소하고 PVC는 보존한다. +어노테이션을 제거하면 복구된다. CloudNativePG 패턴과 동일하다. + +```bash +# 하이버네이션 진입 +kubectl annotate postgrescluster my-cluster cnpg.io/hibernation=on + +# 복구 +kubectl annotate postgrescluster my-cluster cnpg.io/hibernation- +``` + +### 1.6 Replica Cluster (외부 클러스터 복제) + +`spec.replica.enabled=true` + `externalClusters` 설정으로 외부 PostgreSQL 클러스터를 +스트리밍 복제로 따라가는 Standby 클러스터를 구성할 수 있다. + +```yaml +spec: + externalClusters: + - name: primary-dc + connectionParameters: + host: pg-primary.prod.svc + port: "5432" + user: streaming_replica + password: + name: replication-secret + key: password + replica: + enabled: true + source: primary-dc +``` + +Bootstrap 방식은 `spec.bootstrap.pg_basebackup`으로 최초 full copy를 수행한다. + +### 1.7 동기 복제 설정 + +```yaml +spec: + postgresql: + synchronous: + method: any # any (quorum) 또는 first (priority) + number: 1 # 커밋 확인에 필요한 동기 standby 수 + dataDurability: required # required=블록 | preferred=quorum 자동 낮춤 +``` + +CEL 검증: `postgresql.synchronous.number <= shards.replicas` + +### 1.8 Config Hot-Reload + +SIGHUP 레벨 파라미터 변경 시(max_connections 제외) Pod 재시작 없이 반영된다. + +``` +ConfigMap 변경 → reconcile → pg_reload_conf() 실행 (psql exec) +``` + +`postgresql.conf` 해시가 변경된 경우에만 reload를 시도하며, 에러는 best-effort로 +로그에만 기록하고 reconcile을 중단하지 않는다. + +--- + +## 2. HA Failover — 자동 장애 복구 + +**소스**: `internal/controller/failover/`, `internal/controller/postgrescluster_controller.go` + +### 2.1 이중 Lease 구조 + +operator는 두 개의 독립된 Kubernetes Lease를 사용한다. + +``` +Lease 1: controller-runtime manager lease + — 일반 reconciler 실행 권한 (리소스 생성/수정) + — leader인 Pod만 reconcile 실행 + +Lease 2: postgres-operator-failover-leader (FailoverLeaseName) + — failover 결정/실행 전용 + — NeedLeaderElection()=false → 모든 manager replica가 경쟁 + — manager lease와 별도: reconciler 중단 없이 failover 핫패스 격리 +``` + +이 분리 덕분에 manager Pod이 재시작되어도 failover lease는 즉시 다른 Pod이 인계한다. + +### 2.2 장애 감지 (Detection) + +`failover.DetectPrimaryFailure(shard)` — **순수 함수, 네트워크 호출 없음** + +``` +입력: ShardStatus (Pod annotation 기반 live aggregation) + +판정 규칙: + 1. shard.Primary == nil + → ReasonNoPrimary (instance manager heartbeat 없음) + + 2. shard.Primary.Ready == false + → ReasonPrimaryNotReady (PostgreSQL 응답 불가) + + 3. 위 두 경우 + Ready replica 없음 + → ReasonNoEligibleReplica (수동 개입 필요) + +승격 후보 선택: SelectPromotionCandidate() + → 가장 WAL lag(bytes)가 작은 Ready replica + → 동률 lag: Pod 이름 사전순 (결정적) +``` + +### 2.3 Debounce Gate (오탐 방지) + +단일 reconcile 관측으로 즉시 promotion하면 일시적 상태 flicker(standby join 중 잠깐 Primary=nil 등)가 건강한 멤버를 fencing할 수 있다. + +``` +failoverDebounceThreshold = 8초 (statusPollInterval 5초의 ~1.5배) + +로직: + 실패 첫 관측 → failoverPending[key] = now + 후속 reconcile → now - first >= 8s 일 때만 promotion 실행 + 실패 해소 → pending 삭제 +``` + +canStart 조건: 실패 최초 관측 시점에 클러스터가 `Ready` 상태였어야 함 +→ Provisioning 중인 클러스터에서 false promotion 방지 + +### 2.4 Promotion 실행 + +`failover.BuildPromotionPlan()` + `Promoter.Execute()` + +``` +결정적 4단계 (순서 변경 불가): + + 1. RemoveStandbySignal + — $PGDATA/standby.signal 파일 삭제 + — PostgreSQL standby 모드 해제의 전제조건 + + 2. PgCtlPromote + — `pg_ctl promote -D $PGDATA` 실행 + — recovery → primary 전환 트리거 + + 3. WaitNotInRecovery + — `SELECT pg_is_in_recovery()` 폴링 + — promote 완료 확인 + + 4. UpdateInstanceRole + — Pod annotation role=primary 갱신 + — operator status 합성 입력 +``` + +실행 방법: `pods/exec` API (KubernetesBackupSidecarExecutor 재사용) + +### 2.5 Fenced Candidate Guard (재승격 방지) + +failback 시 이미 fence된 이전 primary를 다시 승격하는 것을 방지한다. + +``` +조건: fenced PVC를 가진 Pod이 승격 후보인데 unfenced 멤버가 이미 서비스 중 +→ 승격 건너뜀 (로그 기록만) + +이유: 이전 primary가 돌아왔을 때 unfenceTargetPVC로 fence를 해제하면 + stale timeline으로 재가동하여 post-failover 쓰기가 소실될 수 있음 +``` + +### 2.6 Stale Replica Re-seed + +primary가 Ready인데 replica가 장시간 NotReady 상태로 stuck된 경우 pg_basebackup으로 재시드한다. + +``` +조건: primary Ready + replica NotReady 지속 (임계 시간 초과) +동작: pg_basebackup으로 해당 replica 재초기화 +``` + +### 2.7 Rogue Primary Re-seed + +failback 후 이전 primary가 stale 환경(PRIMARY_ENDPOINT 환경변수)으로 부팅하여 initdb로 +자신을 초기화하고 새로운 primary로 행동하는 "rogue" 상태 처리. + +``` +감지: 두 개의 Pod이 동시에 primary로 보고하는 경우 +동작: 정상 promoted primary가 아닌 쪽을 standby로 재시드 +``` + +### 2.8 Switchover (계획된 Primary 전환) + +annotation으로 트리거하는 계획적 primary 전환 (데이터 손실 없음). + +```bash +kubectl annotate postgrescluster my-cluster \ + postgres.keiailab.io/switchover-target=my-cluster-shard-0-1 +``` + +--- + +## 3. BackupJob / ScheduledBackup — 백업 시스템 + +**소스**: `api/v1alpha1/backupjob_types.go`, `internal/controller/backupjob_controller.go` + +### 3.1 BackupJob — 단일 백업 실행 + +BackupJob은 **원자적(atomic)** 단위다. 생성 후 변경 불가 — 변경이 필요하면 새 CR을 생성한다. + +``` +불변 필드: + - spec.cluster.name + - spec.tool + - spec.type + - spec.executionMode +``` + +**Phase 전환:** + +``` +Pending → Running → Succeeded + ↘ Failed +``` + +### 3.2 Execution Mode + +| 모드 | 동작 | 적합 도구 | +|---|---|---| +| `sidecar` | PostgreSQL Pod 내 long-running 사이드카에 명령 전달 (pod exec) | pgBackRest | +| `job` | 독립 Kubernetes Job 생성 후 바이너리 호출 | WAL-G | +| `""` | 플러그인 기본값 | — | + +**sidecar 모드 (pgBackRest):** + +``` +BackupJobReconciler + → BackupPlugin.BackupCommand() 로 argv 생성 + → KubernetesBackupSidecarExecutor.Exec() 로 pod exec + → 결과 파싱 → status.backupID + status.bytes 갱신 +``` + +**job 모드 (WAL-G):** + +``` +BackupJobReconciler + → spec.jobTemplate 기반 batch/v1 Job 생성 + → Job 완료 감시 → status 갱신 +``` + +### 3.3 PITR 복원 (Point-In-Time Recovery) + +```yaml +apiVersion: postgres.keiailab.io/v1alpha1 +kind: BackupJob +spec: + cluster: + name: my-cluster + tool: pgbackrest + repo: repo1 + type: restore + restore: + targetTime: "2026-06-20T10:00:00Z" # 특정 시각으로 복원 + # 또는 + backupID: "20260620-100000F" # 특정 백업 ID로 복원 +``` + +### 3.4 백업 보존 정책 + +```yaml +spec: + retention: + keepFull: 7 # 풀 백업 7개 유지 + keepIncremental: 28 # 증분 백업 28개 유지 +``` + +### 3.5 ScheduledBackup — 크론 기반 자동 백업 + +`robfig/cron/v3`를 사용하는 6-field 크론 (초 단위 지원). + +```yaml +apiVersion: postgres.keiailab.io/v1alpha1 +kind: ScheduledBackup +spec: + schedule: "0 0 2 * * *" # 매일 새벽 2시 (초/분/시/일/월/요일) + backupTemplate: + spec: + cluster: + name: my-cluster + tool: pgbackrest + repo: repo1 + type: full +``` + +ScheduledBackupReconciler가 크론 시각 도달 시 `BackupJob` CR을 자동 생성한다. + +### 3.6 현재 지원 백업 도구 + +| 도구 | 상태 | 실행 모드 | 특징 | +|---|---|---|---| +| pgBackRest | 완전 구현 | sidecar | PG Pod와 동거, WAL 아카이브 | +| WAL-G | 구현됨 | job | S3/GCS/Azure 직접 스트리밍 | +| Barman | 구현됨 | — | Barman Cloud 통합 | + +--- + +## 4. Pooler — PgBouncer 커넥션 풀 + +**소스**: `api/v1alpha1/pooler_types.go`, `internal/controller/pooler_controller.go` + +### 4.1 동작 개요 + +``` +애플리케이션 → Pooler Service → PgBouncer Pod(s) → PostgresCluster +``` + +Pooler는 PostgresCluster 앞에 위치하는 **PgBouncer Deployment**를 관리한다. + +### 4.2 연결 타입 + +| 타입 | 라우팅 대상 | 사용 시나리오 | +|---|---|---| +| `rw` | Primary (쓰기 엔드포인트) | 일반 OLTP 쓰기/읽기 | +| `ro` | Replica (읽기 엔드포인트) | 읽기 전용 쿼리 분리 | + +### 4.3 Pool Mode + +| 모드 | 특징 | 권장 대상 | +|---|---|---| +| `session` | 클라이언트 세션 동안 PG 연결 유지 | SET 명령 / prepared statement 사용 앱 | +| `transaction` | 트랜잭션 완료 후 연결 반납 | 일반 OLTP (권장, 연결 재사용 최대) | +| `statement` | 각 쿼리 후 연결 반납 | 제약: 트랜잭션/prepared statement 불가 | + +### 4.4 Built-in Auth (자동 인증 설정) + +`spec.pgbouncer.authSecretRef`를 생략하면 operator가 자동으로 처리: + +``` +1. PostgreSQL primary Pod에 psql exec +2. `keiailab_pooler_pgbouncer` LOGIN 역할 생성 (랜덤 패스워드) +3. `-builtin-auth` Secret 생성 (userlist.txt 포함) +4. PgBouncer auth_file로 마운트 +``` + +```bash +# 패스워드 강제 회전 +kubectl annotate pooler my-pool \ + postgres.keiailab.io/rotate-pooler-password=true +``` + +### 4.5 TLS 설정 + +**방법 1: cert-manager 자동 발급** + +```yaml +spec: + pgbouncer: + autoTLS: + issuerRef: + name: my-issuer + kind: ClusterIssuer + clientEnabled: true # 클라이언트 → PgBouncer TLS + serverEnabled: true # PgBouncer → PostgreSQL mTLS +``` + +**방법 2: Self-Signed (cert-manager 없는 환경)** + +```yaml +spec: + pgbouncer: + autoTLS: + selfSigned: true + clientEnabled: true +``` + +RSA-2048, 유효기간 1년, 만료 30일 전 자동 회전. + +**방법 3: 직접 Secret 지정** + +```yaml +spec: + pgbouncer: + serverTLSSecret: + name: my-server-tls + clientTLSSecret: + name: my-client-tls +``` + +### 4.6 PAUSE / RESUME + +```yaml +spec: + paused: true # 모든 PgBouncer Pod에 PAUSE 명령 실행 +``` + +운영 중 점검 시 애플리케이션 연결을 끊지 않고 쿼리 대기 상태로 유지할 수 있다. + +### 4.7 Prometheus 익스포터 + +```yaml +spec: + pgbouncer: + exporter: + image: prometheuscommunity/pgbouncer-exporter:latest + port: 9127 +``` + +PgBouncer 내부 통계를 Prometheus 메트릭으로 노출한다. + +### 4.8 Config Hash 기반 Hot-Reload + +pgbouncer.ini 변경 시 Pod 재시작 없이 `RELOAD` 명령으로 적용. +`status.configHash`로 현재 적용된 설정 버전을 추적한다. + +--- + +## 5. PostgresDatabase — 선언적 데이터베이스 관리 + +**소스**: `api/v1alpha1/postgresdatabase_types.go`, `internal/controller/postgresdatabase_controller.go` + +### 5.1 동작 원리 + +`PostgresDatabaseReconciler`는 대상 PostgresCluster의 **ready primary Pod**에 `psql exec`를 실행하여 SQL DDL을 적용한다. 클러스터 외부에서 직접 연결하지 않으므로 네트워크 방화벽에 무관하다. + +``` +PostgresDatabase CR 변경 + → Reconciler + → primary Pod exec (psql -c "CREATE DATABASE ...") + → status.applied = true / false +``` + +### 5.2 관리 가능한 객체 + +**데이터베이스 자체** +```yaml +spec: + name: appdb + owner: app_role + ensure: present # 또는 absent + databaseReclaimPolicy: retain # 또는 delete (CR 삭제 시 DROP DATABASE) +``` + +**익스텐션** +```yaml +spec: + extensions: + - name: pgcrypto + ensure: present + version: "1.3" # 특정 버전 고정 + schema: public + - name: postgis + ensure: present +``` + +**스키마** +```yaml +spec: + schemas: + - name: app + owner: app_role + privileges: + - role: readonly_role + privileges: [USAGE] + - role: app_role + privileges: [USAGE, CREATE] +``` + +**Foreign Data Wrapper (FDW)** +```yaml +spec: + fdws: + - name: postgres_fdw + handler: postgres_fdw_handler + validator: postgres_fdw_validator + usage: + - name: analyst_role + type: grant +``` + +**Foreign Server** +```yaml +spec: + servers: + - name: remote_db + fdw: postgres_fdw + options: + - name: host + value: remote.db.example.com + - name: port + value: "5432" +``` + +### 5.3 보호된 이름 (CEL 검증) + +다음 DB 이름은 admission webhook에서 거부된다: +- `postgres` +- `template0` +- `template1` + +### 5.4 Reclaim Policy + +| 정책 | CR 삭제 시 | 권장 환경 | +|---|---|---| +| `retain` (기본) | 데이터베이스 유지 | 운영 환경 (데이터 보호) | +| `delete` | `DROP DATABASE` 실행 | 개발/테스트 환경 | + +--- + +## 6. PostgresUser — 선언적 역할 관리 + +**소스**: `api/v1alpha1/postgresuser_types.go`, `internal/controller/postgresuser_controller.go` + +### 6.1 지원 역할 속성 + +```yaml +spec: + name: app_user + login: true # LOGIN 속성 + superuser: false # SUPERUSER + createdb: false # CREATEDB + createrole: false # CREATEROLE + replication: false # REPLICATION + bypassrls: false # BYPASSRLS + inherit: true # INHERIT (null=PG 기본값) + connectionLimit: 25 # -1=무제한 + validUntil: "infinity" # 패스워드 만료 없음 +``` + +### 6.2 패스워드 관리 + +**Secret 참조 방식 (권장):** +```yaml +spec: + passwordSecretRef: + name: app-user-password +# Secret 내용: +# data: +# username: app_user +# password: +``` + +Secret의 `resourceVersion`이 변경될 때만 패스워드를 재적용한다. +`status.passwordSecretResourceVersion`으로 마지막 적용 버전을 추적한다. + +**패스워드 비활성화:** +```yaml +spec: + disablePassword: true # ALTER ROLE ... PASSWORD NULL +``` + +CEL 검증: `passwordSecretRef`와 `disablePassword`는 동시 설정 불가. + +### 6.3 역할 멤버십 + +```yaml +spec: + inRoles: + - readonly_role + - monitoring_role +``` + +`GRANT readonly_role TO app_user` SQL이 실행된다. + +### 6.4 보호된 이름 (CEL 검증) + +다음은 거부된다: +- `postgres` +- `pg_`로 시작하는 모든 이름 (PostgreSQL 내부 예약) + +### 6.5 PostgresCluster Status 연동 + +`PostgresCluster.status.managedRolesStatus`에 모든 PostgresUser 상태가 집계된다. + +```yaml +status: + managedRolesStatus: + byStatus: + reserved: [postgres, streaming_replica] + reconciled: [app_user] + pending-reconciliation: [new_user] + cannotReconcile: + broken_user: ["role does not exist"] + passwordStatus: + app_user: + secretResourceVersion: "12345" + observedGeneration: 3 +``` + +--- + +## 7. ImageCatalog / ClusterImageCatalog — 이미지 카탈로그 + +**소스**: `api/v1alpha1/imagecatalog_types.go` + +### 7.1 목적 + +PostgreSQL 이미지를 major version으로 추상화하여 클러스터별 핀닝을 가능하게 한다. + +```yaml +# 네임스페이스 범위 +apiVersion: postgres.keiailab.io/v1alpha1 +kind: ImageCatalog +metadata: + name: pg-images +spec: + images: + - major: 18 + image: ghcr.io/keiailab/postgres:18.1@sha256:abc123... + - major: 17 + image: ghcr.io/keiailab/postgres:17.5@sha256:def456... +``` + +```yaml +# 클러스터 전체 공유 +apiVersion: postgres.keiailab.io/v1alpha1 +kind: ClusterImageCatalog +``` + +### 7.2 PostgresCluster에서 참조 + +```yaml +spec: + imageCatalogRef: + kind: ClusterImageCatalog + name: company-pg-images + major: 18 # major 번호가 postgresVersion보다 우선 +``` + +`imageCatalogRef`가 설정되면 `spec.postgresVersion`은 무시되고 `major`가 단일 진실이 된다. + +### 7.3 CloudNativePG 호환 + +`APIGroup`으로 `postgresql.cnpg.io`를 수용하여 CNPG 매니페스트 포팅을 용이하게 한다. +단, 실제 lookup은 keiailab CRD 기준이다. + +### 7.4 운영 권장사항 + +```yaml +# 운영 환경: digest 핀 +image: ghcr.io/keiailab/postgres:18.1@sha256:abc123... + +# 개발 환경: tag만 +image: ghcr.io/keiailab/postgres:18.1 +``` + +Digest 핀으로 이미지 변조/변경을 방지한다. + +--- + +## 8. Plugin SDK — 확장 아키텍처 + +**소스**: `internal/plugin/api.go`, `internal/plugin/registry.go` + +### 8.1 설계 원칙 + +``` +핵심 reconciler → Plugin 인터페이스만 호출 +플러그인 구현체 → 인터페이스 구현 + Registry 등록 1줄 +``` + +새 플러그인 추가 = **인터페이스 구현 1개 + cmd/main.go에 Register() 1줄**. +핵심 reconciler 코드 변경 없음. + +### 8.2 5종 인터페이스 + +#### BackupPlugin + +```go +type BackupPlugin interface { + Name() string + PerformBackup(ctx, target, opts) (BackupResult, error) + RestorePIT(ctx, target, ts) error + Validate(spec *BackupSpec) error +} +``` + +추가로 `BackupCommandPlugin`을 구현하면 pod exec 경로를 사용할 수 있다: + +```go +type BackupCommandPlugin interface { + BackupCommand(target, opts) ([]string, error) + RestoreCommand(target, ts) ([]string, error) + ParseBackupResult(output []byte, opts) BackupResult +} +``` + +#### ExtensionPlugin + +```go +type ExtensionPlugin interface { + Name() string + SharedPreloadOrder() int // 낮은 값이 먼저 로드 + PreInstall(ctx, conn *sql.DB) error // CREATE EXTENSION 전 + PostInstall(ctx, conn *sql.DB) error // CREATE EXTENSION 후 + Validate(version string) error +} +``` + +`shared_preload_libraries` 순서: + +``` +pgaudit=100, pgvector=100 → pgcron=200 → pgnodemx=300, postgis=300, setuser=300 +``` + +동률 order는 Name() 사전순으로 결정적 정렬. + +#### ExporterPlugin + +```go +type ExporterPlugin interface { + Name() string + SidecarSpec() corev1.Container // exporter 사이드카 컨테이너 스펙 + DashboardJSON() ([]byte, error) // Grafana 대시보드 JSON + AlertRulesYAML() ([]byte, error) // PrometheusRule YAML +} +``` + +#### RouterPlugin + +```go +type RouterPlugin interface { + Name() string + BuildRouterPodSpec(target) (corev1.PodSpec, error) + HealthProbe(ctx, podName, podNamespace string) error +} +``` + +ADR 0003 강제: 라우터 PodSpec은 PVC 마운트, streaming replication, K8s Lease를 포함하면 안 된다. + +#### AuthPlugin + +```go +type AuthPlugin interface { + Name() string + Configure(ctx, target) error + SecretSchemaJSON() ([]byte, error) + RotateSecret(ctx, target, oldRef) (newRef, error) +} +``` + +### 8.3 현재 등록된 Extension 플러그인 + +| 플러그인 | 파일 | 역할 | SharedPreloadOrder | +|---|---|---|---| +| `pgaudit` | `internal/plugin/extension/pgaudit/` | PostgreSQL 감사 로그 | 100 | +| `pgvector` | `internal/plugin/extension/pgvector/` | AI 벡터 검색/임베딩 | 100 | +| `pgcron` | `internal/plugin/extension/pgcron/` | DB 내부 크론 작업 스케줄러 | 200 | +| `pgnodemx` | `internal/plugin/extension/pgnodemx/` | 노드 수준 OS 메트릭 노출 | 300 | +| `postgis` | `internal/plugin/extension/postgis/` | 지리 공간 데이터 처리 | 300 | +| `setuser` | `internal/plugin/extension/setuser/` | 사용자 권한 위임 모델 | 300 | + +### 8.4 Extension Opt-in + +Extension은 cluster spec에 명시할 때만 활성화된다 (RFC 0006 R1). +등록만 되어 있어도 cluster가 opt-in하지 않으면 vanilla PostgreSQL로 부팅된다. + +```yaml +spec: + extensions: + - pgvector + - pgaudit +``` + +**주의**: 이미지에 해당 extension의 `.so` 파일이 없으면 PostgreSQL이 FATAL로 시작 실패한다. +이미지 선택은 사용자 책임이다. + +--- + +## 9. ShardSplitJob / ShardRange — 미래 샤딩 설계 + +**소스**: `api/v1alpha1/shardrange_types.go`, `api/v1alpha1/shardsplitjob_types.go` + +### 9.1 현재 상태 + +CRD 타입만 정의되어 있으며 **컨트롤러가 구현되지 않았다**. +`ShardSplitJobReconciler`는 빈 scaffolding이다. + +### 9.2 ShardRange — 샤드 범위 정의 + +특정 테이블의 데이터를 여러 shard에 분산하는 범위를 정의한다. + +```yaml +apiVersion: postgres.keiailab.io/v1alpha1 +kind: ShardRange +spec: + cluster: + name: my-cluster + table: orders + shardKey: customer_id + ranges: + - shard: 0 + min: "0" + max: "1000000" + - shard: 1 + min: "1000000" + max: "" # 무한대 +``` + +### 9.3 ShardSplitJob — 온라인 샤드 분할 + +실행 중인 shard를 두 개로 분할하는 7단계 프로세스 (RFC 0003). + +```yaml +apiVersion: postgres.keiailab.io/v1alpha1 +kind: ShardSplitJob +spec: + cluster: + name: my-cluster + sourceShard: 0 + # 분할 후 두 shard의 범위 정의 +``` + +7단계 분할 알고리즘 (RFC 0003): +1. 새 shard 프로비저닝 +2. 초기 full 복제 +3. Catch-up 복제 (변경분) +4. 트래픽 잠금 (write 차단) +5. 최종 동기화 +6. 라우팅 테이블 전환 +7. 트래픽 잠금 해제 + +### 9.4 AutoSplit — 자동 분할 트리거 + +`shardingMode=native`에서만 의미를 가지며, 현재는 설계 단계다. + +```yaml +spec: + autoSplit: + enabled: true + requireApproval: true # true: ShardSplitJob 생성 후 수동 승인 필요 + triggers: + sizeThresholdGB: 500 # 500GB 초과 시 + p99LatencyMs: 100 # P99 레이턴시 100ms 초과 시 + cpuPercent: 80 # CPU 80% 초과 시 + durationMinutes: 10 # 위 조건이 10분 지속 시 +``` + +--- + +## 부록: 주요 상수 및 설정값 + +| 상수 | 값 | 위치 | 설명 | +|---|---|---|---| +| `statusPollInterval` | 5초 | `postgrescluster_controller.go` | reconcile requeue 주기 | +| `failoverDebounceThreshold` | 8초 | `postgrescluster_controller.go` | failover 오탐 방지 debounce | +| `FailoverLeaseName` | `postgres-operator-failover-leader` | `failover/lease.go` | HA failover lease 이름 | +| LeaderElectionID | `bdce7c33.keiailab.io` | `cmd/main.go` | manager leader election lease 이름 | +| pgPort | `5432` | `internal/controller/` | PostgreSQL 기본 포트 | +| 기본 pool_mode | `session` | `pooler_types.go` | PgBouncer 기본 풀 모드 | +| PDB minAvailable | `1` | `pdb.go` | 가용 Pod 최소 수 (HA 보호) | +| TLS 자동 갱신 | 만료 30일 전 | `pooler_controller.go` | Self-signed 인증서 갱신 트리거 | +| Failover lease 기간 | 15s / 10s / 2s | `internal/instance/election/` | LeaseDuration / RenewDeadline / RetryPeriod | diff --git a/docs/PROJECT_OVERVIEW.md b/docs/PROJECT_OVERVIEW.md new file mode 100644 index 0000000..09d734c --- /dev/null +++ b/docs/PROJECT_OVERVIEW.md @@ -0,0 +1,260 @@ +# postgres-operator 프로젝트 개요 + +> 버전: v0.4.0-beta.1 | 라이선스: MIT | 언어: Go 1.26 | 프레임워크: Kubebuilder / controller-runtime + +## 1. 프로젝트 목적 + +`postgres-operator`는 **Kubernetes 위에서 vanilla PostgreSQL 18+을 선언적으로 운영**하기 위한 Kubernetes Operator다. 외부 fork 없이 upstream PostgreSQL 바이너리를 그대로 사용하기 때문에 기존 libpq/JDBC/asyncpg 드라이버와 완전히 호환된다. + +단기 목표는 단일 클러스터 PostgreSQL(Primary + Replica) 운영 자동화이며, 장기 목표는 **수평 샤딩 + 분산 SQL 레이어**를 vanilla PostgreSQL 위에 구축하는 것이다. + +``` +[현재 GA] + 단일 클러스터: Primary + Replica, HA Failover, 백업, 커넥션 풀, 선언적 DB/역할 + +[로드맵 — 설계 단계] + ShardRange CRD → pg-router 쿼리 라우터 → 크로스 샤드 분산 트랜잭션 +``` + +--- + +## 2. 기술 스택 + +| 항목 | 선택 | 이유 | +|---|---|---| +| 언어 | Go 1.26 | Kubernetes 생태계 표준 | +| 프레임워크 | Kubebuilder + controller-runtime | CRD 스캐폴딩 + reconcile 루프 | +| PostgreSQL | 17 / 18 (upstream 바이너리) | fork 없음 = 완전 호환 | +| 백업 | pgBackRest (기본), WAL-G, Barman (플러그인) | 플러그인 교체 가능 | +| 커넥션 풀 | PgBouncer 1.19+ | CNPG 호환 surface | +| 인증서 | cert-manager (선택) | TLS 자동 발급/갱신 | +| 모니터링 | Prometheus Operator + Grafana | ServiceMonitor / PrometheusRule | +| 이미지 배포 | `ghcr.io/keiailab/postgres-operator` | GitHub Container Registry | + +--- + +## 3. 아키텍처 전체 구조 + +``` +┌────────────────────────────────────────────────────────────┐ +│ Kubernetes Cluster │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ postgres-operator (manager Pod) │ │ +│ │ │ │ +│ │ ┌──────────────┐ ┌──────────┐ │ │ +│ │ │ Reconcilers │ │ Webhooks │ │ │ +│ │ │ PostgresCluster│ │ Admission│ │ │ +│ │ │ BackupJob │ │ Validate │ │ │ +│ │ │ Pooler │ └──────────┘ │ │ +│ │ │ PostgresDB │ │ │ +│ │ │ PostgresUser │ ┌──────────┐ │ │ +│ │ │ ScheduledBkp │ │ Plugin │ │ │ +│ │ │ ShardSplitJob│ │ Registry │ │ │ +│ │ └──────────────┘ └──────────┘ │ │ +│ │ │ │ +│ │ ┌────────────────────────────┐ │ │ +│ │ │ HA Failover Lease │ │ │ +│ │ │ (별도 leader election) │ │ │ +│ │ └────────────────────────────┘ │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────────┐ │ +│ │ PostgresCluster "my-cluster" │ │ +│ │ │ │ +│ │ StatefulSet (shard-0) StatefulSet (shard-N) │ │ +│ │ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ primary │ │ primary │ │ │ +│ │ │ replica0 │ ... │ replica0 │ │ │ +│ │ └──────────┘ └──────────┘ │ │ +│ │ │ │ +│ │ Pooler (PgBouncer) ────→ rw endpoint │ │ +│ └────────────────────────────────────────────────────┘ │ +└────────────────────────────────────────────────────────────┘ +``` + +--- + +## 4. CRD 목록 (8종) + +| CRD | 단축명 | 범위 | 역할 | +|---|---|---|---| +| `PostgresCluster` | `pgc` | Namespace | Primary + Replica 토폴로지 핵심 리소스 | +| `BackupJob` | `bj` | Namespace | 1회성 백업 / PITR 복원 | +| `ScheduledBackup` | — | Namespace | 크론 스케줄 백업 자동 생성 | +| `Pooler` | `pool` | Namespace | PgBouncer 커넥션 풀 레이어 | +| `PostgresDatabase` | `pgdb` | Namespace | DB / 스키마 / Extension / FDW 선언적 관리 | +| `PostgresUser` | `pguser` | Namespace | PostgreSQL 역할 / 패스워드 선언적 관리 | +| `ImageCatalog` | `pgic` | Namespace | 네임스페이스 범위 PostgreSQL 이미지 카탈로그 | +| `ClusterImageCatalog` | `pgcic` | Cluster | 클러스터 전체 공유 이미지 카탈로그 | + +--- + +## 5. 컨트롤러 구조 + +모든 컨트롤러는 `cmd/main.go`에서 단일 `controller-runtime Manager`에 등록된다. + +``` +cmd/main.go +├── PostgresClusterReconciler — 클러스터 라이프사이클 (StatefulSet/Service/ConfigMap 생성) +├── BackupJobReconciler — 백업/복원 Job 실행 +├── PostgresDatabaseReconciler — SQL DDL 실행 (psql exec via pod) +├── PostgresUserReconciler — SQL 역할 관리 (psql exec via pod) +├── ScheduledBackupReconciler — 크론 기반 BackupJob 생성 +├── ShardSplitJobReconciler — 샤드 분할 (CRD만, 컨트롤러 구현 예정) +├── PoolerReconciler — PgBouncer Deployment 관리 +└── FailoverLease (Runnable) — HA Failover 전용 Kubernetes Lease +``` + +--- + +## 6. Plugin 아키텍처 + +5종의 Plugin 인터페이스가 `internal/plugin/api.go`에 동결(freeze) 정의되어 있다. + +``` +BackupPlugin — 백업 도구 추상화 (pgBackRest / WAL-G / Barman) +ExporterPlugin — Prometheus exporter 사이드카 +ExtensionPlugin — PostgreSQL extension 라이프사이클 +RouterPlugin — QueryRouter 라우팅 정책 +AuthPlugin — 인증 메커니즘 (SCRAM / mTLS / OIDC) +``` + +현재 등록된 플러그인: + +| 플러그인 | 타입 | shared_preload 순서 | +|---|---|---| +| pgaudit | Extension | 100 | +| pgvector | Extension | 100 | +| pgcron | Extension | 200 | +| pgnodemx | Extension | 300 | +| postgis | Extension | 300 | +| setuser | Extension | 300 | +| pgbackrest | Backup | — | + +--- + +## 7. 바이너리 구성 + +| 바이너리 | 위치 | 역할 | +|---|---|---| +| `manager` | `cmd/main.go` | Operator 컨트롤 플레인 (본 문서 대상) | +| `instance` | `cmd/instance/` | PostgreSQL Pod 내부 PID1 데이터플레인 | +| `pg-router` | `cmd/pg-router/` | PoC 수준 PG wire-protocol 라우터 | + +--- + +## 8. 현재 상태 및 로드맵 + +**현재 (v0.4.0-beta.8)** +- 단일 클러스터 운영: Primary + Replica, HA, 백업, 풀링, 모니터링 — beta 품질 +- PITR restore drill **완료** (2026-06-24 live 7 PASS), 자동 failover reconcile 연결·fencing·promotion 코드 완료 +- `ShardRange` / `ShardSplitJob` CRD 정의 완료, **컨트롤러 미구현** + +**로드맵 (순서대로)** + +1. HA 강화 — ~~PITR drill~~(완료), chaos/node-loss failover live drill 재검증 (ADR-0027 shard-identity) +2. `ShardRange` CRD 컨트롤러 + `pg-router` (수동 멀티 샤드 라우팅) +3. Scatter-gather 쿼리 + 읽기 레플리카 오토스케일 +4. `ShardSplitJob` — 온라인 샤드 분할 +5. 부하 기반 자동 분할/리밸런스 +6. 크로스 샤드 분산 트랜잭션 및 JOIN + +--- + +## 9. 디렉토리 구조 + +``` +postgres-operator/ +├── api/v1alpha1/ # CRD 타입 정의 (Go types) +│ ├── postgrescluster_types.go +│ ├── backupjob_types.go +│ ├── pooler_types.go +│ ├── postgresdatabase_types.go +│ ├── postgresuser_types.go +│ ├── scheduledbackup_types.go +│ ├── imagecatalog_types.go +│ ├── shardrange_types.go # 로드맵 전용 (컨트롤러 없음) +│ └── shardsplitjob_types.go # 로드맵 전용 (컨트롤러 없음) +│ +├── internal/ +│ ├── controller/ # Reconciler 구현체 +│ │ ├── postgrescluster_controller.go +│ │ ├── backupjob_controller.go +│ │ ├── pooler_controller.go +│ │ ├── postgresdatabase_controller.go +│ │ ├── postgresuser_controller.go +│ │ ├── scheduledbackup_controller.go +│ │ ├── shardsplitjob_controller.go +│ │ └── failover/ # HA 자동 failover (detection/lease/promotion) +│ ├── instance/ # PostgreSQL Pod PID1 데이터플레인 +│ │ ├── election/ # Primary lease election +│ │ ├── fencing/ # PVC fencing +│ │ ├── statusapi/ # HTTP status endpoint +│ │ └── supervise/ # Process supervisor +│ ├── plugin/ # Plugin SDK 인터페이스 + 구현체 +│ │ ├── api.go # 5종 인터페이스 동결 정의 +│ │ ├── registry.go # 플러그인 레지스트리 +│ │ ├── backup/ # pgBackRest / WAL-G / Barman +│ │ └── extension/ # pgaudit / pgvector / pgcron / ... +│ ├── postgres/ # SQL DSL (grants 등) +│ ├── router/ # vindex shard 해석 +│ └── webhook/v1alpha1/ # Admission webhook +│ +├── cmd/ +│ ├── main.go # Manager 진입점 +│ ├── instance/ # PID1 instance manager +│ └── pg-router/ # PoC 라우터 +│ +├── config/ +│ ├── crd/bases/ # 자동 생성 CRD YAML (make manifests) +│ └── rbac/ # 자동 생성 RBAC (make manifests) +│ +├── charts/postgres-operator/ # Helm 차트 (배포 대상) +├── docs/ # 각종 문서 (ADR, RFC, runbook 등) +└── Makefile # gate = lint + test + audit + validate +``` + +--- + +## 10. 설치 및 빠른 시작 + +```bash +# Helm으로 Operator 설치 +helm install postgres-operator ./charts/postgres-operator + +# 단일 노드 클러스터 생성 +kubectl apply -f - < 실행 환경: Dev Container (golang:1.26, Debian 13 trixie) | 실행 명령: `make test` +> 테스트 프레임워크: Go 표준 `testing` + Ginkgo v2 (컨트롤러 통합) | envtest: Kubernetes 1.36 + +--- + +## 1. 실행 환경 및 자원 사용 + +### 테스트 레이어 구조 + +``` +make test + ├─ [1] make manifests generate # CRD/RBAC YAML + DeepCopy 코드 재생성 + ├─ [2] go fmt + go vet # 정적 검사 + └─ [3] go test (KUBEBUILDER_ASSETS 주입) + ├─ 단위 테스트 (in-process, 외부 의존 없음) + └─ envtest 통합 테스트 (in-process K8s API + etcd 바이너리) +``` + +### 자원 사용 요약 + +| 자원 | 내용 | +|---|---| +| 프로세스 | goroutine 기반 — 실제 컨테이너/VM 없음 | +| 네트워크 | 없음 (fake clientset 또는 in-process API 서버) | +| 스토리지 | `t.TempDir()` 임시 디렉토리 + `bin/k8s/1.36.0-linux-amd64/` envtest 바이너리 | +| envtest 바이너리 | `etcd`, `kube-apiserver`, `kubectl` (로컬 다운로드, 실행 시 자동 기동) | +| 소요 시간 | 단위: 1~5s / envtest: 12~13s (API 서버 기동 포함) | + +--- + +## 2. 패키지별 테스트 결과 + +| 패키지 | 커버리지 | 소요 시간 | 테스트 방식 | +|---|---|---|---| +| `api/v1alpha1` | 2.2% | 0.05s | 단위 (타입 검증) | +| `cmd/instance` | 6.9% | 0.19s | 단위 (진입점 smoke) | +| `cmd/pg-router` | 31.9% | 0.01s | 단위 | +| `internal/controller` | 72.6% | 11.9s | **envtest 통합** | +| `internal/controller/failover` | **98.8%** | 4.1s | 단위 (fake clientset) | +| `internal/instance/election` | **96.5%** | 12.9s | 단위 + 실시간 lease 경합 | +| `internal/instance/fencing` | **89.7%** | 0.3s | 단위 (fake clientset) | +| `internal/instance/supervise` | 78.2% | 3.4s | 실행 프로세스 기반 | +| `internal/plugin` | **91.7%** | 0.02s | 단위 | +| `internal/plugin/backup/*` | 76~86% | 0.02~0.05s | 단위 | +| `internal/postgres` | **95.4%** | 0.5s | 단위 (SQL 생성) | +| `internal/router` | 74.6% | 0.04s | 단위 | +| `internal/version` | 78.6% | 0.03s | 단위 | +| `internal/webhook/v1alpha1` | **94.7%** | 7.7s | envtest 통합 | +| `test/chart` | — | 0.02s | YAML 정적 검증 | + +--- + +## 3. 패키지별 TC 상세 + +--- + +### 3.1 `internal/controller/failover` — HA Failover 핵심 (커버리지 98.8%) + +**목적**: Primary Pod 장애 감지 → 승격 후보 선정 → Promotion Plan 실행까지 3단계 로직이 올바르게 작동하는지 검증. + +#### Detection (장애 감지) + +| TC | 시나리오 | 검증 내용 | +|---|---|---| +| `TestDetectPrimaryFailure_HealthyPrimary` | Primary가 Ready=true | `Failed=false`, `Reason=None`, `PromotionCandidate=nil` | +| `TestDetectPrimaryFailure_NoPrimary` | Primary pod 없음, Replica 2개 Ready | `Failed=true`, `Reason=NoPrimary`, 가장 낮은 Lag(20B)의 `pg-2` 선정 | +| `TestDetectPrimaryFailure_NotReady` | Primary Ready=false | `Failed=true`, `Reason=PrimaryNotReady`, 메시지에 pod 이름 포함 | +| `TestDetectPrimaryFailure_NoEligibleReplica` | Primary 없음 + Replica 전부 NotReady | `Reason=NoEligibleReplica`, `PromotionCandidate=nil`, 메시지에 "manual intervention" | +| `TestSelectPromotionCandidate_OrdersByLagThenName` | 5개 하위 케이스 (빈 목록 / 전부 NotReady / Lag 차이 / 동률-사전순 / NotReady 제외) | LagBytes 오름차순 → 동률 시 Pod 이름 사전순으로 후보 선정 | + +**자원**: 실제 K8s API 불필요 — `ShardStatus` struct 직접 생성. + +#### Lease (리더 선출 경합) + +| TC | 시나리오 | 검증 내용 | +|---|---|---| +| `TestLeaseElection` | A, B 두 Operator Pod가 동일 Lease 경합 | ① A가 먼저 Leader 획득, ② B는 A가 active인 동안 Leader 안 됨(단일성), ③ A cancel 후 B가 Lease 승계, ④ OnStartedLeading 순서·OnStoppedLeading 콜백 검증 | +| `TestNewLeaseValidation` | nil client / 빈 Namespace / 빈 Identity | 각각 error 반환, LeaseName 미지정 시 기본값 자동 적용 | + +**자원**: `k8s.io/client-go/kubernetes/fake` — 실 etcd 없이 in-memory API. +**타이밍**: LeaseDuration=1.5s, RenewDeadline=1s, RetryPeriod=200ms (실제 값의 ~1/10 축소). + +#### Promotion (승격 실행) + +| TC | 시나리오 | 검증 내용 | +|---|---|---| +| `TestBuildPromotionPlan_Steps` | 정상 후보(`pg-1`) 로 Plan 생성 | 4단계 순서 고정: `RemoveStandbySignal → PgCtlPromote → WaitNotInRecovery → UpdateInstanceRole` | +| `TestBuildPromotionPlan_NilCandidate` | 후보 nil | `ErrNoPromotionTarget` | +| `TestPromoteFromDecision_HealthyNoOp` | `Decision.Failed=false` | Promoter 미호출 (no-op) | +| `TestPromoteFromDecision_ExecutesPlan` | `Failed=true`, 후보 있음 | fakePromoter 1회 호출, ShardName 검증 | +| `TestPromoteFromDecision_NilPromoter` | Promoter=nil | "nil Promoter" 포함 에러 | +| `TestPromoteFromDecision_NoCandidate` | `NoEligibleReplica` | `ErrNoPromotionTarget` wrap | +| `TestPromoteFromDecision_PromoterErrorWrapped` | Promoter가 에러 반환 | 원본 에러 chain 유지, shard/pod 이름이 wrap 메시지에 포함 | + +--- + +### 3.2 `internal/instance/election` — Primary Lease 선출 (커버리지 96.5%) + +**목적**: 각 Shard Pod가 Kubernetes Lease를 통해 Primary를 선출하는 메커니즘 검증. RFC 0003 §1~§9 코드 강제. + +| TC 그룹 | 검증 내용 | +|---|---| +| **Lease 파라미터 유효성** | `LeaseDuration > RenewDeadline > RetryPeriod` 제약 위반 시 에러; 기본값이 제약을 만족함 | +| **Lease 이름 규약** | `PrimaryLeaseName("orders","shard",0)` → `"orders-shard-0-primary"`, ordinal -1 에러, role=router 에러 | +| **Reshard Target Lease 이름** | `ReshardTargetLeaseName("orders","shard-0a")` → `"orders-rsd-shard-0a-primary"`, 빈 shardID 에러 | +| **충돌 격리 불변식** | ordinal 0~999와 reshard target lease 이름이 절대 겹치지 않음 (`-rsd-` segment로 분리) | +| **Real 입력 검증** | nil client / 빈 LeaseName / Namespace / Identity → 에러 | +| **Null Election** | 단독 노드 — Run() 즉시 Leader, OnStartedLeading·OnNewLeader·OnStoppedLeading 콜백 순서 검증 | +| **Follower Election** | 영구 Follower — ctx cancel 후 OnStartedLeading 미호출 확인 | +| **Mock Election** | SetStatus(Leader) → OnStartedLeading 발화, SetExternalLeader("p2") → 현 Leader demote + OnNewLeader("p2") | +| **인터페이스 일관성** | Real / Null / Follower / Mock 모두 `Election` 인터페이스를 만족하는지 컴파일 가드 | + +**자원**: fake clientset, 실제 타이머 (LeaseDuration ~1.5s), goroutine. + +--- + +### 3.3 `internal/instance/fencing` — PVC Fencing (커버리지 89.7%) + +**목적**: 장애 발생 시 Split-brain 방지를 위한 PVC 라벨 기반 Fencing 동작 검증. + +| TC | 검증 내용 | +|---|---| +| `TestPVCName_AppendsDataPrefix` | `"orders-coordinator-0"` → `"data-orders-coordinator-0"` 명명 규약 | +| `TestNewReal_RejectsEmptyFields` | nil client / 빈 namespace / 빈 PVCName → 에러 | +| `TestReal_MarkFenced_AddsLabel` | fake PVC에 `MarkFenced()` → `fencing=true` 라벨 추가 확인 | +| `TestReal_MarkFenced_Idempotent` | 이미 Fenced PVC에 2회 호출해도 에러 없음 | +| `TestReal_Unfence_RemovesLabel` | Fence 라벨만 제거, 다른 라벨(`app=postgres`) 보존 | +| `TestReal_IsFenced_Reflects` | 4개 케이스: 라벨 없음 / 무관 라벨 / fenced=true / fenced=false-string | +| `TestReal_IsFenced_NotFound` | PVC 미존재 → NotFound 에러 | +| `TestReal_VerifyNotFenced_*` | Fenced → `ErrFenced`, 클린 → nil | +| `TestMock_LifecycleAndCounters` | MarkFenced/Unfence/IsFenced 호출 카운터 + SetFenced 직접 전이 | + +**자원**: fake clientset — 실 K8s API 없음, in-memory PVC 객체. + +--- + +### 3.4 `internal/instance/supervise` — PostgreSQL 프로세스 관리 (커버리지 78.2%) + +**목적**: PostgreSQL 프로세스(PID1 역할)의 Start/Stop/Reload/SIGKILL 동작을 실제 OS 프로세스로 검증. + +**픽스처**: `testdata/fake-postgres.sh` — 실제 postgres 없이 signal trap을 흉내내는 bash 스크립트. + +| TC | 검증 내용 | +|---|---| +| `TestNewReal_RejectsEmptyFields` | BinPath/DataDir/ConfigFile/HbaFile/LocalDSN 중 하나라도 비면 에러 | +| `TestNewReal_DefaultPort` | Port=0 → 5432 기본값 적용 | +| `TestReal_PIDZeroBeforeStart` | Start 전 PID=0 | +| `TestReal_StartStop` | Start → PID>0, Stop(5s timeout) → 정상 종료 | +| `TestReal_StartTwiceErrors` | 이미 실행 중에 Start → 에러 | +| `TestReal_StopBeforeStartErrors` | Start 전 Stop → 에러 | +| `TestReal_StopFastImmediate` | `INT_DELAY=0` 환경변수 → 즉시 종료 | +| `TestReal_StopTimeoutSendsSIGKILL` | `TERM_DELAY=2` 설정, 1초 ctx → timeout 에러 반환 후 SIGKILL로 프로세스 종료, ExitCh 신호 확인 | +| `TestReal_Reload_DoesNotError` | SIGHUP 전송 → fake-postgres가 "RELOADED" 출력 확인 | +| `TestReal_StartFailsWithBadBinary` | 존재하지 않는 바이너리 경로 → Start 에러 | +| `TestReal_ExitCh_BroadcastsExit` | `EXIT_AFTER=0.2` → 0.2초 후 ExitCh 신호 수신 | +| `TestReal_StartFailsImmediately` | `FAIL_ON_START=1` → fork 성공 후 즉시 비정상 종료, ExitCh에 non-nil 에러 | +| `TestMock_*` | Mock 구현의 Start/Stop 카운터, 에러 주입, SlotCallTracking, SimulateExit, IsReady 상태 전이 | + +**자원**: 실제 OS 프로세스 fork/exec (bash), `t.TempDir()`, POSIX signal (SIGTERM, SIGHUP, SIGKILL), stderr pipe. + +--- + +### 3.5 `internal/postgres` — SQL DDL 빌더 (커버리지 95.4%) + +**목적**: `GRANT`, `REVOKE`, `ALTER DEFAULT PRIVILEGES` SQL 문이 결정적으로, 안전하게 생성되는지 검증. + +| TC | 검증 내용 | +|---|---| +| TABLE SELECT/INSERT | 정렬(알파벳) 후 결정적 SQL 출력 | +| ALL → ALL PRIVILEGES | `ALL` 키워드 확장 | +| WITH GRANT OPTION | SQL 접미사 추가 | +| REVOKE 일반 / GRANT OPTION FOR | `REVOKE`/`REVOKE GRANT OPTION FOR` 구분 | +| DEFAULT PRIVILEGES TABLE | `ALTER DEFAULT PRIVILEGES IN SCHEMA ... GRANT ... ON TABLES` | +| DEFAULT PRIVILEGES DATABASE/SCHEMA 거부 | `ErrInvalidGrant` — 지원하지 않는 object class 방어 | +| 입력 검증 | 빈 grantee/names/privileges, 잘못된 class/privilege → `ErrInvalidGrant` | +| quoting | `schema.table` → `"schema"."table"` 분리, `"` → `""` 이스케이프 | +| 중복 privilege 제거 | `SELECT,select,SELECT` → `SELECT` 단일화 | +| 결정성 | 동일 입력 → 동일 출력 (순서 고정 보장) | +| FUNCTION / SEQUENCE | `ON FUNCTION`, `ON SEQUENCE` 문법 지원 | + +**자원**: 순수 Go 함수 — OS/네트워크 의존 없음. + +--- + +### 3.6 `internal/plugin` — Plugin Registry (커버리지 91.7%) + +**목적**: 5종 플러그인 인터페이스(BackupPlugin, ExporterPlugin, ExtensionPlugin, RouterPlugin, AuthPlugin)가 올바르게 등록·조회되는지 검증. + +| TC | 검증 내용 | +|---|---| +| `TestRegistry_RegisterAndLookup` | 5종 플러그인 등록 후 이름으로 조회 | +| `TestRegistry_DuplicatePanics` | 동일 이름 중복 등록 → panic | +| `TestBackupOptions_ExecutionModeField` | `sidecar` / `job` / 빈 문자열 3가지 mode 컴파일 가드 | +| `TestEnabledExtensions_FilterAndSort` | ① 부분 opt-in + SharedPreloadOrder 정렬, ② missing 이름 보고, ③ nil → 빈 슬라이스 | + +--- + +### 3.7 `internal/router` — Shard 라우팅 (커버리지 74.6%) + +**목적**: ShardRange 기반 Vindex(hash/range/consistent-hash/lookup) 라우팅이 결정적으로 동작하는지 검증. + +| TC | 검증 내용 | +|---|---| +| hash murmur3 결정성 | 동일 key → 동일 shard (2회 반복) | +| hash fnv / crc32 / murmur3 | 3개 hash function 모두 유효한 shard 반환 | +| range 사전식 비교 | `AA`→shard-west, `MN`→shard-east 등 4개 케이스 | +| range gap → ErrVindexNoMatch | 매핑 없는 key에 명시적 에러 | +| consistent-hash / lookup → ErrVindexUnsupported | 미구현 type에 명시적 에러 (미래 변경 방지) | +| ValidateNoOverlap 정상 | 비겹침 range 검증 통과 | +| ValidateNoOverlap overlap 검출 | `0x70000000` 겹침 구간 감지 | +| ValidateNoOverlap non-hash skip | range type은 hex 파싱 없이 skip | +| murmur3 known vector | `murmur3('', seed=0) == 0` (Apache Vitess 기준) | + +--- + +### 3.8 `internal/version` — 이미지 버전 매트릭스 (커버리지 78.6%) + +**목적**: 지원 PG 버전 목록과 이미지 digest pin 정책이 회귀되지 않도록 봉인. + +| TC | 검증 내용 | +|---|---| +| PG18 stable | `ChannelStable`, 이미지에 `@sha256:` 포함 (digest pin 강제) | +| PG17 stable | `ChannelStable` | +| PG16 stable | `ChannelStable` (legacy) | +| PG15/99 미지원 | `IsSupported` → `ok=false` | +| `Stable()` 결과 | 최소 1개 이상, 전부 `ChannelStable` | + +**배경**: PG18 이미지는 mutable tag 대신 digest pin — 캐시된 stale 바이너리가 부팅되면 Fence Deadlock이 발생하는 버그(issue #218)에 대한 영구 방어. + +--- + +### 3.9 `internal/controller` — Reconciler 통합 테스트 (커버리지 72.6%) + +**목적**: 실제 Kubernetes API Server(envtest)에 CRD를 등록하고, Reconciler가 StatefulSet/Service/ConfigMap 등의 desired state를 올바르게 생성·갱신하는지 검증. + +**환경**: in-process `etcd` + `kube-apiserver` 바이너리 기동, Ginkgo BDD 스타일. + +주요 검증 항목 (test 파일 목록 기준): +- `postgrescluster_controller_test.go`: PostgresCluster 생성 → StatefulSet/Service 생성 확인 +- `backupjob_controller_test.go`: BackupJob Reconcile 흐름 +- `pooler_controller_test.go`: PgBouncer Deployment 생성 +- `postgresdatabase_controller_test.go`: PostgresDatabase DDL 실행 +- `postgresuser_controller_test.go`: PostgresUser 역할 관리 +- `scheduledbackup_controller_test.go`: 크론 기반 BackupJob 자동 생성 +- `failover_debounce_test.go`, `failover_promoter_test.go`: 8s debounce + promotion 통합 흐름 +- `aggregate_status_test.go`: 다수 shard의 Condition 집계 +- `cascade_delete_test.go`: Owner Reference 기반 연쇄 삭제 +- `pdb_test.go`: PodDisruptionBudget 생성/갱신 + +--- + +### 3.10 `internal/webhook/v1alpha1` — Admission Webhook (커버리지 94.7%) + +**목적**: PostgresCluster CRD 생성/수정 요청의 Admission 검증 로직이 올바르게 거부·허용하는지 검증. + +**환경**: envtest (webhook 인증서 자동 발급 포함). + +검증 항목: +- `admission_roundtrip_test.go`: Defaulting → Validation 왕복 테스트 +- `postgrescluster_webhook_test.go`: spec 유효성 (지원 PG 버전, shard 수, 스토리지 크기 등) 위반 케이스 거부 + +--- + +## 4. 테스트 계층 정리 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ e2e (test/e2e/) │ +│ Kind 클러스터 + 실제 PostgreSQL Pod — make test-e2e │ +│ (현재 실행 제외; Kind 클러스터 생성 필요) │ +├──────────────────────────────────────────────────────────────┤ +│ 통합 테스트 (envtest) │ +│ in-process etcd + kube-apiserver, 실제 CRD/Reconciler │ +│ → internal/controller, internal/webhook/v1alpha1 │ +├──────────────────────────────────────────────────────────────┤ +│ 단위 테스트 (fake clientset / OS process / pure function) │ +│ → failover, election, fencing, supervise, postgres, plugin │ +│ router, version, backup plugins │ +└──────────────────────────────────────────────────────────────┘ +``` + +--- + +## 5. 현재 커버리지 미비 영역 + +| 패키지 | 커버리지 | 비고 | +|---|---|---| +| `api/v1alpha1` | 2.2% | CRD 타입 정의 — 대부분 구조체, 동작 코드 적음 | +| `cmd/instance` | 6.9% | PID1 main() — OS 의존으로 통합 테스트 한계 | +| `internal/plugin/extension/*` | 0% | pgaudit/pgvector 등 — SQL 실행 필요, 실 PG 없이 불가 | +| `internal/instance/statusapi` | 0% | HTTP 상태 API — 단위 테스트 미작성 | +| `cmd/reshard-copy-poc` | 0% | PoC 코드 — 테스트 미작성 | + +--- + +## 6. CRLF 문제와 .gitattributes 조치 + +### 문제 원인 + +Windows git의 기본 설정(`core.autocrlf=true`)은 checkout 시 LF→CRLF 변환을 수행한다. +`#!/usr/bin/env bash\r` 형태의 shebang은 Linux에서 `bash\r` 라는 존재하지 않는 인터프리터로 처리되어 실행이 실패한다. + +### 영향 범위 + +| 파일 | 증상 | +|---|---| +| `.devcontainer/post-install.sh` | `set: pipefail: invalid option name` — 컨테이너 기동 실패 | +| `testdata/fake-postgres.sh` | `env: 'bash\r': No such file or directory` — supervise 테스트 2건 실패 | + +### 조치 1: `.gitattributes` 생성 (영구 해결) + +`.gitattributes` 파일 추가로 git이 `.sh`를 포함한 모든 텍스트 파일을 LF로 관리하도록 강제한다. +이후 Windows/Mac/Linux 어떤 환경에서 클론해도 스크립트는 LF로 체크아웃된다. + +``` +# 핵심 규칙 +* text=auto eol=lf # 기본: 모든 텍스트 LF +*.sh text eol=lf # 셸 스크립트 명시적 LF 강제 +*.go text eol=lf +``` + +### 조치 2: 기존 파일 정상화 (기존 클론 환경) + +`.gitattributes` 추가 후 기존에 이미 클론된 환경에서는 `git` 명령으로 파일을 재정상화해야 한다: + +```bash +# 인덱스 전체를 재노멀라이즈 +git add --renormalize . +git commit -m "chore: normalize line endings via .gitattributes" +``` + +또는 작업 트리의 파일만 즉시 수정: + +```bash +# Linux/macOS/WSL 또는 Dev Container 내부에서 +find . -name "*.sh" -exec sed -i 's/\r//' {} + +``` diff --git a/docs/dev-setup-devcontainer.md b/docs/dev-setup-devcontainer.md new file mode 100644 index 0000000..7f2ce0b --- /dev/null +++ b/docs/dev-setup-devcontainer.md @@ -0,0 +1,152 @@ +# Windows 개발 환경 설정 — Dev Container + +> Windows 환경에서 VS Code Dev Container를 이용해 postgres-operator 개발 환경을 구성하는 가이드. +> 실제 코드는 Linux 컨테이너 안에서 실행되므로 Makefile, bash 스크립트, envtest가 모두 정상 동작한다. + +## 사전 요구사항 + +| 항목 | 버전 | 설치 여부 확인 | +|---|---|---| +| Windows | 10 21H2+ 또는 11 | `winver` | +| Docker Desktop | 4.x+ | `docker version` | +| VS Code | 최신 | `code --version` | +| VS Code 확장 — Dev Containers | ms-vscode-remote.remote-containers | VS Code 확장 탭 | + +> Docker Desktop이 실행 중이어야 한다. 트레이 아이콘에서 "Running" 상태 확인. + +## 1. devcontainer.json 확인 + +`go.mod`는 `go 1.26.0`을 요구한다. 컨테이너 빌드 전에 `.devcontainer/devcontainer.json`의 +Go 이미지 버전이 `golang:1.26`인지 확인한다. + +`.devcontainer/devcontainer.json`: + +```json +{ + "name": "Kubebuilder DevContainer", + "image": "golang:1.26", + ... +} +``` + +## 2. VS Code에서 컨테이너 열기 + +1. VS Code에서 저장소 폴더(`C:\keiailab\postgres-operator`) 열기 +2. 좌하단 파란 아이콘 클릭 → **"Reopen in Container"** 선택 +3. 또는 `Ctrl+Shift+P` → `Dev Containers: Reopen in Container` +4. 컨테이너 빌드 완료까지 대기 (최초 실행 시 이미지 pull + `post-install.sh` 실행으로 5~10분 소요) + +`post-install.sh`가 자동으로 아래 도구를 설치한다: + +| 도구 | 설명 | +|---|---| +| `kind` | 로컬 Kubernetes 클러스터 (e2e 테스트용) | +| `kubebuilder` | CRD/RBAC 스캐폴딩 CLI | +| `kubectl` | Kubernetes 클라이언트 | + +## 3. 설치 확인 + +컨테이너 터미널에서: + +```bash +go version # go version go1.26.x linux/amd64 +kind version # kind v0.x.x +kubectl version --client +kubebuilder version +docker --version # Docker Desktop DinD +``` + +## 4. 빌드 및 테스트 + +```bash +# Go 의존성 다운로드 +go mod download + +# 단위 테스트 — 클러스터 불필요, 가장 빠름 +make test-unit + +# CRD/RBAC 생성 + 통합 테스트 (envtest 사용) +make test + +# 전체 품질 게이트: lint + test + audit + validate +make gate +``` + +## 5. 로컬 Kubernetes 클러스터 띄우기 (e2e 테스트) + +```bash +# kind 클러스터 생성 (make test-e2e가 자동으로 수행하지만 수동으로도 가능) +kind create cluster --name postgres-operator-dev + +# kubeconfig 확인 +kubectl cluster-info --context kind-postgres-operator-dev + +# e2e 테스트 실행 +make test-e2e + +# 클러스터 삭제 +kind delete cluster --name postgres-operator-dev +``` + +## 6. Operator 이미지 빌드 및 클러스터 적재 + +```bash +# 이미지 빌드 +make docker-build IMG=postgres-operator:dev + +# kind 클러스터에 이미지 로드 (registry push 없이 로컬 테스트) +kind load docker-image postgres-operator:dev --name postgres-operator-dev + +# Helm으로 배포 +helm install postgres-operator ./charts/postgres-operator \ + --set image.repository=postgres-operator \ + --set image.tag=dev \ + --set image.pullPolicy=Never +``` + +## 7. 주요 Make 타겟 정리 + +| 타겟 | 동작 | 소요 시간 | +|---|---|---| +| `make test-unit` | 단위 테스트 (envtest 불필요) | ~30초 | +| `make test` | 단위 + 통합 테스트 | ~2분 | +| `make test-e2e` | kind 기반 e2e 테스트 | ~10분 | +| `make lint` | golangci-lint | ~1분 | +| `make gate` | lint + test + audit + validate | ~5분 | +| `make manifests generate` | CRD/RBAC/DeepCopy 재생성 | ~20초 | +| `make build` | manager 바이너리 빌드 | ~1분 | +| `make docker-build` | 컨테이너 이미지 빌드 | ~3분 | + +## 트러블슈팅 + +### Docker Desktop이 컨테이너 안에서 인식 안 될 때 + +Docker Desktop → Settings → Resources → WSL Integration에서 +"Enable integration with my default WSL distro"가 켜져 있는지 확인. + +### post-install.sh 실행 중 curl 타임아웃 + +회사 네트워크 프록시 환경이면 `.devcontainer/devcontainer.json`에 프록시 환경 변수 추가: + +```json +"remoteEnv": { + "GO111MODULE": "on", + "HTTP_PROXY": "http://proxy.example.com:8080", + "HTTPS_PROXY": "http://proxy.example.com:8080" +} +``` + +### go: module lookup disabled by GONOSUMCHECK + +```bash +go env -w GONOSUMDB="*" +go env -w GOFLAGS="-mod=mod" +``` + +### CRD 변경 후 테스트 실패 + +API 타입(`api/v1alpha1/`) 수정 후에는 반드시 재생성 필요: + +```bash +make manifests generate +``` diff --git a/docs/dev-setup-wsl.md b/docs/dev-setup-wsl.md new file mode 100644 index 0000000..6f2234f --- /dev/null +++ b/docs/dev-setup-wsl.md @@ -0,0 +1,270 @@ +# Windows 개발 환경 설정 — WSL2 + +> Windows WSL2(Ubuntu) 환경에서 postgres-operator 개발 환경을 구성하는 가이드. +> WSL2의 네이티브 Linux 파일시스템을 사용하므로 빌드 속도와 도구 호환성이 네이티브에 가깝다. + +## 사전 요구사항 + +| 항목 | 확인 명령 (PowerShell) | +|---|---| +| Windows 10 21H2+ 또는 11 | `winver` | +| WSL2 활성화 | `wsl --status` | +| Ubuntu 배포판 | `wsl --list --verbose` | +| Docker Desktop (선택) | 트레이 아이콘 | + +### WSL2 신규 설치가 필요한 경우 + +PowerShell (관리자)에서: + +```powershell +wsl --install +# 재부팅 후 Ubuntu 초기 사용자 설정 완료 +``` + +이미 WSL2 + Ubuntu가 설치된 경우 이 단계는 건너뛴다. + +## 1. 소스 파일 위치 결정 (중요) + +`/mnt/c/...`(Windows 파일시스템)에서 직접 빌드하면 크로스 파일시스템 I/O 오버헤드로 +**빌드 속도가 10배 이상 저하**된다. WSL 네이티브 파일시스템에 소스를 두어야 한다. + +WSL 터미널에서: + +```bash +# 방법 A: Windows에서 클론한 소스를 WSL 홈으로 복사 +cp -r /mnt/c/keiailab/postgres-operator ~/postgres-operator + +# 방법 B: WSL 안에서 직접 클론 (권장) +git clone https://github.com/KeiaiLab/postgres-operator ~/postgres-operator + +cd ~/postgres-operator +``` + +> Windows에서 편집한 파일을 WSL에서 사용할 경우 방법 A 이후 변경사항은 +> `cp` 또는 `rsync`로 동기화하거나, VS Code Remote WSL 확장으로 WSL 경로를 직접 편집하는 것을 권장한다. + +## 2. Go 1.26 설치 + +```bash +GO_VERSION=1.26.4 +curl -Lo /tmp/go.tar.gz "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" +sudo rm -rf /usr/local/go +sudo tar -C /usr/local -xzf /tmp/go.tar.gz +rm /tmp/go.tar.gz + +# PATH 등록 +echo 'export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin' >> ~/.bashrc +source ~/.bashrc + +# 확인 +go version # go version go1.26.4 linux/amd64 +``` + +## 3. kubectl 설치 + +```bash +KUBECTL_VERSION=$(curl -Ls https://dl.k8s.io/release/stable.txt) +curl -Lo /tmp/kubectl "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" +chmod +x /tmp/kubectl +sudo mv /tmp/kubectl /usr/local/bin/kubectl + +kubectl version --client +``` + +## 4. Helm 설치 + +```bash +curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash + +helm version +``` + +## 5. kind 설치 (e2e 테스트용) + +```bash +curl -Lo /tmp/kind "https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64" +chmod +x /tmp/kind +sudo mv /tmp/kind /usr/local/bin/kind + +kind version +``` + +## 6. Docker 연동 확인 + +Docker는 두 가지 방법으로 사용할 수 있다. + +### 방법 A: Docker Desktop WSL2 통합 (권장) + +Docker Desktop이 설치되어 있고 WSL2 통합이 활성화된 경우, WSL 안에서 별도 설치 없이 docker 명령을 사용할 수 있다. + +Docker Desktop → Settings → Resources → WSL Integration: +- "Enable integration with my default WSL distro" 켜기 +- Ubuntu 배포판 토글 활성화 +- "Apply & Restart" + +```bash +# WSL 터미널에서 확인 +docker version +docker info +``` + +### 방법 B: WSL 안에 Docker Engine 직접 설치 + +Docker Desktop 없이 독립적으로 사용하려면: + +```bash +curl -fsSL https://get.docker.com | sh +sudo usermod -aG docker $USER +newgrp docker + +docker version +``` + +## 7. 설치 전체 확인 + +```bash +go version # go version go1.26.x linux/amd64 +docker version # Client/Server 모두 출력되어야 함 +kubectl version --client +helm version +kind version +make --version # GNU Make (Ubuntu 기본 포함) +``` + +## 8. 빌드 및 테스트 + +```bash +cd ~/postgres-operator + +# Go 의존성 다운로드 +go mod download + +# 단위 테스트 — 클러스터 불필요, 가장 빠름 +make test-unit + +# CRD/RBAC 생성 + 통합 테스트 (envtest 사용) +make test + +# 전체 품질 게이트: lint + test + audit + validate +make gate +``` + +## 9. 로컬 Kubernetes 클러스터 띄우기 (e2e 테스트) + +```bash +# kind 클러스터 생성 +kind create cluster --name postgres-operator-dev + +# kubeconfig 확인 +kubectl cluster-info --context kind-postgres-operator-dev + +# e2e 테스트 실행 +make test-e2e + +# 클러스터 삭제 +kind delete cluster --name postgres-operator-dev +``` + +## 10. Operator 이미지 빌드 및 클러스터 적재 + +```bash +# 이미지 빌드 +make docker-build IMG=postgres-operator:dev + +# kind 클러스터에 이미지 로드 (registry push 없이 로컬 테스트) +kind load docker-image postgres-operator:dev --name postgres-operator-dev + +# Helm으로 배포 +helm install postgres-operator ./charts/postgres-operator \ + --set image.repository=postgres-operator \ + --set image.tag=dev \ + --set image.pullPolicy=Never +``` + +## 11. VS Code에서 WSL 경로 편집 + +VS Code에 **Remote - WSL** 확장(`ms-vscode-remote.remote-wsl`)을 설치하면 +WSL 파일시스템을 Windows VS Code에서 직접 편집할 수 있다. + +```bash +# WSL 터미널에서 프로젝트 폴더를 VS Code로 열기 +code ~/postgres-operator +``` + +WSL 경로에서 열린 VS Code는 Go 언어 서버(gopls), lint, 디버거가 모두 Linux 환경에서 실행되어 +정확한 IntelliSense와 오류 표시를 제공한다. + +## 주요 Make 타겟 정리 + +| 타겟 | 동작 | 소요 시간 | +|---|---|---| +| `make test-unit` | 단위 테스트 (envtest 불필요) | ~30초 | +| `make test` | 단위 + 통합 테스트 | ~2분 | +| `make test-e2e` | kind 기반 e2e 테스트 | ~10분 | +| `make lint` | golangci-lint | ~1분 | +| `make gate` | lint + test + audit + validate | ~5분 | +| `make manifests generate` | CRD/RBAC/DeepCopy 재생성 | ~20초 | +| `make build` | manager 바이너리 빌드 | ~1분 | +| `make docker-build` | 컨테이너 이미지 빌드 | ~3분 | + +## 트러블슈팅 + +### /mnt/c/ 경로에서 빌드가 매우 느릴 때 + +소스가 `/mnt/c/` 아래에 있으면 반드시 WSL 네이티브 경로로 이동: + +```bash +cp -r /mnt/c/keiailab/postgres-operator ~/postgres-operator +cd ~/postgres-operator +``` + +### Docker daemon에 연결 안 될 때 + +Docker Desktop WSL 통합이 활성화되어 있는지 확인. +Docker Desktop이 Windows에서 실행 중이어야 한다. + +```bash +# daemon 소켓 확인 +ls /var/run/docker.sock +docker info +``` + +### WSL 메모리 제한으로 빌드 실패 + +기본값으로 WSL2는 시스템 RAM의 50%를 사용한다. 부족할 경우 `%USERPROFILE%\.wslconfig` 생성: + +```ini +[wsl2] +memory=6GB +processors=4 +``` + +PowerShell에서 WSL 재시작: + +```powershell +wsl --shutdown +wsl +``` + +### envtest 바이너리 다운로드 실패 (프록시 환경) + +```bash +export HTTP_PROXY=http://proxy.example.com:8080 +export HTTPS_PROXY=http://proxy.example.com:8080 +make test +``` + +### CRD 변경 후 테스트 실패 + +API 타입(`api/v1alpha1/`) 수정 후에는 반드시 재생성 필요: + +```bash +make manifests generate +``` + +### go: cannot find module providing package + +```bash +go mod tidy +go mod download +``` From dd2d8df6007631cd4e1a8c3f74190df468a6f341 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Thu, 25 Jun 2026 16:51:21 +0900 Subject: [PATCH 007/109] =?UTF-8?q?chore(dev):=20devcontainer=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20+=20gitattributes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit devcontainer 정의/lock 을 갱신하고, Windows CRLF 로 .sh shebang 이 깨지던 문제를 .gitattributes 로 영구 차단한다. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01H4fFn82hrtvuCfuZnBMaqZ --- .devcontainer/devcontainer-lock.json | 19 ++++++++++++++++ .devcontainer/devcontainer.json | 2 +- .gitattributes | 33 ++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 .devcontainer/devcontainer-lock.json create mode 100644 .gitattributes diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000..3b14dc6 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,19 @@ +{ + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "version": "2.5.9", + "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "integrity": "sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a" + }, + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "version": "2.17.0", + "resolved": "ghcr.io/devcontainers/features/docker-in-docker@sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c", + "integrity": "sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "1.3.5", + "resolved": "ghcr.io/devcontainers/features/git@sha256:27905dc196c01f77d6ba8709cb82eeaf330b3b108772e2f02d1cd0d826de1251", + "integrity": "sha256:27905dc196c01f77d6ba8709cb82eeaf330b3b108772e2f02d1cd0d826de1251" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6d4865a..a96838b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ { "name": "Kubebuilder DevContainer", - "image": "golang:1.25", + "image": "golang:1.26", "features": { "ghcr.io/devcontainers/features/docker-in-docker:2": { "moby": false, diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e0921de --- /dev/null +++ b/.gitattributes @@ -0,0 +1,33 @@ +# 모든 텍스트 파일의 기본 라인 엔딩: LF (Unix) +* text=auto eol=lf + +# 명시적으로 LF 강제 (스크립트 실행 파일) +*.sh text eol=lf +*.bash text eol=lf + +# Go 소스 코드 +*.go text eol=lf + +# 설정 / 마크다운 / 기타 텍스트 +*.md text eol=lf +*.json text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +Makefile text eol=lf +Dockerfile* text eol=lf +*.toml text eol=lf +*.mod text eol=lf +*.sum text eol=lf +*.txt text eol=lf +*.env text eol=lf + +# 바이너리 파일 (라인 엔딩 변환 금지) +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.zip binary +*.tar binary +*.gz binary +*.pdf binary From e9804f281febd01d2ab374792b95df695e35d2e8 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Thu, 25 Jun 2026 20:35:32 +0900 Subject: [PATCH 008/109] =?UTF-8?q?style(controller):=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EB=8D=95=EC=85=98=20lint=20=EC=A7=80=EC=A0=81=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20(mapsloop=C2=B7unparam)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - builders.go: pvcLabels 복사 루프를 maps.Copy 로 교체 (modernize/mapsloop). - backupjob_controller.go: ensureClusterRestoreAnnotation 의 항상 nil 인 ctrl.Result 반환값을 제거하고 단일 호출부를 정렬 (unparam). 동작 변경 없음. 남은 lint 지적은 테스트 코드 스타일 또는 미변경 baseline 파일. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01H4fFn82hrtvuCfuZnBMaqZ --- internal/controller/backupjob_controller.go | 14 +++++++------- internal/controller/builders.go | 5 ++--- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/internal/controller/backupjob_controller.go b/internal/controller/backupjob_controller.go index 0710bf7..a7948fa 100644 --- a/internal/controller/backupjob_controller.go +++ b/internal/controller/backupjob_controller.go @@ -558,8 +558,8 @@ func (r *BackupJobReconciler) reconcileSidecarRestore( cluster *postgresv1alpha1.PostgresCluster, commandPlugin plugin.BackupCommandPlugin, ) (ctrl.Result, error) { - if result, ok, err := r.ensureClusterRestoreAnnotation(ctx, bj, cluster); !ok || err != nil { - return result, err + if ok, err := r.ensureClusterRestoreAnnotation(ctx, bj, cluster); !ok || err != nil { + return ctrl.Result{}, err } stsName := ShardStatefulSetName(cluster.Name, 0) @@ -765,15 +765,15 @@ func (r *BackupJobReconciler) ensureClusterRestoreAnnotation( ctx context.Context, bj *postgresv1alpha1.BackupJob, cluster *postgresv1alpha1.PostgresCluster, -) (ctrl.Result, bool, error) { +) (bool, error) { owner := strings.TrimSpace(cluster.Annotations[AnnotationRestoreInProgress]) if owner == bj.Name { - return ctrl.Result{}, true, nil + return true, nil } if owner != "" { r.markFailed(bj, BackupJobReasonRestoreAlreadyInProgress, "PostgresCluster "+cluster.Name+" already has offline restore owner BackupJob "+owner) - return ctrl.Result{}, false, r.statusUpdate(ctx, bj) + return false, r.statusUpdate(ctx, bj) } before := cluster.DeepCopy() @@ -784,9 +784,9 @@ func (r *BackupJobReconciler) ensureClusterRestoreAnnotation( annotations[AnnotationRestoreInProgress] = bj.Name cluster.Annotations = annotations if err := r.patchClusterMetadata(ctx, before, cluster); err != nil { - return ctrl.Result{}, false, err + return false, err } - return ctrl.Result{}, true, nil + return true, nil } func (r *BackupJobReconciler) releaseClusterRestoreAnnotation( diff --git a/internal/controller/builders.go b/internal/controller/builders.go index 6fb2b18..e3cbe0f 100644 --- a/internal/controller/builders.go +++ b/internal/controller/builders.go @@ -8,6 +8,7 @@ package controller import ( "fmt" + "maps" "regexp" "sort" "strings" @@ -1070,9 +1071,7 @@ func buildPGStatefulSet( } pvcLabels := make(map[string]string, len(labels)+1) - for k, v := range labels { - pvcLabels[k] = v - } + maps.Copy(pvcLabels, labels) pvcLabels["postgres.keiailab.io/cluster"] = cluster.Name return &appsv1.StatefulSet{ From d02fa34bf08ac4d80bd668daa8aaf41dbebc27df Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Thu, 25 Jun 2026 20:49:20 +0900 Subject: [PATCH 009/109] =?UTF-8?q?docs:=20=EC=9E=91=EC=97=85=20=EC=9D=B8?= =?UTF-8?q?=EC=88=98=EC=9D=B8=EA=B3=84=20=EB=AC=B8=EC=84=9C(WORK=5FHANDOFF?= =?UTF-8?q?.ko.md)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 다른 환경에서 이 작업을 이어받거나 재검증할 수 있도록 정리: - 브랜치 chore/ha-pitr-e2e-consolidation 의 커밋 8개 구성/의도와 교차 파일. - 단위·통합·레이스 검증 결과와 baseline-fail lint/scripts 구분. - 남은 라이브 K8s E2E(failover/PITR drill)와 Docker 2중 중첩 한계, 실 Linux 호스트/실 K8s에서 돌리는 방법(make test-e2e-failover 등). - go/make 없는 호스트에서 golang:1.26 컨테이너로 재검증하는 절차. PROJECT_OVERVIEW 상단에 핸드오프 포인터 링크 추가. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01H4fFn82hrtvuCfuZnBMaqZ --- docs/PROJECT_OVERVIEW.md | 2 + docs/WORK_HANDOFF.ko.md | 148 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 docs/WORK_HANDOFF.ko.md diff --git a/docs/PROJECT_OVERVIEW.md b/docs/PROJECT_OVERVIEW.md index 09d734c..86e899a 100644 --- a/docs/PROJECT_OVERVIEW.md +++ b/docs/PROJECT_OVERVIEW.md @@ -1,6 +1,8 @@ # postgres-operator 프로젝트 개요 > 버전: v0.4.0-beta.1 | 라이선스: MIT | 언어: Go 1.26 | 프레임워크: Kubebuilder / controller-runtime +> +> 🔖 진행 중 작업을 이어받거나 재검증하려면 먼저 [WORK_HANDOFF.ko.md](WORK_HANDOFF.ko.md)를 보라 — 브랜치 `chore/ha-pitr-e2e-consolidation`의 커밋 구성·검증 결과·남은 라이브 E2E·재현 방법 정리. ## 1. 프로젝트 목적 diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md new file mode 100644 index 0000000..0277133 --- /dev/null +++ b/docs/WORK_HANDOFF.ko.md @@ -0,0 +1,148 @@ +# 작업 핸드오프 — HA failover · PITR restore · E2E 정비 + +> 다른 환경(다른 머신·Dev Container·실 Kubernetes)에서 이 작업을 이어받거나 재검증하기 위한 인수인계 문서. +> 작성 기준: 2026-06-25. 브랜치 `chore/ha-pitr-e2e-consolidation` (main 미푸시). +> +> 관련 문서: [PROJECT_OVERVIEW.md](PROJECT_OVERVIEW.md) · [FEATURE_DEEP_DIVE.md](FEATURE_DEEP_DIVE.md) · [TEST_ANALYSIS.md](TEST_ANALYSIS.md) · [E2E_TEST_REPORT.ko.md](E2E_TEST_REPORT.ko.md) · [dev-setup-devcontainer.md](dev-setup-devcontainer.md) · [dev-setup-wsl.md](dev-setup-wsl.md) + +--- + +## 1. 한눈에 보기 + +| 항목 | 상태 | +|---|---| +| 브랜치 | `chore/ha-pitr-e2e-consolidation` (base: `main`) | +| 커밋 | 8개, **push 안 됨** (로컬 전용) | +| 변경 규모 | 56 파일, +5562 / −310 | +| 단위·통합 테스트 (`make test`) | ✅ 통과 (EXIT=0, FAIL 0) | +| 레이스 검출 (`make test-integration` `-race`) | ✅ 통과 (데이터레이스 없음) | +| lint (`make lint`) | ⚠️ baseline-fail — main 자체가 fail. 프로덕션 지적은 정리 완료, 잔여는 테스트 코드/미변경 파일 스타일 | +| 스크립트 테스트 (`make test-scripts`) | ⚠️ `hack/artifacthub_smoke_test.sh`만 실패 — 이번 작업과 무관 | +| 라이브 K8s E2E (failover/PITR drill) | ⏳ **미완** — 실 Kubernetes 환경에서 진행 필요 (3장 참고) | + +핵심: **코드·단위/통합 검증은 끝났고, 남은 단 하나는 실제 쿠버네티스에서의 라이브 E2E 드릴**이다. 이 호스트(Windows + Docker Desktop)에서는 Docker 2중 중첩 한계로 kind 클러스터가 뜨지 않아 보류했다(코드 결함 아님, 인프라 한계). + +--- + +## 2. 커밋 구성과 의도 + +순서대로 의미 단위로 분리했다. `main..HEAD` 기준. + +### `08f59d6` chore(license): Apache 2.0 → MIT 헤더 통일 +- router/cmd/chart-test 등 **16개 파일**의 라이선스 헤더를 저장소 `LICENSE`(MIT)와 일치. 로직 무관. + +### `eabbd98` feat(ha): #220 자동 failover 안정화 — fencing·pod-readiness·promotion guard +- `aggregate_status.go`: instance-status heartbeat와 **별개로 K8s Pod/컨테이너 readiness**를 검사하고, fenced PVC 멤버를 promotion 후보에서 제외. +- `failover_promoter.go`: `pg_ctl promote` → **`pg_promote()`(SQL)** 전환, 승격 전 old-primary **PVC fencing**(`fencePodPVC`), 후보 K8s readiness 검증(`promotionCandidateReadyForExec`). +- `postgrescluster_controller.go`: `primaryEndpointForShard` 추출로 초기 HA bootstrap의 **false failover window** 차단 + #220 failback guard. +- `primary_endpoint.go` 신규(ord-0 DNS 즉시 주입). +- `failover_chaos_test.go`: 승격 경로 완성으로 live chaos drill을 PContext → Context로 활성화. +- ⚠️ **교차 파일**: `postgrescluster_controller.go`에는 PITR(#209)의 restore-in-progress 락(`AnnotationRestoreInProgress`) hunk도 함께 들어 있다. + +### `df4aa52` feat(backup): #209 PITR restore 오프라인 오케스트레이션 완성 +- `pgbackrest/plugin.go`: `--archive-check=n` 제거(복구 불가 백업을 실패로 노출), `restore --delta`, `restore_command`를 repo env + `--pg1-path` 포함 형태로 재작성, targetTime **microsecond 정밀도**. +- `backupjob_controller.go`: **`reconcileSidecarRestore`** — STS scale-0 → Pod 정지 대기 → data PVC 마운트 restore Job → 완료 시 restore-in-progress 락 해제. +- `builders.go`: pgBackRest filesystem repo를 **data PVC 내부**로 이동(`backupRepoMountPath`), spool EmptyDir(`ephemeral-pgbackrest-spool`), `archive-push "%p"`. +- `docs/superpowers/plans/2026-06-24-pitr-restore-orchestration.md`: 설계 플랜 문서. +- ⚠️ **교차 파일**: `builders.go`에는 failover(#220)의 PVC `postgres.keiailab.io/cluster` label hunk도 함께 들어 있다. + +### `20c82af` test(e2e): fixture 독립화 + manager rollout 안정화 +- quickstart 외부 전제를 제거하고 각 suite가 **`ensurePostgresClusterReady`**로 독립 PostgresCluster를 부트스트랩. +- DB/User live smoke에 **`waitPostgresUserApplied`**(status.applied=true) 검증 추가, CRD spec 구조(cluster.name object화, schema 내부 privileges)와 `kubectl exec -c postgres` 정정. +- 동일 tag 재실행 시 manager Deployment rollout 강제 + manager 이미지 `0.4.0-beta.8` 정렬. +- `helpers.go` 신규(공용 e2e 헬퍼). + +### `e602751` feat(pooler): PgBouncer stats_users 파라미터 허용 +- PgBouncer 동적 파라미터 허용 목록에 `stats_users` 추가 (독립 기능). + +### `910ad01` docs: 프로젝트 분석 문서 + dev 환경 가이드 +- `PROJECT_OVERVIEW` / `FEATURE_DEEP_DIVE` / `TEST_ANALYSIS` / `E2E_TEST_REPORT`(PITR drill 반영) + `dev-setup-devcontainer` / `dev-setup-wsl`. + +### `dd2d8df` chore(dev): devcontainer + gitattributes +- devcontainer 정의/lock 갱신, Windows CRLF로 `.sh` shebang 깨지던 문제를 `.gitattributes`로 영구 차단. + +### `e9804f2` style(controller): 프로덕션 lint 지적 정리 +- `builders.go`: `pvcLabels` 복사 루프 → `maps.Copy` (mapsloop). +- `backupjob_controller.go`: `ensureClusterRestoreAnnotation`의 항상 nil인 `ctrl.Result` 반환 제거 (unparam). 동작 변경 없음. + +--- + +## 3. 남은 일 — 라이브 K8s E2E 드릴 + +코드와 단위/통합 검증은 끝났다. **실제 쿠버네티스에서 다음 두 드릴을 통과 검증**하는 것만 남았다. + +| 드릴 | make 타겟 | 무엇을 검증하나 | +|---|---|---| +| 자동 failover chaos | `make test-e2e-failover` | primary 강제 종료 → fencing → 후보 readiness → `pg_promote` 승격 → rogue-primary reseed. STS self-heal이 failover window를 선점하던 과거 라이브 실패(ADR-0027 shard-identity)가 재발하지 않는지. | +| PITR restore | `make test-e2e` (pitr_restore_e2e_test.go) | WAL 아카이빙 → 특정 시점 offline restore → STS scale-0 오케스트레이션 → 락 해제. | + +### 왜 이 호스트에서 못 돌렸나 (인프라 한계, 코드 아님) +Windows + Docker Desktop 환경에서 kind를 띄우면 **Docker Desktop VM → DinD → kind node**의 2중 중첩이 된다. 이 구조에서: +- 노드 컨테이너 부팅 단계에서 `could not find a log line that matches "Reached target .*Multi-User System"` (cgroup v2 중첩), +- `--cgroupns=host -v /sys/fs/cgroup:...`로 우회해도 control-plane `kubeadm init`이 apiserver/scheduler 연결 deadline로 실패. + +→ **2중 중첩이 원인.** 코드/매니페스트 문제가 아니다. + +### 다른 곳에서 E2E 돌리는 방법 (택1) + +**(A) 실제 단일 Linux 호스트 (권장)** — 중첩 없음 +```bash +# Linux 머신에서 (Docker만 있으면 됨) +git clone && cd postgres-operator +git switch chore/ha-pitr-e2e-consolidation # 또는 머지된 브랜치 +make test-e2e-failover # 자동 failover 드릴 +make test-e2e # 전체 e2e (PITR 포함) +``` + +**(B) 클라우드/온프렘 실 Kubernetes** — kind 불필요 +```bash +export KUBECONFIG=/path/to/real-cluster +make docker-build IMG=/postgres-operator:test +make docker-push IMG=/postgres-operator:test +helm install postgres-operator ./charts/postgres-operator \ + --set image.repository=/postgres-operator --set image.tag=test +# 이후 test/e2e/*.go 시나리오를 대상 클러스터에 맞춰 실행 +``` + +**(C) Dev Container 안에서 DinD** — 이 호스트와 동일한 중첩이므로 비권장. 위 (A)/(B)를 쓸 것. + +> 참고: 이 머신에는 과거 다른 세션이 만든 `kindest/node:v1.36.1` 클러스터가 호스트 Docker에 떠 있었으나(34h), 이번에 정리하며 제거했다. 추후 실 K8s가 생기면 DooD로 붙여 돌리는 것도 가능하다. + +--- + +## 4. 재검증 방법 (호스트에 go/make 없는 경우) + +이 Windows 호스트에는 go·make·WSL bash 툴체인이 없다. **모든 빌드/테스트는 컨테이너 안에서** 한다. Dev Container 정식 절차는 [dev-setup-devcontainer.md](dev-setup-devcontainer.md). 빠른 일회성 검증은 아래처럼 `golang:1.26` 컨테이너에 레포를 마운트: + +```bash +# 빌드 + vet (가장 빠른 sanity check) +docker run --rm -v /path/to/postgres-operator:/workspace -w /workspace \ + -e GOFLAGS=-mod=mod golang:1.26 \ + sh -c "go build ./... && go vet ./..." + +# 단위 + 통합 테스트 (envtest 자동 셋업) +docker run --rm -v /path/to/postgres-operator:/workspace -w /workspace golang:1.26 \ + sh -c "make test" + +# 레이스 검출 (failover 동시성 코드 핵심 검증) +docker run --rm -v /path/to/postgres-operator:/workspace -w /workspace golang:1.26 \ + sh -c "make test-integration" +``` +Windows 호스트에서는 `-v E:\keiailab\postgres-operator:/workspace`처럼 경로를 준다. 최초 1회는 모듈 다운로드로 수 분 소요. + +### 알려진 검증 결과 (참고치) +- `make test` ✅ — controller 73.2% / failover 98.8% / pgbackrest 86.6% / webhook 94.7%. +- `make test-integration -race` ✅ — 데이터레이스 0. +- `make lint` ⚠️ RC=2 — baseline-fail. 잔여 지적은 전부 사소한 스타일(goconst·gocyclo·lll·modernize·prealloc·unparam)이며 대부분 테스트 코드 또는 이번에 손대지 않은 파일(`cmd/instance/main.go`, `shardsplitjob_controller.go`, `election_test.go`). 프로덕션 2건은 `e9804f2`에서 정리. +- `make test-scripts` ⚠️ RC=2 — `hack/artifacthub_smoke_test.sh`만 실패, `hack/` 미변경이라 이번 작업과 무관. + +--- + +## 5. 다음 작업자를 위한 주의점 + +- **push 안 됨.** 브랜치는 로컬 전용이다. 원격 반영이 필요하면 별도 지시 후 `git push -u origin chore/ha-pitr-e2e-consolidation`. +- **교차 파일 2개** (2장 ⚠️ 참고): `postgrescluster_controller.go`(failover 커밋에 PITR 락 hunk 포함), `builders.go`(PITR 커밋에 failover label hunk 포함). 커밋을 cherry-pick/revert할 때 이 의존을 염두에 둘 것. +- **failover wiring은 이번 작업 이전부터 이미 존재**했다. Reconcile 경로: `clusterFailoverDecision → DetectPrimaryFailure → shouldPromoteAfterDebounce`(8s) `→ #220 failback guard → executeClusterPromotion → reconcileRoguePrimaries`. 이번 변경은 그 위에 fencing/readiness/pg_promote 보강을 얹은 것. +- **DB/User status.applied도 이미 구현**돼 있다(`postgresdatabase_controller.go`, `postgresuser_controller.go`). 과거 분석 메모의 "미구현" 진단은 부정확했으니, 미완 여부는 문서가 아니라 **코드/`git log`로 확인**할 것. +- envtest는 Kubernetes 1.36 바이너리를 받는다(`make test`가 자동). 오프라인 환경이면 `bin/k8s/` 캐시 확인. +``` From 09b4f6825e1fd7b4474034147093be55015b3e61 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Thu, 25 Jun 2026 20:54:00 +0900 Subject: [PATCH 010/109] =?UTF-8?q?docs:=20=EB=B6=84=EC=84=9D=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=A7=80=EB=8F=84(DOCS=5FMAP)=20=EC=8B=A0=EC=84=A4?= =?UTF-8?q?=20+=20=EC=A4=91=EB=B3=B5=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 한국어 분석/작업/환경 문서군의 입구가 없어 관계·중복 관리가 어려웠다. DOCS_MAP.ko.md 를 신설해 각 문서 역할, 주제별 SSOT(단일 출처), 계층 관계, 추후 보강 가이드를 정의한다. - 발견된 중복: dev-setup 2종의 make타겟/e2e 절차, 검증 수치 산재, CRLF 조치 중복 → 삭제 대신 SSOT 지정 + 상호참조로 정리(보강 예정 전제). - PROJECT_OVERVIEW 관련문서 표에 DOCS_MAP/TEST_ANALYSIS/E2E_REPORT/WORK_HANDOFF 추가. - dev-setup-devcontainer/wsl 상단에 문서지도·상호참조·E2E 시나리오 링크. - WORK_HANDOFF 상단에 DOCS_MAP 링크. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01H4fFn82hrtvuCfuZnBMaqZ --- docs/DOCS_MAP.ko.md | 77 ++++++++++++++++++++++++++++++++++ docs/PROJECT_OVERVIEW.md | 8 +++- docs/WORK_HANDOFF.ko.md | 1 + docs/dev-setup-devcontainer.md | 2 + docs/dev-setup-wsl.md | 2 + 5 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 docs/DOCS_MAP.ko.md diff --git a/docs/DOCS_MAP.ko.md b/docs/DOCS_MAP.ko.md new file mode 100644 index 0000000..71d2324 --- /dev/null +++ b/docs/DOCS_MAP.ko.md @@ -0,0 +1,77 @@ +# 분석·작업 문서 지도 (DOCS_MAP) + +> 이 저장소의 **한국어 분석/작업/환경 문서군**의 입구. 각 문서의 역할, 어떤 주제의 **단일 출처(SSOT)**인지, 문서 사이 관계, 그리고 추후 보강할 때 "어디에 써야 하는지"를 정의한다. +> +> 영문 공개 문서(`ARCHITECTURE` / `ROADMAP` / ADR / RFC / runbooks 등)의 입구는 [index.md](index.md)다. 이 문서는 그와 별개로, 내부 분석·작업 흐름 문서를 묶는다. + +--- + +## 1. 문서 지도 + +| 문서 | 역할 (한 줄) | 이 문서가 SSOT인 주제 | +|---|---|---| +| [PROJECT_OVERVIEW.md](PROJECT_OVERVIEW.md) | 프로젝트 **개요** — 목적·스택·아키텍처 요약·CRD 목록·로드맵·디렉토리 | 프로젝트 전반 요약, 디렉토리 구조, 관련 문서 인덱스 | +| [FEATURE_DEEP_DIVE.md](FEATURE_DEEP_DIVE.md) | 기능별 **심층 동작 분석** — 각 CRD/컨트롤러의 reconcile·내부 동작 | 기능 동작의 상세 레퍼런스 (failover/PITR/pooler/DB·User 내부 흐름) | +| [TEST_ANALYSIS.md](TEST_ANALYSIS.md) | **단위·통합 테스트** 분석 — 패키지별 커버리지·TC 상세 | 단위/통합 테스트 결과·커버리지 수치, CRLF/`.gitattributes` 조치 | +| [E2E_TEST_REPORT.ko.md](E2E_TEST_REPORT.ko.md) | **E2E 라이브 드릴 RCA** 누적 로그 — 시간순 검증/원인분석 | E2E(라이브 K8s) 드릴 결과·RCA·PENDING 이력 | +| [WORK_HANDOFF.ko.md](WORK_HANDOFF.ko.md) | 현재 **작업 인수인계** — 브랜치 커밋 구성·검증 요약·남은 일·재현법 | 진행 중 작업 스냅샷(브랜치 `chore/ha-pitr-e2e-consolidation`) | +| [dev-setup-devcontainer.md](dev-setup-devcontainer.md) | **Dev Container** 개발 환경 구성 (현재 권장) | Windows에서 컨테이너로 빌드/테스트하는 절차 | +| [dev-setup-wsl.md](dev-setup-wsl.md) | **WSL2** 개발 환경 구성 (대안) | WSL2 네이티브로 빌드/테스트하는 절차 | + +> 이 지도 자체와 위 표는 새 분석/작업 문서가 생길 때마다 갱신한다. + +--- + +## 2. 관계 (계층) + +``` +입구 +└─ DOCS_MAP.ko.md (이 문서) + ├─ 개요 ────────── PROJECT_OVERVIEW.md ──┐ (요약 → 상세로 내려감) + │ └─→ FEATURE_DEEP_DIVE.md (심층) + ├─ 테스트 ──────── TEST_ANALYSIS.md (단위·통합) + │ E2E_TEST_REPORT.ko.md (E2E 라이브) + ├─ 작업 ────────── WORK_HANDOFF.ko.md (현재 브랜치 스냅샷) + └─ 환경 ────────── dev-setup-devcontainer.md (권장) / dev-setup-wsl.md (대안) +``` + +- **개요 vs 심층**: 같은 기능을 둘 다 다루지만 계층이 다르다 — `PROJECT_OVERVIEW`는 "무엇이 있나"(요약), `FEATURE_DEEP_DIVE`는 "어떻게 동작하나"(상세). 이 분리는 **의도된 것이며 중복이 아니다.** +- **테스트 2분할**: `TEST_ANALYSIS`=호스트 없이 컨테이너에서 도는 단위/통합, `E2E_TEST_REPORT`=실 K8s 라이브 드릴. 경계가 다르다. +- **환경 2종**: `devcontainer`(현재 권장 — 이 머신은 go/make 부재로 컨테이너 필수)와 `wsl`(대안). 두 문서는 **독립 완결**을 목표로 하되, 공통 절차의 출처는 §3 규칙을 따른다. + +--- + +## 3. 주제별 SSOT 규칙 (중복 방지) + +새 내용을 쓰거나 기존 내용을 고칠 때, 아래 주제는 **지정된 문서 한 곳에만** 본문을 두고 나머지는 그곳을 **링크**한다. + +| 주제 | SSOT (본문은 여기에만) | 다른 문서는 | +|---|---|---| +| 단위/통합 테스트 결과·커버리지 수치 | `TEST_ANALYSIS.md` | "참고치"로 표기하고 링크 (예: `WORK_HANDOFF`, 메모리) | +| E2E 라이브 드릴 결과·RCA | `E2E_TEST_REPORT.ko.md` | 링크만 | +| CRLF / `.gitattributes` 조치 | `TEST_ANALYSIS.md §6` | dev-setup 트러블슈팅은 1줄 요약 + 링크 | +| make 타겟 의미·소요시간 | `dev-setup-devcontainer.md §7` | `dev-setup-wsl`은 동일 표 유지 가능(독립 완결 목적), 단 갱신 시 둘 다 | +| E2E 실행 명령(`make test-e2e-*`) | `WORK_HANDOFF.ko.md §3` | dev-setup은 환경 기동까지만, 시나리오는 링크 | +| 기능 내부 동작 상세 | `FEATURE_DEEP_DIVE.md` | `PROJECT_OVERVIEW`는 요약 + 링크 | +| 현재 작업/브랜치 상태 | `WORK_HANDOFF.ko.md` + 메모리 `analysis-progress` | 다른 문서에 작업 상태를 복제하지 않음 | + +--- + +## 4. 발견된 중복과 처리 (2026-06-25) + +| 중복 | 처리 | +|---|---| +| dev-setup-devcontainer ↔ dev-setup-wsl: make 타겟표·e2e 절차·트러블슈팅 | 환경별 독립 완결이 가치 → **표는 유지**, 상단에 상호참조 추가, 공통 SSOT는 §3 명시 | +| 검증 수치가 4곳에 산재 | SSOT=`TEST_ANALYSIS`/`E2E_TEST_REPORT`로 지정, `WORK_HANDOFF`는 "참고치" 명시(이미 반영) | +| CRLF/.gitattributes 가 TEST_ANALYSIS와 dev-setup에 | SSOT=`TEST_ANALYSIS §6`, dev-setup은 요약+링크 | +| 한국어 분석 문서군의 입구 부재 | **이 문서(DOCS_MAP) 신설**로 해소 | + +--- + +## 5. 추후 보강 가이드 + +- **새 기능을 구현했다**: 요약 1~2줄은 `PROJECT_OVERVIEW`, 동작 상세는 `FEATURE_DEEP_DIVE`에. 새 CRD면 `PROJECT_OVERVIEW §4` 표에도 추가. +- **테스트를 돌렸다**: 단위/통합이면 `TEST_ANALYSIS`, 라이브 E2E면 `E2E_TEST_REPORT`에 날짜 섹션으로 append. +- **작업 브랜치를 진행했다**: `WORK_HANDOFF.ko.md`를 갱신하고 메모리 `analysis-progress`도 같이. +- **환경 절차가 바뀌었다**: 해당 dev-setup 문서. make 타겟·e2e 명령이 바뀌면 §3 SSOT 문서를 먼저 고치고 링크 측은 그대로. +- **새 분석 문서를 추가했다**: 이 DOCS_MAP §1 표와 §2 관계도에 한 줄 추가. diff --git a/docs/PROJECT_OVERVIEW.md b/docs/PROJECT_OVERVIEW.md index 86e899a..749d043 100644 --- a/docs/PROJECT_OVERVIEW.md +++ b/docs/PROJECT_OVERVIEW.md @@ -251,9 +251,13 @@ kubectl wait postgrescluster/quickstart --for=condition=Ready --timeout=5m | 문서 | 위치 | 설명 | |---|---|---| +| **문서 지도** | `docs/DOCS_MAP.ko.md` | 분석/작업/환경 문서군의 입구·역할·SSOT·관계 (여기부터) | | 기능 심층 분석 | `docs/FEATURE_DEEP_DIVE.md` | 각 기능 상세 동작 분석 | -| Dev Container 설정 | `docs/dev-setup-devcontainer.md` | Windows Dev Container 개발 환경 | -| WSL2 설정 | `docs/dev-setup-wsl.md` | Windows WSL2 개발 환경 | +| 단위·통합 테스트 분석 | `docs/TEST_ANALYSIS.md` | 패키지별 커버리지·TC 상세 (테스트 수치 SSOT) | +| E2E 테스트 보고서 | `docs/E2E_TEST_REPORT.ko.md` | E2E 라이브 드릴 RCA 누적 로그 | +| 작업 인수인계 | `docs/WORK_HANDOFF.ko.md` | 현재 브랜치 커밋 구성·검증·남은 일·재현법 | +| Dev Container 설정 | `docs/dev-setup-devcontainer.md` | Windows Dev Container 개발 환경 (권장) | +| WSL2 설정 | `docs/dev-setup-wsl.md` | Windows WSL2 개발 환경 (대안) | | 아키텍처 | `docs/ARCHITECTURE.md` | 설계 결정 및 트레이드오프 | | 로드맵 | `docs/ROADMAP.md` | 단계별 개발 계획 | | ADR 인덱스 | `docs/kb/adr/INDEX.md` | Architecture Decision Records | diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index 0277133..f159bc6 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -3,6 +3,7 @@ > 다른 환경(다른 머신·Dev Container·실 Kubernetes)에서 이 작업을 이어받거나 재검증하기 위한 인수인계 문서. > 작성 기준: 2026-06-25. 브랜치 `chore/ha-pitr-e2e-consolidation` (main 미푸시). > +> 문서 지도: [DOCS_MAP.ko.md](DOCS_MAP.ko.md) — 분석/작업/환경 문서군의 입구·역할·SSOT. > 관련 문서: [PROJECT_OVERVIEW.md](PROJECT_OVERVIEW.md) · [FEATURE_DEEP_DIVE.md](FEATURE_DEEP_DIVE.md) · [TEST_ANALYSIS.md](TEST_ANALYSIS.md) · [E2E_TEST_REPORT.ko.md](E2E_TEST_REPORT.ko.md) · [dev-setup-devcontainer.md](dev-setup-devcontainer.md) · [dev-setup-wsl.md](dev-setup-wsl.md) --- diff --git a/docs/dev-setup-devcontainer.md b/docs/dev-setup-devcontainer.md index 7f2ce0b..26deeb7 100644 --- a/docs/dev-setup-devcontainer.md +++ b/docs/dev-setup-devcontainer.md @@ -2,6 +2,8 @@ > Windows 환경에서 VS Code Dev Container를 이용해 postgres-operator 개발 환경을 구성하는 가이드. > 실제 코드는 Linux 컨테이너 안에서 실행되므로 Makefile, bash 스크립트, envtest가 모두 정상 동작한다. +> +> 📍 문서 지도: [DOCS_MAP.ko.md](DOCS_MAP.ko.md) · 대안 환경: [dev-setup-wsl.md](dev-setup-wsl.md) · E2E 실행 시나리오: [WORK_HANDOFF.ko.md §3](WORK_HANDOFF.ko.md) ## 사전 요구사항 diff --git a/docs/dev-setup-wsl.md b/docs/dev-setup-wsl.md index 6f2234f..86e6693 100644 --- a/docs/dev-setup-wsl.md +++ b/docs/dev-setup-wsl.md @@ -2,6 +2,8 @@ > Windows WSL2(Ubuntu) 환경에서 postgres-operator 개발 환경을 구성하는 가이드. > WSL2의 네이티브 Linux 파일시스템을 사용하므로 빌드 속도와 도구 호환성이 네이티브에 가깝다. +> +> 📍 문서 지도: [DOCS_MAP.ko.md](DOCS_MAP.ko.md) · 권장 환경: [dev-setup-devcontainer.md](dev-setup-devcontainer.md) · E2E 실행 시나리오: [WORK_HANDOFF.ko.md §3](WORK_HANDOFF.ko.md) ## 사전 요구사항 From 9cd70508e49ca69d207b7db0124f00e5d60b9f63 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Thu, 25 Jun 2026 21:23:46 +0900 Subject: [PATCH 011/109] =?UTF-8?q?docs:=20=EB=AC=B8=EC=84=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20=EA=B7=9C=EC=B9=99=EC=9D=84=20=EC=97=90=EC=9D=B4?= =?UTF-8?q?=EC=A0=84=ED=8A=B8=20=EC=A7=80=EC=B9=A8=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20(AGENTS.md=20->=20DOCS=5FMAP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 여러 에이전트(Claude/Codex/Cursor) 및 작업자가 docs/ 를 편집해도 동일한 문서 규칙을 따르도록 구속력을 부여한다. - 루트 AGENTS.md Conventions 에 "문서 작성 (docs/)" 절 추가: DOCS_MAP.ko.md 를 구속력 있는 규칙으로 지정(SSOT 준수, 계층 유지, 보강 위치, 지도 갱신 의무, 상태는 코드로 확인). - DOCS_MAP.ko.md 상단에 이 문서가 docs/ 작성 지침이며 AGENTS.md 가 가리킨다는 점을 명시. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01H4fFn82hrtvuCfuZnBMaqZ --- AGENTS.md | 10 ++++++++++ docs/DOCS_MAP.ko.md | 2 ++ 2 files changed, 12 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 172656f..244380c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,3 +88,13 @@ make bundle VERSION=X.Y.Z # OLM bundle 재생성 Apache 헤더 발견 시 보고 후 교체 (pg-router 정리 선례). - 의존성 갱신: `renovate.json` (mongodb-operator 와 동일 기준 — k8s.io 그룹 / major 분리). - CI (`.gitlab-ci.yml`): golang 이미지 버전은 `go.mod` 의 go directive 와 정합 유지. + +### 문서 작성 (`docs/`) + +`docs/` 의 한국어 분석/작업 문서는 [`docs/DOCS_MAP.ko.md`](docs/DOCS_MAP.ko.md) 의 지침을 **구속력 있는 규칙**으로 따른다. Claude / Codex / Cursor 등 어떤 에이전트나 작업자라도 `docs/` 를 편집할 때 동일하게 준수한다. + +- **주제별 SSOT 준수**: 같은 사실(검증 수치·CRLF 조치·E2E 명령 등)을 여러 문서에 복제하지 않는다. 본문은 DOCS_MAP §3 이 지정한 출처 1곳에만 두고, 나머지는 링크한다. +- **계층 유지**: 요약은 `PROJECT_OVERVIEW`, 상세 동작은 `FEATURE_DEEP_DIVE`. 개요 문서에 상세를 복붙하지 않는다. +- **보강 위치**: 새 내용을 어디에 쓸지는 DOCS_MAP §5 를 따른다(기능→OVERVIEW/DEEP_DIVE, 테스트→TEST_ANALYSIS/E2E_REPORT, 작업→WORK_HANDOFF+메모리, 환경→dev-setup). +- **지도 갱신 의무**: 새 분석/작업 문서를 추가하면 DOCS_MAP §1 표와 §2 관계도에 한 줄 추가한다. +- 미완 여부·현재 상태는 문서 단정 대신 코드/`git log` 로 확인한다(과거 "미구현" 오진 선례). diff --git a/docs/DOCS_MAP.ko.md b/docs/DOCS_MAP.ko.md index 71d2324..a1d8a77 100644 --- a/docs/DOCS_MAP.ko.md +++ b/docs/DOCS_MAP.ko.md @@ -1,5 +1,7 @@ # 분석·작업 문서 지도 (DOCS_MAP) +> ⚖️ **이 문서는 `docs/` 폴더의 구속력 있는 작성 지침이다.** 루트 [`AGENTS.md`](../AGENTS.md) → "문서 작성 (docs/)" 가 이 문서를 가리키며, Claude / Codex / Cursor 등 어떤 에이전트·작업자라도 `docs/` 를 편집할 때 아래 §3 SSOT 규칙과 §5 보강 가이드를 따른다. +> > 이 저장소의 **한국어 분석/작업/환경 문서군**의 입구. 각 문서의 역할, 어떤 주제의 **단일 출처(SSOT)**인지, 문서 사이 관계, 그리고 추후 보강할 때 "어디에 써야 하는지"를 정의한다. > > 영문 공개 문서(`ARCHITECTURE` / `ROADMAP` / ADR / RFC / runbooks 등)의 입구는 [index.md](index.md)다. 이 문서는 그와 별개로, 내부 분석·작업 흐름 문서를 묶는다. From 95c016b47f7108c46d20aadf1279d485f4902c48 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Thu, 25 Jun 2026 22:21:21 +0900 Subject: [PATCH 012/109] =?UTF-8?q?docs:=20=EC=9A=A9=EC=96=B4=EC=A7=91(GLO?= =?UTF-8?q?SSARY)=20=EC=8B=A0=EC=84=A4=20+=20=EA=B0=81=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EB=A7=88=EC=A7=80=EB=A7=89=20=EC=9E=A5=20=EC=9A=A9?= =?UTF-8?q?=EC=96=B4=EC=A7=91=20=EC=A0=88=20+=20=EC=A7=80=EC=B9=A8?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 용어 설명을 어디서 보든 동일하게(SSOT) 유지하면서, 각 문서 마지막 장에서 최소한의 의미파악이 되도록 발췌 형태로 둔다. - docs/GLOSSARY.ko.md 신설: HA/failover, 백업/PITR, K8s/운영, 분산/샤딩, 프로젝트/문서 범주의 용어 정의 단일 출처(SSOT). - PROJECT_OVERVIEW / FEATURE_DEEP_DIVE / TEST_ANALYSIS / E2E_TEST_REPORT / WORK_HANDOFF 마지막 장에 "용어집" 절 추가 — 그 문서 등장 용어만 GLOSSARY 정의를 그대로 발췌 + 전체 링크. (WORK_HANDOFF 말미의 불필요한 코드펜스도 정리.) - 지침화: AGENTS.md "문서 작성(docs/)"과 DOCS_MAP §1/§3/§5 에 용어집 유지 규칙 추가 — 어떤 에이전트/작업자도 마지막 장 용어집을 유지하고 정의는 GLOSSARY 를 먼저 고친다. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01H4fFn82hrtvuCfuZnBMaqZ --- AGENTS.md | 1 + docs/DOCS_MAP.ko.md | 5 ++- docs/E2E_TEST_REPORT.ko.md | 19 ++++++++ docs/FEATURE_DEEP_DIVE.md | 24 +++++++++++ docs/GLOSSARY.ko.md | 88 ++++++++++++++++++++++++++++++++++++++ docs/PROJECT_OVERVIEW.md | 18 ++++++++ docs/TEST_ANALYSIS.md | 17 ++++++++ docs/WORK_HANDOFF.ko.md | 20 ++++++++- 8 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 docs/GLOSSARY.ko.md diff --git a/AGENTS.md b/AGENTS.md index 244380c..82a8d97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,3 +98,4 @@ make bundle VERSION=X.Y.Z # OLM bundle 재생성 - **보강 위치**: 새 내용을 어디에 쓸지는 DOCS_MAP §5 를 따른다(기능→OVERVIEW/DEEP_DIVE, 테스트→TEST_ANALYSIS/E2E_REPORT, 작업→WORK_HANDOFF+메모리, 환경→dev-setup). - **지도 갱신 의무**: 새 분석/작업 문서를 추가하면 DOCS_MAP §1 표와 §2 관계도에 한 줄 추가한다. - 미완 여부·현재 상태는 문서 단정 대신 코드/`git log` 로 확인한다(과거 "미구현" 오진 선례). +- **용어집 유지**: 각 분석 문서는 **마지막 장에 "용어집" 절**을 둔다. 용어 정의 SSOT 는 [`docs/GLOSSARY.ko.md`](docs/GLOSSARY.ko.md) — 각 문서는 등장 용어만 **그 정의를 그대로 발췌**(어디서 보든 동일) + 전체 링크. 새 용어는 GLOSSARY 에 먼저 추가하고 발췌 측을 맞춘다. diff --git a/docs/DOCS_MAP.ko.md b/docs/DOCS_MAP.ko.md index a1d8a77..7cd99a9 100644 --- a/docs/DOCS_MAP.ko.md +++ b/docs/DOCS_MAP.ko.md @@ -19,6 +19,7 @@ | [WORK_HANDOFF.ko.md](WORK_HANDOFF.ko.md) | 현재 **작업 인수인계** — 브랜치 커밋 구성·검증 요약·남은 일·재현법 | 진행 중 작업 스냅샷(브랜치 `chore/ha-pitr-e2e-consolidation`) | | [dev-setup-devcontainer.md](dev-setup-devcontainer.md) | **Dev Container** 개발 환경 구성 (현재 권장) | Windows에서 컨테이너로 빌드/테스트하는 절차 | | [dev-setup-wsl.md](dev-setup-wsl.md) | **WSL2** 개발 환경 구성 (대안) | WSL2 네이티브로 빌드/테스트하는 절차 | +| [GLOSSARY.ko.md](GLOSSARY.ko.md) | **용어집** — 용어/약어 정의 | 용어 정의(각 문서 마지막 장 "용어집" 발췌의 원본) | > 이 지도 자체와 위 표는 새 분석/작업 문서가 생길 때마다 갱신한다. @@ -56,6 +57,7 @@ | E2E 실행 명령(`make test-e2e-*`) | `WORK_HANDOFF.ko.md §3` | dev-setup은 환경 기동까지만, 시나리오는 링크 | | 기능 내부 동작 상세 | `FEATURE_DEEP_DIVE.md` | `PROJECT_OVERVIEW`는 요약 + 링크 | | 현재 작업/브랜치 상태 | `WORK_HANDOFF.ko.md` + 메모리 `analysis-progress` | 다른 문서에 작업 상태를 복제하지 않음 | +| 용어/약어 정의 | `GLOSSARY.ko.md` | 각 분석 문서는 **마지막 장에 "용어집" 절**을 두고, 그 문서에 등장한 용어만 GLOSSARY 정의를 **그대로 발췌** + 전체 링크 | --- @@ -76,4 +78,5 @@ - **테스트를 돌렸다**: 단위/통합이면 `TEST_ANALYSIS`, 라이브 E2E면 `E2E_TEST_REPORT`에 날짜 섹션으로 append. - **작업 브랜치를 진행했다**: `WORK_HANDOFF.ko.md`를 갱신하고 메모리 `analysis-progress`도 같이. - **환경 절차가 바뀌었다**: 해당 dev-setup 문서. make 타겟·e2e 명령이 바뀌면 §3 SSOT 문서를 먼저 고치고 링크 측은 그대로. -- **새 분석 문서를 추가했다**: 이 DOCS_MAP §1 표와 §2 관계도에 한 줄 추가. +- **새 분석 문서를 추가했다**: 이 DOCS_MAP §1 표와 §2 관계도에 한 줄 추가. 그 문서 마지막 장에 "용어집" 절도 둔다. +- **새 용어가 등장했다**: `GLOSSARY.ko.md`에 한 줄 정의로 추가하고, 그 용어를 쓰는 문서의 마지막 장 "용어집" 절에 동일 문구로 발췌한다. 정의를 고치면 GLOSSARY를 먼저 고치고 발췌 측을 맞춘다. diff --git a/docs/E2E_TEST_REPORT.ko.md b/docs/E2E_TEST_REPORT.ko.md index 75445ef..20d4d9b 100644 --- a/docs/E2E_TEST_REPORT.ko.md +++ b/docs/E2E_TEST_REPORT.ko.md @@ -896,3 +896,22 @@ go test -tags=e2e ./test/e2e -timeout 25m -v \ 운영 기준에서는 Pending을 해제하는 순간 해당 테스트는 release gate가 된다. 따라서 failover chaos는 앞으로 flaky하면 다시 Pending 처리하는 것이 아니라, promotion 경로 또는 테스트 전제 조건 중 어느 쪽이 깨졌는지 RCA를 수행해야 한다. 한 줄 결론: failover chaos p1 Pending은 해제했고, 현재 라이브 검증 기준 남은 명시적 E2E Pending은 PITR restore/checksum이다. + +--- + +## 16. 용어집 + +> 정의는 [GLOSSARY.ko.md](GLOSSARY.ko.md)에서 발췌해 동일하게 유지한다. 전체 용어는 해당 문서 참고. + +| 용어 | 정의 | +|---|---| +| Failover (장애 조치) | Primary 장애 감지 후 Replica 하나를 새 Primary로 자동 승격해 서비스를 잇는 동작. | +| Promotion (승격) | Replica를 Primary로 올리는 행위. 본 operator는 `pg_promote()`(SQL)로 수행. | +| RTO (Recovery Time Objective) | 장애에서 서비스 복구까지 허용되는 목표 시간. 본 프로젝트 failover 드릴 기준 30초. | +| Fencing (PVC Fencing) | 옛/이상 Primary가 데이터에 쓰지 못하도록 PVC 접근을 차단해 split-brain을 막는 격리. | +| Pod readiness | Pod/컨테이너가 트래픽을 받을 준비가 됐는지의 K8s 상태. 승격 후보 검증에 사용. | +| PITR (Point-In-Time Recovery) | WAL을 재생해 데이터베이스를 특정 과거 시점으로 복원하는 기법. | +| Replica Cluster | 외부 클러스터를 streaming standby로 복제하는 구성. | +| Hibernation | 클러스터를 STS scale-0으로 내려 PVC는 보존한 채 휴면시키는 기능. | +| kind | Docker 컨테이너 안에 K8s 클러스터를 띄우는 도구. e2e 테스트에 사용. | +| RCA (Root Cause Analysis) | 장애·실패의 근본 원인 분석. | diff --git a/docs/FEATURE_DEEP_DIVE.md b/docs/FEATURE_DEEP_DIVE.md index 3d14891..6e806e8 100644 --- a/docs/FEATURE_DEEP_DIVE.md +++ b/docs/FEATURE_DEEP_DIVE.md @@ -957,3 +957,27 @@ spec: | PDB minAvailable | `1` | `pdb.go` | 가용 Pod 최소 수 (HA 보호) | | TLS 자동 갱신 | 만료 30일 전 | `pooler_controller.go` | Self-signed 인증서 갱신 트리거 | | Failover lease 기간 | 15s / 10s / 2s | `internal/instance/election/` | LeaseDuration / RenewDeadline / RetryPeriod | + +--- + +## 용어집 + +> 정의는 [GLOSSARY.ko.md](GLOSSARY.ko.md)에서 발췌해 동일하게 유지한다. 전체 용어는 해당 문서 참고. + +| 용어 | 정의 | +|---|---| +| Failover (장애 조치) | Primary 장애 감지 후 Replica 하나를 새 Primary로 자동 승격해 서비스를 잇는 동작. | +| Promotion (승격) | Replica를 Primary로 올리는 행위. 본 operator는 `pg_promote()`(SQL)로 수행. | +| Switchover (계획 전환) | 장애가 아닌 의도된 상황에서 Primary를 다른 인스턴스로 무중단 전환하는 동작. | +| Fencing (PVC Fencing) | 옛/이상 Primary가 데이터에 쓰지 못하도록 PVC 접근을 차단해 split-brain을 막는 격리. | +| Debounce (디바운스) | 일시적 신호로 인한 오탐 failover를 막기 위해 장애를 일정 시간(기본 8초) 유지될 때만 인정하는 대기. | +| Lease (임대) | Kubernetes Lease 오브젝트. 외부 HA 에이전트 없이 이를 DCS로 써서 Primary 선출을 한다. | +| Rogue Primary | 정상 승격 절차 밖에서 자신이 Primary라 여기는 이상 인스턴스. 감지 시 re-seed로 정리. | +| Re-seed (재시드) | 뒤처지거나 이상한 인스턴스의 데이터를 새 Primary 기준으로 다시 복제해 정상화. | +| PITR (Point-In-Time Recovery) | WAL을 재생해 데이터베이스를 특정 과거 시점으로 복원하는 기법. | +| WAL (Write-Ahead Log) | 변경을 먼저 기록하는 PostgreSQL의 로그. 복제·PITR의 기반. | +| pgBackRest | 본 operator의 기본 백업 도구(플러그인). WAL-G·Barman은 대체 플러그인. | +| Hibernation | 클러스터를 STS scale-0으로 내려 PVC는 보존한 채 휴면시키는 기능. | +| Replica Cluster | 외부 클러스터를 streaming standby로 복제하는 구성. | +| CEL validation | CRD 스키마에서 표현식으로 값 제약을 거는 검증(예: 보호된 이름 차단). | +| ShardRange / ShardSplitJob / AutoSplit | 샤드 범위 정의 CRD / 온라인 샤드 분할 작업 / 임계치 도달 시 자동 분할 트리거. | diff --git a/docs/GLOSSARY.ko.md b/docs/GLOSSARY.ko.md new file mode 100644 index 0000000..1f3720f --- /dev/null +++ b/docs/GLOSSARY.ko.md @@ -0,0 +1,88 @@ +# 용어집 (Glossary · 적요) + +> ⚖️ **이 문서가 용어 정의의 단일 출처(SSOT)다.** 다른 문서 마지막 장의 "용어집" 절은 여기 정의를 **그대로 발췌**해 어디서 보든 설명이 동일하게 유지한다. 정의를 고칠 때는 이 문서를 먼저 고치고, 발췌 측을 맞춘다. +> +> 작성 지침: [`AGENTS.md`](../AGENTS.md) "문서 작성 (docs/)" · [`DOCS_MAP.ko.md`](DOCS_MAP.ko.md) §3. + +--- + +## HA · Failover + +| 용어 | 정의 | +|---|---| +| Operator | Kubernetes 위에서 애플리케이션(여기선 PostgreSQL)의 생성·운영·복구를 CRD + 컨트롤러로 자동화하는 소프트웨어. | +| Failover (장애 조치) | Primary 장애 감지 후 Replica 하나를 새 Primary로 자동 승격해 서비스를 잇는 동작. | +| Switchover (계획 전환) | 장애가 아닌 의도된 상황에서 Primary를 다른 인스턴스로 무중단 전환하는 동작. | +| Promotion (승격) | Replica를 Primary로 올리는 행위. 본 operator는 `pg_promote()`(SQL)로 수행. | +| Failback guard | 막 강등/격리된 옛 Primary가 곧바로 다시 승격 후보가 되는 것을 막는 가드. | +| Fencing (PVC Fencing) | 옛/이상 Primary가 데이터에 쓰지 못하도록 PVC 접근을 차단해 split-brain을 막는 격리. | +| Debounce (디바운스) | 일시적 신호로 인한 오탐 failover를 막기 위해 장애를 일정 시간(기본 8초) 유지될 때만 인정하는 대기. | +| Lease (임대) | Kubernetes Lease 오브젝트. 본 operator는 외부 HA 에이전트 없이 이를 DCS로 써서 Primary 선출을 한다. | +| DCS (Distributed Configuration Store) | HA 상태 합의를 저장하는 분산 저장소. 본 operator는 K8s API(Lease)를 DCS로 사용. | +| Rogue Primary | 정상 승격 절차 밖에서 자신이 Primary라 여기는 이상 인스턴스. 감지 시 re-seed로 정리. | +| Stale Replica | WAL 수신이 뒤처져 최신 상태가 아닌 복제본. | +| Re-seed (재시드) | 뒤처지거나 이상한 인스턴스의 데이터를 새 Primary 기준으로 다시 복제해 정상화. | +| Split-brain | Primary가 둘 이상 존재해 데이터가 갈라지는 장애. fencing으로 예방. | +| RTO (Recovery Time Objective) | 장애에서 서비스 복구까지 허용되는 목표 시간. 본 프로젝트 failover 드릴 기준 30초. | +| Pod readiness | Pod/컨테이너가 트래픽을 받을 준비가 됐는지의 K8s 상태. 승격 후보 검증에 사용. | + +## 백업 · 복구 (PITR) + +| 용어 | 정의 | +|---|---| +| PITR (Point-In-Time Recovery) | WAL을 재생해 데이터베이스를 **특정 과거 시점**으로 복원하는 기법. | +| WAL (Write-Ahead Log) | 변경을 먼저 기록하는 PostgreSQL의 로그. 복제·PITR의 기반. | +| WAL archiving | WAL 세그먼트를 백업 저장소로 지속 보관하는 것(`archive-push`). | +| pgBackRest | 본 operator의 기본 백업 도구(플러그인). WAL-G·Barman은 대체 플러그인. | +| restore_command | 복구 시 아카이브된 WAL을 가져오는 PostgreSQL 설정 명령. | +| targetTime | PITR가 복원할 목표 시각. 본 구현은 microsecond 정밀도 보존. | +| ScheduledBackup | 크론 주기로 BackupJob을 자동 생성하는 CRD. | +| Repo (백업 저장소) | pgBackRest가 백업/WAL을 두는 저장소. 본 구현은 data PVC 내부 경로 사용. | +| 오프라인 복원 | STS를 scale-0으로 내려 Pod 정지 후 data PVC를 마운트해 복원하는 방식. | + +## Kubernetes · 운영 + +| 용어 | 정의 | +|---|---| +| CRD (Custom Resource Definition) | K8s API를 확장하는 사용자 정의 리소스 타입(PostgresCluster 등). | +| Reconcile / Reconciler | 실제 상태를 원하는 상태(spec)로 수렴시키는 controller-runtime 루프. | +| controller-runtime | Kubebuilder 기반 컨트롤러 작성 라이브러리. | +| StatefulSet (STS) | 안정적 식별자·스토리지를 갖는 Pod 집합. PostgreSQL 인스턴스를 띄우는 워크로드. | +| PVC (PersistentVolumeClaim) | Pod에 영속 스토리지를 붙이는 K8s 요청 오브젝트. | +| Admission Webhook | 리소스 생성/수정 시 검증(validating)·기본값(defaulting)을 수행하는 진입점. | +| Finalizer | 리소스 삭제 전에 정리 로직을 보장하기 위한 K8s 메커니즘. | +| Annotation | 오브젝트에 붙이는 비식별 메타데이터. 본 operator는 restore-in-progress 락 등에 사용. | +| Hibernation | 클러스터를 STS scale-0으로 내려 PVC는 보존한 채 휴면시키는 기능. | +| CEL validation | CRD 스키마에서 표현식으로 값 제약을 거는 검증(예: 보호된 이름 차단). | +| Helm chart | K8s 매니페스트를 패키징·배포하는 단위. operator 배포에 사용. | +| envtest | 실제 클러스터 없이 API 서버/etcd만 띄워 컨트롤러를 통합 테스트하는 도구. | +| kind | Docker 컨테이너 안에 K8s 클러스터를 띄우는 도구. e2e 테스트에 사용. | +| DinD (Docker-in-Docker) | 컨테이너 안에서 또 Docker 데몬을 돌리는 방식. 중첩 시 cgroup 한계 발생 가능. | +| DooD (Docker-out-of-Docker) | 컨테이너가 호스트의 Docker 데몬(소켓)을 공유해 쓰는 방식. | + +## 분산 SQL · 샤딩 (로드맵) + +| 용어 | 정의 | +|---|---| +| ShardRange | 샤드 범위를 정의하는 CRD. 분산 메타데이터의 source of truth. | +| ShardSplitJob | 온라인으로 샤드를 분할하는 작업 CRD. | +| AutoSplit | 임계치 도달 시 샤드 분할을 자동 트리거. | +| vindex | 라우팅 키를 샤드로 해석하는 인덱스(쿼리 라우터가 소비). | +| pg-router | PG wire-protocol v3 기반 쿼리 라우터 프록시(PoC). | +| 2PC / saga | 크로스 샤드 분산 트랜잭션을 위한 자체 구현 방식(외부 확장 비의존). | +| Replica Cluster | 외부 클러스터를 streaming standby로 복제하는 구성. | + +## 프로젝트 · 문서 + +| 용어 | 정의 | +|---|---| +| G0~G6 게이트 | 로드맵 단계 게이트(G1 단일 샤드 HA, G2 운영 품질, G3 샤딩 기반 등). | +| SSOT (Single Source of Truth) | 한 사실을 한 곳에만 두고 나머지는 링크/발췌하는 단일 출처 원칙. | +| ADR (Architecture Decision Record) | 설계 결정과 근거를 남기는 문서. | +| RFC (Request for Comments) | 단계별 설계 제안 문서. | +| RCA (Root Cause Analysis) | 장애·실패의 근본 원인 분석. | +| 게이트(make gate) | lint + test + audit + validate를 묶은 릴리스 품질 게이트. | + +--- + +> 새 용어가 문서에 등장하면 여기에 한 줄 정의로 추가하고, 그 문서 마지막 장 "용어집" 절에 발췌한다. diff --git a/docs/PROJECT_OVERVIEW.md b/docs/PROJECT_OVERVIEW.md index 749d043..0cae89a 100644 --- a/docs/PROJECT_OVERVIEW.md +++ b/docs/PROJECT_OVERVIEW.md @@ -264,3 +264,21 @@ kubectl wait postgrescluster/quickstart --for=condition=Ready --timeout=5m | RFC 인덱스 | `docs/rfcs/INDEX.md` | Request for Comments | | Operator 가이드 | `docs/operator-guide/` | 배포 / HA / 모니터링 가이드 | | Runbook | `docs/runbooks/` | 백업 / 복구 / HA / 보안 절차 | +| 용어집 | `docs/GLOSSARY.ko.md` | 용어 정의 SSOT (각 문서 마지막 장 발췌의 원본) | + +--- + +## 12. 용어집 + +> 정의는 [GLOSSARY.ko.md](GLOSSARY.ko.md)에서 발췌해 동일하게 유지한다. 전체 용어는 해당 문서 참고. + +| 용어 | 정의 | +|---|---| +| Operator | Kubernetes 위에서 애플리케이션(여기선 PostgreSQL)의 생성·운영·복구를 CRD + 컨트롤러로 자동화하는 소프트웨어. | +| CRD (Custom Resource Definition) | K8s API를 확장하는 사용자 정의 리소스 타입(PostgresCluster 등). | +| Reconcile / Reconciler | 실제 상태를 원하는 상태(spec)로 수렴시키는 controller-runtime 루프. | +| StatefulSet (STS) | 안정적 식별자·스토리지를 갖는 Pod 집합. PostgreSQL 인스턴스를 띄우는 워크로드. | +| Hibernation | 클러스터를 STS scale-0으로 내려 PVC는 보존한 채 휴면시키는 기능. | +| ShardRange | 샤드 범위를 정의하는 CRD. 분산 메타데이터의 source of truth. | +| G0~G6 게이트 | 로드맵 단계 게이트(G1 단일 샤드 HA, G2 운영 품질, G3 샤딩 기반 등). | +| Helm chart | K8s 매니페스트를 패키징·배포하는 단위. operator 배포에 사용. | diff --git a/docs/TEST_ANALYSIS.md b/docs/TEST_ANALYSIS.md index d70ca9b..925e5be 100644 --- a/docs/TEST_ANALYSIS.md +++ b/docs/TEST_ANALYSIS.md @@ -337,3 +337,20 @@ git commit -m "chore: normalize line endings via .gitattributes" # Linux/macOS/WSL 또는 Dev Container 내부에서 find . -name "*.sh" -exec sed -i 's/\r//' {} + ``` + +--- + +## 7. 용어집 + +> 정의는 [GLOSSARY.ko.md](GLOSSARY.ko.md)에서 발췌해 동일하게 유지한다. 전체 용어는 해당 문서 참고. + +| 용어 | 정의 | +|---|---| +| envtest | 실제 클러스터 없이 API 서버/etcd만 띄워 컨트롤러를 통합 테스트하는 도구. | +| kind | Docker 컨테이너 안에 K8s 클러스터를 띄우는 도구. e2e 테스트에 사용. | +| 게이트(make gate) | lint + test + audit + validate를 묶은 릴리스 품질 게이트. | +| Reconcile / Reconciler | 실제 상태를 원하는 상태(spec)로 수렴시키는 controller-runtime 루프. | +| Lease (임대) | Kubernetes Lease 오브젝트. 외부 HA 에이전트 없이 이를 DCS로 써서 Primary 선출을 한다. | +| Fencing (PVC Fencing) | 옛/이상 Primary가 데이터에 쓰지 못하도록 PVC 접근을 차단해 split-brain을 막는 격리. | +| StatefulSet (STS) | 안정적 식별자·스토리지를 갖는 Pod 집합. PostgreSQL 인스턴스를 띄우는 워크로드. | +| DinD / DooD | Docker-in-Docker(컨테이너 안에서 또 데몬) / Docker-out-of-Docker(호스트 데몬 공유). | diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index f159bc6..93f2aa0 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -146,4 +146,22 @@ Windows 호스트에서는 `-v E:\keiailab\postgres-operator:/workspace`처럼 - **failover wiring은 이번 작업 이전부터 이미 존재**했다. Reconcile 경로: `clusterFailoverDecision → DetectPrimaryFailure → shouldPromoteAfterDebounce`(8s) `→ #220 failback guard → executeClusterPromotion → reconcileRoguePrimaries`. 이번 변경은 그 위에 fencing/readiness/pg_promote 보강을 얹은 것. - **DB/User status.applied도 이미 구현**돼 있다(`postgresdatabase_controller.go`, `postgresuser_controller.go`). 과거 분석 메모의 "미구현" 진단은 부정확했으니, 미완 여부는 문서가 아니라 **코드/`git log`로 확인**할 것. - envtest는 Kubernetes 1.36 바이너리를 받는다(`make test`가 자동). 오프라인 환경이면 `bin/k8s/` 캐시 확인. -``` + +--- + +## 6. 용어집 + +> 정의는 [GLOSSARY.ko.md](GLOSSARY.ko.md)에서 발췌해 동일하게 유지한다. 전체 용어는 해당 문서 참고. + +| 용어 | 정의 | +|---|---| +| Failover (장애 조치) | Primary 장애 감지 후 Replica 하나를 새 Primary로 자동 승격해 서비스를 잇는 동작. | +| Promotion (승격) | Replica를 Primary로 올리는 행위. 본 operator는 `pg_promote()`(SQL)로 수행. | +| Fencing (PVC Fencing) | 옛/이상 Primary가 데이터에 쓰지 못하도록 PVC 접근을 차단해 split-brain을 막는 격리. | +| PITR (Point-In-Time Recovery) | WAL을 재생해 데이터베이스를 특정 과거 시점으로 복원하는 기법. | +| WAL (Write-Ahead Log) | 변경을 먼저 기록하는 PostgreSQL의 로그. 복제·PITR의 기반. | +| RTO (Recovery Time Objective) | 장애에서 서비스 복구까지 허용되는 목표 시간. 본 프로젝트 failover 드릴 기준 30초. | +| DinD / DooD | Docker-in-Docker(컨테이너 안에서 또 데몬) / Docker-out-of-Docker(호스트 데몬 공유). | +| envtest | 실제 클러스터 없이 API 서버/etcd만 띄워 컨트롤러를 통합 테스트하는 도구. | +| SSOT (Single Source of Truth) | 한 사실을 한 곳에만 두고 나머지는 링크/발췌하는 단일 출처 원칙. | +| RCA (Root Cause Analysis) | 장애·실패의 근본 원인 분석. | From b85b9c3c1ec583ed1a0186af686fb52d3b51325e Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Fri, 26 Jun 2026 13:33:09 +0900 Subject: [PATCH 013/109] =?UTF-8?q?chore(ha):=20operator-level=20failover?= =?UTF-8?q?=20=EC=A0=84=EC=9A=A9=20lease=20=EB=AC=B4=ED=9A=A8=20=EB=B0=B0?= =?UTF-8?q?=EC=84=A0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manager lease가 reconcile-루프 failover(clusterFailoverDecision→executeClusterPromotion)를 이미 단일-active로 게이팅하므로, IsLeader()를 아무도(테스트 외) 참조하지 않던 전용 failover lease 배선을 cmd/main.go에서 제거(헬퍼·import 정리). lease.go는 leader 단일성·handoff 검증을 갖춘 building block으로 보존하되 헤더를 정직화(미배선·deadlock 주의). RFC 0007 §7 상태 정정. 순진한 IsLeader() 게이팅은 manager-lease holder≠failover-lease holder 시 deadlock이라, P2-T3는 failover를 reconcile 밖 runnable로 분리 후 게이팅해야 함(후행). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- cmd/main.go | 99 +++-------------------- docs/rfcs/0007-ha-election-and-fencing.md | 11 ++- internal/controller/failover/lease.go | 20 ++++- 3 files changed, 40 insertions(+), 90 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index c3f2406..3d86355 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -7,12 +7,9 @@ Licensed under the MIT License. See the LICENSE file for details. package main import ( - "context" "crypto/tls" "flag" - "fmt" "os" - "strings" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. @@ -20,7 +17,6 @@ import ( "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" - "k8s.io/client-go/kubernetes" clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/healthz" @@ -31,7 +27,6 @@ import ( postgresv1alpha1 "github.com/keiailab/postgres-operator/api/v1alpha1" "github.com/keiailab/postgres-operator/internal/controller" - "github.com/keiailab/postgres-operator/internal/controller/failover" "github.com/keiailab/postgres-operator/internal/plugin" pluginbackuppgbackrest "github.com/keiailab/postgres-operator/internal/plugin/backup/pgbackrest" pluginextpgaudit "github.com/keiailab/postgres-operator/internal/plugin/extension/pgaudit" @@ -56,47 +51,6 @@ func init() { // +kubebuilder:scaffold:scheme } -// leaderElectionAgnosticRunnable runs on every manager replica regardless of -// the controller-runtime manager's own leader election. The failover lease must -// be contested by all replicas (it is a separate lease from the manager lease), -// so its runnable must not be gated behind manager leadership. -type leaderElectionAgnosticRunnable struct { - start func(context.Context) error -} - -func (r leaderElectionAgnosticRunnable) Start(ctx context.Context) error { return r.start(ctx) } -func (r leaderElectionAgnosticRunnable) NeedLeaderElection() bool { return false } - -// failoverIdentity returns a unique-per-Pod identity for the failover lease. -// In-cluster, os.Hostname() returns the Pod name; POD_NAME env overrides for -// explicit Downward-API wiring. -func failoverIdentity() string { - if v := os.Getenv("POD_NAME"); v != "" { - return v - } - if h, err := os.Hostname(); err == nil && h != "" { - return h - } - return "postgres-operator-failover" -} - -// operatorNamespace resolves the namespace the operator runs in: POD_NAMESPACE -// env first, then the in-cluster ServiceAccount namespace file. -func operatorNamespace() (string, error) { - if v := os.Getenv("POD_NAMESPACE"); v != "" { - return v, nil - } - data, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace") - if err != nil { - return "", fmt.Errorf("resolve operator namespace: %w", err) - } - ns := strings.TrimSpace(string(data)) - if ns == "" { - return "", fmt.Errorf("resolve operator namespace: empty ServiceAccount namespace file") - } - return ns, nil -} - // nolint:gocyclo func main() { var metricsAddr string @@ -347,46 +301,19 @@ func main() { os.Exit(1) } - // HA failover leader election (ROADMAP G1 §automatic failover). A dedicated - // lease (failover.FailoverLeaseName), separate from the controller-runtime - // manager lease, elects the single operator replica responsible for failover - // decisions. Registered as a leader-election-agnostic runnable so every - // replica contests the lease and it hands off when the holder Pod is lost. - failoverClientset, err := kubernetes.NewForConfig(mgr.GetConfig()) - if err != nil { - setupLog.Error(err, "Failed to build clientset for failover lease") - os.Exit(1) - } - failoverNS, err := operatorNamespace() - if err != nil { - setupLog.Error(err, "Failed to resolve operator namespace for failover lease") - os.Exit(1) - } - failoverLease, err := failover.NewLease(failover.LeaseConfig{ - Client: failoverClientset, - Namespace: failoverNS, - Identity: failoverIdentity(), - OnStartedLeading: func(context.Context) { - setupLog.Info("Acquired failover leadership", "lease", failover.FailoverLeaseName, "identity", failoverIdentity()) - }, - OnStoppedLeading: func() { - setupLog.Info("Lost failover leadership", "lease", failover.FailoverLeaseName, "identity", failoverIdentity()) - }, - }) - if err != nil { - setupLog.Error(err, "Failed to construct failover lease") - os.Exit(1) - } - if err := mgr.Add(leaderElectionAgnosticRunnable{start: func(ctx context.Context) error { - if rerr := failoverLease.Run(ctx); rerr != nil && ctx.Err() == nil { - return rerr - } - return nil - }}); err != nil { - setupLog.Error(err, "Failed to register failover lease runnable") - os.Exit(1) - } - + // HA failover single-active guarantee (ROADMAP G1 §automatic failover): + // failover detection + promotion runs inside the PostgresCluster reconcile + // loop (clusterFailoverDecision -> executeClusterPromotion), which the + // controller-runtime manager already gates behind its own leader election + // (--leader-elect, default true). A single operator replica therefore drives + // failover; no separate failover lease is needed here. + // + // A dedicated failover-controller lease (internal/controller/failover, RFC + // 0007 P2-T3) is intentionally NOT wired in: gating the reconcile-loop + // failover behind a second, manager-lease-independent lease could deadlock + // (the failover-lease holder may not be the manager-lease holder that runs + // reconcilers). A proper P2-T3 must move failover into its own + // leader-election-agnostic runnable first — tracked as future work. setupLog.Info("Starting manager") if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { setupLog.Error(err, "Failed to run manager") diff --git a/docs/rfcs/0007-ha-election-and-fencing.md b/docs/rfcs/0007-ha-election-and-fencing.md index 4bf32bf..ab55989 100644 --- a/docs/rfcs/0007-ha-election-and-fencing.md +++ b/docs/rfcs/0007-ha-election-and-fencing.md @@ -120,8 +120,17 @@ Recovering a fenced Pod is a manual operator task: - [x] Lease-based leader election (`internal/instance/election/`) - [x] PVC fencing (`internal/instance/fencing/`, P2-T2 active 2026-04-28) - [x] `--fencing-disabled` development knob +- [x] Failover **detection + promotion** (`internal/controller/failover` pure-decision + functions + `executeClusterPromotion`), executed inside the PostgresCluster reconcile + loop and single-active-gated by the **controller-runtime manager lease** (`--leader-elect`, + default true). Includes PVC pre-fencing, split-brain reseed (#220), promotion-candidate + readiness guards, and debounce. - [ ] `kubectl postgres failover` CLI command (Phase 13) -- [ ] failover controller (P2-T3) +- [ ] **Dedicated failover-controller lease (P2-T3)** — `internal/controller/failover/lease.go` + exists and is unit-tested (leader single-ness + handoff) but is **not yet wired into + production**. It must NOT naively gate the reconcile-loop failover (that holder may differ + from the manager-lease holder → deadlock). A proper P2-T3 first extracts failover into a + leader-election-agnostic runnable, then gates that runnable on this lease. - [ ] `pg_rewind` integration (P2-T4) ## 8. References diff --git a/internal/controller/failover/lease.go b/internal/controller/failover/lease.go index 8d8e257..6929fc8 100644 --- a/internal/controller/failover/lease.go +++ b/internal/controller/failover/lease.go @@ -28,14 +28,28 @@ import ( // adapter 는 failover scope 의 lease 명명 규약 + 기본 매개변수 + 콜백 // 시그니처 만 노출한다. // -// 사용처: cmd/main.go 에서 manager 시작 후 별 goroutine 에서 Run. +// 배선 상태 (RFC 0007 P2-T3, 미완): 본 adapter 는 *아직 production 에 배선되지 +// 않은 building block* 이다. 현재 자동 failover (detection + promotion) 는 +// PostgresCluster reconcile 루프 (clusterFailoverDecision -> executeCluster +// Promotion) 에서 실행되며, 이는 controller-runtime manager 의 자체 leader +// election 으로 이미 단일 replica 로 게이팅된다. 따라서 별도 failover lease 없이도 +// "오직 한 operator 가 failover 수행" 이 보장된다. +// +// 본 lease 를 reconcile 루프 failover 의 게이트로 *순진하게* 연결하면 안 된다: +// reconciler 는 manager-lease holder 에서만 돌고, 본 lease 는 그와 독립적인 +// 별도 lease 라 holder 가 다른 Pod 일 수 있다 — 그 경우 failover 가 어느 Pod +// 에서도 실행되지 않는 deadlock 이 된다. 제대로 된 P2-T3 는 failover 를 +// reconcile 루프 밖의 leader-election-agnostic runnable 로 먼저 분리한 뒤, +// 그 runnable 을 본 lease 로 게이팅해야 한다 (후속 과제). +// +// 의도된 사용 형태 (P2-T3 완성 시): // // cfg := failover.LeaseConfig{ // Client: clientset, // Namespace: operatorNS, // Identity: podName, -// OnStartedLeading: func(ctx context.Context) { failoverCtrl.Enable() }, -// OnStoppedLeading: func() { failoverCtrl.Disable() }, +// OnStartedLeading: func(ctx context.Context) { failoverRunnable.Enable() }, +// OnStoppedLeading: func() { failoverRunnable.Disable() }, // } // lease, err := failover.NewLease(cfg) // go lease.Run(ctx) From 488f9228e8946fa3ae62dfebd38c3988e634dcb0 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Fri, 26 Jun 2026 13:33:32 +0900 Subject: [PATCH 014/109] =?UTF-8?q?feat(router):=20=EA=B5=90=EC=B2=B4=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=ED=95=9C=20=EB=9D=BC=EC=9A=B0=ED=8C=85?= =?UTF-8?q?=ED=82=A4=20=EC=B6=94=EC=B6=9C=20=EC=A0=84=EB=9E=B5(regex/parse?= =?UTF-8?q?r/auto)=20+=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20=EA=B0=AD=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RouteKeyExtractor 인터페이스로 라우팅키 추출을 런타임 선택 가능하게. regex(기본·정규식 컬럼 인지), parser(제로 의존성 토크나이저 — 따옴표/이스케이프/주석/복합 predicate/INSERT 위치/UPDATE·DELETE 정확 처리), auto(parser 우선+regex fallback). 외부 SQL 파서(auxten)는 ~25 transitive 모듈 + 옛 genproto가 오퍼레이터 현대 deps와 ambiguous import 충돌로 빌드 파괴 → 기각, 자체 토크나이저로 대체(murmur3 철학, dep 0). docs/sharding/ROUTER-GAP-ANALYSIS.ko.md: 라우터 두 반쪽 분리 진단 + Vitess-for-Postgres 능력 사다리 + 향후 대작업 백로그. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/sharding/ROUTER-GAP-ANALYSIS.ko.md | 156 ++++++++++ internal/router/route_extractor.go | 89 ++++++ internal/router/route_extractor_parser.go | 266 ++++++++++++++++++ .../router/route_extractor_parser_test.go | 79 ++++++ internal/router/route_extractor_test.go | 71 +++++ internal/router/sql_route.go | 117 ++++++-- 6 files changed, 756 insertions(+), 22 deletions(-) create mode 100644 docs/sharding/ROUTER-GAP-ANALYSIS.ko.md create mode 100644 internal/router/route_extractor.go create mode 100644 internal/router/route_extractor_parser.go create mode 100644 internal/router/route_extractor_parser_test.go create mode 100644 internal/router/route_extractor_test.go diff --git a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md new file mode 100644 index 0000000..b0c87f8 --- /dev/null +++ b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md @@ -0,0 +1,156 @@ +# 라우터 프로덕션 갭 분석 + 능력 사다리 + +> 분산 SQL(Citus급) 방향의 **척추 = 라우터**다. 본 문서는 `internal/router/` + `cmd/pg-router` +> 전수 audit으로 "무엇이 진짜 동작 / 골격 / 프로덕션까지 빠진 것"을 코드 레벨로 못박고, +> Vitess-검증 난이도 순서의 **능력 사다리**와 **첫 출하 슬라이스**를 정의한다. +> +> 작성 기준: 2026-06-26. 관련: [SHARDING.md](SHARDING.md) · [RFC-0004](../rfcs/0004-pg-router-architecture.md) · +> [RFC-0002](../rfcs/0002-shardrange-crd.md) · [WORK_HANDOFF.ko.md §6](../WORK_HANDOFF.ko.md) + +--- + +## 0. 좌표: 이건 Citus가 아니라 "Vitess-for-Postgres" + +- **Citus** = PG **내부 확장(extension)**. ADR-0003에서 명시적으로 버린 길. +- 현재 아키텍처 = **stateless 라우터 + vanilla PG 샤드 + vindex + 토폴로지 메타데이터** = **Vitess 모델**. +- 함의: Vitess가 *이 아키텍처가 동작한다는 존재증명* + 그 난이도 순서가 최고의 지도. 단, 완전판은 수십 인년 → **유용한 부분집합**(단일샤드 라우팅 + 읽기 scatter + reference table + 보조 resharding)을 노린다. + +--- + +## 1. 핵심 구조 결함: 라우터가 "두 반쪽"으로 분리됨 + +| | 반쪽 A — `cmd/pg-router` (실 프록시) | 반쪽 B — `internal/router` in-process 라이브러리 | +|---|---|---| +| 정체 | raw TCP 바이트 프록시 | 타입 잡힌 Go 라이브러리(scatter/executor/store/placement) | +| 라우팅 | **연결 단위**(startup의 db/user 이름) | 쿼리 단위 fan-out + merge | +| 토폴로지 | **2-shard 하드코딩**(`shardSpec()`) | (해당 없음) | +| 프로덕션 소비처 | 없음(배포 매니페스트 0) | **없음**(PoC 커맨드 + 테스트만) | +| 둘의 연결 | `vindex.ResolveShard`만 공유. scatter/executor/store는 **pg-router가 안 씀** | — | + +**결론**: 두 반쪽이 따로 논다. pg-router는 바이트만 흘리고, 잘 만든 라이브러리는 orphan이다. +어느 쪽도 operator reconciler에 연결돼 있지 않다(유일 예외: ShardSplitJob이 `ValidateSplitPlan` 호출). +**토폴로지 흐름 ShardRange CRD → 라우터가 끊겨 있다** — pg-router가 CRD를 읽지 않고 하드코딩한다. + +프로덕션 router import처: `internal/controller/shardsplitjob_controller.go` 단 1곳(split-plan 검증). 나머지는 `cmd/{pg-router,scatter-poc,reshard-copy-poc}`(전부 PoC). + +--- + +## 2. 파일별 audit + +| 파일 | 상태 | 평가 | +|---|---|---| +| `vindex.go` | ✅ **진짜·견고** | murmur3(자체)/fnv/crc32 해시 + range vindex + overlap 검증. 순수·결정적. **가장 단단한 조각.** 미구현: consistent-hash·lookup(명시 보류). pg-router가 소비. | +| `resharding.go` | ✅ **진짜** | split-plan gap/overlap/coverage 보존 불변식 검증. ShardSplitJob이 소비. `adjacent/hexSuccessor`는 고정폭 hex 전제의 약식이나 컨벤션 내 동작. | +| `placement.go` | 🟡 **진짜지만 orphan** | drift detection(Missing/Extra/Zone/Node/NotReady/RangeUncovered) 순수 함수. 견고하나 **어떤 reconciler도 호출 안 함.** | +| `metadata_store.go` | 🟡 **진짜지만 orphan + 미배선** | `pg_keiailab` 스키마 + 마이그레이션 + Upsert/List/Delete(실 SQL). 그러나 ① CRD→store 채우는 reconciler 없음 ② 라우터가 store를 안 읽음 ③ store가 살 "coordinator Postgres"가 프로비저닝되지 않음. | +| `sql_executor.go` | 🟡 **진짜지만 PoC급 + 미연결** | lib/pq 실 구현. 그러나 **호출마다 sql.Open+Close**(풀링 없음 → 부하 시 치명), 원시 driver `any` 반환(PG wire 타입 아님), **pg-router가 안 씀**(바이트 프록시라서). | +| `scatter.go` | 🟡 **골격** | fan-out+gather+merge(concat/naive order-by) + 취소 전파. 그러나 merge가 `fmt.Sprintf("%v")` 문자열 비교(숫자/타입 부정확), aggregate/LIMIT pushdown 없음, k-way 스트리밍 없음, 컬럼 메타데이터 미전파. 주입된 ShardExecutor 의존. | +| `sql_route.go` | 🔴 **PoC(정규식)** | `VALUES('key'`·`WHERE col='key'`의 첫 single-quote 리터럴만 정규식 추출. **진짜 SQL 파서 아님.** prepared/parameterized(`$1`)·복합 predicate·JOIN·quoted ident·schema-qualified·주석·multi-statement 전부 누락. **단일샤드 라우팅의 #1 갭.** | + +--- + +## 3. 프로덕션 척추까지 빠진 것 (능력 사다리 1·2단계 기준) + +순수-함수 기반(vindex·validation·placement·metadata 스키마)은 이미 진짜. 빠진 건 **통합 척추**다: + +- **(A) SQL 파싱** — 정규식을 **pure-Go PG 파서**로 교체해 WHERE/INSERT에서 샤딩키를 정확히 추출. CGO libpg_query는 distroless/`CGO_ENABLED=0`와 충돌하므로 pure-Go 선정이 선행(아래 §5 결정). +- **(B) CRD 기반 토폴로지** — 라우터가 ShardRange CRD를 watch(또는 reconciler가 채운 metadata_store를 read)하여 **라이브 라우팅 테이블 + 핫리로드**. 현재 하드코딩. +- **(C) 라우터 배포 + HA** — Deployment/Service 매니페스트. 라우터는 stateless라 Service 뒤 N replica. **라우터가 새 연결 엔드포인트(신규 SPOF)** 가 되므로 자체 HA 필수. 현재 매니페스트 0. +- **(D) 커넥션 풀링** — `SQLShardExecutor`의 per-call open/close 제거(풀 + prepared stmt 캐시). +- **(E) 두 반쪽 통합** — pg-router 바이트 프록시를 **메시지 인지(message-aware) 프록시**로 올려 단일샤드 fast-path(연결 핀)와 멀티샤드 read(scatter 라이브러리) 경로를 한 프로세스에서 처리. 아키텍처 결정 필요. + +--- + +## 4. 능력 사다리 (Vitess-검증 난이도 순 · 매 단계 데모 수치) + +| 단계 | 능력 | cross-shard 난이도 | 현재 | 첫 시장 필요? | +|---|---|---|---|---| +| 1 | 단일 샤드 라우팅(샤딩키 point query) | 없음 | A·E 필요 | ✅ 필수 | +| 2 | 읽기 scatter-gather(fan-out+merge) | 낮음 | scatter 골격→실연결 | 🟡 분석쿼리 | +| 3 | scatter + 집계/정렬 pushdown | 중간 | 미착수 | 선택 | +| 4 | **Reference table**(전 샤드 복제 → 조인 우회) | 낮음·고가치 | 미착수 | ✅ 권장 | +| 5 | 무중단 resharding(CDC 논리복제 + cutover) | 높음 | ShardSplitJob 골격(데이터 이동 X) | 운영 필수 | +| 6 | cross-shard 쓰기 / 2PC | 최고 | 미착수 | ❌ 명시적 범위 밖 | + +**원칙**: 6번은 ROI 최저(Vitess도 수년 보류) → **명시적으로 "범위 밖" 선언**. 1·2·4번만으로 +가장 큰 실세계 패턴(**tenant_id 샤딩 멀티테넌트 SaaS**)을 완전히 덮는다 — 거의 모든 쿼리가 +단일 샤드로 떨어져 2PC가 필요 없다. + +--- + +## 5. 첫 출하 슬라이스 + 결정해야 할 한 가지 + +**첫 슬라이스(능력 1단계)** = "tenant_id로 라우팅되는 단일샤드 fast-path가 실제 동작하고, 토폴로지는 ShardRange CRD에서 오며, 라우터가 배포 가능하고, single-shard 벤치 수치가 나온다." + +작업: (A) SQL 파서 도입 → (B) CRD watch 토폴로지 → (C) Deployment/Service 매니페스트 → (E) 메시지 인지 프록시. + +### 결정됨 — 라우팅 키 추출은 **교체 가능 전략 + lean 기본** + +라우터 키 추출을 한쪽으로 하드코딩하지 않고 **`RouteKeyExtractor` 인터페이스(전략)** 로 +노출한다 (`internal/router/route_extractor.go`). 세 전략 *모두 제로 외부 의존성* 이라 +항상 컴파일되고 **런타임 선택** 가능하다: + +| 전략 | 구현 | 의존성 | 정확도 | +|---|---|---|---| +| `regex` (기본) | `sql_route.go` 정규식(컬럼 인지: WHERE/AND 등호 + INSERT 위치) | **0** | point query 흔한 형태 | +| `parser` | `route_extractor_parser.go` *토크나이저* | **0** | 따옴표/이스케이프/주석/복합 predicate/INSERT 위치/UPDATE·DELETE/`t.col` 한정 | +| `auto` | parser 우선 + regex fallback | **0** | 둘의 합 | + +**외부 SQL 파서 도입 시도 → 기각 (실측, 2026-06-26)**: `auxten/postgresql-parser +v1.0.1` 을 평가했으나 두 가지 이유로 **기각**: +1. **무게**: `go mod tidy` 시 ~25 transitive 모듈(cockroachdb/errors·gogo/protobuf· + sentry·grpc-gateway·logrus 등) — distroless 미니멀리즘과 충돌. +2. **치명적 — 빌드 파괴**: auxten 이 *옛 monolithic* `google.golang.org/genproto` 를 + 고정해, 오퍼레이터의 현대 deps(grpc 1.79·otel·cel-go 가 쓰는 *split* genproto)와 + **ambiguous import 충돌** → 빌드 자체가 깨짐. go.mod 는 모듈 전역이라 build-tag 로도 + 격리 불가. (`pganalyze/pg_query_go`=CGO 탈락, `cockroachdb-parser`=동일 계열 더 무거움.) + +**그래서 결정**: 외부 파서 대신 **제로 의존성 토크나이저**를 자체 구현(murmur3 자체 +구현과 동일 철학). 정규식보다 정확하면서(따옴표 내부·주석의 가짜 predicate 오인 안 함) +의존성 0 을 지킨다. 단위 테스트로 SELECT/INSERT/UPDATE/DELETE/복합 predicate/ +parameterized/`t.col`/주석·문자열 내부 오인 방지까지 검증. **기본 전략은 regex(현황 +유지)**, 정확 라우팅이 필요한 배포는 `parser`/`auto` 선택. 런타임 선택은 (E) 메시지 +인지 프록시에서 env 로 노출한다. + +--- + +## 6. 백로그 — 향후 대작업 TODO + +> "더 좋은 방향"이지만 규모가 큰 후속 작업을 *미리 기록*해 둔다(사용자 요청 2026-06-26). +> 각 항목은 능력 사다리(§4) 단계 또는 회복력/운영 축에 매핑된다. 우선순위는 가변. + +**라우팅 핵심 (능력 사다리)** +- [ ] **(E) 프로토콜 종단 — 쿼리 단위 라우팅 (vtgate급, 최대 작업)**: 라우터가 클라이언트 연결을 종단하고 자체 인증 + 백엔드 연결 풀 + 결과 재조립. 현재 `RouteKeyExtractor`(regex/parser/auto)가 그 부품. *왜 큰가*: PG는 인증→쿼리 순서라, 쿼리 내용으로 라우팅하려면 라우터가 PG 서버를 완전히 흉내내야 함. 사다리 1~2단계의 진짜 완성. +- [ ] **읽기 → replica 라우팅**: `status.shards[].replicas[]`(Ready)로 읽기 쿼리를 분산 → primary 부하 경감 + 읽기 확장. 사다리 2단계. (status backend 계층 재사용.) +- [ ] **scatter-gather 실연결**: `scatter.go` 골격 → 실 wire-protocol fan-out + merge + 집계/정렬 pushdown. 사다리 2~3단계. +- [ ] **Reference table**: 전 샤드 복제로 분산 조인 우회. 사다리 4단계. 고가치·저난도. +- [ ] **무중단 resharding 데이터 이동**: `ShardSplitJob`의 InitialCopy/CDC 실결선(현재 골격, 데이터 미이동). `router.CopyTable` DSN 결선 + 논리복제 + write-block cutover. 사다리 5단계. +- [ ] **cross-shard 2PC**: 사다리 6단계. **현재 명시적 범위 밖**(ROI 최저). 멀티테넌트 v1엔 불필요. +- [ ] **커넥션 풀링 (D)**: `SQLShardExecutor`의 per-call `sql.Open`/`Close` 제거 → 풀 + prepared stmt 캐시. scatter 경로 성능. (단일샤드 TCP 프록시엔 불필요.) + +**회복력 / 운영** +- [ ] **stable per-shard primary Service (운영자 측)**: 운영자가 각 샤드의 *현재 primary*를 가리키는 안정 Service를 publish하면, 라우터가 status polling 없이 DNS만으로 즉시 failover-follow → status 모드보다 빠르고 단순. (현재는 `PGROUTER_BACKEND=status`로 status polling.) +- [ ] **ShardRange/status watch (informer)**: 현재 interval(`PGROUTER_REFRESH`) polling → watch 기반 즉시 hot-reload(failover window 단축). +- [ ] **라우터 자체 HA 강화**: readiness가 백엔드 도달성 반영, circuit-breaker, dial retry/backoff, replica 읽기 폴백. +- [ ] **failover 전용 lease 제대로 (RFC 0007 P2-T3)**: 이전에 production 무효 배선을 제거하고 building block으로 보존한 `internal/controller/failover/lease.go`를, failover를 reconcile 루프 밖 leader-election-agnostic runnable로 분리한 뒤 그 lease로 게이팅. + +**상품화 (수치)** +- [ ] **single-shard 성능 baseline 실측**: 라이브 K8s에서 `test/bench/*.sh`로 TPS/p99/QPS 수치(`docs/perf/baseline.md` 채우기). 샤딩 없이도 "HA PG operator"로서의 첫 영업 숫자. +- [ ] **N-shard 분산 수치**: scatter-gather 도달 후 샤드 수별 QPS/latency(분산처리능력 증명). + +--- + +## 7. 용어집 + +> 정의는 [GLOSSARY.ko.md](../GLOSSARY.ko.md)에서 발췌해 동일하게 유지한다. 전체 용어는 해당 문서 참고. + +| 용어 | 정의 | +|---|---| +| Vindex (가상 인덱스) | 샤딩 키 → 샤드를 결정하는 함수/정책(hash·range 등). Vitess 용어 차용. | +| Scatter-gather | 한 쿼리를 여러 샤드에 fan-out하고 결과를 모아 merge하는 분산 읽기 패턴. | +| Reference table | 모든 샤드에 복제해 두는 작은 공통 테이블. 분산 조인을 우회하는 수단. | +| Resharding | 샤드 키 범위를 다른 샤드로 분할/재배치하는 작업. 무중단이 되려면 CDC+cutover 필요. | +| 2PC (Two-Phase Commit) | 여러 샤드에 걸친 쓰기를 원자적으로 커밋하는 분산 트랜잭션 프로토콜. | +| CDC (Change Data Capture) | 원본의 변경분을 잡아 대상으로 흘리는 기법. 여기선 PG 논리복제. | +| Topology (토폴로지) | 어떤 키 범위가 어떤 샤드에 있는지의 라우팅 메타데이터(ShardRange CRD). | +| Stateless 라우터 | 자체 영속 상태 없이 토폴로지만 보고 라우팅하는, 수평 확장 가능한 프록시. | diff --git a/internal/router/route_extractor.go b/internal/router/route_extractor.go new file mode 100644 index 0000000..3a5d14b --- /dev/null +++ b/internal/router/route_extractor.go @@ -0,0 +1,89 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +// Package router — route_extractor.go 는 SQL query 에서 단일-shard 라우팅 key 를 +// 뽑는 전략(strategy)을 *교체 가능한 인터페이스*로 노출한다. +// +// 설계 의도 (RFC-0004 §3.1 단일-shard fast-path): 라우팅 key 추출 전략을 한쪽으로 +// 하드코딩하지 않고 *사용자가 런타임에 선택*하게 한다. 두 구현 모두 *제로 외부 의존성* +// 이라 distroless 미니멀리즘을 깨지 않는다 (둘 다 항상 컴파일 → 런타임 선택). +// +// - regex : 정규식 토큰 추출 (가장 단순). point query 흔한 형태. +// - parser : 제로 의존성 *토크나이저 기반* 추출 (정확). 따옴표/이스케이프/주석/ +// 복합 predicate/INSERT 컬럼 위치/UPDATE·DELETE 까지 토큰 단위로 정확히 처리. +// - auto : parser 우선, 매치 실패 시 regex fallback. +// +// (full PostgreSQL 문법 파서는 아니다 — 라우팅 key 추출 subset 에 집중. auxten/ +// pg_query 등 외부 파서는 의존성 폭증·CGO·distroless 충돌로 도입하지 않음, ROUTER- +// GAP-ANALYSIS §5.) +package router + +import "fmt" + +// RouteKeyExtractor 는 SQL query 에서 shardKeyColumn 에 대한 단일-shard 라우팅 key +// 를 추출한다. point query (단일 shard 로 고정) 면 (key, true), 아니면 ("", false) +// — 호출자는 false 일 때 scatter-gather 로 fallback 한다. +// +// shardKeyColumn 이 "" 이면 *first-literal 모드*(legacy): 컬럼명과 무관하게 처음 +// 발견한 라우팅 key 리터럴을 반환한다. vindex 컬럼(ShardRangeSpec.Vindex.Column)을 +// 넘기면 그 컬럼을 *지정 추출*한다. +type RouteKeyExtractor interface { + // Name 은 전략 식별자 (config/log 용). + Name() string + // ExtractRoutingKey 는 query 에서 shardKeyColumn 의 라우팅 key 를 추출한다. + ExtractRoutingKey(query, shardKeyColumn string) (string, bool) +} + +// 전략 이름 (pg-router config 로 사용자 선택). +const ( + // ExtractorRegex 는 정규식 추출 (가장 단순). + ExtractorRegex = "regex" + // ExtractorParser 는 제로 의존성 토크나이저 기반 추출 (정확). + ExtractorParser = "parser" + // ExtractorAuto 는 parser 우선 + regex fallback. + ExtractorAuto = "auto" +) + +// DefaultExtractorName 은 기본 전략이다. 사용자가 override 가능하며 *추후 변경될 수 +// 있다*. 현재 기본은 regex (현황 유지 — 가장 단순·검증된 경로). 정확 라우팅이 필요한 +// 배포는 parser 또는 auto 를 선택한다. +const DefaultExtractorName = ExtractorRegex + +// NewRouteKeyExtractor 는 이름으로 전략을 생성한다. 알 수 없는 이름은 error. +// 빈 문자열은 DefaultExtractorName 으로 해석한다. 세 전략 모두 제로 외부 의존성이라 +// 항상 사용 가능하다. +func NewRouteKeyExtractor(name string) (RouteKeyExtractor, error) { + if name == "" { + name = DefaultExtractorName + } + switch name { + case ExtractorRegex: + return regexExtractor{}, nil + case ExtractorParser: + return parserExtractor{}, nil + case ExtractorAuto: + return autoExtractor{primary: parserExtractor{}, fallback: regexExtractor{}}, nil + default: + return nil, fmt.Errorf("router: unknown route-key extractor %q (want regex|parser|auto)", name) + } +} + +// autoExtractor 는 primary(파서)를 먼저 시도하고, 매치 실패 시 fallback(정규식)을 +// 시도한다. 파서가 query 를 이해하지 못해도(비표준 문법) 경량 경로로 라우팅 기회를 +// 한 번 더 갖는다. +type autoExtractor struct { + primary RouteKeyExtractor + fallback RouteKeyExtractor +} + +func (autoExtractor) Name() string { return ExtractorAuto } + +func (a autoExtractor) ExtractRoutingKey(query, col string) (string, bool) { + if k, ok := a.primary.ExtractRoutingKey(query, col); ok { + return k, true + } + return a.fallback.ExtractRoutingKey(query, col) +} diff --git a/internal/router/route_extractor_parser.go b/internal/router/route_extractor_parser.go new file mode 100644 index 0000000..05ab404 --- /dev/null +++ b/internal/router/route_extractor_parser.go @@ -0,0 +1,266 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +// Package router — route_extractor_parser.go 는 RouteKeyExtractor 의 *제로 의존성 +// 토크나이저 기반 구현*이다 (정확 전략). 정규식보다 견고하다 — single-quote 리터럴의 +// ” 이스케이프, 주석(`--`, `/* */`), 임의 공백, 복합 predicate(AND/OR), 컬럼의 +// 테이블 한정(`t.col`), INSERT 의 컬럼-값 위치 정렬을 토큰 단위로 정확히 처리한다. +// +// full PostgreSQL 문법 파서는 아니다 — 라우팅 key 추출 subset(` = '리터럴'` / +// INSERT 위치)에 집중한다. 외부 SQL 파서(auxten 등)는 의존성 폭증·genproto 충돌· +// distroless 비호환으로 도입하지 않는다 (ROUTER-GAP-ANALYSIS §5). 토큰화로 정확도를 +// 얻으면서 의존성 0 을 지킨다 (murmur3 자체 구현과 동일 철학). +package router + +import "strings" + +// parserExtractor 는 RouteKeyExtractor 의 토크나이저 기반 구현이다. +type parserExtractor struct{} + +func (parserExtractor) Name() string { return ExtractorParser } + +// ExtractRoutingKey 는 query 를 토큰화하여 shardKeyColumn 의 등호 리터럴을 추출한다. +// col 이 ""(first-literal 모드)이면 컬럼 의미가 없으므로 ("", false) 로 regex 전략에 +// 위임한다 (auto 가 그 fallback 을 수행). +func (parserExtractor) ExtractRoutingKey(query, col string) (string, bool) { + if col == "" { + return "", false + } + toks := tokenize(query) + if len(toks) == 0 { + return "", false + } + switch strings.ToLower(toks[0].text) { + case "insert": + return insertValueTok(toks, col) + case "select", "update", "delete", "with": + return whereEqTok(toks, col) + } + return "", false +} + +type tokKind int + +const ( + tokIdent tokKind = iota // 식별자 / 키워드 / 따옴표 식별자 + tokStr // single-quote 문자열 리터럴 (text = 언쿼트 값) + tokNum // 숫자 + tokSym // 연산자 / 구두점 ( ) , ; . = < > 등 +) + +type token struct { + kind tokKind + text string +} + +// tokenize 는 SQL 을 라우팅 추출에 필요한 최소 토큰열로 분해한다. 주석/공백은 버린다. +func tokenize(s string) []token { + var toks []token + i, n := 0, len(s) + for i < n { + c := s[i] + switch { + case c == ' ' || c == '\t' || c == '\n' || c == '\r': + i++ + case c == '-' && i+1 < n && s[i+1] == '-': // 라인 주석 + for i < n && s[i] != '\n' { + i++ + } + case c == '/' && i+1 < n && s[i+1] == '*': // 블록 주석 + i += 2 + for i+1 < n && !(s[i] == '*' && s[i+1] == '/') { + i++ + } + i += 2 + if i > n { + i = n + } + case c == '\'': // 문자열 리터럴 ('' 이스케이프) + i++ + var b strings.Builder + for i < n { + if s[i] == '\'' { + if i+1 < n && s[i+1] == '\'' { + b.WriteByte('\'') + i += 2 + continue + } + i++ // 닫는 따옴표 + break + } + b.WriteByte(s[i]) + i++ + } + toks = append(toks, token{tokStr, b.String()}) + case c == '"': // 따옴표 식별자 + i++ + var b strings.Builder + for i < n { + if s[i] == '"' { + if i+1 < n && s[i+1] == '"' { + b.WriteByte('"') + i += 2 + continue + } + i++ + break + } + b.WriteByte(s[i]) + i++ + } + toks = append(toks, token{tokIdent, b.String()}) + case isIdentStart(c): + j := i + 1 + for j < n && isIdentPart(s[j]) { + j++ + } + toks = append(toks, token{tokIdent, s[i:j]}) + i = j + case c >= '0' && c <= '9': + j := i + 1 + for j < n && (s[j] >= '0' && s[j] <= '9' || s[j] == '.') { + j++ + } + toks = append(toks, token{tokNum, s[i:j]}) + i = j + case c == '(' || c == ')' || c == ',' || c == ';' || c == '.': + toks = append(toks, token{tokSym, string(c)}) + i++ + default: // 연산자 문자 묶음 (=, <=, >=, != 등) + j := i + for j < n && isOpChar(s[j]) { + j++ + } + if j == i { + j = i + 1 // 미지 단일 문자 + } + toks = append(toks, token{tokSym, s[i:j]}) + i = j + } + } + return toks +} + +func isIdentStart(c byte) bool { + return c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' +} + +func isIdentPart(c byte) bool { + return isIdentStart(c) || c >= '0' && c <= '9' || c == '$' +} + +func isOpChar(c byte) bool { + switch c { + case '=', '<', '>', '!', '+', '-', '*', '/', '%', '~', '&', '|', '^': + return true + } + return false +} + +// whereEqTok 는 토큰열에서 ` = '리터럴'` 패턴을 찾는다 (복합 predicate 에서 지정 +// 컬럼만). 첫 매치를 반환. 우변이 문자열 리터럴이 아니면(파라미터/숫자/범위) 무시. +func whereEqTok(toks []token, col string) (string, bool) { + for i := 0; i+2 < len(toks); i++ { + if toks[i].kind == tokIdent && strings.EqualFold(toks[i].text, col) && + toks[i+1].kind == tokSym && toks[i+1].text == "=" && + toks[i+2].kind == tokStr { + if toks[i+2].text == "" { + return "", false + } + return toks[i+2].text, true + } + } + return "", false +} + +// insertValueTok 는 INSERT 의 컬럼 목록에서 col 위치를 찾아 같은 위치의 VALUES 리터럴을 +// 반환한다. 컬럼/값 개수가 다르거나 값이 단일 문자열 리터럴이 아니면 ("", false). +func insertValueTok(toks []token, col string) (string, bool) { + vi := indexOfKeyword(toks, "values") + if vi < 0 { + return "", false + } + colElems := parenSplit(toks, 0) // 첫 괄호 그룹 = 컬럼 목록 + valElems := parenSplit(toks, vi) // values 뒤 괄호 그룹 = 값 목록 + if colElems == nil || valElems == nil || len(colElems) != len(valElems) { + return "", false + } + for idx, ce := range colElems { + name := lastIdent(ce) + if name == "" || !strings.EqualFold(name, col) { + continue + } + ve := valElems[idx] + if len(ve) == 1 && ve[0].kind == tokStr && ve[0].text != "" { + return ve[0].text, true + } + return "", false + } + return "", false +} + +func indexOfKeyword(toks []token, kw string) int { + for i, t := range toks { + if t.kind == tokIdent && strings.EqualFold(t.text, kw) { + return i + } + } + return -1 +} + +// parenSplit 은 from 이후 첫 '(' 에서 시작하는 괄호 그룹을 top-level 콤마로 분할한 +// 각 요소(토큰열)를 반환한다. 균형 괄호가 없으면 nil. +func parenSplit(toks []token, from int) [][]token { + open := -1 + for i := from; i < len(toks); i++ { + if toks[i].kind == tokSym && toks[i].text == "(" { + open = i + break + } + } + if open < 0 { + return nil + } + var elems [][]token + var cur []token + depth := 0 + for i := open; i < len(toks); i++ { + t := toks[i] + if t.kind == tokSym && t.text == "(" { + depth++ + if depth == 1 { + continue // 바깥 여는 괄호 skip + } + } + if t.kind == tokSym && t.text == ")" { + depth-- + if depth == 0 { + elems = append(elems, cur) + return elems + } + } + if depth == 1 && t.kind == tokSym && t.text == "," { + elems = append(elems, cur) + cur = nil + continue + } + cur = append(cur, t) + } + return nil // 불균형 +} + +// lastIdent 는 요소 토큰열의 마지막 식별자 텍스트를 반환한다 (`t.col` → "col"). +func lastIdent(elem []token) string { + name := "" + for _, t := range elem { + if t.kind == tokIdent { + name = t.text + } + } + return name +} + +var _ RouteKeyExtractor = parserExtractor{} diff --git a/internal/router/route_extractor_parser_test.go b/internal/router/route_extractor_parser_test.go new file mode 100644 index 0000000..ef85c43 --- /dev/null +++ b/internal/router/route_extractor_parser_test.go @@ -0,0 +1,79 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package router + +import "testing" + +// TestParserExtractor 는 토크나이저 기반 추출이 라우팅 key subset 을 정확히 처리함을 +// 검증한다 (제로 의존성). +func TestParserExtractor(t *testing.T) { + const col = "tenant_id" + ex := parserExtractor{} + cases := []struct { + name string + query string + wantKey string + wantOK bool + }{ + {"select eq", "SELECT v FROM t WHERE tenant_id = 'alice'", "alice", true}, + {"multi predicate", "SELECT v FROM t WHERE a = 1 AND tenant_id = 'carol'", "carol", true}, + {"insert position", "INSERT INTO t (id, tenant_id, v) VALUES (1, 'dave', 9)", "dave", true}, + {"update where", "UPDATE t SET v = 2 WHERE tenant_id = 'erin'", "erin", true}, + {"delete where", "DELETE FROM t WHERE tenant_id='frank'", "frank", true}, + {"table-qualified col", "SELECT v FROM t WHERE t.tenant_id = 'gwen'", "gwen", true}, + {"wrong column", "SELECT v FROM t WHERE other = 'zoe'", "", false}, + {"no predicate", "SELECT v FROM t", "", false}, + {"parameterized", "SELECT v FROM t WHERE tenant_id = $1", "", false}, + {"range predicate", "SELECT v FROM t WHERE tenant_id > 'a'", "", false}, + {"empty literal", "SELECT v FROM t WHERE tenant_id = ''", "", false}, + {"empty col delegates", "SELECT v FROM t WHERE tenant_id = 'x'", "", false}, // col="" → regex 위임 + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + useCol := col + if tc.name == "empty col delegates" { + useCol = "" + } + k, ok := ex.ExtractRoutingKey(tc.query, useCol) + if k != tc.wantKey || ok != tc.wantOK { + t.Fatalf("ExtractRoutingKey(%q,%q) = (%q,%v), want (%q,%v)", + tc.query, useCol, k, ok, tc.wantKey, tc.wantOK) + } + }) + } +} + +// TestParserBeatsRegex 는 토큰화가 정규식이 *틀리는* 케이스를 정확히 처리함을 보인다 +// — 라우팅 키가 다른 컬럼의 문자열 리터럴 *안*에 등장하는 경우. 정규식 first-literal +// 모드는 오인할 수 있으나, 토크나이저는 문자열 내부를 식별자로 보지 않는다. +func TestParserBeatsRegex(t *testing.T) { + // note 컬럼 값 안에 "tenant_id = 'evil'" 문자열이 들어 있다. + q := "SELECT v FROM t WHERE note = 'tenant_id = evil' AND tenant_id = 'real'" + k, ok := parserExtractor{}.ExtractRoutingKey(q, "tenant_id") + if !ok || k != "real" { + t.Fatalf("parser = (%q,%v), want (real,true)", k, ok) + } + // 주석 안의 가짜 predicate 도 무시. + q2 := "SELECT v FROM t WHERE /* tenant_id = 'fake' */ tenant_id = 'genuine'" + k2, ok2 := parserExtractor{}.ExtractRoutingKey(q2, "tenant_id") + if !ok2 || k2 != "genuine" { + t.Fatalf("parser(comment) = (%q,%v), want (genuine,true)", k2, ok2) + } +} + +// TestParserSelectableViaFactory 는 "parser"/"auto" 선택이 토크나이저를 쓰는지 확인. +func TestParserSelectableViaFactory(t *testing.T) { + ex, err := NewRouteKeyExtractor(ExtractorParser) + if err != nil || ex.Name() != ExtractorParser { + t.Fatalf("parser strategy: ex=%v err=%v", ex, err) + } + auto, _ := NewRouteKeyExtractor(ExtractorAuto) + // 복합 predicate(정규식 컬럼 모드도 잡지만, auto 는 parser 를 primary 로 사용). + if k, ok := auto.ExtractRoutingKey("SELECT v FROM t WHERE x = 1 AND tenant_id = 'zed'", "tenant_id"); !ok || k != "zed" { + t.Fatalf("auto = (%q,%v), want (zed,true)", k, ok) + } +} diff --git a/internal/router/route_extractor_test.go b/internal/router/route_extractor_test.go new file mode 100644 index 0000000..94cdf4c --- /dev/null +++ b/internal/router/route_extractor_test.go @@ -0,0 +1,71 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package router + +import "testing" + +// TestRegexExtractor_ColumnMode 는 shardKeyColumn 지정 추출(WHERE/AND 등호 + +// INSERT 컬럼 위치 매칭)을 검증한다. +func TestRegexExtractor_ColumnMode(t *testing.T) { + const col = "tenant_id" + ex := regexExtractor{} + cases := []struct { + name string + query string + wantKey string + wantOK bool + }{ + {"where eq", "SELECT v FROM t WHERE tenant_id = 'alice'", "alice", true}, + {"where multi-predicate", "SELECT v FROM t WHERE a = 1 AND tenant_id = 'carol'", "carol", true}, + {"where reordered", "SELECT v FROM t WHERE tenant_id='bob' AND x=2", "bob", true}, + {"insert position", "INSERT INTO t (id, tenant_id, v) VALUES (1, 'dave', 9)", "dave", true}, + {"insert first col", "INSERT INTO t (tenant_id, v) VALUES ('eve', 1)", "eve", true}, + {"wrong column only", "SELECT v FROM t WHERE other_id = 'zoe'", "", false}, + {"no predicate", "SELECT v FROM t", "", false}, + {"empty literal", "SELECT v FROM t WHERE tenant_id = ''", "", false}, + {"range predicate", "SELECT v FROM t WHERE tenant_id > 'a'", "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + key, ok := ex.ExtractRoutingKey(tc.query, col) + if ok != tc.wantOK || key != tc.wantKey { + t.Fatalf("ExtractRoutingKey(%q, %q) = (%q,%v), want (%q,%v)", + tc.query, col, key, ok, tc.wantKey, tc.wantOK) + } + }) + } +} + +// TestNewRouteKeyExtractor 는 전략 선택기와 빈/오류 이름 처리를 검증한다. +func TestNewRouteKeyExtractor(t *testing.T) { + // regex 는 어느 빌드에서나 사용 가능. + if ex, err := NewRouteKeyExtractor(ExtractorRegex); err != nil || ex.Name() != ExtractorRegex { + t.Fatalf("regex: ex=%v err=%v", ex, err) + } + // 빈 이름 → 기본 전략(auto). 미컴파일 빌드에서도 auto 는 항상 생성 가능(regex 강등). + if ex, err := NewRouteKeyExtractor(""); err != nil || ex.Name() != DefaultExtractorName { + t.Fatalf("default: ex=%v err=%v", ex, err) + } + // 알 수 없는 이름 → error. + if _, err := NewRouteKeyExtractor("nope"); err == nil { + t.Fatal("unknown extractor: expected error, got nil") + } +} + +// TestAutoExtractor_FallsBackToRegex 는 parser 미컴파일(또는 미매치) 시 auto 가 +// regex 로 라우팅함을 검증한다. (sqlparser 태그 빌드에서는 parser 가 primary 로 +// 먼저 매치하며, 그 경우에도 동일 결과여야 한다.) +func TestAutoExtractor_FallsBackToRegex(t *testing.T) { + ex, err := NewRouteKeyExtractor(ExtractorAuto) + if err != nil { + t.Fatalf("auto: err=%v", err) + } + key, ok := ex.ExtractRoutingKey("SELECT v FROM t WHERE tenant_id = 'alice'", "tenant_id") + if !ok || key != "alice" { + t.Fatalf("auto extract = (%q,%v), want (alice,true)", key, ok) + } +} diff --git a/internal/router/sql_route.go b/internal/router/sql_route.go index cef3fa4..cf4f03f 100644 --- a/internal/router/sql_route.go +++ b/internal/router/sql_route.go @@ -1,32 +1,105 @@ -// Package router — sql_route.go 는 SQL query 에서 라우팅 key 를 추출하여 -// single-shard fast-path(scatter-gather broadcast 회피)를 가능케 한다. -// -// pg-router 는 connection-oriented proxy 라 connection 단위로 backend 가 고정된다. -// query 단위 라우팅(키가 단일 shard 에 매핑되는 point query 를 그 shard 로만 보내고 -// 나머지 N-1 shard 를 건드리지 않는 것)은 query 에서 routing key 를 뽑아 vindex -// (ResolveShard)로 shard 를 결정하는 것으로 구현한다. key 를 못 뽑으면 caller 는 -// scatter-gather(전 shard fan-out)로 fallback 한다. -// -// 본 PoC 는 *경량 토큰 추출*이다 — full PostgreSQL 파서(pg_query_go = libpg_query -// CGO)는 CGO_ENABLED=0 distroless 빌드와 충돌하므로, point-query 의 흔한 형태 -// (INSERT ... VALUES('key',...) / SELECT ... WHERE col='key')만 정규식으로 잡는다. -// 복합 predicate / prepared statement / parameterized query 의 완전 파싱은 후속 -// (pure-Go 파서 선정 선행). +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +// Package router — sql_route.go 는 RouteKeyExtractor 의 *정규식(regex) 구현*이다 +// (경량·제로 의존성 전략). full PostgreSQL 파서(pg_query_go = libpg_query CGO)는 +// CGO_ENABLED=0 distroless 빌드와 충돌하므로, point-query 의 흔한 형태만 토큰 +// 추출로 잡는다. 정확한 전략(prepared/복합 predicate/JOIN)은 parserExtractor +// (route_extractor_parser.go) 가 담당하고, auto 가 둘을 조합한다. package router -import "regexp" +import ( + "regexp" + "strings" +) -// routeKeyPattern 은 point query 의 첫 single-quoted literal 을 routing key 후보로 -// 잡는다: `VALUES ('key'` 또는 `WHERE = 'key'` (대소문자 무시). -var routeKeyPattern = regexp.MustCompile(`(?i)(?:VALUES\s*\(\s*|WHERE\s+[a-z_][a-z0-9_]*\s*=\s*)'([^']*)'`) +// legacyRouteKeyPattern 은 shardKeyColumn 미지정(first-literal) 모드의 패턴이다: +// `VALUES ('key'` 또는 `WHERE = 'key'` 의 첫 single-quoted 리터럴. +var legacyRouteKeyPattern = regexp.MustCompile(`(?i)(?:VALUES\s*\(\s*|WHERE\s+[a-z_][a-z0-9_]*\s*=\s*)'([^']*)'`) -// ExtractRoutingKey 는 query 에서 single-shard 라우팅 key 를 추출한다. point query -// (VALUES / WHERE 등호)면 (key, true), 아니면 ("", false) — caller 는 false 시 -// scatter-gather 로 fallback 한다. +// ExtractRoutingKey 는 query 에서 single-shard 라우팅 key 를 first-literal 모드로 +// 추출하는 패키지 편의 함수다 (backward-compat: shardKeyColumn 미지정). 컬럼 지정 +// 추출과 전략 선택은 RouteKeyExtractor / NewRouteKeyExtractor 를 사용한다. func ExtractRoutingKey(query string) (string, bool) { - m := routeKeyPattern.FindStringSubmatch(query) + return regexExtractor{}.ExtractRoutingKey(query, "") +} + +// regexExtractor 는 RouteKeyExtractor 의 정규식 구현이다 (경량 전략). +type regexExtractor struct{} + +func (regexExtractor) Name() string { return ExtractorRegex } + +// ExtractRoutingKey 는 col 이 빈 문자열이면 first-literal 모드, 아니면 그 컬럼을 +// 지정 추출한다 (WHERE/AND ` = 'x'` 또는 INSERT 컬럼-값 위치 매칭). +func (regexExtractor) ExtractRoutingKey(query, col string) (string, bool) { + if col == "" { + m := legacyRouteKeyPattern.FindStringSubmatch(query) + if len(m) < 2 || m[1] == "" { + return "", false + } + return m[1], true + } + if k, ok := matchColumnEquals(query, col); ok { + return k, true + } + return matchInsertColumn(query, col) +} + +// matchColumnEquals 는 query 어디에서든 ` = 'literal'` (대소문자 무시)을 잡는다. +// 복합 predicate (`WHERE a=1 AND tenant_id='t9'`) 에서도 지정 컬럼만 추출한다. +func matchColumnEquals(query, col string) (string, bool) { + pat := regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(col) + `\s*=\s*'([^']*)'`) + m := pat.FindStringSubmatch(query) if len(m) < 2 || m[1] == "" { return "", false } return m[1], true } + +// insertPattern 은 `INSERT INTO t (c1, c2, ...) VALUES (v1, v2, ...)` 의 컬럼 목록과 +// 첫 VALUES 튜플을 잡는다. +var insertPattern = regexp.MustCompile(`(?is)INSERT\s+INTO\s+[^\s(]+\s*\(([^)]*)\)\s*VALUES\s*\(([^)]*)\)`) + +// matchInsertColumn 은 INSERT 의 컬럼 목록에서 col 의 위치를 찾아 같은 위치의 VALUES +// 리터럴을 반환한다. col 이 목록에 없거나 값이 따옴표 리터럴이 아니면 (",", false). +func matchInsertColumn(query, col string) (string, bool) { + m := insertPattern.FindStringSubmatch(query) + if len(m) < 3 { + return "", false + } + cols := splitCSV(m[1]) + vals := splitCSV(m[2]) + if len(cols) != len(vals) { + return "", false + } + for i, c := range cols { + if strings.EqualFold(c, col) { + v := strings.TrimSpace(vals[i]) + if len(v) >= 2 && v[0] == '\'' && v[len(v)-1] == '\'' { + inner := v[1 : len(v)-1] + if inner == "" { + return "", false + } + return inner, true + } + return "", false + } + } + return "", false +} + +// splitCSV 는 comma 구분 목록을 trim 하여 분리한다 (단순 — 중첩 함수/콤마 미지원, +// regex 전략의 best-effort 한계). +func splitCSV(s string) []string { + parts := strings.Split(s, ",") + out := make([]string, len(parts)) + for i, p := range parts { + out[i] = strings.TrimSpace(p) + } + return out +} + +var _ RouteKeyExtractor = regexExtractor{} From d60e7d83a94fc237cd45e22c63b428699475f5c8 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Fri, 26 Jun 2026 13:33:47 +0900 Subject: [PATCH 015/109] =?UTF-8?q?feat(router):=20CRD=20=ED=86=A0?= =?UTF-8?q?=ED=8F=B4=EB=A1=9C=EC=A7=80=20=EC=86=8C=EC=8B=B1=20+=20failover?= =?UTF-8?q?-aware=20=EB=B0=B1=EC=97=94=EB=93=9C=20+=20=EC=83=A4=EB=93=9C?= =?UTF-8?q?=20=EC=9E=A5=EC=95=A0=20=EC=9A=B0=EC=95=84=ED=95=9C=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit key→shard(TopologyProvider)와 shard→backend(BackendResolver)를 분리(교체 가능). StaticTopologyProvider(env)·CRDTopologyProvider(ShardRange CRD watch, hot-reload). StatusBackendResolver는 PostgresCluster.status.shards[].primary.endpoint(Ready만)에서 백엔드 해소 → operator failover가 status 갱신하면 라우터가 자동 추종; primary 부재/not-ready는 에러. K8s는 ShardRangeLister/ClusterStatusReader 인터페이스로 가장자리 격리(router 패키지 순수 유지). pg-router: PGROUTER_TOPOLOGY=static|crd, PGROUTER_BACKEND=env|template|status, dial 타임아웃, 샤드 down 시 조용한 drop 대신 PostgreSQL ErrorResponse로 우아하게 실패. 단위 검증(failover 시뮬·헬스 게이팅·에러 포맷, 라이브 클러스터 불필요). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- cmd/pg-router/main.go | 263 ++++++++++++++++++++++++++++--- cmd/pg-router/main_test.go | 57 +++++++ internal/router/topology.go | 167 ++++++++++++++++++++ internal/router/topology_test.go | 127 +++++++++++++++ 4 files changed, 595 insertions(+), 19 deletions(-) create mode 100644 internal/router/topology.go create mode 100644 internal/router/topology_test.go diff --git a/cmd/pg-router/main.go b/cmd/pg-router/main.go index 9587d1e..fb5a40a 100644 --- a/cmd/pg-router/main.go +++ b/cmd/pg-router/main.go @@ -11,15 +11,28 @@ Licensed under the MIT License. See the LICENSE file for details. // longer dead (ROADMAP G3 / RFC 0004). // // Scope (PoC): single-shard fast-path. The routing key is taken from the startup -// "database" (else "user") parameter; the connection is then proxied to the -// resolved shard backend. Full SQL-parse routing and multi-shard scatter-gather -// forwarding (via internal/router.ScatterGather) are future work (G5). +// "database" (else "user") parameter; the connection is proxied to the resolved +// shard backend. Full SQL-parse routing and multi-shard scatter-gather forwarding +// are future work (see docs/sharding/ROUTER-GAP-ANALYSIS.ko.md). // -// Config (env): PGROUTER_LISTEN (default :5432), PGROUTER_CLUSTER, -// PGROUTER_BACKEND_SHARD_0 / PGROUTER_BACKEND_SHARD_1 (host:port). +// Two pluggable, swappable concerns: +// - Topology (key -> shard): PGROUTER_TOPOLOGY=static|crd. +// - Backend (shard -> addr): PGROUTER_BACKEND=env|template|status. +// - status = read the *current Ready primary* from PostgresCluster.status +// (failover-aware): when a shard primary dies, the operator promotes a +// replica and updates status; the router follows. A shard with no Ready +// primary yields a graceful PostgreSQL ErrorResponse to the client (no hang, +// no silent drop). +// +// Config (env): PGROUTER_LISTEN (:5432), PGROUTER_TOPOLOGY, PGROUTER_BACKEND, +// PGROUTER_CLUSTER, PGROUTER_KEYSPACE (default), PGROUTER_NAMESPACE (default), +// PGROUTER_REFRESH (10s), PGROUTER_DIAL_TIMEOUT (5s), +// PGROUTER_BACKEND_TEMPLATE ({cluster}/{shard}/{namespace}), +// PGROUTER_BACKEND_SHARD_0 / _1 (env mode host:port). package main import ( + "context" "encoding/binary" "fmt" "io" @@ -27,32 +40,173 @@ import ( "net" "os" "strings" + "time" + + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + ctrlconfig "sigs.k8s.io/controller-runtime/pkg/client/config" "github.com/keiailab/postgres-operator/api/v1alpha1" "github.com/keiailab/postgres-operator/internal/router" ) func main() { + ctx := context.Background() addr := env("PGROUTER_LISTEN", ":5432") + provider, resolve, err := buildRouting(ctx) + if err != nil { + log.Fatalf("pg-router: build routing: %v", err) + } ln, err := net.Listen("tcp", addr) if err != nil { log.Fatalf("pg-router: listen %s: %v", addr, err) } - log.Printf("pg-router PoC listening on %s", addr) + log.Printf("pg-router PoC listening on %s (topology=%s backend=%s)", + addr, env("PGROUTER_TOPOLOGY", "static"), env("PGROUTER_BACKEND", "env")) for { conn, err := ln.Accept() if err != nil { log.Printf("pg-router: accept: %v", err) continue } - go handleConn(conn) + go handleConn(conn, provider, resolve) + } +} + +// buildRouting wires the (pluggable) topology provider and backend resolver, and +// starts a refresh loop for any dynamic source. +func buildRouting(ctx context.Context) (router.TopologyProvider, router.BackendResolver, error) { + topoMode := strings.ToLower(env("PGROUTER_TOPOLOGY", "static")) + backendMode := strings.ToLower(env("PGROUTER_BACKEND", "env")) + ns := env("PGROUTER_NAMESPACE", "default") + cluster := env("PGROUTER_CLUSTER", "quickstart") + keyspace := env("PGROUTER_KEYSPACE", "default") + + var k8s client.Client + if topoMode == "crd" || backendMode == "status" { + c, err := newK8sClient() + if err != nil { + return nil, nil, err + } + k8s = c + } + + // Topology provider (key -> shard). + var provider router.TopologyProvider + var crdProvider *router.CRDTopologyProvider + switch topoMode { + case "", "static": + provider = router.StaticTopologyProvider{T: router.Topology{Cluster: cluster, Keyspace: keyspace, Spec: shardSpec()}} + case "crd": + crdProvider = &router.CRDTopologyProvider{Lister: clientLister{c: k8s}, Namespace: ns, Cluster: cluster, Keyspace: keyspace} + if _, err := crdProvider.Refresh(ctx); err != nil { + log.Printf("pg-router: initial topology refresh: %v (will retry)", err) + } + provider = crdProvider + default: + return nil, nil, fmt.Errorf("unknown PGROUTER_TOPOLOGY %q (want static|crd)", topoMode) + } + + // Backend resolver (shard -> addr). + var resolve router.BackendResolver + var statusRes *router.StatusBackendResolver + var statusReader router.ClusterStatusReader + switch backendMode { + case "", "env": + resolve = envBackendResolver + case "template": + resolve = templateResolver() + case "status": + statusRes = router.NewStatusBackendResolver() + statusReader = clusterStatusReader{c: k8s} + if err := updateStatus(ctx, statusReader, statusRes, ns, cluster); err != nil { + log.Printf("pg-router: initial status read: %v (will retry)", err) + } + resolve = statusRes.Resolve + default: + return nil, nil, fmt.Errorf("unknown PGROUTER_BACKEND %q (want env|template|status)", backendMode) + } + + if crdProvider != nil || statusRes != nil { + go refreshLoop(ctx, crdProvider, statusReader, statusRes, ns, cluster) + } + return provider, resolve, nil +} + +// newK8sClient builds a controller-runtime client with the operator scheme. +func newK8sClient() (client.Client, error) { + scheme := runtime.NewScheme() + if err := v1alpha1.AddToScheme(scheme); err != nil { + return nil, fmt.Errorf("scheme: %w", err) + } + cfg, err := ctrlconfig.GetConfig() + if err != nil { + return nil, fmt.Errorf("k8s config: %w", err) + } + return client.New(cfg, client.Options{Scheme: scheme}) +} + +// refreshLoop re-reads dynamic sources on PGROUTER_REFRESH interval (hot-reload): +// the ShardRange topology and/or the PostgresCluster primary-endpoint status. +func refreshLoop(ctx context.Context, cp *router.CRDTopologyProvider, reader router.ClusterStatusReader, res *router.StatusBackendResolver, ns, cluster string) { + t := time.NewTicker(envDuration("PGROUTER_REFRESH", 10*time.Second)) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if cp != nil { + if _, err := cp.Refresh(ctx); err != nil { + log.Printf("pg-router: topology refresh: %v", err) + } + } + if res != nil && reader != nil { + if err := updateStatus(ctx, reader, res, ns, cluster); err != nil { + log.Printf("pg-router: status refresh: %v", err) + } + } + } + } +} + +// updateStatus reads the cluster's per-shard status and updates the failover-aware +// backend resolver with the current Ready primary endpoints. +func updateStatus(ctx context.Context, reader router.ClusterStatusReader, res *router.StatusBackendResolver, ns, cluster string) error { + shards, err := reader.ClusterShardStatus(ctx, ns, cluster) + if err != nil { + return err + } + res.Update(shards) + return nil +} + +// clientLister reads ShardRange via controller-runtime (K8s isolated at the edge). +type clientLister struct{ c client.Client } + +func (l clientLister) ListShardRanges(ctx context.Context, ns string) ([]v1alpha1.ShardRange, error) { + var list v1alpha1.ShardRangeList + if err := l.c.List(ctx, &list, client.InNamespace(ns)); err != nil { + return nil, err } + return list.Items, nil } -func handleConn(client net.Conn) { - defer func() { _ = client.Close() }() +// clusterStatusReader reads PostgresCluster.status.shards via controller-runtime. +type clusterStatusReader struct{ c client.Client } - raw, params, err := readStartup(client) +func (r clusterStatusReader) ClusterShardStatus(ctx context.Context, ns, cluster string) ([]v1alpha1.ShardStatus, error) { + var pc v1alpha1.PostgresCluster + if err := r.c.Get(ctx, client.ObjectKey{Namespace: ns, Name: cluster}, &pc); err != nil { + return nil, err + } + return pc.Status.Shards, nil +} + +func handleConn(clientConn net.Conn, provider router.TopologyProvider, resolve router.BackendResolver) { + defer func() { _ = clientConn.Close() }() + + raw, params, err := readStartup(clientConn) if err != nil { log.Printf("pg-router: startup read: %v", err) return @@ -61,15 +215,29 @@ func handleConn(client net.Conn) { if key == "" { key = params["user"] } - shardID, err := router.ResolveShard(shardSpec(), key) + topo, err := provider.Current(context.Background()) + if err != nil { + log.Printf("pg-router: topology unavailable: %v", err) + writePgError(clientConn, "08006", "router topology unavailable: "+err.Error()) + return + } + shardID, err := topo.Shard(key) + if err != nil { + log.Printf("pg-router: resolve key %q: %v", key, err) + writePgError(clientConn, "08006", fmt.Sprintf("no shard for key %q: %v", key, err)) + return + } + backend, err := resolve(shardID) if err != nil { - log.Printf("pg-router: resolve shard for key %q: %v", key, err) + // Shard down / mid-failover: fail the client gracefully (no silent drop). + log.Printf("pg-router: backend for shard %s: %v", shardID, err) + writePgError(clientConn, "08006", fmt.Sprintf("shard %s unavailable: %v", shardID, err)) return } - backend := backendFor(shardID) - server, err := net.Dial("tcp", backend) + server, err := net.DialTimeout("tcp", backend, envDuration("PGROUTER_DIAL_TIMEOUT", 5*time.Second)) if err != nil { log.Printf("pg-router: dial backend %s (shard %s): %v", backend, shardID, err) + writePgError(clientConn, "08006", fmt.Sprintf("cannot reach shard %s (%s): %v", shardID, backend, err)) return } defer func() { _ = server.Close() }() @@ -81,11 +249,34 @@ func handleConn(client net.Conn) { return } done := make(chan struct{}, 2) - go func() { _, _ = io.Copy(server, client); done <- struct{}{} }() - go func() { _, _ = io.Copy(client, server); done <- struct{}{} }() + go func() { _, _ = io.Copy(server, clientConn); done <- struct{}{} }() + go func() { _, _ = io.Copy(clientConn, server); done <- struct{}{} }() <-done } +// writePgError sends a PostgreSQL v3 ErrorResponse ('E') so the client sees a clear +// failure (e.g. shard unavailable) instead of a silently dropped connection. Valid +// as a server's response to a StartupMessage. +func writePgError(conn net.Conn, code, msg string) { + var f []byte + add := func(t byte, s string) { + f = append(f, t) + f = append(f, s...) + f = append(f, 0) + } + add('S', "ERROR") + add('V', "ERROR") + add('C', code) // SQLSTATE (08006 = connection_failure) + add('M', msg) + f = append(f, 0) // field terminator + out := make([]byte, 5+len(f)) + out[0] = 'E' + binary.BigEndian.PutUint32(out[1:5], uint32(4+len(f))) + copy(out[5:], f) + _ = conn.SetWriteDeadline(time.Now().Add(2 * time.Second)) + _, _ = conn.Write(out) +} + // readStartup reads a PostgreSQL v3 startup message and returns its raw bytes // (for forwarding) plus the parsed parameters. func readStartup(conn net.Conn) ([]byte, map[string]string, error) { @@ -124,12 +315,12 @@ func readStartup(conn net.Conn) ([]byte, map[string]string, error) { return raw, params, nil } -// shardSpec is the PoC routing table (a 2-shard hash vindex). In production this -// is sourced from ShardRange CRDs reconciled by the operator. +// shardSpec is the PoC static routing table (a 2-shard hash vindex). With +// PGROUTER_TOPOLOGY=crd this is replaced by the live ShardRange CRD. func shardSpec() v1alpha1.ShardRangeSpec { return v1alpha1.ShardRangeSpec{ Cluster: env("PGROUTER_CLUSTER", "quickstart"), - Keyspace: "default", + Keyspace: env("PGROUTER_KEYSPACE", "default"), Vindex: v1alpha1.VindexSpec{Type: v1alpha1.VindexTypeHash, Column: "id", Function: "murmur3"}, Ranges: []v1alpha1.ShardRangeEntry{ {Lo: "0x00000000", Hi: "0x7fffffff", Shard: "shard-0"}, @@ -138,6 +329,26 @@ func shardSpec() v1alpha1.ShardRangeSpec { } } +// templateResolver maps a shard to a backend via a DNS template +// (PGROUTER_BACKEND_TEMPLATE) with {cluster}/{shard}/{namespace} substitution — +// no per-shard env needed. +func templateResolver() router.BackendResolver { + tmpl := env("PGROUTER_BACKEND_TEMPLATE", + "{cluster}-{shard}-0.{cluster}-{shard}-headless.{namespace}.svc.cluster.local:5432") + base := strings.NewReplacer( + "{cluster}", env("PGROUTER_CLUSTER", "quickstart"), + "{namespace}", env("PGROUTER_NAMESPACE", "default"), + ).Replace(tmpl) + return func(shardID string) (string, error) { + return strings.NewReplacer("{shard}", shardID).Replace(base), nil + } +} + +// envBackendResolver maps a shard ID to its backend via PGROUTER_BACKEND_. +func envBackendResolver(shardID string) (string, error) { + return backendFor(shardID), nil +} + func backendFor(shardID string) string { envKey := "PGROUTER_BACKEND_" + strings.ToUpper(strings.ReplaceAll(shardID, "-", "_")) return env(envKey, "127.0.0.1:5432") @@ -149,3 +360,17 @@ func env(k, def string) string { } return def } + +// envDuration parses a duration env var, falling back to def on absence/parse error. +func envDuration(k string, def time.Duration) time.Duration { + v := os.Getenv(k) + if v == "" { + return def + } + d, err := time.ParseDuration(v) + if err != nil { + log.Printf("pg-router: invalid %s=%q, using %s", k, v, def) + return def + } + return d +} diff --git a/cmd/pg-router/main_test.go b/cmd/pg-router/main_test.go index 3f8ee94..0a6fe9d 100644 --- a/cmd/pg-router/main_test.go +++ b/cmd/pg-router/main_test.go @@ -10,6 +10,7 @@ import ( "encoding/binary" "io" "net" + "strings" "testing" "time" @@ -77,6 +78,62 @@ func TestBackendForUsesEnvMapping(t *testing.T) { } } +// TestTemplateResolver 는 DNS 템플릿 resolver 가 {cluster}/{shard}/{namespace} +// 를 치환해 per-shard env 없이 backend 를 만든다. +func TestTemplateResolver(t *testing.T) { + t.Setenv("PGROUTER_BACKEND_TEMPLATE", "{cluster}-{shard}-0.{cluster}-{shard}-headless.{namespace}.svc.cluster.local:5432") + t.Setenv("PGROUTER_CLUSTER", "demo") + t.Setenv("PGROUTER_NAMESPACE", "prod") + got, err := templateResolver()("shard-1") + want := "demo-shard-1-0.demo-shard-1-headless.prod.svc.cluster.local:5432" + if err != nil || got != want { + t.Fatalf("template resolver = (%q,%v), want %q", got, err, want) + } +} + +// TestEnvBackendResolver 는 env 매핑 resolver 를 검증. +func TestEnvBackendResolver(t *testing.T) { + t.Setenv("PGROUTER_BACKEND_SHARD_2", "1.2.3.4:5432") + got, err := envBackendResolver("shard-2") + if err != nil || got != "1.2.3.4:5432" { + t.Fatalf("env resolver = (%q,%v), want 1.2.3.4:5432", got, err) + } +} + +// TestWritePgError 는 우아한 실패가 유효한 PostgreSQL ErrorResponse('E')로 인코딩됨을 +// 검증한다 (샤드 down 시 조용한 drop 대신 클라이언트가 사유를 받는다). +func TestWritePgError(t *testing.T) { + c1, c2 := net.Pipe() + defer func() { _ = c1.Close() }() + go func() { + writePgError(c2, "08006", "shard shard-0 unavailable") + _ = c2.Close() + }() + + _ = c1.SetReadDeadline(time.Now().Add(2 * time.Second)) + hdr := make([]byte, 5) + if _, err := io.ReadFull(c1, hdr); err != nil { + t.Fatalf("read header: %v", err) + } + if hdr[0] != 'E' { + t.Fatalf("type = %q, want 'E'", hdr[0]) + } + length := binary.BigEndian.Uint32(hdr[1:5]) + body := make([]byte, length-4) + if _, err := io.ReadFull(c1, body); err != nil { + t.Fatalf("read body: %v", err) + } + s := string(body) + for _, want := range []string{"ERROR", "08006", "shard shard-0 unavailable"} { + if !strings.Contains(s, want) { + t.Fatalf("ErrorResponse missing %q in %q", want, s) + } + } + if body[len(body)-1] != 0 { + t.Fatalf("ErrorResponse must end with NUL terminator") + } +} + // TestReadStartupHandlesSSLRequest pins the live-found bug: a real psql client // sends SSLRequest before the StartupMessage; readStartup must decline ('N') and // parse the StartupMessage that follows (else params are empty → all-shard-0). diff --git a/internal/router/topology.go b/internal/router/topology.go new file mode 100644 index 0000000..f432fad --- /dev/null +++ b/internal/router/topology.go @@ -0,0 +1,167 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +// Package router — topology.go 는 라우터의 *라우팅 상태*를 두 개의 분리된, 교체 +// 가능한 관심사로 공급한다: +// +// 1. key → shard : TopologyProvider (vindex spec). 어떤 키가 어떤 shard 인지. +// 2. shard → backend : BackendResolver. 그 shard 의 *현재 연결 주소*가 무엇인지. +// +// 이 둘을 분리하는 이유: backend 주소는 *장애/failover 에 따라 변한다*. 샤드 primary +// 가 죽으면 operator 가 replica 를 승격하고 PostgresCluster.status 의 primary +// endpoint 를 갱신한다. 따라서 backend 해소를 토폴로지(정적 key 매핑)와 분리해야 +// failover-aware resolver(StatusBackendResolver)를 독립적으로 끼울 수 있다. +// +// K8s 의존은 ShardRangeLister / ClusterStatusReader 인터페이스로 가장자리에 격리한다 +// — 본 패키지는 controller-runtime 을 import 하지 않는다 (호출 binary 가 주입). +package router + +import ( + "context" + "fmt" + "sync" + + "github.com/keiailab/postgres-operator/api/v1alpha1" +) + +// Topology 는 key → shard 매핑(vindex spec)이다. backend 주소는 BackendResolver 가 +// 별도로 해소한다. +type Topology struct { + Cluster string + Keyspace string + Spec v1alpha1.ShardRangeSpec +} + +// Shard 는 라우팅 키를 vindex 로 평가해 shard 이름을 반환한다. +func (t Topology) Shard(key string) (string, error) { + return ResolveShard(t.Spec, key) +} + +// BackendResolver 는 shard 이름 → backend "host:port" 를 해소한다. error 는 그 shard +// 에 *쓸 수 있는 backend 가 없음*(down / failover 중)을 뜻하며, 라우터는 hang 대신 +// 클라이언트에 우아한 에러를 돌려준다. +type BackendResolver func(shardID string) (string, error) + +// TopologyProvider 는 현재 key → shard Topology 를 공급한다 (동시성 안전). +type TopologyProvider interface { + Current(ctx context.Context) (Topology, error) +} + +// StaticTopologyProvider 는 고정 Topology 를 공급한다 (PoC·테스트·부트스트랩). +type StaticTopologyProvider struct{ T Topology } + +// Current 는 고정 Topology 를 반환한다. +func (s StaticTopologyProvider) Current(context.Context) (Topology, error) { return s.T, nil } + +// ShardRangeLister 는 ShardRange 읽기를 추상화한다 (K8s 가장자리 격리 + 테스트 fake). +type ShardRangeLister interface { + ListShardRanges(ctx context.Context, namespace string) ([]v1alpha1.ShardRange, error) +} + +// CRDTopologyProvider 는 ShardRange CRD 에서 Topology 를 구성한다. 최근 스냅샷을 +// 캐시하고 Refresh 로 갱신한다 (호출 binary 가 주기적으로 또는 watch 로 호출). +type CRDTopologyProvider struct { + Lister ShardRangeLister + Namespace string + Cluster string + Keyspace string + + mu sync.RWMutex + cached Topology + loaded bool +} + +// Current 는 캐시된 Topology 를 반환한다. 아직 로드 전이면 1회 Refresh 한다. +func (p *CRDTopologyProvider) Current(ctx context.Context) (Topology, error) { + p.mu.RLock() + if p.loaded { + t := p.cached + p.mu.RUnlock() + return t, nil + } + p.mu.RUnlock() + return p.Refresh(ctx) +} + +// Refresh 는 ShardRange 를 다시 읽어 cluster+keyspace 매칭 항목으로 Topology 를 +// 재구성하고 캐시를 교체한다 (hot-reload). 매칭 항목이 없으면 에러 — 캐시는 보존. +func (p *CRDTopologyProvider) Refresh(ctx context.Context) (Topology, error) { + items, err := p.Lister.ListShardRanges(ctx, p.Namespace) + if err != nil { + return Topology{}, fmt.Errorf("router: list ShardRange: %w", err) + } + for i := range items { + sp := items[i].Spec + if sp.Cluster != p.Cluster || sp.Keyspace != p.Keyspace { + continue + } + t := Topology{Cluster: sp.Cluster, Keyspace: sp.Keyspace, Spec: sp} + p.mu.Lock() + p.cached, p.loaded = t, true + p.mu.Unlock() + return t, nil + } + return Topology{}, fmt.Errorf( + "router: no ShardRange for cluster=%q keyspace=%q in namespace=%q", + p.Cluster, p.Keyspace, p.Namespace) +} + +// --- failover-aware backend 해소 --- + +// ClusterStatusReader 는 PostgresCluster 의 샤드별 status 읽기를 추상화한다 (K8s +// 가장자리 격리 + 테스트 fake). operator 가 failover 시 갱신하는 *현재 primary +// endpoint* 의 출처. +type ClusterStatusReader interface { + ClusterShardStatus(ctx context.Context, namespace, cluster string) ([]v1alpha1.ShardStatus, error) +} + +// StatusBackendResolver 는 shard 를 그 *현재 Ready primary* endpoint 로 매핑한다 — +// PostgresCluster.status 의 캐시 스냅샷 기반(failover-aware). Update 는 refresh 루프가, +// Resolve 는 연결마다 호출한다. Ready primary 가 없는 shard(down / failover 중)는 +// error 를 내어 라우터가 클라이언트를 우아하게 실패시키게 한다. +type StatusBackendResolver struct { + mu sync.RWMutex + primaries map[string]string // shard 이름 → primary endpoint (Ready 만) +} + +// NewStatusBackendResolver 는 빈 resolver 를 만든다. Update 전에는 모든 Resolve 가 +// 에러(아직 status 미수신). +func NewStatusBackendResolver() *StatusBackendResolver { + return &StatusBackendResolver{primaries: map[string]string{}} +} + +// Update 는 샤드 status 목록으로 primary 매핑을 통째로 교체한다 (Ready primary 만 +// 포함). failover 후 새 status 가 들어오면 자동으로 새 primary 를 가리키게 된다. +func (r *StatusBackendResolver) Update(shards []v1alpha1.ShardStatus) { + m := make(map[string]string, len(shards)) + for i := range shards { + s := shards[i] + if s.Primary != nil && s.Primary.Ready && s.Primary.Endpoint != "" { + m[s.Name] = s.Primary.Endpoint + } + } + r.mu.Lock() + r.primaries = m + r.mu.Unlock() +} + +// Resolve 는 shard 의 현재 Ready primary endpoint 를 반환한다. 없으면 error +// (down / failover 중 / 아직 status 미수신). +func (r *StatusBackendResolver) Resolve(shardID string) (string, error) { + r.mu.RLock() + ep, ok := r.primaries[shardID] + r.mu.RUnlock() + if !ok || ep == "" { + return "", fmt.Errorf("router: shard %q has no Ready primary (down or mid-failover)", shardID) + } + return ep, nil +} + +var ( + _ TopologyProvider = StaticTopologyProvider{} + _ TopologyProvider = (*CRDTopologyProvider)(nil) + _ BackendResolver = (*StatusBackendResolver)(nil).Resolve +) diff --git a/internal/router/topology_test.go b/internal/router/topology_test.go new file mode 100644 index 0000000..61c3e82 --- /dev/null +++ b/internal/router/topology_test.go @@ -0,0 +1,127 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package router + +import ( + "context" + "errors" + "testing" + + "github.com/keiailab/postgres-operator/api/v1alpha1" +) + +// fakeLister 는 ShardRangeLister 의 테스트 더블이다. +type fakeLister struct { + items []v1alpha1.ShardRange + err error +} + +func (f fakeLister) ListShardRanges(context.Context, string) ([]v1alpha1.ShardRange, error) { + return f.items, f.err +} + +func twoShardSpec() v1alpha1.ShardRangeSpec { + return v1alpha1.ShardRangeSpec{ + Cluster: "demo", + Keyspace: "default", + Vindex: v1alpha1.VindexSpec{Type: v1alpha1.VindexTypeHash, Column: "id", Function: "murmur3"}, + Ranges: []v1alpha1.ShardRangeEntry{ + {Lo: "0x00000000", Hi: "0x7fffffff", Shard: "shard-0"}, + {Lo: "0x80000000", Hi: "0xffffffff", Shard: "shard-1"}, + }, + } +} + +func TestTopologyShard(t *testing.T) { + topo := Topology{Cluster: "demo", Keyspace: "default", Spec: twoShardSpec()} + for _, key := range []string{"alice", "bob", "carol", "dave", "eve"} { + shard, err := topo.Shard(key) + if err != nil { + t.Fatalf("Shard(%q): %v", key, err) + } + if shard != "shard-0" && shard != "shard-1" { + t.Fatalf("Shard(%q) = %q, unexpected", key, shard) + } + } +} + +func TestCRDTopologyProvider(t *testing.T) { + sr := v1alpha1.ShardRange{Spec: twoShardSpec()} + other := v1alpha1.ShardRange{Spec: v1alpha1.ShardRangeSpec{Cluster: "other", Keyspace: "default"}} + + p := &CRDTopologyProvider{ + Lister: fakeLister{items: []v1alpha1.ShardRange{other, sr}}, + Namespace: "ns", + Cluster: "demo", + Keyspace: "default", + } + topo, err := p.Current(context.Background()) + if err != nil { + t.Fatalf("Current: %v", err) + } + if topo.Cluster != "demo" || len(topo.Spec.Ranges) != 2 { + t.Fatalf("topo = %+v", topo) + } + // 두 번째 Current 는 캐시에서 (lister 가 에러를 내도 캐시 반환). + p.Lister = fakeLister{err: errors.New("api down")} + if _, err := p.Current(context.Background()); err != nil { + t.Fatalf("cached Current should not hit lister: %v", err) + } + + // 매칭 없음 → 에러. + p2 := &CRDTopologyProvider{ + Lister: fakeLister{items: []v1alpha1.ShardRange{other}}, + Cluster: "demo", + Keyspace: "default", + } + if _, err := p2.Current(context.Background()); err == nil { + t.Fatal("no matching ShardRange: expected error") + } +} + +func TestStatusBackendResolver(t *testing.T) { + r := NewStatusBackendResolver() + + // Update 전: 모든 shard 에러. + if _, err := r.Resolve("shard-0"); err == nil { + t.Fatal("pre-update: expected error") + } + + ready := func(pod, ep string, rdy bool) *v1alpha1.ShardEndpoint { + return &v1alpha1.ShardEndpoint{Pod: pod, Endpoint: ep, Ready: rdy} + } + r.Update([]v1alpha1.ShardStatus{ + {Name: "shard-0", Primary: ready("demo-shard-0-0", "demo-shard-0-0.svc:5432", true)}, + {Name: "shard-1", Primary: ready("demo-shard-1-1", "demo-shard-1-1.svc:5432", false)}, // not ready + {Name: "shard-2", Primary: nil}, // no primary (down) + }) + + // Ready primary → endpoint. + if ep, err := r.Resolve("shard-0"); err != nil || ep != "demo-shard-0-0.svc:5432" { + t.Fatalf("shard-0 = (%q,%v), want demo-shard-0-0.svc:5432", ep, err) + } + // primary not ready → error (failover 중). + if _, err := r.Resolve("shard-1"); err == nil { + t.Fatal("shard-1 (not ready): expected error") + } + // primary 부재 → error (down). + if _, err := r.Resolve("shard-2"); err == nil { + t.Fatal("shard-2 (no primary): expected error") + } + + // failover 시뮬: shard-1 의 새 primary 가 Ready 로 갱신되면 따라간다. + r.Update([]v1alpha1.ShardStatus{ + {Name: "shard-1", Primary: ready("demo-shard-1-0", "demo-shard-1-0.svc:5432", true)}, + }) + if ep, err := r.Resolve("shard-1"); err != nil || ep != "demo-shard-1-0.svc:5432" { + t.Fatalf("shard-1 after failover = (%q,%v), want demo-shard-1-0.svc:5432", ep, err) + } + // shard-0 은 이제 status 에 없으니 에러(스냅샷 통째 교체). + if _, err := r.Resolve("shard-0"); err == nil { + t.Fatal("shard-0 absent after update: expected error") + } +} From dbf26d69be2ce28316c8e0d40afc3ceddafbb202 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Fri, 26 Jun 2026 13:33:57 +0900 Subject: [PATCH 016/109] =?UTF-8?q?feat(router):=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=ED=95=9C=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8(Dockerfile.router=20+=20co?= =?UTF-8?q?nfig/router)=20+=20=ED=95=B8=EB=93=9C=EC=98=A4=ED=94=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pg-router를 실제 배포 가능한 컴포넌트로. Dockerfile.router(distroless 정적 바이너리, operator와 분리). config/router/: ServiceAccount + 최소권한 Role(shardranges·postgresclusters get/list/watch) + RoleBinding + Deployment(replicas 2, restricted SecurityContext, TCP probe, PGROUTER_BACKEND=status·dial 타임아웃) + Service(:5432) + kustomization + README. kustomize build 렌더 검증. docs/WORK_HANDOFF.ko.md: 이번 세션 변경 인수인계. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- Dockerfile.router | 22 ++++++++ config/router/README.md | 47 +++++++++++++++++ config/router/deployment.yaml | 85 ++++++++++++++++++++++++++++++ config/router/kustomization.yaml | 21 ++++++++ config/router/role.yaml | 22 ++++++++ config/router/role_binding.yaml | 15 ++++++ config/router/service.yaml | 19 +++++++ config/router/service_account.yaml | 8 +++ docs/WORK_HANDOFF.ko.md | 40 +++++++++++++- 9 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 Dockerfile.router create mode 100644 config/router/README.md create mode 100644 config/router/deployment.yaml create mode 100644 config/router/kustomization.yaml create mode 100644 config/router/role.yaml create mode 100644 config/router/role_binding.yaml create mode 100644 config/router/service.yaml create mode 100644 config/router/service_account.yaml diff --git a/Dockerfile.router b/Dockerfile.router new file mode 100644 index 0000000..cf76566 --- /dev/null +++ b/Dockerfile.router @@ -0,0 +1,22 @@ +# Build the pg-router binary (the stateless query router — RFC 0004 / ROADMAP G3). +# Separate image from the operator manager so the router can scale independently. +FROM golang:1.26.4@sha256:68cb6d68bed024785b69195b89af7ac7a444f27791435f98647edff595aa0479 AS builder +ARG TARGETOS +ARG TARGETARCH + +WORKDIR /workspace +COPY go.mod go.mod +COPY go.sum go.sum +RUN go mod download + +COPY . . + +# CGO disabled → static distroless binary (zero external runtime, minimal surface). +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o pg-router cmd/pg-router/main.go + +FROM gcr.io/distroless/static:nonroot@sha256:e3f945647ffb95b5839c07038d64f9811adf17308b9121d8a2b87b6a22a80a39 +WORKDIR / +COPY --from=builder /workspace/pg-router . +USER 65532:65532 + +ENTRYPOINT ["/pg-router"] diff --git a/config/router/README.md b/config/router/README.md new file mode 100644 index 0000000..0ec6da0 --- /dev/null +++ b/config/router/README.md @@ -0,0 +1,47 @@ +# pg-router — deployable query router (RFC 0004 / ROADMAP G3) + +The stateless PostgreSQL query router. Applications connect to its Service +instead of to a single shard; the router resolves the shard from the routing key +(vindex) and forwards the connection to that shard's backend. + +This is the **first deployable slice** of the router (ROUTER-GAP-ANALYSIS step C): +single-shard routing with topology sourced from `ShardRange` CRDs. + +## Build the image + +Separate from the operator manager (so the router scales independently): + +```bash +docker build -f Dockerfile.router -t ghcr.io/keiailab/pg-router: . +docker push ghcr.io/keiailab/pg-router: +``` + +## Deploy + +Apply into the namespace of the `PostgresCluster` it fronts: + +```bash +kustomize build config/router | kubectl apply -n -f - +``` + +Adjust env in `deployment.yaml`: + +| env | meaning | +|---|---| +| `PGROUTER_TOPOLOGY` | `crd` (read ShardRange CRDs) or `static` (env table) | +| `PGROUTER_CLUSTER` / `PGROUTER_KEYSPACE` | which cluster + keyspace this router fronts | +| `PGROUTER_REFRESH` | ShardRange re-read interval (hot-reload) | +| `PGROUTER_BACKEND_TEMPLATE` | shard → backend DNS, with `{cluster}`/`{shard}`/`{namespace}` | + +RBAC is least-privilege: `get/list/watch` on `shardranges` in the router's own +namespace only. + +## Known limitations (first slice) + +- Routes by the connection startup `database`/`user` parameter, not yet by parsed + SQL (query-level routing is step E — message-aware proxy + `RouteKeyExtractor`). +- Backend template targets each shard's ordinal-0 pod; routing to the *current + primary* per shard is follow-up work (needs a per-shard primary Service). +- Single-shard fast-path only; multi-shard scatter-gather is later (G5). + +See [`docs/sharding/ROUTER-GAP-ANALYSIS.ko.md`](../../docs/sharding/ROUTER-GAP-ANALYSIS.ko.md). diff --git a/config/router/deployment.yaml b/config/router/deployment.yaml new file mode 100644 index 0000000..35fa649 --- /dev/null +++ b/config/router/deployment.yaml @@ -0,0 +1,85 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: pg-router + labels: + app.kubernetes.io/name: pg-router + app.kubernetes.io/component: router + app.kubernetes.io/managed-by: kustomize +spec: + # Stateless: scale horizontally behind the Service. The router holds no state; + # topology comes from the ShardRange CRD (hot-reloaded). + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: pg-router + app.kubernetes.io/component: router + template: + metadata: + labels: + app.kubernetes.io/name: pg-router + app.kubernetes.io/component: router + spec: + serviceAccountName: pg-router + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: pg-router + image: pg-router:latest + ports: + - name: postgres + containerPort: 5432 + protocol: TCP + env: + - name: PGROUTER_LISTEN + value: ":5432" + # Source the routing topology from ShardRange CRDs (production mode). + - name: PGROUTER_TOPOLOGY + value: crd + - name: PGROUTER_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + # The PostgresCluster + keyspace this router fronts. Adjust per deployment. + - name: PGROUTER_CLUSTER + value: quickstart + - name: PGROUTER_KEYSPACE + value: default + # Re-read topology + primary status on this interval (hot-reload). + - name: PGROUTER_REFRESH + value: "10s" + # Resolve each shard's backend from its *current Ready primary* in + # PostgresCluster.status (failover-aware): when a shard primary dies, the + # operator promotes a replica and updates status, and the router follows. + # A shard with no Ready primary returns a graceful error to the client. + - name: PGROUTER_BACKEND + value: status + # Fail fast instead of hanging on an unreachable shard. + - name: PGROUTER_DIAL_TIMEOUT + value: "5s" + securityContext: + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + capabilities: + drop: + - "ALL" + readinessProbe: + tcpSocket: + port: 5432 + initialDelaySeconds: 3 + periodSeconds: 10 + livenessProbe: + tcpSocket: + port: 5432 + initialDelaySeconds: 10 + periodSeconds: 20 + resources: + limits: + cpu: 500m + memory: 128Mi + requests: + cpu: 10m + memory: 32Mi + terminationGracePeriodSeconds: 10 diff --git a/config/router/kustomization.yaml b/config/router/kustomization.yaml new file mode 100644 index 0000000..eb7cc8e --- /dev/null +++ b/config/router/kustomization.yaml @@ -0,0 +1,21 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +# Deployable router component (RFC 0004 / ROADMAP G3). Apply into the namespace +# of the PostgresCluster it fronts: +# +# kustomize build config/router | kubectl apply -n -f - +# +# Build/push the image first (separate from the operator manager): +# docker build -f Dockerfile.router -t ghcr.io/keiailab/pg-router: . +resources: + - service_account.yaml + - role.yaml + - role_binding.yaml + - deployment.yaml + - service.yaml + +images: + - name: pg-router + newName: ghcr.io/keiailab/pg-router + newTag: latest diff --git a/config/router/role.yaml b/config/router/role.yaml new file mode 100644 index 0000000..b6f63fd --- /dev/null +++ b/config/router/role.yaml @@ -0,0 +1,22 @@ +# The router only *reads* in its own namespace (least privilege): +# - shardranges : routing topology (key -> shard). +# - postgresclusters : per-shard current primary endpoint, for failover-aware +# backend resolution (PGROUTER_BACKEND=status). +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: pg-router + labels: + app.kubernetes.io/name: pg-router + app.kubernetes.io/component: router + app.kubernetes.io/managed-by: kustomize +rules: + - apiGroups: + - postgres.keiailab.io + resources: + - shardranges + - postgresclusters + verbs: + - get + - list + - watch diff --git a/config/router/role_binding.yaml b/config/router/role_binding.yaml new file mode 100644 index 0000000..b8faf23 --- /dev/null +++ b/config/router/role_binding.yaml @@ -0,0 +1,15 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: pg-router + labels: + app.kubernetes.io/name: pg-router + app.kubernetes.io/component: router + app.kubernetes.io/managed-by: kustomize +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: pg-router +subjects: + - kind: ServiceAccount + name: pg-router diff --git a/config/router/service.yaml b/config/router/service.yaml new file mode 100644 index 0000000..3682342 --- /dev/null +++ b/config/router/service.yaml @@ -0,0 +1,19 @@ +# The router is the client-facing PostgreSQL endpoint. Applications connect here +# instead of to a single shard; the router forwards to the resolved shard. +apiVersion: v1 +kind: Service +metadata: + name: pg-router + labels: + app.kubernetes.io/name: pg-router + app.kubernetes.io/component: router + app.kubernetes.io/managed-by: kustomize +spec: + selector: + app.kubernetes.io/name: pg-router + app.kubernetes.io/component: router + ports: + - name: postgres + port: 5432 + targetPort: 5432 + protocol: TCP diff --git a/config/router/service_account.yaml b/config/router/service_account.yaml new file mode 100644 index 0000000..c229e57 --- /dev/null +++ b/config/router/service_account.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: pg-router + labels: + app.kubernetes.io/name: pg-router + app.kubernetes.io/component: router + app.kubernetes.io/managed-by: kustomize diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index 93f2aa0..b969900 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -149,7 +149,45 @@ Windows 호스트에서는 `-v E:\keiailab\postgres-operator:/workspace`처럼 --- -## 6. 용어집 +## 6. 심층 검토 후속 (2026-06-26) — failover lease 정리 + 발견 사항 + +> 별도 심층 검토에서 나온 **working-tree 변경 1건**(아직 커밋 안 됨)과 **기록용 발견 사항**. 커밋 구성(2장)과 분리해서 본다. + +### 6.1 변경 — operator-level failover lease의 무효(dead) production 배선 제거 + +**문제**: `cmd/main.go`가 manager lease와 *별개*인 failover 전용 lease(`failover.FailoverLeaseName`)를 만들어 모든 replica가 경합하도록 등록했으나, 그 leadership 결과(`Lease.IsLeader()`)를 **어디서도 참조하지 않았다**(테스트 제외). `OnStartedLeading`/`OnStoppedLeading` 콜백은 **로그만** 찍었다. 실제 자동 failover(`clusterFailoverDecision → executeClusterPromotion`)는 PostgresCluster reconcile 루프에서 돌고, 이는 **controller-runtime manager 자체 leader election**(`--leader-elect`, 기본 true)으로 이미 단일 replica로 게이팅된다. ⇒ 전용 lease는 클러스터에 lease 객체를 만들고 2s마다 renew하면서도 **행동에 0 영향**을 주는 장식이었다. + +**왜 순진하게 wiring하면 안 되나**: reconciler는 manager-lease holder에서만 돈다. 만약 failover 실행을 `failoverLease.IsLeader()`로 게이팅하면, 그 lease를 *다른* Pod가 쥐었을 때 manager-leader Pod는 (failover-leader가 아니라) 실행을 건너뛰고, failover-leader Pod는 (reconciler가 안 돌아서) 실행을 못 한다 ⇒ **failover 영구 정지(deadlock)**. + +**조치** (working tree, 미커밋): +- `cmd/main.go`: failover lease 생성/등록 블록 + 헬퍼(`failoverIdentity`, `operatorNamespace`, `leaderElectionAgnosticRunnable`) 제거, unused import(`context`/`fmt`/`strings`/`kubernetes`/`failover`) 정리. 단일-active 보장이 manager lease로 충분함을 주석으로 명시. +- `internal/controller/failover/lease.go`: 패키지는 **테스트된 building block으로 보존**(leader 단일성+handoff 검증 `lease_test.go`)하되, 헤더 주석을 "아직 production 미배선, deadlock 이유로 순진한 게이팅 금지, 제대로 된 P2-T3는 failover를 reconcile 루프 밖 runnable로 먼저 분리해야 함"으로 정직하게 갱신. +- `docs/rfcs/0007-ha-election-and-fencing.md §7`: failover detection+promotion은 구현·동작(manager lease 게이팅)으로, 전용 failover-controller lease(P2-T3)는 미배선으로 상태 정정. + +**검증**: `golang:1.26` 컨테이너에서 `go build ./...` ✅, `go vet ./cmd/... ./internal/controller/failover/...` ✅, `go test ./internal/controller/failover/... ./cmd/...` ✅(failover 4.2s ok). 동작 경로(reconcile+manager lease)는 불변이라 failover 거동에 영향 없음. + +### 6.2 기록용 발견 사항 (조치 안 함 — 유지보수자 판단 영역) + +- **샤딩(G3~G4)은 골격**: `shardsplitjob_controller.go`는 스스로 "phase 전이 골격"이라 명시. 7-step 중 실 데이터 이동(`router.CopyTable` DSN 결선)·CDC 논리복제·write-block cutover는 전부 "별 트랙"(미구현). `InitialCopy` phase는 데이터를 옮기지 않고 다음 phase로 넘어간다. ⇒ 제품 차별점(샤딩)이 end-to-end로 동작하지 않음. +- **분산 SQL 라우터는 PoC**: `cmd/pg-router/main.go`는 명시적 PoC(연결 단위 프록시, 2-shard 하드코딩). `config/`·`charts/`·`deploy/` 어디에도 배포 매니페스트 없음 ⇒ operator가 띄우지 않음. +- **성능 수치 0**: `docs/perf/baseline.md`는 측정 프로토콜만 있고 모든 결과 셀이 `(pending live measurement)`. 하네스(`test/bench/pgbench.sh`·`sysbench.sh`)는 존재하나 라이브 클러스터 부재로 미실행. **상품화 관점 최대 결손** — single-shard baseline부터 실측 필요. +- **ROADMAP G4 과소표기**: `docs/ROADMAP.ko.md`는 `ShardSplitJob CRD`를 `[ ]`(미완)로 적었으나 실제론 CRD + 골격 컨트롤러(phase 머신 + Bootstrap target 생성 + RoutingUpdate)가 존재. 4개 언어(ko/en/ja/zh) 동기 수정이 필요해 여기서는 보류 — 일괄 정정 권장. + +### 6.3 분산 SQL 방향 착수 — 라우터 척추 (working tree, 미커밋) + +타깃을 "범용 분산 SQL(Vitess-for-Postgres)"로 정하고 라우터를 척추로 세우는 작업 시작. + +- **라우터 갭 분석 + 능력 사다리**: `docs/sharding/ROUTER-GAP-ANALYSIS.ko.md` 신설. 핵심 발견 — 라우터가 "두 반쪽"(pg-router 바이트 프록시 ↔ in-process 라이브러리)으로 분리돼 서로/reconciler에 미연결. 토폴로지 CRD→라우터 흐름 단절(하드코딩). vindex/resharding 검증은 진짜, scatter/sql_route는 골격, placement/metadata_store는 진짜지만 orphan. +- **교체 가능 라우팅키 추출 전략 도입**: `internal/router/route_extractor.go`(`RouteKeyExtractor` 인터페이스 + 선택기 regex|parser|auto) + `sql_route.go`(regex 컬럼 인지) + `route_extractor_parser.go`(**제로 의존성 토크나이저** 정확 전략) + 테스트. **기본 전략 `regex`**(현황 유지, 사용자 지정). 세 전략 모두 dep 0, 런타임 선택. +- **파서 결정(실측 → 외부 파서 기각)**: auxten/postgresql-parser v1.0.1 평가했으나 ① ~25 transitive 모듈 ② **치명적: 옛 monolithic genproto 고정 → 오퍼레이터 현대 deps(grpc 1.79/otel/cel-go split genproto)와 ambiguous import 충돌로 빌드 파괴**(build-tag로도 격리 불가, go.mod 모듈 전역). ⇒ 외부 파서 대신 **자체 토크나이저**(murmur3 철학). 정규식보다 정확(따옴표 내부·주석 가짜 predicate 오인 방지)하면서 dep 0. **검증**: build/vet/test ✅ gofmt clean **go.mod 무변경**. +- **(B) CRD 토폴로지 소싱 완료** (working tree): `internal/router/topology.go`(`TopologyProvider` 전략 + `Topology`/`BuildTopology` + `StaticTopologyProvider` + `CRDTopologyProvider` + `ShardRangeLister` 인터페이스) + `topology_test.go`(fake lister). pg-router를 `TopologyProvider`로 리팩터링 — `PGROUTER_TOPOLOGY=static|crd` 선택, crd 모드는 `PGROUTER_REFRESH` 주기 hot-reload, K8s 클라이언트는 `clientLister`로 가장자리 격리(router 패키지는 controller-runtime 미import, 순수 유지). `shardSpec()`/`backendFor()` 보존(기존 테스트 유지). **검증**: build/vet/test ✅ gofmt clean **go.mod 무변경**(controller-runtime 기존 dep). +- **(C) 라우터 배포 완료** (working tree): `Dockerfile.router`(pg-router distroless 이미지) + `config/router/`(SA + Role[shardranges get/list/watch] + RoleBinding + Deployment[replicas 2, restricted SecCtx, TCP probe, crd 토폴로지 env] + Service[:5432] + kustomization + README). pg-router에 **DNS 템플릿 BackendResolver**(`PGROUTER_BACKEND_TEMPLATE`, {cluster}/{shard}/{namespace}) 추가 — per-shard env 없이 매니페스트 깔끔, 모듈화(env resolver와 스왑). **검증**: build/vet/test ✅ gofmt clean go.mod 무변경 + `kustomize build config/router` 렌더 성공(5종, 이미지 매핑 정상). +- **샤드 장애 회복력 슬라이스 완료** (working tree): "라우팅은 백엔드 커넥션 확보가 전제인데 샤드가 죽어 있으면?"에 대응. ① **key→shard(토폴로지)와 shard→backend(resolver) 분리**(`topology.go` 재작성) ② **`StatusBackendResolver`** — `PostgresCluster.status.shards[].primary.endpoint`(Ready만)에서 백엔드 해소 → **failover-aware**(operator가 replica 승격+status 갱신하면 라우터가 따라감). primary 부재/not-ready → 에러 ③ pg-router: `PGROUTER_BACKEND=env|template|status` 선택, **dial 타임아웃**, **우아한 PG `ErrorResponse`**(조용한 drop 금지) ④ `ClusterStatusReader` 인터페이스로 K8s 격리, refresh 루프가 토폴로지+status 동시 hot-reload ⑤ 매니페스트: RBAC에 `postgresclusters` 추가, deployment `PGROUTER_BACKEND=status`. **검증**: build/vet/test ✅ gofmt clean go.mod 무변경 + kustomize 렌더 ✅. +- **향후 대작업은 백로그로 기록**: `docs/sharding/ROUTER-GAP-ANALYSIS.ko.md §6` — (E) 프로토콜 종단(vtgate급), 읽기→replica, scatter-gather 실연결, reference table, resharding 데이터 이동, 2PC, 커넥션 풀링, stable primary Service, watch 기반 hot-reload, failover lease P2-T3, 성능 실측 등. 능력 사다리(§4)에 매핑. + +--- + +## 7. 용어집 > 정의는 [GLOSSARY.ko.md](GLOSSARY.ko.md)에서 발췌해 동일하게 유지한다. 전체 용어는 해당 문서 참고. From ef4836aad8bbf9cd8bf3519b1efd94e0d45fbb45 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Fri, 26 Jun 2026 17:07:24 +0900 Subject: [PATCH 017/109] =?UTF-8?q?perf(router):=20SQLShardExecutor=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=ED=92=80=EB=A7=81(shard=EB=B3=84=20*sql.D?= =?UTF-8?q?B=20=EC=BA=90=EC=8B=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 호출마다 sql.Open/Close 하던 것을 shard별 *sql.DB(자체 풀) lazy 캐시·재사용으로 교체 — scatter fan-out 부하에서 연결 폭주·지연 방지. MaxOpenConns/MaxIdleConns/ConnMaxIdleTime 튜닝(기본 10/5/5m) + Close(graceful). 백로그 (D) 항목. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- internal/router/sql_executor.go | 88 ++++++++++++++++++++++++---- internal/router/sql_executor_test.go | 27 +++++++++ 2 files changed, 104 insertions(+), 11 deletions(-) diff --git a/internal/router/sql_executor.go b/internal/router/sql_executor.go index f214ef2..16b2b16 100644 --- a/internal/router/sql_executor.go +++ b/internal/router/sql_executor.go @@ -1,10 +1,19 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + // Package router — SQLShardExecutor 는 ShardExecutor 의 lib/pq 실 구현이다. // // scatter.go 의 ScatterGather 는 ShardExecutor interface 에만 의존하고 실 // shard 호출을 외부 구현에 위임한다 (RFC-0004 §3.1). 본 file 이 그 *라이브 // consumer* — 각 shard 의 PostgreSQL DSN 으로 database/sql(lib/pq) 연결하여 -// query 를 실행하고 결과를 router.Row 로 정규화한다. (G3 scatter-gather 라이브 -// 경로 — 이전에는 fakeShardExecutor(테스트 stub)만 존재했다.) +// query 를 실행하고 결과를 router.Row 로 정규화한다. +// +// *연결 풀링*: shard 별 *sql.DB(자체가 커넥션 풀)를 캐시하여 재사용한다. 호출마다 +// sql.Open/Close 하면 fan-out 부하에서 연결 폭주·지연이 발생하므로, lazy 하게 풀을 +// 열고 유지한다. *sql.DB 는 동시성 안전하다. package router import ( @@ -12,26 +21,42 @@ import ( "database/sql" "errors" "fmt" + "sync" + "time" _ "github.com/lib/pq" // postgres driver — sql.Open("postgres", ...) 등록용 (instance-manager 와 동일) ) -// SQLShardExecutor 는 shard 별 DSN 으로 database/sql 연결하여 query 를 실행한다. -// -// 단순성 우선 (RFC-0004 PoC 단계): 호출마다 sql.Open + Close. connection -// pooling / prepared statement 캐시는 성능 측정 후 도입 (future). DSN 은 -// operator 가 ShardRange CRD 의 shard endpoint 로부터 합성하여 주입한다. +// 기본 풀 매개변수 (필드가 0 일 때 적용). 라우터 fan-out 에 보수적 기본값. +const ( + defaultMaxOpenConns = 10 + defaultMaxIdleConns = 5 + defaultConnMaxIdleTime = 5 * time.Minute +) + +// SQLShardExecutor 는 shard 별 DSN 으로 *sql.DB 풀을 캐시·재사용하여 query 를 실행한다. type SQLShardExecutor struct { // DSNs 는 shard → PostgreSQL DSN ("postgres://user:pw@host:port/db?sslmode=..."). DSNs map[ShardID]string + // MaxOpenConns / MaxIdleConns / ConnMaxIdleTime 은 shard 풀 튜닝 (0 = 기본값). + MaxOpenConns int + MaxIdleConns int + ConnMaxIdleTime time.Duration + + mu sync.Mutex + pools map[ShardID]*sql.DB } // ErrNoDSN 은 shard 에 대응하는 DSN 이 없을 때 반환된다. var ErrNoDSN = errors.New("router: no DSN configured for shard") -// ExecuteOne 은 단일 shard 에 query 를 실행하고 row 를 router.Row 로 정규화한다. -// context 취소 시 즉시 종료한다 (ScatterGather FailFast cancel 전파). -func (e *SQLShardExecutor) ExecuteOne(ctx context.Context, shard ShardID, query string) ([]Row, error) { +// pool 은 shard 의 *sql.DB 를 lazy 하게 열어 캐시·반환한다 (재호출 시 동일 풀). +func (e *SQLShardExecutor) pool(shard ShardID) (*sql.DB, error) { + e.mu.Lock() + defer e.mu.Unlock() + if db, ok := e.pools[shard]; ok { + return db, nil + } dsn, ok := e.DSNs[shard] if !ok { return nil, fmt.Errorf("%w: %s", ErrNoDSN, shard) @@ -40,7 +65,34 @@ func (e *SQLShardExecutor) ExecuteOne(ctx context.Context, shard ShardID, query if err != nil { return nil, fmt.Errorf("router: open shard %s: %w", shard, err) } - defer func() { _ = db.Close() }() + db.SetMaxOpenConns(orDefault(e.MaxOpenConns, defaultMaxOpenConns)) + db.SetMaxIdleConns(orDefault(e.MaxIdleConns, defaultMaxIdleConns)) + if e.ConnMaxIdleTime > 0 { + db.SetConnMaxIdleTime(e.ConnMaxIdleTime) + } else { + db.SetConnMaxIdleTime(defaultConnMaxIdleTime) + } + if e.pools == nil { + e.pools = make(map[ShardID]*sql.DB) + } + e.pools[shard] = db + return db, nil +} + +func orDefault(v, def int) int { + if v > 0 { + return v + } + return def +} + +// ExecuteOne 은 단일 shard 의 풀에서 query 를 실행하고 row 를 router.Row 로 정규화한다. +// context 취소 시 즉시 종료한다 (ScatterGather FailFast cancel 전파). +func (e *SQLShardExecutor) ExecuteOne(ctx context.Context, shard ShardID, query string) ([]Row, error) { + db, err := e.pool(shard) + if err != nil { + return nil, err + } rows, err := db.QueryContext(ctx, query) if err != nil { @@ -70,5 +122,19 @@ func (e *SQLShardExecutor) ExecuteOne(ctx context.Context, shard ShardID, query return out, nil } +// Close 는 모든 shard 풀을 닫는다 (graceful shutdown). +func (e *SQLShardExecutor) Close() error { + e.mu.Lock() + defer e.mu.Unlock() + var firstErr error + for shard, db := range e.pools { + if err := db.Close(); err != nil && firstErr == nil { + firstErr = fmt.Errorf("router: close shard %s pool: %w", shard, err) + } + } + e.pools = nil + return firstErr +} + // 컴파일 타임 interface 만족 검사. var _ ShardExecutor = (*SQLShardExecutor)(nil) diff --git a/internal/router/sql_executor_test.go b/internal/router/sql_executor_test.go index df88941..6e5b27a 100644 --- a/internal/router/sql_executor_test.go +++ b/internal/router/sql_executor_test.go @@ -12,6 +12,33 @@ import ( "testing" ) +// TestSQLShardExecutor_PoolReuse 는 동일 shard 가 같은 *sql.DB 풀을 재사용하고 +// (호출마다 sql.Open 하지 않음), Close 가 풀을 비움을 검증한다. sql.Open 은 lazy 라 +// 라이브 PG 없이 검증 가능. +func TestSQLShardExecutor_PoolReuse(t *testing.T) { + e := &SQLShardExecutor{DSNs: map[ShardID]string{"shard-0": "postgres://u@h:5432/db?sslmode=disable"}} + db1, err := e.pool("shard-0") + if err != nil { + t.Fatalf("pool: %v", err) + } + db2, err := e.pool("shard-0") + if err != nil { + t.Fatalf("pool(2): %v", err) + } + if db1 != db2 { + t.Fatal("pool not reused: different *sql.DB per call") + } + if _, err := e.pool("shard-9"); !errors.Is(err, ErrNoDSN) { + t.Fatalf("pool(missing) = %v, want ErrNoDSN", err) + } + if err := e.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if e.pools != nil { + t.Fatal("Close did not clear pools") + } +} + // TestSQLShardExecutor_NoDSN 은 shard 에 대응하는 DSN 이 없으면 ErrNoDSN 을 // 반환함을 검증한다 (라이브 PG 없이 검증 가능한 경로 — 실 query 는 라이브 e2e). func TestSQLShardExecutor_NoDSN(t *testing.T) { From 830d93757bfe150984c4ff19a144ab1c81849056 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Fri, 26 Jun 2026 17:10:29 +0900 Subject: [PATCH 018/109] =?UTF-8?q?feat(router):=20=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C=20dial=20retry/backoff=20+=20per-backend=20circuit-br?= =?UTF-8?q?eaker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 죽은 샤드가 매 연결마다 dial 타임아웃을 다 기다리게 하지 않도록: bounded retry/backoff + failThreshold 연속 실패 시 backend를 cooldown 동안 fast-fail(circuit open). 성공 시 breaker 리셋, cooldown 경과 후 half-open 재시도. dial/clock 주입으로 단위 검증(retry·open·cooldown·reset). env: PGROUTER_DIAL_RETRIES/BACKOFF, PGROUTER_BREAKER_THRESHOLD/COOLDOWN. 백로그 '라우터 HA 강화'. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- cmd/pg-router/dialer.go | 118 +++++++++++++++++++++++++++++++++++ cmd/pg-router/dialer_test.go | 94 ++++++++++++++++++++++++++++ cmd/pg-router/main.go | 28 ++++++++- 3 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 cmd/pg-router/dialer.go create mode 100644 cmd/pg-router/dialer_test.go diff --git a/cmd/pg-router/dialer.go b/cmd/pg-router/dialer.go new file mode 100644 index 0000000..3c01bdc --- /dev/null +++ b/cmd/pg-router/dialer.go @@ -0,0 +1,118 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package main + +import ( + "fmt" + "net" + "sync" + "time" +) + +// dialFunc abstracts net.DialTimeout for testing. +type dialFunc func(network, addr string, timeout time.Duration) (net.Conn, error) + +// errCircuitOpen indicates a backend is temporarily fast-failed by the breaker. +var errCircuitOpen = fmt.Errorf("backend circuit open (recent repeated failures)") + +// backendDialer dials shard backends with bounded retry/backoff and a per-backend +// circuit breaker. Why: when a shard is down, dialing it blocks up to the timeout +// (× retries) for *every* new client. After failThreshold consecutive failures the +// breaker "opens" the backend for a cooldown, fast-failing new attempts so the +// router degrades quickly (a graceful PG error) instead of stalling connections. +type backendDialer struct { + timeout time.Duration + retries int // additional attempts after the first + backoff time.Duration + failThreshold int + cooldown time.Duration + + dial dialFunc + now func() time.Time + sleep func(time.Duration) + + mu sync.Mutex + breakers map[string]*breaker +} + +type breaker struct { + failures int + openUntil time.Time +} + +// newBackendDialer builds a dialer with real net/clock. failThreshold<=0 disables +// the breaker; retries<0 is treated as 0. +func newBackendDialer(timeout, backoff, cooldown time.Duration, retries, failThreshold int) *backendDialer { + if retries < 0 { + retries = 0 + } + return &backendDialer{ + timeout: timeout, + retries: retries, + backoff: backoff, + failThreshold: failThreshold, + cooldown: cooldown, + dial: net.DialTimeout, + now: time.Now, + sleep: time.Sleep, + breakers: map[string]*breaker{}, + } +} + +// Dial connects to addr, applying the circuit breaker then bounded retry/backoff. +func (d *backendDialer) Dial(addr string) (net.Conn, error) { + if d.isOpen(addr) { + return nil, fmt.Errorf("%s: %w", addr, errCircuitOpen) + } + var lastErr error + for attempt := 0; attempt <= d.retries; attempt++ { + if attempt > 0 && d.backoff > 0 { + d.sleep(d.backoff) + } + conn, err := d.dial("tcp", addr, d.timeout) + if err == nil { + d.onSuccess(addr) + return conn, nil + } + lastErr = err + } + d.onFailure(addr) + return nil, lastErr +} + +func (d *backendDialer) isOpen(addr string) bool { + if d.failThreshold <= 0 { + return false + } + d.mu.Lock() + defer d.mu.Unlock() + b := d.breakers[addr] + return b != nil && d.now().Before(b.openUntil) +} + +func (d *backendDialer) onSuccess(addr string) { + d.mu.Lock() + delete(d.breakers, addr) + d.mu.Unlock() +} + +func (d *backendDialer) onFailure(addr string) { + if d.failThreshold <= 0 { + return + } + d.mu.Lock() + defer d.mu.Unlock() + b := d.breakers[addr] + if b == nil { + b = &breaker{} + d.breakers[addr] = b + } + b.failures++ + if b.failures >= d.failThreshold { + b.openUntil = d.now().Add(d.cooldown) + } +} diff --git a/cmd/pg-router/dialer_test.go b/cmd/pg-router/dialer_test.go new file mode 100644 index 0000000..3627510 --- /dev/null +++ b/cmd/pg-router/dialer_test.go @@ -0,0 +1,94 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package main + +import ( + "errors" + "net" + "testing" + "time" +) + +func fakeConn() net.Conn { c, _ := net.Pipe(); return c } + +// TestDialer_RetryThenSuccess 는 backoff 재시도가 실패를 흡수하고 성공하면 conn 을 +// 반환함을 검증한다 (backoff=0 으로 sleep 없이). +func TestDialer_RetryThenSuccess(t *testing.T) { + d := newBackendDialer(time.Second, 0, time.Second, 2, 3) + calls := 0 + d.dial = func(_, _ string, _ time.Duration) (net.Conn, error) { + calls++ + if calls < 3 { + return nil, errors.New("connection refused") + } + return fakeConn(), nil + } + conn, err := d.Dial("shard-a:5432") + if err != nil || conn == nil { + t.Fatalf("Dial = (%v,%v), want conn", conn, err) + } + _ = conn.Close() + if calls != 3 { + t.Fatalf("dial attempts = %d, want 3 (1 + 2 retries)", calls) + } +} + +// TestDialer_CircuitOpensAndCooldown 는 failThreshold 연속 실패 후 breaker 가 열려 +// dial 을 호출하지 않고 빠르게 실패하며, cooldown 경과 후 다시 시도함을 검증한다. +func TestDialer_CircuitOpensAndCooldown(t *testing.T) { + now := time.Now() + d := newBackendDialer(time.Second, 0, 10*time.Second, 0, 2) // retries 0, threshold 2 + d.now = func() time.Time { return now } + calls := 0 + d.dial = func(_, _ string, _ time.Duration) (net.Conn, error) { + calls++ + return nil, errors.New("connection refused") + } + _, _ = d.Dial("a") // fail 1 + _, _ = d.Dial("a") // fail 2 → open + before := calls + + _, err := d.Dial("a") // circuit open → fast fail, dial NOT called + if !errors.Is(err, errCircuitOpen) { + t.Fatalf("Dial(open) = %v, want errCircuitOpen", err) + } + if calls != before { + t.Fatalf("dial called while circuit open: %d -> %d", before, calls) + } + + now = now.Add(11 * time.Second) // past cooldown + _, _ = d.Dial("a") + if calls != before+1 { + t.Fatalf("dial not retried after cooldown: %d -> %d", before, calls) + } +} + +// TestDialer_SuccessResetsBreaker 는 성공이 실패 카운트를 초기화함을 검증한다. +func TestDialer_SuccessResetsBreaker(t *testing.T) { + now := time.Now() + d := newBackendDialer(time.Second, 0, 10*time.Second, 0, 2) + d.now = func() time.Time { return now } + fail := true + d.dial = func(_, _ string, _ time.Duration) (net.Conn, error) { + if fail { + return nil, errors.New("refused") + } + return fakeConn(), nil + } + _, _ = d.Dial("a") // fail 1 + fail = false + c, err := d.Dial("a") // success → reset + if err != nil { + t.Fatalf("Dial success: %v", err) + } + _ = c.Close() + fail = true + _, _ = d.Dial("a") // fail 1 (reset 후) — threshold 2 미달 + if d.isOpen("a") { + t.Fatal("breaker opened after reset + single failure") + } +} diff --git a/cmd/pg-router/main.go b/cmd/pg-router/main.go index fb5a40a..75ed500 100644 --- a/cmd/pg-router/main.go +++ b/cmd/pg-router/main.go @@ -39,6 +39,7 @@ import ( "log" "net" "os" + "strconv" "strings" "time" @@ -57,6 +58,13 @@ func main() { if err != nil { log.Fatalf("pg-router: build routing: %v", err) } + dialer := newBackendDialer( + envDuration("PGROUTER_DIAL_TIMEOUT", 5*time.Second), + envDuration("PGROUTER_DIAL_BACKOFF", 100*time.Millisecond), + envDuration("PGROUTER_BREAKER_COOLDOWN", 5*time.Second), + envInt("PGROUTER_DIAL_RETRIES", 1), + envInt("PGROUTER_BREAKER_THRESHOLD", 3), + ) ln, err := net.Listen("tcp", addr) if err != nil { log.Fatalf("pg-router: listen %s: %v", addr, err) @@ -69,7 +77,7 @@ func main() { log.Printf("pg-router: accept: %v", err) continue } - go handleConn(conn, provider, resolve) + go handleConn(conn, provider, resolve, dialer) } } @@ -203,7 +211,7 @@ func (r clusterStatusReader) ClusterShardStatus(ctx context.Context, ns, cluster return pc.Status.Shards, nil } -func handleConn(clientConn net.Conn, provider router.TopologyProvider, resolve router.BackendResolver) { +func handleConn(clientConn net.Conn, provider router.TopologyProvider, resolve router.BackendResolver, dialer *backendDialer) { defer func() { _ = clientConn.Close() }() raw, params, err := readStartup(clientConn) @@ -234,7 +242,7 @@ func handleConn(clientConn net.Conn, provider router.TopologyProvider, resolve r writePgError(clientConn, "08006", fmt.Sprintf("shard %s unavailable: %v", shardID, err)) return } - server, err := net.DialTimeout("tcp", backend, envDuration("PGROUTER_DIAL_TIMEOUT", 5*time.Second)) + server, err := dialer.Dial(backend) if err != nil { log.Printf("pg-router: dial backend %s (shard %s): %v", backend, shardID, err) writePgError(clientConn, "08006", fmt.Sprintf("cannot reach shard %s (%s): %v", shardID, backend, err)) @@ -374,3 +382,17 @@ func envDuration(k string, def time.Duration) time.Duration { } return d } + +// envInt parses an int env var, falling back to def on absence/parse error. +func envInt(k string, def int) int { + v := os.Getenv(k) + if v == "" { + return def + } + n, err := strconv.Atoi(v) + if err != nil { + log.Printf("pg-router: invalid %s=%q, using %d", k, v, def) + return def + } + return n +} From ee48b49d90ff93ff215a7a040bd4e9f6e1257fef Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Fri, 26 Jun 2026 17:13:20 +0900 Subject: [PATCH 019/109] =?UTF-8?q?feat(router):=20=EC=9D=BD=EA=B8=B0=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20replica=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20?= =?UTF-8?q?=EB=B6=80=ED=92=88(StatusBackendResolver.ResolveRead=20+=20IsRe?= =?UTF-8?q?adOnlyQuery)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StatusBackendResolver가 Ready replica도 추적, ResolveRead가 replica를 round-robin 선택(읽기 확장+primary 부하 경감), replica 없으면 primary 폴백. IsReadOnlyQuery는 토크나이저로 보수적 분류(확실한 읽기만 true; SELECT FOR UPDATE/SHARE·DML 포함 WITH는 쓰기 — 오분류 비용 비대칭). 쿼리 단위 결선은 (E)에서. 백로그 '읽기→replica'. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- internal/router/route_extractor_parser.go | 23 +++++++ .../router/route_extractor_parser_test.go | 26 +++++++ internal/router/topology.go | 67 ++++++++++++++----- internal/router/topology_test.go | 39 +++++++++++ 4 files changed, 137 insertions(+), 18 deletions(-) diff --git a/internal/router/route_extractor_parser.go b/internal/router/route_extractor_parser.go index 05ab404..439c1b1 100644 --- a/internal/router/route_extractor_parser.go +++ b/internal/router/route_extractor_parser.go @@ -42,6 +42,29 @@ func (parserExtractor) ExtractRoutingKey(query, col string) (string, bool) { return "", false } +// IsReadOnlyQuery 는 query 가 *읽기 전용*인지 토큰 단위로 보수적으로 판정한다 — 읽기를 +// replica 로 보내기 위한 분류. 안전 기본은 *false(쓰기→primary)*: 확실히 읽기인 +// 문장만 true. SELECT 의 `FOR UPDATE/SHARE`(잠금)나 DML 을 포함한 WITH(CTE)는 쓰기로 +// 본다. 오분류 비용이 비대칭이기 때문 — 쓰기를 replica 로 보내면 치명적, 읽기를 +// primary 로 보내면 성능만 손해. +func IsReadOnlyQuery(query string) bool { + toks := tokenize(query) + if len(toks) == 0 { + return false + } + switch strings.ToLower(toks[0].text) { + case "select", "show", "values", "table": + // SELECT ... FOR UPDATE/SHARE 는 잠금 획득 → 쓰기로 취급. + for _, t := range toks { + if t.kind == tokIdent && strings.EqualFold(t.text, "for") { + return false + } + } + return true + } + return false +} + type tokKind int const ( diff --git a/internal/router/route_extractor_parser_test.go b/internal/router/route_extractor_parser_test.go index ef85c43..206e92b 100644 --- a/internal/router/route_extractor_parser_test.go +++ b/internal/router/route_extractor_parser_test.go @@ -65,6 +65,32 @@ func TestParserBeatsRegex(t *testing.T) { } } +// TestIsReadOnlyQuery 는 읽기/쓰기 분류가 보수적(확실한 읽기만 true)임을 검증한다. +func TestIsReadOnlyQuery(t *testing.T) { + cases := []struct { + query string + read bool + }{ + {"SELECT v FROM t WHERE id = 'a'", true}, + {" select 1", true}, + {"SHOW search_path", true}, + {"VALUES (1),(2)", true}, + {"TABLE t", true}, + {"SELECT * FROM t FOR UPDATE", false}, // 잠금 → 쓰기 취급 + {"select * from t for share", false}, // 잠금 + {"INSERT INTO t (id) VALUES ('a')", false}, + {"UPDATE t SET v=1", false}, + {"DELETE FROM t", false}, + {"WITH x AS (INSERT INTO t VALUES (1) RETURNING *) SELECT * FROM x", false}, // WITH 보수적=쓰기 + {"", false}, + } + for _, c := range cases { + if got := IsReadOnlyQuery(c.query); got != c.read { + t.Errorf("IsReadOnlyQuery(%q) = %v, want %v", c.query, got, c.read) + } + } +} + // TestParserSelectableViaFactory 는 "parser"/"auto" 선택이 토크나이저를 쓰는지 확인. func TestParserSelectableViaFactory(t *testing.T) { ex, err := NewRouteKeyExtractor(ExtractorParser) diff --git a/internal/router/topology.go b/internal/router/topology.go index f432fad..a5585eb 100644 --- a/internal/router/topology.go +++ b/internal/router/topology.go @@ -118,50 +118,81 @@ type ClusterStatusReader interface { ClusterShardStatus(ctx context.Context, namespace, cluster string) ([]v1alpha1.ShardStatus, error) } -// StatusBackendResolver 는 shard 를 그 *현재 Ready primary* endpoint 로 매핑한다 — -// PostgresCluster.status 의 캐시 스냅샷 기반(failover-aware). Update 는 refresh 루프가, -// Resolve 는 연결마다 호출한다. Ready primary 가 없는 shard(down / failover 중)는 -// error 를 내어 라우터가 클라이언트를 우아하게 실패시키게 한다. +// StatusBackendResolver 는 shard 를 그 *현재 Ready primary* endpoint(쓰기) 또는 Ready +// replica(읽기 분산)로 매핑한다 — PostgresCluster.status 의 캐시 스냅샷 기반 +// (failover-aware). Update 는 refresh 루프가, Resolve/ResolveRead 는 연결마다 호출한다. +// Ready 대상이 없는 shard(down / failover 중)는 error 를 내어 라우터가 클라이언트를 +// 우아하게 실패시키게 한다. type StatusBackendResolver struct { - mu sync.RWMutex - primaries map[string]string // shard 이름 → primary endpoint (Ready 만) + mu sync.Mutex + primaries map[string]string // shard → primary endpoint (Ready 만) + replicas map[string][]string // shard → Ready replica endpoints + rr map[string]int // shard → round-robin 커서 (읽기 분산) } // NewStatusBackendResolver 는 빈 resolver 를 만든다. Update 전에는 모든 Resolve 가 // 에러(아직 status 미수신). func NewStatusBackendResolver() *StatusBackendResolver { - return &StatusBackendResolver{primaries: map[string]string{}} + return &StatusBackendResolver{ + primaries: map[string]string{}, + replicas: map[string][]string{}, + rr: map[string]int{}, + } } -// Update 는 샤드 status 목록으로 primary 매핑을 통째로 교체한다 (Ready primary 만 -// 포함). failover 후 새 status 가 들어오면 자동으로 새 primary 를 가리키게 된다. +// Update 는 샤드 status 목록으로 primary/replica 매핑을 통째로 교체한다 (Ready 만 포함). +// failover 후 새 status 가 들어오면 자동으로 새 primary/replica 를 가리키게 된다. func (r *StatusBackendResolver) Update(shards []v1alpha1.ShardStatus) { - m := make(map[string]string, len(shards)) + pm := make(map[string]string, len(shards)) + rm := make(map[string][]string, len(shards)) for i := range shards { s := shards[i] if s.Primary != nil && s.Primary.Ready && s.Primary.Endpoint != "" { - m[s.Name] = s.Primary.Endpoint + pm[s.Name] = s.Primary.Endpoint + } + for j := range s.Replicas { + rep := s.Replicas[j] + if rep.Ready && rep.Endpoint != "" { + rm[s.Name] = append(rm[s.Name], rep.Endpoint) + } } } r.mu.Lock() - r.primaries = m + r.primaries, r.replicas = pm, rm r.mu.Unlock() } -// Resolve 는 shard 의 현재 Ready primary endpoint 를 반환한다. 없으면 error -// (down / failover 중 / 아직 status 미수신). +// Resolve 는 shard 의 현재 Ready primary endpoint(쓰기 경로)를 반환한다. 없으면 error. func (r *StatusBackendResolver) Resolve(shardID string) (string, error) { - r.mu.RLock() - ep, ok := r.primaries[shardID] - r.mu.RUnlock() - if !ok || ep == "" { + r.mu.Lock() + ep := r.primaries[shardID] + r.mu.Unlock() + if ep == "" { return "", fmt.Errorf("router: shard %q has no Ready primary (down or mid-failover)", shardID) } return ep, nil } +// ResolveRead 는 읽기 쿼리용으로 Ready replica 를 round-robin 선택한다(읽기 확장 + +// primary 부하 경감). replica 가 없으면 primary 로, 그것도 없으면 error 로 폴백한다. +// BackendResolver 시그니처와 호환 — (E) 쿼리 라우팅에서 읽기/쓰기로 분기 사용. +func (r *StatusBackendResolver) ResolveRead(shardID string) (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + if reps := r.replicas[shardID]; len(reps) > 0 { + i := r.rr[shardID] % len(reps) + r.rr[shardID] = (r.rr[shardID] + 1) % (1 << 30) + return reps[i], nil + } + if ep := r.primaries[shardID]; ep != "" { + return ep, nil // replica 부재 → primary 읽기 폴백 (안전). + } + return "", fmt.Errorf("router: shard %q has no Ready replica or primary", shardID) +} + var ( _ TopologyProvider = StaticTopologyProvider{} _ TopologyProvider = (*CRDTopologyProvider)(nil) _ BackendResolver = (*StatusBackendResolver)(nil).Resolve + _ BackendResolver = (*StatusBackendResolver)(nil).ResolveRead ) diff --git a/internal/router/topology_test.go b/internal/router/topology_test.go index 61c3e82..4cf17f5 100644 --- a/internal/router/topology_test.go +++ b/internal/router/topology_test.go @@ -125,3 +125,42 @@ func TestStatusBackendResolver(t *testing.T) { t.Fatal("shard-0 absent after update: expected error") } } + +func TestStatusBackendResolver_ResolveRead(t *testing.T) { + r := NewStatusBackendResolver() + ep := func(pod, e string, rdy bool) v1alpha1.ShardEndpoint { + return v1alpha1.ShardEndpoint{Pod: pod, Endpoint: e, Ready: rdy} + } + p := func(e string) *v1alpha1.ShardEndpoint { x := ep("p", e, true); return &x } + r.Update([]v1alpha1.ShardStatus{ + {Name: "shard-0", Primary: p("p0:5432"), Replicas: []v1alpha1.ShardEndpoint{ + ep("r0a", "r0a:5432", true), ep("r0b", "r0b:5432", true), ep("r0c", "r0c:5432", false), // not ready 제외 + }}, + {Name: "shard-1", Primary: p("p1:5432")}, // replica 없음 → primary 폴백 + {Name: "shard-2"}, // 아무것도 없음 → 에러 + }) + + // 읽기: 두 Ready replica 를 round-robin (primary 아님). + seen := map[string]int{} + for i := 0; i < 4; i++ { + got, err := r.ResolveRead("shard-0") + if err != nil { + t.Fatalf("ResolveRead shard-0: %v", err) + } + if got == "p0:5432" { + t.Fatalf("read routed to primary, want a replica: %s", got) + } + seen[got]++ + } + if len(seen) != 2 || seen["r0a:5432"] != 2 || seen["r0b:5432"] != 2 { + t.Fatalf("round-robin distribution = %v, want 2x each replica", seen) + } + // replica 없으면 primary 폴백. + if got, err := r.ResolveRead("shard-1"); err != nil || got != "p1:5432" { + t.Fatalf("ResolveRead shard-1 = (%q,%v), want primary fallback p1:5432", got, err) + } + // 아무것도 없으면 에러. + if _, err := r.ResolveRead("shard-2"); err == nil { + t.Fatal("ResolveRead shard-2 (none): expected error") + } +} From 3b6f0b36950c31de04d48a6324bf27c8a609b2ed Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Fri, 26 Jun 2026 17:16:49 +0900 Subject: [PATCH 020/109] =?UTF-8?q?feat(router):=20reference=20table=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=8C=85=20(ShardRange.referenceTables=20+?= =?UTF-8?q?=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20=ED=95=B4=EC=86=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShardRangeSpec에 referenceTables 추가(모든 샤드에 복제되는 공통 테이블 선언, CRD/deepcopy/chart 재생성). 라우터: ExtractTables(토크나이저로 FROM/JOIN/INTO/UPDATE 테이블 추출), Topology.ReferenceOnly(쿼리가 reference 테이블만 참조하는지), AnyShard(결정적 첫 샤드). reference-only 쿼리는 샤딩 키 없이 임의 샤드로 → 분산 조인 우회. (E)에서 결선. 백로그 'reference table'. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- api/v1alpha1/shardrange_types.go | 5 ++ api/v1alpha1/zz_generated.deepcopy.go | 5 ++ .../postgres.keiailab.io_shardranges.yaml | 7 ++ .../postgres.keiailab.io_shardranges.yaml | 7 ++ internal/router/reference.go | 86 +++++++++++++++++++ internal/router/reference_test.go | 79 +++++++++++++++++ 6 files changed, 189 insertions(+) create mode 100644 internal/router/reference.go create mode 100644 internal/router/reference_test.go diff --git a/api/v1alpha1/shardrange_types.go b/api/v1alpha1/shardrange_types.go index b72bea0..ddb1e42 100644 --- a/api/v1alpha1/shardrange_types.go +++ b/api/v1alpha1/shardrange_types.go @@ -100,6 +100,11 @@ type ShardRangeSpec struct { // +kubebuilder:validation:MinItems=1 // +kubebuilder:validation:MaxItems=1024 Ranges []ShardRangeEntry `json:"ranges"` + + // ReferenceTables은 이 키스페이스에서 *모든 샤드에 복제*되는 reference 테이블 목록이다. + // 이 테이블만 참조하는 쿼리는 샤딩 키 없이 임의 샤드로 라우팅된다 (분산 조인 우회). + // +optional + ReferenceTables []string `json:"referenceTables,omitempty"` } // ShardRangeStatus은 reconciler 가 관찰한 ShardRange 상태이다. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 8c65997..efd6572 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -1868,6 +1868,11 @@ func (in *ShardRangeSpec) DeepCopyInto(out *ShardRangeSpec) { *out = make([]ShardRangeEntry, len(*in)) copy(*out, *in) } + if in.ReferenceTables != nil { + in, out := &in.ReferenceTables, &out.ReferenceTables + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ShardRangeSpec. diff --git a/charts/postgres-operator/crds/postgres.keiailab.io_shardranges.yaml b/charts/postgres-operator/crds/postgres.keiailab.io_shardranges.yaml index 79ab723..2f8de5a 100644 --- a/charts/postgres-operator/crds/postgres.keiailab.io_shardranges.yaml +++ b/charts/postgres-operator/crds/postgres.keiailab.io_shardranges.yaml @@ -99,6 +99,13 @@ spec: maxItems: 1024 minItems: 1 type: array + referenceTables: + description: |- + ReferenceTables은 이 키스페이스에서 *모든 샤드에 복제*되는 reference 테이블 목록이다. + 이 테이블만 참조하는 쿼리는 샤딩 키 없이 임의 샤드로 라우팅된다 (분산 조인 우회). + items: + type: string + type: array vindex: description: Vindex은 키 → 샤드 매핑 함수 정의이다. properties: diff --git a/config/crd/bases/postgres.keiailab.io_shardranges.yaml b/config/crd/bases/postgres.keiailab.io_shardranges.yaml index 79ab723..2f8de5a 100644 --- a/config/crd/bases/postgres.keiailab.io_shardranges.yaml +++ b/config/crd/bases/postgres.keiailab.io_shardranges.yaml @@ -99,6 +99,13 @@ spec: maxItems: 1024 minItems: 1 type: array + referenceTables: + description: |- + ReferenceTables은 이 키스페이스에서 *모든 샤드에 복제*되는 reference 테이블 목록이다. + 이 테이블만 참조하는 쿼리는 샤딩 키 없이 임의 샤드로 라우팅된다 (분산 조인 우회). + items: + type: string + type: array vindex: description: Vindex은 키 → 샤드 매핑 함수 정의이다. properties: diff --git a/internal/router/reference.go b/internal/router/reference.go new file mode 100644 index 0000000..049ade3 --- /dev/null +++ b/internal/router/reference.go @@ -0,0 +1,86 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +// Package router — reference.go 는 *reference table* 라우팅을 지원한다. reference +// table 은 모든 샤드에 복제된 작은 공통 테이블이라, 그것만 참조하는 쿼리는 샤딩 키가 +// 없어도 임의 샤드로 보낼 수 있다(분산 조인 우회). 본 파일은 쿼리의 테이블 추출과 +// reference-only 판정, 임의 샤드 선택을 제공한다 — (E) 쿼리 라우팅에서 결선. +package router + +import ( + "fmt" + "strings" +) + +// ExtractTables 는 query 에서 FROM/JOIN/INTO/UPDATE 뒤의 테이블 이름을 추출한다 +// (토크나이저 기반 best-effort; schema 한정 `s.t` → "t"). 중복 제거, 등장 순서 유지. +func ExtractTables(query string) []string { + toks := tokenize(query) + var out []string + seen := map[string]bool{} + for i := 0; i+1 < len(toks); i++ { + if toks[i].kind != tokIdent { + continue + } + switch strings.ToLower(toks[i].text) { + case "from", "join", "into", "update": + if name := tableNameAt(toks, i+1); name != "" { + key := strings.ToLower(name) + if !seen[key] { + seen[key] = true + out = append(out, name) + } + } + } + } + return out +} + +// tableNameAt 는 i 위치의 테이블 이름을 반환한다. `schema . table` 이면 table 부분. +func tableNameAt(toks []token, i int) string { + if i >= len(toks) || toks[i].kind != tokIdent { + return "" + } + name := toks[i].text + if i+2 < len(toks) && toks[i+1].kind == tokSym && toks[i+1].text == "." && toks[i+2].kind == tokIdent { + name = toks[i+2].text + } + return name +} + +// IsReferenceTable 는 name 이 이 토폴로지의 reference 테이블인지 (대소문자 무시) 본다. +func (t Topology) IsReferenceTable(name string) bool { + for _, r := range t.Spec.ReferenceTables { + if strings.EqualFold(r, name) { + return true + } + } + return false +} + +// ReferenceOnly 는 query 가 참조하는 테이블이 1개 이상이고 *전부 reference 테이블*인지 +// 판정한다 (그렇다면 샤딩 키 없이 AnyShard 로 라우팅 가능). 테이블을 못 뽑으면 false +// (보수적 — 일반 라우팅 경로로). +func (t Topology) ReferenceOnly(query string) bool { + tables := ExtractTables(query) + if len(tables) == 0 { + return false + } + for _, tbl := range tables { + if !t.IsReferenceTable(tbl) { + return false + } + } + return true +} + +// AnyShard 는 reference-only 쿼리를 보낼 임의(결정적: 첫) 샤드를 반환한다. +func (t Topology) AnyShard() (string, error) { + if len(t.Spec.Ranges) == 0 { + return "", fmt.Errorf("router: no shards in topology") + } + return t.Spec.Ranges[0].Shard, nil +} diff --git a/internal/router/reference_test.go b/internal/router/reference_test.go new file mode 100644 index 0000000..8f2062c --- /dev/null +++ b/internal/router/reference_test.go @@ -0,0 +1,79 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package router + +import ( + "testing" + + "github.com/keiailab/postgres-operator/api/v1alpha1" +) + +func TestExtractTables(t *testing.T) { + cases := []struct { + query string + want []string + }{ + {"SELECT * FROM users WHERE id = 'a'", []string{"users"}}, + {"SELECT * FROM orders o JOIN countries c ON o.cc = c.id", []string{"orders", "countries"}}, + {"INSERT INTO events (id) VALUES ('x')", []string{"events"}}, + {"UPDATE accounts SET v = 1 WHERE id = 'a'", []string{"accounts"}}, + {"SELECT * FROM public.users", []string{"users"}}, + {"SELECT 1", nil}, + } + for _, c := range cases { + got := ExtractTables(c.query) + if len(got) != len(c.want) { + t.Errorf("ExtractTables(%q) = %v, want %v", c.query, got, c.want) + continue + } + for i := range got { + if got[i] != c.want[i] { + t.Errorf("ExtractTables(%q)[%d] = %q, want %q", c.query, i, got[i], c.want[i]) + } + } + } +} + +func TestReferenceRouting(t *testing.T) { + topo := Topology{Spec: v1alpha1.ShardRangeSpec{ + Cluster: "demo", + Keyspace: "default", + ReferenceTables: []string{"countries", "currencies"}, + Ranges: []v1alpha1.ShardRangeEntry{ + {Lo: "0x00000000", Hi: "0x7fffffff", Shard: "shard-0"}, + {Lo: "0x80000000", Hi: "0xffffffff", Shard: "shard-1"}, + }, + }} + + if !topo.IsReferenceTable("countries") || !topo.IsReferenceTable("CURRENCIES") { + t.Fatal("IsReferenceTable should match (case-insensitive)") + } + if topo.IsReferenceTable("users") { + t.Fatal("users is not a reference table") + } + + // reference 테이블만 → reference-only true. + if !topo.ReferenceOnly("SELECT name FROM countries") { + t.Fatal("SELECT FROM countries should be reference-only") + } + if !topo.ReferenceOnly("SELECT * FROM countries c JOIN currencies x ON c.cur = x.id") { + t.Fatal("countries JOIN currencies should be reference-only") + } + // 샤딩 테이블 섞이면 false. + if topo.ReferenceOnly("SELECT * FROM users u JOIN countries c ON u.cc = c.id") { + t.Fatal("users JOIN countries should NOT be reference-only") + } + // 테이블 못 뽑으면 false. + if topo.ReferenceOnly("SELECT 1") { + t.Fatal("SELECT 1 should not be reference-only") + } + + // AnyShard 는 결정적으로 첫 샤드. + if s, err := topo.AnyShard(); err != nil || s != "shard-0" { + t.Fatalf("AnyShard = (%q,%v), want shard-0", s, err) + } +} From b53dea6ad11701fb0aa53d9ef3da04a5cf9003ab Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Fri, 26 Jun 2026 17:19:33 +0900 Subject: [PATCH 021/109] =?UTF-8?q?fix(router):=20scatter=20merge=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=9D=B8=EC=A7=80=20=EC=A0=95=EB=A0=AC=20?= =?UTF-8?q?+=20LIMIT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MergeOrderBy의 fmt.Sprintf 문자열 비교 버그(숫자 '10'<'9') 수정 — compareValues로 숫자는 수치 비교, []byte/문자열은 사전식. ScatterGather.Limit로 merge 후 행 수 제한(LIMIT pushdown 근사). 백로그 'scatter-gather merge 개선'. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- internal/router/scatter.go | 82 ++++++++++++++++++++++----- internal/router/scatter_merge_test.go | 57 +++++++++++++++++++ 2 files changed, 124 insertions(+), 15 deletions(-) create mode 100644 internal/router/scatter_merge_test.go diff --git a/internal/router/scatter.go b/internal/router/scatter.go index d3b47be..39481a5 100644 --- a/internal/router/scatter.go +++ b/internal/router/scatter.go @@ -18,6 +18,7 @@ import ( "errors" "fmt" "sort" + "strings" "sync" ) @@ -82,6 +83,9 @@ type ScatterGather struct { Policy FailurePolicy // Merge 는 결과 합치기 전략 (default MergeConcat). Merge MergeStrategy + // Limit 은 >0 이면 merge 결과의 행 수를 제한한다 (LIMIT pushdown 근사 — 각 샤드는 + // 전량 반환하고 merge 후 자른다; 진짜 per-shard LIMIT 주입은 planner 책임). + Limit int } // NewScatterGather 는 ScatterGather 인스턴스를 반환한다. Shard 가 nil 이면 @@ -139,7 +143,11 @@ func (s *ScatterGather) Execute(ctx context.Context, query string, shards []Shar if s.Policy == BestEffort && len(collected) == 0 { return nil, fmt.Errorf("%w: all %d shards failed", ErrShardFailure, len(failed)) } - return s.merge(shards, collected), nil + merged := s.merge(shards, collected) + if s.Limit > 0 && len(merged) > s.Limit { + merged = merged[:s.Limit] + } + return merged, nil } func (s *ScatterGather) merge(order []ShardID, collected map[ShardID][]Row) []Row { @@ -177,25 +185,69 @@ func mergeOrderBy(order []ShardID, collected map[ShardID][]Row) []Row { } func cmpFirstValue(a, b Row) int { - if len(a.Values) == 0 && len(b.Values) == 0 { + switch { + case len(a.Values) == 0 && len(b.Values) == 0: return 0 - } - if len(a.Values) == 0 { + case len(a.Values) == 0: return -1 - } - if len(b.Values) == 0 { + case len(b.Values) == 0: return 1 } - as := fmt.Sprintf("%v", a.Values[0]) - bs := fmt.Sprintf("%v", b.Values[0]) - switch { - case as < bs: - return -1 - case as > bs: - return 1 - default: - return 0 + return compareValues(a.Values[0], b.Values[0]) +} + +// compareValues 는 두 값을 *타입 인지* 비교한다 — 숫자는 수치로 비교(문자열 비교 시 +// "10" < "9" 가 되는 버그 회피), []byte/문자열은 사전식, 그 외는 %v fallback. +func compareValues(a, b any) int { + if af, aok := toFloat(a); aok { + if bf, bok := toFloat(b); bok { + switch { + case af < bf: + return -1 + case af > bf: + return 1 + default: + return 0 + } + } + } + return strings.Compare(toStr(a), toStr(b)) +} + +func toFloat(v any) (float64, bool) { + switch n := v.(type) { + case int: + return float64(n), true + case int8: + return float64(n), true + case int16: + return float64(n), true + case int32: + return float64(n), true + case int64: + return float64(n), true + case uint: + return float64(n), true + case uint32: + return float64(n), true + case uint64: + return float64(n), true + case float32: + return float64(n), true + case float64: + return n, true + } + return 0, false +} + +func toStr(v any) string { + switch s := v.(type) { + case []byte: + return string(s) + case string: + return s } + return fmt.Sprintf("%v", v) } // 컴파일 타임 interface 만족 검사. diff --git a/internal/router/scatter_merge_test.go b/internal/router/scatter_merge_test.go new file mode 100644 index 0000000..9078b7a --- /dev/null +++ b/internal/router/scatter_merge_test.go @@ -0,0 +1,57 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package router + +import ( + "context" + "testing" +) + +// TestScatterGather_OrderByNumeric 는 MergeOrderBy 가 *수치* 비교를 함을 검증한다 +// — 과거 fmt.Sprintf 문자열 비교는 9 보다 10 을 앞에 두는 버그가 있었다("10" < "9"). +func TestScatterGather_OrderByNumeric(t *testing.T) { + sg := &ScatterGather{ + Merge: MergeOrderBy, + Shard: &fakeShardExecutor{responses: map[ShardID][]Row{ + "s0": {{Shard: "s0", Values: []any{int64(10)}}}, + "s1": {{Shard: "s1", Values: []any{int64(9)}}}, + "s2": {{Shard: "s2", Values: []any{int64(100)}}}, + }}, + } + rows, err := sg.Execute(context.Background(), "q", []ShardID{"s0", "s1", "s2"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + want := []int64{9, 10, 100} + if len(rows) != 3 { + t.Fatalf("len(rows)=%d, want 3", len(rows)) + } + for i, w := range want { + if rows[i].Values[0].(int64) != w { + t.Fatalf("rows[%d]=%v, want %d (numeric order)", i, rows[i].Values[0], w) + } + } +} + +// TestScatterGather_Limit 는 Limit 가 merge 결과를 자름을 검증한다. +func TestScatterGather_Limit(t *testing.T) { + sg := &ScatterGather{ + Merge: MergeConcat, + Limit: 3, + Shard: &fakeShardExecutor{responses: map[ShardID][]Row{ + "s0": {{Values: []any{1}}, {Values: []any{2}}}, + "s1": {{Values: []any{3}}, {Values: []any{4}}}, + }}, + } + rows, err := sg.Execute(context.Background(), "q", []ShardID{"s0", "s1"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if len(rows) != 3 { + t.Fatalf("len(rows)=%d, want 3 (Limit)", len(rows)) + } +} From cd5f5164d3a0d9649fbf5744ae88536886ffd896 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Fri, 26 Jun 2026 17:22:05 +0900 Subject: [PATCH 022/109] =?UTF-8?q?feat(router):=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EA=B2=B0=EC=A0=95=20=EC=97=94?= =?UTF-8?q?=EC=A7=84=20QueryRouter=20(E=20=EC=A6=9D=EB=B6=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 한 쿼리에 대해 토폴로지(vindex)+라우팅키 추출+reference-table+읽기/쓰기 분류+백엔드 해소를 단일 RouteDecision으로 합성하는 routing brain. reference-only→AnyShard, 키 추출→단일 shard, 키 부재→Scatter+ErrNoRoutingKey, 읽기→replica resolver. 순수·동기라 완전 검증 가능 — (E) 프로토콜 종단(vtgate급 자체 인증+백엔드 풀+결과 재조립)은 별 대작업으로 백로그 유지하되, 그 핵심 결정 로직은 본 엔진으로 확정. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- internal/router/query_router.go | 93 ++++++++++++++++++++++++++++ internal/router/query_router_test.go | 93 ++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 internal/router/query_router.go create mode 100644 internal/router/query_router_test.go diff --git a/internal/router/query_router.go b/internal/router/query_router.go new file mode 100644 index 0000000..2910445 --- /dev/null +++ b/internal/router/query_router.go @@ -0,0 +1,93 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +// Package router — query_router.go 는 *쿼리 라우팅 결정 엔진*("routing brain")이다. +// 한 쿼리에 대해 토폴로지(vindex) + 라우팅키 추출 + reference-table + 읽기/쓰기 분류 + +// 백엔드 해소를 합성해 단일 RouteDecision 을 낸다. +// +// 이것은 (E) 메시지 인지 프록시의 *핵심*이다 — full PostgreSQL 프로토콜 종단(자체 인증 + +// 백엔드 연결 풀 + 결과 재조립, vtgate급)은 별 대작업이지만, 프록시가 쿼리를 읽을 수 +// 있게 되면 본 엔진을 호출해 어디로 보낼지 결정한다. 본 파일은 *순수·동기*(network 없음) +// 라 완전히 단위 검증 가능하다 — 종단 인프라와 독립. +package router + +import ( + "errors" + "fmt" +) + +// ErrNoRoutingKey 는 단일 shard 키를 못 뽑은 경우(scatter-gather 필요)이다. +var ErrNoRoutingKey = errors.New("router: no single-shard routing key (scatter-gather required)") + +// RouteDecision 은 한 쿼리의 라우팅 결정이다. +type RouteDecision struct { + // Shard 는 대상 shard 이름. Scatter=true 면 비어 있다. + Shard string + // Backend 는 해소된 backend "host:port". Scatter=true 면 비어 있다. + Backend string + // Read 는 읽기 전용 쿼리 여부(가능하면 replica 로 라우팅됨). + Read bool + // Scatter 는 단일 shard 로 좁혀지지 않아 fan-out 이 필요함을 뜻한다(키 부재). + Scatter bool +} + +// QueryRouter 는 토폴로지 + extractor + 백엔드 resolver 를 쿼리 라우팅 결정으로 합성한다. +type QueryRouter struct { + // Topology 는 key→shard(vindex) + reference table 정보. + Topology Topology + // Extractor 는 쿼리에서 샤딩 키를 뽑는다(regex/parser/auto). + Extractor RouteKeyExtractor + // Write 는 shard→primary(쓰기) 백엔드 resolver. + Write BackendResolver + // Read 는 shard→replica(읽기) 백엔드 resolver. nil 이면 Write 를 사용. + Read BackendResolver +} + +// Route 는 한 쿼리의 라우팅 결정을 낸다: +// +// 1. reference-only 쿼리(복제 테이블만 참조) → 키 없이 AnyShard. +// 2. 그 외 → 샤딩 키 추출 → 단일 shard. 키가 없으면 Scatter=true + ErrNoRoutingKey +// (호출자가 scatter-gather 또는 거부 선택). +// +// 읽기 전용 쿼리(IsReadOnlyQuery)는 Read resolver(있으면)로 replica 에 분산한다. +func (qr QueryRouter) Route(query string) (RouteDecision, error) { + read := IsReadOnlyQuery(query) + pick := qr.Write + if read && qr.Read != nil { + pick = qr.Read + } + if pick == nil { + return RouteDecision{}, fmt.Errorf("router: QueryRouter has no backend resolver") + } + + // 1) reference-only → 임의 shard. + if qr.Topology.ReferenceOnly(query) { + return qr.decide(qr.Topology.AnyShard())(pick, read) + } + + // 2) 샤딩 키 → 단일 shard. + col := qr.Topology.Spec.Vindex.Column + key, ok := qr.Extractor.ExtractRoutingKey(query, col) + if !ok { + return RouteDecision{Read: read, Scatter: true}, ErrNoRoutingKey + } + return qr.decide(qr.Topology.Shard(key))(pick, read) +} + +// decide 는 (shard, err) 로부터 backend 를 해소해 RouteDecision 을 만드는 클로저를 +// 반환한다 (reference / key 경로의 공통 꼬리). +func (qr QueryRouter) decide(shard string, shardErr error) func(BackendResolver, bool) (RouteDecision, error) { + return func(pick BackendResolver, read bool) (RouteDecision, error) { + if shardErr != nil { + return RouteDecision{}, shardErr + } + backend, err := pick(shard) + if err != nil { + return RouteDecision{}, err + } + return RouteDecision{Shard: shard, Backend: backend, Read: read}, nil + } +} diff --git a/internal/router/query_router_test.go b/internal/router/query_router_test.go new file mode 100644 index 0000000..1069ade --- /dev/null +++ b/internal/router/query_router_test.go @@ -0,0 +1,93 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package router + +import ( + "errors" + "testing" + + "github.com/keiailab/postgres-operator/api/v1alpha1" +) + +func testQueryRouter() QueryRouter { + topo := Topology{Spec: v1alpha1.ShardRangeSpec{ + Cluster: "demo", + Keyspace: "default", + Vindex: v1alpha1.VindexSpec{Type: v1alpha1.VindexTypeHash, Column: "tenant_id", Function: "murmur3"}, + ReferenceTables: []string{"countries"}, + Ranges: []v1alpha1.ShardRangeEntry{ + {Lo: "0x00000000", Hi: "0x7fffffff", Shard: "shard-0"}, + {Lo: "0x80000000", Hi: "0xffffffff", Shard: "shard-1"}, + }, + }} + write := func(s string) (string, error) { return s + "-primary:5432", nil } + read := func(s string) (string, error) { return s + "-replica:5432", nil } + parser, _ := NewRouteKeyExtractor(ExtractorParser) + return QueryRouter{Topology: topo, Extractor: parser, Write: write, Read: read} +} + +func TestQueryRouter_WriteRoutesToPrimaryShard(t *testing.T) { + qr := testQueryRouter() + d, err := qr.Route("UPDATE t SET v = 1 WHERE tenant_id = 'alice'") + if err != nil { + t.Fatalf("Route: %v", err) + } + if d.Read { + t.Fatal("UPDATE should not be Read") + } + if d.Shard == "" || d.Backend != d.Shard+"-primary:5432" { + t.Fatalf("write decision = %+v, want primary backend", d) + } +} + +func TestQueryRouter_ReadRoutesToReplica(t *testing.T) { + qr := testQueryRouter() + d, err := qr.Route("SELECT v FROM t WHERE tenant_id = 'alice'") + if err != nil { + t.Fatalf("Route: %v", err) + } + if !d.Read { + t.Fatal("SELECT should be Read") + } + if d.Backend != d.Shard+"-replica:5432" { + t.Fatalf("read decision = %+v, want replica backend", d) + } +} + +func TestQueryRouter_ReferenceOnlyUsesAnyShard(t *testing.T) { + qr := testQueryRouter() + d, err := qr.Route("SELECT name FROM countries") + if err != nil { + t.Fatalf("Route: %v", err) + } + if d.Shard != "shard-0" { // AnyShard = 결정적 첫 샤드 + t.Fatalf("reference query shard = %q, want shard-0", d.Shard) + } + if !d.Read { + t.Fatal("SELECT countries should be Read") + } +} + +func TestQueryRouter_NoKeySignalsScatter(t *testing.T) { + qr := testQueryRouter() + d, err := qr.Route("SELECT * FROM t") // 키 없음, reference 아님 + if !errors.Is(err, ErrNoRoutingKey) { + t.Fatalf("Route(no key) err = %v, want ErrNoRoutingKey", err) + } + if !d.Scatter { + t.Fatal("no-key decision should set Scatter") + } +} + +func TestQueryRouter_BackendErrorPropagates(t *testing.T) { + qr := testQueryRouter() + qr.Write = func(string) (string, error) { return "", errors.New("shard down") } + // 쓰기 쿼리 → Write resolver 에러 전파. + if _, err := qr.Route("UPDATE t SET v=1 WHERE tenant_id='bob'"); err == nil { + t.Fatal("expected backend error to propagate") + } +} From db04bdb86506f99f1e2fb4ecd685c9abe31062ea Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Fri, 26 Jun 2026 17:25:26 +0900 Subject: [PATCH 023/109] =?UTF-8?q?docs(router):=20=EB=B0=B1=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EB=B0=B0=EC=B9=98=20=EC=A7=84=ED=96=89=20=EC=83=81?= =?UTF-8?q?=ED=99=A9=20+=20=EB=B3=B4=EB=A5=98=20=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EC=82=AC=EC=9C=A0=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-06-26 세션 백로그 처리 결과 — 완료 7건(풀링·HA dialer·읽기replica 부품·reference table·scatter merge·QueryRouter), 보류 3건(per-shard primary Service·watch·failover lease P2-T3, 무검증 랜딩 금지 사유)를 ROUTER-GAP-ANALYSIS §6 + WORK_HANDOFF §6.4에 기록. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/WORK_HANDOFF.ko.md | 14 ++++++++++++++ docs/sharding/ROUTER-GAP-ANALYSIS.ko.md | 7 +++++++ 2 files changed, 21 insertions(+) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index b969900..4b4b284 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -185,6 +185,20 @@ Windows 호스트에서는 `-v E:\keiailab\postgres-operator:/workspace`처럼 - **샤드 장애 회복력 슬라이스 완료** (working tree): "라우팅은 백엔드 커넥션 확보가 전제인데 샤드가 죽어 있으면?"에 대응. ① **key→shard(토폴로지)와 shard→backend(resolver) 분리**(`topology.go` 재작성) ② **`StatusBackendResolver`** — `PostgresCluster.status.shards[].primary.endpoint`(Ready만)에서 백엔드 해소 → **failover-aware**(operator가 replica 승격+status 갱신하면 라우터가 따라감). primary 부재/not-ready → 에러 ③ pg-router: `PGROUTER_BACKEND=env|template|status` 선택, **dial 타임아웃**, **우아한 PG `ErrorResponse`**(조용한 drop 금지) ④ `ClusterStatusReader` 인터페이스로 K8s 격리, refresh 루프가 토폴로지+status 동시 hot-reload ⑤ 매니페스트: RBAC에 `postgresclusters` 추가, deployment `PGROUTER_BACKEND=status`. **검증**: build/vet/test ✅ gofmt clean go.mod 무변경 + kustomize 렌더 ✅. - **향후 대작업은 백로그로 기록**: `docs/sharding/ROUTER-GAP-ANALYSIS.ko.md §6` — (E) 프로토콜 종단(vtgate급), 읽기→replica, scatter-gather 실연결, reference table, resharding 데이터 이동, 2PC, 커넥션 풀링, stable primary Service, watch 기반 hot-reload, failover lease P2-T3, 성능 실측 등. 능력 사다리(§4)에 매핑. +### 6.4 백로그 배치 처리 (2026-06-26, 커밋됨) + +"하나씩 다 처리" 요청으로 백로그를 진행. **검증·커밋 완료(7건)**: +1. `perf(router)` 커넥션 풀링 — SQLShardExecutor shard별 *sql.DB 캐시(per-call open/close 제거). +2. `feat(router)` 라우터 HA — 백엔드 dial retry/backoff + per-backend circuit-breaker(dial/clock 주입 검증). +3. `feat(router)` 읽기→replica *부품* — StatusBackendResolver.ResolveRead(round-robin) + IsReadOnlyQuery(보수적 분류). +4. `feat(router)` reference table — ShardRange.referenceTables(CRD) + ExtractTables/ReferenceOnly/AnyShard. +5. `fix(router)` scatter merge — 타입 인지 정렬(숫자 버그 수정) + Limit. +6. `feat(router)` **QueryRouter** 라우팅 결정 엔진 — extractor+토폴로지+reference+read/write+resolver 합성(E 핵심). + +전부 build/vet/test ✅, gofmt clean, go.mod 무변경(reference table만 CRD/deepcopy/chart 재생성). + +**보류(3건, 무검증 랜딩 금지)**: per-shard primary Service(#5, 운영자 failover 경로+라이브 검증), watch 기반 hot-reload(#7, polling 최적화·informer 단위검증 난), failover lease P2-T3(#9, 고위험 failover 경로 리팩터·라이브 chaos drill 필수). 사유는 태스크/백로그에 기록. + --- ## 7. 용어집 diff --git a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md index b0c87f8..f44f111 100644 --- a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md +++ b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md @@ -118,6 +118,13 @@ parameterized/`t.col`/주석·문자열 내부 오인 방지까지 검증. **기 > "더 좋은 방향"이지만 규모가 큰 후속 작업을 *미리 기록*해 둔다(사용자 요청 2026-06-26). > 각 항목은 능력 사다리(§4) 단계 또는 회복력/운영 축에 매핑된다. 우선순위는 가변. +> +> **2026-06-26 세션 진행** (검증·커밋 완료): 커넥션 풀링(D) ✅ · 라우터 HA(dial retry/backoff +> + circuit-breaker) ✅ · 읽기→replica *부품*(StatusBackendResolver.ResolveRead + +> IsReadOnlyQuery) ✅ · reference table *부품*(CRD referenceTables + ExtractTables/ +> ReferenceOnly/AnyShard) ✅ · scatter merge(타입 정렬 + LIMIT) ✅ · **(E) 라우팅 결정 +> 엔진 QueryRouter** ✅. — 이들의 *쿼리 단위 결선*(프록시가 실제로 호출)은 (E) 프로토콜 +> 종단이 되어야 완성. 아래 항목은 그 종단/운영-코어/라이브검증 필요분. **라우팅 핵심 (능력 사다리)** - [ ] **(E) 프로토콜 종단 — 쿼리 단위 라우팅 (vtgate급, 최대 작업)**: 라우터가 클라이언트 연결을 종단하고 자체 인증 + 백엔드 연결 풀 + 결과 재조립. 현재 `RouteKeyExtractor`(regex/parser/auto)가 그 부품. *왜 큰가*: PG는 인증→쿼리 순서라, 쿼리 내용으로 라우팅하려면 라우터가 PG 서버를 완전히 흉내내야 함. 사다리 1~2단계의 진짜 완성. From 5dae032beb6bdb70e6deb51ad12c527f61ed98fd Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Fri, 26 Jun 2026 18:12:32 +0900 Subject: [PATCH 024/109] =?UTF-8?q?feat(router):=20consistent-hash=20vinde?= =?UTF-8?q?x=20(=EB=A6=AC=EB=B0=B8=EB=9F=B0=EC=8B=B1=20=EC=B9=9C=ED=99=94?= =?UTF-8?q?=EC=A0=81=20=EC=83=A4=EB=94=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ErrVindexUnsupported로 막혀 있던 consistent-hash를 해시 링으로 구현 — 각 샤드를 VirtualNodes개 가상 노드로 링에 흩뿌리고 키를 시계방향 최근접 노드의 샤드로. 샤드 추가 시 ~1/N 키만 이동(테스트: 3→4 샤드에 29%만 이동, modulo 해시는 ~75%). murmur3/fnv/crc32 재사용, VirtualNodes 미설정 시 기본 128. NewConsistentHashRing은 라우터가 캐시 가능. 백로그 'consistent-hash vindex'. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- internal/router/vindex.go | 5 +- internal/router/vindex_consistent.go | 101 ++++++++++++++++++++++ internal/router/vindex_consistent_test.go | 97 +++++++++++++++++++++ 3 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 internal/router/vindex_consistent.go create mode 100644 internal/router/vindex_consistent_test.go diff --git a/internal/router/vindex.go b/internal/router/vindex.go index 436105c..a48729b 100644 --- a/internal/router/vindex.go +++ b/internal/router/vindex.go @@ -38,7 +38,8 @@ var ErrVindexNoMatch = errors.New("router: key did not match any range") // // - hash: function(key) % 2^32 → uint32, ranges 의 [Lo, Hi] hex 와 비교 (포함 비교) // - range: key 자체를 ranges 의 [Lo, Hi] 와 사전식 비교 -// - consistent-hash: ErrVindexUnsupported (D.8.2 scope 외, P3+) +// - consistent-hash: function(shard:vnode) 로 만든 해시 링 위에서 key 를 시계방향 +// 으로 가장 가까운 virtual node 의 shard 로 (vindex_consistent.go) // - lookup: ErrVindexUnsupported (ShardLookup CRD P3+) // // 본 함수는 결정적 (deterministic) — 동일 spec + 동일 key 에서 동일 shard 반환. @@ -49,7 +50,7 @@ func ResolveShard(spec v1alpha1.ShardRangeSpec, key string) (string, error) { case v1alpha1.VindexTypeRange: return resolveRange(spec, key) case v1alpha1.VindexTypeConsistentHash: - return "", fmt.Errorf("%w: consistent-hash (deferred to P3+)", ErrVindexUnsupported) + return resolveConsistentHash(spec, key) case v1alpha1.VindexTypeLookup: return "", fmt.Errorf("%w: lookup (ShardLookup CRD P3+)", ErrVindexUnsupported) default: diff --git a/internal/router/vindex_consistent.go b/internal/router/vindex_consistent.go new file mode 100644 index 0000000..26c291f --- /dev/null +++ b/internal/router/vindex_consistent.go @@ -0,0 +1,101 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +// Package router — vindex_consistent.go 는 *consistent-hash* vindex 를 구현한다. +// +// 일반 해시(`function(key) % 2^32` → [Lo,Hi] 범위)는 샤드를 추가/제거하면 키 대부분이 +// 다른 샤드로 옮겨간다(대규모 데이터 이동). consistent-hash 는 각 샤드를 해시 *링* 위의 +// VirtualNodes 개 가상 노드로 흩뿌리고, 키를 시계방향으로 가장 가까운 가상 노드의 +// 샤드로 보낸다 — 샤드 1개 추가 시 *약 1/N 의 키만* 이동한다(리밸런싱 친화적, Citus/ +// Vitess 와 동일 계열). +// +// 본 구현은 ShardRangeSpec.Ranges 의 *샤드 집합* + Vindex.Function(murmur3/fnv/crc32) +// + Vindex.VirtualNodes 만 사용한다 (Lo/Hi 범위는 consistent-hash 에서 무의미). +package router + +import ( + "fmt" + "sort" + "strconv" + + "github.com/keiailab/postgres-operator/api/v1alpha1" +) + +// defaultVirtualNodes 는 VirtualNodes 미지정(0) 시 가상 노드 수. 클수록 분포가 고르나 +// 링 구성 비용 증가. CRD 는 64~65536 을 강제하므로 0 은 "미설정" 의미. +const defaultVirtualNodes = 128 + +// chPoint 는 해시 링의 한 점(가상 노드)이다. +type chPoint struct { + hash uint32 + shard string +} + +// ConsistentHashRing 은 샤드 집합으로 구성한 해시 링이다. 동일 spec 에서 결정적. +// 라우터는 토폴로지 로드 시 1회 구성해 캐시할 수 있다 (ResolveShard 는 매 호출 구성 — +// 캐싱은 호출자 최적화). +type ConsistentHashRing struct { + points []chPoint // hash 오름차순 정렬 +} + +// NewConsistentHashRing 은 spec 의 샤드 + VirtualNodes + 해시 함수로 링을 구성한다. +func NewConsistentHashRing(spec v1alpha1.ShardRangeSpec) (*ConsistentHashRing, error) { + vnodes := int(spec.Vindex.VirtualNodes) + if vnodes <= 0 { + vnodes = defaultVirtualNodes + } + // Ranges 에서 등장 순서대로 distinct shard 수집. + seen := make(map[string]bool) + var shards []string + for _, r := range spec.Ranges { + if r.Shard != "" && !seen[r.Shard] { + seen[r.Shard] = true + shards = append(shards, r.Shard) + } + } + if len(shards) == 0 { + return nil, fmt.Errorf("%w: consistent-hash has no shards", ErrVindexNoMatch) + } + points := make([]chPoint, 0, len(shards)*vnodes) + for _, s := range shards { + for v := 0; v < vnodes; v++ { + h, err := hashKey(spec.Vindex.Function, s+":"+strconv.Itoa(v)) + if err != nil { + return nil, err + } + points = append(points, chPoint{hash: h, shard: s}) + } + } + sort.Slice(points, func(i, j int) bool { + if points[i].hash != points[j].hash { + return points[i].hash < points[j].hash + } + return points[i].shard < points[j].shard // 충돌 시 결정성 + }) + return &ConsistentHashRing{points: points}, nil +} + +// Lookup 은 key 해시 h 에 대해 시계방향으로 가장 가까운 가상 노드의 shard 를 반환한다. +func (r *ConsistentHashRing) Lookup(h uint32) string { + i := sort.Search(len(r.points), func(i int) bool { return r.points[i].hash >= h }) + if i == len(r.points) { + i = 0 // 링 wrap-around. + } + return r.points[i].shard +} + +// resolveConsistentHash 는 ResolveShard 의 consistent-hash 분기다. +func resolveConsistentHash(spec v1alpha1.ShardRangeSpec, key string) (string, error) { + ring, err := NewConsistentHashRing(spec) + if err != nil { + return "", err + } + h, err := hashKey(spec.Vindex.Function, key) + if err != nil { + return "", err + } + return ring.Lookup(h), nil +} diff --git a/internal/router/vindex_consistent_test.go b/internal/router/vindex_consistent_test.go new file mode 100644 index 0000000..3b6102a --- /dev/null +++ b/internal/router/vindex_consistent_test.go @@ -0,0 +1,97 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package router + +import ( + "fmt" + "testing" + + "github.com/keiailab/postgres-operator/api/v1alpha1" +) + +func chSpec(shards ...string) v1alpha1.ShardRangeSpec { + spec := v1alpha1.ShardRangeSpec{ + Vindex: v1alpha1.VindexSpec{ + Type: v1alpha1.VindexTypeConsistentHash, + Function: v1alpha1.VindexHashMurmur3, + VirtualNodes: 160, + }, + } + for _, s := range shards { + spec.Ranges = append(spec.Ranges, v1alpha1.ShardRangeEntry{Lo: "0", Hi: "0", Shard: s}) + } + return spec +} + +// TestConsistentHash_Deterministic 는 같은 key 가 항상 같은 shard 로 가고, 모든 shard +// 가 사용됨을 검증한다. +func TestConsistentHash_Deterministic(t *testing.T) { + spec := chSpec("shard-0", "shard-1", "shard-2") + seen := map[string]bool{} + for i := 0; i < 600; i++ { + key := fmt.Sprintf("tenant-%d", i) + a, err := ResolveShard(spec, key) + if err != nil { + t.Fatalf("ResolveShard(%q): %v", key, err) + } + b, _ := ResolveShard(spec, key) + if a != b { + t.Fatalf("non-deterministic: %q → %q vs %q", key, a, b) + } + seen[a] = true + } + if len(seen) != 3 { + t.Fatalf("not all shards used: %v", seen) + } +} + +// TestConsistentHash_MinimalMovement 는 consistent-hash 의 핵심 속성을 증명한다 — +// 샤드 1개 추가 시 *약 1/N 의 키만* 이동한다(modulo 해시는 ~3/4 가 이동). 이게 +// 리밸런싱 친화성의 근거. +func TestConsistentHash_MinimalMovement(t *testing.T) { + const n = 4000 + before := chSpec("shard-0", "shard-1", "shard-2") + after := chSpec("shard-0", "shard-1", "shard-2", "shard-3") + + moved := 0 + for i := 0; i < n; i++ { + key := fmt.Sprintf("tenant-%d", i) + a, _ := ResolveShard(before, key) + b, _ := ResolveShard(after, key) + if a != b { + moved++ + } + } + frac := float64(moved) / float64(n) + // 3→4 샤드: 이상적 이동 비율 ≈ 1/4. virtual node 분산 편차를 고려해 넉넉히 0.40 상한, + // 0.10 하한(전혀 안 옮기면 분포가 깨진 것). modulo 해시라면 ~0.75 가 옮겨졌을 것. + if frac < 0.10 || frac > 0.40 { + t.Fatalf("moved fraction = %.3f, want ~0.25 (in [0.10, 0.40]); modulo hash 였다면 ~0.75", frac) + } + t.Logf("샤드 3→4 추가 시 이동 키 비율: %.1f%% (이상적 25%%)", frac*100) +} + +// TestConsistentHash_DefaultVirtualNodes 는 VirtualNodes=0 일 때 기본값으로 동작함을 +// 검증한다. +func TestConsistentHash_DefaultVirtualNodes(t *testing.T) { + spec := chSpec("a", "b") + spec.Vindex.VirtualNodes = 0 // 미설정 → defaultVirtualNodes + ring, err := NewConsistentHashRing(spec) + if err != nil { + t.Fatalf("NewConsistentHashRing: %v", err) + } + if len(ring.points) != 2*defaultVirtualNodes { + t.Fatalf("ring points = %d, want %d", len(ring.points), 2*defaultVirtualNodes) + } +} + +// TestConsistentHash_NoShards 는 샤드가 없으면 에러임을 검증한다. +func TestConsistentHash_NoShards(t *testing.T) { + if _, err := ResolveShard(chSpec(), "k"); err == nil { + t.Fatal("no shards: expected error") + } +} From 7829033f762a0f7d1707f7a2c36eb4978c62f9f4 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sat, 27 Jun 2026 00:28:39 +0900 Subject: [PATCH 025/109] =?UTF-8?q?docs(perf):=201=EC=B0=A8=20=EB=A1=9C?= =?UTF-8?q?=EC=BB=AC=20=EC=8B=A4=EC=B8=A1=20baseline=20(single-shard,=20ki?= =?UTF-8?q?nd)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 호스트 kind에 오퍼레이터 배포 → 단일샤드 PostgresCluster(PG18.3) Ready → pgbench. 첫 제품 수치: tpcb-like(RW) 496/646/889 TPS @ c=8/16/32, select-only(RO) 9035/10493 TPS @ c=32/64. 환경 metadata(16vCPU/7.6GiB WSL2, local-path, client co-located)+해석(쓰기는 WAL fsync 바운드, 읽기 ~10-18배)+재현 명령 기록. percentile/분산/sysbench/전용 PV는 후속. baseline.md 상태 skeleton→1차 실측. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/perf/baseline.md | 52 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/docs/perf/baseline.md b/docs/perf/baseline.md index 99c3fc0..1e4c361 100644 --- a/docs/perf/baseline.md +++ b/docs/perf/baseline.md @@ -2,7 +2,7 @@ > ROADMAP G5 §183 의 *측정 schema + 결과 placeholder*. 실 측정값은 cluster + 분산 인스턴스 도달 후 별 turn 에서 채워짐. 본 문서는 *재현 가능한 측정 protocol* 표준화 + 결과 형식 sealing 목적. > -> **상태**: skeleton (pending live measurement) +> **상태**: 1차 로컬 실측 완료 (2026-06-27, §3.0 — single-shard). 분산/sysbench/2PC/전용 PV 는 pending. ## 1. 측정 환경 명시 표준 @@ -51,7 +51,55 @@ | I-RR | REPEATABLE READ (SI) | write skew 일부 | | I-SER | SERIALIZABLE (SSI) | anomaly 0 | -## 3. 결과 표 — pending live measurement +## 3. 결과 표 + +> **1차 로컬 실측 추가됨 (2026-06-27, §3.0).** §3.1~ 는 여전히 schema placeholder +> (분산 N-shard / sysbench / 2PC / 전용 PV 미측정). + +### 3.0 실측 1차 — 로컬 kind 단일샤드 (2026-06-27) + +오퍼레이터를 호스트 kind(Docker Desktop/WSL2)에 배포 → 단일샤드 PostgresCluster Ready +→ pgbench. *제품 첫 baseline 수치* (single-shard HA-PG-on-K8s). + +**환경**: + +| 필드 | 값 | +|---|---| +| date | 2026-06-27 | +| cluster | 로컬 kind v0.32 (`pgop-dev`, 단일 노드) on Docker Desktop / WSL2 | +| operator | local build `pgop:dev` (브랜치 `chore/ha-pitr-e2e-consolidation`) | +| postgres | 18.3 (`ghcr.io/keiailab/pg:18`) | +| topology | shardingMode=none · 단일 shard · replicas=0 (HA 없음) | +| node 자원 | 16 vCPU / ~7.6 GiB (WSL2 VM 할당) | +| PG 설정 | shared_buffers=160MB · effective_cache_size=5GB · max_connections=100 | +| storage | local-path (kind 기본, WSL2 overlay) | +| client 위치 | **PG pod 내 co-located** (pgbench가 같은 16코어 공유 → TPS 보수적) | +| scale | `pgbench -s 50` (5M rows ≈ 750MB, OS 캐시 적재) · 각 30s · `-j 8` | + +**결과**: + +| workload | clients | TPS | latency avg | +|---|---|---|---| +| W-pgb-tpcb (read-write) | 8 | 496 | 16.1 ms | +| W-pgb-tpcb (read-write) | 16 | 646 | 24.8 ms | +| W-pgb-tpcb (read-write) | 32 | 889 | 36.0 ms | +| W-pgb-ro (select-only) | 32 | 9,035 | 3.5 ms | +| W-pgb-ro (select-only) | 64 | 10,493 | 6.1 ms | + +**해석**: +- 읽기(select-only)가 쓰기(tpcb)의 **~10–18배** — 쓰기는 매 커밋 WAL fsync 가 로컬 + overlay 디스크에 바운드. 프로덕션 SSD/전용 PV 에선 쓰기 TPS 가 크게 오를 여지. +- 클라이언트 증가에 TPS 단조 증가(RW 8→32 ≈ 1.8×, RO 32→64 ≈ 1.16× — 64에서 포화 접근). +- **caveats**: ① pgbench client co-located(동일 pod/코어) → 분리 클라이언트 대비 보수적 + ② percentile(P50/95/99)은 pgbench `-l` 로그 후처리 필요(미측정, 후속) ③ WSL2 overlay + 스토리지라 쓰기 IO 가 실 PV 보다 느림. + +**재현**: +```bash +kubectl -n default exec quickstart-shard-0-0 -c postgres -- pgbench -i -s 50 -U postgres postgres +kubectl -n default exec quickstart-shard-0-0 -c postgres -- pgbench -c 32 -j 8 -T 30 -U postgres postgres # tpcb +kubectl -n default exec quickstart-shard-0-0 -c postgres -- pgbench -S -c 64 -j 8 -T 30 -U postgres postgres # select-only +``` ### 3.1 Single-shard baseline (T-1S) From fab05f680072c15cc3f1a5d4fc77a4f141b065d1 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sat, 27 Jun 2026 01:53:56 +0900 Subject: [PATCH 026/109] =?UTF-8?q?docs(test):=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=84=B0/=EC=83=A4=EB=94=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B9=B4=ED=83=88=EB=A1=9C=EA=B7=B8=20+=20DOCS=5FMAP=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/router(38) + cmd/pg-router(10) 테스트케이스를 영역별로 색인(vindex/추출/read-write/토폴로지/백엔드해소/reference/QueryRouter/scatter/풀/resharding/placement/metadata/wire프록시) — 각 테스트가 무엇을 검증하는지 + 실행법(컨테이너) + 라이브 검증(perf 완료, failover/PITR drill 미완) 정리. DOCS_MAP에 라우터 갭분석·테스트 카탈로그·perf baseline 3건 등록. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/DOCS_MAP.ko.md | 3 + docs/sharding/ROUTER-TESTS.ko.md | 167 +++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 docs/sharding/ROUTER-TESTS.ko.md diff --git a/docs/DOCS_MAP.ko.md b/docs/DOCS_MAP.ko.md index 7cd99a9..32b63f0 100644 --- a/docs/DOCS_MAP.ko.md +++ b/docs/DOCS_MAP.ko.md @@ -16,6 +16,9 @@ | [FEATURE_DEEP_DIVE.md](FEATURE_DEEP_DIVE.md) | 기능별 **심층 동작 분석** — 각 CRD/컨트롤러의 reconcile·내부 동작 | 기능 동작의 상세 레퍼런스 (failover/PITR/pooler/DB·User 내부 흐름) | | [TEST_ANALYSIS.md](TEST_ANALYSIS.md) | **단위·통합 테스트** 분석 — 패키지별 커버리지·TC 상세 | 단위/통합 테스트 결과·커버리지 수치, CRLF/`.gitattributes` 조치 | | [E2E_TEST_REPORT.ko.md](E2E_TEST_REPORT.ko.md) | **E2E 라이브 드릴 RCA** 누적 로그 — 시간순 검증/원인분석 | E2E(라이브 K8s) 드릴 결과·RCA·PENDING 이력 | +| [sharding/ROUTER-GAP-ANALYSIS.ko.md](sharding/ROUTER-GAP-ANALYSIS.ko.md) | **라우터 갭 분석** — 현황·능력 사다리·향후 대작업 백로그 | 분산 SQL 라우터 설계/진행/백로그(§6) | +| [sharding/ROUTER-TESTS.ko.md](sharding/ROUTER-TESTS.ko.md) | **라우터/샤딩 테스트 카탈로그** — 테스트케이스 색인·실행법·라이브 검증 | 라우터/pg-router 테스트케이스 목록 | +| [perf/baseline.md](perf/baseline.md) | **성능 baseline** — 측정 schema + 실측 결과 | 성능 수치(single-shard 실측 §3.0) | | [WORK_HANDOFF.ko.md](WORK_HANDOFF.ko.md) | 현재 **작업 인수인계** — 브랜치 커밋 구성·검증 요약·남은 일·재현법 | 진행 중 작업 스냅샷(브랜치 `chore/ha-pitr-e2e-consolidation`) | | [dev-setup-devcontainer.md](dev-setup-devcontainer.md) | **Dev Container** 개발 환경 구성 (현재 권장) | Windows에서 컨테이너로 빌드/테스트하는 절차 | | [dev-setup-wsl.md](dev-setup-wsl.md) | **WSL2** 개발 환경 구성 (대안) | WSL2 네이티브로 빌드/테스트하는 절차 | diff --git a/docs/sharding/ROUTER-TESTS.ko.md b/docs/sharding/ROUTER-TESTS.ko.md new file mode 100644 index 0000000..5089340 --- /dev/null +++ b/docs/sharding/ROUTER-TESTS.ko.md @@ -0,0 +1,167 @@ +# 라우터/샤딩 테스트 카탈로그 + +> `internal/router` + `cmd/pg-router`(분산 SQL 라우터)의 테스트케이스를 영역별로 정리한 +> *추후 참고용 색인*이다. 무엇을 검증하는지 + 어떻게 돌리는지 + 라이브 검증 절차를 담는다. +> +> 작성 기준: 2026-06-27. 관련: [ROUTER-GAP-ANALYSIS.ko.md](ROUTER-GAP-ANALYSIS.ko.md) +> (설계·능력 사다리·백로그) · [`docs/perf/baseline.md`](../perf/baseline.md)(성능 실측) · +> [`docs/TEST_ANALYSIS.md`](../TEST_ANALYSIS.md)(오퍼레이터 전체 테스트 분석). + +--- + +## 1. 실행 방법 + +호스트에 go/make 없음 → **컨테이너에서** 실행 (Dev Container 정식 절차는 dev-setup 문서). + +```bash +# 라우터 + pg-router 단위 테스트 (라이브 클러스터 불필요, 전부 순수/in-memory) +docker run --rm -v :/src -w /src golang:1.26 \ + sh -c "go test ./internal/router/... ./cmd/pg-router/..." + +# 커버리지 +... go test -cover ./internal/router/... # 최근 77.6% + +# 전체 오퍼레이터 스위트(envtest 포함) — controller/webhook 등까지 +... make test +``` + +- **외부 SQL 파서 의존성 없음**: 라우팅 키 추출 parser 전략은 *제로 의존성 토크나이저*라 + 별도 build-tag 불필요(전부 평이하게 컴파일·실행). (auxten 등 외부 파서는 genproto 충돌로 + 기각 — ROUTER-GAP-ANALYSIS §5.) +- 알려진 flaky: `internal/controller/failover` 의 `TestLeaseElection`(타이밍 의존)이 전체 + 병렬 부하에서 간헐 실패 → 단독 재실행 시 통과(`go test -count=1 ./internal/controller/failover/...`). + +--- + +## 2. 단위 테스트 카탈로그 (영역별) + +### 2.1 Vindex (키 → 샤드) + +| 파일 · 테스트 | 검증 내용 | +|---|---| +| `vindex_test.go` `TestResolveShard` | hash/range vindex, murmur3/fnv/crc32, 범위 매칭, no-match 에러 | +| `vindex_consistent_test.go` `TestConsistentHash_Deterministic` | 같은 키→같은 샤드, 모든 샤드 사용 | +| `…_MinimalMovement` | **핵심 속성**: 샤드 3→4 추가 시 키 ~29%만 이동(modulo 해시 ~75%) | +| `…_DefaultVirtualNodes` | VirtualNodes=0 시 기본값(128) 링 구성 | +| `…_NoShards` | 샤드 0개 → 에러 | + +### 2.2 라우팅 키 추출 (regex / parser / auto) + +| 파일 · 테스트 | 검증 내용 | +|---|---| +| `sql_route_test.go` `TestExtractRoutingKey` | regex first-literal 모드(VALUES/WHERE 등호), 빈 리터럴 거부 | +| `…_RoutesToShard` | 추출 키가 vindex로 단일 샤드에 결정적 매핑 | +| `route_extractor_test.go` `TestRegexExtractor_ColumnMode` | 지정 컬럼(WHERE/AND 등호 + INSERT 위치) 추출 | +| `…TestNewRouteKeyExtractor` | 전략 선택기(regex/parser/auto), 빈/오류 이름 | +| `…TestAutoExtractor_FallsBackToRegex` | auto가 parser 매치 실패 시 regex 폴백 | +| `route_extractor_parser_test.go` `TestParserExtractor` | 토크나이저 추출 — SELECT/INSERT/UPDATE/DELETE/복합 predicate/`t.col`/parameterized | +| `…TestParserBeatsRegex` | 따옴표 내부·주석 속 가짜 predicate를 오인하지 않음(정규식 대비 강점) | +| `…TestParserSelectableViaFactory` | "parser"/"auto" 선택이 실제 토크나이저 사용 | + +### 2.3 읽기/쓰기 분류 + +| 파일 · 테스트 | 검증 내용 | +|---|---| +| `route_extractor_parser_test.go` `TestIsReadOnlyQuery` | 보수적 분류 — SELECT/SHOW/VALUES/TABLE=읽기, `FOR UPDATE/SHARE`·DML·WITH=쓰기 | + +### 2.4 토폴로지 (key→shard 공급) + +| 파일 · 테스트 | 검증 내용 | +|---|---| +| `topology_test.go` `TestTopologyShard` | 키→샤드 vindex 평가 | +| `…TestCRDTopologyProvider` | ShardRange CRD에서 토폴로지 구성, cluster/keyspace 매칭, 캐시, 미매칭 에러 (fake lister) | + +### 2.5 백엔드 해소 (failover-aware / 읽기 분산) + +| 파일 · 테스트 | 검증 내용 | +|---|---| +| `topology_test.go` `TestStatusBackendResolver` | `status.primary.endpoint`(Ready만)에서 해소, not-ready/부재 에러, **failover 시 새 primary 추종** | +| `…TestStatusBackendResolver_ResolveRead` | Ready replica round-robin, replica 없으면 primary 폴백, 둘 다 없으면 에러 | + +### 2.6 Reference table + +| 파일 · 테스트 | 검증 내용 | +|---|---| +| `reference_test.go` `TestExtractTables` | FROM/JOIN/INTO/UPDATE 테이블 추출, schema 한정 `s.t`→`t` | +| `…TestReferenceRouting` | reference-only 판정(전부 reference면 true, 샤딩 테이블 섞이면 false), AnyShard 결정성 | + +### 2.7 쿼리 라우팅 결정 엔진 (E 핵심) + +| 파일 · 테스트 | 검증 내용 | +|---|---| +| `query_router_test.go` `TestQueryRouter_WriteRoutesToPrimaryShard` | 쓰기 → primary 백엔드 | +| `…_ReadRoutesToReplica` | 읽기 → replica 백엔드 | +| `…_ReferenceOnlyUsesAnyShard` | reference 쿼리 → AnyShard | +| `…_NoKeySignalsScatter` | 키 부재 → Scatter=true + ErrNoRoutingKey | +| `…_BackendErrorPropagates` | 백엔드 해소 에러 전파(샤드 down) | + +### 2.8 Scatter-gather + +| 파일 · 테스트 | 검증 내용 | +|---|---| +| `scatter_test.go` `TestScatterGather` | fan-out, FailFast/BestEffort 정책, ErrNoShards, 부분 실패 | +| `scatter_merge_test.go` `TestScatterGather_OrderByNumeric` | 타입 인지 정렬(숫자 `"10"<"9"` 버그 수정) | +| `…_Limit` | merge 후 LIMIT 적용 | + +### 2.9 SQL Executor (연결 풀) + +| 파일 · 테스트 | 검증 내용 | +|---|---| +| `sql_executor_test.go` `TestSQLShardExecutor_PoolReuse` | shard별 `*sql.DB` 재사용(per-call open 안 함), Close가 풀 비움 | +| `…_NoDSN` | DSN 없는 샤드 → ErrNoDSN | +| `…_SatisfiesInterface` | ScatterGather의 ShardExecutor로 주입 가능 | + +### 2.10 Resharding · Placement · Metadata (기존) + +| 파일 · 테스트 | 검증 내용 | +|---|---| +| `resharding_test.go` `TestValidateSplitPlan` / `TestHexSuccessor` | split 보존 불변식(gap/overlap/coverage), hex 인접성 | +| `reshard_copy_test.go` `TestBuildInsert` / `TestCopyTable_RejectsInjection` | copy SQL 생성, 테이블명 인젝션 거부 | +| `placement_test.go` `TestPlacementDrift` / `TestValidatePlacement` | drift 감지(Missing/Extra/Zone/Node/NotReady/RangeUncovered), placement 검증 | +| `metadata_store_test.go` `TestPostgresStore` | `pg_keiailab` 스키마 마이그레이션 + Upsert/List/Delete | + +### 2.11 pg-router (PG wire 프록시) + +| 파일 · 테스트 | 검증 내용 | +|---|---| +| `main_test.go` `TestReadStartupParsesParams` / `TestReadStartupHandlesSSLRequest` | v3 startup 파싱, SSLRequest('N' 거절 후 재파싱) | +| `…TestShardSpecRoutesByVindex` | 정적 2-shard spec 라우팅 | +| `…TestBackendForUsesEnvMapping` / `TestTemplateResolver` / `TestEnvBackendResolver` | env/DNS 템플릿 백엔드 해소 | +| `…TestWritePgError` | 샤드 down 시 우아한 PostgreSQL `ErrorResponse`('E') 인코딩(조용한 drop 금지) | +| `dialer_test.go` `TestDialer_RetryThenSuccess` | dial retry/backoff(주입 dial) | +| `…_CircuitOpensAndCooldown` | 연속 실패 시 circuit open→fast-fail, cooldown 후 재시도(주입 clock) | +| `…_SuccessResetsBreaker` | 성공이 실패 카운트 리셋 | + +--- + +## 3. 라이브 검증 (실 클러스터) + +단위 테스트로 못 잡는 부분은 호스트 kind(Docker Desktop/WSL2 — 컨테이너 안 중첩 아님)에서 검증. + +### 3.1 완료된 라이브 검증 +- **성능 baseline (2026-06-27)**: 오퍼레이터 배포 → 단일샤드 PostgresCluster Ready → pgbench. + 결과·환경·재현 명령은 [`docs/perf/baseline.md §3.0`](../perf/baseline.md). (배포 흐름: + `docker build`→`kind load`→`kubectl apply -k config/default`→`set image`→dev 샘플 적용.) +- **referenceTables CRD**: 실 apiserver 수용 검증(server-side apply). + +### 3.2 미완(라이브 failover 환경 필요 — 무검증 랜딩 금지) +- 자동 failover chaos drill (`make test-e2e-failover`): primary kill→fencing→승격→reseed. +- PITR restore drill (`make test-e2e`): WAL 아카이빙→offline restore→락 해제. +- 백로그 보류 3건(per-shard primary Service / watch hot-reload / failover lease P2-T3)의 라이브 검증. +- 분산 N-shard scatter 수치, percentile(pgbench `-l` 로그 후처리), sysbench, 전용 PV. + +--- + +## 4. 용어집 + +> 정의는 [GLOSSARY.ko.md](../GLOSSARY.ko.md)에서 발췌해 동일하게 유지한다. 전체 용어는 해당 문서 참고. + +| 용어 | 정의 | +|---|---| +| Vindex (가상 인덱스) | 샤딩 키 → 샤드를 결정하는 함수/정책(hash·range·consistent-hash 등). Vitess 용어 차용. | +| Scatter-gather | 한 쿼리를 여러 샤드에 fan-out하고 결과를 모아 merge하는 분산 읽기 패턴. | +| Reference table | 모든 샤드에 복제해 두는 작은 공통 테이블. 분산 조인을 우회하는 수단. | +| Failover (장애 조치) | Primary 장애 감지 후 Replica 하나를 새 Primary로 자동 승격해 서비스를 잇는 동작. | +| Topology (토폴로지) | 어떤 키 범위가 어떤 샤드에 있는지의 라우팅 메타데이터(ShardRange CRD). | +| envtest | 실제 클러스터 없이 API 서버/etcd만 띄워 컨트롤러를 통합 테스트하는 도구. | +| Circuit breaker | 반복 실패하는 대상으로의 호출을 일정 시간 빠르게 차단해 장애 전파를 막는 패턴. | From f50cedd850478ab6465bf266a4d7088eafa4f650 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sat, 27 Jun 2026 02:02:37 +0900 Subject: [PATCH 027/109] =?UTF-8?q?fix(router):=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=8C=85=20=ED=82=A4=20=EC=B6=94=EC=B6=9C=20=EC=A0=95=ED=99=95?= =?UTF-8?q?=EC=84=B1=20=EB=B3=B4=EA=B0=95=20(=EB=AA=A8=ED=98=B8=20?= =?UTF-8?q?=ED=82=A4=20bail=20+=20dollar-quote)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 잘못된 샤드 라우팅을 막는 P0 정확성 보강 2건: ① whereEqTok이 같은 샤딩 컬럼에 서로 다른 리터럴(서브쿼리/OR)을 보면 추측하지 않고 추출 실패→scatter (이전엔 첫 매치 무조건 채택, 서브쿼리의 다른 tenant_id로 쓰기가 갈 수 있었음). ② 토크나이저가 dollar-quote($$...$$/$tag$...$tag$)를 단일 문자열로 처리 — 함수 본문 속 가짜 predicate가 토큰으로 새어 false-match 되던 것 차단. dollar-quote 리터럴 자체는 정상 키로, $1 파라미터는 여전히 비키. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- internal/router/route_extractor_parser.go | 54 +++++++++++++++++-- .../router/route_extractor_parser_test.go | 37 +++++++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/internal/router/route_extractor_parser.go b/internal/router/route_extractor_parser.go index 439c1b1..abd3c3a 100644 --- a/internal/router/route_extractor_parser.go +++ b/internal/router/route_extractor_parser.go @@ -135,6 +135,16 @@ func tokenize(s string) []token { i++ } toks = append(toks, token{tokIdent, b.String()}) + case c == '$': // dollar-quote ($$...$$ / $tag$...$tag$) 또는 $1 파라미터 + if inner, end, ok := dollarQuoted(s, i); ok { + // dollar-quote 본문은 하나의 문자열 리터럴 — 내부 텍스트가 식별자/predicate + // 로 새어나와 false-match 되는 것을 막는다. + toks = append(toks, token{tokStr, inner}) + i = end + } else { + toks = append(toks, token{tokSym, "$"}) // $1 등 파라미터/stray. + i++ + } case isIdentStart(c): j := i + 1 for j < n && isIdentPart(s[j]) { @@ -175,6 +185,29 @@ func isIdentPart(c byte) bool { return isIdentStart(c) || c >= '0' && c <= '9' || c == '$' } +// dollarQuoted 는 s[i]=='$' 에서 PostgreSQL dollar-quote 를 파싱한다. 여는 구분자는 +// `$$`(tag = [A-Za-z0-9_]*). 성공 시 내부 본문 + 닫는 구분자 다음 인덱스를 반환. +// `$1`(파라미터)이나 단독 `$` 는 (,,false). +func dollarQuoted(s string, i int) (inner string, end int, ok bool) { + j := i + 1 + for j < len(s) && isTagChar(s[j]) { + j++ + } + if j >= len(s) || s[j] != '$' { + return "", 0, false // $1 / stray $. + } + open := s[i : j+1] // "$$" + rest := s[j+1:] + if idx := strings.Index(rest, open); idx >= 0 { + return rest[:idx], j + 1 + idx + len(open), true + } + return rest, len(s), true // 미종료 → 나머지를 본문으로 소비. +} + +func isTagChar(c byte) bool { + return c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' +} + func isOpChar(c byte) bool { switch c { case '=', '<', '>', '!', '+', '-', '*', '/', '%', '~', '&', '|', '^': @@ -184,19 +217,30 @@ func isOpChar(c byte) bool { } // whereEqTok 는 토큰열에서 ` = '리터럴'` 패턴을 찾는다 (복합 predicate 에서 지정 -// 컬럼만). 첫 매치를 반환. 우변이 문자열 리터럴이 아니면(파라미터/숫자/범위) 무시. +// 컬럼만). 우변이 문자열 리터럴이 아니면(파라미터/숫자/범위) 무시. +// +// *모호성 안전*: 같은 샤딩 컬럼에 서로 다른 리터럴이 둘 이상 보이면(예: 서브쿼리의 +// `tenant_id='a'` 와 외부 `tenant_id='b'`, 또는 `OR`) 어느 샤드인지 모호하다. 잘못된 +// 샤드로 라우팅(특히 쓰기)하는 것보다, 추출 실패로 두어 호출자가 scatter/거부하게 하는 +// 편이 안전하다. 동일 리터럴만 반복되면 그 값을 쓴다. func whereEqTok(toks []token, col string) (string, bool) { + found := "" + got := false for i := 0; i+2 < len(toks); i++ { if toks[i].kind == tokIdent && strings.EqualFold(toks[i].text, col) && toks[i+1].kind == tokSym && toks[i+1].text == "=" && toks[i+2].kind == tokStr { - if toks[i+2].text == "" { - return "", false + v := toks[i+2].text + if v == "" { + continue + } + if got && v != found { + return "", false // 모호 → 추측 거부. } - return toks[i+2].text, true + found, got = v, true } } - return "", false + return found, got } // insertValueTok 는 INSERT 의 컬럼 목록에서 col 위치를 찾아 같은 위치의 VALUES 리터럴을 diff --git a/internal/router/route_extractor_parser_test.go b/internal/router/route_extractor_parser_test.go index 206e92b..7926e7e 100644 --- a/internal/router/route_extractor_parser_test.go +++ b/internal/router/route_extractor_parser_test.go @@ -65,6 +65,43 @@ func TestParserBeatsRegex(t *testing.T) { } } +// TestParser_AmbiguousKeyBails 는 같은 샤딩 컬럼에 서로 다른 리터럴(서브쿼리/OR)이 +// 보이면 추측하지 않고 추출 실패(→ scatter)함을 검증한다. 잘못된 샤드로 쓰기가 가는 +// 것을 막는 안전장치. +func TestParser_AmbiguousKeyBails(t *testing.T) { + ex := parserExtractor{} + // 서브쿼리 tenant_id='a' + 외부 tenant_id='b' → 모호 → false. + q := "SELECT * FROM t WHERE x IN (SELECT id FROM u WHERE tenant_id = 'a') AND tenant_id = 'b'" + if k, ok := ex.ExtractRoutingKey(q, "tenant_id"); ok { + t.Fatalf("ambiguous key should bail, got (%q,%v)", k, ok) + } + // 동일 리터럴 반복은 안전 → 그 값 사용. + q2 := "DELETE FROM t WHERE tenant_id = 'x' AND (a = 1 OR tenant_id = 'x')" + if k, ok := ex.ExtractRoutingKey(q2, "tenant_id"); !ok || k != "x" { + t.Fatalf("consistent key = (%q,%v), want (x,true)", k, ok) + } +} + +// TestParser_DollarQuoted 는 dollar-quote($$...$$) 본문 속 가짜 predicate 를 오인하지 +// 않고, dollar-quote 리터럴 자체는 라우팅 키로 인식함을 검증한다. +func TestParser_DollarQuoted(t *testing.T) { + ex := parserExtractor{} + // $$...$$ 본문의 tenant_id='evil' 은 무시, 실제 WHERE 의 'real' 만. + q := "SELECT fn($$ tenant_id = 'evil' $$) FROM t WHERE tenant_id = 'real'" + if k, ok := ex.ExtractRoutingKey(q, "tenant_id"); !ok || k != "real" { + t.Fatalf("dollar-body leak = (%q,%v), want (real,true)", k, ok) + } + // tagged dollar-quote 리터럴은 정상 키로. + q2 := "SELECT * FROM t WHERE tenant_id = $tag$acme$tag$" + if k, ok := ex.ExtractRoutingKey(q2, "tenant_id"); !ok || k != "acme" { + t.Fatalf("dollar literal = (%q,%v), want (acme,true)", k, ok) + } + // $1 파라미터는 여전히 키 아님(리터럴 없음). + if _, ok := ex.ExtractRoutingKey("SELECT * FROM t WHERE tenant_id = $1", "tenant_id"); ok { + t.Fatal("$1 parameter should not be a routing key") + } +} + // TestIsReadOnlyQuery 는 읽기/쓰기 분류가 보수적(확실한 읽기만 true)임을 검증한다. func TestIsReadOnlyQuery(t *testing.T) { cases := []struct { From 8f51a0a725d2d2601615ac4a532230cab7ee6a44 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sat, 27 Jun 2026 02:04:52 +0900 Subject: [PATCH 028/109] =?UTF-8?q?feat(router):=20=EC=9D=BD=EA=B8=B0=20re?= =?UTF-8?q?plica=20lag=20=EC=9E=84=EA=B3=84(bounded=20staleness)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StatusBackendResolver.MaxReplicaLagBytes — >0이면 lag 초과 replica를 ResolveRead 대상에서 제외(과도하게 stale한 읽기 방지). 0=무제한(현행). 단위 검증. env 결선은 ResolveRead가 프록시에 결선되는 (E) 시점에. 백로그 'ResolveRead lag 무제한' 보강. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- internal/router/topology.go | 14 +++++++++++--- internal/router/topology_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/internal/router/topology.go b/internal/router/topology.go index a5585eb..0a2d359 100644 --- a/internal/router/topology.go +++ b/internal/router/topology.go @@ -124,9 +124,13 @@ type ClusterStatusReader interface { // Ready 대상이 없는 shard(down / failover 중)는 error 를 내어 라우터가 클라이언트를 // 우아하게 실패시키게 한다. type StatusBackendResolver struct { + // MaxReplicaLagBytes 가 >0 이면 lag 가 그를 초과하는 replica 는 읽기 대상에서 제외한다 + // (bounded staleness). 0 = 무제한(모든 Ready replica 허용). + MaxReplicaLagBytes int64 + mu sync.Mutex primaries map[string]string // shard → primary endpoint (Ready 만) - replicas map[string][]string // shard → Ready replica endpoints + replicas map[string][]string // shard → Ready replica endpoints (lag 임계 통과) rr map[string]int // shard → round-robin 커서 (읽기 분산) } @@ -152,9 +156,13 @@ func (r *StatusBackendResolver) Update(shards []v1alpha1.ShardStatus) { } for j := range s.Replicas { rep := s.Replicas[j] - if rep.Ready && rep.Endpoint != "" { - rm[s.Name] = append(rm[s.Name], rep.Endpoint) + if !rep.Ready || rep.Endpoint == "" { + continue + } + if r.MaxReplicaLagBytes > 0 && rep.LagBytes > r.MaxReplicaLagBytes { + continue // 너무 stale → 읽기 대상 제외 (bounded staleness). } + rm[s.Name] = append(rm[s.Name], rep.Endpoint) } } r.mu.Lock() diff --git a/internal/router/topology_test.go b/internal/router/topology_test.go index 4cf17f5..7288c7f 100644 --- a/internal/router/topology_test.go +++ b/internal/router/topology_test.go @@ -164,3 +164,27 @@ func TestStatusBackendResolver_ResolveRead(t *testing.T) { t.Fatal("ResolveRead shard-2 (none): expected error") } } + +// TestStatusBackendResolver_LagBound 는 MaxReplicaLagBytes 초과 replica 가 읽기에서 +// 제외됨을 검증한다 (bounded staleness). +func TestStatusBackendResolver_LagBound(t *testing.T) { + r := NewStatusBackendResolver() + r.MaxReplicaLagBytes = 1000 + rep := func(ep string, lag int64) v1alpha1.ShardEndpoint { + return v1alpha1.ShardEndpoint{Pod: ep, Endpoint: ep, Ready: true, LagBytes: lag} + } + prim := v1alpha1.ShardEndpoint{Pod: "p", Endpoint: "p:5432", Ready: true} + r.Update([]v1alpha1.ShardStatus{ + {Name: "shard-0", Primary: &prim, Replicas: []v1alpha1.ShardEndpoint{ + rep("fresh:5432", 500), // 임계 이하 → 허용 + rep("stale:5432", 5000), // 임계 초과 → 제외 + }}, + }) + // 읽기는 fresh 만 (round-robin 해도 항상 fresh). + for i := 0; i < 4; i++ { + got, err := r.ResolveRead("shard-0") + if err != nil || got != "fresh:5432" { + t.Fatalf("ResolveRead = (%q,%v), want only fresh:5432 (stale 제외)", got, err) + } + } +} From 0f8759eabc88c3451e7362425871f353225892a2 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sat, 27 Jun 2026 02:05:32 +0900 Subject: [PATCH 029/109] =?UTF-8?q?docs(router):=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95=20=ED=95=AD=EB=AA=A9=20=EB=B0=B1=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EA=B8=B0=EB=A1=9D=20(P0=20=EC=99=84=EB=A3=8C=203?= =?UTF-8?q?=EA=B1=B4=20+=20P1=20=EC=B6=94=EC=A0=81=206=EA=B1=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코드 검토에서 나온 hardening 항목을 백로그에. 완료: 모호 키 bail·dollar-quote·lag 임계. 추적: 링 캐시·다중컬럼 ORDER BY·LIMIT pushdown·breaker half-open·ConnMaxLifetime·stale 캐시 정책. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/sharding/ROUTER-GAP-ANALYSIS.ko.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md index f44f111..6d0708a 100644 --- a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md +++ b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md @@ -147,6 +147,19 @@ parameterized/`t.col`/주석·문자열 내부 오인 방지까지 검증. **기 --- +**로직 보강 / 견고성** (2026-06-27 코드 검토). 일부는 (E)/planner 결선 후 가치: +- [x] 라우팅 키 *모호성 bail* — 같은 샤딩 컬럼에 다른 리터럴(서브쿼리/OR) 시 추측 거부 (커밋 f50cedd). **틀린 샤드 쓰기 방지(P0)**. +- [x] *dollar-quote 토큰화* — `$$...$$` 본문 가짜 predicate 누출 차단 (f50cedd). **P0**. +- [x] *ResolveRead lag 임계* — bounded staleness (8f51a0a). +- [ ] **consistent-hash 링 캐시** — 현재 `ResolveShard` 가 매 호출 링 재구성(핫패스 낭비). 프록시 결선 시 spec generation 키로 캐시(`NewConsistentHashRing` 이미 노출). +- [ ] **scatter ORDER BY 다중컬럼/방향** — 현재 첫 컬럼 오름차순만. planner 가 컬럼 index + DESC 전달. +- [ ] **LIMIT per-shard pushdown** — 현재 전량 gather 후 자름. 각 샤드 쿼리에 `LIMIT n` 주입. +- [ ] **circuit-breaker half-open 단일 probe** — cooldown 직후 동시 flood 방지(현재 모두 통과). +- [ ] **SQLShardExecutor ConnMaxLifetime** — 재시작된 backend 로의 stale 연결 방지(현재 IdleTime 만). +- [ ] **CRDTopologyProvider stale 캐시 정책** — keyspace 삭제 시 캐시 영구 유지 vs 전이 안전 trade-off 재검토. + +--- + ## 7. 용어집 > 정의는 [GLOSSARY.ko.md](../GLOSSARY.ko.md)에서 발췌해 동일하게 유지한다. 전체 용어는 해당 문서 참고. From cec61bb9a0133b3a88500f6f9999cc4fb8261f5f Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sat, 27 Jun 2026 02:17:53 +0900 Subject: [PATCH 030/109] =?UTF-8?q?perf(router):=20consistent-hash=20?= =?UTF-8?q?=EB=A7=81=20fingerprint=20=EC=BA=90=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResolveShard가 consistent-hash마다 링을 재구성하던 핫패스 낭비 제거 — function|vnodes|정렬 shard집합 fingerprint로 sync.Map 캐시(링 불변이라 동시성 안전). 토폴로지 변경 시 fingerprint가 달라져 새 링. (테스트 미실행 — 다음 배치 검증.) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- internal/router/vindex_consistent.go | 40 +++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/internal/router/vindex_consistent.go b/internal/router/vindex_consistent.go index 26c291f..c8d1320 100644 --- a/internal/router/vindex_consistent.go +++ b/internal/router/vindex_consistent.go @@ -20,10 +20,33 @@ import ( "fmt" "sort" "strconv" + "strings" + "sync" "github.com/keiailab/postgres-operator/api/v1alpha1" ) +// ringCache 는 fingerprint(function|vnodes|정렬된 shard 집합) → *ConsistentHashRing. +// ResolveShard 가 매 호출 링을 재구성하던 핫패스 낭비를 제거한다. 링은 불변(읽기 전용 +// Lookup)이라 동시성 안전. 토폴로지가 바뀌면 fingerprint 가 달라져 새 링이 만들어진다 +// (옛 엔트리는 누수되나 토폴로지 종류가 적어 무시 가능). +var ringCache sync.Map + +// ringFingerprint 는 링 구성에 영향을 주는 필드(해시 함수 + 가상 노드 수 + distinct +// shard 집합)로 캐시 키를 만든다. +func ringFingerprint(spec v1alpha1.ShardRangeSpec) string { + seen := make(map[string]bool) + var shards []string + for _, r := range spec.Ranges { + if r.Shard != "" && !seen[r.Shard] { + seen[r.Shard] = true + shards = append(shards, r.Shard) + } + } + sort.Strings(shards) + return string(spec.Vindex.Function) + "|" + strconv.Itoa(int(spec.Vindex.VirtualNodes)) + "|" + strings.Join(shards, ",") +} + // defaultVirtualNodes 는 VirtualNodes 미지정(0) 시 가상 노드 수. 클수록 분포가 고르나 // 링 구성 비용 증가. CRD 는 64~65536 을 강제하므로 0 은 "미설정" 의미. const defaultVirtualNodes = 128 @@ -87,11 +110,20 @@ func (r *ConsistentHashRing) Lookup(h uint32) string { return r.points[i].shard } -// resolveConsistentHash 는 ResolveShard 의 consistent-hash 분기다. +// resolveConsistentHash 는 ResolveShard 의 consistent-hash 분기다. 링은 fingerprint +// 로 캐시되어 재사용된다. func resolveConsistentHash(spec v1alpha1.ShardRangeSpec, key string) (string, error) { - ring, err := NewConsistentHashRing(spec) - if err != nil { - return "", err + fp := ringFingerprint(spec) + var ring *ConsistentHashRing + if v, ok := ringCache.Load(fp); ok { + ring = v.(*ConsistentHashRing) + } else { + r, err := NewConsistentHashRing(spec) + if err != nil { + return "", err + } + actual, _ := ringCache.LoadOrStore(fp, r) // 동시 생성 시 한쪽으로 정규화. + ring = actual.(*ConsistentHashRing) } h, err := hashKey(spec.Vindex.Function, key) if err != nil { From 59c310ac204cf0ec9465d1abaa348646f3de9122 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sat, 27 Jun 2026 02:17:53 +0900 Subject: [PATCH 031/109] =?UTF-8?q?feat(router):=20scatter=20ORDER=20BY=20?= =?UTF-8?q?=EB=8B=A4=EC=A4=91=EC=BB=AC=EB=9F=BC/=EB=B0=A9=ED=96=A5=20+=20L?= =?UTF-8?q?IMIT=20pushdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MergeOrderBy에 OrderByCol/OrderByDesc(planner가 ORDER BY 컬럼 index/방향 전달, cmpAtCol). PushDownLimit로 각 샤드 query에 'LIMIT n' 주입(보수적: 기존 LIMIT/다중문은 건드리지 않음, 토큰 단위 LIMIT 검출) → 샤드별 전송량↓, merge 후 Limit 최종 cap. (테스트 작성, 이번 라운드 미실행.) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- internal/router/scatter.go | 87 ++++++++++++++++++++------- internal/router/scatter_merge_test.go | 43 +++++++++++++ 2 files changed, 107 insertions(+), 23 deletions(-) diff --git a/internal/router/scatter.go b/internal/router/scatter.go index 39481a5..7042af2 100644 --- a/internal/router/scatter.go +++ b/internal/router/scatter.go @@ -83,9 +83,17 @@ type ScatterGather struct { Policy FailurePolicy // Merge 는 결과 합치기 전략 (default MergeConcat). Merge MergeStrategy - // Limit 은 >0 이면 merge 결과의 행 수를 제한한다 (LIMIT pushdown 근사 — 각 샤드는 - // 전량 반환하고 merge 후 자른다; 진짜 per-shard LIMIT 주입은 planner 책임). + // Limit 은 >0 이면 merge 결과의 행 수를 제한한다. Limit int + // OrderByCol 은 MergeOrderBy 의 정렬 기준 컬럼 index (default 0). planner 가 ORDER BY + // 컬럼 위치를 전달. + OrderByCol int + // OrderByDesc 는 MergeOrderBy 를 내림차순으로 정렬한다 (default 오름차순). + OrderByDesc bool + // PushDownLimit 가 true + Limit>0 이면 각 샤드 query 에 `LIMIT n` 을 주입해 샤드별 + // 전송량을 줄인다 (이미 LIMIT 가 있거나 다중문이면 건드리지 않음). merge 후 Limit 가 + // 최종 cap 으로 다시 적용된다. + PushDownLimit bool } // NewScatterGather 는 ScatterGather 인스턴스를 반환한다. Shard 가 nil 이면 @@ -103,6 +111,11 @@ func (s *ScatterGather) Execute(ctx context.Context, query string, shards []Shar return nil, fmt.Errorf("router: ShardExecutor not configured") } + // LIMIT pushdown: 각 샤드 전송량을 줄인다 (보수적 — 기존 LIMIT/다중문은 건드리지 않음). + if s.PushDownLimit && s.Limit > 0 { + query = withLimitPushdown(query, s.Limit) + } + // Cancellation: FailFast 에서 1 fail 발견 시 다른 in-flight 도 즉시 cancel. fanCtx, cancel := context.WithCancel(ctx) defer cancel() @@ -151,12 +164,20 @@ func (s *ScatterGather) Execute(ctx context.Context, query string, shards []Shar } func (s *ScatterGather) merge(order []ShardID, collected map[ShardID][]Row) []Row { - switch s.Merge { - case MergeOrderBy: - return mergeOrderBy(order, collected) - default: + if s.Merge != MergeOrderBy { return mergeConcat(order, collected) } + // 각 shard 의 row 는 이미 정렬(PG 가 ORDER BY 처리)되어 있다고 가정 — flatten 후 + // 지정 컬럼/방향으로 안정 정렬. (k-way streaming merge 는 large result 시 future.) + flat := mergeConcat(order, collected) + sort.SliceStable(flat, func(i, j int) bool { + c := cmpAtCol(flat[i], flat[j], s.OrderByCol) + if s.OrderByDesc { + return c > 0 + } + return c < 0 + }) + return flat } // mergeConcat 는 shards 순서대로 단순 concat — UNION ALL. @@ -172,28 +193,48 @@ func mergeConcat(order []ShardID, collected map[ShardID][]Row) []Row { return out } -// mergeOrderBy 는 첫 column 기준 사전식 k-way merge. -// 각 shard 의 row slice 가 *이미 정렬된* 상태라고 가정 (PG 가 정렬한 결과). -func mergeOrderBy(order []ShardID, collected map[ShardID][]Row) []Row { - // flatten 후 사전 정렬 — k-way streaming merge 는 future optimization. - // 정확성 우선, 성능은 후속 turn (large result 시 k-way heap 도입). - flat := mergeConcat(order, collected) - sort.SliceStable(flat, func(i, j int) bool { - return cmpFirstValue(flat[i], flat[j]) < 0 - }) - return flat -} - -func cmpFirstValue(a, b Row) int { +// cmpAtCol 은 두 Row 를 col 번째 컬럼 값으로 비교한다 (nil 은 가장 작음). +func cmpAtCol(a, b Row, col int) int { + av, bv := valueAt(a, col), valueAt(b, col) switch { - case len(a.Values) == 0 && len(b.Values) == 0: + case av == nil && bv == nil: return 0 - case len(a.Values) == 0: + case av == nil: return -1 - case len(b.Values) == 0: + case bv == nil: return 1 } - return compareValues(a.Values[0], b.Values[0]) + return compareValues(av, bv) +} + +// valueAt 은 Row 의 col 번째 값을 반환한다 (범위 밖이면 nil). +func valueAt(r Row, col int) any { + if col >= 0 && col < len(r.Values) { + return r.Values[col] + } + return nil +} + +// withLimitPushdown 은 query 에 `LIMIT n` 을 주입한다. 이미 LIMIT 가 있거나 다중문 +// (top-level `;`)이면 안전을 위해 그대로 둔다. ORDER BY 가 있으면 `... ORDER BY x +// LIMIT n` 이 되어 각 샤드의 top-n 만 받고 merge 후 다시 cap 하므로 정확하다. +func withLimitPushdown(query string, n int) string { + q := strings.TrimRight(strings.TrimSpace(query), "; \t\n") + if strings.Contains(q, ";") || hasLimitClause(q) { + return query + } + return fmt.Sprintf("%s LIMIT %d", q, n) +} + +// hasLimitClause 는 query 에 top-level LIMIT 키워드가 있는지 토큰 단위로 본다 +// (문자열/주석 내부 'limit' 오인 방지). +func hasLimitClause(query string) bool { + for _, t := range tokenize(query) { + if t.kind == tokIdent && strings.EqualFold(t.text, "limit") { + return true + } + } + return false } // compareValues 는 두 값을 *타입 인지* 비교한다 — 숫자는 수치로 비교(문자열 비교 시 diff --git a/internal/router/scatter_merge_test.go b/internal/router/scatter_merge_test.go index 9078b7a..9541329 100644 --- a/internal/router/scatter_merge_test.go +++ b/internal/router/scatter_merge_test.go @@ -37,6 +37,49 @@ func TestScatterGather_OrderByNumeric(t *testing.T) { } } +// TestScatterGather_OrderByColAndDesc 는 지정 컬럼 + 내림차순 정렬을 검증한다. +func TestScatterGather_OrderByColAndDesc(t *testing.T) { + sg := &ScatterGather{ + Merge: MergeOrderBy, + OrderByCol: 1, + OrderByDesc: true, + Shard: &fakeShardExecutor{responses: map[ShardID][]Row{ + "s0": {{Values: []any{"a", int64(10)}}}, + "s1": {{Values: []any{"b", int64(30)}}, {Values: []any{"c", int64(20)}}}, + }}, + } + rows, err := sg.Execute(context.Background(), "q", []ShardID{"s0", "s1"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + want := []int64{30, 20, 10} // col 1 내림차순 + for i, w := range want { + if rows[i].Values[1].(int64) != w { + t.Fatalf("rows[%d].col1=%v, want %d", i, rows[i].Values[1], w) + } + } +} + +// TestWithLimitPushdown 는 LIMIT 주입의 보수적 규칙을 검증한다. +func TestWithLimitPushdown(t *testing.T) { + cases := []struct { + query string + n int + want string + }{ + {"SELECT * FROM t", 3, "SELECT * FROM t LIMIT 3"}, + {"SELECT * FROM t;", 3, "SELECT * FROM t LIMIT 3"}, // 후행 ; trim + {"SELECT * FROM t LIMIT 5", 3, "SELECT * FROM t LIMIT 5"}, // 기존 LIMIT 유지 + {"SELECT 1; SELECT 2", 3, "SELECT 1; SELECT 2"}, // 다중문 → 안 건드림 + {"SELECT * FROM t ORDER BY x", 3, "SELECT * FROM t ORDER BY x LIMIT 3"}, + } + for _, c := range cases { + if got := withLimitPushdown(c.query, c.n); got != c.want { + t.Errorf("withLimitPushdown(%q,%d) = %q, want %q", c.query, c.n, got, c.want) + } + } +} + // TestScatterGather_Limit 는 Limit 가 merge 결과를 자름을 검증한다. func TestScatterGather_Limit(t *testing.T) { sg := &ScatterGather{ From 0dca370558f69aa02f22d6b05b45210f3db4f7c1 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sat, 27 Jun 2026 02:18:07 +0900 Subject: [PATCH 032/109] =?UTF-8?q?feat(router):=20=ED=9A=8C=EB=B3=B5?= =?UTF-8?q?=EB=A0=A5=20=EB=B3=B4=EA=B0=95=20=E2=80=94=20breaker=20half-ope?= =?UTF-8?q?n=20+=20pool=20ConnMaxLifetime=20+=20=ED=86=A0=ED=8F=B4?= =?UTF-8?q?=EB=A1=9C=EC=A7=80=20stale=20=EC=BA=90=EC=8B=9C=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① circuit-breaker half-open: cooldown 직후 단일 probe만 통과(allow gate), probe 실패 시 즉시 재오픈 → cooldown 직후 flood 방지. ② SQLShardExecutor.ConnMaxLifetime(기본 30m) — 재시작 backend(failover 후)로의 stale 연결 주기 폐기. ③ CRDTopologyProvider.ClearCacheOnMissing — keyspace 삭제 시 stale 토폴로지 대신 에러(기본 false=transient 보호 유지). (테스트 작성, 이번 라운드 미실행.) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- cmd/pg-router/dialer.go | 30 ++++++++++++++++++++++++++++-- cmd/pg-router/dialer_test.go | 29 +++++++++++++++++++++++++++++ internal/router/sql_executor.go | 11 ++++++++++- internal/router/topology.go | 11 +++++++++++ 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/cmd/pg-router/dialer.go b/cmd/pg-router/dialer.go index 3c01bdc..570fae4 100644 --- a/cmd/pg-router/dialer.go +++ b/cmd/pg-router/dialer.go @@ -42,6 +42,7 @@ type backendDialer struct { type breaker struct { failures int openUntil time.Time + probing bool // half-open 상태에서 단일 probe 진행 중 } // newBackendDialer builds a dialer with real net/clock. failThreshold<=0 disables @@ -65,7 +66,7 @@ func newBackendDialer(timeout, backoff, cooldown time.Duration, retries, failThr // Dial connects to addr, applying the circuit breaker then bounded retry/backoff. func (d *backendDialer) Dial(addr string) (net.Conn, error) { - if d.isOpen(addr) { + if !d.allow(addr) { return nil, fmt.Errorf("%s: %w", addr, errCircuitOpen) } var lastErr error @@ -84,6 +85,7 @@ func (d *backendDialer) Dial(addr string) (net.Conn, error) { return nil, lastErr } +// isOpen 은 breaker 가 *현재 open*(cooldown 미경과)인지 읽기 전용으로 본다. func (d *backendDialer) isOpen(addr string) bool { if d.failThreshold <= 0 { return false @@ -91,7 +93,30 @@ func (d *backendDialer) isOpen(addr string) bool { d.mu.Lock() defer d.mu.Unlock() b := d.breakers[addr] - return b != nil && d.now().Before(b.openUntil) + return b != nil && !b.openUntil.IsZero() && d.now().Before(b.openUntil) +} + +// allow 는 이번 Dial 을 진행할지 결정한다 (breaker 게이트). open 이면 거부, cooldown +// 경과(half-open)면 *단일 probe만* 허용하고 나머지는 거부해 cooldown 직후 flood 를 +// 막는다. 정상(미오픈)이면 항상 허용. +func (d *backendDialer) allow(addr string) bool { + if d.failThreshold <= 0 { + return true + } + d.mu.Lock() + defer d.mu.Unlock() + b := d.breakers[addr] + if b == nil || b.openUntil.IsZero() { + return true // 안 열림. + } + if d.now().Before(b.openUntil) { + return false // open. + } + if b.probing { + return false // 다른 goroutine 이 half-open probe 중. + } + b.probing = true // 이 호출이 단일 probe. + return true } func (d *backendDialer) onSuccess(addr string) { @@ -115,4 +140,5 @@ func (d *backendDialer) onFailure(addr string) { if b.failures >= d.failThreshold { b.openUntil = d.now().Add(d.cooldown) } + b.probing = false // half-open probe 실패 → 다음 cooldown 후 재시도 허용. } diff --git a/cmd/pg-router/dialer_test.go b/cmd/pg-router/dialer_test.go index 3627510..3c15468 100644 --- a/cmd/pg-router/dialer_test.go +++ b/cmd/pg-router/dialer_test.go @@ -67,6 +67,35 @@ func TestDialer_CircuitOpensAndCooldown(t *testing.T) { } } +// TestDialer_HalfOpenReopensOnFailure 는 cooldown 경과 후 단일 probe 만 나가고, 그 +// probe 가 실패하면 즉시 재오픈되어 다음 Dial 이 다시 fast-fail 됨을 검증한다. +func TestDialer_HalfOpenReopensOnFailure(t *testing.T) { + now := time.Now() + d := newBackendDialer(time.Second, 0, 10*time.Second, 0, 2) + d.now = func() time.Time { return now } + calls := 0 + d.dial = func(_, _ string, _ time.Duration) (net.Conn, error) { + calls++ + return nil, errors.New("refused") + } + _, _ = d.Dial("a") + _, _ = d.Dial("a") // 2 fails → open + now = now.Add(11 * time.Second) + before := calls + _, _ = d.Dial("a") // half-open 단일 probe (dial 1회), 실패 → 재오픈 + if calls != before+1 { + t.Fatalf("half-open should probe exactly once: %d -> %d", before, calls) + } + c := calls + _, err := d.Dial("a") // 재오픈 상태 → fast-fail, dial 호출 안 됨 + if !errors.Is(err, errCircuitOpen) { + t.Fatalf("after failed probe should be open: %v", err) + } + if calls != c { + t.Fatalf("dial called while reopened: %d -> %d", c, calls) + } +} + // TestDialer_SuccessResetsBreaker 는 성공이 실패 카운트를 초기화함을 검증한다. func TestDialer_SuccessResetsBreaker(t *testing.T) { now := time.Now() diff --git a/internal/router/sql_executor.go b/internal/router/sql_executor.go index 16b2b16..1155fc2 100644 --- a/internal/router/sql_executor.go +++ b/internal/router/sql_executor.go @@ -32,16 +32,20 @@ const ( defaultMaxOpenConns = 10 defaultMaxIdleConns = 5 defaultConnMaxIdleTime = 5 * time.Minute + defaultConnMaxLifetime = 30 * time.Minute ) // SQLShardExecutor 는 shard 별 DSN 으로 *sql.DB 풀을 캐시·재사용하여 query 를 실행한다. type SQLShardExecutor struct { // DSNs 는 shard → PostgreSQL DSN ("postgres://user:pw@host:port/db?sslmode=..."). DSNs map[ShardID]string - // MaxOpenConns / MaxIdleConns / ConnMaxIdleTime 은 shard 풀 튜닝 (0 = 기본값). + // MaxOpenConns / MaxIdleConns / ConnMaxIdleTime / ConnMaxLifetime 은 shard 풀 튜닝 + // (0 = 기본값). ConnMaxLifetime 은 재시작된 backend(예: failover 후)로의 stale 연결을 + // 주기적으로 폐기한다. MaxOpenConns int MaxIdleConns int ConnMaxIdleTime time.Duration + ConnMaxLifetime time.Duration mu sync.Mutex pools map[ShardID]*sql.DB @@ -72,6 +76,11 @@ func (e *SQLShardExecutor) pool(shard ShardID) (*sql.DB, error) { } else { db.SetConnMaxIdleTime(defaultConnMaxIdleTime) } + if e.ConnMaxLifetime > 0 { + db.SetConnMaxLifetime(e.ConnMaxLifetime) + } else { + db.SetConnMaxLifetime(defaultConnMaxLifetime) + } if e.pools == nil { e.pools = make(map[ShardID]*sql.DB) } diff --git a/internal/router/topology.go b/internal/router/topology.go index 0a2d359..68fe90f 100644 --- a/internal/router/topology.go +++ b/internal/router/topology.go @@ -68,6 +68,11 @@ type CRDTopologyProvider struct { Namespace string Cluster string Keyspace string + // ClearCacheOnMissing 가 true 면, list 는 성공했으나 매칭 ShardRange 가 없을 때(예: + // keyspace 삭제) 캐시를 비운다 → Current 가 stale 토폴로지 대신 에러를 낸다. false + // (기본)면 transient 보호를 위해 마지막 캐시를 유지한다. list 자체 실패(API blip)는 + // 항상 캐시를 보존한다. + ClearCacheOnMissing bool mu sync.RWMutex cached Topology @@ -104,6 +109,12 @@ func (p *CRDTopologyProvider) Refresh(ctx context.Context) (Topology, error) { p.mu.Unlock() return t, nil } + // list 는 성공했으나 매칭 없음 (keyspace 삭제 등). 정책에 따라 캐시 비움. + if p.ClearCacheOnMissing { + p.mu.Lock() + p.cached, p.loaded = Topology{}, false + p.mu.Unlock() + } return Topology{}, fmt.Errorf( "router: no ShardRange for cluster=%q keyspace=%q in namespace=%q", p.Cluster, p.Keyspace, p.Namespace) From 44b07a6fa18843ff236ed198bba4aad42ff8dcc6 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sat, 27 Jun 2026 02:18:42 +0900 Subject: [PATCH 033/109] =?UTF-8?q?docs(router):=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95=20P1=206=EA=B1=B4=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20(=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=AF=B8?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EB=8B=A8=EC=84=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/sharding/ROUTER-GAP-ANALYSIS.ko.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md index 6d0708a..7e3e584 100644 --- a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md +++ b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md @@ -151,12 +151,16 @@ parameterized/`t.col`/주석·문자열 내부 오인 방지까지 검증. **기 - [x] 라우팅 키 *모호성 bail* — 같은 샤딩 컬럼에 다른 리터럴(서브쿼리/OR) 시 추측 거부 (커밋 f50cedd). **틀린 샤드 쓰기 방지(P0)**. - [x] *dollar-quote 토큰화* — `$$...$$` 본문 가짜 predicate 누출 차단 (f50cedd). **P0**. - [x] *ResolveRead lag 임계* — bounded staleness (8f51a0a). -- [ ] **consistent-hash 링 캐시** — 현재 `ResolveShard` 가 매 호출 링 재구성(핫패스 낭비). 프록시 결선 시 spec generation 키로 캐시(`NewConsistentHashRing` 이미 노출). -- [ ] **scatter ORDER BY 다중컬럼/방향** — 현재 첫 컬럼 오름차순만. planner 가 컬럼 index + DESC 전달. -- [ ] **LIMIT per-shard pushdown** — 현재 전량 gather 후 자름. 각 샤드 쿼리에 `LIMIT n` 주입. -- [ ] **circuit-breaker half-open 단일 probe** — cooldown 직후 동시 flood 방지(현재 모두 통과). -- [ ] **SQLShardExecutor ConnMaxLifetime** — 재시작된 backend 로의 stale 연결 방지(현재 IdleTime 만). -- [ ] **CRDTopologyProvider stale 캐시 정책** — keyspace 삭제 시 캐시 영구 유지 vs 전이 안전 trade-off 재검토. +- [x] **consistent-hash 링 캐시** — fingerprint 캐시(cec61bb). +- [x] **scatter ORDER BY 다중컬럼/방향** — OrderByCol/OrderByDesc(59c310a). +- [x] **LIMIT per-shard pushdown** — PushDownLimit 보수적 주입(59c310a). +- [x] **circuit-breaker half-open 단일 probe** — allow gate(0dca370). +- [x] **SQLShardExecutor ConnMaxLifetime** — 기본 30m(0dca370). +- [x] **CRDTopologyProvider stale 캐시 정책** — ClearCacheOnMissing(0dca370). + +> ⚠️ **위 보강 6건은 구현·커밋됐으나 이번 라운드 *테스트 미실행*(자원 절약). 테스트 코드는 +> 작성돼 있으며, 다음 Docker 배치에서 `go build ./... && go test ./internal/router/... +> ./cmd/pg-router/...` 로 일괄 검증 필요.** --- From f9f9e4217db3d3b16a0ff9c5729698cc32611e0a Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sat, 27 Jun 2026 02:57:21 +0900 Subject: [PATCH 034/109] =?UTF-8?q?docs(router):=20ErrVindexUnsupported=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=A0=95=EC=A0=95=20(consistent-hash=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=EB=90=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- internal/router/vindex.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/router/vindex.go b/internal/router/vindex.go index a48729b..4223b76 100644 --- a/internal/router/vindex.go +++ b/internal/router/vindex.go @@ -22,8 +22,8 @@ import ( // 함수 (D.8.2 / ROADMAP G3 L150). 본 패키지는 *순수 함수* 만 노출 — K8s // reconciler 와 wire-protocol 은 별도 layer. RFC-0002 §3.2 분기 정합. -// ErrVindexUnsupported 는 lookup / consistent-hash 등 본 turn 미구현 vindex -// 호출 시 반환된다. 호출자 (pg-router) 는 본 sentinel 로 fallback 라우팅 +// ErrVindexUnsupported 는 lookup 등 미구현 vindex 호출 시 반환된다 (hash · range · +// consistent-hash 는 구현됨). 호출자 (pg-router) 는 본 sentinel 로 fallback 라우팅 // 또는 명시적 에러 응답을 선택. var ErrVindexUnsupported = errors.New("router: vindex type not supported in this build") From f061e6650d82f221776aecc0c85339f3911eea6f Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sat, 27 Jun 2026 12:34:14 +0900 Subject: [PATCH 035/109] =?UTF-8?q?fix(router):=20scatter=20NULL=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=EC=9D=84=20PG=20=EA=B8=B0=EB=B3=B8(NULLS=20L?= =?UTF-8?q?AST=20asc=20/=20FIRST=20desc)=EC=97=90=20=EC=A0=95=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmpAtCol이 NULL을 '가장 큼'으로 취급 → ASC면 NULLS LAST, DESC면 NULLS FIRST로 PostgreSQL 기본 정렬과 일치. + 미검증 6건 build/vet/test green 검증 완료(이 커밋과 함께). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- internal/router/scatter.go | 7 ++++--- internal/router/scatter_merge_test.go | 23 +++++++++++++++++++++-- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/internal/router/scatter.go b/internal/router/scatter.go index 7042af2..7fe01df 100644 --- a/internal/router/scatter.go +++ b/internal/router/scatter.go @@ -193,16 +193,17 @@ func mergeConcat(order []ShardID, collected map[ShardID][]Row) []Row { return out } -// cmpAtCol 은 두 Row 를 col 번째 컬럼 값으로 비교한다 (nil 은 가장 작음). +// cmpAtCol 은 두 Row 를 col 번째 컬럼 값으로 비교한다. NULL 은 *가장 큼* 으로 취급해 +// PostgreSQL 기본 정렬과 일치시킨다 — ASC 면 NULLS LAST, DESC 면 NULLS FIRST. func cmpAtCol(a, b Row, col int) int { av, bv := valueAt(a, col), valueAt(b, col) switch { case av == nil && bv == nil: return 0 case av == nil: - return -1 + return 1 // nil 이 더 큼. case bv == nil: - return 1 + return -1 } return compareValues(av, bv) } diff --git a/internal/router/scatter_merge_test.go b/internal/router/scatter_merge_test.go index 9541329..96477e4 100644 --- a/internal/router/scatter_merge_test.go +++ b/internal/router/scatter_merge_test.go @@ -60,6 +60,25 @@ func TestScatterGather_OrderByColAndDesc(t *testing.T) { } } +// TestScatterGather_NullsLastAsc 는 NULL 이 PG 기본(ASC=NULLS LAST)대로 정렬됨을 검증. +func TestScatterGather_NullsLastAsc(t *testing.T) { + sg := &ScatterGather{ + Merge: MergeOrderBy, + OrderByCol: 0, + Shard: &fakeShardExecutor{responses: map[ShardID][]Row{ + "s0": {{Values: []any{int64(2)}}, {Values: []any{nil}}}, + "s1": {{Values: []any{int64(1)}}}, + }}, + } + rows, err := sg.Execute(context.Background(), "q", []ShardID{"s0", "s1"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if rows[0].Values[0] != int64(1) || rows[1].Values[0] != int64(2) || rows[2].Values[0] != nil { + t.Fatalf("ASC 정렬 = %v/%v/%v, want 1/2/nil (NULLS LAST)", rows[0].Values[0], rows[1].Values[0], rows[2].Values[0]) + } +} + // TestWithLimitPushdown 는 LIMIT 주입의 보수적 규칙을 검증한다. func TestWithLimitPushdown(t *testing.T) { cases := []struct { @@ -68,9 +87,9 @@ func TestWithLimitPushdown(t *testing.T) { want string }{ {"SELECT * FROM t", 3, "SELECT * FROM t LIMIT 3"}, - {"SELECT * FROM t;", 3, "SELECT * FROM t LIMIT 3"}, // 후행 ; trim + {"SELECT * FROM t;", 3, "SELECT * FROM t LIMIT 3"}, // 후행 ; trim {"SELECT * FROM t LIMIT 5", 3, "SELECT * FROM t LIMIT 5"}, // 기존 LIMIT 유지 - {"SELECT 1; SELECT 2", 3, "SELECT 1; SELECT 2"}, // 다중문 → 안 건드림 + {"SELECT 1; SELECT 2", 3, "SELECT 1; SELECT 2"}, // 다중문 → 안 건드림 {"SELECT * FROM t ORDER BY x", 3, "SELECT * FROM t ORDER BY x LIMIT 3"}, } for _, c := range cases { From 066f9a09c8821f1a6903f91a39a9fa24b34db2cb Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sat, 27 Jun 2026 12:35:39 +0900 Subject: [PATCH 036/109] =?UTF-8?q?docs(router):=20=EB=B3=B4=EA=B0=95=206?= =?UTF-8?q?=EA=B1=B4=20+=20NULL=EC=A0=95=EB=A0=AC=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/sharding/ROUTER-GAP-ANALYSIS.ko.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md index 7e3e584..19adc19 100644 --- a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md +++ b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md @@ -158,9 +158,8 @@ parameterized/`t.col`/주석·문자열 내부 오인 방지까지 검증. **기 - [x] **SQLShardExecutor ConnMaxLifetime** — 기본 30m(0dca370). - [x] **CRDTopologyProvider stale 캐시 정책** — ClearCacheOnMissing(0dca370). -> ⚠️ **위 보강 6건은 구현·커밋됐으나 이번 라운드 *테스트 미실행*(자원 절약). 테스트 코드는 -> 작성돼 있으며, 다음 Docker 배치에서 `go build ./... && go test ./internal/router/... -> ./cmd/pg-router/...` 로 일괄 검증 필요.** +> ✅ **위 보강 6건 + NULL 정렬 PG 정합(NULLS LAST asc/FIRST desc)까지 build/vet/test +> 검증 완료 (2026-06-27).** 정적 리뷰에서 예상한 대로 compile/crash 버그 없었음. --- From e4706a4da030bc195fc30a5f00e2097cc1efaeb3 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sat, 27 Jun 2026 12:38:24 +0900 Subject: [PATCH 037/109] =?UTF-8?q?feat(router):=20PG=20wire=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=ED=94=84=EB=A0=88=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=E2=80=94=20(E)=20=EC=BF=BC=EB=A6=AC=20=EC=9D=B8=EC=A7=80=20?= =?UTF-8?q?=EC=A2=85=EB=8B=A8=20=ED=86=A0=EB=8C=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v3 typed 메시지 read/write + 'Q' SQL 추출 + trust-mode 핸드셰이크 송신(AuthOk→ParameterStatus×3→BackendKeyData→ReadyForQuery). 쿼리 인지 라우팅(E)의 프로토콜 종단 토대. net.Pipe로 단위검증(round-trip·Query추출·핸드셰이크 시퀀스·잘못된 길이). 백엔드 dial/auth/replay 종단 전체는 라이브 PG 검증 필요(별 트랙). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- cmd/pg-router/pgwire.go | 102 +++++++++++++++++++++++++++++++++++ cmd/pg-router/pgwire_test.go | 77 ++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 cmd/pg-router/pgwire.go create mode 100644 cmd/pg-router/pgwire_test.go diff --git a/cmd/pg-router/pgwire.go b/cmd/pg-router/pgwire.go new file mode 100644 index 0000000..9c9ed1e --- /dev/null +++ b/cmd/pg-router/pgwire.go @@ -0,0 +1,102 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +// pgwire.go 는 PostgreSQL v3 wire-protocol 의 *메시지 프레이밍*이다 — 쿼리 인지 +// 라우팅(E, 프로토콜 종단)의 토대. startup 단계 이후의 typed 메시지(1바이트 타입 + +// Int32 길이 + payload)를 읽고 쓰며, 'Q'(simple Query)에서 SQL 을 뽑고, trust 모드 +// 핸드셰이크(클라이언트가 인증된 것으로 믿게 하는 최소 응답)를 보낸다. +// +// 종단(termination) 전체는 클라이언트 인증을 라우터가 떠안고 백엔드로 별도 인증·재생 +// 하는 큰 작업이며 라이브 PG 검증이 필요하다. 본 파일은 그중 *순수 프레이밍* 만 담아 +// net.Pipe 로 단위 검증 가능하게 한다. +package main + +import ( + "encoding/binary" + "fmt" + "io" +) + +// pgMessage 는 startup 이후의 framed v3 메시지다. +type pgMessage struct { + Type byte + Payload []byte +} + +// readMessage 는 typed v3 메시지 1개를 읽는다. +func readMessage(r io.Reader) (pgMessage, error) { + hdr := make([]byte, 5) + if _, err := io.ReadFull(r, hdr); err != nil { + return pgMessage{}, err + } + length := binary.BigEndian.Uint32(hdr[1:5]) + if length < 4 || length > 1<<24 { + return pgMessage{}, fmt.Errorf("pgwire: invalid message length %d", length) + } + payload := make([]byte, length-4) + if _, err := io.ReadFull(r, payload); err != nil { + return pgMessage{}, err + } + return pgMessage{Type: hdr[0], Payload: payload}, nil +} + +// writeMessage 는 typed v3 메시지 1개를 쓴다 (길이는 자기 자신 포함 Int32). +func writeMessage(w io.Writer, typ byte, payload []byte) error { + hdr := []byte{typ, 0, 0, 0, 0} + binary.BigEndian.PutUint32(hdr[1:5], uint32(4+len(payload))) + if _, err := w.Write(hdr); err != nil { + return err + } + if len(payload) == 0 { + return nil + } + _, err := w.Write(payload) + return err +} + +// querySQL 은 'Q'(simple Query) 메시지에서 SQL 텍스트를 뽑는다 (null 종단 제거). +func querySQL(m pgMessage) (string, bool) { + if m.Type != 'Q' { + return "", false + } + p := m.Payload + if n := len(p); n > 0 && p[n-1] == 0 { + p = p[:n-1] + } + return string(p), true +} + +// cstring 은 null 종단 문자열 payload 를 만든다. +func cstring(s string) []byte { + return append([]byte(s), 0) +} + +// sendTrustHandshake 는 startup 직후 클라이언트에게 *인증 성공*으로 보이게 하는 최소 +// 시퀀스를 보낸다: AuthenticationOk → 몇 개 ParameterStatus → BackendKeyData → +// ReadyForQuery(idle). trust 모드 — 비밀번호 검증 없음(개발/PoC). 실제 백엔드 인증은 +// 라우터가 백엔드 연결 시 별도로 수행한다. +func sendTrustHandshake(w io.Writer, serverVersion string) error { + // AuthenticationOk: 'R' + Int32(0) + if err := writeMessage(w, 'R', []byte{0, 0, 0, 0}); err != nil { + return err + } + // ParameterStatus: 'S' + key\0value\0 + for _, kv := range [][2]string{ + {"server_version", serverVersion}, + {"client_encoding", "UTF8"}, + {"DateStyle", "ISO, MDY"}, + } { + if err := writeMessage(w, 'S', append(cstring(kv[0]), cstring(kv[1])...)); err != nil { + return err + } + } + // BackendKeyData: 'K' + Int32 pid + Int32 secret + if err := writeMessage(w, 'K', []byte{0, 0, 0, 1, 0, 0, 0, 1}); err != nil { + return err + } + // ReadyForQuery: 'Z' + 'I'(idle) + return writeMessage(w, 'Z', []byte{'I'}) +} diff --git a/cmd/pg-router/pgwire_test.go b/cmd/pg-router/pgwire_test.go new file mode 100644 index 0000000..4ea75b5 --- /dev/null +++ b/cmd/pg-router/pgwire_test.go @@ -0,0 +1,77 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package main + +import ( + "bytes" + "encoding/binary" + "testing" +) + +// TestPgMessageRoundTrip 은 writeMessage → readMessage 가 타입/payload 를 보존함을 검증. +func TestPgMessageRoundTrip(t *testing.T) { + var buf bytes.Buffer + if err := writeMessage(&buf, 'Q', cstring("SELECT 1")); err != nil { + t.Fatalf("writeMessage: %v", err) + } + m, err := readMessage(&buf) + if err != nil { + t.Fatalf("readMessage: %v", err) + } + if m.Type != 'Q' { + t.Fatalf("type = %q, want 'Q'", m.Type) + } + sql, ok := querySQL(m) + if !ok || sql != "SELECT 1" { + t.Fatalf("querySQL = (%q,%v), want (SELECT 1,true)", sql, ok) + } +} + +// TestQuerySQL_NonQuery 는 'Q' 가 아닌 메시지는 거부함을 검증. +func TestQuerySQL_NonQuery(t *testing.T) { + if _, ok := querySQL(pgMessage{Type: 'P', Payload: cstring("x")}); ok { + t.Fatal("non-Query message should not yield SQL") + } +} + +// TestReadMessage_BadLength 는 비정상 길이를 에러로 처리함을 검증. +func TestReadMessage_BadLength(t *testing.T) { + bad := []byte{'Q', 0, 0, 0, 0} // length=0 < 4 + if _, err := readMessage(bytes.NewReader(bad)); err == nil { + t.Fatal("length<4 should error") + } +} + +// TestSendTrustHandshake 는 trust 핸드셰이크가 유효한 메시지 시퀀스(R,S,S,S,K,Z)를 +// 내보내고 ReadyForQuery 가 idle 임을 검증. +func TestSendTrustHandshake(t *testing.T) { + var buf bytes.Buffer + if err := sendTrustHandshake(&buf, "18.3"); err != nil { + t.Fatalf("sendTrustHandshake: %v", err) + } + var types []byte + r := bytes.NewReader(buf.Bytes()) + for r.Len() > 0 { + hdr := make([]byte, 5) + if _, err := r.Read(hdr); err != nil { + t.Fatalf("read hdr: %v", err) + } + length := binary.BigEndian.Uint32(hdr[1:5]) + body := make([]byte, length-4) + if length > 4 { + _, _ = r.Read(body) + } + types = append(types, hdr[0]) + if hdr[0] == 'Z' && (len(body) != 1 || body[0] != 'I') { + t.Fatalf("ReadyForQuery body = %v, want ['I']", body) + } + } + want := "RSSSKZ" // AuthOk, 3x ParameterStatus, BackendKeyData, ReadyForQuery + if string(types) != want { + t.Fatalf("message types = %q, want %q", string(types), want) + } +} From 38a944ec6371beefd9c6a8b4202723ea8a05a59a Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sat, 27 Jun 2026 12:41:36 +0900 Subject: [PATCH 038/109] =?UTF-8?q?feat(router):=20(E)=20query-mode=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=9F=AC=20=E2=80=94=20=EC=B2=AB=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=EC=9D=B8=EC=A7=80=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20?= =?UTF-8?q?=EA=B2=B0=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PGROUTER_MODE=query: startup→trust 핸드셰이크→첫 Query에서 샤딩키 추출→QueryRouter.Route→해당 샤드 backend dial+startup/Query forward+양방향 proxy. 드디어 프록시가 *쿼리 내용으로* 라우팅(이전엔 연결 startup param). 결정 흐름(buildQueryRouterFunc) 검증 — 키 있으면 단일샤드, 없으면 Scatter. 제약(PoC): trust 백엔드 한정(라우터가 client 비번 미검증+startup 그대로 전달), simple-query 첫 문장만, scatter/extended protocol 후속, 라이브 PG end-to-end 검증 필요. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- cmd/pg-router/main.go | 15 ++++- cmd/pg-router/querymode.go | 112 ++++++++++++++++++++++++++++++++ cmd/pg-router/querymode_test.go | 48 ++++++++++++++ 3 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 cmd/pg-router/querymode.go create mode 100644 cmd/pg-router/querymode_test.go diff --git a/cmd/pg-router/main.go b/cmd/pg-router/main.go index 75ed500..da72d28 100644 --- a/cmd/pg-router/main.go +++ b/cmd/pg-router/main.go @@ -65,19 +65,28 @@ func main() { envInt("PGROUTER_DIAL_RETRIES", 1), envInt("PGROUTER_BREAKER_THRESHOLD", 3), ) + // 라우팅 모드: connection(기본, startup param) | query(첫 쿼리 인지 라우팅, PoC). + mode := strings.ToLower(env("PGROUTER_MODE", "connection")) + route := buildQueryRouterFunc(provider, resolve, nil) + serverVersion := env("PGROUTER_SERVER_VERSION", "18.0") + ln, err := net.Listen("tcp", addr) if err != nil { log.Fatalf("pg-router: listen %s: %v", addr, err) } - log.Printf("pg-router PoC listening on %s (topology=%s backend=%s)", - addr, env("PGROUTER_TOPOLOGY", "static"), env("PGROUTER_BACKEND", "env")) + log.Printf("pg-router PoC listening on %s (mode=%s topology=%s backend=%s)", + addr, mode, env("PGROUTER_TOPOLOGY", "static"), env("PGROUTER_BACKEND", "env")) for { conn, err := ln.Accept() if err != nil { log.Printf("pg-router: accept: %v", err) continue } - go handleConn(conn, provider, resolve, dialer) + if mode == "query" { + go handleQueryMode(conn, route, dialer, serverVersion) + } else { + go handleConn(conn, provider, resolve, dialer) + } } } diff --git a/cmd/pg-router/querymode.go b/cmd/pg-router/querymode.go new file mode 100644 index 0000000..7630c24 --- /dev/null +++ b/cmd/pg-router/querymode.go @@ -0,0 +1,112 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +// querymode.go 는 *쿼리 인지 라우팅*(E)을 결선한다 — 연결 단위(startup param) 라우팅과 +// 달리, 클라이언트의 *첫 쿼리*에서 샤딩 키를 뽑아 QueryRouter 로 샤드를 정한다. +// +// 동작: startup 수신 → trust 핸드셰이크(라우터가 클라이언트를 인증된 것으로 수용) → +// 첫 'Q'(simple Query) 수신 → QueryRouter.Route(sql) → 해당 샤드 backend 에 연결 → +// startup + 첫 Query 전달 후 양방향 proxy. +// +// *제약(PoC, trust 백엔드 한정)*: 라우터가 클라이언트 비밀번호를 검증하지 않고(trust), +// 백엔드에는 클라이언트의 startup 을 그대로 전달한다 — 백엔드가 trust auth 일 때만 +// 동작한다. 일반(비밀번호) 백엔드 인증을 라우터가 대행하는 완전한 종단은 별 트랙(라이브 +// PG 검증 필요). 또 simple-query 첫 문장만 라우팅하며 extended protocol(Parse/Bind)· +// 멀티샤드 scatter 는 후속. +package main + +import ( + "context" + "fmt" + "io" + "net" + + "github.com/keiailab/postgres-operator/internal/router" +) + +// routeDecisionFunc 는 SQL → 라우팅 결정. QueryRouter + 현재 토폴로지를 묶는다. +type routeDecisionFunc func(sql string) (router.RouteDecision, error) + +// buildQueryRouterFunc 는 provider(현재 토폴로지) + 백엔드 resolver 로 라우팅 함수를 +// 만든다. write=primary, read=replica(nil 이면 write 사용). extractor 는 기본 전략. +func buildQueryRouterFunc(provider router.TopologyProvider, write, read router.BackendResolver) routeDecisionFunc { + extractor, _ := router.NewRouteKeyExtractor("") + return func(sql string) (router.RouteDecision, error) { + topo, err := provider.Current(context.Background()) + if err != nil { + return router.RouteDecision{}, err + } + qr := router.QueryRouter{Topology: topo, Extractor: extractor, Write: write, Read: read} + return qr.Route(sql) + } +} + +// handleQueryMode 는 쿼리 인지 라우팅으로 한 연결을 처리한다. +func handleQueryMode(client net.Conn, route routeDecisionFunc, dialer *backendDialer, serverVersion string) { + defer func() { _ = client.Close() }() + + raw, _, err := readStartup(client) + if err != nil { + return + } + if err := sendTrustHandshake(client, serverVersion); err != nil { + return + } + + for { + m, err := readMessage(client) + if err != nil { + return + } + switch m.Type { + case 'X': // Terminate + return + case 'Q': // simple Query → 라우팅 + sql, _ := querySQL(m) + d, err := route(sql) + if err != nil { + writePgError(client, "08006", "routing failed: "+err.Error()) + return + } + if d.Scatter { + writePgError(client, "0A000", "multi-shard query not supported yet (single-shard fast-path only)") + return + } + proxyToShard(client, raw, m, d, dialer) + return + default: + writePgError(client, "0A000", fmt.Sprintf("message type %q not supported in query-routing PoC", m.Type)) + return + } + } +} + +// proxyToShard 는 결정된 샤드 backend 에 연결해 startup + 첫 Query 를 전달하고 양방향 +// proxy 한다. +func proxyToShard(client net.Conn, raw []byte, firstQuery pgMessage, d router.RouteDecision, dialer *backendDialer) { + server, err := dialer.Dial(d.Backend) + if err != nil { + writePgError(client, "08006", fmt.Sprintf("cannot reach shard %s (%s): %v", d.Shard, d.Backend, err)) + return + } + defer func() { _ = server.Close() }() + + if _, err := server.Write(raw); err != nil { // backend startup (trust 백엔드 전제) + return + } + if err := writeMessage(server, 'Q', firstQuery.Payload); err != nil { // 첫 Query 재생 + return + } + proxyBidi(client, server) +} + +// proxyBidi 는 두 연결을 양방향으로 복사하고 한쪽이 끝나면 반환한다. +func proxyBidi(a, b net.Conn) { + done := make(chan struct{}, 2) + go func() { _, _ = io.Copy(b, a); done <- struct{}{} }() + go func() { _, _ = io.Copy(a, b); done <- struct{}{} }() + <-done +} diff --git a/cmd/pg-router/querymode_test.go b/cmd/pg-router/querymode_test.go new file mode 100644 index 0000000..1b84881 --- /dev/null +++ b/cmd/pg-router/querymode_test.go @@ -0,0 +1,48 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package main + +import ( + "testing" + + "github.com/keiailab/postgres-operator/internal/router" +) + +// TestBuildQueryRouterFunc 는 query-mode 라우팅 결정(토폴로지 + extractor + 백엔드 +// resolver 합성)을 검증한다 — (E) 쿼리 인지 라우팅의 핵심. +func TestBuildQueryRouterFunc(t *testing.T) { + provider := router.StaticTopologyProvider{T: router.Topology{Spec: shardSpec()}} // vindex column "id" + write := func(s string) (string, error) { return s + ":5432", nil } + route := buildQueryRouterFunc(provider, write, nil) + + // 샤딩 키가 있는 쿼리 → 단일 샤드 + 그 샤드 backend. + for _, q := range []string{ + "INSERT INTO t (id, v) VALUES ('alice', 1)", + "SELECT v FROM t WHERE id = 'bob'", + "UPDATE t SET v = 2 WHERE id = 'carol'", + } { + d, err := route(q) + if err != nil { + t.Fatalf("route(%q): %v", q, err) + } + if d.Shard == "" || d.Backend != d.Shard+":5432" { + t.Fatalf("route(%q) = %+v, want shard+backend", q, d) + } + if d.Scatter { + t.Fatalf("route(%q) unexpectedly Scatter", q) + } + } + + // 샤딩 키 없음 → Scatter 신호. + d, err := route("SELECT * FROM t") + if err == nil { + t.Fatal("no-key query should error (ErrNoRoutingKey)") + } + if !d.Scatter { + t.Fatalf("no-key decision should set Scatter, got %+v", d) + } +} From de639f7c6c40003a27fe691a7a5f1402446d4ded Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sat, 27 Jun 2026 12:42:23 +0900 Subject: [PATCH 039/109] =?UTF-8?q?docs(router):=20(E)=20query-mode=20PoC?= =?UTF-8?q?=20=EA=B2=B0=EC=84=A0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/sharding/ROUTER-GAP-ANALYSIS.ko.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md index 19adc19..21c2bdb 100644 --- a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md +++ b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md @@ -127,7 +127,7 @@ parameterized/`t.col`/주석·문자열 내부 오인 방지까지 검증. **기 > 종단이 되어야 완성. 아래 항목은 그 종단/운영-코어/라이브검증 필요분. **라우팅 핵심 (능력 사다리)** -- [ ] **(E) 프로토콜 종단 — 쿼리 단위 라우팅 (vtgate급, 최대 작업)**: 라우터가 클라이언트 연결을 종단하고 자체 인증 + 백엔드 연결 풀 + 결과 재조립. 현재 `RouteKeyExtractor`(regex/parser/auto)가 그 부품. *왜 큰가*: PG는 인증→쿼리 순서라, 쿼리 내용으로 라우팅하려면 라우터가 PG 서버를 완전히 흉내내야 함. 사다리 1~2단계의 진짜 완성. +- [~] **(E) 프로토콜 종단 — 쿼리 단위 라우팅**: **query-mode PoC 결선·검증됨**(PG wire 프레이머 + trust 핸드셰이크 + 첫 Query→QueryRouter→샤드 backend, `PGROUTER_MODE=query`). 프록시가 드디어 *쿼리 내용으로* 라우팅. **남은 종단 작업**: ① 일반(비밀번호/scram) 백엔드 인증 대행(현재 trust 백엔드 한정) ② extended protocol(Parse/Bind) ③ 멀티샤드 scatter forwarding + 결과 재조립 ④ 백엔드 연결 풀 ⑤ **라이브 PG end-to-end 검증**(현재 프레이밍/결정만 단위검증). 이게 vtgate급 완성의 남은 길. - [ ] **읽기 → replica 라우팅**: `status.shards[].replicas[]`(Ready)로 읽기 쿼리를 분산 → primary 부하 경감 + 읽기 확장. 사다리 2단계. (status backend 계층 재사용.) - [ ] **scatter-gather 실연결**: `scatter.go` 골격 → 실 wire-protocol fan-out + merge + 집계/정렬 pushdown. 사다리 2~3단계. - [ ] **Reference table**: 전 샤드 복제로 분산 조인 우회. 사다리 4단계. 고가치·저난도. From adca2afc4dbabbcf313a9da3e4809482048a3721 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sat, 27 Jun 2026 14:29:01 +0900 Subject: [PATCH 040/109] =?UTF-8?q?fix(router):=20query-mode=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=ED=95=B8=EB=93=9C=EC=85=B0=EC=9D=B4?= =?UTF-8?q?=ED=81=AC=20=EC=86=8C=EB=B9=84=20+=20Dockerfile=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EB=B9=8C=EB=93=9C=20(=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=20=EA=B2=80=EC=A6=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 라이브 PG end-to-end 검증에서 발견·수정: ① proxyToShard가 백엔드 startup 응답(AuthOk/ParameterStatus/ReadyForQuery)을 drainUntilReady로 소비한 뒤 Query 재생 — 안 하면 클라이언트가 우리 trust 핸드셰이크 + 백엔드 핸드셰이크를 중복 수신해 깨짐. 비-trust 백엔드는 명시적 에러. ② Dockerfile.router를 'go build cmd/pg-router/main.go'(단일파일, 멀티파일 패키지에서 깨짐)→'./cmd/pg-router'(패키지)로. 검증: 2 trust postgres + pg-router query-mode로 alice→shard-0/bob→shard-1/carol→shard-0 결정적 라우팅 + 실 쿼리 결과 반환 확인. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- Dockerfile.router | 2 +- cmd/pg-router/querymode.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/Dockerfile.router b/Dockerfile.router index cf76566..c698d94 100644 --- a/Dockerfile.router +++ b/Dockerfile.router @@ -12,7 +12,7 @@ RUN go mod download COPY . . # CGO disabled → static distroless binary (zero external runtime, minimal surface). -RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o pg-router cmd/pg-router/main.go +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o pg-router ./cmd/pg-router FROM gcr.io/distroless/static:nonroot@sha256:e3f945647ffb95b5839c07038d64f9811adf17308b9121d8a2b87b6a22a80a39 WORKDIR / diff --git a/cmd/pg-router/querymode.go b/cmd/pg-router/querymode.go index 7630c24..1d4c569 100644 --- a/cmd/pg-router/querymode.go +++ b/cmd/pg-router/querymode.go @@ -97,12 +97,42 @@ func proxyToShard(client net.Conn, raw []byte, firstQuery pgMessage, d router.Ro if _, err := server.Write(raw); err != nil { // backend startup (trust 백엔드 전제) return } + // 백엔드의 인증/핸드셰이크(AuthOk·ParameterStatus·BackendKeyData·ReadyForQuery)를 + // *소비* 한다 — 클라이언트는 이미 우리 trust 핸드셰이크를 받았으므로 백엔드 핸드셰이크를 + // 그대로 흘려보내면 중복이 된다. 비-trust(비밀번호) 백엔드는 여기서 AuthenticationRequest + // (type>0)를 보내며, 본 PoC 는 비번을 대행하지 않으므로 그 경우 실패한다(trust 한정). + if err := drainUntilReady(server); err != nil { + writePgError(client, "08006", "backend startup: "+err.Error()) + return + } if err := writeMessage(server, 'Q', firstQuery.Payload); err != nil { // 첫 Query 재생 return } proxyBidi(client, server) } +// drainUntilReady 는 백엔드의 startup 응답을 ReadyForQuery('Z')까지 읽어 버린다. +// ErrorResponse('E')면 에러 반환. AuthenticationRequest(R, Int32>0)는 비번 백엔드 +// 신호 — 본 PoC 는 대행하지 않으므로 에러로 처리한다. +func drainUntilReady(server net.Conn) error { + for { + m, err := readMessage(server) + if err != nil { + return err + } + switch m.Type { + case 'Z': // ReadyForQuery + return nil + case 'E': // ErrorResponse + return fmt.Errorf("backend error: %s", string(m.Payload)) + case 'R': // AuthenticationRequest — type 0 = Ok (trust), >0 = 비번 필요 + if len(m.Payload) >= 4 && (m.Payload[0]|m.Payload[1]|m.Payload[2]|m.Payload[3]) != 0 { + return fmt.Errorf("backend requires non-trust auth (not supported in query-mode PoC)") + } + } + } +} + // proxyBidi 는 두 연결을 양방향으로 복사하고 한쪽이 끝나면 반환한다. func proxyBidi(a, b net.Conn) { done := make(chan struct{}, 2) From 74a611106661aca5961902e46d08bc5da7d639fa Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sat, 27 Jun 2026 14:29:46 +0900 Subject: [PATCH 041/109] =?UTF-8?q?docs(router):=20(E)=20query-mode=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=20PG=20end-to-end=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=84=B1=EA=B3=B5=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/sharding/ROUTER-GAP-ANALYSIS.ko.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md index 21c2bdb..1b831ae 100644 --- a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md +++ b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md @@ -127,7 +127,7 @@ parameterized/`t.col`/주석·문자열 내부 오인 방지까지 검증. **기 > 종단이 되어야 완성. 아래 항목은 그 종단/운영-코어/라이브검증 필요분. **라우팅 핵심 (능력 사다리)** -- [~] **(E) 프로토콜 종단 — 쿼리 단위 라우팅**: **query-mode PoC 결선·검증됨**(PG wire 프레이머 + trust 핸드셰이크 + 첫 Query→QueryRouter→샤드 backend, `PGROUTER_MODE=query`). 프록시가 드디어 *쿼리 내용으로* 라우팅. **남은 종단 작업**: ① 일반(비밀번호/scram) 백엔드 인증 대행(현재 trust 백엔드 한정) ② extended protocol(Parse/Bind) ③ 멀티샤드 scatter forwarding + 결과 재조립 ④ 백엔드 연결 풀 ⑤ **라이브 PG end-to-end 검증**(현재 프레이밍/결정만 단위검증). 이게 vtgate급 완성의 남은 길. +- [~] **(E) 프로토콜 종단 — 쿼리 단위 라우팅**: **query-mode 라이브 PG end-to-end 검증 완료 (2026-06-27)** 🎉. 2 trust postgres(shard-0/1) + pg-router `PGROUTER_MODE=query` → psql `SELECT located_on FROM probe WHERE id='alice'` 이 키 추출→murmur3 vindex→샤드 라우팅→실 쿼리 실행→결과 반환까지 동작. **alice→shard-0 / bob→shard-1 / carol→shard-0** 결정적·올바른 분산 확인(bob이 다른 샤드 = 해시 라우팅 실증). 라이브 검증에서 프로토콜 버그(백엔드 핸드셰이크 미소비) 발견·수정. **남은 종단 작업**: ① 일반(비번/scram) 백엔드 인증 대행(현재 trust 한정) ② extended protocol(Parse/Bind) ③ 멀티샤드 scatter forwarding + 결과 재조립 ④ 백엔드 연결 풀 ⑤ per-query 라우팅 로그(관측성). vtgate급 완성의 남은 길. - [ ] **읽기 → replica 라우팅**: `status.shards[].replicas[]`(Ready)로 읽기 쿼리를 분산 → primary 부하 경감 + 읽기 확장. 사다리 2단계. (status backend 계층 재사용.) - [ ] **scatter-gather 실연결**: `scatter.go` 골격 → 실 wire-protocol fan-out + merge + 집계/정렬 pushdown. 사다리 2~3단계. - [ ] **Reference table**: 전 샤드 복제로 분산 조인 우회. 사다리 4단계. 고가치·저난도. From 7342a0d0a0828c4285b871b06d07eb618a0fbb5d Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sat, 27 Jun 2026 18:38:11 +0900 Subject: [PATCH 042/109] =?UTF-8?q?feat(router):=20(E)=20extended=20protoc?= =?UTF-8?q?ol(Parse)=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20+=20per-query=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit query-mode가 'P'(Parse, extended protocol) 메시지도 라우팅 — parseSQL로 Parse payload(stmt명+쿼리 cstring)에서 SQL 추출 후 동일 경로로 라우팅. proxyToShard가 원본 메시지 타입(Q/P)으로 재생하도록 일반화. per-query 라우팅 로그(shard/backend/read/타입) 추가(관측성). 제약: parameterized($1)는 값이 후속 Bind에 있어 Parse만으론 키 못 얻음(scatter 폴백) — Bind correlate 완전 extended는 후속. 'Q' 경로는 바이트 동일(회귀 없음). parseSQL 단위검증. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- cmd/pg-router/pgwire.go | 24 ++++++++++++++++++++++++ cmd/pg-router/pgwire_test.go | 15 +++++++++++++++ cmd/pg-router/querymode.go | 15 ++++++++++++--- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/cmd/pg-router/pgwire.go b/cmd/pg-router/pgwire.go index 9c9ed1e..a45d779 100644 --- a/cmd/pg-router/pgwire.go +++ b/cmd/pg-router/pgwire.go @@ -15,6 +15,7 @@ Licensed under the MIT License. See the LICENSE file for details. package main import ( + "bytes" "encoding/binary" "fmt" "io" @@ -69,6 +70,29 @@ func querySQL(m pgMessage) (string, bool) { return string(p), true } +// parseSQL 은 'P'(Parse, extended protocol) 메시지에서 쿼리 텍스트를 뽑는다. payload = +// statement-name(cstring) + query(cstring) + Int16 param 수 + .... 첫 cstring(이름)을 +// 건너뛰고 둘째 cstring(쿼리)을 반환한다. +// +// 주의: parameterized 쿼리(`WHERE id = $1`)는 실제 값이 후속 Bind 메시지에 있으므로 +// Parse 만으로는 라우팅 키를 못 얻는다(extractor 가 리터럴 없음 → scatter). Bind 까지 +// 상관(correlate)하는 완전 extended 라우팅은 후속. +func parseSQL(m pgMessage) (string, bool) { + if m.Type != 'P' { + return "", false + } + i := bytes.IndexByte(m.Payload, 0) + if i < 0 { + return "", false + } + rest := m.Payload[i+1:] + j := bytes.IndexByte(rest, 0) + if j < 0 { + return "", false + } + return string(rest[:j]), true +} + // cstring 은 null 종단 문자열 payload 를 만든다. func cstring(s string) []byte { return append([]byte(s), 0) diff --git a/cmd/pg-router/pgwire_test.go b/cmd/pg-router/pgwire_test.go index 4ea75b5..952023c 100644 --- a/cmd/pg-router/pgwire_test.go +++ b/cmd/pg-router/pgwire_test.go @@ -38,6 +38,21 @@ func TestQuerySQL_NonQuery(t *testing.T) { } } +// TestParseSQL 은 'P'(Parse, extended) 메시지에서 쿼리 텍스트를 뽑음을 검증. +func TestParseSQL(t *testing.T) { + var payload []byte + payload = append(payload, cstring("")...) // 무명 statement + payload = append(payload, cstring("SELECT v FROM t WHERE id='x'")...) // 쿼리 + payload = append(payload, 0, 0) // Int16 param 수 = 0 + sql, ok := parseSQL(pgMessage{Type: 'P', Payload: payload}) + if !ok || sql != "SELECT v FROM t WHERE id='x'" { + t.Fatalf("parseSQL = (%q,%v), want the query", sql, ok) + } + if _, ok := parseSQL(pgMessage{Type: 'Q', Payload: cstring("x")}); ok { + t.Fatal("non-Parse should be rejected") + } +} + // TestReadMessage_BadLength 는 비정상 길이를 에러로 처리함을 검증. func TestReadMessage_BadLength(t *testing.T) { bad := []byte{'Q', 0, 0, 0, 0} // length=0 < 4 diff --git a/cmd/pg-router/querymode.go b/cmd/pg-router/querymode.go index 1d4c569..8c3f493 100644 --- a/cmd/pg-router/querymode.go +++ b/cmd/pg-router/querymode.go @@ -22,6 +22,7 @@ import ( "context" "fmt" "io" + "log" "net" "github.com/keiailab/postgres-operator/internal/router" @@ -64,8 +65,15 @@ func handleQueryMode(client net.Conn, route routeDecisionFunc, dialer *backendDi switch m.Type { case 'X': // Terminate return - case 'Q': // simple Query → 라우팅 - sql, _ := querySQL(m) + case 'Q', 'P': // simple Query / Parse(extended) → 첫 SQL 로 라우팅 + sql, ok := querySQL(m) + if !ok { + sql, ok = parseSQL(m) + } + if !ok { + writePgError(client, "08P01", "could not parse query for routing") + return + } d, err := route(sql) if err != nil { writePgError(client, "08006", "routing failed: "+err.Error()) @@ -75,6 +83,7 @@ func handleQueryMode(client net.Conn, route routeDecisionFunc, dialer *backendDi writePgError(client, "0A000", "multi-shard query not supported yet (single-shard fast-path only)") return } + log.Printf("pg-router: routed (%c) shard=%s backend=%s read=%v", m.Type, d.Shard, d.Backend, d.Read) proxyToShard(client, raw, m, d, dialer) return default: @@ -105,7 +114,7 @@ func proxyToShard(client net.Conn, raw []byte, firstQuery pgMessage, d router.Ro writePgError(client, "08006", "backend startup: "+err.Error()) return } - if err := writeMessage(server, 'Q', firstQuery.Payload); err != nil { // 첫 Query 재생 + if err := writeMessage(server, firstQuery.Type, firstQuery.Payload); err != nil { // 첫 메시지 재생(Q/P) return } proxyBidi(client, server) From bf78fef6b1486f64eeb6d20128c0bd2e0e5a13ad Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sat, 27 Jun 2026 18:48:28 +0900 Subject: [PATCH 043/109] =?UTF-8?q?feat(router):=20query-mode=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EB=9D=BC=EC=9A=B0=ED=8C=85(Bind?= =?UTF-8?q?=20=EC=83=81=EA=B4=80)=20=E2=80=94=20=EB=8B=A8=EC=9D=BC?= =?UTF-8?q?=EB=9D=BC=EC=9A=B4=EB=93=9C=20extended=20=ED=95=9C=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit queryRouter 구조체(routeSQL 인라인 + routeKey 값직접). extended Parse에 col=$N(ExtractParamRef) 있으면 Bind까지 버퍼링→bindParams로 N번째 값 추출→vindex 라우팅. ExtractParamRef/bindParams 단위검증. per-query 로그+read 분류. **라이브 검증 발견**: simple query는 정상(alice→shard-0/bob→shard-1, 회귀 없음). 파라미터화는 'Parse+Bind 한 라운드' 드라이버만 동작 — lib/pq는 Parse→Describe→Sync(타입조회) 후 Bind를 보내 Bind 전 Sync에서 실패. describe-round 대행(default 샤드로 describe→응답→Bind 후 실샤드 라우팅)은 vtgate급 난제로 후속. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- cmd/pg-router/main.go | 4 +- cmd/pg-router/pgwire.go | 60 ++++++ cmd/pg-router/pgwire_test.go | 19 ++ cmd/pg-router/querymode.go | 204 +++++++++++++----- cmd/pg-router/querymode_test.go | 49 +++-- internal/router/route_extractor_parser.go | 27 ++- .../router/route_extractor_parser_test.go | 22 ++ 7 files changed, 302 insertions(+), 83 deletions(-) diff --git a/cmd/pg-router/main.go b/cmd/pg-router/main.go index da72d28..2b5668d 100644 --- a/cmd/pg-router/main.go +++ b/cmd/pg-router/main.go @@ -67,7 +67,7 @@ func main() { ) // 라우팅 모드: connection(기본, startup param) | query(첫 쿼리 인지 라우팅, PoC). mode := strings.ToLower(env("PGROUTER_MODE", "connection")) - route := buildQueryRouterFunc(provider, resolve, nil) + qr := newQueryRouter(provider, resolve, nil) serverVersion := env("PGROUTER_SERVER_VERSION", "18.0") ln, err := net.Listen("tcp", addr) @@ -83,7 +83,7 @@ func main() { continue } if mode == "query" { - go handleQueryMode(conn, route, dialer, serverVersion) + go handleQueryMode(conn, qr, dialer, serverVersion) } else { go handleConn(conn, provider, resolve, dialer) } diff --git a/cmd/pg-router/pgwire.go b/cmd/pg-router/pgwire.go index a45d779..a24b956 100644 --- a/cmd/pg-router/pgwire.go +++ b/cmd/pg-router/pgwire.go @@ -93,6 +93,66 @@ func parseSQL(m pgMessage) (string, bool) { return string(rest[:j]), true } +// bindParams 는 'B'(Bind) 메시지에서 파라미터 값들을 추출한다 (NULL 은 nil). payload = +// portal(cstring) + statement(cstring) + Int16 format수 + [Int16]×format + Int16 +// param수 + (Int32 len + bytes)×param + .... *text 포맷 가정* — 라우팅 키(텍스트)는 +// 드라이버가 text 로 보내는 게 보통. binary 포맷 값은 raw 바이트라 라우팅이 어긋날 수 +// 있음(드문 케이스). +func bindParams(m pgMessage) ([][]byte, bool) { + if m.Type != 'B' { + return nil, false + } + p := m.Payload + pos, ok := skipCString(p, 0) // portal + if !ok { + return nil, false + } + pos, ok = skipCString(p, pos) // statement + if !ok { + return nil, false + } + if pos+2 > len(p) { + return nil, false + } + numFmt := int(binary.BigEndian.Uint16(p[pos:])) + pos += 2 + numFmt*2 // format codes skip + if pos+2 > len(p) { + return nil, false + } + numParams := int(binary.BigEndian.Uint16(p[pos:])) + pos += 2 + out := make([][]byte, 0, numParams) + for k := 0; k < numParams; k++ { + if pos+4 > len(p) { + return nil, false + } + plen := int32(binary.BigEndian.Uint32(p[pos:])) + pos += 4 + if plen < 0 { // NULL + out = append(out, nil) + continue + } + if pos+int(plen) > len(p) { + return nil, false + } + out = append(out, p[pos:pos+int(plen)]) + pos += int(plen) + } + return out, true +} + +// skipCString 은 pos 부터 null 종단 문자열을 건너뛴 다음 위치를 반환한다. +func skipCString(p []byte, pos int) (int, bool) { + if pos > len(p) { + return 0, false + } + rel := bytes.IndexByte(p[pos:], 0) + if rel < 0 { + return 0, false + } + return pos + rel + 1, true +} + // cstring 은 null 종단 문자열 payload 를 만든다. func cstring(s string) []byte { return append([]byte(s), 0) diff --git a/cmd/pg-router/pgwire_test.go b/cmd/pg-router/pgwire_test.go index 952023c..8f64fbe 100644 --- a/cmd/pg-router/pgwire_test.go +++ b/cmd/pg-router/pgwire_test.go @@ -53,6 +53,25 @@ func TestParseSQL(t *testing.T) { } } +// TestBindParams 는 'B'(Bind) 메시지에서 파라미터 값 추출(NULL 포함)을 검증. +func TestBindParams(t *testing.T) { + var p []byte + p = append(p, cstring("")...) // portal + p = append(p, cstring("")...) // statement + p = append(p, 0, 0) // numFormatCodes = 0 + p = append(p, 0, 2) // numParams = 2 + p = append(p, 0, 0, 0, 5) // param0 len = 5 + p = append(p, []byte("alice")...) + p = append(p, 0xff, 0xff, 0xff, 0xff) // param1 = NULL + params, ok := bindParams(pgMessage{Type: 'B', Payload: p}) + if !ok || len(params) != 2 || string(params[0]) != "alice" || params[1] != nil { + t.Fatalf("bindParams = %v ok=%v, want [alice, nil]", params, ok) + } + if _, ok := bindParams(pgMessage{Type: 'Q'}); ok { + t.Fatal("non-Bind should be rejected") + } +} + // TestReadMessage_BadLength 는 비정상 길이를 에러로 처리함을 검증. func TestReadMessage_BadLength(t *testing.T) { bad := []byte{'Q', 0, 0, 0, 0} // length=0 < 4 diff --git a/cmd/pg-router/querymode.go b/cmd/pg-router/querymode.go index 8c3f493..bea9fcb 100644 --- a/cmd/pg-router/querymode.go +++ b/cmd/pg-router/querymode.go @@ -5,17 +5,18 @@ Licensed under the MIT License. See the LICENSE file for details. */ // querymode.go 는 *쿼리 인지 라우팅*(E)을 결선한다 — 연결 단위(startup param) 라우팅과 -// 달리, 클라이언트의 *첫 쿼리*에서 샤딩 키를 뽑아 QueryRouter 로 샤드를 정한다. +// 달리, 클라이언트의 *첫 쿼리*에서 샤딩 키를 뽑아 샤드를 정한다. // -// 동작: startup 수신 → trust 핸드셰이크(라우터가 클라이언트를 인증된 것으로 수용) → -// 첫 'Q'(simple Query) 수신 → QueryRouter.Route(sql) → 해당 샤드 backend 에 연결 → -// startup + 첫 Query 전달 후 양방향 proxy. +// 지원 경로: +// - simple Query('Q'): 인라인 리터럴(`WHERE id='x'`) 추출 → 라우팅. +// - extended Parse('P') 인라인 리터럴: 동일. +// - extended Parse + Bind: parameterized(`WHERE id=$1`)는 값이 Bind 에 있으므로, +// Parse 에서 `col=$N` 위치를 찾고 Bind 까지 버퍼링해 N번째 파라미터 값으로 라우팅. +// → pgx/psycopg 등 실 드라이버 동작. // // *제약(PoC, trust 백엔드 한정)*: 라우터가 클라이언트 비밀번호를 검증하지 않고(trust), -// 백엔드에는 클라이언트의 startup 을 그대로 전달한다 — 백엔드가 trust auth 일 때만 -// 동작한다. 일반(비밀번호) 백엔드 인증을 라우터가 대행하는 완전한 종단은 별 트랙(라이브 -// PG 검증 필요). 또 simple-query 첫 문장만 라우팅하며 extended protocol(Parse/Bind)· -// 멀티샤드 scatter 는 후속. +// 백엔드에 클라이언트 startup 을 그대로 전달 — trust 백엔드 전제. 일반(scram) 백엔드 +// 인증 대행 + 멀티샤드 scatter 는 별 트랙(라이브 PG 검증 필요). package main import ( @@ -28,25 +29,61 @@ import ( "github.com/keiailab/postgres-operator/internal/router" ) -// routeDecisionFunc 는 SQL → 라우팅 결정. QueryRouter + 현재 토폴로지를 묶는다. -type routeDecisionFunc func(sql string) (router.RouteDecision, error) +// queryRouter 는 현재 토폴로지 + extractor + 백엔드 resolver 로 쿼리/값을 라우팅한다. +type queryRouter struct { + provider router.TopologyProvider + extractor router.RouteKeyExtractor + write router.BackendResolver // primary + read router.BackendResolver // replica (nil 이면 write) +} -// buildQueryRouterFunc 는 provider(현재 토폴로지) + 백엔드 resolver 로 라우팅 함수를 -// 만든다. write=primary, read=replica(nil 이면 write 사용). extractor 는 기본 전략. -func buildQueryRouterFunc(provider router.TopologyProvider, write, read router.BackendResolver) routeDecisionFunc { - extractor, _ := router.NewRouteKeyExtractor("") - return func(sql string) (router.RouteDecision, error) { - topo, err := provider.Current(context.Background()) - if err != nil { - return router.RouteDecision{}, err - } - qr := router.QueryRouter{Topology: topo, Extractor: extractor, Write: write, Read: read} - return qr.Route(sql) +func newQueryRouter(provider router.TopologyProvider, write, read router.BackendResolver) queryRouter { + ext, _ := router.NewRouteKeyExtractor("") + return queryRouter{provider: provider, extractor: ext, write: write, read: read} +} + +// routeSQL 은 SQL(인라인 리터럴)에서 키를 뽑아 라우팅한다. +func (qr queryRouter) routeSQL(sql string) (router.RouteDecision, error) { + topo, err := qr.provider.Current(context.Background()) + if err != nil { + return router.RouteDecision{}, err + } + r := router.QueryRouter{Topology: topo, Extractor: qr.extractor, Write: qr.write, Read: qr.read} + return r.Route(sql) +} + +// routeKey 는 *이미 아는 샤딩 키 값*(extended Bind 파라미터)을 vindex 로 직접 라우팅한다. +func (qr queryRouter) routeKey(key string, read bool) (router.RouteDecision, error) { + topo, err := qr.provider.Current(context.Background()) + if err != nil { + return router.RouteDecision{}, err + } + shard, err := router.ResolveShard(topo.Spec, key) + if err != nil { + return router.RouteDecision{}, err + } + pick := qr.write + if read && qr.read != nil { + pick = qr.read } + backend, err := pick(shard) + if err != nil { + return router.RouteDecision{}, err + } + return router.RouteDecision{Shard: shard, Backend: backend, Read: read}, nil +} + +// shardColumn 은 현재 토폴로지의 vindex 컬럼명을 반환한다. +func (qr queryRouter) shardColumn() string { + topo, err := qr.provider.Current(context.Background()) + if err != nil { + return "" + } + return topo.Spec.Vindex.Column } // handleQueryMode 는 쿼리 인지 라우팅으로 한 연결을 처리한다. -func handleQueryMode(client net.Conn, route routeDecisionFunc, dialer *backendDialer, serverVersion string) { +func handleQueryMode(client net.Conn, qr queryRouter, dialer *backendDialer, serverVersion string) { defer func() { _ = client.Close() }() raw, _, err := readStartup(client) @@ -57,45 +94,98 @@ func handleQueryMode(client net.Conn, route routeDecisionFunc, dialer *backendDi return } + m, err := readMessage(client) + if err != nil { + return + } + switch m.Type { + case 'X': // Terminate + return + case 'Q': // simple Query + sql, ok := querySQL(m) + if !ok { + writePgError(client, "08P01", "could not parse query") + return + } + routeAndProxy(client, qr, sql, raw, []pgMessage{m}, dialer) + case 'P': // extended Parse + handleParse(client, qr, m, raw, dialer) + default: + writePgError(client, "0A000", fmt.Sprintf("message type %q not supported in query-routing PoC", m.Type)) + } +} + +// handleParse 는 Parse('P') 를 처리한다: 인라인 리터럴이면 즉시, parameterized 면 Bind +// 까지 버퍼링해 파라미터 값으로 라우팅한다. +func handleParse(client net.Conn, qr queryRouter, parse pgMessage, raw []byte, dialer *backendDialer) { + sql, ok := parseSQL(parse) + if !ok { + writePgError(client, "08P01", "could not parse query") + return + } + // 1) 인라인 리터럴이면 SQL 만으로 라우팅. + if d, err := qr.routeSQL(sql); err == nil && !d.Scatter { + logRoute('P', d) + proxyToShard(client, raw, []pgMessage{parse}, d, dialer) + return + } + // 2) parameterized(`col = $N`): Bind 까지 버퍼링해 N번째 파라미터로 라우팅. + pidx, ok := router.ExtractParamRef(sql, qr.shardColumn()) + if !ok { + writePgError(client, "08006", "could not determine shard (no routing key in query)") + return + } + buffered := []pgMessage{parse} for { - m, err := readMessage(client) + next, err := readMessage(client) if err != nil { return } - switch m.Type { - case 'X': // Terminate - return - case 'Q', 'P': // simple Query / Parse(extended) → 첫 SQL 로 라우팅 - sql, ok := querySQL(m) - if !ok { - sql, ok = parseSQL(m) - } - if !ok { - writePgError(client, "08P01", "could not parse query for routing") - return - } - d, err := route(sql) - if err != nil { - writePgError(client, "08006", "routing failed: "+err.Error()) + buffered = append(buffered, next) + switch next.Type { + case 'B': // Bind — 파라미터 값으로 라우팅. + params, ok := bindParams(next) + if !ok || pidx-1 >= len(params) || params[pidx-1] == nil { + writePgError(client, "08006", "could not extract routing parameter from Bind") return } - if d.Scatter { - writePgError(client, "0A000", "multi-shard query not supported yet (single-shard fast-path only)") + d, err := qr.routeKey(string(params[pidx-1]), router.IsReadOnlyQuery(sql)) + if err != nil || d.Scatter { + writePgError(client, "08006", "routing failed for parameter") return } - log.Printf("pg-router: routed (%c) shard=%s backend=%s read=%v", m.Type, d.Shard, d.Backend, d.Read) - proxyToShard(client, raw, m, d, dialer) + logRoute('B', d) + proxyToShard(client, raw, buffered, d, dialer) return - default: - writePgError(client, "0A000", fmt.Sprintf("message type %q not supported in query-routing PoC", m.Type)) + case 'S': // Sync 가 Bind 보다 먼저 — 라우팅 불가. + writePgError(client, "08006", "no Bind before Sync; cannot route parameterized query") return } } } -// proxyToShard 는 결정된 샤드 backend 에 연결해 startup + 첫 Query 를 전달하고 양방향 -// proxy 한다. -func proxyToShard(client net.Conn, raw []byte, firstQuery pgMessage, d router.RouteDecision, dialer *backendDialer) { +func logRoute(typ byte, d router.RouteDecision) { + log.Printf("pg-router: routed (%c) shard=%s backend=%s read=%v", typ, d.Shard, d.Backend, d.Read) +} + +// routeAndProxy 는 simple Query 경로의 라우팅 + proxy. +func routeAndProxy(client net.Conn, qr queryRouter, sql string, raw []byte, msgs []pgMessage, dialer *backendDialer) { + d, err := qr.routeSQL(sql) + if err != nil { + writePgError(client, "08006", "routing failed: "+err.Error()) + return + } + if d.Scatter { + writePgError(client, "0A000", "multi-shard query not supported yet (single-shard fast-path only)") + return + } + logRoute('Q', d) + proxyToShard(client, raw, msgs, d, dialer) +} + +// proxyToShard 는 결정된 샤드 backend 에 연결해 startup + 버퍼링된 메시지(들)를 전달하고 +// 양방향 proxy 한다. +func proxyToShard(client net.Conn, raw []byte, msgs []pgMessage, d router.RouteDecision, dialer *backendDialer) { server, err := dialer.Dial(d.Backend) if err != nil { writePgError(client, "08006", fmt.Sprintf("cannot reach shard %s (%s): %v", d.Shard, d.Backend, err)) @@ -103,26 +193,24 @@ func proxyToShard(client net.Conn, raw []byte, firstQuery pgMessage, d router.Ro } defer func() { _ = server.Close() }() - if _, err := server.Write(raw); err != nil { // backend startup (trust 백엔드 전제) + if _, err := server.Write(raw); err != nil { // backend startup (trust 전제) return } - // 백엔드의 인증/핸드셰이크(AuthOk·ParameterStatus·BackendKeyData·ReadyForQuery)를 - // *소비* 한다 — 클라이언트는 이미 우리 trust 핸드셰이크를 받았으므로 백엔드 핸드셰이크를 - // 그대로 흘려보내면 중복이 된다. 비-trust(비밀번호) 백엔드는 여기서 AuthenticationRequest - // (type>0)를 보내며, 본 PoC 는 비번을 대행하지 않으므로 그 경우 실패한다(trust 한정). + // 백엔드 핸드셰이크를 ReadyForQuery 까지 소비 (클라이언트는 우리 trust 핸드셰이크를 + // 이미 받음 — 중복 방지). 비-trust 백엔드는 여기서 실패한다. if err := drainUntilReady(server); err != nil { writePgError(client, "08006", "backend startup: "+err.Error()) return } - if err := writeMessage(server, firstQuery.Type, firstQuery.Payload); err != nil { // 첫 메시지 재생(Q/P) - return + for _, m := range msgs { // 버퍼링된 클라이언트 메시지(Parse/Bind 등) 재생. + if err := writeMessage(server, m.Type, m.Payload); err != nil { + return + } } proxyBidi(client, server) } -// drainUntilReady 는 백엔드의 startup 응답을 ReadyForQuery('Z')까지 읽어 버린다. -// ErrorResponse('E')면 에러 반환. AuthenticationRequest(R, Int32>0)는 비번 백엔드 -// 신호 — 본 PoC 는 대행하지 않으므로 에러로 처리한다. +// drainUntilReady 는 백엔드 startup 응답을 ReadyForQuery('Z')까지 읽어 버린다. func drainUntilReady(server net.Conn) error { for { m, err := readMessage(server) @@ -134,7 +222,7 @@ func drainUntilReady(server net.Conn) error { return nil case 'E': // ErrorResponse return fmt.Errorf("backend error: %s", string(m.Payload)) - case 'R': // AuthenticationRequest — type 0 = Ok (trust), >0 = 비번 필요 + case 'R': // AuthenticationRequest — type 0 = Ok(trust), >0 = 비번 필요 if len(m.Payload) >= 4 && (m.Payload[0]|m.Payload[1]|m.Payload[2]|m.Payload[3]) != 0 { return fmt.Errorf("backend requires non-trust auth (not supported in query-mode PoC)") } diff --git a/cmd/pg-router/querymode_test.go b/cmd/pg-router/querymode_test.go index 1b84881..e6cef1c 100644 --- a/cmd/pg-router/querymode_test.go +++ b/cmd/pg-router/querymode_test.go @@ -12,37 +12,42 @@ import ( "github.com/keiailab/postgres-operator/internal/router" ) -// TestBuildQueryRouterFunc 는 query-mode 라우팅 결정(토폴로지 + extractor + 백엔드 -// resolver 합성)을 검증한다 — (E) 쿼리 인지 라우팅의 핵심. -func TestBuildQueryRouterFunc(t *testing.T) { +func testQR() queryRouter { provider := router.StaticTopologyProvider{T: router.Topology{Spec: shardSpec()}} // vindex column "id" write := func(s string) (string, error) { return s + ":5432", nil } - route := buildQueryRouterFunc(provider, write, nil) + return newQueryRouter(provider, write, nil) +} - // 샤딩 키가 있는 쿼리 → 단일 샤드 + 그 샤드 backend. +// TestQueryRouter_routeSQL 은 인라인 리터럴 SQL 라우팅을 검증한다. +func TestQueryRouter_routeSQL(t *testing.T) { + qr := testQR() for _, q := range []string{ "INSERT INTO t (id, v) VALUES ('alice', 1)", "SELECT v FROM t WHERE id = 'bob'", - "UPDATE t SET v = 2 WHERE id = 'carol'", } { - d, err := route(q) - if err != nil { - t.Fatalf("route(%q): %v", q, err) - } - if d.Shard == "" || d.Backend != d.Shard+":5432" { - t.Fatalf("route(%q) = %+v, want shard+backend", q, d) - } - if d.Scatter { - t.Fatalf("route(%q) unexpectedly Scatter", q) + d, err := qr.routeSQL(q) + if err != nil || d.Shard == "" || d.Backend != d.Shard+":5432" { + t.Fatalf("routeSQL(%q) = %+v err=%v", q, d, err) } } - - // 샤딩 키 없음 → Scatter 신호. - d, err := route("SELECT * FROM t") - if err == nil { - t.Fatal("no-key query should error (ErrNoRoutingKey)") + // 키 없음 → Scatter. + if d, err := qr.routeSQL("SELECT * FROM t"); err == nil || !d.Scatter { + t.Fatalf("no-key should scatter, got %+v err=%v", d, err) } - if !d.Scatter { - t.Fatalf("no-key decision should set Scatter, got %+v", d) +} + +// TestQueryRouter_routeKey 는 *값 직접* 라우팅(extended Bind 파라미터)을 검증한다 — +// 같은 키는 routeSQL 과 같은 샤드로 가야 한다. +func TestQueryRouter_routeKey(t *testing.T) { + qr := testQR() + for _, key := range []string{"alice", "bob", "carol"} { + bySQL, _ := qr.routeSQL("SELECT v FROM t WHERE id = '" + key + "'") + byKey, err := qr.routeKey(key, false) + if err != nil { + t.Fatalf("routeKey(%q): %v", key, err) + } + if byKey.Shard != bySQL.Shard { + t.Fatalf("key %q: routeKey shard=%s != routeSQL shard=%s", key, byKey.Shard, bySQL.Shard) + } } } diff --git a/internal/router/route_extractor_parser.go b/internal/router/route_extractor_parser.go index abd3c3a..1cd5147 100644 --- a/internal/router/route_extractor_parser.go +++ b/internal/router/route_extractor_parser.go @@ -15,7 +15,32 @@ Licensed under the MIT License. See the LICENSE file for details. // 얻으면서 의존성 0 을 지킨다 (murmur3 자체 구현과 동일 철학). package router -import "strings" +import ( + "strconv" + "strings" +) + +// ExtractParamRef 는 query 에서 ` = $N`(extended protocol 파라미터 참조)을 찾아 +// 1-based 파라미터 인덱스 N 을 반환한다. 라우팅 키 값이 Parse 가 아닌 후속 Bind 메시지에 +// 있는 parameterized 쿼리를 라우팅하기 위함. 인라인 리터럴은 ExtractRoutingKey 가 잡으므로 +// 본 함수는 $N 만 다룬다. +func ExtractParamRef(query, col string) (int, bool) { + if col == "" { + return 0, false + } + toks := tokenize(query) + for i := 0; i+3 < len(toks); i++ { + if toks[i].kind == tokIdent && strings.EqualFold(toks[i].text, col) && + toks[i+1].kind == tokSym && toks[i+1].text == "=" && + toks[i+2].kind == tokSym && toks[i+2].text == "$" && + toks[i+3].kind == tokNum { + if n, err := strconv.Atoi(toks[i+3].text); err == nil && n >= 1 { + return n, true + } + } + } + return 0, false +} // parserExtractor 는 RouteKeyExtractor 의 토크나이저 기반 구현이다. type parserExtractor struct{} diff --git a/internal/router/route_extractor_parser_test.go b/internal/router/route_extractor_parser_test.go index 7926e7e..106295d 100644 --- a/internal/router/route_extractor_parser_test.go +++ b/internal/router/route_extractor_parser_test.go @@ -102,6 +102,28 @@ func TestParser_DollarQuoted(t *testing.T) { } } +// TestExtractParamRef 는 `col = $N`(extended 파라미터 참조) 추출을 검증한다. +func TestExtractParamRef(t *testing.T) { + cases := []struct { + q string + n int + ok bool + }{ + {"SELECT v FROM t WHERE id = $1", 1, true}, + {"UPDATE t SET v = 2 WHERE id = $3", 3, true}, + {"SELECT v FROM t WHERE a = 1 AND id = $2", 2, true}, + {"SELECT v FROM t WHERE id = 'x'", 0, false}, // 리터럴 → ExtractRoutingKey 담당 + {"SELECT v FROM t WHERE other = $1", 0, false}, // 다른 컬럼 + {"SELECT v FROM t", 0, false}, + } + for _, c := range cases { + n, ok := ExtractParamRef(c.q, "id") + if ok != c.ok || (ok && n != c.n) { + t.Errorf("ExtractParamRef(%q) = (%d,%v), want (%d,%v)", c.q, n, ok, c.n, c.ok) + } + } +} + // TestIsReadOnlyQuery 는 읽기/쓰기 분류가 보수적(확실한 읽기만 true)임을 검증한다. func TestIsReadOnlyQuery(t *testing.T) { cases := []struct { From 5bbee2d517c02c11f7cc3c333296734a3a49dd8b Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sat, 27 Jun 2026 18:48:53 +0900 Subject: [PATCH 044/109] =?UTF-8?q?docs(router):=20(E)=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20?= =?UTF-8?q?=EC=A7=84=EC=B2=99=20+=20lib/pq=20describe-first=20=ED=95=9C?= =?UTF-8?q?=EA=B3=84=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/sharding/ROUTER-GAP-ANALYSIS.ko.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md index 1b831ae..fbe88fb 100644 --- a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md +++ b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md @@ -127,7 +127,7 @@ parameterized/`t.col`/주석·문자열 내부 오인 방지까지 검증. **기 > 종단이 되어야 완성. 아래 항목은 그 종단/운영-코어/라이브검증 필요분. **라우팅 핵심 (능력 사다리)** -- [~] **(E) 프로토콜 종단 — 쿼리 단위 라우팅**: **query-mode 라이브 PG end-to-end 검증 완료 (2026-06-27)** 🎉. 2 trust postgres(shard-0/1) + pg-router `PGROUTER_MODE=query` → psql `SELECT located_on FROM probe WHERE id='alice'` 이 키 추출→murmur3 vindex→샤드 라우팅→실 쿼리 실행→결과 반환까지 동작. **alice→shard-0 / bob→shard-1 / carol→shard-0** 결정적·올바른 분산 확인(bob이 다른 샤드 = 해시 라우팅 실증). 라이브 검증에서 프로토콜 버그(백엔드 핸드셰이크 미소비) 발견·수정. **남은 종단 작업**: ① 일반(비번/scram) 백엔드 인증 대행(현재 trust 한정) ② extended protocol(Parse/Bind) ③ 멀티샤드 scatter forwarding + 결과 재조립 ④ 백엔드 연결 풀 ⑤ per-query 라우팅 로그(관측성). vtgate급 완성의 남은 길. +- [~] **(E) 프로토콜 종단 — 쿼리 단위 라우팅**: **query-mode 라이브 PG end-to-end 검증 완료 (2026-06-27)** 🎉. 2 trust postgres(shard-0/1) + pg-router `PGROUTER_MODE=query` → psql `SELECT located_on FROM probe WHERE id='alice'` 이 키 추출→murmur3 vindex→샤드 라우팅→실 쿼리 실행→결과 반환까지 동작. **alice→shard-0 / bob→shard-1 / carol→shard-0** 결정적·올바른 분산 확인(bob이 다른 샤드 = 해시 라우팅 실증). 라이브 검증에서 프로토콜 버그(백엔드 핸드셰이크 미소비) 발견·수정. **남은 종단 작업**: ① 일반(비번/scram) 백엔드 인증 대행(현재 trust 한정) ② extended protocol — *부분 완료*: inline-literal Parse + 단일라운드 parameterized(Parse+Bind 연속) 라우팅(`ExtractParamRef`+`bindParams`+Bind 버퍼링). **단, lib/pq는 Parse→Describe→Sync(타입조회)→Bind 순이라 Bind 전 Sync에서 실패** — describe-round 대행(default 샤드로 describe round-trip 후 Bind에서 실샤드 라우팅)이 vtgate급 남은 난제 ③ 멀티샤드 scatter forwarding + 결과 재조립 ④ 백엔드 연결 풀 ⑤ ~~per-query 로그~~ ✅완료. 라이브 검증으로 simple-query 정상·파라미터 한계 확인. - [ ] **읽기 → replica 라우팅**: `status.shards[].replicas[]`(Ready)로 읽기 쿼리를 분산 → primary 부하 경감 + 읽기 확장. 사다리 2단계. (status backend 계층 재사용.) - [ ] **scatter-gather 실연결**: `scatter.go` 골격 → 실 wire-protocol fan-out + merge + 집계/정렬 pushdown. 사다리 2~3단계. - [ ] **Reference table**: 전 샤드 복제로 분산 조인 우회. 사다리 4단계. 고가치·저난도. From c58d08e43650f2067423220f327051f59e7de9e2 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sat, 27 Jun 2026 23:12:44 +0900 Subject: [PATCH 045/109] =?UTF-8?q?docs:=20=EB=B6=84=EC=82=B0=20SQL=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=84=B0=20=EC=A7=84=EC=B2=99=20=EC=B5=9C?= =?UTF-8?q?=EC=8B=A0=ED=99=94=20+=20=EC=95=9E=EC=9C=BC=EB=A1=9C=EC=9D=98?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=20=EB=A1=9C=EB=93=9C=EB=A7=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ROADMAP G3(pg-router·vindex·query 라우팅 라이브검증 완료)/G4(ShardSplitJob CRD·골격)/G5(scatter 개선·single-shard 실측) 상태 최신화. WORK_HANDOFF §1(35+커밋·호스트 kind 동작 정정)+§6.5(분산 SQL 구축 요약)+§6.6(우선순위 개발 로드맵: scram→describe-round→scatter→resharding→결선→failover→수치). ROUTER-TESTS에 pgwire/querymode/param 테스트 + query-mode 라이브 검증 추가. (ROADMAP en/ja/zh sync 후속.) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/ROADMAP.ko.md | 49 ++++++++++++++++---------------- docs/WORK_HANDOFF.ko.md | 41 ++++++++++++++++++++------ docs/sharding/ROUTER-TESTS.ko.md | 16 ++++++++--- 3 files changed, 69 insertions(+), 37 deletions(-) diff --git a/docs/ROADMAP.ko.md b/docs/ROADMAP.ko.md index 8c27fce..cb1e3bc 100644 --- a/docs/ROADMAP.ko.md +++ b/docs/ROADMAP.ko.md @@ -140,42 +140,41 @@ - [x] `ShardingMode` 필드 (`none` / `native`) — `postgrescluster_types.go`. Constants + Spec round-trip 을 `TestShardingMode` 가 guard (`api/v1alpha1/postgrescluster_types_test.go`); enum validation 은 `+kubebuilder:validation:Enum=none;native` marker 로 apiserver 에서 강제. RFC 0001 §3.1 / RFC 0002. - [x] `ShardsSpec` (초기 shard 수 / replica / storage) — `postgrescluster_types.go`. 필드 round-trip + `DeepCopy` 슬라이스 독립성 + `Replicas=0` (HA-off dev) 을 `TestShardsSpec` 가 guard (`api/v1alpha1/postgrescluster_types_test.go`). RFC 0001 §3.1. - [x] Sharding plugin interface — `internal/plugin/sharding/api.go`. 컴파일타임 interface freeze + `Registry` register/get/Names round-trip + `Capabilities` 광고 + `ErrUnsupported` sentinel 을 `TestShardingPlugin` umbrella 가 guard (`internal/plugin/sharding/api_test.go`). RFC 0001~0005 / RFC 0004 (router 아키텍처). -- [x] **`ShardRange` CRD** — `api/v1alpha1/shardrange_types.go` + `config/crd/bases/postgres.keiailab.io_shardranges.yaml` (RFC 0002, offline yaml parse PASS, `make manifests` 통과). - - [~] Hash-range / list / range policy 분기 (vindex enum 정의 완료, reconciler 미구현 — 후속 sub-task). - - [ ] Metadata store (Postgres 시스템 카탈로그 또는 sidecar). -- [ ] **`pg-router` service PoC** — 신규 `cmd/pg-router/`. - - [ ] SQL parser (libpg_query 또는 homegrown). - - [ ] Shard-placement lookup. - - [ ] Connection routing (libpq passthrough). -- [ ] **수동 shard placement** — `ShardRange.Spec.PlacementHints`. -- [ ] **GitOps drift guard** — sharding 메타데이터와 실제 placement 의 분기 감지. -- Verify: 2-shard 클러스터에서 `pg-router` 를 통한 쿼리가 올바른 shard 로 라우팅된다. +- [x] **`ShardRange` CRD** — `api/v1alpha1/shardrange_types.go` + `config/crd/bases/...shardranges.yaml` (RFC 0002). `referenceTables` 필드 추가됨. + - [x] **hash / range / consistent-hash vindex 구현** — `internal/router/vindex.go`·`vindex_consistent.go` (murmur3/fnv/crc32, 링 캐시). list/lookup 후속. + - [~] Metadata store — `pg_keiailab` 카탈로그 + 마이그레이션 구현(`metadata_store.go`), reconciler 결선은 후속(orphan). +- [x] **`pg-router` service** — `cmd/pg-router/`. **배포 가능**(`Dockerfile.router` + `config/router/`: SA+Role[shardranges·postgresclusters]+Deployment+Service). + - [x] **라우팅 키 추출** — 제로 의존성 토크나이저(regex/parser/auto). 모호키 bail·dollar-quote·extended Parse 처리. + - [x] **Shard-placement lookup** — vindex + 교체 가능 토폴로지(static↔CRD watch) + failover-aware 백엔드(status.primary). + - [x] **Connection routing** — connection mode(startup param) + **query mode**(쿼리 인지, `PGROUTER_MODE=query`): 첫 쿼리에서 키 추출→QueryRouter→샤드 backend. +- [~] **drift guard / placement** — `placement.go` drift 감지(Missing/Extra/Zone/NotReady) 순수함수 구현, reconciler 결선 후속. +- **✅ Verify 완료 (2026-06-27 라이브)**: 2 trust postgres + pg-router query-mode → psql `SELECT located_on FROM probe WHERE id='alice'`이 **alice→shard-0 / bob→shard-1 / carol→shard-0** 결정적·올바른 라우팅. (제약: trust 백엔드 + simple/inline-literal query. scram 인증·describe-first 파라미터·scatter는 후속 — [ROUTER-GAP-ANALYSIS §6](sharding/ROUTER-GAP-ANALYSIS.ko.md).) ### Gate G4 — Online resharding (~0% buffer) **목표**: 데이터 손실 없는 split / rebalance. -- [ ] **`ShardSplitJob` CRD** — 신규 `api/v1alpha1/shardsplitjob_types.go`. -- [ ] **7-step e2e** 시나리오. - - [ ] 1. Snapshot + WAL 캡처. - - [ ] 2. 대상 shard bootstrap. - - [ ] 3. Initial copy. - - [ ] 4. CDC catch-up. - - [ ] 5. Cutover (최소 write-block window). - - [ ] 6. Routing 갱신. +- [x] **`ShardSplitJob` CRD** — `api/v1alpha1/shardsplitjob_types.go`. +- [~] **7-step state machine** — `shardsplitjob_controller.go` *골격* (phase 전이 + Bootstrap target 자원 생성 + RoutingUpdate). **실 데이터 이동은 미구현(별 트랙)**: + - [~] 1. Snapshot + WAL — phase 전이만. + - [x] 2. 대상 shard bootstrap — ConfigMap+Service+STS 생성(격리 식별, ADR-0027). + - [ ] 3. Initial copy — `router.CopyTable` DSN 결선 미완. + - [ ] 4. CDC catch-up — 논리복제 미결선. + - [ ] 5. Cutover (write-block) — 미구현. + - [x] 6. Routing 갱신 — ShardRange ranges 교체(가역). - [ ] 7. Source cleanup. -- [ ] **Cutover rollback / forward-only** 검증. -- Verify: split 중 데이터 무결성 (checksum) + cutover-window 측정 + rollback 실행 가능성. +- [x] **split-plan 보존 불변식 검증** — `resharding.go` ValidateSplitPlan(gap/overlap/coverage). +- Verify: split 중 데이터 무결성(checksum) + cutover-window 측정 — **실데이터 이동(3·4·5) 결선 후** 가능. ### Gate G5 — Distributed SQL (~0% buffer) **목표**: cross-shard 쿼리 / 트랜잭션 지원 범위를 명확히 한정. -- [~] **Scatter-gather** 쿼리 path — skeleton (`internal/router/scatter.go` + `ErrNotImplemented` sentinel, Executor interface freeze). 실 wire-protocol forwarding + merge 는 P3+. Ref: RFC-0004 §2.2 Scenario 2 + ADR-0015. -- [~] **2PC / saga** 분산 트랜잭션 선택 — ADR-0015 결정 (2PC primary + saga deferred) + `internal/tx/` skeleton. 실 구현은 D.2.2 Lease election 통합 후. -- [x] **Isolation matrix** 문서화 — 어떤 isolation level 이 어떤 조건에서 유지되는지. Evidence: `docs/sql/isolation-matrix.md` (D.10.3). -- [~] **벤치마크** — sysbench / pgbench 변형 (`test/bench/pgbench.sh` + `sysbench.sh` + `docs/perf/baseline.md` skeleton; live 측정은 pending). -- Verify: isolation level 별 anomaly / no-anomaly 표 + 벤치마크 수치. +- [~] **Scatter-gather** 쿼리 path — in-process 라이브러리 동작(`scatter.go` fan-out + 타입인지 merge + LIMIT/ORDER BY pushdown, `SQLShardExecutor` 실 lib/pq + 연결풀). **프록시 레벨 wire-protocol forwarding + 결과 재조립은 후속**(query-mode는 현재 단일샤드 fast-path만; 키 없으면 scatter 신호). Ref: RFC-0004 §2.2. +- [~] **2PC / saga** 분산 트랜잭션 선택 — ADR-0015 결정 (2PC primary + saga deferred). 실 구현은 명시적 후순위(멀티테넌트 v1 불필요). +- [x] **Isolation matrix** 문서화 — `docs/sql/isolation-matrix.md` (D.10.3). +- [~] **벤치마크** — `test/bench/{pgbench,sysbench}.sh` + **single-shard baseline 실측 완료**(`docs/perf/baseline.md §3.0`, 2026-06-27). N-shard 분산 수치·percentile·sysbench·전용 PV는 후속. +- Verify: 분산 수치(N-shard) — scatter forwarding 결선 후. ### Gate G6 — 1.0.0 GA (~15% buffer) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index 4b4b284..f4e0f3a 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -13,15 +13,15 @@ | 항목 | 상태 | |---|---| | 브랜치 | `chore/ha-pitr-e2e-consolidation` (base: `main`) | -| 커밋 | 8개, **push 안 됨** (로컬 전용) | -| 변경 규모 | 56 파일, +5562 / −310 | -| 단위·통합 테스트 (`make test`) | ✅ 통과 (EXIT=0, FAIL 0) | -| 레이스 검출 (`make test-integration` `-race`) | ✅ 통과 (데이터레이스 없음) | -| lint (`make lint`) | ⚠️ baseline-fail — main 자체가 fail. 프로덕션 지적은 정리 완료, 잔여는 테스트 코드/미변경 파일 스타일 | -| 스크립트 테스트 (`make test-scripts`) | ⚠️ `hack/artifacthub_smoke_test.sh`만 실패 — 이번 작업과 무관 | -| 라이브 K8s E2E (failover/PITR drill) | ⏳ **미완** — 실 Kubernetes 환경에서 진행 필요 (3장 참고) | +| 커밋 | **35+개**, **push 안 됨** (로컬 전용). 원 HA/PITR 8커밋(2장) + 분산 SQL 라우터(2026-06-26~27) | +| 범위 확장 | 원래 HA/PITR 정비 → **분산 SQL 라우터(Vitess-for-Postgres) 구축**으로 확장 (6장 참고) | +| 단위·통합 테스트 | ✅ 통과 (컨테이너 검증) | +| 라이브 검증 (호스트 kind) | ✅ **single-shard 성능 baseline** + ✅ **query-mode 쿼리 라우팅 end-to-end** (2 trust postgres) | +| lint | ⚠️ baseline-fail (main 자체 fail), 프로덕션 지적 정리됨 | -핵심: **코드·단위/통합 검증은 끝났고, 남은 단 하나는 실제 쿠버네티스에서의 라이브 E2E 드릴**이다. 이 호스트(Windows + Docker Desktop)에서는 Docker 2중 중첩 한계로 kind 클러스터가 뜨지 않아 보류했다(코드 결함 아님, 인프라 한계). +핵심: HA/PITR(단일샤드)은 검증 완료. 이번 세션은 **분산 SQL 라우터**를 구축·라이브 검증했다 — `pg-router` query-mode가 실 PG에서 쿼리 키로 샤드를 라우팅함을 확인(alice→shard-0/bob→shard-1). **앞으로의 개발 로드맵은 6.5장 + [ROUTER-GAP-ANALYSIS §6](sharding/ROUTER-GAP-ANALYSIS.ko.md)** 참고. + +> 참고: **호스트 직접 kind는 정상 동작**한다(과거 "kind 불가"는 *컨테이너 안* kind 2중 중첩 한정). `kind create cluster`로 라이브 검증 가능. --- @@ -199,6 +199,31 @@ Windows 호스트에서는 `-v E:\keiailab\postgres-operator:/workspace`처럼 **보류(3건, 무검증 랜딩 금지)**: per-shard primary Service(#5, 운영자 failover 경로+라이브 검증), watch 기반 hot-reload(#7, polling 최적화·informer 단위검증 난), failover lease P2-T3(#9, 고위험 failover 경로 리팩터·라이브 chaos drill 필수). 사유는 태스크/백로그에 기록. +### 6.5 분산 SQL 라우터 구축 + query-mode 라이브 검증 (2026-06-27) + +라우터를 척추로 분산 SQL(Vitess-for-Postgres)을 단계 구축. **전부 커밋·검증**: +- **vindex**: hash/range + **consistent-hash**(샤드 추가 시 키 ~29%만 이동, 링 캐시). +- **라우팅 키 추출**: 제로 의존성 토크나이저(regex/parser/auto) — 모호키 bail·dollar-quote·복합 predicate 정확 처리. +- **토폴로지/백엔드**: 교체 가능 provider(static↔CRD watch) + **failover-aware 백엔드**(status.primary Ready) + 읽기→replica + reference table + dial circuit-breaker. +- **QueryRouter** 결정엔진 + **query-mode 프록시**(`PGROUTER_MODE=query`): PG wire 프레이머 + trust 핸드셰이크 + 첫 쿼리 라우팅. +- **🎉 라이브 검증**: 2 trust postgres + pg-router → 쿼리 키로 올바른 샤드 라우팅 확인(`docs/sharding/ROUTER-GAP-ANALYSIS §6`, `docs/perf/baseline.md §3.0`). + +### 6.6 앞으로의 개발 로드맵 (우선순위) + +SSOT는 [ROUTER-GAP-ANALYSIS §4 능력 사다리 + §6 백로그](sharding/ROUTER-GAP-ANALYSIS.ko.md). 요약: + +| 우선 | 항목 | 왜 / 블로커 | +|---|---|---| +| **1** | **scram 인증 대행** | 현재 query-mode는 trust 백엔드 한정 → 프로덕션 PG(scram)와 동작하려면 라우터가 인증 대행. 실 앱 사용의 첫 블로커 | +| **2** | **describe-round 대행** | lib/pq·JDBC의 `Parse→Describe→Sync→Bind` 파라미터화 쿼리 라우팅 (vtgate급 닭-달걀) | +| **3** | **멀티샤드 scatter forwarding** | 키 없는 쿼리 N-shard fan-out + 결과 재조립 → "분산처리능력" 수치의 전제 | +| **4** | **B: resharding 실데이터 이동** | ShardSplitJob의 CopyTable DSN 결선 + CDC + write-block cutover (현재 골격) | +| **5** | reference table·read-replica **프록시 결선** | 부품은 완성, query-mode에 연결만 | +| **6** | 보류 #5/#7/#9 | per-shard primary Service·watch·failover lease P2-T3 — 라이브 failover 필요 | +| **7** | 분산 성능 수치 + percentile·sysbench | scatter 동작 후 | + +> 제약 현황(query-mode 라이브): ✅ simple/inline-literal query · ⚠️ 단일라운드 파라미터만(lib/pq 미지원) · ❌ scram·scatter. + --- ## 7. 용어집 diff --git a/docs/sharding/ROUTER-TESTS.ko.md b/docs/sharding/ROUTER-TESTS.ko.md index 5089340..340c563 100644 --- a/docs/sharding/ROUTER-TESTS.ko.md +++ b/docs/sharding/ROUTER-TESTS.ko.md @@ -129,8 +129,11 @@ docker run --rm -v :/src -w /src golang:1.26 \ | `…TestBackendForUsesEnvMapping` / `TestTemplateResolver` / `TestEnvBackendResolver` | env/DNS 템플릿 백엔드 해소 | | `…TestWritePgError` | 샤드 down 시 우아한 PostgreSQL `ErrorResponse`('E') 인코딩(조용한 drop 금지) | | `dialer_test.go` `TestDialer_RetryThenSuccess` | dial retry/backoff(주입 dial) | -| `…_CircuitOpensAndCooldown` | 연속 실패 시 circuit open→fast-fail, cooldown 후 재시도(주입 clock) | -| `…_SuccessResetsBreaker` | 성공이 실패 카운트 리셋 | +| `…_CircuitOpensAndCooldown` / `…_HalfOpenReopensOnFailure` / `…_SuccessResetsBreaker` | circuit open→fast-fail, half-open 단일 probe·재오픈, 성공 리셋(주입 clock) | +| `pgwire_test.go` `TestPgMessageRoundTrip` / `TestQuerySQL_NonQuery` / `TestReadMessage_BadLength` | v3 메시지 read/write round-trip, 'Q' SQL 추출, 잘못된 길이 거부 | +| `…TestParseSQL` / `TestBindParams` | extended 'P'(Parse) 쿼리 추출, 'B'(Bind) 파라미터 값 추출(NULL 포함) | +| `…TestSendTrustHandshake` | trust 핸드셰이크 시퀀스(R-S-S-S-K-Z) | +| `querymode_test.go` `TestQueryRouter_routeSQL` / `…_routeKey` | query-mode 라우팅 결정(SQL 인라인 / 값 직접), 같은 키 동일 샤드 | --- @@ -140,8 +143,13 @@ docker run --rm -v :/src -w /src golang:1.26 \ ### 3.1 완료된 라이브 검증 - **성능 baseline (2026-06-27)**: 오퍼레이터 배포 → 단일샤드 PostgresCluster Ready → pgbench. - 결과·환경·재현 명령은 [`docs/perf/baseline.md §3.0`](../perf/baseline.md). (배포 흐름: - `docker build`→`kind load`→`kubectl apply -k config/default`→`set image`→dev 샘플 적용.) + 결과·환경·재현은 [`docs/perf/baseline.md §3.0`](../perf/baseline.md). +- **🎉 query-mode 쿼리 라우팅 (2026-06-27)**: 2 trust postgres(shard-0/1) + `pgrouter:dev` + (`PGROUTER_MODE=query`) → psql `SELECT located_on FROM probe WHERE id='alice'` 이 + **alice→shard-0 / bob→shard-1 / carol→shard-0** 결정적·올바르게 라우팅 + 실 쿼리 결과 반환. + 재현: 2 `postgres:18`(trust) + probe 테이블 + `docker build -f Dockerfile.router` + + psql(simple) / lib/pq(extended, scratchpad/pqclient). 라이브에서 백엔드 핸드셰이크 미소비 + 버그(`drainUntilReady`)·lib/pq describe-first 한계 발견. - **referenceTables CRD**: 실 apiserver 수용 검증(server-side apply). ### 3.2 미완(라이브 failover 환경 필요 — 무검증 랜딩 금지) From ed3e8785a66be5d947a2eaa709711b6e31a47742 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sat, 27 Jun 2026 23:23:58 +0900 Subject: [PATCH 046/109] =?UTF-8?q?feat(router):=20(E)=20scram-sha-256=20?= =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20=EC=9D=B8=EC=A6=9D=20=EB=8C=80?= =?UTF-8?q?=ED=96=89=20=E2=80=94=20=ED=94=84=EB=A1=9C=EB=8D=95=EC=85=98=20?= =?UTF-8?q?PG=20=EB=8F=99=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 라우터가 백엔드 인증(trust/cleartext/scram-sha-256)을 대행 — query-mode가 trust 아닌 실 프로덕션 PG와 동작. SCRAM-SHA-256은 crypto/pbkdf2(Go1.24+ stdlib)+hmac/sha256로 구현, 외부 dep 0. PGROUTER_BACKEND_PASSWORD로 백엔드 자격 보유(pgbouncer 모델). authenticateAndDrain이 backend startup 응답의 AuthenticationSASL/Cleartext/Ok를 처리. **검증**: RFC 7677 테스트 벡터로 proof 정확성 단위검증 + 라이브(POSTGRES_PASSWORD 2샤드, password_encryption=scram-sha-256)로 alice→shard-0/bob→shard-1 라우팅+쿼리결과 확인. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- cmd/pg-router/main.go | 3 +- cmd/pg-router/querymode.go | 51 +++------ cmd/pg-router/scram.go | 203 ++++++++++++++++++++++++++++++++++++ cmd/pg-router/scram_test.go | 40 +++++++ 4 files changed, 261 insertions(+), 36 deletions(-) create mode 100644 cmd/pg-router/scram.go create mode 100644 cmd/pg-router/scram_test.go diff --git a/cmd/pg-router/main.go b/cmd/pg-router/main.go index 2b5668d..00df194 100644 --- a/cmd/pg-router/main.go +++ b/cmd/pg-router/main.go @@ -69,6 +69,7 @@ func main() { mode := strings.ToLower(env("PGROUTER_MODE", "connection")) qr := newQueryRouter(provider, resolve, nil) serverVersion := env("PGROUTER_SERVER_VERSION", "18.0") + backendPassword := env("PGROUTER_BACKEND_PASSWORD", "") // 백엔드 인증 대행(scram/cleartext)용. ""=trust. ln, err := net.Listen("tcp", addr) if err != nil { @@ -83,7 +84,7 @@ func main() { continue } if mode == "query" { - go handleQueryMode(conn, qr, dialer, serverVersion) + go handleQueryMode(conn, qr, dialer, serverVersion, backendPassword) } else { go handleConn(conn, provider, resolve, dialer) } diff --git a/cmd/pg-router/querymode.go b/cmd/pg-router/querymode.go index bea9fcb..a0e0be8 100644 --- a/cmd/pg-router/querymode.go +++ b/cmd/pg-router/querymode.go @@ -82,8 +82,9 @@ func (qr queryRouter) shardColumn() string { return topo.Spec.Vindex.Column } -// handleQueryMode 는 쿼리 인지 라우팅으로 한 연결을 처리한다. -func handleQueryMode(client net.Conn, qr queryRouter, dialer *backendDialer, serverVersion string) { +// handleQueryMode 는 쿼리 인지 라우팅으로 한 연결을 처리한다. backendPassword 는 +// 백엔드 인증 대행(scram/cleartext)용 — "" 면 trust 백엔드만 동작. +func handleQueryMode(client net.Conn, qr queryRouter, dialer *backendDialer, serverVersion, backendPassword string) { defer func() { _ = client.Close() }() raw, _, err := readStartup(client) @@ -107,9 +108,9 @@ func handleQueryMode(client net.Conn, qr queryRouter, dialer *backendDialer, ser writePgError(client, "08P01", "could not parse query") return } - routeAndProxy(client, qr, sql, raw, []pgMessage{m}, dialer) + routeAndProxy(client, qr, sql, raw, []pgMessage{m}, dialer, backendPassword) case 'P': // extended Parse - handleParse(client, qr, m, raw, dialer) + handleParse(client, qr, m, raw, dialer, backendPassword) default: writePgError(client, "0A000", fmt.Sprintf("message type %q not supported in query-routing PoC", m.Type)) } @@ -117,7 +118,7 @@ func handleQueryMode(client net.Conn, qr queryRouter, dialer *backendDialer, ser // handleParse 는 Parse('P') 를 처리한다: 인라인 리터럴이면 즉시, parameterized 면 Bind // 까지 버퍼링해 파라미터 값으로 라우팅한다. -func handleParse(client net.Conn, qr queryRouter, parse pgMessage, raw []byte, dialer *backendDialer) { +func handleParse(client net.Conn, qr queryRouter, parse pgMessage, raw []byte, dialer *backendDialer, backendPassword string) { sql, ok := parseSQL(parse) if !ok { writePgError(client, "08P01", "could not parse query") @@ -126,7 +127,7 @@ func handleParse(client net.Conn, qr queryRouter, parse pgMessage, raw []byte, d // 1) 인라인 리터럴이면 SQL 만으로 라우팅. if d, err := qr.routeSQL(sql); err == nil && !d.Scatter { logRoute('P', d) - proxyToShard(client, raw, []pgMessage{parse}, d, dialer) + proxyToShard(client, raw, []pgMessage{parse}, d, dialer, backendPassword) return } // 2) parameterized(`col = $N`): Bind 까지 버퍼링해 N번째 파라미터로 라우팅. @@ -155,7 +156,7 @@ func handleParse(client net.Conn, qr queryRouter, parse pgMessage, raw []byte, d return } logRoute('B', d) - proxyToShard(client, raw, buffered, d, dialer) + proxyToShard(client, raw, buffered, d, dialer, backendPassword) return case 'S': // Sync 가 Bind 보다 먼저 — 라우팅 불가. writePgError(client, "08006", "no Bind before Sync; cannot route parameterized query") @@ -169,7 +170,7 @@ func logRoute(typ byte, d router.RouteDecision) { } // routeAndProxy 는 simple Query 경로의 라우팅 + proxy. -func routeAndProxy(client net.Conn, qr queryRouter, sql string, raw []byte, msgs []pgMessage, dialer *backendDialer) { +func routeAndProxy(client net.Conn, qr queryRouter, sql string, raw []byte, msgs []pgMessage, dialer *backendDialer, backendPassword string) { d, err := qr.routeSQL(sql) if err != nil { writePgError(client, "08006", "routing failed: "+err.Error()) @@ -180,12 +181,12 @@ func routeAndProxy(client net.Conn, qr queryRouter, sql string, raw []byte, msgs return } logRoute('Q', d) - proxyToShard(client, raw, msgs, d, dialer) + proxyToShard(client, raw, msgs, d, dialer, backendPassword) } // proxyToShard 는 결정된 샤드 backend 에 연결해 startup + 버퍼링된 메시지(들)를 전달하고 -// 양방향 proxy 한다. -func proxyToShard(client net.Conn, raw []byte, msgs []pgMessage, d router.RouteDecision, dialer *backendDialer) { +// 양방향 proxy 한다. 백엔드 인증(trust/cleartext/scram)은 라우터가 대행한다. +func proxyToShard(client net.Conn, raw []byte, msgs []pgMessage, d router.RouteDecision, dialer *backendDialer, backendPassword string) { server, err := dialer.Dial(d.Backend) if err != nil { writePgError(client, "08006", fmt.Sprintf("cannot reach shard %s (%s): %v", d.Shard, d.Backend, err)) @@ -193,12 +194,12 @@ func proxyToShard(client net.Conn, raw []byte, msgs []pgMessage, d router.RouteD } defer func() { _ = server.Close() }() - if _, err := server.Write(raw); err != nil { // backend startup (trust 전제) + if _, err := server.Write(raw); err != nil { // backend startup return } - // 백엔드 핸드셰이크를 ReadyForQuery 까지 소비 (클라이언트는 우리 trust 핸드셰이크를 - // 이미 받음 — 중복 방지). 비-trust 백엔드는 여기서 실패한다. - if err := drainUntilReady(server); err != nil { + // 백엔드 인증 대행 + 핸드셰이크를 ReadyForQuery 까지 소비 (클라이언트는 우리 trust + // 핸드셰이크를 이미 받음 — 중복 방지). + if err := authenticateAndDrain(server, backendPassword); err != nil { writePgError(client, "08006", "backend startup: "+err.Error()) return } @@ -210,26 +211,6 @@ func proxyToShard(client net.Conn, raw []byte, msgs []pgMessage, d router.RouteD proxyBidi(client, server) } -// drainUntilReady 는 백엔드 startup 응답을 ReadyForQuery('Z')까지 읽어 버린다. -func drainUntilReady(server net.Conn) error { - for { - m, err := readMessage(server) - if err != nil { - return err - } - switch m.Type { - case 'Z': // ReadyForQuery - return nil - case 'E': // ErrorResponse - return fmt.Errorf("backend error: %s", string(m.Payload)) - case 'R': // AuthenticationRequest — type 0 = Ok(trust), >0 = 비번 필요 - if len(m.Payload) >= 4 && (m.Payload[0]|m.Payload[1]|m.Payload[2]|m.Payload[3]) != 0 { - return fmt.Errorf("backend requires non-trust auth (not supported in query-mode PoC)") - } - } - } -} - // proxyBidi 는 두 연결을 양방향으로 복사하고 한쪽이 끝나면 반환한다. func proxyBidi(a, b net.Conn) { done := make(chan struct{}, 2) diff --git a/cmd/pg-router/scram.go b/cmd/pg-router/scram.go new file mode 100644 index 0000000..517e458 --- /dev/null +++ b/cmd/pg-router/scram.go @@ -0,0 +1,203 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +// scram.go 는 라우터가 *백엔드 인증을 대행* 하는 로직이다 — query-mode 가 trust 가 아닌 +// 실 PostgreSQL(scram-sha-256 / cleartext)과 동작하도록. 백엔드가 startup 직후 인증을 +// 요구하면, 라우터가 설정된 비밀번호(PGROUTER_BACKEND_PASSWORD)로 핸드셰이크를 완료한 뒤 +// ReadyForQuery 까지 소비한다. (클라이언트 인증은 trust — pgbouncer 처럼 라우터가 백엔드 +// 자격을 보유하는 모델. 클라이언트측 인증 강제는 후속.) +// +// SCRAM-SHA-256 은 Go 1.24+ stdlib(crypto/pbkdf2) + crypto/hmac/sha256 으로 구현 — +// 외부 의존성 없음. RFC 5802 / PostgreSQL SASL. +package main + +import ( + "crypto/hmac" + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "fmt" + "net" + "strconv" + "strings" +) + +// authenticateAndDrain 은 백엔드의 startup 응답을 처리한다: 인증 요구(SASL/cleartext)면 +// 대행하고, AuthenticationOk·이후 ParameterStatus/BackendKeyData 를 지나 ReadyForQuery +// ('Z')까지 소비한다. 클라이언트는 이미 라우터의 trust 핸드셰이크를 받았으므로 이 메시지들을 +// 흘려보내지 않는다. +func authenticateAndDrain(server net.Conn, password string) error { + for { + m, err := readMessage(server) + if err != nil { + return err + } + switch m.Type { + case 'Z': // ReadyForQuery + return nil + case 'E': // ErrorResponse + return fmt.Errorf("backend error: %s", string(m.Payload)) + case 'R': // AuthenticationRequest + switch be32(m.Payload) { + case 0: // AuthenticationOk + case 3: // Cleartext password + if err := writeMessage(server, 'p', cstring(password)); err != nil { + return err + } + case 10: // SASL (SCRAM-SHA-256) + if err := scramAuth(server, m, password); err != nil { + return err + } + default: + return fmt.Errorf("unsupported backend auth method %d (need trust/cleartext/scram-sha-256)", be32(m.Payload)) + } + } + } +} + +// scramAuth 는 AuthenticationSASL 메시지를 받은 직후부터 SCRAM-SHA-256 클라이언트 +// 핸드셰이크를 수행한다. +func scramAuth(server net.Conn, sasl pgMessage, password string) error { + if len(sasl.Payload) < 4 || !strings.Contains(string(sasl.Payload[4:]), "SCRAM-SHA-256") { + return fmt.Errorf("backend SASL does not offer SCRAM-SHA-256") + } + clientNonce, err := genNonce() + if err != nil { + return err + } + clientFirstBare := "n=,r=" + clientNonce + clientFirst := "n,," + clientFirstBare + + // SASLInitialResponse: mechanism(cstring) + Int32 len + client-first. + init := cstring("SCRAM-SHA-256") + init = appendInt32(init, int32(len(clientFirst))) + init = append(init, clientFirst...) + if err := writeMessage(server, 'p', init); err != nil { + return err + } + + // AuthenticationSASLContinue (R, 11) + server-first. + m, err := readMessage(server) + if err != nil { + return err + } + if m.Type != 'R' || be32(m.Payload) != 11 { + return fmt.Errorf("expected SASLContinue, got %c/%d", m.Type, be32(m.Payload)) + } + serverFirst := string(m.Payload[4:]) + attrs := parseScramAttrs(serverFirst) + combined := attrs["r"] + if !strings.HasPrefix(combined, clientNonce) { + return fmt.Errorf("scram: server nonce mismatch") + } + salt, err := base64.StdEncoding.DecodeString(attrs["s"]) + if err != nil { + return fmt.Errorf("scram: bad salt: %w", err) + } + iter, err := strconv.Atoi(attrs["i"]) + if err != nil { + return fmt.Errorf("scram: bad iteration count: %w", err) + } + + clientFinalBare := "c=biws,r=" + combined + authMsg := clientFirstBare + "," + serverFirst + "," + clientFinalBare + proofB64, err := scramClientProof(password, salt, iter, authMsg) + if err != nil { + return fmt.Errorf("scram: %w", err) + } + clientFinal := clientFinalBare + ",p=" + proofB64 + + // SASLResponse. + if err := writeMessage(server, 'p', []byte(clientFinal)); err != nil { + return err + } + + // AuthenticationSASLFinal (R, 12) then AuthenticationOk (R, 0). + m, err = readMessage(server) + if err != nil { + return err + } + if m.Type == 'R' && be32(m.Payload) == 12 { // server-final (v=...); 서명 검증은 생략. + m, err = readMessage(server) + if err != nil { + return err + } + } + if m.Type == 'E' { + return fmt.Errorf("backend auth failed: %s", string(m.Payload)) + } + if m.Type == 'R' && be32(m.Payload) == 0 { + return nil // AuthenticationOk + } + return fmt.Errorf("scram: unexpected message after handshake: %c", m.Type) +} + +// scramClientProof 는 SCRAM-SHA-256 의 ClientProof(base64)를 계산한다. +// +// SaltedPassword = PBKDF2(password, salt, i) +// ClientKey = HMAC(SaltedPassword, "Client Key") +// StoredKey = SHA256(ClientKey) +// ClientSig = HMAC(StoredKey, AuthMessage) +// ClientProof = ClientKey XOR ClientSig +func scramClientProof(password string, salt []byte, iter int, authMessage string) (string, error) { + salted, err := pbkdf2.Key(sha256.New, password, salt, iter, 32) + if err != nil { + return "", fmt.Errorf("pbkdf2: %w", err) + } + clientKey := hmacSum(salted, []byte("Client Key")) + storedKey := sha256.Sum256(clientKey) + clientSig := hmacSum(storedKey[:], []byte(authMessage)) + return base64.StdEncoding.EncodeToString(xorBytes(clientKey, clientSig)), nil +} + +func genNonce() (string, error) { + b := make([]byte, 18) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(b), nil +} + +// parseScramAttrs 는 "k=v,k=v" 형식을 맵으로. 값에 '='(base64 패딩)가 있어도 첫 '='만 +// 분리자로 본다(키는 단일 문자). +func parseScramAttrs(s string) map[string]string { + m := map[string]string{} + for _, part := range strings.Split(s, ",") { + if i := strings.IndexByte(part, '='); i > 0 { + m[part[:i]] = part[i+1:] + } + } + return m +} + +func hmacSum(key, data []byte) []byte { + h := hmac.New(sha256.New, key) + h.Write(data) + return h.Sum(nil) +} + +func xorBytes(a, b []byte) []byte { + out := make([]byte, len(a)) + for i := range a { + out[i] = a[i] ^ b[i] + } + return out +} + +func be32(p []byte) uint32 { + if len(p) < 4 { + return 0 + } + return binary.BigEndian.Uint32(p) +} + +func appendInt32(p []byte, v int32) []byte { + var b [4]byte + binary.BigEndian.PutUint32(b[:], uint32(v)) + return append(p, b[:]...) +} diff --git a/cmd/pg-router/scram_test.go b/cmd/pg-router/scram_test.go new file mode 100644 index 0000000..ee7d947 --- /dev/null +++ b/cmd/pg-router/scram_test.go @@ -0,0 +1,40 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package main + +import ( + "encoding/base64" + "testing" +) + +// TestScramClientProof 는 SCRAM-SHA-256 계산을 RFC 7677 §3 테스트 벡터로 검증한다. +func TestScramClientProof(t *testing.T) { + password := "pencil" + salt, _ := base64.StdEncoding.DecodeString("W22ZaJ0SNY7soEsUEjb6gQ==") + iter := 4096 + clientFirstBare := "n=user,r=rOprNGfwEbeRWgbNEkqO" + serverFirst := "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096" + clientFinalBare := "c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0" + authMsg := clientFirstBare + "," + serverFirst + "," + clientFinalBare + + proof, err := scramClientProof(password, salt, iter, authMsg) + if err != nil { + t.Fatalf("scramClientProof: %v", err) + } + want := "dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=" + if proof != want { + t.Fatalf("ClientProof = %q, want %q (RFC 7677)", proof, want) + } +} + +// TestParseScramAttrs 는 server-first 속성 파싱(값에 base64 '=' 포함)을 검증. +func TestParseScramAttrs(t *testing.T) { + a := parseScramAttrs("r=abc%def,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096") + if a["r"] != "abc%def" || a["s"] != "W22ZaJ0SNY7soEsUEjb6gQ==" || a["i"] != "4096" { + t.Fatalf("parseScramAttrs = %v", a) + } +} From 1e9cd13c4a0de3b4dfb2d0abbf5864bbfa47a581 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sat, 27 Jun 2026 23:24:53 +0900 Subject: [PATCH 047/109] =?UTF-8?q?docs(router):=20(E)=20scram=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EB=8C=80=ED=96=89=20=EC=99=84=EB=A3=8C=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=E2=80=94=20query-mode=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EB=8D=95=EC=85=98=20PG=20=EB=8F=99=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/WORK_HANDOFF.ko.md | 4 ++-- docs/sharding/ROUTER-GAP-ANALYSIS.ko.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index f4e0f3a..4a05fe7 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -214,8 +214,8 @@ SSOT는 [ROUTER-GAP-ANALYSIS §4 능력 사다리 + §6 백로그](sharding/ROUT | 우선 | 항목 | 왜 / 블로커 | |---|---|---| -| **1** | **scram 인증 대행** | 현재 query-mode는 trust 백엔드 한정 → 프로덕션 PG(scram)와 동작하려면 라우터가 인증 대행. 실 앱 사용의 첫 블로커 | -| **2** | **describe-round 대행** | lib/pq·JDBC의 `Parse→Describe→Sync→Bind` 파라미터화 쿼리 라우팅 (vtgate급 닭-달걀) | +| ~~1~~ ✅ | ~~scram 인증 대행~~ | **완료(2026-06-27)** — scram-sha-256/cleartext 대행, 라이브 검증. query-mode가 프로덕션 PG와 동작 | +| **1** | **describe-round 대행** | lib/pq·JDBC의 `Parse→Describe→Sync→Bind` 파라미터화 쿼리 라우팅 (vtgate급 닭-달걀) | | **3** | **멀티샤드 scatter forwarding** | 키 없는 쿼리 N-shard fan-out + 결과 재조립 → "분산처리능력" 수치의 전제 | | **4** | **B: resharding 실데이터 이동** | ShardSplitJob의 CopyTable DSN 결선 + CDC + write-block cutover (현재 골격) | | **5** | reference table·read-replica **프록시 결선** | 부품은 완성, query-mode에 연결만 | diff --git a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md index fbe88fb..f799b38 100644 --- a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md +++ b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md @@ -127,7 +127,7 @@ parameterized/`t.col`/주석·문자열 내부 오인 방지까지 검증. **기 > 종단이 되어야 완성. 아래 항목은 그 종단/운영-코어/라이브검증 필요분. **라우팅 핵심 (능력 사다리)** -- [~] **(E) 프로토콜 종단 — 쿼리 단위 라우팅**: **query-mode 라이브 PG end-to-end 검증 완료 (2026-06-27)** 🎉. 2 trust postgres(shard-0/1) + pg-router `PGROUTER_MODE=query` → psql `SELECT located_on FROM probe WHERE id='alice'` 이 키 추출→murmur3 vindex→샤드 라우팅→실 쿼리 실행→결과 반환까지 동작. **alice→shard-0 / bob→shard-1 / carol→shard-0** 결정적·올바른 분산 확인(bob이 다른 샤드 = 해시 라우팅 실증). 라이브 검증에서 프로토콜 버그(백엔드 핸드셰이크 미소비) 발견·수정. **남은 종단 작업**: ① 일반(비번/scram) 백엔드 인증 대행(현재 trust 한정) ② extended protocol — *부분 완료*: inline-literal Parse + 단일라운드 parameterized(Parse+Bind 연속) 라우팅(`ExtractParamRef`+`bindParams`+Bind 버퍼링). **단, lib/pq는 Parse→Describe→Sync(타입조회)→Bind 순이라 Bind 전 Sync에서 실패** — describe-round 대행(default 샤드로 describe round-trip 후 Bind에서 실샤드 라우팅)이 vtgate급 남은 난제 ③ 멀티샤드 scatter forwarding + 결과 재조립 ④ 백엔드 연결 풀 ⑤ ~~per-query 로그~~ ✅완료. 라이브 검증으로 simple-query 정상·파라미터 한계 확인. +- [~] **(E) 프로토콜 종단 — 쿼리 단위 라우팅**: **query-mode 라이브 PG end-to-end 검증 완료 (2026-06-27)** 🎉. 2 trust postgres(shard-0/1) + pg-router `PGROUTER_MODE=query` → psql `SELECT located_on FROM probe WHERE id='alice'` 이 키 추출→murmur3 vindex→샤드 라우팅→실 쿼리 실행→결과 반환까지 동작. **alice→shard-0 / bob→shard-1 / carol→shard-0** 결정적·올바른 분산 확인(bob이 다른 샤드 = 해시 라우팅 실증). 라이브 검증에서 프로토콜 버그(백엔드 핸드셰이크 미소비) 발견·수정. **종단 작업 진척**: ① **scram-sha-256 / cleartext 백엔드 인증 대행 ✅완료**(RFC 7677 단위검증 + 라이브 scram PG 검증, `crypto/pbkdf2` stdlib, `PGROUTER_BACKEND_PASSWORD`) — **query-mode가 trust 아닌 실 프로덕션 PG와 동작** ② extended protocol — *부분 완료*: inline-literal Parse + 단일라운드 parameterized(Parse+Bind 연속) 라우팅(`ExtractParamRef`+`bindParams`+Bind 버퍼링). **단, lib/pq는 Parse→Describe→Sync(타입조회)→Bind 순이라 Bind 전 Sync에서 실패** — describe-round 대행(default 샤드로 describe round-trip 후 Bind에서 실샤드 라우팅)이 vtgate급 남은 난제 ③ 멀티샤드 scatter forwarding + 결과 재조립 ④ 백엔드 연결 풀 ⑤ ~~per-query 로그~~ ✅완료. 라이브 검증으로 simple-query 정상·파라미터 한계 확인. - [ ] **읽기 → replica 라우팅**: `status.shards[].replicas[]`(Ready)로 읽기 쿼리를 분산 → primary 부하 경감 + 읽기 확장. 사다리 2단계. (status backend 계층 재사용.) - [ ] **scatter-gather 실연결**: `scatter.go` 골격 → 실 wire-protocol fan-out + merge + 집계/정렬 pushdown. 사다리 2~3단계. - [ ] **Reference table**: 전 샤드 복제로 분산 조인 우회. 사다리 4단계. 고가치·저난도. From 3ebdc0dd92e8081087212e590576744021e46da7 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sat, 27 Jun 2026 23:32:04 +0900 Subject: [PATCH 048/109] =?UTF-8?q?docs:=20=EB=B6=84=EC=82=B0=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=84=B0=20=ED=98=84=ED=99=A9=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20(RFC-0004=20=EC=8A=A4=ED=8E=99=C2=B7E2E=20=ED=9E=88?= =?UTF-8?q?=EC=8A=A4=ED=86=A0=EB=A6=AC=C2=B7CHANGELOG)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC-0004 Status: Draft→Partially implemented + 구현 현황(P2 single-shard 라이브검증, scram 대행). E2E_TEST_REPORT.ko에 2026-06-27 query-mode+scram 라이브 검증 RCA 추가. CHANGELOG en+ko [Unreleased]에 분산 SQL 쿼리 라우터 항목. (ROADMAP/README/ARCHITECTURE의 en/ja/zh 번역 sync는 후속 배치 — .ko/.md 정본 우선.) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/CHANGELOG.ko.md | 8 ++++++++ docs/CHANGELOG.md | 7 +++++++ docs/E2E_TEST_REPORT.ko.md | 23 +++++++++++++++++++++++ docs/rfcs/0004-pg-router-architecture.md | 13 +++++++++++-- 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/docs/CHANGELOG.ko.md b/docs/CHANGELOG.ko.md index 54d0ecd..96da698 100644 --- a/docs/CHANGELOG.ko.md +++ b/docs/CHANGELOG.ko.md @@ -15,6 +15,14 @@ ### Added (추가됨) +- *(router,sharding)* **분산 SQL 쿼리 라우터** (`cmd/pg-router`, RFC-0004). 쿼리 인지 + 라우팅(`PGROUTER_MODE=query`): PG wire 프레이밍 + 토크나이저 라우팅키 추출 + + vindex(hash/range/consistent-hash) → 샤드 backend. 교체 가능 토폴로지(static / ShardRange + CRD watch), failover-aware 백엔드(`status.primary`), 읽기→replica, reference table, + circuit breaker. **scram-sha-256 / cleartext 백엔드 인증 대행** → 실 프로덕션 PostgreSQL + 동작. 배포 가능(`Dockerfile.router`, `config/router/`). scram PostgreSQL 라이브 검증(id 기준 + 올바른 샤드 라우팅). + - *(helm,security)* External Secrets Operator / Infisical 기반 PostgresUser password Secret, PgBouncer `userlist.txt`, external replica source password 의 opt-in `externalSecrets` chart 렌더링 추가. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e6d44c0..c5462e8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,13 @@ This project follows SemVer. ### Added +- *(router,sharding)* **Distributed SQL query router** (`cmd/pg-router`, RFC-0004). + Query-aware routing (`PGROUTER_MODE=query`): PG wire framing + tokenizer routing-key + extraction + vindex (hash/range/consistent-hash) → shard backend. Pluggable topology + (static / ShardRange CRD watch), failover-aware backends (`status.primary`), read→replica, + reference tables, circuit breaker. **scram-sha-256 / cleartext backend auth delegation** + → works against real production PostgreSQL. Deployable (`Dockerfile.router`, + `config/router/`). Live-validated against scram PostgreSQL (id-based routing to correct shard). - *(helm,security)* Added opt-in `externalSecrets` chart rendering for PostgresUser password Secrets, PgBouncer `userlist.txt`, and external replica-source passwords backed by External Secrets Operator / Infisical. diff --git a/docs/E2E_TEST_REPORT.ko.md b/docs/E2E_TEST_REPORT.ko.md index 20d4d9b..603ebae 100644 --- a/docs/E2E_TEST_REPORT.ko.md +++ b/docs/E2E_TEST_REPORT.ko.md @@ -1,5 +1,28 @@ # E2E 테스트 종합 분석 보고서 +## 2026-06-27 라이브 검증: 분산 SQL 라우터 query-mode (호스트 kind / Docker) + +분산 SQL 라우터(`pg-router`)를 실 PostgreSQL에 대해 라이브 검증. **호스트 직접 Docker/kind** +(과거 "kind 불가"는 *컨테이너 안* 2중 중첩 한정 — 호스트 직접은 정상). + +### 결과 +| 항목 | 결과 | +|------|------| +| 구성 | 2× `postgres:18`(shard-0/1) + `pgrouter:dev`(`PGROUTER_MODE=query`), `probe(id,located_on)` 샤드 마커 | +| **단일샤드 성능 baseline** | ✅ 오퍼레이터 배포 → PostgresCluster Ready → pgbench (tpcb 496/646/889 TPS, select 9k~10.5k). `docs/perf/baseline.md §3.0` | +| **query-mode 쿼리 라우팅** | ✅ `SELECT located_on FROM probe WHERE id='alice'` → **alice→shard-0 / bob→shard-1 / carol→shard-0** 결정적·올바름 + 실 쿼리 결과 반환 | +| **scram-sha-256 인증 대행** | ✅ `POSTGRES_PASSWORD` 백엔드(`password_encryption=scram-sha-256`) + `PGROUTER_BACKEND_PASSWORD` → 라우터가 SCRAM 핸드셰이크 대행, **실 프로덕션 PG 동작** | + +### RCA / 발견 +1. **백엔드 핸드셰이크 중복**: `proxyToShard`가 백엔드 startup 응답을 클라이언트로 흘려보내 핸드셰이크가 중복 → `drainUntilReady`(후에 `authenticateAndDrain`)로 소비 후 Query 재생. +2. **Dockerfile.router 단일파일 빌드**: pg-router가 멀티파일 패키지가 되며 `go build cmd/pg-router/main.go` 실패 → `./cmd/pg-router` 패키지 빌드. +3. **lib/pq 파라미터 라우팅 한계**: lib/pq는 `Parse→Describe→Sync→Bind` 순(타입 조회) → Bind 전 Sync에서 라우팅 불가. describe-round 대행(vtgate급)이 후속 과제. + +### Insight +단위 테스트가 못 잡는 *프로토콜 상호작용*(핸드셰이크 중복, 드라이버별 메시지 순서)을 라이브가 드러냈다. SCRAM은 RFC 7677 벡터 단위검증 + 라이브 scram PG 양쪽으로 확인. + +--- + ## 2026-06-24 추가 검증: PITR restore drill 보강 완료 이번 추가 작업에서는 `PITR restore + checksum drill (D.3.2)`를 `PContext` 상태에서 실제 실행 가능한 `Context`로 전환하고, live Kind 환경에서 실패 원인을 단계적으로 제거했다. diff --git a/docs/rfcs/0004-pg-router-architecture.md b/docs/rfcs/0004-pg-router-architecture.md index 9101b1e..f6469e5 100644 --- a/docs/rfcs/0004-pg-router-architecture.md +++ b/docs/rfcs/0004-pg-router-architecture.md @@ -1,11 +1,20 @@ # RFC-0004: pg-router architecture -- Status: Draft -- Date: 2026-05-02 +- Status: **Partially implemented** (P2 single-shard routing live-validated 2026-06-27) +- Date: 2026-05-02 (impl status updated 2026-06-27) - Authors: @phil - Target: Phase P2 (~v0.5.0) ~ P6 (~v0.9.0) - Supersedes: none (new) +> **구현 현황 (2026-06-27)** — 상세: [docs/sharding/ROUTER-GAP-ANALYSIS.ko.md](../sharding/ROUTER-GAP-ANALYSIS.ko.md): +> - ✅ **P2 single-shard 라우팅 라이브 검증** — `cmd/pg-router` query-mode(`PGROUTER_MODE=query`): +> PG wire 프레이밍 + 라우팅키 추출(토크나이저) + vindex(hash/range/consistent-hash) → 샤드 backend. +> **scram-sha-256 / cleartext 백엔드 인증 대행** 완료 → 실 프로덕션 PG 동작. 배포 가능(`Dockerfile.router` +> + `config/router/`). 라이브: `id='alice'`→shard-0 / `'bob'`→shard-1. +> - 🟡 **P3 scatter-gather** — in-process 라이브러리 동작(`scatter.go` merge), 프록시 레벨 forwarding 후속. +> - ⬜ **parameterized(extended) 라우팅** — 단일라운드만; lib/pq describe-first 는 describe-round 대행 필요. +> **P6 분산 트랜잭션 coordinator** 미착수. + ## §1 Summary `pg-router` is a **stateless PostgreSQL wire protocol proxy**. It accepts the application's PG connections, parses the SQL → evaluates the vindex → performs either single-shard fast-path forwarding or multi-shard scatter-gather, then merges responses. It watches all distributed metadata (ShardRange) via the K8s API and holds no state of its own (free HPA scaling). Gradual introduction by phase: **P2** = hash vindex + single-shard routing, **P3** = vindex extensions + scatter-gather, **P6** = distributed transaction coordinator. It abstracts the application behind a single endpoint, with a target single-shard fast-path latency overhead < 1ms. From 08f9904d2adcd74be22d93ea73825b747d44d53d Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sat, 27 Jun 2026 23:41:16 +0900 Subject: [PATCH 049/109] =?UTF-8?q?feat(router):=20(E)=20describe-round=20?= =?UTF-8?q?=EB=8C=80=ED=96=89=20=E2=80=94=20lib/pq=20=EB=93=B1=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=BF=BC=EB=A6=AC=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit describe-first 드라이버(lib/pq·JDBC: Parse→Describe→Sync→Bind)의 파라미터화 쿼리 라우팅. 클라이언트가 describe 응답을 기다리며 블록하는 닭-달걀을 해결: 스키마는 샤드 공통이므로 라우터가 *임의(default) 샤드*로 describe round를 대행해 메타데이터 반환→Bind에서 파라미터 값으로 실 샤드 결정. 실 샤드==default면 연결 재사용, 다르면 실 샤드에 re-Parse 후 중복 ParseComplete 1개만 필터. **라이브 검증**: lib/pq 로 alice→shard-0/bob→shard-1/carol→shard-0 결정적 라우팅+결과(이전 'Bind 전 Sync' 실패 케이스 해결). ⇒ query-mode가 실 DB 드라이버와 동작. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- cmd/pg-router/describeround.go | 167 +++++++++++++++++++++++++++++++++ cmd/pg-router/querymode.go | 19 +++- 2 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 cmd/pg-router/describeround.go diff --git a/cmd/pg-router/describeround.go b/cmd/pg-router/describeround.go new file mode 100644 index 0000000..7ec8d28 --- /dev/null +++ b/cmd/pg-router/describeround.go @@ -0,0 +1,167 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +// describeround.go 는 *extended protocol describe-first 드라이버*(lib/pq·JDBC 등)의 +// 파라미터화 쿼리 라우팅을 처리한다. +// +// 문제: lib/pq 는 `Parse → Describe → Sync` 를 먼저 보내 파라미터/컬럼 타입을 조회한 뒤, +// 그 응답을 받고 나서야 `Bind → Execute → Sync` 를 보낸다. 라우팅 키는 Bind 의 파라미터 +// 값에 있는데, 클라이언트는 describe 응답을 *기다리며 블록* 하므로 라우터가 Bind 까지 +// 기다릴 수 없다(닭-달걀). +// +// 해법(vtgate 와 동일 계열): 스키마(파라미터/컬럼 타입)는 모든 샤드 공통이므로, 라우터가 +// *임의(default) 샤드* 로 describe round 를 대행해 메타데이터를 클라이언트에 돌려준다. +// 이후 Bind 가 오면 파라미터 값으로 *실제 샤드* 를 정한다. 실제 샤드가 default 와 같으면 +// 그 연결을 재사용하고, 다르면 실 샤드에 re-Parse 한 뒤 그 중복 ParseComplete 만 걸러낸다. +package main + +import ( + "io" + "net" + + "github.com/keiailab/postgres-operator/internal/router" +) + +// PG backend 메시지 타입 (필터용). +const msgParseComplete = '1' + +// describeRoundDelegate 는 describe round(Parse..Sync, describeRound)를 default 샤드로 +// 대행한 뒤, 클라이언트의 Bind 파라미터로 실 샤드를 정해 실행한다. +func describeRoundDelegate(client net.Conn, qr queryRouter, describeRound []pgMessage, parse pgMessage, pidx int, sql string, raw []byte, dialer *backendDialer, password string) { + // 1) default 샤드로 describe round 대행. + _, defBackend, err := qr.anyShard() + if err != nil { + writePgError(client, "08006", "no shard for describe round: "+err.Error()) + return + } + dconn, err := dialer.Dial(defBackend) + if err != nil { + writePgError(client, "08006", "describe-round dial: "+err.Error()) + return + } + if _, err := dconn.Write(raw); err != nil { + _ = dconn.Close() + return + } + if err := authenticateAndDrain(dconn, password); err != nil { + _ = dconn.Close() + writePgError(client, "08006", "describe-round backend startup: "+err.Error()) + return + } + for _, m := range describeRound { // Parse, [Describe], Sync 전달. + if err := writeMessage(dconn, m.Type, m.Payload); err != nil { + _ = dconn.Close() + return + } + } + // describe 응답(ParseComplete·ParameterDescription·RowDescription·ReadyForQuery)을 + // 클라이언트로 전달. + if err := forwardUntilReady(dconn, client); err != nil { + _ = dconn.Close() + return + } + + // 2) 클라이언트의 Bind → 파라미터 값으로 실 샤드 결정. + bind, err := readMessage(client) + if err != nil { + _ = dconn.Close() + return + } + if bind.Type != 'B' { + _ = dconn.Close() + writePgError(client, "08P01", "expected Bind after describe round") + return + } + params, ok := bindParams(bind) + if !ok || pidx-1 >= len(params) || params[pidx-1] == nil { + _ = dconn.Close() + writePgError(client, "08006", "could not extract routing parameter from Bind") + return + } + d, err := qr.routeKey(string(params[pidx-1]), router.IsReadOnlyQuery(sql)) + if err != nil || d.Scatter { + _ = dconn.Close() + writePgError(client, "08006", "routing failed for parameter") + return + } + logRoute('B', d) + + // 3a) 실 샤드 == default: describe 한 연결을 재사용(이미 Parse 됨). + if d.Backend == defBackend { + defer func() { _ = dconn.Close() }() + if err := writeMessage(dconn, bind.Type, bind.Payload); err != nil { + return + } + proxyBidi(client, dconn) // 클라이언트의 Execute/Sync ↔ 결과. + return + } + + // 3b) 실 샤드 != default: 새 연결에 re-Parse + Bind, 중복 ParseComplete 만 필터. + _ = dconn.Close() + rconn, err := dialer.Dial(d.Backend) + if err != nil { + writePgError(client, "08006", "execute dial: "+err.Error()) + return + } + defer func() { _ = rconn.Close() }() + if _, err := rconn.Write(raw); err != nil { + return + } + if err := authenticateAndDrain(rconn, password); err != nil { + writePgError(client, "08006", "execute backend startup: "+err.Error()) + return + } + if err := writeMessage(rconn, parse.Type, parse.Payload); err != nil { // re-Parse. + return + } + if err := writeMessage(rconn, bind.Type, bind.Payload); err != nil { // Bind. + return + } + proxyExecuteRound(client, rconn) // Execute/Sync ↔ 결과(앞선 ParseComplete 1개 필터). +} + +// forwardUntilReady 는 src 의 메시지를 dst 로 전달하며 ReadyForQuery('Z')/ErrorResponse +// ('E')까지 진행한다. +func forwardUntilReady(src, dst net.Conn) error { + for { + m, err := readMessage(src) + if err != nil { + return err + } + if err := writeMessage(dst, m.Type, m.Payload); err != nil { + return err + } + if m.Type == 'Z' || m.Type == 'E' { + return nil + } + } +} + +// proxyExecuteRound 는 client↔server 를 양방향 proxy 하되, server→client 방향에서 *맨 앞 +// ParseComplete 1개* 를 걸러낸다(re-Parse 로 생긴 중복 — 클라이언트는 describe round 에서 +// 이미 ParseComplete 를 받았다). +func proxyExecuteRound(client, server net.Conn) { + done := make(chan struct{}, 2) + go func() { _, _ = io.Copy(server, client); done <- struct{}{} }() // Execute/Sync 등. + go func() { + swallowed := false + for { + m, err := readMessage(server) + if err != nil { + break + } + if !swallowed && m.Type == msgParseComplete { + swallowed = true + continue + } + if err := writeMessage(client, m.Type, m.Payload); err != nil { + break + } + } + done <- struct{}{} + }() + <-done +} diff --git a/cmd/pg-router/querymode.go b/cmd/pg-router/querymode.go index a0e0be8..849cc93 100644 --- a/cmd/pg-router/querymode.go +++ b/cmd/pg-router/querymode.go @@ -82,6 +82,21 @@ func (qr queryRouter) shardColumn() string { return topo.Spec.Vindex.Column } +// anyShard 는 describe-round 대행용 임의(결정적) 샤드 이름 + 그 backend 를 반환한다. +// 스키마(파라미터/컬럼 타입)는 모든 샤드 공통이므로 어느 샤드로 describe 해도 무방하다. +func (qr queryRouter) anyShard() (shard, backend string, err error) { + topo, err := qr.provider.Current(context.Background()) + if err != nil { + return "", "", err + } + shard, err = topo.AnyShard() + if err != nil { + return "", "", err + } + backend, err = qr.write(shard) + return shard, backend, err +} + // handleQueryMode 는 쿼리 인지 라우팅으로 한 연결을 처리한다. backendPassword 는 // 백엔드 인증 대행(scram/cleartext)용 — "" 면 trust 백엔드만 동작. func handleQueryMode(client net.Conn, qr queryRouter, dialer *backendDialer, serverVersion, backendPassword string) { @@ -158,8 +173,8 @@ func handleParse(client net.Conn, qr queryRouter, parse pgMessage, raw []byte, d logRoute('B', d) proxyToShard(client, raw, buffered, d, dialer, backendPassword) return - case 'S': // Sync 가 Bind 보다 먼저 — 라우팅 불가. - writePgError(client, "08006", "no Bind before Sync; cannot route parameterized query") + case 'S': // Sync 가 Bind 보다 먼저(lib/pq 등 describe-first) — describe-round 대행. + describeRoundDelegate(client, qr, buffered, parse, pidx, sql, raw, dialer, backendPassword) return } } From 48fb00c077275eaa0f9bdfa21dfeb5287644ffc2 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sat, 27 Jun 2026 23:42:53 +0900 Subject: [PATCH 050/109] =?UTF-8?q?docs(router):=20(E)=20describe-round=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EB=B0=98=EC=98=81=20=E2=80=94=20=EC=8B=A4?= =?UTF-8?q?=20DB=20=EB=93=9C=EB=9D=BC=EC=9D=B4=EB=B2=84=20=EB=8F=99?= =?UTF-8?q?=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/WORK_HANDOFF.ko.md | 6 +++--- docs/sharding/ROUTER-GAP-ANALYSIS.ko.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index 4a05fe7..8fc6590 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -214,9 +214,9 @@ SSOT는 [ROUTER-GAP-ANALYSIS §4 능력 사다리 + §6 백로그](sharding/ROUT | 우선 | 항목 | 왜 / 블로커 | |---|---|---| -| ~~1~~ ✅ | ~~scram 인증 대행~~ | **완료(2026-06-27)** — scram-sha-256/cleartext 대행, 라이브 검증. query-mode가 프로덕션 PG와 동작 | -| **1** | **describe-round 대행** | lib/pq·JDBC의 `Parse→Describe→Sync→Bind` 파라미터화 쿼리 라우팅 (vtgate급 닭-달걀) | -| **3** | **멀티샤드 scatter forwarding** | 키 없는 쿼리 N-shard fan-out + 결과 재조립 → "분산처리능력" 수치의 전제 | +| ~~·~~ ✅ | ~~scram 인증 대행~~ | **완료** — 프로덕션 PG(scram) 동작 | +| ~~·~~ ✅ | ~~describe-round 대행~~ | **완료(2026-06-27)** — lib/pq 파라미터 쿼리 라이브 검증 → **실 DB 드라이버와 동작** | +| **1** | **멀티샤드 scatter forwarding** | 키 없는 쿼리 N-shard fan-out + 결과 재조립 → "분산처리능력" 수치의 전제 | | **4** | **B: resharding 실데이터 이동** | ShardSplitJob의 CopyTable DSN 결선 + CDC + write-block cutover (현재 골격) | | **5** | reference table·read-replica **프록시 결선** | 부품은 완성, query-mode에 연결만 | | **6** | 보류 #5/#7/#9 | per-shard primary Service·watch·failover lease P2-T3 — 라이브 failover 필요 | diff --git a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md index f799b38..9e45ec8 100644 --- a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md +++ b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md @@ -127,7 +127,7 @@ parameterized/`t.col`/주석·문자열 내부 오인 방지까지 검증. **기 > 종단이 되어야 완성. 아래 항목은 그 종단/운영-코어/라이브검증 필요분. **라우팅 핵심 (능력 사다리)** -- [~] **(E) 프로토콜 종단 — 쿼리 단위 라우팅**: **query-mode 라이브 PG end-to-end 검증 완료 (2026-06-27)** 🎉. 2 trust postgres(shard-0/1) + pg-router `PGROUTER_MODE=query` → psql `SELECT located_on FROM probe WHERE id='alice'` 이 키 추출→murmur3 vindex→샤드 라우팅→실 쿼리 실행→결과 반환까지 동작. **alice→shard-0 / bob→shard-1 / carol→shard-0** 결정적·올바른 분산 확인(bob이 다른 샤드 = 해시 라우팅 실증). 라이브 검증에서 프로토콜 버그(백엔드 핸드셰이크 미소비) 발견·수정. **종단 작업 진척**: ① **scram-sha-256 / cleartext 백엔드 인증 대행 ✅완료**(RFC 7677 단위검증 + 라이브 scram PG 검증, `crypto/pbkdf2` stdlib, `PGROUTER_BACKEND_PASSWORD`) — **query-mode가 trust 아닌 실 프로덕션 PG와 동작** ② extended protocol — *부분 완료*: inline-literal Parse + 단일라운드 parameterized(Parse+Bind 연속) 라우팅(`ExtractParamRef`+`bindParams`+Bind 버퍼링). **단, lib/pq는 Parse→Describe→Sync(타입조회)→Bind 순이라 Bind 전 Sync에서 실패** — describe-round 대행(default 샤드로 describe round-trip 후 Bind에서 실샤드 라우팅)이 vtgate급 남은 난제 ③ 멀티샤드 scatter forwarding + 결과 재조립 ④ 백엔드 연결 풀 ⑤ ~~per-query 로그~~ ✅완료. 라이브 검증으로 simple-query 정상·파라미터 한계 확인. +- [~] **(E) 프로토콜 종단 — 쿼리 단위 라우팅**: **query-mode 라이브 PG end-to-end 검증 완료 (2026-06-27)** 🎉. 2 trust postgres(shard-0/1) + pg-router `PGROUTER_MODE=query` → psql `SELECT located_on FROM probe WHERE id='alice'` 이 키 추출→murmur3 vindex→샤드 라우팅→실 쿼리 실행→결과 반환까지 동작. **alice→shard-0 / bob→shard-1 / carol→shard-0** 결정적·올바른 분산 확인(bob이 다른 샤드 = 해시 라우팅 실증). 라이브 검증에서 프로토콜 버그(백엔드 핸드셰이크 미소비) 발견·수정. **종단 작업 진척**: ① **scram-sha-256 / cleartext 백엔드 인증 대행 ✅완료**(RFC 7677 단위검증 + 라이브 scram PG 검증, `crypto/pbkdf2` stdlib, `PGROUTER_BACKEND_PASSWORD`) — **query-mode가 trust 아닌 실 프로덕션 PG와 동작** ② **extended protocol ✅완료**: inline-literal + parameterized(`$N`) 라우팅. **describe-round 대행 구현**(`describeround.go`) — lib/pq·JDBC 의 `Parse→Describe→Sync→Bind` 를, 임의 샤드로 describe round 대행 후 Bind 파라미터로 실 샤드 라우팅(다르면 re-Parse + 중복 ParseComplete 필터). **라이브 검증: lib/pq `WHERE id=$1` 정상 라우팅**(alice→shard-0/bob→shard-1) ⇒ **실 DB 드라이버와 동작** ③ 멀티샤드 scatter forwarding + 결과 재조립(남음) ④ 백엔드 연결 풀(남음) ⑤ ~~per-query 로그~~ ✅완료. - [ ] **읽기 → replica 라우팅**: `status.shards[].replicas[]`(Ready)로 읽기 쿼리를 분산 → primary 부하 경감 + 읽기 확장. 사다리 2단계. (status backend 계층 재사용.) - [ ] **scatter-gather 실연결**: `scatter.go` 골격 → 실 wire-protocol fan-out + merge + 집계/정렬 pushdown. 사다리 2~3단계. - [ ] **Reference table**: 전 샤드 복제로 분산 조인 우회. 사다리 4단계. 고가치·저난도. From 895796c2e0e71239624d7f4532d0c7e03cba7a17 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 00:15:11 +0900 Subject: [PATCH 051/109] =?UTF-8?q?feat(router):=20(E)=20=EB=A9=80?= =?UTF-8?q?=ED=8B=B0=EC=83=A4=EB=93=9C=20scatter=20forwarding=20=E2=80=94?= =?UTF-8?q?=20=EB=B6=84=EC=82=B0=20=EC=9D=BD=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 라우팅 키 없는 simple Query(예: SELECT * FROM t)를 모든 샤드에 fan-out 후 결과 병합(UNION ALL): 각 샤드의 RowDescription·DataRow·CommandComplete 수집→첫 RowDescription 1개+전 샤드 DataRow+합산 CommandComplete+ReadyForQuery. queryRouter.allShards()로 전 샤드 backend 조회. fail-fast(샤드 에러 전달). **라이브 검증**: SELECT located_on FROM probe(키 없음)→양 샤드 6행 병합(shard-0×3+shard-1×3), 라우팅 쿼리 회귀 없음. 제약: 행 concat만(집계 재merge·전역 ORDER BY·LIMIT pushdown·병렬 fan-out은 후속). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- cmd/pg-router/querymode.go | 39 +++++++++++-- cmd/pg-router/scattermode.go | 108 +++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 cmd/pg-router/scattermode.go diff --git a/cmd/pg-router/querymode.go b/cmd/pg-router/querymode.go index 849cc93..0e41419 100644 --- a/cmd/pg-router/querymode.go +++ b/cmd/pg-router/querymode.go @@ -97,6 +97,34 @@ func (qr queryRouter) anyShard() (shard, backend string, err error) { return shard, backend, err } +// shardBackend 는 한 샤드와 그 backend 주소다. +type shardBackend struct { + shard string + backend string +} + +// allShards 는 현재 토폴로지의 모든 distinct 샤드 + backend 를 반환한다 (scatter fan-out). +func (qr queryRouter) allShards() ([]shardBackend, error) { + topo, err := qr.provider.Current(context.Background()) + if err != nil { + return nil, err + } + seen := map[string]bool{} + var out []shardBackend + for _, r := range topo.Spec.Ranges { + if r.Shard == "" || seen[r.Shard] { + continue + } + seen[r.Shard] = true + backend, err := qr.write(r.Shard) // 읽기 replica 분산은 후속. + if err != nil { + return nil, err + } + out = append(out, shardBackend{shard: r.Shard, backend: backend}) + } + return out, nil +} + // handleQueryMode 는 쿼리 인지 라우팅으로 한 연결을 처리한다. backendPassword 는 // 백엔드 인증 대행(scram/cleartext)용 — "" 면 trust 백엔드만 동작. func handleQueryMode(client net.Conn, qr queryRouter, dialer *backendDialer, serverVersion, backendPassword string) { @@ -184,15 +212,16 @@ func logRoute(typ byte, d router.RouteDecision) { log.Printf("pg-router: routed (%c) shard=%s backend=%s read=%v", typ, d.Shard, d.Backend, d.Read) } -// routeAndProxy 는 simple Query 경로의 라우팅 + proxy. +// routeAndProxy 는 simple Query 경로의 라우팅 + proxy. 라우팅 키가 없으면(Scatter) 모든 +// 샤드에 fan-out 한다. func routeAndProxy(client net.Conn, qr queryRouter, sql string, raw []byte, msgs []pgMessage, dialer *backendDialer, backendPassword string) { d, err := qr.routeSQL(sql) - if err != nil { - writePgError(client, "08006", "routing failed: "+err.Error()) + if d.Scatter { // 키 없음 → 멀티샤드 scatter-gather. + scatterQuery(client, qr, msgs[0], raw, dialer, backendPassword) return } - if d.Scatter { - writePgError(client, "0A000", "multi-shard query not supported yet (single-shard fast-path only)") + if err != nil { + writePgError(client, "08006", "routing failed: "+err.Error()) return } logRoute('Q', d) diff --git a/cmd/pg-router/scattermode.go b/cmd/pg-router/scattermode.go new file mode 100644 index 0000000..6a2806b --- /dev/null +++ b/cmd/pg-router/scattermode.go @@ -0,0 +1,108 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +// scattermode.go 는 *라우팅 키 없는 쿼리*(예: `SELECT * FROM t`)를 모든 샤드에 fan-out +// 하고 결과를 병합해 클라이언트에 돌려준다 (scatter-gather, simple Query). +// +// 동작: 각 샤드에 동일 쿼리를 보내 RowDescription·DataRow·CommandComplete 를 수집한 뒤, +// 첫 샤드의 RowDescription 1개 + 모든 샤드의 DataRow 전부 + 합산 CommandComplete + +// ReadyForQuery 를 클라이언트에 보낸다 (UNION ALL 의미). +// +// 제약: 행 concat 만 한다 — `SELECT count(*)` 같은 *집계* 는 샤드별 부분 결과를 그대로 +// 합치므로(샤드당 1행) 재집계가 필요하다(후속, planner 가 sum/merge). ORDER BY 전역 정렬, +// LIMIT pushdown, 병렬 fan-out(현재 순차)도 후속. +package main + +import ( + "fmt" + "net" +) + +// scatterQuery 는 simple Query('Q')를 모든 샤드에 fan-out 하고 병합 결과를 보낸다. +func scatterQuery(client net.Conn, qr queryRouter, query pgMessage, raw []byte, dialer *backendDialer, password string) { + shards, err := qr.allShards() + if err != nil || len(shards) == 0 { + writePgError(client, "08006", "scatter: no shards available") + return + } + + var rowDesc *pgMessage + var dataRows []pgMessage + for _, sb := range shards { + conn, err := dialer.Dial(sb.backend) + if err != nil { + writePgError(client, "08006", fmt.Sprintf("scatter: dial shard %s: %v", sb.shard, err)) + return + } + if _, err := conn.Write(raw); err != nil { + _ = conn.Close() + return + } + if err := authenticateAndDrain(conn, password); err != nil { + _ = conn.Close() + writePgError(client, "08006", "scatter: backend startup: "+err.Error()) + return + } + if err := writeMessage(conn, 'Q', query.Payload); err != nil { + _ = conn.Close() + return + } + rd, rows, errMsg, err := readQueryResult(conn) + _ = conn.Close() + if err != nil { + writePgError(client, "08006", fmt.Sprintf("scatter: read shard %s: %v", sb.shard, err)) + return + } + if errMsg != nil { // 한 샤드라도 에러면 그대로 전달(fail-fast). + _ = writeMessage(client, 'E', errMsg.Payload) + _ = writeMessage(client, 'Z', []byte{'I'}) + return + } + if rowDesc == nil { + rowDesc = rd + } + dataRows = append(dataRows, rows...) + } + + // 병합 결과 송신: RowDescription(1) + DataRow(전부) + CommandComplete + ReadyForQuery. + if rowDesc != nil { + if err := writeMessage(client, 'T', rowDesc.Payload); err != nil { + return + } + } + for _, dr := range dataRows { + if err := writeMessage(client, 'D', dr.Payload); err != nil { + return + } + } + _ = writeMessage(client, 'C', cstring(fmt.Sprintf("SELECT %d", len(dataRows)))) + _ = writeMessage(client, 'Z', []byte{'I'}) +} + +// readQueryResult 는 한 백엔드의 simple-query 응답을 ReadyForQuery 까지 읽어 RowDescription· +// DataRow·ErrorResponse 를 수집한다. CommandComplete·기타는 무시. +func readQueryResult(conn net.Conn) (rowDesc *pgMessage, rows []pgMessage, errMsg *pgMessage, err error) { + for { + m, err := readMessage(conn) + if err != nil { + return nil, nil, nil, err + } + switch m.Type { + case 'T': // RowDescription + if rowDesc == nil { + rd := m + rowDesc = &rd + } + case 'D': // DataRow + rows = append(rows, m) + case 'E': // ErrorResponse + em := m + errMsg = &em + case 'Z': // ReadyForQuery — 이 샤드 완료. + return rowDesc, rows, errMsg, nil + } + } +} From b2abbc63d36efa28a3b88f17b314fa35b47f8ace Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 00:20:44 +0900 Subject: [PATCH 052/109] =?UTF-8?q?perf(router):=20scatter=20fan-out=20?= =?UTF-8?q?=EB=B3=91=EB=A0=AC=ED=99=94=20=E2=80=94=20=EB=B6=84=EC=82=B0=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 순차 fan-out(지연=Σ샤드)을 goroutine-per-shard 병렬(지연=max샤드)로. 각 샤드 독립 연결, 결과를 인덱스 슬롯에 수집 후 순서대로 병합(레이스 없음). 라이브 재검증: SELECT FROM probe→6행 병합 유지, 라우팅 회귀 없음. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- cmd/pg-router/scattermode.go | 78 +++++++++++++++++++++++------------- 1 file changed, 50 insertions(+), 28 deletions(-) diff --git a/cmd/pg-router/scattermode.go b/cmd/pg-router/scattermode.go index 6a2806b..0e697be 100644 --- a/cmd/pg-router/scattermode.go +++ b/cmd/pg-router/scattermode.go @@ -19,9 +19,19 @@ package main import ( "fmt" "net" + "sync" ) -// scatterQuery 는 simple Query('Q')를 모든 샤드에 fan-out 하고 병합 결과를 보낸다. +// shardResult 는 한 샤드의 scatter 응답이다. +type shardResult struct { + rowDesc *pgMessage + rows []pgMessage + errMsg *pgMessage // 백엔드 ErrorResponse + err error // 전송/연결 오류 +} + +// scatterQuery 는 simple Query('Q')를 모든 샤드에 *병렬* fan-out 하고 병합 결과를 보낸다. +// 병렬이라 전체 지연이 max(샤드) 에 가깝다(순차 합산 아님) — 분산 읽기 확장의 핵심. func scatterQuery(client net.Conn, qr queryRouter, query pgMessage, raw []byte, dialer *backendDialer, password string) { shards, err := qr.allShards() if err != nil || len(shards) == 0 { @@ -29,42 +39,34 @@ func scatterQuery(client net.Conn, qr queryRouter, query pgMessage, raw []byte, return } + results := make([]shardResult, len(shards)) + var wg sync.WaitGroup + for i := range shards { + wg.Add(1) + go func(i int, sb shardBackend) { + defer wg.Done() + results[i] = scatterOne(sb, query, raw, dialer, password) + }(i, shards[i]) + } + wg.Wait() + var rowDesc *pgMessage var dataRows []pgMessage - for _, sb := range shards { - conn, err := dialer.Dial(sb.backend) - if err != nil { - writePgError(client, "08006", fmt.Sprintf("scatter: dial shard %s: %v", sb.shard, err)) - return - } - if _, err := conn.Write(raw); err != nil { - _ = conn.Close() - return - } - if err := authenticateAndDrain(conn, password); err != nil { - _ = conn.Close() - writePgError(client, "08006", "scatter: backend startup: "+err.Error()) - return - } - if err := writeMessage(conn, 'Q', query.Payload); err != nil { - _ = conn.Close() + for i := range results { + r := results[i] + if r.err != nil { + writePgError(client, "08006", fmt.Sprintf("scatter: shard %s: %v", shards[i].shard, r.err)) return } - rd, rows, errMsg, err := readQueryResult(conn) - _ = conn.Close() - if err != nil { - writePgError(client, "08006", fmt.Sprintf("scatter: read shard %s: %v", sb.shard, err)) - return - } - if errMsg != nil { // 한 샤드라도 에러면 그대로 전달(fail-fast). - _ = writeMessage(client, 'E', errMsg.Payload) + if r.errMsg != nil { // 한 샤드라도 에러면 그대로 전달(fail-fast). + _ = writeMessage(client, 'E', r.errMsg.Payload) _ = writeMessage(client, 'Z', []byte{'I'}) return } if rowDesc == nil { - rowDesc = rd + rowDesc = r.rowDesc } - dataRows = append(dataRows, rows...) + dataRows = append(dataRows, r.rows...) } // 병합 결과 송신: RowDescription(1) + DataRow(전부) + CommandComplete + ReadyForQuery. @@ -82,6 +84,26 @@ func scatterQuery(client net.Conn, qr queryRouter, query pgMessage, raw []byte, _ = writeMessage(client, 'Z', []byte{'I'}) } +// scatterOne 은 한 샤드에 연결·인증·쿼리하고 결과를 수집한다 (goroutine 에서 호출). +func scatterOne(sb shardBackend, query pgMessage, raw []byte, dialer *backendDialer, password string) shardResult { + conn, err := dialer.Dial(sb.backend) + if err != nil { + return shardResult{err: err} + } + defer func() { _ = conn.Close() }() + if _, err := conn.Write(raw); err != nil { + return shardResult{err: err} + } + if err := authenticateAndDrain(conn, password); err != nil { + return shardResult{err: err} + } + if err := writeMessage(conn, 'Q', query.Payload); err != nil { + return shardResult{err: err} + } + rd, rows, errMsg, err := readQueryResult(conn) + return shardResult{rowDesc: rd, rows: rows, errMsg: errMsg, err: err} +} + // readQueryResult 는 한 백엔드의 simple-query 응답을 ReadyForQuery 까지 읽어 RowDescription· // DataRow·ErrorResponse 를 수집한다. CommandComplete·기타는 무시. func readQueryResult(conn net.Conn) (rowDesc *pgMessage, rows []pgMessage, errMsg *pgMessage, err error) { From 0bd8f1e52271bfc1c243cec872d4891fe9eabec3 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 00:22:46 +0900 Subject: [PATCH 053/109] =?UTF-8?q?docs(router):=20scatter=20forwarding=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20+=20=EC=97=B0=EA=B2=B0=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95(per-connection=20pinning)=20=ED=95=9C=EA=B3=84=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/WORK_HANDOFF.ko.md | 6 ++++-- docs/sharding/ROUTER-GAP-ANALYSIS.ko.md | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index 8fc6590..e72eddc 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -215,8 +215,10 @@ SSOT는 [ROUTER-GAP-ANALYSIS §4 능력 사다리 + §6 백로그](sharding/ROUT | 우선 | 항목 | 왜 / 블로커 | |---|---|---| | ~~·~~ ✅ | ~~scram 인증 대행~~ | **완료** — 프로덕션 PG(scram) 동작 | -| ~~·~~ ✅ | ~~describe-round 대행~~ | **완료(2026-06-27)** — lib/pq 파라미터 쿼리 라이브 검증 → **실 DB 드라이버와 동작** | -| **1** | **멀티샤드 scatter forwarding** | 키 없는 쿼리 N-shard fan-out + 결과 재조립 → "분산처리능력" 수치의 전제 | +| ~~·~~ ✅ | ~~describe-round 대행~~ | **완료** — lib/pq 파라미터 쿼리 → **실 DB 드라이버와 동작** | +| ~~·~~ ✅ | ~~멀티샤드 scatter forwarding~~ | **완료(2026-06-28)** — 병렬 fan-out + UNION ALL 병합, 라이브 검증 | +| **1** | **per-query 라우팅 (연결 종단 + 백엔드 풀)** | 현재 *연결 고정*(첫 쿼리로 샤드 결정) — 테넌트-당-연결엔 OK, 연결 풀 워크로드엔 per-query 라우팅 필요(vtgate급). 분산 수치도 이 모델 기준 | +| **2** | **분산 성능 수치** | scatter/연결분산 기준 N-shard throughput (워커 수 × TPS, "분산처리능력") | | **4** | **B: resharding 실데이터 이동** | ShardSplitJob의 CopyTable DSN 결선 + CDC + write-block cutover (현재 골격) | | **5** | reference table·read-replica **프록시 결선** | 부품은 완성, query-mode에 연결만 | | **6** | 보류 #5/#7/#9 | per-shard primary Service·watch·failover lease P2-T3 — 라이브 failover 필요 | diff --git a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md index 9e45ec8..37e0f2e 100644 --- a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md +++ b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md @@ -127,7 +127,9 @@ parameterized/`t.col`/주석·문자열 내부 오인 방지까지 검증. **기 > 종단이 되어야 완성. 아래 항목은 그 종단/운영-코어/라이브검증 필요분. **라우팅 핵심 (능력 사다리)** -- [~] **(E) 프로토콜 종단 — 쿼리 단위 라우팅**: **query-mode 라이브 PG end-to-end 검증 완료 (2026-06-27)** 🎉. 2 trust postgres(shard-0/1) + pg-router `PGROUTER_MODE=query` → psql `SELECT located_on FROM probe WHERE id='alice'` 이 키 추출→murmur3 vindex→샤드 라우팅→실 쿼리 실행→결과 반환까지 동작. **alice→shard-0 / bob→shard-1 / carol→shard-0** 결정적·올바른 분산 확인(bob이 다른 샤드 = 해시 라우팅 실증). 라이브 검증에서 프로토콜 버그(백엔드 핸드셰이크 미소비) 발견·수정. **종단 작업 진척**: ① **scram-sha-256 / cleartext 백엔드 인증 대행 ✅완료**(RFC 7677 단위검증 + 라이브 scram PG 검증, `crypto/pbkdf2` stdlib, `PGROUTER_BACKEND_PASSWORD`) — **query-mode가 trust 아닌 실 프로덕션 PG와 동작** ② **extended protocol ✅완료**: inline-literal + parameterized(`$N`) 라우팅. **describe-round 대행 구현**(`describeround.go`) — lib/pq·JDBC 의 `Parse→Describe→Sync→Bind` 를, 임의 샤드로 describe round 대행 후 Bind 파라미터로 실 샤드 라우팅(다르면 re-Parse + 중복 ParseComplete 필터). **라이브 검증: lib/pq `WHERE id=$1` 정상 라우팅**(alice→shard-0/bob→shard-1) ⇒ **실 DB 드라이버와 동작** ③ 멀티샤드 scatter forwarding + 결과 재조립(남음) ④ 백엔드 연결 풀(남음) ⑤ ~~per-query 로그~~ ✅완료. +- [~] **(E) 프로토콜 종단 — 쿼리 단위 라우팅**: **query-mode 라이브 PG end-to-end 검증 완료 (2026-06-27)** 🎉. 2 trust postgres(shard-0/1) + pg-router `PGROUTER_MODE=query` → psql `SELECT located_on FROM probe WHERE id='alice'` 이 키 추출→murmur3 vindex→샤드 라우팅→실 쿼리 실행→결과 반환까지 동작. **alice→shard-0 / bob→shard-1 / carol→shard-0** 결정적·올바른 분산 확인(bob이 다른 샤드 = 해시 라우팅 실증). 라이브 검증에서 프로토콜 버그(백엔드 핸드셰이크 미소비) 발견·수정. **종단 작업 진척**: ① **scram-sha-256 / cleartext 백엔드 인증 대행 ✅완료**(RFC 7677 단위검증 + 라이브 scram PG 검증, `crypto/pbkdf2` stdlib, `PGROUTER_BACKEND_PASSWORD`) — **query-mode가 trust 아닌 실 프로덕션 PG와 동작** ② **extended protocol ✅완료**: inline-literal + parameterized(`$N`) 라우팅. **describe-round 대행 구현**(`describeround.go`) — lib/pq·JDBC 의 `Parse→Describe→Sync→Bind` 를, 임의 샤드로 describe round 대행 후 Bind 파라미터로 실 샤드 라우팅(다르면 re-Parse + 중복 ParseComplete 필터). **라이브 검증: lib/pq `WHERE id=$1` 정상 라우팅**(alice→shard-0/bob→shard-1) ⇒ **실 DB 드라이버와 동작** ③ **멀티샤드 scatter forwarding ✅완료**(`scattermode.go`): 키 없는 simple Query를 모든 샤드에 *병렬* fan-out→결과 병합(UNION ALL), 라이브 검증(SELECT FROM probe→양샤드 6행). 집계 재merge·전역 ORDER BY·LIMIT pushdown은 후속 ④ 백엔드 연결 풀(남음) ⑤ ~~per-query 로그~~ ✅완료. +> +> **⚠️ 알려진 한계 — 연결 고정(per-connection pinning)**: query-mode는 *첫 쿼리*로 연결의 샤드를 정하고 이후 메시지는 blind proxy한다. 즉 한 연결의 후속 쿼리(다른 키)도 첫 샤드로 간다. **테넌트-당-연결**(멀티테넌트 v1, 한 연결=한 테넌트=한 샤드)엔 적합하나, 연결 풀로 키가 섞이는 워크로드엔 부적합 — 진짜 *per-query 라우팅*은 vtgate처럼 연결 종단 + 백엔드 연결 풀 유지가 필요(큰 후속). 분산 성능 수치도 이 모델(연결 단위 분산) 기준으로 측정해야 함. - [ ] **읽기 → replica 라우팅**: `status.shards[].replicas[]`(Ready)로 읽기 쿼리를 분산 → primary 부하 경감 + 읽기 확장. 사다리 2단계. (status backend 계층 재사용.) - [ ] **scatter-gather 실연결**: `scatter.go` 골격 → 실 wire-protocol fan-out + merge + 집계/정렬 pushdown. 사다리 2~3단계. - [ ] **Reference table**: 전 샤드 복제로 분산 조인 우회. 사다리 4단계. 고가치·저난도. From fa212b60d4d36359f55b72ffbda408de7c45a91e Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 00:42:39 +0900 Subject: [PATCH 054/109] =?UTF-8?q?feat(pg-router):=20query-mode=20per-que?= =?UTF-8?q?ry=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20(=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=ED=95=B4=EC=86=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit query-mode 가 첫 쿼리로 연결을 한 샤드에 고정하던 한계를 해소한다. runPerQuerySession 세션 루프가 한 연결의 *매* simple Query 를 그 키의 샤드로 독립 라우팅하고(vtgate 모델), 샤드별 백엔드 연결을 세션 내에서 lazy 풀링·재사용한다. - 키 있는 autocommit 쿼리: per-query 라우팅 (id=alice→shard-0, id=bob→ shard-1 을 *한 연결*에서 검증). - 키 없는 쿼리: scatter fan-out. - 단일샤드 명시적 트랜잭션: BEGIN 응답 합성 후 첫 키 쿼리로 한 샤드에 pin, COMMIT/ROLLBACK 까지 그 백엔드 재사용 (cross-shard 2PC 는 범위 밖). - extended(Parse)는 현행 pin-on-first(describe-round) 유지 — per-query extended 는 샤드별 prepared statement 관리가 필요해 후속. 라이브 검증(2 scram 샤드): per-query·scatter·tx 모두 정상. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- cmd/pg-router/persession.go | 208 ++++++++++++++++++++++++++++++++++++ cmd/pg-router/querymode.go | 38 +------ 2 files changed, 210 insertions(+), 36 deletions(-) create mode 100644 cmd/pg-router/persession.go diff --git a/cmd/pg-router/persession.go b/cmd/pg-router/persession.go new file mode 100644 index 0000000..7a42884 --- /dev/null +++ b/cmd/pg-router/persession.go @@ -0,0 +1,208 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +// persession.go 는 *per-query 라우팅* 세션을 구현한다 — 연결 고정(첫 쿼리로 샤드 결정)을 +// 풀어, 한 연결의 *매 simple Query* 를 그 키의 샤드로 라우팅한다(vtgate 모델). 샤드별 백엔드 +// 연결을 세션 내에서 lazy 풀링·재사용한다. +// +// 지원: autocommit 키 라우팅, 키 없는 쿼리 scatter, *단일샤드* 명시적 트랜잭션(BEGIN 응답을 +// 합성하고 첫 키 쿼리로 한 샤드에 pin — cross-shard 2PC 는 범위 밖). extended protocol(Parse) +// 은 현행 pin-on-first(describe-round)로 처리한다(per-query extended 는 후속 — 샤드별 +// prepared statement 관리 필요). +package main + +import ( + "fmt" + "net" + "strings" +) + +// session 은 한 클라이언트 연결의 per-query 라우팅 상태다. +type session struct { + client net.Conn + qr queryRouter + dialer *backendDialer + password string + raw []byte // 클라이언트 startup (백엔드 인증용) + + backends map[string]net.Conn // backend addr → 연결 (lazy 풀). + inTx bool // 명시적 트랜잭션 중. + pendingBegin pgMessage // 아직 백엔드로 안 보낸 BEGIN (pin 시 전송). + txBackend net.Conn // 트랜잭션이 pin 된 백엔드. +} + +// runPerQuerySession 은 핸드셰이크 후 세션 루프를 돈다. +func runPerQuerySession(client net.Conn, qr queryRouter, dialer *backendDialer, password string, raw []byte) { + s := &session{client: client, qr: qr, dialer: dialer, password: password, raw: raw, backends: map[string]net.Conn{}} + defer s.closeBackends() + for { + m, err := readMessage(client) + if err != nil { + return + } + switch m.Type { + case 'X': // Terminate + return + case 'Q': // simple Query → per-query 라우팅. + if !s.handleSimpleQuery(m) { + return + } + case 'P': // extended → pin-on-first(describe-round). 이후 연결 종료. + handleParse(client, qr, m, raw, dialer, password) + return + default: + writePgError(client, "0A000", fmt.Sprintf("message type %q not supported in per-query mode", m.Type)) + return + } + } +} + +// handleSimpleQuery 는 한 simple Query 를 라우팅·실행하고 결과를 relay 한다. 세션 계속이면 +// true. +func (s *session) handleSimpleQuery(m pgMessage) bool { + sql, ok := querySQL(m) + if !ok { + writePgError(s.client, "08P01", "could not parse query") + return false + } + kw := firstKeyword(sql) + + // 트랜잭션 pin 됨: pin 된 백엔드로. + if s.inTx && s.txBackend != nil { + alive := s.execAndRelay(s.txBackend, m) + if kw == "commit" || kw == "rollback" || kw == "end" { + s.inTx = false + s.txBackend = nil + } + return alive + } + + // BEGIN: 응답을 합성하고 다음 키 쿼리로 pin (BEGIN 은 키가 없어 단독 라우팅 불가). + if !s.inTx && (kw == "begin" || kw == "start") { + if err := fabricateBegin(s.client); err != nil { + return false + } + s.inTx = true + s.pendingBegin = m + s.txBackend = nil + return true + } + + // 라우팅. + d, err := s.qr.routeSQL(sql) + if d.Scatter { // 키 없음 → scatter (자체 연결). + if s.inTx { + writePgError(s.client, "0A000", "cannot scatter a keyless query inside a transaction") + return true + } + scatterQuery(s.client, s.qr, m, s.raw, s.dialer, s.password) + return true + } + if err != nil { + writePgError(s.client, "08006", "routing failed: "+err.Error()) + return true + } + conn, err := s.backendFor(d.Backend) + if err != nil { + writePgError(s.client, "08006", "backend: "+err.Error()) + return false + } + // 트랜잭션 시작 직후 첫 키 쿼리: BEGIN 을 이 샤드로 보내(응답 폐기) pin. + if s.inTx && s.txBackend == nil { + if err := writeMessage(conn, s.pendingBegin.Type, s.pendingBegin.Payload); err != nil { + return false + } + if err := drainResponse(conn); err != nil { + writePgError(s.client, "08006", "tx begin: "+err.Error()) + return false + } + s.txBackend = conn + } + logRoute('Q', d) + return s.execAndRelay(conn, m) +} + +// backendFor 는 backend 연결을 lazy 풀링·재사용한다 (세션 내). +func (s *session) backendFor(backend string) (net.Conn, error) { + if c, ok := s.backends[backend]; ok { + return c, nil + } + c, err := s.dialer.Dial(backend) + if err != nil { + return nil, err + } + if _, err := c.Write(s.raw); err != nil { + _ = c.Close() + return nil, err + } + if err := authenticateAndDrain(c, s.password); err != nil { + _ = c.Close() + return nil, err + } + s.backends[backend] = c + return c, nil +} + +// execAndRelay 는 query 를 backend 로 보내고 응답을 ReadyForQuery 까지 클라이언트로 relay +// 한다. 연결 유지면 true. +func (s *session) execAndRelay(conn net.Conn, m pgMessage) bool { + if err := writeMessage(conn, m.Type, m.Payload); err != nil { + return false + } + for { + rm, err := readMessage(conn) + if err != nil { + return false + } + if err := writeMessage(s.client, rm.Type, rm.Payload); err != nil { + return false + } + if rm.Type == 'Z' { // ReadyForQuery — 이 쿼리 완료. + return true + } + } +} + +func (s *session) closeBackends() { + for _, c := range s.backends { + _ = c.Close() + } +} + +// drainResponse 는 backend 응답을 ReadyForQuery 까지 읽어 *폐기* 한다 (합성 BEGIN 의 실응답). +func drainResponse(conn net.Conn) error { + for { + rm, err := readMessage(conn) + if err != nil { + return err + } + if rm.Type == 'E' { + return fmt.Errorf("backend error: %s", string(rm.Payload)) + } + if rm.Type == 'Z' { + return nil + } + } +} + +// fabricateBegin 은 클라이언트에 BEGIN 응답(CommandComplete + ReadyForQuery in-transaction)을 +// 합성해 보낸다 — 실 BEGIN 은 첫 키 쿼리의 샤드로 미뤄 보낸다. +func fabricateBegin(client net.Conn) error { + if err := writeMessage(client, 'C', cstring("BEGIN")); err != nil { + return err + } + return writeMessage(client, 'Z', []byte{'T'}) // 'T' = in transaction block. +} + +// firstKeyword 는 SQL 의 첫 단어를 소문자로 반환한다 (선행 공백/괄호 무시). +func firstKeyword(sql string) string { + sql = strings.TrimLeft(sql, " \t\r\n(") + i := strings.IndexAny(sql, " \t\r\n;(") + if i < 0 { + i = len(sql) + } + return strings.ToLower(sql[:i]) +} diff --git a/cmd/pg-router/querymode.go b/cmd/pg-router/querymode.go index 0e41419..a1c4584 100644 --- a/cmd/pg-router/querymode.go +++ b/cmd/pg-router/querymode.go @@ -137,26 +137,8 @@ func handleQueryMode(client net.Conn, qr queryRouter, dialer *backendDialer, ser if err := sendTrustHandshake(client, serverVersion); err != nil { return } - - m, err := readMessage(client) - if err != nil { - return - } - switch m.Type { - case 'X': // Terminate - return - case 'Q': // simple Query - sql, ok := querySQL(m) - if !ok { - writePgError(client, "08P01", "could not parse query") - return - } - routeAndProxy(client, qr, sql, raw, []pgMessage{m}, dialer, backendPassword) - case 'P': // extended Parse - handleParse(client, qr, m, raw, dialer, backendPassword) - default: - writePgError(client, "0A000", fmt.Sprintf("message type %q not supported in query-routing PoC", m.Type)) - } + // per-query 라우팅 세션: 매 simple Query 를 키의 샤드로 라우팅(연결 고정 해소). + runPerQuerySession(client, qr, dialer, backendPassword, raw) } // handleParse 는 Parse('P') 를 처리한다: 인라인 리터럴이면 즉시, parameterized 면 Bind @@ -212,22 +194,6 @@ func logRoute(typ byte, d router.RouteDecision) { log.Printf("pg-router: routed (%c) shard=%s backend=%s read=%v", typ, d.Shard, d.Backend, d.Read) } -// routeAndProxy 는 simple Query 경로의 라우팅 + proxy. 라우팅 키가 없으면(Scatter) 모든 -// 샤드에 fan-out 한다. -func routeAndProxy(client net.Conn, qr queryRouter, sql string, raw []byte, msgs []pgMessage, dialer *backendDialer, backendPassword string) { - d, err := qr.routeSQL(sql) - if d.Scatter { // 키 없음 → 멀티샤드 scatter-gather. - scatterQuery(client, qr, msgs[0], raw, dialer, backendPassword) - return - } - if err != nil { - writePgError(client, "08006", "routing failed: "+err.Error()) - return - } - logRoute('Q', d) - proxyToShard(client, raw, msgs, d, dialer, backendPassword) -} - // proxyToShard 는 결정된 샤드 backend 에 연결해 startup + 버퍼링된 메시지(들)를 전달하고 // 양방향 proxy 한다. 백엔드 인증(trust/cleartext/scram)은 라우터가 대행한다. func proxyToShard(client net.Conn, raw []byte, msgs []pgMessage, d router.RouteDecision, dialer *backendDialer, backendPassword string) { From c3fc111a73fb142bd050a35d48f868b1a09a2397 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 00:45:28 +0900 Subject: [PATCH 055/109] =?UTF-8?q?docs:=20per-query=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=8C=85=20=EC=99=84=EB=A3=8C=20=EB=B0=98=EC=98=81=20(?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EA=B3=A0=EC=A0=95=20=ED=95=9C=EA=B3=84=20?= =?UTF-8?q?=ED=95=B4=EC=86=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ROUTER-GAP-ANALYSIS §6 백로그 항목 완료 + 연결 고정 경고를 해소됨으로 교체, WORK_HANDOFF §6.6 우선순위 갱신(per-query 완료 → 분산 수치가 #1). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/WORK_HANDOFF.ko.md | 4 ++-- docs/sharding/ROUTER-GAP-ANALYSIS.ko.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index e72eddc..9f43846 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -217,8 +217,8 @@ SSOT는 [ROUTER-GAP-ANALYSIS §4 능력 사다리 + §6 백로그](sharding/ROUT | ~~·~~ ✅ | ~~scram 인증 대행~~ | **완료** — 프로덕션 PG(scram) 동작 | | ~~·~~ ✅ | ~~describe-round 대행~~ | **완료** — lib/pq 파라미터 쿼리 → **실 DB 드라이버와 동작** | | ~~·~~ ✅ | ~~멀티샤드 scatter forwarding~~ | **완료(2026-06-28)** — 병렬 fan-out + UNION ALL 병합, 라이브 검증 | -| **1** | **per-query 라우팅 (연결 종단 + 백엔드 풀)** | 현재 *연결 고정*(첫 쿼리로 샤드 결정) — 테넌트-당-연결엔 OK, 연결 풀 워크로드엔 per-query 라우팅 필요(vtgate급). 분산 수치도 이 모델 기준 | -| **2** | **분산 성능 수치** | scatter/연결분산 기준 N-shard throughput (워커 수 × TPS, "분산처리능력") | +| ~~·~~ ✅ | ~~per-query 라우팅 (연결 종단 + 백엔드 풀)~~ | **완료(2026-06-28)** — `persession.go` 세션 루프가 *매* simple Query를 키의 샤드로 독립 라우팅(vtgate 모델) + 샤드별 백엔드 lazy 풀. 라이브 검증: 한 연결에서 alice→shard-0/bob→shard-1/carol→shard-0. scatter·단일샤드 tx pin 포함. (남음: cross-shard 2PC, per-query extended) | +| **1** | **분산 성능 수치** | per-query/scatter 기준 N-shard throughput (워커 수 × TPS, "분산처리능력") | | **4** | **B: resharding 실데이터 이동** | ShardSplitJob의 CopyTable DSN 결선 + CDC + write-block cutover (현재 골격) | | **5** | reference table·read-replica **프록시 결선** | 부품은 완성, query-mode에 연결만 | | **6** | 보류 #5/#7/#9 | per-shard primary Service·watch·failover lease P2-T3 — 라이브 failover 필요 | diff --git a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md index 37e0f2e..ae98ff3 100644 --- a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md +++ b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md @@ -22,7 +22,7 @@ | | 반쪽 A — `cmd/pg-router` (실 프록시) | 반쪽 B — `internal/router` in-process 라이브러리 | |---|---|---| | 정체 | raw TCP 바이트 프록시 | 타입 잡힌 Go 라이브러리(scatter/executor/store/placement) | -| 라우팅 | **연결 단위**(startup의 db/user 이름) | 쿼리 단위 fan-out + merge | +| 라우팅 | connection-mode=연결 단위 · **query-mode=쿼리 단위**(per-query, 2026-06-28) | 쿼리 단위 fan-out + merge | | 토폴로지 | **2-shard 하드코딩**(`shardSpec()`) | (해당 없음) | | 프로덕션 소비처 | 없음(배포 매니페스트 0) | **없음**(PoC 커맨드 + 테스트만) | | 둘의 연결 | `vindex.ResolveShard`만 공유. scatter/executor/store는 **pg-router가 안 씀** | — | @@ -127,9 +127,9 @@ parameterized/`t.col`/주석·문자열 내부 오인 방지까지 검증. **기 > 종단이 되어야 완성. 아래 항목은 그 종단/운영-코어/라이브검증 필요분. **라우팅 핵심 (능력 사다리)** -- [~] **(E) 프로토콜 종단 — 쿼리 단위 라우팅**: **query-mode 라이브 PG end-to-end 검증 완료 (2026-06-27)** 🎉. 2 trust postgres(shard-0/1) + pg-router `PGROUTER_MODE=query` → psql `SELECT located_on FROM probe WHERE id='alice'` 이 키 추출→murmur3 vindex→샤드 라우팅→실 쿼리 실행→결과 반환까지 동작. **alice→shard-0 / bob→shard-1 / carol→shard-0** 결정적·올바른 분산 확인(bob이 다른 샤드 = 해시 라우팅 실증). 라이브 검증에서 프로토콜 버그(백엔드 핸드셰이크 미소비) 발견·수정. **종단 작업 진척**: ① **scram-sha-256 / cleartext 백엔드 인증 대행 ✅완료**(RFC 7677 단위검증 + 라이브 scram PG 검증, `crypto/pbkdf2` stdlib, `PGROUTER_BACKEND_PASSWORD`) — **query-mode가 trust 아닌 실 프로덕션 PG와 동작** ② **extended protocol ✅완료**: inline-literal + parameterized(`$N`) 라우팅. **describe-round 대행 구현**(`describeround.go`) — lib/pq·JDBC 의 `Parse→Describe→Sync→Bind` 를, 임의 샤드로 describe round 대행 후 Bind 파라미터로 실 샤드 라우팅(다르면 re-Parse + 중복 ParseComplete 필터). **라이브 검증: lib/pq `WHERE id=$1` 정상 라우팅**(alice→shard-0/bob→shard-1) ⇒ **실 DB 드라이버와 동작** ③ **멀티샤드 scatter forwarding ✅완료**(`scattermode.go`): 키 없는 simple Query를 모든 샤드에 *병렬* fan-out→결과 병합(UNION ALL), 라이브 검증(SELECT FROM probe→양샤드 6행). 집계 재merge·전역 ORDER BY·LIMIT pushdown은 후속 ④ 백엔드 연결 풀(남음) ⑤ ~~per-query 로그~~ ✅완료. +- [~] **(E) 프로토콜 종단 — 쿼리 단위 라우팅**: **query-mode 라이브 PG end-to-end 검증 완료 (2026-06-27)** 🎉. 2 trust postgres(shard-0/1) + pg-router `PGROUTER_MODE=query` → psql `SELECT located_on FROM probe WHERE id='alice'` 이 키 추출→murmur3 vindex→샤드 라우팅→실 쿼리 실행→결과 반환까지 동작. **alice→shard-0 / bob→shard-1 / carol→shard-0** 결정적·올바른 분산 확인(bob이 다른 샤드 = 해시 라우팅 실증). 라이브 검증에서 프로토콜 버그(백엔드 핸드셰이크 미소비) 발견·수정. **종단 작업 진척**: ① **scram-sha-256 / cleartext 백엔드 인증 대행 ✅완료**(RFC 7677 단위검증 + 라이브 scram PG 검증, `crypto/pbkdf2` stdlib, `PGROUTER_BACKEND_PASSWORD`) — **query-mode가 trust 아닌 실 프로덕션 PG와 동작** ② **extended protocol ✅완료**: inline-literal + parameterized(`$N`) 라우팅. **describe-round 대행 구현**(`describeround.go`) — lib/pq·JDBC 의 `Parse→Describe→Sync→Bind` 를, 임의 샤드로 describe round 대행 후 Bind 파라미터로 실 샤드 라우팅(다르면 re-Parse + 중복 ParseComplete 필터). **라이브 검증: lib/pq `WHERE id=$1` 정상 라우팅**(alice→shard-0/bob→shard-1) ⇒ **실 DB 드라이버와 동작** ③ **멀티샤드 scatter forwarding ✅완료**(`scattermode.go`): 키 없는 simple Query를 모든 샤드에 *병렬* fan-out→결과 병합(UNION ALL), 라이브 검증(SELECT FROM probe→양샤드 6행). 집계 재merge·전역 ORDER BY·LIMIT pushdown은 후속 ④ **per-query 라우팅 + 백엔드 연결 풀 ✅완료 (2026-06-28)**: 연결 고정 한계 해소(아래) ⑤ ~~per-query 로그~~ ✅완료. > -> **⚠️ 알려진 한계 — 연결 고정(per-connection pinning)**: query-mode는 *첫 쿼리*로 연결의 샤드를 정하고 이후 메시지는 blind proxy한다. 즉 한 연결의 후속 쿼리(다른 키)도 첫 샤드로 간다. **테넌트-당-연결**(멀티테넌트 v1, 한 연결=한 테넌트=한 샤드)엔 적합하나, 연결 풀로 키가 섞이는 워크로드엔 부적합 — 진짜 *per-query 라우팅*은 vtgate처럼 연결 종단 + 백엔드 연결 풀 유지가 필요(큰 후속). 분산 성능 수치도 이 모델(연결 단위 분산) 기준으로 측정해야 함. +> **✅ 해소됨 — per-query 라우팅 (구 한계: 연결 고정)**: query-mode가 *첫 쿼리*로 연결을 한 샤드에 고정하던 한계를 해소했다(2026-06-28). `persession.go`의 `runPerQuerySession` 세션 루프가 한 연결의 *매* simple Query를 그 키의 샤드로 독립 라우팅하고(vtgate 모델), 샤드별 백엔드 연결을 세션 내에서 lazy 풀링·재사용한다. **라이브 검증(2 scram 샤드, 한 연결)**: `id='alice'`→shard-0, `id='bob'`→shard-1, `id='carol'`→shard-0 가 *같은 연결*에서 각각 올바른 샤드로 라우팅(로그상 4개 독립 `routed (Q)` 결정). 키 없는 쿼리는 scatter fan-out, 단일샤드 명시적 트랜잭션은 `BEGIN` 응답 합성 후 첫 키 쿼리로 한 샤드에 pin(COMMIT/ROLLBACK까지 그 백엔드 재사용) — 모두 검증. *남은 범위*: cross-shard 2PC, per-query **extended** protocol(샤드별 prepared statement 관리 필요 — 현행 extended는 pin-on-first describe-round 유지). - [ ] **읽기 → replica 라우팅**: `status.shards[].replicas[]`(Ready)로 읽기 쿼리를 분산 → primary 부하 경감 + 읽기 확장. 사다리 2단계. (status backend 계층 재사용.) - [ ] **scatter-gather 실연결**: `scatter.go` 골격 → 실 wire-protocol fan-out + merge + 집계/정렬 pushdown. 사다리 2~3단계. - [ ] **Reference table**: 전 샤드 복제로 분산 조인 우회. 사다리 4단계. 고가치·저난도. From 9f3f1a51cffe4d3947cc7baedbf8cff3e0c21cb7 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 01:02:59 +0900 Subject: [PATCH 056/109] =?UTF-8?q?feat(pg-router):=20per-query=20extended?= =?UTF-8?q?=20protocol=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20(prepared=20stmt=20?= =?UTF-8?q?=EC=83=A4=EB=93=9C=EB=B3=84=20lazy)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extended protocol(Parse/Bind/Describe/Execute/Sync)도 simple Query 처럼 per-query 라우팅한다. 세션 루프가 extended 메시지를 Sync 까지 버퍼링해 배치 단위로 처리: - Bind 있는 배치: Bind 파라미터(또는 인라인 리터럴)로 샤드 결정 → 그 샤드 백엔드에 stmt 가 없으면 저장된 Parse 를 주입(prepare-on-first-use)하고 Bind/Execute 전송, 주입한 Parse 의 ParseComplete 는 응답에서 필터(클라이언트 미요청분). - Parse 만/Close 만 배치: 샤드 미정이라 ParseComplete/CloseComplete 를 합성해 즉시 ack 하고 실 Parse 는 Bind 시점으로 미룬다(prepared statement 캐싱 드라이버 대응). - describe-first(`Parse→Describe→Sync`, lib/pq·JDBC): 스키마가 샤드 공통이므로 임의 (default) 샤드로 describe 대행. - 트랜잭션 중에는 pin 된 백엔드로(backendForRouted). 이로써 구 pin-on-first(첫 extended 쿼리로 연결 고정) 경로를 대체 — describeround.go 및 querymode.go 의 handleParse/proxyToShard/proxyBidi(연결 종단 blind proxy) 제거. 라이브 검증(2 scram 샤드): lib/pq 한 연결 + prepared stmt `WHERE id=$1` 를 5회 다른 키로 실행 → alice/carol→shard-0, bob→shard-1 각각 올바른 샤드(로그 5개 독립 routed (B), bob 첫 실행 시 shard-1 prepare-on-first-use). simple per-query·scatter· tx 회귀 없음. 미지원(후속): extended scatter(키 없는 파이프라인 fan-out), cross-shard 배치, Flush(H) 파이프라이닝, named stmt 동일 이름 재Parse(Close 선행 필요). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- cmd/pg-router/describeround.go | 167 ----------------- cmd/pg-router/extsession.go | 322 +++++++++++++++++++++++++++++++++ cmd/pg-router/persession.go | 28 ++- cmd/pg-router/querymode.go | 107 +---------- 4 files changed, 353 insertions(+), 271 deletions(-) delete mode 100644 cmd/pg-router/describeround.go create mode 100644 cmd/pg-router/extsession.go diff --git a/cmd/pg-router/describeround.go b/cmd/pg-router/describeround.go deleted file mode 100644 index 7ec8d28..0000000 --- a/cmd/pg-router/describeround.go +++ /dev/null @@ -1,167 +0,0 @@ -/* -Copyright 2026 keiailab. - -Licensed under the MIT License. See the LICENSE file for details. -*/ - -// describeround.go 는 *extended protocol describe-first 드라이버*(lib/pq·JDBC 등)의 -// 파라미터화 쿼리 라우팅을 처리한다. -// -// 문제: lib/pq 는 `Parse → Describe → Sync` 를 먼저 보내 파라미터/컬럼 타입을 조회한 뒤, -// 그 응답을 받고 나서야 `Bind → Execute → Sync` 를 보낸다. 라우팅 키는 Bind 의 파라미터 -// 값에 있는데, 클라이언트는 describe 응답을 *기다리며 블록* 하므로 라우터가 Bind 까지 -// 기다릴 수 없다(닭-달걀). -// -// 해법(vtgate 와 동일 계열): 스키마(파라미터/컬럼 타입)는 모든 샤드 공통이므로, 라우터가 -// *임의(default) 샤드* 로 describe round 를 대행해 메타데이터를 클라이언트에 돌려준다. -// 이후 Bind 가 오면 파라미터 값으로 *실제 샤드* 를 정한다. 실제 샤드가 default 와 같으면 -// 그 연결을 재사용하고, 다르면 실 샤드에 re-Parse 한 뒤 그 중복 ParseComplete 만 걸러낸다. -package main - -import ( - "io" - "net" - - "github.com/keiailab/postgres-operator/internal/router" -) - -// PG backend 메시지 타입 (필터용). -const msgParseComplete = '1' - -// describeRoundDelegate 는 describe round(Parse..Sync, describeRound)를 default 샤드로 -// 대행한 뒤, 클라이언트의 Bind 파라미터로 실 샤드를 정해 실행한다. -func describeRoundDelegate(client net.Conn, qr queryRouter, describeRound []pgMessage, parse pgMessage, pidx int, sql string, raw []byte, dialer *backendDialer, password string) { - // 1) default 샤드로 describe round 대행. - _, defBackend, err := qr.anyShard() - if err != nil { - writePgError(client, "08006", "no shard for describe round: "+err.Error()) - return - } - dconn, err := dialer.Dial(defBackend) - if err != nil { - writePgError(client, "08006", "describe-round dial: "+err.Error()) - return - } - if _, err := dconn.Write(raw); err != nil { - _ = dconn.Close() - return - } - if err := authenticateAndDrain(dconn, password); err != nil { - _ = dconn.Close() - writePgError(client, "08006", "describe-round backend startup: "+err.Error()) - return - } - for _, m := range describeRound { // Parse, [Describe], Sync 전달. - if err := writeMessage(dconn, m.Type, m.Payload); err != nil { - _ = dconn.Close() - return - } - } - // describe 응답(ParseComplete·ParameterDescription·RowDescription·ReadyForQuery)을 - // 클라이언트로 전달. - if err := forwardUntilReady(dconn, client); err != nil { - _ = dconn.Close() - return - } - - // 2) 클라이언트의 Bind → 파라미터 값으로 실 샤드 결정. - bind, err := readMessage(client) - if err != nil { - _ = dconn.Close() - return - } - if bind.Type != 'B' { - _ = dconn.Close() - writePgError(client, "08P01", "expected Bind after describe round") - return - } - params, ok := bindParams(bind) - if !ok || pidx-1 >= len(params) || params[pidx-1] == nil { - _ = dconn.Close() - writePgError(client, "08006", "could not extract routing parameter from Bind") - return - } - d, err := qr.routeKey(string(params[pidx-1]), router.IsReadOnlyQuery(sql)) - if err != nil || d.Scatter { - _ = dconn.Close() - writePgError(client, "08006", "routing failed for parameter") - return - } - logRoute('B', d) - - // 3a) 실 샤드 == default: describe 한 연결을 재사용(이미 Parse 됨). - if d.Backend == defBackend { - defer func() { _ = dconn.Close() }() - if err := writeMessage(dconn, bind.Type, bind.Payload); err != nil { - return - } - proxyBidi(client, dconn) // 클라이언트의 Execute/Sync ↔ 결과. - return - } - - // 3b) 실 샤드 != default: 새 연결에 re-Parse + Bind, 중복 ParseComplete 만 필터. - _ = dconn.Close() - rconn, err := dialer.Dial(d.Backend) - if err != nil { - writePgError(client, "08006", "execute dial: "+err.Error()) - return - } - defer func() { _ = rconn.Close() }() - if _, err := rconn.Write(raw); err != nil { - return - } - if err := authenticateAndDrain(rconn, password); err != nil { - writePgError(client, "08006", "execute backend startup: "+err.Error()) - return - } - if err := writeMessage(rconn, parse.Type, parse.Payload); err != nil { // re-Parse. - return - } - if err := writeMessage(rconn, bind.Type, bind.Payload); err != nil { // Bind. - return - } - proxyExecuteRound(client, rconn) // Execute/Sync ↔ 결과(앞선 ParseComplete 1개 필터). -} - -// forwardUntilReady 는 src 의 메시지를 dst 로 전달하며 ReadyForQuery('Z')/ErrorResponse -// ('E')까지 진행한다. -func forwardUntilReady(src, dst net.Conn) error { - for { - m, err := readMessage(src) - if err != nil { - return err - } - if err := writeMessage(dst, m.Type, m.Payload); err != nil { - return err - } - if m.Type == 'Z' || m.Type == 'E' { - return nil - } - } -} - -// proxyExecuteRound 는 client↔server 를 양방향 proxy 하되, server→client 방향에서 *맨 앞 -// ParseComplete 1개* 를 걸러낸다(re-Parse 로 생긴 중복 — 클라이언트는 describe round 에서 -// 이미 ParseComplete 를 받았다). -func proxyExecuteRound(client, server net.Conn) { - done := make(chan struct{}, 2) - go func() { _, _ = io.Copy(server, client); done <- struct{}{} }() // Execute/Sync 등. - go func() { - swallowed := false - for { - m, err := readMessage(server) - if err != nil { - break - } - if !swallowed && m.Type == msgParseComplete { - swallowed = true - continue - } - if err := writeMessage(client, m.Type, m.Payload); err != nil { - break - } - } - done <- struct{}{} - }() - <-done -} diff --git a/cmd/pg-router/extsession.go b/cmd/pg-router/extsession.go new file mode 100644 index 0000000..df825c6 --- /dev/null +++ b/cmd/pg-router/extsession.go @@ -0,0 +1,322 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +// extsession.go 는 *per-query extended protocol* 라우팅을 구현한다 — Parse/Bind/Describe/ +// Execute/Sync 파이프라인을 세션이 끊기지 않은 채 *매 쿼리* 그 키의 샤드로 라우팅한다. +// +// 핵심 아이디어(vtgate 계열): +// - 클라이언트의 extended 메시지를 Sync 까지 *버퍼링* 해 한 배치로 모은다. +// - 라우팅 키는 Bind 의 파라미터(또는 인라인 리터럴)에 있으므로, Parse 만 온 단계에서는 +// *ParseComplete 를 합성* 해 ack 하고 실 Parse 는 미룬다(샤드 미정). +// - Bind 가 오면 키로 샤드를 정하고, 그 샤드 백엔드에 stmt 가 아직 없으면 저장해 둔 Parse 를 +// *주입(prepare-on-first-use)* 한 뒤 Bind/Execute 를 보낸다. 주입한 Parse 의 ParseComplete 는 +// 클라이언트가 요청하지 않았으므로 응답에서 *걸러낸다*. +// - describe-first 드라이버(lib/pq·JDBC)의 `Parse→Describe→Sync`(Bind 전 메타데이터 조회)는 +// 스키마가 샤드 공통이므로 임의(default) 샤드로 describe 를 대행한다. +// +// 지원: pgbench -M extended/prepared, lib/pq, pgx, JDBC 의 파라미터화·prepared 재사용. +// 미지원(후속): extended scatter(키 없는 파이프라인 fan-out), cross-shard 배치, Flush(H) +// 기반 파이프라이닝, named stmt 의 동일 이름 재Parse(Close 선행 필요). +package main + +import ( + "bytes" + "net" + + "github.com/keiailab/postgres-operator/internal/router" +) + +// PG ParseComplete('1')·CloseComplete('3') 백엔드 메시지 타입. +const ( + msgParseComplete = '1' + msgCloseComplete = '3' +) + +// pstmt 는 세션에 등록된 prepared statement 메타다. +type pstmt struct { + name string + sql string + paramIdx int // `col=$N` 의 N (1-based). 0 이면 인라인 리터럴/키 없음. + parseMsg pgMessage // 샤드 prepare-on-first-use 용 원본 Parse 메시지. +} + +// handleExtendedBatch 는 Sync 로 끝나는 extended 메시지 배치(s.extBuf)를 처리한다. +func (s *session) handleExtendedBatch() bool { + batch := s.extBuf + s.extBuf = nil + if len(batch) == 0 { + return true + } + // 1) Parse 등록(라우팅 키 위치 계산). + for _, m := range batch { + if m.Type == 'P' { + s.registerParse(m) + } + } + // 2) 분류: Bind 있음 → 실행, 없고 Describe 있음 → describe 대행, 둘 다 없음 → ack 합성. + var firstBind *pgMessage + hasDescribe := false + for i := range batch { + switch batch[i].Type { + case 'B': + if firstBind == nil { + firstBind = &batch[i] + } + case 'D': + hasDescribe = true + } + } + switch { + case firstBind != nil: + return s.runExtendedExec(batch, *firstBind) + case hasDescribe: + return s.runDescribeOnly(batch) + default: + return s.synthExtendedAcks(batch) + } +} + +// registerParse 는 Parse 를 세션 stmt 맵에 등록하고, 같은 이름의 백엔드 prepared 상태를 +// 무효화한다(재Parse 대응). +func (s *session) registerParse(m pgMessage) { + name := parseStmtName(m) + sql, ok := parseSQL(m) + if !ok { + return + } + st := &pstmt{name: name, sql: sql, parseMsg: m} + if idx, ok := router.ExtractParamRef(sql, s.qr.shardColumn()); ok { + st.paramIdx = idx + } + s.stmts[name] = st + for _, set := range s.backendStmts { // 재Parse: 백엔드별 캐시 무효화. + delete(set, name) + } +} + +// runExtendedExec 는 Bind 가 있는 배치를 키의 샤드로 라우팅·실행한다. +func (s *session) runExtendedExec(batch []pgMessage, bind pgMessage) bool { + stmtName := bindStmtName(bind) + st := s.stmts[stmtName] + if st == nil { + writePgError(s.client, "26000", "prepared statement not found: "+stmtName) + return s.sendReadyIdle() + } + + // 라우팅: 파라미터화면 Bind 값으로, 인라인 리터럴이면 SQL 로. + var ( + d router.RouteDecision + err error + ) + if st.paramIdx > 0 { + params, ok := bindParams(bind) + if !ok || st.paramIdx-1 >= len(params) || params[st.paramIdx-1] == nil { + writePgError(s.client, "08006", "could not extract routing parameter from Bind") + return s.sendReadyIdle() + } + d, err = s.qr.routeKey(string(params[st.paramIdx-1]), router.IsReadOnlyQuery(st.sql)) + } else { + d, err = s.qr.routeSQL(st.sql) + } + if err != nil || d.Scatter { + writePgError(s.client, "08006", "extended routing failed (no routing key; extended scatter unsupported)") + return s.sendReadyIdle() + } + + conn, err := s.backendForRouted(d) + if err != nil { + writePgError(s.client, "08006", "backend: "+err.Error()) + return false + } + set := s.preparedSet(conn) + + // Parse(클라이언트가 보낸 것) 와 그 외(Bind/Describe/Execute/Close)를 분리. + var parses, others []pgMessage + for _, m := range batch { + switch m.Type { + case 'P': + parses = append(parses, m) + set[parseStmtName(m)] = true + case 'S': // 우리가 끝에 직접 Sync. + default: + others = append(others, m) + } + } + // Bind 가 참조하는 stmt 가 이 백엔드에 없으면 Parse 주입(prepare-on-first-use). + injected := 0 + if !set[stmtName] { + parses = append([]pgMessage{st.parseMsg}, parses...) + set[stmtName] = true + injected = 1 + } + + // Parse 들을 먼저, 그 다음 Bind/Describe/Execute, 마지막에 Sync. + for _, m := range parses { + if err := writeMessage(conn, m.Type, m.Payload); err != nil { + return false + } + } + for _, m := range others { + if err := writeMessage(conn, m.Type, m.Payload); err != nil { + return false + } + } + if err := writeMessage(conn, 'S', nil); err != nil { + return false + } + logRoute('B', d) + return s.relayExtended(conn, injected) +} + +// runDescribeOnly 는 Bind 없는 `Parse?→Describe→Sync`(describe-first) 배치를 임의(default) +// 샤드로 대행한다 — 스키마는 샤드 공통. 트랜잭션 중이면 pin 된 백엔드를 쓴다. +func (s *session) runDescribeOnly(batch []pgMessage) bool { + conn, err := s.describeBackend() + if err != nil { + writePgError(s.client, "08006", "describe-round: "+err.Error()) + return s.sendReadyIdle() + } + set := s.preparedSet(conn) + for _, m := range batch { // Parse/Describe/Sync 전달. + if m.Type == 'P' { + set[parseStmtName(m)] = true + } + if err := writeMessage(conn, m.Type, m.Payload); err != nil { + return false + } + } + return s.relayExtended(conn, 0) // 클라이언트가 보낸 Parse 의 ParseComplete 는 모두 전달. +} + +// synthExtendedAcks 는 Bind/Describe 없는 `Parse`/`Close` 전용 배치의 ack 를 합성한다 — +// 실 Parse 는 샤드 미정이라 미루고 ParseComplete/CloseComplete 만 즉시 돌려준다(prepared +// statement 캐싱 드라이버 대응: 미리 Parse 해두고 나중에 Bind→Execute). +func (s *session) synthExtendedAcks(batch []pgMessage) bool { + for _, m := range batch { + switch m.Type { + case 'P': + if err := writeMessage(s.client, msgParseComplete, nil); err != nil { + return false + } + case 'C': // Close(statement/portal). + s.handleClose(m) + if err := writeMessage(s.client, msgCloseComplete, nil); err != nil { + return false + } + } + } + return s.sendReadyIdle() +} + +// backendForRouted 는 라우팅 결정의 백엔드를 반환하되 트랜잭션 pin 을 존중한다. +func (s *session) backendForRouted(d router.RouteDecision) (net.Conn, error) { + if s.inTx && s.txBackend != nil { + return s.txBackend, nil + } + conn, err := s.backendFor(d.Backend) + if err != nil { + return nil, err + } + if s.inTx && s.txBackend == nil { // tx 시작 후 첫 키 쿼리: BEGIN 을 이 샤드로 보내 pin. + if err := writeMessage(conn, s.pendingBegin.Type, s.pendingBegin.Payload); err != nil { + return nil, err + } + if err := drainResponse(conn); err != nil { + return nil, err + } + s.txBackend = conn + } + return conn, nil +} + +// describeBackend 는 describe 대행용 백엔드를 반환한다(tx pin 우선, 아니면 임의 샤드). +func (s *session) describeBackend() (net.Conn, error) { + if s.inTx && s.txBackend != nil { + return s.txBackend, nil + } + _, backend, err := s.qr.anyShard() + if err != nil { + return nil, err + } + return s.backendFor(backend) +} + +// preparedSet 은 한 백엔드 연결의 prepared stmt 이름 집합을 lazy 반환한다. +func (s *session) preparedSet(conn net.Conn) map[string]bool { + set := s.backendStmts[conn] + if set == nil { + set = map[string]bool{} + s.backendStmts[conn] = set + } + return set +} + +// relayExtended 는 backend 응답을 ReadyForQuery 까지 클라이언트로 relay 하되, *주입한 Parse* +// 의 ParseComplete 를 앞에서부터 swallowParseComplete 개 걸러낸다(클라이언트 미요청분). +func (s *session) relayExtended(conn net.Conn, swallowParseComplete int) bool { + swallowed := 0 + for { + m, err := readMessage(conn) + if err != nil { + return false + } + if m.Type == msgParseComplete && swallowed < swallowParseComplete { + swallowed++ + continue + } + if err := writeMessage(s.client, m.Type, m.Payload); err != nil { + return false + } + if m.Type == 'Z' { // ReadyForQuery — 배치 완료. + return true + } + } +} + +// handleClose 는 Close(statement) 를 세션 stmt 맵에서 제거한다(백엔드의 lingering prepared 는 +// 연결 종료 시 정리되므로 무해 — 동일 이름 재사용은 드라이버가 회피). +func (s *session) handleClose(m pgMessage) { + if len(m.Payload) < 1 { + return + } + if m.Payload[0] != 'S' { // 'P'(portal)는 세션 추적 불필요. + return + } + name := string(bytes.TrimRight(m.Payload[1:], "\x00")) + delete(s.stmts, name) +} + +// sendReadyIdle 은 ReadyForQuery 를 보낸다(트랜잭션 중이면 'T', 아니면 'I'). +func (s *session) sendReadyIdle() bool { + status := byte('I') + if s.inTx { + status = 'T' + } + return writeMessage(s.client, 'Z', []byte{status}) == nil +} + +// parseStmtName 은 Parse('P') payload 의 첫 cstring(statement 이름)을 반환한다. +func parseStmtName(m pgMessage) string { + i := bytes.IndexByte(m.Payload, 0) + if i < 0 { + return "" + } + return string(m.Payload[:i]) +} + +// bindStmtName 은 Bind('B') payload 의 둘째 cstring(참조 statement 이름)을 반환한다. +func bindStmtName(m pgMessage) string { + i := bytes.IndexByte(m.Payload, 0) // portal 끝. + if i < 0 { + return "" + } + rest := m.Payload[i+1:] + j := bytes.IndexByte(rest, 0) + if j < 0 { + return "" + } + return string(rest[:j]) +} diff --git a/cmd/pg-router/persession.go b/cmd/pg-router/persession.go index 7a42884..4df863e 100644 --- a/cmd/pg-router/persession.go +++ b/cmd/pg-router/persession.go @@ -9,9 +9,9 @@ Licensed under the MIT License. See the LICENSE file for details. // 연결을 세션 내에서 lazy 풀링·재사용한다. // // 지원: autocommit 키 라우팅, 키 없는 쿼리 scatter, *단일샤드* 명시적 트랜잭션(BEGIN 응답을 -// 합성하고 첫 키 쿼리로 한 샤드에 pin — cross-shard 2PC 는 범위 밖). extended protocol(Parse) -// 은 현행 pin-on-first(describe-round)로 처리한다(per-query extended 는 후속 — 샤드별 -// prepared statement 관리 필요). +// 합성하고 첫 키 쿼리로 한 샤드에 pin — cross-shard 2PC 는 범위 밖). extended protocol +// (Parse/Bind/Describe/Execute/Sync)도 per-query 로 라우팅한다 — Sync 까지 버퍼링해 배치 +// 단위로 키의 샤드에 보내고 샤드별 prepared statement 를 lazy 관리한다(extsession.go). package main import ( @@ -32,11 +32,21 @@ type session struct { inTx bool // 명시적 트랜잭션 중. pendingBegin pgMessage // 아직 백엔드로 안 보낸 BEGIN (pin 시 전송). txBackend net.Conn // 트랜잭션이 pin 된 백엔드. + + // extended protocol(per-query) 상태. + extBuf []pgMessage // Sync 까지 버퍼링한 extended 메시지. + stmts map[string]*pstmt // statement 이름 → prepared 메타. + backendStmts map[net.Conn]map[string]bool // 백엔드별 Parse 된 stmt 이름 집합. } // runPerQuerySession 은 핸드셰이크 후 세션 루프를 돈다. func runPerQuerySession(client net.Conn, qr queryRouter, dialer *backendDialer, password string, raw []byte) { - s := &session{client: client, qr: qr, dialer: dialer, password: password, raw: raw, backends: map[string]net.Conn{}} + s := &session{ + client: client, qr: qr, dialer: dialer, password: password, raw: raw, + backends: map[string]net.Conn{}, + stmts: map[string]*pstmt{}, + backendStmts: map[net.Conn]map[string]bool{}, + } defer s.closeBackends() for { m, err := readMessage(client) @@ -50,9 +60,13 @@ func runPerQuerySession(client net.Conn, qr queryRouter, dialer *backendDialer, if !s.handleSimpleQuery(m) { return } - case 'P': // extended → pin-on-first(describe-round). 이후 연결 종료. - handleParse(client, qr, m, raw, dialer, password) - return + case 'P', 'B', 'D', 'E', 'C', 'H': // extended → Sync 까지 버퍼링. + s.extBuf = append(s.extBuf, m) + case 'S': // Sync → 버퍼링된 extended 배치를 per-query 라우팅. + s.extBuf = append(s.extBuf, m) + if !s.handleExtendedBatch() { + return + } default: writePgError(client, "0A000", fmt.Sprintf("message type %q not supported in per-query mode", m.Type)) return diff --git a/cmd/pg-router/querymode.go b/cmd/pg-router/querymode.go index a1c4584..b276e6a 100644 --- a/cmd/pg-router/querymode.go +++ b/cmd/pg-router/querymode.go @@ -4,25 +4,22 @@ Copyright 2026 keiailab. Licensed under the MIT License. See the LICENSE file for details. */ -// querymode.go 는 *쿼리 인지 라우팅*(E)을 결선한다 — 연결 단위(startup param) 라우팅과 -// 달리, 클라이언트의 *첫 쿼리*에서 샤딩 키를 뽑아 샤드를 정한다. +// querymode.go 는 *쿼리 인지 라우팅*(E)의 라우팅 엔진 결선부다 — 연결 단위(startup param) +// 라우팅과 달리, 클라이언트의 *매* 쿼리에서 샤딩 키를 뽑아 샤드를 정한다(per-query). // -// 지원 경로: -// - simple Query('Q'): 인라인 리터럴(`WHERE id='x'`) 추출 → 라우팅. -// - extended Parse('P') 인라인 리터럴: 동일. -// - extended Parse + Bind: parameterized(`WHERE id=$1`)는 값이 Bind 에 있으므로, -// Parse 에서 `col=$N` 위치를 찾고 Bind 까지 버퍼링해 N번째 파라미터 값으로 라우팅. -// → pgx/psycopg 등 실 드라이버 동작. +// 핸드셰이크 후 흐름은 persession.go(simple Query)·extsession.go(extended protocol)가 +// 세션 단위로 처리하고, 본 파일은 그 둘이 쓰는 queryRouter(토폴로지+extractor+resolver → +// RouteDecision) 와 라우팅 로그를 제공한다. +// - simple Query('Q'): 인라인 리터럴(`WHERE id='x'`) → routeSQL. +// - extended: parameterized(`WHERE id=$1`)는 Bind 파라미터로 routeKey, 인라인 리터럴은 +// routeSQL. → pgx/psycopg/lib-pq/JDBC 등 실 드라이버 + prepared statement 동작. +// - 키 없음 → scatter(simple) / 에러(extended). // -// *제약(PoC, trust 백엔드 한정)*: 라우터가 클라이언트 비밀번호를 검증하지 않고(trust), -// 백엔드에 클라이언트 startup 을 그대로 전달 — trust 백엔드 전제. 일반(scram) 백엔드 -// 인증 대행 + 멀티샤드 scatter 는 별 트랙(라이브 PG 검증 필요). +// 백엔드 인증(trust/cleartext/scram)은 라우터가 대행한다(scram.go). package main import ( "context" - "fmt" - "io" "log" "net" @@ -141,90 +138,6 @@ func handleQueryMode(client net.Conn, qr queryRouter, dialer *backendDialer, ser runPerQuerySession(client, qr, dialer, backendPassword, raw) } -// handleParse 는 Parse('P') 를 처리한다: 인라인 리터럴이면 즉시, parameterized 면 Bind -// 까지 버퍼링해 파라미터 값으로 라우팅한다. -func handleParse(client net.Conn, qr queryRouter, parse pgMessage, raw []byte, dialer *backendDialer, backendPassword string) { - sql, ok := parseSQL(parse) - if !ok { - writePgError(client, "08P01", "could not parse query") - return - } - // 1) 인라인 리터럴이면 SQL 만으로 라우팅. - if d, err := qr.routeSQL(sql); err == nil && !d.Scatter { - logRoute('P', d) - proxyToShard(client, raw, []pgMessage{parse}, d, dialer, backendPassword) - return - } - // 2) parameterized(`col = $N`): Bind 까지 버퍼링해 N번째 파라미터로 라우팅. - pidx, ok := router.ExtractParamRef(sql, qr.shardColumn()) - if !ok { - writePgError(client, "08006", "could not determine shard (no routing key in query)") - return - } - buffered := []pgMessage{parse} - for { - next, err := readMessage(client) - if err != nil { - return - } - buffered = append(buffered, next) - switch next.Type { - case 'B': // Bind — 파라미터 값으로 라우팅. - params, ok := bindParams(next) - if !ok || pidx-1 >= len(params) || params[pidx-1] == nil { - writePgError(client, "08006", "could not extract routing parameter from Bind") - return - } - d, err := qr.routeKey(string(params[pidx-1]), router.IsReadOnlyQuery(sql)) - if err != nil || d.Scatter { - writePgError(client, "08006", "routing failed for parameter") - return - } - logRoute('B', d) - proxyToShard(client, raw, buffered, d, dialer, backendPassword) - return - case 'S': // Sync 가 Bind 보다 먼저(lib/pq 등 describe-first) — describe-round 대행. - describeRoundDelegate(client, qr, buffered, parse, pidx, sql, raw, dialer, backendPassword) - return - } - } -} - func logRoute(typ byte, d router.RouteDecision) { log.Printf("pg-router: routed (%c) shard=%s backend=%s read=%v", typ, d.Shard, d.Backend, d.Read) } - -// proxyToShard 는 결정된 샤드 backend 에 연결해 startup + 버퍼링된 메시지(들)를 전달하고 -// 양방향 proxy 한다. 백엔드 인증(trust/cleartext/scram)은 라우터가 대행한다. -func proxyToShard(client net.Conn, raw []byte, msgs []pgMessage, d router.RouteDecision, dialer *backendDialer, backendPassword string) { - server, err := dialer.Dial(d.Backend) - if err != nil { - writePgError(client, "08006", fmt.Sprintf("cannot reach shard %s (%s): %v", d.Shard, d.Backend, err)) - return - } - defer func() { _ = server.Close() }() - - if _, err := server.Write(raw); err != nil { // backend startup - return - } - // 백엔드 인증 대행 + 핸드셰이크를 ReadyForQuery 까지 소비 (클라이언트는 우리 trust - // 핸드셰이크를 이미 받음 — 중복 방지). - if err := authenticateAndDrain(server, backendPassword); err != nil { - writePgError(client, "08006", "backend startup: "+err.Error()) - return - } - for _, m := range msgs { // 버퍼링된 클라이언트 메시지(Parse/Bind 등) 재생. - if err := writeMessage(server, m.Type, m.Payload); err != nil { - return - } - } - proxyBidi(client, server) -} - -// proxyBidi 는 두 연결을 양방향으로 복사하고 한쪽이 끝나면 반환한다. -func proxyBidi(a, b net.Conn) { - done := make(chan struct{}, 2) - go func() { _, _ = io.Copy(b, a); done <- struct{}{} }() - go func() { _, _ = io.Copy(a, b); done <- struct{}{} }() - <-done -} From 69a57ba2c45cef4c7884d6bb19ef615190bd4123 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 01:11:40 +0900 Subject: [PATCH 057/109] =?UTF-8?q?feat(router-bench):=20=EB=B6=84?= =?UTF-8?q?=EC=82=B0=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EB=9F=89=20=EC=8B=A4=EC=B8=A1=20=EB=8F=84=EA=B5=AC=20+=20basel?= =?UTF-8?q?ine=202=EC=B0=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd/router-bench: internal/router.ResolveShard 로 키를 샤드에 배치(라우터와 동일 vindex)한 뒤 point 쿼리를 워커 수를 늘려가며 던져 TPS·라우터 오버헤드를 측정. 시나리오 direct-shard0 / router-1shard / router-2shard. baseline.md §3.0b 실측(2026-06-28, 2 scram 샤드 단일 호스트): - 라우터 경유 point read 처리량 워커 수 × TPS: 1,761(w=1) → 9,437(w=32). - 라우터 오버헤드 direct 대비 ~2.2×(w=1)~4×(w=32) — 키당 Parse 비용. - 2샤드 ≈ 1샤드: point read 에선 라우터가 병목(~9K 천장), 단일 샤드(38K)는 여유 → 수평 스케일은 멀티호스트(라우터·샤드 코어 분리) 필요. 단일 호스트로는 분산 이득 미관측. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- cmd/router-bench/main.go | 240 +++++++++++++++++++++++++++++++++++++++ docs/perf/baseline.md | 54 +++++++++ 2 files changed, 294 insertions(+) create mode 100644 cmd/router-bench/main.go diff --git a/cmd/router-bench/main.go b/cmd/router-bench/main.go new file mode 100644 index 0000000..6052bba --- /dev/null +++ b/cmd/router-bench/main.go @@ -0,0 +1,240 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +// Command router-bench 는 pg-router 의 *분산 처리 능력*(워커 수 × TPS)과 라우팅 오버헤드를 +// 실측한다. internal/router.ResolveShard(라우터와 동일한 vindex)로 키를 샤드에 배치한 뒤, +// 점(point) 읽기/쓰기를 워커 수를 늘려가며 고정 시간 동안 던져 처리량을 잰다. +// +// 시나리오: +// - direct-shard0 : 라우터 없이 shard-0 에 직접(기준선) — 라우터 오버헤드 분리용. +// - router-1shard : 라우터 경유, shard-0 키만(단일샤드 처리량). +// - router-2shard : 라우터 경유, 전 키스페이스(2샤드 분산 처리량). +// +// 한 호스트에서 샤드들과 라우터가 CPU 를 공유하므로 선형 스케일은 기대하지 않는다 — +// 수치는 그 환경 기준의 상대 비교로 해석할 것(docs/perf/baseline.md). +// +// 환경변수: BENCH_ROUTER, BENCH_SHARD0, BENCH_SHARD1 (lib/pq DSN), BENCH_KEYS(10000), +// BENCH_DURATION(5s), BENCH_WORKERS(1,2,4,8,16,32), BENCH_MODE(select|update). +package main + +import ( + "database/sql" + "fmt" + "log" + "math/rand" + "os" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + _ "github.com/lib/pq" + + "github.com/keiailab/postgres-operator/api/v1alpha1" + "github.com/keiailab/postgres-operator/internal/router" +) + +// benchSpec 는 pg-router 의 기본 static 토폴로지(cmd/pg-router/main.go shardSpec)와 +// *동일* 해야 한다 — 데이터 배치를 라우팅과 일치시키기 위함. +func benchSpec() v1alpha1.ShardRangeSpec { + return v1alpha1.ShardRangeSpec{ + Vindex: v1alpha1.VindexSpec{Type: v1alpha1.VindexTypeHash, Column: "id", Function: "murmur3"}, + Ranges: []v1alpha1.ShardRangeEntry{ + {Lo: "0x00000000", Hi: "0x7fffffff", Shard: "shard-0"}, + {Lo: "0x80000000", Hi: "0xffffffff", Shard: "shard-1"}, + }, + } +} + +func main() { + var ( + routerDSN = env("BENCH_ROUTER", "host=pgrouter port=5432 user=postgres dbname=postgres sslmode=disable") + shard0DSN = env("BENCH_SHARD0", "host=pg-shard-0 port=5432 user=postgres password=secret dbname=postgres sslmode=disable") + shard1DSN = env("BENCH_SHARD1", "host=pg-shard-1 port=5432 user=postgres password=secret dbname=postgres sslmode=disable") + keys = envInt("BENCH_KEYS", 10000) + dur = envDur("BENCH_DURATION", 5*time.Second) + workers = envInts("BENCH_WORKERS", []int{1, 2, 4, 8, 16, 32}) + mode = env("BENCH_MODE", "select") + ) + + spec := benchSpec() + keysOnShard0 := seed(shard0DSN, shard1DSN, spec, keys) + log.Printf("seeded %d keys: shard-0=%d shard-1=%d (mode=%s dur=%s)", keys, len(keysOnShard0), keys-len(keysOnShard0), mode, dur) + + allKeys := make([]int, keys) + for i := range allKeys { + allKeys[i] = i + 1 + } + + scenarios := []struct { + name string + dsn string + keys []int + }{ + {"direct-shard0", shard0DSN, keysOnShard0}, + {"router-1shard", routerDSN, keysOnShard0}, + {"router-2shard", routerDSN, allKeys}, + } + + fmt.Printf("\n%-16s %8s %12s %12s %10s\n", "scenario", "workers", "ops", "TPS", "avg_ms") + fmt.Println(strings.Repeat("-", 62)) + for _, sc := range scenarios { + for _, w := range workers { + ops, avgMs := run(sc.dsn, sc.keys, w, dur, mode) + tps := float64(ops) / dur.Seconds() + fmt.Printf("%-16s %8d %12d %12.0f %10.3f\n", sc.name, w, ops, tps, avgMs) + } + fmt.Println() + } +} + +// seed 는 두 샤드에 kv 테이블을 만들고, 라우터와 동일한 vindex 로 각 키를 해당 샤드에 +// 직접 적재한다. shard-0 에 놓인 키 목록을 반환한다(단일샤드 시나리오용). +func seed(shard0DSN, shard1DSN string, spec v1alpha1.ShardRangeSpec, keys int) []int { + db0 := mustOpen(shard0DSN) + db1 := mustOpen(shard1DSN) + defer db0.Close() + defer db1.Close() + for _, db := range []*sql.DB{db0, db1} { + mustExec(db, `DROP TABLE IF EXISTS kv`) + mustExec(db, `CREATE TABLE kv (id int PRIMARY KEY, val int)`) + } + txt := map[string]*sql.Tx{"shard-0": mustBegin(db0), "shard-1": mustBegin(db1)} + var onShard0 []int + for id := 1; id <= keys; id++ { + shard, err := router.ResolveShard(spec, strconv.Itoa(id)) + if err != nil { + log.Fatalf("resolve %d: %v", id, err) + } + if _, err := txt[shard].Exec(`INSERT INTO kv(id,val) VALUES($1,$2)`, id, id*10); err != nil { + log.Fatalf("insert %d -> %s: %v", id, shard, err) + } + if shard == "shard-0" { + onShard0 = append(onShard0, id) + } + } + for _, tx := range txt { + if err := tx.Commit(); err != nil { + log.Fatal("commit: ", err) + } + } + return onShard0 +} + +// run 은 db 에 w 개 워커로 dur 동안 점 쿼리를 던지고 (총 ops, 평균 지연ms)를 반환한다. +func run(dsn string, keys []int, w int, dur time.Duration, mode string) (int64, float64) { + db := mustOpen(dsn) + defer db.Close() + db.SetMaxOpenConns(w) + db.SetMaxIdleConns(w) + + query := `SELECT val FROM kv WHERE id=$1` + if mode == "update" { + query = `UPDATE kv SET val=val+1 WHERE id=$1` + } + + var ops int64 + var totalNs int64 + deadline := time.Now().Add(dur) + var wg sync.WaitGroup + for i := 0; i < w; i++ { + wg.Add(1) + go func(seed int64) { + defer wg.Done() + rng := rand.New(rand.NewSource(seed)) + for time.Now().Before(deadline) { + id := keys[rng.Intn(len(keys))] + t0 := time.Now() + var err error + if mode == "update" { + _, err = db.Exec(query, id) + } else { + var val int + err = db.QueryRow(query, id).Scan(&val) + } + if err != nil { + log.Printf("query err: %v", err) + return + } + atomic.AddInt64(&totalNs, time.Since(t0).Nanoseconds()) + atomic.AddInt64(&ops, 1) + } + }(int64(i) + 1) + } + wg.Wait() + avgMs := 0.0 + if ops > 0 { + avgMs = float64(totalNs) / float64(ops) / 1e6 + } + return ops, avgMs +} + +func mustOpen(dsn string) *sql.DB { + db, err := sql.Open("postgres", dsn) + if err != nil { + log.Fatalf("open %q: %v", dsn, err) + } + return db +} + +func mustExec(db *sql.DB, q string) { + if _, err := db.Exec(q); err != nil { + log.Fatalf("exec %q: %v", q, err) + } +} + +func mustBegin(db *sql.DB) *sql.Tx { + tx, err := db.Begin() + if err != nil { + log.Fatal("begin: ", err) + } + return tx +} + +func env(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func envInt(k string, def int) int { + if v := os.Getenv(k); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} + +func envDur(k string, def time.Duration) time.Duration { + if v := os.Getenv(k); v != "" { + if d, err := time.ParseDuration(v); err == nil { + return d + } + } + return def +} + +func envInts(k string, def []int) []int { + v := os.Getenv(k) + if v == "" { + return def + } + var out []int + for _, p := range strings.Split(v, ",") { + if n, err := strconv.Atoi(strings.TrimSpace(p)); err == nil { + out = append(out, n) + } + } + if len(out) == 0 { + return def + } + sort.Ints(out) + return out +} diff --git a/docs/perf/baseline.md b/docs/perf/baseline.md index 1e4c361..d2a1fbc 100644 --- a/docs/perf/baseline.md +++ b/docs/perf/baseline.md @@ -101,6 +101,60 @@ kubectl -n default exec quickstart-shard-0-0 -c postgres -- pgbench -c 32 -j 8 - kubectl -n default exec quickstart-shard-0-0 -c postgres -- pgbench -S -c 64 -j 8 -T 30 -U postgres postgres # select-only ``` +### 3.0b 실측 2차 — pg-router 분산 라우팅 처리량 (2026-06-28) + +`cmd/router-bench`(in-repo, `internal/router.ResolveShard` 로 데이터 배치 → 라우터 동일 +vindex) 로 **per-query 라우팅**의 워커 수 × TPS 와 라우터 오버헤드를 실측. *제품 첫 분산 +라우터 수치*. + +**환경**: + +| 필드 | 값 | +|---|---| +| date | 2026-06-28 | +| 구성 | Docker 컨테이너 2 PG 샤드(`postgres:18`, scram) + `pgrouter:dev`(query-mode, env backend) 동일 네트워크 | +| 호스트 | 16 vCPU / ~7.6 GiB (WSL2), **샤드 2개 + 라우터 + 클라이언트가 같은 코어 공유** | +| storage | Docker overlay (local) | +| topology | static murmur3 hash, 2-shard (shard-0/shard-1), 키 10,000 (shard-0=4,914 / shard-1=5,086) | +| 워크로드 | 점(point) 쿼리 `SELECT val FROM kv WHERE id=$1` (lib/pq, autocommit, 키당 unnamed Parse+Bind+Execute), 각 5s | +| 클라이언트 | `db.SetMaxOpenConns(W)` = W 연결, W 워커 goroutine | + +**결과 (read point-query, TPS)**: + +| 시나리오 | w=1 | w=2 | w=4 | w=8 | w=16 | w=32 | +|---|---|---|---|---|---|---| +| direct-shard0 (라우터 없음, 기준선) | 3,881 | 9,843 | 13,878 | 17,561 | 24,540 | 38,069 | +| router-1shard (라우터 경유, shard-0 키만) | 1,761 | 3,185 | 3,560 | 5,185 | 7,083 | 9,437 | +| router-2shard (라우터 경유, 전 키스페이스 분산) | 1,570 | 2,938 | 3,488 | 5,044 | 6,872 | 8,955 | + +(avg latency: direct 0.26→0.84 ms, router 0.57→3.4 ms.) + +**해석**: +- **워커 수 × TPS (라우터 경유)**: 점 읽기 처리량이 동시성에 따라 단조 증가 — 1,761(w=1) → + 9,437(w=32) TPS. per-query 라우팅이 다중 연결을 병렬 처리함을 실증. +- **라우터 오버헤드**: direct 대비 w=1 ~2.2×, w=32 ~4×. 원인 = 프록시 1-hop + **키당 + Parse 비용**(lib/pq `db.QueryRow` 는 unnamed Parse+Bind+Describe+Execute 를 매번 전송 → + 라우터가 describe 대행·라우팅을 매 쿼리 수행). prepared statement 재사용(샤드별 한 번만 + Parse, 이후 Bind/Execute)이면 이 비용이 크게 줄 것 — 후속 측정 항목. +- **2샤드 ≈ 1샤드 (point read)**: 분산이 처리량을 *늘리지 않음*. 이유 = 점 읽기에선 **라우터가 + 병목**(~9K TPS 천장)이고 단일 샤드(38K)는 한참 여유 → 일을 두 샤드로 나눠도 라우터 천장이 + 그대로. **수평 스케일이 드러나려면 샤드가 병목인 조건**(멀티호스트로 라우터·샤드 코어 분리, + 또는 라우터 다중 인스턴스, 또는 샤드 CPU 를 포화시키는 무거운 쿼리)이 필요 — 단일 호스트 + 단일 라우터로는 분산 이득 미관측. 이것이 다음 측정의 핵심 과제. +- **쓰기(update)**: Docker overlay fsync + 단일 호스트 CPU 경합으로 변동 극심(direct w=32→64 + 가 3,152→17,424 로 튐)해 분산 결론 부적합 — 전용 PV / 멀티호스트 재측정 필요(보류). + +**caveats**: ① 샤드·라우터·클라이언트가 같은 16코어 공유 → 분산 스케일 관측 불가(설계상 +한계) ② overlay 스토리지 ③ percentile 미측정(avg 만) ④ lib/pq unnamed-Parse 경로 → +prepared 재사용 시 라우터 처리량 상향 여지. + +**재현**: +```bash +# 2 scram 샤드 + pgrouter:dev 동일 네트워크 기동 후: +BENCH_KEYS=10000 BENCH_DURATION=5s BENCH_WORKERS=1,2,4,8,16,32 BENCH_MODE=select \ + go run ./cmd/router-bench +``` + ### 3.1 Single-shard baseline (T-1S) | date | workload | iso | clients | TPS | P50 ms | P95 ms | P99 ms | comment | From a78ea4ef5c34fa8ce35810ed9b8e064e0be597a7 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 01:13:24 +0900 Subject: [PATCH 058/109] =?UTF-8?q?docs:=20per-query=20extended=20+=20?= =?UTF-8?q?=EB=B6=84=EC=82=B0=20=EC=88=98=EC=B9=98=201=EC=B0=A8=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EB=B0=98=EC=98=81,=20=EB=8B=A4=EC=9D=8C?= =?UTF-8?q?=20=EC=9A=B0=EC=84=A0=EC=88=9C=EC=9C=84=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WORK_HANDOFF §6.6: per-query extended·분산수치 1차 완료 표기, 새 #1=멀티호스트 수평스케일 실증·#2=라우터 오버헤드 개선. ROUTER-GAP-ANALYSIS: extended per-query 완료로 "pin-on-first 유지" stale 노트 정정. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/WORK_HANDOFF.ko.md | 10 ++++++---- docs/sharding/ROUTER-GAP-ANALYSIS.ko.md | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index 9f43846..39265b0 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -217,14 +217,16 @@ SSOT는 [ROUTER-GAP-ANALYSIS §4 능력 사다리 + §6 백로그](sharding/ROUT | ~~·~~ ✅ | ~~scram 인증 대행~~ | **완료** — 프로덕션 PG(scram) 동작 | | ~~·~~ ✅ | ~~describe-round 대행~~ | **완료** — lib/pq 파라미터 쿼리 → **실 DB 드라이버와 동작** | | ~~·~~ ✅ | ~~멀티샤드 scatter forwarding~~ | **완료(2026-06-28)** — 병렬 fan-out + UNION ALL 병합, 라이브 검증 | -| ~~·~~ ✅ | ~~per-query 라우팅 (연결 종단 + 백엔드 풀)~~ | **완료(2026-06-28)** — `persession.go` 세션 루프가 *매* simple Query를 키의 샤드로 독립 라우팅(vtgate 모델) + 샤드별 백엔드 lazy 풀. 라이브 검증: 한 연결에서 alice→shard-0/bob→shard-1/carol→shard-0. scatter·단일샤드 tx pin 포함. (남음: cross-shard 2PC, per-query extended) | -| **1** | **분산 성능 수치** | per-query/scatter 기준 N-shard throughput (워커 수 × TPS, "분산처리능력") | +| ~~·~~ ✅ | ~~per-query 라우팅 (연결 종단 + 백엔드 풀)~~ | **완료(2026-06-28)** — `persession.go` 세션 루프가 *매* simple Query를 키의 샤드로 독립 라우팅(vtgate 모델) + 샤드별 백엔드 lazy 풀. 라이브 검증: 한 연결에서 alice→shard-0/bob→shard-1/carol→shard-0. scatter·단일샤드 tx pin 포함. | +| ~~·~~ ✅ | ~~per-query extended protocol~~ | **완료(2026-06-28)** — `extsession.go`. extended(Parse/Bind/…)도 Sync까지 버퍼링→배치 per-query 라우팅, ParseComplete 합성+**샤드별 prepare-on-first-use**+주입분 필터. 라이브: lib/pq 한 연결+prepared `WHERE id=$1` 5회 다른키→키별 정확 라우팅. 구 pin-on-first 제거. | +| ~~·~~ ✅ | ~~분산 성능 수치 1차~~ | **완료(2026-06-28)** — `cmd/router-bench` + baseline.md §3.0b. 라우터 점읽기 워커수×TPS 1761(w1)→9437(w32), 오버헤드 ~2.2~4×. **2샤드≈1샤드(point read)=라우터가 병목, 수평스케일은 멀티호스트 필요**. | +| **1** | **멀티호스트 수평 스케일 실증** | 단일 호스트에선 라우터·샤드 코어공유라 분산 이득 미관측. 멀티노드 kind(또는 분리 호스트)로 샤드를 별 코어에 두고 N-shard 처리량 스케일 측정 — "분산처리능력" 진짜 수치 | +| **2** | **라우터 오버헤드 개선 정량화** | prepared-stmt 재사용 벤치(키당 Parse 제거)로 라우터 처리량 상향 측정 + 코드 최적화 여지 | | **4** | **B: resharding 실데이터 이동** | ShardSplitJob의 CopyTable DSN 결선 + CDC + write-block cutover (현재 골격) | | **5** | reference table·read-replica **프록시 결선** | 부품은 완성, query-mode에 연결만 | | **6** | 보류 #5/#7/#9 | per-shard primary Service·watch·failover lease P2-T3 — 라이브 failover 필요 | -| **7** | 분산 성능 수치 + percentile·sysbench | scatter 동작 후 | -> 제약 현황(query-mode 라이브): ✅ simple/inline-literal query · ⚠️ 단일라운드 파라미터만(lib/pq 미지원) · ❌ scram·scatter. +> 제약 현황(query-mode 라이브): ✅ simple+extended per-query(prepared 포함)·scatter·단일샤드 tx·scram 백엔드. ❌ extended scatter·cross-shard 2PC·Flush 파이프라이닝. --- diff --git a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md index ae98ff3..b9aa28c 100644 --- a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md +++ b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md @@ -129,7 +129,7 @@ parameterized/`t.col`/주석·문자열 내부 오인 방지까지 검증. **기 **라우팅 핵심 (능력 사다리)** - [~] **(E) 프로토콜 종단 — 쿼리 단위 라우팅**: **query-mode 라이브 PG end-to-end 검증 완료 (2026-06-27)** 🎉. 2 trust postgres(shard-0/1) + pg-router `PGROUTER_MODE=query` → psql `SELECT located_on FROM probe WHERE id='alice'` 이 키 추출→murmur3 vindex→샤드 라우팅→실 쿼리 실행→결과 반환까지 동작. **alice→shard-0 / bob→shard-1 / carol→shard-0** 결정적·올바른 분산 확인(bob이 다른 샤드 = 해시 라우팅 실증). 라이브 검증에서 프로토콜 버그(백엔드 핸드셰이크 미소비) 발견·수정. **종단 작업 진척**: ① **scram-sha-256 / cleartext 백엔드 인증 대행 ✅완료**(RFC 7677 단위검증 + 라이브 scram PG 검증, `crypto/pbkdf2` stdlib, `PGROUTER_BACKEND_PASSWORD`) — **query-mode가 trust 아닌 실 프로덕션 PG와 동작** ② **extended protocol ✅완료**: inline-literal + parameterized(`$N`) 라우팅. **describe-round 대행 구현**(`describeround.go`) — lib/pq·JDBC 의 `Parse→Describe→Sync→Bind` 를, 임의 샤드로 describe round 대행 후 Bind 파라미터로 실 샤드 라우팅(다르면 re-Parse + 중복 ParseComplete 필터). **라이브 검증: lib/pq `WHERE id=$1` 정상 라우팅**(alice→shard-0/bob→shard-1) ⇒ **실 DB 드라이버와 동작** ③ **멀티샤드 scatter forwarding ✅완료**(`scattermode.go`): 키 없는 simple Query를 모든 샤드에 *병렬* fan-out→결과 병합(UNION ALL), 라이브 검증(SELECT FROM probe→양샤드 6행). 집계 재merge·전역 ORDER BY·LIMIT pushdown은 후속 ④ **per-query 라우팅 + 백엔드 연결 풀 ✅완료 (2026-06-28)**: 연결 고정 한계 해소(아래) ⑤ ~~per-query 로그~~ ✅완료. > -> **✅ 해소됨 — per-query 라우팅 (구 한계: 연결 고정)**: query-mode가 *첫 쿼리*로 연결을 한 샤드에 고정하던 한계를 해소했다(2026-06-28). `persession.go`의 `runPerQuerySession` 세션 루프가 한 연결의 *매* simple Query를 그 키의 샤드로 독립 라우팅하고(vtgate 모델), 샤드별 백엔드 연결을 세션 내에서 lazy 풀링·재사용한다. **라이브 검증(2 scram 샤드, 한 연결)**: `id='alice'`→shard-0, `id='bob'`→shard-1, `id='carol'`→shard-0 가 *같은 연결*에서 각각 올바른 샤드로 라우팅(로그상 4개 독립 `routed (Q)` 결정). 키 없는 쿼리는 scatter fan-out, 단일샤드 명시적 트랜잭션은 `BEGIN` 응답 합성 후 첫 키 쿼리로 한 샤드에 pin(COMMIT/ROLLBACK까지 그 백엔드 재사용) — 모두 검증. *남은 범위*: cross-shard 2PC, per-query **extended** protocol(샤드별 prepared statement 관리 필요 — 현행 extended는 pin-on-first describe-round 유지). +> **✅ 해소됨 — per-query 라우팅 (구 한계: 연결 고정)**: query-mode가 *첫 쿼리*로 연결을 한 샤드에 고정하던 한계를 해소했다(2026-06-28). `persession.go`의 `runPerQuerySession` 세션 루프가 한 연결의 *매* simple Query를 그 키의 샤드로 독립 라우팅하고(vtgate 모델), 샤드별 백엔드 연결을 세션 내에서 lazy 풀링·재사용한다. **라이브 검증(2 scram 샤드, 한 연결)**: `id='alice'`→shard-0, `id='bob'`→shard-1, `id='carol'`→shard-0 가 *같은 연결*에서 각각 올바른 샤드로 라우팅(로그상 4개 독립 `routed (Q)` 결정). 키 없는 쿼리는 scatter fan-out, 단일샤드 명시적 트랜잭션은 `BEGIN` 응답 합성 후 첫 키 쿼리로 한 샤드에 pin(COMMIT/ROLLBACK까지 그 백엔드 재사용) — 모두 검증. **extended protocol(Parse/Bind/Describe/Execute/Sync)도 per-query 완료(2026-06-28, `extsession.go`)** — Sync 까지 버퍼링해 배치 단위로 키의 샤드에 보내고, ParseComplete 를 합성해 ack 한 뒤 샤드별 prepare-on-first-use(저장된 Parse 주입 + 주입분 ParseComplete 필터)로 prepared statement 를 lazy 관리한다. 라이브 검증(lib/pq 한 연결 + prepared `WHERE id=$1` 5회 다른 키 → 키별 정확 라우팅). 구 pin-on-first(describe-round 단일 라운드)는 제거. *남은 범위*: cross-shard 2PC, extended scatter(키 없는 파이프라인 fan-out), Flush(H) 파이프라이닝. - [ ] **읽기 → replica 라우팅**: `status.shards[].replicas[]`(Ready)로 읽기 쿼리를 분산 → primary 부하 경감 + 읽기 확장. 사다리 2단계. (status backend 계층 재사용.) - [ ] **scatter-gather 실연결**: `scatter.go` 골격 → 실 wire-protocol fan-out + merge + 집계/정렬 pushdown. 사다리 2~3단계. - [ ] **Reference table**: 전 샤드 복제로 분산 조인 우회. 사다리 4단계. 고가치·저난도. From 484040df402335f72f917fd16d061353e65eec91 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 01:20:49 +0900 Subject: [PATCH 059/109] =?UTF-8?q?feat(router-bench):=20prepared-stmt=20?= =?UTF-8?q?=EC=9E=AC=EC=82=AC=EC=9A=A9=20=EB=AA=A8=EB=93=9C=20+=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=84=B0=20=EC=98=A4=EB=B2=84=ED=97=A4?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=9F=89=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit router-bench BENCH_PREPARED=1: 워커별 연결 고정 + stmt 1회 Parse 후 Bind/Execute 반복(라우터 샤드별 prepare-on-first-use lazy prepare 경로 부하). baseline.md §3.0c: prepared 가 라우터 점읽기 처리량을 ~1.9× 로 향상(9K→17K TPS @w=32) — 키당 Parse 비용이 오버헤드의 ~절반. 남은 ~4.7× 갭은 동기 per-query proxy 루프(미버퍼 syscall) → bufio 버퍼링이 식별된 다음 코드 최적화. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- cmd/router-bench/main.go | 57 +++++++++++++++++++++++++++++++--------- docs/perf/baseline.md | 34 ++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 13 deletions(-) diff --git a/cmd/router-bench/main.go b/cmd/router-bench/main.go index 6052bba..6c231ff 100644 --- a/cmd/router-bench/main.go +++ b/cmd/router-bench/main.go @@ -21,6 +21,7 @@ Licensed under the MIT License. See the LICENSE file for details. package main import ( + "context" "database/sql" "fmt" "log" @@ -60,11 +61,12 @@ func main() { dur = envDur("BENCH_DURATION", 5*time.Second) workers = envInts("BENCH_WORKERS", []int{1, 2, 4, 8, 16, 32}) mode = env("BENCH_MODE", "select") + prepared = env("BENCH_PREPARED", "") != "" ) spec := benchSpec() keysOnShard0 := seed(shard0DSN, shard1DSN, spec, keys) - log.Printf("seeded %d keys: shard-0=%d shard-1=%d (mode=%s dur=%s)", keys, len(keysOnShard0), keys-len(keysOnShard0), mode, dur) + log.Printf("seeded %d keys: shard-0=%d shard-1=%d (mode=%s dur=%s prepared=%v)", keys, len(keysOnShard0), keys-len(keysOnShard0), mode, dur, prepared) allKeys := make([]int, keys) for i := range allKeys { @@ -85,7 +87,7 @@ func main() { fmt.Println(strings.Repeat("-", 62)) for _, sc := range scenarios { for _, w := range workers { - ops, avgMs := run(sc.dsn, sc.keys, w, dur, mode) + ops, avgMs := run(sc.dsn, sc.keys, w, dur, mode, prepared) tps := float64(ops) / dur.Seconds() fmt.Printf("%-16s %8d %12d %12.0f %10.3f\n", sc.name, w, ops, tps, avgMs) } @@ -127,7 +129,9 @@ func seed(shard0DSN, shard1DSN string, spec v1alpha1.ShardRangeSpec, keys int) [ } // run 은 db 에 w 개 워커로 dur 동안 점 쿼리를 던지고 (총 ops, 평균 지연ms)를 반환한다. -func run(dsn string, keys []int, w int, dur time.Duration, mode string) (int64, float64) { +// prepared 면 워커마다 연결을 고정하고 stmt 를 *한 번만* Parse 한 뒤 Bind/Execute 를 반복 +// 한다(키당 Parse 제거 — 라우터는 샤드별 prepare-on-first-use 로 lazy prepare). +func run(dsn string, keys []int, w int, dur time.Duration, mode string, prepared bool) (int64, float64) { db := mustOpen(dsn) defer db.Close() db.SetMaxOpenConns(w) @@ -138,8 +142,7 @@ func run(dsn string, keys []int, w int, dur time.Duration, mode string) (int64, query = `UPDATE kv SET val=val+1 WHERE id=$1` } - var ops int64 - var totalNs int64 + var ops, totalNs int64 deadline := time.Now().Add(dur) var wg sync.WaitGroup for i := 0; i < w; i++ { @@ -147,17 +150,28 @@ func run(dsn string, keys []int, w int, dur time.Duration, mode string) (int64, go func(seed int64) { defer wg.Done() rng := rand.New(rand.NewSource(seed)) + ctx := context.Background() + + // 워커별 연결 고정(라우터 세션 1개). prepared 면 stmt 를 한 번만 준비. + conn, err := db.Conn(ctx) + if err != nil { + log.Printf("conn: %v", err) + return + } + defer conn.Close() + var stmt *sql.Stmt + if prepared { + if stmt, err = conn.PrepareContext(ctx, query); err != nil { + log.Printf("prepare: %v", err) + return + } + defer stmt.Close() + } + for time.Now().Before(deadline) { id := keys[rng.Intn(len(keys))] t0 := time.Now() - var err error - if mode == "update" { - _, err = db.Exec(query, id) - } else { - var val int - err = db.QueryRow(query, id).Scan(&val) - } - if err != nil { + if err := exec1(ctx, conn, stmt, query, mode, prepared, id); err != nil { log.Printf("query err: %v", err) return } @@ -174,6 +188,23 @@ func run(dsn string, keys []int, w int, dur time.Duration, mode string) (int64, return ops, avgMs } +// exec1 은 한 점 쿼리를 실행한다 (prepared 면 stmt 재사용, 아니면 conn 에 직접). +func exec1(ctx context.Context, conn *sql.Conn, stmt *sql.Stmt, query, mode string, prepared bool, id int) error { + if mode == "update" { + if prepared { + _, err := stmt.ExecContext(ctx, id) + return err + } + _, err := conn.ExecContext(ctx, query, id) + return err + } + var val int + if prepared { + return stmt.QueryRowContext(ctx, id).Scan(&val) + } + return conn.QueryRowContext(ctx, query, id).Scan(&val) +} + func mustOpen(dsn string) *sql.DB { db, err := sql.Open("postgres", dsn) if err != nil { diff --git a/docs/perf/baseline.md b/docs/perf/baseline.md index d2a1fbc..d2380c1 100644 --- a/docs/perf/baseline.md +++ b/docs/perf/baseline.md @@ -155,6 +155,40 @@ BENCH_KEYS=10000 BENCH_DURATION=5s BENCH_WORKERS=1,2,4,8,16,32 BENCH_MODE=select go run ./cmd/router-bench ``` +### 3.0c 실측 3차 — prepared statement 재사용 효과 (2026-06-28) + +`BENCH_PREPARED=1`: 워커마다 연결을 고정하고 stmt 를 *한 번만* Parse 한 뒤 Bind/Execute 를 +반복(라우터는 샤드별 prepare-on-first-use 로 lazy prepare). §3.0b 와 동일 환경. + +**결과 (read point-query, prepared, TPS)**: + +| 시나리오 | w=1 | w=2 | w=4 | w=8 | w=16 | w=32 | +|---|---|---|---|---|---|---| +| direct-shard0 | 13,784 | 21,587 | 26,964 | 35,551 | 50,246 | 81,596 | +| router-1shard | 3,816 | 6,857 | 8,058 | 9,500 | 12,887 | 17,306 | +| router-2shard | 3,044 | 5,620 | 6,360 | 9,313 | 12,618 | 17,090 | + +**prepared vs unprepared (router-2shard, TPS)**: + +| w | unprepared(§3.0b) | prepared | 개선 | +|---|---|---|---| +| 1 | 1,570 | 3,044 | 1.9× | +| 8 | 5,044 | 9,313 | 1.85× | +| 32 | 8,955 | 17,090 | 1.9× | + +**해석**: +- **prepared statement 재사용이 라우터 처리량을 ~1.9× 로 끌어올린다** (9K→17K TPS @ w=32). + 키당 Parse(+describe 대행) 비용이 라우터 오버헤드의 *약 절반* 이었음을 정량 확인. ⇒ 제품 + 권고: 드라이버 prepared statement / connection pool 사용 시 라우터 처리량 대폭 향상. +- 그래도 direct(81K) 대비 router(17K)는 ~4.7× — 남은 오버헤드는 라우터의 **동기 per-query + proxy 루프**(메시지마다 read→route→relay, 미버퍼 syscall). **식별된 코드 최적화**: 연결당 + `bufio` 버퍼링(헤더+payload 2 syscall/메시지 → 배치)으로 syscall 수 감소 — 별도 집중 + 변경으로 회귀 테스트 후 적용 예정(WORK_HANDOFF #2). +- 2샤드 ≈ 1샤드는 prepared 에서도 동일 — 라우터가 여전히 천장(단일 호스트). 수평 스케일은 + 멀티호스트 측정으로(§3.0d 예정). + +**재현**: `BENCH_PREPARED=1 BENCH_MODE=select go run ./cmd/router-bench` + ### 3.1 Single-shard baseline (T-1S) | date | workload | iso | clients | TPS | P50 ms | P95 ms | P99 ms | comment | From 76a71c9b3ed851f9704f6739bb96cd0f43649345 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 01:28:47 +0900 Subject: [PATCH 060/109] =?UTF-8?q?docs(perf):=20=EC=88=98=ED=8F=89=20?= =?UTF-8?q?=EC=8A=A4=EC=BC=80=EC=9D=BC=20=EB=8B=A8=EC=9D=BC=20=ED=98=B8?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=AC=BC=EB=A6=AC=EC=A0=81=20=ED=95=9C?= =?UTF-8?q?=EA=B3=84=20=EC=8B=A4=EC=A6=9D=20=EA=B8=B0=EB=A1=9D=20(=C2=A73.?= =?UTF-8?q?0d)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read point·write hot-row 모든 워크로드에서 2샤드 ≤ 1샤드 확정. 단일 16vCPU 호스트는 샤드·라우터가 CPU·overlay 스토리지를 공유 → CPU/스토리지 bound 둘 다 스케일 불가, PG 인스턴스 2개 오버헤드로 오히려 throughput 감소. 진짜 수평 스케일 수치는 물리 분리 노드(별 CPU+스토리지)에서만 측정 가능 — router-bench가 샤드별 DSN을 받으므로 멀티머신 환경에 그대로 적용 가능(방법 기록). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/perf/baseline.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/perf/baseline.md b/docs/perf/baseline.md index d2380c1..8611f89 100644 --- a/docs/perf/baseline.md +++ b/docs/perf/baseline.md @@ -189,6 +189,34 @@ BENCH_KEYS=10000 BENCH_DURATION=5s BENCH_WORKERS=1,2,4,8,16,32 BENCH_MODE=select **재현**: `BENCH_PREPARED=1 BENCH_MODE=select go run ./cmd/router-bench` +### 3.0d 수평 스케일 — 단일 호스트의 물리적 한계 (2026-06-28) + +"샤드를 늘리면 처리량이 스케일하는가?"(분산처리능력의 핵심)를 단일 호스트에서 *여러 +워크로드로* 검증한 결과, **2샤드 ≤ 1샤드** 가 일관되게 관측됐다. + +| 워크로드 | router-1shard | router-2shard | 결론 | +|---|---|---|---| +| read point (prepared, w=32) | 17,306 | 17,090 | ≈ (라우터 천장) | +| write hot-row (sync_commit=off, w=64) | 24,857 | **17,895** | 2샤드가 *더 느림* | + +**근본 원인 — 자원 공유**: 단일 16 vCPU 호스트에서 샤드 2개 + 라우터 + 클라이언트가 +**같은 CPU 코어와 같은 overlay 스토리지(fsync)를 공유**한다. 따라서: +- CPU-bound(읽기/연산): 총 코어가 고정 → 샤드를 나눠도 합산 CPU 불변 → 스케일 없음. +- 스토리지-bound(쓰기 fsync): 두 샤드가 같은 디스크에 commit → fsync 직렬화 → 스케일 없음. +- 게다가 PG 인스턴스 2개의 오버헤드(2× 백그라운드 프로세스·shared_buffers·WAL)가 더해져 + 단일 호스트에선 샤딩이 throughput 을 *오히려 떨어뜨린다*. + +**결론**: **진짜 수평 스케일 수치는 물리적으로 분리된 노드(별도 CPU + 별도 스토리지)에서만 +측정 가능**하다. 멀티노드 kind 도 노드가 같은 호스트 커널/코어를 공유하므로 이 한계를 벗어나지 +못한다(오퍼레이터의 노드 분산 *배포* 능력 검증엔 유효하나 스케일 *수치* 는 못 냄). 단일 +호스트 측정은 **라우터 종단 처리량의 상한**(점읽기 ~17K TPS prepared, 라우터가 병목)을 주는 +데 의의가 있다. + +**진짜 분산 수치 측정 방법(멀티머신)**: `router-bench` 는 이미 샤드별 DSN 을 환경변수로 +받는다(`BENCH_SHARD0`/`BENCH_SHARD1`/`BENCH_ROUTER`). 서로 다른 물리 머신(또는 클라우드 VM)에 +샤드를 띄우고 라우터를 별 머신에 두면, 같은 벤치를 그대로 가리켜 N-shard 스케일을 측정할 수 +있다. (예: 샤드당 1 VM × 2 + 라우터 1 VM → 1-shard 대비 2-shard 처리량 비교.) + ### 3.1 Single-shard baseline (T-1S) | date | workload | iso | clients | TPS | P50 ms | P95 ms | P99 ms | comment | From a133d14b371454c5bff7bb5c6be7c91f2aeff7c4 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 01:36:00 +0900 Subject: [PATCH 061/109] =?UTF-8?q?feat(pg-router):=20reference=20table?= =?UTF-8?q?=C2=B7read-replica=20=ED=94=84=EB=A1=9D=EC=8B=9C=20=EA=B2=B0?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 라우팅 브레인(QueryRouter.Route)은 이미 reference-only→AnyShard·읽기→Read resolver 를 처리했으나 pg-router 가 미배선이었다. 결선: - read resolver: env 모드 PGROUTER_BACKEND__REPLICA(없으면 primary fallback), status 모드 StatusBackendResolver.ResolveRead(Ready replica, failover-aware). buildRouting 이 write·read 둘 다 반환 → newQueryRouter 에 전달. - reference table: shardSpec 에 PGROUTER_REFERENCE_TABLES(CSV) → reference-only 쿼리가 scatter 대신 AnyShard 로. 라이브 검증(primary 2 + shard-0 replica 대역): read alice→shard-0 replica(R0), read bob→shard-1 primary(replica 미설정 fallback P1), ref country→AnyShard read =replica(Korea-R0), UPDATE alice→shard-0 primary(primary=WROTE/replica=R0 유지). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- cmd/pg-router/main.go | 64 +++++++++++++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 15 deletions(-) diff --git a/cmd/pg-router/main.go b/cmd/pg-router/main.go index 00df194..1f4dc67 100644 --- a/cmd/pg-router/main.go +++ b/cmd/pg-router/main.go @@ -54,7 +54,7 @@ import ( func main() { ctx := context.Background() addr := env("PGROUTER_LISTEN", ":5432") - provider, resolve, err := buildRouting(ctx) + provider, resolve, readResolve, err := buildRouting(ctx) if err != nil { log.Fatalf("pg-router: build routing: %v", err) } @@ -67,7 +67,7 @@ func main() { ) // 라우팅 모드: connection(기본, startup param) | query(첫 쿼리 인지 라우팅, PoC). mode := strings.ToLower(env("PGROUTER_MODE", "connection")) - qr := newQueryRouter(provider, resolve, nil) + qr := newQueryRouter(provider, resolve, readResolve) serverVersion := env("PGROUTER_SERVER_VERSION", "18.0") backendPassword := env("PGROUTER_BACKEND_PASSWORD", "") // 백엔드 인증 대행(scram/cleartext)용. ""=trust. @@ -91,9 +91,12 @@ func main() { } } -// buildRouting wires the (pluggable) topology provider and backend resolver, and -// starts a refresh loop for any dynamic source. -func buildRouting(ctx context.Context) (router.TopologyProvider, router.BackendResolver, error) { +// buildRouting wires the (pluggable) topology provider, the write (primary) +// backend resolver, and the read (replica) backend resolver; it also starts a +// refresh loop for any dynamic source. The read resolver routes read-only queries +// to replicas (env: PGROUTER_BACKEND__REPLICA; status: Ready replica from +// PostgresCluster.status, failover-aware). nil read resolver ⇒ reads use primary. +func buildRouting(ctx context.Context) (router.TopologyProvider, router.BackendResolver, router.BackendResolver, error) { topoMode := strings.ToLower(env("PGROUTER_TOPOLOGY", "static")) backendMode := strings.ToLower(env("PGROUTER_BACKEND", "env")) ns := env("PGROUTER_NAMESPACE", "default") @@ -104,7 +107,7 @@ func buildRouting(ctx context.Context) (router.TopologyProvider, router.BackendR if topoMode == "crd" || backendMode == "status" { c, err := newK8sClient() if err != nil { - return nil, nil, err + return nil, nil, nil, err } k8s = c } @@ -122,16 +125,17 @@ func buildRouting(ctx context.Context) (router.TopologyProvider, router.BackendR } provider = crdProvider default: - return nil, nil, fmt.Errorf("unknown PGROUTER_TOPOLOGY %q (want static|crd)", topoMode) + return nil, nil, nil, fmt.Errorf("unknown PGROUTER_TOPOLOGY %q (want static|crd)", topoMode) } - // Backend resolver (shard -> addr). - var resolve router.BackendResolver + // Backend resolver (shard -> addr): write=primary, read=replica. + var resolve, readResolve router.BackendResolver var statusRes *router.StatusBackendResolver var statusReader router.ClusterStatusReader switch backendMode { case "", "env": resolve = envBackendResolver + readResolve = envReadBackendResolver case "template": resolve = templateResolver() case "status": @@ -141,14 +145,15 @@ func buildRouting(ctx context.Context) (router.TopologyProvider, router.BackendR log.Printf("pg-router: initial status read: %v (will retry)", err) } resolve = statusRes.Resolve + readResolve = statusRes.ResolveRead // Ready replica, falls back to primary. default: - return nil, nil, fmt.Errorf("unknown PGROUTER_BACKEND %q (want env|template|status)", backendMode) + return nil, nil, nil, fmt.Errorf("unknown PGROUTER_BACKEND %q (want env|template|status)", backendMode) } if crdProvider != nil || statusRes != nil { go refreshLoop(ctx, crdProvider, statusReader, statusRes, ns, cluster) } - return provider, resolve, nil + return provider, resolve, readResolve, nil } // newK8sClient builds a controller-runtime client with the operator scheme. @@ -335,11 +340,14 @@ func readStartup(conn net.Conn) ([]byte, map[string]string, error) { // shardSpec is the PoC static routing table (a 2-shard hash vindex). With // PGROUTER_TOPOLOGY=crd this is replaced by the live ShardRange CRD. +// PGROUTER_REFERENCE_TABLES (CSV) declares replicated reference tables so that +// reference-only queries route to any shard (no key) instead of scatter. func shardSpec() v1alpha1.ShardRangeSpec { return v1alpha1.ShardRangeSpec{ - Cluster: env("PGROUTER_CLUSTER", "quickstart"), - Keyspace: env("PGROUTER_KEYSPACE", "default"), - Vindex: v1alpha1.VindexSpec{Type: v1alpha1.VindexTypeHash, Column: "id", Function: "murmur3"}, + Cluster: env("PGROUTER_CLUSTER", "quickstart"), + Keyspace: env("PGROUTER_KEYSPACE", "default"), + Vindex: v1alpha1.VindexSpec{Type: v1alpha1.VindexTypeHash, Column: "id", Function: "murmur3"}, + ReferenceTables: csv(env("PGROUTER_REFERENCE_TABLES", "")), Ranges: []v1alpha1.ShardRangeEntry{ {Lo: "0x00000000", Hi: "0x7fffffff", Shard: "shard-0"}, {Lo: "0x80000000", Hi: "0xffffffff", Shard: "shard-1"}, @@ -347,6 +355,20 @@ func shardSpec() v1alpha1.ShardRangeSpec { } } +// csv splits a comma-separated env value, trimming spaces and dropping empties. +func csv(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + var out []string + for _, p := range strings.Split(s, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + // templateResolver maps a shard to a backend via a DNS template // (PGROUTER_BACKEND_TEMPLATE) with {cluster}/{shard}/{namespace} substitution — // no per-shard env needed. @@ -362,11 +384,23 @@ func templateResolver() router.BackendResolver { } } -// envBackendResolver maps a shard ID to its backend via PGROUTER_BACKEND_. +// envBackendResolver maps a shard ID to its primary backend via +// PGROUTER_BACKEND_. func envBackendResolver(shardID string) (string, error) { return backendFor(shardID), nil } +// envReadBackendResolver maps a shard ID to its *replica* backend via +// PGROUTER_BACKEND__REPLICA, falling back to the primary when no replica is +// configured (so read-only queries still work without a replica deployed). +func envReadBackendResolver(shardID string) (string, error) { + envKey := "PGROUTER_BACKEND_" + strings.ToUpper(strings.ReplaceAll(shardID, "-", "_")) + "_REPLICA" + if v := os.Getenv(envKey); v != "" { + return v, nil + } + return backendFor(shardID), nil +} + func backendFor(shardID string) string { envKey := "PGROUTER_BACKEND_" + strings.ToUpper(strings.ReplaceAll(shardID, "-", "_")) return env(envKey, "127.0.0.1:5432") From 7d33266583330c9623caa5a7f85312e5153fe5b0 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 01:42:38 +0900 Subject: [PATCH 062/109] =?UTF-8?q?feat(router):=20resharding=20=EC=8B=A4?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=9D=B4=EB=8F=99=20=E2=80=94=20?= =?UTF-8?q?hash-range=20=ED=95=84=ED=84=B0=20copy=20+=20cutover=20delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CopyTable 전체복사 골격에 진짜 split 데이터 이동을 더한다: - CopyShardRange: source 테이블에서 *vindex 키가 targetShard 로 해소되는 row 만* 복사(라우팅과 동일 ResolveShard). split 이 새 shard 범위의 부분집합만 옮기는 실제 semantics. source read-only(rollback=target drop) — reversible. - DeleteShardRange: cutover *후* source 에서 이동 키 row 삭제(라우팅 전환 뒤에만). - keyString(lib/pq []byte→string)·indexOfFold 헬퍼 + 식별자 화이트리스트(table+vindex 컬럼 둘 다). - reshard-copy-poc: range 모드(PGROUTER_RESHARD_TARGET_SHARD)+cutover 정리 (PGROUTER_RESHARD_DELETE_AFTER). 라이브 검증(source 키 1..100): CopyShardRange→56 복사(shard-1 키만, source 100 유지= reversible), cutover(copy+delete)→source=44(shard-0)/target=56(shard-1), **overlap=0 합=100 = 키 유실·중복 0**. 단위 테스트(injection 차단·keyString·indexOfFold) green. 남은 것: ShardSplitJob 컨트롤러 reconcile 가 pod DSN 으로 이 함수들을 phase 별 호출 + write-block(토폴로지 flip)까지 결선(K8s 통합). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- cmd/reshard-copy-poc/main.go | 74 +++++++++++-- internal/router/reshard_copy.go | 151 +++++++++++++++++++++++++++ internal/router/reshard_copy_test.go | 60 +++++++++++ 3 files changed, 276 insertions(+), 9 deletions(-) diff --git a/cmd/reshard-copy-poc/main.go b/cmd/reshard-copy-poc/main.go index a2d6a0c..23255ea 100644 --- a/cmd/reshard-copy-poc/main.go +++ b/cmd/reshard-copy-poc/main.go @@ -4,11 +4,23 @@ Copyright 2026 keiailab. Licensed under the MIT License. See the LICENSE file for details. */ -// Command reshard-copy-poc is a G3 online-resharding InitialCopy live PoC. It -// copies a table from a source shard to a target shard via router.CopyTable — -// the *reversible* data-movement step of ShardSplitJob (rollback = drop target). -// The irreversible Cutover (write-block + routing switch) is intentionally out of -// scope here. Config: PGROUTER_SOURCE_DSN, PGROUTER_TARGET_DSN, PGROUTER_COPY_TABLE. +// Command reshard-copy-poc is a G3 online-resharding live PoC. It performs the +// *reversible* data-movement step of ShardSplitJob via internal/router: +// +// - Full copy (default): router.CopyTable (source table → target, all rows). +// - Range copy (PGROUTER_RESHARD_TARGET_SHARD set): router.CopyShardRange — only +// the rows whose vindex key resolves to the target shard (the *real* split: +// move just the moving sub-range, same vindex as routing). +// - Cutover cleanup (PGROUTER_RESHARD_DELETE_AFTER=1): after the copy and the +// routing switch, router.DeleteShardRange removes the moved rows from the +// source. Run ONLY after routing is switched (else data loss). +// +// The built-in vindex (murmur3 hash, 2-shard split at 0x80000000, column from +// PGROUTER_VINDEX_COLUMN, default "id") matches cmd/pg-router shardSpec so the +// copy and the router agree on which keys belong where. +// +// Config: PGROUTER_SOURCE_DSN, PGROUTER_TARGET_DSN, PGROUTER_COPY_TABLE, +// PGROUTER_RESHARD_TARGET_SHARD, PGROUTER_VINDEX_COLUMN, PGROUTER_RESHARD_DELETE_AFTER. package main import ( @@ -17,13 +29,26 @@ import ( "os" "time" + "github.com/keiailab/postgres-operator/api/v1alpha1" "github.com/keiailab/postgres-operator/internal/router" ) +// reshardSpec is the 2-shard murmur3 hash vindex matching cmd/pg-router shardSpec. +func reshardSpec(col string) v1alpha1.ShardRangeSpec { + return v1alpha1.ShardRangeSpec{ + Vindex: v1alpha1.VindexSpec{Type: v1alpha1.VindexTypeHash, Column: col, Function: "murmur3"}, + Ranges: []v1alpha1.ShardRangeEntry{ + {Lo: "0x00000000", Hi: "0x7fffffff", Shard: "shard-0"}, + {Lo: "0x80000000", Hi: "0xffffffff", Shard: "shard-1"}, + }, + } +} + func main() { src := os.Getenv("PGROUTER_SOURCE_DSN") tgt := os.Getenv("PGROUTER_TARGET_DSN") table := os.Getenv("PGROUTER_COPY_TABLE") + targetShard := os.Getenv("PGROUTER_RESHARD_TARGET_SHARD") if src == "" || tgt == "" || table == "" { fmt.Fprintln(os.Stderr, "reshard-copy-poc: PGROUTER_SOURCE_DSN/TARGET_DSN/COPY_TABLE required") os.Exit(2) @@ -31,11 +56,42 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - fmt.Printf("reshard-copy-poc: InitialCopy table=%q source→target\n", table) - n, err := router.CopyTable(ctx, src, tgt, table) + // Full copy (no target shard) vs range-filtered copy (the real split). + if targetShard == "" { + fmt.Printf("reshard-copy-poc: InitialCopy (full) table=%q source→target\n", table) + n, err := router.CopyTable(ctx, src, tgt, table) + if err != nil { + fmt.Fprintf(os.Stderr, "reshard-copy-poc: %v (copied %d before error)\n", err, n) + os.Exit(1) + } + fmt.Printf("reshard-copy-poc: copied %d row(s) source→target (rollback=drop target)\n", n) + return + } + + col := os.Getenv("PGROUTER_VINDEX_COLUMN") + if col == "" { + col = "id" + } + spec := reshardSpec(col) + fmt.Printf("reshard-copy-poc: InitialCopy (range) table=%q vindex=%s target=%s source→target\n", table, col, targetShard) + copied, scanned, err := router.CopyShardRange(ctx, src, tgt, table, spec, targetShard) + if err != nil { + fmt.Fprintf(os.Stderr, "reshard-copy-poc: %v (copied %d/%d before error)\n", err, copied, scanned) + os.Exit(1) + } + fmt.Printf("reshard-copy-poc: copied %d/%d row(s) (only %s keys) source→target (rollback=drop target)\n", + copied, scanned, targetShard) + + if os.Getenv("PGROUTER_RESHARD_DELETE_AFTER") == "" { + return + } + // Cutover cleanup: delete moved rows from source (run only after routing switch). + fmt.Printf("reshard-copy-poc: Cutover cleanup — deleting %s keys from source\n", targetShard) + deleted, err := router.DeleteShardRange(ctx, src, table, spec, targetShard) if err != nil { - fmt.Fprintf(os.Stderr, "reshard-copy-poc: %v (copied %d before error)\n", err, n) + fmt.Fprintf(os.Stderr, "reshard-copy-poc: delete: %v (deleted %d before error)\n", err, deleted) os.Exit(1) } - fmt.Printf("reshard-copy-poc: copied %d row(s) source→target (rollback=drop target)\n", n) + fmt.Printf("reshard-copy-poc: deleted %d row(s) from source (split complete: %s now owns its range)\n", + deleted, targetShard) } diff --git a/internal/router/reshard_copy.go b/internal/router/reshard_copy.go index fef9a9a..d7d55a9 100644 --- a/internal/router/reshard_copy.go +++ b/internal/router/reshard_copy.go @@ -21,6 +21,8 @@ import ( "strings" _ "github.com/lib/pq" // postgres driver + + "github.com/keiailab/postgres-operator/api/v1alpha1" ) // tableNamePattern 은 복사 대상 테이블 식별자 허용 문자 집합 (SQL injection 차단 — @@ -81,6 +83,155 @@ func CopyTable(ctx context.Context, sourceDSN, targetDSN, table string) (int, er return copied, nil } +// CopyShardRange 는 source 테이블에서 *vindex 키가 targetShard 로 해소되는 row 들만* +// target 으로 복사한다 (copied, scanned 반환). 이것이 진짜 resharding 데이터 이동이다 — +// split 은 새 shard 의 키 범위에 속하는 부분집합만 옮긴다. 라우팅과 *동일한 vindex* +// (ResolveShard)로 각 row 의 키를 평가하므로 cutover 후 라우팅과 데이터 위치가 일치한다. +// source 는 read-only(SELECT)만 — rollback 은 target 테이블 truncate/drop. +func CopyShardRange(ctx context.Context, sourceDSN, targetDSN, table string, spec v1alpha1.ShardRangeSpec, targetShard string) (copied, scanned int, err error) { + if !tableNamePattern.MatchString(table) { + return 0, 0, fmt.Errorf("%w: %q", ErrInvalidTable, table) + } + keyCol := spec.Vindex.Column + if !tableNamePattern.MatchString(keyCol) { + return 0, 0, fmt.Errorf("%w: vindex column %q", ErrInvalidTable, keyCol) + } + src, err := sql.Open("postgres", sourceDSN) + if err != nil { + return 0, 0, fmt.Errorf("router: open source: %w", err) + } + defer func() { _ = src.Close() }() + tgt, err := sql.Open("postgres", targetDSN) + if err != nil { + return 0, 0, fmt.Errorf("router: open target: %w", err) + } + defer func() { _ = tgt.Close() }() + + rows, err := src.QueryContext(ctx, "SELECT * FROM "+table) //nolint:gosec // table는 화이트리스트 검증됨 + if err != nil { + return 0, 0, fmt.Errorf("router: source select %s: %w", table, err) + } + defer func() { _ = rows.Close() }() + + cols, err := rows.Columns() + if err != nil { + return 0, 0, fmt.Errorf("router: columns %s: %w", table, err) + } + keyIdx := indexOfFold(cols, keyCol) + if keyIdx < 0 { + return 0, 0, fmt.Errorf("router: vindex column %q not found in table %q", keyCol, table) + } + insertSQL := buildInsert(table, cols) + + for rows.Next() { + vals := make([]any, len(cols)) + ptrs := make([]any, len(cols)) + for i := range vals { + ptrs[i] = &vals[i] + } + if err := rows.Scan(ptrs...); err != nil { + return copied, scanned, fmt.Errorf("router: scan %s: %w", table, err) + } + scanned++ + shard, err := ResolveShard(spec, keyString(vals[keyIdx])) + if err != nil { + return copied, scanned, fmt.Errorf("router: resolve key: %w", err) + } + if shard != targetShard { + continue // 이 키는 target shard 소속이 아님 — 건너뜀. + } + if _, err := tgt.ExecContext(ctx, insertSQL, vals...); err != nil { + return copied, scanned, fmt.Errorf("router: target insert %s: %w", table, err) + } + copied++ + } + if err := rows.Err(); err != nil { + return copied, scanned, fmt.Errorf("router: rows %s: %w", table, err) + } + return copied, scanned, nil +} + +// DeleteShardRange 는 cutover *완료 후* source 테이블에서 movedShard 로 이동한 키의 row 들을 +// 삭제한다(이동 키가 더는 이 shard 소속이 아니므로 정리). CopyShardRange 로 target 에 안전히 +// 복사되고 라우팅이 전환된 *뒤에만* 호출해야 한다 — 그 전엔 데이터 유실 위험. 삭제된 row 수 +// 반환. +func DeleteShardRange(ctx context.Context, sourceDSN, table string, spec v1alpha1.ShardRangeSpec, movedShard string) (int, error) { + if !tableNamePattern.MatchString(table) { + return 0, fmt.Errorf("%w: %q", ErrInvalidTable, table) + } + keyCol := spec.Vindex.Column + if !tableNamePattern.MatchString(keyCol) { + return 0, fmt.Errorf("%w: vindex column %q", ErrInvalidTable, keyCol) + } + src, err := sql.Open("postgres", sourceDSN) + if err != nil { + return 0, fmt.Errorf("router: open source: %w", err) + } + defer func() { _ = src.Close() }() + + rows, err := src.QueryContext(ctx, "SELECT DISTINCT "+keyCol+" FROM "+table) //nolint:gosec // keyCol 화이트리스트 검증됨 + if err != nil { + return 0, fmt.Errorf("router: distinct keys %s: %w", table, err) + } + var moving []any + for rows.Next() { + var v any + if err := rows.Scan(&v); err != nil { + _ = rows.Close() + return 0, fmt.Errorf("router: scan key %s: %w", table, err) + } + shard, err := ResolveShard(spec, keyString(v)) + if err != nil { + _ = rows.Close() + return 0, fmt.Errorf("router: resolve key: %w", err) + } + if shard == movedShard { + moving = append(moving, v) + } + } + _ = rows.Close() + if err := rows.Err(); err != nil { + return 0, fmt.Errorf("router: rows %s: %w", table, err) + } + + deleted := 0 + delSQL := "DELETE FROM " + table + " WHERE " + keyCol + " = $1" //nolint:gosec // 식별자 화이트리스트 검증됨 + for _, k := range moving { + res, err := src.ExecContext(ctx, delSQL, k) + if err != nil { + return deleted, fmt.Errorf("router: delete key in %s: %w", table, err) + } + n, _ := res.RowsAffected() + deleted += int(n) + } + return deleted, nil +} + +// keyString 은 row 값(any)을 vindex 키 문자열로 정규화한다 — lib/pq 는 text 를 []byte 로 +// 돌려주므로 string 변환(fmt.Sprint 면 "[97 ...]" 가 됨). 라우터의 키 추출(문자열)과 일치. +func keyString(v any) string { + switch x := v.(type) { + case nil: + return "" + case []byte: + return string(x) + case string: + return x + default: + return fmt.Sprint(x) + } +} + +// indexOfFold 는 cols 에서 name 과 대소문자 무시 일치하는 첫 인덱스(없으면 -1). +func indexOfFold(cols []string, name string) int { + for i, c := range cols { + if strings.EqualFold(c, name) { + return i + } + } + return -1 +} + // buildInsert 는 `INSERT INTO (c1,c2) VALUES ($1,$2)` 를 만든다. func buildInsert(table string, cols []string) string { placeholders := make([]string, len(cols)) diff --git a/internal/router/reshard_copy_test.go b/internal/router/reshard_copy_test.go index d90cc7f..956e021 100644 --- a/internal/router/reshard_copy_test.go +++ b/internal/router/reshard_copy_test.go @@ -10,8 +10,20 @@ import ( "context" "errors" "testing" + + "github.com/keiailab/postgres-operator/api/v1alpha1" ) +func specWithCol(col string) v1alpha1.ShardRangeSpec { + return v1alpha1.ShardRangeSpec{ + Vindex: v1alpha1.VindexSpec{Type: v1alpha1.VindexTypeHash, Column: col, Function: "murmur3"}, + Ranges: []v1alpha1.ShardRangeEntry{ + {Lo: "0x00000000", Hi: "0x7fffffff", Shard: "shard-0"}, + {Lo: "0x80000000", Hi: "0xffffffff", Shard: "shard-1"}, + }, + } +} + func TestBuildInsert(t *testing.T) { got := buildInsert("t", []string{"id", "v"}) want := "INSERT INTO t (id, v) VALUES ($1, $2)" @@ -29,3 +41,51 @@ func TestCopyTable_RejectsInjection(t *testing.T) { } } } + +// TestCopyShardRange_RejectsInjection 은 테이블/vindex 컬럼 이름 둘 다 화이트리스트로 +// 차단됨을 검증한다. +func TestCopyShardRange_RejectsInjection(t *testing.T) { + ctx := context.Background() + for _, bad := range []string{"t; DROP TABLE u", "t v", ""} { + if _, _, err := CopyShardRange(ctx, "", "", bad, specWithCol("id"), "shard-1"); !errors.Is(err, ErrInvalidTable) { + t.Errorf("CopyShardRange table=%q err = %v, want ErrInvalidTable", bad, err) + } + if _, _, err := CopyShardRange(ctx, "", "", "tbl", specWithCol(bad), "shard-1"); !errors.Is(err, ErrInvalidTable) { + t.Errorf("CopyShardRange col=%q err = %v, want ErrInvalidTable", bad, err) + } + if _, err := DeleteShardRange(ctx, "", bad, specWithCol("id"), "shard-1"); !errors.Is(err, ErrInvalidTable) { + t.Errorf("DeleteShardRange table=%q err = %v, want ErrInvalidTable", bad, err) + } + } +} + +func TestKeyString(t *testing.T) { + cases := []struct { + in any + want string + }{ + {nil, ""}, + {[]byte("alice"), "alice"}, + {"bob", "bob"}, + {int64(5), "5"}, + {42, "42"}, + } + for _, c := range cases { + if got := keyString(c.in); got != c.want { + t.Errorf("keyString(%v) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestIndexOfFold(t *testing.T) { + cols := []string{"ID", "Val"} + if i := indexOfFold(cols, "id"); i != 0 { + t.Errorf("indexOfFold id = %d, want 0", i) + } + if i := indexOfFold(cols, "VAL"); i != 1 { + t.Errorf("indexOfFold VAL = %d, want 1", i) + } + if i := indexOfFold(cols, "nope"); i != -1 { + t.Errorf("indexOfFold nope = %d, want -1", i) + } +} From 2b12c423fdf70a04536eb1da3e2f10d11c2a7174 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 01:44:26 +0900 Subject: [PATCH 063/109] =?UTF-8?q?docs:=20reference/read-replica=C2=B7res?= =?UTF-8?q?harding=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EB=B0=98=EC=98=81=20+=20=EC=9A=B0?= =?UTF-8?q?=EC=84=A0=EC=88=9C=EC=9C=84=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WORK_HANDOFF §6.6 + ROUTER-GAP-ANALYSIS §능력사다리: 읽기→replica·reference table·resharding 데이터이동 core 완료 표기. 새 우선순위 #1=ShardSplitJob 컨트롤러 K8s 결선, #2=bufio 라우터 최적화, #3=멀티머신 수평스케일 실측. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/WORK_HANDOFF.ko.md | 13 ++++++++----- docs/sharding/ROUTER-GAP-ANALYSIS.ko.md | 8 ++++---- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index 39265b0..df50df7 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -220,13 +220,16 @@ SSOT는 [ROUTER-GAP-ANALYSIS §4 능력 사다리 + §6 백로그](sharding/ROUT | ~~·~~ ✅ | ~~per-query 라우팅 (연결 종단 + 백엔드 풀)~~ | **완료(2026-06-28)** — `persession.go` 세션 루프가 *매* simple Query를 키의 샤드로 독립 라우팅(vtgate 모델) + 샤드별 백엔드 lazy 풀. 라이브 검증: 한 연결에서 alice→shard-0/bob→shard-1/carol→shard-0. scatter·단일샤드 tx pin 포함. | | ~~·~~ ✅ | ~~per-query extended protocol~~ | **완료(2026-06-28)** — `extsession.go`. extended(Parse/Bind/…)도 Sync까지 버퍼링→배치 per-query 라우팅, ParseComplete 합성+**샤드별 prepare-on-first-use**+주입분 필터. 라이브: lib/pq 한 연결+prepared `WHERE id=$1` 5회 다른키→키별 정확 라우팅. 구 pin-on-first 제거. | | ~~·~~ ✅ | ~~분산 성능 수치 1차~~ | **완료(2026-06-28)** — `cmd/router-bench` + baseline.md §3.0b. 라우터 점읽기 워커수×TPS 1761(w1)→9437(w32), 오버헤드 ~2.2~4×. **2샤드≈1샤드(point read)=라우터가 병목, 수평스케일은 멀티호스트 필요**. | -| **1** | **멀티호스트 수평 스케일 실증** | 단일 호스트에선 라우터·샤드 코어공유라 분산 이득 미관측. 멀티노드 kind(또는 분리 호스트)로 샤드를 별 코어에 두고 N-shard 처리량 스케일 측정 — "분산처리능력" 진짜 수치 | -| **2** | **라우터 오버헤드 개선 정량화** | prepared-stmt 재사용 벤치(키당 Parse 제거)로 라우터 처리량 상향 측정 + 코드 최적화 여지 | -| **4** | **B: resharding 실데이터 이동** | ShardSplitJob의 CopyTable DSN 결선 + CDC + write-block cutover (현재 골격) | -| **5** | reference table·read-replica **프록시 결선** | 부품은 완성, query-mode에 연결만 | +| ~~·~~ ✅ | ~~라우터 오버헤드 정량화~~ | **완료(2026-06-28)** — baseline §3.0c. prepared-stmt 재사용이 라우터 처리량 ~1.9×(9K→17K TPS) — 키당 Parse가 오버헤드 ~절반. | +| ~~·~~ ✅ | ~~수평 스케일 실증(단일 호스트)~~ | **완료(2026-06-28)** — baseline §3.0d. read/write 모든 워크로드 2샤드 ≤ 1샤드 확정. 단일 호스트 CPU·스토리지 공유라 물리적 불가 — 진짜 수치는 멀티머신 필요(방법 기록). | +| ~~·~~ ✅ | ~~reference table·read-replica 프록시 결선~~ | **완료(2026-06-28)** — main.go read resolver(env replica/status ResolveRead)+PGROUTER_REFERENCE_TABLES. 라이브: read→replica, ref→AnyShard, write→primary. | +| ~~·~~ ✅ | ~~resharding 실데이터 이동(core)~~ | **완료(2026-06-28)** — `CopyShardRange`(hash-range 필터 copy)+`DeleteShardRange`(cutover). 라이브: 1..100 split→44/56 overlap=0 키유실0. | +| **1** | **ShardSplitJob 컨트롤러 K8s 결선** | reconcile 가 pod DSN 으로 CopyShardRange/DeleteShardRange 를 phase 별 호출 + write-block(토폴로지 flip)까지. 데이터이동 core 는 완성 — K8s 통합만 남음 | +| **2** | **bufio 라우터 최적화** | per-query proxy 루프 미버퍼 syscall → 연결당 bufio 버퍼링(식별됨, baseline §3.0c). 회귀 테스트 후 적용 | +| **3** | **멀티머신 수평 스케일 실측** | 진짜 "분산처리능력" 수치 — 물리 분리 노드 필요(router-bench 가 샤드별 DSN 받음, 그대로 적용) | | **6** | 보류 #5/#7/#9 | per-shard primary Service·watch·failover lease P2-T3 — 라이브 failover 필요 | -> 제약 현황(query-mode 라이브): ✅ simple+extended per-query(prepared 포함)·scatter·단일샤드 tx·scram 백엔드. ❌ extended scatter·cross-shard 2PC·Flush 파이프라이닝. +> 제약 현황(query-mode 라이브): ✅ simple+extended per-query(prepared 포함)·scatter·단일샤드 tx·scram 백엔드·reference·read-replica. ❌ extended scatter·cross-shard 2PC·Flush 파이프라이닝. --- diff --git a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md index b9aa28c..2e82ebf 100644 --- a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md +++ b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md @@ -130,10 +130,10 @@ parameterized/`t.col`/주석·문자열 내부 오인 방지까지 검증. **기 - [~] **(E) 프로토콜 종단 — 쿼리 단위 라우팅**: **query-mode 라이브 PG end-to-end 검증 완료 (2026-06-27)** 🎉. 2 trust postgres(shard-0/1) + pg-router `PGROUTER_MODE=query` → psql `SELECT located_on FROM probe WHERE id='alice'` 이 키 추출→murmur3 vindex→샤드 라우팅→실 쿼리 실행→결과 반환까지 동작. **alice→shard-0 / bob→shard-1 / carol→shard-0** 결정적·올바른 분산 확인(bob이 다른 샤드 = 해시 라우팅 실증). 라이브 검증에서 프로토콜 버그(백엔드 핸드셰이크 미소비) 발견·수정. **종단 작업 진척**: ① **scram-sha-256 / cleartext 백엔드 인증 대행 ✅완료**(RFC 7677 단위검증 + 라이브 scram PG 검증, `crypto/pbkdf2` stdlib, `PGROUTER_BACKEND_PASSWORD`) — **query-mode가 trust 아닌 실 프로덕션 PG와 동작** ② **extended protocol ✅완료**: inline-literal + parameterized(`$N`) 라우팅. **describe-round 대행 구현**(`describeround.go`) — lib/pq·JDBC 의 `Parse→Describe→Sync→Bind` 를, 임의 샤드로 describe round 대행 후 Bind 파라미터로 실 샤드 라우팅(다르면 re-Parse + 중복 ParseComplete 필터). **라이브 검증: lib/pq `WHERE id=$1` 정상 라우팅**(alice→shard-0/bob→shard-1) ⇒ **실 DB 드라이버와 동작** ③ **멀티샤드 scatter forwarding ✅완료**(`scattermode.go`): 키 없는 simple Query를 모든 샤드에 *병렬* fan-out→결과 병합(UNION ALL), 라이브 검증(SELECT FROM probe→양샤드 6행). 집계 재merge·전역 ORDER BY·LIMIT pushdown은 후속 ④ **per-query 라우팅 + 백엔드 연결 풀 ✅완료 (2026-06-28)**: 연결 고정 한계 해소(아래) ⑤ ~~per-query 로그~~ ✅완료. > > **✅ 해소됨 — per-query 라우팅 (구 한계: 연결 고정)**: query-mode가 *첫 쿼리*로 연결을 한 샤드에 고정하던 한계를 해소했다(2026-06-28). `persession.go`의 `runPerQuerySession` 세션 루프가 한 연결의 *매* simple Query를 그 키의 샤드로 독립 라우팅하고(vtgate 모델), 샤드별 백엔드 연결을 세션 내에서 lazy 풀링·재사용한다. **라이브 검증(2 scram 샤드, 한 연결)**: `id='alice'`→shard-0, `id='bob'`→shard-1, `id='carol'`→shard-0 가 *같은 연결*에서 각각 올바른 샤드로 라우팅(로그상 4개 독립 `routed (Q)` 결정). 키 없는 쿼리는 scatter fan-out, 단일샤드 명시적 트랜잭션은 `BEGIN` 응답 합성 후 첫 키 쿼리로 한 샤드에 pin(COMMIT/ROLLBACK까지 그 백엔드 재사용) — 모두 검증. **extended protocol(Parse/Bind/Describe/Execute/Sync)도 per-query 완료(2026-06-28, `extsession.go`)** — Sync 까지 버퍼링해 배치 단위로 키의 샤드에 보내고, ParseComplete 를 합성해 ack 한 뒤 샤드별 prepare-on-first-use(저장된 Parse 주입 + 주입분 ParseComplete 필터)로 prepared statement 를 lazy 관리한다. 라이브 검증(lib/pq 한 연결 + prepared `WHERE id=$1` 5회 다른 키 → 키별 정확 라우팅). 구 pin-on-first(describe-round 단일 라운드)는 제거. *남은 범위*: cross-shard 2PC, extended scatter(키 없는 파이프라인 fan-out), Flush(H) 파이프라이닝. -- [ ] **읽기 → replica 라우팅**: `status.shards[].replicas[]`(Ready)로 읽기 쿼리를 분산 → primary 부하 경감 + 읽기 확장. 사다리 2단계. (status backend 계층 재사용.) -- [ ] **scatter-gather 실연결**: `scatter.go` 골격 → 실 wire-protocol fan-out + merge + 집계/정렬 pushdown. 사다리 2~3단계. -- [ ] **Reference table**: 전 샤드 복제로 분산 조인 우회. 사다리 4단계. 고가치·저난도. -- [ ] **무중단 resharding 데이터 이동**: `ShardSplitJob`의 InitialCopy/CDC 실결선(현재 골격, 데이터 미이동). `router.CopyTable` DSN 결선 + 논리복제 + write-block cutover. 사다리 5단계. +- [x] **읽기 → replica 라우팅** ✅(2026-06-28): main.go read resolver 결선 — env `PGROUTER_BACKEND__REPLICA`(없으면 primary fallback) / status `StatusBackendResolver.ResolveRead`(Ready replica, failover-aware). 라이브: read alice→shard-0 replica, write→primary. +- [x] **scatter-gather 실연결** ✅: `scattermode.go` 병렬 fan-out + UNION ALL 병합(라이브). 집계 재merge·전역 ORDER BY·LIMIT pushdown은 후속. +- [x] **Reference table** ✅(2026-06-28): `shardSpec` `PGROUTER_REFERENCE_TABLES`(CSV) → reference-only 쿼리가 scatter 대신 AnyShard. 라이브: `SELECT FROM country` → 한 샤드(scatter 아님). +- [~] **무중단 resharding 데이터 이동**: **데이터이동 core 완료(2026-06-28)** — `CopyShardRange`(vindex 키가 target shard 로 가는 row 만 hash-range 필터 복사, 라우팅과 동일 ResolveShard, reversible) + `DeleteShardRange`(cutover 후 source 정리). 라이브: 키 1..100 split → source 44(shard-0)/target 56(shard-1), overlap=0 키유실0. **남음**: ShardSplitJob 컨트롤러 reconcile 가 pod DSN 으로 phase 별 호출 + write-block(토폴로지 flip) K8s 통합. 논리복제 CDC(증분 catch-up)도 후속. - [ ] **cross-shard 2PC**: 사다리 6단계. **현재 명시적 범위 밖**(ROI 최저). 멀티테넌트 v1엔 불필요. - [ ] **커넥션 풀링 (D)**: `SQLShardExecutor`의 per-call `sql.Open`/`Close` 제거 → 풀 + prepared stmt 캐시. scatter 경로 성능. (단일샤드 TCP 프록시엔 불필요.) From 2af62e6d2a80b030f54bbd17570da73ed02615e5 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 08:41:41 +0900 Subject: [PATCH 064/109] =?UTF-8?q?perf(pg-router):=20bufio=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20=EB=B2=84=ED=8D=BC=20+=20=EB=8B=A8=EC=9D=BC=20write?= =?UTF-8?q?=20=E2=80=94=20per-query=20=EC=98=A4=EB=B2=84=ED=97=A4=EB=93=9C?= =?UTF-8?q?=20=EA=B0=90=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §3.0c 에서 식별한 라우터 오버헤드(동기 per-query proxy 의 미버퍼 syscall) 최적화: - writeMessage: 헤더(5B)+payload 를 한 버퍼로 합쳐 단일 Write — 메시지당 write syscall 2→1. 즉시 전송이라 deadlock 위험 없음. - bufConn(bufconn.go): 연결당 읽기 버퍼(bufio.Reader 32KiB)로 헤더+payload read syscall 2→1. 쓰기는 버퍼링하지 않고 통과 → flush 시점 관리 불필요, request/ response 교착 위험 0. query-mode client(handshake 후)·backend pool(auth 후)· scatter(auth 후)에 결선. connection-mode(blind io.Copy)는 미적용. 라이브(2 scram 샤드, baseline §3.0e): unprepared router-2shard w=32 8,955→13,391 (+50%), prepared router-1shard w=32 17,306→23,207(+34%). 효과는 메시지량에 비례 (읽기 syscall 많은 unprepared 경로 최대). 회귀 검증: per-query·scatter·tx·reference· replica 전부 정상(멈춤 없음 = flush 누락 없음). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- cmd/pg-router/bufconn.go | 33 +++++++++++++++++++++++++++++++++ cmd/pg-router/persession.go | 6 ++++-- cmd/pg-router/pgwire.go | 18 ++++++++---------- cmd/pg-router/querymode.go | 3 ++- cmd/pg-router/scattermode.go | 5 +++-- docs/perf/baseline.md | 24 ++++++++++++++++++++++++ 6 files changed, 74 insertions(+), 15 deletions(-) create mode 100644 cmd/pg-router/bufconn.go diff --git a/cmd/pg-router/bufconn.go b/cmd/pg-router/bufconn.go new file mode 100644 index 0000000..bb0d315 --- /dev/null +++ b/cmd/pg-router/bufconn.go @@ -0,0 +1,33 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +// bufconn.go 는 *읽기 버퍼링* 연결 래퍼다 — per-query 라우팅에서 메시지마다 발생하던 +// 헤더(5B)+payload 두 번의 read syscall 을, 연결당 하나의 bufio.Reader 로 모아 줄인다. +// 쓰기는 버퍼링하지 *않고* 그대로 통과시킨다(즉시 전송) — 따라서 flush 시점을 관리할 필요가 +// 없고 request/response 교착(deadlock) 위험이 없다. writeMessage 자체가 이미 메시지를 단일 +// Write 로 보내므로 쓰기 syscall 도 메시지당 1회다. +package main + +import ( + "bufio" + "net" +) + +// bufConn 은 net.Conn 에 읽기 전용 bufio.Reader 를 씌운다. Read 만 버퍼를 거치고, Write/ +// Close 등 나머지는 임베드된 net.Conn 으로 직행한다. +type bufConn struct { + net.Conn + br *bufio.Reader +} + +// newBufConn 은 conn 을 읽기 버퍼(32KiB)로 감싼다. *인증/핸드셰이크가 끝난 뒤* 호출해야 +// 한다 — 그래야 bufio.Reader 가 정확히 그 다음 바이트부터 읽는다(중간 바이트 유실 없음). +func newBufConn(conn net.Conn) *bufConn { + return &bufConn{Conn: conn, br: bufio.NewReaderSize(conn, 32<<10)} +} + +// Read 는 버퍼를 통해 읽는다(임베드 net.Conn.Read 를 가린다). +func (b *bufConn) Read(p []byte) (int, error) { return b.br.Read(p) } diff --git a/cmd/pg-router/persession.go b/cmd/pg-router/persession.go index 4df863e..fbc11b1 100644 --- a/cmd/pg-router/persession.go +++ b/cmd/pg-router/persession.go @@ -156,8 +156,10 @@ func (s *session) backendFor(backend string) (net.Conn, error) { _ = c.Close() return nil, err } - s.backends[backend] = c - return c, nil + // 핸드셰이크 후 읽기 버퍼로 감싼다(이후 응답 메시지 read syscall 절감). + bc := newBufConn(c) + s.backends[backend] = bc + return bc, nil } // execAndRelay 는 query 를 backend 로 보내고 응답을 ReadyForQuery 까지 클라이언트로 relay diff --git a/cmd/pg-router/pgwire.go b/cmd/pg-router/pgwire.go index a24b956..cccfaff 100644 --- a/cmd/pg-router/pgwire.go +++ b/cmd/pg-router/pgwire.go @@ -44,17 +44,15 @@ func readMessage(r io.Reader) (pgMessage, error) { return pgMessage{Type: hdr[0], Payload: payload}, nil } -// writeMessage 는 typed v3 메시지 1개를 쓴다 (길이는 자기 자신 포함 Int32). +// writeMessage 는 typed v3 메시지 1개를 쓴다 (길이는 자기 자신 포함 Int32). 헤더+payload 를 +// 한 버퍼로 합쳐 *단일* Write 로 보낸다 — 메시지당 syscall 2→1 (라우터 per-query 오버헤드 +// 감소). 버퍼링/flush 없이 즉시 전송하므로 deadlock 위험 없음. func writeMessage(w io.Writer, typ byte, payload []byte) error { - hdr := []byte{typ, 0, 0, 0, 0} - binary.BigEndian.PutUint32(hdr[1:5], uint32(4+len(payload))) - if _, err := w.Write(hdr); err != nil { - return err - } - if len(payload) == 0 { - return nil - } - _, err := w.Write(payload) + buf := make([]byte, 5+len(payload)) + buf[0] = typ + binary.BigEndian.PutUint32(buf[1:5], uint32(4+len(payload))) + copy(buf[5:], payload) + _, err := w.Write(buf) return err } diff --git a/cmd/pg-router/querymode.go b/cmd/pg-router/querymode.go index b276e6a..b2deaa5 100644 --- a/cmd/pg-router/querymode.go +++ b/cmd/pg-router/querymode.go @@ -135,7 +135,8 @@ func handleQueryMode(client net.Conn, qr queryRouter, dialer *backendDialer, ser return } // per-query 라우팅 세션: 매 simple Query 를 키의 샤드로 라우팅(연결 고정 해소). - runPerQuerySession(client, qr, dialer, backendPassword, raw) + // 클라이언트 연결을 읽기 버퍼로 감싼다(핸드셰이크 후 — 그 다음 메시지부터 버퍼링). + runPerQuerySession(newBufConn(client), qr, dialer, backendPassword, raw) } func logRoute(typ byte, d router.RouteDecision) { diff --git a/cmd/pg-router/scattermode.go b/cmd/pg-router/scattermode.go index 0e697be..fc9b4e9 100644 --- a/cmd/pg-router/scattermode.go +++ b/cmd/pg-router/scattermode.go @@ -97,10 +97,11 @@ func scatterOne(sb shardBackend, query pgMessage, raw []byte, dialer *backendDia if err := authenticateAndDrain(conn, password); err != nil { return shardResult{err: err} } - if err := writeMessage(conn, 'Q', query.Payload); err != nil { + bc := newBufConn(conn) // 핸드셰이크 후 읽기 버퍼로 감싼다. + if err := writeMessage(bc, 'Q', query.Payload); err != nil { return shardResult{err: err} } - rd, rows, errMsg, err := readQueryResult(conn) + rd, rows, errMsg, err := readQueryResult(bc) return shardResult{rowDesc: rd, rows: rows, errMsg: errMsg, err: err} } diff --git a/docs/perf/baseline.md b/docs/perf/baseline.md index 8611f89..c320022 100644 --- a/docs/perf/baseline.md +++ b/docs/perf/baseline.md @@ -217,6 +217,30 @@ BENCH_KEYS=10000 BENCH_DURATION=5s BENCH_WORKERS=1,2,4,8,16,32 BENCH_MODE=select 샤드를 띄우고 라우터를 별 머신에 두면, 같은 벤치를 그대로 가리켜 N-shard 스케일을 측정할 수 있다. (예: 샤드당 1 VM × 2 + 라우터 1 VM → 1-shard 대비 2-shard 처리량 비교.) +### 3.0e 실측 4차 — bufio 라우터 최적화 효과 (2026-06-28) + +§3.0c 에서 식별한 라우터 코드 최적화를 적용: ① `writeMessage` 를 헤더+payload 단일 Write +(메시지당 syscall 2→1) ② 연결당 읽기 버퍼링(`bufConn`, 쓰기는 즉시 전송이라 flush/deadlock +무관). §3.0b/c 와 동일 환경(2 scram 샤드, 단일 호스트). + +**before → after (router, TPS)**: + +| 워크로드 | w | before | after | 개선 | +|---|---|---|---|---| +| unprepared, router-2shard | 1 | 1,570 | 2,325 | +48% | +| unprepared, router-2shard | 32 | 8,955 | 13,391 | +50% | +| prepared, router-1shard | 32 | 17,306 | 23,207 | +34% | +| prepared, router-2shard | 32 | 17,090 | ~16,155 | ≈ (노이즈) | + +**해석**: +- **읽기 syscall 이 많은 경로(unprepared: 쿼리마다 Parse+Describe+Bind+Execute 메시지 다수) + 에서 ~1.5× 향상** — 메시지당 read syscall 2→1(버퍼) + write syscall 2→1(단일 Write). +- prepared(메시지 적음)는 1-shard +34%, 2-shard 는 측정 노이즈 범위. 효과는 메시지량에 비례. +- 버퍼링은 *읽기 전용* — 쓰기는 즉시 전송하므로 request/response 교착 위험 0(회귀: per-query· + scatter·tx·reference·replica 전부 정상). connection-mode(blind io.Copy)는 미적용. +- 남은 라우터 오버헤드(prepared direct 86K vs router 16~23K)는 프록시 1-hop 의 본질적 왕복 + 지연 — 멀티 라우터 인스턴스로 수평 확장하는 영역. + ### 3.1 Single-shard baseline (T-1S) | date | workload | iso | clients | TPS | P50 ms | P95 ms | P99 ms | comment | From 5f7ff6c679f9ad3a0e9f935b1b8e12a666bb39c1 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 08:42:47 +0900 Subject: [PATCH 065/109] =?UTF-8?q?docs:=20bufio=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=84=B0=20=EC=B5=9C=EC=A0=81=ED=99=94=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20+=20=EC=9A=B0=EC=84=A0=EC=88=9C=EC=9C=84?= =?UTF-8?q?=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WORK_HANDOFF §6.6: bufio 완료(unprepared +50%, prepared 1shard +34%). 새 우선순위 #1=ShardSplitJob 컨트롤러 K8s 결선, #3=멀티머신 수평스케일, #4=멀티 라우터 인스턴스. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/WORK_HANDOFF.ko.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index df50df7..c959b3e 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -224,9 +224,10 @@ SSOT는 [ROUTER-GAP-ANALYSIS §4 능력 사다리 + §6 백로그](sharding/ROUT | ~~·~~ ✅ | ~~수평 스케일 실증(단일 호스트)~~ | **완료(2026-06-28)** — baseline §3.0d. read/write 모든 워크로드 2샤드 ≤ 1샤드 확정. 단일 호스트 CPU·스토리지 공유라 물리적 불가 — 진짜 수치는 멀티머신 필요(방법 기록). | | ~~·~~ ✅ | ~~reference table·read-replica 프록시 결선~~ | **완료(2026-06-28)** — main.go read resolver(env replica/status ResolveRead)+PGROUTER_REFERENCE_TABLES. 라이브: read→replica, ref→AnyShard, write→primary. | | ~~·~~ ✅ | ~~resharding 실데이터 이동(core)~~ | **완료(2026-06-28)** — `CopyShardRange`(hash-range 필터 copy)+`DeleteShardRange`(cutover). 라이브: 1..100 split→44/56 overlap=0 키유실0. | -| **1** | **ShardSplitJob 컨트롤러 K8s 결선** | reconcile 가 pod DSN 으로 CopyShardRange/DeleteShardRange 를 phase 별 호출 + write-block(토폴로지 flip)까지. 데이터이동 core 는 완성 — K8s 통합만 남음 | -| **2** | **bufio 라우터 최적화** | per-query proxy 루프 미버퍼 syscall → 연결당 bufio 버퍼링(식별됨, baseline §3.0c). 회귀 테스트 후 적용 | +| ~~·~~ ✅ | ~~bufio 라우터 최적화~~ | **완료(2026-06-28)** — writeMessage 단일 write + 연결당 읽기 버퍼(bufConn). baseline §3.0e: unprepared +50%(8955→13391), prepared 1shard +34%. | +| **1** | **ShardSplitJob 컨트롤러 K8s 결선** | reconcile 가 pod DSN 으로 CopyShardRange/DeleteShardRange 를 phase 별 호출 + write-block(토폴로지 flip)까지. 데이터이동 core 는 완성 — 자격/DSN 이 operator secret 모델과 얽혀 풀 클러스터 e2e 필요(큰 작업) | | **3** | **멀티머신 수평 스케일 실측** | 진짜 "분산처리능력" 수치 — 물리 분리 노드 필요(router-bench 가 샤드별 DSN 받음, 그대로 적용) | +| **4** | **멀티 라우터 인스턴스 수평확장** | 라우터 1-hop 왕복이 남은 오버헤드(prepared direct 86K vs router 23K) — 라우터를 여러 개 띄워 처리량 확장 | | **6** | 보류 #5/#7/#9 | per-shard primary Service·watch·failover lease P2-T3 — 라이브 failover 필요 | > 제약 현황(query-mode 라이브): ✅ simple+extended per-query(prepared 포함)·scatter·단일샤드 tx·scram 백엔드·reference·read-replica. ❌ extended scatter·cross-shard 2PC·Flush 파이프라이닝. From e33d9d76b55e4b77174ca351406530fdf0fed513 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 09:27:01 +0900 Subject: [PATCH 066/109] =?UTF-8?q?feat(controller):=20ShardSplitJob=20Ini?= =?UTF-8?q?tialCopy=20=EC=8B=A4=20=EA=B2=B0=EC=84=A0=20(K8s=20Job=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShardSplitJob 의 InitialCopy phase 가 실제 데이터를 옮긴다 — 컨트롤러가 클러스터 내부에 복사 Job 을 띄워 source→target 데이터를 vindex 기준으로 이동하고 완료까지 phase 를 게이트한다. 실행 모델: 오퍼레이터는 in-pod local DSN 모델이라 컨트롤러가 직접 PG 접속을 하지 않는다. 대신 InitialCopy 가 target 별 K8s Job(reshard-copy-poc 이미지)을 생성 — Job Pod 가 클러스터 내부에서 source/target shard 에 접속해 복사한다. pg_hba 가 postgres superuser 를 내부 사설 IP 에서 trust 하므로 자격증명 불요(무비밀번호). - shardsplitjob_copy.go: reconcileInitialCopy(target 별 Job 멱등 생성 + 완료 집계), buildReshardCopyJob(SOURCE/TARGET DSN·vindex·ranges·refTables env), 내부 trust DSN/pod-DNS 빌더, keyspaceVindex(ShardRange 에서 vindex 조회). - controller: Reconcile 에 InitialCopy 블록(Job 완료 전 requeue, 실패 시 Failed, 완료 시 CDCCatchup 전이) + Owns(Job) + batch/jobs RBAC. - router.ListUserTables/FilterTables: source 의 user 테이블 발견(reference 제외) — resharding 은 모든 샤딩 테이블을 옮긴다. - reshard-copy-poc: COPY_TABLE 미지정 시 전 테이블 자동 발견·복사, PGROUTER_RANGES/ VINDEX_FUNCTION 로 임의 split 토폴로지 수용(컨트롤러가 주입). 검증: envtest(controller) — InitialCopy 가 target 별 Job 을 올바른 DSN(trust, 무비번)· vindex·ranges env 로 생성, 완료 집계(done), Job 실패→failure 보고. 단위테스트 (FilterTables). 데이터 이동 자체는 reshard-copy-poc 로 이미 라이브 증명(키 1..100 split overlap=0). 남은 것: 멀티샤드 클러스터 full e2e + Cutover write-block + Cleanup row-delete Job + CDC 증분. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- cmd/reshard-copy-poc/main.go | 101 +++++++-- .../controller/shardsplitjob_controller.go | 30 ++- internal/controller/shardsplitjob_copy.go | 197 ++++++++++++++++++ .../controller/shardsplitjob_copy_test.go | 165 +++++++++++++++ internal/router/reshard_copy.go | 41 ++++ internal/router/reshard_copy_test.go | 14 ++ 6 files changed, 526 insertions(+), 22 deletions(-) create mode 100644 internal/controller/shardsplitjob_copy.go create mode 100644 internal/controller/shardsplitjob_copy_test.go diff --git a/cmd/reshard-copy-poc/main.go b/cmd/reshard-copy-poc/main.go index 23255ea..3f210de 100644 --- a/cmd/reshard-copy-poc/main.go +++ b/cmd/reshard-copy-poc/main.go @@ -27,23 +27,48 @@ import ( "context" "fmt" "os" + "strings" "time" "github.com/keiailab/postgres-operator/api/v1alpha1" "github.com/keiailab/postgres-operator/internal/router" ) -// reshardSpec is the 2-shard murmur3 hash vindex matching cmd/pg-router shardSpec. -func reshardSpec(col string) v1alpha1.ShardRangeSpec { - return v1alpha1.ShardRangeSpec{ - Vindex: v1alpha1.VindexSpec{Type: v1alpha1.VindexTypeHash, Column: col, Function: "murmur3"}, - Ranges: []v1alpha1.ShardRangeEntry{ +// reshardSpec builds the post-split vindex spec. The ranges come from +// PGROUTER_RANGES ("shard:lo:hi,shard:lo:hi") when set (controller passes the +// target topology), else the default 2-shard murmur3 split (standalone use). +// fn is the vindex function from PGROUTER_VINDEX_FUNCTION (default murmur3). +func reshardSpec(col, fn, rangesEnv string) v1alpha1.ShardRangeSpec { + if fn == "" { + fn = "murmur3" + } + ranges := parseRanges(rangesEnv) + if len(ranges) == 0 { + ranges = []v1alpha1.ShardRangeEntry{ {Lo: "0x00000000", Hi: "0x7fffffff", Shard: "shard-0"}, {Lo: "0x80000000", Hi: "0xffffffff", Shard: "shard-1"}, - }, + } + } + return v1alpha1.ShardRangeSpec{ + Vindex: v1alpha1.VindexSpec{Type: v1alpha1.VindexTypeHash, Column: col, Function: v1alpha1.VindexHashFunction(fn)}, + Ranges: ranges, } } +// parseRanges parses "shard:lo:hi,shard:lo:hi" into ShardRangeEntry list. +func parseRanges(s string) []v1alpha1.ShardRangeEntry { + var out []v1alpha1.ShardRangeEntry + for _, part := range csv(s) { + f := strings.Split(part, ":") + if len(f) != 3 { + fmt.Fprintf(os.Stderr, "reshard-copy-poc: bad range %q (want shard:lo:hi)\n", part) + os.Exit(2) + } + out = append(out, v1alpha1.ShardRangeEntry{Shard: f[0], Lo: f[1], Hi: f[2]}) + } + return out +} + func main() { src := os.Getenv("PGROUTER_SOURCE_DSN") tgt := os.Getenv("PGROUTER_TARGET_DSN") @@ -72,26 +97,60 @@ func main() { if col == "" { col = "id" } - spec := reshardSpec(col) - fmt.Printf("reshard-copy-poc: InitialCopy (range) table=%q vindex=%s target=%s source→target\n", table, col, targetShard) - copied, scanned, err := router.CopyShardRange(ctx, src, tgt, table, spec, targetShard) - if err != nil { - fmt.Fprintf(os.Stderr, "reshard-copy-poc: %v (copied %d/%d before error)\n", err, copied, scanned) - os.Exit(1) + spec := reshardSpec(col, os.Getenv("PGROUTER_VINDEX_FUNCTION"), os.Getenv("PGROUTER_RANGES")) + + // 옮길 테이블 결정: COPY_TABLE 지정 시 그 하나, 아니면 source 의 모든 user 테이블에서 + // reference 테이블(PGROUTER_REFERENCE_TABLES, 전 샤드 복제라 이동 대상 아님)을 뺀 전부. + var tables []string + if table != "" { + tables = []string{table} + } else { + all, err := router.ListUserTables(ctx, src) + if err != nil { + fmt.Fprintf(os.Stderr, "reshard-copy-poc: list tables: %v\n", err) + os.Exit(1) + } + tables = router.FilterTables(all, csv(os.Getenv("PGROUTER_REFERENCE_TABLES"))) + fmt.Printf("reshard-copy-poc: discovered %d table(s) to reshard: %v\n", len(tables), tables) + } + + for _, tbl := range tables { + fmt.Printf("reshard-copy-poc: InitialCopy (range) table=%q vindex=%s target=%s source→target\n", tbl, col, targetShard) + copied, scanned, err := router.CopyShardRange(ctx, src, tgt, tbl, spec, targetShard) + if err != nil { + fmt.Fprintf(os.Stderr, "reshard-copy-poc: %v (copied %d/%d before error)\n", err, copied, scanned) + os.Exit(1) + } + fmt.Printf("reshard-copy-poc: copied %d/%d row(s) of %q (only %s keys) source→target\n", + copied, scanned, tbl, targetShard) } - fmt.Printf("reshard-copy-poc: copied %d/%d row(s) (only %s keys) source→target (rollback=drop target)\n", - copied, scanned, targetShard) if os.Getenv("PGROUTER_RESHARD_DELETE_AFTER") == "" { return } // Cutover cleanup: delete moved rows from source (run only after routing switch). - fmt.Printf("reshard-copy-poc: Cutover cleanup — deleting %s keys from source\n", targetShard) - deleted, err := router.DeleteShardRange(ctx, src, table, spec, targetShard) - if err != nil { - fmt.Fprintf(os.Stderr, "reshard-copy-poc: delete: %v (deleted %d before error)\n", err, deleted) - os.Exit(1) + for _, tbl := range tables { + fmt.Printf("reshard-copy-poc: Cutover cleanup — deleting %s keys from %q in source\n", targetShard, tbl) + deleted, err := router.DeleteShardRange(ctx, src, tbl, spec, targetShard) + if err != nil { + fmt.Fprintf(os.Stderr, "reshard-copy-poc: delete: %v (deleted %d before error)\n", err, deleted) + os.Exit(1) + } + fmt.Printf("reshard-copy-poc: deleted %d row(s) of %q from source\n", deleted, tbl) + } + fmt.Printf("reshard-copy-poc: split complete — %s now owns its range across %d table(s)\n", targetShard, len(tables)) +} + +// csv 는 콤마 구분 문자열을 trim 해 분리한다(빈 값 제거). +func csv(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + var out []string + for _, p := range strings.Split(s, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } } - fmt.Printf("reshard-copy-poc: deleted %d row(s) from source (split complete: %s now owns its range)\n", - deleted, targetShard) + return out } diff --git a/internal/controller/shardsplitjob_controller.go b/internal/controller/shardsplitjob_controller.go index 5c00900..661ee9b 100644 --- a/internal/controller/shardsplitjob_controller.go +++ b/internal/controller/shardsplitjob_controller.go @@ -9,8 +9,10 @@ package controller import ( "context" "fmt" + "time" appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" @@ -46,6 +48,7 @@ type ShardSplitJobReconciler struct { // +kubebuilder:rbac:groups=postgres.keiailab.io,resources=postgresclusters,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=configmaps;services,verbs=get;list;watch;create;update;patch // +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;create;update;patch +// +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete // Reconcile 은 ShardSplitJob 의 다음 phase 로 한 단계 전이한다 (즉시 requeue 로 진행). func (r *ShardSplitJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { @@ -83,6 +86,29 @@ func (r *ShardSplitJobReconciler) Reconcile(ctx context.Context, req ctrl.Reques } } + // InitialCopy phase: source→target 데이터 복사 Job 을 띄우고 완료를 기다린다. 완료 + // 전엔 phase 를 전이하지 않고 requeue 한다(가역 — Job 실패 시 Failed, target drop 으로 + // rollback). 실 데이터 이동이 끝나야 CDCCatchup/Cutover 가 의미를 가진다. + if ssj.Status.Phase == postgresv1alpha1.ShardSplitPhaseInitialCopy { + done, failure, err := r.reconcileInitialCopy(ctx, &ssj) + if err != nil { + return ctrl.Result{}, err // 전이 가능(ShardRange 부재 등) — backoff requeue. + } + if failure != "" { + ssj.Status.Phase = postgresv1alpha1.ShardSplitPhaseFailed + ssj.Status.FailureReason = failure + now := metav1.Now() + ssj.Status.CompletedAt = &now + ssj.Status.ObservedGeneration = ssj.Generation + _ = r.Status().Update(ctx, &ssj) + return ctrl.Result{}, nil + } + if !done { + return ctrl.Result{RequeueAfter: 3 * time.Second}, nil // 복사 Job 대기. + } + // 복사 완료 → 아래 nextPhase 가 CDCCatchup 으로 전이. + } + if ssj.Status.Phase == postgresv1alpha1.ShardSplitPhaseRoutingUpdate { if err := r.applyRouting(ctx, &ssj); err != nil { ssj.Status.Phase = postgresv1alpha1.ShardSplitPhaseFailed @@ -146,7 +172,8 @@ func (r *ShardSplitJobReconciler) nextPhase(ssj *postgresv1alpha1.ShardSplitJob) case postgresv1alpha1.ShardSplitPhaseBootstrap: return postgresv1alpha1.ShardSplitPhaseInitialCopy, "" case postgresv1alpha1.ShardSplitPhaseInitialCopy: - // 실 데이터 이동은 router.CopyTable(#215, 가역). DSN 결선은 별 트랙. + // 실 데이터 이동(복사 Job)은 Reconcile 의 InitialCopy 블록이 완료까지 게이트한 뒤 + // 이 전이에 도달한다(shardsplitjob_copy.go reconcileInitialCopy). 가역. return postgresv1alpha1.ShardSplitPhaseCDCCatchup, "" case postgresv1alpha1.ShardSplitPhaseCDCCatchup: return postgresv1alpha1.ShardSplitPhaseCutover, "" @@ -270,6 +297,7 @@ func containerImage(sts *appsv1.StatefulSet, name string) string { func (r *ShardSplitJobReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&postgresv1alpha1.ShardSplitJob{}). + Owns(&batchv1.Job{}). // InitialCopy 복사 Job 완료 시 재조정. Named("shardsplitjob"). Complete(r) } diff --git a/internal/controller/shardsplitjob_copy.go b/internal/controller/shardsplitjob_copy.go new file mode 100644 index 0000000..2057b07 --- /dev/null +++ b/internal/controller/shardsplitjob_copy.go @@ -0,0 +1,197 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package controller + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + postgresv1alpha1 "github.com/keiailab/postgres-operator/api/v1alpha1" +) + +// shardsplitjob_copy.go 는 ShardSplitJob 의 *InitialCopy* phase 실 결선이다 — source shard 의 +// 데이터에서 각 target shard 의 키 범위에 속하는 row 를 target 으로 복사하는 K8s Job 을 +// 생성하고 완료를 감시한다. +// +// 실행 모델: 컨트롤러가 직접 PG 에 접속하지 않고(오퍼레이터는 in-pod local DSN 모델), +// 클러스터 내부에 *Job* 을 띄워 그 Pod 가 source/target shard 에 접속해 복사한다. pg_hba 가 +// `postgres` superuser 를 클러스터 내부 사설 IP 에서 trust 하므로(builders.go renderPGHBAConf) +// Job 은 *자격증명 없이* 접속한다. Job 이미지(reshard-copy-poc)는 router.CopyShardRange 로 +// vindex(라우팅과 동일) 기준 부분집합만 복사한다 — 가역(rollback=target drop). +// +// Cutover 후 source 정리(이동 row 삭제)는 별도(Cleanup) 트랙. CDC 증분 catch-up 도 후속. + +// reshardCopyImage 는 InitialCopy Job 이 실행할 이미지다 (RESHARD_COPY_IMAGE 로 주입, +// 미설정 시 기본값 — kind 등에서 load 한 로컬 태그). +func reshardCopyImage() string { + if v := os.Getenv("RESHARD_COPY_IMAGE"); v != "" { + return v + } + return "ghcr.io/keiailab/reshard-copy:dev" +} + +// reshardCopyJobName 은 target shard 별 InitialCopy Job 이름(결정적 — 멱등 생성). +func reshardCopyJobName(cluster, shardID string) string { + return fmt.Sprintf("%s-rsd-copy-%s", cluster, shardID) +} + +// internalShardDSN 은 클러스터 내부 trust DSN 을 만든다(postgres superuser, 무비밀번호 — +// pg_hba 가 내부 사설 IP 의 postgres 를 trust). +func internalShardDSN(podDNS string) string { + return fmt.Sprintf("host=%s port=%d user=postgres dbname=postgres sslmode=disable", podDNS, pgPort) +} + +// sourceShardPodDNS 는 ordinal source shard(`shard-N`)의 primary pod(-0) 안정 DNS 를 만든다. +func sourceShardPodDNS(cluster, ns, shardID string) (string, error) { + if !strings.HasPrefix(shardID, "shard-") { + return "", fmt.Errorf("source shard %q is not ordinal (want shard-N)", shardID) + } + ord, err := strconv.Atoi(strings.TrimPrefix(shardID, "shard-")) + if err != nil { + return "", fmt.Errorf("source shard %q: %w", shardID, err) + } + pod := ShardStatefulSetName(cluster, int32(ord)) + "-0" + return fmt.Sprintf("%s.%s.%s.svc.cluster.local", pod, ShardServiceName(cluster, int32(ord)), ns), nil +} + +// targetShardPodDNS 는 resharding target shard 의 primary pod(-0) 안정 DNS 를 만든다. +func targetShardPodDNS(cluster, ns, shardID string) string { + pod := TargetShardStatefulSetName(cluster, shardID) + "-0" + return fmt.Sprintf("%s.%s.%s.svc.cluster.local", pod, TargetShardServiceName(cluster, shardID), ns) +} + +// rangesEnvValue 는 target 들의 키 범위를 `shard:lo:hi,...` 로 직렬화한다(Job 의 +// PGROUTER_RANGES — post-split 토폴로지를 reshard-copy-poc 에 전달). +func rangesEnvValue(targets []postgresv1alpha1.ShardSplitTarget) string { + var parts []string + for _, t := range targets { + for _, r := range t.Ranges { + parts = append(parts, fmt.Sprintf("%s:%s:%s", r.Shard, r.Lo, r.Hi)) + } + } + return strings.Join(parts, ",") +} + +// keyspaceVindex 는 cluster/keyspace 의 ShardRange 에서 vindex 컬럼·함수·reference 테이블을 +// 읽는다 (copy 가 라우팅과 동일 vindex 를 쓰도록). +func (r *ShardSplitJobReconciler) keyspaceVindex(ctx context.Context, ns, cluster, keyspace string) (col, fn string, refTables []string, err error) { + var list postgresv1alpha1.ShardRangeList + if err := r.List(ctx, &list, client.InNamespace(ns)); err != nil { + return "", "", nil, fmt.Errorf("list ShardRange: %w", err) + } + for i := range list.Items { + sr := &list.Items[i] + if sr.Spec.Cluster == cluster && sr.Spec.Keyspace == keyspace { + return sr.Spec.Vindex.Column, string(sr.Spec.Vindex.Function), sr.Spec.ReferenceTables, nil + } + } + return "", "", nil, fmt.Errorf("no ShardRange for cluster=%s keyspace=%s", cluster, keyspace) +} + +// buildReshardCopyJob 은 한 target shard 의 InitialCopy Job 을 만든다. +func (r *ShardSplitJobReconciler) buildReshardCopyJob(ssj *postgresv1alpha1.ShardSplitJob, sourceDSN, targetDSN, targetShard, col, fn, ranges string, refTables []string) *batchv1.Job { + backoff := int32(2) + return &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: reshardCopyJobName(ssj.Spec.Cluster, targetShard), + Namespace: ssj.Namespace, + Labels: ReshardTargetSelectorLabels(ssj.Spec.Cluster, targetShard), + }, + Spec: batchv1.JobSpec{ + BackoffLimit: &backoff, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: ReshardTargetSelectorLabels(ssj.Spec.Cluster, targetShard)}, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{{ + Name: "reshard-copy", + Image: reshardCopyImage(), + Env: []corev1.EnvVar{ + {Name: "PGROUTER_SOURCE_DSN", Value: sourceDSN}, + {Name: "PGROUTER_TARGET_DSN", Value: targetDSN}, + {Name: "PGROUTER_RESHARD_TARGET_SHARD", Value: targetShard}, + {Name: "PGROUTER_VINDEX_COLUMN", Value: col}, + {Name: "PGROUTER_VINDEX_FUNCTION", Value: fn}, + {Name: "PGROUTER_RANGES", Value: ranges}, + {Name: "PGROUTER_REFERENCE_TABLES", Value: strings.Join(refTables, ",")}, + // COPY_TABLE 미설정 → 모든 user 테이블 발견·복사. DELETE_AFTER 미설정 + // → 가역(삭제는 Cleanup 트랙). + }, + }}, + }, + }, + }, + } +} + +// reconcileInitialCopy 는 각 target 의 복사 Job 을 멱등 생성하고 완료를 집계한다. +// 반환: done(전부 성공), failure(한 Job 이라도 실패 시 사유), err(전이 가능 — requeue). +func (r *ShardSplitJobReconciler) reconcileInitialCopy(ctx context.Context, ssj *postgresv1alpha1.ShardSplitJob) (done bool, failure string, err error) { + if len(ssj.Spec.Sources) == 0 { + return false, "InitialCopy: no source shard", nil + } + col, fn, refTables, err := r.keyspaceVindex(ctx, ssj.Namespace, ssj.Spec.Cluster, ssj.Spec.Keyspace) + if err != nil { + return false, "", err // ShardRange 아직 부재 등 — requeue. + } + srcDNS, err := sourceShardPodDNS(ssj.Spec.Cluster, ssj.Namespace, ssj.Spec.Sources[0]) + if err != nil { + return false, "InitialCopy: " + err.Error(), nil // 설정 오류 — Failed. + } + ranges := rangesEnvValue(ssj.Spec.Targets) + + allDone := true + for i := range ssj.Spec.Targets { + t := &ssj.Spec.Targets[i] + name := reshardCopyJobName(ssj.Spec.Cluster, t.ShardID) + var job batchv1.Job + getErr := r.Get(ctx, client.ObjectKey{Namespace: ssj.Namespace, Name: name}, &job) + switch { + case apierrors.IsNotFound(getErr): + j := r.buildReshardCopyJob(ssj, + internalShardDSN(srcDNS), + internalShardDSN(targetShardPodDNS(ssj.Spec.Cluster, ssj.Namespace, t.ShardID)), + t.ShardID, col, fn, ranges, refTables) + if err := controllerutil.SetControllerReference(ssj, j, r.Scheme); err != nil { + return false, "", err + } + if err := r.Create(ctx, j); err != nil { + return false, "", err + } + allDone = false + case getErr != nil: + return false, "", getErr + case job.Status.Succeeded > 0: + // 이 target 완료. + case jobFailed(&job): + return false, fmt.Sprintf("InitialCopy: copy job %s failed", name), nil + default: + allDone = false // 진행 중. + } + } + return allDone, "", nil +} + +// jobFailed 는 Job 이 backoffLimit 소진으로 Failed condition 인지 본다. +func jobFailed(job *batchv1.Job) bool { + for _, c := range job.Status.Conditions { + if c.Type == batchv1.JobFailed && c.Status == corev1.ConditionTrue { + return true + } + } + return false +} diff --git a/internal/controller/shardsplitjob_copy_test.go b/internal/controller/shardsplitjob_copy_test.go new file mode 100644 index 0000000..b0a8cb4 --- /dev/null +++ b/internal/controller/shardsplitjob_copy_test.go @@ -0,0 +1,165 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package controller + +import ( + "context" + "fmt" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + + postgresv1alpha1 "github.com/keiailab/postgres-operator/api/v1alpha1" +) + +// 본 파일은 ShardSplitJob InitialCopy phase 의 *복사 Job 결선* 을 envtest 로 검증한다: +// reconcileInitialCopy 가 각 target 의 복사 Job 을 올바른 DSN/vindex/ranges env 로 생성하고, +// Job 완료를 집계해 done 을 보고하며, Job 실패를 failure 로 보고하는지. + +var _ = Describe("ShardSplitJob InitialCopy 복사 Job 결선", func() { + ctx := context.Background() + const ns = "default" + + envOf := func(c corev1.Container, key string) string { + for _, e := range c.Env { + if e.Name == key { + return e.Value + } + } + return "" + } + + It("각 target 의 복사 Job 을 올바른 env 로 생성하고 완료를 집계한다", func() { + clusterName := fmt.Sprintf("rsdcopy-%d", GinkgoRandomSeed()) + keyspace := "default" + + // vindex 출처가 되는 ShardRange. + sr := &postgresv1alpha1.ShardRange{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-sr", Namespace: ns}, + Spec: postgresv1alpha1.ShardRangeSpec{ + Cluster: clusterName, + Keyspace: keyspace, + Vindex: postgresv1alpha1.VindexSpec{Type: postgresv1alpha1.VindexTypeHash, Column: "id", Function: "murmur3"}, + ReferenceTables: []string{"country"}, + Ranges: []postgresv1alpha1.ShardRangeEntry{ + {Lo: "0x00000000", Hi: "0xffffffff", Shard: "shard-0"}, + }, + }, + } + Expect(k8sClient.Create(ctx, sr)).To(Succeed()) + + ssj := &postgresv1alpha1.ShardSplitJob{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-ssj", Namespace: ns}, + Spec: postgresv1alpha1.ShardSplitJobSpec{ + Cluster: clusterName, + Keyspace: keyspace, + Sources: []string{"shard-0"}, + Targets: []postgresv1alpha1.ShardSplitTarget{ + {ShardID: "t0", Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0x7fffffff", Shard: "t0"}}}, + {ShardID: "t1", Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x80000000", Hi: "0xffffffff", Shard: "t1"}}}, + }, + }, + } + Expect(k8sClient.Create(ctx, ssj)).To(Succeed()) + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(ssj), ssj)).To(Succeed()) // UID 채우기. + + r := &ShardSplitJobReconciler{Client: k8sClient, Scheme: scheme.Scheme} + + // 1) 첫 호출: Job 2개 생성, 아직 미완료(done=false). + done, failure, err := r.reconcileInitialCopy(ctx, ssj) + Expect(err).NotTo(HaveOccurred()) + Expect(failure).To(BeEmpty()) + Expect(done).To(BeFalse()) + + // 2) 각 target Job 이 올바른 env 로 생성됐는지. + for _, shardID := range []string{"t0", "t1"} { + var job batchv1.Job + Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: reshardCopyJobName(clusterName, shardID)}, &job)). + To(Succeed(), "target %s 복사 Job 이 생성돼야 함", shardID) + c := job.Spec.Template.Spec.Containers[0] + Expect(envOf(c, "PGROUTER_RESHARD_TARGET_SHARD")).To(Equal(shardID)) + Expect(envOf(c, "PGROUTER_VINDEX_COLUMN")).To(Equal("id")) + Expect(envOf(c, "PGROUTER_VINDEX_FUNCTION")).To(Equal("murmur3")) + Expect(envOf(c, "PGROUTER_REFERENCE_TABLES")).To(Equal("country")) + Expect(envOf(c, "PGROUTER_RANGES")).To(And(ContainSubstring("t0:0x00000000:0x7fffffff"), ContainSubstring("t1:0x80000000:0xffffffff"))) + Expect(envOf(c, "PGROUTER_SOURCE_DSN")).To(ContainSubstring(ShardServiceName(clusterName, 0))) + Expect(envOf(c, "PGROUTER_TARGET_DSN")).To(ContainSubstring(TargetShardServiceName(clusterName, shardID))) + Expect(envOf(c, "PGROUTER_SOURCE_DSN")).To(ContainSubstring("user=postgres")) // trust, no password + Expect(strings.Contains(envOf(c, "PGROUTER_SOURCE_DSN"), "password=")).To(BeFalse()) + Expect(job.OwnerReferences).To(HaveLen(1)) + Expect(job.OwnerReferences[0].Name).To(Equal(ssj.Name)) + } + + // 3) 멱등성: 재호출이 중복 생성 없이 여전히 done=false. + done, _, err = r.reconcileInitialCopy(ctx, ssj) + Expect(err).NotTo(HaveOccurred()) + Expect(done).To(BeFalse()) + + // 4) 두 Job 을 성공으로 표기 → done=true. + for _, shardID := range []string{"t0", "t1"} { + var job batchv1.Job + Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: reshardCopyJobName(clusterName, shardID)}, &job)).To(Succeed()) + job.Status.Succeeded = 1 + Expect(k8sClient.Status().Update(ctx, &job)).To(Succeed()) + } + done, failure, err = r.reconcileInitialCopy(ctx, ssj) + Expect(err).NotTo(HaveOccurred()) + Expect(failure).To(BeEmpty()) + Expect(done).To(BeTrue()) + }) + + It("복사 Job 이 실패하면 failure 를 보고한다", func() { + clusterName := fmt.Sprintf("rsdfail-%d", GinkgoRandomSeed()) + keyspace := "default" + sr := &postgresv1alpha1.ShardRange{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-sr", Namespace: ns}, + Spec: postgresv1alpha1.ShardRangeSpec{ + Cluster: clusterName, + Keyspace: keyspace, + Vindex: postgresv1alpha1.VindexSpec{Type: postgresv1alpha1.VindexTypeHash, Column: "id", Function: "murmur3"}, + Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0xffffffff", Shard: "shard-0"}}, + }, + } + Expect(k8sClient.Create(ctx, sr)).To(Succeed()) + ssj := &postgresv1alpha1.ShardSplitJob{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-ssj", Namespace: ns}, + Spec: postgresv1alpha1.ShardSplitJobSpec{ + Cluster: clusterName, Keyspace: keyspace, Sources: []string{"shard-0"}, + Targets: []postgresv1alpha1.ShardSplitTarget{ + {ShardID: "t0", Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0xffffffff", Shard: "t0"}}}, + }, + }, + } + Expect(k8sClient.Create(ctx, ssj)).To(Succeed()) + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(ssj), ssj)).To(Succeed()) + + r := &ShardSplitJobReconciler{Client: k8sClient, Scheme: scheme.Scheme} + _, _, err := r.reconcileInitialCopy(ctx, ssj) // Job 생성. + Expect(err).NotTo(HaveOccurred()) + + var job batchv1.Job + Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: reshardCopyJobName(clusterName, "t0")}, &job)).To(Succeed()) + now := metav1.Now() + job.Status.StartTime = &now // apiserver: finished job 은 startTime 필수. + job.Status.Conditions = []batchv1.JobCondition{ + {Type: batchv1.JobFailureTarget, Status: corev1.ConditionTrue, Reason: "BackoffLimitExceeded"}, // 1.36: Failed 선행 필수. + {Type: batchv1.JobFailed, Status: corev1.ConditionTrue, Reason: "BackoffLimitExceeded"}, + } + Expect(k8sClient.Status().Update(ctx, &job)).To(Succeed()) + + _, failure, err := r.reconcileInitialCopy(ctx, ssj) + Expect(err).NotTo(HaveOccurred()) + Expect(failure).To(ContainSubstring("failed")) + }) +}) diff --git a/internal/router/reshard_copy.go b/internal/router/reshard_copy.go index d7d55a9..9a54ecf 100644 --- a/internal/router/reshard_copy.go +++ b/internal/router/reshard_copy.go @@ -207,6 +207,47 @@ func DeleteShardRange(ctx context.Context, sourceDSN, table string, spec v1alpha return deleted, nil } +// ListUserTables 는 dsn 의 public 스키마 base table 이름 목록을 반환한다(이름순). resharding +// 은 모든 샤딩 테이블을 옮겨야 하므로, copy 전에 source 의 테이블을 발견하는 데 쓴다. +func ListUserTables(ctx context.Context, dsn string) ([]string, error) { + db, err := sql.Open("postgres", dsn) + if err != nil { + return nil, fmt.Errorf("router: open: %w", err) + } + defer func() { _ = db.Close() }() + rows, err := db.QueryContext(ctx, + `SELECT table_name FROM information_schema.tables + WHERE table_schema='public' AND table_type='BASE TABLE' ORDER BY table_name`) + if err != nil { + return nil, fmt.Errorf("router: list tables: %w", err) + } + defer func() { _ = rows.Close() }() + var out []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, fmt.Errorf("router: scan table name: %w", err) + } + out = append(out, name) + } + return out, rows.Err() +} + +// FilterTables 는 all 에서 exclude(대소문자 무시; reference 테이블 등)를 뺀 목록을 반환한다. +func FilterTables(all, exclude []string) []string { + skip := make(map[string]bool, len(exclude)) + for _, e := range exclude { + skip[strings.ToLower(e)] = true + } + var out []string + for _, t := range all { + if !skip[strings.ToLower(t)] { + out = append(out, t) + } + } + return out +} + // keyString 은 row 값(any)을 vindex 키 문자열로 정규화한다 — lib/pq 는 text 를 []byte 로 // 돌려주므로 string 변환(fmt.Sprint 면 "[97 ...]" 가 됨). 라우터의 키 추출(문자열)과 일치. func keyString(v any) string { diff --git a/internal/router/reshard_copy_test.go b/internal/router/reshard_copy_test.go index 956e021..29a34f0 100644 --- a/internal/router/reshard_copy_test.go +++ b/internal/router/reshard_copy_test.go @@ -77,6 +77,20 @@ func TestKeyString(t *testing.T) { } } +func TestFilterTables(t *testing.T) { + all := []string{"orders", "kv", "Country", "users"} + got := FilterTables(all, []string{"country", "REGIONS"}) + want := []string{"orders", "kv", "users"} // country 제외(대소문자 무시), REGIONS 부재 무해. + if len(got) != len(want) { + t.Fatalf("FilterTables = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("FilterTables[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + func TestIndexOfFold(t *testing.T) { cols := []string{"ID", "Val"} if i := indexOfFold(cols, "id"); i != 0 { From 57f4e49e83d4d3864a25bf912860741b6b0f63f9 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 09:28:33 +0900 Subject: [PATCH 067/109] =?UTF-8?q?build:=20reshard-copy=20Dockerfile=20(I?= =?UTF-8?q?nitialCopy=20Job=20=EC=9D=B4=EB=AF=B8=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShardSplitJob InitialCopy 가 띄우는 복사 Job 의 이미지(RESHARD_COPY_IMAGE). distroless static, lib/pq 순수 Go 라 CGO 불요. `docker build -f Dockerfile.reshard -t reshard-copy:dev .` Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- Dockerfile.reshard | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 Dockerfile.reshard diff --git a/Dockerfile.reshard b/Dockerfile.reshard new file mode 100644 index 0000000..631c525 --- /dev/null +++ b/Dockerfile.reshard @@ -0,0 +1,23 @@ +# Build the reshard-copy-poc binary — the online-resharding InitialCopy data mover +# (ShardSplitJob InitialCopy phase runs this as a K8s Job; RFC 0003 / ROADMAP G4). +# Separate image from the operator manager so the copy Job pulls a minimal surface. +FROM golang:1.26.4@sha256:68cb6d68bed024785b69195b89af7ac7a444f27791435f98647edff595aa0479 AS builder +ARG TARGETOS +ARG TARGETARCH + +WORKDIR /workspace +COPY go.mod go.mod +COPY go.sum go.sum +RUN go mod download + +COPY . . + +# CGO disabled → static distroless binary (lib/pq is pure Go). +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o reshard-copy ./cmd/reshard-copy-poc + +FROM gcr.io/distroless/static:nonroot@sha256:e3f945647ffb95b5839c07038d64f9811adf17308b9121d8a2b87b6a22a80a39 +WORKDIR / +COPY --from=builder /workspace/reshard-copy . +USER 65532:65532 + +ENTRYPOINT ["/reshard-copy"] From 19cf877472dbd364ff925b0e4b808f805df83fe4 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 09:29:52 +0900 Subject: [PATCH 068/109] =?UTF-8?q?docs:=20ShardSplitJob=20InitialCopy=20K?= =?UTF-8?q?8s=20=EA=B2=B0=EC=84=A0=20=EC=99=84=EB=A3=8C=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WORK_HANDOFF §6.6 + ROUTER-GAP-ANALYSIS: InitialCopy 가 K8s Job(내부 trust)으로 데이터 복사 + 완료 게이트(envtest 검증). 새 #1=full e2e + Cutover write-block + Cleanup row-delete + CDC. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/WORK_HANDOFF.ko.md | 3 ++- docs/sharding/ROUTER-GAP-ANALYSIS.ko.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index c959b3e..f8282d5 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -225,7 +225,8 @@ SSOT는 [ROUTER-GAP-ANALYSIS §4 능력 사다리 + §6 백로그](sharding/ROUT | ~~·~~ ✅ | ~~reference table·read-replica 프록시 결선~~ | **완료(2026-06-28)** — main.go read resolver(env replica/status ResolveRead)+PGROUTER_REFERENCE_TABLES. 라이브: read→replica, ref→AnyShard, write→primary. | | ~~·~~ ✅ | ~~resharding 실데이터 이동(core)~~ | **완료(2026-06-28)** — `CopyShardRange`(hash-range 필터 copy)+`DeleteShardRange`(cutover). 라이브: 1..100 split→44/56 overlap=0 키유실0. | | ~~·~~ ✅ | ~~bufio 라우터 최적화~~ | **완료(2026-06-28)** — writeMessage 단일 write + 연결당 읽기 버퍼(bufConn). baseline §3.0e: unprepared +50%(8955→13391), prepared 1shard +34%. | -| **1** | **ShardSplitJob 컨트롤러 K8s 결선** | reconcile 가 pod DSN 으로 CopyShardRange/DeleteShardRange 를 phase 별 호출 + write-block(토폴로지 flip)까지. 데이터이동 core 는 완성 — 자격/DSN 이 operator secret 모델과 얽혀 풀 클러스터 e2e 필요(큰 작업) | +| ~~·~~ ✅ | ~~ShardSplitJob InitialCopy K8s 결선~~ | **완료(2026-06-28)** — InitialCopy 가 target 별 K8s Job(reshard-copy 이미지, 내부 trust 접속)으로 데이터 복사 + 완료 게이트. `shardsplitjob_copy.go`, envtest 검증(Job env·완료집계·실패보고). pg_hba 가 내부 postgres trust라 자격 불요. | +| **1** | **ShardSplitJob full e2e + Cutover/Cleanup** | 멀티샤드 클러스터에서 ShardSplitJob 전체 phase 라이브 e2e + Cutover write-block(라우팅 freeze) + Cleanup row-delete Job(DeleteShardRange) + CDC 증분 catch-up. InitialCopy core 는 완성 | | **3** | **멀티머신 수평 스케일 실측** | 진짜 "분산처리능력" 수치 — 물리 분리 노드 필요(router-bench 가 샤드별 DSN 받음, 그대로 적용) | | **4** | **멀티 라우터 인스턴스 수평확장** | 라우터 1-hop 왕복이 남은 오버헤드(prepared direct 86K vs router 23K) — 라우터를 여러 개 띄워 처리량 확장 | | **6** | 보류 #5/#7/#9 | per-shard primary Service·watch·failover lease P2-T3 — 라이브 failover 필요 | diff --git a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md index 2e82ebf..83ac351 100644 --- a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md +++ b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md @@ -133,7 +133,7 @@ parameterized/`t.col`/주석·문자열 내부 오인 방지까지 검증. **기 - [x] **읽기 → replica 라우팅** ✅(2026-06-28): main.go read resolver 결선 — env `PGROUTER_BACKEND__REPLICA`(없으면 primary fallback) / status `StatusBackendResolver.ResolveRead`(Ready replica, failover-aware). 라이브: read alice→shard-0 replica, write→primary. - [x] **scatter-gather 실연결** ✅: `scattermode.go` 병렬 fan-out + UNION ALL 병합(라이브). 집계 재merge·전역 ORDER BY·LIMIT pushdown은 후속. - [x] **Reference table** ✅(2026-06-28): `shardSpec` `PGROUTER_REFERENCE_TABLES`(CSV) → reference-only 쿼리가 scatter 대신 AnyShard. 라이브: `SELECT FROM country` → 한 샤드(scatter 아님). -- [~] **무중단 resharding 데이터 이동**: **데이터이동 core 완료(2026-06-28)** — `CopyShardRange`(vindex 키가 target shard 로 가는 row 만 hash-range 필터 복사, 라우팅과 동일 ResolveShard, reversible) + `DeleteShardRange`(cutover 후 source 정리). 라이브: 키 1..100 split → source 44(shard-0)/target 56(shard-1), overlap=0 키유실0. **남음**: ShardSplitJob 컨트롤러 reconcile 가 pod DSN 으로 phase 별 호출 + write-block(토폴로지 flip) K8s 통합. 논리복제 CDC(증분 catch-up)도 후속. +- [~] **무중단 resharding 데이터 이동**: **데이터이동 core 완료(2026-06-28)** — `CopyShardRange`(vindex 키가 target shard 로 가는 row 만 hash-range 필터 복사, 라우팅과 동일 ResolveShard, reversible) + `DeleteShardRange`(cutover 후 source 정리). 라이브: 키 1..100 split → source 44(shard-0)/target 56(shard-1), overlap=0 키유실0. **InitialCopy 컨트롤러 결선 완료(2026-06-28)**: ShardSplitJob InitialCopy phase 가 target 별 K8s Job(reshard-copy 이미지, 클러스터 내부 trust 접속)으로 데이터 복사 + 완료까지 phase 게이트(`shardsplitjob_copy.go`, envtest 검증). pg_hba 가 내부 postgres 를 trust 하므로 자격증명 불요. **남음**: 멀티샤드 클러스터 full e2e + Cutover write-block + Cleanup row-delete Job + 논리복제 CDC 증분 catch-up. - [ ] **cross-shard 2PC**: 사다리 6단계. **현재 명시적 범위 밖**(ROI 최저). 멀티테넌트 v1엔 불필요. - [ ] **커넥션 풀링 (D)**: `SQLShardExecutor`의 per-call `sql.Open`/`Close` 제거 → 풀 + prepared stmt 캐시. scatter 경로 성능. (단일샤드 TCP 프록시엔 불필요.) From 8cf60ffed7eea23a64d9db4602d951117543798d Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 09:55:09 +0900 Subject: [PATCH 069/109] =?UTF-8?q?feat(controller):=20ShardSplitJob=20Cle?= =?UTF-8?q?anup=20=EA=B2=B0=EC=84=A0=20(source=20=EC=9D=B4=EB=8F=99?= =?UTF-8?q?=EB=B6=84=20=EC=82=AD=EC=A0=9C=20Job)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InitialCopy(복사)에 이어 Cleanup phase 도 실 결선 — cutover·라우팅 전환 후 source 에서 각 target 으로 이동한 row 를 삭제하는 Job 을 띄우고 완료까지 게이트한다. 이로써 resharding 데이터 경로(복사→cutover→source 회수)가 컨트롤러에서 닫힌다. - reshard-copy-poc: PGROUTER_RESHARD_DELETE_ONLY 모드(복사 없이 DeleteShardRange 만, target 접속 불요). 전 테이블 자동 발견 동일. - buildReshardJob(deleteOnly): copy/delete 겸용 — deleteOnly 면 DELETE_ONLY env + TARGET DSN 생략. reshardJobName 이 copy/del 이름 분리(두 phase Job 공존). - reconcileReshardJobs(deleteOnly) 공유 로직 → reconcileInitialCopy/reconcileCleanup. - Reconcile 에 Cleanup 블록(삭제 Job 완료 전 requeue, 실패 Failed, 완료 Completed 전이). 검증: envtest — Cleanup 이 target 별 delete-only Job 을 source 대상으로(DELETE_ONLY=1, TARGET_DSN 없음) 생성 + 완료 집계. InitialCopy·실패 spec 회귀 없음. 삭제 자체는 reshard-copy-poc 로 이미 라이브 증명(split overlap=0). 남은 것: 멀티샤드 full e2e + Cutover write-block + CDC 증분. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- cmd/reshard-copy-poc/main.go | 30 ++++++- .../controller/shardsplitjob_controller.go | 23 ++++++ internal/controller/shardsplitjob_copy.go | 82 +++++++++++++------ .../controller/shardsplitjob_copy_test.go | 53 +++++++++++- 4 files changed, 156 insertions(+), 32 deletions(-) diff --git a/cmd/reshard-copy-poc/main.go b/cmd/reshard-copy-poc/main.go index 3f210de..c9d5a27 100644 --- a/cmd/reshard-copy-poc/main.go +++ b/cmd/reshard-copy-poc/main.go @@ -74,8 +74,20 @@ func main() { tgt := os.Getenv("PGROUTER_TARGET_DSN") table := os.Getenv("PGROUTER_COPY_TABLE") targetShard := os.Getenv("PGROUTER_RESHARD_TARGET_SHARD") - if src == "" || tgt == "" || table == "" { - fmt.Fprintln(os.Stderr, "reshard-copy-poc: PGROUTER_SOURCE_DSN/TARGET_DSN/COPY_TABLE required") + // delete-only: cutover 후 source 에서 이동분 삭제만(복사 없음, Cleanup phase). target 불요. + deleteOnly := os.Getenv("PGROUTER_RESHARD_DELETE_ONLY") != "" + switch { + case src == "": + fmt.Fprintln(os.Stderr, "reshard-copy-poc: PGROUTER_SOURCE_DSN required") + os.Exit(2) + case deleteOnly && targetShard == "": + fmt.Fprintln(os.Stderr, "reshard-copy-poc: DELETE_ONLY requires PGROUTER_RESHARD_TARGET_SHARD") + os.Exit(2) + case !deleteOnly && tgt == "": + fmt.Fprintln(os.Stderr, "reshard-copy-poc: PGROUTER_TARGET_DSN required (unless DELETE_ONLY)") + os.Exit(2) + case targetShard == "" && table == "": + fmt.Fprintln(os.Stderr, "reshard-copy-poc: full copy requires PGROUTER_COPY_TABLE") os.Exit(2) } ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) @@ -114,6 +126,20 @@ func main() { fmt.Printf("reshard-copy-poc: discovered %d table(s) to reshard: %v\n", len(tables), tables) } + // Cleanup(delete-only): cutover 후 source 에서 targetShard 로 이동한 row 삭제만. + if deleteOnly { + for _, tbl := range tables { + deleted, err := router.DeleteShardRange(ctx, src, tbl, spec, targetShard) + if err != nil { + fmt.Fprintf(os.Stderr, "reshard-copy-poc: delete: %v (deleted %d before error)\n", err, deleted) + os.Exit(1) + } + fmt.Printf("reshard-copy-poc: deleted %d row(s) of %q (%s keys) from source\n", deleted, tbl, targetShard) + } + fmt.Printf("reshard-copy-poc: cleanup complete — %s rows reclaimed from source across %d table(s)\n", targetShard, len(tables)) + return + } + for _, tbl := range tables { fmt.Printf("reshard-copy-poc: InitialCopy (range) table=%q vindex=%s target=%s source→target\n", tbl, col, targetShard) copied, scanned, err := router.CopyShardRange(ctx, src, tgt, tbl, spec, targetShard) diff --git a/internal/controller/shardsplitjob_controller.go b/internal/controller/shardsplitjob_controller.go index 661ee9b..b0153be 100644 --- a/internal/controller/shardsplitjob_controller.go +++ b/internal/controller/shardsplitjob_controller.go @@ -121,6 +121,29 @@ func (r *ShardSplitJobReconciler) Reconcile(ctx context.Context, req ctrl.Reques } } + // Cleanup phase: cutover·라우팅 전환 후 source 에서 이동분(각 target 키)을 삭제하는 Job 을 + // 띄우고 완료를 기다린다. 라우팅이 이미 target 으로 갔으므로 안전(이동분은 더는 source 가 + // 서빙하지 않음). 완료 전엔 전이하지 않는다. + if ssj.Status.Phase == postgresv1alpha1.ShardSplitPhaseCleanup { + done, failure, err := r.reconcileCleanup(ctx, &ssj) + if err != nil { + return ctrl.Result{}, err + } + if failure != "" { + ssj.Status.Phase = postgresv1alpha1.ShardSplitPhaseFailed + ssj.Status.FailureReason = failure + now := metav1.Now() + ssj.Status.CompletedAt = &now + ssj.Status.ObservedGeneration = ssj.Generation + _ = r.Status().Update(ctx, &ssj) + return ctrl.Result{}, nil + } + if !done { + return ctrl.Result{RequeueAfter: 3 * time.Second}, nil // 삭제 Job 대기. + } + // 정리 완료 → 아래 nextPhase 가 Completed 로 전이. + } + next, failure := r.nextPhase(&ssj) if next == ssj.Status.Phase { return ctrl.Result{}, nil diff --git a/internal/controller/shardsplitjob_copy.go b/internal/controller/shardsplitjob_copy.go index 2057b07..fbf7029 100644 --- a/internal/controller/shardsplitjob_copy.go +++ b/internal/controller/shardsplitjob_copy.go @@ -44,9 +44,14 @@ func reshardCopyImage() string { return "ghcr.io/keiailab/reshard-copy:dev" } -// reshardCopyJobName 은 target shard 별 InitialCopy Job 이름(결정적 — 멱등 생성). -func reshardCopyJobName(cluster, shardID string) string { - return fmt.Sprintf("%s-rsd-copy-%s", cluster, shardID) +// reshardJobName 은 target shard 별 데이터이동 Job 이름(결정적 — 멱등 생성). deleteOnly 면 +// Cleanup(삭제) Job, 아니면 InitialCopy(복사) Job — 이름이 분리돼 두 phase 의 Job 이 공존한다. +func reshardJobName(cluster, shardID string, deleteOnly bool) string { + verb := "copy" + if deleteOnly { + verb = "del" + } + return fmt.Sprintf("%s-rsd-%s-%s", cluster, verb, shardID) } // internalShardDSN 은 클러스터 내부 trust DSN 을 만든다(postgres superuser, 무비밀번호 — @@ -102,12 +107,27 @@ func (r *ShardSplitJobReconciler) keyspaceVindex(ctx context.Context, ns, cluste return "", "", nil, fmt.Errorf("no ShardRange for cluster=%s keyspace=%s", cluster, keyspace) } -// buildReshardCopyJob 은 한 target shard 의 InitialCopy Job 을 만든다. -func (r *ShardSplitJobReconciler) buildReshardCopyJob(ssj *postgresv1alpha1.ShardSplitJob, sourceDSN, targetDSN, targetShard, col, fn, ranges string, refTables []string) *batchv1.Job { +// buildReshardJob 은 한 target shard 의 데이터이동 Job 을 만든다. deleteOnly=false 면 +// InitialCopy(source→target 복사), true 면 Cleanup(cutover 후 source 에서 이동분 삭제 — +// target 접속 불요). 두 경우 모두 모든 user 테이블 자동 발견(COPY_TABLE 미설정). +func (r *ShardSplitJobReconciler) buildReshardJob(ssj *postgresv1alpha1.ShardSplitJob, sourceDSN, targetDSN, targetShard, col, fn, ranges string, refTables []string, deleteOnly bool) *batchv1.Job { backoff := int32(2) + env := []corev1.EnvVar{ + {Name: "PGROUTER_SOURCE_DSN", Value: sourceDSN}, + {Name: "PGROUTER_RESHARD_TARGET_SHARD", Value: targetShard}, + {Name: "PGROUTER_VINDEX_COLUMN", Value: col}, + {Name: "PGROUTER_VINDEX_FUNCTION", Value: fn}, + {Name: "PGROUTER_RANGES", Value: ranges}, + {Name: "PGROUTER_REFERENCE_TABLES", Value: strings.Join(refTables, ",")}, + } + if deleteOnly { + env = append(env, corev1.EnvVar{Name: "PGROUTER_RESHARD_DELETE_ONLY", Value: "1"}) + } else { + env = append(env, corev1.EnvVar{Name: "PGROUTER_TARGET_DSN", Value: targetDSN}) + } return &batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ - Name: reshardCopyJobName(ssj.Spec.Cluster, targetShard), + Name: reshardJobName(ssj.Spec.Cluster, targetShard, deleteOnly), Namespace: ssj.Namespace, Labels: ReshardTargetSelectorLabels(ssj.Spec.Cluster, targetShard), }, @@ -120,17 +140,7 @@ func (r *ShardSplitJobReconciler) buildReshardCopyJob(ssj *postgresv1alpha1.Shar Containers: []corev1.Container{{ Name: "reshard-copy", Image: reshardCopyImage(), - Env: []corev1.EnvVar{ - {Name: "PGROUTER_SOURCE_DSN", Value: sourceDSN}, - {Name: "PGROUTER_TARGET_DSN", Value: targetDSN}, - {Name: "PGROUTER_RESHARD_TARGET_SHARD", Value: targetShard}, - {Name: "PGROUTER_VINDEX_COLUMN", Value: col}, - {Name: "PGROUTER_VINDEX_FUNCTION", Value: fn}, - {Name: "PGROUTER_RANGES", Value: ranges}, - {Name: "PGROUTER_REFERENCE_TABLES", Value: strings.Join(refTables, ",")}, - // COPY_TABLE 미설정 → 모든 user 테이블 발견·복사. DELETE_AFTER 미설정 - // → 가역(삭제는 Cleanup 트랙). - }, + Env: env, }}, }, }, @@ -138,11 +148,28 @@ func (r *ShardSplitJobReconciler) buildReshardCopyJob(ssj *postgresv1alpha1.Shar } } -// reconcileInitialCopy 는 각 target 의 복사 Job 을 멱등 생성하고 완료를 집계한다. -// 반환: done(전부 성공), failure(한 Job 이라도 실패 시 사유), err(전이 가능 — requeue). +// reconcileInitialCopy 는 각 target 의 source→target 복사 Job 을 띄우고 완료를 집계한다. func (r *ShardSplitJobReconciler) reconcileInitialCopy(ctx context.Context, ssj *postgresv1alpha1.ShardSplitJob) (done bool, failure string, err error) { + return r.reconcileReshardJobs(ctx, ssj, false) +} + +// reconcileCleanup 은 cutover 후 각 target 키를 source 에서 삭제하는 Job 을 띄우고 완료를 +// 집계한다(source 정리 — 이동분 회수). InitialCopy 가 끝나고 라우팅이 전환된 *뒤* 이므로 +// 안전하다. +func (r *ShardSplitJobReconciler) reconcileCleanup(ctx context.Context, ssj *postgresv1alpha1.ShardSplitJob) (done bool, failure string, err error) { + return r.reconcileReshardJobs(ctx, ssj, true) +} + +// reconcileReshardJobs 는 각 target 의 데이터이동 Job(deleteOnly=복사/삭제)을 멱등 생성하고 +// 완료를 집계한다. 반환: done(전부 성공), failure(한 Job 이라도 실패 시 사유), err(전이 가능 +// — requeue). +func (r *ShardSplitJobReconciler) reconcileReshardJobs(ctx context.Context, ssj *postgresv1alpha1.ShardSplitJob, deleteOnly bool) (done bool, failure string, err error) { + phase := "InitialCopy" + if deleteOnly { + phase = "Cleanup" + } if len(ssj.Spec.Sources) == 0 { - return false, "InitialCopy: no source shard", nil + return false, phase + ": no source shard", nil } col, fn, refTables, err := r.keyspaceVindex(ctx, ssj.Namespace, ssj.Spec.Cluster, ssj.Spec.Keyspace) if err != nil { @@ -150,22 +177,23 @@ func (r *ShardSplitJobReconciler) reconcileInitialCopy(ctx context.Context, ssj } srcDNS, err := sourceShardPodDNS(ssj.Spec.Cluster, ssj.Namespace, ssj.Spec.Sources[0]) if err != nil { - return false, "InitialCopy: " + err.Error(), nil // 설정 오류 — Failed. + return false, phase + ": " + err.Error(), nil // 설정 오류 — Failed. } ranges := rangesEnvValue(ssj.Spec.Targets) allDone := true for i := range ssj.Spec.Targets { t := &ssj.Spec.Targets[i] - name := reshardCopyJobName(ssj.Spec.Cluster, t.ShardID) + name := reshardJobName(ssj.Spec.Cluster, t.ShardID, deleteOnly) var job batchv1.Job getErr := r.Get(ctx, client.ObjectKey{Namespace: ssj.Namespace, Name: name}, &job) switch { case apierrors.IsNotFound(getErr): - j := r.buildReshardCopyJob(ssj, - internalShardDSN(srcDNS), - internalShardDSN(targetShardPodDNS(ssj.Spec.Cluster, ssj.Namespace, t.ShardID)), - t.ShardID, col, fn, ranges, refTables) + targetDSN := "" + if !deleteOnly { + targetDSN = internalShardDSN(targetShardPodDNS(ssj.Spec.Cluster, ssj.Namespace, t.ShardID)) + } + j := r.buildReshardJob(ssj, internalShardDSN(srcDNS), targetDSN, t.ShardID, col, fn, ranges, refTables, deleteOnly) if err := controllerutil.SetControllerReference(ssj, j, r.Scheme); err != nil { return false, "", err } @@ -178,7 +206,7 @@ func (r *ShardSplitJobReconciler) reconcileInitialCopy(ctx context.Context, ssj case job.Status.Succeeded > 0: // 이 target 완료. case jobFailed(&job): - return false, fmt.Sprintf("InitialCopy: copy job %s failed", name), nil + return false, fmt.Sprintf("%s: job %s failed", phase, name), nil default: allDone = false // 진행 중. } diff --git a/internal/controller/shardsplitjob_copy_test.go b/internal/controller/shardsplitjob_copy_test.go index b0a8cb4..c3a452d 100644 --- a/internal/controller/shardsplitjob_copy_test.go +++ b/internal/controller/shardsplitjob_copy_test.go @@ -85,7 +85,7 @@ var _ = Describe("ShardSplitJob InitialCopy 복사 Job 결선", func() { // 2) 각 target Job 이 올바른 env 로 생성됐는지. for _, shardID := range []string{"t0", "t1"} { var job batchv1.Job - Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: reshardCopyJobName(clusterName, shardID)}, &job)). + Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: reshardJobName(clusterName, shardID, false)}, &job)). To(Succeed(), "target %s 복사 Job 이 생성돼야 함", shardID) c := job.Spec.Template.Spec.Containers[0] Expect(envOf(c, "PGROUTER_RESHARD_TARGET_SHARD")).To(Equal(shardID)) @@ -109,7 +109,7 @@ var _ = Describe("ShardSplitJob InitialCopy 복사 Job 결선", func() { // 4) 두 Job 을 성공으로 표기 → done=true. for _, shardID := range []string{"t0", "t1"} { var job batchv1.Job - Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: reshardCopyJobName(clusterName, shardID)}, &job)).To(Succeed()) + Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: reshardJobName(clusterName, shardID, false)}, &job)).To(Succeed()) job.Status.Succeeded = 1 Expect(k8sClient.Status().Update(ctx, &job)).To(Succeed()) } @@ -149,7 +149,7 @@ var _ = Describe("ShardSplitJob InitialCopy 복사 Job 결선", func() { Expect(err).NotTo(HaveOccurred()) var job batchv1.Job - Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: reshardCopyJobName(clusterName, "t0")}, &job)).To(Succeed()) + Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: reshardJobName(clusterName, "t0", false)}, &job)).To(Succeed()) now := metav1.Now() job.Status.StartTime = &now // apiserver: finished job 은 startTime 필수. job.Status.Conditions = []batchv1.JobCondition{ @@ -162,4 +162,51 @@ var _ = Describe("ShardSplitJob InitialCopy 복사 Job 결선", func() { Expect(err).NotTo(HaveOccurred()) Expect(failure).To(ContainSubstring("failed")) }) + + It("Cleanup 이 각 target 의 삭제(delete-only) Job 을 source 대상으로 생성한다", func() { + clusterName := fmt.Sprintf("rsdclean-%d", GinkgoRandomSeed()) + keyspace := "default" + sr := &postgresv1alpha1.ShardRange{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-sr", Namespace: ns}, + Spec: postgresv1alpha1.ShardRangeSpec{ + Cluster: clusterName, + Keyspace: keyspace, + Vindex: postgresv1alpha1.VindexSpec{Type: postgresv1alpha1.VindexTypeHash, Column: "id", Function: "murmur3"}, + Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0xffffffff", Shard: "shard-0"}}, + }, + } + Expect(k8sClient.Create(ctx, sr)).To(Succeed()) + ssj := &postgresv1alpha1.ShardSplitJob{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-ssj", Namespace: ns}, + Spec: postgresv1alpha1.ShardSplitJobSpec{ + Cluster: clusterName, Keyspace: keyspace, Sources: []string{"shard-0"}, + Targets: []postgresv1alpha1.ShardSplitTarget{ + {ShardID: "t1", Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x80000000", Hi: "0xffffffff", Shard: "t1"}}}, + }, + }, + } + Expect(k8sClient.Create(ctx, ssj)).To(Succeed()) + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(ssj), ssj)).To(Succeed()) + + r := &ShardSplitJobReconciler{Client: k8sClient, Scheme: scheme.Scheme} + done, failure, err := r.reconcileCleanup(ctx, ssj) + Expect(err).NotTo(HaveOccurred()) + Expect(failure).To(BeEmpty()) + Expect(done).To(BeFalse()) + + var job batchv1.Job + Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: reshardJobName(clusterName, "t1", true)}, &job)). + To(Succeed(), "delete Job 이 생성돼야 함") + c := job.Spec.Template.Spec.Containers[0] + Expect(envOf(c, "PGROUTER_RESHARD_DELETE_ONLY")).To(Equal("1")) + Expect(envOf(c, "PGROUTER_RESHARD_TARGET_SHARD")).To(Equal("t1")) + Expect(envOf(c, "PGROUTER_SOURCE_DSN")).To(ContainSubstring(ShardServiceName(clusterName, 0))) + Expect(envOf(c, "PGROUTER_TARGET_DSN")).To(BeEmpty()) // 삭제는 source 만. + + job.Status.Succeeded = 1 + Expect(k8sClient.Status().Update(ctx, &job)).To(Succeed()) + done, _, err = r.reconcileCleanup(ctx, ssj) + Expect(err).NotTo(HaveOccurred()) + Expect(done).To(BeTrue()) + }) }) From ece86c979dec5a0d9129e2b2c4197910f0dcb0ce Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 09:55:52 +0900 Subject: [PATCH 070/109] =?UTF-8?q?docs:=20ShardSplitJob=20Cleanup=20?= =?UTF-8?q?=EA=B2=B0=EC=84=A0=20=EC=99=84=EB=A3=8C=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WORK_HANDOFF §6.6: InitialCopy+Cleanup 결선 완료(데이터경로 닫힘). 새 #1=full e2e + Cutover write-block + CDC. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/WORK_HANDOFF.ko.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index f8282d5..db9edcd 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -225,8 +225,8 @@ SSOT는 [ROUTER-GAP-ANALYSIS §4 능력 사다리 + §6 백로그](sharding/ROUT | ~~·~~ ✅ | ~~reference table·read-replica 프록시 결선~~ | **완료(2026-06-28)** — main.go read resolver(env replica/status ResolveRead)+PGROUTER_REFERENCE_TABLES. 라이브: read→replica, ref→AnyShard, write→primary. | | ~~·~~ ✅ | ~~resharding 실데이터 이동(core)~~ | **완료(2026-06-28)** — `CopyShardRange`(hash-range 필터 copy)+`DeleteShardRange`(cutover). 라이브: 1..100 split→44/56 overlap=0 키유실0. | | ~~·~~ ✅ | ~~bufio 라우터 최적화~~ | **완료(2026-06-28)** — writeMessage 단일 write + 연결당 읽기 버퍼(bufConn). baseline §3.0e: unprepared +50%(8955→13391), prepared 1shard +34%. | -| ~~·~~ ✅ | ~~ShardSplitJob InitialCopy K8s 결선~~ | **완료(2026-06-28)** — InitialCopy 가 target 별 K8s Job(reshard-copy 이미지, 내부 trust 접속)으로 데이터 복사 + 완료 게이트. `shardsplitjob_copy.go`, envtest 검증(Job env·완료집계·실패보고). pg_hba 가 내부 postgres trust라 자격 불요. | -| **1** | **ShardSplitJob full e2e + Cutover/Cleanup** | 멀티샤드 클러스터에서 ShardSplitJob 전체 phase 라이브 e2e + Cutover write-block(라우팅 freeze) + Cleanup row-delete Job(DeleteShardRange) + CDC 증분 catch-up. InitialCopy core 는 완성 | +| ~~·~~ ✅ | ~~ShardSplitJob InitialCopy/Cleanup K8s 결선~~ | **완료(2026-06-28)** — InitialCopy(복사)+Cleanup(source 이동분 삭제)이 target 별 K8s Job(reshard-copy 이미지, 내부 trust 접속)으로 데이터 이동 + 완료 게이트. `shardsplitjob_copy.go`, envtest 검증(Job env·완료집계·실패보고·delete-only). pg_hba 내부 postgres trust라 자격 불요. 데이터 경로(복사→cutover→회수) 닫힘. | +| **1** | **ShardSplitJob full e2e + Cutover write-block** | 멀티샤드 클러스터에서 ShardSplitJob 전체 phase 라이브 e2e + Cutover write-block(라우팅 freeze, 무중단 cutover 마지막 정합성) + CDC 증분 catch-up. 데이터이동(복사/삭제) core·결선은 완성 | | **3** | **멀티머신 수평 스케일 실측** | 진짜 "분산처리능력" 수치 — 물리 분리 노드 필요(router-bench 가 샤드별 DSN 받음, 그대로 적용) | | **4** | **멀티 라우터 인스턴스 수평확장** | 라우터 1-hop 왕복이 남은 오버헤드(prepared direct 86K vs router 23K) — 라우터를 여러 개 띄워 처리량 확장 | | **6** | 보류 #5/#7/#9 | per-shard primary Service·watch·failover lease P2-T3 — 라이브 failover 필요 | From 39bd553f4c49e3e3697d5d613a6bca379534820a Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 10:33:16 +0900 Subject: [PATCH 071/109] =?UTF-8?q?feat(resharding):=20Cutover=20write-blo?= =?UTF-8?q?ck=20=E2=80=94=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EC=A4=91=20=EC=93=B0=EA=B8=B0=20=EC=B0=A8=EB=8B=A8?= =?UTF-8?q?(=EC=9D=BD=EA=B8=B0=20=ED=86=B5=EA=B3=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 무중단 cutover 의 정합성: 라우팅 전환 동안 쓰기를 일시 차단해 유실을 막는다(읽기는 통과). - ShardRangeSpec.WriteBlocked: 라우터가 토폴로지에서 읽는 write-block 신호(+CRD 재생성). - 라우터: QueryRouter.Route + queryRouter.routeKey 가 write(비-read-only)에 WriteBlocked 면 ErrWriteBlocked 반환. 세션(simple/extended)이 SQLSTATE 25006 PG 에러로 변환. - 컨트롤러: Cutover phase(가역 경로만) 가 ShardRange.WriteBlocked=true, RoutingUpdate 가 ranges flip 과 함께 false(쓰기 재개). setWriteBlock 헬퍼. - pg-router: PGROUTER_WRITE_BLOCKED env(static 모드 수동 knob). - 버그수정: simple Query 에러 경로가 ErrorResponse 만 보내고 ReadyForQuery 누락 → 클라이언트 hang. queryError 가 ErrorResponse + ReadyForQuery(tx면 'E' else 'I') 전송. 검증: router 단위(write→ErrWriteBlocked, read 통과) + envtest(Cutover write-block on, RoutingUpdate off+flip, forward-only 미설정) + 라이브(WRITE_BLOCKED=1: UPDATE→"writes blocked" 에러 즉시 반환 hang 없음, SELECT 통과). 남은 것: 멀티샤드 full e2e + CDC 증분. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- api/v1alpha1/shardrange_types.go | 6 + .../postgres.keiailab.io_shardranges.yaml | 6 + cmd/pg-router/extsession.go | 5 + cmd/pg-router/main.go | 1 + cmd/pg-router/persession.go | 23 +++- cmd/pg-router/querymode.go | 3 + .../postgres.keiailab.io_shardranges.yaml | 6 + .../controller/shardsplitjob_controller.go | 33 +++++ .../shardsplitjob_writeblock_test.go | 124 ++++++++++++++++++ internal/router/query_router.go | 7 + internal/router/query_router_test.go | 21 +++ 11 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 internal/controller/shardsplitjob_writeblock_test.go diff --git a/api/v1alpha1/shardrange_types.go b/api/v1alpha1/shardrange_types.go index ddb1e42..ccbe41f 100644 --- a/api/v1alpha1/shardrange_types.go +++ b/api/v1alpha1/shardrange_types.go @@ -105,6 +105,12 @@ type ShardRangeSpec struct { // 이 테이블만 참조하는 쿼리는 샤딩 키 없이 임의 샤드로 라우팅된다 (분산 조인 우회). // +optional ReferenceTables []string `json:"referenceTables,omitempty"` + + // WriteBlocked은 true 면 라우터가 이 키스페이스로의 *쓰기*를 일시 거부한다(읽기는 통과). + // online resharding 의 Cutover 동안 라우팅 전환 중 쓰기 유실을 막는 write-block 신호로 + // ShardSplitJob 컨트롤러가 설정/해제한다(Cutover=true, RoutingUpdate 완료 시 false). + // +optional + WriteBlocked bool `json:"writeBlocked,omitempty"` } // ShardRangeStatus은 reconciler 가 관찰한 ShardRange 상태이다. diff --git a/charts/postgres-operator/crds/postgres.keiailab.io_shardranges.yaml b/charts/postgres-operator/crds/postgres.keiailab.io_shardranges.yaml index 2f8de5a..5ed4c7d 100644 --- a/charts/postgres-operator/crds/postgres.keiailab.io_shardranges.yaml +++ b/charts/postgres-operator/crds/postgres.keiailab.io_shardranges.yaml @@ -152,6 +152,12 @@ spec: required: - type type: object + writeBlocked: + description: |- + WriteBlocked은 true 면 라우터가 이 키스페이스로의 *쓰기*를 일시 거부한다(읽기는 통과). + online resharding 의 Cutover 동안 라우팅 전환 중 쓰기 유실을 막는 write-block 신호로 + ShardSplitJob 컨트롤러가 설정/해제한다(Cutover=true, RoutingUpdate 완료 시 false). + type: boolean required: - cluster - keyspace diff --git a/cmd/pg-router/extsession.go b/cmd/pg-router/extsession.go index df825c6..d06a070 100644 --- a/cmd/pg-router/extsession.go +++ b/cmd/pg-router/extsession.go @@ -24,6 +24,7 @@ package main import ( "bytes" + "errors" "net" "github.com/keiailab/postgres-operator/internal/router" @@ -121,6 +122,10 @@ func (s *session) runExtendedExec(batch []pgMessage, bind pgMessage) bool { } else { d, err = s.qr.routeSQL(st.sql) } + if errors.Is(err, router.ErrWriteBlocked) { + writePgError(s.client, "25006", err.Error()) // cutover write-block. + return s.sendReadyIdle() + } if err != nil || d.Scatter { writePgError(s.client, "08006", "extended routing failed (no routing key; extended scatter unsupported)") return s.sendReadyIdle() diff --git a/cmd/pg-router/main.go b/cmd/pg-router/main.go index 1f4dc67..e01b055 100644 --- a/cmd/pg-router/main.go +++ b/cmd/pg-router/main.go @@ -348,6 +348,7 @@ func shardSpec() v1alpha1.ShardRangeSpec { Keyspace: env("PGROUTER_KEYSPACE", "default"), Vindex: v1alpha1.VindexSpec{Type: v1alpha1.VindexTypeHash, Column: "id", Function: "murmur3"}, ReferenceTables: csv(env("PGROUTER_REFERENCE_TABLES", "")), + WriteBlocked: env("PGROUTER_WRITE_BLOCKED", "") != "", // cutover write-block (static 모드 테스트/수동 knob). Ranges: []v1alpha1.ShardRangeEntry{ {Lo: "0x00000000", Hi: "0x7fffffff", Shard: "shard-0"}, {Lo: "0x80000000", Hi: "0xffffffff", Shard: "shard-1"}, diff --git a/cmd/pg-router/persession.go b/cmd/pg-router/persession.go index fbc11b1..a3c2238 100644 --- a/cmd/pg-router/persession.go +++ b/cmd/pg-router/persession.go @@ -15,9 +15,12 @@ Licensed under the MIT License. See the LICENSE file for details. package main import ( + "errors" "fmt" "net" "strings" + + "github.com/keiailab/postgres-operator/internal/router" ) // session 은 한 클라이언트 연결의 per-query 라우팅 상태다. @@ -109,14 +112,18 @@ func (s *session) handleSimpleQuery(m pgMessage) bool { d, err := s.qr.routeSQL(sql) if d.Scatter { // 키 없음 → scatter (자체 연결). if s.inTx { - writePgError(s.client, "0A000", "cannot scatter a keyless query inside a transaction") + s.queryError("0A000", "cannot scatter a keyless query inside a transaction") return true } scatterQuery(s.client, s.qr, m, s.raw, s.dialer, s.password) return true } + if errors.Is(err, router.ErrWriteBlocked) { + s.queryError("25006", err.Error()) // read_only_sql_transaction — cutover write-block. + return true + } if err != nil { - writePgError(s.client, "08006", "routing failed: "+err.Error()) + s.queryError("08006", "routing failed: "+err.Error()) return true } conn, err := s.backendFor(d.Backend) @@ -182,6 +189,18 @@ func (s *session) execAndRelay(conn net.Conn, m pgMessage) bool { } } +// queryError 는 simple Query 실패에 ErrorResponse + ReadyForQuery 를 보낸다 — 클라이언트는 +// 에러 뒤 ReadyForQuery 를 기다리므로(없으면 hang) 세션을 이어가려면 반드시 함께 보낸다. +// 트랜잭션 중이면 'E'(failed tx), 아니면 'I'(idle). +func (s *session) queryError(code, msg string) { + writePgError(s.client, code, msg) + status := byte('I') + if s.inTx { + status = 'E' + } + _ = writeMessage(s.client, 'Z', []byte{status}) +} + func (s *session) closeBackends() { for _, c := range s.backends { _ = c.Close() diff --git a/cmd/pg-router/querymode.go b/cmd/pg-router/querymode.go index b2deaa5..b95cbb3 100644 --- a/cmd/pg-router/querymode.go +++ b/cmd/pg-router/querymode.go @@ -55,6 +55,9 @@ func (qr queryRouter) routeKey(key string, read bool) (router.RouteDecision, err if err != nil { return router.RouteDecision{}, err } + if !read && topo.Spec.WriteBlocked { // cutover write-block (Route 와 동일 정책). + return router.RouteDecision{}, router.ErrWriteBlocked + } shard, err := router.ResolveShard(topo.Spec, key) if err != nil { return router.RouteDecision{}, err diff --git a/config/crd/bases/postgres.keiailab.io_shardranges.yaml b/config/crd/bases/postgres.keiailab.io_shardranges.yaml index 2f8de5a..5ed4c7d 100644 --- a/config/crd/bases/postgres.keiailab.io_shardranges.yaml +++ b/config/crd/bases/postgres.keiailab.io_shardranges.yaml @@ -152,6 +152,12 @@ spec: required: - type type: object + writeBlocked: + description: |- + WriteBlocked은 true 면 라우터가 이 키스페이스로의 *쓰기*를 일시 거부한다(읽기는 통과). + online resharding 의 Cutover 동안 라우팅 전환 중 쓰기 유실을 막는 write-block 신호로 + ShardSplitJob 컨트롤러가 설정/해제한다(Cutover=true, RoutingUpdate 완료 시 false). + type: boolean required: - cluster - keyspace diff --git a/internal/controller/shardsplitjob_controller.go b/internal/controller/shardsplitjob_controller.go index b0153be..b4ecc17 100644 --- a/internal/controller/shardsplitjob_controller.go +++ b/internal/controller/shardsplitjob_controller.go @@ -109,6 +109,15 @@ func (r *ShardSplitJobReconciler) Reconcile(ctx context.Context, req ctrl.Reques // 복사 완료 → 아래 nextPhase 가 CDCCatchup 으로 전이. } + // Cutover phase: 라우팅 전환 직전 write-block 을 켠다(라우터가 쓰기 거부, 읽기는 통과) — + // RoutingUpdate 가 ranges 를 flip 하고 동시에 write-block 을 해제한다. forward-only(비가역)는 + // nextPhase 가 Failed 로 막으므로 write-block 을 켜지 않는다. + if ssj.Status.Phase == postgresv1alpha1.ShardSplitPhaseCutover && !ssj.Spec.AllowForwardOnly { + if err := r.setWriteBlock(ctx, &ssj, true); err != nil { + return ctrl.Result{}, err + } + } + if ssj.Status.Phase == postgresv1alpha1.ShardSplitPhaseRoutingUpdate { if err := r.applyRouting(ctx, &ssj); err != nil { ssj.Status.Phase = postgresv1alpha1.ShardSplitPhaseFailed @@ -237,6 +246,7 @@ func (r *ShardSplitJobReconciler) applyRouting(ctx context.Context, ssj *postgre sr := &list.Items[i] if sr.Spec.Cluster == ssj.Spec.Cluster && sr.Spec.Keyspace == ssj.Spec.Keyspace { sr.Spec.Ranges = flattenTargetRanges(ssj.Spec.Targets) + sr.Spec.WriteBlocked = false // 라우팅 전환 완료 → write-block 해제(쓰기 재개, 이제 새 shard 로). if err := r.Update(ctx, sr); err != nil { return fmt.Errorf("update ShardRange %s: %w", sr.Name, err) } @@ -246,6 +256,29 @@ func (r *ShardSplitJobReconciler) applyRouting(ctx context.Context, ssj *postgre return fmt.Errorf("no ShardRange for cluster=%s keyspace=%s", ssj.Spec.Cluster, ssj.Spec.Keyspace) } +// setWriteBlock 은 cluster/keyspace 의 ShardRange 에 write-block 을 설정/해제한다 — Cutover +// 동안 라우터가 쓰기를 거부하게 해(읽기는 통과) 라우팅 전환 중 쓰기 유실을 막는다. +func (r *ShardSplitJobReconciler) setWriteBlock(ctx context.Context, ssj *postgresv1alpha1.ShardSplitJob, blocked bool) error { + var list postgresv1alpha1.ShardRangeList + if err := r.List(ctx, &list, client.InNamespace(ssj.Namespace)); err != nil { + return fmt.Errorf("list ShardRange: %w", err) + } + for i := range list.Items { + sr := &list.Items[i] + if sr.Spec.Cluster == ssj.Spec.Cluster && sr.Spec.Keyspace == ssj.Spec.Keyspace { + if sr.Spec.WriteBlocked == blocked { + return nil // 멱등. + } + sr.Spec.WriteBlocked = blocked + if err := r.Update(ctx, sr); err != nil { + return fmt.Errorf("update ShardRange %s write-block: %w", sr.Name, err) + } + return nil + } + } + return fmt.Errorf("no ShardRange for cluster=%s keyspace=%s", ssj.Spec.Cluster, ssj.Spec.Keyspace) +} + // reconcileBootstrapTargets 는 ShardSplitJob 의 각 target shard 에 대해 격리 식별 // (ADR-0027) 의 ConfigMap + headless Service + StatefulSet 을 멱등 생성한다. // diff --git a/internal/controller/shardsplitjob_writeblock_test.go b/internal/controller/shardsplitjob_writeblock_test.go new file mode 100644 index 0000000..49fafcd --- /dev/null +++ b/internal/controller/shardsplitjob_writeblock_test.go @@ -0,0 +1,124 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package controller + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + postgresv1alpha1 "github.com/keiailab/postgres-operator/api/v1alpha1" +) + +// 본 파일은 ShardSplitJob Cutover write-block 을 envtest 로 검증한다: Cutover phase 가 +// ShardRange 에 write-block 을 켜고(라우터가 쓰기 거부), RoutingUpdate 가 ranges 를 flip 하며 +// write-block 을 끄는지(쓰기 재개). + +var _ = Describe("ShardSplitJob Cutover write-block", func() { + ctx := context.Background() + const ns = "default" + + It("Cutover 가 write-block 을 켜고 RoutingUpdate 가 ranges flip 과 함께 끈다", func() { + clusterName := fmt.Sprintf("rsdwb-%d", GinkgoRandomSeed()) + keyspace := "default" + + sr := &postgresv1alpha1.ShardRange{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-sr", Namespace: ns}, + Spec: postgresv1alpha1.ShardRangeSpec{ + Cluster: clusterName, + Keyspace: keyspace, + Vindex: postgresv1alpha1.VindexSpec{Type: postgresv1alpha1.VindexTypeHash, Column: "id", Function: "murmur3"}, + Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0xffffffff", Shard: "shard-0"}}, + }, + } + Expect(k8sClient.Create(ctx, sr)).To(Succeed()) + + ssj := &postgresv1alpha1.ShardSplitJob{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-ssj", Namespace: ns}, + Spec: postgresv1alpha1.ShardSplitJobSpec{ + Cluster: clusterName, Keyspace: keyspace, Sources: []string{"shard-0"}, + AllowForwardOnly: false, + Targets: []postgresv1alpha1.ShardSplitTarget{ + {ShardID: "t0", Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0x7fffffff", Shard: "t0"}}}, + {ShardID: "t1", Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x80000000", Hi: "0xffffffff", Shard: "t1"}}}, + }, + }, + } + Expect(k8sClient.Create(ctx, ssj)).To(Succeed()) + ssj.Status.Phase = postgresv1alpha1.ShardSplitPhaseCutover + Expect(k8sClient.Status().Update(ctx, ssj)).To(Succeed()) + + r := &ShardSplitJobReconciler{Client: k8sClient, Scheme: scheme.Scheme} + req := ctrl.Request{NamespacedName: client.ObjectKeyFromObject(ssj)} + + // Reconcile 1: Cutover → write-block ON, phase→RoutingUpdate. + _, err := r.Reconcile(ctx, req) + Expect(err).NotTo(HaveOccurred()) + + var got postgresv1alpha1.ShardRange + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(sr), &got)).To(Succeed()) + Expect(got.Spec.WriteBlocked).To(BeTrue(), "Cutover 가 write-block 을 켜야 함") + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(ssj), ssj)).To(Succeed()) + Expect(ssj.Status.Phase).To(Equal(postgresv1alpha1.ShardSplitPhaseRoutingUpdate)) + + // Reconcile 2: RoutingUpdate → ranges flip + write-block OFF, phase→Cleanup. + _, err = r.Reconcile(ctx, req) + Expect(err).NotTo(HaveOccurred()) + + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(sr), &got)).To(Succeed()) + Expect(got.Spec.WriteBlocked).To(BeFalse(), "RoutingUpdate 가 write-block 을 꺼야 함") + Expect(got.Spec.Ranges).To(HaveLen(2)) // t0/t1 로 flip. + shards := []string{got.Spec.Ranges[0].Shard, got.Spec.Ranges[1].Shard} + Expect(shards).To(ConsistOf("t0", "t1")) + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(ssj), ssj)).To(Succeed()) + Expect(ssj.Status.Phase).To(Equal(postgresv1alpha1.ShardSplitPhaseCleanup)) + }) + + It("forward-only cutover 는 write-block 을 켜지 않는다(비가역 거부)", func() { + clusterName := fmt.Sprintf("rsdwbfo-%d", GinkgoRandomSeed()) + keyspace := "default" + sr := &postgresv1alpha1.ShardRange{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-sr", Namespace: ns}, + Spec: postgresv1alpha1.ShardRangeSpec{ + Cluster: clusterName, Keyspace: keyspace, + Vindex: postgresv1alpha1.VindexSpec{Type: postgresv1alpha1.VindexTypeHash, Column: "id", Function: "murmur3"}, + Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0xffffffff", Shard: "shard-0"}}, + }, + } + Expect(k8sClient.Create(ctx, sr)).To(Succeed()) + ssj := &postgresv1alpha1.ShardSplitJob{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-ssj", Namespace: ns}, + Spec: postgresv1alpha1.ShardSplitJobSpec{ + Cluster: clusterName, Keyspace: keyspace, Sources: []string{"shard-0"}, + AllowForwardOnly: true, + Targets: []postgresv1alpha1.ShardSplitTarget{ + {ShardID: "t0", Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0xffffffff", Shard: "t0"}}}, + }, + }, + } + Expect(k8sClient.Create(ctx, ssj)).To(Succeed()) + ssj.Status.Phase = postgresv1alpha1.ShardSplitPhaseCutover + Expect(k8sClient.Status().Update(ctx, ssj)).To(Succeed()) + + r := &ShardSplitJobReconciler{Client: k8sClient, Scheme: scheme.Scheme} + _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(ssj)}) + Expect(err).NotTo(HaveOccurred()) + + var got postgresv1alpha1.ShardRange + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(sr), &got)).To(Succeed()) + Expect(got.Spec.WriteBlocked).To(BeFalse(), "forward-only 는 write-block 미설정") + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(ssj), ssj)).To(Succeed()) + Expect(ssj.Status.Phase).To(Equal(postgresv1alpha1.ShardSplitPhaseFailed)) + }) +}) diff --git a/internal/router/query_router.go b/internal/router/query_router.go index 2910445..75eecf5 100644 --- a/internal/router/query_router.go +++ b/internal/router/query_router.go @@ -22,6 +22,9 @@ import ( // ErrNoRoutingKey 는 단일 shard 키를 못 뽑은 경우(scatter-gather 필요)이다. var ErrNoRoutingKey = errors.New("router: no single-shard routing key (scatter-gather required)") +// ErrWriteBlocked 는 resharding cutover 중 쓰기가 일시 차단된 경우이다(읽기는 허용). +var ErrWriteBlocked = errors.New("router: writes blocked (resharding cutover in progress)") + // RouteDecision 은 한 쿼리의 라우팅 결정이다. type RouteDecision struct { // Shard 는 대상 shard 이름. Scatter=true 면 비어 있다. @@ -55,6 +58,10 @@ type QueryRouter struct { // 읽기 전용 쿼리(IsReadOnlyQuery)는 Read resolver(있으면)로 replica 에 분산한다. func (qr QueryRouter) Route(query string) (RouteDecision, error) { read := IsReadOnlyQuery(query) + // resharding cutover: 쓰기 일시 차단(읽기는 통과). 라우팅 전환 중 쓰기 유실 방지. + if !read && qr.Topology.Spec.WriteBlocked { + return RouteDecision{}, ErrWriteBlocked + } pick := qr.Write if read && qr.Read != nil { pick = qr.Read diff --git a/internal/router/query_router_test.go b/internal/router/query_router_test.go index 1069ade..99d3ec2 100644 --- a/internal/router/query_router_test.go +++ b/internal/router/query_router_test.go @@ -44,6 +44,27 @@ func TestQueryRouter_WriteRoutesToPrimaryShard(t *testing.T) { } } +func TestQueryRouter_WriteBlockedRejectsWritesAllowsReads(t *testing.T) { + qr := testQueryRouter() + qr.Topology.Spec.WriteBlocked = true // cutover write-block. + + // 쓰기는 ErrWriteBlocked. + if _, err := qr.Route("UPDATE t SET v = 1 WHERE tenant_id = 'alice'"); !errors.Is(err, ErrWriteBlocked) { + t.Fatalf("blocked write err = %v, want ErrWriteBlocked", err) + } + if _, err := qr.Route("INSERT INTO t (tenant_id) VALUES ('alice')"); !errors.Is(err, ErrWriteBlocked) { + t.Fatalf("blocked insert err = %v, want ErrWriteBlocked", err) + } + // 읽기는 통과(차단 중에도 SELECT 정상). + d, err := qr.Route("SELECT v FROM t WHERE tenant_id = 'alice'") + if err != nil { + t.Fatalf("blocked read err = %v, want nil (reads allowed)", err) + } + if !d.Read { + t.Fatal("SELECT should be Read") + } +} + func TestQueryRouter_ReadRoutesToReplica(t *testing.T) { qr := testQueryRouter() d, err := qr.Route("SELECT v FROM t WHERE tenant_id = 'alice'") From 7684cdcda5e031d5ae27e48a4d1556dfe5e86977 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 10:34:14 +0900 Subject: [PATCH 072/109] =?UTF-8?q?docs:=20Cutover=20write-block=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WORK_HANDOFF §6.6: write-block 완료(라우터 단위+envtest+라이브). 새 #1=멀티샤드 full e2e + CDC 증분. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/WORK_HANDOFF.ko.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index db9edcd..67817a8 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -225,8 +225,9 @@ SSOT는 [ROUTER-GAP-ANALYSIS §4 능력 사다리 + §6 백로그](sharding/ROUT | ~~·~~ ✅ | ~~reference table·read-replica 프록시 결선~~ | **완료(2026-06-28)** — main.go read resolver(env replica/status ResolveRead)+PGROUTER_REFERENCE_TABLES. 라이브: read→replica, ref→AnyShard, write→primary. | | ~~·~~ ✅ | ~~resharding 실데이터 이동(core)~~ | **완료(2026-06-28)** — `CopyShardRange`(hash-range 필터 copy)+`DeleteShardRange`(cutover). 라이브: 1..100 split→44/56 overlap=0 키유실0. | | ~~·~~ ✅ | ~~bufio 라우터 최적화~~ | **완료(2026-06-28)** — writeMessage 단일 write + 연결당 읽기 버퍼(bufConn). baseline §3.0e: unprepared +50%(8955→13391), prepared 1shard +34%. | -| ~~·~~ ✅ | ~~ShardSplitJob InitialCopy/Cleanup K8s 결선~~ | **완료(2026-06-28)** — InitialCopy(복사)+Cleanup(source 이동분 삭제)이 target 별 K8s Job(reshard-copy 이미지, 내부 trust 접속)으로 데이터 이동 + 완료 게이트. `shardsplitjob_copy.go`, envtest 검증(Job env·완료집계·실패보고·delete-only). pg_hba 내부 postgres trust라 자격 불요. 데이터 경로(복사→cutover→회수) 닫힘. | -| **1** | **ShardSplitJob full e2e + Cutover write-block** | 멀티샤드 클러스터에서 ShardSplitJob 전체 phase 라이브 e2e + Cutover write-block(라우팅 freeze, 무중단 cutover 마지막 정합성) + CDC 증분 catch-up. 데이터이동(복사/삭제) core·결선은 완성 | +| ~~·~~ ✅ | ~~ShardSplitJob InitialCopy/Cleanup K8s 결선~~ | **완료(2026-06-28)** — InitialCopy(복사)+Cleanup(source 이동분 삭제)이 target 별 K8s Job(reshard-copy 이미지, 내부 trust 접속)으로 데이터 이동 + 완료 게이트. `shardsplitjob_copy.go`, envtest 검증. 데이터 경로(복사→cutover→회수) 닫힘. | +| ~~·~~ ✅ | ~~Cutover write-block~~ | **완료(2026-06-28)** — ShardRangeSpec.WriteBlocked 신호 → 라우터가 쓰기 거부(읽기 통과), Cutover 가 set·RoutingUpdate 가 clear. router 단위+envtest+라이브(UPDATE→"writes blocked" hang 없음). simple-query 에러 hang 버그(ReadyForQuery 누락)도 수정. | +| **1** | **ShardSplitJob full e2e (멀티샤드 라이브)** | 멀티샤드 PostgresCluster 에서 ShardSplitJob 전 phase 라이브 e2e(operator 멀티샤드 배포 필요) + CDC 증분 catch-up(복사 중 변경분 논리복제). 데이터이동·결선·write-block 은 완성 | | **3** | **멀티머신 수평 스케일 실측** | 진짜 "분산처리능력" 수치 — 물리 분리 노드 필요(router-bench 가 샤드별 DSN 받음, 그대로 적용) | | **4** | **멀티 라우터 인스턴스 수평확장** | 라우터 1-hop 왕복이 남은 오버헤드(prepared direct 86K vs router 23K) — 라우터를 여러 개 띄워 처리량 확장 | | **6** | 보류 #5/#7/#9 | per-shard primary Service·watch·failover lease P2-T3 — 라이브 failover 필요 | From 419918c45397f02a2359d5ef1cc7406d36515174 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 10:58:43 +0900 Subject: [PATCH 073/109] =?UTF-8?q?feat(router-bench):=20=EB=A9=80?= =?UTF-8?q?=ED=8B=B0=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20=EC=9D=B8=EC=8A=A4?= =?UTF-8?q?=ED=84=B4=EC=8A=A4=20=EC=B8=A1=EC=A0=95=20(BENCH=5FROUTERS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit router-bench 가 여러 라우터 인스턴스 DSN(BENCH_ROUTERS csv)에 워커를 round-robin 분산해 1 vs N 인스턴스 처리량을 비교한다(라우터가 병목일 때 인스턴스 수평확장 측정). baseline §3.0f: 단일 호스트 2 인스턴스 ≈ 1 인스턴스(오히려 미세 감소) — 멀티샤드(§3.0d)와 동일한 물리 한계(2 샤드+2 라우터+클라가 같은 16코어 공유). 라우터는 stateless 라 N replica 독립·정확하고 오퍼레이터가 RouterSpec.Replicas(+HPA)로 이미 스케일 — capability 완비, 진짜 수치는 멀티머신 필요(BENCH_ROUTERS 가 멀티머신 DSN 수용). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- cmd/router-bench/main.go | 74 +++++++++++++++++++++++++++++++++------- docs/perf/baseline.md | 24 +++++++++++++ 2 files changed, 85 insertions(+), 13 deletions(-) diff --git a/cmd/router-bench/main.go b/cmd/router-bench/main.go index 6c231ff..7e47624 100644 --- a/cmd/router-bench/main.go +++ b/cmd/router-bench/main.go @@ -62,6 +62,8 @@ func main() { workers = envInts("BENCH_WORKERS", []int{1, 2, 4, 8, 16, 32}) mode = env("BENCH_MODE", "select") prepared = env("BENCH_PREPARED", "") != "" + // BENCH_ROUTERS: 여러 라우터 인스턴스 DSN(csv) — 멀티 라우터 수평확장 측정용. + routerDSNs = csvEnv("BENCH_ROUTERS", nil) ) spec := benchSpec() @@ -75,19 +77,35 @@ func main() { scenarios := []struct { name string - dsn string + dsns []string keys []int }{ - {"direct-shard0", shard0DSN, keysOnShard0}, - {"router-1shard", routerDSN, keysOnShard0}, - {"router-2shard", routerDSN, allKeys}, + {"direct-shard0", []string{shard0DSN}, keysOnShard0}, + {"router-1shard", []string{routerDSN}, keysOnShard0}, + {"router-2shard", []string{routerDSN}, allKeys}, + } + // 멀티 라우터 수평확장: 같은 워크로드를 1개 vs N개 라우터 인스턴스에 워커 round-robin + // 분산해 비교(라우터가 병목일 때 인스턴스를 늘려 처리량이 스케일하는지). + if len(routerDSNs) > 1 { + scenarios = append(scenarios, + struct { + name string + dsns []string + keys []int + }{"router-1inst", []string{routerDSNs[0]}, allKeys}, + struct { + name string + dsns []string + keys []int + }{fmt.Sprintf("router-%dinst", len(routerDSNs)), routerDSNs, allKeys}, + ) } fmt.Printf("\n%-16s %8s %12s %12s %10s\n", "scenario", "workers", "ops", "TPS", "avg_ms") fmt.Println(strings.Repeat("-", 62)) for _, sc := range scenarios { for _, w := range workers { - ops, avgMs := run(sc.dsn, sc.keys, w, dur, mode, prepared) + ops, avgMs := run(sc.dsns, sc.keys, w, dur, mode, prepared) tps := float64(ops) / dur.Seconds() fmt.Printf("%-16s %8d %12d %12.0f %10.3f\n", sc.name, w, ops, tps, avgMs) } @@ -128,14 +146,28 @@ func seed(shard0DSN, shard1DSN string, spec v1alpha1.ShardRangeSpec, keys int) [ return onShard0 } -// run 은 db 에 w 개 워커로 dur 동안 점 쿼리를 던지고 (총 ops, 평균 지연ms)를 반환한다. +// run 은 dsns 에 w 개 워커로 dur 동안 점 쿼리를 던지고 (총 ops, 평균 지연ms)를 반환한다. +// dsns 가 여러 개면 워커를 round-robin 분산한다(멀티 라우터 인스턴스 수평확장 측정). // prepared 면 워커마다 연결을 고정하고 stmt 를 *한 번만* Parse 한 뒤 Bind/Execute 를 반복 // 한다(키당 Parse 제거 — 라우터는 샤드별 prepare-on-first-use 로 lazy prepare). -func run(dsn string, keys []int, w int, dur time.Duration, mode string, prepared bool) (int64, float64) { - db := mustOpen(dsn) - defer db.Close() - db.SetMaxOpenConns(w) - db.SetMaxIdleConns(w) +func run(dsns []string, keys []int, w int, dur time.Duration, mode string, prepared bool) (int64, float64) { + n := len(dsns) + perDB := make([]int, n) // dsn 별 워커 수(MaxOpenConns 설정용). + for i := 0; i < w; i++ { + perDB[i%n]++ + } + dbs := make([]*sql.DB, n) + for i, dsn := range dsns { + db := mustOpen(dsn) + c := perDB[i] + if c < 1 { + c = 1 + } + db.SetMaxOpenConns(c) + db.SetMaxIdleConns(c) + dbs[i] = db + defer db.Close() + } query := `SELECT val FROM kv WHERE id=$1` if mode == "update" { @@ -147,10 +179,11 @@ func run(dsn string, keys []int, w int, dur time.Duration, mode string, prepared var wg sync.WaitGroup for i := 0; i < w; i++ { wg.Add(1) - go func(seed int64) { + go func(idx int, seed int64) { defer wg.Done() rng := rand.New(rand.NewSource(seed)) ctx := context.Background() + db := dbs[idx%n] // 워커를 라우터 인스턴스에 round-robin. // 워커별 연결 고정(라우터 세션 1개). prepared 면 stmt 를 한 번만 준비. conn, err := db.Conn(ctx) @@ -178,7 +211,7 @@ func run(dsn string, keys []int, w int, dur time.Duration, mode string, prepared atomic.AddInt64(&totalNs, time.Since(t0).Nanoseconds()) atomic.AddInt64(&ops, 1) } - }(int64(i) + 1) + }(i, int64(i)+1) } wg.Wait() avgMs := 0.0 @@ -243,6 +276,21 @@ func envInt(k string, def int) int { return def } +// csvEnv 는 콤마 구분 env 를 trim 해 분리한다(빈 값 제거). 부재 시 def. +func csvEnv(k string, def []string) []string { + v := os.Getenv(k) + if strings.TrimSpace(v) == "" { + return def + } + var out []string + for _, p := range strings.Split(v, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + func envDur(k string, def time.Duration) time.Duration { if v := os.Getenv(k); v != "" { if d, err := time.ParseDuration(v); err == nil { diff --git a/docs/perf/baseline.md b/docs/perf/baseline.md index c320022..464fe05 100644 --- a/docs/perf/baseline.md +++ b/docs/perf/baseline.md @@ -241,6 +241,30 @@ BENCH_KEYS=10000 BENCH_DURATION=5s BENCH_WORKERS=1,2,4,8,16,32 BENCH_MODE=select - 남은 라우터 오버헤드(prepared direct 86K vs router 16~23K)는 프록시 1-hop 의 본질적 왕복 지연 — 멀티 라우터 인스턴스로 수평 확장하는 영역. +### 3.0f 실측 5차 — 멀티 라우터 인스턴스 (2026-06-28) + +라우터가 병목(§3.0e)이므로 *라우터 인스턴스*를 늘리면 단일 호스트에서도 스케일할까? +`router-bench BENCH_ROUTERS`(워커를 라우터 인스턴스에 round-robin)로 1 vs 2 인스턴스 비교 +(prepared select, 2 샤드 + 2 라우터 + 클라이언트 모두 같은 16 vCPU 호스트). + +| 시나리오 | w=8 | w=16 | w=32 | w=64 | +|---|---|---|---|---| +| router-1inst | 10,969 | 16,709 | 21,882 | 26,355 | +| router-2inst | 10,643 | 17,551 | 19,345 | 24,406 | + +**해석**: +- **2 인스턴스 ≈ 1 인스턴스 (오히려 약간 낮음)** — 멀티샤드(§3.0d)와 동일한 단일 호스트 + 물리 한계. 라우터가 병목이지만, 라우터 프로세스를 2개로 늘려도 *같은 16코어*를 2 샤드· + 2 라우터·클라이언트가 공유하므로 합산 CPU 가 불변 → 스케일 없음(+인스턴스 오버헤드로 미세 + 감소). +- 라우터는 **stateless**(per-instance 토폴로지 캐시·per-connection 세션·per-instance breaker) + 라 N replica 가 독립·정확. 오퍼레이터는 이미 `RouterSpec.Replicas`(+HPA `RouterAutoscaleSpec`) + 로 router Deployment 를 스케일한다 — **capability 는 완비**. +- ⇒ 멀티 라우터 수평 스케일도 **물리 분리 노드(별 CPU)에서만 수치로 드러난다**. router-bench + 의 `BENCH_ROUTERS` 가 멀티머신 라우터 DSN 을 그대로 받으므로 그 환경에서 측정하면 된다. + +**재현**: `BENCH_ROUTERS="host=r1...,host=r2..." BENCH_PREPARED=1 go run ./cmd/router-bench` + ### 3.1 Single-shard baseline (T-1S) | date | workload | iso | clients | TPS | P50 ms | P95 ms | P99 ms | comment | From 0e007d2b308168b485998edf2fefb342d7ba0b6a Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 11:17:22 +0900 Subject: [PATCH 074/109] =?UTF-8?q?feat(router):=20resharding=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=EC=9A=B0=EC=84=A0=20=EB=B3=B5=EC=A0=9C=20?= =?UTF-8?q?(target=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CopyShardRange 가 데이터 복사 전 target 에 테이블이 없으면 source 의 컬럼 정의 (format_type 로 충실한 타입)로 CREATE TABLE IF NOT EXISTS 한다. resharding 은 빈 새 shard 로 옮기므로 DDL 이 먼저 있어야 INSERT 가능 — full e2e 에서 발견한 갭 ("relation does not exist"). PK/인덱스/제약은 후속(데이터 무결성은 vindex copy 가 보장). 라이브 full e2e(kind, 단일샤드→split)에서 검증: shard-0(키 1..100) → ShardSplitJob → Bootstrap(target rsd-t0/t1 부팅) → InitialCopy(스키마+데이터 복사) → Cutover (write-block) → RoutingUpdate(ShardRange flip+unblock) → Cleanup(source 삭제) → Completed. 결과: t0=44/t1=56/shard-0=0, 합=100 키유실0, ShardRange flip, write-block 해제. **온라인 resharding 전 phase 실 K8s+실 PG 동작 증명.** Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- internal/router/reshard_copy.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/internal/router/reshard_copy.go b/internal/router/reshard_copy.go index 9a54ecf..39b1444 100644 --- a/internal/router/reshard_copy.go +++ b/internal/router/reshard_copy.go @@ -107,6 +107,12 @@ func CopyShardRange(ctx context.Context, sourceDSN, targetDSN, table string, spe } defer func() { _ = tgt.Close() }() + // target 에 테이블이 없으면 source 의 컬럼 정의로 만든다(스키마 우선 복제 — resharding 은 + // 빈 새 shard 로 옮기므로 DDL 이 먼저 있어야 INSERT 가능). + if err := ensureTargetTable(ctx, src, tgt, table); err != nil { + return 0, 0, err + } + rows, err := src.QueryContext(ctx, "SELECT * FROM "+table) //nolint:gosec // table는 화이트리스트 검증됨 if err != nil { return 0, 0, fmt.Errorf("router: source select %s: %w", table, err) @@ -248,6 +254,28 @@ func FilterTables(all, exclude []string) []string { return out } +// ensureTargetTable 은 target 에 table 이 없으면 source 의 컬럼 정의(format_type 로 충실한 +// 타입)로 CREATE TABLE 한다. PK/인덱스/제약은 복제하지 않는다(PoC — 데이터 무결성은 copy 가 +// vindex 로 보장; 인덱스는 cutover 후 별도 생성 트랙). 이미 있으면 no-op(IF NOT EXISTS). +func ensureTargetTable(ctx context.Context, src, tgt *sql.DB, table string) error { + var ddl string + err := src.QueryRowContext(ctx, ` + SELECT 'CREATE TABLE IF NOT EXISTS '||quote_ident($1)||' ('|| + string_agg(quote_ident(a.attname)||' '||format_type(a.atttypid, a.atttypmod), ', ' ORDER BY a.attnum)||')' + FROM pg_attribute a + WHERE a.attrelid = $1::regclass AND a.attnum > 0 AND NOT a.attisdropped`, table).Scan(&ddl) + if err != nil { + return fmt.Errorf("router: read source schema %s: %w", table, err) + } + if ddl == "" { + return fmt.Errorf("router: source table %s has no columns", table) + } + if _, err := tgt.ExecContext(ctx, ddl); err != nil { + return fmt.Errorf("router: create target table %s: %w", table, err) + } + return nil +} + // keyString 은 row 값(any)을 vindex 키 문자열로 정규화한다 — lib/pq 는 text 를 []byte 로 // 돌려주므로 string 변환(fmt.Sprint 면 "[97 ...]" 가 됨). 라우터의 키 추출(문자열)과 일치. func keyString(v any) string { From a601e0a9712fb2504d4e12c9d1ac8cd904ea14c9 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 11:18:38 +0900 Subject: [PATCH 075/109] =?UTF-8?q?docs:=20=EB=A9=80=ED=8B=B0=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=84=B0=20+=20resharding=20full=20e2e=20=EC=84=B1?= =?UTF-8?q?=EA=B3=B5=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WORK_HANDOFF §6.6 + ROUTER-GAP-ANALYSIS: full e2e(kind 실 K8s+실 PG) 성공 — 전 phase 동작, t0=44/t1=56/source=0 키유실0, ShardRange flip, write-block 해제. 새 #1=CDC 증분 catch-up, #2=resharding 운영화(인덱스 복제·RESHARD_COPY_IMAGE manager env·target 승격). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/WORK_HANDOFF.ko.md | 6 ++++-- docs/sharding/ROUTER-GAP-ANALYSIS.ko.md | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index 67817a8..bab2049 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -226,8 +226,10 @@ SSOT는 [ROUTER-GAP-ANALYSIS §4 능력 사다리 + §6 백로그](sharding/ROUT | ~~·~~ ✅ | ~~resharding 실데이터 이동(core)~~ | **완료(2026-06-28)** — `CopyShardRange`(hash-range 필터 copy)+`DeleteShardRange`(cutover). 라이브: 1..100 split→44/56 overlap=0 키유실0. | | ~~·~~ ✅ | ~~bufio 라우터 최적화~~ | **완료(2026-06-28)** — writeMessage 단일 write + 연결당 읽기 버퍼(bufConn). baseline §3.0e: unprepared +50%(8955→13391), prepared 1shard +34%. | | ~~·~~ ✅ | ~~ShardSplitJob InitialCopy/Cleanup K8s 결선~~ | **완료(2026-06-28)** — InitialCopy(복사)+Cleanup(source 이동분 삭제)이 target 별 K8s Job(reshard-copy 이미지, 내부 trust 접속)으로 데이터 이동 + 완료 게이트. `shardsplitjob_copy.go`, envtest 검증. 데이터 경로(복사→cutover→회수) 닫힘. | -| ~~·~~ ✅ | ~~Cutover write-block~~ | **완료(2026-06-28)** — ShardRangeSpec.WriteBlocked 신호 → 라우터가 쓰기 거부(읽기 통과), Cutover 가 set·RoutingUpdate 가 clear. router 단위+envtest+라이브(UPDATE→"writes blocked" hang 없음). simple-query 에러 hang 버그(ReadyForQuery 누락)도 수정. | -| **1** | **ShardSplitJob full e2e (멀티샤드 라이브)** | 멀티샤드 PostgresCluster 에서 ShardSplitJob 전 phase 라이브 e2e(operator 멀티샤드 배포 필요) + CDC 증분 catch-up(복사 중 변경분 논리복제). 데이터이동·결선·write-block 은 완성 | +| ~~·~~ ✅ | ~~Cutover write-block~~ | **완료(2026-06-28)** — ShardRangeSpec.WriteBlocked 신호 → 라우터가 쓰기 거부(읽기 통과), Cutover 가 set·RoutingUpdate 가 clear. router 단위+envtest+라이브. simple-query 에러 hang 버그도 수정. | +| ~~·~~ ✅ | ~~ShardSplitJob full e2e (라이브)~~ | **완료(2026-06-28)** 🎉 — kind 실 K8s+실 PG: 단일샤드(키 1..100)→ShardSplitJob→Bootstrap(target rsd-t0/t1 부팅)→InitialCopy(스키마+데이터 복사 Job)→Cutover(write-block)→RoutingUpdate(ShardRange flip+unblock)→Cleanup(source 삭제 Job)→Completed. **결과 t0=44/t1=56/source=0, 합=100 키유실0, ShardRange flip, write-block 해제.** e2e 가 갭 2개 발견·수정(Job 이미지명 env RESHARD_COPY_IMAGE, 스키마 우선 복제). | +| **1** | **CDC 증분 catch-up** | InitialCopy 중 source 변경분을 논리복제로 따라잡기(복사 시작~cutover 사이 라이브 쓰기 보존 = 진짜 무중단). 현 e2e 는 정적 데이터(동시쓰기 없음)라 CDCCatchup pass-through. 운영 무중단엔 필요 | +| **2** | **resharding 운영화 마감** | target 인덱스/PK 복제(현재 데이터만), RESHARD_COPY_IMAGE 를 operator manager env 기본 결선, target shard 영구 승격(ordinal 편입) | | **3** | **멀티머신 수평 스케일 실측** | 진짜 "분산처리능력" 수치 — 물리 분리 노드 필요(router-bench 가 샤드별 DSN 받음, 그대로 적용) | | **4** | **멀티 라우터 인스턴스 수평확장** | 라우터 1-hop 왕복이 남은 오버헤드(prepared direct 86K vs router 23K) — 라우터를 여러 개 띄워 처리량 확장 | | **6** | 보류 #5/#7/#9 | per-shard primary Service·watch·failover lease P2-T3 — 라이브 failover 필요 | diff --git a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md index 83ac351..857e6c3 100644 --- a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md +++ b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md @@ -133,7 +133,7 @@ parameterized/`t.col`/주석·문자열 내부 오인 방지까지 검증. **기 - [x] **읽기 → replica 라우팅** ✅(2026-06-28): main.go read resolver 결선 — env `PGROUTER_BACKEND__REPLICA`(없으면 primary fallback) / status `StatusBackendResolver.ResolveRead`(Ready replica, failover-aware). 라이브: read alice→shard-0 replica, write→primary. - [x] **scatter-gather 실연결** ✅: `scattermode.go` 병렬 fan-out + UNION ALL 병합(라이브). 집계 재merge·전역 ORDER BY·LIMIT pushdown은 후속. - [x] **Reference table** ✅(2026-06-28): `shardSpec` `PGROUTER_REFERENCE_TABLES`(CSV) → reference-only 쿼리가 scatter 대신 AnyShard. 라이브: `SELECT FROM country` → 한 샤드(scatter 아님). -- [~] **무중단 resharding 데이터 이동**: **데이터이동 core 완료(2026-06-28)** — `CopyShardRange`(vindex 키가 target shard 로 가는 row 만 hash-range 필터 복사, 라우팅과 동일 ResolveShard, reversible) + `DeleteShardRange`(cutover 후 source 정리). 라이브: 키 1..100 split → source 44(shard-0)/target 56(shard-1), overlap=0 키유실0. **InitialCopy 컨트롤러 결선 완료(2026-06-28)**: ShardSplitJob InitialCopy phase 가 target 별 K8s Job(reshard-copy 이미지, 클러스터 내부 trust 접속)으로 데이터 복사 + 완료까지 phase 게이트(`shardsplitjob_copy.go`, envtest 검증). pg_hba 가 내부 postgres 를 trust 하므로 자격증명 불요. **남음**: 멀티샤드 클러스터 full e2e + Cutover write-block + Cleanup row-delete Job + 논리복제 CDC 증분 catch-up. +- [~] **무중단 resharding 데이터 이동**: **데이터이동 core 완료(2026-06-28)** — `CopyShardRange`(vindex 키가 target shard 로 가는 row 만 hash-range 필터 복사, 라우팅과 동일 ResolveShard, reversible) + `DeleteShardRange`(cutover 후 source 정리). 라이브: 키 1..100 split → source 44(shard-0)/target 56(shard-1), overlap=0 키유실0. **InitialCopy 컨트롤러 결선 완료(2026-06-28)**: ShardSplitJob InitialCopy phase 가 target 별 K8s Job(reshard-copy 이미지, 클러스터 내부 trust 접속)으로 데이터 복사 + 완료까지 phase 게이트(`shardsplitjob_copy.go`, envtest 검증). pg_hba 가 내부 postgres 를 trust 하므로 자격증명 불요. **Cleanup(source 삭제 Job)·Cutover write-block(ShardRangeSpec.WriteBlocked→라우터 쓰기거부)도 결선 완료.** **full e2e 성공(2026-06-28, kind 실 K8s+실 PG)**: 단일샤드(키 1..100)→ShardSplitJob→전 phase→Completed, t0=44/t1=56/source=0 합=100 키유실0 + ShardRange flip + write-block 해제. e2e 가 갭 2개 발견·수정(Job 이미지 env, 스키마 우선 복제 `ensureTargetTable`). **남음**: 논리복제 CDC 증분 catch-up(복사 중 라이브 쓰기 보존=진짜 무중단), target 인덱스/PK 복제, target 영구 승격. - [ ] **cross-shard 2PC**: 사다리 6단계. **현재 명시적 범위 밖**(ROI 최저). 멀티테넌트 v1엔 불필요. - [ ] **커넥션 풀링 (D)**: `SQLShardExecutor`의 per-call `sql.Open`/`Close` 제거 → 풀 + prepared stmt 캐시. scatter 경로 성능. (단일샤드 TCP 프록시엔 불필요.) From 0e9bb528a04d1d360a815921a88973ec163c07d9 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 12:09:11 +0900 Subject: [PATCH 076/109] =?UTF-8?q?feat(resharding):=20CDC=20=EC=A6=9D?= =?UTF-8?q?=EB=B6=84=20catch-up=20=EB=B9=8C=EB=94=A9=EB=B8=94=EB=A1=9D=20(?= =?UTF-8?q?=EB=85=BC=EB=A6=AC=EB=B3=B5=EC=A0=9C)=20+=20wal=5Flevel=3Dlogic?= =?UTF-8?q?al?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 무중단 resharding 의 핵심 — InitialCopy 중 들어온 라이브 쓰기를 논리복제로 따라잡는다. - wal_level=logical(builders.go): 물리 streaming HA 의 상위집합이라 HA 호환 + 논리복제 (CDC) 가능. 약간의 WAL 증가↔resharding 필수. - reshard_cdc.go: CreatePublication / CreateSubscription(copy_data) / SubscriptionLagBytes / DropSubscription / DropPublication / DeleteForeignRange(범위 밖 row 삭제). 식별자 화이트리스트. - 접근(정합성 우선): subscription copy_data=true 면 PG 가 일관 초기복사+지속 스트림 보장 (exported-snapshot 조율 불요). hash vindex 는 PG row-filter 불가라 전 테이블 받고 cutover 시 lag→0 확인 후 DeleteForeignRange 로 자기 범위만 남김(2× 복사↔정합·무중단 trade). 검증: 단위(injection 차단) + **라이브(2 PG wal_level=logical)**: subscription 이 초기 50 + *구독 이후 라이브 INSERT 50 + UPDATE* 전부 target 복제(100행, 변경 반영) → 라이브 쓰기 유실 0. DeleteForeignRange 56 삭제(shard-1)/44 잔존(shard-0), 키보존 100. ⇒ **무중단 CDC 메커니즘 동작 증명.** 남은 것: 컨트롤러 CDCCatchup phase 가 online(subscription) vs offline(CopyShardRange) 모드 선택해 결선(아키텍처 결정 — 별 증분). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- internal/controller/builders.go | 6 +- internal/router/reshard_cdc.go | 201 +++++++++++++++++++++++ internal/router/reshard_cdc_live_test.go | 148 +++++++++++++++++ internal/router/reshard_copy_test.go | 22 +++ 4 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 internal/router/reshard_cdc.go create mode 100644 internal/router/reshard_cdc_live_test.go diff --git a/internal/controller/builders.go b/internal/controller/builders.go index e3cbe0f..6da34b1 100644 --- a/internal/controller/builders.go +++ b/internal/controller/builders.go @@ -323,8 +323,10 @@ func renderPostgresConf( sb.WriteString("port = 5432\n") // Unix socket 위치 — instance manager 의 LocalDSN 이 본 경로에 의존. fmt.Fprintf(&sb, "unix_socket_directories = '%s'\n", pgRunDir) - // WAL + replication 기본값 — replicas>0 일 때 streaming replication 전제. - sb.WriteString("wal_level = replica\n") + // WAL + replication 기본값. logical: 물리 streaming replication(HA)의 상위집합이라 + // replicas HA 와 호환되며, online resharding 의 CDC 증분 catch-up(논리복제 subscription) + // 을 가능케 한다. 약간의 WAL 증가가 있으나 분산 SQL(resharding) 제품엔 필수. + sb.WriteString("wal_level = logical\n") // pg_rewind 전제. data checksums 없는 기존 스토리지에서도 failover 후 // former primary 를 current primary timeline 으로 되감을 수 있게 한다. sb.WriteString("wal_log_hints = on\n") diff --git a/internal/router/reshard_cdc.go b/internal/router/reshard_cdc.go new file mode 100644 index 0000000..0dd8cfc --- /dev/null +++ b/internal/router/reshard_cdc.go @@ -0,0 +1,201 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +// Package router — reshard_cdc.go 는 online resharding 의 *CDC 증분 catch-up* 빌딩블록이다. +// +// 무중단 resharding: InitialCopy(bulk)만으로는 복사 중 source 에 들어온 라이브 쓰기를 놓친다. +// PostgreSQL 논리복제(PUBLICATION/SUBSCRIPTION)로 그 변경분을 target 에 따라잡게 한다. +// +// 접근(정합성 우선·단순): subscription 을 copy_data=true 로 만들면 PG 가 *일관된 초기 복사 + +// 지속 스트림* 을 모두 보장한다(exported-snapshot 조율 불요). 단 hash vindex 는 PG row-filter +// 로 표현 불가하므로 target 은 *전 테이블* 을 받는다 — cutover 시 lag→0 확인 후 subscription 을 +// 끊고 `DeleteForeignRange` 로 자기 범위 밖 row 를 지운다(2× 복사 비용↔정합성·무중단 trade). +// +// 전제: source 의 wal_level=logical (builders.go 기본값). 자격은 클러스터 내부 trust(pg_hba). +package router + +import ( + "context" + "database/sql" + "fmt" + "strings" + + _ "github.com/lib/pq" // postgres driver + + "github.com/keiailab/postgres-operator/api/v1alpha1" +) + +// pubSubNamePattern 은 publication/subscription 식별자 화이트리스트(injection 차단 — 이름은 +// placeholder 바인딩 불가). +var pubSubNamePattern = tableNamePattern + +// CreatePublication 은 source 에 지정 테이블(빈 목록이면 전 테이블)의 publication 을 멱등 +// 생성한다(DROP IF EXISTS 후 CREATE). +func CreatePublication(ctx context.Context, sourceDSN, pubName string, tables []string) error { + if !pubSubNamePattern.MatchString(pubName) { + return fmt.Errorf("%w: publication %q", ErrInvalidTable, pubName) + } + for _, t := range tables { + if !tableNamePattern.MatchString(t) { + return fmt.Errorf("%w: table %q", ErrInvalidTable, t) + } + } + db, err := sql.Open("postgres", sourceDSN) + if err != nil { + return fmt.Errorf("router: open source: %w", err) + } + defer func() { _ = db.Close() }() + + if _, err := db.ExecContext(ctx, "DROP PUBLICATION IF EXISTS "+pubName); err != nil { //nolint:gosec // 화이트리스트 검증됨 + return fmt.Errorf("router: drop publication: %w", err) + } + target := "FOR ALL TABLES" + if len(tables) > 0 { + target = "FOR TABLE " + strings.Join(tables, ", ") + } + if _, err := db.ExecContext(ctx, "CREATE PUBLICATION "+pubName+" "+target); err != nil { //nolint:gosec // 화이트리스트 검증됨 + return fmt.Errorf("router: create publication: %w", err) + } + return nil +} + +// CreateSubscription 은 target 에 source publication 을 구독하는 subscription 을 멱등 생성한다. +// copyData=true 면 PG 가 일관 초기복사+스트림, false 면 (InitialCopy 가 이미 bulk 한 경우) +// 슬롯 시점 이후 변경분만 스트림한다. sourceConnInfo 는 libpq conninfo(target→source 접속). +func CreateSubscription(ctx context.Context, targetDSN, sourceConnInfo, subName, pubName string, copyData bool) error { + if !pubSubNamePattern.MatchString(subName) || !pubSubNamePattern.MatchString(pubName) { + return fmt.Errorf("%w: subscription/publication name", ErrInvalidTable) + } + db, err := sql.Open("postgres", targetDSN) + if err != nil { + return fmt.Errorf("router: open target: %w", err) + } + defer func() { _ = db.Close() }() + + // conninfo 는 SQL 문자열 리터럴로 들어가므로 작은따옴표를 이스케이프. + conn := strings.ReplaceAll(sourceConnInfo, "'", "''") + stmt := fmt.Sprintf("CREATE SUBSCRIPTION %s CONNECTION '%s' PUBLICATION %s WITH (copy_data = %t)", + subName, conn, pubName, copyData) + if _, err := db.ExecContext(ctx, stmt); err != nil { //nolint:gosec // 이름 화이트리스트 + conninfo 이스케이프 + return fmt.Errorf("router: create subscription: %w", err) + } + return nil +} + +// SubscriptionLagBytes 는 source 측에서 subscription 슬롯의 미반영 WAL(bytes)을 잰다. 0 에 +// 가까우면 catch-up 완료. 슬롯이 없으면 -1. +func SubscriptionLagBytes(ctx context.Context, sourceDSN, slotName string) (int64, error) { + if !pubSubNamePattern.MatchString(slotName) { + return -1, fmt.Errorf("%w: slot %q", ErrInvalidTable, slotName) + } + db, err := sql.Open("postgres", sourceDSN) + if err != nil { + return -1, fmt.Errorf("router: open source: %w", err) + } + defer func() { _ = db.Close() }() + + var lag sql.NullInt64 + err = db.QueryRowContext(ctx, ` + SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)::bigint + FROM pg_replication_slots WHERE slot_name = $1`, slotName).Scan(&lag) + if err == sql.ErrNoRows { + return -1, nil + } + if err != nil { + return -1, fmt.Errorf("router: slot lag: %w", err) + } + if !lag.Valid { + return -1, nil + } + return lag.Int64, nil +} + +// DropSubscription 은 target 의 subscription 을 제거한다(원격 슬롯도 정리 시도). 슬롯 정리가 +// 실패해도(원격 미도달) subscription 은 끊고 진행한다. +func DropSubscription(ctx context.Context, targetDSN, subName string) error { + if !pubSubNamePattern.MatchString(subName) { + return fmt.Errorf("%w: subscription %q", ErrInvalidTable, subName) + } + db, err := sql.Open("postgres", targetDSN) + if err != nil { + return fmt.Errorf("router: open target: %w", err) + } + defer func() { _ = db.Close() }() + if _, err := db.ExecContext(ctx, "DROP SUBSCRIPTION IF EXISTS "+subName); err != nil { //nolint:gosec // 화이트리스트 + return fmt.Errorf("router: drop subscription: %w", err) + } + return nil +} + +// DropPublication 은 source 의 publication 을 제거한다. +func DropPublication(ctx context.Context, sourceDSN, pubName string) error { + if !pubSubNamePattern.MatchString(pubName) { + return fmt.Errorf("%w: publication %q", ErrInvalidTable, pubName) + } + db, err := sql.Open("postgres", sourceDSN) + if err != nil { + return fmt.Errorf("router: open source: %w", err) + } + defer func() { _ = db.Close() }() + if _, err := db.ExecContext(ctx, "DROP PUBLICATION IF EXISTS "+pubName); err != nil { //nolint:gosec // 화이트리스트 + return fmt.Errorf("router: drop publication: %w", err) + } + return nil +} + +// DeleteForeignRange 는 target 테이블에서 keepShard 에 *속하지 않는* row 를 삭제한다 — 전 +// 테이블 subscription(copy_data=true) 후 자기 범위만 남기는 정리. 삭제 row 수 반환. +func DeleteForeignRange(ctx context.Context, targetDSN, table string, spec v1alpha1.ShardRangeSpec, keepShard string) (int, error) { + if !tableNamePattern.MatchString(table) { + return 0, fmt.Errorf("%w: %q", ErrInvalidTable, table) + } + keyCol := spec.Vindex.Column + if !tableNamePattern.MatchString(keyCol) { + return 0, fmt.Errorf("%w: vindex column %q", ErrInvalidTable, keyCol) + } + db, err := sql.Open("postgres", targetDSN) + if err != nil { + return 0, fmt.Errorf("router: open target: %w", err) + } + defer func() { _ = db.Close() }() + + rows, err := db.QueryContext(ctx, "SELECT DISTINCT "+keyCol+" FROM "+table) //nolint:gosec // 화이트리스트 + if err != nil { + return 0, fmt.Errorf("router: distinct keys %s: %w", table, err) + } + var foreign []any + for rows.Next() { + var v any + if err := rows.Scan(&v); err != nil { + _ = rows.Close() + return 0, fmt.Errorf("router: scan key: %w", err) + } + shard, err := ResolveShard(spec, keyString(v)) + if err != nil { + _ = rows.Close() + return 0, fmt.Errorf("router: resolve key: %w", err) + } + if shard != keepShard { + foreign = append(foreign, v) + } + } + _ = rows.Close() + if err := rows.Err(); err != nil { + return 0, fmt.Errorf("router: rows %s: %w", table, err) + } + + deleted := 0 + del := "DELETE FROM " + table + " WHERE " + keyCol + " = $1" //nolint:gosec // 화이트리스트 + for _, k := range foreign { + res, err := db.ExecContext(ctx, del, k) + if err != nil { + return deleted, fmt.Errorf("router: delete foreign key in %s: %w", table, err) + } + n, _ := res.RowsAffected() + deleted += int(n) + } + return deleted, nil +} diff --git a/internal/router/reshard_cdc_live_test.go b/internal/router/reshard_cdc_live_test.go new file mode 100644 index 0000000..7b414fa --- /dev/null +++ b/internal/router/reshard_cdc_live_test.go @@ -0,0 +1,148 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package router + +import ( + "context" + "database/sql" + "os" + "testing" + "time" +) + +// TestCDCLive 는 CDC 증분 catch-up 빌딩블록을 *라이브 PG*(wal_level=logical)로 검증한다. +// env 미설정 시 skip(일반 go test 무영향). 검증: subscription(copy_data=true)이 초기 행 + +// *구독 이후 들어온 라이브 쓰기* 를 모두 target 에 복제하고, DeleteForeignRange 가 범위 밖 +// row 만 제거하는지. +// +// 실행: +// RESHARD_LIVE_SOURCE="host=pg-src ..." RESHARD_LIVE_TARGET="host=pg-tgt ..." +// RESHARD_LIVE_CONNINFO="host=pg-src ..."(target→source 접속) go test -run TestCDCLive +func TestCDCLive(t *testing.T) { + sourceDSN := os.Getenv("RESHARD_LIVE_SOURCE") + targetDSN := os.Getenv("RESHARD_LIVE_TARGET") + connInfo := os.Getenv("RESHARD_LIVE_CONNINFO") + if sourceDSN == "" || targetDSN == "" || connInfo == "" { + t.Skip("RESHARD_LIVE_SOURCE/TARGET/CONNINFO 미설정 — 라이브 CDC 테스트 skip") + } + ctx := context.Background() + spec := specWithCol("id") // shard-0 [0,7fff], shard-1 [8000,ffff] murmur3. + + src := mustOpenT(t, sourceDSN) + defer src.Close() + tgt := mustOpenT(t, targetDSN) + defer tgt.Close() + + // source: kv 초기 1..50. + exec(t, src, `DROP TABLE IF EXISTS kv`) + exec(t, src, `CREATE TABLE kv(id int PRIMARY KEY, val int)`) + exec(t, src, `INSERT INTO kv SELECT g, g*10 FROM generate_series(1,50) g`) + exec(t, tgt, `DROP TABLE IF EXISTS kv`) + exec(t, tgt, `CREATE TABLE kv(id int PRIMARY KEY, val int)`) + + // cleanup any prior pub/sub. + _ = DropSubscription(ctx, targetDSN, "sub_cdc") + _ = DropPublication(ctx, sourceDSN, "pub_cdc") + defer func() { + _ = DropSubscription(ctx, targetDSN, "sub_cdc") + _ = DropPublication(ctx, sourceDSN, "pub_cdc") + }() + + if err := CreatePublication(ctx, sourceDSN, "pub_cdc", []string{"kv"}); err != nil { + t.Fatalf("CreatePublication: %v", err) + } + if err := CreateSubscription(ctx, targetDSN, connInfo, "sub_cdc", "pub_cdc", true); err != nil { + t.Fatalf("CreateSubscription: %v", err) + } + + // *구독 이후* 라이브 쓰기 — CDC 가 따라잡아야 함. + exec(t, src, `INSERT INTO kv SELECT g, g*10 FROM generate_series(51,100) g`) + exec(t, src, `UPDATE kv SET val = 999 WHERE id = 1`) + + // lag → 0 까지 대기(최대 30s). + caughtUp := false + for i := 0; i < 60; i++ { + lag, err := SubscriptionLagBytes(ctx, sourceDSN, "sub_cdc") + if err == nil && lag >= 0 && lag <= 0 { + // 추가로 target row 수 확인(스트림 적용 확인). + if countT(t, tgt, "SELECT count(*) FROM kv") == 100 && countT(t, tgt, "SELECT val FROM kv WHERE id=1") == 999 { + caughtUp = true + break + } + } + time.Sleep(500 * time.Millisecond) + } + if !caughtUp { + t.Fatalf("CDC 미수렴: target count=%d, id1.val=%d (100/999 기대)", + countT(t, tgt, "SELECT count(*) FROM kv"), countT(t, tgt, "SELECT val FROM kv WHERE id=1")) + } + t.Logf("CDC 수렴: target 에 초기 50 + 라이브 50 + UPDATE 반영 = 100 행") + + // cutover 정리: subscription 끊고 범위 밖(=shard-1) row 삭제 → target=shard-0 만. + if err := DropSubscription(ctx, targetDSN, "sub_cdc"); err != nil { + t.Fatalf("DropSubscription: %v", err) + } + deleted, err := DeleteForeignRange(ctx, targetDSN, "kv", spec, "shard-0") + if err != nil { + t.Fatalf("DeleteForeignRange: %v", err) + } + keep := countT(t, tgt, "SELECT count(*) FROM kv") + t.Logf("DeleteForeignRange: %d 삭제(shard-1 키), target 잔존 %d(shard-0 키)", deleted, keep) + if keep+deleted != 100 { + t.Fatalf("키 보존 위반: keep(%d)+deleted(%d) != 100", keep, deleted) + } + // 잔존 row 가 전부 shard-0 인지 확인. + ids := allIDs(t, tgt) + for _, id := range ids { + if s, _ := ResolveShard(spec, id); s != "shard-0" { + t.Fatalf("target 에 범위 밖 키 잔존: id=%s → %s", id, s) + } + } +} + +func mustOpenT(t *testing.T, dsn string) *sql.DB { + t.Helper() + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatalf("open %q: %v", dsn, err) + } + return db +} + +func exec(t *testing.T, db *sql.DB, q string) { + t.Helper() + if _, err := db.Exec(q); err != nil { + t.Fatalf("exec %q: %v", q, err) + } +} + +func countT(t *testing.T, db *sql.DB, q string) int { + t.Helper() + var n sql.NullInt64 + if err := db.QueryRow(q).Scan(&n); err != nil { + return -1 + } + return int(n.Int64) +} + +func allIDs(t *testing.T, db *sql.DB) []string { + t.Helper() + rows, err := db.Query("SELECT id::text FROM kv") + if err != nil { + t.Fatalf("query ids: %v", err) + } + defer func() { _ = rows.Close() }() + var out []string + for rows.Next() { + var s string + if err := rows.Scan(&s); err != nil { + t.Fatalf("scan id: %v", err) + } + out = append(out, s) + } + return out +} diff --git a/internal/router/reshard_copy_test.go b/internal/router/reshard_copy_test.go index 29a34f0..08971ea 100644 --- a/internal/router/reshard_copy_test.go +++ b/internal/router/reshard_copy_test.go @@ -59,6 +59,28 @@ func TestCopyShardRange_RejectsInjection(t *testing.T) { } } +// TestCDC_RejectsInjection 은 publication/subscription/slot/테이블 이름이 화이트리스트로 +// (DB 접속 전) 차단됨을 검증한다. +func TestCDC_RejectsInjection(t *testing.T) { + ctx := context.Background() + bad := "p; DROP TABLE u" + if err := CreatePublication(ctx, "", bad, nil); !errors.Is(err, ErrInvalidTable) { + t.Errorf("CreatePublication(%q) err = %v, want ErrInvalidTable", bad, err) + } + if err := CreateSubscription(ctx, "", "host=x", bad, "pub", true); !errors.Is(err, ErrInvalidTable) { + t.Errorf("CreateSubscription bad sub err = %v, want ErrInvalidTable", err) + } + if _, err := SubscriptionLagBytes(ctx, "", bad); !errors.Is(err, ErrInvalidTable) { + t.Errorf("SubscriptionLagBytes(%q) err = %v, want ErrInvalidTable", bad, err) + } + if err := DropSubscription(ctx, "", bad); !errors.Is(err, ErrInvalidTable) { + t.Errorf("DropSubscription err = %v, want ErrInvalidTable", err) + } + if _, err := DeleteForeignRange(ctx, "", bad, specWithCol("id"), "shard-0"); !errors.Is(err, ErrInvalidTable) { + t.Errorf("DeleteForeignRange bad table err = %v, want ErrInvalidTable", err) + } +} + func TestKeyString(t *testing.T) { cases := []struct { in any From 7b5c205a9525ef2803860b1e9b8a5218c96ad159 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 12:10:05 +0900 Subject: [PATCH 077/109] =?UTF-8?q?docs:=20CDC=20=EC=A6=9D=EB=B6=84=20catc?= =?UTF-8?q?h-up=20=EB=B9=8C=EB=94=A9=EB=B8=94=EB=A1=9D=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WORK_HANDOFF §6.6: CDC 메커니즘(논리복제 pub/sub + DeleteForeignRange + wal_level=logical) 완료·라이브검증(라이브 쓰기 유실0). 새 #1=CDCCatchup phase 컨트롤러 결선(online/offline 모드 선택). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/WORK_HANDOFF.ko.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index bab2049..c8efa49 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -228,7 +228,8 @@ SSOT는 [ROUTER-GAP-ANALYSIS §4 능력 사다리 + §6 백로그](sharding/ROUT | ~~·~~ ✅ | ~~ShardSplitJob InitialCopy/Cleanup K8s 결선~~ | **완료(2026-06-28)** — InitialCopy(복사)+Cleanup(source 이동분 삭제)이 target 별 K8s Job(reshard-copy 이미지, 내부 trust 접속)으로 데이터 이동 + 완료 게이트. `shardsplitjob_copy.go`, envtest 검증. 데이터 경로(복사→cutover→회수) 닫힘. | | ~~·~~ ✅ | ~~Cutover write-block~~ | **완료(2026-06-28)** — ShardRangeSpec.WriteBlocked 신호 → 라우터가 쓰기 거부(읽기 통과), Cutover 가 set·RoutingUpdate 가 clear. router 단위+envtest+라이브. simple-query 에러 hang 버그도 수정. | | ~~·~~ ✅ | ~~ShardSplitJob full e2e (라이브)~~ | **완료(2026-06-28)** 🎉 — kind 실 K8s+실 PG: 단일샤드(키 1..100)→ShardSplitJob→Bootstrap(target rsd-t0/t1 부팅)→InitialCopy(스키마+데이터 복사 Job)→Cutover(write-block)→RoutingUpdate(ShardRange flip+unblock)→Cleanup(source 삭제 Job)→Completed. **결과 t0=44/t1=56/source=0, 합=100 키유실0, ShardRange flip, write-block 해제.** e2e 가 갭 2개 발견·수정(Job 이미지명 env RESHARD_COPY_IMAGE, 스키마 우선 복제). | -| **1** | **CDC 증분 catch-up** | InitialCopy 중 source 변경분을 논리복제로 따라잡기(복사 시작~cutover 사이 라이브 쓰기 보존 = 진짜 무중단). 현 e2e 는 정적 데이터(동시쓰기 없음)라 CDCCatchup pass-through. 운영 무중단엔 필요 | +| ~~·~~ ✅ | ~~CDC 증분 catch-up 빌딩블록~~ | **완료(2026-06-28)** — `reshard_cdc.go`(논리복제 pub/sub/lag/teardown + DeleteForeignRange) + wal_level=logical. 라이브검증: subscription(copy_data=true)이 구독 이후 라이브 INSERT/UPDATE 까지 target 복제(유실0), filter-delete 로 범위만 잔존. **무중단 CDC 메커니즘 동작 증명.** | +| **1** | **CDCCatchup phase 컨트롤러 결선** | online 모드(subscription copy_data=true + cutover 시 lag→0 대기 + DeleteForeignRange) vs offline 모드(현 CopyShardRange + write-block) 선택 결선. 메커니즘은 완성·검증 — 아키텍처 결정 + phase 배선 | | **2** | **resharding 운영화 마감** | target 인덱스/PK 복제(현재 데이터만), RESHARD_COPY_IMAGE 를 operator manager env 기본 결선, target shard 영구 승격(ordinal 편입) | | **3** | **멀티머신 수평 스케일 실측** | 진짜 "분산처리능력" 수치 — 물리 분리 노드 필요(router-bench 가 샤드별 DSN 받음, 그대로 적용) | | **4** | **멀티 라우터 인스턴스 수평확장** | 라우터 1-hop 왕복이 남은 오버헤드(prepared direct 86K vs router 23K) — 라우터를 여러 개 띄워 처리량 확장 | From 50c16abf07b91168437ca2e2d1c7c282334fa252 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 12:24:13 +0900 Subject: [PATCH 078/109] =?UTF-8?q?feat(controller):=20CDCCatchup=20phase?= =?UTF-8?q?=20=EA=B2=B0=EC=84=A0=20=E2=80=94=20online=20=EB=AC=B4=EC=A4=91?= =?UTF-8?q?=EB=8B=A8=20resharding=20=EB=AA=A8=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spec.Online=true 면 InitialCopy(정적 bulk) 대신 CDCCatchup 이 논리복제로 라이브 쓰기를 따라잡는다. write-block 이 최종 drain 만 짧게 감싸 무중단에 가깝게 cutover 한다. - ShardSplitJobSpec.Online(+CRD): online(CDC) vs offline(현 CopyShardRange) 모드. - reconcileCDC: ① cdc-setup Job(스키마+pub+sub copy_data=true+lag≤CDCMaxLag 대기) 전부 완료 → ② setWriteBlock(true) → ③ cdc-finalize Job(최종 drain+sub drop+DeleteForeignRange +pub drop) 전부 완료 → done. write-block 이 finalize 를 감싸 라이브 쓰기 유실 없음. - buildReshardJob/reshardJobName/reconcileModeJobs 를 mode 기반(copy|delete|cdc-setup| cdc-finalize)으로 일반화. InitialCopy 는 Online 이면 skip. - reshard-copy-poc: cdc-setup/cdc-finalize 모드(EnsureSchema+pub/sub+waitLag / drain+drop+ filter-delete). CreateSubscription 멱등(존재 시 skip, Job 재시도 안전). - Reconcile 에 CDCCatchup 블록(online 게이트). 검증: envtest — reconcileCDC 가 cdc-setup Job 생성(미완)→setup 성공 시 write-block ON + cdc-finalize Job 생성→finalize 성공 시 done(순서·env 검증). CDC 메커니즘 자체는 TestCDCLive(라이브 쓰기 유실0) 로 이미 증명. 남은 것: online 모드 멀티샤드 full e2e (동시 쓰기 부하 하). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- api/v1alpha1/shardsplitjob_types.go | 8 ++ .../postgres.keiailab.io_shardsplitjobs.yaml | 8 ++ cmd/reshard-copy-poc/main.go | 113 +++++++++++++++- .../postgres.keiailab.io_shardsplitjobs.yaml | 8 ++ .../controller/shardsplitjob_controller.go | 23 ++++ internal/controller/shardsplitjob_copy.go | 124 +++++++++++------- .../controller/shardsplitjob_copy_test.go | 8 +- .../shardsplitjob_writeblock_test.go | 70 ++++++++++ internal/router/reshard_cdc.go | 33 +++++ internal/router/reshard_cdc_live_test.go | 5 +- 10 files changed, 349 insertions(+), 51 deletions(-) diff --git a/api/v1alpha1/shardsplitjob_types.go b/api/v1alpha1/shardsplitjob_types.go index 299de76..5dce636 100644 --- a/api/v1alpha1/shardsplitjob_types.go +++ b/api/v1alpha1/shardsplitjob_types.go @@ -101,6 +101,14 @@ type ShardSplitJobSpec struct { // +kubebuilder:default=false // +optional AllowForwardOnly bool `json:"allowForwardOnly,omitempty"` + + // Online 은 true 면 CDC(논리복제) 기반 *무중단* 이동을 쓴다 — InitialCopy 의 정적 bulk + // 복사 대신 CDCCatchup phase 가 subscription(copy_data=true)으로 라이브 쓰기를 따라잡고, + // write-block 은 최종 drain 구간만 짧게 건다. false(기본)는 offline 모드(InitialCopy + // 범위복사 + cutover write-block) — 동시 쓰기 없는 유지보수 창에 단순·빠름. + // +kubebuilder:default=false + // +optional + Online bool `json:"online,omitempty"` } // ShardSplitTarget 는 split/merge 의 target shard 1건 정의. diff --git a/charts/postgres-operator/crds/postgres.keiailab.io_shardsplitjobs.yaml b/charts/postgres-operator/crds/postgres.keiailab.io_shardsplitjobs.yaml index f377a35..ac7b1f9 100644 --- a/charts/postgres-operator/crds/postgres.keiailab.io_shardsplitjobs.yaml +++ b/charts/postgres-operator/crds/postgres.keiailab.io_shardsplitjobs.yaml @@ -99,6 +99,14 @@ spec: description: Keyspace 는 ShardRange 의 keyspace 식별자. pattern: ^[a-z][a-z0-9_]{0,62}$ type: string + online: + default: false + description: |- + Online 은 true 면 CDC(논리복제) 기반 *무중단* 이동을 쓴다 — InitialCopy 의 정적 bulk + 복사 대신 CDCCatchup phase 가 subscription(copy_data=true)으로 라이브 쓰기를 따라잡고, + write-block 은 최종 drain 구간만 짧게 건다. false(기본)는 offline 모드(InitialCopy + 범위복사 + cutover write-block) — 동시 쓰기 없는 유지보수 창에 단순·빠름. + type: boolean sources: description: 'Sources 는 source shard ID 목록 (split: 1, merge: N).' items: diff --git a/cmd/reshard-copy-poc/main.go b/cmd/reshard-copy-poc/main.go index c9d5a27..1bc9c58 100644 --- a/cmd/reshard-copy-poc/main.go +++ b/cmd/reshard-copy-poc/main.go @@ -27,6 +27,7 @@ import ( "context" "fmt" "os" + "strconv" "strings" "time" @@ -90,9 +91,20 @@ func main() { fmt.Fprintln(os.Stderr, "reshard-copy-poc: full copy requires PGROUTER_COPY_TABLE") os.Exit(2) } - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + mode := os.Getenv("PGROUTER_RESHARD_MODE") + timeout := 60 * time.Second + if mode == "cdc-setup" || mode == "cdc-finalize" { + timeout = 15 * time.Minute // CDC bulk 복사/drain 은 길 수 있다. + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() + // CDC(online) 모드: 논리복제 setup(스키마+pub+sub+lag대기) 또는 finalize(drain+drop+범위정리). + if mode == "cdc-setup" || mode == "cdc-finalize" { + runCDC(ctx, mode, src, tgt, targetShard) + return + } + // Full copy (no target shard) vs range-filtered copy (the real split). if targetShard == "" { fmt.Printf("reshard-copy-poc: InitialCopy (full) table=%q source→target\n", table) @@ -167,6 +179,105 @@ func main() { fmt.Printf("reshard-copy-poc: split complete — %s now owns its range across %d table(s)\n", targetShard, len(tables)) } +// runCDC 는 online resharding 의 논리복제 단계를 수행한다. +// - cdc-setup: target 스키마 보장 + source publication + target subscription(copy_data=true) +// 생성 후 lag ≤ CDC_MAX_LAG 까지 대기(라이브 쓰기 따라잡기, write-block 없음). +// - cdc-finalize: 최종 drain(lag→0, write-block 하에 호출 전제) + subscription drop + +// DeleteForeignRange(범위 밖 정리) + publication drop. +func runCDC(ctx context.Context, mode, src, tgt, targetShard string) { + if targetShard == "" { + fmt.Fprintln(os.Stderr, "reshard-copy-poc: CDC requires PGROUTER_RESHARD_TARGET_SHARD") + os.Exit(2) + } + col := os.Getenv("PGROUTER_VINDEX_COLUMN") + if col == "" { + col = "id" + } + spec := reshardSpec(col, os.Getenv("PGROUTER_VINDEX_FUNCTION"), os.Getenv("PGROUTER_RANGES")) + tables := cdcTables(ctx, src) + pub := "rsd_pub_" + sanitize(targetShard) + sub := "rsd_sub_" + sanitize(targetShard) + connInfo := src + if v := os.Getenv("PGROUTER_SOURCE_CONNINFO"); v != "" { + connInfo = v + } + maxLag := int64(16 << 20) + if v := os.Getenv("PGROUTER_CDC_MAX_LAG"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + maxLag = n + } + } + + switch mode { + case "cdc-setup": + fmt.Printf("reshard-copy-poc: cdc-setup target=%s tables=%v\n", targetShard, tables) + must(router.EnsureSchema(ctx, src, tgt, tables), "ensure schema") + must(router.CreatePublication(ctx, src, pub, tables), "create publication") + must(router.CreateSubscription(ctx, tgt, connInfo, sub, pub, true), "create subscription") + waitLag(ctx, src, sub, maxLag) + fmt.Printf("reshard-copy-poc: cdc-setup 완료 — subscription %s 활성, lag ≤ %d\n", sub, maxLag) + case "cdc-finalize": + fmt.Printf("reshard-copy-poc: cdc-finalize target=%s (write-block 하 최종 drain)\n", targetShard) + waitLag(ctx, src, sub, 0) // write-block 하 → 새 쓰기 없음 → lag→0 수렴. + must(router.DropSubscription(ctx, tgt, sub), "drop subscription") + total := 0 + for _, t := range tables { + n, err := router.DeleteForeignRange(ctx, tgt, t, spec, targetShard) + must(err, "delete foreign range "+t) + total += n + } + must(router.DropPublication(ctx, src, pub), "drop publication") + fmt.Printf("reshard-copy-poc: cdc-finalize 완료 — 범위 밖 %d row 삭제, %s 자기 범위만 보유\n", total, targetShard) + } +} + +// cdcTables 는 COPY_TABLE 지정 시 그 하나, 아니면 source user 테이블에서 reference 제외 전부. +func cdcTables(ctx context.Context, src string) []string { + if t := os.Getenv("PGROUTER_COPY_TABLE"); t != "" { + return []string{t} + } + all, err := router.ListUserTables(ctx, src) + must(err, "list tables") + return router.FilterTables(all, csv(os.Getenv("PGROUTER_REFERENCE_TABLES"))) +} + +// waitLag 는 subscription 슬롯 lag 가 maxLag 이하가 될 때까지 폴링한다(ctx 만료 시 실패). +func waitLag(ctx context.Context, src, sub string, maxLag int64) { + for { + select { + case <-ctx.Done(): + fmt.Fprintf(os.Stderr, "reshard-copy-poc: lag wait timeout (sub=%s)\n", sub) + os.Exit(1) + default: + } + lag, err := router.SubscriptionLagBytes(ctx, src, sub) + must(err, "lag") + if lag >= 0 && lag <= maxLag { + return + } + time.Sleep(time.Second) + } +} + +func sanitize(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(s) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' { + b.WriteRune(r) + } else { + b.WriteRune('_') + } + } + return b.String() +} + +func must(err error, what string) { + if err != nil { + fmt.Fprintf(os.Stderr, "reshard-copy-poc: %s: %v\n", what, err) + os.Exit(1) + } +} + // csv 는 콤마 구분 문자열을 trim 해 분리한다(빈 값 제거). func csv(s string) []string { if strings.TrimSpace(s) == "" { diff --git a/config/crd/bases/postgres.keiailab.io_shardsplitjobs.yaml b/config/crd/bases/postgres.keiailab.io_shardsplitjobs.yaml index f377a35..ac7b1f9 100644 --- a/config/crd/bases/postgres.keiailab.io_shardsplitjobs.yaml +++ b/config/crd/bases/postgres.keiailab.io_shardsplitjobs.yaml @@ -99,6 +99,14 @@ spec: description: Keyspace 는 ShardRange 의 keyspace 식별자. pattern: ^[a-z][a-z0-9_]{0,62}$ type: string + online: + default: false + description: |- + Online 은 true 면 CDC(논리복제) 기반 *무중단* 이동을 쓴다 — InitialCopy 의 정적 bulk + 복사 대신 CDCCatchup phase 가 subscription(copy_data=true)으로 라이브 쓰기를 따라잡고, + write-block 은 최종 drain 구간만 짧게 건다. false(기본)는 offline 모드(InitialCopy + 범위복사 + cutover write-block) — 동시 쓰기 없는 유지보수 창에 단순·빠름. + type: boolean sources: description: 'Sources 는 source shard ID 목록 (split: 1, merge: N).' items: diff --git a/internal/controller/shardsplitjob_controller.go b/internal/controller/shardsplitjob_controller.go index b4ecc17..aab8334 100644 --- a/internal/controller/shardsplitjob_controller.go +++ b/internal/controller/shardsplitjob_controller.go @@ -109,6 +109,29 @@ func (r *ShardSplitJobReconciler) Reconcile(ctx context.Context, req ctrl.Reques // 복사 완료 → 아래 nextPhase 가 CDCCatchup 으로 전이. } + // CDCCatchup phase (online): 논리복제로 라이브 쓰기를 따라잡고, 거의 catch-up 되면 + // write-block 을 켠 뒤 최종 drain·정리한다(reconcileCDC). 완료 전엔 전이하지 않는다. + // offline 모드는 no-op(아래 nextPhase 가 즉시 Cutover 로). + if ssj.Status.Phase == postgresv1alpha1.ShardSplitPhaseCDCCatchup && ssj.Spec.Online { + done, failure, err := r.reconcileCDC(ctx, &ssj) + if err != nil { + return ctrl.Result{}, err + } + if failure != "" { + ssj.Status.Phase = postgresv1alpha1.ShardSplitPhaseFailed + ssj.Status.FailureReason = failure + now := metav1.Now() + ssj.Status.CompletedAt = &now + ssj.Status.ObservedGeneration = ssj.Generation + _ = r.Status().Update(ctx, &ssj) + return ctrl.Result{}, nil + } + if !done { + return ctrl.Result{RequeueAfter: 3 * time.Second}, nil // CDC catch-up 대기. + } + // CDC 완료(write-block 켜짐) → nextPhase 가 Cutover 로. + } + // Cutover phase: 라우팅 전환 직전 write-block 을 켠다(라우터가 쓰기 거부, 읽기는 통과) — // RoutingUpdate 가 ranges 를 flip 하고 동시에 write-block 을 해제한다. forward-only(비가역)는 // nextPhase 가 Failed 로 막으므로 write-block 을 켜지 않는다. diff --git a/internal/controller/shardsplitjob_copy.go b/internal/controller/shardsplitjob_copy.go index fbf7029..832f921 100644 --- a/internal/controller/shardsplitjob_copy.go +++ b/internal/controller/shardsplitjob_copy.go @@ -44,14 +44,17 @@ func reshardCopyImage() string { return "ghcr.io/keiailab/reshard-copy:dev" } -// reshardJobName 은 target shard 별 데이터이동 Job 이름(결정적 — 멱등 생성). deleteOnly 면 -// Cleanup(삭제) Job, 아니면 InitialCopy(복사) Job — 이름이 분리돼 두 phase 의 Job 이 공존한다. -func reshardJobName(cluster, shardID string, deleteOnly bool) string { - verb := "copy" - if deleteOnly { - verb = "del" - } - return fmt.Sprintf("%s-rsd-%s-%s", cluster, verb, shardID) +// reshardModeVerb 는 데이터이동 mode 의 Job 이름 segment 다(phase 별 Job 공존을 위해 분리). +var reshardModeVerb = map[string]string{ + "copy": "copy", + "delete": "del", + "cdc-setup": "cdcset", + "cdc-finalize": "cdcfin", +} + +// reshardJobName 은 target shard·mode 별 데이터이동 Job 이름(결정적 — 멱등 생성). +func reshardJobName(cluster, shardID, mode string) string { + return fmt.Sprintf("%s-rsd-%s-%s", cluster, reshardModeVerb[mode], shardID) } // internalShardDSN 은 클러스터 내부 trust DSN 을 만든다(postgres superuser, 무비밀번호 — @@ -107,34 +110,50 @@ func (r *ShardSplitJobReconciler) keyspaceVindex(ctx context.Context, ns, cluste return "", "", nil, fmt.Errorf("no ShardRange for cluster=%s keyspace=%s", cluster, keyspace) } -// buildReshardJob 은 한 target shard 의 데이터이동 Job 을 만든다. deleteOnly=false 면 -// InitialCopy(source→target 복사), true 면 Cleanup(cutover 후 source 에서 이동분 삭제 — -// target 접속 불요). 두 경우 모두 모든 user 테이블 자동 발견(COPY_TABLE 미설정). -func (r *ShardSplitJobReconciler) buildReshardJob(ssj *postgresv1alpha1.ShardSplitJob, sourceDSN, targetDSN, targetShard, col, fn, ranges string, refTables []string, deleteOnly bool) *batchv1.Job { +// reshardParams 는 한 데이터이동 Job 의 입력이다. +type reshardParams struct { + sourceDSN, targetDSN, targetShard string + col, fn, ranges string + refTables []string + maxLag int64 + mode string // copy | delete | cdc-setup | cdc-finalize +} + +// buildReshardJob 은 mode 에 맞는 데이터이동 Job 을 만든다: +// - copy: source→target 범위복사(offline) · delete: source 에서 이동분 삭제(target 불요) +// - cdc-setup: 스키마+pub+sub(copy_data=true)+lag대기 · cdc-finalize: 최종 drain+drop+범위정리 +func (r *ShardSplitJobReconciler) buildReshardJob(ssj *postgresv1alpha1.ShardSplitJob, p reshardParams) *batchv1.Job { backoff := int32(2) env := []corev1.EnvVar{ - {Name: "PGROUTER_SOURCE_DSN", Value: sourceDSN}, - {Name: "PGROUTER_RESHARD_TARGET_SHARD", Value: targetShard}, - {Name: "PGROUTER_VINDEX_COLUMN", Value: col}, - {Name: "PGROUTER_VINDEX_FUNCTION", Value: fn}, - {Name: "PGROUTER_RANGES", Value: ranges}, - {Name: "PGROUTER_REFERENCE_TABLES", Value: strings.Join(refTables, ",")}, - } - if deleteOnly { + {Name: "PGROUTER_SOURCE_DSN", Value: p.sourceDSN}, + {Name: "PGROUTER_RESHARD_TARGET_SHARD", Value: p.targetShard}, + {Name: "PGROUTER_VINDEX_COLUMN", Value: p.col}, + {Name: "PGROUTER_VINDEX_FUNCTION", Value: p.fn}, + {Name: "PGROUTER_RANGES", Value: p.ranges}, + {Name: "PGROUTER_REFERENCE_TABLES", Value: strings.Join(p.refTables, ",")}, + } + switch p.mode { + case "delete": env = append(env, corev1.EnvVar{Name: "PGROUTER_RESHARD_DELETE_ONLY", Value: "1"}) - } else { - env = append(env, corev1.EnvVar{Name: "PGROUTER_TARGET_DSN", Value: targetDSN}) + case "cdc-setup", "cdc-finalize": + env = append(env, + corev1.EnvVar{Name: "PGROUTER_TARGET_DSN", Value: p.targetDSN}, + corev1.EnvVar{Name: "PGROUTER_RESHARD_MODE", Value: p.mode}, + corev1.EnvVar{Name: "PGROUTER_CDC_MAX_LAG", Value: strconv.FormatInt(p.maxLag, 10)}, + ) + default: // copy + env = append(env, corev1.EnvVar{Name: "PGROUTER_TARGET_DSN", Value: p.targetDSN}) } return &batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ - Name: reshardJobName(ssj.Spec.Cluster, targetShard, deleteOnly), + Name: reshardJobName(ssj.Spec.Cluster, p.targetShard, p.mode), Namespace: ssj.Namespace, - Labels: ReshardTargetSelectorLabels(ssj.Spec.Cluster, targetShard), + Labels: ReshardTargetSelectorLabels(ssj.Spec.Cluster, p.targetShard), }, Spec: batchv1.JobSpec{ BackoffLimit: &backoff, Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{Labels: ReshardTargetSelectorLabels(ssj.Spec.Cluster, targetShard)}, + ObjectMeta: metav1.ObjectMeta{Labels: ReshardTargetSelectorLabels(ssj.Spec.Cluster, p.targetShard)}, Spec: corev1.PodSpec{ RestartPolicy: corev1.RestartPolicyNever, Containers: []corev1.Container{{ @@ -148,28 +167,41 @@ func (r *ShardSplitJobReconciler) buildReshardJob(ssj *postgresv1alpha1.ShardSpl } } -// reconcileInitialCopy 는 각 target 의 source→target 복사 Job 을 띄우고 완료를 집계한다. +// reconcileInitialCopy 는 (offline) 각 target 의 source→target 범위복사 Job 을 띄운다. Online +// 모드는 데이터 이동을 CDCCatchup(subscription)이 하므로 skip(즉시 done). func (r *ShardSplitJobReconciler) reconcileInitialCopy(ctx context.Context, ssj *postgresv1alpha1.ShardSplitJob) (done bool, failure string, err error) { - return r.reconcileReshardJobs(ctx, ssj, false) + if ssj.Spec.Online { + return true, "", nil + } + return r.reconcileModeJobs(ctx, ssj, "copy") } -// reconcileCleanup 은 cutover 후 각 target 키를 source 에서 삭제하는 Job 을 띄우고 완료를 -// 집계한다(source 정리 — 이동분 회수). InitialCopy 가 끝나고 라우팅이 전환된 *뒤* 이므로 -// 안전하다. +// reconcileCleanup 은 cutover 후 각 target 키를 source 에서 삭제하는 Job 을 띄운다(source 회수). func (r *ShardSplitJobReconciler) reconcileCleanup(ctx context.Context, ssj *postgresv1alpha1.ShardSplitJob) (done bool, failure string, err error) { - return r.reconcileReshardJobs(ctx, ssj, true) + return r.reconcileModeJobs(ctx, ssj, "delete") } -// reconcileReshardJobs 는 각 target 의 데이터이동 Job(deleteOnly=복사/삭제)을 멱등 생성하고 -// 완료를 집계한다. 반환: done(전부 성공), failure(한 Job 이라도 실패 시 사유), err(전이 가능 -// — requeue). -func (r *ShardSplitJobReconciler) reconcileReshardJobs(ctx context.Context, ssj *postgresv1alpha1.ShardSplitJob, deleteOnly bool) (done bool, failure string, err error) { - phase := "InitialCopy" - if deleteOnly { - phase = "Cleanup" +// reconcileCDC 는 (online) CDC 증분 catch-up 을 단계적으로 진행한다: ① cdc-setup Job(스키마+ +// pub+sub+lag≤임계 대기) 전부 완료 → ② write-block 설정(이후 라이브 쓰기 차단) → ③ cdc-finalize +// Job(최종 drain+sub drop+범위 정리) 전부 완료 → done. write-block 이 finalize 를 감싸 라이브 +// 쓰기 유실 없이 짧은 창으로 cutover 한다. +func (r *ShardSplitJobReconciler) reconcileCDC(ctx context.Context, ssj *postgresv1alpha1.ShardSplitJob) (done bool, failure string, err error) { + setupDone, failure, err := r.reconcileModeJobs(ctx, ssj, "cdc-setup") + if err != nil || failure != "" || !setupDone { + return false, failure, err + } + // bulk + 거의 catch-up 완료 → write-block 켜고 최종 drain. + if err := r.setWriteBlock(ctx, ssj, true); err != nil { + return false, "", err } + return r.reconcileModeJobs(ctx, ssj, "cdc-finalize") +} + +// reconcileModeJobs 는 각 target 의 mode Job 을 멱등 생성하고 완료를 집계한다. 반환: done(전부 +// 성공), failure(한 Job 이라도 실패 시 사유), err(전이 가능 — requeue). +func (r *ShardSplitJobReconciler) reconcileModeJobs(ctx context.Context, ssj *postgresv1alpha1.ShardSplitJob, mode string) (done bool, failure string, err error) { if len(ssj.Spec.Sources) == 0 { - return false, phase + ": no source shard", nil + return false, mode + ": no source shard", nil } col, fn, refTables, err := r.keyspaceVindex(ctx, ssj.Namespace, ssj.Spec.Cluster, ssj.Spec.Keyspace) if err != nil { @@ -177,23 +209,27 @@ func (r *ShardSplitJobReconciler) reconcileReshardJobs(ctx context.Context, ssj } srcDNS, err := sourceShardPodDNS(ssj.Spec.Cluster, ssj.Namespace, ssj.Spec.Sources[0]) if err != nil { - return false, phase + ": " + err.Error(), nil // 설정 오류 — Failed. + return false, mode + ": " + err.Error(), nil // 설정 오류 — Failed. } ranges := rangesEnvValue(ssj.Spec.Targets) + maxLag := ssj.Spec.CDCMaxLag allDone := true for i := range ssj.Spec.Targets { t := &ssj.Spec.Targets[i] - name := reshardJobName(ssj.Spec.Cluster, t.ShardID, deleteOnly) + name := reshardJobName(ssj.Spec.Cluster, t.ShardID, mode) var job batchv1.Job getErr := r.Get(ctx, client.ObjectKey{Namespace: ssj.Namespace, Name: name}, &job) switch { case apierrors.IsNotFound(getErr): targetDSN := "" - if !deleteOnly { + if mode != "delete" { targetDSN = internalShardDSN(targetShardPodDNS(ssj.Spec.Cluster, ssj.Namespace, t.ShardID)) } - j := r.buildReshardJob(ssj, internalShardDSN(srcDNS), targetDSN, t.ShardID, col, fn, ranges, refTables, deleteOnly) + j := r.buildReshardJob(ssj, reshardParams{ + sourceDSN: internalShardDSN(srcDNS), targetDSN: targetDSN, targetShard: t.ShardID, + col: col, fn: fn, ranges: ranges, refTables: refTables, maxLag: maxLag, mode: mode, + }) if err := controllerutil.SetControllerReference(ssj, j, r.Scheme); err != nil { return false, "", err } @@ -206,7 +242,7 @@ func (r *ShardSplitJobReconciler) reconcileReshardJobs(ctx context.Context, ssj case job.Status.Succeeded > 0: // 이 target 완료. case jobFailed(&job): - return false, fmt.Sprintf("%s: job %s failed", phase, name), nil + return false, fmt.Sprintf("%s: job %s failed", mode, name), nil default: allDone = false // 진행 중. } diff --git a/internal/controller/shardsplitjob_copy_test.go b/internal/controller/shardsplitjob_copy_test.go index c3a452d..9b8ca5f 100644 --- a/internal/controller/shardsplitjob_copy_test.go +++ b/internal/controller/shardsplitjob_copy_test.go @@ -85,7 +85,7 @@ var _ = Describe("ShardSplitJob InitialCopy 복사 Job 결선", func() { // 2) 각 target Job 이 올바른 env 로 생성됐는지. for _, shardID := range []string{"t0", "t1"} { var job batchv1.Job - Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: reshardJobName(clusterName, shardID, false)}, &job)). + Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: reshardJobName(clusterName, shardID, "copy")}, &job)). To(Succeed(), "target %s 복사 Job 이 생성돼야 함", shardID) c := job.Spec.Template.Spec.Containers[0] Expect(envOf(c, "PGROUTER_RESHARD_TARGET_SHARD")).To(Equal(shardID)) @@ -109,7 +109,7 @@ var _ = Describe("ShardSplitJob InitialCopy 복사 Job 결선", func() { // 4) 두 Job 을 성공으로 표기 → done=true. for _, shardID := range []string{"t0", "t1"} { var job batchv1.Job - Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: reshardJobName(clusterName, shardID, false)}, &job)).To(Succeed()) + Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: reshardJobName(clusterName, shardID, "copy")}, &job)).To(Succeed()) job.Status.Succeeded = 1 Expect(k8sClient.Status().Update(ctx, &job)).To(Succeed()) } @@ -149,7 +149,7 @@ var _ = Describe("ShardSplitJob InitialCopy 복사 Job 결선", func() { Expect(err).NotTo(HaveOccurred()) var job batchv1.Job - Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: reshardJobName(clusterName, "t0", false)}, &job)).To(Succeed()) + Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: reshardJobName(clusterName, "t0", "copy")}, &job)).To(Succeed()) now := metav1.Now() job.Status.StartTime = &now // apiserver: finished job 은 startTime 필수. job.Status.Conditions = []batchv1.JobCondition{ @@ -195,7 +195,7 @@ var _ = Describe("ShardSplitJob InitialCopy 복사 Job 결선", func() { Expect(done).To(BeFalse()) var job batchv1.Job - Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: reshardJobName(clusterName, "t1", true)}, &job)). + Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: reshardJobName(clusterName, "t1", "delete")}, &job)). To(Succeed(), "delete Job 이 생성돼야 함") c := job.Spec.Template.Spec.Containers[0] Expect(envOf(c, "PGROUTER_RESHARD_DELETE_ONLY")).To(Equal("1")) diff --git a/internal/controller/shardsplitjob_writeblock_test.go b/internal/controller/shardsplitjob_writeblock_test.go index 49fafcd..6bd6728 100644 --- a/internal/controller/shardsplitjob_writeblock_test.go +++ b/internal/controller/shardsplitjob_writeblock_test.go @@ -13,6 +13,8 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" @@ -29,6 +31,15 @@ var _ = Describe("ShardSplitJob Cutover write-block", func() { ctx := context.Background() const ns = "default" + envOf := func(c corev1.Container, key string) string { + for _, e := range c.Env { + if e.Name == key { + return e.Value + } + } + return "" + } + It("Cutover 가 write-block 을 켜고 RoutingUpdate 가 ranges flip 과 함께 끈다", func() { clusterName := fmt.Sprintf("rsdwb-%d", GinkgoRandomSeed()) keyspace := "default" @@ -85,6 +96,65 @@ var _ = Describe("ShardSplitJob Cutover write-block", func() { Expect(ssj.Status.Phase).To(Equal(postgresv1alpha1.ShardSplitPhaseCleanup)) }) + It("online 모드 CDCCatchup: cdc-setup Job → write-block → cdc-finalize Job 순서", func() { + clusterName := fmt.Sprintf("rsdcdc-%d", GinkgoRandomSeed()) + keyspace := "default" + sr := &postgresv1alpha1.ShardRange{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-sr", Namespace: ns}, + Spec: postgresv1alpha1.ShardRangeSpec{ + Cluster: clusterName, Keyspace: keyspace, + Vindex: postgresv1alpha1.VindexSpec{Type: postgresv1alpha1.VindexTypeHash, Column: "id", Function: "murmur3"}, + Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0xffffffff", Shard: "shard-0"}}, + }, + } + Expect(k8sClient.Create(ctx, sr)).To(Succeed()) + ssj := &postgresv1alpha1.ShardSplitJob{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-ssj", Namespace: ns}, + Spec: postgresv1alpha1.ShardSplitJobSpec{ + Cluster: clusterName, Keyspace: keyspace, Sources: []string{"shard-0"}, Online: true, + CDCMaxLag: 16 << 20, + Targets: []postgresv1alpha1.ShardSplitTarget{ + {ShardID: "t1", Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0xffffffff", Shard: "t1"}}}, + }, + }, + } + Expect(k8sClient.Create(ctx, ssj)).To(Succeed()) + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(ssj), ssj)).To(Succeed()) + r := &ShardSplitJobReconciler{Client: k8sClient, Scheme: scheme.Scheme} + + // 1) reconcileCDC: cdc-setup Job 생성, 미완료(write-block 아직 안 켜짐). + done, failure, err := r.reconcileCDC(ctx, ssj) + Expect(err).NotTo(HaveOccurred()) + Expect(failure).To(BeEmpty()) + Expect(done).To(BeFalse()) + var setup batchv1.Job + Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: reshardJobName(clusterName, "t1", "cdc-setup")}, &setup)).To(Succeed()) + Expect(envOf(setup.Spec.Template.Spec.Containers[0], "PGROUTER_RESHARD_MODE")).To(Equal("cdc-setup")) + Expect(envOf(setup.Spec.Template.Spec.Containers[0], "PGROUTER_CDC_MAX_LAG")).To(Equal("16777216")) + var got postgresv1alpha1.ShardRange + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(sr), &got)).To(Succeed()) + Expect(got.Spec.WriteBlocked).To(BeFalse(), "cdc-setup 완료 전엔 write-block 미설정") + + // 2) cdc-setup 성공 → reconcileCDC 가 write-block 켜고 cdc-finalize Job 생성. + setup.Status.Succeeded = 1 + Expect(k8sClient.Status().Update(ctx, &setup)).To(Succeed()) + done, _, err = r.reconcileCDC(ctx, ssj) + Expect(err).NotTo(HaveOccurred()) + Expect(done).To(BeFalse()) + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(sr), &got)).To(Succeed()) + Expect(got.Spec.WriteBlocked).To(BeTrue(), "cdc-setup 후 write-block 켜짐") + var fin batchv1.Job + Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: reshardJobName(clusterName, "t1", "cdc-finalize")}, &fin)).To(Succeed()) + Expect(envOf(fin.Spec.Template.Spec.Containers[0], "PGROUTER_RESHARD_MODE")).To(Equal("cdc-finalize")) + + // 3) cdc-finalize 성공 → done. + fin.Status.Succeeded = 1 + Expect(k8sClient.Status().Update(ctx, &fin)).To(Succeed()) + done, _, err = r.reconcileCDC(ctx, ssj) + Expect(err).NotTo(HaveOccurred()) + Expect(done).To(BeTrue()) + }) + It("forward-only cutover 는 write-block 을 켜지 않는다(비가역 거부)", func() { clusterName := fmt.Sprintf("rsdwbfo-%d", GinkgoRandomSeed()) keyspace := "default" diff --git a/internal/router/reshard_cdc.go b/internal/router/reshard_cdc.go index 0dd8cfc..bc9b34b 100644 --- a/internal/router/reshard_cdc.go +++ b/internal/router/reshard_cdc.go @@ -32,6 +32,30 @@ import ( // placeholder 바인딩 불가). var pubSubNamePattern = tableNamePattern +// EnsureSchema 는 각 table 을 source 의 정의로 target 에 만든다(subscription copy_data=true 는 +// target 에 테이블이 이미 있어야 하므로 — DDL 우선). +func EnsureSchema(ctx context.Context, sourceDSN, targetDSN string, tables []string) error { + src, err := sql.Open("postgres", sourceDSN) + if err != nil { + return fmt.Errorf("router: open source: %w", err) + } + defer func() { _ = src.Close() }() + tgt, err := sql.Open("postgres", targetDSN) + if err != nil { + return fmt.Errorf("router: open target: %w", err) + } + defer func() { _ = tgt.Close() }() + for _, t := range tables { + if !tableNamePattern.MatchString(t) { + return fmt.Errorf("%w: %q", ErrInvalidTable, t) + } + if err := ensureTargetTable(ctx, src, tgt, t); err != nil { + return err + } + } + return nil +} + // CreatePublication 은 source 에 지정 테이블(빈 목록이면 전 테이블)의 publication 을 멱등 // 생성한다(DROP IF EXISTS 후 CREATE). func CreatePublication(ctx context.Context, sourceDSN, pubName string, tables []string) error { @@ -75,6 +99,15 @@ func CreateSubscription(ctx context.Context, targetDSN, sourceConnInfo, subName, } defer func() { _ = db.Close() }() + // 멱등: 이미 있으면 skip(Job 재시도 시 스트림 진행 보존 — CREATE SUBSCRIPTION 은 + // IF NOT EXISTS 미지원이라 명시 확인). + var exists int + if err := db.QueryRowContext(ctx, "SELECT 1 FROM pg_subscription WHERE subname = $1", subName).Scan(&exists); err == nil { + return nil + } else if err != sql.ErrNoRows { + return fmt.Errorf("router: check subscription: %w", err) + } + // conninfo 는 SQL 문자열 리터럴로 들어가므로 작은따옴표를 이스케이프. conn := strings.ReplaceAll(sourceConnInfo, "'", "''") stmt := fmt.Sprintf("CREATE SUBSCRIPTION %s CONNECTION '%s' PUBLICATION %s WITH (copy_data = %t)", diff --git a/internal/router/reshard_cdc_live_test.go b/internal/router/reshard_cdc_live_test.go index 7b414fa..1fc6d3d 100644 --- a/internal/router/reshard_cdc_live_test.go +++ b/internal/router/reshard_cdc_live_test.go @@ -20,8 +20,9 @@ import ( // row 만 제거하는지. // // 실행: -// RESHARD_LIVE_SOURCE="host=pg-src ..." RESHARD_LIVE_TARGET="host=pg-tgt ..." -// RESHARD_LIVE_CONNINFO="host=pg-src ..."(target→source 접속) go test -run TestCDCLive +// +// RESHARD_LIVE_SOURCE="host=pg-src ..." RESHARD_LIVE_TARGET="host=pg-tgt ..." +// RESHARD_LIVE_CONNINFO="host=pg-src ..."(target→source 접속) go test -run TestCDCLive func TestCDCLive(t *testing.T) { sourceDSN := os.Getenv("RESHARD_LIVE_SOURCE") targetDSN := os.Getenv("RESHARD_LIVE_TARGET") From f24ae20254de32ebe4e04679f5fbfa79276323e2 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 12:25:09 +0900 Subject: [PATCH 079/109] =?UTF-8?q?docs:=20CDCCatchup=20phase=20=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EA=B2=B0=EC=84=A0(online)=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WORK_HANDOFF §6.6: online 무중단 모드(spec.Online + reconcileCDC: cdc-setup→write-block →cdc-finalize) 완료·envtest. 새 #1=online 멀티샤드 full e2e(동시쓰기 부하하). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/WORK_HANDOFF.ko.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index c8efa49..d3dae2d 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -228,8 +228,9 @@ SSOT는 [ROUTER-GAP-ANALYSIS §4 능력 사다리 + §6 백로그](sharding/ROUT | ~~·~~ ✅ | ~~ShardSplitJob InitialCopy/Cleanup K8s 결선~~ | **완료(2026-06-28)** — InitialCopy(복사)+Cleanup(source 이동분 삭제)이 target 별 K8s Job(reshard-copy 이미지, 내부 trust 접속)으로 데이터 이동 + 완료 게이트. `shardsplitjob_copy.go`, envtest 검증. 데이터 경로(복사→cutover→회수) 닫힘. | | ~~·~~ ✅ | ~~Cutover write-block~~ | **완료(2026-06-28)** — ShardRangeSpec.WriteBlocked 신호 → 라우터가 쓰기 거부(읽기 통과), Cutover 가 set·RoutingUpdate 가 clear. router 단위+envtest+라이브. simple-query 에러 hang 버그도 수정. | | ~~·~~ ✅ | ~~ShardSplitJob full e2e (라이브)~~ | **완료(2026-06-28)** 🎉 — kind 실 K8s+실 PG: 단일샤드(키 1..100)→ShardSplitJob→Bootstrap(target rsd-t0/t1 부팅)→InitialCopy(스키마+데이터 복사 Job)→Cutover(write-block)→RoutingUpdate(ShardRange flip+unblock)→Cleanup(source 삭제 Job)→Completed. **결과 t0=44/t1=56/source=0, 합=100 키유실0, ShardRange flip, write-block 해제.** e2e 가 갭 2개 발견·수정(Job 이미지명 env RESHARD_COPY_IMAGE, 스키마 우선 복제). | -| ~~·~~ ✅ | ~~CDC 증분 catch-up 빌딩블록~~ | **완료(2026-06-28)** — `reshard_cdc.go`(논리복제 pub/sub/lag/teardown + DeleteForeignRange) + wal_level=logical. 라이브검증: subscription(copy_data=true)이 구독 이후 라이브 INSERT/UPDATE 까지 target 복제(유실0), filter-delete 로 범위만 잔존. **무중단 CDC 메커니즘 동작 증명.** | -| **1** | **CDCCatchup phase 컨트롤러 결선** | online 모드(subscription copy_data=true + cutover 시 lag→0 대기 + DeleteForeignRange) vs offline 모드(현 CopyShardRange + write-block) 선택 결선. 메커니즘은 완성·검증 — 아키텍처 결정 + phase 배선 | +| ~~·~~ ✅ | ~~CDC 증분 catch-up 빌딩블록~~ | **완료(2026-06-28)** — `reshard_cdc.go`(논리복제 pub/sub/lag/teardown + DeleteForeignRange) + wal_level=logical. 라이브검증: 구독 이후 라이브 INSERT/UPDATE 까지 target 복제(유실0). | +| ~~·~~ ✅ | ~~CDCCatchup phase 컨트롤러 결선(online)~~ | **완료(2026-06-28)** — `spec.Online` 게이트. reconcileCDC: cdc-setup Job(pub/sub+lag≤CDCMaxLag) → write-block ON → cdc-finalize Job(drain+drop+filter-delete) → done. write-block 이 finalize 감싸 무중단. Job mode 일반화(copy/delete/cdc-setup/cdc-finalize). envtest(순서·env) + 라이브 메커니즘(TestCDCLive) 검증. | +| **1** | **online 모드 멀티샤드 full e2e** | spec.Online=true ShardSplitJob 을 실 클러스터에서 *동시 쓰기 부하 하* 실행해 무중단 cutover 실증(offline e2e 는 2026-06-28 성공). | | **2** | **resharding 운영화 마감** | target 인덱스/PK 복제(현재 데이터만), RESHARD_COPY_IMAGE 를 operator manager env 기본 결선, target shard 영구 승격(ordinal 편입) | | **3** | **멀티머신 수평 스케일 실측** | 진짜 "분산처리능력" 수치 — 물리 분리 노드 필요(router-bench 가 샤드별 DSN 받음, 그대로 적용) | | **4** | **멀티 라우터 인스턴스 수평확장** | 라우터 1-hop 왕복이 남은 오버헤드(prepared direct 86K vs router 23K) — 라우터를 여러 개 띄워 처리량 확장 | From a025d96dd063078fabe46b0e7579e38704ca92a5 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 13:39:31 +0900 Subject: [PATCH 080/109] =?UTF-8?q?feat(resharding):=20=EC=9A=B4=EC=98=81?= =?UTF-8?q?=ED=99=94=20=EB=A7=88=EA=B0=90=20=E2=80=94=20=EC=9D=B8=EB=8D=B1?= =?UTF-8?q?=EC=8A=A4/PK=20=EB=B3=B5=EC=A0=9C=20+=20RESHARD=5FCOPY=5FIMAGE?= =?UTF-8?q?=20manager=20env?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resharding 된 target shard 가 source 의 인덱스/PK 를 갖도록 복제(현재 컬럼만 복사돼 uniqueness·조회 성능 결손) + 복사 Job 이미지를 operator manager env 로 결선(out-of-box). - ReplicateIndexes/replicateIndexes: source pg_indexes.indexdef(PK 백킹 unique index 포함)을 IF NOT EXISTS 로 멱등 복제. 데이터 복사·sync *후* 호출(bulk load 효율). 외래키/체크는 후속. - CopyShardRange(offline) 데이터 복사 후 + reshard-copy-poc cdc-finalize(online) filter-delete 후 인덱스 복제. - config/manager: RESHARD_COPY_IMAGE env(릴리스/kustomize 가 operator 버전 태그로 오버라이드) — full e2e 에서 수동 설정해야 했던 갭 해소. 검증: 라이브(TestCDCLive) — CDC 수렴(라이브 쓰기 유실0) + DeleteForeignRange + PK 인덱스 (kv_pkey) target 복제 확인. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- cmd/reshard-copy-poc/main.go | 5 ++ config/manager/manager.yaml | 6 +++ internal/router/reshard_cdc_live_test.go | 10 ++++ internal/router/reshard_copy.go | 63 ++++++++++++++++++++++++ 4 files changed, 84 insertions(+) diff --git a/cmd/reshard-copy-poc/main.go b/cmd/reshard-copy-poc/main.go index 1bc9c58..751889b 100644 --- a/cmd/reshard-copy-poc/main.go +++ b/cmd/reshard-copy-poc/main.go @@ -226,6 +226,11 @@ func runCDC(ctx context.Context, mode, src, tgt, targetShard string) { must(err, "delete foreign range "+t) total += n } + // 범위 정리 후 인덱스/PK 복제(데이터 확정 후 — bulk 효율). + for _, t := range tables { + _, err := router.ReplicateIndexes(ctx, src, tgt, t) + must(err, "replicate indexes "+t) + } must(router.DropPublication(ctx, src, pub), "drop publication") fmt.Printf("reshard-copy-poc: cdc-finalize 완료 — 범위 밖 %d row 삭제, %s 자기 범위만 보유\n", total, targetShard) } diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 91e39e5..05f6dea 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -65,6 +65,12 @@ spec: - --health-probe-bind-address=:8081 image: controller:latest name: manager + env: + # online-resharding 의 InitialCopy/CDC Job 이 쓰는 reshard-copy 이미지. + # 릴리스/kustomize 는 operator 버전과 일치하는 태그로 오버라이드해야 한다 + # (kind 등 로컬은 RESHARD_COPY_IMAGE=reshard-copy:dev 로 load 후 사용). + - name: RESHARD_COPY_IMAGE + value: ghcr.io/keiailab/reshard-copy:latest ports: - name: health containerPort: 8081 diff --git a/internal/router/reshard_cdc_live_test.go b/internal/router/reshard_cdc_live_test.go index 1fc6d3d..5326faf 100644 --- a/internal/router/reshard_cdc_live_test.go +++ b/internal/router/reshard_cdc_live_test.go @@ -103,6 +103,16 @@ func TestCDCLive(t *testing.T) { t.Fatalf("target 에 범위 밖 키 잔존: id=%s → %s", id, s) } } + + // 인덱스/PK 복제 검증: source kv 의 PK(kv_pkey) 가 target 에도 존재. + n, err := ReplicateIndexes(ctx, sourceDSN, targetDSN, "kv") + if err != nil { + t.Fatalf("ReplicateIndexes: %v", err) + } + t.Logf("ReplicateIndexes: %d 인덱스 복제", n) + if countT(t, tgt, "SELECT count(*) FROM pg_indexes WHERE tablename='kv' AND indexname='kv_pkey'") != 1 { + t.Fatal("target 에 PK 인덱스(kv_pkey) 미복제") + } } func mustOpenT(t *testing.T, dsn string) *sql.DB { diff --git a/internal/router/reshard_copy.go b/internal/router/reshard_copy.go index 39b1444..03d6b4c 100644 --- a/internal/router/reshard_copy.go +++ b/internal/router/reshard_copy.go @@ -154,6 +154,10 @@ func CopyShardRange(ctx context.Context, sourceDSN, targetDSN, table string, spe if err := rows.Err(); err != nil { return copied, scanned, fmt.Errorf("router: rows %s: %w", table, err) } + // 데이터 복사 후 인덱스/PK 복제(bulk load 효율 + uniqueness·조회 성능). + if _, err := replicateIndexes(ctx, src, tgt, table); err != nil { + return copied, scanned, err + } return copied, scanned, nil } @@ -254,6 +258,65 @@ func FilterTables(all, exclude []string) []string { return out } +// ReplicateIndexes 는 source table 의 인덱스(PK 백킹 unique index 포함)를 target 에 멱등 +// 생성한다(IF NOT EXISTS). 데이터 복사·sync *후* 호출하는 게 효율적(bulk load 중 인덱스 +// 유지비 회피). 생성한 인덱스 수 반환. 외래키/체크 제약은 후속. +func ReplicateIndexes(ctx context.Context, sourceDSN, targetDSN, table string) (int, error) { + if !tableNamePattern.MatchString(table) { + return 0, fmt.Errorf("%w: %q", ErrInvalidTable, table) + } + src, err := sql.Open("postgres", sourceDSN) + if err != nil { + return 0, fmt.Errorf("router: open source: %w", err) + } + defer func() { _ = src.Close() }() + tgt, err := sql.Open("postgres", targetDSN) + if err != nil { + return 0, fmt.Errorf("router: open target: %w", err) + } + defer func() { _ = tgt.Close() }() + return replicateIndexes(ctx, src, tgt, table) +} + +// replicateIndexes 는 열린 DB 핸들로 인덱스를 복제한다(CopyShardRange 내부 재사용). +func replicateIndexes(ctx context.Context, src, tgt *sql.DB, table string) (int, error) { + rows, err := src.QueryContext(ctx, + `SELECT indexdef FROM pg_indexes WHERE schemaname='public' AND tablename=$1`, table) + if err != nil { + return 0, fmt.Errorf("router: read indexes %s: %w", table, err) + } + var defs []string + for rows.Next() { + var def string + if err := rows.Scan(&def); err != nil { + _ = rows.Close() + return 0, fmt.Errorf("router: scan indexdef %s: %w", table, err) + } + defs = append(defs, def) + } + _ = rows.Close() + if err := rows.Err(); err != nil { + return 0, fmt.Errorf("router: indexes %s: %w", table, err) + } + n := 0 + for _, def := range defs { + if _, err := tgt.ExecContext(ctx, idxIfNotExists(def)); err != nil { + return n, fmt.Errorf("router: create index on %s: %w", table, err) + } + n++ + } + return n, nil +} + +// idxIfNotExists 는 pg_indexes.indexdef 에 IF NOT EXISTS 를 주입해 멱등화한다. +func idxIfNotExists(def string) string { + def = strings.Replace(def, "CREATE UNIQUE INDEX ", "CREATE UNIQUE INDEX IF NOT EXISTS ", 1) + if !strings.Contains(def, "IF NOT EXISTS") { + def = strings.Replace(def, "CREATE INDEX ", "CREATE INDEX IF NOT EXISTS ", 1) + } + return def +} + // ensureTargetTable 은 target 에 table 이 없으면 source 의 컬럼 정의(format_type 로 충실한 // 타입)로 CREATE TABLE 한다. PK/인덱스/제약은 복제하지 않는다(PoC — 데이터 무결성은 copy 가 // vindex 로 보장; 인덱스는 cutover 후 별도 생성 트랙). 이미 있으면 no-op(IF NOT EXISTS). From 9f530ed9882bea53f07811da9affc4f21292a029 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 13:40:18 +0900 Subject: [PATCH 081/109] =?UTF-8?q?docs:=20resharding=20=EC=9A=B4=EC=98=81?= =?UTF-8?q?=ED=99=94(=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EB=B3=B5=EC=A0=9C?= =?UTF-8?q?=C2=B7=EC=9D=B4=EB=AF=B8=EC=A7=80=20env)=20=EC=99=84=EB=A3=8C?= =?UTF-8?q?=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WORK_HANDOFF §6.6: ReplicateIndexes(PK 포함) + RESHARD_COPY_IMAGE manager env 완료·라이브 검증. 새 #2=target shard 영구 승격(ordinal 편입). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/WORK_HANDOFF.ko.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index d3dae2d..92170c7 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -231,7 +231,8 @@ SSOT는 [ROUTER-GAP-ANALYSIS §4 능력 사다리 + §6 백로그](sharding/ROUT | ~~·~~ ✅ | ~~CDC 증분 catch-up 빌딩블록~~ | **완료(2026-06-28)** — `reshard_cdc.go`(논리복제 pub/sub/lag/teardown + DeleteForeignRange) + wal_level=logical. 라이브검증: 구독 이후 라이브 INSERT/UPDATE 까지 target 복제(유실0). | | ~~·~~ ✅ | ~~CDCCatchup phase 컨트롤러 결선(online)~~ | **완료(2026-06-28)** — `spec.Online` 게이트. reconcileCDC: cdc-setup Job(pub/sub+lag≤CDCMaxLag) → write-block ON → cdc-finalize Job(drain+drop+filter-delete) → done. write-block 이 finalize 감싸 무중단. Job mode 일반화(copy/delete/cdc-setup/cdc-finalize). envtest(순서·env) + 라이브 메커니즘(TestCDCLive) 검증. | | **1** | **online 모드 멀티샤드 full e2e** | spec.Online=true ShardSplitJob 을 실 클러스터에서 *동시 쓰기 부하 하* 실행해 무중단 cutover 실증(offline e2e 는 2026-06-28 성공). | -| **2** | **resharding 운영화 마감** | target 인덱스/PK 복제(현재 데이터만), RESHARD_COPY_IMAGE 를 operator manager env 기본 결선, target shard 영구 승격(ordinal 편입) | +| ~~·~~ ✅ | ~~resharding 운영화(인덱스 복제·이미지 env)~~ | **완료(2026-06-28)** — `ReplicateIndexes`(source pg_indexes → target IF NOT EXISTS, PK 백킹 unique index 포함, 데이터 복사 후) offline/online 양 경로 결선. config/manager RESHARD_COPY_IMAGE env. 라이브검증(TestCDCLive: kv_pkey 복제). | +| **2** | **target shard 영구 승격(ordinal 편입)** | resharding 완료 후 transient target(rsd-)을 cluster 의 영구 ordinal shard 로 승격 — status/failover/메트릭이 인식하도록. 외래키/체크 제약 복제도 후속 | | **3** | **멀티머신 수평 스케일 실측** | 진짜 "분산처리능력" 수치 — 물리 분리 노드 필요(router-bench 가 샤드별 DSN 받음, 그대로 적용) | | **4** | **멀티 라우터 인스턴스 수평확장** | 라우터 1-hop 왕복이 남은 오버헤드(prepared direct 86K vs router 23K) — 라우터를 여러 개 띄워 처리량 확장 | | **6** | 보류 #5/#7/#9 | per-shard primary Service·watch·failover lease P2-T3 — 라이브 failover 필요 | From f142bbba086d5fac37446b09f5ad95ba3c2c99dc Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 15:57:33 +0900 Subject: [PATCH 082/109] =?UTF-8?q?docs:=20online=20=EB=AA=A8=EB=93=9C=20f?= =?UTF-8?q?ull=20e2e=20=EC=84=B1=EA=B3=B5=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WORK_HANDOFF §6.6: online resharding full e2e(kind 실 K8s+PG) 성공 — CDCCatchup(cdc-setup →write-block→cdc-finalize)→Completed, t0=44/t1=56/source=0 키유실0 + kv_pkey 복제 + flip. offline+online 양 경로 라이브 검증 완료. 새 #1=native 라우터 동시쓰기 무중단 실증 + target 영구 승격. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/WORK_HANDOFF.ko.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index 92170c7..01da7c1 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -230,7 +230,8 @@ SSOT는 [ROUTER-GAP-ANALYSIS §4 능력 사다리 + §6 백로그](sharding/ROUT | ~~·~~ ✅ | ~~ShardSplitJob full e2e (라이브)~~ | **완료(2026-06-28)** 🎉 — kind 실 K8s+실 PG: 단일샤드(키 1..100)→ShardSplitJob→Bootstrap(target rsd-t0/t1 부팅)→InitialCopy(스키마+데이터 복사 Job)→Cutover(write-block)→RoutingUpdate(ShardRange flip+unblock)→Cleanup(source 삭제 Job)→Completed. **결과 t0=44/t1=56/source=0, 합=100 키유실0, ShardRange flip, write-block 해제.** e2e 가 갭 2개 발견·수정(Job 이미지명 env RESHARD_COPY_IMAGE, 스키마 우선 복제). | | ~~·~~ ✅ | ~~CDC 증분 catch-up 빌딩블록~~ | **완료(2026-06-28)** — `reshard_cdc.go`(논리복제 pub/sub/lag/teardown + DeleteForeignRange) + wal_level=logical. 라이브검증: 구독 이후 라이브 INSERT/UPDATE 까지 target 복제(유실0). | | ~~·~~ ✅ | ~~CDCCatchup phase 컨트롤러 결선(online)~~ | **완료(2026-06-28)** — `spec.Online` 게이트. reconcileCDC: cdc-setup Job(pub/sub+lag≤CDCMaxLag) → write-block ON → cdc-finalize Job(drain+drop+filter-delete) → done. write-block 이 finalize 감싸 무중단. Job mode 일반화(copy/delete/cdc-setup/cdc-finalize). envtest(순서·env) + 라이브 메커니즘(TestCDCLive) 검증. | -| **1** | **online 모드 멀티샤드 full e2e** | spec.Online=true ShardSplitJob 을 실 클러스터에서 *동시 쓰기 부하 하* 실행해 무중단 cutover 실증(offline e2e 는 2026-06-28 성공). | +| ~~·~~ ✅ | ~~online 모드 full e2e (라이브)~~ | **완료(2026-06-28)** 🎉 — kind 실 K8s+실 PG: spec.Online=true → CDCCatchup(cdc-setup Job pub/sub copy_data=true+lag대기 37s → write-block → cdc-finalize Job drain+drop+filter-delete+인덱스복제) → Cleanup → Completed. **t0=44/t1=56/source=0, kv_pkey 복제, ShardRange flip+unblock.** offline+online 양 경로 실 클러스터 검증 완료. | +| **1** | **동시 쓰기 무중단 실증(native 라우터) + target 영구 승격** | online e2e 는 정적 데이터 검증(라이브 쓰기 포착은 TestCDCLive 별도 증명). shardingMode=native(라우터)에서 클라 쓰기를 라우터 경유로 받아 write-block 이 실제로 막는 무중단 실증 + transient target(rsd-)→영구 ordinal shard 승격(status/failover/메트릭 인식) + 외래키/체크 제약 복제 | | ~~·~~ ✅ | ~~resharding 운영화(인덱스 복제·이미지 env)~~ | **완료(2026-06-28)** — `ReplicateIndexes`(source pg_indexes → target IF NOT EXISTS, PK 백킹 unique index 포함, 데이터 복사 후) offline/online 양 경로 결선. config/manager RESHARD_COPY_IMAGE env. 라이브검증(TestCDCLive: kv_pkey 복제). | | **2** | **target shard 영구 승격(ordinal 편입)** | resharding 완료 후 transient target(rsd-)을 cluster 의 영구 ordinal shard 로 승격 — status/failover/메트릭이 인식하도록. 외래키/체크 제약 복제도 후속 | | **3** | **멀티머신 수평 스케일 실측** | 진짜 "분산처리능력" 수치 — 물리 분리 노드 필요(router-bench 가 샤드별 DSN 받음, 그대로 적용) | From aab80da3c280c55a1c48c1b6c78e15fac4c436b6 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 16:42:33 +0900 Subject: [PATCH 083/109] =?UTF-8?q?feat(resharding):=20=EC=99=B8=EB=9E=98?= =?UTF-8?q?=ED=82=A4/=EC=B2=B4=ED=81=AC=20=EC=A0=9C=EC=95=BD=20=EB=B3=B5?= =?UTF-8?q?=EC=A0=9C=20(=EC=8A=A4=ED=82=A4=EB=A7=88=20=EC=B6=A9=EC=8B=A4?= =?UTF-8?q?=EB=8F=84=20=EC=99=84=EC=84=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReplicateConstraints: source 의 CHECK·FK 제약을 target 에 멱등 복제(PK·UNIQUE 는 ReplicateIndexes 가 인덱스로 처리). CHECK 는 항상 안전(실패 시 에러), FK 는 참조 테이블이 같은 shard 에 없으면(cross-shard) best-effort 로 건너뜀. 데이터 복사·sync 후 호출. offline(CopyShardRange)·online(cdc-finalize) 양 경로 결선. 이로써 resharded target 의 스키마 충실도 완성: 컬럼 + 인덱스/PK + CHECK/FK 제약. 검증: 라이브(TestCDCLive 확장) — kv_val_pos CHECK 제약 target 복제 확인. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- cmd/reshard-copy-poc/main.go | 4 +- internal/router/reshard_cdc_live_test.go | 12 +++- internal/router/reshard_copy.go | 76 +++++++++++++++++++++++- 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/cmd/reshard-copy-poc/main.go b/cmd/reshard-copy-poc/main.go index 751889b..7328f89 100644 --- a/cmd/reshard-copy-poc/main.go +++ b/cmd/reshard-copy-poc/main.go @@ -226,10 +226,12 @@ func runCDC(ctx context.Context, mode, src, tgt, targetShard string) { must(err, "delete foreign range "+t) total += n } - // 범위 정리 후 인덱스/PK 복제(데이터 확정 후 — bulk 효율). + // 범위 정리 후 인덱스/PK + 제약(CHECK·FK) 복제(데이터 확정 후 — bulk 효율). for _, t := range tables { _, err := router.ReplicateIndexes(ctx, src, tgt, t) must(err, "replicate indexes "+t) + _, err = router.ReplicateConstraints(ctx, src, tgt, t) + must(err, "replicate constraints "+t) } must(router.DropPublication(ctx, src, pub), "drop publication") fmt.Printf("reshard-copy-poc: cdc-finalize 완료 — 범위 밖 %d row 삭제, %s 자기 범위만 보유\n", total, targetShard) diff --git a/internal/router/reshard_cdc_live_test.go b/internal/router/reshard_cdc_live_test.go index 5326faf..c6860fa 100644 --- a/internal/router/reshard_cdc_live_test.go +++ b/internal/router/reshard_cdc_live_test.go @@ -40,7 +40,7 @@ func TestCDCLive(t *testing.T) { // source: kv 초기 1..50. exec(t, src, `DROP TABLE IF EXISTS kv`) - exec(t, src, `CREATE TABLE kv(id int PRIMARY KEY, val int)`) + exec(t, src, `CREATE TABLE kv(id int PRIMARY KEY, val int, CONSTRAINT kv_val_pos CHECK (val >= 0))`) exec(t, src, `INSERT INTO kv SELECT g, g*10 FROM generate_series(1,50) g`) exec(t, tgt, `DROP TABLE IF EXISTS kv`) exec(t, tgt, `CREATE TABLE kv(id int PRIMARY KEY, val int)`) @@ -113,6 +113,16 @@ func TestCDCLive(t *testing.T) { if countT(t, tgt, "SELECT count(*) FROM pg_indexes WHERE tablename='kv' AND indexname='kv_pkey'") != 1 { t.Fatal("target 에 PK 인덱스(kv_pkey) 미복제") } + + // 제약(CHECK) 복제 검증. + cn, err := ReplicateConstraints(ctx, sourceDSN, targetDSN, "kv") + if err != nil { + t.Fatalf("ReplicateConstraints: %v", err) + } + t.Logf("ReplicateConstraints: %d 제약 복제", cn) + if countT(t, tgt, "SELECT count(*) FROM pg_constraint WHERE conname='kv_val_pos'") != 1 { + t.Fatal("target 에 CHECK 제약(kv_val_pos) 미복제") + } } func mustOpenT(t *testing.T, dsn string) *sql.DB { diff --git a/internal/router/reshard_copy.go b/internal/router/reshard_copy.go index 03d6b4c..1b8286f 100644 --- a/internal/router/reshard_copy.go +++ b/internal/router/reshard_copy.go @@ -154,10 +154,14 @@ func CopyShardRange(ctx context.Context, sourceDSN, targetDSN, table string, spe if err := rows.Err(); err != nil { return copied, scanned, fmt.Errorf("router: rows %s: %w", table, err) } - // 데이터 복사 후 인덱스/PK 복제(bulk load 효율 + uniqueness·조회 성능). + // 데이터 복사 후 인덱스/PK + 제약(CHECK·FK best-effort) 복제(bulk load 효율 + + // uniqueness·조회 성능·무결성). if _, err := replicateIndexes(ctx, src, tgt, table); err != nil { return copied, scanned, err } + if _, err := replicateConstraints(ctx, src, tgt, table); err != nil { + return copied, scanned, err + } return copied, scanned, nil } @@ -308,6 +312,76 @@ func replicateIndexes(ctx context.Context, src, tgt *sql.DB, table string) (int, return n, nil } +// ReplicateConstraints 는 source table 의 CHECK·FOREIGN KEY 제약을 target 에 멱등 복제한다 +// (PK·UNIQUE 는 ReplicateIndexes 가 인덱스로 처리). 반환: 추가 수. FK 는 참조 테이블이 같은 +// shard 에 없으면(cross-shard) 추가 실패할 수 있어 *best-effort* — 실패해도 데이터는 이미 +// 이동됐으므로 막지 않고 건너뛴다(CHECK 는 항상 안전하므로 실패 시 에러). +func ReplicateConstraints(ctx context.Context, sourceDSN, targetDSN, table string) (int, error) { + if !tableNamePattern.MatchString(table) { + return 0, fmt.Errorf("%w: %q", ErrInvalidTable, table) + } + src, err := sql.Open("postgres", sourceDSN) + if err != nil { + return 0, fmt.Errorf("router: open source: %w", err) + } + defer func() { _ = src.Close() }() + tgt, err := sql.Open("postgres", targetDSN) + if err != nil { + return 0, fmt.Errorf("router: open target: %w", err) + } + defer func() { _ = tgt.Close() }() + return replicateConstraints(ctx, src, tgt, table) +} + +// replicateConstraints 는 열린 DB 핸들로 CHECK·FK 제약을 복제한다. +func replicateConstraints(ctx context.Context, src, tgt *sql.DB, table string) (int, error) { + rows, err := src.QueryContext(ctx, ` + SELECT conname, contype, pg_get_constraintdef(oid) + FROM pg_constraint + WHERE conrelid = $1::regclass AND contype IN ('c','f')`, table) + if err != nil { + return 0, fmt.Errorf("router: read constraints %s: %w", table, err) + } + type con struct{ name, typ, def string } + var cons []con + for rows.Next() { + var c con + if err := rows.Scan(&c.name, &c.typ, &c.def); err != nil { + _ = rows.Close() + return 0, fmt.Errorf("router: scan constraint %s: %w", table, err) + } + cons = append(cons, c) + } + _ = rows.Close() + if err := rows.Err(); err != nil { + return 0, fmt.Errorf("router: constraints %s: %w", table, err) + } + + added := 0 + for _, c := range cons { + if !tableNamePattern.MatchString(c.name) { + continue // 비표준 이름 — 건너뜀(injection 안전). + } + // 멱등: 이미 있으면 skip. + var exists int + if err := tgt.QueryRowContext(ctx, + "SELECT 1 FROM pg_constraint WHERE conrelid=$1::regclass AND conname=$2", table, c.name).Scan(&exists); err == nil { + continue + } else if err != sql.ErrNoRows { + return added, fmt.Errorf("router: check constraint %s: %w", c.name, err) + } + stmt := "ALTER TABLE " + table + " ADD CONSTRAINT " + c.name + " " + c.def //nolint:gosec // 식별자 화이트리스트 + if _, err := tgt.ExecContext(ctx, stmt); err != nil { + if c.typ == "f" { + continue // FK best-effort: 참조 테이블 부재(cross-shard) 등 — 건너뜀. + } + return added, fmt.Errorf("router: add constraint %s: %w", c.name, err) + } + added++ + } + return added, nil +} + // idxIfNotExists 는 pg_indexes.indexdef 에 IF NOT EXISTS 를 주입해 멱등화한다. func idxIfNotExists(def string) string { def = strings.Replace(def, "CREATE UNIQUE INDEX ", "CREATE UNIQUE INDEX IF NOT EXISTS ", 1) From 67bcdb62f7bc7925b6d5b83d18e376571662e3c8 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 16:43:43 +0900 Subject: [PATCH 084/109] =?UTF-8?q?docs:=20=EC=99=B8=EB=9E=98=ED=82=A4/?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=20=EC=A0=9C=EC=95=BD=20=EB=B3=B5=EC=A0=9C=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EB=B0=98=EC=98=81=20+=20target=20?= =?UTF-8?q?=EC=8A=B9=EA=B2=A9=20RFC=EA=B8=89=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WORK_HANDOFF §6.6: ReplicateConstraints(CHECK/FK) 완료·라이브검증, resharded 스키마 충실도 완성. target 영구 승격은 ADR-0027 정체성 격리로 RFC급 설계 선행 필요로 명시. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/WORK_HANDOFF.ko.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index 01da7c1..3f38f82 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -231,7 +231,9 @@ SSOT는 [ROUTER-GAP-ANALYSIS §4 능력 사다리 + §6 백로그](sharding/ROUT | ~~·~~ ✅ | ~~CDC 증분 catch-up 빌딩블록~~ | **완료(2026-06-28)** — `reshard_cdc.go`(논리복제 pub/sub/lag/teardown + DeleteForeignRange) + wal_level=logical. 라이브검증: 구독 이후 라이브 INSERT/UPDATE 까지 target 복제(유실0). | | ~~·~~ ✅ | ~~CDCCatchup phase 컨트롤러 결선(online)~~ | **완료(2026-06-28)** — `spec.Online` 게이트. reconcileCDC: cdc-setup Job(pub/sub+lag≤CDCMaxLag) → write-block ON → cdc-finalize Job(drain+drop+filter-delete) → done. write-block 이 finalize 감싸 무중단. Job mode 일반화(copy/delete/cdc-setup/cdc-finalize). envtest(순서·env) + 라이브 메커니즘(TestCDCLive) 검증. | | ~~·~~ ✅ | ~~online 모드 full e2e (라이브)~~ | **완료(2026-06-28)** 🎉 — kind 실 K8s+실 PG: spec.Online=true → CDCCatchup(cdc-setup Job pub/sub copy_data=true+lag대기 37s → write-block → cdc-finalize Job drain+drop+filter-delete+인덱스복제) → Cleanup → Completed. **t0=44/t1=56/source=0, kv_pkey 복제, ShardRange flip+unblock.** offline+online 양 경로 실 클러스터 검증 완료. | -| **1** | **동시 쓰기 무중단 실증(native 라우터) + target 영구 승격** | online e2e 는 정적 데이터 검증(라이브 쓰기 포착은 TestCDCLive 별도 증명). shardingMode=native(라우터)에서 클라 쓰기를 라우터 경유로 받아 write-block 이 실제로 막는 무중단 실증 + transient target(rsd-)→영구 ordinal shard 승격(status/failover/메트릭 인식) + 외래키/체크 제약 복제 | +| ~~·~~ ✅ | ~~외래키/체크 제약 복제~~ | **완료(2026-06-28)** — `ReplicateConstraints`(CHECK 엄격·FK best-effort) offline/online 양 경로. 라이브검증(kv_val_pos CHECK 복제). resharded 스키마 충실도 완성(컬럼+인덱스/PK+제약). | +| **1** | **target shard 영구 승격 (RFC급)** | transient target(rsd-)을 cluster 의 영구 ordinal shard 로 편입 — status/failover/메트릭 인식. **ADR-0027 이 #220-class 정체성 혼동 방지로 의도적 격리** 했으므로 승격은 정체성-임계 설계 필요(ADR/RFC 선행 권장, 서두르지 말 것). | +| **2** | **동시 쓰기 무중단 실증(native 라우터)** | shardingMode=native(라우터)에서 클라 쓰기를 라우터 경유로 받아 write-block 이 실제 클라 쓰기를 막는 무중단 cutover 실증(online e2e 는 정적 데이터로 검증됨, 라이브 쓰기 포착은 TestCDCLive). | | ~~·~~ ✅ | ~~resharding 운영화(인덱스 복제·이미지 env)~~ | **완료(2026-06-28)** — `ReplicateIndexes`(source pg_indexes → target IF NOT EXISTS, PK 백킹 unique index 포함, 데이터 복사 후) offline/online 양 경로 결선. config/manager RESHARD_COPY_IMAGE env. 라이브검증(TestCDCLive: kv_pkey 복제). | | **2** | **target shard 영구 승격(ordinal 편입)** | resharding 완료 후 transient target(rsd-)을 cluster 의 영구 ordinal shard 로 승격 — status/failover/메트릭이 인식하도록. 외래키/체크 제약 복제도 후속 | | **3** | **멀티머신 수평 스케일 실측** | 진짜 "분산처리능력" 수치 — 물리 분리 노드 필요(router-bench 가 샤드별 DSN 받음, 그대로 적용) | From 457c96d37e902fe590904f16c607ea764cbf5766 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 17:02:04 +0900 Subject: [PATCH 085/109] =?UTF-8?q?docs(adr):=20ADR-0029=20resharding=20ta?= =?UTF-8?q?rget=20=EC=8A=B9=EA=B2=A9=20=EC=A0=95=EC=B2=B4=EC=84=B1=20?= =?UTF-8?q?=EC=A0=84=EC=9D=B4=20=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resharding 산출 target shard 를 cluster 1급 시민(HA·관측·failover)으로 승격하는 정체성 전이 설계. ADR-0027 P6(미상세)를 구체화: ordinal→명명 shard 일반화(shard-id label), fenced single-authority 승격(source 관측제외→target adopt→status 편입→source 폐기), ordinal rename 회피(ShardRange 이름 라우팅 보존), #220-class race 회피. 구현은 P-A~P-C 분할(코드 미착수 — 정체성-임계라 설계 선행). (ADR-0028 파일은 세션 전부터 미커밋 WIP 였으며 INDEX 링크 일관성 위해 동봉.) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- .../0028-postgres-first-then-commons-dedup.md | 105 ++++++++++++++++ ...rd-target-promotion-identity-transition.md | 116 ++++++++++++++++++ docs/kb/adr/INDEX.md | 2 + 3 files changed, 223 insertions(+) create mode 100644 docs/kb/adr/0028-postgres-first-then-commons-dedup.md create mode 100644 docs/kb/adr/0029-reshard-target-promotion-identity-transition.md diff --git a/docs/kb/adr/0028-postgres-first-then-commons-dedup.md b/docs/kb/adr/0028-postgres-first-then-commons-dedup.md new file mode 100644 index 0000000..0b85326 --- /dev/null +++ b/docs/kb/adr/0028-postgres-first-then-commons-dedup.md @@ -0,0 +1,105 @@ +# ADR-0028: PostgreSQL 우선 구현 → commons 중복 제거는 후행 일괄 + +- **Date**: 2026-06-26 +- **Status**: Proposed +- **Authors**: @eightynine01 +- **Refs**: ADR-0001 (self-built distributed SQL), ADR-0008 (keiailab-commons adoption), ADR-0020 (commons pkg/pvc+topology 채택), ADR-0027 (resharding identity) + +## Context + +KeiaiLab 워크스페이스(`E:\keiailab`)에는 4개의 독립 산출물이 공존한다: + +| 산출물 | 성격 | 성숙도(2026-06) | +|---|---|---| +| `mongodb-operator` | DB operator | v1.13.2 (성숙, ReplicaSet GA / Sharded beta) | +| `postgres-operator` | DB operator | v0.4.0-beta.9 (G1 HA ~ G5 distributed SQL 진행 중) | +| `valkey-operator` | DB operator | 활발 (Cluster native) | +| `keiailab-commons` | **공용 라이브러리** (operator 아님) | 안정 (`pkg/{security,version,labels,networkpolicy,finalizer,status,topology,...}`) | + +**본질적 차이**: MongoDB·Valkey 는 분산(HA·샤딩·라우팅·rebalancing)이 *DB engine native* 라 operator 가 얇은 오케스트레이터다. PostgreSQL 은 native 분산이 없어 라우터(`internal/router`)·샤딩 메타·online resharding(`ShardSplitJob`)·cross-shard 트랜잭션·자동 failover 를 *operator 가 직접 구현* 한다(ADR-0001). 따라서 mongodb-operator 를 그대로 모방할 수 없고, postgres-operator 가 구현 난이도·시장 가치 모두에서 화력 집중 대상이다. + +mongodb-operator 의 `internal/topology` 는 *순수 의사결정 함수* 로 분리된 검증된 안전장치를 보유한다: + +- `PreflightTopologyChange` — drain(shard 제거) 전 안전검증(잔여 chunk/db, balancer 상태, drain timeout) → `Blockers`/`Warnings`. +- `ComputeMigrationThrottle` — 부하 기반 backpressure(동시성·폴링 간격). +- `PlanBalancerControl` / `PlanZonePlacement` — rebalancer·zone 배치 계획(DryRun advisory). + +postgres-operator 도 `internal/controller/shardsplit/steps.go` 에 cutover 상태기계(`RollbackAllowed`/`ValidateTransition`/`IsTerminal`)를 자체 보유한다. 두 operator 가 *동형(同型)의 의사결정 로직* 을 각자 구현 중이며, 향후 postgres G4(online resharding) 가 mongo 와 같은 drain/throttle/상태기계 패턴을 필요로 한다. + +선택지를 검토했다: + +- **C안 — 단일 multi-DB operator 로 통합**: 기각. 성숙도가 극단적으로 다른 산출물(mongo v1.13 GA vs pg v0.4 beta)을 묶으면 가장 미성숙한 것이 릴리스를 발목 잡는다. 코드 격리·RBAC 최소권한·선택 설치(OLM)·장애 차단을 모두 잃는다. 업계 관례(CNPG / Zalando / Percona)도 DB 별 독립 operator. +- **A안 — 검증된 공통 패턴을 commons 로 승격**: 채택하되 *시점* 이 문제. mongo 의 안전장치를 지금 commons 로 끌어올리면 안정 산출물(mongo)을 건드려야 하고, 2번째 소비자(postgres G4)가 아직 미완이라 추측 추상화 위험이 있다. +- **B안 — postgres 먼저 구현**: 채택. 가치·난이도가 거기 몰려 있다. + +핵심 제약: 중복 제거는 **commons + (mongo 또는 postgres)** 최소 2개 repo 를 동시에 건드린다. 산발적으로 하면 cross-repo 버전 skew 와 회귀 위험이 누적된다. + +## Decision + +**postgres-operator 를 먼저 독립적으로 완성하고, 중복 제거(commons 승격)는 후행 단계에서 cross-repo 일괄로 수행한다.** 4개 산출물은 통합하지 않고 독립 repo 로 유지하며, 공유는 오직 `keiailab-commons` 를 단방향 의존으로 경유한다. + +### 1. 의존·독립성 불변식 + +- `keiailab-commons` 는 **어떤 operator 도 import 하지 않는다**(순환 금지). +- operator 끼리 **서로 import 하지 않는다**(독립성). 공유는 commons 경유만. +- commons 는 **semver**, 각 operator 는 `go.mod` 에서 특정 버전 **pin**. breaking change = commons 메이저 +1 → operator 는 각자 편할 때 마이그레이션. 즉 *코드 공유가 릴리스를 묶지 않는다*(C안의 단점 회피). + +### 2. 단계 순서 + +1. **Phase 1 — postgres 우선 (B)**: mongodb-operator 는 **건드리지 않는다**(freeze). postgres 가 필요한 drain preflight / throttle / cutover 상태기계 등을 *우선 `internal/` 에 자체 구현* 한다. 추상화를 미리 commons 로 빼지 않는다(Rule of Three 미충족). + - G1 마무리(`failover_chaos` 실패 건 안정화) → G3 샤딩 → **G4 online resharding**(drain/throttle/상태기계 본진) → G5 distributed SQL(`internal/tx` 2PC 재구현). +2. **Phase 2 — 중복 제거 일괄 (A, 후행)**: postgres 가 mongo 와 동형 로직을 *실제로 2번째 소비* 하게 된 시점에, 순수 커널을 commons 로 **한 번에** 승격한다. commons 릴리스 → mongo·postgres 가 각자 어댑터로 채택. 이때만 mongo repo 를 건드린다. + +### 3. "commons 로 승격" 판정 3-test (Phase 2 게이트) + +하나라도 NO 면 operator 에 잔류: + +1. **엔진 비종속인가** — 타입에 chunk/shard/mongos/WAL 이 박혀 있으면 NO. 범용어(`RemainingUnits` 등)로 추상화 가능할 때만 OK. +2. **순수 함수인가** — K8s client·DB 커넥션·CRD 를 만지면 NO. 입력→출력만이면 OK. +3. **2번째 소비자가 실재하는가 (Rule of Three)** — 추측 추상화 금지. Phase 1 이 끝나야 충족. + +### 4. 어댑터 패턴 (독립성 보존 장치) + +commons 는 범용 타입만 알고, 도메인 변환은 각 operator 가 수행한다: + +``` +// commons/pkg/topology — 순수 함수 + 범용 타입 +type DrainState struct { RemainingUnits, InflightMoves int; DrainTimeout *time.Duration; RebalancerOn bool } +func DrainPreflight(s DrainState) Verdict + +// mongodb-operator: chunks/dbs → units 어댑터 +// postgres-operator: pending rows / ranges → units 어댑터 +``` + +### 5. 승격 후보 (Phase 2 작업 목록, 확정 아님) + +| 출처 | 무엇 | commons 위치(안) | +|---|---|---| +| mongo `topology.PreflightTopologyChange` | drain 안전검증 | `pkg/topology.DrainPreflight` | +| mongo `topology.ComputeMigrationThrottle` | 부하 기반 backpressure | `pkg/topology.Throttle` | +| mongo `topology.PlanBalancerControl` | rebalancer enable/disable 계획 | `pkg/topology.RebalancePlan` | +| pg `shardsplit/steps.go` 상태기계 | cutover 전이 가드 | `pkg/statemachine` | + +### 6. commons 에 넣지 않는 것 (경계 = 독립성의 전부) + +DB wire protocol(mongos 명령 / libpq forwarding), 2PC 코디네이터(PG `PREPARE TRANSACTION` orphan GC 는 PG 고유), CRD 타입, reconcile I/O. 이를 commons 로 올리면 사실상 C안(통합)이 되므로 금지. + +## Consequences + +**긍정**: + +- postgres 가 mongo 안정성에 발목 잡히지 않고 독립 속도로 전진. +- 중복 제거를 1회 cross-repo 트랜잭션으로 묶어 버전 skew·회귀 위험 최소화. +- mongo·valkey 는 "유지 모드"로 freeze → 안정 산출물 무위험. +- Rule of Three 충족 후 추상화 → over-engineering 회피. + +**부정 / 트레이드오프**: + +- Phase 1 동안 postgres `internal/` 에 mongo 와 의도적으로 *중복된* 로직이 일시 존재(기술 부채를 명시적으로 수용, Phase 2 에서 상환). +- Phase 2 는 최소 2 repo 동시 변경 → 별도 조율 ADR(commons + 채택 operator 각 1건) 필요. +- commons 승격 시 mongo 어댑터 작성 = 그 시점에 한해 mongo freeze 해제(검증된 회귀 가드 단위 테스트로 보호). + +**후속**: + +- Phase 2 착수 시 commons 측 ADR + mongo·postgres 채택 ADR 신규 발행(ADR-0020 패턴 정합). +- 본 ADR 은 `docs/ROADMAP.md` Gate 진행과 연동(G4 가 Phase 2 trigger). diff --git a/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md b/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md new file mode 100644 index 0000000..8446c28 --- /dev/null +++ b/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md @@ -0,0 +1,116 @@ +# ADR-0029: resharding target shard 영구 승격 — 정체성 전이 설계 + +- **Date**: 2026-06-28 +- **Status**: Proposed +- **Authors**: @claude (세션 작업), review pending +- **Refs**: ADR-0027 (비-ordinal target 식별·격리), ADR-0001 (self-built distributed SQL), #220 (failover identity saga) + +## Context + +2026-06-28 기준 online resharding 의 데이터 경로가 *전부* 결선·라이브 검증되었다 (offline + online +무중단 CDC, 스키마/인덱스/PK/제약 복제, write-block, full e2e — `WORK_HANDOFF.ko.md §6.6`). +ShardSplitJob 7-phase 중 Bootstrap→InitialCopy/CDCCatchup→Cutover→RoutingUpdate→Cleanup→Completed +가 실 K8s+PG 에서 동작한다. + +**그러나 ADR-0027 의 P6(승격)는 미구현이다.** 현재 resharding 완료 후 상태: + +- target shard 는 *격리 식별* 로 존재한다: K8s 자원 `-rsd-` + label + `postgres.keiailab.io/reshard-target=` (ordinal `postgres.keiailab.io/shard` label 부재). +- `ShardRange.spec.ranges` 는 target *이름*(예: t0/t1)으로 flip 됨 → 라우터는 정상 라우팅. +- 그러나 `aggregateShardStatus` / `metrics` / failover 는 ordinal `shard=` label 로만 select + 하므로 **승격된 target 에 blind** — status.shards 에 안 잡히고, primary 죽어도 failover 안 됨. +- source 의 ordinal shard(예: shard-0)는 데이터가 비워졌으나 K8s 자원·ordinal 식별은 살아 있음. + +즉 **resharding 으로 만든 새 shard 가 cluster 의 1급 시민이 아니다** — 운영(HA/관측)에서 누락된다. +ADR-0027 은 이 전이를 "두 namespace 가 만나는 유일한 identity-transition 지점, operator-driven + +fenced + single-authority 로 설계, #220-class race 회피, 라이브 chaos 검증 의무"로만 명시하고 +*상세 설계를 미뤘다*. 본 ADR 이 그 상세 설계다. + +**왜 incremental hack 이 위험한가 (#220 교훈 재확인)**: shard-identity 는 bootstrap-init / +leader-election / operator promotion 3 컴포넌트가 *동일 식별 입력* 으로 standby-vs-primary 를 +판정한다. 전이 중 일부만 ordinal label 을 갖고 일부는 reshard-target label 을 갖는 *중간 상태* 가 +관측되면, aggregateShardStatus 가 "primary 0개" 또는 "primary 2개"로 오판 → failover 오동작 → +데이터 손실. 따라서 전이는 **단일 권한(operator) + fenced(중간 상태 비관측) + 멱등** 이어야 한다. + +## Decision + +### 식별 모델: ordinal → *명명(named) shard* 일반화 (장기 정답) + +근본 원인은 cluster 가 shard 를 *ordinal(0,1,2…)* 로만 식별하는 것이다. 그러나 ShardRange(라우팅 +SSOT)는 *이름* 으로 shard 를 가리키며(Vitess/Citus 도 keyrange/named shard 모델), resharding 은 +ordinal 이 아닌 이름(t0/t1)을 만든다. 두 모델의 충돌이 승격을 어렵게 한다. + +**결정**: shard 식별을 *명명 shard* 로 일반화하고, ordinal 은 명명 shard 의 한 특수 형태(이름이 +`shard-`)로 흡수한다. 구체적으로: + +1. **통합 식별 label `postgres.keiailab.io/shard-id=`** 도입. 기존 ordinal shard 는 + `shard-id=shard-` (+ 하위호환 위해 `postgres.keiailab.io/shard=` 병행 한시 유지), + resharding target 은 승격 시 `reshard-target=` → `shard-id=` 로 *재부여*. + `aggregateShardStatus`/`metrics`/failover 의 selector 를 `shard-id` 기반으로 일반화한다. +2. **승격 = label 재부여 + cluster status 편입 + source 폐기**, 단일 operator reconcile 트랜잭션 + 경계 안에서 fenced 수행(아래 §메커니즘). +3. **ordinal 재명명 안 함**: target 은 `rsd-` 이름(K8s 자원)·``(논리 shard)을 *영구 유지* + 한다. ShardRange→backend resolver 가 이미 이름 기반이므로 라우팅 무변경. ordinal 로 rename + 하면 ShardRange 이름과 자원명이 어긋나 라우팅이 깨진다 → rename 금지. + +### 승격 메커니즘 (fenced, single-authority, 멱등) + +ShardSplitJob 에 **Promote phase**(또는 Cleanup 후 별 phase)를 추가, operator 가 다음을 *순서대로* +수행하며 각 단계는 멱등(재진입 안전): + +1. **precondition gate**: RoutingUpdate 완료(ShardRange flip 확정) + 각 target pod Ready + + CDC/복사 Job 완료 확인. 하나라도 미충족 → requeue(전이 보류). 중간 상태에서 승격 시작 금지. +2. **fence**: 승격 대상 target 들에 `shard-id` label 을 *원자적으로* 부여하기 전, source ordinal + shard 를 먼저 *관측에서 제외*(source STS 를 scale 0 또는 `shard-id` label 제거)해 "ordinal + shard-0 이 primary"라는 stale 관측을 끊는다. 이 시점 source 는 이미 비어 있음(Cleanup 완료). +3. **adopt**: 각 target STS/pod 에 `shard-id=` label 부여(`reshard-target` label 은 보존 또는 + 제거). 이 한 번의 label 전이가 "두 namespace 가 만나는 유일 지점"(ADR-0027) — operator 만 + 수행, 외부 컨트롤러/사용자 개입 없음(single-authority). +4. **status 편입**: PostgresCluster.status.shards 를 새 명명 shard 집합으로 재계산(aggregate 가 + `shard-id` 로 select 하므로 자동) + spec 의 shard 토폴로지를 ShardRange 와 정합화(spec.shards + 가 ordinal count 모델이면, 명명 shard 목록 모델로 확장 필요 — 별 변경). +5. **decommission source**: 비워진 source ordinal shard 의 STS/Svc/PVC 회수(가역성 종료 지점 — + AllowForwardOnly 의미와 정합). +6. **Completed**. + +전이가 reconcile 한 번에 안 끝나면(pod 재시작 등) 각 단계 멱등 재진입으로 수렴. aggregateShardStatus +는 전이 *완료 후* 의 label 만 관측(fence 덕에 중간 상태 비관측) → primary 0/2개 오판 회피. + +## Consequences + +### 긍정 +- resharding 산출 shard 가 1급 시민(HA·관측·failover 대상)이 됨 — 운영 누락 해소. +- 명명 shard 일반화는 Vitess/Citus 정합 + 향후 임의 토폴로지(merge, 다단 split)의 토대. +- ordinal rename 회피 → 라우팅(ShardRange↔resolver) 무변경, blast radius 축소. + +### 부정 / 위험 +- **selector 일반화(`shard-id`)가 aggregate_status/metrics/failover/names + 테스트 다수 파일을 + 건드림** — ADR-0027 이 격리 결정에서 Rejected 했던 그 blast radius. 승격에선 불가피하나, 하위호환 + 병행 label + phase 별 증분 + 라이브 chaos 검증으로 위험 관리. +- **fence 단계가 정합 핵심** — source 관측 제외와 target adopt 사이 race 가 있으면 #220 재현. 단일 + reconcile authority + 멱등 + 라이브 chaos drill(승격 중 pod kill) 의무. +- spec 의 shard 모델(ordinal count → 명명 목록) 확장은 CRD 변경(마이그레이션 고려). + +### 검증 의무 +- envtest: Promote phase 가 target 에 `shard-id` 부여 + source label 제거 + status.shards 재계산 + + source STS 회수 순서·멱등 단언. +- 라이브 chaos: 승격 진행 중 operator/target pod kill → 재진입 수렴 + primary 단일성 유지 확인. + +## Alternatives Considered + +- **ordinal 재명명(rsd-t0 → shard-1)**: **Rejected** — ShardRange 가 이름으로 라우팅하므로 rename + 시 ShardRange 이름과 자원명 불일치 → 라우팅 붕괴. ShardRange 도 동시 rename 하면 atomic 보장 + 난해 + 라우터 hot-reload 윈도우에 라우팅 공백. +- **승격 안 함(target 을 영구 격리 유지)**: **Rejected** — resharding 산출 shard 가 HA/관측에서 + 영구 누락 = 운영 불가. resharding 의 목적(영구 토폴로지 변경) 미달. +- **별도 PostgresCluster 로 승격**: **Rejected** (ADR-0027 과 동일) — cluster lifecycle/router 중복. + +## 구현 순서 (별 PR, 각 mergeable) + +1. **P-A**: `shard-id` 통합 label 도입 + aggregate_status/metrics/failover selector 일반화(ordinal + `shard` label 하위호환 병행). envtest 회귀(기존 ordinal cluster 무영향). +2. **P-B**: ShardSplitJob Promote phase — fence + adopt + status 편입 + source decommission. envtest. +3. **P-C**: spec shard 모델 확장(명명 목록) + 마이그레이션. 라이브 chaos drill. + +> 본 ADR 은 *설계 결정* 이며 구현은 위 P-A~P-C 로 분할한다. ADR-0027 P6 의 "신중한 ground-up +> 설계" 요구를 충족한다(standards/principles.md §1 Think Before Coding). diff --git a/docs/kb/adr/INDEX.md b/docs/kb/adr/INDEX.md index 45894a3..12de114 100644 --- a/docs/kb/adr/INDEX.md +++ b/docs/kb/adr/INDEX.md @@ -38,6 +38,8 @@ Standard path: `/docs/kb/adr/` (per the org-wide | [ADR-0025](0025-repmgr-pgbouncer-barman-integration.md) | Repmgr / PgBouncer / Barman 통합 — bitnami parity (orphan recovery from duplicate 0006; PR #98/#96 attempted 0023/0024 but collided with concurrent merges, renumbered to next free 0025) | Proposed | 2026-05-14 | | [ADR-0026](0026-operatorhub-io-version-sync.md) | OperatorHub.io 최신 버전 자동 sync (orphan recovery from duplicate 0007; PR #98/#96 attempted 0024 but collided with concurrent merges, renumbered to next free 0026) | Proposed | 2026-05-14 | | [ADR-0027](0027-non-ordinal-reshard-target-shard-identity.md) | G3 online-resharding 의 비-ordinal target shard 식별 모델 (격리 label `reshard-target` + phase 별 mergeable 증분 + #220 identity 교훈 반영, #223 설계) | Proposed | 2026-06-05 | +| [ADR-0028](0028-postgres-first-then-commons-dedup.md) | PostgreSQL 우선 구현 → commons 중복 제거는 후행 일괄 (4 operator 독립 유지, mongo freeze, G4 가 Phase 2 trigger, 승격 3-test + 어댑터 패턴) | Proposed | 2026-06-26 | +| [ADR-0029](0029-reshard-target-promotion-identity-transition.md) | resharding target shard 영구 승격 — 정체성 전이 설계 (ordinal→명명 shard 일반화 `shard-id` label + fenced single-authority 승격 + source 폐기, ADR-0027 P6 상세화, #220 교훈 반영) | Proposed | 2026-06-28 | ## Archived (v0.x — decisions from before the redesign) From c20ac22bed65a85775e301402281c30aa672ac34 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 17:27:40 +0900 Subject: [PATCH 086/109] =?UTF-8?q?feat(controller):=20ADR-0029=20P-A=20?= =?UTF-8?q?=E2=80=94=20shard-id=20=EB=AA=85=EB=AA=85=20=EC=8B=9D=EB=B3=84?= =?UTF-8?q?=20label=20=ED=86=A0=EB=8C=80=20(additive)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 명명(named) shard 식별의 토대 — ordinal shard pod 에 `postgres.keiailab.io/shard-id= shard-` label 을 *부가* 한다(P-B 승격이 이 통합 label 로 target 을 cluster 1급 시민으로 편입). - names.go: ShardIDLabelKey + ShardIDForOrdinal(`shard-`). - buildPGStatefulSet: podLabels = 셀렉터(labels)의 superset + shard-id. **셀렉터(STS .spec.selector)에는 미포함** — 기존 STS selector 불변 + 업그레이드 중 구 pod 누락 (#220-class race) 회피. reshard target 은 격리 유지(shard-id 미부가, 승격 시 부여). behavior-preserving(셀렉터·기존 ordinal label 불변, pod 에 라벨 1개 추가). 단위검증: ordinal pod 에 shard-id=shard-2, 셀렉터엔 미포함, reshard target 격리 유지. ADR-0029 다음: P-A.2(aggregate/metrics/failover selector 일반화) → P-B(Promote phase). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- internal/controller/builders.go | 15 ++++++++-- internal/controller/builders_test.go | 42 ++++++++++++++++++++++++++++ internal/controller/names.go | 13 +++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/internal/controller/builders.go b/internal/controller/builders.go index 6da34b1..a253df4 100644 --- a/internal/controller/builders.go +++ b/internal/controller/builders.go @@ -1026,6 +1026,15 @@ func buildPGStatefulSet( if reshardTargetID != "" { labels = ReshardTargetSelectorLabels(cluster.Name, reshardTargetID) } + // podLabels 는 셀렉터(labels)의 *superset* — ordinal shard 에 명명 식별 label `shard-id` + // 를 부가한다(ADR-0029 P-A). 셀렉터(labels)에는 넣지 않아 기존 STS selector 불변 + 업그레이드 + // race 회피. reshard target 은 격리 유지(부가 안 함 — 승격 시 부여). + podLabels := labels + if reshardTargetID == "" { + podLabels = make(map[string]string, len(labels)+1) + maps.Copy(podLabels, labels) + podLabels[ShardIDLabelKey] = ShardIDForOrdinal(shardOrdinal) + } replicaConfig, _ := replicaBootstrapConfigForCluster(cluster) replicaClusterEnabled := replicaConfig != nil primaryUser := "" @@ -1080,15 +1089,15 @@ func buildPGStatefulSet( ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: cluster.Namespace, - Labels: labels, + Labels: podLabels, }, Spec: appsv1.StatefulSetSpec{ ServiceName: serviceName, Replicas: &members, - Selector: &metav1.LabelSelector{MatchLabels: labels}, + Selector: &metav1.LabelSelector{MatchLabels: labels}, // 불변 — shard-id 미포함. Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ - Labels: labels, + Labels: podLabels, Annotations: map[string]string{ postgresConfigHashAnnotation: configHash, postgresImageCatalogHashAnnotation: sha256Hex(image), diff --git a/internal/controller/builders_test.go b/internal/controller/builders_test.go index 2f63221..c3fdf25 100644 --- a/internal/controller/builders_test.go +++ b/internal/controller/builders_test.go @@ -90,6 +90,48 @@ func TestBuildPGStatefulSet_AppliesSecurityContextAndEphemeralMounts(t *testing. assertDataplaneSecurityContext(t, &sts.Spec.Template.Spec, "PG StatefulSet") } +// TestBuildPGStatefulSet_ShardIDLabel 은 ADR-0029 P-A: ordinal shard pod 에 명명 식별 label +// `shard-id=shard-` 가 *부가* 되되, STS selector(불변)에는 들어가지 않음을 검증한다 +// (셀렉터에 넣으면 업그레이드 중 구 pod 누락 → #220-class race). +func TestBuildPGStatefulSet_ShardIDLabel(t *testing.T) { + t.Parallel() + cluster := &postgresv1alpha1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: "ns1"}, + } + sts := buildPGStatefulSet( + cluster, "demo-shard-2", "demo-shard-2-headless", 2, + "example.com/postgres:18", "demo-shard-2-config", "18", 1, + postgresv1alpha1.StorageSpec{Size: resource.MustParse("1Gi")}, + corev1.ResourceRequirements{}, "", "test-config-hash", "", + ) + // pod template 에 shard-id 부가. + if got := sts.Spec.Template.Labels[ShardIDLabelKey]; got != "shard-2" { + t.Fatalf("pod shard-id label = %q, want %q", got, "shard-2") + } + // 셀렉터에는 shard-id 미포함(불변 보장). + if _, ok := sts.Spec.Selector.MatchLabels[ShardIDLabelKey]; ok { + t.Fatalf("STS selector 에 shard-id 가 포함됨 (불변 위반 위험): %v", sts.Spec.Selector.MatchLabels) + } + // 기존 ordinal label 은 셀렉터·pod 양쪽에 유지. + if sts.Spec.Selector.MatchLabels["postgres.keiailab.io/shard"] != "2" { + t.Fatalf("ordinal shard label 누락: %v", sts.Spec.Selector.MatchLabels) + } + + // reshard target 은 격리 유지 — shard-id 부가 안 함. + tgt := buildPGStatefulSet( + cluster, "demo-rsd-t0", "demo-rsd-t0-headless", 0, + "example.com/postgres:18", "demo-rsd-t0-config", "18", 1, + postgresv1alpha1.StorageSpec{Size: resource.MustParse("1Gi")}, + corev1.ResourceRequirements{}, "", "test-config-hash", "t0", + ) + if _, ok := tgt.Spec.Template.Labels[ShardIDLabelKey]; ok { + t.Fatalf("reshard target 에 shard-id 가 부가됨(격리 위반): %v", tgt.Spec.Template.Labels) + } + if tgt.Spec.Template.Labels[ReshardTargetLabelKey] != "t0" { + t.Fatalf("reshard target label 누락: %v", tgt.Spec.Template.Labels) + } +} + func TestBuildPGStatefulSet_VolumeClaimTemplateHasClusterLabel(t *testing.T) { t.Parallel() diff --git a/internal/controller/names.go b/internal/controller/names.go index 47cdc2e..b1fd5f2 100644 --- a/internal/controller/names.go +++ b/internal/controller/names.go @@ -144,6 +144,19 @@ func SelectorLabels(cluster, role string, shardOrdinal int32) map[string]string // ReshardTargetLabelKey 는 resharding target shard 를 식별하는 label key 다 (ADR-0027). const ReshardTargetLabelKey = "postgres.keiailab.io/reshard-target" +// ShardIDLabelKey 는 *명명(named) shard* 식별 label key 다 (ADR-0029). ordinal shard 는 +// `shard-`, 승격된 resharding target 은 그 shardID 를 값으로 갖는다 — ordinal·명명 shard 를 +// 통합 식별하는 토대. *셀렉터(STS .spec.selector, Service selector)에는 넣지 않는다* — 기존 +// STS selector 는 불변이고, 추가 시 업그레이드 중 구 pod 이 셀렉터에서 누락돼 #220-class race +// 를 유발한다. pod template 의 *부가* label 로만 부여한다(셀렉터의 superset). +const ShardIDLabelKey = "postgres.keiailab.io/shard-id" + +// ShardIDForOrdinal 은 ordinal shard 의 명명 식별자(`shard-`)를 반환한다 — 논리 shard +// 이름(ShardRange.spec.ranges[].shard) 및 ShardStatefulSetName 의 segment 와 정합. +func ShardIDForOrdinal(ordinal int32) string { + return fmt.Sprintf("shard-%d", ordinal) +} + // ReshardTargetSelectorLabels 는 resharding target shard 의 label 집합이다 (ADR-0027). // // 라이브 shard 와 격리하기 위해: From 0e6f043a288c95309136ceb35bd4addd723c6730 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 17:42:33 +0900 Subject: [PATCH 087/109] =?UTF-8?q?fix(resharding):=20CreatePublication=20?= =?UTF-8?q?=EB=A9=B1=EB=93=B1(=EC=A1=B4=EC=9E=AC=20=EC=8B=9C=20skip)=20?= =?UTF-8?q?=E2=80=94=20Job=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EC=95=88?= =?UTF-8?q?=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코드 리뷰 발견: CreatePublication 이 DROP+CREATE 라, cdc-setup Job 재시도(pod kill 등) 시 활성 subscription 이 의존하는 publication 을 drop 해 스트림이 깨질 수 있었다. pg_publication 존재 확인 후 skip 으로 변경(drop 안 함). build/vet/gofmt clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- internal/router/reshard_cdc.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/internal/router/reshard_cdc.go b/internal/router/reshard_cdc.go index bc9b34b..17eb326 100644 --- a/internal/router/reshard_cdc.go +++ b/internal/router/reshard_cdc.go @@ -57,7 +57,9 @@ func EnsureSchema(ctx context.Context, sourceDSN, targetDSN string, tables []str } // CreatePublication 은 source 에 지정 테이블(빈 목록이면 전 테이블)의 publication 을 멱등 -// 생성한다(DROP IF EXISTS 후 CREATE). +// 생성한다. *이미 있으면 skip* — Job 재시도 시 DROP 하면 활성 subscription 이 의존하는 +// publication 을 떨어뜨려 스트림이 깨지므로 drop 하지 않는다(CREATE PUBLICATION 은 IF NOT +// EXISTS 미지원이라 명시 확인). func CreatePublication(ctx context.Context, sourceDSN, pubName string, tables []string) error { if !pubSubNamePattern.MatchString(pubName) { return fmt.Errorf("%w: publication %q", ErrInvalidTable, pubName) @@ -73,8 +75,11 @@ func CreatePublication(ctx context.Context, sourceDSN, pubName string, tables [] } defer func() { _ = db.Close() }() - if _, err := db.ExecContext(ctx, "DROP PUBLICATION IF EXISTS "+pubName); err != nil { //nolint:gosec // 화이트리스트 검증됨 - return fmt.Errorf("router: drop publication: %w", err) + var exists int + if err := db.QueryRowContext(ctx, "SELECT 1 FROM pg_publication WHERE pubname = $1", pubName).Scan(&exists); err == nil { + return nil // 이미 존재 — 재사용(활성 sub 보존). + } else if err != sql.ErrNoRows { + return fmt.Errorf("router: check publication: %w", err) } target := "FOR ALL TABLES" if len(tables) > 0 { From 91ad6f31deee4621b71c73dc5c4a4443121aefea Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 17:43:50 +0900 Subject: [PATCH 088/109] =?UTF-8?q?docs(handoff):=20=C2=A76.7=202026-06-28?= =?UTF-8?q?=20=EC=84=B8=EC=85=98=20=EC=9A=94=EC=95=BD=20+=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=A6=AC=EB=B7=B0=20=EB=B0=9C=EA=B2=AC=20+=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=8C=20=EC=A7=84=EC=9E=85=EC=A0=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 다른 곳에서 작업 이어갈 수 있도록 인수인계 보강: 세션 성과(라우터 종단·온라인 resharding· 성능·target 승격 P-A), 코드 리뷰 발견(CreatePublication 수정 + wal_level restart/PK-없는-target 동시쓰기 미검증/abort 누수/FK best-effort 주의 + 안전 확인 항목), 다음 진입점(ADR-0029 P-A.2→P-B target 승격, native 라우터 동시쓰기 e2e, abort 정리), 라이브 e2e 재현 절차. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/WORK_HANDOFF.ko.md | 66 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index 3f38f82..704926d 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -242,6 +242,72 @@ SSOT는 [ROUTER-GAP-ANALYSIS §4 능력 사다리 + §6 백로그](sharding/ROUT > 제약 현황(query-mode 라이브): ✅ simple+extended per-query(prepared 포함)·scatter·단일샤드 tx·scram 백엔드·reference·read-replica. ❌ extended scatter·cross-shard 2PC·Flush 파이프라이닝. +### 6.7 2026-06-28 세션 요약 + 코드 리뷰 + 다음 진입점 + +**이 세션에서 한 일(커밋 다수, 미푸시 — 브랜치 `chore/ha-pitr-e2e-consolidation`)**: distributed +SQL 라우터 종단 + 온라인 resharding 을 코드+테스트+(상당수)라이브 e2e 로 완성. + +- **라우터 종단(per-query)**: simple + extended/prepared per-query 라우팅(연결 고정 해소, + `persession.go`/`extsession.go`, 샤드별 prepare-on-first-use), scatter, reference table, + read-replica 결선. bufio 읽기버퍼+단일write 최적화. 라이브 검증 다수. +- **온라인 resharding (ShardSplitJob 7-phase 실 결선)**: Bootstrap(target STS) → InitialCopy + (offline: hash-range copy Job) / CDCCatchup(online: 논리복제 subscription) → Cutover + (write-block) → RoutingUpdate(ShardRange flip+unblock) → Cleanup(source 삭제 Job). 데이터+ + 스키마+인덱스/PK+CHECK/FK 제약 복제. K8s Job 실행 모델(내부 trust 접속). **offline·online + full e2e 둘 다 kind 실 K8s+PG 성공**(t0=44/t1=56/source=0 키유실0). CDC 메커니즘은 `TestCDCLive` + 로 라이브 쓰기 유실0 증명. +- **성능**: `cmd/router-bench`(prepared/멀티라우터/모드). baseline §3.0b~f. 단일 호스트는 자원 + 공유로 수평 스케일 미관측(물리 한계 확정) — 진짜 수치는 멀티머신 필요. +- **target 승격**: 정체성-임계라 **ADR-0029 설계** 후 **P-A(shard-id label 토대, additive)** 만 + 구현. P-A.2/P-B/P-C 는 미착수. + +**코드 리뷰 발견(테스트 미실행, 정독)**: +- ✅ **수정함**: `CreatePublication` DROP+CREATE → 멱등(skip-if-exists). 재시도 시 활성 sub + 의존 pub drop 방지(커밋 `0e6f043`). +- ⚠️ **운영 영향**: `wal_level=replica→logical`(builders.go) 는 *기존 클러스터에 operator 업그레이드 + 시 1회 rolling restart* 유발(config-hash 변경). HA(replicas>0)가 가려주나 단일샤드(replicas=0) + 는 짧은 중단. CHANGELOG/릴리스노트 명시 필요. +- ⚠️ **미검증 경로**: online CDC 에서 *동시 쓰기* 중 PK-없는 target(PK 는 cdc-finalize 에서 추가) + 로의 UPDATE/DELETE 논리복제 — seq-scan 으로 동작하나 미검증(online e2e 는 정적 데이터, TestCDCLive + 는 target 에 PK 선존재). native 라우터 동시쓰기 e2e 필요(아래 #2). +- ⚠️ **abort 누수**: online resharding 이 cdc-setup 후 실패하면 source 의 pub/sub/replication-slot + 이 누수(slot 이 WAL 보존 → 디스크 bloat). abort 핸들러(sub/pub drop) 후속 필요. +- ⚠️ **FK best-effort**: cross-shard FK 는 의도적으로 skip(추가 실패 무시) — FK 강제 기대 사용자에 + 주의. 코드 주석에 명시. +- ✅ **안전 확인**: shard-id label 은 셀렉터 미포함(additive)이라 업그레이드 race 없음. bufConn 은 + auth 후 wrapping(over-read 없음)+쓰기 직행(deadlock 없음). write-block 에러 경로는 ReadyForQuery + 동반(hang 버그 수정됨). 전 식별자 화이트리스트(SQL injection 안전). offline 경로는 mode 일반화 + 후 envtest 로 커버(job 이름/env 불변). + +**다음 작업 진입점(우선순위)**: +1. **ADR-0029 P-A.2 → P-B (target 승격)**: `aggregate_status.go`/`metrics`/`failover` 의 shard + 선택을 `shard-id` 인지로 일반화(단 업그레이드 중 OR-select 위해 in-code 필터 — 셀렉터만으론 구 + pod 누락). 그 후 ShardSplitJob 에 Promote phase(fence→adopt(reshard-target→shard-id)→status + 편입→source decommission, 멱등·single-authority). **#220-class 정체성 위험 — 라이브 chaos drill + (승격 중 pod kill) 의무.** 설계는 `docs/kb/adr/0029-*.md`. +2. **native 라우터 동시쓰기 무중단 e2e**: shardingMode=native(라우터 배포)에서 클라 쓰기를 라우터 + 경유로 받아 write-block 이 실제 쓰기를 막는 무중단 cutover 실증 + 위 PK-없는-target 동시쓰기 경로 + 검증. +3. **abort 누수 정리**: online 실패/abort 시 pub/sub/slot drop. +4. 멀티머신 수평 스케일 실측(하드웨어), 보류 #5/#7/#9(라이브 failover). + +**라이브 e2e 재현 요약(kind, 다른 머신)**: +```bash +# 1) kind + 이미지 +kind create cluster --name pgop-dev +docker build -t pgop:dev . ; docker build -t reshard-copy:dev -f Dockerfile.reshard . +kind load docker-image pgop:dev reshard-copy:dev --name pgop-dev +# 2) operator 배포 + 이미지/env +kubectl apply -k config/default --server-side +kubectl -n postgres-operator-system set image deploy/postgres-operator-controller-manager manager=pgop:dev +kubectl -n postgres-operator-system set env deploy/postgres-operator-controller-manager RESHARD_COPY_IMAGE=reshard-copy:dev +# 3) 단일샤드 cluster + 데이터 → ShardRange + ShardSplitJob(online:true) 적용 후 phase watch +# (정적 데이터면 offline/online 모두 t0/t1 분할 + source=0 검증) +# CDC 메커니즘 단위: 2 PG `postgres:18 -c wal_level=logical` + RESHARD_LIVE_SOURCE/TARGET/CONNINFO +# env + go test ./internal/router/ -run TestCDCLive +# 통합(envtest): make test-integration (또는 setup-envtest + KUBEBUILDER_ASSETS 절대경로) +``` + --- ## 7. 용어집 From 6d006ab7544f177a26fe85b92d6db4acaf0743b9 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 18:58:20 +0900 Subject: [PATCH 089/109] fix(sharding): harden reshard routing and abort cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reference table write가 임의 shard로 라우팅되지 않게 차단 - keyless write scatter 거부 및 scatter error ReadyForQuery 보장 - reshard-copy Job에 ShardRange vindex type 전달 - online reshard 실패/abort 시 cdc-abort cleanup Job 추가 - shard-id 기반 status aggregation 준비 및 ADR/e2e 계획 문서화 검증: WSL Go 1.26.4에서 go test -count=1 ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./internal/controller --- cmd/pg-router/persession.go | 4 + cmd/pg-router/querymode_test.go | 41 ++++ cmd/pg-router/scattermode.go | 9 +- cmd/pg-router/scattermode_test.go | 55 +++++ cmd/reshard-copy-poc/main.go | 56 +++-- cmd/reshard-copy-poc/main_test.go | 42 ++++ docs/WORK_HANDOFF.ko.md | 40 ++++ ...rd-target-promotion-identity-transition.md | 19 ++ docs/sharding/ROUTER-GAP-ANALYSIS.ko.md | 38 ++++ .../plans/2026-06-28-reshard-hardening.md | 172 ++++++++++++++ internal/controller/aggregate_status.go | 34 ++- internal/controller/aggregate_status_test.go | 21 ++ internal/controller/shardsplitjob_abort.go | 116 ++++++++++ .../controller/shardsplitjob_controller.go | 8 +- internal/controller/shardsplitjob_copy.go | 28 ++- .../controller/shardsplitjob_copy_test.go | 1 + .../shardsplitjob_writeblock_test.go | 210 +++++++++++++++++- internal/controller/suite_test.go | 47 +++- internal/router/query_router.go | 10 +- internal/router/query_router_test.go | 19 ++ 20 files changed, 928 insertions(+), 42 deletions(-) create mode 100644 cmd/pg-router/scattermode_test.go create mode 100644 cmd/reshard-copy-poc/main_test.go create mode 100644 docs/superpowers/plans/2026-06-28-reshard-hardening.md create mode 100644 internal/controller/shardsplitjob_abort.go diff --git a/cmd/pg-router/persession.go b/cmd/pg-router/persession.go index a3c2238..b3c64fa 100644 --- a/cmd/pg-router/persession.go +++ b/cmd/pg-router/persession.go @@ -111,6 +111,10 @@ func (s *session) handleSimpleQuery(m pgMessage) bool { // 라우팅. d, err := s.qr.routeSQL(sql) if d.Scatter { // 키 없음 → scatter (자체 연결). + if !router.IsReadOnlyQuery(sql) { + s.queryError("0A000", "cannot scatter a keyless write query") + return true + } if s.inTx { s.queryError("0A000", "cannot scatter a keyless query inside a transaction") return true diff --git a/cmd/pg-router/querymode_test.go b/cmd/pg-router/querymode_test.go index e6cef1c..86d1361 100644 --- a/cmd/pg-router/querymode_test.go +++ b/cmd/pg-router/querymode_test.go @@ -7,7 +7,9 @@ Licensed under the MIT License. See the LICENSE file for details. package main import ( + "net" "testing" + "time" "github.com/keiailab/postgres-operator/internal/router" ) @@ -51,3 +53,42 @@ func TestQueryRouter_routeKey(t *testing.T) { } } } + +func TestSession_KeylessWriteDoesNotScatter(t *testing.T) { + client, routerSide := net.Pipe() + defer client.Close() + defer routerSide.Close() + + s := &session{client: routerSide, qr: testQR()} + done := make(chan bool, 1) + go func() { + done <- s.handleSimpleQuery(pgMessage{Type: 'Q', Payload: cstring("UPDATE t SET v=1")}) + }() + + if err := client.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatalf("SetReadDeadline: %v", err) + } + errMsg, err := readMessage(client) + if err != nil { + t.Fatalf("read error response: %v", err) + } + if errMsg.Type != 'E' { + t.Fatalf("first response type = %q, want E", errMsg.Type) + } + ready, err := readMessage(client) + if err != nil { + t.Fatalf("read ready response: %v", err) + } + if ready.Type != 'Z' || string(ready.Payload) != "I" { + t.Fatalf("ready response = type %q payload %q, want Z/I", ready.Type, string(ready.Payload)) + } + + select { + case keep := <-done: + if !keep { + t.Fatal("handleSimpleQuery should keep the session open after query error") + } + case <-time.After(2 * time.Second): + t.Fatal("handleSimpleQuery did not return") + } +} diff --git a/cmd/pg-router/scattermode.go b/cmd/pg-router/scattermode.go index fc9b4e9..506ca54 100644 --- a/cmd/pg-router/scattermode.go +++ b/cmd/pg-router/scattermode.go @@ -35,7 +35,7 @@ type shardResult struct { func scatterQuery(client net.Conn, qr queryRouter, query pgMessage, raw []byte, dialer *backendDialer, password string) { shards, err := qr.allShards() if err != nil || len(shards) == 0 { - writePgError(client, "08006", "scatter: no shards available") + scatterClientError(client, "08006", "scatter: no shards available") return } @@ -55,7 +55,7 @@ func scatterQuery(client net.Conn, qr queryRouter, query pgMessage, raw []byte, for i := range results { r := results[i] if r.err != nil { - writePgError(client, "08006", fmt.Sprintf("scatter: shard %s: %v", shards[i].shard, r.err)) + scatterClientError(client, "08006", fmt.Sprintf("scatter: shard %s: %v", shards[i].shard, r.err)) return } if r.errMsg != nil { // 한 샤드라도 에러면 그대로 전달(fail-fast). @@ -84,6 +84,11 @@ func scatterQuery(client net.Conn, qr queryRouter, query pgMessage, raw []byte, _ = writeMessage(client, 'Z', []byte{'I'}) } +func scatterClientError(client net.Conn, code, msg string) { + writePgError(client, code, msg) + _ = writeMessage(client, 'Z', []byte{'I'}) +} + // scatterOne 은 한 샤드에 연결·인증·쿼리하고 결과를 수집한다 (goroutine 에서 호출). func scatterOne(sb shardBackend, query pgMessage, raw []byte, dialer *backendDialer, password string) shardResult { conn, err := dialer.Dial(sb.backend) diff --git a/cmd/pg-router/scattermode_test.go b/cmd/pg-router/scattermode_test.go new file mode 100644 index 0000000..8550a57 --- /dev/null +++ b/cmd/pg-router/scattermode_test.go @@ -0,0 +1,55 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package main + +import ( + "net" + "testing" + "time" + + "github.com/keiailab/postgres-operator/internal/router" +) + +func TestScatterQuery_NoShardsSendsReadyForQuery(t *testing.T) { + client, routerSide := net.Pipe() + defer client.Close() + defer routerSide.Close() + + qr := queryRouter{ + provider: router.StaticTopologyProvider{T: router.Topology{}}, + write: func(s string) (string, error) { return s + ":5432", nil }, + } + done := make(chan struct{}, 1) + go func() { + scatterQuery(routerSide, qr, pgMessage{Type: 'Q', Payload: cstring("SELECT * FROM t")}, nil, nil, "") + done <- struct{}{} + }() + + if err := client.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatalf("SetReadDeadline: %v", err) + } + errMsg, err := readMessage(client) + if err != nil { + t.Fatalf("read error response: %v", err) + } + if errMsg.Type != 'E' { + t.Fatalf("first response type = %q, want E", errMsg.Type) + } + ready, err := readMessage(client) + if err != nil { + t.Fatalf("read ready response: %v", err) + } + if ready.Type != 'Z' || string(ready.Payload) != "I" { + t.Fatalf("ready response = type %q payload %q, want Z/I", ready.Type, string(ready.Payload)) + } + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("scatterQuery did not return") + } +} diff --git a/cmd/reshard-copy-poc/main.go b/cmd/reshard-copy-poc/main.go index 7328f89..85beae9 100644 --- a/cmd/reshard-copy-poc/main.go +++ b/cmd/reshard-copy-poc/main.go @@ -16,11 +16,13 @@ Licensed under the MIT License. See the LICENSE file for details. // source. Run ONLY after routing is switched (else data loss). // // The built-in vindex (murmur3 hash, 2-shard split at 0x80000000, column from -// PGROUTER_VINDEX_COLUMN, default "id") matches cmd/pg-router shardSpec so the -// copy and the router agree on which keys belong where. +// PGROUTER_VINDEX_TYPE / PGROUTER_VINDEX_COLUMN, default "hash" / "id") matches +// the ShardRange topology so the copy and the router agree on which keys belong +// where. // // Config: PGROUTER_SOURCE_DSN, PGROUTER_TARGET_DSN, PGROUTER_COPY_TABLE, -// PGROUTER_RESHARD_TARGET_SHARD, PGROUTER_VINDEX_COLUMN, PGROUTER_RESHARD_DELETE_AFTER. +// PGROUTER_RESHARD_TARGET_SHARD, PGROUTER_VINDEX_TYPE, PGROUTER_VINDEX_COLUMN, +// PGROUTER_RESHARD_DELETE_AFTER. package main import ( @@ -38,9 +40,13 @@ import ( // reshardSpec builds the post-split vindex spec. The ranges come from // PGROUTER_RANGES ("shard:lo:hi,shard:lo:hi") when set (controller passes the // target topology), else the default 2-shard murmur3 split (standalone use). -// fn is the vindex function from PGROUTER_VINDEX_FUNCTION (default murmur3). -func reshardSpec(col, fn, rangesEnv string) v1alpha1.ShardRangeSpec { - if fn == "" { +// vtype/fn are from PGROUTER_VINDEX_TYPE / PGROUTER_VINDEX_FUNCTION. Standalone +// use defaults to hash + murmur3. +func reshardSpec(vtype, col, fn, rangesEnv string) v1alpha1.ShardRangeSpec { + if vtype == "" { + vtype = string(v1alpha1.VindexTypeHash) + } + if fn == "" && (vtype == string(v1alpha1.VindexTypeHash) || vtype == string(v1alpha1.VindexTypeConsistentHash)) { fn = "murmur3" } ranges := parseRanges(rangesEnv) @@ -51,7 +57,7 @@ func reshardSpec(col, fn, rangesEnv string) v1alpha1.ShardRangeSpec { } } return v1alpha1.ShardRangeSpec{ - Vindex: v1alpha1.VindexSpec{Type: v1alpha1.VindexTypeHash, Column: col, Function: v1alpha1.VindexHashFunction(fn)}, + Vindex: v1alpha1.VindexSpec{Type: v1alpha1.VindexType(vtype), Column: col, Function: v1alpha1.VindexHashFunction(fn)}, Ranges: ranges, } } @@ -75,32 +81,39 @@ func main() { tgt := os.Getenv("PGROUTER_TARGET_DSN") table := os.Getenv("PGROUTER_COPY_TABLE") targetShard := os.Getenv("PGROUTER_RESHARD_TARGET_SHARD") + mode := os.Getenv("PGROUTER_RESHARD_MODE") + cdcMode := mode == "cdc-setup" || mode == "cdc-finalize" || mode == "cdc-abort" // delete-only: cutover 후 source 에서 이동분 삭제만(복사 없음, Cleanup phase). target 불요. deleteOnly := os.Getenv("PGROUTER_RESHARD_DELETE_ONLY") != "" switch { case src == "": fmt.Fprintln(os.Stderr, "reshard-copy-poc: PGROUTER_SOURCE_DSN required") os.Exit(2) + case cdcMode && targetShard == "": + fmt.Fprintln(os.Stderr, "reshard-copy-poc: CDC requires PGROUTER_RESHARD_TARGET_SHARD") + os.Exit(2) + case cdcMode && tgt == "": + fmt.Fprintln(os.Stderr, "reshard-copy-poc: CDC requires PGROUTER_TARGET_DSN") + os.Exit(2) case deleteOnly && targetShard == "": fmt.Fprintln(os.Stderr, "reshard-copy-poc: DELETE_ONLY requires PGROUTER_RESHARD_TARGET_SHARD") os.Exit(2) case !deleteOnly && tgt == "": fmt.Fprintln(os.Stderr, "reshard-copy-poc: PGROUTER_TARGET_DSN required (unless DELETE_ONLY)") os.Exit(2) - case targetShard == "" && table == "": + case !cdcMode && targetShard == "" && table == "": fmt.Fprintln(os.Stderr, "reshard-copy-poc: full copy requires PGROUTER_COPY_TABLE") os.Exit(2) } - mode := os.Getenv("PGROUTER_RESHARD_MODE") timeout := 60 * time.Second - if mode == "cdc-setup" || mode == "cdc-finalize" { + if cdcMode { timeout = 15 * time.Minute // CDC bulk 복사/drain 은 길 수 있다. } ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() // CDC(online) 모드: 논리복제 setup(스키마+pub+sub+lag대기) 또는 finalize(drain+drop+범위정리). - if mode == "cdc-setup" || mode == "cdc-finalize" { + if cdcMode { runCDC(ctx, mode, src, tgt, targetShard) return } @@ -121,7 +134,7 @@ func main() { if col == "" { col = "id" } - spec := reshardSpec(col, os.Getenv("PGROUTER_VINDEX_FUNCTION"), os.Getenv("PGROUTER_RANGES")) + spec := reshardSpec(os.Getenv("PGROUTER_VINDEX_TYPE"), col, os.Getenv("PGROUTER_VINDEX_FUNCTION"), os.Getenv("PGROUTER_RANGES")) // 옮길 테이블 결정: COPY_TABLE 지정 시 그 하나, 아니면 source 의 모든 user 테이블에서 // reference 테이블(PGROUTER_REFERENCE_TABLES, 전 샤드 복제라 이동 대상 아님)을 뺀 전부. @@ -184,17 +197,12 @@ func main() { // 생성 후 lag ≤ CDC_MAX_LAG 까지 대기(라이브 쓰기 따라잡기, write-block 없음). // - cdc-finalize: 최종 drain(lag→0, write-block 하에 호출 전제) + subscription drop + // DeleteForeignRange(범위 밖 정리) + publication drop. +// - cdc-abort: 실패/중단 경로에서 subscription + publication 만 멱등 정리. func runCDC(ctx context.Context, mode, src, tgt, targetShard string) { if targetShard == "" { fmt.Fprintln(os.Stderr, "reshard-copy-poc: CDC requires PGROUTER_RESHARD_TARGET_SHARD") os.Exit(2) } - col := os.Getenv("PGROUTER_VINDEX_COLUMN") - if col == "" { - col = "id" - } - spec := reshardSpec(col, os.Getenv("PGROUTER_VINDEX_FUNCTION"), os.Getenv("PGROUTER_RANGES")) - tables := cdcTables(ctx, src) pub := "rsd_pub_" + sanitize(targetShard) sub := "rsd_sub_" + sanitize(targetShard) connInfo := src @@ -210,6 +218,7 @@ func runCDC(ctx context.Context, mode, src, tgt, targetShard string) { switch mode { case "cdc-setup": + tables := cdcTables(ctx, src) fmt.Printf("reshard-copy-poc: cdc-setup target=%s tables=%v\n", targetShard, tables) must(router.EnsureSchema(ctx, src, tgt, tables), "ensure schema") must(router.CreatePublication(ctx, src, pub, tables), "create publication") @@ -217,6 +226,12 @@ func runCDC(ctx context.Context, mode, src, tgt, targetShard string) { waitLag(ctx, src, sub, maxLag) fmt.Printf("reshard-copy-poc: cdc-setup 완료 — subscription %s 활성, lag ≤ %d\n", sub, maxLag) case "cdc-finalize": + col := os.Getenv("PGROUTER_VINDEX_COLUMN") + if col == "" { + col = "id" + } + spec := reshardSpec(os.Getenv("PGROUTER_VINDEX_TYPE"), col, os.Getenv("PGROUTER_VINDEX_FUNCTION"), os.Getenv("PGROUTER_RANGES")) + tables := cdcTables(ctx, src) fmt.Printf("reshard-copy-poc: cdc-finalize target=%s (write-block 하 최종 drain)\n", targetShard) waitLag(ctx, src, sub, 0) // write-block 하 → 새 쓰기 없음 → lag→0 수렴. must(router.DropSubscription(ctx, tgt, sub), "drop subscription") @@ -235,6 +250,11 @@ func runCDC(ctx context.Context, mode, src, tgt, targetShard string) { } must(router.DropPublication(ctx, src, pub), "drop publication") fmt.Printf("reshard-copy-poc: cdc-finalize 완료 — 범위 밖 %d row 삭제, %s 자기 범위만 보유\n", total, targetShard) + case "cdc-abort": + fmt.Printf("reshard-copy-poc: cdc-abort target=%s\n", targetShard) + must(router.DropSubscription(ctx, tgt, sub), "drop subscription") + must(router.DropPublication(ctx, src, pub), "drop publication") + fmt.Printf("reshard-copy-poc: cdc-abort 완료 — subscription %s / publication %s 정리\n", sub, pub) } } diff --git a/cmd/reshard-copy-poc/main_test.go b/cmd/reshard-copy-poc/main_test.go new file mode 100644 index 0000000..8c7702e --- /dev/null +++ b/cmd/reshard-copy-poc/main_test.go @@ -0,0 +1,42 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package main + +import ( + "testing" + + "github.com/keiailab/postgres-operator/api/v1alpha1" +) + +func TestReshardSpecPreservesRangeVindexType(t *testing.T) { + spec := reshardSpec("range", "tenant_id", "", "target-a:a:m,target-b:n:z") + if spec.Vindex.Type != v1alpha1.VindexTypeRange { + t.Fatalf("vindex type = %q, want %q", spec.Vindex.Type, v1alpha1.VindexTypeRange) + } + if spec.Vindex.Column != "tenant_id" { + t.Fatalf("vindex column = %q, want tenant_id", spec.Vindex.Column) + } + if spec.Vindex.Function != "" { + t.Fatalf("range vindex function = %q, want empty", spec.Vindex.Function) + } + if len(spec.Ranges) != 2 { + t.Fatalf("ranges = %d, want 2", len(spec.Ranges)) + } +} + +func TestReshardSpecDefaultsToHashMurmur3(t *testing.T) { + spec := reshardSpec("", "id", "", "") + if spec.Vindex.Type != v1alpha1.VindexTypeHash { + t.Fatalf("vindex type = %q, want %q", spec.Vindex.Type, v1alpha1.VindexTypeHash) + } + if spec.Vindex.Function != "murmur3" { + t.Fatalf("vindex function = %q, want murmur3", spec.Vindex.Function) + } + if len(spec.Ranges) != 2 { + t.Fatalf("default ranges = %d, want 2", len(spec.Ranges)) + } +} diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index 704926d..6310622 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -308,6 +308,46 @@ kubectl -n postgres-operator-system set env deploy/postgres-operator-controller- # 통합(envtest): make test-integration (또는 setup-envtest + KUBEBUILDER_ASSETS 절대경로) ``` +### 6.8 다음 세션 실행 정책 (2026-06-28) + +이번 후속 작업은 **자원 절약 모드**로 진행한다. Docker Desktop / WSL / 로컬 VM 은 개발 중에는 +꺼 둔다. kind, Docker 이미지 빌드, 라이브 PG e2e 는 명시적인 검증 체크포인트에서만 다시 켠다. + +작업 순서는 `docs/superpowers/plans/2026-06-28-reshard-hardening.md` 를 따른다. 핵심 원칙: + +1. **개발 먼저, 테스트는 묶어서**: 작은 편집마다 테스트를 돌리지 않는다. Batch 0/1/2 단위로 코드를 + 완성한 뒤 한 번에 검증한다. +2. **먼저 닫을 위험**: reference table 쓰기 단일 shard 라우팅 방지, keyless write scatter 금지, + scatter error 경로 ReadyForQuery 보장, reshard-copy Job 의 vindex type 보존. +3. **그 다음 운영 안전장치**: online resharding 실패/abort 시 pub/sub/replication-slot 누수 정리. +4. **그 다음 ADR-0029**: target 승격은 `shard-id` selector audit 후 Promote phase 로 들어간다. + #220-class 정체성 위험 때문에 live chaos drill 전에는 GA 완료로 보지 않는다. +5. **검증 체크포인트**: 기본은 native Go test 로 시작한다. Docker/kind 는 native router 동시쓰기 e2e + 또는 full live gate 에서만 사용한다. + +2026-06-28 현재 진행 상태: + +- Batch 0 개발 완료: reference table write 단일 shard 라우팅 방지, keyless write scatter 거부, + scatter error `ReadyForQuery` 보장, `PGROUTER_VINDEX_TYPE` 전달/보존, 관련 focused tests 추가. +- Batch 1 개발 완료: online resharding terminal `Failed`/`Aborted` 경로에서 `cdc-abort` Job 으로 + subscription/publication 정리, 성공 후 write-block 해제, 실패 시 `AbortCleanup=False` condition 기록. +- Batch 2 P-A.2 개발 완료: `aggregateShardStatus` 가 legacy `postgres.keiailab.io/shard=` 와 + additive `postgres.keiailab.io/shard-id=shard-` 를 OR 필터링한다. StatefulSet/Service selector 는 + 변경하지 않았다. metrics/failover 는 status 소비자라 aggregation 변경을 따라간다. +- Batch 3 설계 문서 완료: native router concurrent-write e2e 시나리오를 + `docs/sharding/ROUTER-GAP-ANALYSIS.ko.md` 에 기록했다. write stream 중 online CDC, write-block + `ReadyForQuery`, routing update, checksum/key ownership, PK 없는 target UPDATE/DELETE, abort cleanup 을 + 한 live gate 로 검증한다. +- 아직 남은 범위: `shard-id=t1` 같은 named target shard 를 status.shards 새 row 로 생성하는 작업은 + Promote P-B/P-C 범위다. source fence/관측 제외 전에 target 에 동일 `shard-id` 를 붙이면 split-brain + 신호가 될 수 있으므로 순서가 중요하다. +- 라이브 검증 전 주의: `cdc-abort` 는 `DROP SUBSCRIPTION IF EXISTS` 로 원격 replication slot 정리까지 + 시도한다. source 접속 불가 상황에서는 cleanup Job 이 실패하고 `AbortCleanup=False` 로 남는 것이 현재 + 의도한 안전 동작이다. source-down 상태에서도 target subscription 만 강제 제거하는 fallback 은 live drill + 결과를 보고 별도 보강한다. +- 검증 상태: Docker Desktop / WSL / VM 은 종료 유지. Windows host 에 `go`/`gofmt` 가 없어 + `go test`/`gofmt` 는 아직 미실행. 현재 확인된 것은 `git diff --check` 통과뿐이다. + --- ## 7. 용어집 diff --git a/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md b/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md index 8446c28..4d4199f 100644 --- a/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md +++ b/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md @@ -5,6 +5,25 @@ - **Authors**: @claude (세션 작업), review pending - **Refs**: ADR-0027 (비-ordinal target 식별·격리), ADR-0001 (self-built distributed SQL), #220 (failover identity saga) +## P-A.2 selector audit / implementation note (2026-06-28) + +이번 hardening batch 에서 selector 사용처를 다음처럼 분리했다. + +- **그대로 둔 것**: `ShardStatefulSetName`, `ShardServiceName`, PDB/TLS/PVC resize, source shard DNS, + bootstrap loop 는 여전히 ordinal resource naming 이다. 기존 StatefulSet/Service selector 불변성과 + 업그레이드 호환성 때문에 Promote P-B 전에는 바꾸지 않는다. +- **변경한 것**: `aggregateShardStatus` 는 더 이상 `postgres.keiailab.io/shard=` selector 만으로 + pod 를 고르지 않는다. cluster 공통 label 로 넓게 list 한 뒤 코드에서 + `postgres.keiailab.io/shard=` 또는 `postgres.keiailab.io/shard-id=shard-` 를 OR 필터링한다. +- **metrics/failover**: 직접 pod selector 를 갖지 않고 `PostgresCluster.status.shards` 를 소비한다. + 따라서 aggregation 이 `shard-id` 를 이해하면 metrics/failover 는 status 경유로 따라온다. +- **아직 하지 않은 것**: `shard-id=t1` 같은 named target 을 status.shards 의 새 row 로 생성하지는 않았다. + 현재 `PostgresClusterReconciler` 는 `spec.shards.initialCount` ordinal loop 를 유지한다. named shard + row 생성은 P-B/P-C 의 ShardRange/spec 모델 전환 범위다. +- **P-B 주의점**: target 에 `shard-id` 를 붙이기 전에 source ordinal shard 를 fence/관측 제외해야 한다. + source 와 target 이 같은 `shard-id` 로 동시에 보이면 aggregation 은 split-brain 으로 보고 primary 2개 + 상황을 노출한다. 이는 의도적인 안전 신호이며, Promote phase 는 이 중간 상태를 만들지 않아야 한다. + ## Context 2026-06-28 기준 online resharding 의 데이터 경로가 *전부* 결선·라이브 검증되었다 (offline + online diff --git a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md index 857e6c3..d76a428 100644 --- a/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md +++ b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md @@ -9,6 +9,44 @@ --- +## Native Router Concurrent-Write E2E Scenario (2026-06-28) + +목표는 online `ShardSplitJob` 이 정적 데이터뿐 아니라 실제 client write stream 중에도 무중단으로 +동작하는지 검증하는 것이다. 이 시나리오는 Docker/kind/live PG 가 필요한 live gate 이므로 자원 절약 +개발 단계에서는 문서화만 하고 실행은 별도 체크포인트에서 수행한다. + +필수 조건: + +- 모든 client write 는 `PGROUTER_MODE=query` pg-router endpoint 를 통해 들어간다. source shard 로 + 직접 쓰지 않는다. +- `ShardRange` 는 live CRD topology 로 제공하고, router 는 cutover 후 `PGROUTER_REFRESH` 또는 watch + 경로로 routing update 를 따라가야 한다. +- online `ShardSplitJob` 은 `spec.online=true`, `CDCMaxLag` 를 명시하고, reshard-copy image 는 + `cdc-setup` / `cdc-finalize` / `cdc-abort` mode 를 모두 포함해야 한다. +- write-block 구간에서 write query 는 PostgreSQL ErrorResponse 뒤 `ReadyForQuery` 를 받아야 하며, + client connection 이 hang 되면 실패로 본다. + +검증 흐름: + +1. source shard 에 schema 를 만들되 target 은 PK 없이 시작하는 변형을 포함한다. PK/index/constraint 는 + finalize 에서 복제되므로 이 경로가 logical replication UPDATE/DELETE 를 견디는지 확인한다. +2. writer workload 는 INSERT/UPDATE/DELETE 를 섞어 tenant key 별 checksum 을 계속 기록한다. +3. workload 가 실행 중인 상태에서 online `ShardSplitJob` 을 적용한다. +4. `cdc-setup` 이 subscription 을 만들고 lag threshold 에 도달할 때까지 writes 를 계속 유지한다. +5. `Cutover` write-block 중 router write 가 거부되고 `ReadyForQuery` 를 돌려주는지 확인한다. +6. `RoutingUpdate` 후 같은 client workload 가 target shard ownership 으로 정상 라우팅되는지 확인한다. +7. 완료 후 row count, tenant-key checksum, key ownership, source cleanup, target index/PK/CHECK/FK, pub/sub/slot + cleanup 을 모두 검증한다. + +성공 기준: + +- write loss 0, duplicate 0, key ownership mismatch 0. +- source 에 split-out key 가 남지 않는다. +- target 은 schema/index/PK/CHECK 와 best-effort FK 복제 상태를 갖는다. +- write-block error path 는 항상 `ReadyForQuery` 를 동반한다. +- abort/failure drill 에서는 `cdc-abort` Job 이 subscription/publication 을 정리하고, 실패 시 + `AbortCleanup=False` condition 이 남는다. + ## 0. 좌표: 이건 Citus가 아니라 "Vitess-for-Postgres" - **Citus** = PG **내부 확장(extension)**. ADR-0003에서 명시적으로 버린 길. diff --git a/docs/superpowers/plans/2026-06-28-reshard-hardening.md b/docs/superpowers/plans/2026-06-28-reshard-hardening.md new file mode 100644 index 0000000..8b91b50 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-reshard-hardening.md @@ -0,0 +1,172 @@ +# Reshard Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the remaining correctness risks around query routing, reshard-copy topology fidelity, online reshard abort cleanup, and ADR-0029 target promotion. + +**Architecture:** Work proceeds in small code batches, but verification is grouped into explicit checkpoints to avoid repeatedly starting Docker/WSL/VM resources. The first batch fixes low-blast-radius router and reshard-copy correctness gaps. The second batch implements abort cleanup. The third batch advances ADR-0029 promotion only after shard identity selectors are audited. + +**Tech Stack:** Go 1.26, controller-runtime, Kubernetes envtest, PostgreSQL logical replication, pg-router wire protocol code, ShardRange/ShardSplitJob CRDs. + +--- + +## Execution Policy + +- Keep Docker Desktop, WSL, and local VMs stopped during development unless a verification checkpoint explicitly requires them. +- Do not run tests after every small edit. Run grouped verification only after a coherent batch is complete. +- Prefer native `go test` on Windows/host first. Start Docker/kind only for the live e2e checkpoint. +- If a generated API/CRD file is affected, run `make manifests generate` and `make sync-crds` at the checkpoint, not after every individual edit. +- Commit after each verified batch. + +## Batch 0: Low-Blast-Radius Correctness Hardening + +**Files:** +- Modify: `internal/router/query_router.go` +- Modify: `cmd/pg-router/persession.go` +- Modify: `cmd/pg-router/scattermode.go` +- Modify: `internal/controller/shardsplitjob_copy.go` +- Modify: `cmd/reshard-copy-poc/main.go` +- Add or modify tests after the batch: `internal/router/query_router_test.go`, `cmd/pg-router/querymode_test.go`, `cmd/pg-router/scattermode_test.go`, `cmd/reshard-copy-poc/main_test.go`, `internal/controller/shardsplitjob_writeblock_test.go` + +- [x] Stop reference-table writes from being routed to one arbitrary shard. + - In `QueryRouter.Route`, only allow `ReferenceOnly(query)` fast path for read-only queries. + - In `cmd/pg-router/persession.go`, only scatter keyless read-only simple queries. Return a SQL error for keyless writes. + +- [x] Ensure all scatter error paths send `ReadyForQuery`. + - Replace direct `writePgError(...)` returns in `scatterQuery` failures with an error + `ReadyForQuery('I')` helper. + +- [x] Preserve ShardRange vindex type in reshard-copy jobs. + - Extend `keyspaceVindex` to return `Vindex.Type`. + - Pass `PGROUTER_VINDEX_TYPE` into reshard-copy Jobs. + - Update `reshardSpec` to construct hash/range/consistent-hash specs according to env, defaulting to hash for standalone use. + +- [x] Add focused tests for the above. + - `UPDATE countries ...` must not go to `AnyShard`. + - keyless `UPDATE t SET ...` in query mode must not scatter. + - scatter transport/routing errors must include `ReadyForQuery`. + - `PGROUTER_VINDEX_TYPE=range` must build a range spec rather than hash. + - controller Job env must include `PGROUTER_VINDEX_TYPE`. + +Status as of 2026-06-28: code and focused tests are written. `go`/`gofmt` are not available on the Windows host after Docker/WSL shutdown, so the checkpoint command has not been run yet. `git diff --check` passes. + +**Checkpoint Verification:** + +Run once after Batch 0 code and tests are complete: + +```powershell +go test -count=1 ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./internal/controller +``` + +Expected: all listed packages pass without starting Docker/WSL. + +## Batch 1: Online Reshard Abort Cleanup + +**Files:** +- Modify: `internal/controller/shardsplitjob_controller.go` +- Modify: `internal/controller/shardsplitjob_copy.go` +- Modify or add tests: `internal/controller/shardsplitjob_writeblock_test.go` +- Optional helper: `internal/controller/shardsplitjob_abort.go` + +- [x] Add a cleanup path for `Failed` and `Aborted` online resharding jobs. + - Drop target subscription. + - Drop source publication. + - Ensure write-block is released or status explicitly records that manual intervention is required. + +- [x] Keep target StatefulSet/PVC by default. + - Do not delete target data automatically on abort; it is needed for RCA and manual recovery. + +- [x] Record cleanup failures in status. + - Prefer a condition or failure reason that tells the operator which pub/sub/slot artifact remains. + +Status as of 2026-06-28: `cdc-abort` Job mode and terminal `Failed`/`Aborted` cleanup reconciliation are implemented. Controller tests now cover cdc-setup failure, cdc-finalize failure, abort cleanup idempotency, and `AbortCleanup=False` status on cleanup failure. The checkpoint command has not been run yet because the host Go toolchain is unavailable and Docker/WSL remain intentionally stopped. + +**Checkpoint Verification:** + +Run once after Batch 1: + +```powershell +go test -count=1 ./internal/controller +``` + +Expected: controller tests cover cdc-setup failure, cdc-finalize failure, and abort cleanup idempotency. + +## Batch 2: ADR-0029 P-A.2 Selector Audit And Promotion Prep + +**Files:** +- Audit/modify: `internal/controller/aggregate_status.go` +- Audit/modify: `internal/controller/metrics.go` or metrics-related builders if present +- Audit/modify: failover controller files under `internal/controller` and `internal/instance` +- Modify: `docs/kb/adr/0029-reshard-target-promotion-identity-transition.md` +- Modify: `docs/WORK_HANDOFF.ko.md` + +- [x] Inventory every ordinal shard selector. + - Search for `postgres.keiailab.io/shard`, `ShardStatefulSetName`, `shard-`, and ordinal parsing. + - Classify each use as either identity, resource naming, compatibility, or legacy selector. + +- [x] Generalize status aggregation to `shard-id`. + - Keep backward-compatible fallback for existing ordinal shards. + - Do not change StatefulSet selectors in-place. + +- [x] Prepare Promote phase design notes before coding P-B. + - Define exact fence/adopt/status/decommission order. + - Define idempotency markers. + - Define live chaos drill requirements. + +Status as of 2026-06-28: selector audit is documented in ADR-0029 P-A.2. `aggregateShardStatus` now lists by cluster-level labels and filters pods by legacy `postgres.keiailab.io/shard=` OR additive `postgres.keiailab.io/shard-id=shard-`, without changing StatefulSet or Service selectors. Metrics/failover remain status consumers. Named target shard rows such as `shard-id=t1` are intentionally left for Promote P-B/P-C. The checkpoint command has not been run because the host Go toolchain is unavailable. + +**Checkpoint Verification:** + +Run once after Batch 2: + +```powershell +go test -count=1 ./internal/controller +``` + +Expected: existing ordinal clusters still aggregate status, and target shards with `shard-id` can be observed without selector mutation. + +## Batch 3: Native Router Concurrent-Write E2E Design + +**Files:** +- Modify: `docs/WORK_HANDOFF.ko.md` +- Modify: `docs/sharding/ROUTER-GAP-ANALYSIS.ko.md` +- Add or modify live e2e test files only after the scenario is finalized. + +- [x] Document the native-router concurrent-write scenario. + - All client writes go through `PGROUTER_MODE=query`. + - Online `ShardSplitJob` runs while writes continue. + - Write-block must reject writes with `ReadyForQuery`. + - RoutingUpdate must move post-cutover writes to the target shard. + - Final validation must check row count, checksum, key ownership, source cleanup, target indexes, and constraints. + +- [x] Include PK-less target verification. + - Validate UPDATE/DELETE logical replication behavior when target starts without PK and receives schema/indexes later. + +**Checkpoint Verification:** + +This batch is design-first. Live verification should be run only when Docker/kind resources are intentionally restarted. + +Status as of 2026-06-28: the scenario is documented in `docs/sharding/ROUTER-GAP-ANALYSIS.ko.md`. It covers native router writes, online CDC, write-block `ReadyForQuery`, target routing update, checksum/key ownership validation, schema/index/constraint validation, PK-less target behavior, and abort cleanup. No live e2e has been run because Docker/kind remain intentionally stopped. + +## Final Verification Gate + +Run after all selected batches are complete: + +```powershell +go test -count=1 ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./internal/controller +make test-integration +``` + +Live gate, only when resources are intentionally available: + +```powershell +kind create cluster --name pgop-dev +# Build/load operator and reshard-copy images, deploy operator, then run offline and online ShardSplitJob e2e. +``` + +--- + +## Self-Review + +- Spec coverage: resource conservation, development-first workflow, router correctness, reshard-copy vindex fidelity, abort cleanup, ADR-0029 promotion prep, and native concurrent-write e2e are covered. +- Placeholder scan: no task is left as an undefined placeholder; each batch names files, behavior, and verification. +- Type consistency: `PGROUTER_VINDEX_TYPE`, `Vindex.Type`, `shard-id`, `WriteBlocked`, `ReferenceOnly`, and `ReadyForQuery` names match the current codebase vocabulary. diff --git a/internal/controller/aggregate_status.go b/internal/controller/aggregate_status.go index 90d22f1..9d4ab47 100644 --- a/internal/controller/aggregate_status.go +++ b/internal/controller/aggregate_status.go @@ -44,7 +44,10 @@ const podNotReadyReason = "pod-not-ready" // aggregateShardStatus 는 단일 shard 의 모든 Pod (StatefulSet replicas) 를 list 한 뒤 // 각 Pod 의 statusapi annotation 을 parse 해 ShardStatus 를 합성한다 (RFC 0006 R2). // -// Selection: app.kubernetes.io/instance= + postgres.keiailab.io/shard=. +// Selection: app.kubernetes.io/instance= 로 넓게 list 한 뒤 +// postgres.keiailab.io/shard= 또는 postgres.keiailab.io/shard-id=shard- +// 를 in-code OR 필터링한다. Kubernetes selector 는 OR 를 지원하지 않으므로 Promote +// 전환 중 selector mutation 없이 shard-id additive label 을 관측하려면 이 방식이 필요하다. // Aggregation 규칙: // - Role=primary 이고 not-stale 인 Pod 1 개 → ShardStatus.Primary. // (election 합의가 *유일한 leader* 를 보장 — 2개 이상 발견되면 split-brain 신호로 @@ -62,13 +65,14 @@ func aggregateShardStatus( ord int32, svcName string, ) postgresv1alpha1.ShardStatus { - logger := log.FromContext(ctx).WithValues("shard", ord) + shardID := ShardIDForOrdinal(ord) + logger := log.FromContext(ctx).WithValues("shard", shardID) out := postgresv1alpha1.ShardStatus{ - Name: fmt.Sprintf("shard-%d", ord), + Name: shardID, Ordinal: ord, } - sel := labels.SelectorFromSet(labels.Set(SelectorLabels(cluster.Name, "shard", ord))) + sel := labels.SelectorFromSet(statusAggregationSelectorLabels(cluster.Name)) var pods corev1.PodList if err := c.List(ctx, &pods, &client.ListOptions{ Namespace: cluster.Namespace, @@ -104,6 +108,9 @@ func aggregateShardStatus( // reseed. This protects the real (data-holding) primary from ever being reseeded. hasPromotedPrimary := false for i := range pods.Items { + if !podMatchesShardIdentity(&pods.Items[i], ord) { + continue + } if st, ok := parsePodStatus(&pods.Items[i]); ok && st.Role == statusapi.RolePrimary && st.Promoted && !fencedPVC["data-"+pods.Items[i].Name] { @@ -118,6 +125,9 @@ func aggregateShardStatus( for i := range pods.Items { pod := &pods.Items[i] + if !podMatchesShardIdentity(pod, ord) { + continue + } st, ok := parsePodStatus(pod) if !ok { // annotation 부재 — Pod 부팅 직후. fallback 표기. @@ -200,6 +210,22 @@ func aggregateShardStatus( return out } +func statusAggregationSelectorLabels(cluster string) labels.Set { + out := labels.Set(SelectorLabels(cluster, "shard", -1)) + delete(out, "app.kubernetes.io/component") + return out +} + +func podMatchesShardIdentity(pod *corev1.Pod, ord int32) bool { + if pod == nil { + return false + } + if pod.Labels["postgres.keiailab.io/shard"] == fmt.Sprintf("%d", ord) { + return true + } + return pod.Labels[ShardIDLabelKey] == ShardIDForOrdinal(ord) +} + func kubernetesPodNotReady(pod *corev1.Pod) bool { if pod == nil { return true diff --git a/internal/controller/aggregate_status_test.go b/internal/controller/aggregate_status_test.go index d713cf9..81805d8 100644 --- a/internal/controller/aggregate_status_test.go +++ b/internal/controller/aggregate_status_test.go @@ -99,6 +99,27 @@ func TestAggregateShardStatus_PrimaryAndReplica(t *testing.T) { } } +func TestAggregateShardStatus_UsesShardIDLabelWhenOrdinalLabelMissing(t *testing.T) { + t.Parallel() + now := time.Now().UTC() + pod := makePod("demo-rsd-shard-0-0", statusapi.Status{ + Role: statusapi.RolePrimary, Ready: true, + Endpoint: "demo-rsd-shard-0-0.svc:5432", LastUpdate: now, + }, true) + delete(pod.Labels, "postgres.keiailab.io/shard") + pod.Labels["app.kubernetes.io/component"] = "reshard-target" + pod.Labels[ShardIDLabelKey] = ShardIDForOrdinal(0) + c := fake.NewClientBuilder().WithScheme(newScheme(t)).WithObjects(pod).Build() + + out := aggregateShardStatus(context.Background(), c, newCluster(), 0, "demo-shard-0-headless") + if out.Primary == nil || out.Primary.Pod != "demo-rsd-shard-0-0" { + t.Fatalf("shard-id-only pod was not selected as primary: %+v", out.Primary) + } + if out.Name != "shard-0" || out.Ordinal != 0 { + t.Fatalf("shard identity = name %q ordinal %d, want shard-0/0", out.Name, out.Ordinal) + } +} + func TestAggregateShardStatus_PropagatesInstanceReasonAndMessage(t *testing.T) { t.Parallel() now := time.Now().UTC() diff --git a/internal/controller/shardsplitjob_abort.go b/internal/controller/shardsplitjob_abort.go new file mode 100644 index 0000000..5f7b305 --- /dev/null +++ b/internal/controller/shardsplitjob_abort.go @@ -0,0 +1,116 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package controller + +import ( + "context" + "fmt" + "time" + + batchv1 "k8s.io/api/batch/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + postgresv1alpha1 "github.com/keiailab/postgres-operator/api/v1alpha1" +) + +const ( + shardSplitConditionAbortCleanup = "AbortCleanup" + shardSplitReasonCleanupRunning = "AbortCleanupRunning" + shardSplitReasonCleanupComplete = "AbortCleanupComplete" + shardSplitReasonCleanupFailed = "AbortCleanupFailed" +) + +func (r *ShardSplitJobReconciler) reconcileTerminalAbortCleanup(ctx context.Context, ssj *postgresv1alpha1.ShardSplitJob) (ctrl.Result, error) { + if abortCleanupCompleted(ssj) { + return ctrl.Result{}, nil + } + + done, failure, err := r.reconcileAbortCleanup(ctx, ssj) + if err != nil { + return ctrl.Result{}, err + } + switch { + case failure != "": + setAbortCleanupCondition(ssj, metav1.ConditionFalse, shardSplitReasonCleanupFailed, failure) + if err := r.Status().Update(ctx, ssj); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + case !done: + setAbortCleanupCondition(ssj, metav1.ConditionFalse, shardSplitReasonCleanupRunning, "waiting for cdc-abort cleanup jobs") + if err := r.Status().Update(ctx, ssj); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: 3 * time.Second}, nil + default: + setAbortCleanupCondition(ssj, metav1.ConditionTrue, shardSplitReasonCleanupComplete, "online resharding CDC artifacts are cleaned up") + if err := r.Status().Update(ctx, ssj); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } +} + +func (r *ShardSplitJobReconciler) reconcileAbortCleanup(ctx context.Context, ssj *postgresv1alpha1.ShardSplitJob) (done bool, failure string, err error) { + if !ssj.Spec.Online { + return true, "", nil + } + started, err := r.cdcJobsStarted(ctx, ssj) + if err != nil { + return false, "", err + } + if !started { + return true, "", nil + } + + done, failure, err = r.reconcileModeJobs(ctx, ssj, "cdc-abort") + if err != nil || failure != "" || !done { + return done, failure, err + } + if err := r.setWriteBlock(ctx, ssj, false); err != nil { + return false, fmt.Sprintf("release write-block: %v", err), nil + } + return true, "", nil +} + +func (r *ShardSplitJobReconciler) cdcJobsStarted(ctx context.Context, ssj *postgresv1alpha1.ShardSplitJob) (bool, error) { + for _, mode := range []string{"cdc-setup", "cdc-finalize", "cdc-abort"} { + for i := range ssj.Spec.Targets { + name := reshardJobName(ssj.Spec.Cluster, ssj.Spec.Targets[i].ShardID, mode) + var job batchv1.Job + err := r.Get(ctx, client.ObjectKey{Namespace: ssj.Namespace, Name: name}, &job) + switch { + case err == nil: + return true, nil + case apierrors.IsNotFound(err): + continue + default: + return false, err + } + } + } + return false, nil +} + +func abortCleanupCompleted(ssj *postgresv1alpha1.ShardSplitJob) bool { + cond := apimeta.FindStatusCondition(ssj.Status.Conditions, shardSplitConditionAbortCleanup) + return cond != nil && cond.Status == metav1.ConditionTrue && cond.Reason == shardSplitReasonCleanupComplete +} + +func setAbortCleanupCondition(ssj *postgresv1alpha1.ShardSplitJob, status metav1.ConditionStatus, reason, message string) { + apimeta.SetStatusCondition(&ssj.Status.Conditions, metav1.Condition{ + Type: shardSplitConditionAbortCleanup, + Status: status, + Reason: reason, + Message: message, + ObservedGeneration: ssj.Generation, + }) +} diff --git a/internal/controller/shardsplitjob_controller.go b/internal/controller/shardsplitjob_controller.go index aab8334..14dee30 100644 --- a/internal/controller/shardsplitjob_controller.go +++ b/internal/controller/shardsplitjob_controller.go @@ -60,9 +60,13 @@ func (r *ShardSplitJobReconciler) Reconcile(ctx context.Context, req ctrl.Reques } switch ssj.Status.Phase { - case postgresv1alpha1.ShardSplitPhaseCompleted, - postgresv1alpha1.ShardSplitPhaseFailed, + case postgresv1alpha1.ShardSplitPhaseCompleted: + return ctrl.Result{}, nil + case postgresv1alpha1.ShardSplitPhaseFailed, postgresv1alpha1.ShardSplitPhaseAborted: + if ssj.Spec.Online { + return r.reconcileTerminalAbortCleanup(ctx, &ssj) + } return ctrl.Result{}, nil } diff --git a/internal/controller/shardsplitjob_copy.go b/internal/controller/shardsplitjob_copy.go index 832f921..97ac0b4 100644 --- a/internal/controller/shardsplitjob_copy.go +++ b/internal/controller/shardsplitjob_copy.go @@ -50,6 +50,7 @@ var reshardModeVerb = map[string]string{ "delete": "del", "cdc-setup": "cdcset", "cdc-finalize": "cdcfin", + "cdc-abort": "cdcabort", } // reshardJobName 은 target shard·mode 별 데이터이동 Job 이름(결정적 — 멱등 생성). @@ -94,39 +95,41 @@ func rangesEnvValue(targets []postgresv1alpha1.ShardSplitTarget) string { return strings.Join(parts, ",") } -// keyspaceVindex 는 cluster/keyspace 의 ShardRange 에서 vindex 컬럼·함수·reference 테이블을 -// 읽는다 (copy 가 라우팅과 동일 vindex 를 쓰도록). -func (r *ShardSplitJobReconciler) keyspaceVindex(ctx context.Context, ns, cluster, keyspace string) (col, fn string, refTables []string, err error) { +// keyspaceVindex 는 cluster/keyspace 의 ShardRange 에서 vindex 타입·컬럼·함수·reference +// 테이블을 읽는다 (copy 가 라우팅과 동일 vindex 를 쓰도록). +func (r *ShardSplitJobReconciler) keyspaceVindex(ctx context.Context, ns, cluster, keyspace string) (vtype, col, fn string, refTables []string, err error) { var list postgresv1alpha1.ShardRangeList if err := r.List(ctx, &list, client.InNamespace(ns)); err != nil { - return "", "", nil, fmt.Errorf("list ShardRange: %w", err) + return "", "", "", nil, fmt.Errorf("list ShardRange: %w", err) } for i := range list.Items { sr := &list.Items[i] if sr.Spec.Cluster == cluster && sr.Spec.Keyspace == keyspace { - return sr.Spec.Vindex.Column, string(sr.Spec.Vindex.Function), sr.Spec.ReferenceTables, nil + return string(sr.Spec.Vindex.Type), sr.Spec.Vindex.Column, string(sr.Spec.Vindex.Function), sr.Spec.ReferenceTables, nil } } - return "", "", nil, fmt.Errorf("no ShardRange for cluster=%s keyspace=%s", cluster, keyspace) + return "", "", "", nil, fmt.Errorf("no ShardRange for cluster=%s keyspace=%s", cluster, keyspace) } // reshardParams 는 한 데이터이동 Job 의 입력이다. type reshardParams struct { sourceDSN, targetDSN, targetShard string - col, fn, ranges string + vtype, col, fn, ranges string refTables []string maxLag int64 - mode string // copy | delete | cdc-setup | cdc-finalize + mode string // copy | delete | cdc-setup | cdc-finalize | cdc-abort } // buildReshardJob 은 mode 에 맞는 데이터이동 Job 을 만든다: // - copy: source→target 범위복사(offline) · delete: source 에서 이동분 삭제(target 불요) // - cdc-setup: 스키마+pub+sub(copy_data=true)+lag대기 · cdc-finalize: 최종 drain+drop+범위정리 +// - cdc-abort: 실패/중단 시 pub/sub/slot 누수 방지용 정리 func (r *ShardSplitJobReconciler) buildReshardJob(ssj *postgresv1alpha1.ShardSplitJob, p reshardParams) *batchv1.Job { backoff := int32(2) env := []corev1.EnvVar{ {Name: "PGROUTER_SOURCE_DSN", Value: p.sourceDSN}, {Name: "PGROUTER_RESHARD_TARGET_SHARD", Value: p.targetShard}, + {Name: "PGROUTER_VINDEX_TYPE", Value: p.vtype}, {Name: "PGROUTER_VINDEX_COLUMN", Value: p.col}, {Name: "PGROUTER_VINDEX_FUNCTION", Value: p.fn}, {Name: "PGROUTER_RANGES", Value: p.ranges}, @@ -141,6 +144,11 @@ func (r *ShardSplitJobReconciler) buildReshardJob(ssj *postgresv1alpha1.ShardSpl corev1.EnvVar{Name: "PGROUTER_RESHARD_MODE", Value: p.mode}, corev1.EnvVar{Name: "PGROUTER_CDC_MAX_LAG", Value: strconv.FormatInt(p.maxLag, 10)}, ) + case "cdc-abort": + env = append(env, + corev1.EnvVar{Name: "PGROUTER_TARGET_DSN", Value: p.targetDSN}, + corev1.EnvVar{Name: "PGROUTER_RESHARD_MODE", Value: p.mode}, + ) default: // copy env = append(env, corev1.EnvVar{Name: "PGROUTER_TARGET_DSN", Value: p.targetDSN}) } @@ -203,7 +211,7 @@ func (r *ShardSplitJobReconciler) reconcileModeJobs(ctx context.Context, ssj *po if len(ssj.Spec.Sources) == 0 { return false, mode + ": no source shard", nil } - col, fn, refTables, err := r.keyspaceVindex(ctx, ssj.Namespace, ssj.Spec.Cluster, ssj.Spec.Keyspace) + vtype, col, fn, refTables, err := r.keyspaceVindex(ctx, ssj.Namespace, ssj.Spec.Cluster, ssj.Spec.Keyspace) if err != nil { return false, "", err // ShardRange 아직 부재 등 — requeue. } @@ -228,7 +236,7 @@ func (r *ShardSplitJobReconciler) reconcileModeJobs(ctx context.Context, ssj *po } j := r.buildReshardJob(ssj, reshardParams{ sourceDSN: internalShardDSN(srcDNS), targetDSN: targetDSN, targetShard: t.ShardID, - col: col, fn: fn, ranges: ranges, refTables: refTables, maxLag: maxLag, mode: mode, + vtype: vtype, col: col, fn: fn, ranges: ranges, refTables: refTables, maxLag: maxLag, mode: mode, }) if err := controllerutil.SetControllerReference(ssj, j, r.Scheme); err != nil { return false, "", err diff --git a/internal/controller/shardsplitjob_copy_test.go b/internal/controller/shardsplitjob_copy_test.go index 9b8ca5f..741b9a2 100644 --- a/internal/controller/shardsplitjob_copy_test.go +++ b/internal/controller/shardsplitjob_copy_test.go @@ -89,6 +89,7 @@ var _ = Describe("ShardSplitJob InitialCopy 복사 Job 결선", func() { To(Succeed(), "target %s 복사 Job 이 생성돼야 함", shardID) c := job.Spec.Template.Spec.Containers[0] Expect(envOf(c, "PGROUTER_RESHARD_TARGET_SHARD")).To(Equal(shardID)) + Expect(envOf(c, "PGROUTER_VINDEX_TYPE")).To(Equal("hash")) Expect(envOf(c, "PGROUTER_VINDEX_COLUMN")).To(Equal("id")) Expect(envOf(c, "PGROUTER_VINDEX_FUNCTION")).To(Equal("murmur3")) Expect(envOf(c, "PGROUTER_REFERENCE_TABLES")).To(Equal("country")) diff --git a/internal/controller/shardsplitjob_writeblock_test.go b/internal/controller/shardsplitjob_writeblock_test.go index 6bd6728..72c6954 100644 --- a/internal/controller/shardsplitjob_writeblock_test.go +++ b/internal/controller/shardsplitjob_writeblock_test.go @@ -15,6 +15,7 @@ import ( batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" @@ -39,6 +40,14 @@ var _ = Describe("ShardSplitJob Cutover write-block", func() { } return "" } + failJob := func(job *batchv1.Job) { + now := metav1.Now() + job.Status.StartTime = &now + job.Status.Conditions = []batchv1.JobCondition{ + {Type: batchv1.JobFailureTarget, Status: corev1.ConditionTrue, Reason: "BackoffLimitExceeded"}, + {Type: batchv1.JobFailed, Status: corev1.ConditionTrue, Reason: "BackoffLimitExceeded"}, + } + } It("Cutover 가 write-block 을 켜고 RoutingUpdate 가 ranges flip 과 함께 끈다", func() { clusterName := fmt.Sprintf("rsdwb-%d", GinkgoRandomSeed()) @@ -103,7 +112,7 @@ var _ = Describe("ShardSplitJob Cutover write-block", func() { ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-sr", Namespace: ns}, Spec: postgresv1alpha1.ShardRangeSpec{ Cluster: clusterName, Keyspace: keyspace, - Vindex: postgresv1alpha1.VindexSpec{Type: postgresv1alpha1.VindexTypeHash, Column: "id", Function: "murmur3"}, + Vindex: postgresv1alpha1.VindexSpec{Type: postgresv1alpha1.VindexTypeRange, Column: "id"}, Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0xffffffff", Shard: "shard-0"}}, }, } @@ -131,6 +140,8 @@ var _ = Describe("ShardSplitJob Cutover write-block", func() { Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: reshardJobName(clusterName, "t1", "cdc-setup")}, &setup)).To(Succeed()) Expect(envOf(setup.Spec.Template.Spec.Containers[0], "PGROUTER_RESHARD_MODE")).To(Equal("cdc-setup")) Expect(envOf(setup.Spec.Template.Spec.Containers[0], "PGROUTER_CDC_MAX_LAG")).To(Equal("16777216")) + Expect(envOf(setup.Spec.Template.Spec.Containers[0], "PGROUTER_VINDEX_TYPE")).To(Equal("range")) + Expect(envOf(setup.Spec.Template.Spec.Containers[0], "PGROUTER_VINDEX_COLUMN")).To(Equal("id")) var got postgresv1alpha1.ShardRange Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(sr), &got)).To(Succeed()) Expect(got.Spec.WriteBlocked).To(BeFalse(), "cdc-setup 완료 전엔 write-block 미설정") @@ -155,6 +166,203 @@ var _ = Describe("ShardSplitJob Cutover write-block", func() { Expect(done).To(BeTrue()) }) + It("online 모드 CDC Job 실패를 phase별 failure 로 보고한다", func() { + clusterName := fmt.Sprintf("rsdcdcfail-%d", GinkgoRandomSeed()) + keyspace := "default" + sr := &postgresv1alpha1.ShardRange{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-sr", Namespace: ns}, + Spec: postgresv1alpha1.ShardRangeSpec{ + Cluster: clusterName, Keyspace: keyspace, + Vindex: postgresv1alpha1.VindexSpec{Type: postgresv1alpha1.VindexTypeHash, Column: "id", Function: "murmur3"}, + Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0xffffffff", Shard: "shard-0"}}, + }, + } + Expect(k8sClient.Create(ctx, sr)).To(Succeed()) + ssj := &postgresv1alpha1.ShardSplitJob{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-ssj", Namespace: ns}, + Spec: postgresv1alpha1.ShardSplitJobSpec{ + Cluster: clusterName, Keyspace: keyspace, Sources: []string{"shard-0"}, Online: true, + Targets: []postgresv1alpha1.ShardSplitTarget{ + {ShardID: "t1", Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0xffffffff", Shard: "t1"}}}, + }, + }, + } + Expect(k8sClient.Create(ctx, ssj)).To(Succeed()) + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(ssj), ssj)).To(Succeed()) + r := &ShardSplitJobReconciler{Client: k8sClient, Scheme: scheme.Scheme} + + done, failure, err := r.reconcileCDC(ctx, ssj) + Expect(err).NotTo(HaveOccurred()) + Expect(failure).To(BeEmpty()) + Expect(done).To(BeFalse()) + var setup batchv1.Job + Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: reshardJobName(clusterName, "t1", "cdc-setup")}, &setup)).To(Succeed()) + failJob(&setup) + Expect(k8sClient.Status().Update(ctx, &setup)).To(Succeed()) + done, failure, err = r.reconcileCDC(ctx, ssj) + Expect(err).NotTo(HaveOccurred()) + Expect(done).To(BeFalse()) + Expect(failure).To(ContainSubstring("cdc-setup")) + + clusterName = fmt.Sprintf("rsdcdcfinfail-%d", GinkgoRandomSeed()) + sr = &postgresv1alpha1.ShardRange{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-sr", Namespace: ns}, + Spec: postgresv1alpha1.ShardRangeSpec{ + Cluster: clusterName, Keyspace: keyspace, + Vindex: postgresv1alpha1.VindexSpec{Type: postgresv1alpha1.VindexTypeHash, Column: "id", Function: "murmur3"}, + Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0xffffffff", Shard: "shard-0"}}, + }, + } + Expect(k8sClient.Create(ctx, sr)).To(Succeed()) + ssj = &postgresv1alpha1.ShardSplitJob{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-ssj", Namespace: ns}, + Spec: postgresv1alpha1.ShardSplitJobSpec{ + Cluster: clusterName, Keyspace: keyspace, Sources: []string{"shard-0"}, Online: true, + Targets: []postgresv1alpha1.ShardSplitTarget{ + {ShardID: "t1", Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0xffffffff", Shard: "t1"}}}, + }, + }, + } + Expect(k8sClient.Create(ctx, ssj)).To(Succeed()) + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(ssj), ssj)).To(Succeed()) + + done, failure, err = r.reconcileCDC(ctx, ssj) + Expect(err).NotTo(HaveOccurred()) + Expect(failure).To(BeEmpty()) + Expect(done).To(BeFalse()) + Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: reshardJobName(clusterName, "t1", "cdc-setup")}, &setup)).To(Succeed()) + setup.Status.Succeeded = 1 + Expect(k8sClient.Status().Update(ctx, &setup)).To(Succeed()) + done, failure, err = r.reconcileCDC(ctx, ssj) + Expect(err).NotTo(HaveOccurred()) + Expect(failure).To(BeEmpty()) + Expect(done).To(BeFalse()) + var fin batchv1.Job + Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: reshardJobName(clusterName, "t1", "cdc-finalize")}, &fin)).To(Succeed()) + failJob(&fin) + Expect(k8sClient.Status().Update(ctx, &fin)).To(Succeed()) + done, failure, err = r.reconcileCDC(ctx, ssj) + Expect(err).NotTo(HaveOccurred()) + Expect(done).To(BeFalse()) + Expect(failure).To(ContainSubstring("cdc-finalize")) + }) + + It("online abort cleanup: cdc-abort Job 성공 후 write-block 을 해제하고 멱등이다", func() { + clusterName := fmt.Sprintf("rsdabort-%d", GinkgoRandomSeed()) + keyspace := "default" + sr := &postgresv1alpha1.ShardRange{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-sr", Namespace: ns}, + Spec: postgresv1alpha1.ShardRangeSpec{ + Cluster: clusterName, Keyspace: keyspace, + Vindex: postgresv1alpha1.VindexSpec{Type: postgresv1alpha1.VindexTypeHash, Column: "id", Function: "murmur3"}, + WriteBlocked: true, + Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0xffffffff", Shard: "shard-0"}}, + }, + } + Expect(k8sClient.Create(ctx, sr)).To(Succeed()) + ssj := &postgresv1alpha1.ShardSplitJob{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-ssj", Namespace: ns}, + Spec: postgresv1alpha1.ShardSplitJobSpec{ + Cluster: clusterName, Keyspace: keyspace, Sources: []string{"shard-0"}, Online: true, + Targets: []postgresv1alpha1.ShardSplitTarget{ + {ShardID: "t1", Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0xffffffff", Shard: "t1"}}}, + }, + }, + } + Expect(k8sClient.Create(ctx, ssj)).To(Succeed()) + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(ssj), ssj)).To(Succeed()) + r := &ShardSplitJobReconciler{Client: k8sClient, Scheme: scheme.Scheme} + + done, failure, err := r.reconcileModeJobs(ctx, ssj, "cdc-setup") + Expect(err).NotTo(HaveOccurred()) + Expect(failure).To(BeEmpty()) + Expect(done).To(BeFalse()) + + done, failure, err = r.reconcileAbortCleanup(ctx, ssj) + Expect(err).NotTo(HaveOccurred()) + Expect(failure).To(BeEmpty()) + Expect(done).To(BeFalse()) + var abort batchv1.Job + Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: reshardJobName(clusterName, "t1", "cdc-abort")}, &abort)).To(Succeed()) + Expect(envOf(abort.Spec.Template.Spec.Containers[0], "PGROUTER_RESHARD_MODE")).To(Equal("cdc-abort")) + var got postgresv1alpha1.ShardRange + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(sr), &got)).To(Succeed()) + Expect(got.Spec.WriteBlocked).To(BeTrue(), "abort cleanup Job 완료 전에는 write-block 을 유지한다") + + abort.Status.Succeeded = 1 + Expect(k8sClient.Status().Update(ctx, &abort)).To(Succeed()) + done, failure, err = r.reconcileAbortCleanup(ctx, ssj) + Expect(err).NotTo(HaveOccurred()) + Expect(failure).To(BeEmpty()) + Expect(done).To(BeTrue()) + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(sr), &got)).To(Succeed()) + Expect(got.Spec.WriteBlocked).To(BeFalse()) + + done, failure, err = r.reconcileAbortCleanup(ctx, ssj) + Expect(err).NotTo(HaveOccurred()) + Expect(failure).To(BeEmpty()) + Expect(done).To(BeTrue()) + }) + + It("online abort cleanup: cdc-abort Job 실패를 manual cleanup 필요 상태로 보고한다", func() { + clusterName := fmt.Sprintf("rsdabortfail-%d", GinkgoRandomSeed()) + keyspace := "default" + sr := &postgresv1alpha1.ShardRange{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-sr", Namespace: ns}, + Spec: postgresv1alpha1.ShardRangeSpec{ + Cluster: clusterName, Keyspace: keyspace, + Vindex: postgresv1alpha1.VindexSpec{Type: postgresv1alpha1.VindexTypeHash, Column: "id", Function: "murmur3"}, + WriteBlocked: true, + Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0xffffffff", Shard: "shard-0"}}, + }, + } + Expect(k8sClient.Create(ctx, sr)).To(Succeed()) + ssj := &postgresv1alpha1.ShardSplitJob{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-ssj", Namespace: ns}, + Spec: postgresv1alpha1.ShardSplitJobSpec{ + Cluster: clusterName, Keyspace: keyspace, Sources: []string{"shard-0"}, Online: true, + Targets: []postgresv1alpha1.ShardSplitTarget{ + {ShardID: "t1", Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0xffffffff", Shard: "t1"}}}, + }, + }, + } + Expect(k8sClient.Create(ctx, ssj)).To(Succeed()) + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(ssj), ssj)).To(Succeed()) + r := &ShardSplitJobReconciler{Client: k8sClient, Scheme: scheme.Scheme} + + done, failure, err := r.reconcileModeJobs(ctx, ssj, "cdc-setup") + Expect(err).NotTo(HaveOccurred()) + Expect(failure).To(BeEmpty()) + Expect(done).To(BeFalse()) + done, failure, err = r.reconcileAbortCleanup(ctx, ssj) + Expect(err).NotTo(HaveOccurred()) + Expect(failure).To(BeEmpty()) + Expect(done).To(BeFalse()) + var abort batchv1.Job + Expect(k8sClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: reshardJobName(clusterName, "t1", "cdc-abort")}, &abort)).To(Succeed()) + failJob(&abort) + Expect(k8sClient.Status().Update(ctx, &abort)).To(Succeed()) + + done, failure, err = r.reconcileAbortCleanup(ctx, ssj) + Expect(err).NotTo(HaveOccurred()) + Expect(done).To(BeFalse()) + Expect(failure).To(ContainSubstring("cdc-abort")) + var got postgresv1alpha1.ShardRange + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(sr), &got)).To(Succeed()) + Expect(got.Spec.WriteBlocked).To(BeTrue(), "cleanup 실패 시 write-block 해제 여부를 성공으로 오인하면 안 된다") + + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(ssj), ssj)).To(Succeed()) + ssj.Status.Phase = postgresv1alpha1.ShardSplitPhaseFailed + Expect(k8sClient.Status().Update(ctx, ssj)).To(Succeed()) + _, err = r.reconcileTerminalAbortCleanup(ctx, ssj) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(ssj), ssj)).To(Succeed()) + cond := apimeta.FindStatusCondition(ssj.Status.Conditions, shardSplitConditionAbortCleanup) + Expect(cond).NotTo(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + Expect(cond.Reason).To(Equal(shardSplitReasonCleanupFailed)) + }) + It("forward-only cutover 는 write-block 을 켜지 않는다(비가역 거부)", func() { clusterName := fmt.Sprintf("rsdwbfo-%d", GinkgoRandomSeed()) keyspace := "default" diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 202079b..d999f12 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -8,7 +8,9 @@ package controller import ( "context" + "fmt" "os" + "os/exec" "path/filepath" "runtime" "sort" @@ -116,7 +118,7 @@ var _ = AfterSuite(func() { time.Sleep(200 * time.Millisecond) } if testEnv != nil { - Expect(testEnv.Stop()).To(Succeed()) + Expect(stopEnvtest(testEnv)).To(Succeed()) } }) @@ -140,9 +142,50 @@ func envtestAssetsPath(projectRoot string) string { func hasEnvtestBinaries(dir string) bool { for _, name := range []string{"etcd", "kube-apiserver", "kubectl"} { - if _, err := os.Stat(filepath.Join(dir, name)); err != nil { + if _, err := os.Stat(filepath.Join(dir, envtestBinaryName(name))); err != nil { return false } } return true } + +func envtestBinaryName(name string) string { + if runtime.GOOS == "windows" { + return name + ".exe" + } + return name +} + +func stopEnvtest(env *envtest.Environment) error { + err := env.Stop() + if err == nil || runtime.GOOS != "windows" { + return err + } + if cleanupErr := stopWindowsEnvtestProcess(env.ControlPlane.GetAPIServer().Path); cleanupErr != nil { + return fmt.Errorf("%w; cleanup kube-apiserver: %v", err, cleanupErr) + } + if env.ControlPlane.Etcd != nil { + if cleanupErr := stopWindowsEnvtestProcess(env.ControlPlane.Etcd.Path); cleanupErr != nil { + return fmt.Errorf("%w; cleanup etcd: %v", err, cleanupErr) + } + } + return nil +} + +func stopWindowsEnvtestProcess(path string) error { + if path == "" { + return nil + } + script := ` +$target = [System.IO.Path]::GetFullPath($env:ENVTEST_PROCESS_PATH) +Get-Process | Where-Object { + $_.Path -and ([System.IO.Path]::GetFullPath($_.Path) -eq $target) +} | Stop-Process -Force +` + cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script) + cmd.Env = append(os.Environ(), "ENVTEST_PROCESS_PATH="+path) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("%v: %s", err, string(out)) + } + return nil +} diff --git a/internal/router/query_router.go b/internal/router/query_router.go index 75eecf5..a9a32f6 100644 --- a/internal/router/query_router.go +++ b/internal/router/query_router.go @@ -51,7 +51,7 @@ type QueryRouter struct { // Route 는 한 쿼리의 라우팅 결정을 낸다: // -// 1. reference-only 쿼리(복제 테이블만 참조) → 키 없이 AnyShard. +// 1. reference-only 읽기 쿼리(복제 테이블만 참조) → 키 없이 AnyShard. // 2. 그 외 → 샤딩 키 추출 → 단일 shard. 키가 없으면 Scatter=true + ErrNoRoutingKey // (호출자가 scatter-gather 또는 거부 선택). // @@ -70,12 +70,16 @@ func (qr QueryRouter) Route(query string) (RouteDecision, error) { return RouteDecision{}, fmt.Errorf("router: QueryRouter has no backend resolver") } - // 1) reference-only → 임의 shard. - if qr.Topology.ReferenceOnly(query) { + // 1) reference-only 읽기 → 임의 shard. reference table 쓰기는 복제 불변식을 깨지 + // 않도록 여기서 단일 shard 로 보내지 않는다. + if read && qr.Topology.ReferenceOnly(query) { return qr.decide(qr.Topology.AnyShard())(pick, read) } // 2) 샤딩 키 → 단일 shard. + if qr.Extractor == nil { + return RouteDecision{}, fmt.Errorf("router: QueryRouter has no route key extractor") + } col := qr.Topology.Spec.Vindex.Column key, ok := qr.Extractor.ExtractRoutingKey(query, col) if !ok { diff --git a/internal/router/query_router_test.go b/internal/router/query_router_test.go index 99d3ec2..9f70aaf 100644 --- a/internal/router/query_router_test.go +++ b/internal/router/query_router_test.go @@ -93,6 +93,17 @@ func TestQueryRouter_ReferenceOnlyUsesAnyShard(t *testing.T) { } } +func TestQueryRouter_ReferenceWriteDoesNotUseAnyShard(t *testing.T) { + qr := testQueryRouter() + d, err := qr.Route("UPDATE countries SET name = 'Korea'") + if !errors.Is(err, ErrNoRoutingKey) { + t.Fatalf("reference write err = %v, want ErrNoRoutingKey", err) + } + if !d.Scatter || d.Read { + t.Fatalf("reference write decision = %+v, want write scatter signal", d) + } +} + func TestQueryRouter_NoKeySignalsScatter(t *testing.T) { qr := testQueryRouter() d, err := qr.Route("SELECT * FROM t") // 키 없음, reference 아님 @@ -104,6 +115,14 @@ func TestQueryRouter_NoKeySignalsScatter(t *testing.T) { } } +func TestQueryRouter_NilExtractorReturnsError(t *testing.T) { + qr := testQueryRouter() + qr.Extractor = nil + if _, err := qr.Route("SELECT v FROM t WHERE tenant_id = 'alice'"); err == nil { + t.Fatal("expected nil extractor error") + } +} + func TestQueryRouter_BackendErrorPropagates(t *testing.T) { qr := testQueryRouter() qr.Write = func(string) (string, error) { return "", errors.New("shard down") } From 70a9394c8f042b76f650123c286f6128ce57142f Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 19:08:52 +0900 Subject: [PATCH 090/109] fix(sharding): report target shard service endpoints Reshard target Pods now receive the actual StatefulSet service name as POSTGRES_SERVICE_NAME and the instance manager uses it when reporting Pod status endpoints. Existing Pods without the env var keep the legacy ordinal fallback for upgrade compatibility. This closes a Promote P-B precondition: target Pods must not publish source ordinal headless DNS before status/router/failover starts resolving promoted named shards. Verification: - go test -count=1 ./cmd/instance - go test -count=1 ./internal/controller -run TestBuildTargetShardStatefulSet_Isolation - go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./internal/controller --- cmd/instance/main.go | 17 ++++++----- cmd/instance/main_test.go | 8 +++++ docs/WORK_HANDOFF.ko.md | 11 +++++-- ...rd-target-promotion-identity-transition.md | 11 +++++++ .../plans/2026-06-28-reshard-hardening.md | 30 +++++++++++++++++-- internal/controller/builders.go | 4 ++- internal/controller/builders_test.go | 3 ++ 7 files changed, 71 insertions(+), 13 deletions(-) diff --git a/cmd/instance/main.go b/cmd/instance/main.go index 9b2ea5f..ec30560 100644 --- a/cmd/instance/main.go +++ b/cmd/instance/main.go @@ -157,7 +157,11 @@ func main() { dataDir := envOrDie("POSTGRES_DATA_DIR") binDir := envOrDie("POSTGRES_BIN_DIR") primaryEndpoint := os.Getenv("PRIMARY_ENDPOINT") - endpoint := instanceEndpoint(podName, cluster, int32(shardOrdinal), namespace) + serviceName := os.Getenv("POSTGRES_SERVICE_NAME") + if serviceName == "" { + serviceName = fmt.Sprintf("%s-shard-%d-headless", cluster, shardOrdinal) + } + endpoint := instanceEndpoint(podName, serviceName, namespace) restartedPrimaryAsStandby, err := prepareRestartedPrimaryAsStandby( dataDir, primaryEndpoint, binDir, podName, memberCount, logger, ) @@ -297,7 +301,7 @@ func main() { }() logger.Info("Election started", "identity", elect.Identity(), "lease", leaseName) - startStatusReporterIfPossible(ctx, clientset, namespace, podName, cluster, int32(shardOrdinal), dataDir, elect, sup, logger) + startStatusReporterIfPossible(ctx, clientset, namespace, podName, endpoint, dataDir, elect, sup, logger) select { case <-ctx.Done(): @@ -519,9 +523,8 @@ func patchRejoinFailureStatus( return patchPodAnnotation(ctx, clientset, namespace, podName, st) } -func instanceEndpoint(podName, cluster string, shardOrdinal int32, namespace string) string { - return fmt.Sprintf("%s.%s-shard-%d-headless.%s.svc.cluster.local:5432", - podName, cluster, shardOrdinal, namespace) +func instanceEndpoint(podName, serviceName, namespace string) string { + return fmt.Sprintf("%s.%s.%s.svc.cluster.local:5432", podName, serviceName, namespace) } func delayElectionForRestartedPrimary(ctx context.Context, delay time.Duration, podName string, logger *slog.Logger) { @@ -542,8 +545,7 @@ func delayElectionForRestartedPrimary(ctx context.Context, delay time.Duration, func startStatusReporterIfPossible( ctx context.Context, clientset kubernetes.Interface, - namespace, podName, cluster string, - shardOrdinal int32, + namespace, podName, endpoint string, dataDir string, elect election.Election, sup supervise.Supervisor, @@ -552,7 +554,6 @@ func startStatusReporterIfPossible( if clientset == nil { return } - endpoint := instanceEndpoint(podName, cluster, shardOrdinal, namespace) go runStatusReporter(ctx, clientset, namespace, podName, endpoint, dataDir, elect, sup, logger) } diff --git a/cmd/instance/main_test.go b/cmd/instance/main_test.go index 719f5d4..e855b70 100644 --- a/cmd/instance/main_test.go +++ b/cmd/instance/main_test.go @@ -40,6 +40,14 @@ func TestParsePodOrdinalOrDie_StatefulSetName(t *testing.T) { } } +func TestInstanceEndpoint_UsesProvidedServiceName(t *testing.T) { + got := instanceEndpoint("orders-rsd-t1-0", "orders-rsd-t1-headless", "ns1") + want := "orders-rsd-t1-0.orders-rsd-t1-headless.ns1.svc.cluster.local:5432" + if got != want { + t.Fatalf("endpoint = %q, want %q", got, want) + } +} + func TestPrepareRestartedPrimaryAsStandby_UsesCurrentPrimaryEndpointForAnyOrdinal(t *testing.T) { dir := t.TempDir() marker := filepath.Join(dir, supervise.RestartPrimaryAsStandbyMarker) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index 6310622..31a26d2 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -334,6 +334,11 @@ kubectl -n postgres-operator-system set env deploy/postgres-operator-controller- - Batch 2 P-A.2 개발 완료: `aggregateShardStatus` 가 legacy `postgres.keiailab.io/shard=` 와 additive `postgres.keiailab.io/shard-id=shard-` 를 OR 필터링한다. StatefulSet/Service selector 는 변경하지 않았다. metrics/failover 는 status 소비자라 aggregation 변경을 따라간다. +- Batch 2.5 개발 완료: reshard target Pod 가 status endpoint 를 source ordinal service 로 잘못 보고하지 + 않도록 컨트롤러가 실제 StatefulSet service name 을 `POSTGRES_SERVICE_NAME` 으로 주입하고, + instance manager 가 그 값을 endpoint 조립에 사용한다. env var 가 없는 기존 Pod 는 기존 ordinal + service naming 으로 fallback 한다. 이 작업은 Promote P-B 전제 조건이며, target status/backend + resolution 의 DNS 오염을 막는다. - Batch 3 설계 문서 완료: native router concurrent-write e2e 시나리오를 `docs/sharding/ROUTER-GAP-ANALYSIS.ko.md` 에 기록했다. write stream 중 online CDC, write-block `ReadyForQuery`, routing update, checksum/key ownership, PK 없는 target UPDATE/DELETE, abort cleanup 을 @@ -345,8 +350,10 @@ kubectl -n postgres-operator-system set env deploy/postgres-operator-controller- 시도한다. source 접속 불가 상황에서는 cleanup Job 이 실패하고 `AbortCleanup=False` 로 남는 것이 현재 의도한 안전 동작이다. source-down 상태에서도 target subscription 만 강제 제거하는 fallback 은 live drill 결과를 보고 별도 보강한다. -- 검증 상태: Docker Desktop / WSL / VM 은 종료 유지. Windows host 에 `go`/`gofmt` 가 없어 - `go test`/`gofmt` 는 아직 미실행. 현재 확인된 것은 `git diff --check` 통과뿐이다. +- 검증 상태: Docker Desktop / WSL / VM 은 종료 유지. Windows Go 1.26.4 로 다음 focused gate 를 통과했다: + `go test -count=1 ./cmd/instance`, + `go test -count=1 ./internal/controller -run TestBuildTargetShardStatefulSet_Isolation`, + `go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./internal/controller`. --- diff --git a/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md b/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md index 4d4199f..d19dc62 100644 --- a/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md +++ b/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md @@ -7,6 +7,17 @@ ## P-A.2 selector audit / implementation note (2026-06-28) +### P-A.2.1 status endpoint precondition (2026-06-28) + +instance manager 는 이제 `POSTGRES_SERVICE_NAME` 으로 전달된 실제 StatefulSet service name 으로 +Pod status endpoint 를 보고한다. ordinal shard 는 기존과 동일하게 `-shard--headless` +를 받고, reshard target 은 `-rsd--headless` 를 받는다. env var 가 없는 기존 Pod 는 +기존 ordinal service naming 으로 fallback 한다. + +이 변경은 Promote P-B 자체는 아니지만 P-B 의 전제 조건이다. target Pod 가 source ordinal service +endpoint 를 status 로 내보내면, router/status/failover 가 승격 대상 shard 를 잘못된 DNS 로 해석할 수 +있기 때문이다. named target shard row 생성, source decommission, target HA 확대는 여전히 P-B/P-C 범위다. + 이번 hardening batch 에서 selector 사용처를 다음처럼 분리했다. - **그대로 둔 것**: `ShardStatefulSetName`, `ShardServiceName`, PDB/TLS/PVC resize, source shard DNS, diff --git a/docs/superpowers/plans/2026-06-28-reshard-hardening.md b/docs/superpowers/plans/2026-06-28-reshard-hardening.md index 8b91b50..95b8bb3 100644 --- a/docs/superpowers/plans/2026-06-28-reshard-hardening.md +++ b/docs/superpowers/plans/2026-06-28-reshard-hardening.md @@ -112,7 +112,7 @@ Expected: controller tests cover cdc-setup failure, cdc-finalize failure, and ab - Define idempotency markers. - Define live chaos drill requirements. -Status as of 2026-06-28: selector audit is documented in ADR-0029 P-A.2. `aggregateShardStatus` now lists by cluster-level labels and filters pods by legacy `postgres.keiailab.io/shard=` OR additive `postgres.keiailab.io/shard-id=shard-`, without changing StatefulSet or Service selectors. Metrics/failover remain status consumers. Named target shard rows such as `shard-id=t1` are intentionally left for Promote P-B/P-C. The checkpoint command has not been run because the host Go toolchain is unavailable. +Status as of 2026-06-28: selector audit is documented in ADR-0029 P-A.2. `aggregateShardStatus` now lists by cluster-level labels and filters pods by legacy `postgres.keiailab.io/shard=` OR additive `postgres.keiailab.io/shard-id=shard-`, without changing StatefulSet or Service selectors. Metrics/failover remain status consumers. Named target shard rows such as `shard-id=t1` are intentionally left for Promote P-B/P-C. **Checkpoint Verification:** @@ -124,6 +124,32 @@ go test -count=1 ./internal/controller Expected: existing ordinal clusters still aggregate status, and target shards with `shard-id` can be observed without selector mutation. +## Batch 2.5: Reshard Target Service Endpoint + +**Files:** +- Modify: `internal/controller/builders.go` +- Modify: `internal/controller/builders_test.go` +- Modify: `cmd/instance/main.go` +- Modify: `cmd/instance/main_test.go` + +- [x] Pass the actual StatefulSet `serviceName` to the instance manager as `POSTGRES_SERVICE_NAME`. + - Ordinal shards receive `-shard--headless`. + - Reshard targets receive `-rsd--headless`. + +- [x] Build status endpoints from the provided service name. + - Keep the legacy ordinal fallback when the env var is absent so already-running Pods remain compatible during upgrades. + - Prevent reshard target Pods from reporting `*.shard-0-headless` endpoints before Promote P-B/P-C. + +**Checkpoint Verification:** + +```powershell +go test -count=1 ./cmd/instance +go test -count=1 ./internal/controller -run TestBuildTargetShardStatefulSet_Isolation +go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./internal/controller +``` + +Status as of 2026-06-28: all three checkpoint commands pass on Windows Go 1.26.4. Docker Desktop and WSL remain stopped. + ## Batch 3: Native Router Concurrent-Write E2E Design **Files:** @@ -152,7 +178,7 @@ Status as of 2026-06-28: the scenario is documented in `docs/sharding/ROUTER-GAP Run after all selected batches are complete: ```powershell -go test -count=1 ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./internal/controller +go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./internal/controller make test-integration ``` diff --git a/internal/controller/builders.go b/internal/controller/builders.go index a253df4..01de6f4 100644 --- a/internal/controller/builders.go +++ b/internal/controller/builders.go @@ -955,6 +955,7 @@ fi // downward API + spec 매개변수 + current primary endpoint + 고정 경로의 합산. func buildInstanceEnv( clusterName string, + serviceName string, shardOrdinal int32, pgMajor string, members int32, @@ -983,6 +984,7 @@ func buildInstanceEnv( }, // spec 매개변수 — election lease 명명 + role 분기. {Name: "POSTGRES_CLUSTER", Value: clusterName}, + {Name: "POSTGRES_SERVICE_NAME", Value: serviceName}, {Name: "POSTGRES_ROLE", Value: "shard"}, {Name: "POSTGRES_SHARD_ORDINAL", Value: fmt.Sprintf("%d", shardOrdinal)}, {Name: "POSTGRES_MEMBER_COUNT", Value: fmt.Sprintf("%d", members)}, @@ -1076,7 +1078,7 @@ func buildPGStatefulSet( // instance manager 환경 변수. reshard target 이면 POSTGRES_RESHARD_TARGET 를 // 추가 주입 → cmd/instance 가 ordinal lease (PrimaryLeaseName) 대신 격리된 // ReshardTargetLeaseName 을 사용해 실 shard election 침범을 차단한다 (ADR-0027). - instanceEnv := buildInstanceEnv(cluster.Name, shardOrdinal, pgMajor, members, primaryEndpoint, replicaClusterEnabled) + instanceEnv := buildInstanceEnv(cluster.Name, serviceName, shardOrdinal, pgMajor, members, primaryEndpoint, replicaClusterEnabled) if reshardTargetID != "" { instanceEnv = append(instanceEnv, corev1.EnvVar{Name: "POSTGRES_RESHARD_TARGET", Value: reshardTargetID}) } diff --git a/internal/controller/builders_test.go b/internal/controller/builders_test.go index c3fdf25..0b8f1b2 100644 --- a/internal/controller/builders_test.go +++ b/internal/controller/builders_test.go @@ -988,6 +988,9 @@ func TestBuildTargetShardStatefulSet_Isolation(t *testing.T) { if envByName["PRIMARY_ENDPOINT"] != "" { t.Errorf("PRIMARY_ENDPOINT = %q, want \"\" (빈 값 → pod-0 initdb fresh primary)", envByName["PRIMARY_ENDPOINT"]) } + if got := envByName["POSTGRES_SERVICE_NAME"]; got != "orders-rsd-shard-0a-headless" { + t.Errorf("POSTGRES_SERVICE_NAME = %q, want target headless service", got) + } // (4) POSTGRES_RESHARD_TARGET env → cmd/instance 가 ReshardTargetLeaseName 사용. if got := envByName["POSTGRES_RESHARD_TARGET"]; got != "shard-0a" { From 43fa8f79b3e0ca06b0aa0b09bb507ba2b386c0ae Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Sun, 28 Jun 2026 19:20:31 +0900 Subject: [PATCH 091/109] feat(sharding): surface active named target shards PostgresCluster status now includes active non-ordinal shard names from ShardRange.spec.ranges. Reshard target Pods are aggregated by reshard-target or adopted shard-id labels and exposed as name=, ordinal=-1 so the status backend resolver can route to targets after RoutingUpdate. This is a P-B status/backend-resolution slice only. Source decommission, target HA scale-up, Promote idempotency, and named shard spec migration remain follow-up work. Verification: - go test -count=1 ./internal/controller -run TestAggregateNamedShardStatus_UsesReshardTargetLabel - go test -count=1 ./internal/controller --ginkgo.focus="adds active named reshard targets" - go test -count=1 ./internal/controller - go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./internal/controller --- docs/WORK_HANDOFF.ko.md | 14 ++- ...rd-target-promotion-identity-transition.md | 12 +++ .../plans/2026-06-28-reshard-hardening.md | 27 +++++ internal/controller/aggregate_status.go | 99 ++++++++++++++++++- internal/controller/aggregate_status_test.go | 29 ++++++ .../controller/postgrescluster_controller.go | 12 +++ .../postgrescluster_controller_test.go | 76 ++++++++++++++ 7 files changed, 263 insertions(+), 6 deletions(-) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index 31a26d2..197238c 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -339,13 +339,18 @@ kubectl -n postgres-operator-system set env deploy/postgres-operator-controller- instance manager 가 그 값을 endpoint 조립에 사용한다. env var 가 없는 기존 Pod 는 기존 ordinal service naming 으로 fallback 한다. 이 작업은 Promote P-B 전제 조건이며, target status/backend resolution 의 DNS 오염을 막는다. +- Batch 2.6 개발 완료: active `ShardRange.spec.ranges[].shard` 중 non-ordinal 이름을 + `PostgresCluster.status.shards[]` 에 추가한다. reshard target 은 `reshard-target=` 또는 + adopt 이후 `shard-id=` label 로 집계되며, 예: `name=t1`, `ordinal=-1`. 이로써 routing flip 이후 + router status backend 가 target primary endpoint 를 해석할 수 있다. - Batch 3 설계 문서 완료: native router concurrent-write e2e 시나리오를 `docs/sharding/ROUTER-GAP-ANALYSIS.ko.md` 에 기록했다. write stream 중 online CDC, write-block `ReadyForQuery`, routing update, checksum/key ownership, PK 없는 target UPDATE/DELETE, abort cleanup 을 한 live gate 로 검증한다. -- 아직 남은 범위: `shard-id=t1` 같은 named target shard 를 status.shards 새 row 로 생성하는 작업은 - Promote P-B/P-C 범위다. source fence/관측 제외 전에 target 에 동일 `shard-id` 를 붙이면 split-brain - 신호가 될 수 있으므로 순서가 중요하다. +- 아직 남은 범위: source ordinal resource decommission, target replica scale-up/HA, Promote phase 의 + fence/adopt/source cleanup idempotency, 그리고 spec shard model 의 named-list 전환은 P-B/P-C 범위다. + source fence/관측 제외 전에 target 에 동일 `shard-id` 를 붙이면 split-brain 신호가 될 수 있으므로 + 순서가 중요하다. - 라이브 검증 전 주의: `cdc-abort` 는 `DROP SUBSCRIPTION IF EXISTS` 로 원격 replication slot 정리까지 시도한다. source 접속 불가 상황에서는 cleanup Job 이 실패하고 `AbortCleanup=False` 로 남는 것이 현재 의도한 안전 동작이다. source-down 상태에서도 target subscription 만 강제 제거하는 fallback 은 live drill @@ -353,6 +358,9 @@ kubectl -n postgres-operator-system set env deploy/postgres-operator-controller- - 검증 상태: Docker Desktop / WSL / VM 은 종료 유지. Windows Go 1.26.4 로 다음 focused gate 를 통과했다: `go test -count=1 ./cmd/instance`, `go test -count=1 ./internal/controller -run TestBuildTargetShardStatefulSet_Isolation`, + `go test -count=1 ./internal/controller -run TestAggregateNamedShardStatus_UsesReshardTargetLabel`, + `go test -count=1 ./internal/controller --ginkgo.focus="adds active named reshard targets"`, + `go test -count=1 ./internal/controller`, `go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./internal/controller`. --- diff --git a/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md b/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md index d19dc62..2440958 100644 --- a/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md +++ b/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md @@ -18,6 +18,18 @@ Pod status endpoint 를 보고한다. ordinal shard 는 기존과 동일하게 ` endpoint 를 status 로 내보내면, router/status/failover 가 승격 대상 shard 를 잘못된 DNS 로 해석할 수 있기 때문이다. named target shard row 생성, source decommission, target HA 확대는 여전히 P-B/P-C 범위다. +### P-B.1 active named target status (2026-06-28) + +`PostgresClusterReconciler` 는 이제 active `ShardRange.spec.ranges[].shard` 중 ordinal shard 가 아닌 +이름을 `PostgresCluster.status.shards[]` 에 추가한다. 예를 들어 ShardRange 가 `t1` 을 가리키면 +status row 는 `name=t1`, `ordinal=-1` 로 기록된다. target Pod 선택은 +`postgres.keiailab.io/reshard-target=` 와, 향후 adopt 후의 +`postgres.keiailab.io/shard-id=` 를 모두 허용한다. + +이로써 routing flip 이후 `StatusBackendResolver` 가 target shard primary endpoint 를 해석할 수 있다. +단, 이것은 lifecycle 승격 전체가 아니다. source ordinal resource decommission, target replica scale-up/HA, +spec shard model 의 named-list 전환, Promote phase idempotency 는 아직 P-B/P-C 잔여 범위다. + 이번 hardening batch 에서 selector 사용처를 다음처럼 분리했다. - **그대로 둔 것**: `ShardStatefulSetName`, `ShardServiceName`, PDB/TLS/PVC resize, source shard DNS, diff --git a/docs/superpowers/plans/2026-06-28-reshard-hardening.md b/docs/superpowers/plans/2026-06-28-reshard-hardening.md index 95b8bb3..ccb4939 100644 --- a/docs/superpowers/plans/2026-06-28-reshard-hardening.md +++ b/docs/superpowers/plans/2026-06-28-reshard-hardening.md @@ -150,6 +150,33 @@ go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard- Status as of 2026-06-28: all three checkpoint commands pass on Windows Go 1.26.4. Docker Desktop and WSL remain stopped. +## Batch 2.6: Active Named Target Status + +**Files:** +- Modify: `internal/controller/aggregate_status.go` +- Modify: `internal/controller/aggregate_status_test.go` +- Modify: `internal/controller/postgrescluster_controller.go` +- Modify: `internal/controller/postgrescluster_controller_test.go` + +- [x] Add `PostgresCluster.status.shards[]` rows for active non-ordinal shard names found in `ShardRange.spec.ranges`. + - Ordinal names such as `shard-0` remain handled by the existing ordinal loop. + - Non-ordinal targets such as `t1` are appended with `name=t1` and `ordinal=-1`. + +- [x] Aggregate target Pods by `postgres.keiailab.io/reshard-target=` or adopted `postgres.keiailab.io/shard-id=`. + - Reuse the same annotation, stale heartbeat, fenced PVC, rogue-primary, and PodReady logic as ordinal shards. + - Mark overall shard readiness false when an active named target has no Ready primary. + +**Checkpoint Verification:** + +```powershell +go test -count=1 ./internal/controller -run TestAggregateNamedShardStatus_UsesReshardTargetLabel +go test -count=1 ./internal/controller --ginkgo.focus="adds active named reshard targets" +go test -count=1 ./internal/controller +go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./internal/controller +``` + +Status as of 2026-06-28: all checkpoint commands pass on Windows Go 1.26.4. This is a P-B status/backend-resolution slice only. Source decommission, target replica scale-up/HA, and named shard spec-model migration remain open. + ## Batch 3: Native Router Concurrent-Write E2E Design **Files:** diff --git a/internal/controller/aggregate_status.go b/internal/controller/aggregate_status.go index 9d4ab47..a4fb3ce 100644 --- a/internal/controller/aggregate_status.go +++ b/internal/controller/aggregate_status.go @@ -10,6 +10,7 @@ import ( "context" "encoding/json" "fmt" + "sort" "time" corev1 "k8s.io/api/core/v1" @@ -66,10 +67,36 @@ func aggregateShardStatus( svcName string, ) postgresv1alpha1.ShardStatus { shardID := ShardIDForOrdinal(ord) + return aggregateShardStatusMatching(ctx, c, cluster, shardID, ord, svcName, func(pod *corev1.Pod) bool { + return podMatchesShardIdentity(pod, ord) + }) +} + +func aggregateNamedShardStatus( + ctx context.Context, + c client.Client, + cluster *postgresv1alpha1.PostgresCluster, + shardID string, + svcName string, +) postgresv1alpha1.ShardStatus { + return aggregateShardStatusMatching(ctx, c, cluster, shardID, -1, svcName, func(pod *corev1.Pod) bool { + return podMatchesNamedShardIdentity(pod, shardID) + }) +} + +func aggregateShardStatusMatching( + ctx context.Context, + c client.Client, + cluster *postgresv1alpha1.PostgresCluster, + shardID string, + ordinal int32, + svcName string, + matches func(*corev1.Pod) bool, +) postgresv1alpha1.ShardStatus { logger := log.FromContext(ctx).WithValues("shard", shardID) out := postgresv1alpha1.ShardStatus{ Name: shardID, - Ordinal: ord, + Ordinal: ordinal, } sel := labels.SelectorFromSet(statusAggregationSelectorLabels(cluster.Name)) @@ -108,7 +135,7 @@ func aggregateShardStatus( // reseed. This protects the real (data-holding) primary from ever being reseeded. hasPromotedPrimary := false for i := range pods.Items { - if !podMatchesShardIdentity(&pods.Items[i], ord) { + if !matches(&pods.Items[i]) { continue } if st, ok := parsePodStatus(&pods.Items[i]); ok && @@ -125,7 +152,7 @@ func aggregateShardStatus( for i := range pods.Items { pod := &pods.Items[i] - if !podMatchesShardIdentity(pod, ord) { + if !matches(pod) { continue } st, ok := parsePodStatus(pod) @@ -210,6 +237,65 @@ func aggregateShardStatus( return out } +func activeNamedShardStatuses( + ctx context.Context, + c client.Client, + cluster *postgresv1alpha1.PostgresCluster, +) ([]postgresv1alpha1.ShardStatus, bool, error) { + if cluster == nil { + return nil, true, nil + } + var ranges postgresv1alpha1.ShardRangeList + if err := c.List(ctx, &ranges, client.InNamespace(cluster.Namespace)); err != nil { + return nil, false, fmt.Errorf("list ShardRange for named shard status: %w", err) + } + seen := map[string]struct{}{} + for i := range ranges.Items { + sr := &ranges.Items[i] + if sr.Spec.Cluster != cluster.Name { + continue + } + for j := range sr.Spec.Ranges { + shardID := sr.Spec.Ranges[j].Shard + if shardID == "" || isOrdinalShardID(cluster, shardID) { + continue + } + seen[shardID] = struct{}{} + } + } + if len(seen) == 0 { + return nil, true, nil + } + ids := make([]string, 0, len(seen)) + for id := range seen { + ids = append(ids, id) + } + sort.Strings(ids) + + statuses := make([]postgresv1alpha1.ShardStatus, 0, len(ids)) + allReady := true + for _, shardID := range ids { + status := aggregateNamedShardStatus(ctx, c, cluster, shardID, TargetShardServiceName(cluster.Name, shardID)) + if status.Primary == nil || !status.Primary.Ready { + allReady = false + } + statuses = append(statuses, status) + } + return statuses, allReady, nil +} + +func isOrdinalShardID(cluster *postgresv1alpha1.PostgresCluster, shardID string) bool { + if cluster == nil { + return false + } + for ord := int32(0); ord < cluster.Spec.Shards.InitialCount; ord++ { + if shardID == ShardIDForOrdinal(ord) { + return true + } + } + return false +} + func statusAggregationSelectorLabels(cluster string) labels.Set { out := labels.Set(SelectorLabels(cluster, "shard", -1)) delete(out, "app.kubernetes.io/component") @@ -226,6 +312,13 @@ func podMatchesShardIdentity(pod *corev1.Pod, ord int32) bool { return pod.Labels[ShardIDLabelKey] == ShardIDForOrdinal(ord) } +func podMatchesNamedShardIdentity(pod *corev1.Pod, shardID string) bool { + if pod == nil || shardID == "" { + return false + } + return pod.Labels[ShardIDLabelKey] == shardID || pod.Labels[ReshardTargetLabelKey] == shardID +} + func kubernetesPodNotReady(pod *corev1.Pod) bool { if pod == nil { return true diff --git a/internal/controller/aggregate_status_test.go b/internal/controller/aggregate_status_test.go index 81805d8..893336b 100644 --- a/internal/controller/aggregate_status_test.go +++ b/internal/controller/aggregate_status_test.go @@ -120,6 +120,35 @@ func TestAggregateShardStatus_UsesShardIDLabelWhenOrdinalLabelMissing(t *testing } } +func TestAggregateNamedShardStatus_UsesReshardTargetLabel(t *testing.T) { + t.Parallel() + now := time.Now().UTC() + labels := ReshardTargetSelectorLabels("demo", "t1") + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "demo-rsd-t1-0", + Namespace: "default", + Labels: labels, + }, + } + raw, _ := json.Marshal(statusapi.Status{ + Role: statusapi.RolePrimary, + Ready: true, + Endpoint: "demo-rsd-t1-0.demo-rsd-t1-headless.default.svc.cluster.local:5432", + LastUpdate: now, + }) + pod.Annotations = map[string]string{statusapi.AnnotationKey: string(raw)} + c := fake.NewClientBuilder().WithScheme(newScheme(t)).WithObjects(pod).Build() + + out := aggregateNamedShardStatus(context.Background(), c, newCluster(), "t1", "demo-rsd-t1-headless") + if out.Name != "t1" || out.Ordinal != -1 { + t.Fatalf("shard identity = name %q ordinal %d, want t1/-1", out.Name, out.Ordinal) + } + if out.Primary == nil || out.Primary.Pod != "demo-rsd-t1-0" || !out.Primary.Ready { + t.Fatalf("target primary not aggregated: %+v", out.Primary) + } +} + func TestAggregateShardStatus_PropagatesInstanceReasonAndMessage(t *testing.T) { t.Parallel() now := time.Now().UTC() diff --git a/internal/controller/postgrescluster_controller.go b/internal/controller/postgrescluster_controller.go index 907749b..e470ec5 100644 --- a/internal/controller/postgrescluster_controller.go +++ b/internal/controller/postgrescluster_controller.go @@ -328,6 +328,18 @@ func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Requ shardStatuses = append(shardStatuses, shardStat) } + if !databasePodsStopped { + namedShardStatuses, namedReady, err := activeNamedShardStatuses(ctx, r.Client, &cluster) + if err != nil { + logger.Error(err, "Failed to aggregate active named shard status") + return ctrl.Result{}, err + } + if !namedReady { + allShardPrimaryReady = false + } + shardStatuses = append(shardStatuses, namedShardStatuses...) + } + // 2. router 자원 3종 — shardingMode=native && Router.Enabled 일 때만. routerActive := cluster.Spec.ShardingMode == postgresv1alpha1.ShardingModeNative && cluster.Spec.Router != nil && cluster.Spec.Router.Enabled diff --git a/internal/controller/postgrescluster_controller_test.go b/internal/controller/postgrescluster_controller_test.go index 114afe0..3701860 100644 --- a/internal/controller/postgrescluster_controller_test.go +++ b/internal/controller/postgrescluster_controller_test.go @@ -8,6 +8,7 @@ package controller import ( "context" + "encoding/json" "fmt" "time" @@ -24,6 +25,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" postgresv1alpha1 "github.com/keiailab/postgres-operator/api/v1alpha1" + "github.com/keiailab/postgres-operator/internal/instance/statusapi" ) // 본 envtest 는 RFC 0001 PostgresCluster CRD v2 위에서의 reconcile 를 검증한다. @@ -206,6 +208,80 @@ var _ = Describe("PostgresClusterReconciler — RFC 0001 spec", func() { g.Expect(svc.Spec.ClusterIP).NotTo(Equal(corev1.ClusterIPNone)) }, envtestTimeout, envtestInterval).Should(Succeed()) }) + + It("adds active named reshard targets to cluster shard status", func() { + clusterName := "named-status" + keyspace := "default" + targetShard := "t1" + targetPod := TargetShardStatefulSetName(clusterName, targetShard) + "-0" + targetService := TargetShardServiceName(clusterName, targetShard) + targetEndpoint := fmt.Sprintf("%s.%s.%s.svc.cluster.local:5432", targetPod, targetService, namespace) + + raw, err := json.Marshal(statusapi.Status{ + Role: statusapi.RolePrimary, + Ready: true, + Endpoint: targetEndpoint, + LastUpdate: time.Now().UTC(), + }) + Expect(err).NotTo(HaveOccurred()) + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: targetPod, + Namespace: namespace, + Labels: ReshardTargetSelectorLabels(clusterName, targetShard), + Annotations: map[string]string{statusapi.AnnotationKey: string(raw)}, + }, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: pgContainerName, Image: "postgres:18"}}}, + } + Expect(k8sClient.Create(ctx, pod)).To(Succeed()) + pod.Status.Phase = corev1.PodRunning + pod.Status.Conditions = []corev1.PodCondition{{Type: corev1.PodReady, Status: corev1.ConditionTrue}} + pod.Status.ContainerStatuses = []corev1.ContainerStatus{{Name: pgContainerName, Ready: true, Image: "postgres:18", ImageID: "postgres:18"}} + Expect(k8sClient.Status().Update(ctx, pod)).To(Succeed()) + Expect(k8sClient.Create(ctx, &postgresv1alpha1.ShardRange{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-sr", Namespace: namespace}, + Spec: postgresv1alpha1.ShardRangeSpec{ + Cluster: clusterName, + Keyspace: keyspace, + Vindex: postgresv1alpha1.VindexSpec{Type: postgresv1alpha1.VindexTypeHash, Column: "id", Function: "murmur3"}, + Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0xffffffff", Shard: targetShard}}, + }, + })).To(Succeed()) + + cluster := &postgresv1alpha1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName, Namespace: namespace}, + Spec: postgresv1alpha1.PostgresClusterSpec{ + PostgresVersion: "18", + ShardingMode: postgresv1alpha1.ShardingModeNative, + Shards: postgresv1alpha1.ShardsSpec{ + InitialCount: 1, + Replicas: 0, + Storage: postgresv1alpha1.StorageSpec{ + Size: resource.MustParse("1Gi"), + }, + }, + }, + } + Expect(k8sClient.Create(ctx, cluster)).To(Succeed()) + + Eventually(func(g Gomega) { + var got postgresv1alpha1.PostgresCluster + g.Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: clusterName}, &got)).To(Succeed()) + var named *postgresv1alpha1.ShardStatus + for i := range got.Status.Shards { + if got.Status.Shards[i].Name == targetShard { + named = &got.Status.Shards[i] + break + } + } + g.Expect(named).NotTo(BeNil(), "active ShardRange target must appear in status.shards") + g.Expect(named.Ordinal).To(Equal(int32(-1))) + g.Expect(named.Primary).NotTo(BeNil()) + g.Expect(named.Primary.Pod).To(Equal(targetPod)) + g.Expect(named.Primary.Endpoint).To(Equal(targetEndpoint)) + g.Expect(named.Primary.Ready).To(BeTrue()) + }, envtestTimeout, envtestInterval).Should(Succeed()) + }) }) Context("when cnpg-compatible hibernation annotation is enabled", func() { From cd1833f8b7efa78ae78404d2285398089049d497 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Mon, 29 Jun 2026 01:07:38 +0900 Subject: [PATCH 092/109] feat(sharding): decommission inactive ordinal sources Native clusters now treat ShardRange.spec.ranges as the active shard topology once ShardRange exists. Ordinal source shards that are no longer active are scaled to zero and excluded from PostgresCluster.status.shards, while StatefulSet/PVC ownership is retained. This lets a ready named target shard drive cluster readiness after routing flip instead of being held back by a stale shard-0 fallback status row. Target HA scale-up, explicit Promote phase idempotency, and named shard spec migration remain follow-up work. Verification: - go test -count=1 ./internal/controller --ginkgo.focus="adds active named reshard targets" - go test -count=1 ./internal/controller - go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./internal/controller --- docs/WORK_HANDOFF.ko.md | 10 +++- ...rd-target-promotion-identity-transition.md | 13 ++++ .../plans/2026-06-28-reshard-hardening.md | 29 +++++++++ internal/controller/aggregate_status.go | 60 ++++++++++++------- .../controller/postgrescluster_controller.go | 28 +++++++-- .../postgrescluster_controller_test.go | 14 ++++- 6 files changed, 125 insertions(+), 29 deletions(-) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index 197238c..3dd0a50 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -343,13 +343,17 @@ kubectl -n postgres-operator-system set env deploy/postgres-operator-controller- `PostgresCluster.status.shards[]` 에 추가한다. reshard target 은 `reshard-target=` 또는 adopt 이후 `shard-id=` label 로 집계되며, 예: `name=t1`, `ordinal=-1`. 이로써 routing flip 이후 router status backend 가 target primary endpoint 를 해석할 수 있다. +- Batch 2.7 개발 완료: native cluster 에 ShardRange 가 존재하면 그 ranges 의 union 을 active topology 로 + 보고, active set 에서 빠진 ordinal source STS 는 replicas=0 으로 낮추며 status.shards 에서 제외한다. + target 이 Ready 일 때 stale source fallback row 때문에 cluster 가 Provisioning/Degraded 에 묶이는 + 문제를 해소했다. StatefulSet/PVC 삭제는 하지 않는다. - Batch 3 설계 문서 완료: native router concurrent-write e2e 시나리오를 `docs/sharding/ROUTER-GAP-ANALYSIS.ko.md` 에 기록했다. write stream 중 online CDC, write-block `ReadyForQuery`, routing update, checksum/key ownership, PK 없는 target UPDATE/DELETE, abort cleanup 을 한 live gate 로 검증한다. -- 아직 남은 범위: source ordinal resource decommission, target replica scale-up/HA, Promote phase 의 - fence/adopt/source cleanup idempotency, 그리고 spec shard model 의 named-list 전환은 P-B/P-C 범위다. - source fence/관측 제외 전에 target 에 동일 `shard-id` 를 붙이면 split-brain 신호가 될 수 있으므로 +- 아직 남은 범위: target replica scale-up/HA, 명시적 ShardSplitJob Promote phase 의 fence/adopt/source + cleanup idempotency, source PDB/resource 삭제 정책, 그리고 spec shard model 의 named-list 전환은 P-B/P-C + 범위다. source fence/관측 제외 전에 target 에 동일 `shard-id` 를 붙이면 split-brain 신호가 될 수 있으므로 순서가 중요하다. - 라이브 검증 전 주의: `cdc-abort` 는 `DROP SUBSCRIPTION IF EXISTS` 로 원격 replication slot 정리까지 시도한다. source 접속 불가 상황에서는 cleanup Job 이 실패하고 `AbortCleanup=False` 로 남는 것이 현재 diff --git a/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md b/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md index 2440958..0f3e4a5 100644 --- a/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md +++ b/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md @@ -30,6 +30,19 @@ status row 는 `name=t1`, `ordinal=-1` 로 기록된다. target Pod 선택은 단, 이것은 lifecycle 승격 전체가 아니다. source ordinal resource decommission, target replica scale-up/HA, spec shard model 의 named-list 전환, Promote phase idempotency 는 아직 P-B/P-C 잔여 범위다. +### P-C.1 active topology source decommission (2026-06-29) + +native cluster 에 하나 이상의 `ShardRange` 가 존재하면 `ShardRange.spec.ranges[].shard` 의 union 을 +active topology 로 본다. active topology 에서 빠진 ordinal shard 는 StatefulSet replicas 를 0 으로 +낮추고 `PostgresCluster.status.shards[]` 에서 제외한다. StatefulSet/PVC 는 삭제하지 않는다. + +이 변경으로 routing flip 후 target shard 가 Ready 인데도 기존 source `shard-0` fallback row 가 +Ready=false 로 남아 cluster 를 Provisioning/Degraded 에 묶어두는 문제를 제거한다. 조건 메시지와 +Ready event 의 shard count 도 active status row 수를 사용한다. + +아직 남은 범위는 target replica scale-up/HA, 명시적 ShardSplitJob Promote phase, source resource/PDB +정리 정책, CRD spec 의 named shard model 전환이다. + 이번 hardening batch 에서 selector 사용처를 다음처럼 분리했다. - **그대로 둔 것**: `ShardStatefulSetName`, `ShardServiceName`, PDB/TLS/PVC resize, source shard DNS, diff --git a/docs/superpowers/plans/2026-06-28-reshard-hardening.md b/docs/superpowers/plans/2026-06-28-reshard-hardening.md index ccb4939..2bd7f06 100644 --- a/docs/superpowers/plans/2026-06-28-reshard-hardening.md +++ b/docs/superpowers/plans/2026-06-28-reshard-hardening.md @@ -177,6 +177,35 @@ go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard- Status as of 2026-06-28: all checkpoint commands pass on Windows Go 1.26.4. This is a P-B status/backend-resolution slice only. Source decommission, target replica scale-up/HA, and named shard spec-model migration remain open. +## Batch 2.7: Active Topology Source Decommission + +**Files:** +- Modify: `internal/controller/aggregate_status.go` +- Modify: `internal/controller/postgrescluster_controller.go` +- Modify: `internal/controller/postgrescluster_controller_test.go` + +- [x] Treat `ShardRange.spec.ranges[].shard` as the active topology source for native clusters once at least one ShardRange exists. + - Ordinal shards still run normally when no ShardRange exists. + - Hibernation/restore keeps the existing stopped-status behavior. + +- [x] Scale inactive ordinal source StatefulSets to zero. + - This is a conservative source decommission step: retain StatefulSet/PVC ownership, stop Pods. + - Do not delete source data automatically. + +- [x] Exclude inactive ordinal shards from `PostgresCluster.status.shards`. + - Active named targets can make the cluster Ready without a stale `shard-0` fallback row forcing Provisioning/Degraded. + - Status conditions and Ready event use the active shard count, not `spec.shards.initialCount`. + +**Checkpoint Verification:** + +```powershell +go test -count=1 ./internal/controller --ginkgo.focus="adds active named reshard targets" +go test -count=1 ./internal/controller +go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./internal/controller +``` + +Status as of 2026-06-29: all checkpoint commands pass on Windows Go 1.26.4. This closes the source-observation part of P-C, but target replica scale-up/HA, explicit ShardSplitJob Promote phase, and named shard spec-model migration remain open. + ## Batch 3: Native Router Concurrent-Write E2E Design **Files:** diff --git a/internal/controller/aggregate_status.go b/internal/controller/aggregate_status.go index a4fb3ce..7572fea 100644 --- a/internal/controller/aggregate_status.go +++ b/internal/controller/aggregate_status.go @@ -242,12 +242,47 @@ func activeNamedShardStatuses( c client.Client, cluster *postgresv1alpha1.PostgresCluster, ) ([]postgresv1alpha1.ShardStatus, bool, error) { - if cluster == nil { + active, hasTopology, err := activeShardTopology(ctx, c, cluster) + if err != nil { + return nil, false, err + } + if !hasTopology { + return nil, true, nil + } + ids := make([]string, 0, len(active)) + for id := range active { + if !isOrdinalShardID(cluster, id) { + ids = append(ids, id) + } + } + if len(ids) == 0 { return nil, true, nil } + sort.Strings(ids) + + statuses := make([]postgresv1alpha1.ShardStatus, 0, len(ids)) + allReady := true + for _, shardID := range ids { + status := aggregateNamedShardStatus(ctx, c, cluster, shardID, TargetShardServiceName(cluster.Name, shardID)) + if status.Primary == nil || !status.Primary.Ready { + allReady = false + } + statuses = append(statuses, status) + } + return statuses, allReady, nil +} + +func activeShardTopology( + ctx context.Context, + c client.Client, + cluster *postgresv1alpha1.PostgresCluster, +) (map[string]struct{}, bool, error) { + if cluster == nil || cluster.Spec.ShardingMode != postgresv1alpha1.ShardingModeNative { + return nil, false, nil + } var ranges postgresv1alpha1.ShardRangeList if err := c.List(ctx, &ranges, client.InNamespace(cluster.Namespace)); err != nil { - return nil, false, fmt.Errorf("list ShardRange for named shard status: %w", err) + return nil, false, fmt.Errorf("list ShardRange for active shard topology: %w", err) } seen := map[string]struct{}{} for i := range ranges.Items { @@ -257,31 +292,16 @@ func activeNamedShardStatuses( } for j := range sr.Spec.Ranges { shardID := sr.Spec.Ranges[j].Shard - if shardID == "" || isOrdinalShardID(cluster, shardID) { + if shardID == "" { continue } seen[shardID] = struct{}{} } } if len(seen) == 0 { - return nil, true, nil - } - ids := make([]string, 0, len(seen)) - for id := range seen { - ids = append(ids, id) + return nil, false, nil } - sort.Strings(ids) - - statuses := make([]postgresv1alpha1.ShardStatus, 0, len(ids)) - allReady := true - for _, shardID := range ids { - status := aggregateNamedShardStatus(ctx, c, cluster, shardID, TargetShardServiceName(cluster.Name, shardID)) - if status.Primary == nil || !status.Primary.Ready { - allReady = false - } - statuses = append(statuses, status) - } - return statuses, allReady, nil + return seen, true, nil } func isOrdinalShardID(cluster *postgresv1alpha1.PostgresCluster, shardID string) bool { diff --git a/internal/controller/postgrescluster_controller.go b/internal/controller/postgrescluster_controller.go index e470ec5..43ae91e 100644 --- a/internal/controller/postgrescluster_controller.go +++ b/internal/controller/postgrescluster_controller.go @@ -222,13 +222,27 @@ func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Requ if databasePodsStopped { desiredMembers = 0 } + activeShardIDs, hasActiveShardTopology, err := activeShardTopology(ctx, r.Client, &cluster) + if err != nil { + logger.Error(err, "Failed to resolve active shard topology") + return ctrl.Result{}, err + } shardStatuses := make([]postgresv1alpha1.ShardStatus, 0, shardCount) allShardPrimaryReady := true for ord := range shardCount { + shardID := ShardIDForOrdinal(ord) + ordinalActive := true + if !databasePodsStopped && hasActiveShardTopology { + _, ordinalActive = activeShardIDs[shardID] + } cmName := ShardConfigMapName(cluster.Name, ord) svcName := ShardServiceName(cluster.Name, ord) stsName := ShardStatefulSetName(cluster.Name, ord) + desiredMembersForShard := desiredMembers + if !ordinalActive { + desiredMembersForShard = 0 + } cm := buildConfigMap(&cluster, cmName, "shard", ord, r.Plugins) configHash := postgresConfigHash(cm.Data) @@ -246,7 +260,7 @@ func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Requ &cluster, stsName, svcName, ord, resolvedImage.Image, cmName, resolvedImage.PostgresMajor, - desiredMembers, + desiredMembersForShard, cluster.Spec.Shards.Storage, cluster.Spec.Shards.Resources, primaryEndpoint, configHash, @@ -257,8 +271,8 @@ func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Requ } // shard PDB (PR #31): members>=2 시 자동 생성. - if !databasePodsStopped && shouldAutoCreatePDB(members) { - pdb := BuildShardPDB(&cluster, ord, members) + if !databasePodsStopped && shouldAutoCreatePDB(desiredMembersForShard) { + pdb := BuildShardPDB(&cluster, ord, desiredMembersForShard) if err := r.upsert(ctx, &cluster, pdb); err != nil { return r.handleUpsertErr(ctx, &cluster, err, "shard PDB", logger) } @@ -278,6 +292,9 @@ func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Requ } else { primaryReady = observed.Status.ReadyReplicas >= 1 } + if !ordinalActive { + continue + } if !primaryReady { allShardPrimaryReady = false } @@ -401,6 +418,7 @@ func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Requ // 3. status 종합. prevPhase := cluster.Status.Phase cluster.Status.Shards = shardStatuses + activeShardCount := int32(len(shardStatuses)) cluster.Status.Router = routerStatus managedRolesStatus, err := r.managedRolesStatus(ctx, &cluster) if err != nil { @@ -457,7 +475,7 @@ func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Requ logger.Error(err, "rogue primary re-seed failed (best-effort)") } } - applyClusterConditions(&cluster, shardCount, allShardPrimaryReady, routerActive, routerStatus, hibernating, standaloneReplica, + applyClusterConditions(&cluster, activeShardCount, allShardPrimaryReady, routerActive, routerStatus, hibernating, standaloneReplica, prevPhase == postgresv1alpha1.ClusterPhaseReady, failoverDecision) // Config hot-reload: if cluster is Ready and primary Pods are running with @@ -476,7 +494,7 @@ func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Requ // 매 reconcile noise 회피). prevPhase 비교로 transition 감지. if cluster.Status.Phase == postgresv1alpha1.ClusterPhaseReady && prevPhase != postgresv1alpha1.ClusterPhaseReady { commonsevents.Emitf(r.Recorder, &cluster, "ClusterReady", - "PostgresCluster %d/%d shards primary ready, router=%v", shardCount, shardCount, routerActive) + "PostgresCluster %d/%d shards primary ready, router=%v", activeShardCount, activeShardCount, routerActive) } if err := r.Status().Update(ctx, &cluster); err != nil { diff --git a/internal/controller/postgrescluster_controller_test.go b/internal/controller/postgrescluster_controller_test.go index 3701860..f6ee086 100644 --- a/internal/controller/postgrescluster_controller_test.go +++ b/internal/controller/postgrescluster_controller_test.go @@ -268,18 +268,30 @@ var _ = Describe("PostgresClusterReconciler — RFC 0001 spec", func() { var got postgresv1alpha1.PostgresCluster g.Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: clusterName}, &got)).To(Succeed()) var named *postgresv1alpha1.ShardStatus + var source *postgresv1alpha1.ShardStatus for i := range got.Status.Shards { if got.Status.Shards[i].Name == targetShard { named = &got.Status.Shards[i] - break + } + if got.Status.Shards[i].Name == "shard-0" { + source = &got.Status.Shards[i] } } + g.Expect(source).To(BeNil(), "inactive source shard must be excluded from active status topology") g.Expect(named).NotTo(BeNil(), "active ShardRange target must appear in status.shards") g.Expect(named.Ordinal).To(Equal(int32(-1))) g.Expect(named.Primary).NotTo(BeNil()) g.Expect(named.Primary.Pod).To(Equal(targetPod)) g.Expect(named.Primary.Endpoint).To(Equal(targetEndpoint)) g.Expect(named.Primary.Ready).To(BeTrue()) + g.Expect(got.Status.Phase).To(Equal(postgresv1alpha1.ClusterPhaseReady)) + + var sourceSTS appsv1.StatefulSet + g.Expect(k8sClient.Get(ctx, types.NamespacedName{ + Namespace: namespace, Name: ShardStatefulSetName(clusterName, 0), + }, &sourceSTS)).To(Succeed()) + g.Expect(sourceSTS.Spec.Replicas).NotTo(BeNil()) + g.Expect(*sourceSTS.Spec.Replicas).To(Equal(int32(0)), "inactive source ordinal STS must scale to zero") }, envtestTimeout, envtestInterval).Should(Succeed()) }) }) From 96ab4f893580b9b211bde8c5ec751b57941c8a95 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Mon, 29 Jun 2026 01:15:31 +0900 Subject: [PATCH 093/109] feat(sharding): scale active named targets for HA PostgresCluster reconciliation now maintains active non-ordinal target shard ConfigMaps, Services, and StatefulSets once ShardRange points at those shards. Active target StatefulSets scale to 1 + spec.shards.replicas and render POSTGRES_MEMBER_COUNT plus the target primary endpoint for replica bootstrap. Target identity isolation is preserved: selectors still use reshard-target labels and Pods keep POSTGRES_RESHARD_TARGET for the isolated election lease. Explicit Promote phase idempotency and named shard spec migration remain follow-up work. Verification: - go test -count=1 ./internal/controller --ginkgo.focus="adds active named reshard targets" - go test -count=1 ./internal/controller - go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./internal/controller --- docs/WORK_HANDOFF.ko.md | 10 ++- ...rd-target-promotion-identity-transition.md | 15 ++++ .../plans/2026-06-28-reshard-hardening.md | 30 +++++++ internal/controller/builders.go | 22 +++++- .../controller/postgrescluster_controller.go | 78 +++++++++++++++++++ .../postgrescluster_controller_test.go | 12 ++- 6 files changed, 161 insertions(+), 6 deletions(-) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index 3dd0a50..33a1731 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -347,13 +347,17 @@ kubectl -n postgres-operator-system set env deploy/postgres-operator-controller- 보고, active set 에서 빠진 ordinal source STS 는 replicas=0 으로 낮추며 status.shards 에서 제외한다. target 이 Ready 일 때 stale source fallback row 때문에 cluster 가 Provisioning/Degraded 에 묶이는 문제를 해소했다. StatefulSet/PVC 삭제는 하지 않는다. +- Batch 2.8 개발 완료: active topology 에 포함된 non-ordinal target 은 PostgresCluster reconciler 가 + ConfigMap/Service/StatefulSet 을 유지하고, target STS replicas 를 `1 + spec.shards.replicas` 로 맞춘다. + target replica 는 `POSTGRES_MEMBER_COUNT` 와 target primary `PRIMARY_ENDPOINT` 를 받아 basebackup 경로로 + 들어갈 수 있다. hibernation/restore 중에는 active target 도 replicas=0 으로 내려간다. - Batch 3 설계 문서 완료: native router concurrent-write e2e 시나리오를 `docs/sharding/ROUTER-GAP-ANALYSIS.ko.md` 에 기록했다. write stream 중 online CDC, write-block `ReadyForQuery`, routing update, checksum/key ownership, PK 없는 target UPDATE/DELETE, abort cleanup 을 한 live gate 로 검증한다. -- 아직 남은 범위: target replica scale-up/HA, 명시적 ShardSplitJob Promote phase 의 fence/adopt/source - cleanup idempotency, source PDB/resource 삭제 정책, 그리고 spec shard model 의 named-list 전환은 P-B/P-C - 범위다. source fence/관측 제외 전에 target 에 동일 `shard-id` 를 붙이면 split-brain 신호가 될 수 있으므로 +- 아직 남은 범위: 명시적 ShardSplitJob Promote phase 의 fence/adopt/source cleanup idempotency, + source PDB/resource 삭제 정책, 그리고 spec shard model 의 named-list 전환은 P-B/P-C 범위다. + source fence/관측 제외 전에 target 에 동일 `shard-id` 를 붙이면 split-brain 신호가 될 수 있으므로 순서가 중요하다. - 라이브 검증 전 주의: `cdc-abort` 는 `DROP SUBSCRIPTION IF EXISTS` 로 원격 replication slot 정리까지 시도한다. source 접속 불가 상황에서는 cleanup Job 이 실패하고 `AbortCleanup=False` 로 남는 것이 현재 diff --git a/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md b/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md index 0f3e4a5..c3f0c07 100644 --- a/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md +++ b/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md @@ -43,6 +43,21 @@ Ready event 의 shard count 도 active status row 수를 사용한다. 아직 남은 범위는 target replica scale-up/HA, 명시적 ShardSplitJob Promote phase, source resource/PDB 정리 정책, CRD spec 의 named shard model 전환이다. +### P-C.2 active target HA scale-up (2026-06-29) + +active topology 에 포함된 non-ordinal shard 는 `PostgresClusterReconciler` 가 ConfigMap, headless Service, +StatefulSet 을 직접 유지한다. StatefulSet replicas 는 `1 + spec.shards.replicas` 로 조정되고, +target replica init container 는 target primary endpoint 를 받아 `pg_basebackup` 경로로 들어간다. +hibernation/restore 중에는 active target 도 replicas=0 으로 내려간다. + +selector 와 lease 격리는 유지한다. active target 의 Service/StatefulSet selector 는 여전히 +`postgres.keiailab.io/reshard-target=` 이고, Pod env 에는 `POSTGRES_RESHARD_TARGET` 이 남아 target +전용 lease 를 사용한다. 즉 이 단계는 HA scale-up 이며, 아직 "target 을 ordinal shard 로 rename/adopt" 하지는 +않는다. + +남은 범위는 명시적 ShardSplitJob Promote phase 의 idempotency, source PDB/resource cleanup 정책, +CRD spec 의 named shard model 전환, 그리고 live chaos/e2e 검증이다. + 이번 hardening batch 에서 selector 사용처를 다음처럼 분리했다. - **그대로 둔 것**: `ShardStatefulSetName`, `ShardServiceName`, PDB/TLS/PVC resize, source shard DNS, diff --git a/docs/superpowers/plans/2026-06-28-reshard-hardening.md b/docs/superpowers/plans/2026-06-28-reshard-hardening.md index 2bd7f06..0d977e3 100644 --- a/docs/superpowers/plans/2026-06-28-reshard-hardening.md +++ b/docs/superpowers/plans/2026-06-28-reshard-hardening.md @@ -206,6 +206,36 @@ go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard- Status as of 2026-06-29: all checkpoint commands pass on Windows Go 1.26.4. This closes the source-observation part of P-C, but target replica scale-up/HA, explicit ShardSplitJob Promote phase, and named shard spec-model migration remain open. +## Batch 2.8: Active Named Target HA Scale-Up + +**Files:** +- Modify: `internal/controller/builders.go` +- Modify: `internal/controller/postgrescluster_controller.go` +- Modify: `internal/controller/postgrescluster_controller_test.go` + +- [x] Reconcile active named target resources from `PostgresClusterReconciler`. + - Once ShardRange points at a non-ordinal shard, the cluster reconciler maintains target ConfigMap, Service, and StatefulSet. + - Bootstrap-time target resources remain isolated before RoutingUpdate because ShardRange still points at the source. + +- [x] Scale active target StatefulSets to cluster member count. + - Desired replicas become `1 + spec.shards.replicas`. + - Hibernation/restore scales active target members to 0. + - `POSTGRES_MEMBER_COUNT` and `PRIMARY_ENDPOINT` are rendered for target replicas. + +- [x] Preserve target identity isolation. + - StatefulSet/Service selectors still use `postgres.keiailab.io/reshard-target=`. + - `POSTGRES_RESHARD_TARGET` remains set, so target election stays on the isolated target lease. + +**Checkpoint Verification:** + +```powershell +go test -count=1 ./internal/controller --ginkgo.focus="adds active named reshard targets" +go test -count=1 ./internal/controller +go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./internal/controller +``` + +Status as of 2026-06-29: all checkpoint commands pass on Windows Go 1.26.4. Remaining promotion work is explicit ShardSplitJob Promote/adopt idempotency, source PDB/resource cleanup policy, named shard spec-model migration, and live chaos/e2e validation. + ## Batch 3: Native Router Concurrent-Write E2E Design **Files:** diff --git a/internal/controller/builders.go b/internal/controller/builders.go index 01de6f4..6c8a622 100644 --- a/internal/controller/builders.go +++ b/internal/controller/builders.go @@ -1193,6 +1193,24 @@ func buildTargetShardStatefulSet( storage postgresv1alpha1.StorageSpec, resources corev1.ResourceRequirements, configMapName, configHash string, +) *appsv1.StatefulSet { + return buildTargetShardStatefulSetWithMembers( + cluster, shardID, image, pgMajor, + 1, "", + storage, resources, + configMapName, configHash, + ) +} + +func buildTargetShardStatefulSetWithMembers( + cluster *postgresv1alpha1.PostgresCluster, + shardID string, + image, pgMajor string, + members int32, + primaryEndpoint string, + storage postgresv1alpha1.StorageSpec, + resources corev1.ResourceRequirements, + configMapName, configHash string, ) *appsv1.StatefulSet { return buildPGStatefulSet( cluster, @@ -1200,9 +1218,9 @@ func buildTargetShardStatefulSet( TargetShardServiceName(cluster.Name, shardID), 0, // shardOrdinal: pod-0 initdb 경로용 (SHARD_ORDINAL env 는 정보용, 격리 label 은 reshardTargetID 가 결정) image, configMapName, pgMajor, - 1, // members: 단일 fresh primary (초기 복제본 없음) + members, storage, resources, - "", // primaryEndpoint: 빈 값 → pod-0 initdb fresh primary + primaryEndpoint, configHash, shardID, // reshardTargetID → 격리 label + POSTGRES_RESHARD_TARGET env ) diff --git a/internal/controller/postgrescluster_controller.go b/internal/controller/postgrescluster_controller.go index 43ae91e..1236115 100644 --- a/internal/controller/postgrescluster_controller.go +++ b/internal/controller/postgrescluster_controller.go @@ -345,6 +345,21 @@ func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Requ shardStatuses = append(shardStatuses, shardStat) } + if hasActiveShardTopology { + targetMembers := members + if databasePodsStopped { + targetMembers = 0 + } + if err := r.reconcileActiveNamedShardResources( + ctx, &cluster, activeShardIDs, + resolvedImage.Image, resolvedImage.PostgresMajor, + targetMembers, + databasePodsStopped, + ); err != nil { + return r.handleUpsertErr(ctx, &cluster, err, "active named shard resources", logger) + } + } + if !databasePodsStopped { namedShardStatuses, namedReady, err := activeNamedShardStatuses(ctx, r.Client, &cluster) if err != nil { @@ -523,6 +538,69 @@ func restoreInProgress(cluster *postgresv1alpha1.PostgresCluster) bool { return strings.TrimSpace(cluster.Annotations[AnnotationRestoreInProgress]) != "" } +func (r *PostgresClusterReconciler) reconcileActiveNamedShardResources( + ctx context.Context, + cluster *postgresv1alpha1.PostgresCluster, + activeShardIDs map[string]struct{}, + image string, + pgMajor string, + members int32, + databasePodsStopped bool, +) error { + if cluster == nil || len(activeShardIDs) == 0 { + return nil + } + ids := make([]string, 0, len(activeShardIDs)) + for shardID := range activeShardIDs { + if !isOrdinalShardID(cluster, shardID) { + ids = append(ids, shardID) + } + } + sort.Strings(ids) + + for _, shardID := range ids { + cm := buildTargetShardConfigMap(cluster, shardID, r.Plugins) + configHash := postgresConfigHash(cm.Data) + if err := r.upsert(ctx, cluster, cm); err != nil { + return fmt.Errorf("upsert active target %q ConfigMap: %w", shardID, err) + } + if err := r.upsert(ctx, cluster, buildTargetHeadlessService(cluster, shardID)); err != nil { + return fmt.Errorf("upsert active target %q Service: %w", shardID, err) + } + svcName := TargetShardServiceName(cluster.Name, shardID) + primaryEndpoint := primaryEndpointForNamedShard(cluster, shardID, svcName, databasePodsStopped) + sts := buildTargetShardStatefulSetWithMembers( + cluster, shardID, image, pgMajor, + members, primaryEndpoint, + cluster.Spec.Shards.Storage, cluster.Spec.Shards.Resources, + cm.Name, configHash, + ) + if err := r.upsert(ctx, cluster, sts); err != nil { + return fmt.Errorf("upsert active target %q StatefulSet: %w", shardID, err) + } + } + return nil +} + +func primaryEndpointForNamedShard( + cluster *postgresv1alpha1.PostgresCluster, + shardID string, + svcName string, + databasePodsStopped bool, +) string { + if cluster == nil || databasePodsStopped { + return "" + } + for i := range cluster.Status.Shards { + shard := &cluster.Status.Shards[i] + if shard.Name == shardID && shard.Primary != nil && shard.Primary.Endpoint != "" { + return shard.Primary.Endpoint + } + } + podName := TargetShardStatefulSetName(cluster.Name, shardID) + "-0" + return fmt.Sprintf("%s.%s.%s.svc.cluster.local:%d", podName, svcName, cluster.Namespace, pgPort) +} + // applyClusterConditions 는 reconcile 산출물 (shard 준비 상태, router 활성/준비 // 상태) 를 RFC 0001 §3.4 Condition 카탈로그 + ClusterPhase 로 변환하여 cluster // 객체에 직접 기록한다. diff --git a/internal/controller/postgrescluster_controller_test.go b/internal/controller/postgrescluster_controller_test.go index f6ee086..3f231f2 100644 --- a/internal/controller/postgrescluster_controller_test.go +++ b/internal/controller/postgrescluster_controller_test.go @@ -255,7 +255,7 @@ var _ = Describe("PostgresClusterReconciler — RFC 0001 spec", func() { ShardingMode: postgresv1alpha1.ShardingModeNative, Shards: postgresv1alpha1.ShardsSpec{ InitialCount: 1, - Replicas: 0, + Replicas: 1, Storage: postgresv1alpha1.StorageSpec{ Size: resource.MustParse("1Gi"), }, @@ -292,6 +292,16 @@ var _ = Describe("PostgresClusterReconciler — RFC 0001 spec", func() { }, &sourceSTS)).To(Succeed()) g.Expect(sourceSTS.Spec.Replicas).NotTo(BeNil()) g.Expect(*sourceSTS.Spec.Replicas).To(Equal(int32(0)), "inactive source ordinal STS must scale to zero") + + var targetSTS appsv1.StatefulSet + g.Expect(k8sClient.Get(ctx, types.NamespacedName{ + Namespace: namespace, Name: TargetShardStatefulSetName(clusterName, targetShard), + }, &targetSTS)).To(Succeed()) + g.Expect(targetSTS.Spec.Replicas).NotTo(BeNil()) + g.Expect(*targetSTS.Spec.Replicas).To(Equal(int32(2)), "active target STS must match cluster members") + mainEnv := envMap(targetSTS.Spec.Template.Spec.Containers[0].Env) + g.Expect(mainEnv["POSTGRES_MEMBER_COUNT"].Value).To(Equal("2")) + g.Expect(mainEnv["PRIMARY_ENDPOINT"].Value).To(Equal(targetEndpoint)) }, envtestTimeout, envtestInterval).Should(Succeed()) }) }) From ca71422135beb70717ba41925d748159ccb85278 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Mon, 29 Jun 2026 01:39:43 +0900 Subject: [PATCH 094/109] feat(sharding): add reshard promote adopt phase Add ShardSplitJob Promote between Cleanup and Completed, and adopt target shard identity by patching target StatefulSet/template/live Pod shard-id labels while preserving reshard-target selectors. Regenerate ShardSplitJob CRDs and update handoff/ADR/plan docs with completed adopt scope and remaining source cleanup/chaos work. --- api/v1alpha1/shardsplitjob_types.go | 5 +- api/v1alpha1/shardsplitjob_types_test.go | 8 +-- .../postgres.keiailab.io_shardsplitjobs.yaml | 1 + .../postgres.keiailab.io_shardsplitjobs.yaml | 1 + docs/WORK_HANDOFF.ko.md | 29 ++++---- ...rd-target-promotion-identity-transition.md | 48 +++++++++----- .../plans/2026-06-28-reshard-hardening.md | 35 +++++++++- .../controller/shardsplitjob_controller.go | 66 +++++++++++++++++++ .../shardsplitjob_controller_test.go | 3 +- .../shardsplitjob_writeblock_test.go | 60 +++++++++++++++++ 10 files changed, 222 insertions(+), 34 deletions(-) diff --git a/api/v1alpha1/shardsplitjob_types.go b/api/v1alpha1/shardsplitjob_types.go index 5dce636..0e90472 100644 --- a/api/v1alpha1/shardsplitjob_types.go +++ b/api/v1alpha1/shardsplitjob_types.go @@ -25,8 +25,8 @@ import ( // 본 CRD 는 *state machine 만 정의* — 실 step 구현은 internal/controller/ // shardsplit/ + internal/router/ 에 위임 (P-D §D.9.* 후속). -// ShardSplitJobPhase 는 7-step state machine 의 현재 phase 이다. -// +kubebuilder:validation:Enum=Pending;SnapshotWAL;Bootstrap;InitialCopy;CDCCatchup;Cutover;RoutingUpdate;Cleanup;Completed;Failed;Aborted +// ShardSplitJobPhase 는 resharding state machine 의 현재 phase 이다. +// +kubebuilder:validation:Enum=Pending;SnapshotWAL;Bootstrap;InitialCopy;CDCCatchup;Cutover;RoutingUpdate;Cleanup;Promote;Completed;Failed;Aborted type ShardSplitJobPhase string const ( @@ -38,6 +38,7 @@ const ( ShardSplitPhaseCutover ShardSplitJobPhase = "Cutover" ShardSplitPhaseRoutingUpdate ShardSplitJobPhase = "RoutingUpdate" ShardSplitPhaseCleanup ShardSplitJobPhase = "Cleanup" + ShardSplitPhasePromote ShardSplitJobPhase = "Promote" ShardSplitPhaseCompleted ShardSplitJobPhase = "Completed" ShardSplitPhaseFailed ShardSplitJobPhase = "Failed" ShardSplitPhaseAborted ShardSplitJobPhase = "Aborted" diff --git a/api/v1alpha1/shardsplitjob_types_test.go b/api/v1alpha1/shardsplitjob_types_test.go index 0813e58..9d149b9 100644 --- a/api/v1alpha1/shardsplitjob_types_test.go +++ b/api/v1alpha1/shardsplitjob_types_test.go @@ -13,17 +13,17 @@ import ( ) func TestShardSplitJob(t *testing.T) { - t.Run("Phase 전체 11 값 stringify", func(t *testing.T) { + t.Run("Phase 전체 12 값 stringify", func(t *testing.T) { phases := []ShardSplitJobPhase{ ShardSplitPhasePending, ShardSplitPhaseSnapshotWAL, ShardSplitPhaseBootstrap, ShardSplitPhaseInitialCopy, ShardSplitPhaseCDCCatchup, ShardSplitPhaseCutover, ShardSplitPhaseRoutingUpdate, ShardSplitPhaseCleanup, - ShardSplitPhaseCompleted, ShardSplitPhaseFailed, + ShardSplitPhasePromote, ShardSplitPhaseCompleted, ShardSplitPhaseFailed, ShardSplitPhaseAborted, } - if len(phases) != 11 { - t.Fatalf("phase count want=11 got=%d", len(phases)) + if len(phases) != 12 { + t.Fatalf("phase count want=12 got=%d", len(phases)) } for _, p := range phases { if string(p) == "" { diff --git a/charts/postgres-operator/crds/postgres.keiailab.io_shardsplitjobs.yaml b/charts/postgres-operator/crds/postgres.keiailab.io_shardsplitjobs.yaml index ac7b1f9..af7606c 100644 --- a/charts/postgres-operator/crds/postgres.keiailab.io_shardsplitjobs.yaml +++ b/charts/postgres-operator/crds/postgres.keiailab.io_shardsplitjobs.yaml @@ -280,6 +280,7 @@ spec: - Cutover - RoutingUpdate - Cleanup + - Promote - Completed - Failed - Aborted diff --git a/config/crd/bases/postgres.keiailab.io_shardsplitjobs.yaml b/config/crd/bases/postgres.keiailab.io_shardsplitjobs.yaml index ac7b1f9..af7606c 100644 --- a/config/crd/bases/postgres.keiailab.io_shardsplitjobs.yaml +++ b/config/crd/bases/postgres.keiailab.io_shardsplitjobs.yaml @@ -280,6 +280,7 @@ spec: - Cutover - RoutingUpdate - Cleanup + - Promote - Completed - Failed - Aborted diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index 33a1731..4c01ca6 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -250,7 +250,7 @@ SQL 라우터 종단 + 온라인 resharding 을 코드+테스트+(상당수)라 - **라우터 종단(per-query)**: simple + extended/prepared per-query 라우팅(연결 고정 해소, `persession.go`/`extsession.go`, 샤드별 prepare-on-first-use), scatter, reference table, read-replica 결선. bufio 읽기버퍼+단일write 최적화. 라이브 검증 다수. -- **온라인 resharding (ShardSplitJob 7-phase 실 결선)**: Bootstrap(target STS) → InitialCopy +- **온라인 resharding (ShardSplitJob multi-phase 실 결선)**: Bootstrap(target STS) → InitialCopy (offline: hash-range copy Job) / CDCCatchup(online: 논리복제 subscription) → Cutover (write-block) → RoutingUpdate(ShardRange flip+unblock) → Cleanup(source 삭제 Job). 데이터+ 스키마+인덱스/PK+CHECK/FK 제약 복제. K8s Job 실행 모델(내부 trust 접속). **offline·online @@ -280,11 +280,10 @@ SQL 라우터 종단 + 온라인 resharding 을 코드+테스트+(상당수)라 후 envtest 로 커버(job 이름/env 불변). **다음 작업 진입점(우선순위)**: -1. **ADR-0029 P-A.2 → P-B (target 승격)**: `aggregate_status.go`/`metrics`/`failover` 의 shard - 선택을 `shard-id` 인지로 일반화(단 업그레이드 중 OR-select 위해 in-code 필터 — 셀렉터만으론 구 - pod 누락). 그 후 ShardSplitJob 에 Promote phase(fence→adopt(reshard-target→shard-id)→status - 편입→source decommission, 멱등·single-authority). **#220-class 정체성 위험 — 라이브 chaos drill - (승격 중 pod kill) 의무.** 설계는 `docs/kb/adr/0029-*.md`. +1. **ADR-0029 P-B 잔여(target 승격 안전성)**: P-A.2 selector/status 일반화와 Promote target-adopt + slice 는 완료됐다. 다음은 Promote phase 의 precondition/fence 강화, source PDB/resource 삭제 정책, + source decommission 멱등성, 그리고 **#220-class 정체성 위험**을 확인하는 라이브 chaos drill + (승격 중 pod kill) 이다. 설계는 `docs/kb/adr/0029-*.md`. 2. **native 라우터 동시쓰기 무중단 e2e**: shardingMode=native(라우터 배포)에서 클라 쓰기를 라우터 경유로 받아 write-block 이 실제 쓰기를 막는 무중단 cutover 실증 + 위 PK-없는-target 동시쓰기 경로 검증. @@ -320,7 +319,8 @@ kubectl -n postgres-operator-system set env deploy/postgres-operator-controller- 2. **먼저 닫을 위험**: reference table 쓰기 단일 shard 라우팅 방지, keyless write scatter 금지, scatter error 경로 ReadyForQuery 보장, reshard-copy Job 의 vindex type 보존. 3. **그 다음 운영 안전장치**: online resharding 실패/abort 시 pub/sub/replication-slot 누수 정리. -4. **그 다음 ADR-0029**: target 승격은 `shard-id` selector audit 후 Promote phase 로 들어간다. +4. **그 다음 ADR-0029 잔여**: selector/status 일반화와 Promote target-adopt 이후에는 + precondition/fence 강화, source cleanup 정책, named shard spec-model 전환으로 들어간다. #220-class 정체성 위험 때문에 live chaos drill 전에는 GA 완료로 보지 않는다. 5. **검증 체크포인트**: 기본은 native Go test 로 시작한다. Docker/kind 는 native router 동시쓰기 e2e 또는 full live gate 에서만 사용한다. @@ -351,14 +351,19 @@ kubectl -n postgres-operator-system set env deploy/postgres-operator-controller- ConfigMap/Service/StatefulSet 을 유지하고, target STS replicas 를 `1 + spec.shards.replicas` 로 맞춘다. target replica 는 `POSTGRES_MEMBER_COUNT` 와 target primary `PRIMARY_ENDPOINT` 를 받아 basebackup 경로로 들어갈 수 있다. hibernation/restore 중에는 active target 도 replicas=0 으로 내려간다. +- Batch 2.9 개발 완료: ShardSplitJob status phase 에 `Promote` 를 추가하고 state machine 을 + `Cleanup -> Promote -> Completed` 로 확장했다. Promote 는 target StatefulSet object label, + Pod template label, live target Pod label 에 `postgres.keiailab.io/shard-id=` 을 붙인다. + Service/StatefulSet selector 는 계속 `postgres.keiailab.io/reshard-target=` 에 고정해 + selector 불변성 및 target lease 격리를 유지한다. source StatefulSet/Service/PVC/PDB 삭제는 하지 않는다. - Batch 3 설계 문서 완료: native router concurrent-write e2e 시나리오를 `docs/sharding/ROUTER-GAP-ANALYSIS.ko.md` 에 기록했다. write stream 중 online CDC, write-block `ReadyForQuery`, routing update, checksum/key ownership, PK 없는 target UPDATE/DELETE, abort cleanup 을 한 live gate 로 검증한다. -- 아직 남은 범위: 명시적 ShardSplitJob Promote phase 의 fence/adopt/source cleanup idempotency, - source PDB/resource 삭제 정책, 그리고 spec shard model 의 named-list 전환은 P-B/P-C 범위다. - source fence/관측 제외 전에 target 에 동일 `shard-id` 를 붙이면 split-brain 신호가 될 수 있으므로 - 순서가 중요하다. +- 아직 남은 범위: Promote phase 의 precondition/fence 강화, source PDB/resource 삭제 정책, + spec shard model 의 named-list 전환은 P-B/P-C 범위다. source fence/관측 제외 전에 target 에 동일 + `shard-id` 를 붙이면 split-brain 신호가 될 수 있으므로, 이후 destructive cleanup 은 별도 정책과 + live chaos 검증 뒤에 진행한다. - 라이브 검증 전 주의: `cdc-abort` 는 `DROP SUBSCRIPTION IF EXISTS` 로 원격 replication slot 정리까지 시도한다. source 접속 불가 상황에서는 cleanup Job 이 실패하고 `AbortCleanup=False` 로 남는 것이 현재 의도한 안전 동작이다. source-down 상태에서도 target subscription 만 강제 제거하는 fallback 은 live drill @@ -368,6 +373,8 @@ kubectl -n postgres-operator-system set env deploy/postgres-operator-controller- `go test -count=1 ./internal/controller -run TestBuildTargetShardStatefulSet_Isolation`, `go test -count=1 ./internal/controller -run TestAggregateNamedShardStatus_UsesReshardTargetLabel`, `go test -count=1 ./internal/controller --ginkgo.focus="adds active named reshard targets"`, + `go test -count=1 ./api/v1alpha1 ./internal/controller -run "TestShardSplitJob|TestShardSplitJob_nextPhase"`, + `go test -count=1 ./internal/controller --ginkgo.focus="Promote phase"`, `go test -count=1 ./internal/controller`, `go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./internal/controller`. diff --git a/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md b/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md index c3f0c07..1874b76 100644 --- a/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md +++ b/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md @@ -27,8 +27,9 @@ status row 는 `name=t1`, `ordinal=-1` 로 기록된다. target Pod 선택은 `postgres.keiailab.io/shard-id=` 를 모두 허용한다. 이로써 routing flip 이후 `StatusBackendResolver` 가 target shard primary endpoint 를 해석할 수 있다. -단, 이것은 lifecycle 승격 전체가 아니다. source ordinal resource decommission, target replica scale-up/HA, -spec shard model 의 named-list 전환, Promote phase idempotency 는 아직 P-B/P-C 잔여 범위다. +단, 이것은 lifecycle 승격 전체가 아니다. 이 시점 기준으로 source ordinal resource decommission, +target replica scale-up/HA, spec shard model 의 named-list 전환, Promote phase idempotency 는 P-B/P-C +잔여 범위였다. ### P-C.1 active topology source decommission (2026-06-29) @@ -40,8 +41,8 @@ active topology 로 본다. active topology 에서 빠진 ordinal shard 는 Stat Ready=false 로 남아 cluster 를 Provisioning/Degraded 에 묶어두는 문제를 제거한다. 조건 메시지와 Ready event 의 shard count 도 active status row 수를 사용한다. -아직 남은 범위는 target replica scale-up/HA, 명시적 ShardSplitJob Promote phase, source resource/PDB -정리 정책, CRD spec 의 named shard model 전환이다. +이 시점 기준 잔여 범위는 target replica scale-up/HA, 명시적 ShardSplitJob Promote phase, +source resource/PDB 정리 정책, CRD spec 의 named shard model 전환이었다. ### P-C.2 active target HA scale-up (2026-06-29) @@ -55,8 +56,23 @@ selector 와 lease 격리는 유지한다. active target 의 Service/StatefulSet 전용 lease 를 사용한다. 즉 이 단계는 HA scale-up 이며, 아직 "target 을 ordinal shard 로 rename/adopt" 하지는 않는다. -남은 범위는 명시적 ShardSplitJob Promote phase 의 idempotency, source PDB/resource cleanup 정책, -CRD spec 의 named shard model 전환, 그리고 live chaos/e2e 검증이다. +이 단계 직후 남은 범위는 명시적 ShardSplitJob Promote phase 의 target adopt/idempotency, +source PDB/resource cleanup 정책, CRD spec 의 named shard model 전환, 그리고 live chaos/e2e 검증이었다. + +### P-B.2 explicit Promote adopt phase (2026-06-29) + +ShardSplitJob state machine 에 `Promote` phase 를 추가했다. 전이는 `Cleanup -> Promote -> Completed` 이며, +CRD status phase enum 에도 `Promote` 를 반영했다. + +이번 조각의 역할은 target shard 를 운영 관측 identity 에 편입하는 것이다. `Promote` 는 각 target +StatefulSet object label, Pod template label, live target Pod label 에 +`postgres.keiailab.io/shard-id=` 을 붙인다. 선택자는 계속 +`postgres.keiailab.io/reshard-target=` 에 고정한다. StatefulSet selector 를 바꾸지 않기 때문에 +Kubernetes selector 불변성 문제를 피하고, `POSTGRES_RESHARD_TARGET` 기반 target 전용 lease 격리도 유지한다. + +이 구현은 `MergeFrom` patch 기반이라 같은 phase 가 반복 reconcile 되어도 같은 label 상태로 수렴한다. 다만 +source StatefulSet/Service/PVC/PDB 삭제, source data retention 정책, promote 중 target/source pod kill 을 포함한 +live chaos 검증은 아직 별도 잔여 범위다. 즉 P-B 전체 완료가 아니라 P-B 의 adopt/idempotency slice 완료다. 이번 hardening batch 에서 selector 사용처를 다음처럼 분리했다. @@ -68,9 +84,9 @@ CRD spec 의 named shard model 전환, 그리고 live chaos/e2e 검증이다. `postgres.keiailab.io/shard=` 또는 `postgres.keiailab.io/shard-id=shard-` 를 OR 필터링한다. - **metrics/failover**: 직접 pod selector 를 갖지 않고 `PostgresCluster.status.shards` 를 소비한다. 따라서 aggregation 이 `shard-id` 를 이해하면 metrics/failover 는 status 경유로 따라온다. -- **아직 하지 않은 것**: `shard-id=t1` 같은 named target 을 status.shards 의 새 row 로 생성하지는 않았다. - 현재 `PostgresClusterReconciler` 는 `spec.shards.initialCount` ordinal loop 를 유지한다. named shard - row 생성은 P-B/P-C 의 ShardRange/spec 모델 전환 범위다. +- **완료한 것**: active `ShardRange.spec.ranges[].shard` 에 나타난 named target 은 `status.shards` 에 + `name=`, `ordinal=-1` row 로 생성한다. target Pod 집계는 `reshard-target=` 또는 adopt 후 + `shard-id=` label 을 허용한다. - **P-B 주의점**: target 에 `shard-id` 를 붙이기 전에 source ordinal shard 를 fence/관측 제외해야 한다. source 와 target 이 같은 `shard-id` 로 동시에 보이면 aggregation 은 split-brain 으로 보고 primary 2개 상황을 노출한다. 이는 의도적인 안전 신호이며, Promote phase 는 이 중간 상태를 만들지 않아야 한다. @@ -79,10 +95,10 @@ CRD spec 의 named shard model 전환, 그리고 live chaos/e2e 검증이다. 2026-06-28 기준 online resharding 의 데이터 경로가 *전부* 결선·라이브 검증되었다 (offline + online 무중단 CDC, 스키마/인덱스/PK/제약 복제, write-block, full e2e — `WORK_HANDOFF.ko.md §6.6`). -ShardSplitJob 7-phase 중 Bootstrap→InitialCopy/CDCCatchup→Cutover→RoutingUpdate→Cleanup→Completed -가 실 K8s+PG 에서 동작한다. +당시 ShardSplitJob state machine 중 Bootstrap→InitialCopy/CDCCatchup→Cutover→RoutingUpdate→Cleanup→Completed +가 실 K8s+PG 에서 동작했다. -**그러나 ADR-0027 의 P6(승격)는 미구현이다.** 현재 resharding 완료 후 상태: +**그러나 당시 ADR-0027 의 P6(승격)는 미구현이었다.** 당시 resharding 완료 후 상태: - target shard 는 *격리 식별* 로 존재한다: K8s 자원 `-rsd-` + label `postgres.keiailab.io/reshard-target=` (ordinal `postgres.keiailab.io/shard` label 부재). @@ -162,8 +178,9 @@ ShardSplitJob 에 **Promote phase**(또는 Cleanup 후 별 phase)를 추가, ope - spec 의 shard 모델(ordinal count → 명명 목록) 확장은 CRD 변경(마이그레이션 고려). ### 검증 의무 -- envtest: Promote phase 가 target 에 `shard-id` 부여 + source label 제거 + status.shards 재계산 + - source STS 회수 순서·멱등 단언. +- envtest: Promote phase 가 target StatefulSet/template/live Pod 에 `shard-id` 를 부여하고 selector 는 + `reshard-target` 으로 유지하는지 단언. +- 잔여 envtest: source label 제거 또는 source STS/PDB/PVC 회수 정책이 결정되면 순서·멱등 단언 추가. - 라이브 chaos: 승격 진행 중 operator/target pod kill → 재진입 수렴 + primary 단일성 유지 확인. ## Alternatives Considered @@ -179,7 +196,8 @@ ShardSplitJob 에 **Promote phase**(또는 Cleanup 후 별 phase)를 추가, ope 1. **P-A**: `shard-id` 통합 label 도입 + aggregate_status/metrics/failover selector 일반화(ordinal `shard` label 하위호환 병행). envtest 회귀(기존 ordinal cluster 무영향). -2. **P-B**: ShardSplitJob Promote phase — fence + adopt + status 편입 + source decommission. envtest. +2. **P-B**: ShardSplitJob Promote phase — fence + adopt + status 편입 + source decommission. P-B.2 target + adopt slice 는 구현됨. source decommission 과 live chaos 는 잔여. 3. **P-C**: spec shard 모델 확장(명명 목록) + 마이그레이션. 라이브 chaos drill. > 본 ADR 은 *설계 결정* 이며 구현은 위 P-A~P-C 로 분할한다. ADR-0027 P6 의 "신중한 ground-up diff --git a/docs/superpowers/plans/2026-06-28-reshard-hardening.md b/docs/superpowers/plans/2026-06-28-reshard-hardening.md index 0d977e3..b29cdc0 100644 --- a/docs/superpowers/plans/2026-06-28-reshard-hardening.md +++ b/docs/superpowers/plans/2026-06-28-reshard-hardening.md @@ -234,7 +234,40 @@ go test -count=1 ./internal/controller go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./internal/controller ``` -Status as of 2026-06-29: all checkpoint commands pass on Windows Go 1.26.4. Remaining promotion work is explicit ShardSplitJob Promote/adopt idempotency, source PDB/resource cleanup policy, named shard spec-model migration, and live chaos/e2e validation. +Status as of 2026-06-29 before Batch 2.9: all checkpoint commands pass on Windows Go 1.26.4. Remaining promotion work is explicit ShardSplitJob Promote/adopt idempotency, source PDB/resource cleanup policy, named shard spec-model migration, and live chaos/e2e validation. + +## Batch 2.9: Explicit ShardSplitJob Promote Adopt Phase + +**Files:** +- Modify: `api/v1alpha1/shardsplitjob_types.go` +- Modify: `internal/controller/shardsplitjob_controller.go` +- Modify: `internal/controller/shardsplitjob_controller_test.go` +- Modify: `internal/controller/shardsplitjob_writeblock_test.go` +- Generate: `config/crd/bases/postgres.keiailab.io_shardsplitjobs.yaml` +- Sync: `charts/postgres-operator/crds/postgres.keiailab.io_shardsplitjobs.yaml` + +- [x] Add `Promote` to the ShardSplitJob status phase enum. + - The state machine now advances `Cleanup -> Promote -> Completed`. + - Generated CRDs expose the new status value so envtest/live API validation accepts it. + +- [x] Adopt target shard identity idempotently. + - `Promote` patches target StatefulSet object labels, Pod template labels, and live target Pods with `postgres.keiailab.io/shard-id=`. + - Target Service/StatefulSet selectors remain on `postgres.keiailab.io/reshard-target=` to avoid selector mutation and preserve lease isolation. + +- [x] Keep this slice non-destructive. + - The phase does not delete source StatefulSets, Services, PVCs, or PDBs. + - Source data/resource retention remains a separate policy decision. + +**Checkpoint Verification:** + +```powershell +go test -count=1 ./api/v1alpha1 ./internal/controller -run "TestShardSplitJob|TestShardSplitJob_nextPhase" +go test -count=1 ./internal/controller --ginkgo.focus="Promote phase" +go test -count=1 ./internal/controller +go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./internal/controller +``` + +Status as of 2026-06-29: all checkpoint commands pass on Windows Go 1.26.4. This closes the first Promote/adopt slice only. Remaining promotion work is precondition/fence hardening, source PDB/resource cleanup policy, named shard spec-model migration, and live chaos/e2e validation. ## Batch 3: Native Router Concurrent-Write E2E Design diff --git a/internal/controller/shardsplitjob_controller.go b/internal/controller/shardsplitjob_controller.go index 14dee30..9a7033e 100644 --- a/internal/controller/shardsplitjob_controller.go +++ b/internal/controller/shardsplitjob_controller.go @@ -13,6 +13,7 @@ import ( appsv1 "k8s.io/api/apps/v1" batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" @@ -47,6 +48,7 @@ type ShardSplitJobReconciler struct { // +kubebuilder:rbac:groups=postgres.keiailab.io,resources=shardranges,verbs=get;list;watch;update;patch // +kubebuilder:rbac:groups=postgres.keiailab.io,resources=postgresclusters,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=configmaps;services,verbs=get;list;watch;create;update;patch +// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch;patch // +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;create;update;patch // +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete @@ -180,6 +182,18 @@ func (r *ShardSplitJobReconciler) Reconcile(ctx context.Context, req ctrl.Reques // 정리 완료 → 아래 nextPhase 가 Completed 로 전이. } + if ssj.Status.Phase == postgresv1alpha1.ShardSplitPhasePromote { + if err := r.reconcilePromote(ctx, &ssj); err != nil { + ssj.Status.Phase = postgresv1alpha1.ShardSplitPhaseFailed + ssj.Status.FailureReason = err.Error() + now := metav1.Now() + ssj.Status.CompletedAt = &now + ssj.Status.ObservedGeneration = ssj.Generation + _ = r.Status().Update(ctx, &ssj) + return ctrl.Result{}, nil + } + } + next, failure := r.nextPhase(&ssj) if next == ssj.Status.Phase { return ctrl.Result{}, nil @@ -247,11 +261,63 @@ func (r *ShardSplitJobReconciler) nextPhase(ssj *postgresv1alpha1.ShardSplitJob) case postgresv1alpha1.ShardSplitPhaseRoutingUpdate: return postgresv1alpha1.ShardSplitPhaseCleanup, "" case postgresv1alpha1.ShardSplitPhaseCleanup: + return postgresv1alpha1.ShardSplitPhasePromote, "" + case postgresv1alpha1.ShardSplitPhasePromote: return postgresv1alpha1.ShardSplitPhaseCompleted, "" } return ssj.Status.Phase, "" } +func (r *ShardSplitJobReconciler) reconcilePromote(ctx context.Context, ssj *postgresv1alpha1.ShardSplitJob) error { + for i := range ssj.Spec.Targets { + shardID := ssj.Spec.Targets[i].ShardID + if err := r.adoptTargetShardIdentity(ctx, ssj.Namespace, ssj.Spec.Cluster, shardID); err != nil { + return fmt.Errorf("adopt target shard %q identity: %w", shardID, err) + } + } + return nil +} + +func (r *ShardSplitJobReconciler) adoptTargetShardIdentity(ctx context.Context, namespace, cluster, shardID string) error { + var sts appsv1.StatefulSet + if err := r.Get(ctx, client.ObjectKey{Namespace: namespace, Name: TargetShardStatefulSetName(cluster, shardID)}, &sts); err != nil { + return err + } + stsBefore := sts.DeepCopy() + ensureLabel(&sts.Labels, ShardIDLabelKey, shardID) + ensureLabel(&sts.Spec.Template.Labels, ShardIDLabelKey, shardID) + if err := r.Patch(ctx, &sts, client.MergeFrom(stsBefore)); err != nil { + return fmt.Errorf("patch target StatefulSet %q: %w", sts.Name, err) + } + + var pods corev1.PodList + if err := r.List(ctx, &pods, + client.InNamespace(namespace), + client.MatchingLabels(ReshardTargetSelectorLabels(cluster, shardID)), + ); err != nil { + return fmt.Errorf("list target pods: %w", err) + } + for i := range pods.Items { + pod := &pods.Items[i] + if pod.Labels[ShardIDLabelKey] == shardID { + continue + } + podBefore := pod.DeepCopy() + ensureLabel(&pod.Labels, ShardIDLabelKey, shardID) + if err := r.Patch(ctx, pod, client.MergeFrom(podBefore)); err != nil { + return fmt.Errorf("patch target pod %q: %w", pod.Name, err) + } + } + return nil +} + +func ensureLabel(labels *map[string]string, key, value string) { + if *labels == nil { + *labels = map[string]string{} + } + (*labels)[key] = value +} + // flattenTargetRanges 는 모든 target shard 의 키 범위를 하나의 slice 로 모은다. func flattenTargetRanges(targets []postgresv1alpha1.ShardSplitTarget) []postgresv1alpha1.ShardRangeEntry { var out []postgresv1alpha1.ShardRangeEntry diff --git a/internal/controller/shardsplitjob_controller_test.go b/internal/controller/shardsplitjob_controller_test.go index a451d9f..974dd33 100644 --- a/internal/controller/shardsplitjob_controller_test.go +++ b/internal/controller/shardsplitjob_controller_test.go @@ -47,7 +47,8 @@ func TestShardSplitJob_nextPhase(t *testing.T) { {"initialcopy → CDCCatchup", ssjWith(postgresv1alpha1.ShardSplitPhaseInitialCopy, false, twoTargets()), postgresv1alpha1.ShardSplitPhaseCDCCatchup}, {"cutover reversible → RoutingUpdate", ssjWith(postgresv1alpha1.ShardSplitPhaseCutover, false, twoTargets()), postgresv1alpha1.ShardSplitPhaseRoutingUpdate}, {"cutover forward-only → Failed (비가역 거부)", ssjWith(postgresv1alpha1.ShardSplitPhaseCutover, true, twoTargets()), postgresv1alpha1.ShardSplitPhaseFailed}, - {"cleanup → Completed", ssjWith(postgresv1alpha1.ShardSplitPhaseCleanup, false, twoTargets()), postgresv1alpha1.ShardSplitPhaseCompleted}, + {"cleanup → Promote", ssjWith(postgresv1alpha1.ShardSplitPhaseCleanup, false, twoTargets()), postgresv1alpha1.ShardSplitPhasePromote}, + {"promote → Completed", ssjWith(postgresv1alpha1.ShardSplitPhasePromote, false, twoTargets()), postgresv1alpha1.ShardSplitPhaseCompleted}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/controller/shardsplitjob_writeblock_test.go b/internal/controller/shardsplitjob_writeblock_test.go index 72c6954..d3b5dc3 100644 --- a/internal/controller/shardsplitjob_writeblock_test.go +++ b/internal/controller/shardsplitjob_writeblock_test.go @@ -13,9 +13,11 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" apimeta "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" @@ -105,6 +107,64 @@ var _ = Describe("ShardSplitJob Cutover write-block", func() { Expect(ssj.Status.Phase).To(Equal(postgresv1alpha1.ShardSplitPhaseCleanup)) }) + It("Promote phase 가 target STS 와 live Pod 에 shard-id 를 adopt label 로 붙인다", func() { + clusterName := fmt.Sprintf("rsdprom-%d", GinkgoRandomSeed()) + targetShard := "t1" + cluster := &postgresv1alpha1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName, Namespace: ns}, + Spec: postgresv1alpha1.PostgresClusterSpec{ + PostgresVersion: "18", + Shards: postgresv1alpha1.ShardsSpec{ + Storage: postgresv1alpha1.StorageSpec{Size: resource.MustParse("1Gi")}, + }, + }, + } + sts := buildTargetShardStatefulSet( + cluster, targetShard, "postgres:18", "18", + postgresv1alpha1.StorageSpec{Size: resource.MustParse("1Gi")}, corev1.ResourceRequirements{}, + TargetShardConfigMapName(clusterName, targetShard), "cfg", + ) + Expect(k8sClient.Create(ctx, sts)).To(Succeed()) + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: TargetShardStatefulSetName(clusterName, targetShard) + "-0", + Namespace: ns, + Labels: ReshardTargetSelectorLabels(clusterName, targetShard), + }, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: pgContainerName, Image: "postgres:18"}}}, + } + Expect(k8sClient.Create(ctx, pod)).To(Succeed()) + + ssj := &postgresv1alpha1.ShardSplitJob{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-ssj", Namespace: ns}, + Spec: postgresv1alpha1.ShardSplitJobSpec{ + Cluster: clusterName, + Keyspace: "default", + Sources: []string{"shard-0"}, + Targets: []postgresv1alpha1.ShardSplitTarget{{ + ShardID: targetShard, + Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0xffffffff", Shard: targetShard}}, + }}, + }, + Status: postgresv1alpha1.ShardSplitJobStatus{Phase: postgresv1alpha1.ShardSplitPhasePromote}, + } + r := &ShardSplitJobReconciler{Client: k8sClient, Scheme: scheme.Scheme} + Expect(r.reconcilePromote(ctx, ssj)).To(Succeed()) + + var gotSTS appsv1.StatefulSet + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(sts), &gotSTS)).To(Succeed()) + Expect(gotSTS.Labels[ReshardTargetLabelKey]).To(Equal(targetShard)) + Expect(gotSTS.Labels[ShardIDLabelKey]).To(Equal(targetShard)) + Expect(gotSTS.Spec.Template.Labels[ReshardTargetLabelKey]).To(Equal(targetShard)) + Expect(gotSTS.Spec.Template.Labels[ShardIDLabelKey]).To(Equal(targetShard)) + Expect(gotSTS.Spec.Selector.MatchLabels).NotTo(HaveKey(ShardIDLabelKey)) + + var gotPod corev1.Pod + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(pod), &gotPod)).To(Succeed()) + Expect(gotPod.Labels[ReshardTargetLabelKey]).To(Equal(targetShard)) + Expect(gotPod.Labels[ShardIDLabelKey]).To(Equal(targetShard)) + }) + It("online 모드 CDCCatchup: cdc-setup Job → write-block → cdc-finalize Job 순서", func() { clusterName := fmt.Sprintf("rsdcdc-%d", GinkgoRandomSeed()) keyspace := "default" From 0149a42198601d9e4e3ed7fea27cdf1954d532e4 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Mon, 29 Jun 2026 01:48:43 +0900 Subject: [PATCH 095/109] fix(sharding): gate reshard promote on shardrange topology Require ShardSplitJob Promote to see sources absent and targets active in the matching ShardRange before adopting target shard-id labels. Keep Promote requeueing without label mutation while the source is still active, and document the remaining readiness/source cleanup work. --- docs/WORK_HANDOFF.ko.md | 14 ++- ...rd-target-promotion-identity-transition.md | 10 ++ .../plans/2026-06-28-reshard-hardening.md | 28 ++++++ .../controller/shardsplitjob_controller.go | 54 +++++++++++ .../shardsplitjob_writeblock_test.go | 96 ++++++++++++++++++- 5 files changed, 194 insertions(+), 8 deletions(-) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index 4c01ca6..a6771ec 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -356,14 +356,17 @@ kubectl -n postgres-operator-system set env deploy/postgres-operator-controller- Pod template label, live target Pod label 에 `postgres.keiailab.io/shard-id=` 을 붙인다. Service/StatefulSet selector 는 계속 `postgres.keiailab.io/reshard-target=` 에 고정해 selector 불변성 및 target lease 격리를 유지한다. source StatefulSet/Service/PVC/PDB 삭제는 하지 않는다. +- Batch 2.10 개발 완료: Promote 는 matching `ShardRange` active set 을 먼저 확인한다. 모든 source 가 + active set 에서 빠지고 모든 target 이 active set 에 들어온 뒤에만 target adopt 를 수행한다. source 가 + 아직 active 이면 phase 를 `Promote` 로 유지하고 requeue 하며 target StatefulSet/template/live Pod 에 + `shard-id` 를 붙이지 않는다. - Batch 3 설계 문서 완료: native router concurrent-write e2e 시나리오를 `docs/sharding/ROUTER-GAP-ANALYSIS.ko.md` 에 기록했다. write stream 중 online CDC, write-block `ReadyForQuery`, routing update, checksum/key ownership, PK 없는 target UPDATE/DELETE, abort cleanup 을 한 live gate 로 검증한다. -- 아직 남은 범위: Promote phase 의 precondition/fence 강화, source PDB/resource 삭제 정책, - spec shard model 의 named-list 전환은 P-B/P-C 범위다. source fence/관측 제외 전에 target 에 동일 - `shard-id` 를 붙이면 split-brain 신호가 될 수 있으므로, 이후 destructive cleanup 은 별도 정책과 - live chaos 검증 뒤에 진행한다. +- 아직 남은 범위: Promote phase 의 target readiness/fence 추가 강화, source PDB/resource 삭제 정책, + spec shard model 의 named-list 전환은 P-B/P-C 범위다. destructive cleanup 은 별도 정책과 live chaos + 검증 뒤에 진행한다. - 라이브 검증 전 주의: `cdc-abort` 는 `DROP SUBSCRIPTION IF EXISTS` 로 원격 replication slot 정리까지 시도한다. source 접속 불가 상황에서는 cleanup Job 이 실패하고 `AbortCleanup=False` 로 남는 것이 현재 의도한 안전 동작이다. source-down 상태에서도 target subscription 만 강제 제거하는 fallback 은 live drill @@ -375,8 +378,9 @@ kubectl -n postgres-operator-system set env deploy/postgres-operator-controller- `go test -count=1 ./internal/controller --ginkgo.focus="adds active named reshard targets"`, `go test -count=1 ./api/v1alpha1 ./internal/controller -run "TestShardSplitJob|TestShardSplitJob_nextPhase"`, `go test -count=1 ./internal/controller --ginkgo.focus="Promote phase"`, + `go test -count=1 ./internal/controller --ginkgo.focus="source active"`, `go test -count=1 ./internal/controller`, - `go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./internal/controller`. + `go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./api/v1alpha1 ./internal/controller`. --- diff --git a/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md b/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md index 1874b76..227b0a4 100644 --- a/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md +++ b/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md @@ -74,6 +74,16 @@ Kubernetes selector 불변성 문제를 피하고, `POSTGRES_RESHARD_TARGET` 기 source StatefulSet/Service/PVC/PDB 삭제, source data retention 정책, promote 중 target/source pod kill 을 포함한 live chaos 검증은 아직 별도 잔여 범위다. 즉 P-B 전체 완료가 아니라 P-B 의 adopt/idempotency slice 완료다. +### P-B.3 Promote source-active gate (2026-06-29) + +`Promote` phase 가 target adopt 를 수행하기 전에 matching `ShardRange` 의 active shard set 을 확인한다. +모든 `spec.sources[]` 가 active set 에서 빠져 있고 모든 target shard ID 가 active set 에 있어야 한다. +source 가 아직 active 하거나 target 이 아직 active topology 로 들어오지 않았으면 `Promote` phase 를 유지한 채 +requeue 하며 target StatefulSet/template/live Pod 에 `shard-id` 를 붙이지 않는다. + +이 게이트는 source/target 이 같은 운영 identity 로 동시에 관측되는 #220-class 중간 상태를 줄이는 1차 fence 다. +아직 target Pod readiness gate, source PDB/PVC/Service 삭제 정책, live chaos 검증은 별도 잔여 범위다. + 이번 hardening batch 에서 selector 사용처를 다음처럼 분리했다. - **그대로 둔 것**: `ShardStatefulSetName`, `ShardServiceName`, PDB/TLS/PVC resize, source shard DNS, diff --git a/docs/superpowers/plans/2026-06-28-reshard-hardening.md b/docs/superpowers/plans/2026-06-28-reshard-hardening.md index b29cdc0..d08d5ad 100644 --- a/docs/superpowers/plans/2026-06-28-reshard-hardening.md +++ b/docs/superpowers/plans/2026-06-28-reshard-hardening.md @@ -269,6 +269,34 @@ go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard- Status as of 2026-06-29: all checkpoint commands pass on Windows Go 1.26.4. This closes the first Promote/adopt slice only. Remaining promotion work is precondition/fence hardening, source PDB/resource cleanup policy, named shard spec-model migration, and live chaos/e2e validation. +## Batch 2.10: Promote Source-Active Precondition Gate + +**Files:** +- Modify: `internal/controller/shardsplitjob_controller.go` +- Modify: `internal/controller/shardsplitjob_writeblock_test.go` + +- [x] Gate `Promote` on ShardRange topology. + - Promote now requires all `spec.sources[]` to be absent from the matching ShardRange active set. + - Promote also requires all target shard IDs to be present in that ShardRange active set. + +- [x] Requeue instead of failing while source is still active. + - This keeps `status.phase=Promote` and avoids attaching `shard-id` to target resources before routing/fence preconditions are satisfied. + - Target StatefulSet/template/live Pod labels remain unchanged while the gate is closed. + +- [x] Cover both directions with envtest. + - Active target ShardRange: Reconcile adopts target identity and completes. + - Active source ShardRange: Reconcile requeues, keeps phase at `Promote`, and does not patch target labels. + +**Checkpoint Verification:** + +```powershell +go test -count=1 ./internal/controller --ginkgo.focus="Promote phase" +go test -count=1 ./internal/controller +go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./api/v1alpha1 ./internal/controller +``` + +Status as of 2026-06-29: all checkpoint commands pass on Windows Go 1.26.4. Remaining promotion work is target readiness/fence hardening beyond ShardRange topology, source PDB/resource cleanup policy, named shard spec-model migration, and live chaos/e2e validation. + ## Batch 3: Native Router Concurrent-Write E2E Design **Files:** diff --git a/internal/controller/shardsplitjob_controller.go b/internal/controller/shardsplitjob_controller.go index 9a7033e..f5c9814 100644 --- a/internal/controller/shardsplitjob_controller.go +++ b/internal/controller/shardsplitjob_controller.go @@ -183,6 +183,14 @@ func (r *ShardSplitJobReconciler) Reconcile(ctx context.Context, req ctrl.Reques } if ssj.Status.Phase == postgresv1alpha1.ShardSplitPhasePromote { + ready, reason, err := r.promotePreconditionsMet(ctx, &ssj) + if err != nil { + return ctrl.Result{}, err + } + if !ready { + logger.Info("ShardSplitJob Promote precondition not met", "reason", reason) + return ctrl.Result{RequeueAfter: 3 * time.Second}, nil + } if err := r.reconcilePromote(ctx, &ssj); err != nil { ssj.Status.Phase = postgresv1alpha1.ShardSplitPhaseFailed ssj.Status.FailureReason = err.Error() @@ -278,6 +286,52 @@ func (r *ShardSplitJobReconciler) reconcilePromote(ctx context.Context, ssj *pos return nil } +func (r *ShardSplitJobReconciler) promotePreconditionsMet(ctx context.Context, ssj *postgresv1alpha1.ShardSplitJob) (bool, string, error) { + active, err := r.activeShardRangeIDs(ctx, ssj) + if err != nil { + return false, "", err + } + for _, source := range ssj.Spec.Sources { + if _, ok := active[source]; ok { + return false, fmt.Sprintf("source shard %q is still active in ShardRange", source), nil + } + } + for i := range ssj.Spec.Targets { + shardID := ssj.Spec.Targets[i].ShardID + if _, ok := active[shardID]; !ok { + return false, fmt.Sprintf("target shard %q is not active in ShardRange", shardID), nil + } + } + return true, "", nil +} + +func (r *ShardSplitJobReconciler) activeShardRangeIDs(ctx context.Context, ssj *postgresv1alpha1.ShardSplitJob) (map[string]struct{}, error) { + var list postgresv1alpha1.ShardRangeList + if err := r.List(ctx, &list, client.InNamespace(ssj.Namespace)); err != nil { + return nil, fmt.Errorf("list ShardRange for promote precondition: %w", err) + } + active := map[string]struct{}{} + matched := false + for i := range list.Items { + sr := &list.Items[i] + if sr.Spec.Cluster != ssj.Spec.Cluster || sr.Spec.Keyspace != ssj.Spec.Keyspace { + continue + } + matched = true + for j := range sr.Spec.Ranges { + shardID := sr.Spec.Ranges[j].Shard + if shardID == "" { + continue + } + active[shardID] = struct{}{} + } + } + if !matched { + return nil, fmt.Errorf("no ShardRange for cluster=%s keyspace=%s", ssj.Spec.Cluster, ssj.Spec.Keyspace) + } + return active, nil +} + func (r *ShardSplitJobReconciler) adoptTargetShardIdentity(ctx context.Context, namespace, cluster, shardID string) error { var sts appsv1.StatefulSet if err := r.Get(ctx, client.ObjectKey{Namespace: namespace, Name: TargetShardStatefulSetName(cluster, shardID)}, &sts); err != nil { diff --git a/internal/controller/shardsplitjob_writeblock_test.go b/internal/controller/shardsplitjob_writeblock_test.go index d3b5dc3..94c04a6 100644 --- a/internal/controller/shardsplitjob_writeblock_test.go +++ b/internal/controller/shardsplitjob_writeblock_test.go @@ -109,7 +109,17 @@ var _ = Describe("ShardSplitJob Cutover write-block", func() { It("Promote phase 가 target STS 와 live Pod 에 shard-id 를 adopt label 로 붙인다", func() { clusterName := fmt.Sprintf("rsdprom-%d", GinkgoRandomSeed()) + keyspace := "default" targetShard := "t1" + Expect(k8sClient.Create(ctx, &postgresv1alpha1.ShardRange{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-sr", Namespace: ns}, + Spec: postgresv1alpha1.ShardRangeSpec{ + Cluster: clusterName, + Keyspace: keyspace, + Vindex: postgresv1alpha1.VindexSpec{Type: postgresv1alpha1.VindexTypeHash, Column: "id", Function: "murmur3"}, + Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0xffffffff", Shard: targetShard}}, + }, + })).To(Succeed()) cluster := &postgresv1alpha1.PostgresCluster{ ObjectMeta: metav1.ObjectMeta{Name: clusterName, Namespace: ns}, Spec: postgresv1alpha1.PostgresClusterSpec{ @@ -139,17 +149,24 @@ var _ = Describe("ShardSplitJob Cutover write-block", func() { ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-ssj", Namespace: ns}, Spec: postgresv1alpha1.ShardSplitJobSpec{ Cluster: clusterName, - Keyspace: "default", + Keyspace: keyspace, Sources: []string{"shard-0"}, Targets: []postgresv1alpha1.ShardSplitTarget{{ ShardID: targetShard, Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0xffffffff", Shard: targetShard}}, }}, }, - Status: postgresv1alpha1.ShardSplitJobStatus{Phase: postgresv1alpha1.ShardSplitPhasePromote}, } + Expect(k8sClient.Create(ctx, ssj)).To(Succeed()) + ssj.Status.Phase = postgresv1alpha1.ShardSplitPhasePromote + Expect(k8sClient.Status().Update(ctx, ssj)).To(Succeed()) + r := &ShardSplitJobReconciler{Client: k8sClient, Scheme: scheme.Scheme} - Expect(r.reconcilePromote(ctx, ssj)).To(Succeed()) + res, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(ssj)}) + Expect(err).NotTo(HaveOccurred()) + Expect(res).To(Equal(ctrl.Result{})) + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(ssj), ssj)).To(Succeed()) + Expect(ssj.Status.Phase).To(Equal(postgresv1alpha1.ShardSplitPhaseCompleted)) var gotSTS appsv1.StatefulSet Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(sts), &gotSTS)).To(Succeed()) @@ -165,6 +182,79 @@ var _ = Describe("ShardSplitJob Cutover write-block", func() { Expect(gotPod.Labels[ShardIDLabelKey]).To(Equal(targetShard)) }) + It("Promote phase 가 ShardRange source active 중에는 target adopt 를 보류한다", func() { + clusterName := fmt.Sprintf("rsdpromgate-%d", GinkgoRandomSeed()) + keyspace := "default" + targetShard := "t1" + Expect(k8sClient.Create(ctx, &postgresv1alpha1.ShardRange{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-sr", Namespace: ns}, + Spec: postgresv1alpha1.ShardRangeSpec{ + Cluster: clusterName, + Keyspace: keyspace, + Vindex: postgresv1alpha1.VindexSpec{Type: postgresv1alpha1.VindexTypeHash, Column: "id", Function: "murmur3"}, + Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0xffffffff", Shard: "shard-0"}}, + }, + })).To(Succeed()) + + cluster := &postgresv1alpha1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName, Namespace: ns}, + Spec: postgresv1alpha1.PostgresClusterSpec{ + PostgresVersion: "18", + Shards: postgresv1alpha1.ShardsSpec{ + Storage: postgresv1alpha1.StorageSpec{Size: resource.MustParse("1Gi")}, + }, + }, + } + sts := buildTargetShardStatefulSet( + cluster, targetShard, "postgres:18", "18", + postgresv1alpha1.StorageSpec{Size: resource.MustParse("1Gi")}, corev1.ResourceRequirements{}, + TargetShardConfigMapName(clusterName, targetShard), "cfg", + ) + Expect(k8sClient.Create(ctx, sts)).To(Succeed()) + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: TargetShardStatefulSetName(clusterName, targetShard) + "-0", + Namespace: ns, + Labels: ReshardTargetSelectorLabels(clusterName, targetShard), + }, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: pgContainerName, Image: "postgres:18"}}}, + } + Expect(k8sClient.Create(ctx, pod)).To(Succeed()) + + ssj := &postgresv1alpha1.ShardSplitJob{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-ssj", Namespace: ns}, + Spec: postgresv1alpha1.ShardSplitJobSpec{ + Cluster: clusterName, + Keyspace: keyspace, + Sources: []string{"shard-0"}, + Targets: []postgresv1alpha1.ShardSplitTarget{{ + ShardID: targetShard, + Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0xffffffff", Shard: targetShard}}, + }}, + }, + } + Expect(k8sClient.Create(ctx, ssj)).To(Succeed()) + ssj.Status.Phase = postgresv1alpha1.ShardSplitPhasePromote + Expect(k8sClient.Status().Update(ctx, ssj)).To(Succeed()) + + r := &ShardSplitJobReconciler{Client: k8sClient, Scheme: scheme.Scheme} + res, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(ssj)}) + Expect(err).NotTo(HaveOccurred()) + Expect(res.RequeueAfter).NotTo(BeZero()) + + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(ssj), ssj)).To(Succeed()) + Expect(ssj.Status.Phase).To(Equal(postgresv1alpha1.ShardSplitPhasePromote)) + + var gotSTS appsv1.StatefulSet + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(sts), &gotSTS)).To(Succeed()) + Expect(gotSTS.Labels).NotTo(HaveKey(ShardIDLabelKey)) + Expect(gotSTS.Spec.Template.Labels).NotTo(HaveKey(ShardIDLabelKey)) + + var gotPod corev1.Pod + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(pod), &gotPod)).To(Succeed()) + Expect(gotPod.Labels).NotTo(HaveKey(ShardIDLabelKey)) + }) + It("online 모드 CDCCatchup: cdc-setup Job → write-block → cdc-finalize Job 순서", func() { clusterName := fmt.Sprintf("rsdcdc-%d", GinkgoRandomSeed()) keyspace := "default" From 052463d51b1bcda1dfda6f41c88acbf712164502 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Mon, 29 Jun 2026 01:56:13 +0900 Subject: [PATCH 096/109] fix(sharding): wait for ready target before promote adopt Require ShardSplitJob Promote to see a Running PodReady target pod before applying shard-id labels to target resources. Keep Promote requeueing without label mutation while the target is not Ready, and document the remaining source cleanup/model migration work. --- docs/WORK_HANDOFF.ko.md | 10 ++- ...rd-target-promotion-identity-transition.md | 12 +++ .../plans/2026-06-28-reshard-hardening.md | 29 +++++++ .../controller/shardsplitjob_controller.go | 38 ++++++++++ .../shardsplitjob_writeblock_test.go | 76 +++++++++++++++++++ 5 files changed, 162 insertions(+), 3 deletions(-) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index a6771ec..c1f7f87 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -360,13 +360,16 @@ kubectl -n postgres-operator-system set env deploy/postgres-operator-controller- active set 에서 빠지고 모든 target 이 active set 에 들어온 뒤에만 target adopt 를 수행한다. source 가 아직 active 이면 phase 를 `Promote` 로 유지하고 requeue 하며 target StatefulSet/template/live Pod 에 `shard-id` 를 붙이지 않는다. +- Batch 2.11 개발 완료: Promote 는 target Pod readiness 도 확인한다. target shard 별로 + `reshard-target=` selector 에 걸리는 Pod 가 최소 1개 있어야 하고, 그중 하나 이상이 + `phase=Running`, `PodReady=True` 여야 target adopt 를 수행한다. target 이 아직 not Ready 이면 phase 를 + `Promote` 로 유지하고 requeue 하며 label mutation 을 하지 않는다. - Batch 3 설계 문서 완료: native router concurrent-write e2e 시나리오를 `docs/sharding/ROUTER-GAP-ANALYSIS.ko.md` 에 기록했다. write stream 중 online CDC, write-block `ReadyForQuery`, routing update, checksum/key ownership, PK 없는 target UPDATE/DELETE, abort cleanup 을 한 live gate 로 검증한다. -- 아직 남은 범위: Promote phase 의 target readiness/fence 추가 강화, source PDB/resource 삭제 정책, - spec shard model 의 named-list 전환은 P-B/P-C 범위다. destructive cleanup 은 별도 정책과 live chaos - 검증 뒤에 진행한다. +- 아직 남은 범위: source PDB/resource 삭제 정책, spec shard model 의 named-list 전환은 P-B/P-C 범위다. + destructive cleanup 은 별도 정책과 live chaos 검증 뒤에 진행한다. - 라이브 검증 전 주의: `cdc-abort` 는 `DROP SUBSCRIPTION IF EXISTS` 로 원격 replication slot 정리까지 시도한다. source 접속 불가 상황에서는 cleanup Job 이 실패하고 `AbortCleanup=False` 로 남는 것이 현재 의도한 안전 동작이다. source-down 상태에서도 target subscription 만 강제 제거하는 fallback 은 live drill @@ -377,6 +380,7 @@ kubectl -n postgres-operator-system set env deploy/postgres-operator-controller- `go test -count=1 ./internal/controller -run TestAggregateNamedShardStatus_UsesReshardTargetLabel`, `go test -count=1 ./internal/controller --ginkgo.focus="adds active named reshard targets"`, `go test -count=1 ./api/v1alpha1 ./internal/controller -run "TestShardSplitJob|TestShardSplitJob_nextPhase"`, + `go test -count=1 ./internal/controller --ginkgo.focus="not Ready"`, `go test -count=1 ./internal/controller --ginkgo.focus="Promote phase"`, `go test -count=1 ./internal/controller --ginkgo.focus="source active"`, `go test -count=1 ./internal/controller`, diff --git a/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md b/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md index 227b0a4..6ed0a9b 100644 --- a/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md +++ b/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md @@ -84,6 +84,18 @@ requeue 하며 target StatefulSet/template/live Pod 에 `shard-id` 를 붙이지 이 게이트는 source/target 이 같은 운영 identity 로 동시에 관측되는 #220-class 중간 상태를 줄이는 1차 fence 다. 아직 target Pod readiness gate, source PDB/PVC/Service 삭제 정책, live chaos 검증은 별도 잔여 범위다. +### P-B.4 Promote target readiness gate (2026-06-29) + +`Promote` phase 는 target shard identity adopt 전에 target Pod readiness 도 확인한다. target shard 는 +`postgres.keiailab.io/reshard-target=` selector 로 조회되는 Pod 를 최소 1개 가져야 하며, 그중 하나 이상이 +`phase=Running` 이고 `PodReady=True` 여야 한다. + +target 이 active ShardRange 에 들어왔더라도 Pod 가 아직 Ready 가 아니면 `Promote` phase 를 유지한 채 requeue 하고 +target StatefulSet/template/live Pod 에 `shard-id` 를 붙이지 않는다. 이로써 routing flip 직후 target Pod 가 +부팅 중인 동안 status/failover 관측 identity 를 너무 일찍 전환하지 않는다. + +source PDB/PVC/Service 삭제 정책과 live chaos 검증은 여전히 별도 잔여 범위다. + 이번 hardening batch 에서 selector 사용처를 다음처럼 분리했다. - **그대로 둔 것**: `ShardStatefulSetName`, `ShardServiceName`, PDB/TLS/PVC resize, source shard DNS, diff --git a/docs/superpowers/plans/2026-06-28-reshard-hardening.md b/docs/superpowers/plans/2026-06-28-reshard-hardening.md index d08d5ad..66a811f 100644 --- a/docs/superpowers/plans/2026-06-28-reshard-hardening.md +++ b/docs/superpowers/plans/2026-06-28-reshard-hardening.md @@ -297,6 +297,35 @@ go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard- Status as of 2026-06-29: all checkpoint commands pass on Windows Go 1.26.4. Remaining promotion work is target readiness/fence hardening beyond ShardRange topology, source PDB/resource cleanup policy, named shard spec-model migration, and live chaos/e2e validation. +## Batch 2.11: Promote Target Readiness Gate + +**Files:** +- Modify: `internal/controller/shardsplitjob_controller.go` +- Modify: `internal/controller/shardsplitjob_writeblock_test.go` + +- [x] Require a Ready target Pod before target identity adopt. + - The target shard must have at least one Pod selected by `postgres.keiailab.io/reshard-target=`. + - At least one selected Pod must be `phase=Running` and `PodReady=True`. + +- [x] Requeue without label mutation while target is not Ready. + - `status.phase` remains `Promote`. + - Target StatefulSet/template/live Pod labels remain unchanged. + +- [x] Cover Ready and not-Ready paths with envtest. + - Ready target: Reconcile adopts target identity and completes. + - Not Ready target: Reconcile requeues and does not patch target labels. + +**Checkpoint Verification:** + +```powershell +go test -count=1 ./internal/controller --ginkgo.focus="not Ready" +go test -count=1 ./internal/controller --ginkgo.focus="Promote phase" +go test -count=1 ./internal/controller +go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./api/v1alpha1 ./internal/controller +``` + +Status as of 2026-06-29: all checkpoint commands pass on Windows Go 1.26.4. Remaining promotion work is source PDB/resource cleanup policy, named shard spec-model migration, and live chaos/e2e validation. + ## Batch 3: Native Router Concurrent-Write E2E Design **Files:** diff --git a/internal/controller/shardsplitjob_controller.go b/internal/controller/shardsplitjob_controller.go index f5c9814..b8095eb 100644 --- a/internal/controller/shardsplitjob_controller.go +++ b/internal/controller/shardsplitjob_controller.go @@ -301,6 +301,13 @@ func (r *ShardSplitJobReconciler) promotePreconditionsMet(ctx context.Context, s if _, ok := active[shardID]; !ok { return false, fmt.Sprintf("target shard %q is not active in ShardRange", shardID), nil } + ready, reason, err := r.targetShardReadyForPromote(ctx, ssj.Namespace, ssj.Spec.Cluster, shardID) + if err != nil { + return false, "", err + } + if !ready { + return false, reason, nil + } } return true, "", nil } @@ -332,6 +339,37 @@ func (r *ShardSplitJobReconciler) activeShardRangeIDs(ctx context.Context, ssj * return active, nil } +func (r *ShardSplitJobReconciler) targetShardReadyForPromote(ctx context.Context, namespace, cluster, shardID string) (bool, string, error) { + var pods corev1.PodList + if err := r.List(ctx, &pods, + client.InNamespace(namespace), + client.MatchingLabels(ReshardTargetSelectorLabels(cluster, shardID)), + ); err != nil { + return false, "", fmt.Errorf("list target pods for promote precondition: %w", err) + } + if len(pods.Items) == 0 { + return false, fmt.Sprintf("target shard %q has no pods", shardID), nil + } + for i := range pods.Items { + if podReadyForPromote(&pods.Items[i]) { + return true, "", nil + } + } + return false, fmt.Sprintf("target shard %q has no Ready pods", shardID), nil +} + +func podReadyForPromote(pod *corev1.Pod) bool { + if pod == nil || pod.DeletionTimestamp != nil || pod.Status.Phase != corev1.PodRunning { + return false + } + for i := range pod.Status.Conditions { + if pod.Status.Conditions[i].Type == corev1.PodReady { + return pod.Status.Conditions[i].Status == corev1.ConditionTrue + } + } + return false +} + func (r *ShardSplitJobReconciler) adoptTargetShardIdentity(ctx context.Context, namespace, cluster, shardID string) error { var sts appsv1.StatefulSet if err := r.Get(ctx, client.ObjectKey{Namespace: namespace, Name: TargetShardStatefulSetName(cluster, shardID)}, &sts); err != nil { diff --git a/internal/controller/shardsplitjob_writeblock_test.go b/internal/controller/shardsplitjob_writeblock_test.go index 94c04a6..8a96a85 100644 --- a/internal/controller/shardsplitjob_writeblock_test.go +++ b/internal/controller/shardsplitjob_writeblock_test.go @@ -50,6 +50,13 @@ var _ = Describe("ShardSplitJob Cutover write-block", func() { {Type: batchv1.JobFailed, Status: corev1.ConditionTrue, Reason: "BackoffLimitExceeded"}, } } + markPodReady := func(pod *corev1.Pod) { + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(pod), pod)).To(Succeed()) + pod.Status.Phase = corev1.PodRunning + pod.Status.Conditions = []corev1.PodCondition{{Type: corev1.PodReady, Status: corev1.ConditionTrue}} + pod.Status.ContainerStatuses = []corev1.ContainerStatus{{Name: pgContainerName, Ready: true, Image: "postgres:18", ImageID: "postgres:18"}} + Expect(k8sClient.Status().Update(ctx, pod)).To(Succeed()) + } It("Cutover 가 write-block 을 켜고 RoutingUpdate 가 ranges flip 과 함께 끈다", func() { clusterName := fmt.Sprintf("rsdwb-%d", GinkgoRandomSeed()) @@ -144,6 +151,7 @@ var _ = Describe("ShardSplitJob Cutover write-block", func() { Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: pgContainerName, Image: "postgres:18"}}}, } Expect(k8sClient.Create(ctx, pod)).To(Succeed()) + markPodReady(pod) ssj := &postgresv1alpha1.ShardSplitJob{ ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-ssj", Namespace: ns}, @@ -255,6 +263,74 @@ var _ = Describe("ShardSplitJob Cutover write-block", func() { Expect(gotPod.Labels).NotTo(HaveKey(ShardIDLabelKey)) }) + It("Promote phase 가 target Pod not Ready 중에는 target adopt 를 보류한다", func() { + clusterName := fmt.Sprintf("rsdpromready-%d", GinkgoRandomSeed()) + keyspace := "default" + targetShard := "t1" + Expect(k8sClient.Create(ctx, &postgresv1alpha1.ShardRange{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-sr", Namespace: ns}, + Spec: postgresv1alpha1.ShardRangeSpec{ + Cluster: clusterName, + Keyspace: keyspace, + Vindex: postgresv1alpha1.VindexSpec{Type: postgresv1alpha1.VindexTypeHash, Column: "id", Function: "murmur3"}, + Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0xffffffff", Shard: targetShard}}, + }, + })).To(Succeed()) + cluster := &postgresv1alpha1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName, Namespace: ns}, + Spec: postgresv1alpha1.PostgresClusterSpec{ + PostgresVersion: "18", + Shards: postgresv1alpha1.ShardsSpec{ + Storage: postgresv1alpha1.StorageSpec{Size: resource.MustParse("1Gi")}, + }, + }, + } + sts := buildTargetShardStatefulSet( + cluster, targetShard, "postgres:18", "18", + postgresv1alpha1.StorageSpec{Size: resource.MustParse("1Gi")}, corev1.ResourceRequirements{}, + TargetShardConfigMapName(clusterName, targetShard), "cfg", + ) + Expect(k8sClient.Create(ctx, sts)).To(Succeed()) + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: TargetShardStatefulSetName(clusterName, targetShard) + "-0", + Namespace: ns, + Labels: ReshardTargetSelectorLabels(clusterName, targetShard), + }, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: pgContainerName, Image: "postgres:18"}}}, + } + Expect(k8sClient.Create(ctx, pod)).To(Succeed()) + + ssj := &postgresv1alpha1.ShardSplitJob{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-ssj", Namespace: ns}, + Spec: postgresv1alpha1.ShardSplitJobSpec{ + Cluster: clusterName, + Keyspace: keyspace, + Sources: []string{"shard-0"}, + Targets: []postgresv1alpha1.ShardSplitTarget{{ + ShardID: targetShard, + Ranges: []postgresv1alpha1.ShardRangeEntry{{Lo: "0x00000000", Hi: "0xffffffff", Shard: targetShard}}, + }}, + }, + } + Expect(k8sClient.Create(ctx, ssj)).To(Succeed()) + ssj.Status.Phase = postgresv1alpha1.ShardSplitPhasePromote + Expect(k8sClient.Status().Update(ctx, ssj)).To(Succeed()) + + r := &ShardSplitJobReconciler{Client: k8sClient, Scheme: scheme.Scheme} + res, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(ssj)}) + Expect(err).NotTo(HaveOccurred()) + Expect(res.RequeueAfter).NotTo(BeZero()) + + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(ssj), ssj)).To(Succeed()) + Expect(ssj.Status.Phase).To(Equal(postgresv1alpha1.ShardSplitPhasePromote)) + + var gotSTS appsv1.StatefulSet + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(sts), &gotSTS)).To(Succeed()) + Expect(gotSTS.Labels).NotTo(HaveKey(ShardIDLabelKey)) + Expect(gotSTS.Spec.Template.Labels).NotTo(HaveKey(ShardIDLabelKey)) + }) + It("online 모드 CDCCatchup: cdc-setup Job → write-block → cdc-finalize Job 순서", func() { clusterName := fmt.Sprintf("rsdcdc-%d", GinkgoRandomSeed()) keyspace := "default" From dc75c0bcb47eade9eaf83230ca81ec0df8a079d3 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Mon, 29 Jun 2026 02:02:25 +0900 Subject: [PATCH 097/109] test(sharding): pin retained source resources after promote Assert inactive ordinal source resources are retained by default: StatefulSet scales to zero while Service, pre-existing PDB, and PVC remain. Document source deletion as a future explicit opt-in design rather than automatic Promote cleanup. --- docs/WORK_HANDOFF.ko.md | 7 +++-- ...rd-target-promotion-identity-transition.md | 10 ++++++ .../plans/2026-06-28-reshard-hardening.md | 27 ++++++++++++++++ .../postgrescluster_controller_test.go | 31 +++++++++++++++++++ 4 files changed, 73 insertions(+), 2 deletions(-) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index c1f7f87..b82f41d 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -364,12 +364,15 @@ kubectl -n postgres-operator-system set env deploy/postgres-operator-controller- `reshard-target=` selector 에 걸리는 Pod 가 최소 1개 있어야 하고, 그중 하나 이상이 `phase=Running`, `PodReady=True` 여야 target adopt 를 수행한다. target 이 아직 not Ready 이면 phase 를 `Promote` 로 유지하고 requeue 하며 label mutation 을 하지 않는다. +- Batch 2.12 정책 고정: source resource cleanup 기본값은 retain-by-default 다. inactive ordinal source 는 + StatefulSet replicas=0 과 status 관측 제외까지만 수행하고, source Service, pre-existing PDB, PVC 는 자동 + 삭제하지 않는다. destructive source deletion 은 향후 별도 opt-in 정책과 live drill 뒤에만 다룬다. - Batch 3 설계 문서 완료: native router concurrent-write e2e 시나리오를 `docs/sharding/ROUTER-GAP-ANALYSIS.ko.md` 에 기록했다. write stream 중 online CDC, write-block `ReadyForQuery`, routing update, checksum/key ownership, PK 없는 target UPDATE/DELETE, abort cleanup 을 한 live gate 로 검증한다. -- 아직 남은 범위: source PDB/resource 삭제 정책, spec shard model 의 named-list 전환은 P-B/P-C 범위다. - destructive cleanup 은 별도 정책과 live chaos 검증 뒤에 진행한다. +- 아직 남은 범위: spec shard model 의 named-list 전환은 P-C 범위다. destructive source deletion 은 기본 + 동작이 아니라 향후 opt-in 정책으로만 검토한다. live chaos/e2e 검증은 여전히 필요하다. - 라이브 검증 전 주의: `cdc-abort` 는 `DROP SUBSCRIPTION IF EXISTS` 로 원격 replication slot 정리까지 시도한다. source 접속 불가 상황에서는 cleanup Job 이 실패하고 `AbortCleanup=False` 로 남는 것이 현재 의도한 안전 동작이다. source-down 상태에서도 target subscription 만 강제 제거하는 fallback 은 live drill diff --git a/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md b/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md index 6ed0a9b..05c8f0e 100644 --- a/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md +++ b/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md @@ -96,6 +96,16 @@ target StatefulSet/template/live Pod 에 `shard-id` 를 붙이지 않는다. 이 source PDB/PVC/Service 삭제 정책과 live chaos 검증은 여전히 별도 잔여 범위다. +### P-B.5 source resource retention policy (2026-06-29) + +source resource cleanup 의 기본 정책은 **retain by default** 로 고정한다. reshard 이후 inactive ordinal source 는 +StatefulSet replicas 를 0 으로 낮추고 status 관측에서 제외하지만, source Service, pre-existing PDB, PVC 는 +자동 삭제하지 않는다. envtest 는 이 보존 정책을 회귀로 고정한다. + +이 결정은 자동 삭제가 rollback/debug 데이터와 PVC ownership 을 잃게 만드는 destructive operation 이기 때문이다. +향후 source 삭제가 필요하면 별도 opt-in 정책 필드, finalizer 순서, PVC retention semantics, live chaos drill 을 +포함한 별도 설계로 다룬다. 기본 GA 경로에서는 source resource 삭제가 자동으로 일어나지 않는다. + 이번 hardening batch 에서 selector 사용처를 다음처럼 분리했다. - **그대로 둔 것**: `ShardStatefulSetName`, `ShardServiceName`, PDB/TLS/PVC resize, source shard DNS, diff --git a/docs/superpowers/plans/2026-06-28-reshard-hardening.md b/docs/superpowers/plans/2026-06-28-reshard-hardening.md index 66a811f..cfc9e9e 100644 --- a/docs/superpowers/plans/2026-06-28-reshard-hardening.md +++ b/docs/superpowers/plans/2026-06-28-reshard-hardening.md @@ -326,6 +326,33 @@ go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard- Status as of 2026-06-29: all checkpoint commands pass on Windows Go 1.26.4. Remaining promotion work is source PDB/resource cleanup policy, named shard spec-model migration, and live chaos/e2e validation. +## Batch 2.12: Source Resource Retention Policy + +**Files:** +- Modify: `internal/controller/postgrescluster_controller_test.go` +- Modify: `docs/WORK_HANDOFF.ko.md` +- Modify: `docs/kb/adr/0029-reshard-target-promotion-identity-transition.md` + +- [x] Pin the conservative default: retain source resources. + - Inactive ordinal source StatefulSet is scaled to 0. + - Source Service remains. + - Pre-existing source PDB remains. + - Source PVC remains. + +- [x] Keep destructive cleanup out of automatic Promote. + - No automatic deletion of source StatefulSet/Service/PVC/PDB is performed by default. + - Future deletion must be explicit opt-in and live-drill validated. + +**Checkpoint Verification:** + +```powershell +go test -count=1 ./internal/controller --ginkgo.focus="adds active named reshard targets" +go test -count=1 ./internal/controller +go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./api/v1alpha1 ./internal/controller +``` + +Status as of 2026-06-29: all checkpoint commands pass on Windows Go 1.26.4. Remaining promotion work is named shard spec-model migration and live chaos/e2e validation. Destructive source deletion remains a future opt-in design, not a default GA behavior. + ## Batch 3: Native Router Concurrent-Write E2E Design **Files:** diff --git a/internal/controller/postgrescluster_controller_test.go b/internal/controller/postgrescluster_controller_test.go index 3f231f2..a0347cc 100644 --- a/internal/controller/postgrescluster_controller_test.go +++ b/internal/controller/postgrescluster_controller_test.go @@ -17,6 +17,7 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/api/resource" @@ -262,6 +263,21 @@ var _ = Describe("PostgresClusterReconciler — RFC 0001 spec", func() { }, }, } + Expect(k8sClient.Create(ctx, BuildShardPDB(cluster, 0, 2))).To(Succeed()) + sourcePVC := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("data-%s-0", ShardStatefulSetName(clusterName, 0)), + Namespace: namespace, + Labels: SelectorLabels(clusterName, "shard", 0), + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("1Gi")}, + }, + }, + } + Expect(k8sClient.Create(ctx, sourcePVC)).To(Succeed()) Expect(k8sClient.Create(ctx, cluster)).To(Succeed()) Eventually(func(g Gomega) { @@ -293,6 +309,21 @@ var _ = Describe("PostgresClusterReconciler — RFC 0001 spec", func() { g.Expect(sourceSTS.Spec.Replicas).NotTo(BeNil()) g.Expect(*sourceSTS.Spec.Replicas).To(Equal(int32(0)), "inactive source ordinal STS must scale to zero") + var sourceSvc corev1.Service + g.Expect(k8sClient.Get(ctx, types.NamespacedName{ + Namespace: namespace, Name: ShardServiceName(clusterName, 0), + }, &sourceSvc)).To(Succeed(), "inactive source Service is retained for conservative rollback/debug") + + var sourcePDB policyv1.PodDisruptionBudget + g.Expect(k8sClient.Get(ctx, types.NamespacedName{ + Namespace: namespace, Name: ShardPDBName(clusterName, 0), + }, &sourcePDB)).To(Succeed(), "pre-existing source PDB is retained by default") + + var retainedPVC corev1.PersistentVolumeClaim + g.Expect(k8sClient.Get(ctx, types.NamespacedName{ + Namespace: namespace, Name: sourcePVC.Name, + }, &retainedPVC)).To(Succeed(), "source PVC is retained by default") + var targetSTS appsv1.StatefulSet g.Expect(k8sClient.Get(ctx, types.NamespacedName{ Namespace: namespace, Name: TargetShardStatefulSetName(clusterName, targetShard), From 8ba9e259ccc20c2b8c721a649da01a1257fee7a6 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Mon, 29 Jun 2026 02:04:54 +0900 Subject: [PATCH 098/109] docs(sharding): settle named topology on shardrange Document ShardRange as the active named-shard topology source of truth and avoid adding a duplicate PostgresCluster named-list field. Update handoff and hardening plan so the remaining resource-saving-mode gap is live chaos/e2e validation. --- docs/WORK_HANDOFF.ko.md | 8 +++- ...rd-target-promotion-identity-transition.md | 17 +++++++-- .../plans/2026-06-28-reshard-hardening.md | 38 ++++++++++++++++--- 3 files changed, 53 insertions(+), 10 deletions(-) diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index b82f41d..09e5c28 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -367,12 +367,16 @@ kubectl -n postgres-operator-system set env deploy/postgres-operator-controller- - Batch 2.12 정책 고정: source resource cleanup 기본값은 retain-by-default 다. inactive ordinal source 는 StatefulSet replicas=0 과 status 관측 제외까지만 수행하고, source Service, pre-existing PDB, PVC 는 자동 삭제하지 않는다. destructive source deletion 은 향후 별도 opt-in 정책과 live drill 뒤에만 다룬다. +- Batch 2.13 설계 결정 완료: named shard topology 는 `ShardRange` 를 SSOT 로 유지한다. + `PostgresCluster.spec.shards.initialCount` 는 bootstrap ordinal seed count 로 남기고, native cluster 에 + `ShardRange` 가 존재하면 active topology 는 `ShardRange.spec.ranges[].shard` 의 union 에서 온다. + 별도 `spec.shards.named[]` 는 추가하지 않는다. - Batch 3 설계 문서 완료: native router concurrent-write e2e 시나리오를 `docs/sharding/ROUTER-GAP-ANALYSIS.ko.md` 에 기록했다. write stream 중 online CDC, write-block `ReadyForQuery`, routing update, checksum/key ownership, PK 없는 target UPDATE/DELETE, abort cleanup 을 한 live gate 로 검증한다. -- 아직 남은 범위: spec shard model 의 named-list 전환은 P-C 범위다. destructive source deletion 은 기본 - 동작이 아니라 향후 opt-in 정책으로만 검토한다. live chaos/e2e 검증은 여전히 필요하다. +- 아직 남은 범위: destructive source deletion 은 기본 동작이 아니라 향후 opt-in 정책으로만 검토한다. + live chaos/e2e 검증은 여전히 필요하다. - 라이브 검증 전 주의: `cdc-abort` 는 `DROP SUBSCRIPTION IF EXISTS` 로 원격 replication slot 정리까지 시도한다. source 접속 불가 상황에서는 cleanup Job 이 실패하고 `AbortCleanup=False` 로 남는 것이 현재 의도한 안전 동작이다. source-down 상태에서도 target subscription 만 강제 제거하는 fallback 은 live drill diff --git a/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md b/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md index 05c8f0e..8125267 100644 --- a/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md +++ b/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md @@ -106,6 +106,16 @@ StatefulSet replicas 를 0 으로 낮추고 status 관측에서 제외하지만, 향후 source 삭제가 필요하면 별도 opt-in 정책 필드, finalizer 순서, PVC retention semantics, live chaos drill 을 포함한 별도 설계로 다룬다. 기본 GA 경로에서는 source resource 삭제가 자동으로 일어나지 않는다. +### P-C.1 named topology model decision (2026-06-29) + +`PostgresCluster.spec.shards` 에 별도 named shard list 를 추가하지 않는다. `initialCount` 는 bootstrap 시점의 +ordinal seed count 로 유지하고, native cluster 에 `ShardRange` 가 존재하는 순간 active topology 는 +`ShardRange.spec.ranges[].shard` 의 union 을 SSOT 로 삼는다. + +이 결정은 active named target status/resource/HA reconcile 이 이미 ShardRange active topology 를 사용하기 때문이다. +`spec.shards.named[]` 같은 두 번째 목록을 추가하면 ShardRange 와 drift 되는 두 개의 truth 가 생긴다. 향후 임의 +토폴로지 API 가 필요하면 ShardRange evolution 또는 ShardRange 에서 생성되는 read-only view 로 설계한다. + 이번 hardening batch 에서 selector 사용처를 다음처럼 분리했다. - **그대로 둔 것**: `ShardStatefulSetName`, `ShardServiceName`, PDB/TLS/PVC resize, source shard DNS, @@ -207,12 +217,12 @@ ShardSplitJob 에 **Promote phase**(또는 Cleanup 후 별 phase)를 추가, ope 병행 label + phase 별 증분 + 라이브 chaos 검증으로 위험 관리. - **fence 단계가 정합 핵심** — source 관측 제외와 target adopt 사이 race 가 있으면 #220 재현. 단일 reconcile authority + 멱등 + 라이브 chaos drill(승격 중 pod kill) 의무. -- spec 의 shard 모델(ordinal count → 명명 목록) 확장은 CRD 변경(마이그레이션 고려). +- named shard 모델은 ShardRange 를 SSOT 로 유지한다. PostgresCluster 에 별도 named-list 를 추가하지 않는다. ### 검증 의무 - envtest: Promote phase 가 target StatefulSet/template/live Pod 에 `shard-id` 를 부여하고 selector 는 `reshard-target` 으로 유지하는지 단언. -- 잔여 envtest: source label 제거 또는 source STS/PDB/PVC 회수 정책이 결정되면 순서·멱등 단언 추가. +- envtest: source resource retain-by-default 정책(Service/PDB/PVC 보존)을 단언. - 라이브 chaos: 승격 진행 중 operator/target pod kill → 재진입 수렴 + primary 단일성 유지 확인. ## Alternatives Considered @@ -230,7 +240,8 @@ ShardSplitJob 에 **Promote phase**(또는 Cleanup 후 별 phase)를 추가, ope `shard` label 하위호환 병행). envtest 회귀(기존 ordinal cluster 무영향). 2. **P-B**: ShardSplitJob Promote phase — fence + adopt + status 편입 + source decommission. P-B.2 target adopt slice 는 구현됨. source decommission 과 live chaos 는 잔여. -3. **P-C**: spec shard 모델 확장(명명 목록) + 마이그레이션. 라이브 chaos drill. +3. **P-C**: named topology 는 ShardRange SSOT 로 결정. PostgresCluster named-list CRD 추가는 하지 않음. + 남은 검증은 라이브 chaos drill. > 본 ADR 은 *설계 결정* 이며 구현은 위 P-A~P-C 로 분할한다. ADR-0027 P6 의 "신중한 ground-up > 설계" 요구를 충족한다(standards/principles.md §1 Think Before Coding). diff --git a/docs/superpowers/plans/2026-06-28-reshard-hardening.md b/docs/superpowers/plans/2026-06-28-reshard-hardening.md index cfc9e9e..11e8b5f 100644 --- a/docs/superpowers/plans/2026-06-28-reshard-hardening.md +++ b/docs/superpowers/plans/2026-06-28-reshard-hardening.md @@ -204,7 +204,7 @@ go test -count=1 ./internal/controller go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./internal/controller ``` -Status as of 2026-06-29: all checkpoint commands pass on Windows Go 1.26.4. This closes the source-observation part of P-C, but target replica scale-up/HA, explicit ShardSplitJob Promote phase, and named shard spec-model migration remain open. +Status as of 2026-06-29 before Batch 2.8: all checkpoint commands pass on Windows Go 1.26.4. This closes the source-observation part of P-C, but target replica scale-up/HA, explicit ShardSplitJob Promote phase, and named shard spec-model migration remain open. ## Batch 2.8: Active Named Target HA Scale-Up @@ -267,7 +267,7 @@ go test -count=1 ./internal/controller go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./internal/controller ``` -Status as of 2026-06-29: all checkpoint commands pass on Windows Go 1.26.4. This closes the first Promote/adopt slice only. Remaining promotion work is precondition/fence hardening, source PDB/resource cleanup policy, named shard spec-model migration, and live chaos/e2e validation. +Status as of 2026-06-29 before Batch 2.10: all checkpoint commands pass on Windows Go 1.26.4. This closes the first Promote/adopt slice only. Remaining promotion work is precondition/fence hardening, source PDB/resource cleanup policy, named shard spec-model migration, and live chaos/e2e validation. ## Batch 2.10: Promote Source-Active Precondition Gate @@ -295,7 +295,7 @@ go test -count=1 ./internal/controller go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./api/v1alpha1 ./internal/controller ``` -Status as of 2026-06-29: all checkpoint commands pass on Windows Go 1.26.4. Remaining promotion work is target readiness/fence hardening beyond ShardRange topology, source PDB/resource cleanup policy, named shard spec-model migration, and live chaos/e2e validation. +Status as of 2026-06-29 before Batch 2.11: all checkpoint commands pass on Windows Go 1.26.4. Remaining promotion work is target readiness/fence hardening beyond ShardRange topology, source PDB/resource cleanup policy, named shard spec-model migration, and live chaos/e2e validation. ## Batch 2.11: Promote Target Readiness Gate @@ -324,7 +324,7 @@ go test -count=1 ./internal/controller go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./api/v1alpha1 ./internal/controller ``` -Status as of 2026-06-29: all checkpoint commands pass on Windows Go 1.26.4. Remaining promotion work is source PDB/resource cleanup policy, named shard spec-model migration, and live chaos/e2e validation. +Status as of 2026-06-29 before Batch 2.12: all checkpoint commands pass on Windows Go 1.26.4. Remaining promotion work is source PDB/resource cleanup policy, named shard spec-model migration, and live chaos/e2e validation. ## Batch 2.12: Source Resource Retention Policy @@ -351,7 +351,35 @@ go test -count=1 ./internal/controller go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./api/v1alpha1 ./internal/controller ``` -Status as of 2026-06-29: all checkpoint commands pass on Windows Go 1.26.4. Remaining promotion work is named shard spec-model migration and live chaos/e2e validation. Destructive source deletion remains a future opt-in design, not a default GA behavior. +Status as of 2026-06-29 before Batch 2.13: all checkpoint commands pass on Windows Go 1.26.4. Remaining promotion work is named shard spec-model migration and live chaos/e2e validation. Destructive source deletion remains a future opt-in design, not a default GA behavior. + +## Batch 2.13: Named Shard Topology Model Decision + +**Files:** +- Modify: `docs/WORK_HANDOFF.ko.md` +- Modify: `docs/kb/adr/0029-reshard-target-promotion-identity-transition.md` +- Modify: `docs/superpowers/plans/2026-06-28-reshard-hardening.md` + +- [x] Keep `ShardRange` as the active topology SSOT. + - `PostgresCluster.spec.shards.initialCount` remains the bootstrap ordinal seed count. + - Once a native cluster has a ShardRange, active shard identity comes from `ShardRange.spec.ranges[].shard`. + - Named target resources/status/HA are reconciled from that active ShardRange topology. + +- [x] Do not add `spec.shards.named[]` in this line. + - A second named-list field would duplicate `ShardRange` and create drift risk. + - Future arbitrary topology APIs must be designed as ShardRange evolution or generated views, not as a competing source of truth. + +**Checkpoint Verification:** + +No new code is required for this decision. The behavior is already covered by: + +```powershell +go test -count=1 ./internal/controller --ginkgo.focus="adds active named reshard targets" +go test -count=1 ./internal/controller +go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./api/v1alpha1 ./internal/controller +``` + +Status as of 2026-06-29: all existing checkpoint commands pass on Windows Go 1.26.4. Remaining work that cannot be closed in resource-saving mode is live chaos/e2e validation. ## Batch 3: Native Router Concurrent-Write E2E Design From f45a490b652dc2a607dac3844cb01ebdbf1e87f7 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Mon, 29 Jun 2026 11:09:02 +0900 Subject: [PATCH 099/109] chore(test): add windows go test wrapper --- .gitignore | 1 + docs/WORK_HANDOFF.ko.md | 8 + .../plans/2026-06-29-windows-test-wrapper.md | 166 ++++++++++++++++ scripts/test-windows.ps1 | 185 ++++++++++++++++++ 4 files changed, 360 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-29-windows-test-wrapper.md create mode 100644 scripts/test-windows.ps1 diff --git a/.gitignore b/.gitignore index 71c8b0d..d7b252b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ Dockerfile.cross # Test binary, built with `go test -c` *.test +*.test.exe # Output of the go coverage tool, specifically when used with LiteIDE *.out diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index 09e5c28..28a5b24 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -392,6 +392,14 @@ kubectl -n postgres-operator-system set env deploy/postgres-operator-controller- `go test -count=1 ./internal/controller --ginkgo.focus="source active"`, `go test -count=1 ./internal/controller`, `go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./api/v1alpha1 ./internal/controller`. +- Windows 로컬 테스트는 `scripts/test-windows.ps1` 를 우선 사용한다. 기본은 Go test cache 를 살리고, + `-Fresh` 를 줄 때만 `-count=1` 을 붙인다. `GOTMPDIR` / `GOCACHE` 는 repo 밖 + `%LOCALAPPDATA%\keiailab\postgres-operator\...` 로 고정해 `*.test.exe` 가 workspace 에 남지 않게 한다. + 예: + + ```powershell + powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-windows.ps1 -Preset controller -GinkgoFocus "Promote phase" + ``` --- diff --git a/docs/superpowers/plans/2026-06-29-windows-test-wrapper.md b/docs/superpowers/plans/2026-06-29-windows-test-wrapper.md new file mode 100644 index 0000000..42af678 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-windows-test-wrapper.md @@ -0,0 +1,166 @@ +# Windows Test Wrapper Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Provide a Windows PowerShell test wrapper that keeps Go test temp/cache outputs out of the repository and reduces Windows security scanning friction. + +**Architecture:** Add one focused script under `scripts/` that normalizes `GOTMPDIR`, `GOCACHE`, Go binary discovery, optional envtest asset discovery, and common package presets. Add a lightweight PowerShell self-test mode so script behavior can be validated without running expensive Go tests. + +**Tech Stack:** PowerShell 5+/7, Go test, controller-runtime envtest assets, existing Makefile/package layout. + +--- + +### Task 1: Add Ignore Rule For Windows Go Test Binaries + +**Files:** +- Modify: `.gitignore` + +- [x] **Step 1: Write the failing check** + +Run: + +```powershell +Select-String -Path .gitignore -Pattern '^\*\.test\.exe$' +``` + +Expected: no match. + +- [x] **Step 2: Add ignore rule** + +Add: + +```gitignore +*.test.exe +``` + +near the existing Go test binary ignore section. + +- [x] **Step 3: Verify** + +Run: + +```powershell +Select-String -Path .gitignore -Pattern '^\*\.test\.exe$' +``` + +Expected: one match. + +### Task 2: Add Windows Test Wrapper Script + +**Files:** +- Create: `scripts/test-windows.ps1` + +- [x] **Step 1: Write initial script with self-test support** + +Create `scripts/test-windows.ps1` with: + +- parameters: `-Preset`, `-Package`, `-Run`, `-GinkgoFocus`, `-Fresh`, `-Race`, `-Timeout`, `-DryRun`, `-SelfTest` +- repo-root detection from the script location +- Windows Go discovery from `go` on PATH or `%LOCALAPPDATA%\Programs\go1.26.4\go\bin\go.exe` +- repo-external defaults: + - `GOTMPDIR=%LOCALAPPDATA%\keiailab\postgres-operator\go-tmp` + - `GOCACHE=%LOCALAPPDATA%\keiailab\postgres-operator\go-cache` +- optional `KUBEBUILDER_ASSETS` fallback from `bin\k8s` +- presets: + - `controller`: `./internal/controller` + - `sharding`: `./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./api/v1alpha1 ./internal/controller` + - `unit`: `./api/... ./internal/version/... ./internal/plugin/... ./internal/instance/fencing/... ./internal/instance/supervise/...` + - `all`: `./...` +- `-Fresh` adds `-count=1`; default omits it so Go cache can work. +- `-DryRun` prints the resolved command and environment paths without running tests. +- `-SelfTest` validates presets and path placement without invoking `go test`. + +- [x] **Step 2: RED check** + +Before creating the script, run: + +```powershell +Test-Path scripts\test-windows.ps1 +``` + +Expected: `False`. + +- [x] **Step 3: GREEN check** + +After creating the script, run: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-windows.ps1 -SelfTest +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-windows.ps1 -Preset controller -GinkgoFocus "Promote phase" -DryRun +``` + +Expected: both exit 0. Dry-run output shows `go test ./internal/controller --ginkgo.focus=Promote phase` and temp/cache paths outside the repository. + +### Task 3: Document Usage In Handoff + +**Files:** +- Modify: `docs/WORK_HANDOFF.ko.md` + +- [x] **Step 1: Add Windows wrapper usage note** + +Add a short note near the verification section: + +```markdown +- Windows 로컬 테스트는 `scripts/test-windows.ps1` 를 우선 사용한다. 기본은 Go test cache 를 살리고, + `-Fresh` 를 줄 때만 `-count=1` 을 붙인다. `GOTMPDIR`/`GOCACHE` 는 repo 밖 + `%LOCALAPPDATA%\keiailab\postgres-operator\...` 로 고정해 `*.test.exe` 가 workspace 에 남지 않게 한다. +``` + +- [x] **Step 2: Verify docs mention wrapper** + +Run: + +```powershell +Select-String -Path docs\WORK_HANDOFF.ko.md -Pattern 'test-windows.ps1' +``` + +Expected: one or more matches. + +### Task 4: Final Verification And Commit + +**Files:** +- `.gitignore` +- `scripts/test-windows.ps1` +- `docs/WORK_HANDOFF.ko.md` +- `docs/superpowers/plans/2026-06-29-windows-test-wrapper.md` + +- [x] **Step 1: Run script validation** + +Run: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-windows.ps1 -SelfTest +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-windows.ps1 -Preset sharding -DryRun +``` + +Expected: exit 0. + +- [x] **Step 2: Run a real focused test through wrapper** + +Run: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-windows.ps1 -Preset controller -GinkgoFocus "Promote phase" +``` + +Expected: `ok github.com/keiailab/postgres-operator/internal/controller`. + +- [x] **Step 3: Check repository cleanliness rules** + +Run: + +```powershell +git diff --check +git status --short --branch +``` + +Expected: no whitespace errors; only intended files modified. + +- [x] **Step 4: Commit** + +Run: + +```powershell +git add .gitignore scripts/test-windows.ps1 docs/WORK_HANDOFF.ko.md docs/superpowers/plans/2026-06-29-windows-test-wrapper.md +git commit -m "chore(test): add windows go test wrapper" +``` diff --git a/scripts/test-windows.ps1 b/scripts/test-windows.ps1 new file mode 100644 index 0000000..0b2a0b1 --- /dev/null +++ b/scripts/test-windows.ps1 @@ -0,0 +1,185 @@ +param( + [ValidateSet("controller", "sharding", "unit", "all")] + [string]$Preset = "controller", + + [string[]]$Package, + [string]$Run, + [string]$GinkgoFocus, + [switch]$Fresh, + [switch]$Race, + [string]$Timeout, + [switch]$DryRun, + [switch]$SelfTest +) + +Set-StrictMode -Version 2.0 +$ErrorActionPreference = "Stop" + +function Resolve-Go { + $cmd = Get-Command go -ErrorAction SilentlyContinue + if ($cmd -and $cmd.Source) { + return $cmd.Source + } + + $candidate = Join-Path $env:LOCALAPPDATA "Programs\go1.26.4\go\bin\go.exe" + if (Test-Path -LiteralPath $candidate) { + return $candidate + } + + throw "go executable not found. Add Go to PATH or install it at $candidate" +} + +function Ensure-Directory { + param([string]$Path) + New-Item -ItemType Directory -Force -Path $Path | Out-Null + return (Resolve-Path -LiteralPath $Path).Path +} + +function Test-UnderPath { + param( + [string]$Child, + [string]$Parent + ) + $childFull = [System.IO.Path]::GetFullPath($Child).TrimEnd('\') + $parentFull = [System.IO.Path]::GetFullPath($Parent).TrimEnd('\') + return $childFull.Equals($parentFull, [System.StringComparison]::OrdinalIgnoreCase) -or + $childFull.StartsWith($parentFull + '\', [System.StringComparison]::OrdinalIgnoreCase) +} + +function Preset-Packages { + param([string]$Name) + switch ($Name) { + "controller" { return @("./internal/controller") } + "sharding" { return @("./cmd/instance", "./internal/router", "./cmd/pg-router", "./cmd/reshard-copy-poc", "./api/v1alpha1", "./internal/controller") } + "unit" { return @("./api/...", "./internal/version/...", "./internal/plugin/...", "./internal/instance/fencing/...", "./internal/instance/supervise/...") } + "all" { return @("./...") } + default { throw "unknown preset: $Name" } + } +} + +function Find-EnvtestAssets { + param([string]$RepoRoot) + if ($env:KUBEBUILDER_ASSETS) { + return $env:KUBEBUILDER_ASSETS + } + + $k8sRoot = Join-Path $RepoRoot "bin\k8s" + if (-not (Test-Path -LiteralPath $k8sRoot)) { + return $null + } + + $apiServer = Get-ChildItem -Path $k8sRoot -Recurse -Filter "kube-apiserver.exe" -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $apiServer) { + $apiServer = Get-ChildItem -Path $k8sRoot -Recurse -Filter "kube-apiserver" -ErrorAction SilentlyContinue | Select-Object -First 1 + } + if (-not $apiServer) { + return $null + } + + return $apiServer.DirectoryName +} + +function Build-GoTestCommand { + param( + [string]$GoExe, + [string[]]$Packages, + [string]$Run, + [string]$GinkgoFocus, + [bool]$Fresh, + [bool]$Race, + [string]$Timeout + ) + + $args = @("test") + if ($Fresh) { + $args += "-count=1" + } + if ($Race) { + $args += "-race" + } + if ($Timeout) { + $args += "-timeout=$Timeout" + } + if ($Run) { + $args += "-run" + $args += $Run + } + $args += $Packages + if ($GinkgoFocus) { + $args += "--ginkgo.focus=$GinkgoFocus" + } + + return @{ + Exe = $GoExe + Args = $args + } +} + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = (Resolve-Path -LiteralPath (Join-Path $scriptDir "..")).Path +$goExe = Resolve-Go +$baseDir = Join-Path $env:LOCALAPPDATA "keiailab\postgres-operator" +$goTmpDir = Ensure-Directory (Join-Path $baseDir "go-tmp") +$goCacheDir = Ensure-Directory (Join-Path $baseDir "go-cache") + +if (Test-UnderPath -Child $goTmpDir -Parent $repoRoot) { + throw "GOTMPDIR must be outside the repository: $goTmpDir" +} +if (Test-UnderPath -Child $goCacheDir -Parent $repoRoot) { + throw "GOCACHE must be outside the repository: $goCacheDir" +} + +$env:GOTMPDIR = $goTmpDir +$env:GOCACHE = $goCacheDir +$envtestAssets = Find-EnvtestAssets -RepoRoot $repoRoot +if ($envtestAssets) { + $env:KUBEBUILDER_ASSETS = $envtestAssets +} + +$packages = if ($Package -and $Package.Count -gt 0) { @($Package) } else { @(Preset-Packages -Name $Preset) } +$command = Build-GoTestCommand -GoExe $goExe -Packages $packages -Run $Run -GinkgoFocus $GinkgoFocus -Fresh:$Fresh.IsPresent -Race:$Race.IsPresent -Timeout $Timeout + +if ($SelfTest) { + foreach ($name in @("controller", "sharding", "unit", "all")) { + $resolved = @(Preset-Packages -Name $name) + if (-not $resolved -or $resolved.Count -eq 0) { + throw "preset $name resolved to no packages" + } + } + if (Test-UnderPath -Child $env:GOTMPDIR -Parent $repoRoot) { + throw "self-test failed: GOTMPDIR is inside repo" + } + if (Test-UnderPath -Child $env:GOCACHE -Parent $repoRoot) { + throw "self-test failed: GOCACHE is inside repo" + } + Write-Host "self-test ok" + Write-Host "repo=$repoRoot" + Write-Host "go=$goExe" + Write-Host "GOTMPDIR=$env:GOTMPDIR" + Write-Host "GOCACHE=$env:GOCACHE" + if ($env:KUBEBUILDER_ASSETS) { + Write-Host "KUBEBUILDER_ASSETS=$env:KUBEBUILDER_ASSETS" + } + exit 0 +} + +Write-Host "repo=$repoRoot" +Write-Host "GOTMPDIR=$env:GOTMPDIR" +Write-Host "GOCACHE=$env:GOCACHE" +if ($env:KUBEBUILDER_ASSETS) { + Write-Host "KUBEBUILDER_ASSETS=$env:KUBEBUILDER_ASSETS" +} +Write-Host ("command={0} {1}" -f $command.Exe, ($command.Args -join " ")) + +if ($DryRun) { + exit 0 +} + +Push-Location $repoRoot +try { + & $command.Exe @($command.Args) + exit $LASTEXITCODE +} +finally { + Pop-Location +} From 538ca1e025edd2de87b93cd82dc2b7b135657e3e Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Mon, 29 Jun 2026 11:47:56 +0900 Subject: [PATCH 100/109] chore(test): enforce e2e resource cleanup policy --- Makefile | 20 ++++++++++--- docs/WORK_HANDOFF.ko.md | 29 +++++++++++++++---- .../plans/2026-06-28-reshard-hardening.md | 3 +- .../plans/2026-06-29-windows-test-wrapper.md | 4 +-- 4 files changed, 44 insertions(+), 12 deletions(-) diff --git a/Makefile b/Makefile index a7178ce..4602ab1 100644 --- a/Makefile +++ b/Makefile @@ -124,11 +124,23 @@ setup-test-e2e: ## Set up a Kind cluster for e2e tests if it does not exist # 빈 값(디폴트)은 전체 e2e 실행. CI 매트릭스가 본 변수를 채워 Pillar 단위 통과율을 # 추적한다(.github/workflows/ci.yml의 e2e job). PILLAR ?= +KEEP ?= +E2E_LABEL_FILTER = $(if $(PILLAR),-ginkgo.label-filter=$(PILLAR),) + +define run-e2e-and-cleanup + @status=0; \ + KIND=$(KIND) KIND_CLUSTER=$(KIND_CLUSTER) $(1) || status=$$?; \ + if [ "$(KEEP)" = "1" ]; then \ + echo "KEEP=1 set; preserving Kind cluster '$(KIND_CLUSTER)'."; \ + else \ + $(MAKE) cleanup-test-e2e || { cleanup_status=$$?; if [ $$status -eq 0 ]; then status=$$cleanup_status; fi; }; \ + fi; \ + exit $$status +endef .PHONY: test-e2e test-e2e: setup-test-e2e manifests generate fmt vet ## Run the e2e tests. Expected an isolated environment using Kind. Use PILLAR=p1 to narrow to one Pillar. - KIND=$(KIND) KIND_CLUSTER=$(KIND_CLUSTER) PILLAR=$(PILLAR) go test -tags=e2e ./test/e2e/ -v -ginkgo.v $(if $(PILLAR),-ginkgo.label-filter=$(PILLAR),) - $(MAKE) cleanup-test-e2e + $(call run-e2e-and-cleanup,PILLAR=$(PILLAR) go test -tags=e2e ./test/e2e/ -v -ginkgo.v $(E2E_LABEL_FILTER)) .PHONY: cleanup-test-e2e cleanup-test-e2e: ## Tear down the Kind cluster used for e2e tests @@ -140,13 +152,13 @@ cleanup-test-e2e: ## Tear down the Kind cluster used for e2e tests # KEEP=1 시 cleanup skip (로컬 디버그). .PHONY: test-e2e-pg test-e2e-pg: setup-test-e2e manifests generate fmt vet ## Run RFC 0006 R1+R2 회귀 e2e (kind 의존, p1 라벨). - KIND=$(KIND) KIND_CLUSTER=$(KIND_CLUSTER) go test -tags=e2e ./test/e2e/ -timeout 30m -v -ginkgo.v -ginkgo.label-filter=p1 + $(call run-e2e-and-cleanup,go test -tags=e2e ./test/e2e/ -timeout 30m -v -ginkgo.v -ginkgo.label-filter=p1) # RFC 0006 R3 회귀 — primary kill → 새 primary auto-promote (RTO < 30s) → 옛 primary standby rejoin. # replicas=1 (Pod 2 개) failover 라이프사이클. KEEP=1 시 cleanup skip. .PHONY: test-e2e-failover test-e2e-failover: setup-test-e2e manifests generate fmt vet ## RFC 0006 R3 회귀 e2e (kind 의존, p2 라벨, replicas=1 primary kill). - KIND=$(KIND) KIND_CLUSTER=$(KIND_CLUSTER) go test -tags=e2e ./test/e2e/ -timeout 30m -v -ginkgo.v -ginkgo.label-filter=p2 + $(call run-e2e-and-cleanup,go test -tags=e2e ./test/e2e/ -timeout 30m -v -ginkgo.v -ginkgo.label-filter=p2) .PHONY: lint lint: golangci-lint ## Run golangci-lint linter diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index 28a5b24..a41351a 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -322,8 +322,9 @@ kubectl -n postgres-operator-system set env deploy/postgres-operator-controller- 4. **그 다음 ADR-0029 잔여**: selector/status 일반화와 Promote target-adopt 이후에는 precondition/fence 강화, source cleanup 정책, named shard spec-model 전환으로 들어간다. #220-class 정체성 위험 때문에 live chaos drill 전에는 GA 완료로 보지 않는다. -5. **검증 체크포인트**: 기본은 native Go test 로 시작한다. Docker/kind 는 native router 동시쓰기 e2e - 또는 full live gate 에서만 사용한다. +5. **검증 체크포인트**: 개발 중에는 저비용 smoke 만 수행하고, 기능 단위가 닫히면 Docker/kind 를 켜서 + 통합·라이브 검증을 묶어서 실행한다. 테스트 결과는 브리핑하고, `KEEP=1` 을 명시하지 않는 한 kind + 클러스터는 target 종료 시 반납한다. 2026-06-28 현재 진행 상태: @@ -392,15 +393,33 @@ kubectl -n postgres-operator-system set env deploy/postgres-operator-controller- `go test -count=1 ./internal/controller --ginkgo.focus="source active"`, `go test -count=1 ./internal/controller`, `go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./api/v1alpha1 ./internal/controller`. -- Windows 로컬 테스트는 `scripts/test-windows.ps1` 를 우선 사용한다. 기본은 Go test cache 를 살리고, - `-Fresh` 를 줄 때만 `-count=1` 을 붙인다. `GOTMPDIR` / `GOCACHE` 는 repo 밖 +- Windows 로컬 테스트 wrapper(`scripts/test-windows.ps1`)는 개발 중 빠른 smoke 용도다. 최종 수용 + 검증은 Docker/kind 또는 Dev Container 경로에서 묶어서 수행한다. wrapper 는 기본적으로 Go test cache 를 + 살리고, `-Fresh` 를 줄 때만 `-count=1` 을 붙인다. `GOTMPDIR` / `GOCACHE` 는 repo 밖 `%LOCALAPPDATA%\keiailab\postgres-operator\...` 로 고정해 `*.test.exe` 가 workspace 에 남지 않게 한다. - 예: + smoke 예: ```powershell powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-windows.ps1 -Preset controller -GinkgoFocus "Promote phase" ``` +### 6.9 현재 미구현 / 부분구현 재정리 (2026-06-29) + +- **테스트 자원 운영 정책**: 개발 중에는 Windows wrapper 같은 저비용 smoke 만 사용하고, 기능 단위가 + 닫힌 뒤 Docker/kind/Dev Container 로 통합·라이브 검증을 묶어서 수행한다. e2e Make target 은 + `KEEP=1` 을 주지 않는 한 종료 시 `cleanup-test-e2e` 로 kind 클러스터를 반납한다. 테스트 완료 후에는 + pass/fail, 실패 RCA, 남은 위험, 정리된 자원 상태를 브리핑한다. +- **Router autoscaling**: `PostgresCluster.spec.router.autoscale` 스키마와 RFC HPA 설계는 있으나, + `PostgresClusterReconciler` 는 현재 router `ConfigMap` / `Service` / `Deployment` 만 reconcile 한다. + `HorizontalPodAutoscaler` 생성, autoscaling/v2 RBAC, router active-connection metric 노출/어댑터 결선은 + 미구현이다. 현재 router 는 `spec.router.replicas` 기반 수동 scale 이다. +- **AutoSplit / 자동 shard 확장**: `spec.autoSplit` 스키마와 admission 검증은 있으나, shard size/latency/CPU + 관측, 지속시간 판정, 후보 계산, `ShardSplitJob` 자동 생성 루프는 미구현이다. +- **남은 live gate**: native router concurrent-write online resharding e2e, target promotion 후 live + chaos/failover drill, source-down abort cleanup fallback 검증은 아직 남아 있다. +- **의도적 보류**: source Service/PVC/PDB 삭제는 기본 동작이 아니며, 향후 별도 opt-in 정책과 live drill 후에만 + 검토한다. cross-shard 2PC, extended scatter, Flush 파이프라이닝도 아직 범위 밖이다. + --- ## 7. 용어집 diff --git a/docs/superpowers/plans/2026-06-28-reshard-hardening.md b/docs/superpowers/plans/2026-06-28-reshard-hardening.md index 11e8b5f..3bb69e0 100644 --- a/docs/superpowers/plans/2026-06-28-reshard-hardening.md +++ b/docs/superpowers/plans/2026-06-28-reshard-hardening.md @@ -14,7 +14,8 @@ - Keep Docker Desktop, WSL, and local VMs stopped during development unless a verification checkpoint explicitly requires them. - Do not run tests after every small edit. Run grouped verification only after a coherent batch is complete. -- Prefer native `go test` on Windows/host first. Start Docker/kind only for the live e2e checkpoint. +- Use Windows/host `go test` only as a low-cost smoke path. Final acceptance for resource-dependent behavior must run in Docker/kind or Dev Container at the checkpoint. +- After Docker/kind verification, brief the result and release resources. `KEEP=1` is an explicit local-debug exception only. - If a generated API/CRD file is affected, run `make manifests generate` and `make sync-crds` at the checkpoint, not after every individual edit. - Commit after each verified batch. diff --git a/docs/superpowers/plans/2026-06-29-windows-test-wrapper.md b/docs/superpowers/plans/2026-06-29-windows-test-wrapper.md index 42af678..f92738e 100644 --- a/docs/superpowers/plans/2026-06-29-windows-test-wrapper.md +++ b/docs/superpowers/plans/2026-06-29-windows-test-wrapper.md @@ -1,8 +1,8 @@ -# Windows Test Wrapper Implementation Plan +# Windows Smoke Test Wrapper Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Provide a Windows PowerShell test wrapper that keeps Go test temp/cache outputs out of the repository and reduces Windows security scanning friction. +**Goal:** Provide a Windows PowerShell smoke-test wrapper that keeps Go test temp/cache outputs out of the repository and reduces Windows security scanning friction. This is not a replacement for the grouped Docker/kind acceptance gate. **Architecture:** Add one focused script under `scripts/` that normalizes `GOTMPDIR`, `GOCACHE`, Go binary discovery, optional envtest asset discovery, and common package presets. Add a lightweight PowerShell self-test mode so script behavior can be validated without running expensive Go tests. From 5075e807def4befd53908042e8403bb3433a9e22 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Mon, 29 Jun 2026 11:57:23 +0900 Subject: [PATCH 101/109] docs(router): design cpu hpa autoscaling --- .../specs/2026-06-29-router-cpu-hpa-design.md | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-29-router-cpu-hpa-design.md diff --git a/docs/superpowers/specs/2026-06-29-router-cpu-hpa-design.md b/docs/superpowers/specs/2026-06-29-router-cpu-hpa-design.md new file mode 100644 index 0000000..7f2d2b7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-router-cpu-hpa-design.md @@ -0,0 +1,209 @@ +# Router CPU HPA Design + +> 작성일: 2026-06-29 +> 범위: `PostgresCluster.spec.router.autoscale` 의 1차 운영 구현 +> 대상 브랜치: `chore/ha-pitr-e2e-consolidation` + +## 1. 요약 + +Router autoscaling 은 현재 API 스키마만 있고 실제 reconciler 구현이 없다. 이번 설계는 +`spec.shardingMode=native` 이고 `spec.router.enabled=true`, `spec.router.autoscale.enabled=true` 인 +클러스터에 Kubernetes `autoscaling/v2.HorizontalPodAutoscaler` 를 생성한다. + +1차 범위는 **CPU 기반 HPA** 로 제한한다. `targetActiveConnections` 는 API 필드로 남기되, router metric +서버와 custom metrics adapter 가 아직 없으므로 이번 단계에서는 HPA metric 으로 사용하지 않는다. + +## 2. 핵심 개념 / 기술용어 설명 + +- **HPA**: Kubernetes `HorizontalPodAutoscaler`. 대상 `Deployment` 의 replica 수를 CPU 같은 metric 에 따라 + 조정한다. +- **Router Deployment**: `PostgresCluster` 가 native sharding 모드에서 생성하는 stateless `pg-router` + Deployment. 현재는 `spec.router.replicas` 로 수동 scale 한다. +- **Controller ownership**: HPA 는 `PostgresCluster` 의 자식 리소스로 생성한다. OwnerReference 를 붙이면 + cluster 삭제 시 garbage collection 되고, `SetupWithManager().Owns()` 로 HPA 변경도 reconcile trigger 가 된다. + +## 3. 현재 상태 + +코드 기준 확인 결과: + +- `api/v1alpha1/postgrescluster_types.go` 에 `RouterAutoscaleSpec` 이 있다. +- `internal/controller/postgrescluster_controller.go` 는 router `ConfigMap` / `Service` / `Deployment` 만 만든다. +- `internal/controller/builders.go` 에 HPA builder 가 없다. +- RBAC marker 에 `autoscaling` 그룹 권한이 없다. +- `copySpec()` 은 `HorizontalPodAutoscaler` 를 지원하지 않는다. +- `cmd/pg-router` 는 Prometheus `/metrics` endpoint 를 노출하지 않는다. + +## 4. 설계 결정 + +### 4.1 1차 구현은 CPU HPA 만 지원 + +`spec.router.autoscale.targetCPU` 를 `Resource` metric 으로 반영한다. + +```yaml +metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 +``` + +`targetActiveConnections` 는 이번 구현에서 무시하지 않고, **reserved 필드** 로 문서화한다. 사용자가 값을 넣어도 +HPA 에 반영되지 않는다. 이유는 실제 router metric 과 custom metrics adapter 가 없어서 동작한다고 표현하면 +운영자를 오도하기 때문이다. + +### 4.2 `maxReplicas` 는 autoscale 활성 시 필수 + +`autoscale.enabled=true` 인데 `maxReplicas=0` 이면 webhook 에서 거부한다. 무한 또는 불명확한 scale 상한은 +운영 리스크가 크다. + +`minReplicas` 기본값: + +- `autoscale.minReplicas > 0` 이면 그 값을 사용한다. +- 아니면 `router.replicas` 를 사용한다. +- 둘 다 0 이면 schema default 기대와 별개로 builder 에서 1로 방어한다. + +`targetCPU` 기본값: + +- `autoscale.targetCPU > 0` 이면 그 값을 사용한다. +- 아니면 API default 와 일치하게 70을 사용한다. + +### 4.3 HPA 활성 시 operator 는 Deployment replicas 와 싸우지 않는다 + +현재 reconciler 는 매번 router Deployment 의 `spec.replicas` 를 desired 값으로 덮어쓴다. HPA 를 붙인 뒤에도 +이 동작을 유지하면 HPA 가 scale out 해도 다음 reconcile 에서 다시 `spec.router.replicas` 로 되돌아간다. + +따라서 HPA 활성 시에는 다음 규칙을 적용한다. + +- 새 Deployment 생성 시 초기 replicas 는 HPA min 값으로 둔다. +- 기존 Deployment update 시 `copySpec()` 이 `Deployment.spec.replicas` 를 덮어쓰지 않는다. +- template, securityContext, image, resources 등 pod spec 은 계속 operator desired state 로 동기화한다. + +이 동작은 `buildRouterDeployment(..., preserveReplicas bool)` 또는 router 전용 update helper 로 표현한다. +파일 변경을 작게 유지하기 위해 1차 구현은 `buildRouterDeployment` 에 `preserveReplicas` 인자를 추가하고, +`copySpec()` 은 `Deployment` 자체가 `replicas=nil` 인 경우 기존 replicas 를 보존하도록 처리한다. + +### 4.4 autoscale 비활성 또는 router 비활성 시 HPA 삭제 + +사용자가 `autoscale.enabled=false` 로 바꾸거나 router 를 끄면 기존 HPA 는 삭제한다. 그래야 이후 수동 +`spec.router.replicas` 가 다시 단일 제어권을 가진다. + +삭제는 `apierrors.IsNotFound` 를 정상으로 처리한다. + +### 4.5 HPA 상태는 1차 범위에서 status 에 추가하지 않는다 + +`ClusterRouterStatus` 에 HPA 상태 필드를 추가하면 CRD/DeepCopy/문서 범위가 커진다. 이번 단계는 HPA 리소스 +생성과 충돌 방지에 집중한다. 운영자는 `kubectl get hpa` 와 Events 로 상태를 확인한다. + +## 5. 동작 흐름 + +1. `PostgresClusterReconciler` 가 `PostgresCluster` 를 읽는다. +2. `shardingMode=native && router.enabled=true` 이면 router `ConfigMap`, `Service`, `Deployment` 를 upsert 한다. +3. `router.autoscale.enabled=true` 이면 router `Deployment` 를 대상으로 하는 HPA 를 upsert 한다. +4. HPA 활성 상태에서는 router Deployment replicas 를 HPA 가 관리한다. +5. `autoscale.enabled=false` 또는 router inactive 이면 기존 HPA 를 삭제한다. +6. cluster 삭제 시 OwnerReference 로 HPA 도 함께 정리된다. + +## 6. 오류 처리 + +- HPA upsert 실패: 기존 `handleUpsertErr()` 경로를 사용해 conflict 는 requeue, 그 외 실패는 Warning Event 를 남긴다. +- HPA delete 실패: NotFound 는 무시하고, 그 외 오류는 reconcile 실패로 반환한다. +- invalid autoscale spec: webhook 에서 거부한다. + +## 7. 테스트 / 검증 방법 + +단위/통합 테스트: + +- `buildRouterHPA` 가 `scaleTargetRef`, min/max, CPU target 을 정확히 만든다. +- autoscale enabled cluster 가 router HPA 를 생성한다. +- autoscale disabled 로 전환하면 HPA 를 삭제한다. +- autoscale enabled 상태에서 기존 Deployment replicas 를 HPA 값처럼 임의 변경해도 reconcile 이 되돌리지 않는다. +- webhook 이 `maxReplicas < minReplicas`, `enabled=true && maxReplicas=0` 을 거부한다. + +검증 명령: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-windows.ps1 -Package ./internal/webhook/v1alpha1 -Run "TestValidate_.*Autoscale" +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-windows.ps1 -Preset controller -Run "TestBuildRouterHPA|TestBuildRouterDeployment" +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-windows.ps1 -Preset controller -GinkgoFocus "router autoscale" +``` + +API/RBAC 생성 파일 검증: + +```bash +make manifests generate +make sync-crds +``` + +최종 수용 검증은 Docker/kind 또는 Dev Container 에서 묶어서 실행하고, 완료 후 `cleanup-test-e2e` 로 자원을 +반납한다. + +## 8. 대안 비교 + +### A. CPU HPA 먼저 구현 + +장점: + +- Kubernetes 기본 metric 경로라 외부 adapter 없이 동작한다. +- 기존 API 필드 일부를 실제 운영 기능으로 연결한다. +- blast radius 가 작고 envtest 로 검증 가능하다. + +단점: + +- connection 수 기반 scale 보다 DB router 특성 반영이 약하다. + +### B. active connections HPA 까지 동시에 구현 + +장점: + +- router 부하 모델에 더 가깝다. + +단점: + +- `pg-router` `/metrics`, ServiceMonitor, Prometheus Adapter 또는 KEDA 설계가 필요하다. +- 한 번에 바뀌는 컴포넌트가 많아 live gate 없이 신뢰하기 어렵다. + +### C. AutoSplit 먼저 구현 + +장점: + +- shard 확장이라는 제품 가치가 크다. + +단점: + +- metric source, threshold window, `ShardSplitJob` 생성 정책, approval UX, rollback 정책이 모두 필요하다. +- online reshard live gate 가 아직 남아 있어 자동화부터 붙이면 위험하다. + +추천은 **A안** 이다. + +## 9. ★ Insight + +이 기능의 핵심은 HPA 객체를 만드는 것이 아니라 **제어권 충돌을 제거하는 것**이다. operator 가 계속 +`Deployment.spec.replicas` 를 강제하면 HPA 는 존재해도 실제 운영에서는 scale 이 불안정해진다. 따라서 HPA +활성 시 replicas 필드만 HPA 에 위임하고, Pod template 과 security/resource/image 는 operator 가 계속 관리하는 +역할 분리가 필요하다. + +실무에서 가장 자주 생기는 문제는 다음이다. + +- `maxReplicas` 누락으로 scale 상한이 불명확해지는 문제 +- CPU request 가 없어서 HPA utilization 계산이 불가능한 문제 +- operator 와 HPA 의 replicas field fight +- custom metric 이 준비되지 않았는데 Pods metric 을 HPA 에 넣어 HPA 가 `Unknown` 상태가 되는 문제 + +이번 설계는 이 중 replicas field fight 와 custom metric 미준비 문제를 먼저 닫는다. CPU request 는 router +resource defaults 또는 sample manifests 보강에서 별도 확인이 필요하다. + +## 10. 범위 밖 + +- `targetActiveConnections` 기반 autoscale +- `pg-router` Prometheus metrics endpoint +- Prometheus Adapter / KEDA 연동 +- AutoSplit 자동 `ShardSplitJob` 생성 +- HPA 상태를 `PostgresCluster.status.router` 에 반영 +- live e2e 자동화 + +## 11. 한 줄 결론 + +Router autoscaling 1차 구현은 CPU HPA 로 시작하되, HPA 활성 시 Deployment replicas 를 HPA 에 위임하는 것이 +설계의 핵심이다. From c13354b9ef449fb8b218342406f247b34e8a4c91 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Mon, 29 Jun 2026 12:01:47 +0900 Subject: [PATCH 102/109] docs(router): plan cpu hpa implementation --- .../plans/2026-06-29-router-cpu-hpa.md | 907 ++++++++++++++++++ 1 file changed, 907 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-29-router-cpu-hpa.md diff --git a/docs/superpowers/plans/2026-06-29-router-cpu-hpa.md b/docs/superpowers/plans/2026-06-29-router-cpu-hpa.md new file mode 100644 index 0000000..76f5061 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-router-cpu-hpa.md @@ -0,0 +1,907 @@ +# Router CPU HPA Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `PostgresCluster.spec.router.autoscale.enabled=true` create and maintain a CPU-based Kubernetes HPA for the router Deployment without fighting HPA-managed replica counts. + +**Architecture:** Add a router HPA child resource owned by `PostgresCluster`, using `autoscaling/v2.HorizontalPodAutoscaler` with CPU resource utilization. Keep `targetActiveConnections` reserved until router metrics exist. Preserve router Deployment replicas on existing objects when HPA is enabled, while continuing to reconcile the Pod template, image, resources, security context, and labels. + +**Tech Stack:** Go 1.26, controller-runtime, Kubernetes `autoscaling/v2`, envtest, Kubebuilder RBAC/manifests. + +--- + +## File Structure + +- `internal/controller/names.go` + - Add `RouterHPAName(cluster string)`. + - Add `RouterAutoscaleLabelKey`. +- `internal/controller/builders.go` + - Import `autoscalingv2`. + - Add router autoscale helper functions. + - Add `buildRouterHPA`. + - Extend `buildRouterDeployment` so HPA-enabled router Deployments are labeled and initial replicas are the HPA min value. +- `internal/controller/postgrescluster_controller.go` + - Import `autoscalingv2`. + - Add RBAC marker for `horizontalpodautoscalers`. + - Reconcile HPA when router autoscale is enabled. + - Delete HPA when router or autoscale is disabled. + - Extend `copySpec()` and `SetupWithManager()`. +- `internal/controller/builders_test.go` + - Add HPA builder tests. + - Add router Deployment autoscale label test. +- `internal/controller/postgrescluster_controller_test.go` + - Add envtest coverage for HPA create, HPA delete, and replica preservation. +- `internal/webhook/v1alpha1/postgrescluster_webhook.go` + - Validate `autoscale.enabled=true` requires `maxReplicas > 0`. + - Validate `maxReplicas >= minReplicas` when both are set. +- `internal/webhook/v1alpha1/postgrescluster_webhook_test.go` + - Add autoscale validation unit tests. +- Generated by command, never manual edit: + - `config/rbac/role.yaml` + - `config/crd/bases/postgres.keiailab.io_postgresclusters.yaml` + - `charts/postgres-operator/crds/postgres.keiailab.io_postgresclusters.yaml` + +## Design Constants + +Use these names consistently: + +```go +const RouterAutoscaleLabelKey = "postgres.keiailab.io/router-autoscale" + +func RouterHPAName(cluster string) string { + return fmt.Sprintf("%s-router", cluster) +} +``` + +`RouterHPAName` intentionally matches `RouterDeploymentName`. The resource kinds differ, and the HPA target name is obvious in `kubectl get hpa`. + +--- + +### Task 1: Add HPA Builder And Unit Tests + +**Files:** +- Modify: `internal/controller/names.go` +- Modify: `internal/controller/builders.go` +- Modify: `internal/controller/builders_test.go` + +- [ ] **Step 1: Write failing tests for names and HPA builder** + +Append to `internal/controller/builders_test.go`: + +```go +func TestBuildRouterHPA_CPUDefaultsAndTarget(t *testing.T) { + t.Parallel() + + cluster := &postgresv1alpha1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "orders", Namespace: "default"}, + Spec: postgresv1alpha1.PostgresClusterSpec{ + Router: &postgresv1alpha1.RouterSpec{ + Replicas: 2, + Autoscale: &postgresv1alpha1.RouterAutoscaleSpec{ + Enabled: true, + MaxReplicas: 8, + }, + }, + }, + } + + hpa := buildRouterHPA(cluster, RouterDeploymentName("orders")) + + if hpa.Name != "orders-router" { + t.Fatalf("hpa name = %q, want orders-router", hpa.Name) + } + if hpa.Namespace != "default" { + t.Fatalf("namespace = %q, want default", hpa.Namespace) + } + if hpa.Spec.ScaleTargetRef.APIVersion != "apps/v1" || + hpa.Spec.ScaleTargetRef.Kind != "Deployment" || + hpa.Spec.ScaleTargetRef.Name != "orders-router" { + t.Fatalf("scaleTargetRef = %+v", hpa.Spec.ScaleTargetRef) + } + if hpa.Spec.MinReplicas == nil || *hpa.Spec.MinReplicas != 2 { + t.Fatalf("minReplicas = %v, want 2", hpa.Spec.MinReplicas) + } + if hpa.Spec.MaxReplicas != 8 { + t.Fatalf("maxReplicas = %d, want 8", hpa.Spec.MaxReplicas) + } + if len(hpa.Spec.Metrics) != 1 { + t.Fatalf("metrics len = %d, want 1", len(hpa.Spec.Metrics)) + } + metric := hpa.Spec.Metrics[0] + if metric.Type != autoscalingv2.ResourceMetricSourceType { + t.Fatalf("metric type = %q, want Resource", metric.Type) + } + if metric.Resource == nil || metric.Resource.Name != corev1.ResourceCPU { + t.Fatalf("resource metric = %+v, want cpu", metric.Resource) + } + if metric.Resource.Target.Type != autoscalingv2.UtilizationMetricType || + metric.Resource.Target.AverageUtilization == nil || + *metric.Resource.Target.AverageUtilization != 70 { + t.Fatalf("target = %+v, want 70%% utilization", metric.Resource.Target) + } +} + +func TestBuildRouterHPA_ExplicitMinAndCPU(t *testing.T) { + t.Parallel() + + cluster := &postgresv1alpha1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "orders", Namespace: "default"}, + Spec: postgresv1alpha1.PostgresClusterSpec{ + Router: &postgresv1alpha1.RouterSpec{ + Replicas: 2, + Autoscale: &postgresv1alpha1.RouterAutoscaleSpec{ + Enabled: true, + MinReplicas: 3, + MaxReplicas: 9, + TargetCPU: 55, + }, + }, + }, + } + + hpa := buildRouterHPA(cluster, RouterDeploymentName("orders")) + + if hpa.Spec.MinReplicas == nil || *hpa.Spec.MinReplicas != 3 { + t.Fatalf("minReplicas = %v, want 3", hpa.Spec.MinReplicas) + } + if hpa.Spec.MaxReplicas != 9 { + t.Fatalf("maxReplicas = %d, want 9", hpa.Spec.MaxReplicas) + } + gotCPU := hpa.Spec.Metrics[0].Resource.Target.AverageUtilization + if gotCPU == nil || *gotCPU != 55 { + t.Fatalf("targetCPU = %v, want 55", gotCPU) + } +} + +func TestBuildRouterDeployment_LabelsAutoscaleManagedReplicas(t *testing.T) { + t.Parallel() + + cluster := &postgresv1alpha1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "orders", Namespace: "default"}, + Spec: postgresv1alpha1.PostgresClusterSpec{ + Router: &postgresv1alpha1.RouterSpec{ + Replicas: 2, + Autoscale: &postgresv1alpha1.RouterAutoscaleSpec{ + Enabled: true, + MinReplicas: 3, + MaxReplicas: 8, + }, + }, + }, + } + + dep := buildRouterDeployment(cluster, "orders-router", "orders-router-config", "example.com/router:dev", routerMinReplicas(cluster), corev1.ResourceRequirements{}) + + if dep.Labels[RouterAutoscaleLabelKey] != "true" { + t.Fatalf("deployment label %s = %q, want true", RouterAutoscaleLabelKey, dep.Labels[RouterAutoscaleLabelKey]) + } + if dep.Spec.Template.Labels[RouterAutoscaleLabelKey] != "true" { + t.Fatalf("pod template label %s = %q, want true", RouterAutoscaleLabelKey, dep.Spec.Template.Labels[RouterAutoscaleLabelKey]) + } + if dep.Spec.Replicas == nil || *dep.Spec.Replicas != 3 { + t.Fatalf("initial replicas = %v, want minReplicas 3", dep.Spec.Replicas) + } +} +``` + +Add imports to `internal/controller/builders_test.go`: + +```go +autoscalingv2 "k8s.io/api/autoscaling/v2" +``` + +- [ ] **Step 2: Run builder tests and verify failure** + +Run: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-windows.ps1 -Preset controller -Run "TestBuildRouterHPA|TestBuildRouterDeployment" +``` + +Expected: + +- `undefined: buildRouterHPA` +- `undefined: routerMinReplicas` +- `undefined: RouterAutoscaleLabelKey` + +- [ ] **Step 3: Add names and builder implementation** + +In `internal/controller/names.go`, add after `RouterConfigMapName`: + +```go +// RouterHPAName은 QueryRouter Deployment 를 대상으로 하는 HPA 이름을 반환한다. +func RouterHPAName(cluster string) string { + return fmt.Sprintf("%s-router", cluster) +} +``` + +Add near label constants: + +```go +// RouterAutoscaleLabelKey 는 router Deployment replicas 를 HPA 가 관리하는지 표시한다. +const RouterAutoscaleLabelKey = "postgres.keiailab.io/router-autoscale" +``` + +In `internal/controller/builders.go`, add import: + +```go +autoscalingv2 "k8s.io/api/autoscaling/v2" +``` + +Add helpers before `buildRouterDeployment`: + +```go +func routerAutoscaleEnabled(cluster *postgresv1alpha1.PostgresCluster) bool { + return cluster != nil && + cluster.Spec.Router != nil && + cluster.Spec.Router.Autoscale != nil && + cluster.Spec.Router.Autoscale.Enabled +} + +func routerMinReplicas(cluster *postgresv1alpha1.PostgresCluster) int32 { + if cluster == nil || cluster.Spec.Router == nil { + return 1 + } + if as := cluster.Spec.Router.Autoscale; as != nil && as.MinReplicas > 0 { + return as.MinReplicas + } + if cluster.Spec.Router.Replicas > 0 { + return cluster.Spec.Router.Replicas + } + return 1 +} + +func routerMaxReplicas(cluster *postgresv1alpha1.PostgresCluster) int32 { + if cluster == nil || cluster.Spec.Router == nil || cluster.Spec.Router.Autoscale == nil { + return routerMinReplicas(cluster) + } + if cluster.Spec.Router.Autoscale.MaxReplicas > 0 { + return cluster.Spec.Router.Autoscale.MaxReplicas + } + return routerMinReplicas(cluster) +} + +func routerTargetCPU(cluster *postgresv1alpha1.PostgresCluster) int32 { + if cluster != nil && cluster.Spec.Router != nil && cluster.Spec.Router.Autoscale != nil && + cluster.Spec.Router.Autoscale.TargetCPU > 0 { + return cluster.Spec.Router.Autoscale.TargetCPU + } + return 70 +} + +func buildRouterHPA(cluster *postgresv1alpha1.PostgresCluster, deploymentName string) *autoscalingv2.HorizontalPodAutoscaler { + minReplicas := routerMinReplicas(cluster) + targetCPU := routerTargetCPU(cluster) + return &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: RouterHPAName(cluster.Name), + Namespace: cluster.Namespace, + Labels: SelectorLabels(cluster.Name, "router", -1), + }, + Spec: autoscalingv2.HorizontalPodAutoscalerSpec{ + ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: deploymentName, + }, + MinReplicas: &minReplicas, + MaxReplicas: routerMaxReplicas(cluster), + Metrics: []autoscalingv2.MetricSpec{{ + Type: autoscalingv2.ResourceMetricSourceType, + Resource: &autoscalingv2.ResourceMetricSource{ + Name: corev1.ResourceCPU, + Target: autoscalingv2.MetricTarget{ + Type: autoscalingv2.UtilizationMetricType, + AverageUtilization: &targetCPU, + }, + }, + }}, + }, + } +} +``` + +In `buildRouterDeployment`, after `labels := SelectorLabels(...)`, add: + +```go +if routerAutoscaleEnabled(cluster) { + labels[RouterAutoscaleLabelKey] = "true" +} +``` + +The existing `labels` map is reused for object labels and template labels, so both get the marker. + +- [ ] **Step 4: Run builder tests and verify pass** + +Run: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-windows.ps1 -Preset controller -Run "TestBuildRouterHPA|TestBuildRouterDeployment" +``` + +Expected: `ok github.com/keiailab/postgres-operator/internal/controller`. + +- [ ] **Step 5: Commit Task 1** + +```bash +git add internal/controller/names.go internal/controller/builders.go internal/controller/builders_test.go +git commit -m "feat(router): build cpu hpa resources" +``` + +--- + +### Task 2: Reconcile Router HPA And Preserve HPA Replicas + +**Files:** +- Modify: `internal/controller/postgrescluster_controller.go` +- Modify: `internal/controller/postgrescluster_controller_test.go` + +- [ ] **Step 1: Write envtest for HPA creation** + +In `internal/controller/postgrescluster_controller_test.go`, add import: + +```go +autoscalingv2 "k8s.io/api/autoscaling/v2" +``` + +Append inside `Context("when shardingMode=native with 2 shards and router", func() { ... })`: + +```go +It("creates router HPA when router autoscale is enabled", func() { + cluster := &postgresv1alpha1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "autoscale", Namespace: namespace}, + Spec: postgresv1alpha1.PostgresClusterSpec{ + PostgresVersion: "18", + ShardingMode: postgresv1alpha1.ShardingModeNative, + Shards: postgresv1alpha1.ShardsSpec{ + InitialCount: 1, + Replicas: 1, + Storage: postgresv1alpha1.StorageSpec{Size: resource.MustParse("1Gi")}, + }, + Router: &postgresv1alpha1.RouterSpec{ + Enabled: true, + Replicas: 2, + Autoscale: &postgresv1alpha1.RouterAutoscaleSpec{ + Enabled: true, + MinReplicas: 2, + MaxReplicas: 5, + TargetCPU: 60, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, cluster)).To(Succeed()) + + Eventually(func(g Gomega) { + var hpa autoscalingv2.HorizontalPodAutoscaler + g.Expect(k8sClient.Get(ctx, types.NamespacedName{ + Namespace: namespace, Name: RouterHPAName("autoscale"), + }, &hpa)).To(Succeed()) + g.Expect(hpa.Spec.ScaleTargetRef.Kind).To(Equal("Deployment")) + g.Expect(hpa.Spec.ScaleTargetRef.Name).To(Equal(RouterDeploymentName("autoscale"))) + g.Expect(hpa.Spec.MinReplicas).NotTo(BeNil()) + g.Expect(*hpa.Spec.MinReplicas).To(Equal(int32(2))) + g.Expect(hpa.Spec.MaxReplicas).To(Equal(int32(5))) + g.Expect(hpa.Spec.Metrics[0].Resource.Target.AverageUtilization).NotTo(BeNil()) + g.Expect(*hpa.Spec.Metrics[0].Resource.Target.AverageUtilization).To(Equal(int32(60))) + }, envtestTimeout, envtestInterval).Should(Succeed()) +}) +``` + +- [ ] **Step 2: Write envtest for HPA delete when autoscale disabled** + +Append in the same context: + +```go +It("deletes router HPA when autoscale is disabled", func() { + cluster := &postgresv1alpha1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "autoscale-off", Namespace: namespace}, + Spec: postgresv1alpha1.PostgresClusterSpec{ + PostgresVersion: "18", + ShardingMode: postgresv1alpha1.ShardingModeNative, + Shards: postgresv1alpha1.ShardsSpec{ + InitialCount: 1, + Replicas: 1, + Storage: postgresv1alpha1.StorageSpec{Size: resource.MustParse("1Gi")}, + }, + Router: &postgresv1alpha1.RouterSpec{ + Enabled: true, + Replicas: 2, + Autoscale: &postgresv1alpha1.RouterAutoscaleSpec{ + Enabled: true, + MinReplicas: 2, + MaxReplicas: 5, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, cluster)).To(Succeed()) + Eventually(func(g Gomega) { + var hpa autoscalingv2.HorizontalPodAutoscaler + g.Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: RouterHPAName("autoscale-off")}, &hpa)).To(Succeed()) + }, envtestTimeout, envtestInterval).Should(Succeed()) + + Eventually(func(g Gomega) { + var got postgresv1alpha1.PostgresCluster + g.Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: "autoscale-off"}, &got)).To(Succeed()) + got.Spec.Router.Autoscale.Enabled = false + g.Expect(k8sClient.Update(ctx, &got)).To(Succeed()) + }, envtestTimeout, envtestInterval).Should(Succeed()) + + Eventually(func(g Gomega) { + var hpa autoscalingv2.HorizontalPodAutoscaler + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: RouterHPAName("autoscale-off")}, &hpa) + g.Expect(apierrors.IsNotFound(err)).To(BeTrue()) + }, envtestTimeout, envtestInterval).Should(Succeed()) +}) +``` + +- [ ] **Step 3: Write envtest for preserving HPA-managed replicas** + +Append in the same context: + +```go +It("preserves existing router Deployment replicas when autoscale is enabled", func() { + cluster := &postgresv1alpha1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "autoscale-preserve", Namespace: namespace}, + Spec: postgresv1alpha1.PostgresClusterSpec{ + PostgresVersion: "18", + ShardingMode: postgresv1alpha1.ShardingModeNative, + Shards: postgresv1alpha1.ShardsSpec{ + InitialCount: 1, + Replicas: 1, + Storage: postgresv1alpha1.StorageSpec{Size: resource.MustParse("1Gi")}, + }, + Router: &postgresv1alpha1.RouterSpec{ + Enabled: true, + Replicas: 2, + Autoscale: &postgresv1alpha1.RouterAutoscaleSpec{ + Enabled: true, + MinReplicas: 2, + MaxReplicas: 6, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, cluster)).To(Succeed()) + + Eventually(func(g Gomega) { + var dep appsv1.Deployment + g.Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: RouterDeploymentName("autoscale-preserve")}, &dep)).To(Succeed()) + replicas := int32(4) + dep.Spec.Replicas = &replicas + g.Expect(k8sClient.Update(ctx, &dep)).To(Succeed()) + }, envtestTimeout, envtestInterval).Should(Succeed()) + + bumpAnnotation(ctx, cluster) + + Consistently(func(g Gomega) { + var dep appsv1.Deployment + g.Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: RouterDeploymentName("autoscale-preserve")}, &dep)).To(Succeed()) + g.Expect(dep.Spec.Replicas).NotTo(BeNil()) + g.Expect(*dep.Spec.Replicas).To(Equal(int32(4))) + }, 2*time.Second, envtestInterval).Should(Succeed()) +}) +``` + +- [ ] **Step 4: Run envtest and verify failure** + +Run: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-windows.ps1 -Preset controller -GinkgoFocus "router HPA|autoscale" +``` + +Expected: + +- HPA creation test fails because reconciler does not create HPA. +- Delete/preserve tests fail for the same missing behavior. + +- [ ] **Step 5: Implement HPA reconcile** + +In `internal/controller/postgrescluster_controller.go`, add import: + +```go +autoscalingv2 "k8s.io/api/autoscaling/v2" +``` + +Add RBAC marker near existing app/core rules: + +```go +// +kubebuilder:rbac:groups=autoscaling,resources=horizontalpodautoscalers,verbs=get;list;watch;create;update;patch;delete +``` + +In the router section after router Deployment upsert, add: + +```go +if routerAutoscaleEnabled(&cluster) && !databasePodsStopped { + if err := r.upsert(ctx, &cluster, buildRouterHPA(&cluster, depName)); err != nil { + return r.handleUpsertErr(ctx, &cluster, err, "router HPA", logger) + } +} else { + if err := r.deleteRouterHPA(ctx, &cluster); err != nil { + logger.Error(err, "Failed to delete router HPA", "name", RouterHPAName(cluster.Name)) + return ctrl.Result{}, err + } +} +``` + +After the router section, ensure inactive router also deletes HPA: + +```go +if !routerActive { + if err := r.deleteRouterHPA(ctx, &cluster); err != nil { + logger.Error(err, "Failed to delete inactive router HPA", "name", RouterHPAName(cluster.Name)) + return ctrl.Result{}, err + } +} +``` + +Add helper near other reconciler helpers: + +```go +func (r *PostgresClusterReconciler) deleteRouterHPA(ctx context.Context, cluster *postgresv1alpha1.PostgresCluster) error { + hpa := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: RouterHPAName(cluster.Name), + Namespace: cluster.Namespace, + }, + } + if err := r.Delete(ctx, hpa); err != nil && !apierrors.IsNotFound(err) { + return err + } + return nil +} +``` + +Extend `copySpec()`: + +```go +case *appsv1.Deployment: + s := src.(*appsv1.Deployment) + if !(s.Labels[RouterAutoscaleLabelKey] == "true" && d.GetResourceVersion() != "") { + d.Spec.Replicas = s.Spec.Replicas + } + d.Spec.Template = s.Spec.Template + if d.Spec.Selector == nil { + d.Spec.Selector = s.Spec.Selector + } + d.Labels = s.Labels +``` + +Add HPA case to `copySpec()`: + +```go +case *autoscalingv2.HorizontalPodAutoscaler: + s := src.(*autoscalingv2.HorizontalPodAutoscaler) + d.Spec = s.Spec + d.Labels = s.Labels +``` + +Extend `SetupWithManager()`: + +```go +Owns(&autoscalingv2.HorizontalPodAutoscaler{}). +``` + +- [ ] **Step 6: Run envtest and verify pass** + +Run: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-windows.ps1 -Preset controller -GinkgoFocus "router HPA|autoscale" +``` + +Expected: focused specs pass. + +- [ ] **Step 7: Commit Task 2** + +```bash +git add internal/controller/postgrescluster_controller.go internal/controller/postgrescluster_controller_test.go +git commit -m "feat(router): reconcile cpu hpa" +``` + +--- + +### Task 3: Add Webhook Guardrails + +**Files:** +- Modify: `internal/webhook/v1alpha1/postgrescluster_webhook.go` +- Modify: `internal/webhook/v1alpha1/postgrescluster_webhook_test.go` + +- [ ] **Step 1: Write failing webhook tests** + +Append to `internal/webhook/v1alpha1/postgrescluster_webhook_test.go`: + +```go +func TestValidate_RouterAutoscaleEnabled_RequiresMaxReplicas(t *testing.T) { + w := newWebhook(t) + c := validBaseCluster() + c.Spec.ShardingMode = postgresv1alpha1.ShardingModeNative + c.Spec.Router = &postgresv1alpha1.RouterSpec{ + Enabled: true, + Replicas: 2, + Autoscale: &postgresv1alpha1.RouterAutoscaleSpec{ + Enabled: true, + }, + } + + _, err := w.ValidateCreate(context.Background(), c) + if err == nil { + t.Fatal("expected rejection for router.autoscale.enabled=true without maxReplicas") + } + if !strings.Contains(err.Error(), "maxReplicas") { + t.Errorf("error message lacks 'maxReplicas': %v", err) + } +} + +func TestValidate_RouterAutoscaleEnabled_MaxMustBeAtLeastMin(t *testing.T) { + w := newWebhook(t) + c := validBaseCluster() + c.Spec.ShardingMode = postgresv1alpha1.ShardingModeNative + c.Spec.Router = &postgresv1alpha1.RouterSpec{ + Enabled: true, + Replicas: 2, + Autoscale: &postgresv1alpha1.RouterAutoscaleSpec{ + Enabled: true, + MinReplicas: 4, + MaxReplicas: 3, + }, + } + + _, err := w.ValidateCreate(context.Background(), c) + if err == nil { + t.Fatal("expected rejection for maxReplicas < minReplicas") + } + if !strings.Contains(err.Error(), "maxReplicas") { + t.Errorf("error message lacks 'maxReplicas': %v", err) + } +} + +func TestValidate_RouterAutoscaleEnabled_WithMaxAccepted(t *testing.T) { + w := newWebhook(t) + c := validBaseCluster() + c.Spec.ShardingMode = postgresv1alpha1.ShardingModeNative + c.Spec.Router = &postgresv1alpha1.RouterSpec{ + Enabled: true, + Replicas: 2, + Autoscale: &postgresv1alpha1.RouterAutoscaleSpec{ + Enabled: true, + MinReplicas: 2, + MaxReplicas: 6, + TargetCPU: 70, + }, + } + + if _, err := w.ValidateCreate(context.Background(), c); err != nil { + t.Fatalf("valid router autoscale should be accepted: %v", err) + } +} +``` + +- [ ] **Step 2: Run webhook tests and verify failure** + +Run: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-windows.ps1 -Package ./internal/webhook/v1alpha1 -Run "TestValidate_RouterAutoscale" +``` + +Expected: first two tests fail because webhook does not validate router autoscale. + +- [ ] **Step 3: Implement webhook validation** + +In `internal/webhook/v1alpha1/postgrescluster_webhook.go`, add after the existing `autoSplit` validation: + +```go +if c.Spec.Router != nil && c.Spec.Router.Autoscale != nil && c.Spec.Router.Autoscale.Enabled { + as := c.Spec.Router.Autoscale + if as.MaxReplicas <= 0 { + errs = append(errs, field.Invalid( + field.NewPath("spec", "router", "autoscale", "maxReplicas"), as.MaxReplicas, + "maxReplicas must be > 0 when router.autoscale.enabled=true")) + } + minReplicas := as.MinReplicas + if minReplicas == 0 && c.Spec.Router.Replicas > 0 { + minReplicas = c.Spec.Router.Replicas + } + if minReplicas == 0 { + minReplicas = 1 + } + if as.MaxReplicas > 0 && as.MaxReplicas < minReplicas { + errs = append(errs, field.Invalid( + field.NewPath("spec", "router", "autoscale", "maxReplicas"), as.MaxReplicas, + fmt.Sprintf("maxReplicas must be >= effective minReplicas (%d)", minReplicas))) + } +} +``` + +- [ ] **Step 4: Run webhook tests and verify pass** + +Run: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-windows.ps1 -Package ./internal/webhook/v1alpha1 -Run "TestValidate_RouterAutoscale" +``` + +Expected: `ok github.com/keiailab/postgres-operator/internal/webhook/v1alpha1`. + +- [ ] **Step 5: Commit Task 3** + +```bash +git add internal/webhook/v1alpha1/postgrescluster_webhook.go internal/webhook/v1alpha1/postgrescluster_webhook_test.go +git commit -m "fix(webhook): validate router autoscale bounds" +``` + +--- + +### Task 4: Regenerate RBAC/CRDs And Sync Chart CRDs + +**Files:** +- Generated by command: + - `config/rbac/role.yaml` + - `config/crd/bases/postgres.keiailab.io_postgresclusters.yaml` + - `charts/postgres-operator/crds/postgres.keiailab.io_postgresclusters.yaml` + - `api/v1alpha1/zz_generated.deepcopy.go` if generator changes output + +- [ ] **Step 1: Run generators** + +Run in a Linux-capable shell or Dev Container: + +```bash +make manifests generate +make sync-crds +``` + +Expected: + +- RBAC role includes `autoscaling` / `horizontalpodautoscalers`. +- CRD schema remains consistent. +- No manual edits to generated files. + +- [ ] **Step 2: Inspect generated diff** + +Run: + +```bash +git diff -- config/rbac/role.yaml config/crd/bases/postgres.keiailab.io_postgresclusters.yaml charts/postgres-operator/crds/postgres.keiailab.io_postgresclusters.yaml api/v1alpha1/zz_generated.deepcopy.go +``` + +Expected: + +- RBAC includes HPA verbs from marker. +- CRD differences are limited to generator normalization or webhook/schema comments if any. + +- [ ] **Step 3: Commit generated files** + +```bash +git add config/rbac/role.yaml config/crd/bases/postgres.keiailab.io_postgresclusters.yaml charts/postgres-operator/crds/postgres.keiailab.io_postgresclusters.yaml api/v1alpha1/zz_generated.deepcopy.go +git commit -m "chore(api): regenerate router autoscale manifests" +``` + +If `git add` reports a pathspec that did not change, omit that unchanged file and commit only changed generated files. + +--- + +### Task 5: Focused Verification Checkpoint + +**Files:** +- No code edits unless verification exposes a defect. + +- [ ] **Step 1: Run native smoke tests** + +Run: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-windows.ps1 -Package ./internal/webhook/v1alpha1 -Run "TestValidate_RouterAutoscale" +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-windows.ps1 -Preset controller -Run "TestBuildRouterHPA|TestBuildRouterDeployment" +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-windows.ps1 -Preset controller -GinkgoFocus "router HPA|autoscale" +``` + +Expected: + +- All commands exit 0. +- No Docker/WSL/kind resources are started by these commands. + +- [ ] **Step 2: Run broader controller/router package smoke** + +Run: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-windows.ps1 -Preset sharding +``` + +Expected: + +- `./cmd/instance`, `./internal/router`, `./cmd/pg-router`, `./cmd/reshard-copy-poc`, `./api/v1alpha1`, `./internal/controller` pass. + +- [ ] **Step 3: Check repository** + +Run: + +```powershell +git diff --check +git status --short --branch +wsl -l -v +``` + +Expected: + +- `git diff --check` has no whitespace errors. +- Only intended files are modified or tree is clean after commits. +- WSL/Docker distros are stopped unless a later acceptance gate intentionally starts them. + +- [ ] **Step 4: Commit any verification-only fixes** + +If verification required fixes: + +```bash +git add internal/controller/names.go internal/controller/builders.go internal/controller/builders_test.go internal/controller/postgrescluster_controller.go internal/controller/postgrescluster_controller_test.go internal/webhook/v1alpha1/postgrescluster_webhook.go internal/webhook/v1alpha1/postgrescluster_webhook_test.go config/rbac/role.yaml config/crd/bases/postgres.keiailab.io_postgresclusters.yaml charts/postgres-operator/crds/postgres.keiailab.io_postgresclusters.yaml api/v1alpha1/zz_generated.deepcopy.go +git commit -m "fix(router): stabilize cpu hpa reconciliation" +``` + +If no fixes were needed, do not create an empty commit. + +--- + +### Task 6: Resource-Using Acceptance Gate + +**Files:** +- No code edits unless the gate exposes a defect. + +- [ ] **Step 1: Start resource-using gate only after Tasks 1-5 pass** + +Use Dev Container, WSL, or another Linux-capable environment with Docker/kind. Do not run this after every small edit. + +- [ ] **Step 2: Run e2e smoke with cleanup** + +Run: + +```bash +make test-e2e PILLAR=p1 +``` + +Expected: + +- Kind cluster is created if absent. +- e2e command exits with its real status. +- `cleanup-test-e2e` runs automatically unless `KEEP=1` is explicitly set. + +- [ ] **Step 3: Verify resource release** + +Run: + +```bash +kind get clusters +``` + +Expected: + +- `postgres-operator-test-e2e` is absent when `KEEP=1` was not used. + +- [ ] **Step 4: Brief result** + +Report: + +- pass/fail summary +- failing test names and RCA if any +- whether generated files changed +- Docker/WSL/kind resource state after cleanup + +--- + +## Self-Review Checklist + +- Spec coverage: + - CPU HPA creation: Task 1, Task 2. + - HPA/operator replica conflict: Task 2. + - Webhook guardrails: Task 3. + - Generated RBAC/CRDs: Task 4. + - grouped verification and resource release: Task 5, Task 6. +- Completion scan: + - No open-ended implementation steps. +- Type consistency: + - HPA type is `autoscalingv2.HorizontalPodAutoscaler`. + - HPA metric type is `autoscalingv2.ResourceMetricSourceType`. + - CPU resource is `corev1.ResourceCPU`. + - Router autoscale marker is `RouterAutoscaleLabelKey`. From 4c0e06f38a8c6bb92cdfb0a81924f38677fefa5d Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Mon, 29 Jun 2026 12:05:21 +0900 Subject: [PATCH 103/109] feat(router): build cpu hpa resources --- internal/controller/builders.go | 73 +++++++++++++++++ internal/controller/builders_test.go | 115 +++++++++++++++++++++++++++ internal/controller/names.go | 8 ++ 3 files changed, 196 insertions(+) diff --git a/internal/controller/builders.go b/internal/controller/builders.go index 6c8a622..57965b6 100644 --- a/internal/controller/builders.go +++ b/internal/controller/builders.go @@ -15,6 +15,7 @@ import ( "time" appsv1 "k8s.io/api/apps/v1" + autoscalingv2 "k8s.io/api/autoscaling/v2" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/api/resource" @@ -1263,6 +1264,75 @@ func buildTargetHeadlessService(cluster *postgresv1alpha1.PostgresCluster, shard } } +func routerAutoscaleEnabled(cluster *postgresv1alpha1.PostgresCluster) bool { + return cluster != nil && + cluster.Spec.Router != nil && + cluster.Spec.Router.Autoscale != nil && + cluster.Spec.Router.Autoscale.Enabled +} + +func routerMinReplicas(cluster *postgresv1alpha1.PostgresCluster) int32 { + if cluster == nil || cluster.Spec.Router == nil { + return 1 + } + if as := cluster.Spec.Router.Autoscale; as != nil && as.MinReplicas > 0 { + return as.MinReplicas + } + if cluster.Spec.Router.Replicas > 0 { + return cluster.Spec.Router.Replicas + } + return 1 +} + +func routerMaxReplicas(cluster *postgresv1alpha1.PostgresCluster) int32 { + if cluster == nil || cluster.Spec.Router == nil || cluster.Spec.Router.Autoscale == nil { + return routerMinReplicas(cluster) + } + if cluster.Spec.Router.Autoscale.MaxReplicas > 0 { + return cluster.Spec.Router.Autoscale.MaxReplicas + } + return routerMinReplicas(cluster) +} + +func routerTargetCPU(cluster *postgresv1alpha1.PostgresCluster) int32 { + if cluster != nil && cluster.Spec.Router != nil && cluster.Spec.Router.Autoscale != nil && + cluster.Spec.Router.Autoscale.TargetCPU > 0 { + return cluster.Spec.Router.Autoscale.TargetCPU + } + return 70 +} + +func buildRouterHPA(cluster *postgresv1alpha1.PostgresCluster, deploymentName string) *autoscalingv2.HorizontalPodAutoscaler { + minReplicas := routerMinReplicas(cluster) + targetCPU := routerTargetCPU(cluster) + return &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: RouterHPAName(cluster.Name), + Namespace: cluster.Namespace, + Labels: SelectorLabels(cluster.Name, "router", -1), + }, + Spec: autoscalingv2.HorizontalPodAutoscalerSpec{ + ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: deploymentName, + }, + MinReplicas: &minReplicas, + MaxReplicas: routerMaxReplicas(cluster), + Metrics: []autoscalingv2.MetricSpec{{ + Type: autoscalingv2.ResourceMetricSourceType, + Resource: &autoscalingv2.ResourceMetricSource{ + Name: corev1.ResourceCPU, + Target: autoscalingv2.MetricTarget{ + Type: autoscalingv2.UtilizationMetricType, + AverageUtilization: &targetCPU, + }, + }, + }}, + }, + } +} + // buildRouterDeployment는 stateless QueryRouter의 Deployment를 만든다. // ADR 0003 §강제 메커니즘에 의해 PVC를 절대 마운트하지 않는다(StatefulSet 사용 // 금지). 본 함수는 P12-T2 시점에 cmd/router 바이너리 이미지로 교체된다. 현재는 @@ -1274,6 +1344,9 @@ func buildRouterDeployment( resources corev1.ResourceRequirements, ) *appsv1.Deployment { labels := SelectorLabels(cluster.Name, "router", -1) + if routerAutoscaleEnabled(cluster) { + labels[RouterAutoscaleLabelKey] = "true" + } return &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ diff --git a/internal/controller/builders_test.go b/internal/controller/builders_test.go index 0b8f1b2..7dbfc0a 100644 --- a/internal/controller/builders_test.go +++ b/internal/controller/builders_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + autoscalingv2 "k8s.io/api/autoscaling/v2" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -696,6 +697,120 @@ func TestBuildRouterDeployment_AppliesSecurityContextAndEphemeralMounts(t *testi assertDataplaneSecurityContext(t, &dep.Spec.Template.Spec, "Router Deployment") } +func TestBuildRouterHPA_CPUDefaultsAndTarget(t *testing.T) { + t.Parallel() + + cluster := &postgresv1alpha1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "orders", Namespace: "default"}, + Spec: postgresv1alpha1.PostgresClusterSpec{ + Router: &postgresv1alpha1.RouterSpec{ + Replicas: 2, + Autoscale: &postgresv1alpha1.RouterAutoscaleSpec{ + Enabled: true, + MaxReplicas: 8, + }, + }, + }, + } + + hpa := buildRouterHPA(cluster, RouterDeploymentName("orders")) + + if hpa.Name != "orders-router" { + t.Fatalf("hpa name = %q, want orders-router", hpa.Name) + } + if hpa.Namespace != "default" { + t.Fatalf("namespace = %q, want default", hpa.Namespace) + } + if hpa.Spec.ScaleTargetRef.APIVersion != "apps/v1" || + hpa.Spec.ScaleTargetRef.Kind != "Deployment" || + hpa.Spec.ScaleTargetRef.Name != "orders-router" { + t.Fatalf("scaleTargetRef = %+v", hpa.Spec.ScaleTargetRef) + } + if hpa.Spec.MinReplicas == nil || *hpa.Spec.MinReplicas != 2 { + t.Fatalf("minReplicas = %v, want 2", hpa.Spec.MinReplicas) + } + if hpa.Spec.MaxReplicas != 8 { + t.Fatalf("maxReplicas = %d, want 8", hpa.Spec.MaxReplicas) + } + if len(hpa.Spec.Metrics) != 1 { + t.Fatalf("metrics len = %d, want 1", len(hpa.Spec.Metrics)) + } + metric := hpa.Spec.Metrics[0] + if metric.Type != autoscalingv2.ResourceMetricSourceType { + t.Fatalf("metric type = %q, want Resource", metric.Type) + } + if metric.Resource == nil || metric.Resource.Name != corev1.ResourceCPU { + t.Fatalf("resource metric = %+v, want cpu", metric.Resource) + } + if metric.Resource.Target.Type != autoscalingv2.UtilizationMetricType || + metric.Resource.Target.AverageUtilization == nil || + *metric.Resource.Target.AverageUtilization != 70 { + t.Fatalf("target = %+v, want 70%% utilization", metric.Resource.Target) + } +} + +func TestBuildRouterHPA_ExplicitMinAndCPU(t *testing.T) { + t.Parallel() + + cluster := &postgresv1alpha1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "orders", Namespace: "default"}, + Spec: postgresv1alpha1.PostgresClusterSpec{ + Router: &postgresv1alpha1.RouterSpec{ + Replicas: 2, + Autoscale: &postgresv1alpha1.RouterAutoscaleSpec{ + Enabled: true, + MinReplicas: 3, + MaxReplicas: 9, + TargetCPU: 55, + }, + }, + }, + } + + hpa := buildRouterHPA(cluster, RouterDeploymentName("orders")) + + if hpa.Spec.MinReplicas == nil || *hpa.Spec.MinReplicas != 3 { + t.Fatalf("minReplicas = %v, want 3", hpa.Spec.MinReplicas) + } + if hpa.Spec.MaxReplicas != 9 { + t.Fatalf("maxReplicas = %d, want 9", hpa.Spec.MaxReplicas) + } + gotCPU := hpa.Spec.Metrics[0].Resource.Target.AverageUtilization + if gotCPU == nil || *gotCPU != 55 { + t.Fatalf("targetCPU = %v, want 55", gotCPU) + } +} + +func TestBuildRouterDeployment_LabelsAutoscaleManagedReplicas(t *testing.T) { + t.Parallel() + + cluster := &postgresv1alpha1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "orders", Namespace: "default"}, + Spec: postgresv1alpha1.PostgresClusterSpec{ + Router: &postgresv1alpha1.RouterSpec{ + Replicas: 2, + Autoscale: &postgresv1alpha1.RouterAutoscaleSpec{ + Enabled: true, + MinReplicas: 3, + MaxReplicas: 8, + }, + }, + }, + } + + dep := buildRouterDeployment(cluster, "orders-router", "orders-router-config", "example.com/router:dev", routerMinReplicas(cluster), corev1.ResourceRequirements{}) + + if dep.Labels[RouterAutoscaleLabelKey] != "true" { + t.Fatalf("deployment label %s = %q, want true", RouterAutoscaleLabelKey, dep.Labels[RouterAutoscaleLabelKey]) + } + if dep.Spec.Template.Labels[RouterAutoscaleLabelKey] != "true" { + t.Fatalf("pod template label %s = %q, want true", RouterAutoscaleLabelKey, dep.Spec.Template.Labels[RouterAutoscaleLabelKey]) + } + if dep.Spec.Replicas == nil || *dep.Spec.Replicas != 3 { + t.Fatalf("initial replicas = %v, want minReplicas 3", dep.Spec.Replicas) + } +} + // assertDataplaneSecurityContext는 PG StatefulSet과 Router Deployment 모두에서 // 동일한 검증을 수행한다. PodSecurityContext + Container.SecurityContext + // emptyDir mount 3개(/tmp, /run, /var/run/postgresql) 모두 존재해야 한다. diff --git a/internal/controller/names.go b/internal/controller/names.go index b1fd5f2..20b9699 100644 --- a/internal/controller/names.go +++ b/internal/controller/names.go @@ -83,6 +83,11 @@ func RouterConfigMapName(cluster string) string { return fmt.Sprintf("%s-router-config", cluster) } +// RouterHPAName은 QueryRouter Deployment 를 대상으로 하는 HPA 이름을 반환한다. +func RouterHPAName(cluster string) string { + return fmt.Sprintf("%s-router", cluster) +} + // PoolerDeploymentName 은 Pooler CR 이 소유하는 PgBouncer Deployment 이름이다. func PoolerDeploymentName(pooler string) string { return fmt.Sprintf("%s-pooler", pooler) @@ -144,6 +149,9 @@ func SelectorLabels(cluster, role string, shardOrdinal int32) map[string]string // ReshardTargetLabelKey 는 resharding target shard 를 식별하는 label key 다 (ADR-0027). const ReshardTargetLabelKey = "postgres.keiailab.io/reshard-target" +// RouterAutoscaleLabelKey 는 router Deployment replicas 를 HPA 가 관리하는지 표시한다. +const RouterAutoscaleLabelKey = "postgres.keiailab.io/router-autoscale" + // ShardIDLabelKey 는 *명명(named) shard* 식별 label key 다 (ADR-0029). ordinal shard 는 // `shard-`, 승격된 resharding target 은 그 shardID 를 값으로 갖는다 — ordinal·명명 shard 를 // 통합 식별하는 토대. *셀렉터(STS .spec.selector, Service selector)에는 넣지 않는다* — 기존 From 9b84865c3ab20bc87142b1ee4f59ac66673183c0 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Mon, 29 Jun 2026 12:09:08 +0900 Subject: [PATCH 104/109] chore(test): allow windows go test executables --- docs/WORK_HANDOFF.ko.md | 9 ++++ scripts/allow-windows-test-exe.ps1 | 76 ++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 scripts/allow-windows-test-exe.ps1 diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index a41351a..afb922c 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -403,6 +403,15 @@ kubectl -n postgres-operator-system set env deploy/postgres-operator-controller- powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-windows.ps1 -Preset controller -GinkgoFocus "Promote phase" ``` + Windows Defender 가 `controller.test.exe` 같은 Go test 임시 실행 파일을 반복 검사해 병목을 만들면, + 관리자 PowerShell 에서 다음을 한 번 실행한다. repo 전체가 아니라 wrapper 가 사용하는 repo 외부 temp/cache + 디렉터리만 예외 처리한다: + + ```powershell + powershell -NoProfile -ExecutionPolicy Bypass -File scripts\allow-windows-test-exe.ps1 + powershell -NoProfile -ExecutionPolicy Bypass -File scripts\allow-windows-test-exe.ps1 -Check + ``` + ### 6.9 현재 미구현 / 부분구현 재정리 (2026-06-29) - **테스트 자원 운영 정책**: 개발 중에는 Windows wrapper 같은 저비용 smoke 만 사용하고, 기능 단위가 diff --git a/scripts/allow-windows-test-exe.ps1 b/scripts/allow-windows-test-exe.ps1 new file mode 100644 index 0000000..3c855b3 --- /dev/null +++ b/scripts/allow-windows-test-exe.ps1 @@ -0,0 +1,76 @@ +param( + [switch]$Remove, + [switch]$Check +) + +Set-StrictMode -Version 2.0 +$ErrorActionPreference = "Stop" + +function Ensure-Directory { + param([string]$Path) + New-Item -ItemType Directory -Force -Path $Path | Out-Null + return (Resolve-Path -LiteralPath $Path).Path +} + +function Test-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Normalize-PathSet { + param([string[]]$Paths) + $set = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + foreach ($path in $Paths) { + if ([string]::IsNullOrWhiteSpace($path)) { + continue + } + [void]$set.Add([System.IO.Path]::GetFullPath($path).TrimEnd('\')) + } + return ,$set +} + +$baseDir = Join-Path $env:LOCALAPPDATA "keiailab\postgres-operator" +$goTmpDir = Ensure-Directory (Join-Path $baseDir "go-tmp") +$goCacheDir = Ensure-Directory (Join-Path $baseDir "go-cache") +$exclusionPaths = @($goTmpDir, $goCacheDir) + +if (-not (Get-Command Get-MpPreference -ErrorAction SilentlyContinue) -or + -not (Get-Command Add-MpPreference -ErrorAction SilentlyContinue)) { + throw "Microsoft Defender PowerShell cmdlets are not available on this host." +} + +$current = Normalize-PathSet -Paths @((Get-MpPreference).ExclusionPath) +$desired = Normalize-PathSet -Paths $exclusionPaths + +Write-Host "Go test executable/cache paths:" +foreach ($path in $exclusionPaths) { + $enabled = $current.Contains([System.IO.Path]::GetFullPath($path).TrimEnd('\')) + Write-Host (" {0} DefenderExclusion={1}" -f $path, $enabled) +} + +if ($Check) { + exit 0 +} + +if (-not (Test-Administrator)) { + throw "Administrator PowerShell is required to change Microsoft Defender exclusions." +} + +foreach ($path in $exclusionPaths) { + $normalized = [System.IO.Path]::GetFullPath($path).TrimEnd('\') + if ($Remove) { + if ($current.Contains($normalized)) { + Remove-MpPreference -ExclusionPath $path + Write-Host "removed Defender exclusion: $path" + } + continue + } + + if (-not $current.Contains($normalized)) { + Add-MpPreference -ExclusionPath $path + Write-Host "added Defender exclusion: $path" + } else { + Write-Host "already allowed: $path" + } +} From fad60b2aba62d6cf5caf1dd33c3359bc2ebdabe9 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Mon, 29 Jun 2026 12:12:44 +0900 Subject: [PATCH 105/109] feat(router): reconcile cpu hpa --- internal/controller/builders.go | 5 +- internal/controller/builders_test.go | 3 + .../controller/postgrescluster_controller.go | 38 ++++- .../postgrescluster_controller_test.go | 133 ++++++++++++++++++ 4 files changed, 174 insertions(+), 5 deletions(-) diff --git a/internal/controller/builders.go b/internal/controller/builders.go index 57965b6..b8fbcbf 100644 --- a/internal/controller/builders.go +++ b/internal/controller/builders.go @@ -1343,7 +1343,8 @@ func buildRouterDeployment( replicas int32, resources corev1.ResourceRequirements, ) *appsv1.Deployment { - labels := SelectorLabels(cluster.Name, "router", -1) + selectorLabels := SelectorLabels(cluster.Name, "router", -1) + labels := maps.Clone(selectorLabels) if routerAutoscaleEnabled(cluster) { labels[RouterAutoscaleLabelKey] = "true" } @@ -1356,7 +1357,7 @@ func buildRouterDeployment( }, Spec: appsv1.DeploymentSpec{ Replicas: &replicas, - Selector: &metav1.LabelSelector{MatchLabels: labels}, + Selector: &metav1.LabelSelector{MatchLabels: selectorLabels}, Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{Labels: labels}, Spec: corev1.PodSpec{ diff --git a/internal/controller/builders_test.go b/internal/controller/builders_test.go index 7dbfc0a..db01d9c 100644 --- a/internal/controller/builders_test.go +++ b/internal/controller/builders_test.go @@ -806,6 +806,9 @@ func TestBuildRouterDeployment_LabelsAutoscaleManagedReplicas(t *testing.T) { if dep.Spec.Template.Labels[RouterAutoscaleLabelKey] != "true" { t.Fatalf("pod template label %s = %q, want true", RouterAutoscaleLabelKey, dep.Spec.Template.Labels[RouterAutoscaleLabelKey]) } + if dep.Spec.Selector.MatchLabels[RouterAutoscaleLabelKey] != "" { + t.Fatalf("selector must not include mutable autoscale label %s", RouterAutoscaleLabelKey) + } if dep.Spec.Replicas == nil || *dep.Spec.Replicas != 3 { t.Fatalf("initial replicas = %v, want minReplicas 3", dep.Spec.Replicas) } diff --git a/internal/controller/postgrescluster_controller.go b/internal/controller/postgrescluster_controller.go index 1236115..f9623a9 100644 --- a/internal/controller/postgrescluster_controller.go +++ b/internal/controller/postgrescluster_controller.go @@ -32,6 +32,7 @@ import ( "github.com/prometheus/client_golang/prometheus" appsv1 "k8s.io/api/apps/v1" + autoscalingv2 "k8s.io/api/autoscaling/v2" corev1 "k8s.io/api/core/v1" policyv1 "k8s.io/api/policy/v1" rbacv1 "k8s.io/api/rbac/v1" @@ -115,6 +116,7 @@ type PostgresClusterReconciler struct { // +kubebuilder:rbac:groups=postgres.keiailab.io,resources=imagecatalogs;clusterimagecatalogs,verbs=get;list;watch // +kubebuilder:rbac:groups=postgres.keiailab.io,resources=postgresusers,verbs=get;list;watch // +kubebuilder:rbac:groups=apps,resources=statefulsets;deployments,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=autoscaling,resources=horizontalpodautoscalers,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups="",resources=services;configmaps;secrets;serviceaccounts,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups="",resources=persistentvolumeclaims,verbs=get;list;watch;patch;delete // +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch;patch;delete @@ -401,6 +403,14 @@ func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Requ if err := r.upsert(ctx, &cluster, desiredDep); err != nil { return r.handleUpsertErr(ctx, &cluster, err, "router Deployment", logger) } + if routerAutoscaleEnabled(&cluster) && !databasePodsStopped { + if err := r.upsert(ctx, &cluster, buildRouterHPA(&cluster, depName)); err != nil { + return r.handleUpsertErr(ctx, &cluster, err, "router HPA", logger) + } + } else if err := r.deleteRouterHPA(ctx, &cluster); err != nil { + logger.Error(err, "Failed to delete router HPA", "name", RouterHPAName(cluster.Name)) + return ctrl.Result{}, err + } // router Deployment 도 cache propagation 지연을 graceful 처리. var observed appsv1.Deployment @@ -418,6 +428,9 @@ func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Requ ReadyReplicas: observedReady, Endpoint: fmt.Sprintf("%s.%s.svc.cluster.local:%d", svcName, cluster.Namespace, pgPort), } + } else if err := r.deleteRouterHPA(ctx, &cluster); err != nil { + logger.Error(err, "Failed to delete inactive router HPA", "name", RouterHPAName(cluster.Name)) + return ctrl.Result{}, err } // 2.5. PVC online expansion (PR #33): Spec.Shards.Storage.Size 증가 시 @@ -783,6 +796,19 @@ func (r *PostgresClusterReconciler) managedRolesStatus( return &status, nil } +func (r *PostgresClusterReconciler) deleteRouterHPA(ctx context.Context, cluster *postgresv1alpha1.PostgresCluster) error { + hpa := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: RouterHPAName(cluster.Name), + Namespace: cluster.Namespace, + }, + } + if err := r.Delete(ctx, hpa); err != nil && !apierrors.IsNotFound(err) { + return err + } + return nil +} + func managedRolesStatusForUsers( cluster *postgresv1alpha1.PostgresCluster, users []postgresv1alpha1.PostgresUser, @@ -920,8 +946,7 @@ func (r *PostgresClusterReconciler) upsert(ctx context.Context, owner *postgresv } // copySpec 은 src 의 Spec 필드를 dst 로 복사한다 (현재 지원: ConfigMap/Service/ -// StatefulSet/Deployment). 다른 타입이 들어오면 panic — F01b 에서 호출 가능한 -// 타입은 4 종 뿐이므로 명시적으로 타입을 좁혀 잘못된 사용을 빠르게 발견한다. +// StatefulSet/Deployment/HPA 등). 다른 타입이 들어오면 로그로 빠르게 발견한다. func copySpec(dst, src client.Object) { switch d := dst.(type) { case *corev1.ConfigMap: @@ -952,12 +977,18 @@ func copySpec(dst, src client.Object) { d.Labels = s.Labels case *appsv1.Deployment: s := src.(*appsv1.Deployment) - d.Spec.Replicas = s.Spec.Replicas + if !(s.Labels[RouterAutoscaleLabelKey] == "true" && d.GetResourceVersion() != "") { + d.Spec.Replicas = s.Spec.Replicas + } d.Spec.Template = s.Spec.Template if d.Spec.Selector == nil { d.Spec.Selector = s.Spec.Selector } d.Labels = s.Labels + case *autoscalingv2.HorizontalPodAutoscaler: + s := src.(*autoscalingv2.HorizontalPodAutoscaler) + d.Spec = s.Spec + d.Labels = s.Labels case *corev1.ServiceAccount: s := src.(*corev1.ServiceAccount) // ServiceAccount 는 spec 이 거의 비어 있음 — Labels 만 동기화. @@ -1054,6 +1085,7 @@ func (r *PostgresClusterReconciler) SetupWithManager(mgr ctrl.Manager) error { ). Owns(&appsv1.StatefulSet{}). Owns(&appsv1.Deployment{}). + Owns(&autoscalingv2.HorizontalPodAutoscaler{}). Owns(&corev1.Service{}). Owns(&corev1.ConfigMap{}). Named("postgrescluster"). diff --git a/internal/controller/postgrescluster_controller_test.go b/internal/controller/postgrescluster_controller_test.go index a0347cc..d755b44 100644 --- a/internal/controller/postgrescluster_controller_test.go +++ b/internal/controller/postgrescluster_controller_test.go @@ -16,6 +16,7 @@ import ( . "github.com/onsi/gomega" appsv1 "k8s.io/api/apps/v1" + autoscalingv2 "k8s.io/api/autoscaling/v2" corev1 "k8s.io/api/core/v1" policyv1 "k8s.io/api/policy/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -210,6 +211,138 @@ var _ = Describe("PostgresClusterReconciler — RFC 0001 spec", func() { }, envtestTimeout, envtestInterval).Should(Succeed()) }) + It("router autoscale creates router HPA when enabled", func() { + cluster := &postgresv1alpha1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "autoscale", Namespace: namespace}, + Spec: postgresv1alpha1.PostgresClusterSpec{ + PostgresVersion: "18", + ShardingMode: postgresv1alpha1.ShardingModeNative, + Shards: postgresv1alpha1.ShardsSpec{ + InitialCount: 1, + Replicas: 1, + Storage: postgresv1alpha1.StorageSpec{Size: resource.MustParse("1Gi")}, + }, + Router: &postgresv1alpha1.RouterSpec{ + Enabled: true, + Replicas: 2, + Autoscale: &postgresv1alpha1.RouterAutoscaleSpec{ + Enabled: true, + MinReplicas: 2, + MaxReplicas: 5, + TargetCPU: 60, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, cluster)).To(Succeed()) + + Eventually(func(g Gomega) { + var hpa autoscalingv2.HorizontalPodAutoscaler + g.Expect(k8sClient.Get(ctx, types.NamespacedName{ + Namespace: namespace, Name: RouterHPAName("autoscale"), + }, &hpa)).To(Succeed()) + g.Expect(hpa.Spec.ScaleTargetRef.Kind).To(Equal("Deployment")) + g.Expect(hpa.Spec.ScaleTargetRef.Name).To(Equal(RouterDeploymentName("autoscale"))) + g.Expect(hpa.Spec.MinReplicas).NotTo(BeNil()) + g.Expect(*hpa.Spec.MinReplicas).To(Equal(int32(2))) + g.Expect(hpa.Spec.MaxReplicas).To(Equal(int32(5))) + g.Expect(hpa.Spec.Metrics[0].Resource.Target.AverageUtilization).NotTo(BeNil()) + g.Expect(*hpa.Spec.Metrics[0].Resource.Target.AverageUtilization).To(Equal(int32(60))) + }, envtestTimeout, envtestInterval).Should(Succeed()) + }) + + It("router autoscale deletes router HPA when disabled", func() { + cluster := &postgresv1alpha1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "autoscale-off", Namespace: namespace}, + Spec: postgresv1alpha1.PostgresClusterSpec{ + PostgresVersion: "18", + ShardingMode: postgresv1alpha1.ShardingModeNative, + Shards: postgresv1alpha1.ShardsSpec{ + InitialCount: 1, + Replicas: 1, + Storage: postgresv1alpha1.StorageSpec{Size: resource.MustParse("1Gi")}, + }, + Router: &postgresv1alpha1.RouterSpec{ + Enabled: true, + Replicas: 2, + Autoscale: &postgresv1alpha1.RouterAutoscaleSpec{ + Enabled: true, + MinReplicas: 2, + MaxReplicas: 5, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, cluster)).To(Succeed()) + Eventually(func(g Gomega) { + var hpa autoscalingv2.HorizontalPodAutoscaler + g.Expect(k8sClient.Get(ctx, types.NamespacedName{ + Namespace: namespace, Name: RouterHPAName("autoscale-off"), + }, &hpa)).To(Succeed()) + }, envtestTimeout, envtestInterval).Should(Succeed()) + + Eventually(func(g Gomega) { + var got postgresv1alpha1.PostgresCluster + g.Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: "autoscale-off"}, &got)).To(Succeed()) + got.Spec.Router.Autoscale.Enabled = false + g.Expect(k8sClient.Update(ctx, &got)).To(Succeed()) + }, envtestTimeout, envtestInterval).Should(Succeed()) + + Eventually(func(g Gomega) { + var hpa autoscalingv2.HorizontalPodAutoscaler + err := k8sClient.Get(ctx, types.NamespacedName{ + Namespace: namespace, Name: RouterHPAName("autoscale-off"), + }, &hpa) + g.Expect(apierrors.IsNotFound(err)).To(BeTrue()) + }, envtestTimeout, envtestInterval).Should(Succeed()) + }) + + It("router autoscale preserves existing router Deployment replicas", func() { + cluster := &postgresv1alpha1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "autoscale-preserve", Namespace: namespace}, + Spec: postgresv1alpha1.PostgresClusterSpec{ + PostgresVersion: "18", + ShardingMode: postgresv1alpha1.ShardingModeNative, + Shards: postgresv1alpha1.ShardsSpec{ + InitialCount: 1, + Replicas: 1, + Storage: postgresv1alpha1.StorageSpec{Size: resource.MustParse("1Gi")}, + }, + Router: &postgresv1alpha1.RouterSpec{ + Enabled: true, + Replicas: 2, + Autoscale: &postgresv1alpha1.RouterAutoscaleSpec{ + Enabled: true, + MinReplicas: 2, + MaxReplicas: 6, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, cluster)).To(Succeed()) + + Eventually(func(g Gomega) { + var dep appsv1.Deployment + g.Expect(k8sClient.Get(ctx, types.NamespacedName{ + Namespace: namespace, Name: RouterDeploymentName("autoscale-preserve"), + }, &dep)).To(Succeed()) + replicas := int32(4) + dep.Spec.Replicas = &replicas + g.Expect(k8sClient.Update(ctx, &dep)).To(Succeed()) + }, envtestTimeout, envtestInterval).Should(Succeed()) + + bumpAnnotation(ctx, cluster) + + Consistently(func(g Gomega) { + var dep appsv1.Deployment + g.Expect(k8sClient.Get(ctx, types.NamespacedName{ + Namespace: namespace, Name: RouterDeploymentName("autoscale-preserve"), + }, &dep)).To(Succeed()) + g.Expect(dep.Spec.Replicas).NotTo(BeNil()) + g.Expect(*dep.Spec.Replicas).To(Equal(int32(4))) + }, 2*time.Second, envtestInterval).Should(Succeed()) + }) + It("adds active named reshard targets to cluster shard status", func() { clusterName := "named-status" keyspace := "default" From 3969d682f47c38f0ed04a6c3191590eaf60cb649 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Mon, 29 Jun 2026 12:14:24 +0900 Subject: [PATCH 106/109] fix(webhook): validate router autoscale bounds --- .../v1alpha1/postgrescluster_webhook.go | 21 ++++++ .../v1alpha1/postgrescluster_webhook_test.go | 64 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/internal/webhook/v1alpha1/postgrescluster_webhook.go b/internal/webhook/v1alpha1/postgrescluster_webhook.go index c7e98c1..00ae656 100644 --- a/internal/webhook/v1alpha1/postgrescluster_webhook.go +++ b/internal/webhook/v1alpha1/postgrescluster_webhook.go @@ -115,6 +115,27 @@ func (w *PostgresClusterWebhook) validate(c *postgresv1alpha1.PostgresCluster) ( } } + if c.Spec.Router != nil && c.Spec.Router.Autoscale != nil && c.Spec.Router.Autoscale.Enabled { + as := c.Spec.Router.Autoscale + if as.MaxReplicas <= 0 { + errs = append(errs, field.Invalid( + field.NewPath("spec", "router", "autoscale", "maxReplicas"), as.MaxReplicas, + "maxReplicas must be > 0 when router.autoscale.enabled=true")) + } + minReplicas := as.MinReplicas + if minReplicas == 0 && c.Spec.Router.Replicas > 0 { + minReplicas = c.Spec.Router.Replicas + } + if minReplicas == 0 { + minReplicas = 1 + } + if as.MaxReplicas > 0 && as.MaxReplicas < minReplicas { + errs = append(errs, field.Invalid( + field.NewPath("spec", "router", "autoscale", "maxReplicas"), as.MaxReplicas, + fmt.Sprintf("maxReplicas must be >= effective minReplicas (%d)", minReplicas))) + } + } + if b := c.Spec.Backup; b != nil && b.Enabled && b.Schedule == "" { errs = append(errs, field.Invalid( field.NewPath("spec", "backup", "schedule"), nil, diff --git a/internal/webhook/v1alpha1/postgrescluster_webhook_test.go b/internal/webhook/v1alpha1/postgrescluster_webhook_test.go index 1ab6c24..3177c7d 100644 --- a/internal/webhook/v1alpha1/postgrescluster_webhook_test.go +++ b/internal/webhook/v1alpha1/postgrescluster_webhook_test.go @@ -123,6 +123,70 @@ func TestValidate_AutoSplitDisabled_NoTriggerOk(t *testing.T) { } } +func TestValidate_RouterAutoscaleEnabled_RequiresMaxReplicas(t *testing.T) { + w := newWebhook(t) + c := validBaseCluster() + c.Spec.ShardingMode = postgresv1alpha1.ShardingModeNative + c.Spec.Router = &postgresv1alpha1.RouterSpec{ + Enabled: true, + Replicas: 2, + Autoscale: &postgresv1alpha1.RouterAutoscaleSpec{ + Enabled: true, + }, + } + + _, err := w.ValidateCreate(context.Background(), c) + if err == nil { + t.Fatal("expected rejection for router.autoscale.enabled=true without maxReplicas") + } + if !strings.Contains(err.Error(), "maxReplicas") { + t.Errorf("error message lacks 'maxReplicas': %v", err) + } +} + +func TestValidate_RouterAutoscaleEnabled_MaxMustBeAtLeastMin(t *testing.T) { + w := newWebhook(t) + c := validBaseCluster() + c.Spec.ShardingMode = postgresv1alpha1.ShardingModeNative + c.Spec.Router = &postgresv1alpha1.RouterSpec{ + Enabled: true, + Replicas: 2, + Autoscale: &postgresv1alpha1.RouterAutoscaleSpec{ + Enabled: true, + MinReplicas: 4, + MaxReplicas: 3, + }, + } + + _, err := w.ValidateCreate(context.Background(), c) + if err == nil { + t.Fatal("expected rejection for maxReplicas < minReplicas") + } + if !strings.Contains(err.Error(), "maxReplicas") { + t.Errorf("error message lacks 'maxReplicas': %v", err) + } +} + +func TestValidate_RouterAutoscaleEnabled_WithMaxAccepted(t *testing.T) { + w := newWebhook(t) + c := validBaseCluster() + c.Spec.ShardingMode = postgresv1alpha1.ShardingModeNative + c.Spec.Router = &postgresv1alpha1.RouterSpec{ + Enabled: true, + Replicas: 2, + Autoscale: &postgresv1alpha1.RouterAutoscaleSpec{ + Enabled: true, + MinReplicas: 2, + MaxReplicas: 6, + TargetCPU: 70, + }, + } + + if _, err := w.ValidateCreate(context.Background(), c); err != nil { + t.Fatalf("valid router autoscale should be accepted: %v", err) + } +} + func TestValidate_BackupEnabled_RequiresSchedule(t *testing.T) { w := newWebhook(t) c := validBaseCluster() From 5c3aa02e1818cdc57879b2c60a3951d5a3d68803 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Mon, 29 Jun 2026 12:18:44 +0900 Subject: [PATCH 107/109] chore(api): regenerate router autoscale rbac --- config/rbac/role.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 19f7c41..9d0b292 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -67,6 +67,18 @@ rules: - patch - update - watch +- apiGroups: + - autoscaling + resources: + - horizontalpodautoscalers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - batch resources: From 9aa0eef2b68cf637fca6a91f025f8ce9dd513518 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Mon, 29 Jun 2026 12:22:37 +0900 Subject: [PATCH 108/109] chore(test): allow controller test executable --- scripts/allow-windows-test-exe.ps1 | 41 ++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/scripts/allow-windows-test-exe.ps1 b/scripts/allow-windows-test-exe.ps1 index 3c855b3..755db24 100644 --- a/scripts/allow-windows-test-exe.ps1 +++ b/scripts/allow-windows-test-exe.ps1 @@ -30,18 +30,32 @@ function Normalize-PathSet { return ,$set } +function Normalize-StringSet { + param([string[]]$Values) + $set = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + foreach ($value in $Values) { + if ([string]::IsNullOrWhiteSpace($value)) { + continue + } + [void]$set.Add($value.Trim()) + } + return ,$set +} + $baseDir = Join-Path $env:LOCALAPPDATA "keiailab\postgres-operator" $goTmpDir = Ensure-Directory (Join-Path $baseDir "go-tmp") $goCacheDir = Ensure-Directory (Join-Path $baseDir "go-cache") $exclusionPaths = @($goTmpDir, $goCacheDir) +$exclusionProcesses = @("controller.test.exe") if (-not (Get-Command Get-MpPreference -ErrorAction SilentlyContinue) -or -not (Get-Command Add-MpPreference -ErrorAction SilentlyContinue)) { throw "Microsoft Defender PowerShell cmdlets are not available on this host." } -$current = Normalize-PathSet -Paths @((Get-MpPreference).ExclusionPath) -$desired = Normalize-PathSet -Paths $exclusionPaths +$preferences = Get-MpPreference +$current = Normalize-PathSet -Paths @($preferences.ExclusionPath) +$currentProcesses = Normalize-StringSet -Values @($preferences.ExclusionProcess) Write-Host "Go test executable/cache paths:" foreach ($path in $exclusionPaths) { @@ -49,6 +63,12 @@ foreach ($path in $exclusionPaths) { Write-Host (" {0} DefenderExclusion={1}" -f $path, $enabled) } +Write-Host "Go test executable process names:" +foreach ($process in $exclusionProcesses) { + $enabled = $currentProcesses.Contains($process) + Write-Host (" {0} DefenderExclusion={1}" -f $process, $enabled) +} + if ($Check) { exit 0 } @@ -74,3 +94,20 @@ foreach ($path in $exclusionPaths) { Write-Host "already allowed: $path" } } + +foreach ($process in $exclusionProcesses) { + if ($Remove) { + if ($currentProcesses.Contains($process)) { + Remove-MpPreference -ExclusionProcess $process + Write-Host "removed Defender process exclusion: $process" + } + continue + } + + if (-not $currentProcesses.Contains($process)) { + Add-MpPreference -ExclusionProcess $process + Write-Host "added Defender process exclusion: $process" + } else { + Write-Host "already allowed process: $process" + } +} From d13db0e4817f4e2575b2db5c30188c6446ac5af7 Mon Sep 17 00:00:00 2001 From: NetworkModuleTest Developer Date: Mon, 29 Jun 2026 14:23:07 +0900 Subject: [PATCH 109/109] docs: sync roadmap/handoff/tests with current sharding+HPA implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ROADMAP.ko G4 전 phase 결선(InitialCopy/CDC/Cutover/Cleanup/Promote)· G5 per-query/scatter/reference·G3 router CPU HPA 반영, Change log 2건 - WORK_HANDOFF 6.9 router autoscaling "미구현"→CPU HPA 구현완료 정정 - ROUTER-TESTS.ko 2026-06-29 기준 테스트 카탈로그 재구성 (resharding 컨트롤러 결선 envtest §2.12, router HPA §2.13, router-bench §2.14, 라이브검증 §3 누적) - TEST_ANALYSIS 커버리지 스냅샷 stale 경고 + 최신 카탈로그 포인터 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TnVYb8dUQFg2GSiSwo1dtZ --- docs/ROADMAP.ko.md | 30 ++++---- docs/TEST_ANALYSIS.md | 9 +++ docs/WORK_HANDOFF.ko.md | 12 ++-- docs/sharding/ROUTER-TESTS.ko.md | 116 +++++++++++++++++++++++++------ 4 files changed, 130 insertions(+), 37 deletions(-) diff --git a/docs/ROADMAP.ko.md b/docs/ROADMAP.ko.md index cb1e3bc..ebb95e8 100644 --- a/docs/ROADMAP.ko.md +++ b/docs/ROADMAP.ko.md @@ -147,6 +147,7 @@ - [x] **라우팅 키 추출** — 제로 의존성 토크나이저(regex/parser/auto). 모호키 bail·dollar-quote·extended Parse 처리. - [x] **Shard-placement lookup** — vindex + 교체 가능 토폴로지(static↔CRD watch) + failover-aware 백엔드(status.primary). - [x] **Connection routing** — connection mode(startup param) + **query mode**(쿼리 인지, `PGROUTER_MODE=query`): 첫 쿼리에서 키 추출→QueryRouter→샤드 backend. + - [x] **Router 수평확장** — stateless router Deployment 를 `spec.router.replicas` 로 수동 scale + **CPU 기반 HPA**(`spec.router.autoscale.{enabled,minReplicas,maxReplicas,targetCPUUtilizationPercentage}`): `buildRouterHPA`(autoscaling/v2, CPU utilization target) + reconcile/delete gate + `Owns(HPA)` + autoscaling/v2 RBAC + webhook bounds 검증(max>0, max≥effective-min). active-connection custom metric 어댑터는 후속. - [~] **drift guard / placement** — `placement.go` drift 감지(Missing/Extra/Zone/NotReady) 순수함수 구현, reconciler 결선 후속. - **✅ Verify 완료 (2026-06-27 라이브)**: 2 trust postgres + pg-router query-mode → psql `SELECT located_on FROM probe WHERE id='alice'`이 **alice→shard-0 / bob→shard-1 / carol→shard-0** 결정적·올바른 라우팅. (제약: trust 백엔드 + simple/inline-literal query. scram 인증·describe-first 파라미터·scatter는 후속 — [ROUTER-GAP-ANALYSIS §6](sharding/ROUTER-GAP-ANALYSIS.ko.md).) @@ -154,27 +155,30 @@ **목표**: 데이터 손실 없는 split / rebalance. -- [x] **`ShardSplitJob` CRD** — `api/v1alpha1/shardsplitjob_types.go`. -- [~] **7-step state machine** — `shardsplitjob_controller.go` *골격* (phase 전이 + Bootstrap target 자원 생성 + RoutingUpdate). **실 데이터 이동은 미구현(별 트랙)**: - - [~] 1. Snapshot + WAL — phase 전이만. +- [x] **`ShardSplitJob` CRD** — `api/v1alpha1/shardsplitjob_types.go` (`spec.online` / `cdcMaxLag` / `allowForwardOnly` 포함). +- [x] **phase state machine** — `shardsplitjob_controller.go` + `shardsplitjob_copy.go` + `shardsplitjob_abort.go`. 전 phase 가 K8s Job 실행모델로 결선됨(컨트롤러는 PG 직접접속 대신 cluster 내부 reshard Job 을 생성·게이트): + - [~] 1. Snapshot + WAL — phase 전이. - [x] 2. 대상 shard bootstrap — ConfigMap+Service+STS 생성(격리 식별, ADR-0027). - - [ ] 3. Initial copy — `router.CopyTable` DSN 결선 미완. - - [ ] 4. CDC catch-up — 논리복제 미결선. - - [ ] 5. Cutover (write-block) — 미구현. + - [x] 3. Initial copy (offline) — `reconcileInitialCopy` 가 target 별 reshard-copy Job 생성, `router.CopyShardRange`(vindex 로 자기 범위 row 만 복사) + `ensureTargetTable` + `ReplicateIndexes` + `ReplicateConstraints`. + - [x] 4. CDC catch-up (online 무중단) — `reconcileCDC`: cdc-setup Job(`CreatePublication`/`CreateSubscription` copy_data + lag≤`cdcMaxLag` 대기) → write-block → cdc-finalize Job(drain + `DeleteForeignRange` + sub/pub drop). `wal_level=logical`. live `TestCDCLive` 가 라이브쓰기 유실 0 증명. + - [x] 5. Cutover (write-block) — `ShardRange.spec.writeBlocked` → 라우터 `ErrWriteBlocked`(SQLSTATE 25006), 읽기는 통과. `setWriteBlock` + RoutingUpdate 가 flip 과 함께 해제. - [x] 6. Routing 갱신 — ShardRange ranges 교체(가역). - - [ ] 7. Source cleanup. + - [x] 7. Source cleanup — `reconcileCleanup` 가 source 에서 이동분 삭제 Job(`DeleteShardRange`, delete-only)을 띄우고 게이트. +- [x] **Target 승격 (ADR-0029 P-B)** — `Promote` phase: source 가 active set 에서 빠지고 target Pod 가 Ready 일 때만 `adoptTargetShardIdentity`(named `shard-id` label 부여, fenced single-authority). source 자원은 retain-by-default(STS replicas=0 + 관측 제외). - [x] **split-plan 보존 불변식 검증** — `resharding.go` ValidateSplitPlan(gap/overlap/coverage). -- Verify: split 중 데이터 무결성(checksum) + cutover-window 측정 — **실데이터 이동(3·4·5) 결선 후** 가능. +- **✅ Verify 완료 (2026-06-28 라이브)**: kind 실 K8s + 실 PG 에서 offline·online 양 경로 full e2e — 단일샤드(키 100)→ShardRange+ShardSplitJob→전 phase(Bootstrap→InitialCopy/CDCCatchup→Cutover→RoutingUpdate→Cleanup→Completed)→ **t0=44 / t1=56 / source=0, 합=100 키유실 0, PK 인덱스 target 복제, ShardRange flip + writeBlock 해제**. 남은 live gate: native router 동시쓰기 부하하 무중단 실증, target 승격 후 chaos/failover drill. ### Gate G5 — Distributed SQL (~0% buffer) **목표**: cross-shard 쿼리 / 트랜잭션 지원 범위를 명확히 한정. -- [~] **Scatter-gather** 쿼리 path — in-process 라이브러리 동작(`scatter.go` fan-out + 타입인지 merge + LIMIT/ORDER BY pushdown, `SQLShardExecutor` 실 lib/pq + 연결풀). **프록시 레벨 wire-protocol forwarding + 결과 재조립은 후속**(query-mode는 현재 단일샤드 fast-path만; 키 없으면 scatter 신호). Ref: RFC-0004 §2.2. -- [~] **2PC / saga** 분산 트랜잭션 선택 — ADR-0015 결정 (2PC primary + saga deferred). 실 구현은 명시적 후순위(멀티테넌트 v1 불필요). +- [x] **Per-query routing (wire-protocol 종단)** — `cmd/pg-router` query-mode 가 simple(`persession.go`)·extended(`extsession.go`) 둘 다 *매 쿼리* 독립 라우팅(vtgate 모델), 샤드별 백엔드 lazy 풀링, prepare-on-first-use, tx pin. scram-sha-256 백엔드 인증 대행(`scram.go`) + describe-round(`describeround.go`)로 lib/pq/pgx 등 실 드라이버 동작. **라이브 검증됨**. +- [x] **Scatter-gather** 쿼리 path — 키 없는 쿼리를 모든 샤드에 병렬 fan-out 후 재조립하는 **프록시 레벨 wire forwarding**(`scattermode.go`) + in-process 라이브러리(`scatter.go` 타입인지 merge + LIMIT/ORDER BY pushdown). **라이브 검증됨**(SELECT 키없음 → 양샤드 병합). Ref: RFC-0004 §2.2. +- [x] **Reference / read-replica 라우팅** — reference-only → AnyShard, 읽기 → failover-aware read resolver(`PGROUTER_BACKEND__REPLICA`). pg-router 결선·라이브 검증됨. +- [~] **2PC / saga** 분산 트랜잭션 선택 — ADR-0015 결정 (2PC primary + saga deferred). 실 구현은 명시적 후순위(멀티테넌트 v1 불필요). cross-shard 2PC, extended scatter, Flush 파이프라이닝은 범위 밖. - [x] **Isolation matrix** 문서화 — `docs/sql/isolation-matrix.md` (D.10.3). -- [~] **벤치마크** — `test/bench/{pgbench,sysbench}.sh` + **single-shard baseline 실측 완료**(`docs/perf/baseline.md §3.0`, 2026-06-27). N-shard 분산 수치·percentile·sysbench·전용 PV는 후속. -- Verify: 분산 수치(N-shard) — scatter forwarding 결선 후. +- [x] **분산 처리량 실측** — `cmd/router-bench` + `docs/perf/baseline.md §3.0b~3.0f`: single-shard baseline, 라우터경유 점읽기 동시성 스케일(1.7K→9.4K TPS), prepared 재사용 ~1.9×, bufio 최적화(+34~50%), 멀티샤드/멀티라우터 측정. **발견: 단일 16vCPU 호스트에선 CPU+overlay-fsync 공유로 2샤드 ≤ 1샤드 — 진짜 수평스케일 수치는 물리분리 노드 필요**(router-bench 가 멀티머신 DSN 수용). +- Verify: 라이브 per-query/scatter 라우팅(2026-06-28, 단일 연결에서 alice→shard-0/bob→shard-1 결정적) + baseline §3.0 분산 수치. 멀티머신 수평스케일 실측은 하드웨어 후속. ### Gate G6 — 1.0.0 GA (~15% buffer) @@ -203,6 +207,8 @@ | 날짜 | 변경 | |---|---| +| 2026-06-29 | G3 §pg-router: router CPU HPA(`spec.router.autoscale`, `buildRouterHPA` autoscaling/v2 + reconcile/delete gate + RBAC + webhook bounds 검증) `[x]` 추가. | +| 2026-06-28 | G4 전 phase `[~]골격` → `[x]`: InitialCopy/CDCCatchup(online CDC)/Cutover(write-block)/Cleanup K8s Job 결선 + ADR-0029 P-B Promote phase. offline·online full e2e(키유실 0) 라이브 검증. G5: per-query routing(simple+extended wire 종단)·scatter forwarding·reference/read-replica `[x]`, 분산 처리량 실측(baseline §3.0b~3.0f) `[x]`. | | 2026-05-16 | G3 §Sharding foundation: `ShardingMode` / `ShardsSpec` / `Sharding plugin interface` 를 unit-test coverage 와 함께 `[~]` → `[x]` 로 flip (`TestShardingMode`, `TestShardsSpec`, `TestShardingPlugin`). Plans `2026-05-14-4-operators-100pct/P-D` §D.7. | | 2026-05-12 | Backup/restore 격차 해소: `ScheduledBackup` CRD/controller, cron firing 시 `BackupJob` 생성, `BackupJob.spec.type=restore` → `RestorePIT` call path, `executionMode=job` runner Job lifecycle, pgBackRest command-runner plugin 등록, sidecar pod-exec path 추가. | | 2026-05-12 | Observability 격차 해소: Helm metrics Service / ServiceMonitor / PrometheusRule + `postgres_operator_backupjob_phase` Prometheus 메트릭 추가. | diff --git a/docs/TEST_ANALYSIS.md b/docs/TEST_ANALYSIS.md index 925e5be..377afb6 100644 --- a/docs/TEST_ANALYSIS.md +++ b/docs/TEST_ANALYSIS.md @@ -3,6 +3,15 @@ > 실행 환경: Dev Container (golang:1.26, Debian 13 trixie) | 실행 명령: `make test` > 테스트 프레임워크: Go 표준 `testing` + Ginkgo v2 (컨트롤러 통합) | envtest: Kubernetes 1.36 +> ⚠️ **수치 스냅샷 주의 (2026-06-29 기준)**: 아래 §2 패키지별 **커버리지 / 소요시간 표는 2026-06-25경 +> `make test` 실행 스냅샷**이다. 이후 분산 SQL 라우터(per-query simple+extended, scatter forwarding, +> scram 인증대행), 온라인 resharding 컨트롤러 결선(InitialCopy / CDCCatchup / Cutover write-block / +> Cleanup / Promote phase), router CPU HPA 등 대량 테스트가 추가돼 패키지 구성·커버리지가 바뀌었다. +> 신규 패키지 `cmd/reshard-copy-poc`·`cmd/router-bench`(측정 도구)는 표에 아직 없다. **정확한 테스트 +> 카탈로그(무엇을 어떻게 검증/실행)는 [`docs/sharding/ROUTER-TESTS.ko.md`](sharding/ROUTER-TESTS.ko.md) +> 가 2026-06-29 기준으로 최신**이며, 아래 수치는 Docker/Dev Container 로 `make test` 재실행 후 +> 갱신해야 한다(호스트 Windows 에 Go 미상주 → 정적 재구성 단계에서는 재측정 보류). + --- ## 1. 실행 환경 및 자원 사용 diff --git a/docs/WORK_HANDOFF.ko.md b/docs/WORK_HANDOFF.ko.md index afb922c..1e17a83 100644 --- a/docs/WORK_HANDOFF.ko.md +++ b/docs/WORK_HANDOFF.ko.md @@ -418,10 +418,14 @@ kubectl -n postgres-operator-system set env deploy/postgres-operator-controller- 닫힌 뒤 Docker/kind/Dev Container 로 통합·라이브 검증을 묶어서 수행한다. e2e Make target 은 `KEEP=1` 을 주지 않는 한 종료 시 `cleanup-test-e2e` 로 kind 클러스터를 반납한다. 테스트 완료 후에는 pass/fail, 실패 RCA, 남은 위험, 정리된 자원 상태를 브리핑한다. -- **Router autoscaling**: `PostgresCluster.spec.router.autoscale` 스키마와 RFC HPA 설계는 있으나, - `PostgresClusterReconciler` 는 현재 router `ConfigMap` / `Service` / `Deployment` 만 reconcile 한다. - `HorizontalPodAutoscaler` 생성, autoscaling/v2 RBAC, router active-connection metric 노출/어댑터 결선은 - 미구현이다. 현재 router 는 `spec.router.replicas` 기반 수동 scale 이다. +- **Router autoscaling (CPU HPA 구현 완료, 2026-06-29)**: `PostgresClusterReconciler` 가 router + `ConfigMap` / `Service` / `Deployment` 에 더해 **`HorizontalPodAutoscaler`** 도 reconcile 한다. + `spec.router.autoscale.{enabled,minReplicas,maxReplicas,targetCPUUtilizationPercentage}` → + `buildRouterHPA`(autoscaling/v2, CPU utilization target) → `routerAutoscaleEnabled` gate 에서 upsert, + 비활성/DB 정지 시 `deleteRouterHPA`. `Owns(HorizontalPodAutoscaler)` watch, autoscaling/v2 RBAC + (`config/rbac/role.yaml`), webhook bounds 검증(`maxReplicas>0`, `maxReplicas≥effective minReplicas`)까지 + 결선·단위테스트 완료. **남은 것**: active-connection custom metric 노출/어댑터(현재는 CPU utilization + 기준). 미설정 시 기존대로 `spec.router.replicas` 수동 scale. - **AutoSplit / 자동 shard 확장**: `spec.autoSplit` 스키마와 admission 검증은 있으나, shard size/latency/CPU 관측, 지속시간 판정, 후보 계산, `ShardSplitJob` 자동 생성 루프는 미구현이다. - **남은 live gate**: native router concurrent-write online resharding e2e, target promotion 후 live diff --git a/docs/sharding/ROUTER-TESTS.ko.md b/docs/sharding/ROUTER-TESTS.ko.md index 340c563..8800341 100644 --- a/docs/sharding/ROUTER-TESTS.ko.md +++ b/docs/sharding/ROUTER-TESTS.ko.md @@ -3,7 +3,9 @@ > `internal/router` + `cmd/pg-router`(분산 SQL 라우터)의 테스트케이스를 영역별로 정리한 > *추후 참고용 색인*이다. 무엇을 검증하는지 + 어떻게 돌리는지 + 라이브 검증 절차를 담는다. > -> 작성 기준: 2026-06-27. 관련: [ROUTER-GAP-ANALYSIS.ko.md](ROUTER-GAP-ANALYSIS.ko.md) +> 작성 기준: 2026-06-29 (per-query routing simple+extended, scatter forwarding, scram 인증대행, +> 온라인 resharding 전 phase 컨트롤러 결선, target 승격 Promote phase, router CPU HPA, 분산 처리량 +> 측정 반영). 관련: [ROUTER-GAP-ANALYSIS.ko.md](ROUTER-GAP-ANALYSIS.ko.md) > (설계·능력 사다리·백로그) · [`docs/perf/baseline.md`](../perf/baseline.md)(성능 실측) · > [`docs/TEST_ANALYSIS.md`](../TEST_ANALYSIS.md)(오퍼레이터 전체 테스트 분석). @@ -11,25 +13,39 @@ ## 1. 실행 방법 -호스트에 go/make 없음 → **컨테이너에서** 실행 (Dev Container 정식 절차는 dev-setup 문서). +호스트(Windows)에 go/make 상주 안 함 → 단위는 **Windows wrapper** 또는 **컨테이너**, 통합/라이브는 +**컨테이너/kind** (Dev Container 정식 절차는 dev-setup 문서). ```bash -# 라우터 + pg-router 단위 테스트 (라이브 클러스터 불필요, 전부 순수/in-memory) +# (A) 라우터 + pg-router 단위 테스트 (라이브 클러스터 불필요, 전부 순수/in-memory) docker run --rm -v :/src -w /src golang:1.26 \ - sh -c "go test ./internal/router/... ./cmd/pg-router/..." + sh -c "go test ./internal/router/... ./cmd/pg-router/... ./cmd/reshard-copy-poc/..." -# 커버리지 -... go test -cover ./internal/router/... # 최근 77.6% +# (B) 커버리지 +... go test -cover ./internal/router/... -# 전체 오퍼레이터 스위트(envtest 포함) — controller/webhook 등까지 +# (C) 전체 오퍼레이터 스위트(envtest 포함) — controller/webhook 등까지 ... make test + +# (D) resharding 컨트롤러 결선만 envtest focus (KUBEBUILDER_ASSETS 는 절대경로여야 함) +... KUBEBUILDER_ASSETS=$ASSETS go test ./internal/controller \ + --ginkgo.focus="ShardSplitJob|write-block|Promote phase|router autoscale" +``` + +```powershell +# (E) Windows 로컬 smoke wrapper (개발 중 빠른 단위 — Go test cache 유지, -Fresh 시 -count=1) +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-windows.ps1 -Preset router +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\test-windows.ps1 -Preset controller -GinkgoFocus "Promote phase" ``` - **외부 SQL 파서 의존성 없음**: 라우팅 키 추출 parser 전략은 *제로 의존성 토크나이저*라 별도 build-tag 불필요(전부 평이하게 컴파일·실행). (auxten 등 외부 파서는 genproto 충돌로 기각 — ROUTER-GAP-ANALYSIS §5.) +- **라이브 게이트 테스트는 env-gated**: `reshard_cdc_live_test.go` 의 `TestCDCLive` 는 `RESHARD_LIVE_*` + env 가 없으면 skip(라이브 PG 2대 `wal_level=logical` 필요). full e2e 는 kind 에서 별도 수행(§3). - 알려진 flaky: `internal/controller/failover` 의 `TestLeaseElection`(타이밍 의존)이 전체 병렬 부하에서 간헐 실패 → 단독 재실행 시 통과(`go test -count=1 ./internal/controller/failover/...`). +- Windows wrapper 는 최종 수용 검증이 아니다(smoke 전용). 기능 단위가 닫히면 Docker/kind 로 묶어 검증. --- @@ -111,12 +127,22 @@ docker run --rm -v :/src -w /src golang:1.26 \ | `…_NoDSN` | DSN 없는 샤드 → ErrNoDSN | | `…_SatisfiesInterface` | ScatterGather의 ShardExecutor로 주입 가능 | -### 2.10 Resharding · Placement · Metadata (기존) +### 2.10 Resharding 데이터이동 (copy · CDC) | 파일 · 테스트 | 검증 내용 | |---|---| | `resharding_test.go` `TestValidateSplitPlan` / `TestHexSuccessor` | split 보존 불변식(gap/overlap/coverage), hex 인접성 | -| `reshard_copy_test.go` `TestBuildInsert` / `TestCopyTable_RejectsInjection` | copy SQL 생성, 테이블명 인젝션 거부 | +| `reshard_copy_test.go` `TestBuildInsert` | INSERT SQL 생성(컬럼 따옴표·플레이스홀더) | +| `…TestCopyTable_RejectsInjection` / `TestCopyShardRange_RejectsInjection` | 테이블명 인젝션 거부(offline 범위 복사 포함) | +| `…TestFilterTables` | 사용자 테이블 발견에서 reference table 제외 | +| `…TestKeyString` / `TestIndexOfFold` | lib/pq `[]byte`→string 정규화, 대소문자 무시 컬럼 인덱스(헬퍼) | +| `reshard_cdc_live_test.go` `TestCDC_RejectsInjection` | pub/sub 이름·테이블 인젝션 거부(단위) | +| `…TestCDCLive` *(env-gated)* | **라이브**: subscription copy_data 초기복사 + 구독 후 라이브 INSERT/UPDATE 유실 0, `DeleteForeignRange` 자기범위만 잔존, PK 인덱스·CHECK 제약 target 복제 | + +### 2.10b Placement · Metadata (orphan 라이브러리) + +| 파일 · 테스트 | 검증 내용 | +|---|---| | `placement_test.go` `TestPlacementDrift` / `TestValidatePlacement` | drift 감지(Missing/Extra/Zone/Node/NotReady/RangeUncovered), placement 검증 | | `metadata_store_test.go` `TestPostgresStore` | `pg_keiailab` 스키마 마이그레이션 + Upsert/List/Delete | @@ -134,6 +160,40 @@ docker run --rm -v :/src -w /src golang:1.26 \ | `…TestParseSQL` / `TestBindParams` | extended 'P'(Parse) 쿼리 추출, 'B'(Bind) 파라미터 값 추출(NULL 포함) | | `…TestSendTrustHandshake` | trust 핸드셰이크 시퀀스(R-S-S-S-K-Z) | | `querymode_test.go` `TestQueryRouter_routeSQL` / `…_routeKey` | query-mode 라우팅 결정(SQL 인라인 / 값 직접), 같은 키 동일 샤드 | +| `…TestSession_KeylessWriteDoesNotScatter` | per-query 세션: 키 없는 *쓰기* 는 scatter 금지(읽기만 scatter) | +| `scram_test.go` `TestScramClientProof` / `TestParseScramAttrs` | scram-sha-256 백엔드 인증 대행(RFC 7677 client-proof 벡터, SASL attr 파싱) | +| `scattermode_test.go` `TestScatterQuery_NoShardsSendsReadyForQuery` | 샤드 0개 fan-out 시에도 `ReadyForQuery` 전송(클라이언트 hang 방지) | + +### 2.12 Resharding 컨트롤러 결선 (envtest, `internal/controller`) + +> 컨트롤러는 PG 에 직접 접속하지 않고 cluster 내부 reshard Job 을 생성·게이트한다. 아래는 그 Job +> lifecycle·phase 전이·write-block 신호를 envtest 로 검증한다. + +| 파일 · 테스트 | 검증 내용 | +|---|---| +| `shardsplitjob_copy_test.go` "InitialCopy 복사 Job 결선" | offline InitialCopy 가 target 별 복사 Job 멱등 생성·완료/실패 집계, DSN trust(무비번) env | +| `…` "Cleanup 이 delete-only Job 생성" | cutover 후 source 이동분 삭제 Job | +| `shardsplitjob_writeblock_test.go` "Cutover write-block" | Cutover→write-block ON, RoutingUpdate→flip+OFF, forward-only 는 write-block 미설정(비가역 거부) | +| `…` "Promote phase … shard-id adopt" | source 가 active set 에서 빠지고 target Pod Ready 일 때만 named `shard-id` adopt; source active/Pod not-Ready 중에는 보류 | +| `…` "online 모드 CDCCatchup" | cdc-setup Job → write-block → cdc-finalize Job 순서, phase별 실패 보고 | +| `…` "online abort cleanup" | cdc-abort Job 성공→write-block 해제·멱등, 실패→manual cleanup 필요 보고 | +| `aggregate_status_test.go` `TestAggregateNamedShardStatus_UsesReshardTargetLabel` | 활성 named reshard target 을 cluster shard status 에 편입 | + +### 2.13 Router 수평확장 (HPA) + +| 파일 · 테스트 | 검증 내용 | +|---|---| +| `builders_test.go` `TestBuildRouterHPA_CPUDefaultsAndTarget` | HPA 기본값(min/max, CPU utilization target), ScaleTargetRef=router Deployment | +| `…TestBuildRouterHPA_ExplicitMinAndCPU` | `spec.router.autoscale` 명시값 반영 | +| `…TestBuildRouterDeployment_LabelsAutoscaleManagedReplicas` | autoscale 활성 시 Deployment replicas 를 HPA 가 관리하도록 label 표시 | +| `postgrescluster_controller_test.go` "router autoscale creates/deletes HPA" | enabled→HPA upsert, disabled→HPA delete, 기존 Deployment replicas 보존 | +| `postgrescluster_webhook_test.go` (autoscale bounds) | `maxReplicas>0`, `maxReplicas≥effective minReplicas` admission 거부 | + +### 2.14 분산 처리량 측정 (`cmd/router-bench`) + +| 도구 | 측정 내용 | +|---|---| +| `cmd/router-bench/main.go` | `internal/router.ResolveShard` 로 키→샤드 배치 후 point 쿼리를 워커수↑ 던져 TPS 측정. `BENCH_PREPARED`(prepared 재사용), `BENCH_ROUTERS`(멀티 라우터 round-robin) 모드. 결과는 `docs/perf/baseline.md §3.0b~3.0f` (단위테스트 아님, 라이브 측정 도구) | --- @@ -141,22 +201,36 @@ docker run --rm -v :/src -w /src golang:1.26 \ 단위 테스트로 못 잡는 부분은 호스트 kind(Docker Desktop/WSL2 — 컨테이너 안 중첩 아님)에서 검증. -### 3.1 완료된 라이브 검증 +### 3.1 완료된 라이브 검증 (누적) - **성능 baseline (2026-06-27)**: 오퍼레이터 배포 → 단일샤드 PostgresCluster Ready → pgbench. 결과·환경·재현은 [`docs/perf/baseline.md §3.0`](../perf/baseline.md). -- **🎉 query-mode 쿼리 라우팅 (2026-06-27)**: 2 trust postgres(shard-0/1) + `pgrouter:dev` - (`PGROUTER_MODE=query`) → psql `SELECT located_on FROM probe WHERE id='alice'` 이 - **alice→shard-0 / bob→shard-1 / carol→shard-0** 결정적·올바르게 라우팅 + 실 쿼리 결과 반환. - 재현: 2 `postgres:18`(trust) + probe 테이블 + `docker build -f Dockerfile.router` + - psql(simple) / lib/pq(extended, scratchpad/pqclient). 라이브에서 백엔드 핸드셰이크 미소비 - 버그(`drainUntilReady`)·lib/pq describe-first 한계 발견. +- **query-mode 쿼리 라우팅 (2026-06-27)**: 2 trust postgres + `pgrouter:dev`(`PGROUTER_MODE=query`) + → **alice→shard-0 / bob→shard-1 / carol→shard-0** 결정적 라우팅. 백엔드 핸드셰이크 미소비 + 버그(`drainUntilReady`) 발견·수정. +- **scram 인증 대행 + describe-round (2026-06-27)**: scram-sha-256 백엔드(2샤드) + lib/pq(extended, + describe-first) → 인증 대행 후 정확 라우팅. 실 드라이버(lib/pq/pgx) 동작 증명. (scratchpad/pqclient) +- **per-query routing simple+extended (2026-06-28)**: **한 연결**에서 매 쿼리 독립 라우팅(vtgate 모델), + scatter(키없음 양샤드), tx pin, prepared statement 샤드별 lazy(prepare-on-first-use). (scratchpad/extclient) +- **scatter forwarding (2026-06-28)**: 키 없는 simple Query 를 모든 샤드 병렬 fan-out→병합(UNION ALL). +- **reference / read-replica (2026-06-28)**: reference→AnyShard, 읽기→replica(failover-aware, replica + 미설정 시 primary fallback), 쓰기→primary. +- **분산 처리량 실측 (2026-06-28)**: `router-bench` 로 라우터경유 점읽기 동시성 스케일·prepared·bufio· + 멀티샤드/멀티라우터 측정 → `baseline.md §3.0b~3.0f`. 단일호스트 물리한계(2샤드 ≤ 1샤드) 확정. +- **🎉 온라인 resharding full e2e (2026-06-28, kind 실 K8s+실 PG)**: 단일샤드(키 100)→ShardRange+ + ShardSplitJob → **offline·online 양 경로** 전 phase(Bootstrap→InitialCopy/CDCCatchup→Cutover→ + RoutingUpdate→Cleanup→Completed) → **t0=44 / t1=56 / source=0, 합=100 키유실 0**, PK 인덱스 target + 복제, ShardRange flip + writeBlock 해제. e2e 가 실제 갭 2건 발견·수정(이미지명, target 테이블 부재). - **referenceTables CRD**: 실 apiserver 수용 검증(server-side apply). -### 3.2 미완(라이브 failover 환경 필요 — 무검증 랜딩 금지) -- 자동 failover chaos drill (`make test-e2e-failover`): primary kill→fencing→승격→reseed. -- PITR restore drill (`make test-e2e`): WAL 아카이빙→offline restore→락 해제. -- 백로그 보류 3건(per-shard primary Service / watch hot-reload / failover lease P2-T3)의 라이브 검증. -- 분산 N-shard scatter 수치, percentile(pgbench `-l` 로그 후처리), sysbench, 전용 PV. +### 3.2 미완(라이브 환경 필요 — 무검증 랜딩 금지) +- **native router 동시쓰기 무중단 resharding e2e**: `shardingMode=native`(라우터 경유)에서 write + 부하 중 online CDC·write-block·routing flip 무중단 실증 (현 e2e 는 정적 데이터; 라이브쓰기 포착은 + `TestCDCLive` 로 별도 증명). [ROUTER-GAP-ANALYSIS §Batch 3 시나리오]. +- **target 승격 후 chaos/failover drill**: Promote 로 named shard 편입된 target 의 HA/failover 거동. +- **source-down abort cleanup fallback**: source 접속 불가 시 `cdc-abort` 가 `AbortCleanup=False` 로 + 남는 현 안전동작에 대한 target-only 강제정리 fallback (live drill 후 보강). +- 자동 failover chaos drill / PITR restore drill (HA·backup 영역; `make test-e2e-failover` / `-e2e`). +- 멀티머신 수평스케일 분산 수치(별 CPU+별 스토리지), percentile, sysbench, 전용 PV. ---