Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Topology

Topology separates protocol logic from deployment configuration. Choreographies remain location-free while topology definitions specify where roles execute. This enables the same protocol to run in different environments without modification.

Overview

The topology system provides three capabilities. It maps roles to network locations. It defines constraints on role placement. It selects transports based on location relationships.

Design Principle

Choreographies describe what happens between roles, not where they are.

Alice -> Bob: Ping
Bob -> Alice: Pong

This protocol works regardless of whether Alice and Bob are in the same process, on the same machine, or across continents. Location is a deployment concern, not a protocol concern.

The topology layer makes this separation explicit.

Choreography        GlobalType          Topology            Execution
─────────────────────────────────────────────────────────────────────
Alice -> Bob: Ping  comm "Alice" "Bob"  Alice ↦ nodeA      send(nodeA, nodeB, Ping)
                    [("Ping", end)]     Bob ↦ nodeB

This diagram shows how a choreography and a topology combine at runtime. It highlights that location mapping happens after projection.

Location Types

Locations specify where a role executes.

#![allow(unused)]
fn main() {
pub enum Location {
    Local,                       // In-process
    Remote(TopologyEndpoint),    // Network endpoint
    Colocated(RoleName),         // Same node as another role
}
}

The Local variant indicates in-process execution using memory channels. The Remote variant specifies a network endpoint. The Colocated variant references another role’s location.

Topology Structure

A topology maps roles to locations with optional constraints.

#![allow(unused)]
fn main() {
pub struct Topology {
    mode: Option<TopologyMode>,
    locations: BTreeMap<RoleName, Location>,
    constraints: Vec<TopologyConstraint>,
}
}

The mode field stores an optional deployment mode. The locations field maps role names to their locations. The constraints field specifies placement requirements.

Topology Constraints

Constraints express requirements on role placement.

#![allow(unused)]
fn main() {
pub enum TopologyConstraint {
    Colocated(RoleName, RoleName), // Must be same node
    Separated(RoleName, RoleName), // Must be different nodes
    Pinned(RoleName, Location),    // Must be at specific location
    Region(RoleName, Region),      // Must be in specific region
}
}

Constraints are validated when binding a topology to a choreography.

Role Family Constraints

For protocols with parameterized roles (wildcards and ranges), you can specify instance count constraints.

#![allow(unused)]
fn main() {
pub struct RoleFamilyConstraint {
    min: u32,              // Minimum instances required
    max: Option<u32>,      // Maximum instances allowed (None = unlimited)
}
}

These constraints validate that resolved role families have an acceptable number of instances.

DSL Syntax

Role constraints are specified in a role_constraints block.

#![allow(unused)]
fn main() {
topology ThresholdSig for ThresholdSignature {
    Coordinator: localhost:8000

    role_constraints {
        Witness: min = 3, max = 10
        Worker: min = 1
    }

    constraints {
        region: Coordinator -> us_east_1
    }
}
}

The min specifies the minimum number of instances required. The max specifies the maximum allowed. If max is omitted, there is no upper limit.

Rust API

Role constraints are available through the Topology struct.

#![allow(unused)]
fn main() {
use rumpsteak_aura_choreography::topology::{Topology, RoleFamilyConstraint};

// Access constraint for a family
let constraint = topology.get_family_constraint("Witness");
if let Some(c) = constraint {
    println!("Witness: min={}, max={:?}", c.min, c.max);
}

// Validate a resolved count
let count = adapter.family_size("Witness")?;
topology.validate_family("Witness", count)?;
}

The validate_family method returns an error if the count is below minimum or above maximum.

Integration with TestAdapter

Role constraints integrate with the runtime adapter for validation at startup.

#![allow(unused)]
fn main() {
let topology = Topology::parse(config)?.topology;

// Create adapter with role family
let witnesses: Vec<Role> = (0..5).map(Role::Witness).collect();
let adapter = TestAdapter::new(Role::Coordinator)
    .with_family("Witness", witnesses);

// Validate before running protocol
let count = adapter.family_size("Witness")?;
topology.validate_family("Witness", count)?;
}

This ensures the configured role family meets the deployment requirements before the protocol starts.

DSL Syntax

Topologies are defined using a DSL extension.

#![allow(unused)]
fn main() {
topology TwoPhaseCommit_Dev for TwoPhaseCommit {
    Coordinator: localhost:9000
    ParticipantA: localhost:9001
    ParticipantB: localhost:9002
}
}

This topology maps three roles to local network endpoints.

Constraints are specified in a nested block.

#![allow(unused)]
fn main() {
topology TwoPhaseCommit_Prod for TwoPhaseCommit {
    Coordinator: coordinator.prod.internal:9000
    ParticipantA: participant-a.prod.internal:9000
    ParticipantB: participant-b.prod.internal:9000

    constraints {
        separated: Coordinator, ParticipantA
        separated: Coordinator, ParticipantB
        region: Coordinator -> us_east_1
        region: ParticipantA -> us_west_2
    }
}
}

The constraints block specifies separation constraints and regions. Use multiple separated lines when more than two roles must be distinct.

Built-in Modes

Topology modes provide common configurations.

topology MyProtocol_Test for MyProtocol {
    mode: local
}

The local mode places all roles in the same process. This is the default for testing.

topology MyProtocol_K8s for MyProtocol {
    mode: kubernetes(my_app)
}

The kubernetes mode discovers services using the Kubernetes API. The namespace is provided in the mode value.

topology MyProtocol_Consul for MyProtocol {
    mode: consul(dc1)
}

The consul mode discovers services using the Consul API. The datacenter is provided in the mode value.

Available modes include local, per_role, kubernetes, and consul.

Rust API

Zero Configuration

Testing requires no explicit topology.

#![allow(unused)]
fn main() {
let handler = InMemoryHandler::new(Role::Alice);
}

This creates an in-memory handler with implicit local topology.

Minimal Configuration

Simple deployments specify peer addresses directly.

#![allow(unused)]
fn main() {
use rumpsteak_aura_choreography::{RoleName, TopologyEndpoint};

let topology = Topology::builder()
    .local_role(RoleName::from_static("Alice"))
    .remote_role(
        RoleName::from_static("Bob"),
        TopologyEndpoint::new("localhost:8081").unwrap(),
    )
    .build();

let handler = PingPong::with_topology(topology, Role::Alice)?;
}

This builds a topology in code and binds it to a generated protocol handler.

Full Configuration

Production deployments use explicit topology objects.

#![allow(unused)]
fn main() {
use rumpsteak_aura_choreography::{Region, RoleName, TopologyEndpoint};

let topology = Topology::builder()
    .remote_role(
        RoleName::from_static("Coordinator"),
        TopologyEndpoint::new("coordinator.internal:9000").unwrap(),
    )
    .remote_role(
        RoleName::from_static("ParticipantA"),
        TopologyEndpoint::new("participant-a.internal:9000").unwrap(),
    )
    .remote_role(
        RoleName::from_static("ParticipantB"),
        TopologyEndpoint::new("participant-b.internal:9000").unwrap(),
    )
    .separated(
        RoleName::from_static("Coordinator"),
        RoleName::from_static("ParticipantA"),
    )
    .separated(
        RoleName::from_static("Coordinator"),
        RoleName::from_static("ParticipantB"),
    )
    .region(
        RoleName::from_static("Coordinator"),
        Region::new("us_east_1").unwrap(),
    )
    .build();

let handler = TwoPhaseCommit::with_topology(topology, Role::Coordinator)?;
}

This example configures explicit endpoints and constraints. It then creates a topology aware handler for a role.

Loading from Files

Topologies can be loaded from external files.

#![allow(unused)]
fn main() {
let parsed = Topology::load("deploy/prod.topology")?;
let handler = PingPong::with_topology(parsed.topology, Role::Alice)?;
}

This supports separation of code and configuration.

Topology Integration

Topology definitions are separate from the choreography DSL. Define topologies in standalone .topology files or strings and load them at runtime.

#![allow(unused)]
fn main() {
let parsed = Topology::load("deploy/dev.topology")?;
let handler = PingPong::with_topology(parsed.topology, Role::Alice)?;
}

This loads a topology file and binds it to a generated handler.

You can also parse a topology from a string.

#![allow(unused)]
fn main() {
use rumpsteak_aura_choreography::topology::parse_topology;

let parsed = parse_topology(r#"
topology Dev for PingPong {
  Alice: localhost:8080
  Bob: localhost:8081
}
"#)?;

let handler = PingPong::with_topology(parsed.topology, Role::Alice)?;
}

This parses the DSL into a ParsedTopology. The topology field contains the Topology value used at runtime.

Transport Selection

The topology determines which transport to use for each role pair.

#![allow(unused)]
fn main() {
use rumpsteak_aura_choreography::{RoleName, TopologyError};

fn select_transport(
    topo: &Topology,
    from: &RoleName,
    to: &RoleName,
) -> Result<Transport, TopologyError> {
    let from_loc = topo.get_location(from)?;
    let to_loc = topo.get_location(to)?;
    Ok(match (from_loc, to_loc) {
        (Location::Local, Location::Local) => InMemoryTransport::new(),
        (_, Location::Remote(endpoint)) => TcpTransport::new(endpoint),
        (_, Location::Colocated(peer)) => SharedMemoryTransport::new(peer),
    })
}
}

The handler automatically routes messages through appropriate transports.

Validation

Topologies are validated against choreography roles.

#![allow(unused)]
fn main() {
use rumpsteak_aura_choreography::RoleName;

let choreo = parse_choreography_str(dsl)?;
let topo = parse_topology(topo_dsl)?;

let roles = [
    RoleName::from_static("Alice"),
    RoleName::from_static("Bob"),
];
let validation = topo.topology.validate(&roles);
if !validation.is_valid() {
    return Err(format!("Topology validation failed: {:?}", validation).into());
}
}

Validation ensures all choreography roles appear in the topology. It also verifies constraints are satisfiable.

Lean Correspondence

The Lean formalization defines topology types and validation.

inductive Location where
  | local
  | remote (endpoint : String)
  | colocated (peer : String)

structure Topology where
  locations : RBMap String Location compare
  constraints : List TopologyConstraint

def Topology.valid (topo : Topology) (g : GlobalType) : Bool :=
  g.roles.all (fun r => topo.locations.contains r)

Projection correctness is proven independent of topology. The project function does not reference location information.

Default Behavior

The InMemoryHandler::new() API remains valid. Choreographies without explicit topologies use implicit local mode.

Usage Example

#![allow(unused)]
fn main() {
use rumpsteak_aura_choreography::{choreography, RoleName, Topology, TopologyEndpoint};

choreography!(r#"
protocol Auction =
  roles Auctioneer, Bidder1, Bidder2
  Auctioneer -> Bidder1 : Item
  Auctioneer -> Bidder2 : Item
  Bidder1 -> Auctioneer : Bid
  Bidder2 -> Auctioneer : Bid
  Auctioneer -> Bidder1 : Result
  Auctioneer -> Bidder2 : Result
"#);

// Development topology
let dev_topo = Topology::builder()
    .remote_role(
        RoleName::from_static("Auctioneer"),
        TopologyEndpoint::new("localhost:9000").unwrap(),
    )
    .remote_role(
        RoleName::from_static("Bidder1"),
        TopologyEndpoint::new("localhost:9001").unwrap(),
    )
    .remote_role(
        RoleName::from_static("Bidder2"),
        TopologyEndpoint::new("localhost:9002").unwrap(),
    )
    .build();

// Production topology
let prod_topo = Topology::builder()
    .remote_role(
        RoleName::from_static("Auctioneer"),
        TopologyEndpoint::new("auction.prod:9000").unwrap(),
    )
    .remote_role(
        RoleName::from_static("Bidder1"),
        TopologyEndpoint::new("bidder1.prod:9000").unwrap(),
    )
    .remote_role(
        RoleName::from_static("Bidder2"),
        TopologyEndpoint::new("bidder2.prod:9000").unwrap(),
    )
    .separated(
        RoleName::from_static("Auctioneer"),
        RoleName::from_static("Bidder1"),
    )
    .separated(
        RoleName::from_static("Auctioneer"),
        RoleName::from_static("Bidder2"),
    )
    .build();

// Same protocol, different deployments
let dev_handler = Auction::with_topology(dev_topo, Role::Auctioneer)?;
let prod_handler = Auction::with_topology(prod_topo, Role::Auctioneer)?;
}

This example shows the same auction protocol deployed in development and production environments.