moonsd

    Service discovery for MoonBit: the contract backends implement, with one in-memory registry and a conformance suite.

    discovery
    registry
    moonbit
    Download zip
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    4 hours ago
    Downloads
    4

    #moonsd

    The contract of service discovery for MoonBit: what a registry does, and nothing about any one registry. A backend implements three small traits and gets the rest; a framework depends on the traits and works with every backend.

    // A service keeps itself registered 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 without waiting.
    let view = @moonsd.View::new()
    group.spawn_bg(() => view.follow(registry, "greeter"))
    view.endpoints()

    Run moon run src/examples/tour --target native for the whole surface in one go.

    #The contract

    TraitMethodsWhat it promises
    Registrarregister, renew, deregisterAn endpoint is known for ttl milliseconds from its last renewal. renew raises Expired once the backend 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 at a third of ttl, and rides out a backend that goes away: the failure goes to report, and the endpoint registers again when the backend 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, 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

    #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.

    #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, ToJson,
    Debug
    ,
    FromJson
    )

    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

    fn Endpoint::to_json(Endpoint) -> 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

    pub(all) struct Update {
    endpoints : Array[Endpoint]
    revision : Int64
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    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

    fn Update::to_json(Update) -> 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 at a third of ttl, the interval etcd's own keep-alive uses. 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.

    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: go-zero's discov default of ten seconds.

    weight

    let weight : Int

    The weight an endpoint carries unless it says otherwise.

    Source Files

    Powered by MoonBit

    Site sourceReport issueModulesSkillsBuild queueStatistics

    © 2026 mooncakes.io