diff --git a/proto.lock b/proto.lock
index 068485f54f..b970e0f612 100644
--- a/proto.lock
+++ b/proto.lock
@@ -1,1506 +1,5 @@
{
"definitions": [
- {
- "protopath": "ClientAPI:/:ClientMessageDtos.proto",
- "def": {
- "enums": [
- {
- "name": "OperationResult",
- "enum_fields": [
- {
- "name": "Success"
- },
- {
- "name": "PrepareTimeout",
- "integer": 1
- },
- {
- "name": "CommitTimeout",
- "integer": 2
- },
- {
- "name": "ForwardTimeout",
- "integer": 3
- },
- {
- "name": "WrongExpectedVersion",
- "integer": 4
- },
- {
- "name": "StreamDeleted",
- "integer": 5
- },
- {
- "name": "InvalidTransaction",
- "integer": 6
- },
- {
- "name": "AccessDenied",
- "integer": 7
- }
- ]
- },
- {
- "name": "ReadEventCompleted.ReadEventResult",
- "enum_fields": [
- {
- "name": "Success"
- },
- {
- "name": "NotFound",
- "integer": 1
- },
- {
- "name": "NoStream",
- "integer": 2
- },
- {
- "name": "StreamDeleted",
- "integer": 3
- },
- {
- "name": "Error",
- "integer": 4
- },
- {
- "name": "AccessDenied",
- "integer": 5
- }
- ]
- },
- {
- "name": "ReadStreamEventsCompleted.ReadStreamResult",
- "enum_fields": [
- {
- "name": "Success"
- },
- {
- "name": "NoStream",
- "integer": 1
- },
- {
- "name": "StreamDeleted",
- "integer": 2
- },
- {
- "name": "NotModified",
- "integer": 3
- },
- {
- "name": "Error",
- "integer": 4
- },
- {
- "name": "AccessDenied",
- "integer": 5
- }
- ]
- },
- {
- "name": "ReadAllEventsCompleted.ReadAllResult",
- "enum_fields": [
- {
- "name": "Success"
- },
- {
- "name": "NotModified",
- "integer": 1
- },
- {
- "name": "Error",
- "integer": 2
- },
- {
- "name": "AccessDenied",
- "integer": 3
- }
- ]
- },
- {
- "name": "Filter.FilterContext",
- "enum_fields": [
- {
- "name": "StreamId"
- },
- {
- "name": "EventType",
- "integer": 1
- }
- ]
- },
- {
- "name": "Filter.FilterType",
- "enum_fields": [
- {
- "name": "Regex"
- },
- {
- "name": "Prefix",
- "integer": 1
- }
- ]
- },
- {
- "name": "FilteredReadAllEventsCompleted.FilteredReadAllResult",
- "enum_fields": [
- {
- "name": "Success"
- },
- {
- "name": "NotModified",
- "integer": 1
- },
- {
- "name": "Error",
- "integer": 2
- },
- {
- "name": "AccessDenied",
- "integer": 3
- }
- ]
- },
- {
- "name": "UpdatePersistentSubscriptionCompleted.UpdatePersistentSubscriptionResult",
- "enum_fields": [
- {
- "name": "Success"
- },
- {
- "name": "DoesNotExist",
- "integer": 1
- },
- {
- "name": "Fail",
- "integer": 2
- },
- {
- "name": "AccessDenied",
- "integer": 3
- }
- ]
- },
- {
- "name": "CreatePersistentSubscriptionCompleted.CreatePersistentSubscriptionResult",
- "enum_fields": [
- {
- "name": "Success"
- },
- {
- "name": "AlreadyExists",
- "integer": 1
- },
- {
- "name": "Fail",
- "integer": 2
- },
- {
- "name": "AccessDenied",
- "integer": 3
- }
- ]
- },
- {
- "name": "DeletePersistentSubscriptionCompleted.DeletePersistentSubscriptionResult",
- "enum_fields": [
- {
- "name": "Success"
- },
- {
- "name": "DoesNotExist",
- "integer": 1
- },
- {
- "name": "Fail",
- "integer": 2
- },
- {
- "name": "AccessDenied",
- "integer": 3
- }
- ]
- },
- {
- "name": "PersistentSubscriptionNakEvents.NakAction",
- "enum_fields": [
- {
- "name": "Unknown"
- },
- {
- "name": "Park",
- "integer": 1
- },
- {
- "name": "Retry",
- "integer": 2
- },
- {
- "name": "Skip",
- "integer": 3
- },
- {
- "name": "Stop",
- "integer": 4
- }
- ]
- },
- {
- "name": "SubscriptionDropped.SubscriptionDropReason",
- "enum_fields": [
- {
- "name": "Unsubscribed"
- },
- {
- "name": "AccessDenied",
- "integer": 1
- },
- {
- "name": "NotFound",
- "integer": 2
- },
- {
- "name": "PersistentSubscriptionDeleted",
- "integer": 3
- },
- {
- "name": "SubscriberMaxCountReached",
- "integer": 4
- }
- ]
- },
- {
- "name": "NotHandled.NotHandledReason",
- "enum_fields": [
- {
- "name": "NotReady"
- },
- {
- "name": "TooBusy",
- "integer": 1
- },
- {
- "name": "NotLeader",
- "integer": 2
- },
- {
- "name": "IsReadOnly",
- "integer": 3
- }
- ]
- },
- {
- "name": "ScavengeDatabaseResponse.ScavengeResult",
- "enum_fields": [
- {
- "name": "Started"
- },
- {
- "name": "InProgress",
- "integer": 1
- },
- {
- "name": "Unauthorized",
- "integer": 2
- }
- ]
- }
- ],
- "messages": [
- {
- "name": "NewEvent",
- "fields": [
- {
- "id": 1,
- "name": "event_id",
- "type": "bytes"
- },
- {
- "id": 2,
- "name": "event_type",
- "type": "string"
- },
- {
- "id": 3,
- "name": "data_content_type",
- "type": "int32"
- },
- {
- "id": 4,
- "name": "metadata_content_type",
- "type": "int32"
- },
- {
- "id": 5,
- "name": "data",
- "type": "bytes"
- },
- {
- "id": 6,
- "name": "metadata",
- "type": "bytes"
- }
- ]
- },
- {
- "name": "EventRecord",
- "fields": [
- {
- "id": 1,
- "name": "event_stream_id",
- "type": "string"
- },
- {
- "id": 2,
- "name": "event_number",
- "type": "int64"
- },
- {
- "id": 3,
- "name": "event_id",
- "type": "bytes"
- },
- {
- "id": 4,
- "name": "event_type",
- "type": "string"
- },
- {
- "id": 5,
- "name": "data_content_type",
- "type": "int32"
- },
- {
- "id": 6,
- "name": "metadata_content_type",
- "type": "int32"
- },
- {
- "id": 7,
- "name": "data",
- "type": "bytes"
- },
- {
- "id": 8,
- "name": "metadata",
- "type": "bytes"
- },
- {
- "id": 9,
- "name": "created",
- "type": "int64"
- },
- {
- "id": 10,
- "name": "created_epoch",
- "type": "int64"
- }
- ]
- },
- {
- "name": "ResolvedIndexedEvent",
- "fields": [
- {
- "id": 1,
- "name": "event",
- "type": "EventRecord"
- },
- {
- "id": 2,
- "name": "link",
- "type": "EventRecord"
- }
- ]
- },
- {
- "name": "ResolvedEvent",
- "fields": [
- {
- "id": 1,
- "name": "event",
- "type": "EventRecord"
- },
- {
- "id": 2,
- "name": "link",
- "type": "EventRecord"
- },
- {
- "id": 3,
- "name": "commit_position",
- "type": "int64"
- },
- {
- "id": 4,
- "name": "prepare_position",
- "type": "int64"
- }
- ]
- },
- {
- "name": "WriteEvents",
- "fields": [
- {
- "id": 1,
- "name": "event_stream_id",
- "type": "string"
- },
- {
- "id": 2,
- "name": "expected_version",
- "type": "int64"
- },
- {
- "id": 3,
- "name": "events",
- "type": "NewEvent",
- "is_repeated": true
- },
- {
- "id": 4,
- "name": "require_leader",
- "type": "bool"
- }
- ]
- },
- {
- "name": "WriteEventsCompleted",
- "fields": [
- {
- "id": 1,
- "name": "result",
- "type": "OperationResult"
- },
- {
- "id": 2,
- "name": "message",
- "type": "string"
- },
- {
- "id": 3,
- "name": "first_event_number",
- "type": "int64"
- },
- {
- "id": 4,
- "name": "last_event_number",
- "type": "int64"
- },
- {
- "id": 5,
- "name": "prepare_position",
- "type": "int64"
- },
- {
- "id": 6,
- "name": "commit_position",
- "type": "int64"
- },
- {
- "id": 7,
- "name": "current_version",
- "type": "int64"
- }
- ]
- },
- {
- "name": "DeleteStream",
- "fields": [
- {
- "id": 1,
- "name": "event_stream_id",
- "type": "string"
- },
- {
- "id": 2,
- "name": "expected_version",
- "type": "int64"
- },
- {
- "id": 3,
- "name": "require_leader",
- "type": "bool"
- },
- {
- "id": 4,
- "name": "hard_delete",
- "type": "bool"
- }
- ]
- },
- {
- "name": "DeleteStreamCompleted",
- "fields": [
- {
- "id": 1,
- "name": "result",
- "type": "OperationResult"
- },
- {
- "id": 2,
- "name": "message",
- "type": "string"
- },
- {
- "id": 3,
- "name": "prepare_position",
- "type": "int64"
- },
- {
- "id": 4,
- "name": "commit_position",
- "type": "int64"
- },
- {
- "id": 5,
- "name": "current_version",
- "type": "int64"
- }
- ]
- },
- {
- "name": "TransactionStart",
- "fields": [
- {
- "id": 1,
- "name": "event_stream_id",
- "type": "string"
- },
- {
- "id": 2,
- "name": "expected_version",
- "type": "int64"
- },
- {
- "id": 3,
- "name": "require_leader",
- "type": "bool"
- }
- ]
- },
- {
- "name": "TransactionStartCompleted",
- "fields": [
- {
- "id": 1,
- "name": "transaction_id",
- "type": "int64"
- },
- {
- "id": 2,
- "name": "result",
- "type": "OperationResult"
- },
- {
- "id": 3,
- "name": "message",
- "type": "string"
- }
- ]
- },
- {
- "name": "TransactionWrite",
- "fields": [
- {
- "id": 1,
- "name": "transaction_id",
- "type": "int64"
- },
- {
- "id": 2,
- "name": "events",
- "type": "NewEvent",
- "is_repeated": true
- },
- {
- "id": 3,
- "name": "require_leader",
- "type": "bool"
- }
- ]
- },
- {
- "name": "TransactionWriteCompleted",
- "fields": [
- {
- "id": 1,
- "name": "transaction_id",
- "type": "int64"
- },
- {
- "id": 2,
- "name": "result",
- "type": "OperationResult"
- },
- {
- "id": 3,
- "name": "message",
- "type": "string"
- }
- ]
- },
- {
- "name": "TransactionCommit",
- "fields": [
- {
- "id": 1,
- "name": "transaction_id",
- "type": "int64"
- },
- {
- "id": 2,
- "name": "require_leader",
- "type": "bool"
- }
- ]
- },
- {
- "name": "TransactionCommitCompleted",
- "fields": [
- {
- "id": 1,
- "name": "transaction_id",
- "type": "int64"
- },
- {
- "id": 2,
- "name": "result",
- "type": "OperationResult"
- },
- {
- "id": 3,
- "name": "message",
- "type": "string"
- },
- {
- "id": 4,
- "name": "first_event_number",
- "type": "int64"
- },
- {
- "id": 5,
- "name": "last_event_number",
- "type": "int64"
- },
- {
- "id": 6,
- "name": "prepare_position",
- "type": "int64"
- },
- {
- "id": 7,
- "name": "commit_position",
- "type": "int64"
- }
- ]
- },
- {
- "name": "ReadEvent",
- "fields": [
- {
- "id": 1,
- "name": "event_stream_id",
- "type": "string"
- },
- {
- "id": 2,
- "name": "event_number",
- "type": "int64"
- },
- {
- "id": 3,
- "name": "resolve_link_tos",
- "type": "bool"
- },
- {
- "id": 4,
- "name": "require_leader",
- "type": "bool"
- }
- ]
- },
- {
- "name": "ReadEventCompleted",
- "fields": [
- {
- "id": 1,
- "name": "result",
- "type": "ReadEventResult"
- },
- {
- "id": 2,
- "name": "event",
- "type": "ResolvedIndexedEvent"
- },
- {
- "id": 3,
- "name": "error",
- "type": "string"
- }
- ]
- },
- {
- "name": "ReadStreamEvents",
- "fields": [
- {
- "id": 1,
- "name": "event_stream_id",
- "type": "string"
- },
- {
- "id": 2,
- "name": "from_event_number",
- "type": "int64"
- },
- {
- "id": 3,
- "name": "max_count",
- "type": "int32"
- },
- {
- "id": 4,
- "name": "resolve_link_tos",
- "type": "bool"
- },
- {
- "id": 5,
- "name": "require_leader",
- "type": "bool"
- }
- ]
- },
- {
- "name": "ReadStreamEventsCompleted",
- "fields": [
- {
- "id": 1,
- "name": "events",
- "type": "ResolvedIndexedEvent",
- "is_repeated": true
- },
- {
- "id": 2,
- "name": "result",
- "type": "ReadStreamResult"
- },
- {
- "id": 3,
- "name": "next_event_number",
- "type": "int64"
- },
- {
- "id": 4,
- "name": "last_event_number",
- "type": "int64"
- },
- {
- "id": 5,
- "name": "is_end_of_stream",
- "type": "bool"
- },
- {
- "id": 6,
- "name": "last_commit_position",
- "type": "int64"
- },
- {
- "id": 7,
- "name": "error",
- "type": "string"
- }
- ]
- },
- {
- "name": "ReadAllEvents",
- "fields": [
- {
- "id": 1,
- "name": "commit_position",
- "type": "int64"
- },
- {
- "id": 2,
- "name": "prepare_position",
- "type": "int64"
- },
- {
- "id": 3,
- "name": "max_count",
- "type": "int32"
- },
- {
- "id": 4,
- "name": "resolve_link_tos",
- "type": "bool"
- },
- {
- "id": 5,
- "name": "require_leader",
- "type": "bool"
- }
- ]
- },
- {
- "name": "ReadAllEventsCompleted",
- "fields": [
- {
- "id": 1,
- "name": "commit_position",
- "type": "int64"
- },
- {
- "id": 2,
- "name": "prepare_position",
- "type": "int64"
- },
- {
- "id": 3,
- "name": "events",
- "type": "ResolvedEvent",
- "is_repeated": true
- },
- {
- "id": 4,
- "name": "next_commit_position",
- "type": "int64"
- },
- {
- "id": 5,
- "name": "next_prepare_position",
- "type": "int64"
- },
- {
- "id": 6,
- "name": "result",
- "type": "ReadAllResult"
- },
- {
- "id": 7,
- "name": "error",
- "type": "string"
- }
- ]
- },
- {
- "name": "Filter",
- "fields": [
- {
- "id": 1,
- "name": "context",
- "type": "FilterContext"
- },
- {
- "id": 2,
- "name": "type",
- "type": "FilterType"
- },
- {
- "id": 3,
- "name": "data",
- "type": "string",
- "is_repeated": true
- }
- ]
- },
- {
- "name": "FilteredReadAllEvents",
- "fields": [
- {
- "id": 1,
- "name": "commit_position",
- "type": "int64"
- },
- {
- "id": 2,
- "name": "prepare_position",
- "type": "int64"
- },
- {
- "id": 3,
- "name": "max_count",
- "type": "int32"
- },
- {
- "id": 4,
- "name": "max_search_window",
- "type": "int32"
- },
- {
- "id": 5,
- "name": "resolve_link_tos",
- "type": "bool"
- },
- {
- "id": 6,
- "name": "require_leader",
- "type": "bool"
- },
- {
- "id": 7,
- "name": "filter",
- "type": "Filter"
- }
- ]
- },
- {
- "name": "FilteredReadAllEventsCompleted",
- "fields": [
- {
- "id": 1,
- "name": "commit_position",
- "type": "int64"
- },
- {
- "id": 2,
- "name": "prepare_position",
- "type": "int64"
- },
- {
- "id": 3,
- "name": "events",
- "type": "ResolvedEvent",
- "is_repeated": true
- },
- {
- "id": 4,
- "name": "next_commit_position",
- "type": "int64"
- },
- {
- "id": 5,
- "name": "next_prepare_position",
- "type": "int64"
- },
- {
- "id": 6,
- "name": "is_end_of_stream",
- "type": "bool"
- },
- {
- "id": 7,
- "name": "result",
- "type": "FilteredReadAllResult"
- },
- {
- "id": 8,
- "name": "error",
- "type": "string"
- }
- ]
- },
- {
- "name": "CreatePersistentSubscription",
- "fields": [
- {
- "id": 1,
- "name": "subscription_group_name",
- "type": "string"
- },
- {
- "id": 2,
- "name": "event_stream_id",
- "type": "string"
- },
- {
- "id": 3,
- "name": "resolve_link_tos",
- "type": "bool"
- },
- {
- "id": 4,
- "name": "start_from",
- "type": "int64"
- },
- {
- "id": 5,
- "name": "message_timeout_milliseconds",
- "type": "int32"
- },
- {
- "id": 6,
- "name": "record_statistics",
- "type": "bool"
- },
- {
- "id": 7,
- "name": "live_buffer_size",
- "type": "int32"
- },
- {
- "id": 8,
- "name": "read_batch_size",
- "type": "int32"
- },
- {
- "id": 9,
- "name": "buffer_size",
- "type": "int32"
- },
- {
- "id": 10,
- "name": "max_retry_count",
- "type": "int32"
- },
- {
- "id": 11,
- "name": "prefer_round_robin",
- "type": "bool"
- },
- {
- "id": 12,
- "name": "checkpoint_after_time",
- "type": "int32"
- },
- {
- "id": 13,
- "name": "checkpoint_max_count",
- "type": "int32"
- },
- {
- "id": 14,
- "name": "checkpoint_min_count",
- "type": "int32"
- },
- {
- "id": 15,
- "name": "subscriber_max_count",
- "type": "int32"
- },
- {
- "id": 16,
- "name": "named_consumer_strategy",
- "type": "string"
- }
- ]
- },
- {
- "name": "DeletePersistentSubscription",
- "fields": [
- {
- "id": 1,
- "name": "subscription_group_name",
- "type": "string"
- },
- {
- "id": 2,
- "name": "event_stream_id",
- "type": "string"
- }
- ]
- },
- {
- "name": "UpdatePersistentSubscription",
- "fields": [
- {
- "id": 1,
- "name": "subscription_group_name",
- "type": "string"
- },
- {
- "id": 2,
- "name": "event_stream_id",
- "type": "string"
- },
- {
- "id": 3,
- "name": "resolve_link_tos",
- "type": "bool"
- },
- {
- "id": 4,
- "name": "start_from",
- "type": "int64"
- },
- {
- "id": 5,
- "name": "message_timeout_milliseconds",
- "type": "int32"
- },
- {
- "id": 6,
- "name": "record_statistics",
- "type": "bool"
- },
- {
- "id": 7,
- "name": "live_buffer_size",
- "type": "int32"
- },
- {
- "id": 8,
- "name": "read_batch_size",
- "type": "int32"
- },
- {
- "id": 9,
- "name": "buffer_size",
- "type": "int32"
- },
- {
- "id": 10,
- "name": "max_retry_count",
- "type": "int32"
- },
- {
- "id": 11,
- "name": "prefer_round_robin",
- "type": "bool"
- },
- {
- "id": 12,
- "name": "checkpoint_after_time",
- "type": "int32"
- },
- {
- "id": 13,
- "name": "checkpoint_max_count",
- "type": "int32"
- },
- {
- "id": 14,
- "name": "checkpoint_min_count",
- "type": "int32"
- },
- {
- "id": 15,
- "name": "subscriber_max_count",
- "type": "int32"
- },
- {
- "id": 16,
- "name": "named_consumer_strategy",
- "type": "string"
- }
- ]
- },
- {
- "name": "UpdatePersistentSubscriptionCompleted",
- "fields": [
- {
- "id": 1,
- "name": "result",
- "type": "UpdatePersistentSubscriptionResult"
- },
- {
- "id": 2,
- "name": "reason",
- "type": "string"
- }
- ]
- },
- {
- "name": "CreatePersistentSubscriptionCompleted",
- "fields": [
- {
- "id": 1,
- "name": "result",
- "type": "CreatePersistentSubscriptionResult"
- },
- {
- "id": 2,
- "name": "reason",
- "type": "string"
- }
- ]
- },
- {
- "name": "DeletePersistentSubscriptionCompleted",
- "fields": [
- {
- "id": 1,
- "name": "result",
- "type": "DeletePersistentSubscriptionResult"
- },
- {
- "id": 2,
- "name": "reason",
- "type": "string"
- }
- ]
- },
- {
- "name": "ConnectToPersistentSubscription",
- "fields": [
- {
- "id": 1,
- "name": "subscription_id",
- "type": "string"
- },
- {
- "id": 2,
- "name": "event_stream_id",
- "type": "string"
- },
- {
- "id": 3,
- "name": "allowed_in_flight_messages",
- "type": "int32"
- }
- ]
- },
- {
- "name": "PersistentSubscriptionAckEvents",
- "fields": [
- {
- "id": 1,
- "name": "subscription_id",
- "type": "string"
- },
- {
- "id": 2,
- "name": "processed_event_ids",
- "type": "bytes",
- "is_repeated": true
- }
- ]
- },
- {
- "name": "PersistentSubscriptionNakEvents",
- "fields": [
- {
- "id": 1,
- "name": "subscription_id",
- "type": "string"
- },
- {
- "id": 2,
- "name": "processed_event_ids",
- "type": "bytes",
- "is_repeated": true
- },
- {
- "id": 3,
- "name": "message",
- "type": "string"
- },
- {
- "id": 4,
- "name": "action",
- "type": "NakAction"
- }
- ]
- },
- {
- "name": "PersistentSubscriptionConfirmation",
- "fields": [
- {
- "id": 1,
- "name": "last_commit_position",
- "type": "int64"
- },
- {
- "id": 2,
- "name": "subscription_id",
- "type": "string"
- },
- {
- "id": 3,
- "name": "last_event_number",
- "type": "int64"
- }
- ]
- },
- {
- "name": "PersistentSubscriptionStreamEventAppeared",
- "fields": [
- {
- "id": 1,
- "name": "event",
- "type": "ResolvedIndexedEvent"
- },
- {
- "id": 2,
- "name": "retryCount",
- "type": "int32"
- }
- ]
- },
- {
- "name": "SubscribeToStream",
- "fields": [
- {
- "id": 1,
- "name": "event_stream_id",
- "type": "string"
- },
- {
- "id": 2,
- "name": "resolve_link_tos",
- "type": "bool"
- }
- ]
- },
- {
- "name": "FilteredSubscribeToStream",
- "fields": [
- {
- "id": 1,
- "name": "event_stream_id",
- "type": "string"
- },
- {
- "id": 2,
- "name": "resolve_link_tos",
- "type": "bool"
- },
- {
- "id": 3,
- "name": "filter",
- "type": "Filter"
- },
- {
- "id": 4,
- "name": "checkpoint_interval",
- "type": "int32"
- }
- ]
- },
- {
- "name": "CheckpointReached",
- "fields": [
- {
- "id": 1,
- "name": "commit_position",
- "type": "int64"
- },
- {
- "id": 2,
- "name": "prepare_position",
- "type": "int64"
- }
- ]
- },
- {
- "name": "SubscriptionConfirmation",
- "fields": [
- {
- "id": 1,
- "name": "last_commit_position",
- "type": "int64"
- },
- {
- "id": 2,
- "name": "last_event_number",
- "type": "int64"
- }
- ]
- },
- {
- "name": "StreamEventAppeared",
- "fields": [
- {
- "id": 1,
- "name": "event",
- "type": "ResolvedEvent"
- }
- ]
- },
- {
- "name": "UnsubscribeFromStream"
- },
- {
- "name": "SubscriptionDropped",
- "fields": [
- {
- "id": 1,
- "name": "reason",
- "type": "SubscriptionDropReason"
- }
- ]
- },
- {
- "name": "NotHandled",
- "fields": [
- {
- "id": 1,
- "name": "reason",
- "type": "NotHandledReason"
- },
- {
- "id": 2,
- "name": "additional_info",
- "type": "bytes"
- }
- ],
- "messages": [
- {
- "name": "LeaderInfo",
- "fields": [
- {
- "id": 1,
- "name": "external_tcp_address",
- "type": "string"
- },
- {
- "id": 2,
- "name": "external_tcp_port",
- "type": "int32"
- },
- {
- "id": 3,
- "name": "http_address",
- "type": "string"
- },
- {
- "id": 4,
- "name": "http_port",
- "type": "int32"
- },
- {
- "id": 5,
- "name": "external_secure_tcp_address",
- "type": "string"
- },
- {
- "id": 6,
- "name": "external_secure_tcp_port",
- "type": "int32"
- }
- ]
- }
- ]
- },
- {
- "name": "ScavengeDatabase"
- },
- {
- "name": "ScavengeDatabaseResponse",
- "fields": [
- {
- "id": 1,
- "name": "result",
- "type": "ScavengeResult"
- },
- {
- "id": 2,
- "name": "scavengeId",
- "type": "string"
- }
- ]
- },
- {
- "name": "IdentifyClient",
- "fields": [
- {
- "id": 1,
- "name": "version",
- "type": "int32"
- },
- {
- "id": 2,
- "name": "connection_name",
- "type": "string"
- }
- ]
- },
- {
- "name": "ClientIdentified"
- }
- ],
- "package": {
- "name": "EventStore.Client.Messages"
- }
- }
- },
{
"protopath": "Grpc:/:cluster.proto",
"def": {
@@ -1909,26 +408,6 @@
"name": "http_end_point",
"type": "EndPoint"
},
- {
- "id": 6,
- "name": "internal_tcp",
- "type": "EndPoint"
- },
- {
- "id": 7,
- "name": "external_tcp",
- "type": "EndPoint"
- },
- {
- "id": 8,
- "name": "internal_tcp_uses_tls",
- "type": "bool"
- },
- {
- "id": 9,
- "name": "external_tcp_uses_tls",
- "type": "bool"
- },
{
"id": 10,
"name": "last_commit_position",
@@ -1979,16 +458,30 @@
"name": "advertise_http_port_to_client_as",
"type": "uint32"
},
- {
- "id": 20,
- "name": "advertise_tcp_port_to_client_as",
- "type": "uint32"
- },
{
"id": 21,
"name": "es_version",
"type": "string"
+ },
+ {
+ "id": 22,
+ "name": "replication_end_point",
+ "type": "EndPoint"
}
+ ],
+ "reserved_ids": [
+ 6,
+ 7,
+ 8,
+ 9,
+ 20
+ ],
+ "reserved_names": [
+ "internal_tcp",
+ "external_tcp",
+ "internal_tcp_uses_tls",
+ "external_tcp_uses_tls",
+ "advertise_tcp_port_to_client_as"
]
}
],
@@ -2784,21 +1277,19 @@
{
"name": "LeaderInfo",
"fields": [
- {
- "id": 1,
- "name": "external_tcp",
- "type": "EndPoint"
- },
- {
- "id": 2,
- "name": "is_secure",
- "type": "bool"
- },
{
"id": 3,
"name": "http",
"type": "EndPoint"
}
+ ],
+ "reserved_ids": [
+ 1,
+ 2
+ ],
+ "reserved_names": [
+ "external_tcp",
+ "is_secure"
]
},
{
@@ -3063,21 +1554,21 @@
]
},
{
- "name": "TcpStatsReq"
+ "name": "ConnectionStatsReq"
},
{
- "name": "TcpStatsResp",
+ "name": "ConnectionStatsResp",
"fields": [
{
"id": 1,
"name": "connections",
- "type": "TcpConnectionStats",
+ "type": "ConnectionStats",
"is_repeated": true
}
]
},
{
- "name": "TcpConnectionStats",
+ "name": "ConnectionStats",
"fields": [
{
"id": 1,
@@ -3112,22 +1603,32 @@
{
"id": 7,
"name": "pending_send_bytes",
- "type": "int32"
+ "type": "int64"
},
{
"id": 8,
"name": "pending_received_bytes",
- "type": "int32"
+ "type": "int64"
},
{
"id": 9,
- "name": "is_external_connection",
+ "name": "is_tls",
"type": "bool"
},
{
"id": 10,
- "name": "is_ssl_connection",
- "type": "bool"
+ "name": "protocol",
+ "type": "string"
+ },
+ {
+ "id": 11,
+ "name": "application",
+ "type": "string"
+ },
+ {
+ "id": 12,
+ "name": "connected_at",
+ "type": "google.protobuf.Timestamp"
}
]
},
@@ -3202,9 +1703,9 @@
"out_streamed": true
},
{
- "name": "TcpStats",
- "in_type": "TcpStatsReq",
- "out_type": "TcpStatsResp"
+ "name": "ConnectionStats",
+ "in_type": "ConnectionStatsReq",
+ "out_type": "ConnectionStatsResp"
},
{
"name": "ReplicationStats",
@@ -3217,6 +1718,9 @@
"imports": [
{
"path": "google/protobuf/struct.proto"
+ },
+ {
+ "path": "google/protobuf/timestamp.proto"
}
],
"package": {
diff --git a/src/EventStore.ClusterNode/Components/Pages/Observability.razor b/src/EventStore.ClusterNode/Components/Pages/Observability.razor
index 8d3540afe8..00fd36a002 100644
--- a/src/EventStore.ClusterNode/Components/Pages/Observability.razor
+++ b/src/EventStore.ClusterNode/Components/Pages/Observability.razor
@@ -10,7 +10,7 @@
Observability
A map of the signals the node already emits.
-
Inspect live queue pressure, processing throughput, health, metrics, replication, TCP, and grouped runtime statistics.
+
Inspect live queue pressure, processing throughput, health, metrics, replication, and grouped runtime statistics.
@@ -20,6 +20,112 @@
+
+
+
+
Network boundary
+
Shared HTTP and gRPC connections
+
Active connections accepted by the node endpoint, including per-second traffic rates and pending bytes.
+
+
+ @NetworkStatusLabel
+ @NetworkPageStatusLabel
+
+
+
+
+
+
+
+
+
+
+ | Connection |
+ Client |
+ Type |
+ Remote endpoint |
+ Sent rate |
+ Sent current |
+ Sent pending |
+ Received rate |
+ Received current |
+ Received pending |
+
+
+
+ @if (NetworkRows.Count == 0)
+ {
+ | No active shared-endpoint connections. |
+ }
+ else
+ {
+ foreach (var connection in NetworkRows.Take(5))
+ {
+
+ | @connection.ConnectionId |
+ @Display(connection.ClientName) |
+ @connection.Application · @Display(connection.Protocol) · @(connection.IsTls ? "TLS" : "Cleartext") |
+ @connection.RemoteEndPoint |
+ Waiting |
+ @FormatBytes(connection.TotalBytesSent) |
+ @FormatBytes(connection.PendingSendBytes) |
+ Waiting |
+ @FormatBytes(connection.TotalBytesReceived) |
+ @FormatBytes(connection.PendingReceivedBytes) |
+
+ }
+ }
+
+
+
+
+
+
+
+
+
Cluster transport
+
gRPC replication connections
+
Live database replication sessions on the shared HTTP/2 endpoint.
+
+
+
+
+
+
+
+ | Endpoint |
+ Connection |
+ Sent |
+ Received |
+ Pending |
+ Send queue |
+
+
+
+ @if (Page is not null && Page.ReplicationConnections.Count > 0)
+ {
+ @foreach (var connection in Page.ReplicationConnections)
+ {
+
+ | @connection.Endpoint |
+ @connection.ConnectionId |
+ @FormatBytes(connection.TotalBytesSent) |
+ @FormatBytes(connection.TotalBytesReceived) |
+ @FormatBytes(connection.PendingSendBytes + connection.PendingReceivedBytes) |
+ @connection.SendQueueSize.ToString("N0", CultureInfo.InvariantCulture) |
+
+ }
+ }
+ else
+ {
+ | No active gRPC replication connections. |
+ }
+
+
+
+
+
+
Dashboard snapshot
@@ -87,58 +193,6 @@
}
-
-
-
-
TCP dashboard
-
Realtime connections
-
Shows active connections with per-second sent and received byte rates.
-
-
- @TcpStatusLabel
- @TcpPageStatusLabel
-
-
-
-
-
-
-
-
-
-
- | Connection |
- Client |
- Type |
- IP Address |
- Sent rate |
- Sent current |
- Sent pending |
- Received rate |
- Received current |
- Received pending |
-
-
-
- @if (TcpRows.Count == 0)
- {
-
- | @TcpEmptyMessage |
-
- }
- else
- {
- foreach (var connection in TcpRows.Take(5))
- {
- @RenderTcpRow(connection)
- }
- }
-
-
-
-
-
-
@code {
@@ -148,29 +202,21 @@
private string Expanded { get; set; } = "";
private string DashboardPayloadJson => Page?.ClientPayloadJson ?? "{}";
- private IReadOnlyList TcpRows => Page?.TcpConnections ?? Array.Empty();
- private string TcpErrorMessage => !string.IsNullOrWhiteSpace(Page?.TcpMessage)
- ? Page.TcpMessage
- : Page?.Message ?? "";
- private string TcpStatusLabel {
- get {
- if (Page is null)
- return "Connecting TCP stats...";
-
- if (!string.IsNullOrWhiteSpace(TcpErrorMessage))
- return "TCP unavailable";
-
- return TcpRows.Count == 0
- ? "TCP live"
- : string.Create(CultureInfo.InvariantCulture, $"TCP live · {TcpRows.Count} connection{(TcpRows.Count == 1 ? "" : "s")}");
- }
- }
- private string TcpPageStatusLabel => TcpRows.Count == 0
+ private IReadOnlyList NetworkRows =>
+ Page?.NodeConnections ?? Array.Empty();
+ private string NetworkStatusLabel => NetworkRows.Count == 0
+ ? "Network live"
+ : string.Create(CultureInfo.InvariantCulture, $"Network live · {NetworkRows.Count} connection{(NetworkRows.Count == 1 ? "" : "s")}");
+ private string NetworkPageStatusLabel => NetworkRows.Count == 0
? "No pages"
- : string.Create(CultureInfo.InvariantCulture, $"Page 1 of {Math.Max(1, (int)Math.Ceiling(TcpRows.Count / 5.0))}");
- private string TcpEmptyMessage => string.IsNullOrWhiteSpace(TcpErrorMessage)
- ? "No TCP connections are currently reported."
- : TcpErrorMessage;
+ : string.Create(CultureInfo.InvariantCulture, $"Page 1 of {Math.Max(1, (int)Math.Ceiling(NetworkRows.Count / 5.0))}");
+
+ private static string FormatBytes(long value) =>
+ value.ToString("N0", CultureInfo.InvariantCulture) + " B";
+ private static string Display(string value) => string.IsNullOrWhiteSpace(value) ? "" : value;
+ private static string ConnectionDetails(NodeConnectionSnapshot connection) =>
+ string.Create(CultureInfo.InvariantCulture,
+ $"Local {connection.LocalEndPoint}, connected {connection.ConnectedAt:u}");
protected override async Task OnParametersSetAsync() {
Page = null;
@@ -216,19 +262,6 @@
@row.CurrentLastMessageLabel |
;
- private RenderFragment RenderTcpRow(TcpConnectionRow connection) => @
- | @connection.IdLabel |
- @connection.ClientLabel |
- @connection.TypeLabel |
- @connection.RemoteEndPointLabel |
- @connection.SentRateLabel |
- @connection.TotalBytesSentLabel |
- @connection.PendingSendBytesLabel |
- @connection.ReceivedRateLabel |
- @connection.TotalBytesReceivedLabel |
- @connection.PendingReceivedBytesLabel |
-
;
-
private bool IsExpanded(string groupName) =>
ParseExpandedGroups().Contains(groupName);
diff --git a/src/EventStore.ClusterNode/Components/Services/NodeConnectionTracker.cs b/src/EventStore.ClusterNode/Components/Services/NodeConnectionTracker.cs
index 8b98cc7262..0a0fdfc005 100644
--- a/src/EventStore.ClusterNode/Components/Services/NodeConnectionTracker.cs
+++ b/src/EventStore.ClusterNode/Components/Services/NodeConnectionTracker.cs
@@ -6,11 +6,12 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using EventStore.Core.Services.Transport.Grpc;
using Microsoft.AspNetCore.Connections;
namespace EventStore.ClusterNode.Components.Services;
-public sealed class NodeConnectionTracker
+public sealed class NodeConnectionTracker : IConnectionStatsProvider
{
private readonly ConcurrentDictionary _connections = new();
@@ -20,6 +21,8 @@ public IReadOnlyList Snapshot() =>
.ThenBy(x => x.ConnectionId, StringComparer.Ordinal)
.ToArray();
+ IReadOnlyList IConnectionStatsProvider.Snapshot() => Snapshot();
+
public async Task Track(ConnectionContext context, ConnectionDelegate next, bool isTls)
{
var state = new NodeConnectionState(
@@ -49,7 +52,9 @@ public void ObserveRequest(
string userAgent)
{
if (_connections.TryGetValue(connectionId, out var connection))
+ {
connection.ObserveRequest(protocol, isGrpc, connectionName, userAgent);
+ }
}
}
@@ -65,7 +70,19 @@ public sealed record NodeConnectionSnapshot(
long TotalBytesSent,
long TotalBytesReceived,
long PendingSendBytes,
- long PendingReceivedBytes);
+ long PendingReceivedBytes) : ConnectionStatsSnapshot(
+ ConnectionId,
+ RemoteEndPoint,
+ LocalEndPoint,
+ ClientName,
+ Application,
+ Protocol,
+ IsTls,
+ ConnectedAt,
+ TotalBytesSent,
+ TotalBytesReceived,
+ PendingSendBytes,
+ PendingReceivedBytes);
internal sealed class NodeConnectionState
{
@@ -172,7 +189,10 @@ public NodeConnectionSnapshot Snapshot()
private static string Merge(string current, string observed)
{
if (string.IsNullOrWhiteSpace(observed) || current == observed)
+ {
return current;
+ }
+
return string.IsNullOrWhiteSpace(current) ? observed : "Mixed";
}
}
@@ -228,7 +248,9 @@ public override async ValueTask ReadAsync(CancellationToken cancella
public override bool TryRead(out ReadResult result)
{
if (!_inner.TryRead(out result))
+ {
return false;
+ }
Observe(result);
return true;
@@ -268,7 +290,10 @@ public override async ValueTask FlushAsync(CancellationToken cancel
{
var result = await _inner.FlushAsync(cancellationToken);
if (!result.IsCanceled)
+ {
_state.Sent();
+ }
+
return result;
}
diff --git a/src/EventStore.ClusterNode/Components/Services/QueueDashboardService.cs b/src/EventStore.ClusterNode/Components/Services/QueueDashboardService.cs
index 6cba5ae36b..84d2642921 100644
--- a/src/EventStore.ClusterNode/Components/Services/QueueDashboardService.cs
+++ b/src/EventStore.ClusterNode/Components/Services/QueueDashboardService.cs
@@ -18,23 +18,22 @@ public sealed class QueueDashboardService
{
private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(10);
private static readonly Operation StatisticsOperation = new(Operations.Node.Statistics.Read);
- private static readonly Operation TcpStatisticsOperation = new(Operations.Node.Statistics.Tcp);
private readonly IAuthorizationProvider _authorizationProvider;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IPublisher _monitoringQueue;
- private readonly object _tcpGate = new();
- private Dictionary _previousTcpConnections = new();
- private DateTime? _lastTcpRefresh;
+ private readonly NodeConnectionTracker _nodeConnectionTracker;
public QueueDashboardService(
IAuthorizationProvider authorizationProvider,
IHttpContextAccessor httpContextAccessor,
- StandardComponents standardComponents)
+ StandardComponents standardComponents,
+ NodeConnectionTracker nodeConnectionTracker)
{
_authorizationProvider = authorizationProvider;
_httpContextAccessor = httpContextAccessor;
_monitoringQueue = standardComponents.MonitoringQueue;
+ _nodeConnectionTracker = nodeConnectionTracker;
}
public async Task Read(CancellationToken cancellationToken = default)
@@ -49,9 +48,13 @@ public async Task Read(CancellationToken cancellationToken =
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(ReadTimeout);
- var queues = await ReadQueueStats(timeout.Token);
- var tcp = await ReadTcpStatsSafe(timeout.Token, cancellationToken);
- return QueueDashboardPage.Success(queues, tcp.Rows, tcp.Message);
+ var queuesTask = ReadQueueStats(timeout.Token);
+ var replicationConnectionsTask = ReadReplicationStats(timeout.Token);
+ await Task.WhenAll(queuesTask, replicationConnectionsTask);
+ return QueueDashboardPage.Success(
+ await queuesTask,
+ await replicationConnectionsTask,
+ _nodeConnectionTracker.Snapshot());
}
catch (TimeoutException)
{
@@ -102,67 +105,17 @@ private async Task> ReadQueueStats(Cancellation
return queues;
}
- private async Task ReadTcpStats(CancellationToken cancellationToken)
- {
- if (!await HasAccess(TcpStatisticsOperation, cancellationToken))
- {
- return new TcpConnectionResult(Array.Empty(), "TCP statistics access was denied.");
- }
-
- var envelope = new TaskCompletionEnvelope();
- _monitoringQueue.Publish(new MonitoringMessage.GetFreshTcpConnectionStats(envelope));
- var completed = await envelope.Task.WaitAsync(ReadTimeout, cancellationToken);
- return BuildTcpRows(completed.ConnectionStats ?? []);
- }
-
- private async Task ReadTcpStatsSafe(
- CancellationToken timeoutToken,
+ private async Task> ReadReplicationStats(
CancellationToken cancellationToken)
{
- try
- {
- return await ReadTcpStats(timeoutToken);
- }
- catch (TimeoutException)
- {
- return new TcpConnectionResult(Array.Empty(), "Timed out reading TCP statistics.");
- }
- catch (OperationCanceledException)
- {
- if (cancellationToken.IsCancellationRequested)
- {
- throw;
- }
-
- return new TcpConnectionResult(Array.Empty(), "Timed out reading TCP statistics.");
- }
- catch (Exception ex)
- {
- return new TcpConnectionResult(
- Array.Empty(),
- $"Unable to read TCP statistics: {UiMessages.Friendly(ex)}");
- }
- }
-
- private TcpConnectionResult BuildTcpRows(IReadOnlyList connections)
- {
- lock (_tcpGate)
- {
- var now = DateTime.UtcNow;
- var elapsedSeconds = _lastTcpRefresh.HasValue
- ? Math.Max(1, (now - _lastTcpRefresh.Value).TotalSeconds)
- : 1;
-
- var rows = connections
- .Select(x => TcpConnectionRow.From(x, _previousTcpConnections.GetValueOrDefault(x.ConnectionId), elapsedSeconds))
- .OrderBy(x => x.ClientConnectionName, StringComparer.OrdinalIgnoreCase)
- .ThenBy(x => x.ConnectionId)
- .ToArray();
+ var envelope = new TaskCompletionEnvelope();
+ _monitoringQueue.Publish(new ReplicationMessage.GetReplicationStats(envelope));
+ var completed = await envelope.Task.WaitAsync(ReadTimeout, cancellationToken);
- _previousTcpConnections = rows.ToDictionary(x => x.ConnectionId);
- _lastTcpRefresh = now;
- return new TcpConnectionResult(rows, "");
- }
+ return completed.ReplicationStats
+ .Select(ReplicationConnectionRow.From)
+ .OrderBy(x => x.Endpoint, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
}
}
@@ -196,8 +149,8 @@ public static bool TryReadDictionary(object value, out IReadOnlyDictionary Blocks,
IReadOnlyList Queues,
- IReadOnlyList TcpConnections,
- string TcpMessage,
+ IReadOnlyList ReplicationConnections,
+ IReadOnlyList NodeConnections,
string Message)
{
private static readonly JsonSerializerOptions PayloadJsonOptions = new(JsonSerializerDefaults.Web);
@@ -214,23 +167,28 @@ public sealed record QueueDashboardPage(
public string ClientPayloadJson => JsonSerializer.Serialize(
new QueueDashboardPayload(
Queues.Select(QueuePayload.From).ToArray(),
- TcpConnections.Select(TcpConnectionPayload.From).ToArray(),
- Message,
- TcpMessage),
+ ReplicationConnections,
+ NodeConnections,
+ Message),
PayloadJsonOptions);
public static QueueDashboardPage Success(
IReadOnlyList queues,
- IReadOnlyList tcpConnections,
- string tcpMessage) =>
- new(BuildBlocks(queues), queues, tcpConnections, tcpMessage, "");
+ IReadOnlyList replicationConnections = null,
+ IReadOnlyList nodeConnections = null) =>
+ new(
+ BuildBlocks(queues),
+ queues,
+ replicationConnections ?? Array.Empty(),
+ nodeConnections ?? Array.Empty(),
+ "");
public static QueueDashboardPage Unavailable(string message) =>
new(
Array.Empty(),
Array.Empty(),
- Array.Empty(),
- message,
+ Array.Empty(),
+ Array.Empty(),
message);
private static IReadOnlyList BuildBlocks(IReadOnlyList queues)
@@ -266,15 +224,33 @@ public sealed record QueueDashboardBlock(
public bool HasChildren => Children.Count > 0;
}
-public sealed record TcpConnectionResult(
- IReadOnlyList Rows,
- string Message);
-
public sealed record QueueDashboardPayload(
IReadOnlyList Queues,
- IReadOnlyList TcpConnections,
- string Message,
- string TcpMessage);
+ IReadOnlyList ReplicationConnections,
+ IReadOnlyList NodeConnections,
+ string Message);
+
+public sealed record ReplicationConnectionRow(
+ string SubscriptionId,
+ string ConnectionId,
+ string Endpoint,
+ long TotalBytesSent,
+ long TotalBytesReceived,
+ int PendingSendBytes,
+ int PendingReceivedBytes,
+ int SendQueueSize)
+{
+ public static ReplicationConnectionRow From(ReplicationMessage.ReplicationStats stats) =>
+ new(
+ stats.SubscriptionId.ToString("D"),
+ stats.ConnectionId.ToString("D"),
+ stats.SubscriptionEndpoint ?? "",
+ stats.TotalBytesSent,
+ stats.TotalBytesReceived,
+ stats.PendingSendBytes,
+ stats.PendingReceivedBytes,
+ stats.SendQueueSize);
+}
public sealed record QueuePayload(
string Kind,
@@ -304,95 +280,6 @@ public static QueuePayload From(QueueDashboardRow row) =>
row.LastProcessedMessage);
}
-public sealed record TcpConnectionPayload(
- Guid ConnectionId,
- string ClientConnectionName,
- string RemoteEndPoint,
- string LocalEndPoint,
- long TotalBytesSent,
- long TotalBytesReceived,
- int PendingSendBytes,
- int PendingReceivedBytes,
- double SentRate,
- double ReceivedRate,
- bool IsExternalConnection,
- bool IsSslConnection)
-{
- public static TcpConnectionPayload From(TcpConnectionRow row) =>
- new(
- row.ConnectionId,
- row.ClientConnectionName,
- row.RemoteEndPoint,
- row.LocalEndPoint,
- row.TotalBytesSent,
- row.TotalBytesReceived,
- row.PendingSendBytes,
- row.PendingReceivedBytes,
- row.SentRate,
- row.ReceivedRate,
- row.IsExternalConnection,
- row.IsSslConnection);
-}
-
-public sealed record TcpConnectionRow(
- Guid ConnectionId,
- string ClientConnectionName,
- string RemoteEndPoint,
- string LocalEndPoint,
- long TotalBytesSent,
- long TotalBytesReceived,
- int PendingSendBytes,
- int PendingReceivedBytes,
- double SentRate,
- double ReceivedRate,
- bool IsExternalConnection,
- bool IsSslConnection)
-{
- public string IdLabel => ConnectionId == Guid.Empty ? "" : ConnectionId.ToString("D");
- public string ClientLabel => DisplayMessage(ClientConnectionName);
- public string TypeLabel => $"{(IsExternalConnection ? "External" : "Internal")} {(IsSslConnection ? "TLS" : "TCP")}";
- public string RemoteEndPointLabel => DisplayMessage(RemoteEndPoint);
- public string SentRateLabel => FormatByteRate(SentRate);
- public string ReceivedRateLabel => FormatByteRate(ReceivedRate);
- public string TotalBytesSentLabel => TotalBytesSent.ToString("N0", CultureInfo.InvariantCulture);
- public string TotalBytesReceivedLabel => TotalBytesReceived.ToString("N0", CultureInfo.InvariantCulture);
- public string PendingSendBytesLabel => PendingSendBytes.ToString("N0", CultureInfo.InvariantCulture);
- public string PendingReceivedBytesLabel => PendingReceivedBytes.ToString("N0", CultureInfo.InvariantCulture);
-
- public static TcpConnectionRow From(
- MonitoringMessage.TcpConnectionStats stats,
- TcpConnectionRow previous,
- double elapsedSeconds)
- {
- var sentRate = previous is null
- ? 0
- : Math.Max(0, (stats.TotalBytesSent - previous.TotalBytesSent) / elapsedSeconds);
- var receivedRate = previous is null
- ? 0
- : Math.Max(0, (stats.TotalBytesReceived - previous.TotalBytesReceived) / elapsedSeconds);
-
- return new TcpConnectionRow(
- stats.ConnectionId,
- stats.ClientConnectionName ?? "",
- stats.RemoteEndPoint ?? "",
- stats.LocalEndPoint ?? "",
- stats.TotalBytesSent,
- stats.TotalBytesReceived,
- stats.PendingSendBytes,
- stats.PendingReceivedBytes,
- sentRate,
- receivedRate,
- stats.IsExternalConnection,
- stats.IsSslConnection);
- }
-
- private static string DisplayMessage(string value) =>
- string.IsNullOrWhiteSpace(value) ? "" : value;
-
- private static string FormatByteRate(double value) =>
- $"{Math.Round(value).ToString("N0", CultureInfo.InvariantCulture)} B/s";
-}
-
public enum QueueDashboardRowKind
{
Queue,
diff --git a/src/EventStore.ClusterNode/Program.cs b/src/EventStore.ClusterNode/Program.cs
index 6dbe3b2dbb..e387023c5c 100644
--- a/src/EventStore.ClusterNode/Program.cs
+++ b/src/EventStore.ClusterNode/Program.cs
@@ -20,6 +20,7 @@
using EventStore.Core.Authentication.OAuth;
using EventStore.Core.Certificates;
using EventStore.Core.Configuration;
+using EventStore.Core.Services.Transport.Grpc;
using EventStore.Core.Services.Transport.Http;
using EventStore.Plugins.Authentication;
using Microsoft.AspNetCore.Builder;
@@ -278,6 +279,7 @@ async Task Run(ClusterVNodeHostedService hostedService, ManualResetEventSlim sig
var replicationEndpointPolicy = new ReplicationEndpointPolicy(
new System.Net.IPEndPoint(options.Interface.ReplicationIp, options.Interface.ReplicationPort));
builder.Services.AddSingleton(nodeConnectionTracker);
+ builder.Services.AddSingleton(nodeConnectionTracker);
builder.WebHost.ConfigureKestrel(server =>
{
server.Limits.Http2.KeepAlivePingDelay =
diff --git a/src/EventStore.ClusterNode/metricsconfig.json b/src/EventStore.ClusterNode/metricsconfig.json
index cb1edb93ff..f249180912 100644
--- a/src/EventStore.ClusterNode/metricsconfig.json
+++ b/src/EventStore.ClusterNode/metricsconfig.json
@@ -236,10 +236,6 @@
"Regex": "CoreMessage-System-.*",
"Label": "System"
},
- {
- "Regex": "CoreMessage-Tcp-.*",
- "Label": "Tcp"
- },
{
"Regex": "ProjectionMessage-.*",
"Label": "Projections"
diff --git a/src/EventStore.ClusterNode/ui-assets/js/queue-dashboard.js b/src/EventStore.ClusterNode/ui-assets/js/queue-dashboard.js
index d4602e7bec..4e059393a4 100644
--- a/src/EventStore.ClusterNode/ui-assets/js/queue-dashboard.js
+++ b/src/EventStore.ClusterNode/ui-assets/js/queue-dashboard.js
@@ -2,7 +2,7 @@
"use strict";
var pollIntervalMs = 1000;
- var tcpPageSize = 5;
+ var networkPageSize = 5;
var dashboards = new WeakMap();
function start() {
@@ -20,8 +20,10 @@
expanded: parseExpanded(root),
blocks: [],
queues: [],
- tcpConnections: [],
- tcpPage: 0,
+ networkConnections: [],
+ networkSamples: new Map(),
+ networkPage: 0,
+ replicationConnections: [],
timer: null,
inFlight: false
};
@@ -33,10 +35,10 @@
applyPayload(state, initialPayload);
root.addEventListener("click", function (event) {
- var tcpPager = event.target.closest("[data-tcp-page]");
- if (tcpPager && root.contains(tcpPager)) {
+ var networkPager = event.target.closest("[data-network-page]");
+ if (networkPager && root.contains(networkPager)) {
event.preventDefault();
- changeTcpPage(state, tcpPager.getAttribute("data-tcp-page"));
+ changeNetworkPage(state, networkPager.getAttribute("data-network-page"));
return;
}
@@ -124,21 +126,14 @@
var parsed = parseQueues(payload);
state.queues = parsed.queues;
state.blocks = parsed.blocks;
- state.tcpConnections = parseTcpConnections(payload);
+ state.networkConnections = parseNetworkConnections(payload, state);
+ state.replicationConnections = parseReplicationConnections(payload);
setStatus(
state.root,
payload.message ? "Live stats unavailable" : "Live stats",
payload.message || "Updated " + formatTime(new Date()));
+ setNetworkStatus(state.root, payload.message, state.networkConnections.length);
render(state);
-
- if (state.root.querySelector("[data-tcp-table-body]")) {
- var tcpMessage = payload.tcpMessage || payload.message || "";
- setTcpStatus(
- state.root,
- tcpMessage ? "TCP unavailable" : "TCP live",
- tcpMessage || state.tcpConnections.length + " connection" + (state.tcpConnections.length === 1 ? "" : "s"));
- renderTcpTable(state.root, state.tcpConnections, state);
- }
}
async function refresh(state) {
@@ -159,11 +154,11 @@
} catch (error) {
state.queues = [];
state.blocks = [];
- state.tcpConnections = [];
+ state.networkConnections = [];
+ state.replicationConnections = [];
setStatus(state.root, "Live stats unavailable", friendlyMessage(error));
- setTcpStatus(state.root, "TCP unavailable", friendlyMessage(error));
+ setNetworkStatus(state.root, friendlyMessage(error), 0);
render(state);
- renderTcpTable(state.root, state.tcpConnections, state);
} finally {
state.inFlight = false;
}
@@ -208,32 +203,72 @@
};
}
- function parseTcpConnections(payload) {
- var rows = payload && Array.isArray(payload.tcpConnections) ? payload.tcpConnections : [];
-
- return rows.map(function (row) {
- var id = readFieldString(row, ["connectionId", "ConnectionId"], "");
- var totalBytesSent = readFieldNumber(row, ["totalBytesSent", "TotalBytesSent"]);
- var totalBytesReceived = readFieldNumber(row, ["totalBytesReceived", "TotalBytesReceived"]);
+ function parseNetworkConnections(payload, state) {
+ var rows = payload && Array.isArray(payload.nodeConnections) ? payload.nodeConnections : [];
+ var now = Date.now();
+ var nextSamples = new Map();
+ var connections = rows.map(function (row) {
+ var id = readString(row.connectionId, "");
+ var totalBytesSent = readNumber(row.totalBytesSent);
+ var totalBytesReceived = readNumber(row.totalBytesReceived);
+ var previous = state.networkSamples.get(id);
+ var elapsedSeconds = previous ? Math.max(0.001, (now - previous.observedAt) / 1000) : 0;
+ var sentRate = previous ? Math.max(0, totalBytesSent - previous.totalBytesSent) / elapsedSeconds : 0;
+ var receivedRate = previous
+ ? Math.max(0, totalBytesReceived - previous.totalBytesReceived) / elapsedSeconds
+ : 0;
+
+ nextSamples.set(id, {
+ observedAt: now,
+ totalBytesSent: totalBytesSent,
+ totalBytesReceived: totalBytesReceived
+ });
return {
id: id,
- clientConnectionName: readFieldString(row, ["clientConnectionName", "ClientConnectionName"], ""),
- remoteEndPoint: readFieldString(row, ["remoteEndPoint", "RemoteEndPoint"], ""),
- localEndPoint: readFieldString(row, ["localEndPoint", "LocalEndPoint"], ""),
+ clientName: readString(row.clientName, ""),
+ application: readString(row.application, "Awaiting request"),
+ protocol: readString(row.protocol, ""),
+ remoteEndPoint: readString(row.remoteEndPoint, ""),
+ localEndPoint: readString(row.localEndPoint, ""),
+ connectedAt: readString(row.connectedAt, ""),
+ isTls: Boolean(row.isTls),
totalBytesSent: totalBytesSent,
totalBytesReceived: totalBytesReceived,
- pendingSendBytes: readFieldNumber(row, ["pendingSendBytes", "PendingSendBytes"]),
- pendingReceivedBytes: readFieldNumber(row, ["pendingReceivedBytes", "PendingReceivedBytes"]),
- sentRate: readFieldNumber(row, ["sentRate", "SentRate"]),
- receivedRate: readFieldNumber(row, ["receivedRate", "ReceivedRate"]),
- isExternalConnection: readFieldBoolean(row, ["isExternalConnection", "IsExternalConnection"]),
- isSslConnection: readFieldBoolean(row, ["isSslConnection", "IsSslConnection"])
+ pendingSendBytes: readNumber(row.pendingSendBytes),
+ pendingReceivedBytes: readNumber(row.pendingReceivedBytes),
+ sentRate: sentRate,
+ receivedRate: receivedRate
};
}).sort(function (left, right) {
- return left.clientConnectionName.localeCompare(right.clientConnectionName, undefined, { sensitivity: "base" }) ||
+ return left.clientName.localeCompare(right.clientName, undefined, { sensitivity: "base" }) ||
left.id.localeCompare(right.id, undefined, { sensitivity: "base" });
});
+
+ state.networkSamples = nextSamples;
+ return connections;
+ }
+
+ function parseReplicationConnections(payload) {
+ var rows = payload && Array.isArray(payload.replicationConnections)
+ ? payload.replicationConnections
+ : [];
+
+ return rows.map(function (row) {
+ return {
+ subscriptionId: readString(row.subscriptionId, ""),
+ connectionId: readString(row.connectionId, ""),
+ endpoint: readString(row.endpoint, ""),
+ totalBytesSent: readNumber(row.totalBytesSent),
+ totalBytesReceived: readNumber(row.totalBytesReceived),
+ pendingSendBytes: readNumber(row.pendingSendBytes),
+ pendingReceivedBytes: readNumber(row.pendingReceivedBytes),
+ sendQueueSize: readNumber(row.sendQueueSize)
+ };
+ }).sort(function (left, right) {
+ return left.endpoint.localeCompare(right.endpoint, undefined, { sensitivity: "base" }) ||
+ left.connectionId.localeCompare(right.connectionId, undefined, { sensitivity: "base" });
+ });
}
function buildBlocks(queues) {
@@ -311,6 +346,8 @@
renderSpotlightTable(state.root, state.queues);
renderQueueTable(state);
renderDashboardSnapshot(state.root, state.blocks);
+ renderNetworkTable(state);
+ renderReplicationTable(state.root, state.replicationConnections);
}
function updateMetrics(root, queues) {
@@ -451,32 +488,33 @@
node.textContent = lines.join("\n");
}
- function renderTcpTable(root, connections, state) {
- var tbody = root.querySelector("[data-tcp-table-body]");
+ function renderNetworkTable(state) {
+ var tbody = state.root.querySelector("[data-network-table-body]");
if (!tbody)
return;
replaceChildren(tbody);
- if (connections.length === 0) {
+ if (state.networkConnections.length === 0) {
var empty = element("tr");
- var cell = element("td", "px-5 py-4 text-es-muted");
- cell.colSpan = 10;
- cell.textContent = "No TCP connections are currently reported.";
- empty.appendChild(cell);
+ var emptyCell = element("td", "px-5 py-4 text-es-muted");
+ emptyCell.colSpan = 10;
+ emptyCell.textContent = "No active shared-endpoint connections.";
+ empty.appendChild(emptyCell);
tbody.appendChild(empty);
- updateTcpPagination(root, state, 0);
+ updateNetworkPagination(state, 0);
return;
}
- var pageCount = Math.ceil(connections.length / tcpPageSize);
- state.tcpPage = Math.min(state.tcpPage, Math.max(0, pageCount - 1));
- var pageStart = state.tcpPage * tcpPageSize;
- connections.slice(pageStart, pageStart + tcpPageSize).forEach(function (connection) {
+ var pageCount = Math.ceil(state.networkConnections.length / networkPageSize);
+ state.networkPage = Math.min(state.networkPage, pageCount - 1);
+ var offset = state.networkPage * networkPageSize;
+ state.networkConnections.slice(offset, offset + networkPageSize).forEach(function (connection) {
var row = element("tr", "bg-white/70 text-es-ink");
- appendText(row, "td", connection.id || "", "max-w-[14rem] truncate px-5 py-4 font-mono text-xs text-es-muted");
- appendText(row, "td", displayMessage(connection.clientConnectionName), "px-5 py-4 font-bold text-es-ink");
- appendText(row, "td", tcpTypeLabel(connection), "px-5 py-4 text-es-muted");
- appendText(row, "td", displayMessage(connection.remoteEndPoint), "px-5 py-4 font-mono text-xs text-es-muted");
+ appendText(row, "td", displayMessage(connection.id), "max-w-[14rem] truncate px-5 py-4 font-mono text-xs text-es-muted");
+ appendText(row, "td", displayMessage(connection.clientName), "max-w-[16rem] truncate px-5 py-4 font-bold text-es-ink");
+ appendText(row, "td", networkTypeLabel(connection), "px-5 py-4 text-es-muted");
+ var endpointCell = appendText(row, "td", displayMessage(connection.remoteEndPoint), "px-5 py-4 font-mono text-xs text-es-muted");
+ endpointCell.title = networkConnectionDetails(connection);
appendText(row, "td", formatByteRate(connection.sentRate), "px-5 py-4 text-right font-mono text-es-ink");
appendText(row, "td", formatInteger(connection.totalBytesSent), "px-5 py-4 text-right font-mono text-es-ink");
appendText(row, "td", formatInteger(connection.pendingSendBytes), "px-5 py-4 text-right font-mono text-es-ink");
@@ -485,17 +523,80 @@
appendText(row, "td", formatInteger(connection.pendingReceivedBytes), "px-5 py-4 text-right font-mono text-es-ink");
tbody.appendChild(row);
});
- updateTcpPagination(root, state, pageCount);
+
+ updateNetworkPagination(state, pageCount);
+ }
+
+ function renderReplicationTable(root, connections) {
+ var tbody = root.querySelector("[data-replication-table-body]");
+ if (!tbody)
+ return;
+
+ replaceChildren(tbody);
+ if (connections.length === 0) {
+ var empty = element("tr");
+ var emptyCell = element("td", "px-5 py-4 text-es-muted");
+ emptyCell.colSpan = 6;
+ emptyCell.textContent = "No active gRPC replication connections.";
+ empty.appendChild(emptyCell);
+ tbody.appendChild(empty);
+ return;
+ }
+
+ connections.forEach(function (connection) {
+ var row = element("tr", "bg-white/70 text-es-ink");
+ appendText(row, "td", connection.endpoint, "px-5 py-4 font-bold text-es-ink");
+ var connectionCell = appendText(row, "td", displayMessage(connection.connectionId), "px-5 py-4 font-mono text-xs text-es-muted");
+ connectionCell.title = "Subscription " + displayMessage(connection.subscriptionId);
+ appendText(row, "td", formatInteger(connection.totalBytesSent), "px-5 py-4 text-right font-mono text-es-ink");
+ appendText(row, "td", formatInteger(connection.totalBytesReceived), "px-5 py-4 text-right font-mono text-es-ink");
+ appendText(row, "td", formatInteger(connection.pendingSendBytes + connection.pendingReceivedBytes), "px-5 py-4 text-right font-mono text-es-ink");
+ appendText(row, "td", formatInteger(connection.sendQueueSize), "px-5 py-4 text-right font-mono text-es-ink");
+ tbody.appendChild(row);
+ });
}
- function changeTcpPage(state, direction) {
- var pageCount = Math.ceil(state.tcpConnections.length / tcpPageSize);
+ function changeNetworkPage(state, direction) {
+ var pageCount = Math.ceil(state.networkConnections.length / networkPageSize);
if (direction === "previous")
- state.tcpPage = Math.max(0, state.tcpPage - 1);
+ state.networkPage = Math.max(0, state.networkPage - 1);
else if (direction === "next")
- state.tcpPage = Math.min(Math.max(0, pageCount - 1), state.tcpPage + 1);
+ state.networkPage = Math.min(Math.max(0, pageCount - 1), state.networkPage + 1);
+
+ renderNetworkTable(state);
+ }
+
+ function updateNetworkPagination(state, pageCount) {
+ var status = state.root.querySelector("[data-network-page-status]");
+ var previous = state.root.querySelector('[data-network-page="previous"]');
+ var next = state.root.querySelector('[data-network-page="next"]');
+ var hasRows = state.networkConnections.length > 0;
+
+ if (status)
+ status.textContent = hasRows ? "Page " + (state.networkPage + 1) + " of " + pageCount : "No pages";
+ if (previous)
+ previous.disabled = !hasRows || state.networkPage === 0;
+ if (next)
+ next.disabled = !hasRows || state.networkPage >= pageCount - 1;
+ }
+
+ function setNetworkStatus(root, message, connectionCount) {
+ var status = root.querySelector("[data-network-status]");
+ if (!status)
+ return;
+
+ status.textContent = message
+ ? "Network unavailable · " + message
+ : "Network live · " + connectionCount + " connection" + (connectionCount === 1 ? "" : "s");
+ }
+
+ function networkTypeLabel(connection) {
+ return connection.application + " · " + connection.protocol + " · " + (connection.isTls ? "TLS" : "Cleartext");
+ }
- renderTcpTable(state.root, state.tcpConnections, state);
+ function networkConnectionDetails(connection) {
+ var connected = connection.connectedAt ? new Date(connection.connectedAt).toLocaleString() : "unknown";
+ return "Local " + connection.localEndPoint + ", connected " + connected;
}
function queueTableRow(queue, block, state) {
@@ -554,37 +655,6 @@
updatedNode.textContent = updated;
}
- function setTcpStatus(root, status, detail) {
- var statusNode = root.querySelector("[data-tcp-status]");
- if (!statusNode)
- return;
-
- statusNode.textContent = detail ? status + " · " + detail : status;
- }
-
- function updateTcpPagination(root, state, pageCount) {
- var status = root.querySelector("[data-tcp-page-status]");
- var previous = root.querySelector('[data-tcp-page="previous"]');
- var next = root.querySelector('[data-tcp-page="next"]');
- var hasRows = state.tcpConnections.length > 0;
-
- if (status)
- status.textContent = hasRows
- ? "Page " + (state.tcpPage + 1) + " of " + pageCount
- : "No pages";
-
- if (previous)
- previous.disabled = !hasRows || state.tcpPage === 0;
-
- if (next)
- next.disabled = !hasRows || state.tcpPage >= pageCount - 1;
- }
-
- function tcpTypeLabel(connection) {
- return (connection.isExternalConnection ? "External" : "Internal") + " " +
- (connection.isSslConnection ? "TLS" : "TCP");
- }
-
function currentLastMessage(queue) {
return queue.kind === "group"
? "n/a"
@@ -662,36 +732,6 @@
return Number.isFinite(number) ? number : 0;
}
- function readFieldString(source, keys, fallback) {
- var value = readField(source, keys);
- if (value === null || value === undefined)
- return fallback;
-
- var text = String(value);
- return text.trim() ? text : fallback;
- }
-
- function readFieldNumber(source, keys) {
- return readNumber(readField(source, keys));
- }
-
- function readFieldBoolean(source, keys) {
- var value = readField(source, keys);
- return value === true || String(value).toLowerCase() === "true";
- }
-
- function readField(source, keys) {
- if (!source || typeof source !== "object")
- return undefined;
-
- for (var i = 0; i < keys.length; i++) {
- if (Object.prototype.hasOwnProperty.call(source, keys[i]))
- return source[keys[i]];
- }
-
- return undefined;
- }
-
function sum(rows, key) {
return rows.reduce(function (total, row) {
return total + row[key];
diff --git a/src/EventStore.Core.Tests/Regression/GrpcOnlySurfaceParityTests.cs b/src/EventStore.Core.Tests/Regression/GrpcOnlySurfaceParityTests.cs
new file mode 100644
index 0000000000..b8bfbcedfa
--- /dev/null
+++ b/src/EventStore.Core.Tests/Regression/GrpcOnlySurfaceParityTests.cs
@@ -0,0 +1,112 @@
+using System;
+using System.IO.Pipelines;
+using System.Net;
+using System.Text.Json;
+using System.Threading.Tasks;
+using EventStore.ClusterNode.Components.Services;
+using Microsoft.AspNetCore.Connections;
+using Microsoft.AspNetCore.Http;
+using NUnit.Framework;
+
+namespace EventStore.Core.Tests.Regression;
+
+[TestFixture]
+public class GrpcOnlySurfaceParityTests
+{
+ [Test]
+ public void observability_payload_preserves_replication_visibility()
+ {
+ var page = QueueDashboardPage.Success(Array.Empty());
+ using var payload = JsonDocument.Parse(page.ClientPayloadJson);
+
+ Assert.That(payload.RootElement.TryGetProperty("replicationConnections", out _), Is.True);
+ Assert.That(payload.RootElement.TryGetProperty("nodeConnections", out _), Is.True);
+ }
+
+ [Test]
+ public async Task http_connections_are_visible_only_while_active()
+ {
+ var tracker = new NodeConnectionTracker();
+ var connection = new DefaultConnectionContext("grpc-connection")
+ {
+ LocalEndPoint = new IPEndPoint(IPAddress.Loopback, 2113),
+ RemoteEndPoint = new IPEndPoint(IPAddress.Loopback, 50123)
+ };
+ var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var trafficObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var incoming = new Pipe();
+ var outgoing = new Pipe();
+ connection.Transport = new TestDuplexPipe(incoming.Reader, outgoing.Writer);
+ var tracking = tracker.Track(connection, async trackedConnection =>
+ {
+ tracker.ObserveRequest(
+ trackedConnection.ConnectionId,
+ "HTTP/2",
+ isGrpc: true,
+ connectionName: "projection-catchup",
+ userAgent: "grpc-dotnet");
+ await trackedConnection.Transport.Output.WriteAsync(new byte[3]);
+ await incoming.Writer.WriteAsync(new byte[5]);
+ var read = await trackedConnection.Transport.Input.ReadAsync();
+ trackedConnection.Transport.Input.AdvanceTo(read.Buffer.GetPosition(2), read.Buffer.End);
+ trafficObserved.SetResult();
+ await release.Task;
+ }, isTls: true);
+ await trafficObserved.Task;
+
+ Assert.That(tracker.Snapshot(), Has.One.Matches(x =>
+ x.ConnectionId == "grpc-connection" && x.IsTls &&
+ x.ClientName == "projection-catchup" && x.Application == "gRPC" && x.Protocol == "HTTP/2" &&
+ x.TotalBytesSent == 3 && x.TotalBytesReceived == 2 && x.PendingReceivedBytes == 3));
+
+ tracker.ObserveRequest(
+ connection.ConnectionId,
+ "HTTP/2",
+ isGrpc: false,
+ connectionName: "",
+ userAgent: "browser");
+ Assert.That(tracker.Snapshot(), Has.One.Matches(x =>
+ x.ClientName == "projection-catchup" && x.Application == "HTTP and gRPC"));
+
+ release.SetResult();
+ await tracking;
+
+ Assert.That(tracker.Snapshot(), Is.Empty);
+ }
+
+ [TestCase(2113, "/streams", true)]
+ [TestCase(2113, "/event_store.replication.Replication/Replicate", false)]
+ [TestCase(1112, "/streams", false)]
+ [TestCase(1112, "/event_store.replication.Replication/Replicate", true)]
+ public void replication_listener_isolated_from_the_public_node_listener(
+ int localPort,
+ string path,
+ bool expected)
+ {
+ var policy = new ReplicationEndpointPolicy(new IPEndPoint(IPAddress.Loopback, 1112));
+ var context = new DefaultHttpContext();
+ context.Connection.LocalIpAddress = IPAddress.Loopback;
+ context.Connection.LocalPort = localPort;
+ context.Request.Path = path;
+
+ Assert.That(policy.Allows(context), Is.EqualTo(expected));
+ }
+
+ [Test]
+ public void wildcard_replication_binding_matches_the_resolved_local_address()
+ {
+ var policy = new ReplicationEndpointPolicy(new IPEndPoint(IPAddress.Any, 1112));
+ var context = new DefaultHttpContext();
+ context.Connection.LocalIpAddress = IPAddress.Parse("192.0.2.1");
+ context.Connection.LocalPort = 1112;
+ context.Request.Path = "/event_store.replication.Replication/Replicate";
+
+ Assert.That(policy.Allows(context), Is.True);
+ }
+
+ private sealed class TestDuplexPipe(PipeReader input, PipeWriter output) : IDuplexPipe
+ {
+ public PipeReader Input { get; } = input;
+ public PipeWriter Output { get; } = output;
+ }
+}
diff --git a/src/EventStore.Core.Tests/Services/Transport/Grpc/MonitoringTests/ConnectionStatsTests.cs b/src/EventStore.Core.Tests/Services/Transport/Grpc/MonitoringTests/ConnectionStatsTests.cs
new file mode 100644
index 0000000000..aeeda7f815
--- /dev/null
+++ b/src/EventStore.Core.Tests/Services/Transport/Grpc/MonitoringTests/ConnectionStatsTests.cs
@@ -0,0 +1,114 @@
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using EventStore.Client.Monitoring;
+using EventStore.Core.Bus;
+using EventStore.Core.Messaging;
+using EventStore.Core.Services.Transport.Grpc;
+using Grpc.Core;
+using NUnit.Framework;
+
+namespace EventStore.Core.Tests.Services.Transport.Grpc.MonitoringTests;
+
+[TestFixture]
+public class ConnectionStatsTests
+{
+ private static readonly DateTimeOffset ConnectedAt = new(2026, 9, 12, 12, 34, 56, TimeSpan.Zero);
+ private ConnectionStatsResp _response;
+
+ [SetUp]
+ public async Task SetUp()
+ {
+ var provider = new StubConnectionStatsProvider([
+ new ConnectionStatsSnapshot(
+ "connection-1",
+ "127.0.0.1:50123",
+ "127.0.0.1:1112",
+ "projection-catchup",
+ "gRPC",
+ "HTTP/2",
+ true,
+ ConnectedAt,
+ 123,
+ 456,
+ 7,
+ 8)
+ ]);
+ var serviceType = typeof(Message).Assembly.GetType(
+ "EventStore.Core.Services.Transport.Grpc.Monitoring",
+ throwOnError: true);
+ var service = Activator.CreateInstance(
+ serviceType!,
+ BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
+ binder: null,
+ args: [new NoOpPublisher(), provider],
+ culture: null);
+
+ var task = (Task)serviceType!.GetMethod(
+ nameof(EventStore.Client.Monitoring.Monitoring.MonitoringBase.ConnectionStats))!
+ .Invoke(service, [new ConnectionStatsReq(), TestServerCallContext.Instance])!;
+ _response = await task;
+ }
+
+ [Test]
+ public void should_map_the_active_connection()
+ {
+ var connection = _response.Connections[0];
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(connection.ConnectionId, Is.EqualTo("connection-1"));
+ Assert.That(connection.RemoteEndpoint, Is.EqualTo("127.0.0.1:50123"));
+ Assert.That(connection.LocalEndpoint, Is.EqualTo("127.0.0.1:1112"));
+ Assert.That(connection.ClientConnectionName, Is.EqualTo("projection-catchup"));
+ Assert.That(connection.Application, Is.EqualTo("gRPC"));
+ Assert.That(connection.Protocol, Is.EqualTo("HTTP/2"));
+ Assert.That(connection.IsTls, Is.True);
+ Assert.That(connection.ConnectedAt.ToDateTimeOffset(), Is.EqualTo(ConnectedAt));
+ Assert.That(connection.TotalBytesSent, Is.EqualTo(123));
+ Assert.That(connection.TotalBytesReceived, Is.EqualTo(456));
+ Assert.That(connection.PendingSendBytes, Is.EqualTo(7));
+ Assert.That(connection.PendingReceivedBytes, Is.EqualTo(8));
+ });
+ }
+
+ private sealed class StubConnectionStatsProvider(IReadOnlyList connections)
+ : IConnectionStatsProvider
+ {
+ public IReadOnlyList Snapshot() => connections;
+ }
+
+ private sealed class NoOpPublisher : IPublisher
+ {
+ public void Publish(Message message)
+ {
+ }
+ }
+
+ private sealed class TestServerCallContext : ServerCallContext
+ {
+ public static readonly TestServerCallContext Instance = new();
+
+ private TestServerCallContext()
+ {
+ }
+
+ protected override string MethodCore =>
+ nameof(EventStore.Client.Monitoring.Monitoring.MonitoringBase.ConnectionStats);
+ protected override string HostCore => "localhost";
+ protected override string PeerCore => "ipv4:127.0.0.1:0";
+ protected override DateTime DeadlineCore => DateTime.MaxValue;
+ protected override Metadata RequestHeadersCore { get; } = new();
+ protected override CancellationToken CancellationTokenCore => CancellationToken.None;
+ protected override Metadata ResponseTrailersCore { get; } = new();
+ protected override Status StatusCore { get; set; }
+ protected override WriteOptions WriteOptionsCore { get; set; }
+ protected override AuthContext AuthContextCore => new(null, new Dictionary>());
+ protected override IDictionary