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:
| Registry | Module | Package to import |
|---|---|---|
| in this process | moonbitstack/moonsd | moonsd/memory |
| Redis | moonbitstack/moonredis | moonredis/sd |
| etcd | moonbitstack/moonetcd | moonetcd/sd |
| Consul | moonbitstack/moonconsul | moonconsul/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.
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.
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.
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.
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.
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)
}
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.
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.
| Package | Constructor | What a lease is |
|---|---|---|
moonredis/sd | Registry::new(client, prefix~, poll~) | a key with a ttl, so expiring is the whole lifetime |
moonetcd/sd | Registry::new(client, prefix~) | a key attached to a lease, so the key goes with it |
moonconsul/sd | Registry::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.
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.
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.