Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 38 additions & 38 deletions ROADMAP.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions api/v1alpha1/groupversion_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ var (
GroupVersion = schema.GroupVersion{Group: "postgres.keiailab.io", Version: "v1alpha1"}

// SchemeBuilder registers this package's Go types under GroupVersion.
//nolint:staticcheck // SA1019: kubebuilder-generated; api packages use sigs.k8s.io/controller-runtime/pkg/scheme as the canonical Builder
SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}

// AddToScheme is invoked from cmd/main.go's init() to register this package's types
Expand Down
209 changes: 209 additions & 0 deletions api/v1alpha1/shardsplitjob_types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/*
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
*/

package v1alpha1

import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// ShardSplitJob CRD — G4 online resharding 7-step orchestrator (D.9.1).
//
// 7-step workflow (RFC-0002 §online-resharding 정합):
//
// 1. Snapshot + WAL capture — source shard 의 시점 일관 base snapshot 확보
// 2. Bootstrap target shard — 신규 shard StatefulSet 생성 + PG init
// 3. Initial copy — base snapshot 적용 (logical 또는 pg_basebackup)
// 4. CDC catch-up — source 의 변경분을 logical replication 으로 따라잡기
// 5. Cutover — write 차단 최소화 윈도우 + router 라우팅 갱신
// 6. Routing update — ShardRange CRD 의 ranges 갱신 + metadata store sync
// 7. Source cleanup — old shard 의 split-out 키 범위 데이터 회수
//
// 본 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
type ShardSplitJobPhase string

const (
ShardSplitPhasePending ShardSplitJobPhase = "Pending"
ShardSplitPhaseSnapshotWAL ShardSplitJobPhase = "SnapshotWAL"
ShardSplitPhaseBootstrap ShardSplitJobPhase = "Bootstrap"
ShardSplitPhaseInitialCopy ShardSplitJobPhase = "InitialCopy"
ShardSplitPhaseCDCCatchup ShardSplitJobPhase = "CDCCatchup"
ShardSplitPhaseCutover ShardSplitJobPhase = "Cutover"
ShardSplitPhaseRoutingUpdate ShardSplitJobPhase = "RoutingUpdate"
ShardSplitPhaseCleanup ShardSplitJobPhase = "Cleanup"
ShardSplitPhaseCompleted ShardSplitJobPhase = "Completed"
ShardSplitPhaseFailed ShardSplitJobPhase = "Failed"
ShardSplitPhaseAborted ShardSplitJobPhase = "Aborted"
)

// ShardSplitDirection 은 split 의 방향 의도이다.
// +kubebuilder:validation:Enum=split;merge
type ShardSplitDirection string

const (
// ShardSplitDirectionSplit — 1 shard 의 키 범위를 N 으로 분할.
ShardSplitDirectionSplit ShardSplitDirection = "split"
// ShardSplitDirectionMerge — N shard 의 키 범위를 1 로 병합.
ShardSplitDirectionMerge ShardSplitDirection = "merge"
)

// ShardSplitJobSpec 는 사용자 의도된 shard split/merge 작업이다.
// +kubebuilder:validation:XValidation:rule="size(self.sources) > 0",message="sources must not be empty"
// +kubebuilder:validation:XValidation:rule="size(self.targets) > 0",message="targets must not be empty"
type ShardSplitJobSpec struct {
// Cluster 는 본 작업이 속한 PostgresCluster 의 이름 (동일 namespace).
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinLength=1
Cluster string `json:"cluster"`

// Keyspace 는 ShardRange 의 keyspace 식별자.
// +kubebuilder:validation:Required
// +kubebuilder:validation:Pattern=`^[a-z][a-z0-9_]{0,62}$`
Keyspace string `json:"keyspace"`

// Direction 은 split 또는 merge 방향. 기본 split.
// +kubebuilder:default=split
// +optional
Direction ShardSplitDirection `json:"direction,omitempty"`

// Sources 는 source shard ID 목록 (split: 1, merge: N).
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinItems=1
Sources []string `json:"sources"`

// Targets 는 target shard 정의 목록 (split: N, merge: 1).
// 각 target 은 자체 키 범위와 placement hint 를 갖는다.
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinItems=1
Targets []ShardSplitTarget `json:"targets"`

// CutoverWindow 는 cutover phase 의 최대 write-block 시간이다 (예: "30s").
// 초과 시 자동 abort + rollback. 기본 60s.
// +kubebuilder:default="60s"
// +optional
CutoverWindow metav1.Duration `json:"cutoverWindow,omitempty"`

// CDCMaxLag 은 CDC catch-up phase 에서 cutover 진입 허용 LSN 차이 (bytes).
// 기본 16MB.
// +kubebuilder:default=16777216
// +optional
CDCMaxLag int64 `json:"cdcMaxLag,omitempty"`

// AllowForwardOnly 는 true 면 cutover 이후 rollback 불가 (D.9.10).
// 기본 false — rollback 가능 (역방향 logical replication 유지).
// +kubebuilder:default=false
// +optional
AllowForwardOnly bool `json:"allowForwardOnly,omitempty"`
}

