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 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/AGENTS.md b/AGENTS.md index 172656f..82a8d97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,3 +88,14 @@ 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` 로 확인한다(과거 "미구현" 오진 선례). +- **용어집 유지**: 각 분석 문서는 **마지막 장에 "용어집" 절**을 둔다. 용어 정의 SSOT 는 [`docs/GLOSSARY.ko.md`](docs/GLOSSARY.ko.md) — 각 문서는 등장 용어만 **그 정의를 그대로 발췌**(어디서 보든 동일) + 전체 링크. 새 용어는 GLOSSARY 에 먼저 추가하고 발췌 측을 맞춘다. 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"] diff --git a/Dockerfile.router b/Dockerfile.router new file mode 100644 index 0000000..c698d94 --- /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 + +FROM gcr.io/distroless/static:nonroot@sha256:e3f945647ffb95b5839c07038d64f9811adf17308b9121d8a2b87b6a22a80a39 +WORKDIR / +COPY --from=builder /workspace/pg-router . +USER 65532:65532 + +ENTRYPOINT ["/pg-router"] 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/api/v1alpha1/shardrange_types.go b/api/v1alpha1/shardrange_types.go index b72bea0..ccbe41f 100644 --- a/api/v1alpha1/shardrange_types.go +++ b/api/v1alpha1/shardrange_types.go @@ -100,6 +100,17 @@ type ShardRangeSpec struct { // +kubebuilder:validation:MinItems=1 // +kubebuilder:validation:MaxItems=1024 Ranges []ShardRangeEntry `json:"ranges"` + + // ReferenceTables은 이 키스페이스에서 *모든 샤드에 복제*되는 reference 테이블 목록이다. + // 이 테이블만 참조하는 쿼리는 샤딩 키 없이 임의 샤드로 라우팅된다 (분산 조인 우회). + // +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/api/v1alpha1/shardsplitjob_types.go b/api/v1alpha1/shardsplitjob_types.go index 299de76..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" @@ -101,6 +102,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/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/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..5ed4c7d 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: @@ -145,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/charts/postgres-operator/crds/postgres.keiailab.io_shardsplitjobs.yaml b/charts/postgres-operator/crds/postgres.keiailab.io_shardsplitjobs.yaml index f377a35..af7606c 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: @@ -272,6 +280,7 @@ spec: - Cutover - RoutingUpdate - Cleanup + - Promote - Completed - Failed - Aborted 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/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/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/dialer.go b/cmd/pg-router/dialer.go new file mode 100644 index 0000000..570fae4 --- /dev/null +++ b/cmd/pg-router/dialer.go @@ -0,0 +1,144 @@ +/* +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 + probing bool // half-open 상태에서 단일 probe 진행 중 +} + +// 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.allow(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 +} + +// isOpen 은 breaker 가 *현재 open*(cooldown 미경과)인지 읽기 전용으로 본다. +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 && !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) { + 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) + } + b.probing = false // half-open probe 실패 → 다음 cooldown 후 재시도 허용. +} diff --git a/cmd/pg-router/dialer_test.go b/cmd/pg-router/dialer_test.go new file mode 100644 index 0000000..3c15468 --- /dev/null +++ b/cmd/pg-router/dialer_test.go @@ -0,0 +1,123 @@ +/* +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_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() + 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/extsession.go b/cmd/pg-router/extsession.go new file mode 100644 index 0000000..d06a070 --- /dev/null +++ b/cmd/pg-router/extsession.go @@ -0,0 +1,327 @@ +/* +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" + "errors" + "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 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() + } + + 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/main.go b/cmd/pg-router/main.go index 9587d1e..e01b055 100644 --- a/cmd/pg-router/main.go +++ b/cmd/pg-router/main.go @@ -11,48 +11,225 @@ 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" "log" "net" "os" + "strconv" "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, readResolve, err := buildRouting(ctx) + 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), + ) + // 라우팅 모드: connection(기본, startup param) | query(첫 쿼리 인지 라우팅, PoC). + mode := strings.ToLower(env("PGROUTER_MODE", "connection")) + qr := newQueryRouter(provider, resolve, readResolve) + serverVersion := env("PGROUTER_SERVER_VERSION", "18.0") + backendPassword := env("PGROUTER_BACKEND_PASSWORD", "") // 백엔드 인증 대행(scram/cleartext)용. ""=trust. + 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 (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) + if mode == "query" { + go handleQueryMode(conn, qr, dialer, serverVersion, backendPassword) + } else { + go handleConn(conn, provider, resolve, dialer) + } + } +} + +// 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") + 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, 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, nil, fmt.Errorf("unknown PGROUTER_TOPOLOGY %q (want static|crd)", topoMode) + } + + // 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": + 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 + readResolve = statusRes.ResolveRead // Ready replica, falls back to primary. + default: + 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, readResolve, 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 +} + +// clusterStatusReader reads PostgresCluster.status.shards via controller-runtime. +type clusterStatusReader struct{ c client.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(client net.Conn) { - defer func() { _ = client.Close() }() +func handleConn(clientConn net.Conn, provider router.TopologyProvider, resolve router.BackendResolver, dialer *backendDialer) { + defer func() { _ = clientConn.Close() }() - raw, params, err := readStartup(client) + raw, params, err := readStartup(clientConn) if err != nil { log.Printf("pg-router: startup read: %v", err) return @@ -61,15 +238,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: resolve shard for key %q: %v", key, err) + log.Printf("pg-router: topology unavailable: %v", err) + writePgError(clientConn, "08006", "router topology unavailable: "+err.Error()) return } - backend := backendFor(shardID) - server, err := net.Dial("tcp", backend) + 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 { + // 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 + } + 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)) return } defer func() { _ = server.Close() }() @@ -81,11 +272,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,13 +338,17 @@ 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. +// 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: "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", "")), + 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"}, @@ -138,6 +356,52 @@ 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. +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 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") @@ -149,3 +413,31 @@ 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 +} + +// 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 +} 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/cmd/pg-router/persession.go b/cmd/pg-router/persession.go new file mode 100644 index 0000000..b3c64fa --- /dev/null +++ b/cmd/pg-router/persession.go @@ -0,0 +1,247 @@ +/* +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/Bind/Describe/Execute/Sync)도 per-query 로 라우팅한다 — Sync 까지 버퍼링해 배치 +// 단위로 키의 샤드에 보내고 샤드별 prepared statement 를 lazy 관리한다(extsession.go). +package main + +import ( + "errors" + "fmt" + "net" + "strings" + + "github.com/keiailab/postgres-operator/internal/router" +) + +// 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 된 백엔드. + + // 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{}, + stmts: map[string]*pstmt{}, + backendStmts: map[net.Conn]map[string]bool{}, + } + 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', '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 + } + } +} + +// 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 !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 + } + 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 { + s.queryError("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 + } + // 핸드셰이크 후 읽기 버퍼로 감싼다(이후 응답 메시지 read syscall 절감). + bc := newBufConn(c) + s.backends[backend] = bc + return bc, 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 + } + } +} + +// 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() + } +} + +// 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/pgwire.go b/cmd/pg-router/pgwire.go new file mode 100644 index 0000000..cccfaff --- /dev/null +++ b/cmd/pg-router/pgwire.go @@ -0,0 +1,184 @@ +/* +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 ( + "bytes" + "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). 헤더+payload 를 +// 한 버퍼로 합쳐 *단일* Write 로 보낸다 — 메시지당 syscall 2→1 (라우터 per-query 오버헤드 +// 감소). 버퍼링/flush 없이 즉시 전송하므로 deadlock 위험 없음. +func writeMessage(w io.Writer, typ byte, payload []byte) error { + 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 +} + +// 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 +} + +// 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 +} + +// 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) +} + +// 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..8f64fbe --- /dev/null +++ b/cmd/pg-router/pgwire_test.go @@ -0,0 +1,111 @@ +/* +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") + } +} + +// 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") + } +} + +// 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 + 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) + } +} diff --git a/cmd/pg-router/querymode.go b/cmd/pg-router/querymode.go new file mode 100644 index 0000000..b95cbb3 --- /dev/null +++ b/cmd/pg-router/querymode.go @@ -0,0 +1,147 @@ +/* +Copyright 2026 keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +// querymode.go 는 *쿼리 인지 라우팅*(E)의 라우팅 엔진 결선부다 — 연결 단위(startup param) +// 라우팅과 달리, 클라이언트의 *매* 쿼리에서 샤딩 키를 뽑아 샤드를 정한다(per-query). +// +// 핸드셰이크 후 흐름은 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). +// +// 백엔드 인증(trust/cleartext/scram)은 라우터가 대행한다(scram.go). +package main + +import ( + "context" + "log" + "net" + + "github.com/keiailab/postgres-operator/internal/router" +) + +// queryRouter 는 현재 토폴로지 + extractor + 백엔드 resolver 로 쿼리/값을 라우팅한다. +type queryRouter struct { + provider router.TopologyProvider + extractor router.RouteKeyExtractor + write router.BackendResolver // primary + read router.BackendResolver // replica (nil 이면 write) +} + +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 + } + 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 + } + 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 +} + +// 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 +} + +// 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) { + defer func() { _ = client.Close() }() + + raw, _, err := readStartup(client) + if err != nil { + return + } + if err := sendTrustHandshake(client, serverVersion); err != nil { + return + } + // per-query 라우팅 세션: 매 simple Query 를 키의 샤드로 라우팅(연결 고정 해소). + // 클라이언트 연결을 읽기 버퍼로 감싼다(핸드셰이크 후 — 그 다음 메시지부터 버퍼링). + runPerQuerySession(newBufConn(client), qr, dialer, backendPassword, raw) +} + +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) +} diff --git a/cmd/pg-router/querymode_test.go b/cmd/pg-router/querymode_test.go new file mode 100644 index 0000000..86d1361 --- /dev/null +++ b/cmd/pg-router/querymode_test.go @@ -0,0 +1,94 @@ +/* +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 testQR() queryRouter { + provider := router.StaticTopologyProvider{T: router.Topology{Spec: shardSpec()}} // vindex column "id" + write := func(s string) (string, error) { return s + ":5432", nil } + return newQueryRouter(provider, write, nil) +} + +// 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'", + } { + 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. + if d, err := qr.routeSQL("SELECT * FROM t"); err == nil || !d.Scatter { + t.Fatalf("no-key should scatter, got %+v err=%v", d, err) + } +} + +// 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) + } + } +} + +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 new file mode 100644 index 0000000..506ca54 --- /dev/null +++ b/cmd/pg-router/scattermode.go @@ -0,0 +1,136 @@ +/* +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" + "sync" +) + +// 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 { + scatterClientError(client, "08006", "scatter: no shards available") + 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 i := range results { + r := results[i] + if r.err != nil { + scatterClientError(client, "08006", fmt.Sprintf("scatter: shard %s: %v", shards[i].shard, r.err)) + return + } + if r.errMsg != nil { // 한 샤드라도 에러면 그대로 전달(fail-fast). + _ = writeMessage(client, 'E', r.errMsg.Payload) + _ = writeMessage(client, 'Z', []byte{'I'}) + return + } + if rowDesc == nil { + rowDesc = r.rowDesc + } + dataRows = append(dataRows, r.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'}) +} + +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) + 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} + } + bc := newBufConn(conn) // 핸드셰이크 후 읽기 버퍼로 감싼다. + if err := writeMessage(bc, 'Q', query.Payload); err != nil { + return shardResult{err: err} + } + rd, rows, errMsg, err := readQueryResult(bc) + 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) { + 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 + } + } +} 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/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) + } +} diff --git a/cmd/reshard-copy-poc/main.go b/cmd/reshard-copy-poc/main.go index 2fe7483..85beae9 100644 --- a/cmd/reshard-copy-poc/main.go +++ b/cmd/reshard-copy-poc/main.go @@ -1,45 +1,320 @@ /* 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 -// 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_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_TYPE, PGROUTER_VINDEX_COLUMN, +// PGROUTER_RESHARD_DELETE_AFTER. package main import ( "context" "fmt" "os" + "strconv" + "strings" "time" + "github.com/keiailab/postgres-operator/api/v1alpha1" "github.com/keiailab/postgres-operator/internal/router" ) +// 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). +// 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) + 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.VindexType(vtype), 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") table := os.Getenv("PGROUTER_COPY_TABLE") - if src == "" || tgt == "" || table == "" { - fmt.Fprintln(os.Stderr, "reshard-copy-poc: PGROUTER_SOURCE_DSN/TARGET_DSN/COPY_TABLE required") + 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 !cdcMode && targetShard == "" && table == "": + fmt.Fprintln(os.Stderr, "reshard-copy-poc: full copy requires PGROUTER_COPY_TABLE") + os.Exit(2) + } + timeout := 60 * time.Second + if cdcMode { + timeout = 15 * time.Minute // CDC bulk 복사/drain 은 길 수 있다. } - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - fmt.Printf("reshard-copy-poc: InitialCopy table=%q source→target\n", table) - n, err := router.CopyTable(ctx, src, tgt, table) + // CDC(online) 모드: 논리복제 setup(스키마+pub+sub+lag대기) 또는 finalize(drain+drop+범위정리). + if cdcMode { + 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) + 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(os.Getenv("PGROUTER_VINDEX_TYPE"), 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) + } + + // 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) + 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) + } + + if os.Getenv("PGROUTER_RESHARD_DELETE_AFTER") == "" { + return + } + // Cutover cleanup: delete moved rows from source (run only after routing switch). + 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)) +} + +// 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. +// - 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) + } + 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": + 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") + 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": + 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") + total := 0 + for _, t := range tables { + n, err := router.DeleteForeignRange(ctx, tgt, t, spec, targetShard) + must(err, "delete foreign range "+t) + total += n + } + // 범위 정리 후 인덱스/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) + 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) + } +} + +// 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: %v (copied %d before error)\n", err, n) + fmt.Fprintf(os.Stderr, "reshard-copy-poc: %s: %v\n", what, err) os.Exit(1) } - fmt.Printf("reshard-copy-poc: copied %d row(s) source→target (rollback=drop target)\n", n) +} + +// 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) + } + } + return out } 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/cmd/router-bench/main.go b/cmd/router-bench/main.go new file mode 100644 index 0000000..7e47624 --- /dev/null +++ b/cmd/router-bench/main.go @@ -0,0 +1,319 @@ +/* +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 ( + "context" + "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") + prepared = env("BENCH_PREPARED", "") != "" + // BENCH_ROUTERS: 여러 라우터 인스턴스 DSN(csv) — 멀티 라우터 수평확장 측정용. + routerDSNs = csvEnv("BENCH_ROUTERS", nil) + ) + + spec := benchSpec() + keysOnShard0 := seed(shard0DSN, shard1DSN, spec, keys) + 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 { + allKeys[i] = i + 1 + } + + scenarios := []struct { + name string + dsns []string + keys []int + }{ + {"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.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) + } + 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 은 dsns 에 w 개 워커로 dur 동안 점 쿼리를 던지고 (총 ops, 평균 지연ms)를 반환한다. +// dsns 가 여러 개면 워커를 round-robin 분산한다(멀티 라우터 인스턴스 수평확장 측정). +// prepared 면 워커마다 연결을 고정하고 stmt 를 *한 번만* Parse 한 뒤 Bind/Execute 를 반복 +// 한다(키당 Parse 제거 — 라우터는 샤드별 prepare-on-first-use 로 lazy prepare). +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" { + query = `UPDATE kv SET val=val+1 WHERE id=$1` + } + + var ops, totalNs int64 + deadline := time.Now().Add(dur) + var wg sync.WaitGroup + for i := 0; i < w; i++ { + wg.Add(1) + 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) + 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() + if err := exec1(ctx, conn, stmt, query, mode, prepared, id); err != nil { + log.Printf("query err: %v", err) + return + } + atomic.AddInt64(&totalNs, time.Since(t0).Nanoseconds()) + atomic.AddInt64(&ops, 1) + } + }(i, int64(i)+1) + } + wg.Wait() + avgMs := 0.0 + if ops > 0 { + avgMs = float64(totalNs) / float64(ops) / 1e6 + } + 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 { + 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 +} + +// 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 { + 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/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/config/crd/bases/postgres.keiailab.io_shardranges.yaml b/config/crd/bases/postgres.keiailab.io_shardranges.yaml index 79ab723..5ed4c7d 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: @@ -145,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/config/crd/bases/postgres.keiailab.io_shardsplitjobs.yaml b/config/crd/bases/postgres.keiailab.io_shardsplitjobs.yaml index f377a35..af7606c 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: @@ -272,6 +280,7 @@ spec: - Cutover - RoutingUpdate - Cleanup + - Promote - Completed - Failed - Aborted 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/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: 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/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/DOCS_MAP.ko.md b/docs/DOCS_MAP.ko.md new file mode 100644 index 0000000..32b63f0 --- /dev/null +++ b/docs/DOCS_MAP.ko.md @@ -0,0 +1,85 @@ +# 분석·작업 문서 지도 (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)다. 이 문서는 그와 별개로, 내부 분석·작업 흐름 문서를 묶는다. + +--- + +## 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 이력 | +| [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 네이티브로 빌드/테스트하는 절차 | +| [GLOSSARY.ko.md](GLOSSARY.ko.md) | **용어집** — 용어/약어 정의 | 용어 정의(각 문서 마지막 장 "용어집" 발췌의 원본) | + +> 이 지도 자체와 위 표는 새 분석/작업 문서가 생길 때마다 갱신한다. + +--- + +## 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` | 다른 문서에 작업 상태를 복제하지 않음 | +| 용어/약어 정의 | `GLOSSARY.ko.md` | 각 분석 문서는 **마지막 장에 "용어집" 절**을 두고, 그 문서에 등장한 용어만 GLOSSARY 정의를 **그대로 발췌** + 전체 링크 | + +--- + +## 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 관계도에 한 줄 추가. 그 문서 마지막 장에 "용어집" 절도 둔다. +- **새 용어가 등장했다**: `GLOSSARY.ko.md`에 한 줄 정의로 추가하고, 그 용어를 쓰는 문서의 마지막 장 "용어집" 절에 동일 문구로 발췌한다. 정의를 고치면 GLOSSARY를 먼저 고치고 발췌 측을 맞춘다. diff --git a/docs/E2E_TEST_REPORT.ko.md b/docs/E2E_TEST_REPORT.ko.md new file mode 100644 index 0000000..603ebae --- /dev/null +++ b/docs/E2E_TEST_REPORT.ko.md @@ -0,0 +1,940 @@ +# 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 환경에서 실패 원인을 단계적으로 제거했다. + +### 최종 결과 + +| 항목 | 결과 | +|------|------| +| 대상 | `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이다. + +--- + +## 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 new file mode 100644 index 0000000..6e806e8 --- /dev/null +++ b/docs/FEATURE_DEEP_DIVE.md @@ -0,0 +1,983 @@ +# 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 | + +--- + +## 용어집 + +> 정의는 [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 new file mode 100644 index 0000000..0cae89a --- /dev/null +++ b/docs/PROJECT_OVERVIEW.md @@ -0,0 +1,284 @@ +# 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. 프로젝트 목적 + +`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 - < 정의는 [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/ROADMAP.ko.md b/docs/ROADMAP.ko.md index 8c27fce..ebb95e8 100644 --- a/docs/ROADMAP.ko.md +++ b/docs/ROADMAP.ko.md @@ -140,42 +140,45 @@ - [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. + - [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).) ### 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 갱신. - - [ ] 7. Source cleanup. -- [ ] **Cutover rollback / forward-only** 검증. -- Verify: split 중 데이터 무결성 (checksum) + cutover-window 측정 + rollback 실행 가능성. +- [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). + - [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 교체(가역). + - [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 완료 (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 — 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 표 + 벤치마크 수치. +- [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). +- [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) @@ -204,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 new file mode 100644 index 0000000..377afb6 --- /dev/null +++ b/docs/TEST_ANALYSIS.md @@ -0,0 +1,365 @@ +# 테스트 분석 보고서 + +> 실행 환경: 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. 실행 환경 및 자원 사용 + +### 테스트 레이어 구조 + +``` +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//' {} + +``` + +--- + +## 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 new file mode 100644 index 0000000..1e17a83 --- /dev/null +++ b/docs/WORK_HANDOFF.ko.md @@ -0,0 +1,453 @@ +# 작업 핸드오프 — HA failover · PITR restore · E2E 정비 + +> 다른 환경(다른 머신·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) + +--- + +## 1. 한눈에 보기 + +| 항목 | 상태 | +|---|---| +| 브랜치 | `chore/ha-pitr-e2e-consolidation` (base: `main`) | +| 커밋 | **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), 프로덕션 지적 정리됨 | + +핵심: 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`로 라이브 검증 가능. + +--- + +## 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/` 캐시 확인. + +--- + +## 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)에 매핑. + +### 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 필수). 사유는 태스크/백로그에 기록. + +### 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). 요약: + +| 우선 | 항목 | 왜 / 블로커 | +|---|---|---| +| ~~·~~ ✅ | ~~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 포함. | +| ~~·~~ ✅ | ~~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)=라우터가 병목, 수평스케일은 멀티호스트 필요**. | +| ~~·~~ ✅ | ~~라우터 오버헤드 정량화~~ | **완료(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. | +| ~~·~~ ✅ | ~~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+라이브. 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. 라이브검증: 구독 이후 라이브 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 양 경로 실 클러스터 검증 완료. | +| ~~·~~ ✅ | ~~외래키/체크 제약 복제~~ | **완료(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 받음, 그대로 적용) | +| **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 파이프라이닝. + +### 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 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 + 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-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 동시쓰기 경로 + 검증. +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 절대경로) +``` + +### 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 잔여**: selector/status 일반화와 Promote target-adopt 이후에는 + precondition/fence 강화, source cleanup 정책, named shard spec-model 전환으로 들어간다. + #220-class 정체성 위험 때문에 live chaos drill 전에는 GA 완료로 보지 않는다. +5. **검증 체크포인트**: 개발 중에는 저비용 smoke 만 수행하고, 기능 단위가 닫히면 Docker/kind 를 켜서 + 통합·라이브 검증을 묶어서 실행한다. 테스트 결과는 브리핑하고, `KEEP=1` 을 명시하지 않는 한 kind + 클러스터는 target 종료 시 반납한다. + +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 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 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 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 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 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 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 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 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 로 검증한다. +- 아직 남은 범위: 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 + 결과를 보고 별도 보강한다. +- 검증 상태: 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 ./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`, + `go test -count=1 ./cmd/instance ./internal/router ./cmd/pg-router ./cmd/reshard-copy-poc ./api/v1alpha1 ./internal/controller`. +- 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" + ``` + + 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 만 사용하고, 기능 단위가 + 닫힌 뒤 Docker/kind/Dev Container 로 통합·라이브 검증을 묶어서 수행한다. e2e Make target 은 + `KEEP=1` 을 주지 않는 한 종료 시 `cleanup-test-e2e` 로 kind 클러스터를 반납한다. 테스트 완료 후에는 + pass/fail, 실패 RCA, 남은 위험, 정리된 자원 상태를 브리핑한다. +- **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 + chaos/failover drill, source-down abort cleanup fallback 검증은 아직 남아 있다. +- **의도적 보류**: source Service/PVC/PDB 삭제는 기본 동작이 아니며, 향후 별도 opt-in 정책과 live drill 후에만 + 검토한다. cross-shard 2PC, extended scatter, Flush 파이프라이닝도 아직 범위 밖이다. + +--- + +## 7. 용어집 + +> 정의는 [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) | 장애·실패의 근본 원인 분석. | diff --git a/docs/dev-setup-devcontainer.md b/docs/dev-setup-devcontainer.md new file mode 100644 index 0000000..26deeb7 --- /dev/null +++ b/docs/dev-setup-devcontainer.md @@ -0,0 +1,154 @@ +# Windows 개발 환경 설정 — Dev Container + +> 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) + +## 사전 요구사항 + +| 항목 | 버전 | 설치 여부 확인 | +|---|---|---| +| 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..86e6693 --- /dev/null +++ b/docs/dev-setup-wsl.md @@ -0,0 +1,272 @@ +# Windows 개발 환경 설정 — WSL2 + +> 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) + +## 사전 요구사항 + +| 항목 | 확인 명령 (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 +``` 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..8125267 --- /dev/null +++ b/docs/kb/adr/0029-reshard-target-promotion-identity-transition.md @@ -0,0 +1,247 @@ +# 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) + +## 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 범위다. + +### 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 +잔여 범위였다. + +### 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 전환이었다. + +### 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 의 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 완료다. + +### 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 검증은 별도 잔여 범위다. + +### 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 검증은 여전히 별도 잔여 범위다. + +### 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 삭제가 자동으로 일어나지 않는다. + +### 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, + 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 경유로 따라온다. +- **완료한 것**: 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 는 이 중간 상태를 만들지 않아야 한다. + +## Context + +2026-06-28 기준 online resharding 의 데이터 경로가 *전부* 결선·라이브 검증되었다 (offline + online +무중단 CDC, 스키마/인덱스/PK/제약 복제, write-block, full e2e — `WORK_HANDOFF.ko.md §6.6`). +당시 ShardSplitJob state machine 중 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) 의무. +- named shard 모델은 ShardRange 를 SSOT 로 유지한다. PostgresCluster 에 별도 named-list 를 추가하지 않는다. + +### 검증 의무 +- envtest: Promote phase 가 target StatefulSet/template/live Pod 에 `shard-id` 를 부여하고 selector 는 + `reshard-target` 으로 유지하는지 단언. +- envtest: source resource retain-by-default 정책(Service/PDB/PVC 보존)을 단언. +- 라이브 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. P-B.2 target + adopt slice 는 구현됨. source decommission 과 live chaos 는 잔여. +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/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) diff --git a/docs/perf/baseline.md b/docs/perf/baseline.md index 99c3fc0..464fe05 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,219 @@ | 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.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.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.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.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.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) 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. 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/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md new file mode 100644 index 0000000..d76a428 --- /dev/null +++ b/docs/sharding/ROUTER-GAP-ANALYSIS.ko.md @@ -0,0 +1,219 @@ +# 라우터 프로덕션 갭 분석 + 능력 사다리 + +> 분산 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) + +--- + +## 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에서 명시적으로 버린 길. +- 현재 아키텍처 = **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) | +| 라우팅 | connection-mode=연결 단위 · **query-mode=쿼리 단위**(per-query, 2026-06-28) | 쿼리 단위 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) 단계 또는 회복력/운영 축에 매핑된다. 우선순위는 가변. +> +> **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) 프로토콜 종단 — 쿼리 단위 라우팅**: **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) 파이프라이닝. +- [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 하므로 자격증명 불요. **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 프록시엔 불필요.) + +**회복력 / 운영** +- [ ] **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(분산처리능력 증명). + +--- + +**로직 보강 / 견고성** (2026-06-27 코드 검토). 일부는 (E)/planner 결선 후 가치: +- [x] 라우팅 키 *모호성 bail* — 같은 샤딩 컬럼에 다른 리터럴(서브쿼리/OR) 시 추측 거부 (커밋 f50cedd). **틀린 샤드 쓰기 방지(P0)**. +- [x] *dollar-quote 토큰화* — `$$...$$` 본문 가짜 predicate 누출 차단 (f50cedd). **P0**. +- [x] *ResolveRead lag 임계* — bounded staleness (8f51a0a). +- [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건 + NULL 정렬 PG 정합(NULLS LAST asc/FIRST desc)까지 build/vet/test +> 검증 완료 (2026-06-27).** 정적 리뷰에서 예상한 대로 compile/crash 버그 없었음. + +--- + +## 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/docs/sharding/ROUTER-TESTS.ko.md b/docs/sharding/ROUTER-TESTS.ko.md new file mode 100644 index 0000000..8800341 --- /dev/null +++ b/docs/sharding/ROUTER-TESTS.ko.md @@ -0,0 +1,249 @@ +# 라우터/샤딩 테스트 카탈로그 + +> `internal/router` + `cmd/pg-router`(분산 SQL 라우터)의 테스트케이스를 영역별로 정리한 +> *추후 참고용 색인*이다. 무엇을 검증하는지 + 어떻게 돌리는지 + 라이브 검증 절차를 담는다. +> +> 작성 기준: 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)(오퍼레이터 전체 테스트 분석). + +--- + +## 1. 실행 방법 + +호스트(Windows)에 go/make 상주 안 함 → 단위는 **Windows wrapper** 또는 **컨테이너**, 통합/라이브는 +**컨테이너/kind** (Dev Container 정식 절차는 dev-setup 문서). + +```bash +# (A) 라우터 + pg-router 단위 테스트 (라이브 클러스터 불필요, 전부 순수/in-memory) +docker run --rm -v :/src -w /src golang:1.26 \ + sh -c "go test ./internal/router/... ./cmd/pg-router/... ./cmd/reshard-copy-poc/..." + +# (B) 커버리지 +... go test -cover ./internal/router/... + +# (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 로 묶어 검증. + +--- + +## 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 데이터이동 (copy · CDC) + +| 파일 · 테스트 | 검증 내용 | +|---|---| +| `resharding_test.go` `TestValidateSplitPlan` / `TestHexSuccessor` | split 보존 불변식(gap/overlap/coverage), hex 인접성 | +| `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 | + +### 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` / `…_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 인라인 / 값 직접), 같은 키 동일 샤드 | +| `…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` (단위테스트 아님, 라이브 측정 도구) | + +--- + +## 3. 라이브 검증 (실 클러스터) + +단위 테스트로 못 잡는 부분은 호스트 kind(Docker Desktop/WSL2 — 컨테이너 안 중첩 아님)에서 검증. + +### 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 + `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 미완(라이브 환경 필요 — 무검증 랜딩 금지) +- **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. + +--- + +## 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 | 반복 실패하는 대상으로의 호출을 일정 시간 빠르게 차단해 장애 전파를 막는 패턴. | 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/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..3bb69e0 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-reshard-hardening.md @@ -0,0 +1,430 @@ +# 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. +- 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. + +## 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. + +**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 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 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 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 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 + +**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 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 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 + +**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 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 + +**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 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 + +**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 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 + +**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 ./cmd/instance ./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/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`. 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..f92738e --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-windows-test-wrapper.md @@ -0,0 +1,166 @@ +# 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 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. + +**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/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 에 위임하는 것이 +설계의 핵심이다. diff --git a/internal/controller/aggregate_status.go b/internal/controller/aggregate_status.go index c34492d..7572fea 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" @@ -32,10 +33,22 @@ 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). // -// 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 신호로 @@ -53,13 +66,40 @@ func aggregateShardStatus( ord int32, svcName string, ) postgresv1alpha1.ShardStatus { - logger := log.FromContext(ctx).WithValues("shard", ord) + 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: fmt.Sprintf("shard-%d", ord), - Ordinal: ord, + Name: shardID, + Ordinal: ordinal, } - 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, @@ -95,6 +135,9 @@ func aggregateShardStatus( // reseed. This protects the real (data-holding) primary from ever being reseeded. hasPromotedPrimary := false for i := range pods.Items { + if !matches(&pods.Items[i]) { + continue + } if st, ok := parsePodStatus(&pods.Items[i]); ok && st.Role == statusapi.RolePrimary && st.Promoted && !fencedPVC["data-"+pods.Items[i].Name] { @@ -109,6 +152,9 @@ func aggregateShardStatus( for i := range pods.Items { pod := &pods.Items[i] + if !matches(pod) { + continue + } st, ok := parsePodStatus(pod) if !ok { // annotation 부재 — Pod 부팅 직후. fallback 표기. @@ -126,6 +172,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 +184,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 +237,132 @@ func aggregateShardStatus( return out } +func activeNamedShardStatuses( + ctx context.Context, + c client.Client, + cluster *postgresv1alpha1.PostgresCluster, +) ([]postgresv1alpha1.ShardStatus, bool, error) { + 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 active shard topology: %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 == "" { + continue + } + seen[shardID] = struct{}{} + } + } + if len(seen) == 0 { + return nil, false, nil + } + return seen, true, 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") + 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 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 + } + 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..893336b 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" ) @@ -98,6 +99,56 @@ 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 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() @@ -123,6 +174,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/backupjob_controller.go b/internal/controller/backupjob_controller.go index 0f57ed8..a7948fa 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 ok, err := r.ensureClusterRestoreAnnotation(ctx, bj, cluster); !ok || err != nil { + return ctrl.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, +) (bool, error) { + owner := strings.TrimSpace(cluster.Annotations[AnnotationRestoreInProgress]) + if owner == bj.Name { + return true, nil + } + if owner != "" { + r.markFailed(bj, BackupJobReasonRestoreAlreadyInProgress, + "PostgresCluster "+cluster.Name+" already has offline restore owner BackupJob "+owner) + return 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 false, err + } + return 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..b8fbcbf 100644 --- a/internal/controller/builders.go +++ b/internal/controller/builders.go @@ -8,12 +8,14 @@ package controller import ( "fmt" + "maps" "regexp" "sort" "strings" "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" @@ -82,8 +84,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 +183,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 +194,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{}}}, } } @@ -321,8 +324,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") @@ -386,14 +391,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, @@ -951,6 +956,7 @@ fi // downward API + spec 매개변수 + current primary endpoint + 고정 경로의 합산. func buildInstanceEnv( clusterName string, + serviceName string, shardOrdinal int32, pgMajor string, members int32, @@ -979,6 +985,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)}, @@ -1022,6 +1029,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 := "" @@ -1063,24 +1079,28 @@ 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}) } + pvcLabels := make(map[string]string, len(labels)+1) + maps.Copy(pvcLabels, labels) + pvcLabels["postgres.keiailab.io/cluster"] = cluster.Name + return &appsv1.StatefulSet{ 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), @@ -1145,7 +1165,7 @@ func buildPGStatefulSet( VolumeClaimTemplates: []corev1.PersistentVolumeClaim{{ ObjectMeta: metav1.ObjectMeta{ Name: "data", - Labels: labels, + Labels: pvcLabels, }, Spec: pvcSpec, }}, @@ -1174,6 +1194,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, @@ -1181,9 +1219,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 ) @@ -1226,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 바이너리 이미지로 교체된다. 현재는 @@ -1236,7 +1343,11 @@ 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" + } return &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ @@ -1246,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 99c7516..db01d9c 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" @@ -90,6 +91,111 @@ 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() + + 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() @@ -591,6 +697,123 @@ 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.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) + } +} + // assertDataplaneSecurityContext는 PG StatefulSet과 Router Deployment 모두에서 // 동일한 검증을 수행한다. PodSecurityContext + Container.SecurityContext + // emptyDir mount 3개(/tmp, /run, /var/run/postgresql) 모두 존재해야 한다. @@ -619,9 +842,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 +1019,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 +1030,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) @@ -863,6 +1106,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" { 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/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) 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/names.go b/internal/controller/names.go index 47cdc2e..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,22 @@ 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)에는 넣지 않는다* — 기존 +// 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 와 격리하기 위해: 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) diff --git a/internal/controller/postgrescluster_controller.go b/internal/controller/postgrescluster_controller.go index adc9c64..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" @@ -71,6 +72,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 한다. @@ -110,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 @@ -181,6 +188,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,17 +218,33 @@ 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 } + 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) @@ -230,23 +254,15 @@ 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, resolvedImage.Image, cmName, resolvedImage.PostgresMajor, - desiredMembers, + desiredMembersForShard, cluster.Spec.Shards.Storage, cluster.Spec.Shards.Resources, primaryEndpoint, configHash, @@ -257,8 +273,8 @@ func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Requ } // shard PDB (PR #31): members>=2 시 자동 생성. - if !hibernating && 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,10 +294,13 @@ func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Requ } else { primaryReady = observed.Status.ReadyReplicas >= 1 } + if !ordinalActive { + continue + } if !primaryReady { allShardPrimaryReady = false } - if hibernating { + if databasePodsStopped { shardStatuses = append(shardStatuses, postgresv1alpha1.ShardStatus{ Name: fmt.Sprintf("shard-%d", ord), Ordinal: ord, @@ -292,40 +311,69 @@ 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) } + 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 { + 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 @@ -344,7 +392,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( @@ -355,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 @@ -372,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 증가 시 @@ -387,6 +446,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 { @@ -396,14 +456,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 +493,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 +503,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, activeShardCount, 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 { @@ -462,7 +522,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 { @@ -484,6 +544,76 @@ 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]) != "" +} + +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 // 객체에 직접 기록한다. @@ -493,6 +623,7 @@ func applyClusterConditions( allShardPrimaryReady, routerActive bool, routerStatus *postgresv1alpha1.ClusterRouterStatus, hibernating bool, + standaloneReplica bool, wasReady bool, failoverDecision failover.Decision, ) { @@ -522,8 +653,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 +687,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 +745,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 { @@ -651,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, @@ -788,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: @@ -820,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 만 동기화. @@ -922,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 b1f0679..d755b44 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" @@ -15,7 +16,9 @@ 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" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/api/resource" @@ -24,6 +27,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 를 검증한다. @@ -59,6 +63,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}, @@ -172,6 +210,264 @@ var _ = Describe("PostgresClusterReconciler — RFC 0001 spec", func() { g.Expect(svc.Spec.ClusterIP).NotTo(Equal(corev1.ClusterIPNone)) }, 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" + 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: 1, + Storage: postgresv1alpha1.StorageSpec{ + Size: resource.MustParse("1Gi"), + }, + }, + }, + } + 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) { + 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] + } + 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") + + 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), + }, &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()) + }) }) Context("when cnpg-compatible hibernation annotation is enabled", func() { @@ -245,6 +541,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/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 5c00900..b8095eb 100644 --- a/internal/controller/shardsplitjob_controller.go +++ b/internal/controller/shardsplitjob_controller.go @@ -9,8 +9,11 @@ package controller import ( "context" "fmt" + "time" 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" @@ -45,7 +48,9 @@ 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 // Reconcile 은 ShardSplitJob 의 다음 phase 로 한 단계 전이한다 (즉시 requeue 로 진행). func (r *ShardSplitJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { @@ -57,9 +62,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 } @@ -83,6 +92,61 @@ 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 으로 전이. + } + + // 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 을 켜지 않는다. + 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 @@ -95,6 +159,49 @@ 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 로 전이. + } + + 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() + 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 @@ -146,7 +253,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, "" @@ -161,11 +269,147 @@ 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) 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 + } + 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 +} + +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) 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 { + 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 @@ -187,6 +431,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) } @@ -196,6 +441,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 을 멱등 생성한다. // @@ -270,6 +538,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_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_copy.go b/internal/controller/shardsplitjob_copy.go new file mode 100644 index 0000000..97ac0b4 --- /dev/null +++ b/internal/controller/shardsplitjob_copy.go @@ -0,0 +1,269 @@ +/* +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" +} + +// reshardModeVerb 는 데이터이동 mode 의 Job 이름 segment 다(phase 별 Job 공존을 위해 분리). +var reshardModeVerb = map[string]string{ + "copy": "copy", + "delete": "del", + "cdc-setup": "cdcset", + "cdc-finalize": "cdcfin", + "cdc-abort": "cdcabort", +} + +// 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, 무비밀번호 — +// 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) (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) + } + for i := range list.Items { + sr := &list.Items[i] + if sr.Spec.Cluster == cluster && sr.Spec.Keyspace == keyspace { + 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) +} + +// reshardParams 는 한 데이터이동 Job 의 입력이다. +type reshardParams struct { + sourceDSN, targetDSN, targetShard string + vtype, col, fn, ranges string + refTables []string + maxLag int64 + 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}, + {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"}) + 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)}, + ) + 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}) + } + return &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: reshardJobName(ssj.Spec.Cluster, p.targetShard, p.mode), + Namespace: ssj.Namespace, + Labels: ReshardTargetSelectorLabels(ssj.Spec.Cluster, p.targetShard), + }, + Spec: batchv1.JobSpec{ + BackoffLimit: &backoff, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: ReshardTargetSelectorLabels(ssj.Spec.Cluster, p.targetShard)}, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{{ + Name: "reshard-copy", + Image: reshardCopyImage(), + Env: env, + }}, + }, + }, + }, + } +} + +// 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) { + if ssj.Spec.Online { + return true, "", nil + } + return r.reconcileModeJobs(ctx, ssj, "copy") +} + +// reconcileCleanup 은 cutover 후 각 target 키를 source 에서 삭제하는 Job 을 띄운다(source 회수). +func (r *ShardSplitJobReconciler) reconcileCleanup(ctx context.Context, ssj *postgresv1alpha1.ShardSplitJob) (done bool, failure string, err error) { + return r.reconcileModeJobs(ctx, ssj, "delete") +} + +// 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, mode + ": no source shard", nil + } + vtype, 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, 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, mode) + var job batchv1.Job + getErr := r.Get(ctx, client.ObjectKey{Namespace: ssj.Namespace, Name: name}, &job) + switch { + case apierrors.IsNotFound(getErr): + targetDSN := "" + if mode != "delete" { + targetDSN = internalShardDSN(targetShardPodDNS(ssj.Spec.Cluster, ssj.Namespace, t.ShardID)) + } + j := r.buildReshardJob(ssj, reshardParams{ + sourceDSN: internalShardDSN(srcDNS), targetDSN: targetDSN, targetShard: t.ShardID, + 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 + } + 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("%s: job %s failed", mode, 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..741b9a2 --- /dev/null +++ b/internal/controller/shardsplitjob_copy_test.go @@ -0,0 +1,213 @@ +/* +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: 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)) + 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")) + 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: reshardJobName(clusterName, shardID, "copy")}, &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: reshardJobName(clusterName, "t0", "copy")}, &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")) + }) + + 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", "delete")}, &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()) + }) +}) diff --git a/internal/controller/shardsplitjob_writeblock_test.go b/internal/controller/shardsplitjob_writeblock_test.go new file mode 100644 index 0000000..8a96a85 --- /dev/null +++ b/internal/controller/shardsplitjob_writeblock_test.go @@ -0,0 +1,628 @@ +/* +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" + + 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" + "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" + + envOf := func(c corev1.Container, key string) string { + for _, e := range c.Env { + if e.Name == key { + return e.Value + } + } + 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"}, + } + } + 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()) + 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("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{ + 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()) + markPodReady(pod) + + 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).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()) + 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("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("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" + sr := &postgresv1alpha1.ShardRange{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName + "-sr", Namespace: ns}, + Spec: postgresv1alpha1.ShardRangeSpec{ + Cluster: clusterName, Keyspace: keyspace, + Vindex: postgresv1alpha1.VindexSpec{Type: postgresv1alpha1.VindexTypeRange, Column: "id"}, + 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")) + 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 미설정") + + // 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("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" + 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/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/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/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/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/query_router.go b/internal/router/query_router.go new file mode 100644 index 0000000..a9a32f6 --- /dev/null +++ b/internal/router/query_router.go @@ -0,0 +1,104 @@ +/* +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)") + +// ErrWriteBlocked 는 resharding cutover 중 쓰기가 일시 차단된 경우이다(읽기는 허용). +var ErrWriteBlocked = errors.New("router: writes blocked (resharding cutover in progress)") + +// 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) + // resharding cutover: 쓰기 일시 차단(읽기는 통과). 라우팅 전환 중 쓰기 유실 방지. + if !read && qr.Topology.Spec.WriteBlocked { + return RouteDecision{}, ErrWriteBlocked + } + 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. 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 { + 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..9f70aaf --- /dev/null +++ b/internal/router/query_router_test.go @@ -0,0 +1,133 @@ +/* +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_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'") + 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_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 아님 + 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_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") } + // 쓰기 쿼리 → Write resolver 에러 전파. + if _, err := qr.Route("UPDATE t SET v=1 WHERE tenant_id='bob'"); err == nil { + t.Fatal("expected backend error to propagate") + } +} 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) + } +} diff --git a/internal/router/reshard_cdc.go b/internal/router/reshard_cdc.go new file mode 100644 index 0000000..17eb326 --- /dev/null +++ b/internal/router/reshard_cdc.go @@ -0,0 +1,239 @@ +/* +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 + +// 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 을 멱등 +// 생성한다. *이미 있으면 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) + } + 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() }() + + 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 { + 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() }() + + // 멱등: 이미 있으면 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)", + 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..c6860fa --- /dev/null +++ b/internal/router/reshard_cdc_live_test.go @@ -0,0 +1,169 @@ +/* +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, 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)`) + + // 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) + } + } + + // 인덱스/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) 미복제") + } + + // 제약(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 { + 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.go b/internal/router/reshard_copy.go index fef9a9a..1b8286f 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,361 @@ 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() }() + + // 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) + } + 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) + } + // 데이터 복사 후 인덱스/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 +} + +// 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 +} + +// 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 +} + +// 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 +} + +// 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) + 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). +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 { + 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 3f64b6e..08971ea 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 @@ -14,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)" @@ -33,3 +41,87 @@ 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) + } + } +} + +// 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 + 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 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 { + 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) + } +} 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/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..1cd5147 --- /dev/null +++ b/internal/router/route_extractor_parser.go @@ -0,0 +1,358 @@ +/* +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 ( + "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{} + +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 +} + +// 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 ( + 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 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]) { + 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 == '$' +} + +// 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 '=', '<', '>', '!', '+', '-', '*', '/', '%', '~', '&', '|', '^': + return true + } + return false +} + +// 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 { + v := toks[i+2].text + if v == "" { + continue + } + if got && v != found { + return "", false // 모호 → 추측 거부. + } + found, got = v, true + } + } + return found, got +} + +// 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..106295d --- /dev/null +++ b/internal/router/route_extractor_parser_test.go @@ -0,0 +1,164 @@ +/* +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) + } +} + +// 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") + } +} + +// 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 { + 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) + 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/scatter.go b/internal/router/scatter.go index d3b47be..7fe01df 100644 --- a/internal/router/scatter.go +++ b/internal/router/scatter.go @@ -18,6 +18,7 @@ import ( "errors" "fmt" "sort" + "strings" "sync" ) @@ -82,6 +83,17 @@ type ScatterGather struct { Policy FailurePolicy // Merge 는 결과 합치기 전략 (default MergeConcat). Merge MergeStrategy + // 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 이면 @@ -99,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() @@ -139,16 +156,28 @@ 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 { - 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. @@ -164,38 +193,103 @@ 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 +// 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 // nil 이 더 큼. + case bv == nil: + return -1 + } + return compareValues(av, bv) } -func cmpFirstValue(a, b Row) int { - if len(a.Values) == 0 && len(b.Values) == 0 { - return 0 +// valueAt 은 Row 의 col 번째 값을 반환한다 (범위 밖이면 nil). +func valueAt(r Row, col int) any { + if col >= 0 && col < len(r.Values) { + return r.Values[col] } - if len(a.Values) == 0 { - return -1 + 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 } - if len(b.Values) == 0 { - return 1 + 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 + } } - 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 false +} + +// 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..96477e4 --- /dev/null +++ b/internal/router/scatter_merge_test.go @@ -0,0 +1,119 @@ +/* +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_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) + } + } +} + +// 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 { + 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{ + 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)) + } +} diff --git a/internal/router/sql_executor.go b/internal/router/sql_executor.go index f214ef2..1155fc2 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,46 @@ 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 + 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 / 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 } // 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 +69,39 @@ 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.ConnMaxLifetime > 0 { + db.SetConnMaxLifetime(e.ConnMaxLifetime) + } else { + db.SetConnMaxLifetime(defaultConnMaxLifetime) + } + 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 +131,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 050d1ad..6e5b27a 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 @@ -16,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) { 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{} 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/topology.go b/internal/router/topology.go new file mode 100644 index 0000000..68fe90f --- /dev/null +++ b/internal/router/topology.go @@ -0,0 +1,217 @@ +/* +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 + // ClearCacheOnMissing 가 true 면, list 는 성공했으나 매칭 ShardRange 가 없을 때(예: + // keyspace 삭제) 캐시를 비운다 → Current 가 stale 토폴로지 대신 에러를 낸다. false + // (기본)면 transient 보호를 위해 마지막 캐시를 유지한다. list 자체 실패(API blip)는 + // 항상 캐시를 보존한다. + ClearCacheOnMissing bool + + 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 + } + // 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) +} + +// --- 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(쓰기) 또는 Ready +// replica(읽기 분산)로 매핑한다 — PostgresCluster.status 의 캐시 스냅샷 기반 +// (failover-aware). Update 는 refresh 루프가, Resolve/ResolveRead 는 연결마다 호출한다. +// 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 (lag 임계 통과) + rr map[string]int // shard → round-robin 커서 (읽기 분산) +} + +// NewStatusBackendResolver 는 빈 resolver 를 만든다. Update 전에는 모든 Resolve 가 +// 에러(아직 status 미수신). +func NewStatusBackendResolver() *StatusBackendResolver { + return &StatusBackendResolver{ + primaries: map[string]string{}, + replicas: map[string][]string{}, + rr: map[string]int{}, + } +} + +// Update 는 샤드 status 목록으로 primary/replica 매핑을 통째로 교체한다 (Ready 만 포함). +// failover 후 새 status 가 들어오면 자동으로 새 primary/replica 를 가리키게 된다. +func (r *StatusBackendResolver) Update(shards []v1alpha1.ShardStatus) { + 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 != "" { + pm[s.Name] = s.Primary.Endpoint + } + for j := range s.Replicas { + rep := s.Replicas[j] + 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() + r.primaries, r.replicas = pm, rm + r.mu.Unlock() +} + +// Resolve 는 shard 의 현재 Ready primary endpoint(쓰기 경로)를 반환한다. 없으면 error. +func (r *StatusBackendResolver) Resolve(shardID string) (string, error) { + 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 new file mode 100644 index 0000000..7288c7f --- /dev/null +++ b/internal/router/topology_test.go @@ -0,0 +1,190 @@ +/* +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") + } +} + +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") + } +} + +// 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) + } + } +} diff --git a/internal/router/vindex.go b/internal/router/vindex.go index 4563310..4223b76 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 @@ -26,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") @@ -42,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 반환. @@ -53,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..c8d1320 --- /dev/null +++ b/internal/router/vindex_consistent.go @@ -0,0 +1,133 @@ +/* +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" + "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 + +// 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 분기다. 링은 fingerprint +// 로 캐시되어 재사용된다. +func resolveConsistentHash(spec v1alpha1.ShardRangeSpec, key string) (string, error) { + 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 { + 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") + } +} 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/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() diff --git a/scripts/allow-windows-test-exe.ps1 b/scripts/allow-windows-test-exe.ps1 new file mode 100644 index 0000000..755db24 --- /dev/null +++ b/scripts/allow-windows-test-exe.ps1 @@ -0,0 +1,113 @@ +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 +} + +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." +} + +$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) { + $enabled = $current.Contains([System.IO.Path]::GetFullPath($path).TrimEnd('\')) + 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 +} + +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" + } +} + +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" + } +} 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 +} 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 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/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() { 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/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") }) }) 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)