Diffo Example — NBN Domain

Copy Markdown View Source
Mix.install(
  [
    {:diffo_example, "~> 0.3.0"},
    {:diffo, "~> 0.7.0"},
    {:kino, "~> 0.14"}
  ],
  config: [
    bolty: [
      {Bolt,
       [
         uri: "bolt://localhost:7687",
         auth: [username: "neo4j", password: "password"],
         user_agent: "diffoExampleNbnLivebook/1",
         pool_size: 15,
         max_overflow: 3,
         prefix: :default,
         name: Bolt,
         log: false,
         log_hex: false
       ]}
    ]
  ],
  consolidate_protocols: false
)

Overview

NBN is a fictional, simplified take on Australia's wholesale broadband network. Where Access is a single telco with its own DSL service, NBN is wholesale — one physical network shared by many Retail Service Providers (RSPs).

This notebook walks the provisioning flow when an RSP sells an NBN Ethernet access to a subscriber. Two things make NBN richer than Access:

  • Multi-tenancy — every resource is owned by an RSP and policy-scoped to its owner.
  • Longer delivery chainNbnEthernet (PRI) owns an Avc and a Uni. The Avc consumes a :cvlan from a Cvc. The Cvc consumes an :svlan from an NniGroup. The Uni consumes a :port from an Ntd. Every step is a chance to bring upstream context up.

See nbn.md for the narrative version including the named-vs-metrics characteristic pattern, the alias convention, and what each consumer inherits.

Setting up

Connect to Neo4j (running locally on the default port). It is helpful to keep the Neo4j browser open at http://localhost:7474/browser/ as you go through the cells.

AshNeo4j.BoltyHelper.is_connected()

Optional — clear the database so the scenario builds from a clean slate:

AshNeo4j.Neo4jHelper.delete_all()
alias Diffo.Provider.Assignment
alias Diffo.Provider.Instance.Relationship
alias DiffoExample.Nbn
alias DiffoExample.Nbn.{CvcMetrics, NniGroupMetrics}

The Retail Service Providers

Seed the RSPs and pick one to operate as. Every resource we build will be owned by that RSP and isolated from resources owned by others.

DiffoExample.Nbn.Initializer.init()
rsps = Nbn.list_rsps!()
Kino.DataTable.new(rsps, keys: [:id, :short_name, :name, :state])
rsp_input =
  Kino.Input.select(
    "Operate as RSP",
    Enum.map(rsps, fn rsp -> {rsp.name, Atom.to_string(rsp.short_name)} end)
  )
actor = Enum.find(rsps, fn rsp -> Atom.to_string(rsp.short_name) == Kino.Input.read(rsp_input) end)
actor

The interconnect geography — POIs and CSAs

Before any service exists, NBN's fixed-line footprint is a set of Points of Interconnect (POI) — the handover sites where an RSP's traffic meets the network — each paired 1:1 with the Connectivity Serving Area (CSA) it interconnects. Initializer.init() above already seeded them.

pois = Nbn.list_pois!()
Kino.DataTable.new(pois, keys: [:id, :name, :type])

A POI carries a location (a point); its CSA carries bounds (a polygon). Take Stirling (5STI) and the area it serves:

poi = Nbn.get_poi_by_id!("5STI")
csa = Nbn.get_csa_by_id!("CSA-5STI")
{poi.name, poi.location.coordinates, length(hd(csa.bounds.coordinates))}

The POI → CSA pairing is a PlaceRef (role :interconnects), reachable from the POI side:

Diffo.Provider.list_place_refs_from({:place, "5STI"})

To see the geography — labels, points and polygons — drop to Neo4j Browser (observation only; note the Ash id persists as the node key property):

MATCH (poi:`Poi` {key: '5STI'})-[:`RELATES`]->(:`PlaceRef`)-[:`RELATES`]->(csa:`Csa`)
RETURN poi, csa;

Or every pair at once:

MATCH (poi:`Poi`)-[:`RELATES`]->(:`PlaceRef`)-[:`RELATES`]->(csa:`Csa`)
RETURN DISTINCT poi, csa LIMIT 150;

