Skip to content

servers: becomes literal deploy target list; replicas:/placement: become scale: #96

Description

@acidtib

Goal

Change the placement model so that listing a server in servers: always means "deploy here." scale: N (replacing replicas: N) means N instances on each listed server, not N total spread across a candidate pool. Example from the request that started this: servers: [a, b] with scale: 1 today deploys 1 instance on a and 1 on b (2 total); scale: 2 deploys 2 on each (4 total).

This supersedes the docs/todo.md "Clarify replicas: semantics" item -- that item assumed the current pool-based meaning was correct and only needed better documentation. It doesn't need documentation; it needs to change to the model below, per direct instruction.

This is not just a rename -- the codebase already has two inconsistent placement models

Found while reading the code, not assumed:

  • jiji-network::planner::plan_endpoints already builds exactly one ServiceEndpointPlan per (service, server) pair for every server in service.servers -- no replicas/placement filtering at that layer at all. The network layer already treats servers: as the literal, complete target list. replicas/placement is a filter applied on top, only in the CLI.
  • jiji-cli::placement::endpoint_replica_id already computes a replica's identity purely from a server's sorted position within service.servers -- exactly "one replica per listed server," with no replicas/placement input at all. Its own doc comment calls it "the legacy one-endpoint-per-server command surface while it is being migrated to replica assignments," i.e. this codebase's own history has this exact model, and a previous change was migrating away from it toward the pool model. jiji service restart and jiji service rollback (commands/service/restart.rs:76, commands/service/rollback.rs:85) use endpoint_replica_id for every selected endpoint today, not just network_mode: service:<name> dependents.
  • jiji-cli::placement::place is the other model: replicas total, greedily distributed across service.servers by spread/packed policy, producing ordinal-based replica ids (replica_id(project, service, ordinal), ordinal = position in the greedy assignment, not position in servers:). jiji deploy (commands/deploy.rs:887 and :972) and jiji service scale (commands/service/scale.rs:158, :179) use this model.
  • These two models silently disagree whenever replicas < servers.len(). Take servers: [a, b, c], replicas: 2, placement: spread. jiji deploy places replicas on a and b (ordinals 0, 1; alphabetical tie-break under equal load). jiji service restart -H c -S <service> computes endpoint_replica_id from c's position in the sorted list (index 2) and produces replica_id(project, service, 2) -- a replica id that place() never assigned, because only 2 of the 3 servers ever got used. This is a live inconsistency in the current code, not a hypothetical: it only happens to look correct today in the common case where replicas == servers.len(), which is exactly the case the requested model makes universal.

So this plan isn't introducing a new mental model against the grain of the codebase -- it's collapsing two already-present, already-disagreeing models into the one that both the network layer and half the CLI (restart, rollback) already assume, and finishing what "being migrated to replica assignments" apparently reversed direction on at some point.

Design

Config schema (crates/jiji-config/src/schema.rs)

  • Rename Service.replicas: u32 -> Service.scale: u32 (default = 1, same as today's default_replicas). Meaning changes from "total across the eligible pool" to "instances per listed server."
  • Remove Service.placement: PlacementPolicy and the PlacementPolicy enum (Spread/Packed) entirely. There is no longer a pool to spread across or pack -- every listed server is used, unconditionally.
  • Investigate adding #[serde(deny_unknown_fields)] to Service (most other config structs already have it; Service currently doesn't, for a reason not yet established -- check before assuming it's safe). Without it, an unmigrated config with a stray replicas:/placement: key parses silently, ignoring both, which quietly changes what gets deployed instead of failing loudly. This is exactly the kind of silent misbehavior the project's error-handling conventions want to avoid; a hard parse error that names the field and points at scale: is much better than a service that quietly deploys fewer instances than the user thinks it does.

crates/jiji-cli/src/placement.rs

  • Delete place() and ReplicaAssignment (the ordinal/pool version).
  • Generalize endpoint_replica_id to also carry a local index, since it needs to name up to scale replicas per server now, not exactly one: replica_id_for(project, service, server, local_index) -> String, hashing (project, service, server, local_index) instead of today's (project, service, ordinal). Hashing the server name and a server-local index directly, instead of a single flat pool-ordinal, means a replica's id depends only on its own (server, local_index) pair: adding/removing an unrelated server from servers:, or changing scale, never reassigns any other replica's id. (Today's ordinal scheme happens to be prefix-stable under growing replicas for spread, but is not designed to survive a servers: list change; the new scheme is stable under both by construction.)
  • Keep a plain, deterministic ordinal: u32 on the returned assignment (position in the sorted (server, local_index) enumeration) purely for ordering purposes -- cron owner selection already picks "the lowest-ordinal Active/Healthy replica" (cron_reconcile:: select_cron_owner) and needs some total order, but this ordinal is not part of replica identity anymore, only a secondary sort key computed fresh each time.
  • New pub fn assignments_for(project, service, servers: &[String], scale: u32) -> Vec<ReplicaAssignment>: sorts+dedups servers, then for each server in order, for local_index in 0..scale, emits one assignment. Replaces every place() call site.

