moonsd

    Service discovery for MoonBit: the contract a registry implements, an in-process registry, a conformance suite, and clients for Redis, etcd and Consul.

    service-discovery
    discovery
    registry
    service-registry
    register
    lease
    watcher
    endpoint
    microservices
    backend
    redis
    etcd
    consul
    conformance
    json
    snapshot
    restore
    revision
    in-memory
    async
    moonbit
    Download zip
    Version
    0.2.0
    License
    Apache-2.0
    Last updated
    8 hours ago
    Downloads
    576

    #moonsd

    The register of what is alive. A service says it is here, keeps saying it, and is found. moonsd writes that down as three traits, and leaves every registry to implement them.

    Site · Guide · API · mooncakes


    async fn greeter(registry : @memory.Registry) -> Unit {
    @async.with_task_group(group => {
    // A service announces itself for as long as this task runs, and leaves on the way out.
    group.spawn_bg(() =>
    @moonsd.hold(registry, "greeter", @moonsd.Endpoint::new("10.0.0.1", 12000))
    )

    // A caller follows the service in one task and reads the list on the request path.
    let view = @moonsd.View::new()
    group.spawn_bg(() => view.follow(registry, "greeter"))
    @async.sleep(50)
    println(view.endpoints().map(e => e.address()).join(", "))
    })
    }

    #Install

    moon add moonbitstack/moonsd

    Then the client for the registry you run: moonbitstack/moonredis, moonbitstack/moonetcd or moonbitstack/moonconsul. Every one of them implements the same three traits, so the code above runs unchanged on all of them; moonsd/memory runs it inside a test.

    #The contract

    TraitMethodsWhat it promises
    Registrarregister, renew, deregisterAn endpoint is known for ttl milliseconds from its last renewal. renew raises Refused::Expired once the registry has let it go; deregister is harmless on a lease already gone.
    Discoveryresolve, watchresolve answers once, and an unknown service has no endpoints.
    Watchernext, closeThe first next gives the service as it stands; each later one waits for a change.

    A watcher gives whole snapshots, not events. A backend that lost its connection reconnects, one whose history was compacted starts again, and neither is the caller's business: what comes out is always what is true now.

    hold and View are written once, against the traits. hold renews every renew_after(ttl) milliseconds — a third of the lease — and rides out a registry that goes away: the failure goes to report, and the endpoint registers again when the registry is back. View::follow keeps the last list when a watcher fails, since a stale list beats none, and watches again after retry milliseconds.

    #Packages

    PackageContents
    moonsdEndpoint, Lease, Update, Refused, the three traits, hold, renew_after, View
    moonsd/memoryRegistry, the contract in this process: leases when given a clock, revisions, snapshot and restore
    moonsd/conformancecheck, every behaviour the contract promises, run against any backend

    #Backends

    Each backend is a full client for its registry, published as its own module from drivers/ in this repository; its sd package is the part that implements the contract.

    ModuleDirectorysd packagePage
    moonbitstack/moonredisdrivers/redisKeys that expire, watched by reading againredis
    moonbitstack/moonetcddrivers/etcdKeys on leases, watched by revisionetcd
    moonbitstack/moonconsuldrivers/consulServices with ttl checks, watched by blocking queriesconsul

    #Writing a backend

    Implement Registrar and Discovery, and call check from your tests against a real server:

    async test "my backend keeps the contract" {
    let backend = connect()
    @conformance.check(backend, backend)
    }

    check asks only that a change shows up within settle milliseconds, so an eventually consistent backend passes. Pass expiry=false for a backend whose leases do not end on their own. It registers and removes everything it touches under one unused service name.

    #Consistency

    There is no standards body for service discovery, so the authority for this contract is the servers themselves plus one suite that every implementation runs. conformance.check is that suite: it is run against moonsd/memory (with and without a clock) and against each backend on a live server, and it fails on the first behaviour a backend breaks, naming it.

    BackendVerified againstWhat the suite covers
    moonsd/memoryitself, on every targetthe round trip, removal, Expired on a spent lease, a watcher's arrival, departure and empty service, hold with View, and a lease left to lapse
    moonredisRedis 8, standalone, cluster and sentinelthe same, with Expired coming from a key whose ttl ran out, and a watcher that sees the change on its next poll
    moonetcdetcd 3.5, one node and a three-node cluster with members killedthe same, with Expired coming from a keep-alive that reports no time left, and a watcher that resumes after a compaction
    moonconsulConsul 1.20, two agentsthe same, with Expired coming from a ttl check that failed, and a watcher that follows a blocking query's index

    Behaviour that differs between registries, and is therefore stated rather than hidden:

    • How late a change may be. Redis has no change feed a client can rely on, so a watcher reads the service again every poll milliseconds (a second by default). etcd and Consul push, so a change arrives with the registry's own latency.
    • When a lease ends. Redis and etcd end it at the ttl. Consul takes the instance out of resolve as soon as the check fails, but keeps the registration for reap_after — a minute, the agent's minimum — before removing it.
    • The ttl's unit. etcd leases last whole seconds and refuse a ttl below their minimum, so a millisecond ttl is rounded up: rounding down would end the lease early.
    • Targets. hold, View and the backends' transports need an asynchronous runtime, so they run on native and js. On wasm-gc the types, the traits and moonsd/memory without a clock still compile and test.

    #License

    Apache-2.0.

    Discovery

    pub(open) trait Discovery {
    async fn resolve(Self, String) -> Array[Endpoint]
    async fn watch(Self, String) -> &Watcher
    }

    Where a service's callers find its instances.

    resolve answers once, with no endpoints for a service nobody registered. watch follows one service: its watcher's first next gives the service as it stands, and each later one waits for a change. Implement these two and View works for the backend.

    Registrar

    pub(open) trait Registrar {
    async fn register(Self, String, Endpoint, Int) -> Lease
    async fn renew(Self, Lease) -> Unit
    async fn deregister(Self, Lease) -> Unit
    }

    Where a service's instances announce themselves.

    register makes endpoint known under service for ttl milliseconds; renew extends that, and raises Expired once the backend has let it go; deregister removes it at once, and is harmless on a lease already gone. Implement these three and hold works for the backend.

    Watcher

    pub(open) trait Watcher {
    async fn next(Self) -> Update
    fn close(Self) -> Unit
    }

    One service followed. next gives whole snapshots rather than events, so losing a connection costs an implementation a reconnect and a caller nothing: reconnecting, and recovering from a compacted history, are the implementation's business, and what comes out is always what is true now. next raises when the watcher cannot go on; close ends it.

    Refused

    pub(all) suberror Refused {
    Malformed(String)
    Expired(Lease)
    } derive(Eq,
    Debug
    )

    Why a value was refused. Malformed is text that is not an endpoint or a snapshot; Expired is a lease its backend no longer holds, which hold answers by registering again.
    impl Show for Refused

    Refused::equal

    fn Refused::equal(Refused, Refused) -> Bool

    Refused::not_equal

    fn Refused::not_equal(x : Refused, y : Refused) -> Bool

    Refused::output

    fn Refused::output(self : Refused, logger : &Logger) -> Unit

    Refused::to_repr

    Refused::to_string

    fn Refused::to_string(self : Refused) -> String

    Endpoint

    pub(all) struct Endpoint {
    id : String
    host : String
    port : Int
    weight : Int
    metadata : Map[String, String]
    } derive(Eq,
    Debug
    ,
    FromJson
    ,
    ToJson
    )

    One instance of a service. id tells it from the other instances of that service, and is the address unless given. weight and metadata are carried for whoever picks among endpoints; nothing here reads them.

    Endpoint::address

    fn Endpoint::address(self : Endpoint) -> String

    host:port, with an IPv6 host in brackets, as a socket address is written.

    Endpoint::equal

    fn Endpoint::equal(Endpoint, Endpoint) -> Bool

    Endpoint::new

    fn Endpoint::new(host : String, port : Int, id? : String, weight? : Int, metadata? : Map[String, String]) -> Endpoint

    Endpoint::not_equal

    fn Endpoint::not_equal(x : Endpoint, y : Endpoint) -> Bool

    Endpoint::parse

    fn Endpoint::parse(text : StringView) -> Endpoint raise Refused

    An endpoint from host:port or [v6]:port.

    Endpoint::to_json

    Endpoint::to_repr

    Lease

    pub(all) struct Lease {
    service : String
    endpoint : Endpoint
    ttl : Int
    token : String
    } derive(Eq,
    Debug
    )

    A registration: what Registrar::register hands back and takes again to renew or remove it. token is the backend's own handle (etcd's lease id, a Consul check), opaque to everyone else.

    Lease::equal

    fn Lease::equal(Lease, Lease) -> Bool

    Lease::not_equal

    fn Lease::not_equal(x : Lease, y : Lease) -> Bool

    Lease::to_repr

    Update

    A service as it stands: all its live endpoints, and a revision that grows with every change a backend can number. A backend that numbers nothing counts its own updates.

    Update::equal

    fn Update::equal(Update, Update) -> Bool

    Update::not_equal

    fn Update::not_equal(x : Update, y : Update) -> Bool

    Update::to_json

    Update::to_repr

    View

    pub struct View {
    // private fields
    }

    The latest word on a service, readable without waiting: one task runs follow, and the request path reads endpoints. Choosing an endpoint must not cost a round trip.

    View::endpoints

    fn View::endpoints(self : View) -> Array[Endpoint]

    The endpoints as last heard; empty until the first update.

    View::follow

    async fn View::follow(self : View, discovery : &Discovery, service : String, retry? : Int, report? : (Error) -> Unit) -> Unit

    Keep this view on service until the task is cancelled. When a watcher fails the view keeps what it last heard, since a stale list beats none, tells report, and watches again after retry milliseconds.

    View::new

    fn View::new() -> View

    View::revision

    fn View::revision(self : View) -> Int64

    The revision of the last update, 0 until the first.

    hold

    async fn hold(registrar : &Registrar, service : String, endpoint : Endpoint, ttl? : Int, report? : (Error) -> Unit) -> Unit

    Keep endpoint registered under service until the task is cancelled, then remove it.

    It renews every renew_after(ttl) milliseconds, a third of the lease. The first register failing is raised: nothing has been announced, and the caller should hear. After that a failure is a backend that went away, which is no reason to stop serving: it goes to report and the next round registers again, so the endpoint comes back with the backend.

    renew_after

    fn renew_after(ttl : Int) -> Int

    How long hold waits between two renewals of a lease of ttl milliseconds: a third, the interval a lease needs to survive two lost renewals. Never zero, so a tiny ttl does not turn the loop into a spin.

    retry

    let retry : Int

    How long View::follow waits before watching again after a watcher fails, in milliseconds.

    ttl

    let ttl : Int

    How long a registration lasts without being renewed, in milliseconds. Ten seconds is the interval a registry conventionally uses: long enough that one lost renewal is not fatal, short enough that an instance which died is noticed quickly.

    weight

    let weight : Int

    The weight an endpoint carries unless it says otherwise.

    Source Files