// ShardSplitTarget 는 split/merge 의 target shard 1건 정의.
type ShardSplitTarget struct {
// ShardID 는 target shard 의 식별자 (ShardRange.spec.ranges[].shard 와 동일).
// +kubebuilder:validation:Required
ShardID string `json:"shardID"`

// Ranges 는 본 target 이 가질 키 범위 목록. ShardRange.spec.ranges 와 동일 형식.
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinItems=1
Ranges []ShardRangeEntry `json:"ranges"`

// Placement 는 target shard 의 nodeAffinity / topology hint.
// +optional
Placement *ShardSplitPlacement `json:"placement,omitempty"`
}

// ShardSplitPlacement 는 target shard 의 K8s scheduling hint.
type ShardSplitPlacement struct {
// PreferredZone 은 의도된 topology zone.
// +optional
PreferredZone string `json:"preferredZone,omitempty"`
// PreferredNode 는 의도된 노드 이름 (특수 hardware).
// +optional
PreferredNode string `json:"preferredNode,omitempty"`
}

// ShardSplitJobStatus 는 reconciler 가 관찰한 7-step state machine 상태.
type ShardSplitJobStatus struct {
// Phase 는 현재 phase (state machine).
// +optional
Phase ShardSplitJobPhase `json:"phase,omitempty"`

// ObservedGeneration 은 마지막으로 처리한 metadata.generation.
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`

// StartedAt 은 본 작업이 Pending → SnapshotWAL 으로 진입한 시각.
// +optional
StartedAt *metav1.Time `json:"startedAt,omitempty"`

// CompletedAt 은 Completed / Failed / Aborted 진입 시각.
// +optional
CompletedAt *metav1.Time `json:"completedAt,omitempty"`

// CurrentLagBytes 는 CDCCatchup phase 동안의 lag (bytes).
// +optional
CurrentLagBytes int64 `json:"currentLagBytes,omitempty"`

// CutoverStartedAt 은 Cutover phase 진입 시각 (window 측정 시작).
// +optional
CutoverStartedAt *metav1.Time `json:"cutoverStartedAt,omitempty"`

// SnapshotLSN 은 SnapshotWAL phase 에서 확정된 source 시점 LSN.
// +optional
SnapshotLSN string `json:"snapshotLSN,omitempty"`

// FailureReason 은 Failed phase 의 원인.
// +optional
FailureReason string `json:"failureReason,omitempty"`

// Conditions 는 표준 K8s condition 집합 (StepCompleted, RollbackPossible, etc).
// +optional
// +patchMergeKey=type
// +patchStrategy=merge
// +listType=map
// +listMapKey=type
Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"`
}

// +kubebuilder:object:root=true
// +kubebuilder:resource:scope=Namespaced,shortName=ssj,categories=postgres;sharding;all
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Cluster",type=string,JSONPath=`.spec.cluster`
// +kubebuilder:printcolumn:name="Keyspace",type=string,JSONPath=`.spec.keyspace`
// +kubebuilder:printcolumn:name="Direction",type=string,JSONPath=`.spec.direction`
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
// +kubebuilder:printcolumn:name="Lag",type=integer,JSONPath=`.status.currentLagBytes`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`

// ShardSplitJob 은 G4 online resharding 의 7-step orchestrator CRD 이다 (RFC-0002).
type ShardSplitJob struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`

Spec ShardSplitJobSpec `json:"spec,omitempty"`
Status ShardSplitJobStatus `json:"status,omitempty"`
}

// +kubebuilder:object:root=true

// ShardSplitJobList 는 ShardSplitJob 의 컬렉션이다.
type ShardSplitJobList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []ShardSplitJob `json:"items"`
}

func init() {
SchemeBuilder.Register(&ShardSplitJob{}, &ShardSplitJobList{})
}
112 changes: 112 additions & 0 deletions api/v1alpha1/shardsplitjob_types_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
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
*/

package v1alpha1

import (
"testing"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

func TestShardSplitJob(t *testing.T) {
t.Run("Phase 전체 11 값 stringify", func(t *testing.T) {
phases := []ShardSplitJobPhase{
ShardSplitPhasePending, ShardSplitPhaseSnapshotWAL,
ShardSplitPhaseBootstrap, ShardSplitPhaseInitialCopy,
ShardSplitPhaseCDCCatchup, ShardSplitPhaseCutover,
ShardSplitPhaseRoutingUpdate, ShardSplitPhaseCleanup,
ShardSplitPhaseCompleted, ShardSplitPhaseFailed,
ShardSplitPhaseAborted,
}
if len(phases) != 11 {
t.Fatalf("phase count want=11 got=%d", len(phases))
}
for _, p := range phases {
if string(p) == "" {
t.Fatalf("phase string empty: %v", p)
}
}
})

t.Run("Direction 2 enum", func(t *testing.T) {
if ShardSplitDirectionSplit != "split" || ShardSplitDirectionMerge != "merge" {
t.Fatalf("Direction stringify mismatch")
}
})

t.Run("Spec round-trip", func(t *testing.T) {
spec := ShardSplitJobSpec{
Cluster: "cl-0",
Keyspace: "ks",
Direction: ShardSplitDirectionSplit,
Sources: []string{"sh-0"},
Targets: []ShardSplitTarget{
{
ShardID: "sh-0a",
Ranges: []ShardRangeEntry{{Lo: "0x0", Hi: "0x7f", Shard: "sh-0a"}},
Placement: &ShardSplitPlacement{
PreferredZone: "z-1",
},
},
{
ShardID: "sh-0b",
Ranges: []ShardRangeEntry{{Lo: "0x80", Hi: "0xff", Shard: "sh-0b"}},
},
},
CutoverWindow: metav1.Duration{Duration: 30 * 1e9},
CDCMaxLag: 16 * 1024 * 1024,
AllowForwardOnly: false,
}
j := ShardSplitJob{
ObjectMeta: metav1.ObjectMeta{Name: "split-1", Namespace: "ns"},
Spec: spec,
}
if j.Spec.Cluster != "cl-0" || j.Spec.Keyspace != "ks" {
t.Fatalf("round-trip basic field mismatch")
}
if len(j.Spec.Sources) != 1 || j.Spec.Sources[0] != "sh-0" {
t.Fatalf("Sources round-trip mismatch")
}
if len(j.Spec.Targets) != 2 {
t.Fatalf("Targets count round-trip mismatch")
}
if j.Spec.Targets[0].Placement == nil ||
j.Spec.Targets[0].Placement.PreferredZone != "z-1" {
t.Fatalf("Placement round-trip mismatch")
}
})

t.Run("List wraps Items", func(t *testing.T) {
list := ShardSplitJobList{
Items: []ShardSplitJob{
{ObjectMeta: metav1.ObjectMeta{Name: "j1"}},
{ObjectMeta: metav1.ObjectMeta{Name: "j2"}},
},
}
if len(list.Items) != 2 {
t.Fatalf("list Items round-trip")
}
})

t.Run("Status round-trip", func(t *testing.T) {
st := ShardSplitJobStatus{
Phase: ShardSplitPhaseCDCCatchup,
CurrentLagBytes: 8 * 1024 * 1024,
SnapshotLSN: "0/3DA43A0",
}
if st.Phase != "CDCCatchup" {
t.Fatalf("Phase round-trip")
}
if st.CurrentLagBytes != 8*1024*1024 {
t.Fatalf("Lag round-trip")
}
})
}
2 changes: 1 addition & 1 deletion api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading