// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// The weight an endpoint carries unless it says otherwise.
pub let weight : Int32 = 1
///|
/// How long a registration lasts without being renewed, in milliseconds: go-zero's `discov`
/// default of ten seconds.
pub let ttl : Int32 = 10000
///|
/// 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.
pub(all) suberror Refused {
Malformed(String)
Expired(Lease)
} derive(Eq, Debug)
///|
pub impl Show for Refused with fn output(self, logger) {
match self {
Malformed(why) => logger.write_string("malformed: " + why)
Expired(lease) =>
logger.write_string(
"lease expired: " + lease.service + "/" + lease.endpoint.id,
)
}
}
///|
pub extend Refused with Show::{to_string, output}
///|
pub extend Refused with Eq::{equal, not_equal}
///|
pub extend Refused with Debug::{to_repr}
///|
/// 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.
pub(all) struct Endpoint {
id : String
host : String
port : Int32
weight : Int32
metadata : Map[String, String]
} derive(Eq, Debug, ToJson, FromJson)
///|
pub extend Endpoint with Eq::{equal, not_equal}
///|
pub extend Endpoint with Debug::{to_repr}
///|
pub extend Endpoint with ToJson::{to_json}
///|
pub extend Endpoint with @json.FromJson::{from_json}
///|
pub fn Endpoint::new(
host : String,
port : Int32,
id? : String,
weight~ : Int32 = weight,
metadata? : Map[String, String],
) -> Endpoint {
let address = address_of(host, port)
{
id: id.unwrap_or(address),
host,
port,
weight,
metadata: metadata.unwrap_or(Map([])),
}
}
///|
fn address_of(host : String, port : Int32) -> String {
if host.contains(":") {
"[\{host}]:\{port}"
} else {
"\{host}:\{port}"
}
}
///|
/// `host:port`, with an IPv6 host in brackets, as a socket address is written.
pub fn Endpoint::address(self : Endpoint) -> String {
address_of(self.host, self.port)
}
///|
/// An endpoint from `host:port` or `[v6]:port`.
pub fn Endpoint::parse(text : StringView) -> Endpoint raise Refused {
guard text.rev_find(":") is Some(at) else {
raise Malformed("no port in " + text.to_owned())
}
let host = text[:at].to_owned()
let bare = if host.has_prefix("[") && host.has_suffix("]") {
host[1:host.length() - 1].to_owned()
} else {
host
}
let port = @string.parse_int(text[at + 1:]) catch {
_ => raise Malformed("no port in " + text.to_owned())
}
if bare == "" || port < 0 || port > 65535 {
raise Malformed("not an address: " + text.to_owned())
}
Endpoint::new(bare, port)
}
///|
/// 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.
pub(all) struct Lease {
service : String
endpoint : Endpoint
ttl : Int32
token : String
} derive(Eq, Debug)
///|
pub extend Lease with Eq::{equal, not_equal}
///|
pub extend Lease with Debug::{to_repr}
///|
/// 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.
pub(all) struct Update {
endpoints : Array[Endpoint]
revision : Int64
} derive(Eq, Debug, ToJson, FromJson)
///|
pub extend Update with Eq::{equal, not_equal}
///|
pub extend Update with Debug::{to_repr}
///|
pub extend Update with ToJson::{to_json}
///|
pub extend Update with @json.FromJson::{from_json}
///|
/// 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.
pub(open) trait Registrar {
async fn register(Self, String, Endpoint, Int32) -> Lease
async fn renew(Self, Lease) -> Unit
async fn deregister(Self, Lease) -> Unit
}
///|
/// 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.
pub(open) trait Discovery {
async fn resolve(Self, String) -> Array[Endpoint]
async fn watch(Self, String) -> &Watcher
}
///|
/// 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.
pub(open) trait Watcher {
async fn next(Self) -> Update
fn close(Self) -> Unit
}