Skip to content

Elastic Deployment ​

From single monolith to N replicas — without changing a line of plugin code.

MicroCoreOS is an Elastic Monolith: one codebase that runs as a single process on a laptop and as N identical replicas behind a load balancer. Scaling is done by swapping tools and flipping environment variables.

This guide is the operational path between those two extremes. Every step is independently reversible.

The three stages ​

StagePersistenceStateEvent transportReplicas
Dev (default)SQLitein-memoryin-process1
Small productionPostgreSQLin-memoryin-process1
ElasticPostgreSQLRedisRedis StreamsN

You never skip a contract: every swap keeps the same tool name and the same API, so plugins are untouched at every stage.

Stage 1 — Single replica on PostgreSQL ​

  1. Swap the db tool (both declare name = "db"; only one may live in tools/):
    bash
    mv tools/sqlite extras/available_tools/sqlite
    mv extras/available_tools/postgresql tools/postgresql
  2. Configure (defaults in parentheses):
    bash
    PG_HOST=...        # (localhost)
    PG_PORT=...        # (5432)
    PG_USER=...        # (postgres)
    PG_PASSWORD=...
    PG_DATABASE=...    # (microcoreos)
    PG_MIN_POOL=...    PG_MAX_POOL=...    # pool sizing
  3. Run. Migrations in domains/*/migrations/*.sql are applied on boot exactly as with SQLite (same topological sort, same $1, $2 placeholders).

A single replica may keep the in-memory state tool and the in-process event bus — they are correct as long as there is exactly one process.

Stage 2 — N replicas ​

Three things break when you naively run two copies of any monolith: shared volatile state, event delivery, and the scheduler. MicroCoreOS has one switch for each.

2.1 Shared state → RedisStateTool ​

The in-memory state tool is per-process; counters and rate-limit windows must move to Redis so the fleet shares them.

bash
mv tools/state extras/available_tools/state
mv extras/available_tools/redis_state tools/redis_state

Configuration (shared with the bus driver — one Redis serves both):

bash
REDIS_HOST=...              # (localhost)
REDIS_PORT=...              # (6379)
REDIS_DB=...                # (0)
REDIS_PASSWORD=...          # ("" = no auth)
REDIS_CONNECT_TIMEOUT=...   # (5 seconds)

2.2 Event transport → Redis Streams driver ​

No tool swap here — the driver is selected by env var, the EventBusTool (retries, DLQ, RPC, tracing) stays exactly the same:

bash
EVENT_BUS_DRIVER=redis_streams
EVENT_BUS_STREAM_MAXLEN=10000   # optional, approximate cap per stream

What you get across the fleet, with zero plugin changes:

  • Exactly-one-consumer per logical subscriber. subscribe() derives a stable consumer group from the callback identity, so N replicas running the same plugin share one group — each event is handled by ONE replica. Distinct plugins get distinct groups, so each logical consumer still receives its copy.
  • At-least-once delivery. Messages are acknowledged only after the handler (including bus-side retries) finishes. If a replica dies mid-handler, another replica of the group reclaims the message (XAUTOCLAIM, idle > 60s). Handlers must be idempotent — this was already the bus contract.
  • Causal traces survive the network. Envelopes travel with id and parent_id; the causality tree crosses instances intact.

2.3 Scheduler → one beat, many workers ​

With N replicas, every cron job would fire N times. Designate exactly one replica as the "beat":

bash
# beat replica (exactly one)
SCHEDULER_ENABLED=true    # default

# worker replicas (all the others)
SCHEDULER_ENABLED=false

Jobs register everywhere (same code in every replica) but only fire on the beat. For heavy jobs, the recommended pattern is: the job handler publishes to the bus (bus.publish("jobs.report.due")) and workers consume — the automatic consumer groups guarantee exactly-one-consumer across the fleet, so the beat schedules and the fleet executes.

2.4 Migrations → pipeline step, not boot step ​

In production, migrations never run inside the replicas (a rolling deploy would race N replicas against the same DDL). They run once, as a pipeline step, before the new replicas start:

bash
# all production replicas
DB_AUTO_MIGRATE=false

# CI/CD pipeline step (boots ONLY the db tool, migrates, exits)
DB_AUTO_MIGRATE=true uv run main.py --boot-tool db

This is the same model as Django/Rails/Flyway: the pipeline (with DBA sign-off if required) owns the schema; replicas just connect.

The edge layer (in front of the monolith) ​

These concerns belong to the reverse proxy / load balancer, not to MicroCoreOS — putting them inside the app would waste a Python worker on requests that should die at the door:

  • TLS termination and HTTP→HTTPS redirect.

  • Load balancing across the N replicas (any strategy works: the replicas are stateless once Stage 2 is complete — sessions live in the JWT, shared state lives in Redis).

  • Protecting the /system/* observability endpoints (status, events, metrics, traces). They are public BY DESIGN — the framework does not impose an auth scheme on its own introspection; each deployment decides (block at the proxy, IP-allowlist, or add auth_validator in your fork).

  • Volumetric rate limiting (per-IP, anti-abuse, DDoS). Example, nginx:

    nginx
    limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
    
    server {
        location / {
            limit_req zone=perip burst=20 nodelay;
            proxy_pass http://microcoreos_fleet;
        }
    }

    Identity-aware rate limiting (per-user, per-plan quotas) is the app's job because it needs business context — see the rate-limiting pattern in INSTRUCTIONS_FOR_AI.md, built on the state tool (already distributed after step 2.1).

Full checklist (per replica) ​

bash
# database (Stage 1 swap: tools/postgresql)
PG_HOST=db.internal  PG_PORT=5432  PG_USER=app  PG_PASSWORD=***  PG_DATABASE=microcoreos
DB_AUTO_MIGRATE=false                  # pipeline runs --boot-tool db

# shared state (Stage 2 swap: tools/redis_state) + event transport
REDIS_HOST=redis.internal  REDIS_PORT=6379  REDIS_PASSWORD=***
EVENT_BUS_DRIVER=redis_streams

# scheduler role
SCHEDULER_ENABLED=false                # true on exactly ONE replica

# http
HTTP_HOST=0.0.0.0  HTTP_PORT=8000
HTTP_CORS_ORIGINS=https://app.example.com

# auth
AUTH_SECRET_KEY=***                    # same key on ALL replicas (JWT must validate anywhere)

# observability (optional)
OTEL_ENABLED=true
OTEL_SERVICE_NAME=microcoreos
OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4317

Deploy order: --boot-tool db in the pipeline → start/roll the worker replicas → ensure exactly one beat replica is up.

Verifying the elastic setup ​

  1. Boot two replicas against the same Redis/PostgreSQL.
  2. Create a user through replica A (POST /users).
  3. Confirm the user.created handler (welcome service) ran on exactly ONE replica — not both, not zero.
  4. Check GET /system/traces/tree on the handling replica: the causal tree must link the handler back to the original HTTP request envelope.
  5. Kill a replica mid-load and repeat: pending messages are reclaimed by the surviving replica (at-least-once).

Known limits at this stage (tracked in the Roadmap):

  • GET /system/traces is a per-instance ring buffer — cross-instance trace aggregation goes through OpenTelemetry.
  • Event payload contracts are validated statically per instance; runtime validation on the bus is on the roadmap.

Released under the MIT License.