Horizontal Scaling

This guide covers best practices for building TinySystems components that scale horizontally across multiple pod replicas.

Scaling Overview

+-----------------------------------------------------------------------------+
|                      HORIZONTALLY SCALED MODULE                              |
+-----------------------------------------------------------------------------+

                    Kubernetes Deployment
                    replicas: 3
                           |
         +-----------------+-----------------+
         |                 |                 |
         v                 v                 v
    +---------+       +---------+       +---------+
    |  Pod 1  |       |  Pod 2  |       |  Pod 3  |
    | (Leader)|       |(Reader) |       |(Reader) |
    +----+----+       +----+----+       +----+----+
         |                 |                 |
         +-----------------+-----------------+
                           |
                    Load Balancing
                           |
                    +------+------+
                    |             |
          Cross-module traffic   HTTP Traffic
          (NATS queue group,     (from Ingress via
           or K8s Service for     Kubernetes Service)
           the gRPC fallback)

With the NATS transport, incoming business messages are load-balanced by the module's queue group on tinymodule.<module>.msg; system-port writes fan out to every pod. On the gRPC fallback, the Kubernetes Service round-robins connections.

Design Principles

1. Stateless by Default

Components should be stateless - all state comes from:

  • Incoming messages
  • Settings (via settings port)
  • TinyNode metadata (shared state)
// Good: Stateless processing
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
    input := msg.(Input)
    result := transform(input, c.settings)  // Only uses input + settings
    return output(ctx, "output", result)
}

// Bad: Local state
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
    c.cache[input.ID] = input  // This won't be shared across pods!
    return module.Ok(nil)
}

2. CR-Based Shared State

For state that must be shared, use TinyNode metadata via the ReconcileHandler capability (for larger or per-execution state, the module.Stateful capability provides a scoped state backend instead):

func (c *Component) OnReconcile(ctx context.Context, node v1alpha1.TinyNode) error {
    // Read shared state (all pods)
    c.sharedConfig = node.Status.Metadata["config"]

    // Write shared state (leader only)
    if utils.IsLeader(ctx) {
        c.emit(ctx, v1alpha1.ReconcilePort, func(n *v1alpha1.TinyNode) error {
            n.Status.Metadata["last-update"] = time.Now().Format(time.RFC3339)
            return nil
        })
    }
    return nil
}

3. Idempotent Operations

Operations should be safe to repeat:

func (c *Component) ensurePort(targetPort int) error {
    // Idempotent: Check before acting
    if c.currentPort == targetPort {
        return nil // Already done
    }

    // Perform action
    return c.startOnPort(targetPort)
}

4. Leader-Only Writes

Only the leader modifies cluster state:

// All pods: handle business messages
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
    if port == "input" {
        return c.processInput(ctx, output, msg)
    }
    return module.Ok(nil)
}

// Leader only: update cluster state
func (c *Component) OnReconcile(ctx context.Context, node v1alpha1.TinyNode) error {
    if utils.IsLeader(ctx) {
        c.updateClusterState(ctx)
    }
    return nil
}

Scaling Patterns

Pattern 1: Load-Balanced Processing

All pods process incoming messages:

// All pods can handle this
func (c *Processor) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
    if port == "input" {
        result := c.process(msg.(Input))
        return output(ctx, "output", result)
    }
    return module.Ok(nil)
}

Traffic is distributed by the NATS queue group (or the Kubernetes Service on the gRPC fallback).

Pattern 2: Coordinated Server

One port, all pods listen:

func (c *Server) OnReconcile(ctx context.Context, node v1alpha1.TinyNode) error {
    configuredPort := getPortFromMetadata(node)

    if configuredPort == 0 && utils.IsLeader(ctx) {
        // Leader: assign port
        port := c.startOnRandomPort()
        c.publishPort(ctx, port) // metadata updater via the emitter
        return nil
    }

    if configuredPort > 0 && c.currentPort != configuredPort {
        // All pods: use assigned port
        return c.startOnPort(configuredPort)
    }

    return nil
}

Pattern 3: Singleton Operations

Only leader performs certain operations:

func (c *Scheduler) OnControl(ctx context.Context, msg any) error {
    if !utils.IsLeader(ctx) {
        return nil // Only leader handles control
    }

    control, ok := msg.(Control)
    if !ok {
        return nil
    }
    if control.Start {
        c.startScheduledJobs(ctx)
    }
    return nil
}

Pattern 4: Sharded Processing

Distribute work by key:

func (c *Sharded) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
    input := msg.(Input)

    // Determine if this pod should handle this key
    podIndex := getPodIndex()
    totalPods := getTotalPods()
    targetPod := hash(input.Key) % totalPods

    if podIndex != targetPod {
        // Not our shard - forward to correct pod
        return c.forwardToShard(ctx, input, targetPod)
    }

    // Our shard - process
    return c.process(ctx, output, input)
}

Helm Values for Scaling

# values.yaml
replicaCount: 3

resources:
  requests:
    cpu: 100m
    memory: 128Mi
  limits:
    cpu: 500m
    memory: 512Mi

# Horizontal Pod Autoscaler
autoscaling:
  enabled: true
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70

Graceful Scaling

Scale Up

1. New pod starts
2. Joins leader election (as reader) and CR watches
3. Subscribes to the module's NATS subjects
   (or starts its gRPC server on the fallback)
4. Starts receiving load-balanced traffic
5. Processes messages

No special handling needed - Kubernetes and the SDK handle it.

Scale Down

1. Pod receives SIGTERM
2. Context cancelled
3. Graceful shutdown
4. Stop accepting new requests
5. Complete in-flight requests
6. Pod terminates

Handle context cancellation:

func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
    for _, item := range items {
        select {
        case <-ctx.Done():
            return module.Fail(ctx.Err()) // Graceful shutdown
        default:
            c.process(item)
        }
    }
    return module.Ok(nil)
}

Monitoring Scaled Deployments

Metrics per Pod

import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/metric"
)

var meter = otel.Meter("my-module")
var messagesProcessed, _ = meter.Int64Counter("messages_processed")

func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
    // Increment counter with pod label
    messagesProcessed.Add(ctx, 1, metric.WithAttributes(
        attribute.String("pod", os.Getenv("HOSTNAME")),
    ))
    return module.Ok(nil)
}

Leader Status

var isLeaderGauge, _ = meter.Int64Gauge("is_leader")

func updateLeaderMetric(isLeader bool) {
    value := int64(0)
    if isLeader {
        value = 1
    }
    isLeaderGauge.Record(context.Background(), value)
}

Common Pitfalls

1. Local Caching

// Bad: Cache not shared
type Component struct {
    cache map[string]Result
}

// Good: Use TinyNode metadata or external cache
func (c *Component) getFromMetadata(node v1alpha1.TinyNode, key string) string {
    return node.Status.Metadata[key]
}

2. Assuming Single Instance

// Bad: Assumes single instance
var globalCounter int

// Good: Use distributed counter or accept per-pod counting
func (c *Component) Handle(ctx context.Context, ...) any {
    // Either: Don't count, or use distributed storage
}

3. File-Based State

// Bad: File not shared between pods
f, _ := os.Create("/data/state.json")

// Good: Use ConfigMap, Secret, or TinyNode metadata

4. In-Memory Timers

// Bad: Timer only runs on one pod
time.AfterFunc(5*time.Minute, doWork)

// Good: Use leader-only timer with state in CR
if utils.IsLeader(ctx) {
    go c.runTimerLoop(ctx, output)
}

Checklist for Scalable Components

  • All processing is stateless or uses CR metadata
  • Leader-only operations check utils.IsLeader(ctx)
  • Context cancellation is respected
  • Operations are idempotent
  • No local file storage for shared state
  • No global variables for shared data
  • Graceful shutdown implemented
  • Metrics include pod identifier

Next Steps