Geographic data derived from NBN Co's fixed-line coverage dataset (data.gov.au, © NBN Co, CC BY 4.0), simplified to coarse convex hulls — see DiffoExample.Nbn.Geo. Fixed-wireless and satellite coverage are excluded.

Customer locations

A customer is somewhere. Initializer.init() seeded a handful of real Adelaide-Hills venues — each a Location (street address) geo-located by a LocationPoint (lat/long). The point is what service qualification runs against; the address is for humans.

import Ash.Expr
require Ash.Query

Nbn.list_locations!()
|> Enum.map(fn loc -> %{name: loc.name, street: "#{loc.street_nr} #{loc.street_name}, #{loc.locality} #{loc.postcode}"} end)
|> Kino.DataTable.new()

Some LocationPoints stand alone — a lat/long needing comms with no postal address (NBN's non-premise model). Here, temporary connectivity for the Tour Down Under: cabinets at the Stirling Oval and Mylor Oval carparks.

Nbn.list_location_points!()
|> Enum.map(fn p -> %{id: p.id, name: p.name, coords: inspect(p.location.coordinates)} end)
|> Kino.DataTable.new()

Service qualification — which CSA, and how far to the POI

Pick a customer location. Service qualification is a spatial question answered as an Ash expression: which CSA's bounds polygon contains the point? st_contains pushes the bounding-box stage down to Neo4j, then checks exact containment.

# the Stirling Hotel's point (try Crafers Hotel / Stanley Bridge Tavern too — all qualify)
point = Nbn.get_location_point_by_id!("LOP000998597184")

csa =
  Nbn.Csa
  |> Ash.Query.filter(st_contains(bounds, ^point.location))
  |> Ash.read!()
  |> List.first()

csa && csa.name

A qualifying location is served by the CSA's POI — traverse the PlaceRef (CSA is the target, the POI the source):

poi =
  if csa do
    {:ok, csa} = Ash.load(csa, place_refs: [:source_place])
    ref = Enum.find(csa.place_refs, &(&1.role == :interconnects))
    ref && ref.source_place
  end

poi && %{poi_id: poi.id, poi_name: poi.name}

How far is the customer from that interconnect? A geodesic distance, again a pushed-down Ash expression (st_distance_in_meters, point to point):

if poi do
  [%{m: m}] =
    Nbn.LocationPoint
    |> Ash.Query.filter(id == ^point.id)
    |> Ash.Query.calculate(:m, :float, expr(st_distance_in_meters(location, ^poi.location)))
    |> Ash.read!()

  "#{point.name} is #{round(m)} m from POI #{poi.id}"
end

When SQ misses — how far outside coverage?

A location with no containing CSA isn't served by fixed line. Rather than a bare "no", we can say by how much it missed — st_distance_in_meters works point-to-polygon, giving the distance to a CSA's edge. Narrow to the few nearest POIs, then measure to their CSAs.

# German Arms, Hahndorf — real FTTP, but an island outside our coarse Stirling hull
miss = %Geo.Point{coordinates: {138.8103, -35.0283}, srid: 4326}

hit? = Nbn.Csa |> Ash.Query.filter(st_contains(bounds, ^miss)) |> Ash.read!() |> Enum.any?()

if hit? do
  "covered"
else
  # nearest POIs by point distance, then distance to each one's CSA edge
  nearest_pois =
    Nbn.Poi
    |> Ash.Query.calculate(:m, :float, expr(st_distance_in_meters(location, ^miss)))
    |> Ash.read!()
    |> Enum.sort_by(& &1.m)
    |> Enum.take(6)
    |> Enum.map(& &1.id)

  Nbn.Csa
  |> Ash.Query.filter(id in ^Enum.map(nearest_pois, &("CSA-" <> &1)))
  |> Ash.Query.calculate(:edge_m, :float, expr(st_distance_in_meters(bounds, ^miss)))
  |> Ash.read!()
  |> Enum.sort_by(& &1.edge_m)
  |> List.first()
  |> then(fn csa -> "missed #{csa.name} CSA by #{round(csa.edge_m)} m" end)
end

1. Shareable infrastructure — NNI Group + NNIs

An RSP builds (or has built for it) shareable infrastructure at each Point of Interconnect: an NniGroup containing Nnis, and a Cvc that terminates at the NNI Group. This gets done once per POI per RSP and serves many customers.

{:ok, nni_group} = Nbn.build_nni_group(%{}, actor: actor)

{:ok, nni_group} =
  Nbn.define_nni_group(
    nni_group,
    %{
      characteristic_value_updates: [
        nni_group: [group_name: "SYD-POI-01", location: "Sydney Olympic Park"],
        svlans: [first: 1, last: 4000, assignable_type: "svlan"]
      ]
    },
    actor: actor
  )

Add two Nnis (the physical interconnect ports) and relate them as :contains:

nni_ids =
  for {port_id, capacity} <- [{"SYD-01-ETH-1", 10_000}, {"SYD-01-ETH-2", 10_000}] do
    {:ok, nni} = Nbn.build_nni(%{}, actor: actor)

    {:ok, _} =
      Nbn.define_nni(
        nni,
        %{
          characteristic_value_updates: [
            nni: [port_id: port_id, capacity: capacity]
          ]
        },
        actor: actor
      )

    nni.id
  end

{:ok, nni_group} =
  Nbn.relate_nni_group(
    nni_group,
    %{
      relationships:
        Enum.map(nni_ids, fn id ->
          %Relationship{id: id, direction: :forward, type: :contains}
        end)
    },
    actor: actor
  )

2. Shareable infrastructure — CVC

A Cvc takes an :svlan from the NniGroup and names its upstream :nni_group (the consumer-alias names the related resource):

{:ok, cvc} = Nbn.build_cvc(%{}, actor: actor)

{:ok, cvc} =
  Nbn.define_cvc(
    cvc,
    %{
      characteristic_value_updates: [
        cvc: [bandwidth: 1000],
        cvlans: [first: 1, last: 4000, assignable_type: "cvlan"]
      ]
    },
    actor: actor
  )

{:ok, _nni_group} =
  Nbn.assign_svlan(
    nni_group,
    %{
      assignment: %Assignment{
        assignee_id: cvc.id,
        alias: :nni_group,
        operation: :auto_assign
      }
    },
    actor: actor
  )

3. Per-customer infrastructure — NTD + UNI

The Ntd is the device installed at the customer premises — NBN-managed, no RSP actor: needed. The Uni consumes a :port from it and names its upstream :ntd.

{:ok, ntd} = Nbn.build_ntd(%{})

{:ok, ntd} =
  Nbn.define_ntd(ntd, %{
    characteristic_value_updates: [
      ntd: [model: "Sercomm CG4000A", serial_number: "SCOMA1A057A2", technology: :FTTP],
      ports: [first: 1, last: 4, assignable_type: "port"]
    ]
  })

{:ok, uni} = Nbn.build_uni(%{})

{:ok, uni} =
  Nbn.define_uni(uni, %{
    characteristic_value_updates: [
      uni: [port: 1, encapsulation: "DSCP Mapped", technology: :FTTP]
    ]
  })

{:ok, _ntd} =
  Nbn.assign_port(ntd, %{
    assignment: %Assignment{
      assignee_id: uni.id,
      alias: :ntd,
      operation: :auto_assign
    }
  })

4. The AVC

An Avc belongs to one RSP. It consumes a :cvlan from the RSP's Cvc and names its upstream :cvc:

{:ok, avc} = Nbn.build_avc(%{}, actor: actor)

{:ok, avc} =
  Nbn.define_avc(
    avc,
    %{
      characteristic_value_updates: [avc: [bandwidth_profile: :home_fast]]
    },
    actor: actor
  )

{:ok, _cvc} =
  Nbn.assign_cvlan(
    cvc,
    %{
      assignment: %Assignment{
        assignee_id: avc.id,
        alias: :cvc,
        operation: :auto_assign
      }
    },
    actor: actor
  )

5. The NBN Ethernet access (PRI)

The PRI is the service the RSP sells. It owns the Avc and the Uni via two :owns relationships — aliased :circuit (the role the AVC plays — the access virtual circuit) and :port (the role the UNI plays — the customer's port):

{:ok, pri} = Nbn.build_nbn_ethernet(%{}, actor: actor)

{:ok, _pri} =
  Nbn.relate_nbn_ethernet(
    pri,
    %{
      relationships: [
        %Relationship{id: avc.id, direction: :forward, type: :owns, alias: :circuit},
        %Relationship{id: uni.id, direction: :forward, type: :owns, alias: :port}
      ]
    },
    actor: actor
  )

6. Inheritance — the brought-up delivery chain

Load the PRI with all four inherited characteristics. Each one resolves through the assignment and relationship graph live:

{:ok, pri} =
  Nbn.get_nbn_ethernet_by_id(
    pri.id,
    load: [:avc, :uni, :cvc, :ntd],
    actor: actor
  )

%{
  avc: pri.avc,
  uni: pri.uni,
  cvc: pri.cvc,
  ntd: pri.ntd
}

Single-hop (:avc, :uni) goes via the :circuit and :port owns relationships. Two-hop (:cvc, :ntd) walks the relationship hop then back through the :cvc and :ntd assignment aliases. All singular — the AssignmentRelationship's [target_id, alias] identity guarantees at most one upstream per hop.

The AVC and CVC can also bring up their own context:

{:ok, avc} = Nbn.get_avc_by_id(avc.id, load: [:cvc, :nni_group], actor: actor)

%{
  cvc: avc.cvc,           # single-hop via :cvc
  nni_group: avc.nni_group  # two-hop via [:cvc, :nni_group]
}

7. Metrics — the local KPIs

Metrics are local-only characteristics that don't propagate. Read the CVC's metrics and the NNI Group's metrics directly:

cvc_metrics =
  CvcMetrics
  |> Ash.Query.filter_input(instance_id: cvc.id)
  |> Ash.Query.load(:value)
  |> Ash.read_one!(actor: actor)

cvc_metrics.value
nni_group_metrics =
  NniGroupMetrics
  |> Ash.Query.filter_input(instance_id: nni_group.id)
  |> Ash.Query.load(:value)
  |> Ash.read_one!(actor: actor)

nni_group_metrics.value

utilization = cvcs_total_bandwidth / nnis_total_bandwidth — demand over capacity at the NNI Group. Expected 0–1 under normal provisioning; >1 under deliberate oversubscription.

NniGroup.nnis brings up the typed values of each contained NNI (low-cardinality, so the list is fine):

{:ok, nni_group} = Nbn.get_nni_group_by_id(nni_group.id, load: [:nnis], actor: actor)
nni_group.nnis

8. TMF JSON

The PRI serialises to TMF-shaped JSON. The metrics characteristic is inline; the :owns relationships surface in resourceRelationship; the typed characteristics are surfaced post-#169.

pri
|> Jason.encode!()
|> Jason.decode!()
|> Jason.encode!(pretty: true)
|> IO.puts()

The NNI Group's JSON shows the :contains relationships to NNIs alongside the :assignedTo relationships to CVCs, the svlans pool, and the metrics characteristic:

nni_group
|> Jason.encode!()
|> Jason.decode!()
|> Jason.encode!(pretty: true)
|> IO.puts()

9. Lawful intercept — trace a UNI to the network edge

Given a customer's UNI (the interface at the premises), which network-edge NNIs could its traffic traverse?

One inherited_characteristic on Uni answers it by walking the whole bearer chain in reverse — UNI → PRI → AVC → CVC → NNI Group → NNIs — a single declaration mixing relationship and assignment hops and changing direction mid-walk:

inherited_characteristic :intercept_nnis,
  via: [
    {:reverse, relationship: [alias: :port]},      # UNI ← its PRI (owns :port)
    {:forward, relationship: [alias: :circuit]},   # PRI → the owned AVC (:circuit)
    {:reverse, assignment: :cvc},                  # AVC ← its CVC (cvlan)
    {:reverse, assignment: :nni_group},            # CVC ← its NNI Group (svlan)
    {:forward, relationship: :contains}            # NNI Group → the NNIs
  ],
  read: :nni

DiffoExample.Nbn.ServiceInitializer has already seeded (via Initializer.init() above) quokka's standing edge at the Stirling (5STI) POI — two NNI Groups carrying four CVCs — and NBN's on-site NTD at the library with four idle UNIs. Here's the edge:

alias DiffoExample.Nbn.ServiceInitializer, as: SI
{:ok, quokka} = Nbn.get_rsp_by_short_name(:quokka)

for {label, id} <- [{"A — 10G", SI.group_ids().group_a}, {"B — 100G", SI.group_ids().group_b}] do
  {:ok, group} = Nbn.get_nni_group_by_id(id, load: [:nnis], actor: quokka)
  %{nni_group: label, nnis: group.nnis |> Enum.map(& &1.port_id) |> Enum.join(", ")}
end
|> Kino.DataTable.new()

Now provision a service. You are quokka — pick an idle UNI and the CVC to land it on. The CVC choice is the load-balancing decision, and it decides which NNI Group (and so which NNIs) the service rides:

intercept_cvc_input =
  Kino.Input.select(
    "CVC — the RSP's load-balancing choice",
    [
      {"NNI Group A (10G) · CVC 1", Enum.at(SI.cvc_ids().group_a, 0)},
      {"NNI Group A (10G) · CVC 2", Enum.at(SI.cvc_ids().group_a, 1)},
      {"NNI Group B (100G) · CVC 1", Enum.at(SI.cvc_ids().group_b, 0)},
      {"NNI Group B (100G) · CVC 2", Enum.at(SI.cvc_ids().group_b, 1)}
    ]
  )
intercept_uni_input =
  Kino.Input.select(
    "Idle UNI to light up",
    SI.uni_ids()
    |> Enum.with_index(1)
    |> Enum.map(fn {id, port} -> {"UNI on NTD port #{port}", id} end)
  )
intercept_cvc_id = Kino.Input.read(intercept_cvc_input)
intercept_uni_id = Kino.Input.read(intercept_uni_input)

{:ok, chosen_uni} = Nbn.get_uni_by_id(intercept_uni_id)
{:ok, chosen_cvc} = Nbn.get_cvc_by_id(intercept_cvc_id, actor: quokka)

# The order: land this UNI on that CVC — builds an AVC (cvlan from the CVC) and a
# PRI owning the AVC (:circuit) and the UNI (:port).
_pri = SI.provision_service(chosen_uni, chosen_cvc, quokka)

# Now ask the UNI: which NNIs could my traffic traverse?
{:ok, chosen_uni} = Nbn.get_uni_by_id(intercept_uni_id, load: [:intercept_nnis])

chosen_uni.intercept_nnis
|> Enum.map(fn nni -> %{nni: nni.port_id, capacity_mbps: nni.capacity} end)
|> Kino.DataTable.new()

Pick a CVC on Group A and the answer is the two 10G NNIs; pick one on Group B and it's the two 100G NNIs — same NTD, same kind of UNI, but the intercept follows the path you actually provisioned. Nothing stored that answer: intercept_nnis re-walks the graph on every read, so re-wiring moves it. That liveness is the whole point of the unified via: grammar.

Each run lights a fresh idle UNI (one per NTD port) — choose a different port to compare a second path side by side.

Exploring the graph

MATCH (n)-[r]->(m) RETURN n, r, m LIMIT 100;

You'll see Specification nodes (one per resource type), Instance nodes (the things we just built), Characteristic nodes (typed and pool), and the assignment / relationship edges between them. Filter by the RSP's stamp to see one tenant's slice.

What next?

You've used multi-tenancy, the long delivery chain, the named-vs-metrics pattern, the alias convention, and the cross-resource inheritance — every pattern from the #49 design.

Open provider.md if you want to revisit the primitives now that you've seen them at scale, or access.md for the simpler single-tenant warm-up.

When you're ready to model your own domain, start with one specification, declare its characteristics, decide whether anything pools, and watch the JSON come out the other side. The structure carries you a long way.