moonsd service discovery
§1Install

Two lines, no server

The contract is one module. The registry in this process is part of it, so the first example runs anywhere, including a test:

moon add moonbitstack/moonsd

Then add the client for the registry you actually run. Each is a full client for that server, with an sd package that implements the contract:

RegistryModulePackage to import
in this processmoonbitstack/moonsdmoonsd/memory
Redismoonbitstack/moonredismoonredis/sd
etcdmoonbitstack/moonetcdmoonetcd/sd
Consulmoonbitstack/moonconsulmoonconsul/sd

Every example below is written against the traits, so the same code runs on all four. Methods are asynchronous: the contract is called from tasks.

§2The first registration

An endpoint, a service, a lease

An Endpoint is an address, a weight and whatever metadata the caller wants carried through. Its id is the address unless you give one. Registering it returns a Lease: the handle you renew or give back.

async fn announce(registry : &@moonsd.Registrar) -> Unit {
  let endpoint = @moonsd.Endpoint::new("10.0.0.11", 12000, weight=2, metadata=Map([("zone", "a")]))
  let lease = registry.register("greeter", endpoint, 15_000)
  println(lease.service + " " + lease.endpoint.address() + " for " + lease.ttl.to_string() + " ms")
}

The same call on the in-process registry, and the list it can be read back from:

async fn in_memory() -> Unit {
  let registry = @memory.Registry::new()
  let lease = registry.register("greeter", @moonsd.Endpoint::new("10.0.0.11", 12000), 15_000)
  let found = registry.resolve("greeter")
  println(found.length().to_string() + " endpoints, revision " + registry.revision().to_string())
  registry.deregister(lease)
  println(registry.resolve("greeter").length().to_string() + " endpoints")
}

Three states matter and they are all ordinary: an unknown service resolves to an empty list, a lease the registry has let go is refused by renew, and removing a lease twice is harmless.

§3Keep it alive

hold renews, so you do not

A registration is only good for its lease. hold keeps it current: it registers, renews every renew_after(ttl) — a third of the lease — and deregisters when its task is cancelled. It also rides out a registry that has gone away: the error goes to report, and the endpoint registers again when the registry is back.

async fn serve(registry : &@moonsd.Registrar) -> Unit {
  let endpoint = @moonsd.Endpoint::new("10.0.0.11", 12000)
  @async.with_task_group(group => {
    group.spawn_bg(() =>
      @moonsd.hold(registry, "greeter", endpoint, ttl=15_000, report=e => println(e.to_string()))
    )
    // serve here; leaving the group cancels hold, which deregisters
  })
}

The first register is different from every later one: if it fails, nothing has been announced and the caller hears it. After that a failure means the registry is unreachable, which is no reason to stop serving.

§4On the request path

View reads without waiting

Choosing an endpoint must not cost a round trip. One task follows the service and writes what it hears into a View; every request reads the latest list from memory. When a watcher fails the view keeps what it last heard — a stale list beats none — and watches again after retry milliseconds.

async fn pick(discovery : &@moonsd.Discovery) -> Unit {
  let view = @moonsd.View::new()
  @async.with_task_group(group => {
    group.spawn_bg(() => view.follow(discovery, "greeter"))
    @async.sleep(50)
    let addresses = view.endpoints().map(e => e.address())
    println(addresses.join(", ") + " at revision " + view.revision().to_string())
  })
}

The revision is worth keeping: it grows with every change a registry can number, so a caller can tell two lists apart without comparing them.

§5The watcher itself

Whole snapshots, never events

A Watcher hands over the service as it stands, then waits. Every later next is another whole snapshot; there are no deltas to apply and no reconnect to handle at the call site. That is what lets a caller treat three very different registries the same way.

async fn follow_once(discovery : &@moonsd.Discovery) -> Unit {
  let watcher = discovery.watch("greeter")
  defer watcher.close()
  for _ in 0..<2 {
    let update = watcher.next()
    println("revision " + update.revision.to_string() + ": " + update.endpoints.length().to_string())
  }
}

Read View::follow in the source if you want the whole pattern: watch, write, retry. It is twenty lines and it is the same twenty for every backend.

§6Write a backend

Six methods and a watcher

Implement Registrar — register, renew, deregister — and Discovery — resolve, watch — plus a Watcher with next and close. That is the whole surface. hold, View and every framework built on the traits work with it unchanged.