jiji-cli::commands::deploy

  • select_replica_endpoints and select_effective_replica_endpoints (commands/deploy.rs:868, :922) both call placement::place(..., service.replicas, &service.servers, service.placement) today. Replace both with placement::assignments_for(..., &service.servers, service.scale).
  • select_effective_replica_endpoints also has a distributed desired-state fallback path (reads a DesiredStateRecord if jiji service scale previously wrote one) -- see the agent-side changes below for what that record now means.

jiji-cli::commands::service::{restart,rollback}

  • Both already call placement::endpoint_replica_id for every selected endpoint. Update the call to the generalized replica_id_for, which now needs a local_index -- since these commands target endpoints (one per (service, server), from ServiceEndpointPlan) rather than individual replicas, and scale can be greater than 1, this is where scope has to expand: restarting/rolling back "the endpoint on server a" now potentially means every one of that server's scale replicas, not implicitly just local_index 0. Fan -H/-S-selected endpoints out to all 0..scale replicas per matched server, the same way cascading already fans a selection out today (add_cascaded_dependents).
  • restart.rs has a second, easy-to-miss call site: resolve_restart_image (restart.rs:530) calls endpoint_replica_id to look up the currently active/healthy catalog record's image when the service has no image: configured (restart-in-place). It only has an endpoint: &ServiceEndpointPlan (one per server, no local index) to work from. Decide explicitly what to do here rather than leaving it ambiguous: since every replica on the same server for the same service is expected to run the same image, look up local_index == 0's replica id for this purpose regardless of scale -- document that assumption in the code, don't silently pick an arbitrary index.

jiji-cli::cascade

  • add_cascaded_dependents's doc comment already justifies using endpoint_replica_id for service:<name> dependents specifically as "the real cardinality is one instance per shared-namespace server." That reasoning now applies to every service, not just dependents -- simplify the comment accordingly. A dependent's own scale stays fixed at 1 (the existing "dependent can't set replicas above 1" validation rule, renamed), so its per-server fan-out is trivially local_index == 0 always; upstream fan-out uses the general 0..scale case above.

jiji-cli::commands::service::scale

  • Command name stays jiji service scale -- it finally means what its name says once the config field is also scale:. Rename the --replicas N flag to --scale N (the current flag name matched the old config field, which is what made this confusing in the first place; the whole point of this change is that "scale" and the number of instances per server mean the same thing everywhere). --reset keeps its current meaning (drop the override, fall back to the configured value).
  • Internally: replace every placement::place(...) call (scale.rs:158, :179) with placement::assignments_for(...), and stop accepting a -H/--hosts rejection message that talks about "eligible servers" -- update the actionable error text at scale.rs:36 ("does not accept -H/--hosts; placement is computed across the service's configured eligible servers") to describe the new model instead.

