// Broker connection: TCP transport, handshake, keepalive and
// request/response correlation for the Pulsar binary protocol.
///|
/// Protocol version advertised in the CONNECT command.
const PROTOCOL_VERSION : Int32 = 21
///|
// Shared by CONNECT and AUTH_RESPONSE; checked against moon.mod in regression.
const CLIENT_VERSION : String = "moonpulsar 0.1.3"
///|
/// Default keepalive probe interval and response/write timeout.
const KEEPALIVE_INTERVAL_MS : Int32 = 30000
///|
/// Default timeout for request/response commands.
const DEFAULT_REQUEST_TIMEOUT_MS : Int32 = 60000
///|
struct PendingRequest {
reply : @aqueue.Queue[@proto.BaseCommand]
}
///|
/// A single connection to a Pulsar broker speaking the binary protocol.
pub struct Connection {
priv reader : &@io.Reader
priv writer : &@io.Writer
priv auth : AuthData?
priv auth_provider : (() -> AuthData)?
priv async_auth_provider : (async () -> AuthData)?
priv close_transport : () -> Unit
priv mut transport_closed : Bool
priv mut next_id : UInt64
priv pending : Map[UInt64, PendingRequest]
/// consumer_id -> handler for MESSAGE / ACK_RESPONSE / CLOSE_CONSUMER
priv message_handlers : Map[UInt64, (IncomingFrame) -> Unit]
/// producer_id -> handler for SEND_RECEIPT / SEND_ERROR / CLOSE_PRODUCER
priv producer_handlers : Map[UInt64, (@proto.BaseCommand) -> Unit]
/// Coordinator handles keep pooled connections alive between requests.
priv mut coordinator_leases : Int32
/// notified once when the connection breaks
priv close_handlers : Map[UInt64, (Error?) -> Unit]
priv mut next_close_handler_id : UInt64
priv mut closed : Bool
priv mut waiting_for_ping_response : Bool
/// signalled on close to interrupt the keepalive timer
priv stop : @aqueue.Queue[Unit]
priv write_mu : @async.Mutex
priv mut remote_version : String
priv mut remote_protocol_version : Int32
priv mut max_message_size : Int32?
priv request_timeout_ms : Int32
priv memory_limiter : ClientMemoryLimiter?
}
///|
/// Authentication data attached to the CONNECT command.
pub(all) struct AuthData {
method_name : String
data : Bytes
} derive(Debug)
///|
/// Connect to `addr` ("host:port"), perform the protocol handshake and
/// start the background reader/keepalive tasks in `group`.
/// `keepalive_interval_ms` also bounds PING writes and the subsequent wait for
/// peer activity. PONG or any other complete incoming command proves liveness.
pub async fn Connection::connect(
group : @async.TaskGroup[Unit],
addr : String,
auth? : AuthData,
auth_provider? : () -> AuthData,
tls~ : Bool = false,
tls_insecure~ : Bool = false,
tls_trust? : @tls.TrustedRoot,
tls_identity? : TlsIdentity,
tls_identity_provider? : async () -> TlsIdentity,
tls_min_version? : TlsVersion,
tls_max_version? : TlsVersion,
tls_cipher_list? : String,
tls13_ciphersuites? : String,
keepalive_interval_ms~ : Int32 = KEEPALIVE_INTERVAL_MS,
timeout_ms~ : Int32 = DEFAULT_REQUEST_TIMEOUT_MS,
connection_timeout_ms~ : Int32 = 10000,
async_auth_provider? : async () -> AuthData,
) -> Connection {
Connection::connect_with_memory(
group,
addr,
auth?,
auth_provider?,
tls~,
tls_insecure~,
tls_trust?,
tls_identity?,
tls_identity_provider?,
tls_min_version?,
tls_max_version?,
tls_cipher_list?,
tls13_ciphersuites?,
keepalive_interval_ms~,
timeout_ms~,
connection_timeout_ms~,
async_auth_provider?,
)
}
///|
async fn Connection::connect_with_memory(
group : @async.TaskGroup[Unit],
addr : String,
auth? : AuthData,
auth_provider? : () -> AuthData,
tls~ : Bool = false,
/// skip certificate verification (self-signed / testing only)
tls_insecure~ : Bool = false,
tls_trust? : @tls.TrustedRoot,
tls_identity? : TlsIdentity,
tls_identity_provider? : async () -> TlsIdentity,
tls_min_version? : TlsVersion,
tls_max_version? : TlsVersion,
tls_cipher_list? : String,
tls13_ciphersuites? : String,
keepalive_interval_ms~ : Int32 = KEEPALIVE_INTERVAL_MS,
timeout_ms~ : Int32 = DEFAULT_REQUEST_TIMEOUT_MS,
connection_timeout_ms~ : Int32 = 10000,
async_auth_provider? : async () -> AuthData,
memory_limiter? : ClientMemoryLimiter,
) -> Connection {
guard timeout_ms > 0 && connection_timeout_ms > 0 && keepalive_interval_ms > 0 else {
raise InvalidFrame("connection timeouts must be positive")
}
guard tls ||
(
tls_identity is None &&
tls_identity_provider is None &&
tls_min_version is None &&
tls_max_version is None &&
tls_cipher_list is None &&
tls13_ciphersuites is None
) else {
raise InvalidFrame("TLS settings require TLS transport")
}
let auth = match async_auth_provider {
Some(provider) => Some(provider())
None =>
match auth_provider {
Some(provider) => Some(provider())
None =>
if auth is Some(_) {
auth
} else if tls_identity is Some(_) || tls_identity_provider is Some(_) {
Some({ method_name: "tls", data: b"", })
} else {
None
}
}
}
let (host, port) = split_host_port(addr)
let tcp = @async.with_timeout(connection_timeout_ms, async fn() {
@socket.Tcp::connect_to_host(host, port~)
}) catch {
_ => raise ConnectFailed(addr)
}
errdefer tcp.close()
let (reader, writer, close_transport) : (&@io.Reader, &@io.Writer, () -> Unit) = if tls {
let trust = match tls_trust {
Some(root) => root
None =>
if tls_insecure {
@tls.TrustedRoot::NoVerification
} else {
@tls.TrustedRoot::SystemRoot
}
}
if tls_identity is Some(_) ||
tls_identity_provider is Some(_) ||
tls_min_version is Some(_) ||
tls_max_version is Some(_) ||
tls_cipher_list is Some(_) ||
tls13_ciphersuites is Some(_) {
let identity = match tls_identity_provider {
Some(provider) => Some(provider())
None => tls_identity
}
let ca_file = match trust {
@tls.TrustedRoot::CustomPemFile(path) => path
_ => ""
}
let native = @async.with_timeout(connection_timeout_ms, async fn() {
@native_tls.NativeTls::connect(
tcp,
host,
ca_file~,
insecure=trust is @tls.TrustedRoot::NoVerification,
cert_file=identity.map(fn(id) { id.certificate_file }).unwrap_or(""),
key_file=identity.map(fn(id) { id.private_key_file }).unwrap_or(""),
min_version=tls_min_version
.map(fn(v) { v.native_value() })
.unwrap_or(0),
max_version=tls_max_version
.map(fn(v) { v.native_value() })
.unwrap_or(0),
cipher_list=tls_cipher_list.unwrap_or(""),
tls13_ciphersuites=tls13_ciphersuites.unwrap_or(""),
)
}) catch {
e => raise ConnectFailed("TLS handshake with \{addr} failed: \{e}")
}
(native, native, fn() { native.close() })
} else {
let tls_conn = @async.with_timeout(connection_timeout_ms, async fn() {
@tls.Tls::client(tcp, host~, trust~)
}) catch {
e => raise ConnectFailed("TLS handshake with \{addr} failed: \{e}")
}
(
tls_conn,
tls_conn,
fn() {
// Tls::close releases SSL resources but leaves its TCP open.
tls_conn.close()
tcp.close()
},
)
}
} else {
(tcp, tcp, fn() { tcp.close() })
}
let conn = Connection::{
reader,
writer,
auth,
auth_provider,
async_auth_provider,
close_transport,
transport_closed: false,
next_id: 0,
pending: {},
message_handlers: {},
producer_handlers: {},
coordinator_leases: 0,
close_handlers: {},
next_close_handler_id: 0UL,
closed: false,
waiting_for_ping_response: false,
stop: @aqueue.Queue::Queue(kind=@aqueue.Kind::Blocking(1)),
write_mu: @async.Mutex::Mutex(),
remote_version: "",
remote_protocol_version: 0,
max_message_size: None,
request_timeout_ms: timeout_ms,
memory_limiter,
}
errdefer conn.close()
// handshake before any background traffic
let connect = @proto.CommandConnect::default()
connect.client_version = CLIENT_VERSION
connect.protocol_version = Some(PROTOCOL_VERSION)
if auth is Some(a) {
connect.auth_method_name = Some(a.method_name)
connect.auth_data = Some(a.data)
}
let cmd = @proto.BaseCommand::default()
cmd.type_ = @proto.BaseCommand_Type::CONNECT
cmd.connect = Some(connect)
conn.send_bytes(encode_command_frame(cmd))
let mut connected = false
for _ in 0..<8 {
let (reply, lease) = @async.with_timeout(timeout_ms, async fn() {
read_frame_with_budget(
conn.reader,
conn.memory_limiter,
diagnostic_phase="connect",
)
}) catch {
@async.TimeoutError => raise ConnectFailed("handshake timeout: \{addr}")
e => raise e
}
if lease is Some(reservation) {
reservation.release()
}
match reply.cmd.type_ {
@proto.BaseCommand_Type::CONNECTED => {
guard reply.cmd.connected is Some(info) else {
raise ConnectFailed("CONNECTED without broker information")
}
conn.remote_version = info.server_version
conn.remote_protocol_version = info.protocol_version.unwrap_or(0)
conn.max_message_size = info.max_message_size
connected = true
break
}
@proto.BaseCommand_Type::AUTH_CHALLENGE => conn.respond_to_challenge()
@proto.BaseCommand_Type::ERROR =>
if reply.cmd.error is Some(err) {
raise ConnectFailed("broker rejected CONNECT: " + err.message)
} else {
raise ConnectFailed("broker rejected CONNECT")
}
_ => raise ConnectFailed("unexpected handshake response from \{addr}")
}
}
if !connected {
raise ConnectFailed("too many authentication challenges from \{addr}")
}
group.spawn_bg(
async fn() { conn.reader_loop() },
no_wait=true,
allow_failure=true,
)
group.spawn_bg(
async fn() { conn.keepalive_loop(keepalive_interval_ms) },
no_wait=true,
allow_failure=true,
)
conn
}
///|
async fn Connection::respond_to_challenge(self : Connection) -> Unit {
let current = match self.async_auth_provider {
Some(provider) => Some(provider())
None =>
match self.auth_provider {
Some(provider) => Some(provider())
None => self.auth
}
}
guard current is Some(auth) else {
raise ConnectFailed("broker requested authentication without credentials")
}
let response = @proto.CommandAuthResponse::default()
response.client_version = Some(CLIENT_VERSION)
response.protocol_version = Some(PROTOCOL_VERSION)
let data = @proto.AuthData::default()
data.auth_method_name = Some(auth.method_name)
data.auth_data = Some(auth.data)
response.response = Some(data)
let cmd = @proto.BaseCommand::default()
cmd.type_ = @proto.BaseCommand_Type::AUTH_RESPONSE
cmd.auth_response = Some(response)
self.send_command(cmd)
}
///|
async fn Connection::send_bytes(self : Connection, bytes : Bytes) -> Unit {
if self.closed {
raise ConnectionClosed
}
self.write_mu.acquire()
defer self.write_mu.release()
if self.closed {
raise ConnectionClosed
}
// Once writing begins, cancellation can leave a partial frame (including TLS
// buffered output). Retire the transport before releasing the writer lock.
// Cancellation while acquiring the lock never reaches this cleanup.
errdefer {
self.notify_closed(Some(ConnectionClosed))
self.close_transport_once()
}
self.writer.write(bytes) catch {
e => {
self.notify_closed(Some(e))
raise ConnectionClosed
}
}
}
///|
/// Send a fire-and-forget command frame (ACK, FLOW, PONG, ...).
pub async fn Connection::send_command(
self : Connection,
cmd : @proto.BaseCommand,
) -> Unit {
self.send_bytes(encode_command_frame(cmd))
}
///|
/// Send a frame with attached message metadata and payload (SEND).
pub async fn Connection::send_payload_frame(
self : Connection,
cmd : @proto.BaseCommand,
metadata : @proto.MessageMetadata,
payload : Bytes,
) -> Unit {
self.send_bytes(encode_send_frame(cmd, metadata, payload))
}
///|
/// Allocate a fresh request id for this connection.
pub fn Connection::next_request_id(self : Connection) -> UInt64 {
let id = self.next_id
self.next_id += 1
id
}
///|
fn request_id_of(cmd : @proto.BaseCommand) -> UInt64? {
if cmd.lookup_topic is Some(c) {
return Some(c.request_id)
}
if cmd.lookup_topic_response is Some(c) {
return Some(c.request_id)
}
if cmd.partition_metadata is Some(c) {
return Some(c.request_id)
}
if cmd.partition_metadata_response is Some(c) {
return Some(c.request_id)
}
if cmd.producer is Some(c) {
return Some(c.request_id)
}
if cmd.producer_success is Some(c) {
return Some(c.request_id)
}
if cmd.subscribe is Some(c) {
return Some(c.request_id)
}
if cmd.unsubscribe is Some(c) {
return Some(c.request_id)
}
if cmd.close_producer is Some(c) {
return Some(c.request_id)
}
if cmd.close_consumer is Some(c) {
return Some(c.request_id)
}
if cmd.success is Some(c) {
return Some(c.request_id)
}
if cmd.error is Some(c) {
return Some(c.request_id)
}
if cmd.get_last_message_id is Some(c) {
return Some(c.request_id)
}
if cmd.get_last_message_id_response is Some(c) {
return Some(c.request_id)
}
if cmd.get_schema is Some(c) {
return Some(c.request_id)
}
if cmd.get_schema_response is Some(c) {
return Some(c.request_id)
}
if cmd.seek is Some(c) {
return Some(c.request_id)
}
if cmd.ack is Some(c) {
return c.request_id
}
if cmd.ack_response is Some(c) {
return c.request_id
}
if cmd.get_topics_of_namespace is Some(c) {
return Some(c.request_id)
}
if cmd.get_topics_of_namespace_response is Some(c) {
return Some(c.request_id)
}
if cmd.tc_client_connect_request is Some(c) {
return Some(c.request_id)
}
if cmd.tc_client_connect_response is Some(c) {
return Some(c.request_id)
}
if cmd.new_txn is Some(c) {
return Some(c.request_id)
}
if cmd.add_partition_to_txn is Some(c) {
return Some(c.request_id)
}
if cmd.add_subscription_to_txn is Some(c) {
return Some(c.request_id)
}
if cmd.end_txn is Some(c) {
return Some(c.request_id)
}
if cmd.new_txn_response is Some(c) {
return Some(c.request_id)
}
if cmd.add_partition_to_txn_response is Some(c) {
return Some(c.request_id)
}
if cmd.add_subscription_to_txn_response is Some(c) {
return Some(c.request_id)
}
if cmd.end_txn_response is Some(c) {
return Some(c.request_id)
}
None
}
///|
/// Send a request command and wait for the correlated response.
/// `CommandError` responses are raised as `ServerError`.
pub async fn Connection::request(
self : Connection,
cmd : @proto.BaseCommand,
timeout_ms? : Int32,
) -> @proto.BaseCommand {
let timeout_ms = timeout_ms.unwrap_or(self.request_timeout_ms)
guard request_id_of(cmd) is Some(id) else {
raise UnexpectedResponse("command carries no request id")
}
let pending : PendingRequest = {
reply: @aqueue.Queue::Queue(kind=@aqueue.Kind::Unbounded),
}
// register before sending: send_command awaits internally, and the reader
// task may dispatch the response during those awaits
self.pending[id] = pending
defer self.pending.remove(id)
self.send_command(cmd)
let mut waiting_for_exclusive = false
while true {
let reply = if waiting_for_exclusive {
pending.reply.get()
} else {
@async.with_timeout(timeout_ms, async fn() { pending.reply.get() }) catch {
@async.TimeoutError => raise RequestTimeout
e => raise e
}
}
if reply.producer_success is Some(success) &&
success.producer_ready == Some(false) {
waiting_for_exclusive = true
continue
}
if reply.type_ is @proto.BaseCommand_Type::ERROR && reply.error is Some(err) {
raise ServerError(
code=err.error.to_enum().0.reinterpret_as_int(),
message=err.message,
)
}
return reply
}
raise UnexpectedResponse("unreachable request state")
}
///|
fn Connection::notify_closed(self : Connection, error : Error?) -> Unit {
if self.closed {
return
}
self.closed = true
self.waiting_for_ping_response = false
ignore(self.stop.try_put(()) catch { _ => false })
for id, pending in self.pending {
pending.reply.close(error?, clear=true)
ignore(id)
}
self.pending.clear()
let ids = self.close_handlers.keys().collect()
ids.sort()
for id in ids {
if self.close_handlers.get(id) is Some(handler) {
self.close_handlers.remove(id)
handler(error)
}
}
}
///|
// Internal owners keep the ID to release captured handles when they retire.
// A closed connection never retains a newly registered callback.
fn Connection::add_close_handler(
self : Connection,
handler : (Error?) -> Unit,
) -> UInt64? {
if self.closed {
return None
}
let id = self.next_close_handler_id
self.next_close_handler_id += 1UL
self.close_handlers[id] = handler
Some(id)
}
///|
/// Register a handler invoked once when the connection breaks.
pub fn Connection::on_close(
self : Connection,
handler : (Error?) -> Unit,
) -> Unit {
ignore(self.add_close_handler(handler))
}
///|
/// Close the connection and fail all pending requests.
pub fn Connection::close(self : Connection) -> Unit {
self.notify_closed(None)
self.close_transport_once()
}
///|
fn Connection::close_transport_once(self : Connection) -> Unit {
if !self.transport_closed {
self.transport_closed = true
(self.close_transport)()
}
}
///|
/// Server version reported by the broker during the handshake.
pub fn Connection::server_version(self : Connection) -> String {
self.remote_version
}
///|
/// Protocol version reported by the broker during the handshake.
pub fn Connection::remote_protocol_version(self : Connection) -> Int32 {
self.remote_protocol_version
}
///|
/// Maximum frame payload announced by the broker, when available.
pub fn Connection::max_message_size(self : Connection) -> Int32? {
self.max_message_size
}
///|
/// Whether the connection is still usable.
pub fn Connection::is_closed(self : Connection) -> Bool {
self.closed
}
///|
async fn Connection::reader_loop(self : Connection) -> Unit {
while true {
if self.closed {
return
}
let (frame, lease) = read_frame_with_budget(
self.reader,
self.memory_limiter,
diagnostic_phase="reader",
) catch {
e => {
connection_diagnostic("reader-close", Some(e))
self.notify_closed(Some(e))
self.close_transport_once()
return
}
}
errdefer (if lease is Some(reservation) { reservation.release() })
let dispatched : Result[Unit, Error] = Ok(self.dispatch(frame)) catch {
e => Err(e)
}
if lease is Some(reservation) {
reservation.release()
}
if dispatched is Err(error) {
connection_diagnostic("dispatch-close", Some(error))
self.notify_closed(Some(error))
self.close_transport_once()
return
}
}
}
///|
async fn Connection::keepalive_loop(
self : Connection,
interval_ms : Int32,
) -> Unit {
// The stop task interrupts both the timer and a queued/blocked PING write.
// Structured cancellation waits for the losing task to release its write lock.
errdefer {
self.notify_closed(Some(ConnectionClosed))
self.close_transport_once()
}
@async.any([
() => self.stop.get(),
() => {
while !self.closed {
@async.sleep(interval_ms)
if self.closed {
return
}
if self.waiting_for_ping_response {
raise ConnectionClosed
}
if self.remote_protocol_version >= 1 {
let cmd = @proto.BaseCommand::default()
cmd.type_ = @proto.BaseCommand_Type::PING
cmd.ping = Some(@proto.CommandPing::default())
// Register before writing: the reader may run during write awaits.
self.waiting_for_ping_response = true
@async.with_timeout(interval_ms, () => self.send_command(cmd))
}
}
},
]) catch {
_ => {
self.notify_closed(Some(ConnectionClosed))
self.close_transport_once()
}
}
}
///|
/// Split "host:port" into (host, port); a missing port defaults to 6650.
pub fn split_host_port(addr : String) -> (String, Int32) raise PulsarError {
match addr.split(":").collect() {
[host, port] =>
(
host.to_owned(),
@string.parse_int(port) catch {
_ => raise InvalidFrame("invalid port in address: \{addr}")
},
)
[host] => (host.to_owned(), 6650)
_ => raise InvalidFrame("invalid address: \{addr}")
}
}
///|
async fn Connection::dispatch(self : Connection, frame : IncomingFrame) -> Unit {
// Match PulsarHandler: every decoded command (including PONG) is evidence
// that the peer is alive; outbound traffic alone never resets this state.
self.waiting_for_ping_response = false
let cmd = frame.cmd
match cmd.type_ {
@proto.BaseCommand_Type::AUTH_CHALLENGE =>
self.respond_to_challenge() catch {
e => {
self.notify_closed(Some(e))
self.close_transport_once()
}
}
@proto.BaseCommand_Type::PING => {
let pong = @proto.BaseCommand::default()
pong.type_ = @proto.BaseCommand_Type::PONG
pong.pong = Some(@proto.CommandPong::default())
self.send_command(pong) catch {
_ => ()
}
}
@proto.BaseCommand_Type::MESSAGE =>
if cmd.message is Some(message) &&
self.message_handlers.get(message.consumer_id) is Some(handler) {
handler(frame)
}
@proto.BaseCommand_Type::ACK_RESPONSE =>
if cmd.ack_response is Some(resp) {
if resp.request_id is Some(id) && self.pending.get(id) is Some(pending) {
ignore(pending.reply.try_put(cmd))
} else if self.message_handlers.get(resp.consumer_id) is Some(handler) {
handler(frame)
}
}
@proto.BaseCommand_Type::CLOSE_CONSUMER =>
if cmd.close_consumer is Some(c) &&
self.message_handlers.get(c.consumer_id) is Some(handler) {
handler(frame)
}
@proto.BaseCommand_Type::ACTIVE_CONSUMER_CHANGE =>
if cmd.active_consumer_change is Some(c) &&
self.message_handlers.get(c.consumer_id) is Some(handler) {
handler(frame)
}
@proto.BaseCommand_Type::REACHED_END_OF_TOPIC =>
if cmd.reached_end_of_topic is Some(c) &&
self.message_handlers.get(c.consumer_id) is Some(handler) {
handler(frame)
}
@proto.BaseCommand_Type::SEND_RECEIPT =>
if cmd.send_receipt is Some(receipt) &&
self.producer_handlers.get(receipt.producer_id) is Some(handler) {
handler(cmd)
}
@proto.BaseCommand_Type::SEND_ERROR =>
if cmd.send_error is Some(err) &&
self.producer_handlers.get(err.producer_id) is Some(handler) {
handler(cmd)
}
@proto.BaseCommand_Type::CLOSE_PRODUCER =>
if cmd.close_producer is Some(c) &&
self.producer_handlers.get(c.producer_id) is Some(handler) {
handler(cmd)
}
_ =>
if request_id_of(cmd) is Some(id) && self.pending.get(id) is Some(pending) {
if !(cmd.producer_success is Some(success) &&
success.producer_ready == Some(false)) {
self.pending.remove(id)
}
ignore(pending.reply.try_put(cmd))
}
}
}