The smallest honest backend is a map with a watcher that looks at it on an interval. This one is complete enough to pass the suite below:

struct Slots {
  kept : Map[String, Array[@moonsd.Endpoint]]
}

impl @moonsd.Registrar for Slots with fn register(self, service, endpoint, ttl) {
  let list = self.kept.get(service).unwrap_or([]).filter(e => e.id != endpoint.id)
  list.push(endpoint)
  self.kept[service] = list
  { service, endpoint, ttl, token: endpoint.id }
}

impl @moonsd.Registrar for Slots with fn renew(self, lease) {
  match self.kept.get(lease.service) {
    Some(list) if list.any(e => e.id == lease.endpoint.id) => ()
    _ => raise @moonsd.Refused::Expired(lease)
  }
}

impl @moonsd.Registrar for Slots with fn deregister(self, lease) {
  match self.kept.get(lease.service) {
    Some(list) => self.kept[lease.service] = list.filter(e => e.id != lease.endpoint.id)
    None => ()
  }
}

impl @moonsd.Discovery for Slots with fn resolve(self, service) {
  self.kept.get(service).unwrap_or([])
}

struct SlotWatcher {
  backend : Slots
  service : String
  mut revision : Int64
}

impl @moonsd.Watcher for SlotWatcher with fn next(self) {
  // polling, the way a registry with no change feed has to
  @async.sleep(500)
  self.revision = self.revision + 1L
  { endpoints: @moonsd.Discovery::resolve(self.backend, self.service), revision: self.revision }
}

impl @moonsd.Watcher for SlotWatcher with fn close(_self) {}

impl @moonsd.Discovery for Slots with fn watch(self, service) {
  ({ backend: self, service, revision: 0L } : SlotWatcher)
}
Do not lose the endpoint on the way through. The metadata map, the weight and the id travel with the endpoint, and a caller that load-balances reads them. A backend that keeps only host and port passes a smoke test and fails the suite.
§7Prove it

The same suite, every backend

conformance.check runs the behaviours the contract promises against one registrar and one discovery: the round trip, the lease that ends, the watcher that hears an arrival, a departure and an empty service, and the hold/View pair on top of them. It waits up to settle milliseconds for a change to show, so an eventually consistent registry passes.

async test "the slots backend keeps the contract" {
  let backend : Slots = { kept: Map([]) }
  @conformance.check(backend, backend, settle=1_500, expiry=false)
}

Pass expiry=false for a registry whose leases never end by themselves, as this one does not. Everything the suite registers it also removes, so it can run against a development server.

§8Run it on a real registry

Three constructors, one contract

Each client builds its own connection and hands you a registry. What differs between them is only how the registry keeps time and how a change reaches a watcher.

PackageConstructorWhat a lease is
moonredis/sdRegistry::new(client, prefix~, poll~)a key with a ttl, so expiring is the whole lifetime
moonetcd/sdRegistry::new(client, prefix~)a key attached to a lease, so the key goes with it
moonconsul/sdRegistry::new(client, wait~, reap_after~)a service with a ttl check, passed in time

The three backends have their own pages — Redis, etcd, Consul — and the home page compares how each one keeps time.

§9When a lease ends

One error worth handling

renew raises Refused::Expired when the registry no longer holds the lease: the process was paused, the network was gone for longer than the ttl, or someone else took the id. hold answers it by registering again. A caller renewing by hand should do the same.

async fn keep(registry : &@moonsd.Registrar, service : String, endpoint : @moonsd.Endpoint) -> Unit {
  let mut lease = registry.register(service, endpoint, 10_000)
  @async.sleep(100)
  registry.renew(lease) catch {
    @moonsd.Refused::Expired(_) => lease = registry.register(service, endpoint, 10_000)
    e => println("the registry said no: " + e.to_string())
  } noraise {
    _ => ()
  }
  registry.deregister(lease)
}

The other variant, Malformed, is text that is not an endpoint or a snapshot — Endpoint::parse and Registry::restore are where you meet it.

§10What is not here

Choosing an endpoint is somebody else's job

moonsd answers who is here. It does not choose among them, retry a call, or open a connection. Picking an endpoint — round robin, least outstanding, a hash of the request — belongs to the pool that makes the call, and keeping the list fresh is what View is for. A registry that also load-balanced would have to be replaced whenever the balancing changed.

Nor does it speak any registry's protocol. That is the point of the three clients: a backend is a library, not a branch inside this one.