jiji-agent distributed desired-state (crates/jiji-agent/src/desired.rs, store.rs)

  • DesiredStateRecord.replica_override: Option<u32> changes meaning from "total override" to "per-server-scale override." The field's Rust type doesn't need to change, but its meaning does, which is exactly the kind of change docs/architecture-notes.md's protocol/schema-version rejection exists for: bump DESIRED_SCHEMA_VERSION from 1 to 2 so an agent running the old code rejects a new-meaning record instead of silently misinterpreting an old total as a new per-server count (or vice versa) during a rolling upgrade. Rename the field to scale_override to make the meaning change visible in the diff and in every call site.
  • DesiredStateRecord.assignments: Vec<ReplicaAssignment> (the desired.rs one, distinct from placement.rs's) currently stores (replica_id, ordinal, owner_node_id) triples computed by place(). Under the new model this is fully derivable from servers (on the Service config, already known to every agent) plus scale_override -- owner_node_id is no longer a free placement choice, it's just the server itself. Evaluate during implementation whether assignments can be dropped from the record entirely (recomputed on read from servers + scale_override) or should stay for compatibility with existing consumers (DNS, cron ownership selection) that read it directly -- don't assume either way without checking every read site first.
  • store.rs's local scale_overrides table (service TEXT PRIMARY KEY, replicas INTEGER NOT NULL, revision INTEGER NOT NULL, updated_at TEXT NOT NULL) needs its replicas column's meaning updated to match (rename to scale for clarity); since this is local per-agent durable state, not wire-replicated, this is a plain SQLite migration on agent upgrade, not a protocol-version concern.

Validation (crates/jiji-config/src/validation.rs)

  • total_replicas (the running sum feeding MAX_REPLICAS/ TOO_MANY_REPLICAS) changes from sum(service.replicas) to sum(service.servers.len() as u32 * service.scale).
  • Important distinction, don't get this wrong: NON_BRIDGE_SCALE ("can only scale with project bridge networking"), STATEFUL_SCALE ("cannot scale local volumes/files/directories implicitly"), and EXCLUSIVE_RESOURCE_SCALE (privileged/devices/GPU) currently key off service.replicas > 1. They must key off service.scale > 1 after the rename, not off total instance count (servers.len() * scale). The thing these rules actually guard against is multiple containers of the same replica identity contending for one host's resources/state (multiple non-bridge containers on the same server, multiple writers to the same bind-mounted volume, multiple claims on one exclusive device) -- a service listing several servers at scale: 1 puts exactly one instance per host, which was always safe and remains safe under every one of these rules; it's scale > 1 (more than one instance sharing a single host) that's the actual hazard.
  • The service:<name> dependent rule ("dependent can't set replicas above 1") is renamed to "dependent can't set scale above 1," same check, service.scale instead of service.replicas.
  • The "dependent's servers must be a subset of upstream's servers" rule is unaffected.

Docs

  • crates/jiji-config/src/jiji.yml: rewrite the servers:/replicas:/ placement: block (currently lines ~429-436) to document servers: as the literal deploy target list and scale: as the per-server instance count; remove the placement: example entirely.
  • Website app/docs/reference/configuration/page.mdx (~/Code/jiji-website): same section, plus the "Replicas and placement" section the current docs/todo.md documentation-clarity item points at -- that whole section's premise (a worked example distinguishing "total across a pool" from a naive per-server reading) goes away, since there's no longer a distinction to explain; replace it with the new, much simpler model.
  • Website commands reference: jiji service scale's --replicas mention becomes --scale.
  • AGENTS.md: update the "Health-Gated Deployment Strategy" and command reference sections that describe replicas/placement/jiji service scale --replicas.
  • docs/todo.md: remove the "Clarify replicas: semantics" item under "Documentation clarity" (superseded, not just resolved) once this ships.

Testing plan

  • Pure unit tests: placement.rs (assignments_for produces exactly servers.len() * scale assignments, one per (server, local_index); replica_id_for is stable under an added/removed unrelated server and under a scale change -- this is the regression test for the bug class found above); validation.rs (scale-vs-total-instance-count gating distinction above, capacity sum, dependent scale cap). Delete/replace placement.rs's existing spread_is_balanced_and_input_order_independent and packed_uses_deterministic_first_host tests, which test the removed place() algorithm directly.
  • Existing fixtures that reference what's being removed and need updating, not just new tests: crates/jiji-config/tests/validation_test.rs uses jiji_config::PlacementPolicy::Spread directly (line 94) and has two replicas: 2 YAML literals (lines 110, 268); crates/jiji-cli/tests/ service_restart_test.rs and crates/jiji-cli/tests/deploy_test.rs both have replicas: in test fixture YAML.
  • Mock-SSH integration tests: a service with servers: [a, b, c], scale: 1 deploys to all three, not two; jiji service restart -H c and jiji service rollback -H c target the same replica id jiji deploy created for c (the exact scenario that's inconsistent today); jiji service scale -S <service> --scale 2 doubles the instance count on every already-listed server, not just the total.
  • Agent-side tests: inline #[cfg(test)] mod tests in desired.rs and store.rs (jiji-agent has no separate tests/ integration directory; all its tests are co-located in the source files, unlike jiji-cli/jiji-config). DESIRED_SCHEMA_VERSION bump is enforced (an agent on schema 1 rejects a schema-2 record and vice versa); the scale_overrides table migration preserves existing rows sanely on upgrade (or document why a value with the old meaning can't be auto-migrated and must reset to the configured default instead -- decide during implementation, don't guess now).
  • Live-host verification: deploy a multi-server service under the new model, confirm every listed server gets a container; run jiji service scale --scale 2 and confirm doubled instances per host; run jiji service restart -H <one-of-several-servers> and confirm it targets the right, already-running container. Use a test VPS and tear down afterward.

Task checklist

  • jiji-config::schema: rename replicas -> scale, remove placement/PlacementPolicy, investigate deny_unknown_fields on Service.
  • jiji-cli::placement: remove place(), generalize endpoint_replica_id -> replica_id_for with a local index, add assignments_for.
  • jiji-cli::commands::deploy: both selection functions use assignments_for.
  • jiji-cli::commands::service::{restart,rollback}: use replica_id_for, fan out selected endpoints across 0..scale, including the second call site in restart.rs::resolve_restart_image (line 530, easy to miss).
  • jiji-cli::cascade: simplify the now-universal one-per-server reasoning in the doc comment.
  • jiji-cli::commands::service::scale: --scale flag, use assignments_for, update the -H/--hosts rejection message.
  • jiji-agent::desired: rename replica_override -> scale_override, bump DESIRED_SCHEMA_VERSION, decide the fate of assignments.
  • jiji-agent::store: migrate scale_overrides.replicas column.
  • jiji-config::validation: recompute capacity sum, fix the scale-vs-total distinction in the three scale-gating rules, rename the dependent-scale-cap rule.
  • Unit + mock-SSH + agent-side tests above, including the restart/rollback-vs-deploy identity regression test.
  • Live-host verification.
  • jiji.yml, website configuration + commands reference, AGENTS.md updated.
  • docs/todo.md: remove the superseded documentation-clarity item.
  • mise build / mise lint / mise test / mise scan all clean.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions