Skip to content

Commit 2e9065d

Browse files
stubbiclaude
andcommitted
feat: production-ready horizontal scaling and multi-replica support
1. Configurable replicas: add spec.availability.replicas field (default 1, ignored when HPA is enabled). Controller preserves HPA-managed replica count to avoid fighting the autoscaler. 2. Heartbeat leader election: when replicas > 1, only pod-0 runs the heartbeat scheduler. Uses a shell wrapper that checks the StatefulSet ordinal in $HOSTNAME before exec-ing the entrypoint. 3. Auto TCP probes: in authenticated/single-tenant mode, /api/health returns 403 without credentials, breaking HTTP probes. New spec.probes.type field (auto/http/tcp) defaults to "auto" which uses TCP for authenticated modes and HTTP for open mode. 4. Managed DB improvements: add default resource requests/limits (250m/256Mi requests, 1/1Gi limits), enable --data-checksums for storage corruption detection, add preStop hook for graceful PostgreSQL shutdown. Document that external DB is recommended for HA production deployments. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7db4e83 commit 2e9065d

9 files changed

Lines changed: 158 additions & 26 deletions

File tree

api/v1alpha1/paperclipinstance_types.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,9 @@ type DeploymentSpec struct {
177177
}
178178

179179
// DatabaseSpec configures PostgreSQL.
180+
// For high-availability production deployments, use mode "external" with a managed
181+
// PostgreSQL service (e.g., Amazon RDS, Cloud SQL). The "managed" mode provides a
182+
// single-instance PostgreSQL suitable for development and small deployments.
180183
type DatabaseSpec struct {
181184
// Mode selects the database mode: "embedded" (PGlite), "external" (connection string), or "managed" (operator-managed StatefulSet).
182185
// +kubebuilder:default="managed"
@@ -474,6 +477,13 @@ type LoggingSpec struct {
474477

475478
// AvailabilitySpec configures scaling and pod scheduling.
476479
type AvailabilitySpec struct {
480+
// Replicas is the desired number of Paperclip server pods.
481+
// Ignored when autoScaling is enabled (the HPA manages replicas).
482+
// +kubebuilder:default=1
483+
// +kubebuilder:validation:Minimum=1
484+
// +optional
485+
Replicas *int32 `json:"replicas,omitempty"`
486+
477487
// PodDisruptionBudget configures the PDB.
478488
// +optional
479489
PodDisruptionBudget *PDBSpec `json:"podDisruptionBudget,omitempty"`
@@ -540,6 +550,14 @@ type AutoScalingSpec struct {
540550

541551
// ProbesSpec configures health probes.
542552
type ProbesSpec struct {
553+
// Type specifies the probe mechanism: "auto" (default), "http", or "tcp".
554+
// "auto" uses HTTP probes in open mode and TCP probes in authenticated/single-tenant mode
555+
// (where /api/health returns 403 without credentials).
556+
// +kubebuilder:default="auto"
557+
// +kubebuilder:validation:Enum=auto;http;tcp
558+
// +optional
559+
Type string `json:"type,omitempty"`
560+
543561
// Liveness configures the liveness probe against /api/health.
544562
// +optional
545563
Liveness *ProbeSpec `json:"liveness,omitempty"`

api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

charts/paperclip-operator/templates/crds/paperclip.inc_instances.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1083,6 +1083,14 @@ spec:
10831083
required:
10841084
- enabled
10851085
type: object
1086+
replicas:
1087+
default: 1
1088+
description: |-
1089+
Replicas is the desired number of Paperclip server pods.
1090+
Ignored when autoScaling is enabled (the HPA manages replicas).
1091+
format: int32
1092+
minimum: 1
1093+
type: integer
10861094
tolerations:
10871095
description: Tolerations specifies pod tolerations.
10881096
items:
@@ -5271,6 +5279,17 @@ spec:
52715279
format: int32
52725280
type: integer
52735281
type: object
5282+
type:
5283+
default: auto
5284+
description: |-
5285+
Type specifies the probe mechanism: "auto" (default), "http", or "tcp".
5286+
"auto" uses HTTP probes in open mode and TCP probes in authenticated/single-tenant mode
5287+
(where /api/health returns 403 without credentials).
5288+
enum:
5289+
- auto
5290+
- http
5291+
- tcp
5292+
type: string
52745293
type: object
52755294
resources:
52765295
description: Resources specifies the compute resources for the Paperclip

config/crd/bases/paperclip.inc_instances.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1077,6 +1077,14 @@ spec:
10771077
required:
10781078
- enabled
10791079
type: object
1080+
replicas:
1081+
default: 1
1082+
description: |-
1083+
Replicas is the desired number of Paperclip server pods.
1084+
Ignored when autoScaling is enabled (the HPA manages replicas).
1085+
format: int32
1086+
minimum: 1
1087+
type: integer
10801088
tolerations:
10811089
description: Tolerations specifies pod tolerations.
10821090
items:
@@ -5265,6 +5273,17 @@ spec:
52655273
format: int32
52665274
type: integer
52675275
type: object
5276+
type:
5277+
default: auto
5278+
description: |-
5279+
Type specifies the probe mechanism: "auto" (default), "http", or "tcp".
5280+
"auto" uses HTTP probes in open mode and TCP probes in authenticated/single-tenant mode
5281+
(where /api/health returns 403 without credentials).
5282+
enum:
5283+
- auto
5284+
- http
5285+
- tcp
5286+
type: string
52685287
type: object
52695288
resources:
52705289
description: Resources specifies the compute resources for the Paperclip

internal/controller/instance_controller.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,11 @@ func (r *InstanceReconciler) reconcileStatefulSet(ctx context.Context, instance
372372

373373
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, obj, func() error {
374374
obj.Labels = desired.Labels
375+
// When HPA is enabled, preserve the current replica count to avoid
376+
// fighting the autoscaler on every reconcile.
377+
if as := instance.Spec.Availability.AutoScaling; as != nil && as.Enabled && obj.Spec.Replicas != nil {
378+
desired.Spec.Replicas = obj.Spec.Replicas
379+
}
375380
obj.Spec = desired.Spec
376381
return controllerutil.SetControllerReference(instance, obj, r.Scheme)
377382
})

internal/resources/common.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,40 @@ const (
4242

4343
// HealthPath is the HTTP health check path.
4444
HealthPath = "/api/health"
45+
46+
// DefaultPaperclipEntrypoint is the default Paperclip container entrypoint.
47+
// Used when the operator needs to inject a shell wrapper (e.g., heartbeat leader election).
48+
DefaultPaperclipEntrypoint = `node --import ./server/node_modules/tsx/dist/loader.mjs server/dist/index.js`
4549
)
4650

4751
// Ptr returns a pointer to the given value.
4852
func Ptr[T any](v T) *T {
4953
return &v
5054
}
5155

56+
// EffectiveReplicas returns the configured replica count, defaulting to 1.
57+
func EffectiveReplicas(instance *paperclipv1alpha1.Instance) int32 {
58+
if instance.Spec.Availability.Replicas != nil {
59+
return *instance.Spec.Availability.Replicas
60+
}
61+
return 1
62+
}
63+
64+
// UseTCPProbes returns true when probes should use TCP instead of HTTP.
65+
// This is needed in authenticated/single-tenant mode where /api/health returns 403.
66+
func UseTCPProbes(instance *paperclipv1alpha1.Instance) bool {
67+
probeType := instance.Spec.Probes.Type
68+
if probeType == "tcp" {
69+
return true
70+
}
71+
if probeType == "http" {
72+
return false
73+
}
74+
// "auto" or empty: use TCP for authenticated/single-tenant modes
75+
mode := instance.Spec.Deployment.Mode
76+
return mode == "authenticated" || mode == "single-tenant"
77+
}
78+
5279
// Labels returns the standard labels for a Instance resource.
5380
func Labels(instance *paperclipv1alpha1.Instance) map[string]string {
5481
return map[string]string{

internal/resources/database.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,16 @@ func BuildDatabaseStatefulSet(instance *paperclipv1alpha1.Instance) *appsv1.Stat
4949
},
5050
},
5151
{Name: "PGDATA", Value: DatabaseMountPath + "/pgdata"},
52+
{Name: "POSTGRES_INITDB_ARGS", Value: "--data-checksums"},
53+
},
54+
Resources: databaseResources(instance),
55+
Lifecycle: &corev1.Lifecycle{
56+
PreStop: &corev1.LifecycleHandler{
57+
Exec: &corev1.ExecAction{
58+
Command: []string{"/bin/sh", "-c", "pg_ctl stop -m fast -D $PGDATA"},
59+
},
60+
},
5261
},
53-
Resources: instance.Spec.Database.Managed.Resources,
5462
ImagePullPolicy: corev1.PullIfNotPresent,
5563
TerminationMessagePath: "/dev/termination-log",
5664
TerminationMessagePolicy: corev1.TerminationMessageReadFile,
@@ -144,6 +152,23 @@ func BuildDatabaseStatefulSet(instance *paperclipv1alpha1.Instance) *appsv1.Stat
144152
return sts
145153
}
146154

155+
func databaseResources(instance *paperclipv1alpha1.Instance) corev1.ResourceRequirements {
156+
r := instance.Spec.Database.Managed.Resources
157+
if len(r.Requests) == 0 && len(r.Limits) == 0 {
158+
return corev1.ResourceRequirements{
159+
Requests: corev1.ResourceList{
160+
corev1.ResourceCPU: resource.MustParse("250m"),
161+
corev1.ResourceMemory: resource.MustParse("256Mi"),
162+
},
163+
Limits: corev1.ResourceList{
164+
corev1.ResourceCPU: resource.MustParse("1"),
165+
corev1.ResourceMemory: resource.MustParse("1Gi"),
166+
},
167+
}
168+
}
169+
return r
170+
}
171+
147172
// BuildDatabaseSecret constructs the auto-generated database credentials Secret.
148173
func BuildDatabaseSecret(instance *paperclipv1alpha1.Instance, password string) *corev1.Secret {
149174
return &corev1.Secret{

internal/resources/resources_test.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,12 @@ func TestBuildStatefulSet(t *testing.T) {
9595
t.Error("expected startup probe")
9696
}
9797

98-
// Verify health check path
99-
if container.LivenessProbe.HTTPGet.Path != HealthPath {
100-
t.Errorf("expected liveness probe path %q, got %q", HealthPath, container.LivenessProbe.HTTPGet.Path)
98+
// Verify probe type: authenticated mode should use TCP probes
99+
if container.LivenessProbe.TCPSocket == nil {
100+
t.Error("expected TCP liveness probe for authenticated mode")
101+
}
102+
if container.ReadinessProbe.TCPSocket == nil {
103+
t.Error("expected TCP readiness probe for authenticated mode")
101104
}
102105

103106
// Verify volume mounts

internal/resources/statefulset.go

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ func BuildStatefulSet(instance *paperclipv1alpha1.Instance) *appsv1.StatefulSet
1616
labels := LabelsWithComponent(instance, "server")
1717
selectorLabels := SelectorLabels(instance)
1818

19-
replicas := int32(1)
19+
replicas := EffectiveReplicas(instance)
2020

2121
container := buildMainContainer(instance)
2222
volumes := buildVolumes(instance)
@@ -136,6 +136,15 @@ func buildMainContainer(instance *paperclipv1alpha1.Instance) corev1.Container {
136136
}
137137
}
138138

139+
// Multi-replica heartbeat gating: only pod-0 runs the scheduler.
140+
// Uses a shell wrapper that checks the StatefulSet ordinal in $HOSTNAME.
141+
if instance.Spec.Heartbeat.Enabled && EffectiveReplicas(instance) > 1 {
142+
container.Command = []string{"/bin/sh", "-c"}
143+
container.Args = []string{
144+
`case "$HOSTNAME" in *-0) export HEARTBEAT_SCHEDULER_ENABLED=true ;; *) export HEARTBEAT_SCHEDULER_ENABLED=false ;; esac; exec ` + DefaultPaperclipEntrypoint,
145+
}
146+
}
147+
139148
// Probes
140149
container.LivenessProbe = buildLivenessProbe(instance, port)
141150
container.ReadinessProbe = buildReadinessProbe(instance, port)
@@ -231,6 +240,9 @@ func buildEnvVars(instance *paperclipv1alpha1.Instance) []corev1.EnvVar {
231240
}
232241

233242
// Heartbeat scheduler
243+
// When heartbeat is disabled, explicitly disable it on all pods.
244+
// When enabled with multiple replicas, the command wrapper handles per-pod gating
245+
// (only pod-0 runs the scheduler), so we skip the static env var here.
234246
if !instance.Spec.Heartbeat.Enabled {
235247
vars = append(vars, corev1.EnvVar{Name: "HEARTBEAT_SCHEDULER_ENABLED", Value: "false"})
236248
}
@@ -349,15 +361,26 @@ func buildVolumeMounts(instance *paperclipv1alpha1.Instance) []corev1.VolumeMoun
349361
return mounts
350362
}
351363

352-
func buildLivenessProbe(instance *paperclipv1alpha1.Instance, port int32) *corev1.Probe {
353-
probe := &corev1.Probe{
354-
ProbeHandler: corev1.ProbeHandler{
355-
HTTPGet: &corev1.HTTPGetAction{
356-
Path: HealthPath,
357-
Port: intstr.FromInt32(port),
358-
Scheme: corev1.URISchemeHTTP,
364+
func probeHandler(instance *paperclipv1alpha1.Instance, port int32) corev1.ProbeHandler {
365+
if UseTCPProbes(instance) {
366+
return corev1.ProbeHandler{
367+
TCPSocket: &corev1.TCPSocketAction{
368+
Port: intstr.FromInt32(port),
359369
},
370+
}
371+
}
372+
return corev1.ProbeHandler{
373+
HTTPGet: &corev1.HTTPGetAction{
374+
Path: HealthPath,
375+
Port: intstr.FromInt32(port),
376+
Scheme: corev1.URISchemeHTTP,
360377
},
378+
}
379+
}
380+
381+
func buildLivenessProbe(instance *paperclipv1alpha1.Instance, port int32) *corev1.Probe {
382+
probe := &corev1.Probe{
383+
ProbeHandler: probeHandler(instance, port),
361384
InitialDelaySeconds: 15,
362385
PeriodSeconds: 20,
363386
TimeoutSeconds: 5,
@@ -388,13 +411,7 @@ func buildLivenessProbe(instance *paperclipv1alpha1.Instance, port int32) *corev
388411

389412
func buildReadinessProbe(instance *paperclipv1alpha1.Instance, port int32) *corev1.Probe {
390413
probe := &corev1.Probe{
391-
ProbeHandler: corev1.ProbeHandler{
392-
HTTPGet: &corev1.HTTPGetAction{
393-
Path: HealthPath,
394-
Port: intstr.FromInt32(port),
395-
Scheme: corev1.URISchemeHTTP,
396-
},
397-
},
414+
ProbeHandler: probeHandler(instance, port),
398415
InitialDelaySeconds: 5,
399416
PeriodSeconds: 10,
400417
TimeoutSeconds: 3,
@@ -425,13 +442,7 @@ func buildReadinessProbe(instance *paperclipv1alpha1.Instance, port int32) *core
425442

426443
func buildStartupProbe(instance *paperclipv1alpha1.Instance, port int32) *corev1.Probe {
427444
probe := &corev1.Probe{
428-
ProbeHandler: corev1.ProbeHandler{
429-
HTTPGet: &corev1.HTTPGetAction{
430-
Path: HealthPath,
431-
Port: intstr.FromInt32(port),
432-
Scheme: corev1.URISchemeHTTP,
433-
},
434-
},
445+
ProbeHandler: probeHandler(instance, port),
435446
InitialDelaySeconds: 0,
436447
PeriodSeconds: 5,
437448
TimeoutSeconds: 3,

0 commit comments

Comments
 (0)