Qirava Cloud docs
Mode switching: standalone <-> cluster
Mode switching moves a tenant between standalone (a single instance) and cluster (a leader plus followers). In Qirava Cloud this is the cloud.switch_mode function. The defining property — a hard requirement from the design — is that it is NON-DESTRUCTIVE in BOTH directions, because the on-disk storage/WAL layout is identical across modes: switching to cluster adds followers, switching to standalone drops to one instance, and neither converts data. The function flips mode and replicas on the tenant row and adjusts node allocation (real + durable); adding/dropping followers via the replication path is SIMULATED and returned as a PLANNED note.
The two modes#
- standalone
- Exactly one instance. replicas = 1. No followers, no failover peers.
- cluster
- A leader (master) plus one or more followers on distinct nodes. replicas >= 2.
A tenant is created in one mode (provision defaults to standalone; choosing cluster starts it with 2 replicas). Mode switching changes between them later without recreating the tenant. The same call handles both directions — there is no separate "convert to cluster" vs "collapse to standalone" function.
Why it is non-destructive in both directions#
The engine's storage and WAL format is IDENTICAL whether a node runs standalone or as part of a cluster. A follower is just another node applying the leader's committed op-frames to the same on-disk format via apply_replicated, which durably logs them so the follower can itself fail over. That means:
- standalone -> cluster: bring up follower(s) on distinct nodes and stream the leader's WAL + change-stream to them until caught up. Nothing on the original instance is converted or moved.
- cluster -> standalone: drain the follower(s); the leader already holds all the data, so it simply keeps serving as a single instance.
- Neither direction performs a data conversion, dump/restore, or rebuild — it is purely add/remove replicas around an unchanged store.
Replica-count effects#
Switching mode sets the replica count deterministically from the target:
| Target mode | New replicas | Node alloc effect |
|---|---|---|
| cluster | max(current, 2) — at least master + 1 follower | alloc += cap x (new_rep - old_rep) |
| standalone | 1 | alloc += cap x (1 - old_rep) — i.e. releases follower capacity |
When switching TO cluster, if the tenant somehow already carried a higher replica count it is preserved (max with 2); a fresh standalone goes to exactly 2. When switching TO standalone, replicas collapses to 1. The node allocation always moves by the per-instance cap times the replica delta, and node counters are clamped at 0.
Inputs and validation#
| Field | Required | Accepted values |
|---|---|---|
| id | yes | an existing tenant id |
| mode | yes | standalone | cluster |
Validation rejects, before any write: a missing id or mode (InvalidInput: missing required field), an unrecognized mode value (mode must be standalone|cluster), an unknown tenant (no tenant '<id>'), and a no-op where the tenant is already in the requested mode (tenant '<id>' is already <mode>). The role gate requires admin or higher (deny-by-default).
What it does, step by step#
- Re-check
ctx.role>= admin. - Require id and mode fields; normalize mode to exactly standalone or cluster (else InvalidInput).
- Load the tenant row; reject if absent.
- Reject if the tenant is already in the target mode (no-op guard).
- Compute new replicas: max(old, 2) for cluster, or 1 for standalone.
- UPDATE
_cp_tenantsSET mode, replicas via the write funnel. - Apply node allocation delta: cap x (new_rep - old_rep) on the placed node.
- Write a
_cp_auditrow (action=switch_mode, result=the target mode). - Return the new state with a PLANNED note naming the direction and the non-destructive guarantee.
standalone (replicas 1) cluster (replicas >= 2)
│ switch_mode mode=cluster │ switch_mode mode=standalone
▼ ▼
add follower(s) on distinct nodes drain follower(s), keep ONE instance
via replication path (catch up live) leader already holds all data
│ │
REAL: mode=cluster, replicas=2 (or max) REAL: mode=standalone, replicas=1
REAL: node alloc += cap x (new-old) REAL: node alloc += cap x (1-old) (releases)
└──────────── on-disk layout NEVER changes ───────────┘Worked example: both directions#
Tenant acme starts standalone (replicas 1). We switch it to cluster, then back to standalone. This is the exact non-destructive round-trip the test suite asserts.
Request
{
ctx: { user: "op", role: "admin" },
id: "acme",
mode: "cluster"
}Response
// code = Ok
{
tenant_id: "acme",
simulated: "PLANNED (standalone->cluster: add follower(s) on distinct nodes via replication) — NON-DESTRUCTIVE: on-disk layout is identical across modes",
status: "active",
mode: "cluster",
threads: 2,
mem_gb: 4,
storage_gb: 20,
replicas: 2,
node_id: "node-eu-1"
}Output
Persisted: _cp_tenants(acme) mode=cluster, replicas=2.
Node alloc doubles for the tenant (cap charged once per replica): e.g. alloc_threads 2 -> 4.Request
{
ctx: { user: "op", role: "admin" },
id: "acme",
mode: "standalone"
}Response
// code = Ok
{
tenant_id: "acme",
simulated: "PLANNED (cluster->standalone: drain follower(s), keep one instance) — NON-DESTRUCTIVE: on-disk layout is identical across modes",
status: "active",
mode: "standalone",
threads: 2,
mem_gb: 4,
storage_gb: 20,
replicas: 1,
node_id: "node-eu-1"
}Output
Persisted: _cp_tenants(acme) mode=standalone, replicas=1.
Node alloc returns to one instance's worth (the follower capacity is released).Request
{
ctx: { user: "op", role: "admin" },
id: "acme", // already standalone
mode: "standalone"
}Response
// code = InvalidInput
{
message: "tenant 'acme' is already standalone"
}Console usage and relationship to scaling#
On the Tenants screen (/cloud/tenants, admin+), the mode form POSTs id + mode to cloud.action.switch_mode, which after session + CSRF + rank checks calls cloud.switch_mode. The success flash carries the direction + non-destructive note. Mode switching is the gateway to horizontal scaling: a standalone tenant must be switched to cluster (which sets it to 2 replicas) before cloud.scale_horizontal will accept a replica change. Vertical scaling, by contrast, works in either mode since it resizes the per-instance cap rather than the count.