# KubeMQ Documentation (/) **One broker. Every protocol.** KubeMQ is a Kubernetes-native enterprise message broker that ships as a single binary. Your existing Kafka, RabbitMQ, MQTT, AMQP, STOMP, AWS SQS/SNS, and Google Cloud Pub/Sub clients connect to it unchanged — and native SDKs in 10 languages give you queues, pub/sub, and request-reply over gRPC, REST, and WebSocket. No ZooKeeper, no external database, no sidecars. ## What brings you here? [#what-brings-you-here] ## Keep your clients — change one connection string [#keep-your-clients--change-one-connection-string] KubeMQ speaks each protocol natively, on its standard port. The client that talks to Kafka or RabbitMQ today talks to KubeMQ tomorrow. ## Built on four messaging patterns [#built-on-four-messaging-patterns] Every protocol and every SDK rides the same core patterns. ## Why teams choose KubeMQ [#why-teams-choose-kubemq] * **Built for Kubernetes** — [Helm charts and an operator](/deploy/kubernetes-helm), KEDA autoscaling, and clustering designed for safe rollouts. * **Enterprise-grade** — [role-based access control, audit logging](/configure), FIPS builds, and replicated storage with a strict zero-loss durability mode. * **AI-ready** — built-in [Model Context Protocol (MCP) and Agent-to-Agent (A2A) gateways](/aiway) for agentic workloads. * **Simple to run** — one binary, a [web dashboard](/operate), and the [kmq CLI](/deploy/cli) for messaging, administration, and observability. ## Explore the documentation [#explore-the-documentation] *** Questions? [Open an issue on GitHub](https://github.com/kubemq-io/kubemq-community) or [contact support](https://kubemq.io/contact-us/). Building with AI agents? This site ships [llms.txt](/llms.txt) for machine consumption. # KubeMQ Aiway (/aiway) **KubeMQ Aiway is an AI Agents Fabric** — a switchboard that lets AI agents and AI applications find each other, talk to each other, and stream results in real time, with the reliability of an enterprise message broker underneath. **A2A** and **MCP** are the two doors into the same fabric: register an agent, discover it by capability, invoke it synchronously or as a live stream, and orchestrate the whole thing from an LLM. ## What is KubeMQ Aiway [#what-is-kubemq-aiway] Aiway is a switchboard for AI agents. Agents register what they can do, callers find them by capability, and the fabric routes every call — synchronous or streamed — over an enterprise message broker. KubeMQ does the discovery, routing, protocol bridging, streaming, security, high availability, and observability so that no caller and no agent has to. The agents themselves stay **plain HTTP services** — no KubeMQ SDK, no broker library, no protobuf, no special runtime. An agent is just an HTTP server that speaks JSON-RPC 2.0 (and, optionally, Server-Sent Events). Aiway bridges the broker to that endpoint for you, so you can wrap an existing internal microservice as a first-class AI agent in minutes. Two doors open into the same fabric: * **A2A (Agent-to-Agent)** — agents register their endpoint and capabilities; any caller discovers them *by capability* and invokes them, synchronously or as a live stream. * **MCP (Model Context Protocol)** — LLM hosts (Claude Desktop, IDEs, agent frameworks) plug in over the open MCP standard and gain both **messaging** and **agent orchestration** through a single endpoint. ## The big picture [#the-big-picture] A caller — over A2A HTTP, gRPC/REST, or an MCP host — reaches the fabric, which discovers the target agent, bridges the call to its plain-HTTP endpoint, and relays the response (or a live SSE stream) back. The agents never touch the broker directly. *Callers reach Aiway over A2A, gRPC/REST, or MCP; the Agent Bridge POSTs to each agent's plain-HTTP URL and the streaming relay carries live events back.* The **Agent Bridge** is the per-agent virtual subscriber that translates broker messages into HTTP calls to your agent (and relays responses and SSE streams back). It is why agents need zero KubeMQ dependency: the bridge does all the broker and protocol translation, so the agent only ever sees an HTTP `POST`. The MCP **agent-bridge tools** (`agent_list`, `agent_info`, `agent_send`, `agent_query`, covered under [MCP](/aiway/mcp)) invoke agents *through* this same Agent Bridge — same concept at two layers, not two meanings. The tools are the LLM-facing door; the Agent Bridge is the per-agent connector they (and A2A callers) route through. ## One fabric, three planes [#one-fabric-three-planes] Aiway is not "A2A plus a separate MCP server." It is **one fabric** with three planes that share the same core and compose freely — an LLM over MCP can enqueue durable work *and* call an A2A agent in the same session, and a gRPC backend can invoke the same agent an LLM just used. | Plane | Door | What it does | | ------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | **Agent plane** | A2A | Agents register, get discovered by capability, get invoked, and stream results. | | **LLM plane** | MCP | LLM hosts discover and drive both agents and messaging through one endpoint. | | **Messaging plane** | KubeMQ core | Durable queues, pub/sub events, a persisted/replayable event log, and RPC (commands/queries) — the same primitives the MCP messaging tools expose. | ## Two doors in [#two-doors-in] ## The enterprise envelope [#the-enterprise-envelope] What makes Aiway production-grade rather than a demo is the envelope around both doors — the same auth, HA, guardrails, and observability that the rest of KubeMQ runs on: * **Security** — token-based authentication, per-agent ownership (only the registrant can modify or remove an agent), origin validation, and TLS / mutual-TLS. * **High availability** — the agent registry is replicated to every node, so an agent registered on one node is reachable and discoverable cluster-wide; if a node fails, its agents expire and re-register elsewhere and the roster self-heals. * **Guardrails & backpressure** — TTL liveness, default and max request timeouts, a max-agents ceiling, a per-agent concurrency cap, a max agent-response size, and traffic gating that rejects calls when the core isn't ready instead of hanging. * **Observability** — per-agent request, error, latency, and active-stream metrics (Prometheus + a durable per-agent store) plus a live operations dashboard, so you can govern an entire agent fleet. Both doors run on the [shared HTTP server](/connectors/concepts/shared-http-server) (port 9090\) and inherit its unified middleware. The shared foundations are documented once under Connectors: ## Next steps [#next-steps] # Use cases (/aiway/use-cases) New to Aiway? Start with the [overview](/aiway). KubeMQ Aiway is an AI Agents Fabric: agents register, callers discover them by capability, and anyone invokes them — synchronously or as a live stream — over an enterprise message broker. The scenarios below show where that fabric is uniquely strong, grouped by the problem they solve. Each one names the scenario, how Aiway delivers it, and why it is hard to get this cleanly any other way. A few terms recur: * **Agent Bridge** — the per-agent virtual subscriber KubeMQ spawns for each registered agent. It translates broker messages into HTTP POSTs to your agent's URL and relays the response (or live stream) back, which is why agents need no KubeMQ SDK. See [Building agents](/aiway/a2a/guides/building-agents). * **Skill tags** — the capability labels (`tags[]`) an agent advertises on each skill, used to discover agents by what they can do rather than by URL. See the [Agent registry](/aiway/a2a/registry). * **The transport-vs-application error split** — Aiway returns `Executed: false` when the agent never processed the request (unreachable, timeout, oversized response), so it is **safe to retry**; an `Executed: true` response with a JSON-RPC error means the agent ran and returned an error, so **don't blind-retry**. See [Error handling](/aiway/a2a/error-handling). ## Onboarding & orchestration [#onboarding--orchestration] ### Zero-SDK agent onboarding [#zero-sdk-agent-onboarding] **Scenario:** Turn an existing internal HTTP microservice — a Python summarizer, a Go OCR service, a Node scraper — into a first-class AI agent in minutes. **How:** the service stays a plain HTTP server. It registers an Agent Card; the Agent Bridge does all protocol translation. **Why Aiway:** no KubeMQ client library, broker SDK, protobuf, or special runtime in the agent — most frameworks force an SDK and a specific language, while Aiway onboards *any* HTTP service in any stack. Walk it end-to-end in the [Tutorial](/aiway/tutorial). ### LLM as orchestrator over a real agent fleet [#llm-as-orchestrator-over-a-real-agent-fleet] **Scenario:** An LLM host (Claude Desktop, an IDE, an agent framework) plans a task, discovers the right specialist agents, invokes them, and persists intermediate results — all from one MCP connection. **How:** `agent_list` / `agent_info` to discover, `agent_send` / `agent_query` to invoke, `events_store_publish` to log, `queue_send` to hand off — same session, same fabric. **Why Aiway:** the LLM gets messaging **and** agent orchestration through one open-standard endpoint, with enterprise auth and high availability — no glue code, no custom tool server per agent. See the [MCP agent-bridge tools](/aiway/mcp/tools/agent-bridge). ### Polyglot, mixed-protocol orchestration [#polyglot-mixed-protocol-orchestration] **Scenario:** A gRPC backend, a REST web app, and an LLM all call the *same* agent during one workflow. **How:** every transport — the A2A HTTP gateway, gRPC, REST, the MCP bridge — converges on the same Agent Bridge path. **Why Aiway:** one agent, callable from every protocol your stack already speaks, with no per-protocol adapters or duplicated endpoints. See [Synchronous messaging](/aiway/a2a/sync-messaging). ## Real-time & long-running work [#real-time--long-running-work] ### Real-time long-running tasks with live progress [#real-time-long-running-tasks-with-live-progress] **Scenario:** A caller kicks off a multi-minute job — document generation, transcode, deep research, multi-step reasoning — and watches `task.status` and `task.artifact` events stream in with a progress bar. **How:** `message/stream` opens an SSE relay with keepalive, idle timeout, and auto-cancel when the caller disconnects. **Why Aiway:** streaming, cancellation, and backpressure (per-agent concurrency cap, response-size limits) are built in — *production* streaming, not a fragile long-poll. See [Streaming (SSE)](/aiway/a2a/streaming). ### Human-in-the-loop and approval flows [#human-in-the-loop-and-approval-flows] **Scenario:** An agent streams a draft, a human reviews it, and on approval the workflow resumes or hands off to another agent. **How:** streaming for live drafts; durable queues or persisted events park the task while it awaits a decision; a second agent invocation continues the work. **Why Aiway:** real-time streaming *and* durable hand-off coexist on one fabric, so long-lived, human-gated workflows don't need a separate workflow engine. See the [Streaming task pipeline](/aiway/a2a/scenarios/streaming-task-pipeline). ### Ephemeral, on-demand agents [#ephemeral-on-demand-agents] **Scenario:** Spin up specialist agents on demand — per job, per tenant — have them register, take work, then disappear, without callers caring. **How:** an agent registers on start, heartbeats while alive, and TTL-expires when gone; discovery and routing always reflect the live set. **Why Aiway:** the self-healing, capability-indexed roster makes transient agents first-class — callers always see only what is actually available right now. See the [Agent registry](/aiway/a2a/registry). ## Routing & multi-tenancy [#routing--multi-tenancy] ### Capability-based dynamic routing [#capability-based-dynamic-routing] **Scenario:** "Find an agent that can `translate-legal` for `de-DE`, prefer the lowest-latency one, call it, and fall back to another if it errors." **How:** discover via `GET /agents?skill_tags=…` (or `agent_list`), select at runtime, invoke, and use the transport-vs-application error split to fail over. **Why Aiway:** agents are addressed **by capability**, not by URL — new agents appear in discovery the moment they register, and the routing logic never changes. See the [Multi-agent gateway](/aiway/a2a/scenarios/multi-agent-gateway). ### Multi-tenant agent directory [#multi-tenant-agent-directory] **Scenario:** Multiple teams publish agents into one shared directory; each team can manage only its own; everyone can discover and call them, subject to authorization. **How:** ownership is bound to the registering identity (only the owner can modify or deregister; a blank owner fails closed); discovery is open, invocation is authenticated. **Why Aiway:** a built-in, fail-closed ownership model turns a shared registry into a safe internal agent marketplace, without bespoke RBAC plumbing. See the [Agent registry](/aiway/a2a/registry). ### Distributed, multi-region agent mesh [#distributed-multi-region-agent-mesh] **Scenario:** Agents run close to data or users in different nodes or regions; callers anywhere reach any agent. **How:** the registry replicates cluster-wide; agents register on a local node; the cluster routes calls to the owning node; on node loss, agents re-register elsewhere. **Why Aiway:** location transparency and a self-healing registry come from the broker, so callers never track where an agent physically runs. See the [A2A architecture](/aiway/a2a/architecture). ## Reliability & governance [#reliability--governance] ### Resilient, retry-aware agent calls [#resilient-retry-aware-agent-calls] **Scenario:** A flaky agent or a slow network shouldn't corrupt a workflow, and the orchestrator needs to know *what* failed. **How:** the `Executed: false` (transport, safe-retry) vs `Executed: true` plus error (application, don't blind-retry) split, with specific A2A error codes and per-agent timeouts. **Why Aiway:** the fabric encodes retry-safe semantics at the protocol level, so orchestration logic gets clean, correct failure signals for free. See [Error handling](/aiway/a2a/error-handling). ### Secure enterprise agent gateway [#secure-enterprise-agent-gateway] **Scenario:** Expose agents to partners or teams behind authentication, mutual TLS, and origin controls, and reject traffic when the platform is degraded. **How:** the shared security chain — auth, ownership, TLS/mTLS, trusted origins, traffic gating — wraps every agent call. **Why Aiway:** an enterprise security envelope is built into the fabric, so you don't bolt a gateway in front of every agent. See [Agent authentication](/aiway/a2a/guides/authentication). ### Governed agent fleet [#governed-agent-fleet] **Scenario:** An ops team needs per-agent traffic, error rates, latency, active streams, and a live roster across a large fleet, surviving restarts. **How:** per-agent metrics (Prometheus plus a durable store) and a live dashboard; TTL and heartbeat keep the roster honest. **Why Aiway:** fleet-grade observability and lifecycle are native — not something you assemble from logs after the fact. See the [A2A configuration](/aiway/a2a/configuration) for the limits that govern a fleet. ## Hybrid pipelines & safe LLM access [#hybrid-pipelines--safe-llm-access] ### Agents plus durable messaging [#agents-plus-durable-messaging] **Scenario:** An agent's streamed artifacts are written to a persisted, replayable event log; downstream agents and classic consumers read from it; failed work lands in a dead-letter queue for human review. **How:** mix `events_store_publish` / `events_store_read` (or queues with a `dead_letter_queue`) with agent invocation, on one fabric. **Why Aiway:** agents and enterprise messaging are the *same* substrate, so you compose durable, replayable, at-least-once pipelines with AI agents inside them — no integration layer. See the [Events Store tools](/aiway/mcp/tools/events). ### MCP tool gateway for safe enterprise access [#mcp-tool-gateway-for-safe-enterprise-access] **Scenario:** Give any MCP-speaking LLM safe, governed access to enterprise messaging — queues, events, persisted logs, RPC — without handing it the raw broker. **How:** the MCP messaging tools expose curated operations with input schemas, reserved-channel protection, timeouts, and auth. **Why Aiway:** the LLM gets a bounded, schema'd, authenticated surface onto real enterprise messaging — a controlled blast radius, not a firehose. See the [Queue tools](/aiway/mcp/tools/queues). ## Next steps [#next-steps] # Configure KubeMQ (/configure) Everything you need to configure KubeMQ for production. KubeMQ ships as a single binary with no external dependencies, so configuring it comes down to choosing how you run it and tuning the settings for your workload. Looking for metrics, tracing, and logging? **Turning them on** is here — [Observability settings](/configure/reference/observability) has the OpenTelemetry, audit and notification fields for both targets. **Reading and acting on them** lives under [Operate](/operate). # Drive KubeMQ from the terminal (/deploy/cli) `kmq` is KubeMQ's command-line client — a single static binary that talks to the management API on port **`:8080`**, the same API the web dashboard uses. **`kmq` does not run the broker.** It is a client, not a server: every command it runs is an HTTP call against a KubeMQ instance that is already up. You need a KubeMQ server already running and reachable before any of the steps below will work. If you don't have one yet, the [Quickstart](/deploy/quickstart) gets you there in a few minutes. ## Install [#install] ```sh curl -sSfL https://raw.githubusercontent.com/kubemq-io/kmq/main/install.sh | sh ``` `install.sh` detects your OS and architecture, downloads the matching archive, and performs a mandatory SHA-256 checksum verification before installing `kmq` into the first writable `PATH` directory. To pin a specific version instead of the latest release: ```sh curl -sSfL https://raw.githubusercontent.com/kubemq-io/kmq/main/install.sh | sh -s -- --version v0.3.1 ``` Prefer a container image over a local binary: ```sh podman run --rm europe-docker.pkg.dev/kubemq/images/kmq:latest version ``` (`docker run` works identically in place of `podman run`.) Full installer flags and environment variables (`KMQ_VERSION`, `KMQ_INSTALL_DIR`, signature verification) are in the [kmq CLI reference](/operate/kmq-cli#installation). ## Connect [#connect] `kmq` reads a **context** — a named connection profile — to know which server to talk to. Create one pointing at your running server: ```sh kmq context create default --api-address http://localhost:8080 ``` Confirm the connection: ```sh kmq status ``` You should see a compact JSON health digest with `is_healthy: true` — visible proof that `kmq` can reach your KubeMQ server. ## Send your first message [#send-your-first-message] Round-trip a message through the **Queues** pattern — guaranteed delivery, send now, receive whenever you're ready: ```sh kmq queue send orders '{"id":1}' ``` ```sh kmq queue receive orders ``` The second command returns the message you just sent. For a wider view across every pattern: ```sh kmq overview ``` ## Teach your coding agent [#teach-your-coding-agent] `kmq` ships an installable agent skill so a coding agent can drive it correctly without guessing at flags. Three ways to install it: ```sh npx skills add kubemq-io/kmq ``` The universal installer reaches 70+ agents — Claude Code, Cursor, Codex, Gemini CLI, Windsurf, and more. ```sh claude plugin marketplace add kubemq-io/kmq ``` The Claude Code plugin path, if you're already using Claude Code's plugin marketplace. ```sh kmq skills install ``` A zero-Node fallback that installs the skill stub locally for Claude Code; add `--global` to install it for the current user instead of just the current project. ## Where next [#where-next] `kmq` also ships migration commands (`kmq assess kafka`, `kmq migrate assess|replicate|translate|cutover`) that move an external Kafka cluster onto KubeMQ in phases — see the full reference for the complete workflow. # Install KubeMQ with Docker (/deploy/docker) Docker is recommended for **development and testing only**. For production, deploy on Kubernetes with [Helm and the operator](/deploy/kubernetes-helm). New here? Start with the [Quickstart](/deploy/quickstart) — it runs KubeMQ, opens the dashboard, and round-trips your first message in about 5 minutes. This page is the in-depth Docker reference: Compose, configuration, connectors, persistence, health checks, and standalone upgrade/rollback/backup. ## Docker Compose [#docker-compose] For a more structured setup, use Docker Compose. This configuration includes persistent storage and environment variable support. ```yaml title="docker-compose.yml" services: kubemq: image: europe-docker.pkg.dev/kubemq/images/kubemq:next container_name: kubemq hostname: kubemq ports: - "50000:50000" # gRPC - "9090:9090" # REST - "8080:8080" # API & Dashboard environment: - KUBEMQ_TOKEN=${KUBEMQ_TOKEN:-} volumes: - kubemq-data:/kubemq/store restart: unless-stopped volumes: kubemq-data: ``` Save this file and start KubeMQ: ```bash title="Terminal" docker compose up -d ``` A downloadable version of this file is available at [/docker-compose.yml](/docker-compose.yml). ### Stop and remove [#stop-and-remove] ```bash title="Terminal" docker compose down ``` To also remove persistent data: ```bash title="Terminal" docker compose down -v ``` ## Configuration [#configuration] KubeMQ is configured through environment variables. Pass them using the `-e` flag with `docker run` or the `environment` section in Docker Compose. ### Common environment variables [#common-environment-variables] | Variable | Default | Description | | -------------------------------- | ------- | ------------------------------------------------------------- | | `KUBEMQ_TOKEN` | — | License key (required for standalone mode outside Kubernetes) | | `LOG_LEVEL` | `2` | Log verbosity: 0=Trace 1=Debug 2=Info 3=Warn 4=Error 5=Fatal | | `API_PORT` | `8080` | Dashboard and management API port | | `CONNECTORS_GRPC_PORT` | `50000` | gRPC transport port | | `CONNECTORS_REST_PORT` | `9090` | REST transport port | | `STORE_CLEAN_STORE` | `false` | Remove stored data on startup | | `STORE_MAX_RETENTION` | `1440` | Max message retention in minutes (default: 24 hours) | | `STORE_MAX_MESSAGES` | `0` | Max messages per channel (0 = unlimited) | | `STORE_MAX_QUEUE_SIZE` | `0` | Max queue size in bytes (0 = unlimited) | | `QUEUE_MAX_NUMBER_OF_MESSAGES` | `1024` | Max messages per receive request | | `QUEUE_MAX_WAIT_TIMEOUT_SECONDS` | `3600` | Max polling wait timeout in seconds | ### Example with configuration [#example-with-configuration] **Sizing for local eval:** a single container is light — 1-2 vCPU and 2 GB RAM is enough for local development and evaluation traffic on any of the four patterns. Scale up only if you drive sustained high-throughput persistence (Events Store, Queues, or the Kafka connector) with a large `STORE_MAX_RETENTION`. Production/cluster sizing lives in the Helm [production checklist](/deploy/kubernetes-helm#sizing-eval-vs-production). ## Ports [#ports] KubeMQ exposes three core network ports by default. Wire-protocol connectors are opt-in — they open additional ports only when you enable them. | Port | Protocol | Service | Description | | ------- | ------------- | ---------------- | ---------------------------------------------------------------------------------------- | | `50000` | gRPC (HTTP/2) | Transport | Primary SDK transport. All SDKs connect here by default. | | `9090` | HTTP | REST / WebSocket | REST API and WebSocket connections for subscriptions. | | `8080` | HTTP | Dashboard / API | Web dashboard, health probes (`/health`, `/ready`), and Prometheus metrics (`/metrics`). | The gRPC port (50000) is the primary transport used by all KubeMQ SDKs. The REST port (9090) is useful for testing with cURL or when gRPC is not available. The dashboard port (8080) provides a management UI and health endpoints. ## Enabling wire-protocol connectors [#enabling-wire-protocol-connectors] The seven wire-protocol connectors (MQTT, AMQP 0-9-1, AMQP 1.0, STOMP, Kafka, AWS, GCP Pub/Sub) are **disabled by default**. Each opens a new network port and must be explicitly enabled. Enable them with environment variables: | Connector | Env var | Ports opened | | --------------------- | ------------------------------- | ------------------------ | | MQTT | `CONNECTORSMQTT_ENABLE=true` | 1883 / 8883 / 8083 | | AMQP 0-9-1 (RabbitMQ) | `CONNECTORS_AMQP_ENABLE=true` | 5672 / 5671 | | AMQP 1.0 | `CONNECTORS_AMQP10_ENABLE=true` | 5672 / 5671 (shared mux) | | STOMP | `CONNECTORS_STOMP_ENABLE=true` | 61613 / 61614 | | Kafka | `CONNECTORS_KAFKA_ENABLE=true` | 9092 / 9093 | | AWS (SQS & SNS) | `CONNECTORS_AWS_ENABLE=true` | 4566 | | GCP Pub/Sub | `CONNECTORS_GCP_ENABLE=true` | 8085 | Example — start KubeMQ with MQTT and the AWS connector enabled: **Env var spelling matters.** The MQTT enable variable is `CONNECTORSMQTT_ENABLE` — no underscore between `CONNECTORS` and `MQTT`. Other connectors use an underscore: `CONNECTORS_AMQP_ENABLE`, `CONNECTORS_STOMP_ENABLE`, etc. See the [connectors reference](/configure/reference/connectors) for all variable names. ## Kafka single-node (Docker) [#kafka-single-node-docker] KubeMQ can expose a Kafka wire-protocol endpoint for single-node Docker development. Kafka runs on the **`next` storage engine** — which is **auto-selected with zero configuration** on a fresh store when the Kafka connector is enabled. You do not set the engine first; see [Storage Engines](/configure/reference/storage-engines) for the full selection rules. Enable Kafka with `CONNECTORS_KAFKA_ENABLE=true`. ### User-defined network [#user-defined-network] Docker's default bridge network has no DNS, so a Kafka client resolving the advertised container name will not be able to connect there. Create a user-defined network first, then attach KubeMQ to it with a stable `--hostname`: ```bash title="Terminal" docker network create mynet docker run -d \ --network mynet \ --name kubemq \ --hostname kubemq \ -e KUBEMQ_TOKEN=YOUR_LICENSE_KEY \ -e CONNECTORS_KAFKA_ENABLE=true \ europe-docker.pkg.dev/kubemq/images/kubemq:next ``` Other containers attached to `mynet` reach Kafka at `kubemq:9092`. ### Port-mapped host access [#port-mapped-host-access] To connect from a Kafka client running on the host (outside Docker), advertise `localhost` and publish the Kafka port. The published host port must equal `CONNECTORS_KAFKA_PORT` (`9092` by default): A Kafka client on the host can now connect at `localhost:9092`. ### Persistent volume [#persistent-volume] Give the container a stable `--name`/`--hostname` so the advertised name and the store's host-subdir stay stable across restarts, and mount a volume for `/store`: ```bash title="Terminal" docker run -d \ --name kubemq \ --hostname kubemq \ -e KUBEMQ_TOKEN=YOUR_LICENSE_KEY \ -e CONNECTORS_KAFKA_ENABLE=true \ -v kubemq-kafka-data:/kubemq/store \ europe-docker.pkg.dev/kubemq/images/kubemq:next ``` **The user-defined-network and port-mapped-host recipes above are mutually exclusive per deployment.** A single advertised endpoint means you pick either user-defined-network reachability or port-mapped host reachability — not both. See the [Kafka connector](/connectors/kafka) overview and the [Kafka settings reference](/configure/reference/connectors#kafka) for the full set of options (TLS port, SASL mechanisms, OAUTHBEARER, and more). ## Persistence [#persistence] By default, KubeMQ stores data inside the container at `/store`. This data is lost when the container is removed. To persist data across container restarts, mount a volume. ### Named volume [#named-volume] ### Bind mount [#bind-mount] Mount a host directory for easier access to the stored data: Persistence is required for **Events Store** and **Queues** patterns. Events (fire-and-forget) and RPC patterns do not require persistence, but the store is still used for internal metadata. ## Health checks [#health-checks] KubeMQ exposes health endpoints on the API port: ```bash title="Terminal" # Liveness check — is the process running? curl http://localhost:8080/health # Readiness check — is the broker ready to accept traffic? curl http://localhost:8080/ready ``` Add a Docker health check to your `docker run` command: ```bash title="Terminal" docker run -d \ --name kubemq \ -p 50000:50000 \ -p 9090:9090 \ -p 8080:8080 \ -e KUBEMQ_TOKEN=YOUR_LICENSE_KEY \ --health-cmd="curl -f http://localhost:8080/health || exit 1" \ --health-interval=10s \ --health-timeout=5s \ --health-retries=3 \ europe-docker.pkg.dev/kubemq/images/kubemq:next ``` ## Upgrade, rollback & backup [#upgrade-rollback--backup] KubeMQ ships on the mandatory `:next` tag — there's no version number to bump. "Upgrading" means re-pulling `:next` and recreating the container underneath the same name; "rolling back" means restarting from an image you deliberately kept on disk, not from a pinned digest. `:next` is the only supported channel — see the Helm [image-tag policy](/deploy/kubernetes-helm#production-checklist) for why pinning by digest or semver isn't the model. ### Upgrade [#upgrade] Re-pull the image and recreate the container: ```bash title="Terminal" docker pull europe-docker.pkg.dev/kubemq/images/kubemq:next docker stop kubemq docker rm kubemq ``` Then start it again with the same command you used originally (same name, same volume) — Docker resolves `:next` to the image you just pulled: ### Roll back [#roll-back] Because `:next` is a moving tag, "rolling back" means restarting from a **local image you retained before upgrading** — not re-pulling an older digest. Do one of these *before* you upgrade: ```bash title="Terminal" # Option A — tag the currently-running image under a name you control docker tag europe-docker.pkg.dev/kubemq/images/kubemq:next kubemq-known-good # Option B — or just record its image ID docker inspect --format='{{.Image}}' kubemq ``` If the upgrade goes wrong, stop and remove the new container, then start a container from the retained local image (or ID) instead of `:next`: ```bash title="Terminal" docker stop kubemq docker rm kubemq docker run -d \ --name kubemq \ --hostname kubemq \ -p 50000:50000 \ -p 9090:9090 \ -p 8080:8080 \ -e KUBEMQ_TOKEN=YOUR_LICENSE_KEY \ -v kubemq-data:/kubemq/store \ kubemq-known-good ``` This is **local image retention**, not registry pinning. `:next` stays the only image reference you pull from the registry — the rollback target lives only on this host, which is why you must tag or record it *before* you re-pull. ### Back up the store [#back-up-the-store] The `kubemq-data` volume — mounted at `/kubemq/store` in the server container — holds everything **Events Store** and **Queues** persist. Back it up with a throwaway container that mounts the same volume (the path inside the throwaway container is arbitrary): ```bash title="Terminal" docker run --rm -v kubemq-data:/store -v "$(pwd)":/backup alpine \ tar czf /backup/kubemq-store-backup.tar.gz -C /store . ``` Restore it the same way, in reverse, onto a fresh volume before starting KubeMQ: ```bash title="Terminal" docker run --rm -v kubemq-data:/store -v "$(pwd)":/backup alpine \ sh -c "cd /store && tar xzf /backup/kubemq-store-backup.tar.gz" ``` ## Related [#related] Run KubeMQ and send your first message in about 5 minutes. Deploy KubeMQ to Kubernetes for production use. Env vars, a mounted config.yaml, the CONFIG variable, and docker-compose. Adopt KubeMQ as a drop-in Kafka broker — what works and how to migrate. # Deploy KubeMQ (/deploy) ## One broker, two jobs [#one-broker-two-jobs] KubeMQ is a Kubernetes-native message broker that ships as a single binary with two co-equal headline capabilities. **Consolidate your messaging stack** — point your existing Kafka, RabbitMQ, AWS SQS/SNS, MQTT, STOMP, AMQP, or Google Cloud Pub/Sub client at KubeMQ by changing one connection string. No rewrite, no new SDK, no new wire protocol to learn. **Run your AI-agent fabric** — KubeMQ ships built-in **Agent-to-Agent (A2A)** and **Model Context Protocol (MCP)** gateways. Agents register, get discovered by capability, and stream results; any MCP-compatible LLM host — Claude, Cursor, or your own IDE — drives both messaging and agents over the open MCP standard. Both capabilities ride the same core: * **10-language SDKs plus REST** * **Four messaging patterns** (Events, Events Store, Queues, and RPC — Commands and Queries) * Built-in persistence * A web dashboard * Clustering and high availability * No ZooKeeper, no external database, no sidecars ## Choose your path [#choose-your-path] ### What do you want to do [#what-do-you-want-to-do] Two things KubeMQ does — pick the one you came for. ### Ways to get started [#ways-to-get-started] Or just get it running: ## The four messaging patterns [#the-four-messaging-patterns] ## Upgrading an existing install? [#upgrading-an-existing-install] Coming from KubeMQ v2? Follow the [v2 → v3 migration guide](/deploy/migrate-v2-to-v3). Rolling upgrades, rollback, and backup are covered in [Install with Docker](/deploy/docker) and [Install on Kubernetes](/deploy/kubernetes-helm). # Install KubeMQ with Helm (/deploy/kubernetes-helm) ## Prerequisites [#prerequisites] Before you begin, ensure you have the following installed and configured: * **Kubernetes cluster** (v1.20 or later) — any distribution (EKS, GKE, AKS, k3s, minikube) * **kubectl** — configured to access your cluster * **Helm v3** — download from [helm.sh](https://helm.sh/docs/intro/install/) * **KubeMQ license key** — required for the cluster to start Running KubeMQ for local development? Consider using [Docker](/deploy/docker) instead for a simpler setup. ## How it works [#how-it-works] KubeMQ ships **four** Helm charts. The standard install path is a 3-step sequence: 1. **`kubemq-crds`** — CRD schema only. Registers the `KubemqCluster` (and `KubemqConnector`) resource types with Kubernetes. No workloads, no operator. 2. **`kubemq-controller`** — The operator Deployment. Watches for `KubemqCluster` resources and reconciles StatefulSets, Services, and configuration. 3. **`kubemq-cluster`** — Renders one `KubemqCluster` custom resource. This chart is a thin passthrough: nearly every Helm value you set becomes the identically-named field on the CR's `spec`, and the operator installed in step 2 does the actual reconciling. A fourth chart, the **umbrella `kubemq`** chart, bundles CRDs + operator + one `KubemqCluster` CR into a single release — a one-shot alternative to running the three charts above in sequence. It requires only `key` (your license) to render. All components are installed into the `kubemq` namespace. ## Add the KubeMQ Helm repository [#add-the-kubemq-helm-repository] Register the KubeMQ Helm chart repository and update your local chart index. The charts are published as stable GA releases, each versioned independently — `kubemq-crds` **3.2.0**, `kubemq-cluster` **3.2.0**, the umbrella `kubemq` **3.2.0**, and `kubemq-controller` **2.0.0**. Install with plain `helm` commands — no `--devel` flag is needed. The chart **version** (each chart's own semver, e.g. `kubemq-cluster` at `3.2.0`) is separate from the chart's **appVersion** — the product line it tracks. appVersion is **3.0.0** (the server) for `kubemq-crds`, `kubemq-cluster`, and the umbrella `kubemq`; it's **2.0.0** (the operator) for `kubemq-controller` — appVersion is per-chart, not one shared number, and it names the GA line rather than the exact build (the current operator build on `:next` is **v2.3.0**). Both are separate again from the operator/server container **image tag** `:next` you'll see later on this page — the server rides the mutable `:next` image tag rather than a pinned version. Don't confuse the three. ```bash title="Terminal" helm repo add kubemq-charts https://kubemq-io.github.io/charts helm repo update ``` Verify the repository was added successfully: ```bash title="Terminal" helm search repo kubemq-charts ``` You should see charts for `kubemq-crds`, `kubemq-controller`, `kubemq-cluster`, and the umbrella `kubemq` chart. ## Install KubeMQ [#install-kubemq] KubeMQ installation on Kubernetes requires three Helm charts installed in order: CRDs, the controller (operator), and the cluster itself. ### Install KubeMQ CRDs [#install-kubemq-crds] The Custom Resource Definitions must be installed first. They define the `KubemqCluster` resource type that the operator manages. ```bash title="Terminal" helm install --create-namespace -n kubemq kubemq-crds kubemq-charts/kubemq-crds ``` ### Install KubeMQ controller [#install-kubemq-controller] The KubeMQ controller (operator) watches for `KubemqCluster` resources and manages the lifecycle of KubeMQ nodes. ```bash title="Terminal" helm install --wait -n kubemq kubemq-controller kubemq-charts/kubemq-controller ``` ### Install KubeMQ cluster [#install-kubemq-cluster] Deploy the KubeMQ cluster. Replace `YOUR_LICENSE_KEY` with your actual license key. ```bash title="Terminal" helm install --wait -n kubemq kubemq-cluster kubemq-charts/kubemq-cluster \ --set key=YOUR_LICENSE_KEY ``` By default, this creates a 3-node cluster. To deploy a single standalone node for development, add `--set standalone=true`. ## Supplying the license from a Secret [#supplying-the-license-from-a-secret] Passing `--set key=YOUR_LICENSE_KEY` stores the raw license as a Helm value (recoverable via `helm get values`) and on the `KubemqCluster` object. To keep the token out of cluster state — the recommended path for **GitOps**, where manifests live in git — put the license in a Kubernetes Secret and reference it instead. ```bash title="Terminal" kubectl create secret generic kmq-license -n kubemq --from-literal=key=YOUR_LICENSE_KEY ``` Then install (or apply a CR) with `keySecretRef` in place of `key`: ```bash title="Terminal" helm install --wait -n kubemq kubemq-cluster kubemq-charts/kubemq-cluster \ --set keySecretRef=kmq-license ``` The operator resolves the key from the Secret at reconcile time; the raw token never appears in the `KubemqCluster` object or the Helm release values. `keySecretRef` is **opt-in** — the literal `key` / `--set key=` path is unchanged and remains the default. Set only one: providing both a literal `key` and `keySecretRef` is rejected. The optional `keySecretKey` overrides the Secret's data key (defaults to `key`); `licenseSecretRef` / `licenseSecretKey` behave the same for `spec.license`. **Operator logs from before v2.3.0 contain the activation key in plaintext.** Earlier operator builds printed the full key at INFO level on every activation. As of **v2.3.0** the operator logs only a last-4 fingerprint, everywhere — including inside licence-store error strings. If you have archived operator logs, or a log aggregator that ingested them, **treat those archives as containing the activation key and rotate it.** Anyone with `kubectl logs` access on the operator over that window had the token. ## Verify installation [#verify-installation] Confirm that all KubeMQ pods are running and ready. ```bash title="Terminal" kubectl get pods -n kubemq ``` Expected output: ```text NAME READY STATUS RESTARTS AGE kubemq-controller-xxxxxxxxx-xxxxx 1/1 Running 0 2m kubemq-cluster-0 1/1 Running 0 1m kubemq-cluster-1 1/1 Running 0 1m kubemq-cluster-2 1/1 Running 0 1m ``` Check the services exposed by KubeMQ: ```bash title="Terminal" kubectl get svc -n kubemq ``` To access the KubeMQ dashboard, port-forward the API service: ```bash title="Terminal" kubectl port-forward -n kubemq svc/kubemq-cluster 8080:8080 ``` Then open `http://localhost:8080` in your browser. ## Charts & images [#charts--images] | Chart | Purpose | | ------------------- | ---------------------------------------------------------------------------- | | `kubemq-crds` | Registers the `KubemqCluster` / `KubemqConnector` CRD schemas — no workloads | | `kubemq-controller` | The operator Deployment | | `kubemq-cluster` | Renders one `KubemqCluster` CR (thin `spec.*` passthrough) | | `kubemq` (umbrella) | CRDs + operator + one CR in a single release (`key` required) | All four charts are published as stable GA releases — `kubemq-crds` `3.2.0`, `kubemq-cluster` `3.2.0`, the umbrella `kubemq` `3.2.0`, and `kubemq-controller` `2.0.0` — a plain `helm install` resolves them, no `--devel` needed (see the callout above). ### Images and the `:next` channel [#images-and-the-next-channel] The `kubemq-controller` chart's operator and server images default to the mutable image-tag `:next` — `operatorImage` and `kubemqImage` in the [Controller chart values](#controller-chart-values) below. `kubemqImage` isn't consumed by the operator's own container — it feeds the operator's `RELATED_IMAGE_KUBEMQ_CLUSTER` environment variable, which is what the operator uses as the container image when it renders a `KubemqCluster` StatefulSet. **Tracking `:next` vs. pinning a digest.** Tracking `:next` — letting each pod roll re-pull the tag — is the default and recommended path; it always lands the current build. If a deployment needs byte-for-byte reproducibility instead, you may pin the same image to an immutable digest, `europe-docker.pkg.dev/kubemq/images/kubemq:next@sha256:`. The tag is still `:next`, so this stays compatible with the `:next`-only policy — it isn't a semver pin. The tradeoff: once pinned, a pod roll no longer adopts a new `:next` build on its own. You own the upgrade — re-pull `:next`, read its current digest, and re-pin: ```bash title="Terminal" docker pull europe-docker.pkg.dev/kubemq/images/kubemq:next && \ docker inspect --format='{{index .RepoDigests 0}}' europe-docker.pkg.dev/kubemq/images/kubemq:next ``` **There is no `image.tag` — set the whole reference in `image.image`.** The `kubemq-cluster` chart passes **every** value straight through into the `KubemqCluster` object, so `--set image.tag=…` reaches the API server as an undeclared field and the install fails outright: ```text server-side apply failed … .spec.image.tag: field not declared in schema ``` `spec.image.image` is **one full reference** — repository, and tag or digest, together: ```yaml title="values.yaml (kubemq-cluster)" image: image: europe-docker.pkg.dev/kubemq/images/kubemq:next@sha256: pullPolicy: IfNotPresent ``` Keep the tag `:next` — that is the mandatory release channel, and a digest appended to it is the supported way to freeze a build. **Don't substitute a semver tag** such as `:v3.1.3`: the server does not publish one, and a reference that names it will not resolve. ## Configurable values [#configurable-values] The snippets below are illustrative, one per chart — not exhaustive field tables. Field-by-field defaults and valid values for the `KubemqCluster` spec live in the Configuration Reference (linked throughout this section). ### Controller chart values [#controller-chart-values] ```yaml title="values.yaml (kubemq-controller)" operatorImage: europe-docker.pkg.dev/kubemq/images/kubemq-operator:next kubemqImage: europe-docker.pkg.dev/kubemq/images/kubemq:next imagePullSecrets: - name: my-registry-secret # Images for the connector workloads started by the operator connectorTargetsImage: europe-docker.pkg.dev/kubemq/images/kubemq-targets:next connectorSourcesImage: europe-docker.pkg.dev/kubemq/images/kubemq-sources:next connectorBridgesImage: europe-docker.pkg.dev/kubemq/images/kubemq-bridges:next ``` `connectorTargetsImage`, `connectorSourcesImage`, and `connectorBridgesImage` set the images the operator uses for the Targets, Sources, and Bridges connector workloads. Pin these to your own registry mirror in production — don't rely on the chart's built-in default. ### Cluster chart values [#cluster-chart-values] Only `key` (your license, or `keySecretRef` — see [Supplying the license from a Secret](#supplying-the-license-from-a-secret)) and `imagePullSecrets` are native to this chart. Every other value is a **verbatim passthrough** to the `KubemqCluster` CR — a Helm value maps 1:1 to the `spec.*` field of the same name: ```yaml title="values.yaml (kubemq-cluster)" key: "YOUR_LICENSE_KEY" # Everything below passes through as spec.* on the KubemqCluster CR — # see the Configuration Reference for the full field list. replicas: 3 volume: size: 50Gi ``` For the complete field list, see [Deployment & High Availability](/configure/reference/deployment) and [Storage Engines](/configure/reference/storage-engines). ### Umbrella chart values [#umbrella-chart-values] Only `key` is chart-native, and it's **required** — the release fails to render without it. The operator image is hardcoded in the chart's template, not driven by `operatorImage`/`kubemqImage`: ```yaml title="values.yaml (kubemq)" key: "YOUR_LICENSE_KEY" ``` Any value you set in a `values.yaml` **pins** that field permanently — once active, the server's built-in default no longer applies for that field, even across upgrades. Keep `values.yaml` minimal and only set what you intend to override long-term. ### Using a values file [#using-a-values-file] For complex configurations, create a `values.yaml` file for the `kubemq-cluster` chart: ```yaml title="values.yaml" key: "YOUR_LICENSE_KEY" replicas: 3 standalone: false volume: size: 20Gi storageClass: fast-ssd grpc: expose: LoadBalancer port: 50000 api: expose: LoadBalancer port: 8080 resources: requestsCpu: "2" requestsMemory: 4Gi limitsCpu: "4" limitsMemory: 8Gi ``` Then install with: ```bash title="Terminal" helm install --wait -n kubemq kubemq-cluster kubemq-charts/kubemq-cluster -f values.yaml ``` ## Declarative install (KubemqCluster CR) [#declarative-install-kubemqcluster-cr] The `kubemq-controller` you installed above is the KubeMQ operator: it watches for `KubemqCluster` custom resources and reconciles the actual cluster state to match. Instead of passing cluster settings as Helm flags, you can describe the desired cluster declaratively in a `KubemqCluster` manifest and let the controller create and manage it. This reuses the controller already running from the steps above — there is nothing extra to install. Apply a `KubemqCluster` resource and the operator does the rest. ### Create a KubeMQ cluster [#create-a-kubemq-cluster] Define the desired cluster state in a `KubemqCluster` custom resource, then apply it with `kubectl`. #### Basic cluster [#basic-cluster] ```yaml title="kubemq-cluster.yaml" apiVersion: core.k8s.kubemq.io/v1beta1 kind: KubemqCluster metadata: name: kubemq-cluster namespace: kubemq spec: replicas: 3 key: "YOUR_LICENSE_KEY" ``` Apply the manifest: ```bash title="Terminal" kubectl apply -f kubemq-cluster.yaml ``` #### Standalone node (development) [#standalone-node-development] For local development or testing, deploy a single standalone node: ```yaml title="kubemq-standalone.yaml" apiVersion: core.k8s.kubemq.io/v1beta1 kind: KubemqCluster metadata: name: kubemq-dev namespace: kubemq spec: replicas: 1 standalone: true key: "YOUR_LICENSE_KEY" grpc: expose: NodePort nodePort: 32000 api: expose: NodePort nodePort: 32080 ``` #### Production cluster [#production-cluster] A production-ready configuration with resource limits, persistent storage, and LoadBalancer services: ```yaml title="kubemq-production.yaml" apiVersion: core.k8s.kubemq.io/v1beta1 kind: KubemqCluster metadata: name: kubemq-production namespace: kubemq spec: replicas: 3 key: "YOUR_LICENSE_KEY" volume: size: 50Gi storageClass: fast-ssd grpc: expose: LoadBalancer port: 50000 bodyLimit: 100000000 rest: disabled: false expose: ClusterIP port: 9090 api: expose: LoadBalancer port: 8080 resources: requestsCpu: "2" requestsMemory: 4Gi limitsCpu: "4" limitsMemory: 8Gi health: initialDelaySeconds: 5 periodSeconds: 10 timeoutSeconds: 5 ``` **A `next`-engine cluster requires `spec.volume.size` — and a new cluster on a clean store comes up on `next`.** So this applies to a default install, not just to clusters that asked for `next`. A volumeless `next`-engine cluster raises the `EphemeralNextStore` warning — its durable data would otherwise live on ephemeral container storage. The storage engine itself (`legacy` vs `next`) is **established once, at cluster creation**, and is immutable thereafter — there is no in-place migration between engines; to run `legacy` you name it explicitly at creation. See [Storage Engines](/configure/reference/storage-engines) for the full engine-selection model. To enable Kafka on Kubernetes, see the zero-config recipe in [Configure → Zero-config Kafka](/configure/kubernetes#zero-config-kafka). Check the status of your cluster after applying a manifest: ```bash title="Terminal" kubectl get kubemqclusters -n kubemq ``` ### Manage the cluster [#manage-the-cluster] #### Scale [#scale] Update the `replicas` field in your manifest and re-apply, or patch the resource directly: ```bash title="Terminal" kubectl patch kubemqcluster kubemq-cluster -n kubemq \ --type merge -p '{"spec":{"replicas":5}}' ``` For clustered mode (non-standalone), use an **odd** number of replicas (3, 5, 7) to maintain consensus quorum. With even numbers, the cluster cannot tolerate as many failures. #### Update configuration [#update-configuration] Edit the `KubemqCluster` resource to change configuration. The operator reconciles changes automatically. ```bash title="Terminal" kubectl edit kubemqcluster kubemq-cluster -n kubemq ``` Or apply an updated manifest: ```bash title="Terminal" kubectl apply -f kubemq-cluster-updated.yaml ``` #### Delete [#delete] Remove a KubeMQ cluster while keeping the controller running (so it can still manage other clusters): ```bash title="Terminal" kubectl delete kubemqcluster kubemq-cluster -n kubemq ``` This removes all pods and services for that cluster. Persistent volume claims are retained by default. ### Field reference [#field-reference] Field-by-field settings for the `KubemqCluster` spec — types, defaults, and valid values — live in the Configuration Reference, not here: [Deployment & High Availability](/configure/reference/deployment), [Storage Engines](/configure/reference/storage-engines), and [Connectors](/configure/reference/connectors). ## Upgrade [#upgrade] Upgrade an existing KubeMQ installation to a new version or change configuration: ```bash title="Terminal" helm upgrade --wait -n kubemq kubemq-cluster kubemq-charts/kubemq-cluster \ --set key=YOUR_LICENSE_KEY \ --reuse-values ``` The `--reuse-values` flag preserves your existing configuration and only applies the changes you specify. ### Helm does not upgrade CRDs [#helm-does-not-upgrade-crds] Helm **never** upgrades CRDs shipped in a chart's `crds/` directory — running `helm upgrade` against `kubemq-crds` will not update an already-installed `KubemqCluster` CRD. That is deliberate: it is what stops a `helm uninstall` from cascade-deleting every `KubemqCluster` and its data. The CRD-upgrade path is applying the canonical manifest directly, not `helm upgrade`: ```bash title="Terminal" kubectl apply -f https://raw.githubusercontent.com/kubemq-io/charts/master/kubemq-crds/crds/kubemqclusters.core.k8s.kubemq.io.crd.yaml ``` **Charts 3.2.0 carries a CRD schema change — apply it.** The new rule rejects a `KubemqCluster` that sets **both** an `oidc` block and the JWT `key` / `signatureType` fields, a combination that previously produced a broker with **no authentication at all**. Skipping the apply doesn't break anything: it only means the API server won't enforce the rule at admission, and the operator's own check catches the same case at reconcile instead. See [the authentication callout](/configure/reference/security#authentication) for how to find affected clusters before you upgrade. ### Upgrade order [#upgrade-order] Upgrade in this order: 1. **CRDs and the operator, together, in one step.** The window where CRDs are upgraded but the operator is still old is transient — it is not a resting state you should pause in. 2. **Server images.** Because the server rides the mutable `:next` tag rather than a pinned semantic version, "upgrading" the server means **rolling the pods so they re-pull the current `:next` image** — there's no version number to bump to. If you've pinned the image to a digest for reproducibility (see [Images and the `:next` channel](#images-and-the-next-channel)), a plain pod roll won't pick up a new build on its own — re-pull `:next`, read its current digest, and re-pin it to upgrade. 3. **Charts** — the Helm release metadata itself (`--reuse-values`/`--set` as needed). **An old operator strips fields it doesn't know about.** Don't add `spec.env`, `spec.envFromSecrets`, `expose`, or `nodePort` — or rely on the engine writing itself back onto the CR — until the operator itself has been upgraded. An old operator **permanently** strips unknown fields the first time it reconciles a resource, it doesn't just ignore them once. **Operator rollback is not engine-safe.** If you roll the operator back, pin `spec.store.engine: next` explicitly first — a clustered `next` CR left to auto-detect will crash-loop under an old operator. The engine-establishment annotation on the CR survives rollback (it's untyped metadata an old operator doesn't touch), so re-upgrading the operator later re-derives the engine correctly. An explicit `legacy` CR gets **one** checksum-triggered rolling restart on upgrade. An **established** cluster keeps the engine it already has — the operator names it explicitly and nothing rolls. A **fresh** cluster with no engine on record is created with `STORE_ENGINE=auto`, and the server resolves it at boot. Once the engine is established as `next`, the **replicas-freeze** arms: `spec.replicas` becomes immutable on that CR. ### v2.8.x → v2.9.0 (breaking) [#v28x--v290-breaking] The six wire-protocol connectors — MQTT, AMQP 0.9.1, AMQP 1.0, STOMP, AWS, and GCP — changed their CRD field from `disabled: bool` to `enabled: *bool`. This requires all three components upgraded in lockstep: `kubemq-crds` ≥ 2.13.0, `kubemq-operator` ≥ 1.19.0, and the server ≥ v3.0.0-b7. Kafka's `enabled: *bool` opt-in field is unaffected — it already used this shape, consistent with the post-v2.9 pattern. ## Uninstall [#uninstall] Remove KubeMQ from your cluster. If you installed the three charts separately, uninstall in reverse order — this is the recommended, default path: ```bash title="Terminal" helm uninstall -n kubemq kubemq-cluster helm uninstall -n kubemq kubemq-controller helm uninstall -n kubemq kubemq-crds ``` If you installed the umbrella `kubemq` chart, you can remove the whole release in one step instead: ```bash title="Terminal" helm uninstall kubemq -n kubemq ``` The umbrella chart ships a `pre-delete` hook designed to delete the `KubemqCluster` resource and let the operator drain its finalizer *before* the operator itself is removed — that's what makes the one-step path possible. {/* TODO(CH-PREDEL): drop this caveat when the chart default preDelete.image is fixed */} **This isn't safe out-of-the-box today.** The hook's default `preDelete.image` (`europe-docker.pkg.dev/kubemq/images/kubectl:latest`) doesn't resolve, so a plain `helm uninstall kubemq -n kubemq` will **hang** waiting on the hook. `helm uninstall` doesn't take `--set` itself, so override the value with an upgrade first, then uninstall — either point the hook at a `kubectl` image you know resolves: ```bash title="Terminal" helm upgrade kubemq kubemq-charts/kubemq -n kubemq --reuse-values \ --set preDelete.image= helm uninstall kubemq -n kubemq ``` or disable the hook and delete the cluster yourself first: ```bash title="Terminal" helm upgrade kubemq kubemq-charts/kubemq -n kubemq --reuse-values \ --set preDelete.enabled=false kubectl delete kubemqcluster -n kubemq helm uninstall kubemq -n kubemq ``` Until this is fixed, prefer the 3-step reverse uninstall above. Uninstalling deletes all KubeMQ pods and services. Persistent volume claims (PVCs) are retained by default. Delete them manually if you want to remove all data: `kubectl delete pvc -n kubemq -l app=kubemq-cluster`. To also remove the namespace: ```bash title="Terminal" kubectl delete namespace kubemq ``` ## Production checklist [#production-checklist] Before you point real traffic at a cluster, run down this list. | Check | What to do | | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Odd replica count, 3 or more** | Use **3, 5, or 7** replicas for clustered mode. The operator creates a `PodDisruptionBudget` only from 3 up — below that it creates none and emits an `UnsupportedReplicaCount` warning event, because the only correct budget at 2 replicas would block every node drain. An even count gets an `EvenReplicaCount` warning: 4 tolerates the same single failure as 3, at the cost of a node and a copy of the data. See [Disruption budget & pod spread](/configure/reference/deployment#disruption-budget--pod-spread). | | **Shutdown grace period** | Set `terminationGracePeriodSeconds` to at least **45**. The server splits the grace between connector teardown — which **requeues in-flight messages that would otherwise be lost** — and store shutdown. At the Kubernetes default of 30s the requeue backstop is only **1 second**; 45s funds the full **12-second** backstop. See [Shutdown grace period](/configure/reference/deployment#shutdown-grace-period). | | **Session affinity on AWS / GCP** | If you enable the **AWS** or **GCP Pub/Sub** connector on more than one replica, set `sessionAffinity: ClientIP`. Their delete/ack tokens are bound to the replica that issued them — without stickiness an SQS queue never drains and GCP acks are refused. Prefer ingress cookie affinity where clients share a NAT or egress IP. See [Service exposure & session affinity](/configure/reference/connectors#service-exposure--session-affinity). | | **`volume.size` set** | Set `spec.volume.size` (or the `volume.size` Helm value) explicitly. It's **required for the `next` storage engine** — a volumeless `next` cluster raises the `EphemeralNextStore` warning because its durable data would otherwise live on ephemeral container storage. See [Storage Engines](/configure/reference/storage-engines). | | **Resource requests/limits** | Set `resources.requestsCpu` / `requestsMemory` / `limitsCpu` / `limitsMemory` (or the matching `spec.resources.*` fields). There are no built-in defaults — an unset field is simply omitted from the pod spec. See the sizing table below. | | **Service exposure** | Decide `LoadBalancer` vs `ClusterIP` (vs `NodePort`) per interface (`grpc.expose`, `rest.expose`, `api.expose`) based on whether clients connect from outside the cluster. | | **Health probes** | Set `spec.health.enabled: true` so the operator wires a liveness probe (`/health` on the API port) and `API_BIND_ADDRESS=0.0.0.0` so the kubelet can reach it. Off by default. The API port also serves a `/ready` **readiness** endpoint that gates when the pod can actually serve — for a clustered `next`-engine cold start, it holds the pod not-Ready until cluster quorum forms. See [Health probe](/configure/reference/deployment#health-probe) for the full liveness/readiness model. | | **Secure it** | Turn on **authentication** (JWT or OIDC), **authorization** (policy-based roles), and **TLS/mTLS** before exposing the cluster beyond a trusted network. All off by default. Full field-by-field reference: [Security](/configure/reference/security). | | **Image-tag policy** | The server rides the mutable **`:next`** tag — that's the mandatory release channel, not a placeholder. Tracking `:next` (re-pulling on every pod roll) is the default and recommended path — own the rolling model: "upgrading" means re-pulling `:next` and rolling the pods, there's no version number to bump to. For byte-for-byte reproducibility you may instead pin `…/kubemq:next@sha256:` — the tag stays `:next`, so it's compatible with this policy — but then you own upgrades: a pod roll won't adopt a new `:next` build, so you upgrade by re-pulling `:next` and re-pinning the new digest (see [Images and the `:next` channel](#images-and-the-next-channel)). For change control without digest-pinning, **mirror `:next` to your own registry** and promote on your own schedule. | | **PVC retention** | Deleting a `KubemqCluster` (or uninstalling the chart) retains PVCs by default — data survives. Delete them explicitly (`kubectl delete pvc -n kubemq -l app=kubemq-cluster`) if you actually want to wipe storage. | ### Sizing: eval vs. production [#sizing-eval-vs-production] Illustrative starting points, not hard requirements — tune to your message volume, payload size, and retention needs. | | Eval / dev | Production | | ---------------------- | ----------------------------- | --------------------------------- | | Replicas | 1 (`standalone: true`) | 3, 5, or 7 | | CPU request / limit | `250m` / `500m` | `2` / `4` | | Memory request / limit | `512Mi` / `1Gi` | `4Gi` / `8Gi` | | `volume.size` | `5Gi` (or unset for `legacy`) | `50Gi`+, `fast-ssd` storage class | | Service exposure | `ClusterIP` / port-forward | `LoadBalancer` | ## Related [#related] Task guide: values.yaml, the KubemqCluster CR, single-node vs HA, and interface exposure. Reference: the legacy and next persistence engines, zero-config engine selection, and durability trade-offs — types, defaults, and valid values. Reference: Kubernetes packaging, replicas/standalone, resources, health probes, and Service exposure — types, defaults, and valid values. # Get a License Key (/deploy/license-key) A standalone KubeMQ server requires a license token to start. Getting one is **free** — about **2-3 minutes the first time**, since it includes creating a free account. This page explains what the token is, where to get one, and how to pass it to the server. ## Why a license key is required [#why-a-license-key-is-required] When KubeMQ runs **outside Kubernetes** — for example, a single Docker container on your laptop or a CI runner — the server validates a license token on startup. Without a valid `KUBEMQ_TOKEN`, a standalone server will **not start**: it logs a licensing error and exits immediately. Set the token before you run the container. The token is read from the `KUBEMQ_TOKEN` environment variable (or, equivalently, the `--key` command-line flag). It is free to obtain and ties your running server to your KubeMQ account. ## Get your token [#get-your-token] ### Create an account or sign in [#create-an-account-or-sign-in] Register or log in at the KubeMQ account portal: Create a free account or sign in to retrieve your license token. ### Copy your license token [#copy-your-license-token] Once signed in, locate your license key and copy its value. This is the string you will pass to the server as `KUBEMQ_TOKEN`. ### Pass it to the server [#pass-it-to-the-server] Provide the token wherever you start KubeMQ — see the methods below. ## Pass the token to the server [#pass-the-token-to-the-server] The same license value works across every way you run KubeMQ. In the examples below, replace `YOUR_LICENSE_KEY` with the token you copied. Pass the token with `-e KUBEMQ_TOKEN=…`. The `docker run` command already shows `YOUR_LICENSE_KEY` as a placeholder: Add the token to the `environment` section of your service: ```yaml title="docker-compose.yml" services: kubemq: image: europe-docker.pkg.dev/kubemq/images/kubemq:next container_name: kubemq ports: - "50000:50000" - "9090:9090" - "8080:8080" environment: - KUBEMQ_TOKEN=YOUR_LICENSE_KEY ``` On Kubernetes, the same license value is supplied to the chart with `--set key=…`: ```bash title="Terminal" helm install kubemq-cluster kubemq/kubemq \ --set key=YOUR_LICENSE_KEY ``` ## One license, two surfaces [#one-license-two-surfaces] A single license value reaches KubeMQ through two different surfaces: | Surface | How the key is supplied | Mechanism | | --------------------- | ------------------------------------------------------------ | --------------------------------------------------- | | Standalone server | `-e KUBEMQ_TOKEN=YOUR_LICENSE_KEY` (or the `--key` flag) | The server validates the token on startup. | | Kubernetes deployment | Helm `--set key=YOUR_LICENSE_KEY` / `KubemqCluster.spec.key` | The chart passes the key into the cluster resource. | Under Kubernetes, the **server** skips its own license check — licensing is handled by the operator. The chart still needs the `key` value, so supply it via `--set key=…` or `KubemqCluster.spec.key`. ## Next steps [#next-steps] Run KubeMQ locally with the token-bearing Docker command. Deploy KubeMQ to Kubernetes and supply the key via the chart. # Migrate from v2 to v3 (/deploy/migrate-v2-to-v3) Moving from KubeMQ v2.10.x to KubeMQ v3. Companion to the [KubeMQ v3 release notes](/release-notes/v3). ## The short version [#the-short-version] * **Recommended: start fresh.** Deploy a new v3 cluster on the new (next) storage engine, move your applications to it, and retire the v2 cluster when you're done. The new cluster starts empty; no data is carried over. * **Alternative: upgrade in place.** Replace the v2 server with v3 on your existing cluster. The server detects your existing data automatically and keeps running on it — same data, same configuration, no conversion. * **Either way, v3 is a one-way door.** There is no rollback to v2. Plan the move, take a backup first, and validate before you commit. ## Choosing your path [#choosing-your-path] | | Path A — new cluster (recommended) | Path B — in-place upgrade | | ------------------------------ | ----------------------------------- | ------------------------- | | Storage engine after migration | next (new) | legacy (unchanged) | | Existing data | not carried over — fresh start | fully preserved | | Kafka connector available | yes | no (requires next) | | Application cutover | planned, gradual | none — same endpoints | | Rollback to v2 | old cluster untouched until retired | **none** | * Choose **Path A** when you want the full v3 feature set — the [Kafka connector](/connectors/kafka), strict durability mode, log compaction — and can treat messaging data as transient: queues drained before cutover, event history not needed on the new cluster. * Choose **Path B** when preserving in-flight data and endpoints matters more than the next-engine features. It also works as a first step — you can stand up a fresh next-engine cluster later and move to it. ## Before you begin [#before-you-begin] * **Version** — confirm you are on v2.10.x. * **Back up your store directory.** Mandatory for Path B — it is your only way back. Recommended for Path A as part of retiring the old cluster. * **License** — v3 uses the same license mechanism as v2 (see [License key](/deploy/license-key)). Unsure whether your license covers v3? Ask [support@kubemq.io](mailto:support@kubemq.io) before you start. * **Get v3** — images and install commands are in [Docker](/deploy/docker) and [Kubernetes with Helm](/deploy/kubernetes-helm). Note the v3 Helm charts are published on the prerelease channel and require `--devel` on install and upgrade. * **Read** the [breaking changes](/release-notes/v3#breaking-changes-at-a-glance) in the release notes. ## Path A — start a new v3 cluster (recommended) [#path-a--start-a-new-v3-cluster-recommended] ### Deploy the new cluster [#deploy-the-new-cluster] * **Docker / VM** — follow the [install guide](/deploy/docker). A fresh data directory means the server selects the next storage engine on its own; no engine setting required. * **Kubernetes** — create a new KubemqCluster resource per [Kubernetes with Helm](/deploy/kubernetes-helm), and set the storage engine explicitly in the cluster spec (`spec.store.engine: next`) so the new cluster starts on the next engine. * **Verify** — the dashboard cluster overview shows the engine per node, or run `kmq cluster info`. The startup log also prints the selected engine. ### Move your applications [#move-your-applications] The connection change is the whole change: same APIs, same SDK calls — point your applications at the new address. v2-era SDKs keep working against v3; upgrading SDKs is separate and not required for migration. Recommended cutover order per pattern: * **Queues** — move producers first. New messages arrive on the new cluster while consumers drain the old backlog; move consumers once the old queues are empty. * **Pub/Sub (Events, Events Store)** — move subscribers first so nothing published on the new cluster is missed, then move publishers. * **Commands/Queries** — move responders first, then requesters. Per application: change the connection configuration, deploy, then confirm its traffic on the new cluster's [dashboard](/operate/web-dashboard). ### Drain and retire the v2 cluster [#drain-and-retire-the-v2-cluster] 1. Let consumers drain the remaining queue backlogs on the old cluster. 2. Confirm zero traffic on the v2 dashboard. 3. Take a final backup, keep it per your retention policy, and decommission. The old cluster is your safety net until this moment — after retirement, the move is complete and there is nothing to roll back to. ## Path B — upgrade the existing cluster in place [#path-b--upgrade-the-existing-cluster-in-place] ### What happens on first start [#what-happens-on-first-start] The v3 server inspects the existing data directory and detects the v2-era (legacy) engine automatically — your data and configuration are used as-is. Detection is read-only and fail-safe: on anything unexpected the server stops with a clear error instead of touching your data. It never wipes, never converts, never guesses. Your endpoints, channels, queues, and subscriptions continue unchanged; applications reconnect and continue. ### Upgrade steps [#upgrade-steps] * **Docker / VM** — stop v2, back up the store directory, start v3 on the same store path (same commands as in [Docker](/deploy/docker), pointing at your existing data volume). * **Kubernetes** — update the chart/image per [Kubernetes with Helm](/deploy/kubernetes-helm) (remember `--devel`). Plan a maintenance window for the restart rather than assuming a zero-downtime mixed-version roll. ### Verify [#verify] * The startup log reports the detected engine; the dashboard overview shows every node healthy on the legacy engine. * Spot-check queue depths and subscriptions, and run a send/receive round-trip: `kmq queue send` / `kmq queue receive` ([kmq CLI](/operate/kmq-cli)). ### Life on the legacy engine [#life-on-the-legacy-engine] Everything in v3 works on the legacy engine **except** the next-engine features: the Kafka connector, the strict ack durability mode, and log compaction / time-based retention. Native retention limits continue to work exactly as in v2. Details: [Storage engines](/configure/reference/storage-engines). Moving to the next engine later is Path A with your upgraded cluster as the source — there is no in-place engine conversion. ## No rollback — read this before you start [#no-rollback--read-this-before-you-start] Once a cluster runs v3, returning to v2 is not supported — for either path. Your protections instead of rollback: * **Path A** — the untouched v2 cluster remains live until you retire it. * **Path B** — the mandatory pre-upgrade backup is the only way back; restoring it means accepting the loss of everything since the backup. Additionally, cluster nodes rebuilt with the v3 recovery procedure cannot roll back below v3.1.0. ## After you migrate [#after-you-migrate] * Enable what you need — [protocol connectors](/connectors), [management-plane accounts](/configure/reference/security), [OpenTelemetry](/learn/guides/opentelemetry) — all off by default. * Use the [kmq CLI](/operate/kmq-cli) for day-2 operations. * Help: [support@kubemq.io](mailto:support@kubemq.io). ## Frequently asked questions [#frequently-asked-questions] **Can I run v2 and v3 side by side?** Yes — that is Path A. The two clusters are independent until you retire the old one. **Do I have to upgrade my SDKs?** No. v2 SDKs work against v3; SDK upgrades are independent of the migration. **Can I move my queue backlog or event history to the new cluster?** No — there is no data carry-over between clusters. Plan the cutover so queues drain on the old cluster before you retire it. **Can I switch my upgraded (legacy) cluster to the next engine later?** Yes — by standing up a next-engine cluster and moving to it (Path A). There is no in-place engine switch. **What happens if I try to downgrade?** Downgrading is not supported. Restore the pre-upgrade backup to a v2 server if you must go back, accepting the loss of everything since that backup. # Quickstart (/deploy/quickstart) The whole flow takes about 5 minutes the first time — roughly 2-3 of those are the free account signup in Step 1. By the end you'll have KubeMQ running, the dashboard open, and a message you sent yourself showing up in real time. ## 1 · Get your key [#get-your-key] A standalone KubeMQ server needs a free license token to start — there's no keyless mode. Grabbing one is **under a minute if you already have an account, \~2-3 minutes including the free signup** if you don't. [Get a license key →](/deploy/license-key) ## 2 · Run KubeMQ [#2--run-kubemq] Replace `YOUR_LICENSE_KEY` below with the token from Step 1, then run: This starts the broker with gRPC on port `50000`, REST on port `9090`, and the web dashboard on port `8080`. ## 3 · Open the dashboard [#3--open-the-dashboard] Open [`http://localhost:8080`](http://localhost:8080). An empty dashboard here is healthy — it just means nothing has been sent yet. The `orders` channel appears and ticks the moment you complete Step 4. ## 4 · Send & receive your first message [#4--send--receive-your-first-message] This uses **Queues** — guaranteed, persistent delivery where send and receive are independent steps, so nothing is lost if you run them in either order. Send one message to a channel called `orders`, then receive it back. Install `kmq`, KubeMQ's command-line client, then send and receive: ```sh curl -sSfL https://raw.githubusercontent.com/kubemq-io/kmq/main/install.sh | sh ``` ```sh kmq context create default --api-address http://localhost:8080 kmq queue send orders '{"id":1}' kmq queue receive orders ``` ```text {"id":1} ``` `kmq queue receive orders` prints the body of the message it just pulled off the `orders` channel — the same `{"id":1}` you sent. Watch the `orders` channel tick in the dashboard as the two commands run. The full command reference and the agent-skill install are in [Drive KubeMQ from the terminal](/deploy/cli). No installation needed — this talks straight to the REST gateway on port `9090`. ```bash title="Terminal" curl -X POST http://localhost:9090/queue/send \ -H "Content-Type: application/json" \ -d '{ "Channel": "orders", "ClientID": "quickstart-curl", "BodyString": "{\"id\":1}" }' ``` ```bash title="Terminal" curl -X POST http://localhost:9090/queue/receive \ -H "Content-Type: application/json" \ -d '{ "Channel": "orders", "ClientID": "quickstart-curl", "MaxNumberOfMessages": 1, "WaitTimeSeconds": 5 }' ``` ```text {"is_error":false,"message":"OK","data":{"MessagesReceived":1,"Messages":[{"MessageID":"...","Channel":"orders","Body":"eyJpZCI6MX0="}]}} ``` `Body` comes back base64-encoded (the wire format for raw bytes) — decode it and you'll see `{"id":1}`. ```go title="main.go" package main import ( "context" "fmt" "log" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), kubemq.WithClientId("quickstart"), ) if err != nil { log.Fatal("Failed to create client:", err) } defer client.Close() channel := "orders" // Send result, err := client.SendQueueMessage(ctx, kubemq.NewQueueMessage(). SetChannel(channel). SetBody([]byte(`{"id":1}`)), ) if err != nil { log.Fatal("Failed to send:", err) } if result.IsError { log.Fatal("Send error:", result.Error) } fmt.Printf("Sent: id=%s\n", result.MessageID) // Receive resp, err := client.PollQueue(ctx, &kubemq.PollRequest{ Channel: channel, MaxItems: 1, WaitTimeoutSeconds: 5, AutoAck: true, }) if err != nil { log.Fatal("Failed to receive:", err) } for _, dsMsg := range resp.Messages { fmt.Printf("Received: %s\n", dsMsg.Message.Body) } } ``` Requires Go 1.23 or later. Nine more languages plus REST are covered in [Client SDKs](/sdks). ## 5 · Pick your path [#5--pick-your-path] ## Didn't work? [#didnt-work] * **Port already in use** — something else is bound to `50000`, `9090`, or `8080`. Stop it, or change the published ports in the `docker run` command. * **Missing or invalid token** — `KUBEMQ_TOKEN` must be a real key from [Get a license key](/deploy/license-key). A missing or malformed token makes the server exit immediately on startup. * **Container not `Up`** — run `docker ps` and check the `kubemq` container's status; `docker logs kubemq` shows the exit reason if it isn't running. # Connectors (/connectors) A **connector** is a built-in, server-side **protocol gateway** in kubemq-server that bridges an external protocol to KubeMQ messaging — no KubeMQ SDK is required on the client. Point a standard HTTP, JSON-RPC, or CloudEvents client at the server and it speaks to the message broker for you. ## What is a connector [#what-is-a-connector] KubeMQ ships connectors in **two classes**, all built into the server. **Shared-HTTP gateway** — runs on the shared HTTP server (port 9090): * **CloudEvents** — a native CNCF CloudEvents-over-HTTP interface for all five messaging patterns — Events, Events Store, Queues, Commands, and Queries — with CESQL routing and SSE. KubeMQ's two AI-facing HTTP gateways — **AI Agents (A2A)** and **MCP** — also fit this connector definition: they are built-in, server-side HTTP gateways that run on the same shared HTTP server. They are now documented under [Aiway](/aiway), KubeMQ's AI Agents Fabric, where the agent-to-agent story is told as a whole. **Wire-protocol connectors** — six connectors that speak a standard messaging protocol on their **own dedicated ports**, so an existing client connects by changing only its connection string, broker address, or endpoint URL: * **AMQP 1.0** — native AMQP 1.0 clients, all five patterns (ports 5672 / 5671). * **RabbitMQ (AMQP 0-9-1)** — RabbitMQ clients over the AMQP 0-9-1 wire dialect (5672 / 5671). * **AWS (SQS & SNS)** — unmodified AWS SDKs over the real SQS/SNS HTTP protocols (port 4566). * **MQTT** — MQTT 3.1.1 / 5.0 clients (ports 1883 / 8883 / 8083). * **STOMP** — native STOMP 1.0/1.1/1.2 clients (ports 61613 / 61614). * **Google Cloud Pub/Sub** — unmodified Pub/Sub clients via `PUBSUB_EMULATOR_HOST` over the real Pub/Sub v1 gRPC protocol (port 8085). A connector is distinct from the other ways you reach KubeMQ. A **native SDK client** links a KubeMQ library into your application and speaks the gRPC API. A **framework adapter** (NestJS, Spring Boot, Celery, KEDA, and the others under [Integrations](/integrations)) wires KubeMQ into a specific framework's programming model. A connector, by contrast, runs **inside the server** and accepts a standard wire protocol directly — nothing KubeMQ-specific is installed on the caller. The **HTTP gateways** listen on the **shared HTTP server (port 9090)** and are **enabled by default** — start kubemq-server and CloudEvents (along with the Aiway gateways, A2A and MCP) is live. The **wire-protocol connectors** listen on their own dedicated ports and are **all opt-in (disabled by default)** — each opens a new network listener, so you enable only the ones you need. *CloudEvents is the shared-HTTP gateway documented here; the AI gateways (A2A & MCP) run on the same port 9090 server but are documented under Aiway. The six wire-protocol connectors listen on their own ports. All front the same message broker.* ## The HTTP gateway [#the-http-gateway] AI agents (A2A) and MCP now live under **[Aiway](/aiway)** — KubeMQ's AI Agents Fabric. They are still built-in HTTP gateways on the shared HTTP server; their docs just moved to where the agent-to-agent story is told end to end. ## The wire-protocol connectors [#the-wire-protocol-connectors] Speak a standard messaging protocol on a dedicated port — point an existing client at KubeMQ by changing only its connection string, broker address, or endpoint URL. ## Shared foundations [#shared-foundations] Every **HTTP gateway** — CloudEvents here, plus the Aiway gateways (A2A and MCP) — runs on **one HTTP server** and shares the same auth model, middleware chain, and metrics surface. That shared story is told once here, and each gateway links back to it instead of repeating it (the wire-protocol connectors document their own ports and auth in their own pages): Every HTTP gateway also inherits the unified middleware pipeline — JWT auth, CORS, origin validation, TLS/mTLS, a traffic gate, and OpenTelemetry tracing — and the reserved `_AGENTS_.` channel prefix, which user channels may not use. Connector metrics roll up into the cluster-wide observability view alongside the rest of the server. To **disable** an HTTP gateway, set its enable env var to `false` — for example `CONNECTORSCE_ENABLE=false` for CloudEvents. The HTTP gateways are on by default; you never need to set `=true` for them. See [Shared HTTP server](/connectors/concepts/shared-http-server) for their disable vars and why the names look the way they do. The **wire-protocol connectors are all opt-in** — disabled by default. Enable each one explicitly with its enable env var: `CONNECTORSMQTT_ENABLE=true`, `CONNECTORS_AMQP_ENABLE=true`, `CONNECTORS_AMQP10_ENABLE=true`, `CONNECTORS_STOMP_ENABLE=true`, `CONNECTORS_AWS_ENABLE=true`, `CONNECTORS_GCP_ENABLE=true`. Each wire-protocol connector documents its own ports, enable var, and auth in its own pages. ## Migrating from another broker [#migrating-from-another-broker] Already running RabbitMQ, ActiveMQ, JMS, AMQP 1.0, AWS SQS/SNS, STOMP, GCP Pub/Sub, or MQTT? The [Migration hub](/connectors/how-to/migration) maps each ecosystem to its KubeMQ wire-protocol connector and walks through the cutover — endpoint changes, concept mapping, a working client example, and what does not carry over — for all eight. # Messaging Patterns (/learn) KubeMQ is a single message broker that supports four distinct messaging patterns — from fire-and-forget broadcasting to reliable point-to-point delivery to synchronous request-reply. This section is a learning track, not just a reference: it starts with vendor-neutral fundamentals, shows how KubeMQ implements each pattern, helps you pick and combine them, then composes them into real architectures. One broker, four patterns, every use case. ## How to read this track [#how-to-read-this-track] Work through it in order, or jump straight to the tier you need. Each step links to the next. **New to messaging?** Start with the [Concepts](/learn/concepts) — six short pages that explain the ideas every pattern is built on. ## The Four Patterns [#the-four-patterns] ## The Four Patterns at a Glance [#the-four-patterns-at-a-glance] | Feature | [Events](/learn/events) | [Events Store](/learn/events-store) | [Queues](/learn/queues) | [RPC](/learn/rpc) | | ------------------ | ----------------------- | ----------------------------------- | ----------------------- | ----------------- | | Direction | Pub/Sub | Pub/Sub | Point-to-Point | Request-Reply | | Persistence | No | Yes (disk) | Yes (disk) | No | | Delivery guarantee | At-most-once | At-least-once | Exactly-once | At-most-once | | Replay | No | Yes (6 positions) | No | No | | Acknowledgment | No | No | Yes (manual) | Yes (automatic) | | Ordering | No | Yes (sequence) | Yes (FIFO) | N/A | | Response | No | No | No | Yes | | Dead letter queue | No | No | Yes | No | | Delayed delivery | No | No | Yes | No | | Caching | No | No | No | Yes (queries) | ## Which Pattern Should I Use? [#which-pattern-should-i-use] Use the [interactive decision guide](/learn/guides/choosing-a-pattern) to find the right pattern for your use case. | If you need... | Use | | --------------------------------------------- | ----------------------------------- | | Lowest latency, message loss acceptable | [Events](/learn/events) | | Pub/sub with no message loss | [Events Store](/learn/events-store) | | Replay historical messages | [Events Store](/learn/events-store) | | One consumer per message, guaranteed delivery | [Queues](/learn/queues) | | Retry, DLQ, delayed delivery | [Queues](/learn/queues) | | Synchronous request-reply | [RPC](/learn/rpc) | | Execute action, confirm success | [RPC Commands](/learn/rpc) | | Request data, get result | [RPC Queries](/learn/rpc) | ## Combining Patterns [#combining-patterns] Patterns can work together in a single application. KubeMQ's channel routing syntax lets you publish to multiple patterns simultaneously. Here are two common shapes. *Order pipeline: a command processes the order, emits an event to the durable log, which feeds a queue for reliable fulfillment.* *CQRS shape: commands write through the event store, the read service subscribes to build its projection, and clients query that read side.* Channel routing syntax: use `;` to separate channels and `:` to prefix the pattern type: ```text events:live-feed;events_store:archive;queues:process ``` ## Get Started [#get-started] # Integrations (/integrations) KubeMQ **integrations** are framework and platform adapters that wire KubeMQ into a specific framework's programming model — so you keep writing idiomatic NestJS, Spring, Celery, or Ray Serve code while the adapter handles the messaging underneath. Every integration is a native gRPC SDK client on port `50000`; there is no separate protocol to learn and no SDK call to make by hand. The protocol gateways are documented separately: A2A and MCP under [**Aiway**](/aiway), and CloudEvents under [**Connectors**](/connectors). ## What an integration is [#what-an-integration-is] An integration is a thin layer over the native KubeMQ gRPC SDK: it exposes KubeMQ through a framework's own idioms (decorators, listeners, broker URLs, scalers) and connects to the broker on `:50000` like any other SDK client. There are three ways to reach KubeMQ, differing in **what you install** and **where the bridge runs**: | Approach | What you install | Where the bridge runs | Wire protocol | Example | | ------------------------------- | ---------------------------------- | --------------------------------- | ------------------------------------- | -------------------------------------- | | Native SDK client | a KubeMQ library | in your app | gRPC `:50000` | [Go](/sdks/go), [Python](/sdks/python) | | Framework adapter (integration) | the adapter package + the gRPC SDK | in your app, inside the framework | gRPC `:50000` | this section | | Server connector | nothing on the caller | inside kubemq-server | HTTP / JSON-RPC / CloudEvents `:9090` | [Connectors](/connectors) | An integration **is** a native SDK client — it just sits *inside* a framework, exposing KubeMQ through that framework's idioms rather than raw SDK calls. A **server connector**, by contrast, runs inside kubemq-server and accepts a standard wire protocol on the shared HTTP server, so the caller installs nothing KubeMQ-specific — see [Connectors](/connectors) for that side of the picture. Because integrations are gRPC clients, they connect to the **gRPC server on port 50000**, which runs independently and is **always on**. They are **not** HTTP connectors and need **no connector enable flag** — `CONNECTORS*_ENABLE` gates only the HTTP connectors (A2A / MCP / CloudEvents) at `:9090`. The only prerequisite for any integration is a running KubeMQ broker reachable on `:50000`. ## Architecture [#architecture] Different apps load different adapters, but they all converge on the same native gRPC SDK and the same broker on port `50000`. *Each framework loads its own adapter, but all integrations ride the native gRPC SDK to the broker on :50000.* ## Messaging Framework Adapters [#messaging-framework-adapters] Wire KubeMQ in as the transport for an application-messaging framework. ## Task & Inference Processing [#task--inference-processing] Use KubeMQ as the work queue behind distributed task and ML-inference systems. ## Platform & Operations [#platform--operations] Integrate KubeMQ with the surrounding platform — provisioning, DI, and autoscaling. ## Compare the integrations [#compare-the-integrations] Every integration is a native gRPC SDK client on `:50000`. They differ by language, framework, the KubeMQ patterns they expose, and what you install. | Integration | Language / Runtime | Framework + min version | KubeMQ patterns | Primary use case | Package / install | | ---------------------------------------- | ------------------------ | ----------------------------- | -------------------------------------------------------------- | --------------------------------------- | ------------------------------------------------------ | | [NestJS](/integrations/nestjs) | TypeScript / Node 20.11+ | NestJS 10.x / 11.x | Events · Events Store · Queues · Commands · Queries | Decorator-driven NestJS microservices | `npm i @kubemq/nestjs-transport kubemq-js` | | [Spring Boot](/integrations/spring-boot) | Java / Kotlin / JVM 17+ | Spring Boot 3.2.0+ | Events · Events Store · Queues · Commands · Queries | Spring services + Spring Cloud Stream | `io.kubemq:kubemq-spring-boot-starter` | | [MassTransit](/integrations/masstransit) | C# / .NET 8.0 | MassTransit 8.5+ | Events · Events Store · Queues · Commands · Queries | KubeMQ as a MassTransit transport | NuGet `MassTransit.KubeMQ` | | [FastStream](/integrations/faststream) | Python 3.11+ | FastStream 0.6.7+ | Events · Events Store · Queues · Commands · Queries | Async, event-driven Python apps | `pip install kubemq-faststream` | | [Watermill](/integrations/watermill) | Go 1.25+ | Watermill | Events · Events Store · Queues · Commands · Queries | Watermill pub/sub + CQRS in Go | `go get github.com/kubemq-io/watermill-kubemq` | | [Celery](/integrations/celery) | Python 3.10+ | Celery 5.4+ | Queues (tasks) · result backend | Distributed Python task queues | `pip install kubemq-celery` | | [Ray Serve](/integrations/rayserve) | Python 3.10+ | Ray Serve 2.50+ | Queues · Queries · Events | Async / sync ML inference | `uv pip install kubemq-rayserve` | | [.NET Aspire](/integrations/aspire) | C# / .NET 8.0 or 9.0 | .NET Aspire 9.0+ | Container provisioning + DI (all patterns via `IKubeMQClient`) | Provision KubeMQ + wire `IKubeMQClient` | NuGet `KubeMQ.Aspire.Hosting` + `KubeMQ.Aspire.Client` | | [KEDA](/integrations/keda) | Go (cluster service) | KEDA 2.10+ · Kubernetes 1.27+ | Queue-depth (`Waiting`) metric | Autoscale queue consumers on Kubernetes | Helm `kubemq-keda-scaler` | ## Which integration should I use? [#which-integration-should-i-use] Power users: the [comparison matrix](#compare-the-integrations) above lists every integration's language, framework version, patterns, and install command side by side. | If you need to… | Use | | ---------------------------------------------------------- | ---------------------------------------- | | Wire KubeMQ into a NestJS app with decorators & DI | [NestJS](/integrations/nestjs) | | Add KubeMQ to a Spring Boot service (template + listeners) | [Spring Boot](/integrations/spring-boot) | | Use KubeMQ as a MassTransit transport (.NET) | [MassTransit](/integrations/masstransit) | | Build async Python apps with the FastStream framework | [FastStream](/integrations/faststream) | | Use KubeMQ as a Watermill pub/sub in Go | [Watermill](/integrations/watermill) | | Run distributed Python task queues (drop-in Celery broker) | [Celery](/integrations/celery) | | Serve async/sync ML inference on Ray Serve | [Ray Serve](/integrations/rayserve) | | Provision KubeMQ + wire `IKubeMQClient` in .NET Aspire | [.NET Aspire](/integrations/aspire) | | Autoscale queue consumers on Kubernetes by queue depth | [KEDA](/integrations/keda) | ## Next steps [#next-steps] # Operate KubeMQ (/operate) Once KubeMQ is deployed and configured, this is where you keep it healthy. KubeMQ exposes metrics, distributed tracing, structured logging, and an audit trail, so you always have visibility into what the server is doing and can catch issues early. Operators interact with a running broker through the web dashboard and the kmq CLI. # kmq CLI (/operate/kmq-cli) `kmq` is KubeMQ's command-line client — a single static Go binary that talks to the KubeMQ management API on port **`:8080`** (the same API the web dashboard uses). It is built to be **agent-native**: predictable output, typed exit codes, and a self-describing command tree make it a first-class tool for AI coding agents as well as human operators. ## Overview [#overview] `kmq` exposes every messaging pattern — Queues, Events, Events Store, and RPC (Commands/Queries) — plus full observability (status, metrics, connections, audit, connectors, agents) and a set of meta/discovery commands. Four properties make it suited to automated and agent-driven use: * **Token-efficient** — a `kmq queue send` is a few hundred tokens versus a multi-round exchange over a protocol like MCP. * **Deterministic** — typed exit codes and machine-readable `json`/`ndjson` output make results easy to branch on in a script or an agent loop. * **Self-describing** — `kmq schema` emits the whole command tree, offline, with no network call. * **Bounded by default** — every stream/subscribe/replay command honors `--count`/`--duration`/`--idle`, so an agent can never loop forever waiting on it. `kmq` is a *client of* the management API — it does not embed the broker, and it never talks to the message broker directly. Every action is an HTTP call to the `:8080` management API. `kmq mcp` reads the server's **registered MCP tools**; it does not make `kmq` itself an MCP server. ## Architecture [#architecture] `kmq` never talks to the message broker directly. Every action is an HTTP call to the `:8080` management API: one-shot actions dispatch via `POST /api/request` (`{type, data}`) or dedicated REST routes, and streaming commands use Server-Sent Events / WebSocket subscription endpoints. Meta commands (`schema`, `cheat`, `skills`) serve content **compiled into the binary**, so they work fully offline and never need the server. ## Installation [#installation] ```sh # Primary — GitHub Releases (kubemq-io/kmq), no credentials curl -sSfL https://raw.githubusercontent.com/kubemq-io/kmq/main/install.sh | sh # Pin a version, or verify the cosign signature strictly curl -sSfL https://raw.githubusercontent.com/kubemq-io/kmq/main/install.sh | sh -s -- --version v0.3.1 curl -sSfL https://raw.githubusercontent.com/kubemq-io/kmq/main/install.sh | sh -s -- --verify-signature # Container image (no install) podman run --rm europe-docker.pkg.dev/kubemq/images/kmq:latest version ``` `install.sh` detects OS (`linux`/`darwin`/`windows`) and architecture (`amd64`/`arm64`), resolves the version, downloads the archive plus `checksums.txt`, and performs a **mandatory SHA-256 verification** — the install aborts if it cannot verify the checksum. An **optional cosign signature check** runs on top of that (best-effort by default, strict when requested), and the binary installs to the first writable `PATH` directory. | Install env var | Purpose | | ---------------------- | ------------------------------------------------- | | `KMQ_VERSION` | Explicit version to install (e.g. `v0.3.1`) | | `KMQ_INSTALL_DIR` | Target directory for the `kmq` binary | | `KMQ_BASE_URL` | Mirror/staging base URL (switches to mirror mode) | | `KMQ_PREFIX` | Object prefix for a mirror (default `kmq`) | | `KMQ_VERIFY_SIGNATURE` | Set to `1` to require a valid cosign signature | ## Contexts & configuration [#contexts--configuration] A **context** is a named connection profile (API address, token, TLS, defaults). Contexts live under `$XDG_CONFIG_HOME/kmq/` (`~/.config/kmq/` by default): ```text $XDG_CONFIG_HOME/kmq/ ├── contexts/ │ ├── default.json │ └── prod.json └── current-context # pointer file (active context name) ``` ```sh kmq context create default --api-address http://localhost:8080 kmq context create prod \ --api-address https://kubemq.example.com:8080 \ --token kmq__ --tls kmq context use prod kmq context list # (alias: ls) — current-context marked kmq context current kmq context edit prod --token kmq__ kmq context delete staging # (aliases: rm, remove) ``` Configuration is resolved by precedence, highest to lowest: | Precedence | Source | | ----------- | ----------------------------------------------- | | 1 (highest) | Persistent flags (`--context`, `--api-address`) | | 2 | Environment variables | | 3 | Active-context file | | 4 (lowest) | Built-in defaults (`http://127.0.0.1:8080`) | | Env var | Purpose | | ----------------- | --------------------------------------------------------------------- | | `KMQ_TOKEN` | Service-account Bearer key (`kmq__`) — preferred in CI | | `KMQ_CONTEXT` | Active context name (overrides the pointer file) | | `KMQ_API_ADDRESS` | Management API URL | ### Authentication & roles [#authentication--roles] Authentication is opt-in on the server — see [Security](/configure/reference/security) for the account model and roles. Check whether it is on with `kmq doctor -o json | jq .auth` (`on`/`off`). When auth is on, supply a service-account key via `KMQ_TOKEN` or the active context. Service-account roles gate what the CLI can do: | Role | Grants | | ------------ | ---------------------------------------------------------------------- | | `read_only` | list/inspect, metrics, status, overview, schema, doctor, billing | | `read_write` | the above + send/receive/stream/subscribe/purge, channel create/delete | | `admin` | the above + audit, account management | A handful of commands and routes succeed with **no token**, even when server auth is on: `kmq doctor`, `kmq metrics scrape`, `kmq billing` (routes `/ready`, `/health`, `/metrics`, `/billing`, `/api/v1/auth/status`). Don't read the role table above as universal gating — these are the exceptions. `config set`/`revert` and account management require the `admin` role. Service accounts never carry `admin`, so those operations are deliberate non-goals of the CLI. ## Global flags & output discipline [#global-flags--output-discipline] Persistent flags are inherited by every subcommand: | Flag | Default | Purpose | | --------------- | --------- | ----------------------------------------------------------- | | `-o, --output` | `json` | Output format: `json` \| `ndjson` \| `yaml` \| `table` | | `--context` | — | Use a specific context (overrides current-context) | | `--api-address` | — | Target `:8080` endpoint (overrides context) | | `--no-color` | `false` | Disable color in table output | | `--verbose` | `false` | Request timing to stderr (token redacted) | | `--yes` | `false` | Confirm destructive operations without prompting | | `--dry-run` | `false` | Render the action without executing | | `--fields` | — | Project output to these camelCase wire fields | | `--detail` | `summary` | `summary` \| `full` verbosity, for commands that support it | Output discipline: * **Data → stdout**, warnings/errors/diagnostics → **stderr** — safe to pipe. * One-shot commands default to compact `json`; **streaming** commands (`queue stream`, `*subscribe`, `*replay`, `conn watch`, `command/query receive`) default to **`ndjson`** (one record per line, flushed per record). * `metrics scrape` emits raw Prometheus text; `cheat` and `skills get` emit raw markdown — for those, `-o` is ignored. ```sh kmq queue receive orders --count 10 -o ndjson | jq .body # stream, per-line kmq estore replay telemetry --from-first --count 100 -o json # buffer then array kmq status --fields is_healthy,channels,clients ``` ## Exit codes [#exit-codes] `kmq` returns typed exit codes so scripts and agents can branch deterministically. On error it also writes a JSON envelope to stderr: `{"error":{"code":"...","message":"...","retryable":bool}}`. | Code | Name | Meaning | Retry? | | ---- | --------- | --------------------------------------- | ------------------------ | | 0 | OK | Success | — | | 1 | Generic | Unclassified error | No | | 2 | Usage | Bad flags / usage | No | | 3 | NotFound | Resource not found | No | | 4 | Auth | Auth required / failed / forbidden | No | | 5 | Conn | Server unreachable | Yes (server down?) | | 6 | Timeout | Request timed out | Yes | | 7 | Partial | Partial success | Case-by-case | | 8 | Retryable | Server initializing / too many attempts | Yes — `kmq` auto-retries | Server wire codes map onto this table as follows: `auth_required`, `auth_failed`, `forbidden`, `must_change`, and `tls_required` all map to exit code **4**; `auth_initializing` and `too_many_attempts` map to exit code **8** and are auto-retried; `seed_read_only`, `duplicate`, and `service_admin_forbidden` map to exit code **2**; any empty or unknown wire code maps to exit code **1**. ## Command reference [#command-reference] Every send command reads its body from (in priority) a positional argument → `--body-base64` → `-f/--file` → stdin, and accepts `--message-id`/`--client-id`/ `--metadata`/`-M`/`--tag k:v` (repeatable). Every stream/subscribe/replay/receive command accepts the bounding flags `--count N`, `--duration 5m`, `--idle 30s` — whichever fires first stops it; Ctrl-C also exits cleanly with code 0. ### Messaging [#messaging] | Pattern | Send | Receive / Subscribe | Notes | | ----------------------------------- | ------------------------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Queue](/learn/queues) | `kmq queue send ` | `kmq queue receive [--count N]` | persistent, at-least-once | | Queue (peek) | — | `kmq queue peek [--count N]` | non-consuming | | Queue (interactive) | — | `kmq queue stream [--visibility 60] [--wait 5] [--auto-ack]` | WS poll/ack/reject session | | Queue (drain) | — | `kmq queue purge --yes` | destructive | | [Events](/learn/events) | `kmq events send ` | `kmq events subscribe [--group g]` | fire-and-forget pub/sub | | [Events Store](/learn/events-store) | `kmq estore send ` | `kmq estore subscribe ` | persistent + replayable | | Events Store (replay) | — | `kmq estore replay ` | historical replay — one offset mode: `--new-only` (default), `--from-first`, `--from-last`, `--from-sequence N`, `--from-time `, `--since-seconds N`, plus `--group` | | [Command](/learn/rpc) (RPC) | `kmq command send --timeout 30` | `kmq command receive [--respond-body '{}' \| --command 'sh']` | request/ack | | [Query](/learn/rpc) (RPC) | `kmq query send --timeout 30` | `kmq query receive [--respond-body '{}' \| --command 'sh']` | request/data | **Queue send** extras: `--max-receive-count`, `--dead-letter `, `--expiration-seconds`, `--delay-seconds`. **RPC responders**: `receive` with `--respond-body ''` echoes a static reply; with `--command ''` the inbound body is piped to the command's stdin and its stdout becomes the reply; with neither, requests are printed for manual handling. `--respond-body` and `--command` are mutually exclusive. ### Channels [#channels] ```sh kmq channel create --type queues|events|events_store|commands|queries kmq channel delete --type queues --yes kmq channel list [--type ] kmq channel inspect # full detail: clients, rates, totals # per-family shortcuts also exist: kmq queue list / kmq queue inspect ``` ### Cluster [#cluster] ```sh kmq cluster info [--node] # (alias: snapshot) cluster-merged, or --node for local kmq cluster health # /ready — leadership role, ready/healthy kmq cluster nodes # topology: node, type, unavailable nodes ``` ### Observability [#observability] ```sh kmq status # composite digest: /ready + snapshot, one-line health kmq overview [--detail full] # per-pattern rollups (queues / pubsub / request-reply / totals) kmq metrics scrape # raw Prometheus /metrics (auth-exempt) kmq metrics history [--metric message_rate|volume_rate|error_rate|messages|bytes] kmq conn list # active connections (first SSE snapshot) kmq conn inspect kmq conn watch # live SSE stream of connection events kmq audit query [--from ..] [--to ..] [--event-type queue.send] [--limit N] ... kmq audit stats [--group-by event_type|client_id|category|channel|transport] kmq connector list # aws / gcp / amqp / amqp10 / stomp / mqtt / ce kmq connector inspect [--operation ] kmq agent list [--limit N] [--offset N] # A2A agents kmq agent inspect kmq mcp list # server's registered MCP tools kmq mcp inspect ``` ### Kafka [#kafka] ```sh kmq kafka probe --bootstrap host:port[,host:port] # dial a source cluster, read-only kmq kafka probe -b host:port --dial-timeout 30s # override the 15s default kmq kafka probe -b host:port \ --aws-access-key --aws-secret-key --aws-session-token # MSK IAM ``` `kmq kafka probe` dials a **source** Apache Kafka / MSK / Confluent cluster over the Kafka wire protocol and reports back the brokers plus their `ApiVersions`. It is **read-only** — it never produces, commits, or auto-creates topics — so it's safe to point at a production cluster. Flags: `-b/--bootstrap host:port[,host:port...]`, `--dial-timeout` (default `15s`), and AWS MSK-IAM auth via `--aws-access-key`/`--aws-secret-key`/`--aws-session-token`. Run it as a first connectivity check ahead of the [Migration](#migration) family below, or see [Migrate from Kafka](/connectors/kafka/how-to/migrate-from-kafka) for the full workflow. ### Migration [#migration] | Command | Flags | Notes | | --------------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | | `kmq assess kafka` | `--bootstrap`, `--tls`, `--sasl-mechanism` | Read-only fit assessment of an external Kafka cluster → per-topic READY/CAVEAT/UNKNOWN/BLOCKED + a T1–T4 verdict. Never writes to the source. | | `kmq migrate assess\|replicate\|translate\|cutover` | `--state`, `--dry-run`, `--force` | **Beta** four-phase migration (assess → replicate → translate → cutover) from a Kafka cluster to KubeMQ. | For the full narrative (per-source auth, MirrorMaker 2 hybrid, rollback, staged dry-run) see [Migrate from Kafka](/connectors/kafka/how-to/migrate-from-kafka). ### Meta & discovery [#meta--discovery] ```sh kmq version # binary version kmq whoami # identity + role (auth on), or {"authenticated":false,"auth":"disabled"} (auth off) kmq doctor # connectivity + auth check (no auth required) kmq config get [--fields ..] # server config (read-only, server-redacted) kmq billing # usage/license (auth-exempt) kmq schema -o json # full command tree + roles + exit codes (offline) kmq cheat [topic] # embedded recipes (offline) kmq docs [topic] [--open] # signpost to the online docs + LLM corpus kmq skills ... # serve/install the agent skill (see next section) ``` `kmq cheat` topics (embedded, offline): `queue`, `events`, `estore`, `rpc`, `drain`, `subscribe`, `health`, `auth`, `context`, `output`. `kmq schema -o json` is the machine-readable contract — the full command tree (path, description, required role, flags), the exit-code table, the server wire error-code catalogue, and the CLI/server versions — with no network and no auth. It is the recommended way for an agent to introspect the CLI. ## Agent-skill distribution [#agent-skill-distribution] Any coding agent can be taught to drive `kmq` with one command, and the instructions always match the installed binary version — the skill content is served straight from the binary, so it can never go stale between releases. ```sh kmq skills # list skills (alias: kmq skills list) → 'core' kmq skills get core # print the core skill (raw markdown) kmq skills get core --full # + the full command reference kmq skills get --all # every skill kmq skills path [name] # skills source dir, or '(embedded)' kmq skills install [--global] [--force] # install the stub locally (zero-Node) ``` | Install path | Command | Reach | | ------------------- | --------------------------------------------- | ---------------------------------------------------------------------- | | Universal installer | `npx skills add kubemq-io/kmq` | 70+ agents (Claude Code, Cursor, Codex, Gemini CLI, Windsurf, …) | | Claude Code plugin | `claude plugin marketplace add kubemq-io/kmq` | Claude Code | | Zero-Node fallback | `kmq skills install` | Local Claude Code install; `--global` installs it for the current user | ## Server communication [#server-communication] `kmq` speaks to the management API over HTTP. One-shot actions dispatch as `POST /api/request` with `{type, data}` (or dedicated REST routes); the server replies HTTP 200 with an envelope: ```json { "error": false, "error_string": "", "code": "", "data": {} } ``` `error: false` decodes `data` into the requested output; `error: true` maps the `code` field to an exit code via the wire-code table above. A handful of routes are auth-exempt (no Bearer needed) — the same ones called out in the **Auth-exempt commands** note above. Retryable server states (`auth_initializing`, `too_many_attempts`) are auto-retried with exponential backoff, cancellable by SIGINT/SIGTERM. Streaming uses SSE/WebSocket subscription endpoints and stops on the first of `--count`/`--duration`/`--idle` or a signal. SIGINT/SIGTERM cancel the root context — streaming/waiting commands close gracefully and exit 0 on clean cancellation. ## Related [#related] # Web Dashboard (/operate/web-dashboard) The web dashboard is the primary visual surface for a running KubeMQ broker. It's a single-page application served on port **8080** that shows cluster health, per-channel throughput, connected clients, protocol connectors, AI agents, the audit trail, and live configuration. ## Overview [#overview] The dashboard is an embedded SPA served by the [management API](/operate/observability/api-reference). Every number it renders comes from the same metrics collectors that feed the Prometheus exporter and the JSON management endpoints — the dashboard is one view over that data, not a separate source of truth. The dashboard is **read-only for messaging data**. Producing and consuming happen through the client SDKs, the REST/gRPC API, or a drop-in connector — never from the UI. The only write paths in the dashboard are control-plane operations: editing or reverting configuration, and managing accounts. ## Access [#access] | Property | Value | | --------- | -------------------- | | URL | `http://:8080` | | Served by | Management API | | Bundle | Embedded SPA | The dashboard is open by default, with no login required. When control-plane authentication is enabled, it requires a session login, and features are gated by the caller's role (viewer / editor / admin). See [Security](/configure/reference/security) for the account model and roles. ## The shell [#the-shell] Common controls appear on every page: * **Left navigation** — switches between the sections listed below. * **Time range** — selects the window used by all rate charts on the page. * **Refresh interval** — auto-refresh rate for live values. * **Theme** — light / dark toggle. * **Language** — UI localization. ## Navigation map [#navigation-map] ## Sections [#sections] ### Overview [#overview-1] The landing page. Aggregate cluster health, total send/receive rates across all patterns, and a connector band summarizing every active protocol connector at a glance. Use it as the daily starting point before drilling into a specific channel or connector. ### Channels [#channels] One page per messaging family — [Events](/learn/events), [Events Store](/learn/events-store), [Queues](/learn/queues), and [Commands / Queries](/learn/rpc) (RPC). Each page lists the channels of that family with their current rates and, where applicable, depth. Clicking a channel opens a detail view with throughput and backlog history over the selected time range, plus the clients attached to it. Key per-channel values: * **Send / receive rate** — current-activity message rate (msg/s). * **Waiting** (Queues) — messages ready and waiting for a consumer. * **Delayed** (Queues) — messages scheduled for future delivery. * **Clients** — connected producers/consumers on the channel. ### Clients [#clients] Every client connected to the broker: identity, channels in use, and activity. Use it to confirm expected consumers are attached and to spot clients that connected or dropped. ### Connectors [#connectors] One page per drop-in protocol connector — [AWS SQS/SNS](/connectors/aws), [Google Pub/Sub](/connectors/gcp-pub-sub), [MQTT](/connectors/mqtt), [AMQP 0-9-1](/connectors/rabbitmq), [AMQP 1.0](/connectors/amqp), [STOMP](/connectors/stomp), and [CloudEvents](/connectors/cloudevents). Each shares the same shape: connection/link counts, per-operation throughput, error rates, and history charts specific to that protocol's operations. These pages are how you confirm a drop-in connector is receiving client traffic and mapping it onto KubeMQ channels. ### Agents [#agents] **A2A** and **MCP** pages showing registered AI agents, their liveness, and request throughput/latency. See [Aiway](/aiway). ### Accounts [#accounts] Present when control-plane authentication is enabled. Manage user and service accounts, assign roles, and issue/revoke API keys. Gated to the admin role. See [Security](/configure/reference/security). ### Audit [#audit] The audit trail — control, data, and system events — with filtering. The who-did-what record for management and messaging activity. See [Audit Logging](/operate/observability/audit). ### Config [#config] A live view of the effective server configuration, with the ability to change and revert it. A control-plane write path, gated by role. See [Security](/configure/reference/security) for who can write, and [Observability settings](/configure/reference/observability) for the fields shown. ### System [#system] Node and cluster status: per-node health, cluster membership, and liveness. In a multi-node deployment, most metric pages present cluster-wide aggregates while System exposes the per-node breakdown. ## How to read the numbers [#how-to-read-the-numbers] * **Rates** are current-activity deltas in messages/second — short spikes under bursty producers are normal; a sustained slope is the signal that warrants action. * **Depths** (waiting, delayed, backlog) are point-in-time counts, not rates. * A rising **Waiting** count while the receive rate stays flat means consumers are not keeping up. A non-zero **Delayed** count is intentional scheduling, not a stuck queue. The same values are exported as Prometheus metrics and available on the JSON management API. See [Prometheus Metrics](/operate/observability/metrics) and the [Management API](/operate/observability/api-reference). ## Related [#related] # Release Notes (/release-notes) Release notes for the KubeMQ server, the kmq CLI, and the client SDKs. Each release gets its own page covering what's new, what changed, breaking changes, and how to upgrade. ## Current release [#current-release] **KubeMQ v3** (v3.1.4, August 2026) is the largest release in the product's history — a rebuilt storage and clustering core, drop-in support for the messaging protocols you already use, ten client SDKs, and a new management experience. ## All releases [#all-releases] | Release | Date | Highlights | | -------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------- | | [v3](/release-notes/v3) (v3.1.4) | August 2026 | Drop-in protocol connectors, Raft-based storage engine, kmq CLI, rebuilt dashboard, 10 SDKs, MCP/A2A | ## Staying up to date [#staying-up-to-date] * Coming from v2? The [v2 → v3 migration guide](/deploy/migrate-v2-to-v3) covers both paths — a fresh v3 cluster or an in-place upgrade. * Install and upgrade procedures: [Docker](/deploy/docker) · [Kubernetes with Helm](/deploy/kubernetes-helm). * Questions about a release? Reach us at [support@kubemq.io](mailto:support@kubemq.io). # KubeMQ v3 (/release-notes/v3) **KubeMQ v3.1.4 · August 2026.** KubeMQ v3 is the largest release in the product's history. Since v2.10.1, KubeMQ has been rebuilt around a new replicated storage core and extended to speak the messaging protocols your teams already use — Kafka, RabbitMQ, MQTT, AMQP 1.0/JMS, STOMP, AWS SQS/SNS, Google Pub/Sub, and CloudEvents — directly, with no bridges and no code changes. Everything your applications rely on today keeps working. The native gRPC, REST, and WebSocket APIs and all four messaging patterns — [Queues](/learn/queues), [Events](/learn/events), [Events Store](/learn/events-store), and [Commands/Queries](/learn/rpc) — are unchanged, and in-place upgrades preserve your data and configuration automatically. ## Highlights at a glance [#highlights-at-a-glance] * **Drop-in protocol support** — your existing Kafka, RabbitMQ, MQTT, AMQP 1.0, STOMP, SQS/SNS, and Google Pub/Sub clients connect directly to KubeMQ, unmodified. * **New storage engine** — a Raft-based replication and persistence core with a selectable durability contract, alongside the existing engine, which remains fully supported. * **Ten client SDKs** — the five you use today (Go, Java, C#/.NET, TypeScript, Python) upgraded for v3, plus five new: C++, Kotlin, Ruby, Rust, and Elixir. * **Framework integrations** — Spring Boot, NestJS, Celery, FastStream, MassTransit, .NET Aspire, Ray Serve, Watermill, and KEDA autoscaling. * **Rebuilt management dashboard** — per-protocol consoles, live configuration editing, accounts with roles, audit trail, six languages. * **New kmq CLI** — messaging, cluster administration, observability, and a guided Kafka migration workflow from one binary. * **OpenTelemetry** — distributed traces and metrics exported over OTLP. * **Enterprise security** — role-based access control for the management plane, API keys and service accounts, audit logging, and FIPS builds. * **AI-ready** — a built-in Model Context Protocol (MCP) server and Agent-to-Agent (A2A) support for agentic workloads. * **New Developer Center** — this documentation site. ## Protocol connectors — use your existing clients [#protocol-connectors--use-your-existing-clients] In v2, connecting non-KubeMQ clients meant deploying separate connector containers next to the broker. In v3, the broker itself speaks each protocol on its native port. The same client that talks to Kafka, RabbitMQ, or SQS today talks to KubeMQ tomorrow. All connectors are disabled by default and enabled per protocol in configuration; CloudEvents is on by default. A [migration guide](/connectors/how-to/migration) is available for each ecosystem. | Protocol | Ports | In short | | ----------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Kafka](/connectors/kafka) | 9092 / 9093 TLS | Full wire-protocol compatibility — producers (idempotent), consumer groups, offset management, admin operations, multi-partition topics, transactions with exactly-once semantics, log compaction and retention (enabling Kafka Connect and Kafka Streams). Share groups ("Queues for Kafka") in preview. Requires the next storage engine. | | [RabbitMQ / AMQP 0-9-1](/connectors/rabbitmq) | 5672 / 5671 TLS | Exchanges (direct, fanout, topic, headers), queues, bindings, publisher confirms, dead-letter exchanges with RabbitMQ-exact headers, prefetch, virtual hosts. | | [AMQP 1.0 / JMS](/connectors/amqp) | 5672 / 5671 TLS | Links, credit-based flow control, durable subscriptions, request/reply — compatible with Qpid JMS, go-amqp, and AMQP.Net Lite, including JMS message selectors. Both AMQP versions share one port with automatic detection. | | [MQTT 3.1.1 / 5.0](/connectors/mqtt) | 1883 / 8883 TLS / 8083 WS | QoS 0/1/2, shared subscriptions, last-will messages, MQTT 5 user properties and request/response. | | [STOMP 1.0–1.2](/connectors/stomp) | 61613 / 61614 TLS | All three acknowledgment modes, receipts, heartbeats, request/reply. | | [AWS SQS / SNS](/connectors/aws) | 4566 | Point your AWS SDK or CLI at KubeMQ — SQS with long polling, visibility timeouts, FIFO queues; SNS topics, subscriptions, and filter policies with Signature-V4 authentication. | | [Google Cloud Pub/Sub](/connectors/gcp-pub-sub) | 8085 | Emulator-compatible gRPC surface for the official client libraries — publish, pull, streaming pull, ordering keys, filters, dead-letter, seek/snapshots, schema validation. | | [CloudEvents 1.0](/connectors/cloudevents) | 9090 (on by default) | Native HTTP endpoint for structured and binary CloudEvents — publish into any pattern, consume over server-sent events with resume, route by content (CESQL). | The built-in connectors supersede the separate kubemq-targets, kubemq-sources, and kubemq-bridges deployments for the protocols above. The external connector containers remain available for other integrations — databases, caches, and third-party services. ## New storage engine [#new-storage-engine] v3 introduces a new replicated storage engine built on Raft consensus, designed for stronger durability guarantees and safer day-2 operations. Full details: [Storage engines](/configure/reference/storage-engines). * **Two engines, one server.** The v2 engine ("legacy", unchanged) and the new engine ("next") ship in the same binary and are selected in configuration. New deployments can adopt the next engine; existing ones stay on legacy until they choose to migrate. * **Selectable durability contract** on the next engine. In **strict** mode, a message is acknowledged only after it is replicated to a quorum *and* written to disk — zero acknowledged-message loss, even through node crashes. In **fast** mode (the default), acknowledgment follows quorum replication, with disk sync completing in a bounded background window. * **Engine auto-detection.** On upgrade, the server inspects the existing data directory and resolves the correct engine on its own. It never wipes or reinterprets data on a mismatch — ambiguity is a loud startup error, not a silent guess. * **Log compaction and time-based retention** on the next engine — the same mechanisms that enable Kafka Connect and Kafka Streams support. * **Engine scope.** The Kafka connector requires the next engine. All other connectors and all native messaging patterns run on both engines. ## Clustering and operations [#clustering-and-operations] Clustering has been rebuilt around the new engine with one focus: predictable failure handling. * **Safe node replacement.** A node that returns with lost or empty storage no longer rejoins silently. It stays up, reports the reason on its readiness endpoint, and is restored through an explicit, guided `kmq cluster replace` procedure that re-adds it as a learner until it has caught up. * **Calmer elections.** Pre-vote prevents a rejoining node from disrupting a healthy cluster, and leader changes warm up connector state — Kafka coordinators included — before serving traffic. * **Kubernetes-aware lifecycle.** Replica identity derives from the pod name, readiness is decoupled from liveness (a booting node reports live while quorum forms, avoiding restart loops), and shutdown phases are sized to the pod's termination grace period. * **Replication security.** Optional mutual TLS on the cluster replication port. ## New kmq command-line tool [#new-kmq-command-line-tool] One installable binary for everything that previously needed scripts, curl, or the dashboard. Full reference: [kmq CLI](/operate/kmq-cli). * **Messaging** — send, receive, peek, stream, and purge queues; publish and subscribe to events; replay the Events Store from six starting positions; send commands and queries and run responders. * **Cluster administration** — inspect health, members, and nodes; add, promote, remove, and replace members. Mutations are role-gated and confirmation-gated. * **Observability** — live connection watch, metrics, audit queries, and connector and agent inspection. * **Kafka migration workflow** — `kmq assess kafka` produces a read-only migration-fitness report of an existing Kafka cluster; `kmq migrate` replicates topics with exact offset mapping, translates configuration, and manages cutover. * **Automation-friendly** — machine-readable output, typed exit codes, dry-run support, and a complete offline schema of every command. ## Client SDKs — ten languages [#client-sdks--ten-languages] The SDK family has doubled from five languages to ten, all covering the four messaging patterns over the native gRPC API, each documented with working examples per pattern. Start here: [Client SDKs](/sdks). * **Upgraded** — Go, Java, C#/.NET, TypeScript (Node.js), and Python: updated and fully compatible with v3. * **New** — C++, Kotlin, Ruby, Rust, and Elixir. ## Framework integrations [#framework-integrations] Adapters for the frameworks your teams already use — you keep writing idiomatic framework code; the adapter handles the messaging underneath: Spring Boot, NestJS, Celery, FastStream, MassTransit, .NET Aspire, Ray Serve, Watermill, and KEDA autoscaling on queue depth. Details: [Integrations](/integrations). ## Management dashboard — rebuilt [#management-dashboard--rebuilt] What was a monitoring view in v2 is now a full management console, protected by login and roles. See [Web dashboard](/operate/web-dashboard). * **Cluster overview** — health, throughput, storage-engine status, and replication posture per node. * **Per-protocol consoles** — dedicated pages for Kafka, RabbitMQ-style queues, MQTT clients, AMQP 1.0 links, STOMP destinations, SQS/SNS, Google Pub/Sub, and CloudEvents, each modeled on the console your team already knows. * **Live configuration** — view and change every subsystem's settings from the UI, with revert support and role gating. * **Accounts and roles** — user accounts, service accounts, and API keys managed in the UI. * **Audit trail** — a searchable log of who changed what, and when. * **Channel monitor** — a live, observe-only tap on any channel. * **Six languages** — English, Spanish, French, German, Portuguese (Brazil), and Italian, in light and dark themes. ## Observability [#observability] * **OpenTelemetry** — distributed traces and metrics exported over OTLP (gRPC or HTTP), with W3C trace-context propagation and configurable sampling. Off by default. Setup: [OpenTelemetry guide](/learn/guides/opentelemetry). * **Metrics that survive restarts** — counters are durable; a server restart no longer resets your Prometheus series. * **Per-connector metrics** — dedicated metric families for every protocol connector. * **Audit logging** — control-plane and data-plane events recorded as CloudEvents with configurable retention, queryable from the API, the CLI, and the dashboard. On by default. ## Security and access control [#security-and-access-control] The management plane — dashboard and management API — is now protected by full authentication and role-based access control. Reference: [Security](/configure/reference/security). * **Management-plane access control** — user accounts with session login and service accounts with API keys; three roles (read-only, read-write, admin); deny-by-default on every management endpoint; hardened credential storage, login throttling, and CSRF protection. Off by default — existing deployments are unaffected until you enable it. * **Safer defaults** — the management port binds to localhost unless authentication is enabled or the server runs in a container or Kubernetes; secrets are redacted from configuration views. * **Data-plane security carried forward** — TLS/mTLS, JWT and OIDC authentication, and policy-based authorization continue as in v2, extended to the new protocol connectors. * **FIPS builds** — a dedicated FIPS image line, cryptographically verified and vulnerability-scan-gated before publication. * **Signed supply chain** — release images are vulnerability-scanned before publication; CLI artifacts ship with signed checksums. ## AI and agentic workloads [#ai-and-agentic-workloads] KubeMQ speaks the two emerging standards for AI-agent communication out of the box. Start here: [AI Way](/aiway). * **Model Context Protocol (MCP) server built in** — AI assistants and agents send, receive, and inspect KubeMQ messages through standard MCP tools, with no extra infrastructure. * **Agent-to-Agent (A2A) protocol** — agent registration and discovery, agent cards, synchronous request/reply, and streaming responses, backed by a cluster-replicated agent registry. * Dedicated dashboard pages for agent and MCP activity. ## Kubernetes and deployment [#kubernetes-and-deployment] Install guide: [Kubernetes with Helm](/deploy/kubernetes-helm). * **Helm charts** — cluster, controller, CRDs, and an umbrella chart. * **Declarative cluster resource** — a zero-configuration KubemqCluster custom resource; every connector and subsystem is configurable in the spec, with per-connector service exposure. * **Guard rails** — the operator records the cluster's established storage engine and refuses unsafe changes (engine switches, enabling Kafka on a legacy-engine cluster) instead of applying them silently. * **License visibility** — license type, holder, and expiry surfaced directly in `kubectl get`. * **Escape hatches** — an environment-variable overlay and secret projection for tunables the typed spec doesn't cover, with a deny-list protecting settings the operator owns. ## Breaking changes at a glance [#breaking-changes-at-a-glance] * **Helm installation** — the v2 stable chart line is retired. v3 charts are published on the prerelease channel and require `--devel` on install and upgrade. * **Storage engine choice is per-deployment** — there is no in-place migration between the legacy and next engines. A data directory created on one engine cannot be opened by the other; the server fails loudly rather than converting. * **Node-rebuild recovery sets a version floor** — cluster nodes rebuilt with the v3 recovery procedure cannot roll back below v3.1.0. * **Kafka requires the next engine** — enabling the Kafka connector on a legacy-engine deployment is a configuration error. * **Management API surface changes** — some management and statistics endpoints changed between v2 and v3. * **Storage sizing on the next engine** — native retention limits (maximum retention, queue size, and message caps) currently apply on the legacy engine only; plan storage for next-engine channels accordingly. * **External connector containers superseded** — the separate targets/sources/bridges deployments are replaced by built-in connectors for broker protocols; they remain available for other integrations. ## Known limitations [#known-limitations] * MQTT retained messages are not supported; the broker advertises this to connecting clients. * AMQP 0-9-1 transactions are not supported. * STOMP transactions and selectors are not supported. * Kafka — replication factor is always cluster-managed; the next-generation consumer protocol (KIP-848) and Kerberos are not supported. * Native retention limits are not yet enforced on next-engine channels. ## Upgrading [#upgrading] In-place upgrades from v2.10.x keep your data and configuration — the server detects your existing storage automatically. The [v2 → v3 migration guide](/deploy/migrate-v2-to-v3) covers both upgrade paths step by step. Adopting the next storage engine or the Kafka connector is a planned migration; see [Storage engines](/configure/reference/storage-engines) and the per-ecosystem [migration guides](/connectors/how-to/migration). Questions? Reach us at [support@kubemq.io](mailto:support@kubemq.io). # Client SDKs (/sdks) KubeMQ ships **official client libraries for ten languages**, each a thin, idiomatic wrapper over the same gRPC API. Every SDK speaks the four messaging patterns — [Events](/learn/events), [Events Store](/learn/events-store), [Queues](/learn/queues), and [RPC](/learn/rpc) — so the concepts you learn in one language carry directly to the rest. Pick your language below, or expand it in the sidebar — the trees are structured identically, so `reference/client` in Go maps to `reference/client` in Python. ## Choose your language [#choose-your-language] ## No SDK for your language? [#no-sdk-for-your-language] Every KubeMQ operation is also available over the **REST and WebSocket APIs**, so any language with an HTTP client can talk to KubeMQ. See the [Quick Start](/deploy/quickstart) for a language-agnostic first message. # Agent Cards (/aiway/a2a/agent-cards) An **agent card** is the machine-readable description of an agent's identity and capabilities. KubeMQ serves agent cards at the standard A2A `/.well-known/agent-card.json` endpoints so callers can discover what an agent does before sending it a request. ## Overview [#overview] Following the A2A protocol convention, KubeMQ exposes two kinds of card: * **Platform card** — describes the KubeMQ gateway itself. The `name` is always `kubemq`. It carries no agent-specific skills; it advertises the gateway and the protocol versions it speaks. * **Individual card** — describes one registered agent. It is the `AgentCard` you supplied at registration, **enriched** with the server-managed `registered_at` and `last_seen` timestamps. This is the same representation returned by `GET /agents/{agent_id}`. Cards are read-only discovery surfaces. You create and update them through the [registry](/aiway/a2a/registry) (register / heartbeat); the card endpoints just expose the current state. Both card endpoints are **public** — no JWT is required to read them, since discovery must work before a caller authenticates. ## The two card endpoints [#the-two-card-endpoints] | Endpoint | Returns | | ------------------------------------------------- | --------------------------------------------- | | `GET /.well-known/agent-card.json` | Platform card — the KubeMQ gateway's own card | | `GET /a2a/{agent_id}/.well-known/agent-card.json` | Individual agent's enriched card | Requesting a well-known card for a non-existent agent returns **HTTP 404**. ### Platform card [#platform-card] The platform card represents the gateway, not any agent. Its `name` is always `kubemq`: ```json { "name": "kubemq", "description": "KubeMQ A2A Gateway", "version": "latest", "url": "http://localhost:9090/", "skills": [], "defaultInputModes": ["text"], "defaultOutputModes": ["text"], "protocolVersions": ["1.0"] } ``` ### Individual agent card [#individual-agent-card] An individual card returns the agent's registered fields plus the server-managed timestamps: ```json { "agent_id": "echo-agent-01", "name": "Echo Agent", "description": "A simple echo agent for testing", "version": "1.0.0", "url": "http://localhost:18080/", "skills": [ { "id": "echo", "name": "Echo", "description": "Echoes back the received message", "tags": ["test", "echo"] } ], "defaultInputModes": ["text"], "defaultOutputModes": ["text"], "protocolVersions": ["1.0"], "registered_at": "2026-04-06T10:00:00Z", "last_seen": "2026-04-06T10:05:00Z" } ``` ## Card fields [#card-fields] The `AgentCard` carries the agent's identity, endpoint, and capabilities. The **required** fields are `agent_id`, `name`, and `url`. | Field | JSON key | Required | Description | | -------------------- | -------------------- | -------- | ----------------------------------------------------------------------------------------------------------- | | Agent ID | `agent_id` | Yes | Unique identifier — 2–128 chars, lowercase alphanumeric with hyphens, starting and ending with alphanumeric | | Name | `name` | Yes | Human-readable name (max 256 chars) | | Description | `description` | No | Free-text description (max 2048 chars) | | Version | `version` | No | Agent version, e.g. `1.0.0` (max 64 chars) | | URL | `url` | Yes | Agent endpoint — an absolute `http://` or `https://` URL (max 2048 chars) | | Skills | `skills` | No | List of `AgentSkill` entries (see below) | | Capabilities | `capabilities` | No | Free-form capability map | | Default input modes | `defaultInputModes` | No | e.g. `["text"]` | | Default output modes | `defaultOutputModes` | No | e.g. `["text"]` | | Protocol versions | `protocolVersions` | No | Defaults to `["1.0"]` if omitted | | Metadata | `metadata` | No | Key-value string map | `registered_at` and `last_seen` are **server-managed** — KubeMQ sets them on registration and heartbeat. Do not send them when registering; they will be overwritten. ### Skills and tags [#skills-and-tags] Each entry in `skills` is an `AgentSkill`. Skills make agents discoverable: the registry filters by skill **tags** when listing agents, so well-chosen tags let callers find the right agent without knowing its ID. | Field | JSON key | Required | Description | | ----------- | ------------- | -------- | --------------------------------- | | ID | `id` | Yes | Skill identifier | | Name | `name` | Yes | Skill name | | Description | `description` | No | What the skill does | | Tags | `tags` | No | Tags used for discovery filtering | To find every agent advertising a tag, pass it to the list endpoint — `GET /agents?skill_tags=echo` — see the [registry](/aiway/a2a/registry). ## Fetch a card [#fetch-a-card] The example below reads an individual agent's card. The curl tab hits the standard well-known endpoint; the language tabs fetch the same card representation via the registry API and print every field. ```bash # Platform card (the gateway itself) curl http://localhost:9090/.well-known/agent-card.json # Individual agent card curl http://localhost:9090/a2a/echo-agent-01/.well-known/agent-card.json ``` ```csharp using System.Text.Json.Nodes; const string KubeMqUrl = "http://localhost:9090"; const string AgentId = "full-info-agent-01"; using var client = new HttpClient(); var resp = await client.GetAsync($"{KubeMqUrl}/agents/{AgentId}"); Console.WriteLine($"Status: {(int)resp.StatusCode}"); var body = await resp.Content.ReadAsStringAsync(); var data = JsonNode.Parse(body)!; Console.WriteLine("\n--- Agent Card ---"); Console.WriteLine($" agent_id: {data["agent_id"]}"); Console.WriteLine($" name: {data["name"]}"); Console.WriteLine($" description: {data["description"]}"); Console.WriteLine($" version: {data["version"]}"); Console.WriteLine($" url: {data["url"]}"); Console.WriteLine($" defaultInputModes: {data["defaultInputModes"]}"); Console.WriteLine($" defaultOutputModes: {data["defaultOutputModes"]}"); Console.WriteLine($" protocolVersions: {data["protocolVersions"]}"); Console.WriteLine($" registered_at: {data["registered_at"]}"); Console.WriteLine($" last_seen: {data["last_seen"]}"); var skills = data["skills"]?.AsArray() ?? []; Console.WriteLine($"\n--- Skills ({skills.Count}) ---"); foreach (var skill in skills) { Console.WriteLine($" [{skill!["id"]}] {skill["name"]}: {skill["description"]}"); Console.WriteLine($" tags: {skill["tags"]}"); } ``` ```go package main import ( "encoding/json" "fmt" "io" "net/http" "os" ) const ( kubemqURL = "http://localhost:9090" agentID = "full-info-agent-01" ) func main() { resp, err := http.Get(kubemqURL + "/agents/" + agentID) if err != nil { fmt.Fprintf(os.Stderr, "Request failed: %v\n", err) os.Exit(1) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) fmt.Printf("Status: %d\n", resp.StatusCode) var data map[string]interface{} json.Unmarshal(body, &data) fmt.Println("\n--- Agent Card ---") fmt.Printf(" agent_id: %v\n", data["agent_id"]) fmt.Printf(" name: %v\n", data["name"]) fmt.Printf(" description: %v\n", data["description"]) fmt.Printf(" version: %v\n", data["version"]) fmt.Printf(" url: %v\n", data["url"]) fmt.Printf(" defaultInputModes: %v\n", data["defaultInputModes"]) fmt.Printf(" defaultOutputModes: %v\n", data["defaultOutputModes"]) fmt.Printf(" protocolVersions: %v\n", data["protocolVersions"]) fmt.Printf(" registered_at: %v\n", data["registered_at"]) fmt.Printf(" last_seen: %v\n", data["last_seen"]) skills, _ := data["skills"].([]interface{}) fmt.Printf("\n--- Skills (%d) ---\n", len(skills)) for _, s := range skills { sk, _ := s.(map[string]interface{}) fmt.Printf(" [%v] %v: %v\n", sk["id"], sk["name"], sk["description"]) fmt.Printf(" tags: %v\n", sk["tags"]) } } ``` ```java import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Client { static final String KUBEMQ_URL = "http://localhost:9090"; static final String AGENT_ID = "full-info-agent-01"; static final ObjectMapper MAPPER = new ObjectMapper(); public static void main(String[] args) throws Exception { var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/agents/" + AGENT_ID)) .GET().build(); var resp = client.send(req, HttpResponse.BodyHandlers.ofString()); System.out.println("Status: " + resp.statusCode()); var data = MAPPER.readTree(resp.body()); System.out.println("\n--- Agent Card ---"); System.out.println(" agent_id: " + data.path("agent_id").asText()); System.out.println(" name: " + data.path("name").asText()); System.out.println(" description: " + data.path("description").asText()); System.out.println(" version: " + data.path("version").asText()); System.out.println(" url: " + data.path("url").asText()); System.out.println(" defaultInputModes: " + data.path("defaultInputModes")); System.out.println(" defaultOutputModes: " + data.path("defaultOutputModes")); System.out.println(" protocolVersions: " + data.path("protocolVersions")); System.out.println(" registered_at: " + data.path("registered_at").asText()); System.out.println(" last_seen: " + data.path("last_seen").asText()); var skills = data.path("skills"); System.out.println("\n--- Skills (" + skills.size() + ") ---"); for (var skill : skills) { System.out.println(" [" + skill.get("id").asText() + "] " + skill.get("name").asText() + ": " + skill.get("description").asText()); System.out.println(" tags: " + skill.get("tags")); } } } ``` ```python """Agent-Info example — retrieves and displays all agent card fields.""" import asyncio import httpx KUBEMQ_URL = "http://localhost:9090" AGENT_ID = "full-info-agent-01" async def main() -> None: async with httpx.AsyncClient() as client: resp = await client.get(f"{KUBEMQ_URL}/agents/{AGENT_ID}") print(f"Status: {resp.status_code}") data = resp.json() print("\n--- Agent Card ---") print(f" agent_id: {data.get('agent_id')}") print(f" name: {data.get('name')}") print(f" description: {data.get('description')}") print(f" version: {data.get('version')}") print(f" url: {data.get('url')}") print(f" defaultInputModes: {data.get('defaultInputModes')}") print(f" defaultOutputModes: {data.get('defaultOutputModes')}") print(f" protocolVersions: {data.get('protocolVersions')}") print(f" registered_at: {data.get('registered_at')}") print(f" last_seen: {data.get('last_seen')}") skills = data.get("skills", []) print(f"\n--- Skills ({len(skills)}) ---") for skill in skills: print(f" [{skill['id']}] {skill['name']}: {skill['description']}") print(f" tags: {skill.get('tags', [])}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript const KUBEMQ_URL = "http://localhost:9090"; const AGENT_ID = "info-agent-01"; async function main() { const resp = await fetch(`${KUBEMQ_URL}/agents/${AGENT_ID}`); const agent = await resp.json(); console.log("=== Agent Card Details ==="); console.log(` agent_id: ${agent.agent_id}`); console.log(` name: ${agent.name}`); console.log(` description: ${agent.description}`); console.log(` version: ${agent.version}`); console.log(` url: ${agent.url}`); console.log(` registered_at: ${agent.registered_at}`); console.log(` last_seen: ${agent.last_seen}`); console.log(` protocolVersions: ${JSON.stringify(agent.protocolVersions)}`); console.log(` defaultInputModes: ${JSON.stringify(agent.defaultInputModes)}`); console.log(` defaultOutputModes:${JSON.stringify(agent.defaultOutputModes)}`); console.log("\n=== Skills ==="); for (const skill of agent.skills || []) { console.log(` - ${skill.id}: ${skill.name} (tags: ${(skill.tags || []).join(", ")})`); console.log(` ${skill.description}`); } console.log("\n=== Full JSON ==="); console.log(JSON.stringify(agent, null, 2)); } main().catch(console.error); ``` The curl tab uses the public `/.well-known/agent-card.json` endpoint; the language tabs read the **same** card via `GET /agents/{agent_id}` (the registry API), which returns an identical representation. Both surfaces return the enriched card. ## Related [#related] # How It Works (/aiway/a2a/architecture) The A2A connector is a **transparent JSON-RPC 2.0 proxy**. It never runs your agent's logic — it routes a request to the right agent, waits for the reply, and relays it back unchanged. The piece that makes agents plain HTTP servers (no KubeMQ SDK) is the **virtual subscriber**: a per-agent internal bridge that turns a Query into an outbound HTTP POST. ## Overview [#overview] When a caller hits `POST /a2a/`, the request does **not** go straight to your agent over HTTP. Instead it is published as a **Query** on the agent's internal channel, picked up by that agent's virtual subscriber, and forwarded to the agent's registered URL as an HTTP POST. The agent's response travels the same path in reverse. This indirection is what lets agents stay simple — they are standard A2A-compliant HTTP servers with **zero KubeMQ dependencies** (no broker client, no protobuf, no client library) — while still benefiting from KubeMQ's routing, authorization, metrics, and persistence infrastructure. For why agents are plain HTTP URLs, see the [overview](/aiway/a2a). ## The components [#the-components] The gateway is one of several cooperating parts. The A2A connector handles the protocol; the **Agent Registry** tracks who is registered; the **Subscriber Manager** owns the lifecycle of every **virtual subscriber**; and the **Replicator** keeps registry state in sync across a cluster. *The A2A connector proxies via the broker; a per-agent virtual subscriber bridges the Query to an HTTP POST.* | Component | Role | | ---------------------- | ------------------------------------------------------------------------------------ | | **A2A connector** | Transparent JSON-RPC 2.0 proxy; agent-management REST API; SSE relay | | **Agent Registry** | SQLite-backed store of registered agents with TTL liveness and cluster replication | | **Subscriber Manager** | Creates/destroys virtual subscribers on registration, deregistration, and TTL expiry | | **Virtual Subscriber** | Per-agent broker-to-HTTP bridge; the reason agents need no KubeMQ SDK | | **Replicator** | Propagates registry changes across cluster nodes over Events Store | ## The virtual-subscriber bridge [#the-virtual-subscriber-bridge] Each registered agent gets its own **virtual subscriber** — an internal broker client that subscribes to Queries on the agent's channel and forwards them to the agent over HTTP. It is spawned by the Subscriber Manager when the agent registers and torn down when the agent deregisters or its TTL expires. When a Query arrives, the virtual subscriber: Extracts the JSON-RPC body from the broker request. Unpacks forwarded HTTP headers (the `a2a_hdr_*` tags) back into real headers. Sets the `X-KubeMQ-Caller-ID` header to the original caller's identity. Makes an HTTP `POST` to the agent's registered URL with `Content-Type: application/json` . Wraps the agent's HTTP response and publishes it back on the broker reply channel. The subscriber dispatches on the `a2a_method` tag in the incoming Query: `message/stream` is routed to the streaming handler (which opens an SSE connection to the agent), `stream_cancel` is routed to the cancel handler, and everything else — including `message/send` and `tasks/send` — goes to the synchronous request handler. All broker operations for an agent use the client ID `a2a-vsub-` (for example `a2a-vsub-agent-b`). Virtual subscribers use a queue subscription with the queue group set to the agent ID, so load-balanced delivery works correctly across the cluster. ## Internal channels [#internal-channels] All agent-platform traffic uses channels under the reserved `_AGENTS_.` prefix. User channels that start with `_AGENTS_.` are rejected by `IsReservedChannel` — see [Auth & security](/connectors/reference/auth-and-security). | Channel | Purpose | Transport | | ----------------------------- | ------------------------------------------------------------------------------- | ------------ | | `_AGENTS_.agents/` | Request/reply to the agent via its virtual subscriber (including stream cancel) | Query | | `_AGENTS_.stream/` | Temporary SSE stream events relayed by the virtual subscriber | Events | | `_AGENTS_.discovery` | Registry replication events across cluster nodes | Events Store | ## Header forwarding [#header-forwarding] Caller HTTP headers ride along to the agent through the broker request's `Tags` field using an `a2a_hdr_` prefix: the connector packs each allowed header as `a2a_hdr_: `, and the virtual subscriber strips the prefix and replays it as a real HTTP header on the outbound POST. Hop-by-hop and sensitive headers are **never** forwarded: `Connection`, `Keep-Alive`, `Transfer-Encoding`, `Te`, `Trailer`, `Upgrade`, `Host`, `Content-Length`, `Authorization`, `Cookie`, `Set-Cookie`, `Proxy-Authorization`, `Proxy-Authenticate`, `X-Forwarded-For`, and `X-Real-Ip` are dropped. Regardless of which headers the caller sent, the virtual subscriber always sets **`X-KubeMQ-Caller-ID`** on the outbound request, carrying the original caller's KubeMQ client identity — so your agent can always tell who called it. Any KubeMQ transport (gRPC, REST, the A2A HTTP gateway, or the MCP bridge) can forward headers the same way by setting `a2a_hdr_*` tags on the request. ## Concurrency control [#concurrency-control] Each virtual subscriber enforces a **per-agent** concurrency cap using a buffered-channel semaphore sized to `AgentMaxConcurrency` (default `100`). When a Query arrives and the semaphore has capacity, a handler goroutine runs and releases its slot on completion. When all slots are occupied, the overflow Query is **immediately rejected** with a transport failure — `Executed: false`, `Error: "server busy: concurrency limit reached"` — without spawning a goroutine. This keeps a single hot agent from consuming unbounded goroutines under load. The cap, the response-size limit, and the timeout/gateway-buffer behavior are covered in detail in [Concurrency & limits](/aiway/a2a/guides/concurrency). ## Timeouts and the gateway buffer [#timeouts-and-the-gateway-buffer] Each request carries a timeout taken from `params.configuration.timeout` in the JSON-RPC body, falling back to `DefaultTimeoutSeconds` (`300`) and capped at `MaxTimeoutSeconds` (`3600`). Before forwarding, the gateway adds a **`GatewayTimeoutBuffer` of 10 seconds** to the downstream deadline so the gateway never times out before the agent does. SSE stream endpoints skip the per-route timeout middleware entirely because they are long-lived; they are bounded by the idle timeout instead (see [Streaming](/aiway/a2a/streaming)). ## Cluster behavior [#cluster-behavior] Virtual subscribers are **local to the node where the agent registered** — they are not replicated. Registry *state*, however, is shared cluster-wide. *Only the registering node spawns the virtual subscriber; other nodes store the card and route Queries to it over the cluster mesh.* * An agent registers on **Node A** → its virtual subscriber is spawned on **Node A only**. * Replication events over `_AGENTS_.discovery` propagate the `AgentCard` to **Nodes B and C**; they store the card but do **not** spawn a subscriber. * The cluster mesh routes Queries from any node to the virtual subscriber on Node A. * If **Node A** goes down, the agent's heartbeat expires on all nodes and its subscribers are cleaned up. * When the agent re-registers on **Node B**, a fresh virtual subscriber is spawned there. ## Transport vs. application errors [#transport-vs-application-errors] Because the bridge is the boundary between the broker and HTTP, it cleanly separates two failure classes via the `Executed` flag on the response: * **Transport error** (`Executed: false`) — the agent never processed the request: it was unreachable, timed out, returned `502`/`503`/`504`, or its response exceeded the size cap. Safe to retry. * **Application error** (`Executed: true`) — the agent *did* process the request and returned an HTTP `4xx`/`5xx` with a JSON-RPC error body. Retrying blindly usually will not help. This distinction lets callers choose the right retry strategy — see [Error handling](/aiway/a2a/error-handling). ## Related [#related] # Configuration (/aiway/a2a/configuration) The A2A connector is **enabled by default** on the [shared HTTP server](/connectors/concepts/shared-http-server) — start kubemq-server and `/a2a/*` is live with no flag to set. Configuration tunes agent liveness, request timeouts, concurrency, and response limits; you only set values that differ from the defaults below. ## Overview [#overview] A2A settings live under `connectors.a2a` in the kubemq-server configuration (the Go `A2aConfig` struct). Every field has a working default, so a minimal deployment needs no A2A configuration at all. You can override any field through a YAML/TOML config file, an environment variable, or a Docker `-e` flag — they map one-to-one. The connector listens on the shared HTTP server's port (`9090` by default, inherited from `Rest.Port`) and is **on unless you disable it**. There is no `=true` flag — older KubeMQ builds were off-by-default, but current builds ship all three connectors enabled. ## Config fields [#config-fields] Defaults are taken verbatim from the `A2aConfig` struct in kubemq-server. | Field | Type | Default | Description | | ----------------------- | ---------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `Enable` | `bool` | `true` | Whether the A2A connector is served. Set to `false` to disable `/a2a/*` and the registry. | | `AgentTTLSeconds` | `int` | `300` | Liveness window for a registered agent. An agent that does not heartbeat within this window is considered stale. Must be positive. | | `DefaultTimeoutSeconds` | `int` | `300` | Timeout applied to an agent request when the caller does not set `params.configuration.timeout`. Must be positive. | | `MaxTimeoutSeconds` | `int` | `3600` | Upper bound for caller-specified timeouts. Larger values are capped to this. Must be `>= DefaultTimeoutSeconds`. | | `MaxAgents` | `int` | `0` | Maximum number of registered agents. `0` means unlimited. Cannot be negative. | | `MaxSSEIdleSeconds` | `int` | `300` | Idle timeout for an SSE stream. When it fires, a `task.error` with code `-32001` is sent and the agent is asked to cancel. Must be positive. | | `TrustedOrigins` | `[]string` | `["auto"]` | Origins allowed by Origin validation. `auto` matches localhost and the bind address; `*` allows all. See [Authentication](/aiway/a2a/guides/authentication). | | `AgentMaxResponseBytes` | `int64` | `10485760` | Maximum agent response size in bytes (10 MB). Oversized responses are rejected with `-32603`. `0` means unlimited. Cannot be negative. | | `AgentTLSSkipVerify` | `bool` | `false` | When agents are registered with `https://` URLs, skip TLS certificate verification. Leave `false` in production. | | `AgentMaxConcurrency` | `int` | `100` | Maximum simultaneous in-flight requests per agent. The 101st concurrent request is rejected with `-32603`. A non-positive value resets to `100`. | **Validation.** kubemq-server rejects the configuration at startup if `AgentTTLSeconds`, `DefaultTimeoutSeconds`, or `MaxSSEIdleSeconds` is not positive, if `MaxTimeoutSeconds < DefaultTimeoutSeconds`, or if `MaxAgents` / `AgentMaxResponseBytes` is negative. ## Enable / disable [#enable--disable] The A2A connector is enabled by default. To **disable** it, set its enable variable to `false`: **The enable variable name is irregular by design.** `Connectors.A2A.Enable` becomes **`CONNECTORSA2_A_ENABLE`** — the `2`-to-`A` boundary inside `A2A` splits into `A2_A`. kubemq-server derives every env var by snake-casing the dotted config path with two regexes, stripping dots, and uppercasing. Do not "fix" the name to `CONNECTORS_A2A_ENABLE`; that string is not bound and has no effect. The full algorithm and the matching MCP/CE names are documented in [Shared HTTP server](/connectors/concepts/shared-http-server#enable-model-on-by-default). ## Environment variables [#environment-variables] Each field maps to one environment variable via the same transform. These are the A2A variables in full: | Variable | Config field | Default | | ----------------------------------------- | -------------------------------------- | ---------- | | `CONNECTORSA2_A_ENABLE` | `Connectors.A2A.Enable` | `true` | | `CONNECTORSA2_A_AGENT_TTL_SECONDS` | `Connectors.A2A.AgentTTLSeconds` | `300` | | `CONNECTORSA2_A_DEFAULT_TIMEOUT_SECONDS` | `Connectors.A2A.DefaultTimeoutSeconds` | `300` | | `CONNECTORSA2_A_MAX_TIMEOUT_SECONDS` | `Connectors.A2A.MaxTimeoutSeconds` | `3600` | | `CONNECTORSA2_A_MAX_AGENTS` | `Connectors.A2A.MaxAgents` | `0` | | `CONNECTORSA2_A_MAX_SSE_IDLE_SECONDS` | `Connectors.A2A.MaxSSEIdleSeconds` | `300` | | `CONNECTORSA2_A_TRUSTED_ORIGINS` | `Connectors.A2A.TrustedOrigins` | `auto` | | `CONNECTORSA2_A_AGENT_MAX_RESPONSE_BYTES` | `Connectors.A2A.AgentMaxResponseBytes` | `10485760` | | `CONNECTORSA2_A_AGENT_TLS_SKIP_VERIFY` | `Connectors.A2A.AgentTLSSkipVerify` | `false` | | `CONNECTORSA2_A_AGENT_MAX_CONCURRENCY` | `Connectors.A2A.AgentMaxConcurrency` | `100` | ## Configuration file [#configuration-file] kubemq-server loads a YAML or TOML file (auto-detected) from the `--config` flag or the `CONFIG` environment variable. The A2A connector sits under `connectors.a2a`, alongside the shared `http` block. This example pins every A2A field to its default and shows the shared HTTP/CORS context: ```yaml title="config.yaml" connectors: rest: enable: true port: "9090" http: readtimeout: 60 bodylimit: "100M" cors: alloworigins: ["*"] allowmethods: ["GET", "POST", "DELETE", "OPTIONS"] allowheaders: ["Authorization", "Content-Type", "MCP-Protocol-Version", "MCP-Session-Id", "Last-Event-ID", "Accept"] a2a: enable: true agentttlseconds: 300 defaulttimeoutseconds: 300 maxtimeoutseconds: 3600 maxagents: 0 maxsseidleseconds: 300 trustedorigins: ["auto"] agentmaxresponsebytes: 10485760 agenttlsskipverify: false agentmaxconcurrency: 100 ``` The shared HTTP server inherits its port from `connectors.rest.port`. To run the connectors on a separate port from REST, set `connectors.http.port` explicitly. See [Shared HTTP server](/connectors/concepts/shared-http-server) for the port-inheritance and middleware details. ## Tuning the limits [#tuning-the-limits] The concurrency and response-size caps are the two limits you are most likely to hit under load. The `.kb` examples exercise both against a live server — the curl call below shows the wire behavior, and the language tabs run the full client that fires past the limit and reports the rejection. ### Per-agent concurrency [#per-agent-concurrency] `AgentMaxConcurrency` (default `100`) bounds in-flight requests per agent. The 101st concurrent request to a single agent is rejected with JSON-RPC error `-32603` ("internal error") while the first 100 succeed. ```bash # Each of these is a normal request; firing more than AgentMaxConcurrency # of them at once against one agent makes the overflow return -32603. curl -X POST http://localhost:9090/a2a/echo-agent-01 \ -H 'Content-Type: application/json' \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": { "message": { "parts": [{ "text": "hello" }] } } }' ``` ```csharp // .kb/integration-a2a/examples/csharp/limits/concurrency-limit/Client.cs using System.Net.Http.Json; var kubemqUrl = Environment.GetEnvironmentVariable("KUBEMQ_URL") ?? "http://localhost:9090"; var agentId = "concurrency-agent-01"; const int total = 101; // limit is 100; 1 request will be rejected using var http = new HttpClient(); async Task<(bool ok, int? code)> SendOne(int i) { var payload = new { jsonrpc = "2.0", id = i, method = "message/send", @params = new { message = new { parts = new[] { new { text = $"req-{i}" } } } } }; var resp = await http.PostAsJsonAsync($"{kubemqUrl}/a2a/{agentId}", payload); var body = await resp.Content.ReadFromJsonAsync(); if (body?.Error is not null) return (false, body.Error.Code); return (true, null); } var results = await Task.WhenAll(Enumerable.Range(0, total).Select(SendOne)); var rejected = results.Count(r => r.code == -32603); Console.WriteLine($"Successes: {results.Count(r => r.ok)} Rejected (-32603): {rejected}"); ``` ```go // .kb/integration-a2a/examples/go/limits/concurrency-limit/client.go package main import ( "bytes" "encoding/json" "fmt" "net/http" "os" "sync" "sync/atomic" ) func main() { kubemqURL := os.Getenv("KUBEMQ_URL") if kubemqURL == "" { kubemqURL = "http://localhost:9090" } agentID := "concurrency-agent-01" const total = 101 // limit is 100; 1 request is rejected var success, rejected int64 var wg sync.WaitGroup for i := 0; i < total; i++ { wg.Add(1) go func(i int) { defer wg.Done() body, _ := json.Marshal(map[string]any{ "jsonrpc": "2.0", "id": i, "method": "message/send", "params": map[string]any{"message": map[string]any{ "parts": []map[string]string{{"text": fmt.Sprintf("req-%d", i)}}}}, }) resp, err := http.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(body)) if err != nil { return } defer resp.Body.Close() var out struct { Error *struct { Code int `json:"code"` } `json:"error"` } _ = json.NewDecoder(resp.Body).Decode(&out) if out.Error != nil && out.Error.Code == -32603 { atomic.AddInt64(&rejected, 1) } else { atomic.AddInt64(&success, 1) } }(i) } wg.Wait() fmt.Printf("Successes: %d Rejected (-32603): %d\n", success, rejected) } ``` ```java // .kb/integration-a2a/examples/java/limits/concurrency-limit/Client.java import java.net.URI; import java.net.http.*; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.IntStream; public class Client { public static void main(String[] args) throws Exception { String kubemqUrl = System.getenv().getOrDefault("KUBEMQ_URL", "http://localhost:9090"); String agentId = "concurrency-agent-01"; int total = 101; // limit is 100; 1 request is rejected HttpClient http = HttpClient.newHttpClient(); AtomicInteger success = new AtomicInteger(); AtomicInteger rejected = new AtomicInteger(); ExecutorService pool = Executors.newFixedThreadPool(total); var tasks = IntStream.range(0, total).>mapToObj(i -> () -> { String payload = """ {"jsonrpc":"2.0","id":%d,"method":"message/send", "params":{"message":{"parts":[{"text":"req-%d"}]}}}""".formatted(i, i); HttpRequest req = HttpRequest.newBuilder() .uri(URI.create(kubemqUrl + "/a2a/" + agentId)) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(payload)) .build(); String body = http.send(req, HttpResponse.BodyHandlers.ofString()).body(); if (body.contains("-32603")) rejected.incrementAndGet(); else success.incrementAndGet(); return null; }).toList(); pool.invokeAll(tasks); pool.shutdown(); System.out.printf("Successes: %d Rejected (-32603): %d%n", success.get(), rejected.get()); } } ``` ```python # .kb/integration-a2a/examples/python/limits/concurrency-limit/client.py import asyncio import os import httpx KUBEMQ_URL = os.getenv("KUBEMQ_URL", "http://localhost:9090") AGENT_ID = "concurrency-agent-01" TOTAL = 101 # limit is 100; 1 request is rejected async def send_one(client: httpx.AsyncClient, i: int) -> int | None: payload = { "jsonrpc": "2.0", "id": i, "method": "message/send", "params": {"message": {"parts": [{"text": f"req-{i}"}]}}, } resp = await client.post(f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload) error = resp.json().get("error") return error["code"] if error else None async def main() -> None: async with httpx.AsyncClient(timeout=30) as client: codes = await asyncio.gather(*(send_one(client, i) for i in range(TOTAL))) rejected = sum(1 for c in codes if c == -32603) successes = sum(1 for c in codes if c is None) print(f"Successes: {successes} Rejected (-32603): {rejected}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript // .kb/integration-a2a/examples/typescript/limits/concurrency-limit/client.ts const kubemqUrl = process.env.KUBEMQ_URL ?? "http://localhost:9090"; const agentId = "concurrency-agent-01"; const total = 101; // limit is 100; 1 request is rejected async function sendOne(i: number): Promise { const resp = await fetch(`${kubemqUrl}/a2a/${agentId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: i, method: "message/send", params: { message: { parts: [{ text: `req-${i}` }] } }, }), }); const body = (await resp.json()) as { error?: { code: number } }; return body.error?.code ?? null; } const codes = await Promise.all( Array.from({ length: total }, (_, i) => sendOne(i)), ); const rejected = codes.filter((c) => c === -32603).length; const successes = codes.filter((c) => c === null).length; console.log(`Successes: ${successes} Rejected (-32603): ${rejected}`); ``` ### Response size [#response-size] `AgentMaxResponseBytes` (default `10485760`, 10 MB) caps the size of an agent's reply. A larger response is rejected before it reaches the caller, who receives `-32603` with the message `internal error: response too large`. ```bash # A normal request; the cap is hit only when the agent's reply exceeds # AgentMaxResponseBytes, in which case the response carries error -32603. curl -X POST http://localhost:9090/a2a/oversize-agent-01 \ -H 'Content-Type: application/json' \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": { "message": { "parts": [{ "text": "return a big payload" }] } } }' ``` ```csharp // .kb/integration-a2a/examples/csharp/limits/response-size/Client.cs using System.Net.Http.Json; var kubemqUrl = Environment.GetEnvironmentVariable("KUBEMQ_URL") ?? "http://localhost:9090"; var agentId = "oversize-agent-01"; using var http = new HttpClient(); var payload = new { jsonrpc = "2.0", id = 1, method = "message/send", @params = new { message = new { parts = new[] { new { text = "return a big payload" } } } } }; var resp = await http.PostAsJsonAsync($"{kubemqUrl}/a2a/{agentId}", payload); var body = await resp.Content.ReadFromJsonAsync(); if (body?.Error is not null) Console.WriteLine($"Error code: {body.Error.Code} Message: {body.Error.Message}"); else Console.WriteLine("Response within the size limit."); ``` ```go // .kb/integration-a2a/examples/go/limits/response-size/client.go package main import ( "bytes" "encoding/json" "fmt" "net/http" "os" ) func main() { kubemqURL := os.Getenv("KUBEMQ_URL") if kubemqURL == "" { kubemqURL = "http://localhost:9090" } agentID := "oversize-agent-01" body, _ := json.Marshal(map[string]any{ "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": map[string]any{"message": map[string]any{ "parts": []map[string]string{{"text": "return a big payload"}}}}, }) resp, err := http.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(body)) if err != nil { panic(err) } defer resp.Body.Close() var out struct { Error *struct { Code int `json:"code"` Message string `json:"message"` } `json:"error"` } _ = json.NewDecoder(resp.Body).Decode(&out) if out.Error != nil { fmt.Printf("Error code: %d Message: %s\n", out.Error.Code, out.Error.Message) } else { fmt.Println("Response within the size limit.") } } ``` ```java // .kb/integration-a2a/examples/java/limits/response-size/Client.java import java.net.URI; import java.net.http.*; public class Client { public static void main(String[] args) throws Exception { String kubemqUrl = System.getenv().getOrDefault("KUBEMQ_URL", "http://localhost:9090"); String agentId = "oversize-agent-01"; String payload = """ {"jsonrpc":"2.0","id":1,"method":"message/send", "params":{"message":{"parts":[{"text":"return a big payload"}]}}}"""; HttpClient http = HttpClient.newHttpClient(); HttpRequest req = HttpRequest.newBuilder() .uri(URI.create(kubemqUrl + "/a2a/" + agentId)) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(payload)) .build(); String body = http.send(req, HttpResponse.BodyHandlers.ofString()).body(); if (body.contains("-32603")) System.out.println("Error code: -32603 (response too large)"); else System.out.println("Response within the size limit."); } } ``` ```python # .kb/integration-a2a/examples/python/limits/response-size/client.py import os import httpx KUBEMQ_URL = os.getenv("KUBEMQ_URL", "http://localhost:9090") AGENT_ID = "oversize-agent-01" def main() -> None: payload = { "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": {"message": {"parts": [{"text": "return a big payload"}]}}, } resp = httpx.post(f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload, timeout=30) error = resp.json().get("error") if error: print(f"Error code: {error['code']} Message: {error['message']}") else: print("Response within the size limit.") if __name__ == "__main__": main() ``` ```typescript // .kb/integration-a2a/examples/typescript/limits/response-size/client.ts const kubemqUrl = process.env.KUBEMQ_URL ?? "http://localhost:9090"; const agentId = "oversize-agent-01"; const resp = await fetch(`${kubemqUrl}/a2a/${agentId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { parts: [{ text: "return a big payload" }] } }, }), }); const body = (await resp.json()) as { error?: { code: number; message: string }; }; if (body.error) { console.log(`Error code: ${body.error.code} Message: ${body.error.message}`); } else { console.log("Response within the size limit."); } ``` Timeouts compound. The gateway adds `GatewayTimeoutBuffer` (10s) on top of the effective request timeout before timing out the proxied call to the agent, so the agent always has a slightly longer window than the caller-facing timeout. See [Concurrency & limits](/aiway/a2a/guides/concurrency). ## Related [#related] # Error Handling (/aiway/a2a/error-handling) Every A2A response is a **JSON-RPC 2.0** envelope, so failures come back as a structured `error` object with a numeric `code` and a `message` — never as a raw HTTP error. This page is the field guide to those codes: which ones the gateway raises, which come from the agent, and how to decide whether to retry. ## Overview [#overview] The A2A gateway returns errors at HTTP `200` with a JSON-RPC `error` body for anything it can parse as a request. A failed call looks like this: ```json { "jsonrpc": "2.0", "id": 1, "error": { "code": -32002, "message": "agent not found: nonexistent-agent" } } ``` There are two families of codes: * **JSON-RPC base codes** (`-32700`, `-32600`, `-32601`, `-32602`, `-32603`, `-32010`) — shared with the [MCP connector](/aiway/mcp) and raised by the gateway for malformed requests, bad methods, or auth failures. * **A2A-specific codes** (`-32001` to `-32004`) — raised when the gateway routes to a registered agent but the call to that agent fails (timeout, missing agent, rejection, or an unparseable reply). ## JSON-RPC base codes [#json-rpc-base-codes] These are the standard JSON-RPC 2.0 codes returned by the shared HTTP layer for requests that never reach an agent — the body or method is wrong, or authentication failed. | Code | Constant | Meaning | Typical cause | | -------- | ----------------------- | ---------------------- | -------------------------------------------------------------------------- | | `-32700` | `JSONRPCParseError` | Parse error | Invalid JSON body, or `Content-Type` is not `application/json` | | `-32600` | `JSONRPCInvalidRequest` | Invalid request | Missing `method`, wrong `jsonrpc` version, or an invalid `agent_id` format | | `-32601` | `JSONRPCMethodNotFound` | Method not found | Unknown `method` for the endpoint | | `-32602` | `JSONRPCInvalidParams` | Invalid params | The `params` object failed validation | | `-32603` | `JSONRPCInternalError` | Internal error | Unexpected gateway-side failure | | `-32010` | `jsonrpcAuthError` | Authentication failure | Missing or invalid JWT on a protected route | The `agent_id` in the URL path is validated before routing. An invalid format (for example uppercase characters) is rejected with `-32600` — the request never reaches an agent. See [Authentication](/aiway/a2a/guides/authentication) for the `-32010` auth flow. ## A2A-specific codes [#a2a-specific-codes] These codes are raised by the gateway when it has a registered agent to route to but the outbound HTTP call to that agent fails. | Code | Constant | Meaning | | -------- | ------------------------- | -------------------------------------------------------------- | | `-32001` | `jsonrpcAgentTimeout` | The agent did not respond within the request timeout | | `-32002` | `jsonrpcAgentNotFound` | No agent is registered under that `agent_id` | | `-32003` | `jsonrpcAgentUnavailable` | The agent rejected the request (unreachable or returned a 5xx) | | `-32004` | `jsonrpcInvalidResponse` | The agent replied, but the body was not valid JSON-RPC | The request timeout that drives `-32001` defaults to `DefaultTimeoutSeconds` (300s) and is capped by `MaxTimeoutSeconds` (3600s); per-call, set `params.configuration.timeout` (seconds) to lower it. The same code is also emitted as a `task.error` envelope when an SSE stream hits its idle timeout (`MaxSSEIdleSeconds`, default 300s) — see [SSE behavior](/aiway/a2a/guides/sse-behavior). ## Transport vs application errors [#transport-vs-application-errors] Underneath the JSON-RPC codes, the gateway's virtual subscriber draws a sharp line between a call the agent **never processed** and one it **processed but rejected**. This distinction is what lets you decide whether retrying is safe. The agent's reply carries an `Executed` flag: * **`Executed: false`** — a **transport error**. The agent never handled the request, so retrying (against another instance, or after a backoff) is safe. Mapped to `-32001` (timeout), `-32002` (not found), or `-32003` (unavailable). * **`Executed: true`** — the agent **did** process the request. A `2xx` is a success; a `4xx`/`5xx` body is an **application error** the agent chose to return. Retrying the same call will produce the same result, so handle it as a business outcome rather than blindly retrying. | Condition | Gateway result | Classification | | ------------------------------------------------------ | ------------------------------------------------------------ | -------------- | | Connection refused / DNS failure | `Executed: false` · "agent unreachable" | Transport | | HTTP timeout (deadline exceeded) | `Executed: false` · "agent timeout" | Transport | | HTTP 502 / 503 / 504 | `Executed: false` · "agent unavailable: 50x" | Transport | | Response body exceeds `AgentMaxResponseBytes` (10 MB) | `Executed: false` · "agent response too large" | Transport | | Concurrency limit reached (`AgentMaxConcurrency`, 100) | `Executed: false` · "server busy: concurrency limit reached" | Transport | | HTTP 200–299 | `Executed: true` · response body | Success | | HTTP 400 / 401 / 403 / 404 / 409 / 422 / 500 | `Executed: true` · response body | Application | ## Retry strategy [#retry-strategy] A safe default: **retry transport errors, surface application errors.** * **`-32002` (agent not found)** — the target is not in the registry. Do not retry the same `agent_id`; re-resolve the agent (it may have expired its TTL) or [register it](/aiway/a2a/registry) first. * **`-32001` (timeout)** — retry with backoff. If it persists, the agent is overloaded or the per-call timeout is too low for the workload; raise `params.configuration.timeout`. * **`-32003` (unavailable)** and `Executed: false` — the agent never ran the request; retry with exponential backoff, optionally against a healthy instance. * **`-32004` (invalid response)** — the agent is misbehaving (non-JSON-RPC body). Retrying rarely helps; treat it as a bug in the agent. * **JSON-RPC base codes** (`-32600`, `-32602`, `-32700`) — the request itself is wrong. Fix the payload; retrying unchanged will fail identically. * **Application errors** (`Executed: true` with a `4xx`/`5xx` body) — these are the agent's deliberate response. Handle them as domain outcomes, not infrastructure faults. ## Examples [#examples] The snippets below trigger each error family against a running gateway: a missing agent (`-32002`), malformed JSON-RPC requests (base codes), and a timeout (`-32001`). ### Agent not found (-32002) [#agent-not-found--32002] Send a `message/send` to an `agent_id` that is not registered. The gateway returns a JSON-RPC error with code `-32002`. ```bash curl -X POST http://localhost:9090/a2a/nonexistent-agent \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": {"message": {"parts": [{"text": "Hello?"}]}} }' # => {"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"agent not found: nonexistent-agent"}} ``` ```csharp using System.Text; using System.Text.Json; using System.Text.Json.Nodes; const string KubeMqUrl = "http://localhost:9090"; const string AgentId = "nonexistent-agent"; var payload = new JsonObject { ["jsonrpc"] = "2.0", ["id"] = 1, ["method"] = "message/send", ["params"] = new JsonObject { ["message"] = new JsonObject { ["parts"] = new JsonArray(new JsonObject { ["text"] = "Hello?" }) } } }; using var client = new HttpClient(); var resp = await client.PostAsync( $"{KubeMqUrl}/a2a/{AgentId}", new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json")); var body = await resp.Content.ReadAsStringAsync(); var data = JsonNode.Parse(body)!; Console.WriteLine(JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true })); var error = data["error"]; Console.WriteLine($"\nError code: {error?["code"]}"); Console.WriteLine($"Error message: {error?["message"]}"); var code = error?["code"]?.GetValue(); if (code == -32002) Console.WriteLine("\nAgent-not-found error (-32002) received as expected!"); ``` ```go package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" ) const ( kubemqURL = "http://localhost:9090" agentID = "nonexistent-agent" ) func main() { payload := map[string]interface{}{ "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": map[string]interface{}{ "message": map[string]interface{}{ "parts": []map[string]interface{}{{"text": "Hello?"}}, }, }, } data, _ := json.Marshal(payload) resp, err := http.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(data)) if err != nil { fmt.Fprintf(os.Stderr, "Request failed: %v\n", err) os.Exit(1) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var pretty bytes.Buffer json.Indent(&pretty, body, "", " ") fmt.Println(pretty.String()) var result map[string]interface{} json.Unmarshal(body, &result) errorObj, _ := result["error"].(map[string]interface{}) code, _ := errorObj["code"].(float64) msg, _ := errorObj["message"].(string) fmt.Printf("\nError code: %.0f\n", code) fmt.Printf("Error message: %s\n", msg) if int(code) != -32002 { fmt.Fprintf(os.Stderr, "Expected -32002, got %.0f\n", code) os.Exit(1) } fmt.Println("\nAgent-not-found error (-32002) received as expected!") } ``` ```java import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.List; import java.util.Map; public class Client { static final String KUBEMQ_URL = "http://localhost:9090"; static final String AGENT_ID = "nonexistent-agent"; static final ObjectMapper MAPPER = new ObjectMapper(); public static void main(String[] args) throws Exception { var payload = Map.of( "jsonrpc", "2.0", "id", 1, "method", "message/send", "params", Map.of( "message", Map.of("parts", List.of(Map.of("text", "Hello?"))) ) ); var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID)) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload))) .build(); var resp = client.send(req, HttpResponse.BodyHandlers.ofString()); var data = MAPPER.readTree(resp.body()); System.out.println(MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(data)); var error = data.path("error"); System.out.println("\nError code: " + error.path("code").asInt()); System.out.println("Error message: " + error.path("message").asText()); assert error.path("code").asInt() == -32002 : "Expected -32002, got " + error.path("code").asInt(); System.out.println("\nAgent-not-found error (-32002) received as expected!"); } } ``` ```python import asyncio import json import httpx KUBEMQ_URL = "http://localhost:9090" AGENT_ID = "nonexistent-agent" async def main() -> None: payload = { "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": { "message": {"parts": [{"text": "Hello?"}]}, }, } async with httpx.AsyncClient() as client: resp = await client.post(f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload) data = resp.json() print(json.dumps(data, indent=2)) error = data.get("error", {}) print(f"\nError code: {error.get('code')}") print(f"Error message: {error.get('message')}") assert error.get("code") == -32002, f"Expected -32002, got {error.get('code')}" print("\nAgent-not-found error (-32002) received as expected!") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript const KUBEMQ_URL = "http://localhost:9090"; async function main() { const request = { jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { parts: [{ text: "Hello?" }] }, }, }; console.log("Sending to nonexistent agent..."); const resp = await fetch(`${KUBEMQ_URL}/a2a/nonexistent-agent`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), }); const data = await resp.json(); console.log("Response:", JSON.stringify(data, null, 2)); if (data.error) { console.log(`\nError code: ${data.error.code} (expect -32002)`); console.log(`Error message: ${data.error.message}`); console.log(`Match: ${data.error.code === -32002}`); } } main().catch(console.error); ``` ### Invalid request (JSON-RPC base codes) [#invalid-request-json-rpc-base-codes] Malformed payloads are rejected by the gateway with base codes — invalid JSON or a wrong `Content-Type` yields `-32700`, while a missing `method` or wrong `jsonrpc` version yields `-32600`. ```bash # Invalid JSON body -> -32700 curl -X POST http://localhost:9090/a2a/echo-agent-01 \ -H "Content-Type: application/json" \ -d '{invalid json!!!}' # Missing method field -> -32600 curl -X POST http://localhost:9090/a2a/echo-agent-01 \ -H "Content-Type: application/json" \ -d '{"jsonrpc": "2.0", "id": 1, "params": {}}' # Bad jsonrpc version -> -32600 curl -X POST http://localhost:9090/a2a/echo-agent-01 \ -H "Content-Type: application/json" \ -d '{"jsonrpc": "1.0", "id": 1, "method": "message/send", "params": {}}' ``` ```csharp using System.Text; using System.Text.Json.Nodes; const string KubeMqUrl = "http://localhost:9090"; const string AgentId = "echo-agent-01"; using var client = new HttpClient(); Console.WriteLine("=== Test 1: Invalid JSON ==="); var resp = await client.PostAsync( $"{KubeMqUrl}/a2a/{AgentId}", new StringContent("{invalid json!!!}", Encoding.UTF8, "application/json")); var data = JsonNode.Parse(await resp.Content.ReadAsStringAsync())!; var error = data["error"]; Console.WriteLine($" Code: {error?["code"]} (expected -32700)"); Console.WriteLine($" Message: {error?["message"]}"); Console.WriteLine("\n=== Test 2: Missing method field ==="); var payload2 = new JsonObject { ["jsonrpc"] = "2.0", ["id"] = 1, ["params"] = new JsonObject() }; resp = await client.PostAsync( $"{KubeMqUrl}/a2a/{AgentId}", new StringContent(payload2.ToJsonString(), Encoding.UTF8, "application/json")); data = JsonNode.Parse(await resp.Content.ReadAsStringAsync())!; error = data["error"]; Console.WriteLine($" Code: {error?["code"]} (expected -32600)"); Console.WriteLine($" Message: {error?["message"]}"); Console.WriteLine("\n=== Test 3: Bad jsonrpc version ==="); var payload3 = new JsonObject { ["jsonrpc"] = "1.0", ["id"] = 1, ["method"] = "message/send", ["params"] = new JsonObject() }; resp = await client.PostAsync( $"{KubeMqUrl}/a2a/{AgentId}", new StringContent(payload3.ToJsonString(), Encoding.UTF8, "application/json")); data = JsonNode.Parse(await resp.Content.ReadAsStringAsync())!; error = data["error"]; Console.WriteLine($" Code: {error?["code"]} (expected -32600)"); Console.WriteLine($" Message: {error?["message"]}"); Console.WriteLine("\nAll invalid request errors demonstrated!"); ``` ```go package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" "strings" ) const ( kubemqURL = "http://localhost:9090" agentID = "echo-agent-01" ) func main() { fmt.Println("=== Test 1: Invalid JSON ===") req, _ := http.NewRequest("POST", kubemqURL+"/a2a/"+agentID, strings.NewReader("{invalid json!!!}")) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { fmt.Fprintf(os.Stderr, "Request failed: %v\n", err) os.Exit(1) } body, _ := io.ReadAll(resp.Body) resp.Body.Close() var data map[string]interface{} json.Unmarshal(body, &data) errorObj, _ := data["error"].(map[string]interface{}) code, _ := errorObj["code"].(float64) fmt.Printf(" Code: %.0f (expected -32700)\n", code) fmt.Printf(" Message: %v\n", errorObj["message"]) fmt.Println("\n=== Test 2: Missing method field ===") payload2, _ := json.Marshal(map[string]interface{}{ "jsonrpc": "2.0", "id": 1, "params": map[string]interface{}{}, }) resp, _ = http.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(payload2)) body, _ = io.ReadAll(resp.Body) resp.Body.Close() json.Unmarshal(body, &data) errorObj, _ = data["error"].(map[string]interface{}) code, _ = errorObj["code"].(float64) fmt.Printf(" Code: %.0f (expected -32600)\n", code) fmt.Printf(" Message: %v\n", errorObj["message"]) fmt.Println("\n=== Test 3: Bad jsonrpc version ===") payload3, _ := json.Marshal(map[string]interface{}{ "jsonrpc": "1.0", "id": 1, "method": "message/send", "params": map[string]interface{}{}, }) resp, _ = http.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(payload3)) body, _ = io.ReadAll(resp.Body) resp.Body.Close() json.Unmarshal(body, &data) errorObj, _ = data["error"].(map[string]interface{}) code, _ = errorObj["code"].(float64) fmt.Printf(" Code: %.0f (expected -32600)\n", code) fmt.Printf(" Message: %v\n", errorObj["message"]) fmt.Println("\nAll invalid request errors demonstrated!") } ``` ```java import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.Map; public class Client { static final String KUBEMQ_URL = "http://localhost:9090"; static final String AGENT_ID = "echo-agent-01"; static final ObjectMapper MAPPER = new ObjectMapper(); public static void main(String[] args) throws Exception { var client = HttpClient.newHttpClient(); System.out.println("=== Test 1: Invalid JSON ==="); var req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID)) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString("{invalid json!!!}")) .build(); var resp = client.send(req, HttpResponse.BodyHandlers.ofString()); var data = MAPPER.readTree(resp.body()); var error = data.path("error"); System.out.println(" Code: " + error.path("code").asInt() + " (expected -32700)"); System.out.println(" Message: " + error.path("message").asText()); System.out.println("\n=== Test 2: Missing method field ==="); req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID)) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString( MAPPER.writeValueAsString(Map.of("jsonrpc", "2.0", "id", 1, "params", Map.of())))) .build(); resp = client.send(req, HttpResponse.BodyHandlers.ofString()); data = MAPPER.readTree(resp.body()); error = data.path("error"); System.out.println(" Code: " + error.path("code").asInt() + " (expected -32600)"); System.out.println(" Message: " + error.path("message").asText()); System.out.println("\n=== Test 3: Bad jsonrpc version ==="); req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID)) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString( MAPPER.writeValueAsString(Map.of( "jsonrpc", "1.0", "id", 1, "method", "message/send", "params", Map.of())))) .build(); resp = client.send(req, HttpResponse.BodyHandlers.ofString()); data = MAPPER.readTree(resp.body()); error = data.path("error"); System.out.println(" Code: " + error.path("code").asInt() + " (expected -32600)"); System.out.println(" Message: " + error.path("message").asText()); System.out.println("\nAll invalid request errors demonstrated!"); } } ``` ```python import asyncio import httpx KUBEMQ_URL = "http://localhost:9090" AGENT_ID = "echo-agent-01" async def main() -> None: async with httpx.AsyncClient() as client: print("=== Test 1: Invalid JSON ===") resp = await client.post( f"{KUBEMQ_URL}/a2a/{AGENT_ID}", content=b"{invalid json!!!}", headers={"Content-Type": "application/json"}, ) data = resp.json() error = data.get("error", {}) print(f" Code: {error.get('code')} (expected -32700)") print(f" Message: {error.get('message')}") print("\n=== Test 2: Missing method field ===") resp = await client.post( f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json={"jsonrpc": "2.0", "id": 1, "params": {}}, ) data = resp.json() error = data.get("error", {}) print(f" Code: {error.get('code')} (expected -32600)") print(f" Message: {error.get('message')}") print("\n=== Test 3: Bad jsonrpc version ===") resp = await client.post( f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json={"jsonrpc": "1.0", "id": 1, "method": "message/send", "params": {}}, ) data = resp.json() error = data.get("error", {}) print(f" Code: {error.get('code')} (expected -32600)") print(f" Message: {error.get('message')}") print("\nAll invalid request errors demonstrated!") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript const KUBEMQ_URL = "http://localhost:9090"; const AGENT_ID = "echo-agent-01"; async function sendRaw(label: string, url: string, opts: RequestInit) { console.log(`=== ${label} ===`); try { const resp = await fetch(url, opts); const text = await resp.text(); let data: unknown; try { data = JSON.parse(text); } catch { data = text; } console.log(`Status: ${resp.status}`); console.log(`Response: ${JSON.stringify(data, null, 2)}\n`); } catch (err) { console.log(`Error: ${err}\n`); } } async function main() { await sendRaw( "Invalid JSON body (expect -32700)", `${KUBEMQ_URL}/a2a/${AGENT_ID}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{invalid json!!!}", }, ); await sendRaw( "Missing method field (expect -32600)", `${KUBEMQ_URL}/a2a/${AGENT_ID}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, params: {} }), }, ); await sendRaw( "Wrong JSON-RPC version (expect -32600)", `${KUBEMQ_URL}/a2a/${AGENT_ID}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "1.0", id: 1, method: "message/send", params: {} }), }, ); await sendRaw( "Wrong Content-Type (expect -32700)", `${KUBEMQ_URL}/a2a/${AGENT_ID}`, { method: "POST", headers: { "Content-Type": "text/plain" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "message/send", params: {} }), }, ); await sendRaw( "Invalid agent_id format (expect -32600)", `${KUBEMQ_URL}/a2a/UPPERCASE-BAD`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "message/send", params: {} }), }, ); } main().catch(console.error); ``` ### Timeout (-32001) [#timeout--32001] Set a short per-call timeout via `params.configuration.timeout` (seconds) and target an agent that is slower than that. The gateway returns `-32001` once the deadline passes. ```bash curl -X POST http://localhost:9090/a2a/slow-agent-01 \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": { "message": {"parts": [{"text": "This will timeout"}]}, "configuration": {"timeout": 1} } }' # => {"jsonrpc":"2.0","id":1,"error":{"code":-32001,"message":"agent timeout: slow-agent-01"}} ``` ```csharp using System.Text; using System.Text.Json; using System.Text.Json.Nodes; const string KubeMqUrl = "http://localhost:9090"; const string AgentId = "slow-agent-01"; var payload = new JsonObject { ["jsonrpc"] = "2.0", ["id"] = 1, ["method"] = "message/send", ["params"] = new JsonObject { ["message"] = new JsonObject { ["parts"] = new JsonArray(new JsonObject { ["text"] = "This will timeout" }) }, ["configuration"] = new JsonObject { ["timeout"] = 1 } } }; using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; Console.WriteLine("Sending request with timeout=1 to slow agent (5s delay)..."); var resp = await client.PostAsync( $"{KubeMqUrl}/a2a/{AgentId}", new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json")); var body = await resp.Content.ReadAsStringAsync(); var data = JsonNode.Parse(body)!; Console.WriteLine(JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true })); var error = data["error"]; Console.WriteLine($"\nError code: {error?["code"]}"); Console.WriteLine($"Error message: {error?["message"]}"); var code = error?["code"]?.GetValue(); if (code == -32001) Console.WriteLine("\nTimeout error (-32001) received as expected!"); ``` ```go package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" "time" ) const ( kubemqURL = "http://localhost:9090" agentID = "slow-agent-01" ) func main() { payload := map[string]interface{}{ "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": map[string]interface{}{ "message": map[string]interface{}{"parts": []map[string]interface{}{{"text": "This will timeout"}}}, "configuration": map[string]interface{}{"timeout": 1}, }, } data, _ := json.Marshal(payload) fmt.Println("Sending request with timeout=1 to slow agent (5s delay)...") client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(data)) if err != nil { fmt.Fprintf(os.Stderr, "Request failed: %v\n", err) os.Exit(1) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var pretty bytes.Buffer json.Indent(&pretty, body, "", " ") fmt.Println(pretty.String()) var result map[string]interface{} json.Unmarshal(body, &result) errorObj, _ := result["error"].(map[string]interface{}) code, _ := errorObj["code"].(float64) msg, _ := errorObj["message"].(string) fmt.Printf("\nError code: %.0f\n", code) fmt.Printf("Error message: %s\n", msg) if int(code) != -32001 { fmt.Fprintf(os.Stderr, "Expected -32001, got %.0f\n", code) os.Exit(1) } fmt.Println("\nTimeout error (-32001) received as expected!") } ``` ```java import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.util.List; import java.util.Map; public class Client { static final String KUBEMQ_URL = "http://localhost:9090"; static final String AGENT_ID = "slow-agent-01"; static final ObjectMapper MAPPER = new ObjectMapper(); public static void main(String[] args) throws Exception { var payload = Map.of( "jsonrpc", "2.0", "id", 1, "method", "message/send", "params", Map.of( "message", Map.of("parts", List.of(Map.of("text", "This will timeout"))), "configuration", Map.of("timeout", 1) ) ); var client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build(); var req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID)) .header("Content-Type", "application/json") .timeout(Duration.ofSeconds(30)) .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload))) .build(); System.out.println("Sending request with timeout=1 to slow agent (5s delay)..."); var resp = client.send(req, HttpResponse.BodyHandlers.ofString()); var data = MAPPER.readTree(resp.body()); System.out.println(MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(data)); var error = data.path("error"); System.out.println("\nError code: " + error.path("code").asInt()); System.out.println("Error message: " + error.path("message").asText()); assert error.path("code").asInt() == -32001 : "Expected -32001, got " + error.path("code").asInt(); System.out.println("\nTimeout error (-32001) received as expected!"); } } ``` ```python import asyncio import json import httpx KUBEMQ_URL = "http://localhost:9090" AGENT_ID = "slow-agent-01" async def main() -> None: payload = { "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": { "message": {"parts": [{"text": "This will timeout"}]}, "configuration": {"timeout": 1}, }, } async with httpx.AsyncClient(timeout=30) as client: print("Sending request with timeout=1 to slow agent (5s delay)...") resp = await client.post(f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload) data = resp.json() print(json.dumps(data, indent=2)) error = data.get("error", {}) print(f"\nError code: {error.get('code')}") print(f"Error message: {error.get('message')}") assert error.get("code") == -32001, f"Expected -32001, got {error.get('code')}" print("\nTimeout error (-32001) received as expected!") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript const KUBEMQ_URL = "http://localhost:9090"; const AGENT_ID = "slow-agent-01"; async function main() { const request = { jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { parts: [{ text: "This will timeout" }] }, configuration: { timeout: 1 }, }, }; console.log("Sending with timeout=1s to a 5s-delay agent..."); const resp = await fetch(`${KUBEMQ_URL}/a2a/${AGENT_ID}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), }); const data = await resp.json(); console.log("Response:", JSON.stringify(data, null, 2)); if (data.error) { console.log(`\nError code: ${data.error.code} (expect -32001)`); console.log(`Error message: ${data.error.message}`); console.log(`Match: ${data.error.code === -32001}`); } else { console.log("\nUnexpected: got success response instead of timeout"); } } main().catch(console.error); ``` ## Related [#related] # Getting Started (/aiway/a2a/getting-started) The A2A connector turns any HTTP server into a callable agent. You register an agent's URL with the gateway, then route JSON-RPC `message/send` requests to it through `POST /a2a/` — no KubeMQ SDK on the agent. This walkthrough takes you from a running server to a verified round-trip. ## Prerequisites [#prerequisites] * A running **kubemq-server** with the shared HTTP server reachable on **port 9090**. * An HTTP endpoint to register as your agent. For this guide, run one of the example echo agents from `.kb/integration-a2a/examples` (each agent registers itself on startup and echoes the request body back). * `curl` (or one of the language clients below) to send the message. Confirm the gateway is live by fetching the platform agent card: ```bash curl http://localhost:9090/.well-known/agent-card.json ``` ## Enable / disable [#enable--disable] The A2A connector is **enabled by default**. Start kubemq-server and the `/a2a/*` and `/agents/*` routes are live immediately — there is **no `=true` flag to set**. To **disable** A2A, set its enable variable to `false`: The disable variable name is irregular by design — the config key `Connectors.A2A.Enable` snake-cases to `CONNECTORSA2_A_ENABLE`, not `KUBEMQ_A2A_ENABLE`. See [Shared HTTP server](/connectors/concepts/shared-http-server) for why, and never rely on the older off-by-default behavior. ## How it works [#how-it-works] A `message/send` request travels from the caller to the gateway, across the broker to the agent's virtual subscriber, out to the agent over HTTP, and back the same way — the gateway relays the agent's JSON-RPC response to the caller unchanged. *The gateway proxies the request to the agent over the broker and relays the reply unchanged.* ## Steps [#steps] ### Register an agent [#register-an-agent] An agent registers itself by POSTing its **agent card** — including the absolute `url` the gateway will call — to `POST /agents/register`. The example echo agents do this on startup; the snippets below show the registration call. ```bash curl -X POST http://localhost:9090/agents/register \ -H "Content-Type: application/json" \ -d '{ "agent_id": "echo-agent-01", "name": "Echo Agent", "description": "A simple echo agent for testing", "version": "1.0.0", "url": "http://localhost:18080/", "skills": [ {"id": "echo", "name": "Echo", "description": "Echoes back the received message", "tags": ["test", "echo"]} ], "defaultInputModes": ["text"], "defaultOutputModes": ["text"], "protocolVersions": ["1.0"] }' ``` ```csharp var card = new JsonObject { ["agent_id"] = "echo-agent-01", ["name"] = "Echo Agent", ["description"] = "A simple echo agent for testing", ["version"] = "1.0.0", ["url"] = "http://localhost:18080/", ["skills"] = new JsonArray(new JsonObject { ["id"] = "echo", ["name"] = "Echo", ["description"] = "Echoes back the received message", ["tags"] = new JsonArray("test", "echo") }), ["defaultInputModes"] = new JsonArray("text"), ["defaultOutputModes"] = new JsonArray("text"), ["protocolVersions"] = new JsonArray("1.0") }; using var client = new HttpClient(); var resp = await client.PostAsync( "http://localhost:9090/agents/register", new StringContent(card.ToJsonString(), System.Text.Encoding.UTF8, "application/json")); Console.WriteLine($"Registered: {(int)resp.StatusCode}"); ``` ```go card := map[string]interface{}{ "agent_id": "echo-agent-01", "name": "Echo Agent", "description": "A simple echo agent for testing", "version": "1.0.0", "url": "http://localhost:18080/", "skills": []map[string]interface{}{ { "id": "echo", "name": "Echo", "description": "Echoes back the received message", "tags": []string{"test", "echo"}, }, }, "defaultInputModes": []string{"text"}, "defaultOutputModes": []string{"text"}, "protocolVersions": []string{"1.0"}, } data, _ := json.Marshal(card) resp, err := http.Post("http://localhost:9090/agents/register", "application/json", bytes.NewReader(data)) if err != nil { fmt.Fprintf(os.Stderr, "Registration failed: %v\n", err) return } defer resp.Body.Close() fmt.Printf("Registered: %d\n", resp.StatusCode) ``` ```java var card = Map.of( "agent_id", "echo-agent-01", "name", "Echo Agent", "description", "A simple echo agent for testing", "version", "1.0.0", "url", "http://localhost:18080/", "skills", List.of(Map.of( "id", "echo", "name", "Echo", "description", "Echoes back the received message", "tags", List.of("test", "echo") )), "defaultInputModes", List.of("text"), "defaultOutputModes", List.of("text"), "protocolVersions", List.of("1.0") ); var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create("http://localhost:9090/agents/register")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(card))) .build(); var resp = client.send(req, HttpResponse.BodyHandlers.ofString()); System.out.println("Registered: " + resp.statusCode()); ``` ```python card = { "agent_id": "echo-agent-01", "name": "Echo Agent", "description": "A simple echo agent for testing", "version": "1.0.0", "url": "http://localhost:18080/", "skills": [ { "id": "echo", "name": "Echo", "description": "Echoes back the received message", "tags": ["test", "echo"], } ], "defaultInputModes": ["text"], "defaultOutputModes": ["text"], "protocolVersions": ["1.0"], } async with httpx.AsyncClient() as client: resp = await client.post("http://localhost:9090/agents/register", json=card) print(f"Registered: {resp.status_code}") ``` ```typescript const card = { agent_id: "echo-agent-01", name: "Echo Agent", description: "A simple echo agent for testing", version: "1.0.0", url: "http://localhost:18080/", skills: [ { id: "echo", name: "Echo", description: "Echoes back the received message", tags: ["test", "echo"], }, ], defaultInputModes: ["text"], defaultOutputModes: ["text"], protocolVersions: ["1.0"], }; const resp = await fetch("http://localhost:9090/agents/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(card), }); console.log("Registered:", resp.status); ``` A `200` response means the agent is registered. The gateway returns the stored card with server-managed `registered_at` and `last_seen` fields populated. ### Send a message [#send-a-message] Route a JSON-RPC 2.0 `message/send` request to the agent through `POST /a2a/`. The gateway forwards it to the agent and relays the reply back. ```bash curl -X POST http://localhost:9090/a2a/echo-agent-01 \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": { "message": { "parts": [{"text": "Hello, agent!"}] } } }' ``` ```csharp var payload = new JsonObject { ["jsonrpc"] = "2.0", ["id"] = 1, ["method"] = "message/send", ["params"] = new JsonObject { ["message"] = new JsonObject { ["parts"] = new JsonArray(new JsonObject { ["text"] = "Hello, agent!" }) } } }; using var client = new HttpClient(); var resp = await client.PostAsync( "http://localhost:9090/a2a/echo-agent-01", new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json")); Console.WriteLine($"Status: {(int)resp.StatusCode}"); var body = await resp.Content.ReadAsStringAsync(); var data = JsonNode.Parse(body)!; Console.WriteLine(JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true })); ``` ```go payload := map[string]interface{}{ "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": map[string]interface{}{ "message": map[string]interface{}{ "parts": []map[string]interface{}{{"text": "Hello, agent!"}}, }, }, } data, _ := json.Marshal(payload) resp, err := http.Post("http://localhost:9090/a2a/echo-agent-01", "application/json", bytes.NewReader(data)) if err != nil { fmt.Fprintf(os.Stderr, "Request failed: %v\n", err) os.Exit(1) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) fmt.Printf("Status: %d\n", resp.StatusCode) fmt.Println(string(body)) ``` ```java var payload = Map.of( "jsonrpc", "2.0", "id", 1, "method", "message/send", "params", Map.of( "message", Map.of( "parts", List.of(Map.of("text", "Hello, agent!")) ) ) ); var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create("http://localhost:9090/a2a/echo-agent-01")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload))) .build(); var resp = client.send(req, HttpResponse.BodyHandlers.ofString()); System.out.println("Status: " + resp.statusCode()); var data = MAPPER.readTree(resp.body()); System.out.println(MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(data)); ``` ```python payload = { "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": { "message": { "parts": [{"text": "Hello, agent!"}], }, }, } async with httpx.AsyncClient() as client: resp = await client.post("http://localhost:9090/a2a/echo-agent-01", json=payload) print(f"Status: {resp.status_code}") data = resp.json() print(json.dumps(data, indent=2)) assert "result" in data ``` ```typescript const request = { jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { parts: [{ text: "Hello, agent!" }], }, }, }; const resp = await fetch("http://localhost:9090/a2a/echo-agent-01", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), }); const data = await resp.json(); console.log("Response:", JSON.stringify(data, null, 2)); ``` ### Verify the reply [#verify-the-reply] The echo agent returns the full request body inside `result.echo`, confirming the round-trip through the gateway: ```json { "jsonrpc": "2.0", "id": 1, "result": { "echo": { "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": { "message": { "parts": [{ "text": "Hello, agent!" }] } } } } } ``` A response containing `result` is a successful round-trip. A response with an `error` object instead means the gateway or agent reported a problem — see [Error handling](/aiway/a2a/error-handling) for the codes and how to distinguish transport failures from application errors. ## What's next [#whats-next] # AI Agents (A2A) (/aiway/a2a) The **A2A connector** turns KubeMQ into a gateway for AI agents. It implements a subset of Google's Agent-to-Agent protocol as a transparent JSON-RPC 2.0 proxy: a caller POSTs to `/a2a/{agent_id}`, and KubeMQ routes the request to the right agent and relays the reply back — agents stay plain HTTP servers with **zero KubeMQ dependencies**. **Part of Aiway.** A2A is one of the two doors into [KubeMQ Aiway](/aiway), the AI Agents Fabric. New here? Start with the [Aiway overview](/aiway), or follow the end-to-end [Aiway tutorial](/aiway/tutorial). ## What is A2A [#what-is-a2a] A2A lets one agent call another through a single, well-known endpoint instead of wiring point-to-point connections between every pair of agents. KubeMQ sits in the middle as the gateway and does the work that would otherwise be repeated in each agent: looking up where a target agent lives, enforcing timeouts and concurrency limits, forwarding the right headers, and proxying Server-Sent Event streams. Two pieces make this work: * An **agent registry** — a REST API where agents announce themselves by `agent_id` and HTTP URL. The registry tracks each agent's card (name, skills, version) with TTL-based liveness. * A **virtual subscriber** (also called the **Agent Bridge**) — when an agent registers, KubeMQ spawns an internal subscriber on the internal channel `_AGENTS_.agents/`. Incoming JSON-RPC requests arrive over the broker, and the virtual subscriber forwards each one as an HTTP `POST` to the agent's registered URL, then relays the response back. The MCP *agent-bridge tools* (`agent_list`/`agent_info`/`agent_send`/`agent_query`) invoke agents *through* this Agent Bridge — same concept, two layers, not two meanings. Because the virtual subscriber handles all broker and protobuf translation, **an agent is just an HTTP server that speaks JSON-RPC 2.0** — there is no KubeMQ SDK, no protobuf, and no broker knowledge on the agent side. This is a breaking change from older KubeMQ A2A docs, which described agents that embedded a KubeMQ SDK. Agents are now registered by absolute `http(s)://` URL and require no library. See [Building agents](/aiway/a2a/guides/building-agents) for the current model. ## Why A2A on KubeMQ [#why-a2a-on-kubemq] * **No SDK on agents** — register a URL; KubeMQ bridges the broker to your agent's HTTP endpoint for you. * **One gateway, many agents** — callers always POST to `/a2a/{agent_id}`; routing, discovery, and lifecycle are centralized. * **Sync and streaming** — `message/send` for request/reply, `message/stream` for long-running tasks proxied as SSE. * **Method-agnostic proxy** — standard A2A methods and any custom JSON-RPC method are forwarded as-is; the agent decides what to handle. * **Built-in guardrails** — per-agent concurrency caps, timeout enforcement with a gateway buffer, response-size limits, and selective header forwarding. ## Architecture [#architecture] A caller never connects to an agent directly. The request flows through the A2A gateway, over the broker to the target agent's virtual subscriber, and out as an HTTP POST. *The gateway proxies JSON-RPC over the broker; the virtual subscriber calls the agent's HTTP URL.* ## Endpoint surface [#endpoint-surface] | Method | Path | Purpose | | ------ | --------------------------------------------- | ---------------------------------------------------------------------------------- | | `POST` | `/a2a/{agent_id}` | JSON-RPC 2.0 proxy to the agent (`message/send`, `message/stream`, custom methods) | | `GET` | `/a2a/{agent_id}/stream` | SSE streaming endpoint | | `GET` | `/a2a/{agent_id}/.well-known/agent-card.json` | Individual agent card | | `GET` | `/.well-known/agent-card.json` | Platform-level card | | `POST` | `/agents/register` | Register an agent | | `POST` | `/agents/heartbeat` | Agent heartbeat (refresh liveness) | | `POST` | `/agents/deregister` | Deregister an agent | | `GET` | `/agents` | List registered agents (optional skill-tag filter) | | `GET` | `/agents/{agent_id}` | Get one agent's card | The A2A connector runs on the [shared HTTP server](/connectors/concepts/shared-http-server) (port 9090) and is **enabled by default** — there is no flag to turn it on. To disable it, set `CONNECTORSA2_A_ENABLE=false`. ## Send a message [#send-a-message] A request is a JSON-RPC 2.0 envelope POSTed to `/a2a/{agent_id}`. The example below sends `message/send` to a registered `echo-agent-01`. ```bash curl -X POST http://localhost:9090/a2a/echo-agent-01 \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": { "message": { "parts": [{"text": "Hello, agent!"}] } } }' ``` ```csharp using System.Text; using System.Text.Json; using System.Text.Json.Nodes; const string KubeMqUrl = "http://localhost:9090"; const string AgentId = "echo-agent-01"; var payload = new JsonObject { ["jsonrpc"] = "2.0", ["id"] = 1, ["method"] = "message/send", ["params"] = new JsonObject { ["message"] = new JsonObject { ["parts"] = new JsonArray(new JsonObject { ["text"] = "Hello, agent!" }) } } }; using var client = new HttpClient(); var resp = await client.PostAsync( $"{KubeMqUrl}/a2a/{AgentId}", new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json")); Console.WriteLine($"Status: {(int)resp.StatusCode}"); var body = await resp.Content.ReadAsStringAsync(); var data = JsonNode.Parse(body)!; Console.WriteLine(JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true })); if (data["result"] != null) Console.WriteLine("\nBasic send completed successfully!"); ``` ```go package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" ) const ( kubemqURL = "http://localhost:9090" agentID = "echo-agent-01" ) func main() { payload := map[string]interface{}{ "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": map[string]interface{}{ "message": map[string]interface{}{ "parts": []map[string]interface{}{{"text": "Hello, agent!"}}, }, }, } data, _ := json.Marshal(payload) resp, err := http.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(data)) if err != nil { fmt.Fprintf(os.Stderr, "Request failed: %v\n", err) os.Exit(1) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) fmt.Printf("Status: %d\n", resp.StatusCode) var pretty bytes.Buffer json.Indent(&pretty, body, "", " ") fmt.Println(pretty.String()) var result map[string]interface{} json.Unmarshal(body, &result) if _, ok := result["result"]; !ok { fmt.Fprintf(os.Stderr, "Missing 'result' in response\n") os.Exit(1) } fmt.Println("\nBasic send completed successfully!") } ``` ```java import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.List; import java.util.Map; public class Client { static final String KUBEMQ_URL = "http://localhost:9090"; static final String AGENT_ID = "echo-agent-01"; static final ObjectMapper MAPPER = new ObjectMapper(); public static void main(String[] args) throws Exception { var payload = Map.of( "jsonrpc", "2.0", "id", 1, "method", "message/send", "params", Map.of( "message", Map.of( "parts", List.of(Map.of("text", "Hello, agent!")) ) ) ); var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID)) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload))) .build(); var resp = client.send(req, HttpResponse.BodyHandlers.ofString()); System.out.println("Status: " + resp.statusCode()); var data = MAPPER.readTree(resp.body()); System.out.println(MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(data)); assert data.has("result"); System.out.println("\nBasic send completed successfully!"); } } ``` ```python import asyncio import json import httpx KUBEMQ_URL = "http://localhost:9090" AGENT_ID = "echo-agent-01" async def main() -> None: payload = { "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": { "message": { "parts": [{"text": "Hello, agent!"}], }, }, } async with httpx.AsyncClient() as client: resp = await client.post( f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload, ) print(f"Status: {resp.status_code}") data = resp.json() print(json.dumps(data, indent=2)) assert "result" in data print("\nBasic send completed successfully!") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript const KUBEMQ_URL = "http://localhost:9090"; const AGENT_ID = "echo-agent-01"; async function main() { const request = { jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { parts: [{ text: "Hello, agent!" }], }, }, }; const resp = await fetch(`${KUBEMQ_URL}/a2a/${AGENT_ID}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), }); const data = await resp.json(); console.log("Response:", JSON.stringify(data, null, 2)); if (data.result) { console.log("\nBasic send completed successfully!"); } else if (data.error) { console.error("\nError:", data.error.message); } } main().catch(console.error); ``` ## Supported languages [#supported-languages] Every A2A operation ships with a curl example plus client code in five languages, sourced from real working examples. | Language | Client | | ----------- | ------------------------------------ | | curl / HTTP | Raw JSON-RPC over HTTP | | C# | `HttpClient` + `System.Text.Json` | | Go | `net/http` + `encoding/json` | | Java | `java.net.http.HttpClient` + Jackson | | Python | `httpx` (async) | | TypeScript | `fetch` | ## Next steps [#next-steps] # Reference (/aiway/a2a/reference) The complete API surface of the KubeMQ A2A connector: HTTP endpoints, the JSON-RPC 2.0 wire format, the agent card schema, internal channels, configuration fields, error codes, and Prometheus metrics. The connector is a transparent JSON-RPC proxy — agents are plain HTTP servers registered by URL, reached through a per-agent virtual subscriber over the broker. All HTTP endpoints are served on the [shared HTTP server](/connectors/concepts/shared-http-server) (default port `9090`), which also hosts the REST, MCP, and CloudEvents connectors. Prometheus metrics are exposed separately on port `8080` (see [Observability](/connectors/concepts/observability)). ## HTTP Endpoints [#http-endpoints] ### A2A endpoints [#a2a-endpoints] | Method | Path | Description | Request body | Success | Error | | ------ | ------------------------ | ------------------------------------- | -------------------- | ----------------------------- | ---------------------- | | POST | `/a2a/{agent_id}` | JSON-RPC 2.0 request (sync or stream) | JSON-RPC 2.0 payload | JSON-RPC result or SSE stream | JSON-RPC error | | GET | `/a2a/{agent_id}` | Not supported | — | — | 405 Method Not Allowed | | GET | `/a2a/{agent_id}/stream` | SSE stream via GET | — | `text/event-stream` | — | `POST /a2a/{agent_id}` is the primary endpoint. Its behavior depends on the JSON-RPC `method`: `message/stream` opens an SSE proxy; every other method is forwarded synchronously to the agent. `GET /a2a/{agent_id}` always returns **405** — use POST for JSON-RPC or `GET /a2a/{agent_id}/stream` for a server-pushed stream. ```bash # Synchronous message/send curl -X POST http://localhost:9090/a2a/echo-agent-01 \ -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"message/send","params":{"message":{"parts":[{"text":"Hello, agent!"}]}}}' # SSE stream via GET curl -N http://localhost:9090/a2a/echo-agent-01/stream \ -H 'Accept: text/event-stream' ``` ### Registry endpoints [#registry-endpoints] Standard HTTP JSON responses (not JSON-RPC). See [Agent registry](/aiway/a2a/registry) for the full workflow. | Method | Path | Description | Request body | Success | Error | | ------ | -------------------- | --------------------------------------------- | ------------------ | ------------------------------------- | --------------- | | POST | `/agents/register` | Register an agent | Agent card JSON | 200 + enriched card | 400 / 403 / 409 | | GET | `/agents` | List agents (filter by `skill_tags`, `limit`) | — | 200 + `[, …]` (bare array) | — | | GET | `/agents/{agent_id}` | Get one agent | — | 200 + agent card | 404 | | POST | `/agents/deregister` | Deregister (JSON body) | `{"agent_id":"…"}` | 200 + `{"ok":true}` | 404 | | DELETE | `/agents/{agent_id}` | Deregister (REST, backward compat) | — | 200 + `{"ok":true}` | 404 | | POST | `/agents/heartbeat` | Refresh liveness | `{"agent_id":"…"}` | 200 + `{"ok":true}` | 400 | `POST /agents/register` errors: **400** (card validation), **403** (ownership conflict — a different principal owns this `agent_id`), **409** (`MaxAgents` limit reached). `registered_by` is set automatically from the caller's JWT claims. ```bash # List agents tagged "search" or "nlp", capped at 10 curl 'http://localhost:9090/agents?skill_tags=search,nlp&limit=10' ``` ### Agent card endpoints [#agent-card-endpoints] | Method | Path | Description | Success | Error | | ------ | --------------------------------------------- | ------------------------------------- | ---------------------- | ----- | | GET | `/.well-known/agent-card.json` | Platform agent card (KubeMQ metadata) | 200 (`name: "kubemq"`) | — | | GET | `/a2a/{agent_id}/.well-known/agent-card.json` | Individual agent card from registry | 200 + enriched card | 404 | Both `.well-known/agent-card.json` paths are public — they bypass authentication. See [Agent cards](/aiway/a2a/agent-cards). ```bash # Platform card — confirms the A2A connector is reachable curl http://localhost:9090/.well-known/agent-card.json ``` ## JSON-RPC 2.0 wire format [#json-rpc-20-wire-format] The connector uses JSON-RPC 2.0 for all client-to-agent communication. A request carries three required fields plus an optional `params` object. ```json { "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": { "message": { "parts": [{ "text": "Hello, agent!" }] }, "contextId": "optional-correlation-id", "configuration": { "timeout": 30 } } } ``` ### Request fields [#request-fields] | Field | Type | Required | Description | | ------------------------------ | ----------------- | -------- | ------------------------------------------------------------------------------------------------- | | `jsonrpc` | string | Yes | Must be `"2.0"` | | `id` | integer or string | Yes | Request identifier, echoed in the response | | `method` | string | Yes | JSON-RPC method name (missing/empty → `-32600`) | | `params` | object | No | Method parameters | | `params.message.parts` | array | No | Message content; each part has a `text` field | | `params.contextId` | string | No | Correlation ID, passed to the agent unmodified | | `params.configuration.timeout` | number | No | Request timeout in seconds (falls back to `DefaultTimeoutSeconds`, capped at `MaxTimeoutSeconds`) | ### Methods [#methods] KubeMQ is method-agnostic — it forwards any method to the agent unchanged. The five methods below are recognized for metrics labeling; all others are recorded as `method="unknown"`. | Method | Behavior | Response | | -------------------- | ------------------------------------------- | ------------------- | | `message/send` | Synchronous proxy to the agent | JSON-RPC response | | `message/stream` | SSE stream proxy via the virtual subscriber | `text/event-stream` | | `tasks/get` | Forwarded to the agent | JSON-RPC response | | `tasks/cancel` | Forwarded to the agent | JSON-RPC response | | `tasks/send` | Forwarded to the agent | JSON-RPC response | | *(any other method)* | Forwarded to the agent | JSON-RPC response | JSON-RPC batch requests (an array of request objects) are **not supported** — send individual requests. Notifications (requests without an `id`) are forwarded to the agent but receive no response. ### Success and error responses [#success-and-error-responses] On success, the agent's return value is placed verbatim in `result` — KubeMQ does not modify the payload. On failure, the response carries an `error` object instead. ```json { "jsonrpc": "2.0", "id": 1, "result": { "…": "agent output, unmodified" } } ``` ```json { "jsonrpc": "2.0", "id": 1, "error": { "code": -32002, "message": "agent not found: nonexistent-agent" } } ``` ## Agent card schema [#agent-card-schema] The `AgentCard` is submitted on registration and returned, enriched with server-managed timestamps, from the list/get/well-known endpoints. Required fields are `agent_id`, `name`, and `url`. | Field | JSON key | Type | Required | Description | | --------------------- | --------------------- | --------- | -------- | -------------------------------------------------------------------------- | | `AgentID` | `agent_id` | string | Yes | Unique ID; 2–128 chars, `^[a-z0-9][a-z0-9-]{0,126}[a-z0-9]$` | | `Name` | `name` | string | Yes | Human-readable name (max 256 chars) | | `Description` | `description` | string | No | Agent description (max 2048 chars) | | `Version` | `version` | string | No | Agent version (max 64 chars) | | `URL` | `url` | string | Yes | Absolute `http://` or `https://` URL (max 2048 chars) | | `RegisteredBy` | `registered_by` | string | No | JWT principal that registered the agent (server-set) | | `Capabilities` | `capabilities` | object | No | Free-form capability map | | `Skills` | `skills` | array | No | List of `AgentSkill` (see below) | | `DefaultInputModes` | `defaultInputModes` | string\[] | No | Default input modes | | `DefaultOutputModes` | `defaultOutputModes` | string\[] | No | Default output modes | | `SupportedInterfaces` | `supportedInterfaces` | JSON | No | Opaque JSON | | `SecuritySchemes` | `securitySchemes` | JSON | No | Opaque JSON | | `Security` | `security` | JSON | No | Opaque JSON | | `ProtocolVersions` | `protocolVersions` | string\[] | No | Supported versions (default `["1.0"]`) | | `Metadata` | `metadata` | object | No | Key-value metadata | | `LastSeen` | `last_seen` | timestamp | — | Last heartbeat/registration (server-set) | | `RegisteredAt` | `registered_at` | timestamp | — | Original registration time, preserved across re-registrations (server-set) | ### AgentSkill [#agentskill] | Field | JSON key | Type | Required | Description | | ------------- | ------------- | --------- | -------- | ------------------------------------------- | | `ID` | `id` | string | Yes | Skill identifier | | `Name` | `name` | string | Yes | Skill name | | `Description` | `description` | string | No | Skill description | | `Tags` | `tags` | string\[] | No | Skill tags, used by the `skill_tags` filter | ```json { "agent_id": "echo-agent-01", "name": "Echo Agent", "url": "http://localhost:18080/", "skills": [{ "id": "echo", "name": "Echo", "tags": ["test", "echo"] }] } ``` ## Internal channel map [#internal-channel-map] The connector and registry use the reserved `_AGENTS_.` prefix internally. User channels carrying this prefix are rejected (`IsReservedChannel`). Callers never address these channels directly — the connector and virtual subscriber own them. | Channel pattern | Purpose | Transport | | ----------------------------- | -------------------------------------------------------------------------------- | ------------ | | `_AGENTS_.agents/{agent_id}` | Request/reply to an agent via its virtual subscriber (including `stream_cancel`) | Query | | `_AGENTS_.stream/{stream_id}` | Temporary SSE stream events relayed by the virtual subscriber | Events | | `_AGENTS_.discovery` | Registry replication across cluster nodes | Events Store | Caller HTTP headers are forwarded to the agent as `a2a_hdr_*` tags on the broker request; the virtual subscriber always sets `X-KubeMQ-Caller-ID` to the original caller's identity. See [Architecture](/aiway/a2a/architecture). ## SSE event types [#sse-event-types] Streaming responses (`message/stream` or `GET /a2a/{agent_id}/stream`) are delivered as Server-Sent Events. Each event has an `event:` line and a `data:` line carrying the stream envelope. A keepalive comment (`: keepalive`) is sent every 30 seconds; the idle timeout is `MaxSSEIdleSeconds` (default 300s). See [Streaming](/aiway/a2a/streaming). | SSE event | Envelope `type` | Description | Terminal | | --------------- | --------------- | ----------------------- | -------- | | `task.status` | `status_update` | Progress update | No | | `task.artifact` | `artifact` | Intermediate artifact | No | | `task.done` | `done` | Successful completion | Yes | | `task.error` | `error` | Failure | Yes | | `message` | *(default)* | Any other envelope type | No | ```text event: task.status data: {"stream_id":"…","type":"status_update","payload":{"status":"working"}} event: task.done data: {"stream_id":"…","type":"done","payload":{"final_result":"completed"}} ``` ## Error codes [#error-codes] The connector returns standard JSON-RPC 2.0 base codes plus four A2A-specific codes. See [Error handling](/aiway/a2a/error-handling) for the transport-vs-application distinction and retry guidance. | Code | Name | Trigger | | -------- | ---------------------- | ------------------------------------------------------------------------- | | `-32700` | Parse Error | Malformed JSON body, or `Content-Type` is not `application/json` | | `-32600` | Invalid Request | Missing `method` field, `jsonrpc` != `"2.0"`, or empty/invalid `agent_id` | | `-32601` | Method Not Found | Reserved — KubeMQ forwards all methods to agents and does not raise this | | `-32602` | Invalid Params | Malformed `params` object | | `-32603` | Internal Error | Server-side failure | | `-32010` | Authentication Failure | JWT auth error on `/a2a/*` (REST endpoints return HTTP 401 instead) | | `-32001` | Agent Timeout | Agent did not respond within the timeout | | `-32002` | Agent Not Found | No agent registered with the given `agent_id` | | `-32003` | Agent Unavailable | Agent rejected the request | | `-32004` | Invalid Response | Invalid response from the agent | ### Transport vs application errors [#transport-vs-application-errors] The virtual subscriber sets `Executed: false` on **transport** failures (the agent never processed the request) and `Executed: true` on **application** errors (the agent processed it and returned an error body). This lets callers choose a retry strategy. | Agent result | `pb.Response` | Category | | -------------------------------------------- | -------------------------------------------------------------------- | ----------- | | Connection refused / DNS failure | `Executed: false`, `Error: "agent unreachable: …"` | Transport | | HTTP timeout | `Executed: false`, `Error: "agent timeout"` | Transport | | HTTP 502 / 503 / 504 | `Executed: false`, `Error: "agent unavailable: …"` | Transport | | Response exceeds `AgentMaxResponseBytes` | `Executed: false`, `Error: "agent response too large"` | Transport | | Concurrency limit reached | `Executed: false`, `Error: "server busy: concurrency limit reached"` | Transport | | HTTP 200–299 | `Executed: true`, `Body: response` | Success | | HTTP 400 / 401 / 403 / 404 / 409 / 422 / 500 | `Executed: true`, `Body: response` | Application | ## Configuration fields [#configuration-fields] `A2aConfig` is configured in `Connectors.A2A.*`. The connector is **enabled by default** — set `CONNECTORSA2_A_ENABLE=false` to disable it. See [Configuration](/aiway/a2a/configuration) and the [shared HTTP server](/connectors/concepts/shared-http-server#enable-model-on-by-default) enable model for the irregular env-var naming. | Field | Default | Disable/override env var | Description | | ----------------------- | ------------------ | ----------------------------------------- | --------------------------------------------------------------------- | | `Enable` | `true` | `CONNECTORSA2_A_ENABLE` | Enable the A2A connector (set `=false` to disable) | | `AgentTTLSeconds` | `300` | `CONNECTORSA2_A_AGENT_TTL_SECONDS` | Agent expiry TTL (liveness check) | | `DefaultTimeoutSeconds` | `300` | `CONNECTORSA2_A_DEFAULT_TIMEOUT_SECONDS` | Default request timeout | | `MaxTimeoutSeconds` | `3600` | `CONNECTORSA2_A_MAX_TIMEOUT_SECONDS` | Maximum allowed request timeout (must be ≥ `DefaultTimeoutSeconds`) | | `MaxAgents` | `0` (unlimited) | `CONNECTORSA2_A_MAX_AGENTS` | Maximum registered agents | | `MaxSSEIdleSeconds` | `300` | `CONNECTORSA2_A_MAX_SSE_IDLE_SECONDS` | SSE stream idle timeout | | `TrustedOrigins` | `["auto"]` | `CONNECTORSA2_A_TRUSTED_ORIGINS` | Trusted origins for browser requests | | `AgentMaxResponseBytes` | `10485760` (10 MB) | `CONNECTORSA2_A_AGENT_MAX_RESPONSE_BYTES` | Max agent HTTP response size; larger → `Executed: false` | | `AgentTLSSkipVerify` | `false` | `CONNECTORSA2_A_AGENT_TLS_SKIP_VERIFY` | Skip TLS verification for outbound agent calls (dev only) | | `AgentMaxConcurrency` | `100` | `CONNECTORSA2_A_AGENT_MAX_CONCURRENCY` | Max concurrent in-flight requests per agent; overflow → "server busy" | The gateway adds a `GatewayTimeoutBuffer` of 10 seconds on top of the caller-specified timeout so it does not time out before the downstream agent. ## Metrics [#metrics] Prometheus metrics are exposed on port `8080` at `/metrics`. See [Observability](/connectors/concepts/observability) for the full surface and the web AI dashboard. | Metric | Type | Labels | Description | | -------------------------------------- | --------- | ------------------------------ | ------------------------------------------------------------- | | `kubemq_a2a_requests_total` | Counter | `agent_id`, `method`, `status` | A2A requests forwarded to agents | | `kubemq_a2a_request_duration_seconds` | Histogram | `agent_id` | A2A request duration | | `kubemq_a2a_errors_total` | Counter | `agent_id`, `error_code` | A2A gateway errors | | `kubemq_a2a_registry_operations_total` | Counter | `op`, `status` | Registry operations (register, deregister, heartbeat, expire) | | `kubemq_a2a_sse_streams_active` | Gauge | — | Currently active SSE streams | The `method` label is sanitized against the five standard methods; any other value is recorded as `method="unknown"` to bound Prometheus label cardinality. ```bash curl http://localhost:8080/metrics 2>/dev/null | grep kubemq_a2a_ ``` # Agent Registry (/aiway/a2a/registry) The **agent registry** is a REST API where agents announce themselves to KubeMQ by `agent_id` and HTTP URL. It is the source of truth for who can be reached over the A2A gateway: registering an agent spawns its [virtual subscriber](/aiway/a2a/architecture), and a TTL-based liveness check removes agents that stop sending heartbeats. ## Overview [#overview] Every agent that callers can reach through `POST /a2a/{agent_id}` must first be registered. A registration is an **agent card** — `agent_id`, human-readable `name`, absolute `url`, and an optional list of skills. The registry persists cards in SQLite, tracks each agent's `last_seen` time, and replicates state across a cluster so any node can route to any agent. The registry exposes five operations as plain HTTP+JSON (not JSON-RPC): | Operation | Method · Path | Purpose | | ---------- | -------------------------------------------------------- | ---------------------------------------------- | | Register | `POST /agents/register` | Add or re-register an agent card | | List | `GET /agents` | List agents, optionally filtered by skill tags | | Get one | `GET /agents/{agent_id}` | Fetch a single agent's full card | | Heartbeat | `POST /agents/heartbeat` | Refresh `last_seen` to stay alive | | Deregister | `POST /agents/deregister` or `DELETE /agents/{agent_id}` | Remove an agent | ## How it works [#how-it-works] The registry is a service backed by SQLite. Registering spawns a virtual subscriber and emits a replication event; a background liveness checker sweeps expired agents. *Registration persists the card and spawns a virtual subscriber; the liveness checker prunes agents past their TTL.* ## The agent card [#the-agent-card] An agent card describes one agent. `agent_id`, `name`, and `url` are required; the `url` must be an absolute `http://` or `https://` address. Server-managed fields (`registered_at`, `last_seen`) are populated on the response. | Field | Type | Required | Description | | -------------------- | --------- | ---------- | -------------------------------------------------------------------- | | `agent_id` | string | yes | Unique identifier; 2–128 chars, lowercase alphanumeric and hyphens | | `name` | string | yes | Human-readable name (max 256 chars) | | `url` | string | yes | Absolute `http(s)://` endpoint the gateway POSTs to (max 2048 chars) | | `description` | string | no | Free-text description (max 2048 chars) | | `version` | string | no | Agent version (max 64 chars) | | `skills` | array | no | List of `AgentSkill` objects (see below) | | `defaultInputModes` | string\[] | no | Default input modes, e.g. `["text"]` | | `defaultOutputModes` | string\[] | no | Default output modes, e.g. `["text"]` | | `protocolVersions` | string\[] | no | Supported protocol versions; defaults to `["1.0"]` | | `registered_at` | string | server-set | Original registration time (preserved across re-registration) | | `last_seen` | string | server-set | Last heartbeat or registration time | Each entry in `skills` is an **AgentSkill**: `id` (required), `name` (required), `description`, and `tags` (used by the [list](#list-agents) filter and skill-based discovery). ```json { "agent_id": "echo-agent-01", "name": "Echo Agent", "description": "A simple echo agent for testing", "version": "1.0.0", "url": "http://localhost:18080/", "skills": [ { "id": "echo", "name": "Echo", "description": "Echoes back the received message", "tags": ["test", "echo"] } ], "defaultInputModes": ["text"], "defaultOutputModes": ["text"], "protocolVersions": ["1.0"] } ``` ## Register an agent [#register-an-agent] `POST /agents/register` with the agent card as the JSON body. Re-registering the same `agent_id` upserts the card and **preserves the original `registered_at`**. The response is the stored card with `registered_at` and `last_seen` populated. When auth is enabled, the `registered_by` field is set from the JWT principal and is used for [ownership](#ownership) checks. Registration fails with `400` (validation), `403` (ownership conflict), or `409` (the `MaxAgents` limit was reached). ```bash curl -X POST http://localhost:9090/agents/register \ -H "Content-Type: application/json" \ -d '{ "agent_id": "echo-agent-01", "name": "Echo Agent", "description": "A simple echo agent for testing", "version": "1.0.0", "url": "http://localhost:18080/", "skills": [ {"id": "echo", "name": "Echo", "description": "Echoes back the received message", "tags": ["test", "echo"]} ], "defaultInputModes": ["text"], "defaultOutputModes": ["text"], "protocolVersions": ["1.0"] }' ``` ```csharp using System.Text; using System.Text.Json; using System.Text.Json.Nodes; const string KubeMqUrl = "http://localhost:9090"; const int AgentPort = 18080; var card = new JsonObject { ["agent_id"] = "echo-agent-01", ["name"] = "Echo Agent", ["description"] = "A simple echo agent for testing", ["version"] = "1.0.0", ["url"] = $"http://localhost:{AgentPort}/", ["skills"] = new JsonArray(new JsonObject { ["id"] = "echo", ["name"] = "Echo", ["description"] = "Echoes back the received message", ["tags"] = new JsonArray("test", "echo") }), ["defaultInputModes"] = new JsonArray("text"), ["defaultOutputModes"] = new JsonArray("text"), ["protocolVersions"] = new JsonArray("1.0") }; using var client = new HttpClient(); var resp = await client.PostAsync( $"{KubeMqUrl}/agents/register", new StringContent(card.ToJsonString(), Encoding.UTF8, "application/json")); Console.WriteLine($"Registered: {(int)resp.StatusCode}"); var body = await resp.Content.ReadAsStringAsync(); Console.WriteLine(JsonSerializer.Serialize(JsonNode.Parse(body), new JsonSerializerOptions { WriteIndented = true })); ``` ```go package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" ) const ( kubemqURL = "http://localhost:9090" agentID = "echo-agent-01" agentPort = 18080 ) func main() { card := map[string]interface{}{ "agent_id": agentID, "name": "Echo Agent", "description": "A simple echo agent for testing", "version": "1.0.0", "url": fmt.Sprintf("http://localhost:%d/", agentPort), "skills": []map[string]interface{}{ { "id": "echo", "name": "Echo", "description": "Echoes back the received message", "tags": []string{"test", "echo"}, }, }, "defaultInputModes": []string{"text"}, "defaultOutputModes": []string{"text"}, "protocolVersions": []string{"1.0"}, } data, _ := json.Marshal(card) resp, err := http.Post(kubemqURL+"/agents/register", "application/json", bytes.NewReader(data)) if err != nil { fmt.Fprintf(os.Stderr, "Registration failed: %v\n", err) os.Exit(1) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) fmt.Printf("Registered: %d\n", resp.StatusCode) var pretty bytes.Buffer json.Indent(&pretty, body, "", " ") fmt.Println(pretty.String()) } ``` ```java import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.List; import java.util.Map; public class Client { static final String KUBEMQ_URL = "http://localhost:9090"; static final int AGENT_PORT = 18080; static final ObjectMapper MAPPER = new ObjectMapper(); public static void main(String[] args) throws Exception { var card = Map.of( "agent_id", "echo-agent-01", "name", "Echo Agent", "description", "A simple echo agent for testing", "version", "1.0.0", "url", "http://localhost:" + AGENT_PORT + "/", "skills", List.of(Map.of( "id", "echo", "name", "Echo", "description", "Echoes back the received message", "tags", List.of("test", "echo"))), "defaultInputModes", List.of("text"), "defaultOutputModes", List.of("text"), "protocolVersions", List.of("1.0") ); var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/agents/register")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(card))) .build(); var resp = client.send(req, HttpResponse.BodyHandlers.ofString()); System.out.println("Registered: " + resp.statusCode()); var data = MAPPER.readTree(resp.body()); System.out.println(MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(data)); } } ``` ```python import asyncio import json import httpx KUBEMQ_URL = "http://localhost:9090" AGENT_PORT = 18080 async def main() -> None: card = { "agent_id": "echo-agent-01", "name": "Echo Agent", "description": "A simple echo agent for testing", "version": "1.0.0", "url": f"http://localhost:{AGENT_PORT}/", "skills": [ { "id": "echo", "name": "Echo", "description": "Echoes back the received message", "tags": ["test", "echo"], } ], "defaultInputModes": ["text"], "defaultOutputModes": ["text"], "protocolVersions": ["1.0"], } async with httpx.AsyncClient() as client: resp = await client.post(f"{KUBEMQ_URL}/agents/register", json=card) print(f"Registered: {resp.status_code}") print(json.dumps(resp.json(), indent=2)) if __name__ == "__main__": asyncio.run(main()) ``` ```typescript const KUBEMQ_URL = "http://localhost:9090"; const AGENT_PORT = 18080; async function main() { const card = { agent_id: "echo-agent-01", name: "Echo Agent", description: "A simple echo agent for testing", version: "1.0.0", url: `http://localhost:${AGENT_PORT}/`, skills: [ { id: "echo", name: "Echo", description: "Echoes back the received message", tags: ["test", "echo"], }, ], defaultInputModes: ["text"], defaultOutputModes: ["text"], protocolVersions: ["1.0"], }; const resp = await fetch(`${KUBEMQ_URL}/agents/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(card), }); const data = await resp.json(); console.log("Registered:", JSON.stringify(data, null, 2)); } main().catch(console.error); ``` ## List agents [#list-agents] `GET /agents` returns a bare JSON array `[, ...]`. Add `?skill_tags=tag1,tag2` (comma-separated) to filter by skill tags, and `?limit=N` to cap the page size. Skill-tag filtering is applied in memory after fetching, enabling skill-based discovery. ```bash # All agents curl http://localhost:9090/agents # Filter by skill tags curl "http://localhost:9090/agents?skill_tags=echo" ``` ```csharp using System.Text.Json.Nodes; const string KubeMqUrl = "http://localhost:9090"; using var client = new HttpClient(); Console.WriteLine("=== All Agents ==="); var resp = await client.GetAsync($"{KubeMqUrl}/agents"); var agentsRoot = JsonNode.Parse(await resp.Content.ReadAsStringAsync())!; var agents = agentsRoot is JsonArray arr ? arr : agentsRoot["agents"]!.AsArray(); foreach (var agent in agents) { var skills = agent!["skills"]?.AsArray().Select(s => s!["id"]!.GetValue()).ToList() ?? []; Console.WriteLine($" {agent["agent_id"]}: skills=[{string.Join(", ", skills)}]"); } Console.WriteLine($"\nTotal agents: {agents.Count}"); Console.WriteLine("\n=== Filter by skill_tags=echo ==="); resp = await client.GetAsync($"{KubeMqUrl}/agents?skill_tags=echo"); var echoRoot = JsonNode.Parse(await resp.Content.ReadAsStringAsync())!; var filtered = echoRoot is JsonArray echoArr ? echoArr : echoRoot["agents"]!.AsArray(); foreach (var agent in filtered) Console.WriteLine($" {agent!["agent_id"]}"); ``` ```go package main import ( "encoding/json" "fmt" "io" "net/http" "os" ) const kubemqURL = "http://localhost:9090" func listAgents(url string, label string) { resp, err := http.Get(url) if err != nil { fmt.Fprintf(os.Stderr, "Request failed: %v\n", err) return } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) // GET /agents returns a bare JSON array: [, ...] var agents []map[string]interface{} if err := json.Unmarshal(body, &agents); err != nil { // Fallback for a {"agents":[...]} wrapper, if ever present. var wrapper map[string]interface{} json.Unmarshal(body, &wrapper) if raw, ok := wrapper["agents"].([]interface{}); ok { for _, a := range raw { if m, ok := a.(map[string]interface{}); ok { agents = append(agents, m) } } } } fmt.Printf("=== %s ===\n", label) for _, agent := range agents { fmt.Printf(" %s\n", agent["agent_id"]) } fmt.Printf("\nTotal: %d\n\n", len(agents)) } func main() { listAgents(kubemqURL+"/agents", "All Agents") listAgents(kubemqURL+"/agents?skill_tags=echo", "Filter by skill_tags=echo") } ``` ```java import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Client { static final String KUBEMQ_URL = "http://localhost:9090"; static final ObjectMapper MAPPER = new ObjectMapper(); public static void main(String[] args) throws Exception { var client = HttpClient.newHttpClient(); System.out.println("=== All Agents ==="); var req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/agents")) .GET().build(); var resp = client.send(req, HttpResponse.BodyHandlers.ofString()); var root = MAPPER.readTree(resp.body()); var agents = root.isArray() ? root : root.get("agents"); for (var agent : agents) { System.out.println(" " + agent.get("agent_id").asText()); } System.out.println("\nTotal agents: " + agents.size()); } } ``` ```python import asyncio import httpx KUBEMQ_URL = "http://localhost:9090" async def main() -> None: async with httpx.AsyncClient() as client: print("=== All Agents ===") resp = await client.get(f"{KUBEMQ_URL}/agents") data = resp.json() agents = data.get("agents", data) if isinstance(data, dict) else data for agent in agents: skills = [s["id"] for s in agent.get("skills", [])] print(f" {agent['agent_id']}: skills={skills}") print(f"\nTotal agents: {len(agents)}") print("\n=== Filter by skill_tags=echo ===") resp = await client.get(f"{KUBEMQ_URL}/agents", params={"skill_tags": "echo"}) data = resp.json() filtered = data.get("agents", data) if isinstance(data, dict) else data for agent in filtered: print(f" {agent['agent_id']}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript const KUBEMQ_URL = "http://localhost:9090"; async function main() { console.log("=== List all agents ==="); const allResp = await fetch(`${KUBEMQ_URL}/agents`); const allData = await allResp.json(); const allAgents = Array.isArray(allData) ? allData : (allData.agents || []); console.log(`Found ${allAgents.length} agent(s):`); for (const agent of allAgents) { const skillIds = (agent.skills || []).map((s: { id?: string }) => s.id).filter(Boolean); console.log(` - ${agent.agent_id} (skills: ${skillIds.join(", ") || "none"})`); } console.log("\n=== Filter by skill_tags=echo ==="); const echoResp = await fetch(`${KUBEMQ_URL}/agents?skill_tags=echo`); const echoData = await echoResp.json(); const echoAgents = Array.isArray(echoData) ? echoData : (echoData.agents || []); for (const agent of echoAgents) { console.log(` - ${agent.agent_id}`); } } main().catch(console.error); ``` ## Get one agent [#get-one-agent] `GET /agents/{agent_id}` returns the full agent card, or `404` if the agent is not registered. Use it to inspect server-managed fields like `registered_at` and `last_seen`. ```bash curl http://localhost:9090/agents/echo-agent-01 ``` ```csharp using System.Text.Json; using System.Text.Json.Nodes; const string KubeMqUrl = "http://localhost:9090"; const string AgentId = "echo-agent-01"; using var client = new HttpClient(); var resp = await client.GetAsync($"{KubeMqUrl}/agents/{AgentId}"); Console.WriteLine($"Status: {(int)resp.StatusCode}"); var data = JsonNode.Parse(await resp.Content.ReadAsStringAsync())!; Console.WriteLine($" agent_id: {data["agent_id"]}"); Console.WriteLine($" name: {data["name"]}"); Console.WriteLine($" url: {data["url"]}"); Console.WriteLine($" registered_at: {data["registered_at"]}"); Console.WriteLine($" last_seen: {data["last_seen"]}"); ``` ```go package main import ( "encoding/json" "fmt" "io" "net/http" "os" ) const ( kubemqURL = "http://localhost:9090" agentID = "echo-agent-01" ) func main() { resp, err := http.Get(kubemqURL + "/agents/" + agentID) if err != nil { fmt.Fprintf(os.Stderr, "Request failed: %v\n", err) os.Exit(1) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) fmt.Printf("Status: %d\n", resp.StatusCode) var data map[string]interface{} json.Unmarshal(body, &data) fmt.Printf(" agent_id: %v\n", data["agent_id"]) fmt.Printf(" name: %v\n", data["name"]) fmt.Printf(" url: %v\n", data["url"]) fmt.Printf(" registered_at: %v\n", data["registered_at"]) fmt.Printf(" last_seen: %v\n", data["last_seen"]) } ``` ```java import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Client { static final String KUBEMQ_URL = "http://localhost:9090"; static final String AGENT_ID = "echo-agent-01"; static final ObjectMapper MAPPER = new ObjectMapper(); public static void main(String[] args) throws Exception { var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/agents/" + AGENT_ID)) .GET().build(); var resp = client.send(req, HttpResponse.BodyHandlers.ofString()); System.out.println("Status: " + resp.statusCode()); var data = MAPPER.readTree(resp.body()); System.out.println(MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(data)); } } ``` ```python import asyncio import httpx KUBEMQ_URL = "http://localhost:9090" AGENT_ID = "echo-agent-01" async def main() -> None: async with httpx.AsyncClient() as client: resp = await client.get(f"{KUBEMQ_URL}/agents/{AGENT_ID}") print(f"Status: {resp.status_code}") data = resp.json() print(f" agent_id: {data.get('agent_id')}") print(f" name: {data.get('name')}") print(f" url: {data.get('url')}") print(f" registered_at: {data.get('registered_at')}") print(f" last_seen: {data.get('last_seen')}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript const KUBEMQ_URL = "http://localhost:9090"; const AGENT_ID = "echo-agent-01"; async function main() { const resp = await fetch(`${KUBEMQ_URL}/agents/${AGENT_ID}`); const agent = await resp.json(); console.log(` agent_id: ${agent.agent_id}`); console.log(` name: ${agent.name}`); console.log(` url: ${agent.url}`); console.log(` registered_at: ${agent.registered_at}`); console.log(` last_seen: ${agent.last_seen}`); } main().catch(console.error); ``` ## Heartbeat [#heartbeat] `POST /agents/heartbeat` with `{"agent_id": "..."}` refreshes the agent's `last_seen` time. An agent must heartbeat (or re-register) within its [TTL](#ttl-and-liveness) to avoid being expired. When auth is enabled, the ownership check applies. The response is `{"ok": true}`; heartbeating an unregistered agent returns an error. ```bash curl -X POST http://localhost:9090/agents/heartbeat \ -H "Content-Type: application/json" \ -d '{"agent_id": "echo-agent-01"}' ``` ```csharp using System.Text; using System.Text.Json.Nodes; const string KubeMqUrl = "http://localhost:9090"; const string AgentId = "echo-agent-01"; using var client = new HttpClient(); var body = new JsonObject { ["agent_id"] = AgentId }; var resp = await client.PostAsync( $"{KubeMqUrl}/agents/heartbeat", new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json")); var data = JsonNode.Parse(await resp.Content.ReadAsStringAsync())!; Console.WriteLine($"Heartbeat: status={(int)resp.StatusCode} last_seen={data["last_seen"]}"); ``` ```go package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" ) const ( kubemqURL = "http://localhost:9090" agentID = "echo-agent-01" ) func main() { body, _ := json.Marshal(map[string]string{"agent_id": agentID}) resp, err := http.Post(kubemqURL+"/agents/heartbeat", "application/json", bytes.NewReader(body)) if err != nil { fmt.Fprintf(os.Stderr, "Heartbeat failed: %v\n", err) os.Exit(1) } defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) var data map[string]interface{} json.Unmarshal(raw, &data) fmt.Printf("Heartbeat: status=%d last_seen=%v\n", resp.StatusCode, data["last_seen"]) } ``` ```java import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.Map; public class Client { static final String KUBEMQ_URL = "http://localhost:9090"; static final String AGENT_ID = "echo-agent-01"; static final ObjectMapper MAPPER = new ObjectMapper(); public static void main(String[] args) throws Exception { var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/agents/heartbeat")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString( MAPPER.writeValueAsString(Map.of("agent_id", AGENT_ID)))) .build(); var resp = client.send(req, HttpResponse.BodyHandlers.ofString()); var data = MAPPER.readTree(resp.body()); System.out.println("Heartbeat: status=" + resp.statusCode() + " last_seen=" + data.path("last_seen").asText()); } } ``` ```python import asyncio import httpx KUBEMQ_URL = "http://localhost:9090" AGENT_ID = "echo-agent-01" async def main() -> None: async with httpx.AsyncClient() as client: resp = await client.post( f"{KUBEMQ_URL}/agents/heartbeat", json={"agent_id": AGENT_ID}, ) data = resp.json() print(f"Heartbeat: status={resp.status_code} last_seen={data.get('last_seen')}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript const KUBEMQ_URL = "http://localhost:9090"; const AGENT_ID = "echo-agent-01"; async function main() { const resp = await fetch(`${KUBEMQ_URL}/agents/heartbeat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ agent_id: AGENT_ID }), }); const data = await resp.json(); console.log(`Heartbeat: status=${resp.status}, last_seen=${data.last_seen}`); } main().catch(console.error); ``` ## Deregister [#deregister] Remove an agent with either `POST /agents/deregister` (body `{"agent_id": "..."}`) or `DELETE /agents/{agent_id}`. Deregistering deletes the card, stops the agent's virtual subscriber, and drains in-flight requests. Both methods return `{"ok": true}`; when auth is enabled, the [ownership](#ownership) check applies. ```bash # Deregister via POST curl -X POST http://localhost:9090/agents/deregister \ -H "Content-Type: application/json" \ -d '{"agent_id": "echo-agent-01"}' # Or via DELETE curl -X DELETE http://localhost:9090/agents/echo-agent-01 ``` ```csharp using System.Text; using System.Text.Json.Nodes; const string KubeMqUrl = "http://localhost:9090"; const string AgentId = "echo-agent-01"; using var client = new HttpClient(); // Deregister via POST var body = new JsonObject { ["agent_id"] = AgentId }; var postResp = await client.PostAsync( $"{KubeMqUrl}/agents/deregister", new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json")); Console.WriteLine($"POST deregister: {(int)postResp.StatusCode}"); // Or via DELETE var delResp = await client.DeleteAsync($"{KubeMqUrl}/agents/{AgentId}"); Console.WriteLine($"DELETE: {(int)delResp.StatusCode}"); ``` ```go package main import ( "bytes" "encoding/json" "fmt" "net/http" "os" ) const ( kubemqURL = "http://localhost:9090" agentID = "echo-agent-01" ) func main() { // Deregister via POST body, _ := json.Marshal(map[string]string{"agent_id": agentID}) resp, err := http.Post(kubemqURL+"/agents/deregister", "application/json", bytes.NewReader(body)) if err != nil { fmt.Fprintf(os.Stderr, "Deregister POST failed: %v\n", err) os.Exit(1) } resp.Body.Close() fmt.Printf("POST /agents/deregister: %d\n", resp.StatusCode) // Or via DELETE req, _ := http.NewRequest(http.MethodDelete, kubemqURL+"/agents/"+agentID, nil) resp, err = http.DefaultClient.Do(req) if err != nil { fmt.Fprintf(os.Stderr, "DELETE failed: %v\n", err) os.Exit(1) } resp.Body.Close() fmt.Printf("DELETE /agents/%s: %d\n", agentID, resp.StatusCode) } ``` ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; public class Client { static final String KUBEMQ_URL = "http://localhost:9090"; static final String AGENT_ID = "echo-agent-01"; public static void main(String[] args) throws Exception { var client = HttpClient.newHttpClient(); // Deregister via POST var postReq = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/agents/deregister")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString( "{\"agent_id\": \"" + AGENT_ID + "\"}")) .build(); var postResp = client.send(postReq, HttpResponse.BodyHandlers.ofString()); System.out.println("POST deregister: " + postResp.statusCode()); // Or via DELETE var delReq = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/agents/" + AGENT_ID)) .DELETE() .build(); var delResp = client.send(delReq, HttpResponse.BodyHandlers.ofString()); System.out.println("DELETE: " + delResp.statusCode()); } } ``` ```python import asyncio import httpx KUBEMQ_URL = "http://localhost:9090" AGENT_ID = "echo-agent-01" async def main() -> None: async with httpx.AsyncClient() as client: # Deregister via POST resp = await client.post( f"{KUBEMQ_URL}/agents/deregister", json={"agent_id": AGENT_ID}, ) print(f"POST /agents/deregister: {resp.status_code}") # Or via DELETE resp = await client.delete(f"{KUBEMQ_URL}/agents/{AGENT_ID}") print(f"DELETE /agents/{AGENT_ID}: {resp.status_code}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript const KUBEMQ_URL = "http://localhost:9090"; const AGENT_ID = "echo-agent-01"; async function main() { // Deregister via POST const postResp = await fetch(`${KUBEMQ_URL}/agents/deregister`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ agent_id: AGENT_ID }), }); console.log(`POST deregister status: ${postResp.status}`); // Or via DELETE const delResp = await fetch(`${KUBEMQ_URL}/agents/${AGENT_ID}`, { method: "DELETE", }); console.log(`DELETE status: ${delResp.status}`); } main().catch(console.error); ``` ## TTL and liveness [#ttl-and-liveness] The registry expires agents that go silent. A background **liveness checker** runs every 60 seconds and deletes any agent whose `last_seen` is older than `AgentTTLSeconds` (default `300` — five minutes). Expiring an agent also removes its virtual subscriber and emits a `deregister` replication event. To stay registered, an agent must [heartbeat](#heartbeat) (or re-register) within the TTL window. A safe interval is well under `AgentTTLSeconds` — for the default 300s TTL, a heartbeat every 60–120 seconds gives plenty of margin. Tune the window with `AgentTTLSeconds`. See [Configuration](/aiway/a2a/configuration) for the full `A2aConfig` field set and the disable env var. ## Ownership [#ownership] When authentication is enabled, the registry records the JWT principal that registered each agent in `registered_by`. Heartbeat and deregister then enforce an **ownership check**: only the registering principal may refresh or remove the agent. Cross-principal re-registration is rejected with `403` (ownership conflict). The check **fails closed**: if auth is enabled and an agent's `registered_by` is blank, no principal can modify or delete it. See [Authentication](/aiway/a2a/guides/authentication) for the auth model and `X-KubeMQ-Caller-ID` propagation. ## MaxAgents limit [#maxagents-limit] `MaxAgents` caps the total number of registered agents. The default is `0`, meaning **unlimited**. When a positive limit is set and reached, new registrations are rejected with `409` (conflict) — re-registering an existing agent still succeeds, since it does not grow the count. ## Response and status codes [#response-and-status-codes] | Status | Operation | Meaning | | ------ | --------------------------------- | ----------------------------------------------- | | `200` | register / heartbeat / get / list | Success | | `400` | register / heartbeat | Validation error (bad card, missing `agent_id`) | | `403` | register / heartbeat / deregister | Ownership conflict (auth enabled) | | `404` | get / deregister | Agent not found | | `409` | register | `MaxAgents` limit reached | ## Related [#related] # Streaming (SSE) (/aiway/a2a/streaming) When an agent produces results incrementally — progress updates, partial artifacts, a final answer — A2A streams them back to the caller as **Server-Sent Events (SSE)**. The caller sends a JSON-RPC `message/stream` request and reads a sequence of typed event frames until a terminal `task.done` or `task.error`. ## Overview [#overview] Synchronous [`message/send`](/aiway/a2a/sync-messaging) returns a single reply. **`message/stream`** instead opens a long-lived SSE connection and relays each event the agent emits as it happens, so callers can show progress or consume artifacts before the task finishes. There are two ways to start a stream against the same agent: | Trigger | Request | Pre-stream error format | | -------------------------------------------------------- | ------------- | --------------------------------------------------------- | | `POST /a2a/{agent_id}` with `"method": "message/stream"` | JSON-RPC body | JSON-RPC 2.0 error at HTTP 200 | | `GET /a2a/{agent_id}/stream` | query/headers | HTTP status code + `{"is_error": true, "message": "..."}` | The POST form is the common case and what the examples below use. In both cases the response is `Content-Type: text/event-stream` and the wire protocol is identical. ## How it works [#how-it-works] The gateway does not hold a socket open to your agent on the caller's behalf. Instead it uses the agent's [virtual subscriber](/aiway/a2a/architecture) as an **SSE relay**: a temporary internal channel carries the agent's events to the gateway, which forwards them to the caller. This subscribe-first ordering guarantees no event is lost between the query and the agent's first emission. *The virtual subscriber relays the agent's SSE events over a temporary internal channel; the gateway forwards them to the caller until a terminal envelope closes the stream.* ## Event types [#event-types] Each SSE frame has an `event:` name and a JSON `data:` payload. The agent's envelope `type` maps to the SSE event name the caller sees: | Envelope `type` | SSE event name | Meaning | | --------------- | --------------- | ------------------------------------------ | | `status_update` | `task.status` | Progress or status update (non-terminal) | | `artifact` | `task.artifact` | A partial or complete result artifact | | `done` | `task.done` | Terminal — the task completed successfully | | `error` | `task.error` | Terminal — the task failed | | (other) | `message` | Default event name for untyped envelopes | Each `data:` line is a stream envelope: ```json { "stream_id": "f3c1...", "type": "status_update", "payload": { "status": "working", "progress": 3, "total": 5 } } ``` A caller reads frames until it sees `task.done` or `task.error`, then stops — both are terminal and the gateway closes the connection after sending them. ## Stream a task [#stream-a-task] Send a `message/stream` request with `Accept: text/event-stream` and read the event frames as they arrive. Stop on the terminal `task.done` / `task.error`. ```bash curl -N -X POST http://localhost:9090/a2a/stream-agent-01 \ -H "Content-Type: application/json" \ -H "Accept: text/event-stream" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "message/stream", "params": { "message": {"parts": [{"text": "Stream me some updates"}]} } }' ``` ```csharp using System.Text; using System.Text.Json.Nodes; const string KubeMqUrl = "http://localhost:9090"; const string AgentId = "stream-agent-01"; var payload = new JsonObject { ["jsonrpc"] = "2.0", ["id"] = 1, ["method"] = "message/stream", ["params"] = new JsonObject { ["message"] = new JsonObject { ["parts"] = new JsonArray(new JsonObject { ["text"] = "Stream me some updates" }) } } }; using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(60) }; var request = new HttpRequestMessage(HttpMethod.Post, $"{KubeMqUrl}/a2a/{AgentId}") { Content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json") }; Console.WriteLine("Connecting to SSE stream..."); using var resp = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); using var stream = await resp.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream); string? eventType = null; int eventCount = 0; while (!reader.EndOfStream) { var line = await reader.ReadLineAsync(); if (line == null) break; if (line.StartsWith("event: ")) eventType = line[7..]; else if (line.StartsWith("data: ") && eventType != null) { eventCount++; var data = line[6..]; Console.WriteLine($"[{eventType}] {data}"); if (eventType is "task.done" or "task.error") break; } else if (line.Length == 0) eventType = null; } Console.WriteLine($"\nReceived {eventCount} events"); Console.WriteLine("Stream completed!"); ``` ```go package main import ( "bufio" "bytes" "encoding/json" "fmt" "net/http" "os" "strings" ) const ( kubemqURL = "http://localhost:9090" agentID = "stream-agent-01" ) func main() { payload := map[string]interface{}{ "jsonrpc": "2.0", "id": 1, "method": "message/stream", "params": map[string]interface{}{ "message": map[string]interface{}{ "parts": []map[string]interface{}{{"text": "Stream me some updates"}}, }, }, } data, err := json.Marshal(payload) if err != nil { fmt.Fprintf(os.Stderr, "Marshal failed: %v\n", err) os.Exit(1) } req, err := http.NewRequest(http.MethodPost, kubemqURL+"/a2a/"+agentID, bytes.NewReader(data)) if err != nil { fmt.Fprintf(os.Stderr, "Request build failed: %v\n", err) os.Exit(1) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "text/event-stream") resp, err := http.DefaultClient.Do(req) if err != nil { fmt.Fprintf(os.Stderr, "Request failed: %v\n", err) os.Exit(1) } defer resp.Body.Close() fmt.Println("Connecting to SSE stream...") eventCount := 0 eventType := "" scanner := bufio.NewScanner(resp.Body) for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, "event: ") { eventType = strings.TrimPrefix(line, "event: ") } else if strings.HasPrefix(line, "data: ") { eventCount++ dataStr := strings.TrimPrefix(line, "data: ") fmt.Printf("[%s] %s\n", eventType, dataStr) if eventType == "task.done" || eventType == "task.error" { break } } } fmt.Printf("\nReceived %d events\n", eventCount) fmt.Println("Stream completed!") } ``` ```java import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.util.List; import java.util.Map; public class Client { static final String KUBEMQ_URL = "http://localhost:9090"; static final String AGENT_ID = "stream-agent-01"; static final ObjectMapper MAPPER = new ObjectMapper(); public static void main(String[] args) throws Exception { var payload = Map.of( "jsonrpc", "2.0", "id", 1, "method", "message/stream", "params", Map.of( "message", Map.of("parts", List.of(Map.of("text", "Stream me some updates"))) ) ); var client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(60)) .build(); var req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID)) .header("Content-Type", "application/json") .header("Accept", "text/event-stream") .timeout(Duration.ofSeconds(60)) .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload))) .build(); System.out.println("Connecting to SSE stream..."); var resp = client.send(req, HttpResponse.BodyHandlers.ofLines()); int eventCount = 0; String currentEvent = null; for (var it = resp.body().iterator(); it.hasNext(); ) { String line = it.next(); if (line.startsWith("event: ")) { currentEvent = line.substring(7).trim(); } else if (line.startsWith("data: ")) { eventCount++; String data = line.substring(6); System.out.println("[" + currentEvent + "] " + data); if ("task.done".equals(currentEvent) || "task.error".equals(currentEvent)) { break; } } } System.out.println("\nReceived " + eventCount + " events"); System.out.println("Stream completed!"); } } ``` ```python import asyncio import json import httpx from httpx_sse import aconnect_sse KUBEMQ_URL = "http://localhost:9090" AGENT_ID = "stream-agent-01" async def main() -> None: payload = { "jsonrpc": "2.0", "id": 1, "method": "message/stream", "params": { "message": {"parts": [{"text": "Stream me some updates"}]}, }, } async with httpx.AsyncClient(timeout=60) as client: print("Connecting to SSE stream...") async with aconnect_sse( client, "POST", f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload, headers={"Accept": "text/event-stream"}, ) as event_source: event_count = 0 async for event in event_source.aiter_sse(): event_count += 1 data = json.loads(event.data) print(f"[{event.event}] {json.dumps(data)}") if event.event in ("task.done", "task.error"): break print(f"\nReceived {event_count} events") print("Stream completed!") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript const KUBEMQ_URL = "http://localhost:9090"; const AGENT_ID = "stream-agent-01"; async function main() { const request = { jsonrpc: "2.0", id: 1, method: "message/stream", params: { message: { parts: [{ text: "Stream me some updates" }] } }, }; console.log("=== POST-based streaming ==="); const resp = await fetch(`${KUBEMQ_URL}/a2a/${AGENT_ID}`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "text/event-stream", }, body: JSON.stringify(request), }); const reader = resp.body!.getReader(); const decoder = new TextDecoder(); let eventCount = 0; let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const frames = buffer.split("\n\n"); buffer = frames.pop() ?? ""; for (const frame of frames) { if (!frame.trim()) continue; let eventType = ""; let eventData = ""; for (const line of frame.split("\n")) { if (line.startsWith("event: ")) eventType = line.slice(7).trim(); else if (line.startsWith("data: ")) eventData = line.slice(6).trim(); } if (!eventType) continue; eventCount++; const payload = JSON.parse(eventData); console.log(`[${eventType}] ${JSON.stringify(payload)}`); if (eventType === "task.done" || eventType === "task.error") { console.log(`\nStream complete. Total events: ${eventCount}`); reader.cancel(); return; } } } console.log(`\nStream ended. Total events: ${eventCount}`); } main().catch(console.error); ``` A typical stream is a sequence of `task.status` frames followed by a terminal `task.done`: ```text Connecting to SSE stream... [task.status] {"type": "status_update", "payload": {"status": "working", "progress": 1, "total": 5}} [task.status] {"type": "status_update", "payload": {"status": "working", "progress": 2, "total": 5}} [task.status] {"type": "status_update", "payload": {"status": "working", "progress": 5, "total": 5}} [task.done] {"type": "done", "payload": {"final_result": "completed", "event_count": 5}} ``` ## Keepalive and idle timeout [#keepalive-and-idle-timeout] To keep the connection alive through proxies during quiet periods, the gateway emits an SSE comment line — `: keepalive` — every **30 seconds**. It is a comment, not an event, so SSE clients ignore it; it exists only to keep the socket warm. If the agent produces no events for `MaxSSEIdleSeconds` (default **300 seconds**), the gateway closes the stream: it sends a terminal `task.error` with code `-32001` (`"stream idle timeout"`) and issues a best-effort cancel to the agent. Tune the window with `MaxSSEIdleSeconds` — see [Configuration](/aiway/a2a/configuration). ## Client disconnect [#client-disconnect] If the caller closes the connection before a terminal event, the gateway detects the disconnect and **cancels the work on the agent** rather than letting it run to completion. It sends a `stream_cancel` query to the agent's virtual subscriber on `_AGENTS_.agents/{agent_id}` (carrying `a2a_stream_id`, with a 10-second timeout), which closes the relay's HTTP connection to the agent. This frees the [concurrency slot](/aiway/a2a/guides/concurrency) promptly instead of waiting for the task to finish. The SSE stream endpoint omits the per-route timeout middleware — long-lived streams are bounded by `MaxSSEIdleSeconds`, not the 60-second request timeout that applies to `message/send`. ## Response size limit [#response-size-limit] During relay, the virtual subscriber tracks the accumulated `data:` bytes of each SSE event. If a single event exceeds `AgentMaxResponseBytes` (default **10 MB**), the relay is aborted to protect the gateway from memory exhaustion. Keep individual artifact events under this bound; for large results, chunk them across multiple `task.artifact` events. ## Related [#related] # Synchronous Messaging (/aiway/a2a/sync-messaging) Synchronous A2A messaging is a request/reply call: a caller POSTs a JSON-RPC 2.0 request to `POST /a2a/`, the gateway routes it to the target agent through its virtual subscriber, and the agent's reply comes back on the same HTTP response — no SSE, no polling. ## Overview [#overview] `message/send` is the workhorse of the A2A connector. You address an agent by ID in the URL, put a standard JSON-RPC 2.0 envelope in the body, and read the reply synchronously. The gateway is **method-agnostic**: it forwards whatever `method` you send — `message/send`, `tasks/get`, `tasks/cancel`, or your own `custom/action` — and returns the agent's response as-is. Use synchronous messaging whenever you want a single answer back in one round-trip; switch to [streaming](/aiway/a2a/streaming) when the agent produces incremental output over time. ## How it works [#how-it-works] A synchronous call travels from the caller through the gateway and the agent's virtual subscriber to the plain HTTP agent, then the reply retraces the path. *The gateway proxies one request to one agent and relays the reply on the same HTTP response.* The gateway validates the agent ID and `Content-Type`, reads the timeout from `params.configuration.timeout` (falling back to `DefaultTimeoutSeconds`, capped at `MaxTimeoutSeconds`), packs the caller's `X-*` headers, and issues a Query on `_AGENTS_.agents/`. The agent's virtual subscriber unpacks the request, POSTs it to the agent's registered URL, and relays the response. ## Basic message/send [#basic-messagesend] Send a text message and read the synchronous reply. The agent receives the full JSON-RPC envelope and returns its result. ```bash curl -X POST http://localhost:9090/a2a/echo-agent-01 \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": { "message": { "parts": [{"text": "Hello, agent!"}] } } }' ``` ```csharp const string KubeMqUrl = "http://localhost:9090"; const string AgentId = "echo-agent-01"; var payload = new JsonObject { ["jsonrpc"] = "2.0", ["id"] = 1, ["method"] = "message/send", ["params"] = new JsonObject { ["message"] = new JsonObject { ["parts"] = new JsonArray(new JsonObject { ["text"] = "Hello, agent!" }) } } }; using var client = new HttpClient(); var resp = await client.PostAsync( $"{KubeMqUrl}/a2a/{AgentId}", new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json")); Console.WriteLine($"Status: {(int)resp.StatusCode}"); var body = await resp.Content.ReadAsStringAsync(); var data = JsonNode.Parse(body)!; Console.WriteLine(JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true })); if (data["result"] != null) Console.WriteLine("\nBasic send completed successfully!"); ``` ```go const ( kubemqURL = "http://localhost:9090" agentID = "echo-agent-01" ) payload := map[string]interface{}{ "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": map[string]interface{}{ "message": map[string]interface{}{ "parts": []map[string]interface{}{{"text": "Hello, agent!"}}, }, }, } data, _ := json.Marshal(payload) resp, err := http.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(data)) if err != nil { fmt.Fprintf(os.Stderr, "Request failed: %v\n", err) os.Exit(1) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) fmt.Printf("Status: %d\n", resp.StatusCode) fmt.Println(string(body)) ``` ```java static final String KUBEMQ_URL = "http://localhost:9090"; static final String AGENT_ID = "echo-agent-01"; static final ObjectMapper MAPPER = new ObjectMapper(); var payload = Map.of( "jsonrpc", "2.0", "id", 1, "method", "message/send", "params", Map.of( "message", Map.of( "parts", List.of(Map.of("text", "Hello, agent!")) ) ) ); var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID)) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload))) .build(); var resp = client.send(req, HttpResponse.BodyHandlers.ofString()); System.out.println("Status: " + resp.statusCode()); var data = MAPPER.readTree(resp.body()); System.out.println(MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(data)); ``` ```python import httpx KUBEMQ_URL = "http://localhost:9090" AGENT_ID = "echo-agent-01" payload = { "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": { "message": { "parts": [{"text": "Hello, agent!"}], }, }, } async with httpx.AsyncClient() as client: resp = await client.post(f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload) print(f"Status: {resp.status_code}") data = resp.json() print(json.dumps(data, indent=2)) assert "result" in data ``` ```typescript const KUBEMQ_URL = "http://localhost:9090"; const AGENT_ID = "echo-agent-01"; const request = { jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { parts: [{ text: "Hello, agent!" }], }, }, }; const resp = await fetch(`${KUBEMQ_URL}/a2a/${AGENT_ID}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), }); const data = await resp.json(); console.log("Response:", JSON.stringify(data, null, 2)); ``` ## Context IDs [#context-ids] Set `params.contextId` to correlate a sequence of messages with the same agent — for conversation threading or session management. The gateway forwards it to the agent, which echoes it back so callers can confirm the correlation. ```bash curl -X POST http://localhost:9090/a2a/context-agent-01 \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": { "message": {"parts": [{"text": "Track this request"}]}, "contextId": "ctx-001" } }' ``` ```csharp var payload = new JsonObject { ["jsonrpc"] = "2.0", ["id"] = 1, ["method"] = "message/send", ["params"] = new JsonObject { ["message"] = new JsonObject { ["parts"] = new JsonArray(new JsonObject { ["text"] = "Track this request" }) }, ["contextId"] = "ctx-001" } }; using var client = new HttpClient(); var resp = await client.PostAsync( $"{KubeMqUrl}/a2a/context-agent-01", new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json")); var body = await resp.Content.ReadAsStringAsync(); var data = JsonNode.Parse(body)!; var returnedCtx = data["result"]?["contextId"]?.GetValue(); Console.WriteLine($"Received contextId: {returnedCtx}"); ``` ```go payload := map[string]interface{}{ "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": map[string]interface{}{ "message": map[string]interface{}{"parts": []map[string]interface{}{{"text": "Track this request"}}}, "contextId": "ctx-001", }, } data, _ := json.Marshal(payload) resp, _ := http.Post("http://localhost:9090/a2a/context-agent-01", "application/json", bytes.NewReader(data)) defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var result map[string]interface{} json.Unmarshal(body, &result) if r, ok := result["result"].(map[string]interface{}); ok { fmt.Printf("Received contextId: %v\n", r["contextId"]) } ``` ```java var payload = Map.of( "jsonrpc", "2.0", "id", 1, "method", "message/send", "params", Map.of( "message", Map.of("parts", List.of(Map.of("text", "Track this request"))), "contextId", "ctx-001" ) ); var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/a2a/context-agent-01")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload))) .build(); var resp = client.send(req, HttpResponse.BodyHandlers.ofString()); var data = MAPPER.readTree(resp.body()); var returnedCtx = data.path("result").path("contextId").asText(null); System.out.println("Received contextId: " + returnedCtx); ``` ```python payload = { "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": { "message": {"parts": [{"text": "Track this request"}]}, "contextId": "ctx-001", }, } async with httpx.AsyncClient() as client: resp = await client.post("http://localhost:9090/a2a/context-agent-01", json=payload) data = resp.json() returned_ctx = data.get("result", {}).get("contextId") print(f"Received contextId: {returned_ctx}") ``` ```typescript const contextId = "ctx-001"; const request = { jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { parts: [{ text: "Track this request" }] }, contextId, }, }; const resp = await fetch(`${KUBEMQ_URL}/a2a/context-agent-01`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), }); const data = await resp.json(); const echoedCtx = data.result?.contextId; console.log(`Echoed contextId: ${echoedCtx}`); ``` ## Custom methods [#custom-methods] The gateway forwards any JSON-RPC `method` name to the agent unchanged — standard A2A methods like `tasks/get` and `tasks/cancel`, or your own `custom/action`. KubeMQ does not interpret the method; the agent decides how to handle it. ```bash curl -X POST http://localhost:9090/a2a/custom-method-agent-01 \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "custom/action", "params": {"data": "custom-payload"} }' ``` ```csharp var payload = new JsonObject { ["jsonrpc"] = "2.0", ["id"] = 1, ["method"] = "custom/action", ["params"] = new JsonObject { ["data"] = "custom-payload" } }; using var client = new HttpClient(); var resp = await client.PostAsync( $"{KubeMqUrl}/a2a/custom-method-agent-01", new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json")); var body = await resp.Content.ReadAsStringAsync(); var data = JsonNode.Parse(body)!; var handledMethod = data["result"]?["handled_method"]?.GetValue(); Console.WriteLine($"Handled method: {handledMethod}"); ``` ```go payload := map[string]interface{}{ "jsonrpc": "2.0", "id": 1, "method": "custom/action", "params": map[string]interface{}{"data": "custom-payload"}, } data, _ := json.Marshal(payload) resp, _ := http.Post("http://localhost:9090/a2a/custom-method-agent-01", "application/json", bytes.NewReader(data)) defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var result map[string]interface{} json.Unmarshal(body, &result) r, _ := result["result"].(map[string]interface{}) fmt.Printf("Handled method: %v\n", r["handled_method"]) ``` ```java var payload = Map.of( "jsonrpc", "2.0", "id", 1, "method", "custom/action", "params", Map.of("data", "custom-payload") ); var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/a2a/custom-method-agent-01")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload))) .build(); var resp = client.send(req, HttpResponse.BodyHandlers.ofString()); var data = MAPPER.readTree(resp.body()); System.out.println("Handled method: " + data.path("result").path("handled_method").asText()); ``` ```python payload = { "jsonrpc": "2.0", "id": 1, "method": "custom/action", "params": {"data": "custom-payload"}, } async with httpx.AsyncClient() as client: resp = await client.post("http://localhost:9090/a2a/custom-method-agent-01", json=payload) data = resp.json() result = data.get("result", {}) print(f"Handled method: {result.get('handled_method')}") ``` ```typescript async function sendRpc(method: string, params: unknown) { const request = { jsonrpc: "2.0", id: Date.now(), method, params }; const resp = await fetch(`${KUBEMQ_URL}/a2a/custom-method-agent-01`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), }); return resp.json(); } const custom = await sendRpc("custom/action", { data: "custom-payload" }); console.log("Response:", JSON.stringify(custom, null, 2)); ``` ## Header forwarding [#header-forwarding] Caller request headers prefixed with `X-` are forwarded to the agent; hop-by-hop and sensitive headers (`Authorization`, `Cookie`, `X-Forwarded-For`, and others) are stripped. The gateway always injects `X-KubeMQ-Caller-ID` so the agent knows which client originated the call. ```bash curl -X POST http://localhost:9090/a2a/header-agent-01 \ -H "Content-Type: application/json" \ -H "X-Custom-Header: my-custom-value" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": { "message": {"parts": [{"text": "Check my headers"}]} } }' ``` ```csharp var payload = new JsonObject { ["jsonrpc"] = "2.0", ["id"] = 1, ["method"] = "message/send", ["params"] = new JsonObject { ["message"] = new JsonObject { ["parts"] = new JsonArray(new JsonObject { ["text"] = "Check my headers" }) } } }; using var client = new HttpClient(); var request = new HttpRequestMessage(HttpMethod.Post, $"{KubeMqUrl}/a2a/header-agent-01") { Content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json") }; request.Headers.Add("X-Custom-Header", "my-custom-value"); var resp = await client.SendAsync(request); var body = await resp.Content.ReadAsStringAsync(); var data = JsonNode.Parse(body)!; var received = data["result"]?["received_headers"]; Console.WriteLine($"Forwarded headers: {received?.ToJsonString()}"); ``` ```go payload := map[string]interface{}{ "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": map[string]interface{}{ "message": map[string]interface{}{ "parts": []map[string]interface{}{{"text": "Check my headers"}}, }, }, } data, _ := json.Marshal(payload) req, _ := http.NewRequest("POST", "http://localhost:9090/a2a/header-agent-01", bytes.NewReader(data)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Custom-Header", "my-custom-value") resp, _ := http.DefaultClient.Do(req) defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var result map[string]interface{} json.Unmarshal(body, &result) received, _ := result["result"].(map[string]interface{})["received_headers"].(map[string]interface{}) fmt.Printf("Forwarded headers: %v\n", received) ``` ```java var payload = Map.of( "jsonrpc", "2.0", "id", 1, "method", "message/send", "params", Map.of( "message", Map.of("parts", List.of(Map.of("text", "Check my headers"))) ) ); var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/a2a/header-agent-01")) .header("Content-Type", "application/json") .header("X-Custom-Header", "my-custom-value") .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload))) .build(); var resp = client.send(req, HttpResponse.BodyHandlers.ofString()); var data = MAPPER.readTree(resp.body()); System.out.println("Forwarded headers: " + data.path("result").path("received_headers")); ``` ```python payload = { "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": { "message": {"parts": [{"text": "Check my headers"}]}, }, } async with httpx.AsyncClient() as client: resp = await client.post( "http://localhost:9090/a2a/header-agent-01", json=payload, headers={"X-Custom-Header": "my-custom-value"}, ) data = resp.json() received = data.get("result", {}).get("received_headers", {}) print(f"Forwarded headers: {received}") ``` ```typescript const request = { jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { parts: [{ text: "Check my headers" }] }, }, }; const resp = await fetch(`${KUBEMQ_URL}/a2a/header-agent-01`, { method: "POST", headers: { "Content-Type": "application/json", "X-Custom-Header": "test-value-123", }, body: JSON.stringify(request), }); const data = await resp.json(); const headers = data.result?.received_headers || {}; console.log(`X-Custom-Header: ${headers["x-custom-header"] || "(not forwarded)"}`); console.log(`X-KubeMQ-Caller-ID: ${headers["x-kubemq-caller-id"] || "(not present)"}`); ``` ## Concurrent requests [#concurrent-requests] Multiple simultaneous calls to the same agent are processed in parallel up to the per-agent concurrency cap (`AgentMaxConcurrency`, default 100). Requests beyond the cap are rejected immediately with a "server busy" error instead of being queued — see [Concurrency & limits](/aiway/a2a/guides/concurrency). ```bash # Fire 20 requests in parallel against the same agent for i in $(seq 1 20); do curl -s -X POST http://localhost:9090/a2a/concurrent-agent-01 \ -H "Content-Type: application/json" \ -d "{\"jsonrpc\":\"2.0\",\"id\":$i,\"method\":\"message/send\",\"params\":{\"message\":{\"parts\":[{\"text\":\"Request #$i\"}]}}}" & done wait ``` ```csharp const int NumRequests = 20; async Task SendRequest(HttpClient httpClient, int requestId) { var payload = new JsonObject { ["jsonrpc"] = "2.0", ["id"] = requestId, ["method"] = "message/send", ["params"] = new JsonObject { ["message"] = new JsonObject { ["parts"] = new JsonArray(new JsonObject { ["text"] = $"Request #{requestId}" }) } } }; var resp = await httpClient.PostAsync( $"{KubeMqUrl}/a2a/concurrent-agent-01", new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json")); var body = await resp.Content.ReadAsStringAsync(); return JsonNode.Parse(body); } using var client = new HttpClient(); var tasks = Enumerable.Range(1, NumRequests).Select(i => SendRequest(client, i)).ToArray(); var results = await Task.WhenAll(tasks); var successes = results.Count(r => r?["result"] != null); Console.WriteLine($"Successes: {successes} / {NumRequests}"); ``` ```go const numRequests = 20 results := make(chan map[string]interface{}, numRequests) var wg sync.WaitGroup for i := 1; i <= numRequests; i++ { wg.Add(1) go func(id int) { defer wg.Done() payload := map[string]interface{}{ "jsonrpc": "2.0", "id": id, "method": "message/send", "params": map[string]interface{}{ "message": map[string]interface{}{ "parts": []map[string]interface{}{{"text": fmt.Sprintf("Request #%d", id)}}, }, }, } data, _ := json.Marshal(payload) resp, err := http.Post("http://localhost:9090/a2a/concurrent-agent-01", "application/json", bytes.NewReader(data)) if err != nil { results <- nil return } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var r map[string]interface{} json.Unmarshal(body, &r) results <- r }(i) } wg.Wait() close(results) successes := 0 for r := range results { if r != nil { if _, ok := r["result"]; ok { successes++ } } } fmt.Printf("Successes: %d / %d\n", successes, numRequests) ``` ```java static final int NUM_REQUESTS = 20; var client = HttpClient.newHttpClient(); List>> futures = new ArrayList<>(); for (int i = 1; i <= NUM_REQUESTS; i++) { var payload = Map.of( "jsonrpc", "2.0", "id", i, "method", "message/send", "params", Map.of( "message", Map.of("parts", List.of(Map.of("text", "Request #" + i))) ) ); var req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/a2a/concurrent-agent-01")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload))) .build(); futures.add(client.sendAsync(req, HttpResponse.BodyHandlers.ofString())); } CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); int successes = 0; for (var future : futures) { var data = MAPPER.readTree(future.get().body()); if (data.has("result")) successes++; } System.out.println("Successes: " + successes + " / " + NUM_REQUESTS); ``` ```python NUM_REQUESTS = 20 async def send_request(client: httpx.AsyncClient, request_id: int) -> dict: payload = { "jsonrpc": "2.0", "id": request_id, "method": "message/send", "params": { "message": {"parts": [{"text": f"Request #{request_id}"}]}, }, } resp = await client.post("http://localhost:9090/a2a/concurrent-agent-01", json=payload) return resp.json() async with httpx.AsyncClient() as client: tasks = [send_request(client, i) for i in range(1, NUM_REQUESTS + 1)] results = await asyncio.gather(*tasks, return_exceptions=True) successes = sum(1 for r in results if isinstance(r, dict) and "result" in r) print(f"Successes: {successes} / {NUM_REQUESTS}") ``` ```typescript const NUM_REQUESTS = 20; async function sendRequest(id: number): Promise { const request = { jsonrpc: "2.0", id, method: "message/send", params: { message: { parts: [{ text: `Request ${id}` }] } }, }; try { const resp = await fetch(`${KUBEMQ_URL}/a2a/concurrent-agent-01`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), }); const data = await resp.json(); return !!data.result; } catch { return false; } } const promises = Array.from({ length: NUM_REQUESTS }, (_, i) => sendRequest(i + 1)); const results = await Promise.all(promises); const succeeded = results.filter((r) => r).length; console.log(`Succeeded: ${succeeded} / ${NUM_REQUESTS}`); ``` ## Parameters [#parameters] The body is a standard JSON-RPC 2.0 request. Method-specific fields live under `params`. | Field | Type | Required | Default | Description | | ------------------------------ | ---------------- | ------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------- | | `jsonrpc` | string | yes | — | Must be `"2.0"`. | | `id` | string \| number | yes | — | Request identifier; echoed back on the response. | | `method` | string | yes | — | Any JSON-RPC method name, e.g. `message/send`, `tasks/get`, or a custom one. Empty method is rejected with `-32600`. | | `params.message.parts` | array | for `message/send` | — | Message content parts, each with a `text` (or other typed) field. | | `params.contextId` | string | no | — | Correlation ID forwarded to the agent for conversation threading. | | `params.configuration.timeout` | number | no | `DefaultTimeoutSeconds` (300) | Per-request timeout in seconds, capped at `MaxTimeoutSeconds` (3600). | The URL path segment `` selects the target agent and is validated against the registry before routing. ## Response [#response] On success the gateway returns the agent's JSON-RPC `result` verbatim (the echo agent mirrors the request under `result.echo`): ```json { "jsonrpc": "2.0", "id": 1, "result": { "echo": { "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": { "message": { "parts": [{ "text": "Hello, agent!" }] } } } } } ``` A response carrying an `error` object signals a problem. KubeMQ distinguishes two failure classes: * **Transport errors** — the agent never processed the request (unreachable, timeout, `502`/`503`/`504`, or an oversized response). Internally these surface as `Executed: false`; safe to retry. * **Application errors** — the agent processed the request and returned a JSON-RPC error in its response body (`Executed: true`). Retrying without changing the request usually will not help. A timeout, for example, returns: ```json { "jsonrpc": "2.0", "id": 1, "error": { "code": -32001, "message": "agent timeout: echo-agent-01" } } ``` See [Error handling](/aiway/a2a/error-handling) for the full code list and retry guidance. ## Related [#related] # Configuration (/aiway/mcp/configuration) The MCP connector runs on KubeMQ's [shared HTTP server](/connectors/concepts/shared-http-server) and is **enabled by default**. Configuration is minimal: a tool-execution timeout and an origin-validation allow-list. This page lists every `McpConfig` field with its verified default and shows how to set each one through TOML, environment variables, and Docker. ## Configuration fields [#configuration-fields] These are the `McpConfig` fields and their defaults, taken verbatim from `kubemq-server`. All three connectors share the HTTP server's CORS, auth, traffic-gate, and TLS settings — those live in [Shared HTTP server](/connectors/concepts/shared-http-server) and [Auth & security](/connectors/reference/auth-and-security), not here. | Field | Type | Default | Description | | -------------------- | ---------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Enable` | `bool` | `true` | Whether the MCP connector is mounted. Enabled by default — set to `false` to turn it off. | | `ToolTimeoutSeconds` | `int` | `300` | Server-side timeout applied to each tool execution. A tool that exceeds this window returns a tool error (`isError: true`). Must be positive when the connector is enabled. | | `TrustedOrigins` | `[]string` | `["auto"]` | Origins permitted by the connector's `Origin`-header validation. `auto` allows localhost variants and the server bind address; `*` allows all; an explicit list permits only those origins. | `ToolTimeoutSeconds` (default `300`) is the **server-side** tool-execution budget. It is independent of any client-side HTTP timeout and of the per-call `timeout_seconds` argument on tools like `command_send`, `query_send`, and `agent_send`, which is itself capped at `300`. ## Enable / disable [#enable--disable] The MCP connector is **enabled by default**. Start `kubemq-server` and `POST /mcp` is live — no `=true` flag is required. To **disable** it, set its enable env var to `false`: The disable variable is `CONNECTORSMCP_ENABLE` — there is **no underscore** between `MCP` and `ENABLE`. The name is generated by snake-casing the dotted config key `Connectors.MCP.Enable`; because `MCP` has no lowercase letters, the transform inserts no separator and the segments join. This differs from older KubeMQ docs that described MCP as off-by-default and instructed enabling with `=true` — that framing is stale. See [Shared HTTP server → enable model](/connectors/concepts/shared-http-server#enable-model-on-by-default) for the full algorithm. ## TOML configuration [#toml-configuration] Set the fields under a `[Connectors.MCP]` table in `config.toml`: ```toml title="config.toml" [Connectors.MCP] Enable = true ToolTimeoutSeconds = 300 TrustedOrigins = ["auto"] ``` ## Environment variables [#environment-variables] Each config field binds to an environment variable derived from its dotted key. The dots are stripped, the remainder is snake-cased and upper-cased, so `Connectors.MCP.ToolTimeoutSeconds` becomes `CONNECTORSMCP_TOOL_TIMEOUT_SECONDS`. | Variable | Config field | Default | Description | | ------------------------------------ | ----------------------------------- | ------- | ---------------------------------------------------------- | | `CONNECTORSMCP_ENABLE` | `Connectors.MCP.Enable` | `true` | Enable (default) or disable (`false`) the MCP connector. | | `CONNECTORSMCP_TOOL_TIMEOUT_SECONDS` | `Connectors.MCP.ToolTimeoutSeconds` | `300` | Server-side per-tool execution timeout, in seconds. | | `CONNECTORSMCP_TRUSTED_ORIGINS` | `Connectors.MCP.TrustedOrigins` | `auto` | Comma-separated origin allow-list for `Origin` validation. | ```bash title="env.sh" export CONNECTORSMCP_ENABLE=true export CONNECTORSMCP_TOOL_TIMEOUT_SECONDS=300 export CONNECTORSMCP_TRUSTED_ORIGINS=auto ``` The `Origin` allow-list set here is an MCP-specific check layered on top of the shared HTTP server's CORS middleware. For how `auto`, `*`, and explicit origins are evaluated — and the `-32010` error returned on a rejected origin — see [Auth & security](/connectors/reference/auth-and-security). ## Docker [#docker] Pass the same variables to `docker run`. This example keeps MCP at its default-on state and raises the tool timeout: The connector is served because `Enable` defaults to `true`; to turn it off, add `-e CONNECTORSMCP_ENABLE=false` as shown in [Enable / disable](#enable--disable) above. ## Client protocol settings [#client-protocol-settings] The settings above are server-side. When a client opens a session it negotiates the protocol in the `initialize` handshake. KubeMQ expects protocol version `2025-11-25`, a `clientInfo` object, and an empty `capabilities` object — capabilities are server-driven. | Setting | Value | Description | | ---------------- | ------------------- | --------------------------------------------------------------------------------------------------------------- | | Protocol version | `2025-11-25` | MCP protocol version sent in the `initialize` request and echoed in the `MCP-Protocol-Version` response header. | | Client info | `{ name, version }` | Identifies the client to the server. | | Capabilities | `{}` | Empty object — the server advertises its own capabilities in the response. | ```json title="initialize.json" { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": { "name": "my-agent", "version": "1.0.0" } } } ``` The server replies with its own `protocolVersion`, `capabilities`, `serverInfo`, and a `sessionId` under `result._meta`. The handshake and session reuse are covered in depth in [Session management](/aiway/mcp/guides/session-management). ## Related [#related] # Getting Started with MCP (/aiway/mcp/getting-started) The MCP connector is **enabled by default** on the shared HTTP server. Start KubeMQ and `POST /mcp` is live — there is no flag to turn it on. This guide takes you from a running server to your first tool call, with a Claude Desktop config, raw JSON-RPC over `curl`, and the official MCP SDK in nine languages. ## Prerequisites [#prerequisites] * A running `kubemq-server` with its [shared HTTP server](/connectors/concepts/shared-http-server) on port `9090`. Docker is the quickest way to get one. * For Claude Desktop: a current Claude Desktop install. * For the SDK examples (optional): one of the nine supported runtimes — Python 3.10+, Node.js 18+, Java 21+, .NET 8+, Go 1.21+, Rust 1.75+, Ruby 3.1+, Kotlin 1.9+, or Swift 6.0+. The `curl` path needs no SDK or runtime — it speaks the MCP JSON-RPC protocol directly over HTTP. ## Enable / disable [#enable--disable] The MCP connector is **enabled by default** — start `kubemq-server` and `POST /mcp` is immediately available. No `=true` flag is needed. Port `9090` is the [shared HTTP server](/connectors/concepts/shared-http-server) that hosts MCP alongside the REST, A2A, and CloudEvents connectors. Port `50000` is the gRPC port used by native SDKs and by the command/query subscribers behind some MCP tools. To **disable** MCP, set its enable env var to `false`: The enable var name `CONNECTORSMCP_ENABLE` is irregular by design, and older KubeMQ docs described MCP as off-by-default — both are explained on [Shared HTTP server](/connectors/concepts/shared-http-server#enable-model-on-by-default). Set it to `false` to disable; never to `true` to enable. ## How it works [#how-it-works] Every MCP interaction is a JSON-RPC 2.0 call to `POST /mcp`. A client first negotiates a session with `initialize`, acknowledges it with `notifications/initialized`, then discovers tools with `tools/list` and invokes them with `tools/call`. The connector resolves each tool to a KubeMQ operation and returns the result. *The MCP handshake establishes a session, then each `tools/call` maps to a KubeMQ operation.* ## Connect Claude Desktop [#connect-claude-desktop] Point Claude Desktop at KubeMQ's MCP endpoint by adding a server entry to `claude_desktop_config.json` (Claude Desktop → Settings → Developer → Edit Config): ```json title="claude_desktop_config.json" { "mcpServers": { "kubemq": { "url": "http://localhost:9090/mcp" } } } ``` Restart Claude Desktop. It performs the `initialize` handshake over the Streamable HTTP transport, discovers all 15 KubeMQ tools, and makes them available in the conversation. For an authenticated server, in-depth transport options, and a generic (non-Claude) JSON-RPC client walkthrough, see [Client setup](/aiway/mcp/guides/client-setup). ## The handshake by hand [#the-handshake-by-hand] The steps below drive the protocol directly with `curl` so you can see exactly what each MCP client does for you. ### Initialize a session [#initialize-a-session] Send an `initialize` request to negotiate the protocol version (`2025-11-25`) and open a session: ```bash title="terminal" curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": { "name": "test", "version": "1.0.0" } } }' ``` The server replies with its capabilities and a session ID under `result._meta.sessionId`. The same value is also returned in the `MCP-Session-Id` response header: ```json { "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2025-11-25", "capabilities": { "tools": {} }, "serverInfo": { "name": "kubemq", "version": "..." }, "_meta": { "sessionId": "" } } } ``` Save the `sessionId` — every subsequent request carries it in the `MCP-Session-Id` header. ### Acknowledge the handshake [#acknowledge-the-handshake] Tell the server the handshake is complete with the `notifications/initialized` notification, passing the session ID: ```bash title="terminal" curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'MCP-Session-Id: ' \ -d '{ "jsonrpc": "2.0", "method": "notifications/initialized" }' ``` This is a notification, so it has no `id` field. The server returns HTTP `200 OK` with body `{"jsonrpc":"2.0","result":null,"id":null}` — there is no meaningful JSON-RPC result to parse. ### List the available tools [#list-the-available-tools] Discover what you can call with `tools/list`. KubeMQ returns 11 core tools, plus 4 agent-bridge tools when the agent registry is available (15 total): ```bash title="terminal" curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'MCP-Session-Id: ' \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }' ``` Each entry carries a `name`, `description`, and `inputSchema`. The [Tools overview](/aiway/mcp/tools) maps every tool to its KubeMQ operation. ### Call your first tool [#call-your-first-tool] Invoke `queue_send` to put a message on a queue channel, passing the session ID: ```bash title="terminal" curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'MCP-Session-Id: ' \ -d '{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "queue_send", "arguments": { "channel": "my-queue", "body": "Hello from MCP" } } }' ``` A successful call returns a `content` array with the result text and `isError: false`: ```json { "jsonrpc": "2.0", "id": 3, "result": { "content": [{ "type": "text", "text": "Message sent successfully to queue 'my-queue'" }], "isError": false } } ``` ## The same call from an SDK [#the-same-call-from-an-sdk] Each official MCP SDK performs the `initialize` handshake and tracks the session ID for you over the Streamable HTTP transport — you only write the `tools/call`. All examples read `KUBEMQ_MCP_URL` (default `http://localhost:9090`) and append `/mcp`. ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'MCP-Session-Id: ' \ -d '{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "queue_send", "arguments": { "channel": "example-queue", "body": "Hello from MCP", "metadata": "example-metadata", "tags": { "env": "dev", "source": "mcp-example" } } } }' ``` ```go package main import ( "context" "fmt" "log" "os" "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/mcp" ) func main() { url := os.Getenv("KUBEMQ_MCP_URL") if url == "" { url = "http://localhost:9090" } c, err := client.NewStreamableHttpClient(url + "/mcp") if err != nil { log.Fatal(err) } defer c.Close() ctx := context.Background() if err := c.Start(ctx); err != nil { log.Fatal(err) } result, err := c.CallTool(ctx, mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: "queue_send", Arguments: map[string]any{ "channel": "example-queue", "body": "Hello from Go MCP", "metadata": "example-metadata", "tags": map[string]any{"env": "dev", "source": "mcp-example"}, }, }, }) if err != nil { log.Fatal(err) } fmt.Println("Tool: queue_send") fmt.Printf("Result: %+v\n", result) } ``` ```python import asyncio import os from mcp.client.streamable_http import streamablehttp_client from mcp import ClientSession KUBEMQ_MCP_URL = os.environ.get("KUBEMQ_MCP_URL", "http://localhost:9090") async def main(): async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool("queue_send", { "channel": "example-queue", "body": "Hello from Python MCP", "metadata": "example-metadata", "tags": {"env": "dev", "source": "mcp-example"}, }) print(f"Tool: queue_send") print(f"IsError: {result.isError}") for content in result.content: print(f"Result: {content.text}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const KUBEMQ_MCP_URL = process.env.KUBEMQ_MCP_URL || "http://localhost:9090"; async function main() { const transport = new StreamableHTTPClientTransport( new URL(`${KUBEMQ_MCP_URL}/mcp`) ); const client = new Client({ name: "kubemq-mcp-ts-example", version: "1.0.0" }); await client.connect(transport); const result = await client.callTool({ name: "queue_send", arguments: { channel: "example-queue", body: "Hello from TypeScript MCP", metadata: "example-metadata", tags: { env: "dev", source: "mcp-example" }, }, }); console.log(JSON.stringify(result, null, 2)); await client.close(); } main().catch(console.error); ``` ```java import io.modelcontextprotocol.sdk.McpClient; import io.modelcontextprotocol.sdk.client.transport.HttpClientStreamableHttpTransport; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import java.util.Map; public class QueueSend { public static void main(String[] args) { String url = System.getenv().getOrDefault("KUBEMQ_MCP_URL", "http://localhost:9090"); var transport = HttpClientStreamableHttpTransport.builder(url).endpoint("/mcp").build(); var client = McpClient.sync(transport).build(); client.initialize(); var result = client.callTool(new CallToolRequest( "queue_send", Map.of( "channel", "example-queue", "body", "Hello from Java MCP", "metadata", "example-metadata", "tags", Map.of("env", "dev", "source", "mcp-example") ) )); System.out.println(result); client.closeGracefully(); } } ``` ```csharp using ModelContextProtocol.Client; class QueueSend { static async Task Main(string[] args) { var url = Environment.GetEnvironmentVariable("KUBEMQ_MCP_URL") ?? "http://localhost:9090"; var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{url}/mcp") }); await using var client = await McpClientFactory.CreateAsync(transport); var result = await client.CallToolAsync("queue_send", new Dictionary { ["channel"] = "example-queue", ["body"] = "Hello from C# MCP", ["metadata"] = "example-metadata", ["tags"] = new Dictionary { ["env"] = "dev", ["source"] = "mcp-example" }, }); Console.WriteLine($"Tool: queue_send"); Console.WriteLine($"Result: {result}"); } } ``` ```kotlin import io.modelcontextprotocol.kotlin.sdk.Implementation import io.modelcontextprotocol.kotlin.sdk.client.Client import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport import io.ktor.client.* import io.ktor.client.plugins.sse.* import kotlinx.coroutines.runBlocking fun main() = runBlocking { val url = System.getenv("KUBEMQ_MCP_URL") ?: "http://localhost:9090" val httpClient = HttpClient { install(SSE) } val transport = StreamableHttpClientTransport(client = httpClient, url = "$url/mcp") val client = Client(clientInfo = Implementation(name = "kubemq-mcp-kotlin-example", version = "1.0.0")) client.connect(transport) val result = client.callTool("queue_send", mapOf( "channel" to "example-queue", "body" to "Hello from Kotlin MCP", "metadata" to "example-metadata", "tags" to mapOf("env" to "dev", "source" to "mcp-example") )) println("Tool: queue_send") println("Result: $result") client.close() httpClient.close() } ``` ```ruby require "mcp" url = ENV.fetch("KUBEMQ_MCP_URL", "http://localhost:9090") client = MCP::Client.new( transport: MCP::Transport::StreamableHTTP.new("#{url}/mcp"), name: "kubemq-mcp-ruby-example", version: "1.0.0" ) client.initialize_handshake result = client.call_tool("queue_send", { "channel" => "example-queue", "body" => "Hello from Ruby MCP", "metadata" => "example-metadata", "tags" => { "env" => "dev", "source" => "mcp-example" }, }) puts "Tool: queue_send" puts "Result: #{result}" client.close ``` ```rust use rmcp::transport::streamable_http::StreamableHttpClientTransport; use rmcp::service::RunService; use serde_json::json; #[tokio::main] async fn main() -> anyhow::Result<()> { let url = std::env::var("KUBEMQ_MCP_URL") .unwrap_or_else(|_| "http://localhost:9090".to_string()); let transport = StreamableHttpClientTransport::from_uri(format!("{url}/mcp"))?; let client = ().serve(transport).await?; let result = client.call_tool("queue_send", json!({ "channel": "example-queue", "body": "Hello from Rust MCP", "metadata": "example-metadata", "tags": {"env": "dev", "source": "mcp-example"} })).await?; println!("Tool: queue_send"); println!("Result: {result:#?}"); Ok(()) } ``` ```swift import Foundation import MCP @main struct QueueSend { static func main() async throws { let url = ProcessInfo.processInfo.environment["KUBEMQ_MCP_URL"] ?? "http://localhost:9090" let transport = HTTPClientTransport(endpoint: URL(string: "\(url)/mcp")!, streaming: true) let client = Client(name: "kubemq-mcp-swift-example", version: "1.0.0") try await client.connect(transport: transport) let result = try await client.callTool("queue_send", arguments: [ "channel": "example-queue", "body": "Hello from Swift MCP", "metadata": "example-metadata", "tags": ["env": "dev", "source": "mcp-example"], ]) print("Tool: queue_send") print("Result: \(result)") } } ``` `queue_send` and the event-publishing tools work on their own, but `command_send` and `query_send` need an active subscriber on the target channel, and the `agent_*` bridge tools need agents registered with the server. Without them, those calls time out. ## What's next [#whats-next] # MCP (/aiway/mcp) The **MCP connector** exposes KubeMQ messaging operations as [Model Context Protocol](https://modelcontextprotocol.io) tools, so Claude and other AI models can publish, subscribe, and call KubeMQ — and reach registered agents — without a KubeMQ-specific client library. It speaks MCP protocol version `2025-11-25` over JSON-RPC 2.0 at a single endpoint on the [shared HTTP server](/connectors/concepts/shared-http-server). **Part of Aiway.** MCP is one of the two doors into [KubeMQ Aiway](/aiway), the AI Agents Fabric. New here? Start with the [Aiway overview](/aiway), or follow the end-to-end [Aiway tutorial](/aiway/tutorial). ## What MCP is [#what-mcp-is] The Model Context Protocol is an open standard for connecting AI models to external tools and data over a uniform JSON-RPC 2.0 interface. A model's MCP client connects to an MCP **server**, asks it which tools it offers (`tools/list`), and invokes them by name (`tools/call`). KubeMQ is one such server: every KubeMQ messaging operation is published as a named MCP tool. Because the protocol is standard, any MCP-aware model or runtime can use KubeMQ with no KubeMQ code on the caller. You point the official MCP SDK for your language — or a client like Claude Desktop — at the endpoint, and the model gains messaging, persistent events, request/reply, channel introspection, and a bridge to A2A agents as native tools. ## Why MCP with KubeMQ [#why-mcp-with-kubemq] * **No KubeMQ SDK on the caller** — clients use the official MCP SDK for their language; KubeMQ is just an MCP server they connect to. * **One endpoint** — `POST /mcp` for JSON-RPC requests (single or batch), plus `GET /mcp` for a keepalive SSE stream, both on the shared HTTP port `9090`. * **15 ready-to-use tools** — 11 core messaging tools plus 4 agent-bridge tools, covering every KubeMQ pattern. * **A bridge to agents** — the model can list, inspect, and message agents registered with the [A2A connector](/aiway/a2a) through the same tool interface. * **Enabled by default** — start kubemq-server and `/mcp` is live; there is no flag to turn it on. ## How it works [#how-it-works] An MCP client connects to the `/mcp` endpoint, completes the `initialize` handshake to obtain a session, then calls tools. The connector translates each tool call into a native KubeMQ operation; agent-bridge tools forward over the broker to the A2A registry. *The MCP connector turns JSON-RPC tool calls into KubeMQ operations and bridges to A2A agents.* The connector runs on the [shared HTTP server](/connectors/concepts/shared-http-server) and inherits its middleware, [authentication](/aiway/mcp/guides/authentication), and [observability](/connectors/concepts/observability). The reserved `_AGENTS_.` channel prefix is rejected for direct messaging tools — use the agent-bridge tools to reach agents. ## The 15 tools [#the-15-tools] KubeMQ exposes 15 MCP tools across five categories — 11 core messaging tools that are always available, plus 4 agent-bridge tools that appear when the agent registry is present. | Category | Tools | Count | | ------------------ | ----------------------------------------------------------------------------------------- | -----: | | Queue | `queue_send`, `queue_receive`, `queue_peek` | 3 | | Events | `events_publish`, `events_store_publish`, `events_store_read`, `events_store_read_latest` | 4 | | Command / Query | `command_send`, `query_send` | 2 | | Channel management | `channel_list`, `channel_info` | 2 | | Agent bridge | `agent_list`, `agent_info`, `agent_send`, `agent_query` | 4 | | **Total** | | **15** | ## Endpoint surface [#endpoint-surface] | Method | Path | Description | | ------ | ------ | ---------------------------------------------- | | `POST` | `/mcp` | JSON-RPC 2.0 request handler (single or batch) | | `GET` | `/mcp` | SSE keepalive stream | JSON-RPC methods on `POST /mcp`: `initialize`, `notifications/initialized`, `ping`, `tools/list`, and `tools/call`. The `MCP-Protocol-Version` response header is always `2025-11-25`, and `MCP-Session-Id` carries the session returned by `initialize`. See the [endpoints reference](/aiway/mcp/reference/endpoints) for full signatures. ## Discover the tools [#discover-the-tools] The `tools/list` method returns every available tool with its `name`, `description`, and `inputSchema`. It is the first call after `initialize` — it tells the model what KubeMQ can do. ```bash curl -X POST http://localhost:9090/mcp \ -H "Content-Type: application/json" \ -H "MCP-Protocol-Version: 2025-11-25" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }' ``` ```csharp using ModelContextProtocol.Client; var url = Environment.GetEnvironmentVariable("KUBEMQ_MCP_URL") ?? "http://localhost:9090"; var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{url}/mcp") }); await using var client = await McpClientFactory.CreateAsync(transport); var tools = await client.ListToolsAsync(); foreach (var tool in tools) { Console.WriteLine($"{tool.Name}: {tool.Description}"); } ``` ```go package main import ( "context" "fmt" "log" "os" "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/mcp" ) func main() { url := os.Getenv("KUBEMQ_MCP_URL") if url == "" { url = "http://localhost:9090" } c, err := client.NewStreamableHttpClient(url + "/mcp") if err != nil { log.Fatal(err) } defer c.Close() ctx := context.Background() if err := c.Start(ctx); err != nil { log.Fatal(err) } result, err := c.ListTools(ctx, mcp.ListToolsRequest{}) if err != nil { log.Fatal(err) } for _, tool := range result.Tools { fmt.Printf("%s: %s\n", tool.Name, tool.Description) } } ``` ```java import io.modelcontextprotocol.sdk.McpClient; import io.modelcontextprotocol.sdk.client.transport.HttpClientStreamableHttpTransport; public class ToolsList { public static void main(String[] args) { String url = System.getenv().getOrDefault("KUBEMQ_MCP_URL", "http://localhost:9090"); var transport = HttpClientStreamableHttpTransport.builder(url).endpoint("/mcp").build(); var client = McpClient.sync(transport).build(); client.initialize(); var tools = client.listTools(); System.out.println(tools); client.closeGracefully(); } } ``` ```kotlin import io.modelcontextprotocol.kotlin.sdk.Implementation import io.modelcontextprotocol.kotlin.sdk.client.Client import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport import io.ktor.client.* import io.ktor.client.plugins.sse.* import kotlinx.coroutines.runBlocking fun main() = runBlocking { val url = System.getenv("KUBEMQ_MCP_URL") ?: "http://localhost:9090" val httpClient = HttpClient { install(SSE) } val transport = StreamableHttpClientTransport(client = httpClient, url = "$url/mcp") val client = Client(clientInfo = Implementation(name = "kubemq-mcp-kotlin-example", version = "1.0.0")) client.connect(transport) val tools = client.listTools() println(tools) client.close() httpClient.close() } ``` ```python import asyncio import os from mcp.client.streamable_http import streamablehttp_client from mcp import ClientSession KUBEMQ_MCP_URL = os.environ.get("KUBEMQ_MCP_URL", "http://localhost:9090") async def main(): async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() tools = await session.list_tools() for tool in tools.tools: print(f"{tool.name}: {tool.description}") if __name__ == "__main__": asyncio.run(main()) ``` ```ruby require "mcp" url = ENV.fetch("KUBEMQ_MCP_URL", "http://localhost:9090") client = MCP::Client.new( transport: MCP::Transport::StreamableHTTP.new("#{url}/mcp"), name: "kubemq-mcp-ruby-example", version: "1.0.0" ) client.initialize_handshake tools = client.list_tools puts tools client.close ``` ```rust use rmcp::transport::streamable_http::StreamableHttpClientTransport; use rmcp::service::RunService; #[tokio::main] async fn main() -> anyhow::Result<()> { let url = std::env::var("KUBEMQ_MCP_URL") .unwrap_or_else(|_| "http://localhost:9090".to_string()); let transport = StreamableHttpClientTransport::from_uri(format!("{url}/mcp"))?; let client = ().serve(transport).await?; let tools = client.list_tools(Default::default()).await?; println!("{tools:#?}"); Ok(()) } ``` ```swift import Foundation import MCP @main struct ToolsList { static func main() async throws { let url = ProcessInfo.processInfo.environment["KUBEMQ_MCP_URL"] ?? "http://localhost:9090" let transport = HTTPClientTransport(endpoint: URL(string: "\(url)/mcp")!, streaming: true) let client = Client(name: "kubemq-mcp-swift-example", version: "1.0.0") try await client.connect(transport: transport) let (tools, _) = try await client.listTools() for tool in tools { print("\(tool.name): \(tool.description ?? "")") } } } ``` ```typescript import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const KUBEMQ_MCP_URL = process.env.KUBEMQ_MCP_URL || "http://localhost:9090"; async function main() { const transport = new StreamableHTTPClientTransport( new URL(`${KUBEMQ_MCP_URL}/mcp`) ); const client = new Client({ name: "kubemq-mcp-ts-example", version: "1.0.0" }); await client.connect(transport); const tools = await client.listTools(); console.log(JSON.stringify(tools, null, 2)); await client.close(); } main().catch(console.error); ``` ## Supported languages [#supported-languages] Every operation has a `curl` example plus the official MCP SDK in nine languages. The SDK wraps the Streamable HTTP transport — session IDs, request IDs, and JSON-RPC serialization are handled for you. | Language | MCP SDK package | Source | | --------------- | ------------------------------------ | --------------------- | | C# | `ModelContextProtocol` | NuGet | | Go | `github.com/mark3labs/mcp-go` | Go modules | | Java | `io.modelcontextprotocol:sdk` | Maven Central | | Kotlin | `io.modelcontextprotocol:kotlin-sdk` | Maven Central | | Python | `mcp` | PyPI | | Ruby | `mcp` | RubyGems | | Rust | `rmcp` | crates.io | | Swift | `mcp-swift-sdk` | Swift Package Manager | | TypeScript / JS | `@modelcontextprotocol/sdk` | npm | ## Next steps [#next-steps] # 2. Build & register an agent (/aiway/tutorial/build-and-register-an-agent) In [step 1](/aiway/tutorial/setup) you started KubeMQ and confirmed the Aiway endpoints are live with an empty agent roster. Now you'll build the **research agent** the rest of this tutorial discovers, invokes, and streams — a plain HTTP server that speaks JSON-RPC 2.0, with **no KubeMQ SDK**. KubeMQ's per-agent [Agent Bridge](/aiway/a2a/architecture) (a virtual subscriber) does all the broker work; your agent only answers HTTP POSTs. This page keeps the agent minimal so you can follow the end-to-end story. For the full agent contract — every method, header forwarding, and the lifecycle endpoints — see [Building agents](/aiway/a2a/guides/building-agents). The agent listens on its own port (`18080` below). It **registers** with — and is invoked through — the Aiway gateway on the shared HTTP port `9090`. Keep both running. ## Write the research agent [#write-the-research-agent] The agent handles two JSON-RPC methods on a single `POST /` route: * **`message/send`** — returns one synchronous result (used in [step 3](/aiway/tutorial/discover-and-invoke)). * **`message/stream`** — emits Server-Sent Events: three `task.status` updates (`searching` → `reading` → `summarizing`), then a `task.artifact`, then a terminal `task.done` (consumed in [step 4](/aiway/tutorial/stream-live-results)). The `task.artifact` envelope follows the wire format in [SSE behavior](/aiway/a2a/guides/sse-behavior) — `type: "artifact"` with a `payload` carrying a `name` and `data`. [Building agents](/aiway/a2a/guides/building-agents) shows only the `status` → `done` path, so the artifact shape comes from the SSE behavior reference. Save this as `research_agent.py`. It uses only `aiohttp` (server) and `httpx` (registration) — install them with `pip install aiohttp httpx`. ```python """A plain-HTTP research agent — no KubeMQ SDK.""" import asyncio import json import signal import httpx from aiohttp import web KUBEMQ_URL = "http://localhost:9090" # Aiway gateway (register + invoke) AGENT_ID = "research-agent-01" AGENT_PORT = 18080 # the agent's own HTTP port def extract_query(body: dict) -> str: """Pull the caller's text out of the JSON-RPC params.""" parts = body.get("params", {}).get("message", {}).get("parts", []) return " ".join(p.get("text", "") for p in parts).strip() or "(empty query)" async def handle_send(body: dict) -> web.Response: """message/send — return one synchronous JSON-RPC result.""" query = extract_query(body) return web.json_response({ "jsonrpc": "2.0", "id": body.get("id"), "result": { "summary": f"Research summary for: {query}", "sources": 3, }, }) async def handle_stream(request: web.Request, body: dict) -> web.StreamResponse: """message/stream — emit status events, an artifact, then done over SSE.""" query = extract_query(body) resp = web.StreamResponse( status=200, headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache"}, ) await resp.prepare(request) stages = ["searching", "reading", "summarizing"] for i, status in enumerate(stages, start=1): event = json.dumps({ "type": "status_update", "payload": {"status": status, "progress": i, "total": len(stages)}, }) await resp.write(f"event: task.status\ndata: {event}\n\n".encode()) await asyncio.sleep(0.5) # Intermediate artifact — envelope per the SSE behavior reference. artifact = json.dumps({ "type": "artifact", "payload": { "name": "summary.json", "data": {"summary": f"Research summary for: {query}", "sources": 3}, }, }) await resp.write(f"event: task.artifact\ndata: {artifact}\n\n".encode()) # Terminal event — stop reading after this. done = json.dumps({"type": "done", "payload": {"final_result": "completed", "event_count": 4}}) await resp.write(f"event: task.done\ndata: {done}\n\n".encode()) await resp.write_eof() return resp async def handle_request(request: web.Request) -> web.StreamResponse: body = await request.json() if body.get("method") == "message/stream": return await handle_stream(request, body) return await handle_send(body) async def register_agent() -> None: card = { "agent_id": AGENT_ID, "name": "Research Agent", "description": "Searches, reads, and summarizes — a mock LLM research agent.", "version": "1.0.0", "url": f"http://localhost:{AGENT_PORT}/", "skills": [ { "id": "research", "name": "Research", "description": "Searches sources and returns a summary.", "tags": ["research", "summarize"], } ], "defaultInputModes": ["text"], "defaultOutputModes": ["text"], "protocolVersions": ["1.0"], } async with httpx.AsyncClient() as client: resp = await client.post(f"{KUBEMQ_URL}/agents/register", json=card) print(f"Registered: {resp.status_code}") print(json.dumps(resp.json(), indent=2)) async def main() -> None: app = web.Application() app.router.add_post("/", handle_request) runner = web.AppRunner(app) await runner.setup() site = web.TCPSite(runner, "0.0.0.0", AGENT_PORT) await site.start() print(f"Research agent listening on port {AGENT_PORT}") # Always start the server BEFORE registering — KubeMQ may route the moment # registration succeeds. await register_agent() stop = asyncio.Event() loop = asyncio.get_running_loop() for sig in (signal.SIGINT, signal.SIGTERM): loop.add_signal_handler(sig, stop.set) await stop.wait() await runner.cleanup() if __name__ == "__main__": asyncio.run(main()) ``` Run it in one terminal and leave it running: ```bash python research_agent.py ``` It prints `Research agent listening on port 18080` and then the registration response. ## Register the Agent Card [#register-the-agent-card] The script above self-registers on startup, but you can register (or re-register) any agent with a plain `curl` — useful for a sidecar agent or a language not shown here. The body is the **Agent Card**: `agent_id`, `url`, `name`, and the `skills` array, with `protocolVersions: ["1.0"]` (this is the A2A card protocol version — not the MCP session version you'll see in [step 5](/aiway/tutorial/orchestrate-from-an-llm)). ```bash curl -X POST http://localhost:9090/agents/register \ -H "Content-Type: application/json" \ -d '{ "agent_id": "research-agent-01", "name": "Research Agent", "description": "Searches, reads, and summarizes — a mock LLM research agent.", "version": "1.0.0", "url": "http://localhost:18080/", "skills": [ {"id": "research", "name": "Research", "description": "Searches sources and returns a summary.", "tags": ["research", "summarize"]} ], "defaultInputModes": ["text"], "defaultOutputModes": ["text"], "protocolVersions": ["1.0"] }' ``` A `200` response echoes the stored card, enriched with server-set `registered_at` and `last_seen` timestamps. See the [agent registry](/aiway/a2a/registry) for the full card schema and the [agent cards](/aiway/a2a/agent-cards) reference for every field. ## Confirm it's in the roster [#confirm-its-in-the-roster] In step 1 `GET /agents` returned an empty list. Now it returns your agent: ```bash curl http://localhost:9090/agents ``` ```json [ { "agent_id": "research-agent-01", "name": "Research Agent", "url": "http://localhost:18080/", "skills": [ {"id": "research", "name": "Research", "tags": ["research", "summarize"]} ], "protocolVersions": ["1.0"], "registered_at": "2026-06-26T10:00:00Z", "last_seen": "2026-06-26T10:00:00Z" } ] ``` ## Keep it alive (heartbeat & TTL) [#keep-it-alive-heartbeat--ttl] The registry expires agents that go silent. A background liveness checker removes any agent whose `last_seen` is older than the TTL (`AgentTTLSeconds`, default **300s**). To stay registered, an agent must heartbeat (or re-register) within that window — a beat every 30–60s gives plenty of margin: ```bash curl -X POST http://localhost:9090/agents/heartbeat \ -H "Content-Type: application/json" \ -d '{"agent_id": "research-agent-01"}' ``` For this tutorial the agent stays up the whole time, so you won't hit the TTL — but a production agent should heartbeat on a timer and deregister on shutdown. The [agent registry](/aiway/a2a/registry) covers heartbeat, TTL, deregistration, and ownership in full. ## Next step [#next-step] Your research agent is live in the fabric. Next, find it by capability and call it synchronously. # 3. Discover & invoke (/aiway/tutorial/discover-and-invoke) In [Step 2](/aiway/tutorial/build-and-register-an-agent) you registered the `research-agent-01` agent with a `research` skill. Now you'll act as a **caller**: first discover the agent by its capability (no need to know its ID up front), then invoke it synchronously and read the result back in a single round-trip. This step assumes KubeMQ is running on port `9090` and `research-agent-01` is already registered (Steps 1 and 2). Verify with `curl http://localhost:9090/agents` — the agent should appear in the roster. ## Discover by capability [#discover-by-capability] Callers don't have to hard-code agent IDs. The registry lets you find agents by **skill tag**: `GET /agents?skill_tags=research` returns every registered agent that advertises the `research` skill as a bare JSON array of agent cards. This is capability-based discovery — ask for *what you need done*, get back *who can do it*. ```bash curl "http://localhost:9090/agents?skill_tags=research" ``` ```python import asyncio import httpx KUBEMQ_URL = "http://localhost:9090" async def main() -> None: async with httpx.AsyncClient() as client: resp = await client.get( f"{KUBEMQ_URL}/agents", params={"skill_tags": "research"}, ) agents = resp.json() for agent in agents: skills = [s["id"] for s in agent.get("skills", [])] print(f" {agent['agent_id']}: skills={skills}, url={agent['url']}") if __name__ == "__main__": asyncio.run(main()) ``` The response includes `research-agent-01` — its `agent_id`, `url`, and advertised `skills`: ```json [ { "agent_id": "research-agent-01", "name": "Research Agent", "url": "http://localhost:18080/", "skills": [ { "id": "research", "name": "Research", "description": "Searches sources and returns a summary.", "tags": ["research", "summarize"] } ], "protocolVersions": ["1.0"] } ] ``` `skill_tags` accepts a comma-separated list (`?skill_tags=research,summarize`) and is matched in memory after the roster is fetched. See the [Agent registry](/aiway/a2a/registry) for the full list/filter API. ## Invoke synchronously [#invoke-synchronously] Once you know the agent's ID, invoke it by POSTing a JSON-RPC 2.0 `message/send` envelope to `POST /a2a/research-agent-01`. The gateway routes the request to the agent through its [Agent Bridge](/aiway/a2a/architecture) (the per-agent virtual subscriber) and relays the agent's reply on the **same HTTP response** — no SSE, no polling. ```bash curl -X POST http://localhost:9090/a2a/research-agent-01 \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": { "message": { "parts": [{"text": "Summarize the history of message queues"}] } } }' ``` ```python import asyncio import json import httpx KUBEMQ_URL = "http://localhost:9090" AGENT_ID = "research-agent-01" async def main() -> None: payload = { "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": { "message": { "parts": [{"text": "Summarize the history of message queues"}], }, }, } async with httpx.AsyncClient() as client: resp = await client.post(f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload) data = resp.json() print(json.dumps(data, indent=2)) if "error" in data: print(f"\nAgent returned an error: {data['error']['message']}") else: print("\nGot a synchronous result from the agent.") if __name__ == "__main__": asyncio.run(main()) ``` The agent's JSON-RPC `result` comes back verbatim under `result`: ```json { "jsonrpc": "2.0", "id": 1, "result": { "summary": "Research summary for: Summarize the history of message queues", "sources": 3 } } ``` ## Header forwarding & error handling [#header-forwarding--error-handling] A couple of behaviors are worth knowing before you build real callers: * **Header forwarding** — any request header you send prefixed with `X-` is forwarded to the agent (hop-by-hop and sensitive headers like `Authorization` and `Cookie` are stripped), and the gateway always injects `X-KubeMQ-Caller-ID` so the agent knows who originated the call. Use this to pass tracing or tenant context through to your agent. * **Transport vs application errors** — when a response carries an `error` object, KubeMQ distinguishes two classes: * **Transport errors** — the agent never processed the request (unreachable, timeout, `502`/`503`/`504`, or an oversized reply). Internally these surface as `Executed: false`, and they are **safe to retry**. * **Application errors** — the agent processed the request and chose to return a JSON-RPC error (`Executed: true`). Retrying the same request usually won't help — fix the request instead. Tell them apart so your retry logic only re-sends calls that never reached the agent. For the full `message/send` envelope (context IDs, custom methods), header-forwarding rules, and the complete error-code list, see [Synchronous messaging](/aiway/a2a/sync-messaging) and the [Agent registry](/aiway/a2a/registry). ## Next step [#next-step] You've discovered and invoked your agent in one round-trip. Next, switch from a single reply to a live stream of task events as the agent works. # Tutorial (/aiway/tutorial) Tutorial = the end-to-end *fabric* story (build → discover → stream → LLM orchestration); the A2A and MCP **Getting started** pages are single-gateway quickstarts. New here? This page is the guided path. Across five steps you start KubeMQ, build and register a plain-HTTP "research" agent with **no KubeMQ SDK**, discover it by capability and invoke it synchronously, stream its live task events over SSE, and finally let an LLM discover and call that same agent over MCP — proving the "one fabric" thesis end-to-end. Every step uses Python and `curl`. ## What you'll build [#what-youll-build] By the last step, an LLM host reaches your custom research agent through the MCP→A2A bridge — discovering it with `agent_list` and invoking it with `agent_send`, all over the message broker. *The LLM discovers and invokes the research agent you built — through MCP, Aiway, and the message broker.* ## Prerequisites [#prerequisites] * **Docker** (to run KubeMQ locally) — or a KubeMQ server already running with the shared HTTP server on port `9090`. * **Python 3.11+** for the agent server and the clients. * **`curl`** for probing the Aiway endpoints. No KubeMQ SDK is required anywhere in this tutorial. The agent is a plain HTTP service, and every call to the fabric is JSON over HTTP. ## The steps [#the-steps] Follow the steps in order — each one builds on the last. # 5. Orchestrate from an LLM (MCP) (/aiway/tutorial/orchestrate-from-an-llm) In [Step 2](/aiway/tutorial/build-and-register-an-agent) you built and registered `research-agent-01`, and in Steps [3](/aiway/tutorial/discover-and-invoke) and [4](/aiway/tutorial/stream-live-results) you discovered and invoked it as an A2A caller. Now you'll put an **LLM** in the driver's seat: an MCP client connects to the same KubeMQ server, discovers your agent, and calls it — all through the **Model Context Protocol**, with no new glue code on your side. This is the **MCP→A2A bridge**: the LLM reaches the exact agent you built in Step 2. This step assumes KubeMQ is running on port `9090` and `research-agent-01` is registered and listening (Steps 1 and 2). The MCP connector is **enabled by default** — `POST /mcp` is already live on the shared HTTP server. For the Python path, install the MCP SDK with `pip install mcp`. ## Connect and list the tools [#connect-and-list-the-tools] An MCP client opens a session against `POST http://localhost:9090/mcp`, negotiates the protocol version (`2025-11-25`), then calls `tools/list`. KubeMQ exposes **15 tools** — **11 core messaging tools** plus **4 agent-bridge tools** (`agent_list`, `agent_info`, `agent_send`, `agent_query`) that appear when the A2A agent registry is available. The agent-bridge tools are what let the LLM reach your agent. The MCP session version `2025-11-25` is a **different** version from the A2A Agent Card's `protocolVersions: ["1.0"]` you registered in [Step 2](/aiway/tutorial/build-and-register-an-agent) — one is the MCP handshake, the other is the agent's A2A card version. They are not interchangeable. First open a session with `initialize`, then list the tools with the returned session ID: ```bash # 1. Initialize a session — note the protocol version is the MCP one. curl -X POST http://localhost:9090/mcp \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "tutorial", "version": "1.0.0"} } }' # 2. List the tools (substitute the session ID from the response above). curl -X POST http://localhost:9090/mcp \ -H "Content-Type: application/json" \ -H "MCP-Session-Id: " \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }' ``` The `mcp` SDK runs the `initialize` handshake and tracks the session ID for you over the Streamable HTTP transport — you only call `tools/list`: ```python import asyncio from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client KUBEMQ_MCP_URL = "http://localhost:9090" async def main() -> None: async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() tools = await session.list_tools() names = [t.name for t in tools.tools] print(f"{len(names)} tools available") # 15: 11 core + 4 agent-bridge print("agent-bridge tools:", [n for n in names if n.startswith("agent_")]) if __name__ == "__main__": asyncio.run(main()) ``` The tool list includes the four agent-bridge tools alongside the 11 core messaging tools: ```json ["agent_list", "agent_info", "agent_send", "agent_query"] ``` ## Discover the agent with agent\_list [#discover-the-agent-with-agent_list] `agent_list` reads the same A2A registry you queried in [Step 3](/aiway/tutorial/discover-and-invoke) — but now through an MCP tool call. Pass `skill_tags` to filter by capability, exactly as before, and the LLM gets back `research-agent-01` without ever hard-coding its ID. ```bash curl -X POST http://localhost:9090/mcp \ -H "Content-Type: application/json" \ -H "MCP-Session-Id: " \ -d '{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "agent_list", "arguments": {"skill_tags": ["research"]} } }' ``` ```python result = await session.call_tool("agent_list", {"skill_tags": ["research"]}) print(f"IsError: {result.isError}") for content in result.content: print(f"Result: {content.text}") ``` The result text is a JSON array of agent summaries — your agent is in it: ```json { "jsonrpc": "2.0", "id": 3, "result": { "content": [{"type": "text", "text": "[{\"agent_id\":\"research-agent-01\",\"name\":\"Research Agent\",\"skills\":[{\"id\":\"research\",\"name\":\"Research\",\"tags\":[\"research\",\"summarize\"]}]}]"}], "isError": false } } ``` ## Invoke the agent with agent\_send [#invoke-the-agent-with-agent_send] `agent_send` wraps your message in an A2A `message/send` envelope and forwards it through the per-agent [Agent Bridge](/aiway/a2a/architecture) — the same path the direct A2A call took in [Step 3](/aiway/tutorial/discover-and-invoke), just initiated from MCP. By default the call is **blocking**: it waits up to `timeout_seconds` for the agent's reply (**default `60`, max `300`**). Pass `blocking: false` for fire-and-forget, or `context_id` to thread the message into an existing conversation. ```bash curl -X POST http://localhost:9090/mcp \ -H "Content-Type: application/json" \ -H "MCP-Session-Id: " \ -d '{ "jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": { "name": "agent_send", "arguments": { "agent_id": "research-agent-01", "message": "Summarize the history of message queues", "timeout_seconds": 60 } } }' ``` ```python result = await session.call_tool("agent_send", { "agent_id": "research-agent-01", "message": "Summarize the history of message queues", "timeout_seconds": 60, }) print(f"IsError: {result.isError}") for content in result.content: print(f"Result: {content.text}") ``` The agent's reply comes back in the `content` text — the same result your Step 2 agent returns from `message/send`, now delivered to the LLM: ```json { "jsonrpc": "2.0", "id": 4, "result": { "content": [{"type": "text", "text": "{\"summary\":\"Research summary for: Summarize the history of message queues\",\"sources\":3}"}], "isError": false } } ``` An unknown `agent_id` returns `isError: true` with `Agent '' not found` — a tool-level error in the `content` block, not a JSON-RPC protocol error. The bridge also adds a gateway timeout buffer on top of your `timeout_seconds`. See [Agent-bridge tools](/aiway/mcp/tools/agent-bridge) for `agent_info`, `agent_query`, and the full argument and error reference. ## One fabric, no glue code [#one-fabric-no-glue-code] Step back and notice what just happened. The LLM **discovered** your agent by capability and **invoked** it — and you wrote no integration code to make that possible. The same `research-agent-01` you built in Step 2 (a plain-HTTP service, no KubeMQ SDK) is now reachable by an A2A caller *and* by any MCP-speaking LLM, over the same message broker. That's the Aiway thesis: **one fabric, two doors** — register once, reach it from both. To wire this into a real LLM host such as Claude Desktop — a `claude_desktop_config.json` entry that points at `http://localhost:9090/mcp` — see [MCP Getting started](/aiway/mcp/getting-started). The host then sees all 15 tools (including the four agent-bridge tools) and can call your agent from a conversation. ## Where to go next [#where-to-go-next] You've built the full fabric end-to-end: started KubeMQ, registered a zero-SDK agent, discovered and invoked it, streamed live task events, and orchestrated it from an LLM over MCP. From here, go deeper: # 1. Set up & start KubeMQ (/aiway/tutorial/setup) This first step gets KubeMQ running locally and confirms the two Aiway doors — the **A2A** agent gateway and the **MCP** gateway — are live on the shared HTTP server (port `9090`). Both are enabled by default, so there is nothing to turn on. Need Docker? Get it from [docker.com/get-started](https://www.docker.com/get-started/). Already have a KubeMQ server reachable on port `9090`? Skip straight to the probes in Step 2. ## Setup steps [#setup-steps] ### Start KubeMQ [#start-kubemq] Run KubeMQ locally with a single Docker command. This starts the broker with gRPC on port `50000`, the shared HTTP server (where the A2A and MCP gateways live) on port `9090`, and the dashboard on port `8080`. Confirm the dashboard is up at `http://localhost:8080`, then move on to verify the Aiway endpoints. ### Verify the A2A agent roster [#verify-the-a2a-agent-roster] The A2A gateway exposes the agent registry at `GET /agents`. On a fresh server the roster is empty — that is exactly what you want to see before registering an agent in the next step. ```bash title="Terminal" curl http://localhost:9090/agents ``` You should get back an empty roster: ```json { "agents": [] } ``` An empty list confirms the A2A gateway is live and ready to accept registrations. You'll register `research-agent-01` here in Step 2. ### Verify the MCP gateway [#verify-the-mcp-gateway] The MCP gateway answers at `POST /mcp`. Open a session with a JSON-RPC `initialize` call — this negotiates the MCP protocol version (`2025-11-25`) and proves the gateway is reachable. ```bash title="Terminal" curl -X POST http://localhost:9090/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": { "name": "aiway-tutorial", "version": "1.0.0" } } }' ``` A successful handshake returns the server's protocol version and capabilities: ```json { "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2025-11-25", "capabilities": { "tools": {} }, "serverInfo": { "name": "kubemq-mcp", "version": "1.0.0" } } } ``` The MCP **session** protocol version (`2025-11-25`) is a different field from the A2A **Agent Card** `protocolVersions` (which is `["1.0"]`, used when you register an agent in Step 2). They are two distinct layers — don't conflate them. With KubeMQ running and both Aiway gateways answering, you're ready to build an agent and register it with the fabric. # 4. Stream live results (/aiway/tutorial/stream-live-results) In [step 3](/aiway/tutorial/discover-and-invoke) you called `research-agent-01` with `message/send` and got a single reply. Real research takes time, though — and your agent already emits progress as it works. In this step you'll switch to **`message/stream`** and watch the task unfold live: status updates as the agent searches, reads, and summarizes, then the result artifact, then a terminal "done" — all over a single **Server-Sent Events (SSE)** connection. This step assumes KubeMQ is running on port `9090` and `research-agent-01` is registered and listening (Steps 1 and 2). All calls go to the **Aiway gateway** on `9090`, not to the agent's own port — the gateway relays the agent's events back to you. ## How streaming works [#how-streaming-works] `message/send` returns one reply on the same HTTP response. `message/stream` instead opens a long-lived `text/event-stream` connection: the gateway uses the agent's [Agent Bridge](/aiway/a2a/architecture) (its per-agent virtual subscriber) as an **SSE relay**, forwarding each event the agent emits to you as it happens. You read frames until a terminal event closes the stream. Each frame has an `event:` name and a JSON `data:` envelope. The agent's envelope `type` maps to the SSE event name you see: | SSE event | Envelope `type` | Meaning | Terminal | | --------------- | --------------- | ---------------------------------------------- | -------- | | `task.status` | `status_update` | Progress update (non-terminal) | No | | `task.artifact` | `artifact` | A partial or complete result artifact | No | | `task.done` | `done` | The task completed successfully | Yes | | `task.error` | `error` | The task failed (carries `code` and `message`) | Yes | The `research-agent-01` agent from [step 2](/aiway/tutorial/build-and-register-an-agent) emits, in order: three `task.status` updates (`searching` → `reading` → `summarizing`), one `task.artifact`, then a terminal `task.done`. Stop reading on `task.done` (or `task.error`). The exact wire format and envelope shapes are in [SSE behavior](/aiway/a2a/guides/sse-behavior). ## Open a stream [#open-a-stream] Send a `message/stream` request to `POST /a2a/research-agent-01` with `Accept: text/event-stream`. The body is a JSON-RPC 2.0 envelope identical to `message/send`, just with the `message/stream` method. (You can also open a stream with `GET /a2a/research-agent-01/stream` — the POST form is the common case.) ```bash curl -N -X POST http://localhost:9090/a2a/research-agent-01 \ -H "Content-Type: application/json" \ -H "Accept: text/event-stream" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "message/stream", "params": { "message": { "parts": [{"text": "Summarize the history of message queues"}] } } }' ``` The `-N` flag disables curl's output buffering so frames print the instant they arrive. This uses `httpx` with the `httpx-sse` helper for clean SSE parsing — install both with `pip install httpx httpx-sse`. ```python import asyncio import json import httpx from httpx_sse import aconnect_sse KUBEMQ_URL = "http://localhost:9090" AGENT_ID = "research-agent-01" async def main() -> None: payload = { "jsonrpc": "2.0", "id": 1, "method": "message/stream", "params": { "message": {"parts": [{"text": "Summarize the history of message queues"}]}, }, } async with httpx.AsyncClient(timeout=None) as client: print("Connecting to SSE stream...") async with aconnect_sse( client, "POST", f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload, headers={"Accept": "text/event-stream"}, ) as event_source: async for event in event_source.aiter_sse(): data = json.loads(event.data) print(f"[{event.event}] {json.dumps(data)}") # task.done and task.error are terminal — stop reading. if event.event in ("task.done", "task.error"): break print("Stream complete.") if __name__ == "__main__": asyncio.run(main()) ``` The `httpx-sse` library skips keepalive comment lines for you and yields one object per event with `.event` (the name) and `.data` (the JSON payload). ## Read the live event sequence [#read-the-live-event-sequence] As the agent works, the frames arrive in order. You'll see the three status updates, then the artifact, then the terminal `task.done`: ```text Connecting to SSE stream... [task.status] {"type": "status_update", "payload": {"status": "searching", "progress": 1, "total": 3}} [task.status] {"type": "status_update", "payload": {"status": "reading", "progress": 2, "total": 3}} [task.status] {"type": "status_update", "payload": {"status": "summarizing", "progress": 3, "total": 3}} [task.artifact] {"type": "artifact", "payload": {"name": "summary.json", "data": {"summary": "Research summary for: Summarize the history of message queues", "sources": 3}}} [task.done] {"type": "done", "payload": {"final_result": "completed", "event_count": 4}} Stream complete. ``` Mapped to the event table above: * **`task.status`** (`status_update`) — non-terminal progress. The agent emits one per stage (`searching`, `reading`, `summarizing`) with `progress`/`total` so a UI can show a bar. There can be any number of these. * **`task.artifact`** (`artifact`) — the result payload. The envelope carries a `name` (`summary.json`) and a `data` object with the actual result. An agent can emit several artifacts; the [artifact envelope is defined in SSE behavior](/aiway/a2a/guides/sse-behavior). * **`task.done`** (`done`) — **terminal**. After this the gateway closes the connection. A failed task ends with `task.error` instead, carrying a `code` and `message`. Always break on either. ## Keepalive, idle timeout & disconnect [#keepalive-idle-timeout--disconnect] A few stream behaviors matter once you build real callers — all relayed by the gateway, not the agent: * **Keepalive** — during quiet periods the gateway emits an SSE comment line (`: keepalive`) every **30 seconds** so proxies don't drop the idle socket. It's a comment, not an event; SSE client libraries (including `httpx-sse`) ignore it. If you parse the stream by hand, skip any line starting with `:`. * **Idle timeout** — if the agent emits no events for `MaxSSEIdleSeconds` (default **300s**), the gateway closes the stream with a terminal `task.error` (code `-32001`, `"stream idle timeout"`). The timer measures the gap *between* events, so a long task stays alive as long as it heartbeats with periodic `task.status` frames. * **Auto-cancel on disconnect** — if you close the connection before a terminal event, the gateway detects it and **cancels the work on the agent** (via a `stream_cancel` to the Agent Bridge) rather than letting it run to completion. Closing your SSE reader is a real cancellation signal — it frees the agent's concurrency slot promptly. For the full streaming walkthrough, see [Streaming (SSE)](/aiway/a2a/streaming). For the exact wire format, the artifact envelope, keepalive cadence, idle-timeout behavior, and disconnect cancellation, see [SSE behavior](/aiway/a2a/guides/sse-behavior). ## Next step [#next-step] You've watched a task stream live from `task.status` through `task.artifact` to a terminal `task.done`. So far every call has been a hand-written HTTP request. In the final step, an **LLM** does the discovering and invoking for you — over MCP, through the same fabric. # Docker (single-node) (/configure/docker) Docker single-node **is** the standalone KubeMQ server, containerized. One container runs the full server: every interface, every connector, the persistent store, and the embedded messaging engine. There is no separate "bare-binary" target — the container *is* the binary. This guide hosts the complete, runnable Docker configs; the [reference](/configure/reference) pages show per-setting snippets and link back here. **Install KubeMQ first** → [Docker install](/deploy/docker). This page covers how to *configure* a single node, not how to install one. | Port | Interface | Purpose | | ------- | ---------------- | -------------------------------------------------------------------------- | | `50000` | gRPC | Primary SDK transport. | | `9090` | REST / WebSocket | REST API, WebSocket, and the shared HTTP server (MCP · A2A · CloudEvents). | | `8080` | API / Dashboard | Web dashboard, management API, health probes, and metrics. | The store is bind-mounted to **`/kubemq/store`** inside the container. Persistence is required for the **Events Store** and **Queues** patterns. **Two things a persistent store needs, and both are easy to miss.** **Mount at `/kubemq/store`, not `/store`.** The image's working directory is `/kubemq` and the default `store.storepath` is the *relative* `./store`, so the server writes to `/kubemq/store`. A volume mounted at `/store` receives nothing and the data dies with the container — and every health check on this page still passes, because the server is running fine, just onto container-local disk. **Pin the hostname with `--hostname`.** The store layout is `//…`, and Docker assigns a fresh random hostname to every container. Recreate the container without `--hostname` and the server boots clean and healthy onto a *new empty directory*, with the old one sitting intact beside it. On the `next` engine the node's raft identity moves with it too. Setting KubeMQ's own `HOST` does **not** substitute — the directory name comes from the OS hostname. ## Three ways to supply config [#three-ways-to-supply-config] Every setting has a documented default, so a server runs with no overrides at all. To change a setting you have three delivery methods, in order of how much you are configuring: ### Per-field environment variables [#per-field-environment-variables] Pass each setting as `-e GROUP_FIELD=value`. The env var is the `config.yaml` key snake-cased, dotless, and uppercased — `store.maxretention` becomes `STORE_MAX_RETENTION`. This is the lightest method, best for a handful of overrides: **The connector acronym variables drop the underscore.** The CloudEvents enable variable is **`CONNECTORSCE_ENABLE`** (no underscore between `CONNECTORS` and `CE`). The same collapse applies to `CONNECTORSMCP_*` and `CONNECTORSMQTT_*`, while the Title-case connectors keep the underscore (`CONNECTORS_AMQP_*`, `CONNECTORS_STOMP_*`, `CONNECTORS_AWS_*`), and A2A splits to `CONNECTORSA2_A_*`. The full rule is in [the reference legend](/configure/reference). **What happens when you get it wrong depends on which form you used, and the warning is not reliable in either direction:** | You set | Does it work? | Does the server say anything? | | ----------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------ | | `CONNECTORS_MQTT_ENABLE` (wrong twin of a collapsed name) | **No** | Yes — an `IGNORED` warning, because the name is in the `CONNECTORS_` namespace | | `CONNECTORS_CE_ENABLE` (CloudEvents only) | **Yes** — CE is the one connector that binds both forms | Yes — an `IGNORED` warning **that is wrong**; the value is applied anyway | | A typo in a correct collapsed name (`CONNECTORSMQT_ENABLE`) | No | **Nothing at all** — collapsed names sit outside the warner's namespace list | So: an `IGNORED` warning for a `CONNECTORS_CE_*` variable is a false alarm — do not "fix" a working setting because of it. And the absence of a warning proves nothing about a collapsed-form name. Check the effective value in the dashboard rather than trusting the log either way. ### A mounted config.yaml [#a-mounted-configyaml] Once you are setting more than a few fields, keep them in a `config.yaml` and mount it into the container. The server auto-detects YAML or TOML; point it at the file with the `--config` flag. ```yaml title="config.yaml" log: level: 1 store: storepath: ./store maxretention: 2880 connectors: grpc: enable: true port: "50000" rest: enable: true port: "9090" ce: enable: false api: port: "8080" ``` **The license key is not a `config.yaml` field.** It is read straight from the environment as **`KUBEMQ_TOKEN`** (or passed as the `--key` flag) — there is no viper binding and no config-file key for it. A `key:` entry in `config.yaml` is the *Helm* spelling; put it here and the server ignores it and refuses to start unlicensed. Keep the token in the environment, as every `docker run` on this page does. Mount the file and select it with `--config`. **The flag goes after the image name *and* after the binary path:** ```bash title="Terminal" docker run -d \ --name kubemq \ --hostname kubemq \ -p 50000:50000 \ -p 9090:9090 \ -p 8080:8080 \ -e KUBEMQ_TOKEN=YOUR_LICENSE_KEY \ -v "$(pwd)/kubemq-store:/kubemq/store" \ -v "$(pwd)/config.yaml:/kubemq/config.yaml:ro" \ europe-docker.pkg.dev/kubemq/images/kubemq:next \ /kubemq/kubemq-run --config /kubemq/config.yaml ``` **You must repeat `/kubemq/kubemq-run`.** The image declares no `ENTRYPOINT` — only `CMD ["/kubemq/kubemq-run"]` — so anything you put after the image name **replaces** the command rather than appending to it. Passing just `--config /kubemq/config.yaml` makes Docker try to execute `--config` as the binary, and the container never starts. **`enable` is the Docker toggle — but the default differs by family.** The interfaces and the HTTP-family connectors (gRPC, REST, API, MCP, A2A, CloudEvents) ship **on by default**; the wire-protocol connectors (MQTT, AMQP, STOMP, Kafka, AWS, GCP) are **opt-in** and ship **off**. On Docker you flip either with `enable: true | false` (env `..._ENABLE`) — turn an always-on interface **off** with `enable: false`, or turn a wire connector **on** with `enable: true`. The Helm/CRD surface splits the two: the always-on interfaces use an opt-out `disabled: true`, while the wire connectors use an opt-in `enabled: true` (see [Kubernetes](/configure/kubernetes)). Same toggles, inverted boolean. ### The CONFIG environment variable [#the-config-environment-variable] When mounting a file is awkward — orchestrators that inject env vars, CI runners, secret managers — supply the **whole** config inline through the `CONFIG` environment variable. The server writes the value to a file and loads it, exactly as if you had passed `--config`: ```bash title="Terminal" docker run -d \ --name kubemq \ --hostname kubemq \ -p 50000:50000 \ -p 9090:9090 \ -p 8080:8080 \ -e KUBEMQ_TOKEN=YOUR_LICENSE_KEY \ -v "$(pwd)/kubemq-store:/kubemq/store" \ -e CONFIG="$(cat config.yaml)" \ europe-docker.pkg.dev/kubemq/images/kubemq:next ``` `CONFIG` also accepts a **base64-encoded** payload, which avoids newline and quoting issues when the value passes through a secret store or a templating layer: ```bash title="Terminal" docker run -d \ --name kubemq \ --hostname kubemq \ -p 50000:50000 \ -p 9090:9090 \ -p 8080:8080 \ -e KUBEMQ_TOKEN=YOUR_LICENSE_KEY \ -v "$(pwd)/kubemq-store:/kubemq/store" \ -e CONFIG="$(base64 < config.yaml)" \ europe-docker.pkg.dev/kubemq/images/kubemq:next ``` ## docker-compose [#docker-compose] The same single node as a `docker-compose.yaml`, combining a mounted `config.yaml` with a couple of per-field environment overrides: ```yaml title="docker-compose.yaml" services: kubemq: image: europe-docker.pkg.dev/kubemq/images/kubemq:next container_name: kubemq hostname: kubemq command: ["/kubemq/kubemq-run", "--config", "/kubemq/config.yaml"] ports: - "50000:50000" # gRPC - "9090:9090" # REST / WebSocket - "8080:8080" # API / Dashboard environment: - KUBEMQ_TOKEN=${KUBEMQ_TOKEN:-} - LOG_LEVEL=1 volumes: - kubemq-store:/kubemq/store - ./config.yaml:/kubemq/config.yaml:ro restart: unless-stopped volumes: kubemq-store: ``` Start it: ```bash title="Terminal" docker compose up -d ``` To supply the config inline instead of mounting a file, drop the `command` and `config.yaml` volume and set `CONFIG` (or `CONFIG` as base64) in the `environment` block. ## Configure by domain [#configure-by-domain] Every setting in the configs above is documented in the reference, grouped by domain. Each page lists the `config.yaml` key, the environment variable, the type, the default, and the valid values. ## Verify [#verify] Confirm the node is up. Open the dashboard at `http://localhost:8080`, then check the container logs: ```bash title="Terminal" docker logs kubemq ``` A healthy start logs each interface binding to its port. The health and readiness probes on the API port confirm the server is accepting traffic: ```bash title="Terminal" curl http://localhost:8080/health curl http://localhost:8080/ready ``` ## Related [#related] * [Kubernetes (Helm)](/configure/kubernetes) — the production target with replicas and `Service` exposure. * [Configuration overview](/configure) — the two targets and the config model. * [Configuration reference](/configure/reference) — every setting, grouped by domain. # Kubernetes (Helm) (/configure/kubernetes) **Install KubeMQ first** → [Helm install](/deploy/kubernetes-helm). This page covers how to *configure* a KubeMQ cluster. On Kubernetes, KubeMQ runs through the **operator**. You describe the server with a `KubemqCluster` custom resource, the operator reconciles it into a StatefulSet, Services, and configuration, and the **Helm charts** render that resource from your `values.yaml`. Because the chart writes values straight into the CR, a Helm value path equals the `KubemqCluster` `spec.*` path with the leading `spec.` removed (`spec.grpc.port` → `grpc.port`). This guide is the single source of truth for **complete, runnable** Helm and CRD configurations. The [Configuration overview](/configure) and the [reference pages](/configure/reference) show minimal single-setting snippets and link here. ## Configure with values.yaml [#configure-with-valuesyaml] For anything beyond the license key, supply a `values.yaml` file. Each value maps **1:1** to a `KubemqCluster` `spec.*` field — the chart renders your values verbatim into the CR spec, so there is no separate Helm schema to learn. A complete `values.yaml`: ```yaml title="values.yaml" # Replace with your license key key: YOUR_LICENSE_KEY # High availability: 3 replicas (set standalone: true for a single node) replicas: 3 standalone: false # Persistent store volume: size: 20Gi storageClass: fast-ssd # Interfaces — .port moves the listener + Service port together grpc: port: 50000 expose: LoadBalancer # REST is on by default; MCP / A2A / CloudEvents ride the REST HTTP port # (disabled: false is the default — shown here only to be explicit) rest: disabled: false port: 9090 expose: ClusterIP api: port: 8080 expose: LoadBalancer # Store limits and retention — enforced on the `legacy` engine only (see callout below) store: messagesRetentionMinutes: 1440 maxChannels: 0 # Pod resources resources: requestsCpu: "2" requestsMemory: 4Gi limitsCpu: "4" limitsMemory: 8Gi ``` **`store.messagesRetentionMinutes` and the other `store.max*` limits are enforced by the `legacy` engine only — and a new cluster on a clean store comes up on `next`.** So the `1440` above does nothing on a default install: native Events Store and Queues channels have no age, size, or count cap and grow until the disk does. Size by `spec.volume.size`, or use Kafka topic channels where age eviction matters. See [Native retention scope](/configure/reference/storage-engines#native-retention-scope). Install (or upgrade) the cluster with the file: ```bash title="Terminal" helm install --wait -n kubemq kubemq-cluster kubemq-charts/kubemq-cluster -f values.yaml ``` The same configuration as a `KubemqCluster` CR — apply it directly with `kubectl apply -f` if you manage the resource yourself rather than through Helm: ```yaml title="kubemqcluster.yaml" apiVersion: core.k8s.kubemq.io/v1beta1 kind: KubemqCluster metadata: name: kubemq-cluster namespace: kubemq spec: key: YOUR_LICENSE_KEY replicas: 3 standalone: false volume: size: 20Gi storageClass: fast-ssd grpc: port: 50000 expose: LoadBalancer rest: disabled: false port: 9090 expose: ClusterIP api: port: 8080 expose: LoadBalancer store: messagesRetentionMinutes: 1440 maxChannels: 0 resources: requestsCpu: "2" requestsMemory: 4Gi limitsCpu: "4" limitsMemory: 8Gi ``` **REST is enabled by default on the chart.** The cluster chart ships `rest.disabled: false` (`spec.rest.disabled: false`), so REST — and the MCP, A2A, and CloudEvents connectors that ride the **REST HTTP port** — are reachable out of the box. `disabled` is an **opt-out** boolean: omit the key entirely to leave REST on; set `rest.disabled: true` only to turn REST off, which also takes MCP, A2A, and CloudEvents offline. **Version floor.** The first-class `spec.telemetry.*`, `spec.audit.*`, and `spec.http.*` fields, and the aligned `spec.authentication.*` fields, are present throughout the current GA chart line — `kubemq-crds` and `kubemq-cluster` **3.x** (latest **3.2.0**) with `kubemq-controller` **2.x** (operator **v2.3.0**). Anything older than the 3.0.0 / 2.0.0 GA release predates this reference and will **reject** these fields: upgrade to the current line rather than trying to work out which pre-GA build carried which field. **On Kubernetes, `.port` moves everything together.** For `grpc`, `rest`, and `api`, setting `spec..port` makes the operator emit the matching listener env var (`CONNECTORS_GRPC_PORT` / `CONNECTORS_REST_PORT` / `API_PORT`) **and** set the Kubernetes `Service` `port`/`targetPort` **and** the container port — the in-pod listener and the `Service` port move as one. This matches Docker, where the same `*_PORT` setting moves the actual listener (which you then publish with `-p`) — see the [Docker guide](/configure/docker). ## Single-node vs HA [#single-node-vs-ha] The same chart runs both topologies — the difference is `replicas`. ```yaml title="values.yaml" key: YOUR_LICENSE_KEY replicas: 1 standalone: true ``` ```yaml title="values.yaml" key: YOUR_LICENSE_KEY replicas: 3 standalone: false ``` A single replica (or `standalone: true`) runs one non-clustered node — the equivalent of one Docker container. Three or more replicas give you high availability; the operator wires up clustering across the pods. For the full deployment and HA reference, see [Deployment & HA](/configure/reference/deployment). ## Expose interfaces [#expose-interfaces] Each interface (`grpc`, `rest`, `api`) is fronted by its own Kubernetes `Service`. Control the `Service` type with `expose` and the published node port with `nodePort`: ```yaml title="values.yaml" grpc: expose: LoadBalancer # ClusterIP | NodePort | LoadBalancer api: expose: NodePort nodePort: 32080 # only used with NodePort ``` * **`ClusterIP`** — reachable only inside the cluster (the default for internal-only interfaces). * **`NodePort`** — published on every node at `nodePort`; useful for direct access without a cloud load balancer. * **`LoadBalancer`** — provisions an external load balancer (cloud environments). `nodePort` applies only when `expose: NodePort`. On Docker there is no `Service` — publish ports with `-p` instead (see the [Docker guide](/configure/docker)). ## Zero-config Kafka [#zero-config-kafka] On a **fresh** cluster, enabling the Kafka connector is the whole story — no separate engine setting to manage: ```yaml title="values.yaml" key: YOUR_LICENSE_KEY replicas: 3 standalone: false kafka: enabled: true ``` With the store empty and `store.engine` left unset, the operator auto-selects the `next` persistence engine at first boot — see [Storage Engines → Zero-config engine selection](/configure/reference/storage-engines#zero-config-engine-selection) for the full selection rules. **The default `replicas: 3` means Kafka producers should use `acks>=1` for durable writes.** The `next` engine acknowledges a publish only after it's quorum-replicated across raft peers, so an `acks=0` producer won't see a stalled or leaderless partition. External reachability needs `spec.kafka.expose` plus an advertised host/port pair — see [Connectors → Kafka](/configure/reference/connectors#kafka) for the full listener/TLS/SAN details. ## Configure by domain [#configure-by-domain] Every server setting — with its type, default, valid values, and both per-target columns — lives in the domain reference pages. ## Verify [#verify] Confirm the operator has reconciled the cluster and the pods are ready. Check the `KubemqCluster` resource and its status: ```bash title="Terminal" kubectl get kubemqcluster -n kubemq ``` Inspect the full status, including the reconcile phase and any conditions: ```bash title="Terminal" kubectl describe kubemqcluster kubemq-cluster -n kubemq ``` Confirm the server pods are running: ```bash title="Terminal" kubectl get pods -n kubemq ``` To reach the dashboard, port-forward the API service and open `http://localhost:8080`: ```bash title="Terminal" kubectl port-forward -n kubemq svc/kubemq-cluster 8080:8080 ``` ## Related [#related] * [Configuration overview](/configure) — the two targets and the config model. * [Docker (single-node)](/configure/docker) — the local / dev target. * [Deployment & HA reference](/configure/reference/deployment) — packaging, replicas, and exposure in full. * [Install with Helm](/deploy/kubernetes-helm) — the step-by-step install guide. # Advanced (config.yaml-only) (/configure/reference/advanced) These are advanced, low-level knobs. The **message-broker engine** (`broker.*`), **runtime tuning** (`tuning.*`), and **standalone clustering** (`cluster.*`) have **no Helm/CRD path** — they are Docker / `config.yaml`-only, so their Helm/CRD column is `—` (per the [reference legend](/configure/reference)). On Kubernetes the operator owns those concerns. **Routing** is the exception: three of its fields (`data`, `url`, `autoReload`) map to `spec.routing.*` on the CRD. ## Message-broker engine [#message-broker-engine] The embedded message-broker engine's internal limits and buffers, configured through the `broker.*` block. These are Docker / `config.yaml`-only; there is **no Helm/CRD path** (the operator owns the engine on Kubernetes). Most fields bind to a `BROKER_*` env var — but the two auto-assigned ports (`port`, `monitoringPort`) have **no env var** and are file-only. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ------------------ | -------------- | ------------------------- | ------------ | ------------------------------------------------------------ | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | Port | int | auto (free port) | port number | `broker.port` · *(no env var)* | — | Ephemeral — a free port is picked at every startup (`getFreePort()`). Not meant to be pinned; **no `BROKER_PORT` env var exists**. Not validated. | | Max payload | int (bytes) | `1048576000` (\~1000 MiB) | ≥ 0 | `broker.maxpayload` · `BROKER_MAX_PAYLOAD` | — | Max message payload in **bytes**. Cast to `int32` when applied, so the effective ceiling is 2,147,483,647 bytes (\~2 GiB); larger values overflow. | | Write deadline | int (ms) | `2000` | ≥ 0 | `broker.writedeadline` · `BROKER_WRITE_DEADLINE` | — | Per-write deadline in **milliseconds**. | | Max connections | int | `0` (unlimited) | ≥ 0 | `broker.maxconn` · `BROKER_MAX_CONN` | — | `0` = unlimited. | | Monitoring port | int | auto (free port) | 1–65535 | `broker.monitoringport` · *(no env var)* | — | Ephemeral — picked at every startup. **No `BROKER_MONITORING_PORT` env var exists.** This is the only broker field validated as a port. | | Write buffer size | int (**MB**) | `2` (→ 2 MB) | ≥ 0 | `broker.writebuffersize` · `BROKER_WRITE_BUFFER_SIZE` | — | Value is in **megabytes** — the config value is multiplied by 1024×1024 (`broker.go:52`). `2` means 2 MB, **not** 2 bytes. | | Read buffer size | int (**MB**) | `10` (→ 10 MB) | ≥ 0 | `broker.readbuffersize` · `BROKER_READ_BUFFER_SIZE` | — | Value is in **megabytes** (×1024×1024, `broker.go:53`). Read-ahead buffer. `10` = 10 MB. | | Disk sync (s) | int | `5` | ≥ 0 | `broker.disksyncseconds` · `BROKER_DISK_SYNC_SECONDS` | — | Disk flush interval in **seconds**. Lowered from 60 s → 5 s to shrink the data-loss window. | | Slice max messages | int | `0` (unlimited) | ≥ 0 | `broker.slicemaxmessages` · `BROKER_SLICE_MAX_MESSAGES` | — | `0` = no message-count limit per file slice. | | Slice max bytes | int64 (**MB**) | `64` (→ 64 MB) | ≥ 0 | `broker.slicemaxbytes` · `BROKER_SLICE_MAX_BYTES` | — | Per-slice ceiling in **megabytes** (×1024×1024, `broker.go:56`). `64` = 64 MB. | | Slice max age (s) | int | `0` (no rollover) | ≥ 0 | `broker.slicemaxageseconds` · `BROKER_SLICE_MAX_AGE_SECONDS` | — | `0` = no age-based slice rollover. Value in **seconds**. | | Parallel recovery | int | `4` | **≥ 1** | `broker.parallelrecovery` · `BROKER_PARALLEL_RECOVERY` | — | Recovery worker count. Must be **≥ 1** (`0` or negative is rejected). | `broker.writeBufferSize`, `broker.readBufferSize`, and `broker.sliceMaxBytes` are expressed in **megabytes**, not bytes — the raw config value is multiplied by 1024×1024 internally. Set `readBufferSize: 10` for a 10 MB buffer. `broker.maxPayload`, by contrast, is in **bytes**. ## Runtime tuning [#runtime-tuning] Optional Go-runtime tuning through the `tuning.*` block. Every zero value means "auto-detect from the environment." **`TuningConfig` is the one config domain with no env-var bindings at all** — there are **no `TUNING_*` environment variables** (verified: `tuning.go`'s `defaultTuningConfig()` makes no `bindViperEnv` call). These keys are therefore **only settable through a mounted `config.yaml`/TOML file**. There is no Helm/CRD path. For the three fields that wrap standard Go knobs, the Go runtime independently honors the native `GOGC`, `GOMEMLIMIT`, and `GOMAXPROCS` env vars. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | --------------------- | ---- | ---------- | ------------ | ---------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------- | | GC percent | int | `0` (auto) | ≥ 0 | `tuning.gc_percent` · *(no env var)* | — | Config-file-only. Wraps Go GC frequency; use the native `GOGC` env var instead for env-based tuning. | | Memory limit (MB) | int | `0` (auto) | ≥ 0 | `tuning.memory_limit_mb` · *(no env var)* | — | Config-file-only. Wraps `GOMEMLIMIT`; use the native `GOMEMLIMIT` env var for env-based tuning. | | Max procs | int | `0` (auto) | ≥ 0 | `tuning.max_procs` · *(no env var)* | — | Config-file-only. Wraps `GOMAXPROCS`; use the native `GOMAXPROCS` env var for env-based tuning. | | Pipe init buffer (KB) | int | `0` (auto) | ≥ 0 | `tuning.pipe_init_buf_kb` · *(no env var)* | — | Config-file-only. Internal memory-pipe initial buffer size. | | Pipe max buffer (KB) | int | `0` (auto) | ≥ 0 | `tuning.pipe_max_buf_kb` · *(no env var)* | — | Config-file-only. Internal memory-pipe maximum buffer size. | | Allow TLS SHA-1 | bool | `false` | true / false | `tuning.tls_allow_sha1` · *(no env var)* | — | Config-file-only. Permits SHA-1 in TLS cipher suites (legacy interop). | `tuning.*` keys use `snake_case` in `config.yaml` (they carry explicit `mapstructure` tags — `gc_percent`, `memory_limit_mb`, etc.), unlike the dotted-camel keys elsewhere. Because there are **no `TUNING_*` env vars**, a K8s CRD/Helm chart cannot set these today without a custom mounted config file — prefer the native `GOGC` / `GOMEMLIMIT` / `GOMAXPROCS` container env vars for GC/memory/CPU tuning. ## Standalone clustering [#standalone-clustering] Manual standalone clustering for Docker, configured through the `cluster.*` block. Uses the server's standard **`enable: true/false`** toggle. This is Docker / `config.yaml`-only (env `CLUSTER_*`) — there is **no Helm/CRD path**. On Kubernetes, high availability is `spec.replicas` (operator-managed clustering), not `cluster.*`; the operator injects the `CLUSTER_*` env vars into the StatefulSet pod template itself — see [Deployment & High Availability](/configure/reference/deployment). | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | -------------- | ------ | -------- | ------------ | ------------------------------------ | ------------- | -------------------------------------------------------------------------------------------------- | | Enable | bool | `false` | true / false | `cluster.enable` · `CLUSTER_ENABLE` | — | Master toggle. When `false`, all other `cluster.*` fields are ignored (validation short-circuits). | | Cluster name | string | `kubemq` | name string | `cluster.name` · `CLUSTER_NAME` | — | Shared cluster identifier across peers. | | Cluster port | int | `5228` | 1–65535 | `cluster.port` · `CLUSTER_PORT` | — | Peer-to-peer cluster port. Validated only when `enable: true`. | | Cluster routes | string | `""` | route list | `cluster.routes` · `CLUSTER_ROUTES` | — | Addresses of peer nodes to connect to. | | Is seed | bool | `false` | true / false | `cluster.isseed` · `CLUSTER_IS_SEED` | — | Whether this node is a seed node. Struct field is `IsSeed`; config.yaml key is `cluster.isseed`. | The `next` storage engine uses an additional replication membership plane managed by Dragonboat. Its dedicated `Cluster.Replication.*` configuration fields and `POD_NAME`→`ReplicaID` auto-derivation are documented in the [Storage Engines](/configure/reference/storage-engines) reference page. ## Routing [#routing] Channel-routing rules, configured through the `routing.*` block. The server uses the standard **`enable: true/false`** toggle. On the CRD, routing has **no explicit toggle** — the operator auto-emits `ROUTING_ENABLE=true` whenever `spec.routing.data` **or** `spec.routing.url` is set. Three fields map to `spec.routing.*`; `enable` and `filePath` are Docker-only (`—`). | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ---------------- | ------ | --------- | -------------------- | -------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Enable | bool | `false` | true / false | `routing.enable` · `ROUTING_ENABLE` | — | Master toggle (server side). On the CRD it is implicit — set automatically when `data` or `url` is provided. When `false`, the rest of the block is ignored. | | Routing data | string | `""` | inline routing rules | `routing.data` · `ROUTING_DATA` | `spec.routing.data` | Inline routing table. Consumed **raw** (not base64-decoded), so the operator emits it as a plain ConfigMap variable. Takes precedence over `filePath` and `url`. | | Routing URL | string | `""` | URL | `routing.url` · `ROUTING_URL` | `spec.routing.url` | Fetch the routing table from a URL. Validated as a URL when set. Struct field is `URL`; config.yaml key is `routing.url`. Emitted raw. | | Auto-reload (ms) | int | `0` (off) | ≥ 0 | `routing.autoreload` · `ROUTING_AUTO_RELOAD` | `spec.routing.autoReload` | Reload interval in **milliseconds**. `0` disables periodic reload. CRD field is `int32`, `omitempty` — the operator only emits `ROUTING_AUTO_RELOAD` when non-zero (no CRD-side default). | | Routing file | string | `""` | file path | `routing.filepath` · `ROUTING_FILE_PATH` | — | Load rules from a file path. Docker / config.yaml-only — **not** exposed on the CRD (superseded by inline `data`). Validated as a filename when set. | When routing is enabled you must provide **exactly one** source: `data`, `filePath`, or `url` (enabling routing with none set is a configuration error). Precedence when more than one is present: `data` → `filePath` → `url`. `autoReload` must be ≥ 0. ## Example [#example] Set a message-broker buffer. On Docker this is a `config.yaml` key (in **MB**) or a `BROKER_*` env var; on Kubernetes the broker engine has **no Helm/CRD path**, so the operator owns it. This is a single-setting snippet — see the [Docker guide](/configure/docker) for complete, runnable configurations. ```yaml title="config.yaml" broker: readBufferSize: 10 # megabytes → 10 MB ``` ```yaml title="values.yaml" # Not available on Helm/CRD — the message-broker engine is config.yaml-only. # On Kubernetes the operator manages the broker engine. ``` For the full Docker delivery methods (env vars, mounted `config.yaml`, the `CONFIG` variable) see the [Docker guide](/configure/docker). # Connectors (/configure/reference/connectors) KubeMQ ships ten connectors — the **MCP** and **A2A (agents)** agent platforms, **CloudEvents**, and seven wire-protocol connectors: **MQTT**, **AMQP 0.9.1**, **AMQP 1.0**, **STOMP**, **Kafka**, **AWS** (SQS/SNS), and **GCP Pub/Sub**. The three HTTP-server connectors (MCP, A2A, CloudEvents) are **on by default**; the **seven wire-protocol connectors are opt-in (disabled by default)** — each opens a new network port and must be explicitly enabled. Each setting is shown for both targets — Docker single-node (`config.yaml` key · env var) and Kubernetes/Helm (`spec.*` path). A dash (`—`) in the Helm/CRD column means the setting is not available on that surface (it is `config.yaml`/env-var-only — supply it through a mounted config file or a raw pod env var, never a typed CRD field). Connector environment-variable prefixes follow the acronym rule: all-caps acronym segments **drop** the underscore (`CONNECTORSMCP_*`, `CONNECTORSCE_*`, `CONNECTORSMQTT_*`, `CONNECTORSA2_A_*`), while Title-case segments **keep** it (`CONNECTORS_AMQP_*`, `CONNECTORS_AMQP10_*`, `CONNECTORS_STOMP_*`, `CONNECTORS_KAFKA_*`, `CONNECTORS_AWS_*`, `CONNECTORS_GCP_*`). See [the reference legend](/configure/reference) for the full rule and the silently-ignored wrong twin. ## Enabling and disabling a connector [#enabling-and-disabling-a-connector] The toggle shape differs by connector type and by target: * **HTTP-server connectors (MCP, A2A, CloudEvents):** always on by default. Docker turns them **off** with `enable: false`; Kubernetes/Helm turns them off with `disabled: true` (omit the key while the connector is on). * **Wire-protocol connectors (MQTT, AMQP 0.9.1, AMQP 1.0, STOMP, Kafka, AWS, GCP Pub/Sub):** **disabled by default** (opt-in). Docker turns them **on** with `enable: true`; Kubernetes/Helm turns them on with `enabled: true` (a positive-sense field — omitting it leaves the connector off). The snippets below show turning CloudEvents off (HTTP-server connector, uses `disabled:`) and enabling MQTT (wire-protocol connector, uses `enabled:`). See the [Docker guide](/configure/docker) and the [Kubernetes guide](/configure/kubernetes) for complete, runnable configurations. ```yaml title="config.yaml" connectors: ce: enable: false ``` ```yaml title="values.yaml" ce: disabled: true ``` ```bash title="docker run" docker run -e CONNECTORSMQTT_ENABLE=true ... ``` ```yaml title="values.yaml" mqtt: enabled: true ``` ## Service exposure & session affinity [#service-exposure--session-affinity] Every wire connector carries the same Kubernetes exposure surface on the CRD, alongside its protocol settings. These are Kubernetes-only — on Docker you publish a port with `docker run -p`. | Field | Type | Default | Valid values | Helm/CRD path | Notes | | ------------------- | ------------- | ----------- | ----------------------------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | Service exposure | string (enum) | `ClusterIP` | `ClusterIP` / `NodePort` / `LoadBalancer` | `spec..expose` | Type of the connector's `Service`. | | Session affinity | string (enum) | `None` | `None` / `ClientIP` | `spec..sessionAffinity` | Pins a client to one replica. **Required for AWS and GCP** — see the callout below. | | Node port | int32 | *unset* | 30000–32767 | `spec..nodePort` | Honored **only** when `expose: NodePort`. Unset ⇒ the kernel assigns one, which you cannot configure into a client ahead of the install. | | TLS node port | int32 | *unset* | 30000–32767 | `spec..tlsNodePort` | Same, for the connector's TLS listener. | | WebSocket node port | int32 | *unset* | 30000–32767 | `spec.mqtt.wsNodePort` | MQTT only — its WebSocket listener. | Which connector has which: | Connector | `expose` | `sessionAffinity` | `nodePort` | `tlsNodePort` | `wsNodePort` | | ----------- | -------------- | ----------------- | ---------- | ------------- | ------------ | | MQTT | ✅ | ✅ | ✅ | ✅ | ✅ | | AMQP 0.9.1 | ✅ | ✅ | ✅ | ✅ | — | | AMQP 1.0 | ✅ | ✅ | ✅ | ✅ | — | | STOMP | ✅ | ✅ | ✅ | ✅ | — | | Kafka | ✅ | ✅ | ✅ | ✅ | — | | AWS | ✅ | ✅ | ✅ | — | — | | GCP Pub/Sub | ✅ | ✅ | ✅ | — | — | | CloudEvents | — (rides REST) | — | — | — | — | **Session affinity is not optional for the AWS and GCP connectors on a multi-replica cluster.** Both protocols hand the client a token that only the replica that minted it can honor: * **AWS** — an SQS **receipt handle** is bound to the replica that issued it, and it is the only way to delete a message. A delete that lands on another replica is refused, the message reappears at the visibility timeout, **and the queue never drains**. A client holding a single keep-alive connection is pinned by accident and never sees this; it bites when the connection breaks, or when the client does not pool connections. * **GCP Pub/Sub** — an **ack id** minted by one replica and acked on another is refused, with no disruption required to trigger it. Ordered subscriptions additionally need stickiness to preserve per-key ordering. Set `sessionAffinity: ClientIP` on both: ```yaml title="values.yaml" aws: enabled: true expose: NodePort sessionAffinity: ClientIP nodePort: 30171 gcp: enabled: true expose: NodePort sessionAffinity: ClientIP nodePort: 32439 ``` **`ClientIP` affinity is unreliable when clients share a NAT or egress IP** — every client behind it looks like one address and lands on one replica. Where an ingress exists, prefer **cookie-based affinity on the ingress** instead. **AMQP 0.9.1 and AMQP 1.0 share one listener, and one Service.** Both ride `5672`/`5671`; enabling both is supported and both are served through the **`-amqp`** Service. Enabling `amqp10` additionally creates a **`-amqp10`** Service as a discoverability alias onto that same listener. `expose` and `sessionAffinity` are **shared** between the two blocks — last one wins — so set them consistently. The operator raises an `AmqpSessionAffinityConflict` warning event if the two disagree. **Kafka exposure on a multi-replica cluster needs one address per broker.** Kafka hands every client an address **per broker** in its Metadata response, so a single Service — `ClusterIP`, `NodePort`, or `LoadBalancer` alike — is one address that round-robins across all replicas and cannot address a 3-broker cluster. Setting `kafka.expose: LoadBalancer` on a multi-replica cluster does **not** give you working external Kafka. * **In-cluster, any replica count:** leave `spec.kafka.advertisedHost` unset. The operator derives per-broker addresses from the pods' stable DNS names and gives each pod its own advertised host. Nothing to configure. * **External, single replica:** `expose` + `advertisedHost` works. * **External, multi-replica:** you must provision one client-reachable address per broker — a Service or LoadBalancer per pod, or a per-pod NodePort — and list them in `spec.kafka.peers`. The operator does not create per-broker addressing for you. Leave `advertisedHost` unset in this case: with a peer map each broker advertises itself from its own entry, and one `advertisedHost` could only ever be right for one of them. **CloudEvents has no port or Service of its own.** It rides the server's shared HTTP listener alongside REST, and is reached and exposed through **`spec.rest.expose` / `spec.rest.nodePort`**. There is deliberately no `spec.ce.expose`. ## MCP [#mcp] The Model Context Protocol agent platform, served on the shared HTTP server. Env prefix `CONNECTORSMCP_*` (all-caps `MCP` collapses the underscore after `CONNECTORS`); CRD group `spec.mcp.*`. This is an **HTTP-family connector — on by default** (opt-out): Docker turns it off with `enable: false`, Kubernetes/Helm with `spec.mcp.disabled: true`. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ---------------- | --------- | ----------- | ---------------------- | -------------------------------------------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------ | | Enable / disable | bool | `true` (on) | true / false | `connectors.mcp.enable` · `CONNECTORSMCP_ENABLE` | `spec.mcp.disabled` | Inverted boolean: Docker `enable: false` turns it off; Helm `disabled: true` turns it off. | | Tool timeout (s) | int | `300` | `> 0` | `connectors.mcp.tooltimeoutseconds` · `CONNECTORSMCP_TOOL_TIMEOUT_SECONDS` | `spec.mcp.toolTimeoutSeconds` | Must be positive (rejected if ≤ 0). CRD minimum 1. | | Trusted origins | string\[] | `["auto"]` | origin list / `"auto"` | `connectors.mcp.trustedorigins` · `CONNECTORSMCP_TRUSTED_ORIGINS` | `spec.mcp.trustedOrigins` | `auto` derives allowed origins from the request host. | **The MCP env prefix is `CONNECTORSMCP_` — no underscore between `CONNECTORS` and `MCP`.** The natural `CONNECTORS_MCP_*` form does **not** bind — the server starts, accepts the variable without error, and silently ignores it. Unlike CloudEvents, MCP has no natural-name alias, so `CONNECTORSMCP_ENABLE` / `CONNECTORSMCP_TOOL_TIMEOUT_SECONDS` / `CONNECTORSMCP_TRUSTED_ORIGINS` are the only working names. ## A2A (Agents) [#a2a-agents] The agent-to-agent platform, served on the shared HTTP server. Env prefix `CONNECTORSA2_A_*`; the CRD group is `spec.agents.*` (note the group name differs from the connector name). This is an **HTTP-family connector — on by default** (opt-out): Docker turns it off with `enable: false`, Kubernetes/Helm with `spec.agents.disabled: true`. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | -------------------------- | --------- | ------------------ | ------------------------ | ---------------------------------------------------------------------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | Enable / disable | bool | `true` (on) | true / false | `connectors.a2a.enable` · `CONNECTORSA2_A_ENABLE` | `spec.agents.disabled` | Inverted boolean. | | Agent TTL (s) | int | `300` | `> 0` | `connectors.a2a.agentttlseconds` · `CONNECTORSA2_A_AGENT_TTL_SECONDS` | `spec.agents.agentTtlSeconds` | Must be positive. CRD json tag is `agentTtlSeconds` (lowercase `tl`). | | Default timeout (s) | int | `300` | `> 0` | `connectors.a2a.defaulttimeoutseconds` · `CONNECTORSA2_A_DEFAULT_TIMEOUT_SECONDS` | `spec.agents.defaultTimeoutSeconds` | Must be positive. | | Max timeout (s) | int | `3600` | `≥ default timeout` | `connectors.a2a.maxtimeoutseconds` · `CONNECTORSA2_A_MAX_TIMEOUT_SECONDS` | `spec.agents.maxTimeoutSeconds` | Cross-field: must be ≥ Default timeout (enforced server-side, not by the CRD schema). | | Max agents | int | `0` | `≥ 0` (`0` = unlimited) | `connectors.a2a.maxagents` · `CONNECTORSA2_A_MAX_AGENTS` | `spec.agents.maxAgents` | `0` = unlimited. | | Max SSE idle (s) | int | `300` | `> 0` | `connectors.a2a.maxsseidleseconds` · `CONNECTORSA2_A_MAX_SSE_IDLE_SECONDS` | `spec.agents.maxSseIdleSeconds` | Must be positive. CRD json tag is `maxSseIdleSeconds`. | | Trusted origins | string\[] | `["auto"]` | origin list / `"auto"` | `connectors.a2a.trustedorigins` · `CONNECTORSA2_A_TRUSTED_ORIGINS` | `spec.agents.trustedOrigins` | | | Agent max response (bytes) | int64 | `10485760` (10 MB) | `≥ 0` | `connectors.a2a.agentmaxresponsebytes` · `CONNECTORSA2_A_AGENT_MAX_RESPONSE_BYTES` | `spec.agents.agentMaxResponseBytes` | Caps a downstream agent's response body. | | Agent TLS skip verify | bool | `false` | true / false | `connectors.a2a.agenttlsskipverify` · `CONNECTORSA2_A_AGENT_TLS_SKIP_VERIFY` | `spec.agents.agentTlsSkipVerify` | CRD json tag is `agentTlsSkipVerify`. | | Agent max concurrency | int | `100` | any int (`≤ 0` accepted) | `connectors.a2a.agentmaxconcurrency` · `CONNECTORSA2_A_AGENT_MAX_CONCURRENCY` | `spec.agents.agentMaxConcurrency` | `≤ 0` is **accepted** and silently clamped back to `100` — it does not error, and there is no output saying it was rewritten. | | Metrics retention (h) | int | `168` (7 days) | `> 0` | `connectors.a2a.metricsretentionhours` · `CONNECTORSA2_A_METRICS_RETENTION_HOURS` | `spec.agents.metricsRetentionHours` | Must be positive. Retention window for per-agent metrics. | **The A2A env prefix is `CONNECTORSA2_A_` — not `CONNECTORS_A2A_`.** The `convertEnvFormat` rule splits `A2A` into `A2_A` (the regex breaks between the digit and the trailing `A`), so the agent variables read `CONNECTORSA2_A_MAX_AGENTS`, `CONNECTORSA2_A_AGENT_TTL_SECONDS`, and so on. Neither `CONNECTORS_A2A_*` nor `CONNECTORSA2A_*` binds — both are silently ignored. Note also that the CRD group is `spec.agents.*`, not `spec.a2a.*`. ## CloudEvents [#cloudevents] The CloudEvents connector, served on the shared HTTP server. Env prefix `CONNECTORSCE_*`; CRD group `spec.ce.*`. This is an **HTTP-family connector — on by default** (opt-out): Docker turns it off with `enable: false`, Kubernetes/Helm with `spec.ce.disabled: true`. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ------------------- | ---- | ----------- | ----------------------- | ----------------------------------------------------------------------- | --------------------------- | ------------------------------------------------------ | | Enable / disable | bool | `true` (on) | true / false | `connectors.ce.enable` · `CONNECTORSCE_ENABLE` | `spec.ce.disabled` | Inverted boolean. | | Timeout (s) | int | `60` | `> 0` | `connectors.ce.timeoutseconds` · `CONNECTORSCE_TIMEOUT_SECONDS` | `spec.ce.timeoutSeconds` | Must be positive. | | Sub buffer size | int | `100` | `1`–`10000` | `connectors.ce.subbuffsize` · `CONNECTORSCE_SUB_BUFF_SIZE` | `spec.ce.subBuffSize` | Rejected if ≤ 0 or > 10000. | | Max SSE idle (s) | int | `300` | `> 0` | `connectors.ce.maxsseidleseconds` · `CONNECTORSCE_MAX_SSE_IDLE_SECONDS` | `spec.ce.maxSseIdleSeconds` | Must be positive. CRD json tag is `maxSseIdleSeconds`. | | Max SSE connections | int | `0` | `≥ 0` (`0` = unlimited) | `connectors.ce.maxsseconnections` · `CONNECTORSCE_MAX_SSE_CONNECTIONS` | `spec.ce.maxSseConnections` | `0` = unlimited. CRD json tag is `maxSseConnections`. | **CloudEvents accepts both env forms.** The primary name is the collapsed `CONNECTORSCE_*` (e.g. `CONNECTORSCE_ENABLE`), but CE is the one connector that also binds the natural `CONNECTORS_CE_*` alias (`CONNECTORS_CE_ENABLE`, `CONNECTORS_CE_TIMEOUT_SECONDS`, …). Both resolve to the same setting — this compensating alias exists only for CloudEvents; MCP, A2A, and MQTT do **not** have it. **But the server warns that the alias is IGNORED, and that warning is wrong.** The unknown-variable warner does not track the alias binding, so a `CONNECTORS_CE_*` variable is reported as ignored while its value is being applied. Meanwhile a typo in the *collapsed* form (`CONNECTORSC_ENABLE`) produces no warning at all, because collapsed names sit outside the warner's namespace list. See [the reference legend](/configure/reference) for the full picture. ## MQTT [#mqtt] The MQTT 3.1.1 / 5.0 wire protocol. Env prefix `CONNECTORSMQTT_*` (no underscore after `CONNECTORS`); CRD group `spec.mqtt.*`. This is a **wire-protocol connector — opt-in (disabled by default)**: Docker turns it on with `enable: true`, Kubernetes/Helm with `spec.mqtt.enabled: true` (a positive-sense `*bool` — omitting it leaves MQTT off). Ports are `string` server-side (`""` disables a listener) and `int32` on the CRD. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ------------------------------------------- | ------ | -------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | Enable / disable | bool | **`false` (opt-in)** | true / false | `connectors.mqtt.enable` · `CONNECTORSMQTT_ENABLE` | `spec.mqtt.enabled` | Opt-in: set `true` to open ports 1883/8883/8083. Helm uses positive-sense `enabled: true`. | | Port | string | `1883` | port / `""` | `connectors.mqtt.port` · `CONNECTORSMQTT_PORT` | `spec.mqtt.port` | Plaintext TCP listener; `""` disables it. CRD type int32 (1–65535). | | TLS port | string | `8883` | port / `""` | `connectors.mqtt.tlsport` · `CONNECTORSMQTT_TLS_PORT` | `spec.mqtt.tlsPort` | TLS listener; **active only when Security mode ≠ None**. `""` disables. CRD int32 (1–65535). | | WebSocket port | string | `8083` | port / `""` | `connectors.mqtt.wsport` · `CONNECTORSMQTT_WS_PORT` | `spec.mqtt.wsPort` | WebSocket listener; `""` disables. CRD int32 (1–65535). | | Default pattern | enum | `events` | `events` / `store` / `none` | `connectors.mqtt.defaultpattern` · `CONNECTORSMQTT_DEFAULT_PATTERN` | `spec.mqtt.defaultPattern` | KubeMQ pattern for prefixless topics. | | Sub buffer size | int | `100` | `1`–`10000` | `connectors.mqtt.subbuffsize` · `CONNECTORSMQTT_SUB_BUFF_SIZE` | `spec.mqtt.subBuffSize` | Rejected if ≤ 0 or > 10000. | | Queue ACK timeout (s) | int | `30` | `> 0` | `connectors.mqtt.queueacktimeoutseconds` · `CONNECTORSMQTT_QUEUE_ACK_TIMEOUT_SECONDS` | `spec.mqtt.queueAckTimeoutSeconds` | Must be positive. | | RPC timeout (s) | int | `30` | `> 0` | `connectors.mqtt.rpctimeoutseconds` · `CONNECTORSMQTT_RPC_TIMEOUT_SECONDS` | `spec.mqtt.rpcTimeoutSeconds` | Must be positive. | | RPC max pending | int | `1024` | `> 0` | `connectors.mqtt.rpcmaxpending` · `CONNECTORSMQTT_RPC_MAX_PENDING` | `spec.mqtt.rpcMaxPending` | Must be positive. | | Detail history enabled | bool | `true` | true / false | `connectors.mqtt.detailhistoryenabled` · `CONNECTORSMQTT_DETAIL_HISTORY_ENABLED` | `spec.mqtt.detailHistoryEnabled` | Master switch for per-entity (client/subscription) detail-page history recording. | | Detail history max entities | int | `5000` | `≥ 0` (`0` = unbounded) | `connectors.mqtt.detailhistorymaxentities` · `CONNECTORSMQTT_DETAIL_HISTORY_MAX_ENTITIES` | `spec.mqtt.detailHistoryMaxEntities` | Caps tracked per-entity history keys; new keys refused beyond it. Validated even when history is off. | | Capabilities · max clients | int64 | `0` | `≥ 0` (`0` = unlimited) | `connectors.mqtt.capabilities.maxclients` · `CONNECTORSMQTT_CAPABILITIES_MAX_CLIENTS` | `spec.mqtt.capabilities.maxClients` | `0` = unlimited (explicit opt-in; startup logs a WARN). Never clamped. | | Capabilities · max packet size (bytes) | uint32 | `4194304` (4 MB) | `0` or `1`–`4294967295` | `connectors.mqtt.capabilities.maxpacketsizebytes` · `CONNECTORSMQTT_CAPABILITIES_MAX_PACKET_SIZE_BYTES` | `spec.mqtt.capabilities.maxPacketSizeBytes` | A `0` is clamped back to 4 MB (0 = "unlimited" is a DoS footgun); effective value logged at startup. | | Capabilities · receive maximum | uint16 | `1024` | `0` or `1`–`65535` | `connectors.mqtt.capabilities.receivemaximum` · `CONNECTORSMQTT_CAPABILITIES_RECEIVE_MAXIMUM` | `spec.mqtt.capabilities.receiveMaximum` | A `0` is clamped back to 1024. | | Capabilities · max inflight | uint16 | `8192` | `0`–`65535` | `connectors.mqtt.capabilities.maxinflight` · `CONNECTORSMQTT_CAPABILITIES_MAX_INFLIGHT` | `spec.mqtt.capabilities.maxInflight` | Not clamped. | | Capabilities · max session expiry (s) | uint32 | `3600` | `0` or `1`–`4294967295` | `connectors.mqtt.capabilities.maxsessionexpiryseconds` · `CONNECTORSMQTT_CAPABILITIES_MAX_SESSION_EXPIRY_SECONDS` | `spec.mqtt.capabilities.maxSessionExpirySeconds` | A `0` is clamped back to 3600. | | Capabilities · max message expiry (s) | int64 | `86400` | `≥ 0` | `connectors.mqtt.capabilities.maxmessageexpiryseconds` · `CONNECTORSMQTT_CAPABILITIES_MAX_MESSAGE_EXPIRY_SECONDS` | `spec.mqtt.capabilities.maxMessageExpirySeconds` | Rejected if negative. | | Capabilities · max QoS | byte | `2` | `0`–`2` | `connectors.mqtt.capabilities.maxqos` · `CONNECTORSMQTT_CAPABILITIES_MAX_QOS` | `spec.mqtt.capabilities.maxQos` | Rejected if > 2. | | Capabilities · min protocol version | byte | `4` | `4` / `5` | `connectors.mqtt.capabilities.minprotocolversion` · `CONNECTORSMQTT_CAPABILITIES_MIN_PROTOCOL_VERSION` | `spec.mqtt.capabilities.minProtocolVersion` | `4` = MQTT 3.1.1, `5` = MQTT 5.0. | | Capabilities · max subscriptions per client | int | `1000` | `≥ 0` (`0` = unlimited) | `connectors.mqtt.capabilities.maxsubscriptionsperclient` · `CONNECTORSMQTT_CAPABILITIES_MAX_SUBSCRIPTIONS_PER_CLIENT` | `spec.mqtt.capabilities.maxSubscriptionsPerClient` | Caps distinct subscription filters per client; excess SUBSCRIBEs get SUBACK 0x97 (DoS cap). `0` = unlimited. | **The MQTT env prefix is `CONNECTORSMQTT_` — no underscore between `CONNECTORS` and `MQTT`.** The natural `CONNECTORS_MQTT_*` form does **not** bind and is silently ignored. This applies to every MQTT variable, including the nested `CONNECTORSMQTT_CAPABILITIES_*` keys. Unlike CloudEvents, MQTT has no natural-name alias. **Some MQTT capabilities are forced, not configurable.** At server construction the connector pins `RetainAvailable = 0` (retained messages rejected), `SharedSubAvailable = 1`, and `WildcardSubAvailable = 1` regardless of config. A partially-specified `capabilities` block leaves unset safety caps (`maxPacketSizeBytes`, `receiveMaximum`, `maxSessionExpirySeconds`) at `0`, which the server clamps back to their safe defaults rather than treating as "unlimited" — the effective caps are logged at startup. At least one listener port (`port`, `tlsPort`, or `wsPort`) must be non-empty, and no two may share the same value. ## AMQP 0.9.1 [#amqp-091] The AMQP 0.9.1 / RabbitMQ wire protocol. Env prefix `CONNECTORS_AMQP_*`; CRD group `spec.amqp.*`. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | --------------------- | ------ | -------------------- | --------------------------------------------------- | ---------------------------------------------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Enable | bool | **`false` (opt-in)** | true / false | `connectors.amqp.enable` · `CONNECTORS_AMQP_ENABLE` | `spec.amqp.enabled` | Opt-in wire connector: Docker `enable: true`, Helm `enabled: true` (positive-sense). Opens ports 5672/5671 (shared mux with AMQP 1.0). | | Port | int | `5672` | `0`–`65535` (`0` disables) | `connectors.amqp.port` · `CONNECTORS_AMQP_PORT` | `spec.amqp.port` | Plaintext listener, shared with AMQP 1.0. `Port` and `TlsPort` cannot both be `0` when enabled. CRD accepts `1`–`65535`. | | TLS port | int | `5671` | `0`–`65535` (`0` disables) | `connectors.amqp.tlsport` · `CONNECTORS_AMQP_TLS_PORT` | `spec.amqp.tlsPort` | TLS listener. | | Heartbeat (s) | int | `60` | ≥ `0` | `connectors.amqp.heartbeatseconds` · `CONNECTORS_AMQP_HEARTBEAT_SECONDS` | `spec.amqp.heartbeatSeconds` | | | Frame max (bytes) | int | `131072` | ≥ `4096` (accepted; > 512 MiB is clamped down) | `connectors.amqp.framemax` · `CONNECTORS_AMQP_FRAME_MAX` | `spec.amqp.frameMax` | Silently **clamped down to 536870912 (512 MiB)** if set higher (a stderr warning is emitted); a value ≥ 2³² would otherwise narrow to `0` and disable the codec frame-size cap. | | Channel max | int | `2047` | `1`–`65535` | `connectors.amqp.channelmax` · `CONNECTORS_AMQP_CHANNEL_MAX` | `spec.amqp.channelMax` | | | Max connections | int | `1000` | ≥ `0` (`0` = unlimited) | `connectors.amqp.maxconnections` · `CONNECTORS_AMQP_MAX_CONNECTIONS` | `spec.amqp.maxConnections` | `0` = unlimited. | | Max body size (bytes) | int | `104857600` | > `0` | `connectors.amqp.maxbodysize` · `CONNECTORS_AMQP_MAX_BODY_SIZE` | `spec.amqp.maxBodySize` | | | Default vhost | string | `default` | non-empty; no whitespace or `;:*>`; no trailing `.` | `connectors.amqp.defaultvhost` · `CONNECTORS_AMQP_DEFAULT_VHOST` | `spec.amqp.defaultVhost` | Becomes a channel segment, so it must pass the channel-charset rules. | | Get batch size | int | `32` | `1`–`1024` | `connectors.amqp.getbatchsize` · `CONNECTORS_AMQP_GET_BATCH_SIZE` | `spec.amqp.getBatchSize` | Must also be ≤ `queue.maxNumberOfMessages` (cross-checked in top-level config validation). | | Dead-letter max hops | int | `16` | ≥ `1` | `connectors.amqp.deadlettermaxhops` · `CONNECTORS_AMQP_DEAD_LETTER_MAX_HOPS` | `spec.amqp.deadLetterMaxHops` | | | Max receive count | int | `0` | ≥ `0` (`0` = unlimited) | `connectors.amqp.maxreceivecount` · `CONNECTORS_AMQP_MAX_RECEIVE_COUNT` | `spec.amqp.maxReceiveCount` | `0` = unlimited. Must also be ≤ `queue.maxReceiveCount` (cross-checked in top-level config validation). | **Two cross-checks against the queue limits.** The server rejects the config unless `amqp.maxReceiveCount ≤ queue.maxReceiveCount` and `amqp.getBatchSize ≤ queue.maxNumberOfMessages`. Keep the AMQP values within the queue ceilings (see [Storage & Queues](/configure/reference/storage-queues)). ## AMQP 1.0 [#amqp-10] The AMQP 1.0 wire protocol (also the JMS / Qpid path). Env prefix `CONNECTORS_AMQP10_*`; CRD group `spec.amqp10.*`. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ------------------------ | ----- | -------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | Enable | bool | **`false` (opt-in)** | true / false | `connectors.amqp10.enable` · `CONNECTORS_AMQP10_ENABLE` | `spec.amqp10.enabled` | Opt-in wire connector. Opens ports 5672/5671 (shared mux with AMQP 0.9.1). | | Port | int | `5672` | `0`–`65535` (`0` disables) | `connectors.amqp10.port` · `CONNECTORS_AMQP10_PORT` | `spec.amqp10.port` | Shared with AMQP 0.9.1 — a `Port == Amqp.Port` collision is intentionally allowed (the mux dedupes the bind). `Port` and `TlsPort` cannot both be `0`. | | TLS port | int | `5671` | `0`–`65535` (`0` disables) | `connectors.amqp10.tlsport` · `CONNECTORS_AMQP10_TLS_PORT` | `spec.amqp10.tlsPort` | | | Max frame size (bytes) | int | `131072` | ≥ `512` | `connectors.amqp10.maxframesize` · `CONNECTORS_AMQP10_MAX_FRAME_SIZE` | `spec.amqp10.maxFrameSize` | Spec floor 512. | | Max message size (bytes) | int64 | `104857600` | > `0` | `connectors.amqp10.maxmessagesize` · `CONNECTORS_AMQP10_MAX_MESSAGE_SIZE` | `spec.amqp10.maxMessageSize` | `int64`; matches the AMQP 0.9.1 `maxBodySize` default (100 MB). | | Session max | int | `256` | `1`–`65535` | `connectors.amqp10.sessionmax` · `CONNECTORS_AMQP10_SESSION_MAX` | `spec.amqp10.sessionMax` | | | Max links per session | int | `256` | ≥ `1` | `connectors.amqp10.maxlinkspersession` · `CONNECTORS_AMQP10_MAX_LINKS_PER_SESSION` | `spec.amqp10.maxLinksPerSession` | | | Max connections | int | `1000` | ≥ `0` (`0` = unlimited) | `connectors.amqp10.maxconnections` · `CONNECTORS_AMQP10_MAX_CONNECTIONS` | `spec.amqp10.maxConnections` | `0` = unlimited. | | Idle timeout (s) | int | `120` | ≥ `0` (`0` = disabled) | `connectors.amqp10.idletimeoutseconds` · `CONNECTORS_AMQP10_IDLE_TIMEOUT_SECONDS` | `spec.amqp10.idleTimeoutSeconds` | `0` = disabled. | | Default pattern | enum | `queues` | `queues` / `events` / `events-store` / `commands` / `queries` | `connectors.amqp10.defaultpattern` · `CONNECTORS_AMQP10_DEFAULT_PATTERN` | `spec.amqp10.defaultPattern` | KubeMQ pattern mapped from AMQP addresses. | | Get batch size | int | `32` | `1`–`1024` | `connectors.amqp10.getbatchsize` · `CONNECTORS_AMQP10_GET_BATCH_SIZE` | `spec.amqp10.getBatchSize` | | | Max unsettled per link | int | `1024` | ≥ `1` | `connectors.amqp10.maxunsettledperlink` · `CONNECTORS_AMQP10_MAX_UNSETTLED_PER_LINK` | `spec.amqp10.maxUnsettledPerLink` | | | Default RPC timeout (s) | int | `30` | ≥ `1` | `connectors.amqp10.defaultrpctimeoutseconds` · `CONNECTORS_AMQP10_DEFAULT_RPC_TIMEOUT_SECONDS` | `spec.amqp10.defaultRpcTimeoutSeconds` | | | RPC max pending | int | `512` | ≥ `1` | `connectors.amqp10.rpcmaxpending` · `CONNECTORS_AMQP10_RPC_MAX_PENDING` | `spec.amqp10.rpcMaxPending` | | **AMQP 1.0 shares port `5672` (and TLS `5671`) with AMQP 0.9.1.** The two protocols are demultiplexed on a single shared listener, so running both on the default ports is fine. If you set a **different** port for one, keep the pair consistent so clients reach the listener you intend. ## STOMP [#stomp] The STOMP 1.0 / 1.1 / 1.2 wire protocol. Env prefix `CONNECTORS_STOMP_*`; CRD group `spec.stomp.*`. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | --------------------- | ------ | -------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | Enable | bool | **`false` (opt-in)** | true / false | `connectors.stomp.enable` · `CONNECTORS_STOMP_ENABLE` | `spec.stomp.enabled` | Opt-in wire connector. Opens ports 61613/61614. | | Port | string | `61613` | `""` (disabled) or `1`–`65535` | `connectors.stomp.port` · `CONNECTORS_STOMP_PORT` | `spec.stomp.port` | Plaintext listener. String on the server (`""` disables); the CRD takes an `int32` (`1`–`65535`). `Port` must differ from `TlsPort`. | | TLS port | string | `61614` | `""` (disabled) or `1`–`65535` | `connectors.stomp.tlsport` · `CONNECTORS_STOMP_TLS_PORT` | `spec.stomp.tlsPort` | TLS listener. At least one of `Port`/`TlsPort` must be set. | | Default pattern | enum | `events` | `events` / `queues` / `store` / `none` | `connectors.stomp.defaultpattern` · `CONNECTORS_STOMP_DEFAULT_PATTERN` | `spec.stomp.defaultPattern` | KubeMQ pattern for bare (prefixless) destinations. | | Sub buffer size | int | `100` | `1`–`10000` | `connectors.stomp.subbuffsize` · `CONNECTORS_STOMP_SUB_BUFF_SIZE` | `spec.stomp.subBuffSize` | Events deliver-channel buffer. | | Max connections | int | `1000` | ≥ `0` (`0` = unlimited) | `connectors.stomp.maxconnections` · `CONNECTORS_STOMP_MAX_CONNECTIONS` | `spec.stomp.maxConnections` | `0` = unlimited. | | Max body size (bytes) | int | `104857600` | > `0` | `connectors.stomp.maxbodysize` · `CONNECTORS_STOMP_MAX_BODY_SIZE` | `spec.stomp.maxBodySize` | | | Heartbeat (ms) | int | `10000` | ≥ `0` (`0` = disabled) | `connectors.stomp.heartbeatms` · `CONNECTORS_STOMP_HEARTBEAT_MS` | `spec.stomp.heartbeatMs` | Advertised sx,sy; `0` disables the server side. | | Queue ACK timeout (s) | int | `30` | > `0` | `connectors.stomp.queueacktimeoutseconds` · `CONNECTORS_STOMP_QUEUE_ACK_TIMEOUT_SECONDS` | `spec.stomp.queueAckTimeoutSeconds` | | | RPC timeout (s) | int | `30` | > `0` (accepted; > `2147483` is clamped down) | `connectors.stomp.rpctimeoutseconds` · `CONNECTORS_STOMP_RPC_TIMEOUT_SECONDS` | `spec.stomp.rpcTimeoutSeconds` | Silently **clamped down to `2147483`** (\~24.8 days) if set higher, so `timeout × 1000` cannot overflow the `int32` RPC-bridge deadline. | | RPC max pending | int | `1024` | > `0` | `connectors.stomp.rpcmaxpending` · `CONNECTORS_STOMP_RPC_MAX_PENDING` | `spec.stomp.rpcMaxPending` | In-flight RPC cap. | ## Kafka [#kafka] The embedded **Kafka drop-in connector** — KubeMQ speaks the native Kafka wire protocol, so real `librdkafka`/`kcat`/Java clients connect unchanged. Env prefix `CONNECTORS_KAFKA_*`; CRD group `spec.kafka.*`. As of v3.1 the connector is compiled into the **default build** (no build tag) and gated purely at runtime by `Enable`. Eight fields are exposed as typed CRD fields (`spec.kafka.*`) — including the Service-exposure type; the remaining advanced knobs — fourteen scalars **plus the six-field OAUTHBEARER block** — are **`config.yaml`/env-var-only** (Helm/CRD path `—`) and were deliberately deferred from the CRD in v3.1 — set them via a mounted config file or a raw pod env var. The SASL credential store is **secret/file-only** (no env var, no CRD field). See the [Kafka connector overview](/connectors/kafka) and the [migration guide](/connectors/kafka/how-to/migrate-from-kafka) for adoption planning, fitness assessment, and cutover tooling beyond this config reference. **Kafka requires the `next` storage engine — but it's zero-config.** On a **fresh** store with Kafka enabled and `Store.Engine` unset, the server **auto-selects `next`** (a `NOTICE` is logged) — no manual `store.engine=next` step needed. It fails closed only when the store directory already holds `legacy` data (a config error naming the conflicting directory) or `Store.Engine=legacy` is set explicitly alongside Kafka. Pinning `STORE_ENGINE=next` skips the probe entirely and always wins. See [Storage Engines](/configure/reference/storage-engines#zero-config-engine-selection). ### Core (CRD-exposed) settings [#core-crd-exposed-settings] | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ----------------- | ------------- | -------------------- | ----------------------------------------- | ------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Enable | bool | **`false` (opt-in)** | true / false | `connectors.kafka.enable` · `CONNECTORS_KAFKA_ENABLE` | `spec.kafka.enabled` | Opt-in wire connector. Opens ports 9092/9093. | | Port | string | `9092` | `""` (disabled) or `1`–`65535` | `connectors.kafka.port` · `CONNECTORS_KAFKA_PORT` | `spec.kafka.port` | Plaintext listener. **A TLS-only config (`TlsPort` set, `Port` empty) is rejected** — the TLS accept path is not yet wired, so `Port` is currently required. `Port` must differ from `TlsPort`. | | TLS port | string | `9093` | `""` (disabled) or `1`–`65535` | `connectors.kafka.tlsport` · `CONNECTORS_KAFKA_TLS_PORT` | `spec.kafka.tlsPort` | Reserved TLS listener (see the TLS-only note above). | | Advertised host | string | `""` | hostname / IP | `connectors.kafka.advertisedhost` · `CONNECTORS_KAFKA_ADVERTISED_HOST` | `spec.kafka.advertisedHost` | The single broker address handed to every client in Metadata/FindCoordinator. **Set it** to the external LoadBalancer DNS / NodePort IP (or the in-cluster Service DNS) — leaving it `""` falls back to the server `Host`, then the pod hostname, which is unreachable off-pod (connect-then-hang). The TLS cert SAN must include this value. | | Advertised port | int | `0` | `0`–`65535` (`0` = use `Port`) | `connectors.kafka.advertisedport` · `CONNECTORS_KAFKA_ADVERTISED_PORT` | `spec.kafka.advertisedPort` | The external LB/NodePort port. `0` = fall back to `Port` on the config.yaml/env path. **The CRD schema is stricter than the server**: `spec.kafka.advertisedPort` enforces `minimum: 1`, so `0` is rejected on the typed CRD field even though the server itself accepts it. | | Max connections | int | `1000` | ≥ `0` (`0` = unlimited) | `connectors.kafka.maxconnections` · `CONNECTORS_KAFKA_MAX_CONNECTIONS` | `spec.kafka.maxConnections` | `0` = unlimited. | | Max message bytes | int | `1048576` | `1`–`1073741824` (1 GiB) | `connectors.kafka.maxmessagebytes` · `CONNECTORS_KAFKA_MAX_MESSAGE_BYTES` | `spec.kafka.maxMessageBytes` | Per-message cap (1 MiB default). Hard ceiling 1 GiB — Kafka frames are `int32`-length-prefixed, so a larger value would truncate. | | Service exposure | string (enum) | `ClusterIP` | `ClusterIP` / `NodePort` / `LoadBalancer` | — (Docker: `-p` host port mapping) | `spec.kafka.expose` | Kubernetes Service type for the Kafka listener. Kafka also takes `sessionAffinity`, `nodePort`, and `tlsNodePort` — see [Service exposure & session affinity](#service-exposure--session-affinity), including the **multi-replica addressing** limit. | ### Advanced settings (config.yaml / env-var only — no CRD field) [#advanced-settings-configyaml--env-var-only--no-crd-field] These are on the `configData` allowlist (deferred from the CRD in v3.1). Every one has a working env var but **no `spec.kafka.*` path** — mount them via config file or a raw pod env var. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | -------------------------------- | --------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Max fetch wait (ms) | int | `1000` | ≤ `300000` (5 min) | `connectors.kafka.maxfetchwaitms` · `CONNECTORS_KAFKA_MAX_FETCH_WAIT_MS` | — | Ceiling a client's `fetch.max.wait.ms` is clamped to. `≤ 0` falls back to the 1 s default; an over-ceiling value is rejected. | | Timestamp type | string | `CreateTime` | `""` / `CreateTime` | `connectors.kafka.timestamptype` · `CONNECTORS_KAFKA_TIMESTAMP_TYPE` | — | `LogAppendTime` is **rejected** (deferred); any other value is rejected. | | Offsets retention (min) | int | `10080` (7 days) | ≤ `52560000` (100 yr) | `connectors.kafka.offsetsretentionminutes` · `CONNECTORS_KAFKA_OFFSETS_RETENTION_MINUTES` | — | Committed-offset expiry. `≤ 0` is floored to the default (no "retain forever"); over-ceiling rejected. | | Max groups | int | `10000` | ≤ `10000000` | `connectors.kafka.maxgroups` · `CONNECTORS_KAFKA_MAX_GROUPS` | — | Coordinator-wide consumer-group registry cap. `≤ 0` floored to default; over-ceiling rejected. | | Max topics per request | int | `10000` | ≤ `1000000` | `connectors.kafka.maxtopicsperrequest` · `CONNECTORS_KAFKA_MAX_TOPICS_PER_REQUEST` | — | Per-request distinct-topic cap (DoS fan-out guard). `≤ 0` floored; over-ceiling rejected. | | Max partitions per request | int | `100000` | ≤ `10000000` | `connectors.kafka.maxpartitionsperrequest` · `CONNECTORS_KAFKA_MAX_PARTITIONS_PER_REQUEST` | — | Per-request distinct-partition cap. `≤ 0` floored; over-ceiling rejected. | | SCRAM iterations | int | `4096` | ≤ `1000000` | `connectors.kafka.scramiterations` · `CONNECTORS_KAFKA_SCRAM_ITERATIONS` | — | PBKDF2 iteration count for the SCRAM verifier. `≤ 0` floored to 4096 (RFC-7677 minimum); over-ceiling rejected. | | SASL mechanisms | string\[] | `[]` (offer `PLAIN`, `SCRAM-SHA-256`, `SCRAM-SHA-512`) | subset of `PLAIN`, `SCRAM-SHA-256`, `SCRAM-SHA-512`, `OAUTHBEARER` (empty = offer the first three only) | `connectors.kafka.saslmechanisms` · `CONNECTORS_KAFKA_SASL_MECHANISMS` | — | Empty = offer the first three (`PLAIN`, `SCRAM-SHA-256`, `SCRAM-SHA-512`). `OAUTHBEARER` is **never** part of the implicit default — it must be listed explicitly, and doing so requires `OAuthBearer.Issuer` set (see OAUTHBEARER authentication below). A non-empty allow-list restricts what `SaslHandshake` offers (e.g. SCRAM-only, dropping cleartext PLAIN); an unknown entry is rejected. | | Produce byte rate | int | `0` (unlimited) | ≤ `1099511627776` (1 TiB/s) | `connectors.kafka.producebyterate` · `CONNECTORS_KAFKA_PRODUCE_BYTE_RATE` | — | Per-principal produce quota (bytes/s). `0` = unlimited (`ThrottleMillis=0`). `< 0` floored to 0; over-ceiling rejected. | | Fetch byte rate | int | `0` (unlimited) | ≤ `1099511627776` (1 TiB/s) | `connectors.kafka.fetchbyterate` · `CONNECTORS_KAFKA_FETCH_BYTE_RATE` | — | Fetch-direction twin of the produce quota. | | Max transaction timeout (ms) | int | `900000` (15 min) | ≤ `86400000` (24 h) | `connectors.kafka.maxtransactiontimeoutms` · `CONNECTORS_KAFKA_MAX_TRANSACTION_TIMEOUT_MS` | — | Ceiling on the `transaction.timeout.ms` a client negotiates. `≤ 0` floored; over-ceiling rejected. | | Transactional ID expiration (ms) | int64 | `604800000` (7 days) | ≤ `3153600000000` (100 yr) | `connectors.kafka.transactionalidexpirationms` · `CONNECTORS_KAFKA_TRANSACTIONAL_ID_EXPIRATION_MS` | — | Idle-`transactional.id` reaper deadline. `int64`. `≤ 0` floored; over-ceiling rejected. | | Max transactional IDs | int | `10000` | ≤ `10000000` | `connectors.kafka.maxtransactionalids` · `CONNECTORS_KAFKA_MAX_TRANSACTIONAL_I_DS` | — | Txn-coordinator registry cap. **Env-name trap** (see callout). `≤ 0` floored; over-ceiling rejected. | | Producer ID block size | int | `1000` | ≤ `1000000` | `connectors.kafka.produceridblocksize` · `CONNECTORS_KAFKA_PRODUCER_ID_BLOCK_SIZE` | — | Producer-ID allocation block. `≤ 0` floored; over-ceiling rejected. | | Produce pipeline depth | int | `5` | `1`–`5` | `connectors.kafka.producepipelinedepth` · `CONNECTORS_KAFKA_PRODUCE_PIPELINE_DEPTH` | — | Per-partition in-flight produce batches. `5` matches Kafka's idempotent in-flight cap, and is the hard ceiling — above it the broker cannot de-duplicate on replay, so it is rejected. `1` is serial behavior, the emergency rollback. `< 1` is floored to `1` (the safe side), **not** to the default. Worst-case memory is `depth × maxMessageBytes` held per hot partition. | | Per-broker peer map | string | `""` | `id@host:port,…` | `connectors.kafka.peers` · `CONNECTORS_KAFKA_PEERS` | — | The clustered per-broker **client-reachable** advertised addresses, same grammar as the replication peer map. The id is the peer's raft replica id (= its Kafka broker node id). Meaningful **only** when Kafka is enabled on a clustered `next` cluster — setting it while the connector is on but the cluster is not is **rejected**. A peer's Kafka address must never equal that same id's raft address; that paste mistake is rejected, since clients would otherwise hammer the replication listener. See the [multi-replica exposure callout](#service-exposure--session-affinity). | | SASL credentials | struct\[] | — | list of `{username, password}` | `connectors.kafka.credentials` (config file / secret only) | — | **Secret — no env var, no CRD field.** SASL/PLAIN + SCRAM user store; when non-empty, SASL auth is enforced on every listener. `Validate()` rejects empty or duplicate usernames, empty passwords, and a username matching the reserved internal dashboard identity. Passwords are redacted in logs. | **Kafka env-var trap: `CONNECTORS_KAFKA_MAX_TRANSACTIONAL_I_DS`.** The `MaxTransactionalIDs` field renders to `..._MAX_TRANSACTIONAL_I_DS` (an extra underscore before `DS`), **not** the intuitive `..._MAX_TRANSACTIONAL_IDS`. The wrong form does not bind and is silently ignored. All other Kafka names follow the normal `CONNECTORS_KAFKA_*` rule. ### Kafka SASL credentials [#kafka-sasl-credentials] **Kafka SASL credentials are secret/file-only.** Unlike AWS, there is **no `CredentialsData` env-var escape hatch and no CRD field** — supply the `credentials` list through a mounted `config.yaml` (or Secret-mounted file). On Kubernetes the delivery mechanism is [`spec.envFromSecrets`](/configure/reference/deployment#advanced--other-top-level-spec-fields), which projects an existing Secret into the pod without the values transiting the operator. Plan the credential delivery path before enabling SASL on Kubernetes. ### OAUTHBEARER authentication [#oauthbearer-authentication] OAUTHBEARER activates when `OAuthBearer.Issuer` is non-empty — there is no separate enable flag. It is enforced only on the TLS/SASL\_SSL listener (`TlsPort`); like the advanced knobs above, it is `config.yaml`/env-var-only (Helm/CRD path `—`, not a CRD field). | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ----------------------------- | ------ | ------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------- | | Issuer | string | `""` | non-empty to activate; else OAUTHBEARER stays off | `connectors.kafka.oauthbearer.issuer` · `CONNECTORS_KAFKA_OAUTH_BEARER_ISSUER` (alias of `CONNECTORS_KAFKAO_AUTH_BEARER_ISSUER`) | — | Non-empty **requires** a non-empty `TlsPort` — a bearer token must not cross a plaintext transport. | | Client ID | string | `""` | required unless Skip client-ID check is `true` | `connectors.kafka.oauthbearer.clientid` (by convention) · `CONNECTORS_KAFKA_OAUTH_BEARER_CLIENT_ID` | — | The OIDC audience. Enforced only when `Issuer` is set. | | Skip client-ID check | bool | `false` | true / false | `connectors.kafka.oauthbearer.skipclientidcheck` (by convention) · `CONNECTORS_KAFKA_OAUTH_BEARER_SKIP_CLIENT_ID_CHECK` | — | The one skip-flag Kafka **permits** as `true` — some IdPs legitimately omit or vary the audience claim. | | Skip expiry check | bool | `false` | must stay `false` | `connectors.kafka.oauthbearer.skipexpirycheck` (by convention) · `CONNECTORS_KAFKA_OAUTH_BEARER_SKIP_EXPIRY_CHECK` | — | `true` is **hard-rejected** on the Kafka listener — stricter than the generic OIDC authentication path, which only warns. | | Skip issuer check | bool | `false` | must stay `false` | `connectors.kafka.oauthbearer.skipissuercheck` (by convention) · `CONNECTORS_KAFKA_OAUTH_BEARER_SKIP_ISSUER_CHECK` | — | `true` is **hard-rejected** — it would accept tokens from any issuer. | | Insecure skip signature check | bool | `false` | must stay `false` | `connectors.kafka.oauthbearer.insecureskipsignaturecheck` (by convention) · `CONNECTORS_KAFKA_OAUTH_BEARER_INSECURE_SKIP_SIGNATURE_CHECK` | — | `true` is **hard-rejected** — it would accept forged/unsigned tokens. | **Both OAUTHBEARER env forms work — use the readable one.** The generic snake-caser fuses `Kafka` and `OAuthBearer` into one word and produces the unguessable `CONNECTORS_KAFKAO_AUTH_BEARER_*`. Because nobody can guess that, the server **also** binds the natural **`CONNECTORS_KAFKA_OAUTH_BEARER_*`** form deliberately, and both resolve to the same setting. Prefer the natural form: ```bash CONNECTORS_KAFKA_OAUTH_BEARER_ISSUER=https://idp.example.com CONNECTORS_KAFKA_OAUTH_BEARER_CLIENT_ID=kubemq ``` **One caveat: the server's unknown-variable warner does not know about the alias**, so setting the natural form prints an `IGNORED` warning even though the value is applied. The warning is wrong. Do not "fix" a working issuer because of it — confirm the effective value in the dashboard instead. **Skip-flags are hard-rejected on Kafka, not just warned.** Setting `Insecure skip signature check`, `Skip issuer check`, or `Skip expiry check` to `true` on the Kafka listener is **rejected outright** by `Validate()` — stricter than the generic OIDC authentication path, which only warns. `Skip client-ID check` is the one permitted skip. (`OAuthBearer.Issuer` also requires a non-empty `TlsPort` — see the Issuer row above.) ## AWS [#aws] The AWS SQS/SNS-compatible connector. Env prefix `CONNECTORS_AWS_*`; CRD group `spec.aws.*`. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ------------------------ | --------- | -------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Enable | bool | **`false` (opt-in)** | true / false | `connectors.aws.enable` · `CONNECTORS_AWS_ENABLE` | `spec.aws.enabled` | Opt-in wire connector. Opens port 4566. | | Port | string | `4566` | `1`–`65535` | `connectors.aws.port` · `CONNECTORS_AWS_PORT` | `spec.aws.port` | Rejected if it collides with the gRPC/REST/HTTP or GCP listener port. | | Region | string | `kubemq` | non-empty | `connectors.aws.region` · `CONNECTORS_AWS_REGION` | `spec.aws.region` | | | Account ID | string | `000000000000` | `^[0-9]{12}$` | `connectors.aws.accountid` · `CONNECTORS_AWS_ACCOUNT_ID` | `spec.aws.accountId` | Exactly 12 digits. | | Advertised URL | string | `""` | `""` or `scheme://host[:port]` | `connectors.aws.advertisedurl` · `CONNECTORS_AWS_ADVERTISED_URL` | `spec.aws.advertisedUrl` | Rejected unless it parses with a scheme and host. | | Max inflight per queue | int | `20000` | `1`–`10000000` | `connectors.aws.maxinflightperqueue` · `CONNECTORS_AWS_MAX_INFLIGHT_PER_QUEUE` | `spec.aws.maxInflightPerQueue` | Over-ceiling rejected (OOM guard). | | Max concurrent polls | int | `1024` | `1`–`1000000` | `connectors.aws.maxconcurrentpolls` · `CONNECTORS_AWS_MAX_CONCURRENT_POLLS` | `spec.aws.maxConcurrentPolls` | Over-ceiling rejected. | | Read timeout (s) | int | `60` | `1`–`3600` | `connectors.aws.readtimeout` · `CONNECTORS_AWS_READ_TIMEOUT` | `spec.aws.readTimeout` | Per-action sync deadline; over-ceiling rejected. | | Body limit | string | `2M` | size string | `connectors.aws.bodylimit` · `CONNECTORS_AWS_BODY_LIMIT` | `spec.aws.bodyLimit` | | | Message signing | bool | `false` | true / false | `connectors.aws.messagesigning` · `CONNECTORS_AWS_MESSAGE_SIGNING` | `spec.aws.messageSigning` | Sign SNS Notification / SubscriptionConfirmation envelopes (SigV2). | | Signing cert TTL (h) | int | `8760` | ≥ `1` | `connectors.aws.signingcertttlhours` · `CONNECTORS_AWS_SIGNING_CERT_TTL_HOURS` | `spec.aws.signingCertTtlHours` | Self-signed signing-cert validity (default 365 days). Applies when message signing is on. | | Credentials (data blob) | string | `""` | JSON or base64-of-JSON credential array | `connectors.aws.credentialsdata` · `CONNECTORS_AWS_CREDENTIALS_DATA` | `spec.aws.credentialsData` | SigV4 credential array. On Helm/CRD this is rendered into a **Kubernetes Secret** (not a plain ConfigMap). | | Credentials (structured) | struct\[] | — | list of `{accessKeyId, secretAccessKey, clientID}` | `connectors.aws.credentials` (config file only) | — | **No env var, no CRD field.** File/structured-only credential list; the env/CRD path is `credentialsData`. Empty or duplicate `accessKeyId` is rejected; `clientID` defaults to `accessKeyId`. | **AWS credentials go through a Secret, not a plain CRD value.** The `spec.aws.credentialsData` field exists but the operator writes it into a Kubernetes **Secret** (`CONNECTORS_AWS_CREDENTIALS_DATA`), never a ConfigMap. On Docker, set `connectors.aws.credentialsdata` (JSON or base64-of-JSON) or the `CONNECTORS_AWS_CREDENTIALS_DATA` env var. The fully-structured `connectors.aws.credentials` list is config-file-only and has no env/CRD route. ## GCP Pub/Sub [#gcp-pubsub] The Google Cloud Pub/Sub emulator connector (gRPC). Env prefix `CONNECTORS_GCP_*`; CRD group `spec.gcp.*`. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ----------------------------- | ------ | -------------------- | ----------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | Enable | bool | **`false` (opt-in)** | true / false | `connectors.gcp.enable` · `CONNECTORS_GCP_ENABLE` | `spec.gcp.enabled` | Opt-in wire connector. Opens port 8085. | | Port | string | `8085` | `1`–`65535` | `connectors.gcp.port` · `CONNECTORS_GCP_PORT` | `spec.gcp.port` | gRPC listener (Pub/Sub emulator convention). Rejected if it collides with the gRPC/REST/HTTP or AWS listener port. | | Advertised endpoint | string | `""` | endpoint | `connectors.gcp.advertisedendpoint` · `CONNECTORS_GCP_ADVERTISED_ENDPOINT` | `spec.gcp.advertisedEndpoint` | Endpoint advertised to clients. | | Max message bytes | int | `10485760` (10 MiB) | `1`–`1073741824` (1 GiB) | `connectors.gcp.maxmessagebytes` · `CONNECTORS_GCP_MAX_MESSAGE_BYTES` | `spec.gcp.maxMessageBytes` | Feeds the gRPC frame ceiling (value + 1 MiB); hard cap 1 GiB. | | Default ack deadline (s) | int | `10` | `10`–`600` | `connectors.gcp.defaultackdeadlineseconds` · `CONNECTORS_GCP_DEFAULT_ACK_DEADLINE_SECONDS` | `spec.gcp.defaultAckDeadlineSeconds` | | | Max outstanding messages | int | `1000` | > `0` | `connectors.gcp.maxoutstandingmessages` · `CONNECTORS_GCP_MAX_OUTSTANDING_MESSAGES` | `spec.gcp.maxOutstandingMessages` | | | Max inflight per subscription | int | `20000` | > `0` | `connectors.gcp.maxinflightpersubscription` · `CONNECTORS_GCP_MAX_INFLIGHT_PER_SUBSCRIPTION` | `spec.gcp.maxInflightPerSubscription` | | | Max concurrent polls | int | `1024` | > `0` | `connectors.gcp.maxconcurrentpolls` · `CONNECTORS_GCP_MAX_CONCURRENT_POLLS` | `spec.gcp.maxConcurrentPolls` | | | Max concurrent streams | int | `1024` | `0` (default) or `1`–`65536` | `connectors.gcp.maxconcurrentstreams` · `CONNECTORS_GCP_MAX_CONCURRENT_STREAMS` | `spec.gcp.maxConcurrentStreams` | Per-server cap on concurrent StreamingPull streams. `0` = use the built-in default (1024). | | Delivery shards | int | `16` | `1`–`256` | `connectors.gcp.deliveryshards` · `CONNECTORS_GCP_DELIVERY_SHARDS` | `spec.gcp.deliveryShards` | Striped delivery-pool shards. | | Max ack extension (s) | int | `600` | `0` (disabled) or `10`–`3600` | `connectors.gcp.maxackextensionseconds` · `CONNECTORS_GCP_MAX_ACK_EXTENSION_SECONDS` | `spec.gcp.maxAckExtensionSeconds` | `0` disables the ordered-head ack-deadline keep-alive. | | Stream close (s) | int | `1800` | > `0` | `connectors.gcp.streamcloseseconds` · `CONNECTORS_GCP_STREAM_CLOSE_SECONDS` | `spec.gcp.streamCloseSeconds` | | | Max seek replay | int | `1000000` | > `0` | `connectors.gcp.maxseekreplay` · `CONNECTORS_GCP_MAX_SEEK_REPLAY` | `spec.gcp.maxSeekReplay` | | | Enable reflection | bool | `false` | true / false | `connectors.gcp.enablereflection` · `CONNECTORS_GCP_ENABLE_REFLECTION` | `spec.gcp.enableReflection` | gRPC server reflection. | **Enable GCP Pub/Sub explicitly.** A stock kubemq-server does **not** bind port 8085 until you set `CONNECTORS_GCP_ENABLE=true` (Docker) or `spec.gcp.enabled: true` (Kubernetes). Point clients at the connector with `PUBSUB_EMULATOR_HOST=localhost:8085` — no auth, no TLS (emulator mode). # Core & Licensing (/configure/reference/core) These are the foundational settings every KubeMQ server needs: the **license key** that activates the server, the **log level** that controls how much it writes, and the **host identity** it reports itself under. Each setting is shown for both deployment targets — Docker single-node (`config.yaml` key · env var) and Kubernetes/Helm (`spec.*` path). A dash (`—`) in the Helm/CRD column means the setting is not available on that surface. ## Licensing & identity [#licensing--identity] The license is required on both targets. On Docker you can pass the key directly as an env var, point at a license file, or embed the key in `config.yaml`; on Helm the chart renders `key` (or `license`) straight into the CR. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ----------------- | ------ | ---------------- | --------------------------------------------------------- | ----------------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | License token | string | `""` | activation UUID (online) **or** offline license-data blob | — · `KUBEMQ_TOKEN` (or `--key` flag) | `spec.key` | `License.KubeMQToken` — the value the runtime license service validates. Env/flag-only (`os.Getenv`); no config.yaml key, no viper binding. `spec.key` is consumed **by the operator** (activation + inventory metadata), **not** injected into the pod as `KUBEMQ_TOKEN`. | | License data | string | `""` | license-data blob (base64/PEM) | `license.key.data` · `LICENSE_KEY_DATA` | `spec.license` | `License.Key.Data`. Operator renders `spec.license` → `LICENSE_KEY_DATA` secret. Redacted in settings API. | | License from file | string | `""` | filesystem path | `license.key.filename` · `LICENSE_KEY_FILENAME` | — | `License.Key.Filename`. Docker/on-disk only; `data` wins over `filename`. | | Host identity | string | derived hostname | any host string | `host` · `HOST` | — | `Config.Host`. Auto-derived from OS hostname when empty; explicit `viper.BindEnv("Host","HOST")`. No CRD field. ⚠️ **Changing it orphans the store** — see the warning below. | **Changing `HOST` silently orphans the entire persistent store.** The store layout is `//…`, so the host identity is part of the data path, not just a label. Point a running node at a new `HOST` and it **boots clean and completely healthy onto an empty directory**, with the previous store sitting intact beside it, untouched and unread. Nothing fails, nothing warns, and every health check passes — the messages are simply gone from the server's point of view. The same trap catches you from the other direction on Docker, where `HOST` is derived from the OS hostname: a container recreated without `--hostname` gets a fresh random one and lands on a new empty directory. See the [Docker configuration guide](/configure/docker). If you must change it, treat it as a **migration**: stop the server, move `/` to `/`, then start it. To recover from an accidental change, set `HOST` back to the original value — the old directory is still there. `KUBEMQ_TOKEN` and `LICENSE_KEY_DATA` are two different fields, not aliases for the same setting. **License token** (`KUBEMQ_TOKEN`, `License.KubeMQToken`) is read directly from the environment — there is no `config.yaml` key and no viper binding — and it is the value the runtime license service validates. **License data** (`license.key.data` · `LICENSE_KEY_DATA`, `License.Key.Data`) is the license-data blob bound to `config.yaml`/Helm. On Kubernetes, `spec.key` is consumed **by the operator** for activation and inventory metadata; it is **not** injected into the pod as `KUBEMQ_TOKEN`. Only `spec.license` is delivered to the running server, rendered as the `LICENSE_KEY_DATA` secret. `LicenseConfig.Validate()` is dead code — `Config.Validate()` never calls it, so no startup-time check runs against the license fields above. The real gate is the license service, which validates `KUBEMQ_TOKEN` at runtime; a missing or invalid token fails there, not at config validation. ## Logging [#logging] `Log level` controls verbosity — the same value on both targets, passed through to the server unchanged. `Log to file` / `Log file path` enable and locate an on-disk log; both are Docker / `config.yaml`-only (no Helm/CRD path). | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ------------- | -------------------- | ---------- | --------------------------------------------------------- | ------------------------------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | Log level | int (`LogLevelType`) | `2` (Info) | `0`=Trace `1`=Debug `2`=Info `3`=Warn `4`=Error `5`=Fatal | `log.level` · `LOG_LEVEL` | `spec.log.level` | Higher = less. Validate rejects only negatives; a value >5 silently maps to the verbose default (Trace/Debug), not silent. CRD constrains 0–5. | | Log to file | bool | `false` | `true` \| `false` | `log.fileenable` · `LOG_FILE_ENABLE` | — | When `true`, **Log file path** must be non-empty. | | Log file path | string | `""` | filesystem path | `log.filepath` · `LOG_FILE_PATH` | — | Required when file logging is enabled. | The **0–5 scale above is authoritative** (`0`=Trace … `5`=Fatal, default `2`=Info). The operator passes the value through to the server **verbatim**. An older CRD annotation describes a different scale (`0`=silent / `1`=info / `2`=debug) — that annotation does **not** match the running server, so do not rely on it. Use the 0–5 enum above. ## Example [#example] Set the log level on each target. This is a single-setting snippet — see the [Docker guide](/configure/docker) and the [Kubernetes guide](/configure/kubernetes) for complete, runnable configurations. ```yaml title="config.yaml" log: level: 2 ``` ```yaml title="values.yaml" log: level: 2 ``` For the full Docker delivery methods (env vars, mounted `config.yaml`, the `CONFIG` variable) see the [Docker guide](/configure/docker); for `values.yaml` mapped to the `KubemqCluster` spec see the [Kubernetes guide](/configure/kubernetes). # Deployment & High Availability (/configure/reference/deployment) These settings cover how KubeMQ is **packaged and run on Kubernetes** — the container image, persistent storage, resource requests/limits, health probes, node scheduling, and `Service` exposure — plus the **high-availability** controls (`replicas` and `standalone`). Unlike the rest of this reference, these fields do **not** exist in the server's `config.yaml`: they are typed on the `KubemqCluster` **CRD** and consumed by the operator, which renders the StatefulSet, Services, and PersistentVolumeClaim. The pattern is therefore **inverted** — the **Helm/CRD path is the real one**, and the Docker column is `—` (Kubernetes-only) except where the operator translates a CRD field into a pod env var (shown as `· env VAR`). On Docker single-node, the equivalent concerns are `docker run` flags (`-p`, `-v`, `--cpus` / `--memory`) covered in the [Kubernetes guide](/configure/kubernetes). ## Kubernetes packaging [#kubernetes-packaging] ### Container image [#container-image] | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ------------------ | ------------- | ------------------------------------------------- | ----------------------------------- | ---------------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | Image | string | `europe-docker.pkg.dev/kubemq/images/kubemq:next` | image reference | — (K8s-only) | `spec.image.image` | Resolution order: `spec.image.image` → operator env `RELATED_IMAGE_KUBEMQ_CLUSTER` → built-in fallback (`config/image.go:8,18-30`). No server env binding. | | Pull policy | string (enum) | `Always` | `IfNotPresent` / `Always` / `Never` | — | `spec.image.pullPolicy` | CRD pattern `(IfNotPresent\|Always\|Never)`; empty is coerced to `Always` (`config/image.go:14,34-37`). | | Image pull secrets | string\[] | `[]` | Secret names | — | — (chart value `imagePullSecrets[]`) | **Not** a typed CRD field — supplied via Helm values / a `spec.statefulsetConfigData` override, not the KubemqCluster spec. | ### Storage (volume) [#storage-volume] | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ------------- | ------ | ---------------------------- | -------------------------- | ---------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Volume size | string | *unset → ephemeral (no PVC)* | k8s quantity (e.g. `50Gi`) | — | `spec.volume.size` | When set, the operator renders a `volumeClaimTemplates` PVC (`ReadWriteOnce`) mounted at `./kubemq/store`. When **unset/empty, the pod has no persistent volume** and the store is ephemeral (`deployment.go:78-81`, `deployment/statefulset.go:108-124`). There is **no built-in `10Gi` default** in code. **Required for durable `next`-engine data** — a `next` cluster with no `spec.volume.size` set raises the `EphemeralNextStore` warning (its durable data would otherwise live on ephemeral container storage). | | Storage class | string | `""` → cluster default | StorageClass name | — | `spec.volume.storageClass` | Only consulted when `size` is set; empty renders a blank `storageClassName:` → the cluster's default StorageClass (`config/volume.go:8`). | **`spec.store.path` on the `next` engine: the operator rejects a leading `/`.** The operator itself supplies the mount-rooted absolute path for the PVC, so a `spec.store.path` value starting with `/` is rejected outright — the operator, not the server, owns that absolute path. This is the **inverse** of the server-layer behavior documented on [Storage & Queues](/configure/reference/storage-queues): the `next` engine's **server process** honors an absolute `StorePath` verbatim (it's the `legacy` engine that rewrites a leading `/` to `./`). Keep the two facts distinct — "the operator rejects a leading `/` in the CR field" and "the next-engine server process honors an absolute `StorePath`" are both true, at different layers. ### Resources [#resources] Each field is rendered into the pod's `resources:` block only when non-empty; there are no defaults (`config/resources.go:32-47`). | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ------------------------- | ------ | ------- | ----------------------------------- | ---------------------------------- | ----------------------------------------- | ------------------------------------------- | | CPU limit | string | unset | k8s CPU quantity (e.g. `2`, `500m`) | — | `spec.resources.limitsCpu` | Pod `resources.limits.cpu`. | | Memory limit | string | unset | k8s memory quantity (e.g. `2Gi`) | — | `spec.resources.limitsMemory` | Pod `resources.limits.memory`. | | Ephemeral-storage limit | string | unset | k8s quantity | — | `spec.resources.limitsEphemeralStorage` | Pod `resources.limits.ephemeral-storage`. | | CPU request | string | unset | k8s CPU quantity | — | `spec.resources.requestsCpu` | Pod `resources.requests.cpu`. | | Memory request | string | unset | k8s memory quantity | — | `spec.resources.requestsMemory` | Pod `resources.requests.memory`. | | Ephemeral-storage request | string | unset | k8s quantity | — | `spec.resources.requestsEphemeralStorage` | Pod `resources.requests.ephemeral-storage`. | ### Health probe [#health-probe] The liveness probe is **off by default** and only injected when `enabled: true` (`config/health.go:46-60`). | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ----------------- | ----- | ------- | ---------------- | -------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Enabled | bool | `false` | true / false | · env `API_BIND_ADDRESS=0.0.0.0` (side-effect when true) | `spec.health.enabled` | When true, the operator adds a `livenessProbe` httpGet `/health` on the API port **and** sets `API_BIND_ADDRESS=0.0.0.0` so the kubelet (pod IP) can reach the probe — the API binds `127.0.0.1` otherwise (`config/health.go:48-60`). | | Initial delay (s) | int32 | `5` | any; `≤0` → `5` | — | `spec.health.initialDelaySeconds` | `config/health.go:38-40`. | | Period (s) | int32 | `10` | any; `≤0` → `10` | — | `spec.health.periodSeconds` | `config/health.go:35-37`. | | Timeout (s) | int32 | `5` | any; `≤0` → `5` | — | `spec.health.timeoutSeconds` | `config/health.go:32-34`. | | Success threshold | int32 | `1` | any; `≤0` → `1` | — | `spec.health.successThreshold` | `config/health.go:29-31`. | | Failure threshold | int32 | `12` | any; `≤0` → `12` | — | `spec.health.failureThreshold` | `config/health.go:41-43`. | **`/health` (liveness) vs `/ready` (readiness).** The table above covers the **optional** `/health` liveness probe (`spec.health.enabled`, off by default). Readiness is separate: for a **clustered `next`-engine deployment** (`engine=next` and not `standalone`), the operator wires a **readinessProbe** against a `/ready` endpoint on the API port — it holds the pod not-`Ready` until cluster quorum forms, so traffic only reaches pods that can actually serve. Standalone and legacy-engine pods run a single/loopback node with no multi-node quorum to wait on, so the operator does **not** render this readinessProbe for them. The response code varies by mode, so it isn't listed here. ### Node scheduling [#node-scheduling] | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | -------------- | ------------------ | ------- | -------------------------- | ---------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------- | | Node selectors | map\[string]string | `{}` | node label key/value pairs | — | `spec.nodeSelectors.keys` | Rendered as the pod `nodeSelector`; an empty map applies no constraint (`config/node_selectors.go:9-24`). | ### Service exposure & interface toggles [#service-exposure--interface-toggles] The three built-in interface Services — **gRPC** (`50000`), **REST/WebSocket** (`9090`), and **API/dashboard** (`8080`) — each expose a Service type, optional NodePort, custom port, and a disable toggle. These HTTP/interface Services are **opt-out** via `disabled: true`. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ----------------- | ------------- | ------------------- | ----------------------------------------- | ------------------------------------ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | gRPC disabled | bool | `false` | true / false | · env `CONNECTORS_GRPC_ENABLE=false` | `spec.grpc.disabled` | Deletes the `-grpc` Service and disables the listener (`config/grpc.go:64-66`, `deployment.go:203-206`). | | gRPC Service type | string (enum) | `ClusterIP` | `ClusterIP` / `NodePort` / `LoadBalancer` | — | `spec.grpc.expose` | Empty → `ClusterIP` (`config/grpc.go:56`); CRD pattern-validated. | | gRPC NodePort | int32 | `0` (auto-assigned) | 30000–32767 | — | `spec.grpc.nodePort` | Applied only when `expose: NodePort` **and** value > 0 (`config/grpc.go:77-81`). | | gRPC port | int32 | `50000` | port | · `CONNECTORS_GRPC_PORT` | `spec.grpc.port` | Moves the container/Service/target port and emits the env (`config/grpc.go:71-75`). | | REST disabled | bool | `false` | true / false | · env `CONNECTORS_REST_ENABLE=false` | `spec.rest.disabled` | The `-rest` Service (9090) is **shared** by REST/MCP/Agents/CE — the operator removes it only when **all four** are disabled (`deployment.go:99-120`). | | REST Service type | string (enum) | `ClusterIP` | `ClusterIP` / `NodePort` / `LoadBalancer` | — | `spec.rest.expose` | Empty → `ClusterIP` (`config/rest.go:71`). | | REST NodePort | int32 | `0` (auto) | 30000–32767 | — | `spec.rest.nodePort` | Applied only when `expose: NodePort` and > 0 (`config/rest.go:91-95`). | | REST port | int32 | `9090` | port | · `CONNECTORS_REST_PORT` | `spec.rest.port` | `config/rest.go:85-88`. | | API disabled | bool | `false` | true / false | · env `API_ENABLE=false` | `spec.api.disabled` | Deletes the `-api` Service (`config/api.go:65-66`, `deployment.go:89-92`). | | API Service type | string (enum) | `ClusterIP` | `ClusterIP` / `NodePort` / `LoadBalancer` | — | `spec.api.expose` | Empty → `ClusterIP` (`config/api.go:58`). | | API NodePort | int32 | `0` (auto) | 30000–32767 | — | `spec.api.nodePort` | Applied only when `expose: NodePort` and > 0 (`config/api.go:79-83`). | | API port | int32 | `8080` | port | · `API_PORT` | `spec.api.port` | `config/api.go:73-76`. | **`spec.statefulsetConfigData` is a one-way door for every other packaging field.** It does not merge with, patch, or extend the StatefulSet the operator builds — it **replaces the body outright**. The moment it is set, `spec.image`, `spec.volume`, `spec.resources`, `spec.health`, `spec.nodeSelectors`, `spec.podAntiAffinity` and `spec.terminationGracePeriodSeconds` stop having any effect on the pod, silently: they stay in the CR, they still validate, and they change nothing. You now own the whole pod spec, including the parts the operator was maintaining for you across upgrades. Use it only when a field you need genuinely has no typed equivalent, and expect to re-check it on every operator upgrade. Only the **exposure** subset of `spec.grpc` / `spec.rest` / `spec.api` lives here. Their tuning fields (buffer/body limits, gRPC reflection, REST read/write timeouts, CORS, API allow-origins, and the API-auth block) are documented on the [Interfaces](/configure/reference/interfaces) and [Security](/configure/reference/security) pages. ### Advanced / other top-level spec fields [#advanced--other-top-level-spec-fields] | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | -------------------- | ------------------ | ------- | ------------------------- | ---------------------------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | StatefulSet override | string (YAML) | unset | StatefulSet YAML fragment | — | `spec.statefulsetConfigData` | ⚠️ Escape hatch — when set, **replaces the operator-generated StatefulSet body** with your fragment (`deployment/statefulset.go:126-135,268-271`). All other packaging fields above are ignored for the STS. See the warning below. | | Config data (OIDC) | string | unset | OIDC block | — | `spec.configData` | OIDC-only passthrough — **not** a generic `config.yaml`. See [Security](/configure/reference/security). | | License | string | unset | license key | — (Secret `LICENSE_KEY_DATA`) | `spec.license` | Injected as Secret data, not env (`deployment.go:66-68`). Config/secret-only. | | Key | string | unset | activation key | — | `spec.key` | Top-level activation key; secret-only. | | Key from Secret | string | unset | existing Secret name | — | `spec.keySecretRef` · `spec.keySecretKey` | Sources `key` from an existing Secret instead of the literal. Data key defaults to `key`; override with `keySecretKey`. **Mutually exclusive with the literal `key`** — setting both is rejected. See [Supplying the license from a Secret](/deploy/kubernetes-helm#supplying-the-license-from-a-secret). | | License from Secret | string | unset | existing Secret name | — | `spec.licenseSecretRef` · `spec.licenseSecretKey` | Same mechanism for `spec.license`; data key defaults to `license`. Mutually exclusive with the literal. | | Env overlay | map\[string]string | `{}` | env key/value pairs | — | `spec.env` | Last-wins overlay onto the pod ConfigMap — the general escape hatch for any server env key the CRD doesn't type. Operator-computed identity keys are **rejected**: `STORE_ENGINE`, `CLUSTER_ENABLE`, `CLUSTER_NAME`, `CLUSTER_ROUTES`, `API_BIND_ADDRESS`, `CHECKSUM`, `POD_NAME`, and any `CLUSTER_REPLICATION_*` key — **not** a `CLUSTER_*` wildcard (e.g. `CLUSTER_PORT` is still allowed). Not for secrets. | | Env from Secrets | string\[] | `[]` | existing Secret names | — | `spec.envFromSecrets` | Projects existing Secret(s) as pod env (`envFrom`) — the general Secret-envFrom escape hatch; Secret values never transit the operator. This is the mechanism behind [Kafka SASL credentials](/configure/reference/connectors#kafka-sasl-credentials). | ## High availability [#high-availability] High availability on Kubernetes is **multiple replicas** managed by the operator — not the Docker `cluster.*` block. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ------------- | -------- | ------- | --------------------------------------- | ---------------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Replica count | `*int32` | `3` | CRD `Minimum=0`; `0`/nil coerced to `3` | — | `spec.replicas` | nil → 3 (`deployment.go:59-62`), and `SetReplicas(0)` → 3 (`deployment/statefulset.go:183-191`). Backs the `scale` subresource (`specpath=.spec.replicas`). More than one replica = clustered HA (operator wires `CLUSTER_*` + the 5228 cluster port). **Once the engine is established as `next`, `spec.replicas` is immutable** — a CEL rule plus an operator fail-closed guard reject any change (see the engine-establishment guard below). | | Standalone | bool | `false` | true / false | — | `spec.standalone` | true → a single **non-clustered** node: the operator omits `CLUSTER_NAME`/`CLUSTER_ROUTES`/`CLUSTER_ENABLE` and the `5228` cluster-port from the pod (`deployment/statefulset.go:40-47,103-107`). On the `next` engine, standalone runs a **loopback raft** and skips replication wiring entirely. | **`spec.replicas` becomes immutable once the engine is `next` — and `next` is what a new cluster gets.** This is a one-way door, armed at creation on a default install, not an edge case for people who opted into `next`. Once the engine is established as `next`, a CEL rule on the CRD and an operator fail-closed guard both reject **any** change to the replica count — up or down. `helm upgrade --set replicas=5` fails; so does editing the CR. **Choose the replica count before you create the cluster.** 3 is the smallest count that gets a disruption budget; see [Disruption budget & pod spread](#disruption-budget--pod-spread). Changing it afterwards means creating a new cluster and migrating, so decide with the ceiling in mind rather than the starting load. On Kubernetes, high availability is `spec.replicas` (operator-managed clustering) — **not** the `config.yaml`-only `cluster.*` block, which has no Helm/CRD path. See the [Advanced reference](/configure/reference/advanced) for the Docker `cluster.*` path. ### Disruption budget & pod spread [#disruption-budget--pod-spread] Both default to **on** for a multi-replica cluster — the operator creates them without being asked, and these fields exist to tune or disable them. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ------------------------- | ----- | -------------------------------- | ------------ | ---------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------- | | Disruption budget enabled | bool | `true` | true / false | — | `spec.podDisruptionBudget.enabled` | Set `false` to create no `PodDisruptionBudget`. | | Minimum available | int32 | raft majority (`replicas/2 + 1`) | ≥ `1` | — | `spec.podDisruptionBudget.minAvailable` | Defaults to **2 of 3, 3 of 5**. Overriding it downward trades quorum safety for drainability. | | Anti-affinity enabled | bool | `true` | true / false | — | `spec.podAntiAffinity.enabled` | Spreads replicas across nodes. | | Anti-affinity required | bool | `false` | true / false | — | `spec.podAntiAffinity.required` | `false` (default) = *preferred* spread; `true` = *required*. See the trade below. | **Use an odd replica count, 3 or more. The operator refuses to protect anything smaller, and tells you.** A budget is created only from **3 replicas up** — below that the operator creates none and emits an **`UnsupportedReplicaCount`** Warning event explaining why: a single replica forms a quorum of one and survives its own restart, while two replicas need both, so any single failure loses quorum. The only correct budget at 2 replicas would be `minAvailable: 2`, which blocks every voluntary eviction and wedges node drains indefinitely — the operator refuses to ship one that deadlocks maintenance. An **even** replica count gets an **`EvenReplicaCount`** Warning event: the majority of 4 is 3, the same single-failure tolerance as 3, at the cost of an extra replica and another copy of the data. These two events are your only signal — nothing else reports that no budget was created: ```bash kubectl get events -n --field-selector reason=UnsupportedReplicaCount ``` **The anti-affinity trade is real — choose it deliberately.** `required: false` (the default) means pods **always schedule**, but two replicas **may** share a node, so a single node loss can still cost quorum. `required: true` **guarantees** the spread, but a replica stays `Pending` while there are fewer schedulable nodes than replicas. ### Shutdown grace period [#shutdown-grace-period] | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ---------------------------- | ----- | -------------------------- | ------------ | ---------------------------------- | ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | | Termination grace period (s) | int32 | *unset* → Kubernetes' `30` | ≥ `1` | — (`docker stop -t`) | `spec.terminationGracePeriodSeconds` · env `KUBEMQ_TERMINATION_GRACE_PERIOD_SECONDS` | The pod's shutdown budget. The operator sets the **pod spec** and passes the **same number to the server** — they always move together. | **This setting governs message durability on shutdown, not just tidiness.** The server splits the grace period between **connector teardown** — which requeues in-flight messages that would otherwise be **lost** — and **store shutdown**. Kubernetes does not expose `terminationGracePeriodSeconds` to the container, so the operator both sets the pod spec **and** passes the same value as `KUBEMQ_TERMINATION_GRACE_PERIOD_SECONDS`. Left unset, Kubernetes uses **30s** and the server assumes 30s. **The concrete numbers:** at the default 30-second grace the requeue backstop is **1 second**. **45 seconds funds the full 12-second backstop.** Set at least `45` on any cluster where losing in-flight messages on a pod roll matters, and higher still if your connectors hold long in-flight batches. ```yaml title="values.yaml" terminationGracePeriodSeconds: 45 ``` **Connector Services & ports (operator-exposed).** The pod always publishes container ports for every wire connector — MQTT `1883/8883/8083`, AMQP `5672/5671`, STOMP `61613/61614`, AWS `4566`, GCP `8085`, **Kafka `9092/9093`** (`deployment/statefulset.go:70-102`, `deployment/service.go:192-256`). The matching ClusterIP `Service` is created **only when that connector is enabled** (`spec..enabled: true`) — otherwise the operator prunes it (`deployment.go:122-199`). Wire connectors are **opt-in** (`enabled: true`, default off); the HTTP interfaces above (gRPC/REST/API) remain **opt-out** (`disabled: true`). **`status.engine`.** The `KubemqCluster` status subresource exposes `status.engine` (`legacy` / `next` / `auto`) — the engine established for this cluster by the guard below. Read it alongside `status.replicas` when auditing the replicas-freeze caveat above: once `status.engine` reports `next`, `spec.replicas` can no longer change. **`auto` is not an engine.** It means the cluster **delegated** the choice to the server, which resolves it at boot from the store directory. On such a cluster `status.engine` and the `established-engine` annotation both read `auto`, and the engine actually running is reported **only** in the server's boot NOTICE in the pod log — see [Which engine am I actually on?](/configure/reference/storage-engines#which-engine-am-i-actually-on). ### Engine-establishment guard [#engine-establishment-guard] The operator records the **live** persistence engine in the `core.k8s.kubemq.io/established-engine` annotation (`legacy` / `next` / `auto`) — once present, this annotation is **authoritative** on an established cluster, **outranking `spec.store.engine`** for every guard decision. A mismatch between the two is **refused, not silently applied**. Derivation runs in order: (1) the annotation, if present; (2) else the explicit `spec.store.engine`; (3) else the pod ConfigMap's `STORE_ENGINE` key; (4) else, if a StatefulSet or retained PVC already exists, the engine is **unknown** and the operator refuses to guess — set `spec.store.engine` explicitly or delete the retained PVCs; (5) else the cluster has **no engine on record** and the operator delegates: it writes `STORE_ENGINE=auto` and the server resolves the engine at boot from the store directory (clean ⇒ `next`). **From operator v2.3.0 a fresh cluster gets `auto`, not a pinned engine** — the operator no longer inverts the server's clean-store default. An established cluster keeps its named engine and nothing rolls. **Setting `spec.store.engine` later on an `auto` cluster is allowed**, because `auto` records a delegation rather than an established engine — pin the engine the server actually resolved; a pin that disagrees with the data on disk is refused by the server at boot, and the server never deletes a datadir. The operator **never changes a live cluster's engine.** Two advisory CEL rules on the CRD back this: `spec.store.engine` is immutable once set (born-one-mode), and `spec.replicas` is immutable once the engine is established as `next`. This operator-side derivation runs **before** the server-side probe documented on [Storage Engines](/configure/reference/storage-engines#zero-config-engine-selection) and the Kafka callout on [Connectors](/configure/reference/connectors#kafka) — two layers of one decision: the operator decides what the pod's env will say before the pod ever boots, and when that env says `auto`, the server-side probe is what actually decides once it does. ## Example [#example] Publish the gRPC port on each target. On Kubernetes you set the `Service` exposure and node port; on Docker you publish the port with `-p`. This is a single-setting snippet — see the [Kubernetes guide](/configure/kubernetes) for complete, runnable configurations. ```yaml title="values.yaml" grpc: expose: NodePort nodePort: 32000 ``` For the full install flow (CRDs → operator → cluster), `values.yaml` mapped to the `KubemqCluster` spec, and single-node vs HA, see the [Kubernetes guide](/configure/kubernetes). # Configuration Reference (/configure/reference) The reference documents the KubeMQ server settings an operator configures, grouped by domain. Every option is rendered in a table with the same seven columns — most pages carry several, one per settings group — so the same field reads identically whether you run KubeMQ with Docker or on Kubernetes via Helm. This page is the legend — read it once, then every table downstream is unambiguous. **Not every field in the server's config struct appears here.** Internal, test-only, and derived fields are deliberately out of scope, and a handful of newer knobs are documented on the page that owns their behavior rather than in a settings table. If a variable you found in a log or a struct dump isn't here, that is not proof it does nothing — check the domain page for the feature it belongs to first. ## How to read the tables [#how-to-read-the-tables] Every settings table on every domain page uses the same **7 columns**: | Column | What it tells you | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | **Setting** | The human-readable name of the option. | | **Type** | The value type — `int`, `bool`, `string`, size string, enum, etc. | | **Default** | The value the server uses when the setting is omitted. | | **Valid values** | The accepted range or enum, where one applies. | | **Docker (config.yaml key · env var)** | Both Docker forms: the `config.yaml` key **and** its environment variable. | | **Helm/CRD path** | The `KubemqCluster` `spec.*` path. The **Helm value** is that path with the leading `spec.` removed (`spec.grpc.port` → `grpc.port`). | | **Notes** | Name divergences, validation rules, cross-field constraints, and version floors. | The Docker column always shows **two** forms of the same setting. The `config.yaml` key is viper-lowercased with no per-segment separators (for example `store.maxretention`); the environment variable is that key in `UPPER_SNAKE` (`STORE_MAX_RETENTION`). The Helm/CRD column shows the CRD `spec.*` path. Because the chart renders `values.yaml` straight into the CR `spec`, a Helm value is just that path minus `spec.` — there is no separate Helm schema to learn. ## The "—" convention [#the--convention] A dash (`—`) in the **Helm/CRD path** column means the setting is **not available on that surface** — it is a Docker / `config.yaml`-only knob with no Helm route. It does **not** mean "to be filled in later." Whole domains are Docker-only by design: the message-broker engine (`broker.*`), runtime tuning (`tuning.*`), and standalone clustering (`cluster.*`) are advanced settings exposed through `config.yaml`/env only. On Kubernetes the operator owns those concerns. See the [Advanced](/configure/reference/advanced) page. ## The env-var acronym rule [#the-env-var-acronym-rule] Docker derives each environment variable from the `config.yaml` key with `convertEnvFormat`: snake-case the key, drop the dots, uppercase. The trap is in the **connector prefixes** — how `CONNECTORS` joins the next segment depends on whether that segment is a Title-case word or an all-caps acronym: | Connector segment | Form | Env-var prefix | | ------------------ | ------------------------------------------- | --------------------- | | `Amqp` (0.9.1) | Title-case → **keeps** the underscore | `CONNECTORS_AMQP_*` | | `Amqp10` (1.0) | Title-case → keeps the underscore | `CONNECTORS_AMQP10_*` | | `Stomp` | Title-case → keeps the underscore | `CONNECTORS_STOMP_*` | | `Aws` | Title-case → keeps the underscore | `CONNECTORS_AWS_*` | | `Kafka` | Title-case → keeps the underscore | `CONNECTORS_KAFKA_*` | | `Gcp` | Title-case → keeps the underscore | `CONNECTORS_GCP_*` | | `MCP` | all-caps acronym → **drops** the underscore | `CONNECTORSMCP_*` | | `CE` (CloudEvents) | all-caps acronym → drops the underscore | `CONNECTORSCE_*` | | `MQTT` | all-caps acronym → drops the underscore | `CONNECTORSMQTT_*` | | `A2A` (agents) | splits to `A2_A` | `CONNECTORSA2_A_*` | **The wrong twin is silently ignored — except for CloudEvents.** CloudEvents is the one connector that binds **both** forms: the collapsed `CONNECTORSCE_*` (primary) and the underscored `CONNECTORS_CE_*` (a compensating alias) resolve to the same setting — either `CONNECTORSCE_ENABLE` or `CONNECTORS_CE_ENABLE` works. **MCP, A2A, and MQTT have no such alias.** For those, only the collapsed form binds — `CONNECTORSMCP_*`, `CONNECTORSA2_A_*`, and `CONNECTORSMQTT_*`. The underscored twin (`CONNECTORS_MCP_*`, `CONNECTORS_A2_A_*`, `CONNECTORS_MQTT_*`) does **not** bind — the server starts and accepts the variable without error. **Kafka's OAUTHBEARER block has the same kind of alias as CloudEvents:** the generic rule produces the unguessable `CONNECTORS_KAFKAO_AUTH_BEARER_*`, so the natural `CONNECTORS_KAFKA_OAUTH_BEARER_*` is bound deliberately alongside it. Both work. **The server's "IGNORED" warning is unreliable in both directions — do not use it as proof.** It reports variables that sit in a KubeMQ namespace prefix but bind to no config key, and that heuristic has two holes: * **False alarms.** The two deliberate aliases above — `CONNECTORS_CE_*` and `CONNECTORS_KAFKA_OAUTH_BEARER_*` — are bound by a path the warner doesn't track, so it reports them as IGNORED **while they are being applied**. Do not "fix" a working setting because of this warning. * **Silence that means nothing.** The warner's namespace list contains only prefixes ending in `_` (`STORE_`, `CLUSTER_`, `API_`, `CONNECTORS_`, `BROKER_`, `QUEUE_`, `ROUTING_`, `AUTHORIZATION_`, `AUTHENTICATION_`, `LOG_`, `METRICS_`, `AUDIT_`). Every collapsed-acronym form is structurally outside it, so a typo in `CONNECTORSMQTT_*`, `CONNECTORSMCP_*`, `CONNECTORSCE_*` or `CONNECTORSA2_A_*` produces **no output at all**. So are the `LICENSE_`, `NOTIFICATION_`, `SECURITY_`, `TELEMETRY_` and `TUNING_` namespaces — a typo in `LICENSE_KEY_DATA` is silent. Confirm the **effective** value in the dashboard rather than trusting the log either way. ## `enable` ↔ `disabled` inversion [#enable--disabled-inversion] The enable model is **not uniform** — it splits into two families, and each family's default and CRD field name are different: * **HTTP-family** interfaces/connectors (gRPC, REST, API, MCP, A2A, CloudEvents) are **opt-out** — **on by default**. Docker turns one off with `enable: false` — for example `connectors.ce.enable: false` (env `CONNECTORSCE_ENABLE=false`). Helm/CRD turns one off with `spec..disabled: true`; omit the key while the connector stays on. * **Wire-protocol** connectors (MQTT, AMQP 0.9.1, AMQP 1.0, STOMP, Kafka, AWS, GCP) are **opt-in** — **off by default**. Docker turns one on with `enable: true`. Helm/CRD turns one on with `spec..enabled: true`; omit the key and the connector stays off. **Docker always uses `enable: true | false`** regardless of family — only the default and the Helm/CRD field name (`disabled` vs `enabled`) differ. Each domain page shows the form that applies on each target. ## Configuration domains [#configuration-domains] The settings are grouped across these nine domain pages. # Interfaces (gRPC · REST · API · HTTP) (/configure/reference/interfaces) KubeMQ fronts its server core with four interfaces: the **gRPC** transport, the **REST/WebSocket** transport, the management/dashboard **API** (with opt-in API authentication), and the **shared HTTP server** that hosts MCP, A2A, and CloudEvents on the REST port. Each setting is shown for both targets — Docker single-node (`config.yaml` key · env var) and Kubernetes/Helm (`spec.*` path). A dash (`—`) in the Helm/CRD column means the setting is not a typed CRD field; on Kubernetes it is reachable only through `spec.configData` (raw config) or a directly-set pod env var. **All four interfaces are opt-out (always-on).** gRPC, REST, and API are enabled by default on both targets. On Docker they use the `enable: true/false` model; on the chart they use the inverted `disabled: true/false` framing (`spec.grpc.disabled`, `spec.rest.disabled`, `spec.api.disabled`) — all shipped as `disabled: false`. Set `disabled: true` to turn one off. The shared HTTP server has no toggle of its own — it rides the REST port and is emitted only when you set one of its `spec.http.*` fields. **The shared HTTP listener does not belong to REST.** It starts if **any** of REST, MCP, A2A, or CloudEvents is enabled, and stops only when all four are off. Disabling REST alone retires the REST routes and leaves the other three serving on the same port. **`.port` on Kubernetes moves everything together.** Setting `spec.grpc.port` / `spec.rest.port` / `spec.api.port` makes the operator emit the matching listener env var (`CONNECTORS_GRPC_PORT` / `CONNECTORS_REST_PORT` / `API_PORT`) **and** set the Kubernetes `Service` `port`/`targetPort` **and** the container port — the in-pod listener and the Service port move as one. On Docker the same env var moves the actual listener, which you then publish with `docker run -p`. **A default `helm install` publishes nothing outside the cluster.** The shipped `values.yaml` sets no `expose` and no `nodePort` for any interface, and a `KubemqCluster` with `expose` unset gets a **`ClusterIP`** Service. gRPC is *not* reachable on every node out of the box — to reach it from outside you set `expose` yourself (`NodePort` or `LoadBalancer`), and if you need a **predictable** port you must also set `nodePort`, because an unset one is assigned by the kernel and cannot be configured into a client ahead of the install. ## gRPC [#grpc] The primary client transport. Enabled by default on both targets. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ---------------- | ------------ | --------------------- | ----------------------------------------- | ------------------------------------------------------------------------ | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Enable / disable | bool | enabled (`true`) | true / false | `connectors.grpc.enable` · `CONNECTORS_GRPC_ENABLE` | `spec.grpc.disabled` | Inverted boolean: Docker `enable: true`, Helm `disabled: false`. When disabled, no listener starts and the rest of the section is skipped. | | Port | int (string) | `50000` | 1–65535 | `connectors.grpc.port` · `CONNECTORS_GRPC_PORT` | `spec.grpc.port` | Server field is a string port; rejected if non-numeric or out of range. | | Sub buffer size | int | `100` | ≥ 0 | `connectors.grpc.subbuffsize` · `CONNECTORS_GRPC_SUB_BUFF_SIZE` | `spec.grpc.bufferSize` | Per-subscription channel buffer. Negative rejected. **Name divergence:** server `subBuffSize` ↔ CRD `bufferSize`. | | Body limit | int (bytes) | `104857600` (100 MB) | ≥ 0 bytes | `connectors.grpc.bodylimit` · `CONNECTORS_GRPC_BODY_LIMIT` | `spec.grpc.bodyLimit` | Server `int` bytes; CRD `int32` bytes, emitted only when non-zero (no CRD type default). Chart example sets `10000000`. | | gRPC reflection | bool | `false` | true / false | `connectors.grpc.enablereflection` · `CONNECTORS_GRPC_ENABLE_REFLECTION` | `spec.grpc.enableReflection` | Enables server reflection for grpcurl/tooling. | | Service exposure | enum | `ClusterIP` | `ClusterIP` / `NodePort` / `LoadBalancer` | (`-p` host port) | `spec.grpc.expose` | Kubernetes `Service` type; empty ⇒ `ClusterIP`. The chart ships **no** `expose` value. On Docker use `-p`. | | NodePort | int | `0` (kernel-assigned) | 30000–32767 | (`-p`) | `spec.grpc.nodePort` | Applied only when `expose: NodePort` **and** the value is > 0. The chart ships no default — leave it unset and the kernel picks a port you cannot configure into a client ahead of the install. | ## REST · WebSocket [#rest--websocket] The HTTP/WebSocket transport, which also hosts the shared HTTP server (MCP, A2A, CloudEvents). Enabled by default on both targets. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ---------------- | ------------ | --------------------- | ----------------------------------------- | ---------------------------------------------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Enable / disable | bool | enabled (`true`) | true / false | `connectors.rest.enable` · `CONNECTORS_REST_ENABLE` | `spec.rest.disabled` | Inverted boolean. Disabling REST removes the REST routes (they return `404`) but **leaves MCP, A2A and CloudEvents serving** — the shared HTTP server starts if *any* of the four is enabled. To take the whole listener down, disable all four. | | Port | int (string) | `9090` | 1–65535 | `connectors.rest.port` · `CONNECTORS_REST_PORT` | `spec.rest.port` | String port; rejected if non-numeric or out of range. | | Body limit | string / int | server `"100M"` | Echo size string or bytes | `connectors.rest.bodylimit` · `CONNECTORS_REST_BODY_LIMIT` | `spec.rest.bodyLimit` | Server size-string (`"100M"`); CRD `int32` bytes, emitted only when non-zero. Chart example sets `1000000`. | | Sub buffer size | int | `100` | ≥ 0 | `connectors.rest.subbuffsize` · `CONNECTORS_REST_SUB_BUFF_SIZE` | `spec.rest.bufferSize` | Negative rejected. **Name divergence:** `subBuffSize` ↔ `bufferSize`. | | Read timeout | int (s) | `60` | ≥ 0 (CRD ≥ 1) | `connectors.rest.readtimeout` · `CONNECTORS_REST_READ_TIMEOUT` | `spec.rest.readTimeout` | Seconds. Server rejects negative; CRD enforces `Minimum=1`. | | Write timeout | int (s) | `60` | ≥ 0 (CRD ≥ 1) | `connectors.rest.writetimeout` · `CONNECTORS_REST_WRITE_TIMEOUT` | `spec.rest.writeTimeout` | Seconds. Server rejects negative; CRD enforces `Minimum=1`. | | Service exposure | enum | `ClusterIP` | `ClusterIP` / `NodePort` / `LoadBalancer` | (`-p` host port) | `spec.rest.expose` | Kubernetes `Service` type; empty ⇒ `ClusterIP`. The chart ships **no** `expose` value. On Docker use `-p`. | | NodePort | int | `0` (kernel-assigned) | 30000–32767 | (`-p`) | `spec.rest.nodePort` | Applied only when `expose: NodePort` **and** the value is > 0. The chart ships no default. | ### REST CORS [#rest-cors] CORS policy for the REST transport. On the CRD it is a first-class `spec.rest.cors.*` sub-object; on Docker/env the keys ride the `CONNECTORS_REST_CORS_*` prefix. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ----------------- | --------- | ---------------- | ------------ | ---------------------------------------------------------------------------------- | --------------------------------- | --------------------------------------------------------- | | Allow origins | string\[] | `["*"]` | origin list | `connectors.rest.cors.alloworigins` · `CONNECTORS_REST_CORS_ALLOW_ORIGINS` | `spec.rest.cors.allowOrigins` | **Must be non-empty** — validation rejects an empty list. | | Allow methods | string\[] | `["GET","POST"]` | method list | `connectors.rest.cors.allowmethods` · `CONNECTORS_REST_CORS_ALLOW_METHODS` | `spec.rest.cors.allowMethods` | **Must be non-empty.** | | Allow headers | string\[] | `[]` | header list | `connectors.rest.cors.allowheaders` · `CONNECTORS_REST_CORS_ALLOW_HEADERS` | `spec.rest.cors.allowHeaders` | Empty list allowed. | | Allow credentials | bool | `false` | true / false | `connectors.rest.cors.allowcredentials` · `CONNECTORS_REST_CORS_ALLOW_CREDENTIALS` | `spec.rest.cors.allowCredentials` | | | Expose headers | string\[] | `[]` | header list | `connectors.rest.cors.exposeheaders` · `CONNECTORS_REST_CORS_EXPOSE_HEADERS` | `spec.rest.cors.exposeHeaders` | | | Max age | int (s) | `0` | ≥ 0 | `connectors.rest.cors.maxage` · `CONNECTORS_REST_CORS_MAX_AGE` | `spec.rest.cors.maxAge` | Negative rejected. | **REST → shared-HTTP inheritance.** When you explicitly set `Rest.ReadTimeout`, `Rest.BodyLimit`, `Rest.Cors.AllowOrigins`, or `Rest.Cors.AllowMethods` (and don't set the matching `Http.*` key), the value propagates to the shared HTTP server. `Http.Port` always inherits `Rest.Port` when `Http.Port` is unset. The shared server's distinct CORS defaults (the `MCP-*`/`OPTIONS`/`DELETE` headers below) are preserved whenever you did **not** override the REST side, so MCP and A2A keep working. ## Management API [#management-api] The management/dashboard API. Enabled by default on both targets. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | -------------------- | --------- | ------------------------------- | ----------------------------------------- | ---------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | Enable / disable | bool | enabled (`true`) | true / false | `api.enable` · `API_ENABLE` | `spec.api.disabled` | Inverted boolean. | | Port | int | `8080` | 1–65535 | `api.port` · `API_PORT` | `spec.api.port` | Validated only when the API is enabled. | | Bind address | string | `127.0.0.1` (empty → `0.0.0.0`) | IP address | `api.bindaddress` · `API_BIND_ADDRESS` | — | Config/env only; no typed CRD field. An empty value is normalized to `0.0.0.0`. | | Allow origins (CORS) | string\[] | `["*"]` | origin list | `api.alloworigins` · `API_ALLOW_ORIGINS` | `spec.api.allowOrigins` | CRD joins the list with commas into the env var. **When API auth is enabled, `"*"` and an empty list are rejected** (see below). | | Service exposure | enum | `ClusterIP` | `ClusterIP` / `NodePort` / `LoadBalancer` | (`-p` host port) | `spec.api.expose` | Kubernetes `Service` type; empty ⇒ `ClusterIP`. The chart ships **no** `expose` value. On Docker use `-p`. | | NodePort | int | `0` (kernel-assigned) | 30000–32767 | (`-p`) | `spec.api.nodePort` | Applied only when `expose: NodePort` **and** the value is > 0. The chart ships no default. | ### Management API authentication [#management-api-authentication] Opt-in authentication for the management API + web dashboard (`[Api.Auth]`). **Disabled by default.** The data plane (gRPC/REST messaging) is unaffected. On the CRD it lives under `spec.api.auth.*`. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ---------------------- | ------ | --------------------- | ---------------- | ----------------------------------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Enable | bool | `false` | true / false | `api.auth.enable` · `API_AUTH_ENABLE` | `spec.api.auth.enable` | Gates the whole section. | | Session idle minutes | int | `30` | > 0 when enabled | `api.auth.sessionidleminutes` · `API_AUTH_SESSION_IDLE_MINUTES` | `spec.api.auth.sessionIdleMinutes` | Idle-session timeout. Must be positive. | | Session absolute hours | int | `8` | > 0 when enabled | `api.auth.sessionabsolutehours` · `API_AUTH_SESSION_ABSOLUTE_HOURS` | `spec.api.auth.sessionAbsoluteHours` | Absolute session lifetime. Must be positive. | | Store path | string | `""` → main store dir | filesystem path | `api.auth.storepath` · `API_AUTH_STORE_PATH` | `spec.api.auth.storePath` | When empty and auth enabled, defaults to `Store.StorePath` (the main store directory). | | Trusted TLS proxy | bool | `false` | true / false | `api.auth.trustedtlsproxy` · `API_AUTH_TRUSTED_TLS_PROXY` | `spec.api.auth.trustedTLSProxy` | Set `true` when a TLS-terminating reverse proxy fronts the API port; otherwise cookie/session login is refused on plain HTTP. | | Admin username | string | `admin` | username | `KUBEMQ_API_ADMIN_USERNAME` (os env) | `spec.api.auth.adminUsername` | **Env/secret only — not a viper config-file field.** Read directly from the environment. | | Admin password | string | — | password | `KUBEMQ_API_ADMIN_PASSWORD` / `KUBEMQ_API_ADMIN_PASSWORD_FILE` (os env) | `spec.api.auth.adminSecretRef` · `adminSecretKey` | **Secret only** — never a config field. The operator injects the password into the pod from a Secret; on Docker set the env var (or `_FILE`) yourself. Cluster mode requires it when auth is enabled. | #### The account model [#the-account-model] The two settings above (`adminUsername` / `adminPassword`) bootstrap the **first** account. They are not the whole model — once auth is on, the control plane has three roles and two account types, and only the first admin is configured through the server config at all. Every other account is created through the API or dashboard. **Three roles**, each a superset of the one below it: | Role | Can do | | ------------ | ------------------------------------------------------------------------- | | `read_only` | Read dashboards, stats, snapshots, audit logs | | `read_write` | Everything in `read_only`, plus send/receive messages and subscribe | | `admin` | Everything in `read_write`, plus manage accounts and revert configuration | **Two account types:** | Type | Authenticates with | Notes | | --------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `user` | Username + password, cookie session | Subject to the idle and absolute session limits above. | | `service` | A bearer API key, `kmq__` | Sent as `Authorization: Bearer …`. For scripts, agents, and CI. **A service account cannot hold the `admin` role** — that combination is rejected at creation. | Two behaviors worth knowing before you turn auth on: * **The bootstrap admin must rotate its password on first login.** The login response carries `must_change_password`, and the account stays in that state until the password is changed — an automation that logs in with the bootstrap credentials and ignores the flag will not get far. * **Cookie-authenticated mutations require an `X-KubeMQ-CSRF` header.** Bearer-authenticated service accounts are exempt: there is no ambient credential for a malicious site to replay, so the guard applies to session cookies only. **A `read_only` account is a real account, and it can read configuration.** If you are handing out dashboard access, `read_only` is the right default — but treat it as a principal with visibility into server settings, not as a view of nothing sensitive. **Wildcard CORS is refused when API auth is enabled.** With `api.auth.enable: true`, the server rejects boot if `api.alloworigins` is empty or contains `"*"` — credentialed auth requires a concrete origin list (e.g. `["https://app.example.com"]`). The `sessionIdleMinutes`/`sessionAbsoluteHours` values must both be positive. ## Shared HTTP server [#shared-http-server] The shared HTTP server hosts MCP, A2A, and CloudEvents on the REST port. On Kubernetes it is a first-class `spec.http.*` group; its CORS lives under `spec.http.cors.*`. It has no enable/disable toggle — it is active whenever REST is, and the CRD emits its env vars only for the `spec.http.*` fields you set. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ---------------------- | --------- | ------------------------------------------------------------------------------------------ | ---------------- | ---------------------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------ | | Port | string | inherits `Rest.Port` (`9090`) | 1–65535 | `connectors.http.port` · `CONNECTORS_HTTP_PORT` | `spec.http.port` | Empty ⇒ inherits the REST port. A differing explicit value logs a warning; the HTTP server uses `Http.Port`. | | Read timeout | int (s) | `60` | ≥ 0 (CRD ≥ 1) | `connectors.http.readtimeout` · `CONNECTORS_HTTP_READ_TIMEOUT` | `spec.http.readTimeout` | Negative rejected. | | Body limit | string | `"100M"` | Echo size string | `connectors.http.bodylimit` · `CONNECTORS_HTTP_BODY_LIMIT` | `spec.http.bodyLimit` | CRD field is a string. | | Base URL | string | `""` | URL | `connectors.http.baseurl` · `CONNECTORS_HTTP_BASE_URL` | `spec.http.baseUrl` | Server field is `BaseURL`; env `..._BASE_URL`. | | CORS allow origins | string\[] | `["*"]` | origin list | `connectors.http.cors.alloworigins` · `CONNECTORS_HTTP_CORS_ALLOW_ORIGINS` | `spec.http.cors.allowOrigins` | | | CORS allow methods | string\[] | `GET, POST, DELETE, OPTIONS` | method list | `connectors.http.cors.allowmethods` · `CONNECTORS_HTTP_CORS_ALLOW_METHODS` | `spec.http.cors.allowMethods` | Wider than REST — MCP/A2A need `DELETE`/`OPTIONS`. | | CORS allow headers | string\[] | `Authorization, Content-Type, MCP-Protocol-Version, MCP-Session-Id, Last-Event-ID, Accept` | header list | `connectors.http.cors.allowheaders` · `CONNECTORS_HTTP_CORS_ALLOW_HEADERS` | `spec.http.cors.allowHeaders` | Includes the `MCP-*` headers. | | CORS allow credentials | bool | `false` | true / false | `connectors.http.cors.allowcredentials` · `CONNECTORS_HTTP_CORS_ALLOW_CREDENTIALS` | `spec.http.cors.allowCredentials` | | | CORS expose headers | string\[] | `MCP-Session-Id, MCP-Protocol-Version` | header list | `connectors.http.cors.exposeheaders` · `CONNECTORS_HTTP_CORS_EXPOSE_HEADERS` | `spec.http.cors.exposeHeaders` | | | CORS max age | int (s) | `86400` | ≥ 0 | `connectors.http.cors.maxage` · `CONNECTORS_HTTP_CORS_MAX_AGE` | `spec.http.cors.maxAge` | Negative rejected. | **Version floor:** the shared-HTTP `spec.http.*` fields are present throughout the current GA chart line — `kubemq-crds` and `kubemq-cluster` **3.x** (latest **3.2.0**) with `kubemq-controller` **2.x** (operator **v2.3.0**). Anything older than the 3.0.0 / 2.0.0 GA release predates this reference: upgrade to the current line rather than trying to work out which pre-GA build carried which field. On Docker the `connectors.http.*` keys are available regardless of chart version. ## Example [#example] Set the gRPC port on each target. On Kubernetes `spec.grpc.port` moves the in-pod listener, the `Service` port/`targetPort`, and the container port together; on Docker it moves the listener and you publish it with `-p`. This is a single-setting snippet — see the [Docker guide](/configure/docker) and the [Kubernetes guide](/configure/kubernetes) for complete, runnable configurations. ```yaml title="config.yaml" connectors: grpc: port: "50000" ``` ```yaml title="values.yaml" grpc: port: 50000 ``` For the full Docker delivery methods and `docker run` port publishing see the [Docker guide](/configure/docker); for `values.yaml` mapped to the `KubemqCluster` spec and `Service` exposure see the [Kubernetes guide](/configure/kubernetes). # Observability (/configure/reference/observability) KubeMQ reports on itself through three domains: **OpenTelemetry** traces and metrics, an **audit** log of operations, and **notifications** about server events. Each setting is shown for both deployment targets — Docker single-node (`config.yaml` key · env var) and Kubernetes/Helm (`spec.*` path). A dash (`—`) in the Helm/CRD column means the setting is not available on that surface; a dash in the env-var slot means the field has **no environment binding at all** (config-file/secret-only). This page is the **settings reference** for telemetry, audit, and notifications. For concepts, how-to guides, the full metric series, the audit event catalog, and the management API, see **[Observability](/operate/observability)**. **Version floor:** the `spec.telemetry.*` and `spec.audit.*` fields are present throughout the current GA chart line — `kubemq-crds` and `kubemq-cluster` **3.x** (latest **3.2.0**) with `kubemq-controller` **2.x** (operator **v2.3.0**). Anything older than the 3.0.0 / 2.0.0 GA release predates this reference and will reject these fields; upgrade to the current line. On Docker the `telemetry.*` and `audit.*` keys are available regardless of chart version. ## Telemetry (OpenTelemetry) [#telemetry-opentelemetry] KubeMQ exports traces and metrics over OTLP. The scalar fields are a first-class `spec.telemetry.*` group on Kubernetes. The master `enable` flag is **off by default**; turn it on, then optionally tune the traces, metrics, and exporter sub-blocks. Every sub-block only takes effect while the master switch is on. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ----------------------- | ------------------ | ---------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | Enable (master) | bool | `false` | true / false | `telemetry.enable` · `TELEMETRY_ENABLE` | `spec.telemetry.enable` | Master switch — off disables all telemetry (`Validate` returns early). | | Service name | string | `kubemq` | non-empty service name | `telemetry.servicename` · `TELEMETRY_SERVICE_NAME` | `spec.telemetry.serviceName` | Reported as the OTLP `service.name`. Empty is coerced back to `kubemq` at load. | | Traces enable | bool | `true` | true / false | `telemetry.traces.enable` · `TELEMETRY_TRACES_ENABLE` | `spec.telemetry.traces.enable` | Applies only when the master switch is on. | | Traces sampling ratio | float64 | `1.0` | `0`–`1` | `telemetry.traces.samplingratio` · `TELEMETRY_TRACES_SAMPLING_RATIO` | `spec.telemetry.traces.samplingRatio` | Fraction of traces sampled. Out-of-range values are coerced back to `1.0`. | | Traces sampler | enum | `parent_based` | `always_on` / `always_off` / `trace_id_ratio` / `parent_based` | `telemetry.traces.sampler` · `TELEMETRY_TRACES_SAMPLER` | `spec.telemetry.traces.sampler` | Sampling strategy. Any other value is coerced to `parent_based`. | | Metrics enable | bool | `true` | true / false | `telemetry.metrics.enable` · `TELEMETRY_METRICS_ENABLE` | `spec.telemetry.metrics.enable` | Applies only when the master switch is on. | | Metrics export interval | duration | `30s` | Go duration string | `telemetry.metrics.exportinterval` · `TELEMETRY_METRICS_EXPORT_INTERVAL` | `spec.telemetry.metrics.exportInterval` | How often metrics are pushed. Unparseable values are coerced to `30s`. | | Exporter protocol | enum | `grpc` | `grpc` / `http` | `telemetry.exporter.protocol` · `TELEMETRY_EXPORTER_PROTOCOL` | `spec.telemetry.exporter.protocol` | OTLP wire protocol. Any other value is coerced to `grpc`. | | Exporter endpoint | string | `localhost:4317` | host:port | `telemetry.exporter.endpoint` · `TELEMETRY_EXPORTER_ENDPOINT` | `spec.telemetry.exporter.endpoint` | OTLP collector endpoint. Empty is coerced to `localhost:4317`. | | Exporter insecure | bool | `true` | true / false | `telemetry.exporter.insecure` · `TELEMETRY_EXPORTER_INSECURE` | `spec.telemetry.exporter.insecure` | Disable TLS to the collector. | | Exporter compression | enum | `gzip` | `gzip` / `none` | `telemetry.exporter.compression` · `TELEMETRY_EXPORTER_COMPRESSION` | `spec.telemetry.exporter.compression` | Payload compression. Any other value is coerced to `gzip`. | | Exporter timeout | duration | `10s` | Go duration string | `telemetry.exporter.timeout` · `TELEMETRY_EXPORTER_TIMEOUT` | `spec.telemetry.exporter.timeout` | Export request timeout. Unparseable values are coerced to `10s`. | | Exporter headers | map\[string]string | `{}` | key/value map | `telemetry.exporter.headers` · ⚠️ `TELEMETRY_EXPORTER_HEADERS` (**do not use — see callout**) | — | **Config-file-only in practice.** A `TELEMETRY_EXPORTER_HEADERS` env binding exists but is a landmine (below). Not exposed on the CRD. | | Resource attributes | map\[string]string | `{}` | key/value map | `telemetry.resource` · — (no env binding) | — | **Config-file-only.** No `bindViperEnv` call and no CRD field — set only in the mounted `config.yaml`. | **Do not set `TELEMETRY_EXPORTER_HEADERS` — it corrupts the entire running config.** `Telemetry.Exporter.Headers` is a `map[string]string`, and viper/mapstructure has no default string→map decode hook. Setting the env var makes `viper.Unmarshal` fail, and the loader's error path **discards the whole config and falls back to pure defaults** — every other file setting and env var is silently lost, with only a stderr line as evidence. The env name appears in the golden key list, but treat it as unusable: configure exporter headers only via the `telemetry.exporter.headers` map in the mounted `config.yaml`. The sibling `telemetry.resource` map is safer — it has **no** env binding at all, so it is cleanly config-file-only. Neither map has a CRD path. ## Audit [#audit] The audit log records server operations and retains them for a configurable window. It is a first-class `spec.audit.*` group on Kubernetes and is **enabled by default**. The CRD models `enable` as `*bool` deliberately — because audit defaults on server-side, a plain bool could never emit `false` to turn it off. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ---------------------- | ---- | ------- | ------------ | ----------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | Enable | bool | `true` | true / false | `audit.enable` · `AUDIT_ENABLE` | `spec.audit.enable` | Audit is on by default. CRD field is `*bool` so it can emit `false`. | | Retention hours | int | `720` | ≥ 1 | `audit.retentionhours` · `AUDIT_RETENTION_HOURS` | `spec.audit.retentionHours` | How long audit records are kept (720h = 30 days). Values \< 1 are coerced to `720`. CRD type is `int32` with `Minimum=1`. | | Cleanup interval (min) | int | `60` | ≥ 1 | `audit.cleanupintervalminutes` · `AUDIT_CLEANUP_INTERVAL_MINUTES` | `spec.audit.cleanupIntervalMinutes` | How often expired records are purged. Values \< 1 are coerced to `60`. CRD type is `int32` with `Minimum=1`. | ## Notifications [#notifications] Server-event notifications are published to an internal channel. The toggle and prefix have **name divergences** between the two targets — read the Notes column carefully. The server struct has no `mapstructure` tags; viper binds these by field path (case-insensitive). | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ----------------- | ------ | --------------- | ---------------- | ------------------------------------------------------------------------- | --------------------------- | ------------------------------------------------------ | | Enable | bool | `false` | true / false | `notification.enable` · `NOTIFICATION_ENABLE` | `spec.notification.enabled` | **Name divergence:** Docker `enable` ↔ Helm `enabled`. | | Channel prefix | string | `notifications` | non-empty prefix | `notification.reportchannelprefix` · `NOTIFICATION_REPORT_CHANNEL_PREFIX` | `spec.notification.prefix` | **Name divergence:** `reportchannelprefix` ↔ `prefix`. | | Log notifications | bool | `false` | true / false | `notification.log` · `NOTIFICATION_LOG` | `spec.notification.log` | Also write notifications to the server log. | **Cross-field rule:** when notifications are enabled, if `log` is `false` **and** the channel prefix is empty, the server fails validation with `bad notification configuration: missing parameters`. Either keep a non-empty prefix (the default `notifications` satisfies this) or set `log: true`. ## Example [#example] Enable telemetry on each target. This is a single-setting snippet — see the [Docker guide](/configure/docker) and the [Kubernetes guide](/configure/kubernetes) for complete, runnable configurations. ```yaml title="config.yaml" telemetry: enable: true ``` ```yaml title="values.yaml" telemetry: enable: true ``` For the full Docker delivery methods (env vars, mounted `config.yaml`, the `CONFIG` variable) see the [Docker guide](/configure/docker); for `values.yaml` mapped to the `KubemqCluster` spec see the [Kubernetes guide](/configure/kubernetes). # Security (Auth · TLS) (/configure/reference/security) KubeMQ secures the server with three independent layers: **authentication** (verify who is connecting, via JWT or OIDC), **authorization** (policy-based access control), and **TLS/mTLS** (transport encryption). Each setting is shown for both deployment targets — Docker single-node (`config.yaml` key · env var) and Kubernetes/Helm (`spec.*` path). A dash (`—`) in the Helm/CRD column means the setting is not available on that surface (it is `config.yaml`/env-only). **Version floor:** the aligned `spec.authentication.enable`, `spec.authentication.type`, `spec.authentication.signatureType`, `spec.authentication.key`, and `spec.authentication.oidc` fields are present throughout the current GA chart line — `kubemq-crds` and `kubemq-cluster` **3.x** (latest **3.2.0**) with `kubemq-controller` **2.x** (operator **v2.3.0**). Anything older than the 3.0.0 / 2.0.0 GA release predates this reference and will reject these fields; upgrade to the current line. On Docker the corresponding `authentication.*` keys are available regardless of chart version. ## Authentication [#authentication] Authentication is **off by default** and opt-in (`enable: true`). The `type` field is the **mode selector**: the literal value `oidc` delegates verification to an OIDC provider; **any other value (including empty or `jwt`) selects JWT mode**, which validates a signed token with a configured key and signature algorithm. There is no wire-connector opt-in/opt-out toggle here — authentication uses the plain `enable` flag on both surfaces. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | -------------------- | -------------------- | ------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Enable | bool | `false` | true / false | `authentication.enable` · `AUTHENTICATION_ENABLE` | `spec.authentication.enable` | Off by default. When `false` the server ignores every other auth field (`authentication.go:100`). Operator emits from the `Enable` pointer. | | Type (mode selector) | string | `""` | `oidc`, or empty ⇒ JWT | `authentication.type` · `AUTHENTICATION_TYPE` | `spec.authentication.type` | Only the exact value `oidc` triggers OIDC; empty or any other value ⇒ JWT (`authentication.go:103`). | | JWT signature type | string | `""` | `HS256`·`HS384`·`HS512`·`RS256`·`RS384`·`RS512`·`ES256`·`ES384`·`ES512` | `authentication.jwtconfig.signaturetype` · `AUTHENTICATION_JWT_CONFIG_SIGNATURE_TYPE` | `spec.authentication.signatureType` | Required when JWT is enabled (`authentication.go:19`). The 9 algorithms come from `pkg/authentication/jwt.go:10-20`. Name divergence `jwtconfig.signaturetype` ↔ `signatureType`. Operator stores it in a **Secret**. | | JWT key | string | `""` | HMAC secret / PEM public key | `authentication.jwtconfig.key` · `AUTHENTICATION_JWT_CONFIG_KEY` | `spec.authentication.key` | Verification key, read verbatim. Either `key` or `filePath` is required (`authentication.go:22`). Name divergence `jwtconfig.key` ↔ `key`. Operator stores it in a **Secret**. | | JWT key file | string | `""` | file path | `authentication.jwtconfig.filepath` · `AUTHENTICATION_JWT_CONFIG_FILE_PATH` | — | Alternative to the inline key; validated as a filename (`authentication.go:25`). **`config.yaml`/env-only** — superseded by the inline `key` on the CRD (allowlist). | | OIDC config | string (base64 JSON) | `""` | base64-encoded OIDC JSON | `authentication.config` · `AUTHENTICATION_CONFIG` | `spec.authentication.oidc` | Docker takes **base64-encoded** OIDC JSON, which the server base64-decodes (`authentication.go:74`). Helm takes a first-class `oidc` block that the operator encodes for you. Required when `type: oidc` (`authentication.go:104`). | ### OIDC block fields [#oidc-block-fields] These live inside the OIDC config: on Docker they are keys of the base64-encoded JSON in `AUTHENTICATION_CONFIG`; on Helm they are typed fields under `spec.authentication.oidc`. They have **no individual env bindings** — the whole block travels as the single `AUTHENTICATION_CONFIG` value (config-file/secret-only per field). | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ----------------------------- | ------ | ------- | ---------------- | -------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------ | | Issuer | string | `""` | issuer URL | *(part of `authentication.config` JSON)* · — | `spec.authentication.oidc.issuer` | **Required** (`authentication.go:51`). | | Client ID | string | `""` | OAuth2 client id | *(part of `authentication.config` JSON)* · — | `spec.authentication.oidc.clientID` | Required unless `skipClientIDCheck` is set (`authentication.go:54`). | | Skip client-ID check | bool | `false` | true / false | *(part of `authentication.config` JSON)* · — | `spec.authentication.oidc.skipClientIDCheck` | Disables audience validation. | | Skip expiry check | bool | `false` | true / false | *(part of `authentication.config` JSON)* · — | `spec.authentication.oidc.skipExpiryCheck` | **Insecure** — accepts expired tokens; logs a warning (`authentication.go:64`). | | Skip issuer check | bool | `false` | true / false | *(part of `authentication.config` JSON)* · — | `spec.authentication.oidc.skipIssuerCheck` | **Insecure** — accepts any issuer; logs a warning (`authentication.go:67`). | | Insecure skip signature check | bool | `false` | true / false | *(part of `authentication.config` JSON)* · — | `spec.authentication.oidc.insecureSkipSignatureCheck` | **Insecure** — token signatures are NOT verified; logs a warning (`authentication.go:61`). | **OIDC hard rejection:** a config that disables **all four** checks at once (`insecureSkipSignatureCheck` + `skipExpiryCheck` + `skipIssuerCheck` + `skipClientIDCheck`) is **rejected** — at least one check must remain enabled (`authentication.go:58`). **`spec.authentication.oidc` supersedes the legacy `spec.configData` OIDC block.** A CR that sets **both** JWT fields (`key` / `signatureType`) **and** `oidc` is **rejected** — pick one authentication mode (k8s `config/authentication.go:100`). Note that `spec.configData` is a raw string carrying **only** an OIDC block (k8s `config/config_data.go`); it is **not** a generic `config.yaml` passthrough. The rejection happens twice and is loud: **at admission**, by a CEL rule on the `KubemqCluster` CRD shipped in charts **3.2.0**, and again **at reconcile** by the operator (**v2.3.0**), which raises a `ReconcileError` condition and a `Warning` event and **leaves running pods untouched**. **`ReconcileError` clears on its own once you fix the CR.** From operator v2.3.0 the condition flips to `False` with reason `LastReconcileCycleSucceeded` on the next reconcile that completes — it is not deleted, so alerts keyed on the condition's *presence* keep working and the recovery carries its own transition time. On older operators the condition stayed `True` with the original stale message indefinitely; if you are reading a cluster that has never been through a v2.3.0 reconcile, compare `observedGeneration` against `generation` before believing it. **If you already have a cluster with both modes set, it is serving traffic unauthenticated right now.** Before operator v2.3.0 that combination was accepted and produced a broker with **no authentication at all** — no `AUTHENTICATION_*` variable reached the pod, the pod was `Running` and ready, and the CR reported `Deployed` with no conditions. Nothing surfaced the problem. Two things follow. **Fix the CR before you upgrade** — on operator v2.3.0 an affected cluster stops reconciling until it is fixed (running pods keep serving; new changes stop being applied). And **treat the window as an exposure**: the broker was reachable without credentials for as long as that CR was live. Find affected clusters: ```bash kubectl get kubemqclusters.core.k8s.kubemq.io -A -o json \ | jq -r '.items[] | select(.spec.authentication.oidc != null and (((.spec.authentication.key // "") != "") or ((.spec.authentication.signatureType // "") != ""))) | "\(.metadata.namespace)/\(.metadata.name)"' ``` Then remove **one** of the two blocks — either `oidc`, or the `key` / `signatureType` pair — so exactly one authentication mode remains. ## Authorization [#authorization] Policy-based access control, **off by default**. Supply the policy inline (`policy`) or by URL (`url`), with optional periodic auto-reload. When enabled, **exactly one** of policy data, policy file, or URL must be present (`authorization.go:34`). | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | --------------------- | --------------- | ------- | ------------------------------ | -------------------------------------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Enable | bool | `false` | true / false | `authorization.enable` · `AUTHORIZATION_ENABLE` | — (auto-derived) | Docker opt-in. On the CRD **there is no enable field** — the operator sets `AUTHORIZATION_ENABLE=true` automatically whenever `policy` or `url` is set (k8s `config/authorization.go:20-25`). | | Policy data | string (base64) | `""` | base64-encoded policy document | `authorization.policydata` · `AUTHORIZATION_POLICY_DATA` | `spec.authorization.policy` | **Base64-encoded** — the server base64-decodes the policy content. Name divergence `policydata` ↔ `policy`. Operator emits it base64 (k8s `config/authorization.go:27`). | | Policy URL | string | `""` | http(s) URL | `authorization.url` · `AUTHORIZATION_URL` | `spec.authorization.url` | **Raw, NOT base64** — the server hands it verbatim to `http.Get`; `validateURL` rejects a base64 blob (`authorization.go:51`, k8s `config/authorization.go:32`). `config.yaml` key is `authorization.url` (server field `Authorization.Url`). | | Auto-reload (seconds) | int | `0` | ≥ 0 (`0` = disabled) | `authorization.autoreload` · `AUTHORIZATION_AUTO_RELOAD` | `spec.authorization.autoReload` | Reload interval in **seconds** (`services/authorization/authorization.go:50`). Negative is rejected (`authorization.go:37`). Name divergence `autoreload` ↔ `autoReload`. Omitted from CRD env when `0`. | | Policy file | string | `""` | file path | `authorization.filepath` · `AUTHORIZATION_FILE_PATH` | — | Validated as a filename (`authorization.go:44`). **`config.yaml`/env-only** — superseded by the inline `policy` on the CRD (allowlist). | ## TLS / mTLS [#tls--mtls] Transport encryption for the interfaces. There is **no enable flag** — the mode is **auto-derived** from which artifacts are present (`security.go:76`): **none** (omit all), **TLS** (server `cert` + `key`), and **mTLS** (additionally a client `ca`, so both peers authenticate). The Docker `config.yaml` group is `security.*`; the Helm/CRD group is `spec.tls.*` — a **name divergence**. On Docker each artifact accepts inline `data` or a `filename`, and inline `data` takes precedence over `filename` (`resource.go:19`). | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ----------- | ------ | ------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | Server cert | string | `""` | PEM block (`data`) / file path (`filename`) | `security.cert.data` / `security.cert.filename` · `SECURITY_CERT_DATA` / `SECURITY_CERT_FILENAME` | `spec.tls.cert` | **Name divergence:** `security` ↔ `tls`. Required for TLS and mTLS (`security.go:48`). `*_DATA` is a raw PEM; the operator stores it in a **Secret**. | | Server key | string | `""` | PEM block (`data`) / file path (`filename`) | `security.key.data` / `security.key.filename` · `SECURITY_KEY_DATA` / `SECURITY_KEY_FILENAME` | `spec.tls.key` | Required for TLS and mTLS (`security.go:52`). Secret on the CRD. | | CA (mTLS) | string | `""` | PEM block (`data`) / file path (`filename`) | `security.ca.data` / `security.ca.filename` · `SECURITY_CA_DATA` / `SECURITY_CA_FILENAME` | `spec.tls.ca` | Presence promotes the mode to **mTLS** (client-certificate verification, `security.go:79`). Secret on the CRD. | On the CRD only the inline **data** fields (`spec.tls.cert` / `.key` / `.ca`) are exposed. The `SECURITY_*_FILENAME` keys are **`config.yaml`/env-only** and are superseded by the inline data on the CRD (allowlist). The same holds for `AUTHENTICATION_JWT_CONFIG_FILE_PATH` and `AUTHORIZATION_FILE_PATH`. ## Example [#example] Supply a TLS server certificate on each target. This is a single-setting snippet — see the [Docker guide](/configure/docker) and the [Kubernetes guide](/configure/kubernetes) for complete, runnable configurations. ```yaml title="config.yaml" security: cert: filename: /certs/server.crt key: filename: /certs/server.key ``` ```yaml title="values.yaml" tls: cert: | -----BEGIN CERTIFICATE----- ... -----END CERTIFICATE----- key: | -----BEGIN PRIVATE KEY----- ... -----END PRIVATE KEY----- ``` For the full Docker delivery methods (env vars, mounted `config.yaml`, the `CONFIG` variable) see the [Docker guide](/configure/docker); for `values.yaml` mapped to the `KubemqCluster` spec see the [Kubernetes guide](/configure/kubernetes). # Storage Engines (/configure/reference/storage-engines) KubeMQ ships **two persistence engines**: `next` and `legacy`. The engine governs **only the persistence plane** — the Events Store and Queues. Ephemeral patterns (Events pub/sub, Commands/Queries RPC) ride the same internal messaging core in both modes and behave **identically** regardless of which engine a cluster runs. ## The two engines [#the-two-engines] | | `legacy` | `next` (resolved on a clean store) | | ---------------------------------------------- | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Persistence | the legacy persistence engine (file store) | owned segment log + Dragonboat raft | | Durability on ack | accepted into the legacy file store; fsync on a timer (`Broker.DiskSyncSeconds`, default 5s) | **selectable.** `fast` (**the default**): ack after quorum-replication, fsync follows in a bounded background window. `strict`: ack only after quorum-replication **and** fsync (zero acked-loss). See [Ack policy](#ack-policy) | | Cluster consensus | file-store raft over the cluster mesh | Dragonboat raft over a dedicated replication listener (optional mTLS, default off) | | Kafka compaction | not supported (Kafka is `next`-only) | supported — unlocks Kafka Connect and Kafka Streams | | Native store limits & retention (`store.max*`) | enforced | **not consumed** — native channels have no age/size/count cap ([details](#native-retention-scope)) | | Status | **feature-frozen** (maintenance/security/bug fixes only); opt-in — name it explicitly at creation | what a clean store resolves to; receives all new durability and compaction features | ## Choosing an engine [#choosing-an-engine] * **A new deployment on a clean store comes up on `next`.** There is nothing to set — see [Zero-config engine selection](#zero-config-engine-selection) below. * **`next` is also required for Kafka** compacted topics, and it is the only engine that can give you quorum-fsynced durability — **but not by default**: that needs `store.nextackpolicy: strict`. See [Ack policy](#ack-policy). * **To run `legacy`, name it explicitly at creation** — `store.engine: legacy` (Docker) or `spec.store.engine: legacy` (Kubernetes). It is no longer what you get by omission. * **An existing cluster keeps the engine it was born with.** There is no migration path between engines, and nothing about the change above rolls or reinterprets a running cluster. **If you have a page, script, or values file that assumes `legacy` is what you get by default, it is now wrong.** A fresh cluster with a clean store resolves to `next`. This changed in operator **v2.3.0** on Kubernetes; on Docker the server has always resolved a clean store to `next` when the engine was left unset. ## Zero-config engine selection [#zero-config-engine-selection] An explicit engine always wins and skips every probe below: `store.engine` (Docker `config.yaml`), `STORE_ENGINE` (env), or `spec.store.engine` (Kubernetes). Otherwise **the server resolves the engine itself at boot**, by probing the store directory: * **The directory already holds a store** → the engine that wrote it. * **The directory is clean** → `next`. The resolved engine is named in a **boot NOTICE** in the server log, along with the deciding signal and the directory that was probed. When the choice was delegated rather than pinned, **that log line is the only place the running engine is reported** — see [Which engine am I actually on?](#which-engine-am-i-actually-on) below. ### On Kubernetes the operator delegates, with `STORE_ENGINE=auto` [#on-kubernetes-the-operator-delegates-with-store_engineauto] From operator **v2.3.0**, a cluster with **no engine on record** is created with `STORE_ENGINE=auto` and the server resolves it as above. Established clusters are untouched — their engine stays named explicitly and nothing rolls. Three consequences worth holding onto: * **`status.engine` and the `core.k8s.kubemq.io/established-engine` annotation both read `auto`** on such a cluster. That value records *the delegation*, not the engine running. * **Setting `spec.store.engine` later on an `auto` cluster is allowed.** `auto` records that the cluster delegated the choice, not an established engine, so pinning afterwards is a first choice rather than an engine change. **Pin the engine the server actually resolved** — a pin that disagrees with the data on disk is refused by the server at boot, and the server never deletes a datadir. * **On an established cluster the annotation is authoritative and outranks `spec.store.engine`.** A mismatch is refused, not silently applied. See the [engine-establishment guard](/configure/reference/deployment#engine-establishment-guard). ### Which engine am I actually on? [#which-engine-am-i-actually-on] | Cluster | Where to read the engine | | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Engine named explicitly | `spec.store.engine`, `status.engine`, or the annotation — all agree | | Engine delegated (`auto`) | **the server's boot NOTICE in the pod log**, or the two API fields below. `status.engine` and the annotation both say `auto`, which is the delegation, not the answer | ```bash kubectl logs -n | grep -i 'store engine' ``` The management API's config view carries the same answer in two read-only fields — they exist precisely because this question was previously unanswerable on a delegating deployment. Neither is an operator input, and neither is ever written back into a saved config file: | Field | What it holds | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `engineResolved` | The engine this node is **actually running** — always a concrete `legacy` or `next`, never `auto`. | | `engineResolvedSignal` | **Why** it holds that, in plain words: `clean store (auto default)`, `explicitly configured (detection did not run)`, `clean store (CleanStore=true)`, or the on-disk evidence the probe matched. | The signal matters as much as the engine. A node reporting `legacy` may have been pinned by an operator, or may have detected legacy data on disk — and the remedy differs. ### Kafka [#kafka] Kafka runs on `next` only, and needs no manual engine step: * **Clean store + Kafka enabled + engine unset** → resolves to `next` (NOTICE logged) — the same clean-store outcome as without Kafka. * **Existing `legacy` store data + Kafka enabled** → fails closed with a config error naming the conflicting store directory. Kafka never ran on `legacy`, so the server refuses to mix engines rather than silently reinterpreting existing data. * **Explicit `store.engine: legacy` + Kafka enabled** → rejected at boot. Kafka requires the `next` engine; naming `legacy` alongside it is a configuration error. * **Naming `next` explicitly** skips the probe entirely and always wins — the predictable choice for IaC/GitOps that shouldn't depend on probe-time filesystem state. ## Mode isolation [#mode-isolation] A cluster is **born one mode and stays there**. The data directory records a mode marker at first boot. Every later boot compares that marker against the configured engine — a **mismatch is a fatal boot error**: the server refuses to start, and it **never wipes the directory**. There is no in-place engine migration and no cross-format compatibility between `legacy` and `next` data. ## Durability guarantees [#durability-guarantees] Three distinct dimensions govern durability, and they should not be conflated: 1. **Ack meaning.** On `legacy`, a publish is acknowledged after the write lands in the file store; the store is fsynced on a timer (`Broker.DiskSyncSeconds`, default 5s) — so an ack can precede the fsync. On `next`, **it depends on the ack policy** below: under `strict` a publish is acknowledged only after the write is quorum-replicated **and** fsynced; under `fast` — the shipped default — it is acknowledged once quorum-replicated, with the fsync following in a bounded background window. 2. **Loss window.** "Zero acked-loss" describes **`next` with `strict` only**. It means an **acked** message is guaranteed to survive. Under the default `fast`, an ack does **not** imply the record is on disk: writes acknowledged inside the flush window can be lost to power loss on a whole-quorum failure. And on any engine or policy, "zero acked-loss" never means zero loss overall — unacked, in-flight work can still be lost if the client never receives the ack. 3. **Consistency under failover.** On `next`, per-channel sequence numbers are assigned from the committed raft apply order — they are **gap-free, monotonic, and restart-stable**, even across a leader election. ## Next-engine settings [#next-engine-settings] Six `next`-engine settings live on the store config. All are `config.yaml` / env-only — none has a typed CRD field, so on Kubernetes they travel through `spec.env` or a mounted config. Every one of them parses and validates on **both** engines; they are simply inert under `legacy`, so a config that would be wrong after an engine switch fails when it is written rather than when it becomes load-bearing. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | --------------------- | ------------- | ----------------------------- | ------------------ | --------------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Ack policy | string (enum) | `fast` | `fast` \| `strict` | `store.nextackpolicy` · `STORE_NEXT_ACK_POLICY` | — | The ack durability contract — see [Ack policy](#ack-policy) below. **Exact match only**: `Fast`, `STRICT` and `true` are all rejected. | | Raft address | string | `""` → `127.0.0.1:` | `host:port` | `store.nextraftaddress` · `STORE_NEXT_RAFT_ADDRESS` | — | The engine's Dragonboat replication-listener bind address. Empty computes a single-node loopback default. A standalone node pins the listener to loopback regardless — it must not be routable. | | Segment size (bytes) | int64 | `0` → engine default (64 MiB) | ≥ `0` | `store.nextsegmentsize` · `STORE_NEXT_SEGMENT_SIZE` | — | Per-channel segment-log size, the analog of Kafka's `segment.bytes`. A smaller value rolls segments sooner, which is what lets compaction reclaim space promptly on low-volume topics. | | Shard-pool size | int | `1` | `1`–`16` | `store.nextshards` · `STORE_NEXT_SHARDS` | — | Number of independent raft groups the persistent data plane is partitioned across, with channels hash-mapped onto them. `1` is the GA layout and is behavior-identical to it. `0` normalizes to `1`; above `16` is rejected. | | Balance shard leaders | bool | `false` | true / false | `store.nextbalanceleaders` · `STORE_NEXT_BALANCE_LEADERS` | — | Opt-in: spreads shard leaders across cluster nodes instead of pinning them all to one. Meaningless without a shard pool > 1 — rejected unless the cluster is clustered `next` with `nextshards` > 1. `false` is the shipped GA behavior. | ### Ack policy [#ack-policy] **The shipped default is `fast`, and under `fast` an ack does NOT mean the record is on disk.** A fresh `next` cluster does not give you the quorum-fsynced ack unless you ask for it. | | `fast` (**default**) | `strict` | | ----------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | Ack means | the entry is **quorum-replicated**; the raft log fsync follows in the background, within a bounded window | the entry is quorum-replicated **and fsynced on a quorum** of raft logs | | Loss window | writes acked inside the flush window can be lost to a whole-quorum power loss | none — this is the "zero acked-loss" contract | | Cost | lower publish latency | fsync on the publish path | If a compliance requirement or an audit answer depends on *"an ack means it is on disk"*, set it explicitly: ```yaml title="config.yaml" store: nextackpolicy: strict ``` On Kubernetes, via `spec.env`: ```yaml title="values.yaml" env: STORE_NEXT_ACK_POLICY: "strict" ``` ## Native retention scope [#native-retention-scope] The store-level limit and retention settings — **Max channels**, **Max channel size**, **Max messages**, **Message retention**, and **Purge inactive** (see [Storage & Queues](/configure/reference/storage-queues)) — are enforced by the **`legacy` engine only**. The `next` engine does not consume them: a native Events Store or Queues channel on `next` has **no age, size, or count cap** and grows unbounded under a slow or absent consumer. The queue ack-wait timeout governs redelivery of in-flight messages — it is not a backlog-eviction bound. On `next`, the only age-evicted channels are **Kafka topic channels**, via the Kafka connector's `retention.ms` (see [Durability & Retention](/connectors/kafka/concepts/durability-and-retention#retention-time-and-size)). Size native Events Store / Queues workloads on `next` by disk capacity, or use Kafka topic channels where age eviction matters. **This now applies to new clusters by default.** Because a clean store resolves to `next`, a freshly installed cluster has **no age, size, or count cap** on native Events Store and Queues channels unless you set one — the `store.max*` limits are `legacy`-only. Size by disk, and set a `spec.volume.size` you can live with. ## Support & deprecation [#support--deprecation] Both engines are **fully supported today**. `legacy` is **feature-frozen** — it receives maintenance, security, and bug fixes only. `next` receives all new durability and compaction features going forward. The deprecation policy for `legacy` is **explicitly TBD** — no sunset date has been set, and both engines are supported until one is. ## Compaction [#compaction] Kafka log compaction is **`next`-only**. The Kafka connector's `cleanup.policy` accepts `delete` (default), `compact`, or `compact,delete`. Compaction keeps only the latest record per key and reaps tombstones after `delete.retention.ms` (default 24h) — it never renumbers surviving offsets. Compaction is scoped to Kafka topic channels only; native Queues and Events Store channels never compact. This is the feature that unlocks Kafka Connect (its internal topics require compaction) and Kafka Streams changelogs. ## Clustered next-engine replication [#clustered-next-engine-replication] The `Cluster.Replication.*` block configures the `next` engine's Dragonboat replication listener — a second membership plane a `next`-mode cluster runs alongside the cluster mesh. It is meaningful **only** when `Store.Engine` is `next` **and** `Cluster.Enable` is `true`. This block is Docker / `config.yaml` and env-only — there is **no Helm/CRD path**. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | -------------------- | ------ | ------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Replica ID | uint64 | `0` | ≥ 0 | `cluster.replication.replicaid` · `CLUSTER_REPLICATION_REPLICA_ID` | — | This node's Dragonboat replica id (1..N); must be a key in `Peers`. May be left `0` when `POD_NAME` is present — see [ReplicaID auto-derive](#replicaid-auto-derive). | | Raft address | string | `""` | `host:port` | `cluster.replication.raftaddress` · `CLUSTER_REPLICATION_RAFT_ADDRESS` | — | This node's **advertised** raft address. When set it wins over the address derived from `Peers` for what this node announces to its peers — set it when the address peers must dial differs from the one this node binds. | | Peers | string | `""` | `id@host:port,...` | `cluster.replication.peers` · `CLUSTER_REPLICATION_PEERS` | — | Full initial-members map — every initial member, including self. Byte-identical on every pod. | | Join | bool | `false` | true / false | `cluster.replication.join` · `CLUSTER_REPLICATION_JOIN` | — | Boot as a later-added replica, with an empty members map. | | RTT (ms) | uint64 | `200` | ≥ 0 | `cluster.replication.rttmillisecond` · `CLUSTER_REPLICATION_RTT_MILLISECOND` | — | Dragonboat logical-clock tick, in milliseconds. | | Election RTT | uint64 | `10` | ≥ 0 | `cluster.replication.electionrtt` · `CLUSTER_REPLICATION_ELECTION_RTT` | — | Must be greater than `2 × HeartbeatRTT`. | | Heartbeat RTT | uint64 | `1` | ≥ 0 | `cluster.replication.heartbeatrtt` · `CLUSTER_REPLICATION_HEARTBEAT_RTT` | — | | | Snapshot entries | uint64 | `10000` | ≥ 0 | `cluster.replication.snapshotentries` · `CLUSTER_REPLICATION_SNAPSHOT_ENTRIES` | — | Committed entries between automatic snapshots. | | Compaction overhead | uint64 | `2000` | ≥ 0 | `cluster.replication.compactionoverhead` · `CLUSTER_REPLICATION_COMPACTION_OVERHEAD` | — | Log entries retained past a snapshot. | | Boot timeout (s) | int | `60` | ≥ 0 | `cluster.replication.boottimeoutseconds` · `CLUSTER_REPLICATION_BOOT_TIMEOUT_SECONDS` | — | Clustered-boot readiness-probe budget, in seconds. | | Mutual TLS | bool | `false` | true / false | `cluster.replication.mutualtls` · `CLUSTER_REPLICATION_MUTUAL_TLS` | — | Optional mTLS trust domain. Default **off** (plaintext) — see the security callout below. | | CA / Cert / Key file | string | `""` | file path | `cluster.replication.cafile` / `cluster.replication.certfile` / `cluster.replication.keyfile` · `CLUSTER_REPLICATION_CA_FILE` / `CLUSTER_REPLICATION_CERT_FILE` / `CLUSTER_REPLICATION_KEY_FILE` | — | A **dedicated** trust domain — not reused from `Security`. Required (fail-closed) when `Mutual TLS` is `true`. | When **Mutual TLS** is left at its default `false`, a clustered `next` node's Dragonboat replication listener runs an **unauthenticated, FSM-writing raft port**. This is acceptable only on a trusted pod network, bounded by a `NetworkPolicy` — never expose this listener beyond the cluster's own pod network. ## ReplicaID auto-derive [#replicaid-auto-derive] On a `next`-engine cluster with clustering enabled, `Peers` set, `Replica ID` left at `0`, and `POD_NAME` present in the environment, the server derives `ReplicaID = ordinal(POD_NAME) + 1` — the integer after the last `-` in the pod name, plus one (StatefulSet ordinals are 0-based, so replica ids run `1..N`). * An **explicit non-zero** `Replica ID` always wins — the derive path is skipped. * An **unparseable** `POD_NAME` (no `-` suffix) fails closed. * The operator obligation: the host at id `ordinal + 1` in `Peers` must be the DNS name of the pod at that ordinal — a mismatch is a DNS-time failure (the cluster boots but never forms quorum), not a config-validation error. This lets `Peers` be byte-identical across every pod in a StatefulSet, with no per-pod templating required. ## Kubernetes [#kubernetes] On Kubernetes, the persistence engine is established once, at cluster creation, by the operator's engine-establishment guard — it is never changed for a live cluster. See [Deployment & High Availability](/configure/reference/deployment) for the operator-side behavior. ## Example [#example] Set the persistence engine on each target. This is a single-setting snippet — see the [Docker guide](/configure/docker) and the [Kubernetes guide](/configure/kubernetes) for complete, runnable configurations. ```yaml title="config.yaml" store: engine: next ``` ```yaml title="values.yaml" store: engine: next ``` ## See Also [#see-also] # Storage & Queues (/configure/reference/storage-queues) KubeMQ persists messages through an embedded **persistent store** and serves pull-based delivery through its **queues**. The store controls how much is retained and for how long; the queue settings control visibility, wait, delay, expiration, and retry behavior. Each setting is shown for both deployment targets — Docker single-node (`config.yaml` key · env var) and Kubernetes/Helm (`spec.*` path). A dash (`—`) in the Helm/CRD column means the setting is not available on that surface. Store and queue tuning is **opt-out**: the `store` and `queue` blocks are internal server sections (not wire connectors), so they are always active with the defaults below — there is no `enable`/`disabled` toggle. Override only the fields you need. ## Persistent store [#persistent-store] Limits and retention for the persistent store. The **persistence engine** is chosen once at cluster creation — see [Storage Engines](/configure/reference/storage-engines) for the full engine model, durability guarantees, and clustering. Several store fields carry **name divergences** between the Docker `config.yaml` key and the Helm/CRD field — the Notes column flags each one. The ten fields below are all CRD-settable under `spec.store.*`; the store config carries **five more** — the `next`-engine settings (`nextackpolicy`, `nextraftaddress`, `nextsegmentsize`, `nextshards`, `nextbalanceleaders`), which are `config.yaml`/env-only and documented on [Storage Engines](/configure/reference/storage-engines#next-engine-settings). | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | ------------------------ | ------ | ------------------------------------------------------------ | ---------------------------- | -------------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Persistence engine | string | *unset* → resolved from the store directory (clean ⇒ `next`) | `legacy` \| `next` \| `auto` | `store.engine` · `STORE_ENGINE` | `spec.store.engine` (typed CRD enum) | Chosen **once, at cluster creation** — a cluster is born one mode and stays there; no in-place migration. **Unset (or the explicit `auto`) does not mean `legacy`:** the server probes the store directory and resolves the engine that wrote it, or `next` for a clean one. To get `legacy` you must name it. See [Zero-config engine selection](/configure/reference/storage-engines#zero-config-engine-selection). | | Clean store on start | bool | `false` | true / false | `store.cleanstore` · `STORE_CLEAN_STORE` | `spec.store.clean` | ⚠️ **Destructive — wipes the store on every boot.** See the warning below. Name divergence: `cleanstore` ↔ `clean`. CRD emits `STORE_CLEAN_STORE=true` only when `clean: true`; otherwise unset. | | Store path | string | `./store` | non-empty file path | `store.storepath` · `STORE_STORE_PATH` | `spec.store.path` | Name divergence: `storepath` ↔ `path`. Empty string is rejected. **On the `legacy` engine, absolute paths are rewritten to relative:** a leading `/` is prefixed with `.` (so `/data` becomes `./data`). **On the `next` engine, an absolute `StorePath` is honored verbatim** (e.g. a mounted PVC path like `/store`) — no rewrite. This is server-process behavior; on Kubernetes the operator supplies the mount and rejects a leading `/` in `spec.store.path` outright (see [Deployment](/configure/reference/deployment)) — the two layers aren't in conflict. | | Max channels | int | `0` (∞) | ≥ 0; `0` = unlimited | `store.maxqueues` · `STORE_MAX_QUEUES` | `spec.store.maxChannels` | **Name divergence:** `store.maxqueues` ↔ `spec.store.maxChannels`. Negative rejected. `0` logs a stderr WARNING (unbounded). | | Max channel size (bytes) | int64 | `0` (∞) | ≥ 0; `0` = unlimited | `store.maxqueuesize` · `STORE_MAX_QUEUE_SIZE` | `spec.store.maxChannelSize` | **Name divergence:** `store.maxqueuesize` ↔ `spec.store.maxChannelSize`. **Type divergence:** server field is `int64`; the CRD field is `*int32`, so via Helm the max is \~2.1 GB — set larger caps through `config.yaml`/env. `0` logs a stderr WARNING. | | Max messages / channel | int | `0` (∞) | ≥ 0; `0` = unlimited | `store.maxmessages` · `STORE_MAX_MESSAGES` | `spec.store.maxMessages` | Negative rejected. `0` logs a stderr WARNING. | | Max subscribers | int | `0` (∞) | ≥ 0; `0` = unlimited | `store.maxsubscribers` · `STORE_MAX_SUBSCRIBERS` | `spec.store.maxSubscribers` | Negative rejected. `0` logs a stderr WARNING. | | Message retention (min) | int | `1440` | ≥ 0 (minutes) | `store.maxretention` · `STORE_MAX_RETENTION` | `spec.store.messagesRetentionMinutes` | **Name divergence:** `store.maxretention` ↔ `spec.store.messagesRetentionMinutes`. Negative rejected. | | Purge inactive (min) | int | `1440` | ≥ 0 (minutes) | `store.maxpurgeinactive` · `STORE_MAX_PURGE_INACTIVE` | `spec.store.purgeInactiveMinutes` | **Name divergence:** `store.maxpurgeinactive` ↔ `spec.store.purgeInactiveMinutes`. Negative rejected. | | Idle prune cutoff (hrs) | int | `24` | ≥ 1 | `store.idleprunecutoffhours` · `STORE_IDLE_PRUNE_CUTOFF_HOURS` | `spec.store.idlePruneCutoffHours` | Must be **at least 1** (server rejects `0`; CRD schema enforces `minimum: 1`). | **`store.cleanstore: true` deletes the persistent store on every single boot — not once.** It is not a one-shot reset. Left in a `values.yaml` or a `config.yaml`, every pod roll, node drain, crash-restart, and routine upgrade wipes production data — **and every restart looks completely healthy**, because deleting the store is exactly what you asked for. Use it for a deliberate, supervised reset and **take it back out immediately**. There is no confirmation, no dry-run, and no undo. Two things bound the damage, neither of which is a safety net you should rely on: the engine-mode guard runs **before** the wipe, so a mode-mismatched or unrecognized directory fails fast and is never deleted; and on a clustered `next` cluster the server **refuses** to wipe a member's datadir outright, telling you to remove the member from the cluster first. A standalone node has neither protection. Leaving any of **Max channels / Max channel size / Max messages / Max subscribers** at its default of `0` means *unlimited* and prints a startup WARNING to stderr — the store places no bound on that dimension, which can drive unbounded memory/disk use in production. Set explicit ceilings for production workloads. **These limits are enforced by the `legacy` engine only — and `next` is what a new cluster gets.** A fresh install on a clean store resolves to `next`, so **the default deployment enforces no retention at all**, including the `1440`-minute Message retention and Purge inactive values printed in the table above. Those two rows describe `legacy`. On the `next` engine, **Max channels / Max channel size / Max messages / Message retention / Purge inactive** are not consumed — a native Events Store or Queues channel has no age, size, or count cap and grows unbounded under a slow or absent consumer. Size `next` deployments by disk capacity, or use Kafka topic channels (whose `retention.ms` *is* age-enforced on `next`) where eviction matters. See [Storage Engines](/configure/reference/storage-engines#native-retention-scope). ## Queues [#queues] Delivery defaults and ceilings for pull-based queues. Two Docker keys diverge from the Helm/CRD field names — `queue.maxreceivecount` ↔ `spec.queue.maxReQueues` (the retry ceiling) and `queue.maxnumberofmessages` ↔ `spec.queue.maxReceiveMessagesRequest` (the per-request batch). All ten queue fields are fully CRD-settable under `spec.queue.*`. | Setting | Type | Default | Valid values | Docker (config.yaml key · env var) | Helm/CRD path | Notes | | -------------------------- | ----- | ------- | ------------ | ------------------------------------------------------------------------ | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | Default visibility (s) | int32 | `60` | > 0 | `queue.defaultvisibilityseconds` · `QUEUE_DEFAULT_VISIBILITY_SECONDS` | `spec.queue.defaultVisibilitySeconds` | | | Default wait timeout (s) | int32 | `1` | > 0 | `queue.defaultwaittimeoutseconds` · `QUEUE_DEFAULT_WAIT_TIMEOUT_SECONDS` | `spec.queue.defaultWaitTimeoutSeconds` | | | Max visibility (s) | int32 | `43200` | > 0 | `queue.maxvisibilityseconds` · `QUEUE_MAX_VISIBILITY_SECONDS` | `spec.queue.maxVisibilitySeconds` | 43200 s = 12 h. | | Max wait timeout (s) | int32 | `3600` | > 0 | `queue.maxwaittimeoutseconds` · `QUEUE_MAX_WAIT_TIMEOUT_SECONDS` | `spec.queue.maxWaitTimeoutSeconds` | | | Max delay (s) | int32 | `43200` | > 0 | `queue.maxdelayseconds` · `QUEUE_MAX_DELAY_SECONDS` | `spec.queue.maxDelaySeconds` | 43200 s = 12 h. | | Max expiration (s) | int32 | `43200` | > 0 | `queue.maxexpirationseconds` · `QUEUE_MAX_EXPIRATION_SECONDS` | `spec.queue.maxExpirationSeconds` | 43200 s = 12 h. | | Retry ceiling (→ DLQ) | int32 | `1024` | > 0 | `queue.maxreceivecount` · `QUEUE_MAX_RECEIVE_COUNT` | `spec.queue.maxReQueues` | **Name divergence:** `queue.maxreceivecount` ↔ `spec.queue.maxReQueues`. Max redeliveries before dead-lettering. | | Max messages / receive req | int32 | `1024` | > 0 | `queue.maxnumberofmessages` · `QUEUE_MAX_NUMBER_OF_MESSAGES` | `spec.queue.maxReceiveMessagesRequest` | **Name divergence:** `queue.maxnumberofmessages` ↔ `spec.queue.maxReceiveMessagesRequest`. Per-request batch ceiling. | | Max inflight | int32 | `2048` | > 0 | `queue.maxinflight` · `QUEUE_MAX_INFLIGHT` | `spec.queue.maxInflight` | Max unacked in-flight messages per queue. | | Pub-ack wait (s) | int32 | `60` | > 0 | `queue.pubackwaitseconds` · `QUEUE_PUB_ACK_WAIT_SECONDS` | `spec.queue.pubAckWaitSeconds` | Publish-ack wait before the send is considered failed. | **Every queue field must be greater than `0` — the server rejects `0` at startup.** The CRD schema already enforces this with `minimum: 1` on all ten queue fields, so a Helm value of `0` is rejected by the API server *and* would fail server validation. Treat the effective minimum for every queue setting as **`1`**. ## Example [#example] Set message retention on each target. This is a single-setting snippet — see the [Docker guide](/configure/docker) and the [Kubernetes guide](/configure/kubernetes) for complete, runnable configurations. ```yaml title="config.yaml" store: maxretention: 1440 ``` ```yaml title="values.yaml" store: messagesRetentionMinutes: 1440 ``` For the full Docker delivery methods (env vars, mounted `config.yaml`, the `CONFIG` variable) see the [Docker guide](/configure/docker); for `values.yaml` mapped to the `KubemqCluster` spec see the [Kubernetes guide](/configure/kubernetes). # AMQP 1.0 (/connectors/amqp) Point your AMQP 1.0 application at KubeMQ by changing only the connection string and node address. The **AMQP 1.0 connector** is a built-in, wire-protocol bridge inside kubemq-server that speaks the OASIS AMQP 1.0 dialect natively — any standard AMQP 1.0 client (Qpid, go-amqp, AMQPNetLite, rhea) talks to KubeMQ's Queues, Events, Events-Store, Commands, and Queries with no KubeMQ SDK, no library swap, and no code rewrite. ## What is the AMQP 1.0 connector [#what-is-the-amqp-10-connector] The AMQP 1.0 connector exposes [AMQP 1.0](https://www.amqp.org/) (OASIS / ISO-IEC 19464) natively over a dedicated port for **all five KubeMQ messaging patterns**. The leading segment of the address a client attaches to selects the pattern: attaching to `queues/orders` binds to a KubeMQ Queue, `events/telemetry` to Events, and so on. The connector is a *gateway*, not a client library — your application only needs a stock AMQP 1.0 client. AMQP 1.0 is a peer-to-peer [link](/connectors/amqp/concepts/architecture#connection--session--link) protocol, distinct from the AMQP 0-9-1 dialect the [RabbitMQ connector](/connectors/rabbitmq) speaks: a link attaches to a *node* (an address), flow is governed by *credit*, and delivery is resolved by a *delivery state* (accepted / released / modified / rejected) — there are no exchanges, bindings, routing keys, or publisher-confirms. See [Architecture](/connectors/amqp/concepts/architecture) for the full model, or [Migrating from ActiveMQ](/connectors/how-to/migration/from-activemq) if you're coming from 0-9-1. Key capabilities: * **All five patterns over one wire** — Queues, Events, Events-Store, Commands, and Queries, selected by the address prefix. * **Address-driven pattern routing** — `queues/`, `events/`, `events-store/`, `commands/`, `queries/` prefixes map a link to a KubeMQ pattern by longest-prefix match. * **Native settlement and credit** — at-least-once (unsettled) or at-most-once (pre-settled) delivery, credit-driven flow control, and the standard AMQP dispositions. * **Cross-protocol interop** — a message sent over AMQP 1.0 to `queues/orders` is consumable by a gRPC or REST KubeMQ client on the same channel, and vice-versa. ## How it works [#how-it-works] An AMQP 1.0 client connects to the connector and attaches a link to a node address. The connector resolves the address to a KubeMQ `(pattern, channel)` pair, hands the message to the message broker, and consumers on the same channel — over AMQP 1.0 or any other KubeMQ transport — receive it. *The shared `amqpmux` front door classifies the connection by its 8-byte protocol header and dispatches it to the AMQP 1.0 engine, which maps the node address onto a KubeMQ pattern and channel.* ## Ports & protocol surface [#ports--protocol-surface] | Port | Transport | Protocol | Notes | | ------ | ---------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------- | | `5672` | Plain TCP (SASL ANONYMOUS / PLAIN) | AMQP 1.0 (OASIS / ISO-IEC 19464) | Default plain listener. **Shared with the RabbitMQ (AMQP 0-9-1) connector** via the internal `amqpmux`. | | `5671` | TLS over TCP | AMQP 1.0 | Binds only when the server-global `Security` block is configured. Shared TLS listener with AMQP 0-9-1. | A single `amqpmux` listener accepts every connection on `5672`/`5671`, reads the 8-byte AMQP protocol header, and routes it to the matching dialect engine — so AMQP 1.0 and AMQP 0-9-1 coexist on the same ports. There is **no vhost**: the OPEN `hostname` field is accepted and ignored. See [Architecture](/connectors/amqp/concepts/architecture) for the dispatch detail. ## Send a message [#send-a-message] The example below produces one message to `queues/` over a stock AMQP 1.0 client. Each send is unsettled (at-least-once): it blocks until the connector returns an `accepted` disposition, confirming the broker stored the message. Every client reads the broker endpoint from `KUBEMQ_AMQP_URL` (default `amqp://localhost:5672`). ```go package main import ( "context" "fmt" "log" "os" "time" amqp "github.com/Azure/go-amqp" ) func amqpURL() string { if v := os.Getenv("KUBEMQ_AMQP_URL"); v != "" { return v } return "amqp://localhost:5672" } func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() addr := "queues/amqp10.examples.basic" // OPEN: SASL ANONYMOUS by default (no userinfo in the URL). conn, err := amqp.Dial(ctx, amqpURL(), nil) if err != nil { log.Fatalf("dial: %v", err) } defer func() { _ = conn.Close() }() session, err := conn.NewSession(ctx, nil) if err != nil { log.Fatalf("new session: %v", err) } // ATTACH a sender (server-receiver link) and send one unsettled message. sender, err := session.NewSender(ctx, addr, nil) if err != nil { log.Fatalf("new sender: %v", err) } if err := sender.Send(ctx, amqp.NewMessage([]byte("hello from AMQP 1.0")), nil); err != nil { log.Fatalf("send: %v", err) } _ = sender.Close(ctx) fmt.Printf("sent 1 message to %s (accepted)\n", addr) } ``` ```python import os from proton import Message from proton.utils import BlockingConnection def amqp_url() -> str: return os.environ.get("KUBEMQ_AMQP_URL", "amqp://localhost:5672") def main() -> None: addr = "queues/amqp10.examples.basic" # OPEN: SASL ANONYMOUS by default (no userinfo in the URL). conn = BlockingConnection(amqp_url()) try: # ATTACH a sender; each send blocks for the accepted disposition. sender = conn.create_sender(addr) sender.send(Message(body="hello from AMQP 1.0")) sender.close() print(f"sent 1 message to {addr} (accepted)") finally: conn.close() if __name__ == "__main__": main() ``` ```java import javax.jms.Connection; import javax.jms.MessageProducer; import javax.jms.Queue; import javax.jms.Session; import javax.jms.TextMessage; import org.apache.qpid.jms.JmsConnectionFactory; public final class Main { public static void main(String[] args) throws Exception { String url = System.getenv().getOrDefault("KUBEMQ_AMQP_URL", "amqp://localhost:5672"); String address = "queues/amqp10.examples.basic"; // OPEN: SASL ANONYMOUS by default. The JMS destination name IS the // connector node address. JmsConnectionFactory factory = new JmsConnectionFactory(url); try (Connection connection = factory.createConnection()) { connection.start(); try (Session session = connection.createSession(false, Session.CLIENT_ACKNOWLEDGE)) { Queue queue = session.createQueue(address); try (MessageProducer producer = session.createProducer(queue)) { TextMessage msg = session.createTextMessage("hello from AMQP 1.0"); producer.send(msg); // blocks until the accepted DISPOSITION } System.out.printf("sent 1 message to %s (accepted)%n", address); } } } } ``` ```csharp using System.Text; using Amqp; using Amqp.Framing; static string AmqpUrl() => Environment.GetEnvironmentVariable("KUBEMQ_AMQP_URL") is { Length: > 0 } v ? v : "amqp://localhost:5672"; var addr = "queues/amqp10.examples.basic"; // OPEN: SASL ANONYMOUS by default (no userinfo in the URL). var connection = await Connection.Factory.CreateAsync(new Address(AmqpUrl())); try { var session = new Session(connection); // ATTACH a sender (server-receiver link) and send one unsettled message. var sender = new SenderLink(session, "basic-sender", addr); var message = new Message { BodySection = new Data { Binary = Encoding.UTF8.GetBytes("hello from AMQP 1.0") }, }; sender.Send(message, TimeSpan.FromSeconds(15)); // blocks for the accepted DISPOSITION await sender.CloseAsync(); await session.CloseAsync(); Console.WriteLine($"sent 1 message to {addr} (accepted)"); } finally { await connection.CloseAsync(); } ``` ```typescript import { Connection, type ConnectionOptions } from "rhea-promise"; function connectionOptions(): ConnectionOptions { const url = new URL(process.env["KUBEMQ_AMQP_URL"] ?? "amqp://localhost:5672"); return { host: url.hostname, port: url.port ? Number(url.port) : 5672, // The connector requires a non-empty container-id; rhea sends one by default. container_id: `kubemq-amqp10-js-${process.pid}`, reconnect: false, }; } async function main(): Promise { const address = "queues/amqp10.examples.basic"; // OPEN: SASL ANONYMOUS by default (no username/password). const connection = new Connection(connectionOptions()); await connection.open(); try { // Attach an AwaitableSender; send() resolves on the accepted disposition. const sender = await connection.createAwaitableSender({ target: { address } }); await sender.send({ body: "hello from AMQP 1.0" }, { timeoutInSeconds: 15 }); await sender.close(); console.log(`sent 1 message to ${address} (accepted)`); } finally { await connection.close(); } } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```rust use fe2o3_amqp::{Connection, Sender, Session}; use fe2o3_amqp_types::definitions::SenderSettleMode; fn amqp_url() -> String { std::env::var("KUBEMQ_AMQP_URL").unwrap_or_else(|_| "amqp://localhost:5672".to_string()) } #[tokio::main] async fn main() -> Result<(), Box> { let addr = "queues/amqp10.examples.basic"; // OPEN: SASL ANONYMOUS by default; a non-empty container-id is required. let mut connection = Connection::open("kubemq-amqp10-rust", amqp_url().as_str()).await?; let mut session = Session::begin(&mut connection).await?; // ATTACH a sender pinned to Unsettled (at-least-once); the connector // rejects the AMQP default `mixed`. let mut sender = Sender::builder() .name("basic-sender") .target(addr) .sender_settle_mode(SenderSettleMode::Unsettled) .attach(&mut session) .await?; let outcome = sender.send("hello from AMQP 1.0").await?; if !outcome.is_accepted() { return Err(format!("unexpected outcome {outcome:?}").into()); } sender.close().await?; println!("sent 1 message to {addr} (accepted)"); session.end().await?; connection.close().await?; Ok(()) } ``` ## Supported languages [#supported-languages] The connector speaks standard AMQP 1.0, so any conformant client works. The examples pin one native AMQP 1.0 client per language — there is no KubeMQ SDK, no proto bindings, and no published package. | Language | Client library | Notes | | ----------------------- | ----------------------------------------------------------------------------------------------- | --------------------------------- | | Go | [`github.com/Azure/go-amqp`](https://github.com/Azure/go-amqp) | The connector's reference client. | | Python | [`python-qpid-proton`](https://qpid.apache.org/proton/) | Sync `BlockingConnection`. | | Java | [`org.apache.qpid:qpid-jms-client`](https://qpid.apache.org/components/jms/) | `javax.jms` (not Jakarta). | | C# / .NET | [`AMQPNetLite.Core`](https://github.com/Azure/amqpnetlite) | Task-based async. | | JavaScript / TypeScript | [`rhea`](https://github.com/amqp/rhea) + [`rhea-promise`](https://github.com/amqp/rhea-promise) | Event-driven, promise-wrapped. | | Rust | [`fe2o3-amqp`](https://github.com/minghuaw/fe2o3-amqp) | async/await on Tokio. | Apache Qpid JMS (Java) cannot drive the anonymous-terminus link — it has no API to force a raw null-target link, and the connector advertises no `ANONYMOUS-RELAY` capability. Use per-pattern senders instead. See [Address mapping](/connectors/amqp/reference/address-mapping). ## Next steps [#next-steps] # AWS (SQS & SNS) (/connectors/aws) Point your AWS SQS / SNS application at KubeMQ by changing only the endpoint URL. The **AWS connector** is a built-in, wire-protocol bridge inside kubemq-server that speaks the genuine AWS SQS and SNS HTTP protocols on a dedicated second listener — any standard, unmodified AWS SDK (boto3, `aws-sdk-go-v2`, the AWS SDK for Java/JS/.NET/Ruby/Rust) talks to KubeMQ with no LocalStack, no library swap, and no KubeMQ SDK. ## What is the AWS connector [#what-is-the-aws-connector] The connector is **one binary with two service surfaces** that map onto two distinct KubeMQ models: * **SQS → KubeMQ Queue.** Every SQS queue maps onto a native KubeMQ **Queue** channel `sqs.{name}`. AWS producers and native gRPC/REST consumers share the same messages on that channel. A FIFO group fans onto its own channel `sqs.{name}.fifo.g.{enc(group)}`. * **SNS → virtual fan-out.** SNS topics are **virtual** — a registry replicated across cluster nodes, with no native channel. At publish time a topic fans out to every confirmed subscription: subscribed SQS queues (a batch send) and HTTP/HTTPS webhooks (a delivery engine). Because SQS is point-to-point and SNS is publish/subscribe — neither is request/reply — there is **no RPC**: no Commands, no Queries, no gRPC responder anywhere. The connector exposes the queue and fan-out surfaces only. The AWS connector is **opt-in (disabled by default)** — enabling it opens a **new HTTP listener on port 4566** that is not bound until you set `CONNECTORS_AWS_ENABLE=true`. Unlike the other wire-protocol connectors, a stock server does **not** serve AWS until you turn it on. See [Getting started](/connectors/aws/tutorials/getting-started). ## How it works [#how-it-works] An AWS SDK client sends a signed SQS or SNS request to the connector's endpoint. The connector detects the protocol, verifies the SigV4 signature shape, and dispatches: SQS operations land on the KubeMQ Queue channel `sqs.{name}` through the message broker; SNS publishes resolve the virtual topic registry and fan out to the subscribed targets. *SQS requests map onto the KubeMQ Queue channel `sqs.{name}` through the message broker; SNS publishes resolve the virtual topic registry and fan out to subscribed SQS queues and HTTP/HTTPS webhooks.* ## Ports & protocol surface [#ports--protocol-surface] | Port | Transport | Protocol | Notes | | ------ | ------------------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `4566` | Plain HTTP (SigV4) | AWS SQS JSON + SNS Query | A dedicated second listener (the LocalStack convention). **Bound only when the connector is enabled** (`CONNECTORS_AWS_ENABLE=true`), and must differ from the gRPC/REST/HTTP ports. | | — | HTTPS | AWS SQS JSON + SNS Query | TLS is provided by the server-wide `Security` block — there is **no AWS-specific TLS option**. SigV4 over plain HTTP is unencrypted on the wire; production deployments should use the HTTPS listener. | The listener accepts both `POST /` and `GET /` on a single AWS-style endpoint — there are no per-route REST paths. SQS uses the AWS **JSON protocol** (`X-Amz-Target: AmazonSQS.{Op}`) with a Query-protocol fallback; SNS uses the AWS **Query protocol** (form body / GET query → XML). See [Architecture](/connectors/aws/concepts/architecture) for the dispatch detail. ## Send a message [#send-a-message] The example below runs the full SQS round-trip — `CreateQueue` → `GetQueueUrl` → `SendMessage` → `ReceiveMessage` → `DeleteMessage` — over a stock AWS SDK. The only change versus a real-AWS app is the **endpoint override**: each client points at `KUBEMQ_AWS_URL` (default `http://localhost:4566`). **Dummy credentials are still required** so the SDK forms a valid SigV4 signature; the connector's default accept-any mode checks the signature shape, not its value. ```go package main import ( "context" "fmt" "log" "os" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/sqs" ) func awsURL() string { if v := os.Getenv("KUBEMQ_AWS_URL"); v != "" { return v } return "http://localhost:4566" } func main() { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() // Dummy static credentials are required so the SDK forms a valid SigV4 // request; the connector's accept-any mode does not verify their value. cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion("us-east-1"), config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), ) if err != nil { log.Fatalf("load config: %v", err) } // Override ONLY the endpoint URL — everything else is a normal AWS SDK app. client := sqs.NewFromConfig(cfg, func(o *sqs.Options) { o.BaseEndpoint = aws.String(awsURL()) }) // CreateQueue "orders" maps to KubeMQ Queue channel "sqs.orders". created, err := client.CreateQueue(ctx, &sqs.CreateQueueInput{QueueName: aws.String("orders")}) if err != nil { log.Fatalf("CreateQueue: %v", err) } queueURL := aws.ToString(created.QueueUrl) if _, err := client.SendMessage(ctx, &sqs.SendMessageInput{ QueueUrl: aws.String(queueURL), MessageBody: aws.String("hello from the AWS SDK"), }); err != nil { log.Fatalf("SendMessage: %v", err) } recv, err := client.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{ QueueUrl: aws.String(queueURL), MaxNumberOfMessages: 1, WaitTimeSeconds: 5, }) if err != nil || len(recv.Messages) != 1 { log.Fatalf("ReceiveMessage: %v (got %d)", err, len(recv.Messages)) } msg := recv.Messages[0] fmt.Printf("received: %q\n", aws.ToString(msg.Body)) // DeleteMessage acks the message off the queue by its receipt handle. if _, err := client.DeleteMessage(ctx, &sqs.DeleteMessageInput{ QueueUrl: aws.String(queueURL), ReceiptHandle: msg.ReceiptHandle, }); err != nil { log.Fatalf("DeleteMessage: %v", err) } } ``` ```python import os import boto3 QUEUE = "orders" def make_sqs(): # Override ONLY the endpoint URL; dummy credentials are still required so # boto3 forms a valid SigV4 request (accept-any mode checks shape only). return boto3.client( "sqs", endpoint_url=os.environ.get("KUBEMQ_AWS_URL", "http://localhost:4566"), region_name="us-east-1", aws_access_key_id="test", aws_secret_access_key="test", ) def main() -> None: sqs = make_sqs() # CreateQueue "orders" maps to KubeMQ Queue channel "sqs.orders". queue_url = sqs.create_queue(QueueName=QUEUE)["QueueUrl"] sqs.send_message(QueueUrl=queue_url, MessageBody="hello from boto3") recv = sqs.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=1, WaitTimeSeconds=5) msg = recv["Messages"][0] print(f"received: {msg['Body']!r}") # DeleteMessage acks the message off the queue by its receipt handle. sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=msg["ReceiptHandle"]) if __name__ == "__main__": main() ``` ```java import java.net.URI; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.sqs.SqsClient; import software.amazon.awssdk.services.sqs.model.Message; public final class Main { public static void main(String[] args) { String url = System.getenv().getOrDefault("KUBEMQ_AWS_URL", "http://localhost:4566"); // endpointOverride is the only change versus a real-AWS app; dummy // credentials are still required to form a valid SigV4 request. try (SqsClient sqs = SqsClient.builder() .endpointOverride(URI.create(url)) .region(Region.US_EAST_1) .credentialsProvider(StaticCredentialsProvider.create( AwsBasicCredentials.create("test", "test"))) .build()) { // CreateQueue "orders" maps to KubeMQ Queue channel "sqs.orders". String queueUrl = sqs.createQueue(b -> b.queueName("orders")).queueUrl(); sqs.sendMessage(b -> b.queueUrl(queueUrl).messageBody("hello from the AWS SDK for Java")); Message msg = sqs.receiveMessage(b -> b .queueUrl(queueUrl) .maxNumberOfMessages(1) .waitTimeSeconds(5)) .messages().get(0); System.out.printf("received: %s%n", msg.body()); // DeleteMessage acks the message off the queue by its receipt handle. sqs.deleteMessage(b -> b.queueUrl(queueUrl).receiptHandle(msg.receiptHandle())); } } } ``` ```typescript import { SQSClient, CreateQueueCommand, SendMessageCommand, ReceiveMessageCommand, DeleteMessageCommand, } from "@aws-sdk/client-sqs"; const QUEUE = "orders"; // Override ONLY the endpoint; dummy credentials are still required so the SDK // forms a valid SigV4 request (accept-any mode checks the signature shape). const sqs = new SQSClient({ endpoint: process.env["KUBEMQ_AWS_URL"] ?? "http://localhost:4566", region: "us-east-1", credentials: { accessKeyId: "test", secretAccessKey: "test" }, }); async function main(): Promise { // CreateQueue "orders" maps to KubeMQ Queue channel "sqs.orders". const created = await sqs.send(new CreateQueueCommand({ QueueName: QUEUE })); const queueUrl = created.QueueUrl!; await sqs.send(new SendMessageCommand({ QueueUrl: queueUrl, MessageBody: "hello from the AWS SDK v3" })); const recv = await sqs.send( new ReceiveMessageCommand({ QueueUrl: queueUrl, MaxNumberOfMessages: 1, WaitTimeSeconds: 5 }), ); const msg = recv.Messages![0]; console.log(`received: ${msg.Body}`); // DeleteMessage acks the message off the queue by its receipt handle. await sqs.send(new DeleteMessageCommand({ QueueUrl: queueUrl, ReceiptHandle: msg.ReceiptHandle! })); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using Amazon.Runtime; using Amazon.SQS; using Amazon.SQS.Model; var url = Environment.GetEnvironmentVariable("KUBEMQ_AWS_URL") ?? "http://localhost:4566"; // ServiceURL carries the full http://host:port; dummy credentials are still // required to form a valid SigV4 request (accept-any mode checks shape only). var config = new AmazonSQSConfig { ServiceURL = url, AuthenticationRegion = "us-east-1" }; using var sqs = new AmazonSQSClient(new BasicAWSCredentials("test", "test"), config); const string queueName = "orders"; // CreateQueue "orders" maps to KubeMQ Queue channel "sqs.orders". var created = await sqs.CreateQueueAsync(new CreateQueueRequest { QueueName = queueName }); var queueUrl = created.QueueUrl; await sqs.SendMessageAsync(new SendMessageRequest { QueueUrl = queueUrl, MessageBody = "hello from AWSSDK.NET", }); var recv = await sqs.ReceiveMessageAsync(new ReceiveMessageRequest { QueueUrl = queueUrl, MaxNumberOfMessages = 1, WaitTimeSeconds = 5, }); var msg = recv.Messages[0]; Console.WriteLine($"received: {msg.Body}"); // DeleteMessage acks the message off the queue by its receipt handle. await sqs.DeleteMessageAsync(new DeleteMessageRequest { QueueUrl = queueUrl, ReceiptHandle = msg.ReceiptHandle, }); ``` ```ruby # frozen_string_literal: true require "aws-sdk-sqs" # The Ruby SQS plugin rewrites the request endpoint to the full QueueUrl path, # which the single-endpoint connector rejects — remove it so requests stay on # the configured base endpoint (as boto3 and aws-sdk-go-v2 do). Aws::SQS::Client.remove_plugin(Aws::SQS::Plugins::QueueUrls) url = ENV.fetch("KUBEMQ_AWS_URL", "http://localhost:4566") # Override ONLY the endpoint; dummy credentials are still required so the SDK # forms a valid SigV4 request (accept-any mode checks the signature shape). sqs = Aws::SQS::Client.new( endpoint: url, region: "us-east-1", credentials: Aws::Credentials.new("test", "test") ) # CreateQueue "orders" maps to KubeMQ Queue channel "sqs.orders". queue_url = sqs.create_queue(queue_name: "orders").queue_url sqs.send_message(queue_url: queue_url, message_body: "hello from aws-sdk-ruby") recv = sqs.receive_message(queue_url: queue_url, max_number_of_messages: 1, wait_time_seconds: 5) msg = recv.messages.first puts "received: #{msg.body.inspect}" # DeleteMessage acks the message off the queue by its receipt handle. sqs.delete_message(queue_url: queue_url, receipt_handle: msg.receipt_handle) ``` ```rust use aws_config::BehaviorVersion; use aws_sdk_sqs::config::Credentials; use aws_sdk_sqs::config::Region; use std::error::Error; fn aws_url() -> String { std::env::var("KUBEMQ_AWS_URL").unwrap_or_else(|_| "http://localhost:4566".to_string()) } #[tokio::main] async fn main() -> Result<(), Box> { // Override ONLY the endpoint; dummy credentials are still required so the // SDK forms a valid SigV4 request (accept-any mode checks shape only). let creds = Credentials::new("test", "test", None, None, "kubemq-aws"); let conf = aws_config::defaults(BehaviorVersion::latest()) .region(Region::new("us-east-1")) .credentials_provider(creds) .endpoint_url(aws_url()) .load() .await; let sqs = aws_sdk_sqs::Client::new(&conf); // CreateQueue "orders" maps to KubeMQ Queue channel "sqs.orders". sqs.create_queue().queue_name("orders").send().await?; let url = sqs .get_queue_url() .queue_name("orders") .send() .await? .queue_url .ok_or("GetQueueUrl returned no URL")?; sqs.send_message() .queue_url(&url) .message_body("hello from aws-sdk-rust") .send() .await?; let received = sqs .receive_message() .queue_url(&url) .max_number_of_messages(1) .wait_time_seconds(5) .send() .await?; let msg = &received.messages()[0]; println!("received: {}", msg.body().unwrap_or_default()); // DeleteMessage acks the message off the queue by its receipt handle. let handle = msg.receipt_handle().ok_or("no receipt handle")?; sqs.delete_message().queue_url(&url).receipt_handle(handle).send().await?; Ok(()) } ``` ## Supported languages [#supported-languages] The connector speaks the genuine AWS SQS and SNS wire protocols, so any standard AWS SDK works — you only override the endpoint URL. There is no KubeMQ SDK, no proto bindings, and no published package; the examples pin one native AWS SDK per language. | Language | AWS SDK / client library | Endpoint override | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | | Go | [`aws-sdk-go-v2`](https://github.com/aws/aws-sdk-go-v2) (`service/sqs`, `service/sns`) | `config.WithBaseEndpoint` / `o.BaseEndpoint` | | Python | [`boto3`](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html) (`client('sqs')`, `client('sns')`) | `endpoint_url=` per client | | Java | [AWS SDK for Java v2](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/home.html) (`sqs`, `sns`) | `.endpointOverride(URI.create(...))` | | JavaScript / TypeScript | [AWS SDK v3](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/welcome.html) (`@aws-sdk/client-sqs`, `@aws-sdk/client-sns`) | `{ endpoint }` | | C# / .NET | [AWS SDK for .NET](https://docs.aws.amazon.com/sdk-for-net/latest/developer-guide/welcome.html) (`AWSSDK.SQS`, `AWSSDK.SimpleNotificationService`) | `ServiceURL` | | Ruby | [AWS SDK for Ruby v3](https://docs.aws.amazon.com/sdk-for-ruby/v3/developer-guide/welcome.html) (`aws-sdk-sqs`, `aws-sdk-sns`) | `endpoint:` per client | | Rust | [AWS SDK for Rust](https://docs.aws.amazon.com/sdk-for-rust/latest/dg/welcome.html) (`aws-sdk-sqs`, `aws-sdk-sns`) | `.endpoint_url(...)` | Only `aws-sdk-go-v2` is proven by the KubeMQ server's integration tests; the other six SDKs are wire-compatible and the connector's example suite is their proof. The Ruby SQS client needs its `QueueUrls` plugin removed (shown above) so requests stay on the configured base endpoint. See [Connections endpoint](/connectors/aws/reference/connections-endpoint). ## Next steps [#next-steps] # Observability (/connectors/concepts/observability) The connectors emit Prometheus metrics and OpenTelemetry traces on the same observability surface as the rest of kubemq-server, plus a dedicated **AI dashboard** for agents and MCP — so you watch connector traffic with the tools you already run. ## Overview [#overview] Every gateway on the [shared HTTP server](/connectors/concepts/shared-http-server) shares one observability story. CloudEvents is documented here under Connectors; the AI gateways — [A2A and MCP](/aiway) — are documented under Aiway, but they run on the same server and report through this same surface: * **Prometheus metrics** for A2A requests, MCP tool calls, registry operations, and active SSE streams, scraped from the management API port (`:8080`). * **OpenTelemetry tracing** for every HTTP request through the connector middleware chain. * A web **AI dashboard** (Agents and MCP) that reads cluster-aggregated metrics. No extra configuration turns connector telemetry on — it follows the server's existing metrics and tracing setup. ## How it works [#how-it-works] Connectors record metrics inline as they handle requests; the metrics exporter publishes them on the management API port (`:8080`) for Prometheus to scrape, while the dashboard reads the same data through the internal API. *Connectors report metrics to the exporter on the management API port (`:8080`); Prometheus scrapes it and the AI dashboard reads the internal API.* ## Prometheus metrics [#prometheus-metrics] Connector metrics are exposed on the **management API port (`:8080`)** at `/metrics`, alongside the core KubeMQ messaging metrics. Scrape it the same way: ```bash curl http://localhost:8080/metrics ``` The connectors emit `kubemq_mcp_*` and `kubemq_a2a_*` series; the full series — names, types, and labels — is documented in [Observability → Prometheus Metrics](/operate/observability/metrics). The A2A `method` label is sanitized against a fixed allowlist of known A2A methods. Any unrecognized method is recorded as `unknown` so a malformed or malicious request stream cannot explode Prometheus label cardinality. In a cluster, these counters are **aggregated across all nodes** so the totals you see reflect the whole deployment rather than a single replica. ## OpenTelemetry tracing [#opentelemetry-tracing] Every request that enters the shared HTTP server passes through OpenTelemetry instrumentation in the middleware chain, traced under the service name `kubemq-http`. When tracing is enabled on the server, connector requests appear in your traces automatically — there is nothing connector-specific to configure. ## The AI dashboard [#the-ai-dashboard] The KubeMQ web dashboard includes an **AI** area for the agent platform: * **Agents** — every registered agent and its live request stats (total requests, errors, average latency, last-seen time). * **MCP** — tool usage drawn from the MCP metrics above. The dashboard reads from an internal management API on the management API port (`:8080`): | Method | Path | Description | | ------ | ----------------- | -------------------------------------- | | `GET` | `/api/agents` | List registered agents with pagination | | `GET` | `/api/agents/:id` | Agent detail plus a stats snapshot | ```bash curl http://localhost:8080/api/agents ``` The full `/api/agents` and `/api/agents/:id` reference — parameters, response envelope, and stats fields — is documented in [Observability → Dashboard Endpoints](/operate/observability/api-reference/dashboard-endpoints). Per-agent stats are populated from the same counters that feed `kubemq_a2a_requests_total`. ## Related [#related] # Shared HTTP Server (/connectors/concepts/shared-http-server) Three built-in gateways run on **one shared HTTP server** inside kubemq-server: CloudEvents (documented here under Connectors) plus the AI gateways [A2A and MCP](/aiway) (documented under Aiway). There is no separate port or process per gateway: they register their routes on the same server, pass through the same middleware chain, and inherit the same auth, CORS, and TLS configuration. ## One server, many connectors [#one-server-many-connectors] When kubemq-server starts, each enabled connector registers its routes on the shared HTTP server, and the server is started once. Requests to `/a2a/*`, `/mcp`, and `/ce/*` all land on the same listener and flow through the same middleware pipeline before reaching the connector that owns the route. *A request passes through the middleware chain in order before reaching a connector route.* ## Port 9090 [#port-9090] The shared HTTP server listens on **port 9090**. The port is configured by `Connectors.Http.Port`; if it is left unset, it **inherits from `Rest.Port`** (which defaults to `9090`). A warning is logged if both are set to different values. Connector metrics are exposed separately on **port 8080** (`/metrics`), alongside the internal dashboard API. See [Observability](/connectors/concepts/observability) for the metrics surface and the AI dashboard. Health and readiness probes are public and unauthenticated: ```bash curl http://localhost:9090/ping curl http://localhost:9090/health curl http://localhost:9090/ready ``` ## The middleware chain [#the-middleware-chain] Every request flows through the same middleware stack, applied outermost-first: | Order | Middleware | Purpose | | ----- | ---------------- | ------------------------------------------------------------------------------- | | 1 | **Recovery** | Catches panics so one bad request cannot crash the server. | | 2 | **Traffic Gate** | Rejects requests with HTTP `503` while the broker is not ready. | | 3 | **OTel Tracing** | OpenTelemetry instrumentation (`kubemq-http`), when telemetry is enabled. | | 4 | **CORS** | Configurable cross-origin policy via `HttpConfig.Cors`. | | 5 | **Auth** | Extracts a JWT Bearer token and sets claims; anonymous claims when auth is off. | | 6 | **Body Limit** | Caps request body size (default `100M`). | | 7 | **Logging** | Debug-level request start/end logging. | The **traffic gate** is wired to the broker's readiness: the server automatically starts accepting traffic when the broker becomes ready and rejects it (with `503`) when it is not. This is why a connector can be enabled yet briefly return `503` during startup. Auth and TLS/mTLS are shared across all connectors and documented once in [Auth & security](/connectors/reference/auth-and-security). ### SSE and request timeouts [#sse-and-request-timeouts] The server's `WriteTimeout` is set to `0` so long-lived **Server-Sent Events** streams (A2A `message/stream`, CloudEvents SSE subscriptions) stay open indefinitely. Non-SSE route groups instead apply a per-route `TimeoutMiddleware` — default **60 seconds**, returning HTTP `504 Gateway Timeout` if the deadline is exceeded before the response is committed. For requests that proxy to a downstream agent, a `GatewayTimeoutBuffer` of **10 seconds** is added on top of the caller-specified timeout, so the gateway does not time out before the agent it is waiting on. ## Enable model: on by default [#enable-model-on-by-default] All three gateways are **enabled by default**. Start kubemq-server and `/a2a/*`, `/mcp`, and `/ce/*` are live — there is **no flag to turn them on**. (The A2A and MCP gateways are documented under [Aiway](/aiway); their enable vars are listed here because they share this server's enable model.) To turn one off, set its enable env var to `false`: | Connector | Disable with | | ----------- | ----------------------------- | | A2A | `CONNECTORSA2_A_ENABLE=false` | | MCP | `CONNECTORSMCP_ENABLE=false` | | CloudEvents | `CONNECTORSCE_ENABLE=false` | For example, to run with MCP disabled: **The enable var names are irregular by design.** Environment variables are derived from the dotted config keys (`Connectors.A2A.Enable`, `Connectors.MCP.Enable`, `Connectors.CE.Enable`) by a snake-casing transform that splits on letter-case boundaries, strips dots, and uppercases. The boundaries fall in unexpected places — so A2A becomes `CONNECTORSA2_A_ENABLE` (the `2`→`A` boundary inserts an underscore), while MCP and CE join into `CONNECTORSMCP_ENABLE` and `CONNECTORSCE_ENABLE` with **no** underscore before the connector name. Use these exact names; never invent a `=true` flag to enable a connector, and note that **`CONNECTORS_CE_ENABLE` (with an underscore) does not work** — the live binding is `CONNECTORSCE_ENABLE`. This differs from older KubeMQ behavior, where these gateways were off by default and opted in. That framing is stale: today they ship on. ## Reserved channel prefix [#reserved-channel-prefix] The shared server reserves the **`_AGENTS_.`** channel prefix for internal agent platform subjects (agent request/reply, SSE stream relays, and registry replication). Any user operation targeting a channel that begins with `_AGENTS_.` is **rejected** by `IsReservedChannel`. Pick channel names outside this prefix for your own queues, events, commands, and queries. ## Related [#related] # CloudEvents (/connectors/cloudevents) The **CloudEvents (CE) connector** is a built-in protocol gateway in kubemq-server that speaks the [CNCF CloudEvents](https://cloudevents.io/) specification over HTTP. It lets any HTTP client publish and subscribe to KubeMQ using a standard, interoperable event envelope — no KubeMQ SDK required. ## What is the CloudEvents connector [#what-is-the-cloudevents-connector] [CloudEvents](https://cloudevents.io/) is a CNCF specification that describes event metadata in a common format: a standard envelope of `specversion`, `type`, `source`, `id`, `subject`, `time`, `data`, and extensions that travels consistently across platforms and languages. The CE connector exposes this format natively over HTTP for **all five KubeMQ messaging patterns** — Events, Events Store, Queues, Commands, and Queries. A CloudEvent posted to `/ce/send/event` becomes a native KubeMQ message; a message delivered over Server-Sent Events (SSE) is reconstructed back into a CloudEvent. The connector is a *gateway*, not a client library — your application only needs an HTTP client (and, optionally, a CloudEvents SDK to build the envelope for you). Key capabilities: * **All messaging patterns** — pub/sub events, persistent events store, durable queues, and request/reply commands and queries, all over CloudEvents HTTP. * **Structured and binary content modes** — send the whole event as a JSON body (`application/cloudevents+json`) or carry attributes in `ce-*` HTTP headers; the connector detects either automatically. * **CESQL attribute routing** — route events to channels using CloudEvents SQL expressions evaluated server-side against event attributes. * **SSE subscriptions with replay** — subscribe to a long-lived event stream and resume an events-store subscription from a chosen position using `Last-Event-ID`. The CE connector runs on the **shared HTTP server (port 9090)** alongside the REST, MCP, and A2A connectors and is **enabled by default** — start kubemq-server and `/ce/*` is live. See [Shared HTTP server](/connectors/concepts/shared-http-server) for the port, middleware chain, and the disable model. ## How it works [#how-it-works] A CloudEvents client sends events to the CE connector, which maps them into native KubeMQ messages and hands them to the broker; subscribers receive the same events back as CloudEvents over SSE. *The CloudEvents connector translates CloudEvents HTTP requests into native KubeMQ messages and streams them back to subscribers over SSE.* ## Endpoint surface [#endpoint-surface] | Endpoint | Method | Pattern | | ---------------------------- | --------- | ------------------------------------------------------ | | `/ce/send/event` | POST | Events (fire-and-forget pub/sub) | | `/ce/send/event-store` | POST | Events Store (persistent, replayable) | | `/ce/send/command` | POST | Command request | | `/ce/send/query` | POST | Query request | | `/ce/send/response` | POST | Response to a command/query (`?request_id=`) | | `/ce/queue/send` | POST | Queue send | | `/ce/queue/receive` | POST | Queue receive (control op) | | `/ce/queue/ack_all` | POST | Queue ack-all (control op) | | `/ce/subscribe/events` | GET (SSE) | Subscribe to events | | `/ce/subscribe/events-store` | GET (SSE) | Subscribe to events store (replay via `Last-Event-ID`) | | `/ce/subscribe/commands` | GET (SSE) | Subscribe to commands | | `/ce/subscribe/queries` | GET (SSE) | Subscribe to queries | Every send endpoint accepts a CloudEvent in either structured or binary content mode. Channels resolve from the CloudEvent `subject` attribute, falling back to a `?channel=` query parameter. See [CE ↔ KubeMQ mapping](/connectors/cloudevents/reference/ce-to-kubemq-mapping) for the full attribute table and resolution rules. ## Send a CloudEvent [#send-a-cloudevent] Publish a fire-and-forget event with `POST /ce/send/event`. The example below sends a structured-mode CloudEvent whose `subject` (`notifications`) becomes the KubeMQ channel. A successful send returns HTTP 202 with `is_error: false`. ```bash curl -X POST http://localhost:9090/ce/send/event \ -H "Content-Type: application/cloudevents+json" \ -d '{ "specversion": "1.0", "type": "com.example.order.created", "source": "order-service", "subject": "notifications", "datacontenttype": "application/json", "data": {"order_id": "12345", "amount": 99.99} }' ``` ```csharp using CloudNative.CloudEvents; using CloudNative.CloudEvents.SystemTextJson; using System.Net.Http.Headers; var base_ = "http://localhost:9090"; var channel = "notifications"; // Build and publish a CloudEvent (structured mode). var formatter = new JsonEventFormatter(); var cloudEvent = new CloudEvent { Id = Guid.NewGuid().ToString(), Type = "com.kubemq.examples.events.sent", Source = new Uri("urn:kubemq-ce-csharp-example"), Subject = channel, DataContentType = "application/json", Data = new { message = "Hello from C# CloudEvents example!" }, }; cloudEvent.SetAttributeFromString("time", DateTimeOffset.UtcNow.ToString("O")); var eventBytes = formatter.EncodeStructuredModeMessage(cloudEvent, out var contentType); using var httpClient = new HttpClient(); using var content = new ByteArrayContent(eventBytes.ToArray()); content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType.ToString()); var resp = await httpClient.PostAsync($"{base_}/ce/send/event", content); Console.WriteLine($"Published: status={resp.StatusCode}"); ``` ```go import ( "encoding/json" "net/http" "strings" cloudevents "github.com/cloudevents/sdk-go/v2" ) base := "http://localhost:9090" channel := "notifications" // Build and send CloudEvent (structured mode). event := cloudevents.NewEvent() event.SetType("com.kubemq.examples.events.sent") event.SetSource("kubemq-ce-go-example") event.SetSubject(channel) // subject = KubeMQ channel _ = event.SetData(cloudevents.ApplicationJSON, map[string]string{ "message": "Hello from Go CloudEvents example!", }) body, _ := json.Marshal(event) req, _ := http.NewRequest("POST", base+"/ce/send/event", strings.NewReader(string(body))) req.Header.Set("Content-Type", "application/cloudevents+json") resp, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer resp.Body.Close() ``` ```java import com.fasterxml.jackson.databind.ObjectMapper; import io.cloudevents.CloudEvent; import io.cloudevents.core.builder.CloudEventBuilder; import io.cloudevents.core.format.EventFormat; import io.cloudevents.core.provider.EventFormatProvider; import io.cloudevents.jackson.JsonFormat; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.OffsetDateTime; import java.util.Map; import java.util.UUID; String base = "http://localhost:9090"; String channel = "notifications"; ObjectMapper mapper = new ObjectMapper(); // Build CloudEvent (structured mode). EventFormatProvider.getInstance().registerFormat(new JsonFormat()); EventFormat format = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE); CloudEvent event = CloudEventBuilder.v1() .withId(UUID.randomUUID().toString()) .withType("com.kubemq.examples.events.sent") .withSource(URI.create("kubemq-ce-java-example")) .withSubject(channel) .withDataContentType("application/json") .withTime(OffsetDateTime.now()) .withData("application/json", mapper.writeValueAsBytes(Map.of("message", "Hello from Java CloudEvents example!"))) .build(); byte[] body = format.serialize(event); HttpClient httpClient = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(base + "/ce/send/event")) .POST(HttpRequest.BodyPublishers.ofByteArray(body)) .header("Content-Type", "application/cloudevents+json") .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); System.out.printf("Published: status=%d%n", response.statusCode()); ``` ```typescript import { CloudEvent, HTTP } from 'cloudevents'; const base = 'http://localhost:9090'; const channel = 'notifications'; // Build and publish CloudEvent (structured mode). const event = new CloudEvent({ type: 'com.kubemq.examples.events.sent', source: 'kubemq-ce-js-example', subject: channel, datacontenttype: 'application/json', data: { message: 'Hello from JavaScript/TypeScript CloudEvents example!' }, }); const message = HTTP.structured(event); const resp = await fetch(`${base}/ce/send/event`, { method: 'POST', headers: message.headers as Record, body: message.body as string, }); const result = (await resp.json()) as { is_error: boolean }; console.log(`Published: status=${resp.status} is_error=${result.is_error}`); ``` ```python import requests from cloudevents.v1.conversion import to_structured from cloudevents.v1.http import CloudEvent base = "http://localhost:9090" channel = "notifications" # Build and send CloudEvent (structured mode). event = CloudEvent( attributes={ "type": "com.kubemq.examples.events.sent", "source": "kubemq-ce-python-example", "subject": channel, "datacontenttype": "application/json", }, data={"message": "Hello from Python CloudEvents example!"}, ) headers, body = to_structured(event) resp = requests.post( f"{base}/ce/send/event", data=body, headers=dict(headers), timeout=10, ) result = resp.json() print(f"Published: status={resp.status_code} is_error={result.get('is_error')}") ``` ```ruby require "net/http" require "uri" require "json" require "securerandom" require "cloud_events" base = "http://localhost:9090" channel = "notifications" # Build and publish CloudEvent (structured mode). sdk = CloudEvents::HttpBinding.default event = CloudEvents::Event::V1.new( id: SecureRandom.uuid, type: "com.kubemq.examples.events.sent", source: URI("urn:kubemq-ce-ruby-example"), subject: channel, spec_version: "1.0", data_content_type: CloudEvents::ContentType.new("application/json"), data: JSON.generate({ message: "Hello from Ruby CloudEvents example!" }) ) headers, body = sdk.encode_event(event, structured_format: "json") uri = URI("#{base}/ce/send/event") Net::HTTP.start(uri.host, uri.port) do |http| req = Net::HTTP::Post.new(uri) req["Content-Type"] = headers["Content-Type"] req.body = body res = http.request(req) result = JSON.parse(res.body) puts "Published: status=#{res.code} is_error=#{result['is_error']}" end ``` ```rust use cloudevents::{EventBuilder, EventBuilderV10}; use reqwest::Client; use serde_json::{json, Value}; use uuid::Uuid; let base = "http://localhost:9090"; let channel = "notifications"; let client = Client::new(); // Build CloudEvent (structured mode using cloudevents-sdk). let event = EventBuilderV10::new() .id(Uuid::new_v4().to_string()) .ty("com.kubemq.examples.events.sent") .source("urn:kubemq-ce-rust-example") .subject(channel) .data( "application/json", json!({"message": "Hello from Rust CloudEvents example!"}), ) .build()?; // Serialize to structured mode JSON. let body = serde_json::to_string(&event)?; let resp = client .post(format!("{}/ce/send/event", base)) .header("Content-Type", "application/cloudevents+json") .body(body) .send() .await?; let result: Value = resp.json().await?; println!("Published: is_error={}", result["is_error"]); ``` ## Supported languages [#supported-languages] The CE connector is plain HTTP, so any language with an HTTP client works. The examples in this section use each language's official CloudEvents SDK to build the envelope: | Language | CloudEvents SDK | Version | Runtime | | ----------------------- | ---------------------------------- | ------- | ------------ | | Go | `github.com/cloudevents/sdk-go/v2` | 2.16.0 | Go 1.21+ | | Python | `cloudevents` | 2.0.0 | Python 3.10+ | | JavaScript / TypeScript | `cloudevents` | 10.0.0 | Node.js 18+ | | Java | `io.cloudevents:cloudevents-core` | 4.0.1 | Java 21+ | | C# | `CloudNative.CloudEvents` | 2.8.0 | .NET 8+ | | Ruby | `cloud_events` | 0.9 | Ruby 3.1+ | | Rust | `cloudevents-sdk` | 0.9 | Rust 1.75+ | The CloudEvents SDK is a convenience for building and parsing the envelope — it is not required. The structured-mode body and binary-mode `ce-*` headers are plain HTTP, so curl and any raw HTTP client work just as well. See [Content modes](/connectors/cloudevents/how-to/content-modes) for both wire formats. ## Next steps [#next-steps] # Google Cloud Pub/Sub (/connectors/gcp-pub-sub) Point your Google Cloud Pub/Sub application at KubeMQ by setting **one environment variable** — `PUBSUB_EMULATOR_HOST`. The **Google Cloud Pub/Sub connector** is a built-in, wire-protocol bridge inside kubemq-server that speaks the genuine Pub/Sub v1 gRPC services on a dedicated gRPC listener (default port **8085**, the Pub/Sub emulator convention). Any standard, unmodified Pub/Sub client — the Go, Python, Java, Node.js, C#, and Ruby first-party Google clients, plus `gcloud pubsub` — talks to KubeMQ exactly as it would to Google's local emulator, with **no code changes, no library swap, and no KubeMQ SDK**. ## What is the Pub/Sub connector [#what-is-the-pubsub-connector] The connector is a **single gRPC listener** that implements the real Pub/Sub v1 wire protocol — **38 RPCs** across four services: * **`google.pubsub.v1.Publisher`** (9 RPCs) — topics and publish. * **`google.pubsub.v1.Subscriber`** (16 RPCs) — subscriptions, pull, streaming pull, ack, snapshots, and seek. * **`google.pubsub.v1.SchemaService`** (10 RPCs) — Avro and Protobuf schemas. * **`google.iam.v1.IAMPolicy`** (3 permissive stubs) — emulator-parity IAM. Every official Pub/Sub client library honours the `PUBSUB_EMULATOR_HOST` environment variable: when it is set, the SDK **clears credentials, skips Google auth, and dials insecure gRPC** — exactly as it would against Google's local emulator. **The connector *is* the emulator** — there is no separate emulator to install, no LocalStack, and no boot-the-server step beyond running kubemq-server. Two KubeMQ primitives back the model: a **topic** maps onto a native KubeMQ **Events Store** log `gcp.{topic}` (the authoritative, replayable source of truth), and each **subscription** maps onto a native **Queue** channel `gcp.sub.{subscription}`. A publish is written **once** to the topic log, then fanned out to one queue copy per subscription — so Pub/Sub producers and native gRPC/REST consumers interoperate on the same messages. The Pub/Sub connector is **opt-in (disabled by default)**. A stock kubemq-server does **not** bind gRPC port 8085 until you enable it with `CONNECTORS_GCP_ENABLE=true` (Docker) or `spec.gcp.enabled: true` (Kubernetes). See [Getting started](/connectors/gcp-pub-sub/tutorials/getting-started). ## How it works [#how-it-works] A Pub/Sub SDK client dials the connector's gRPC endpoint. A `Publish` is written once to the topic's Events Store log `gcp.{topic}` through the message broker, then fanned out to one Queue copy per subscription `gcp.sub.{subscription}` (applying each subscription's filter). `Pull` / `StreamingPull` lease each delivered message under an ack-deadline, and `Acknowledge` removes it from the subscription queue. *A publish writes once to the Events Store log `gcp.{topic}` and fans out one Queue copy per subscription on `gcp.sub.{subscription}`, all backed by the message broker; consumers pull each message under an ack-deadline lease.* ## Ports & protocol surface [#ports--protocol-surface] | Port | Transport | Protocol | Notes | | ------ | ------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `8085` | Insecure gRPC | Pub/Sub v1 gRPC (emulator mode) | The Pub/Sub emulator convention. **Opt-in** — bound only when `CONNECTORS_GCP_ENABLE=true`. Must differ from the gRPC/REST/HTTP and AWS-connector ports. No auth, no TLS — exactly like Google's local emulator. | | — | gRPC over TLS | Pub/Sub v1 gRPC | TLS is provided by the server-wide `Security` block — there is **no Pub/Sub-specific TLS option**. The default emulator path is unencrypted; do not expose port 8085 to untrusted networks. | The connector is **gRPC only** — there is **no REST/JSON v1** surface (no grpc-gateway). Clients and tools that only speak the Pub/Sub REST API will not work; use a gRPC client library or `gcloud` (which uses gRPC for the emulator). See [Architecture](/connectors/gcp-pub-sub/concepts/architecture) for the dispatch detail. ## Send a message [#send-a-message] The example below runs the full round-trip — `CreateTopic` → `CreateSubscription` → `Publish` → `Pull` → `Acknowledge` — over a stock Google Cloud Pub/Sub client. The only change versus a real-GCP app is the **emulator endpoint**: each client reads `PUBSUB_EMULATOR_HOST` (default `localhost:8085`) and a project id from `PUBSUB_PROJECT_ID` (any value — the project segment is parsed but ignored). ```go package main import ( "context" "fmt" "log" "os" "sync" "time" "cloud.google.com/go/pubsub" ) func projectID() string { if v := os.Getenv("PUBSUB_PROJECT_ID"); v != "" { return v } return "my-project" // any id; the project segment is parsed but ignored. } func main() { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() // PUBSUB_EMULATOR_HOST (default localhost:8085) routes the official client at // the connector over insecure gRPC with credentials cleared — no code change. client, err := pubsub.NewClient(ctx, projectID()) if err != nil { log.Fatalf("NewClient: %v", err) } defer client.Close() // CreateTopic "orders" maps to Events Store log "gcp.orders". topic, err := client.CreateTopic(ctx, "orders") if err != nil { log.Fatalf("CreateTopic: %v", err) } defer topic.Stop() // CreateSubscription maps to Queue channel "gcp.sub.sub-orders". sub, err := client.CreateSubscription(ctx, "sub-orders", pubsub.SubscriptionConfig{ Topic: topic, AckDeadline: 10 * time.Second, // connector default; valid range 10..600s. }) if err != nil { log.Fatalf("CreateSubscription: %v", err) } id, err := topic.Publish(ctx, &pubsub.Message{Data: []byte("hello from cloud.google.com/go/pubsub")}).Get(ctx) if err != nil { log.Fatalf("Publish: %v", err) } fmt.Printf("published: %s\n", id) recvCtx, recvCancel := context.WithTimeout(ctx, 15*time.Second) defer recvCancel() var once sync.Once err = sub.Receive(recvCtx, func(_ context.Context, m *pubsub.Message) { fmt.Printf("received: %q\n", string(m.Data)) m.Ack() // Acknowledge by ack_id under its lease. once.Do(recvCancel) }) if err != nil && recvCtx.Err() == nil { log.Fatalf("Receive: %v", err) } } ``` ```python import os from google.cloud import pubsub_v1 def project_id() -> str: # Any id works — the project segment is parsed but ignored. return os.environ.get("PUBSUB_PROJECT_ID", "my-project") def main() -> None: # Both clients honour PUBSUB_EMULATOR_HOST (default localhost:8085): they clear # credentials, skip Google auth, and dial insecure gRPC. publisher = pubsub_v1.PublisherClient() subscriber = pubsub_v1.SubscriberClient() proj = project_id() topic_path = publisher.topic_path(proj, "orders") # -> gcp.orders sub_path = subscriber.subscription_path(proj, "sub-orders") # -> gcp.sub.sub-orders publisher.create_topic(request={"name": topic_path}) subscriber.create_subscription(request={"name": sub_path, "topic": topic_path}) future = publisher.publish(topic_path, b"hello from google-cloud-pubsub") print(f"published: {future.result(timeout=15)}") resp = subscriber.pull(request={"subscription": sub_path, "max_messages": 1}, timeout=20) msg = resp.received_messages[0] print(f"received: {msg.message.data.decode()!r}") # Acknowledge by ack_id; the message leaves the subscription queue. subscriber.acknowledge(request={"subscription": sub_path, "ack_ids": [msg.ack_id]}) subscriber.close() if __name__ == "__main__": main() ``` ```java import com.google.api.gax.core.NoCredentialsProvider; import com.google.api.gax.grpc.GrpcTransportChannel; import com.google.api.gax.rpc.FixedTransportChannelProvider; import com.google.api.gax.rpc.TransportChannelProvider; import com.google.cloud.pubsub.v1.SubscriptionAdminClient; import com.google.cloud.pubsub.v1.SubscriptionAdminSettings; import com.google.cloud.pubsub.v1.TopicAdminClient; import com.google.cloud.pubsub.v1.TopicAdminSettings; import com.google.cloud.pubsub.v1.stub.GrpcSubscriberStub; import com.google.cloud.pubsub.v1.stub.SubscriberStubSettings; import com.google.protobuf.ByteString; import com.google.pubsub.v1.AcknowledgeRequest; import com.google.pubsub.v1.PublishRequest; import com.google.pubsub.v1.PubsubMessage; import com.google.pubsub.v1.PullRequest; import com.google.pubsub.v1.PullResponse; import com.google.pubsub.v1.PushConfig; import com.google.pubsub.v1.ReceivedMessage; import com.google.pubsub.v1.SubscriptionName; import com.google.pubsub.v1.TopicName; import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; public final class Main { public static void main(String[] args) throws Exception { String emulatorHost = System.getenv().getOrDefault("PUBSUB_EMULATOR_HOST", "localhost:8085"); String projectId = System.getenv().getOrDefault("PUBSUB_PROJECT_ID", "my-project"); // The Go/Python/Node clients auto-detect the emulator; Java points a plaintext // gRPC channel at the host explicitly with NoCredentialsProvider. ManagedChannel channel = ManagedChannelBuilder.forTarget(emulatorHost).usePlaintext().build(); TransportChannelProvider channelProvider = FixedTransportChannelProvider.create(GrpcTransportChannel.create(channel)); NoCredentialsProvider noCreds = NoCredentialsProvider.create(); TopicName topic = TopicName.of(projectId, "orders"); // -> gcp.orders SubscriptionName sub = SubscriptionName.of(projectId, "sub-orders"); // -> gcp.sub.sub-orders try (TopicAdminClient topicAdmin = TopicAdminClient.create(TopicAdminSettings.newBuilder() .setTransportChannelProvider(channelProvider).setCredentialsProvider(noCreds).build()); SubscriptionAdminClient subAdmin = SubscriptionAdminClient.create( SubscriptionAdminSettings.newBuilder() .setTransportChannelProvider(channelProvider) .setCredentialsProvider(noCreds).build()); GrpcSubscriberStub subStub = GrpcSubscriberStub.create(SubscriberStubSettings.newBuilder() .setTransportChannelProvider(channelProvider) .setCredentialsProvider(noCreds).build())) { topicAdmin.createTopic(topic); subAdmin.createSubscription(sub, topic, PushConfig.getDefaultInstance(), 10); topicAdmin.publish(PublishRequest.newBuilder() .setTopic(topic.toString()) .addMessages(PubsubMessage.newBuilder() .setData(ByteString.copyFromUtf8("hello from google-cloud-pubsub")).build()) .build()); PullResponse resp = subStub.pullCallable().call(PullRequest.newBuilder() .setSubscription(sub.toString()).setMaxMessages(1).build()); ReceivedMessage got = resp.getReceivedMessages(0); System.out.printf("received: %s%n", got.getMessage().getData().toStringUtf8()); // Acknowledge by ack_id. subStub.acknowledgeCallable().call(AcknowledgeRequest.newBuilder() .setSubscription(sub.toString()).addAckIds(got.getAckId()).build()); } finally { channel.shutdown(); } } } ``` ```typescript import { PubSub, v1 } from "@google-cloud/pubsub"; const projectId = process.env["PUBSUB_PROJECT_ID"] ?? "my-project"; // The high-level PubSub client reads PUBSUB_EMULATOR_HOST (default localhost:8085) // and resolves the insecure emulator transport; reuse its options for the v1 clients. const baseOptions = new PubSub({ projectId }).options; const options = { ...baseOptions, port: baseOptions.port === undefined ? undefined : Number(baseOptions.port) }; const publisher = new v1.PublisherClient(options); const subscriber = new v1.SubscriberClient(options); async function main(): Promise { const topic = publisher.projectTopicsPath(projectId, "orders"); // -> gcp.orders const sub = subscriber.subscriptionPath(projectId, "sub-orders"); // -> gcp.sub.sub-orders await publisher.createTopic({ name: topic }); await subscriber.createSubscription({ name: sub, topic, ackDeadlineSeconds: 10 }); const [published] = await publisher.publish({ topic, messages: [{ data: Buffer.from("hello from @google-cloud/pubsub") }], }); console.log(`published: ${published.messageIds?.[0]}`); const [pull] = await subscriber.pull({ subscription: sub, maxMessages: 1 }); const received = pull.receivedMessages![0]; console.log(`received: ${Buffer.from(received.message!.data as Uint8Array).toString("utf8")}`); // Acknowledge by ack_id. await subscriber.acknowledge({ subscription: sub, ackIds: [received.ackId!] }); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using Google.Api.Gax; using Google.Cloud.PubSub.V1; using Google.Protobuf; var projectId = Environment.GetEnvironmentVariable("PUBSUB_PROJECT_ID") ?? "my-project"; var topicName = TopicName.FromProjectTopic(projectId, "orders"); // -> gcp.orders var subName = SubscriptionName.FromProjectSubscription(projectId, "sub-orders"); // -> gcp.sub.sub-orders // The .NET client does NOT auto-detect the emulator — set EmulatorOnly so each // client reads PUBSUB_EMULATOR_HOST (default localhost:8085) and dials insecurely. var publisher = await new PublisherServiceApiClientBuilder { EmulatorDetection = EmulatorDetection.EmulatorOnly, }.BuildAsync(); var subscriber = await new SubscriberServiceApiClientBuilder { EmulatorDetection = EmulatorDetection.EmulatorOnly, }.BuildAsync(); await publisher.CreateTopicAsync(topicName); await subscriber.CreateSubscriptionAsync(subName, topicName, pushConfig: null, ackDeadlineSeconds: 10); var publishResponse = await publisher.PublishAsync(topicName, new[] { new PubsubMessage { Data = ByteString.CopyFromUtf8("hello from Google.Cloud.PubSub.V1") }, }); Console.WriteLine($"published: {publishResponse.MessageIds[0]}"); var pull = await subscriber.PullAsync(subName, maxMessages: 1); var received = pull.ReceivedMessages[0]; Console.WriteLine($"received: {received.Message.Data.ToStringUtf8()}"); // Acknowledge by ack_id. await subscriber.AcknowledgeAsync(subName, new[] { received.AckId }); ``` ```ruby # frozen_string_literal: true require "google/cloud/pubsub" project_id = ENV["PUBSUB_PROJECT_ID"] || "my-project" # Ruby needs the emulator host passed explicitly (it does not always read the env var). emulator_host = ENV["PUBSUB_EMULATOR_HOST"] || "localhost:8085" pubsub = Google::Cloud::PubSub.new(project_id: project_id, emulator_host: emulator_host) topic_admin = pubsub.topic_admin sub_admin = pubsub.subscription_admin topic_path = pubsub.topic_path("orders") # -> gcp.orders sub_path = pubsub.subscription_path("sub-orders") # -> gcp.sub.sub-orders topic = topic_admin.create_topic(name: topic_path) sub_admin.create_subscription(name: sub_path, topic: topic_path, ack_deadline_seconds: 10) publisher = pubsub.publisher(topic.name) msg = publisher.publish("hello from google-cloud-pubsub") puts "published: #{msg.message_id}" subscriber = pubsub.subscriber(sub_path) received = subscriber.pull(immediate: false, max: 1) rcv = received.first puts "received: #{rcv.data.inspect}" # Acknowledge by ack_id; the message leaves the subscription queue. rcv.acknowledge! ``` ## Supported languages [#supported-languages] The connector speaks the genuine Pub/Sub v1 wire protocol, so any first-party Google Cloud Pub/Sub client works — you only set `PUBSUB_EMULATOR_HOST`. There is no KubeMQ SDK, no proto bindings, and no published package; the examples pin one official Google client per language. | Language | Google Cloud Pub/Sub client | Emulator construction | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | Go | [`cloud.google.com/go/pubsub`](https://pkg.go.dev/cloud.google.com/go/pubsub) | `pubsub.NewClient(ctx, projectID)` — auto-detects `PUBSUB_EMULATOR_HOST`, dials insecurely | | Python | [`google-cloud-pubsub`](https://cloud.google.com/python/docs/reference/pubsub/latest) (via **uv**) | `pubsub_v1.PublisherClient()` — honours `PUBSUB_EMULATOR_HOST` | | Java | [`com.google.cloud:google-cloud-pubsub`](https://cloud.google.com/java/docs/reference/google-cloud-pubsub/latest/overview) (BOM) | plaintext `ManagedChannel` to the emulator host + `NoCredentialsProvider` | | JavaScript / TypeScript | [`@google-cloud/pubsub`](https://cloud.google.com/nodejs/docs/reference/pubsub/latest) | `new PubSub({ projectId })` — auto-detects the emulator; run via `tsx` | | C# / .NET | [`Google.Cloud.PubSub.V1`](https://cloud.google.com/dotnet/docs/reference/Google.Cloud.PubSub.V1/latest) (.NET 8) | `…Builder { EmulatorDetection = EmulatorDetection.EmulatorOnly }.Build()` | | Ruby | [`google-cloud-pubsub`](https://cloud.google.com/ruby/docs/reference/google-cloud-pubsub/latest) | `Google::Cloud::PubSub.new(project_id:, emulator_host:)` | There is **no Rust tab** — Google ships no first-party Pub/Sub client for Rust. The connector's verified example suite is the six languages above. Most clients auto-detect the emulator from `PUBSUB_EMULATOR_HOST`; **C#** needs `EmulatorDetection.EmulatorOnly`, **Ruby** needs an explicit `emulator_host:`, and **Java** points a plaintext channel at the host — see [Connectivity & emulator mode](/connectors/gcp-pub-sub/how-to/connectivity-and-emulator-mode). ## Next steps [#next-steps] # Kafka (/connectors/kafka) Point your Kafka clients at KubeMQ by repointing `bootstrap.servers` — no client-library swap, no code change. The **Kafka connector** is a built-in, wire-protocol bridge inside kubemq-server that speaks the real Kafka request/response protocol — length-prefixed frames, flexible versions, the `kmsg` codec — over plain TCP and TLS. Any off-the-shelf Kafka client — Java `kafka-clients`/AdminClient/Spring, librdkafka/`kcat`/`confluent-kafka`, or `franz-go`/`sarama`/`segmentio` — connects to KubeMQ exactly as it would to a real Kafka broker. ## What is the Kafka connector [#what-is-the-kafka-connector] The Kafka connector implements Kafka's Produce/Fetch/group-coordinator/admin request surface directly inside kubemq-server — there's no separate broker process to run and no protocol-translation layer for your application to configure around. Every produced record lands in a persistent, ordered, replayable **Events Store** channel whose `Sequence` maps one-to-one onto a Kafka offset: durable, restart-stable, and identical across every node of a cluster. Key capabilities: * **Native produce/consume with classic consumer groups** — the large majority of everyday Kafka usage, a straight repoint with no client changes. * **OAUTHBEARER/OIDC federated auth**, alongside SASL/PLAIN, SASL/SCRAM, mTLS client certificates, and Kafka ACL enforcement. * **Compacted topics** (`cleanup.policy=compact`), which unlock the compaction-dependent ecosystem — Kafka Connect and Kafka Streams both run against it. * **Transactions and exactly-once semantics (EOS)** — a transactional producer with `read_committed` isolation and producer fencing. * **Static membership** (`group.instance.id`, KIP-345), so a consumer restart rejoins its group without triggering a rebalance. **Opt-in — disabled by default.** A stock kubemq-server does **not** bind ports `9092` / `9093` until you turn the connector on with `CONNECTORS_KAFKA_ENABLE=true` (Docker) or `spec.kafka.enabled: true` (Kubernetes). See [Getting started](/connectors/kafka/tutorials/getting-started). ## How it works [#how-it-works] A Kafka client dials the connector's TCP listener and issues the same request types it would send to a real broker. A `Produce` request is appended to the topic's Events Store log; a `Fetch` request — including one driven by a consumer group's assigned partitions — reads back from that same log. Because each Kafka offset maps one-to-one onto the log's durable `Sequence`, both directions stay durable and ordered through the message broker underneath. *A `Produce` request from a Kafka client is appended to the topic's Events Store log `kafka.{topic}` through the message broker; a `Fetch` request — including one driven by a consumer group's assigned partitions — reads back from that same log, so each Kafka offset maps one-to-one onto the log's durable `Sequence`.* **Runs on the auto-selected `next` storage engine.** Kafka's headline capabilities — compacted topics and the quorum-fsynced ack contract — exist only on KubeMQ's `next` storage engine. On a **fresh** store, enabling Kafka **auto-selects `next`** with no manual `store.engine` step. See [Storage Engines → Zero-config engine selection](/configure/reference/storage-engines#zero-config-engine-selection) for the full selection rules, including what happens on an existing store. ## Ports & protocol surface [#ports--protocol-surface] | Port | Transport | Protocol | Notes | | ------ | ------------ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `9092` | Plain TCP | Kafka wire protocol — `PLAINTEXT` / `SASL_PLAINTEXT` | Default listener. **Opt-in** — bound only when `CONNECTORS_KAFKA_ENABLE=true`. Disable by setting `CONNECTORS_KAFKA_PORT=""`. Must differ from the TLS port. | | `9093` | TLS over TCP | Kafka wire protocol — `SSL` / `SASL_SSL` | Reuses the server-wide `Security` block (cert + key) — there is no Kafka-specific TLS option. **Required for OAUTHBEARER** (refused on plaintext). | Clients read one setting, `bootstrap.servers` (or the client's equivalent), pointed at `localhost:9092` for the plaintext listener. See [Configuration](/connectors/kafka/concepts/configuration) for the opt-in flag, the advertised-host/port pair, and where the full field-by-field settings reference lives. ## Send & receive a message [#send--receive-a-message] The example below produces one message to the `orders` topic and reads it back on `localhost:9092` — the only change versus a real Kafka client is the **`bootstrap.servers` value**. No KubeMQ SDK, no code change: every tab uses a stock Kafka client library. ```bash # Produce one message to topic "orders" echo "hello from kcat" | kcat -P -b localhost:9092 -t orders # Consume from the beginning and exit after one message kcat -C -b localhost:9092 -t orders -o beginning -c 1 ``` ```go package main import ( "context" "fmt" "log" "github.com/twmb/franz-go/pkg/kgo" ) func main() { ctx := context.Background() // bootstrap.servers repoint only — no KubeMQ SDK, no code change. cl, err := kgo.NewClient( kgo.SeedBrokers("localhost:9092"), kgo.ConsumeTopics("orders"), kgo.ConsumeResetOffset(kgo.NewOffset().AtStart()), ) if err != nil { log.Fatal(err) } defer cl.Close() record := &kgo.Record{Topic: "orders", Value: []byte("hello from franz-go")} if err := cl.ProduceSync(ctx, record).FirstErr(); err != nil { log.Fatalf("produce: %v", err) } fmt.Println("produced to orders") fetches := cl.PollFetches(ctx) fetches.EachRecord(func(r *kgo.Record) { fmt.Printf("received: %s\n", string(r.Value)) }) } ``` ```python from confluent_kafka import Consumer, Producer BOOTSTRAP = "localhost:9092" def main() -> None: producer = Producer({"bootstrap.servers": BOOTSTRAP}) producer.produce("orders", value=b"hello from confluent-kafka") producer.flush() print("produced to orders") consumer = Consumer({ "bootstrap.servers": BOOTSTRAP, "group.id": "orders-consumer", "auto.offset.reset": "earliest", }) consumer.subscribe(["orders"]) msg = consumer.poll(timeout=10.0) if msg is not None and msg.error() is None: print(f"received: {msg.value().decode()!r}") consumer.close() if __name__ == "__main__": main() ``` ```java import org.apache.kafka.clients.consumer.ConsumerRecord; import org.apache.kafka.clients.consumer.ConsumerRecords; import org.apache.kafka.clients.consumer.KafkaConsumer; import org.apache.kafka.clients.producer.KafkaProducer; import org.apache.kafka.clients.producer.ProducerRecord; import org.apache.kafka.common.serialization.StringDeserializer; import org.apache.kafka.common.serialization.StringSerializer; import java.time.Duration; import java.util.Collections; import java.util.Properties; public final class Main { public static void main(String[] args) { String bootstrap = "localhost:9092"; Properties producerProps = new Properties(); producerProps.put("bootstrap.servers", bootstrap); producerProps.put("key.serializer", StringSerializer.class.getName()); producerProps.put("value.serializer", StringSerializer.class.getName()); try (KafkaProducer producer = new KafkaProducer<>(producerProps)) { producer.send(new ProducerRecord<>("orders", "hello from kafka-clients")); producer.flush(); System.out.println("produced to orders"); } Properties consumerProps = new Properties(); consumerProps.put("bootstrap.servers", bootstrap); consumerProps.put("group.id", "orders-consumer"); consumerProps.put("key.deserializer", StringDeserializer.class.getName()); consumerProps.put("value.deserializer", StringDeserializer.class.getName()); consumerProps.put("auto.offset.reset", "earliest"); try (KafkaConsumer consumer = new KafkaConsumer<>(consumerProps)) { consumer.subscribe(Collections.singletonList("orders")); ConsumerRecords records = consumer.poll(Duration.ofSeconds(10)); for (ConsumerRecord record : records) { System.out.printf("received: %s%n", record.value()); } } } } ``` ```typescript import { Kafka } from "kafkajs"; const kafka = new Kafka({ clientId: "kubemq-kafka-example", brokers: ["localhost:9092"], }); async function main(): Promise { const producer = kafka.producer(); await producer.connect(); await producer.send({ topic: "orders", messages: [{ value: "hello from kafkajs" }], }); console.log("produced to orders"); await producer.disconnect(); const consumer = kafka.consumer({ groupId: "orders-consumer" }); await consumer.connect(); await consumer.subscribe({ topic: "orders", fromBeginning: true }); await consumer.run({ eachMessage: async ({ message }) => { console.log(`received: ${message.value?.toString()}`); await consumer.disconnect(); process.exit(0); }, }); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using Confluent.Kafka; const string bootstrapServers = "localhost:9092"; const string topic = "orders"; var producerConfig = new ProducerConfig { BootstrapServers = bootstrapServers }; using (var producer = new ProducerBuilder(producerConfig).Build()) { await producer.ProduceAsync(topic, new Message { Value = "hello from Confluent.Kafka" }); Console.WriteLine("produced to orders"); } var consumerConfig = new ConsumerConfig { BootstrapServers = bootstrapServers, GroupId = "orders-consumer", AutoOffsetReset = AutoOffsetReset.Earliest, }; using var consumer = new ConsumerBuilder(consumerConfig).Build(); consumer.Subscribe(topic); var result = consumer.Consume(TimeSpan.FromSeconds(10)); if (result is not null) Console.WriteLine($"received: {result.Message.Value}"); consumer.Close(); ``` ```ruby require "rdkafka" BOOTSTRAP = "localhost:9092" producer_config = Rdkafka::Config.new("bootstrap.servers" => BOOTSTRAP) producer = producer_config.producer producer.produce(topic: "orders", payload: "hello from rdkafka-ruby").wait puts "produced to orders" producer.close consumer_config = Rdkafka::Config.new( "bootstrap.servers" => BOOTSTRAP, "group.id" => "orders-consumer", "auto.offset.reset" => "earliest" ) consumer = consumer_config.consumer consumer.subscribe("orders") consumer.each do |message| puts "received: #{message.payload}" break end consumer.close ``` ```rust use rdkafka::config::ClientConfig; use rdkafka::consumer::{BaseConsumer, Consumer}; use rdkafka::message::Message; use rdkafka::producer::{BaseProducer, BaseRecord, Producer}; use std::time::Duration; fn main() { let bootstrap = "localhost:9092"; let producer: BaseProducer = ClientConfig::new() .set("bootstrap.servers", bootstrap) .create() .expect("producer creation failed"); producer .send( BaseRecord::to("orders") .payload("hello from rust-rdkafka") .key("order-1"), ) .expect("produce failed"); producer.flush(Duration::from_secs(10)).expect("flush failed"); println!("produced to orders"); let consumer: BaseConsumer = ClientConfig::new() .set("bootstrap.servers", bootstrap) .set("group.id", "orders-consumer") .set("auto.offset.reset", "earliest") .create() .expect("consumer creation failed"); consumer.subscribe(&["orders"]).expect("subscribe failed"); if let Some(result) = consumer.poll(Duration::from_secs(10)) { let msg = result.expect("poll failed"); if let Some(payload) = msg.payload() { println!("received: {}", String::from_utf8_lossy(payload)); } } } ``` ## Supported clients [#supported-clients] The connector speaks the genuine Kafka wire protocol, so any conformant client library works — there is no KubeMQ SDK and no proto bindings. The examples above pin one client per language; alternates are noted where the ecosystem commonly uses more than one. | Language | Client library | Notes | | ----------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | kcat | [`kcat`](https://github.com/edenhill/kcat) (librdkafka CLI) | The canonical quick-check Kafka client — no code, just a bootstrap address. | | Go | [`github.com/twmb/franz-go`](https://github.com/twmb/franz-go) | Matches the connector's own conformance-harness client; `sarama` and `segmentio/kafka-go` also work. | | Python | [`confluent-kafka`](https://github.com/confluentinc/confluent-kafka-python) (librdkafka bindings) | | | Java | [`org.apache.kafka:kafka-clients`](https://kafka.apache.org/documentation/#api) | AdminClient and Spring Kafka run on top of the same client unchanged. | | JavaScript / TypeScript | [`kafkajs`](https://kafka.js.org/) | | | C# / .NET | [`Confluent.Kafka`](https://github.com/confluentinc/confluent-kafka-dotnet) (librdkafka bindings) | | | Ruby | [`rdkafka`](https://github.com/karafka/rdkafka-ruby) (librdkafka bindings) | | | Rust | [`rdkafka`](https://github.com/fede1024/rust-rdkafka) (librdkafka bindings) | | **Share groups (KIP-932) are supported in preview, not GA.** Queue-style acquire/acknowledge consumption is implemented and advertised, but only **franz-go** and Java's preview `KafkaShareConsumer` ship a share-consumer API today — GA is pending the multi-client conformance matrix. See the [fitness matrix](/connectors/kafka/reference/fitness-matrix) for the full verdict. Not every capability above is proven to the same tier — see the [fitness matrix](/connectors/kafka/reference/fitness-matrix) for the full supported / caveat / roadmap / unsupported breakdown before you commit to a migration. Every Kafka setting — ports, advertised host/port, SASL mechanisms, OAUTHBEARER, and the advanced tuning knobs — is documented field-by-field in [the Kafka settings reference](/configure/reference/connectors#kafka). Moving an *existing* Kafka, MSK, or Confluent workload onto KubeMQ starts with the read-only `kmq assess kafka` command, then the `kmq migrate` tool — [Migrate from Kafka](/connectors/kafka/how-to/migrate-from-kafka) walks through the full assess → replicate → translate → cutover playbook, and the full [`kmq` CLI reference](/operate/kmq-cli) documents both commands. ## Next steps [#next-steps] # MQTT (/connectors/mqtt) Point your MQTT 3.1.1 / 5.0 application at KubeMQ by changing only the broker address. The **MQTT connector** is a built-in, wire-protocol bridge inside kubemq-server: an embedded MQTT broker that speaks the standard protocol natively, so any off-the-shelf MQTT client (paho, MQTT.js, MQTTnet, rumqttc) reaches KubeMQ's Events, Events-Store, Queues, Commands, and Queries with no KubeMQ SDK, no library swap, and no code rewrite. ## What is the MQTT connector [#what-is-the-mqtt-connector] [MQTT](https://mqtt.org/) is a lightweight publish/subscribe protocol built around *topics*: a client connects, subscribes to topic filters, and publishes messages with a chosen *Quality of Service* (QoS 0/1/2). The KubeMQ MQTT connector exposes this protocol on its own dedicated ports and maps it onto **all five KubeMQ messaging patterns**. The **first segment of the MQTT topic** selects the KubeMQ pattern, and the remaining segments become the KubeMQ channel with `/` translated to `.`. A publish to `events/site1/temp` lands on the Events channel `site1.temp`; `queues/jobs` targets a KubeMQ Queue; `commands/restart` and `queries/status` drive RPC. The connector is a *gateway*, not a client library — your application only needs a stock MQTT client. Key capabilities: * **All five patterns over one wire** — Events, Events-Store, Queues, Commands, and Queries, selected by the topic prefix. * **Topic-driven pattern routing** — `events/`, `store/`, `queues/`, `commands/`, `queries/` prefixes map a topic to a KubeMQ pattern; a bare (prefixless) topic routes to the configured `DefaultPattern` (`events` by default). * **MQTT 3.1.1 and 5.0** — both protocol levels on the same listener; MQTT 5.0 unlocks User-Properties (carried as KubeMQ Tags) and RPC (Commands / Queries). * **Cross-protocol interop** — a message published over MQTT to `events/it/cross` is consumable by a gRPC or REST KubeMQ client on channel `it.cross`, and vice-versa. **Retain is silently dropped.** The connector forces `RetainAvailable=0` in `CONNACK`. A runtime publish with the retain flag set returns a success PUBACK (`0x00`) but the message is stripped and **never delivered or stored**. A *Will*-retain requested at CONNECT time is rejected outright with CONNACK `0x9A`. There are also **no durable subscriptions** — MQTT sessions are in-memory and node-local. See [QoS and sessions](/connectors/mqtt/concepts/qos-and-sessions). ## How it works [#how-it-works] An MQTT client connects to the connector and publishes to (or subscribes to) a topic. The connector resolves the topic prefix to a KubeMQ `(pattern, channel)` pair, hands the message to the message broker, and consumers on the same channel — over MQTT or any other KubeMQ transport — receive it. *The embedded broker accepts a standard MQTT connection, the topic mapper resolves the prefix to a KubeMQ pattern and channel, and the message broker fans it out to consumers on any transport.* ## Ports & protocol surface [#ports--protocol-surface] | Port | Transport | Protocol | Notes | | ------ | -------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `1883` | Plain TCP | MQTT 3.1.1 & 5.0 | Default listener. Disable by setting `CONNECTORSMQTT_PORT=""`. | | `8883` | TLS over TCP | MQTT 3.1.1 & 5.0 | Binds only when the server-global `Security` block (cert + key) is configured; otherwise the port is open but the listener is inactive. | | `8083` | WebSocket (path `/`) | MQTT 3.1.1 & 5.0 | MQTT-over-WebSocket at path `/`. Upgraded to `wss://` when `Security` is configured. | Clients select the transport by URL scheme: `tcp://host:1883`, `tls://host:8883`, or `ws://host:8083/`. The examples read one environment variable, `KUBEMQ_MQTT_URL` (default `tcp://localhost:1883`). MQTT 3.1 (protocol level 3) is always rejected; the minimum accepted level is 3.1.1. See [Configuration](/connectors/mqtt/concepts/configuration) for the listener and capability settings. ## Publish an event [#publish-an-event] The example below connects an MQTT 5.0 client and publishes one message to `events/` at QoS 1. The topic prefix `events/` selects the Events pattern, and `/` becomes `.` in the channel, so `events/demo/x` lands on the KubeMQ channel `demo.x`. Every client reads the broker endpoint from `KUBEMQ_MQTT_URL` (default `tcp://localhost:1883`). ```go package main import ( "context" "fmt" "log" "net" "os" "strings" "time" "github.com/eclipse/paho.golang/paho" ) func brokerURL() string { if u := os.Getenv("KUBEMQ_MQTT_URL"); u != "" { return u } return "tcp://localhost:1883" } // tcpAddr strips the scheme prefix to obtain "host:port". func tcpAddr(rawURL string) string { for _, pfx := range []string{"tcp://", "ws://", "tls://"} { if strings.HasPrefix(rawURL, pfx) { return rawURL[len(pfx):] } } return rawURL } func main() { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() // Prefix "events/" selects the Events pattern; '/' becomes '.' so // "events/demo/x" maps to the KubeMQ channel "demo.x". const topic = "events/demo/x" conn, err := net.Dial("tcp", tcpAddr(brokerURL())) if err != nil { log.Fatalf("dial: %v", err) } client := paho.NewClient(paho.ClientConfig{Conn: conn}) connAck, err := client.Connect(ctx, &paho.Connect{ ClientID: "go-mqtt-events-pub", KeepAlive: 30, CleanStart: true, }) if err != nil { log.Fatalf("connect: %v", err) } if connAck.ReasonCode != 0 { log.Fatalf("CONNACK reason=0x%02X", connAck.ReasonCode) } // QoS 1 returns a PUBACK. Do NOT set Retain — a retained publish is // silently dropped (PUBACK 0x00, message never delivered). pubAck, err := client.Publish(ctx, &paho.Publish{ Topic: topic, QoS: 1, Payload: []byte("hello from MQTT"), Properties: &paho.PublishProperties{ // MQTT 5.0 User Properties round-trip as KubeMQ Tags. User: []paho.UserProperty{{Key: "sensor", Value: "thermometer"}}, }, }) if err != nil { log.Fatalf("publish: %v", err) } if pubAck.ReasonCode != 0 { log.Fatalf("PUBACK reason=0x%02X", pubAck.ReasonCode) } fmt.Printf("published to %q (channel demo.x)\n", topic) _ = client.Disconnect(&paho.Disconnect{ReasonCode: 0}) } ``` ```python import os import paho.mqtt.client as mqtt from paho.mqtt.enums import CallbackAPIVersion from paho.mqtt.properties import Properties from paho.mqtt.packettypes import PacketTypes def parse_url(url: str) -> tuple[str, int]: scheme, rest = url.split("://", 1) host_port = rest.rstrip("/") if ":" in host_port: host, port = host_port.rsplit(":", 1) return host, int(port) return host_port, {"tcp": 1883, "tls": 8883, "ws": 8083}.get(scheme, 1883) def main() -> None: url = os.environ.get("KUBEMQ_MQTT_URL", "tcp://localhost:1883") host, port = parse_url(url) # Prefix "events/" selects the Events pattern; "/" becomes "." so # "events/demo/x" maps to the KubeMQ channel "demo.x". topic = "events/demo/x" # paho-mqtt 2.x requires an explicit callback API version. client = mqtt.Client( callback_api_version=CallbackAPIVersion.VERSION2, client_id="python-mqtt-events-pub", protocol=mqtt.MQTTv5, ) client.connect(host, port, keepalive=30, clean_start=True) client.loop_start() # MQTT 5.0 User Properties round-trip as KubeMQ Tags. props = Properties(PacketTypes.PUBLISH) props.UserProperty = [("sensor", "thermometer")] # Do not set retain=True — a retained publish is silently dropped. info = client.publish(topic, payload=b"hello from MQTT", qos=1, properties=props) info.wait_for_publish(timeout=10) print(f"published to {topic!r} (channel demo.x)") client.loop_stop() client.disconnect() if __name__ == "__main__": main() ``` ```java import java.nio.charset.StandardCharsets; import java.util.List; import org.eclipse.paho.mqttv5.client.MqttAsyncClient; import org.eclipse.paho.mqttv5.client.MqttConnectionOptions; import org.eclipse.paho.mqttv5.client.persist.MemoryPersistence; import org.eclipse.paho.mqttv5.common.MqttMessage; import org.eclipse.paho.mqttv5.common.packet.MqttProperties; import org.eclipse.paho.mqttv5.common.packet.UserProperty; public final class Main { public static void main(String[] args) throws Exception { String broker = System.getenv().getOrDefault("KUBEMQ_MQTT_URL", "tcp://localhost:1883"); // Prefix "events/" selects the Events pattern; "/" becomes "." so // "events/demo/x" maps to the KubeMQ channel "demo.x". String topic = "events/demo/x"; MqttConnectionOptions opts = new MqttConnectionOptions(); opts.setCleanStart(true); opts.setKeepAliveInterval(30); // org.eclipse.paho.mqttv5.client always uses MQTT protocol level 5. MqttAsyncClient client = new MqttAsyncClient(broker, "java-mqtt-events-pub", new MemoryPersistence()); client.connect(opts).waitForCompletion(10_000); MqttMessage msg = new MqttMessage("hello from MQTT".getBytes(StandardCharsets.UTF_8)); msg.setQos(1); // Do NOT call msg.setRetained(true) — a retained publish is silently dropped. // MQTT 5.0 User Properties round-trip as KubeMQ Tags. MqttProperties props = new MqttProperties(); props.setUserProperties(List.of(new UserProperty("sensor", "thermometer"))); msg.setProperties(props); client.publish(topic, msg).waitForCompletion(10_000); System.out.printf("published to %s (channel demo.x)%n", topic); client.disconnect().waitForCompletion(5_000); client.close(); } } ``` ```typescript import mqtt, { type MqttClient } from "mqtt"; function brokerUrl(): string { return process.env["KUBEMQ_MQTT_URL"] ?? "tcp://localhost:1883"; } async function main(): Promise { // Prefix "events/" selects the Events pattern; "/" becomes "." so // "events/demo/x" maps to the KubeMQ channel "demo.x". const topic = "events/demo/x"; const client: MqttClient = mqtt.connect(brokerUrl(), { clientId: "js-mqtt-events-pub", protocolVersion: 5, clean: true, keepalive: 30, }); await new Promise((resolve, reject) => { client.once("connect", () => resolve()); client.once("error", reject); }); await new Promise((resolve, reject) => { client.publish( topic, "hello from MQTT", { qos: 1, retain: false, // retain is NOT supported; a retained publish is silently dropped. // MQTT 5.0 User Properties round-trip as KubeMQ Tags. properties: { userProperties: { sensor: "thermometer" } }, }, (err) => (err ? reject(err) : resolve()), ); }); console.log(`published to "${topic}" (channel demo.x)`); await client.endAsync(); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using System.Text; using MQTTnet; using MQTTnet.Client; using MQTTnet.Protocol; static (string host, int port) ParseEndpoint(string url) { foreach (var prefix in new[] { "tcp://", "tls://", "ws://" }) if (url.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) url = url[prefix.Length..]; var parts = url.TrimEnd('/').Split(':'); return (parts[0], parts.Length > 1 ? int.Parse(parts[1]) : 1883); } var url = Environment.GetEnvironmentVariable("KUBEMQ_MQTT_URL") ?? "tcp://localhost:1883"; var (host, port) = ParseEndpoint(url); // Prefix "events/" selects the Events pattern; "/" becomes "." so // "events/demo/x" maps to the KubeMQ channel "demo.x". const string topic = "events/demo/x"; var factory = new MqttFactory(); using var client = factory.CreateMqttClient(); var options = new MqttClientOptionsBuilder() .WithTcpServer(host, port) .WithClientId("csharp-mqtt-events-pub") .WithProtocolVersion(MQTTnet.Formatter.MqttProtocolVersion.V500) .WithCleanSession(true) .Build(); await client.ConnectAsync(options); var pubResult = await client.PublishAsync( new MqttApplicationMessageBuilder() .WithTopic(topic) .WithPayload(Encoding.UTF8.GetBytes("hello from MQTT")) .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce) // MQTT 5.0 User Property round-trips as a KubeMQ Tag. // Do NOT call WithRetainFlag(true) — a retained publish is silently dropped. .WithUserProperty("sensor", "thermometer") .Build()); if (pubResult.ReasonCode != MqttClientPublishReasonCode.Success) throw new Exception($"PUBACK reason: {pubResult.ReasonCode}"); Console.WriteLine($"published to '{topic}' (channel demo.x)"); await client.DisconnectAsync(); ``` ```ruby # The Ruby `mqtt` gem speaks MQTT 3.1.1 only (no User Properties, no v5 RPC). require "mqtt" require "uri" uri = URI.parse(ENV.fetch("KUBEMQ_MQTT_URL", "tcp://localhost:1883")) # Prefix "events/" selects the Events pattern; "/" becomes "." so # "events/demo/x" maps to the KubeMQ channel "demo.x". topic = "events/demo/x" MQTT::Client.connect( host: uri.host, port: uri.port, ssl: uri.scheme == "tls", client_id: "ruby-mqtt-events-pub", clean_session: true, keep_alive: 30 ) do |client| # retain=false is mandatory — the broker silently drops retained publishes. client.publish(topic, "hello from MQTT", false, 1) puts "published to '#{topic}' (channel demo.x)" end ``` ```rust use rumqttc::v5::mqttbytes::v5::PublishProperties; use rumqttc::v5::mqttbytes::QoS; use rumqttc::v5::{AsyncClient, MqttOptions}; use std::env; use std::time::Duration; fn parse_host_port(url: &str) -> (String, u16) { let stripped = url .trim_start_matches("tcp://") .trim_start_matches("tls://") .trim_start_matches("ws://"); let host_port = stripped.split('/').next().unwrap_or(stripped); let mut parts = host_port.splitn(2, ':'); let host = parts.next().unwrap_or("localhost").to_string(); let port: u16 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(1883); (host, port) } #[tokio::main] async fn main() -> Result<(), Box> { let url = env::var("KUBEMQ_MQTT_URL").unwrap_or_else(|_| "tcp://localhost:1883".to_string()); let (host, port) = parse_host_port(&url); // Prefix "events/" selects the Events pattern; '/' becomes '.' so // "events/demo/x" maps to the KubeMQ channel "demo.x". let topic = "events/demo/x"; let mut opts = MqttOptions::new("rust-mqtt-events-pub", host, port); opts.set_keep_alive(Duration::from_secs(30)); let (client, mut eventloop) = AsyncClient::new(opts, 10); // Drive the event loop so the PUBLISH is flushed and the PUBACK is processed. tokio::spawn(async move { while eventloop.poll().await.is_ok() {} }); // MQTT 5.0 User Properties round-trip as KubeMQ Tags. let props = PublishProperties { user_properties: vec![("sensor".to_string(), "thermometer".to_string())], ..Default::default() }; // retain = false — a retained publish is silently dropped. client .publish_with_properties(topic, QoS::AtLeastOnce, false, b"hello from MQTT".as_ref(), props) .await?; println!("published to '{topic}' (channel demo.x)"); Ok(()) } ``` ## Supported languages [#supported-languages] The connector speaks standard MQTT, so any conformant client works. The examples pin one native MQTT client per language — there is no KubeMQ SDK and no proto bindings. | Language | Client library | Protocol | Notes | | ----------------------- | ----------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------- | | Go | [`eclipse/paho.golang`](https://github.com/eclipse/paho.golang) (`paho.mqtt.golang` for v3.1.1) | MQTT 5.0 | The connector's reference client. | | Python | [`paho-mqtt`](https://pypi.org/project/paho-mqtt/) ≥ 2.1 | MQTT 5.0 | Callback-API v2. | | Java | [Eclipse Paho `mqttv5`](https://eclipse.dev/paho/) | MQTT 5.0 | `org.eclipse.paho.mqttv5.client`. | | JavaScript / TypeScript | [`mqtt.js`](https://github.com/mqttjs/MQTT.js) ≥ 5.15 | MQTT 5.0 | Works over TCP, TLS, and WebSocket. | | C# / .NET | [`MQTTnet`](https://github.com/dotnet/MQTTnet) ≥ 4.3.7 | MQTT 5.0 | Task-based async. | | Ruby | [`mqtt` gem](https://github.com/njh/ruby-mqtt) ≥ 0.6 | MQTT 3.1.1 | v3.1.1 subset only — no User Properties, no RPC, no WebSocket. | | Rust | [`rumqttc`](https://github.com/bytebeamio/rumqtt) ≥ 0.24 | MQTT 5.0 | async/await on Tokio. | The Ruby `mqtt` gem implements **MQTT 3.1.1 only**. The MQTT 5.0-only features — RPC (Commands / Queries), shared-subscription queue consume, and User-Properties — are not available from Ruby. Use a v5 client (Go, Python, Java, JavaScript, C#, Rust) for those patterns. See [Topic grammar](/connectors/mqtt/reference/topic-grammar). ## Next steps [#next-steps] # Auth & Security (/connectors/reference/auth-and-security) Every gateway on the [shared HTTP server](/connectors/concepts/shared-http-server) sits behind the same security middleware. CloudEvents is documented here under Connectors; the AI gateways — [A2A and MCP](/aiway) — are documented under Aiway, but they run on the same server and inherit this identical security model. The authentication, CORS, origin-validation, and TLS rules described here apply uniformly to `/a2a/*`, `/mcp`, and `/ce/*`; each gateway's own auth guide simply points back to this page. ## How requests are secured [#how-requests-are-secured] A request crosses three security stages before it reaches the broker: TLS terminates the transport, CORS and origin validation screen browser callers, and the auth middleware verifies the Bearer token and attaches identity claims. Public routes skip authentication entirely. *Transport security, browser screening, and JWT auth guard every connector request.* ## Authentication [#authentication] The auth middleware extracts a **`Bearer` token** from the `Authorization` header and verifies it against KubeMQ's authentication singleton. On success it attaches the caller's claims (including `ClientID`) to the request context, where the connector and the broker use them for authorization and identity propagation. When server authentication is **disabled**, the middleware injects synthetic anonymous claims (`ClientID: "anonymous"`) so requests still carry an identity. When it is **enabled**, an unverified or missing token is rejected. ```bash curl -X POST http://localhost:9090/mcp \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","id":"1","method":"tools/list"}' ``` ### How auth failures are reported [#how-auth-failures-are-reported] The error shape depends on the endpoint family. JSON-RPC connectors return a protocol error object; the REST-style CloudEvents endpoints return a standard HTTP status. | Endpoint | On auth failure | | --------------------------------- | ------------------------------------------ | | JSON-RPC (`/mcp`, `/a2a/*`) | JSON-RPC error with code **`-32010`** | | HTTP / REST (`/ce/*`, management) | **HTTP 401 Unauthorized** | ### Public routes [#public-routes] A small set of routes bypass authentication entirely so health probes and agent discovery work without credentials: | Route | Purpose | | ------------------------------- | --------------------------------------------- | | `/ping` | Liveness probe | | `/health` | Health check | | `/ready` | Readiness probe | | `*/.well-known/agent-card.json` | Agent card discovery (platform and per-agent) | The platform card `GET /.well-known/agent-card.json` and any per-agent card path ending in `/.well-known/agent-card.json` are public by design — A2A discovery must be reachable before a caller has a token. ## CORS [#cors] Browser-based callers are governed by the connectors' CORS configuration on `Connectors.Http.Cors`. The defaults are permissive on origin but explicit about the headers connectors need — in particular the MCP session/protocol headers and the CloudEvents replay header. | Setting | Default | | ------------------ | ------------------------------------------------------------------------------------------ | | `AllowOrigins` | `["*"]` | | `AllowMethods` | `GET, POST, DELETE, OPTIONS` | | `AllowHeaders` | `Authorization, Content-Type, MCP-Protocol-Version, MCP-Session-Id, Last-Event-ID, Accept` | | `ExposeHeaders` | `MCP-Session-Id, MCP-Protocol-Version` | | `AllowCredentials` | `false` | | `MaxAge` | `86400` (preflight cache, seconds) | A browser preflight is answered by the CORS middleware before the request reaches the connector: ```bash curl -i -X OPTIONS http://localhost:9090/mcp \ -H 'Origin: https://app.example.com' \ -H 'Access-Control-Request-Method: POST' \ -H 'Access-Control-Request-Headers: Authorization, MCP-Session-Id' ``` ## Origin validation [#origin-validation] In addition to CORS, browser requests are checked by **origin validation** against a list of trusted origins. This is independent of CORS and guards against cross-site request forgery from untrusted pages. | `TrustedOrigins` value | Behavior | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `auto` (default) | Matches localhost variants (`localhost`, `127.0.0.1`, and for MCP also `::1`, `[::1]`, `0.0.0.0`) plus the server's bind address | | `*` | Allows all origins | | *(custom list)* | Allows exactly the listed origins | An **empty `Origin` header** — which non-browser clients such as curl, SDKs, and server-to-server calls send — is **always allowed**. MCP applies its own origin check (`McpConfig.TrustedOrigins`) in addition to the shared HTTP origin middleware; A2A uses `A2aConfig.TrustedOrigins`. Both default to `["auto"]`. ## TLS and mTLS [#tls-and-mtls] Transport security is read from the server's `SecurityConfig` and applies to the shared HTTP listener that fronts every connector. Three modes are supported: | Mode | Behavior | | ------------------ | -------------------------------------------------------- | | `SecurityModeNone` | Plain TCP listener (no TLS) | | `SecurityModeTLS` | TLS with a server certificate | | `SecurityModeMTLS` | Mutual TLS — clients must present a verified certificate | TLS here secures the **inbound** connection from callers to the connectors. The A2A gateway's **outbound** calls to agent URLs are a separate concern, controlled by `AgentTLSSkipVerify` — see the [A2A configuration](/aiway/a2a/configuration). ## Reserved channel prefix [#reserved-channel-prefix] Channel-name validation rejects any user channel beginning with the reserved **`_AGENTS_.`** prefix. This prefix is owned by the agent platform's internal channels (registry, streaming, discovery); rejecting it prevents user operations from colliding with platform traffic. Attempting to send to or subscribe on such a channel through any connector is refused. ## Related [#related] # RabbitMQ (AMQP 0-9-1) (/connectors/rabbitmq) Point a RabbitMQ app at KubeMQ by changing only the connection string. The **RabbitMQ (AMQP 0-9-1) connector** is a built-in, wire-protocol bridge inside kubemq-server that speaks the RabbitMQ wire dialect natively — any standard AMQP 0-9-1 client (`amqp091-go`, `pika`, `amqp-client`, `amqplib`, `RabbitMQ.Client`, `bunny`, `lapin`) talks to KubeMQ's Queues with no KubeMQ SDK, no library swap, and no code rewrite. ## What is the RabbitMQ connector [#what-is-the-rabbitmq-connector] [AMQP 0-9-1](https://www.rabbitmq.com/tutorials/amqp-concepts) is the wire protocol that RabbitMQ popularized: a client opens a connection, multiplexes *channels* over it, declares *queues* and *exchanges*, and publishes messages routed by exchange type and routing key. The KubeMQ connector accepts every one of these operations from a stock client and bridges them onto KubeMQ — it is a *gateway*, not a client library, so your application only needs its existing AMQP 0-9-1 client. **Mental model — everything is a Queue.** Every AMQP queue maps to exactly one KubeMQ **Queue** channel named `amqp.{vhost}.{queue}`. Exchanges and bindings are **virtual**, connector-side routing metadata resolved at publish time — not data stores. AMQP only ever touches the KubeMQ Queue primitive, so the connector's "patterns" mirror AMQP routing concepts (work queues, pub/sub, routing, topics, RPC), not KubeMQ's five messaging patterns. Key capabilities: * **Drop-in connection-string migration** — keep your RabbitMQ client and code; change only the broker host in the URL. * **Everything is a Queue** — every AMQP queue is a durable KubeMQ Queue channel `amqp.{vhost}.{queue}`; exchanges (default, direct, fanout, topic, headers) route to those queues virtually at publish time. * **Native RPC** — request/reply uses RabbitMQ's `amq.rabbitmq.reply-to` (direct reply-to); there is no gRPC responder. * **Cross-protocol interop** — a message published over AMQP to `amqp.default.orders` is consumable by a gRPC or REST KubeMQ client on the same channel, and vice-versa. ## How it works [#how-it-works] An AMQP client publishes to an exchange with a routing key. The connector resolves the exchange routing (default / direct / fanout / topic / headers) to a set of target queues *at publish time*, then writes each message to that queue's KubeMQ Queue channel through the message broker. A consumer on the same queue — over AMQP or any other KubeMQ transport — receives it. *A publish to queue `orders` on vhost `/` resolves to the KubeMQ Queue channel `amqp.default.orders`; any consumer on that channel — AMQP or gRPC/REST — receives the message.* ## Ports & protocol surface [#ports--protocol-surface] | Port | Transport | Protocol | Notes | | ------ | ---------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------- | | `5672` | Plain TCP (SASL PLAIN) | AMQP 0-9-1 (RabbitMQ wire dialect) | Default plain listener. **Shared with the AMQP 1.0 connector** via the internal `amqpmux`. | | `5671` | TLS / AMQPS over TCP | AMQP 0-9-1 | Binds only when the server-global `Security` block is configured. Shared TLS listener with AMQP 1.0. | A single `amqpmux` listener accepts every connection on `5672`/`5671`, reads the 8-byte AMQP protocol header, and routes it to the matching dialect engine — so AMQP 0-9-1 and AMQP 1.0 coexist on the same ports. SASL is **PLAIN only**. The AMQP vhost `/` maps to the connector's configured `DefaultVhost` segment (literal `"default"`); see [Architecture](/connectors/rabbitmq/concepts/architecture) for the dispatch detail. ## Publish to a queue [#publish-to-a-queue] The example below declares a queue, publishes one `text/plain` message to it through the default exchange (routing key = queue name), and consumes it back. Queue `hello` on vhost `/` lands on the KubeMQ Queue channel `amqp.default.hello`. Every client reads the broker endpoint from `KUBEMQ_AMQP_URL` (default `amqp://guest:guest@localhost:5672/`). This is the same round trip as the [Getting started](/connectors/rabbitmq/tutorials/getting-started) tutorial, shown here inline for reference. For a step-by-step walkthrough — enabling the connector, running a local broker, and verifying each step — use Getting started instead. ```go package main import ( "context" "log" "os" "time" amqp "github.com/rabbitmq/amqp091-go" ) func amqpURL() string { if v := os.Getenv("KUBEMQ_AMQP_URL"); v != "" { return v } return "amqp://guest:guest@localhost:5672/" } func main() { conn, err := amqp.Dial(amqpURL()) if err != nil { log.Fatalf("dial: %v", err) } defer func() { _ = conn.Close() }() ch, err := conn.Channel() if err != nil { log.Fatalf("channel: %v", err) } defer func() { _ = ch.Close() }() // Declare "hello" → KubeMQ Queue channel amqp.default.hello. q, err := ch.QueueDeclare("hello", false, false, false, false, nil) if err != nil { log.Fatalf("declare queue: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() // Publish on the default exchange — routing key = queue name. if err := ch.PublishWithContext(ctx, "", q.Name, false, false, amqp.Publishing{ ContentType: "text/plain", Body: []byte("Hello World!"), }); err != nil { log.Fatalf("publish: %v", err) } log.Printf(" [x] Sent %q", "Hello World!") // Consume with auto-ack and print the exact body. msgs, err := ch.Consume(q.Name, "", true, false, false, false, nil) if err != nil { log.Fatalf("consume: %v", err) } select { case d := <-msgs: log.Printf(" [x] Received %q", string(d.Body)) case <-ctx.Done(): log.Fatalf("timed out: %v", ctx.Err()) } } ``` ```python import os import pika URL = os.environ.get("KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/") QUEUE = "hello" # → KubeMQ Queue channel amqp.default.hello def main() -> None: connection = pika.BlockingConnection(pika.URLParameters(URL)) channel = connection.channel() # Default (nameless) exchange: routing key == queue name. channel.queue_declare(queue=QUEUE, durable=False, exclusive=False, auto_delete=False) channel.basic_publish( exchange="", routing_key=QUEUE, body=b"Hello World!", properties=pika.BasicProperties(content_type="text/plain"), ) print(" [x] Sent 'Hello World!'") method, _props, body = channel.basic_get(queue=QUEUE, auto_ack=True) if method is None: raise SystemExit("no message received from queue") print(f" [x] Received {body.decode()!r}") channel.close() connection.close() if __name__ == "__main__": main() ``` ```java import com.rabbitmq.client.AMQP; import com.rabbitmq.client.Channel; import com.rabbitmq.client.Connection; import com.rabbitmq.client.ConnectionFactory; import com.rabbitmq.client.DeliverCallback; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; public final class Main { private static final String QUEUE = "hello"; // → amqp.default.hello public static void main(String[] args) throws Exception { ConnectionFactory factory = new ConnectionFactory(); factory.setUri(System.getenv().getOrDefault( "KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/")); try (Connection connection = factory.newConnection(); Channel channel = connection.createChannel()) { // queue.declare: non-durable, non-exclusive, no auto-delete. channel.queueDeclare(QUEUE, false, false, false, null); // Publish via the default exchange ("") with routing key = queue name. AMQP.BasicProperties props = new AMQP.BasicProperties.Builder() .contentType("text/plain") .build(); channel.basicPublish("", QUEUE, props, "Hello World!".getBytes("UTF-8")); System.out.println("[x] Sent 'Hello World!'"); BlockingQueue received = new ArrayBlockingQueue<>(1); DeliverCallback onDeliver = (tag, delivery) -> received.offer(new String(delivery.getBody(), "UTF-8")); channel.basicConsume(QUEUE, true, onDeliver, tag -> { }); String got = received.poll(15, TimeUnit.SECONDS); if (got == null) { throw new IllegalStateException("timed out waiting for the message"); } System.out.println("[x] Received '" + got + "'"); } } } ``` ```javascript import amqp from "amqplib"; // amqplib reads the URL path as the vhost; the bare trailing "/" in the dev URL // resolves to the default vhost (KubeMQ segment "default"). const URL = process.env.KUBEMQ_AMQP_URL ?? "amqp://guest:guest@localhost:5672/"; const QUEUE = "hello"; // → KubeMQ Queue channel amqp.default.hello async function main() { const conn = await amqp.connect(URL); const ch = await conn.createChannel(); await ch.assertQueue(QUEUE, { durable: false, autoDelete: false, exclusive: false }); const received = new Promise((resolve) => { ch.consume( QUEUE, (msg) => { if (msg === null) return; console.log(`[x] Received: ${msg.content.toString()}`); resolve(); }, { noAck: true }, ); }); // Default exchange ("") routes by queue name. ch.publish("", QUEUE, Buffer.from("Hello World!"), { contentType: "text/plain" }); console.log("[x] Sent: Hello World!"); await received; await ch.close(); await conn.close(); } main().catch((err) => { console.error("publish failed:", err); process.exit(1); }); ``` ```csharp using System.Text; using RabbitMQ.Client; using RabbitMQ.Client.Events; const string queueName = "hello"; // → KubeMQ Queue channel amqp.default.hello var factory = new ConnectionFactory { Uri = new Uri(Environment.GetEnvironmentVariable("KUBEMQ_AMQP_URL") ?? "amqp://guest:guest@localhost:5672/"), }; using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); var ct = cts.Token; await using var connection = await factory.CreateConnectionAsync(ct); await using var channel = await connection.CreateChannelAsync(cancellationToken: ct); // Non-durable, not exclusive, not auto-delete — a plain shared queue. await channel.QueueDeclareAsync(queueName, durable: false, exclusive: false, autoDelete: false, arguments: null, cancellationToken: ct); var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var consumer = new AsyncEventingBasicConsumer(channel); consumer.ReceivedAsync += (_, ea) => { received.TrySetResult(Encoding.UTF8.GetString(ea.Body.Span)); return Task.CompletedTask; }; await channel.BasicConsumeAsync(queueName, autoAck: true, consumer: consumer, cancellationToken: ct); // Publish to the default exchange with routing key = queue name. var props = new BasicProperties { ContentType = "text/plain" }; await channel.BasicPublishAsync(exchange: "", routingKey: queueName, mandatory: false, basicProperties: props, body: Encoding.UTF8.GetBytes("Hello World!"), cancellationToken: ct); Console.WriteLine("[x] Sent 'Hello World!'"); var message = await received.Task.WaitAsync(ct); Console.WriteLine($"[x] Received '{message}'"); ``` ```ruby # frozen_string_literal: true require "bunny" URL = ENV.fetch("KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/") QUEUE = "hello" # → KubeMQ Queue channel amqp.default.hello conn = Bunny.new(URL) conn.start ch = conn.create_channel # Publisher confirms: wait for the broker to accept and route the publish before # reading it back, so a fire-and-forget publish is never lost in flight. ch.confirm_select queue = ch.queue(QUEUE, durable: false, auto_delete: false, exclusive: false) # Default ("") exchange routes by queue name. ch.default_exchange.publish("Hello World!", routing_key: queue.name, content_type: "text/plain") ch.wait_for_confirms puts " [x] Sent 'Hello World!'" body = nil deadline = Time.now + 5 while body.nil? && Time.now < deadline _info, _props, body = queue.pop(manual_ack: false) sleep 0.2 if body.nil? end puts " [x] Received '#{body}'" conn.close ``` ```rust use futures_lite::StreamExt; use lapin::{ options::{BasicConsumeOptions, BasicPublishOptions, QueueDeclareOptions}, types::FieldTable, BasicProperties, Connection, ConnectionProperties, }; const QUEUE: &str = "hello"; // → KubeMQ Queue channel amqp.default.hello // lapin reads the URL path as the vhost and treats a bare trailing "/" as an // empty vhost, which the connector rejects. The default "/" vhost must be // percent-encoded as "%2f". fn amqp_url() -> String { let url = std::env::var("KUBEMQ_AMQP_URL") .unwrap_or_else(|_| "amqp://guest:guest@localhost:5672/".into()); let host = url.rsplit_once('@').map_or(url.as_str(), |(_, h)| h); if host.ends_with('/') && !host.ends_with("/%2f") { format!("{url}%2f") } else { url } } #[tokio::main] async fn main() -> Result<(), Box> { let conn = Connection::connect(&amqp_url(), ConnectionProperties::default()).await?; let channel = conn.create_channel().await?; channel .queue_declare(QUEUE, QueueDeclareOptions::default(), FieldTable::default()) .await?; // Publish to the default exchange, routing key = queue name. channel .basic_publish( "", QUEUE, BasicPublishOptions::default(), b"Hello World!", BasicProperties::default().with_content_type("text/plain".into()), ) .await? .await?; println!("[x] Sent 'Hello World!'"); let mut consumer = channel .basic_consume( QUEUE, "hello-consumer", BasicConsumeOptions { no_ack: true, ..Default::default() }, FieldTable::default(), ) .await?; if let Some(delivery) = consumer.next().await { let delivery = delivery?; println!("[x] Received '{}'", String::from_utf8_lossy(&delivery.data)); } conn.close(0, "done").await?; Ok(()) } ``` ## Supported languages [#supported-languages] The connector speaks standard AMQP 0-9-1, so any conformant RabbitMQ client works. The examples pin one native client per language — there is no KubeMQ SDK, no proto bindings, and no published package. | Language | Client library | Notes | | ----------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------- | | Go | [`github.com/rabbitmq/amqp091-go`](https://github.com/rabbitmq/amqp091-go) | The RabbitMQ team's Go client. | | Python | [`pika`](https://pika.readthedocs.io/) | `BlockingConnection` with `URLParameters`. | | Java | [`com.rabbitmq:amqp-client`](https://www.rabbitmq.com/client-libraries/java-api-guide) | The official RabbitMQ Java client. | | JavaScript / TypeScript | [`amqplib`](https://github.com/amqp-node/amqplib) | Encode the default `/` vhost as `%2f` in the URL. | | C# / .NET | [`RabbitMQ.Client`](https://www.rabbitmq.com/client-libraries/dotnet-api-guide) | Task-based async API (v7+). | | Ruby | [`bunny`](https://github.com/ruby-amqp/bunny) | Use publisher confirms before consuming. | | Rust | [`lapin`](https://github.com/amqp-rs/lapin) | async/await on Tokio; percent-encode the `/` vhost. | ## Next steps [#next-steps] # STOMP (/connectors/stomp) Point your existing STOMP application at KubeMQ by changing only the broker address. The **STOMP connector** is a built-in, wire-protocol bridge inside kubemq-server — an embedded STOMP server with its own dedicated TCP/TLS listeners and a hand-rolled frame codec. Any standard, unmodified STOMP client (go-stomp, stomp.py, Stomp.Net, Spring) talks to KubeMQ's Queues, Events, Events-Store, Commands, and Queries with no code change, no library swap, and no KubeMQ SDK. ## What is the STOMP connector [#what-is-the-stomp-connector] [STOMP](https://stomp.github.io/) (Simple Text Oriented Messaging Protocol) is a frame-based text protocol: a client opens a connection with a `CONNECT` frame, then `SEND`s to and `SUBSCRIBE`s on **destinations**. The KubeMQ STOMP connector negotiates STOMP **1.0, 1.1, and 1.2** (it picks the highest common version; the examples default to `1.2`) over raw TCP, and maps the STOMP wire protocol onto KubeMQ's five native messaging patterns by **destination prefix**. The first path segment of a destination selects the pattern; the remaining segments are joined with `.` into the KubeMQ channel — `/topic/orders/new` becomes Events channel `orders.new`. The connector is a *gateway*, not a client library: your application only needs a stock STOMP client. Key capabilities: * **All five patterns over one wire** — Queues, Events, Events-Store, Commands, and Queries, selected by the destination prefix. * **ActiveMQ-style primary names, MQTT-style aliases** — lead with `/queue/`, `/topic/`, `/topic-store/`, `/command/`, `/query/`; the aliases `/queues/`, `/events/`, `/store/`, `/commands/`, `/queries/` resolve to the same patterns, and egress always canonicalizes back to the primary name. * **RPC requester-only** — a STOMP client `SEND`s to `/command/` or `/query/` and receives the reply on a connection-local `/reply/` subscription; the responder runs on the KubeMQ (gRPC) side. * **Cross-protocol interop** — a message sent over STOMP to `/topic/orders/new` is consumable by a gRPC or REST KubeMQ client on channel `orders.new`, and vice-versa. ## How it works [#how-it-works] A STOMP client connects to the connector and `SEND`s to a destination. The connector resolves the destination to a KubeMQ `(pattern, channel)` pair, hands the message to the message broker, and consumers on the same channel — over STOMP or any other KubeMQ transport — receive it. *The connector parses the destination prefix into a KubeMQ pattern and joins the remaining segments (slash→dot) into the channel `orders.new`, then bridges onto the shared KubeMQ array.* ## Ports & protocol surface [#ports--protocol-surface] | Port | Transport | Protocol | Notes | | ------- | ------------ | --------------------- | ----------------------------------------------------------------------------------------------------------- | | `61613` | Plain TCP | STOMP 1.0 / 1.1 / 1.2 | Default plain listener; binds all interfaces. | | `61614` | TLS over TCP | STOMP 1.0 / 1.1 / 1.2 | Binds only when the server-wide `Security` block resolves to TLS. **Must differ from the plain port.** | There is **no STOMP-over-WebSocket listener** — the connector speaks raw TCP only, so a WebSocket-only client (such as `@stomp/stompjs`) cannot drive it. TLS has no STOMP-specific configuration: certificate material, mTLS, and the minimum TLS version come from the server-wide `Security` block. Connecting over TLS is purely a transport swap (`tls://host:61614`); the STOMP frames on top are identical. See [Architecture](/connectors/stomp/concepts/architecture) for the protocol stack. ## Send a message [#send-a-message] The example below produces one message to a Queue over a stock STOMP client. The `/queue/` prefix selects the **Queues** pattern (competing consumer, at-least-once); the remaining segments become the KubeMQ channel with `/` translated to `.` — `/queue/orders/new` lands on channel `orders.new`. Every client reads the broker endpoint from `KUBEMQ_STOMP_URL` (default `tcp://localhost:61613`); the scheme selects the transport. ```go package main import ( "fmt" "log" "os" "github.com/go-stomp/stomp/v3" ) func stompURL() string { if v := os.Getenv("KUBEMQ_STOMP_URL"); v != "" { return v } return "tcp://localhost:61613" } func main() { // The scheme in KUBEMQ_STOMP_URL selects the transport; strip it for net.Dial. addr := stompURL()[len("tcp://"):] const destination = "/queue/orders/new" // Queues pattern, channel "orders.new" // CONNECT: accept-version 1.2, default heart-beat, no-auth. conn, err := stomp.Dial("tcp", addr, stomp.ConnOpt.AcceptVersion(stomp.V12), stomp.ConnOpt.Login("my-app", "")) if err != nil { log.Fatalf("connect: %v", err) } defer conn.Disconnect() //nolint:errcheck // SEND one message; the receipt blocks until KubeMQ accepts the frame. if err := conn.Send(destination, "text/plain", []byte("hello from STOMP"), stomp.SendOpt.Receipt); err != nil { log.Fatalf("send: %v", err) } fmt.Printf("sent 1 message to %s (channel orders.new)\n", destination) } ``` ```python import os import stomp def stomp_endpoint() -> tuple[str, int]: url = os.environ.get("KUBEMQ_STOMP_URL", "tcp://localhost:61613") host_port = url.split("://", 1)[1] host, port = host_port.split(":", 1) return host, int(port) def main() -> None: destination = "/queue/orders/new" # Queues pattern, channel "orders.new" host, port = stomp_endpoint() # CONNECT: accept-version 1.2, default heart-beat, no-auth. conn = stomp.Connection([(host, port)], heartbeats=(10000, 10000)) conn.connect(login="my-app", passcode="", wait=True) try: conn.send(destination=destination, body="hello from STOMP", content_type="text/plain") print(f"sent 1 message to {destination} (channel orders.new)") finally: conn.disconnect() if __name__ == "__main__": main() ``` ```java import java.net.URI; import org.springframework.messaging.simp.stomp.StompHeaders; import org.springframework.messaging.simp.stomp.StompSession; import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; import org.springframework.messaging.tcp.reactor.ReactorNettyTcpClient; import org.springframework.web.socket.messaging.WebSocketStompClient; import org.springframework.messaging.simp.stomp.ReactorNettyTcpStompClient; public final class Main { public static void main(String[] args) throws Exception { String url = System.getenv().getOrDefault("KUBEMQ_STOMP_URL", "tcp://localhost:61613"); URI uri = URI.create(url); String destination = "/queue/orders/new"; // Queues pattern, channel "orders.new" // CONNECT over raw TCP; Spring negotiates STOMP 1.2 by default. ReactorNettyTcpStompClient client = new ReactorNettyTcpStompClient(uri.getHost(), uri.getPort()); StompSession session = client.connect(new StompSessionHandlerAdapter() {}).get(); StompHeaders headers = new StompHeaders(); headers.setDestination(destination); headers.add("content-type", "text/plain"); session.send(headers, "hello from STOMP".getBytes()); System.out.printf("sent 1 message to %s (channel orders.new)%n", destination); session.disconnect(); client.shutdown(); } } ``` ```typescript import { connect, type Client } from "stompit"; function endpoint(): { host: string; port: number } { const url = new URL(process.env["KUBEMQ_STOMP_URL"] ?? "tcp://localhost:61613"); return { host: url.hostname, port: Number(url.port) || 61613 }; } async function main(): Promise { const destination = "/queue/orders/new"; // Queues pattern, channel "orders.new" const { host, port } = endpoint(); // CONNECT over raw TCP — stompit, NOT @stomp/stompjs (which is WebSocket-only). const client: Client = await new Promise((resolve, reject) => { connect({ host, port, connectHeaders: { "accept-version": "1.2", "heart-beat": "10000,10000", login: "my-app" } }, (err, c) => (err ? reject(err) : resolve(c))); }); const frame = client.send({ destination, "content-type": "text/plain" }); frame.write("hello from STOMP"); frame.end(); console.log(`sent 1 message to ${destination} (channel orders.new)`); client.disconnect(); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using System.Text; using Stomp.Net; static string BrokerUri() { var url = Environment.GetEnvironmentVariable("KUBEMQ_STOMP_URL") ?? "tcp://localhost:61613"; var u = new Uri(url); // Stomp.Net dispatches on the outer scheme: tcp:// or ssl://. var transport = u.Scheme == "tls" ? "ssl" : "tcp"; return $"{transport}://{u.Host}:{(u.Port > 0 ? u.Port : 61613)}"; } const string destination = "/queue/orders/new"; // Queues pattern, channel "orders.new" // CONNECT: Stomp.Net performs the STOMP 1.2 handshake; no-auth default. var factory = new ConnectionFactory(BrokerUri(), new StompConnectionSettings()); using var connection = factory.CreateConnection(); connection.Start(); using var session = connection.CreateSession(AcknowledgementMode.AutoAcknowledge); using var producer = session.CreateProducer(session.GetQueue(destination)); var message = session.CreateBytesMessage(Encoding.UTF8.GetBytes("hello from STOMP")); message.StompType = "text/plain"; producer.Send(message); Console.WriteLine($"sent 1 message to {destination} (channel orders.new)"); ``` ```ruby require "stomp" require "uri" uri = URI.parse(ENV.fetch("KUBEMQ_STOMP_URL", "tcp://localhost:61613")) destination = "/queue/orders/new" # Queues pattern, channel "orders.new" # CONNECT: accept-version 1.2, default heart-beat, no-auth. client = Stomp::Client.new( hosts: [{ host: uri.host, port: uri.port }], connect_headers: { "accept-version" => "1.2", "heart-beat" => "10000,10000", "login" => "my-app", "passcode" => "" }, ) client.publish(destination, "hello from STOMP", { "content-type" => "text/plain" }) puts "sent 1 message to #{destination} (channel orders.new)" client.close ``` ```rust use async_stomp::client::Connector; use async_stomp::ToServer; use futures::SinkExt; #[tokio::main] async fn main() -> Result<(), Box> { let url = std::env::var("KUBEMQ_STOMP_URL").unwrap_or_else(|_| "tcp://localhost:61613".into()); let host_port = url.split("://").nth(1).unwrap_or("localhost:61613"); let destination = "/queue/orders/new"; // Queues pattern, channel "orders.new" // CONNECT over raw TCP; async-stomp negotiates STOMP 1.2. let mut conn = Connector::builder() .server(host_port) .login("my-app".to_string()) .passcode(String::new()) .connect() .await?; conn.send(ToServer::Send { destination: destination.to_string(), transaction: None, headers: Some(vec![("content-type".to_string(), "text/plain".to_string())]), body: Some(b"hello from STOMP".to_vec()), }) .await?; println!("sent 1 message to {destination} (channel orders.new)"); conn.send(ToServer::Disconnect { receipt: None }).await?; Ok(()) } ``` ## Supported languages [#supported-languages] The connector speaks standard STOMP over raw TCP, so any conformant native STOMP client works. The examples pin one client per language — there is no KubeMQ SDK, no proto bindings, and no published package. Only `go-stomp/v3` and `stomp.py` are proven by kubemq-server integration tests; the others are wire-compatible. | Language | Client library | Notes | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | | Go | [`github.com/go-stomp/stomp/v3`](https://github.com/go-stomp/stomp) | The connector's reference client. | | Python | [`stomp.py`](https://github.com/jasonrbriggs/stomp.py) | Listener-based; install via `uv`. | | Java | Spring [`ReactorNettyTcpStompClient`](https://docs.spring.io/spring-framework/reference/web/websocket/stomp.html) (spring-messaging) | Raw-TCP STOMP over Reactor Netty. | | JavaScript / TypeScript | [`stompit`](https://github.com/gdaws/stompit) | Raw TCP — **not** `@stomp/stompjs` (WebSocket-only). | | C# / .NET | [`Stomp.Net`](https://github.com/DaveSenn/Stomp.Net) | NMS-style API over STOMP 1.2. | | Ruby | [`stomp`](https://github.com/stompgem/stomp) gem | Native STOMP 1.0/1.1/1.2 client. | | Rust | [`async-stomp`](https://docs.rs/async-stomp) | async/await on Tokio. | **`@stomp/stompjs` is WebSocket-only and cannot drive the STOMP connector.** The connector listens on raw TCP (61613/61614) with no WebSocket upgrade, so the JavaScript/TypeScript examples use `stompit` (raw TCP). See [Connectivity and security](/connectors/stomp/how-to/connectivity-and-security). ## Next steps [#next-steps] # Channels & Routing (/learn/concepts/channels-and-routing) Think of a channel like the address on an envelope. You do not hand a letter directly to a person — you drop it in the mail with an address, and the postal system delivers it to whoever picks up mail at that address. Senders and receivers never need to know about each other; they only need to agree on the address. A **channel** is that address: a named destination that a broker uses to route messages. Publishers send to a channel name; subscribers express interest in a channel name. The broker connects the two. This indirection is what makes messaging *loosely coupled* — you can add, remove, or move services without either side changing the other's code. ## Channels — the idea [#channels--the-idea] A channel is just a string name, chosen by you. Publishers attach that name to each message; the broker matches it against the names subscribers asked for and delivers accordingly. Nothing is hard-wired — the channel is created the moment something uses it. Good channel names follow a **hierarchy**, read left to right from broad to specific, with a separator between each level. A common convention is `{domain}.{entity}.{action}`: ```text orders.created orders.updated orders.us-east.created payments.completed inventory.reserved ``` That hierarchy is not decoration. It is what makes the next idea — wildcards — possible. *Channels are named destinations; the broker matches a publisher's channel name to the subscribers that asked for it.* ## Wildcards — subscribing to a pattern [#wildcards--subscribing-to-a-pattern] Naming channels one by one works until you have dozens of them. A monitoring service that needs *every* order event would have to subscribe to `orders.created`, `orders.updated`, `orders.shipped`, and remember to add a new subscription every time someone invents `orders.cancelled`. A **wildcard subscription** solves this: instead of naming exact channels, the subscriber names a *pattern*, and the broker delivers every message whose channel matches. The hierarchy makes the pattern meaningful — each level can be matched broadly or exactly. Two wildcard tokens are common, and KubeMQ uses both: | Token | Matches | Example | | ----- | ------------------ | ------------------------------------------------------------------ | | `*` | exactly one level | `orders.*` matches `orders.created`, not `orders.us-east.created` | | `>` | one or more levels | `orders.>` matches `orders.created` *and* `orders.us-east.created` | A standalone `>` subscribes to everything — a firehose for audit or debugging. *One pattern subscription captures many channels: `orders.*` matches single-level order events, while `>` captures everything.* **Pitfall:** wildcards belong to *subscriptions*, not *publishes*. A publisher must always send to a concrete channel name — `orders.created`, never `orders.*`. The `*` and `>` characters are illegal in a publish channel. ## Routing — one publish, many channels [#routing--one-publish-many-channels] Wildcards let one subscriber listen to many channels. **Multicast routing** is the mirror image: it lets one *publish* reach many channels at once, without the publisher looping or opening multiple connections. The publisher encodes a list of targets into the channel name, and the broker fans the message out to each. This is how you tee a single business event to several consumers with different needs — a live feed *and* a durable copy *and* a work queue — from one call. *A single publish fans out to channels of different types at once — a real-time feed, a durable store, and a reliable work queue.* ## Precise definition [#precise-definition] * **Channel** — a named destination string. Created on first use, addressed by publishers and subscribers; the unit the broker routes on. * **Hierarchical naming** — a dot-separated convention (`domain.entity.action`) that gives channels structure so patterns can match levels. * **Wildcard subscription** — a subscription whose channel is a *pattern* using `*` (one level) or `>` (one or more levels). The broker delivers every message whose channel matches. * **Multicast routing** — a single publish addressed to multiple channels at once, encoded in the channel string; the broker delivers a copy to each target. ## Trade-offs [#trade-offs] | When it helps | When it bites | | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | A consumer needs a whole category of channels — one `orders.>` beats a dozen explicit subscriptions. | A too-broad pattern (`>`) hauls in traffic you do not need, wasting bandwidth and processing. | | You want to tee one event to several patterns (live + durable + queue) without publisher-side loops. | Routing to many targets multiplies broker work per publish; very wide fan-outs add latency. | | A naming hierarchy lets new channels appear without changing subscribers. | Inconsistent naming breaks wildcards — `order.created` and `orders.created` will not match the same pattern. | | Routing keeps publishers simple — they do not track who consumes what. | Routing hides destinations in a string; an inspectable, documented naming scheme is essential to avoid surprise delivery. | ## In KubeMQ [#in-kubemq] KubeMQ channels are exactly the named destinations above — a plain string you choose, created on first use. **Wildcard subscriptions** are supported for **Events** (Pub/Sub): subscribe to `orders.*` or `>` and the broker matches every event channel against your pattern. **Multicast routing** is encoded directly in the channel string: `;` separates targets and a `type:` prefix selects the pattern. | Routing string | Effect | | ---------------------------------------------- | ---------------------------------------------- | | `events:order-live;events_store:order-archive` | one publish → a live feed *and* a durable copy | | `events:notify;queues:order-fulfilment` | broadcast *and* a reliable work queue | The snippet below is the canonical wildcard subscribe from the Events tutorial — one subscription to `orders.*` captures every single-level order channel. ```go title="orders_monitor.go" sub, err := client.SubscribeToEvents(ctx, "orders.*", "", kubemq.WithOnEvent(func(event *kubemq.Event) { fmt.Printf("[Orders Monitor] channel=%s body=%s\n", event.Channel, string(event.Body)) }), kubemq.WithOnError(func(err error) { log.Println("Error:", err) }), ) ``` ```python title="orders_monitor.py" client.subscribe_to_events( subscription=EventsSubscription( channel="orders.*", on_receive_event_callback=lambda e: print( f"[Orders Monitor] channel={e.channel} body={e.body.decode()}" ), on_error_callback=lambda e: print(f"Error: {e}"), ), cancel=CancellationToken(), ) ``` ```javascript title="orders_monitor.js" client.subscribeToEvents({ channel: "orders.*", onEvent: (msg) => console.log( `[Orders Monitor] channel=${msg.channel} body=${Buffer.from(msg.body).toString()}` ), onError: (err) => console.error("Error:", err.message), }); ``` ```java title="OrdersMonitor.java" client.subscribeToEvents(EventsSubscription.builder() .channel("orders.*") .onReceiveEventCallback(event -> System.out.printf("[Orders Monitor] channel=%s body=%s%n", event.getChannel(), new String(event.getBody()))) .onErrorCallback(err -> System.err.println("Error: " + err.getMessage())) .build()); ``` ```csharp title="OrdersMonitor.cs" await foreach (var msg in client.SubscribeToEventsAsync( new EventsSubscription { Channel = "orders.*" })) { Console.WriteLine($"[Orders Monitor] channel={msg.Channel} " + $"body={Encoding.UTF8.GetString(msg.Body.Span)}"); } ``` ```kotlin title="OrdersMonitor.kt" client.subscribeToEvents( channel = "orders.*", onEvent = { event -> println("[Orders Monitor] channel=${event.channel} body=${String(event.body)}") }, onError = { err -> System.err.println("Error: ${err.message}") } ) ``` ```cpp title="orders_monitor.cpp" client.subscribeToEvents("orders.*", "", [](const kubemq::Event& event) { std::cout << "[Orders Monitor] channel=" << event.channel << " body=" << event.body << std::endl; }, [](const std::string& err) { std::cerr << "Error: " << err << std::endl; } ); ``` ```rust title="orders_monitor.rs" // A single wildcard subscription matches every channel under the pattern. let sub = client .subscribe_to_events( "orders.*", "", |event| { Box::pin(async move { println!( "[Orders Monitor] channel={}, body={}", event.channel, String::from_utf8_lossy(&event.body) ); }) }, None, ) .await?; ``` ```ruby title="orders_monitor.rb" cancel = KubeMQ::CancellationToken.new sub = KubeMQ::PubSub::EventsSubscription.new(channel: "orders.*") client.subscribe_to_events(sub, cancellation_token: cancel, on_error: ->(e) { puts "Error: #{e.message}" }) do |event| puts "[Orders Monitor] channel=#{event.channel} body=#{event.body}" end ``` ```elixir title="orders_monitor.exs" {:ok, sub} = KubeMQ.Client.subscribe_to_events(client, "orders.*", on_event: fn event -> IO.puts("[Orders Monitor] channel=#{event.channel} body=#{event.body}") end ) ``` **In KubeMQ:** wildcard subscriptions are an **Events** feature. Events Store, Queues, and RPC use exact channel names — they do not match `*` or `>` patterns. Multicast routing, by contrast, works across patterns: one routing string can tee an event into Events, Events Store, and Queues at the same time. ### How KubeMQ does this → [#how-kubemq-does-this-] # Delivery Guarantees (/learn/concepts/delivery-guarantees) Imagine mailing a contract. You could drop it in a mailbox and hope it arrives — fast, but if it gets lost you never know. You could send it certified mail, where the courier keeps trying until someone signs for it — nothing is lost, but a frazzled courier might leave two copies. Or you could number every envelope and have the recipient ignore duplicates — slower and more bookkeeping, but each contract lands exactly once. Those three choices are the three **delivery guarantees** every messaging system makes you pick between. The guarantee decides what happens when something fails — a subscriber is offline, a network blips, a consumer crashes mid-work. There is no "always perfect" option for free: stronger guarantees cost latency, storage, and complexity. This page explains the three guarantees, the acknowledgement mechanics that make the stronger ones possible, and the safety nets (idempotency and dead-letter queues) that keep them honest. ## Delivery guarantees — the idea [#delivery-guarantees--the-idea] A delivery guarantee is a promise about how many times a message is processed by a consumer in the face of failure. There are three: * **at-most-once** — deliver the message, never retry. A message is processed zero or one times. Fastest and cheapest; messages can be silently lost. * **at-least-once** — keep delivering until the consumer confirms success. A message is processed one or more times. Nothing is lost, but duplicates are possible. * **exactly-once** — the message is processed once and only once, even across failures. No loss, no duplicates. The strongest promise and the most expensive to provide. The pivot between them is the **acknowledgement** — a small signal the consumer sends back after handling a message. Whether the system waits for an ack, and what it does when an ack never arrives, is what separates the three guarantees. ### at-most-once: fire and forget [#at-most-once-fire-and-forget] The broker hands the message to whoever is listening right now and immediately forgets it. There is no ack, no retry, no stored copy. If a subscriber is offline or the message is dropped in transit, it is gone. *at-most-once: the broker delivers to active subscribers only; an offline subscriber misses the message and it is never retried.* ### at-least-once: acknowledge or redeliver [#at-least-once-acknowledge-or-redeliver] The broker keeps the message until the consumer **acks** it. If the consumer fails to ack — it crashes, times out, or sends a **nack** (negative acknowledgement) — the broker redelivers. Nothing is lost, but a consumer that did the work and then crashed *before* acking will see the same message again. *at-least-once: with no ack inside the window, the broker redelivers; the message is never lost but may arrive more than once.* ### exactly-once: acknowledge, then deduplicate [#exactly-once-acknowledge-then-deduplicate] You reach effectively-once by combining at-least-once delivery with **deduplication** on the consumer side. The consumer records which message IDs it has already handled; a redelivered duplicate is recognized and skipped before it has any effect. The work happens once even though the message may be delivered twice. *exactly-once (effectively-once): at-least-once delivery plus a dedup check on the message ID means the redelivered duplicate has no effect.* ## Precise definition [#precise-definition] A **delivery guarantee** is the contract a messaging system upholds for how many times a given message is successfully processed when failures occur. It is realized through three mechanics: * **Acknowledgement (ack)** — a signal from the consumer that a message was processed successfully and can be discarded by the broker. A **negative acknowledgement (nack)** signals failure and asks for redelivery. * **Redelivery window** — the time the broker waits for an ack before assuming failure and redelivering. (When a consumer holds a message during processing, this is the **visibility timeout**.) * **Idempotency** — a processing operation is *idempotent* if applying it twice has the same effect as applying it once. Idempotent consumers turn at-least-once delivery into effectively-once results, because duplicates do no extra harm. A **dead letter queue (DLQ)** is the final safety net: after a message fails and is redelivered up to a configured maximum number of times, the broker stops retrying and moves it to a separate channel for inspection. This prevents a single "poison" message from being redelivered forever and blocking the queue behind it. > True end-to-end **exactly-once** across independent systems is impossible in the general case (a consumer cannot atomically both ack the broker and commit a side effect). In practice, "exactly-once" means **at-least-once delivery + idempotent processing (or dedup)** — often called **effectively-once**. ## Trade-offs [#trade-offs] | Guarantee | Loss? | Duplicates? | Cost | Use when | | ----------------- | -------- | -------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------- | | **at-most-once** | Possible | Never | Lowest latency, no storage | Telemetry, live dashboards, cache invalidation — a missed message is harmless | | **at-least-once** | Never | Possible | Storage + ack round-trip | Orders, payments, jobs — losing a message is unacceptable; you can tolerate (or dedup) a repeat | | **exactly-once** | Never | Never (effect) | All of the above + dedup/idempotency | Financial postings, inventory decrements — a duplicate would corrupt state | **Pitfall:** "at-least-once" guarantees delivery, not single processing. If your consumer is **not idempotent** — for example it blindly increments a balance — a redelivered duplicate will double-charge. Make the handler idempotent (key side effects by message ID, or upsert instead of insert) before relying on at-least-once for anything stateful. ## In KubeMQ [#in-kubemq] **In KubeMQ:** the guarantee is a property of the **pattern you choose**, not a per-message flag. Events are fire-and-forget (at-most-once). Events Store persists every message so durable subscribers never lose one (at-least-once on the delivery path). Queues give each consumer an explicit **ack / nack** decision plus a **dead-letter queue** after `maxReceiveCount` retries — the basis for exactly-once-processing when your handler is idempotent. RPC is request/reply: the response *is* the acknowledgement. Each KubeMQ pattern occupies a different point on the guarantee spectrum: | Delivery guarantee | KubeMQ pattern | How it is provided | | --------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | **at-most-once** | [Events](/learn/events) | Fire-and-forget pub/sub — delivered to active subscribers only, no persistence, no retry | | **at-least-once** | [Events Store](/learn/events-store) | Every message is persisted; durable subscriptions track their position and replay anything missed | | **exactly-once-processing** | [Queues](/learn/queues) | Explicit ack/nack settlement + redelivery on nack/timeout + a dead-letter queue; pair with an idempotent handler for effectively-once | | **request/reply** | [RPC](/learn/rpc) | The reply confirms processing synchronously; a Command returns an ack, a Query returns data — no separate ack step | The clearest place to see ack/nack in code is the Queues consumer. After receiving a message, you decide its fate: **ack** removes it, **nack** returns it for redelivery. If it is nacked past `maxReceiveCount`, KubeMQ routes it to the configured dead-letter queue. ```go title="settle.go" resp, err := client.PollQueue(ctx, &kubemq.PollRequest{ Channel: "orders", MaxItems: 1, WaitTimeoutSeconds: 5, }) if err != nil { log.Fatal(err) } for _, m := range resp.Messages { if err := process(m.Message.Body); err != nil { m.NAck() // failed — return to queue for redelivery continue } m.Ack() // success — remove from queue } ``` ```python title="settle.py" response = client.receive_queue_messages( channel="orders", max_messages=1, wait_timeout_in_seconds=5, ) for msg in response.messages: try: process(msg.body) msg.ack() # success — remove from queue except Exception: msg.nack() # failed — return to queue for redelivery ``` ```typescript title="settle.ts" const messages = await client.receiveQueueMessages({ channel: 'orders', maxMessages: 1, waitTimeoutSeconds: 5, }); for (const msg of messages) { try { await process(msg.body); await msg.ack(); // success — remove from queue } catch { await msg.nack(); // failed — return to queue for redelivery } } ``` ```java title="Settle.java" ReceiveQueueMessagesResponse response = client.receiveQueueMessages( ReceiveQueueMessagesRequest.builder() .channel("orders") .maxMessages(1) .waitTimeoutSeconds(5) .build()); for (QueueMessageReceived msg : response.getMessages()) { try { process(msg.getBody()); msg.ack(); // success — remove from queue } catch (Exception e) { msg.nack(); // failed — return to queue for redelivery } } ``` ```csharp title="Settle.cs" var response = await client.ReceiveQueueMessagesAsync(new ReceiveQueueMessagesRequest { Channel = "orders", MaxMessages = 1, WaitTimeoutSeconds = 5, }); foreach (var msg in response.Messages) { try { Process(msg.Body); await msg.AckAsync(); // success — remove from queue } catch { await msg.NAckAsync(); // failed — return to queue for redelivery } } ``` ```kotlin title="Settle.kt" val response = client.receiveQueueMessages( channel = "orders", maxMessages = 1, waitTimeoutSeconds = 5 ) for (msg in response.messages) { try { process(msg.body) msg.ack() // success — remove from queue } catch (e: Exception) { msg.nack() // failed — return to queue for redelivery } } ``` ```cpp title="settle.cpp" auto response = client.receiveQueueMessages("orders", 1, 5); for (const auto& msg : response.messages) { try { process(msg.body); msg.ack(); // success — remove from queue } catch (const std::exception&) { msg.nack(); // failed — return to queue for redelivery } } ``` ```rust title="settle.rs" let mut receiver = client.new_queue_downstream_receiver().await?; let response = receiver .poll(PollRequest { channel: "orders".to_string(), max_items: 1, wait_timeout_seconds: 5, auto_ack: false, }) .await?; for msg in &response.messages { match process(&msg.message.body) { Ok(_) => msg.ack().await?, // success — remove from queue Err(_) => msg.nack().await?, // failed — return to queue for redelivery } } ``` ```ruby title="settle.rb" receiver = client.create_downstream_receiver request = KubeMQ::Queues::QueuePollRequest.new( channel: 'orders', max_items: 1, wait_timeout: 5 ) response = receiver.poll(request) response.messages.each do |m| begin process(m.body) m.ack # success — remove from queue rescue StandardError m.nack # failed — return to queue for redelivery end end ``` ```elixir title="settle.exs" {:ok, poll} = KubeMQ.Client.poll_queue(client, channel: "orders", max_items: 1, wait_timeout: 5_000 ) # Settle the whole transaction: ack on success, nack to redeliver case process(poll.messages) do :ok -> KubeMQ.PollResponse.ack_all(poll) _ -> KubeMQ.PollResponse.nack_all(poll) end ``` *The same receive-then-settle loop in every SDK: ack a processed message, nack a failed one. After `maxReceiveCount` nacks, KubeMQ moves the message to the dead-letter queue.* ### How KubeMQ does this → [#how-kubemq-does-this-] # Messaging Fundamentals (/learn/concepts) Two services need to work together: an Orders service takes a customer's order, and a dozen other things have to happen because of it — charge a card, reserve stock, email a receipt, update a dashboard. How do those services *talk*? You can have the Orders service call each of the others directly, or you can put something in the middle that carries the messages for it. That "something in the middle" is what this whole track is about. Think of it like a busy office. People could walk to each other's desks every time they need something — fast when the other person is there, useless when they're out. Or they could drop notes in a central mailroom that sorts and delivers them. The mailroom never forgets to deliver, doesn't care who's at their desk right now, and lets one announcement reach a hundred people at once. **Messaging** is software's mailroom. ## Synchronous vs asynchronous — the idea [#synchronous-vs-asynchronous--the-idea] The first choice in any conversation between two services is *whether the sender waits*. **Synchronous** communication is a phone call. You dial, the other person picks up, you talk, you get an answer, and only then do you hang up and move on. The caller is blocked the whole time — if the other side is slow or down, the caller is stuck. It is simple and immediate, and you get an answer right away. **Asynchronous** communication is leaving a voicemail or sending a letter. You say your piece and move on with your day; the recipient picks it up when they can and acts on it later. The sender is not blocked, the recipient does not have to be available at the same moment, and the work happens in the background. *Top: a synchronous call — the caller waits for a reply. Bottom: an asynchronous message — the sender hands off and continues; delivery happens when the receiver is ready.* Neither is "better." A phone call is right when you genuinely need the answer *now* to continue (checking whether a credit card is valid). A voicemail is right when you just need the other side to *eventually* know something (a receipt should be emailed). Most real systems use both. ## Tight vs loose coupling — the idea [#tight-vs-loose-coupling--the-idea] When the Orders service calls the Payments service directly, it has to know Payments exists, where it lives, that it is up, and how to talk to it. If Payments moves, scales, slows down, or fails, Orders feels it immediately. That is **tight coupling**: the two are wired straight to each other, and a change or failure in one ripples into the other. Now add a fifth, sixth, and seventh thing that must happen on every order — fulfillment, analytics, fraud checks, loyalty points. With direct calls, the Orders service grows a hard-wired dependency on each one, and every new consumer means editing and redeploying Orders. Put a broker in the middle and the picture changes. Orders publishes "an order was placed" to a channel and stops caring who listens. Payments, fulfillment, and analytics each subscribe on their own terms. Orders does not know they exist; they do not know Orders exists. That is **loose coupling**: services depend on a shared *channel*, not on each other. New consumers slot in without touching the producer, and one service being down no longer takes the sender down with it. *Left: direct calls — the producer is wired to every consumer and must change when the set of consumers changes. Right: via a broker — the producer publishes to one channel; consumers come and go independently.* **Pitfall:** loose coupling is not free. Asynchronous, broker-mediated messaging adds a hop, makes end-to-end flows harder to trace, and means "done" no longer means "everyone who cares has finished." You trade immediate, all-or-nothing simplicity for independence and resilience. Reach for it when services must scale, fail, and evolve separately — not for a single call that needs an answer right now. ## What a message broker is [#what-a-message-broker-is] A **message broker** is the piece of infrastructure in the middle. Its job is narrow and important: accept messages from producers, hold them in named **channels**, and deliver them to the right consumers — then get out of the way. A broker does the work that every messaging system would otherwise reinvent: * **Decouples** producers from consumers in space (they need not know each other's location), in time (they need not run at the same moment), and in number (one producer, many consumers — or the reverse). * **Buffers** bursts so a fast producer does not overwhelm a slow consumer. * **Routes** each message to the consumers that asked for it, by channel name and pattern. * **Applies delivery rules** — try once, try until acknowledged, preserve order, allow replay — depending on the channel type. *A simple topology: producers send to channels through one broker, which delivers to the consumers that subscribed — over whatever transport each client speaks.* ## Why distinct messaging patterns exist [#why-distinct-messaging-patterns-exist] If a broker just "delivers messages," why does this track have four different patterns? Because **one size does not fit all**. Different jobs need different delivery contracts, and trying to serve them all with one mechanism makes every job worse. Consider what changes from job to job: | Question | Notifications | Order processing | Audit log | "Is the card valid?" | | ------------------------------------------------------------ | ------------- | ---------------- | ------------------ | -------------------- | | Does the sender need a reply? | No | No | No | **Yes, now** | | Must every message survive a crash? | No | **Yes** | **Yes** | No | | Should each message go to *one* worker or *all* subscribers? | All | **One** | Replayable by many | One responder | | Does order matter? | No | Often | **Yes** | N/A | | Can old messages be replayed later? | No | No | **Yes** | No | No single delivery rule answers all of these well. A pattern that guarantees nothing is lost and lets you replay history is overkill (and slower) for a fleeting "user is typing" notification. A fire-and-forget broadcast is dangerous for a payment that must not be processed twice. So messaging gives you a small set of **patterns**, each a deliberate trade-off between speed, durability, ordering, and shape of delivery. Picking the right one is most of the skill — and the rest of this track teaches you how. ## In KubeMQ [#in-kubemq] **In KubeMQ:** KubeMQ *is* the broker — a single engine that hosts every channel type. Your services connect once, then publish to and subscribe from named channels using one client SDK. The same connection speaks Events, Events Store, Queues, and RPC; the channel type you choose decides the delivery contract. The channels in the topology above are just KubeMQ channels of different types behind one address (`localhost:50000`). Connecting and publishing a single message is the smallest possible "hello, broker." Here the Orders service sends one order event to a channel — it does not know or care who is listening. ```go title="publish.go" package main import ( "context" "log" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() err = client.SendEvent(ctx, kubemq.NewEvent(). SetChannel("order-placed"). SetBody([]byte(`{"orderId":"ORD-1234","status":"placed"}`)), ) if err != nil { log.Fatal(err) } log.Println("Message sent — Orders does not wait for any consumer") } ``` ```python title="publish.py" from kubemq.pubsub import Client as PubSubClient from kubemq.pubsub import EventMessage client = PubSubClient(address="localhost:50000") client.send_event( EventMessage( channel="order-placed", body=b'{"orderId":"ORD-1234","status":"placed"}', ) ) print("Message sent — Orders does not wait for any consumer") client.close() ``` ```javascript title="publish.js" const { KubeMQClient } = require("kubemq-js"); const client = new KubeMQClient({ address: "localhost:50000" }); await client.sendEvent({ channel: "order-placed", body: Buffer.from(JSON.stringify({ orderId: "ORD-1234", status: "placed" })), }); console.log("Message sent — Orders does not wait for any consumer"); ``` ```java title="Publish.java" PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId("orders-service") .build(); client.sendEventsMessage(EventMessage.builder() .channel("order-placed") .body("{\"orderId\":\"ORD-1234\",\"status\":\"placed\"}".getBytes()) .build()); System.out.println("Message sent — Orders does not wait for any consumer"); client.close(); ``` ```csharp title="Publish.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); await client.SendEventAsync(new EventMessage { Channel = "order-placed", Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-1234\",\"status\":\"placed\"}") }); Console.WriteLine("Message sent — Orders does not wait for any consumer"); ``` ```kotlin title="Publish.kt" val client = PubSubClient("localhost:50000") client.sendEvent(EventMessage( channel = "order-placed", body = """{"orderId":"ORD-1234","status":"placed"}""".toByteArray() )) println("Message sent — Orders does not wait for any consumer") client.close() ``` ```cpp title="publish.cpp" #include #include auto client = kubemq::PubSubClient("localhost:50000"); kubemq::EventMessage event; event.channel = "order-placed"; event.body = R"({"orderId":"ORD-1234","status":"placed"})"; client.sendEvent(event); std::cout << "Message sent — Orders does not wait for any consumer" << std::endl; ``` ```rust title="publish.rs" use kubemq::prelude::*; use kubemq::EventBuilder; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; let event = EventBuilder::new() .channel("order-placed") .body(br#"{"orderId":"ORD-1234","status":"placed"}"#.to_vec()) .build(); client.send_event(event).await?; println!("Message sent — Orders does not wait for any consumer"); client.close().await?; Ok(()) } ``` ```ruby title="publish.rb" require 'kubemq' client = KubeMQ::PubSubClient.new(address: "localhost:50000", client_id: "orders-service") msg = KubeMQ::PubSub::EventMessage.new( channel: "order-placed", body: '{"orderId":"ORD-1234","status":"placed"}' ) client.send_event(msg) puts "Message sent — Orders does not wait for any consumer" client.close ``` ```elixir title="publish.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "orders-service") event = KubeMQ.Event.new(channel: "order-placed", body: ~s({"orderId":"ORD-1234","status":"placed"})) case KubeMQ.Client.send_event(client, event) do :ok -> IO.puts("Message sent — Orders does not wait for any consumer") {:error, err} -> IO.puts("Send failed: #{err.message}") end KubeMQ.Client.close(client) ``` That same `client` connection can also persist events, queue work for a single worker, or make a request and wait for a reply — each is one of the four patterns below. ### How KubeMQ does this → [#how-kubemq-does-this-] ## Where to go next [#where-to-go-next] You now have the vocabulary: synchronous vs asynchronous, tight vs loose coupling, what a broker does, and why patterns differ. Next, learn the small set of shapes every messaging system reduces to — then how delivery, ordering, scaling, and routing actually work. # Interaction Styles (/learn/concepts/interaction-styles) Pick apart any messaging system and you find the same three conversation shapes underneath. A pattern's name and its bells and whistles vary, but the way a message travels from sender to receiver always reduces to one of three styles: **one-to-many** (pub/sub), **one-to-one-of-many** (point-to-point), or **there-and-back** (request/reply). Think of how people communicate. A speaker at a conference addresses the whole room at once — everyone listening hears it (pub/sub). A help desk has a single ticket line feeding several agents — your ticket goes to whichever agent is free, and to exactly one of them (point-to-point). A phone call is a back-and-forth — you ask, you wait, you get an answer (request/reply). Learn these three shapes and every messaging pattern becomes a variation on a theme you already understand. ## Pub/Sub — one sender, many receivers [#pubsub--one-sender-many-receivers] In **pub/sub** (publish/subscribe), a sender publishes a message to a named destination and **every** active receiver subscribed to that destination gets its own copy. The sender does not know or care who is listening — it could be zero receivers or a thousand. This shape is called **fan-out**: one message in, many copies out. The sender and receivers are decoupled. You can add a new subscriber tomorrow without touching the publisher, and a slow or absent subscriber does not hold anyone else up. *One published message is copied to every active subscriber — fan-out, one-to-many.* **Reach for pub/sub when** every interested party needs the same message: broadcasting state changes, notifying multiple services of an event, feeding live dashboards, or invalidating caches across a fleet. ## Point-to-Point — one sender, one-of-many receivers [#point-to-point--one-sender-one-of-many-receivers] In **point-to-point**, a sender puts a message on a shared queue and **exactly one** receiver consumes it. When several receivers read from the same queue, they form a pool of **competing consumers** — the queue hands each message to whichever consumer is free, spreading the work across all of them. One message in, delivered once, to one worker. This is how you scale a workload horizontally. Add more workers and throughput goes up; each message is still processed exactly once, and no two workers do the same job. *Each queued message goes to exactly one of the competing workers — load-balanced, one-to-one-of-many. The dotted line is the acknowledgment that removes the message.* **Reach for point-to-point when** each message represents a unit of work that should be done once: order processing, background jobs, task distribution, or anything where you want to add workers to handle more load. **Pub/sub vs point-to-point** is the most consequential choice you make. Pub/sub *copies* a message to everyone; point-to-point *hands* a message to one worker. Same starting point, opposite outcomes. ## Request/Reply — there and back [#requestreply--there-and-back] In **request/reply**, a sender issues a request and **waits** for a response from a receiver before continuing. It is the synchronous shape: the round-trip is part of the flow, and the sender blocks (up to a timeout) until the answer arrives or it gives up. One request out, one matching response back. Unlike the other two styles, the sender expects a reply and is coupled to it in time — if the receiver is down or slow, the sender waits. That tight coupling is the point: you want the result *now*, before moving on. *The sender blocks until the response returns through the broker — synchronous, there-and-back. A request that returns data is a query; one that only confirms an action is a command.* **Reach for request/reply when** the sender needs an answer to proceed: looking up data, calling a service-to-service API, confirming a write succeeded, or any classic remote-procedure call. ## The three styles at a glance [#the-three-styles-at-a-glance] | | Pub/Sub | Point-to-Point | Request/Reply | | ------------------------- | -------------------------------- | ------------------------------ | -------------------------------- | | **Direction** | one → many | one → one-of-many | one ↔ one | | **Receivers per message** | every subscriber | exactly one consumer | one responder | | **Coupling** | loose (sender ignores receivers) | loose (sender ignores workers) | tight (sender waits for reply) | | **Timing** | asynchronous | asynchronous | synchronous | | **Adds receivers to…** | reach more listeners (fan-out) | share more work (scale) | distribute load behind one reply | | **Typical use** | broadcasts, notifications | jobs, task queues | lookups, RPC, confirmations | **Pitfall:** don't force a synchronous request/reply where a one-way style fits. Blocking a sender on a slow downstream service is a common cause of cascading timeouts — if you only need to *tell* someone something, publish an event or enqueue a job and move on. ## In KubeMQ [#in-kubemq] KubeMQ implements all three interaction styles natively, so you choose the conversation shape rather than wiring it together yourself: | Interaction style | KubeMQ pattern | Why | | ------------------------------------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Pub/Sub (fan-out) | **Events** and **Events Store** | A publish is copied to every active subscriber on the channel. Events is at-most-once and in-memory; Events Store persists messages so subscribers can also replay. | | Point-to-Point (competing consumers) | **Queues** | Each queued message is delivered to one consumer and removed on acknowledgment; multiple consumers on a channel compete for messages and share the load. | | Request/Reply (round-trip) | **RPC** (Commands & Queries) | The sender blocks until a responder answers or the timeout expires. A Query returns a payload; a Command returns an execution acknowledgment. | The snippets below show the *send* side of each style against `localhost:50000`, using the same e-commerce orders domain. They are deliberately minimal — see the pattern pages for full subscribe/receive/respond flows. ### Pub/Sub — publish an event [#pubsub--publish-an-event] ```go title="publish.go" err = client.SendEvent(ctx, kubemq.NewEvent(). SetChannel("order-events"). SetMetadata("order.created"). SetBody([]byte(`{"orderId":"ORD-1234","status":"created"}`)), ) ``` ```python title="publish.py" client.send_event( EventMessage( channel="order-events", metadata="order.created", body=b'{"orderId":"ORD-1234","status":"created"}', ) ) ``` ```javascript title="publish.js" await client.sendEvent({ channel: "order-events", metadata: "order.created", body: Buffer.from(JSON.stringify({ orderId: "ORD-1234", status: "created" })), }); ``` ```java title="Publish.java" client.sendEventsMessage(EventMessage.builder() .channel("order-events") .metadata("order.created") .body("{\"orderId\":\"ORD-1234\",\"status\":\"created\"}".getBytes()) .build()); ``` ```csharp title="Publish.cs" await client.SendEventAsync(new EventMessage { Channel = "order-events", Metadata = "order.created", Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-1234\",\"status\":\"created\"}") }); ``` ```kotlin title="Publish.kt" client.sendEvent(EventMessage( channel = "order-events", metadata = "order.created", body = """{"orderId":"ORD-1234","status":"created"}""".toByteArray() )) ``` ```cpp title="publish.cpp" kubemq::EventMessage event; event.channel = "order-events"; event.metadata = "order.created"; event.body = R"({"orderId":"ORD-1234","status":"created"})"; client.sendEvent(event); ``` ```rust title="publish.rs" let event = EventBuilder::new() .channel("order-events") .metadata("order.created") .body(br#"{"orderId":"ORD-1234","status":"created"}"#.to_vec()) .build(); client.send_event(event).await?; ``` ```ruby title="publish.rb" msg = KubeMQ::PubSub::EventMessage.new( channel: "order-events", metadata: "order.created", body: '{"orderId":"ORD-1234","status":"created"}' ) client.send_event(msg) ``` ```elixir title="publish.exs" event = KubeMQ.Event.new( channel: "order-events", metadata: "order.created", body: ~s({"orderId":"ORD-1234","status":"created"}) ) KubeMQ.Client.send_event(client, event) ``` ### Point-to-Point — enqueue a job [#point-to-point--enqueue-a-job] ```go title="enqueue.go" msg := kubemq.NewQueueMessage(). SetChannel("order-jobs"). SetBody([]byte(`{"orderId":"ORD-1234","total":99.99}`)) result, err := client.SendQueueMessage(ctx, msg) ``` ```python title="enqueue.py" result = client.send_queue_message( QueueMessage( channel="order-jobs", body=b'{"orderId":"ORD-1234","total":99.99}', ) ) ``` ```typescript title="enqueue.ts" const result = await client.sendQueueMessage( createQueueMessage({ channel: "order-jobs", body: JSON.stringify({ orderId: "ORD-1234", total: 99.99 }), }), ); ``` ```java title="Enqueue.java" SendQueueMessageResult result = client.sendQueueMessage( QueueMessage.builder() .channel("order-jobs") .body("{\"orderId\":\"ORD-1234\",\"total\":99.99}".getBytes()) .build()); ``` ```csharp title="Enqueue.cs" var result = await client.SendQueueMessageAsync(new QueueMessage { Channel = "order-jobs", Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-1234\",\"total\":99.99}") }); ``` ```kotlin title="Enqueue.kt" val result = client.sendQueueMessage(QueueMessage( channel = "order-jobs", body = """{"orderId":"ORD-1234","total":99.99}""".toByteArray() )) ``` ```cpp title="enqueue.cpp" kubemq::QueueMessage msg; msg.channel = "order-jobs"; msg.body = R"({"orderId":"ORD-1234","total":99.99})"; auto result = client.sendQueueMessage(msg); ``` ```rust title="enqueue.rs" let msg = QueueMessageBuilder::new() .channel("order-jobs") .body(br#"{"orderId":"ORD-1234","total":99.99}"#.to_vec()) .build(); let result = client.send_queue_message(msg).await?; ``` ```ruby title="enqueue.rb" msg = KubeMQ::Queues::QueueMessage.new( channel: "order-jobs", body: '{"orderId":"ORD-1234","total":99.99}' ) result = client.send_queue_message(msg) ``` ```elixir title="enqueue.exs" msg = KubeMQ.QueueMessage.new( channel: "order-jobs", body: ~s({"orderId":"ORD-1234","total":99.99}) ) {:ok, result} = KubeMQ.Client.send_queue_message(client, msg) ``` ### Request/Reply — send a command and wait [#requestreply--send-a-command-and-wait] ```go title="send_command.go" resp, err := client.SendCommand(ctx, kubemq.NewCommand(). SetChannel("orders.process"). SetBody([]byte(`{"action":"create","orderId":"ORD-1234"}`)). SetTimeout(10 * time.Second)) log.Printf("executed: %v", resp.Executed) ``` ```python title="send_command.py" response = client.send_command( CommandMessage( channel="orders.process", body=b'{"action":"create","orderId":"ORD-1234"}', timeout_in_seconds=10, ) ) print(f"executed: {response.is_executed}") ``` ```javascript title="send_command.js" const response = await client.sendCommand({ channel: "orders.process", body: Buffer.from(JSON.stringify({ action: "create", orderId: "ORD-1234" })), timeoutInSeconds: 10, }); console.log("executed:", response.isExecuted); ``` ```java title="SendCommand.java" CommandResponseMessage response = client.sendCommandRequest( CommandMessage.builder() .channel("orders.process") .body("{\"action\":\"create\",\"orderId\":\"ORD-1234\"}".getBytes()) .timeout(10000) .build()); System.out.println("executed: " + response.isExecuted()); ``` ```csharp title="SendCommand.cs" var response = await client.SendCommandAsync(new CommandMessage { Channel = "orders.process", Body = Encoding.UTF8.GetBytes("{\"action\":\"create\",\"orderId\":\"ORD-1234\"}"), Timeout = TimeSpan.FromSeconds(10) }); Console.WriteLine($"executed: {response.IsExecuted}"); ``` ```kotlin title="SendCommand.kt" val response = client.sendCommand(CommandMessage( channel = "orders.process", body = """{"action":"create","orderId":"ORD-1234"}""".toByteArray(), timeout = 10000 )) println("executed: ${response.isExecuted}") ``` ```cpp title="send_command.cpp" kubemq::CommandMessage cmd; cmd.channel = "orders.process"; cmd.body = R"({"action":"create","orderId":"ORD-1234"})"; cmd.timeout = 10000; auto response = client.sendCommand(cmd); std::cout << "executed: " << response.isExecuted << std::endl; ``` ```rust title="send_command.rs" let command = CommandBuilder::new() .channel("orders.process") .body(br#"{"action":"create","orderId":"ORD-1234"}"#.to_vec()) .timeout(Duration::from_secs(10)) .build(); let response = client.send_command(command).await?; println!("executed: {}", response.executed); ``` ```ruby title="send_command.rb" msg = KubeMQ::CQ::CommandMessage.new( channel: "orders.process", timeout: 10, body: '{"action":"create","orderId":"ORD-1234"}' ) result = client.send_command(msg) puts "executed: #{result.executed}" ``` ```elixir title="send_command.exs" command = KubeMQ.Command.new( channel: "orders.process", body: ~s({"action":"create","orderId":"ORD-1234"}), timeout: 10_000 ) {:ok, response} = KubeMQ.Client.send_command(client, command) IO.puts("executed: #{response.executed}") ``` ### How KubeMQ does this → [#how-kubemq-does-this-] # Ordering & Replay (/learn/concepts/ordering-and-replay) Imagine a deli counter that hands out numbered tickets. Everyone is served in the order they arrived — ticket 41 before 42 before 43 — and the numbers never repeat or skip. Now imagine the deli also kept a logbook of every ticket it ever served. A clerk arriving for the late shift could open the book, find where the morning clerk left off, and pick up from exactly that ticket — no customer served twice, none missed. Those two ideas — **serving in a fixed order** and **rewinding the log to any point** — are *ordering* and *replay*. They are what separate a fleeting stream of notifications from a durable, rebuildable record of what happened. ## Ordering — the idea [#ordering--the-idea] **Ordering** is the guarantee about *what sequence consumers observe messages in*. The strongest common form is **FIFO** (first-in, first-out): messages come out in exactly the order they went in. A queue gives you this naturally — like a single-file line, the message enqueued first is delivered first. *A FIFO queue delivers messages in the exact order they were enqueued.* Global FIFO across an entire channel is simple but limits throughput — only one consumer can safely process at a time without reordering. In practice most systems offer **per-key ordering** instead: messages that share a *partition key* (an order ID, a user ID) stay strictly ordered relative to each other, while unrelated keys flow in parallel. You get order where it matters and parallelism everywhere else. ## Replay — the idea [#replay--the-idea] A real-time channel is a PA announcement: hear it now or miss it forever. A **replayable** channel is a recording. To replay, the system has to do two things: 1. **Number every message** with a monotonically increasing **sequence number** (also called an **offset**) — a stable address for each message in the log. 2. **Persist the log** so messages survive after delivery, and let a consumer say *"start me at offset N"* instead of always "start me at the newest." *Every message is numbered and written to a persistent log; a late subscriber rewinds to any offset and re-reads history, while a live subscriber follows the tail.* Because the log keeps the sequence intact, replay and ordering reinforce each other: re-reading from offset 42 always returns 42, 43, 44… in the same order, every time. That determinism is what makes a log trustworthy as a system of record. ### The event-sourcing idea [#the-event-sourcing-idea] If the log is the source of truth, you do not need to store the *current state* of anything — you can **rebuild it by replaying the events that produced it**. This is **event sourcing**: instead of saving "account balance = $80," you save the sequence of facts (`Deposited $100`, `Withdrew $20`) and replay them to compute the balance on demand. A new service, a rebuilt cache, or a bug fix that needs to reprocess history all start the same way: replay from the beginning. **Concept:** A sequence number (offset) is just a message's permanent position in the log. "Replay" means asking the log to start delivering from a chosen position instead of from the newest message. ## Precise definition [#precise-definition] * **Ordering** — a delivery guarantee that consumers observe messages in a defined sequence. **FIFO** orders an entire channel; **per-key ordering** orders only messages sharing a partition key, allowing parallel processing across keys. * **Sequence number / offset** — a monotonically increasing integer assigned to each message as it is persisted, giving every message a stable, addressable position in the log. * **Replay** — re-reading messages from a persisted log starting at a chosen **start position** (a sequence number, a timestamp, the first message, or the last), rather than receiving only messages published after subscribing. * **Event sourcing** — modeling state as the ordered log of events that produced it, and reconstructing current state by replaying that log from the start. ## Trade-offs [#trade-offs] | Property | When it helps | When it bites | | ---------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | **Strict FIFO** | Steps that must happen in order (state machines, financial postings) | Caps throughput — one in-flight consumer per ordered stream | | **Per-key ordering** | Order per entity (per order, per user) plus parallelism across entities | Requires choosing a good key; a hot key still serializes | | **Persistent log + offsets** | Late joiners, audit trails, reprocessing, event sourcing | Costs disk and retention management; the log grows | | **Replay from a position** | Recovery, backfills, rebuilding state from history | Re-delivering old events can re-trigger side effects if consumers are not idempotent | **Pitfall:** Replaying history re-delivers messages a consumer may have already handled. If processing a message has side effects — charging a card, sending an email — make consumers **idempotent** (safe to run twice for the same message), keyed on the sequence number or a message ID. Otherwise a replay double-charges. See [delivery guarantees](/learn/concepts/delivery-guarantees) for idempotency. ## In KubeMQ [#in-kubemq] **In KubeMQ:** **Queues** preserve FIFO order — messages are delivered in the order they were sent. **Events Store** persists every message with a **sequence number** and lets a subscriber choose a **start position**: `StartNewOnly`, `StartFromFirst`, `StartFromLast`, `StartAtSequence`, `StartAtTime`, or `StartAtTimeDelta`. Pointing a subscriber at `StartFromFirst` and rebuilding state from the result is exactly event sourcing. The snippet below subscribes to a persisted channel from **sequence 3** — replaying every stored event at or after that offset, then streaming new ones as they arrive. Swapping `StartAtSequence` for `StartFromFirst` replays the entire history; `StartAtTimeDelta` replays a recent time window. ```go title="replay_from_sequence.go" sub, err := client.SubscribeToEventsStore(ctx, "orders.events", "", kubemq.StartAtSequence(3), kubemq.WithOnEvent(func(event *kubemq.Event) { fmt.Printf("[replay] seq=%d body=%s\n", event.Sequence, string(event.Body)) }), kubemq.WithOnError(func(err error) { log.Println(err) }), ) ``` ```python title="replay_from_sequence.py" client.subscribe_to_events_store( subscription=EventsStoreSubscription( channel="orders.events", start_position=EventStoreStartPosition.StartAtSequence, start_position_value=3, on_receive_event_callback=lambda e: print( f"[replay] seq={e.sequence} body={e.body.decode('utf-8')}" ), on_error_callback=lambda e: print(f"Error: {e}"), ), cancel=CancellationToken(), ) ``` ```typescript title="replay_from_sequence.ts" client.subscribeToEventsStore({ channel: 'orders.events', startPosition: EventStoreStartPosition.StartAtSequence, startPositionValue: 3, onEvent: (msg) => console.log(`[replay] seq=${msg.sequence} body=${new TextDecoder().decode(msg.body)}`), onError: (err) => console.error(err.message), }); ``` ```java title="ReplayFromSequence.java" client.subscribeToEventsStore(EventsStoreSubscription.builder() .channel("orders.events") .startPosition(EventStoreStartPosition.StartAtSequence) .startPositionValue(3) .onReceiveEventCallback(event -> System.out.printf("[replay] seq=%d body=%s%n", event.getSequence(), new String(event.getBody()))) .onErrorCallback(err -> System.err.println(err.getMessage())) .build()); ``` ```csharp title="ReplayFromSequence.cs" await foreach (var msg in client.SubscribeToEventsStoreAsync( new EventsStoreSubscription { Channel = "orders.events", StartPosition = EventStoreStartPosition.StartAtSequence, StartPositionValue = 3, })) { Console.WriteLine($"[replay] seq={msg.Sequence} body={Encoding.UTF8.GetString(msg.Body.Span)}"); } ``` ```kotlin title="ReplayFromSequence.kt" client.subscribeToEventsStore { channel = "orders.events" startPosition = StartPosition.StartAtSequence startPositionValue = 3 }.collect { msg -> println("[replay] seq=${msg.sequence} body=${String(msg.body)}") } ``` ```cpp title="replay_from_sequence.cc" client->SubscribeToEventsStore("orders.events", "", kubemq::StartPosition::StartAtSequence, 3, [](const kubemq::EventStoreReceived& msg) { std::cout << "[replay] seq=" << msg.sequence() << " body=" << msg.body() << std::endl; }, [](const std::string& err) { std::cerr << err << std::endl; }); ``` ```rust title="replay_from_sequence.rs" use kubemq::prelude::*; use kubemq::EventsStoreSubscription; let sub = client .subscribe_to_events_store( "orders.events", "", EventsStoreSubscription::StartAtSequence(3), |event| { Box::pin(async move { println!( "[replay] seq={} body={}", event.sequence, String::from_utf8_lossy(&event.body) ); }) }, None, ) .await?; ``` ```ruby title="replay_from_sequence.rb" sub = KubeMQ::PubSub::EventsStoreSubscription.new( channel: "orders.events", start_position: KubeMQ::PubSub::EventStoreStartPosition::START_AT_SEQUENCE, start_position_value: 3 ) client.subscribe_to_events_store(sub, cancellation_token: cancel, on_error: lambda { |e| puts "Error: #{e.message}" }) do |event| puts "[replay] seq=#{event.sequence} body=#{event.body}" end ``` ```elixir title="replay_from_sequence.exs" {:ok, sub} = KubeMQ.Client.subscribe_to_events_store(client, "orders.events", start_at: {:start_at_sequence, 3}, on_event: fn event -> IO.puts("[replay] seq #{event.sequence}: #{event.body}") end ) ``` Queues deliver in FIFO order with no start-position parameter — order is inherent to the queue. The start positions above apply to **Events Store**, where the persistent log makes any offset addressable. ### How KubeMQ does this → [#how-kubemq-does-this-] # Scaling & Flow Control (/learn/concepts/scaling-and-flow) When work piles up faster than one worker can handle it, you have two completely different levers — and reaching for the wrong one quietly breaks your system. This page is about telling them apart: **scaling out** (adding workers to share the load) and **flow control** (keeping a fast producer from drowning a slow consumer). Picture a busy coffee shop. To serve more customers, you put more baristas behind **one** counter — each drink order goes to whichever barista is free. That is scaling by *competing consumers*. Now picture the shop's radio: every barista hears the same announcement, no matter how many you hire. That is *fan-out*. Adding baristas speeds up the counter; it does nothing to the radio. Confusing the two is how you end up processing every order three times — or building a "load balancer" that never balances. ## Competing consumers vs fan-out — the idea [#competing-consumers-vs-fan-out--the-idea] Both shapes start with one stream of messages and several consumers. The difference is **who gets each message**. **Competing consumers** (point-to-point): the consumers share a single logical destination, and each message is handed to **exactly one** of them. Add a consumer and total throughput goes up, because the work is split. This is how you scale processing. **Fan-out** (pub/sub): every consumer is an independent subscriber, and each message is **copied to all** of them. Add a subscriber and you get one more *full copy* of the stream — useful for independent reactions, not for sharing load. *Competing consumers: one destination, each message to exactly one worker — add workers to share the load.* *Fan-out: one channel, every subscriber gets its own copy — add subscribers for independent reactions, not for throughput.* ### Consumer groups: getting both from one channel [#consumer-groups-getting-both-from-one-channel] What if you want load-balancing **and** broadcast on the same channel? A **consumer group** is the bridge. Each consumer declares a group name when it subscribes. Within a group, members compete — a message goes to exactly one of them. Across groups, each group gets its own copy. So three workers in group `billing` split the stream between them, while a separate `analytics` group (its own members) receives the full stream in parallel. One channel, two behaviors, decided entirely by group membership. The group mechanics on this page apply to both Events and Events Store, but durability does not: **Events** groups are fire-and-forget — a member that is offline simply misses messages, with nothing to replay. **Events Store** groups are durable and position-tracked — the store remembers each group's progress, so a member that reconnects resumes from where it left off instead of losing messages. The example below uses plain Events; see [In KubeMQ](#in-kubemq) for the Events Store distinction in full. *Group `billing` competes for messages (load-balanced); group `analytics` gets its own full copy — group name decides the behavior.* ## Backpressure and slow consumers [#backpressure-and-slow-consumers] Scaling out assumes consumers can keep up. When they cannot — a producer bursts, a downstream API slows down, a worker stalls on a long job — the system needs a way to push back. That feedback is **backpressure**: signalling upstream to slow down (or buffer, or shed load) so a fast producer does not overwhelm a slow consumer. Without backpressure, the gap has to go *somewhere*, and every option is bad: an unbounded in-memory buffer grows until the process runs out of memory; a fixed buffer overflows and silently drops messages; or the producer blocks and the whole pipeline stalls. The healthy outcome is the producer feeling resistance and easing off — exactly like water pressure backing up a pipe when the drain is too small. *A buffer absorbs short bursts; when it fills, backpressure flows back to the producer so it eases off instead of overflowing.* A durable queue is itself a form of backpressure-by-buffering: it absorbs bursts on disk so producers never block on slow consumers, and you drain the backlog by adding more competing workers. A broadcast (fire-and-forget) channel has no such buffer — a subscriber that cannot keep up simply misses messages. ### Visibility timeout and in-flight messages [#visibility-timeout-and-in-flight-messages] There is a subtler flow-control problem hiding inside competing consumers: what happens to a message **while** a worker is processing it? If the destination handed the same message to a second worker, you would process it twice. If it deleted the message immediately on delivery, a crash mid-processing would lose it. The standard answer is the **visibility timeout**. When a worker receives a message, the message is not deleted — it is hidden from other consumers for a bounded window and counts as **in-flight**. The worker has until the timeout to finish and acknowledge (ack), which deletes it. If the worker crashes or the timeout expires first, the message becomes visible again and is redelivered to another worker. *Visibility timeout: a received message is in-flight and hidden; ack within the window deletes it, otherwise it reappears for another worker.* The timeout is a balance. Too short, and a legitimately slow job gets redelivered (and processed twice) before it finishes. Too long, and a crashed worker's messages sit invisible for ages before anyone retries them. The limit on in-flight messages also caps real concurrency: a destination only lets so many messages be in-flight at once, which is itself a backpressure knob. ## Precise definitions [#precise-definitions] * **Competing consumers (point-to-point):** a distribution model where multiple consumers read from one shared destination and each message is delivered to exactly one consumer. Throughput scales with the number of consumers. * **fan-out (pub/sub):** a distribution model where each message is copied to every independent subscriber on a channel. Adding subscribers adds parallel copies, not shared load. * **Consumer Group:** a named set of consumers on one channel that compete as a unit — each message goes to one member of the group, while every group on the channel receives its own copy. * **backpressure:** flow control that signals a producer to slow down (or buffer, or shed) when consumers cannot keep up, preventing overflow and loss. * **Visibility timeout:** the bounded window during which a received-but-unacknowledged message is hidden from other consumers and counts as in-flight; on expiry without an ack it is redelivered. * **In-flight message:** a message that has been delivered to a consumer but not yet acknowledged — held, not deleted, so it can be redelivered if processing fails. ## Trade-offs [#trade-offs] | Goal | Reach for | Why | Watch out for | | ------------------------------- | ----------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------ | | Process more, faster | Competing consumers / a group | Each message done once; add workers to scale | One slow worker holds its in-flight messages until timeout | | React independently in N places | fan-out / separate groups | Every consumer sees every message | Adds load, not throughput — N copies of the work | | Absorb bursts without dropping | Durable queue (buffer) | Disk soaks up the spike; drain with more workers | Backlog grows if consumers stay too slow — monitor depth | | Don't overwhelm a slow consumer | Backpressure | Producer eases off instead of overflowing | Fire-and-forget channels have no buffer — slow subscribers miss messages | | Survive crashes mid-processing | Visibility timeout + ack | Unacked work reappears for another worker | Wrong timeout → double-processing (too short) or stalls (too long) | **Pitfall — "scaling" a broadcast.** Adding subscribers to a fire-and-forget channel does **not** share the load: every subscriber still receives every message, so you multiply the work instead of dividing it. To actually scale processing, put the consumers in the same group so they compete for messages. **Pitfall — designing for exactly-once consumers.** Visibility-timeout redelivery means a consumer can see the same message more than once (a slow job, a crash, an expired timeout). Make handlers **idempotent** so a redelivery is harmless rather than betting on never seeing a duplicate. ## In KubeMQ [#in-kubemq] KubeMQ exposes both levers directly: * **Fan-out vs competing consumers is one parameter.** On Events and Events Store, subscribers that pass the **same group name** compete (each message to one member); subscribers with **no group** (or different groups) each get a full copy. Same channel, different `group` argument. * **Queues give you the buffer and the visibility timeout.** A queue durably stores messages, so producers never block on slow consumers — you scale by running more receivers, and each received message is hidden for its **visibility timeout** until you ack it, then redelivered if you don't. The snippet below is the same load-balanced subscribe from the [Events Consumer Groups](/learn/events/tutorials/consumer-groups) tutorial: several consumers join one **group** on `order-events`, and KubeMQ delivers each event to exactly one of them. Drop the group name and the very same subscribers turn into fan-out. ```go title="grouped_worker.go" sub, err := client.SubscribeToEvents(ctx, "order-events", "workers", kubemq.WithOnEvent(func(event *kubemq.Event) { fmt.Printf("Processing: %s\n", string(event.Body)) }), ) if err != nil { log.Fatal(err) } defer sub.Unsubscribe() // Members sharing group "workers" compete; pass "" for fan-out instead. ``` ```python title="grouped_worker.py" from kubemq.pubsub import Client as PubSubClient from kubemq.pubsub import EventsSubscription, CancellationToken client = PubSubClient(address="localhost:50000") client.subscribe_to_events( subscription=EventsSubscription( channel="order-events", group="workers", # same group -> competing consumers; omit for fan-out on_receive_event_callback=lambda e: print(f"Processing: {e.body.decode()}"), ), cancel=CancellationToken(), ) ``` ```javascript title="grouped_worker.js" const { KubeMQClient } = require("kubemq-js"); const client = new KubeMQClient({ address: "localhost:50000" }); client.subscribeToEvents({ channel: "order-events", group: "workers", // same group -> competing consumers; omit for fan-out onEvent: (msg) => console.log(`Processing: ${Buffer.from(msg.body).toString()}`), onError: (err) => console.error(err.message), }); ``` ```java title="GroupedWorker.java" PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId("worker-1") .build(); client.subscribeToEvents(EventsSubscription.builder() .channel("order-events") .group("workers") // same group -> competing consumers; omit for fan-out .onReceiveEventCallback(event -> System.out.printf("Processing: %s%n", new String(event.getBody()))) .build()); ``` ```csharp title="GroupedWorker.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); await foreach (var msg in client.SubscribeToEventsAsync( new EventsSubscription { Channel = "order-events", Group = "workers" })) { // same Group -> competing consumers; leave Group unset for fan-out Console.WriteLine($"Processing: {Encoding.UTF8.GetString(msg.Body.Span)}"); } ``` ```kotlin title="GroupedWorker.kt" val client = PubSubClient("localhost:50000") client.subscribeToEvents( channel = "order-events", group = "workers", // same group -> competing consumers; omit for fan-out onEvent = { event -> println("Processing: ${String(event.body)}") }, onError = { err -> System.err.println(err.message) }, ) ``` ```cpp title="grouped_worker.cpp" auto client = kubemq::PubSubClient("localhost:50000"); // same group ("workers") -> competing consumers; pass "" for fan-out client.subscribeToEvents("order-events", "workers", [](const kubemq::Event& event) { std::cout << "Processing: " << event.body << std::endl; }, [](const std::string& err) { std::cerr << err << std::endl; } ); ``` ```rust title="grouped_worker.rs" let group = "workers"; // same group -> competing consumers; "" for fan-out let sub = client .subscribe_to_events( "order-events", group, |event| { Box::pin(async move { println!("Processing: {}", String::from_utf8_lossy(&event.body)); }) }, None, ) .await?; ``` ```ruby title="grouped_worker.rb" client = KubeMQ::PubSubClient.new(address: "localhost:50000", client_id: "worker-1") cancel = KubeMQ::CancellationToken.new # group: -> competing consumers; omit group: for fan-out sub = KubeMQ::PubSub::EventsSubscription.new(channel: "order-events", group: "workers") client.subscribe_to_events(sub, cancellation_token: cancel) do |event| puts "Processing: #{event.body}" end ``` ```elixir title="grouped_worker.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "worker-1") # group: -> competing consumers; omit group: for fan-out {:ok, _sub} = KubeMQ.Client.subscribe_to_events(client, "order-events", group: "workers", on_event: fn event -> IO.puts("Processing: #{event.body}") end ) ``` The same group switch applies to **Events Store** (durable, position-tracked groups) and to **Queues**, where competing receivers share the buffer and each delivery is governed by a visibility timeout you ack to clear. ### How KubeMQ does this [#how-kubemq-does-this] # Getting Started with Events (/learn/events/getting-started) **Prerequisites:** KubeMQ server running on `localhost:50000` and your SDK installed. See [Getting Started](/deploy) for setup. ## What You Will Build [#what-you-will-build] A notification publisher that sends order events and a subscriber that receives them in real time. The publisher fans out one event to every connected subscriber. Because Events are **at-most-once**, a subscriber that is offline (Subscriber C) misses the message — there is no replay. ## Steps [#steps] ### Install the SDK [#install-the-sdk] ```bash go get github.com/kubemq-io/kubemq-go/v2 ``` ```bash pip install kubemq ``` ```bash npm install kubemq-js ``` ```xml io.kubemq.sdk kubemq-sdk-Java 3.1.1 ``` ```xml ``` ```kotlin implementation("io.kubemq.sdk:kubemq-sdk-kotlin:1.0.1") ``` ```bash vcpkg install kubemq ``` ```toml [dependencies] kubemq = "1.0" tokio = { version = "1", features = ["full"] } ``` ```bash gem install kubemq ``` ```elixir # mix.exs def deps do [{:kubemq, "~> 1.0"}] end ``` ### Create a Subscriber [#create-a-subscriber] Start the subscriber first. Events are fire-and-forget, so the subscriber must be connected before the publisher sends events. ```go title="subscriber.go" package main import ( "context" "fmt" "log" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() sub, err := client.SubscribeToEvents(ctx, "order-notifications", "", kubemq.WithOnEvent(func(event *kubemq.Event) { fmt.Printf("Received: %s\n", string(event.Body)) }), kubemq.WithOnError(func(err error) { log.Println("Subscription error:", err) }), ) if err != nil { log.Fatal(err) } defer sub.Unsubscribe() log.Println("Subscriber listening on 'order-notifications'...") <-ctx.Done() } ``` ```python title="subscriber.py" import time from kubemq.pubsub import Client as PubSubClient from kubemq.pubsub import EventsSubscription, CancellationToken def on_event(event): print(f"Received: {event.body.decode('utf-8')}") def on_error(err): print(f"Subscription error: {err}") client = PubSubClient(address="localhost:50000") cancel = CancellationToken() client.subscribe_to_events( subscription=EventsSubscription( channel="order-notifications", on_receive_event_callback=on_event, on_error_callback=on_error, ), cancel=cancel, ) print("Subscriber listening on 'order-notifications'...") time.sleep(120) client.close() ``` ```javascript title="subscriber.js" const { KubeMQClient } = require("kubemq-js"); const client = new KubeMQClient({ address: "localhost:50000" }); client.subscribeToEvents({ channel: "order-notifications", onEvent: (msg) => console.log(`Received: ${Buffer.from(msg.body).toString()}`), onError: (err) => console.error("Subscription error:", err.message), }); console.log("Subscriber listening on 'order-notifications'..."); ``` ```java title="Subscriber.java" import io.kubemq.sdk.pubsub.PubSubClient; import io.kubemq.sdk.pubsub.EventsSubscription; PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId("order-subscriber") .build(); client.subscribeToEvents(EventsSubscription.builder() .channel("order-notifications") .onReceiveEventCallback(event -> System.out.println("Received: " + new String(event.getBody()))) .onErrorCallback(err -> System.err.println("Subscription error: " + err.getMessage())) .build()); System.out.println("Subscriber listening on 'order-notifications'..."); Thread.sleep(120_000); client.close(); ``` ```csharp title="Subscriber.cs" using KubeMQ.Sdk.Client; using KubeMQ.Sdk.Events; using System.Text; await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); Console.WriteLine("Subscriber listening on 'order-notifications'..."); await foreach (var msg in client.SubscribeToEventsAsync( new EventsSubscription { Channel = "order-notifications" })) { Console.WriteLine($"Received: {Encoding.UTF8.GetString(msg.Body.Span)}"); } ``` ```kotlin title="Subscriber.kt" import io.kubemq.sdk.client.KubeMQClient import kotlinx.coroutines.runBlocking fun main() = runBlocking { val pubsub = KubeMQClient.pubSub { address = "localhost:50000" clientId = "order-subscriber" } println("Subscriber listening on 'order-notifications'...") pubsub.subscribeToEvents { channel = "order-notifications" }.collect { event -> println("Received: ${String(event.body)}") } } ``` ```cpp title="subscriber.cpp" #include #include auto client = kubemq::PubSubClient("localhost:50000"); client.subscribeToEvents("order-notifications", "", [](const kubemq::Event& event) { std::cout << "Received: " << event.body << std::endl; }, [](const std::string& err) { std::cerr << "Subscription error: " << err << std::endl; } ); std::cout << "Subscriber listening on 'order-notifications'..." << std::endl; std::this_thread::sleep_for(std::chrono::seconds(120)); ``` ```rust title="subscriber.rs" use kubemq::prelude::*; use kubemq::Subscription; use std::time::Duration; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; let sub: Subscription = client .subscribe_to_events( "order-notifications", "", |event| { Box::pin(async move { println!("Received: {}", String::from_utf8_lossy(&event.body)); }) }, None, ) .await?; println!("Subscriber listening on 'order-notifications'..."); tokio::time::sleep(Duration::from_secs(120)).await; sub.unsubscribe().await; client.close().await?; Ok(()) } ``` ```ruby title="subscriber.rb" require 'kubemq' client = KubeMQ::PubSubClient.new(address: "localhost:50000", client_id: "order-subscriber") cancel = KubeMQ::CancellationToken.new sub = KubeMQ::PubSub::EventsSubscription.new(channel: "order-notifications") client.subscribe_to_events(sub, cancellation_token: cancel, on_error: ->(e) { puts "Subscription error: #{e.message}" }) do |event| puts "Received: #{event.body}" end puts "Subscriber listening on 'order-notifications'..." sleep 120 cancel.cancel client.close ``` ```elixir title="subscriber.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-subscriber") {:ok, sub} = KubeMQ.Client.subscribe_to_events(client, "order-notifications", on_event: fn event -> IO.puts("Received: #{event.body}") end, on_error: fn err -> IO.puts("Subscription error: #{err.message}") end ) IO.puts("Subscriber listening on 'order-notifications'...") Process.sleep(120_000) KubeMQ.Subscription.cancel(sub) KubeMQ.Client.close(client) ``` ### Create a Publisher [#create-a-publisher] In a separate terminal, run the publisher. The subscriber receives the event instantly. ```go title="publisher.go" package main import ( "context" "log" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() err = client.SendEvent(ctx, kubemq.NewEvent(). SetChannel("order-notifications"). SetBody([]byte(`{"orderId":"ORD-1234","status":"created"}`)), ) if err != nil { log.Fatal(err) } log.Println("Event published to 'order-notifications'") } ``` ```python title="publisher.py" from kubemq.pubsub import Client as PubSubClient from kubemq.pubsub import EventMessage client = PubSubClient(address="localhost:50000") client.send_event( EventMessage( channel="order-notifications", body=b'{"orderId":"ORD-1234","status":"created"}', ) ) print("Event published to 'order-notifications'") client.close() ``` ```javascript title="publisher.js" const { KubeMQClient } = require("kubemq-js"); const client = new KubeMQClient({ address: "localhost:50000" }); await client.sendEvent({ channel: "order-notifications", body: Buffer.from(JSON.stringify({ orderId: "ORD-1234", status: "created" })), }); console.log("Event published to 'order-notifications'"); ``` ```java title="Publisher.java" import io.kubemq.sdk.pubsub.PubSubClient; import io.kubemq.sdk.pubsub.EventMessage; PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId("order-publisher") .build(); client.sendEventsMessage(EventMessage.builder() .channel("order-notifications") .body("{\"orderId\":\"ORD-1234\",\"status\":\"created\"}".getBytes()) .build()); System.out.println("Event published to 'order-notifications'"); client.close(); ``` ```csharp title="Publisher.cs" using KubeMQ.Sdk.Client; using KubeMQ.Sdk.Events; using System.Text; await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); await client.SendEventAsync(new EventMessage { Channel = "order-notifications", Body = Encoding.UTF8.GetBytes( "{\"orderId\":\"ORD-1234\",\"status\":\"created\"}") }); Console.WriteLine("Event published to 'order-notifications'"); ``` ```kotlin title="Publisher.kt" import io.kubemq.sdk.client.KubeMQClient import io.kubemq.sdk.pubsub.eventMessage import kotlinx.coroutines.runBlocking fun main() = runBlocking { val pubsub = KubeMQClient.pubSub { address = "localhost:50000" clientId = "order-publisher" } pubsub.publishEvent(eventMessage { channel = "order-notifications" body = """{"orderId":"ORD-1234","status":"created"}""".toByteArray() }) println("Event published to 'order-notifications'") } ``` ```cpp title="publisher.cpp" #include #include auto client = kubemq::PubSubClient("localhost:50000"); kubemq::EventMessage event; event.channel = "order-notifications"; event.body = R"({"orderId":"ORD-1234","status":"created"})"; client.sendEvent(event); std::cout << "Event published to 'order-notifications'" << std::endl; ``` ```rust title="publisher.rs" use kubemq::prelude::*; use kubemq::EventBuilder; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; let event = EventBuilder::new() .channel("order-notifications") .body(br#"{"orderId":"ORD-1234","status":"created"}"#.to_vec()) .build(); client.send_event(event).await?; println!("Event published to 'order-notifications'"); client.close().await?; Ok(()) } ``` ```ruby title="publisher.rb" require 'kubemq' client = KubeMQ::PubSubClient.new(address: "localhost:50000", client_id: "order-publisher") client.send_event(KubeMQ::PubSub::EventMessage.new( channel: "order-notifications", body: '{"orderId":"ORD-1234","status":"created"}' )) puts "Event published to 'order-notifications'" client.close ``` ```elixir title="publisher.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-publisher") event = KubeMQ.Event.new( channel: "order-notifications", body: ~s({"orderId":"ORD-1234","status":"created"}) ) :ok = KubeMQ.Client.send_event(client, event) IO.puts("Event published to 'order-notifications'") KubeMQ.Client.close(client) ``` ### Run the Example [#run-the-example] 1. Start the **subscriber** in one terminal 2. Run the **publisher** in a separate terminal 3. The subscriber receives the event in real time ### Verify [#verify] The subscriber terminal should display: ```text Subscriber listening on 'order-notifications'... Received: {"orderId":"ORD-1234","status":"created"} ``` The publisher terminal should display: ```text Event published to 'order-notifications' ``` ## What Just Happened [#what-just-happened] 1. The **subscriber** connected to KubeMQ and registered interest in `order-notifications` 2. The **publisher** sent an event containing an order payload to the same channel 3. KubeMQ delivered the event to the subscriber in real time 4. Because Events are fire-and-forget, the publisher does not wait for acknowledgment Events have **at-most-once** delivery. If the subscriber was not connected when the event was published, the message would be lost. For persistent delivery with replay, use [Events Store](/learn/events-store/getting-started). ## Next Steps [#next-steps] # Events — Real-Time Pub/Sub (/learn/events) Think of Events like a PA system in a building — when an announcement is made, everyone currently listening hears it. If you step outside, you miss the announcement. There's no recording, no replay. KubeMQ Events implement fire-and-forget publish/subscribe with at-most-once delivery. Publishers send messages to a named channel, and all active subscribers receive the message in real time. There is no persistence — if a subscriber is offline, the message is lost for that subscriber. ## The concept it implements [#the-concept-it-implements] Events is KubeMQ's implementation of two fundamental ideas. The interaction style is **[pub/sub](/learn/concepts/interaction-styles)** (fan-out) — one publisher, many subscribers, each receiving every message. The delivery guarantee is **[at-most-once](/learn/concepts/delivery-guarantees)** — messages reach only the subscribers connected at publish time and are never persisted or redelivered. New to these terms? Start with the [Fundamentals](/learn/concepts) track. ## Key Properties [#key-properties] | Property | This pattern | Learn the concept | | ------------------ | --------------------------------------------- | ---------------------------------------------------------- | | Interaction style | pub/sub (fan-out) | [Interaction styles](/learn/concepts/interaction-styles) | | Delivery guarantee | at-most-once | [Delivery guarantees](/learn/concepts/delivery-guarantees) | | Persistence | None — fire-and-forget | [Ordering & replay](/learn/concepts/ordering-and-replay) | | Ordering | Not guaranteed | [Ordering & replay](/learn/concepts/ordering-and-replay) | | Scaling | Fan-out, or load-balance with consumer groups | [Scaling & flow](/learn/concepts/scaling-and-flow) | | Addressing | Named channels, wildcards, multicast routing | [Channels & routing](/learn/concepts/channels-and-routing) | ## Key Features [#key-features] * **At-most-once delivery** — messages delivered to active subscribers only * **Lowest latency** — no disk I/O or acknowledgment overhead * **Multicast delivery** — every subscriber on the channel receives every message (fan-out) * **Channel groups** — load balance across subscribers in a named group * **Multicast routing** — publish to multiple channels using routing syntax * **Stream publishing** — high-throughput batched delivery via bidirectional streaming ## How It Works [#how-it-works] *Fan-out: a publisher broadcasts to the channel, every connected subscriber receives the message, and offline subscribers miss it.* ## Quick Example [#quick-example] ```go title="publish.go" package main import ( "context" "log" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() err = client.SendEvent(ctx, kubemq.NewEvent(). SetChannel("order-notifications"). SetMetadata("order.created"). SetBody([]byte(`{"orderId":"ORD-1234","status":"created"}`)), ) if err != nil { log.Fatal(err) } log.Println("Event sent successfully") } ``` ```python title="publish.py" from kubemq.pubsub import Client as PubSubClient from kubemq.pubsub import EventMessage client = PubSubClient(address="localhost:50000") client.send_event( EventMessage( channel="order-notifications", metadata="order.created", body=b'{"orderId":"ORD-1234","status":"created"}', ) ) print("Event sent successfully") client.close() ``` ```javascript title="publish.js" import { KubeMQClient } from 'kubemq-js'; const client = await KubeMQClient.create({ address: 'localhost:50000' }); await client.sendEvent({ channel: "order-notifications", metadata: "order.created", body: Buffer.from(JSON.stringify({ orderId: "ORD-1234", status: "created" })), }); console.log("Event sent successfully"); ``` ```java title="Publish.java" PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId("order-publisher") .build(); client.sendEventsMessage(EventMessage.builder() .channel("order-notifications") .metadata("order.created") .body("{\"orderId\":\"ORD-1234\",\"status\":\"created\"}".getBytes()) .build()); System.out.println("Event sent successfully"); client.close(); ``` ```csharp title="Publish.cs" using KubeMQ.Sdk.Client; await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); await client.SendEventAsync(new EventMessage { Channel = "order-notifications", Metadata = "order.created", Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-1234\",\"status\":\"created\"}") }); Console.WriteLine("Event sent successfully"); ``` ```kotlin title="Publish.kt" val client = PubSubClient("localhost:50000") client.sendEvent(EventMessage( channel = "order-notifications", metadata = "order.created", body = """{"orderId":"ORD-1234","status":"created"}""".toByteArray() )) println("Event sent successfully") client.close() ``` ```cpp title="publish.cpp" #include auto client = kubemq::PubSubClient("localhost:50000"); kubemq::EventMessage event; event.channel = "order-notifications"; event.metadata = "order.created"; event.body = R"({"orderId":"ORD-1234","status":"created"})"; client.sendEvent(event); std::cout << "Event sent successfully" << std::endl; ``` ```rust title="publish.rs" use kubemq::prelude::*; use kubemq::EventBuilder; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; let event = EventBuilder::new() .channel("order-notifications") .metadata("order.created") .body(br#"{"orderId":"ORD-1234","status":"created"}"#.to_vec()) .build(); client.send_event(event).await?; println!("Event sent successfully"); client.close().await?; Ok(()) } ``` ```ruby title="publish.rb" require 'kubemq' client = KubeMQ::PubSubClient.new(address: "localhost:50000", client_id: "order-publisher") client.send_event(KubeMQ::PubSub::EventMessage.new( channel: "order-notifications", metadata: "order.created", body: '{"orderId":"ORD-1234","status":"created"}' )) puts "Event sent successfully" client.close ``` ```elixir title="publish.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-publisher") event = KubeMQ.Event.new( channel: "order-notifications", metadata: "order.created", body: ~s({"orderId":"ORD-1234","status":"created"}) ) :ok = KubeMQ.Client.send_event(client, event) IO.puts("Event sent successfully") KubeMQ.Client.close(client) ``` ## When to Use Events [#when-to-use-events] | Scenario | Events | Events Store | | ----------------------- | ---------------------- | ----------------------------- | | Real-time notifications | ✅ Best choice | Overkill | | Log/metric streaming | ✅ Best choice | Use if logs must not be lost | | Live dashboards | ✅ Best choice | Use if historical data needed | | Cache invalidation | ✅ Best choice | Not needed | | Audit trails | ❌ Messages can be lost | ✅ Use Events Store | | Event sourcing | ❌ No persistence | ✅ Use Events Store | Need guaranteed delivery or replay capability? Use [Events Store](/learn/events-store) instead. Events are also available via the [CloudEvents protocol](/connectors/cloudevents/how-to/events) — use any language with a CloudEvents SDK, no KubeMQ client library needed. ## Learn More [#learn-more] # Events Reference (/learn/events/reference) ## Message Structure [#message-structure] ### Event Message (Send) [#event-message-send] ### Event Receive (Subscribe) [#event-receive-subscribe] Unlike Events Store, plain Events do not include `Timestamp` or `Sequence` fields because there is no persistence layer. ### Send Result [#send-result] ## Subscription Options [#subscription-options] ### Consumer Groups [#consumer-groups] When multiple subscribers specify the same `group` value on the same channel: * Each event is delivered to exactly **one** member of the group (round-robin) * When `group` is empty, every subscriber receives every event (standard fan-out) * Groups are independent per channel * There is no limit on the number of group members See [Consumer Groups](/learn/events/tutorials/consumer-groups) and [Scale Subscribers](/learn/events/how-to/scale-subscribers) for examples. ## Channel Naming Rules [#channel-naming-rules] | Rule | Constraint | Error Code | | ---------------------- | ----------------------------------------- | ---------- | | Required | Cannot be empty | 102 | | No trailing dot | Cannot end with `.` | 119 | | No whitespace | Cannot contain spaces | 108 | | No wildcards (publish) | Cannot contain `*` or `>` when publishing | 107 | | Max length | 256 characters | — | **Valid channel regex (publish):** `^[^\s*>]+[^.]$` Wildcards (`*` and `>`) are allowed in **subscription** channel patterns only. See [Wildcard Subscriptions](/learn/events/tutorials/wildcard-subscriptions). ### Channel Naming Conventions [#channel-naming-conventions] Use dot-separated hierarchical names for best results with wildcard subscriptions: ```text {domain}.{entity}.{action} orders.created orders.us-east.shipped payments.completed inventory.reserved ``` ## Routing (Multicast) [#routing-multicast] Events support multicast publishing through special channel syntax: | Character | Purpose | Example | | --------- | ---------------------------------- | ---------------------------------- | | `;` | Separate channels of the same type | `orders;notifications` | | `:` | Specify target pattern type | `events:orders;events_store:audit` | ### Pattern Type Prefixes [#pattern-type-prefixes] | Prefix | Target Pattern | | --------------- | ---------------------------- | | `events:` | Events (fire-and-forget) | | `events_store:` | Events Store (persistent) | | `queues:` | Queues (guaranteed delivery) | Routed messages are automatically tagged with `X-KUBEMQ-ROUTED=true`. See [Multicast Events](/learn/events/tutorials/multicast). ## Transport Protocols [#transport-protocols] ### gRPC [#grpc] * **Publish:** `SendEvent(pb.Event)` — unary RPC * **Publish Stream:** `SendEventsStream()` — bidirectional streaming for high-throughput publishing * **Subscribe:** `SubscribeToEvents(pb.Subscribe)` — server-streaming RPC * Default port: `50000` * Max message size: \~1 GB (configurable) ### REST [#rest] * **Publish:** `POST /send` with JSON body * **Subscribe:** WebSocket upgrade for streaming delivery * Default port: `9090` * Max body size: 100 MB (configurable) ### WebSocket [#websocket] * Publish and subscribe over persistent WebSocket connections * JSON-encoded messages * Read limit: 1 MB ## Configuration [#configuration] ### Server Configuration [#server-configuration] | Setting | Default | Description | | --------------------------- | ------- | --------------------- | | `Connectors.Grpc.BodyLimit` | \~1 GB | Max gRPC message size | | `Connectors.Rest.BodyLimit` | 100 MB | Max REST body size | ### Client Configuration [#client-configuration] ## Slow Consumer Handling [#slow-consumer-handling] When a subscriber's receive buffer is full, KubeMQ waits up to the **write deadline** (default 2 seconds) for the buffer to clear. If the deadline expires: * The event is **dropped** for that subscriber * A warning is logged server-side with the channel, event ID, and metadata * Other subscribers are not affected See [Handle Slow Consumers](/learn/events/how-to/handle-slow-consumers) for mitigation strategies. ## Delivery Semantics [#delivery-semantics] | Aspect | Behavior | | ------------------ | ------------------------------------------------- | | Delivery guarantee | **At-most-once** | | Persistence | None — events flow through memory only | | Ordering | Events are delivered in publish order per channel | | Duplicates | No duplicates (single delivery attempt) | | Acknowledgment | None — fire-and-forget | | Retry | None — failed deliveries are not retried | ## Events vs Events Store [#events-vs-events-store] | Feature | Events | Events Store | | ---------------------- | ---------------------------------- | --------------------------------- | | Persistence | No | Yes (disk-backed) | | Replay | No | Yes (from offset, time, sequence) | | Delivery guarantee | At-most-once | At-least-once | | Wildcard subscriptions | Yes (`*`, `>`) | No | | Consumer groups | Yes (round-robin) | Yes (durable) | | Latency | Lowest | Slightly higher (disk write) | | Use cases | Real-time notifications, streaming | Audit trails, event sourcing | ## Error Codes [#error-codes] | Code | Error | Description | | ---- | ------------------------- | ------------------------------------------ | | 101 | Invalid ClientID | ClientID is empty | | 102 | Invalid Channel | Channel is empty | | 107 | Invalid Wildcards | Channel contains `*` or `>` (publish only) | | 108 | Invalid Whitespace | Channel contains spaces | | 110 | Invalid Message | Both `Body` and `Metadata` are empty | | 119 | Invalid Channel Separator | Channel ends with `.` | ### Runtime Errors [#runtime-errors] | Error | Cause | Resolution | | -------------------------- | ------------------------------------ | ---------------------------------------- | | `ErrShutdownMode` | Server is shutting down | Reconnect after server restart | | `ErrConnectionNoAvailable` | broker connection is down | SDK auto-reconnects; check server health | | Authorization denied | Casbin policy rejected the operation | Verify client permissions | ## SDK Quick Reference [#sdk-quick-reference] ```go // Publish client.SendEvent(ctx, kubemq.NewEvent(). SetChannel("ch").SetBody([]byte("data"))) // Subscribe client.SubscribeToEvents(ctx, "ch", "group", kubemq.WithOnEvent(handler), kubemq.WithOnError(errHandler)) // Stream Publish streamCh := make(chan *kubemq.Event, 100) resultCh := make(chan *kubemq.EventSendResult, 100) go client.StreamEvents(ctx, streamCh, resultCh) ``` ```python # Publish client.send_event(EventMessage(channel="ch", body=b"data")) # Subscribe client.subscribe_to_events( EventsSubscription(channel="ch", group="group", on_receive_event_callback=handler, on_error_callback=err_handler), cancel=CancellationToken()) # Stream Publish stream = client.open_events_stream() stream.send(EventMessage(channel="ch", body=b"data")) ``` ```javascript // Publish await client.sendEvent({ channel: "ch", body: Buffer.from("data") }); // Subscribe client.subscribeToEvents({ channel: "ch", group: "group", onEvent: handler, onError: errHandler }); // Stream Publish const stream = client.createEventStream(); stream.send({ channel: "ch", body: Buffer.from("data") }); ``` ```java // Publish client.sendEventsMessage(EventMessage.builder() .channel("ch").body("data".getBytes()).build()); // Subscribe client.subscribeToEvents(EventsSubscription.builder() .channel("ch").group("group") .onReceiveEventCallback(handler) .onErrorCallback(errHandler).build()); // Stream Publish EventsStream stream = client.openEventsStream(); stream.send(EventMessage.builder() .channel("ch").body("data".getBytes()).build()); ``` ```csharp // Publish await client.SendEventAsync(new EventMessage { Channel = "ch", Body = Encoding.UTF8.GetBytes("data") }); // Subscribe await foreach (var msg in client.SubscribeToEventsAsync( new EventsSubscription { Channel = "ch", Group = "group" })) { } // Stream Publish var stream = client.OpenEventsStream(); await stream.SendAsync(new EventMessage { Channel = "ch", Body = Encoding.UTF8.GetBytes("data") }); ``` ```kotlin // Publish client.sendEvent(EventMessage( channel = "ch", body = "data".toByteArray())) // Subscribe client.subscribeToEvents( channel = "ch", group = "group", onEvent = handler, onError = errHandler) // Stream Publish val stream = client.openEventsStream() stream.send(EventMessage(channel = "ch", body = "data".toByteArray())) ``` ```cpp // Publish kubemq::EventMessage event; event.channel = "ch"; event.body = "data"; client.sendEvent(event); // Subscribe client.subscribeToEvents("ch", "group", handler, errHandler); // Stream Publish auto stream = client.openEventsStream(); stream.send(event); ``` ```rust // Publish let event = EventBuilder::new() .channel("ch").body(b"data".to_vec()).build(); client.send_event(event).await?; // Subscribe let sub = client.subscribe_to_events("ch", "group", |event| Box::pin(async move { handle(event).await }), None).await?; // Stream Publish let mut stream = client.send_event_stream().await?; stream.send(EventBuilder::new() .channel("ch").body(b"data".to_vec()).build()).await?; ``` ```ruby # Publish client.send_event(KubeMQ::PubSub::EventMessage.new( channel: "ch", body: "data")) # Subscribe sub = KubeMQ::PubSub::EventsSubscription.new(channel: "ch", group: "group") client.subscribe_to_events(sub, cancellation_token: cancel, on_error: ->(e) { handle_error(e) }) { |event| handle(event) } # Stream Publish sender = client.create_events_sender sender.publish(KubeMQ::PubSub::EventMessage.new( channel: "ch", body: "data")) ``` ```elixir # Publish event = KubeMQ.Event.new(channel: "ch", body: "data") KubeMQ.Client.send_event(client, event) # Subscribe {:ok, sub} = KubeMQ.Client.subscribe_to_events(client, "ch", group: "group", on_event: fn event -> handle(event) end) # Stream Publish {:ok, handle} = KubeMQ.Client.send_event_stream(client) KubeMQ.EventStreamHandle.send(handle, KubeMQ.Event.new(channel: "ch", body: "data")) ``` ## Related [#related] * [Getting Started with Events](/learn/events/getting-started) * [Events Store Reference](/learn/events-store/reference) for the persistent variant * [Queues Reference](/learn/queues/reference) for guaranteed delivery # Getting Started with Events Store (/learn/events-store/getting-started) This guide walks you through publishing persistent events and subscribing with replay. By the end, you will see how Events Store preserves messages for subscribers that connect after the event was published. This is the **quickstart** — a single publisher and subscriber in 5 minutes. For the deep-dive covering multiple subscriber types (audit replay vs. real-time-only) and durable subscriptions, see [Persistent Publish & Subscribe](/learn/events-store/tutorials/persistent-publish-subscribe). ## Prerequisites [#prerequisites] * **KubeMQ server** running on `localhost:50000` * One of the supported SDKs installed Need to install KubeMQ? Run it with Docker in seconds: ## What You Will Build [#what-you-will-build] An order tracking system where: * A **publisher** stores order events to a persistent channel * A **subscriber** connects later and replays the full order history using `StartFromFirst` *Events are stored on publish; a later subscriber replays the full history, then continues live.* ## Step-by-Step Guide [#step-by-step-guide] ### Install the SDK [#install-the-sdk] ```bash go get github.com/kubemq-io/kubemq-go/v2 ``` ```bash pip install kubemq ``` ```bash npm install kubemq-js ``` ```xml io.kubemq.sdk kubemq-sdk-Java 2.1.1 ``` ```bash dotnet add package KubeMQ.SDK.CSharp ``` ```kotlin implementation("io.kubemq.sdk:kubemq-sdk-kotlin:2.1.0") ``` ```bash vcpkg install kubemq ``` ```bash cargo add kubemq ``` ```bash gem install kubemq ``` ```elixir # mix.exs def deps do [{:kubemq, "~> 1.0"}] end ``` ### Publish Persistent Events [#publish-persistent-events] Publish several order events **before** starting the subscriber. With Events Store, messages are persisted and available for replay. ```go title="publisher.go" package main import ( "context" "fmt" "log" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() orders := []string{ `{"action":"order.created","orderId":"ORD-1001","total":149.99}`, `{"action":"order.paid","orderId":"ORD-1001","method":"credit_card"}`, `{"action":"order.shipped","orderId":"ORD-1001","carrier":"fedex"}`, } for i, body := range orders { result, err := client.SendEventStore(ctx, kubemq.NewEvent(). SetChannel("orders.events"). SetBody([]byte(body)), ) if err != nil { log.Printf("Failed to store event %d: %v", i+1, err) continue } log.Printf("Stored event %d: ID=%s", i+1, result.EventID) } } ``` ```python title="publisher.py" from kubemq import PubSubClient, EventStoreMessage orders = [ b'{"action":"order.created","orderId":"ORD-1001","total":149.99}', b'{"action":"order.paid","orderId":"ORD-1001","method":"credit_card"}', b'{"action":"order.shipped","orderId":"ORD-1001","carrier":"fedex"}', ] with PubSubClient(address="localhost:50000") as client: for i, body in enumerate(orders, 1): result = client.publish_event_store( EventStoreMessage(channel="orders.events", body=body) ) print(f"Stored event {i}: ID={result.id}") ``` ```typescript title="publisher.ts" import { KubeMQClient, createEventStoreMessage } from 'kubemq-js'; const client = await KubeMQClient.create({ address: 'localhost:50000' }); const orders = [ '{"action":"order.created","orderId":"ORD-1001","total":149.99}', '{"action":"order.paid","orderId":"ORD-1001","method":"credit_card"}', '{"action":"order.shipped","orderId":"ORD-1001","carrier":"fedex"}', ]; for (const [i, body] of orders.entries()) { const result = await client.sendEventStore( createEventStoreMessage({ channel: 'orders.events', body }) ); console.log(`Stored event ${i + 1}: ID=${result.id}`); } ``` ```java title="Publisher.java" PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId("order-publisher") .build(); String[] orders = { "{\"action\":\"order.created\",\"orderId\":\"ORD-1001\",\"total\":149.99}", "{\"action\":\"order.paid\",\"orderId\":\"ORD-1001\",\"method\":\"credit_card\"}", "{\"action\":\"order.shipped\",\"orderId\":\"ORD-1001\",\"carrier\":\"fedex\"}" }; for (int i = 0; i < orders.length; i++) { EventSendResult result = client.sendEventsStoreMessage( EventStoreMessage.builder() .channel("orders.events") .body(orders[i].getBytes()) .build()); System.out.printf("Stored event %d: ID=%s%n", i + 1, result.getId()); } client.close(); ``` ```csharp title="Publisher.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); string[] orders = { "{\"action\":\"order.created\",\"orderId\":\"ORD-1001\",\"total\":149.99}", "{\"action\":\"order.paid\",\"orderId\":\"ORD-1001\",\"method\":\"credit_card\"}", "{\"action\":\"order.shipped\",\"orderId\":\"ORD-1001\",\"carrier\":\"fedex\"}" }; for (var i = 0; i < orders.Length; i++) { var result = await client.SendEventStoreAsync(new EventStoreMessage { Channel = "orders.events", Body = Encoding.UTF8.GetBytes(orders[i]), }); Console.WriteLine($"Stored event {i + 1}: ID={result.Id}"); } ``` ```kotlin title="Publisher.kt" val client = KubeMQClient.pubSub { address = "localhost:50000" clientId = "order-publisher" } val orders = listOf( """{"action":"order.created","orderId":"ORD-1001","total":149.99}""", """{"action":"order.paid","orderId":"ORD-1001","method":"credit_card"}""", """{"action":"order.shipped","orderId":"ORD-1001","carrier":"fedex"}""", ) client.use { orders.forEachIndexed { i, body -> val result = client.sendEventStore(eventStoreMessage { channel = "orders.events" this.body = body.toByteArray() }) println("Stored event ${i + 1}: ID=${result.id}") } } ``` ```cpp title="publisher.cc" kubemq::ClientOptions options; options.set_address("localhost", 50000); options.set_client_id("order-publisher"); auto client = kubemq::Client::Create(options).value(); std::vector orders = { R"({"action":"order.created","orderId":"ORD-1001","total":149.99})", R"({"action":"order.paid","orderId":"ORD-1001","method":"credit_card"})", R"({"action":"order.shipped","orderId":"ORD-1001","carrier":"fedex"})" }; for (size_t i = 0; i < orders.size(); ++i) { kubemq::EventStoreMessage msg; msg.set_channel("orders.events"); msg.set_body(orders[i]); auto result = client->SendEventStore(msg); if (result.ok()) { std::cout << "Stored event " << i + 1 << ": ID=" << result->id() << std::endl; } } ``` ```rust title="publisher.rs" use kubemq::prelude::*; use kubemq::EventStoreBuilder; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; let orders = [ r#"{"action":"order.created","orderId":"ORD-1001","total":149.99}"#, r#"{"action":"order.paid","orderId":"ORD-1001","method":"credit_card"}"#, r#"{"action":"order.shipped","orderId":"ORD-1001","carrier":"fedex"}"#, ]; for (i, body) in orders.iter().enumerate() { let event = EventStoreBuilder::new() .channel("orders.events") .body(body.as_bytes().to_vec()) .build(); let result = client.send_event_store(event).await?; println!("Stored event {}: id={}", i + 1, result.id); } client.close().await?; Ok(()) } ``` ```ruby title="publisher.rb" require 'kubemq' client = KubeMQ::PubSubClient.new(address: 'localhost:50000', client_id: 'order-publisher') orders = [ '{"action":"order.created","orderId":"ORD-1001","total":149.99}', '{"action":"order.paid","orderId":"ORD-1001","method":"credit_card"}', '{"action":"order.shipped","orderId":"ORD-1001","carrier":"fedex"}' ] orders.each_with_index do |body, i| msg = KubeMQ::PubSub::EventStoreMessage.new(channel: 'orders.events', body: body) result = client.send_event_store(msg) puts "Stored event #{i + 1}: sent=#{result.sent}" end client.close ``` ```elixir title="publisher.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-publisher") orders = [ ~s({"action":"order.created","orderId":"ORD-1001","total":149.99}), ~s({"action":"order.paid","orderId":"ORD-1001","method":"credit_card"}), ~s({"action":"order.shipped","orderId":"ORD-1001","carrier":"fedex"}) ] orders |> Enum.with_index(1) |> Enum.each(fn {body, i} -> event = KubeMQ.EventStore.new(channel: "orders.events", body: body) {:ok, result} = KubeMQ.Client.send_event_store(client, event) IO.puts("Stored event #{i}: sent=#{result.sent}") end) KubeMQ.Client.close(client) ``` ### Subscribe with Replay from Beginning [#subscribe-with-replay-from-beginning] Start the subscriber **after** all events have been published. Using `StartFromFirst`, it replays the entire history. ```go title="subscriber.go" package main import ( "context" "fmt" "log" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() sub, err := client.SubscribeToEventsStore(ctx, "orders.events", "", kubemq.StartFromFirst(), kubemq.WithOnEvent(func(event *kubemq.Event) { fmt.Printf("Received: seq=%d body=%s\n", event.Sequence, string(event.Body)) }), kubemq.WithOnError(func(err error) { log.Println("Error:", err) }), ) if err != nil { log.Fatal(err) } defer sub.Unsubscribe() log.Println("Replaying order history...") <-ctx.Done() } ``` ```python title="subscriber.py" import time from kubemq import ( PubSubClient, EventsStoreSubscription, EventStoreStartPosition, CancellationToken, ) def on_event(event): print(f"Received: seq={event.sequence} body={event.body.decode('utf-8')}") with PubSubClient(address="localhost:50000") as client: client.subscribe_to_events_store( subscription=EventsStoreSubscription( channel="orders.events", start_position=EventStoreStartPosition.StartFromFirst, on_receive_event_callback=on_event, on_error_callback=lambda e: print(f"Error: {e}"), ), cancel=CancellationToken(), ) print("Replaying order history...") time.sleep(120) ``` ```typescript title="subscriber.ts" import { KubeMQClient, EventStoreStartPosition } from 'kubemq-js'; const client = await KubeMQClient.create({ address: 'localhost:50000' }); client.subscribeToEventsStore({ channel: 'orders.events', startPosition: EventStoreStartPosition.StartFromFirst, onEvent: (msg) => console.log( `Received: seq=${msg.sequence} body=${new TextDecoder().decode(msg.body)}` ), onError: (err) => console.error('Error:', err.message), }); console.log('Replaying order history...'); ``` ```java title="Subscriber.java" PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId("order-subscriber") .build(); client.subscribeToEventsStore(EventsStoreSubscription.builder() .channel("orders.events") .startPosition(EventStoreStartPosition.StartFromFirst) .onReceiveEventCallback(event -> System.out.printf("Received: seq=%d body=%s%n", event.getSequence(), new String(event.getBody()))) .onErrorCallback(err -> System.err.println("Error: " + err.getMessage())) .build()); System.out.println("Replaying order history..."); Thread.sleep(120_000); client.close(); ``` ```csharp title="Subscriber.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); Console.WriteLine("Replaying order history..."); await foreach (var msg in client.SubscribeToEventsStoreAsync( new EventsStoreSubscription { Channel = "orders.events", StartPosition = EventStoreStartPosition.StartFromFirst, })) { Console.WriteLine($"Received: seq={msg.Sequence} " + $"body={Encoding.UTF8.GetString(msg.Body.Span)}"); } ``` ```kotlin title="Subscriber.kt" val client = KubeMQClient.pubSub { address = "localhost:50000" clientId = "order-subscriber" } client.use { val flow = client.subscribeToEventsStore { channel = "orders.events" startPosition = StartPosition.StartFromFirst } println("Replaying order history...") flow.collect { msg -> println("Received: seq=${msg.sequence} body=${String(msg.body)}") } } ``` ```cpp title="subscriber.cc" kubemq::ClientOptions options; options.set_address("localhost", 50000); options.set_client_id("order-subscriber"); auto client = kubemq::Client::Create(options).value(); std::cout << "Replaying order history..." << std::endl; client->SubscribeToEventsStore( "orders.events", "", kubemq::StartPosition::StartFromFirst, [](const kubemq::EventStoreReceived& msg) { std::cout << "Received: seq=" << msg.sequence() << " body=" << msg.body() << std::endl; }, [](const std::string& err) { std::cerr << "Error: " << err << std::endl; }); ``` ```rust title="subscriber.rs" use kubemq::prelude::*; use kubemq::EventsStoreSubscription; use std::time::Duration; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; println!("Replaying order history..."); let sub = client .subscribe_to_events_store( "orders.events", "", EventsStoreSubscription::StartFromFirst, |event| { Box::pin(async move { println!( "Received: seq={} body={}", event.sequence, String::from_utf8_lossy(&event.body) ); }) }, None, ) .await?; tokio::time::sleep(Duration::from_secs(120)).await; sub.unsubscribe().await; client.close().await?; Ok(()) } ``` ```ruby title="subscriber.rb" require 'kubemq' client = KubeMQ::PubSubClient.new(address: 'localhost:50000', client_id: 'order-subscriber') cancel = KubeMQ::CancellationToken.new sub = KubeMQ::PubSub::EventsStoreSubscription.new( channel: 'orders.events', start_position: KubeMQ::PubSub::EventStoreStartPosition::START_FROM_FIRST ) client.subscribe_to_events_store(sub, cancellation_token: cancel, on_error: lambda { |e| puts "Error: #{e.message}" }) do |event| puts "Received: seq=#{event.sequence} body=#{event.body}" end puts 'Replaying order history...' sleep 120 cancel.cancel client.close ``` ```elixir title="subscriber.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-subscriber") IO.puts("Replaying order history...") {:ok, _sub} = KubeMQ.Client.subscribe_to_events_store(client, "orders.events", start_at: :start_from_first, on_event: fn event -> IO.puts("Received: seq=#{event.sequence} body=#{event.body}") end ) Process.sleep(120_000) KubeMQ.Client.close(client) ``` ### Verify the Output [#verify-the-output] The subscriber receives all 3 previously published events, then continues waiting for new ones: ```text Replaying order history... Received: seq=1 body={"action":"order.created","orderId":"ORD-1001","total":149.99} Received: seq=2 body={"action":"order.paid","orderId":"ORD-1001","method":"credit_card"} Received: seq=3 body={"action":"order.shipped","orderId":"ORD-1001","carrier":"fedex"} ``` Any new events published after the subscriber connects are delivered in real time. ## Understanding What Happened [#understanding-what-happened] *The store assigns each event a sequence number; a `StartFromFirst` subscriber replays the full history, then receives live events.* 1. The **publisher** stored 3 events in the `orders.events` channel 2. Each event received a **sequence number** (1, 2, 3) and a **server timestamp** 3. The **subscriber** connected later and requested `StartFromFirst` 4. KubeMQ replayed the entire history, then continues delivering new events 5. The subscription is **durable** — if the subscriber disconnects and reconnects with the same client ID and group, it resumes from the last delivered sequence With plain [Events](/learn/events), the subscriber would have received nothing because Events are not persisted. Events Store is essential when subscribers must not miss messages. ## What's Next [#whats-next] # Events Store — Persistent Pub/Sub (/learn/events-store) Think of Events Store as a **DVR for your messages**. Just like a DVR records live TV so you can watch it later, Events Store records every event published to a channel. Subscribers can rewind to the beginning, fast-forward to a specific point, or jump in live — all from the same persistent stream. KubeMQ Events Store implements persistent publish/subscribe with at-least-once delivery. Publishers send messages to a named channel; KubeMQ writes each event to disk with a monotonically increasing sequence number, and subscribers choose a start position to receive historical events, new events, or both. Because events are durable, a subscriber that was offline at publish time can still replay everything it missed. ## The concept it implements [#the-concept-it-implements] Events Store is KubeMQ's implementation of two fundamental ideas. The interaction style is **[pub/sub](/learn/concepts/interaction-styles)** (fan-out) — one publisher, many subscribers — extended with durable storage so subscribers can **[replay from any position](/learn/concepts/ordering-and-replay)**. The delivery guarantee is **[at-least-once](/learn/concepts/delivery-guarantees)** — events are persisted and durable subscriptions track their position, so a message is redelivered until the subscriber has processed it. New to these terms? Start with the [Fundamentals](/learn/concepts) track. ## Key Properties [#key-properties] | Property | This pattern | Learn the concept | | ------------------ | ----------------------------------------------------- | ---------------------------------------------------------- | | Interaction style | pub/sub (fan-out) with replay | [Interaction styles](/learn/concepts/interaction-styles) | | Delivery guarantee | at-least-once | [Delivery guarantees](/learn/concepts/delivery-guarantees) | | Persistence | Disk-backed — survives restarts | [Ordering & replay](/learn/concepts/ordering-and-replay) | | Ordering | Sequenced per channel (sequence number + timestamp) | [Ordering & replay](/learn/concepts/ordering-and-replay) | | Scaling | Fan-out, or load-balance with durable consumer groups | [Scaling & flow](/learn/concepts/scaling-and-flow) | | Addressing | Named channels | [Channels & routing](/learn/concepts/channels-and-routing) | ## Key Features [#key-features] * **Persistent storage** — events are written to disk and survive server restarts * **Replay from any point** — subscribe from the first message, last message, a specific sequence number, an absolute timestamp, or a relative time delta * **Durable subscriptions** — subscribers resume from their last position after reconnecting * **Consumer groups** — distribute event processing across multiple consumers with automatic position tracking * **Sequenced messages** — every stored event receives a monotonically increasing sequence number and a server timestamp * **Stream publishing** — high-throughput bidirectional streaming with per-event acknowledgment ## How It Works [#how-it-works] *A publisher persists events to a durable channel; a live subscriber streams new events while a late subscriber replays missed history from a chosen offset.* 1. A **publisher** sends an event to a named channel with persistence enabled 2. KubeMQ writes the event to the **store** on disk 3. Each event receives a **sequence number** and **timestamp** 4. **Subscribers** connect and specify a start position — they receive historical and/or new events based on that position 5. **Durable subscriptions** track the subscriber's position so reconnections resume automatically For fire-and-forget pub/sub without persistence, use [Events](/learn/events) instead. ## Quick Example [#quick-example] ```go title="publish_store.go" ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() result, err := client.SendEventStore(ctx, kubemq.NewEvent(). SetChannel("orders.events"). SetBody([]byte(`{"action":"order.created","orderId":"ORD-1001"}`)), ) if err != nil { log.Fatal(err) } log.Printf("Event stored: ID=%s, Sent=%v", result.EventID, result.Sent) ``` ```python title="publish_store.py" from kubemq import PubSubClient, EventStoreMessage with PubSubClient(address="localhost:50000") as client: result = client.publish_event_store( EventStoreMessage( channel="orders.events", body=b'{"action":"order.created","orderId":"ORD-1001"}', ) ) print(f"Event stored: ID={result.id}, Sent={result.sent}") ``` ```typescript title="publish_store.ts" import { KubeMQClient, createEventStoreMessage } from 'kubemq-js'; const client = await KubeMQClient.create({ address: 'localhost:50000' }); const result = await client.sendEventStore( createEventStoreMessage({ channel: 'orders.events', body: '{"action":"order.created","orderId":"ORD-1001"}', }) ); console.log(`Event stored: ID=${result.id}, Sent=${result.sent}`); ``` ```java title="PublishStore.java" PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId("order-publisher") .build(); EventSendResult result = client.sendEventsStoreMessage( EventStoreMessage.builder() .channel("orders.events") .body("{\"action\":\"order.created\",\"orderId\":\"ORD-1001\"}".getBytes()) .build()); System.out.println("Event stored: " + result.getId()); client.close(); ``` ```csharp title="PublishStore.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); var result = await client.SendEventStoreAsync(new EventStoreMessage { Channel = "orders.events", Body = Encoding.UTF8.GetBytes( "{\"action\":\"order.created\",\"orderId\":\"ORD-1001\"}") }); Console.WriteLine($"Event stored: ID={result.Id}, Sent={result.Sent}"); ``` ```kotlin title="PublishStore.kt" val client = KubeMQClient.pubSub { address = "localhost:50000" clientId = "order-publisher" } client.use { val result = client.sendEventStore(eventStoreMessage { channel = "orders.events" body = """{"action":"order.created","orderId":"ORD-1001"}""".toByteArray() }) println("Event stored: ID=${result.id}, Sent=${result.sent}") } ``` ```cpp title="publish_store.cc" kubemq::ClientOptions options; options.set_address("localhost", 50000); options.set_client_id("order-publisher"); auto client = kubemq::Client::Create(options).value(); kubemq::EventStoreMessage msg; msg.set_channel("orders.events"); msg.set_body(R"({"action":"order.created","orderId":"ORD-1001"})"); auto result = client->SendEventStore(msg); if (result.ok()) { std::cout << "Event stored: " << result->id() << std::endl; } ``` ```rust title="publish_store.rs" use kubemq::prelude::*; use kubemq::EventStoreBuilder; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; let event = EventStoreBuilder::new() .channel("orders.events") .body(br#"{"action":"order.created","orderId":"ORD-1001"}"#.to_vec()) .build(); let result = client.send_event_store(event).await?; println!("Event stored: id={}, sent={}", result.id, result.sent); client.close().await?; Ok(()) } ``` ```ruby title="publish_store.rb" require 'kubemq' client = KubeMQ::PubSubClient.new(address: 'localhost:50000', client_id: 'order-publisher') msg = KubeMQ::PubSub::EventStoreMessage.new( channel: 'orders.events', body: '{"action":"order.created","orderId":"ORD-1001"}' ) result = client.send_event_store(msg) puts "Event stored: id=#{result.id}, sent=#{result.sent}" client.close ``` ```elixir title="publish_store.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-publisher") event = KubeMQ.EventStore.new( channel: "orders.events", body: ~s({"action":"order.created","orderId":"ORD-1001"}) ) {:ok, result} = KubeMQ.Client.send_event_store(client, event) IO.puts("Event stored: sent=#{result.sent}") KubeMQ.Client.close(client) ``` ## Subscription Start Positions [#subscription-start-positions] | Position | Description | Use Case | | ------------------ | ------------------------------------------- | ----------------------------------- | | `StartNewOnly` | Only new events published after subscribing | Live monitoring, real-time alerting | | `StartFromFirst` | Replay all events from the beginning | State rebuild, full audit replay | | `StartFromLast` | Start from the last stored event, then new | Resume from most recent | | `StartAtSequence` | Start from a specific sequence number | Checkpoint-based recovery | | `StartAtTime` | Start from a specific timestamp | Point-in-time recovery | | `StartAtTimeDelta` | Start from N seconds ago | Recent history replay | ## When to Use Events Store [#when-to-use-events-store] | Scenario | Events | Events Store | | ---------------------------- | -------------------------------------- | ----------------------------- | | Audit trails | ❌ Messages can be lost | ✅ Best choice | | Event sourcing | ❌ No persistence | ✅ Best choice | | Late or offline subscribers | ❌ Miss everything published while away | ✅ Replay missed history | | Real-time notifications | ✅ Best choice | Overkill | | Live dashboards (no history) | ✅ Best choice | Use if historical data needed | | Wildcard subscriptions | ✅ Supported | ❌ Not supported | **When not to use Events Store:** if you never need replay, persistence, or guaranteed delivery — fire-and-forget [Events](/learn/events) have lower latency and support wildcard subscriptions. If a message must be processed by exactly one of several competing workers (work distribution, not fan-out), reach for [Queues](/learn/queues) instead. ### Events vs Events Store at a glance [#events-vs-events-store-at-a-glance] | Feature | Events | Events Store | | ---------------------- | --------------- | ----------------------- | | Persistence | No | Yes (disk-backed) | | Replay | No | Yes (6 start positions) | | Delivery guarantee | at-most-once | at-least-once | | Wildcard subscriptions | Yes | No | | Consumer groups | Yes (ephemeral) | Yes (durable) | | Sequence numbers | No | Yes | | Timestamps | No | Yes (server-assigned) | | Latency | Lowest | Slightly higher | Events Store is also available via the [CloudEvents protocol](/connectors/cloudevents/how-to/events-store) — use any language with a CloudEvents SDK, no KubeMQ client library needed. ## Learn More [#learn-more] # Events Store Reference (/learn/events-store/reference) This reference documents every aspect of the KubeMQ Events Store messaging pattern. ## Message Structure [#message-structure] ### Event Store Message (Publish) [#event-store-message-publish] ### Event Store Receive (Subscription Callback) [#event-store-receive-subscription-callback] The `Sequence` and `Timestamp` fields are assigned by the server at persistence time and are not set by the publisher. They are available only on received events. ### Send Result [#send-result] ## Subscription Start Positions [#subscription-start-positions] ## Subscribe Request [#subscribe-request] ## Channel Naming Rules [#channel-naming-rules] | Rule | Constraint | Error Code | | --------------- | ------------------------- | ---------- | | Required | Cannot be empty | 102 | | No trailing dot | Cannot end with `.` | 119 | | No whitespace | Cannot contain spaces | 108 | | No wildcards | Cannot contain `*` or `>` | 107 | **Valid channel regex:** `^[^\s*>]+[^.]$` Events Store does **not** support wildcard subscriptions. Both publish and subscribe channel names must be exact. For wildcard support, use plain [Events](/learn/events). ## Durable Subscriptions [#durable-subscriptions] Every Events Store subscription creates a durable name: ```text DurableName = "{channel}-{group}" ``` * On first connection, the `EventsStoreTypeData` determines the start position * On subsequent connections with the same durable name, the start position is **ignored** — the subscription resumes from the last tracked position * To force a fresh replay, use a different `group` name ## Consumer Groups [#consumer-groups] When multiple subscribers specify the same `Group` on the same channel: * Each event is delivered to exactly **one** member (round-robin) * The group's position is tracked durably * Adding or removing members rebalances delivery automatically * Groups are independent per channel ## Routing (Multicast) [#routing-multicast] | Character | Purpose | Example | | --------- | ---------------------------------- | ------------------------------------------------- | | `;` | Separate channels of the same type | `audit;compliance` | | `:` | Specify target pattern type | `events_store:audit;events:notify;queues:process` | For Events Store publish, channels without a prefix default to `events_store`. Routed messages are tagged with `X-KUBEMQ-ROUTED=true`. ## Storage Configuration [#storage-configuration] ## File Store Configuration [#file-store-configuration] ## Storage Utilization Thresholds [#storage-utilization-thresholds] | Utilization | Level | Polling Interval | Publishing | | ----------- | -------- | ---------------- | ------------------------- | | 0-80% | Normal | 5 seconds | Allowed | | 80-90% | Warning | 3 seconds | Allowed (warnings logged) | | 90-95% | Critical | 2 seconds | Allowed (errors logged) | | Above 95% | Disabled | 1 second | **Blocked** | Publishing automatically resumes when utilization drops below 95%. ## Transport Protocols [#transport-protocols] ### gRPC [#grpc] * **Publish:** `SendEvent(pb.Event)` with `Store=true` * **Publish Stream:** `SendEventsStream()` — bidirectional streaming, result sent for every store event * **Subscribe:** `SubscribeToEvents(pb.Subscribe)` with `SubscribeType=EventsStore` * Default port: `50000` ### REST [#rest] * **Publish:** `POST /send` with `isEvents=false` * **Subscribe:** WebSocket with `subscribe_type=events_store` * Default port: `9090` ## Delivery Semantics [#delivery-semantics] | Aspect | Behavior | | ------------------ | ----------------------------------------------------- | | Delivery guarantee | **At-least-once** | | Persistence | Disk-backed file store | | Ordering | Events delivered in sequence order per channel | | Sequence numbers | Monotonically increasing, starting from 1 per channel | | Acknowledgment | Asynchronous publish ack | | Durable names | Position tracked across reconnections | | Replay | Yes (6 start positions) | ## Events Store vs Events [#events-store-vs-events] | Feature | Events Store | Events | | -------------------------- | ---------------------------- | ---------------- | | Persistence | Yes (disk-backed) | No (memory only) | | Replay | Yes (6 start positions) | No | | Delivery guarantee | At-least-once | At-most-once | | Wildcard subscriptions | No | Yes (`*`, `>`) | | Consumer groups | Durable | Ephemeral | | Sequence numbers | Yes | No | | Server timestamps | Yes | No | | Latency | Slightly higher (disk write) | Lowest | | Storage utilization limits | Yes (80/90/95% thresholds) | No | ## SDK Quick Reference [#sdk-quick-reference] ```go client.SendEventStore(ctx, event) client.SendEventsStoreStream(ctx, opts...) client.SubscribeToEventsStore(ctx, channel, group, startPos, opts...) ``` ```python client.publish_event_store(message) client.open_events_store_stream(on_result, on_error) client.subscribe_to_events_store(subscription, cancel) ``` ```typescript client.sendEventStore(message) client.sendEventsStoreStream(opts) client.subscribeToEventsStore(opts) ``` ```java client.sendEventsStoreMessage(message) client.openEventsStoreStream(onResult, onError) client.subscribeToEventsStore(subscription) ``` ```csharp client.SendEventStoreAsync(message) client.OpenEventsStoreStream(onResult, onError) client.SubscribeToEventsStoreAsync(subscription) ``` ```kotlin client.sendEventStore(message) client.openEventsStoreStream(onResult, onError) client.subscribeToEventsStore { ... } ``` ```cpp client->SendEventStore(message) client->OpenEventsStoreStream(onResult, onError) client->SubscribeToEventsStore(channel, group, startPos, onEvent, onError) ``` ```rust client.send_event_store(event).await client.send_event_store_stream().await // returns a stream; stream.send(event) client.subscribe_to_events_store(channel, group, EventsStoreSubscription::StartFromFirst, on_event, None).await ``` ```ruby client.send_event_store(msg) client.create_events_store_sender # sender.publish(msg) client.subscribe_to_events_store(subscription, cancellation_token:, on_error:) { |event| ... } ``` ```elixir KubeMQ.Client.send_event_store(client, event) KubeMQ.Client.send_event_store(client, event) # stream-style: send in rapid succession KubeMQ.Client.subscribe_to_events_store(client, channel, start_at: :start_new_only, on_event: fn event -> ... end) ``` ## Error Codes [#error-codes] ### Validation Errors [#validation-errors] | Code | Error | Description | | ---- | ------------------------- | ------------------------------------------------------------------ | | 101 | Invalid ClientID | ClientID is empty | | 102 | Invalid Channel | Channel is empty | | 107 | Invalid Wildcards | Channel contains `*` or `>` | | 108 | Invalid Whitespace | Channel contains spaces | | 110 | Invalid Message | Both `Body` and `Metadata` are empty | | 111 | Invalid Subscription Type | `EventsStoreTypeData` is `Undefined` (0) | | 112 | Invalid Sequence Value | `StartAtSequence` value is 0 or negative | | 113 | Invalid Time Value | `StartAtTime` value is 0 or negative | | 114 | Invalid Time Delta Value | `StartAtTimeDelta` value is 0 or negative | | 118 | Wrong Subscribe Type | Event store parameters set but subscribe type is not events\_store | | 119 | Invalid Channel Separator | Channel ends with `.` | ### Runtime Errors [#runtime-errors] | Error | Cause | Resolution | | ---------------------------- | ------------------------------------ | -------------------------------------------- | | `ErrShutdownMode` | Server is shutting down | Reconnect after server restart | | `ErrConnectionNoAvailable` | Connection is down | SDK auto-reconnects; check server health | | Storage utilization exceeded | Disk usage above 95% | Free disk space or adjust retention settings | | Authorization denied | Casbin policy rejected the operation | Verify client permissions | ## Related [#related] * [Getting Started with Events Store](/learn/events-store/getting-started) * [Events Reference](/learn/events/reference) for the non-persistent variant * Core Concepts for foundational KubeMQ concepts # Channel Management (/learn/guides/channel-management) For the conceptual model behind channels and how messages reach subscribers, see [Channels & Routing](/learn/concepts/channels-and-routing) in Fundamentals. ## Overview [#overview] KubeMQ channels are created implicitly when a message is first published or subscribed to. However, you can also manage channels explicitly — creating, listing, deleting, and purging them through the SDK. ## Create Channel [#create-channel] Create a channel before publishing to pre-register it with the server. The channel type must be specified: `events`, `events_store`, or `queues`. ```go title="create_channel.go" package main import ( "context" "log" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() err = client.CreateChannel(ctx, "queues", "order-processing") if err != nil { log.Fatal(err) } log.Println("Channel created: order-processing") } ``` ```python title="create_channel.py" from kubemq.queues import Client as QueuesClient client = QueuesClient(address="localhost:50000") client.create_channel("order-processing") print("Channel created: order-processing") client.close() ``` ```javascript title="create_channel.js" const { KubeMQClient } = require("kubemq-js"); const client = new KubeMQClient({ address: "localhost:50000" }); await client.createChannel("queues", "order-processing"); console.log("Channel created: order-processing"); ``` ```java title="CreateChannel.java" QueuesClient client = QueuesClient.builder() .address("localhost:50000") .clientId("admin-client") .build(); client.createChannel("order-processing"); System.out.println("Channel created: order-processing"); client.close(); ``` ```csharp title="CreateChannel.cs" await using var client = new KubeMQClient(new KubeMQClientOptions { Address = "localhost:50000", }); await client.ConnectAsync(); await client.CreateChannelAsync("queues", "order-processing"); Console.WriteLine("Channel created: order-processing"); ``` ```kotlin title="CreateChannel.kt" val client = KubeMQClient.queues { address = "localhost:50000" clientId = "admin-client" } client.createChannel("order-processing") println("Channel created: order-processing") client.close() ``` ```cpp title="create_channel.cpp" #include #include kubemq::QueuesClient client("localhost:50000"); client.createChannel("order-processing"); std::cout << "Channel created: order-processing" << std::endl; ``` ```rust title="create_channel.rs" use kubemq::prelude::*; let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; client.create_queues_channel("order-processing").await?; println!("Channel created: order-processing"); ``` ```ruby title="create_channel.rb" require "kubemq" client = KubeMQ::QueuesClient.new(address: "localhost:50000", client_id: "admin-client") client.create_queues_channel(channel_name: "order-processing") puts "Channel created: order-processing" client.close ``` ```elixir title="create_channel.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "admin-client") :ok = KubeMQ.Client.create_channel(client, "order-processing", :queues) IO.puts("Channel created: order-processing") KubeMQ.Client.close(client) ``` ## Delete Channel [#delete-channel] Remove a channel and its configuration from the server. Messages in the channel are discarded. Deleting a channel is irreversible. All pending messages in a queue channel are lost. Active subscriptions on the channel will receive an error. ```go title="delete_channel.go" err = client.DeleteChannel(ctx, "queues", "order-processing") if err != nil { log.Fatal(err) } log.Println("Channel deleted: order-processing") ``` ```python title="delete_channel.py" client.delete_channel("order-processing") print("Channel deleted: order-processing") ``` ```javascript title="delete_channel.js" await client.deleteChannel("queues", "order-processing"); console.log("Channel deleted: order-processing"); ``` ```java title="DeleteChannel.java" client.deleteChannel("order-processing"); System.out.println("Channel deleted: order-processing"); ``` ```csharp title="DeleteChannel.cs" await client.DeleteChannelAsync("queues", "order-processing"); Console.WriteLine("Channel deleted: order-processing"); ``` ```kotlin title="DeleteChannel.kt" client.deleteChannel("order-processing") println("Channel deleted: order-processing") ``` ```cpp title="delete_channel.cpp" client.deleteChannel("order-processing"); std::cout << "Channel deleted: order-processing" << std::endl; ``` ```rust title="delete_channel.rs" client.delete_queues_channel("order-processing").await?; println!("Channel deleted: order-processing"); ``` ```ruby title="delete_channel.rb" client.delete_queues_channel(channel_name: "order-processing") puts "Channel deleted: order-processing" ``` ```elixir title="delete_channel.exs" :ok = KubeMQ.Client.delete_channel(client, "order-processing", :queues) IO.puts("Channel deleted: order-processing") ``` ## List Channels [#list-channels] Query the server for all active channels, optionally filtering by channel type or name pattern. ```go title="list_channels.go" channels, err := client.ListChannels(ctx, "queues", "order") if err != nil { log.Fatal(err) } for _, ch := range channels { log.Printf("Channel: %s | Type: %s | Subscribers: %d | Messages: %d", ch.Name, ch.Type, ch.LastActivity, ch.Incoming) } ``` ```python title="list_channels.py" channels = client.list_channels("order") for ch in channels: print(f"Channel: {ch.name} | Type: {ch.type} | Messages: {ch.incoming}") ``` ```javascript title="list_channels.js" const channels = await client.listChannels("queues", "order"); for (const ch of channels) { console.log(`Channel: ${ch.name} | Type: ${ch.type} | Messages: ${ch.incoming}`); } ``` ```java title="ListChannels.java" List channels = client.listChannels("order"); for (ChannelInfo ch : channels) { System.out.printf("Channel: %s | Type: %s | Messages: %d%n", ch.getName(), ch.getType(), ch.getIncoming()); } ``` ```csharp title="ListChannels.cs" var channels = await client.ListChannelsAsync("queues", "order"); foreach (var ch in channels) { Console.WriteLine($"Channel: {ch.Name} | Type: {ch.Type} | Messages: {ch.Incoming}"); } ``` ```kotlin title="ListChannels.kt" val channels = client.listChannels("order") for (ch in channels) { println("Channel: ${ch.name} | Type: ${ch.type} | Messages: ${ch.incoming}") } ``` ```cpp title="list_channels.cpp" auto channels = client.listChannels("order"); for (const auto& ch : channels) { std::cout << "Channel: " << ch.name << " | Type: " << ch.type << " | Messages: " << ch.incoming << std::endl; } ``` ```rust title="list_channels.rs" use kubemq::channel_type; let channels = client.list_channels(channel_type::QUEUES, "order").await?; for ch in &channels { println!("Channel: {} | Active: {} | Last activity: {}", ch.name, ch.is_active, ch.last_activity); } ``` ```ruby title="list_channels.rb" channels = client.list_queues_channels(search: "order") channels.each do |ch| puts "Channel: #{ch.name} | Active: #{ch.is_active} | Messages: #{ch.incoming}" end ``` ```elixir title="list_channels.exs" {:ok, channels} = KubeMQ.Client.list_queues_channels(client, "order") Enum.each(channels, fn ch -> IO.puts("Channel: #{ch.name} | Active: #{ch.is_active} | Messages: #{ch.incoming}") end) ``` ## Purge Queue [#purge-queue] Remove all pending messages from a queue channel without deleting the channel itself. This is useful for clearing a backlog during development or after recovering from a failure. Purge permanently removes all messages from the queue. This cannot be undone. ```go title="purge_queue.go" resp, err := client.PurgeQueue(ctx, "order-processing") if err != nil { log.Fatal(err) } log.Printf("Purged %d messages from order-processing", resp.MessagesCount) ``` ```python title="purge_queue.py" result = client.purge_queue("order-processing") print(f"Purged {result.messages_count} messages from order-processing") ``` ```javascript title="purge_queue.js" const result = await client.purgeQueue("order-processing"); console.log(`Purged ${result.messagesCount} messages from order-processing`); ``` ```java title="PurgeQueue.java" PurgeResult result = client.purgeQueue("order-processing"); System.out.printf("Purged %d messages from order-processing%n", result.getMessagesCount()); ``` ```csharp title="PurgeQueue.cs" var result = await client.PurgeQueueAsync("order-processing"); Console.WriteLine($"Purged {result.MessagesCount} messages from order-processing"); ``` ```kotlin title="PurgeQueue.kt" val result = client.purgeQueue("order-processing") println("Purged ${result.messagesCount} messages from order-processing") ``` ```cpp title="purge_queue.cpp" auto result = client.purgeQueue("order-processing"); std::cout << "Purged " << result.messagesCount << " messages from order-processing" << std::endl; ``` ```rust title="purge_queue.rs" // The Rust SDK purges a queue by acking all pending messages. client.ack_all_queue_messages("order-processing").await?; println!("Purged all messages from order-processing"); ``` ```ruby title="purge_queue.rb" client.purge_queue_channel(channel_name: "order-processing") puts "Purged all messages from order-processing" ``` ```elixir title="purge_queue.exs" :ok = KubeMQ.Client.purge_queue_channel(client, "order-processing") IO.puts("Purged all messages from order-processing") ``` ## Key Points [#key-points] * **Implicit creation** — channels are automatically created on first use (publish or subscribe) * **Explicit management** — use the SDK for administrative operations like cleanup or provisioning * **Type-scoped** — create and list operations require specifying the channel type (`events`, `events_store`, or `queues`) * **Purge is queue-only** — only queue channels support purging; events and events\_store channels have different lifecycle semantics ## Next Steps [#next-steps] # Channel Routing (/learn/guides/channel-routing) New to channel naming and fan-out? Start with [Channels & Routing](/learn/concepts/channels-and-routing) in Fundamentals to understand how channels and patterns work before applying the routing syntax below. ## Routing Syntax [#routing-syntax] KubeMQ supports publishing to multiple channels and patterns in a single operation using a routing syntax built into the channel name: * **`;`** separates multiple channel targets * **`:`** prefixes the pattern type (`events`, `events_store`, `queues`) | Syntax | Meaning | | ----------------------------------- | ------------------------------- | | `events:channel-a;events:channel-b` | Two event channels | | `events:live;events_store:archive` | Event + persistent copy | | `events:notify;queues:process` | Broadcast + reliable work queue | | `events:a;events_store:b;queues:c` | All three patterns | Routing is transparent to subscribers — each target channel receives the message as if it were published directly. ## Examples [#examples] ### Events + Events Store [#events--events-store] Publish an order event to a live channel and persist a copy to an archive channel in a single call. ```go title="route_events_store.go" package main import ( "context" "log" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), kubemq.WithClientId("order-router"), ) if err != nil { log.Fatal(err) } defer client.Close() err = client.SendEvent(ctx, kubemq.NewEvent(). SetChannel("events:order-live;events_store:order-archive"). SetMetadata("order.created"). SetBody([]byte(`{"orderId":"ORD-100","amount":59.99}`)), ) if err != nil { log.Fatal(err) } log.Println("Routed to events + events_store") } ``` ```python title="route_events_store.py" from kubemq.pubsub import Client as PubSubClient, EventMessage client = PubSubClient(address="localhost:50000", client_id="order-router") client.send_event( EventMessage( channel="events:order-live;events_store:order-archive", metadata="order.created", body=b'{"orderId":"ORD-100","amount":59.99}', ) ) print("Routed to events + events_store") client.close() ``` ```javascript title="route_events_store.js" const { KubeMQClient } = require("kubemq-js"); const client = new KubeMQClient({ address: "localhost:50000", clientId: "order-router" }); await client.sendEvent({ channel: "events:order-live;events_store:order-archive", metadata: "order.created", body: Buffer.from('{"orderId":"ORD-100","amount":59.99}'), }); console.log("Routed to events + events_store"); ``` ```java title="RouteEventsStore.java" PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId("order-router") .build(); client.sendEventsMessage(EventMessage.builder() .channel("events:order-live;events_store:order-archive") .metadata("order.created") .body("{\"orderId\":\"ORD-100\",\"amount\":59.99}".getBytes()) .build()); System.out.println("Routed to events + events_store"); client.close(); ``` ```csharp title="RouteEventsStore.cs" await using var client = new KubeMQClient(new KubeMQClientOptions { Address = "localhost:50000", ClientId = "order-router" }); await client.ConnectAsync(); await client.SendEventAsync(new EventMessage { Channel = "events:order-live;events_store:order-archive", Metadata = "order.created", Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-100\",\"amount\":59.99}"), }); Console.WriteLine("Routed to events + events_store"); ``` ```kotlin title="RouteEventsStore.kt" val client = PubSubClient("localhost:50000") client.sendEvent(EventMessage( channel = "events:order-live;events_store:order-archive", metadata = "order.created", body = """{"orderId":"ORD-100","amount":59.99}""".toByteArray(), )) println("Routed to events + events_store") client.close() ``` ```cpp title="route_events_store.cpp" #include #include auto client = kubemq::PubSubClient("localhost:50000"); kubemq::EventMessage event; event.channel = "events:order-live;events_store:order-archive"; event.metadata = "order.created"; event.body = R"({"orderId":"ORD-100","amount":59.99})"; client.sendEvent(event); std::cout << "Routed to events + events_store" << std::endl; ``` ```rust title="route_events_store.rs" use kubemq::prelude::*; use kubemq::EventBuilder; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .client_id("order-router") .build() .await?; let event = EventBuilder::new() .channel("events:order-live;events_store:order-archive") .metadata("order.created") .body(br#"{"orderId":"ORD-100","amount":59.99}"#.to_vec()) .build(); client.send_event(event).await?; println!("Routed to events + events_store"); client.close().await?; Ok(()) } ``` ```ruby title="route_events_store.rb" require 'kubemq' client = KubeMQ::PubSubClient.new(address: 'localhost:50000', client_id: 'order-router') msg = KubeMQ::PubSub::EventMessage.new( channel: 'events:order-live;events_store:order-archive', metadata: 'order.created', body: '{"orderId":"ORD-100","amount":59.99}' ) client.send_event(msg) puts 'Routed to events + events_store' client.close ``` ```elixir title="route_events_store.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-router") event = KubeMQ.Event.new( channel: "events:order-live;events_store:order-archive", metadata: "order.created", body: ~s({"orderId":"ORD-100","amount":59.99}) ) :ok = KubeMQ.Client.send_event(client, event) IO.puts("Routed to events + events_store") KubeMQ.Client.close(client) ``` ### Events + Queue [#events--queue] Broadcast an event for real-time subscribers and queue a copy for reliable processing. ```go title="route_events_queue.go" err = client.SendEvent(ctx, kubemq.NewEvent(). SetChannel("events:order-notify;queues:order-process"). SetMetadata("order.created"). SetBody([]byte(`{"orderId":"ORD-200","amount":129.00}`)), ) ``` ```python title="route_events_queue.py" client.send_event( EventMessage( channel="events:order-notify;queues:order-process", metadata="order.created", body=b'{"orderId":"ORD-200","amount":129.00}', ) ) ``` ```javascript title="route_events_queue.js" await client.sendEvent({ channel: "events:order-notify;queues:order-process", metadata: "order.created", body: Buffer.from('{"orderId":"ORD-200","amount":129.00}'), }); ``` ```java title="RouteEventsQueue.java" client.sendEventsMessage(EventMessage.builder() .channel("events:order-notify;queues:order-process") .metadata("order.created") .body("{\"orderId\":\"ORD-200\",\"amount\":129.00}".getBytes()) .build()); ``` ```csharp title="RouteEventsQueue.cs" await client.SendEventAsync(new EventMessage { Channel = "events:order-notify;queues:order-process", Metadata = "order.created", Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-200\",\"amount\":129.00}"), }); ``` ```kotlin title="RouteEventsQueue.kt" client.sendEvent(EventMessage( channel = "events:order-notify;queues:order-process", metadata = "order.created", body = """{"orderId":"ORD-200","amount":129.00}""".toByteArray(), )) ``` ```cpp title="route_events_queue.cpp" kubemq::EventMessage event; event.channel = "events:order-notify;queues:order-process"; event.metadata = "order.created"; event.body = R"({"orderId":"ORD-200","amount":129.00})"; client.sendEvent(event); ``` ```rust title="route_events_queue.rs" let event = EventBuilder::new() .channel("events:order-notify;queues:order-process") .metadata("order.created") .body(br#"{"orderId":"ORD-200","amount":129.00}"#.to_vec()) .build(); client.send_event(event).await?; ``` ```ruby title="route_events_queue.rb" msg = KubeMQ::PubSub::EventMessage.new( channel: 'events:order-notify;queues:order-process', metadata: 'order.created', body: '{"orderId":"ORD-200","amount":129.00}' ) client.send_event(msg) ``` ```elixir title="route_events_queue.exs" event = KubeMQ.Event.new( channel: "events:order-notify;queues:order-process", metadata: "order.created", body: ~s({"orderId":"ORD-200","amount":129.00}) ) :ok = KubeMQ.Client.send_event(client, event) ``` ### All Three Patterns [#all-three-patterns] Route a single publish to an event channel, persistent store, and a work queue simultaneously. ```go title="route_all.go" err = client.SendEvent(ctx, kubemq.NewEvent(). SetChannel("events:order-live;events_store:order-archive;queues:order-fulfill"). SetMetadata("order.created"). SetBody([]byte(`{"orderId":"ORD-300","amount":349.99}`)), ) ``` ```python title="route_all.py" client.send_event( EventMessage( channel="events:order-live;events_store:order-archive;queues:order-fulfill", metadata="order.created", body=b'{"orderId":"ORD-300","amount":349.99}', ) ) ``` ```javascript title="route_all.js" await client.sendEvent({ channel: "events:order-live;events_store:order-archive;queues:order-fulfill", metadata: "order.created", body: Buffer.from('{"orderId":"ORD-300","amount":349.99}'), }); ``` ```java title="RouteAll.java" client.sendEventsMessage(EventMessage.builder() .channel("events:order-live;events_store:order-archive;queues:order-fulfill") .metadata("order.created") .body("{\"orderId\":\"ORD-300\",\"amount\":349.99}".getBytes()) .build()); ``` ```csharp title="RouteAll.cs" await client.SendEventAsync(new EventMessage { Channel = "events:order-live;events_store:order-archive;queues:order-fulfill", Metadata = "order.created", Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-300\",\"amount\":349.99}"), }); ``` ```kotlin title="RouteAll.kt" client.sendEvent(EventMessage( channel = "events:order-live;events_store:order-archive;queues:order-fulfill", metadata = "order.created", body = """{"orderId":"ORD-300","amount":349.99}""".toByteArray(), )) ``` ```cpp title="route_all.cpp" kubemq::EventMessage event; event.channel = "events:order-live;events_store:order-archive;queues:order-fulfill"; event.metadata = "order.created"; event.body = R"({"orderId":"ORD-300","amount":349.99})"; client.sendEvent(event); ``` ```rust title="route_all.rs" let event = EventBuilder::new() .channel("events:order-live;events_store:order-archive;queues:order-fulfill") .metadata("order.created") .body(br#"{"orderId":"ORD-300","amount":349.99}"#.to_vec()) .build(); client.send_event(event).await?; ``` ```ruby title="route_all.rb" msg = KubeMQ::PubSub::EventMessage.new( channel: 'events:order-live;events_store:order-archive;queues:order-fulfill', metadata: 'order.created', body: '{"orderId":"ORD-300","amount":349.99}' ) client.send_event(msg) ``` ```elixir title="route_all.exs" event = KubeMQ.Event.new( channel: "events:order-live;events_store:order-archive;queues:order-fulfill", metadata: "order.created", body: ~s({"orderId":"ORD-300","amount":349.99}) ) :ok = KubeMQ.Client.send_event(client, event) ``` ## How Routing Works Internally [#how-routing-works-internally] When the server receives a channel string containing `;` or `:` delimiters, it splits the targets and fans the message out to each pattern and channel independently. Each target is processed independently — a failure on one target does not affect the others. The server returns an error only if the routing syntax itself is invalid. ## Key Points [#key-points] * **Atomic fan-out** — a single SDK call routes to all targets without client-side loops * **Mixed patterns** — combine events, events\_store, and queues in any combination * **Independent delivery** — each target processes the message according to its own pattern semantics * **Subscriber transparency** — subscribers see messages as if published directly to their channel # Choosing a Messaging Pattern (/learn/guides/choosing-a-pattern) Every messaging pattern is a variation on one of three [interaction styles](/learn/concepts/interaction-styles) — pub/sub fan-out, point-to-point work distribution, or request/reply round-trips — tuned to a particular [delivery guarantee](/learn/concepts/delivery-guarantees). Choosing a pattern is really two questions: *what shape is the conversation*, and *how much can you afford to lose*. Answer those and the pattern falls out. ## Decision Guide [#decision-guide] Answer a few questions to find the right messaging pattern for your use case. ### Decision Flowchart [#decision-flowchart] The same logic as a flowchart. The first fork is the interaction style (does the sender wait for a reply?); the second is the delivery guarantee (can a message be lost?). *Decision flowchart: the first fork is whether the sender waits for a reply, the second is whether a message can be lost, and the leaves are the five patterns.* The two branches map straight onto the Fundamentals: * **The sender waits → request/reply.** [Commands](/learn/concepts/interaction-styles) and Queries are the there-and-back style. Commands carry a write that returns only success/failure; Queries carry a read that returns a full body. * **The sender hands off and moves on → pub/sub or point-to-point.** Now the [delivery guarantee](/learn/concepts/delivery-guarantees) decides: at-most-once fire-and-forget (Events), or persisted delivery you can replay (Events Store). If each message must be handled by exactly one worker, that is point-to-point work distribution (Queues). ## Pattern Comparison [#pattern-comparison] | Feature | Events | Events Store | Queues | Commands | Queries | | ----------------- | ------------ | ------------- | -------------- | ------------- | ------------- | | Interaction style | Pub/sub | Pub/sub | Point-to-point | Request/reply | Request/reply | | Delivery | At-most-once | At-least-once | Exactly-once | At-most-once | At-most-once | | Persistence | No | Yes | Yes | No | No | | Response | No | No | No | Executed only | Full body | | Ordering | No | Sequenced | FIFO | N/A | N/A | | Replay | No | 6 positions | No | No | No | | DLQ | No | No | Yes | No | No | | Caching | No | No | No | No | Yes | | Groups | Yes | Yes | N/A | Yes | Yes | ## When to Use Each Pattern [#when-to-use-each-pattern] ### Events — Real-Time Broadcasting [#events--real-time-broadcasting] Pub/sub fan-out with [at-most-once](/learn/concepts/delivery-guarantees) delivery. Best for real-time notifications, log streaming, live dashboards, and cache invalidation where occasional message loss is acceptable. **Trade-offs:** Fastest (no disk I/O), but messages are lost if no subscriber is connected — there is no acknowledgement and nothing to replay. *Events: the publisher fires once, every connected subscriber gets the message, and anyone offline simply misses it.* ### Events Store — Persistent Pub/Sub [#events-store--persistent-pubsub] Pub/sub fan-out with [at-least-once](/learn/concepts/delivery-guarantees) delivery, persisted to disk. Best for audit trails, event sourcing, cross-service state sync, and any scenario where messages must not be lost. **Trade-offs:** Higher latency than Events (disk I/O), but subscribers can replay from any point — a late subscriber can read the whole history. *Events Store: messages are persisted to disk as they are delivered live, so a subscriber that connects later can replay the full history.* ### Queues — Work Distribution [#queues--work-distribution] Point-to-point competing consumers with [exactly-once](/learn/concepts/delivery-guarantees) processing. Best for order processing, background jobs, webhook delivery, and any task where each message must be handled by exactly one worker. **Trade-offs:** Requires an explicit ack/nack, but in return you get the full reliability toolkit — DLQ, delayed delivery, visibility timeout, and retry. *Queues: one consumer receives the message and acknowledges it, and only then is the message removed from the queue.* ### RPC — Request/Reply [#rpc--requestreply] The there-and-back interaction style. Best for service-to-service communication, API gateways, CQRS, and device command/control. **Trade-offs:** Synchronous (sender blocks), but provides a direct response. Commands strip the response body (write semantics), Queries preserve it (read semantics). *RPC: the sender blocks while KubeMQ routes the request to a responder and returns the single response back.* ## Combining Patterns [#combining-patterns] Real systems rarely pick one pattern. The same message often fans out across several styles at once — broadcast for visibility, queue for reliable work. KubeMQ does this with [channel routing](/learn/concepts/channels-and-routing): one send, multiple destinations. ### Event-Driven with Work Queue [#event-driven-with-work-queue] Broadcast events for monitoring (pub/sub, loss OK), and route critical work to a queue for reliable point-to-point processing. *Event-driven with work queue: a single send fans out to a dashboard for monitoring and to a queue for reliable point-to-point processing.* ### CQRS with Events [#cqrs-with-events] Commands write data (request/reply), events propagate the change (pub/sub), queries read projections (request/reply). *CQRS with events: commands write through the event store, the read service subscribes to build projections, and clients query that read side.* ### Fan-Out with Routing [#fan-out-with-routing] Publish once, deliver to multiple patterns using channel routing syntax. | Syntax | Meaning | | ----------------------------------- | ------------------------- | | `events:channel-a;events:channel-b` | Two event channels | | `events:live;events_store:archive` | Event + persistent copy | | `events:notify;queues:process` | Broadcast + reliable work | ## Decision Matrix [#decision-matrix] | Requirement | Recommended Pattern | | --------------------------- | -------------------- | | Real-time notifications | Events | | Log/metric streaming | Events | | Cache invalidation | Events | | Audit trail | Events Store | | Event sourcing | Events Store | | Cross-service state sync | Events Store | | Order processing | Queues | | Background jobs | Queues | | Scheduled/delayed tasks | Queues | | Webhook delivery with retry | Queues | | Service-to-service calls | RPC Commands/Queries | | API gateway backend | RPC Commands/Queries | | Device command & control | RPC Commands | | Cached lookups | RPC Queries | # Connect with TLS & mTLS (/learn/guides/connect-with-tls) Securing connections applies across every messaging pattern. See the [Messaging Patterns Fundamentals](/learn/concepts) for the conceptual model these guides build on. ## Prerequisites [#prerequisites] * KubeMQ server configured with TLS certificates * CA certificate file (for TLS and mTLS) * Client certificate and key files (for mTLS only) Server-side TLS configuration is managed in the KubeMQ deployment. ## TLS Connection [#tls-connection] Server-side TLS encrypts all traffic between the client and KubeMQ. The client verifies the server's identity using a CA certificate, preventing man-in-the-middle attacks. ### Configure TLS [#configure-tls] ```go title="tls_connect.go" package main import ( "context" "fmt" "log" "time" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("kubemq.example.com", 50000), kubemq.WithClientId("secure-client"), kubemq.WithTLS("path/to/ca-cert.pem"), ) if err != nil { log.Fatalf("TLS connection failed: %v", err) } defer client.Close() info, err := client.Ping(ctx) if err != nil { log.Fatalf("Ping failed: %v", err) } fmt.Printf("TLS connected: host=%s version=%s\n", info.Host, info.Version) } ``` ```python title="tls_connect.py" from kubemq import TLSConfig from kubemq.pubsub import Client as PubSubClient tls_config = TLSConfig( enabled=True, ca_file="/path/to/ca.pem", ) with PubSubClient( address="kubemq.example.com:50000", client_id="secure-client", tls=tls_config, ) as client: info = client.ping() print(f"TLS connected: host={info.host}") ``` ```typescript title="tls_connect.ts" import { KubeMQClient, ConnectionError } from "kubemq-js"; try { const client = await KubeMQClient.create({ address: "kubemq.example.com:50000", clientId: "secure-client", tls: { enabled: true, caCert: "/path/to/ca-cert.pem", }, }); console.log("TLS connected:", client.state); await client.close(); } catch (err) { if (err instanceof ConnectionError) { console.error("TLS connection failed:", err.message); } } ``` ```java title="TlsConnect.java" QueuesClient client = QueuesClient.builder() .address("kubemq.example.com:50000") .clientId("secure-client") .tls(true) .caCertFile("/path/to/ca.pem") .build(); ServerInfo info = client.ping(); System.out.println("TLS connected: " + info); client.close(); ``` ```csharp title="TlsConnect.cs" await using var client = new KubeMQClient(new KubeMQClientOptions { Address = "kubemq.example.com:50000", ClientId = "secure-client", Tls = new TlsOptions { Enabled = true, CaFile = "/path/to/ca.pem", }, }); await client.ConnectAsync(); var info = await client.PingAsync(); Console.WriteLine($"TLS connected: {info}"); ``` ```kotlin title="TlsConnect.kt" val client = KubeMQClient.queues { address = "kubemq.example.com:50000" clientId = "secure-client" tls { caCertFile = "/path/to/ca.pem" } } client.use { val info = it.ping() println("TLS connected: host=${info.host}") } ``` ```cpp title="tls_connect.cpp" #include #include kubemq::ClientOptions opts; opts.address = "kubemq.example.com:50000"; opts.client_id = "secure-client"; opts.set_tls_config(kubemq::TlsConfig::FromCertFile("/path/to/ca-cert.pem")); kubemq::PubSubClient client(opts); auto info = client.ping(); std::cout << "TLS connected: " << info.host << std::endl; ``` ```rust title="tls_connect.rs" use kubemq::prelude::*; use kubemq::TlsConfig; #[tokio::main] async fn main() -> kubemq::Result<()> { let tls = TlsConfig { ca_cert_file: Some("/path/to/ca.pem".to_string()), ..Default::default() }; let client = KubemqClient::builder() .host("kubemq.example.com") .port(50000) .tls_config(tls) .build() .await?; let info = client.ping().await?; println!("TLS connected. Server version: {}", info.version); client.close().await?; Ok(()) } ``` ```ruby title="tls_connect.rb" require 'kubemq' tls = KubeMQ::TLSConfig.new(enabled: true, ca_file: '/path/to/ca.pem') client = KubeMQ::PubSubClient.new( address: 'kubemq.example.com:50000', client_id: 'secure-client', tls: tls ) info = client.ping puts "TLS connected: host=#{info.host}, version=#{info.version}" client.close ``` ```elixir title="tls_connect.exs" {:ok, client} = KubeMQ.Client.start_link( address: "kubemq.example.com:50000", client_id: "secure-client", tls: [cacertfile: "/path/to/ca.pem"] ) {:ok, info} = KubeMQ.Client.ping(client) IO.puts("TLS connected: version=#{info.version}") KubeMQ.Client.close(client) ``` ### Verify Connection [#verify-connection] After establishing a TLS connection, call `Ping` (or equivalent) to confirm the secure channel is working. A successful ping confirms both network connectivity and certificate validation. ## Mutual TLS (mTLS) [#mutual-tls-mtls] mTLS extends standard TLS by requiring both the client and server to present certificates. The server verifies the client's identity, and the client verifies the server — establishing bidirectional trust. ### Configure mTLS [#configure-mtls] ```go title="mtls_connect.go" client, err := kubemq.NewClient(ctx, kubemq.WithAddress("kubemq.example.com", 50000), kubemq.WithClientId("mtls-client"), kubemq.WithMTLS( "path/to/client-cert.pem", "path/to/client-key.pem", "path/to/ca-cert.pem", ), ) if err != nil { log.Fatalf("mTLS connection failed: %v", err) } defer client.Close() info, err := client.Ping(ctx) if err != nil { log.Fatalf("Ping failed: %v", err) } fmt.Printf("mTLS connected: host=%s\n", info.Host) ``` ```python title="mtls_connect.py" from kubemq import TLSConfig from kubemq.pubsub import Client as PubSubClient tls_config = TLSConfig( enabled=True, cert_file="/path/to/client-cert.pem", key_file="/path/to/client-key.pem", ca_file="/path/to/ca.pem", ) with PubSubClient( address="kubemq.example.com:50000", client_id="mtls-client", tls=tls_config, ) as client: info = client.ping() print(f"mTLS connected: host={info.host}") ``` ```typescript title="mtls_connect.ts" const client = await KubeMQClient.create({ address: "kubemq.example.com:50000", clientId: "mtls-client", tls: { enabled: true, caCert: "/path/to/ca-cert.pem", clientCert: "/path/to/client-cert.pem", clientKey: "/path/to/client-key.pem", }, }); console.log("mTLS connected:", client.state); await client.close(); ``` ```java title="MtlsConnect.java" QueuesClient client = QueuesClient.builder() .address("kubemq.example.com:50000") .clientId("mtls-client") .tls(true) .caCertFile("/path/to/ca.pem") .tlsCertFile("/path/to/client-cert.pem") .tlsKeyFile("/path/to/client-key.pem") .build(); ServerInfo info = client.ping(); System.out.println("mTLS connected: " + info); client.close(); ``` ```csharp title="MtlsConnect.cs" await using var client = new KubeMQClient(new KubeMQClientOptions { Address = "kubemq.example.com:50000", ClientId = "mtls-client", Tls = new TlsOptions { Enabled = true, CaFile = "/path/to/ca.pem", CertFile = "/path/to/client-cert.pem", KeyFile = "/path/to/client-key.pem", }, }); await client.ConnectAsync(); var info = await client.PingAsync(); Console.WriteLine($"mTLS connected: {info}"); ``` ```kotlin title="MtlsConnect.kt" val client = KubeMQClient.queues { address = "kubemq.example.com:50000" clientId = "mtls-client" tls { caCertFile = "/path/to/ca.pem" certFile = "/path/to/client-cert.pem" keyFile = "/path/to/client-key.pem" } } client.use { val info = it.ping() println("mTLS connected: host=${info.host}") } ``` ```cpp title="mtls_connect.cpp" kubemq::ClientOptions opts; opts.address = "kubemq.example.com:50000"; opts.client_id = "mtls-client"; opts.set_tls_config(kubemq::TlsConfig::FromMutualTls( "/path/to/client-cert.pem", "/path/to/client-key.pem", "/path/to/ca-cert.pem" )); kubemq::PubSubClient client(opts); auto info = client.ping(); std::cout << "mTLS connected: " << info.host << std::endl; ``` ```rust title="mtls_connect.rs" use kubemq::prelude::*; use kubemq::TlsConfig; #[tokio::main] async fn main() -> kubemq::Result<()> { let tls = TlsConfig { ca_cert_file: Some("/path/to/ca.pem".to_string()), cert_file: Some("/path/to/client-cert.pem".to_string()), key_file: Some("/path/to/client-key.pem".to_string()), ..Default::default() }; let client = KubemqClient::builder() .host("kubemq.example.com") .port(50000) .tls_config(tls) .build() .await?; let info = client.ping().await?; println!("mTLS connected. Server version: {}", info.version); client.close().await?; Ok(()) } ``` ```ruby title="mtls_connect.rb" require 'kubemq' tls = KubeMQ::TLSConfig.new( enabled: true, cert_file: '/path/to/client-cert.pem', key_file: '/path/to/client-key.pem', ca_file: '/path/to/ca.pem' ) client = KubeMQ::PubSubClient.new( address: 'kubemq.example.com:50000', client_id: 'mtls-client', tls: tls ) info = client.ping puts "mTLS connected: host=#{info.host}, version=#{info.version}" client.close ``` ```elixir title="mtls_connect.exs" {:ok, client} = KubeMQ.Client.start_link( address: "kubemq.example.com:50000", client_id: "mtls-client", tls: [ cacertfile: "/path/to/ca.pem", certfile: "/path/to/client-cert.pem", keyfile: "/path/to/client-key.pem", verify: :verify_peer ] ) {:ok, info} = KubeMQ.Client.ping(client) IO.puts("mTLS connected: version=#{info.version}") KubeMQ.Client.close(client) ``` ### When to Use mTLS [#when-to-use-mtls] | Scenario | TLS | mTLS | | -------------------------- | ---------- | ----------- | | Encrypt traffic in transit | Yes | Yes | | Verify server identity | Yes | Yes | | Verify client identity | No | Yes | | Zero-trust network | — | Recommended | | Multi-tenant deployment | — | Recommended | | Internal trusted network | Sufficient | Optional | ## Pattern-Specific Notes [#pattern-specific-notes] TLS and mTLS configuration is **identical for Events, Events Store, Queues, and RPC**. The secure connection is established once at client creation and applies to all operations performed through that client. You do not need separate TLS configuration per pattern. ## Troubleshooting [#troubleshooting] Verify the CA certificate matches the server's certificate issuer: ```bash openssl verify -CAfile ca-cert.pem server-cert.pem ``` Common causes: expired certificates, wrong CA file, or intermediate CA missing from the chain. Verify the client certificate and key are a matching pair: ```bash openssl x509 -noout -modulus -in client-cert.pem | openssl md5 openssl rsa -noout -modulus -in client-key.pem | openssl md5 ``` Both commands should output the same MD5 hash. If they differ, the cert and key do not match. TLS typically uses a different port than plaintext. Confirm the server port in your deployment configuration. The default TLS port varies by deployment method. ## Next Steps [#next-steps] # Error Handling Patterns (/learn/guides/error-handling) Retries and dead-lettering build on KubeMQ's delivery semantics. Review [Delivery Guarantees](/learn/concepts/delivery-guarantees) in Fundamentals to understand at-least-once delivery and acknowledgement before designing your error-handling strategy. ## Error Categories [#error-categories] KubeMQ errors fall into four categories. Handling differs by category, not by messaging pattern. | Category | Cause | Example | Recovery | | ----------------- | --------------------------------------------------- | ---------------------------------------------- | ------------------------------- | | **Validation** | Invalid input before the message reaches the server | Empty channel name, body + metadata both empty | Fix the input — do not retry | | **Connection** | Network or server unavailable | DNS failure, server restart, TLS mismatch | Reconnect with backoff | | **Timeout** | Operation exceeded its deadline | RPC request timeout, queue poll timeout | Retry or increase timeout | | **Authorization** | Missing or invalid credentials | Bad auth token, expired certificate | Refresh credentials, then retry | ## Connection Error Handling [#connection-error-handling] Detect connection failures and attempt reconnection. All SDKs expose connection state or error callbacks. ```go title="connection_error.go" package main import ( "context" "log" "time" "github.com/kubemq-io/kubemq-go/v2" ) func connectWithRetry(ctx context.Context) *kubemq.Client { var client *kubemq.Client var err error backoff := time.Second for { client, err = kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), kubemq.WithClientId("resilient-client"), ) if err == nil { if _, pingErr := client.Ping(ctx); pingErr == nil { log.Println("Connected to KubeMQ") return client } client.Close() } log.Printf("Connection failed: %v — retrying in %v", err, backoff) time.Sleep(backoff) if backoff < 30*time.Second { backoff *= 2 } } } ``` ```python title="connection_error.py" import time from kubemq.pubsub import Client as PubSubClient def connect_with_retry(address: str) -> PubSubClient: backoff = 1.0 while True: try: client = PubSubClient( address=address, client_id="resilient-client", ) client.ping() print("Connected to KubeMQ") return client except Exception as e: print(f"Connection failed: {e} — retrying in {backoff}s") time.sleep(backoff) backoff = min(backoff * 2, 30) ``` ```javascript title="connection_error.js" const { KubeMQClient } = require("kubemq-js"); async function connectWithRetry(address) { let backoff = 1000; while (true) { try { const client = new KubeMQClient({ address, clientId: "resilient-client", }); await client.ping(); console.log("Connected to KubeMQ"); return client; } catch (err) { console.error(`Connection failed: ${err.message} — retrying in ${backoff}ms`); await new Promise((r) => setTimeout(r, backoff)); backoff = Math.min(backoff * 2, 30000); } } } ``` ```java title="ConnectionError.java" PubSubClient connectWithRetry(String address) throws InterruptedException { long backoff = 1000; while (true) { try { PubSubClient client = PubSubClient.builder() .address(address) .clientId("resilient-client") .build(); client.ping(); System.out.println("Connected to KubeMQ"); return client; } catch (Exception e) { System.err.printf("Connection failed: %s — retrying in %dms%n", e.getMessage(), backoff); Thread.sleep(backoff); backoff = Math.min(backoff * 2, 30000); } } } ``` ```csharp title="ConnectionError.cs" async Task ConnectWithRetryAsync(string address) { var backoff = TimeSpan.FromSeconds(1); while (true) { try { var client = new KubeMQClient(new KubeMQClientOptions { Address = address, ClientId = "resilient-client", }); await client.ConnectAsync(); await client.PingAsync(); Console.WriteLine("Connected to KubeMQ"); return client; } catch (Exception ex) { Console.WriteLine($"Connection failed: {ex.Message} — retrying in {backoff}"); await Task.Delay(backoff); if (backoff < TimeSpan.FromSeconds(30)) backoff *= 2; } } } ``` ```kotlin title="ConnectionError.kt" import io.kubemq.sdk.pubsub.PubSubClient import kotlinx.coroutines.delay suspend fun connectWithRetry(address: String): PubSubClient { var backoff = 1000L while (true) { try { val client = PubSubClient(address) client.ping() println("Connected to KubeMQ") return client } catch (e: Exception) { println("Connection failed: ${e.message} — retrying in ${backoff}ms") delay(backoff) backoff = minOf(backoff * 2, 30000) } } } ``` ```cpp title="connection_error.cpp" #include #include #include #include kubemq::PubSubClient connectWithRetry(const std::string& address) { int backoff = 1000; while (true) { try { kubemq::PubSubClient client(address); client.ping(); std::cout << "Connected to KubeMQ" << std::endl; return client; } catch (const std::exception& e) { std::cerr << "Connection failed: " << e.what() << " — retrying in " << backoff << "ms" << std::endl; std::this_thread::sleep_for(std::chrono::milliseconds(backoff)); backoff = std::min(backoff * 2, 30000); } } } ``` ```rust title="connection_error.rs" use kubemq::prelude::*; use kubemq::RetryPolicy; use std::time::Duration; async fn connect_with_retry(host: &str, port: u16) -> KubemqClient { let mut backoff = Duration::from_secs(1); loop { // The builder applies its own retry policy on transient failures; // check_connection verifies the connection before returning. let result = KubemqClient::builder() .host(host) .port(port) .client_id("resilient-client") .check_connection(true) .retry_policy(RetryPolicy { max_retries: 3, initial_backoff: Duration::from_millis(100), max_backoff: Duration::from_secs(10), multiplier: 2.0, ..Default::default() }) .build() .await; match result { Ok(client) => { println!("Connected to KubeMQ"); return client; } Err(e) => { println!("Connection failed: {e} — retrying in {backoff:?}"); tokio::time::sleep(backoff).await; backoff = std::cmp::min(backoff * 2, Duration::from_secs(30)); } } } } ``` ```ruby title="connection_error.rb" require 'kubemq' def connect_with_retry(address) backoff = 1.0 loop do begin client = KubeMQ::PubSubClient.new( address: address, client_id: 'resilient-client' ) client.ping puts 'Connected to KubeMQ' return client rescue KubeMQ::Error, StandardError => e puts "Connection failed: #{e.message} — retrying in #{backoff}s" sleep backoff backoff = [backoff * 2, 30].min end end end ``` ```elixir title="connection_error.exs" # KubeMQ.Client.start_link/1 returns {:ok, client} or {:error, reason}. # Retry with exponential backoff until the connection succeeds. defmodule Resilient do def connect_with_retry(address, backoff \\ 1_000) do case KubeMQ.Client.start_link(address: address, client_id: "resilient-client") do {:ok, client} -> IO.puts("Connected to KubeMQ") client {:error, reason} -> IO.puts("Connection failed: #{inspect(reason)} — retrying in #{backoff}ms") Process.sleep(backoff) connect_with_retry(address, min(backoff * 2, 30_000)) end end end ``` ## Retry with Backoff [#retry-with-backoff] Wrap send operations with exponential backoff and a maximum retry count to handle transient failures without overwhelming the server. *A message moves from received to processing; on failure it backs off and is requeued for redelivery, and once retries are exhausted it lands in the dead-letter queue.* ```go title="retry_backoff.go" func sendWithRetry(ctx context.Context, client *kubemq.Client, event *kubemq.Event, maxRetries int) error { backoff := 100 * time.Millisecond for attempt := 0; attempt <= maxRetries; attempt++ { err := client.SendEvent(ctx, event) if err == nil { return nil } if attempt == maxRetries { return fmt.Errorf("failed after %d retries: %w", maxRetries, err) } log.Printf("Attempt %d failed: %v — retrying in %v", attempt+1, err, backoff) time.Sleep(backoff) backoff *= 2 } return nil } ``` ```python title="retry_backoff.py" import time def send_with_retry(client, event, max_retries=3): backoff = 0.1 for attempt in range(max_retries + 1): try: client.send_event(event) return except Exception as e: if attempt == max_retries: raise RuntimeError(f"Failed after {max_retries} retries") from e print(f"Attempt {attempt + 1} failed: {e} — retrying in {backoff}s") time.sleep(backoff) backoff *= 2 ``` ```javascript title="retry_backoff.js" async function sendWithRetry(client, event, maxRetries = 3) { let backoff = 100; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { await client.sendEvent(event); return; } catch (err) { if (attempt === maxRetries) { throw new Error(`Failed after ${maxRetries} retries: ${err.message}`); } console.warn(`Attempt ${attempt + 1} failed: ${err.message} — retrying in ${backoff}ms`); await new Promise((r) => setTimeout(r, backoff)); backoff *= 2; } } } ``` ```java title="RetryBackoff.java" void sendWithRetry(PubSubClient client, EventMessage event, int maxRetries) throws Exception { long backoff = 100; for (int attempt = 0; attempt <= maxRetries; attempt++) { try { client.sendEventsMessage(event); return; } catch (Exception e) { if (attempt == maxRetries) { throw new RuntimeException("Failed after " + maxRetries + " retries", e); } System.err.printf("Attempt %d failed: %s — retrying in %dms%n", attempt + 1, e.getMessage(), backoff); Thread.sleep(backoff); backoff *= 2; } } } ``` ```csharp title="RetryBackoff.cs" async Task SendWithRetryAsync(KubeMQClient client, EventMessage message, int maxRetries = 3) { var backoff = TimeSpan.FromMilliseconds(100); for (int attempt = 0; attempt <= maxRetries; attempt++) { try { await client.SendEventAsync(message); return; } catch (Exception ex) { if (attempt == maxRetries) throw new InvalidOperationException($"Failed after {maxRetries} retries", ex); Console.WriteLine($"Attempt {attempt + 1} failed: {ex.Message} — retrying in {backoff}"); await Task.Delay(backoff); backoff *= 2; } } } ``` ```kotlin title="RetryBackoff.kt" suspend fun sendWithRetry(client: PubSubClient, event: EventMessage, maxRetries: Int = 3) { var backoff = 100L for (attempt in 0..maxRetries) { try { client.sendEvent(event) return } catch (e: Exception) { if (attempt == maxRetries) { throw RuntimeException("Failed after $maxRetries retries", e) } println("Attempt ${attempt + 1} failed: ${e.message} — retrying in ${backoff}ms") delay(backoff) backoff *= 2 } } } ``` ```cpp title="retry_backoff.cpp" void sendWithRetry(kubemq::PubSubClient& client, kubemq::EventMessage& event, int maxRetries = 3) { int backoff = 100; for (int attempt = 0; attempt <= maxRetries; ++attempt) { try { client.sendEvent(event); return; } catch (const std::exception& e) { if (attempt == maxRetries) { throw std::runtime_error( "Failed after " + std::to_string(maxRetries) + " retries: " + e.what()); } std::cerr << "Attempt " << attempt + 1 << " failed: " << e.what() << " — retrying in " << backoff << "ms" << std::endl; std::this_thread::sleep_for(std::chrono::milliseconds(backoff)); backoff *= 2; } } } ``` ```rust title="retry_backoff.rs" use kubemq::prelude::*; use kubemq::Event; use std::time::Duration; async fn send_with_retry( client: &KubemqClient, event: Event, max_retries: u32, ) -> kubemq::Result<()> { let mut backoff = Duration::from_millis(100); for attempt in 0..=max_retries { match client.send_event(event.clone()).await { Ok(()) => return Ok(()), Err(e) => { if attempt == max_retries { return Err(e); } println!("Attempt {} failed: {e} — retrying in {backoff:?}", attempt + 1); tokio::time::sleep(backoff).await; backoff *= 2; } } } Ok(()) } ``` ```ruby title="retry_backoff.rb" def send_with_retry(client, message, max_retries = 3) backoff = 0.1 (0..max_retries).each do |attempt| begin client.send_event(message) return rescue KubeMQ::Error, StandardError => e raise "Failed after #{max_retries} retries: #{e.message}" if attempt == max_retries puts "Attempt #{attempt + 1} failed: #{e.message} — retrying in #{backoff}s" sleep backoff backoff *= 2 end end end ``` ```elixir title="retry_backoff.exs" # send_event/2 returns :ok or {:error, err}. Retry transient failures # with exponential backoff, raising once retries are exhausted. defmodule Retry do def send_with_retry(client, event, max_retries \\ 3, backoff \\ 100, attempt \\ 0) do case KubeMQ.Client.send_event(client, event) do :ok -> :ok {:error, err} when attempt >= max_retries -> raise "Failed after #{max_retries} retries: #{err.message}" {:error, err} -> IO.puts("Attempt #{attempt + 1} failed: #{err.message} — retrying in #{backoff}ms") Process.sleep(backoff) send_with_retry(client, event, max_retries, backoff * 2, attempt + 1) end end end ``` ## Graceful Shutdown [#graceful-shutdown] Close client connections cleanly to flush pending messages and release server resources. ```go title="graceful_shutdown.go" package main import ( "context" "log" "os" "os/signal" "syscall" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx, cancel := context.WithCancel(context.Background()) client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) go func() { <-sig log.Println("Shutting down...") cancel() client.Close() os.Exit(0) }() log.Println("Running — press Ctrl+C to stop") <-ctx.Done() } ``` ```python title="graceful_shutdown.py" import signal import sys from kubemq.pubsub import Client as PubSubClient client = PubSubClient(address="localhost:50000") def shutdown(signum, frame): print("Shutting down...") client.close() sys.exit(0) signal.signal(signal.SIGINT, shutdown) signal.signal(signal.SIGTERM, shutdown) print("Running — press Ctrl+C to stop") signal.pause() ``` ```javascript title="graceful_shutdown.js" const { KubeMQClient } = require("kubemq-js"); const client = new KubeMQClient({ address: "localhost:50000" }); async function shutdown() { console.log("Shutting down..."); await client.close(); process.exit(0); } process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); console.log("Running — press Ctrl+C to stop"); ``` ```java title="GracefulShutdown.java" PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId("shutdown-demo") .build(); Runtime.getRuntime().addShutdownHook(new Thread(() -> { System.out.println("Shutting down..."); client.close(); })); System.out.println("Running — press Ctrl+C to stop"); Thread.currentThread().join(); ``` ```csharp title="GracefulShutdown.cs" await using var client = new KubeMQClient(new KubeMQClientOptions { Address = "localhost:50000", }); await client.ConnectAsync(); var cts = new CancellationTokenSource(); Console.CancelKeyPress += (_, e) => { e.Cancel = true; Console.WriteLine("Shutting down..."); cts.Cancel(); }; Console.WriteLine("Running — press Ctrl+C to stop"); try { await Task.Delay(Timeout.Infinite, cts.Token); } catch (OperationCanceledException) { } ``` ```kotlin title="GracefulShutdown.kt" val client = PubSubClient("localhost:50000") Runtime.getRuntime().addShutdownHook(Thread { println("Shutting down...") client.close() }) println("Running — press Ctrl+C to stop") Thread.currentThread().join() ``` ```cpp title="graceful_shutdown.cpp" #include #include #include #include std::atomic running{true}; kubemq::PubSubClient* globalClient = nullptr; void signalHandler(int) { std::cout << "Shutting down..." << std::endl; running = false; if (globalClient) globalClient->close(); } int main() { kubemq::PubSubClient client("localhost:50000"); globalClient = &client; std::signal(SIGINT, signalHandler); std::signal(SIGTERM, signalHandler); std::cout << "Running — press Ctrl+C to stop" << std::endl; while (running) { std::this_thread::sleep_for(std::chrono::seconds(1)); } return 0; } ``` ```rust title="graceful_shutdown.rs" use kubemq::prelude::*; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; println!("Running — press Ctrl+C to stop"); // Wait for a shutdown signal, then close the client cleanly. tokio::signal::ctrl_c().await.ok(); println!("Shutting down..."); // close() flushes pending work and cancels active subscriptions. client.close().await?; println!("Client closed — shutdown complete"); Ok(()) } ``` ```ruby title="graceful_shutdown.rb" require 'kubemq' client = KubeMQ::PubSubClient.new(address: 'localhost:50000', client_id: 'shutdown-demo') shutdown = proc do puts 'Shutting down...' client.close exit 0 end Signal.trap('INT', &shutdown) Signal.trap('TERM', &shutdown) puts 'Running — press Ctrl+C to stop' sleep ``` ```elixir title="graceful_shutdown.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "shutdown-demo") # Trap exits so the process can close the client before terminating. Process.flag(:trap_exit, true) IO.puts("Running — press Ctrl+C to stop") receive do {:EXIT, _from, _reason} -> IO.puts("Shutting down...") KubeMQ.Client.close(client) IO.puts("Client closed — shutdown complete") end ``` ## Error Code Reference [#error-code-reference] Common error codes returned by the KubeMQ server across all patterns. | Code | Category | Description | | ---- | ------------- | -------------------------------------------------------------- | | 100 | Validation | Message body and metadata are both empty | | 107 | Validation | Channel name contains wildcard characters | | 108 | Validation | Channel name contains whitespace | | 119 | Validation | Channel name ends with a dot | | 120 | Validation | Channel name is empty | | 200 | Connection | Server unavailable or connection refused | | 201 | Connection | Connection timeout exceeded | | 300 | Timeout | Request timeout (RPC commands/queries) | | 301 | Timeout | Queue poll wait timeout (not an error — no messages available) | | 400 | Authorization | Authentication token missing or invalid | | 401 | Authorization | Client not authorized for the requested channel | Code 301 (queue poll timeout) is expected behavior when no messages are available. Do not treat it as a failure in your error handling logic. # OpenTelemetry Integration (/learn/guides/opentelemetry) ## Overview [#overview] KubeMQ supports OpenTelemetry for distributed tracing and metrics. Instrumenting your producers and consumers gives you end-to-end visibility across messaging boundaries — regardless of which pattern (Events, Events Store, Queues, or RPC) you use. Tracing is how you observe message flow under load. See [Scaling & Flow](/learn/concepts/scaling-and-flow) in Fundamentals for the concepts behind throughput, backpressure, and consumer groups that these traces will surface. *Producer and consumer spans flow through the OTel Collector to a tracing backend, giving end-to-end visibility across the KubeMQ message path.* ## Setup [#setup] Configure an OpenTelemetry tracer and meter provider in your application before creating KubeMQ clients. ```go title="otel_setup.go" package main import ( "context" "log" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.24.0" ) func initTracer(ctx context.Context) (*sdktrace.TracerProvider, error) { exporter, err := otlptracegrpc.New(ctx, otlptracegrpc.WithEndpoint("localhost:4317"), otlptracegrpc.WithInsecure(), ) if err != nil { return nil, err } tp := sdktrace.NewTracerProvider( sdktrace.WithBatcher(exporter), sdktrace.WithResource(resource.NewWithAttributes( semconv.SchemaURL, semconv.ServiceName("order-service"), )), ) otel.SetTracerProvider(tp) return tp, nil } ``` ```python title="otel_setup.py" from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter from opentelemetry.sdk.resources import Resource resource = Resource.create({"service.name": "order-service"}) provider = TracerProvider(resource=resource) exporter = OTLPSpanExporter(endpoint="localhost:4317", insecure=True) provider.add_span_processor(BatchSpanProcessor(exporter)) trace.set_tracer_provider(provider) tracer = trace.get_tracer("order-service") ``` ```typescript title="otel_setup.ts" import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-grpc"; import { Resource } from "@opentelemetry/resources"; import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; const provider = new NodeTracerProvider({ resource: new Resource({ [ATTR_SERVICE_NAME]: "order-service" }), }); provider.addSpanProcessor( new BatchSpanProcessor( new OTLPTraceExporter({ url: "http://localhost:4317" }) ) ); provider.register(); const tracer = provider.getTracer("order-service"); ``` ```java title="OtelSetup.java" import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.api.trace.Tracer; import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter; import io.opentelemetry.sdk.OpenTelemetrySdk; import io.opentelemetry.sdk.resources.Resource; import io.opentelemetry.sdk.trace.SdkTracerProvider; import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; import io.opentelemetry.semconv.ResourceAttributes; Resource resource = Resource.getDefault() .merge(Resource.create( io.opentelemetry.api.common.Attributes.of( ResourceAttributes.SERVICE_NAME, "order-service"))); OtlpGrpcSpanExporter exporter = OtlpGrpcSpanExporter.builder() .setEndpoint("http://localhost:4317") .build(); SdkTracerProvider tracerProvider = SdkTracerProvider.builder() .addSpanProcessor(BatchSpanProcessor.builder(exporter).build()) .setResource(resource) .build(); OpenTelemetry otel = OpenTelemetrySdk.builder() .setTracerProvider(tracerProvider) .build(); Tracer tracer = otel.getTracer("order-service"); ``` ```csharp title="OtelSetup.cs" using OpenTelemetry; using OpenTelemetry.Resources; using OpenTelemetry.Trace; using var tracerProvider = Sdk.CreateTracerProviderBuilder() .SetResourceBuilder(ResourceBuilder.CreateDefault() .AddService("order-service")) .AddOtlpExporter(opts => { opts.Endpoint = new Uri("http://localhost:4317"); }) .Build(); var tracer = tracerProvider.GetTracer("order-service"); ``` ```kotlin title="OtelSetup.kt" import io.opentelemetry.api.OpenTelemetry import io.opentelemetry.api.trace.Tracer import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter import io.opentelemetry.sdk.OpenTelemetrySdk import io.opentelemetry.sdk.resources.Resource import io.opentelemetry.sdk.trace.SdkTracerProvider import io.opentelemetry.sdk.trace.export.BatchSpanProcessor import io.opentelemetry.semconv.ResourceAttributes val resource = Resource.getDefault() .merge(Resource.create( io.opentelemetry.api.common.Attributes.of( ResourceAttributes.SERVICE_NAME, "order-service"))) val exporter = OtlpGrpcSpanExporter.builder() .setEndpoint("http://localhost:4317") .build() val tracerProvider = SdkTracerProvider.builder() .addSpanProcessor(BatchSpanProcessor.builder(exporter).build()) .setResource(resource) .build() val otel: OpenTelemetry = OpenTelemetrySdk.builder() .setTracerProvider(tracerProvider) .build() val tracer: Tracer = otel.getTracer("order-service") ``` ```cpp title="otel_setup.cpp" #include #include #include #include #include namespace trace_sdk = opentelemetry::sdk::trace; namespace otlp = opentelemetry::exporter::otlp; namespace resource = opentelemetry::sdk::resource; auto exporter = std::make_unique( otlp::OtlpGrpcExporterOptions{"localhost:4317"}); auto processor = std::make_unique( std::move(exporter)); auto provider = std::make_shared( std::move(processor), resource::Resource::Create({{"service.name", "order-service"}})); opentelemetry::trace::Provider::SetTracerProvider(provider); auto tracer = provider->GetTracer("order-service"); ``` ```rust title="otel_setup.rs" use kubemq::prelude::*; use opentelemetry::global; use opentelemetry_otlp::WithExportConfig; use opentelemetry_sdk::{trace::TracerProvider, Resource}; use opentelemetry::KeyValue; // Register a global TracerProvider before building the client. Once a // provider is registered globally, the KubeMQ Rust client automatically // instruments its gRPC calls — no per-call wiring required. let exporter = opentelemetry_otlp::SpanExporter::builder() .with_tonic() .with_endpoint("http://localhost:4317") .build()?; let provider = TracerProvider::builder() .with_batch_exporter(exporter, opentelemetry_sdk::runtime::Tokio) .with_resource(Resource::new(vec![ KeyValue::new("service.name", "order-service"), ])) .build(); global::set_tracer_provider(provider); let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; ``` ```ruby title="otel_setup.rb" require 'kubemq' require 'opentelemetry/sdk' require 'opentelemetry/exporter/otlp' # Configure the global OpenTelemetry SDK before creating the client. With a # provider configured, the KubeMQ Ruby client's gRPC calls are instrumented # through the standard OpenTelemetry gRPC instrumentation. OpenTelemetry::SDK.configure do |c| c.service_name = 'order-service' c.use_all end tracer = OpenTelemetry.tracer_provider.tracer('order-service') client = KubeMQ::PubSubClient.new( address: 'localhost:50000', client_id: 'order-service' ) ``` ```elixir title="otel_setup.exs" # The KubeMQ Elixir client emits :telemetry events for every operation # rather than OpenTelemetry spans directly. Bridge those telemetry events # into OpenTelemetry with the opentelemetry_telemetry library, or attach a # handler to forward measurements to your tracer. :telemetry.attach_many( "kubemq-otel-bridge", [ [:kubemq, :client, :send_event, :start], [:kubemq, :client, :send_event, :stop], [:kubemq, :client, :send_event, :exception] ], fn event, measurements, metadata, _config -> [_, _, action, phase] = event # Forward to your OpenTelemetry tracer here, e.g. via OpenTelemetry.Tracer. IO.puts("[otel] #{action}:#{phase} #{inspect(metadata[:channel])}") end, nil ) {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-service") ``` The Elixir client exposes observability through Erlang/Elixir `:telemetry` events (`[:kubemq, :client, :send_event, :start | :stop | :exception]`, and equivalents for commands, queries, and queues) rather than emitting OpenTelemetry spans directly. Use [`opentelemetry_telemetry`](https://hex.pm/packages/opentelemetry_telemetry) to convert these into spans, or attach your own handler. ## Tracing Messaging Operations [#tracing-messaging-operations] Wrap your publish and subscribe operations in spans to trace message flow across services. The pattern is the same for Events, Events Store, Queues, and RPC. ```go title="traced_publish.go" tracer := otel.Tracer("order-service") ctx, span := tracer.Start(ctx, "publish-order", trace.WithAttributes( attribute.String("messaging.system", "kubemq"), attribute.String("messaging.destination", "order-events"), attribute.String("messaging.operation", "publish"), ), ) defer span.End() err := client.SendEvent(ctx, kubemq.NewEvent(). SetChannel("order-events"). SetBody([]byte(`{"orderId":"ORD-500"}`)), ) if err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) } ``` ```python title="traced_publish.py" with tracer.start_as_current_span( "publish-order", attributes={ "messaging.system": "kubemq", "messaging.destination": "order-events", "messaging.operation": "publish", }, ) as span: try: client.send_event( EventMessage( channel="order-events", body=b'{"orderId":"ORD-500"}', ) ) except Exception as e: span.record_exception(e) span.set_status(StatusCode.ERROR, str(e)) raise ``` ```typescript title="traced_publish.ts" await tracer.startActiveSpan("publish-order", { attributes: { "messaging.system": "kubemq", "messaging.destination": "order-events", "messaging.operation": "publish", }, }, async (span) => { try { await client.sendEvent({ channel: "order-events", body: Buffer.from('{"orderId":"ORD-500"}'), }); } catch (err) { span.recordException(err); span.setStatus({ code: SpanStatusCode.ERROR, message: err.message }); throw err; } finally { span.end(); } }); ``` ```java title="TracedPublish.java" Span span = tracer.spanBuilder("publish-order") .setAttribute("messaging.system", "kubemq") .setAttribute("messaging.destination", "order-events") .setAttribute("messaging.operation", "publish") .startSpan(); try (Scope scope = span.makeCurrent()) { client.sendEventsMessage(EventMessage.builder() .channel("order-events") .body("{\"orderId\":\"ORD-500\"}".getBytes()) .build()); } catch (Exception e) { span.recordException(e); span.setStatus(StatusCode.ERROR, e.getMessage()); throw e; } finally { span.end(); } ``` ```csharp title="TracedPublish.cs" using var span = tracer.StartActiveSpan("publish-order"); span.SetAttribute("messaging.system", "kubemq"); span.SetAttribute("messaging.destination", "order-events"); span.SetAttribute("messaging.operation", "publish"); try { await client.SendEventAsync(new EventMessage { Channel = "order-events", Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-500\"}"), }); } catch (Exception ex) { span.RecordException(ex); span.SetStatus(Status.Error.WithDescription(ex.Message)); throw; } ``` ```kotlin title="TracedPublish.kt" val span = tracer.spanBuilder("publish-order") .setAttribute("messaging.system", "kubemq") .setAttribute("messaging.destination", "order-events") .setAttribute("messaging.operation", "publish") .startSpan() try { span.makeCurrent().use { client.sendEvent(EventMessage( channel = "order-events", body = """{"orderId":"ORD-500"}""".toByteArray(), )) } } catch (e: Exception) { span.recordException(e) span.setStatus(StatusCode.ERROR, e.message ?: "unknown error") throw e } finally { span.end() } ``` ```cpp title="traced_publish.cpp" auto span = tracer->StartSpan("publish-order", { {"messaging.system", "kubemq"}, {"messaging.destination", "order-events"}, {"messaging.operation", "publish"}, }); auto scope = tracer->WithActiveSpan(span); try { kubemq::EventMessage event; event.channel = "order-events"; event.body = R"({"orderId":"ORD-500"})"; client.sendEvent(event); } catch (const std::exception& e) { span->AddEvent("exception", {{"exception.message", e.what()}}); span->SetStatus(opentelemetry::trace::StatusCode::kError, e.what()); throw; } span->End(); ``` ```rust title="traced_publish.rs" use kubemq::EventBuilder; use opentelemetry::trace::{Span, Status, Tracer}; use opentelemetry::{global, KeyValue}; let tracer = global::tracer("order-service"); let mut span = tracer.start("publish-order"); span.set_attribute(KeyValue::new("messaging.system", "kubemq")); span.set_attribute(KeyValue::new("messaging.destination", "order-events")); span.set_attribute(KeyValue::new("messaging.operation", "publish")); let event = EventBuilder::new() .channel("order-events") .body(br#"{"orderId":"ORD-500"}"#.to_vec()) .build(); match client.send_event(event).await { Ok(_) => {} Err(e) => { span.set_status(Status::error(e.to_string())); span.record_error(&e); } } span.end(); ``` ```ruby title="traced_publish.rb" tracer.in_span( 'publish-order', attributes: { 'messaging.system' => 'kubemq', 'messaging.destination' => 'order-events', 'messaging.operation' => 'publish' } ) do |span| begin msg = KubeMQ::PubSub::EventMessage.new( channel: 'order-events', body: '{"orderId":"ORD-500"}' ) client.send_event(msg) rescue KubeMQ::Error => e span.record_exception(e) span.status = OpenTelemetry::Trace::Status.error(e.message) raise end end ``` ```elixir title="traced_publish.exs" require OpenTelemetry.Tracer, as: Tracer Tracer.with_span "publish-order" do Tracer.set_attributes([ {"messaging.system", "kubemq"}, {"messaging.destination", "order-events"}, {"messaging.operation", "publish"} ]) event = KubeMQ.Event.new(channel: "order-events", body: ~s({"orderId":"ORD-500"})) case KubeMQ.Client.send_event(client, event) do :ok -> :ok {:error, err} -> Tracer.set_status(OpenTelemetry.status(:error, err.message)) end end ``` ## Metrics [#metrics] Instrument your messaging clients with OpenTelemetry metrics to track throughput, latency, and error rates. ### Recommended Metrics [#recommended-metrics] | Metric | Type | Description | | ---------------------------- | --------- | --------------------------------------- | | `messaging.publish.duration` | Histogram | Time to publish a message (ms) | | `messaging.process.duration` | Histogram | Time to process a received message (ms) | | `messaging.publish.messages` | Counter | Total messages published | | `messaging.receive.messages` | Counter | Total messages received | | `messaging.publish.errors` | Counter | Failed publish attempts | ### Attribute Conventions [#attribute-conventions] Follow OpenTelemetry semantic conventions for messaging attributes: | Attribute | Example | Description | | ----------------------- | --------------------- | --------------------------- | | `messaging.system` | `kubemq` | Messaging system identifier | | `messaging.destination` | `order-events` | Channel name | | `messaging.operation` | `publish` / `receive` | Operation type | | `messaging.message.id` | `uuid` | Message identifier | These conventions align with the [OpenTelemetry Semantic Conventions for Messaging](https://opentelemetry.io/docs/specs/semconv/messaging/). Following them ensures compatibility with observability platforms like Jaeger, Grafana Tempo, and Datadog. ## Next Steps [#next-steps] # Production Checklist (/learn/guides/production-checklist) Before working this checklist, ground yourself in the [Messaging Patterns Fundamentals](/learn/concepts) — especially [Delivery Guarantees](/learn/concepts/delivery-guarantees), since most production decisions (retries, dead-lettering, idempotency) follow from your chosen delivery semantics. ## Deployment Checklist [#deployment-checklist] Follow these steps before deploying KubeMQ messaging to production. Each step covers a critical area — complete all that apply to your deployment. ### Connection & Security [#connection--security] * [ ] TLS enabled for all client connections ([Connect with TLS](/learn/guides/connect-with-tls)) * [ ] mTLS enabled for zero-trust or multi-tenant environments * [ ] Connection timeouts configured to match network conditions * [ ] Reconnection logic with exponential backoff implemented ([Error Handling](/learn/guides/error-handling)) * [ ] Client IDs set to unique, descriptive values for traceability * [ ] Authentication tokens rotated and not hardcoded ### Observability [#observability] * [ ] OpenTelemetry configured for distributed traces and metrics ([OpenTelemetry](/learn/guides/opentelemetry)) * [ ] Structured logging with correlation IDs linking traces to logs * [ ] Alert thresholds set for queue depth, error rates, and latency * [ ] Dashboard monitoring KubeMQ server health (CPU, memory, connections) * [ ] Log retention policy configured for compliance requirements ### Queues [#queues] * [ ] Dead letter queues configured for all critical queues ([Dead Letter Queue](/learn/queues/tutorials/dead-letter-queue)) * [ ] `maxReceiveCount` set to a reasonable retry limit (default 1024 is typically too high) * [ ] Visibility timeout set to match expected processing time ([Visibility Timeout](/learn/queues/how-to/visibility-timeout)) * [ ] Message expiration (TTL) set for time-sensitive work * [ ] Retry strategy with exponential backoff implemented * [ ] Queue depth monitoring and alerting in place ### Events Store [#events-store] * [ ] Retention policy configured — time, size, or message count * [ ] Storage utilization monitored with alerts before 90% capacity * [ ] Durable subscription names set for consumer recovery after restart * [ ] Replay strategy documented — consumers know which offset to start from * [ ] Storage backend appropriate for workload (memory vs. disk) ### RPC (Commands & Queries) [#rpc-commands--queries] * [ ] Request timeouts set on all commands and queries — never use infinite timeout * [ ] Circuit breaker in place for downstream service failures * [ ] Query caching enabled for slow-changing data to reduce responder load * [ ] Timeout values documented and agreed upon between caller and responder * [ ] Load balancing strategy validated for multi-responder deployments ### All Patterns [#all-patterns] * [ ] Error handling covers all four categories: validation, connection, timeout, authorization ([Error Handling](/learn/guides/error-handling)) * [ ] Graceful shutdown implemented — close connections cleanly on SIGTERM * [ ] Idempotent message processing where applicable (at-least-once delivery) * [ ] Message serialization format agreed upon (JSON, Protobuf, etc.) * [ ] Channel naming convention documented and enforced * [ ] Load testing completed under expected peak throughput * [ ] Disaster recovery plan documented — what happens when KubeMQ restarts ## Quick Reference [#quick-reference] | Area | Key Config | Default | Recommended | | ------------ | ------------------ | -------------------- | ------------------------------- | | Connection | TLS | Disabled | Enable in production | | Connection | Reconnect backoff | None (SDK-dependent) | 1s initial, 30s max | | Queues | Visibility timeout | 60s | Match your processing time | | Queues | Max receive count | 1024 | 3–5 for most workloads | | Queues | Message expiration | None | Set for time-sensitive messages | | Events Store | Retention | Unlimited | Set based on storage budget | | RPC | Request timeout | 10s | Set per operation | | All | Client ID | Auto-generated | Set explicitly for tracing | ## Next Steps [#next-steps] # Getting Started with Queues (/learn/queues/getting-started) **Prerequisites:** KubeMQ server running on `localhost:50000` and your SDK installed. See [Getting Started](/deploy) for setup. ## What You Will Build [#what-you-will-build] An order message sender that places a task on a queue, and a receiver that pulls the task, processes it, and acknowledges completion. ## Steps [#steps] ### Install the SDK [#install-the-sdk] ```bash go get github.com/kubemq-io/kubemq-go/v2 ``` ```bash pip install kubemq ``` ```bash npm install kubemq-js ``` ```xml io.kubemq.sdk kubemq-sdk-Java 3.1.1 ``` ```bash dotnet add package KubeMQ.SDK.CSharp --version 3.0.1 ``` ```kotlin implementation("io.kubemq.sdk:kubemq-sdk-kotlin:1.0.1") ``` ```bash vcpkg install kubemq ``` ```toml [dependencies] kubemq = "1.0.1" tokio = { version = "1", features = ["full"] } ``` ```bash gem install kubemq ``` ```elixir def deps do [{:kubemq, "~> 1.0"}] end ``` ### Create the Sender [#create-the-sender] Connect to KubeMQ and send an order message to the `orders` queue. ```go title="sender.go" package main import ( "context" "fmt" "log" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), kubemq.WithClientId("order-sender"), ) if err != nil { log.Fatal(err) } defer client.Close() msg := kubemq.NewQueueMessage(). SetChannel("orders"). SetBody([]byte(`{"orderId":"ORD-1234","total":99.99}`)). SetMetadata("order.created") result, err := client.SendQueueMessage(ctx, msg) if err != nil { log.Fatal(err) } if result.IsError { log.Fatalf("Send failed: %s", result.Error) } fmt.Printf("Sent: id=%s\n", result.MessageID) } ``` ```python title="sender.py" from kubemq.queues import Client as QueuesClient from kubemq import QueueMessage client = QueuesClient( address="localhost:50000", client_id="order-sender", ) result = client.send_queue_message( QueueMessage( channel="orders", body=b'{"orderId":"ORD-1234","total":99.99}', metadata="order.created", ) ) print(f"Sent: id={result.id}") client.close() ``` ```typescript title="sender.ts" import { KubeMQClient, createQueueMessage } from 'kubemq-js'; const client = await KubeMQClient.create({ address: 'localhost:50000', clientId: 'order-sender', }); const result = await client.sendQueueMessage( createQueueMessage({ channel: 'orders', body: JSON.stringify({ orderId: 'ORD-1234', total: 99.99 }), metadata: 'order.created', }), ); console.log('Sent:', result.messageId); await client.close(); ``` ```java title="Sender.java" import io.kubemq.sdk.queues.QueuesClient; import io.kubemq.sdk.queues.QueueMessage; import io.kubemq.sdk.queues.QueueSendResult; QueuesClient client = QueuesClient.builder() .address("localhost:50000") .clientId("order-sender") .build(); QueueMessage msg = QueueMessage.builder() .channel("orders") .body("{\"orderId\":\"ORD-1234\",\"total\":99.99}".getBytes()) .metadata("order.created") .build(); QueueSendResult result = client.sendQueueMessage(msg); System.out.println("Sent: id=" + result.getId()); client.close(); ``` ```csharp title="Sender.cs" using System.Text; using KubeMQ.Sdk.Client; using KubeMQ.Sdk.Queues; await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); var result = await client.SendQueueMessageAsync(new QueueMessage { Channel = "orders", Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-1234\",\"total\":99.99}"), Metadata = "order.created" }); Console.WriteLine($"Sent: id={result.MessageId}"); ``` ```kotlin title="Sender.kt" import io.kubemq.sdk.client.KubeMQClient import io.kubemq.sdk.queues.QueueMessage import kotlinx.coroutines.runBlocking fun main() = runBlocking { val client = KubeMQClient.queues { address = "localhost:50000" clientId = "order-sender" } val result = client.sendQueuesMessage(QueueMessage( channel = "orders", body = """{"orderId":"ORD-1234","total":99.99}""".toByteArray(), metadata = "order.created" )) println("Sent: id=${result.messageId}") client.close() } ``` ```cpp title="sender.cpp" #include #include auto client = kubemq::QueuesClient("localhost:50000"); kubemq::QueueMessage msg; msg.channel = "orders"; msg.body = R"({"orderId":"ORD-1234","total":99.99})"; msg.metadata = "order.created"; auto result = client.sendQueueMessage(msg); std::cout << "Sent: id=" << result.messageId << std::endl; ``` ```rust title="sender.rs" use kubemq::prelude::*; use kubemq::QueueMessageBuilder; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .client_id("order-sender") .build() .await?; let msg = QueueMessageBuilder::new() .channel("orders") .body(br#"{"orderId":"ORD-1234","total":99.99}"#.to_vec()) .metadata("order.created") .build(); let result = client.send_queue_message(msg).await?; println!("Sent: id={}", result.message_id); client.close().await?; Ok(()) } ``` ```ruby title="sender.rb" require 'kubemq' client = KubeMQ::QueuesClient.new( address: 'localhost:50000', client_id: 'order-sender' ) msg = KubeMQ::Queues::QueueMessage.new( channel: 'orders', body: '{"orderId":"ORD-1234","total":99.99}', metadata: 'order.created' ) result = client.send_queue_message(msg) puts "Sent: id=#{result.id}" client.close ``` ```elixir title="sender.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-sender") msg = KubeMQ.QueueMessage.new( channel: "orders", body: ~s({"orderId":"ORD-1234","total":99.99}), metadata: "order.created" ) {:ok, result} = KubeMQ.Client.send_queue_message(client, msg) IO.puts("Sent: id=#{result.message_id}") KubeMQ.Client.close(client) ``` ### Create the Receiver [#create-the-receiver] In a separate terminal, receive the message and acknowledge it. ```go title="receiver.go" package main import ( "context" "fmt" "log" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), kubemq.WithClientId("order-receiver"), ) if err != nil { log.Fatal(err) } defer client.Close() resp, err := client.PollQueue(ctx, &kubemq.PollRequest{ Channel: "orders", MaxItems: 1, WaitTimeoutSeconds: 10, AutoAck: false, }) if err != nil { log.Fatal(err) } for _, m := range resp.Messages { fmt.Printf("Received: %s\n", string(m.Message.Body)) fmt.Printf("Metadata: %s\n", m.Message.Metadata) } if err := resp.AckAll(); err != nil { log.Fatal(err) } fmt.Println("All messages acknowledged") } ``` ```python title="receiver.py" from kubemq.queues import Client as QueuesClient client = QueuesClient( address="localhost:50000", client_id="order-receiver", ) response = client.receive_queue_messages( channel="orders", max_messages=1, wait_timeout_in_seconds=10, ) for msg in response.messages: print(f"Received: {msg.body.decode('utf-8')}") print(f"Metadata: {msg.metadata}") msg.ack() print("Message acknowledged") client.close() ``` ```typescript title="receiver.ts" import { KubeMQClient } from 'kubemq-js'; const client = await KubeMQClient.create({ address: 'localhost:50000', clientId: 'order-receiver', }); const messages = await client.receiveQueueMessages({ channel: 'orders', maxMessages: 1, waitTimeoutSeconds: 10, }); for (const msg of messages) { console.log('Received:', new TextDecoder().decode(msg.body)); console.log('Metadata:', msg.metadata); await msg.ack(); console.log('Message acknowledged'); } await client.close(); ``` ```java title="Receiver.java" import io.kubemq.sdk.queues.QueuesClient; import io.kubemq.sdk.queues.QueuesPollRequest; import io.kubemq.sdk.queues.QueuesPollResponse; import io.kubemq.sdk.queues.QueueMessageReceived; QueuesClient client = QueuesClient.builder() .address("localhost:50000") .clientId("order-receiver") .build(); QueuesPollResponse response = client.receiveQueueMessages( QueuesPollRequest.builder() .channel("orders") .pollMaxMessages(1) .pollWaitTimeoutInSeconds(10) .autoAckMessages(false) .build()); for (QueueMessageReceived msg : response.getMessages()) { System.out.println("Received: " + new String(msg.getBody())); System.out.println("Metadata: " + msg.getMetadata()); msg.ack(); System.out.println("Message acknowledged"); } client.close(); ``` ```csharp title="Receiver.cs" using System.Text; using KubeMQ.Sdk.Client; using KubeMQ.Sdk.Queues; await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); var response = await client.ReceiveQueueMessagesAsync(new QueuePollRequest { Channel = "orders", MaxMessages = 1, WaitTimeoutSeconds = 10, AutoAck = false, }); foreach (var msg in response.Messages) { Console.WriteLine($"Received: {Encoding.UTF8.GetString(msg.Body.Span)}"); Console.WriteLine($"Metadata: {msg.Metadata}"); await msg.AckAsync(); Console.WriteLine("Message acknowledged"); } ``` ```kotlin title="Receiver.kt" import io.kubemq.sdk.client.KubeMQClient import kotlinx.coroutines.runBlocking fun main() = runBlocking { val client = KubeMQClient.queues { address = "localhost:50000" clientId = "order-receiver" } val response = client.receiveQueuesMessages { channel = "orders" maxItems = 1 waitTimeoutMs = 10_000 autoAck = false } for (msg in response.messages) { println("Received: ${String(msg.body)}") println("Metadata: ${msg.metadata}") msg.ack() println("Message acknowledged") } client.close() } ``` ```cpp title="receiver.cpp" #include #include auto client = kubemq::QueuesClient("localhost:50000"); auto response = client.receiveQueueMessages("orders", 1, 10); for (const auto& msg : response.messages) { std::cout << "Received: " << msg.body << std::endl; std::cout << "Metadata: " << msg.metadata << std::endl; msg.ack(); std::cout << "Message acknowledged" << std::endl; } ``` ```rust title="receiver.rs" use kubemq::prelude::*; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .client_id("order-receiver") .build() .await?; let (response, _receiver) = client .poll_queue(PollRequest { channel: "orders".to_string(), max_items: 1, wait_timeout_seconds: 10, auto_ack: false, }) .await?; for m in &response.messages { println!("Received: {}", String::from_utf8_lossy(&m.message.body)); println!("Metadata: {}", m.message.metadata); } response.ack_all().await?; println!("All messages acknowledged"); client.close().await?; Ok(()) } ``` ```ruby title="receiver.rb" require 'kubemq' client = KubeMQ::QueuesClient.new( address: 'localhost:50000', client_id: 'order-receiver' ) messages = client.receive_queue_messages( channel: 'orders', max_messages: 1, wait_timeout_seconds: 10 ) messages.each do |msg| puts "Received: #{msg.body}" puts "Metadata: #{msg.metadata}" end client.close ``` ```elixir title="receiver.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-receiver") {:ok, result} = KubeMQ.Client.receive_queue_messages(client, "orders", max_messages: 1, wait_timeout: 10_000 ) Enum.each(result.messages, fn msg -> IO.puts("Received: #{msg.body}") IO.puts("Metadata: #{msg.metadata}") end) KubeMQ.Client.close(client) ``` ### Verify [#verify] Run the sender first, then the receiver. You should see: ```text Sent: id= Received: {"orderId":"ORD-1234","total":99.99} Metadata: order.created Message acknowledged ``` ## What Just Happened [#what-just-happened] *Send → durable queue → poll & deliver to one receiver → acknowledge to remove.* The full round-trip, step by step: 1. The **sender** connected to KubeMQ and placed an order message on the `orders` queue 2. The message was **stored durably** — it persists even if no receiver is connected yet 3. The **receiver** polled the queue and received the message (hidden from other consumers) 4. After processing, the receiver **acknowledged** the message, permanently removing it from the queue Unlike Events, queue messages persist until acknowledged. If the receiver crashes before acknowledging, the message becomes available again after the visibility timeout expires. ## Next Steps [#next-steps] # Queues — Point-to-Point Messaging (/learn/queues) Think of Queues like a mailbox — you drop a letter in, and it waits safely until the recipient picks it up. Even if the recipient is away, the letter doesn't disappear. Each letter is delivered to exactly one person, and they confirm receipt by opening it. KubeMQ Queues provide durable, point-to-point messaging with guaranteed delivery. Each message is persisted to a named queue and delivered to exactly one consumer, where it remains until that consumer explicitly acknowledges it. When several consumers poll the same queue, they form a pool of *competing consumers* — the broker hands each message to just one of them, load-balancing the work. Queues are ideal for task distribution, job processing, and any workflow where reliable, ordered, exactly-once processing is required. ## The concept it implements [#the-concept-it-implements] Queues realize the **point-to-point** interaction style with **exactly-once processing**. To understand the underlying ideas first, see the Fundamentals layer: * [Interaction styles → point-to-point](/learn/concepts/interaction-styles) — competing consumers, one message → one consumer. * [Delivery guarantees → exactly-once](/learn/concepts/delivery-guarantees) — manual ack/nack, redelivery, and idempotency. * [Scaling & flow → competing consumers](/learn/concepts/scaling-and-flow) — visibility timeout, backpressure, and adding consumers to drain a queue faster. ## Key Features [#key-features] * **Guaranteed delivery** — messages persist in the queue until a consumer acknowledges them. No message is lost, even if consumers restart. * **Exactly-once processing** — each message is delivered to a single consumer with manual acknowledgment, preventing duplicate processing. * **FIFO ordering** — messages are delivered in the order they were sent, maintaining strict first-in, first-out semantics. * **Visibility timeout** — while a consumer processes a message, it is hidden from other consumers. If not acknowledged within the timeout, the message becomes available again. * **Dead Letter Queue (DLQ)** — messages that exceed a configurable retry count are automatically routed to a DLQ for inspection and recovery. * **Delayed delivery** — schedule messages to become available after a specified delay, enabling deferred processing and retry patterns. * **Batch operations** — send and receive multiple messages in a single request for high-throughput scenarios. * **Peek without consuming** — inspect queue contents without removing messages, useful for monitoring and debugging. * **Message expiration (TTL)** — set time-to-live on messages so unprocessed items auto-expire. ## How It Works [#how-it-works] A queue is a durable, first-in-first-out buffer. Producers append messages to a named queue; a pool of consumers polls the same queue and competes for messages. The broker delivers each message to **exactly one** consumer, hides it from the others while it is being processed, and removes it only after that consumer acknowledges it. *Producers append to the queue; competing consumers each receive a different message and confirm with a dotted acknowledgment.* ## Key Properties [#key-properties] | Property | Behavior | Learn more | | ---------------- | ---------------------------------------- | --------------------------------------------------------------- | | Delivery | Exactly one consumer per message | [Send & Receive](/learn/queues/tutorials/send-receive) | | Settlement | Manual `ack` / `nack` / requeue | [Ack, Nack & Requeue](/learn/queues/tutorials/ack-nack-requeue) | | Visibility | Hidden from others during processing | [Visibility timeout](/learn/queues/how-to/visibility-timeout) | | Failure handling | Redeliver, then route to DLQ | [Dead Letter Queue](/learn/queues/tutorials/dead-letter-queue) | | Scheduling | Delay before a message becomes available | [Delayed Messages](/learn/queues/tutorials/delayed-messages) | | Expiration | TTL auto-discards unprocessed messages | [Message expiration](/learn/queues/how-to/message-expiration) | ## Message Lifecycle [#message-lifecycle] *A message moves from queued to delivered to acked; on failure it is requeued or, after exhausting retries, routed to a dead letter queue.* ## When to Use Queues [#when-to-use-queues] | Scenario | Queues | Events / Events Store | | ------------------------------------- | -------------------------- | ----------------------------------------- | | Task & job distribution | ✅ Best choice | ❌ No load balancing per message | | Exactly-once work processing | ✅ Best choice | ❌ At-most/at-least-once | | Order-sensitive pipelines (FIFO) | ✅ Best choice | Use Events Store for replay | | Retry with backoff & dead-letter | ✅ Best choice | ❌ Not built in | | Real-time fan-out to many subscribers | ❌ One consumer per message | ✅ Use [Events](/learn/events) | | Durable history / replay | ❌ Messages removed on ack | ✅ Use [Events Store](/learn/events-store) | Need to broadcast every message to **all** listeners instead of distributing work? Use [Events](/learn/events). Need a durable, replayable log? Use [Events Store](/learn/events-store). ## Quick Example [#quick-example] ```go title="main.go" package main import ( "context" "fmt" "log" "time" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), kubemq.WithClientId("queue-demo"), ) if err != nil { log.Fatal(err) } defer client.Close() msg := kubemq.NewQueueMessage(). SetChannel("orders"). SetBody([]byte(`{"orderId":"ORD-1234","total":99.99}`)) result, err := client.SendQueueMessage(ctx, msg) if err != nil { log.Fatal(err) } fmt.Printf("Sent: id=%s\n", result.MessageID) resp, err := client.PollQueue(ctx, &kubemq.PollRequest{ Channel: "orders", MaxItems: 1, WaitTimeoutSeconds: 5, AutoAck: false, }) if err != nil { log.Fatal(err) } for _, m := range resp.Messages { fmt.Printf("Received: %s\n", string(m.Message.Body)) } resp.AckAll() } ``` ```python title="main.py" from kubemq.queues import Client as QueuesClient from kubemq import QueueMessage client = QueuesClient( address="localhost:50000", client_id="queue-demo", ) result = client.send_queue_message( QueueMessage( channel="orders", body=b'{"orderId":"ORD-1234","total":99.99}', ) ) print(f"Sent: id={result.id}") response = client.receive_queue_messages( channel="orders", max_messages=1, wait_timeout_in_seconds=5, ) for msg in response.messages: print(f"Received: {msg.body.decode('utf-8')}") msg.ack() client.close() ``` ```typescript title="main.ts" import { KubeMQClient, createQueueMessage } from 'kubemq-js'; const client = await KubeMQClient.create({ address: 'localhost:50000', clientId: 'queue-demo', }); const result = await client.sendQueueMessage( createQueueMessage({ channel: 'orders', body: JSON.stringify({ orderId: 'ORD-1234', total: 99.99 }), }), ); console.log('Sent:', result.messageId); const messages = await client.receiveQueueMessages({ channel: 'orders', maxMessages: 1, waitTimeoutSeconds: 5, }); for (const msg of messages) { console.log('Received:', new TextDecoder().decode(msg.body)); await msg.ack(); } await client.close(); ``` ```java title="Main.java" QueuesClient client = QueuesClient.builder() .address("localhost:50000") .clientId("queue-demo") .build(); QueueMessage msg = QueueMessage.builder() .channel("orders") .body("{\"orderId\":\"ORD-1234\",\"total\":99.99}".getBytes()) .build(); SendQueueMessageResult result = client.sendQueueMessage(msg); System.out.println("Sent: id=" + result.getMessageId()); ReceiveQueueMessagesResponse response = client.receiveQueueMessages( ReceiveQueueMessagesRequest.builder() .channel("orders") .maxMessages(1) .waitTimeoutSeconds(5) .build()); for (QueueMessageReceived m : response.getMessages()) { System.out.println("Received: " + new String(m.getBody())); m.ack(); } client.close(); ``` ```csharp title="Program.cs" using KubeMQ.Sdk.Client; await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); var result = await client.SendQueueMessageAsync(new QueueMessage { Channel = "orders", Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-1234\",\"total\":99.99}") }); Console.WriteLine($"Sent: id={result.MessageId}"); var response = await client.ReceiveQueueMessagesAsync(new ReceiveQueueMessagesRequest { Channel = "orders", MaxMessages = 1, WaitTimeoutSeconds = 5, }); foreach (var msg in response.Messages) { Console.WriteLine($"Received: {Encoding.UTF8.GetString(msg.Body.Span)}"); await msg.AckAsync(); } ``` ```kotlin title="Main.kt" val client = QueuesClient("localhost:50000") val result = client.sendQueueMessage(QueueMessage( channel = "orders", body = """{"orderId":"ORD-1234","total":99.99}""".toByteArray() )) println("Sent: id=${result.messageId}") val response = client.receiveQueueMessages( channel = "orders", maxMessages = 1, waitTimeoutSeconds = 5 ) for (msg in response.messages) { println("Received: ${String(msg.body)}") msg.ack() } client.close() ``` ```cpp title="main.cpp" #include #include auto client = kubemq::QueuesClient("localhost:50000"); kubemq::QueueMessage msg; msg.channel = "orders"; msg.body = R"({"orderId":"ORD-1234","total":99.99})"; auto result = client.sendQueueMessage(msg); std::cout << "Sent: id=" << result.messageId << std::endl; auto response = client.receiveQueueMessages("orders", 1, 5); for (const auto& m : response.messages) { std::cout << "Received: " << m.body << std::endl; m.ack(); } ``` ```rust title="main.rs" use kubemq::prelude::*; use kubemq::QueueMessageBuilder; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .client_id("queue-demo") .build() .await?; let msg = QueueMessageBuilder::new() .channel("orders") .body(br#"{"orderId":"ORD-1234","total":99.99}"#.to_vec()) .build(); let result = client.send_queue_message(msg).await?; println!("Sent: id={}", result.message_id); // receive_queue_messages(channel, max_messages, wait_timeout_secs, auto_ack) let messages = client .receive_queue_messages("orders", 1, 5, false) .await?; for m in &messages { println!("Received: {}", String::from_utf8_lossy(&m.body)); m.ack().await?; } client.close().await?; Ok(()) } ``` ```ruby title="main.rb" require 'kubemq' client = KubeMQ::QueuesClient.new(address: 'localhost:50000', client_id: 'queue-demo') msg = KubeMQ::Queues::QueueMessage.new( channel: 'orders', body: '{"orderId":"ORD-1234","total":99.99}' ) result = client.send_queue_message(msg) puts "Sent: id=#{result.id}" messages = client.receive_queue_messages(channel: 'orders', max_messages: 1, wait_timeout_seconds: 5) messages.each do |m| puts "Received: #{m.body}" m.ack end client.close ``` ```elixir title="main.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "queue-demo") msg = KubeMQ.QueueMessage.new( channel: "orders", body: ~s({"orderId":"ORD-1234","total":99.99}) ) {:ok, result} = KubeMQ.Client.send_queue_message(client, msg) IO.puts("Sent: id=#{result.message_id}") {:ok, response} = KubeMQ.Client.receive_queue_messages(client, "orders", max_messages: 1, wait_timeout: 5_000 ) Enum.each(response.messages, fn m -> IO.puts("Received: #{m.body}") KubeMQ.Client.ack_queue_message(client, m) end) KubeMQ.Client.close(client) ``` Queues are also available via the [CloudEvents protocol](/connectors/cloudevents/how-to/queues) — use any language with a CloudEvents SDK, no KubeMQ client library needed. ## Learn More [#learn-more] # Queue Reference (/learn/queues/reference) This reference documents every aspect of the KubeMQ Queues messaging pattern. ## Message Structure [#message-structure] ### Send Request [#send-request] ### Message Policy Fields [#message-policy-fields] Policy fields control delivery behavior and are set per message at send time. ### Send Response [#send-response] ### Receive Response (per message) [#receive-response-per-message] ## Receive Request [#receive-request] ## Channel Name Rules [#channel-name-rules] | Rule | Constraint | Error Code | | --------------- | ------------------------- | ---------- | | Required | Cannot be empty | 120 | | No trailing dot | Cannot end with `.` | 119 | | No whitespace | Cannot contain spaces | 108 | | No wildcards | Cannot contain `*` or `>` | 107 | Valid channel name pattern: `^[^\s*>]+[^.]$` **Examples:** * `orders` — valid * `payments.us-east` — valid * `orders processing` — invalid (contains space) * `orders.` — invalid (trailing dot) * `orders.*` — invalid (contains wildcard) ## Server Configuration [#server-configuration] | Transport | Default Limit | Setting | | --------- | ------------- | --------------------------- | | gRPC | \~1 GB | `Connectors.Grpc.BodyLimit` | | REST | 100 MB | `Connectors.Rest.BodyLimit` | ## Message Lifecycle [#message-lifecycle] *A queue message moves through delay, availability, processing, and settlement — ending in removal, redelivery, expiration, or the dead letter queue.* ## Dead Letter Queue Behavior [#dead-letter-queue-behavior] When a message exceeds the maximum receive count: 1. If `maxReceiveQueue` is set, the message is copied to the DLQ channel with: * `reRouted` set to `true` * `reRoutedFromQueue` set to the original channel name * `receiveCount` reset to `0` * Policy reset to server defaults 2. If `maxReceiveQueue` is not set, the message is silently discarded 3. The original message is acknowledged (removed from the source queue) ## Delayed Message Behavior [#delayed-message-behavior] Messages with `delaySeconds > 0` follow this path: 1. Message is published to an internal delay channel (`_QUEUE_DELAY_`) 2. A background processor checks for expired delays every 500ms 3. When the delay elapses, the message is moved to the target queue 4. The `delaySeconds` and `delayedTo` attributes are cleared before delivery 5. If both delay and expiration are set, the expiration clock starts after the delay ## Error Codes [#error-codes] ### Queue-Specific Errors [#queue-specific-errors] | Code | Error | Cause | | ---- | ------------------------------------ | ------------------------------------------------- | | 120 | Invalid queue name | Empty channel or invalid characters | | 122 | Max messages exceeded config limit | Receive request exceeds `MaxNumberOfMessages` | | 125 | Ack sequence cannot be 0 or negative | Invalid sequence in stream ack | | 127 | Invalid visibility time | Visibility timeout exceeds `MaxVisibilitySeconds` | | 133 | Invalid expiration seconds | Expiration exceeds `MaxExpirationSeconds` | | 134 | Invalid max receive count | Max receive count exceeds server config | | 135 | Invalid delay seconds | Delay exceeds `MaxDelaySeconds` | | 136 | Invalid wait timeout | Wait timeout exceeds `MaxWaitTimeoutSeconds` | | 139 | Invalid max items | Value must be at least 1 | | 140 | Invalid wait time | Value must be at least 1 second | ### General Errors [#general-errors] | Code | Error | Applies To | | ---- | -------------------------------------- | ------------------------------ | | 101 | Invalid clientID, cannot be empty | All patterns | | 107 | Invalid channel, no wildcards allowed | All patterns | | 108 | Invalid channel, no whitespace allowed | All patterns | | 110 | Invalid message, cannot be empty | Body and metadata both missing | | 119 | Invalid channel, cannot end with dot | All patterns | ## SDK Quick Reference [#sdk-quick-reference] ```go // go get github.com/kubemq-io/kubemq-go/v2 msg := kubemq.NewQueueMessage(). SetChannel("orders"). SetBody([]byte("payload")). SetMetadata("metadata"). SetTags(map[string]string{"key": "value"}). SetDelaySeconds(10). SetExpirationSeconds(300). SetMaxReceiveCount(3). SetMaxReceiveQueue("orders.dlq") result, err := client.SendQueueMessage(ctx, msg) results, err := client.SendQueueMessages(ctx, msgs) resp, err := client.PollQueue(ctx, &kubemq.PollRequest{ Channel: "orders", MaxItems: 10, WaitTimeoutSeconds: 5, AutoAck: false, VisibilitySeconds: 120, IsPeek: false, }) resp.AckAll() resp.NAckAll() resp.ReQueueAll("other-channel") ``` ```python # pip install kubemq from kubemq.queues import Client as QueuesClient from kubemq import QueueMessage result = client.send_queue_message(QueueMessage( channel="orders", body=b"payload", metadata="metadata", tags={"key": "value"}, delay_in_seconds=10, expiration_in_seconds=300, max_receive_count=3, max_receive_queue="orders.dlq", )) response = client.receive_queue_messages( channel="orders", max_messages=10, wait_timeout_in_seconds=5, is_peek=False, visibility_seconds=120, ) msg.ack() msg.nack() msg.requeue("other-channel") ``` ```typescript // npm install kubemq-js import { KubeMQClient, createQueueMessage } from 'kubemq-js'; const result = await client.sendQueueMessage(createQueueMessage({ channel: 'orders', body: 'payload', metadata: 'metadata', tags: { key: 'value' }, policy: { delaySeconds: 10, expirationSeconds: 300, maxReceiveCount: 3, maxReceiveQueue: 'orders.dlq' }, })); const msgs = await client.receiveQueueMessages({ channel: 'orders', maxMessages: 10, waitTimeoutSeconds: 5, isPeek: false, visibilitySeconds: 120, }); await msg.ack(); await msg.nack(); await msg.requeue('other-channel'); ``` ```java // io.kubemq.sdk:kubemq-sdk-Java:2.1.1 QueueMessage msg = QueueMessage.builder() .channel("orders").body("payload".getBytes()) .metadata("metadata").tags(Map.of("key", "value")) .delaySeconds(10).expirationSeconds(300) .maxReceiveCount(3).maxReceiveQueue("orders.dlq").build(); SendQueueMessageResult result = client.sendQueueMessage(msg); ReceiveQueueMessagesResponse response = client.receiveQueueMessages( ReceiveQueueMessagesRequest.builder() .channel("orders").maxMessages(10).waitTimeoutSeconds(5) .isPeek(false).visibilitySeconds(120).build()); msg.ack(); msg.nack(); msg.requeue("other-channel"); ``` ```csharp // dotnet add package KubeMQ.SDK.CSharp var result = await client.SendQueueMessageAsync(new QueueMessage { Channel = "orders", Body = Encoding.UTF8.GetBytes("payload"), Metadata = "metadata", Tags = new() { ["key"] = "value" }, DelaySeconds = 10, ExpirationSeconds = 300, MaxReceiveCount = 3, MaxReceiveQueue = "orders.dlq" }); var response = await client.ReceiveQueueMessagesAsync(new ReceiveQueueMessagesRequest { Channel = "orders", MaxMessages = 10, WaitTimeoutSeconds = 5, IsPeek = false, VisibilitySeconds = 120, }); await msg.AckAsync(); await msg.NAckAsync(); await msg.ReQueueAsync("other-channel"); ``` ```kotlin // io.kubemq.sdk:kubemq-sdk-kotlin:2.1.0 val result = client.sendQueueMessage(QueueMessage( channel = "orders", body = "payload".toByteArray(), metadata = "metadata", tags = mapOf("key" to "value"), delaySeconds = 10, expirationSeconds = 300, maxReceiveCount = 3, maxReceiveQueue = "orders.dlq" )) val response = client.receiveQueueMessages( channel = "orders", maxMessages = 10, waitTimeoutSeconds = 5, isPeek = false, visibilitySeconds = 120) msg.ack() msg.nack() msg.requeue("other-channel") ``` ```cpp // vcpkg install kubemq kubemq::QueueMessage msg; msg.channel = "orders"; msg.body = "payload"; msg.metadata = "metadata"; msg.tags = {{"key", "value"}}; msg.delaySeconds = 10; msg.expirationSeconds = 300; msg.maxReceiveCount = 3; msg.maxReceiveQueue = "orders.dlq"; auto result = client.sendQueueMessage(msg); auto response = client.receiveQueueMessages("orders", 10, 5); msg.ack(); msg.nack(); msg.requeue("other-channel"); ``` ```rust // cargo add kubemq use kubemq::prelude::*; use kubemq::{PollRequest, QueueMessageBuilder}; let msg = QueueMessageBuilder::new() .channel("orders") .body(b"payload".to_vec()) .metadata("metadata") .tags([("key", "value")]) .delay_seconds(10) .expiration_seconds(300) .max_receive_count(3) .max_receive_queue("orders.dlq") .build(); let result = client.send_queue_message(msg).await?; // receive_queue_messages(channel, max_items, wait_timeout_seconds, is_peek) let messages = client.receive_queue_messages("orders", 10, 5, false).await?; // Stream poll for explicit settlement (ack / nack / re-queue) let mut receiver = client.new_queue_downstream_receiver().await?; let response = receiver.poll(PollRequest { channel: "orders".to_string(), max_items: 10, wait_timeout_seconds: 5, auto_ack: false, }).await?; response.ack_all().await?; response.nack_all().await?; for m in &response.messages { m.re_queue("other-channel").await?; } ``` ```ruby # gem install kubemq require 'kubemq' policy = KubeMQ::Queues::QueueMessagePolicy.new( delay_seconds: 10, expiration_seconds: 300, max_receive_count: 3, max_receive_queue: 'orders.dlq' ) msg = KubeMQ::Queues::QueueMessage.new( channel: 'orders', body: 'payload', metadata: 'metadata', tags: { 'key' => 'value' }, policy: policy ) result = client.send_queue_message(msg) messages = client.receive_queue_messages( channel: 'orders', max_messages: 10, wait_timeout_seconds: 5 ) # Stream poll for explicit settlement (ack / nack / requeue) receiver = client.create_downstream_receiver request = KubeMQ::Queues::QueuePollRequest.new( channel: 'orders', max_items: 10, wait_timeout: 5 ) response = receiver.poll(request) response.messages.each(&:ack) response.messages.each(&:nack) response.requeue_all(channel: 'other-channel') ``` ```elixir # {:kubemq, "~> 1.0"} in mix.exs msg = KubeMQ.QueueMessage.new( channel: "orders", body: "payload", metadata: "metadata", tags: %{"key" => "value"}, policy: KubeMQ.QueuePolicy.new( delay_seconds: 10, expiration_seconds: 300, max_receive_count: 3, max_receive_queue: "orders.dlq" ) ) {:ok, result} = KubeMQ.Client.send_queue_message(client, msg) {:ok, received} = KubeMQ.Client.receive_queue_messages(client, "orders", max_messages: 10, wait_timeout: 5_000) # Stream poll for explicit settlement (ack / nack / requeue) {:ok, poll} = KubeMQ.Client.poll_queue(client, channel: "orders", max_items: 10, wait_timeout: 5_000) KubeMQ.PollResponse.ack_all(poll) KubeMQ.PollResponse.nack_all(poll) KubeMQ.PollResponse.requeue_all(poll, "other-channel") ``` ## Related Pages [#related-pages] * [Queues Overview](/learn/queues) — Introduction and key features * [Getting Started](/learn/queues/getting-started) — Step-by-step first message guide * [Ack, Nack & Requeue](/learn/queues/tutorials/ack-nack-requeue) — Settlement options * [Dead Letter Queue](/learn/queues/tutorials/dead-letter-queue) — DLQ routing and monitoring * [Delayed Messages](/learn/queues/tutorials/delayed-messages) — Schedule future delivery * [Visibility Timeout](/learn/queues/how-to/visibility-timeout) — Processing time configuration * [Batch Operations](/learn/queues/tutorials/batch-operations) — High-throughput send and receive # Getting Started with RPC (/learn/rpc/getting-started) **Prerequisites:** KubeMQ server running on `localhost:50000` and your SDK installed. See [Getting Started](/deploy) for setup. ## What You Will Build [#what-you-will-build] A command sender that creates an order and a query sender that retrieves order status — each paired with a responder that handles the request and returns a response. ## Steps [#steps] ### Install the SDK [#install-the-sdk] ```bash go get github.com/kubemq-io/kubemq-go/v2 ``` ```bash pip install kubemq ``` ```bash npm install kubemq-js ``` ```xml io.kubemq.sdk kubemq-sdk-Java 2.1.1 ``` ```bash dotnet add package KubeMQ.SDK.CSharp ``` ```kotlin implementation("io.kubemq.sdk:kubemq-sdk-kotlin:2.1.0") ``` ```bash vcpkg install kubemq ``` ```toml [dependencies] kubemq = "1.0" tokio = { version = "1", features = ["full"] } ``` ```bash gem install kubemq ``` ```elixir def deps do [{:kubemq, "~> 1.0"}] end ``` ### Create a Command Responder [#create-a-command-responder] Start the responder first. It subscribes to incoming commands on `orders.process`, processes them, and sends back an execution status. ```go title="command_responder.go" package main import ( "context" "fmt" "log" "time" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() sub, err := client.SubscribeToCommands(ctx, "orders.process", "", kubemq.WithOnCommandReceive(func(cmd *kubemq.CommandReceive) { fmt.Printf("Received command: %s\n", cmd.Body) resp := kubemq.NewCommandReply(). SetRequestId(cmd.Id). SetResponseTo(cmd.ResponseTo). SetExecutedAt(time.Now()) _ = client.SendCommandResponse(ctx, resp) }), kubemq.WithOnError(func(err error) { log.Println("Error:", err) }), ) if err != nil { log.Fatal(err) } defer sub.Unsubscribe() fmt.Println("Command responder listening on 'orders.process'...") <-ctx.Done() } ``` ```python title="command_responder.py" import time from kubemq.cq import Client as CQClient from kubemq.cq import CommandsSubscription, CommandReceived, CommandResponse, CancellationToken def on_command(request: CommandReceived) -> None: print(f"Received command: {request.body.decode('utf-8')}") client.send_response_message( CommandResponse(command_received=request, is_executed=True) ) def on_error(err): print(f"Error: {err}") client = CQClient(address="localhost:50000") cancel = CancellationToken() client.subscribe_to_commands( subscription=CommandsSubscription( channel="orders.process", on_receive_command_callback=on_command, on_error_callback=on_error, ), cancel=cancel, ) print("Command responder listening on 'orders.process'...") time.sleep(3600) ``` ```javascript title="command_responder.js" const { KubeMQClient } = require("kubemq-js"); const client = new KubeMQClient({ address: "localhost:50000" }); client.subscribeToCommands({ channel: "orders.process", onCommand: (cmd) => { console.log("Received command:", Buffer.from(cmd.body).toString()); client.sendCommandResponse({ requestId: cmd.id, isExecuted: true }); }, onError: (err) => console.error("Error:", err.message), }); console.log("Command responder listening on 'orders.process'..."); ``` ```java title="CommandResponder.java" CQClient client = CQClient.builder() .address("localhost:50000") .clientId("order-responder") .build(); client.subscribeToCommands(CommandsSubscription.builder() .channel("orders.process") .onReceiveCommandCallback(cmd -> { System.out.println("Received: " + new String(cmd.getBody())); return CommandResponseMessage.builder() .requestId(cmd.getId()) .isExecuted(true) .build(); }) .onErrorCallback(err -> System.err.println("Error: " + err.getMessage())) .build()); System.out.println("Command responder listening on 'orders.process'..."); Thread.sleep(3600000); client.close(); ``` ```csharp title="CommandResponder.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); Console.WriteLine("Command responder listening on 'orders.process'..."); await foreach (var cmd in client.SubscribeToCommandsAsync( new CommandsSubscription { Channel = "orders.process" })) { Console.WriteLine($"Received: {Encoding.UTF8.GetString(cmd.Body.Span)}"); await client.SendCommandResponseAsync(new CommandResponse { RequestId = cmd.Id, IsExecuted = true }); } ``` ```kotlin title="CommandResponder.kt" val client = CQClient("localhost:50000") client.subscribeToCommands( channel = "orders.process", onCommand = { cmd -> println("Received command: ${String(cmd.body)}") client.sendCommandResponse( requestId = cmd.id, isExecuted = true ) }, onError = { err -> System.err.println("Error: ${err.message}") } ) println("Command responder listening on 'orders.process'...") Thread.sleep(3600000) client.close() ``` ```cpp title="command_responder.cpp" #include #include #include auto client = kubemq::CQClient("localhost:50000"); client.subscribeToCommands("orders.process", "", [&client](const kubemq::CommandReceive& cmd) { std::cout << "Received command: " << cmd.body << std::endl; client.sendCommandResponse(cmd.id, true); }, [](const std::string& err) { std::cerr << "Error: " << err << std::endl; } ); std::cout << "Command responder listening on 'orders.process'..." << std::endl; std::this_thread::sleep_for(std::chrono::hours(1)); ``` ```rust title="command_responder.rs" use kubemq::prelude::*; use kubemq::CommandReplyBuilder; use std::time::Duration; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; let rc = client.clone(); let sub = client .subscribe_to_commands( "orders.process", "", move |cmd| { let c = rc.clone(); Box::pin(async move { println!("Received command: {}", String::from_utf8_lossy(&cmd.body)); let reply = CommandReplyBuilder::new() .request_id(&cmd.id) .response_to(&cmd.response_to) .build(); tokio::spawn(async move { let _ = c.send_command_response(reply).await; }); }) }, None, ) .await?; println!("Command responder listening on 'orders.process'..."); tokio::signal::ctrl_c().await.ok(); sub.unsubscribe().await; client.close().await?; Ok(()) } ``` ```ruby title="command_responder.rb" require 'kubemq' client = KubeMQ::CQClient.new(address: 'localhost:50000', client_id: 'order-responder') cancel = KubeMQ::CancellationToken.new sub = KubeMQ::CQ::CommandsSubscription.new(channel: 'orders.process') client.subscribe_to_commands(sub, cancellation_token: cancel, on_error: ->(e) { puts "Error: #{e.message}" }) do |cmd| puts "Received command: #{cmd.body}" response = KubeMQ::CQ::CommandResponseMessage.new( request_id: cmd.id, reply_channel: cmd.reply_channel, executed: true ) client.send_response(response) end puts "Command responder listening on 'orders.process'..." cancel.wait ``` ```elixir title="command_responder.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-responder") {:ok, _sub} = KubeMQ.Client.subscribe_to_commands(client, "orders.process", on_command: fn cmd -> IO.puts("Received command: #{cmd.body}") KubeMQ.CommandReply.new( request_id: cmd.id, response_to: cmd.reply_channel, executed: true ) end, on_error: fn err -> IO.puts("Error: #{err.message}") end ) IO.puts("Command responder listening on 'orders.process'...") Process.sleep(:infinity) ``` ### Send a Command [#send-a-command] In a separate terminal, send a command. The sender blocks until the responder replies or the timeout expires. ```go title="send_command.go" ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() resp, err := client.SendCommand(ctx, kubemq.NewCommand(). SetChannel("orders.process"). SetBody([]byte(`{"action":"create","orderId":"ORD-1234"}`)). SetTimeout(10 * time.Second)) if err != nil { log.Fatal(err) } log.Printf("Executed: %v", resp.Executed) ``` ```python title="send_command.py" from kubemq.cq import Client as CQClient from kubemq.cq import CommandMessage with CQClient(address="localhost:50000") as client: response = client.send_command( CommandMessage( channel="orders.process", body=b'{"action":"create","orderId":"ORD-1234"}', timeout_in_seconds=10, ) ) print(f"Executed: {response.is_executed}") ``` ```javascript title="send_command.js" const { KubeMQClient } = require("kubemq-js"); const client = new KubeMQClient({ address: "localhost:50000" }); const response = await client.sendCommand({ channel: "orders.process", body: Buffer.from(JSON.stringify({ action: "create", orderId: "ORD-1234" })), timeoutInSeconds: 10, }); console.log("Executed:", response.isExecuted); ``` ```java title="SendCommand.java" CQClient client = CQClient.builder() .address("localhost:50000") .clientId("order-sender") .build(); CommandResponseMessage response = client.sendCommandRequest( CommandMessage.builder() .channel("orders.process") .body("{\"action\":\"create\",\"orderId\":\"ORD-1234\"}".getBytes()) .timeout(10000) .build()); System.out.println("Executed: " + response.isExecuted()); client.close(); ``` ```csharp title="SendCommand.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); var response = await client.SendCommandAsync(new CommandMessage { Channel = "orders.process", Body = Encoding.UTF8.GetBytes("{\"action\":\"create\",\"orderId\":\"ORD-1234\"}"), Timeout = TimeSpan.FromSeconds(10) }); Console.WriteLine($"Executed: {response.IsExecuted}"); ``` ```kotlin title="SendCommand.kt" val client = CQClient("localhost:50000") val response = client.sendCommand(CommandMessage( channel = "orders.process", body = """{"action":"create","orderId":"ORD-1234"}""".toByteArray(), timeout = 10000 )) println("Executed: ${response.isExecuted}") client.close() ``` ```cpp title="send_command.cpp" #include #include auto client = kubemq::CQClient("localhost:50000"); kubemq::CommandMessage cmd; cmd.channel = "orders.process"; cmd.body = R"({"action":"create","orderId":"ORD-1234"})"; cmd.timeout = 10000; auto response = client.sendCommand(cmd); std::cout << "Executed: " << response.isExecuted << std::endl; ``` ```rust title="send_command.rs" use kubemq::prelude::*; use kubemq::CommandBuilder; use std::time::Duration; let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; let command = CommandBuilder::new() .channel("orders.process") .body(br#"{"action":"create","orderId":"ORD-1234"}"#.to_vec()) .timeout(Duration::from_secs(10)) .build(); let response = client.send_command(command).await?; println!("Executed: {}", response.executed); client.close().await?; ``` ```ruby title="send_command.rb" require 'kubemq' client = KubeMQ::CQClient.new(address: 'localhost:50000', client_id: 'order-sender') msg = KubeMQ::CQ::CommandMessage.new( channel: 'orders.process', timeout: 10, body: '{"action":"create","orderId":"ORD-1234"}' ) result = client.send_command(msg) puts "Executed: #{result.executed}" client.close ``` ```elixir title="send_command.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-sender") command = KubeMQ.Command.new( channel: "orders.process", body: ~s({"action":"create","orderId":"ORD-1234"}), timeout: 10_000 ) {:ok, response} = KubeMQ.Client.send_command(client, command) IO.puts("Executed: #{response.executed}") KubeMQ.Client.close(client) ``` **Expected output (sender):** `Executed: true` **Expected output (responder):** `Received command: {"action":"create","orderId":"ORD-1234"}` ### Create a Query Responder [#create-a-query-responder] Queries work like commands but the responder can include data in the response body. Set up a query responder on `orders.lookup`. ```go title="query_responder.go" sub, err := client.SubscribeToQueries(ctx, "orders.lookup", "", kubemq.WithOnQueryReceive(func(query *kubemq.QueryReceive) { fmt.Printf("Query for: %s\n", query.Body) resp := kubemq.NewQueryReply(). SetRequestId(query.Id). SetResponseTo(query.ResponseTo). SetBody([]byte(`{"orderId":"ORD-1234","status":"shipped","total":99.99}`)). SetExecutedAt(time.Now()) _ = client.SendQueryResponse(ctx, resp) }), kubemq.WithOnError(func(err error) { log.Println("Error:", err) }), ) ``` ```python title="query_responder.py" from kubemq.cq import QueriesSubscription, QueryReceived, QueryResponse def on_query(request: QueryReceived) -> None: print(f"Query for: {request.body.decode('utf-8')}") client.send_response_message( QueryResponse( query_received=request, is_executed=True, body=b'{"orderId":"ORD-1234","status":"shipped","total":99.99}', ) ) client.subscribe_to_queries( subscription=QueriesSubscription( channel="orders.lookup", on_receive_query_callback=on_query, on_error_callback=on_error, ), cancel=cancel, ) ``` ```javascript title="query_responder.js" client.subscribeToQueries({ channel: "orders.lookup", onQuery: (query) => { console.log("Query for:", Buffer.from(query.body).toString()); client.sendQueryResponse({ requestId: query.id, isExecuted: true, body: Buffer.from( JSON.stringify({ orderId: "ORD-1234", status: "shipped", total: 99.99 }) ), }); }, onError: (err) => console.error("Error:", err.message), }); ``` ```java title="QueryResponder.java" client.subscribeToQueries(QueriesSubscription.builder() .channel("orders.lookup") .onReceiveQueryCallback(query -> { System.out.println("Query for: " + new String(query.getBody())); return QueryResponseMessage.builder() .requestId(query.getId()) .isExecuted(true) .body("{\"orderId\":\"ORD-1234\",\"status\":\"shipped\",\"total\":99.99}".getBytes()) .build(); }) .onErrorCallback(err -> System.err.println("Error: " + err.getMessage())) .build()); ``` ```csharp title="QueryResponder.cs" await foreach (var query in client.SubscribeToQueriesAsync( new QueriesSubscription { Channel = "orders.lookup" })) { Console.WriteLine($"Query for: {Encoding.UTF8.GetString(query.Body.Span)}"); await client.SendQueryResponseAsync(new QueryResponse { RequestId = query.Id, IsExecuted = true, Body = Encoding.UTF8.GetBytes( "{\"orderId\":\"ORD-1234\",\"status\":\"shipped\",\"total\":99.99}") }); } ``` ```kotlin title="QueryResponder.kt" client.subscribeToQueries( channel = "orders.lookup", onQuery = { query -> println("Query for: ${String(query.body)}") client.sendQueryResponse( requestId = query.id, isExecuted = true, body = """{"orderId":"ORD-1234","status":"shipped","total":99.99}""".toByteArray() ) }, onError = { err -> System.err.println("Error: ${err.message}") } ) ``` ```cpp title="query_responder.cpp" client.subscribeToQueries("orders.lookup", "", [&client](const kubemq::QueryReceive& query) { std::cout << "Query for: " << query.body << std::endl; client.sendQueryResponse(query.id, true, R"({"orderId":"ORD-1234","status":"shipped","total":99.99})"); }, [](const std::string& err) { std::cerr << "Error: " << err << std::endl; } ); ``` ```rust title="query_responder.rs" use kubemq::QueryReplyBuilder; let rc = client.clone(); let sub = client .subscribe_to_queries( "orders.lookup", "", move |query| { let c = rc.clone(); Box::pin(async move { println!("Query for: {}", String::from_utf8_lossy(&query.body)); let reply = QueryReplyBuilder::new() .request_id(&query.id) .response_to(&query.response_to) .body(br#"{"orderId":"ORD-1234","status":"shipped","total":99.99}"#.to_vec()) .build(); tokio::spawn(async move { let _ = c.send_query_response(reply).await; }); }) }, None, ) .await?; ``` ```ruby title="query_responder.rb" sub = KubeMQ::CQ::QueriesSubscription.new(channel: 'orders.lookup') client.subscribe_to_queries(sub, cancellation_token: cancel, on_error: ->(e) { puts "Error: #{e.message}" }) do |query| puts "Query for: #{query.body}" response = KubeMQ::CQ::QueryResponseMessage.new( request_id: query.id, reply_channel: query.reply_channel, executed: true, body: '{"orderId":"ORD-1234","status":"shipped","total":99.99}' ) client.send_response(response) end ``` ```elixir title="query_responder.exs" {:ok, _sub} = KubeMQ.Client.subscribe_to_queries(client, "orders.lookup", on_query: fn query -> IO.puts("Query for: #{query.body}") KubeMQ.QueryReply.new( request_id: query.id, response_to: query.reply_channel, executed: true, body: ~s({"orderId":"ORD-1234","status":"shipped","total":99.99}) ) end, on_error: fn err -> IO.puts("Error: #{err.message}") end ) ``` ### Send a Query [#send-a-query] Send a query to retrieve order data. Unlike commands, the response body is preserved. ```go title="send_query.go" resp, err := client.SendQuery(ctx, kubemq.NewQuery(). SetChannel("orders.lookup"). SetBody([]byte("ORD-1234")). SetTimeout(10 * time.Second)) if err != nil { log.Fatal(err) } log.Printf("Order data: %s", resp.Body) ``` ```python title="send_query.py" from kubemq.cq import Client as CQClient from kubemq.cq import QueryMessage with CQClient(address="localhost:50000") as client: response = client.send_query( QueryMessage( channel="orders.lookup", body=b"ORD-1234", timeout_in_seconds=10, ) ) print(f"Order data: {response.body.decode('utf-8')}") ``` ```javascript title="send_query.js" const response = await client.sendQuery({ channel: "orders.lookup", body: Buffer.from("ORD-1234"), timeoutInSeconds: 10, }); console.log("Order data:", Buffer.from(response.body).toString()); ``` ```java title="SendQuery.java" QueryResponseMessage response = client.sendQueryRequest( QueryMessage.builder() .channel("orders.lookup") .body("ORD-1234".getBytes()) .timeout(10000) .build()); System.out.println("Order data: " + new String(response.getBody())); ``` ```csharp title="SendQuery.cs" var response = await client.SendQueryAsync(new QueryMessage { Channel = "orders.lookup", Body = Encoding.UTF8.GetBytes("ORD-1234"), Timeout = TimeSpan.FromSeconds(10) }); Console.WriteLine($"Order data: {Encoding.UTF8.GetString(response.Body.Span)}"); ``` ```kotlin title="SendQuery.kt" val response = client.sendQuery(QueryMessage( channel = "orders.lookup", body = "ORD-1234".toByteArray(), timeout = 10000 )) println("Order data: ${String(response.body)}") ``` ```cpp title="send_query.cpp" kubemq::QueryMessage query; query.channel = "orders.lookup"; query.body = "ORD-1234"; query.timeout = 10000; auto response = client.sendQuery(query); std::cout << "Order data: " << response.body << std::endl; ``` ```rust title="send_query.rs" use kubemq::QueryBuilder; use std::time::Duration; let query = QueryBuilder::new() .channel("orders.lookup") .body(b"ORD-1234".to_vec()) .timeout(Duration::from_secs(10)) .build(); let response = client.send_query(query).await?; println!("Order data: {}", String::from_utf8_lossy(&response.body)); ``` ```ruby title="send_query.rb" msg = KubeMQ::CQ::QueryMessage.new( channel: 'orders.lookup', timeout: 10, body: 'ORD-1234' ) result = client.send_query(msg) puts "Order data: #{result.body}" ``` ```elixir title="send_query.exs" query = KubeMQ.Query.new( channel: "orders.lookup", body: "ORD-1234", timeout: 10_000 ) {:ok, response} = KubeMQ.Client.send_query(client, query) IO.puts("Order data: #{response.body}") ``` Unlike commands, query responses preserve the full response body and metadata. This makes queries ideal for data retrieval operations. ### Run the Example [#run-the-example] 1. Start the **responder** in one terminal (handles both commands and queries) 2. Run the **command sender** in a separate terminal 3. Run the **query sender** in a separate terminal 4. Observe the responses in each terminal ## What Just Happened [#what-just-happened] 1. The **responder** subscribed to both command and query channels 2. The **sender** sent a command — the responder processed it and returned `Executed: true` (response body stripped) 3. The **sender** sent a query — the responder returned the full order data in the response body (preserved) ## Commands vs Queries Summary [#commands-vs-queries-summary] | Aspect | Command | Query | | --------------------- | -------------------------- | ------------------------------ | | **Response body** | Stripped (always `nil`) | Preserved | | **Response metadata** | Stripped | Preserved | | **CacheHit field** | Stripped | Preserved | | **Use for** | Writes, mutations, actions | Reads, lookups, data retrieval | ## Next Steps [#next-steps] # RPC — Commands & Queries (/learn/rpc) Think of RPC like a phone call — you dial a number, ask a question, and wait on the line for an answer. If nobody picks up within a set time, you hang up and try again. KubeMQ RPC brings this synchronous request-reply model to messaging infrastructure. KubeMQ implements RPC through two complementary operation types following the CQRS (Command Query Responsibility Segregation) principle: **Commands** for writes and **Queries** for reads. **The concept it implements.** RPC is KubeMQ's realization of the [request/reply interaction style](/learn/concepts/interaction-styles) — the sender blocks on a single matched response. The timeout-and-retry behavior maps to the [delivery guarantees](/learn/concepts/delivery-guarantees) you choose at the application level: a request either gets exactly one answer or a timeout error. ## Commands vs Queries [#commands-vs-queries] | Aspect | Commands | Queries | | --------------------- | ------------------------------------------------ | ------------------------------------------------- | | **Purpose** | State-changing operations (writes, mutations) | Read-only operations (data lookups) | | **Response body** | Stripped — sender receives only execution status | Preserved — sender receives full response payload | | **Response metadata** | Stripped | Preserved | | **Caching** | Not supported | Supported via `CacheKey` / `CacheTTL` | | **Use cases** | Order placement, device control, config changes | Data lookups, status checks, service reads | Commands tell the system to **do something** and return only a success/failure indicator. Queries **ask for data** and return the full response body and metadata. ## How It Works [#how-it-works] *Request/reply: the sender blocks while KubeMQ routes the request to a responder and delivers the single matched response back.* The sender publishes a request to a named channel with a timeout. KubeMQ routes the request to a subscribed responder (or load-balances across a group of responders). The responder processes the request and sends a response back through KubeMQ. If no response arrives before the timeout expires, the sender receives a timeout error. ## Key Features [#key-features] * **Synchronous request-reply** — sender blocks until a response arrives or timeout expires * **Commands and Queries** — separate semantics for writes and reads following CQRS * **Response caching** — server-side caching for queries with configurable TTL * **Load balancing** — distribute requests across multiple responders using queue groups * **Configurable timeouts** — per-request timeout in milliseconds * **gRPC and REST** — use any transport protocol ## Quick Example [#quick-example] ```go title="send_command.go" package main import ( "context" "log" "time" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() resp, err := client.SendCommand(ctx, kubemq.NewCommand(). SetChannel("orders.process"). SetBody([]byte(`{"action":"create","orderId":"ORD-1234"}`)). SetTimeout(10 * time.Second)) if err != nil { log.Fatal(err) } log.Printf("Command executed: %v", resp.Executed) } ``` ```python title="send_command.py" from kubemq.cq import Client as CQClient from kubemq.cq import CommandMessage client = CQClient(address="localhost:50000") response = client.send_command( CommandMessage( channel="orders.process", body=b'{"action":"create","orderId":"ORD-1234"}', timeout_in_seconds=10, ) ) print(f"Command executed: {response.is_executed}") client.close() ``` ```javascript title="send_command.js" const { KubeMQClient } = require("kubemq-js"); const client = new KubeMQClient({ address: "localhost:50000" }); const response = await client.sendCommand({ channel: "orders.process", body: Buffer.from(JSON.stringify({ action: "create", orderId: "ORD-1234" })), timeoutInSeconds: 10, }); console.log("Command executed:", response.isExecuted); ``` ```java title="SendCommand.java" CQClient client = CQClient.builder() .address("localhost:50000") .clientId("order-service") .build(); CommandResponseMessage response = client.sendCommandRequest( CommandMessage.builder() .channel("orders.process") .body("{\"action\":\"create\",\"orderId\":\"ORD-1234\"}".getBytes()) .timeout(10000) .build()); System.out.println("Executed: " + response.isExecuted()); client.close(); ``` ```csharp title="SendCommand.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); var response = await client.SendCommandAsync(new CommandMessage { Channel = "orders.process", Body = Encoding.UTF8.GetBytes("{\"action\":\"create\",\"orderId\":\"ORD-1234\"}"), Timeout = TimeSpan.FromSeconds(10) }); Console.WriteLine($"Executed: {response.IsExecuted}"); ``` ```kotlin title="SendCommand.kt" val client = CQClient("localhost:50000") val response = client.sendCommand(CommandMessage( channel = "orders.process", body = """{"action":"create","orderId":"ORD-1234"}""".toByteArray(), timeout = 10000 )) println("Command executed: ${response.isExecuted}") client.close() ``` ```cpp title="send_command.cpp" #include #include auto client = kubemq::CQClient("localhost:50000"); kubemq::CommandMessage cmd; cmd.channel = "orders.process"; cmd.body = R"({"action":"create","orderId":"ORD-1234"})"; cmd.timeout = 10000; auto response = client.sendCommand(cmd); std::cout << "Command executed: " << response.isExecuted << std::endl; ``` ```rust title="send_command.rs" use kubemq::prelude::*; use kubemq::CommandBuilder; use std::time::Duration; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; let command = CommandBuilder::new() .channel("orders.process") .body(br#"{"action":"create","orderId":"ORD-1234"}"#.to_vec()) .timeout(Duration::from_secs(10)) .build(); let response = client.send_command(command).await?; println!("Command executed: {}", response.executed); client.close().await?; Ok(()) } ``` ```ruby title="send_command.rb" require 'kubemq' client = KubeMQ::CQClient.new(address: 'localhost:50000', client_id: 'order-service') msg = KubeMQ::CQ::CommandMessage.new( channel: 'orders.process', body: '{"action":"create","orderId":"ORD-1234"}', timeout: 10 ) result = client.send_command(msg) puts "Command executed: #{result.executed}" client.close ``` ```elixir title="send_command.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-service") command = KubeMQ.Command.new( channel: "orders.process", body: ~s({"action":"create","orderId":"ORD-1234"}), timeout: 10_000 ) case KubeMQ.Client.send_command(client, command) do {:ok, response} -> IO.puts("Command executed: #{response.executed}") {:error, err} -> IO.puts("Command failed: #{err.message}") end KubeMQ.Client.close(client) ``` ## When to Use RPC [#when-to-use-rpc] | Scenario | RPC | Events / Queues | | ---------------------------- | -------------------- | ---------------------- | | Service-to-service API calls | ✅ Best choice | Not suitable | | Data lookups and reads | ✅ Queries | Possible but awkward | | Write confirmations | ✅ Commands | Queues with ack | | CQRS implementation | ✅ Commands + Queries | Events for projections | | Fire-and-forget broadcasts | ❌ Blocks on response | ✅ Use Events | | Reliable async processing | ❌ Blocks on response | ✅ Use Queues | Need fire-and-forget delivery? Use [Events](/learn/events) for broadcasts or [Queues](/learn/queues) for reliable processing. Commands and queries are also available via the [CloudEvents protocol](/connectors/cloudevents/how-to/commands-queries) — use any language with a CloudEvents SDK, no KubeMQ client library needed. ## Learn More [#learn-more] # Commands & Queries Reference (/learn/rpc/reference) ## Request Model [#request-model] Commands and queries share the same request structure. The `RequestTypeData` field determines whether the request is treated as a command or query. ## Response Model [#response-model] ## Command vs Query Response Differences [#command-vs-query-response-differences] | Response Field | Command | Query | | -------------- | ------------------------- | ---------------------- | | `Body` | Always `nil` (stripped) | Preserved | | `Metadata` | Always `""` (stripped) | Preserved | | `CacheHit` | Always `false` (stripped) | Preserved | | `ReplyChannel` | Always `""` (stripped) | Always `""` (stripped) | | `Executed` | Preserved | Preserved | | `Error` | Preserved | Preserved | ## Validation Rules [#validation-rules] ### Channel Name [#channel-name] | Rule | Constraint | Error Code | | --------------- | ------------------------- | ---------- | | Required | Cannot be empty | 102 | | No trailing dot | Cannot end with `.` | 119 | | No whitespace | Cannot contain spaces | 108 | | No wildcards | Cannot contain `*` or `>` | 107 | Valid channel name regex: `^[^\s*>]+[^.]$` ### Client ID [#client-id] | Rule | Constraint | Error Code | | ------------ | ----------------------------- | ---------- | | Required | Cannot be empty | 101 | | Alphanumeric | Must match `^[a-zA-Z0-9_-]+$` | — | ### Request Content [#request-content] At least one of `Body` or `Metadata` must be provided. If both are empty, the request is rejected with error code 115. ### Timeout [#timeout] | Rule | Constraint | Error Code | | -------- | ------------------------- | ---------- | | Required | Must be greater than zero | 109 | ### Cache (Queries Only) [#cache-queries-only] | Rule | Constraint | Error Code | | ----------------- | -------------------------------------------- | ---------- | | CacheTTL required | If `CacheKey` is set, `CacheTTL` must be > 0 | 116 | ## Subscription Model [#subscription-model] ### Consumer Groups [#consumer-groups] When multiple responders specify the same `group` value on the same channel: * Each request is delivered to exactly **one** member of the group (round-robin) * When `group` is empty, every responder receives every request (fan-out) * Groups are independent per channel * There is no limit on the number of group members See [Load Balancing](/learn/rpc/how-to/load-balancing) for examples. ## Caching Configuration [#caching-configuration] KubeMQ provides server-side response caching for queries. When a query includes a `CacheKey` and `CacheTTL`: 1. KubeMQ checks the in-memory cache for an entry matching `CacheKey` 2. **Cache hit:** Returns the cached response with `CacheHit: true` (responder is not called) 3. **Cache miss:** Routes the query to a responder, caches the response, and returns it with `CacheHit: false` | Setting | Value | | ------------------ | -------------------------------------------------- | | Storage | In-memory TTL cache | | Default expiration | Per-entry, specified by `CacheTTL` in milliseconds | | Cleanup interval | Every 10 seconds | | Persistence | None — cache is cleared on server restart | Commands do **not** support caching — the `CacheKey` and `CacheTTL` fields are ignored for command requests. See [Query Caching](/learn/rpc/tutorials/query-caching) for a step-by-step tutorial. ## Transport Protocols [#transport-protocols] ### gRPC [#grpc] | Method | Type | Description | | ------------------------------------------------- | ------------- | -------------------------------- | | `SendRequest(Request) → Response` | Unary | Send command or query | | `SendResponse(Response) → Empty` | Unary | Send response back to requester | | `SubscribeToRequests(Subscribe) → stream Request` | Server stream | Subscribe to commands or queries | Default port: `50000` ### REST [#rest] | Method | Path | Description | | ------ | --------------------- | ------------------------------------------- | | `POST` | `/send/request` | Send command or query | | `POST` | `/send/response` | Send RPC response | | `GET` | `/subscribe/requests` | WebSocket: subscribe to commands or queries | Default port: `9090` REST subscription query parameters: | Parameter | Description | Example | | ---------------- | ----------------------- | ---------------- | | `client_id` | Client identifier | `my-responder` | | `channel` | Channel name | `orders.process` | | `group` | Load balancing group | `workers` | | `subscribe_type` | `commands` or `queries` | `commands` | ## Internal Channel Mapping [#internal-channel-mapping] | Pattern | Channel Prefix | Example | | -------- | -------------- | ---------------------------- | | Commands | `_COMMANDS_.` | `_COMMANDS_.orders.process` | | Queries | `_QUERIES_.` | `_QUERIES_.inventory.lookup` | ## Middleware Chain [#middleware-chain] ### Command Sender [#command-sender] ```text Request → Logging → Monitor → Metrics → broker request ``` ### Query Sender [#query-sender] ```text Request → Logging → Monitor → Cache → Metrics → broker request ``` ### Receiver (Commands and Queries) [#receiver-commands-and-queries] ```text broker queue-subscribe → Logging → reqCh delivery ``` ## Error Codes [#error-codes] ### Input Validation [#input-validation] | Code | Error | Description | | ---- | ----------------- | ------------------------------------------------ | | 101 | Invalid ClientID | ClientID is empty | | 102 | Invalid Channel | Channel is empty | | 107 | Invalid Channel | Channel contains wildcards (`*` or `>`) | | 108 | Invalid Channel | Channel contains whitespace | | 109 | Invalid Timeout | Timeout is zero or negative | | 115 | Invalid Request | Both body and metadata are empty | | 116 | Invalid CacheTTL | CacheKey is set but CacheTTL is zero or negative | | 117 | Invalid RequestID | RequestID is empty (on response) | | 119 | Invalid Channel | Channel ends with `.` | ### Runtime Errors [#runtime-errors] | Code | Error | Description | | ---- | ----------------------- | ------------------------------------------------ | | 206 | Invalid Request Type | Request type is not Command or Query | | 207 | Invalid Subscribe Type | Subscribe type is not Commands or Queries | | 301 | Request Timeout | No reply received before timeout expired | | 302 | Connection Unavailable | broker connection is down | | 303 | Invalid Response Format | Reply data cannot be unmarshaled | | 409 | Shutdown Mode | Server is shutting down, all operations rejected | | 412 | Access Denied | Authorization denied for the resource | ## Delivery Semantics [#delivery-semantics] | Aspect | Behavior | | ------------------ | ----------------------------------------------------------------- | | Delivery guarantee | **At-most-once** (request is sent once, no automatic retry) | | Ordering | Requests are independent (no ordering guarantee between requests) | | Timeout | Sender blocks until response arrives or timeout expires | | Acknowledgment | Implicit — response is the acknowledgment | ## SDK Quick Reference [#sdk-quick-reference] ```go // Send Command client.SendCommand(ctx, kubemq.NewCommand(). SetChannel("ch").SetBody([]byte("data")).SetTimeout(10*time.Second)) // Send Query client.SendQuery(ctx, kubemq.NewQuery(). SetChannel("ch").SetBody([]byte("data")).SetTimeout(10*time.Second)) // Send Query with Cache client.SendQuery(ctx, kubemq.NewQuery(). SetChannel("ch").SetBody([]byte("data")).SetTimeout(10*time.Second). SetCacheKey("key").SetCacheTTL(60*time.Second)) // Subscribe to Commands client.SubscribeToCommands(ctx, "ch", "group", kubemq.WithOnCommandReceive(handler), kubemq.WithOnError(errHandler)) // Subscribe to Queries client.SubscribeToQueries(ctx, "ch", "group", kubemq.WithOnQueryReceive(handler), kubemq.WithOnError(errHandler)) ``` ```python # Send Command client.send_command(CommandMessage( channel="ch", body=b"data", timeout_in_seconds=10)) # Send Query client.send_query(QueryMessage( channel="ch", body=b"data", timeout_in_seconds=10)) # Send Query with Cache client.send_query(QueryMessage( channel="ch", body=b"data", timeout_in_seconds=10, cache_key="key", cache_ttl_in_seconds=60)) # Subscribe to Commands client.subscribe_to_commands(CommandsSubscription( channel="ch", group="group", on_receive_command_callback=handler, on_error_callback=err_handler), cancel=cancel) # Subscribe to Queries client.subscribe_to_queries(QueriesSubscription( channel="ch", group="group", on_receive_query_callback=handler, on_error_callback=err_handler), cancel=cancel) ``` ```javascript // Send Command await client.sendCommand({ channel: "ch", body: Buffer.from("data"), timeoutInSeconds: 10 }); // Send Query await client.sendQuery({ channel: "ch", body: Buffer.from("data"), timeoutInSeconds: 10 }); // Send Query with Cache await client.sendQuery({ channel: "ch", body: Buffer.from("data"), timeoutInSeconds: 10, cacheKey: "key", cacheTTL: 60000 }); // Subscribe to Commands client.subscribeToCommands({ channel: "ch", group: "group", onCommand: handler, onError: errHandler }); // Subscribe to Queries client.subscribeToQueries({ channel: "ch", group: "group", onQuery: handler, onError: errHandler }); ``` ```java // Send Command client.sendCommandRequest(CommandMessage.builder() .channel("ch").body("data".getBytes()).timeout(10000).build()); // Send Query client.sendQueryRequest(QueryMessage.builder() .channel("ch").body("data".getBytes()).timeout(10000).build()); // Send Query with Cache client.sendQueryRequest(QueryMessage.builder() .channel("ch").body("data".getBytes()).timeout(10000) .cacheKey("key").cacheTTL(60000).build()); // Subscribe to Commands client.subscribeToCommands(CommandsSubscription.builder() .channel("ch").group("group") .onReceiveCommandCallback(handler) .onErrorCallback(errHandler).build()); // Subscribe to Queries client.subscribeToQueries(QueriesSubscription.builder() .channel("ch").group("group") .onReceiveQueryCallback(handler) .onErrorCallback(errHandler).build()); ``` ```csharp // Send Command await client.SendCommandAsync(new CommandMessage { Channel = "ch", Body = Encoding.UTF8.GetBytes("data"), Timeout = TimeSpan.FromSeconds(10) }); // Send Query await client.SendQueryAsync(new QueryMessage { Channel = "ch", Body = Encoding.UTF8.GetBytes("data"), Timeout = TimeSpan.FromSeconds(10) }); // Send Query with Cache await client.SendQueryAsync(new QueryMessage { Channel = "ch", Body = Encoding.UTF8.GetBytes("data"), Timeout = TimeSpan.FromSeconds(10), CacheKey = "key", CacheTTL = TimeSpan.FromSeconds(60) }); // Subscribe to Commands await foreach (var cmd in client.SubscribeToCommandsAsync( new CommandsSubscription { Channel = "ch", Group = "group" })) { } // Subscribe to Queries await foreach (var q in client.SubscribeToQueriesAsync( new QueriesSubscription { Channel = "ch", Group = "group" })) { } ``` ```kotlin // Send Command client.sendCommand(CommandMessage( channel = "ch", body = "data".toByteArray(), timeout = 10000)) // Send Query client.sendQuery(QueryMessage( channel = "ch", body = "data".toByteArray(), timeout = 10000)) // Send Query with Cache client.sendQuery(QueryMessage( channel = "ch", body = "data".toByteArray(), timeout = 10000, cacheKey = "key", cacheTTL = 60000)) // Subscribe to Commands client.subscribeToCommands( channel = "ch", group = "group", onCommand = handler, onError = errHandler) // Subscribe to Queries client.subscribeToQueries( channel = "ch", group = "group", onQuery = handler, onError = errHandler) ``` ```cpp // Send Command kubemq::CommandMessage cmd; cmd.channel = "ch"; cmd.body = "data"; cmd.timeout = 10000; client.sendCommand(cmd); // Send Query kubemq::QueryMessage query; query.channel = "ch"; query.body = "data"; query.timeout = 10000; client.sendQuery(query); // Send Query with Cache query.cacheKey = "key"; query.cacheTTL = 60000; client.sendQuery(query); // Subscribe to Commands client.subscribeToCommands("ch", "group", handler, errHandler); // Subscribe to Queries client.subscribeToQueries("ch", "group", handler, errHandler); ``` ```rust // Send Command let command = CommandBuilder::new() .channel("ch").body(b"data".to_vec()) .timeout(Duration::from_secs(10)).build(); client.send_command(command).await?; // Send Query let query = QueryBuilder::new() .channel("ch").body(b"data".to_vec()) .timeout(Duration::from_secs(10)).build(); client.send_query(query).await?; // Send Query with Cache let query = QueryBuilder::new() .channel("ch").body(b"data".to_vec()) .timeout(Duration::from_secs(10)) .cache_key("key").cache_ttl(Duration::from_secs(60)).build(); client.send_query(query).await?; // Subscribe to Commands client.subscribe_to_commands("ch", "group", handler, None).await?; // Subscribe to Queries client.subscribe_to_queries("ch", "group", handler, None).await?; ``` ```ruby # Send Command msg = KubeMQ::CQ::CommandMessage.new( channel: "ch", body: "data", timeout: 10) client.send_command(msg) # Send Query msg = KubeMQ::CQ::QueryMessage.new( channel: "ch", body: "data", timeout: 10) client.send_query(msg) # Send Query with Cache msg = KubeMQ::CQ::QueryMessage.new( channel: "ch", body: "data", timeout: 10, cache_key: "key", cache_ttl: 60) client.send_query(msg) # Subscribe to Commands sub = KubeMQ::CQ::CommandsSubscription.new(channel: "ch", group: "group") client.subscribe_to_commands(sub, cancellation_token: cancel, on_error: err_handler) { |cmd| handler.call(cmd) } # Subscribe to Queries sub = KubeMQ::CQ::QueriesSubscription.new(channel: "ch", group: "group") client.subscribe_to_queries(sub, cancellation_token: cancel, on_error: err_handler) { |query| handler.call(query) } ``` ```elixir # Send Command command = KubeMQ.Command.new( channel: "ch", body: "data", timeout: 10_000) KubeMQ.Client.send_command(client, command) # Send Query query = KubeMQ.Query.new( channel: "ch", body: "data", timeout: 10_000) KubeMQ.Client.send_query(client, query) # Send Query with Cache query = KubeMQ.Query.new( channel: "ch", body: "data", timeout: 10_000, cache_key: "key", cache_ttl: 60_000) KubeMQ.Client.send_query(client, query) # Subscribe to Commands KubeMQ.Client.subscribe_to_commands(client, "ch", group: "group", on_command: handler, on_error: err_handler) # Subscribe to Queries KubeMQ.Client.subscribe_to_queries(client, "ch", group: "group", on_query: handler, on_error: err_handler) ``` ## Related [#related] * [Getting Started](/learn/rpc/getting-started) — send your first command and query * [Configure Timeouts](/learn/rpc/how-to/timeout-configuration) — per-request timeouts and retries * [Load Balancing](/learn/rpc/how-to/load-balancing) — distribute requests across responders * [Events Reference](/learn/events/reference) — for the fire-and-forget pattern * [Queues Reference](/learn/queues/reference) — for guaranteed delivery # .NET Aspire (/integrations/aspire) The KubeMQ .NET Aspire integration wires the [KubeMQ](https://kubemq.io) message broker into the [.NET Aspire](https://learn.microsoft.com/dotnet/aspire/) application model. It follows the standard Aspire two-package model: `KubeMQ.Aspire.Hosting` provisions a KubeMQ container in your AppHost, and `KubeMQ.Aspire.Client` configures an `IKubeMQClient` in each consuming service — complete with health checks, OpenTelemetry, and keyed dependency injection. The client is a **native gRPC SDK client** that connects to the broker on port `50000`; there is no connector to enable. New to the idea? See [what is an integration](/integrations#what-an-integration-is) for how SDK-level integrations differ from server-side [connectors](/connectors). ## Why KubeMQ + .NET Aspire? [#why-kubemq--net-aspire] * **Container auto-provisioning** — `AddKubeMQ("messaging")` in the AppHost runs a KubeMQ broker container with the gRPC, REST, and Dashboard endpoints already mapped * **Zero-config connection strings** — `WithReference(messaging)` injects the broker address into each service as an Aspire connection string; `AddKubeMQClient("messaging")` reads it back automatically, so no host or port is hard-coded * **Built-in health checks** — readiness and liveness checks are registered out of the box with `ready` and `live` tags, surfacing the SDK's connection state to the Aspire dashboard and Kubernetes probes * **OpenTelemetry on by default** — the SDK's `KubeMQ.Sdk` tracing source and meter are registered automatically, so traces and metrics flow into the Aspire telemetry pipeline with no extra code * **Keyed DI for multiple brokers** — `AddKeyedKubeMQClient` registers a distinct `IKubeMQClient` per named broker, resolved with `[FromKeyedServices(...)]` ## The two packages [#the-two-packages] | Package | Project | Responsibility | | ----------------------- | ------- | ------------------------------------------------------------------------- | | `KubeMQ.Aspire.Hosting` | AppHost | Provision KubeMQ containers in the Aspire AppHost | | `KubeMQ.Aspire.Client` | Service | Configure `IKubeMQClient` with health checks, OpenTelemetry, and keyed DI | ## Install [#install] Add the hosting package to your **AppHost** project: ```bash dotnet add package KubeMQ.Aspire.Hosting ``` Add the client package to each **service** project that talks to KubeMQ: ```bash dotnet add package KubeMQ.Aspire.Client ``` ## Quick start [#quick-start] Provision the broker in the AppHost and reference it from a service. The connection string flows from the container resource into the service automatically. ```csharp title="AppHost/Program.cs" var builder = DistributedApplication.CreateBuilder(args); var kubemqKey = builder.AddParameter("kubemq-key", secret: true); var messaging = builder.AddKubeMQ("messaging") .WithLicenseKey(kubemqKey) .WithDataVolume(); builder.AddProject("webapi") .WithReference(messaging) .WaitFor(messaging); builder.Build().Run(); ``` ```csharp title="MyWebApi/Program.cs" var builder = WebApplication.CreateBuilder(args); builder.AddKubeMQClient("messaging"); var app = builder.Build(); app.Run(); ``` `AddKubeMQClient` registers `IKubeMQClient` as a singleton and resolves the broker address from the `"messaging"` connection string injected by `WithReference`. For the full walkthrough — parameters, secrets, and verifying the connection — see [Getting Started](/integrations/aspire/tutorials/getting-started). ## Architecture [#architecture] The AppHost provisions the KubeMQ container and injects its address as a connection string into each referencing service. In the service, `AddKubeMQClient` reads that connection string, constructs an `IKubeMQClient` that speaks gRPC to the broker on port `50000`, and registers the health checks and OpenTelemetry instrumentation as side outputs into the Aspire pipeline. *The AppHost provisions the broker container and injects its `host:port` address; the service resolves it by connection name, builds an `IKubeMQClient`, and gets health checks and telemetry for free.* ## What the integration provisions [#what-the-integration-provisions] The hosting package provisions a real KubeMQ container; the client package layers Aspire conventions on top of the KubeMQ .NET SDK. | Feature | Detail | | ----------------------- | --------------------------------------------------------------------------------------- | | **Container image** | `AddKubeMQ` runs `europe-docker.pkg.dev/kubemq/images/kubemq:2.5.0` | | **gRPC endpoint** | Target port `50000` (TCP) — the primary client connection | | **REST endpoint** | Target port `9090` (HTTP) | | **Dashboard endpoint** | Target port `8080` (HTTP) | | **Persistent lifetime** | The container uses `ContainerLifetime.Persistent`, so it survives AppHost restarts | | **Persistent storage** | `WithDataVolume()` binds a volume to `/store` for durable messages | | **License key** | `WithLicenseKey()` sets the `KUBEMQ_TOKEN` environment variable from a secret parameter | | **Image override** | `WithImageTag()` overrides the default image tag | | **Health checks** | Readiness (`ready`) and liveness (`live`) checks reporting the SDK connection state | | **OpenTelemetry** | Tracing source and meter `KubeMQ.Sdk` registered by default | | **Keyed DI** | `AddKeyedKubeMQClient` for multiple named brokers in one service | ## Supported runtime [#supported-runtime] | Requirement | Version | | ------------------- | --------------------------------------------- | | Language | C# (.NET) | | .NET runtime | .NET 8.0 or .NET 9.0 | | .NET Aspire | 9.0+ | | `KubeMQ.SDK.CSharp` | 3.0.1 | | KubeMQ broker | gRPC on `:50000` (always on — no enable flag) | ## Messaging patterns [#messaging-patterns] Once `IKubeMQClient` is injected, it speaks the full KubeMQ messaging surface through the native SDK. The capability pages document the Aspire-specific wiring and link out to the core pattern docs for the send/receive semantics. ## Quick links [#quick-links] **Requirements** — .NET 8.0 or .NET 9.0, .NET Aspire 9.0 or later, a KubeMQ license key set via `WithLicenseKey()`, and Docker for local development with Aspire. New to KubeMQ? Start with the [KubeMQ Getting Started guide](/deploy) for core concepts like Events, Queues, and RPC before wiring up Aspire. # Celery (/integrations/celery) [`kubemq-celery`](https://pypi.org/project/kubemq-celery/) is a KubeMQ transport and queue-peek result backend for [Celery](https://docs.celeryq.dev/) task queues. It lets you run Celery on KubeMQ with a one-line configuration change — `broker="kubemq://localhost:50000"` — making KubeMQ the only Kubernetes-native, in-cluster Celery broker. The transport plugs into Kombu (Celery's messaging layer) and registers the `kubemq://` URL scheme, so your existing tasks, workers, and tooling keep working unchanged. New to the idea? See [what is an integration](/integrations#what-an-integration-is) for how SDK-level integrations differ from the server-side [connectors](/connectors). ## Supported versions [#supported-versions] | Requirement | Supported versions | | ------------- | ------------------------------------------ | | Language | Python `>= 3.10` | | Celery | `>= 5.4` | | Kombu | `>= 5.4` | | KubeMQ SDK | `kubemq >= 4.1.5` | | KubeMQ broker | Reachable over native gRPC on port `50000` | | Package | `kubemq-celery` `1.1.0` (MIT) | ## Why KubeMQ over Redis/RabbitMQ? [#why-kubemq-over-redisrabbitmq] KubeMQ replaces visibility-timeout heuristics and broker plugins with native gRPC primitives, and runs as a first-class workload inside your Kubernetes cluster. | | Redis | RabbitMQ | KubeMQ | | ------------------------ | -------------------- | ----------------- | --------------------------- | | **Acknowledgment** | Visibility timeout | Native AMQP ack | Native gRPC ack | | **Delayed delivery** | Client-side polling | Plugin required | Native `delay_in_seconds` | | **Kubernetes** | External StatefulSet | External + Erlang | K8s-native, auto-clustering | | **Connection stability** | TCP reset under load | Stable | gRPC keep-alive | | **Setup complexity** | Moderate | High | Low | * **No visibility-timeout bugs** — messages are explicitly acknowledged or rejected over a gRPC stream, instead of relying on a timeout window that causes duplicates when set too low and stalls reprocessing when set too high. * **Native delayed delivery** — `countdown` and `eta` map directly to KubeMQ's `delay_in_seconds` with zero polling overhead and no broker plugin. * **Kubernetes-native** — an in-cluster broker with auto-clustering, gRPC keep-alive for long-lived connections, and KEDA-driven autoscaling, instead of an external StatefulSet or an Erlang-backed service. ## Install [#install] Install from PyPI with `pip` or [uv](https://docs.astral.sh/uv/): ```bash # pip pip install kubemq-celery # uv (recommended) uv add kubemq-celery ``` Switching an existing app is a one-line change — `kubemq-celery` is a drop-in replacement for Redis or RabbitMQ: ```python title="tasks.py" import kubemq_celery # registers the kubemq:// transport from celery import Celery app = Celery("myapp", broker="kubemq://localhost:50000") @app.task def add(x, y): return x + y ``` `import kubemq_celery` must run before Celery resolves the broker URL — it registers the `kubemq://` scheme with Kombu. Without it, Celery raises an "unknown transport" error. Start a worker and send a task: ```bash celery -A tasks worker --loglevel=info ``` ```python from tasks import add add.delay(4, 6) ``` For the full walkthrough — including a local Docker broker and the result backend — see [Getting Started](/integrations/celery/tutorials/getting-started). ## Features [#features] | Feature | Description | | ---------------------- | -------------------------------------------------------------------------------- | | **One-line setup** | `broker="kubemq://host:50000"` — drop-in replacement for Redis/RabbitMQ | | **Native ack/nack** | Messages are explicitly acknowledged over gRPC — no visibility-timeout bugs | | **Delayed delivery** | `countdown` and `eta` map to KubeMQ's native `delay_in_seconds` (no polling) | | **Per-message TTL** | `message_expiration` transport option for automatic message expiration | | **Dead letter queue** | Built-in DLQ via `max_receive_count` + `dead_letter_queue` transport options | | **Batch receive** | `max_batch_size` fetches multiple messages per gRPC call for higher throughput | | **Queue-peek results** | Optional result backend using non-destructive peek — no external Redis/DB needed | | **Full monitoring** | Flower, `celery inspect`, `celery control` — all work via KubeMQ Events fanout | | **TLS + mTLS** | `kubemq+tls://` for encrypted gRPC connections, mTLS for mutual authentication | | **Async transport** | `kubemq+async://` for native asyncio I/O with `--pool=asyncio` workers | | **KEDA autoscaling** | Kubernetes-native broker with auto-clustering and KEDA-driven scaling | | **Auto-registration** | `import kubemq_celery` registers the `kubemq://` URL scheme automatically | ## Architecture [#architecture] A Celery worker talks to KubeMQ through Kombu's virtual transport layer. `kubemq-celery` implements a `Channel` that extends Kombu's `virtual.Channel` (with `supports_fanout = True`) and maps Kombu's storage primitives onto KubeMQ's gRPC API on port `50000`. Task messages travel over KubeMQ **Queues** (with native ack/nack and `delay_in_seconds`), while pidbox and monitoring traffic — Flower, `celery inspect`, `celery control` — use KubeMQ **Events** fanout. When the optional result backend is enabled, results are written as Queue messages and read back with a non-destructive peek, so multiple callers can fetch the same result without consuming it. *A Celery worker speaks `kubemq://` through Kombu; tasks and peek-read results ride KubeMQ Queues, while pidbox and monitoring traffic fan out over KubeMQ Events.* ## Usage [#usage] ## Quick Links [#quick-links] **Requirements** — Python `>= 3.10`, Celery `>= 5.4`, Kombu `>= 5.4`, and the `kubemq` SDK `>= 4.1.5`. Current version: `1.1.0`. You also need a reachable KubeMQ broker (Docker, Kubernetes, or standalone). New to KubeMQ? Start with the [KubeMQ Getting Started guide](/deploy) for core concepts like Queues and Events before wiring up Celery. # FastStream (/integrations/faststream) [`kubemq-faststream`](https://github.com/kubemq-io/kubemq-faststream) is a KubeMQ broker adapter for the [FastStream](https://github.com/airtai/faststream) async messaging framework. It registers `KubeMQBroker` as a first-class FastStream broker, so all five KubeMQ patterns — **Events**, **Events Store**, **Queues**, **Commands**, and **Queries** — become ordinary `@broker.subscriber(...)` endpoints, fully wired into FastStream's lifecycle, dependency injection, middleware, and testing infrastructure. New to the idea? See [what is an integration](/integrations#what-an-integration-is) for how SDK-level integrations differ from server-side [connectors](/connectors). ## Why FastStream + KubeMQ [#why-faststream--kubemq] * **All five patterns as FastStream endpoints** — one keyword on the subscriber (`events=`, `events_store=`, `queues=`, `commands=`, `queries=`) selects the pattern; no per-pattern client wiring. * **Idiomatic decorator API** — register handlers with `@broker.subscriber(...)` and auto-publish results with `@broker.publisher(...)`, exactly like every other FastStream broker. * **Full FastStream pipeline** — parser, decoder, broker and subscriber middleware, and FastDepends dependency injection run for every message. * **In-memory testing** — `TestKubeMQBroker` routes published messages to matching subscribers without a live broker, exercising the real parse/decode/middleware path. * **Runs inside your web app** — drop the broker into a FastAPI, Starlette, Django, or Flask process and consume KubeMQ messages on the same event loop. ## Installation [#installation] ```bash uv add kubemq-faststream ``` ```bash pip install kubemq-faststream ``` **Requirements**: Python 3.11+ and a running [KubeMQ](https://kubemq.io/) broker. | Requirement | Supported versions | | ----------- | ------------------------- | | Language | Python 3.11, 3.12, 3.13 | | FastStream | >= 0.6.7, \< 0.7.0 | | KubeMQ SDK | >= 4.1.5, \< 5 | | Package | `kubemq-faststream` 0.1.0 | Start a broker locally with Docker: `kubemq-faststream` is a native gRPC SDK client: it talks to KubeMQ over the gRPC port `50000`, the same transport the native SDKs use. It is **always on** — there is no server-side connector to enable and no HTTP flag to set. Port `9090` is the shared HTTP server (REST and the HTTP connectors) and is not used by FastStream. ## Architecture [#architecture] When it connects, `KubeMQBroker` creates three KubeMQ SDK async clients — one per transport family — each on its own gRPC channel and all sharing a single set of connection settings (URL, client ID, auth token, TLS, message-size limits, keepalive). Your subscriber keyword routes each `publish`, `request`, and handler registration to the matching client. *`KubeMQBroker` fans your handlers across three SDK clients, all reaching the KubeMQ broker over gRPC on `:50000`.* A minimal app wires the broker into `FastStream`, registers a subscriber, and publishes after startup: ```python title="app.py" import asyncio from faststream import FastStream from kubemq_faststream import KubeMQBroker broker = KubeMQBroker("kubemq://localhost:50000") app = FastStream(broker) @broker.subscriber(queues="orders") async def handle_order(order: dict) -> None: print(f"Processing order: {order}") @app.after_startup async def publish() -> None: await broker.publish({"id": "ORD-001", "item": "Widget"}, queues="orders") if __name__ == "__main__": asyncio.run(app.run()) ``` ## Messaging patterns [#messaging-patterns] Each KubeMQ pattern maps to a subscriber keyword. The pages below document the FastStream API for each — the decorators, options, and `broker.publish`/`broker.request` calls — and link to the underlying KubeMQ concept. ## Capabilities [#capabilities] * **`KubeMQBroker` over native gRPC** — connect with `kubemq://host:50000` (or `kubemq+tls://` for TLS) straight to the KubeMQ gRPC port. * **`KubeMQRouter` composition** — group handlers into routers whose `prefix` propagates to every channel. * **`@broker.publisher` auto-publish** — stack on a subscriber to publish its return value to another channel. * **`AckPolicy` settlement** — choose how queue messages are acked, nacked, or rejected. * **`StartPosition` replay** — replay Events Store streams from first, a sequence, or a point in time. * **Server-side query caching** — pass `cache_key` and `cache_ttl` on a query request. * **Health check** — `await broker.ping()` verifies broker connectivity. ## Next steps [#next-steps] New to KubeMQ? Start with the [KubeMQ Getting Started guide](/deploy) for the core concepts behind these patterns. # MassTransit (/integrations/masstransit) [MassTransit](https://masstransit.io) is the open-source distributed-application framework for .NET. **MassTransit.KubeMQ** is a MassTransit *transport* that runs the bus over KubeMQ via gRPC: you register it the same way you would RabbitMQ or Azure Service Bus, and your existing consumers, sagas, and message contracts work unchanged on KubeMQ. Because the transport plugs in below the MassTransit abstractions, adopting KubeMQ is a configuration change rather than a rewrite. Message contracts, `IConsumer` implementations, sagas, the middleware pipeline, serialization, retry/redelivery policies, the EF Core outbox, OpenTelemetry, and DI registration (`AddConsumer`, `AddSaga`) all behave identically — only where messages physically travel changes. New to the idea? See [what is an integration](/integrations#what-an-integration-is) for how SDK-level integrations differ from the server-side [connectors](/connectors). ## Why MassTransit + KubeMQ [#why-masstransit--kubemq] * **Drop-in transport swap** — change `UsingRabbitMq`, `UsingAzureServiceBus`, or `UsingAmazonSqs` to `UsingKubeMQ`; contracts and consumers stay the same. * **Native request/response** — KubeMQ's CQ pattern provides built-in request-reply with no temporary reply queues (unlike RabbitMQ/SQS, which simulate it). * **Fire-and-forget or durable-replayable publish** — `Publish()` maps to KubeMQ Events for fan-out, or to EventsStore for persistent, replayable pub/sub. * **Built-in priority queues and consumer groups** — weighted priority channels and competing consumers without extra infrastructure. * **Full observability** — W3C trace context is propagated via tags, and the bus integrates with ASP.NET Core health checks and a `MassTransit.KubeMQ` metrics meter. ## Install [#install] ```bash title="terminal" dotnet add package MassTransit.KubeMQ ``` The transport targets `net8.0` and requires MassTransit `>= 8.5.0`. ## Supported versions [#supported-versions] | Requirement | Supported versions | | ------------- | ------------------------------------------ | | Language | C# / .NET (`net8.0` target) | | Runtime | .NET SDK `8.0+` | | MassTransit | `>= 8.5.0` | | KubeMQ broker | Reachable over native gRPC on port `50000` | | Package | `MassTransit.KubeMQ` `1.0.0` | The transport is a **native gRPC client** that connects to KubeMQ on port `50000`. It is not an HTTP connector, so there is no `CONNECTORS*_ENABLE` flag to set — exposing the gRPC port is all that is needed. KubeMQ channels are created automatically on first use; there is no exchange, binding, virtual host, or topic-subscription topology to provision. ## Architecture [#architecture] Your application talks to MassTransit abstractions as usual. The transport layers KubeMQ onto an InMemory base bus (for the `IBusControl` lifecycle) plus a **KubeMQ rider** that owns the connections and receive transports. The rider bridges those abstractions to the broker over gRPC on port `50000`, where KubeMQ dispatches to its four native subsystems. *The KubeMQ rider maps MassTransit's transport-agnostic operations onto native KubeMQ Queues, Events, EventsStore, and CQ over gRPC.* ## Pattern mapping [#pattern-mapping] MassTransit's transport-agnostic verbs map to KubeMQ's native messaging patterns. The mapping determines delivery semantics and which KubeMQ subsystem backs each message. | MassTransit verb | KubeMQ pattern | Delivery | Capability page | | --------------------- | --------------------- | ------------------------------------------------- | ----------------------------------------------------------------------- | | **Send** | Queues | Point-to-point (exactly one consumer) | [Queues](/integrations/masstransit/how-to/queues) | | **Publish** | Events | Fan-out (all active subscribers, fire-and-forget) | [Events](/integrations/masstransit/how-to/events) | | **Publish** (durable) | EventsStore | Fan-out with persistence and replay | [Events Store](/integrations/masstransit/how-to/events-store) | | **Request/Response** | Commands/Queries (CQ) | Native request-reply | [Commands & Queries](/integrations/masstransit/how-to/commands-queries) | ## Capabilities [#capabilities] ## Next steps [#next-steps] New to KubeMQ? Start with the [KubeMQ Getting Started guide](/deploy) for the core messaging concepts. # KEDA (/integrations/keda) The **KubeMQ KEDA external scaler** brings Kubernetes-native autoscaling to KubeMQ queue consumers. It is a standalone gRPC service that reads the live `Waiting` message count from a KubeMQ queue channel and exposes it to [KEDA](https://keda.sh) as a single metric, so a `ScaledObject` can scale queue-consuming workloads — GPU inference workers, batch processors, task consumers — up and down (and all the way to zero) on real backlog rather than CPU or memory. ## What it is [#what-it-is] KEDA is the Kubernetes Event-Driven Autoscaler. It scales workloads from external signals through *scalers* — small gRPC services that implement KEDA's `ExternalScaler` interface. The KubeMQ scaler is one such service: it answers KEDA's `IsActive` / `GetMetrics` calls by querying the broker's `ListQueuesChannels` API and reporting the channel's `Outgoing.Waiting` count. Unlike the framework adapters in this section, KEDA is **not a messaging client your app embeds** — it is an autoscaler that runs alongside your consumers. Your consumers connect to KubeMQ with the [native gRPC SDK](/deploy) on port `50000` as usual; the scaler observes the same queues over that API and tells KEDA how many replicas to run. The metric it exposes is queue depth, so the underlying concept is [Queues](/learn/queues) — the scaler reads the same `Waiting` count you would see when introspecting a queue channel. ## Why KEDA + KubeMQ [#why-keda--kubemq] * **Queue-depth-driven autoscaling** — scale on the live `Waiting` count of a [queue](/learn/queues), not on CPU or memory. * **Scale-to-zero** — drop a deployment to zero replicas when its queue is empty and scale back up the moment work arrives. * **Never-fake-zero** — KubeMQ errors map to gRPC status codes so KEDA applies its `fallback` strategy on a broker outage instead of scaling a healthy workload to zero. * **Push or poll** — `external` (poll) and `external-push` (long-lived stream) trigger types, the latter for faster scale-from-zero detection. * **Lightweight** — a single Go service requesting just `50m` CPU and `64Mi` memory, hardened with a non-root, read-only-root-filesystem `securityContext`. ## Architecture [#architecture] The scaler is a standalone gRPC `ExternalScaler` server (default port `9090`). The KEDA operator calls it to read queue depth; the scaler in turn dials the KubeMQ broker's gRPC API (`50000`), calls `ListQueuesChannels`, and reports the `Waiting` count so KEDA can scale the target deployment. *The KEDA operator reads queue depth from the scaler, which polls the KubeMQ broker and drives the target deployment's replica count.* ## The metric and the RPCs [#the-metric-and-the-rpcs] The scaler exposes exactly one metric, `kubemq-queue-waiting`, whose value is the queue channel's `Outgoing.Waiting` count. It implements the four RPCs of KEDA's `ExternalScaler` service: | RPC | Purpose | | ---------------- | ----------------------------------------------------------------------------------------------------- | | `IsActive` | Returns `true` when `Waiting > activationTargetWaiting` — the scale-from-zero gate. | | `StreamIsActive` | Server-streaming. Pushes active status immediately, then re-polls every `5s`. Drives `external-push`. | | `GetMetricSpec` | Returns the target value for `kubemq-queue-waiting`, set from `targetWaiting`. | | `GetMetrics` | Returns the current `Waiting` count as the metric value. | KEDA supports two trigger types against the same scaler — `external` (poll-based, default) and `external-push` (a long-lived `StreamIsActive` stream for faster scale-from-zero). See [Concepts](/integrations/keda/concepts) for the full model. ## Prerequisites [#prerequisites] * **Kubernetes 1.27+** — required for the native gRPC liveness/readiness probes the scaler uses. * **KEDA 2.10+** — installed in the cluster. * **A KubeMQ broker** — reachable in-cluster on its gRPC port `50000` at the address you pass in `kubemqAddress`. ## Supported runtime [#supported-runtime] The scaler is a standalone Go service, not a language SDK you embed — your consumers can be written in any language and connect to the broker over native gRPC as usual. | Requirement | Version | | ----------------------------------- | --------------------------------------------- | | KEDA | 2.10+ | | Kubernetes | 1.27+ | | Scaler image | `kubemq/kubemq-keda-scaler:1.0.0` | | `github.com/kubemq-io/kubemq-go/v2` | v2.0.3 | | Go (to build from source) | 1.25+ | | KubeMQ broker | gRPC on `:50000` (always on — no enable flag) | ## A ScaledObject at a glance [#a-scaledobject-at-a-glance] A `ScaledObject` targets your consumer `Deployment` and points an `external` trigger at both the scaler Service and your broker: ```yaml title="scaled-object-basic.yaml" apiVersion: keda.sh/v1alpha1 kind: ScaledObject metadata: name: kubemq-queue-scaler spec: scaleTargetRef: name: my-queue-consumer pollingInterval: 15 cooldownPeriod: 60 minReplicaCount: 1 maxReplicaCount: 10 triggers: - type: external metadata: scalerAddress: kubemq-keda-scaler.default.svc.cluster.local:9090 kubemqAddress: kubemq.default.svc.cluster.local:50000 queueName: my-queue targetWaiting: "10" ``` ## Explore [#explore] New to integrations? See [what an integration is](/integrations#what-an-integration-is) for the mental model, then the [Queues](/learn/queues) concept and the [Getting Started guide](/deploy) for the core SDK your consumers use. The scaler observes queues over native gRPC on port `50000` — it is not a [connector](/connectors). # NestJS (/integrations/nestjs) [`@kubemq/nestjs-transport`](https://www.npmjs.com/package/@kubemq/nestjs-transport) is a custom [NestJS](https://nestjs.com/) transport that integrates KubeMQ into the NestJS microservices ecosystem. It surfaces all five KubeMQ messaging patterns — Commands, Queries, Events, Events Store, and Queues — through idiomatic `@*Handler` decorators and a standard `ClientProxy`, so a Nest app sends and handles KubeMQ messages with the same DI, modules, and testing patterns it already uses for HTTP. It is built on the native [`kubemq-js`](https://www.npmjs.com/package/kubemq-js) SDK and speaks gRPC directly to the broker on port `50000`. ## Why KubeMQ + NestJS [#why-kubemq--nestjs] * **Idiomatic decorators** — `@CommandHandler`, `@QueryHandler`, `@EventHandler`, `@EventStoreHandler`, and `@QueueHandler` replace manual `@MessagePattern` / `@EventPattern` metadata wiring; the right KubeMQ pattern is attached for you. * **One transport, five patterns** — a single `KubeMQServer` strategy (inbound) and `KubeMQClientProxy` (outbound) cover every pattern; the `KubeMQRecord` builder re-targets the message type with `.asQuery()`, `.asEventStore()`, or `.asQueue()`. * **Dynamic-module DI** — `forRoot` / `forRootAsync` / `register` / `registerAsync` / `forFeature` / `forTest` register the connection and named clients through NestJS dependency injection. * **Distributed CQRS** — the [CQRS bridge](/integrations/nestjs/how-to/cqrs-bridge) routes `@nestjs/cqrs` `CommandBus`, `QueryBus`, and `EventBus` traffic across services over KubeMQ channels. * **TypeScript-first** — full type safety with an ESM + CJS dual build and per-pattern context types. * **Broker-free tests** — `MockKubeMQClient`, `MockKubeMQServer`, and `KubeMQModule.forTest()` exercise services and handlers without a live broker. ## Installation [#installation] ```bash npm install @kubemq/nestjs-transport kubemq-js ``` Most peer dependencies already ship with a typical NestJS project; install any that are missing: ```bash npm install @nestjs/common @nestjs/core @nestjs/microservices rxjs reflect-metadata ``` Optional peer dependencies enable specific features — `@nestjs/terminus` for the health-check indicator and `@nestjs/cqrs` for the [CQRS bridge](/integrations/nestjs/how-to/cqrs-bridge): ```bash npm install @nestjs/terminus # health checks npm install @nestjs/cqrs # CQRS bridge ``` **Prerequisites:** Node.js 20.11.0 or later and a running KubeMQ broker (default `localhost:50000`). The transport is a native gRPC SDK client — there is **no connector flag to enable**; the gRPC API on `50000` is always on. Start a local broker with Docker (gRPC on `50000`, the shared HTTP server and dashboard on `9090`): ## Supported versions [#supported-versions] | Requirement | Supported versions | | ----------- | ---------------------------------------- | | Language | TypeScript `5.5+` (ESM + CJS dual build) | | Runtime | Node.js `>= 20.11.0` | | NestJS | `10.x` or `11.x` | | KubeMQ SDK | `kubemq-js` `^3.0.1` | | Package | `@kubemq/nestjs-transport` `1.0.0` | ## Architecture [#architecture] A KubeMQ-backed NestJS app is a [hybrid application](https://docs.nestjs.com/faq/hybrid-application): the HTTP app stays as-is while the KubeMQ transport is attached as a microservice. Inbound handlers run through the `KubeMQServer` strategy passed to `app.connectMicroservice({ strategy })`; outbound clients are `KubeMQClientProxy` instances registered with `KubeMQModule`. Both wrap a `kubemq-js` client and speak gRPC to the broker on port `50000`. *Decorated handlers and injected client proxies both wrap `kubemq-js` and reach the broker over native gRPC.* ## Capabilities [#capabilities] The transport plugs into NestJS DI and dispatch. Each capability below documents the integration's own API surface and links to the underlying KubeMQ concept rather than re-teaching it. | Capability | Surface | KubeMQ concept | | ------------------ | ---------------------------------------------------- | ----------------------------------- | | Commands / Queries | `@CommandHandler` / `@QueryHandler`, `client.send()` | [RPC](/learn/rpc) | | Events | `@EventHandler`, `client.emit()` | [Events](/learn/events) | | Events Store | `@EventStoreHandler`, `.asEventStore()` | [Events Store](/learn/events-store) | | Queues | `@QueueHandler`, `.asQueue()`, manual ack | [Queues](/learn/queues) | | Module DI | `forRoot` / `register` / `forFeature` / `forTest` | — | | Distributed CQRS | `KubeMQCqrsModule` over `@nestjs/cqrs` buses | — | ## Next steps [#next-steps] New to integrations? See [what an integration is](/integrations#what-an-integration-is) for the mental model, or start with the [KubeMQ Getting Started guide](/deploy) for core broker concepts. This transport speaks native gRPC on port `50000` — it is a direct SDK client, not a [connector](/connectors). # Ray Serve (/integrations/rayserve) `kubemq-rayserve` is a Python package that plugs [KubeMQ](https://kubemq.io/) into [Ray Serve](https://docs.ray.io/en/latest/serve/index.html) as a task backend. It provides `KubeMQTaskProcessorAdapter` — the first non-Celery adapter for Ray Serve's `TaskProcessorAdapter` framework — so a Ray Serve deployment can run **queue-based asynchronous** and **blocking synchronous** ML inference, with KubeMQ acting as the message broker, the result store, the autoscaling signal, and the progress event bus all at once. The adapter is a native gRPC SDK client. It connects to a KubeMQ broker on port `50000` and is always available — there is no connector to enable on the server. You install one package, point it at a broker, and Ray Serve drives the rest of the lifecycle. Ray Serve is an [integration](/integrations#what-an-integration-is): a client-side library that embeds a KubeMQ SDK, distinct from the server-side [connectors](/connectors) (MCP, A2A, CloudEvents) that run inside the broker. ## Why KubeMQ + Ray Serve [#why-kubemq--ray-serve] * **One Kubernetes-native broker** — a single binary carries the task queue, the synchronous query channel, the result store, and the progress event stream. No Erlang, Redis, or RabbitMQ to operate alongside Ray. * **Built-in sync inference** — `query_task_sync` gives blocking request-response inference that the reference Celery adapter does not offer. * **Built-in progress tracking** — `report_progress` streams real-time task progress over KubeMQ Events; Celery requires custom signals. * **Queue-peek result backend** — results live in KubeMQ itself, with no separate Redis or database to provision. * **Native autoscaling** — `kubemq_queue_depth_policy` scales Ray Serve replicas from live queue depth, and the same depth drives [KEDA](/integrations/keda) for cluster-level scaling. * **One-dependency setup** — only KubeMQ is required, versus a broker plus a separate result backend. ## Install [#install] ```bash title="terminal" uv pip install kubemq-rayserve ``` | Requirement | Version | | ------------------------ | --------- | | Python | >= 3.10 | | Ray Serve (`ray[serve]`) | >= 2.50.0 | | `kubemq` (Python SDK) | >= 4.1.5 | | `pydantic` | >= 2.0 | A running KubeMQ broker is required (default address `localhost:50000`). Start one locally with Docker: Port `50000` is KubeMQ's gRPC endpoint — the only port the adapter's SDK clients use. Port `9090` is the shared HTTP server (REST and the AI-agent connectors); the dashboard runs separately on port `8080`. The adapter and the `kubemq_queue_depth_policy` autoscaler both speak gRPC on `50000`. ## Architecture [#architecture] A producer enqueues a serialized task onto a KubeMQ Queue. Inside the Ray Serve deployment, the `KubeMQTaskProcessorAdapter` runs a consumer thread that polls the queue, dispatches each task to a registered handler, and writes the result back into the queue-peek result backend. A parallel Query channel serves blocking sync inference, and an Events channel carries progress updates — all over the same gRPC connection to port `50000`. *The adapter maps Ray Serve's task model onto three KubeMQ primitives — Queues, Queries, and Events — over one gRPC connection.* The adapter never re-teaches KubeMQ messaging: async tasks ride [Queues](/learn/queues), sync inference rides request-response [RPC](/learn/rpc) (Queries), and progress rides [Events](/learn/events). Each capability page documents the adapter API and links to the underlying concept. ## Capabilities [#capabilities] `KubeMQTaskProcessorAdapter` exposes eight capabilities, grouped into three inference models plus the operational features that surround them. | Capability | What it does | KubeMQ primitive | | ----------------- | ------------------------------------------------------------------ | --------------------------- | | Async inference | Enqueue a task, poll the result backend for the outcome | [Queues](/learn/queues) | | Sync inference | Blocking request-response inference via `query_task_sync` | [RPC](/learn/rpc) (Queries) | | Progress tracking | Stream live `report_progress` updates to subscribers | [Events](/learn/events) | | Cancellation | Soft-cancel a task — overwrite its result with `CANCELLED` | Queues | | DLQ monitoring | `on_dlq` callback alerts on permanently failed tasks | Queues | | Autoscaling | Scale replicas on queue depth (`kubemq_queue_depth_policy` / KEDA) | Queues | | Result backend | Queue-peek result storage with purge-then-write and TTL | Queues | | Metrics | An 8-metric dict (depth, in-flight, DLQ, counters, durations) | — | ## Metrics at a glance [#metrics-at-a-glance] `get_metrics_sync()` returns a single dict with eight entries — three live gauges read from KubeMQ on each call, plus in-memory counters and a histogram. See the [metrics guide](/integrations/rayserve/how-to/metrics) for collection patterns and the [API reference](/integrations/rayserve/reference/api#metrics) for the full schema. | Metric | Type | Reports | | ---------------------------------- | --------- | ----------------------------------------------------------- | | `queue_depth` | Gauge | Waiting messages in the task queue (the autoscaling signal) | | `in_flight` | Gauge | Tasks currently being processed | | `dlq_depth` | Gauge | Waiting messages in the dead-letter queue | | `tasks_enqueued_total` | Counter | Tasks enqueued since startup | | `tasks_completed_total` | Counter | Completed counts by status (`SUCCESS` / `FAILURE`) | | `task_processing_duration_seconds` | Histogram | `min` / `max` / `avg` / `count` of handler durations | | `result_storage_retries_total` | Counter | Result-storage retry attempts | | `consumer_poll_latency_seconds` | Gauge | Duration of the last queue poll | ## How it compares to the Celery adapter [#how-it-compares-to-the-celery-adapter] Ray Serve's reference `TaskProcessorAdapter` is Celery-backed. KubeMQ collapses the broker, result backend, and scaler into one component. | Feature | kubemq-rayserve | CeleryTaskProcessorAdapter | | ------------------ | ---------------------------- | ------------------------------------- | | Message broker | KubeMQ (Kubernetes-native) | Redis / RabbitMQ | | Sync inference | Built-in (`query_task_sync`) | Not available | | Progress tracking | Built-in (`report_progress`) | Requires custom signals | | Autoscaling policy | `kubemq_queue_depth_policy` | Manual HPA configuration | | DLQ monitoring | `on_dlq` callback | Requires Celery signals + custom code | | Result backend | Queue-peek (no extra infra) | Requires separate Redis / DB | | Setup complexity | 1 dependency (KubeMQ) | 2+ dependencies (broker + backend) | ## Next steps [#next-steps] New to KubeMQ? Start with the [KubeMQ Getting Started guide](/deploy) for core concepts. # Watermill (/integrations/watermill) [`kubemq-watermill`](https://github.com/kubemq-io/kubemq-watermill) is a production-ready [Watermill](https://watermill.io/) pub/sub plugin for KubeMQ. It implements Watermill's `message.Publisher` and `message.Subscriber` interfaces across three KubeMQ messaging patterns — Events, EventsStore, and Queues — plus a native `CQPublisher` for Commands and Queries (request-reply). Drop it into an existing Watermill application and your Router, handlers, and middleware run unchanged on top of KubeMQ. It is a **native gRPC client** built on the `kubemq-go/v2` SDK — not an HTTP connector. If you need protocol bridging instead (REST, MCP, A2A, CloudEvents), see the [Connectors](/connectors) overview. For the messaging model behind the patterns, see the core docs: [Events](/learn/events), [Events Store](/learn/events-store), [Queues](/learn/queues), and [RPC](/learn/rpc). ## Why Watermill on KubeMQ [#why-watermill-on-kubemq] * **Three messaging patterns** — Events (fire-and-forget), EventsStore (persistent with replay), and Queues (reliable with explicit ack/nack), each behind the same Watermill `Publisher`/`Subscriber` interfaces. * **Native CQPublisher** — wraps KubeMQ's Commands and Queries APIs directly for low-latency request-reply, with execution confirmation and query caching. * **Full Watermill compatibility** — works with the Watermill Router and the entire standard middleware stack (Retry, Throttle, CorrelationID, Poison Queue, CircuitBreaker). * **Ack/Nack bridged to KubeMQ** — Watermill `msg.Ack()`/`msg.Nack()` map to KubeMQ queue settlement; a Nack returns the message to the queue for redelivery. * **Built-in DLQ, OTel, and metrics** — native dead-lettering via `QueueMessagePolicy`, W3C Trace Context propagation through KubeMQ Tags, and Watermill's Prometheus component. * **Verified compatibility** — passes the full Watermill `pubsub/tests.TestPubSub` suite. ## Architecture [#architecture] Your Watermill Router, Publisher, and Subscriber call the plugin, which uses `kubemq-go/v2` to open a gRPC connection to the KubeMQ broker on port `50000`. There is no HTTP connector and no server-side translation layer — the broker's gRPC server is always on, so the plugin works as soon as a broker is reachable. *The plugin maps Watermill's Publisher/Subscriber model onto KubeMQ patterns over the native gRPC SDK on port 50000.* ## Capabilities [#capabilities] Each Publisher and Subscriber serves exactly one KubeMQ pattern, selected with the `Pattern` field. Commands and Queries use the standalone `CQPublisher`. ## Pattern selection [#pattern-selection] KubeMQ supports three messaging patterns. Each Publisher/Subscriber instance serves exactly one — pick the one that matches your delivery and persistence needs: | Pattern | Delivery | Ack/Nack | Persistence | Best for | | --------------- | ------------- | -------------------- | --------------------------- | ---------------------------------------------- | | **Events** | At-most-once | No (fire-and-forget) | No | Real-time notifications, metrics, logs | | **EventsStore** | At-least-once | Offset auto-advance | Yes (replay from any point) | Event sourcing, audit trails, stream replay | | **Queues** | At-least-once | Explicit ack/nack | Yes (until acked) | Task queues, job processing, reliable delivery | For request-reply, reach for the native `CQPublisher` (single hop, lower latency) or Watermill's `requestreply` component over Queues (middleware-compatible) — see [Commands & Queries](/integrations/watermill/how-to/commands-queries). ## Supported runtime [#supported-runtime] | Requirement | Version | | ------------------------------------ | --------------------------------------------- | | Go | 1.25+ | | `github.com/ThreeDotsLabs/watermill` | v1.5.1 | | `github.com/kubemq-io/kubemq-go/v2` | v2.0.3 | | KubeMQ broker | gRPC on `:50000` (always on — no enable flag) | Install the plugin with `go get`: ```bash go get github.com/kubemq-io/watermill-kubemq ``` ## Next steps [#next-steps] New to KubeMQ? Start with the [What is an integration?](/integrations#what-an-integration-is) mental model, then the [KubeMQ Getting Started guide](/deploy). # Spring Boot (/integrations/spring-boot) The KubeMQ Spring Boot Starter is a production-quality Spring Boot 3.x and Spring Cloud Stream integration for the [KubeMQ](https://kubemq.io) message broker. It brings KubeMQ into the Spring programming model through auto-configuration, a thread-safe `KubeMQTemplate` send API, and annotation-driven listeners — all built on the native KubeMQ Java SDK clients so your application speaks gRPC to the broker directly. ## Why Spring Boot + KubeMQ? [#why-spring-boot--kubemq] * **Native Spring idioms** — auto-configuration, `@ConfigurationProperties` bound under `kubemq.*`, Spring Boot Actuator health contributors, and Micrometer metrics and observations * **Annotation-driven listeners** — `@KubeMQEventListener`, `@KubeMQEventStoreListener`, `@KubeMQQueueListener`, `@KubeMQCommandHandler`, and `@KubeMQQueryHandler` turn beans into message consumers * **`KubeMQTemplate` send API** — one injectable template covers all five messaging patterns (Events, Events Store, Queues, Commands, Queries) with sync, async, and fluent-builder variants * **Kotlin support** — coroutine extensions, `Flow` adapters, and a configuration DSL via the Kotlin starter * **Spring Cloud Stream binder** — bind Events, Events Store, and Queues through the Spring Cloud Stream programming model * **First-class test harness** — `MockKubeMQServer`, TestContainers support, and a test starter for exercising producers and consumers without a live broker ## Requirements [#requirements] The starter targets the JVM and a running **KubeMQ broker** reachable over native gRPC on port `50000`. Supported languages and versions: | Requirement | Version | | -------------------------- | ------------- | | Java | 17+ | | Kotlin (Kotlin starter) | JVM target 17 | | Spring Boot | 3.2.0+ | | Spring Cloud (binder BOM) | 2023.0.0 | | KubeMQ Spring Boot Starter | 1.0.0 | The Spring Cloud Stream binder pulls in the **Spring Cloud 2023.0.0** BOM; add it only when you use the binder. ## Installation [#installation] Add the starter dependency — it aggregates the auto-configuration and the KubeMQ Java SDK: ```kotlin title="build.gradle.kts" dependencies { implementation("io.kubemq:kubemq-spring-boot-starter:1.0.0") } ``` Then point the application at your broker. The properties are bound under `kubemq.*`: ```yaml title="application.yml" kubemq: address: localhost:50000 client-id: my-app ``` Inject `KubeMQTemplate` to send, and annotate a bean method to receive: ```java title="OrderService.java" @Service public class OrderService { private final KubeMQTemplate template; public OrderService(KubeMQTemplate template) { this.template = template; } public void placeOrder(Order order) { template.sendEvent("orders", order); } } ``` ```java title="OrderConsumer.java" @Component public class OrderConsumer { @KubeMQEventListener(channels = "orders") public void onOrder(EventMessageReceived event) { // process event } } ``` Start a local broker with Docker — the gRPC API the starter connects to listens on `50000`, and the shared HTTP/REST and dashboard endpoints on `9090`: ## Modules [#modules] The project is a multi-module Gradle build. Add `kubemq-spring-boot-starter` to your project; the rest are pulled in transitively or added when you need Spring Cloud Stream, Kotlin, or testing support. | Module | Description | | ----------------------------------- | --------------------------------------------------------------------------- | | `kubemq-spring-boot-autoconfigure` | Auto-configuration, `KubeMQTemplate`, listener annotations, health, metrics | | `kubemq-spring-boot-starter` | Dependency aggregator — add this to your project | | `kubemq-spring-cloud-stream-binder` | Spring Cloud Stream binder for Events, Events Store, and Queues | | `kubemq-spring-boot-starter-kotlin` | Kotlin coroutine extensions, `Flow` adapters, and DSL | | `kubemq-spring-boot-starter-test` | `MockKubeMQServer`, TestContainers, and test harness | ## Architecture [#architecture] `KubeMQAutoConfiguration` wires the native KubeMQ Java SDK clients — `PubSubClient`, `QueuesClient`, and `CQClient` — from your `kubemq.*` properties. The `KubeMQTemplate` send API and the `@KubeMQ*Listener` / `@KubeMQ*Handler` beans both delegate to those clients, which speak gRPC to the broker on port `50000`. There is no connector to enable — the starter is a native SDK client that connects directly. *Auto-configuration binds the three SDK clients from `kubemq.*` properties; the template and listener beans delegate to them over gRPC on port 50000.* ## Capabilities [#capabilities] Each capability page documents the Spring API surface (`KubeMQTemplate` send methods and the `@KubeMQ*Listener` / `@KubeMQ*Handler` annotations) and links to the underlying KubeMQ pattern so you learn the concept once. ## Quick Links [#quick-links] New to integrations? See [what an integration is](/integrations#what-an-integration-is) for the mental model, or start with the [KubeMQ Getting Started guide](/deploy) for core broker concepts. This starter speaks native gRPC on port `50000` — it is a direct SDK client, not a [connector](/connectors). # C++ SDK (/sdks/cpp) The KubeMQ C++ SDK provides a high-performance, type-safe C++17 client for all messaging patterns. It requires C++17 (GCC 9+, Clang 9+, MSVC 2019+), CMake 3.16+, gRPC v1.78+, and Protobuf v5.29+. ## Installation [#installation] ### vcpkg (Recommended) [#vcpkg-recommended] ```bash vcpkg install grpc protobuf nlohmann-json cmake -B build -S . \ -DCMAKE_TOOLCHAIN_FILE=[vcpkg-root]/scripts/buildsystems/vcpkg.cmake cmake --build build --parallel ``` ### CMake FetchContent [#cmake-fetchcontent] ```cmake title="CMakeLists.txt" include(FetchContent) FetchContent_Declare( kubemq-cpp GIT_REPOSITORY https://github.com/kubemq-io/kubemq-cpp.git GIT_TAG v1.0.0 ) FetchContent_MakeAvailable(kubemq-cpp) target_link_libraries(your_app PRIVATE kubemq_static) ``` ## Next: send your first message [#next-send-your-first-message] With the SDK installed, continue to the [first-message tutorial](/sdks/cpp/tutorials/first-message) to connect a client and publish and receive your first message. # Audit Logging (/operate/observability/audit) KubeMQ records a built-in audit trail of security, control-plane, and data-plane error events. Every audit event follows the [CloudEvents v1.0](https://cloudevents.io/) specification, so the trail is interoperable with any CloudEvents-aware tooling, and it is queryable through a REST API on the management API port (`:8080`). ## Overview [#overview] Audit logging is **on by default**. Events are persisted to a local durable store on each node and synchronized across cluster nodes over the cluster mesh, so every node holds the full audit trail for the entire cluster. Each event is a CloudEvents v1.0 envelope carrying a structured `data` payload that captures who did what, on which channel, over which transport, and whether it succeeded. Three kinds of events are recorded: * **Control events** — security, connection lifecycle, and administrative operations (authentication, authorization, client connect/disconnect, subscriptions, channel create/delete, cluster membership, server lifecycle). * **Data error events** — failures in message send and receive operations across the patterns. * **System error events** — infrastructure-level failures such as storage or broker errors. ## Configure it [#configure-it] Audit logging has three settings: a master `enable` toggle, the retention window in hours, and how often expired records are purged. ```yaml title="config.yaml" audit: enable: true # on by default retentionHours: 720 # retain events for 30 days cleanupIntervalMinutes: 60 # purge expired events hourly ``` ```yaml title="values.yaml" spec: audit: enable: true # on by default retentionHours: 720 # retain events for 30 days cleanupIntervalMinutes: 60 # purge expired events hourly ``` **Version floor:** the `spec.audit.*` fields are present throughout the current GA chart line — `kubemq-crds` and `kubemq-cluster` **3.x** (latest **3.2.0**) with `kubemq-controller` **2.x** (operator **v2.3.0**). Anything older than the 3.0.0 / 2.0.0 GA release predates this reference and will reject these fields; upgrade to the current line. On Docker the `audit.*` keys are available regardless of chart version. For the full settings table — types, defaults, valid values, and the Docker/Helm naming for each key — see the [Observability settings reference](/configure/reference/observability). ## Event format [#event-format] Each audit event is a CloudEvents v1.0 envelope. The envelope carries the standard CloudEvents attributes; the audit-specific payload lives in `data`. ```json { "specversion": "1.0", "id": "550e8400-e29b-41d4-a716-446655440000", "source": "kubemq://my-cluster/node-1", "type": "io.kubemq.audit.auth.success", "time": "2026-03-26T12:34:56.789Z", "datacontenttype": "application/json", "data": { "category": "control", "subcategory": "success", "client_id": "my-producer", "source_ip": "10.0.0.5", "transport": "grpc", "channel": "orders", "action": "auth.success", "outcome": "success", "metadata": { "method": "SendEvent" } } } ``` ### Envelope fields [#envelope-fields] | Field | Description | | ----------------- | ---------------------------------------------- | | `specversion` | Always `"1.0"`. | | `id` | Unique UUID for each event. | | `source` | `kubemq://{cluster_name}/{node_id}`. | | `type` | `io.kubemq.audit.{event_type}`. | | `time` | ISO 8601 timestamp with millisecond precision. | | `datacontenttype` | Always `"application/json"`. | ### Data fields [#data-fields] | Field | Type | Description | | ---------------- | ------ | ----------------------------------------------- | | `category` | string | Event category: `control`, `data`, or `system`. | | `subcategory` | string | Sub-category (e.g. `success`, `error`). | | `client_id` | string | Client identifier (if applicable). | | `source_ip` | string | Client IP address (if applicable). | | `transport` | string | Transport layer (`grpc`, `rest`). | | `channel` | string | Channel name (if applicable). | | `message_id` | string | Message, event, or request ID (if applicable). | | `action` | string | Event type name (same as the `type` suffix). | | `outcome` | string | `success` or `error`. | | `error` | string | Error message (only when `outcome` is `error`). | | `error_category` | string | Error classification (if applicable). | | `metadata` | map | Additional key-value metadata. | ## Event catalog [#event-catalog] ### Control events [#control-events] Control-plane events track security, connection lifecycle, and administrative operations. These events carry `category: "control"`. | Event type | Description | | ---------------------- | -------------------------------------------------- | | `server.started` | Server completed startup. | | `server.stopped` | Server shutting down. | | `server.ready` | Server ready to accept traffic. | | `auth.success` | Authentication succeeded. | | `auth.failure` | Authentication failed. | | `authz.denied` | Authorization denied. | | `client.connected` | Client established a connection. | | `client.disconnected` | Client disconnected. | | `client.connect_error` | Client connection failed. | | `subscription.created` | Subscription established. | | `subscription.deleted` | Subscription removed. | | `subscription.error` | Subscription failed. | | `channel.created` | Channel created (detected by the channel monitor). | | `channel.deleted` | Channel deleted (detected by the channel monitor). | | `stream.opened` | Bidirectional stream opened. | | `stream.closed` | Bidirectional stream closed. | | `cluster.node_joined` | Cluster node joined. | | `cluster.node_left` | Cluster node left. | A channel monitor watches for channels appearing and disappearing and emits `channel.created` / `channel.deleted` control events accordingly. ### Data error events [#data-error-events] Data-plane error events track failures in message send and receive operations. These events carry `category: "data"`. | Event type | Description | | ------------------------- | ---------------------------- | | `events.send_error` | Event publish failed. | | `events_store.send_error` | Events Store publish failed. | | `command.send_error` | Command send failed. | | `query.send_error` | Query send failed. | | `queue.send_error` | Queue message send failed. | | `queue.batch_send_error` | Queue batch send failed. | | `queue.receive_error` | Queue receive failed. | ### System error events [#system-error-events] System-level errors from infrastructure components. These events carry `category: "system"`. | Event type | Description | | ---------------------- | --------------------------- | | `system.storage_error` | Persistent storage failure. | | `system.broker_error` | Message broker error. | ## Retention & cleanup [#retention--cleanup] Audit events are retained for the window set by `retentionHours` (default `720` hours = 30 days). A background cleanup runs at the interval set by `cleanupIntervalMinutes` (default `60` minutes) and deletes every event older than the retention window. Both settings accept a minimum of `1`. Tune `retentionHours` to your compliance window and lower `cleanupIntervalMinutes` only if you need expired events purged more aggressively. Retention applies per node, but because the trail is synchronized over the cluster mesh, every node holds — and prunes — the same cluster-wide set of events. ## Query the audit log (REST) [#query-the-audit-log-rest] The audit trail is queryable through the management API port (`:8080`). Two endpoints are available: one to list matching events and one to aggregate them. ### List events [#list-events] ```text GET /api/v1/audit ``` | Parameter | Type | Default | Description | | ------------- | -------------- | ---------- | ------------------------------------------------- | | `from` | RFC3339 string | 1 hour ago | Start time. | | `to` | RFC3339 string | now | End time. | | `category` | string | (all) | Filter by category (`control`, `data`, `system`). | | `subcategory` | string | (all) | Filter by subcategory. | | `event_type` | string | (all) | Filter by event type (e.g. `auth.success`). | | `channel` | string | (all) | Filter by channel name. | | `client_id` | string | (all) | Filter by client ID. | | `limit` | int | 50 | Results per page (1–1000). | | `offset` | int | 0 | Pagination offset. | ```bash curl "http://localhost:8080/api/v1/audit?category=control&event_type=auth.failure&limit=10" ``` ### Aggregate statistics [#aggregate-statistics] ```text GET /api/v1/audit/stats ``` | Parameter | Type | Default | Description | | ---------- | -------------- | ------------ | ------------------- | | `from` | RFC3339 string | 1 hour ago | Start time. | | `to` | RFC3339 string | now | End time. | | `group_by` | string | `event_type` | Grouping dimension. | ```bash curl "http://localhost:8080/api/v1/audit/stats?group_by=category&from=2026-03-25T00:00:00Z" ``` ### Error responses [#error-responses] | HTTP code | Error | Description | | --------- | ---------------------- | ----------------------------------------------- | | 404 | `ErrAuditDisabled` | Audit service is disabled or not initialized. | | 400 | `ErrAuditInvalidParam` | Invalid query parameter (e.g. bad date format). | | 500 | `ErrAuditQueryFailed` | Query failed. | ## Access control [#access-control] When control-plane authentication is enabled, the audit query endpoints require at least the **ReadOnly** role, like the rest of the management API. See the [Management API](/operate/observability/api-reference) overview for the response envelope, status codes, and access-control model. ## Feed a SIEM [#feed-a-siem] The REST query API makes it straightforward to feed audit events into an external SIEM. Poll `GET /api/v1/audit` on an interval, advancing the `from` / `to` window each pass, and forward the returned CloudEvents records into Splunk, Elastic, or any log-management platform. Because the trail is synchronized cluster-wide, you can poll a single node and still capture every event from the cluster. ## Related [#related] * [Observability settings reference](/configure/reference/observability) — the audit configuration keys with their Docker and Helm naming, defaults, and valid values. * [Management API](/operate/observability/api-reference) — the response envelope, status codes, and access-control model shared by the audit query endpoints. # Observability (/operate/observability) KubeMQ reports on itself across several complementary surfaces. An always-on Prometheus exporter, opt-in OpenTelemetry traces and metrics, structured JSON logs, a CloudEvents audit trail, a built-in web dashboard, and an HTTP/WebSocket management API all expose what the server is doing right now and what it has done. This section is the operator's home for monitoring KubeMQ in production — how to scrape metrics, wire up tracing, read the logs, query the audit log, and consume the management API. Each surface answers a different question: Prometheus metrics give you the quantitative time series, tracing shows you the path of a single message across patterns, logs and the audit trail record discrete events, the dashboard renders the live picture, and the management API is the programmatic interface behind it all. ## The observability surfaces [#the-observability-surfaces] | Surface | What it gives you | Where it's served | Always-on? | | --------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | ----------- | | Prometheus metrics | Counters, gauges, and histograms for every messaging pattern, queues, RPC latency, cluster health, and the agent platform | `GET /metrics` (`:8080`) | Yes | | OpenTelemetry tracing | Distributed traces and OTel metrics over OTLP to Jaeger, Grafana Tempo, or Datadog | OTLP export to an external collector | No (opt-in) | | Structured logging | JSON log lines on stdout — levels, the field set, and trace correlation | stdout | Yes | | Audit logging | A CloudEvents v1.0 trail of auth, lifecycle, and data-plane error events | `GET /api/v1/audit` (`:8080`) | Yes | | Built-in dashboard | A live web view of channels, clients, queues, the patterns, cluster topology, and agents | `:8080` | Yes | | Management API | The HTTP and WebSocket interface for health, stats, snapshots, audit query, and dashboard actions | `:8080` | Yes | ## Push vs pull [#push-vs-pull] KubeMQ uses a **pull-first** model for metrics and a **push-first** model for traces, and the two can run at the same time. Logs are written to stdout and captured by your runtime. | Signal | Model | Consumer | | --------------------- | ------------------------------------------------------ | ----------------------------------- | | Prometheus metrics | Pull — Prometheus scrapes `GET /metrics` on demand | Prometheus / Grafana | | Dashboard snapshots | Pull — the dashboard polls the snapshot endpoints | Built-in dashboard | | OpenTelemetry traces | Push — the OTLP exporter pushes to a collector | Jaeger / Tempo / Datadog | | OpenTelemetry metrics | Push — the meter provider exports periodically | OTLP-compatible metrics backend | | Audit events | Pull — the REST query API | Operator / SIEM | | Logs | Push — JSON written to stdout, captured by the runtime | Log aggregator (Loki, Splunk, etc.) | The Prometheus exporter always runs — it also powers the built-in dashboard. OpenTelemetry is opt-in and is enabled through the telemetry settings. ## Where each surface is served [#where-each-surface-is-served] The management API port (`:8080`) is the same in-process port that serves the dashboard, the metrics endpoint, the stats and snapshot endpoints, and the audit query API. When control-plane auth is enabled, every endpoint below requires at least the **ReadOnly** role. | Surface | Endpoint | Access (control-plane auth on) | | ------------------------ | -------------------------------------------------------------- | ------------------------------ | | Prometheus scrape | `GET /metrics` (`:8080`) | ReadOnly+ | | Dashboard snapshots | `GET /api/snapshot`, `GET /api/cluster-snapshot` | ReadOnly+ | | Audit query | `GET /api/v1/audit`, `GET /api/v1/audit/stats` | ReadOnly+ | | Channel and client stats | `GET /v1/stats/channels`, `GET /v1/stats/clients` | ReadOnly+ | | Live message monitor | WebSocket `/api/monitor` | ReadOnly+ | | Real-time cluster stream | WebSocket `/api/connection` | ReadOnly+ | | Billing summary | `GET /billing` | ReadOnly+ | | OTLP export | OTLP gRPC (`:4317`) or HTTP (`:4318`) to an external collector | Collector-side | | Application logs | stdout (JSON) | — | ## Start here [#start-here] ## Configure it [#configure-it] The raw configuration keys for telemetry, audit, and server-event notifications — with their Docker and Helm naming, defaults, and valid values — live in the [Observability settings reference](/configure/reference/observability). That page is the source of truth for the settings; this section covers the concepts, how-to, and the full management API. # Structured Logging (/operate/observability/logging) KubeMQ writes **structured JSON logs to stdout**. Every log line is a single JSON object, which makes the output easy to capture and parse with any container log aggregator — Loki, Splunk, Datadog, Fluentd, and others — with no extra log files or rotation to manage. ## Log format [#log-format] Every log line is one JSON object on stdout: ```json { "level": "INFO", "time": "2026-06-29T12:34:56.789Z", "component": "grpc", "caller": "grpc/grpc.go:144", "msg": "starting gRPC server", "host": "kubemq-node-1" } ``` The field set: | JSON key | Description | | ----------- | --------------------------------------------------------------------------------------------------------------- | | `msg` | The log message string. | | `level` | The level in ALL-CAPS: `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL`. | | `time` | ISO 8601 timestamp with milliseconds. | | `component` | The logger name (the KubeMQ component that emitted the line, e.g. `grpc`, `array`). | | `caller` | The trimmed `file:line` source location. | | `stack` | A stack trace — present on `ERROR` and above when configured. | | `host` | The node hostname. | | `trace_id` | Trace identifier — present only when [tracing](/operate/observability/tracing) is enabled and a span is active. | | `span_id` | Span identifier — present only when tracing is enabled and a span is active. | Components enrich their loggers with extra context fields (such as `client_id` or `channel`) that appear as additional key/value pairs on the relevant lines. ## Log levels [#log-levels] KubeMQ maps six application levels onto the JSON `level` field. `Info` is the default. | Level | JSON `level` | | ----- | ---------------- | | Trace | `DEBUG` | | Debug | `DEBUG` | | Info | `INFO` (default) | | Warn | `WARN` | | Error | `ERROR` | | Fatal | `FATAL` | Trace and Debug both emit at the `DEBUG` JSON level. The numeric values used in configuration are `0=Trace`, `1=Debug`, `2=Info`, `3=Warn`, `4=Error`, `5=Fatal`. ## Configure the level [#configure-the-level] Set the log level at startup with the `log.level` config key (or its environment-variable binding). The default is `2` (Info). ```yaml title="config.yaml" log: level: 2 # 0=Trace 1=Debug 2=Info 3=Warn 4=Error 5=Fatal ``` Or set it with an environment variable, which overrides the config-file value at startup: ```bash LOGLEVEL=1 # Debug ``` ```yaml title="values.yaml" log: level: 2 # 0=Trace 1=Debug 2=Info 3=Warn 4=Error 5=Fatal ``` ## Change the level at runtime [#change-the-level-at-runtime] The log level can be changed **without restarting the server**. The management API exposes a `set_log_level` action on its unified request endpoint; a single call takes effect immediately across every component, because all loggers share one level. See the [Action Endpoint](/operate/observability/api-reference/actions) for how requests are sent to the management API. ## Correlate logs with traces [#correlate-logs-with-traces] When [OpenTelemetry tracing](/operate/observability/tracing) is enabled, log lines emitted during a traced operation carry `trace_id` and `span_id` fields. This lets you pivot from a log line to the matching trace (and back) in your backend — Jaeger, Grafana Tempo, Datadog, and others. ```json { "level": "ERROR", "time": "2026-06-29T12:34:56.789Z", "component": "array", "caller": "array/events_sender.go:42", "msg": "publish pub/sub event error", "host": "kubemq-node-1", "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "span_id": "00f067aa0ba902b7", "error": "context deadline exceeded" } ``` These two fields appear **only when tracing is on and a span is active** for the operation being logged. With tracing disabled, the fields are simply absent and logging behaves exactly as before. Correlation therefore requires OpenTelemetry — see the [Distributed Tracing](/operate/observability/tracing) page to enable it. ## Ship logs [#ship-logs] Because KubeMQ logs JSON to stdout, any log shipper that captures container stdout works without extra configuration — **Loki**, **Splunk**, **Datadog**, and **Fluentd** all ingest the lines as structured records. To correlate logs and traces in Grafana, link a Loki datasource to a Tempo datasource: 1. Ship KubeMQ's JSON logs to Loki (for example via Promtail or the Grafana Agent). 2. In the Loki datasource settings, configure **Derived Fields** with a regex that extracts `trace_id`, pointing it at the Tempo datasource. 3. Log lines from traced operations then show a clickable **Tempo** button that opens the trace in the same time range. For Datadog, the `trace_id` and `span_id` field names already match the fields Datadog expects for log–trace correlation, once your log pipeline parses them from the JSON body. ## Related [#related] # Prometheus Metrics (/operate/observability/metrics) KubeMQ exposes an always-on Prometheus exporter that reports message counts, byte volumes, client counts, queue health, RPC latency, cluster state, and agent-platform activity. It needs no configuration — it is running the moment KubeMQ starts. ## Overview [#overview] The Prometheus exporter is **always on**. The same in-process metrics also power the built-in dashboard and its snapshot system, so the numbers you scrape with Prometheus and the numbers you see in the dashboard come from one source. Metrics are served in-process by the [management API](/operate/observability/api-reference) at `GET /metrics` on the management API port (`:8080`) — the same port that serves health probes, stats, and the dashboard. There is no separate listener and no extra setup; the endpoint returns standard Prometheus text format. Prometheus is the **pull** path and is always on. KubeMQ also supports an opt-in **push** path — OpenTelemetry metrics over OTLP — covered on the [Distributed Tracing](/operate/observability/tracing) page. The OTel `pending` instrument (active in-flight messages) is exported only over OTLP; there is no equivalent Prometheus series. ## Scraping KubeMQ [#scraping-kubemq] Read the endpoint directly with `curl`: ```bash curl http://localhost:8080/metrics ``` Point Prometheus at the same endpoint with a `scrape_config`: ```yaml title="prometheus.yml" scrape_configs: - job_name: kubemq metrics_path: /metrics static_configs: - targets: ["kubemq:8080"] ``` ## Labels [#labels] Core messaging metrics carry a shared label set. The `type` and `side` label values are determined by the messaging pattern. | Label | Values | Description | | ----------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `node` | host name | The KubeMQ node that recorded the metric. | | `client_id` | client id | The connected client. **Aggregated away on store series** — the per-channel store collectors drop `client_id` at collect time, so queue, RPC-latency, and similar series carry only `node`, `type`, `side`, `channel`. | | `type` | `events`, `events_store`, `commands`, `queries`, `queues` | The messaging pattern. | | `side` | `send`, `receive` | Whether the metric is for the producing or consuming side of the channel. | | `channel` | channel name | The message channel the metric belongs to. | ## Core messaging metrics [#core-messaging-metrics] These collectors track message activity across all five patterns. They share the label set above. | Prometheus name | Type | Description | | --------------------------- | ------- | ----------------------------------------- | | `kubemq_messages_count` | Counter | Total messages sent/received per channel. | | `kubemq_messages_volume` | Counter | Total byte volume per channel. | | `kubemq_messages_last_seen` | Gauge | Unix timestamp (ms) of last activity. | | `kubemq_messages_delayed` | Counter | Number of delayed queue messages. | | `kubemq_messages_expired` | Counter | Total expired queue messages. | | `kubemq_messages_waiting` | Gauge | Queue messages waiting for consumers. | | `kubemq_clients_count` | Gauge | Connected client count per channel. | | `kubemq_errors_count` | Counter | Total errors per channel. | | `kubemq_messages_responses` | Counter | Total RPC responses per channel. | ## Queue health metrics [#queue-health-metrics] These series give queue operators visibility into redelivery, dead-letter dispositions, backlog age, and dwell time. They use the standard four-label set (`node`, `type`, `side`, `channel`) — `client_id` is aggregated away — except where a row notes an extra label. | Prometheus name | Type | Description | | ----------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `kubemq_queue_redelivery_total` | Counter | Total queue redeliveries per channel — a receive whose post-increment receive count is greater than one — attributed to the source queue channel on the `receive` side. Uses the standard four labels only (no extra label). | | `kubemq_queue_dlq_total` | Counter | Total queue dead-letter dispositions per channel, attributed to the source channel on the `receive` side. Carries one **extra `reason` label**: `routed` (re-published to the configured dead-letter queue) or `dropped` (no DLQ configured or a marshal error — silent data loss made visible). The `reason` label is on `dlq_total` only, not on `redelivery_total`. | | `kubemq_queue_backlog_age_seconds` | Gauge | Age (seconds) of the oldest undelivered message in the backlog, per channel (`queues`/`receive`). A live gauge recomputed each 5-second snapshot cycle; `0` when the backlog is empty or fully delivered, and `0` after a restart until the next cycle rebuilds it. | | `kubemq_queue_dwell_seconds_sum` | Counter | Cumulative sum of per-message dwell times (seconds) on the `receive` side — the time from when a message entered the queue until it was delivered to a consumer. Survives restarts. Divide by `kubemq_queue_dwell_messages_total` for the average dwell. | | `kubemq_queue_dwell_messages_total` | Counter | Cumulative count of messages whose dwell time was recorded. Survives restarts. The denominator for the average dwell time. | ## RPC latency metrics [#rpc-latency-metrics] These series measure command and query round-trip latency on the `send` side. They use the standard four-label set. | Prometheus name | Type | Description | | -------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `kubemq_rpc_latency_seconds_sum` | Counter | Cumulative sum of RPC round-trip times (seconds) on the `send` side of a command or query channel — measured from request dispatch until the response is received. Survives restarts. Recorded only when a response is received (errored or timed-out requests with no response are excluded). Divide by `kubemq_rpc_latency_calls_total` for the average. | | `kubemq_rpc_latency_calls_total` | Counter | Cumulative count of RPC calls whose round-trip latency was recorded (`type ∈ {commands, queries}`, `side = send`). Survives restarts. The denominator for the average RPC latency. | **Cache-served queries are excluded.** The query cache short-circuits before the latency is recorded, so these series measure real backend round-trips (cache misses), not local cache lookups. Commands have no cache and are always recorded. ## Latency histograms [#latency-histograms] Three histogram instruments expose latency **distributions** (so you can compute percentiles with `histogram_quantile`), labeled by `node`, `type`, and `side`. | Prometheus name | Type | Description | | -------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `kubemq_rpc_latency_histogram_seconds` | Histogram | RPC round-trip latency distribution (commands and queries, `send` side). Same gate as `kubemq_rpc_latency_seconds_sum` — recorded only when a response is received; cache-served queries are excluded. | | `kubemq_queue_dwell_histogram_seconds` | Histogram | Queue dwell-time distribution (`queues`, `receive` side). Its population matches `kubemq_queue_dwell_messages_total`. | | `kubemq_operation_duration_seconds` | Histogram | Per-operation send duration for patterns with no round-trip: `type ∈ {events, events_store, queues}` on the `send` side. Commands and queries use `kubemq_rpc_latency_histogram_seconds` for their percentile source. | All three histograms share one bucket set (seconds), giving a consistent latency vocabulary across `/metrics`: ```text 0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60 ``` ## Cluster & infra metrics [#cluster--infra-metrics] These gauges expose node health, cluster role, and configured peer state, labeled by `node` (and `role` where noted). | Prometheus name | Type | Description | | ------------------------------ | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `kubemq_cluster_leader` | Gauge | `1` if this node is the cluster leader **or** a standalone node (both act as the authoritative message processor); `0` otherwise. | | `kubemq_cluster_role` | Gauge | Info-gauge: emits `1` for the active `role` label only, `0` for the rest. `role ∈ {leader, follower, candidate, standalone}`. Filter with `kubemq_cluster_role == 1`. | | `kubemq_cluster_ready` | Gauge | `1` when the node is ready to accept traffic; `0` during startup or unhealthy states. | | `kubemq_cluster_healthy` | Gauge | `1` when the node is healthy. Distinct from ready — a node can be healthy but not yet ready. | | `kubemq_cluster_state_seconds` | Gauge | Seconds since the last cluster state change. For clustered nodes: seconds since the last ready-settle (leadership election or follower sync). For standalone nodes: seconds since server startup. | | `kubemq_cluster_peers` | Gauge | The configured bootstrap peer count, including self (minimum `1` for standalone). This is the configured bootstrap peer list, **not** live cluster membership — on a running cluster, live membership may differ during leader transitions. | On a single-node (standalone) server, `kubemq_cluster_leader = 1`, `kubemq_cluster_role{role="standalone"} = 1`, `kubemq_cluster_ready = 1` once ready, and `kubemq_cluster_peers = 1`. On a multi-node cluster, exactly one node has `kubemq_cluster_leader = 1` at any given time. ## Agent-platform metrics [#agent-platform-metrics] KubeMQ's [agent platform](/aiway) emits its own series for MCP tool calls and A2A agent traffic. These use domain-specific labels rather than the core messaging label set. | Prometheus name | Type | Labels | Description | | -------------------------------------- | --------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------- | | `kubemq_mcp_tool_calls_total` | Counter | `tool`, `status` | Total MCP tool calls. | | `kubemq_mcp_tool_duration_seconds` | Histogram | `tool` | Duration of MCP tool calls (seconds). | | `kubemq_a2a_requests_total` | Counter | `agent_id`, `method`, `status` | Total A2A requests routed to agents (any path — HTTP gateway and MCP bridge). | | `kubemq_a2a_request_duration_seconds` | Histogram | `agent_id` | Duration of A2A requests routed to agents (any path — HTTP gateway and MCP bridge). | | `kubemq_a2a_errors_total` | Counter | `agent_id`, `error_code` | Total A2A errors for requests routed to agents (any path — HTTP gateway and MCP bridge). | | `kubemq_a2a_stream_events_total` | Counter | `agent_id` | Total A2A stream events delivered to SSE clients (receive side). | | `kubemq_a2a_stream_outcomes_total` | Counter | `agent_id`, `outcome` | Total A2A streams by terminal `outcome` (`done`, `error`, `idle_timeout`, `canceled`) — exactly one per stream. | | `kubemq_a2a_registry_operations_total` | Counter | `op`, `status` | Total agent registry operations (register, deregister, heartbeat, expire). | | `kubemq_a2a_sse_streams_active` | Gauge | — | Current number of active A2A SSE streams. | The MCP tool-call histogram (`kubemq_mcp_tool_duration_seconds`) uses the bucket set `0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10`; the A2A request-duration histogram (`kubemq_a2a_request_duration_seconds`) uses `0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60`. **The `agent_id` label is bounded.** The first \~1000 distinct agent IDs each get their own label value; every subsequent agent ID collapses into `agent_id="other"`. This is cardinality protection — for exact per-agent leaderboards across the whole fleet, use the dashboard rather than Prometheus. **The `method` label is sanitized.** Each A2A method is checked against a fixed allowlist of known methods; anything not in the list is recorded as `method="unknown"`. This keeps a malformed or malicious request stream from exploding Prometheus label cardinality. In a cluster, these counters are aggregated across all nodes, so the totals reflect the whole deployment rather than a single replica. ## PromQL examples [#promql-examples] ```text # RPC p99 latency (commands) — rolling 5-minute window histogram_quantile(0.99, rate(kubemq_rpc_latency_histogram_seconds_bucket{type="commands"}[5m]) ) # Queue dwell p95 across all nodes histogram_quantile(0.95, sum(rate(kubemq_queue_dwell_histogram_seconds_bucket[5m])) by (le) ) # Alert: no leader exists across the cluster sum(kubemq_cluster_leader) == 0 # Alert: a node has been not-ready for more than 60 seconds kubemq_cluster_ready == 0 and kubemq_cluster_state_seconds > 60 ``` ## Grafana [#grafana] Point Grafana at the Prometheus instance scraping KubeMQ and build dashboards on the series above — message rates from `kubemq_messages_count`, queue health from the `kubemq_queue_*` series, latency percentiles from the histogram buckets, and cluster state from the `kubemq_cluster_*` gauges. ## Internal channels [#internal-channels] Internal cluster channels are filtered out of the user-facing metrics, so they never appear in the series above. # Distributed Tracing (/operate/observability/tracing) KubeMQ integrates with [OpenTelemetry](https://opentelemetry.io/) (OTel) for distributed tracing and metrics export. It is **opt-in** — off by default and turned on with a single `telemetry.enable` flag. When enabled, KubeMQ instruments every messaging operation (Events, Events Store, Queues, Commands, Queries) with spans and exports them, along with a set of OTel metric instruments, over the OTLP protocol to any compatible backend (Jaeger, Grafana Tempo, Datadog, and others). Tracing is **complementary** to the [always-on Prometheus exporter](/operate/observability/metrics). Prometheus gives you a pull-based metric series for dashboards and alerting; OpenTelemetry adds distributed traces with context propagation across services, plus a parallel push-based metric stream over OTLP. ## Enable it [#enable-it] Telemetry is controlled by the master `telemetry.enable` switch (off by default). Turn it on, then tune the traces, metrics, and exporter sub-blocks as needed. ```yaml title="config.yaml" telemetry: enable: true ``` ```yaml title="values.yaml" telemetry: enable: true ``` **Version floor:** the `spec.telemetry.*` fields are present throughout the current GA chart line — `kubemq-crds` and `kubemq-cluster` **3.x** (latest **3.2.0**) with `kubemq-controller` **2.x** (operator **v2.3.0**). Anything older than the 3.0.0 / 2.0.0 GA release predates this reference and will reject these fields; upgrade to the current line. On Docker the `telemetry.*` keys are available regardless of chart version. The full set of telemetry settings — service name, traces/metrics sub-blocks, exporter options, and the Docker/Helm key and environment-variable mappings — lives in the [Configuration reference](/configure/reference/observability). This page covers what those settings do and what you get when telemetry is on. ## OTLP exporter [#otlp-exporter] Traces and metrics are exported over OTLP to a collector or backend. The exporter is configured under `telemetry.exporter`. | Setting | Default | Description | | ------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `protocol` | `grpc` | OTLP wire protocol — `grpc` (default endpoint `:4317`) or `http` (default endpoint `:4318`). | | `endpoint` | `localhost:4317` | OTLP collector endpoint (`host:port`). | | `insecure` | `true` | When `true`, the connection skips TLS. Set `false` to use TLS 1.2+ to the collector. | | `compression` | `gzip` | Payload compression — `gzip` or `none`. | | `timeout` | `10s` | Export request timeout (Go duration). | | `headers` | `{}` | Custom headers sent with each export request (a `key/value` map). **`config.yaml`-only** — there is no environment-variable or CRD path for this field. | | Protocol | Default endpoint | Notes | | -------- | ---------------- | -------------------------------------------------- | | `grpc` | `localhost:4317` | OTLP over gRPC. Supports TLS and gzip compression. | | `http` | `localhost:4318` | OTLP over HTTP. Supports TLS and gzip compression. | ## Samplers [#samplers] The sampler decides which traces are recorded. It is set with `telemetry.traces.sampler`; the ratio-based samplers read `telemetry.traces.samplingRatio` (a fraction from `0.0` to `1.0`). | Sampler | Description | | ---------------- | ---------------------------------------------------------------------------------------------------- | | `always_on` | Sample every trace. | | `always_off` | Sample no traces. | | `trace_id_ratio` | Sample a fraction of traces based on `samplingRatio` (e.g. `0.1` = 10% of traces). | | `parent_based` | Use the parent span's sampling decision; falls back to `trace_id_ratio` for root spans. The default. | ## Environment variable overrides [#environment-variable-overrides] Standard OTel environment variables are read after the config file loads and **override** the file values. These are the recommended way to set exporter and sampler options from outside the container. | Environment variable | Overrides | Notes | | -------------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | `OTEL_SERVICE_NAME` | Service name | The OTLP `service.name` resource attribute. | | `OTEL_EXPORTER_OTLP_ENDPOINT` | Exporter endpoint | | | `OTEL_EXPORTER_OTLP_PROTOCOL` | Exporter protocol | `grpc` or `http`. | | `OTEL_EXPORTER_OTLP_INSECURE` | Exporter insecure | `"true"` / `"false"`. | | `OTEL_EXPORTER_OTLP_COMPRESSION` | Exporter compression | `gzip` or `none`. | | `OTEL_EXPORTER_OTLP_TIMEOUT` | Exporter timeout | Integer milliseconds (e.g. `"10000"`). | | `OTEL_EXPORTER_OTLP_HEADERS` | Exporter headers | Comma-separated `key=value` pairs. | | `OTEL_TRACES_SAMPLER` | Traces sampler | OTel sampler names: `always_on`, `always_off`, `traceidratio`, `parentbased_traceidratio`, `parentbased_always_on`, `parentbased_always_off`. | | `OTEL_TRACES_SAMPLER_ARG` | Traces sampling ratio | Float string (e.g. `"0.1"`). | KubeMQ also derives its own `TELEMETRY_*` environment variables from the config keys (for example `TELEMETRY_ENABLE`, `TELEMETRY_EXPORTER_ENDPOINT`). Where both exist, **prefer the standard `OTEL_*` variables** above — they are the conventional way to configure an OTel exporter and are read after the KubeMQ-specific values. ## What gets traced [#what-gets-traced] When tracing is on, every messaging pattern is instrumented. Each operation produces a span whose name encodes the operation and channel. | Span name | Span kind | Pattern | | -------------------------------- | --------- | ---------------- | | `process events.{channel}` | Consumer | Events | | `process events-store.{channel}` | Consumer | Events Store | | `process commands.{channel}` | Consumer | Commands | | `process queries.{channel}` | Consumer | Queries | | `process queues.{channel}` | Consumer | Queues (single) | | `publish-batch queues.{channel}` | Consumer | Queues (batch) | | `deliver queues.{channel}` | Producer | Queues (receive) | In addition, the shared HTTP server applies automatic **transport-level tracing** to all HTTP-based connectors (REST, A2A, MCP, and CloudEvents) under the service name `kubemq-http`. When telemetry is enabled, each HTTP request handled by these connectors generates a span with the standard HTTP attributes: | Attribute | Description | | ------------------ | ------------------------------ | | `http.method` | HTTP method (GET, POST, etc.). | | `http.route` | Matched route pattern. | | `http.status_code` | Response status code. | | `http.target` | Request path. | The A2A and MCP connectors and the agent registry have **no application-level OTel instrumentation** — they rely on this transport-level tracing for HTTP spans (method, route, status, duration) and on [Prometheus metrics](/operate/observability/metrics) for per-operation detail. Per-operation spans (which agent was invoked, tool execution time, JSON-RPC method) are not currently traced. ## Span attributes [#span-attributes] Every messaging span carries a standard set of attributes: | Attribute | Description | Example | | ------------------------------- | ------------------------------ | --------------------------------------------------------- | | `messaging.system` | Always `kubemq`. | `kubemq` | | `messaging.operation.type` | Operation type. | `process`, `receive` | | `messaging.destination.name` | Channel name. | `orders` | | `messaging.message.id` | Message, event, or request ID. | `abc123` | | `messaging.client.id` | Client identifier. | `my-producer` | | `messaging.kubemq.channel_type` | Channel type. | `events`, `events_store`, `commands`, `queries`, `queues` | Queue spans add these extra attributes: | Attribute | Description | | ------------------------------------ | ---------------------------------------------------------------------- | | `messaging.kubemq.expiration` | Message expiration (e.g. `30s`). | | `messaging.kubemq.delay` | Message delay (e.g. `10s`). | | `messaging.kubemq.max_receive` | Max receive count before a message is routed to the dead-letter queue. | | `messaging.batch.message_count` | Number of messages in a batch. | | `messaging.kubemq.batch_error_count` | Number of failed messages in a batch. | | `messaging.kubemq.messages_received` | Messages received in a receive operation. | | `messaging.kubemq.messages_expired` | Messages expired during a receive. | ## Context propagation [#context-propagation] KubeMQ propagates trace context through message tags using the W3C **TraceContext** and **Baggage** propagators: 1. **Inject** — when a message passes through the tracing middleware, the current trace context is injected into the message's `Tags` map. 2. **Extract** — when the message arrives at the message broker, the trace headers are read back out of the `Tags` map to link the consumer span to the producer. This enables end-to-end tracing across **producer → the message broker → consumer**, even when the producer and consumer use different SDKs. ## Error recording [#error-recording] When a send or receive operation fails, the tracing middleware: 1. Records the error on the span. 2. Sets the span status to `Error`. 3. Increments the `messaging.kubemq.errors.count` metric (see below). ## OTel metrics [#otel-metrics] In addition to traces, KubeMQ exports a set of OTel metric instruments over OTLP. These are a parallel push-based path to the [pull-based Prometheus series](/operate/observability/metrics) and are useful when your backend already ingests OTLP metrics (Mimir, Datadog, and others). All instruments share the labels `node`, `type` (channel type), `side` (`send` / `receive`), and `channel`. | Instrument | Type | Unit | Description | | ------------------------------------- | ------------- | ----- | ------------------------------ | | `messaging.kubemq.messages.count` | Counter | — | Total messages processed. | | `messaging.kubemq.messages.volume` | Counter | bytes | Total message volume in bytes. | | `messaging.kubemq.errors.count` | Counter | — | Total errors. | | `messaging.kubemq.messages.pending` | UpDownCounter | — | Pending messages. | | `messaging.kubemq.messages.delayed` | UpDownCounter | — | Delayed queue messages. | | `messaging.kubemq.messages.expired` | Counter | — | Expired messages. | | `messaging.kubemq.messages.waiting` | UpDownCounter | — | Queue messages waiting. | | `messaging.kubemq.clients.count` | UpDownCounter | — | Connected clients. | | `messaging.kubemq.messages.last_seen` | Gauge | — | Last activity timestamp. | | `messaging.kubemq.responses.count` | Counter | — | RPC responses. | | `messaging.kubemq.operation.duration` | Histogram | s | Per-operation latency. | | `messaging.kubemq.message.size` | Histogram | bytes | Message payload size. | | `messaging.kubemq.queue.depth` | UpDownCounter | — | Queue depth per channel. | The OTel `messaging.kubemq.operation.duration` histogram uses its **own** bucket boundaries (in seconds), which are **distinct** from the Prometheus histogram buckets on the [Metrics page](/operate/observability/metrics) — do not assume they match: ```text 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10 ``` ## Export to a backend [#export-to-a-backend] Point the exporter at any OTLP-compatible collector or backend. The example below runs Jaeger locally and exports traces to it over OTLP gRPC. ```yaml title="docker-compose.yml" services: jaeger: image: jaegertracing/all-in-one:latest ports: - "4317:4317" # OTLP gRPC - "16686:16686" # Jaeger UI environment: - COLLECTOR_OTLP_ENABLED=true ``` ```yaml title="config.yaml" telemetry: enable: true serviceName: "kubemq-production" traces: enable: true sampler: "parent_based" samplingRatio: 0.1 # sample 10% of traces metrics: enable: true exportInterval: "30s" exporter: protocol: "grpc" endpoint: "jaeger:4317" insecure: true compression: "gzip" ``` The same exporter block works for **Grafana Tempo** and **Datadog** — point `endpoint` at the relevant OTLP receiver, switch `protocol` to `http` if the backend expects OTLP/HTTP, and supply any required auth headers via `OTEL_EXPORTER_OTLP_HEADERS`. ## Correlate with logs [#correlate-with-logs] When tracing is on, KubeMQ adds `trace_id` and `span_id` fields to the log lines emitted during a traced operation, so you can pivot between a log line and its trace in your backend. See [Structured Logging](/operate/observability/logging) for the log field set and a Grafana Loki ↔ Tempo derived-fields setup. ## Related [#related] # C# SDK (/sdks/csharp) The KubeMQ .NET SDK provides an async-first C# client for all messaging patterns with dependency injection support, auto-reconnection, and retry policies. It requires .NET 8.0 (LTS). ## Installation [#installation] ```bash dotnet add package KubeMQ.SDK.CSharp ``` Or via PackageReference: ```xml title="csproj" ``` **Prerequisites:** .NET 8.0 (LTS), KubeMQ server v3.0+. ## Next: send your first message [#next-send-your-first-message] With the SDK installed, continue to the [first-message tutorial](/sdks/csharp/tutorials/first-message) to connect a client and publish and receive your first message. # Elixir SDK (/sdks/elixir) The KubeMQ Elixir SDK provides an OTP-native GenServer client for all messaging patterns. It requires Elixir 1.15+ and communicates with KubeMQ over gRPC. ## Installation [#installation] Add `kubemq` to your `mix.exs` dependencies: ```elixir title="mix.exs" defp deps do [ {:kubemq, "~> 1.0"} ] end ``` Then fetch: ```bash mix deps.get ``` ## Next: send your first message [#next-send-your-first-message] With the SDK installed, continue to the [first-message tutorial](/sdks/elixir/tutorials/first-message) to connect a client and publish and receive your first message. # Go SDK (/sdks/go) The KubeMQ Go SDK provides a high-performance gRPC client for all messaging patterns. It requires Go 1.25 or later and is compatible with KubeMQ server v2.2+. ## Installation [#installation] ```bash go get github.com/kubemq-io/kubemq-go/v2 ``` ## Quick connect [#quick-connect] ```go import "github.com/kubemq-io/kubemq-go/v2" // Connect to a local KubeMQ server on the default gRPC port. client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000)) ``` ## Next: send your first message [#next-send-your-first-message] With the SDK installed, continue to the [first-message tutorial](/sdks/go/tutorials/first-message) to connect a client and publish and receive your first message. # Java SDK (/sdks/java) The KubeMQ Java SDK provides a type-safe client for all messaging patterns over gRPC transport with built-in TLS, authentication, and reconnection support. It requires Java 11+ (LTS releases 11, 17, and 21 are tested). ## Installation [#installation] ### Maven [#maven] ```xml title="pom.xml" io.kubemq.sdk kubemq-sdk-Java 3.1.1 ``` ### Gradle [#gradle] ```groovy title="build.gradle" implementation 'io.kubemq.sdk:kubemq-sdk-Java:3.1.1' ``` ## Next: send your first message [#next-send-your-first-message] With the SDK installed, continue to the [first-message tutorial](/sdks/java/tutorials/first-message) to connect a client and publish and receive your first message. # Kotlin SDK (/sdks/kotlin) The KubeMQ Kotlin SDK provides a coroutine-first client for all messaging patterns over gRPC transport with DSL builders, Flow-based subscriptions, TLS, authentication, and automatic reconnection. It requires Kotlin 2.0+ and JDK 11, 17, or 21. ## Installation [#installation] ### Gradle (Kotlin DSL) [#gradle-kotlin-dsl] ```kotlin title="build.gradle.kts" dependencies { implementation("io.kubemq.sdk:kubemq-sdk-kotlin:1.0.1") } ``` Or use the BOM for version alignment: ```kotlin title="build.gradle.kts" dependencies { implementation(platform("io.kubemq.sdk:kubemq-sdk-kotlin-bom:1.0.1")) implementation("io.kubemq.sdk:kubemq-sdk-kotlin") } ``` ### Maven [#maven] ```xml title="pom.xml" io.kubemq.sdk kubemq-sdk-kotlin 1.0.1 ``` ## Next: send your first message [#next-send-your-first-message] With the SDK installed, continue to the [first-message tutorial](/sdks/kotlin/tutorials/first-message) to connect a client and publish and receive your first message. # Node.js SDK (/sdks/nodejs) The KubeMQ Node.js SDK is a TypeScript-first client with auto-retry, structured error handling, and OpenTelemetry integration. It requires Node.js 20+ and communicates with KubeMQ over gRPC. ## Installation [#installation] ```bash npm install kubemq-js ``` **Prerequisites:** Node.js 20+ (22, 24 also supported). ## Next: send your first message [#next-send-your-first-message] With the SDK installed, continue to the [first-message tutorial](/sdks/nodejs/tutorials/first-message) to connect a client and publish and receive your first message. # Ruby SDK (/sdks/ruby) The KubeMQ Ruby SDK provides a thread-safe gRPC client for all messaging patterns. It requires Ruby 3.1 or later and is compatible with KubeMQ server v2.2+. ## Installation [#installation] ```bash gem install kubemq ``` Or add to your `Gemfile`: ```ruby title="Gemfile" gem 'kubemq' ``` ## Next: send your first message [#next-send-your-first-message] With the SDK installed, continue to the [first-message tutorial](/sdks/ruby/tutorials/first-message) to connect a client and publish and receive your first message. # Python SDK (/sdks/python) The KubeMQ Python SDK provides async clients for all messaging patterns. It requires Python 3.9+ and communicates with KubeMQ over gRPC. ## Installation [#installation] ```bash pip install kubemq ``` For optional features: ```bash pip install kubemq[otel] # OpenTelemetry integration ``` ## Quick connect [#quick-connect] ```python from kubemq import AsyncPubSubClient # Connect to a local KubeMQ server on the default gRPC port. async with AsyncPubSubClient(address="localhost:50000") as client: ... ``` ## Next: send your first message [#next-send-your-first-message] With the SDK installed, continue to the [first-message tutorial](/sdks/python/tutorials/first-message) to connect a client and publish and receive your first message. # Rust SDK (/sdks/rust) The KubeMQ Rust SDK provides an async gRPC client for all messaging patterns. It is built on Tokio and Tonic, supports all five KubeMQ patterns (Events, Events Store, Queues, Commands, Queries), and is compatible with KubeMQ server v2.2+. ## Installation [#installation] ```bash cargo add kubemq ``` The SDK requires an async runtime. Add Tokio if you haven't already: ```bash cargo add tokio --features full ``` ## Next: send your first message [#next-send-your-first-message] With the SDK installed, continue to the [first-message tutorial](/sdks/rust/tutorials/first-message) to connect a client and publish and receive your first message. # Authentication (/aiway/a2a/guides/authentication) The A2A connector is guarded by the same JWT Bearer authentication as every other KubeMQ connector. You authenticate to the **gateway** (`/a2a/*`) and the **registry** (`/agents/*`) with an `Authorization: Bearer` header; KubeMQ verifies the token, records who you are, and propagates your identity to the agent as `X-KubeMQ-Caller-ID` — without ever forwarding your token downstream. ## Overview [#overview] Authentication for A2A is the connector-wide model described in [Auth & Security](/connectors/reference/auth-and-security), applied to two route families: * **Gateway** — `POST /a2a/` and the SSE stream endpoint route requests to agents. A verified token identifies the caller; that identity becomes the agent's `X-KubeMQ-Caller-ID`. * **Registry** — `POST /agents/register`, `/agents/heartbeat`, `/agents/deregister`, and `DELETE /agents/` are owned operations. The principal in your token becomes the agent's `registered_by`, and only that principal may later modify or delete it. When server authentication is **disabled**, every caller is treated as the synthetic `anonymous` principal and no token is required. When it is **enabled**, an unverified or missing token is rejected — as a JSON-RPC `-32010` error on `/a2a/*`, or HTTP 401 on the REST registry endpoints. A2A discovery is intentionally public: `GET /.well-known/agent-card.json` and any per-agent `/.well-known/agent-card.json` path bypass authentication so a caller can read an [agent card](/aiway/a2a/agent-cards) before it has a token. ## How it works [#how-it-works] The token is verified once, at the shared auth middleware, before the request reaches the gateway. The middleware attaches your claims (including `ClientID`) to the request; the gateway uses that identity for registry ownership and stamps it onto the agent call as `X-KubeMQ-Caller-ID`. The agent receives the caller's identity — never the raw token. *KubeMQ verifies the token at the edge and forwards the caller's identity — not the token — to the agent.* The `Authorization` header is **stripped** before the gateway calls the agent — along with `Cookie`, `Proxy-Authorization`, and other sensitive or hop-by-hop headers. Agents must trust `X-KubeMQ-Caller-ID` for the caller's identity, not a forwarded token. If an agent needs its own credential, register it with the agent's URL and let the agent authenticate downstream itself. ## Authenticating gateway calls [#authenticating-gateway-calls] Add an `Authorization: Bearer ` header to any `POST /a2a/` request. The header carrying the token is consumed by KubeMQ; the rest of the request — including any `X-*` headers — is forwarded to the agent. These snippets send a `message/send` with a Bearer token; on success the agent echoes its `received_headers` and you can see that `Authorization` is absent and `X-KubeMQ-Caller-ID` is present. ```bash curl -X POST http://localhost:9090/a2a/echo-agent-01 \ -H "Authorization: Bearer $KUBEMQ_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": { "message": {"parts": [{"text": "Authenticated call"}]} } }' ``` ```csharp using System.Text; using System.Text.Json; using System.Text.Json.Nodes; const string KubeMqUrl = "http://localhost:9090"; const string AgentId = "echo-agent-01"; var token = Environment.GetEnvironmentVariable("KUBEMQ_TOKEN"); var payload = new JsonObject { ["jsonrpc"] = "2.0", ["id"] = 1, ["method"] = "message/send", ["params"] = new JsonObject { ["message"] = new JsonObject { ["parts"] = new JsonArray(new JsonObject { ["text"] = "Authenticated call" }) } } }; using var client = new HttpClient(); var request = new HttpRequestMessage(HttpMethod.Post, $"{KubeMqUrl}/a2a/{AgentId}") { Content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json") }; request.Headers.Add("Authorization", $"Bearer {token}"); var resp = await client.SendAsync(request); var body = await resp.Content.ReadAsStringAsync(); var data = JsonNode.Parse(body)!; var received = data["result"]?["received_headers"]; Console.WriteLine($"Status: {(int)resp.StatusCode}"); Console.WriteLine($"Caller ID seen by agent: {received?["X-KubeMQ-Caller-ID"]?.GetValue()}"); ``` ```go package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" ) const ( kubemqURL = "http://localhost:9090" agentID = "echo-agent-01" ) func main() { token := os.Getenv("KUBEMQ_TOKEN") payload := map[string]interface{}{ "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": map[string]interface{}{ "message": map[string]interface{}{ "parts": []map[string]interface{}{{"text": "Authenticated call"}}, }, }, } data, _ := json.Marshal(payload) req, _ := http.NewRequest("POST", kubemqURL+"/a2a/"+agentID, bytes.NewReader(data)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { fmt.Fprintf(os.Stderr, "Request failed: %v\n", err) os.Exit(1) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var result map[string]interface{} json.Unmarshal(body, &result) received, _ := result["result"].(map[string]interface{})["received_headers"].(map[string]interface{}) fmt.Printf("Status: %d\n", resp.StatusCode) fmt.Printf("Caller ID seen by agent: %v\n", received["X-KubeMQ-Caller-ID"]) } ``` ```java import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.List; import java.util.Map; public class Client { static final String KUBEMQ_URL = "http://localhost:9090"; static final String AGENT_ID = "echo-agent-01"; static final ObjectMapper MAPPER = new ObjectMapper(); public static void main(String[] args) throws Exception { var token = System.getenv("KUBEMQ_TOKEN"); var payload = Map.of( "jsonrpc", "2.0", "id", 1, "method", "message/send", "params", Map.of( "message", Map.of("parts", List.of(Map.of("text", "Authenticated call"))) ) ); var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID)) .header("Content-Type", "application/json") .header("Authorization", "Bearer " + token) .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload))) .build(); var resp = client.send(req, HttpResponse.BodyHandlers.ofString()); var data = MAPPER.readTree(resp.body()); var received = data.path("result").path("received_headers"); System.out.println("Status: " + resp.statusCode()); System.out.println("Caller ID seen by agent: " + received.path("X-KubeMQ-Caller-ID").asText()); } } ``` ```python import json import os import httpx KUBEMQ_URL = "http://localhost:9090" AGENT_ID = "echo-agent-01" payload = { "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": { "message": {"parts": [{"text": "Authenticated call"}]}, }, } token = os.environ["KUBEMQ_TOKEN"] async with httpx.AsyncClient() as client: resp = await client.post( f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload, headers={"Authorization": f"Bearer {token}"}, ) data = resp.json() received = data.get("result", {}).get("received_headers", {}) print(f"Status: {resp.status_code}") print(f"Caller ID seen by agent: {received.get('X-KubeMQ-Caller-ID')}") ``` ```typescript const KUBEMQ_URL = "http://localhost:9090"; const AGENT_ID = "echo-agent-01"; const token = process.env.KUBEMQ_TOKEN; const request = { jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { parts: [{ text: "Authenticated call" }] }, }, }; const resp = await fetch(`${KUBEMQ_URL}/a2a/${AGENT_ID}`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify(request), }); const data = await resp.json(); const received = data.result?.received_headers || {}; console.log(`Status: ${resp.status}`); console.log(`Caller ID seen by agent: ${received["x-kubemq-caller-id"]}`); ``` ## Authenticating registry calls [#authenticating-registry-calls] The registry uses the **same** `Authorization: Bearer` header. The difference is what KubeMQ does with your identity: `POST /agents/register` records the token's principal as the agent's `registered_by`, and ownership-protected operations (`heartbeat`, `deregister`, `DELETE`) verify that the caller's principal matches. ```bash curl -X POST http://localhost:9090/agents/register \ -H "Authorization: Bearer $KUBEMQ_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "agent_id": "echo-agent-01", "name": "Echo Agent", "url": "http://echo-agent.internal:8000/", "skill_tags": ["echo"] }' ``` ```csharp const string KubeMqUrl = "http://localhost:9090"; var token = Environment.GetEnvironmentVariable("KUBEMQ_TOKEN"); var card = new JsonObject { ["agent_id"] = "echo-agent-01", ["name"] = "Echo Agent", ["url"] = "http://echo-agent.internal:8000/", ["skill_tags"] = new JsonArray("echo") }; using var client = new HttpClient(); var request = new HttpRequestMessage(HttpMethod.Post, $"{KubeMqUrl}/agents/register") { Content = new StringContent(card.ToJsonString(), Encoding.UTF8, "application/json") }; request.Headers.Add("Authorization", $"Bearer {token}"); var resp = await client.SendAsync(request); var data = JsonNode.Parse(await resp.Content.ReadAsStringAsync())!; Console.WriteLine($"registered_by: {data["registered_by"]?.GetValue()}"); ``` ```go token := os.Getenv("KUBEMQ_TOKEN") card := map[string]interface{}{ "agent_id": "echo-agent-01", "name": "Echo Agent", "url": "http://echo-agent.internal:8000/", "skill_tags": []string{"echo"}, } data, _ := json.Marshal(card) req, _ := http.NewRequest("POST", "http://localhost:9090/agents/register", bytes.NewReader(data)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) resp, _ := http.DefaultClient.Do(req) defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var result map[string]interface{} json.Unmarshal(body, &result) fmt.Printf("registered_by: %v\n", result["registered_by"]) ``` ```java var token = System.getenv("KUBEMQ_TOKEN"); var card = Map.of( "agent_id", "echo-agent-01", "name", "Echo Agent", "url", "http://echo-agent.internal:8000/", "skill_tags", List.of("echo") ); var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/agents/register")) .header("Content-Type", "application/json") .header("Authorization", "Bearer " + token) .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(card))) .build(); var resp = client.send(req, HttpResponse.BodyHandlers.ofString()); var data = MAPPER.readTree(resp.body()); System.out.println("registered_by: " + data.path("registered_by").asText()); ``` ```python token = os.environ["KUBEMQ_TOKEN"] card = { "agent_id": "echo-agent-01", "name": "Echo Agent", "url": "http://echo-agent.internal:8000/", "skill_tags": ["echo"], } async with httpx.AsyncClient() as client: resp = await client.post( "http://localhost:9090/agents/register", json=card, headers={"Authorization": f"Bearer {token}"}, ) data = resp.json() print(f"registered_by: {data.get('registered_by')}") ``` ```typescript const token = process.env.KUBEMQ_TOKEN; const card = { agent_id: "echo-agent-01", name: "Echo Agent", url: "http://echo-agent.internal:8000/", skill_tags: ["echo"], }; const resp = await fetch(`${KUBEMQ_URL}/agents/register`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify(card), }); const data = await resp.json(); console.log(`registered_by: ${data.registered_by}`); ``` ## Agent ownership [#agent-ownership] When authentication is enabled, the registry enforces ownership so one principal cannot hijack or delete another principal's agents. * On `POST /agents/register`, the agent's `registered_by` is set automatically from the token's principal — clients cannot spoof it in the body. * `heartbeat`, `deregister`, and `DELETE /agents/` succeed only when the caller's principal matches the agent's `registered_by`. A mismatch returns **403 Forbidden** (ownership conflict). * If an agent's `registered_by` is **blank** while auth is enabled, the ownership check **fails closed** — no principal can modify or delete it. Re-register the agent with a token to take ownership. | Operation | Auth | Ownership check | | ------------------------------------------------ | -------------------------- | --------------------------------------------------------- | | `GET /.well-known/agent-card.json` | Public | — | | `GET /agents`, `GET /agents/` | Bearer (when auth enabled) | None (read-only) | | `POST /agents/register` | Bearer | Sets `registered_by`; rejects cross-principal re-register | | `POST /agents/heartbeat` | Bearer | Caller must own the agent | | `POST /agents/deregister`, `DELETE /agents/` | Bearer | Caller must own the agent | | `POST /a2a/` (gateway) | Bearer | None (any authenticated caller may message any agent) | ## Caller identity reaches the agent [#caller-identity-reaches-the-agent] Because the gateway strips `Authorization` before calling the agent, the agent learns who is calling from the **`X-KubeMQ-Caller-ID`** header, which the virtual subscriber always injects with the caller's `ClientID`. This holds regardless of which transport originated the call (A2A HTTP, gRPC, REST, or the MCP bridge), so an agent can apply its own per-caller logic without parsing tokens. | Header | Set by | Visible to agent | Contains | | ----------------------- | ------------------ | ----------------- | ---------------------------------- | | `Authorization: Bearer` | Caller | **No** (stripped) | The caller's JWT | | `X-KubeMQ-Caller-ID` | Virtual subscriber | **Yes** | The caller's `ClientID` (identity) | When server authentication is disabled, the injected `X-KubeMQ-Caller-ID` reflects the synthetic `anonymous` identity — agents that gate on caller identity should account for this in non-secured deployments. ## Related [#related] # Building Agents (/aiway/a2a/guides/building-agents) An A2A agent is a **plain HTTP server** that speaks JSON-RPC 2.0. It runs anywhere, needs no KubeMQ SDK, and joins the platform by registering its URL with the [registry](/aiway/a2a/registry). The gateway's per-agent virtual subscriber does all the broker work — your agent just answers HTTP POSTs. ## Overview [#overview] A compliant agent has to do three things: 1. **Listen** on a URL, e.g. `http://localhost:18080/`. 2. **Answer** `POST` requests whose body is a JSON-RPC 2.0 request, returning a JSON-RPC 2.0 response (or an SSE stream for `message/stream`). 3. **Register** its [agent card](/aiway/a2a/agent-cards) — including the absolute `url` — with KubeMQ so the gateway can route to it. That is the entire contract. Your agent never connects to the broker, never imports a KubeMQ client, and never deals with protobuf. KubeMQ's virtual subscriber subscribes to Queries on `_AGENTS_.agents/` on your behalf, POSTs each request to your registered URL, and relays the reply back through the broker. This replaces the previous SDK-based model. Agents no longer subscribe to Queries through the gRPC SDK — the `AgentCard.URL` field is now **required** and must be an absolute `http://` or `https://` URL. See [Migration](#migration-from-the-sdk-model). ## How it works [#how-it-works] When a caller targets your agent, the request flows through the gateway and your agent's virtual subscriber, which turns it into an ordinary HTTP POST to your server. *Your agent is a plain HTTP server; the gateway's virtual subscriber bridges the broker to HTTP POST.* The virtual subscriber always injects an `X-KubeMQ-Caller-ID` header carrying the originating caller's identity, and forwards caller `X-*` headers (stripping hop-by-hop and sensitive ones). Your agent reads them like any other HTTP header. ## Build the agent server [#build-the-agent-server] The minimal agent is an HTTP server with one `POST /` handler that parses the JSON-RPC body, processes it, and returns a JSON-RPC response with the same `id`. The example below is an **echo agent** that starts its server, then registers its card with KubeMQ. Always start the HTTP server **before** registering. KubeMQ may route a request to your agent the moment registration succeeds. ```csharp using System.Text.Json; using System.Text.Json.Nodes; public static class Agent { private const string KubeMqUrl = "http://localhost:9090"; private const string AgentId = "echo-agent-01"; private const int AgentPort = 18080; public static async Task RunAsync() { var builder = WebApplication.CreateBuilder(); builder.Logging.ClearProviders(); var app = builder.Build(); app.MapPost("/", async (HttpContext context) => { var body = (await JsonSerializer.DeserializeAsync(context.Request.Body))!; var response = new JsonObject { ["jsonrpc"] = "2.0", ["id"] = body["id"]?.DeepClone(), ["result"] = new JsonObject { ["echo"] = body.DeepClone() } }; context.Response.ContentType = "application/json"; await context.Response.WriteAsync(response.ToJsonString()); }); app.Urls.Add($"http://0.0.0.0:{AgentPort}"); await app.StartAsync(); Console.WriteLine($"Agent listening on port {AgentPort}"); await RegisterAgentAsync(); await app.WaitForShutdownAsync(); } private static async Task RegisterAgentAsync() { var card = new JsonObject { ["agent_id"] = AgentId, ["name"] = "Echo Agent", ["description"] = "A simple echo agent for testing", ["version"] = "1.0.0", ["url"] = $"http://localhost:{AgentPort}/", ["skills"] = new JsonArray(new JsonObject { ["id"] = "echo", ["name"] = "Echo", ["description"] = "Echoes back the received message", ["tags"] = new JsonArray("test", "echo") }), ["defaultInputModes"] = new JsonArray("text"), ["defaultOutputModes"] = new JsonArray("text"), ["protocolVersions"] = new JsonArray("1.0") }; using var client = new HttpClient(); var resp = await client.PostAsync( $"{KubeMqUrl}/agents/register", new StringContent(card.ToJsonString(), System.Text.Encoding.UTF8, "application/json")); Console.WriteLine($"Registered: {(int)resp.StatusCode}"); var body = await resp.Content.ReadAsStringAsync(); Console.WriteLine(JsonSerializer.Serialize(JsonNode.Parse(body), new JsonSerializerOptions { WriteIndented = true })); } } ``` ```go package main import ( "bytes" "encoding/json" "fmt" "io" "net" "net/http" "os" "os/signal" ) const ( kubemqURL = "http://localhost:9090" agentID = "echo-agent-01" agentPort = 18080 ) func handler(w http.ResponseWriter, r *http.Request) { var body map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } resp := map[string]interface{}{ "jsonrpc": "2.0", "id": body["id"], "result": map[string]interface{}{"echo": body}, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } func registerAgent() { card := map[string]interface{}{ "agent_id": agentID, "name": "Echo Agent", "description": "A simple echo agent for testing", "version": "1.0.0", "url": fmt.Sprintf("http://localhost:%d/", agentPort), "skills": []map[string]interface{}{ { "id": "echo", "name": "Echo", "description": "Echoes back the received message", "tags": []string{"test", "echo"}, }, }, "defaultInputModes": []string{"text"}, "defaultOutputModes": []string{"text"}, "protocolVersions": []string{"1.0"}, } data, _ := json.Marshal(card) resp, err := http.Post(kubemqURL+"/agents/register", "application/json", bytes.NewReader(data)) if err != nil { fmt.Fprintf(os.Stderr, "Registration failed: %v\n", err) return } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) fmt.Printf("Registered: %d\n", resp.StatusCode) var pretty bytes.Buffer json.Indent(&pretty, body, "", " ") fmt.Println(pretty.String()) } func main() { http.HandleFunc("/", handler) ln, err := net.Listen("tcp", fmt.Sprintf(":%d", agentPort)) if err != nil { fmt.Fprintf(os.Stderr, "Listen failed: %v\n", err) os.Exit(1) } fmt.Printf("Agent listening on port %d\n", agentPort) go http.Serve(ln, nil) registerAgent() sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt) <-sig fmt.Println("\nShutting down") } ``` ```java import com.fasterxml.jackson.databind.ObjectMapper; import com.sun.net.httpserver.HttpServer; import java.io.OutputStream; import java.net.InetSocketAddress; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.List; import java.util.Map; public class Agent { static final String KUBEMQ_URL = "http://localhost:9090"; static final String AGENT_ID = "echo-agent-01"; static final int AGENT_PORT = 18080; static final ObjectMapper MAPPER = new ObjectMapper(); public static void main(String[] args) throws Exception { HttpServer server = HttpServer.create(new InetSocketAddress(AGENT_PORT), 0); server.createContext("/", exchange -> { if (!"POST".equals(exchange.getRequestMethod())) { exchange.sendResponseHeaders(405, -1); return; } var body = MAPPER.readTree(exchange.getRequestBody()); var response = MAPPER.createObjectNode(); response.put("jsonrpc", "2.0"); response.set("id", body.get("id")); response.putObject("result").set("echo", body); byte[] out = MAPPER.writeValueAsBytes(response); exchange.getResponseHeaders().set("Content-Type", "application/json"); exchange.sendResponseHeaders(200, out.length); try (OutputStream os = exchange.getResponseBody()) { os.write(out); } }); server.start(); System.out.println("Agent listening on port " + AGENT_PORT); var card = Map.of( "agent_id", AGENT_ID, "name", "Echo Agent", "description", "A simple echo agent for testing", "version", "1.0.0", "url", "http://localhost:" + AGENT_PORT + "/", "skills", List.of(Map.of( "id", "echo", "name", "Echo", "description", "Echoes back the received message", "tags", List.of("test", "echo") )), "defaultInputModes", List.of("text"), "defaultOutputModes", List.of("text"), "protocolVersions", List.of("1.0") ); var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/agents/register")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(card))) .build(); var resp = client.send(req, HttpResponse.BodyHandlers.ofString()); System.out.println("Registered: " + resp.statusCode()); System.out.println(MAPPER.writerWithDefaultPrettyPrinter() .writeValueAsString(MAPPER.readTree(resp.body()))); Thread.currentThread().join(); } } ``` ```python """Echo agent that registers with KubeMQ.""" import asyncio import json import signal import httpx from aiohttp import web KUBEMQ_URL = "http://localhost:9090" AGENT_ID = "echo-agent-01" AGENT_PORT = 18080 async def handle_request(request: web.Request) -> web.Response: body = await request.json() return web.json_response({ "jsonrpc": "2.0", "id": body.get("id"), "result": {"echo": body}, }) async def register_agent() -> None: card = { "agent_id": AGENT_ID, "name": "Echo Agent", "description": "A simple echo agent for testing", "version": "1.0.0", "url": f"http://localhost:{AGENT_PORT}/", "skills": [ { "id": "echo", "name": "Echo", "description": "Echoes back the received message", "tags": ["test", "echo"], } ], "defaultInputModes": ["text"], "defaultOutputModes": ["text"], "protocolVersions": ["1.0"], } async with httpx.AsyncClient() as client: resp = await client.post(f"{KUBEMQ_URL}/agents/register", json=card) print(f"Registered: {resp.status_code}") print(json.dumps(resp.json(), indent=2)) async def main() -> None: app = web.Application() app.router.add_post("/", handle_request) runner = web.AppRunner(app) await runner.setup() site = web.TCPSite(runner, "0.0.0.0", AGENT_PORT) await site.start() print(f"Agent listening on port {AGENT_PORT}") await register_agent() stop = asyncio.Event() loop = asyncio.get_running_loop() for sig in (signal.SIGINT, signal.SIGTERM): loop.add_signal_handler(sig, stop.set) await stop.wait() await runner.cleanup() if __name__ == "__main__": asyncio.run(main()) ``` ```typescript import express from "express"; const KUBEMQ_URL = "http://localhost:9090"; const AGENT_ID = "echo-agent-01"; const AGENT_PORT = 18080; const app = express(); app.use(express.json()); app.post("/", (req, res) => { const body = req.body; console.log("Received request:", JSON.stringify(body)); res.json({ jsonrpc: "2.0", id: body.id, result: { echo: body }, }); }); app.listen(AGENT_PORT, async () => { console.log(`Agent listening on port ${AGENT_PORT}`); const card = { agent_id: AGENT_ID, name: "Echo Agent", description: "A simple echo agent for testing", version: "1.0.0", url: `http://localhost:${AGENT_PORT}/`, skills: [ { id: "echo", name: "Echo", description: "Echoes back the received message", tags: ["test", "echo"], }, ], defaultInputModes: ["text"], defaultOutputModes: ["text"], protocolVersions: ["1.0"], }; const resp = await fetch(`${KUBEMQ_URL}/agents/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(card), }); const data = await resp.json(); console.log("Registered:", JSON.stringify(data, null, 2)); }); ``` You can register the same way with plain curl — useful for a sidecar agent or a language not shown above: ```bash curl -X POST http://localhost:9090/agents/register \ -H "Content-Type: application/json" \ -d '{ "agent_id": "echo-agent-01", "name": "Echo Agent", "description": "A simple echo agent for testing", "version": "1.0.0", "url": "http://localhost:18080/", "skills": [{"id": "echo", "name": "Echo", "tags": ["test", "echo"]}], "defaultInputModes": ["text"], "defaultOutputModes": ["text"], "protocolVersions": ["1.0"] }' ``` ## Handle streaming (message/stream) [#handle-streaming-messagestream] If your agent produces incremental output, support the `message/stream` method. Inspect the request's `method`; when it is `message/stream`, respond with `Content-Type: text/event-stream` and write SSE events instead of a single JSON body. Each event uses an `event:` name and a JSON `data:` line, and the stream ends with a terminal `task.done` (or `task.error`) event. ```text event: task.status data: {"type": "status_update", "payload": {"status": "working", "progress": 1, "total": 5}} event: task.done data: {"type": "done", "payload": {"final_result": "completed"}} ``` The handler below extends the echo agent: it emits five `task.status` events, then a `task.done`, for `message/stream` requests, and falls back to a plain JSON-RPC reply for everything else. ```csharp app.MapPost("/", async (HttpContext context) => { var body = (await JsonSerializer.DeserializeAsync(context.Request.Body))!; var method = body["method"]?.GetValue() ?? ""; if (method == "message/stream") { context.Response.ContentType = "text/event-stream"; context.Response.Headers.CacheControl = "no-cache"; for (int i = 1; i <= 5; i++) { var ev = JsonSerializer.Serialize(new { type = "status_update", payload = new { status = "working", progress = i, total = 5 } }); await context.Response.WriteAsync($"event: task.status\ndata: {ev}\n\n"); await context.Response.Body.FlushAsync(); await Task.Delay(500); } var done = JsonSerializer.Serialize(new { type = "done", payload = new { final_result = "completed", event_count = 5 } }); await context.Response.WriteAsync($"event: task.done\ndata: {done}\n\n"); await context.Response.Body.FlushAsync(); return; } var response = new JsonObject { ["jsonrpc"] = "2.0", ["id"] = body["id"]?.DeepClone(), ["result"] = new JsonObject { ["echo"] = body.DeepClone() } }; context.Response.ContentType = "application/json"; await context.Response.WriteAsync(response.ToJsonString()); }); ``` ```go func handleStream(w http.ResponseWriter, r *http.Request) { flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming not supported", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") for i := 1; i <= 5; i++ { event, _ := json.Marshal(map[string]interface{}{ "type": "status_update", "payload": map[string]interface{}{ "status": "working", "progress": i, "total": 5, }, }) fmt.Fprintf(w, "event: task.status\ndata: %s\n\n", event) flusher.Flush() time.Sleep(500 * time.Millisecond) } done, _ := json.Marshal(map[string]interface{}{ "type": "done", "payload": map[string]interface{}{"final_result": "completed", "event_count": 5}, }) fmt.Fprintf(w, "event: task.done\ndata: %s\n\n", done) flusher.Flush() } func handler(w http.ResponseWriter, r *http.Request) { var body map[string]interface{} json.NewDecoder(r.Body).Decode(&body) if method, _ := body["method"].(string); method == "message/stream" { handleStream(w, r) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "jsonrpc": "2.0", "id": body["id"], "result": map[string]interface{}{"echo": body}, }) } ``` ```java server.createContext("/", exchange -> { if (!"POST".equals(exchange.getRequestMethod())) { exchange.sendResponseHeaders(405, -1); return; } var body = MAPPER.readTree(exchange.getRequestBody()); var method = body.path("method").asText(""); if ("message/stream".equals(method)) { exchange.getResponseHeaders().set("Content-Type", "text/event-stream"); exchange.getResponseHeaders().set("Cache-Control", "no-cache"); exchange.sendResponseHeaders(200, 0); try (OutputStream os = exchange.getResponseBody()) { for (int i = 1; i <= 5; i++) { var event = MAPPER.writeValueAsString(Map.of( "type", "status_update", "payload", Map.of("status", "working", "progress", i, "total", 5) )); os.write(("event: task.status\ndata: " + event + "\n\n").getBytes()); os.flush(); try { Thread.sleep(500); } catch (InterruptedException ie) { break; } } var done = MAPPER.writeValueAsString(Map.of( "type", "done", "payload", Map.of("final_result", "completed", "event_count", 5) )); os.write(("event: task.done\ndata: " + done + "\n\n").getBytes()); os.flush(); } return; } var response = MAPPER.createObjectNode(); response.put("jsonrpc", "2.0"); response.set("id", body.get("id")); response.putObject("result").set("echo", body); byte[] out = MAPPER.writeValueAsBytes(response); exchange.getResponseHeaders().set("Content-Type", "application/json"); exchange.sendResponseHeaders(200, out.length); try (OutputStream os = exchange.getResponseBody()) { os.write(out); } }); ``` ```python async def handle_stream(request: web.Request) -> web.StreamResponse: resp = web.StreamResponse( status=200, headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache"}, ) await resp.prepare(request) for i in range(1, 6): event = json.dumps({"type": "status_update", "payload": {"status": "working", "progress": i, "total": 5}}) await resp.write(f"event: task.status\ndata: {event}\n\n".encode()) await asyncio.sleep(0.5) done = json.dumps({"type": "done", "payload": {"final_result": "completed", "event_count": 5}}) await resp.write(f"event: task.done\ndata: {done}\n\n".encode()) await resp.write_eof() return resp async def handle_request(request: web.Request) -> web.Response: body = await request.json() method = body.get("method", "") if method == "message/stream": return await handle_stream(request) return web.json_response({"jsonrpc": "2.0", "id": body.get("id"), "result": {"echo": body}}) ``` ```typescript app.post("/", async (req, res) => { if (req.body.method === "message/stream") { res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); res.flushHeaders(); for (let i = 1; i <= 5; i++) { const event = { type: "status_update", payload: { status: "working", progress: i, total: 5 }, }; res.write(`event: task.status\ndata: ${JSON.stringify(event)}\n\n`); await new Promise((r) => setTimeout(r, 500)); } const done = { type: "done", payload: { final_result: "completed", event_count: 5 } }; res.write(`event: task.done\ndata: ${JSON.stringify(done)}\n\n`); res.end(); } else { res.json({ jsonrpc: "2.0", id: req.body.id, result: { echo: req.body } }); } }); ``` The gateway maps your SSE event names to caller-facing envelopes: `task.status`, `task.artifact`, `task.done`, and `task.error`. See [SSE behavior](/aiway/a2a/guides/sse-behavior) for the full wire format, keepalive, and cancel-on-disconnect rules. ## Agent lifecycle [#agent-lifecycle] Beyond serving requests, an agent manages its presence in the registry: | Stage | Endpoint | When | | ---------- | ------------------------- | ----------------------------------------------------- | | Register | `POST /agents/register` | After the HTTP server is listening, on startup. | | Heartbeat | `POST /agents/heartbeat` | Every 30–60s to refresh `last_seen` and beat the TTL. | | Deregister | `POST /agents/deregister` | On graceful shutdown, before stopping the server. | If an agent stops sending heartbeats, the registry expires it after `AgentTTLSeconds` (default 300) and tears down its virtual subscriber. Refresh liveness with a periodic heartbeat: ```bash curl -X POST http://localhost:9090/agents/heartbeat \ -H "Content-Type: application/json" \ -d '{"agent_id": "echo-agent-01"}' ``` On shutdown, deregister first so callers stop being routed to the agent, then drain in-flight requests and stop the server: ```bash curl -X POST http://localhost:9090/agents/deregister \ -H "Content-Type: application/json" \ -d '{"agent_id": "echo-agent-01"}' ``` When authentication is enabled, only the principal that registered an agent can heartbeat or deregister it. See [Authentication](/aiway/a2a/guides/authentication). ## Migration from the SDK model [#migration-from-the-sdk-model] Earlier KubeMQ versions required receiving agents to use the gRPC SDK to subscribe to Queries on `_AGENTS_.agents/`. That path is gone — replaced by the virtual-subscriber architecture, where agents are plain HTTP servers. **What changed:** * Agents no longer connect to KubeMQ via the gRPC SDK to receive tasks, and no longer call `SubscribeToQueries()` on agent channels. * The `AgentCard.URL` field is now **required** and must be an absolute `http://` or `https://` URL. Relative paths (e.g. `/a2a/`) are rejected. * The old default URL auto-assignment (`/a2a/` when `url` was empty) has been removed. **To migrate an existing agent:** 1. Deploy it as a standard HTTP server that accepts `POST` requests with `Content-Type: application/json`. 2. Implement a JSON-RPC 2.0 handler at the root URL (or any path). 3. Update registration to include the full absolute URL, e.g. `"url": "http://my-agent:8080/"`. 4. Remove all KubeMQ SDK dependencies from the agent. 5. For streaming agents, implement SSE responses for `message/stream`. The **caller side is unchanged** — callers can keep using any KubeMQ transport (gRPC, REST, the A2A HTTP gateway, or the MCP bridge) to reach agents. ## Related [#related] # Concurrency & Limits (/aiway/a2a/guides/concurrency) The A2A gateway protects itself and your agents with three hard limits: a **per-agent concurrency cap**, a **response-size cap**, and **timeout capping**. Each is enforced by the agent's virtual subscriber, returns a predictable error when crossed, and is tunable through [`A2aConfig`](/aiway/a2a/configuration). This guide explains the behavior of each limit and shows how to observe it from a client. ## Overview [#overview] Every registered agent runs behind a **virtual subscriber** — the bridge that turns an inbound JSON-RPC request into an outbound HTTP POST to the agent's URL. That subscriber is where the limits live, so they apply **per agent**, not per client. Multiple callers hitting the same `agent_id` share the same budget. | Limit | Config field | Default | What happens when exceeded | | ----------------------------- | --------------------------------------------- | ------------------ | ---------------------------------------------------------------------------- | | Concurrent in-flight requests | `AgentMaxConcurrency` | `100` | Overflow request rejected immediately — `Executed: false`, JSON-RPC `-32603` | | Agent response body size | `AgentMaxResponseBytes` | `10485760` (10 MB) | Response dropped — `Executed: false`, JSON-RPC `-32603` | | Per-request timeout | `DefaultTimeoutSeconds` / `MaxTimeoutSeconds` | `300` / `3600` | Capped silently, or `-32001` (agent timeout) on expiry | All three surface as **transport errors** (`Executed: false`) — the request never reached your agent's business logic, so a retry is generally safe. See [Error handling](/aiway/a2a/error-handling) for the transport-vs-application distinction. ## Per-agent concurrency limit [#per-agent-concurrency-limit] Each agent's virtual subscriber holds a buffered-channel **semaphore** sized to `AgentMaxConcurrency` (default **100**). The count covers **both** synchronous (`message/send`) and streaming (`message/stream`) requests in flight at the same time. | Request # | Behavior | | --------- | ---------------------------------------------------------------------- | | 1–100 | A handler is spawned and the request is proxied to the agent | | 101+ | Immediately rejected — no goroutine spawned, the agent is never called | When the semaphore is full, the overflow request comes back as a JSON-RPC `-32603` error: ```json { "jsonrpc": "2.0", "id": 101, "error": { "code": -32603, "message": "internal error: concurrency limit exceeded" } } ``` At the transport layer this is recorded as `Executed: false` with the reason `server busy: concurrency limit reached`. Because the request was never processed, retrying after a short backoff is safe. The limit is **per agent**, not per client. To raise headroom for a busy agent, increase `AgentMaxConcurrency` in [configuration](/aiway/a2a/configuration) — or run more agent instances behind distinct `agent_id`s and route across them. ### Observe the limit [#observe-the-limit] Fire more than `AgentMaxConcurrency` requests at one agent simultaneously and at least one comes back with `-32603`. The snippets below send **101** concurrent `message/send` requests and count how many were rejected. ```bash # Fire 101 requests in parallel; at least one returns -32603 "concurrency limit exceeded". for i in $(seq 1 101); do curl -s -X POST http://localhost:9090/a2a/concurrency-agent-01 \ -H "Content-Type: application/json" \ -d "{\"jsonrpc\":\"2.0\",\"id\":$i,\"method\":\"message/send\",\"params\":{\"message\":{\"parts\":[{\"text\":\"Request #$i\"}]}}}" & done | grep -c -- -32603 wait # => prints the number of requests rejected by the concurrency limit (>= 1) ``` ```csharp using System.Text; using System.Text.Json.Nodes; const string KubeMqUrl = "http://localhost:9090"; const string AgentId = "concurrency-agent-01"; const int NumRequests = 101; async Task SendRequest(HttpClient httpClient, int requestId) { var payload = new JsonObject { ["jsonrpc"] = "2.0", ["id"] = requestId, ["method"] = "message/send", ["params"] = new JsonObject { ["message"] = new JsonObject { ["parts"] = new JsonArray(new JsonObject { ["text"] = $"Request #{requestId}" }) } } }; var resp = await httpClient.PostAsync( $"{KubeMqUrl}/a2a/{AgentId}", new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json")); return JsonNode.Parse(await resp.Content.ReadAsStringAsync()); } using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; Console.WriteLine($"Sending {NumRequests} concurrent requests (limit is 100)..."); var tasks = Enumerable.Range(1, NumRequests) .Select(i => SendRequest(client, i)) .ToArray(); var results = await Task.WhenAll(tasks); int successes = 0, concurrencyErrors = 0, otherErrors = 0; foreach (var r in results) { if (r?["result"] != null) successes++; else if (r?["error"]?["code"]?.GetValue() == -32603) concurrencyErrors++; else otherErrors++; } Console.WriteLine($" Successes: {successes}"); Console.WriteLine($" Concurrency errors: {concurrencyErrors} (code -32603)"); Console.WriteLine($" Other errors: {otherErrors}"); if (concurrencyErrors >= 1) Console.WriteLine($"\nConcurrency limit enforced — {concurrencyErrors} request(s) rejected!"); ``` ```go package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "sync" "time" ) const ( kubemqURL = "http://localhost:9090" agentID = "concurrency-agent-01" numRequests = 101 ) type result struct { data map[string]interface{} err error } func sendRequest(id int, client *http.Client, wg *sync.WaitGroup, results chan<- result) { defer wg.Done() payload := map[string]interface{}{ "jsonrpc": "2.0", "id": id, "method": "message/send", "params": map[string]interface{}{ "message": map[string]interface{}{ "parts": []map[string]interface{}{{"text": fmt.Sprintf("Request #%d", id)}}, }, }, } data, _ := json.Marshal(payload) resp, err := client.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(data)) if err != nil { results <- result{err: err} return } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var r map[string]interface{} json.Unmarshal(body, &r) results <- result{data: r} } func main() { fmt.Printf("Sending %d concurrent requests (limit is 100)...\n", numRequests) client := &http.Client{Timeout: 30 * time.Second} results := make(chan result, numRequests) var wg sync.WaitGroup for i := 1; i <= numRequests; i++ { wg.Add(1) go sendRequest(i, client, &wg, results) } wg.Wait() close(results) successes, concurrencyErrors, otherErrors := 0, 0, 0 for r := range results { if r.err != nil { otherErrors++ } else if _, ok := r.data["result"]; ok { successes++ } else if e, ok := r.data["error"].(map[string]interface{}); ok { if code, _ := e["code"].(float64); int(code) == -32603 { concurrencyErrors++ } else { otherErrors++ } } } fmt.Printf(" Successes: %d\n", successes) fmt.Printf(" Concurrency errors: %d (code -32603)\n", concurrencyErrors) fmt.Printf(" Other errors: %d\n", otherErrors) if concurrencyErrors >= 1 { fmt.Printf("\nConcurrency limit enforced — %d request(s) rejected!\n", concurrencyErrors) } } ``` ```java import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; public class Client { static final String KUBEMQ_URL = "http://localhost:9090"; static final String AGENT_ID = "concurrency-agent-01"; static final int NUM_REQUESTS = 101; static final ObjectMapper MAPPER = new ObjectMapper(); public static void main(String[] args) throws Exception { var client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(30)) .build(); System.out.println("Sending " + NUM_REQUESTS + " concurrent requests (limit is 100)..."); List>> futures = new ArrayList<>(); for (int i = 1; i <= NUM_REQUESTS; i++) { var payload = Map.of( "jsonrpc", "2.0", "id", i, "method", "message/send", "params", Map.of( "message", Map.of("parts", List.of(Map.of("text", "Request #" + i))) ) ); var req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID)) .header("Content-Type", "application/json") .timeout(Duration.ofSeconds(30)) .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload))) .build(); futures.add(client.sendAsync(req, HttpResponse.BodyHandlers.ofString())); } CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); int successes = 0, concurrencyErrors = 0, otherErrors = 0; for (var future : futures) { var data = MAPPER.readTree(future.get().body()); if (data.has("result")) { successes++; } else if (data.has("error")) { if (data.path("error").path("code").asInt() == -32603) concurrencyErrors++; else otherErrors++; } } System.out.println(" Successes: " + successes); System.out.println(" Concurrency errors: " + concurrencyErrors + " (code -32603)"); System.out.println(" Other errors: " + otherErrors); if (concurrencyErrors >= 1) System.out.println("\nConcurrency limit enforced — " + concurrencyErrors + " request(s) rejected!"); } } ``` ```python import asyncio import httpx KUBEMQ_URL = "http://localhost:9090" AGENT_ID = "concurrency-agent-01" NUM_REQUESTS = 101 async def send_request(client: httpx.AsyncClient, request_id: int) -> dict: payload = { "jsonrpc": "2.0", "id": request_id, "method": "message/send", "params": { "message": {"parts": [{"text": f"Request #{request_id}"}]}, }, } resp = await client.post(f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload) return resp.json() async def main() -> None: limits = httpx.Limits(max_connections=200) async with httpx.AsyncClient(timeout=30, limits=limits) as client: print(f"Sending {NUM_REQUESTS} concurrent requests (limit is 100)...") tasks = [send_request(client, i) for i in range(1, NUM_REQUESTS + 1)] results = await asyncio.gather(*tasks) successes = sum(1 for r in results if "result" in r) concurrency_errors = sum(1 for r in results if r.get("error", {}).get("code") == -32603) other_errors = len(results) - successes - concurrency_errors print(f" Successes: {successes}") print(f" Concurrency errors: {concurrency_errors} (code -32603)") print(f" Other errors: {other_errors}") assert concurrency_errors >= 1, "Expected at least 1 concurrency limit error" print(f"\nConcurrency limit enforced — {concurrency_errors} request(s) rejected!") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript const KUBEMQ_URL = "http://localhost:9090"; const AGENT_ID = "concurrency-agent-01"; const NUM_REQUESTS = 101; async function sendRequest(id: number): Promise<{ ok: boolean; errorCode?: number }> { const request = { jsonrpc: "2.0", id, method: "message/send", params: { message: { parts: [{ text: `Request ${id}` }] } }, }; const resp = await fetch(`${KUBEMQ_URL}/a2a/${AGENT_ID}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), }); const data = await resp.json(); if (data.error) { return { ok: false, errorCode: data.error.code }; } return { ok: true }; } async function main() { console.log(`Sending ${NUM_REQUESTS} concurrent requests (limit is 100)...`); const promises = Array.from({ length: NUM_REQUESTS }, (_, i) => sendRequest(i + 1)); const results = await Promise.all(promises); const succeeded = results.filter((r) => r.ok).length; const rejected = results.filter((r) => r.errorCode === -32603).length; const otherErrors = results.length - succeeded - rejected; console.log(` Succeeded: ${succeeded} (expect <=100)`); console.log(` Rejected -32603: ${rejected} (expect >=1)`); console.log(` Other errors: ${otherErrors}`); if (rejected > 0) { console.log(`\nConcurrency limit enforced correctly.`); } } main().catch(console.error); ``` ## Response-size limit [#response-size-limit] The gateway caps the agent's HTTP response body at `AgentMaxResponseBytes` (default **10 MB**). When an agent returns more than that — for a synchronous reply, or for a single SSE event's accumulated data lines on a stream — the gateway aborts the relay to protect itself from memory exhaustion and returns `-32603`: ```json { "jsonrpc": "2.0", "id": 1, "error": { "code": -32603, "message": "internal error: response too large" } } ``` At the transport layer this is `Executed: false` with the reason `agent response too large`. Unlike a timeout, retrying the **same** request will fail identically — the agent is producing an oversized body. Fix it by paginating the agent's output, streaming the result via [`message/stream`](/aiway/a2a/streaming), or raising `AgentMaxResponseBytes` if the payload is legitimately large. ### Observe the limit [#observe-the-limit-1] Target an agent that returns more than 10 MB and inspect the `error` object. ```bash curl -s -X POST http://localhost:9090/a2a/oversize-agent-01 \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": {"message": {"parts": [{"text": "Give me a large response"}]}} }' # => {"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"internal error: response too large"}} ``` ```csharp using System.Text; using System.Text.Json.Nodes; const string KubeMqUrl = "http://localhost:9090"; const string AgentId = "oversize-agent-01"; var payload = new JsonObject { ["jsonrpc"] = "2.0", ["id"] = 1, ["method"] = "message/send", ["params"] = new JsonObject { ["message"] = new JsonObject { ["parts"] = new JsonArray(new JsonObject { ["text"] = "Give me a large response" }) } } }; using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; Console.WriteLine("Requesting oversized response (>10MB)..."); var resp = await client.PostAsync( $"{KubeMqUrl}/a2a/{AgentId}", new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json")); var body = await resp.Content.ReadAsStringAsync(); var data = JsonNode.Parse(body)!; if (data["error"] != null) { var error = data["error"]!; Console.WriteLine($"Error code: {error["code"]}"); Console.WriteLine($"Error message: {error["message"]}"); Console.WriteLine("\nResponse size limit enforced!"); } else { Console.WriteLine($"Status: {(int)resp.StatusCode}"); Console.WriteLine("Note: Response was accepted (check AgentMaxResponseBytes)"); } ``` ```go package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" "time" ) const ( kubemqURL = "http://localhost:9090" agentID = "oversize-agent-01" ) func main() { payload := map[string]interface{}{ "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": map[string]interface{}{ "message": map[string]interface{}{ "parts": []map[string]interface{}{{"text": "Give me a large response"}}, }, }, } data, _ := json.Marshal(payload) fmt.Println("Requesting oversized response (>10MB)...") client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(data)) if err != nil { fmt.Fprintf(os.Stderr, "Request failed: %v\n", err) os.Exit(1) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var result map[string]interface{} json.Unmarshal(body, &result) if errorObj, ok := result["error"].(map[string]interface{}); ok { code, _ := errorObj["code"].(float64) msg, _ := errorObj["message"].(string) fmt.Printf("Error code: %.0f\n", code) fmt.Printf("Error message: %s\n", msg) fmt.Println("\nResponse size limit enforced!") } else { fmt.Printf("Status: %d\n", resp.StatusCode) fmt.Println("Note: Response was accepted (check AgentMaxResponseBytes)") } } ``` ```java import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.util.List; import java.util.Map; public class Client { static final String KUBEMQ_URL = "http://localhost:9090"; static final String AGENT_ID = "oversize-agent-01"; static final ObjectMapper MAPPER = new ObjectMapper(); public static void main(String[] args) throws Exception { var payload = Map.of( "jsonrpc", "2.0", "id", 1, "method", "message/send", "params", Map.of( "message", Map.of("parts", List.of(Map.of("text", "Give me a large response"))) ) ); var client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build(); var req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID)) .header("Content-Type", "application/json") .timeout(Duration.ofSeconds(30)) .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload))) .build(); System.out.println("Requesting oversized response (>10MB)..."); var resp = client.send(req, HttpResponse.BodyHandlers.ofString()); var data = MAPPER.readTree(resp.body()); if (data.has("error")) { var error = data.path("error"); System.out.println("Error code: " + error.path("code").asInt()); System.out.println("Error message: " + error.path("message").asText()); System.out.println("\nResponse size limit enforced!"); } else { System.out.println("Status: " + resp.statusCode()); System.out.println("Note: Response was accepted (check AgentMaxResponseBytes)"); } } } ``` ```python import asyncio import httpx KUBEMQ_URL = "http://localhost:9090" AGENT_ID = "oversize-agent-01" async def main() -> None: payload = { "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": { "message": {"parts": [{"text": "Give me a large response"}]}, }, } async with httpx.AsyncClient(timeout=30) as client: print("Requesting oversized response (>10MB)...") resp = await client.post(f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload) data = resp.json() if "error" in data: error = data["error"] print(f"Error code: {error.get('code')}") print(f"Error message: {error.get('message')}") print("\nResponse size limit enforced!") else: print(f"Status: {resp.status_code}") print("Note: Response was accepted (check AgentMaxResponseBytes)") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript const KUBEMQ_URL = "http://localhost:9090"; const AGENT_ID = "oversize-agent-01"; async function main() { const request = { jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { parts: [{ text: "Give me a large response" }] }, }, }; console.log("Requesting oversized response (>10MB)..."); const resp = await fetch(`${KUBEMQ_URL}/a2a/${AGENT_ID}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), }); const data = await resp.json(); if (data.error) { console.log(`Error code: ${data.error.code}`); console.log(`Error message: ${data.error.message}`); console.log("\nResponse size limit enforced!"); } else { console.log("Unexpected: got success response. Response may have been under the limit."); } } main().catch(console.error); ``` ## Timeout capping [#timeout-capping] Each call carries a deadline. When a client sets `params.configuration.timeout` (in seconds), the gateway clamps it to the configured window before proxying to the agent: * If no timeout is supplied, the server applies `DefaultTimeoutSeconds` (**300s**). * Any value above `MaxTimeoutSeconds` (**3600s**) is **silently reduced** to that ceiling. * When the deadline passes before the agent replies, the call returns [`-32001` (agent timeout)](/aiway/a2a/error-handling). | `params.configuration.timeout` | Effective deadline | | ------------------------------ | ------------------------------------- | | not set | `DefaultTimeoutSeconds` (300s) | | `30` | 30s | | `3600` | 3600s | | `99999` | 3600s (capped to `MaxTimeoutSeconds`) | A **gateway buffer** of \~10 seconds (`GatewayTimeoutBuffer`) is added on top of the effective deadline before the gateway times out its proxy request, so the gateway never gives up before the agent's own deadline. Give your HTTP client a slightly longer timeout than the value you request (a 15s pad is typical) so the client does not abort before the gateway returns the `-32001` envelope. ## Monitoring [#monitoring] Concurrency and limit pressure are visible in the Prometheus metrics exported on port 8080 — see [Observability](/connectors/concepts/observability). | Metric | What it tells you | | ------------------------------- | ---------------------------------------------------------------------------------- | | `kubemq_a2a_requests_total` | Total requests per agent and method — rising rejections track concurrency pressure | | `kubemq_a2a_errors_total` | Error count per agent — `-32603` spikes signal a saturated agent | | `kubemq_a2a_sse_streams_active` | Active SSE streams per agent (each counts against the concurrency budget) | ## Related [#related] # SSE Behavior (/aiway/a2a/guides/sse-behavior) A2A streaming rides on **Server-Sent Events (SSE)**: the gateway proxies a long-lived `text/event-stream` response from the agent back to the caller. This guide is the ground-truth reference for *how that stream behaves on the wire* — the exact event names, the JSON envelope each event carries, the keepalive cadence, when the gateway closes a stream, and what happens when the caller hangs up. For the task-level walkthrough of *using* streaming, see [Streaming (SSE)](/aiway/a2a/streaming). This page focuses on protocol details you need when writing or debugging an SSE client. ## Overview [#overview] A stream is opened either by `POST /a2a/:agent_id` with `method: "message/stream"`, or by `GET /a2a/:agent_id/stream`. In both cases the gateway responds with `Content-Type: text/event-stream` and relays events the agent emits, framed in the standard SSE format, until a terminal event arrives or the stream is torn down. The gateway is a transparent relay: the agent produces the events, KubeMQ's per-agent **virtual subscriber** bridges them from the agent's HTTP SSE connection onto a temporary Events channel (`_AGENTS_.stream/`), and the A2A connector replays them to the caller. The caller only ever sees a normal SSE stream. ## How it works [#how-it-works] The diagram below traces a single streaming request through the gateway and virtual subscriber and back to the caller's SSE reader. *The gateway and virtual subscriber relay the agent's SSE events to the caller; a `task.done` or `task.error` envelope ends the stream.* ## Wire format [#wire-format] Each SSE message is an `event:` line and a `data:` line, terminated by a blank line. The `data:` payload is a single-line JSON envelope: ```text event: task.status data: {"stream_id":"...","type":"status_update","payload":{"status":"working","progress":3,"total":10}} ``` Multi-line JSON is not used — every envelope is serialized to one line so it fits a single `data:` field. Responses carry `Content-Type: text/event-stream`. ## Event types [#event-types] The agent's envelope `type` maps to a named SSE `event`. `task.done` and `task.error` are **terminal** — after either, stop reading; the gateway closes the connection and the `kubemq_a2a_sse_streams_active` gauge decrements. | SSE event | Envelope `type` | Meaning | Terminal | | --------------- | --------------- | ----------------------------------------------- | -------- | | `task.status` | `status_update` | Progress update (`status`, `progress`, `total`) | No | | `task.artifact` | `artifact` | Intermediate artifact delivery | No | | `task.done` | `done` | Successful completion | Yes | | `task.error` | `error` | Failure (carries `code` and `message`) | Yes | | `message` | (default) | Any envelope without a recognized type | No | Example payloads: ```json {"type": "status_update", "payload": {"status": "working", "progress": 3, "total": 10}} {"type": "artifact", "payload": {"name": "result.json", "data": {"key": "value"}}} {"type": "done", "payload": {"final_result": "completed", "event_count": 10}} {"type": "error", "payload": {"code": -32001, "message": "agent timeout"}} ``` ## Keepalive comments [#keepalive-comments] To keep proxies and load balancers from dropping an idle connection, the gateway emits an SSE **comment** line every 30 seconds (`sseKeepaliveInterval`): ```text : keepalive ``` Comment lines start with `:`, carry no `event:` or `data:` line, and are not stream events. Standard SSE client libraries ignore them automatically. If you parse the stream by hand, skip any line beginning with `:`. ## Idle timeout [#idle-timeout] If no events flow for `MaxSSEIdleSeconds` (default **300s**, set on `A2aConfig`), the gateway closes the stream. Before closing it sends a terminal `task.error` with code `-32001` and message `"stream idle timeout"`, then issues a best-effort cancel to the agent. Agents handling long-running work should emit periodic `task.status` events to keep the stream alive. The idle timer measures time *between events*, not total stream duration. A stream can run indefinitely as long as the agent keeps emitting events (including `task.status` heartbeats) more often than `MaxSSEIdleSeconds`. ## Client disconnect and cancellation [#client-disconnect-and-cancellation] When the caller disconnects from an open stream, the gateway detects it and propagates the cancellation to the agent rather than leaking the upstream connection: 1. The gateway detects the closed caller connection. 2. It sends a Query to the agent's virtual subscriber on `_AGENTS_.agents/` with `a2a_method: "stream_cancel"` and the `stream_id` (10s timeout). 3. The virtual subscriber cancels its SSE relay goroutine, closing the HTTP SSE connection to the agent. 4. `kubemq_a2a_sse_streams_active` decrements. This means closing your SSE reader is a real cancellation signal — the agent is told to stop, freeing its work and the agent's concurrency slot. ## Reconnection [#reconnection] A2A streams are **not resumable**. There is no event ID and no `Last-Event-ID` support — a dropped stream cannot be resumed from where it stopped, and any undelivered events are lost. To recover, start a fresh stream. Do not rely on automatic SSE reconnection to continue a task. Because there is no replay, a reconnect starts a brand-new request. For long-running work, make the agent idempotent and correlate retries with `context_id`. For resilience across reconnects: * Reuse `context_id` so the agent can correlate the new stream with the original request. * Make agent-side processing idempotent. * Where supported, check task status before re-streaming so you do not duplicate work. ## Concurrent streams [#concurrent-streams] Multiple SSE streams can be open at once: * Multiple streams to the **same agent** are allowed; each one counts against that agent's `AgentMaxConcurrency` limit (default 100). * Streams to **different agents** are fully independent. * `kubemq_a2a_sse_streams_active` tracks all active streams. ## Consuming the stream [#consuming-the-stream] Read the stream line by line, track the most recent `event:`, parse each `data:` line as JSON, and stop when you see `task.done` or `task.error`. The snippets below read a `message/stream` response end to end. ```bash curl -N -X POST http://localhost:9090/a2a/stream-agent-01 \ -H 'Content-Type: application/json' \ -H 'Accept: text/event-stream' \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "message/stream", "params": { "message": { "parts": [{ "text": "Stream me some updates" }] } } }' ``` ```csharp using System.Text; using System.Text.Json; const string KubeMqUrl = "http://localhost:9090"; const string AgentId = "stream-agent-01"; var payload = new { jsonrpc = "2.0", id = 1, method = "message/stream", @params = new { message = new { parts = new[] { new { text = "Stream me some updates" } } } } }; using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(60) }; var request = new HttpRequestMessage(HttpMethod.Post, $"{KubeMqUrl}/a2a/{AgentId}") { Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json") }; request.Headers.Add("Accept", "text/event-stream"); Console.WriteLine("Connecting to SSE stream..."); using var resp = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); using var stream = await resp.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream); string? eventType = null; int eventCount = 0; while (!reader.EndOfStream) { var line = await reader.ReadLineAsync(); if (line == null) break; if (line.StartsWith("event: ")) eventType = line[7..]; else if (line.StartsWith("data: ") && eventType != null) { eventCount++; var data = line[6..]; Console.WriteLine($"[{eventType}] {data}"); if (eventType is "task.done" or "task.error") break; } else if (line.Length == 0) eventType = null; } Console.WriteLine($"\nReceived {eventCount} events"); ``` ```go package main import ( "bufio" "bytes" "encoding/json" "fmt" "net/http" "os" "strings" ) const ( kubemqURL = "http://localhost:9090" agentID = "stream-agent-01" ) func main() { payload := map[string]interface{}{ "jsonrpc": "2.0", "id": 1, "method": "message/stream", "params": map[string]interface{}{ "message": map[string]interface{}{ "parts": []map[string]interface{}{{"text": "Stream me some updates"}}, }, }, } data, err := json.Marshal(payload) if err != nil { fmt.Fprintf(os.Stderr, "Marshal failed: %v\n", err) os.Exit(1) } req, err := http.NewRequest(http.MethodPost, kubemqURL+"/a2a/"+agentID, bytes.NewReader(data)) if err != nil { fmt.Fprintf(os.Stderr, "Request build failed: %v\n", err) os.Exit(1) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "text/event-stream") resp, err := http.DefaultClient.Do(req) if err != nil { fmt.Fprintf(os.Stderr, "Request failed: %v\n", err) os.Exit(1) } defer resp.Body.Close() fmt.Println("Connecting to SSE stream...") eventCount := 0 eventType := "" scanner := bufio.NewScanner(resp.Body) for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, "event: ") { eventType = strings.TrimPrefix(line, "event: ") } else if strings.HasPrefix(line, "data: ") { eventCount++ dataStr := strings.TrimPrefix(line, "data: ") fmt.Printf("[%s] %s\n", eventType, dataStr) if eventType == "task.done" || eventType == "task.error" { break } } } fmt.Printf("\nReceived %d events\n", eventCount) } ``` ```java import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.util.List; import java.util.Map; public class Client { static final String KUBEMQ_URL = "http://localhost:9090"; static final String AGENT_ID = "stream-agent-01"; static final ObjectMapper MAPPER = new ObjectMapper(); public static void main(String[] args) throws Exception { var payload = Map.of( "jsonrpc", "2.0", "id", 1, "method", "message/stream", "params", Map.of( "message", Map.of("parts", List.of(Map.of("text", "Stream me some updates"))) ) ); var client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(60)) .build(); var req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID)) .header("Content-Type", "application/json") .header("Accept", "text/event-stream") .timeout(Duration.ofSeconds(60)) .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload))) .build(); System.out.println("Connecting to SSE stream..."); var resp = client.send(req, HttpResponse.BodyHandlers.ofLines()); int eventCount = 0; String currentEvent = null; for (var it = resp.body().iterator(); it.hasNext(); ) { String line = it.next(); if (line.startsWith("event: ")) { currentEvent = line.substring(7).trim(); } else if (line.startsWith("data: ")) { eventCount++; String data = line.substring(6); System.out.println("[" + currentEvent + "] " + data); if ("task.done".equals(currentEvent) || "task.error".equals(currentEvent)) { break; } } } System.out.println("\nReceived " + eventCount + " events"); } } ``` ```python import asyncio import json import httpx from httpx_sse import aconnect_sse KUBEMQ_URL = "http://localhost:9090" AGENT_ID = "stream-agent-01" async def main() -> None: payload = { "jsonrpc": "2.0", "id": 1, "method": "message/stream", "params": { "message": {"parts": [{"text": "Stream me some updates"}]}, }, } async with httpx.AsyncClient(timeout=60) as client: print("Connecting to SSE stream...") async with aconnect_sse( client, "POST", f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload, headers={"Accept": "text/event-stream"}, ) as event_source: event_count = 0 async for event in event_source.aiter_sse(): event_count += 1 data = json.loads(event.data) print(f"[{event.event}] {json.dumps(data)}") if event.event in ("task.done", "task.error"): break print(f"\nReceived {event_count} events") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript const KUBEMQ_URL = "http://localhost:9090"; const AGENT_ID = "stream-agent-01"; async function main() { const request = { jsonrpc: "2.0", id: 1, method: "message/stream", params: { message: { parts: [{ text: "Stream me some updates" }] } }, }; const resp = await fetch(`${KUBEMQ_URL}/a2a/${AGENT_ID}`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "text/event-stream", }, body: JSON.stringify(request), }); const reader = resp.body!.getReader(); const decoder = new TextDecoder(); let eventCount = 0; let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const frames = buffer.split("\n\n"); buffer = frames.pop() ?? ""; for (const frame of frames) { if (!frame.trim()) continue; let eventType = ""; let eventData = ""; for (const line of frame.split("\n")) { if (line.startsWith("event: ")) eventType = line.slice(7).trim(); else if (line.startsWith("data: ")) eventData = line.slice(6).trim(); } if (!eventType) continue; eventCount++; const payload = JSON.parse(eventData); console.log(`[${eventType}] ${JSON.stringify(payload)}`); if (eventType === "task.done" || eventType === "task.error") { console.log(`\nStream complete. Total events: ${eventCount}`); reader.cancel(); return; } } } } main().catch(console.error); ``` Always break on `task.done` or `task.error`. Closing the reader after a terminal event is how the gateway and agent learn the stream is finished — and, mid-stream, how a caller cancels the agent's work (see [Client disconnect and cancellation](#client-disconnect-and-cancellation)). ## Related [#related] # Multi-Agent Gateway (/aiway/a2a/scenarios/multi-agent-gateway) This scenario stands up **three independent agents** — echo, translate, and summarize — behind a single A2A gateway, then drives them from one client: discover the right agent by **skill tag**, and dispatch a `message/send` to it by **`agent_id`**. It ties together the [registry](/aiway/a2a/registry) and [synchronous messaging](/aiway/a2a/sync-messaging). ## The setup [#the-setup] Each agent is a plain HTTP server registered by URL — no KubeMQ SDK runs on any of them. The gateway keeps one [virtual subscriber](/aiway/a2a/architecture) per agent, so a caller reaches any agent through the same `POST /a2a/{agent_id}` surface. Callers never address an agent's HTTP URL directly; they address its `agent_id` and the gateway routes the request. *One gateway fronts many agents; the client discovers by skill tag and routes by `agent_id`.* ## Step 1 — Register the agents [#step-1--register-the-agents] Each agent registers its own [agent card](/aiway/a2a/agent-cards) with a unique `agent_id`, its HTTP `url`, and a `skills` list. The `tags` on each skill are what make the agent discoverable later. Agents that share a capability (here, `translate` and `summarize` both carry the `nlp` tag) can be found together. ```bash # Register three agents with distinct skills. # (Each agent process registers itself on start; shown here as explicit calls.) curl -X POST http://localhost:9090/agents/register \ -H "Content-Type: application/json" \ -d '{ "agent_id": "echo-agent-01", "name": "Echo Agent", "url": "http://localhost:18081/", "skills": [{"id": "echo", "name": "Echo", "tags": ["echo"]}] }' curl -X POST http://localhost:9090/agents/register \ -H "Content-Type: application/json" \ -d '{ "agent_id": "translate-agent-01", "name": "Translate Agent", "url": "http://localhost:18082/", "skills": [{"id": "translate", "name": "Translate", "tags": ["translate", "nlp"]}] }' curl -X POST http://localhost:9090/agents/register \ -H "Content-Type: application/json" \ -d '{ "agent_id": "summarize-agent-01", "name": "Summarize Agent", "url": "http://localhost:18083/", "skills": [{"id": "summarize", "name": "Summarize", "tags": ["summarize", "nlp"]}] }' ``` ```csharp using System.Text; using System.Text.Json.Nodes; const string KubeMqUrl = "http://localhost:9090"; using var client = new HttpClient(); var agents = new[] { ("echo-agent-01", "Echo Agent", 18081, "echo", new[] { "echo" }), ("translate-agent-01", "Translate Agent", 18082, "translate", new[] { "translate", "nlp" }), ("summarize-agent-01", "Summarize Agent", 18083, "summarize", new[] { "summarize", "nlp" }), }; foreach (var (id, name, port, skillId, tags) in agents) { var card = new JsonObject { ["agent_id"] = id, ["name"] = name, ["url"] = $"http://localhost:{port}/", ["skills"] = new JsonArray(new JsonObject { ["id"] = skillId, ["name"] = skillId, ["tags"] = new JsonArray(tags.Select(t => (JsonNode)t!).ToArray()) }) }; var resp = await client.PostAsync( $"{KubeMqUrl}/agents/register", new StringContent(card.ToJsonString(), Encoding.UTF8, "application/json")); Console.WriteLine($"Registered {id}: {(int)resp.StatusCode}"); } ``` ```go package main import ( "bytes" "encoding/json" "fmt" "net/http" ) const kubemqURL = "http://localhost:9090" func main() { agents := []map[string]interface{}{ { "agent_id": "echo-agent-01", "name": "Echo Agent", "url": "http://localhost:18081/", "skills": []map[string]interface{}{{"id": "echo", "name": "Echo", "tags": []string{"echo"}}}, }, { "agent_id": "translate-agent-01", "name": "Translate Agent", "url": "http://localhost:18082/", "skills": []map[string]interface{}{{"id": "translate", "name": "Translate", "tags": []string{"translate", "nlp"}}}, }, { "agent_id": "summarize-agent-01", "name": "Summarize Agent", "url": "http://localhost:18083/", "skills": []map[string]interface{}{{"id": "summarize", "name": "Summarize", "tags": []string{"summarize", "nlp"}}}, }, } for _, card := range agents { data, _ := json.Marshal(card) resp, err := http.Post(kubemqURL+"/agents/register", "application/json", bytes.NewReader(data)) if err != nil { fmt.Printf("Register %v failed: %v\n", card["agent_id"], err) continue } resp.Body.Close() fmt.Printf("Registered %v: %d\n", card["agent_id"], resp.StatusCode) } } ``` ```java import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.List; import java.util.Map; public class Client { static final String KUBEMQ_URL = "http://localhost:9090"; static final ObjectMapper MAPPER = new ObjectMapper(); public static void main(String[] args) throws Exception { var client = HttpClient.newHttpClient(); var agents = List.of( Map.of("agent_id", "echo-agent-01", "name", "Echo Agent", "url", "http://localhost:18081/", "skills", List.of(Map.of("id", "echo", "name", "Echo", "tags", List.of("echo")))), Map.of("agent_id", "translate-agent-01", "name", "Translate Agent", "url", "http://localhost:18082/", "skills", List.of(Map.of("id", "translate", "name", "Translate", "tags", List.of("translate", "nlp")))), Map.of("agent_id", "summarize-agent-01", "name", "Summarize Agent", "url", "http://localhost:18083/", "skills", List.of(Map.of("id", "summarize", "name", "Summarize", "tags", List.of("summarize", "nlp")))) ); for (var card : agents) { var req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/agents/register")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(card))) .build(); var resp = client.send(req, HttpResponse.BodyHandlers.ofString()); System.out.println("Registered " + card.get("agent_id") + ": " + resp.statusCode()); } } } ``` ```python import asyncio import httpx KUBEMQ_URL = "http://localhost:9090" AGENTS = [ {"agent_id": "echo-agent-01", "name": "Echo Agent", "url": "http://localhost:18081/", "skills": [{"id": "echo", "name": "Echo", "tags": ["echo"]}]}, {"agent_id": "translate-agent-01", "name": "Translate Agent", "url": "http://localhost:18082/", "skills": [{"id": "translate", "name": "Translate", "tags": ["translate", "nlp"]}]}, {"agent_id": "summarize-agent-01", "name": "Summarize Agent", "url": "http://localhost:18083/", "skills": [{"id": "summarize", "name": "Summarize", "tags": ["summarize", "nlp"]}]}, ] async def main() -> None: async with httpx.AsyncClient() as client: for card in AGENTS: resp = await client.post(f"{KUBEMQ_URL}/agents/register", json=card) print(f"Registered {card['agent_id']}: {resp.status_code}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript const KUBEMQ_URL = "http://localhost:9090"; const AGENTS = [ { agent_id: "echo-agent-01", name: "Echo Agent", url: "http://localhost:18081/", skills: [{ id: "echo", name: "Echo", tags: ["echo"] }] }, { agent_id: "translate-agent-01", name: "Translate Agent", url: "http://localhost:18082/", skills: [{ id: "translate", name: "Translate", tags: ["translate", "nlp"] }] }, { agent_id: "summarize-agent-01", name: "Summarize Agent", url: "http://localhost:18083/", skills: [{ id: "summarize", name: "Summarize", tags: ["summarize", "nlp"] }] }, ]; async function main() { for (const card of AGENTS) { const resp = await fetch(`${KUBEMQ_URL}/agents/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(card), }); console.log(`Registered ${card.agent_id}: ${resp.status}`); } } main().catch(console.error); ``` ## Step 2 — Discover agents by skill [#step-2--discover-agents-by-skill] The router doesn't hardcode `agent_id`s — it asks the registry which agents have a needed skill. `GET /agents?skill_tags=...` returns only agents whose skills carry **all** of the requested tags (comma-separated). Filtering `nlp` returns both the translate and summarize agents; filtering `echo` returns just one. ```bash # Agents that can do NLP work curl "http://localhost:9090/agents?skill_tags=nlp" # Agents that can echo curl "http://localhost:9090/agents?skill_tags=echo" ``` ```csharp using System.Text.Json.Nodes; const string KubeMqUrl = "http://localhost:9090"; using var client = new HttpClient(); async Task> Discover(string tag) { var resp = await client.GetAsync($"{KubeMqUrl}/agents?skill_tags={tag}"); var root = JsonNode.Parse(await resp.Content.ReadAsStringAsync())!; var agents = root is JsonArray arr ? arr : root["agents"]!.AsArray(); return agents.Select(a => a!["agent_id"]!.GetValue()).ToList(); } Console.WriteLine($"nlp: [{string.Join(", ", await Discover("nlp"))}]"); Console.WriteLine($"echo: [{string.Join(", ", await Discover("echo"))}]"); ``` ```go package main import ( "encoding/json" "fmt" "io" "net/http" ) const kubemqURL = "http://localhost:9090" func discover(tag string) []string { resp, err := http.Get(kubemqURL + "/agents?skill_tags=" + tag) if err != nil { return nil } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var wrapper map[string]interface{} json.Unmarshal(body, &wrapper) agents, _ := wrapper["agents"].([]interface{}) ids := make([]string, 0, len(agents)) for _, a := range agents { ids = append(ids, a.(map[string]interface{})["agent_id"].(string)) } return ids } func main() { fmt.Printf("nlp: %v\n", discover("nlp")) fmt.Printf("echo: %v\n", discover("echo")) } ``` ```java import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.ArrayList; import java.util.List; public class Client { static final String KUBEMQ_URL = "http://localhost:9090"; static final ObjectMapper MAPPER = new ObjectMapper(); static final HttpClient CLIENT = HttpClient.newHttpClient(); static List discover(String tag) throws Exception { var req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/agents?skill_tags=" + tag)) .GET().build(); var resp = CLIENT.send(req, HttpResponse.BodyHandlers.ofString()); var root = MAPPER.readTree(resp.body()); var agents = root.isArray() ? root : root.get("agents"); var ids = new ArrayList(); for (var agent : agents) ids.add(agent.get("agent_id").asText()); return ids; } public static void main(String[] args) throws Exception { System.out.println("nlp: " + discover("nlp")); System.out.println("echo: " + discover("echo")); } } ``` ```python import asyncio import httpx KUBEMQ_URL = "http://localhost:9090" async def discover(client: httpx.AsyncClient, tag: str) -> list[str]: resp = await client.get(f"{KUBEMQ_URL}/agents", params={"skill_tags": tag}) data = resp.json() agents = data.get("agents", data) if isinstance(data, dict) else data return [a["agent_id"] for a in agents] async def main() -> None: async with httpx.AsyncClient() as client: print(f"nlp: {await discover(client, 'nlp')}") print(f"echo: {await discover(client, 'echo')}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript const KUBEMQ_URL = "http://localhost:9090"; async function discover(tag: string): Promise { const resp = await fetch(`${KUBEMQ_URL}/agents?skill_tags=${tag}`); const data = await resp.json(); const agents = Array.isArray(data) ? data : (data.agents || []); return agents.map((a: { agent_id: string }) => a.agent_id); } async function main() { console.log("nlp: ", await discover("nlp")); console.log("echo:", await discover("echo")); } main().catch(console.error); ``` ## Step 3 — Route a request by agent\_id [#step-3--route-a-request-by-agent_id] Once the router has picked an agent, it sends a normal [`message/send`](/aiway/a2a/sync-messaging) to `POST /a2a/{agent_id}`. The same client can fan a workload across agents by choosing a different `agent_id` per call — the gateway routes each request to the matching agent's virtual subscriber. ```bash # Route to the translate agent curl -X POST http://localhost:9090/a2a/translate-agent-01 \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": {"message": {"parts": [{"text": "Translate: hello"}]}} }' # Route to the summarize agent curl -X POST http://localhost:9090/a2a/summarize-agent-01 \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "message/send", "params": {"message": {"parts": [{"text": "Summarize this report..."}]}} }' ``` ```csharp using System.Text; using System.Text.Json.Nodes; const string KubeMqUrl = "http://localhost:9090"; using var client = new HttpClient(); async Task RouteTo(string agentId, string text) { var payload = new JsonObject { ["jsonrpc"] = "2.0", ["id"] = 1, ["method"] = "message/send", ["params"] = new JsonObject { ["message"] = new JsonObject { ["parts"] = new JsonArray(new JsonObject { ["text"] = text }) } } }; var resp = await client.PostAsync( $"{KubeMqUrl}/a2a/{agentId}", new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json")); Console.WriteLine($"{agentId} -> {(int)resp.StatusCode}"); } await RouteTo("translate-agent-01", "Translate: hello"); await RouteTo("summarize-agent-01", "Summarize this report..."); ``` ```go package main import ( "bytes" "encoding/json" "fmt" "net/http" ) const kubemqURL = "http://localhost:9090" func routeTo(agentID, text string) { payload := map[string]interface{}{ "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": map[string]interface{}{ "message": map[string]interface{}{ "parts": []map[string]interface{}{{"text": text}}, }, }, } data, _ := json.Marshal(payload) resp, err := http.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(data)) if err != nil { fmt.Printf("%s -> error: %v\n", agentID, err) return } defer resp.Body.Close() fmt.Printf("%s -> %d\n", agentID, resp.StatusCode) } func main() { routeTo("translate-agent-01", "Translate: hello") routeTo("summarize-agent-01", "Summarize this report...") } ``` ```java import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.List; import java.util.Map; public class Client { static final String KUBEMQ_URL = "http://localhost:9090"; static final ObjectMapper MAPPER = new ObjectMapper(); static final HttpClient CLIENT = HttpClient.newHttpClient(); static void routeTo(String agentId, String text) throws Exception { var payload = Map.of( "jsonrpc", "2.0", "id", 1, "method", "message/send", "params", Map.of("message", Map.of("parts", List.of(Map.of("text", text)))) ); var req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/a2a/" + agentId)) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload))) .build(); var resp = CLIENT.send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(agentId + " -> " + resp.statusCode()); } public static void main(String[] args) throws Exception { routeTo("translate-agent-01", "Translate: hello"); routeTo("summarize-agent-01", "Summarize this report..."); } } ``` ```python import asyncio import httpx KUBEMQ_URL = "http://localhost:9090" async def route_to(client: httpx.AsyncClient, agent_id: str, text: str) -> None: payload = { "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": {"message": {"parts": [{"text": text}]}}, } resp = await client.post(f"{KUBEMQ_URL}/a2a/{agent_id}", json=payload) print(f"{agent_id} -> {resp.status_code}") async def main() -> None: async with httpx.AsyncClient() as client: await route_to(client, "translate-agent-01", "Translate: hello") await route_to(client, "summarize-agent-01", "Summarize this report...") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript const KUBEMQ_URL = "http://localhost:9090"; async function routeTo(agentId: string, text: string) { const request = { jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { parts: [{ text }] } }, }; const resp = await fetch(`${KUBEMQ_URL}/a2a/${agentId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), }); console.log(`${agentId} -> ${resp.status}`); } async function main() { await routeTo("translate-agent-01", "Translate: hello"); await routeTo("summarize-agent-01", "Summarize this report..."); } main().catch(console.error); ``` ## How routing works [#how-routing-works] The gateway is a thin router, not a load balancer — `agent_id` selects exactly one agent. * **Addressing.** The `{agent_id}` path segment names the target. The gateway validates it against the registry, then issues a Query on `_AGENTS_.agents/`, which only that agent's virtual subscriber consumes. * **Discovery vs. routing.** Skill tags are a **discovery** convenience for picking an `agent_id`; they never auto-route. The caller (or its own routing logic) decides which `agent_id` to send to. * **Isolation.** Each agent has its own [concurrency cap](/aiway/a2a/guides/concurrency) (`AgentMaxConcurrency`, default 100) and its own liveness/TTL — one busy or expired agent does not affect the others. * **Scaling out.** Want two interchangeable translators? Register them under different `agent_id`s with the same `translate` tag, then let your router pick between the matches returned by `GET /agents?skill_tags=translate`. Skill-tag filtering matches agents that carry **all** requested tags, and the filter runs in memory after the registry fetch. To group agents for discovery, give them a shared tag (like `nlp` above) in addition to their specific skill. ## Related [#related] # Streaming Task Pipeline (/aiway/a2a/scenarios/streaming-task-pipeline) This scenario streams a **long-running agent task** end to end. A single client opens a `message/stream` request, the gateway relays the agent's progress as Server-Sent Events, and the client consumes a sequence of **`task.*` envelopes** — interim `task.status` updates, one or more `task.artifact` results, and a terminal `task.done`. It builds on [streaming (SSE)](/aiway/a2a/streaming) and the per-agent [virtual subscriber](/aiway/a2a/architecture). ## The setup [#the-setup] The agent is a plain HTTP server registered by URL — no KubeMQ SDK runs on it. When the client `POST`s a `message/stream` request to `/a2a/{agent_id}`, the gateway opens an SSE relay through the agent's virtual subscriber: it sends a Query carrying the stream channel, the agent streams SSE events back, and the gateway relays each one to the caller as a `task.*` event. The client reads the stream until it sees the terminal `task.done` (or `task.error`), and **cancelling is just disconnecting** — when the caller's connection drops, the gateway sends a `stream_cancel` query to the agent and tears the relay down. *The gateway relays the agent's SSE task events back to the caller and cancels the agent when the caller disconnects.* ## The task envelopes [#the-task-envelopes] Each SSE frame names an event type and carries a JSON envelope. The agent emits four envelope kinds; the gateway maps them to these SSE event names: | SSE event | Envelope `type` | Meaning | | --------------- | --------------- | ---------------------------------------------------- | | `task.status` | `status_update` | Interim progress (`status`, `progress`, `total`) | | `task.artifact` | `artifact` | A produced result (`name`, `data`) | | `task.done` | `done` | Terminal success (`final_result`) — stream closes | | `task.error` | `error` | Terminal failure (`code`, `message`) — stream closes | The wire frame for a status update looks like this: ```text event: task.status data: {"stream_id":"","type":"status_update","payload":{"status":"working","progress":1,"total":3}} ``` A comment keepalive (`: keepalive`) arrives every 30 seconds on an otherwise idle stream, and the stream closes on the first `task.done` or `task.error`. ## Step 1 — Open the stream [#step-1--open-the-stream] Send a `message/stream` JSON-RPC request to the agent. The gateway responds with `Content-Type: text/event-stream` and begins relaying the agent's `task.*` events. Pass `Accept: text/event-stream` and read the response body line by line. ```bash # -N disables curl buffering so events print as they arrive. curl -N -X POST http://localhost:9090/a2a/task-events-agent-01 \ -H 'Content-Type: application/json' \ -H 'Accept: text/event-stream' \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "message/stream", "params": {"message": {"parts": [{"text": "Show me all event types"}]}} }' ``` ```csharp using System.Text; using System.Text.Json.Nodes; const string KubeMqUrl = "http://localhost:9090"; const string AgentId = "task-events-agent-01"; var payload = new JsonObject { ["jsonrpc"] = "2.0", ["id"] = 1, ["method"] = "message/stream", ["params"] = new JsonObject { ["message"] = new JsonObject { ["parts"] = new JsonArray(new JsonObject { ["text"] = "Show me all event types" }) } } }; using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(60) }; var request = new HttpRequestMessage(HttpMethod.Post, $"{KubeMqUrl}/a2a/{AgentId}") { Content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json") }; Console.WriteLine("Connecting to SSE stream..."); using var resp = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); using var stream = await resp.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream); // ...continue reading events in Step 2 ``` ```go package main import ( "bytes" "encoding/json" "fmt" "net/http" "os" ) const ( kubemqURL = "http://localhost:9090" agentID = "task-events-agent-01" ) func main() { payload := map[string]interface{}{ "jsonrpc": "2.0", "id": 1, "method": "message/stream", "params": map[string]interface{}{ "message": map[string]interface{}{ "parts": []map[string]interface{}{{"text": "Show me all event types"}}, }, }, } data, _ := json.Marshal(payload) resp, err := http.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(data)) if err != nil { fmt.Fprintf(os.Stderr, "Request failed: %v\n", err) os.Exit(1) } defer resp.Body.Close() fmt.Println("Connecting to SSE stream...") // ...continue reading events in Step 2 } ``` ```java import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.util.List; import java.util.Map; public class Client { static final String KUBEMQ_URL = "http://localhost:9090"; static final String AGENT_ID = "task-events-agent-01"; static final ObjectMapper MAPPER = new ObjectMapper(); public static void main(String[] args) throws Exception { var payload = Map.of( "jsonrpc", "2.0", "id", 1, "method", "message/stream", "params", Map.of( "message", Map.of("parts", List.of(Map.of("text", "Show me all event types"))) ) ); var client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(60)).build(); var req = HttpRequest.newBuilder() .uri(URI.create(KUBEMQ_URL + "/a2a/" + AGENT_ID)) .header("Content-Type", "application/json") .header("Accept", "text/event-stream") .timeout(Duration.ofSeconds(60)) .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload))) .build(); System.out.println("Connecting to SSE stream..."); var resp = client.send(req, HttpResponse.BodyHandlers.ofLines()); // ...continue reading events in Step 2 } } ``` ```python """Stream a long-running task and consume its task.* envelopes.""" import asyncio import json from collections import Counter import httpx from httpx_sse import aconnect_sse KUBEMQ_URL = "http://localhost:9090" AGENT_ID = "task-events-agent-01" async def main() -> None: payload = { "jsonrpc": "2.0", "id": 1, "method": "message/stream", "params": {"message": {"parts": [{"text": "Show me all event types"}]}}, } async with httpx.AsyncClient(timeout=60) as client: print("Connecting to SSE stream...") async with aconnect_sse( client, "POST", f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload ) as event_source: ... # consume events in Step 2 if __name__ == "__main__": asyncio.run(main()) ``` ```typescript const KUBEMQ_URL = "http://localhost:9090"; const AGENT_ID = "task-events-agent-01"; async function main() { const request = { jsonrpc: "2.0", id: 1, method: "message/stream", params: { message: { parts: [{ text: "Send me task events" }] } }, }; const resp = await fetch(`${KUBEMQ_URL}/a2a/${AGENT_ID}`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "text/event-stream", }, body: JSON.stringify(request), }); const reader = resp.body!.getReader(); // ...continue reading events in Step 2 } main().catch(console.error); ``` ## Step 2 — Consume the task.\* envelopes [#step-2--consume-the-task-envelopes] Read the stream frame by frame, dispatch on the SSE event name, and pull the result out of `payload.payload`. Keep a count of each event type for a summary, and **break on the terminal envelope** (`task.done` or `task.error`) — that frame closes the stream. ```text event: task.status data: {"stream_id":"...","type":"status_update","payload":{"status":"working","progress":1,"total":3}} event: task.status data: {"stream_id":"...","type":"status_update","payload":{"status":"working","progress":2,"total":3}} event: task.artifact data: {"stream_id":"...","type":"artifact","payload":{"name":"result.json","data":{"key":"value"}}} event: task.done data: {"stream_id":"...","type":"done","payload":{"final_result":"completed"}} ``` ```csharp var eventTypes = new Dictionary(); string? eventType = null; while (!reader.EndOfStream) { var line = await reader.ReadLineAsync(); if (line == null) break; if (line.StartsWith("event: ")) eventType = line[7..]; else if (line.StartsWith("data: ") && eventType != null) { eventTypes[eventType] = eventTypes.GetValueOrDefault(eventType) + 1; var data = JsonNode.Parse(line[6..])!; if (eventType == "task.status") { var p = data["payload"]!["payload"]!; Console.WriteLine($" [STATUS] progress={p["progress"]}/{p["total"]}"); } else if (eventType == "task.artifact") Console.WriteLine($" [ARTIFACT] name={data["payload"]!["payload"]!["name"]}"); else if (eventType == "task.done") Console.WriteLine($" [DONE] result={data["payload"]!["payload"]!["final_result"]}"); else if (eventType == "task.error") Console.WriteLine($" [ERROR] {data["payload"]!["payload"]}"); if (eventType is "task.done" or "task.error") break; } else if (line.Length == 0) eventType = null; } Console.WriteLine($"\n--- Event Summary ---"); foreach (var (et, count) in eventTypes.OrderBy(kv => kv.Key)) Console.WriteLine($" {et}: {count}"); Console.WriteLine($" Total: {eventTypes.Values.Sum()}"); ``` ```go scanner := bufio.NewScanner(resp.Body) eventType := "" counts := map[string]int{} for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, "event: ") { eventType = strings.TrimPrefix(line, "event: ") } else if strings.HasPrefix(line, "data: ") { counts[eventType]++ dataStr := strings.TrimPrefix(line, "data: ") var d map[string]interface{} json.Unmarshal([]byte(dataStr), &d) payload, _ := d["payload"].(map[string]interface{}) inner, _ := payload["payload"].(map[string]interface{}) if inner == nil { inner = payload } switch eventType { case "task.status": fmt.Printf(" [STATUS] progress=%.0f/%.0f\n", inner["progress"], inner["total"]) case "task.artifact": fmt.Printf(" [ARTIFACT] name=%v\n", inner["name"]) case "task.done": fmt.Printf(" [DONE] result=%v\n", inner["final_result"]) case "task.error": fmt.Printf(" [ERROR] %v\n", inner) } if eventType == "task.done" || eventType == "task.error" { break } } } ``` ```java Map eventTypes = new LinkedHashMap<>(); String currentEvent = null; for (var it = resp.body().iterator(); it.hasNext(); ) { String line = it.next(); if (line.startsWith("event: ")) { currentEvent = line.substring(7).trim(); } else if (line.startsWith("data: ") && currentEvent != null) { var data = MAPPER.readTree(line.substring(6)); eventTypes.merge(currentEvent, 1, Integer::sum); var inner = data.path("payload").path("payload"); switch (currentEvent) { case "task.status" -> System.out.println(" [STATUS] progress=" + inner.path("progress") + "/" + inner.path("total")); case "task.artifact" -> System.out.println(" [ARTIFACT] name=" + inner.path("name").asText()); case "task.done" -> System.out.println(" [DONE] result=" + inner.path("final_result").asText()); case "task.error" -> System.out.println(" [ERROR] " + inner); } if ("task.done".equals(currentEvent) || "task.error".equals(currentEvent)) break; } } ``` ```python event_types: Counter[str] = Counter() async for event in event_source.aiter_sse(): data = json.loads(event.data) event_types[event.event] += 1 if event.event == "task.status": inner = data["payload"]["payload"] print(f" [STATUS] progress={inner['progress']}/{inner['total']}") elif event.event == "task.artifact": inner = data["payload"]["payload"] print(f" [ARTIFACT] name={inner['name']}") elif event.event == "task.done": inner = data["payload"]["payload"] print(f" [DONE] result={inner['final_result']}") elif event.event == "task.error": print(f" [ERROR] {data['payload']['payload']}") if event.event in ("task.done", "task.error"): break print(f"\n--- Event Summary ---") for event_type, count in sorted(event_types.items()): print(f" {event_type}: {count}") print(f" Total: {sum(event_types.values())}") ``` ```typescript function parseSSE(chunk: string): Array<{ event: string; data: string }> { const events: Array<{ event: string; data: string }> = []; let currentEvent = ""; let currentData = ""; for (const line of chunk.split("\n")) { if (line.startsWith("event: ")) { currentEvent = line.slice(7).trim(); } else if (line.startsWith("data: ")) { currentData = line.slice(6).trim(); } else if (line === "" && currentEvent) { events.push({ event: currentEvent, data: currentData }); currentEvent = ""; currentData = ""; } } return events; } const decoder = new TextDecoder(); const counts: Record = {}; while (true) { const { done, value } = await reader.read(); if (done) break; const events = parseSSE(decoder.decode(value, { stream: true })); for (const evt of events) { counts[evt.event] = (counts[evt.event] || 0) + 1; const payload = JSON.parse(evt.data); const inner = payload.payload?.payload ?? payload.payload; switch (evt.event) { case "task.status": console.log(`[STATUS] progress=${inner.progress}/${inner.total} status=${inner.status}`); break; case "task.artifact": console.log(`[ARTIFACT] name=${inner.name} data=${JSON.stringify(inner.data)}`); break; case "task.done": console.log(`[DONE] result=${inner.final_result}`); break; case "task.error": console.log(`[ERROR] ${inner.message}`); break; } if (evt.event === "task.done" || evt.event === "task.error") { console.log("\n=== Event Summary ==="); for (const [type, count] of Object.entries(counts)) { console.log(` ${type}: ${count}`); } reader.cancel(); return; } } } ``` A complete run prints the interim status, the artifact, the terminal result, and a summary: ```text Connecting to SSE stream... [STATUS] progress=1/3 [STATUS] progress=2/3 [ARTIFACT] name=result.json [DONE] result=completed --- Event Summary --- task.artifact: 1 task.done: 1 task.status: 2 Total: 4 ``` ## Step 3 — Cancel by disconnecting [#step-3--cancel-by-disconnecting] There is no explicit "cancel" RPC for the caller — **cancelling a streaming task is just closing the connection**. When the client stops reading and disconnects before the terminal envelope, the gateway detects the dropped connection and sends a `stream_cancel` query to the agent's virtual subscriber (on `_AGENTS_.agents/{agent_id}`, with the `stream_id` and a 10-second timeout). The virtual subscriber closes its HTTP SSE connection to the agent and tears down the relay, so the agent stops doing work. ```bash # Read only the first events, then Ctrl-C (or pipe through head) to disconnect. # The gateway sends stream_cancel to the agent on disconnect. curl -N -X POST http://localhost:9090/a2a/slow-stream-agent-01 \ -H 'Content-Type: application/json' \ -H 'Accept: text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"message/stream", "params":{"message":{"parts":[{"text":"I will disconnect early"}]}}}' \ | head -n 6 ``` ```go // Break out of the read loop early and close the body — the gateway // detects the disconnect and cancels the agent's stream. resp, _ := http.Post(kubemqURL+"/a2a/"+agentID, "application/json", bytes.NewReader(data)) count := 0 eventType := "" scanner := bufio.NewScanner(resp.Body) for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, "event: ") { eventType = strings.TrimPrefix(line, "event: ") } else if strings.HasPrefix(line, "data: ") { count++ var d map[string]interface{} json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &d) p, _ := d["payload"].(map[string]interface{}) fmt.Printf(" Event %d: [%s] progress=%.0f\n", count, eventType, p["progress"]) if count >= maxEvents { fmt.Printf("\nDisconnecting after %d events...\n", maxEvents) break } } } resp.Body.Close() // closing the connection triggers stream_cancel fmt.Println("KubeMQ will detect the disconnect and clean up the stream.") ``` ```python """Disconnect from the SSE stream after a couple of events to cancel the task.""" MAX_EVENTS = 2 async with httpx.AsyncClient(timeout=30) as client: async with aconnect_sse( client, "POST", f"{KUBEMQ_URL}/a2a/{AGENT_ID}", json=payload ) as event_source: count = 0 async for event in event_source.aiter_sse(): count += 1 data = json.loads(event.data) print(f" Event {count}: [{event.event}] progress={data['payload'].get('progress')}") if count >= MAX_EVENTS: print(f"\nDisconnecting after {MAX_EVENTS} events...") break # leaving the context closes the connection -> stream_cancel print("Client disconnected.") print("KubeMQ will detect the disconnect and clean up the stream.") ``` The gateway also closes the stream on its own when the idle timer fires (`MaxSSEIdleSeconds`, default 300s): it emits a `task.error` with code `-32001` and message `"stream idle timeout"`, then best-effort cancels the agent. See [SSE behavior](/aiway/a2a/guides/sse-behavior) for the full wire-level rules. ## Related [#related] # Endpoints (/aiway/mcp/reference/endpoints) The MCP connector exposes a single HTTP path, `/mcp`, on the shared HTTP server (port 9090). All tool discovery and invocation flow through it as JSON-RPC 2.0 over the Streamable HTTP transport. The connector is **enabled by default** — start kubemq-server and `/mcp` is live; no `=true` flag is needed. To disable it, set `CONNECTORSMCP_ENABLE=false` (see [Shared HTTP server](/connectors/concepts/shared-http-server#enable-model-on-by-default) for the enable model and the irregular var name). ## HTTP endpoints [#http-endpoints] | Method | Path | Description | | ------ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `POST` | `/mcp` | JSON-RPC 2.0 request handler — a single request or a JSON array (batch). Carries `initialize`, `ping`, `tools/list`, `tools/call`, and notifications. | | `GET` | `/mcp` | SSE keepalive stream. Holds an event-stream connection open; clients that prefer a long-lived channel use it alongside the stateless `POST`. | Both routes pass through origin validation; `POST /mcp` additionally runs the per-route timeout middleware. ## JSON-RPC methods [#json-rpc-methods] `POST /mcp` accepts the following methods: | Method | Type | Description | | --------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | | `initialize` | Request | Start a session and negotiate capabilities. Returns `protocolVersion`, `capabilities`, `serverInfo`, and `_meta.sessionId`. | | `notifications/initialized` | Notification | Client confirms initialization is complete. No `id` field; the server replies `200 OK` with body `{"jsonrpc":"2.0","result":null,"id":null}`. | | `ping` | Request | Health check. Returns an empty result `{}`. | | `tools/list` | Request | Discover the available tools with their `inputSchema` (11 core tools, plus 4 agent-bridge tools when the agent registry is available). | | `tools/call` | Request | Invoke a tool by name with its `arguments`. | The full tool catalog and per-tool arguments live in [Tools reference](/aiway/mcp/reference/tools-reference). For the JSON-RPC and tool-level error model, see [Error codes](/aiway/mcp/reference/error-codes). ## Headers [#headers] | Header | Direction | Description | | ---------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `Content-Type` | Request | Must be `application/json`. | | `MCP-Session-Id` | Both | Session identifier. The server sets it in the `initialize` response (`_meta.sessionId`); the client echoes it on every subsequent request. | | `MCP-Protocol-Version` | Response | Negotiated protocol version — always `2025-11-25`. | | `Authorization` | Request | `Bearer ` when JWT auth is enabled. See [Authentication](/aiway/mcp/guides/authentication). | ## Request shape [#request-shape] A JSON-RPC 2.0 request carries an `id` so the client can correlate the response: ```json { "jsonrpc": "2.0", "id": 1, "method": "", "params": { } } ``` * `jsonrpc` — must be `"2.0"`. * `id` — request identifier; the response echoes this value. * `method` — one of the JSON-RPC methods above. * `params` — method-specific parameters (object). Optional when the method takes no arguments, such as `ping`. ## Notification shape [#notification-shape] Notifications omit the `id` field. The server processes them but returns no JSON-RPC result body: ```json { "jsonrpc": "2.0", "method": "notifications/initialized" } ``` In a batch (`POST` with a JSON array), notification entries are executed but produce no response entry. ## initialize response [#initialize-response] ```json { "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2025-11-25", "capabilities": { "tools": { "listChanged": false }, "resources": null, "prompts": null }, "serverInfo": { "name": "kubemq", "version": "" }, "_meta": { "sessionId": "" } } } ``` Save `result._meta.sessionId` and send it back as the `MCP-Session-Id` header on every later request. See [Session management](/aiway/mcp/guides/session-management) for the full lifecycle. ## HTTP status codes [#http-status-codes] | Status | Meaning | | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `200` | Success — covers successful results, notifications (body `{"jsonrpc":"2.0","result":null,"id":null}`), and JSON-RPC error responses. A protocol error — including an auth failure (code `-32010`) — still returns `200` with an `error` object in the body, never `401`. See [Error codes](/aiway/mcp/reference/error-codes). | ## Endpoint signatures [#endpoint-signatures] The following `curl` calls cover every method on the endpoint. ```bash # initialize — start a session, capture _meta.sessionId curl -X POST http://localhost:9090/mcp \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": { "name": "curl", "version": "1.0.0" } } }' # notifications/initialized — confirm; server replies 200 with {"jsonrpc":"2.0","result":null,"id":null} curl -X POST http://localhost:9090/mcp \ -H "Content-Type: application/json" \ -H "MCP-Session-Id: " \ -d '{ "jsonrpc": "2.0", "method": "notifications/initialized" }' # ping — health check, returns an empty result {} curl -X POST http://localhost:9090/mcp \ -H "Content-Type: application/json" \ -H "MCP-Session-Id: " \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "ping" }' # tools/list — discover tools and their inputSchema curl -X POST http://localhost:9090/mcp \ -H "Content-Type: application/json" \ -H "MCP-Session-Id: " \ -d '{ "jsonrpc": "2.0", "id": 3, "method": "tools/list" }' # tools/call — invoke a tool by name curl -X POST http://localhost:9090/mcp \ -H "Content-Type: application/json" \ -H "MCP-Session-Id: " \ -d '{ "jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": { "name": "queue_send", "arguments": { "channel": "my-queue", "body": "Hello from MCP" } } }' # GET /mcp — open the SSE keepalive stream curl -N http://localhost:9090/mcp \ -H "Accept: text/event-stream" \ -H "MCP-Session-Id: " ``` ## Related [#related] # Error Codes (/aiway/mcp/reference/error-codes) The MCP connector surfaces failures across three layers: **JSON-RPC protocol errors** (in the response `error` field), **tool-execution errors** (in a normal `result` with `isError: true`), and **HTTP status codes** from the shared HTTP server. This page is the authoritative catalog of every code. For task-focused guidance on detecting and recovering from these layers in client code, see the [Error Handling guide](/aiway/mcp/guides/error-handling). ## The three error layers [#the-three-error-layers] A single failed `tools/call` can fail at exactly one of three levels. Check them in order — protocol first, then tool result, then transport — because a higher-layer failure short-circuits the lower layers. | Layer | Where it appears | Example cause | | ----------------------- | --------------------------- | ------------------------------------------------------------ | | JSON-RPC protocol error | `error` field (no `result`) | Malformed JSON, unknown method, missing params, auth failure | | Tool-execution error | `result.isError: true` | Reserved channel, agent not found, command timeout | | HTTP status | HTTP response status line | Broker not ready (503), gateway timeout (504) | ## JSON-RPC protocol error codes [#json-rpc-protocol-error-codes] Protocol-level errors mean the request itself was malformed or could not be dispatched. They are returned in the `error` field — there is **no** `result` field. The HTTP status is still `200` (per JSON-RPC convention); the failure is encoded in the body. | Code | Constant | Name | Trigger | | -------- | ----------------------- | ---------------------- | ---------------------------------------------------------------------------------------------- | | `-32700` | `JSONRPCParseError` | Parse Error | Malformed JSON body, or wrong `Content-Type` (e.g. `text/plain` instead of `application/json`) | | `-32600` | `JSONRPCInvalidRequest` | Invalid Request | Empty `method` field, or `jsonrpc` version is not `"2.0"` | | `-32601` | `JSONRPCMethodNotFound` | Method Not Found | Unknown method name (e.g. `tools/unknown`) | | `-32602` | `JSONRPCInvalidParams` | Invalid Params | `params` is not an object, or a required tool argument is missing | | `-32603` | `JSONRPCInternalError` | Internal Error | Unexpected server-side failure while dispatching the request | | `-32010` | `jsonrpcAuthError` | Authentication Failure | Bearer token missing or invalid on the JSON-RPC endpoint (`/mcp`) | `-32700` through `-32603` are the standard JSON-RPC 2.0 reserved codes. `-32010` is a KubeMQ server-defined code shared by the JSON-RPC endpoints (`/mcp`, `/a2a`). ### Error response format [#error-response-format] ```json { "jsonrpc": "2.0", "id": 1, "error": { "code": -32601, "message": "Method not found" } } ``` A parse error that occurs before the request `id` can be read returns `"id": null`: ```json { "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": "Parse error" } } ``` ### Authentication failures (-32010) [#authentication-failures--32010] When JWT auth is enabled and a request to `/mcp` carries a missing or invalid `Authorization: Bearer` token, the connector returns the JSON-RPC auth code `-32010` with HTTP status `200` — **not** HTTP `401`: ```json { "jsonrpc": "2.0", "id": 1, "error": { "code": -32010, "message": "authentication required" } } ``` The `-32010` code applies only to JSON-RPC endpoints (`/mcp`, `/a2a`). Standard HTTP/REST endpoints return HTTP `401` for the same auth failure. See [Auth & security](/connectors/reference/auth-and-security) for the shared auth model. ## Tool-execution errors (`isError`) [#tool-execution-errors-iserror] Tool-level errors are **not** JSON-RPC errors. The request was valid JSON-RPC and was dispatched successfully, but the underlying messaging operation failed. The response carries a normal `result` field with `isError: true`; the human-readable cause is in `result.content[].text`. ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "Agent 'nonexistent' not found" }], "isError": true } } ``` This is why robust clients **check for `error` first, then `result.isError`** — a successful protocol response can still describe a failed tool operation. ### Common tool-execution errors [#common-tool-execution-errors] | Error | Affected tools | Example message | | -------------------------- | ------------------------------------------------------------------------------------ | --------------------------------------------------------------- | | Reserved channel rejection | `queue_send`, `events_publish`, `events_store_publish`, `command_send`, `query_send` | `"Channel '_AGENTS_reserved' uses a reserved prefix"` | | Non-existent agent | `agent_info`, `agent_send`, `agent_query` | `"Agent 'nonexistent' not found"` | | Timeout exceeded | `command_send`, `query_send`, `agent_send`, `agent_query` | `"Command timed out: no subscriber on channel 'ch' within 10s"` | | No subscriber available | `command_send`, `query_send` | `"Command timed out: no subscriber on channel 'ch' within 10s"` | | Non-existent channel | `channel_info` | `"Channel 'nonexistent' of type 'queues' not found"` | Reserved-channel rejection comes from the shared `_AGENTS_.` prefix guard — see [Channel resolution](/aiway/mcp/guides/channel-resolution). ## HTTP status codes [#http-status-codes] For a well-formed request, the connector returns HTTP `200` even when the body contains a JSON-RPC error. Other statuses originate from the shared HTTP server layer (middleware chain) before or around tool dispatch. | Status | Meaning | When | | ------ | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `200` | OK | All JSON-RPC responses, including error responses and notifications (e.g. `notifications/initialized`, which returns body `{"jsonrpc":"2.0","result":null,"id":null}`) | | `503` | Service Unavailable | The broker is not ready; the traffic gate rejects the request | | `504` | Gateway Timeout | The request deadline elapsed before a response was committed | HTTP `200` is returned even for JSON-RPC error responses. The error is encoded in the JSON body, not the HTTP status line — always parse the body before treating a `200` as success. ## Resolution order [#resolution-order] When handling a response, evaluate the layers top-down: 1. **Inspect the HTTP status.** A `503` means the broker is not ready (retry with backoff); a `504` means the request timed out. 2. **Check for `error`.** A present `error` field is a JSON-RPC protocol failure — fix the request (or token for `-32010`). Do not look for `result`. 3. **Check `result.isError`.** If `true`, the tool ran but the operation failed; read the cause from `result.content[0].text`. 4. **Otherwise the call succeeded** — the payload is in `result.content`. ## Related [#related] # Tools Reference (/aiway/mcp/reference/tools-reference) The authoritative catalog of every tool the MCP connector exposes through `tools/call`: 11 core messaging tools plus 4 agent-bridge tools. For each tool you get its arguments, defaults, JSON Schema, a ready-to-run curl request, and the response shape. ## Overview [#overview] The MCP connector advertises **15 tools** in its `tools/list` response and runs them through the single `POST /mcp` endpoint on the [shared HTTP server](/connectors/concepts/shared-http-server) (port `9090`, protocol version `2025-11-25`). The 11 core tools are always registered; the 4 agent-bridge tools appear only when the [A2A agent registry](/aiway/a2a) is present. This page is the canonical argument and schema reference. The per-tool pages under [Tools](/aiway/mcp/tools) carry the same operations with examples in all nine languages. For the wire-level request/response forms see [Endpoints](/aiway/mcp/reference/endpoints); for the failure codes see [Error codes](/aiway/mcp/reference/error-codes). ## Tool summary [#tool-summary] | # | Tool | Category | Required args | Optional args | | -- | -------------------------- | --------------- | ------------------------- | --------------------------------------------------------------------------------------------------- | | 1 | `queue_send` | Queue | `channel`, `body` | `metadata`, `tags`, `delay_seconds`, `expiration_seconds`, `max_receive_count`, `dead_letter_queue` | | 2 | `queue_receive` | Queue | `channel`, `max_messages` | `wait_timeout_seconds` | | 3 | `queue_peek` | Queue | `channel`, `max_messages` | *(none)* | | 4 | `events_publish` | Events | `channel`, `body` | `metadata`, `tags` | | 5 | `events_store_publish` | Events | `channel`, `body` | `metadata`, `tags` | | 6 | `events_store_read` | Events | `channel`, `max_messages` | `from_sequence`, `from_time` | | 7 | `events_store_read_latest` | Events | `channel` | `count` | | 8 | `command_send` | Command / Query | `channel`, `body` | `timeout_seconds`, `metadata`, `tags` | | 9 | `query_send` | Command / Query | `channel`, `body` | `timeout_seconds`, `metadata`, `tags` | | 10 | `channel_list` | Channel | *(none)* | `type`, `pattern` | | 11 | `channel_info` | Channel | `channel`, `type` | *(none)* | | 12 | `agent_list` | Agent bridge | *(none)* | `skill_tags` | | 13 | `agent_info` | Agent bridge | `agent_id` | *(none)* | | 14 | `agent_send` | Agent bridge | `agent_id`, `message` | `blocking`, `context_id`, `timeout_seconds` | | 15 | `agent_query` | Agent bridge | `agent_id`, `method` | `params` | ## Calling convention [#calling-convention] Every tool is invoked the same way: a `tools/call` JSON-RPC request naming the tool and passing its `arguments` object. Only `name` and `arguments` change between tools. ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "", "arguments": { } } }' ``` Every successful `tools/call` returns a result with a `content` array of typed blocks — KubeMQ uses `text` blocks carrying the operation result as a string (often a JSON string). A tool-level failure sets `isError: true` in the same envelope; the JSON-RPC response itself still succeeds with HTTP `200`. ```json { "jsonrpc": "2.0", "id": 1, "result": { "content": [{ "type": "text", "text": "..." }], "isError": false } } ``` A `channel` that starts with the reserved prefix `_AGENTS_.` is rejected — see [Channel resolution](/aiway/mcp/guides/channel-resolution). Missing required arguments return a `-32602` Invalid Params JSON-RPC error, not an `isError` result. ## Queue tools [#queue-tools] Durable, point-to-point queue messaging. See [Queue tools](/aiway/mcp/tools/queues) for language examples. ### queue\_send [#queue_send] Send a message to a queue channel. | Argument | Type | Required | Default | Description | | -------------------- | ------- | -------- | ------- | ------------------------------------------------------------------------- | | `channel` | string | Yes | — | Target queue channel. Must not start with the reserved prefix `_AGENTS_.` | | `body` | string | Yes | — | Message body content | | `metadata` | string | No | `""` | Optional message metadata string | | `tags` | object | No | `{}` | Key-value tags for message classification | | `delay_seconds` | integer | No | `0` | Delay before the message becomes visible. `0` = immediately available | | `expiration_seconds` | integer | No | `0` | TTL in seconds. `0` = no expiration | | `max_receive_count` | integer | No | `0` | Max receives before dead-letter. `0` = unlimited | | `dead_letter_queue` | string | No | `""` | Dead-letter queue channel name | ```json { "type": "object", "required": ["channel", "body"], "properties": { "channel": { "type": "string" }, "body": { "type": "string" }, "metadata": { "type": "string", "default": "" }, "tags": { "type": "object", "additionalProperties": { "type": "string" }, "default": {} }, "delay_seconds": { "type": "integer", "minimum": 0, "default": 0 }, "expiration_seconds": { "type": "integer", "minimum": 0, "default": 0 }, "max_receive_count": { "type": "integer", "minimum": 0, "default": 0 }, "dead_letter_queue": { "type": "string", "default": "" } } } ``` ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "queue_send", "arguments": { "channel": "example-queue", "body": "Hello from MCP", "metadata": "example-metadata", "tags": { "env": "dev", "source": "mcp-example" } } } }' ``` **Response** — a confirmation message in a single text block: ```json { "content": [{ "type": "text", "text": "Message sent successfully to queue 'example-queue'" }], "isError": false } ``` **Errors:** reserved channel → `isError: true`; missing `channel`/`body` → `-32602`. ### queue\_receive [#queue_receive] Receive and consume messages from a queue channel. This is a destructive read — returned messages are removed from the queue. | Argument | Type | Required | Default | Description | | ---------------------- | ------- | -------- | ------- | ------------------------------------------------ | | `channel` | string | Yes | — | Source queue channel | | `max_messages` | integer | Yes | `1` | Max messages to receive in a single call (1–100) | | `wait_timeout_seconds` | integer | No | `5` | Long-poll wait time in seconds (1–60) | ```json { "type": "object", "required": ["channel", "max_messages"], "properties": { "channel": { "type": "string" }, "max_messages": { "type": "integer", "minimum": 1, "maximum": 100, "default": 1 }, "wait_timeout_seconds": { "type": "integer", "minimum": 1, "maximum": 60, "default": 5 } } } ``` ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "queue_receive", "arguments": { "channel": "example-queue", "max_messages": 5 } } }' ``` **Response** — a JSON array of received messages, serialized as the text payload: ```json { "content": [{ "type": "text", "text": "[{\"body\":\"Hello from MCP\",\"metadata\":\"example-metadata\",\"tags\":{\"env\":\"dev\",\"source\":\"mcp-example\"}}]" }], "isError": false } ``` **Errors:** reserved channel → `isError: true`; a non-existent channel returns an empty result (no error). ### queue\_peek [#queue_peek] Peek at messages without consuming them — a non-destructive read; messages stay in the queue. | Argument | Type | Required | Default | Description | | -------------- | ------- | -------- | ------- | ---------------------------------------------- | | `channel` | string | Yes | — | Source queue channel | | `max_messages` | integer | Yes | `1` | Max messages to peek without consuming (1–100) | ```json { "type": "object", "required": ["channel", "max_messages"], "properties": { "channel": { "type": "string" }, "max_messages": { "type": "integer", "minimum": 1, "maximum": 100, "default": 1 } } } ``` ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{ "jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": { "name": "queue_peek", "arguments": { "channel": "example-queue", "max_messages": 5 } } }' ``` **Response** — same shape as `queue_receive`, but the messages remain in the queue: ```json { "content": [{ "type": "text", "text": "[{\"body\":\"Hello from MCP\",\"metadata\":\"example-metadata\",\"tags\":{\"env\":\"dev\"}}]" }], "isError": false } ``` **Errors:** reserved channel → `isError: true`; a non-existent channel returns an empty result (no error). ## Events tools [#events-tools] Pub/sub and the persistent events store. See [Events tools](/aiway/mcp/tools/events) for language examples. ### events\_publish [#events_publish] Publish an ephemeral event (fire-and-forget; no persistence). | Argument | Type | Required | Default | Description | | ---------- | ------ | -------- | ------- | --------------------------------------- | | `channel` | string | Yes | — | Target events channel | | `body` | string | Yes | — | Event body content | | `metadata` | string | No | `""` | Optional event metadata string | | `tags` | object | No | `{}` | Key-value tags for event classification | ```json { "type": "object", "required": ["channel", "body"], "properties": { "channel": { "type": "string" }, "body": { "type": "string" }, "metadata": { "type": "string", "default": "" }, "tags": { "type": "object", "additionalProperties": { "type": "string" }, "default": {} } } } ``` ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{ "jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": { "name": "events_publish", "arguments": { "channel": "example-events", "body": "Event data", "metadata": "event-meta", "tags": { "source": "mcp-example" } } } }' ``` **Response:** ```json { "content": [{ "type": "text", "text": "Event published successfully to channel 'example-events'" }], "isError": false } ``` **Errors:** reserved channel → `isError: true`; missing `channel`/`body` → `-32602`. ### events\_store\_publish [#events_store_publish] Publish a persistent event to the events store. Stored events receive a monotonic sequence number. | Argument | Type | Required | Default | Description | | ---------- | ------ | -------- | ------- | --------------------------------------- | | `channel` | string | Yes | — | Target events-store channel | | `body` | string | Yes | — | Event body content to store | | `metadata` | string | No | `""` | Optional event metadata string | | `tags` | object | No | `{}` | Key-value tags for event classification | ```json { "type": "object", "required": ["channel", "body"], "properties": { "channel": { "type": "string" }, "body": { "type": "string" }, "metadata": { "type": "string", "default": "" }, "tags": { "type": "object", "additionalProperties": { "type": "string" }, "default": {} } } } ``` ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{ "jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": { "name": "events_store_publish", "arguments": { "channel": "example-events-store", "body": "Stored event data", "metadata": "store-meta", "tags": { "source": "mcp-example" } } } }' ``` **Response:** ```json { "content": [{ "type": "text", "text": "Event published successfully to events store channel 'example-events-store'" }], "isError": false } ``` **Errors:** reserved channel → `isError: true`; missing `channel`/`body` → `-32602`. ### events\_store\_read [#events_store_read] Read stored events starting from a sequence number or a timestamp. | Argument | Type | Required | Default | Description | | --------------- | ------- | -------- | ------- | --------------------------------------------------------------------------- | | `channel` | string | Yes | — | Source events-store channel | | `from_sequence` | integer | No | — | Start from this sequence number. Mutually exclusive with `from_time` | | `from_time` | string | No | — | Start from this ISO 8601 timestamp. Mutually exclusive with `from_sequence` | | `max_messages` | integer | Yes | — | Maximum number of messages to return (1–100) | `from_sequence` and `from_time` are mutually exclusive — supply at most one. ```json { "type": "object", "required": ["channel", "max_messages"], "properties": { "channel": { "type": "string" }, "from_sequence": { "type": "integer", "minimum": 1 }, "from_time": { "type": "string", "format": "date-time" }, "max_messages": { "type": "integer", "minimum": 1, "maximum": 100 } } } ``` ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{ "jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": { "name": "events_store_read", "arguments": { "channel": "example-events-store", "from_sequence": 1, "max_messages": 10 } } }' ``` **Response** — a JSON array of stored events, each with its `sequence` and `timestamp`: ```json { "content": [{ "type": "text", "text": "[{\"body\":\"Stored event data\",\"metadata\":\"store-meta\",\"sequence\":1,\"timestamp\":\"2026-06-08T12:00:00Z\"}]" }], "isError": false } ``` **Errors:** reserved channel → `isError: true`; a non-existent channel returns an empty result; missing `channel` → `-32602`. ### events\_store\_read\_latest [#events_store_read_latest] Read the N most recent stored events. | Argument | Type | Required | Default | Description | | --------- | ------- | -------- | ------- | -------------------------------------- | | `channel` | string | Yes | — | Source events-store channel | | `count` | integer | No | `1` | Number of most recent events to return | ```json { "type": "object", "required": ["channel"], "properties": { "channel": { "type": "string" }, "count": { "type": "integer", "minimum": 1, "default": 1 } } } ``` ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{ "jsonrpc": "2.0", "id": 8, "method": "tools/call", "params": { "name": "events_store_read_latest", "arguments": { "channel": "example-events-store", "count": 3 } } }' ``` **Response** — the most recent events, newest first: ```json { "content": [{ "type": "text", "text": "[{\"body\":\"Stored event 3\",\"sequence\":3},{\"body\":\"Stored event 2\",\"sequence\":2},{\"body\":\"Stored event 1\",\"sequence\":1}]" }], "isError": false } ``` **Errors:** reserved channel → `isError: true`; a non-existent channel returns an empty result; missing `channel` → `-32602`. ## Command & query tools [#command--query-tools] Synchronous request/reply. See [Command & query tools](/aiway/mcp/tools/commands-queries) for language examples. ### command\_send [#command_send] Send a command and wait for acknowledgment from a subscriber. | Argument | Type | Required | Default | Description | | ----------------- | ------- | -------- | ------- | ------------------------------------------- | | `channel` | string | Yes | — | Target command channel | | `body` | string | Yes | — | Command body content | | `timeout_seconds` | integer | No | `10` | Timeout in seconds waiting for the response | | `metadata` | string | No | `""` | Optional command metadata string | | `tags` | object | No | `{}` | Key-value tags for command classification | ```json { "type": "object", "required": ["channel", "body"], "properties": { "channel": { "type": "string" }, "body": { "type": "string" }, "timeout_seconds": { "type": "integer", "minimum": 1, "default": 10 }, "metadata": { "type": "string", "default": "" }, "tags": { "type": "object", "additionalProperties": { "type": "string" }, "default": {} } } } ``` ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{ "jsonrpc": "2.0", "id": 9, "method": "tools/call", "params": { "name": "command_send", "arguments": { "channel": "example-commands", "body": "do-work", "timeout_seconds": 10, "metadata": "cmd-meta", "tags": { "action": "process" } } } }' ``` **Response (success):** ```json { "content": [{ "type": "text", "text": "Command executed successfully on channel 'example-commands'" }], "isError": false } ``` **Errors:** reserved channel, no subscriber (timeout), or subscriber rejection → `isError: true`; missing `channel`/`body` → `-32602`. ### query\_send [#query_send] Send a query and receive a data response from a subscriber. | Argument | Type | Required | Default | Description | | ----------------- | ------- | -------- | ------- | ------------------------------------------- | | `channel` | string | Yes | — | Target query channel | | `body` | string | Yes | — | Query body content | | `timeout_seconds` | integer | No | `30` | Timeout in seconds waiting for the response | | `metadata` | string | No | `""` | Optional query metadata string | | `tags` | object | No | `{}` | Key-value tags for query classification | ```json { "type": "object", "required": ["channel", "body"], "properties": { "channel": { "type": "string" }, "body": { "type": "string" }, "timeout_seconds": { "type": "integer", "minimum": 1, "default": 30 }, "metadata": { "type": "string", "default": "" }, "tags": { "type": "object", "additionalProperties": { "type": "string" }, "default": {} } } } ``` ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{ "jsonrpc": "2.0", "id": 10, "method": "tools/call", "params": { "name": "query_send", "arguments": { "channel": "example-queries", "body": "get-data", "timeout_seconds": 30, "metadata": "qry-meta", "tags": { "action": "lookup" } } } }' ``` **Response (success)** — the subscriber's reply payload as the text block: ```json { "content": [{ "type": "text", "text": "{\"data\":\"query response payload from subscriber\"}" }], "isError": false } ``` **Errors:** reserved channel, no subscriber (timeout), or subscriber rejection → `isError: true`; missing `channel`/`body` → `-32602`. ## Channel tools [#channel-tools] Discovery and inspection of channels. See [Channel tools](/aiway/mcp/tools/channel-management) for language examples. ### channel\_list [#channel_list] List channels, optionally filtered by type or name pattern. An empty list is a normal successful result. | Argument | Type | Required | Default | Description | | --------- | ------ | -------- | ------- | ---------------------------------------------------------------------------------- | | `type` | string | No | — | Filter by channel type (`queues`, `events`, `events_store`, `commands`, `queries`) | | `pattern` | string | No | — | Filter by channel name pattern or prefix | ```json { "type": "object", "required": [], "properties": { "type": { "type": "string" }, "pattern": { "type": "string" } } } ``` ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{ "jsonrpc": "2.0", "id": 11, "method": "tools/call", "params": { "name": "channel_list", "arguments": {} } }' ``` **Response** — a JSON array of channel descriptors: ```json { "content": [{ "type": "text", "text": "[{\"name\":\"example-queue\",\"type\":\"queues\",\"is_active\":true},{\"name\":\"example-events\",\"type\":\"events\",\"is_active\":true}]" }], "isError": false } ``` **Errors:** invalid arguments → `-32602`. No tool-specific failures otherwise. ### channel\_info [#channel_info] Get metadata for a specific channel. | Argument | Type | Required | Default | Description | | --------- | ------ | -------- | ------- | ------------------------------------------------------------------------ | | `channel` | string | Yes | — | Channel name | | `type` | string | Yes | — | Channel type (`queues`, `events`, `events_store`, `commands`, `queries`) | ```json { "type": "object", "required": ["channel", "type"], "properties": { "channel": { "type": "string" }, "type": { "type": "string" } } } ``` ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{ "jsonrpc": "2.0", "id": 12, "method": "tools/call", "params": { "name": "channel_info", "arguments": { "channel": "example-queue", "type": "queues" } } }' ``` **Response (success)** — channel metadata with traffic counters: ```json { "content": [{ "type": "text", "text": "{\"name\":\"example-queue\",\"type\":\"queues\",\"is_active\":true,\"incoming\":5,\"outgoing\":3}" }], "isError": false } ``` **Errors:** non-existent channel → `isError: true`. ## Agent-bridge tools [#agent-bridge-tools] These 4 tools appear in `tools/list` **only when the A2A agent registry is present**. They turn an MCP client into an A2A caller. See [Agent-bridge tools](/aiway/mcp/tools/agent-bridge) for language examples and the [A2A connector](/aiway/a2a) for the agent model. ### agent\_list [#agent_list] List registered agents, optionally filtered by skill tags. An empty list is a normal successful result. | Argument | Type | Required | Default | Description | | ------------ | --------------- | -------- | ------- | --------------------------- | | `skill_tags` | array of string | No | — | Filter agents by skill tags | ```json { "type": "object", "required": [], "properties": { "skill_tags": { "type": "array", "items": { "type": "string" } } } } ``` ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{ "jsonrpc": "2.0", "id": 13, "method": "tools/call", "params": { "name": "agent_list", "arguments": {} } }' ``` **Response** — a JSON array of agents with their skills: ```json { "content": [{ "type": "text", "text": "[{\"agent_id\":\"echo-01\",\"name\":\"Echo Agent 01\",\"skills\":[{\"id\":\"echo\",\"name\":\"Echo\",\"tags\":[\"test\",\"echo\"]}]}]" }], "isError": false } ``` **Errors:** invalid arguments → `-32602`. No tool-specific failures otherwise. ### agent\_info [#agent_info] Get detailed metadata for a specific agent. | Argument | Type | Required | Default | Description | | ---------- | ------ | -------- | ------- | --------------------------- | | `agent_id` | string | Yes | — | Agent identifier to look up | ```json { "type": "object", "required": ["agent_id"], "properties": { "agent_id": { "type": "string" } } } ``` ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{ "jsonrpc": "2.0", "id": 14, "method": "tools/call", "params": { "name": "agent_info", "arguments": { "agent_id": "echo-01" } } }' ``` **Response (success)** — the agent's card, including its registered HTTP `url` and skills: ```json { "content": [{ "type": "text", "text": "{\"agent_id\":\"echo-01\",\"name\":\"Echo Agent 01\",\"description\":\"echo agent\",\"version\":\"1.0.0\",\"url\":\"http://localhost:18080/\",\"skills\":[{\"id\":\"echo\",\"name\":\"Echo\",\"tags\":[\"test\",\"echo\"]}]}" }], "isError": false } ``` **Errors:** non-existent agent → `isError: true`. ### agent\_send [#agent_send] Send a message to an agent. The connector builds a `message/send` envelope and forwards it over the broker as a Query to `_AGENTS_.agents/`. | Argument | Type | Required | Default | Description | | ----------------- | ------- | -------- | ------- | ----------------------------------------------------------------- | | `agent_id` | string | Yes | — | Target agent identifier | | `message` | string | Yes | — | Message content to send to the agent | | `blocking` | boolean | No | `true` | Wait for the agent response if `true`; fire-and-forget if `false` | | `context_id` | string | No | — | Conversation context ID for multi-turn interactions | | `timeout_seconds` | integer | No | — | Timeout in seconds. The server adds a +10s `GatewayTimeoutBuffer` | ```json { "type": "object", "required": ["agent_id", "message"], "properties": { "agent_id": { "type": "string" }, "message": { "type": "string" }, "blocking": { "type": "boolean", "default": true }, "context_id": { "type": "string" }, "timeout_seconds": { "type": "integer", "minimum": 1 } } } ``` ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{ "jsonrpc": "2.0", "id": 15, "method": "tools/call", "params": { "name": "agent_send", "arguments": { "agent_id": "echo-01", "message": "hello from MCP" } } }' ``` **Response (success)** — the agent's reply payload as the text block: ```json { "content": [{ "type": "text", "text": "{\"echo\":{\"method\":\"message/send\",\"params\":{\"message\":\"hello from MCP\"}},\"received_headers\":{}}" }], "isError": false } ``` **Errors:** non-existent agent or timeout exceeded → `isError: true`. ### agent\_query [#agent_query] Query an agent with a specific JSON-RPC method. | Argument | Type | Required | Default | Description | | ---------- | ------ | -------- | ------- | ------------------------------------------------------------------------ | | `agent_id` | string | Yes | — | Target agent identifier | | `method` | string | Yes | — | Query method to invoke (`tasks/get`, `tasks/cancel`, or a custom method) | | `params` | object | No | — | Method-specific parameters | ```json { "type": "object", "required": ["agent_id", "method"], "properties": { "agent_id": { "type": "string" }, "method": { "type": "string" }, "params": { "type": "object" } } } ``` ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{ "jsonrpc": "2.0", "id": 16, "method": "tools/call", "params": { "name": "agent_query", "arguments": { "agent_id": "echo-01", "method": "tasks/get" } } }' ``` **Response (success):** ```json { "content": [{ "type": "text", "text": "{\"echo\":{\"method\":\"tasks/get\",\"params\":{}},\"received_headers\":{}}" }], "isError": false } ``` **Errors:** non-existent agent or timeout exceeded → `isError: true`. ## Related [#related] # Authentication (/aiway/mcp/guides/authentication) The MCP connector is guarded by the same JWT Bearer authentication as every other KubeMQ connector. You authenticate to `POST /mcp` with an `Authorization: Bearer` header; KubeMQ verifies the token, attaches your identity claims, and lets the tool call through. This guide shows how to attach that header through each MCP SDK's transport — the place an SDK client differs from a raw `curl` call — plus how MCP reports auth failures and validates request origins. ## Overview [#overview] Authentication for MCP is the connector-wide model described in [Auth & Security](/connectors/reference/auth-and-security), applied to the single MCP endpoint: * **Endpoint** — `POST /mcp` (and the `GET /mcp` keepalive stream) carry every JSON-RPC method: `initialize`, `tools/list`, `tools/call`, and `ping`. A verified token identifies the caller; that `ClientID` flows through to the broker and, for the [agent-bridge tools](/aiway/mcp/tools/agent-bridge), to the agent as `X-KubeMQ-Caller-ID`. When server authentication is **disabled** (the default for local development), every caller is treated as the synthetic `anonymous` principal and no token is required. When it is **enabled**, a missing or unverified token is rejected as a JSON-RPC `-32010` error. Authentication is **disabled by default** so MCP works out of the box for local development. Enable it before exposing the endpoint beyond a trusted network — see [Production recommendations](#production-recommendations). ## How it works [#how-it-works] The token is verified once, at the shared auth middleware, before the request reaches the MCP connector. The middleware extracts the `Bearer` token from the `Authorization` header, verifies it against KubeMQ's authentication singleton, and attaches the caller's claims (including `ClientID`) to the request context. The connector then resolves the tool to a KubeMQ operation under that identity. *KubeMQ verifies the Bearer token at the edge, then runs the tool call under the caller's identity.* ## Attaching the token [#attaching-the-token] Every MCP request must carry an `Authorization: Bearer ` header when server auth is enabled. With raw JSON-RPC the header goes straight onto the HTTP request; with an MCP SDK you attach it through the **streamable-HTTP transport** so the header rides on every call in the session — the `initialize` handshake, `tools/list`, and each `tools/call`. These snippets store the token in the `KUBEMQ_MCP_AUTH_TOKEN` environment variable — the same variable the KubeMQ MCP examples use — and send it on a `tools/list` request. ```bash curl -X POST http://localhost:9090/mcp \ -H "Authorization: Bearer $KUBEMQ_MCP_AUTH_TOKEN" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` ```csharp using ModelContextProtocol.Client; var url = Environment.GetEnvironmentVariable("KUBEMQ_MCP_URL") ?? "http://localhost:9090"; var token = Environment.GetEnvironmentVariable("KUBEMQ_MCP_AUTH_TOKEN"); var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{url}/mcp"), AdditionalHeaders = new Dictionary { ["Authorization"] = $"Bearer {token}", }, }); await using var client = await McpClientFactory.CreateAsync(transport); var tools = await client.ListToolsAsync(); Console.WriteLine($"Tools: {tools.Count}"); ``` ```go package main import ( "context" "fmt" "log" "os" "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/client/transport" "github.com/mark3labs/mcp-go/mcp" ) func main() { url := os.Getenv("KUBEMQ_MCP_URL") if url == "" { url = "http://localhost:9090" } token := os.Getenv("KUBEMQ_MCP_AUTH_TOKEN") c, err := client.NewStreamableHttpClient(url+"/mcp", transport.WithHTTPHeaders(map[string]string{ "Authorization": "Bearer " + token, }), ) if err != nil { log.Fatal(err) } defer c.Close() ctx := context.Background() if err := c.Start(ctx); err != nil { log.Fatal(err) } if _, err := c.Initialize(ctx, mcp.InitializeRequest{}); err != nil { log.Fatal(err) } tools, err := c.ListTools(ctx, mcp.ListToolsRequest{}) if err != nil { log.Fatal(err) } fmt.Printf("Tools: %d\n", len(tools.Tools)) } ``` ```java import io.modelcontextprotocol.sdk.McpClient; import io.modelcontextprotocol.sdk.client.transport.HttpClientStreamableHttpTransport; public class Auth { public static void main(String[] args) { String url = System.getenv().getOrDefault("KUBEMQ_MCP_URL", "http://localhost:9090"); String token = System.getenv("KUBEMQ_MCP_AUTH_TOKEN"); var transport = HttpClientStreamableHttpTransport.builder(url) .endpoint("/mcp") .httpRequestCustomizer((builder, method, endpoint, body, context) -> builder.header("Authorization", "Bearer " + token)) .build(); var client = McpClient.sync(transport).build(); client.initialize(); var tools = client.listTools(); System.out.println("Tools: " + tools.tools().size()); client.closeGracefully(); } } ``` ```kotlin import io.modelcontextprotocol.kotlin.sdk.Implementation import io.modelcontextprotocol.kotlin.sdk.client.Client import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport import io.ktor.client.* import io.ktor.client.plugins.* import io.ktor.client.plugins.sse.* import io.ktor.client.request.* import kotlinx.coroutines.runBlocking fun main() = runBlocking { val url = System.getenv("KUBEMQ_MCP_URL") ?: "http://localhost:9090" val token = System.getenv("KUBEMQ_MCP_AUTH_TOKEN") val httpClient = HttpClient { install(SSE) defaultRequest { header("Authorization", "Bearer $token") } } val transport = StreamableHttpClientTransport(client = httpClient, url = "$url/mcp") val client = Client(clientInfo = Implementation(name = "kubemq-mcp-kotlin-example", version = "1.0.0")) client.connect(transport) val tools = client.listTools() println("Tools: ${tools?.tools?.size}") } ``` ```python import asyncio import os from mcp.client.streamable_http import streamablehttp_client from mcp import ClientSession KUBEMQ_MCP_URL = os.environ.get("KUBEMQ_MCP_URL", "http://localhost:9090") TOKEN = os.environ.get("KUBEMQ_MCP_AUTH_TOKEN") async def main(): headers = {"Authorization": f"Bearer {TOKEN}"} if TOKEN else None async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() tools = await session.list_tools() print(f"Tools: {len(tools.tools)}") if __name__ == "__main__": asyncio.run(main()) ``` ```ruby require "mcp" url = ENV.fetch("KUBEMQ_MCP_URL", "http://localhost:9090") token = ENV["KUBEMQ_MCP_AUTH_TOKEN"] client = MCP::Client.new( transport: MCP::Transport::StreamableHTTP.new( "#{url}/mcp", headers: { "Authorization" => "Bearer #{token}" } ), name: "kubemq-mcp-ruby-example", version: "1.0.0" ) client.initialize_handshake tools = client.list_tools puts "Tools: #{tools.size}" client.close ``` ```rust use rmcp::transport::streamable_http::StreamableHttpClientTransport; use rmcp::transport::streamable_http::client::StreamableHttpClientTransportConfig; use rmcp::service::RunService; #[tokio::main] async fn main() -> anyhow::Result<()> { let url = std::env::var("KUBEMQ_MCP_URL") .unwrap_or_else(|_| "http://localhost:9090".to_string()); let token = std::env::var("KUBEMQ_MCP_AUTH_TOKEN").unwrap_or_default(); let http = reqwest::Client::builder() .default_headers({ let mut h = reqwest::header::HeaderMap::new(); h.insert( reqwest::header::AUTHORIZATION, format!("Bearer {token}").parse()?, ); h }) .build()?; let transport = StreamableHttpClientTransport::with_client( http, StreamableHttpClientTransportConfig::with_uri(format!("{url}/mcp")), ); let client = ().serve(transport).await?; let tools = client.list_all_tools().await?; println!("Tools: {}", tools.len()); Ok(()) } ``` ```swift import Foundation import MCP @main struct Auth { static func main() async throws { let url = ProcessInfo.processInfo.environment["KUBEMQ_MCP_URL"] ?? "http://localhost:9090" let token = ProcessInfo.processInfo.environment["KUBEMQ_MCP_AUTH_TOKEN"] ?? "" let transport = HTTPClientTransport( endpoint: URL(string: "\(url)/mcp")!, streaming: true, requestModifier: { request in var modified = request modified.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") return modified } ) let client = Client(name: "kubemq-mcp-swift-example", version: "1.0.0") try await client.connect(transport: transport) let (tools, _) = try await client.listTools() print("Tools: \(tools.count)") } } ``` ```typescript import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const KUBEMQ_MCP_URL = process.env.KUBEMQ_MCP_URL || "http://localhost:9090"; const token = process.env.KUBEMQ_MCP_AUTH_TOKEN; const transport = new StreamableHTTPClientTransport( new URL(`${KUBEMQ_MCP_URL}/mcp`), { requestInit: { headers: { Authorization: `Bearer ${token}` }, }, } ); const client = new Client({ name: "kubemq-mcp-ts-example", version: "1.0.0" }); await client.connect(transport); const tools = await client.listTools(); console.log(`Tools: ${tools.tools.length}`); await client.close(); ``` The token is set on the **transport**, not on a single request, so it is sent with the `initialize` handshake and every subsequent `tools/call` in the session. Set `KUBEMQ_MCP_AUTH_TOKEN` in your environment rather than hard-coding the JWT in source. ## How auth failures are reported [#how-auth-failures-are-reported] MCP is a JSON-RPC endpoint, so authentication errors come back as a JSON-RPC error object — not an HTTP 401. The HTTP status stays `200`; the failure is encoded in the `error` field with code `-32010`. ```json { "jsonrpc": "2.0", "id": 1, "error": { "code": -32010, "message": "Authentication failed" } } ``` | Condition | Result | | -------------------------------------- | ----------------------------------------- | | Auth disabled (default) | Request runs as the `anonymous` principal | | Auth enabled, valid token | Request runs as the token's `ClientID` | | Auth enabled, missing or invalid token | JSON-RPC error **`-32010`** | `-32010` is distinct from the standard JSON-RPC codes (`-32700` … `-32603`) and from tool-level errors, which return a normal result with `isError: true`. A `-32010` is an **authentication** failure, never a tool failure. The full code list is in the [error codes reference](/aiway/mcp/reference/error-codes). ## Origin validation [#origin-validation] Beyond the shared HTTP server's CORS and origin middleware, MCP applies its **own** origin check (`validateOrigin`) against `McpConfig.TrustedOrigins`. This guards the endpoint against DNS-rebinding and cross-site requests from untrusted browser pages. | `TrustedOrigins` value | Behavior | | ---------------------- | ----------------------------------------------------------------------------------------------------------------- | | `auto` (default) | Matches localhost variants — `localhost`, `127.0.0.1`, `::1`, `[::1]`, `0.0.0.0` — plus the server's bind address | | `*` | Allows all origins | | *(custom list)* | Allows exactly the listed origins | An **empty `Origin` header** — which non-browser clients such as `curl`, the MCP SDKs, and server-to-server calls send — is **always allowed**. Only browser callers, which set an `Origin`, are screened. Configure the list with `CONNECTORSMCP_TRUSTED_ORIGINS` (see [Configuration](/aiway/mcp/configuration)); for example, to allow a single web app: ```bash title="env.sh" export CONNECTORSMCP_TRUSTED_ORIGINS=https://app.example.com ``` A rejected origin is refused before the JSON-RPC method runs. ## Production recommendations [#production-recommendations] Authentication is off by default for convenience; turn it on before the endpoint is reachable from anywhere untrusted. * **Enable authentication** in any non-local deployment and require a Bearer token on every MCP call. * **Use TLS** (`https://`) for all production MCP endpoints — see [Auth & Security → TLS and mTLS](/connectors/reference/auth-and-security#tls-and-mtls). * **Prefer short-lived, rotated tokens** to limit the blast radius of a leaked JWT. * **Restrict `TrustedOrigins`** to the exact web origins that need browser access instead of leaving `auto` or `*` in place. * **Restrict network access** to the endpoint with firewall rules or Kubernetes network policies as defense in depth. ## Related [#related] # Channel Resolution (/aiway/mcp/guides/channel-resolution) Every KubeMQ MCP tool acts on a **channel** — the addressable destination a message is sent to or read from. This guide explains how channels are named, the five channel types each tool family targets, the single reserved prefix you must avoid, and how a model discovers channels at runtime before it acts. ## Overview [#overview] A channel is just a string. There is no registry to provision and no create step: a channel comes into existence the first time a tool references it, and disappears from discovery when it no longer carries traffic. This makes the MCP surface self-describing — a model can name a channel on the fly (`orders.us-west`), and the same name resolves consistently across every tool that uses it. Each tool argument named `channel` is resolved against one of the five KubeMQ messaging patterns. The tool you call determines the pattern; the channel string determines the destination within it. ## How tools map to channels [#how-tools-map-to-channels] There is no separate "channel resolution" step in the protocol — the **tool name selects the channel type**, and the `channel` argument names the destination. The model never has to declare a type alongside a send; calling `queue_send` *is* the declaration that the channel is a queue. *The tool family selects the channel type; the `channel` argument names the destination.* ## Channel naming [#channel-naming] * Channels are arbitrary strings — for example `my-app.orders`, `notifications`, or `user-events`. * Channels are **created implicitly on first use**. There is no explicit creation step. * **Convention:** use dot-separated hierarchical names to organize a namespace, such as `orders.us-west` or `events.user.signup`. Because channels are created on demand, a typo creates a new (empty) channel rather than raising an error. Use [`channel_list`](#discovering-channels) to confirm a channel exists and carries traffic before relying on it. ## Channel types [#channel-types] Each channel belongs to exactly one of five types — one per messaging pattern — and each type is served by a specific set of tools: | Type | Description | Associated tools | | -------------- | ------------------------------------------ | ----------------------------------------------------------------------- | | `queues` | Point-to-point durable queue channels | `queue_send`, `queue_receive`, `queue_peek` | | `events` | Ephemeral fire-and-forget pub/sub channels | `events_publish` | | `events_store` | Persistent pub/sub channels with replay | `events_store_publish`, `events_store_read`, `events_store_read_latest` | | `commands` | Request/reply command channels | `command_send` | | `queries` | Request/reply query channels | `query_send` | The same string can name distinct channels under different types — `orders` as a queue and `orders` as an events channel are unrelated. That is why `channel_info` requires both the `channel` name **and** the `type`. Only `queues` and `events_store` channels are backed by broker monitoring, so `channel_list` and `channel_info` report live message statistics for them. The ephemeral types (`events`, `commands`, `queries`) exist only while subscribers are connected and are returned as type descriptions rather than per-channel stats. ## Reserved prefix [#reserved-prefix] One prefix is reserved by the broker for the agent bridge and **cannot be targeted by the direct messaging tools**. | Prefix | Purpose | Restriction | | ----------- | ----------------------------------- | -------------------------------------- | | `_AGENTS_.` | Agent-bridge internal communication | Rejected by all direct messaging tools | A channel is reserved when its name begins with the literal prefix `_AGENTS_.` (the trailing dot is part of the prefix). Passing such a channel to `queue_send`, `events_publish`, `events_store_publish`, `command_send`, or `query_send` returns a tool error (`isError: true`) at the tool layer — the message is never published. To reach an agent, use the agent-bridge tools `agent_send` and `agent_query` instead of addressing `_AGENTS_.*` channels directly. The bridge manages the reserved channels on your behalf — see [Agent-bridge tools](/aiway/mcp/tools/agent-bridge). ## Discovering channels [#discovering-channels] Two read-only tools let a model explore the namespace at runtime instead of hard-coding channel names: `channel_list` enumerates channels (optionally filtered), and `channel_info` returns metadata for one channel. ### Listing and filtering [#listing-and-filtering] `channel_list` returns every known channel, or a subset filtered by `type`, by a name `pattern`, or both. The `pattern` filter is a **prefix match** — `"example-"` matches `example-queue` and `example-events` but not `my-example`. With no arguments it returns all channels. ```bash # List all channels (no filter) curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H 'MCP-Protocol-Version: 2025-11-25' \ -d '{ "jsonrpc": "2.0", "id": 11, "method": "tools/call", "params": { "name": "channel_list", "arguments": {} } }' # Filter by type: "arguments": { "type": "queues" } # Filter by prefix: "arguments": { "pattern": "example-" } ``` ```go url := os.Getenv("KUBEMQ_MCP_URL") if url == "" { url = "http://localhost:9090" } c, err := client.NewStreamableHttpClient(url + "/mcp") if err != nil { log.Fatal(err) } defer c.Close() ctx := context.Background() if err := c.Start(ctx); err != nil { log.Fatal(err) } result, err := c.CallTool(ctx, mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: "channel_list", Arguments: map[string]any{}, }, }) if err != nil { log.Fatal(err) } fmt.Printf("Result: %+v\n", result) ``` ```python async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() # List all channels (no filter) result = await session.call_tool("channel_list", {}) print(f"IsError: {result.isError}") for content in result.content: print(f"Result: {content.text}") # To filter by type, use: {"type": "queues"} # To filter by pattern, use: {"pattern": "example-"} ``` ```typescript const transport = new StreamableHTTPClientTransport( new URL(`${KUBEMQ_MCP_URL}/mcp`) ); const client = new Client({ name: "kubemq-mcp-ts-example", version: "1.0.0" }); await client.connect(transport); // List all channels (no filter) const result = await client.callTool({ name: "channel_list", arguments: {}, }); console.log(JSON.stringify(result, null, 2)); // To filter by type: { type: "queues" } // To filter by pattern: { pattern: "example-" } await client.close(); ``` ```java String url = System.getenv().getOrDefault("KUBEMQ_MCP_URL", "http://localhost:9090"); var transport = HttpClientStreamableHttpTransport.builder(url).endpoint("/mcp").build(); var client = McpClient.sync(transport).build(); client.initialize(); // List all channels (no filter) var result = client.callTool(new CallToolRequest( "channel_list", Map.of() )); System.out.println(result); // To filter by type: Map.of("type", "queues") // To filter by pattern: Map.of("pattern", "example-") client.closeGracefully(); ``` ```csharp var url = Environment.GetEnvironmentVariable("KUBEMQ_MCP_URL") ?? "http://localhost:9090"; var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{url}/mcp") }); await using var client = await McpClientFactory.CreateAsync(transport); var result = await client.CallToolAsync("channel_list", new Dictionary()); Console.WriteLine($"Result: {result}"); ``` ```kotlin val url = System.getenv("KUBEMQ_MCP_URL") ?: "http://localhost:9090" val httpClient = HttpClient { install(SSE) } val transport = StreamableHttpClientTransport(client = httpClient, url = "$url/mcp") val client = Client(clientInfo = Implementation(name = "kubemq-mcp-kotlin-example", version = "1.0.0")) client.connect(transport) val result = client.callTool("channel_list", emptyMap()) println("Result: $result") client.close() httpClient.close() ``` ```ruby url = ENV.fetch("KUBEMQ_MCP_URL", "http://localhost:9090") client = MCP::Client.new( transport: MCP::Transport::StreamableHTTP.new("#{url}/mcp"), name: "kubemq-mcp-ruby-example", version: "1.0.0" ) client.initialize_handshake result = client.call_tool("channel_list", {}) puts "Result: #{result}" client.close ``` ```rust let url = std::env::var("KUBEMQ_MCP_URL") .unwrap_or_else(|_| "http://localhost:9090".to_string()); let transport = StreamableHttpClientTransport::from_uri(format!("{url}/mcp"))?; let client = ().serve(transport).await?; let result = client.call_tool("channel_list", json!({})).await?; println!("Result: {result:#?}"); ``` ```swift let url = ProcessInfo.processInfo.environment["KUBEMQ_MCP_URL"] ?? "http://localhost:9090" let transport = HTTPClientTransport(endpoint: URL(string: "\(url)/mcp")!, streaming: true) let client = Client(name: "kubemq-mcp-swift-example", version: "1.0.0") try await client.connect(transport: transport) let result = try await client.callTool("channel_list", arguments: [:]) print("Result: \(result)") ``` A successful `channel_list` returns a JSON array in the standard `content[]`/`isError` envelope. An empty array (`[]`) is a normal result, not an error: ```json { "jsonrpc": "2.0", "id": 11, "result": { "content": [ { "type": "text", "text": "[{\"name\":\"example-queue\",\"type\":\"queues\",\"is_active\":true},{\"name\":\"example-events\",\"type\":\"events\",\"is_active\":true}]" } ], "isError": false } } ``` ### Inspecting one channel [#inspecting-one-channel] `channel_info` confirms a single channel's type and live state. Both the `channel` name and its `type` are required — the type scopes the lookup to the correct messaging pattern. ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H 'MCP-Protocol-Version: 2025-11-25' \ -d '{ "jsonrpc": "2.0", "id": 12, "method": "tools/call", "params": { "name": "channel_info", "arguments": { "channel": "example-queue", "type": "queues" } } }' ``` ```go result, err := c.CallTool(ctx, mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: "channel_info", Arguments: map[string]any{ "channel": "example-queue", "type": "queues", }, }, }) if err != nil { log.Fatal(err) } fmt.Printf("Result: %+v\n", result) ``` ```python result = await session.call_tool("channel_info", { "channel": "example-queue", "type": "queues", }) print(f"IsError: {result.isError}") for content in result.content: print(f"Result: {content.text}") ``` ```typescript const result = await client.callTool({ name: "channel_info", arguments: { channel: "example-queue", type: "queues", }, }); console.log(JSON.stringify(result, null, 2)); ``` ```java var result = client.callTool(new CallToolRequest( "channel_info", Map.of( "channel", "example-queue", "type", "queues" ) )); System.out.println(result); ``` ```csharp var result = await client.CallToolAsync("channel_info", new Dictionary { ["channel"] = "example-queue", ["type"] = "queues", }); Console.WriteLine($"Result: {result}"); ``` ```kotlin val result = client.callTool("channel_info", mapOf( "channel" to "example-queue", "type" to "queues" )) println("Result: $result") ``` ```ruby result = client.call_tool("channel_info", { "channel" => "example-queue", "type" => "queues", }) puts "Result: #{result}" ``` ```rust let result = client.call_tool("channel_info", json!({ "channel": "example-queue", "type": "queues" })).await?; println!("Result: {result:#?}"); ``` ```swift let result = try await client.callTool("channel_info", arguments: [ "channel": "example-queue", "type": "queues", ]) print("Result: \(result)") ``` For `queues` and `events_store` channels the result includes live `incoming`/`outgoing` message counts; for the ephemeral types it returns the channel's type and active state: ```json { "jsonrpc": "2.0", "id": 12, "result": { "content": [ { "type": "text", "text": "{\"name\":\"example-queue\",\"type\":\"queues\",\"is_active\":true,\"incoming\":5,\"outgoing\":3}" } ], "isError": false } } ``` ## Resolution failures [#resolution-failures] | Condition | Result | | --------------------------------------------------------------- | ------------------------------------------ | | Channel name begins with `_AGENTS_.` on a direct messaging tool | Tool error — `isError: true` | | Invalid `type` value (not one of the five) | JSON-RPC `-32602` Invalid Params | | `channel_info` missing `channel` or `type` | JSON-RPC `-32602` Invalid Params | | `channel_list` with a pattern that matches nothing | Successful result with an empty array `[]` | For the full JSON-RPC error catalog, see [Error codes](/aiway/mcp/reference/error-codes). ## Related [#related] # Client Setup (/aiway/mcp/guides/client-setup) The MCP connector speaks the [Model Context Protocol](/aiway/mcp) (version `2025-11-25`) as JSON-RPC 2.0 over the **Streamable HTTP transport** at `POST /mcp`. Any compliant MCP client — Claude Desktop, an official MCP SDK, or a hand-rolled JSON-RPC caller — connects the same way: point it at the endpoint, run the `initialize` handshake, and start calling tools. This guide covers each path in depth. For the 5-minute quick start, see [Getting started](/aiway/mcp/getting-started). ## Overview [#overview] Every MCP client connects to a single URL and goes through the same three-step lifecycle before any tool call: 1. **`initialize`** — negotiate the protocol version and open a session. 2. **`notifications/initialized`** — acknowledge the handshake. 3. **`tools/list` / `tools/call`** — discover and invoke tools. The official SDKs and Claude Desktop perform steps 1 and 2 automatically and track the [session ID](/aiway/mcp/guides/session-management) for you. With raw `curl` you drive each step yourself. ## How it works [#how-it-works] The Streamable HTTP transport carries every JSON-RPC message as a standalone `POST /mcp`. The server returns an `MCP-Session-Id` on `initialize`, and the client echoes it on all subsequent requests. A separate `GET /mcp` opens a stateless keepalive stream that emits a `: keepalive` comment every 30 seconds. *One session, established once, carries every later request via the `MCP-Session-Id` header.* ## Claude Desktop [#claude-desktop] Claude Desktop connects to KubeMQ over the HTTP transport. Add a server entry to `claude_desktop_config.json` (Claude Desktop → Settings → Developer → Edit Config), then restart Claude Desktop. ```json title="claude_desktop_config.json" { "mcpServers": { "kubemq": { "url": "http://localhost:9090/mcp" } } } ``` On restart, Claude Desktop runs the `initialize` handshake, discovers all 15 KubeMQ [tools](/aiway/mcp/tools), and exposes them in the conversation. The `url` must include the `/mcp` path — it is the only required field for an unauthenticated server. ### Authenticated servers [#authenticated-servers] If the [shared HTTP server](/connectors/concepts/shared-http-server) has JWT auth enabled, add an `Authorization` header so Claude Desktop sends a Bearer token with every request: ```json title="claude_desktop_config.json" { "mcpServers": { "kubemq": { "url": "https://kubemq.example.com/mcp", "headers": { "Authorization": "Bearer " } } } } ``` Use `https://` for any remote endpoint. See [Authentication](/aiway/mcp/guides/authentication) for how the connector validates the token and returns `-32010` on failure. The MCP connector also validates the request `Origin` against `McpConfig.TrustedOrigins` (default `["auto"]`, which matches `localhost`, `127.0.0.1`, `::1`, `[::1]`, `0.0.0.0`, and the server's bind address). When connecting from a remote host or a custom origin, add it to the trusted-origins list in [Configuration](/aiway/mcp/configuration) or origin validation will reject the connection. ## Generic JSON-RPC client [#generic-json-rpc-client] Any HTTP client can speak MCP directly — no SDK required. You manage the handshake, the `MCP-Session-Id` header, and request IDs yourself. This is the lowest-level path and mirrors exactly what an SDK does internally. ### Open a session with `initialize` [#open-a-session-with-initialize] `POST` an `initialize` request announcing the protocol version (`2025-11-25`) and your client identity: ```bash title="terminal" curl -i -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": { "name": "my-agent", "version": "1.0.0" } } }' ``` Capture the session ID from `result._meta.sessionId` in the body — it is also returned in the `MCP-Session-Id` response header (use `-i` to see headers): ```json { "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2025-11-25", "capabilities": { "tools": {} }, "serverInfo": { "name": "kubemq", "version": "..." }, "_meta": { "sessionId": "" } } } ``` ### Acknowledge with `notifications/initialized` [#acknowledge-with-notificationsinitialized] Send the `notifications/initialized` notification, carrying the session ID. It has no `id`, so the server replies with HTTP `200 OK` and body `{"jsonrpc":"2.0","result":null,"id":null}`: ```bash title="terminal" curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'MCP-Session-Id: ' \ -d '{ "jsonrpc": "2.0", "method": "notifications/initialized" }' ``` ### Call tools with the session header [#call-tools-with-the-session-header] Every later request — `tools/list`, `tools/call`, `ping` — includes the `MCP-Session-Id` header: ```bash title="terminal" curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'MCP-Session-Id: ' \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "queue_send", "arguments": { "channel": "my-queue", "body": "Hello from MCP" } } }' ``` ### Required headers [#required-headers] | Header | Direction | When | Description | | ---------------------- | --------- | ------------------ | ----------------------------------------------- | | `Content-Type` | Request | Always | Must be `application/json`. | | `MCP-Session-Id` | Request | After `initialize` | The session ID returned by the handshake. | | `MCP-Session-Id` | Response | Always | Echoed back by the server. | | `MCP-Protocol-Version` | Response | Always | The protocol version, `2025-11-25`. | | `Authorization` | Request | When auth is on | `Bearer ` for authenticated servers. | A stateless `GET /mcp` opens an SSE keepalive stream that emits a `: keepalive` comment every 30 seconds and carries no MCP messages — it only keeps an idle connection alive and closes when the client disconnects. See [Session management](/aiway/mcp/guides/session-management) for the full lifecycle and batching. ## Official MCP SDKs [#official-mcp-sdks] Each official MCP SDK wraps the Streamable HTTP transport: it performs the `initialize` handshake, sends `notifications/initialized`, and tracks the session ID automatically — you construct the transport with the endpoint URL and call tools. All examples read `KUBEMQ_MCP_URL` (default `http://localhost:9090`) and append `/mcp`. ```bash # Raw JSON-RPC: initialize, then call a tool with the returned session ID. curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'MCP-Session-Id: ' \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "queue_send", "arguments": { "channel": "example-queue", "body": "Hello from MCP", "metadata": "example-metadata", "tags": { "env": "dev", "source": "mcp-example" } } } }' ``` ```go package main import ( "context" "fmt" "log" "os" "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/mcp" ) func main() { url := os.Getenv("KUBEMQ_MCP_URL") if url == "" { url = "http://localhost:9090" } c, err := client.NewStreamableHttpClient(url + "/mcp") if err != nil { log.Fatal(err) } defer c.Close() ctx := context.Background() if err := c.Start(ctx); err != nil { log.Fatal(err) } result, err := c.CallTool(ctx, mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: "queue_send", Arguments: map[string]any{ "channel": "example-queue", "body": "Hello from Go MCP", "metadata": "example-metadata", "tags": map[string]any{"env": "dev", "source": "mcp-example"}, }, }, }) if err != nil { log.Fatal(err) } fmt.Println("Tool: queue_send") fmt.Printf("Result: %+v\n", result) } ``` ```python import asyncio import os from mcp.client.streamable_http import streamablehttp_client from mcp import ClientSession KUBEMQ_MCP_URL = os.environ.get("KUBEMQ_MCP_URL", "http://localhost:9090") async def main(): async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool("queue_send", { "channel": "example-queue", "body": "Hello from Python MCP", "metadata": "example-metadata", "tags": {"env": "dev", "source": "mcp-example"}, }) print(f"Tool: queue_send") print(f"IsError: {result.isError}") for content in result.content: print(f"Result: {content.text}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const KUBEMQ_MCP_URL = process.env.KUBEMQ_MCP_URL || "http://localhost:9090"; async function main() { const transport = new StreamableHTTPClientTransport( new URL(`${KUBEMQ_MCP_URL}/mcp`) ); const client = new Client({ name: "kubemq-mcp-ts-example", version: "1.0.0" }); await client.connect(transport); const result = await client.callTool({ name: "queue_send", arguments: { channel: "example-queue", body: "Hello from TypeScript MCP", metadata: "example-metadata", tags: { env: "dev", source: "mcp-example" }, }, }); console.log(JSON.stringify(result, null, 2)); await client.close(); } main().catch(console.error); ``` ```java import io.modelcontextprotocol.sdk.McpClient; import io.modelcontextprotocol.sdk.client.transport.HttpClientStreamableHttpTransport; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import java.util.Map; public class QueueSend { public static void main(String[] args) { String url = System.getenv().getOrDefault("KUBEMQ_MCP_URL", "http://localhost:9090"); var transport = HttpClientStreamableHttpTransport.builder(url).endpoint("/mcp").build(); var client = McpClient.sync(transport).build(); client.initialize(); var result = client.callTool(new CallToolRequest( "queue_send", Map.of( "channel", "example-queue", "body", "Hello from Java MCP", "metadata", "example-metadata", "tags", Map.of("env", "dev", "source", "mcp-example") ) )); System.out.println(result); client.closeGracefully(); } } ``` ```csharp using ModelContextProtocol.Client; class QueueSend { static async Task Main(string[] args) { var url = Environment.GetEnvironmentVariable("KUBEMQ_MCP_URL") ?? "http://localhost:9090"; var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{url}/mcp") }); await using var client = await McpClientFactory.CreateAsync(transport); var result = await client.CallToolAsync("queue_send", new Dictionary { ["channel"] = "example-queue", ["body"] = "Hello from C# MCP", ["metadata"] = "example-metadata", ["tags"] = new Dictionary { ["env"] = "dev", ["source"] = "mcp-example" }, }); Console.WriteLine($"Tool: queue_send"); Console.WriteLine($"Result: {result}"); } } ``` ```kotlin import io.modelcontextprotocol.kotlin.sdk.Implementation import io.modelcontextprotocol.kotlin.sdk.client.Client import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport import io.ktor.client.* import io.ktor.client.plugins.sse.* import kotlinx.coroutines.runBlocking fun main() = runBlocking { val url = System.getenv("KUBEMQ_MCP_URL") ?: "http://localhost:9090" val httpClient = HttpClient { install(SSE) } val transport = StreamableHttpClientTransport(client = httpClient, url = "$url/mcp") val client = Client(clientInfo = Implementation(name = "kubemq-mcp-kotlin-example", version = "1.0.0")) client.connect(transport) val result = client.callTool("queue_send", mapOf( "channel" to "example-queue", "body" to "Hello from Kotlin MCP", "metadata" to "example-metadata", "tags" to mapOf("env" to "dev", "source" to "mcp-example") )) println("Tool: queue_send") println("Result: $result") client.close() httpClient.close() } ``` ```ruby require "mcp" url = ENV.fetch("KUBEMQ_MCP_URL", "http://localhost:9090") client = MCP::Client.new( transport: MCP::Transport::StreamableHTTP.new("#{url}/mcp"), name: "kubemq-mcp-ruby-example", version: "1.0.0" ) client.initialize_handshake result = client.call_tool("queue_send", { "channel" => "example-queue", "body" => "Hello from Ruby MCP", "metadata" => "example-metadata", "tags" => { "env" => "dev", "source" => "mcp-example" }, }) puts "Tool: queue_send" puts "Result: #{result}" client.close ``` ```rust use rmcp::transport::streamable_http::StreamableHttpClientTransport; use rmcp::service::RunService; use serde_json::json; #[tokio::main] async fn main() -> anyhow::Result<()> { let url = std::env::var("KUBEMQ_MCP_URL") .unwrap_or_else(|_| "http://localhost:9090".to_string()); let transport = StreamableHttpClientTransport::from_uri(format!("{url}/mcp"))?; let client = ().serve(transport).await?; let result = client.call_tool("queue_send", json!({ "channel": "example-queue", "body": "Hello from Rust MCP", "metadata": "example-metadata", "tags": {"env": "dev", "source": "mcp-example"} })).await?; println!("Tool: queue_send"); println!("Result: {result:#?}"); Ok(()) } ``` ```swift import Foundation import MCP @main struct QueueSend { static func main() async throws { let url = ProcessInfo.processInfo.environment["KUBEMQ_MCP_URL"] ?? "http://localhost:9090" let transport = HTTPClientTransport(endpoint: URL(string: "\(url)/mcp")!, streaming: true) let client = Client(name: "kubemq-mcp-swift-example", version: "1.0.0") try await client.connect(transport: transport) let result = try await client.callTool("queue_send", arguments: [ "channel": "example-queue", "body": "Hello from Swift MCP", "metadata": "example-metadata", "tags": ["env": "dev", "source": "mcp-example"], ]) print("Tool: queue_send") print("Result: \(result)") } } ``` For an authenticated server, pass the JWT through the SDK's transport options (an `Authorization: Bearer ` header) — the same mechanism Claude Desktop uses. The standard environment variables are `KUBEMQ_MCP_URL`, `KUBEMQ_MCP_TIMEOUT` (client-side HTTP timeout, default `30`s), and `KUBEMQ_MCP_AUTH_TOKEN`. ## Related [#related] # Error Handling (/aiway/mcp/guides/error-handling) A `tools/call` can fail in three structurally different ways, and each one shows up in a different part of the response. A client that treats them as one bucket will either crash on a recoverable failure or silently swallow a malformed request. This guide shows how to detect each layer and branch on them in the right order; for the exhaustive list of codes and messages, see the [Error codes reference](/aiway/mcp/reference/error-codes). ## Overview [#overview] When you call a tool over `/mcp`, the request passes through three checkpoints before you get a result back: 1. **HTTP / auth layer** — the shared HTTP server validates the origin and the `Authorization` header *before* any JSON-RPC is parsed. Failures here surface as the JSON-RPC auth error `-32010` (the body still parses as JSON-RPC). 2. **JSON-RPC protocol layer** — the connector parses the envelope and resolves the method. A malformed body, an unknown method, or bad params produce a JSON-RPC `error` object **with no `result`**. 3. **Tool execution layer** — the envelope was valid and a tool ran, but the operation failed (reserved channel, missing agent, timeout). The response is a normal `result` carrying `isError: true`. The decisive split is between layers 2 and 3: a **protocol error** means the tool never ran, so retrying the same request fails identically — the fix is to correct the request. A **tool error** means a valid request ran and failed for an operational reason, which may be transient (a timeout) and worth retrying. Every JSON-RPC response — success *or* error — comes back as **HTTP 200**. The outcome is encoded in the JSON body, not the status line. Do not branch on the HTTP status code for JSON-RPC calls. ## How it works [#how-it-works] Branch on the layers in a fixed order so a recoverable tool failure is never mistaken for a protocol failure, and vice versa. *A `tools/call` clears three checkpoints; each failure surfaces in a different field, so inspect them in order.* ## Layer 1 — HTTP and auth [#layer-1--http-and-auth] Authentication and origin checks run in the shared HTTP server, ahead of JSON-RPC parsing. When JWT auth is enabled and the `Authorization: Bearer` header is missing or invalid, the `/mcp` endpoint returns the JSON-RPC auth error `-32010` — the response is still valid JSON-RPC, so you read it the same way as any other error object: ```json title="Auth failure (-32010)" { "jsonrpc": "2.0", "id": 1, "error": { "code": -32010, "message": "authentication failed" } } ``` Origin validation can also reject the request before a tool runs. Auth and origin behavior are shared across all connectors and documented once in [Auth & security](/connectors/reference/auth-and-security); the MCP-specific `validateOrigin`/`TrustedOrigins` rules are covered in [Authentication](/aiway/mcp/guides/authentication). ## Layer 2 — JSON-RPC protocol errors [#layer-2--json-rpc-protocol-errors] Protocol errors mean the envelope itself was rejected: malformed JSON, the wrong `Content-Type`, an unknown method, or invalid params. They appear in the `error` field and there is **no `result`** field at all. ```json title="Protocol error (-32601)" { "jsonrpc": "2.0", "id": 1, "error": { "code": -32601, "message": "Method not found" } } ``` | Code | Trigger | | -------- | ---------------------------------------------------------------------------------------------- | | `-32700` | Malformed JSON body, or wrong `Content-Type` (e.g. `text/plain` instead of `application/json`) | | `-32600` | Empty `method` field, or `jsonrpc` is not `"2.0"` | | `-32601` | Unknown method name (e.g. `tools/unknown`) | | `-32602` | `params` is not an object, or required tool arguments are missing | Retrying an identical request after a protocol error fails the same way — correct the request instead of retrying. The full catalog, with response examples for each code, lives in the [Error codes reference](/aiway/mcp/reference/error-codes). ## Layer 3 — tool execution errors [#layer-3--tool-execution-errors] A tool error means the envelope was valid and a tool was invoked, but the operation failed. The response is structurally a **success** — HTTP 200, a populated `result` — with `isError: true` and a human-readable cause in `result.content[].text`. A client that only checks the `error` field treats this as a successful call, so always inspect `result.isError` as a second step. ```json title="Tool error (result.isError)" { "jsonrpc": "2.0", "id": 9, "result": { "content": [{ "type": "text", "text": "Command timed out: no subscriber on channel 'example-commands' within 10s" }], "isError": true } } ``` Common tool-level failures and the tools that raise them: | Failure | Affected tools | | ------------------------------------------------------------ | ------------------------------------------------------------------------------------ | | Reserved-channel rejection (channel starts with `_AGENTS_.`) | `queue_send`, `events_publish`, `events_store_publish`, `command_send`, `query_send` | | Non-existent agent | `agent_info`, `agent_send`, `agent_query` | | Timeout exceeded / no subscriber | `command_send`, `query_send`, `agent_send`, `agent_query` | | Non-existent channel | `channel_info` | Channels beginning with `_AGENTS_.` are reserved for the agent bridge, so any publish or request tool targeting one is rejected at the tool layer. To reach an agent, use the [agent-bridge tools](/aiway/mcp/tools/agent-bridge) (`agent_send`, `agent_query`) instead. ## Handling timeouts [#handling-timeouts] The synchronous tools — `command_send`, `query_send`, `agent_send`, `agent_query` — block until a responder replies or the timeout elapses. When no responder is listening, the call does **not** raise a protocol error; it returns a normal result with `isError: true` and a timeout message. Detect a timeout by reading `result.isError`, not by catching a transport exception. * The per-call timeout is the `timeout_seconds` argument (default 10 for commands, 30 for queries, 60 for the agent-bridge tools), capped at **300**. * The connector also enforces a server-side `ToolTimeoutSeconds` ceiling (default **300**) — see [Configuration](/aiway/mcp/configuration). * A timeout may be transient (the responder was briefly absent). It is one of the few tool errors that is reasonable to **retry** with backoff — unlike a reserved-channel or invalid-params error, which will fail identically. ## Detecting both layers in code [#detecting-both-layers-in-code] Branch in a fixed order: check the `error` field first, then `result.isError`. The example below calls `command_send` (which surfaces a timeout as a tool error) and inspects the result. Each snippet assumes an initialized MCP session — for the connect-and-initialize handshake, see [Client setup](/aiway/mcp/guides/client-setup). ```bash # The JSON-RPC envelope always returns HTTP 200. Inspect the body: # .error -> protocol error (no .result) # .result.isError -> tool execution error curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -d '{ "jsonrpc": "2.0", "id": 9, "method": "tools/call", "params": { "name": "command_send", "arguments": { "channel": "example-commands", "body": "do-work", "timeout_seconds": 10 } } }' ``` ```go result, err := c.CallTool(ctx, mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: "command_send", Arguments: map[string]any{ "channel": "example-commands", "body": "do-work", "timeout_seconds": 10, }, }, }) if err != nil { // Layer 1/2: transport or JSON-RPC protocol error — the tool never ran. log.Fatal(err) } // Layer 3: a valid call that failed at the tool layer (e.g. timeout). if result.IsError { fmt.Printf("tool error: %+v\n", result.Content) return } fmt.Printf("Result: %+v\n", result) ``` ```python result = await session.call_tool("command_send", { "channel": "example-commands", "body": "do-work", "timeout_seconds": 10, }) # Layer 3: tool execution error (timeout, reserved channel, ...). # Layer 1/2 protocol errors raise an exception before reaching here. if result.isError: print(f"tool error: {result.content[0].text}") else: print(f"Result: {result.content[0].text}") ``` ```typescript const result = await client.callTool({ name: "command_send", arguments: { channel: "example-commands", body: "do-work", timeout_seconds: 10, }, }); // Layer 3: inspect isError before trusting the content. if (result.isError) { console.error("tool error:", result.content); } else { console.log(JSON.stringify(result, null, 2)); } ``` ```java var result = client.callTool(new CallToolRequest( "command_send", Map.of( "channel", "example-commands", "body", "do-work", "timeout_seconds", 10 ) )); // Layer 3: a valid call that failed at the tool layer. if (Boolean.TRUE.equals(result.isError())) { System.out.println("tool error: " + result.content()); } else { System.out.println(result); } ``` ```csharp var result = await client.CallToolAsync("command_send", new Dictionary { ["channel"] = "example-commands", ["body"] = "do-work", ["timeout_seconds"] = 10, }); // Layer 3: tool execution error (e.g. timeout). if (result.IsError) { Console.WriteLine($"tool error: {result.Content}"); } else { Console.WriteLine($"Result: {result}"); } ``` ```kotlin val result = client.callTool("command_send", mapOf( "channel" to "example-commands", "body" to "do-work", "timeout_seconds" to 10 )) // Layer 3: inspect isError before using the content. if (result.isError == true) { println("tool error: ${result.content}") } else { println("Result: $result") } ``` ```ruby result = client.call_tool("command_send", { "channel" => "example-commands", "body" => "do-work", "timeout_seconds" => 10, }) # Layer 3: tool execution error (e.g. timeout). if result.is_error puts "tool error: #{result.content}" else puts "Result: #{result}" end ``` ```rust let result = client.call_tool("command_send", json!({ "channel": "example-commands", "body": "do-work", "timeout_seconds": 10 })).await?; // Layer 3: a valid call that failed at the tool layer. if result.is_error.unwrap_or(false) { eprintln!("tool error: {:#?}", result.content); } else { println!("Result: {result:#?}"); } ``` ```swift let result = try await client.callTool("command_send", arguments: [ "channel": "example-commands", "body": "do-work", "timeout_seconds": 10, ]) // Layer 3: inspect isError before trusting the content. if result.isError == true { print("tool error: \(result.content)") } else { print("Result: \(result)") } ``` A timeout is reported through `result.isError`, **not** as a JSON-RPC error and **not** as a non-200 HTTP status. Code that only catches transport exceptions or checks the status code will silently treat a timed-out command as a success. ## Best practices [#best-practices] 1. **Check `error` first, then `result.isError`.** A protocol error has no `result`; a tool error is structurally a success with `isError: true`. 2. **Do not branch on the HTTP status for JSON-RPC calls** — every response is HTTP 200 (auth/origin rejections excepted, which return `-32010` in the body). 3. **Retry only transient tool errors** (timeouts, no-subscriber). Protocol errors and reserved-channel/invalid-params errors fail identically on retry — fix the request instead. 4. **Log the full response** when debugging, not just the field you branched on. ## Related [#related] # Session Management (/aiway/mcp/guides/session-management) Every MCP interaction with the KubeMQ connector runs inside a **session**. The session is opened by an `initialize` handshake, identified by an `MCP-Session-Id` header, and reused across every tool call until the client disconnects. ## Overview [#overview] The MCP connector speaks JSON-RPC 2.0 over the shared HTTP server on port 9090. A single endpoint — `POST /mcp` — handles `initialize`, `tools/list`, `tools/call`, and `ping`. A companion `GET /mcp` endpoint provides an SSE keepalive stream. A session is **server-managed**: the server issues a session ID during `initialize`, and the client echoes it on every subsequent request. Multiple tool calls share one session, and each session keeps its own request context. You rarely build the handshake by hand — every official MCP SDK performs it for you when you connect. This page shows both: the raw protocol so you understand what travels on the wire, and the SDK call that establishes the session for you. The session model is part of the [shared HTTP server](/connectors/concepts/shared-http-server). For who may open a session and how auth applies, see [Auth & security](/connectors/reference/auth-and-security). ## How it works [#how-it-works] The handshake is three steps: the client sends `initialize`, the server returns its capabilities plus a session ID, and the client acknowledges with a `notifications/initialized` notification. After that, every request carries the `MCP-Session-Id` header. *The initialize handshake opens a session; the session ID is replayed on each later request.* ## The handshake [#the-handshake] ### Step 1 — initialize [#step-1--initialize] Send an `initialize` request with the protocol version, your capabilities, and `clientInfo`: ```json { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": { "name": "my-agent", "version": "1.0.0" } } } ``` ### Step 2 — receive the session ID [#step-2--receive-the-session-id] The server responds with its `protocolVersion`, `capabilities`, `serverInfo`, and the session ID under `result._meta.sessionId`: ```json { "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2025-11-25", "capabilities": { "tools": { "listChanged": false } }, "serverInfo": { "name": "kubemq", "version": "" }, "_meta": { "sessionId": "abc123-def456" } } } ``` The session ID is also returned in the `MCP-Session-Id` response header, alongside `MCP-Protocol-Version: 2025-11-25`. ### Step 3 — send the initialized notification [#step-3--send-the-initialized-notification] Acknowledge with a `notifications/initialized` notification. A notification has **no `id` field** and the server returns **HTTP 200** with body `{"jsonrpc":"2.0","result":null,"id":null}`: ```json { "jsonrpc": "2.0", "method": "notifications/initialized" } ``` ## Establishing a session [#establishing-a-session] Below, `curl` walks the raw three-step handshake; the SDK tabs perform the same handshake transparently when you connect, then reuse the session for every tool call. ```bash # 1. initialize — capture the session ID from the MCP-Session-Id response header curl -i -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"my-agent","version":"1.0.0"}}}' # 2. acknowledge — replay the session ID; server returns 200 with {"jsonrpc":"2.0","result":null,"id":null} curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'MCP-Session-Id: abc123-def456' \ -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' # 3. call a tool inside the session curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'MCP-Session-Id: abc123-def456' \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"queue_send","arguments":{"channel":"example-queue","body":"Hello"}}}' ``` ```csharp using ModelContextProtocol.Client; var url = Environment.GetEnvironmentVariable("KUBEMQ_MCP_URL") ?? "http://localhost:9090"; var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{url}/mcp") }); // CreateAsync runs the initialize handshake and holds the session for reuse. await using var client = await McpClientFactory.CreateAsync(transport); // Every later call rides the same session. var result = await client.CallToolAsync("queue_send", new Dictionary { ["channel"] = "example-queue", ["body"] = "Hello from C# MCP", }); Console.WriteLine($"Result: {result}"); ``` ```go package main import ( "context" "log" "os" "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/mcp" ) func main() { url := os.Getenv("KUBEMQ_MCP_URL") if url == "" { url = "http://localhost:9090" } c, err := client.NewStreamableHttpClient(url + "/mcp") if err != nil { log.Fatal(err) } defer c.Close() ctx := context.Background() // Start performs the initialize handshake and binds the session. if err := c.Start(ctx); err != nil { log.Fatal(err) } // The same session is reused for every CallTool. _, err = c.CallTool(ctx, mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: "queue_send", Arguments: map[string]any{"channel": "example-queue", "body": "Hello from Go MCP"}, }, }) if err != nil { log.Fatal(err) } } ``` ```java import io.modelcontextprotocol.sdk.McpClient; import io.modelcontextprotocol.sdk.client.transport.HttpClientStreamableHttpTransport; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import java.util.Map; public class Session { public static void main(String[] args) { String url = System.getenv().getOrDefault("KUBEMQ_MCP_URL", "http://localhost:9090"); var transport = HttpClientStreamableHttpTransport.builder(url).endpoint("/mcp").build(); var client = McpClient.sync(transport).build(); // initialize() performs the handshake and opens the session. client.initialize(); // The client reuses the session for each call. var result = client.callTool(new CallToolRequest( "queue_send", Map.of("channel", "example-queue", "body", "Hello from Java MCP") )); System.out.println(result); client.closeGracefully(); } } ``` ```kotlin import io.modelcontextprotocol.kotlin.sdk.Implementation import io.modelcontextprotocol.kotlin.sdk.client.Client import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport import io.ktor.client.* import io.ktor.client.plugins.sse.* import kotlinx.coroutines.runBlocking fun main() = runBlocking { val url = System.getenv("KUBEMQ_MCP_URL") ?: "http://localhost:9090" val httpClient = HttpClient { install(SSE) } val transport = StreamableHttpClientTransport(client = httpClient, url = "$url/mcp") val client = Client(clientInfo = Implementation(name = "kubemq-mcp-kotlin", version = "1.0.0")) // connect() runs the initialize handshake and holds the session. client.connect(transport) client.callTool("queue_send", mapOf("channel" to "example-queue", "body" to "Hello from Kotlin MCP")) client.close() httpClient.close() } ``` ```python import asyncio import os from mcp.client.streamable_http import streamablehttp_client from mcp import ClientSession KUBEMQ_MCP_URL = os.environ.get("KUBEMQ_MCP_URL", "http://localhost:9090") async def main(): async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _): async with ClientSession(read, write) as session: # initialize() performs the 3-step handshake and stores the session ID. await session.initialize() # Every call_tool on this session replays the same MCP-Session-Id. result = await session.call_tool("queue_send", { "channel": "example-queue", "body": "Hello from Python MCP", }) print(f"IsError: {result.isError}") if __name__ == "__main__": asyncio.run(main()) ``` ```ruby require "mcp" url = ENV.fetch("KUBEMQ_MCP_URL", "http://localhost:9090") client = MCP::Client.new( transport: MCP::Transport::StreamableHTTP.new("#{url}/mcp"), name: "kubemq-mcp-ruby", version: "1.0.0" ) # initialize_handshake performs the handshake and opens the session. client.initialize_handshake # The session is reused for each call_tool. result = client.call_tool("queue_send", { "channel" => "example-queue", "body" => "Hello from Ruby MCP", }) puts "Result: #{result}" client.close ``` ```rust use rmcp::transport::streamable_http::StreamableHttpClientTransport; use rmcp::service::RunService; use serde_json::json; #[tokio::main] async fn main() -> anyhow::Result<()> { let url = std::env::var("KUBEMQ_MCP_URL") .unwrap_or_else(|_| "http://localhost:9090".to_string()); let transport = StreamableHttpClientTransport::from_uri(format!("{url}/mcp"))?; // serve() runs the initialize handshake and binds the session. let client = ().serve(transport).await?; // The client reuses the session for each call. let _ = client.call_tool("queue_send", json!({ "channel": "example-queue", "body": "Hello from Rust MCP" })).await?; Ok(()) } ``` ```swift import Foundation import MCP let url = ProcessInfo.processInfo.environment["KUBEMQ_MCP_URL"] ?? "http://localhost:9090" let transport = HTTPClientTransport(endpoint: URL(string: "\(url)/mcp")!, streaming: true) let client = Client(name: "kubemq-mcp-swift", version: "1.0.0") // connect() performs the initialize handshake and opens the session. try await client.connect(transport: transport) // The session is reused for each callTool. let result = try await client.callTool("queue_send", arguments: [ "channel": "example-queue", "body": "Hello from Swift MCP", ]) print("Result: \(result)") ``` ```typescript import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const KUBEMQ_MCP_URL = process.env.KUBEMQ_MCP_URL || "http://localhost:9090"; const transport = new StreamableHTTPClientTransport(new URL(`${KUBEMQ_MCP_URL}/mcp`)); const client = new Client({ name: "kubemq-mcp-ts", version: "1.0.0" }); // connect() runs the initialize handshake and holds the session for reuse. await client.connect(transport); // Every callTool on this client rides the same session. const result = await client.callTool({ name: "queue_send", arguments: { channel: "example-queue", body: "Hello from TypeScript MCP" }, }); console.log(JSON.stringify(result, null, 2)); await client.close(); ``` ## Session headers [#session-headers] | Header | Direction | Required | Description | | ---------------------- | --------- | ------------------ | ------------------------------------------------- | | `Content-Type` | Request | Always | Must be `application/json` | | `MCP-Session-Id` | Request | After `initialize` | Session identifier from the `initialize` response | | `MCP-Session-Id` | Response | Always | Echoed back by the server | | `MCP-Protocol-Version` | Response | Always | Protocol version `2025-11-25` | ## Session lifecycle [#session-lifecycle] * Sessions are **server-managed** — the server mints the session ID during `initialize`. * **Multiple tool calls share one session**; each maintains its own request context. * A session persists until the client disconnects or a server-side inactivity timeout occurs. * Reusing the connection (and the `MCP-Session-Id`) avoids re-running the handshake on every call. ## Batch requests [#batch-requests] `POST /mcp` accepts a **JSON array** of JSON-RPC requests and processes each one **sequentially**. Requests carrying an `id` produce a response entry; notifications (no `id`) are executed but produce no entry. Send the batch with the same `MCP-Session-Id` as any single request. ```bash # Two tool calls in a single batched POST — one response entry per id. curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'MCP-Session-Id: abc123-def456' \ -d '[ {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"queue_send","arguments":{"channel":"a","body":"one"}}}, {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"queue_send","arguments":{"channel":"b","body":"two"}}} ]' ``` ```python # SDKs typically issue calls individually over one session rather than # constructing a raw JSON-RPC array. The session is reused for each call: await session.call_tool("queue_send", {"channel": "a", "body": "one"}) await session.call_tool("queue_send", {"channel": "b", "body": "two"}) ``` ```typescript // SDKs reuse the open session per call; the connector batches at the HTTP // layer when a raw JSON-RPC array is posted. await client.callTool({ name: "queue_send", arguments: { channel: "a", body: "one" } }); await client.callTool({ name: "queue_send", arguments: { channel: "b", body: "two" } }); ``` ## Keepalive stream — GET /mcp [#keepalive-stream--get-mcp] `GET /mcp` opens an SSE stream that emits a `: keepalive` comment every **30 seconds**. It is a **stateless keepalive only** — no MCP messages travel over it, and the stream closes when the client disconnects. Use it to hold a long-lived connection open through intermediaries; all real work still goes through `POST /mcp`. ```bash # Hold an SSE keepalive open (a ": keepalive" comment arrives every 30s) curl -N http://localhost:9090/mcp ``` ## Related [#related] # Agent-bridge tools (/aiway/mcp/tools/agent-bridge) The four **agent-bridge tools** turn an MCP client into an [A2A](/aiway/a2a) caller: it can list registered agents, read an agent's card, and send messages or forward JSON-RPC methods to an agent — all without leaving the Model Context Protocol. ## Overview [#overview] The MCP connector exposes 15 tools. Eleven are core messaging tools that always reach the broker directly. The remaining four — `agent_list`, `agent_info`, `agent_send`, and `agent_query` — are **bridge tools**: they forward to the A2A [agent registry](/aiway/a2a) instead of to a messaging channel. | Tool | Purpose | Required arguments | Optional arguments | | ------------- | ------------------------------------- | --------------------- | -------------------------------------------------------------------------------------- | | `agent_list` | List registered agents | (none) | `skill_tags` (array of strings) | | `agent_info` | Get an agent's details | `agent_id` | (none) | | `agent_send` | Send a message to an agent via A2A | `agent_id`, `message` | `blocking` (default `true`), `timeout_seconds` (default `60`, max `300`), `context_id` | | `agent_query` | Forward a JSON-RPC method to an agent | `agent_id`, `method` | `params` (object), `timeout_seconds` (default `60`, max `300`) | The bridge tools appear in `tools/list` **only when the A2A agent registry is injected** into the MCP connector. If the A2A connector is not running, an MCP client sees only the 11 core tools. See [Tools overview](/aiway/mcp/tools). ## How it works [#how-it-works] `agent_send` builds an A2A `message/send` JSON-RPC envelope and forwards it over a Query to `_AGENTS_.agents/`, where the agent's [virtual subscriber](/aiway/a2a) delivers it as an HTTP POST. `agent_query` forwards an arbitrary JSON-RPC method to the same destination. Both add the gateway timeout buffer on top of the caller's `timeout_seconds`. *Bridge tools route discovery to the registry and messages to the agent's internal channel, which a virtual subscriber delivers over HTTP.* ## agent\_list [#agent_list] Lists every registered agent. Pass `skill_tags` to filter agents whose [agent card](/aiway/a2a/agent-cards) advertises matching skill tags. ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -d '{ "jsonrpc": "2.0", "id": 13, "method": "tools/call", "params": { "name": "agent_list", "arguments": {} } }' ``` ```go url := os.Getenv("KUBEMQ_MCP_URL") if url == "" { url = "http://localhost:9090" } c, err := client.NewStreamableHttpClient(url + "/mcp") if err != nil { log.Fatal(err) } defer c.Close() ctx := context.Background() if err := c.Start(ctx); err != nil { log.Fatal(err) } result, err := c.CallTool(ctx, mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: "agent_list", Arguments: map[string]any{}, }, }) if err != nil { log.Fatal(err) } fmt.Println("Tool: agent_list") fmt.Printf("Result: %+v\n", result) ``` ```python KUBEMQ_MCP_URL = os.environ.get("KUBEMQ_MCP_URL", "http://localhost:9090") async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() # List all agents (no filter) result = await session.call_tool("agent_list", {}) print(f"Tool: agent_list") print(f"IsError: {result.isError}") for content in result.content: print(f"Result: {content.text}") # To filter by skill tags, use: {"skill_tags": ["echo"]} ``` ```typescript const KUBEMQ_MCP_URL = process.env.KUBEMQ_MCP_URL || "http://localhost:9090"; const transport = new StreamableHTTPClientTransport( new URL(`${KUBEMQ_MCP_URL}/mcp`) ); const client = new Client({ name: "kubemq-mcp-ts-example", version: "1.0.0" }); await client.connect(transport); // List all agents (no filter) const result = await client.callTool({ name: "agent_list", arguments: {}, }); console.log(JSON.stringify(result, null, 2)); // To filter by skill tags: { skill_tags: ["echo"] } await client.close(); ``` ```java String url = System.getenv().getOrDefault("KUBEMQ_MCP_URL", "http://localhost:9090"); var transport = HttpClientStreamableHttpTransport.builder(url).endpoint("/mcp").build(); var client = McpClient.sync(transport).build(); client.initialize(); // List all agents (no filter) var result = client.callTool(new CallToolRequest( "agent_list", Map.of() )); System.out.println(result); // To filter by skill tags: Map.of("skill_tags", List.of("echo")) client.closeGracefully(); ``` ```csharp var url = Environment.GetEnvironmentVariable("KUBEMQ_MCP_URL") ?? "http://localhost:9090"; var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{url}/mcp") }); await using var client = await McpClientFactory.CreateAsync(transport); var result = await client.CallToolAsync("agent_list", new Dictionary()); Console.WriteLine($"Tool: agent_list"); Console.WriteLine($"Result: {result}"); ``` ```kotlin val url = System.getenv("KUBEMQ_MCP_URL") ?: "http://localhost:9090" val httpClient = HttpClient { install(SSE) } val transport = StreamableHttpClientTransport(client = httpClient, url = "$url/mcp") val client = Client(clientInfo = Implementation(name = "kubemq-mcp-kotlin-example", version = "1.0.0")) client.connect(transport) val result = client.callTool("agent_list", emptyMap()) println("Tool: agent_list") println("Result: $result") client.close() httpClient.close() ``` ```ruby url = ENV.fetch("KUBEMQ_MCP_URL", "http://localhost:9090") client = MCP::Client.new( transport: MCP::Transport::StreamableHTTP.new("#{url}/mcp"), name: "kubemq-mcp-ruby-example", version: "1.0.0" ) client.initialize_handshake result = client.call_tool("agent_list", {}) puts "Tool: agent_list" puts "Result: #{result}" client.close ``` ```rust let url = std::env::var("KUBEMQ_MCP_URL") .unwrap_or_else(|_| "http://localhost:9090".to_string()); let transport = StreamableHttpClientTransport::from_uri(format!("{url}/mcp"))?; let client = ().serve(transport).await?; let result = client.call_tool("agent_list", json!({})).await?; println!("Tool: agent_list"); println!("Result: {result:#?}"); ``` ```swift let url = ProcessInfo.processInfo.environment["KUBEMQ_MCP_URL"] ?? "http://localhost:9090" let transport = HTTPClientTransport(endpoint: URL(string: "\(url)/mcp")!, streaming: true) let client = Client(name: "kubemq-mcp-swift-example", version: "1.0.0") try await client.connect(transport: transport) let result = try await client.callTool("agent_list", arguments: [:]) print("Tool: agent_list") print("Result: \(result)") ``` The result text is a JSON array of agent summaries: ```json { "jsonrpc": "2.0", "id": 13, "result": { "content": [{ "type": "text", "text": "[{\"agent_id\":\"echo-01\",\"name\":\"Echo Agent 01\",\"skills\":[{\"id\":\"echo\",\"name\":\"Echo\",\"tags\":[\"test\",\"echo\"]}]}]" }], "isError": false } } ``` ## agent\_info [#agent_info] Returns the full [agent card](/aiway/a2a/agent-cards) for one agent — name, description, version, URL, and skills. ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -d '{ "jsonrpc": "2.0", "id": 14, "method": "tools/call", "params": { "name": "agent_info", "arguments": { "agent_id": "example-agent" } } }' ``` ```go result, err := c.CallTool(ctx, mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: "agent_info", Arguments: map[string]any{ "agent_id": "example-agent", }, }, }) if err != nil { log.Fatal(err) } fmt.Println("Tool: agent_info") fmt.Printf("Result: %+v\n", result) ``` ```python result = await session.call_tool("agent_info", { "agent_id": "example-agent", }) print(f"Tool: agent_info") print(f"IsError: {result.isError}") for content in result.content: print(f"Result: {content.text}") ``` ```typescript const result = await client.callTool({ name: "agent_info", arguments: { agent_id: "example-agent", }, }); console.log(JSON.stringify(result, null, 2)); ``` ```java var result = client.callTool(new CallToolRequest( "agent_info", Map.of("agent_id", "example-agent") )); System.out.println(result); ``` ```csharp var result = await client.CallToolAsync("agent_info", new Dictionary { ["agent_id"] = "example-agent", }); Console.WriteLine($"Tool: agent_info"); Console.WriteLine($"Result: {result}"); ``` ```kotlin val result = client.callTool("agent_info", mapOf( "agent_id" to "example-agent" )) println("Tool: agent_info") println("Result: $result") ``` ```ruby result = client.call_tool("agent_info", { "agent_id" => "example-agent", }) puts "Tool: agent_info" puts "Result: #{result}" ``` ```rust let result = client.call_tool("agent_info", json!({ "agent_id": "example-agent" })).await?; println!("Tool: agent_info"); println!("Result: {result:#?}"); ``` ```swift let result = try await client.callTool("agent_info", arguments: [ "agent_id": "example-agent", ]) print("Tool: agent_info") print("Result: \(result)") ``` A successful call returns the agent card as a JSON string; an unknown `agent_id` returns `isError: true` with `Agent 'example-agent' not found`: ```json { "jsonrpc": "2.0", "id": 14, "result": { "content": [{ "type": "text", "text": "{\"agent_id\":\"example-agent\",\"name\":\"Example Agent\",\"description\":\"example agent\",\"version\":\"1.0.0\"}" }], "isError": false } } ``` ## agent\_send [#agent_send] Sends a message to an agent. The bridge wraps it in an A2A `message/send` envelope. By default the call is **blocking** — it waits up to `timeout_seconds` for the agent's reply. Pass `blocking: false` for fire-and-forget, or `context_id` to thread the message into an existing conversation. ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -d '{ "jsonrpc": "2.0", "id": 15, "method": "tools/call", "params": { "name": "agent_send", "arguments": { "agent_id": "example-agent", "message": "hello from MCP" } } }' ``` ```go result, err := c.CallTool(ctx, mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: "agent_send", Arguments: map[string]any{ "agent_id": "example-agent", "message": "hello", }, }, }) if err != nil { log.Fatal(err) } fmt.Println("Tool: agent_send") fmt.Printf("Result: %+v\n", result) ``` ```python result = await session.call_tool("agent_send", { "agent_id": "example-agent", "message": "hello from MCP", }) print(f"Tool: agent_send") print(f"IsError: {result.isError}") for content in result.content: print(f"Result: {content.text}") ``` ```typescript const result = await client.callTool({ name: "agent_send", arguments: { agent_id: "example-agent", message: "hello from MCP", }, }); console.log(JSON.stringify(result, null, 2)); ``` ```java var result = client.callTool(new CallToolRequest( "agent_send", Map.of( "agent_id", "example-agent", "message", "hello from MCP" ) )); System.out.println(result); ``` ```csharp var result = await client.CallToolAsync("agent_send", new Dictionary { ["agent_id"] = "example-agent", ["message"] = "hello", }); Console.WriteLine($"Tool: agent_send"); Console.WriteLine($"Result: {result}"); ``` ```kotlin val result = client.callTool("agent_send", mapOf( "agent_id" to "example-agent", "message" to "hello" )) println("Tool: agent_send") println("Result: $result") ``` ```ruby result = client.call_tool("agent_send", { "agent_id" => "example-agent", "message" => "hello from MCP", }) puts "Tool: agent_send" puts "Result: #{result}" ``` ```rust let result = client.call_tool("agent_send", json!({ "agent_id": "example-agent", "message": "hello from MCP" })).await?; println!("Tool: agent_send"); println!("Result: {result:#?}"); ``` ```swift let result = try await client.callTool("agent_send", arguments: [ "agent_id": "example-agent", "message": "hello", ]) print("Tool: agent_send") print("Result: \(result)") ``` The agent's reply is returned in the `content` text. If the agent is not registered, the call returns `isError: true`: ```json { "jsonrpc": "2.0", "id": 15, "result": { "content": [{ "type": "text", "text": "{\"echo\":{\"method\":\"message/send\",\"params\":{\"message\":\"hello from MCP\"}},\"received_headers\":{}}" }], "isError": false } } ``` ## agent\_query [#agent_query] Forwards an arbitrary JSON-RPC `method` to an agent — useful for A2A methods beyond `message/send`, such as `tasks/get`. Pass a `params` object to supply method arguments. ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -d '{ "jsonrpc": "2.0", "id": 16, "method": "tools/call", "params": { "name": "agent_query", "arguments": { "agent_id": "example-agent", "method": "tasks/get" } } }' ``` ```go result, err := c.CallTool(ctx, mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: "agent_query", Arguments: map[string]any{ "agent_id": "example-agent", "method": "tasks/get", }, }, }) if err != nil { log.Fatal(err) } fmt.Println("Tool: agent_query") fmt.Printf("Result: %+v\n", result) ``` ```python result = await session.call_tool("agent_query", { "agent_id": "example-agent", "method": "tasks/get", }) print(f"Tool: agent_query") print(f"IsError: {result.isError}") for content in result.content: print(f"Result: {content.text}") ``` ```typescript const result = await client.callTool({ name: "agent_query", arguments: { agent_id: "example-agent", method: "tasks/get", }, }); console.log(JSON.stringify(result, null, 2)); ``` ```java var result = client.callTool(new CallToolRequest( "agent_query", Map.of( "agent_id", "example-agent", "method", "tasks/get" ) )); System.out.println(result); ``` ```csharp var result = await client.CallToolAsync("agent_query", new Dictionary { ["agent_id"] = "example-agent", ["method"] = "tasks/get", }); Console.WriteLine($"Tool: agent_query"); Console.WriteLine($"Result: {result}"); ``` ```kotlin val result = client.callTool("agent_query", mapOf( "agent_id" to "example-agent", "method" to "tasks/get" )) println("Tool: agent_query") println("Result: $result") ``` ```ruby result = client.call_tool("agent_query", { "agent_id" => "example-agent", "method" => "tasks/get", }) puts "Tool: agent_query" puts "Result: #{result}" ``` ```rust let result = client.call_tool("agent_query", json!({ "agent_id": "example-agent", "method": "tasks/get" })).await?; println!("Tool: agent_query"); println!("Result: {result:#?}"); ``` ```swift let result = try await client.callTool("agent_query", arguments: [ "agent_id": "example-agent", "method": "tasks/get", ]) print("Tool: agent_query") print("Result: \(result)") ``` ```json { "jsonrpc": "2.0", "id": 16, "result": { "content": [{ "type": "text", "text": "{\"echo\":{\"method\":\"tasks/get\",\"params\":{}},\"received_headers\":{}}" }], "isError": false } } ``` ## Errors [#errors] A missing or unknown `agent_id` returns a tool-level error — `isError: true` with the message in the `content` block — not a JSON-RPC protocol error: ```json { "jsonrpc": "2.0", "id": 15, "result": { "content": [{ "type": "text", "text": "Agent 'example-agent' not found" }], "isError": true } } ``` The bridge applies the gateway timeout buffer on top of the caller's `timeout_seconds` (default `60`, max `300`). See [Error handling](/aiway/mcp/guides/error-handling) for the three failure layers and [Error codes](/aiway/mcp/reference/error-codes) for the catalog. ## Related [#related] # Channel Management Tools (/aiway/mcp/tools/channel-management) The channel-management tools let an AI model **discover what messaging surfaces exist** on a KubeMQ server before it sends, publishes, or queries anything. `channel_list` enumerates channels (optionally filtered), and `channel_info` returns live metadata for one channel. ## Overview [#overview] Most MCP tools act on a channel you already know — `queue_send` needs a queue name, `events_publish` needs an events channel. The two **read-only** channel-management tools close that gap: they let the model explore the broker's namespace and confirm a channel's type and activity before acting on it. | Tool | Purpose | Required arguments | | -------------- | ---------------------------------------------------------- | ------------------ | | `channel_list` | List channels, optionally filtered by type or name pattern | *(none)* | | `channel_info` | Return metadata for one specific channel | `channel`, `type` | A **channel type** is one of `queues`, `events`, `events_store`, `commands`, or `queries` — the five KubeMQ messaging patterns. Both tools accept the type to scope the lookup. Reserved channels (those under the `_AGENTS_.` prefix used by the agent bridge) are internal and are not addressable through these tools. Both tools are **read-only discovery operations** — they never create, delete, or modify a channel. Channels in KubeMQ are created implicitly on first use, so `channel_list` reflects channels that already carry traffic. ## channel\_list [#channel_list] List all channels on the server, or narrow the result by channel `type`, by a name `pattern`, or both. With no arguments it returns every known channel. ### Input schema [#input-schema] ```json { "type": "object", "required": [], "properties": { "type": { "type": "string", "description": "Filter by channel type (queues, events, events_store, commands, queries)." }, "pattern": { "type": "string", "description": "Filter by channel name pattern or prefix." } } } ``` | Argument | Type | Required | Default | Description | | --------- | ------ | -------- | ------- | --------------------------------------------------------------------------------- | | `type` | string | no | — | Filter by channel type: `queues`, `events`, `events_store`, `commands`, `queries` | | `pattern` | string | no | — | Filter by channel name pattern or prefix | ### Output [#output] The tool result wraps a JSON **array** of channel summaries in the standard MCP `content[]`/`isError` envelope. Each element carries the channel `name`, `type`, and `is_active` flag. ```json { "jsonrpc": "2.0", "id": 11, "result": { "content": [ { "type": "text", "text": "[{\"name\":\"example-queue\",\"type\":\"queues\",\"is_active\":true},{\"name\":\"example-events\",\"type\":\"events\",\"is_active\":true}]" } ], "isError": false } } ``` An empty list (`[]`) is a normal successful result, not an error. ### Usage [#usage] ```bash # List all channels (no filter) curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H 'MCP-Protocol-Version: 2025-11-25' \ -d '{ "jsonrpc": "2.0", "id": 11, "method": "tools/call", "params": { "name": "channel_list", "arguments": {} } }' # Filter by type # "arguments": { "type": "queues" } # Filter by name pattern # "arguments": { "pattern": "example-" } ``` ```go url := os.Getenv("KUBEMQ_MCP_URL") if url == "" { url = "http://localhost:9090" } c, err := client.NewStreamableHttpClient(url + "/mcp") if err != nil { log.Fatal(err) } defer c.Close() ctx := context.Background() if err := c.Start(ctx); err != nil { log.Fatal(err) } result, err := c.CallTool(ctx, mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: "channel_list", Arguments: map[string]any{}, }, }) if err != nil { log.Fatal(err) } fmt.Printf("Result: %+v\n", result) ``` ```python async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() # List all channels (no filter) result = await session.call_tool("channel_list", {}) print(f"IsError: {result.isError}") for content in result.content: print(f"Result: {content.text}") # To filter by type, use: {"type": "queues"} # To filter by pattern, use: {"pattern": "example-"} ``` ```typescript const transport = new StreamableHTTPClientTransport( new URL(`${KUBEMQ_MCP_URL}/mcp`) ); const client = new Client({ name: "kubemq-mcp-ts-example", version: "1.0.0" }); await client.connect(transport); // List all channels (no filter) const result = await client.callTool({ name: "channel_list", arguments: {}, }); console.log(JSON.stringify(result, null, 2)); // To filter by type: { type: "queues" } // To filter by pattern: { pattern: "example-" } await client.close(); ``` ```java String url = System.getenv().getOrDefault("KUBEMQ_MCP_URL", "http://localhost:9090"); var transport = HttpClientStreamableHttpTransport.builder(url).endpoint("/mcp").build(); var client = McpClient.sync(transport).build(); client.initialize(); // List all channels (no filter) var result = client.callTool(new CallToolRequest( "channel_list", Map.of() )); System.out.println(result); // To filter by type: Map.of("type", "queues") // To filter by pattern: Map.of("pattern", "example-") client.closeGracefully(); ``` ```csharp var url = Environment.GetEnvironmentVariable("KUBEMQ_MCP_URL") ?? "http://localhost:9090"; var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{url}/mcp") }); await using var client = await McpClientFactory.CreateAsync(transport); var result = await client.CallToolAsync("channel_list", new Dictionary()); Console.WriteLine($"Result: {result}"); ``` ```kotlin val url = System.getenv("KUBEMQ_MCP_URL") ?: "http://localhost:9090" val httpClient = HttpClient { install(SSE) } val transport = StreamableHttpClientTransport(client = httpClient, url = "$url/mcp") val client = Client(clientInfo = Implementation(name = "kubemq-mcp-kotlin-example", version = "1.0.0")) client.connect(transport) val result = client.callTool("channel_list", emptyMap()) println("Result: $result") client.close() httpClient.close() ``` ```ruby url = ENV.fetch("KUBEMQ_MCP_URL", "http://localhost:9090") client = MCP::Client.new( transport: MCP::Transport::StreamableHTTP.new("#{url}/mcp"), name: "kubemq-mcp-ruby-example", version: "1.0.0" ) client.initialize_handshake result = client.call_tool("channel_list", {}) puts "Result: #{result}" client.close ``` ```rust let url = std::env::var("KUBEMQ_MCP_URL") .unwrap_or_else(|_| "http://localhost:9090".to_string()); let transport = StreamableHttpClientTransport::from_uri(format!("{url}/mcp"))?; let client = ().serve(transport).await?; let result = client.call_tool("channel_list", json!({})).await?; println!("Result: {result:#?}"); ``` ```swift let url = ProcessInfo.processInfo.environment["KUBEMQ_MCP_URL"] ?? "http://localhost:9090" let transport = HTTPClientTransport(endpoint: URL(string: "\(url)/mcp")!, streaming: true) let client = Client(name: "kubemq-mcp-swift-example", version: "1.0.0") try await client.connect(transport: transport) let result = try await client.callTool("channel_list", arguments: [:]) print("Result: \(result)") ``` ## channel\_info [#channel_info] Return metadata for a single channel. Both `channel` (the name) and `type` are required — the type scopes the lookup to the right messaging pattern. ### Input schema [#input-schema-1] ```json { "type": "object", "required": ["channel", "type"], "properties": { "channel": { "type": "string", "description": "Channel name to get information for." }, "type": { "type": "string", "description": "Channel type (queues, events, events_store, commands, queries)." } } } ``` | Argument | Type | Required | Default | Description | | --------- | ------ | -------- | ------- | ----------------------------------------------------------------------- | | `channel` | string | yes | — | Channel name to inspect | | `type` | string | yes | — | Channel type: `queues`, `events`, `events_store`, `commands`, `queries` | ### Output [#output-1] The result wraps a single JSON **object** describing the channel. Alongside `name`, `type`, and `is_active`, queue-style channels report live `incoming`/`outgoing` message counts. ```json { "jsonrpc": "2.0", "id": 12, "result": { "content": [ { "type": "text", "text": "{\"name\":\"example-queue\",\"type\":\"queues\",\"is_active\":true,\"incoming\":5,\"outgoing\":3}" } ], "isError": false } } ``` Requesting a channel that does not exist returns a tool error (`isError: true`) rather than a transport-level failure. ### Usage [#usage-1] ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H 'MCP-Protocol-Version: 2025-11-25' \ -d '{ "jsonrpc": "2.0", "id": 12, "method": "tools/call", "params": { "name": "channel_info", "arguments": { "channel": "example-queue", "type": "queues" } } }' ``` ```go url := os.Getenv("KUBEMQ_MCP_URL") if url == "" { url = "http://localhost:9090" } c, err := client.NewStreamableHttpClient(url + "/mcp") if err != nil { log.Fatal(err) } defer c.Close() ctx := context.Background() if err := c.Start(ctx); err != nil { log.Fatal(err) } result, err := c.CallTool(ctx, mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: "channel_info", Arguments: map[string]any{ "channel": "example-queue", "type": "queues", }, }, }) if err != nil { log.Fatal(err) } fmt.Printf("Result: %+v\n", result) ``` ```python async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool("channel_info", { "channel": "example-queue", "type": "queues", }) print(f"IsError: {result.isError}") for content in result.content: print(f"Result: {content.text}") ``` ```typescript const transport = new StreamableHTTPClientTransport( new URL(`${KUBEMQ_MCP_URL}/mcp`) ); const client = new Client({ name: "kubemq-mcp-ts-example", version: "1.0.0" }); await client.connect(transport); const result = await client.callTool({ name: "channel_info", arguments: { channel: "example-queue", type: "queues", }, }); console.log(JSON.stringify(result, null, 2)); await client.close(); ``` ```java String url = System.getenv().getOrDefault("KUBEMQ_MCP_URL", "http://localhost:9090"); var transport = HttpClientStreamableHttpTransport.builder(url).endpoint("/mcp").build(); var client = McpClient.sync(transport).build(); client.initialize(); var result = client.callTool(new CallToolRequest( "channel_info", Map.of( "channel", "example-queue", "type", "queues" ) )); System.out.println(result); client.closeGracefully(); ``` ```csharp var url = Environment.GetEnvironmentVariable("KUBEMQ_MCP_URL") ?? "http://localhost:9090"; var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{url}/mcp") }); await using var client = await McpClientFactory.CreateAsync(transport); var result = await client.CallToolAsync("channel_info", new Dictionary { ["channel"] = "example-queue", ["type"] = "queues", }); Console.WriteLine($"Result: {result}"); ``` ```kotlin val url = System.getenv("KUBEMQ_MCP_URL") ?: "http://localhost:9090" val httpClient = HttpClient { install(SSE) } val transport = StreamableHttpClientTransport(client = httpClient, url = "$url/mcp") val client = Client(clientInfo = Implementation(name = "kubemq-mcp-kotlin-example", version = "1.0.0")) client.connect(transport) val result = client.callTool("channel_info", mapOf( "channel" to "example-queue", "type" to "queues" )) println("Result: $result") client.close() httpClient.close() ``` ```ruby url = ENV.fetch("KUBEMQ_MCP_URL", "http://localhost:9090") client = MCP::Client.new( transport: MCP::Transport::StreamableHTTP.new("#{url}/mcp"), name: "kubemq-mcp-ruby-example", version: "1.0.0" ) client.initialize_handshake result = client.call_tool("channel_info", { "channel" => "example-queue", "type" => "queues", }) puts "Result: #{result}" client.close ``` ```rust let url = std::env::var("KUBEMQ_MCP_URL") .unwrap_or_else(|_| "http://localhost:9090".to_string()); let transport = StreamableHttpClientTransport::from_uri(format!("{url}/mcp"))?; let client = ().serve(transport).await?; let result = client.call_tool("channel_info", json!({ "channel": "example-queue", "type": "queues" })).await?; println!("Result: {result:#?}"); ``` ```swift let url = ProcessInfo.processInfo.environment["KUBEMQ_MCP_URL"] ?? "http://localhost:9090" let transport = HTTPClientTransport(endpoint: URL(string: "\(url)/mcp")!, streaming: true) let client = Client(name: "kubemq-mcp-swift-example", version: "1.0.0") try await client.connect(transport: transport) let result = try await client.callTool("channel_info", arguments: [ "channel": "example-queue", "type": "queues", ]) print("Result: \(result)") ``` ## Error handling [#error-handling] | Condition | Result | | ---------------------------------------- | ------------------------------------------ | | Invalid or malformed arguments | JSON-RPC `-32602` Invalid Params | | `channel_info` on a non-existent channel | Tool error — `isError: true` in the result | | `channel_list` with no matches | Successful result with an empty array `[]` | `channel_list` has no tool-specific failures: an empty list is a normal, successful response. For the full JSON-RPC error catalog, see [Error codes](/aiway/mcp/reference/error-codes). ## Related [#related] # Command & Query Tools (/aiway/mcp/tools/commands-queries) The `command_send` and `query_send` tools let an MCP client make **synchronous request/reply** calls into KubeMQ. A command triggers an action and waits for an acknowledgement; a query asks for data and waits for a response payload. ## Overview [#overview] Both tools are part of the [11 core messaging tools](/aiway/mcp/tools) — they are always available at `/mcp`, no agent registry required. Each maps to one KubeMQ Request/Reply operation: * **`command_send`** — sends a command to a channel and blocks until a responder acknowledges it (success or error). Use it for *mutating* actions where you only need to know whether the work was accepted. * **`query_send`** — sends a query to a channel and blocks until a responder returns a payload. Use it for *read* operations that produce data the model needs back. Both are synchronous: the connector holds the `tools/call` open until the responder replies or the timeout elapses. They require an **active subscriber** on the target channel — with no responder, the call times out and returns an error result. ## How it works [#how-it-works] The connector translates the tool call into a native KubeMQ command or query, routes it to a responder over the [Array](/connectors), waits for the reply, and hands the result back inside the `tools/call` response. *Both tools hold the `tools/call` open for the round trip; a query returns data, a command returns an acknowledgement.* ## Input schema [#input-schema] Both tools share the same core arguments. The only difference is the default timeout. | Argument | Type | Required | Default | Description | | ----------------- | ------- | -------- | ----------------------------- | ------------------------------------------------------------------ | | `channel` | string | yes | — | Target channel. Cannot start with the reserved `_AGENTS_.` prefix. | | `body` | string | yes | — | Request payload sent to the responder. | | `metadata` | string | no | — | Optional metadata string carried alongside the body. | | `tags` | object | no | — | Optional string key/value tags attached to the request. | | `timeout_seconds` | integer | no | `10` (command) · `30` (query) | Seconds to wait for a reply. Maximum **300**. | `timeout_seconds` is capped at **300** for both tools. The connector also adds a small gateway buffer on top of the caller-specified timeout. See [Configuration](/aiway/mcp/configuration) for `ToolTimeoutSeconds`. ## Output schema [#output-schema] Both tools return the standard `tools/call` result with a `content` array of `text` blocks. `command_send` returns an acknowledgement string; `query_send` returns the responder's payload (often JSON) as text. A successful command: ```json { "jsonrpc": "2.0", "id": 9, "result": { "content": [{ "type": "text", "text": "Command executed successfully on channel 'example-commands'" }], "isError": false } } ``` A successful query: ```json { "jsonrpc": "2.0", "id": 10, "result": { "content": [{ "type": "text", "text": "{\"data\":\"query response payload from subscriber\"}" }], "isError": false } } ``` When no responder is listening, the call returns `isError: true` with a timeout message — the JSON-RPC envelope itself still succeeds: ```json { "jsonrpc": "2.0", "id": 9, "result": { "content": [{ "type": "text", "text": "Command timed out: no subscriber on channel 'example-commands' within 10s" }], "isError": true } } ``` A timeout is reported through `isError`, not as a JSON-RPC error. See [Error handling](/aiway/mcp/guides/error-handling) for the three failure layers. ## command\_send [#command_send] Send a command and wait for an acknowledgement. ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -d '{ "jsonrpc": "2.0", "id": 9, "method": "tools/call", "params": { "name": "command_send", "arguments": { "channel": "example-commands", "body": "do-work", "timeout_seconds": 10, "metadata": "cmd-meta", "tags": { "action": "process" } } } }' ``` ```go result, err := c.CallTool(ctx, mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: "command_send", Arguments: map[string]any{ "channel": "example-commands", "body": "do-work", "timeout_seconds": 10, "metadata": "cmd-meta", "tags": map[string]any{"action": "process"}, }, }, }) if err != nil { log.Fatal(err) } fmt.Println("Tool: command_send") fmt.Printf("Result: %+v\n", result) ``` ```python result = await session.call_tool("command_send", { "channel": "example-commands", "body": "do-work", "timeout_seconds": 10, "metadata": "cmd-meta", "tags": {"action": "process"}, }) print(f"IsError: {result.isError}") for content in result.content: print(f"Result: {content.text}") ``` ```typescript const result = await client.callTool({ name: "command_send", arguments: { channel: "example-commands", body: "do-work", timeout_seconds: 10, metadata: "cmd-meta", tags: { action: "process" }, }, }); console.log(JSON.stringify(result, null, 2)); ``` ```java var result = client.callTool(new CallToolRequest( "command_send", Map.of( "channel", "example-commands", "body", "do-work", "timeout_seconds", 10, "metadata", "cmd-meta", "tags", Map.of("action", "process") ) )); System.out.println(result); ``` ```csharp var result = await client.CallToolAsync("command_send", new Dictionary { ["channel"] = "example-commands", ["body"] = "do-work", ["timeout_seconds"] = 10, ["metadata"] = "cmd-meta", ["tags"] = new Dictionary { ["action"] = "process" }, }); Console.WriteLine($"Result: {result}"); ``` ```kotlin val result = client.callTool("command_send", mapOf( "channel" to "example-commands", "body" to "do-work", "timeout_seconds" to 10 )) println("Result: $result") ``` ```ruby result = client.call_tool("command_send", { "channel" => "example-commands", "body" => "do-work", "timeout_seconds" => 10, }) puts "Result: #{result}" ``` ```rust let result = client.call_tool("command_send", json!({ "channel": "example-commands", "body": "do-work", "timeout_seconds": 10, "metadata": "cmd-meta", "tags": {"action": "process"} })).await?; println!("Result: {result:#?}"); ``` ```swift let result = try await client.callTool("command_send", arguments: [ "channel": "example-commands", "body": "do-work", "timeout_seconds": 10, ]) print("Result: \(result)") ``` ## query\_send [#query_send] Send a query and wait for a response payload. ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -d '{ "jsonrpc": "2.0", "id": 10, "method": "tools/call", "params": { "name": "query_send", "arguments": { "channel": "example-queries", "body": "get-data", "timeout_seconds": 30, "metadata": "qry-meta", "tags": { "action": "lookup" } } } }' ``` ```go result, err := c.CallTool(ctx, mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: "query_send", Arguments: map[string]any{ "channel": "example-queries", "body": "get-data", "timeout_seconds": 30, "metadata": "qry-meta", "tags": map[string]any{"action": "lookup"}, }, }, }) if err != nil { log.Fatal(err) } fmt.Println("Tool: query_send") fmt.Printf("Result: %+v\n", result) ``` ```python result = await session.call_tool("query_send", { "channel": "example-queries", "body": "get-data", "timeout_seconds": 30, "metadata": "qry-meta", "tags": {"action": "lookup"}, }) print(f"IsError: {result.isError}") for content in result.content: print(f"Result: {content.text}") ``` ```typescript const result = await client.callTool({ name: "query_send", arguments: { channel: "example-queries", body: "get-data", timeout_seconds: 30, metadata: "qry-meta", tags: { action: "lookup" }, }, }); console.log(JSON.stringify(result, null, 2)); ``` ```java var result = client.callTool(new CallToolRequest( "query_send", Map.of( "channel", "example-queries", "body", "get-data", "timeout_seconds", 30, "metadata", "qry-meta", "tags", Map.of("action", "lookup") ) )); System.out.println(result); ``` ```csharp var result = await client.CallToolAsync("query_send", new Dictionary { ["channel"] = "example-queries", ["body"] = "get-data", ["timeout_seconds"] = 30, ["metadata"] = "qry-meta", ["tags"] = new Dictionary { ["action"] = "lookup" }, }); Console.WriteLine($"Result: {result}"); ``` ```kotlin val result = client.callTool("query_send", mapOf( "channel" to "example-queries", "body" to "get-data", "timeout_seconds" to 30 )) println("Result: $result") ``` ```ruby result = client.call_tool("query_send", { "channel" => "example-queries", "body" => "get-data", "timeout_seconds" => 30, }) puts "Result: #{result}" ``` ```rust let result = client.call_tool("query_send", json!({ "channel": "example-queries", "body": "get-data", "timeout_seconds": 30, "metadata": "qry-meta", "tags": {"action": "lookup"} })).await?; println!("Result: {result:#?}"); ``` ```swift let result = try await client.callTool("query_send", arguments: [ "channel": "example-queries", "body": "get-data", "timeout_seconds": 30, ]) print("Result: \(result)") ``` The snippets above assume an initialized MCP session (`client` / `session`). For the full connect-and-initialize handshake in each language, see [Client setup](/aiway/mcp/guides/client-setup). ## Related [#related] # Events Tools (/aiway/mcp/tools/events) The events tools let an AI model publish events to KubeMQ and read them back from the events store, all over the Model Context Protocol. They cover both the **fire-and-forget** pub/sub pattern and the **persistent, replayable** events store. ## Overview [#overview] The MCP connector exposes four events tools, split across two delivery models: * **`events_publish`** — fire-and-forget pub/sub. The event is delivered to whatever subscribers are live at that instant and is **not stored**; if no one is listening, it is lost. * **`events_store_publish`** — persistent publish. The event is appended to the events store with a sequence number and can be re-read later. * **`events_store_read`** — read stored events starting from a sequence number or a timestamp. * **`events_store_read_latest`** — return the most recent N stored events. Use `events_publish` for live notifications where missed messages are acceptable, and the events-store tools when a model needs durable history it can replay — for example, reading recent events to build context before acting. ## How it works [#how-it-works] Every tool is a `tools/call` JSON-RPC request. The connector translates the call into a native KubeMQ events or events-store operation over the [Array](/connectors), then returns the result in the standard `content[]` envelope. *Ephemeral events fan out to live subscribers; events-store events are appended with a sequence number and read back on demand.* ## events\_publish [#events_publish] Publish a fire-and-forget event to an events channel. The call returns as soon as the event is accepted — there is no stored copy and no per-subscriber acknowledgement. ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -d '{ "jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": { "name": "events_publish", "arguments": { "channel": "example-events", "body": "Event data", "metadata": "event-meta", "tags": {"source": "mcp-example"} } } }' ``` ```csharp using ModelContextProtocol.Client; var url = Environment.GetEnvironmentVariable("KUBEMQ_MCP_URL") ?? "http://localhost:9090"; var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{url}/mcp") }); await using var client = await McpClientFactory.CreateAsync(transport); var result = await client.CallToolAsync("events_publish", new Dictionary { ["channel"] = "example-events", ["body"] = "Event data", ["metadata"] = "event-meta", ["tags"] = new Dictionary { ["source"] = "mcp-example" }, }); Console.WriteLine($"Result: {result}"); ``` ```go url := os.Getenv("KUBEMQ_MCP_URL") if url == "" { url = "http://localhost:9090" } c, err := client.NewStreamableHttpClient(url + "/mcp") if err != nil { log.Fatal(err) } defer c.Close() ctx := context.Background() if err := c.Start(ctx); err != nil { log.Fatal(err) } result, err := c.CallTool(ctx, mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: "events_publish", Arguments: map[string]any{ "channel": "example-events", "body": "Event data", "metadata": "event-meta", "tags": map[string]any{"source": "mcp-example"}, }, }, }) if err != nil { log.Fatal(err) } fmt.Printf("Result: %+v\n", result) ``` ```java String url = System.getenv().getOrDefault("KUBEMQ_MCP_URL", "http://localhost:9090"); var transport = HttpClientStreamableHttpTransport.builder(url).endpoint("/mcp").build(); var client = McpClient.sync(transport).build(); client.initialize(); var result = client.callTool(new CallToolRequest( "events_publish", Map.of( "channel", "example-events", "body", "Event data", "metadata", "event-meta", "tags", Map.of("source", "mcp-example") ) )); System.out.println(result); client.closeGracefully(); ``` ```kotlin val url = System.getenv("KUBEMQ_MCP_URL") ?: "http://localhost:9090" val httpClient = HttpClient { install(SSE) } val transport = StreamableHttpClientTransport(client = httpClient, url = "$url/mcp") val client = Client(clientInfo = Implementation(name = "kubemq-mcp-kotlin-example", version = "1.0.0")) client.connect(transport) val result = client.callTool("events_publish", mapOf( "channel" to "example-events", "body" to "Event data" )) println("Result: $result") client.close() httpClient.close() ``` ```python import asyncio import os from mcp.client.streamable_http import streamablehttp_client from mcp import ClientSession KUBEMQ_MCP_URL = os.environ.get("KUBEMQ_MCP_URL", "http://localhost:9090") async def main(): async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool("events_publish", { "channel": "example-events", "body": "Event data", "metadata": "event-meta", "tags": {"source": "mcp-example"}, }) print(f"IsError: {result.isError}") for content in result.content: print(f"Result: {content.text}") if __name__ == "__main__": asyncio.run(main()) ``` ```ruby require "mcp" url = ENV.fetch("KUBEMQ_MCP_URL", "http://localhost:9090") client = MCP::Client.new( transport: MCP::Transport::StreamableHTTP.new("#{url}/mcp"), name: "kubemq-mcp-ruby-example", version: "1.0.0" ) client.initialize_handshake result = client.call_tool("events_publish", { "channel" => "example-events", "body" => "Event data", }) puts "Result: #{result}" client.close ``` ```rust use rmcp::transport::streamable_http::StreamableHttpClientTransport; use rmcp::service::RunService; use serde_json::json; #[tokio::main] async fn main() -> anyhow::Result<()> { let url = std::env::var("KUBEMQ_MCP_URL") .unwrap_or_else(|_| "http://localhost:9090".to_string()); let transport = StreamableHttpClientTransport::from_uri(format!("{url}/mcp"))?; let client = ().serve(transport).await?; let result = client.call_tool("events_publish", json!({ "channel": "example-events", "body": "Event data", "metadata": "event-meta", "tags": {"source": "mcp-example"} })).await?; println!("Result: {result:#?}"); Ok(()) } ``` ```swift import Foundation import MCP let url = ProcessInfo.processInfo.environment["KUBEMQ_MCP_URL"] ?? "http://localhost:9090" let transport = HTTPClientTransport(endpoint: URL(string: "\(url)/mcp")!, streaming: true) let client = Client(name: "kubemq-mcp-swift-example", version: "1.0.0") try await client.connect(transport: transport) let result = try await client.callTool("events_publish", arguments: [ "channel": "example-events", "body": "Event data", ]) print("Result: \(result)") ``` ```typescript import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const KUBEMQ_MCP_URL = process.env.KUBEMQ_MCP_URL || "http://localhost:9090"; const transport = new StreamableHTTPClientTransport( new URL(`${KUBEMQ_MCP_URL}/mcp`) ); const client = new Client({ name: "kubemq-mcp-ts-example", version: "1.0.0" }); await client.connect(transport); const result = await client.callTool({ name: "events_publish", arguments: { channel: "example-events", body: "Event data", metadata: "event-meta", tags: { source: "mcp-example" }, }, }); console.log(JSON.stringify(result, null, 2)); await client.close(); ``` A successful publish returns a confirmation message in the standard envelope: ```json { "jsonrpc": "2.0", "id": 5, "result": { "content": [{ "type": "text", "text": "Event published successfully to channel 'example-events'" }], "isError": false } } ``` ## events\_store\_publish [#events_store_publish] Publish a **persistent** event. The event is appended to the events store, assigned a sequence number, and remains available for re-reading by later `events_store_read` and `events_store_read_latest` calls. ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -d '{ "jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": { "name": "events_store_publish", "arguments": { "channel": "example-events-store", "body": "Stored event data", "metadata": "store-meta", "tags": {"source": "mcp-example"} } } }' ``` ```csharp var result = await client.CallToolAsync("events_store_publish", new Dictionary { ["channel"] = "example-events-store", ["body"] = "Stored event data", ["metadata"] = "store-meta", ["tags"] = new Dictionary { ["source"] = "mcp-example" }, }); Console.WriteLine($"Result: {result}"); ``` ```go result, err := c.CallTool(ctx, mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: "events_store_publish", Arguments: map[string]any{ "channel": "example-events-store", "body": "Stored event data", "metadata": "store-meta", "tags": map[string]any{"source": "mcp-example"}, }, }, }) if err != nil { log.Fatal(err) } fmt.Printf("Result: %+v\n", result) ``` ```java var result = client.callTool(new CallToolRequest( "events_store_publish", Map.of( "channel", "example-events-store", "body", "Stored event data", "metadata", "store-meta", "tags", Map.of("source", "mcp-example") ) )); System.out.println(result); ``` ```kotlin val result = client.callTool("events_store_publish", mapOf( "channel" to "example-events-store", "body" to "Stored event" )) println("Result: $result") ``` ```python result = await session.call_tool("events_store_publish", { "channel": "example-events-store", "body": "Stored event data", "metadata": "store-meta", "tags": {"source": "mcp-example"}, }) print(f"IsError: {result.isError}") for content in result.content: print(f"Result: {content.text}") ``` ```ruby result = client.call_tool("events_store_publish", { "channel" => "example-events-store", "body" => "Stored event", }) puts "Result: #{result}" ``` ```rust let result = client.call_tool("events_store_publish", json!({ "channel": "example-events-store", "body": "Stored event data", "metadata": "store-meta", "tags": {"source": "mcp-example"} })).await?; println!("Result: {result:#?}"); ``` ```swift let result = try await client.callTool("events_store_publish", arguments: [ "channel": "example-events-store", "body": "Stored event", ]) print("Result: \(result)") ``` ```typescript const result = await client.callTool({ name: "events_store_publish", arguments: { channel: "example-events-store", body: "Stored event data", metadata: "store-meta", tags: { source: "mcp-example" }, }, }); console.log(JSON.stringify(result, null, 2)); ``` ```json { "jsonrpc": "2.0", "id": 6, "result": { "content": [{ "type": "text", "text": "Event published successfully to events store channel 'example-events-store'" }], "isError": false } } ``` ## events\_store\_read [#events_store_read] Read stored events starting from a position. Provide `from_sequence` to start at a sequence number, or `from_time` to start at an RFC 3339 timestamp, and cap the result with `max_messages`. ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -d '{ "jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": { "name": "events_store_read", "arguments": { "channel": "example-events-store", "from_sequence": 1, "max_messages": 10 } } }' ``` ```csharp var result = await client.CallToolAsync("events_store_read", new Dictionary { ["channel"] = "example-events-store", ["from_sequence"] = 1, ["max_messages"] = 10, }); Console.WriteLine($"Result: {result}"); ``` ```go result, err := c.CallTool(ctx, mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: "events_store_read", Arguments: map[string]any{ "channel": "example-events-store", "from_sequence": 1, "max_messages": 10, }, }, }) if err != nil { log.Fatal(err) } fmt.Printf("Result: %+v\n", result) ``` ```java var result = client.callTool(new CallToolRequest( "events_store_read", Map.of( "channel", "example-events-store", "from_sequence", 1, "max_messages", 10 ) )); System.out.println(result); ``` ```kotlin val result = client.callTool("events_store_read", mapOf( "channel" to "example-events-store", "from_sequence" to 1, "max_messages" to 10 )) println("Result: $result") ``` ```python result = await session.call_tool("events_store_read", { "channel": "example-events-store", "from_sequence": 1, "max_messages": 10, }) print(f"IsError: {result.isError}") for content in result.content: print(f"Result: {content.text}") ``` ```ruby result = client.call_tool("events_store_read", { "channel" => "example-events-store", "from_sequence" => 1, "max_messages" => 10, }) puts "Result: #{result}" ``` ```rust let result = client.call_tool("events_store_read", json!({ "channel": "example-events-store", "from_sequence": 1, "max_messages": 10 })).await?; println!("Result: {result:#?}"); ``` ```swift let result = try await client.callTool("events_store_read", arguments: [ "channel": "example-events-store", "from_sequence": 1, "max_messages": 10, ]) print("Result: \(result)") ``` ```typescript const result = await client.callTool({ name: "events_store_read", arguments: { channel: "example-events-store", from_sequence: 1, max_messages: 10, }, }); console.log(JSON.stringify(result, null, 2)); ``` The result text is a JSON array of stored events, each carrying its `body`, `metadata`, `sequence`, and `timestamp`: ```json { "jsonrpc": "2.0", "id": 7, "result": { "content": [{ "type": "text", "text": "[{\"body\":\"Stored event data\",\"metadata\":\"store-meta\",\"sequence\":1,\"timestamp\":\"2026-04-06T12:00:00Z\"}]" }], "isError": false } } ``` ## events\_store\_read\_latest [#events_store_read_latest] Return the most recent events from the store. Set `count` to choose how many to read back, newest first. ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -d '{ "jsonrpc": "2.0", "id": 8, "method": "tools/call", "params": { "name": "events_store_read_latest", "arguments": { "channel": "example-events-store", "count": 3 } } }' ``` ```csharp var result = await client.CallToolAsync("events_store_read_latest", new Dictionary { ["channel"] = "example-events-store", ["count"] = 3, }); Console.WriteLine($"Result: {result}"); ``` ```go result, err := c.CallTool(ctx, mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: "events_store_read_latest", Arguments: map[string]any{ "channel": "example-events-store", "count": 3, }, }, }) if err != nil { log.Fatal(err) } fmt.Printf("Result: %+v\n", result) ``` ```java var result = client.callTool(new CallToolRequest( "events_store_read_latest", Map.of( "channel", "example-events-store", "count", 3 ) )); System.out.println(result); ``` ```kotlin val result = client.callTool("events_store_read_latest", mapOf( "channel" to "example-events-store", "count" to 3 )) println("Result: $result") ``` ```python result = await session.call_tool("events_store_read_latest", { "channel": "example-events-store", "count": 3, }) print(f"IsError: {result.isError}") for content in result.content: print(f"Result: {content.text}") ``` ```ruby result = client.call_tool("events_store_read_latest", { "channel" => "example-events-store", "count" => 3, }) puts "Result: #{result}" ``` ```rust let result = client.call_tool("events_store_read_latest", json!({ "channel": "example-events-store", "count": 3 })).await?; println!("Result: {result:#?}"); ``` ```swift let result = try await client.callTool("events_store_read_latest", arguments: [ "channel": "example-events-store", "count": 3, ]) print("Result: \(result)") ``` ```typescript const result = await client.callTool({ name: "events_store_read_latest", arguments: { channel: "example-events-store", count: 3, }, }); console.log(JSON.stringify(result, null, 2)); ``` ```json { "jsonrpc": "2.0", "id": 8, "result": { "content": [{ "type": "text", "text": "[{\"body\":\"Stored event 3\",\"sequence\":3},{\"body\":\"Stored event 2\",\"sequence\":2},{\"body\":\"Stored event 1\",\"sequence\":1}]" }], "isError": false } } ``` ## Parameters [#parameters] ### events\_publish [#events_publish-1] | Argument | Type | Required | Default | Description | | ---------- | ------ | -------- | ------- | ----------------------------------------------- | | `channel` | string | yes | — | Events channel to publish to. | | `body` | string | yes | — | Event payload. | | `metadata` | string | no | — | Optional metadata string attached to the event. | | `tags` | object | no | — | Optional key/value string tags. | ### events\_store\_publish [#events_store_publish-1] | Argument | Type | Required | Default | Description | | ---------- | ------ | -------- | ------- | ----------------------------------------------- | | `channel` | string | yes | — | Events store channel to append to. | | `body` | string | yes | — | Event payload. | | `metadata` | string | no | — | Optional metadata string attached to the event. | | `tags` | object | no | — | Optional key/value string tags. | ### events\_store\_read [#events_store_read-1] | Argument | Type | Required | Default | Description | | --------------- | ------ | -------- | ------- | ----------------------------------------- | | `channel` | string | yes | — | Events store channel to read from. | | `max_messages` | number | yes | — | Maximum number of events to return. | | `from_sequence` | number | no | — | Start reading at this sequence number. | | `from_time` | string | no | — | Start reading at this RFC 3339 timestamp. | ### events\_store\_read\_latest [#events_store_read_latest-1] | Argument | Type | Required | Default | Description | | --------- | ------ | -------- | ------- | --------------------------------------------------- | | `channel` | string | yes | — | Events store channel to read from. | | `count` | number | no | `10` | Number of most-recent events to return (max `100`). | Channel names beginning with the reserved `_AGENTS_.` prefix are rejected — see [Channel resolution](/aiway/mcp/guides/channel-resolution). ## Response [#response] Publish tools return a single text confirmation in the `content[]` envelope. Read tools return a text block whose `text` is a JSON array of stored events. A failed call sets `isError: true` and carries the message in the same block — see [Error handling](/aiway/mcp/guides/error-handling) for the three failure layers. ## Related [#related] # Tools Overview (/aiway/mcp/tools) KubeMQ exposes its messaging operations to AI models as **MCP tools**. A client discovers them with `tools/list` and invokes them with `tools/call` — there is no KubeMQ-specific client library involved, just the Model Context Protocol. ## Overview [#overview] The MCP connector publishes **15 tools** in two families: * **11 core messaging tools** — always available. They cover queues, events, the events store, commands/queries, and channel discovery. * **4 agent-bridge tools** — available only when the [A2A agent registry](/aiway/a2a) is present. They let an MCP client discover and message registered agents, bridging MCP to the A2A gateway. Every tool maps onto a single KubeMQ messaging operation. The model calls the tool by name with a JSON arguments object; the connector translates it into a native KubeMQ call over the [Array](/connectors) and returns the result. ## How it works [#how-it-works] The connector advertises each tool through `tools/list`, then routes each `tools/call` to the matching KubeMQ operation. Core tools reach the broker directly; bridge tools forward through the agent registry to an external agent. *One MCP connector fans out to core messaging tools and, when the registry is present, agent-bridge tools.* ## Core messaging tools [#core-messaging-tools] These 11 tools are registered unconditionally — start kubemq-server and they are live at `/mcp`. | Category | Tools | Page | | ------------------ | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------- | | Queues | `queue_send`, `queue_receive`, `queue_peek` | [Queue tools](/aiway/mcp/tools/queues) | | Events | `events_publish`, `events_store_publish`, `events_store_read`, `events_store_read_latest` | [Events tools](/aiway/mcp/tools/events) | | Commands & Queries | `command_send`, `query_send` | [Command & query tools](/aiway/mcp/tools/commands-queries) | | Channels | `channel_list`, `channel_info` | [Channel tools](/aiway/mcp/tools/channel-management) | ## Agent-bridge tools [#agent-bridge-tools] These 4 tools appear in `tools/list` **only when the A2A agent registry is injected** into the MCP connector. They turn an MCP client into an A2A caller: `agent_send` builds a `message/send` envelope and forwards it over Query to `_AGENTS_.agents/`, while `agent_query` forwards an arbitrary JSON-RPC method to the agent. | Category | Tools | Page | | ------------ | ------------------------------------------------------- | --------------------------------------------------- | | Agent bridge | `agent_list`, `agent_info`, `agent_send`, `agent_query` | [Agent-bridge tools](/aiway/mcp/tools/agent-bridge) | The bridge connects MCP to the [A2A connector](/aiway/a2a). If A2A is not running, only the 11 core tools are listed. ## Calling a tool [#calling-a-tool] Every tool is invoked the same way — a `tools/call` JSON-RPC request naming the tool and passing its `arguments` object. The skeleton below works for any of the 15 tools; only `name` and `arguments` change. ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "queue_send", "arguments": { "channel": "example-queue", "body": "Hello from MCP" } } }' ``` List the available tools and their input schemas first with `tools/list` — see [Endpoints](/aiway/mcp/reference/endpoints). The per-tool pages document each tool's arguments and provide ready-to-run examples in all nine languages. ## Response shape [#response-shape] `tools/call` always returns a result with a `content` array. Each entry is a typed block — KubeMQ uses `text` blocks carrying the operation result as a JSON string. A successful call: ```json { "jsonrpc": "2.0", "id": 2, "result": { "content": [{ "type": "text", "text": "Message sent successfully to queue 'example-queue'" }], "isError": false } } ``` A failed call sets `isError: true` and carries the error message in the same `content` block — the JSON-RPC envelope itself still succeeds: ```json { "jsonrpc": "2.0", "id": 2, "result": { "content": [{ "type": "text", "text": "error: channel name uses reserved prefix '_AGENTS_.'" }], "isError": true } } ``` `isError` is **not** a protocol failure. A malformed JSON-RPC request (missing `name`, bad params) returns a JSON-RPC error instead — see [Error handling](/aiway/mcp/guides/error-handling) for the three failure layers and [Error codes](/aiway/mcp/reference/error-codes) for the catalog. ## Related [#related] # Queue Tools (/aiway/mcp/tools/queues) The queue tools expose KubeMQ's durable, point-to-point queue messaging as MCP tools, so an AI model can enqueue work, consume it, and inspect a backlog without removing it. ## Overview [#overview] KubeMQ's [queue messaging](/learn/queues) is a durable, at-least-once, single-consumer channel: a message is held until exactly one consumer receives it. The MCP connector surfaces three queue operations as tools, all invoked through the standard `tools/call` method against the single `POST /mcp` endpoint on the [shared HTTP server](/connectors/concepts/shared-http-server) (port `9090`). Channels are created automatically on first use. | Tool | Operation | Idempotent | | --------------- | ------------------------------------------------------------- | ---------- | | `queue_send` | Enqueue a message for durable, single-consumer delivery | No | | `queue_receive` | Receive and consume messages (destructive read) | No | | `queue_peek` | Inspect messages without removing them (non-destructive read) | Yes | The MCP connector is **enabled by default** — start kubemq-server and `/mcp` is live. To disable it, set `CONNECTORSMCP_ENABLE=false`. See [Configuration](/aiway/mcp/configuration) for details. ## How it works [#how-it-works] A `tools/call` request flows through the MCP connector, which translates the tool arguments into a native KubeMQ queue operation against the broker. *An AI model sends and receives queue messages through the MCP connector, which bridges to the KubeMQ broker.* A `channel` that starts with the reserved prefix `_AGENTS_.` is rejected with a `-32602` Invalid Params error. See [Channel resolution](/aiway/mcp/guides/channel-resolution). ## queue\_send [#queue_send] > Send a message to a KubeMQ queue channel. Use for reliable, persistent messaging with at-least-once delivery. Each call enqueues a new message (not idempotent). The channel is created automatically if it does not exist. Optional policy fields delay visibility (`delay_seconds`), set a time-to-live (`expiration_seconds`), and route poison messages to a dead-letter queue (`max_receive_count` plus `dead_letter_queue`). Both `channel` and `body` are required — omitting either returns a `-32602` Invalid Params error. ### Input schema [#input-schema] | Argument | Type | Required | Default | Description | | -------------------- | ------- | -------- | ------- | ----------------------------------------------------------------------- | | `channel` | string | Yes | — | Queue channel name. Must not start with the reserved prefix `_AGENTS_.` | | `body` | string | Yes | — | Message body (string) | | `metadata` | string | No | `""` | Optional metadata | | `tags` | object | No | `{}` | Optional key-value tags (string values) | | `delay_seconds` | integer | No | `0` | Delay delivery by N seconds (`0` = immediately visible) | | `expiration_seconds` | integer | No | `0` | Message expiry in seconds (`0` = no expiration) | | `max_receive_count` | integer | No | `0` | Max receive attempts before dead-letter (`0` = unlimited) | | `dead_letter_queue` | string | No | `""` | Dead-letter queue channel name | ### Usage [#usage] ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "queue_send", "arguments": { "channel": "example-queue", "body": "Hello from MCP", "metadata": "example-metadata", "tags": { "env": "dev", "source": "mcp-example" } } } }' ``` ```csharp using ModelContextProtocol.Client; var url = Environment.GetEnvironmentVariable("KUBEMQ_MCP_URL") ?? "http://localhost:9090"; var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{url}/mcp") }); await using var client = await McpClientFactory.CreateAsync(transport); var result = await client.CallToolAsync("queue_send", new Dictionary { ["channel"] = "example-queue", ["body"] = "Hello from C# MCP", ["metadata"] = "example-metadata", ["tags"] = new Dictionary { ["env"] = "dev", ["source"] = "mcp-example" }, }); Console.WriteLine($"Result: {result}"); ``` ```go package main import ( "context" "fmt" "log" "os" "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/mcp" ) func main() { url := os.Getenv("KUBEMQ_MCP_URL") if url == "" { url = "http://localhost:9090" } c, err := client.NewStreamableHttpClient(url + "/mcp") if err != nil { log.Fatal(err) } defer c.Close() ctx := context.Background() if err := c.Start(ctx); err != nil { log.Fatal(err) } result, err := c.CallTool(ctx, mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: "queue_send", Arguments: map[string]any{ "channel": "example-queue", "body": "Hello from Go MCP", "metadata": "example-metadata", "tags": map[string]any{"env": "dev", "source": "mcp-example"}, }, }, }) if err != nil { log.Fatal(err) } fmt.Printf("Result: %+v\n", result) } ``` ```java import io.modelcontextprotocol.sdk.McpClient; import io.modelcontextprotocol.sdk.client.transport.HttpClientStreamableHttpTransport; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import java.util.Map; public class QueueSend { public static void main(String[] args) { String url = System.getenv().getOrDefault("KUBEMQ_MCP_URL", "http://localhost:9090"); var transport = HttpClientStreamableHttpTransport.builder(url).endpoint("/mcp").build(); var client = McpClient.sync(transport).build(); client.initialize(); var result = client.callTool(new CallToolRequest( "queue_send", Map.of( "channel", "example-queue", "body", "Hello from Java MCP", "metadata", "example-metadata", "tags", Map.of("env", "dev", "source", "mcp-example") ) )); System.out.println(result); client.closeGracefully(); } } ``` ```kotlin import io.modelcontextprotocol.kotlin.sdk.Implementation import io.modelcontextprotocol.kotlin.sdk.client.Client import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport import io.ktor.client.* import io.ktor.client.plugins.sse.* import kotlinx.coroutines.runBlocking fun main() = runBlocking { val url = System.getenv("KUBEMQ_MCP_URL") ?: "http://localhost:9090" val httpClient = HttpClient { install(SSE) } val transport = StreamableHttpClientTransport(client = httpClient, url = "$url/mcp") val client = Client(clientInfo = Implementation(name = "kubemq-mcp-kotlin-example", version = "1.0.0")) client.connect(transport) val result = client.callTool("queue_send", mapOf( "channel" to "example-queue", "body" to "Hello from Kotlin MCP", "metadata" to "example-metadata", "tags" to mapOf("env" to "dev", "source" to "mcp-example") )) println("Result: $result") client.close() httpClient.close() } ``` ```python import asyncio import os from mcp.client.streamable_http import streamablehttp_client from mcp import ClientSession KUBEMQ_MCP_URL = os.environ.get("KUBEMQ_MCP_URL", "http://localhost:9090") async def main(): async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool("queue_send", { "channel": "example-queue", "body": "Hello from Python MCP", "metadata": "example-metadata", "tags": {"env": "dev", "source": "mcp-example"}, }) print(f"IsError: {result.isError}") for content in result.content: print(f"Result: {content.text}") if __name__ == "__main__": asyncio.run(main()) ``` ```ruby require "mcp" url = ENV.fetch("KUBEMQ_MCP_URL", "http://localhost:9090") client = MCP::Client.new( transport: MCP::Transport::StreamableHTTP.new("#{url}/mcp"), name: "kubemq-mcp-ruby-example", version: "1.0.0" ) client.initialize_handshake result = client.call_tool("queue_send", { "channel" => "example-queue", "body" => "Hello from Ruby MCP", "metadata" => "example-metadata", "tags" => { "env" => "dev", "source" => "mcp-example" }, }) puts "Result: #{result}" client.close ``` ```rust use rmcp::transport::streamable_http::StreamableHttpClientTransport; use rmcp::service::RunService; use serde_json::json; #[tokio::main] async fn main() -> anyhow::Result<()> { let url = std::env::var("KUBEMQ_MCP_URL") .unwrap_or_else(|_| "http://localhost:9090".to_string()); let transport = StreamableHttpClientTransport::from_uri(format!("{url}/mcp"))?; let client = ().serve(transport).await?; let result = client.call_tool("queue_send", json!({ "channel": "example-queue", "body": "Hello from Rust MCP", "metadata": "example-metadata", "tags": {"env": "dev", "source": "mcp-example"} })).await?; println!("Result: {result:#?}"); Ok(()) } ``` ```swift import Foundation import MCP @main struct QueueSend { static func main() async throws { let url = ProcessInfo.processInfo.environment["KUBEMQ_MCP_URL"] ?? "http://localhost:9090" let transport = HTTPClientTransport(endpoint: URL(string: "\(url)/mcp")!, streaming: true) let client = Client(name: "kubemq-mcp-swift-example", version: "1.0.0") try await client.connect(transport: transport) let result = try await client.callTool("queue_send", arguments: [ "channel": "example-queue", "body": "Hello from Swift MCP", "metadata": "example-metadata", "tags": ["env": "dev", "source": "mcp-example"], ]) print("Result: \(result)") } } ``` ```typescript import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const KUBEMQ_MCP_URL = process.env.KUBEMQ_MCP_URL || "http://localhost:9090"; async function main() { const transport = new StreamableHTTPClientTransport( new URL(`${KUBEMQ_MCP_URL}/mcp`) ); const client = new Client({ name: "kubemq-mcp-ts-example", version: "1.0.0" }); await client.connect(transport); const result = await client.callTool({ name: "queue_send", arguments: { channel: "example-queue", body: "Hello from TypeScript MCP", metadata: "example-metadata", tags: { env: "dev", source: "mcp-example" }, }, }); console.log(JSON.stringify(result, null, 2)); await client.close(); } main().catch(console.error); ``` ### Response [#response] The tool result wraps a JSON string in the standard MCP `content[]` / `isError` envelope. The text payload reports the enqueue result: ```json { "content": [ { "type": "text", "text": "{\"message_id\":\"a1b2c3...\",\"sent_at\":\"2026-06-08T10:00:00Z\",\"is_error\":false}" } ], "isError": false } ``` ## queue\_receive [#queue_receive] > Receive messages from a KubeMQ queue channel. Messages are auto-acknowledged on receipt (destructive read). Not idempotent — failed processing requires re-enqueue. This is a destructive read: returned messages are removed from the queue, so each message is delivered to exactly one consumer. Set `max_messages` to drain a batch (clamped to the `1–100` range), and `wait_timeout_seconds` to long-poll for messages that have not yet arrived. Receiving from a non-existent channel is not an error — it returns an empty `messages` array. Because the read is not idempotent, do not blindly retry: a retry may consume *additional* messages rather than re-fetch the same ones. ### Input schema [#input-schema-1] | Argument | Type | Required | Default | Description | | ---------------------- | ------- | -------- | ------- | -------------------------------------------- | | `channel` | string | Yes | — | Queue channel name | | `max_messages` | integer | Yes | `1` | Max messages to receive (clamped to `1–100`) | | `wait_timeout_seconds` | integer | No | `5` | Long-poll wait timeout in seconds (`1–60`) | ### Usage [#usage-1] ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "queue_receive", "arguments": { "channel": "example-queue", "max_messages": 5 } } }' ``` ```csharp using ModelContextProtocol.Client; var url = Environment.GetEnvironmentVariable("KUBEMQ_MCP_URL") ?? "http://localhost:9090"; var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{url}/mcp") }); await using var client = await McpClientFactory.CreateAsync(transport); var result = await client.CallToolAsync("queue_receive", new Dictionary { ["channel"] = "example-queue", ["max_messages"] = 5, }); Console.WriteLine($"Result: {result}"); ``` ```go package main import ( "context" "fmt" "log" "os" "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/mcp" ) func main() { url := os.Getenv("KUBEMQ_MCP_URL") if url == "" { url = "http://localhost:9090" } c, err := client.NewStreamableHttpClient(url + "/mcp") if err != nil { log.Fatal(err) } defer c.Close() ctx := context.Background() if err := c.Start(ctx); err != nil { log.Fatal(err) } result, err := c.CallTool(ctx, mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: "queue_receive", Arguments: map[string]any{ "channel": "example-queue", "max_messages": 5, }, }, }) if err != nil { log.Fatal(err) } fmt.Printf("Result: %+v\n", result) } ``` ```java import io.modelcontextprotocol.sdk.McpClient; import io.modelcontextprotocol.sdk.client.transport.HttpClientStreamableHttpTransport; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import java.util.Map; public class QueueReceive { public static void main(String[] args) { String url = System.getenv().getOrDefault("KUBEMQ_MCP_URL", "http://localhost:9090"); var transport = HttpClientStreamableHttpTransport.builder(url).endpoint("/mcp").build(); var client = McpClient.sync(transport).build(); client.initialize(); var result = client.callTool(new CallToolRequest( "queue_receive", Map.of( "channel", "example-queue", "max_messages", 5 ) )); System.out.println(result); client.closeGracefully(); } } ``` ```kotlin import io.modelcontextprotocol.kotlin.sdk.Implementation import io.modelcontextprotocol.kotlin.sdk.client.Client import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport import io.ktor.client.* import io.ktor.client.plugins.sse.* import kotlinx.coroutines.runBlocking fun main() = runBlocking { val url = System.getenv("KUBEMQ_MCP_URL") ?: "http://localhost:9090" val httpClient = HttpClient { install(SSE) } val transport = StreamableHttpClientTransport(client = httpClient, url = "$url/mcp") val client = Client(clientInfo = Implementation(name = "kubemq-mcp-kotlin-example", version = "1.0.0")) client.connect(transport) val result = client.callTool("queue_receive", mapOf( "channel" to "example-queue", "max_messages" to 5 )) println("Result: $result") client.close() httpClient.close() } ``` ```python import asyncio import os from mcp.client.streamable_http import streamablehttp_client from mcp import ClientSession KUBEMQ_MCP_URL = os.environ.get("KUBEMQ_MCP_URL", "http://localhost:9090") async def main(): async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool("queue_receive", { "channel": "example-queue", "max_messages": 5, }) print(f"IsError: {result.isError}") for content in result.content: print(f"Result: {content.text}") if __name__ == "__main__": asyncio.run(main()) ``` ```ruby require "mcp" url = ENV.fetch("KUBEMQ_MCP_URL", "http://localhost:9090") client = MCP::Client.new( transport: MCP::Transport::StreamableHTTP.new("#{url}/mcp"), name: "kubemq-mcp-ruby-example", version: "1.0.0" ) client.initialize_handshake result = client.call_tool("queue_receive", { "channel" => "example-queue", "max_messages" => 5, }) puts "Result: #{result}" client.close ``` ```rust use rmcp::transport::streamable_http::StreamableHttpClientTransport; use rmcp::service::RunService; use serde_json::json; #[tokio::main] async fn main() -> anyhow::Result<()> { let url = std::env::var("KUBEMQ_MCP_URL") .unwrap_or_else(|_| "http://localhost:9090".to_string()); let transport = StreamableHttpClientTransport::from_uri(format!("{url}/mcp"))?; let client = ().serve(transport).await?; let result = client.call_tool("queue_receive", json!({ "channel": "example-queue", "max_messages": 5 })).await?; println!("Result: {result:#?}"); Ok(()) } ``` ```swift import Foundation import MCP @main struct QueueReceive { static func main() async throws { let url = ProcessInfo.processInfo.environment["KUBEMQ_MCP_URL"] ?? "http://localhost:9090" let transport = HTTPClientTransport(endpoint: URL(string: "\(url)/mcp")!, streaming: true) let client = Client(name: "kubemq-mcp-swift-example", version: "1.0.0") try await client.connect(transport: transport) let result = try await client.callTool("queue_receive", arguments: [ "channel": "example-queue", "max_messages": 5, ]) print("Result: \(result)") } } ``` ```typescript import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const KUBEMQ_MCP_URL = process.env.KUBEMQ_MCP_URL || "http://localhost:9090"; async function main() { const transport = new StreamableHTTPClientTransport( new URL(`${KUBEMQ_MCP_URL}/mcp`) ); const client = new Client({ name: "kubemq-mcp-ts-example", version: "1.0.0" }); await client.connect(transport); const result = await client.callTool({ name: "queue_receive", arguments: { channel: "example-queue", max_messages: 5, }, }); console.log(JSON.stringify(result, null, 2)); await client.close(); } main().catch(console.error); ``` ### Response [#response-1] The text payload is a JSON object holding the received messages and a count. Each message carries its `id`, `channel`, `body`, `metadata`, and (when present) `timestamp`, `sequence`, and `tags`: ```json { "content": [ { "type": "text", "text": "{\"messages\":[{\"id\":\"...\",\"channel\":\"example-queue\",\"body\":\"Hello from MCP\",\"metadata\":\"example-metadata\",\"tags\":{\"env\":\"dev\",\"source\":\"mcp-example\"}}],\"messages_count\":1,\"is_error\":false}" } ], "isError": false } ``` ## queue\_peek [#queue_peek] > Peek at messages in a KubeMQ queue without removing them. Idempotent — does not modify queue state. Peek is a non-destructive, idempotent read: inspected messages remain in the queue and can still be consumed later by `queue_receive`. Use it for monitoring, debugging, or letting an agent reason about pending work before deciding to consume it. Set `max_messages` to control how many messages to inspect (clamped to `1–100`). As with receive, peeking a non-existent channel returns an empty result rather than an error. ### Input schema [#input-schema-2] | Argument | Type | Required | Default | Description | | -------------- | ------- | -------- | ------- | ----------------------------------------- | | `channel` | string | Yes | — | Queue channel name | | `max_messages` | integer | Yes | `1` | Max messages to peek (clamped to `1–100`) | ### Usage [#usage-2] ```bash curl -X POST http://localhost:9090/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{ "jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": { "name": "queue_peek", "arguments": { "channel": "example-queue", "max_messages": 5 } } }' ``` ```csharp using ModelContextProtocol.Client; var url = Environment.GetEnvironmentVariable("KUBEMQ_MCP_URL") ?? "http://localhost:9090"; var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri($"{url}/mcp") }); await using var client = await McpClientFactory.CreateAsync(transport); var result = await client.CallToolAsync("queue_peek", new Dictionary { ["channel"] = "example-queue", ["max_messages"] = 5, }); Console.WriteLine($"Result: {result}"); ``` ```go package main import ( "context" "fmt" "log" "os" "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/mcp" ) func main() { url := os.Getenv("KUBEMQ_MCP_URL") if url == "" { url = "http://localhost:9090" } c, err := client.NewStreamableHttpClient(url + "/mcp") if err != nil { log.Fatal(err) } defer c.Close() ctx := context.Background() if err := c.Start(ctx); err != nil { log.Fatal(err) } result, err := c.CallTool(ctx, mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: "queue_peek", Arguments: map[string]any{ "channel": "example-queue", "max_messages": 5, }, }, }) if err != nil { log.Fatal(err) } fmt.Printf("Result: %+v\n", result) } ``` ```java import io.modelcontextprotocol.sdk.McpClient; import io.modelcontextprotocol.sdk.client.transport.HttpClientStreamableHttpTransport; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import java.util.Map; public class QueuePeek { public static void main(String[] args) { String url = System.getenv().getOrDefault("KUBEMQ_MCP_URL", "http://localhost:9090"); var transport = HttpClientStreamableHttpTransport.builder(url).endpoint("/mcp").build(); var client = McpClient.sync(transport).build(); client.initialize(); var result = client.callTool(new CallToolRequest( "queue_peek", Map.of( "channel", "example-queue", "max_messages", 5 ) )); System.out.println(result); client.closeGracefully(); } } ``` ```kotlin import io.modelcontextprotocol.kotlin.sdk.Implementation import io.modelcontextprotocol.kotlin.sdk.client.Client import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport import io.ktor.client.* import io.ktor.client.plugins.sse.* import kotlinx.coroutines.runBlocking fun main() = runBlocking { val url = System.getenv("KUBEMQ_MCP_URL") ?: "http://localhost:9090" val httpClient = HttpClient { install(SSE) } val transport = StreamableHttpClientTransport(client = httpClient, url = "$url/mcp") val client = Client(clientInfo = Implementation(name = "kubemq-mcp-kotlin-example", version = "1.0.0")) client.connect(transport) val result = client.callTool("queue_peek", mapOf( "channel" to "example-queue", "max_messages" to 5 )) println("Result: $result") client.close() httpClient.close() } ``` ```python import asyncio import os from mcp.client.streamable_http import streamablehttp_client from mcp import ClientSession KUBEMQ_MCP_URL = os.environ.get("KUBEMQ_MCP_URL", "http://localhost:9090") async def main(): async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool("queue_peek", { "channel": "example-queue", "max_messages": 5, }) print(f"IsError: {result.isError}") for content in result.content: print(f"Result: {content.text}") if __name__ == "__main__": asyncio.run(main()) ``` ```ruby require "mcp" url = ENV.fetch("KUBEMQ_MCP_URL", "http://localhost:9090") client = MCP::Client.new( transport: MCP::Transport::StreamableHTTP.new("#{url}/mcp"), name: "kubemq-mcp-ruby-example", version: "1.0.0" ) client.initialize_handshake result = client.call_tool("queue_peek", { "channel" => "example-queue", "max_messages" => 5, }) puts "Result: #{result}" client.close ``` ```rust use rmcp::transport::streamable_http::StreamableHttpClientTransport; use rmcp::service::RunService; use serde_json::json; #[tokio::main] async fn main() -> anyhow::Result<()> { let url = std::env::var("KUBEMQ_MCP_URL") .unwrap_or_else(|_| "http://localhost:9090".to_string()); let transport = StreamableHttpClientTransport::from_uri(format!("{url}/mcp"))?; let client = ().serve(transport).await?; let result = client.call_tool("queue_peek", json!({ "channel": "example-queue", "max_messages": 5 })).await?; println!("Result: {result:#?}"); Ok(()) } ``` ```swift import Foundation import MCP @main struct QueuePeek { static func main() async throws { let url = ProcessInfo.processInfo.environment["KUBEMQ_MCP_URL"] ?? "http://localhost:9090" let transport = HTTPClientTransport(endpoint: URL(string: "\(url)/mcp")!, streaming: true) let client = Client(name: "kubemq-mcp-swift-example", version: "1.0.0") try await client.connect(transport: transport) let result = try await client.callTool("queue_peek", arguments: [ "channel": "example-queue", "max_messages": 5, ]) print("Result: \(result)") } } ``` ```typescript import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const KUBEMQ_MCP_URL = process.env.KUBEMQ_MCP_URL || "http://localhost:9090"; async function main() { const transport = new StreamableHTTPClientTransport( new URL(`${KUBEMQ_MCP_URL}/mcp`) ); const client = new Client({ name: "kubemq-mcp-ts-example", version: "1.0.0" }); await client.connect(transport); const result = await client.callTool({ name: "queue_peek", arguments: { channel: "example-queue", max_messages: 5, }, }); console.log(JSON.stringify(result, null, 2)); await client.close(); } main().catch(console.error); ``` ### Response [#response-2] Same envelope as `queue_receive`, but the messages stay in the queue. Peeked messages omit `tags` and report `messages_count`: ```json { "content": [ { "type": "text", "text": "{\"messages\":[{\"id\":\"...\",\"channel\":\"example-queue\",\"body\":\"Hello from MCP\",\"metadata\":\"example-metadata\"}],\"messages_count\":1}" } ], "isError": false } ``` ## Receive vs peek [#receive-vs-peek] | Behavior | `queue_receive` | `queue_peek` | | ------------------------------- | ----------------------------------- | --------------------- | | Removes messages from the queue | Yes | No | | Idempotent | No | Yes | | Long-poll wait | `wait_timeout_seconds` (default 5s) | Fixed 1s internally | | Typical use | Processing work items | Monitoring, debugging | ## Related [#related] # Connect an MCP host (/deploy/scenarios/agents/connect-mcp-host) KubeMQ must be running — see the [Quickstart](/deploy/quickstart#get-your-key) (steps 1–2 get you there; instant if it's already up). KubeMQ's MCP gateway is already live on `:9090` the moment the broker starts — there's no connector to enable and no flag to flip. Point any MCP host at the endpoint and it can discover and call KubeMQ's tools right away. ## 1 · Point your MCP host at KubeMQ [#1--point-your-mcp-host-at-kubemq] Add a server entry to your MCP host's config. For Claude Desktop this is `claude_desktop_config.json` (Claude Desktop → Settings → Developer → Edit Config); Cursor and other hosts use an equivalent MCP server list: ```json title="claude_desktop_config.json" { "mcpServers": { "kubemq": { "url": "http://localhost:9090/mcp" } } } ``` The `url` must include the `/mcp` path — it's the only required field for an unauthenticated server. Restart the host so it picks up the new entry. ## 2 · Verify [#2--verify] Cross-check from both ends: * **From the server** — `kmq mcp list` prints every MCP tool KubeMQ has registered. * **From the host** — start a new conversation and ask it what tools it has; KubeMQ's tools (for example `queue_send`) should be in the list. Then make one call as proof: ask the host to send a message to a queue channel — for example, "send `hello` to the `orders` queue." A successful call returns the tool's result text with no error. ## Didn't work? [#didnt-work] * **Wrong endpoint/URL** — confirm the `url` is exactly `http://localhost:9090/mcp` (the `/mcp` path is required) and that port `9090` is reachable. * **Host config** — most MCP hosts need a full restart, not just a reload, to pick up a new or changed server entry; check the host's developer/logs panel for a connection error. * **Auth mismatch** — if the server has JWT auth enabled, add an `Authorization: Bearer ` header to the host's server entry; without it, calls fail even though the endpoint is reachable. ## Go deeper [#go-deeper] # Drive KubeMQ from an LLM (/deploy/scenarios/agents/drive-from-llm) KubeMQ must be running — see the [Quickstart](/deploy/quickstart#get-your-key) (steps 1–2 get you there; instant if it's already up). The MCP gateway is already live on `http://localhost:9090/mcp` — no enable step. Once an MCP host is pointed at it and at least one agent is registered, the same conversation can enqueue durable work **and** call an agent — messaging and agents, one fabric, one session. If you haven't done these yet: [connect an MCP host](/deploy/scenarios/agents/connect-mcp-host) to `http://localhost:9090/mcp`, and [register an agent](/deploy/scenarios/agents/register-first-agent) for it to call. Both take a couple of minutes. ## 1 · One prompt, two tool calls [#1--one-prompt-two-tool-calls] Ask your connected LLM host something that needs both capabilities, for example: > "Enqueue order 1042 for the fulfillment worker, then ask the order-status agent for > an ETA." The host resolves that into two MCP tool calls against the same `/mcp` session — one against KubeMQ's durable queue, one against the agent bridge: ```json title="queue_send" { "name": "queue_send", "arguments": { "channel": "orders", "body": "{\"id\":1042}" } } ``` ```json title="agent_send" { "name": "agent_send", "arguments": { "agent_id": "order-status-agent", "message": "What is the ETA for order 1042?" } } ``` ## 2 · Verify [#2--verify] You should see two results come back in the conversation, both round-tripped through the same MCP session — no separate connection, no glue code. `queue_send` returns the MCP queue envelope with a generated message ID (and the `orders` channel ticks in the dashboard): ```json title="queue_send result" { "content": [ { "type": "text", "text": "{\"message_id\":\"a1b2c3...\",\"sent_at\":\"2026-06-08T10:00:00Z\",\"is_error\":false}" } ], "isError": false } ``` `agent_send` returns the order-status agent's reply, bridged from its A2A response: ```json title="agent_send result" { "content": [ { "type": "text", "text": "{\"status\":\"in-transit\",\"eta\":\"2 days\"}" } ], "isError": false } ``` ## Didn't work? [#didnt-work] * **MCP host has no tools** — confirm it completed the `initialize` handshake against `http://localhost:9090/mcp`; see [Connect an MCP host](/deploy/scenarios/agents/connect-mcp-host). * **`agent_send` returns "Agent not found"** — register the agent first; the `agent_id` must match exactly. * **`queue_send` returns a `-32602` error** — the channel name starts with the reserved `_AGENTS_.` prefix, or `channel`/`body` is missing. ## Go deeper [#go-deeper] # Build on the AI-agent fabric (/deploy/scenarios/agents) KubeMQ must be running — see the [Quickstart](/deploy/quickstart#get-your-key) (steps 1–2 get you there; instant if it's already up). KubeMQ's **Agent-to-Agent (A2A)** and **Model Context Protocol (MCP)** gateways are live the moment KubeMQ runs — both ship on by default on the shared HTTP server, port `9090`. Unlike the seven wire-protocol connectors under [Replace your messaging stack](/deploy/scenarios/replace), which are opt-in and need an enable flag, there's no `CONNECTORS_*_ENABLE` step here: start the broker and the fabric is already listening. ## Three ways in [#three-ways-in] ## What the fabric gives you [#what-the-fabric-gives-you] Both doors open into the same fabric, not two bolted-together servers: * A shared discovery and registry layer so callers find agents by capability * Streaming responses over **Server-Sent Events (SSE)** for long-running work * An **HA agent registry** replicated to every cluster node so a node failure doesn't strand an agent * Guardrails — TTL liveness, request timeouts, and per-agent concurrency caps — that keep a whole agent fleet production-safe rather than a demo The full picture, including the enterprise envelope and both getting-started guides, lives on [KubeMQ Aiway](/aiway). **Didn't work?** * **Nothing answers on `:9090`** — the fabric is only live while KubeMQ itself is running; confirm with `docker ps` or `kmq status`, or work through [Quickstart](/deploy/quickstart) if you haven't started it yet. * **Port 9090 not published** — if you started KubeMQ with a custom `docker run` (or ``), make sure `9090` is exposed. * **No license key** — a standalone server refuses to boot without one; see [Get a license key](/deploy/license-key). # Register your first agent (/deploy/scenarios/agents/register-first-agent) KubeMQ must be running — see the [Quickstart](/deploy/quickstart#get-your-key) (steps 1–2 get you there; instant if it's already up). The A2A gateway is already live on `:9090` — no enable step. Any existing HTTP service becomes a callable agent by registering its URL; the service itself needs no KubeMQ SDK, no new dependency, and no code change beyond answering a POST. ## 1 · Start a throwaway agent [#1--start-a-throwaway-agent] You need something listening on the other end before you register it. This \~15-line Python HTTP server plays the part of your existing service for this walkthrough — it accepts the gateway's forwarded JSON-RPC request and answers with a valid JSON-RPC 2.0 `result`, no KubeMQ SDK involved: ```python title="order_status_agent.py" from http.server import BaseHTTPRequestHandler, HTTPServer import json class Agent(BaseHTTPRequestHandler): def do_POST(self): n = int(self.headers["Content-Length"]) req = json.loads(self.rfile.read(n)) reply = json.dumps({ "jsonrpc": "2.0", "id": req.get("id"), "result": {"status": "in-transit", "eta": "2 days"}, }).encode() self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(reply) HTTPServer(("0.0.0.0", 8090), Agent).serve_forever() ``` Run it in its own terminal and leave it running: ```bash python3 order_status_agent.py ``` ## 2 · Register your agent [#2--register-your-agent] POST a minimal agent card — just an id, a name, and the URL of the service you want to expose — to `/agents/register`. KubeMQ runs inside a container, so the `url` needs to reach your host machine, not the container's own loopback — `host.docker.internal` is the Docker Desktop hostname that does exactly that: ```bash curl -X POST http://localhost:9090/agents/register \ -H "Content-Type: application/json" \ -d '{ "agent_id": "order-status-agent", "name": "Order Status Agent", "url": "http://host.docker.internal:8090/" }' ``` A `200` response means it's registered — pointed at the toy agent you just started. ## 3 · Discover it's live [#3--discover-its-live] Fetch the platform agent card to confirm the A2A gateway is up and serving cards: ```bash curl http://localhost:9090/.well-known/agent-card.json ``` A `200` with `"name":"kubemq"` means the gateway — and everything you just registered against it — is reachable. ## 4 · Invoke it [#4--invoke-it] Route a JSON-RPC `message/send` request through the gateway to your agent with `POST /a2a/`: ```bash curl -X POST http://localhost:9090/a2a/order-status-agent \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "message/send", "params": { "message": { "parts": [{"text": "What is the status of order 42?"}] } } }' ``` ## Verify [#verify] The response comes back with your toy agent's `result` inside it, relayed unchanged through the gateway: ```json { "jsonrpc": "2.0", "id": 1, "result": { "status": "in-transit", "eta": "2 days" } } ``` A response with an `error` field instead means the gateway couldn't reach your service; see **Didn't work?** below. ## Didn't work? [#didnt-work] * **404 on register or invoke** — confirm KubeMQ is running and `9090` is published. * **Agent not found** — the `agent_id` in the invoke URL must exactly match the one you registered. * **Error instead of result** — the `url` you registered isn't reachable from inside the container network. `host.docker.internal` resolves automatically on Docker Desktop (macOS/Windows); on Linux without Desktop, either add `--add-host=host.docker.internal:host-gateway` to the `docker run` command, or run KubeMQ with `--network host` and register a plain `localhost` URL instead. ## Using your own service [#using-your-own-service] The toy agent above exists only to prove the round trip end-to-end. Swap the registered `url` for wherever your own service already listens — nothing about that service has to change: no KubeMQ SDK, no new dependency, just an existing HTTP endpoint that accepts the gateway's JSON-RPC POST and answers with a `result` (or an `error`) in the same shape. ## Go deeper [#go-deeper] # Replace your messaging stack (/deploy/scenarios/replace) Already running one of these brokers? Point your existing client at KubeMQ instead of rewriting it — change one connection string (or set an environment variable), keep your code, and your app keeps speaking the same wire protocol it already speaks. ## Pick your broker [#pick-your-broker] ## Drop-in levels [#drop-in-levels] Each card above states its **drop-in level** — how much application change the move requires: **endpoint-only** — change the host/port or an env var; client + code unchanged. **client-swap** — swap the client library; app code mostly unchanged. **partial-rewrite** — some app-level changes beyond the client/endpoint. Full definitions and the cross-protocol matrix live in the [migration hub](/connectors/how-to/migration). # Replace JMS, ActiveMQ, or AMQP 1.0 with KubeMQ (/deploy/scenarios/replace/replace-amqp-1-0) You need a license key to start KubeMQ — it's free, about 2-3 minutes (it includes creating a free account). [Get one](/deploy/license-key) . Step 1 below starts the broker with the connector enabled. JMS applications, ActiveMQ's Java clients, and native AMQP 1.0 clients all reach KubeMQ through the **same connector** — one AMQP 1.0 listener on ports 5672 (plain) and 5671 (TLS). Enable it once, then jump to the section for your client. **Drop-in level:** varies by client — JMS is a **client-swap**, native AMQP 1.0 is **endpoint/client**, and ActiveMQ is **client-swap** (Java) or **endpoint** (STOMP, MQTT) — see the [drop-in levels legend](/deploy/scenarios/replace#drop-in-levels). ## 1 · Enable the connector [#1--enable-the-connector] The AMQP 1.0 connector is disabled by default — enable it and publish its ports: ## JMS (Qpid JMS) [#jms] **Client:** Apache Qpid JMS **2.x** (jakarta namespace), or the **1.x** line if your codebase still targets `javax.jms`. Swap only the `ConnectionFactory` — the JMS calls you already wrote (`createSession`, `createProducer`, `createConsumer`, and so on) do not change. ```java // Before — any other JMS provider // ConnectionFactory cf = new ActiveMQConnectionFactory("tcp://old-broker:61616"); // After — Qpid JMS over KubeMQ's AMQP 1.0 connector ConnectionFactory cf = new JmsConnectionFactory("amqp://kubemq-host:5672"); ``` **Verify:** send a `TextMessage` to `queues/orders` and receive it back with `AUTO_ACKNOWLEDGE`. Open the KubeMQ dashboard's AMQP 1.0 page — you should see one connection, one sender link, and one receiver link. ## Native AMQP 1.0 (go-amqp) [#native-amqp-1-0] **Client:** `github.com/Azure/go-amqp` v1.7.0 (AMQP.NET Lite and Apache Qpid Proton clients migrate the same way — change only the endpoint). ```go // Before // conn, err := amqp.Dial(ctx, "amqp://old-broker:5672", nil) // After conn, err := amqp.Dial(ctx, "amqp://kubemq-host:5672", &amqp.ConnOptions{SASLType: amqp.SASLTypePlain("svc-orders", "")}) ``` **Verify:** attach a sender to `/queues/orders`, send one message, then attach a receiver and accept it. Expected output: ```text received: {"id":"1","item":"widget"} ``` ## ActiveMQ [#activemq] **Client:** Apache Qpid JMS — the same client as the [JMS](#jms) section above. ActiveMQ's own **OpenWire** protocol is **not supported**; a Java/JMS ActiveMQ application migrates by swapping its `ConnectionFactory` to Qpid JMS, exactly like the JMS path. ```java // Before — ActiveMQ Classic or Artemis (OpenWire) // ConnectionFactory cf = new ActiveMQConnectionFactory("tcp://activemq:61616"); // After — Qpid JMS over KubeMQ ConnectionFactory cf = new JmsConnectionFactory("amqp://kubemq-host:5672"); ``` **Verify:** same as [JMS](#jms) — send and receive a `TextMessage` on `queues/orders`. Not a Java client? ActiveMQ's STOMP and MQTT clients don't ride this connector — repoint them at KubeMQ's own [STOMP](/deploy/scenarios/replace/replace-stomp) or [MQTT](/deploy/scenarios/replace/replace-mqtt) on-ramp instead. ## What carries over — and what doesn't [#what-carries-over--and-what-doesnt] Queues, Events, and durable Events Store subscriptions carry over cleanly on every path above. JMS/AMQP transactions (including XA) and a client-settable dead-letter queue do not — a poison message past `MaxReceiveCount` is silently dropped, not dead-lettered. ## Didn't work? [#didnt-work] * **Connection refused on 5672** — the AMQP 1.0 connector is opt-in; confirm `CONNECTORS_AMQP10_ENABLE=true` was set on the container that's running (the literal `10` stays attached to `AMQP` with no underscore — `CONNECTORS_AMQP_1_0_ENABLE` does not bind). * **`JMSSecurityException` / SASL auth failure** — if the server has authentication enabled, pass a KubeMQ JWT as the SASL PLAIN password; the username is audit-only. * **ActiveMQ client won't connect** — check it isn't still using OpenWire (`tcp://...:61616`); OpenWire has no KubeMQ equivalent, so Java clients move to Qpid JMS and non-Java clients move to the STOMP or MQTT on-ramp. # Replace Google Cloud Pub/Sub with KubeMQ (/deploy/scenarios/replace/replace-gcp-pubsub) You need a license key to start KubeMQ — it's free, about 2-3 minutes (it includes creating a free account). [Get one](/deploy/license-key) . Step 1 below starts the broker with the connector enabled. Point your existing Google Cloud Pub/Sub client at KubeMQ by setting one environment variable — the connection changes, your topics, subscriptions, and application code don't. **Drop-in level:** endpoint-only ([legend](/deploy/scenarios/replace#drop-in-levels)) ## 1 · Enable the connector [#1--enable-the-connector] The GCP Pub/Sub connector is disabled by default. Start KubeMQ with it turned on: ## 2 · Point your client at KubeMQ [#2--point-your-client-at-kubemq] Set `PUBSUB_EMULATOR_HOST` to the connector's gRPC port. The SDK clears its Google credentials and dials insecure gRPC automatically — no code change. Before (real Google Cloud): ```bash export GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json export GOOGLE_CLOUD_PROJECT=my-project ``` After (KubeMQ): ```bash export PUBSUB_EMULATOR_HOST=localhost:8085 export PUBSUB_PROJECT_ID=my-project # arbitrary; the connector ignores the project segment unset GOOGLE_APPLICATION_CREDENTIALS ``` ## 3 · Smoke test [#3--smoke-test] Run a publish → pull → acknowledge round-trip against the emulator endpoint: ```python import os, time from google.cloud import pubsub_v1 os.environ["PUBSUB_EMULATOR_HOST"] = "localhost:8085" os.environ["PUBSUB_PROJECT_ID"] = "smoke-test" pub = pubsub_v1.PublisherClient() sub = pubsub_v1.SubscriberClient() t = pub.topic_path("smoke-test", "smoke-topic") # -> gcp.smoke-topic s = sub.subscription_path("smoke-test", "smoke-sub") # -> gcp.sub.smoke-sub pub.create_topic(request={"name": t}) sub.create_subscription(request={"name": s, "topic": t}) future = pub.publish(t, data=b"smoke-payload") print(f"published id={future.result()}") time.sleep(0.5) resp = sub.pull(request={"subscription": s, "max_messages": 1}) assert len(resp.received_messages) == 1 sub.acknowledge(request={"subscription": s, "ack_ids": [resp.received_messages[0].ack_id]}) print("smoke test PASSED — message received and acked") ``` Expected output: ```text published id=1 smoke test PASSED — message received and acked ``` (adapted from the [full verification smoke test](/connectors/gcp-pub-sub/reference/migration-from-gcp#verification-smoke-test)) ## What carries over — and what doesn't [#what-carries-over--and-what-doesnt] Your Pub/Sub client code, topic/subscription calls, and message attributes carry over unchanged. GCP auth, IAM, and TLS don't — the connector runs in emulator mode with no authentication — and message ordering is node-local, not cluster-wide. ## Didn't work? [#didnt-work] * **Wrong port** — the connector listens on `8085`, not Google's default. Confirm `PUBSUB_EMULATOR_HOST` points at `localhost:8085`. * **Connector not enabled** — a stock server doesn't bind port `8085` until `CONNECTORS_GCP_ENABLE=true` is set. Check `docker logs kubemq` for a bind error. * **Client still tries real GCP auth** — if `PUBSUB_EMULATOR_HOST` isn't set (or credentials weren't cleared), the SDK authenticates against Google instead of the connector. # Replace Kafka with KubeMQ (/deploy/scenarios/replace/replace-kafka) You need a license key to start KubeMQ — it's free, about 2-3 minutes (it includes creating a free account). [Get one](/deploy/license-key) . Step 1 below starts the broker with the connector enabled. Your Kafka client keeps its library, its code, and the real Kafka wire protocol — only `bootstrap.servers` changes, from your Kafka cluster to a local KubeMQ server. **Drop-in level:** endpoint-only ([legend](/deploy/scenarios/replace#drop-in-levels)) ## 1 · Enable the connector [#1--enable-the-connector] The Kafka connector is disabled by default — enable it and publish its ports: ## 2 · Point your client at KubeMQ [#2--point-your-client-at-kubemq] ```text title="bootstrap.servers" # Before (Kafka) bootstrap.servers=your-kafka-cluster.example.com:9092 # After (KubeMQ) bootstrap.servers=localhost:9092 ``` Same client library, same producer/consumer code — only the seed broker address moves. ## 3 · Smoke test [#3--smoke-test] Produce and consume one record with `kcat` (the librdkafka CLI needs no client code at all): ```bash title="produce" echo "hello kubemq" | kcat -b localhost:9092 -t orders -P ``` ```bash title="consume" kcat -b localhost:9092 -G orders-group -o beginning -c 1 orders ``` `-o beginning` reads from the start of the topic, so a brand-new consumer group still sees the record you just produced (without it, a fresh group starts at the latest offset and the consumer would block waiting for the next message). The producer is silent on success; the consumer prints the record body (kcat also logs consumer-group rebalance lines to stderr). You should see: ```text hello kubemq ``` This is the same round-trip as [Getting Started](/connectors/kafka/tutorials/getting-started), which also covers the same produce/consume flow in Go, Python, Java, JavaScript, C#, Ruby, and Rust — no `kcat` required. ## What carries over — and what doesn't [#what-carries-over--and-what-doesnt] Your client library, code, topic and consumer-group names, and produce/consume semantics all carry over unchanged — only the broker address moves. Bringing an *existing* cluster's topic history and consumer offsets across (rather than starting fresh) is a separate step covered by the migration guide's `kmq migrate` tool. ## Didn't work? [#didnt-work] * **Wrong port** — confirm `9092` (plain) or `9093` (TLS) is published and matches `bootstrap.servers`. * **Connector not enabled** — a stock server doesn't bind the Kafka listener until `CONNECTORS_KAFKA_ENABLE=true` is set. * **Auth mismatch** — if you enabled authentication, confirm your client's SASL/JWT credentials match. # Replace MQTT with KubeMQ (/deploy/scenarios/replace/replace-mqtt) You need a license key to start KubeMQ — it's free, about 2-3 minutes (it includes creating a free account). [Get one](/deploy/license-key) . Step 1 below starts the broker with the connector enabled. Your existing MQTT client keeps its library, its code, and its publish/subscribe calls — only the **broker host** changes. MQTT 3.1.1 and 5.0 clients both connect unchanged (MQTT 3.1 is rejected at CONNECT). **Drop-in level:** endpoint-only ([legend](/deploy/scenarios/replace#drop-in-levels)) ## 1 · Enable the connector [#1--enable-the-connector] The MQTT connector is disabled by default — enable it and publish its ports: **The enable variable is `CONNECTORSMQTT_ENABLE`** — no underscore between `CONNECTORS` and `MQTT`. `CONNECTORS_MQTT_ENABLE` is silently ignored. ## 2 · Point your client at KubeMQ [#2--point-your-client-at-kubemq] ```bash title="Before — existing MQTT broker" mqtt://broker.example.com:1883 mqtts://broker.example.com:8883 ``` ```bash title="After — KubeMQ" mqtt://localhost:1883 mqtts://localhost:8883 ``` Only the host:port changes. Topics keep working as-is if they don't start with a reserved prefix (`events/`, `store/`, `queues/`, `commands/`, `queries/`) — those prefixes select a KubeMQ messaging pattern; everything else routes through the default pattern. ## 3 · Smoke test [#3--smoke-test] Adapted from the MQTT connector's [verification smoke test](/connectors/mqtt/scenarios/migration#verification-smoke-test): ```bash title="Terminal 1 — subscribe" mosquitto_sub -h localhost -p 1883 -t 'events/smoke/test' ``` ```bash title="Terminal 2 — publish" mosquitto_pub -h localhost -p 1883 -t 'events/smoke/test' -m '{"ok":true}' ``` You should see, in Terminal 1: ```text {"ok":true} ``` ## What carries over — and what doesn't [#what-carries-over--and-what-doesnt] Your MQTT library, connection code, and publish/subscribe calls carry over unchanged for both 3.1.1 and 5.0. Retained messages are rejected (not silently dropped), MQTT 3.1 is rejected at CONNECT, and RPC over MQTT only works on 5.0 — see the full deviations list below. ## Didn't work? [#didnt-work] * **Connector not enabled** — the MQTT connector is opt-in; confirm `CONNECTORSMQTT_ENABLE=true` (exact spelling, no underscore before `MQTT`) was set when the container started. * **CONNACK rejected the connection** — check your client isn't sending MQTT 3.1 (protocol level 3); use 3.1.1 or 5.0. * **Message never arrives** — subscribe before you publish (Events is fire-and-forget), and check your topic's first segment isn't unintentionally colliding with a reserved prefix (`events/`, `store/`, `queues/`, `commands/`, `queries/`). # Replace RabbitMQ with KubeMQ (/deploy/scenarios/replace/replace-rabbitmq) You need a license key to start KubeMQ — it's free, about 2-3 minutes (it includes creating a free account). [Get one](/deploy/license-key) . Step 1 below starts the broker with the connector enabled. Your RabbitMQ client keeps its library and its code — only the AMQP connection string changes, from your RabbitMQ host to a local KubeMQ server. **Drop-in level:** endpoint-only ([legend](/deploy/scenarios/replace#drop-in-levels)) ## 1 · Enable the connector [#1--enable-the-connector] The RabbitMQ (AMQP 0-9-1) connector is disabled by default — enable it and publish its ports: ## 2 · Point your client at KubeMQ [#2--point-your-client-at-kubemq] ```text title="AMQP URI swap" # Before (RabbitMQ) amqp://user:password@rabbitmq.example.com:5672/ # After (KubeMQ) amqp://user:password@localhost:5672/ ``` Same `pika` (or any AMQP 0-9-1 client library) code — `queue_declare`, `basic_publish`, and `basic_consume` all work unchanged. ## 3 · Smoke test [#3--smoke-test] Publish then consume one message with `pika`: ```python title="publish" import pika conn = pika.BlockingConnection(pika.URLParameters("amqp://user:pass@localhost:5672/")) ch = conn.channel() ch.queue_declare(queue="smoke-test", durable=True) ch.basic_publish(exchange="", routing_key="smoke-test", body=b"hello-kubemq") conn.close() print("published ok") ``` ```python title="consume" import pika conn = pika.BlockingConnection(pika.URLParameters("amqp://user:pass@localhost:5672/")) ch = conn.channel() method, props, body = ch.basic_get(queue="smoke-test", auto_ack=True) assert body == b"hello-kubemq", f"unexpected body: {body!r}" conn.close() print("consume ok:", body) ``` You should see: ```text published ok consume ok: b'hello-kubemq' ``` This is the same publish/consume check as [Migrating from RabbitMQ → Verification Smoke Test](/connectors/rabbitmq/reference/migration-from-rabbitmq#verification-smoke-test). ## What carries over — and what doesn't [#what-carries-over--and-what-doesnt] Your client library, code, queue and exchange declarations, publisher confirms, and DLX all carry over unchanged — only the connection string moves. AMQP transactions don't carry over (use publisher confirms instead), and a handful of declare arguments are accepted but inert — see the migration guide for the full deviation list. ## Didn't work? [#didnt-work] * **Wrong port** — confirm `5672` (plain) or `5671` (TLS) is published and matches your connection string. * **Connector not enabled** — a stock server doesn't bind the AMQP listener until `CONNECTORS_AMQP_ENABLE=true` is set. * **Auth mismatch** — if authentication is enabled, the SASL PLAIN password must be a valid KubeMQ JWT. # Replace AWS SQS & SNS with KubeMQ (/deploy/scenarios/replace/replace-sqs-sns) You need a license key to start KubeMQ — it's free, about 2-3 minutes (it includes creating a free account). [Get one](/deploy/license-key) . Step 1 below starts the broker with the connector enabled. Your AWS SDK (or an existing LocalStack-style setup) already talks SQS and SNS over HTTP — point its **endpoint** at KubeMQ and everything else — SDK, code, request shapes — stays the same. **Drop-in level:** endpoint-only ([legend](/deploy/scenarios/replace#drop-in-levels)) ## 1 · Enable the connector [#1--enable-the-connector] The AWS connector is disabled by default — enable it and publish its port: Port `4566` is the AWS SDK `endpoint_url` / LocalStack-style convention (`Connectors.Aws.Port` on the client side) — not a fixed broker listener, so it doesn't appear in KubeMQ's shared port tables. That's not a typo. ## 2 · Point your client at KubeMQ [#2--point-your-client-at-kubemq] ```bash title="Before — real AWS" # No endpoint override; the SDK talks to the regional AWS endpoint. ``` ```bash title="After — KubeMQ" export AWS_ENDPOINT_URL_SQS=http://localhost:4566 export AWS_ENDPOINT_URL_SNS=http://localhost:4566 export AWS_ACCESS_KEY_ID=test export AWS_SECRET_ACCESS_KEY=test export AWS_DEFAULT_REGION=us-east-1 # any value works; not enforced ``` Or override the endpoint per-client in boto3: ```python import boto3 sqs = boto3.client( "sqs", endpoint_url="http://localhost:4566", region_name="us-east-1", aws_access_key_id="test", aws_secret_access_key="test", ) ``` Dummy credentials are still required — the connector's accept-any mode doesn't verify the signature value, but the SDK must still form a syntactically valid SigV4 request. ## 3 · Smoke test [#3--smoke-test] Adapted from the AWS connector's [verification smoke test](/connectors/aws/reference/migration-from-aws#verification-smoke-test): ```python import boto3 sqs = boto3.client( "sqs", endpoint_url="http://localhost:4566", region_name="us-east-1", aws_access_key_id="test", aws_secret_access_key="test", ) queue_url = sqs.create_queue(QueueName="smoke-test")["QueueUrl"] sqs.send_message(QueueUrl=queue_url, MessageBody="smoke-test-payload") resp = sqs.receive_message(QueueUrl=queue_url, WaitTimeSeconds=5) msg = resp["Messages"][0] assert msg["Body"] == "smoke-test-payload" sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=msg["ReceiptHandle"]) print("Smoke test PASSED.") ``` You should see: ```text Smoke test PASSED. ``` ## What carries over — and what doesn't [#what-carries-over--and-what-doesnt] Your AWS SDK/CLI, code, and the SQS/SNS wire protocol carry over unchanged — only the endpoint moves. Existing queues/topics are **not** auto-migrated (recreate them against KubeMQ) and a few AWS features (SNS email/SMS/Lambda subscriptions, connector-side TLS) aren't supported — see the deviations list below. ## Didn't work? [#didnt-work] * **Connection refused** — the connector is opt-in; confirm `CONNECTORS_AWS_ENABLE=true` was set when the container started. * **"missing credentials" from the SDK** — accept-any mode still requires a syntactically valid SigV4 request; set dummy `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / region even though their values aren't checked. * **Wrong port** — the AWS connector listens on `4566`, not a broker port you may already have mapped (gRPC `50000`, REST `9090`, dashboard `8080`). # Replace STOMP with KubeMQ (/deploy/scenarios/replace/replace-stomp) You need a license key to start KubeMQ — it's free, about 2-3 minutes (it includes creating a free account). [Get one](/deploy/license-key) . Step 1 below starts the broker with the connector enabled. Point your existing STOMP client at KubeMQ by changing the broker host — the connection changes, your STOMP client library and destination code don't. **Drop-in level:** endpoint-only ([legend](/deploy/scenarios/replace#drop-in-levels)) ## 1 · Enable the connector [#1--enable-the-connector] The STOMP connector is disabled by default. Start KubeMQ with it turned on: The enable variable is `CONNECTORS_STOMP_ENABLE` — with the underscore between `CONNECTORS` and `STOMP`. `CONNECTORSSTOMP_ENABLE` (no underscore) is silently ignored. ## 2 · Point your client at KubeMQ [#2--point-your-client-at-kubemq] Change the broker host to KubeMQ's plain-TCP port. Everything else — the client library, `SEND`/`SUBSCRIBE` calls, destination names — stays the same. Before: ```python import stomp conn = stomp.Connection([("stomp-host", 61613)]) conn.connect("user", "password", wait=True) ``` After (KubeMQ): ```python import stomp conn = stomp.Connection([("localhost", 61613)]) conn.connect("user", "password", wait=True) # any login/passcode when auth is disabled ``` ## 3 · Smoke test [#3--smoke-test] In one terminal, subscribe to a queue destination: ```python # consumer.py import stomp, time class Listener(stomp.ConnectionListener): def __init__(self, conn): self._conn = conn def on_message(self, frame): print("RECEIVED:", frame.body) self._conn.ack(frame.headers["ack"]) conn = stomp.Connection([("localhost", 61613)]) conn.set_listener("", Listener(conn)) conn.connect(wait=True) conn.subscribe(destination="/queue/smoke-test", id="s1", ack="client-individual") time.sleep(10) conn.disconnect() ``` In a second terminal, publish one message: ```python # producer.py import stomp conn = stomp.Connection([("localhost", 61613)]) conn.connect(wait=True) conn.send(destination="/queue/smoke-test", body="hello from stomp") conn.disconnect() print("sent") ``` Expected output in the consumer terminal: ```text RECEIVED: hello from stomp ``` (from the [full verification smoke test](/connectors/stomp/scenarios/migration-from-stomp#verification-smoke-test)) ## What carries over — and what doesn't [#what-carries-over--and-what-doesnt] Your STOMP client library, connection API, and all five destination types (`/queue`, `/topic`, `/topic-store`, `/command`, `/query`) carry over unchanged. STOMP transactions and message selectors don't — both are rejected outright. ## Didn't work? [#didnt-work] * **Wrong port** — plain TCP is `61613`; TLS is `61614`. Confirm your client dials the one you enabled. * **Connector not enabled** — a stock server doesn't bind the STOMP listener until `CONNECTORS_STOMP_ENABLE=true` is set (verbatim, with the underscore). Check `docker logs kubemq` for a bind error. * **Auth mismatch** — if `Authentication.Enable = true` on the server, the CONNECT `passcode` must carry a valid KubeMQ JWT; with auth disabled, any value works. # Authentication (/connectors/amqp/how-to/authentication) This guide explains how a native AMQP 1.0 client proves *who it is* to the KubeMQ AMQP 1.0 connector (authentication / SASL) and *what it may do* once attached (authorization). It covers the three SASL mechanisms, how the client identity (`ClientID`) is derived, why `container-id` matters, and the audit events the connector emits. On a stock dev broker, authentication is **off** and clients connect **ANONYMOUS** — so the examples clone-and-run with no credentials. SASL **PLAIN** with a KubeMQ JWT is the one credentialed mechanism that also runs on a stock broker; SASL **EXTERNAL** requires mTLS (see [TLS and mTLS](/connectors/amqp/how-to/tls-and-mtls)). ## The three SASL mechanisms [#the-three-sasl-mechanisms] The connector computes the SASL mechanism list **per connection** from the auth and TLS context and offers it in a fixed order: **EXTERNAL → PLAIN → ANONYMOUS**. | Mechanism | Offered when | Credential | Identity (`ClientID`) becomes | | --------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | **`PLAIN`** | **always** | RFC 4616 `authzid\x00authcid\x00passwd`; the **password is the KubeMQ JWT** | auth on → `ClientID` from the JWT; auth off → sanitized SASL username, else the `container-id` | | **`ANONYMOUS`** | only when the auth service is **disabled** | none | sanitized `container-id` | | **`EXTERNAL`** | only on an **mTLS** connection with a *verified* client certificate | the certificate itself (no JWT) | the cert **Subject CN**, sanitized | ### PLAIN — the documented contract [#plain--the-documented-contract] PLAIN is the mechanism to use when authentication is enabled. The credential layout is the standard RFC 4616 triple `authzid \x00 authcid \x00 passwd`: * **`passwd` (password) = the KubeMQ JWT.** It is validated server-side; on success the connector takes the `ClientID` from the JWT's claims. * **`authcid` (username) is audit-only / informational when auth is on.** It does **not** become the identity — the JWT does. (When auth is *off*, the username is used as a convenience identity; see precedence below.) * The SASL initial-response binary is capped at **16 KiB**. A larger response is rejected as a malformed/hostile peer. A KubeMQ JWT fits comfortably. * **No SCRAM-SHA-256, GSSAPI, or Azure CBS.** PLAIN is the only credentialed mechanism. Most clients accept a `(username, password)` pair — put the JWT in the **password** slot: ```go // Azure/go-amqp — SASL PLAIN: username is audit-only, password is the KubeMQ JWT. conn, err := amqp.Dial(ctx, "amqp://broker:5672", &amqp.ConnOptions{ SASLType: amqp.SASLTypePlain("audit-username", os.Getenv("KUBEMQ_AMQP_JWT")), }) if err != nil { log.Fatalf("dial (bad/expired JWT? auth-disabled broker?): %v", err) } defer conn.Close() ``` ```python # python-qpid-proton — SASL PLAIN: username audit-only, password is the KubeMQ JWT. # allow_insecure_mechs permits PLAIN over plaintext amqp://; use amqps:// in production. conn = BlockingConnection( "amqp://broker:5672", user="audit-username", password=os.environ["KUBEMQ_AMQP_JWT"], allowed_mechs="PLAIN", allow_insecure_mechs=True, ) ``` ```java // qpid-jms — the username/password passed to createConnection become the SASL // PLAIN authcid/password (password = the KubeMQ JWT). Pin PLAIN on the URI. String url = "amqp://broker:5672?amqp.saslMechanisms=PLAIN"; JmsConnectionFactory factory = new JmsConnectionFactory(url); Connection connection = factory.createConnection( "audit-username", System.getenv("KUBEMQ_AMQP_JWT")); connection.start(); ``` ```csharp // AMQPNetLite — an Address carrying User + Password makes the client negotiate PLAIN // (password = the KubeMQ JWT). The host/port/user/password ctor avoids URL-encoding it. var baseAddress = new Address("amqp://broker:5672"); var connectAddress = new Address( baseAddress.Host, baseAddress.Port, "audit-username", Environment.GetEnvironmentVariable("KUBEMQ_AMQP_JWT"), "/", baseAddress.Scheme); var connection = await Connection.Factory.CreateAsync(connectAddress); ``` ```typescript // rhea / rhea-promise — setting both username + password makes rhea select PLAIN // (password = the KubeMQ JWT). const connection = await container.connect({ host: "broker", port: 5672, container_id: `kubemq-amqp10-js-${process.pid}`, username: "audit-username", password: process.env.KUBEMQ_AMQP_JWT, }); ``` ```rust // fe2o3-amqp — SASL PLAIN: username audit-only, password is the KubeMQ JWT. let mut connection = Connection::builder() .container_id("amqp10-client") .sasl_profile(SaslProfile::Plain { username: "audit-username".to_string(), password: std::env::var("KUBEMQ_AMQP_JWT")?, }) .open("amqp://broker:5672") .await?; ``` ### ANONYMOUS — the auth-off default [#anonymous--the-auth-off-default] When the broker's authentication service is disabled, the connector offers `ANONYMOUS`. This is the mechanism the runnable examples rely on: no credential is sent and the identity falls back to the `container-id`. **ANONYMOUS is only offered when auth is off** — requesting it against an auth-enabled broker is rejected as an auth failure (it was never advertised). ### EXTERNAL — mTLS, cert CN → ClientID [#external--mtls-cert-cn--clientid] `EXTERNAL` is offered **only** when the TLS handshake presented a client certificate that the listener *verified* (the mTLS listener). The identity is the client certificate's **Subject CN**, sanitized to a valid `ClientID`; no JWT is needed. An empty CN is rejected. Because EXTERNAL depends on mTLS, it is covered alongside TLS — see [TLS and mTLS](/connectors/amqp/how-to/tls-and-mtls). ## Identity precedence [#identity-precedence] The connector resolves the client identity in this order, depending on the negotiated mechanism and whether auth is enabled: 1. **PLAIN + auth on** → the JWT's `ClientID` claim. 2. **PLAIN + auth off** → the sanitized SASL username (`authcid`); if empty, the `container-id`. 3. **EXTERNAL** → the client certificate's Subject CN (sanitized). 4. **ANONYMOUS** → the sanitized `container-id`. 5. **bare (no SASL)** → the sanitized `container-id`; if empty, a generated `amqp10-`. Sanitization caps the value at **256** characters and maps every character outside `[a-zA-Z0-9_-]` to `_`. ## `container-id` is required — and must be stable for durable subscribers [#container-id-is-required--and-must-be-stable-for-durable-subscribers] Every AMQP 1.0 `OPEN` **must** carry a non-empty `container-id`. An empty value is rejected with `CLOSE(amqp:invalid-field, "open.container-id is required")`. The value is sanitized (`[a-zA-Z0-9_-]`, ≤ 256). `container-id` matters for two reasons beyond identity: * It becomes the `ClientID` whenever there is **no SASL identity** (ANONYMOUS / bare / auth-off PLAIN-with-empty-username). * It is **half of the durable-subscription identity**. A durable events-store subscriber that reconnects with a *different* `container-id` will not resume its old position — it becomes a different durable identity. **Set a stable `container-id` for any durable subscriber.** Because the container-id is half the durable identity, a subscriber that lets its container-id drift across reconnects (e.g. a randomly generated one) will never resume — it creates a new subscription each time. See [Reliability](/connectors/amqp/how-to/reliability). ## Authorization — enforced at attach, per resource [#authorization--enforced-at-attach-per-resource] Once authenticated, the connector enforces a Casbin policy **at `ATTACH` time**, against the `ClientID`, the resolved pattern (mapped to a resource name), the channel, and the link role. If no authorizer is wired (auth off), nothing is enforced. | Link the client attaches | Server role | Permission enforced | | ---------------------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------- | | **Receiver** from `/` (client *consumes*) | server-sender | **Read** on `(ClientID, resource, channel)` | | **Sender** to a fixed `/` (client *produces*) | server-receiver | **Write** on `(ClientID, resource, channel)` | | **Sender** with a **null (anonymous) target** | server-receiver | **deferred** — each message is authorized **per-message with Write** on its `properties.to` | | **`/responses/`** (RPC reply token) | server-receiver | **not enforced** — connection-scoped reply token | * The resource name maps the pattern: `events-store` → `events_store`; `queues` / `events` / `commands` / `queries` map to themselves. * **Anonymous-terminus links** cannot be checked at attach because there is no fixed channel yet. Instead, the transfer layer authorizes **each message** with a **Write** check on the message's `to`, backed by a short-lived LRU cache. See [Addressing](/connectors/amqp/concepts/addressing). A denied authorization closes the link with `DETACH(amqp:unauthorized-access)` and a generic description. **No policy internals leak.** Your client should surface the `amqp:unauthorized-access` condition and treat it as a permission error, not retry it as transient. ## Audit events — exactly two [#audit-events--exactly-two] The connector's audit surface for AMQP 1.0 lives entirely in the SASL layer and emits **only two event types**: | Audit event | Emitted on | Fields | | ------------------ | ----------------------------------- | ----------------------------------------------------------------------------------------- | | **`auth.success`** | successful SASL authentication | `ClientID`, `Transport: "amqp10"`, `SourceIP`, `Metadata{mechanism}` | | **`auth.failure`** | failed/rejected SASL authentication | `ClientID`, `Transport: "amqp10"`, `SourceIP`, `Error` (sanitized), `Metadata{mechanism}` | The AMQP 1.0 connector does **not** emit `client.connected` or `client.disconnected` audit events. Do not build alerting, dashboards, or compliance reporting that depends on connection-lifecycle audit events from this connector — they are not produced. The only audit signal is authentication success/failure (with the SASL mechanism and source IP). For connection/link visibility, use the dashboard API and Prometheus metrics — see [Connections endpoint](/connectors/amqp/reference/connections-endpoint). ## Quick decision guide [#quick-decision-guide] | You want… | Do this | | ------------------------------------------ | -------------------------------------------------------------------------------------------------------- | | Clone-and-run on a stock dev broker | Connect ANONYMOUS (no credentials) | | Authenticate with KubeMQ identity | SASL **PLAIN**, JWT in the **password** slot; username is audit-only | | Authenticate with a client certificate | mTLS + SASL **EXTERNAL** (cert CN → ClientID) — see [TLS and mTLS](/connectors/amqp/how-to/tls-and-mtls) | | Resume a durable events-store subscription | Set a **stable `container-id`** (it is half the durable identity) | | Diagnose a permission failure | Look for `DETACH(amqp:unauthorized-access)`; check the Read/Write policy for that channel | ## Related [#related] # Flow Control (/connectors/amqp/how-to/flow-control) In AMQP 1.0, **link credit** is what publisher confirms and consumer prefetch/QoS are in other protocols: it is the unit of flow control. A sender may not transmit a `TRANSFER` until the receiver has granted `link-credit > 0` via a `FLOW`. This guide is the practical playbook for driving the KubeMQ AMQP 1.0 connector's credit machinery — and, first and foremost, for avoiding the two ways credit mismanagement silently loses data. For the conceptual credit model, see the [Events](/connectors/amqp/concepts/events) and [Events Store](/connectors/amqp/concepts/events-store) pattern pages. ## The two data-loss footguns — read this first [#the-two-data-loss-footguns--read-this-first] These are the two most expensive mistakes you can make against this connector. Both lose messages **silently** — no error, no `DISPOSITION`, no `DETACH` (footgun A) — because the events and events-store patterns deliver **pre-settled** (at-most-once). Internalize them before you write a subscriber. **Footgun A — Events at 0 credit are silently DROPPED.** On an `events/` consume link, a message that arrives while your **link-credit is 0 is silently dropped and counted** — there is no error and no `DISPOSITION`. This is true at-most-once: the message is simply gone. It bites a slow consumer that lets its credit drain, or a subscriber that attaches *after* a publish (events have no replay). **Defense: grant credit continuously.** Open the receiver with a healthy standing credit and replenish *eagerly*, well before it hits 0. Watch `kubemq_amqp10_events_dropped_no_credit_total` — a non-zero value is silent data loss. **Footgun B — Events-Store stalled credit loses the buffered, already-acked window.** An `events-store/` consume link fronts the durable subscription with a **deliver-first ring buffer** (cap `MaxUnsettledPerLink` ≈ 1024) so the broker callback never stalls. The buffer's positions are **auto-acked *before* you take delivery**. If the buffer fills while your credit stays at 0, the link `DETACH`es with `amqp:resource-limit-exceeded` (`"credit stalled"`) and the **entire buffered window — already auto-acked — is lost**; a durable re-attach resumes *after* it. **Defense: size `MaxUnsettledPerLink` to your real prefetch and replenish credit aggressively** so the buffer never fills with credit at zero. The lost window is counted in `kubemq_amqp10_events_store_dropped_stalled_total`. **Why queues are safe by contrast.** A queue consume link never drops on low credit: when you stop granting, KubeMQ simply stops delivering and the messages wait in the queue. The drop footguns are specific to the **pre-settled pub/sub** patterns (events, events-store). ## Who grants credit — the rule that governs everything [#who-grants-credit--the-rule-that-governs-everything] The single most important distinction: **on a *consume* (server-sender) link, YOU must grant credit; on a *produce* (server-receiver) link, the *server* grants credit.** | Your link | Server role | Who grants credit | If credit is 0… | | -------------------------------------------------- | --------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | **Receiver** from `/` (you *consume*) | server-sender | **you (the client)** must `FLOW` `link-credit > 0` | **queues**: delivery pauses, messages wait. **events**: dropped (footgun A). **events-store**: buffered then stalled-`DETACH` (footgun B) | | **Sender** to `/` (you *produce*) | server-receiver | **the server** grants you credit | you may not `TRANSFER` until you receive the server's `FLOW` | ### Produce path — the server grants you credit [#produce-path--the-server-grants-you-credit] On attach, a server-receiver link **immediately emits a `FLOW`** granting `link-credit = MaxUnsettledPerLink` (default 1024, clamped `[1, 1<<20]`; fallback 256 if unset). You **MUST NOT** send a `TRANSFER` before you receive that credit. As you complete deliveries the server **replenishes** the window (a fresh `FLOW`) when remaining credit falls below half, so a steady producer never stalls. The session `incoming-window` (2048) is a secondary bound. ### Consume path — you grant the server credit [#consume-path--you-grant-the-server-credit] A server-sender link delivers **nothing** until you send a `FLOW` with `link-credit > 0`. The effective credit the server may use is `(flow.delivery-count + flow.link-credit) − server.delivery-count`, clamped ≥ 0. * For **automatic** credit, grant a modest standing credit (e.g. **100–1000**, but **≤ `MaxUnsettledPerLink` = 1024**) when you open the receiver, and let your client replenish on settlement. * For **manual** credit control (`IssueCredit` / `DrainCredit`), open the receiver with **`Credit: -1`** — otherwise your client auto-manages credit and your manual calls fight it. ## Prefetch, `GetBatchSize`, and `MaxUnsettledPerLink` [#prefetch-getbatchsize-and-maxunsettledperlink] These three knobs shape how the connector pulls from the broker and how much it will hold unsettled: | Knob | Default | Meaning | | -------------------------- | ---------- | ------------------------------------------------------------------------------------------------ | | **link-credit (prefetch)** | you choose | the standing credit you grant a consume link = your prefetch depth | | **`GetBatchSize`** | `32` | the per-`Get` ceiling on a **queue** consume link | | **`MaxUnsettledPerLink`** | `1024` | the per-link unsettled / pub-sub buffer cap; also drives the **inbound** (produce) credit window | For a queue consume link, each `Get` reserves `min(credit, GetBatchSize=32, MaxUnsettledPerLink − unsettled)` and issues a downstream `Get` with a 1000 ms long-poll. So even with high standing credit, a single `Get` pulls at most 32 messages — credit controls overall in-flight depth, `GetBatchSize` controls batch granularity. `MaxUnsettledPerLink` is the dial that protects you from footgun B — it is the events-store ring-buffer cap. It is a server config field (`CONNECTORS_AMQP10_MAX_UNSETTLED_PER_LINK`); you respect it client-side by keeping your standing credit ≤ it and replenishing eagerly. See [Configuration](/connectors/amqp/concepts/configuration). ## Drain [#drain] Drain is how a consumer says "give me everything you have, then stop." Send a `FLOW` with `drain=true`. The server then: 1. **advances `delivery-count`** by the remaining credit, 2. **zeroes the credit**, and 3. **echoes a `FLOW`** with `link-credit=0, drain=true` (the drain response). Drain completes **promptly** — it does not hang. A held remainder (messages that didn't fit the drained credit) **resumes on a fresh `IssueCredit`**. Two requirements your client must honor: * A `FLOW` **MUST carry `next-incoming-id`** once the session is established. * To drive drain manually, the receiver must be in manual-credit mode (`Credit: -1`). ## Putting it together [#putting-it-together] | Pattern | Consume-side credit hygiene | | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Queues** | Safe: grant standing credit; if you stop granting, messages wait. No drop. | | **Events** | **Footgun A.** Grant generous standing credit, replenish *before* it hits 0, subscribe before publishing. A gap at 0 credit = silent loss. | | **Events-Store** | **Footgun B.** Size `MaxUnsettledPerLink` to your prefetch; replenish aggressively. A genuine stall overflows the deliver-first buffer and loses the already-acked window. | | **Commands / Queries (RPC)** | The responder pump runs under credit; grant the responder credit and grant the dynamic reply node credit so replies can land. | ## Monitoring [#monitoring] Watch these metrics — a non-zero value on the first two **is silent data loss**: | Metric | Meaning | | -------------------------------------------------- | ------------------------------------------------------------------------ | | `kubemq_amqp10_events_dropped_no_credit_total` | **Footgun A** — events dropped at 0 credit | | `kubemq_amqp10_events_store_dropped_stalled_total` | **Footgun B** — events-store buffered window lost to a credit stall | | `kubemq_amqp10_transfers_in_dropped_total` | inbound transfers dropped (oversize / no-consumer / pre-settled failure) | See [Connections endpoint](/connectors/amqp/reference/connections-endpoint) for the full metric and dashboard surface. ## Related [#related] # Reliability (/connectors/amqp/how-to/reliability) AMQP 1.0 reliability is **settlement modes + delivery state**, not publisher confirms and not numeric reason codes. This guide is the practical playbook for getting the delivery guarantee you want from the KubeMQ AMQP 1.0 connector: which settlement modes exist (and which is rejected), how each delivery-state outcome maps to a KubeMQ queue action, how durable subscriptions resume, what happens on disconnect (nothing is lost), and why there is no connector dead-letter exchange. For the credit machinery that governs *when* deliveries arrive — and the pre-settled-pattern data-loss footguns — see [Flow control](/connectors/amqp/how-to/flow-control). ## Settlement modes — pick your guarantee at ATTACH [#settlement-modes--pick-your-guarantee-at-attach] Settlement is negotiated when the link attaches. ### `snd-settle-mode` (the produce / out path) [#snd-settle-mode-the-produce--out-path] | Requested | Server behavior | Guarantee | | ------------------------------ | -------------------------------------------------------------------------------------------------------- | ------------------------------ | | **`settled`** | honored — each outbound `TRANSFER` carries `settled=true` and the server acks it immediately at send | **at-most-once** (pre-settled) | | `unsettled` / `mixed` / absent | server uses **`unsettled`** — sends deliveries unsettled, tracks them, waits for your `DISPOSITION` | **at-least-once** (default) | * **At-least-once (default):** leave `snd-settle-mode` unset (or `unsettled`). The server keeps each delivery tracked until you settle it; if you never do (you disconnect), it is requeued. * **At-most-once:** request `snd-settle-mode=settled`. Use this for high-throughput fire-and-forget where occasional loss is acceptable. (Events are *always* pre-settled regardless — see [Events](/connectors/amqp/concepts/events).) ### `rcv-settle-mode` (the consume / in path) [#rcv-settle-mode-the-consume--in-path] * The server **always** replies `rcv-settle-mode=first`. * **`rcv-settle-mode=second` is NOT implemented.** Requesting it closes the link with `DETACH(amqp:not-implemented)` **before the link is built**. Pin `first`. There is no two-stage (`second`) receiver settlement. Your consumer sends a `DISPOSITION` with a terminal delivery state and that settles the delivery in one step. ## Delivery-state outcomes → KubeMQ actions [#delivery-state-outcomes--kubemq-actions] When your client is the **receiver** (consuming a queue), it settles each delivery by sending a `DISPOSITION` carrying a terminal **delivery state**. The connector maps that state to a KubeMQ queue `AckRange` (settle/remove) or `NAckRange` (requeue): | Client delivery state | KubeMQ action | Queue request | Effect on the message | | ---------------------------------------------------------------- | ---------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------ | | **`accepted`** | settle / consume | **AckRange** | removed from the queue | | **`rejected`** | discard | **AckRange** | discarded; **poison handled by the broker `MaxReceiveQueue` policy — there is NO connector DLX** | | **`released`** | requeue to tail | **NAckRange** | redelivered with a grown `delivery-count`, `first-acquirer=false`; **increments receive-count** toward `MaxReceiveCount` | | **`modified{delivery-failed}` / `modified{undeliverable-here}`** | requeue to tail | **NAckRange** | requeued (no per-consumer exclusion) | | **nil state** (settled, no outcome) | treat as success | **AckRange** | removed | | **unknown terminal state** | conservatively requeue | **NAckRange** | requeued — **never silently dropped** | * A `DISPOSITION` may cover a `first..last` delivery-id range; the connector resolves it against the per-link unsettled map, groups by `RefTransactionId`, and emits one `AckRange`/`NAckRange` per group. Re-settling an already-settled id is **idempotent**. * **`rejected` does NOT dead-letter through the connector.** It discards (AckRanges) the message; whether a repeatedly-failing message is moved aside is a **broker-side `MaxReceiveQueue` poison policy**, not an AMQP-controllable per-link feature. **`released` / `modified` increment the receive-count.** Every NAck-for-redelivery bumps `ReceiveCount`, so a message you keep releasing will eventually hit the broker's `MaxReceiveCount` cap and be removed even though you never `rejected` it. There is **no requeue-without-increment**. ## Body sections [#body-sections] A message body must be one of two AMQP sections: * **`Data`** (binary) — the default; multiple `Data` sections concatenate. * **`AmqpValue`** (a typed value) — use for typed bodies. An **empty body is valid** (it becomes an empty `Data` body downstream). **`AmqpSequence` bodies are rejected.** Only `Data` and `AmqpValue` are supported. A message carrying an `AmqpSequence` body section gets a `rejected` `DISPOSITION` then a `DETACH` with `amqp:not-implemented`. Emit `Data` by default and `AmqpValue` for typed bodies; never `AmqpSequence`. ## Confirming a produce [#confirming-a-produce] When your client is the **sender**, the *server* is the receiver and settles your delivery for you: * On broker success it emits a settled `DISPOSITION(role=receiver, accepted)`. * On failure it emits `rejected{condition}`: broker-not-ready → `amqp:not-allowed`; decode error → `amqp:decode-error`; translate error → `amqp:invalid-field`; array/broker error → `amqp:internal-error`. * A **pre-settled** inbound delivery gets **no** disposition; a failure is dropped, logged, and counted in `kubemq_amqp10_transfers_in_dropped_total`. If you need to know a produce succeeded, do **not** pre-settle — send unsettled and read the server's `DISPOSITION`. ## Durable subscriptions [#durable-subscriptions] The **events-store** pattern is a durable, replayable subscription. To consume durably, attach a **receiver** from `events-store/` with terminus **`expiry-policy = never`**, a **stable container-id**, and a **stable link `Name`**. On reconnect with the same identity, the subscription resumes where it left off. The durable identity is derived from your `container-id` and link name. The practical consequence: **to resume, reconnect with the same container-id AND the same link name.** Change either and you get a *different* durable subscription that starts fresh. Durable subscriptions (and dynamic reply nodes) are **node-local**: a durable subscription created on node A is not visible from node B. Cluster-wide *uniqueness* of the durable identity is still enforced, but a durable subscriber must reconnect to the **same node** to resume — use load-balancer session affinity or a sticky connection. A durable receiver's start position is set with the link property **`x-opt-kubemq-start`** (it applies only to `events-store`): | `x-opt-kubemq-start` value | Meaning | | ------------------------------ | ----------------------------------------------------------------------- | | `""` or `new-only` | only messages published **after** the subscription starts (the default) | | `first` | replay from the **beginning** of stored history | | `last` | start from the **most recent** stored message | | `sequence:` | start at sequence number `` (1-based, non-negative) | | `time:` | start at a wall-clock time | | `time-delta:` | start `` ago from now | There is **no "last N by count"** — use `sequence:`, `time:`, or `time-delta:` to bound a replay. A malformed value (`sequence:abc`, `time:not-a-time`, or an unknown token) is rejected at attach with `DETACH(amqp:invalid-field)` naming the offending token. ## Redelivery and teardown NAck-all [#redelivery-and-teardown-nack-all] `released`, `modified`, and **disconnect-with-unsettled** all requeue the delivery to the tail. A fresh consumer recovers it. A redelivered copy carries a grown `header.delivery-count` and `first-acquirer=false`, so your consumer can detect a redelivery. **On link detach, connection close, or graceful shutdown, every unsettled delivery is `NAckRange`'d exactly once** — returned to the queue tail (the client experiences it as `released`). A disconnecting at-least-once consumer **loses nothing**: whatever it had not yet `accepted` is simply redelivered to the next consumer. This guarantee applies to **queue** consume links — events and events-store are pre-settled and have their own footguns (see [Flow control](/connectors/amqp/how-to/flow-control)). ## No connector DLX, no visibility timeout [#no-connector-dlx-no-visibility-timeout] The connector has **no dead-letter exchange and no visibility/redelivery timeout**: * `rejected` discards via `AckRange`; it does **not** route to a dead-letter destination. * There is no per-link "make this message invisible for N seconds" verb. Queue receive is **destructive, credit-based consume only** — no peek, no browse. * **Poison handling is entirely broker-side**: the broker's `MaxReceiveQueue` / `MaxReceiveCount` policy removes a message redelivered too many times. Design your consumer accordingly: `accept` on success, `reject` for a permanently-bad message (discarded, with the broker policy as your only poison backstop), and `release` / `modify` for a transient failure you want retried — knowing each retry bumps the receive-count toward the broker's cap. ## Decision guide [#decision-guide] | You want… | Do this | | --------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | At-least-once consume (no loss on disconnect) | Consume **unsettled** (default `rcv-settle-mode=first`); `accept` on success; rely on teardown NAck-all | | At-most-once produce (fire-and-forget) | Request `snd-settle-mode=settled` | | Confirm a produce succeeded | Send **unsettled** and read the server's `DISPOSITION(accepted/rejected)` | | Retry a transient failure | `release` (or `modify`) — but each retry **increments the receive-count** | | Discard a permanently-bad message | `reject` — discarded via AckRange; broker `MaxReceiveQueue` is the poison backstop | | Resume after reconnect | events-store + stable `container-id` + stable link name + `expiry-policy=never` | ## Related [#related] # TLS and mTLS (/connectors/amqp/how-to/tls-and-mtls) The KubeMQ AMQP 1.0 connector exposes TLS on `amqps://:5671`, mutual TLS (mTLS), and SASL **EXTERNAL** so you can harden it for production. This guide documents how to configure them. TLS is configured server-side from the top-level **`Security`** block (not from any AMQP-specific field). The runnable examples all use plain `amqp://` against a stock dev broker; to use `amqps://`, supply your own certificates and configure the `Security` block — see [Configuration](/connectors/amqp/concepts/configuration). For the shared TLS/security model across KubeMQ connectors, see [Auth & security](/connectors/reference/auth-and-security). ## Ports and schemes [#ports-and-schemes] The connector listens on two ports, both **shared with the AMQP 0-9-1 connector** through the `amqpmux` listener: | Scheme | Port | When active | | ------------------- | --------------------- | ---------------------------------------------------------- | | `amqp://host:5672` | `5672` (plain / SASL) | always (unless `CONNECTORS_AMQP10_PORT=0`) | | `amqps://host:5671` | `5671` (TLS) | **only when the top-level `Security` block is configured** | The TLS port is controlled by `CONNECTORS_AMQP10_TLS_PORT` (default `5671`; `0` disables it). The certificate material, the CA, and the mode all come from the **top-level `Security` block**. There is **no AMQP-over-WebSocket**: raw TCP and TLS only. ## TLS server-auth vs mTLS [#tls-server-auth-vs-mtls] The connector derives its TLS configuration from the `Security` mode. ### Server authentication only [#server-authentication-only] * The server presents its certificate; the minimum protocol is **TLS 1.2**. * **No client certificate is requested or verified.** * The client authenticates separately, at the SASL layer (PLAIN with a JWT, or ANONYMOUS if auth is off). Use `amqps://` for the transport and SASL PLAIN for identity. ```text # conceptual: server-auth TLS + SASL PLAIN (JWT in password) amqps://broker:5671 + SASL PLAIN("audit-user", "") ``` ### Mutual TLS [#mutual-tls] * The server presents its certificate **and** requires a client certificate (`RequireAndVerifyClientCert`), with the client-CA pool built from `Security.Ca`. The minimum protocol is **TLS 1.2**. * A verified client certificate is the **precondition for SASL EXTERNAL**. ## SASL EXTERNAL — cert CN → ClientID [#sasl-external--cert-cn--clientid] `EXTERNAL` is offered **only** when the connection is mTLS **and** the client presented a *verified* client certificate. When you authenticate with EXTERNAL: * **No JWT is sent.** The certificate *is* the credential. * The client identity (`ClientID`) becomes the client certificate's **Subject CN**, sanitized to a valid `ClientID` (`[a-zA-Z0-9_-]`, ≤ 256). An empty CN is rejected as an auth failure. * Authorization (Read/Write at attach) then runs against that CN-derived `ClientID` exactly as for PLAIN — see [Authentication](/connectors/amqp/how-to/authentication). ```text # conceptual: mTLS + SASL EXTERNAL (identity = client cert CN, no JWT) amqps://broker:5671 + client cert (CN=order-service) + SASL EXTERNAL # resolved ClientID = "order-service" ``` **EXTERNAL is available only when `Security.Mode == mtls`.** Plain TLS (server-auth only) does not present a verified client certificate, so EXTERNAL is not offered there — fall back to PLAIN (JWT) or ANONYMOUS. The offered-mechanism order is **EXTERNAL → PLAIN → ANONYMOUS**, so on an mTLS connection a spec-conformant client that supports EXTERNAL picks it first. ## How the TLS listener shares the `amqpmux` port [#how-the-tls-listener-shares-the-amqpmux-port] Both AMQP dialects (0-9-1 and 1.0) coexist on the **same** ports. The `amqpmux` listener accepts every connection, reads the **8-byte AMQP protocol header**, and dispatches by dialect: | Header bytes | Meaning | | ---------------------- | ------------------------------------------------------------- | | `AMQP\x00\x00\x09\x01` | AMQP 0-9-1 | | `AMQP\x00\x01\x00\x00` | AMQP 1.0, plain (bare/AMQP layer) | | `AMQP\x03\x01\x00\x00` | AMQP 1.0, SASL layer | | `AMQP\x02\x01\x00\x00` | AMQP 1.0, **TLS** token — only meaningful on the TLS listener | For TLS, the connection is wrapped in the `Security`-block TLS configuration **before** the header is interpreted. The upshot for clients: point an `amqps://` AMQP 1.0 client at `:5671`, and the same listener that serves 0-9-1 routes you to the 1.0 engine. ## Production checklist [#production-checklist] | Goal | Configuration | | ------------------------------------------------ | ------------------------------------------------------------------------ | | Encrypt transport, authenticate clients with JWT | `Security` mode `tls` + `amqps://:5671` + SASL PLAIN (JWT in password) | | Authenticate clients with certificates (no JWT) | `Security` mode `mtls` + `amqps://:5671` + SASL EXTERNAL (CN → ClientID) | | Keep plain `amqp://` for local/dev | leave `CONNECTORS_AMQP10_PORT=5672`; the examples use this | | Disable the TLS port | `CONNECTORS_AMQP10_TLS_PORT=0` (or leave the `Security` block unset) | ## Related [#related] # Addressing (/connectors/amqp/concepts/addressing) The terminus address is the single most client-load-bearing fact about the KubeMQ AMQP 1.0 connector: it tells the connector **which KubeMQ pattern and channel** a link talks to. This guide is the practical playbook for the `/` grammar — always use explicit prefixes — plus channel validation, longest-prefix matching, dynamic and anonymous addresses, and the absence of vhosts. For the address validation rules in table form, see [Address mapping](/connectors/amqp/reference/address-mapping). ## Where the address goes [#where-the-address-goes] AMQP 1.0 has no exchanges or routing keys. You choose the destination by setting a terminus address on the link — and *where* it goes depends on the link role: | You are… | The address goes in… | | ------------------------------------------ | -------------------------------------------------------- | | a **receiver** (you consume) | the link **source** address | | a **sender** to a fixed node (you produce) | the link **target** address | | an **anonymous sender** | `properties.to` on **each message** (the target is null) | ## The grammar — always use explicit prefixes [#the-grammar--always-use-explicit-prefixes] ```text address := [ "/" ] pattern "/" channel # leading "/" is optional: queues/x ≡ /queues/x | bare # no recognized prefix → JMS hint, else DefaultPattern | "/responses/" RequestID # RPC reply token (reply path only; server-receiver only) | # source.dynamic / target.dynamic → _amqp10.tmp.. pattern := "queues" | "events" | "events-store" | "commands" | "queries" ``` | Terminus address | KubeMQ pattern | Channel | | ---------------------------- | -------------------------------------------------------------------------------- | ---------------------- | | `queues/` | queues | `` | | `events/` | events | `` | | `events-store/` | events-store | `` | | `commands/` | commands | `` | | `queries/` | queries | `` | | `responses/` | responses (synthetic; RPC reply path only) | the opaque reply token | | bare (no prefix, no `/`) | JMS node-capability hint (`queue`→queues, `topic`→events), else `DefaultPattern` | the bare string | | null target on a sender | anonymous — routed per-message by `properties.to` | (per message) | | anything else containing `/` | **error** → `DETACH(amqp:not-found, "unknown address prefix")` | — | The leading slash is optional and stripped: `queues/orders` ≡ `/queues/orders`. The prefix is **stripped, never prepended** — the resolved channel (`orders`) is what the broker sees, so it always passes the underlying channel validation. **Always emit the explicit `/` prefix.** It makes the destination deterministic and self-documenting: `queues/orders`, `events/telemetry`, `events-store/audit`, `commands/provision`, `queries/lookup`. Do not rely on bare addressing in fresh application code (see below). ## Longest-prefix matching [#longest-prefix-matching] The connector matches prefixes **longest-first**: `events-store/` is tested **before** `events/`. So `events-store/audit` resolves to the **events-store** pattern with channel `audit` — it is never mis-read as the **events** pattern with channel `store/audit`. You never need to escape or disambiguate; just write the full prefix. The full matching order is `events-store → queues → events → commands → queries → responses`. ## Bare addressing — a Qpid-JMS migration convenience only [#bare-addressing--a-qpid-jms-migration-convenience-only] A bare address (no recognized prefix and no `/`) is resolved **non-deterministically**: 1. If the terminus carries a JMS **node-capability hint**, it selects the pattern: `queue` → queues, `topic` → events. 2. Otherwise the connector's configured **`DefaultPattern`** applies (default `queues`). This exists so a **migrating Qpid-JMS / ActiveMQ app** can point at KubeMQ by changing only the connection string and the destination name — a JMS `Queue("orders")` carries the `queue` capability and lands on `queues/orders` without an explicit prefix. Bare addressing is **non-deterministic and config-dependent**: the same bare name resolves differently depending on the client's capability hint and the broker's `DefaultPattern`. Treat it as a migration aid, not a design choice. For any fresh code, **use the explicit prefix** so the destination is unambiguous and survives a `DefaultPattern` change. A bare value that still contains a `/` is treated as an **unknown prefix**, not a bare channel → `DETACH(amqp:not-found, "unknown address prefix")`. ## Channel validation — stricter than the native layer [#channel-validation--stricter-than-the-native-layer] After the prefix is stripped, the connector validates the channel **more strictly than the underlying KubeMQ array layer**. A channel that works on the gRPC/native side can be rejected over AMQP. A violation closes the link with `DETACH(amqp:not-found)`: | Rejected channel | Example | | ------------------------------ | -------------------------- | | empty | `queues/` | | longer than **255** characters | `queues/<256+ chars>` | | has a **trailing `.`** | `queues/orders.` | | contains **whitespace** | `queues/my orders` | | contains `*` or `>` wildcards | `queues/orders.*` | | contains `;` or `:` | `queues/a:b`, `queues/a;b` | A channel name that works on the native KubeMQ side can be **rejected over AMQP** because of the extra `;`, `:`, and trailing-`.` restrictions. Keep AMQP channel names to `[a-zA-Z0-9._-]`, ≤ 255 chars, with no trailing dot, and you are never surprised. ## Dynamic and anonymous addresses [#dynamic-and-anonymous-addresses] These are special address forms the connector mints or routes specially. ### Dynamic nodes (`source.dynamic` / `target.dynamic`) [#dynamic-nodes-sourcedynamic--targetdynamic] Attach a receiver with **`DynamicAddress: true`** (a dynamic source) and the connector creates a fresh node and echoes its address in the reply `ATTACH`: `_amqp10.tmp..`. This is the mechanism for an **RPC reply node** — a requester opens a dynamic receiver, reads back its echoed address, and stamps it as `reply-to`. See [Commands](/connectors/amqp/concepts/commands). Dynamic nodes are **node-local**: a temp node created on node A is not reachable from node B. RPC *replies* are still cluster-safe (they travel the broker reply path), but a direct cross-node send to a dynamic address is not. ### Anonymous terminus (null target sender) [#anonymous-terminus-null-target-sender] A sender opened with a **null target** (`NewSender("", nil)`) is an *anonymous* sender: it has no fixed destination, and each message selects its destination via **`properties.to`** (which itself uses the `/` grammar). Because there is no fixed channel at attach, authorization is deferred to a **per-message Write check** on each message's `to` (see [Authentication](/connectors/amqp/how-to/authentication)). A bad `to` → `amqp:precondition-failed`; a missing `to` → a Send error. The connector advertises **no `ANONYMOUS-RELAY` capability** — anonymous-terminus routing works by the connector inspecting `properties.to`, not by a capability handshake. Clients whose anonymous-producer API depends on `ANONYMOUS-RELAY` negotiation (notably **Qpid JMS**) cannot emit the single null-target link the connector routes on, so they cannot drive the anonymous terminus — use explicit per-pattern senders there instead. ### `/responses/` reply tokens [#responses-reply-tokens] `/responses/` is a synthetic RPC reply address. It is **valid only as a server-receiver attach** (the reply path); a **receiver** attach on `/responses/` → `DETACH(amqp:not-allowed)`. You do not construct these yourself in normal use — the RPC layer manages them, and they are connection-scoped (not authorized via Casbin). ## No vhost [#no-vhost] AMQP 1.0 has **no vhost**. The `OPEN` `hostname` field (which a client carrying over a 0-9-1 mental model might set) is **logged then ignored**. There is no vhost option to expose and no namespace scoping by hostname. ## Reaching AMQP 0-9-1 data [#reaching-amqp-0-9-1-data] The AMQP 1.0 and AMQP 0-9-1 connectors **do not share a namespace**. AMQP 0-9-1 queues live on KubeMQ channels named `amqp..`. To reach the same data from an AMQP 1.0 client, use the explicit queues prefix over that channel name: ```text /queues/amqp.. ``` Cross-protocol equivalence holds the other way too: an AMQP 1.0 `queues/` maps to the bare KubeMQ channel ``, so a gRPC/native client producing to `` interoperates with an AMQP 1.0 consumer of `queues/`. ## Quick reference [#quick-reference] | Destination | Address to use | | ---------------------------- | --------------------------------------------------------------------- | | Queue `orders` | `queues/orders` | | Event stream `telemetry` | `events/telemetry` | | Durable event stream `audit` | `events-store/audit` | | Command channel `provision` | `commands/provision` | | Query channel `lookup` | `queries/lookup` | | RPC reply node | dynamic receiver (`DynamicAddress: true`); never a hand-built address | | Route per-message | anonymous sender + `properties.to = "queues/"` etc. | | AMQP 0-9-1 queue | `/queues/amqp..` | | Fresh app code | **always the explicit prefix** — never rely on bare addressing | ## Related [#related] # Architecture (/connectors/amqp/concepts/architecture) The KubeMQ **AMQP 1.0 connector** is an embedded, wire-protocol bridge inside kubemq-server. It speaks the AMQP 1.0 dialect on plain port **5672** (TCP / SASL) and TLS port **5671**. The connector is **opt-in (disabled by default)** — enable it with `CONNECTORS_AMQP10_ENABLE=true` (Docker) or `spec.amqp10.enabled: true` (Kubernetes). Any standard AMQP 1.0 client connects to it with only a connection-string change — no code rewrite, no library swap, no KubeMQ SDK. Unlike the RabbitMQ (AMQP 0-9-1) connector — which bridges onto exactly one KubeMQ primitive (the Queue) — the AMQP 1.0 connector bridges onto **all five** KubeMQ patterns: Queues, Events, Events-Store, Commands, and Queries. The leading segment of the node address selects which pattern a link is bound to. That single fact drives the whole mental model. AMQP 1.0 is a peer-to-peer link protocol — there are **no exchanges, no bindings, no routing keys, and no publisher-confirms** here. Those are AMQP 0-9-1 concepts. In AMQP 1.0 a *link* is attached to a *node* (an address), message flow is governed by *credit*, and delivery is resolved by *delivery state* (accepted / released / modified / rejected). If you are migrating from 0-9-1 or ActiveMQ, see [Migrating from ActiveMQ](/connectors/how-to/migration/from-activemq). ## How AMQP 1.0 maps to KubeMQ [#how-amqp-10-maps-to-kubemq] The connector binds a link to a KubeMQ pattern by the **leading segment of the node address**. The grammar is `[/]/` — the prefix selects the pattern and the remainder is the KubeMQ channel. *The address prefix selects the KubeMQ pattern; `events-store/` is matched before `events/` so it never collides.* | Address prefix | KubeMQ pattern | Produce (client sender → target) | Consume (client receiver ← source) | | ------------------- | ------------------ | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- | | `queues/` | **Queues** | at-least-once enqueue; the server dispositions `accepted` per send | credit-driven destructive consume; `accept` / `release` / `modify` / `reject` | | `events/` | **Events** | pre-settled fan-out (at-most-once) | standing-credit fan-out; **0-credit → silent drop** | | `events-store/` | **Events-Store** | persisted append | durable replay/resume; start positions via `x-opt-kubemq-start` | | `commands/` | **Commands (RPC)** | request + dynamic reply node; reply carries `x-opt-kubemq-executed` / `-error` | responder consumes, replies to `/responses/` | | `queries/` | **Queries (RPC)** | request + dynamic reply node; reply = body + metadata only | responder consumes, replies to `/responses/` | Resolution rules: * **Longest-prefix wins.** `events-store/` is matched before `events/`, so `events-store/orders` never collides with the `events/` arm. The matching order is `events-store → queues → events → commands → queries → responses`. * **Leading slash is optional.** At most one leading `/` is stripped before matching, so `queues/orders` and `/queues/orders` resolve identically. * **Bare addresses** (no recognized prefix) resolve by a JMS node-capability hint (`queue` → `queues`, `topic` → `events`) or fall back to the configured `DefaultPattern` (`queues` by default). Best practice is to always emit the explicit prefix. * **`/responses/`** is the write-only RPC reply path. A receiver attach on it is refused with `amqp:not-allowed`. * **Channel charset** is stricter than the array layer: non-empty, ≤255 chars, no trailing `.`, no whitespace, no `*` `>` `;` `:`. A violation returns `amqp:not-found`. See [Address mapping](/connectors/amqp/reference/address-mapping) for the master table and [Addressing](/connectors/amqp/concepts/addressing) for narrative guidance. ## The shared front door: `amqpmux` [#the-shared-front-door-amqpmux] KubeMQ ships two embedded AMQP dialects — 0-9-1 (RabbitMQ) and 1.0 — and they **share the same listeners**. A single mux per `(port, tlsPort)` group accepts every connection, reads the **8-byte AMQP protocol header**, and dispatches the raw connection to the engine that speaks the matching dialect. The mux never speaks AMQP itself; once it classifies a connection it hands the connection plus the consumed header to the engine, which resumes the protocol exactly where the header left off. *The mux classifies each connection by its 8-byte protocol header and routes it to the matching dialect engine.* The 8-byte header is `"AMQP"` followed by a 4-byte `(protocol-id, major, minor, revision)` tuple. The mux recognizes: | Header bytes | Meaning | Listener | Dispatched to | | ---------------------- | --------------------- | -------- | ---------------------------------- | | `AMQP\x00\x00\x09\x01` | AMQP 0-9-1 | any | 0-9-1 engine | | `AMQP\x00\x01\x00\x00` | AMQP 1.0 (bare) | any | 1.0 engine | | `AMQP\x03\x01\x00\x00` | AMQP 1.0 (SASL layer) | any | 1.0 engine | | `AMQP\x02\x01\x00\x00` | AMQP 1.0 (TLS token) | TLS only | 1.0 engine (after TLS termination) | Key consequences: * **Plain port `5672` and TLS port `5671` are shared with the AMQP 0-9-1 connector.** Setting `CONNECTORS_AMQP10_PORT` equal to the 0-9-1 port is intentionally accepted — the mux dedupes the bind, so the two dialects coexist on one listener. * **Version negotiation:** if a client presents an AMQP 1.0-family header but no live 1.0 engine is available, the mux writes back the 0-9-1 header and closes (and vice-versa). A client that cannot even send a header gets nothing back — the connection is closed silently. * **The connector advertises no negotiated capabilities** (see [What the server advertises](#what-the-server-advertises)). ## Connection → Session → Link [#connection--session--link] AMQP 1.0 is a three-level container model. The connector implements the **server side** of each finite-state machine, so the peer roles invert relative to your client: a client **sender** is a server **receiver** (you produce to a `target`), and a client **receiver** is a server **sender** (you consume from a `source`). | Level | Client performative | Connector behavior | | -------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Connection** | `OPEN` | `container-id` is required and non-empty (empty → `amqp:invalid-field`), sanitized to `[a-zA-Z0-9_-]`, capped at 256 chars. It becomes the ClientID when there is no SASL identity and is **half the durable-subscription identity — so it must be stable across reconnects** for durable subscribers. The OPEN `hostname` (vhost) is accepted but **ignored**. | | **Session** | `BEGIN` | The server advertises `channel-max = min(client, SessionMax-1)` (255 with defaults). A session window violation closes with `amqp:session:window-violation`; an unattached or in-use handle is `amqp:session:errant-link`. | | **Link** | `ATTACH` | The peer role inverts (sender ↔ receiver). The address resolves to a `(pattern, channel)` pair. Receivers grant credit via `FLOW`; the server never delivers without it. The receive settle mode is `first` (only). | ## What the server advertises [#what-the-server-advertises] On `OPEN` the connector sends back **only** `container-id` (`"KubeMQ"`), `max-frame-size`, `channel-max`, and `idle-time-out`. It sets **no offered/desired connection or link capabilities** — in particular **no `ANONYMOUS-RELAY`** and no `queue`/`topic` node capabilities. Clients must not depend on capability negotiation. This is why the **anonymous terminus** (a sender with a null target that routes per-message by `properties.to`) is driven entirely by the *null target address*, not by an advertised capability. It is also why Apache Qpid JMS cannot drive the anonymous-terminus path — it has no API to force a raw null-target link, and there is no capability to trigger its anonymous-producer path. See [Capabilities](/connectors/amqp/reference/capabilities). ## The metadata envelope and type markers [#the-metadata-envelope-and-type-markers] KubeMQ messages carry a JSON `Metadata` string. The connector serializes the full AMQP message context into a single canonical envelope keyed by **`amqp10`**: ```json { "amqp10": { "props": { "...": "original-form AMQP properties (message_id, correlation_id, to, reply_to, subject, content_type, group_id, ttl, ...)" }, "app": { "...": "application-properties, type-preserved" }, "annotations": { "x-opt-...": "message-annotations" }, "delivery_annotations": { "...": "opaque pass-through" }, "footer": { "...": "opaque pass-through" }, "body_section": "data" } } ``` * The envelope is **always present** — even `{"amqp10":{}}` for an empty/property-less message — so every message carries non-empty `Metadata`. * `body_section` discriminates the body: **`"data"`** (binary `Data` section) or **`"value"`** (`AmqpValue` section). An `AmqpSequence` body is **rejected** with `amqp:not-implemented`. * Treat the envelope as **opaque** from a client's point of view. Set standard AMQP properties natively (message-id, correlation-id, content-type, ttl, group-id) and let the connector derive the `amqp10.*` tags; do not hand-build the envelope. ### Type markers [#type-markers] JSON has no native unsigned, 64-bit, binary, or timestamp types, so the codec wraps AMQP scalar values that would otherwise lose fidelity using a `$`-prefixed marker. Integers within ±2^53 are emitted as plain JSON numbers; only out-of-range or type-ambiguous values are wrapped, and egress restores the exact AMQP type the client sent. | Marker | AMQP type → JSON form | | ------------------------------------------------- | --------------------------- | | `$int64` | int64 beyond ±2^53 → string | | `$u64` | uint64 → string | | `$ts` | timestamp → Unix seconds | | `$bin` | binary → base64 | | `$uuid` | UUID → RFC-4122 string | | `$u8` / `$i8` / `$i16` / `$u16` / `$i32` / `$u32` | sized integers | | `$f32` / `$f64` | 32- / 64-bit floats | Receiver-set link properties (on the consuming ATTACH, not the message) carry pattern options: `x-opt-kubemq-group` (consumer group for events / events-store / queues) and `x-opt-kubemq-start` (the events-store start position, `new-only` by default). Inert sections are accepted but not acted on: message `priority`, `group-id` ordering, and `footer` are pass-through only. ## Cross-protocol interop [#cross-protocol-interop] Because every pattern is backed by a normal KubeMQ channel, a message sent over AMQP 1.0 to `queues/orders` is consumable by a gRPC or REST queue client on the same channel, and vice-versa. The connector asserts this equivalence: `queues/` over AMQP 1.0 maps to the bare channel `` over gRPC. *The same KubeMQ channel backs both connectors, so an AMQP 1.0 client and a gRPC/REST client interoperate transparently.* The RabbitMQ (AMQP 0-9-1) connector uses a **different** namespace (`amqp..`), so the two AMQP connectors do **not** share an address space. To reach 0-9-1 queue data from AMQP 1.0, address it explicitly through the queues pattern: `queues/amqp..` (a naming convention, not a real vhost — AMQP 1.0 has none). ## Related [#related] # Commands (/connectors/amqp/concepts/commands) Commands are **native, in-protocol request/reply** over the AMQP 1.0 connector. Attach a link to a node whose address begins with `commands/` and the connector binds it to the KubeMQ **Commands** pattern. RPC here is **fully native** — there is **no gRPC responder and no KubeMQ SDK**; a responder is just a normal AMQP consumer that publishes a reply. This page documents the shared RPC mechanics — the dynamic reply node, the anonymous responder, and correlation matching. [Queries](/connectors/amqp/concepts/queries) use the **same** request path and defer to this page; the only differences are the reply shape and the failure contract. ## Overview [#overview] A requester opens a dynamic reply node, sends a request to `commands/` naming that node as `reply-to`, and matches the reply by `correlation-id`. The connector fires `SendCommand`; the responder consumes `commands/`, does the work, and sends a reply back. | Step | Who | Action | | --------------- | --------- | -------------------------------------------------------------------------------------------- | | Open reply node | Requester | Attach a receiver with `source.dynamic = true`; the server mints `_amqp10.tmp..` | | Send request | Requester | `TRANSFER` to `commands/` with `reply-to` = the minted node + a `correlation-id` | | Route | Connector | Verifies reply-to ownership, then `SendCommand` | | Reply | Responder | Anonymous sender with `properties.to = /responses/` + the echoed `correlation-id` | | Match | Requester | Correlate the reply to its request by `correlation-id` | The reply's `correlation-id` is the request's `correlation-id`, or — when the request carried none — its **`message-id`** (the Qpid JMS convention). Set one or the other on every request and match the reply on it. ## How it works [#how-it-works] *The requester's dynamic reply node receives the responder's reply out-of-band; the requester matches it by `correlation-id`.* **`reply-to` must name a node this connection owns (snooping guard).** A requester cannot point `reply-to` at an arbitrary or foreign node — that would let it direct a response to another client's node. A **missing** `reply-to` returns `amqp:not-allowed` ("request missing reply-to"); a `reply-to` naming a node **this connection does not own** returns `amqp:not-allowed` ("reply-to is not a node this connection owns"). Always create a dynamic reply node per requester and use its echoed `_amqp10.tmp.*` address. ## Commands vs Queries — the failure contract [#commands-vs-queries--the-failure-contract] Both patterns share the dynamic-reply path, but their reply shape and failure behavior differ: | | **Commands** | **Queries** | | -------------------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | | Reply body | optional | the result **body + metadata** | | Reply app-properties | `x-opt-kubemq-executed` (bool) **always**; `x-opt-kubemq-error` (string) when non-empty | **none** | | On success | `executed=true` | body + metadata returned | | **On failure** | a reply **is** delivered with **`executed=false`** (+ error text) — the requester is never left waiting | **nothing is delivered** — the requester **times out** (\~30 s) | In short: a **command** always answers (success or `executed=false`); a query answers on success and goes silent on failure. Choose commands when you need a positive failure signal. ## Request and reply [#request-and-reply] Each example runs a responder and a requester (separate connections, so the snooping guard is honored), sends a successful command (`executed=true`) and a failing one (`executed=false`), and shows that **both** round-trip — neither hangs. Every client reads the broker endpoint from `KUBEMQ_AMQP_URL` (default `amqp://localhost:5672`). ```go package main import ( "context" "errors" "fmt" "log" "os" "sync" "time" amqp "github.com/Azure/go-amqp" ) const channel = "amqp10.examples.commands" func amqpURL() string { if v := os.Getenv("KUBEMQ_AMQP_URL"); v != "" { return v } return "amqp://localhost:5672" } func main() { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() addr := "commands/" + channel // commands/ prefix → KubeMQ Commands pattern ready := make(chan struct{}) var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done(); runResponder(ctx, addr, ready) }() <-ready runRequester(ctx, addr) cancel() wg.Wait() } // Responder: consume commands/, reply via an anonymous sender. func runResponder(ctx context.Context, addr string, ready chan<- struct{}) { conn, _ := amqp.Dial(ctx, amqpURL(), nil) defer func() { _ = conn.Close() }() session, _ := conn.NewSession(ctx, nil) rcv, _ := session.NewReceiver(ctx, addr, &amqp.ReceiverOptions{Credit: 10}) snd, _ := session.NewSender(ctx, "", nil) // anonymous sender (null target) close(ready) for { req, err := rcv.Receive(ctx, nil) if err != nil { if ctx.Err() != nil || errors.Is(err, context.Canceled) { return } return } _ = rcv.AcceptMessage(context.Background(), req) if req.Properties == nil || req.Properties.ReplyTo == nil { continue } body := string(req.GetData()) // A command body of "fail" is rejected (executed=false); both paths reply. ok := body != "fail" errText := "" if !ok { errText = "command rejected by handler" } replyTo := *req.Properties.ReplyTo reply := amqp.NewMessage([]byte("ack:" + body)) reply.Properties = &amqp.MessageProperties{To: &replyTo} if req.Properties.CorrelationID != nil { reply.Properties.CorrelationID = req.Properties.CorrelationID } else { reply.Properties.CorrelationID = req.Properties.MessageID } // A COMMAND reply carries the execution outcome as application-properties. reply.ApplicationProperties = map[string]any{ "x-opt-kubemq-executed": ok, "x-opt-kubemq-error": errText, } _ = snd.Send(ctx, reply, nil) } } // Requester: dynamic reply node + sender on commands/; correlate replies. func runRequester(ctx context.Context, addr string) { conn, _ := amqp.Dial(ctx, amqpURL(), nil) defer func() { _ = conn.Close() }() session, _ := conn.NewSession(ctx, nil) // DYNAMIC reply node: empty source + DynamicAddress:true → server echoes its address. replyRcv, _ := session.NewReceiver(ctx, "", &amqp.ReceiverOptions{DynamicAddress: true, Credit: 5}) replyNode := replyRcv.Address() snd, _ := session.NewSender(ctx, addr, nil) doRequest(ctx, snd, replyRcv, replyNode, "reboot-node-7", "corr-cmd-1") // executed=true doRequest(ctx, snd, replyRcv, replyNode, "fail", "corr-cmd-2") // executed=false } func doRequest(ctx context.Context, snd *amqp.Sender, replyRcv *amqp.Receiver, replyNode, body, corr string) { req := amqp.NewMessage([]byte(body)) req.Properties = &amqp.MessageProperties{ ReplyTo: &replyNode, // MUST name a node this connection owns (snooping guard) CorrelationID: corr, } if err := snd.Send(ctx, req, nil); err != nil { log.Fatalf("send command: %v", err) } // A command ALWAYS replies (success or failure), so this never times out. reply, err := replyRcv.Receive(ctx, nil) if err != nil { log.Fatalf("await reply: %v", err) } _ = replyRcv.AcceptMessage(context.Background(), reply) executed, _ := reply.ApplicationProperties["x-opt-kubemq-executed"].(bool) errText, _ := reply.ApplicationProperties["x-opt-kubemq-error"].(string) fmt.Printf("reply for %q: executed=%v error=%q\n", body, executed, errText) } ``` ```python import os import threading from proton import Message from proton.utils import BlockingConnection CHANNEL = "amqp10.examples.commands" EXECUTED_PROP = "x-opt-kubemq-executed" ERROR_PROP = "x-opt-kubemq-error" def amqp_url() -> str: return os.environ.get("KUBEMQ_AMQP_URL", "amqp://localhost:5672") def run_responder(addr: str, ready: threading.Event, stop: threading.Event) -> None: conn = BlockingConnection(amqp_url()) try: rcv = conn.create_receiver(addr, credit=10) snd = conn.create_sender(None) # anonymous reply sender (null target) ready.set() while not stop.is_set(): try: req = rcv.receive(timeout=1.0) except Exception: continue rcv.accept() if not req.reply_to: continue body = str(req.body) # A command body of "fail" is rejected (executed=false); both paths reply. ok = body != "fail" err_text = "" if ok else "command rejected by handler" reply = Message(body="ack:" + body) reply.address = req.reply_to reply.correlation_id = req.correlation_id if req.correlation_id else req.id # A COMMAND reply carries the execution outcome as application-properties. reply.properties = {EXECUTED_PROP: ok, ERROR_PROP: err_text} snd.send(reply) finally: conn.close() def do_request(snd, reply_rcv, reply_node: str, body: str, corr: str) -> None: req = Message(body=body) req.reply_to = reply_node # MUST name a node this connection owns (snooping guard) req.correlation_id = corr snd.send(req) # A command ALWAYS replies (success or failure), so this never times out. reply = reply_rcv.receive(timeout=30.0) reply_rcv.accept() props = reply.properties or {} print(f"reply for {body!r}: executed={bool(props.get(EXECUTED_PROP))} error={str(props.get(ERROR_PROP, ''))!r}") def run_requester(addr: str) -> None: conn = BlockingConnection(amqp_url()) try: # DYNAMIC reply node: dynamic=True → server echoes its address. reply_rcv = conn.create_receiver(None, dynamic=True, credit=5) reply_node = reply_rcv.link.remote_source.address snd = conn.create_sender(addr) do_request(snd, reply_rcv, reply_node, "reboot-node-7", "corr-cmd-1") # executed=true do_request(snd, reply_rcv, reply_node, "fail", "corr-cmd-2") # executed=false finally: conn.close() def main() -> None: addr = "commands/" + CHANNEL # commands/ prefix → KubeMQ Commands pattern ready, stop = threading.Event(), threading.Event() responder = threading.Thread(target=run_responder, args=(addr, ready, stop), daemon=True) responder.start() ready.wait(timeout=30.0) try: run_requester(addr) finally: stop.set() responder.join(timeout=10.0) if __name__ == "__main__": main() ``` ```java import javax.jms.Connection; import javax.jms.Destination; import javax.jms.Message; import javax.jms.MessageConsumer; import javax.jms.MessageProducer; import javax.jms.Queue; import javax.jms.Session; import javax.jms.TemporaryQueue; import javax.jms.TextMessage; import org.apache.qpid.jms.JmsConnectionFactory; public final class Main { private static final String CHANNEL = "amqp10.examples.commands"; private static final String PROP_EXECUTED = "x-opt-kubemq-executed"; private static final String PROP_ERROR = "x-opt-kubemq-error"; public static void main(String[] args) throws Exception { String url = System.getenv().getOrDefault("KUBEMQ_AMQP_URL", "amqp://localhost:5672"); String address = "commands/" + CHANNEL; // commands/ prefix → KubeMQ Commands // jms.validatePropertyNames=false lets us set/read the hyphenated // x-opt-kubemq-executed / -error application-properties. JmsConnectionFactory factory = new JmsConnectionFactory(url); factory.setValidatePropertyNames(false); Thread responder = new Thread(() -> runResponder(factory, address), "responder"); responder.setDaemon(true); responder.start(); Thread.sleep(1_000); // let the responder attach before sending runRequester(factory, address); } // Responder: consume commands/, reply to each request's JMSReplyTo. private static void runResponder(JmsConnectionFactory factory, String address) { try (Connection connection = factory.createConnection()) { connection.start(); try (Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE)) { Queue commands = session.createQueue(address); try (MessageConsumer consumer = session.createConsumer(commands); MessageProducer replyProducer = session.createProducer(null)) { // unidentified while (true) { Message req = consumer.receive(1_000); if (req == null) continue; Destination replyTo = req.getJMSReplyTo(); if (replyTo == null) continue; String body = (req instanceof TextMessage) ? ((TextMessage) req).getText() : ""; boolean ok = !"fail".equals(body); String errText = ok ? "" : "command rejected by handler"; TextMessage reply = session.createTextMessage("ack:" + body); reply.setJMSCorrelationID(req.getJMSCorrelationID()); reply.setBooleanProperty(PROP_EXECUTED, ok); reply.setStringProperty(PROP_ERROR, errText); replyProducer.send(replyTo, reply); } } } } catch (Exception e) { // connection torn down on shutdown } } // Requester: a JMS temporary queue is the dynamic reply node. private static void runRequester(JmsConnectionFactory factory, String address) throws Exception { try (Connection connection = factory.createConnection()) { connection.start(); try (Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE)) { TemporaryQueue replyNode = session.createTemporaryQueue(); Queue commands = session.createQueue(address); try (MessageConsumer replyConsumer = session.createConsumer(replyNode); MessageProducer producer = session.createProducer(commands)) { doRequest(session, producer, replyConsumer, replyNode, "reboot-node-7", "corr-cmd-1"); doRequest(session, producer, replyConsumer, replyNode, "fail", "corr-cmd-2"); } } } } private static void doRequest(Session session, MessageProducer producer, MessageConsumer replyConsumer, TemporaryQueue replyNode, String body, String corr) throws Exception { TextMessage req = session.createTextMessage(body); req.setJMSReplyTo(replyNode); // MUST name a node this connection owns (snooping guard) req.setJMSCorrelationID(corr); producer.send(req); Message reply = replyConsumer.receive(30_000); // a command always replies if (reply == null) throw new IllegalStateException("timed out awaiting reply"); System.out.printf("reply for \"%s\": executed=%b error=\"%s\"%n", body, reply.getBooleanProperty(PROP_EXECUTED), reply.getStringProperty(PROP_ERROR)); } } ``` ```csharp using System.Text; using Amqp; using Amqp.Framing; const string channel = "amqp10.examples.commands"; static string AmqpUrl() => Environment.GetEnvironmentVariable("KUBEMQ_AMQP_URL") is { Length: > 0 } v ? v : "amqp://localhost:5672"; var addr = "commands/" + channel; // commands/ prefix → KubeMQ Commands pattern using var responderDone = new CancellationTokenSource(); var responderReady = new TaskCompletionSource(); var responderTask = Task.Run(() => RunResponder(addr, responderReady, responderDone.Token)); await responderReady.Task.WaitAsync(TimeSpan.FromSeconds(20)); await RunRequester(addr); responderDone.Cancel(); await responderTask; // Responder: consume commands/, reply via an anonymous sender. static void RunResponder(string addr, TaskCompletionSource ready, CancellationToken stop) { var connection = Connection.Factory.CreateAsync(new Address(AmqpUrl())).GetAwaiter().GetResult(); try { var session = new Session(connection); var receiver = new ReceiverLink(session, "command-responder", addr); receiver.SetCredit(10, autoRestore: true); var anonAttach = new Attach { Source = new Source(), Target = null }; // anonymous sender var sender = new SenderLink(session, "command-reply-sender", anonAttach, null); ready.TrySetResult(); while (!stop.IsCancellationRequested) { var req = receiver.Receive(TimeSpan.FromSeconds(1)); if (req is null) continue; receiver.Accept(req); if (req.Properties?.ReplyTo is not { Length: > 0 } replyTo) continue; var body = BodyString(req); var ok = body != "fail"; var errText = ok ? "" : "command rejected by handler"; var reply = new Message { BodySection = new Data { Binary = Encoding.UTF8.GetBytes("ack:" + body) } }; reply.Properties = new Properties { To = replyTo }; var corr = req.Properties.GetCorrelationId() ?? req.Properties.GetMessageId(); if (corr is not null) reply.Properties.SetCorrelationId(corr); // A COMMAND reply carries the execution outcome as application-properties. reply.ApplicationProperties = new ApplicationProperties(); reply.ApplicationProperties.Map["x-opt-kubemq-executed"] = ok; reply.ApplicationProperties.Map["x-opt-kubemq-error"] = errText; sender.Send(reply, TimeSpan.FromSeconds(10)); } } catch (Exception ex) when (stop.IsCancellationRequested) { _ = ex; } finally { try { connection.CloseAsync().Wait(2000); } catch (AmqpException) { } } } // Requester: dynamic reply node + sender on commands/; correlate replies. static async Task RunRequester(string addr) { var connection = await Connection.Factory.CreateAsync(new Address(AmqpUrl())); try { var session = new Session(connection); // DYNAMIC reply node: Source.Dynamic = true; the server echoes the address // via the OnAttached callback. string? replyNode = null; using var attached = new SemaphoreSlim(0, 1); var dynAttach = new Attach { Source = new Source { Dynamic = true }, Target = new Target() }; var replyRcv = new ReceiverLink(session, "command-reply-node", dynAttach, (link, attach) => { if (attach.Source is Source s) replyNode = s.Address; attached.Release(); }); replyRcv.SetCredit(5, autoRestore: true); await attached.WaitAsync(TimeSpan.FromSeconds(10)); var sender = new SenderLink(session, "command-requester", addr); DoRequest(sender, replyRcv, replyNode!, "reboot-node-7", "corr-cmd-1"); // executed=true DoRequest(sender, replyRcv, replyNode!, "fail", "corr-cmd-2"); // executed=false await sender.CloseAsync(); await replyRcv.CloseAsync(); await session.CloseAsync(); } finally { await connection.CloseAsync(); } } static void DoRequest(SenderLink sender, ReceiverLink replyRcv, string replyNode, string body, string corr) { var req = new Message { BodySection = new Data { Binary = Encoding.UTF8.GetBytes(body) } }; req.Properties = new Properties { ReplyTo = replyNode }; // snooping guard: must own this node req.Properties.SetCorrelationId(corr); sender.Send(req, TimeSpan.FromSeconds(15)); var reply = replyRcv.Receive(TimeSpan.FromSeconds(30)) // a command always replies ?? throw new InvalidOperationException("await reply: timed out"); replyRcv.Accept(reply); var executed = reply.ApplicationProperties?.Map["x-opt-kubemq-executed"] as bool? ?? false; var errText = reply.ApplicationProperties?.Map["x-opt-kubemq-error"] as string ?? ""; Console.WriteLine($"reply for \"{body}\": executed={executed} error=\"{errText}\""); } static string BodyString(Message message) => message.BodySection switch { Data d => Encoding.UTF8.GetString(d.Binary), AmqpValue { Value: byte[] bytes } => Encoding.UTF8.GetString(bytes), AmqpValue { Value: string str } => str, AmqpValue v => v.Value?.ToString() ?? string.Empty, _ => string.Empty, }; ``` ```typescript import { Connection, ReceiverEvents, type AwaitableSender, type ConnectionOptions, type EventContext, type Receiver, } from "rhea-promise"; const channel = "amqp10.examples.commands"; function connectionOptions(suffix: string): ConnectionOptions { const url = new URL(process.env["KUBEMQ_AMQP_URL"] ?? "amqp://localhost:5672"); return { host: url.hostname, port: url.port ? Number(url.port) : 5672, container_id: `kubemq-amqp10-js-commands-${suffix}-${process.pid}`, reconnect: false, }; } function bodyToString(body: unknown): string { if (Buffer.isBuffer(body)) return body.toString("utf8"); if (typeof body === "string") return body; return ""; } // Responder: consume commands/, reply via an anonymous sender. async function startResponder(address: string): Promise<{ stop: () => Promise }> { const connection = new Connection(connectionOptions("responder")); await connection.open(); const receiver = await connection.createReceiver({ source: { address }, credit_window: 0, autoaccept: false, autosettle: false, }); const replySender = await connection.createAwaitableSender({ target: {} }); // null target receiver.on(ReceiverEvents.message, (ctx: EventContext) => { void onRequest(replySender, ctx).catch((err) => console.error(err)); }); receiver.addCredit(10); return { stop: async () => { await replySender.close(); await receiver.close(); await connection.close(); }, }; } async function onRequest(replySender: AwaitableSender, ctx: EventContext): Promise { const req = ctx.message; ctx.delivery?.accept(); if (!req || req.reply_to === undefined || req.reply_to === null) return; const body = bodyToString(req.body); const ok = body !== "fail"; const errText = ok ? "" : "command rejected by handler"; await replySender.send( { body: `ack:${body}`, to: req.reply_to, correlation_id: req.correlation_id ?? req.message_id, // A COMMAND reply carries the execution outcome as application-properties. application_properties: { "x-opt-kubemq-executed": ok, "x-opt-kubemq-error": errText }, }, { timeoutInSeconds: 10 }, ); } // Requester: dynamic reply node + sender on commands/; correlate replies. async function runRequester(address: string): Promise { const connection = new Connection(connectionOptions("requester")); await connection.open(); try { const replyReceiver = await connection.createReceiver({ source: { address: "", dynamic: true }, // server names the node credit_window: 0, autoaccept: false, autosettle: false, }); replyReceiver.addCredit(5); const replyNode = replyReceiver.address || replyReceiver.source.address; const sender = await connection.createAwaitableSender({ target: { address } }); await doRequest(sender, replyReceiver, replyNode!, "reboot-node-7", "corr-cmd-1"); // executed=true await doRequest(sender, replyReceiver, replyNode!, "fail", "corr-cmd-2"); // executed=false await sender.close(); await replyReceiver.close(); } finally { await connection.close(); } } async function doRequest( sender: AwaitableSender, replyReceiver: Receiver, replyNode: string, body: string, corr: string, ): Promise { const replyPromise = awaitReply(replyReceiver, 30_000); // arm before sending await sender.send( { body, reply_to: replyNode, correlation_id: corr }, // snooping guard: must own replyNode { timeoutInSeconds: 15 }, ); const reply = await replyPromise; // a command always replies const props = (reply.application_properties ?? {}) as Record; const executed = props["x-opt-kubemq-executed"] === true; const errText = typeof props["x-opt-kubemq-error"] === "string" ? props["x-opt-kubemq-error"] : ""; console.log(`reply for "${body}": executed=${executed} error="${errText}"`); } interface ReplyMessage { application_properties?: Record; } function awaitReply(replyReceiver: Receiver, timeoutMs: number): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { replyReceiver.removeListener(ReceiverEvents.message, handler); reject(new Error("timed out awaiting reply")); }, timeoutMs); const handler = (ctx: EventContext): void => { clearTimeout(timer); replyReceiver.removeListener(ReceiverEvents.message, handler); ctx.delivery?.accept(); replyReceiver.addCredit(1); resolve({ application_properties: ctx.message?.application_properties as Record | undefined }); }; replyReceiver.on(ReceiverEvents.message, handler); }); } async function main(): Promise { const address = `commands/${channel}`; // commands/ prefix → KubeMQ Commands pattern const responder = await startResponder(address); try { await runRequester(address); } finally { await responder.stop(); } } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```rust use std::time::Duration; use fe2o3_amqp::link::receiver::CreditMode; use fe2o3_amqp::{Connection, Receiver, Sender, Session}; use fe2o3_amqp_types::definitions::SenderSettleMode; use fe2o3_amqp_types::messaging::{ ApplicationProperties, Body, Message, MessageId, Properties, Source, Target, }; use fe2o3_amqp_types::primitives::{SimpleValue, Value}; use tokio::sync::oneshot; const CHANNEL: &str = "amqp10.examples.commands"; const EXECUTED_PROP: &str = "x-opt-kubemq-executed"; const ERROR_PROP: &str = "x-opt-kubemq-error"; fn amqp_url() -> String { std::env::var("KUBEMQ_AMQP_URL").unwrap_or_else(|_| "amqp://localhost:5672".to_string()) } fn body_string(body: &Body) -> String { let bytes = match body { Body::Data(batch) => batch.iter().flat_map(|d| d.0.to_vec()).collect(), Body::Value(v) => match &v.0 { Value::Binary(b) => b.to_vec(), Value::String(s) => s.clone().into_bytes(), other => format!("{other:?}").into_bytes(), }, _ => Vec::new(), }; String::from_utf8_lossy(&bytes).into_owned() } fn message_id_string(id: &Option) -> Option { match id { Some(MessageId::String(s)) => Some(s.clone()), Some(other) => Some(format!("{other:?}")), None => None, } } fn command_outcome(msg: &Message>) -> (bool, String) { let Some(props) = &msg.application_properties else { return (false, String::new()); }; let executed = matches!(props.get(EXECUTED_PROP), Some(SimpleValue::Bool(true))); let error = match props.get(ERROR_PROP) { Some(SimpleValue::String(s)) => s.clone(), _ => String::new(), }; (executed, error) } #[tokio::main] async fn main() -> Result<(), Box> { let url = amqp_url(); let addr = format!("commands/{CHANNEL}"); // commands/ prefix → KubeMQ Commands let (ready_tx, ready_rx) = oneshot::channel::<()>(); let (stop_tx, stop_rx) = oneshot::channel::<()>(); let (rurl, raddr) = (url.clone(), addr.clone()); let responder = tokio::spawn(async move { run_responder(&rurl, &raddr, ready_tx, stop_rx).await }); ready_rx.await.map_err(|_| "responder failed to become ready")?; run_requester(&url, &addr).await?; let _ = stop_tx.send(()); responder.await.map_err(|e| Box::new(e) as Box)??; Ok(()) } // Responder: consume commands/, reply via an anonymous sender. async fn run_responder( url: &str, addr: &str, ready: oneshot::Sender<()>, mut stop: oneshot::Receiver<()>, ) -> Result<(), Box> { let mut connection = Connection::open("amqp10-examples-commands-responder", url).await?; let mut session = Session::begin(&mut connection).await?; let mut rcv = Receiver::builder() .name("commands-responder-receiver") .source(addr) .credit_mode(CreditMode::Auto(10)) .attach(&mut session) .await?; // Anonymous sender (null target). Senders must set an explicit settle-mode // (the connector rejects the AMQP default `mixed`). let mut snd = Sender::builder() .name("commands-responder-anon-sender") .target(Target::builder().build()) .sender_settle_mode(SenderSettleMode::Unsettled) .attach(&mut session) .await?; let _ = ready.send(()); loop { let delivery = tokio::select! { biased; _ = &mut stop => break, res = rcv.recv::>() => match res { Ok(d) => d, Err(_) => break }, }; rcv.accept(&delivery).await?; let msg = delivery.message(); let Some(reply_to) = msg.properties.as_ref().and_then(|p| p.reply_to.clone()) else { continue; }; let body = body_string(&msg.body); let ok = body != "fail"; let err_text = if ok { String::new() } else { "command rejected by handler".to_string() }; let corr = msg.properties.as_ref() .and_then(|p| p.correlation_id.clone().or_else(|| p.message_id.clone())); let mut props = Properties::builder().to(reply_to); if let Some(c) = corr { props = props.correlation_id(c); } let reply = Message::builder() .properties(props.build()) .application_properties( ApplicationProperties::builder() .insert(EXECUTED_PROP, ok) .insert(ERROR_PROP, err_text.as_str()) .build(), ) .data(format!("ack:{body}").into_bytes()) .build(); snd.send(reply).await?; } snd.close().await?; rcv.close().await?; session.end().await?; connection.close().await?; Ok(()) } // Requester: dynamic reply node + sender on commands/; correlate replies. async fn run_requester(url: &str, addr: &str) -> Result<(), Box> { let mut connection = Connection::open("amqp10-examples-commands-requester", url).await?; let mut session = Session::begin(&mut connection).await?; // DYNAMIC reply node: empty source + dynamic(true); the server echoes the address. let mut reply_rcv = Receiver::builder() .name("commands-requester-reply-node") .source(Source::builder().dynamic(true).build()) .credit_mode(CreditMode::Auto(5)) .attach(&mut session) .await?; let reply_node = reply_rcv.source().as_ref() .and_then(|s| s.address.clone()) .ok_or("server did not assign a dynamic reply-node address")?; let mut snd = Sender::builder() .name("commands-requester-sender") .target(addr) .sender_settle_mode(SenderSettleMode::Unsettled) .attach(&mut session) .await?; do_request(&mut snd, &mut reply_rcv, &reply_node, "reboot-node-7", "corr-cmd-1").await?; // executed=true do_request(&mut snd, &mut reply_rcv, &reply_node, "fail", "corr-cmd-2").await?; // executed=false snd.close().await?; reply_rcv.close().await?; session.end().await?; connection.close().await?; Ok(()) } async fn do_request( snd: &mut Sender, reply_rcv: &mut Receiver, reply_node: &str, body: &str, corr: &str, ) -> Result<(), Box> { let req = Message::builder() .properties( Properties::builder() .reply_to(reply_node.to_string()) // snooping guard: must own this node .correlation_id(corr.to_string()) .build(), ) .data(body.as_bytes().to_vec()) .build(); let outcome = snd.send(req).await?; if !outcome.is_accepted() { return Err(format!("send command {body:?}: unexpected outcome {outcome:?}").into()); } // A command ALWAYS replies (success or failure), so this never times out. let reply = match tokio::time::timeout(Duration::from_secs(30), reply_rcv.recv::>()).await { Ok(Ok(r)) => r, _ => return Err(format!("await reply for {body:?}: timed out").into()), }; reply_rcv.accept(&reply).await?; let (executed, err_text) = command_outcome(reply.message()); let _ = message_id_string(&reply.message().properties.as_ref().and_then(|p| p.correlation_id.clone())); println!("reply for {body:?}: executed={executed} error={err_text:?}"); Ok(()) } ``` **`RpcMaxPending` bounds in-flight requests.** The default cap (512 per connection) limits outstanding requests; a request that cannot reserve a slot returns `amqp:resource-limit-exceeded` ("rpc pending limit reached"). Bound your concurrency accordingly. Dynamic reply nodes are node-local, but RPC **replies travel the broker reply path and are cluster-safe** — request/reply works across a cluster even though the reply node lives on the requester's node. ## Related [#related] # Configuration (/connectors/amqp/concepts/configuration) The AMQP 1.0 connector is configured server-side through **14 settings** under the `Connectors.Amqp10.*` namespace of the KubeMQ server config. The connector is **opt-in (disabled by default)** — you must explicitly enable it. It ships with sensible production defaults, so most deployments only override a value when they need a larger frame size, a higher connection cap, or a different RPC timeout. The only thing **clients** configure is the broker endpoint via the `KUBEMQ_AMQP_URL` environment variable (default `amqp://localhost:5672`). Everything below is broker-side server configuration. These settings are **not hot-reloadable** — changing any of them requires a server restart. ## Enable / disable [#enable--disable] Enable the connector with its enable variable: To turn it **off** again: **The enable variable is `CONNECTORS_AMQP10_ENABLE` — the literal `10` stays attached to `AMQP` with no underscore.** The config key `Connectors.Amqp10.Enable` snake-cases by stripping the dots and upper-casing, so `Amqp10` becomes `AMQP10`. Variants like `CONNECTORS_AMQP_1_0_ENABLE` or `CONNECTORS_AMQP10ENABLE` do **not** bind to the field and are silently ignored. When `Enable` is `false`, all other AMQP 1.0 validation is skipped and no listener binds. ### `DefaultPattern` and the bare-address fallback [#defaultpattern-and-the-bare-address-fallback] `DefaultPattern` only matters for **bare addresses** — a node address with no recognized `/` prefix and no JMS node-capability hint (`queue` → `queues`, `topic` → `events`). Because best practice is to always emit the **explicit prefix** (`queues/orders`, `events/telemetry`), `DefaultPattern` rarely affects real traffic. Treat bare addressing as a migration convenience only — it is non-deterministic by design. See [Addressing](/connectors/amqp/concepts/addressing). ## TLS [#tls] TLS for the AMQP 1.0 connector is driven entirely by the **server-global `Security` block** (the same one the gRPC and AMQP 0-9-1 listeners use), not by an AMQP-1.0-specific field. When `Security` is configured, the shared `amqpmux` TLS listener binds on `TlsPort` with a minimum of TLS 1.2; mTLS adds the client CA pool and requires a verified client certificate (mapped to SASL EXTERNAL). See [TLS and mTLS](/connectors/amqp/how-to/tls-and-mtls) and [Auth & security](/connectors/reference/auth-and-security). For the full field list, validation rules, and TOML/environment/Docker configuration examples, see [Configuration reference](../reference/configuration). ## Related [#related] # Events Store (/connectors/amqp/concepts/events-store) Events Store is **durable, replayable** pub/sub over the AMQP 1.0 connector. Attach a link to a node whose address begins with `events-store/` and the connector binds it to the KubeMQ **Events Store** pattern. Unlike plain [Events](/connectors/amqp/concepts/events) (fire-hose, no replay), an Events Store subscriber can **resume** where it left off after a disconnect and can **replay** history from a chosen start position. ## Overview [#overview] The `events-store/` prefix selects the Events Store pattern (longest-prefix match, evaluated **before** `events/`). A producer attaches a **sender** to `events-store/`; each `TRANSFER` becomes a KubeMQ `SendEventsStore` with `Store=true`, so the message is persisted. A consumer subscribes **durably** by attaching a **receiver** with terminus `expiry-policy = never`, a **stable container-id**, and a **stable link `Name`** — on reconnect with the same identity, the subscription resumes. | Operation | AMQP action | KubeMQ mapping | | ----------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------- | | Produce | Attach a **sender** to `events-store/`, `TRANSFER` | `SendEventsStore` (`Store=true`) — persisted | | Subscribe durably | **Receiver** with `expiry-policy = never` + stable container-id + stable link name | Durable, resumable subscription | | Set replay start | Receiver link property `x-opt-kubemq-start` | Start cursor (first / last / sequence / time …) | ### Durable identity [#durable-identity] A durable subscription is identified by the pair **`(container-id, link name)`**. To resume, reconnect with the **same container-id AND the same link name** — change either and you get a *different* durable subscription that starts fresh. The connector derives a stable id from both: ```text durableID = sanitize40(containerID) + "_" + sanitize40(linkName) + "_" + fnv1a32hex(containerID + "|" + linkName) ``` **A stable container-id is mandatory.** Because the container-id is half the durable identity, a durable subscriber that lets its container-id drift across reconnects (for example, a randomly generated one) will never resume — it creates a brand-new subscription each time. Pin a stable container-id. ## Start positions [#start-positions] A durable receiver's start position is set with the link property **`x-opt-kubemq-start`**. It applies only to `events-store` (it is ignored on plain Events and on RPC, which have no replay). The grammar: | `x-opt-kubemq-start` value | Meaning | | ------------------------------ | ----------------------------------------------------------------------- | | `""` or `new-only` | only messages published **after** the subscription starts (the default) | | `first` | replay from the **beginning** of stored history | | `last` | start from the **most recent** stored message | | `sequence:` | start at sequence number `` (**1-based**, non-negative) | | `time:` | start at a wall-clock time | | `time-delta:` | start `` ago from now | Notes: * **Time granularity is seconds; the store keeps nanoseconds.** You send `time:` as RFC3339 or whole seconds and the connector converts to the store's nanosecond resolution. `time-delta:` is whole seconds, used verbatim. * **No "last N by count".** There is no way to ask for "the last N messages" — bound a replay with `sequence:`, `time:`, or `time-delta:`. * **Malformed values are rejected at attach.** `sequence:abc`, `time:not-a-time`, or an unknown token returns `DETACH(amqp:invalid-field)` naming the offending token. For **filtered replay** — replaying only the subset of stored messages matching a selector — combine a start position with a selector filter on the receiver; see [Address mapping](/connectors/amqp/reference/address-mapping). ## How it works [#how-it-works] A durable subscriber attaches with a stable identity and a non-expiring source. On a clean disconnect the connector preserves the cursor; re-attaching with the same `(container-id, link name)` resumes and delivers every event published while the subscriber was away. *Persisted events are replayed to a durable subscriber from its chosen start position; the cursor survives disconnects so the subscription resumes exactly where it left off.* **Events-store stalled credit loses the buffered window.** A consume link fronts the subscription with a deliver-first ring buffer (≈1024 by default) that is auto-acked in the store *before* you take delivery. If the buffer fills while your credit stays at 0, the link detaches with `amqp:resource-limit-exceeded` ("credit stalled") and the entire buffered, already-acked window is lost — a durable re-attach resumes *after* it. Replenish credit aggressively so the buffer never fills at zero credit. The only signal is the metric `kubemq_amqp10_events_store_dropped_stalled_total`. ## Durable subscribe and resume [#durable-subscribe-and-resume] Each example publishes 3 events to a live durable subscriber, disconnects, publishes 5 more while the subscriber is away, then re-attaches with the **same** durable identity and receives exactly the 5 missed events — no loss, no re-delivery of the already-consumed first 3. Every client reads the broker endpoint from `KUBEMQ_AMQP_URL` (default `amqp://localhost:5672`). ```go package main import ( "context" "fmt" "log" "os" "time" amqp "github.com/Azure/go-amqp" ) const channel = "amqp10.examples.durable" // The durable identity = (containerID, linkName). Both MUST be stable to resume. const ( containerID = "amqp10-examples-durable-container" linkName = "durable-sub" ) const standingCredit = 100 func amqpURL() string { if v := os.Getenv("KUBEMQ_AMQP_URL"); v != "" { return v } return "amqp://localhost:5672" } func main() { ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() addr := "events-store/" + channel // events-store/ prefix → KubeMQ Events Store // Producer — a plain connection (no stable id needed) that publishes throughout. prodConn, _ := amqp.Dial(ctx, amqpURL(), nil) defer func() { _ = prodConn.Close() }() prodSess, _ := prodConn.NewSession(ctx, nil) sender, _ := prodSess.NewSender(ctx, addr, nil) // 1. DURABLE SUBSCRIBE (first attach): stable container-id + link name + // non-expiring source + start=new-only. durRcv, durConn := attachDurable(ctx, "first attach") publish(ctx, sender, 0, 3) first := drain(ctx, durRcv, 3, 30*time.Second) fmt.Printf("first attach received %d events: %v\n", len(first), first) // 2. DISCONNECT — the connector preserves the durable cursor. _ = durConn.Close() time.Sleep(time.Second) // 3. PUBLISH 5 more while away. publish(ctx, sender, 3, 8) // 4. RE-ATTACH with the SAME identity → resumes and delivers the 5 missed events. durRcv2, durConn2 := attachDurable(ctx, "re-attach") defer func() { _ = durConn2.Close() }() resumed := drain(ctx, durRcv2, 5, 30*time.Second) fmt.Printf("re-attach resumed and received the %d events published while away: %v\n", len(resumed), resumed) } func attachDurable(ctx context.Context, phase string) (*amqp.Receiver, *amqp.Conn) { conn, err := amqp.Dial(ctx, amqpURL(), &amqp.ConnOptions{ContainerID: containerID}) if err != nil { log.Fatalf("[%s] dial durable: %v", phase, err) } session, _ := conn.NewSession(ctx, nil) rcv, err := session.NewReceiver(ctx, "events-store/"+channel, &amqp.ReceiverOptions{ Credit: standingCredit, SourceExpiryPolicy: amqp.ExpiryPolicyNever, // durable signal Name: linkName, // stable link name Properties: map[string]any{"x-opt-kubemq-start": "new-only"}, // start cursor }) if err != nil { log.Fatalf("[%s] attach durable receiver: %v", phase, err) } time.Sleep(750 * time.Millisecond) // let the subscription pump go live return rcv, conn } func publish(ctx context.Context, sender *amqp.Sender, lo, hi int) { for i := lo; i < hi; i++ { if err := sender.Send(ctx, amqp.NewMessage([]byte(fmt.Sprintf("es-%03d", i))), nil); err != nil { log.Fatalf("publish: %v", err) } } } func drain(ctx context.Context, rcv *amqp.Receiver, max int, window time.Duration) []string { out := make([]string, 0, max) deadline := time.Now().Add(window) for len(out) < max && time.Now().Before(deadline) { rcvCtx, cancel := context.WithTimeout(ctx, time.Until(deadline)) msg, err := rcv.Receive(rcvCtx, nil) cancel() if err != nil { break } _ = rcv.AcceptMessage(ctx, msg) out = append(out, string(msg.GetData())) } return out } ``` ```python import os import time from proton import Message, Terminus, symbol from proton.reactor import Container, ReceiverOption from proton.utils import BlockingConnection CHANNEL = "amqp10.examples.durable" # The durable identity = (container-id, link-name). Both MUST be stable to resume. CONTAINER_ID = "amqp10-examples-durable-container" LINK_NAME = "durable-sub" START_PROP = "x-opt-kubemq-start" STANDING_CREDIT = 100 def amqp_url() -> str: return os.environ.get("KUBEMQ_AMQP_URL", "amqp://localhost:5672") class DurableSource(ReceiverOption): """Make the source durable + non-expiring and stamp x-opt-kubemq-start.""" def __init__(self, start: str) -> None: self.start = start def apply(self, receiver) -> None: receiver.source.durability = Terminus.DELIVERIES receiver.source.expiry_policy = Terminus.EXPIRE_NEVER # durable signal receiver.properties = {symbol(START_PROP): self.start} # start cursor (link prop) def attach_durable(phase: str): container = Container() container.container_id = CONTAINER_ID # half the durable identity conn = BlockingConnection(amqp_url(), container=container) receiver = conn.create_receiver( "events-store/" + CHANNEL, credit=STANDING_CREDIT, name=LINK_NAME, # the other half of the identity options=DurableSource("new-only"), ) time.sleep(0.75) # let the subscription pump go live return conn, receiver def publish(sender, lo: int, hi: int) -> None: for i in range(lo, hi): sender.send(Message(body=f"es-{i:03d}")) def drain(receiver, want: int, window: float) -> list[str]: out: list[str] = [] deadline = time.monotonic() + window while len(out) < want and time.monotonic() < deadline: try: msg = receiver.receive(timeout=max(0.0, deadline - time.monotonic())) except Exception: break if receiver.fetcher.unsettled: receiver.accept() out.append(str(msg.body)) return out def main() -> None: addr = "events-store/" + CHANNEL # events-store/ prefix → KubeMQ Events Store prod_conn = BlockingConnection(amqp_url()) sender = prod_conn.create_sender(addr) # 1. DURABLE SUBSCRIBE (first attach). dur_conn, dur_rcv = attach_durable("first attach") publish(sender, 0, 3) first = drain(dur_rcv, 3, 30.0) print(f"first attach received {len(first)} events: {first}") # 2. DISCONNECT — the connector preserves the durable cursor. dur_conn.close() time.sleep(1.0) # 3. PUBLISH 5 more while away. publish(sender, 3, 8) # 4. RE-ATTACH with the SAME identity → resumes and delivers the 5 missed events. dur_conn2, dur_rcv2 = attach_durable("re-attach") resumed = drain(dur_rcv2, 5, 30.0) print(f"re-attach resumed and received the {len(resumed)} events published while away: {resumed}") dur_conn2.close() sender.close() prod_conn.close() if __name__ == "__main__": main() ``` ```java import java.util.HashSet; import java.util.Set; import javax.jms.Connection; import javax.jms.Message; import javax.jms.MessageConsumer; import javax.jms.MessageProducer; import javax.jms.Session; import javax.jms.Topic; import org.apache.qpid.jms.JmsConnectionFactory; public final class Main { private static final String CHANNEL = "amqp10.examples.durable"; // The durable identity = (JMS clientID, subscription name). Both MUST be stable. private static final String CLIENT_ID = "amqp10-examples-durable-container"; private static final String SUB_NAME = "durable-sub"; public static void main(String[] args) throws Exception { String url = System.getenv().getOrDefault("KUBEMQ_AMQP_URL", "amqp://localhost:5672"); String address = "events-store/" + CHANNEL; // events-store/ → KubeMQ Events Store JmsConnectionFactory factory = new JmsConnectionFactory(url); // Producer — a separate connection (no stable clientID needed). try (Connection prodConn = factory.createConnection(); Session prodSession = prodConn.createSession(false, Session.AUTO_ACKNOWLEDGE)) { prodConn.start(); Topic topic = prodSession.createTopic(address); try (MessageProducer producer = prodSession.createProducer(topic)) { // 1. DURABLE SUBSCRIBE (first attach): clientID + durable consumer. try (Connection durConn = factory.createConnection()) { durConn.setClientID(CLIENT_ID); durConn.start(); try (Session durSession = durConn.createSession(false, Session.CLIENT_ACKNOWLEDGE)) { Topic durTopic = durSession.createTopic(address); MessageConsumer durable = durSession.createDurableConsumer(durTopic, SUB_NAME); Thread.sleep(750); publish(prodSession, producer, 0, 3); Set first = drain(durable, 3, 30_000); System.out.printf("first attach received %d events: %s%n", first.size(), first); durable.close(); // detach but KEEP the durable } } Thread.sleep(1_000); // 3. PUBLISH 5 more while away. publish(prodSession, producer, 3, 8); // 4. RE-ATTACH with the SAME identity → resumes the subscription. try (Connection durConn2 = factory.createConnection()) { durConn2.setClientID(CLIENT_ID); durConn2.start(); try (Session durSession2 = durConn2.createSession(false, Session.CLIENT_ACKNOWLEDGE)) { Topic durTopic2 = durSession2.createTopic(address); MessageConsumer durable2 = durSession2.createDurableConsumer(durTopic2, SUB_NAME); Set resumed = drain(durable2, 5, 30_000); System.out.printf( "re-attach resumed and received the %d events published while away: %s%n", resumed.size(), resumed); durable2.close(); durSession2.unsubscribe(SUB_NAME); // clean teardown of the durable } } } } } private static void publish(Session session, MessageProducer producer, int lo, int hi) throws Exception { for (int i = lo; i < hi; i++) { producer.send(session.createTextMessage(String.format("es-%03d", i))); } } private static Set drain(MessageConsumer consumer, int max, long timeoutMillis) throws Exception { Set out = new HashSet<>(); long deadline = System.currentTimeMillis() + timeoutMillis; while (out.size() < max) { long remaining = deadline - System.currentTimeMillis(); if (remaining <= 0) break; Message msg = consumer.receive(remaining); if (msg == null) break; msg.acknowledge(); out.add(msg.getBody(String.class)); } return out; } } ``` ```csharp using System.Text; using Amqp; using Amqp.Framing; using Amqp.Types; const string channel = "amqp10.examples.durable"; // The durable identity = (containerID, linkName). Both MUST be stable to resume. const string containerId = "amqp10-examples-durable-container"; const string linkName = "durable-sub"; const int standingCredit = 100; var expiryNever = new Symbol("never"); // terminus-expiry-policy = never (durable signal) static string AmqpUrl() => Environment.GetEnvironmentVariable("KUBEMQ_AMQP_URL") is { Length: > 0 } v ? v : "amqp://localhost:5672"; var addr = "events-store/" + channel; // events-store/ prefix → KubeMQ Events Store // Producer — a plain connection (no stable id needed) that publishes throughout. var prodConnection = await Connection.Factory.CreateAsync(new Address(AmqpUrl())); var prodSession = new Session(prodConnection); var sender = new SenderLink(prodSession, "durable-producer", addr); void Publish(int lo, int hi) { for (var i = lo; i < hi; i++) { var message = new Message { BodySection = new Data { Binary = Encoding.UTF8.GetBytes($"es-{i:D3}") } }; sender.Send(message, TimeSpan.FromSeconds(15)); } } (ReceiverLink Receiver, Connection Connection) AttachDurable(string phase) { var factory = new ConnectionFactory(); factory.AMQP.ContainerId = containerId; // half the durable identity var connection = factory.CreateAsync(new Address(AmqpUrl())).GetAwaiter().GetResult(); var session = new Session(connection); var attach = new Attach { Source = new Source { Address = addr, ExpiryPolicy = expiryNever }, Target = new Target(), LinkName = linkName, // the other half of the durable identity Properties = new Fields { { new Symbol("x-opt-kubemq-start"), "new-only" } }, }; var receiver = new ReceiverLink(session, linkName, attach, null); receiver.SetCredit(standingCredit, autoRestore: true); Thread.Sleep(750); // let the subscription pump go live return (receiver, connection); } List Drain(ReceiverLink receiver, int max, TimeSpan window) { var outp = new List(max); var deadline = DateTime.UtcNow + window; while (outp.Count < max && DateTime.UtcNow < deadline) { var message = receiver.Receive(TimeSpan.FromSeconds(2)); if (message is null) continue; receiver.Accept(message); outp.Add(BodyString(message)); } return outp; } try { // 1. DURABLE SUBSCRIBE (first attach). var (durRcv, durConn) = AttachDurable("first attach"); Publish(0, 3); var first = Drain(durRcv, 3, TimeSpan.FromSeconds(30)); Console.WriteLine($"first attach received {first.Count} events: [{string.Join(" ", first)}]"); // 2. DISCONNECT — the connector preserves the durable cursor. await durConn.CloseAsync(); await Task.Delay(1000); // 3. PUBLISH 5 more while away. Publish(3, 8); // 4. RE-ATTACH with the SAME identity → resumes the subscription. var (durRcv2, durConn2) = AttachDurable("re-attach"); try { var resumed = Drain(durRcv2, 5, TimeSpan.FromSeconds(30)); Console.WriteLine($"re-attach resumed and received the {resumed.Count} events published while away: [{string.Join(" ", resumed)}]"); await durRcv2.CloseAsync(); } finally { await durConn2.CloseAsync(); } } finally { await sender.CloseAsync(); await prodSession.CloseAsync(); await prodConnection.CloseAsync(); } static string BodyString(Message message) => message.BodySection switch { Data d => Encoding.UTF8.GetString(d.Binary), AmqpValue { Value: byte[] bytes } => Encoding.UTF8.GetString(bytes), AmqpValue { Value: string str } => str, AmqpValue v => v.Value?.ToString() ?? string.Empty, _ => string.Empty, }; ``` ```typescript import { Connection, ReceiverEvents, type ConnectionOptions, type EventContext, type Receiver, } from "rhea-promise"; const channel = "amqp10.examples.durable"; // The durable identity = (containerID, linkName). Both MUST be stable to resume. const containerId = "amqp10-examples-durable-container"; const linkName = "durable-sub"; const standingCredit = 100; function brokerEndpoint(): { host: string; port: number } { const url = new URL(process.env["KUBEMQ_AMQP_URL"] ?? "amqp://localhost:5672"); return { host: url.hostname, port: url.port ? Number(url.port) : 5672 }; } function durableOptions(): ConnectionOptions { const { host, port } = brokerEndpoint(); return { host, port, container_id: containerId, reconnect: false }; // STABLE id } function bodyToString(body: unknown): string { return Buffer.isBuffer(body) ? body.toString("utf8") : String(body); } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } interface DurableAttach { connection: Connection; receiver: Receiver; } async function attachDurable(): Promise { const connection = new Connection(durableOptions()); await connection.open(); const receiver = await connection.createReceiver({ name: linkName, // half the durable identity source: { address: `events-store/${channel}`, expiry_policy: "never", // durable signal }, properties: { "x-opt-kubemq-start": "new-only" }, // start cursor credit_window: 0, autoaccept: false, autosettle: false, }); await sleep(750); // let the subscription pump go live return { connection, receiver }; } function drain(receiver: Receiver, max: number, windowMs: number): Promise { return new Promise((resolve, reject) => { const out: string[] = []; const timer = setTimeout(() => { receiver.removeListener(ReceiverEvents.message, handler); resolve(out); }, windowMs); const handler = (ctx: EventContext): void => { try { ctx.delivery?.accept(); // accept advances the durable cursor out.push(bodyToString(ctx.message?.body)); if (out.length >= max) { clearTimeout(timer); receiver.removeListener(ReceiverEvents.message, handler); resolve(out); return; } receiver.addCredit(1); } catch (err) { clearTimeout(timer); receiver.removeListener(ReceiverEvents.message, handler); reject(err instanceof Error ? err : new Error(String(err))); } }; receiver.on(ReceiverEvents.message, handler); receiver.addCredit(standingCredit); }); } async function main(): Promise { const { host, port } = brokerEndpoint(); const address = `events-store/${channel}`; // events-store/ → KubeMQ Events Store // Producer — a plain connection (no stable id needed) that publishes throughout. const prodConnection = new Connection({ host, port, container_id: `kubemq-amqp10-js-durable-prod-${process.pid}`, reconnect: false, }); await prodConnection.open(); const sender = await prodConnection.createAwaitableSender({ target: { address } }); const publish = async (lo: number, hi: number): Promise => { for (let i = lo; i < hi; i++) { await sender.send({ body: `es-${String(i).padStart(3, "0")}` }, { timeoutInSeconds: 15 }); } }; try { // 1. DURABLE SUBSCRIBE (first attach). const first = await attachDurable(); await publish(0, 3); const firstBodies = await drain(first.receiver, 3, 30_000); console.log(`first attach received ${firstBodies.length} events: [${firstBodies.join(" ")}]`); // 2. DISCONNECT — the connector preserves the durable cursor. await first.connection.close(); await sleep(1_000); // 3. PUBLISH 5 more while away. await publish(3, 8); // 4. RE-ATTACH with the SAME identity → resumes the subscription. const second = await attachDurable(); try { const resumed = await drain(second.receiver, 5, 30_000); console.log(`re-attach resumed and received the ${resumed.length} events published while away: [${resumed.join(" ")}]`); } finally { await second.connection.close(); } } finally { await sender.close(); await prodConnection.close(); } } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```rust use std::collections::HashSet; use std::time::Duration; use fe2o3_amqp::connection::ConnectionHandle; use fe2o3_amqp::link::receiver::CreditMode; use fe2o3_amqp::session::SessionHandle; use fe2o3_amqp::{Connection, Receiver, Sender, Session}; use fe2o3_amqp_types::definitions::SenderSettleMode; use fe2o3_amqp_types::messaging::{Body, Source, TerminusExpiryPolicy}; use fe2o3_amqp_types::primitives::{OrderedMap, Symbol, Value}; const CHANNEL: &str = "amqp10.examples.durable"; // The durable identity = (container-id, link name). Both MUST be stable to resume. const CONTAINER_ID: &str = "amqp10-examples-durable-container"; const LINK_NAME: &str = "durable-sub"; const STANDING_CREDIT: u32 = 100; const START_PROP: &str = "x-opt-kubemq-start"; fn amqp_url() -> String { std::env::var("KUBEMQ_AMQP_URL").unwrap_or_else(|_| "amqp://localhost:5672".to_string()) } fn body_string(body: &Body) -> String { let bytes = match body { Body::Data(batch) => batch.iter().flat_map(|d| d.0.to_vec()).collect(), Body::Value(v) => match &v.0 { Value::Binary(b) => b.to_vec(), Value::String(s) => s.clone().into_bytes(), other => format!("{other:?}").into_bytes(), }, _ => Vec::new(), }; String::from_utf8_lossy(&bytes).into_owned() } fn durable_source(addr: &str) -> Source { Source::builder() .address(addr) .expiry_policy(TerminusExpiryPolicy::Never) // durable signal .build() } fn start_props(start: &str) -> OrderedMap { let mut props = OrderedMap::new(); props.insert(Symbol::from(START_PROP), Value::String(start.to_string())); props } #[allow(clippy::type_complexity)] async fn attach_durable( url: &str, addr: &str, ) -> Result<(Receiver, SessionHandle<()>, ConnectionHandle<()>), Box> { let mut connection = Connection::builder() .container_id(CONTAINER_ID) // half the durable identity .open(url) .await?; let mut session = Session::begin(&mut connection).await?; let receiver = Receiver::builder() .name(LINK_NAME) // the other half of the identity .source(durable_source(addr)) .properties(start_props("new-only")) // start cursor .credit_mode(CreditMode::Auto(STANDING_CREDIT)) .attach(&mut session) .await?; tokio::time::sleep(Duration::from_millis(750)).await; // let the pump go live Ok((receiver, session, connection)) } async fn publish(sender: &mut Sender, lo: usize, hi: usize) -> Result<(), Box> { for i in lo..hi { let outcome = sender.send(format!("es-{i:03}")).await?; if !outcome.is_accepted() { return Err(format!("publish es-{i:03}: unexpected outcome {outcome:?}").into()); } } Ok(()) } async fn drain(receiver: &mut Receiver, max: usize, window: Duration) -> Result, Box> { let mut out = Vec::with_capacity(max); let deadline = tokio::time::Instant::now() + window; while out.len() < max { let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); if remaining.is_zero() { break; } match tokio::time::timeout(remaining, receiver.recv::>()).await { Ok(Ok(delivery)) => { receiver.accept(&delivery).await?; out.push(body_string(&delivery.message().body)); } _ => break, } } Ok(out) } #[tokio::main] async fn main() -> Result<(), Box> { let url = amqp_url(); let addr = format!("events-store/{CHANNEL}"); // events-store/ → KubeMQ Events Store // Producer — a plain connection (no stable id needed) that publishes throughout. let mut prod_conn = Connection::open("amqp10-examples-durable-producer", url.as_str()).await?; let mut prod_sess = Session::begin(&mut prod_conn).await?; let mut sender = Sender::builder() .name("durable-replay-producer") .target(addr.as_str()) .sender_settle_mode(SenderSettleMode::Unsettled) .attach(&mut prod_sess) .await?; // 1. DURABLE SUBSCRIBE (first attach). let (mut dur_rcv, mut dur_sess, mut dur_conn) = attach_durable(url.as_str(), addr.as_str()).await?; publish(&mut sender, 0, 3).await?; let first = drain(&mut dur_rcv, 3, Duration::from_secs(30)).await?; println!("first attach received {} events: {first:?}", first.len()); // 2. DISCONNECT — the connector preserves the durable cursor. dur_rcv.close().await?; dur_sess.end().await?; dur_conn.close().await?; tokio::time::sleep(Duration::from_secs(1)).await; // 3. PUBLISH 5 more while away. publish(&mut sender, 3, 8).await?; // 4. RE-ATTACH with the SAME identity → resumes the subscription. let (mut dur_rcv2, mut dur_sess2, mut dur_conn2) = attach_durable(url.as_str(), addr.as_str()).await?; let resumed = drain(&mut dur_rcv2, 5, Duration::from_secs(30)).await?; let resumed_set: HashSet = resumed.iter().cloned().collect(); println!("re-attach resumed and received the {} events published while away: {resumed:?}", resumed_set.len()); dur_rcv2.close().await?; dur_sess2.end().await?; dur_conn2.close().await?; sender.close().await?; prod_sess.end().await?; prod_conn.close().await?; Ok(()) } ``` **Durable subscriptions are node-local.** A durable identity may have at most **one live attach per node**; a second live attach of the same identity returns `DETACH(amqp:not-allowed, "durable subscription in use")`. In a cluster the durable cursor lives on the node that owned the original attach, so a durable subscriber must reconnect to the **same node** to resume — use load-balancer session affinity or a sticky connection. (RPC replies travel the broker reply path and are cluster-safe; durable subscriptions are not.) ## Related [#related] # Events (/connectors/amqp/concepts/events) Events are **fire-and-forget** pub/sub over the AMQP 1.0 connector. Attach a sender or receiver to a node whose address begins with `events/` and the connector binds the link to the KubeMQ **Events** pattern. Every active subscriber receives a **copy** of every message; there is no persistence and no replay. ## Overview [#overview] The `events/` prefix selects the Events pattern (longest-prefix match, evaluated before `events-store/`). A producer attaches a **sender** to `events/`; each `TRANSFER` becomes a KubeMQ `SendEvents` with `Store=false`. A consumer attaches a **receiver** to the same node and grants credit; deliveries are **always pre-settled** (`settled=true`) — at-most-once, with no `DISPOSITION` round-trip. Use Events for real-time notifications, telemetry, and broadcast where a missed message is acceptable. When you need durability and replay, use [Events Store](/connectors/amqp/concepts/events-store) instead. | Operation | AMQP action | KubeMQ mapping | | -------------- | --------------------------------------------------------------------------------- | ------------------------------------------- | | Produce | Attach a **sender** to `events/`, `TRANSFER` (pre-settle for fire-and-forget) | `SendEvents` (`Store=false`) | | Consume | Attach a **receiver** to `events/`, grant credit | Pre-settled fan-out delivery (at-most-once) | | Consumer group | Set link property `x-opt-kubemq-group` on the receiver | Load-balanced subset of the stream | ## How it works [#how-it-works] A published event fans out to every connected receiver on the channel. Receivers that share an `x-opt-kubemq-group` link property split the stream load-balanced; a receiver with no group is a plain fan-out subscriber. *Each event is copied to every plain subscriber; members sharing an `x-opt-kubemq-group` split the stream between them.* **Events at 0 credit are silently dropped.** A message that arrives at a receiver whose link credit is 0 is discarded with no error and no `DISPOSITION` — that is what at-most-once means here. Grant a standing credit and replenish it eagerly, and **subscribe before you publish** (there is no replay to catch up from). The connector counts every drop in `kubemq_amqp10_events_dropped_no_credit_total`. ## Publish and subscribe [#publish-and-subscribe] Each example below subscribes **first** (a receiver with a large standing credit), waits \~750 ms for the connector's subscription pump to go live, then publishes pre-settled events and drains them. Every client reads the broker endpoint from `KUBEMQ_AMQP_URL` (default `amqp://localhost:5672`). ```go package main import ( "context" "fmt" "log" "os" "time" amqp "github.com/Azure/go-amqp" ) const channel = "amqp10.examples.pubsub" const total = 20 const standingCredit = 100 // never let credit reach 0 — a 0-credit event is dropped func amqpURL() string { if v := os.Getenv("KUBEMQ_AMQP_URL"); v != "" { return v } return "amqp://localhost:5672" } func main() { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() addr := "events/" + channel // events/ prefix → KubeMQ Events pattern conn, err := amqp.Dial(ctx, amqpURL(), nil) if err != nil { log.Fatalf("dial: %v", err) } defer func() { _ = conn.Close() }() session, err := conn.NewSession(ctx, nil) if err != nil { log.Fatalf("new session: %v", err) } // 1. SUBSCRIBE FIRST with standing credit. Events have no replay — a publish // that beats the subscription is lost forever. receiver, err := session.NewReceiver(ctx, addr, &amqp.ReceiverOptions{Credit: standingCredit}) if err != nil { log.Fatalf("new receiver: %v", err) } // The attach reply confirms the link, not that the subscription pump is live. time.Sleep(750 * time.Millisecond) // 2. PUBLISH pre-settled (fire-and-forget) — no DISPOSITION to await. sender, err := session.NewSender(ctx, addr, &amqp.SenderOptions{ SettlementMode: amqp.SenderSettleModeSettled.Ptr(), }) if err != nil { log.Fatalf("new sender: %v", err) } for i := 0; i < total; i++ { if err := sender.Send(ctx, amqp.NewMessage([]byte(fmt.Sprintf("event-%03d", i))), nil); err != nil { log.Fatalf("publish: %v", err) } } _ = sender.Close(ctx) // 3. RECEIVE. Standing credit drains every event; accept is a no-op on // pre-settled fan-out but harmless. seen := make(map[string]struct{}, total) for len(seen) < total { msg, err := receiver.Receive(ctx, nil) if err != nil { log.Fatalf("receive: %v", err) } _ = receiver.AcceptMessage(ctx, msg) seen[string(msg.GetData())] = struct{}{} } fmt.Printf("received all %d events\n", len(seen)) _ = receiver.Close(ctx) } ``` ```python import os import time from proton import Message from proton.reactor import AtMostOnce from proton.utils import BlockingConnection CHANNEL = "amqp10.examples.pubsub" TOTAL = 20 STANDING_CREDIT = 100 # never let credit reach 0 — a 0-credit event is dropped def amqp_url() -> str: return os.environ.get("KUBEMQ_AMQP_URL", "amqp://localhost:5672") def accept_if_unsettled(receiver) -> None: # Events fan-out deliveries are pre-settled, so accept() on a settled delivery # raises IndexError. This makes accept a true no-op on pre-settled pub/sub. if receiver.fetcher.unsettled: receiver.accept() def main() -> None: addr = "events/" + CHANNEL # events/ prefix → KubeMQ Events pattern conn = BlockingConnection(amqp_url()) try: # 1. SUBSCRIBE FIRST with standing credit (events have no replay). receiver = conn.create_receiver(addr, credit=STANDING_CREDIT) time.sleep(0.75) # let the subscription pump go live before publishing # 2. PUBLISH pre-settled (AtMostOnce) — fire-and-forget, no DISPOSITION. sender = conn.create_sender(addr, options=AtMostOnce()) for i in range(TOTAL): sender.send(Message(body=f"event-{i:03d}")) sender.close() # 3. RECEIVE. Standing credit drains every event. seen: set[str] = set() while len(seen) < TOTAL: msg = receiver.receive(timeout=30.0) accept_if_unsettled(receiver) seen.add(str(msg.body)) print(f"received all {len(seen)} events") receiver.close() finally: conn.close() if __name__ == "__main__": main() ``` ```java import java.util.HashSet; import java.util.Set; import javax.jms.Connection; import javax.jms.DeliveryMode; import javax.jms.Message; import javax.jms.MessageConsumer; import javax.jms.MessageProducer; import javax.jms.Session; import javax.jms.Topic; import org.apache.qpid.jms.JmsConnectionFactory; public final class Main { private static final String CHANNEL = "amqp10.examples.pubsub"; private static final int TOTAL = 20; public static void main(String[] args) throws Exception { String url = System.getenv().getOrDefault("KUBEMQ_AMQP_URL", "amqp://localhost:5672"); String address = "events/" + CHANNEL; // events/ prefix → KubeMQ Events pattern JmsConnectionFactory factory = new JmsConnectionFactory(url); try (Connection connection = factory.createConnection()) { connection.start(); try (Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE)) { Topic topic = session.createTopic(address); // 1. SUBSCRIBE FIRST (Qpid JMS grants a standing prefetch credit). try (MessageConsumer consumer = session.createConsumer(topic)) { Thread.sleep(750); // let the subscription pump go live // 2. PUBLISH NON_PERSISTENT (fire-and-forget, pre-settled). try (MessageProducer producer = session.createProducer(topic)) { producer.setDeliveryMode(DeliveryMode.NON_PERSISTENT); for (int i = 0; i < TOTAL; i++) { producer.send(session.createTextMessage(String.format("event-%03d", i))); } } // 3. RECEIVE. The connector re-emits the body as a Data section; // getBody(String.class) decodes either type as UTF-8. Set seen = new HashSet<>(); while (seen.size() < TOTAL) { Message msg = consumer.receive(30_000); if (msg == null) throw new IllegalStateException("timed out"); seen.add(msg.getBody(String.class)); } System.out.printf("received all %d events%n", seen.size()); } } } } } ``` ```csharp using System.Text; using Amqp; using Amqp.Framing; const string channel = "amqp10.examples.pubsub"; const int total = 20; const int standingCredit = 100; // never let credit reach 0 — a 0-credit event is dropped static string AmqpUrl() => Environment.GetEnvironmentVariable("KUBEMQ_AMQP_URL") is { Length: > 0 } v ? v : "amqp://localhost:5672"; var addr = "events/" + channel; // events/ prefix → KubeMQ Events pattern var connection = await Connection.Factory.CreateAsync(new Address(AmqpUrl())); try { var session = new Session(connection); // 1. SUBSCRIBE FIRST with standing credit (autoRestore replenishes on settle). var receiver = new ReceiverLink(session, "pubsub-receiver", addr); receiver.SetCredit(standingCredit, autoRestore: true); await Task.Delay(750); // let the subscription pump go live before publishing // 2. PUBLISH pre-settled — SndSettleMode.Settled marks every TRANSFER settled. var senderAttach = new Attach { Source = new Source(), Target = new Target { Address = addr }, SndSettleMode = SenderSettleMode.Settled, }; var sender = new SenderLink(session, "pubsub-sender", senderAttach, null); for (var i = 0; i < total; i++) { var message = new Message { BodySection = new Data { Binary = Encoding.UTF8.GetBytes($"event-{i:D3}") } }; sender.Send(message, TimeSpan.FromSeconds(15)); } // 3. RECEIVE. Standing credit drains every event; Accept is a no-op here. var seen = new HashSet(); while (seen.Count < total) { var message = receiver.Receive(TimeSpan.FromSeconds(30)) ?? throw new InvalidOperationException("receive timed out"); receiver.Accept(message); seen.Add(BodyString(message)); } Console.WriteLine($"received all {seen.Count} events"); await sender.CloseAsync(); await receiver.CloseAsync(); await session.CloseAsync(); } finally { await connection.CloseAsync(); } static string BodyString(Message message) => message.BodySection switch { Data d => Encoding.UTF8.GetString(d.Binary), AmqpValue { Value: byte[] bytes } => Encoding.UTF8.GetString(bytes), AmqpValue { Value: string str } => str, AmqpValue v => v.Value?.ToString() ?? string.Empty, _ => string.Empty, }; ``` ```typescript import { Connection, ReceiverEvents, type EventContext, type Receiver, } from "rhea-promise"; const channel = "amqp10.examples.pubsub"; const total = 20; const standingCredit = 100; // never let credit reach 0 — a 0-credit event is dropped function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function bodyToString(body: unknown): string { return Buffer.isBuffer(body) ? body.toString("utf8") : String(body); } async function main(): Promise { const url = new URL(process.env["KUBEMQ_AMQP_URL"] ?? "amqp://localhost:5672"); const address = `events/${channel}`; // events/ prefix → KubeMQ Events pattern const connection = new Connection({ host: url.hostname, port: url.port ? Number(url.port) : 5672, container_id: `kubemq-amqp10-js-pubsub-${process.pid}`, reconnect: false, }); await connection.open(); try { // 1. SUBSCRIBE FIRST. Register the handler before granting credit so no // early delivery is missed (events have no replay). const receiver = await connection.createReceiver({ source: { address }, credit_window: 0, autoaccept: false, autosettle: false, }); const seen = new Set(); const received = drainEvents(receiver, (ctx) => { ctx.delivery?.accept(); // no-op for pre-settled fan-out, but harmless seen.add(bodyToString(ctx.message?.body)); return seen.size >= total; }, standingCredit, 30_000); await sleep(750); // let the subscription pump go live before publishing // 2. PUBLISH pre-settled (snd_settle_mode: 1) — fire-and-forget. const sender = await connection.createSender({ target: { address }, snd_settle_mode: 1, autosettle: true, }); for (let i = 0; i < total; i++) { sender.send({ body: `event-${String(i).padStart(3, "0")}` }); } await sender.close(); await received; console.log(`received all ${seen.size} events`); await receiver.close(); } finally { await connection.close(); } } // Grants standing credit and tops it back up as messages arrive so the // subscriber is never starved (a 0-credit event is silently dropped). function drainEvents( receiver: Receiver, onMessage: (ctx: EventContext) => boolean, credit: number, timeoutMs: number, ): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { receiver.removeListener(ReceiverEvents.message, handler); reject(new Error("timed out waiting for events")); }, timeoutMs); const handler = (ctx: EventContext): void => { if (onMessage(ctx)) { clearTimeout(timer); receiver.removeListener(ReceiverEvents.message, handler); resolve(); return; } receiver.addCredit(1); // replenish so standing credit never drains to 0 }; receiver.on(ReceiverEvents.message, handler); receiver.addCredit(credit); }); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```rust use std::collections::HashSet; use std::time::Duration; use fe2o3_amqp::link::delivery::Delivery; use fe2o3_amqp::link::receiver::CreditMode; use fe2o3_amqp::{Connection, Receiver, Sender, Session}; use fe2o3_amqp_types::definitions::SenderSettleMode; use fe2o3_amqp_types::messaging::{Body, Message}; use fe2o3_amqp_types::primitives::Value; const CHANNEL: &str = "amqp10.examples.pubsub"; const TOTAL: usize = 20; const STANDING_CREDIT: u32 = 100; // never let credit reach 0 — a 0-credit event is dropped fn amqp_url() -> String { std::env::var("KUBEMQ_AMQP_URL").unwrap_or_else(|_| "amqp://localhost:5672".to_string()) } fn body_string(msg: &Message>) -> String { let bytes = match &msg.body { Body::Data(batch) => batch.iter().flat_map(|d| d.0.to_vec()).collect(), Body::Value(v) => match &v.0 { Value::Binary(b) => b.to_vec(), Value::String(s) => s.clone().into_bytes(), other => format!("{other:?}").into_bytes(), }, _ => Vec::new(), }; String::from_utf8_lossy(&bytes).into_owned() } #[tokio::main] async fn main() -> Result<(), Box> { let addr = format!("events/{CHANNEL}"); // events/ prefix → KubeMQ Events pattern let mut connection = Connection::open("amqp10-examples-pubsub", amqp_url().as_str()).await?; let mut session = Session::begin(&mut connection).await?; // 1. SUBSCRIBE FIRST with standing credit (CreditMode::Auto auto-replenishes). let mut receiver = Receiver::builder() .name("basic-pubsub-receiver") .source(addr.as_str()) .credit_mode(CreditMode::Auto(STANDING_CREDIT)) .attach(&mut session) .await?; tokio::time::sleep(Duration::from_millis(750)).await; // let the pump go live // 2. PUBLISH pre-settled (SenderSettleMode::Settled) — fire-and-forget. let mut sender = Sender::builder() .name("basic-pubsub-sender") .target(addr.as_str()) .sender_settle_mode(SenderSettleMode::Settled) .attach(&mut session) .await?; for i in 0..TOTAL { sender.send(format!("event-{i:03}")).await?; } sender.close().await?; // 3. RECEIVE. Standing credit drains every event. let mut seen: HashSet = HashSet::with_capacity(TOTAL); while seen.len() < TOTAL { let delivery: Delivery> = receiver.recv().await?; let _ = receiver.accept(&delivery).await; // no-op on pre-settled, harmless seen.insert(body_string(delivery.message())); } println!("received all {} events", seen.len()); receiver.close().await?; session.end().await?; connection.close().await?; Ok(()) } ``` ## Consumer groups [#consumer-groups] A receiver with **no** group is a plain fan-out subscriber — it receives every event (the KubeMQ default). Set the link property **`x-opt-kubemq-group`** on a receiver's `ATTACH` to join a consumer group: within one group the stream is **load-balanced** across members (each message goes to exactly one member), while different groups each get the full stream independently. ```text events/orders ├── group "g1": receiver-A ┐ (split — no duplicate within g1) │ receiver-B ┘ └── group "g2": receiver-C (full stream) ``` In Go, set it on the receiver's link properties at attach: ```go receiver, err := session.NewReceiver(ctx, "events/orders", &amqp.ReceiverOptions{ Credit: 100, Properties: map[string]any{"x-opt-kubemq-group": "g1"}, }) ``` The `x-opt-kubemq-group` property is honored on `events`, `events-store`, and the RPC consume patterns. The Go, Python, C#, JavaScript, and Rust clients can all set it on the receiver's `ATTACH`. **Qpid JMS (Java) cannot join a consumer group today.** The connector advertises no `SHARED-SUBS` capability, so `createSharedConsumer` / `createSharedDurableConsumer` throws *"Remote peer does not support shared subscriptions"*, and Qpid JMS exposes no API to set the `x-opt-kubemq-group` link property directly. Java is fan-out only on Events; the other five languages support groups fully. ## Related [#related] # Queries (/connectors/amqp/concepts/queries) Queries are **native, in-protocol request/reply** over the AMQP 1.0 connector, used to **fetch a value**. Attach a link to a node whose address begins with `queries/` and the connector binds it to the KubeMQ **Queries** pattern. A query returns a **result body + metadata**; on failure it delivers **nothing**, and the requester detects failure by **timeout**. ## Overview [#overview] The request path is **identical** to [Commands](/connectors/amqp/concepts/commands): the requester opens a **dynamic reply node** (`source.dynamic = true`), sends to `queries/` with `reply-to` = the minted node + a `correlation-id`, and the responder replies via an **anonymous sender** to `properties.to = /responses/` carrying the echoed `correlation-id`. The same **snooping guard** (`reply-to` must name a connection-owned node) and the same correlation-id-with-message-id-fallback rule apply. **See [Commands](/connectors/amqp/concepts/commands) for the full RPC mechanics** — dynamic reply nodes, the anonymous responder, the snooping guard, correlation matching, and the `RpcMaxPending` cap. This page covers only what differs for queries. ## How Queries differ from Commands [#how-queries-differ-from-commands] | | **Commands** | **Queries** | | -------------------- | ------------------------------------------------------------ | --------------------------------------------------- | | Reply body | optional | the result **body + metadata** | | Reply app-properties | `x-opt-kubemq-executed` + `x-opt-kubemq-error` | **none** | | On success | `executed=true` | body + metadata returned | | **On failure** | a reply with `executed=false` (+ error) — never left waiting | **nothing delivered** — the requester **times out** | A query is a "fetch a value" call: there is **no executed/error envelope**. When a query fails, times out, or the responder ignores it, the connector delivers no reply — so the requester's timeout **is** the failure signal. The connector's default per-request timeout is \~30 s; set the request's `header.ttl` (ms) to choose a per-request budget. Choose queries when a missing reply is an acceptable failure mode; choose commands when you need a positive failure signal. ## Request and reply [#request-and-reply] Each example runs a responder and a requester (separate connections), sends a successful query (the reply round-trips with the result body) and a query the responder **ignores** (no reply, so the requester times out). Every client reads the broker endpoint from `KUBEMQ_AMQP_URL` (default `amqp://localhost:5672`). ```go package main import ( "context" "errors" "fmt" "log" "os" "sync" "time" amqp "github.com/Azure/go-amqp" ) const channel = "amqp10.examples.queries" // A short per-request deadline so the "no reply" leg surfaces a timeout quickly. // The connector's own default RPC timeout is ~30s. const demoTimeout = 5 * time.Second func amqpURL() string { if v := os.Getenv("KUBEMQ_AMQP_URL"); v != "" { return v } return "amqp://localhost:5672" } func main() { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() addr := "queries/" + channel // queries/ prefix → KubeMQ Queries pattern ready := make(chan struct{}) var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done(); runResponder(ctx, addr, ready) }() <-ready runRequester(ctx, addr) cancel() wg.Wait() } // Responder: a query whose body is "ignore" gets NO reply (the requester times out). func runResponder(ctx context.Context, addr string, ready chan<- struct{}) { conn, _ := amqp.Dial(ctx, amqpURL(), nil) defer func() { _ = conn.Close() }() session, _ := conn.NewSession(ctx, nil) rcv, _ := session.NewReceiver(ctx, addr, &amqp.ReceiverOptions{Credit: 10}) snd, _ := session.NewSender(ctx, "", nil) // anonymous reply sender (null target) close(ready) for { req, err := rcv.Receive(ctx, nil) if err != nil { if ctx.Err() != nil || errors.Is(err, context.Canceled) { return } return } _ = rcv.AcceptMessage(context.Background(), req) if req.Properties == nil || req.Properties.ReplyTo == nil { continue } body := string(req.GetData()) if body == "ignore" { continue // send NOTHING — the requester times out } // A QUERY reply carries ONLY the body + metadata — no executed/error props. replyTo := *req.Properties.ReplyTo reply := amqp.NewMessage([]byte("result:" + body)) reply.Properties = &amqp.MessageProperties{To: &replyTo} if req.Properties.CorrelationID != nil { reply.Properties.CorrelationID = req.Properties.CorrelationID } else { reply.Properties.CorrelationID = req.Properties.MessageID } _ = snd.Send(ctx, reply, nil) } } // Requester: dynamic reply node + sender on queries/; correlate replies. func runRequester(ctx context.Context, addr string) { conn, _ := amqp.Dial(ctx, amqpURL(), nil) defer func() { _ = conn.Close() }() session, _ := conn.NewSession(ctx, nil) // DYNAMIC reply node (see the Commands page for the shared mechanics). replyRcv, _ := session.NewReceiver(ctx, "", &amqp.ReceiverOptions{DynamicAddress: true, Credit: 5}) replyNode := replyRcv.Address() snd, _ := session.NewSender(ctx, addr, nil) // 1. A SUCCESSFUL query: round-trips, body intact. sendQuery(ctx, snd, replyNode, "get-temp-sensor-3", "corr-qry-1") rcvCtx, c1 := context.WithTimeout(ctx, demoTimeout) reply, err := replyRcv.Receive(rcvCtx, nil) c1() if err != nil { log.Fatalf("await reply: %v", err) } _ = replyRcv.AcceptMessage(context.Background(), reply) fmt.Printf("reply for %q: body=%q\n", "get-temp-sensor-3", string(reply.GetData())) // 2. A query the responder ignores: NOTHING is delivered → the requester TIMES OUT. // The absence of a reply IS the failure signal for queries. sendQuery(ctx, snd, replyNode, "ignore", "corr-qry-2") rcvCtx2, c2 := context.WithTimeout(ctx, demoTimeout) _, err = replyRcv.Receive(rcvCtx2, nil) c2() if err == nil { log.Fatal("expected NO reply for \"ignore\"") } fmt.Printf("no reply for %q within %s — query timed out (expected)\n", "ignore", demoTimeout) } func sendQuery(ctx context.Context, snd *amqp.Sender, replyNode, body, corr string) { req := amqp.NewMessage([]byte(body)) req.Properties = &amqp.MessageProperties{ ReplyTo: &replyNode, // MUST name a node this connection owns (snooping guard) CorrelationID: corr, } if err := snd.Send(ctx, req, nil); err != nil { log.Fatalf("send query: %v", err) } } ``` ```python import os import threading from proton import Message from proton.utils import BlockingConnection CHANNEL = "amqp10.examples.queries" # A short per-request deadline so the "no reply" leg surfaces a timeout quickly. # The connector's own default RPC timeout is ~30s. DEMO_TIMEOUT = 5.0 def amqp_url() -> str: return os.environ.get("KUBEMQ_AMQP_URL", "amqp://localhost:5672") def run_responder(addr: str, ready: threading.Event, stop: threading.Event) -> None: # A query whose body is "ignore" gets NO reply (the requester times out). conn = BlockingConnection(amqp_url()) try: rcv = conn.create_receiver(addr, credit=10) snd = conn.create_sender(None) # anonymous reply sender (null target) ready.set() while not stop.is_set(): try: req = rcv.receive(timeout=1.0) except Exception: continue rcv.accept() if not req.reply_to: continue body = str(req.body) if body == "ignore": continue # send NOTHING — the requester times out # A QUERY reply carries ONLY the body + metadata — no executed/error props. reply = Message(body="result:" + body) reply.address = req.reply_to reply.correlation_id = req.correlation_id if req.correlation_id else req.id snd.send(reply) finally: conn.close() def run_requester(addr: str) -> None: conn = BlockingConnection(amqp_url()) try: # DYNAMIC reply node (see the Commands page for the shared mechanics). reply_rcv = conn.create_receiver(None, dynamic=True, credit=5) reply_node = reply_rcv.link.remote_source.address snd = conn.create_sender(addr) # 1. A SUCCESSFUL query: round-trips, body intact. send_query(snd, reply_node, "get-temp-sensor-3", "corr-qry-1") reply = reply_rcv.receive(timeout=DEMO_TIMEOUT) reply_rcv.accept() print(f"reply for 'get-temp-sensor-3': body={str(reply.body)!r}") # 2. A query the responder ignores: NOTHING is delivered → the requester # TIMES OUT. The absence of a reply IS the failure signal for queries. send_query(snd, reply_node, "ignore", "corr-qry-2") try: reply_rcv.receive(timeout=DEMO_TIMEOUT) except Exception: print(f"no reply for 'ignore' within {DEMO_TIMEOUT}s — query timed out (expected)") else: raise SystemExit("expected NO reply for 'ignore'") finally: conn.close() def send_query(snd, reply_node: str, body: str, corr: str) -> None: req = Message(body=body) req.reply_to = reply_node # MUST name a node this connection owns (snooping guard) req.correlation_id = corr snd.send(req) def main() -> None: addr = "queries/" + CHANNEL # queries/ prefix → KubeMQ Queries pattern ready, stop = threading.Event(), threading.Event() responder = threading.Thread(target=run_responder, args=(addr, ready, stop), daemon=True) responder.start() ready.wait(timeout=30.0) try: run_requester(addr) finally: stop.set() responder.join(timeout=10.0) if __name__ == "__main__": main() ``` The other languages (Java, C#, JavaScript, Rust) drive queries with the **same** code shape as [Commands](/connectors/amqp/concepts/commands) — the only changes are the `queries/` address prefix, dropping the `x-opt-kubemq-executed` / `x-opt-kubemq-error` application-properties on the reply, and treating a missing reply (timeout) as the failure signal. Adapt the Commands example for those languages. ## Related [#related] # Queues (/connectors/amqp/concepts/queues) Queues are durable, **competing-consumer** work queues over the AMQP 1.0 connector. Attach a link to a node whose address begins with `queues/` and the connector binds it to the KubeMQ **Queues** pattern. A message goes to **exactly one** consumer (it is *moved*, not copied), survives until it is settled, and is delivered **at-least-once** by default. ## Overview [#overview] The `queues/` prefix selects the Queues pattern. A producer attaches a **sender** to `queues/`; each `TRANSFER` becomes a KubeMQ `SendQueueMessage`. A consumer attaches a **receiver** and grants credit; the server runs a credit-driven `Get` long-poll and frames each returned message as a `TRANSFER`. Do the work, then `accept` — the connector emits an `AckRange` and removes the message from the queue. Many consumers can attach to the same `queues/`: the broker hands each message to one of them (competing-consumer move semantics — not fan-out). Add consumers to scale throughput; each message is still processed once. | Operation | AMQP action | KubeMQ mapping | | --------- | ------------------------------------------------------------------------------ | ------------------------------------- | | Produce | Attach a **sender** to `queues/`, `TRANSFER` (unsettled for at-least-once) | `SendQueueMessage` | | Consume | Attach a **receiver**, grant credit, `Receive` | Credit-driven `Get` long-poll | | Accept | `accepted` / `rejected` DISPOSITION | `AckRange` — message removed | | Requeue | `released` / `modified` DISPOSITION | `NAckRange` — redelivered to the tail | ## How it works [#how-it-works] A producer enqueues unsettled messages (each blocks for the server's `accepted` disposition); competing consumers grant credit, receive, do the work, and accept — the message is removed from the queue. *Each queued message is moved to exactly one competing consumer; an `accepted` disposition acks the message and removes it from the queue.* ## Send and receive [#send-and-receive] Each example produces 10 unsettled messages (every send blocks until the server returns an `accepted` disposition, confirming the broker stored it), then consumes and accepts each one, and finally confirms the queue is empty. Every client reads the broker endpoint from `KUBEMQ_AMQP_URL` (default `amqp://localhost:5672`). ```go package main import ( "context" "fmt" "log" "os" "time" amqp "github.com/Azure/go-amqp" ) const channel = "amqp10.examples.basic" const total = 10 func amqpURL() string { if v := os.Getenv("KUBEMQ_AMQP_URL"); v != "" { return v } return "amqp://localhost:5672" } func main() { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() addr := "queues/" + channel // queues/ prefix → KubeMQ Queues pattern conn, err := amqp.Dial(ctx, amqpURL(), nil) if err != nil { log.Fatalf("dial: %v", err) } defer func() { _ = conn.Close() }() session, _ := conn.NewSession(ctx, nil) // 1. Produce — each Send is unsettled and blocks for the accepted DISPOSITION. sender, _ := session.NewSender(ctx, addr, nil) for i := 0; i < total; i++ { if err := sender.Send(ctx, amqp.NewMessage([]byte(fmt.Sprintf("msg-%03d", i))), nil); err != nil { log.Fatalf("send: %v", err) } } _ = sender.Close(ctx) // 2. Consume — grant credit, Receive, AcceptMessage (⇒ AckRange, removed). receiver, _ := session.NewReceiver(ctx, addr, &amqp.ReceiverOptions{Credit: 10}) seen := make(map[string]struct{}, total) for len(seen) < total { msg, err := receiver.Receive(ctx, nil) if err != nil { log.Fatalf("receive: %v", err) } if err := receiver.AcceptMessage(ctx, msg); err != nil { log.Fatalf("accept: %v", err) } seen[string(msg.GetData())] = struct{}{} } fmt.Printf("consumed and accepted %d messages\n", len(seen)) // 3. Assert the queue is empty — a further Receive must time out. emptyCtx, emptyCancel := context.WithTimeout(ctx, 2*time.Second) if _, err := receiver.Receive(emptyCtx, nil); err == nil { log.Fatal("expected an empty queue") } emptyCancel() _ = receiver.Close(ctx) } ``` ```python import os from proton import Message from proton.utils import BlockingConnection CHANNEL = "amqp10.examples.basic" TOTAL = 10 def amqp_url() -> str: return os.environ.get("KUBEMQ_AMQP_URL", "amqp://localhost:5672") def main() -> None: addr = "queues/" + CHANNEL # queues/ prefix → KubeMQ Queues pattern conn = BlockingConnection(amqp_url()) try: # 1. Produce — each send is unsettled and blocks for the accepted DISPOSITION. sender = conn.create_sender(addr) for i in range(TOTAL): sender.send(Message(body=f"msg-{i:03d}")) sender.close() # 2. Consume — grant credit, receive, accept (⇒ AckRange, removed). receiver = conn.create_receiver(addr, credit=10) seen: set[str] = set() while len(seen) < TOTAL: msg = receiver.receive(timeout=30.0) receiver.accept() seen.add(str(msg.body)) print(f"consumed and accepted {len(seen)} messages") # 3. Assert the queue is empty — a further receive must time out. try: receiver.receive(timeout=2.0) except Exception: pass # the EXPECTED idle timeout on an empty queue else: raise SystemExit("expected an empty queue") receiver.close() finally: conn.close() if __name__ == "__main__": main() ``` ```java import java.util.HashSet; import java.util.Set; import javax.jms.Connection; import javax.jms.Message; import javax.jms.MessageConsumer; import javax.jms.MessageProducer; import javax.jms.Queue; import javax.jms.Session; import javax.jms.TextMessage; import org.apache.qpid.jms.JmsConnectionFactory; public final class Main { private static final String CHANNEL = "amqp10.examples.basic"; private static final int TOTAL = 10; public static void main(String[] args) throws Exception { String url = System.getenv().getOrDefault("KUBEMQ_AMQP_URL", "amqp://localhost:5672"); String address = "queues/" + CHANNEL; // queues/ prefix → KubeMQ Queues pattern JmsConnectionFactory factory = new JmsConnectionFactory(url); try (Connection connection = factory.createConnection()) { connection.start(); try (Session session = connection.createSession(false, Session.CLIENT_ACKNOWLEDGE)) { Queue queue = session.createQueue(address); // 1. Produce — a MessageProducer is a server-receiver link; each send // blocks for the accepted DISPOSITION (unsettled, at-least-once). try (MessageProducer producer = session.createProducer(queue)) { for (int i = 0; i < TOTAL; i++) { producer.send(session.createTextMessage(String.format("msg-%03d", i))); } } // 2. Consume — a CLIENT_ACKNOWLEDGE consumer; acknowledge() settles // `accepted` ⇒ AckRange (removed from the queue). try (MessageConsumer consumer = session.createConsumer(queue)) { Set seen = new HashSet<>(); while (seen.size() < TOTAL) { Message msg = consumer.receive(30_000); if (msg == null) throw new IllegalStateException("timed out"); String body = msg.getBody(String.class); msg.acknowledge(); seen.add(body); } System.out.printf("consumed and accepted %d messages%n", seen.size()); // 3. Assert the queue is empty — a further receive times out. if (consumer.receive(2_000) != null) { throw new IllegalStateException("expected an empty queue"); } } } } } } ``` ```csharp using System.Text; using Amqp; using Amqp.Framing; const string channel = "amqp10.examples.basic"; const int total = 10; static string AmqpUrl() => Environment.GetEnvironmentVariable("KUBEMQ_AMQP_URL") is { Length: > 0 } v ? v : "amqp://localhost:5672"; var addr = "queues/" + channel; // queues/ prefix → KubeMQ Queues pattern var connection = await Connection.Factory.CreateAsync(new Address(AmqpUrl())); try { var session = new Session(connection); // 1. Produce — each Send is unsettled and blocks for the accepted DISPOSITION. var sender = new SenderLink(session, "basic-sender", addr); for (var i = 0; i < total; i++) { var message = new Message { BodySection = new Data { Binary = Encoding.UTF8.GetBytes($"msg-{i:D3}") } }; sender.Send(message, TimeSpan.FromSeconds(15)); } // 2. Consume — grant credit, Receive, Accept (⇒ AckRange, removed). // Keep the sender open through the consume phase (see the README gotcha). var receiver = new ReceiverLink(session, "basic-receiver", addr); receiver.SetCredit(10, autoRestore: true); var seen = new HashSet(); while (seen.Count < total) { var message = receiver.Receive(TimeSpan.FromSeconds(30)) ?? throw new InvalidOperationException("receive timed out"); receiver.Accept(message); seen.Add(BodyString(message)); } Console.WriteLine($"consumed and accepted {seen.Count} messages"); // 3. Assert the queue is empty — a further Receive must time out (null). if (receiver.Receive(TimeSpan.FromSeconds(2)) is not null) throw new InvalidOperationException("expected an empty queue"); await receiver.CloseAsync(); await sender.CloseAsync(); await session.CloseAsync(); } finally { await connection.CloseAsync(); } static string BodyString(Message message) => message.BodySection switch { Data d => Encoding.UTF8.GetString(d.Binary), AmqpValue { Value: byte[] bytes } => Encoding.UTF8.GetString(bytes), AmqpValue { Value: string str } => str, AmqpValue v => v.Value?.ToString() ?? string.Empty, _ => string.Empty, }; ``` ```typescript import { Connection, ReceiverEvents, type EventContext, type Receiver, } from "rhea-promise"; const channel = "amqp10.examples.basic"; const total = 10; function bodyToString(body: unknown): string { return Buffer.isBuffer(body) ? body.toString("utf8") : String(body); } async function main(): Promise { const url = new URL(process.env["KUBEMQ_AMQP_URL"] ?? "amqp://localhost:5672"); const address = `queues/${channel}`; // queues/ prefix → KubeMQ Queues pattern const connection = new Connection({ host: url.hostname, port: url.port ? Number(url.port) : 5672, container_id: `kubemq-amqp10-js-basic-${process.pid}`, reconnect: false, }); await connection.open(); try { // 1. Produce — an AwaitableSender; each send() resolves on the accepted DISPOSITION. const sender = await connection.createAwaitableSender({ target: { address } }); for (let i = 0; i < total; i++) { await sender.send({ body: `msg-${String(i).padStart(3, "0")}` }, { timeoutInSeconds: 15 }); } await sender.close(); // 2. Consume — grant credit manually, settle manually. Register the handler // BEFORE addCredit so early deliveries are not missed. const receiver = await connection.createReceiver({ source: { address }, credit_window: 0, autoaccept: false, autosettle: false, }); const seen = new Set(); await receiveUntil(receiver, (ctx) => { ctx.delivery?.accept(); // accept ⇒ AckRange ⇒ removed from the queue seen.add(bodyToString(ctx.message?.body)); return seen.size >= total; }, total + 1, 30_000); console.log(`consumed and accepted ${seen.size} messages`); await receiver.close(); } finally { await connection.close(); } } function receiveUntil( receiver: Receiver, onMessage: (ctx: EventContext) => boolean, credit: number, timeoutMs: number, ): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { receiver.removeListener(ReceiverEvents.message, handler); reject(new Error("timed out waiting for messages")); }, timeoutMs); const handler = (ctx: EventContext): void => { if (onMessage(ctx)) { clearTimeout(timer); receiver.removeListener(ReceiverEvents.message, handler); resolve(); } }; receiver.on(ReceiverEvents.message, handler); receiver.addCredit(credit); }); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```rust use std::collections::HashSet; use std::time::Duration; use fe2o3_amqp::link::delivery::Delivery; use fe2o3_amqp::link::receiver::CreditMode; use fe2o3_amqp::{Connection, Receiver, Sender, Session}; use fe2o3_amqp_types::definitions::SenderSettleMode; use fe2o3_amqp_types::messaging::{Body, Message}; use fe2o3_amqp_types::primitives::Value; const CHANNEL: &str = "amqp10.examples.basic"; const TOTAL: usize = 10; fn amqp_url() -> String { std::env::var("KUBEMQ_AMQP_URL").unwrap_or_else(|_| "amqp://localhost:5672".to_string()) } fn body_bytes(msg: &Message>) -> Vec { match &msg.body { Body::Data(batch) => batch.iter().flat_map(|d| d.0.to_vec()).collect(), Body::Value(v) => match &v.0 { Value::Binary(b) => b.to_vec(), Value::String(s) => s.clone().into_bytes(), other => format!("{other:?}").into_bytes(), }, _ => Vec::new(), } } #[tokio::main] async fn main() -> Result<(), Box> { let addr = format!("queues/{CHANNEL}"); // queues/ prefix → KubeMQ Queues pattern let mut connection = Connection::open("amqp10-examples-basic", amqp_url().as_str()).await?; let mut session = Session::begin(&mut connection).await?; // 1. Produce — pin SenderSettleMode::Unsettled (the connector rejects the AMQP // default `mixed`); each send is at-least-once and blocks for the Accepted outcome. let mut sender = Sender::builder() .name("basic-send-receive-sender") .target(addr.as_str()) .sender_settle_mode(SenderSettleMode::Unsettled) .attach(&mut session) .await?; for i in 0..TOTAL { let outcome = sender.send(format!("msg-{i:03}")).await?; if !outcome.is_accepted() { return Err(format!("send msg-{i:03}: unexpected outcome {outcome:?}").into()); } } sender.close().await?; // 2. Consume — CreditMode::Auto(10) issues 10 credits and replenishes on settle; // accept ⇒ AckRange ⇒ removed from the queue. let mut receiver = Receiver::builder() .name("basic-send-receive-receiver") .source(addr.as_str()) .credit_mode(CreditMode::Auto(10)) .attach(&mut session) .await?; let mut seen: HashSet = HashSet::with_capacity(TOTAL); while seen.len() < TOTAL { let delivery: Delivery> = receiver.recv().await?; let body = String::from_utf8_lossy(&body_bytes(delivery.message())).into_owned(); receiver.accept(&delivery).await?; seen.insert(body); } println!("consumed and accepted {} messages", seen.len()); // 3. Assert the queue is empty — a further recv must time out. if let Ok(Ok(_)) = tokio::time::timeout(Duration::from_secs(2), receiver.recv::>()).await { return Err("expected an empty queue".into()); } receiver.close().await?; session.end().await?; connection.close().await?; Ok(()) } ``` ## Delivery guarantees [#delivery-guarantees] Settlement mode is negotiated at `ATTACH` and decides the produce/consume guarantee: | Mode | How | Guarantee | Use when | | ------------------------------ | ---------------------------------------------------------- | -------------------------------------------------------------------- | ---------------------------- | | **At-least-once (default)** | unsettled deliveries; you `accept` after the work succeeds | survives a crash; may be **redelivered** | the work must not be lost | | **Pre-settled (at-most-once)** | `snd-settle-mode=settled` on the sender / consume | fast, no DISPOSITION round-trip; a failed publish/consume is dropped | loss is acceptable for speed | At-least-once means a consumer crash before `accept` requeues the message, so workers **must be idempotent** — a message can arrive more than once. The receiver settle mode is always replied as `first`; requesting `second` returns `DETACH(amqp:not-implemented)`. ### Delivery-state outcome mapping [#delivery-state-outcome-mapping] When you consume, you settle each delivery by sending a `DISPOSITION` with a delivery state. The connector maps that state onto the KubeMQ queue Ack/NAck machinery: | Your DISPOSITION | Client call (typical) | KubeMQ action | Effect | | ------------------------------- | ---------------------- | ------------- | ----------------------------------------------------------------------------- | | `accepted` | `AcceptMessage` | **AckRange** | message removed | | `rejected` | `RejectMessage` | **AckRange** | discarded; poison handled by the broker's `MaxReceiveQueue` policy | | `released` | `ReleaseMessage` | **NAckRange** | redelivered to the tail; `delivery-count` grows; **increments receive-count** | | `modified{...}` | `ModifyMessage` | **NAckRange** | requeued to the tail | | nil state (settled, no outcome) | settle without a state | **AckRange** | treated as success | | unknown terminal state | — | **NAckRange** | conservatively requeued — never silently dropped | A redelivered message carries `header.delivery-count = ReceiveCount − 1` and `first-acquirer = (ReceiveCount == 1)` — use these to detect and de-duplicate redeliveries. On detach, connection close, or shutdown, **every unsettled delivery is NAcked exactly once** (returned to the queue tail), so a disconnecting worker loses nothing — a fresh consumer recovers the in-flight work. **`released` / `modified` increment the receive-count.** Every release/modify for redelivery bumps `ReceiveCount` toward the broker's `MaxReceiveQueue` cap. A message you keep NAcking eventually hits that cap and is removed **even though you never `rejected` it** — there is no requeue-without-increment. To genuinely discard, `reject`; to retry, understand the count climbs. ### Body sections [#body-sections] A message body must be `Data` (binary — the default; multiple `Data` sections concatenate) or `AmqpValue` (a typed value). An empty body is valid. An `AmqpSequence` body is **rejected** (`rejected` DISPOSITION then `DETACH(amqp:not-implemented)`). ## What queues do not have [#what-queues-do-not-have] The Queues pattern is deliberately minimal — none of these exist: * **No peek / browse / visibility-timeout.** Receive is destructive credit-based consume only; there is no "look without taking". * **No connector dead-letter exchange.** A `rejected` message is discarded; poison handling is the broker-side `MaxReceiveQueue` policy, not a per-link DLX. * **No `copy` distribution-mode.** Requesting `copy` on a `queues/` link returns `DETACH(amqp:invalid-field)` — queues are move-only. * **No selectors.** A selector filter on a `queues/` link returns `amqp:not-implemented` (selectors are pub/sub only). * **No transactions.** Reliability comes from settlement, not `SESSION_TRANSACTED`. ## Related [#related] # Address Mapping (/connectors/amqp/reference/address-mapping) This is the master reference for how the embedded KubeMQ AMQP 1.0 connector maps an AMQP 1.0 terminus **address** to a KubeMQ **(pattern, channel)** pair — which server link role results from a peer's source/target choice, and the channel-naming rules, anonymous-routing behavior, and RPC reply token that each pattern carries. **One rule above all — use the explicit prefix.** Always address a node with its full `/` form (`queues/orders`, `events/telemetry`, `events-store/audit`). The bare-address / `DefaultPattern` fallback exists, but explicit prefixes are unambiguous and portable. See [Addressing](/connectors/amqp/concepts/addressing). ## Address grammar [#address-grammar] ```text address = [ "/" ] ( prefixed-node | bare-node ) prefixed-node = pattern-prefix channel pattern-prefix = "queues/" | "events/" | "events-store/" | "commands/" | "queries/" | "responses/" bare-node = channel ; no "/" — resolved via node-cap hint or DefaultPattern channel = 1*255 VCHAR ; connector charset rules (see Channel validation) ``` * The **leading slash is optional**. The resolver strips at most one leading `/` before matching, so `queues/orders` and `/queues/orders` resolve identically. * Each pattern prefix **includes its trailing slash** (`"events/"`, `"events-store/"`), so the prefix match is unambiguous. * A null/empty address is **not** an error in itself — on a server-receiver link it selects the **anonymous terminus** (see below); on a dynamic terminus it asks the server to **mint a node**. ## The master mapping table [#the-master-mapping-table] The pattern is taken from the address prefix. The **server link role** is the inverse of the peer's role: a peer **receiver** attaches against a **source** address (the server *sends*), and a peer **sender** attaches against a **target** address (the server *receives*). | Pattern | Send to (peer → server, **target**) | Receive from (server → peer, **source**) | Server link role | Settlement & credit | Filters / link-props | | ------------------------------- | -------------------------------------------------------- | ---------------------------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | **Queues** | `queues/` | `queues/` | produce = receiver; consume = sender | at-least-once (unsettled) **or** at-most-once (pre-settled `snd-settle-mode=settled` + `rcv-settle-mode=first`); consumer grants link credit; `accept`/`release`/`modify`/`reject` dispositions; **`copy` distribution-mode rejected → `amqp:invalid-field`** (queues are move-only) | **no** selector (selector on a queue → `amqp:not-implemented`); no link-props | | **Events** | `events/` | `events/` | produce = receiver; consume = sender | at-most-once fan-out; **continuous credit required** — a transfer with 0 link credit is **silently dropped** | consume link may carry a **selector** (`apache.org:selector-filter:string`) and/or `x-opt-kubemq-group` (consumer group) | | **Events Store** | `events-store/` | `events-store/` | produce = receiver; consume = sender | durable replay; durable identity from a **stable container-id + link name**; a second live attach of the same identity → `amqp:not-allowed` | consume link may carry a **selector**, **`x-opt-kubemq-start`** (start position), and `x-opt-kubemq-group` | | **Commands** (RPC) | `commands/` | — (requester reads its **dynamic reply node**) | request = receiver | native RPC; reply carries body + `x-opt-kubemq-executed` / `x-opt-kubemq-error`; failure → `executed=false` reply | `reply-to` + `correlation-id` on the request message | | **Queries** (RPC) | `queries/` | — (requester reads its **dynamic reply node**) | request = receiver | native RPC; reply carries **body + metadata only** (no executed/error); failure → no reply (requester times out) | `reply-to` + `correlation-id` on the request message | | **Responses** (RPC reply token) | `responses/` (peer **sender** writes a reply) | — | receiver only | connection-scoped reply token; **write-only** | none | Notes carried by the table: * The same `/` resolves to the **same KubeMQ channel** regardless of link direction — only the server role (and therefore the authorization check) differs. * `commands/`, `queries/`, and `responses/` are RPC machinery. A requester's *reply* link is a **dynamic node**, not a `/` address (see below). * `responses/` is valid **only** as a server-receiver link (the peer is a sender writing the reply). A **receiver** attach against `responses/` is rejected with `amqp:not-allowed`. ## Longest-prefix discipline [#longest-prefix-discipline] `events-store/` and `events/` share a common stem. The resolver matches `events-store/` **before** `events/`, so `events-store/audit` is never mis-classified as the `events` pattern with channel `store/audit`. The prefix checks run in this fixed order: ```text events-store/ — checked FIRST queues/ events/ commands/ queries/ responses/ ``` A bare address that still contains a `/` but matches **no** recognized prefix is an **unknown prefix → `amqp:not-found`** — a bare KubeMQ channel may use `.` segments but never the reserved `/` separator. ## Special rows — bare, dynamic, anonymous, responses [#special-rows--bare-dynamic-anonymous-responses] | Row | What the client does | How the connector resolves it | | ---------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Bare address** | attaches to a channel with no `pattern/` prefix (e.g. `orders`) | a JMS **node-capability** hint on the terminus selects the pattern: `queue` → `queues`, `topic` → `events`; otherwise the configured `DefaultPattern` applies (degrading to `queues` if misconfigured) | | **Dynamic terminus** | attaches with `source.dynamic=true` (receiver) or `target.dynamic=true` (sender) and an **empty address** | the server mints a transient node `_amqp10.tmp..`, echoes it in the reply ATTACH, and backs it with an in-memory node-local mailbox; **no broker channel, no §2 resolution, no attach-time authz** (the node is connection-private by its unguessable address) | | **Anonymous terminus** | a **server-receiver** link with a **null target address**; routes per-message by `properties.to` | the link binds to no fixed channel; each transfer's `to` is resolved with the **full mapping table** (including `responses/` tokens and dynamic nodes) and authorized **per message** for Write. A missing/invalid `to` or an unreachable node → `amqp:precondition-failed`; an authz denial → `amqp:unauthorized-access` | | **Responses token** | a peer **sender** writes a reply to `responses/` | resolves to `(responses, RequestID)`; the RequestID is opaque (validated by the RPC layer against the pending-reply map, not as a broker channel — only emptiness is rejected). A **receiver** attach → `amqp:not-allowed` | **Anonymous terminus is null-target-driven, not capability-driven.** The connector advertises **no `ANONYMOUS-RELAY` capability** (see [Capabilities](/connectors/amqp/reference/capabilities)). A client gets anonymous routing by attaching a sender link with a **null target**, never by negotiating a capability. **Qpid JMS cannot drive the anonymous terminus** — it gates the single anonymous producer link on the peer's `ANONYMOUS-RELAY` capability, finds none, and falls back to per-destination sender links. Native clients (`Azure/go-amqp`, AMQPNetLite, qpid-proton, fe2o3-amqp, rhea) can open a null-target sender directly. ## Server role and authorization [#server-role-and-authorization] The peer's link role and the chosen terminus side together fix the server role and the permission required at attach: | Peer attaches as | Terminus side read | Server role | Permission enforced at attach | | -------------------------------------- | ------------------ | --------------- | ----------------------------------------------------- | | **receiver** (peer reads) | `source` | server-sender | **Read** on `(pattern, channel)` | | **sender** (peer writes), fixed target | `target` | server-receiver | **Write** on `(pattern, channel)` | | **sender**, null target (anonymous) | — | server-receiver | deferred — **per-message Write** on the resolved `to` | | **sender** to `responses/` | `target` | server-receiver | **none** (connection-scoped reply token) | The pattern → authorization resource map treats `events-store` as the resource `events_store`, and `commands` / `queries` as `commands` / `queries`. See [Authentication](/connectors/amqp/how-to/authentication). ## Channel validation (connector charset) [#channel-validation-connector-charset] The channel component (everything after the pattern prefix) is validated by a connector charset rule that is **stricter** than the array-layer validation, so a channel that passes here always passes downstream. Any violation maps to **`amqp:not-found`** (a bad address is "not found"): | Rule | Rejected example | Reason | | ---------------------- | ------------------ | --------------------------------------------- | | not empty | `queues/` | empty channel | | ≤ 255 bytes | 300-char channel | channel exceeds 255 chars | | no trailing `.` | `queues/orders.` | channel has trailing `.` | | no whitespace | `queues/my orders` | channel contains whitespace (space/tab/CR/LF) | | no `*` or `>` wildcard | `queues/orders.*` | channel contains wildcard | | no `;` or `:` | `queues/a:b` | channel contains `;` or `:` | **The channel charset is stricter than you may expect.** `*`, `>`, `;`, `:`, whitespace, and a trailing `.` are all rejected at attach with `amqp:not-found`. Use `.` only as a path separator inside the channel (e.g. `queues/region.eu.orders`). There is **no vhost / virtual host**. The OPEN `hostname` field is accepted but ignored — the address space is flat and global. For AMQP 0-9-1 interop the channel convention is `queues/amqp..` (a naming convention, not a real vhost). ## Quick examples [#quick-examples] | AMQP address, attached as | Peer role | Resolves to | Server role | | --------------------------------- | --------- | -------------------------------------------- | ------------------------ | | `queues/orders` | sender | `(queues, orders)` | receiver (Write) | | `queues/orders` | receiver | `(queues, orders)` | sender (Read) | | `/events/telemetry` | receiver | `(events, telemetry)` | sender (Read) | | `events-store/audit` | receiver | `(events-store, audit)` | sender (Read) | | `commands/dispatch` | sender | `(commands, dispatch)` | receiver (Write) | | `responses/abc123` | sender | `(responses, abc123)` | receiver (no authz) | | `responses/abc123` | receiver | **DETACH** `amqp:not-allowed` | — | | `orders` (bare, node-cap `queue`) | sender | `(queues, orders)` | receiver (Write) | | null target | sender | anonymous terminus, routes by `to` | receiver (per-msg Write) | | `source.dynamic=true`, empty addr | receiver | minted `_amqp10.tmp..` | sender (no authz) | | `events/x;y` | receiver | **DETACH** `amqp:not-found` (`;` in channel) | — | ## Related [#related] # Capabilities (/connectors/amqp/reference/capabilities) This reference defines exactly what the embedded KubeMQ AMQP 1.0 connector **supports**, what it **rejects**, and — critically — what it **does not advertise**. Use it to decide which client features are safe to rely on and which ones will be refused at attach, transfer, or connection time. ## The connector advertises no capabilities [#the-connector-advertises-no-capabilities] **The connector advertises no offered or desired connection capabilities and no offered or desired link/terminus capabilities.** There is **no `ANONYMOUS-RELAY`**, no `queue`/`topic` node capability, no `DELAYED_DELIVERY`, no `SHARED-SUBS`, no `sole-connection-for-container` — nothing. Clients **must not** depend on AMQP capability negotiation with this connector. The connector's reply OPEN performative sets **only four fields** — `ContainerID`, `MaxFrameSize`, `ChannelMax`, and `IdleTimeout`: ```go serverOpen := &frames.PerformOpen{ ContainerID: "KubeMQ", MaxFrameSize: c.maxFrameSize, ChannelMax: c.channelMax, IdleTimeout: advertisedIdle, } ``` `OfferedCapabilities` and `DesiredCapabilities` are **never set** on the OPEN reply, and the attach-reply path likewise sets no terminus capabilities. The connector *reads* a peer's terminus capabilities only to honor a bare-address `queue`/`topic` hint — it never echoes or offers capabilities back. ### What this means in practice [#what-this-means-in-practice] * **Anonymous senders work, but not via `ANONYMOUS-RELAY`.** A client gets anonymous routing by attaching a sender link with a **null target address**; the connector then routes each transfer by its `properties.to`. It is **null-target-driven**, not capability-driven. * **This is the root cause of the Java / Qpid JMS anonymous-terminus limitation.** Qpid JMS decides whether to use a single anonymous producer link by checking the peer's offered `ANONYMOUS-RELAY` capability. Because the connector offers none, Qpid JMS falls back to **per-destination sender links** and exposes no API to force the raw null-target ATTACH the connector routes on. Native clients (`Azure/go-amqp`, AMQPNetLite, qpid-proton, fe2o3-amqp, rhea) can open a null-target sender directly and use anonymous routing. See [Address Mapping](/connectors/amqp/reference/address-mapping). * Do **not** branch your client logic on a negotiated capability — there will be none. Drive behavior from the address (`/`) and link-properties instead. ## Supported features [#supported-features] | Feature | Support | Notes | | ---------------------------------------------------------------- | ------- | -------------------------------------------------------------------------------------------------- | | Message body `Data` (binary) | Yes | the primary body section | | Message body `AmqpValue` | Yes | string / map / list / scalar values | | Empty body | Yes | accepted | | Settlement `accepted` / `released` / `modified` / `rejected` | Yes | mapped to KubeMQ Ack/NAck; `released`/`modified` requeue and **increment receive-count** | | Sender settle-mode `unsettled` (at-least-once) | Yes | the default; the queues durability path | | Sender settle-mode `settled` (pre-settled, at-most-once) | Yes | paired with `rcv-settle-mode=first` | | Receiver settle-mode `first` | Yes | the **only** supported receiver settle mode | | Link credit + drain (FLOW) | Yes | manual or windowed credit; drain to exhaust-then-stop | | Multi-frame transfers (fragmentation/reassembly) | Yes | `More:true…More:false`; bit-exact reassembly up to the 100 MiB cap | | Dynamic nodes (`dynamic=true`, null address) | Yes | minted `_amqp10.tmp..`; node-local, no TTL/persistence | | Anonymous terminus (null target, route by `to`) | Yes | per-message Write authz; **not** `ANONYMOUS-RELAY` | | JMS/SQL-92 selector on `events/` & `events-store/` consume links | Yes | `apache.org:selector-filter:string` (3-valued logic) | | Consumer groups (`x-opt-kubemq-group`) | Yes | link property on the consume link | | Durable subscriptions (events-store, expiry `never`) | Yes | durable identity = stable container-id + link name; **node-local** | | Start positions (`x-opt-kubemq-start`) | Yes | `first` / `new-only` / `last` / `sequence:` / `time:` / `time-delta:` | | Native RPC (commands / queries) | Yes | dynamic reply node + `reply-to` + `correlation-id`; **no gRPC, no KubeMQ SDK** | | SASL `ANONYMOUS` / `PLAIN` (JWT) / `EXTERNAL` (mTLS) | Yes | see [Authentication](/connectors/amqp/how-to/authentication) | | TLS / mTLS on `amqps://:5671` | Yes | see [TLS & mTLS](/connectors/amqp/how-to/tls-and-mtls) | ## Rejected / unsupported features [#rejected--unsupported-features] These are **non-goals** — they are refused deterministically, not silently ignored. Each maps to a wire condition from the [error conditions](/connectors/amqp/reference/error-conditions). | Feature | Behavior | Condition / mechanism | | ---------------------------------------------------------- | ---------------- | -------------------------------------------------------- | | `rcv-settle-mode=second` | DETACH at attach | `amqp:not-implemented` | | Selector on a `queues/` link | DETACH at attach | `amqp:not-implemented` (queues are move-only) | | `copy` distribution-mode on `queues/` | DETACH at attach | `amqp:invalid-field` | | `AmqpSequence` body section | rejected | `errAMQPSequenceUnsupported` | | AMQP **transactions** (txn coordinator) | not implemented | no coordinator node in the connector | | **Exactly-once** delivery | not provided | at-least-once (queues) or at-most-once (events) only | | **Link resumption** / delivery-state recovery on re-attach | not supported | re-attach is a fresh link | | Message **peek / browse / FIFO ordering guarantee** | not provided | competing-consumer move semantics only | | **Dead-letter exchange (DLX)** | none | the broker's `MaxReceiveQueue` caps backlog; no DLX | | Second live attach of the same durable identity | DETACH | `amqp:not-allowed` (durable subscription in use) | | Receiver attach on `responses/` | DETACH | `amqp:not-allowed` | | AMQP-over-**WebSocket** | not supported | raw TCP / TLS only | | **vhost** / virtual host | none | OPEN `hostname` accepted but ignored; flat address space | | Config **hot-reload** | not supported | connector config is read at start | | Capability negotiation (offered/desired) | none advertised | drive behavior from address + link-props | The **`AmqpSequence` body section is rejected.** Send `Data` (binary / `BytesMessage`) or `AmqpValue` (text / object) instead — an `AmqpSequence` body fails translation. This is one of two Qpid-JMS-relevant limitations, alongside the anonymous-terminus restriction in [Address Mapping](/connectors/amqp/reference/address-mapping). ## Inert / accepted-but-ignored [#inert--accepted-but-ignored] These are accepted on the wire without error but carry **no connector semantics** (documented so you do not expect behavior that is not there): * **Message priority** — accepted, not honored for ordering. * **`group-id` / `group-sequence`** — accepted, not used for grouping/ordering. * **`footer` section** — accepted, passed through where applicable, not interpreted. * **OPEN `hostname`** — accepted, ignored (no vhost). * **Idle-timeout from the client** — honored by the connector emitting empty frames at half the client's advertised interval, but it is not a feature you negotiate via capabilities. ## Limits & forced caps [#limits--forced-caps] | Limit | Value | Enforcement / condition | | -------------------------------- | -------------------------- | -------------------------------------------- | | Max channel length | 255 bytes | charset validation → `amqp:not-found` | | Max multi-frame message size | 100 MiB | oversize → `amqp:link:message-size-exceeded` | | Receiver settle modes | `first` only | `second` → `amqp:not-implemented` | | Connection / session / link caps | from config | over-cap → `amqp:resource-limit-exceeded` | | Durable identity components | 40 chars each + FNV suffix | sanitized to a stable durable id | See [Configuration](/connectors/amqp/concepts/configuration) for the `CONNECTORS_AMQP10_*` knobs behind these caps, and [Connections & Observability](/connectors/amqp/reference/connections-endpoint) for the metrics that count limit breaches. ## Related [#related] # Configuration (/connectors/amqp/reference/configuration) Field-by-field reference for the 14 settings under `Connectors.Amqp10.*`, their validation rules, and the equivalent TOML, environment variable, and Docker forms. For the enable/disable flow and the `DefaultPattern` bare-address fallback, see [Configuration concepts](../concepts/configuration). ## Configuration fields [#configuration-fields] All fields live under `[Connectors.Amqp10]`. Defaults are taken verbatim from the server's `Amqp10Config` struct. ## Validation rules [#validation-rules] Validation is **skipped entirely when `Enable` is `false`** — a disabled connector is always valid regardless of the other fields. When the connector is enabled, the server rejects an invalid config at startup with these rules: | Field | Rule | | -------------------------- | -------------------------------------------------------------------------------------------------- | | `Port` / `TlsPort` | each in `0..65535`. If **both** are `0`, validation fails — at least one listener must be enabled. | | `MaxFrameSize` | `>= 512` | | `MaxMessageSize` | `> 0` | | `SessionMax` | `1..65535` | | `MaxLinksPerSession` | `>= 1` | | `MaxConnections` | `>= 0` (`0` = unlimited) | | `IdleTimeoutSeconds` | `>= 0` (`0` = disabled) | | `DefaultPattern` | exactly one of `queues`, `events`, `events-store`, `commands`, `queries` | | `GetBatchSize` | `1..1024` | | `MaxUnsettledPerLink` | `>= 1` | | `DefaultRpcTimeoutSeconds` | `>= 1` | | `RpcMaxPending` | `>= 1` | `Port` and `TlsPort` are **shared with the RabbitMQ (AMQP 0-9-1) connector**. Setting `Port` equal to the 0-9-1 port is intentionally accepted — the validation step does **not** cross-check the two, and the `amqpmux` dedupes the bind so both dialects coexist on one listener. See [Architecture](/connectors/amqp/concepts/architecture) for the dispatch detail. The TLS listener binds only when the server-global `Security` block is configured. ## Configuring the connector [#configuring-the-connector] The same settings can be supplied through a TOML config file, environment variables, or `docker run` flags. Each environment variable is derived from its dotted config key by snake-casing the field, stripping the `.` separators, and upper-casing — so `Connectors.Amqp10.TlsPort` becomes `CONNECTORS_AMQP10_TLS_PORT`. ```toml title="config.toml" [Connectors.Amqp10] Enable = true Port = 5672 TlsPort = 5671 MaxFrameSize = 131072 MaxMessageSize = 104857600 SessionMax = 256 MaxLinksPerSession = 256 MaxConnections = 1000 IdleTimeoutSeconds = 120 DefaultPattern = "queues" GetBatchSize = 32 MaxUnsettledPerLink = 1024 DefaultRpcTimeoutSeconds = 30 RpcMaxPending = 512 ``` ```bash title="amqp10.env" CONNECTORS_AMQP10_ENABLE=true CONNECTORS_AMQP10_PORT=5672 CONNECTORS_AMQP10_TLS_PORT=5671 CONNECTORS_AMQP10_MAX_FRAME_SIZE=131072 CONNECTORS_AMQP10_MAX_MESSAGE_SIZE=104857600 CONNECTORS_AMQP10_SESSION_MAX=256 CONNECTORS_AMQP10_MAX_LINKS_PER_SESSION=256 CONNECTORS_AMQP10_MAX_CONNECTIONS=1000 CONNECTORS_AMQP10_IDLE_TIMEOUT_SECONDS=120 CONNECTORS_AMQP10_DEFAULT_PATTERN=queues CONNECTORS_AMQP10_GET_BATCH_SIZE=32 CONNECTORS_AMQP10_MAX_UNSETTLED_PER_LINK=1024 CONNECTORS_AMQP10_DEFAULT_RPC_TIMEOUT_SECONDS=30 CONNECTORS_AMQP10_RPC_MAX_PENDING=512 ``` The Docker example includes `CONNECTORS_AMQP10_ENABLE=true` — without it the connector stays disabled and port 5672 is not bound. Set `CONNECTORS_AMQP10_ENABLE=false` only when you want to turn the connector off. ## Related [#related] # Connections & Observability (/connectors/amqp/reference/connections-endpoint) This reference documents the AMQP 1.0 connector's observability surface: the **HTTP detail endpoints**, the **11 Prometheus metric families**, the **SSE metrics group**, and the **audit events** the connector emits. ## HTTP detail endpoints [#http-detail-endpoints] The connector registers two read-only JSON routes on the internal API port (`8080`), network-protected like all `/api/*` routes. They are registered up front and nil-check the live provider, so they respond `200` with **empty lists** until the connector is wired (and even when AMQP 1.0 is disabled), returning `503` only while the API service is not yet ready. | Method & path | Returns | | ----------------------------- | --------------------------------------------------------- | | `GET /api/amqp10/connections` | `{ "connections": [Amqp10ConnectionDTO…], "total": }` | | `GET /api/amqp10/links` | `{ "links": [Amqp10LinkDTO…], "total": }` | ### `Amqp10ConnectionDTO` [#amqp10connectiondto] | JSON field | Type | Meaning | | -------------- | ------ | ------------------------------------------------- | | `client_id` | string | auth ClientID, or the container-id when anonymous | | `container_id` | string | OPEN container-id (sanitized) | | `product` | string | from OPEN properties, if the client sent them | | `version` | string | from OPEN properties, if sent | | `sessions` | int | live sessions on the connection | | `links` | int | live links across all sessions | | `connected_at` | string | RFC3339 timestamp | | `source_ip` | string | peer IP | | `sasl` | string | `plain` / `anonymous` / `external` / `none` | | `tls` | bool | TLS-terminated connection | ### `Amqp10LinkDTO` [#amqp10linkdto] | JSON field | Type | Meaning | | ----------- | ------ | ----------------------------------------------------------------- | | `client_id` | string | owning connection's ClientID | | `name` | string | peer-assigned link name | | `role` | string | `sender` / `receiver` (**server** perspective) | | `address` | string | resolved terminus address | | `pattern` | string | `queues` / `events` / `events-store` / `commands` / `queries` / … | | `channel` | string | resolved KubeMQ channel | | `credit` | int64 | current link credit (server view) | | `unsettled` | int64 | deliveries awaiting settlement | | `durable` | bool | durable subscription link | | `dynamic` | bool | dynamic-node terminus link | AMQP 1.0 has no exchange/binding topology, so there is **no topology endpoint** (unlike the AMQP 0-9-1 / RabbitMQ connector). The link list *is* the topology view. ## Prometheus metrics — 11 families [#prometheus-metrics--11-families] The connector exposes **11** `kubemq_amqp10_*` metric families: **3 gauges + 8 counters**. All are scraped from the standard KubeMQ metrics endpoint. ### Gauges (3) [#gauges-3] | Metric | Labels | Meaning | | --------------------------- | ---------------------------- | ------------------------------------- | | `kubemq_amqp10_connections` | — | current open connector connections | | `kubemq_amqp10_sessions` | — | current open connector sessions | | `kubemq_amqp10_links` | `role` (`sender`/`receiver`) | current attached links by server role | ### Counters (8) [#counters-8] | Metric | Labels | Meaning | | -------------------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------- | | `kubemq_amqp10_transfers_total` | `direction` (`in`/`out`), `pattern` | transfers by direction and KubeMQ pattern | | `kubemq_amqp10_dispositions_total` | `outcome` (`accepted`/`released`/`modified`/`rejected`) | terminal dispositions by outcome | | `kubemq_amqp10_rpc_requests_total` | `pattern` (`commands`/`queries`) | RPC requests routed through the connector | | `kubemq_amqp10_errors_total` | `scope` (`conn`/`session`/`link`) | connector error conditions by scope | | `kubemq_amqp10_transfers_in_dropped_total` | — | **inbound transfers dropped: oversize / no-consumer / pre-settled failure** | | `kubemq_amqp10_events_dropped_no_credit_total` | — | events dropped on an outbound link with **no credit** (fire-hose semantics) | | `kubemq_amqp10_events_store_dropped_stalled_total` | — | events-store messages dropped when the **credit-0 buffer stalled** (link DETACH) | | `kubemq_amqp10_rpc_late_responses_total` | — | RPC responses arriving **after the requester went away** | ### The two drop counters to watch [#the-two-drop-counters-to-watch] These are the connector's **data-loss footgun signals** — on a healthy producer/consumer they should stay at **0**: * **`kubemq_amqp10_events_dropped_no_credit_total`** — increments every time the connector has an `events/` message to deliver but the consumer link has **zero credit**. Events are at-most-once fire-hose: with no credit the message is **silently dropped**. Grant standing credit continuously. * **`kubemq_amqp10_events_store_dropped_stalled_total`** — increments when a durable events-store consumer stops granting credit and the credit-0 bounded buffer overflows, forcing a **DETACH with lost messages**. Replenish credit eagerly. **`kubemq_amqp10_transfers_in_dropped_total` covers three causes, not one.** Its help text is *"inbound AMQP 1.0 transfers dropped (oversize, no consumer, pre-settled failure)"*. It counts an **oversize** inbound transfer, a transfer with **no consumer**, **and** a **pre-settled routing failure** — do not read it as pre-settled-only. ## SSE metrics group [#sse-metrics-group] The live dashboard streams a snapshot of these metrics as an `api.Amqp10MetricsGroup` over the metrics SSE channel. The store mirrors the same gauges and counters as the Prometheus families above (the store clamps gauges at 0 and the Prometheus mirror follows the clamp, so the two never diverge). ## Audit events [#audit-events] **The AMQP 1.0 connector emits exactly two audit events: `auth.success` and `auth.failure`.** It does **not** emit `client.connected` or `client.disconnected` — those are **not** part of this connector's audit surface. Both come from the SASL layer: | Event | When | Fields | | -------------- | --------------- | ------------------------------------------------------------------------------------------------ | | `auth.success` | SASL outcome OK | `ClientID`, `Transport: "amqp10"`, `SourceIP`, `Metadata{mechanism}` | | `auth.failure` | SASL rejected | `ClientID`, `Transport: "amqp10"`, `SourceIP`, `Error` (sanitized reason), `Metadata{mechanism}` | The `mechanism` metadata is `plain` / `anonymous` / `external`, and `SourceIP` is the peer IP. The **full failure reason** lives only in the `auth.failure` audit record — the SASL **wire** outcome carries the code alone (the no-leak rule; see [Error Conditions](/connectors/amqp/reference/error-conditions)). To observe connection lifecycle (count, age, source IP, SASL mechanism), poll `GET /api/amqp10/connections` or scrape `kubemq_amqp10_connections` — **not** the audit log. ## Related [#related] # Error Conditions (/connectors/amqp/reference/error-conditions) The embedded KubeMQ AMQP 1.0 connector reports every failure with an **AMQP 1.0 symbolic error condition** carried on a `DETACH`, `END`, `CLOSE`, or rejected-disposition performative. There are exactly **13** of them, and the connector **never emits a condition outside this set** — it is a pinned, greppable, testable vocabulary. **AMQP 1.0 only — no numeric reason codes.** This connector is the AMQP **1.0** dialect. It does **not** use the numeric reason codes of the AMQP 0-9-1 / RabbitMQ connector. If you are migrating mental models from 0-9-1, replace "reply-code 312/404/406…" with the **`amqp:*` symbols** below. Numeric codes appear **nowhere** in this connector. ## The 13 conditions [#the-13-conditions] | # | Symbol | Meaning | Typical trigger | | -- | --------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1 | `amqp:internal-error` | unexpected server-side failure | a broker send error or other internal fault; the description carries the **sanitized broker message only** | | 2 | `amqp:not-found` | unknown address / bad channel at attach | unrecognized address prefix, or a channel that violates the connector charset (empty, `>255`, trailing `.`, whitespace, `*`/`>`, `;`/`:`) — see [Address Mapping](/connectors/amqp/reference/address-mapping) | | 3 | `amqp:unauthorized-access` | authorization denial | attach denied (Read on consume, Write on produce), or a per-message anonymous-terminus Write denial | | 4 | `amqp:decode-error` | malformed frame / codec failure | a corrupt or invalid AMQP frame on the wire | | 5 | `amqp:resource-limit-exceeded` | capacity breach | connection / session / link / RPC cap reached, **idle timeout**, or events-store **stalled-credit** buffer overflow | | 6 | `amqp:not-allowed` | protocol / FSM violation | duplicate link name, receiver attach on `responses/`, **duplicate durable subscription identity**, broker-not-ready reject, link re-attach | | 7 | `amqp:invalid-field` | invalid link property | a malformed **`x-opt-kubemq-start`** start position, an unparseable **selector**, or `copy` distribution-mode on a queue | | 8 | `amqp:not-implemented` | well-formed but unsupported request | a **selector on a `queues/` link**, or **`rcv-settle-mode=second`** | | 9 | `amqp:precondition-failed` | missing/invalid anonymous-terminus `to` | an anonymous sender transfer with no `to`, an unknown prefix in `to`, or a dynamic node the connection cannot reach | | 10 | `amqp:link:message-size-exceeded` | oversize multi-frame transfer (link scope) | a message body over the **100 MiB** reassembly cap | | 11 | `amqp:session:window-violation` | session incoming/outgoing window breach | the peer sent more transfers than the advertised incoming-window allowed | | 12 | `amqp:session:errant-link` | unattached-handle / handle-in-use session error | a TRANSFER/DISPOSITION on an unknown handle, or an ATTACH reusing a live handle | | 13 | `amqp:connection:forced` | server-initiated CLOSE | graceful shutdown or broker-down — the connection is forced closed | ## Scopes [#scopes] The condition prefix tells you the AMQP scope on which it is delivered: * `amqp:*` — **link or message** scope (delivered on `DETACH` or a rejected disposition): conditions 1–10. * `amqp:session:*` — **session** scope (delivered on `END`): conditions 11–12. * `amqp:connection:*` — **connection** scope (delivered on `CLOSE`): condition 13. ## Message sanitization (the no-leak rule) [#message-sanitization-the-no-leak-rule] Every wire `description` is **sanitized to at most 512 characters** and carries **only a broker error message** — never a file path, internal channel, stack trace, or policy internal. This matches the gRPC connector's sanitization. A SASL auth failure takes this further: the **wire** SASL outcome conveys only the failure code, while the full reason is kept in the **audit record** (see [Connections & Observability](/connectors/amqp/reference/connections-endpoint)) — the cleartext error never crosses the wire. So a client should treat the `description` as a short, human-readable hint and branch its logic on the **symbolic condition**, not on the description string. ## Client handling guidance [#client-handling-guidance] | Condition | Recommended client response | | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `amqp:internal-error` | retry with backoff; the broker hit a transient fault | | `amqp:not-found` | fix the address / channel — it does not exist or breaks the charset; do not retry unchanged | | `amqp:unauthorized-access` | re-authenticate (refresh the JWT) or request a policy grant; do not retry unchanged | | `amqp:decode-error` | a client/codec bug — inspect the encoded frame; do not blind-retry | | `amqp:resource-limit-exceeded` | back off and reconnect (caps), grant credit faster (stalled events-store), or send keepalives (idle) | | `amqp:not-allowed` | resolve the conflict — release the durable identity, use a fresh link name, attach `responses/` as a sender | | `amqp:invalid-field` | fix the link property — correct the `x-opt-kubemq-start` grammar or the selector expression | | `amqp:not-implemented` | the feature is a documented non-goal — use the supported alternative (selector on `events/` not `queues/`; `rcv-settle-mode=first`) | | `amqp:precondition-failed` | set a valid `to` on the anonymous-terminus message | | `amqp:link:message-size-exceeded` | split the payload or stay under the 100 MiB cap | | `amqp:session:window-violation` | respect the advertised session window; grant credit before sending | | `amqp:session:errant-link` | a handle-management bug in the client — do not reuse live handles | | `amqp:connection:forced` | reconnect; the server is shutting down or the broker went away | ## Related [#related] # Getting Started (/connectors/amqp/tutorials/getting-started) Get a message flowing through the KubeMQ AMQP 1.0 connector in minutes. You point a standard AMQP 1.0 client at the broker, attach a sender to `queues/`, produce a few messages, then attach a receiver and consume them back — all over the native AMQP 1.0 wire, with no KubeMQ SDK. This walkthrough takes you from a running server to a verified at-least-once round-trip. ## Prerequisites [#prerequisites] * A running **kubemq-server** with the AMQP 1.0 connector **enabled** and reachable on **port 5672** (plain TCP). The connector is **opt-in (disabled by default)** — see the enable step below. * One of the AMQP 1.0 clients below for your language (the examples pin a native client per language — there is no KubeMQ SDK). ## Enable the connector [#enable-the-connector] The AMQP 1.0 connector is **disabled by default** — a stock kubemq-server does **not** bind the AMQP 1.0 listener until you turn it on. Enable it with its enable variable: The enable variable is **`CONNECTORS_AMQP10_ENABLE`** — the literal `10` stays attached to `AMQP` with no underscore. `CONNECTORS_AMQP_1_0_ENABLE` and `CONNECTORS_AMQP10ENABLE` do **not** bind. For Kubernetes, set `spec.amqp10.enabled: true` in the `KubemqCluster` CR. Bring up a throwaway local broker with AMQP 1.0 enabled: Every example reads a single environment variable for the broker endpoint. A URL with no userinfo negotiates **SASL ANONYMOUS**, so a stock dev broker is clone-and-run with no credentials: ```bash # default: amqp://localhost:5672 export KUBEMQ_AMQP_URL="amqp://localhost:5672" ``` To **disable** the AMQP 1.0 connector after enabling it, set its enable variable to `false`: When `Enable` is `false`, all other AMQP 1.0 validation is skipped and no listener binds. See [Configuration](/connectors/amqp/concepts/configuration) for the full settings list. ## How it works [#how-it-works] A producer attaches a sender link to a node address; the connector resolves the address prefix to a KubeMQ pattern and channel and enqueues each message. A consumer attaches a receiver link to the same address, grants credit, and the connector delivers and removes each accepted message. *A sender produces unsettled messages to a Queue channel; a receiver grants credit, and the connector delivers and removes each accepted message.* ## Steps [#steps] ### Connect to the broker [#connect-to-the-broker] Open an AMQP 1.0 connection to the endpoint in `KUBEMQ_AMQP_URL`. A URL with no userinfo negotiates SASL ANONYMOUS; the client sends a non-empty `container-id` automatically (the connector requires one). One session carries both the producer and consumer links in the steps below. The language tabs across all three steps run the **complete** round-trip from a single program: connect, produce 10 messages to `queues/amqp10.examples.basic`, consume and accept each, and confirm the queue drains to empty. ```go package main import ( "context" "fmt" "log" "os" "time" amqp "github.com/Azure/go-amqp" ) const channel = "amqp10.examples.basic" const total = 10 func amqpURL() string { if v := os.Getenv("KUBEMQ_AMQP_URL"); v != "" { return v } return "amqp://localhost:5672" } func main() { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() addr := "queues/" + channel // CONNECT: OPEN (SASL ANONYMOUS) + BEGIN one session. conn, err := amqp.Dial(ctx, amqpURL(), nil) if err != nil { log.Fatalf("dial: %v", err) } defer func() { _ = conn.Close() }() session, err := conn.NewSession(ctx, nil) if err != nil { log.Fatalf("new session: %v", err) } // SEND: attach a sender; each unsettled Send blocks for the accepted disposition. sender, err := session.NewSender(ctx, addr, nil) if err != nil { log.Fatalf("new sender: %v", err) } for i := 0; i < total; i++ { body := fmt.Sprintf("msg-%03d", i) if err := sender.Send(ctx, amqp.NewMessage([]byte(body)), nil); err != nil { log.Fatalf("send %s: %v", body, err) } } _ = sender.Close(ctx) fmt.Printf("[send] Produced %d messages to %s\n", total, addr) // RECEIVE: attach a receiver with credit; accept each => removed from the queue. receiver, err := session.NewReceiver(ctx, addr, &amqp.ReceiverOptions{Credit: 10}) if err != nil { log.Fatalf("new receiver: %v", err) } seen := make(map[string]struct{}, total) for len(seen) < total { msg, err := receiver.Receive(ctx, nil) if err != nil { log.Fatalf("receive: %v", err) } if err := receiver.AcceptMessage(ctx, msg); err != nil { log.Fatalf("accept: %v", err) } seen[string(msg.GetData())] = struct{}{} } fmt.Printf("[recv] Consumed and accepted %d messages (no loss)\n", len(seen)) _ = receiver.Close(ctx) } ``` ```python import os from proton import Message from proton.utils import BlockingConnection CHANNEL = "amqp10.examples.basic" TOTAL = 10 def amqp_url() -> str: return os.environ.get("KUBEMQ_AMQP_URL", "amqp://localhost:5672") def main() -> None: addr = "queues/" + CHANNEL # CONNECT: OPEN (SASL ANONYMOUS) — proton sends a non-empty container-id. conn = BlockingConnection(amqp_url()) try: # SEND: attach a sender; each send blocks for the accepted disposition. sender = conn.create_sender(addr) for i in range(TOTAL): sender.send(Message(body=f"msg-{i:03d}")) sender.close() print(f"[send] Produced {TOTAL} messages to {addr}") # RECEIVE: attach a receiver with credit; accept each => removed from the queue. receiver = conn.create_receiver(addr, credit=10) seen: set[str] = set() while len(seen) < TOTAL: msg = receiver.receive(timeout=30.0) receiver.accept() seen.add(str(msg.body)) print(f"[recv] Consumed and accepted {len(seen)} messages (no loss)") receiver.close() finally: conn.close() if __name__ == "__main__": main() ``` ```java import java.util.HashSet; import java.util.Set; import javax.jms.Connection; import javax.jms.Message; import javax.jms.MessageConsumer; import javax.jms.MessageProducer; import javax.jms.Queue; import javax.jms.Session; import org.apache.qpid.jms.JmsConnectionFactory; public final class Main { private static final String CHANNEL = "amqp10.examples.basic"; private static final int TOTAL = 10; public static void main(String[] args) throws Exception { String url = System.getenv().getOrDefault("KUBEMQ_AMQP_URL", "amqp://localhost:5672"); String address = "queues/" + CHANNEL; // CONNECT: OPEN (SASL ANONYMOUS). The JMS destination name IS the node address. JmsConnectionFactory factory = new JmsConnectionFactory(url); try (Connection connection = factory.createConnection()) { connection.start(); try (Session session = connection.createSession(false, Session.CLIENT_ACKNOWLEDGE)) { Queue queue = session.createQueue(address); // SEND: a producer is a server-receiver link; send blocks for accepted. try (MessageProducer producer = session.createProducer(queue)) { for (int i = 0; i < TOTAL; i++) { producer.send(session.createTextMessage(String.format("msg-%03d", i))); } } System.out.printf("[send] Produced %d messages to %s%n", TOTAL, address); // RECEIVE: a CLIENT_ACKNOWLEDGE consumer; acknowledge() settles accepted. try (MessageConsumer consumer = session.createConsumer(queue)) { Set seen = new HashSet<>(); while (seen.size() < TOTAL) { Message msg = consumer.receive(30_000); if (msg == null) { throw new IllegalStateException("timed out before draining the queue"); } String body = msg.getBody(String.class); msg.acknowledge(); seen.add(body); } System.out.printf("[recv] Consumed and accepted %d messages (no loss)%n", seen.size()); } } } } } ``` ```csharp using System.Text; using Amqp; using Amqp.Framing; const string channel = "amqp10.examples.basic"; const int total = 10; static string AmqpUrl() => Environment.GetEnvironmentVariable("KUBEMQ_AMQP_URL") is { Length: > 0 } v ? v : "amqp://localhost:5672"; var addr = "queues/" + channel; // CONNECT: OPEN (SASL ANONYMOUS) + one session. var connection = await Connection.Factory.CreateAsync(new Address(AmqpUrl())); try { var session = new Session(connection); // SEND: a SenderLink is a server-receiver link; Send blocks for accepted. var sender = new SenderLink(session, "basic-sender", addr); for (var i = 0; i < total; i++) { var message = new Message { BodySection = new Data { Binary = Encoding.UTF8.GetBytes($"msg-{i:D3}") }, }; sender.Send(message, TimeSpan.FromSeconds(15)); } Console.WriteLine($"[send] Produced {total} messages to {addr}"); // RECEIVE: grant credit; Accept each => removed from the queue. Keep the // sender open through the consume phase (detaching it early can stall delivery). var receiver = new ReceiverLink(session, "basic-receiver", addr); receiver.SetCredit(10, autoRestore: true); var seen = new HashSet(); while (seen.Count < total) { var message = receiver.Receive(TimeSpan.FromSeconds(30)) ?? throw new InvalidOperationException("receive timed out"); receiver.Accept(message); seen.Add(BodyString(message)); } Console.WriteLine($"[recv] Consumed and accepted {seen.Count} messages (no loss)"); await receiver.CloseAsync(); await sender.CloseAsync(); await session.CloseAsync(); } finally { await connection.CloseAsync(); } static string BodyString(Message message) => message.BodySection switch { Data d => Encoding.UTF8.GetString(d.Binary), AmqpValue { Value: byte[] bytes } => Encoding.UTF8.GetString(bytes), AmqpValue { Value: string str } => str, AmqpValue v => v.Value?.ToString() ?? string.Empty, _ => string.Empty, }; ``` ```typescript import { Connection, ReceiverEvents, type ConnectionOptions, type EventContext, type Receiver, } from "rhea-promise"; const channel = "amqp10.examples.basic"; const total = 10; function connectionOptions(): ConnectionOptions { const url = new URL(process.env["KUBEMQ_AMQP_URL"] ?? "amqp://localhost:5672"); return { host: url.hostname, port: url.port ? Number(url.port) : 5672, container_id: `kubemq-amqp10-js-${process.pid}`, reconnect: false, }; } function bodyToString(body: unknown): string { return Buffer.isBuffer(body) ? body.toString("utf8") : String(body); } async function main(): Promise { const address = `queues/${channel}`; // CONNECT: OPEN (SASL ANONYMOUS). const connection = new Connection(connectionOptions()); await connection.open(); try { // SEND: an AwaitableSender; each send() resolves on the accepted disposition. const sender = await connection.createAwaitableSender({ target: { address } }); for (let i = 0; i < total; i++) { await sender.send({ body: `msg-${String(i).padStart(3, "0")}` }, { timeoutInSeconds: 15 }); } await sender.close(); console.log(`[send] Produced ${total} messages to ${address}`); // RECEIVE: manual credit + manual settle; accept() removes from the queue. const receiver = await connection.createReceiver({ source: { address }, credit_window: 0, autoaccept: false, autosettle: false, }); const seen = new Set(); await new Promise((resolve, reject) => { receiver.on(ReceiverEvents.message, (ctx: EventContext) => { ctx.delivery?.accept(); seen.add(bodyToString(ctx.message?.body)); if (seen.size >= total) resolve(); }); receiver.on(ReceiverEvents.receiverError, (ctx: EventContext) => reject(ctx.receiver?.error ?? new Error("receiver error"))); (receiver as Receiver).addCredit(total); }); console.log(`[recv] Consumed and accepted ${seen.size} messages (no loss)`); await receiver.close(); } finally { await connection.close(); } } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```rust use std::collections::HashSet; use fe2o3_amqp::link::delivery::Delivery; use fe2o3_amqp::link::receiver::CreditMode; use fe2o3_amqp::{Connection, Receiver, Sender, Session}; use fe2o3_amqp_types::definitions::SenderSettleMode; use fe2o3_amqp_types::messaging::{Body, Message}; use fe2o3_amqp_types::primitives::Value; const CHANNEL: &str = "amqp10.examples.basic"; const TOTAL: usize = 10; fn amqp_url() -> String { std::env::var("KUBEMQ_AMQP_URL").unwrap_or_else(|_| "amqp://localhost:5672".to_string()) } fn body_string(msg: &Message>) -> String { match &msg.body { Body::Data(batch) => batch.iter().flat_map(|d| d.0.iter().copied()).collect::>(), Body::Value(v) => match &v.0 { Value::Binary(b) => b.to_vec(), Value::String(s) => s.clone().into_bytes(), other => format!("{other:?}").into_bytes(), }, _ => Vec::new(), } .into_iter() .map(|b| b as char) .collect() } #[tokio::main] async fn main() -> Result<(), Box> { let addr = format!("queues/{CHANNEL}"); // CONNECT: OPEN (SASL ANONYMOUS) + BEGIN one session. let mut connection = Connection::open("amqp10-examples-basic", amqp_url().as_str()).await?; let mut session = Session::begin(&mut connection).await?; // SEND: a sender pinned to Unsettled (at-least-once); the default `mixed` is rejected. let mut sender = Sender::builder() .name("basic-sender") .target(addr.as_str()) .sender_settle_mode(SenderSettleMode::Unsettled) .attach(&mut session) .await?; for i in 0..TOTAL { let outcome = sender.send(format!("msg-{i:03}")).await?; if !outcome.is_accepted() { return Err(format!("send {i}: unexpected outcome {outcome:?}").into()); } } sender.close().await?; println!("[send] Produced {TOTAL} messages to {addr}"); // RECEIVE: client-granted auto credit; accept each => removed from the queue. let mut receiver = Receiver::builder() .name("basic-receiver") .source(addr.as_str()) .credit_mode(CreditMode::Auto(10)) .attach(&mut session) .await?; let mut seen: HashSet = HashSet::with_capacity(TOTAL); while seen.len() < TOTAL { let delivery: Delivery> = receiver.recv().await?; receiver.accept(&delivery).await?; seen.insert(body_string(delivery.message())); } println!("[recv] Consumed and accepted {} messages (no loss)", seen.len()); receiver.close().await?; session.end().await?; connection.close().await?; Ok(()) } ``` ### Send messages [#send-messages] The send phase in the program above attaches a **sender** link with the target address `queues/amqp10.examples.basic` and produces 10 messages. Because the sends are *unsettled*, each call blocks until the connector returns an `accepted` disposition — confirmation that the broker stored the message (at-least-once). Always use the **explicit pattern prefix** (`queues/…`); never rely on the connector's bare-address fallback. ### Receive and verify [#receive-and-verify] The receive phase attaches a **receiver** link to the same address and grants link credit; the connector delivers each message, and `accept` settles it — emitting an `AckRange` that removes it from the queue. After consuming all 10, a further receive times out, proving the queue drained with no loss: ```text [send] Produced 10 messages to queues/amqp10.examples.basic [recv] Consumed and accepted 10 messages (no loss) [recv] Queue drained to empty (no further messages) ``` Behind that, the full AMQP 1.0 handshake ran end-to-end: **OPEN** → **BEGIN** → **ATTACH** (sender, then receiver) → **TRANSFER / FLOW / DISPOSITION** → **DETACH / CLOSE**. Two flow-control footguns to internalize early: **Events** drop silently at zero credit (keep a consumer's credit topped up), and **Events-Store** stalls and loses its window if a durable consumer's credit is not replenished. Both are covered in [Flow control](/connectors/amqp/how-to/flow-control). ## Next steps [#next-steps] # Architecture (/connectors/aws/concepts/architecture) The KubeMQ **AWS SQS + SNS connector** is an embedded, wire-protocol bridge inside kubemq-server that speaks the genuine AWS SQS and SNS HTTP protocols on a dedicated second HTTP listener (default TCP **4566**, the LocalStack convention). It is built **only when the connector is enabled** (`CONNECTORS_AWS_ENABLE=true`), because enabling it opens a new port. Any standard AWS SDK connects to it with **only an endpoint-URL change** — no code changes, no library swap, no LocalStack. It is **one connector binary with two service surfaces** that map onto two distinct KubeMQ models: * **SQS** maps each queue onto a native KubeMQ **Queue** channel `sqs.{name}`, so AWS producers and native gRPC/REST consumers share the same messages. * **SNS** topics are **virtual** registry entries — replicated across cluster nodes — that fan out at publish time to subscribed SQS queues (a batch send) and HTTP/HTTPS webhooks (a delivery engine). Unlike connectors that touch every KubeMQ pattern, the AWS connector touches the **Queue primitive** (SQS) and a **topic registry** (SNS) — it does **not** map onto KubeMQ's events / events-store / commands / queries patterns, and there is **no RPC** anywhere. This single fact drives the mental model. ## How SQS & SNS map to KubeMQ [#how-sqs--sns-map-to-kubemq] A request arrives at the single AWS-style endpoint; the connector detects the protocol, verifies the SigV4 signature, and dispatches. SQS operations land on the KubeMQ Queue channel `sqs.{name}` through the message broker. SNS publishes resolve the virtual topic registry and fan out to every confirmed, filter-matching subscription. *SQS operations land on the KubeMQ Queue channel `sqs.{name}`; SNS publishes resolve the virtual, cluster-replicated registry and fan out to subscribed SQS queues and HTTP/HTTPS webhooks. The Queue channel is backed by the message broker's durable store.* The channel mapping is the single most important mental model: | Concept | Behavior | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Listener** | A dedicated second HTTP server on `Connectors.Aws.Port` (default **4566**). `POST /` and `GET /` both dispatch; there is no per-route REST path — everything is a single AWS-style endpoint. | | **Protocol detection** | SQS = AWS **JSON protocol** (`X-Amz-Target: AmazonSQS.{Op}`) with a Query-protocol fallback. SNS = AWS **Query protocol** only (form body / GET query → XML). | | **SQS queue → channel** | SQS queue `orders` ↔ native KubeMQ **Queue** channel `sqs.orders` (`channelPrefix = "sqs."`). AWS producers and native gRPC/REST consumers share the same messages. | | **FIFO group → per-group channel** | A FIFO queue `{name}.fifo` fans each message group onto its own channel `sqs.{name}.fifo.g.{enc(group)}`, where `enc` percent-encodes bytes outside `[a-zA-Z0-9_-]`. | | **SNS topic = virtual** | SNS topics have **no native channel** — they are registry entries replicated across cluster nodes. The authorization pseudo-resource is `sns.{topic}`. Fan-out resolves to target SQS channels + HTTP/HTTPS webhooks at publish time. | | **Registry is authoritative** | Only resources created via the AWS API are visible. A native `sqs.foo` channel never `CreateQueue`d returns `NonExistentQueue`. | | **ARNs / URLs** | `arn:aws:sqs:{Region}:{AccountId}:{name}`; queue URL form `{scheme}://{host}/{AccountId}/{name}`. Resolution parses **path only** → addressing is **path-style**, so stale hosts in saved URLs still work. | Region defaults to the ARN segment `kubemq` and is **NOT enforced** in SigV4; AccountId defaults to `000000000000`. See [Channel mapping](/connectors/aws/reference/channel-mapping) and [Configuration](/connectors/aws/concepts/configuration). ## SQS → KubeMQ internally [#sqs--kubemq-internally] Each SQS operation maps onto a KubeMQ Queue operation: | AWS operation | KubeMQ internal | | ------------------------------------------------ | -------------------------------------------------------------- | | `SendMessage` / `SendMessageBatch` | `SendQueueMessage` / `SendQueueMessagesBatch` | | `ReceiveMessage` | a downstream `Get` with `AutoAck=false` | | `DeleteMessage` | fire-and-forget `AckRange(seq)` | | visibility expiry / `ChangeMessageVisibility(0)` | `NAckRange(seq)` (message visible at the tail) | | `PurgeQueue` | `AckAllQueueMessages` (60 s cooldown → `PurgeQueueInProgress`) | Received-but-not-deleted messages are tracked **node-locally** (per-queue maps + a global visibility-deadline min-heap); a 250 ms sweeper NAcks expired entries back to the tail. A receipt handle minted on one node is rejected on another, so clustered deployments need a **sticky load balancer**. See [SQS queues](/connectors/aws/how-to/sqs-queues) and [SQS queues and consumers](/connectors/aws/how-to/sqs-queues-and-consumers). ## SNS → KubeMQ internally [#sns--kubemq-internally] Topics and subscriptions live **only** in the registry (replicated across cluster nodes) — they have no native channel. At publish time the connector resolves every **confirmed**, filter-matching subscription and: * for `sqs` subscriptions, sends all targets of one publish in a single `SendQueueMessagesBatch` onto each target queue's `sqs.{queue}` channel; * for `http`/`https` subscriptions, hands the body to the in-memory webhook delivery engine. **One `MessageId` per publish** is shared across all deliveries. A publish with zero matching subscriptions **succeeds** (the message is dropped). Because the topic registry is replicated across cluster nodes, a topic created on one node is visible on the others; the cluster uses a registry-sync conflict resolution to converge concurrent writes. See [Fan-out](/connectors/aws/how-to/fan-out) and [SNS fan-out](/connectors/aws/how-to/sns-fan-out). ## Cross-protocol interop [#cross-protocol-interop] Because every SQS queue is a normal KubeMQ Queue channel, an SQS `SendMessage` to `sqs.orders` is consumable by a gRPC/REST queue client on the same channel — and vice-versa. This lets you migrate one side at a time, or run AWS-SDK producers alongside native KubeMQ consumers. *The same KubeMQ Queue channel `sqs.orders` backs both sides, so an AWS SDK client and a gRPC/REST client interoperate transparently.* A message produced by a **native** KubeMQ client on `sqs.*` lacks the connector's `sqs_*` tags. On the SQS receive side its `MessageId` falls back to the broker MessageID, it has no `SenderId`, and no policy stamping is applied. This is harmless for interop — the body and tags round-trip — but do not assume an SQS-style `MessageId`/`SenderId` on natively-produced messages. See [Cross-protocol interop](/connectors/aws/concepts/cross-protocol-interop). ## Related [#related] # Configuration (/connectors/aws/concepts/configuration) The AWS connector is configured server-side under the `Connectors.Aws` block of the KubeMQ server config, exposed as **ten `CONNECTORS_AWS_*` environment variables**. Unlike the other wire-protocol connectors, it is **opt-in — disabled by default** — because enabling it opens a new HTTP listener. A stock server does **not** serve AWS until you turn it on. See the [Configuration reference](../reference/configuration) for the full field table and the TOML/Env/Docker equivalents. The only thing **clients** configure is the endpoint override via the `KUBEMQ_AWS_URL` environment variable (default `http://localhost:4566`), mapped to `AWS_ENDPOINT_URL_SQS` / `AWS_ENDPOINT_URL_SNS`. Everything below is broker-side server configuration. ## Enable the connector [#enable-the-connector] The connector is **off until you enable it.** Set its enable variable to `true`: **Enabling the connector opens a new HTTP listener on port 4566.** That port is **not bound until** `CONNECTORS_AWS_ENABLE=true`, and it **must differ** from the server's enabled gRPC/REST/HTTP ports — a collision aborts startup. This is precisely why the connector is opt-in rather than on by default: like all six wire-protocol connectors, it does not bind a listener until explicitly enabled. The enable variable is `CONNECTORS_AWS_ENABLE` — the prefix carries the underscore (`CONNECTORS_AWS_*`), unlike the MQTT connector's `CONNECTORSMQTT_*`. To disable it again, set `CONNECTORS_AWS_ENABLE=false` (a config-only rollback; no data migration). ## Credentials: accept-any vs static [#credentials-accept-any-vs-static] The connector supports two credential postures. See [Authentication](/connectors/aws/how-to/authentication) for the full treatment. ### Accept-any mode (default — no credentials configured) [#accept-any-mode-default--no-credentials-configured] When **no** credentials are configured, the credential store is empty: the connector parses the `AccessKeyId` and uses it as the ClientID, and logs once that it is running without credential verification. **Dummy credentials are still required.** Even in accept-any mode the request must carry a **syntactically valid** SigV4 signature with an `sqs`/`sns` credential scope. An unsigned request is rejected with `IncompleteSignature` — the only SigV4-exempt action is SNS `ConfirmSubscription`. The signature is not cryptographically verified, but its presence and shape are. So the SDK must be given an access key and secret (any non-empty value). ### Static credentials (`CONNECTORS_AWS_CREDENTIALS_DATA`) [#static-credentials-connectors_aws_credentials_data] Set `CONNECTORS_AWS_CREDENTIALS_DATA` to a JSON (optionally base64-encoded) array of credentials. Each entry carries an `AccessKeyId`, a `SecretAccessKey`, and an optional `ClientID` (defaulting to the `AccessKeyId`): ```json [ { "AccessKeyId": "AKIA...", "SecretAccessKey": "secret...", "ClientID": "billing" } ] ``` When credentials are configured, SigV4 is **fully verified**: the access key must match a configured credential, and the signature is checked with a constant-time compare. The authenticated `ClientID` becomes the identity used for per-channel `write`/`read` authorization and the stamped `sqs_sender_id` tag. An empty key or secret is a configuration error. SigV4 over plain HTTP is **unencrypted on the wire.** HTTPS is provided by the server-wide `Security` block — there is **no AWS-specific TLS option**. Production deployments should use the server's HTTPS listener. See [Connectivity and security](/connectors/aws/how-to/connectivity-and-security) and [Auth & security](/connectors/reference/auth-and-security). ## Related [#related] # Cross-Protocol Interop (/connectors/aws/concepts/cross-protocol-interop) Because every SQS queue is a **normal KubeMQ Queue channel** (`sqs.{name}`), an AWS SDK application and a native KubeMQ gRPC/REST client can work the **same channel**. A message sent by `boto3` to `sqs.shared` is consumable by a native `kubemq-go` queue client on `sqs.shared` — and a message produced natively is receivable by an AWS SDK `ReceiveMessage`. This lets you **migrate one side at a time**, or run AWS-SDK producers alongside native KubeMQ consumers. ## Overview [#overview] Both directions work on the same channel. The AWS-SDK side speaks the SQS HTTP protocol to the connector (port 4566); the native side speaks gRPC/REST to the KubeMQ broker directly. The channel — and therefore the message body — is shared. | Direction | Producer | Consumer | What carries over | | ---------------- | ---------------------------------- | ---------------------------------- | ----------------------------------------------------------------------- | | AWS SDK → native | SQS `SendMessage` on `sqs.shared` | native gRPC `ReceiveQueueMessages` | Body + `sqs_*` tags (`sqs_message_id`, `sqs_sender_id`, attribute tags) | | Native → AWS SDK | native gRPC `Send` on `sqs.shared` | SQS `ReceiveMessage` | Body + native tags; **no** `sqs_*` tags (see caveat) | ## How it works [#how-it-works] The AWS connector registers `sqs.shared` and the native client connects to the same channel through the broker. A produce on either side is visible to a consume on the other. *The SQS queue `shared` and the native channel `sqs.shared` are the same KubeMQ Queue channel; an AWS-SDK producer and a native gRPC consumer share its messages — and vice versa.* ## The AWS-SDK side [#the-aws-sdk-side] The AWS half is ordinary SQS code — `CreateQueue`, `SendMessage`, `ReceiveMessage`, `DeleteMessage` against `sqs.shared`. The queue must be **created via the AWS API first** (the registry is authoritative — a native channel never `CreateQueue`d returns `NonExistentQueue`). Each client overrides only the endpoint (`KUBEMQ_AWS_URL`, default `http://localhost:4566`) and supplies dummy static credentials. ```go package main import ( "context" "fmt" "log" "os" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/sqs" ) func main() { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() url := os.Getenv("KUBEMQ_AWS_URL") if url == "" { url = "http://localhost:4566" } cfg, _ := config.LoadDefaultConfig(ctx, config.WithRegion("us-east-1"), config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), ) sdk := sqs.NewFromConfig(cfg, func(o *sqs.Options) { o.BaseEndpoint = aws.String(url) }) // The channel sqs.shared must exist in the registry — create it via the API. created, err := sdk.CreateQueue(ctx, &sqs.CreateQueueInput{QueueName: aws.String("shared")}) if err != nil { log.Fatalf("CreateQueue: %v", err) } queueURL := aws.ToString(created.QueueUrl) fmt.Printf("CreateQueue: %s (native channel: sqs.shared)\n", queueURL) // Direction 1: SDK produces; a native gRPC client consumes on sqs.shared. if _, err := sdk.SendMessage(ctx, &sqs.SendMessageInput{ QueueUrl: aws.String(queueURL), MessageBody: aws.String("from the AWS SDK"), }); err != nil { log.Fatalf("SendMessage: %v", err) } fmt.Println("[SDK -> native] sent \"from the AWS SDK\" to sqs.shared") // Direction 2: the AWS SDK receives a message a native client produced. recv, err := sdk.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{ QueueUrl: aws.String(queueURL), WaitTimeSeconds: 10, }) if err != nil { log.Fatalf("ReceiveMessage: %v", err) } if len(recv.Messages) == 1 { m := recv.Messages[0] fmt.Printf("[native -> SDK] received %q MessageId=%s\n", aws.ToString(m.Body), aws.ToString(m.MessageId)) _, _ = sdk.DeleteMessage(ctx, &sqs.DeleteMessageInput{ QueueUrl: aws.String(queueURL), ReceiptHandle: m.ReceiptHandle, }) } } ``` ```python import os import boto3 sqs = boto3.client( "sqs", endpoint_url=os.environ.get("KUBEMQ_AWS_URL", "http://localhost:4566"), region_name="us-east-1", aws_access_key_id="test", aws_secret_access_key="test", ) # The channel sqs.shared must exist in the registry — create it via the API. url = sqs.create_queue(QueueName="shared")["QueueUrl"] print(f"CreateQueue -> {url} (native channel: sqs.shared)") # Direction 1: SDK produces; a native gRPC client consumes on sqs.shared. sqs.send_message(QueueUrl=url, MessageBody="from-aws-sdk") print("[SDK -> native] sent 'from-aws-sdk' to sqs.shared") # Direction 2: the AWS SDK receives a message a native client produced. recv = sqs.receive_message(QueueUrl=url, MaxNumberOfMessages=1, WaitTimeSeconds=10) for m in recv.get("Messages", []): print(f"[native -> SDK] received {m['Body']!r} MessageId={m['MessageId']}") sqs.delete_message(QueueUrl=url, ReceiptHandle=m["ReceiptHandle"]) ``` ```java import java.net.URI; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.sqs.SqsClient; import software.amazon.awssdk.services.sqs.model.Message; public final class Main { public static void main(String[] args) { String url = System.getenv().getOrDefault("KUBEMQ_AWS_URL", "http://localhost:4566"); try (SqsClient sqs = SqsClient.builder() .endpointOverride(URI.create(url)).region(Region.US_EAST_1) .credentialsProvider(StaticCredentialsProvider.create( AwsBasicCredentials.create("test", "test"))) .build()) { // The channel sqs.shared must exist in the registry — create it via the API. String queueUrl = sqs.createQueue(b -> b.queueName("shared")).queueUrl(); System.out.println("CreateQueue -> " + queueUrl + " (native channel: sqs.shared)"); // Direction 1: SDK produces; a native gRPC client consumes on sqs.shared. sqs.sendMessage(b -> b.queueUrl(queueUrl).messageBody("from the AWS SDK")); System.out.println("[SDK -> native] sent to sqs.shared"); // Direction 2: the AWS SDK receives a message a native client produced. var recv = sqs.receiveMessage(b -> b.queueUrl(queueUrl).waitTimeSeconds(10)); for (Message m : recv.messages()) { System.out.println("[native -> SDK] received '" + m.body() + "' MessageId=" + m.messageId()); sqs.deleteMessage(b -> b.queueUrl(queueUrl).receiptHandle(m.receiptHandle())); } } } } ``` ```typescript import { SQSClient, CreateQueueCommand, SendMessageCommand, ReceiveMessageCommand, DeleteMessageCommand, } from "@aws-sdk/client-sqs"; const sqs = new SQSClient({ endpoint: process.env["KUBEMQ_AWS_URL"] ?? "http://localhost:4566", region: "us-east-1", credentials: { accessKeyId: "test", secretAccessKey: "test" }, }); async function main(): Promise { // The channel sqs.shared must exist in the registry — create it via the API. const queueUrl = (await sqs.send(new CreateQueueCommand({ QueueName: "shared" }))).QueueUrl!; console.log(`CreateQueue -> ${queueUrl} (native channel: sqs.shared)`); // Direction 1: SDK produces; a native gRPC client consumes on sqs.shared. await sqs.send(new SendMessageCommand({ QueueUrl: queueUrl, MessageBody: "order from AWS SDK" })); console.log("[SDK -> native] sent to sqs.shared"); // Direction 2: the AWS SDK receives a message a native client produced. const recv = await sqs.send(new ReceiveMessageCommand({ QueueUrl: queueUrl, WaitTimeSeconds: 10 })); for (const m of recv.Messages ?? []) { console.log(`[native -> SDK] received "${m.Body}" MessageId=${m.MessageId}`); await sqs.send(new DeleteMessageCommand({ QueueUrl: queueUrl, ReceiptHandle: m.ReceiptHandle! })); } } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using Amazon.Runtime; using Amazon.SQS; using Amazon.SQS.Model; var url = Environment.GetEnvironmentVariable("KUBEMQ_AWS_URL") ?? "http://localhost:4566"; using var sqs = new AmazonSQSClient(new BasicAWSCredentials("test", "test"), new AmazonSQSConfig { ServiceURL = url, AuthenticationRegion = "us-east-1" }); // The channel sqs.shared must exist in the registry — create it via the API. var queueUrl = (await sqs.CreateQueueAsync(new CreateQueueRequest { QueueName = "shared" })).QueueUrl; Console.WriteLine($"CreateQueue -> {queueUrl} (native channel: sqs.shared)"); // Direction 1: SDK produces; a native gRPC client consumes on sqs.shared. await sqs.SendMessageAsync(new SendMessageRequest { QueueUrl = queueUrl, MessageBody = "from the AWS SDK" }); Console.WriteLine("[SDK -> native] sent to sqs.shared"); // Direction 2: the AWS SDK receives a message a native client produced. var recv = await sqs.ReceiveMessageAsync(new ReceiveMessageRequest { QueueUrl = queueUrl, WaitTimeSeconds = 10 }); foreach (var m in recv.Messages) { Console.WriteLine($"[native -> SDK] received '{m.Body}' MessageId={m.MessageId}"); await sqs.DeleteMessageAsync(new DeleteMessageRequest { QueueUrl = queueUrl, ReceiptHandle = m.ReceiptHandle }); } ``` ```ruby # frozen_string_literal: true require "aws-sdk-sqs" Aws::SQS::Client.remove_plugin(Aws::SQS::Plugins::QueueUrls) url = ENV.fetch("KUBEMQ_AWS_URL", "http://localhost:4566") sqs = Aws::SQS::Client.new( endpoint: url, region: "us-east-1", access_key_id: "test", secret_access_key: "test" ) # The channel sqs.shared must exist in the registry — create it via the API. queue_url = sqs.create_queue(queue_name: "shared").queue_url puts "CreateQueue -> #{queue_url} (native channel: sqs.shared)" # Direction 1: SDK produces; a native gRPC client consumes on sqs.shared. sqs.send_message(queue_url: queue_url, message_body: "from the AWS SDK") puts "[SDK -> native] sent to sqs.shared" # Direction 2: the AWS SDK receives a message a native client produced. recv = sqs.receive_message(queue_url: queue_url, max_number_of_messages: 1, wait_time_seconds: 10) recv.messages.each do |m| puts "[native -> SDK] received #{m.body.inspect} MessageId=#{m.message_id}" sqs.delete_message(queue_url: queue_url, receipt_handle: m.receipt_handle) end ``` ```rust use aws_config::{BehaviorVersion, Region}; use aws_credential_types::Credentials; use std::error::Error; #[tokio::main] async fn main() -> Result<(), Box> { let url = std::env::var("KUBEMQ_AWS_URL").unwrap_or_else(|_| "http://localhost:4566".into()); let conf = aws_config::defaults(BehaviorVersion::latest()) .region(Region::new("us-east-1")) .credentials_provider(Credentials::new("test", "test", None, None, "static")) .endpoint_url(url) .load() .await; let sqs = aws_sdk_sqs::Client::new(&conf); // The channel sqs.shared must exist in the registry — create it via the API. let queue_url = sqs.create_queue().queue_name("shared").send().await?.queue_url.unwrap(); println!("CreateQueue -> {queue_url} (native channel: sqs.shared)"); // Direction 1: SDK produces; a native gRPC client consumes on sqs.shared. sqs.send_message().queue_url(&queue_url).message_body("from the AWS SDK").send().await?; println!("[SDK -> native] sent to sqs.shared"); // Direction 2: the AWS SDK receives a message a native client produced. let recv = sqs.receive_message().queue_url(&queue_url).wait_time_seconds(10).send().await?; for m in recv.messages() { println!("[native -> SDK] received '{}' MessageId={}", m.body().unwrap_or_default(), m.message_id().unwrap_or("")); if let Some(handle) = m.receipt_handle() { sqs.delete_message().queue_url(&queue_url).receipt_handle(handle).send().await?; } } Ok(()) } ``` ## The native consumer [#the-native-consumer] The other side of the channel is an ordinary KubeMQ **Queue** client talking gRPC to the broker (default `localhost:50000`) on the `sqs.shared` channel — no AWS SDK involved. A message the AWS SDK sent carries the connector's `sqs_*` tags (`sqs_message_id`, attribute tags); a message the native client sends is later receivable by an SQS `ReceiveMessage`. ```go package main import ( "context" "fmt" "log" "time" kubemq "github.com/kubemq-io/kubemq-go" ) func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // Native KubeMQ gRPC queue client on the shared channel sqs.shared. native, err := kubemq.NewQueuesClient(ctx, kubemq.WithAddress("localhost", 50000), kubemq.WithClientId("kubemq-aws-interop-native"), kubemq.WithTransportType(kubemq.TransportTypeGRPC), ) if err != nil { log.Fatalf("connect native gRPC: %v", err) } defer func() { _ = native.Close() }() // Consume a message the AWS SDK produced on sqs.shared. pull, err := native.Pull(ctx, kubemq.NewReceiveQueueMessagesRequest(). SetClientId("kubemq-aws-interop-native"). SetChannel("sqs.shared"). SetMaxNumberOfMessages(1). SetWaitTimeSeconds(10)) if err != nil || pull.IsError { log.Fatalf("native Pull: %v %s", err, pull.Error) } for _, m := range pull.Messages { fmt.Printf("native received %q (sqs_message_id=%s)\n", string(m.Body), m.Tags["sqs_message_id"]) } // Produce a message the AWS SDK can ReceiveMessage on sqs.shared. if _, err := native.Send(ctx, kubemq.NewQueueMessage(). SetChannel("sqs.shared"). SetBody([]byte("from native gRPC"))); err != nil { log.Fatalf("native Send: %v", err) } fmt.Println("native sent \"from native gRPC\" to sqs.shared") } ``` ```python import os from kubemq import QueueMessage, QueuesClient CHANNEL = "sqs.shared" GRPC_ADDRESS = os.environ.get("KUBEMQ_GRPC_ADDRESS", "localhost:50000") # Native KubeMQ gRPC queue client on the shared channel sqs.shared. with QueuesClient(address=GRPC_ADDRESS, client_id="kubemq-aws-interop-python") as native: # Consume a message the AWS SDK produced on sqs.shared. resp = native.receive_queue_messages(channel=CHANNEL, max_messages=1, wait_timeout_in_seconds=10) for msg in resp.messages: body = msg.body.decode("utf-8") print(f"native received {body!r} sqs_message_id={msg.tags.get('sqs_message_id')}") msg.ack() # Produce a message the AWS SDK can ReceiveMessage on sqs.shared. result = native.send_queue_message( QueueMessage(channel=CHANNEL, body=b"from-native-grpc", tags={"origin": "native"}) ) print(f"native sent -> id={result.id}") ``` ```typescript import { KubeMQClient, createQueueMessage, bytesToString } from "kubemq-js"; const CHANNEL = "sqs.shared"; const address = process.env["KUBEMQ_BROKER_ADDRESS"] ?? "localhost:50000"; async function main(): Promise { // Native KubeMQ gRPC queue client on the shared channel sqs.shared. const native = await KubeMQClient.create({ address, clientId: "kubemq-aws-interop-js" }); try { // Consume a message the AWS SDK produced on sqs.shared. const msgs = await native.receiveQueueMessages({ channel: CHANNEL, maxMessages: 1, waitTimeoutSeconds: 10 }); for (const m of msgs) { console.log(`native received "${bytesToString(m.body)}" sqs_message_id=${m.tags["sqs_message_id"] ?? ""}`); } // Produce a message the AWS SDK can ReceiveMessage on sqs.shared. await native.sendQueueMessage(createQueueMessage({ channel: CHANNEL, body: "from native gRPC" })); console.log("native sent to sqs.shared"); } finally { await native.close(); } } main().catch((err) => { console.error(err); process.exit(1); }); ``` The native consumer is the **only** place a KubeMQ SDK appears in the AWS connector examples — the AWS-SDK half above is idiomatic AWS code in every language. The native half ships in all seven languages; where a language's native queue client is less mature, it can fall back to a `kubemq-go` sidecar or a REST queue call. ## Native-producer MessageId fallback [#native-producer-messageid-fallback] A message produced by a **native** KubeMQ client on `sqs.*` lacks the connector's `sqs_*` tags. On the SQS receive side this means: * its `MessageId` **falls back to the broker message id** (not a connector-minted UUID); * it has **no `SenderId`** (there is no authenticated `sqs_sender_id`); * **no policy stamping** is applied (no `RedrivePolicy` `MaxReceiveCount` / source-queue tags). This is expected and harmless for interop — the body and tags round-trip — but do not assume an SQS-style `MessageId` / `SenderId` on messages that entered the channel natively. ## Cluster caveat [#cluster-caveat] **Receipt handles are node-local.** When an AWS SDK consumer and a native client share a channel across cluster nodes, the AWS consumer's receipt handle is only valid on the node that issued it. Use a **sticky load balancer** (session affinity) so a consumer's receive, delete, and visibility-change calls all land on the same node. See the [connectivity and security guide](/connectors/aws/how-to/connectivity-and-security). ## No RPC responder [#no-rpc-responder] SQS and SNS are queue / pub-sub, **not** request/reply — there is **no gRPC RPC responder** anywhere in the AWS connector. Cross-protocol interop is a native **queue** client (produce / consume), not an RPC responder. ## Related [#related] # Authentication (/connectors/aws/how-to/authentication) The AWS connector authenticates every request with a **hand-rolled AWS Signature V4 (SigV4)** verifier (standard-library crypto only — no AWS SDK at runtime). It supports two postures: **accept-any** (the zero-friction local-dev default) and **configured static credentials** (a secured connector). Exactly one action — SNS `ConfirmSubscription` — is SigV4-exempt. ## SigV4 verification [#sigv4-verification] The verifier accepts both signature styles your SDK can produce: * **header-style** — `Authorization: AWS4-HMAC-SHA256 Credential=…, SignedHeaders=…, Signature=…`; * **query-style / presigned** — `X-Amz-Algorithm=AWS4-HMAC-SHA256` plus the `X-Amz-*` query params. The verification rules are the same in both styles: | Rule | Behavior | | ---------------------------------------- | -------------------------------------------------------- | | Signature compare | constant-time (`hmac.Equal`). | | Clock skew | ±15 minutes on `X-Amz-Date`. | | Credential scope **service** | must be `sqs` or `sns`. | | Credential scope **region** | **NOT enforced** — any region signs successfully. | | `X-Amz-Content-Sha256: UNSIGNED-PAYLOAD` | honored verbatim. | | `X-Amz-Security-Token` | accepted and ignored (no STS / session-token semantics). | **Region is not enforced.** Any `AWS_REGION` your SDK signs with is accepted; the connector's default ARN region segment is `kubemq`. The examples use `us-east-1` purely for familiarity. See [Migration from AWS](/connectors/aws/reference/migration-from-aws). ## Accept-any mode (the default) [#accept-any-mode-the-default] When **no credentials are configured**, the credential store is empty: the verifier only parses the `AccessKeyId` and uses it as the `ClientID`. The middleware logs once on startup — `aws connector running without credential verification`. This is the default, intended for zero-friction local development. **Dummy credentials are still required.** Even in accept-any mode the request must carry a **syntactically valid** SigV4 signature whose credential-scope service is `sqs` or `sns`. An **unsigned** request is rejected with `IncompleteSignature` — the only SigV4-exempt action is SNS `ConfirmSubscription`. The signature is not cryptographically checked, but its presence and shape are. So your SDK must be given an access key and secret (**any value**) to form the signature. Omitting them yields a "missing credentials" SDK error, not a connector error. ```bash export AWS_ACCESS_KEY_ID="x" # any value — used as the ClientID in accept-any mode export AWS_SECRET_ACCESS_KEY="x" # any value — the SDK needs it to sign export AWS_REGION="us-east-1" # not enforced ``` ## Configured static credentials [#configured-static-credentials] When `Credentials` / `CredentialsData` are set, SigV4 is **fully verified**: * the presented access key must match a configured `AwsCredential.AccessKeyId`; * the secret must match (a constant-time compare of the signature); * the authenticated `ClientID` becomes `AwsCredential.ClientID` (defaults to the `AccessKeyId`). `CredentialsData` is a JSON (optionally base64-encoded) credential array — the env / operator path. See [Configuration](/connectors/aws/concepts/configuration) for the JSON shape. This is a getting-started / auth-banner toggle, not a separate example program. ## The ConfirmSubscription exemption [#the-confirmsubscription-exemption] A request with `Action=ConfirmSubscription`, **no** `Authorization` header, and **no** `X-Amz-Algorithm` query param bypasses SigV4 entirely (its `ClientID` becomes `sns-confirmation`). This is the **only** SigV4-exempt action — it exists so the `SubscribeURL` confirmation `GET` embedded in an SNS `SubscriptionConfirmation` envelope is usable as-is. A **signed** `ConfirmSubscription` is still verified normally. See [SNS fan-out](/connectors/aws/how-to/sns-fan-out). ## Casbin authorization [#casbin-authorization] On top of SigV4, the connector applies per-channel Casbin checks: * SQS data-plane operations authorize `write` / `read` on `sqs.{queue}`; * SNS topic management authorizes `write` on the pseudo-resource `sns.{topic}`; * SNS fan-out applies a per-target `write` check on each `sqs.{queue}` it delivers to. A denied SQS data-plane operation (for example `SendMessage`) returns `AccessDeniedException` (403). ## Failure mapping [#failure-mapping] | Trigger | AWS error code | HTTP | | ------------------------------------------------------------------------- | ----------------------- | ---- | | Malformed SigV4 (including an unsigned non-`ConfirmSubscription` request) | `IncompleteSignature` | 400 | | Unknown access key (configured-credentials mode) | `InvalidClientTokenId` | 403 | | Bad signature / clock skew / tampered body | `SignatureDoesNotMatch` | 403 | | Casbin deny on a data-plane operation | `AccessDeniedException` | 403 | All authentication failures are audited as `aws.auth.failure`. See [Error codes](/connectors/aws/reference/error-codes). **SigV4 over plain HTTP transmits the request unencrypted.** Accept-any mode is for local development only. For production, terminate over the server's HTTPS listener — there is no AWS-specific TLS option; TLS comes from the shared server `Security` block. See [Connectivity and security](/connectors/aws/how-to/connectivity-and-security). ## Related [#related] # Connectivity and security (/connectors/aws/how-to/connectivity-and-security) This guide covers how SQS and SNS clients reach the connector: the endpoint override, the opt-in enable, path-style queue URLs, the SigV4-over-HTTP transport, HTTPS via the server's shared `Security` block, the sticky-load-balancer caveat for clusters, and the region/account model. ## The connector is opt-in [#the-connector-is-opt-in] **The AWS connector is disabled by default.** Set `CONNECTORS_AWS_ENABLE=true` to turn it on. Enabling it **opens a new HTTP listener on port 4566** that is not bound until enabled, and that port must differ from the gRPC / REST / HTTP server ports. Unlike the other wire-protocol connectors — all six wire-protocol connectors are opt-in, ports not bound until enabled. See [Configuration](/connectors/aws/concepts/configuration). ## Endpoint override [#endpoint-override] The connector is a single TCP HTTP listener (default port **4566**, the LocalStack convention). Clients reach it by overriding **only the endpoint URL** — no code changes: * set `AWS_ENDPOINT_URL_SQS` and `AWS_ENDPOINT_URL_SNS`, or * set the SDK's `BaseEndpoint` / `endpointOverride` / `ServiceURL` / `endpoint` / `.endpoint_url()`. The examples expose a single convenience variable, `KUBEMQ_AWS_URL` (default `http://localhost:4566`), and map it to both endpoint variables: ```bash export KUBEMQ_AWS_URL="http://localhost:4566" export AWS_ENDPOINT_URL_SQS="$KUBEMQ_AWS_URL" export AWS_ENDPOINT_URL_SNS="$KUBEMQ_AWS_URL" ``` Both `POST /` and `GET /` dispatch (the `GET` only for `Action=ConfirmSubscription`); there is no per-route REST path. See [Getting started](/connectors/aws/tutorials/getting-started) and [Configuration](/connectors/aws/concepts/configuration). ## Path-style queue URLs [#path-style-queue-urls] Queue URLs are **path-style**: `{scheme}://{host}/{AccountId}/{name}`. The host comes from the configured `AdvertisedUrl` when set, otherwise the request `Host`. URL **resolution parses the path only**, so a stale host in a saved queue URL still works — the connector addresses by `/{account}/{queue}`. ## SigV4 over HTTP [#sigv4-over-http] Every request is verified with hand-rolled SigV4 (see [Authentication](/connectors/aws/how-to/authentication)). In the default **accept-any** posture the signature shape is required but not cryptographically checked; with **static credentials** it is fully verified. **SigV4 over plain HTTP transmits the request unencrypted.** Accept-any mode is for local development. For production, terminate over HTTPS (below). ## HTTPS / TLS [#https--tls] **There is no AWS-specific TLS option.** The connector reuses the shared `httpserver` infrastructure, so HTTPS/mTLS is available via the **server-global `Security` block** — the same one that secures gRPC and REST — passed in when the listener is created. There is no `CONNECTORS_AWS_TLS_*` field. When the server-global `Security` block is configured, the connector listens over HTTPS; point the SDK at `https://host:4566` (or whatever the deployment exposes) and keep the same code. TLS is therefore a connectivity / production callout, not a built example variant. For the shared TLS/mTLS model across connectors, see [Auth & security](/connectors/reference/auth-and-security). ## Sticky-LB caveat (cluster) [#sticky-lb-caveat-cluster] **Node-local state needs a sticky load balancer.** Three pieces of connector state are **node-local**: * SQS **receipt handles** — a handle minted on one node yields `ReceiptHandleIsInvalid` on another; * SQS **in-flight tracking** — per-queue / per-node maps plus the sweeper; * SNS **HTTP delivery state** — pending retries live on the publishing node and are lost on its restart. Cluster deployments must put a **sticky load balancer** (session affinity) in front of the connector so each client sticks to one node for the lifetime of its in-flight messages and subscriptions. Single-node deployments are unaffected. See [Reliability](/connectors/aws/how-to/reliability). The registry itself is BoltDB, **synced across cluster nodes**, so queue / topic / subscription **existence** is cluster-wide; only the in-flight / receipt / delivery **state** is node-local. ## Region and account [#region-and-account] * **Region is not enforced.** Any `AWS_REGION` signs successfully; the connector's default ARN region segment is `kubemq`. Use any familiar region (the examples use `us-east-1`). * **AccountId** is a single configurable 12-digit value (default `000000000000`). There is **no cross-account support** — `QueueOwnerAWSAccountId` is accepted and ignored. ## Traffic gate [#traffic-gate] While the message broker is not yet ready, the connector returns an **AWS-shaped 503** (rather than a raw error), so SDKs surface it as a retryable service error. See [Connections endpoint](/connectors/aws/reference/connections-endpoint). ## Related [#related] # Fan-Out (SNS → SQS) (/connectors/aws/how-to/fan-out) **Fan-out** delivers one published message to many subscribers. An SNS `Publish` resolves, at publish time, to every **confirmed**, filter-matching subscription and delivers a copy to each — SQS queues (in a single batch send) and HTTP/HTTPS webhooks. This is the classic "one event, many consumers" pattern: an order-placed event reaches the billing queue, the shipping queue, and an analytics webhook in a single publish. ## Overview [#overview] `CreateQueue` each target queue, `CreateTopic`, then `Subscribe(Protocol=sqs, Endpoint=)` for each — an `sqs` subscription **auto-confirms** immediately. `Publish` then fans out to every matching subscription. | Step | Action | Behavior | | ---------------- | ------------------------------- | --------------------------------------------------------- | | Targets | `CreateQueue` ×N | Each becomes channel `sqs.{name}` | | Topic | `CreateTopic` | Virtual registry entry | | Subscribe | `Subscribe(Protocol=sqs)` | Auto-confirmed; `http`/`https` need `ConfirmSubscription` | | Publish | `Publish` / `PublishBatch` | One `MessageId`, shared across all deliveries | | SQS delivery | Single `SendQueueMessagesBatch` | All `sqs` targets of that publish in one batch | | Webhook delivery | In-memory delivery engine | Retry → circuit breaker → DLQ | Fan-out semantics: * **One `MessageId` per publish**, shared across all deliveries. * **Zero matching subscriptions → the publish still succeeds** and the message is dropped (no error). * **Per-target failures** (deleted queue, unauthorized, oversize, FIFO mismatch) are dropped with a metric and do **not** fail the publish; a per-target authorization check applies on each `sqs.{queue}`. ## How it works [#how-it-works] A single publish resolves to the set of confirmed, filter-matching subscriptions; all SQS targets go out in one batch send and webhooks go through the delivery engine. Every delivery shares the same `MessageId`. *One publish fans out to every confirmed subscription — SQS queues in a single batch send and HTTP/HTTPS webhooks through the delivery engine — all sharing one `MessageId`; a filtered subscription receives the copy only when its `FilterPolicy` matches.* ## Fan one publish to many queues [#fan-one-publish-to-many-queues] Subscribe two SQS queues to a topic, publish once, and watch both queues receive the same `MessageId`. Each client overrides only the endpoint (`KUBEMQ_AWS_URL`, default `http://localhost:4566`) and supplies dummy static credentials. ```go package main import ( "context" "encoding/json" "fmt" "log" "os" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/sns" "github.com/aws/aws-sdk-go-v2/service/sqs" sqstypes "github.com/aws/aws-sdk-go-v2/service/sqs/types" ) func main() { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() url := os.Getenv("KUBEMQ_AWS_URL") if url == "" { url = "http://localhost:4566" } cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion("us-east-1"), config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), ) if err != nil { log.Fatalf("load config: %v", err) } snsClient := sns.NewFromConfig(cfg, func(o *sns.Options) { o.BaseEndpoint = aws.String(url) }) sqsClient := sqs.NewFromConfig(cfg, func(o *sqs.Options) { o.BaseEndpoint = aws.String(url) }) topic, _ := snsClient.CreateTopic(ctx, &sns.CreateTopicInput{Name: aws.String("notify")}) topicArn := aws.ToString(topic.TopicArn) // Two target queues, each subscribed (auto-confirmed). urlA, arnA := makeQueue(ctx, sqsClient, "q-a") urlB, arnB := makeQueue(ctx, sqsClient, "q-b") subscribe(ctx, snsClient, topicArn, arnA) subscribe(ctx, snsClient, topicArn, arnB) // One Publish fans out to both queues with a shared MessageId. pub, err := snsClient.Publish(ctx, &sns.PublishInput{ TopicArn: aws.String(topicArn), Message: aws.String("hello fan-out"), }) if err != nil { log.Fatalf("Publish: %v", err) } msgID := aws.ToString(pub.MessageId) fmt.Printf("Publish: MessageId=%s\n", msgID) for name, qURL := range map[string]string{"q-a": urlA, "q-b": urlB} { env := receiveEnvelope(ctx, sqsClient, qURL) if env.MessageId != msgID { log.Fatalf("FAIL: %s MessageId=%q != publish %q", name, env.MessageId, msgID) } fmt.Printf("%s received the publish (MessageId=%s)\n", name, env.MessageId) } } func makeQueue(ctx context.Context, c *sqs.Client, name string) (url, arn string) { created, _ := c.CreateQueue(ctx, &sqs.CreateQueueInput{QueueName: aws.String(name)}) url = aws.ToString(created.QueueUrl) out, _ := c.GetQueueAttributes(ctx, &sqs.GetQueueAttributesInput{ QueueUrl: aws.String(url), AttributeNames: []sqstypes.QueueAttributeName{sqstypes.QueueAttributeNameQueueArn}, }) return url, out.Attributes[string(sqstypes.QueueAttributeNameQueueArn)] } func subscribe(ctx context.Context, c *sns.Client, topicArn, queueArn string) { if _, err := c.Subscribe(ctx, &sns.SubscribeInput{ TopicArn: aws.String(topicArn), Protocol: aws.String("sqs"), Endpoint: aws.String(queueArn), ReturnSubscriptionArn: true, }); err != nil { log.Fatalf("Subscribe: %v", err) } } type notification struct{ MessageId, Message string } func receiveEnvelope(ctx context.Context, c *sqs.Client, queueURL string) notification { recv, _ := c.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{ QueueUrl: aws.String(queueURL), WaitTimeSeconds: 5, MaxNumberOfMessages: 1, }) var env notification _ = json.Unmarshal([]byte(aws.ToString(recv.Messages[0].Body)), &env) return env } ``` ```python import json import os import boto3 def make(service: str): return boto3.client( service, endpoint_url=os.environ.get("KUBEMQ_AWS_URL", "http://localhost:4566"), region_name="us-east-1", aws_access_key_id="test", aws_secret_access_key="test", ) def make_queue(sqs, name: str): url = sqs.create_queue(QueueName=name)["QueueUrl"] arn = sqs.get_queue_attributes(QueueUrl=url, AttributeNames=["QueueArn"])["Attributes"]["QueueArn"] return url, arn def main() -> None: sns = make("sns") sqs = make("sqs") topic_arn = sns.create_topic(Name="notify")["TopicArn"] # Two target queues, each subscribed (auto-confirmed). url_a, arn_a = make_queue(sqs, "q-a") url_b, arn_b = make_queue(sqs, "q-b") for arn in (arn_a, arn_b): sns.subscribe(TopicArn=topic_arn, Protocol="sqs", Endpoint=arn, ReturnSubscriptionArn=True) # One Publish fans out to both queues with a shared MessageId. msg_id = sns.publish(TopicArn=topic_arn, Message="hello fan-out")["MessageId"] print(f"Publish -> MessageId={msg_id}") for name, url in (("q-a", url_a), ("q-b", url_b)): recv = sqs.receive_message(QueueUrl=url, WaitTimeSeconds=5, MaxNumberOfMessages=1) env = json.loads(recv["Messages"][0]["Body"]) assert env["MessageId"] == msg_id # same MessageId across deliveries print(f"{name} received the publish (MessageId={env['MessageId']})") if __name__ == "__main__": main() ``` ```java import java.net.URI; import java.util.List; import java.util.Map; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.sns.SnsClient; import software.amazon.awssdk.services.sqs.SqsClient; import software.amazon.awssdk.services.sqs.model.QueueAttributeName; public final class Main { public static void main(String[] args) { String url = System.getenv().getOrDefault("KUBEMQ_AWS_URL", "http://localhost:4566"); StaticCredentialsProvider creds = StaticCredentialsProvider.create( AwsBasicCredentials.create("test", "test")); try (SnsClient sns = SnsClient.builder().endpointOverride(URI.create(url)) .region(Region.US_EAST_1).credentialsProvider(creds).build(); SqsClient sqs = SqsClient.builder().endpointOverride(URI.create(url)) .region(Region.US_EAST_1).credentialsProvider(creds).build()) { String topicArn = sns.createTopic(b -> b.name("notify")).topicArn(); // Two target queues, each subscribed (auto-confirmed). Map queues = Map.of("q-a", "", "q-b", ""); for (String name : List.of("q-a", "q-b")) { String queueUrl = sqs.createQueue(b -> b.queueName(name)).queueUrl(); String queueArn = sqs.getQueueAttributes(b -> b .queueUrl(queueUrl).attributeNames(QueueAttributeName.QUEUE_ARN)) .attributes().get(QueueAttributeName.QUEUE_ARN); sns.subscribe(b -> b.topicArn(topicArn).protocol("sqs").endpoint(queueArn) .returnSubscriptionArn(true)); queues = new java.util.HashMap<>(queues); queues.put(name, queueUrl); } // One Publish fans out to both queues with a shared MessageId. String messageId = sns.publish(b -> b.topicArn(topicArn).message("hello fan-out")) .messageId(); System.out.println("Publish -> MessageId=" + messageId); for (Map.Entry e : queues.entrySet()) { var recv = sqs.receiveMessage(b -> b.queueUrl(e.getValue()) .waitTimeSeconds(5).maxNumberOfMessages(1)); System.out.println(e.getKey() + " received: " + recv.messages().get(0).body()); } } } } ``` ```typescript import { SNSClient, CreateTopicCommand, SubscribeCommand, PublishCommand } from "@aws-sdk/client-sns"; import { SQSClient, CreateQueueCommand, GetQueueAttributesCommand, ReceiveMessageCommand, } from "@aws-sdk/client-sqs"; const url = process.env["KUBEMQ_AWS_URL"] ?? "http://localhost:4566"; const opts = { endpoint: url, region: "us-east-1", credentials: { accessKeyId: "test", secretAccessKey: "test" } }; const sns = new SNSClient(opts); const sqs = new SQSClient(opts); async function makeQueue(name: string): Promise<{ url: string; arn: string }> { const url = (await sqs.send(new CreateQueueCommand({ QueueName: name }))).QueueUrl!; const arn = ( await sqs.send(new GetQueueAttributesCommand({ QueueUrl: url, AttributeNames: ["QueueArn"] })) ).Attributes!["QueueArn"]!; return { url, arn }; } async function main(): Promise { const topicArn = (await sns.send(new CreateTopicCommand({ Name: "notify" }))).TopicArn!; // Two target queues, each subscribed (auto-confirmed). const a = await makeQueue("q-a"); const b = await makeQueue("q-b"); for (const q of [a, b]) { await sns.send(new SubscribeCommand({ TopicArn: topicArn, Protocol: "sqs", Endpoint: q.arn, ReturnSubscriptionArn: true })); } // One Publish fans out to both queues with a shared MessageId. const msgId = (await sns.send(new PublishCommand({ TopicArn: topicArn, Message: "hello fan-out" }))).MessageId; console.log(`Publish -> MessageId=${msgId}`); for (const [name, q] of [["q-a", a], ["q-b", b]] as const) { const recv = await sqs.send(new ReceiveMessageCommand({ QueueUrl: q.url, WaitTimeSeconds: 5, MaxNumberOfMessages: 1 })); const env = JSON.parse(recv.Messages![0].Body!); console.log(`${name} received the publish (MessageId=${env.MessageId})`); } } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using System.Text.Json; using Amazon.Runtime; using Amazon.SimpleNotificationService; using Amazon.SimpleNotificationService.Model; using Amazon.SQS; using Amazon.SQS.Model; var url = Environment.GetEnvironmentVariable("KUBEMQ_AWS_URL") ?? "http://localhost:4566"; var creds = new BasicAWSCredentials("test", "test"); using var sns = new AmazonSimpleNotificationServiceClient(creds, new AmazonSimpleNotificationServiceConfig { ServiceURL = url, AuthenticationRegion = "us-east-1" }); using var sqs = new AmazonSQSClient(creds, new AmazonSQSConfig { ServiceURL = url, AuthenticationRegion = "us-east-1" }); var topicArn = (await sns.CreateTopicAsync(new CreateTopicRequest { Name = "notify" })).TopicArn; async Task<(string url, string arn)> MakeQueue(string name) { var queueUrl = (await sqs.CreateQueueAsync(new CreateQueueRequest { QueueName = name })).QueueUrl; var arn = (await sqs.GetQueueAttributesAsync(new GetQueueAttributesRequest { QueueUrl = queueUrl, AttributeNames = ["QueueArn"], })).QueueARN; return (queueUrl, arn); } // Two target queues, each subscribed (auto-confirmed). var a = await MakeQueue("q-a"); var b = await MakeQueue("q-b"); foreach (var q in new[] { a, b }) { await sns.SubscribeAsync(new SubscribeRequest { TopicArn = topicArn, Protocol = "sqs", Endpoint = q.arn, ReturnSubscriptionArn = true, }); } // One Publish fans out to both queues with a shared MessageId. var msgId = (await sns.PublishAsync(new PublishRequest { TopicArn = topicArn, Message = "hello fan-out" })).MessageId; Console.WriteLine($"Publish -> MessageId={msgId}"); foreach (var (name, q) in new[] { ("q-a", a), ("q-b", b) }) { var recv = await sqs.ReceiveMessageAsync(new ReceiveMessageRequest { QueueUrl = q.url, WaitTimeSeconds = 5, MaxNumberOfMessages = 1, }); var env = JsonDocument.Parse(recv.Messages[0].Body).RootElement; Console.WriteLine($"{name} received the publish (MessageId={env.GetProperty("MessageId")})"); } ``` ```ruby # frozen_string_literal: true require "aws-sdk-sns" require "aws-sdk-sqs" require "json" Aws::SQS::Client.remove_plugin(Aws::SQS::Plugins::QueueUrls) url = ENV.fetch("KUBEMQ_AWS_URL", "http://localhost:4566") opts = { endpoint: url, region: "us-east-1", access_key_id: "test", secret_access_key: "test" } sns = Aws::SNS::Client.new(opts) sqs = Aws::SQS::Client.new(opts) topic_arn = sns.create_topic(name: "notify").topic_arn # Two target queues, each subscribed (auto-confirmed). def make_queue(sqs, name) url = sqs.create_queue(queue_name: name).queue_url arn = sqs.get_queue_attributes(queue_url: url, attribute_names: ["QueueArn"]).attributes["QueueArn"] [url, arn] end url_a, arn_a = make_queue(sqs, "q-a") url_b, arn_b = make_queue(sqs, "q-b") [arn_a, arn_b].each do |arn| sns.subscribe(topic_arn: topic_arn, protocol: "sqs", endpoint: arn, return_subscription_arn: true) end # One Publish fans out to both queues with a shared MessageId. msg_id = sns.publish(topic_arn: topic_arn, message: "hello fan-out").message_id puts "Publish -> MessageId=#{msg_id}" { "q-a" => url_a, "q-b" => url_b }.each do |name, queue_url| recv = sqs.receive_message(queue_url: queue_url, wait_time_seconds: 5, max_number_of_messages: 1) env = JSON.parse(recv.messages.first.body) puts "#{name} received the publish (MessageId=#{env['MessageId']})" end ``` ```rust use aws_config::{BehaviorVersion, Region}; use aws_credential_types::Credentials; use std::error::Error; async fn config() -> aws_config::SdkConfig { let url = std::env::var("KUBEMQ_AWS_URL").unwrap_or_else(|_| "http://localhost:4566".into()); aws_config::defaults(BehaviorVersion::latest()) .region(Region::new("us-east-1")) .credentials_provider(Credentials::new("test", "test", None, None, "static")) .endpoint_url(url) .load() .await } async fn make_queue(sqs: &aws_sdk_sqs::Client, name: &str) -> (String, String) { let url = sqs.create_queue().queue_name(name).send().await.unwrap().queue_url.unwrap(); let arn = sqs .get_queue_attributes() .queue_url(&url) .attribute_names(aws_sdk_sqs::types::QueueAttributeName::QueueArn) .send() .await .unwrap() .attributes .and_then(|a| a.get(&aws_sdk_sqs::types::QueueAttributeName::QueueArn).cloned()) .unwrap(); (url, arn) } #[tokio::main] async fn main() -> Result<(), Box> { let conf = config().await; let sns = aws_sdk_sns::Client::new(&conf); let sqs = aws_sdk_sqs::Client::new(&conf); let topic_arn = sns.create_topic().name("notify").send().await?.topic_arn.unwrap(); // Two target queues, each subscribed (auto-confirmed). let (url_a, arn_a) = make_queue(&sqs, "q-a").await; let (url_b, arn_b) = make_queue(&sqs, "q-b").await; for arn in [arn_a, arn_b] { sns.subscribe() .topic_arn(&topic_arn) .protocol("sqs") .endpoint(arn) .return_subscription_arn(true) .send() .await?; } // One Publish fans out to both queues with a shared MessageId. let msg_id = sns.publish().topic_arn(&topic_arn).message("hello fan-out").send().await? .message_id.unwrap_or_default(); println!("Publish -> MessageId={msg_id}"); for (name, url) in [("q-a", url_a), ("q-b", url_b)] { let recv = sqs.receive_message().queue_url(&url).wait_time_seconds(5).max_number_of_messages(1) .send().await?; println!("{name} received: {}", recv.messages().first().and_then(|m| m.body()).unwrap_or_default()); } Ok(()) } ``` ## Filtered fan-out [#filtered-fan-out] Attach a `FilterPolicy` (on the `MessageAttributes` scope) to a subscription so it receives only the publishes it cares about. A matching publish is delivered; a non-matching one is suppressed — and a publish that matches **no** subscription still succeeds. ```python # Only deliver publishes whose "eventType" attribute is "order". sns.subscribe( TopicArn=topic_arn, Protocol="sqs", Endpoint=queue_arn, Attributes={ "FilterPolicy": json.dumps({"eventType": ["order"]}), "FilterPolicyScope": "MessageAttributes", }, ReturnSubscriptionArn=True, ) sns.publish(TopicArn=topic_arn, Message="an order event", MessageAttributes={"eventType": {"DataType": "String", "StringValue": "order"}}) sns.publish(TopicArn=topic_arn, Message="a metric event", MessageAttributes={"eventType": {"DataType": "String", "StringValue": "metric"}}) # Only "an order event" is delivered; the metric publish is suppressed. ``` **Filtering works on `MessageAttributes` scope only.** Setting `FilterPolicyScope = MessageBody` is rejected with `InvalidParameter`. Put the values you filter on into message attributes, not the body. ## Raw vs enveloped delivery [#raw-vs-enveloped-delivery] `RawMessageDelivery` controls the delivered shape per subscription: * **Enveloped (default):** the SQS message body is the SNS `Notification` JSON (`MessageId`, `TopicArn`, `Message`, `UnsubscribeURL`, `MessageAttributes`, `SignatureVersion: "1"`, and an empty `Signature`). * **Raw:** for SQS, the bare body plus `sns_topic_arn` / `sns_subject` tags and the attribute codec. ```python # One subscription raw, one enveloped, on the same topic. sns.subscribe(TopicArn=topic_arn, Protocol="sqs", Endpoint=raw_arn, Attributes={"RawMessageDelivery": "true"}, ReturnSubscriptionArn=True) sns.subscribe(TopicArn=topic_arn, Protocol="sqs", Endpoint=env_arn, ReturnSubscriptionArn=True) # default = enveloped ``` **Delivered SNS notifications are unsigned.** The `Notification` envelope carries `SignatureVersion: "1"` but its `Signature` and `SigningCertURL` are **empty** — a receiver cannot verify the message signature. ## HTTP/HTTPS webhooks [#httphttps-webhooks] A topic can also fan out to `http` / `https` endpoints. Such a subscription goes **pending** until the subscriber calls `ConfirmSubscription` (the only SigV4-exempt action, so the embedded `SubscribeURL` works as an unsigned GET). Once confirmed, deliveries run through an in-memory engine: 8 workers, a bounded job queue, a default 51-attempt retry schedule (overridable by a stored `DeliveryPolicy`), a per-endpoint circuit breaker that opens after 5 consecutive failures (30 s), and a redrive to the subscription's `RedrivePolicy` DLQ on exhaustion. Raw HTTP delivery maps attributes to `x-amz-sns-attr-{name}` headers. See the [reliability guide](/connectors/aws/how-to/reliability) and the [SNS fan-out guide](/connectors/aws/how-to/sns-fan-out). **SNS HTTP delivery state is in-memory on the publishing node.** A node restart loses pending retries, and the bounded job queue drops on overflow. This is part of the node-local / sticky-load-balancer family of caveats. ## Related [#related] # Reliability (/connectors/aws/how-to/reliability) This guide covers the reliability mechanisms across both surfaces: SQS visibility and in-flight tracking, FIFO ordering and deduplication, SQS DLQ/redrive, the SNS HTTP delivery retry → circuit-breaker → DLQ pipeline, and at-least-once delivery. Several behaviors have node-local caveats — read the callouts. ## SQS visibility and in-flight [#sqs-visibility-and-in-flight] A received message is hidden for a **visibility window** before it becomes receivable again. Precedence: per-request `VisibilityTimeout` (0–43200) **>** the queue default **>** 30 s. `ChangeMessageVisibility(timeout>0)` moves the deadline; `ChangeMessageVisibility(0)` NAcks the message so it becomes visible again at the **tail**. A 250 ms sweeper NAcks expired in-flight entries back to the tail, and `ApproximateReceiveCount` increments on each re-receive. Received-but-not-deleted messages count against a per-queue / per-node **in-flight cap**, `MaxInflightPerQueue` (default 20,000). Exceeding it returns `OverLimit`. Full SQS basics — send/receive/delete, long polling, batch — are in [SQS queues and consumers](/connectors/aws/how-to/sqs-queues-and-consumers). ## SQS FIFO ordering and deduplication [#sqs-fifo-ordering-and-deduplication] A `.fifo` queue (`FifoQueue` is immutable post-create) gives per-group ordering and dedup: * **`MessageGroupId` is required** on send (1–128 printable ASCII characters); per-message `DelaySeconds` is **rejected**; `ReceiveRequestAttemptId` is accepted and ignored. * Each group maps to its own channel `sqs.{name}.fifo.g.{enc(group)}`. * **Deduplication** uses an explicit `MessageDeduplicationId`, or the SHA-256 of the body when `ContentBasedDeduplication` is on. A **5-minute LRU** window is keyed `{queue}:{dedupId}` (or `{queue}:{group}:{dedupId}` under `messageGroup` scope). A duplicate returns the **original** `MessageId` / `SequenceNumber` **without re-publishing**. * **Ordering** allows at most one un-acked downstream `Get` per group at a time (a per-group lock); `ReceiveMessage` drains groups round-robin, ≤ 10 total. **`SequenceNumber` differs between send and receive.** The 20-digit `SequenceNumber` is the broker **send-timestamp (UnixNano)** on send and the **true broker sequence** on receive; it is still strictly increasing per group for serialized sends. See [Channel mapping](/connectors/aws/reference/channel-mapping). ## SQS DLQ / redrive [#sqs-dlq--redrive] Set a queue `RedrivePolicy` of `{deadLetterTargetArn, maxReceiveCount}`. The connector stamps each message with `MaxReceiveCount` / `MaxReceiveQueue=sqs.{dlq}` at send. The message broker moves a message to the DLQ when its receive count **exceeds** `maxReceiveCount`, surfacing `DeadLetterQueueSourceArn` on the redriven message. `ListDeadLetterSourceQueues` reverse-resolves which queues redrive into a given DLQ. ## SNS delivery: retry → circuit-breaker → DLQ [#sns-delivery-retry--circuit-breaker--dlq] HTTP / HTTPS subscriptions are delivered by an in-memory engine: **8 workers**, a bounded job queue of **10,000** (overflow drops the message, increments a metric, and logs a `WARN`), and a 10 s per-request timeout. The **default retry schedule** (overridable by a stored `DeliveryPolicy` — subscription wins over topic wins over default) is: 4 immediate attempts + 2 @ 10 s + 10 exponential (1 s → 60 s) + 35 @ 60 s = **50 retries / 51 attempts ≈ 39 minutes**. Retries fire on 5xx / 429 / timeout / connection errors; other 4xx responses are **terminal**. The **circuit breaker** is per-endpoint: **5 consecutive failures → open for 30 s**. **On retry exhaustion**, the body is **redriven** to the subscription's `RedrivePolicy` DLQ queue; if there is none, it is dropped and a metric increments. Outgoing delivery headers are `x-amz-sns-message-type`, `x-amz-sns-message-id`, `x-amz-sns-topic-arn`, and `x-amz-sns-subscription-arn`; the Content-Type is `text/plain; charset=UTF-8`. **SNS HTTP delivery state is in-memory on the publishing node.** Pending retries live only on the node that accepted the `Publish`; a node **restart loses** them, and the bounded job queue (10,000) drops on overflow with a metric. This is part of the **node-local / sticky-LB** family — see the cluster caveat below. **SNS notifications are unsigned.** Delivered envelopes carry an empty `Signature` / `SigningCertURL`, so a webhook **cannot** verify the signature. Do not build delivery authentication on SNS message-signature verification. See [SNS fan-out](/connectors/aws/how-to/sns-fan-out). ## At-least-once delivery [#at-least-once-delivery] Unacked SQS deliveries are NAcked back to the queue **tail** by the 250 ms sweeper at visibility expiry, or on graceful shutdown (≤ 10 s). The registry survives restart (a shared `StorePath`). So no message is lost on a graceful path, but a message may be **redelivered** (`ApproximateReceiveCount` increments) — make consumers **idempotent**. **Exactly-once is not provided.** ## Node-local caveat (cluster) [#node-local-caveat-cluster] **Node-local state needs a sticky load balancer.** SQS receipt handles and in-flight tracking, and SNS HTTP delivery state, are all **node-local**. In a cluster, a consumer's receipt handle is only valid on the node that issued it (`ReceiptHandleIsInvalid` elsewhere), and pending SNS retries are lost if that node restarts. Use a **sticky load balancer** (session affinity). Single-node deployments are unaffected. See [Connectivity and security](/connectors/aws/how-to/connectivity-and-security) and [Migration from AWS](/connectors/aws/reference/migration-from-aws). ## Error quick reference [#error-quick-reference] | Trigger | Result | | -------------------------------------------------- | --------------------------------------------------------------------- | | FIFO duplicate within the 5-minute window | returns the **original** `MessageId`, not re-enqueued | | Receive without delete, visibility expires | redelivery; `ApproximateReceiveCount` increments | | Reach `maxReceiveCount` (`RedrivePolicy`) | moved to DLQ + `DeadLetterQueueSourceArn` | | SNS HTTP endpoint 5xx / timeout / connection error | retried; breaker opens after 5 failures; redrive to DLQ on exhaustion | | Graceful shutdown with in-flight messages | NAcked back to the queue tail; registry survives restart | ## Related [#related] # SNS fan-out (/connectors/aws/how-to/sns-fan-out) This guide covers the SNS surface: topic management, subscriptions and the confirmation flow, message filtering, raw vs enveloped delivery, `PublishBatch`, FIFO topics, and `MessageStructure=json`. SNS topics are **virtual** BoltDB registry entries (no native channel) that fan out, at publish time, to SQS subscriptions and HTTP/HTTPS webhooks. The SNS surface ships **17 actions** (see [Capabilities](/connectors/aws/reference/capabilities)). ## Topic management [#topic-management] * `CreateTopic` — idempotent on an existing name; a **FIFO** topic uses the `.fifo` suffix. * `DeleteTopic` — cascades its subscriptions. * `ListTopics` — ARN-sorted, 100 per page; **not** authorization-filtered. * `GetTopicAttributes` / `SetTopicAttributes` — the only writable attributes are `DisplayName` and `DeliveryPolicy`. **`Policy` is rejected.** Setting a topic `Policy` returns `InvalidParameter`. **Topic-level `ContentBasedDeduplication` is not supported** — a set returns `InvalidParameter` and a get always returns `"false"`. For FIFO topics, pass an explicit `MessageDeduplicationId` (see below). ## Subscriptions [#subscriptions] `Subscribe` accepts three protocols only: | Protocol | Behavior | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `sqs` | The endpoint must be a **registry queue ARN**; the subscription is **auto-confirmed** immediately (`Confirmed=true`). | | `http` / `https` | Goes **pending** with a 48 h confirmation token; a `SubscriptionConfirmation` envelope is POSTed best-effort; expired pending subscriptions are swept hourly. Confirm via `ConfirmSubscription`. | | anything else | `email` / `email-json` / `sms` / `lambda` / `application` / `firehose` → `InvalidParameter` "protocol not supported". | The writable **subscription attributes** are `RawMessageDelivery` (bool), `FilterPolicy` (JSON, parsed and validated at set), `FilterPolicyScope`, `RedrivePolicy` (JSON), and `DeliveryPolicy`. The `http`/`https` confirmation `GET` is **SigV4-exempt** so the `SubscribeURL` embedded in a `SubscriptionConfirmation` envelope is usable as-is — see [Authentication](/connectors/aws/how-to/authentication). ## Message filtering [#message-filtering] A `FilterPolicy` filters which subscriptions receive a publish. All 8 AWS operators are supported: exact string/number/bool, prefix, suffix, anything-but, numeric comparison/range, exists true/false, and CIDR. Keys are ANDed, values within a key ORed; an empty policy matches everything. Write-time limits: ≤ 5 keys, ≤ 150 value combinations. Binary attributes match only `exists:true`. **`MessageBody`-scope filtering is unsupported.** Only `FilterPolicyScope = MessageAttributes` works. Setting `FilterPolicyScope = MessageBody` is **rejected** at the attribute setter with `InvalidParameter` "MessageBody scope is not supported". Filter only on `MessageAttributes`. See [Capabilities](/connectors/aws/reference/capabilities). ## Raw vs enveloped delivery [#raw-vs-enveloped-delivery] `RawMessageDelivery` controls the delivered shape: * **Enveloped** (`false`, the default) — the body is the SNS `Notification` JSON: `{Type:"Notification", MessageId, TopicArn, Subject?, Message, Timestamp, SignatureVersion:"1", Signature:"", SigningCertURL:"", UnsubscribeURL, MessageAttributes?}`. Each `MessageAttributes` entry is `{Type, Value}` (Binary is base64). * **Raw** (`true`) — for an SQS subscription, the bare message bytes plus the attribute tag codec and `sns_topic_arn` / `sns_subject` tags; for an HTTP subscription, the bare payload with attributes mapped to `x-amz-sns-attr-{name}` headers. **SNS notifications are unsigned.** `Signature` and `SigningCertURL` are present in the envelope but **empty** (`SignatureVersion` is `"1"`). No SDK-side signature verification can be performed. Do not rely on verifying SNS message signatures. See [Reliability](/connectors/aws/how-to/reliability). **Raw-HTTP attribute deviation.** Real AWS drops message attributes for raw HTTP delivery; this connector instead maps them to `x-amz-sns-attr-{name}` headers. See [Channel mapping](/connectors/aws/reference/channel-mapping). ## Fan-out semantics [#fan-out-semantics] `Publish` / `PublishBatch` fan out to every **confirmed**, filter-matching subscription: * there is **one `MessageId` per publish**, shared across all deliveries; * all `sqs` deliveries of one publish go out in a single `SendQueueMessagesBatch`; * per-target failures (deleted queue, unauthorized, oversize, FIFO mismatch) are **dropped with a metric and do not fail the publish**; * **zero matching subscriptions → the publish succeeds** and the message is dropped; * a per-target Casbin `write` check applies on each `sqs.{queue}`. `MessageStructure=json` is supported: pass a JSON object with a string `default` key plus optional per-protocol string overrides. ## FIFO topics [#fifo-topics] A `.fifo` topic restricts `Subscribe` to the `sqs` protocol onto a `.fifo` queue (`http` / `https` are rejected). `Publish` / `PublishBatch`: * **require `MessageGroupId`** (and **reject** it on a standard topic); * treat `MessageDeduplicationId` as optional — topic-level content-based dedup is unsupported, so pass it explicitly. Group and dedup ids propagate into the FIFO queue-message build. See [Reliability](/connectors/aws/how-to/reliability) for the full FIFO ordering and dedup detail. ## Error quick reference [#error-quick-reference] | Trigger | AWS error code | | --------------------------------------------------------------------- | ---------------------------------- | | `Subscribe` with `email` / `sms` / `lambda` / `firehose` / … protocol | `InvalidParameter` | | `Subscribe` with `FilterPolicyScope=MessageBody` | `InvalidParameter` | | `Publish` with `MessageGroupId` to a **standard** topic | `InvalidParameter` | | FIFO topic `Subscribe` with `http` / a non-FIFO queue | `InvalidParameter` | | Set a topic `Policy` / topic-level `ContentBasedDeduplication` | `InvalidParameter` | | `Publish` with `TargetArn` / `PhoneNumber` | `InvalidParameter` | | Topic / subscription not in the registry | `NotFound` (404) | | `Publish` to a topic with zero matching subscriptions | *none — succeeds, message dropped* | ## Related [#related] # SNS Topics (/connectors/aws/how-to/sns-topics) An **SNS topic** is publish/subscribe: a publisher sends one message to a topic, and the topic fans it out to every confirmed subscription. In the AWS connector, topics are **virtual** — they have no backing channel of their own. Each topic is a registry entry (the registry is replicated across cluster nodes), and its authorization pseudo-resource is `sns.{topic}`. Fan-out resolves to target SQS channels (a single batch send) and HTTP/HTTPS webhooks **at publish time**. Topics are routing metadata, not data stores. ## Overview [#overview] `CreateTopic` registers the topic; `Subscribe` attaches an endpoint; `Publish` / `PublishBatch` fan out to every confirmed, filter-matching subscription. The connector implements 17 SNS actions. | SNS operation | Behavior | Notes | | -------------------------------- | ---------------------------------------------- | ---------------------------------------- | | `CreateTopic("hooks")` | Registers a virtual topic | Idempotent; `.fifo` suffix makes it FIFO | | `Subscribe(Protocol=sqs)` | Attaches a registry queue, **auto-confirmed** | Endpoint is the queue ARN | | `Subscribe(Protocol=http/https)` | Goes **pending**; 48 h confirmation token | Confirm via `ConfirmSubscription` | | `Subscribe(other protocols)` | Rejected with `InvalidParameter` | No `email` / `sms` / `lambda` | | `Publish` | Fan out to all matching subscriptions | One `MessageId` per publish, shared | | `PublishBatch` | Batched publish (≤ 10 entries) | Per-entry success/failure | | `ConfirmSubscription` | Activates a pending HTTP/HTTPS subscription | The only SigV4-exempt action | | `SetSubscriptionAttributes` | Set `FilterPolicy`, `RawMessageDelivery`, etc. | `MessageAttributes` filter scope only | ## How it works [#how-it-works] A publish resolves, at publish time, to every confirmed subscription. SQS targets are delivered in one batch send; HTTP/HTTPS targets go through an in-memory delivery engine. A single `MessageId` is shared across all deliveries. *A topic is a virtual registry entry; at publish time the connector resolves it to every confirmed subscription — SQS queues in one batch send, HTTP/HTTPS webhooks through the delivery engine — sharing one `MessageId`.* ## Create, subscribe, and publish [#create-subscribe-and-publish] Create a topic, subscribe an SQS queue (auto-confirmed), then publish. Each client overrides only the endpoint (`KUBEMQ_AWS_URL`, default `http://localhost:4566`) and supplies dummy static credentials. ```go package main import ( "context" "encoding/json" "fmt" "log" "os" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/sns" "github.com/aws/aws-sdk-go-v2/service/sqs" sqstypes "github.com/aws/aws-sdk-go-v2/service/sqs/types" ) func loadCfg(ctx context.Context) aws.Config { cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion("us-east-1"), config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), ) if err != nil { log.Fatalf("load config: %v", err) } return cfg } func main() { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() url := os.Getenv("KUBEMQ_AWS_URL") if url == "" { url = "http://localhost:4566" } cfg := loadCfg(ctx) snsClient := sns.NewFromConfig(cfg, func(o *sns.Options) { o.BaseEndpoint = aws.String(url) }) sqsClient := sqs.NewFromConfig(cfg, func(o *sqs.Options) { o.BaseEndpoint = aws.String(url) }) // 1. CreateTopic (virtual; idempotent). topic, err := snsClient.CreateTopic(ctx, &sns.CreateTopicInput{Name: aws.String("notify")}) if err != nil { log.Fatalf("CreateTopic: %v", err) } topicArn := aws.ToString(topic.TopicArn) fmt.Printf("CreateTopic: %s\n", topicArn) // 2. Subscribe an SQS queue — auto-confirmed immediately. q, err := sqsClient.CreateQueue(ctx, &sqs.CreateQueueInput{QueueName: aws.String("notify-q")}) if err != nil { log.Fatalf("CreateQueue: %v", err) } queueURL := aws.ToString(q.QueueUrl) attrs, err := sqsClient.GetQueueAttributes(ctx, &sqs.GetQueueAttributesInput{ QueueUrl: aws.String(queueURL), AttributeNames: []sqstypes.QueueAttributeName{sqstypes.QueueAttributeNameQueueArn}, }) if err != nil { log.Fatalf("GetQueueAttributes: %v", err) } if _, err := snsClient.Subscribe(ctx, &sns.SubscribeInput{ TopicArn: aws.String(topicArn), Protocol: aws.String("sqs"), Endpoint: aws.String(attrs.Attributes[string(sqstypes.QueueAttributeNameQueueArn)]), ReturnSubscriptionArn: true, }); err != nil { log.Fatalf("Subscribe: %v", err) } fmt.Println("Subscribe(sqs): auto-confirmed") // 3. Publish — fans out to the subscribed queue. pub, err := snsClient.Publish(ctx, &sns.PublishInput{ TopicArn: aws.String(topicArn), Message: aws.String("hello fan-out"), }) if err != nil { log.Fatalf("Publish: %v", err) } fmt.Printf("Publish: MessageId=%s\n", aws.ToString(pub.MessageId)) // The queue receives the SNS Notification envelope (default delivery). recv, err := sqsClient.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{ QueueUrl: aws.String(queueURL), WaitTimeSeconds: 5, }) if err != nil { log.Fatalf("ReceiveMessage: %v", err) } var env struct{ Type, Message, MessageId string } _ = json.Unmarshal([]byte(aws.ToString(recv.Messages[0].Body)), &env) fmt.Printf("Received: Type=%s Message=%q\n", env.Type, env.Message) } ``` ```python import json import os import boto3 def make(service: str): return boto3.client( service, endpoint_url=os.environ.get("KUBEMQ_AWS_URL", "http://localhost:4566"), region_name="us-east-1", aws_access_key_id="test", aws_secret_access_key="test", ) def main() -> None: sns = make("sns") sqs = make("sqs") # 1. CreateTopic (virtual; idempotent). topic_arn = sns.create_topic(Name="notify")["TopicArn"] print(f"CreateTopic -> {topic_arn}") # 2. Subscribe an SQS queue — auto-confirmed immediately. queue_url = sqs.create_queue(QueueName="notify-q")["QueueUrl"] queue_arn = sqs.get_queue_attributes(QueueUrl=queue_url, AttributeNames=["QueueArn"])[ "Attributes" ]["QueueArn"] sns.subscribe(TopicArn=topic_arn, Protocol="sqs", Endpoint=queue_arn, ReturnSubscriptionArn=True) print("Subscribe(sqs) -> auto-confirmed") # 3. Publish — fans out to the subscribed queue (one MessageId). pub = sns.publish(TopicArn=topic_arn, Message="hello fan-out") print(f"Publish -> MessageId={pub['MessageId']}") # The queue receives the SNS Notification envelope (default delivery). recv = sqs.receive_message(QueueUrl=queue_url, WaitTimeSeconds=5) env = json.loads(recv["Messages"][0]["Body"]) print(f"Received -> Type={env['Type']} Message={env['Message']!r}") if __name__ == "__main__": main() ``` ```java import java.net.URI; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.sns.SnsClient; import software.amazon.awssdk.services.sqs.SqsClient; import software.amazon.awssdk.services.sqs.model.QueueAttributeName; public final class Main { public static void main(String[] args) { String url = System.getenv().getOrDefault("KUBEMQ_AWS_URL", "http://localhost:4566"); StaticCredentialsProvider creds = StaticCredentialsProvider.create( AwsBasicCredentials.create("test", "test")); try (SnsClient sns = SnsClient.builder() .endpointOverride(URI.create(url)).region(Region.US_EAST_1) .credentialsProvider(creds).build(); SqsClient sqs = SqsClient.builder() .endpointOverride(URI.create(url)).region(Region.US_EAST_1) .credentialsProvider(creds).build()) { // 1. CreateTopic (virtual; idempotent). String topicArn = sns.createTopic(b -> b.name("notify")).topicArn(); System.out.println("CreateTopic -> " + topicArn); // 2. Subscribe an SQS queue — auto-confirmed immediately. String queueUrl = sqs.createQueue(b -> b.queueName("notify-q")).queueUrl(); String queueArn = sqs.getQueueAttributes(b -> b .queueUrl(queueUrl) .attributeNames(QueueAttributeName.QUEUE_ARN)) .attributes().get(QueueAttributeName.QUEUE_ARN); sns.subscribe(b -> b .topicArn(topicArn).protocol("sqs").endpoint(queueArn) .returnSubscriptionArn(true)); System.out.println("Subscribe(sqs) -> auto-confirmed"); // 3. Publish — fans out to the subscribed queue (one MessageId). String messageId = sns.publish(b -> b.topicArn(topicArn).message("hello fan-out")) .messageId(); System.out.println("Publish -> MessageId=" + messageId); // The queue receives the SNS Notification envelope (default delivery). var recv = sqs.receiveMessage(b -> b.queueUrl(queueUrl).waitTimeSeconds(5)); System.out.println("Received -> " + recv.messages().get(0).body()); } } } ``` ```typescript import { SNSClient, CreateTopicCommand, SubscribeCommand, PublishCommand } from "@aws-sdk/client-sns"; import { SQSClient, CreateQueueCommand, GetQueueAttributesCommand, ReceiveMessageCommand, } from "@aws-sdk/client-sqs"; const url = process.env["KUBEMQ_AWS_URL"] ?? "http://localhost:4566"; const opts = { endpoint: url, region: "us-east-1", credentials: { accessKeyId: "test", secretAccessKey: "test" }, }; async function main(): Promise { const sns = new SNSClient(opts); const sqs = new SQSClient(opts); // 1. CreateTopic (virtual; idempotent). const topic = await sns.send(new CreateTopicCommand({ Name: "notify" })); const topicArn = topic.TopicArn!; console.log(`CreateTopic -> ${topicArn}`); // 2. Subscribe an SQS queue — auto-confirmed immediately. const queueUrl = (await sqs.send(new CreateQueueCommand({ QueueName: "notify-q" }))).QueueUrl!; const queueArn = ( await sqs.send(new GetQueueAttributesCommand({ QueueUrl: queueUrl, AttributeNames: ["QueueArn"] })) ).Attributes!["QueueArn"]!; await sns.send( new SubscribeCommand({ TopicArn: topicArn, Protocol: "sqs", Endpoint: queueArn, ReturnSubscriptionArn: true }), ); console.log("Subscribe(sqs) -> auto-confirmed"); // 3. Publish — fans out to the subscribed queue (one MessageId). const pub = await sns.send(new PublishCommand({ TopicArn: topicArn, Message: "hello fan-out" })); console.log(`Publish -> MessageId=${pub.MessageId}`); // The queue receives the SNS Notification envelope (default delivery). const recv = await sqs.send(new ReceiveMessageCommand({ QueueUrl: queueUrl, WaitTimeSeconds: 5 })); const env = JSON.parse(recv.Messages![0].Body!); console.log(`Received -> Type=${env.Type} Message="${env.Message}"`); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using System.Text.Json; using Amazon.Runtime; using Amazon.SimpleNotificationService; using Amazon.SimpleNotificationService.Model; using Amazon.SQS; using Amazon.SQS.Model; var url = Environment.GetEnvironmentVariable("KUBEMQ_AWS_URL") ?? "http://localhost:4566"; var creds = new BasicAWSCredentials("test", "test"); using var sns = new AmazonSimpleNotificationServiceClient(creds, new AmazonSimpleNotificationServiceConfig { ServiceURL = url, AuthenticationRegion = "us-east-1" }); using var sqs = new AmazonSQSClient(creds, new AmazonSQSConfig { ServiceURL = url, AuthenticationRegion = "us-east-1" }); // 1. CreateTopic (virtual; idempotent). var topic = await sns.CreateTopicAsync(new CreateTopicRequest { Name = "notify" }); Console.WriteLine($"CreateTopic -> {topic.TopicArn}"); // 2. Subscribe an SQS queue — auto-confirmed immediately. var queueUrl = (await sqs.CreateQueueAsync(new CreateQueueRequest { QueueName = "notify-q" })).QueueUrl; var queueArn = (await sqs.GetQueueAttributesAsync(new GetQueueAttributesRequest { QueueUrl = queueUrl, AttributeNames = ["QueueArn"], })).QueueARN; await sns.SubscribeAsync(new SubscribeRequest { TopicArn = topic.TopicArn, Protocol = "sqs", Endpoint = queueArn, ReturnSubscriptionArn = true, }); Console.WriteLine("Subscribe(sqs) -> auto-confirmed"); // 3. Publish — fans out to the subscribed queue (one MessageId). var pub = await sns.PublishAsync(new PublishRequest { TopicArn = topic.TopicArn, Message = "hello fan-out" }); Console.WriteLine($"Publish -> MessageId={pub.MessageId}"); // The queue receives the SNS Notification envelope (default delivery). var recv = await sqs.ReceiveMessageAsync(new ReceiveMessageRequest { QueueUrl = queueUrl, WaitTimeSeconds = 5 }); var env = JsonDocument.Parse(recv.Messages[0].Body).RootElement; Console.WriteLine($"Received -> Type={env.GetProperty("Type")} Message={env.GetProperty("Message")}"); ``` ```ruby # frozen_string_literal: true require "aws-sdk-sns" require "aws-sdk-sqs" require "json" # Remove the SQS-only path-rewriting plugin so requests reach the connector at "/". Aws::SQS::Client.remove_plugin(Aws::SQS::Plugins::QueueUrls) url = ENV.fetch("KUBEMQ_AWS_URL", "http://localhost:4566") opts = { endpoint: url, region: "us-east-1", access_key_id: "test", secret_access_key: "test" } sns = Aws::SNS::Client.new(opts) sqs = Aws::SQS::Client.new(opts) # 1. CreateTopic (virtual; idempotent). topic_arn = sns.create_topic(name: "notify").topic_arn puts "CreateTopic -> #{topic_arn}" # 2. Subscribe an SQS queue — auto-confirmed immediately. queue_url = sqs.create_queue(queue_name: "notify-q").queue_url queue_arn = sqs.get_queue_attributes(queue_url: queue_url, attribute_names: ["QueueArn"]) .attributes["QueueArn"] sns.subscribe(topic_arn: topic_arn, protocol: "sqs", endpoint: queue_arn, return_subscription_arn: true) puts "Subscribe(sqs) -> auto-confirmed" # 3. Publish — fans out to the subscribed queue (one MessageId). pub = sns.publish(topic_arn: topic_arn, message: "hello fan-out") puts "Publish -> MessageId=#{pub.message_id}" # The queue receives the SNS Notification envelope (default delivery). recv = sqs.receive_message(queue_url: queue_url, wait_time_seconds: 5) env = JSON.parse(recv.messages.first.body) puts "Received -> Type=#{env['Type']} Message=#{env['Message'].inspect}" ``` ```rust use aws_config::{BehaviorVersion, Region}; use aws_credential_types::Credentials; use std::error::Error; async fn config() -> aws_config::SdkConfig { let url = std::env::var("KUBEMQ_AWS_URL").unwrap_or_else(|_| "http://localhost:4566".into()); aws_config::defaults(BehaviorVersion::latest()) .region(Region::new("us-east-1")) .credentials_provider(Credentials::new("test", "test", None, None, "static")) .endpoint_url(url) .load() .await } #[tokio::main] async fn main() -> Result<(), Box> { let conf = config().await; let sns = aws_sdk_sns::Client::new(&conf); let sqs = aws_sdk_sqs::Client::new(&conf); // 1. CreateTopic (virtual; idempotent). let topic_arn = sns.create_topic().name("notify").send().await?.topic_arn.unwrap(); println!("CreateTopic -> {topic_arn}"); // 2. Subscribe an SQS queue — auto-confirmed immediately. let queue_url = sqs.create_queue().queue_name("notify-q").send().await?.queue_url.unwrap(); let queue_arn = sqs .get_queue_attributes() .queue_url(&queue_url) .attribute_names(aws_sdk_sqs::types::QueueAttributeName::QueueArn) .send() .await? .attributes .and_then(|a| a.get(&aws_sdk_sqs::types::QueueAttributeName::QueueArn).cloned()) .ok_or("no queue ARN")?; sns.subscribe() .topic_arn(&topic_arn) .protocol("sqs") .endpoint(queue_arn) .return_subscription_arn(true) .send() .await?; println!("Subscribe(sqs) -> auto-confirmed"); // 3. Publish — fans out to the subscribed queue (one MessageId). let pub_out = sns.publish().topic_arn(&topic_arn).message("hello fan-out").send().await?; println!("Publish -> MessageId={}", pub_out.message_id().unwrap_or("")); // The queue receives the SNS Notification envelope (default delivery). let recv = sqs.receive_message().queue_url(&queue_url).wait_time_seconds(5).send().await?; println!("Received -> {}", recv.messages().first().and_then(|m| m.body()).unwrap_or_default()); Ok(()) } ``` ## Subscription confirmation (HTTP/HTTPS) [#subscription-confirmation-httphttps] An `sqs` subscription auto-confirms immediately. An `http`/`https` subscription goes **pending** with a 48-hour token: the connector POSTs a `SubscriptionConfirmation` envelope (carrying a `SubscribeURL` and `Token`) to the endpoint, and the subscriber activates it by calling `ConfirmSubscription` — the **only SigV4-exempt action**, so the embedded `SubscribeURL` works as an unsigned GET. Expired pending subscriptions are swept hourly. See [fan-out](/connectors/aws/how-to/fan-out) for the webhook delivery walkthrough. ## Filtering (MessageAttributes scope only) [#filtering-messageattributes-scope-only] A subscription `FilterPolicy` decides which publishes it receives, using all eight AWS operators (exact, prefix, suffix, anything-but, numeric comparison/range, exists, CIDR). Keys are ANDed and values ORed; up to 5 keys / 150 combinations. ```python # Deliver only publishes whose "eventType" message attribute is "order". sub = sns.subscribe( TopicArn=topic_arn, Protocol="sqs", Endpoint=queue_arn, Attributes={ "FilterPolicy": json.dumps({"eventType": ["order"]}), "FilterPolicyScope": "MessageAttributes", }, ReturnSubscriptionArn=True, ) # A publish with eventType=order is delivered; eventType=metric is suppressed. ``` **Only `MessageAttributes`-scope filtering is supported.** Setting `FilterPolicyScope = MessageBody` is rejected with `InvalidParameter` — you can only filter on a publish's `MessageAttributes`, never on its body. ## PublishBatch [#publishbatch] `PublishBatch` sends up to 10 entries in one call and returns per-entry success/failure, mirroring `SendMessageBatch` semantics for SQS. ```python batch = sns.publish_batch( TopicArn=topic_arn, PublishBatchRequestEntries=[ {"Id": "1", "Message": "event-1"}, {"Id": "2", "Message": "event-2"}, ], ) print(f"PublishBatch -> {len(batch.get('Successful', []))} Successful, " f"{len(batch.get('Failed', []))} Failed") ``` ## FIFO topics [#fifo-topics] A `.fifo` topic restricts subscriptions to the `sqs` protocol onto `.fifo` queues, requires `MessageGroupId` on every publish, and propagates the group id and a 20-digit `SequenceNumber` into the FIFO queue message. Topic-level `ContentBasedDeduplication` is unsupported — pass an explicit `MessageDeduplicationId`. A standard topic **rejects** `MessageGroupId`, and a FIFO topic **rejects** `http`/`https` subscriptions. ```python # A FIFO topic fanning out to a FIFO queue; group order is preserved. topic_arn = sns.create_topic(Name="orders.fifo", Attributes={"FifoTopic": "true"})["TopicArn"] queue_url = sqs.create_queue( QueueName="orders-q.fifo", Attributes={"FifoQueue": "true", "ContentBasedDeduplication": "false"}, )["QueueUrl"] queue_arn = sqs.get_queue_attributes(QueueUrl=queue_url, AttributeNames=["QueueArn"])["Attributes"]["QueueArn"] sns.subscribe(TopicArn=topic_arn, Protocol="sqs", Endpoint=queue_arn, ReturnSubscriptionArn=True) for i in range(1, 4): sns.publish( TopicArn=topic_arn, Message=f"order-{i}", MessageGroupId="tenant-1", MessageDeduplicationId=f"dedup-{i}", ) # orders-q.fifo receives order-1, order-2, order-3 in group order, each carrying # MessageGroupId and a 20-digit SequenceNumber. ``` ## Delivery, retry, and unsigned notifications [#delivery-retry-and-unsigned-notifications] HTTP/HTTPS deliveries run through an in-memory engine (8 workers, a bounded job queue, a default 51-attempt retry schedule overridable by a stored `DeliveryPolicy`, a per-endpoint circuit breaker that opens after 5 consecutive failures, and a redrive to the subscription's `RedrivePolicy` DLQ on exhaustion). See the [reliability guide](/connectors/aws/how-to/reliability). **SNS HTTP delivery state is in-memory on the publishing node.** A node restart loses pending retries, and the bounded job queue drops on overflow (recorded as a metric). This is part of the node-local / sticky-load-balancer family of caveats. **Delivered SNS notifications are unsigned.** The `Notification` envelope's `Signature` and `SigningCertURL` fields are present but **empty**, so a webhook cannot verify the message signature. Do not rely on SNS signature verification on the receiving side. ## What SNS topics do not have [#what-sns-topics-do-not-have] The connector implements 17 SNS actions but is deliberately scoped: * **No RPC.** SNS is publish/subscribe, not request/reply. * **No `email` / `sms` / `lambda` subscription protocols** — only `sqs`, `http`, and `https`; other protocols are rejected with `InvalidParameter`. * **No KMS / server-side encryption (SSE).** * **No `MessageBody`-scope filtering** — only `MessageAttributes` scope. * **Topic-level `ContentBasedDeduplication` is unsupported** on FIFO topics — pass an explicit `MessageDeduplicationId`. * **Body size limit ≤ 256 KiB**; **region not enforced**; **single AccountId**. ## Related [#related] # SQS queues and consumers (/connectors/aws/how-to/sqs-queues-and-consumers) This guide covers the SQS surface end to end: queue lifecycle, send/receive/delete, visibility timeouts, the in-flight cap, receipt handles, long polling, batch operations, message attributes, FIFO queues, and DLQ/redrive. Every SQS queue is a native KubeMQ **Queue** channel `sqs.{name}` (see [Channel mapping](/connectors/aws/reference/channel-mapping)). The SQS surface ships **18 actions** (see [Capabilities](/connectors/aws/reference/capabilities)). ## Queue lifecycle [#queue-lifecycle] * `CreateQueue` — idempotent on an existing name with the same attributes; a **FIFO** queue uses the `.fifo` name suffix. Re-creating the same name with **different** attributes returns `QueueNameExists`. * `GetQueueUrl` — returns the path-style URL `{scheme}://{host}/{AccountId}/{name}`. * `ListQueues` — pagination plus `QueueNamePrefix`; results are **not** authorization-filtered. * `DeleteQueue` — removes the registry record, best-effort broker purge, drops node-local in-flight. * `PurgeQueue` — an `AckAllQueueMessages`; a **60 s cooldown** applies (a second purge within the window returns `PurgeQueueInProgress`). * `GetQueueAttributes` / `SetQueueAttributes` — see below. * `TagQueue` / `UntagQueue` / `ListQueueTags` — ≤ 50 tags. * `ListDeadLetterSourceQueues` — reverse-resolves queues whose `RedrivePolicy` names this queue. **The registry is authoritative.** Only queues created via the AWS API are visible; operating on a native `sqs.foo` channel that was never `CreateQueue`d returns `NonExistentQueue`. ## Queue attributes [#queue-attributes] **Writable** (`SetQueueAttributes` is a partial-update overlay; it is **not** retroactive to messages already in the queue): | Attribute | Range | | ---------------------------------- | ---------------------------------------------------------------------- | | `DelaySeconds` | 0–900 | | `MaximumMessageSize` | 1024–262144 | | `MessageRetentionPeriod` | 60–1209600 | | `VisibilityTimeout` | 0–43200 | | `ReceiveMessageWaitTimeSeconds` | 0–20 | | `RedrivePolicy` | raw JSON `{deadLetterTargetArn, maxReceiveCount}` (registry-validated) | | `FifoQueue` (create-only) | must match the `.fifo` suffix | | `ContentBasedDeduplication` (FIFO) | bool | | `DeduplicationScope` (FIFO) | `queue` \| `messageGroup` | An unknown attribute name, or a KMS/SSE attribute (`KmsMasterKeyId`, `Policy`, …), on a `Set` returns `InvalidAttributeName`. **Read-only on `Get`:** `QueueArn`, `ApproximateNumberOfMessages` (broker stats, 2 s TTL), `ApproximateNumberOfMessagesNotVisible` (node-local in-flight), `ApproximateNumberOfMessagesDelayed` (**always `"0"`**), `CreatedTimestamp`, `LastModifiedTimestamp`; FIFO adds `FifoQueue` / `ContentBasedDeduplication` / `DeduplicationScope`. **`ApproximateNumberOfMessagesDelayed` is always `"0"`.** The connector does not track delayed-message counts. See [Capabilities](/connectors/aws/reference/capabilities). ## Send / receive / delete [#send--receive--delete] The round-trip is three actions: 1. `SendMessage` — returns `MessageId`, `MD5OfBody`, and (if attributes are present) `MD5OfMessageAttributes`. 2. `ReceiveMessage` — returns the message body plus a **receipt handle**. 3. `DeleteMessage(receiptHandle)` — acks the message off the queue (`AckRange`). It is **idempotent**: an unknown receipt handle returns success. ### Visibility timeout [#visibility-timeout] A received message is hidden for a visibility window before it becomes receivable again. Precedence: per-request `VisibilityTimeout` (0–43200) **>** the queue default **>** 30 s. `ChangeMessageVisibility(timeout>0)` moves the deadline; `ChangeMessageVisibility(0)` NAcks the message so it becomes visible again at the **tail**. A 250 ms sweeper NAcks expired in-flight entries back to the tail, and `ApproximateReceiveCount` increments on re-receive. ### Receipt handles are node-local [#receipt-handles-are-node-local] A receipt handle is an opaque `base64url(json)` token carrying `{version, node, txnId, queue, seq, receivedMs, salt}`. Decoding validates `version==1` **and** the **node** match. **Receipt handles are node-local → a sticky LB is required in clusters.** A receipt handle minted on one node is rejected on another (`ReceiptHandleIsInvalid`). In-flight tracking and SNS delivery state are node-local too, so cluster deployments need a **sticky load balancer** (session affinity). Single-node deployments are unaffected. See [Connectivity and security](/connectors/aws/how-to/connectivity-and-security) and [Migration from AWS](/connectors/aws/reference/migration-from-aws). ### In-flight cap [#in-flight-cap] Received-but-not-deleted messages count against a per-queue / per-node cap, `MaxInflightPerQueue` (default 20,000). Exceeding it returns `OverLimit`. ## Long polling [#long-polling] * `WaitTimeSeconds` is 0–20; `MaxNumberOfMessages` is 1–10. * A parked long-poll slot pool (`MaxConcurrentPolls`, default 1024); when it is exhausted a request **degrades to a short poll** (it does not error). * Wire waits are chunked into ≤ 2 s sub-Gets for shutdown responsiveness. **An empty-queue short poll has a \~1 s latency floor.** Because the broker wait granularity is integer seconds (1 s minimum), a `ReceiveMessage` on an **empty** queue returns within \~1 s, not instantly. Queues with available messages respond at once. ## Batch operations [#batch-operations] `SendMessageBatch` / `DeleteMessageBatch` / `ChangeMessageVisibilityBatch` take ≤ 10 entries. `SendMessageBatch` returns per-entry `Successful[]` / `Failed[]`: * a single oversize entry → that entry is `Failed` with `InvalidParameterValue` (SenderFault), the rest `Successful`; * an **aggregate** body+attributes over 262,144 bytes → the **whole batch** is rejected with `BatchRequestTooLong`. ## Message attributes [#message-attributes] * Up to 10 message attributes; the name is ≤ 256 chars with no `AWS.` / `Amazon.` prefix; the `DataType` is `String` / `Number` / `Binary` (plus custom subtypes like `String.x`). They round-trip losslessly through the tag codec `sqs_attr_{Name} = {DataType}|{value}` (Binary is base64). * **Message system attributes:** only `AWSTraceHeader` (DataType `String`) is accepted; anything else returns `InvalidParameterValue`. It is stored as `sqs_trace_header`. See [Channel mapping](/connectors/aws/reference/channel-mapping) for the full mapping and the MD5 algorithms. ## FIFO queues [#fifo-queues] A `.fifo` suffix makes a queue FIFO (`FifoQueue` is immutable post-create): * **`MessageGroupId` is required** on a FIFO send (1–128 printable ASCII characters) — omitting it returns `InvalidParameterValue`; * per-message **`DelaySeconds` is rejected** → `InvalidParameterValue`; * `ReceiveRequestAttemptId` is accepted and ignored; * each group maps to its own channel `sqs.{name}.fifo.g.{enc(group)}`; * **deduplication** uses an explicit `MessageDeduplicationId`, or the SHA-256 of the body when `ContentBasedDeduplication` is on; a duplicate within the **5-minute** LRU window returns the **original** `MessageId` / `SequenceNumber` **without re-publishing**; * **per-group ordering** allows at most one un-acked downstream `Get` per group; `ReceiveMessage` drains groups round-robin, ≤ 10 total. **`SequenceNumber` differs between send and receive.** The 20-digit zero-padded `SequenceNumber` is the broker **send-timestamp (UnixNano)** on send and the **true broker sequence** on receive. It is still strictly increasing per group for serialized sends. See [Channel mapping](/connectors/aws/reference/channel-mapping). Full FIFO reliability detail is in [Reliability](/connectors/aws/how-to/reliability). ## DLQ / redrive [#dlq--redrive] Set a queue `RedrivePolicy` of `{deadLetterTargetArn, maxReceiveCount}`. The connector stamps each message with `MaxReceiveCount` / `MaxReceiveQueue=sqs.{dlq}` at send; the message broker moves the message to the DLQ when the receive count **exceeds** `maxReceiveCount`, surfacing `DeadLetterQueueSourceArn` on the redriven message. `ListDeadLetterSourceQueues` reverse-resolves. See [Reliability](/connectors/aws/how-to/reliability). ## At-least-once and graceful shutdown [#at-least-once-and-graceful-shutdown] Unacked deliveries are NAcked back to the queue tail by the 250 ms sweeper or on graceful shutdown (≤ 10 s); the registry survives restart (a shared `StorePath`). **Exactly-once is not provided** — design consumers to be idempotent. ## Error quick reference [#error-quick-reference] | Trigger | AWS error code | | ---------------------------------------------------------------------------- | ------------------------ | | Operate on a queue not in the registry | `NonExistentQueue` | | `CreateQueue` same name + different attributes | `QueueNameExists` | | `PurgeQueue` within the 60 s cooldown | `PurgeQueueInProgress` | | Unknown / KMS attribute on `SetQueueAttributes` | `InvalidAttributeName` | | Oversize batch entry / bad attribute / non-`AWSTraceHeader` system attribute | `InvalidParameterValue` | | `SendMessageBatch` aggregate over 262,144 B | `BatchRequestTooLong` | | Receipt handle from another node / malformed | `ReceiptHandleIsInvalid` | | In-flight over `MaxInflightPerQueue` | `OverLimit` | | FIFO send without `MessageGroupId` / with per-message `DelaySeconds` | `InvalidParameterValue` | ## Related [#related] # SQS Queues (/connectors/aws/how-to/sqs-queues) An **SQS queue** is point-to-point messaging: a producer sends messages, one or more consumers receive them, and each received message is **hidden** (its visibility window) until the consumer deletes it or the timeout expires and it is redelivered. The AWS connector maps this directly onto the KubeMQ **Queues** primitive — SQS queue `orders` becomes KubeMQ channel `sqs.orders`. Your AWS SDK code does not change; you only override the endpoint to point at the connector. ## Overview [#overview] `CreateQueue` registers the queue in the connector's registry and binds it to a KubeMQ Queue channel. `SendMessage` writes to that channel; `ReceiveMessage` returns the message plus an opaque **receipt handle** and hides it for the visibility window; `DeleteMessage(receiptHandle)` acks it off the queue. If a consumer never deletes, a sweeper NAcks the message back to the **tail** at visibility expiry and `ApproximateReceiveCount` increments. | SQS operation | KubeMQ mapping | Notes | | ----------------------------- | ------------------------------------- | ---------------------------------------------------------- | | `CreateQueue("orders")` | Register channel `sqs.orders` | `.fifo` suffix makes it FIFO | | `SendMessage` | `SendQueueMessage` | Returns `MessageId`, `MD5OfBody`, `MD5OfMessageAttributes` | | `SendMessageBatch` | Batched send (≤ 10 entries) | Per-entry success/failure | | `ReceiveMessage` | Credit-driven `Get` long-poll | Receipt handle; hidden for the visibility window | | `DeleteMessage` | `AckRange` — message removed | By receipt handle | | `DeleteMessageBatch` | Batched ack (≤ 10 entries) | Per-entry success/failure | | `ChangeMessageVisibility` | Extend / shorten the hidden window | Per-message override | | visibility expiry (no delete) | `NAckRange` — redelivered to the tail | `ApproximateReceiveCount` increments | Long polling (`WaitTimeSeconds` up to 20) waits for messages; `MaxNumberOfMessages` pulls up to 10 at once. Message attributes (`String` / `Number` / `Binary`, ≤ 10) round-trip losslessly, and the only accepted system attribute is `AWSTraceHeader`. ## How it works [#how-it-works] A producer sends to a queue; the connector writes each message to the backing KubeMQ Queue channel. A consumer receives a message (which is then hidden for its visibility window) and deletes it by receipt handle to ack it off the queue. *A received message is hidden for its visibility window; deleting it by receipt handle acks it off the queue, while letting the window expire NAcks it back to the tail for redelivery.* ## Send and receive [#send-and-receive] The full lifecycle: `CreateQueue` → `GetQueueUrl` → `SendMessage` → `ReceiveMessage` → `DeleteMessage`. Each client overrides only the endpoint (`KUBEMQ_AWS_URL`, default `http://localhost:4566`) and supplies **dummy static credentials** — the connector's default accept-any mode does not cryptographically verify the SigV4 signature, but the SDK must still form a syntactically valid signed request. The region is not enforced. ```go package main import ( "context" "fmt" "log" "os" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/sqs" ) func sqsClient(ctx context.Context) *sqs.Client { url := "http://localhost:4566" if v := os.Getenv("KUBEMQ_AWS_URL"); v != "" { url = v } // Dummy static credentials are mandatory even in accept-any mode: the SDK // must form a valid SigV4 request. The region is not enforced. cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion("us-east-1"), config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), ) if err != nil { log.Fatalf("load config: %v", err) } return sqs.NewFromConfig(cfg, func(o *sqs.Options) { o.BaseEndpoint = aws.String(url) }) } func main() { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() client := sqsClient(ctx) // 1. CreateQueue → registers channel sqs.orders (idempotent on identical attrs). created, err := client.CreateQueue(ctx, &sqs.CreateQueueInput{QueueName: aws.String("orders")}) if err != nil { log.Fatalf("CreateQueue: %v", err) } queueURL := aws.ToString(created.QueueUrl) fmt.Printf("CreateQueue: %s\n", queueURL) // 2. SendMessage. sent, err := client.SendMessage(ctx, &sqs.SendMessageInput{ QueueUrl: aws.String(queueURL), MessageBody: aws.String("order #4242 — 3x widget"), }) if err != nil { log.Fatalf("SendMessage: %v", err) } fmt.Printf("SendMessage: MessageId=%s MD5OfBody=%s\n", aws.ToString(sent.MessageId), aws.ToString(sent.MD5OfMessageBody)) // 3. ReceiveMessage (long-poll a few seconds so the message is ready). recv, err := client.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{ QueueUrl: aws.String(queueURL), MaxNumberOfMessages: 1, WaitTimeSeconds: 5, }) if err != nil { log.Fatalf("ReceiveMessage: %v", err) } if len(recv.Messages) != 1 { log.Fatalf("expected 1 message, got %d", len(recv.Messages)) } msg := recv.Messages[0] fmt.Printf("ReceiveMessage: body=%q\n", aws.ToString(msg.Body)) // 4. DeleteMessage by receipt handle (acks the message off the queue). if _, err := client.DeleteMessage(ctx, &sqs.DeleteMessageInput{ QueueUrl: aws.String(queueURL), ReceiptHandle: msg.ReceiptHandle, }); err != nil { log.Fatalf("DeleteMessage: %v", err) } fmt.Println("DeleteMessage: ok (acked by receipt handle)") } ``` ```python import os import boto3 def sqs_client(): url = os.environ.get("KUBEMQ_AWS_URL", "http://localhost:4566") # Dummy credentials are mandatory even in accept-any mode (the SDK must form # a valid SigV4 request); the region is not enforced. return boto3.client( "sqs", endpoint_url=url, region_name="us-east-1", aws_access_key_id="test", aws_secret_access_key="test", ) def main() -> None: sqs = sqs_client() # 1. CreateQueue → registers channel sqs.orders. queue_url = sqs.create_queue(QueueName="orders")["QueueUrl"] print(f"CreateQueue -> {queue_url}") # 2. SendMessage. body = "hello from boto3" send = sqs.send_message(QueueUrl=queue_url, MessageBody=body) print(f"SendMessage -> MessageId={send['MessageId']} MD5OfBody={send['MD5OfMessageBody']}") # 3. ReceiveMessage (long-poll so the message is ready). recv = sqs.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=1, WaitTimeSeconds=5) messages = recv.get("Messages", []) if len(messages) != 1: raise SystemExit(f"expected 1 message, got {len(messages)}") msg = messages[0] print(f"ReceiveMessage-> body={msg['Body']!r}") # 4. DeleteMessage by receipt handle (acks it off the queue). sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=msg["ReceiptHandle"]) print("DeleteMessage -> acknowledged by receipt handle") if __name__ == "__main__": main() ``` ```java import java.net.URI; import java.util.List; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.sqs.SqsClient; import software.amazon.awssdk.services.sqs.model.Message; import software.amazon.awssdk.services.sqs.model.ReceiveMessageResponse; import software.amazon.awssdk.services.sqs.model.SendMessageResponse; public final class Main { public static void main(String[] args) { String url = System.getenv().getOrDefault("KUBEMQ_AWS_URL", "http://localhost:4566"); // Dummy credentials are mandatory even in accept-any mode (a valid SigV4 // request must be formed); the region is not enforced. try (SqsClient sqs = SqsClient.builder() .endpointOverride(URI.create(url)) .region(Region.US_EAST_1) .credentialsProvider(StaticCredentialsProvider.create( AwsBasicCredentials.create("test", "test"))) .build()) { // 1. CreateQueue → registers channel sqs.orders. String queueUrl = sqs.createQueue(b -> b.queueName("orders")).queueUrl(); System.out.println("CreateQueue -> " + queueUrl); // 2. SendMessage. SendMessageResponse sent = sqs.sendMessage(b -> b .queueUrl(queueUrl) .messageBody("order #1001")); System.out.println("SendMessage -> MessageId=" + sent.messageId() + " MD5OfBody=" + sent.md5OfMessageBody()); // 3. ReceiveMessage (long-poll so the message is ready). ReceiveMessageResponse recv = sqs.receiveMessage(b -> b .queueUrl(queueUrl) .maxNumberOfMessages(1) .waitTimeSeconds(5)); List messages = recv.messages(); if (messages.size() != 1) { throw new IllegalStateException("expected 1 message, got " + messages.size()); } Message msg = messages.get(0); System.out.println("ReceiveMessage-> body='" + msg.body() + "'"); // 4. DeleteMessage by receipt handle (acks it off the queue). sqs.deleteMessage(b -> b.queueUrl(queueUrl).receiptHandle(msg.receiptHandle())); System.out.println("DeleteMessage -> deleted by receipt handle"); } } } ``` ```typescript import { SQSClient, CreateQueueCommand, SendMessageCommand, ReceiveMessageCommand, DeleteMessageCommand, } from "@aws-sdk/client-sqs"; function sqsClient(): SQSClient { // Dummy credentials are mandatory even in accept-any mode (the SDK must form a // valid SigV4 request); the region is not enforced. return new SQSClient({ endpoint: process.env["KUBEMQ_AWS_URL"] ?? "http://localhost:4566", region: "us-east-1", credentials: { accessKeyId: "test", secretAccessKey: "test" }, }); } async function main(): Promise { const sqs = sqsClient(); // 1. CreateQueue → registers channel sqs.orders. const created = await sqs.send(new CreateQueueCommand({ QueueName: "orders" })); const queueUrl = created.QueueUrl!; console.log(`CreateQueue -> ${queueUrl}`); // 2. SendMessage. const sent = await sqs.send( new SendMessageCommand({ QueueUrl: queueUrl, MessageBody: "order #42: 3x widgets" }), ); console.log(`SendMessage -> MessageId=${sent.MessageId} MD5OfBody=${sent.MD5OfMessageBody}`); // 3. ReceiveMessage (long-poll so the message is ready). const recv = await sqs.send( new ReceiveMessageCommand({ QueueUrl: queueUrl, MaxNumberOfMessages: 1, WaitTimeSeconds: 5 }), ); const msg = recv.Messages?.[0]; if (!msg) throw new Error("expected 1 message, got 0"); console.log(`ReceiveMessage -> body="${msg.Body}"`); // 4. DeleteMessage by receipt handle (acks it off the queue). await sqs.send(new DeleteMessageCommand({ QueueUrl: queueUrl, ReceiptHandle: msg.ReceiptHandle! })); console.log("DeleteMessage -> acked by receipt handle"); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using Amazon.Runtime; using Amazon.SQS; using Amazon.SQS.Model; // Dummy credentials are mandatory even in accept-any mode (a valid SigV4 request // must be formed); the region is not enforced. ServiceURL carries the full // http://host:port, so leave UseHttp=false to avoid the port being dropped. var config = new AmazonSQSConfig { ServiceURL = Environment.GetEnvironmentVariable("KUBEMQ_AWS_URL") ?? "http://localhost:4566", AuthenticationRegion = "us-east-1", }; using var sqs = new AmazonSQSClient(new BasicAWSCredentials("test", "test"), config); // 1. CreateQueue → registers channel sqs.orders. var created = await sqs.CreateQueueAsync(new CreateQueueRequest { QueueName = "orders" }); Console.WriteLine($"CreateQueue -> {created.QueueUrl}"); // 2. SendMessage. var sent = await sqs.SendMessageAsync(new SendMessageRequest { QueueUrl = created.QueueUrl, MessageBody = "order #1001", }); Console.WriteLine($"SendMessage -> MessageId={sent.MessageId} MD5OfBody={sent.MD5OfMessageBody}"); // 3. ReceiveMessage (long-poll so the message is ready). var recv = await sqs.ReceiveMessageAsync(new ReceiveMessageRequest { QueueUrl = created.QueueUrl, MaxNumberOfMessages = 1, WaitTimeSeconds = 5, }); if (recv.Messages.Count != 1) throw new InvalidOperationException($"expected 1 message, got {recv.Messages.Count}"); var msg = recv.Messages[0]; Console.WriteLine($"ReceiveMessage -> body='{msg.Body}'"); // 4. DeleteMessage by receipt handle (acks it off the queue). await sqs.DeleteMessageAsync(new DeleteMessageRequest { QueueUrl = created.QueueUrl, ReceiptHandle = msg.ReceiptHandle, }); Console.WriteLine("DeleteMessage -> deleted by receipt handle"); ``` ```ruby # frozen_string_literal: true require "aws-sdk-sqs" url = ENV.fetch("KUBEMQ_AWS_URL", "http://localhost:4566") # Dummy credentials are mandatory even in accept-any mode (the SDK must form a # valid SigV4 request); the region is not enforced. sqs = Aws::SQS::Client.new( endpoint: url, region: "us-east-1", access_key_id: "test", secret_access_key: "test" ) # 1. CreateQueue → registers channel sqs.orders. queue_url = sqs.create_queue(queue_name: "orders").queue_url puts "CreateQueue -> #{queue_url}" # 2. SendMessage. send_resp = sqs.send_message(queue_url: queue_url, message_body: "order #1138 — 2 widgets") puts "SendMessage -> MessageId=#{send_resp.message_id} MD5OfBody=#{send_resp.md5_of_message_body}" # 3. ReceiveMessage (long-poll so the message is ready). recv = sqs.receive_message(queue_url: queue_url, max_number_of_messages: 1, wait_time_seconds: 5) raise "no message received" if recv.messages.empty? msg = recv.messages.first puts "ReceiveMessage-> Body=#{msg.body.inspect}" # 4. DeleteMessage by receipt handle (acks it off the queue). sqs.delete_message(queue_url: queue_url, receipt_handle: msg.receipt_handle) puts "DeleteMessage -> ok (acked)" ``` The AWS SDK for Ruby ships an SQS-only plugin that rewrites the request path to the full queue URL. Because the connector dispatches only on `POST /` and `GET /` (it carries the action in the request, not the path), remove that plugin once at startup so send/receive/delete reach the connector: `Aws::SQS::Client.remove_plugin(Aws::SQS::Plugins::QueueUrls)`. `CreateQueue`/`GetQueueUrl` carry no queue URL and work either way. ```rust use aws_config::{BehaviorVersion, Region}; use aws_credential_types::Credentials; use std::error::Error; async fn sqs_client() -> aws_sdk_sqs::Client { let url = std::env::var("KUBEMQ_AWS_URL").unwrap_or_else(|_| "http://localhost:4566".into()); // Dummy credentials are mandatory even in accept-any mode (a valid SigV4 // request must be formed); the region is not enforced. let conf = aws_config::defaults(BehaviorVersion::latest()) .region(Region::new("us-east-1")) .credentials_provider(Credentials::new("test", "test", None, None, "static")) .endpoint_url(url) .load() .await; aws_sdk_sqs::Client::new(&conf) } #[tokio::main] async fn main() -> Result<(), Box> { let sqs = sqs_client().await; // 1. CreateQueue → registers channel sqs.orders. let url = sqs .create_queue() .queue_name("orders") .send() .await? .queue_url .ok_or("CreateQueue returned no URL")?; println!("CreateQueue -> {url}"); // 2. SendMessage. let sent = sqs.send_message().queue_url(&url).message_body("order-42").send().await?; println!( "SendMessage -> MessageId={} MD5OfBody={}", sent.message_id().unwrap_or(""), sent.md5_of_message_body().unwrap_or("") ); // 3. ReceiveMessage (long-poll so the message is ready). let recv = sqs .receive_message() .queue_url(&url) .max_number_of_messages(1) .wait_time_seconds(5) .send() .await?; let messages = recv.messages(); let msg = messages.first().ok_or("expected 1 message, got 0")?; println!("ReceiveMessage-> body='{}'", msg.body().unwrap_or_default()); // 4. DeleteMessage by receipt handle (acks it off the queue). let handle = msg.receipt_handle().ok_or("message has no receipt handle")?; sqs.delete_message().queue_url(&url).receipt_handle(handle).send().await?; println!("DeleteMessage -> ok (acked)"); Ok(()) } ``` **Receipt handles and in-flight messages are node-local.** A receipt handle minted on one node is rejected on another (`ReceiptHandleIsInvalid`). In a clustered deployment, place a **sticky load balancer** (session affinity) in front of the connector so a consumer's receive, delete, and visibility-change calls all land on the same node. Single-node deployments are unaffected. ## Batch and message attributes [#batch-and-message-attributes] `SendMessageBatch` and `DeleteMessageBatch` take up to 10 entries and return per-entry success/failure, so an oversize or malformed entry fails on its own without failing the whole batch. Combine batch sends with long polling (`WaitTimeSeconds=20`, `MaxNumberOfMessages=10`) to drain efficiently. ```python import os import boto3 sqs = boto3.client( "sqs", endpoint_url=os.environ.get("KUBEMQ_AWS_URL", "http://localhost:4566"), region_name="us-east-1", aws_access_key_id="test", aws_secret_access_key="test", ) url = sqs.create_queue(QueueName="events")["QueueUrl"] # Send a batch of 9 entries. Batch results are per-entry: a single bad entry # comes back in Failed without failing the others. entries = [{"Id": f"m{i}", "MessageBody": f"event-{i}"} for i in range(9)] batch = sqs.send_message_batch(QueueUrl=url, Entries=entries) print(f"SendMessageBatch -> {len(batch.get('Successful', []))} Successful, " f"{len(batch.get('Failed', []))} Failed") # Long-poll-drain up to 10 at a time. drained: list[dict] = [] while len(drained) < 9: recv = sqs.receive_message(QueueUrl=url, MaxNumberOfMessages=10, WaitTimeSeconds=20) msgs = recv.get("Messages", []) if not msgs: break drained.extend(msgs) # Delete them in one batch. del_entries = [{"Id": f"d{i}", "ReceiptHandle": m["ReceiptHandle"]} for i, m in enumerate(drained)] result = sqs.delete_message_batch(QueueUrl=url, Entries=del_entries) print(f"DeleteMessageBatch -> {len(result.get('Successful', []))} deleted") ``` Typed message attributes (`String` / `Number` / `Binary`, up to 10) round-trip losslessly, and `MD5OfMessageAttributes` is returned alongside `MD5OfBody`. The only accepted system attribute is `AWSTraceHeader`; other system attributes are rejected. The full attribute-to-tag mapping is in the [channel mapping reference](/connectors/aws/reference/channel-mapping). **Empty-queue short polls have a \~1 s latency floor**, and `ApproximateNumberOfMessagesDelayed` is always `"0"` (the connector does not track delayed counts). Use `WaitTimeSeconds` for long polling rather than tight short-poll loops. ## Visibility timeout and redelivery [#visibility-timeout-and-redelivery] A received message is hidden for the visibility window (per-request `VisibilityTimeout`, else the queue default, else 30 s). If you do not delete it before the window expires, a sweeper NAcks it back to the **tail** and `ApproximateReceiveCount` increments — the message is redelivered. `ChangeMessageVisibility` extends or shortens the window for an in-flight message. ```python import time # Receive with a 1-second visibility window, do NOT delete, then receive again: # the message is redelivered and ApproximateReceiveCount goes from 1 to 2. first = sqs.receive_message( QueueUrl=url, MaxNumberOfMessages=1, VisibilityTimeout=1, AttributeNames=["ApproximateReceiveCount"], )["Messages"][0] print(f"receive #1: count={first['Attributes']['ApproximateReceiveCount']}") time.sleep(2) # let the visibility window expire → redelivery second = sqs.receive_message( QueueUrl=url, MaxNumberOfMessages=1, WaitTimeSeconds=5, AttributeNames=["ApproximateReceiveCount"], )["Messages"][0] print(f"receive #2: count={second['Attributes']['ApproximateReceiveCount']}") # → 2 (redelivered) ``` ## FIFO queues, ordering, and dedup [#fifo-queues-ordering-and-dedup] A queue whose name ends in `.fifo` is a FIFO queue. `MessageGroupId` is **required** on send and preserves per-group order; `MessageDeduplicationId` (or content-based dedup) suppresses duplicates within a 5-minute window. Each FIFO group maps to its own per-group channel `sqs.{name}.fifo.g.{enc(group)}`, where `enc` percent-encodes bytes outside `[a-zA-Z0-9_-]`. Per-message `DelaySeconds` is **rejected** on FIFO queues. ```python import os import boto3 from botocore.exceptions import ClientError sqs = boto3.client( "sqs", endpoint_url=os.environ.get("KUBEMQ_AWS_URL", "http://localhost:4566"), region_name="us-east-1", aws_access_key_id="test", aws_secret_access_key="test", ) # A .fifo queue with content-based dedup. MessageGroupId is required on send. url = sqs.create_queue( QueueName="tasks.fifo", Attributes={"FifoQueue": "true", "ContentBasedDeduplication": "true"}, )["QueueUrl"] sent_ids = [] for i in range(1, 4): resp = sqs.send_message( QueueUrl=url, MessageBody=f"task-{i}", MessageGroupId="group-A", MessageDeduplicationId=f"dedup-{i}", ) sent_ids.append(resp["MessageId"]) # SequenceNumber is 20 digits. On send it is a UnixNano timestamp; on receive # it is the true broker sequence — both strictly increasing per group. print(f"sent task-{i} SequenceNumber={resp['SequenceNumber']}") # Re-sending with the same MessageDeduplicationId returns the ORIGINAL MessageId # and does not re-enqueue. dup = sqs.send_message( QueueUrl=url, MessageBody="task-1", MessageGroupId="group-A", MessageDeduplicationId="dedup-1" ) assert dup["MessageId"] == sent_ids[0] # duplicate suppressed # Per-message DelaySeconds is rejected on FIFO queues. try: sqs.send_message( QueueUrl=url, MessageBody="late", MessageGroupId="group-A", MessageDeduplicationId="dedup-late", DelaySeconds=5, ) except ClientError as e: print(f"DelaySeconds on FIFO -> {e.response['Error']['Code']}") # InvalidParameterValue ``` The FIFO `SequenceNumber` differs between send and receive: on **send** it is a UnixNano timestamp, on **receive** it is the true broker sequence. Both are strictly increasing within a group — do not compare a send-side number against a receive-side one. ## DLQ and redrive [#dlq-and-redrive] Set a `RedrivePolicy` on a queue (a `deadLetterTargetArn` plus a `maxReceiveCount`) and the broker moves a message to the dead-letter queue once it has been received `maxReceiveCount` times without being deleted. The DLQ message carries a `DeadLetterQueueSourceArn` identifying the source. See the [reliability guide](/connectors/aws/how-to/reliability) for the full redrive walkthrough. ```python # Main queue + DLQ; redrive after 2 failed receives. dlq_url = sqs.create_queue(QueueName="work-dlq")["QueueUrl"] dlq_arn = sqs.get_queue_attributes(QueueUrl=dlq_url, AttributeNames=["QueueArn"])["Attributes"]["QueueArn"] work_url = sqs.create_queue(QueueName="work")["QueueUrl"] sqs.set_queue_attributes( QueueUrl=work_url, Attributes={"RedrivePolicy": f'{{"deadLetterTargetArn":"{dlq_arn}","maxReceiveCount":2}}'}, ) ``` ## What SQS queues do not have [#what-sqs-queues-do-not-have] The connector implements 18 SQS actions but is deliberately scoped — these AWS features are **not** supported: * **No RPC.** SQS is point-to-point queueing, not request/reply; there is no gRPC responder. * **No KMS / server-side encryption (SSE).** Encryption attributes are not honored. * **No `MessageBody`-scope filtering** (that is an SNS subscription feature; only `MessageAttributes` scope is supported there). * **Body size limit ≤ 256 KiB** for the aggregate request, as on AWS. * **Region is not enforced**, and there is a **single AccountId** (`000000000000`); `QueueOwnerAWSAccountId` is accepted and ignored (no cross-account access). ## Related [#related] # Getting Started (/connectors/aws/tutorials/getting-started) Get a message flowing through the KubeMQ AWS connector in minutes. You enable the connector, point a standard AWS SDK at the connector's endpoint, create an SQS queue, send a message, and receive it back — all over the genuine AWS SQS wire protocol, with no LocalStack and no KubeMQ SDK. The only change versus a real-AWS app is the **endpoint override**. ## Prerequisites [#prerequisites] * A running **kubemq-server** with the AWS connector **enabled** and reachable on **port 4566** (the enable step is below — all six wire-protocol connectors are opt-in). * One of the AWS SDKs below for your language. There is no KubeMQ SDK; you use the native AWS SDK with only its endpoint overridden. * **Dummy AWS credentials and a region.** The connector's default accept-any mode does not verify the signature value, but the SDK must still form a valid SigV4 request — so an access key, secret, and region are required even though their values are not checked. Every example reads a single convenience variable for the connector endpoint, which maps to both the SQS and SNS endpoint overrides: ```bash export KUBEMQ_AWS_URL="http://localhost:4566" # default; mapped to AWS_ENDPOINT_URL_SQS / _SNS # Dummy credentials + region — STILL required even in accept-any mode. export AWS_ACCESS_KEY_ID="test" export AWS_SECRET_ACCESS_KEY="test" export AWS_REGION="us-east-1" # NOT enforced (default ARN segment "kubemq") ``` ## Enable the connector [#enable-the-connector] The AWS connector is **opt-in — disabled by default.** A stock kubemq-server does **not** serve AWS until you turn it on. Enable it with its enable variable: **Enabling the connector opens a new HTTP listener on port 4566.** That port is **not bound until** you set `CONNECTORS_AWS_ENABLE=true`, and it **must differ** from the server's gRPC/REST/HTTP ports — this is exactly why the connector is opt-in rather than on by default. Until you enable it, no AWS endpoint exists and the SDK cannot connect. To turn it off again, set `CONNECTORS_AWS_ENABLE=false` (a config-only rollback; no data migration). See [Configuration](/connectors/aws/concepts/configuration) for the full settings list. ## How it works [#how-it-works] You override only the endpoint URL on a standard AWS SDK client. `CreateQueue("orders")` registers the queue and maps it to the KubeMQ Queue channel `sqs.orders`; `SendMessage` writes to that channel through the message broker; `ReceiveMessage` returns the message plus a receipt handle; `DeleteMessage` acks it off the queue. *The SQS queue `orders` maps to the KubeMQ Queue channel `sqs.orders`; the broker stores the message and the receive returns it with a node-local receipt handle.* ## Steps [#steps] ### Point the SDK at the connector [#point-the-sdk-at-the-connector] Build a standard AWS SDK SQS client and override only the endpoint URL to the connector's endpoint in `KUBEMQ_AWS_URL` (default `http://localhost:4566`). Supply dummy credentials and a region so the SDK forms a valid SigV4 request. The language tabs run the **complete** round-trip from a single program: create the queue, send one message, receive it, and delete it by receipt handle. ```go package main import ( "context" "fmt" "log" "os" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/sqs" ) func awsURL() string { if v := os.Getenv("KUBEMQ_AWS_URL"); v != "" { return v } return "http://localhost:4566" } func main() { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() // Dummy static credentials form a valid SigV4 request; accept-any mode // checks the signature shape, not its value. cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion("us-east-1"), config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), ) if err != nil { log.Fatalf("load config: %v", err) } // Override ONLY the endpoint URL. client := sqs.NewFromConfig(cfg, func(o *sqs.Options) { o.BaseEndpoint = aws.String(awsURL()) }) // 1. CreateQueue "orders" -> KubeMQ Queue channel "sqs.orders". created, err := client.CreateQueue(ctx, &sqs.CreateQueueInput{QueueName: aws.String("orders")}) if err != nil { log.Fatalf("CreateQueue: %v", err) } queueURL := aws.ToString(created.QueueUrl) fmt.Printf("queue ready: %s\n", queueURL) // 2. SendMessage. if _, err := client.SendMessage(ctx, &sqs.SendMessageInput{ QueueUrl: aws.String(queueURL), MessageBody: aws.String("hello from the AWS SDK"), }); err != nil { log.Fatalf("SendMessage: %v", err) } // 3. ReceiveMessage (long-poll a few seconds for the message). recv, err := client.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{ QueueUrl: aws.String(queueURL), MaxNumberOfMessages: 1, WaitTimeSeconds: 5, }) if err != nil || len(recv.Messages) != 1 { log.Fatalf("ReceiveMessage: %v (got %d)", err, len(recv.Messages)) } msg := recv.Messages[0] fmt.Printf("received: %q\n", aws.ToString(msg.Body)) // 4. DeleteMessage by receipt handle. if _, err := client.DeleteMessage(ctx, &sqs.DeleteMessageInput{ QueueUrl: aws.String(queueURL), ReceiptHandle: msg.ReceiptHandle, }); err != nil { log.Fatalf("DeleteMessage: %v", err) } fmt.Println("deleted; round-trip complete") } ``` ```python import os import boto3 def make_sqs(): # Override ONLY the endpoint URL; dummy credentials form a valid SigV4 # request (accept-any mode checks the signature shape, not its value). return boto3.client( "sqs", endpoint_url=os.environ.get("KUBEMQ_AWS_URL", "http://localhost:4566"), region_name="us-east-1", aws_access_key_id="test", aws_secret_access_key="test", ) def main() -> None: sqs = make_sqs() # 1. CreateQueue "orders" -> KubeMQ Queue channel "sqs.orders". queue_url = sqs.create_queue(QueueName="orders")["QueueUrl"] print(f"queue ready: {queue_url}") # 2. SendMessage. sqs.send_message(QueueUrl=queue_url, MessageBody="hello from boto3") # 3. ReceiveMessage (long-poll a few seconds). recv = sqs.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=1, WaitTimeSeconds=5) msg = recv["Messages"][0] print(f"received: {msg['Body']!r}") # 4. DeleteMessage by receipt handle. sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=msg["ReceiptHandle"]) print("deleted; round-trip complete") if __name__ == "__main__": main() ``` ```java import java.net.URI; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.sqs.SqsClient; import software.amazon.awssdk.services.sqs.model.Message; public final class Main { public static void main(String[] args) { String url = System.getenv().getOrDefault("KUBEMQ_AWS_URL", "http://localhost:4566"); // endpointOverride is the only change; dummy credentials form a valid // SigV4 request (accept-any mode checks the signature shape). try (SqsClient sqs = SqsClient.builder() .endpointOverride(URI.create(url)) .region(Region.US_EAST_1) .credentialsProvider(StaticCredentialsProvider.create( AwsBasicCredentials.create("test", "test"))) .build()) { // 1. CreateQueue "orders" -> KubeMQ Queue channel "sqs.orders". String queueUrl = sqs.createQueue(b -> b.queueName("orders")).queueUrl(); System.out.println("queue ready: " + queueUrl); // 2. SendMessage. sqs.sendMessage(b -> b.queueUrl(queueUrl).messageBody("hello from the AWS SDK for Java")); // 3. ReceiveMessage (long-poll a few seconds). Message msg = sqs.receiveMessage(b -> b .queueUrl(queueUrl) .maxNumberOfMessages(1) .waitTimeSeconds(5)) .messages().get(0); System.out.printf("received: %s%n", msg.body()); // 4. DeleteMessage by receipt handle. sqs.deleteMessage(b -> b.queueUrl(queueUrl).receiptHandle(msg.receiptHandle())); System.out.println("deleted; round-trip complete"); } } } ``` ```typescript import { SQSClient, CreateQueueCommand, SendMessageCommand, ReceiveMessageCommand, DeleteMessageCommand, } from "@aws-sdk/client-sqs"; // Override ONLY the endpoint; dummy credentials form a valid SigV4 request // (accept-any mode checks the signature shape, not its value). const sqs = new SQSClient({ endpoint: process.env["KUBEMQ_AWS_URL"] ?? "http://localhost:4566", region: "us-east-1", credentials: { accessKeyId: "test", secretAccessKey: "test" }, }); async function main(): Promise { // 1. CreateQueue "orders" -> KubeMQ Queue channel "sqs.orders". const created = await sqs.send(new CreateQueueCommand({ QueueName: "orders" })); const queueUrl = created.QueueUrl!; console.log(`queue ready: ${queueUrl}`); // 2. SendMessage. await sqs.send(new SendMessageCommand({ QueueUrl: queueUrl, MessageBody: "hello from the AWS SDK v3" })); // 3. ReceiveMessage (long-poll a few seconds). const recv = await sqs.send( new ReceiveMessageCommand({ QueueUrl: queueUrl, MaxNumberOfMessages: 1, WaitTimeSeconds: 5 }), ); const msg = recv.Messages![0]; console.log(`received: ${msg.Body}`); // 4. DeleteMessage by receipt handle. await sqs.send(new DeleteMessageCommand({ QueueUrl: queueUrl, ReceiptHandle: msg.ReceiptHandle! })); console.log("deleted; round-trip complete"); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using Amazon.Runtime; using Amazon.SQS; using Amazon.SQS.Model; var url = Environment.GetEnvironmentVariable("KUBEMQ_AWS_URL") ?? "http://localhost:4566"; // ServiceURL carries the full http://host:port; dummy credentials form a valid // SigV4 request (accept-any mode checks the signature shape, not its value). var config = new AmazonSQSConfig { ServiceURL = url, AuthenticationRegion = "us-east-1" }; using var sqs = new AmazonSQSClient(new BasicAWSCredentials("test", "test"), config); // 1. CreateQueue "orders" -> KubeMQ Queue channel "sqs.orders". var created = await sqs.CreateQueueAsync(new CreateQueueRequest { QueueName = "orders" }); var queueUrl = created.QueueUrl; Console.WriteLine($"queue ready: {queueUrl}"); // 2. SendMessage. await sqs.SendMessageAsync(new SendMessageRequest { QueueUrl = queueUrl, MessageBody = "hello from AWSSDK.NET" }); // 3. ReceiveMessage (long-poll a few seconds). var recv = await sqs.ReceiveMessageAsync(new ReceiveMessageRequest { QueueUrl = queueUrl, MaxNumberOfMessages = 1, WaitTimeSeconds = 5, }); var msg = recv.Messages[0]; Console.WriteLine($"received: {msg.Body}"); // 4. DeleteMessage by receipt handle. await sqs.DeleteMessageAsync(new DeleteMessageRequest { QueueUrl = queueUrl, ReceiptHandle = msg.ReceiptHandle }); Console.WriteLine("deleted; round-trip complete"); ``` ```ruby # frozen_string_literal: true require "aws-sdk-sqs" # The Ruby SQS plugin rewrites the request endpoint to the full QueueUrl path, # which the single-endpoint connector rejects — remove it so requests stay on # the configured base endpoint (as boto3 and aws-sdk-go-v2 do). Aws::SQS::Client.remove_plugin(Aws::SQS::Plugins::QueueUrls) url = ENV.fetch("KUBEMQ_AWS_URL", "http://localhost:4566") # Override ONLY the endpoint; dummy credentials form a valid SigV4 request # (accept-any mode checks the signature shape, not its value). sqs = Aws::SQS::Client.new( endpoint: url, region: "us-east-1", credentials: Aws::Credentials.new("test", "test") ) # 1. CreateQueue "orders" -> KubeMQ Queue channel "sqs.orders". queue_url = sqs.create_queue(queue_name: "orders").queue_url puts "queue ready: #{queue_url}" # 2. SendMessage. sqs.send_message(queue_url: queue_url, message_body: "hello from aws-sdk-ruby") # 3. ReceiveMessage (long-poll a few seconds). recv = sqs.receive_message(queue_url: queue_url, max_number_of_messages: 1, wait_time_seconds: 5) msg = recv.messages.first puts "received: #{msg.body.inspect}" # 4. DeleteMessage by receipt handle. sqs.delete_message(queue_url: queue_url, receipt_handle: msg.receipt_handle) puts "deleted; round-trip complete" ``` ```rust use aws_config::BehaviorVersion; use aws_sdk_sqs::config::Credentials; use aws_sdk_sqs::config::Region; use std::error::Error; fn aws_url() -> String { std::env::var("KUBEMQ_AWS_URL").unwrap_or_else(|_| "http://localhost:4566".to_string()) } #[tokio::main] async fn main() -> Result<(), Box> { // Override ONLY the endpoint; dummy credentials form a valid SigV4 request // (accept-any mode checks the signature shape, not its value). let creds = Credentials::new("test", "test", None, None, "kubemq-aws"); let conf = aws_config::defaults(BehaviorVersion::latest()) .region(Region::new("us-east-1")) .credentials_provider(creds) .endpoint_url(aws_url()) .load() .await; let sqs = aws_sdk_sqs::Client::new(&conf); // 1. CreateQueue "orders" -> KubeMQ Queue channel "sqs.orders". sqs.create_queue().queue_name("orders").send().await?; let url = sqs .get_queue_url() .queue_name("orders") .send() .await? .queue_url .ok_or("GetQueueUrl returned no URL")?; println!("queue ready: {url}"); // 2. SendMessage. sqs.send_message() .queue_url(&url) .message_body("hello from aws-sdk-rust") .send() .await?; // 3. ReceiveMessage (long-poll a few seconds). let received = sqs .receive_message() .queue_url(&url) .max_number_of_messages(1) .wait_time_seconds(5) .send() .await?; let msg = &received.messages()[0]; println!("received: {}", msg.body().unwrap_or_default()); // 4. DeleteMessage by receipt handle. let handle = msg.receipt_handle().ok_or("no receipt handle")?; sqs.delete_message().queue_url(&url).receipt_handle(handle).send().await?; println!("deleted; round-trip complete"); Ok(()) } ``` ### Create an SQS queue and send a message [#create-an-sqs-queue-and-send-a-message] `CreateQueue("orders")` registers the queue in the connector's registry and maps it to the KubeMQ Queue channel `sqs.orders`. The returned queue URL is path-style — `{scheme}://{host}/{AccountId}/orders` (the default `AccountId` is `000000000000`). `SendMessage` writes the body to that channel and returns a `MessageId` and an `MD5OfBody` the connector computes exactly like AWS. ### Receive and acknowledge [#receive-and-acknowledge] `ReceiveMessage` returns the message plus a **receipt handle**, and hides the message for its visibility window. `DeleteMessage(receiptHandle)` acks it off the queue. A successful run prints: ```text queue ready: http://localhost:4566/000000000000/orders received: "hello from the AWS SDK" deleted; round-trip complete ``` Receipt handles and in-flight tracking are **node-local** — a handle minted on one node is rejected on another. In a clustered deployment, put the connector behind a **sticky load balancer** (session affinity). See [Connectivity and security](/connectors/aws/how-to/connectivity-and-security). ## Next steps [#next-steps] # Capabilities (/connectors/aws/reference/capabilities) This reference defines exactly what the embedded KubeMQ AWS connector **supports**, what it **accepts-and-ignores**, and what it **rejects**. Use it to decide which AWS SDK calls are safe to rely on and which ones will be refused. Every action below is backed by the connector's SQS and SNS dispatch tables. ## Supported SQS actions (18) [#supported-sqs-actions-18] The SQS dispatch table ships exactly **18** actions: | # | Action | Notes | | -- | ------------------------------ | ----------------------------------------------------------------------------- | | 1 | `CreateQueue` | FIFO via `.fifo` suffix; same name + different attributes → `QueueNameExists` | | 2 | `DeleteQueue` | removes the registry record, best-effort broker purge | | 3 | `GetQueueUrl` | path-style URL; `QueueOwnerAWSAccountId` accepted and ignored | | 4 | `ListQueues` | pagination + `QueueNamePrefix`; not authorization-filtered | | 5 | `PurgeQueue` | 60 s cooldown → `PurgeQueueInProgress` | | 6 | `GetQueueAttributes` | see the read-only attributes below | | 7 | `SetQueueAttributes` | partial-update overlay; not retroactive | | 8 | `TagQueue` | ≤ 50 tags | | 9 | `UntagQueue` | | | 10 | `ListQueueTags` | | | 11 | `ListDeadLetterSourceQueues` | reverse-resolves `RedrivePolicy` sources | | 12 | `SendMessage` | | | 13 | `SendMessageBatch` | ≤ 10 entries; aggregate > 262,144 B → `BatchRequestTooLong` | | 14 | `ReceiveMessage` | long poll, visibility, in-flight cap | | 15 | `DeleteMessage` | idempotent (an unknown handle still succeeds) | | 16 | `DeleteMessageBatch` | ≤ 10 entries | | 17 | `ChangeMessageVisibility` | timeout > 0 moves the deadline; 0 NAcks (visible at tail) | | 18 | `ChangeMessageVisibilityBatch` | ≤ 10 entries | ### SQS queue attributes [#sqs-queue-attributes] **Writable:** `DelaySeconds` (0–900), `MaximumMessageSize` (1024–262144), `MessageRetentionPeriod` (60–1209600), `VisibilityTimeout` (0–43200), `ReceiveMessageWaitTimeSeconds` (0–20), `RedrivePolicy`; FIFO-only `FifoQueue` (create-only), `ContentBasedDeduplication`, `DeduplicationScope` (`queue` | `messageGroup`). **Read-only on Get:** `QueueArn`, `ApproximateNumberOfMessages` (broker stats, 2 s TTL), `ApproximateNumberOfMessagesNotVisible` (node-local in-flight), `ApproximateNumberOfMessagesDelayed` (**always `"0"`**), `CreatedTimestamp`, `LastModifiedTimestamp`; FIFO adds `FifoQueue` / `ContentBasedDeduplication` / `DeduplicationScope`. ## Supported SNS actions (17) [#supported-sns-actions-17] The SNS dispatch table ships exactly **17** actions: | # | Action | Notes | | -- | --------------------------- | --------------------------------------------------------------------------------------------------------------------- | | 1 | `CreateTopic` | FIFO via `.fifo` suffix; idempotent on an existing name | | 2 | `DeleteTopic` | cascades to subscriptions | | 3 | `ListTopics` | ARN-sorted, 100/page; not authorization-filtered | | 4 | `GetTopicAttributes` | | | 5 | `SetTopicAttributes` | only `DisplayName` / `DeliveryPolicy` writable; `Policy` → `InvalidParameter` | | 6 | `Subscribe` | protocols `sqs` / `http` / `https` only | | 7 | `ConfirmSubscription` | the only GET action; SigV4-exempt | | 8 | `Unsubscribe` | | | 9 | `GetSubscriptionAttributes` | | | 10 | `SetSubscriptionAttributes` | `RawMessageDelivery`, `FilterPolicy`, `FilterPolicyScope` (MessageAttributes only), `RedrivePolicy`, `DeliveryPolicy` | | 11 | `ListSubscriptions` | not authorization-filtered | | 12 | `ListSubscriptionsByTopic` | | | 13 | `Publish` | rejects `TargetArn` / `PhoneNumber` | | 14 | `PublishBatch` | | | 15 | `TagResource` | topic OR subscription ARN; ≤ 50 tags | | 16 | `UntagResource` | | | 17 | `ListTagsForResource` | | ### SNS topic attributes [#sns-topic-attributes] **Writable:** `DisplayName`, `DeliveryPolicy`. **Surfaced on Get:** `TopicArn`, `Owner`, `DisplayName`, `SubscriptionsConfirmed`, `SubscriptionsPending`, `SubscriptionsDeleted` (always 0), `EffectiveDeliveryPolicy`, optional `DeliveryPolicy`; FIFO adds `FifoTopic=true` + `ContentBasedDeduplication=false`. **Topic-level `ContentBasedDeduplication` is not supported.** Setting it → `InvalidParameter`; getting it → always `"false"`. Pass an explicit `MessageDeduplicationId` on each `Publish` to a FIFO topic instead. ## Out-of-scope operations [#out-of-scope-operations] These are documented non-goals. They are **never** used as working examples, and most are actively rejected by the connector with the [error code](/connectors/aws/reference/error-codes) shown: | Feature | Behavior | | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | **`FilterPolicyScope=MessageBody`** | rejected at the attribute setter → `InvalidParameter` ("MessageBody scope is not supported"); only `MessageAttributes` scope works | | **KMS / SSE** (`KmsMasterKeyId`, `Policy`, …) | `InvalidAttributeName` on a queue-attribute set | | **`AddPermission` / `RemovePermission`** (SQS + SNS) | `InvalidAction` | | **SQS message-move tasks** (`StartMessageMoveTask` / `CancelMessageMoveTask` / `ListMessageMoveTasks`) | `InvalidAction` | | **SNS `email` / `email-json` / `sms` / `lambda` / `application` / `firehose` protocols** + mobile-push / SMS / data-protection ops | `InvalidParameter` / `InvalidAction` | | **`Publish` with `TargetArn` / `PhoneNumber`** | `InvalidParameter` | | **Signed SNS notification verification** | notifications are **unsigned** — `Signature` / `SigningCertURL` are present but empty; no SDK-side signature verification is possible | | **Extended client > 256 KiB** | the aggregate body + attributes is capped at 262,144 bytes | | **CloudWatch metrics emulation** | none emitted (Prometheus is the metrics surface — see [Connections & Observability](/connectors/aws/reference/connections-endpoint)) | | **Cross-account semantics** | `QueueOwnerAWSAccountId` accepted and ignored; a single configurable AccountId only | | **Topic-level `ContentBasedDeduplication`** | set → `InvalidParameter`, get → always `"false"` | ## Inert / always-fixed values [#inert--always-fixed-values] These are accepted on the wire but carry no behavior (documented so you do not expect what is not there): * `ApproximateNumberOfMessagesDelayed` always `"0"`. * `SubscriptionsDeleted` always `0`. * `X-Amz-Security-Token` accepted and ignored. * The SigV4 credential-scope **region is not enforced** — any region signs successfully; the ARN region segment defaults to `kubemq`. ## The eight gotchas [#the-eight-gotchas] These behaviors deviate from real AWS and are easy to miss until a corner case hits production. Each is documented in depth where shown: | # | Gotcha | Where documented | | - | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1 | **Receipt handles + in-flight are node-local → sticky LB required in clusters** (SNS delivery state too) | [SQS queues & consumers](/connectors/aws/how-to/sqs-queues-and-consumers), [Connectivity & security](/connectors/aws/how-to/connectivity-and-security), [Migrating from AWS](/connectors/aws/reference/migration-from-aws) | | 2 | **Region is not enforced** — any region signs; the ARN segment defaults to `kubemq` | [Authentication](/connectors/aws/how-to/authentication), [Migrating from AWS](/connectors/aws/reference/migration-from-aws) | | 3 | **Dummy credentials still required in accept-any mode** — the SDK must form a SigV4 request; an unsigned request is rejected (except `ConfirmSubscription`) | [Getting Started](/connectors/aws/tutorials/getting-started), [Authentication](/connectors/aws/how-to/authentication) | | 4 | **`MessageBody`-scope filtering unsupported** — only `MessageAttributes` scope works | [SNS fan-out](/connectors/aws/how-to/sns-fan-out), this page | | 5 | **Empty-queue short-poll \~1 s latency floor**; `ApproximateNumberOfMessagesDelayed` always `"0"` | [SQS queues & consumers](/connectors/aws/how-to/sqs-queues-and-consumers), this page | | 6 | **Unsigned SNS notifications** — `Signature` / `SigningCertURL` present but empty | [SNS fan-out](/connectors/aws/how-to/sns-fan-out), [Fan-out](/connectors/aws/how-to/fan-out), [Migrating from AWS](/connectors/aws/reference/migration-from-aws) | | 7 | **Native-producer MessageId fallback** — native producers on `sqs.*` get a broker-id MessageId, no SenderId, no policy stamping | [Cross-protocol interop](/connectors/aws/concepts/cross-protocol-interop), [Channel Mapping](/connectors/aws/reference/channel-mapping) | | 8 | **SNS HTTP delivery state is in-memory on the publishing node** — a restart loses pending retries; bounded job queue (10,000) overflow drops | [Reliability](/connectors/aws/how-to/reliability), [SNS topics](/connectors/aws/how-to/sns-topics), [Migrating from AWS](/connectors/aws/reference/migration-from-aws) | Two further documented deviations are surfaced in the reference docs but are not headline gotchas: the FIFO **`SequenceNumber` send-vs-receive** difference and the **raw-HTTP attribute-drop**. Both live in [Channel Mapping](/connectors/aws/reference/channel-mapping). ## Related [#related] # Channel Mapping (/connectors/aws/reference/channel-mapping) This is the master reference for how the embedded KubeMQ AWS connector maps SQS queues and SNS topics onto KubeMQ. An SQS queue is backed by exactly one KubeMQ **Queue** channel; an SNS topic is **virtual** — a registry entry whose publish fans out to subscribed queues and webhooks. ## SQS queue grammar [#sqs-queue-grammar] Every SQS queue maps to exactly one KubeMQ Queue channel: ```text sqs.{name} └┬─┘ └──┬──┘ │ └─ the SQS queue name (the same name you pass to CreateQueue) └─ fixed connector prefix ``` | SQS queue | KubeMQ channel | | ---------- | -------------- | | `orders` | `sqs.orders` | | `events` | `sqs.events` | | `work-dlq` | `sqs.work-dlq` | ## FIFO group grammar [#fifo-group-grammar] A FIFO queue `{name}.fifo` fans each **message group** onto its own per-group channel: ```text sqs.{name}.fifo.g.{enc(group)} └─────┬────────┘ └┬┘ └───┬────┘ │ │ └─ the MessageGroupId, percent-encoded │ └─ fixed ".g." group separator └─ the FIFO queue channel (the name includes the ".fifo" suffix) ``` `enc` percent-encodes any byte outside `[a-zA-Z0-9_-]`. | FIFO queue | MessageGroupId | KubeMQ channel | | ------------ | -------------- | ----------------------------- | | `tasks.fifo` | `g1` | `sqs.tasks.fifo.g.g1` | | `tasks.fifo` | `order/42` | `sqs.tasks.fifo.g.order%2F42` | ## SNS topics are virtual [#sns-topics-are-virtual] SNS topics have **no native channel**. A topic is a registry entry, **synced/replicated across cluster nodes**; its authorization pseudo-resource is `sns.{topic}`. Fan-out resolves to the target SQS channels (one batched send) plus HTTP/HTTPS webhooks at publish time. See [Fan-out](/connectors/aws/how-to/fan-out). ## ARNs & queue URLs [#arns--queue-urls] | Form | Value | | --------- | ---------------------------------------------------------------------------------------------------- | | SQS ARN | `arn:aws:sqs:{Region}:{AccountId}:{name}` | | SNS ARN | `arn:aws:sns:{Region}:{AccountId}:{name}` | | Queue URL | `{scheme}://{host}/{AccountId}/{name}` (path-style; host from `AdvertisedUrl` or the request `Host`) | `Region` defaults to `kubemq` (not enforced); `AccountId` defaults to `000000000000`. Resolution parses the **path only**, so a stale host in a saved URL still works. ## The registry is authoritative [#the-registry-is-authoritative] Only resources created through the AWS API are visible. Operating on a native `sqs.foo` channel that was never `CreateQueue`d returns `NonExistentQueue`. ## Cross-protocol interoperability [#cross-protocol-interoperability] Because the backing store is a normal KubeMQ Queue channel, an SQS `SendMessage` to `sqs.orders` is consumable by a gRPC/REST queue client on the same channel, and vice-versa. **Native-interop caveat (gotcha #7).** A message produced by a **native** KubeMQ client on `sqs.*` lacks the `sqs_*` tags, so its `MessageId` falls back to the broker MessageID, it has no `SenderId`, and no policy stamping is applied. See [Cross-protocol interop](/connectors/aws/concepts/cross-protocol-interop). ## Message attribute ⇄ tag mapping [#message-attribute--tag-mapping] SQS message attributes round-trip losslessly through the KubeMQ **Tags** codec. | AWS field | KubeMQ Tag | Notes | | --------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Message attribute `{Name}` | `sqs_attr_{Name}` | value is `{DataType}\|{value}`; Binary is base64; ≤ 10 attributes, name ≤ 256 chars, no `AWS.`/`Amazon.` prefix; DataType `String` / `Number` / `Binary` (+ subtypes `String.x`) | | System attribute `AWSTraceHeader` | `sqs_trace_header` | the **only** accepted system attribute; anything else → `InvalidParameterValue` | | *(connector-stamped)* `MessageId` | `sqs_message_id` | generated UUID | | *(connector-stamped)* `SenderId` | `sqs_sender_id` | the authenticated ClientID | | FIFO `MessageGroupId` | `sqs_group_id` | | | FIFO `MessageDeduplicationId` | `sqs_dedup_id` | | ### Receive-side decoded system attributes [#receive-side-decoded-system-attributes] `SentTimestamp`, `ApproximateReceiveCount`, `ApproximateFirstReceiveTimestamp` (node-local approx), `SenderId`, `DeadLetterQueueSourceArn` (redriven messages), and FIFO `MessageGroupId` / `SequenceNumber` / `MessageDeduplicationId`. Attribute-name filtering on receive supports `All`, exact names, and `prefix.*`. ## SNS raw / enveloped tags [#sns-raw--enveloped-tags] | Context | Mapping | | -------------------------- | -------------------------------------------------------------------------------------------------------- | | SQS **enveloped** delivery | the SNS `Notification` JSON carries `MessageAttributes` as `{Type, Value}` per attribute (Binary base64) | | SQS **raw** delivery | bare body + the attribute tag codec + `sns_topic_arn` / `sns_subject` tags | | HTTP **raw** delivery | bare payload with attributes mapped to `x-amz-sns-attr-{name}` headers | **Raw-HTTP attribute-drop deviation.** AWS drops message attributes for raw HTTP delivery; this connector instead surfaces them as `x-amz-sns-attr-{name}` headers. ## MD5 [#md5] | Field | Algorithm | | ------------------------------ | ----------------------------------------- | | `MD5OfMessageBody` | MD5 of the body bytes | | `MD5OfMessageAttributes` | AWS length-prefixed message-attribute MD5 | | `MD5OfMessageSystemAttributes` | present only when `AWSTraceHeader` is set | ## FIFO SequenceNumber deviation [#fifo-sequencenumber-deviation] The 20-digit zero-padded `SequenceNumber` is the broker **send-timestamp (UnixNano)** on SEND and the **true broker sequence** on RECEIVE. It is still strictly increasing per group for serialized sends. See [Reliability](/connectors/aws/how-to/reliability). ## Related [#related] # Configuration (/connectors/aws/reference/configuration) Field-by-field reference for the KubeMQ AWS connector's `Connectors.Aws` config block. See [Configuration concepts](../concepts/configuration) for why the connector is opt-in and how the two credential postures work. ## Configuration fields [#configuration-fields] All values below are verified against the connector source (`AwsConfig` struct and `defaultAwsConfig`). Compound camelCase fields are snake-split in their env form — e.g. `MaxInflightPerQueue` → `CONNECTORS_AWS_MAX_INFLIGHT_PER_QUEUE`, `AccountId` → `CONNECTORS_AWS_ACCOUNT_ID`. | Env var | Default | Type | Meaning / validation | | --------------------------------------- | ---------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `CONNECTORS_AWS_ENABLE` | `false` | bool | Opt-in. `true` opens the listener on `Port`; `false` skips the connector entirely. | | `CONNECTORS_AWS_PORT` | `"4566"` | string | The listener port (the LocalStack convention). Must differ from any enabled Grpc/Rest/Http port. A single TCP listener; `POST /` and `GET /` both dispatch. | | `CONNECTORS_AWS_REGION` | `"kubemq"` | string | The ARN region segment; informational only. **NOT enforced** in SigV4 — any region in the credential scope signs successfully. | | `CONNECTORS_AWS_ACCOUNT_ID` | `"000000000000"` | string | A single configurable 12-digit value (validated). There is **no cross-account support**; `QueueOwnerAWSAccountId` is accepted and ignored. | | `CONNECTORS_AWS_ADVERTISED_URL` | `""` | string | Overrides the host in returned queue URLs; must be `scheme://host[:port]`. When empty, the request `Host` is used. Resolution parses the **path only**, so stale hosts in saved URLs still work. | | `CONNECTORS_AWS_CREDENTIALS_DATA` | `""` | string | A JSON (optionally base64-encoded) array of static SigV4 credentials — the env/operator path for a secured connector. When set, SigV4 is fully verified. | | `CONNECTORS_AWS_MAX_INFLIGHT_PER_QUEUE` | `20000` | int | Per-queue / per-node cap on received-but-not-deleted (in-flight) messages; exceeding it returns `OverLimit`. | | `CONNECTORS_AWS_MAX_CONCURRENT_POLLS` | `1024` | int | The parked long-poll slot pool. When exhausted, a `ReceiveMessage` **degrades to a short poll** rather than erroring. | | `CONNECTORS_AWS_READ_TIMEOUT` | `60` | int | Per-action context deadline in seconds for synchronous ops. `ReceiveMessage` is exempt and gets at least a \~25 s budget. | | `CONNECTORS_AWS_BODY_LIMIT` | `"2M"` | string | Request body size cap. | The `AwsConfig` struct has **11 fields, of which 10 are env-bound.** The 11th, the structured `Credentials` array, has **no env binding** — it is file/structured-config only. `CONNECTORS_AWS_CREDENTIALS_DATA` is the env path for static credentials, and merges with any file-only `Credentials` (Data wins on duplicates). ## Configuring the connector [#configuring-the-connector] The same settings can be supplied through a TOML config file, environment variables, or `docker run` flags. Every environment variable uses the `CONNECTORS_AWS_` prefix (with the underscore between `CONNECTORS` and `AWS`). ```toml title="config.toml" [Connectors.Aws] Enable = true Port = "4566" Region = "kubemq" AccountId = "000000000000" AdvertisedUrl = "" CredentialsData = "" MaxInflightPerQueue = 20000 MaxConcurrentPolls = 1024 ReadTimeout = 60 BodyLimit = "2M" ``` ```bash title="aws.env" CONNECTORS_AWS_ENABLE=true CONNECTORS_AWS_PORT=4566 CONNECTORS_AWS_REGION=kubemq CONNECTORS_AWS_ACCOUNT_ID=000000000000 CONNECTORS_AWS_ADVERTISED_URL= CONNECTORS_AWS_CREDENTIALS_DATA= CONNECTORS_AWS_MAX_INFLIGHT_PER_QUEUE=20000 CONNECTORS_AWS_MAX_CONCURRENT_POLLS=1024 CONNECTORS_AWS_READ_TIMEOUT=60 CONNECTORS_AWS_BODY_LIMIT=2M ``` Because the connector is opt-in (as are all six wire-protocol connectors), the Docker example **must** include `-e CONNECTORS_AWS_ENABLE=true` — without it, no AWS listener binds. Set `=false` to turn it off again. ## Related [#related] # Connections & Observability (/connectors/aws/reference/connections-endpoint) This reference documents the AWS connector's observability surface: the **read-only management API**, the **Prometheus metrics**, the **dashboard page**, and the **audit events**. The management APIs live on the **internal API port** and are network-protected. ## Management API [#management-api] A **read-only** management API summarizes connector activity: | Endpoint | Returns | | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `GET /api/aws/overview` | aggregate counts (queues / topics / subscriptions / in-flight / active long polls) + waiting-message counts (fed by the 2 s stats cache) | | `GET /api/aws/operations` | per-operation counters keyed `{service}/{op}` (e.g. `sqs/send_message`, `sns/publish`) | | `GET /api/aws/operations/:service/:op` | a single operation's counters (e.g. `/api/aws/operations/sqs/send_message`) | Use the overview to confirm the connector accepted traffic after an upgrade. ## Prometheus metrics [#prometheus-metrics] ### Counters [#counters] | Metric | Meaning | | ------------------------------------------------------- | ----------------------------------------------------------- | | `kubemq_aws_operations_total{service,operation,status}` | operations, by service / op / status | | `kubemq_aws_sns_deliveries_total{result}` | SNS deliveries, by result | | `kubemq_aws_sns_delivery_dropped_total` | SNS deliveries dropped (job-queue overflow / unrecoverable) | ### Histogram [#histogram] | Metric | Meaning | | --------------------------------------- | --------------------- | | `kubemq_aws_operation_duration_seconds` | per-operation latency | ### Gauges [#gauges] | Metric | Meaning | | ------------------------------ | --------------------------------------------- | | `kubemq_aws_queues` | registered queues | | `kubemq_aws_topics` | registered topics | | `kubemq_aws_subscriptions` | registered subscriptions | | `kubemq_aws_inflight` | in-flight (received-but-not-deleted) messages | | `kubemq_aws_active_long_polls` | parked long-poll slots in use | There is **no CloudWatch metrics emulation** — Prometheus is the metrics surface. See [Capabilities](/connectors/aws/reference/capabilities). ## Dashboard [#dashboard] The KubeMQ dashboard has an `/aws` page backed by the management API above. ## Audit events [#audit-events] The connector emits audit events for control-plane operations (data-plane SQS sends/receives are **not** audited): | Event | When | | ----------------------- | ------------------------------------------------------------ | | `aws.auth.failure` | a SigV4 / authorization failure | | `aws.queue.*` | queue lifecycle (create / delete / tag / purge) | | `aws.topic.*` | topic lifecycle | | `aws.sub.*` | subscription lifecycle (subscribe / confirm / unsubscribe) | | `aws.registry.conflict` | a registry conflict (e.g. registry-sync conflict resolution) | ## Stats cache [#stats-cache] The `ApproximateNumberOfMessages` attribute and the overview waiting counts are fed by a stats cache that wraps the broker queue stats with a **2 s TTL** plus duplicate-call suppression. ## Related [#related] # Error Codes (/connectors/aws/reference/error-codes) The connector emits **genuine AWS SQS/SNS error codes** over the AWS JSON (SQS) and Query → XML (SNS) wire protocols, so standard AWS SDKs surface them as the normal typed exceptions. You handle them with the same `try`/`catch` and error-type checks you already use against real AWS. ## Error code table [#error-code-table] | AWS error code | HTTP | Trigger | | -------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `MissingAction` | 400 | no / unparseable `Action` | | `InvalidAction` | 400 | unknown action (including all out-of-scope ops: `AddPermission`, message-move, mobile/SMS, etc.); a GET with `Action ≠ ConfirmSubscription` | | `IncompleteSignature` | 400 | malformed SigV4 (including an unsigned non-`ConfirmSubscription` request) | | `InvalidClientTokenId` | 403 | unknown access key (configured-credentials mode) | | `SignatureDoesNotMatch` | 403 | bad signature / clock skew / tampered body | | `AccessDeniedException` | 403 | an authorization deny on a data-plane op | | `NonExistentQueue` (`AWS.SimpleQueueService.NonExistentQueue`) | 400 | queue not in the registry | | `QueueNameExists` | 400 | `CreateQueue` with the same name + different attributes | | `PurgeQueueInProgress` | 403 | `PurgeQueue` within the 60 s cooldown | | `InvalidAttributeName` | 400 | unknown / KMS attribute on `SetQueueAttributes` | | `InvalidParameterValue` | 400 | bad attribute / oversize batch entry / non-`AWSTraceHeader` system attribute / FIFO send without `MessageGroupId` / FIFO per-message `DelaySeconds` | | `BatchRequestTooLong` | 400 | `SendMessageBatch` aggregate > 262,144 B | | `ReceiptHandleIsInvalid` | 400 | receipt handle from another node / malformed | | `OverLimit` | 403 | in-flight > `MaxInflightPerQueue` | | `InvalidParameter` (SNS) | 400 | unsupported SNS protocol / `Policy` set / `TargetArn` / `PhoneNumber` / `FilterPolicyScope=MessageBody` / topic-level `ContentBasedDeduplication` / `MessageGroupId` on a standard topic / FIFO-topic non-`sqs` subscribe | | `NotFound` (SNS) | 404 | topic / subscription not in the registry | ## Common triggers by scenario [#common-triggers-by-scenario] | Scenario | Code | | -------------------------------------------------------------------- | ------------------------------------------------------------ | | `ReceiveMessage` on an empty queue (short poll) | *none — returns within \~1 s, no messages* | | `SendMessageBatch` with one oversize entry | per-entry `InvalidParameterValue` (9 ok, 1 failed) | | `SendMessageBatch` aggregate > 262,144 B | `BatchRequestTooLong` (whole batch) | | Receive without delete, visibility expires | *none — redelivery; `ApproximateReceiveCount`++* | | `DeleteMessage` with an unknown receipt handle | *none — idempotent success* | | Receipt handle from another cluster node | `ReceiptHandleIsInvalid` | | In-flight > `MaxInflightPerQueue` | `OverLimit` | | FIFO send without `MessageGroupId` / with per-message `DelaySeconds` | `InvalidParameterValue` | | FIFO duplicate within the 5-min window | *none — the original MessageId is returned, not re-enqueued* | | `Subscribe` with `FilterPolicyScope=MessageBody` | `InvalidParameter` | | `Subscribe` with an `email` / `sms` / `lambda` / … protocol | `InvalidParameter` | | `Publish` to a topic with zero matching subscriptions | *none — succeeds, message dropped* | | `Publish` with `MessageGroupId` to a standard topic | `InvalidParameter` | | FIFO topic `Subscribe` with `http` / a non-FIFO queue | `InvalidParameter` | | Set a KMS / SSE queue attribute | `InvalidAttributeName` | | `CreateQueue` with the same name + different attributes | `QueueNameExists` | | `PurgeQueue` within the 60 s cooldown | `PurgeQueueInProgress` | | Operate on a queue / topic not in the registry | `NonExistentQueue` / SNS `NotFound` | | Unknown access key (configured-credentials mode) | `InvalidClientTokenId` | | Wrong secret / tampered body / clock skew | `SignatureDoesNotMatch` | | Unsigned request (non-`ConfirmSubscription`) | `IncompleteSignature` | | An authorization deny on a data-plane op | `AccessDeniedException` | | Unknown / out-of-scope action (`AddPermission`, message-move, etc.) | `InvalidAction` | | Broker not ready (traffic gate) | an AWS-shaped 503 | ## Related [#related] # Migrating from AWS SQS/SNS (/connectors/aws/reference/migration-from-aws) **Point your existing AWS SQS/SNS application at KubeMQ by changing only the endpoint URL.** Same AWS SDK, same code, same SQS and SNS wire protocols. There is no SDK to adopt, no proto, no data migration — SQS data lives in normal KubeMQ Queue channels. Rollback is **config-only** (`CONNECTORS_AWS_ENABLE=false`). If you already run an SDK against a **LocalStack** endpoint, the switch is the same single variable — point it at the KubeMQ AWS connector instead of LocalStack. But several connector behaviors **deviate from real AWS**. Read the deviations below before you migrate; most are invisible until a corner case hits production. ## Overview [#overview] The AWS connector exposes the real AWS SQS and SNS wire protocols (the AWS JSON and Query protocols) over HTTP, so unmodified AWS SDK clients — `boto3`, the AWS SDK for Go/JavaScript, the AWS CLI — talk to KubeMQ without any code changes. An endpoint-override environment variable is all that changes on the client side. SQS queues map onto native KubeMQ Queue channels (`sqs.{name}`), making AWS producers and native gRPC/REST consumers interoperable on the same messages. SNS topics are virtual (registry-only) and fan out to SQS subscriptions and HTTP/HTTPS webhook endpoints. Requests are authenticated with AWS Signature V4. * **Canonical client:** AWS SDK `boto3` 1.x (Python). * **Port:** `4566` (HTTP). * **Drop-in level:** endpoint-only — change the SDK endpoint override; no application code changes. Port `4566` is a **client-side endpoint convention** (the LocalStack / SDK default), not a fixed KubeMQ listener — the actual connector port is `Connectors.Aws.Port` and can be changed. That is why it does not appear in KubeMQ's shared broker-ports tables, which track fixed listeners only. The connector is **opt-in — disabled by default** (`Connectors.Aws.Enable = false`). Enable it with its enable variable; this opens the HTTP listener on port `4566`: ## Compatibility Matrix [#compatibility-matrix] The column below is the AWS SQS/SNS slice of the [master cross-protocol matrix](/connectors/how-to/migration). | Dimension | AWS SQS/SNS | | -------------------------------------- | --------------------------------------------------------------------- | | **Drop-in level** | endpoint-only | | **Point-to-point queues** | ✅ SQS | | **Pub/sub (non-durable)** | ✅ SNS | | **Durable / persistent subscriptions** | ✅ (SQS durable) | | **Request/reply (RPC)** | N/A (no RPC) | | **Ordering guarantee** | ✅ FIFO | | **Transactions** | N/A | | **Dead-letter / redrive** | ✅ redrive + move-task | | **Selectors / filtering / wildcards** | ✅ SNS filter policies¹ | | **Auth model** | SigV4 / accept-any | | **TLS / mTLS** | ❌ connector HTTP-only² | | **Top unsupported** | SNS email/SMS/Lambda/push; queue/topic IAM policies; TLS at connector | ¹ SNS filter policies work with the `MessageAttributes` scope only; the `MessageBody` scope is rejected (`InvalidParameter`). ² The connector listens on plain HTTP. Terminate TLS at a reverse proxy (see [Security](#tls--terminate-at-a-reverse-proxy)). ## Connection / Endpoint Migration [#connection--endpoint-migration] No code changes are required. Set the AWS SDK endpoint-override environment variables to point at the KubeMQ host: ```bash title="Terminal" # Before (real AWS): no override; the SDK uses the regional AWS endpoint. # After (KubeMQ): export AWS_ENDPOINT_URL_SQS=http://kubemq-host:4566 export AWS_ENDPOINT_URL_SNS=http://kubemq-host:4566 export AWS_ACCESS_KEY_ID=AKIAEXAMPLE export AWS_SECRET_ACCESS_KEY=secret export AWS_DEFAULT_REGION=kubemq # any region value works; the region segment is not enforced ``` All current AWS SDKs and the AWS CLI honor `AWS_ENDPOINT_URL_SQS` / `AWS_ENDPOINT_URL_SNS`. If your SDK version predates these variables, use the per-client endpoint override instead: ```python import boto3 sqs = boto3.client( "sqs", endpoint_url="http://kubemq-host:4566", region_name="kubemq", aws_access_key_id="AKIAEXAMPLE", aws_secret_access_key="secret", ) ``` In the default **accept-any** mode the connector does not cryptographically verify the signature — but the SDK must still form a **syntactically valid** SigV4 request whose credential-scope service is `sqs` or `sns`. So you must give the SDK a dummy access key, secret, and region (any values); omitting them yields a "missing credentials" SDK error. The only SigV4-exempt action is SNS `ConfirmSubscription`. ### Credential mapping [#credential-mapping] For each `AccessKeyId` your application uses, add a credential entry to the KubeMQ configuration. The `ClientID` field maps that key to a KubeMQ identity for authorization and audit: ```toml title="config.toml" [Connectors.Aws] Enable = true Port = "4566" [[Connectors.Aws.Credentials]] AccessKeyId = "AKIAEXAMPLE" SecretAccessKey = "secret" ClientID = "billing-service" # optional; defaults to AccessKeyId ``` ### Recreate resources [#recreate-resources] Queues, topics, subscriptions, and their attributes/tags must be recreated through the AWS API against KubeMQ. The registry is authoritative — existing SQS/SNS resources from real AWS are **not** migrated automatically. ## Concept & Destination Mapping [#concept--destination-mapping] | AWS concept | KubeMQ pattern | KubeMQ channel | | ---------------------- | ----------------------- | ------------------------------------------------- | | SQS standard queue | Queues | `sqs.{queue-name}` | | SQS FIFO queue | Queues (per group) | `sqs.{queue-name}` per `MessageGroupId` | | SNS topic | Virtual (registry-only) | `sns.{topic-name}` (authorization pseudo-channel) | | SNS subscription → SQS | Queues (fan-out) | `sqs.{target-queue-name}` | **FIFO ordering:** each `MessageGroupId` maps to its own KubeMQ Queue channel, preserving per-group ordering. `MessageGroupId` is required on every FIFO send. **Message attributes** round-trip losslessly through KubeMQ message Tags (`sqs_attr_{Name}` = `{DataType}|{value}`). The connector also stamps `sqs_message_id`, `sqs_sender_id` (the authenticated `ClientID`), and `sqs_trace_header` when present. **Native interop:** native KubeMQ gRPC/REST clients can produce and consume on `sqs.*` channels directly. Natively produced messages lack `sqs_*` tags; `MessageId` falls back to the broker MessageID and no per-message redrive policy is stamped (see [deviations](#native-producers-bypass-sqs-policy-stamping)). **ARNs** use the configured `Region` (default `kubemq`) and `AccountId` (default `000000000000`). Set `Connectors.Aws.Region` / `Connectors.Aws.AccountId` if your tooling validates ARN format. ## Canonical Client Example [#canonical-client-example] The examples use the AWS SDK `boto3` 1.x: `boto3.client`, `create_queue`, `send_message`, `receive_message`, `delete_message`, `create_topic`, `subscribe`, and `publish`. ### SQS — create, send, receive, delete [#sqs--create-send-receive-delete] ```python import boto3 # boto3 1.x — point the SDK at KubeMQ sqs = boto3.client( "sqs", endpoint_url="http://kubemq-host:4566", region_name="kubemq", aws_access_key_id="AKIAEXAMPLE", aws_secret_access_key="secret", ) # Create a standard queue (idempotent — returns the URL if it already exists) resp = sqs.create_queue(QueueName="orders") queue_url = resp["QueueUrl"] # Send a message sqs.send_message( QueueUrl=queue_url, MessageBody='{"orderId": "A-001", "amount": 99.95}', MessageAttributes={ "source": {"DataType": "String", "StringValue": "checkout-service"}, }, ) # Receive (long-poll up to 20 s) resp = sqs.receive_message( QueueUrl=queue_url, MaxNumberOfMessages=1, WaitTimeSeconds=20, MessageAttributeNames=["All"], ) for msg in resp.get("Messages", []): print(f"Received: {msg['Body']}") # Acknowledge by deleting sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=msg["ReceiptHandle"]) ``` ### FIFO queue [#fifo-queue] ```python # Create a FIFO queue (name must end in .fifo) resp = sqs.create_queue( QueueName="orders.fifo", Attributes={ "FifoQueue": "true", "ContentBasedDeduplication": "true", }, ) fifo_url = resp["QueueUrl"] # Send to a specific message group (preserves ordering per group) sqs.send_message( QueueUrl=fifo_url, MessageBody='{"orderId": "A-002"}', MessageGroupId="region-us-east", ) ``` ### Dead-letter queue (redrive) [#dead-letter-queue-redrive] ```python import json # 1. Create the DLQ dlq_resp = sqs.create_queue(QueueName="orders-dlq") dlq_url = dlq_resp["QueueUrl"] dlq_arn = sqs.get_queue_attributes( QueueUrl=dlq_url, AttributeNames=["QueueArn"] )["Attributes"]["QueueArn"] # 2. Attach a redrive policy to the source queue sqs.set_queue_attributes( QueueUrl=queue_url, Attributes={ "RedrivePolicy": json.dumps({ "deadLetterTargetArn": dlq_arn, "maxReceiveCount": "3", }), }, ) # After 3 failed receives the broker automatically moves the message to orders-dlq. ``` ### SNS fan-out (topic → SQS) [#sns-fan-out-topic--sqs] ```python sns = boto3.client( "sns", endpoint_url="http://kubemq-host:4566", region_name="kubemq", aws_access_key_id="AKIAEXAMPLE", aws_secret_access_key="secret", ) # Create the topic and subscribe the SQS queue topic_arn = sns.create_topic(Name="product-events")["TopicArn"] orders_arn = sqs.get_queue_attributes( QueueUrl=queue_url, AttributeNames=["QueueArn"] )["Attributes"]["QueueArn"] sns.subscribe( TopicArn=topic_arn, Protocol="sqs", # only 'sqs' and 'http'/'https' are supported Endpoint=orders_arn, ) # Publish — the message fans out to all confirmed subscriptions sns.publish( TopicArn=topic_arn, Message='{"event": "product.created", "id": "P-100"}', MessageAttributes={ "category": {"DataType": "String", "StringValue": "electronics"}, }, ) ``` ## Security [#security] ### Authentication — SigV4 [#authentication--sigv4] The connector verifies AWS Signature V4 (`Authorization: AWS4-HMAC-SHA256 …`). Header-style and query-string-style signatures are both supported, with a ±15-minute clock-skew window. * Credentials are configured under `Connectors.Aws.Credentials` (or `CredentialsData` for environment / Kubernetes Secret injection). * **Accept-any mode:** if no credentials are configured the connector only parses the `AccessKeyId` and uses it as the `ClientID`. This is intended for local development only and is logged once at startup. * Failure cases: unknown key → HTTP 403 `InvalidClientTokenId`; bad signature / clock skew → HTTP 403 `SignatureDoesNotMatch`; malformed header → HTTP 400 `IncompleteSignature`. * `ConfirmSubscription` requests with no `Authorization` header bypass SigV4 — the single-purpose confirmation token is the authenticator (AWS parity). No other action is exempt. ### TLS — terminate at a reverse proxy [#tls--terminate-at-a-reverse-proxy] The connector listens on plain **HTTP only**. It does **not** support TLS natively. To secure traffic in transit, place a TLS-terminating reverse proxy (nginx, Envoy, HAProxy, an AWS ALB, …) in front of the connector port, and configure clients to target the proxy's HTTPS endpoint. ### Optional SNS message signing (off by default) [#optional-sns-message-signing-off-by-default] By default (`Connectors.Aws.MessageSigning = false`) SNS notification envelopes are unsigned. Set `Connectors.Aws.MessageSigning = true` to emit SigV2 RSA-SHA256 signatures. Note that the signing certificate is self-signed and not Amazon-rooted; SDK verifiers that pin Amazon's cert chain will still reject the signature. See also [SNS notification signatures are unsigned by default](#sns-notification-signatures-are-unsigned-by-default). ### Authorization (Casbin) [#authorization-casbin] When the KubeMQ authorization service is enabled, every channel-mapped operation is enforced against the existing Casbin policy: | Operation class | Casbin check | | ------------------------------------------------------------------------- | ------------------------ | | `SendMessage`, `SendMessageBatch`, SNS `Publish` (per matched SQS target) | `write` on `sqs.{queue}` | | `ReceiveMessage`, `DeleteMessage`, `ChangeMessageVisibility` | `read` on `sqs.{queue}` | | Queue management (Create / Delete / Purge / Set / Tag) | `write` on `sqs.{queue}` | | Topic & subscription management | `write` on `sns.{topic}` | `ListQueues`, `ListTopics`, `ListSubscriptions`, and `GetQueueUrl` are allowed for any authenticated principal and return unfiltered results. See [Authentication & security](/connectors/reference/auth-and-security) for policy configuration. ## What Does NOT Migrate / Deviations [#what-does-not-migrate--deviations] ### Unsupported SNS subscription protocols [#unsupported-sns-subscription-protocols] Only `sqs` and `http`/`https` subscription protocols are supported. The following AWS SNS delivery targets are **not supported** and return `InvalidParameter`: * Email / email-JSON * SMS * AWS Lambda * Mobile push (APNs, GCM, ADM, Baidu) FIFO topics additionally restrict subscriptions to `sqs` only (`http`/`https` are rejected for `.fifo` topics). ### No TLS at the connector [#no-tls-at-the-connector] The connector is HTTP-only (see [TLS — terminate at a reverse proxy](#tls--terminate-at-a-reverse-proxy)). In particular, do not configure clients to send to `https://kubemq-host:4566` directly. ### Queue/topic IAM policies ignored [#queuetopic-iam-policies-ignored] `Policy` fields on `SetQueueAttributes` and `SetTopicAttributes` are accepted but **not enforced**. Authorization is handled by KubeMQ's Casbin engine (see above). ### FIFO `SequenceNumber` is broker-derived [#fifo-sequencenumber-is-broker-derived] FIFO `SequenceNumber` is derived from the broker-assigned timestamp and zero-padded to 20 digits. On send it reflects the broker send-timestamp; on receive it reflects the true broker sequence (still per-group increasing). It is **not** a monotonic counter matching AWS semantics — do not use it for ordering comparisons across producers. ### FIFO topic `ContentBasedDeduplication` not supported [#fifo-topic-contentbaseddeduplication-not-supported] Topic-level `ContentBasedDeduplication` on a FIFO topic is unsupported: setting it returns `InvalidParameter` and getting it always reads `"false"`. Deduplication is enforced on the target FIFO queues, not at the topic level — pass an explicit `MessageDeduplicationId` instead. ### Message retention clamped [#message-retention-clamped] `MessageRetentionPeriod` is clamped to `Connectors.Aws.MaxExpirationSeconds` at send time. Changes to `SetQueueAttributes` retention are **not** retroactive to messages already in the queue (AWS applies them retroactively). ### SNS notification signatures are unsigned by default [#sns-notification-signatures-are-unsigned-by-default] By default (`Connectors.Aws.MessageSigning = false`) the SNS notification envelope is unsigned (`SignatureVersion: "1"`, empty `Signature` / `SigningCertURL`) — webhook consumers must skip verification. Set `Connectors.Aws.MessageSigning = true` to emit `SignatureVersion: "2"` SigV2 RSA-SHA256 signatures (self-signed cert; not Amazon-rooted, so SDK verifiers that pin Amazon's cert chain still won't validate it). ### Receipt handles and the in-flight tracker are node-local [#receipt-handles-and-the-in-flight-tracker-are-node-local] In a cluster, `DeleteMessage` and `ChangeMessageVisibility` must reach the **same node** that served the `ReceiveMessage`. The receipt handle encodes the node ID; other nodes return `ReceiptHandleIsInvalid`. Use session-sticky (source-IP or connection-sticky) load balancing, or pin each consumer to one node. ### Hard crash loses in-flight messages and pending webhook retries [#hard-crash-loses-in-flight-messages-and-pending-webhook-retries] KubeMQ's downstream read is destructive. Only the graceful-shutdown path returns in-flight messages to their queues; SNS delivery/retry state is in-memory on the publishing node, so a hard kill loses it. ### Native producers bypass SQS policy stamping [#native-producers-bypass-sqs-policy-stamping] Messages published to `sqs.*` channels by native KubeMQ gRPC/REST clients carry no per-message retention or redrive policy and no `sqs_*` identity tags. ### Empty-queue short-poll latency [#empty-queue-short-poll-latency] A `ReceiveMessage` with `WaitTimeSeconds=0` on an empty queue returns within \~1 second instead of immediately (the broker's downstream wait granularity has a 1-second minimum). ### Other out-of-scope operations [#other-out-of-scope-operations] The following simply **won't work** (see [Capabilities](/connectors/aws/reference/capabilities)): KMS / SSE, `AddPermission` / `RemovePermission`, SQS message-move tasks, signed-notification verification, extended-client messages over 256 KiB, and CloudWatch metrics emulation. Cross-account is unsupported — `QueueOwnerAWSAccountId` is accepted and ignored; there is a single configurable `AccountId`. ## Verification Smoke Test [#verification-smoke-test] Run this after enabling the connector (`CONNECTORS_AWS_ENABLE=true`) and recreating any needed queues. It is the same `boto3` 1.x code as a one-pass send → receive → delete confirmation: ```python import boto3 ENDPOINT = "http://kubemq-host:4566" CREDS = dict( endpoint_url=ENDPOINT, region_name="kubemq", aws_access_key_id="AKIAEXAMPLE", aws_secret_access_key="secret", ) sqs = boto3.client("sqs", **CREDS) # 1. Ensure the queue exists queue_url = sqs.create_queue(QueueName="smoke-test")["QueueUrl"] # 2. Publish one message sqs.send_message(QueueUrl=queue_url, MessageBody="smoke-test-payload") print("Sent: smoke-test-payload") # 3. Receive and confirm arrival resp = sqs.receive_message(QueueUrl=queue_url, WaitTimeSeconds=5) msgs = resp.get("Messages", []) assert msgs, "ERROR: no message received" assert msgs[0]["Body"] == "smoke-test-payload", f"Unexpected body: {msgs[0]['Body']}" print(f"Received: {msgs[0]['Body']}") # 4. Acknowledge sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=msgs[0]["ReceiptHandle"]) print("Acknowledged. Smoke test PASSED.") ``` ## See Also [#see-also] # Configuration model (/connectors/cloudevents/concepts/configuration-model) The CloudEvents connector is configured through the `Connectors.CE` section of the KubeMQ server config. It is **enabled by default** and ships with sensible production defaults, so most deployments only override a value when they need a larger SSE buffer or a hard cap on concurrent subscriptions. This page explains the enable model and how config keys map to environment variable names — for the full field list, validation rules, and TOML/Env/Docker examples, see the [full field reference](../reference/configuration). ## Enabled by default [#enabled-by-default] The CloudEvents connector is **on as soon as kubemq-server starts** — the `/ce/*` routes on the [shared HTTP server](/connectors/concepts/shared-http-server) are live with no flag to set. You never enable it with `=true`; you only set its enable variable to `false` to turn it **off**. **The enable variable is `CONNECTORSCE_ENABLE` — with no underscore between `CONNECTORS` and `CE`.** Older KubeMQ docs and the configuration reference list `CONNECTORS_CE_ENABLE` (with an underscore), which **does not bind** to the `Connectors.CE.Enable` field and is silently ignored. The correct name is derived from the server's env-var transform — see [Environment variable names](#environment-variable-names) below and the [shared HTTP server enable model](/connectors/concepts/shared-http-server#enable-model-on-by-default). ## Environment variable names [#environment-variable-names] KubeMQ derives each environment variable from its dotted config key by inserting underscores at word boundaries, dropping the dots, and upper-casing. Acronyms with no lowercase boundary (like `CE`) stay joined to the preceding segment — which is why the enable variable is `CONNECTORSCE_ENABLE` and **not** `CONNECTORS_CE_ENABLE`. The full config key to environment variable mapping table is in the [configuration reference](../reference/configuration#environment-variable-names). The CloudEvents connector shares the HTTP server's port (`9090`), body limit, CORS, and TLS settings. Those are configured under `Connectors.Http` and documented once in [Shared HTTP server](/connectors/concepts/shared-http-server) — not repeated here. # Authentication (/connectors/cloudevents/how-to/authentication) The CloudEvents connector has no authentication of its own. It shares the [auth middleware](/connectors/reference/auth-and-security) of the shared HTTP server with the REST, MCP, and A2A connectors, so a single JWT Bearer token secures every `/ce/*` endpoint. ## Overview [#overview] When authentication is enabled on the KubeMQ server, every CloudEvents request — both publishing (`POST /ce/send/*`, `POST /ce/queue/*`) and subscribing (`GET /ce/subscribe/*`) — must carry a valid JWT in the `Authorization` header: ```http Authorization: Bearer ``` Because `/ce/*` endpoints are standard HTTP (not JSON-RPC), an authentication failure returns **HTTP 401 Unauthorized**, unlike the MCP and A2A gateways, which return the JSON-RPC `-32010` error code. When authentication is disabled, requests are accepted with synthetic anonymous claims and no header is required. Token issuance and validation (shared secret vs OIDC provider, claim schema) are configured server-wide. See [Auth & security](/connectors/reference/auth-and-security) for the full model — public routes, CORS, origin validation, and TLS/mTLS. ## How it works [#how-it-works] A CloudEvents request passes through the shared auth middleware before reaching the CE handler; the verified `ClientID` claim flows downstream and becomes the KubeMQ message identity. *The shared auth middleware verifies the Bearer token, then hands the resolved ClientID to the CloudEvents connector.* ## ClientID resolution [#clientid-resolution] The KubeMQ `ClientID` attached to a published message is resolved with authentication taking priority over the event payload: | Authentication | ClientID source | | --------------------- | ---------------------------------------------- | | Disabled | CloudEvent `source` attribute | | Enabled (valid token) | JWT claims `ClientID` — **overrides** `source` | When auth is enabled, the CloudEvent `source` is still preserved as the `ce_source` tag (so the event round-trips intact), but the KubeMQ `ClientID` is set from the verified token. This ensures identity is controlled by the authentication system rather than by client-supplied data. The same override applies to the `client_id` query parameter on queue and subscribe endpoints. ## Authenticated publish [#authenticated-publish] Add the `Authorization` header to any send request. The token format and header are identical across all CloudEvents endpoints — events, events-store, queues, commands, and queries. ```bash curl -X POST http://localhost:9090/ce/send/event \ -H "Authorization: Bearer $JWT_TOKEN" \ -H "Content-Type: application/cloudevents+json" \ -d '{ "specversion": "1.0", "type": "com.example.order.created", "source": "order-service", "subject": "orders", "data": {"order_id": "12345", "amount": 99.99} }' ``` ```go req, _ := http.NewRequest("POST", base+"/ce/send/event", strings.NewReader(string(body))) req.Header.Set("Content-Type", "application/cloudevents+json") req.Header.Set("Authorization", "Bearer "+os.Getenv("JWT_TOKEN")) resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatal("send event:", err) } defer resp.Body.Close() ``` ```python headers, body = to_structured(event) headers["Authorization"] = f"Bearer {os.environ['JWT_TOKEN']}" resp = requests.post( f"{base}/ce/send/event", data=body, headers=dict(headers), timeout=10, ) ``` ```typescript const message = HTTP.structured(event); const resp = await fetch(`${base}/ce/send/event`, { method: 'POST', headers: { ...(message.headers as Record), Authorization: `Bearer ${process.env.JWT_TOKEN}`, }, body: message.body as string, }); ``` ```java HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(base + "/ce/send/event")) .POST(HttpRequest.BodyPublishers.ofByteArray(body)) .header("Content-Type", "application/cloudevents+json") .header("Authorization", "Bearer " + System.getenv("JWT_TOKEN")) .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); ``` ```csharp using var content = new ByteArrayContent(eventBytes.ToArray()); content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType.ToString()); using var httpClient = new HttpClient(); httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("JWT_TOKEN")); var resp = await httpClient.PostAsync($"{base_}/ce/send/event", content); ``` ```ruby headers, body = sdk.encode_event(event, structured_format: "json") uri = URI("#{base}/ce/send/event") Net::HTTP.start(uri.host, uri.port) do |http| req = Net::HTTP::Post.new(uri) req["Content-Type"] = headers["Content-Type"] req["Authorization"] = "Bearer #{ENV.fetch('JWT_TOKEN')}" req.body = body res = http.request(req) end ``` ```rust let body = serde_json::to_string(&event)?; let resp = client .post(format!("{}/ce/send/event", base)) .header("Content-Type", "application/cloudevents+json") .header("Authorization", format!("Bearer {}", env::var("JWT_TOKEN")?)) .body(body) .send() .await?; ``` ## Authenticated subscribe [#authenticated-subscribe] SSE subscriptions are long-lived `GET` requests; the Bearer token is sent once when the stream is opened. ```bash curl -N "http://localhost:9090/ce/subscribe/events?client_id=my-client&channel=orders" \ -H "Authorization: Bearer $JWT_TOKEN" \ -H "Accept: text/event-stream" ``` ```go req, _ := http.NewRequest("GET", sseURL, nil) req.Header.Set("Accept", "text/event-stream") req.Header.Set("Cache-Control", "no-cache") req.Header.Set("Authorization", "Bearer "+os.Getenv("JWT_TOKEN")) resp, err := http.DefaultClient.Do(req) ``` ```python with requests.get(sse_url, stream=True, timeout=None, headers={"Accept": "text/event-stream", "Cache-Control": "no-cache", "Authorization": f"Bearer {os.environ['JWT_TOKEN']}"}) as resp: for raw_line in resp.iter_lines(decode_unicode=True): ... ``` ```typescript const es = new EventSource(sseUrl, { headers: { Authorization: `Bearer ${process.env.JWT_TOKEN}` }, }); es.addEventListener('cloudevent', (evt: MessageEvent) => { console.log(JSON.parse(evt.data)); }); ``` ```java HttpURLConnection conn = (HttpURLConnection) new URL(sseUrl).openConnection(); conn.setRequestMethod("GET"); conn.setRequestProperty("Accept", "text/event-stream"); conn.setRequestProperty("Cache-Control", "no-cache"); conn.setRequestProperty("Authorization", "Bearer " + System.getenv("JWT_TOKEN")); conn.setDoInput(true); ``` ```csharp using var request = new HttpRequestMessage(HttpMethod.Get, sseUrl); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("JWT_TOKEN")); using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); ``` ```ruby Net::HTTP.start(uri.host, uri.port) do |http| req = Net::HTTP::Get.new(uri) req["Accept"] = "text/event-stream" req["Cache-Control"] = "no-cache" req["Authorization"] = "Bearer #{ENV.fetch('JWT_TOKEN')}" http.request(req) do |resp| ... end end ``` ```rust let stream = client .get(&sub_url) .header("Accept", "text/event-stream") .header("Cache-Control", "no-cache") .header("Authorization", format!("Bearer {}", env::var("JWT_TOKEN")?)) .send() .await? .bytes_stream(); ``` ## Running without authentication [#running-without-authentication] When authentication is disabled in the server configuration, the CloudEvents connector accepts every request with synthetic anonymous claims: * No `Authorization` header is required. * The CloudEvent `source` attribute is used directly as the KubeMQ `ClientID`. * The examples throughout the CloudEvents docs assume this mode unless stated otherwise. Running without authentication is for development and trusted networks only. Enable JWT authentication before exposing the CloudEvents connector in production. ## Related [#related] # CESQL Routing (/connectors/cloudevents/how-to/cesql-routing) CESQL (CloudEvents SQL) lets the KubeMQ routing table make fan-out decisions from a CloudEvent's **attributes** — its `type`, `source`, `subject`, and extensions — instead of matching on the channel name. Publishers send to one channel; the server evaluates CESQL rules against the `ce_*` tags and routes each event to the matching destinations. ## Overview [#overview] The CloudEvents connector maps every CE attribute onto a KubeMQ message tag with a `ce_` prefix (`type` → `ce_type`, `source` → `ce_source`, and so on). The server-side **routing table** can carry CESQL rules alongside regular regex rules: each rule with `keyType: "cesql"` is a boolean expression evaluated against those attributes. When the expression returns `true`, the rule's `routes` are applied — potentially fanning the event across events, events store, and queues at once. This is **content-based routing**: the decision comes from the message's attributes, not its body or channel name. It is configured entirely on the server (no client code change), and any message carrying `ce_*` tags — including gRPC or REST messages that set `ce_specversion` — is eligible, not just CE-connector traffic. | Concept | Detail | | ----------------- | --------------------------------------------------------------------------------------- | | Rule type | `keyType: "cesql"` entry in the routing table | | Evaluated against | CloudEvent attributes reconstructed from `ce_*` tags | | Eligible messages | Any message with a `ce_specversion` tag | | Destinations | Standard routing syntax (`events:`, `events_store:`, `queues:`) with `{ce_*}` templates | | Failure mode | Fail-open — a failing rule is skipped, delivery continues | CESQL routing is part of the **server-wide routing engine**, not a CE-only feature. This guide covers the CESQL-specific behavior. ## How it works [#how-it-works] A publisher sends a CloudEvent to a source channel; the connector tags it with `ce_*` attributes and hands it to the routing engine, which evaluates each CESQL rule and fans the event out to every matching destination channel. *The routing table evaluates each CESQL rule against the event's attributes and fans it out to every matching channel.* ## Configuration [#configuration] CESQL rules live in the routing table. Set `keyType` to `"cesql"` and put a CESQL expression in `key`; the `routes` field uses the standard routing syntax and supports `{ce_*}` templates. ```json [ { "key": "type = 'com.example.order.created'", "keyType": "cesql", "routes": "events_store:order-archive;queues:order-processing" }, { "key": "source = 'audit-service'", "keyType": "cesql", "routes": "events_store:{ce_source}-log" }, { "key": "type LIKE 'com.example.%' AND source = 'critical-service'", "keyType": "cesql", "routes": "events:alerts" } ] ``` Enable routing and supply the table inline, from a file, or from a URL: ```toml [Routing] Enable = true # Inline JSON (note the escaped single quotes inside the CESQL expression): Data = '[{"key":"type = '\''com.example.order.created'\''","keyType":"cesql","routes":"queues:order-processing"}]' # Or load from a file: # FilePath = "/etc/kubemq/routes.json" # Or from a config service: # URL = "http://config-service/routes" AutoReload = 0 ``` In CESQL expressions, reference attributes by their **plain CloudEvents name** (`type`, `source`, `subject`, `id`) with **no** `ce_` prefix. The `ce_` prefix only appears in `{ce_*}` template placeholders on the `routes` side. ## Supported operators [#supported-operators] KubeMQ uses the CloudEvents SDK CESQL parser. The following operators and functions are available: | Category | Operators / Functions | Example | | ---------------- | -------------------------------------------------------------------------- | ------------------------------------ | | Comparison | `=`, `!=`, `<>`, `<`, `<=`, `>`, `>=` | `type = 'order.created'` | | Logical | `AND`, `OR`, `NOT` | `type = 'order' AND source = '/app'` | | String match | `LIKE` (with `%` wildcard) | `type LIKE 'order.%'` | | Existence | `EXISTS` | `EXISTS priority` | | Set | `IN` | `type IN ('a', 'b', 'c')` | | String functions | `CONCAT`, `LENGTH`, `LOWER`, `UPPER`, `TRIM`, `LEFT`, `RIGHT`, `SUBSTRING` | `LENGTH(type) > 10` | | Type checks | `IS_BOOL`, `IS_INT` | `IS_INT(source)` | | Math | `+`, `-`, `*`, `/`, `%` | integer attribute comparisons | ## Template substitution [#template-substitution] Route destinations can embed CE attributes with `{ce_*}` placeholders, letting one rule fan out to dynamically named channels: | Placeholder | Substituted with | | -------------- | ------------------------------------------- | | `{ce_type}` | The CloudEvent `type` attribute | | `{ce_source}` | The CloudEvent `source` attribute | | `{ce_subject}` | The CloudEvent `subject` attribute | | `{ce_id}` | The CloudEvent `id` attribute | | `{ce_*}` | Any CE attribute or extension from the tags | For example, this rule routes each event to a channel named after its type: ```json { "key": "type LIKE 'com.example.%'", "keyType": "cesql", "routes": "queues:{ce_type}" } ``` A message with `type: "com.example.order.created"` is routed to `queues:com.example.order.created`. Semicolons in attribute values are stripped during substitution to prevent route injection. ## Evaluation scope [#evaluation-scope] CESQL rules are evaluated against **any message that carries `ce_*` tags**, regardless of which connector produced it: * Messages from the CE connector automatically have `ce_*` tags and are evaluated. * Messages sent over gRPC or REST that include `ce_specversion` and other `ce_*` tags are also evaluated. * Messages **without** `ce_*` tags skip CESQL rules entirely — they are only matched against regex rules. CESQL and regex rules coexist in the same table. Each message is evaluated against all rules in order: CESQL rules match on attributes, regex rules match on the channel name. ## Error behavior [#error-behavior] CESQL evaluation is **fail-open**: * If an expression fails to evaluate (for example, a type mismatch), that rule is skipped, a warning is logged, and the remaining rules still run. * Invalid expressions are caught when the routing table is loaded; the offending entry is skipped with a warning. * The original message delivery is never blocked by a routing failure. ## Usage [#usage] CESQL routing is configured on the server, so the client side is just a normal CloudEvent publish. The examples below publish events whose `type` matches the rules above, then subscribe to the destination channels to confirm the routing took effect. These examples require KubeMQ to be running **with the CESQL routing rules configured** (the three-rule table shown in the comments below). They publish to a source channel and read from the routed destination channels. ```bash # Publish a CloudEvent — the server's CESQL rules route it by `type`. curl -X POST http://localhost:9090/ce/send/event \ -H "Content-Type: application/cloudevents+json" \ -d '{ "specversion": "1.0", "type": "com.kubemq.examples.routing.order", "source": "kubemq-ce-curl-cesql", "subject": "routing-source", "datacontenttype": "application/json", "data": {"description": "Event of type order"} }' # Read the routed destination channel (CESQL routed `order` -> events:order-archive). curl -N "http://localhost:9090/ce/subscribe/events?client_id=curl-order-sub&channel=order-archive" ``` ```csharp // Example: routing/CesqlRouting — publish events for server-side CESQL routing. // Requires KubeMQ configured with CESQL rules (see Go example for config). // Run: dotnet run using CloudNative.CloudEvents; using CloudNative.CloudEvents.SystemTextJson; using System.Net.Http.Headers; using System.Text; using System.Text.Json; static string ServerUrl() => Environment.GetEnvironmentVariable("KUBEMQ_CE_URL") ?? "http://localhost:9090"; var base_ = ServerUrl(); var formatter = new JsonEventFormatter(); Console.WriteLine("CESQL Routing Example — C#"); Console.WriteLine("Requires KubeMQ with CESQL routing configured.\n"); var results = new System.Collections.Concurrent.ConcurrentQueue(); // Subscribe to routed destination channels. async Task StartSubscriber(string ch, string clientId, int maxEvents) { using var http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan }; var url = $"{base_}/ce/subscribe/events?client_id={clientId}&channel={Uri.EscapeDataString(ch)}"; using var req = new HttpRequestMessage(HttpMethod.Get, url); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); using var resp = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead); using var reader = new StreamReader(await resp.Content.ReadAsStreamAsync(), Encoding.UTF8); string? evType = null, data = null, line; int count = 0; while ((line = await reader.ReadLineAsync()) != null && count < maxEvents) { if (line == "") { if (evType == "cloudevent" && data != null) { var ce = JsonSerializer.Deserialize(data); results.Enqueue($" [{ch}] type={ce.GetProperty("type")}"); count++; } evType = null; data = null; } else if (line.StartsWith("event:")) evType = line[6..].Trim(); else if (line.StartsWith("data:")) data = line[5..].Trim(); } } var subTasks = new List { Task.Run(() => StartSubscriber("order-archive", "csharp-order-sub", 1)), Task.Run(() => StartSubscriber("alert-stream", "csharp-alert-sub", 1)), Task.Run(() => StartSubscriber("all-events", "csharp-all-sub", 3)), }; await Task.Delay(500); using var httpClient = new HttpClient(); var eventTypes = new[] { "com.kubemq.examples.routing.order", "com.kubemq.examples.routing.alert", "com.kubemq.examples.routing.info", }; foreach (var evType in eventTypes) { var ev = new CloudEvent { Id = Guid.NewGuid().ToString(), Type = evType, Source = new Uri("urn:kubemq-ce-csharp-cesql"), Subject = "routing-source", DataContentType = "application/json", Data = new { description = $"Event of type {evType}" }, }; var bytes = formatter.EncodeStructuredModeMessage(ev, out var ct); using var c = new ByteArrayContent(bytes.ToArray()); c.Headers.ContentType = MediaTypeHeaderValue.Parse(ct.ToString()); var r = await httpClient.PostAsync($"{base_}/ce/send/event", c); Console.WriteLine($"Published type={evType} (status={r.StatusCode})"); } await Task.Delay(2000); while (results.TryDequeue(out var msg)) Console.WriteLine(msg); Console.WriteLine("\nCESQL routing demonstration complete."); ``` ```go // Example: routing/cesql-routing // // Demonstrates KubeMQ CESQL routing with CloudEvents. // CESQL routing is SERVER-SIDE configuration — this example shows how to // publish events whose attributes match CESQL expressions, then verifies // routing worked by subscribing to the target channels. // // Configure KubeMQ with the following routing rules before running: // // [Routing] // Enable = true // Data = '[ // {"key":"type = '\''com.kubemq.examples.routing.order'\''","keyType":"cesql","routes":"events:order-archive"}, // {"key":"type = '\''com.kubemq.examples.routing.alert'\''","keyType":"cesql","routes":"events:alert-stream"}, // {"key":"type LIKE '\''com.kubemq.examples.routing.%'\''","keyType":"cesql","routes":"events:all-events"} // ]' // // Run: go run ./routing/cesql-routing/main.go package main import ( "bufio" "encoding/json" "fmt" "net/http" "os" "strings" "time" cloudevents "github.com/cloudevents/sdk-go/v2" ) func serverURL() string { if u := os.Getenv("KUBEMQ_CE_URL"); u != "" { return u } return "http://localhost:9090" } func sendEvent(base, evType, channel string) { event := cloudevents.NewEvent() event.SetType(evType) event.SetSource("kubemq-ce-go-cesql") event.SetSubject(channel) _ = event.SetData(cloudevents.ApplicationJSON, map[string]string{ "description": fmt.Sprintf("Event of type %s", evType), }) body, _ := json.Marshal(event) req, _ := http.NewRequest("POST", base+"/ce/send/event", strings.NewReader(string(body))) req.Header.Set("Content-Type", "application/cloudevents+json") resp, err := http.DefaultClient.Do(req) if err != nil { fmt.Printf("send error: %v\n", err) return } defer resp.Body.Close() fmt.Printf("Published type=%s to channel=%s (status=%d)\n", evType, channel, resp.StatusCode) } func subscribeAndPrint(base, channel, clientID string, maxMsgs int) { sseURL := fmt.Sprintf("%s/ce/subscribe/events?client_id=%s&channel=%s", base, clientID, channel) req, _ := http.NewRequest("GET", sseURL, nil) req.Header.Set("Accept", "text/event-stream") req.Header.Set("Cache-Control", "no-cache") client := &http.Client{Timeout: 0} resp, err := client.Do(req) if err != nil { fmt.Printf("[%s] subscribe error: %v\n", channel, err) return } defer resp.Body.Close() count := 0 scanner := bufio.NewScanner(resp.Body) var evType, data string for scanner.Scan() { line := scanner.Text() if line == "" { if evType == "cloudevent" && data != "" { var ce map[string]interface{} _ = json.Unmarshal([]byte(data), &ce) fmt.Printf(" [%s] type=%v\n", channel, ce["type"]) count++ if count >= maxMsgs { return } } evType, data = "", "" continue } if strings.HasPrefix(line, ":") { continue } if strings.HasPrefix(line, "event:") { evType = strings.TrimSpace(strings.TrimPrefix(line, "event:")) } else if strings.HasPrefix(line, "data:") { data = strings.TrimSpace(strings.TrimPrefix(line, "data:")) } } } func main() { base := serverURL() // Subscribe to routed destination channels. go subscribeAndPrint(base, "order-archive", "go-cesql-order-sub", 1) go subscribeAndPrint(base, "alert-stream", "go-cesql-alert-sub", 1) go subscribeAndPrint(base, "all-events", "go-cesql-all-sub", 3) time.Sleep(500 * time.Millisecond) // Publish events — CESQL rules on the server route them to target channels. sendEvent(base, "com.kubemq.examples.routing.order", "routing-source") sendEvent(base, "com.kubemq.examples.routing.alert", "routing-source") sendEvent(base, "com.kubemq.examples.routing.info", "routing-source") // Allow routing to complete. time.Sleep(2 * time.Second) fmt.Println("\nCESQL routing demonstration complete.") } ``` ```java // Example: routing/cesql-routing // // Demonstrates server-side CESQL routing. Events with different type values // are published to the source channel. KubeMQ routes them based on CESQL // expressions to different destination channels. // // Requires KubeMQ configured with CESQL routing rules, e.g.: // [Routing] // Enable = true // Data = '[{"key":"type = ''com.kubemq.examples.routing.order''","keyType":"cesql","routes":"events:order-archive"},...]' // // Run: mvn compile exec:java package io.kubemq.examples.routing.cesqlrouting; import com.fasterxml.jackson.databind.ObjectMapper; import io.cloudevents.CloudEvent; import io.cloudevents.core.builder.CloudEventBuilder; import io.cloudevents.core.format.EventFormat; import io.cloudevents.core.provider.EventFormatProvider; import io.cloudevents.jackson.JsonFormat; import java.io.BufferedReader; import java.io.InputStreamReader; import java.net.HttpURLConnection; import java.net.URI; import java.net.URL; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.OffsetDateTime; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; public class Main { static String serverUrl() { String u = System.getenv("KUBEMQ_CE_URL"); return (u != null && !u.isEmpty()) ? u : "http://localhost:9090"; } static final ObjectMapper MAPPER = new ObjectMapper(); static void startSubscriber(String base, String channel, String clientId, int maxEvents, BlockingQueue results) { String sseUrl = base + "/ce/subscribe/events?client_id=" + clientId + "&channel=" + channel; Thread.ofVirtual().start(() -> { try { HttpURLConnection conn = (HttpURLConnection) new URL(sseUrl).openConnection(); conn.setRequestProperty("Accept", "text/event-stream"); conn.setReadTimeout(15_000); try (BufferedReader reader = new BufferedReader( new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { String line; String evType = null, data = null; int count = 0; while ((line = reader.readLine()) != null && count < maxEvents) { if (line.isEmpty()) { if ("cloudevent".equals(evType) && data != null) { Map ce = MAPPER.readValue(data, Map.class); results.offer(" [" + channel + "] type=" + ce.get("type")); count++; } evType = null; data = null; } else if (line.startsWith("event:")) evType = line.substring(6).trim(); else if (line.startsWith("data:")) data = line.substring(5).trim(); } } } catch (Exception e) { /* read ended */ } }); } public static void main(String[] args) throws Exception { String base = serverUrl(); System.out.println("CESQL Routing Example — Java"); EventFormatProvider.getInstance().registerFormat(new JsonFormat()); EventFormat format = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE); HttpClient httpClient = HttpClient.newHttpClient(); // Subscribe to routed destination channels. BlockingQueue results = new ArrayBlockingQueue<>(20); startSubscriber(base, "order-archive", "java-order-sub", 1, results); startSubscriber(base, "alert-stream", "java-alert-sub", 1, results); startSubscriber(base, "all-events", "java-all-sub", 3, results); Thread.sleep(500); // Publish events with different type values; CESQL rules route them. List eventTypes = List.of( "com.kubemq.examples.routing.order", "com.kubemq.examples.routing.alert", "com.kubemq.examples.routing.info" ); for (String evType : eventTypes) { CloudEvent event = CloudEventBuilder.v1() .withId(UUID.randomUUID().toString()) .withType(evType) .withSource(URI.create("kubemq-ce-java-cesql")) .withSubject("routing-source") .withDataContentType("application/json") .withTime(OffsetDateTime.now()) .withData("application/json", MAPPER.writeValueAsBytes(Map.of("description", "Event of type " + evType))) .build(); HttpResponse resp = httpClient.send( HttpRequest.newBuilder().uri(URI.create(base + "/ce/send/event")) .POST(HttpRequest.BodyPublishers.ofByteArray(format.serialize(event))) .header("Content-Type", "application/cloudevents+json").build(), HttpResponse.BodyHandlers.ofString()); System.out.println("Published type=" + evType + " (status=" + resp.statusCode() + ")"); } // Collect routed events (up to 5 with timeout). Thread.sleep(2000); for (int i = 0; i < 5; i++) { String msg = results.poll(100, TimeUnit.MILLISECONDS); if (msg != null) System.out.println(msg); } System.out.println("\nCESQL routing demonstration complete."); } } ``` ```typescript /** * Example: routing/cesql-routing — publish events for CESQL server-side routing. * Requires KubeMQ configured with CESQL rules (see Go example for config). * Run: npx tsx routing/cesql-routing/index.ts */ import { CloudEvent, HTTP } from 'cloudevents'; import EventSource from 'eventsource'; function serverUrl(): string { return process.env.KUBEMQ_CE_URL ?? 'http://localhost:9090'; } function subscribeAndCollect( base: string, channel: string, clientId: string, max: number, results: string[], ): void { const url = `${base}/ce/subscribe/events?client_id=${clientId}&channel=${encodeURIComponent(channel)}`; const es = new EventSource(url); let count = 0; es.addEventListener('cloudevent', (evt: MessageEvent) => { const ce = JSON.parse(evt.data) as Record; results.push(` [${channel}] type=${ce.type}`); count++; if (count >= max) es.close(); }); es.addEventListener('error', (err) => { console.error('SSE error:', err); es.close(); }); } async function main(): Promise { const base = serverUrl(); console.log('CESQL Routing Example — JavaScript/TypeScript'); const results: string[] = []; subscribeAndCollect(base, 'order-archive', 'js-order-sub', 1, results); subscribeAndCollect(base, 'alert-stream', 'js-alert-sub', 1, results); subscribeAndCollect(base, 'all-events', 'js-all-sub', 3, results); await new Promise((r) => setTimeout(r, 500)); for (const evType of [ 'com.kubemq.examples.routing.order', 'com.kubemq.examples.routing.alert', 'com.kubemq.examples.routing.info', ]) { const event = new CloudEvent({ type: evType, source: 'kubemq-ce-js-cesql', subject: 'routing-source', datacontenttype: 'application/json', data: { description: `Event of type ${evType}` }, }); const msg = HTTP.structured(event); const resp = await fetch(`${base}/ce/send/event`, { method: 'POST', headers: msg.headers as Record, body: msg.body as string, }); console.log(`Published type=${evType} (status=${resp.status})`); } await new Promise((r) => setTimeout(r, 2000)); for (const r of results) console.log(r); console.log('\nCESQL routing demonstration complete.'); } main().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); }); ``` ```python # Example: routing/cesql_routing — publish events for server-side CESQL routing. # # CESQL routing is SERVER-SIDE configuration. Required KubeMQ config (TOML): # [Routing] # Enable = true # Data = '[ # {"key":"type = '\''com.kubemq.examples.routing.order'\''","keyType":"cesql","routes":"events:order-archive"}, # {"key":"type = '\''com.kubemq.examples.routing.alert'\''","keyType":"cesql","routes":"events:alert-stream"}, # {"key":"type LIKE '\''com.kubemq.examples.routing.%'\''","keyType":"cesql","routes":"events:all-events"} # ]' from __future__ import annotations import json import os import threading import time import requests from cloudevents.v1.conversion import to_structured from cloudevents.v1.http import CloudEvent def server_url() -> str: return os.environ.get("KUBEMQ_CE_URL", "http://localhost:9090") def subscribe_and_print(base: str, channel: str, client_id: str, results: list[str], max_msgs: int) -> None: sse_url = (f"{base}/ce/subscribe/events" f"?client_id={client_id}&channel={channel}") with requests.get(sse_url, stream=True, timeout=None, headers={"Accept": "text/event-stream"}) as resp: ev_type = data = "" count = 0 for line in resp.iter_lines(decode_unicode=True): if line == "": if ev_type == "cloudevent" and data: ce = json.loads(data) results.append(f" [{channel}] type={ce.get('type')}") count += 1 if count >= max_msgs: return ev_type = data = "" continue if line.startswith(":"): continue if line.startswith("event:"): ev_type = line[6:].strip() elif line.startswith("data:"): data = line[5:].strip() def main() -> None: base = server_url() print("CESQL Routing Example — Python\n") results: list[str] = [] # Subscribe to routed channels. for ch, cid, n in [("order-archive", "py-order-sub", 1), ("alert-stream", "py-alert-sub", 1), ("all-events", "py-all-sub", 3)]: t = threading.Thread( target=subscribe_and_print, args=(base, ch, cid, results, n), daemon=True, ) t.start() time.sleep(0.5) for ev_type in [ "com.kubemq.examples.routing.order", "com.kubemq.examples.routing.alert", "com.kubemq.examples.routing.info", ]: event = CloudEvent( attributes={ "type": ev_type, "source": "kubemq-ce-python-cesql", "subject": "routing-source", "datacontenttype": "application/json", }, data={"description": f"Event of type {ev_type}"}, ) headers, body = to_structured(event) resp = requests.post(f"{base}/ce/send/event", data=body, headers=dict(headers), timeout=10) print(f"Published type={ev_type} (status={resp.status_code})") time.sleep(2) for r in results: print(r) print("\nCESQL routing demonstration complete.") if __name__ == "__main__": main() ``` ```ruby # Example: routing/cesql_routing — publish events for server-side CESQL routing. # Requires KubeMQ configured with CESQL rules (see Go example for config). require "net/http"; require "uri"; require "json"; require "timeout"; require "securerandom"; require "cloud_events" def server_url = ENV.fetch("KUBEMQ_CE_URL", "http://localhost:9090") base = server_url; sdk = CloudEvents::HttpBinding.default puts "CESQL Routing Example — Ruby\n" results = Queue.new [["order-archive","ruby-order-sub",1],["alert-stream","ruby-alert-sub",1],["all-events","ruby-all-sub",3]].each do |ch, cid, max| Thread.new do uri = URI("#{base}/ce/subscribe/events?client_id=#{cid}&channel=#{URI.encode_www_form_component(ch)}") count = 0 Net::HTTP.start(uri.host, uri.port) do |http| req = Net::HTTP::Get.new(uri); req["Accept"] = "text/event-stream" http.request(req) do |resp| ev_type = nil; data = nil resp.read_body do |chunk| chunk.each_line do |line| line.chomp! if line.empty? if ev_type == "cloudevent" && data ce = JSON.parse(data) results.push(" [#{ch}] type=#{ce['type']}") count += 1 Thread.exit if count >= max end ev_type = nil; data = nil elsif line.start_with?("event:") then ev_type = line.sub("event:","").strip elsif line.start_with?("data:") then data = line.sub("data:","").strip end end end end end end end sleep 0.5 %w[com.kubemq.examples.routing.order com.kubemq.examples.routing.alert com.kubemq.examples.routing.info].each do |ev_type| ev = CloudEvents::Event::V1.new( id: SecureRandom.uuid, type: ev_type, source: URI("urn:kubemq-ce-ruby-cesql"), subject: "routing-source", spec_version: "1.0", data_content_type: CloudEvents::ContentType.new("application/json"), data: JSON.generate({ description: "Event of type #{ev_type}" })) enc_h, enc_b = sdk.encode_event(ev, structured_format: "json") uri = URI("#{base}/ce/send/event") Net::HTTP.start(uri.host, uri.port) do |http| req = Net::HTTP::Post.new(uri); enc_h.each{|k,v|req[k]=v}; req.body=enc_b res = http.request(req) puts "Published type=#{ev_type} (status=#{res.code})" end end sleep 2 5.times { r = begin; Timeout.timeout(0.1) { results.pop }; rescue Timeout::Error; nil; end; puts r if r } puts "\nCESQL routing demonstration complete." ``` ```rust //! Example: routing/cesql-routing //! //! Publishes events with different type values for server-side CESQL routing. //! Requires KubeMQ configured with CESQL routing rules. //! //! Run: cargo run -p cesql-routing use bytes::Bytes; use cloudevents::{EventBuilder, EventBuilderV10}; use futures_util::StreamExt; use reqwest::Client; use serde_json::{json, Value}; use std::env; use tokio::sync::mpsc; use uuid::Uuid; fn server_url() -> String { env::var("KUBEMQ_CE_URL").unwrap_or_else(|_| "http://localhost:9090".to_string()) } async fn start_subscriber(client: Client, base: String, channel: String, client_id: String, max: usize, tx: mpsc::Sender) { let url = format!("{}/ce/subscribe/events?client_id={}&channel={}", base, client_id, channel); let stream = client.get(&url) .header("Accept", "text/event-stream") .send().await.expect("SSE connect").bytes_stream(); let mut stream = Box::pin(stream); let mut ev_type = String::new(); let mut data = String::new(); let mut buffer = String::new(); let mut count = 0usize; while let Some(chunk) = stream.next().await { let chunk: Bytes = chunk.unwrap_or_default(); buffer.push_str(&String::from_utf8_lossy(&chunk)); while let Some(pos) = buffer.find('\n') { let line = buffer[..pos].trim_end_matches('\r').to_string(); buffer = buffer[pos + 1..].to_string(); if line.is_empty() { if ev_type == "cloudevent" && !data.is_empty() { let ce: Value = serde_json::from_str(&data).unwrap_or(Value::Null); let _ = tx.send(format!(" [{}] type={}", channel, ce["type"])).await; count += 1; if count >= max { return; } } ev_type.clear(); data.clear(); } else if line.starts_with(':') { } else if let Some(v) = line.strip_prefix("event:") { ev_type = v.trim().to_string(); } else if let Some(v) = line.strip_prefix("data:") { data = v.trim().to_string(); } } } } #[tokio::main] async fn main() -> Result<(), Box> { let base = server_url(); let client = Client::new(); println!("CESQL Routing Example — Rust"); let (tx, mut rx) = mpsc::channel::(20); // Subscribe to routed destination channels. tokio::spawn(start_subscriber(client.clone(), base.clone(), "order-archive".to_string(), "rust-order-sub".to_string(), 1, tx.clone())); tokio::spawn(start_subscriber(client.clone(), base.clone(), "alert-stream".to_string(), "rust-alert-sub".to_string(), 1, tx.clone())); tokio::spawn(start_subscriber(client.clone(), base.clone(), "all-events".to_string(), "rust-all-sub".to_string(), 3, tx.clone())); tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; // Publish events with different type values. let event_types = [ "com.kubemq.examples.routing.order", "com.kubemq.examples.routing.alert", "com.kubemq.examples.routing.info", ]; for ev_type in &event_types { let event = EventBuilderV10::new() .id(Uuid::new_v4().to_string()) .ty(*ev_type) .source("urn:kubemq-ce-rust-cesql") .subject("routing-source") .data("application/json", json!({"description": format!("Event of type {}", ev_type)})) .build()?; let body = serde_json::to_string(&event)?; let resp = client.post(format!("{}/ce/send/event", base)) .header("Content-Type", "application/cloudevents+json") .body(body).send().await?; println!("Published type={} (status={})", ev_type, resp.status()); } tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; while let Ok(msg) = rx.try_recv() { println!("{}", msg); } println!("\nCESQL routing demonstration complete."); Ok(()) } ``` ## Related [#related] # Channel Resolution (/connectors/cloudevents/how-to/channel-resolution) Every CloudEvent the connector receives must land on exactly one KubeMQ **channel**, and is attributed to one **ClientID**. The connector derives both from the event itself — no separate addressing layer — so a well-formed CloudEvent is self-routing. ## Overview [#overview] The CloudEvents connector accepts events over plain HTTP without any KubeMQ-specific addressing fields. Instead, it reads two things from each incoming event: * the **target channel** — which KubeMQ channel the message is published to, and * the **ClientID** — the identity recorded on the message. Both are resolved on a fixed priority order, with sensible auto-generation for the optional `id` and `time` attributes. Getting this mapping right keeps your producers portable: the same CloudEvent works against KubeMQ or any other CloudEvents endpoint. ## Channel resolution [#channel-resolution] The destination channel is resolved in priority order: 1. **CE `subject` attribute** — if the event sets `subject`, its value is used directly as the channel name. 2. **`?channel=` query parameter** — used only when `subject` is absent (or empty). 3. **HTTP 400** — if neither is present, the request is rejected with `{"is_error": true, "message": "channel is required"}`. This applies to every send endpoint (`/ce/send/event`, `/ce/send/event-store`, `/ce/send/command`, `/ce/send/query`, `/ce/queue/send`). SSE subscribe endpoints always take the channel from the required `?channel=` query parameter instead. ### How it works [#how-it-works] A `subject` on the event wins; otherwise the connector falls back to the `?channel=` query parameter, and rejects the request when neither is set. *The `subject` attribute resolves the channel first; `?channel=` is the fallback, and a missing channel is a 400.* ## Using `subject` (recommended) [#using-subject-recommended] The `subject` attribute is part of the standard CloudEvents envelope and carries semantic meaning — what the event is about. Using it for channel resolution makes the event **self-describing** and portable: the same event routes correctly without any KubeMQ-specific query string. The native examples below all set `subject` to the channel name (`event.SetSubject(channel)`, `.withSubject(channel)`, `subject=channel`, …), publish one CloudEvent, and confirm the subscriber received it on that channel. ```bash # subject sets the KubeMQ channel (structured mode) curl -X POST http://localhost:9090/ce/send/event \ -H "Content-Type: application/cloudevents+json" \ -d '{ "specversion": "1.0", "type": "com.example.order.created", "source": "order-service", "subject": "orders", "datacontenttype": "application/json", "data": {"order_id": "12345"} }' # binary mode — subject travels in the ce-subject header curl -X POST http://localhost:9090/ce/send/event \ -H "Content-Type: application/json" \ -H "ce-specversion: 1.0" \ -H "ce-type: com.example.order.created" \ -H "ce-source: order-service" \ -H "ce-subject: orders" \ -d '{"order_id": "12345"}' ``` ```csharp // Example: events/BasicPubSub // Run: dotnet run using CloudNative.CloudEvents; using CloudNative.CloudEvents.SystemTextJson; using System.Net.Http.Headers; static string ServerUrl() => Environment.GetEnvironmentVariable("KUBEMQ_CE_URL") ?? "http://localhost:9090"; var base_ = ServerUrl(); var channel = "csharp-ce-events.basic-pubsub"; var clientId = "kubemq-ce-csharp-example"; // Build and publish a CloudEvent (structured mode). var formatter = new JsonEventFormatter(); var cloudEvent = new CloudEvent { Id = Guid.NewGuid().ToString(), Type = "com.kubemq.examples.events.sent", Source = new Uri($"urn:{clientId}"), Subject = channel, // subject = KubeMQ channel DataContentType = "application/json", Data = new { message = "Hello from C# CloudEvents example!" }, }; var eventBytes = formatter.EncodeStructuredModeMessage(cloudEvent, out var contentType); using var httpClient = new HttpClient(); using var content = new ByteArrayContent(eventBytes.ToArray()); content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType.ToString()); var resp = await httpClient.PostAsync($"{base_}/ce/send/event", content); Console.WriteLine($"Published to channel '{channel}': status={resp.StatusCode}"); ``` ```go // Example: events/basic-pubsub // Run: go run ./events/basic-pubsub/main.go package main import ( "encoding/json" "fmt" "log" "net/http" "os" "strings" cloudevents "github.com/cloudevents/sdk-go/v2" ) func serverURL() string { if u := os.Getenv("KUBEMQ_CE_URL"); u != "" { return u } return "http://localhost:9090" } func main() { base := serverURL() channel := "go-ce-events.basic-pubsub" // Build and send CloudEvent (structured mode). event := cloudevents.NewEvent() event.SetType("com.kubemq.examples.events.sent") event.SetSource("kubemq-ce-go-example") event.SetSubject(channel) // subject = KubeMQ channel _ = event.SetData(cloudevents.ApplicationJSON, map[string]string{ "message": "Hello from Go CloudEvents example!", }) body, _ := json.Marshal(event) req, _ := http.NewRequest("POST", base+"/ce/send/event", strings.NewReader(string(body))) req.Header.Set("Content-Type", "application/cloudevents+json") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatal("send event:", err) } defer resp.Body.Close() fmt.Printf("Published to channel %q: status=%d\n", channel, resp.StatusCode) } ``` ```java // Example: events/basic-pubsub // Run: mvn compile exec:java package io.kubemq.examples.events.basicpubsub; import com.fasterxml.jackson.databind.ObjectMapper; import io.cloudevents.CloudEvent; import io.cloudevents.core.builder.CloudEventBuilder; import io.cloudevents.core.format.EventFormat; import io.cloudevents.core.provider.EventFormatProvider; import io.cloudevents.jackson.JsonFormat; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.OffsetDateTime; import java.util.Map; import java.util.UUID; public class Main { static final ObjectMapper MAPPER = new ObjectMapper(); public static void main(String[] args) throws Exception { String base = System.getenv().getOrDefault("KUBEMQ_CE_URL", "http://localhost:9090"); String channel = "java-ce-events.basic-pubsub"; String clientId = "kubemq-ce-java-example"; // Build CloudEvent (structured mode). EventFormatProvider.getInstance().registerFormat(new JsonFormat()); EventFormat format = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE); CloudEvent event = CloudEventBuilder.v1() .withId(UUID.randomUUID().toString()) .withType("com.kubemq.examples.events.sent") .withSource(URI.create(clientId)) .withSubject(channel) // subject = KubeMQ channel .withDataContentType("application/json") .withTime(OffsetDateTime.now()) .withData("application/json", MAPPER.writeValueAsBytes(Map.of("message", "Hello from Java CloudEvents example!"))) .build(); byte[] body = format.serialize(event); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(base + "/ce/send/event")) .POST(HttpRequest.BodyPublishers.ofByteArray(body)) .header("Content-Type", "application/cloudevents+json") .build(); HttpResponse response = HttpClient.newHttpClient() .send(request, HttpResponse.BodyHandlers.ofString()); System.out.printf("Published to channel '%s': status=%d%n", channel, response.statusCode()); } } ``` ```typescript // Example: events/basic-pubsub // Run: npx tsx events/basic-pubsub/index.ts import { CloudEvent, HTTP } from 'cloudevents'; function serverUrl(): string { return process.env.KUBEMQ_CE_URL ?? 'http://localhost:9090'; } async function main(): Promise { const base = serverUrl(); const channel = 'js-ce-events.basic-pubsub'; const clientId = 'kubemq-ce-js-example'; // Build and publish CloudEvent (structured mode). const event = new CloudEvent({ type: 'com.kubemq.examples.events.sent', source: clientId, subject: channel, // subject = KubeMQ channel datacontenttype: 'application/json', data: { message: 'Hello from JavaScript/TypeScript CloudEvents example!' }, }); const message = HTTP.structured(event); const resp = await fetch(`${base}/ce/send/event`, { method: 'POST', headers: message.headers as Record, body: message.body as string, }); console.log(`Published to channel '${channel}': status=${resp.status}`); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```python # Example: events/basic_pubsub # Run: python events/basic_pubsub/main.py from __future__ import annotations import os import requests from cloudevents.v1.conversion import to_structured from cloudevents.v1.http import CloudEvent def server_url() -> str: return os.environ.get("KUBEMQ_CE_URL", "http://localhost:9090") def main() -> None: base = server_url() channel = "python-ce-events.basic-pubsub" client_id = "kubemq-ce-python-example" # Build and send CloudEvent (structured mode). event = CloudEvent( attributes={ "type": "com.kubemq.examples.events.sent", "source": client_id, "subject": channel, # subject = KubeMQ channel "datacontenttype": "application/json", }, data={"message": "Hello from Python CloudEvents example!"}, ) headers, body = to_structured(event) resp = requests.post(f"{base}/ce/send/event", data=body, headers=dict(headers), timeout=10) print(f"Published to channel '{channel}': status={resp.status_code}") if __name__ == "__main__": main() ``` ```ruby # Example: events/basic_pubsub # Run: ruby events/basic_pubsub/main.rb require "net/http" require "uri" require "json" require "securerandom" require "cloud_events" base = ENV.fetch("KUBEMQ_CE_URL", "http://localhost:9090") channel = "ruby-ce-events.basic-pubsub" client_id = "kubemq-ce-ruby-example" # Build and publish CloudEvent (structured mode). sdk = CloudEvents::HttpBinding.default event = CloudEvents::Event::V1.new( id: SecureRandom.uuid, type: "com.kubemq.examples.events.sent", source: URI("urn:#{client_id}"), subject: channel, # subject = KubeMQ channel spec_version: "1.0", data_content_type: CloudEvents::ContentType.new("application/json"), data: JSON.generate({ message: "Hello from Ruby CloudEvents example!" }) ) headers, body = sdk.encode_event(event, structured_format: "json") uri = URI("#{base}/ce/send/event") Net::HTTP.start(uri.host, uri.port) do |http| req = Net::HTTP::Post.new(uri) req["Content-Type"] = headers["Content-Type"] req.body = body res = http.request(req) puts "Published to channel '#{channel}': status=#{res.code}" end ``` ```rust //! Example: events/basic-pubsub //! Run: cargo run -p basic-pubsub use cloudevents::{EventBuilder, EventBuilderV10}; use reqwest::Client; use serde_json::json; use std::env; use uuid::Uuid; fn server_url() -> String { env::var("KUBEMQ_CE_URL").unwrap_or_else(|_| "http://localhost:9090".to_string()) } #[tokio::main] async fn main() -> Result<(), Box> { let base = server_url(); let channel = "rust-ce-events.basic-pubsub"; let client_id = "kubemq-ce-rust-example"; // Build CloudEvent (structured mode using cloudevents-sdk). let event = EventBuilderV10::new() .id(Uuid::new_v4().to_string()) .ty("com.kubemq.examples.events.sent") .source(format!("urn:{}", client_id)) .subject(channel) // subject = KubeMQ channel .data( "application/json", json!({"message": "Hello from Rust CloudEvents example!"}), ) .build()?; let body = serde_json::to_string(&event)?; let resp = Client::new() .post(format!("{}/ce/send/event", base)) .header("Content-Type", "application/cloudevents+json") .body(body) .send() .await?; println!("Published to channel '{}': status={}", channel, resp.status()); Ok(()) } ``` If `subject` is present but **empty**, the connector treats it as absent and falls through to the `?channel=` query parameter. ## Using `?channel=` [#using-channel] The `?channel=` query parameter is the fallback for cases where `subject` is unavailable or reserved for a different meaning. It works in both structured and binary content modes. ```bash # channel via query parameter — no subject on the event curl -X POST "http://localhost:9090/ce/send/event?channel=orders" \ -H "Content-Type: application/cloudevents+json" \ -d '{ "specversion": "1.0", "type": "com.example.order.created", "source": "order-service", "data": {"order_id": "12345"} }' ``` Use `?channel=` when: * the CloudEvent schema reserves `subject` for a different semantic purpose; * you are integrating with external CE producers that do not set `subject`; or * you need to route the same event payload to different channels per deployment. Avoid setting `subject` to a value different from the intended channel. Downstream [CESQL routing](/connectors/cloudevents/how-to/cesql-routing) can read CE attributes including `subject`, so a mismatch produces confusing routing behavior. When neither `subject` nor `?channel=` is supplied, the connector returns HTTP 400 with `{"is_error": true, "message": "channel is required"}`. ## ClientID resolution [#clientid-resolution] Every message also carries a KubeMQ **ClientID**. The connector resolves it as follows: 1. **Auth claims** — when authentication is enabled and the request carries valid credentials, the authenticated `ClientID` from the JWT claims **overrides** every other source. 2. **CE `source` attribute** — used as the default ClientID when auth is disabled, or when the authenticated claim is `anonymous`. So with auth off, the event's `source` becomes the ClientID; with auth on, the verified caller identity always wins, regardless of what `source` says. See [Authentication](/connectors/cloudevents/how-to/authentication) for how claims are established. | Condition | ClientID used | | ------------------------------------ | ------------------------------------ | | Auth enabled, valid claims | Authenticated `ClientID` from claims | | Auth disabled (or claim `anonymous`) | CE `source` attribute | ## Auto-generated attributes [#auto-generated-attributes] Two optional CloudEvent attributes are filled in by the connector before the event is processed, so you may omit them: | Attribute | Auto-generated when | Value | | --------- | ------------------- | ---------------------------------- | | `id` | empty or missing | a new UUID v4 | | `time` | zero or missing | the current UTC time (RFC3339Nano) | The required attributes — `specversion`, `type`, and `source` — are never auto-generated; omitting any of them is a validation error. For the full attribute-to-tag mapping (including how every CE attribute is stored as a `ce_*` tag), see the [CE to KubeMQ mapping](/connectors/cloudevents/reference/ce-to-kubemq-mapping) reference. ## Best practices [#best-practices] * **Prefer `subject`** for channel resolution — it makes the CloudEvent self-describing and portable across environments. * Use `?channel=` only for compatibility with external CE sources that cannot set `subject`. * Keep channel names consistent across publishers and subscribers to avoid silent routing mismatches. * When authentication is enabled, rely on the authenticated ClientID rather than `source` for identity-sensitive logic — claims always override `source`. ## Related [#related] # Commands & Queries (/connectors/cloudevents/how-to/commands-queries) Commands and queries give you **synchronous request-response (RPC)** over the CloudEvents HTTP interface. The sender publishes a CloudEvent and **blocks** until a responder processes the request and replies. Use it when the caller needs confirmation that an action ran (a command) or needs data back before continuing (a query). ## Overview [#overview] A responder subscribes to a command or query channel over SSE, receives each request, and sends a CloudEvent response back through `POST /ce/send/response`, correlated by a request ID. The sender's original `POST` stays open and returns the result once the responder replies. The two operations differ only in what comes back: | | Commands | Queries | | ------------------------- | -------------------------------------- | ------------------------------- | | **Intent** | Execute an action, confirm it ran | Request data, get a result back | | **Response payload** | Acknowledgment (e.g. `executed: true`) | Data (e.g. an inventory count) | | **Send endpoint** | `POST /ce/send/command` | `POST /ce/send/query` | | **Subscribe endpoint** | `GET /ce/subscribe/commands` | `GET /ce/subscribe/queries` | | **Sender success status** | `202` | `200` | Both are timed out by the connector's `TimeoutSeconds` (default 60s) — if no responder replies in time, the send returns HTTP 504. ## How it works [#how-it-works] The responder subscribes first; the sender then issues a blocking `POST`, the connector delivers the request over SSE, and the responder posts a correlated response that the connector routes back to the waiting sender. *A command round-trip: the responder subscribes, the sender blocks, and the response is correlated back by request ID.* ## Endpoints [#endpoints] | Method | Endpoint | Description | Success status | | ------ | ---------------------------------------------- | ----------------------------------------------- | -------------- | | `POST` | `/ce/send/command` | Send a command; blocks until a response arrives | `202` | | `POST` | `/ce/send/query` | Send a query; blocks until a response arrives | `200` | | `GET` | `/ce/subscribe/commands?client_id=X&channel=Y` | Subscribe to commands via SSE | SSE stream | | `GET` | `/ce/subscribe/queries?client_id=X&channel=Y` | Subscribe to queries via SSE | SSE stream | | `POST` | `/ce/send/response?request_id=X` | Send a response to a received command or query | `202` | ## Response correlation [#response-correlation] When a command or query is delivered over SSE, the connector adds two correlation fields the responder must echo back. For **CloudEvent** messages (`event: cloudevent`), they are merged into the CE JSON with an underscore prefix: * **`_kubemq_request_id`** — pass this as the `request_id` query parameter on `POST /ce/send/response`. * **`_kubemq_reply_channel`** — set this as the `subject` of the response CloudEvent so it routes back to the original sender. For non-CE messages (`event: message`), the same values appear as top-level `request_id` and `reply_channel` keys without the `_kubemq_` prefix. See [CE-to-KubeMQ mapping](/connectors/cloudevents/reference/ce-to-kubemq-mapping) for the full attribute table. ## Command round-trip [#command-round-trip] A complete command cycle: the responder subscribes over SSE, receives the command, and posts an acknowledgment. The sender blocks until the ack arrives. **curl** — in one terminal, subscribe to the command channel: ```bash curl -N "http://localhost:9090/ce/subscribe/commands?client_id=responder&channel=device-commands" ``` In a second terminal, send a command. The connector blocks the response until the responder replies: ```bash curl -X POST http://localhost:9090/ce/send/command \ -H "Content-Type: application/cloudevents+json" \ -d '{ "specversion": "1.0", "type": "com.example.device.reboot", "source": "control-plane", "subject": "device-commands", "data": {"device_id": "sensor-42", "action": "reboot"} }' ``` The subscriber receives the command with `_kubemq_request_id`. Use it to send the response back: ```bash curl -X POST "http://localhost:9090/ce/send/response?request_id=THE_REQUEST_ID" \ -H "Content-Type: application/cloudevents+json" \ -d '{ "specversion": "1.0", "type": "com.example.device.reboot.result", "source": "device-agent", "subject": "device-commands", "data": {"executed": true, "status": "rebooting"} }' ``` The following examples run the responder and sender together, using the CloudEvents SDK to build and encode each event: ```go // Example: commands/round-trip // // Demonstrates an RPC command round-trip: // - Responder goroutine subscribes via SSE GET /ce/subscribe/commands // - Sender goroutine sends a command via POST /ce/send/command // - Responder extracts _kubemq_request_id and _kubemq_reply_channel // - Responder sends back a response via POST /ce/send/response?request_id=... // - Sender receives the execution acknowledgement in the POST response body // // Run: go run ./commands/round-trip/main.go package main import ( "bufio" "encoding/json" "fmt" "log" "net/http" "os" "strings" "sync" "time" cloudevents "github.com/cloudevents/sdk-go/v2" ) func serverURL() string { if u := os.Getenv("KUBEMQ_CE_URL"); u != "" { return u } return "http://localhost:9090" } type CEResponse struct { IsError bool `json:"is_error"` Message string `json:"message"` Data json.RawMessage `json:"data"` } // startResponder subscribes to commands and replies to each one. func startResponder(base, channel string, ready chan<- struct{}, wg *sync.WaitGroup) { defer wg.Done() sseURL := fmt.Sprintf("%s/ce/subscribe/commands?client_id=go-cmd-responder&channel=%s", base, channel) req, _ := http.NewRequest("GET", sseURL, nil) req.Header.Set("Accept", "text/event-stream") req.Header.Set("Cache-Control", "no-cache") client := &http.Client{Timeout: 0} resp, err := client.Do(req) if err != nil { log.Fatal("responder SSE connect:", err) } defer resp.Body.Close() close(ready) // signal that SSE is connected scanner := bufio.NewScanner(resp.Body) var evType, data string for scanner.Scan() { line := scanner.Text() if line == "" { if evType == "cloudevent" && data != "" { // Parse command to extract KubeMQ correlation fields. var raw map[string]interface{} _ = json.Unmarshal([]byte(data), &raw) requestID, _ := raw["_kubemq_request_id"].(string) replyChannel, _ := raw["_kubemq_reply_channel"].(string) fmt.Printf("[responder] command received: type=%v request_id=%s\n", raw["type"], requestID) // Send response back. sendResponse(base, requestID, replyChannel) return } if evType == "error" { log.Printf("[responder] SSE error: %s", data) return } evType, data = "", "" continue } if strings.HasPrefix(line, ":") { continue } if strings.HasPrefix(line, "event:") { evType = strings.TrimSpace(strings.TrimPrefix(line, "event:")) } else if strings.HasPrefix(line, "data:") { data = strings.TrimSpace(strings.TrimPrefix(line, "data:")) } } } func sendResponse(base, requestID, replyChannel string) { event := cloudevents.NewEvent() event.SetType("com.kubemq.examples.commands.response") event.SetSource("go-cmd-responder") event.SetSubject(replyChannel) // subject = reply channel _ = event.SetData(cloudevents.ApplicationJSON, map[string]interface{}{ "executed": true, "status": "command processed successfully", }) body, _ := json.Marshal(event) url := fmt.Sprintf("%s/ce/send/response?request_id=%s", base, requestID) req, _ := http.NewRequest("POST", url, strings.NewReader(string(body))) req.Header.Set("Content-Type", "application/cloudevents+json") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatal("send response:", err) } defer resp.Body.Close() var result CEResponse _ = json.NewDecoder(resp.Body).Decode(&result) fmt.Printf("[responder] response sent: is_error=%v\n", result.IsError) } func sendCommand(base, channel string) { event := cloudevents.NewEvent() event.SetType("com.kubemq.examples.commands.reboot") event.SetSource("kubemq-ce-go-sender") event.SetSubject(channel) _ = event.SetData(cloudevents.ApplicationJSON, map[string]string{ "device_id": "sensor-42", "action": "reboot", }) body, _ := json.Marshal(event) req, _ := http.NewRequest("POST", base+"/ce/send/command", strings.NewReader(string(body))) req.Header.Set("Content-Type", "application/cloudevents+json") fmt.Println("[sender] sending command...") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatal("send command:", err) } defer resp.Body.Close() var result CEResponse _ = json.NewDecoder(resp.Body).Decode(&result) fmt.Printf("[sender] command ack received: status=%d is_error=%v data=%s\n", resp.StatusCode, result.IsError, string(result.Data)) } func main() { base := serverURL() channel := "go-ce-commands.round-trip" ready := make(chan struct{}) var wg sync.WaitGroup wg.Add(1) go startResponder(base, channel, ready, &wg) // Wait for SSE to be established. select { case <-ready: case <-time.After(5 * time.Second): log.Fatal("Timed out waiting for responder to connect") } time.Sleep(100 * time.Millisecond) // Send command — blocks until response arrives or timeout. sendCommand(base, channel) wg.Wait() } ``` ```python """Example: commands/round_trip — RPC command with execution ack.""" from __future__ import annotations import json import os import threading import time import requests from cloudevents.v1.conversion import to_structured from cloudevents.v1.http import CloudEvent def server_url() -> str: return os.environ.get("KUBEMQ_CE_URL", "http://localhost:9090") def responder(base: str, channel: str, ready: threading.Event) -> None: sse_url = (f"{base}/ce/subscribe/commands" f"?client_id=python-cmd-responder&channel={channel}") with requests.get(sse_url, stream=True, timeout=None, headers={"Accept": "text/event-stream"}) as resp: ready.set() ev_type = data = "" for line in resp.iter_lines(decode_unicode=True): if line == "": if ev_type == "cloudevent" and data: raw = json.loads(data) request_id = raw.get("_kubemq_request_id", "") reply_channel = raw.get("_kubemq_reply_channel", "") print(f"[responder] command received: type={raw.get('type')} " f"request_id={request_id}") # Send response. response_event = CloudEvent( attributes={ "type": "com.kubemq.examples.commands.response", "source": "python-cmd-responder", "subject": reply_channel, "datacontenttype": "application/json", }, data={"executed": True, "status": "command processed"}, ) headers, body = to_structured(response_event) r = requests.post( f"{base}/ce/send/response", params={"request_id": request_id}, data=body, headers=dict(headers), timeout=10, ) print(f"[responder] response sent: is_error={r.json().get('is_error')}") return ev_type = data = "" continue if line.startswith(":"): continue if line.startswith("event:"): ev_type = line[6:].strip() elif line.startswith("data:"): data = line[5:].strip() def main() -> None: base = server_url() channel = "python-ce-commands.round-trip" ready = threading.Event() t = threading.Thread(target=responder, args=(base, channel, ready), daemon=True) t.start() ready.wait(timeout=5) time.sleep(0.1) event = CloudEvent( attributes={ "type": "com.kubemq.examples.commands.reboot", "source": "kubemq-ce-python-sender", "subject": channel, "datacontenttype": "application/json", }, data={"device_id": "sensor-42", "action": "reboot"}, ) headers, body = to_structured(event) print("[sender] sending command...") resp = requests.post(f"{base}/ce/send/command", data=body, headers=dict(headers), timeout=30) result = resp.json() print(f"[sender] command ack: status={resp.status_code} is_error={result.get('is_error')}") print(f"[sender] response data: {result.get('data')}") t.join(timeout=5) if __name__ == "__main__": main() ``` ```typescript /** * Example: commands/round-trip — RPC command with execution ack. * Run: npx tsx commands/round-trip/index.ts */ import { CloudEvent, HTTP } from 'cloudevents'; import EventSource from 'eventsource'; function serverUrl(): string { return process.env.KUBEMQ_CE_URL ?? 'http://localhost:9090'; } function startResponder(base: string, channel: string): Promise { return new Promise((resolve) => { const url = `${base}/ce/subscribe/commands?client_id=js-cmd-responder&channel=${encodeURIComponent(channel)}`; const es = new EventSource(url); es.addEventListener('cloudevent', async (evt: MessageEvent) => { es.close(); const raw = JSON.parse(evt.data) as Record; const requestId = raw._kubemq_request_id as string; const replyChannel = raw._kubemq_reply_channel as string; console.log(`[responder] command received: type=${raw.type} request_id=${requestId}`); const response = new CloudEvent({ type: 'com.kubemq.examples.commands.response', source: 'js-cmd-responder', subject: replyChannel, datacontenttype: 'application/json', data: { executed: true, status: 'command processed' }, }); const msg = HTTP.structured(response); const r = await fetch(`${base}/ce/send/response?request_id=${requestId}`, { method: 'POST', headers: msg.headers as Record, body: msg.body as string, }); const result = await r.json() as { is_error: boolean }; console.log(`[responder] response sent: is_error=${result.is_error}`); resolve(); }); es.addEventListener('error', (evt: MessageEvent) => { if (evt.data) { const err = JSON.parse(evt.data) as { message: string }; console.error('[responder] SSE error:', err.message); es.close(); } }); }); } async function main(): Promise { const base = serverUrl(); const channel = 'js-ce-commands.round-trip'; const responderDone = startResponder(base, channel); await new Promise((r) => setTimeout(r, 500)); const event = new CloudEvent({ type: 'com.kubemq.examples.commands.reboot', source: 'kubemq-ce-js-sender', subject: channel, datacontenttype: 'application/json', data: { device_id: 'sensor-42', action: 'reboot' }, }); const msg = HTTP.structured(event); console.log('[sender] sending command...'); const resp = await fetch(`${base}/ce/send/command`, { method: 'POST', headers: msg.headers as Record, body: msg.body as string, }); const result = await resp.json() as { is_error: boolean; data: unknown }; console.log(`[sender] command ack: status=${resp.status} is_error=${result.is_error}`); await responderDone; } main().catch(console.error); ``` ```java package io.kubemq.examples.commands.roundtrip; import com.fasterxml.jackson.databind.ObjectMapper; import io.cloudevents.CloudEvent; import io.cloudevents.core.builder.CloudEventBuilder; import io.cloudevents.core.format.EventFormat; import io.cloudevents.core.provider.EventFormatProvider; import io.cloudevents.jackson.JsonFormat; import java.io.BufferedReader; import java.io.InputStreamReader; import java.net.HttpURLConnection; import java.net.URI; import java.net.URL; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.OffsetDateTime; import java.util.Map; import java.util.UUID; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; /** * Example: commands/round-trip * * A responder subscribes to commands via SSE, receives the command, and sends * a CE response. A sender publishes a command via POST /ce/send/command * (which blocks until ack is received). * * Run: mvn compile exec:java */ public class Main { static String serverUrl() { String u = System.getenv("KUBEMQ_CE_URL"); return (u != null && !u.isEmpty()) ? u : "http://localhost:9090"; } static final ObjectMapper MAPPER = new ObjectMapper(); public static void main(String[] args) throws Exception { String base = serverUrl(); String channel = "java-ce-commands.round-trip"; EventFormatProvider.getInstance().registerFormat(new JsonFormat()); EventFormat format = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE); HttpClient httpClient = HttpClient.newHttpClient(); BlockingQueue responderReady = new ArrayBlockingQueue<>(1); // Start command responder. String sseUrl = base + "/ce/subscribe/commands?client_id=java-cmd-responder&channel=" + channel; Thread.ofVirtual().start(() -> { try { HttpURLConnection conn = (HttpURLConnection) new URL(sseUrl).openConnection(); conn.setRequestProperty("Accept", "text/event-stream"); conn.setReadTimeout(30_000); try (BufferedReader reader = new BufferedReader( new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { String line; String evType = null, data = null; boolean ready = false; while ((line = reader.readLine()) != null) { if (!ready) { responderReady.offer(true); ready = true; } if (line.isEmpty()) { if ("cloudevent".equals(evType) && data != null) { Map raw = MAPPER.readValue(data, Map.class); String requestId = (String) raw.get("_kubemq_request_id"); String replyChannel = (String) raw.get("_kubemq_reply_channel"); System.out.println("[responder] command received: type=" + raw.get("type") + " request_id=" + requestId); CloudEvent respEvent = CloudEventBuilder.v1() .withId(UUID.randomUUID().toString()) .withType("com.kubemq.examples.commands.response") .withSource(URI.create("kubemq-ce-java-responder")) .withSubject(replyChannel) .withDataContentType("application/json") .withTime(OffsetDateTime.now()) .withData("application/json", MAPPER.writeValueAsBytes(Map.of("executed", true, "status", "command processed"))) .build(); String responseUrl = base + "/ce/send/response?request_id=" + requestId; HttpResponse r = httpClient.send( HttpRequest.newBuilder().uri(URI.create(responseUrl)) .POST(HttpRequest.BodyPublishers.ofByteArray(format.serialize(respEvent))) .header("Content-Type", "application/cloudevents+json").build(), HttpResponse.BodyHandlers.ofString()); Map rResult = MAPPER.readValue(r.body(), Map.class); System.out.println("[responder] response sent: is_error=" + rResult.get("is_error")); return; } evType = null; data = null; } else if (line.startsWith("event:")) evType = line.substring(6).trim(); else if (line.startsWith("data:")) data = line.substring(5).trim(); } } } catch (Exception e) { System.err.println("SSE error: " + e.getMessage()); } }); responderReady.poll(5, TimeUnit.SECONDS); Thread.sleep(100); // Send command. CloudEvent cmd = CloudEventBuilder.v1() .withId(UUID.randomUUID().toString()) .withType("com.kubemq.examples.commands.reboot") .withSource(URI.create("kubemq-ce-java-sender")) .withSubject(channel) .withDataContentType("application/json") .withTime(OffsetDateTime.now()) .withData("application/json", MAPPER.writeValueAsBytes(Map.of("device_id", "sensor-42", "action", "reboot"))) .build(); System.out.println("[sender] sending command..."); HttpResponse resp = httpClient.send( HttpRequest.newBuilder().uri(URI.create(base + "/ce/send/command")) .POST(HttpRequest.BodyPublishers.ofByteArray(format.serialize(cmd))) .header("Content-Type", "application/cloudevents+json").build(), HttpResponse.BodyHandlers.ofString()); Map result = MAPPER.readValue(resp.body(), Map.class); System.out.println("[sender] command ack: status=" + resp.statusCode() + " is_error=" + result.get("is_error")); } } ``` ```csharp // Example: commands/RoundTrip — RPC command with execution ack. // Run: dotnet run using CloudNative.CloudEvents; using CloudNative.CloudEvents.SystemTextJson; using System.Net.Http.Headers; using System.Text; using System.Text.Json; static string ServerUrl() => Environment.GetEnvironmentVariable("KUBEMQ_CE_URL") ?? "http://localhost:9090"; var base_ = ServerUrl(); var channel = "csharp-ce-commands.round-trip"; var formatter = new JsonEventFormatter(); async Task RunResponder() { using var http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan }; var url = $"{base_}/ce/subscribe/commands?client_id=csharp-cmd-responder&channel={Uri.EscapeDataString(channel)}"; using var req = new HttpRequestMessage(HttpMethod.Get, url); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); using var resp = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead); using var stream = await resp.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream, Encoding.UTF8); string? evType = null, data = null, line; while ((line = await reader.ReadLineAsync()) != null) { if (line == "") { if (evType == "cloudevent" && data != null) { var raw = JsonSerializer.Deserialize(data); var requestId = raw.GetProperty("_kubemq_request_id").GetString()!; var replyChannel = raw.GetProperty("_kubemq_reply_channel").GetString()!; Console.WriteLine($"[responder] command received: type={raw.GetProperty("type")} request_id={requestId}"); var responseEvent = new CloudEvent { Id = Guid.NewGuid().ToString(), Type = "com.kubemq.examples.commands.response", Source = new Uri("urn:csharp-cmd-responder"), Subject = replyChannel, DataContentType = "application/json", Data = new { executed = true, status = "command processed" }, }; var bytes = formatter.EncodeStructuredModeMessage(responseEvent, out var ct); using var rc = new ByteArrayContent(bytes.ToArray()); rc.Headers.ContentType = MediaTypeHeaderValue.Parse(ct.ToString()); using var sendHttp = new HttpClient(); var r = await sendHttp.PostAsync($"{base_}/ce/send/response?request_id={requestId}", rc); var rj = JsonSerializer.Deserialize(await r.Content.ReadAsStringAsync()); Console.WriteLine($"[responder] response sent: is_error={rj.GetProperty("is_error")}"); return; } evType = null; data = null; } else if (line.StartsWith("event:")) evType = line[6..].Trim(); else if (line.StartsWith("data:")) data = line[5..].Trim(); } } var responderTask = RunResponder(); await Task.Delay(500); var cmdEvent = new CloudEvent { Id = Guid.NewGuid().ToString(), Type = "com.kubemq.examples.commands.reboot", Source = new Uri("urn:kubemq-ce-csharp-sender"), Subject = channel, DataContentType = "application/json", Data = new { device_id = "sensor-42", action = "reboot" }, }; var cmdBytes = formatter.EncodeStructuredModeMessage(cmdEvent, out var cmdCt); using var cmdContent = new ByteArrayContent(cmdBytes.ToArray()); cmdContent.Headers.ContentType = MediaTypeHeaderValue.Parse(cmdCt.ToString()); using var senderHttp = new HttpClient(); Console.WriteLine("[sender] sending command..."); var cmdResp = await senderHttp.PostAsync($"{base_}/ce/send/command", cmdContent); var cmdResult = JsonSerializer.Deserialize(await cmdResp.Content.ReadAsStringAsync()); Console.WriteLine($"[sender] command ack: status={cmdResp.StatusCode} is_error={cmdResult.GetProperty("is_error")}"); await responderTask; ``` ```ruby # Example: commands/round_trip — RPC command with execution ack. require "net/http"; require "uri"; require "json"; require "timeout"; require "securerandom"; require "cloud_events" def server_url = ENV.fetch("KUBEMQ_CE_URL", "http://localhost:9090") base = server_url; channel = "ruby-ce-commands.round-trip" sdk = CloudEvents::HttpBinding.default ready = Queue.new responder = Thread.new do uri = URI("#{base}/ce/subscribe/commands?client_id=ruby-cmd-responder&channel=#{URI.encode_www_form_component(channel)}") Net::HTTP.start(uri.host, uri.port) do |http| req = Net::HTTP::Get.new(uri); req["Accept"] = "text/event-stream" http.request(req) do |resp| ready.push(true) # signal that SSE connection is established ev_type = nil; data = nil resp.read_body do |chunk| chunk.each_line do |line| line.chomp! if line.empty? if ev_type == "cloudevent" && data raw = JSON.parse(data) request_id = raw["_kubemq_request_id"] reply_channel = raw["_kubemq_reply_channel"] puts "[responder] command received: type=#{raw['type']} request_id=#{request_id}" resp_event = CloudEvents::Event::V1.new( id: SecureRandom.uuid, type: "com.kubemq.examples.commands.response", source: URI("urn:ruby-cmd-responder"), subject: reply_channel, spec_version: "1.0", data_content_type: CloudEvents::ContentType.new("application/json"), data: JSON.generate({ executed: true, status: "command processed" })) resp_h, resp_b = sdk.encode_event(resp_event, structured_format: "json") ruri = URI("#{base}/ce/send/response?request_id=#{URI.encode_www_form_component(request_id)}") Net::HTTP.start(ruri.host, ruri.port) do |rhttp| rreq = Net::HTTP::Post.new(ruri); resp_h.each{|k,v|rreq[k]=v}; rreq.body=resp_b rr = rhttp.request(rreq) puts "[responder] response sent: is_error=#{JSON.parse(rr.body)['is_error']}" end Thread.exit end ev_type = nil; data = nil elsif line.start_with?("event:") then ev_type = line.sub("event:","").strip elsif line.start_with?("data:") then data = line.sub("data:","").strip end end end end end end Timeout.timeout(5) { ready.pop } sleep 0.1 cmd_event = CloudEvents::Event::V1.new( id: SecureRandom.uuid, type: "com.kubemq.examples.commands.reboot", source: URI("urn:kubemq-ce-ruby-sender"), subject: channel, spec_version: "1.0", data_content_type: CloudEvents::ContentType.new("application/json"), data: JSON.generate({ device_id: "sensor-42", action: "reboot" })) cmd_h, cmd_b = sdk.encode_event(cmd_event, structured_format: "json") uri = URI("#{base}/ce/send/command") puts "[sender] sending command..." Net::HTTP.start(uri.host, uri.port) do |http| req = Net::HTTP::Post.new(uri); cmd_h.each{|k,v|req[k]=v}; req.body=cmd_b res = http.request(req) r = JSON.parse(res.body) puts "[sender] command ack: status=#{res.code} is_error=#{r['is_error']}" end responder.join ``` ```rust //! Example: commands/round-trip — RPC command with execution ack. use bytes::Bytes; use cloudevents::{EventBuilder, EventBuilderV10}; use futures_util::StreamExt; use reqwest::Client; use serde_json::{json, Value}; use std::env; use tokio::sync::oneshot; use uuid::Uuid; fn server_url() -> String { env::var("KUBEMQ_CE_URL").unwrap_or_else(|_| "http://localhost:9090".to_string()) } async fn run_responder(base: String, channel: String, ready_tx: oneshot::Sender<()>) { let client = Client::new(); let url = format!("{}/ce/subscribe/commands?client_id=rust-cmd-responder&channel={}", base, channel); let resp = client.get(&url) .header("Accept", "text/event-stream") .send().await.expect("SSE connect"); // Signal ready as soon as the SSE connection is established let _ = ready_tx.send(()); let mut stream = Box::pin(resp.bytes_stream()); let mut buffer = String::new(); let mut ev_type = String::new(); let mut data_str = String::new(); while let Some(chunk) = stream.next().await { let chunk: Bytes = chunk.unwrap(); buffer.push_str(&String::from_utf8_lossy(&chunk)); while let Some(pos) = buffer.find('\n') { let line = buffer[..pos].trim_end_matches('\r').to_string(); buffer = buffer[pos + 1..].to_string(); if line.is_empty() { if ev_type == "cloudevent" && !data_str.is_empty() { let raw: Value = serde_json::from_str(&data_str).unwrap(); let request_id = raw["_kubemq_request_id"].as_str().unwrap_or("").to_string(); let reply_channel = raw["_kubemq_reply_channel"].as_str().unwrap_or("").to_string(); println!("[responder] command received: type={} request_id={}", raw["type"], request_id); let resp_event = EventBuilderV10::new() .id(Uuid::new_v4().to_string()) .ty("com.kubemq.examples.commands.response") .source("urn:rust-cmd-responder") .subject(reply_channel.as_str()) .data("application/json", json!({"executed": true, "status": "command processed"})) .build().unwrap(); let body = serde_json::to_string(&resp_event).unwrap(); let r = client.post(format!("{}/ce/send/response?request_id={}", base, request_id)) .header("Content-Type", "application/cloudevents+json") .body(body).send().await.unwrap(); let rj: Value = r.json().await.unwrap(); println!("[responder] response sent: is_error={}", rj["is_error"]); return; } ev_type.clear(); data_str.clear(); } else if line.starts_with(':') { } else if let Some(v) = line.strip_prefix("event:") { ev_type = v.trim().to_string(); } else if let Some(v) = line.strip_prefix("data:") { data_str = v.trim().to_string(); } } } } #[tokio::main] async fn main() -> Result<(), Box> { let base = server_url(); let channel = "rust-ce-commands.round-trip".to_string(); let client = Client::new(); let (ready_tx, ready_rx) = oneshot::channel::<()>(); let base_clone = base.clone(); let channel_clone = channel.clone(); tokio::spawn(async move { run_responder(base_clone, channel_clone, ready_tx).await }); tokio::time::timeout(tokio::time::Duration::from_secs(5), ready_rx).await??; tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; let event = EventBuilderV10::new() .id(Uuid::new_v4().to_string()) .ty("com.kubemq.examples.commands.reboot") .source("urn:kubemq-ce-rust-sender") .subject(channel.as_str()) .data("application/json", json!({"device_id": "sensor-42", "action": "reboot"})) .build()?; let body = serde_json::to_string(&event)?; println!("[sender] sending command..."); let resp = client.post(format!("{}/ce/send/command", base)) .header("Content-Type", "application/cloudevents+json") .body(body).send().await?; let result: Value = resp.json().await?; println!("[sender] command ack: status=202 is_error={}", result["is_error"]); tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; Ok(()) } ``` ## Query round-trip [#query-round-trip] A complete query cycle: the responder subscribes over SSE, receives the query, performs a lookup, and sends back a **data response**. The sender blocks and receives the data payload directly in the HTTP response. **curl** — in one terminal, subscribe to the query channel: ```bash curl -N "http://localhost:9090/ce/subscribe/queries?client_id=responder&channel=inventory-queries" ``` In a second terminal, send a query. The connector returns `200` with the responder's reply in `data`: ```bash curl -X POST http://localhost:9090/ce/send/query \ -H "Content-Type: application/cloudevents+json" \ -d '{ "specversion": "1.0", "type": "com.example.inventory.check", "source": "web-frontend", "subject": "inventory-queries", "data": {"sku": "WIDGET-100"} }' ``` The subscriber receives the query with `_kubemq_request_id` and replies with the result: ```bash curl -X POST "http://localhost:9090/ce/send/response?request_id=THE_REQUEST_ID" \ -H "Content-Type: application/cloudevents+json" \ -d '{ "specversion": "1.0", "type": "com.example.inventory.result", "source": "inventory-service", "subject": "inventory-queries", "data": {"sku": "WIDGET-100", "quantity": 42} }' ``` The following examples run a responder that looks up an inventory and a sender that prints the returned data: ```go // Example: queries/round-trip // // Demonstrates an RPC query round-trip: // - Responder subscribes via SSE GET /ce/subscribe/queries // - Sender sends a query via POST /ce/send/query (blocks for response) // - Responder extracts _kubemq_request_id and sends data response // - Sender receives the data payload in the query response // // Run: go run ./queries/round-trip/main.go package main import ( "bufio" "encoding/json" "fmt" "log" "net/http" "os" "strings" "sync" "time" cloudevents "github.com/cloudevents/sdk-go/v2" ) func serverURL() string { if u := os.Getenv("KUBEMQ_CE_URL"); u != "" { return u } return "http://localhost:9090" } type CEResponse struct { IsError bool `json:"is_error"` Message string `json:"message"` Data json.RawMessage `json:"data"` } func startQueryResponder(base, channel string, ready chan<- struct{}, wg *sync.WaitGroup) { defer wg.Done() sseURL := fmt.Sprintf("%s/ce/subscribe/queries?client_id=go-query-responder&channel=%s", base, channel) req, _ := http.NewRequest("GET", sseURL, nil) req.Header.Set("Accept", "text/event-stream") req.Header.Set("Cache-Control", "no-cache") client := &http.Client{Timeout: 0} resp, err := client.Do(req) if err != nil { log.Fatal("query responder SSE:", err) } defer resp.Body.Close() close(ready) scanner := bufio.NewScanner(resp.Body) var evType, data string for scanner.Scan() { line := scanner.Text() if line == "" { if evType == "cloudevent" && data != "" { var raw map[string]interface{} _ = json.Unmarshal([]byte(data), &raw) requestID, _ := raw["_kubemq_request_id"].(string) replyChannel, _ := raw["_kubemq_reply_channel"].(string) // Extract the query payload. queryData, _ := raw["data"].(map[string]interface{}) sku, _ := queryData["sku"].(string) fmt.Printf("[responder] query received: sku=%s request_id=%s\n", sku, requestID) // Send data response. sendQueryResponse(base, requestID, replyChannel, sku) return } evType, data = "", "" continue } if strings.HasPrefix(line, ":") { continue } if strings.HasPrefix(line, "event:") { evType = strings.TrimSpace(strings.TrimPrefix(line, "event:")) } else if strings.HasPrefix(line, "data:") { data = strings.TrimSpace(strings.TrimPrefix(line, "data:")) } } } func sendQueryResponse(base, requestID, replyChannel, sku string) { // Simulate database lookup. inventory := map[string]int{"WIDGET-100": 42, "GADGET-200": 7} qty := inventory[sku] event := cloudevents.NewEvent() event.SetType("com.kubemq.examples.queries.inventory-result") event.SetSource("go-query-responder") event.SetSubject(replyChannel) _ = event.SetData(cloudevents.ApplicationJSON, map[string]interface{}{ "sku": sku, "quantity": qty, }) body, _ := json.Marshal(event) url := fmt.Sprintf("%s/ce/send/response?request_id=%s", base, requestID) req, _ := http.NewRequest("POST", url, strings.NewReader(string(body))) req.Header.Set("Content-Type", "application/cloudevents+json") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatal("send query response:", err) } defer resp.Body.Close() fmt.Println("[responder] response sent.") } func sendQuery(base, channel string) { event := cloudevents.NewEvent() event.SetType("com.kubemq.examples.queries.inventory-check") event.SetSource("kubemq-ce-go-sender") event.SetSubject(channel) _ = event.SetData(cloudevents.ApplicationJSON, map[string]string{"sku": "WIDGET-100"}) body, _ := json.Marshal(event) req, _ := http.NewRequest("POST", base+"/ce/send/query", strings.NewReader(string(body))) req.Header.Set("Content-Type", "application/cloudevents+json") fmt.Println("[sender] sending query for sku=WIDGET-100...") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatal("send query:", err) } defer resp.Body.Close() var result CEResponse _ = json.NewDecoder(resp.Body).Decode(&result) // The response data contains the CE response from the responder. fmt.Printf("[sender] query response: status=%d is_error=%v\n", resp.StatusCode, result.IsError) fmt.Printf("[sender] response data: %s\n", string(result.Data)) } func main() { base := serverURL() channel := "go-ce-queries.round-trip" ready := make(chan struct{}) var wg sync.WaitGroup wg.Add(1) go startQueryResponder(base, channel, ready, &wg) select { case <-ready: case <-time.After(5 * time.Second): log.Fatal("Timed out waiting for query responder") } time.Sleep(100 * time.Millisecond) sendQuery(base, channel) wg.Wait() } ``` ```python """Example: queries/round_trip — RPC query with data response.""" from __future__ import annotations import json import os import threading import time import requests from cloudevents.v1.conversion import to_structured from cloudevents.v1.http import CloudEvent def server_url() -> str: return os.environ.get("KUBEMQ_CE_URL", "http://localhost:9090") def responder(base: str, channel: str, ready: threading.Event) -> None: sse_url = (f"{base}/ce/subscribe/queries" f"?client_id=python-query-responder&channel={channel}") inventory = {"WIDGET-100": 42, "GADGET-200": 7} with requests.get(sse_url, stream=True, timeout=None, headers={"Accept": "text/event-stream"}) as resp: ready.set() ev_type = data = "" for line in resp.iter_lines(decode_unicode=True): if line == "": if ev_type == "cloudevent" and data: raw = json.loads(data) request_id = raw.get("_kubemq_request_id", "") reply_channel = raw.get("_kubemq_reply_channel", "") query_data = raw.get("data", {}) sku = query_data.get("sku", "") qty = inventory.get(sku, 0) print(f"[responder] query sku={sku} qty={qty} request_id={request_id}") response_event = CloudEvent( attributes={ "type": "com.kubemq.examples.queries.inventory-result", "source": "python-query-responder", "subject": reply_channel, "datacontenttype": "application/json", }, data={"sku": sku, "quantity": qty}, ) headers, body = to_structured(response_event) requests.post( f"{base}/ce/send/response", params={"request_id": request_id}, data=body, headers=dict(headers), timeout=10, ) print("[responder] response sent.") return ev_type = data = "" continue if line.startswith(":"): continue if line.startswith("event:"): ev_type = line[6:].strip() elif line.startswith("data:"): data = line[5:].strip() def main() -> None: base = server_url() channel = "python-ce-queries.round-trip" ready = threading.Event() t = threading.Thread(target=responder, args=(base, channel, ready), daemon=True) t.start() ready.wait(timeout=5) time.sleep(0.1) event = CloudEvent( attributes={ "type": "com.kubemq.examples.queries.inventory-check", "source": "kubemq-ce-python-sender", "subject": channel, "datacontenttype": "application/json", }, data={"sku": "WIDGET-100"}, ) headers, body = to_structured(event) print("[sender] sending query for sku=WIDGET-100...") resp = requests.post(f"{base}/ce/send/query", data=body, headers=dict(headers), timeout=30) result = resp.json() print(f"[sender] query response: status={resp.status_code} is_error={result.get('is_error')}") print(f"[sender] response data: {result.get('data')}") t.join(timeout=5) if __name__ == "__main__": main() ``` ```typescript /** * Example: queries/round-trip — RPC query with data response. * Run: npx tsx queries/round-trip/index.ts */ import { CloudEvent, HTTP } from 'cloudevents'; import EventSource from 'eventsource'; function serverUrl(): string { return process.env.KUBEMQ_CE_URL ?? 'http://localhost:9090'; } const INVENTORY: Record = { 'WIDGET-100': 42, 'GADGET-200': 7 }; function startQueryResponder(base: string, channel: string): Promise { return new Promise((resolve) => { const url = `${base}/ce/subscribe/queries?client_id=js-query-responder&channel=${encodeURIComponent(channel)}`; const es = new EventSource(url); es.addEventListener('cloudevent', async (evt: MessageEvent) => { es.close(); const raw = JSON.parse(evt.data) as Record; const requestId = raw._kubemq_request_id as string; const replyChannel = raw._kubemq_reply_channel as string; const queryData = raw.data as Record; const sku = queryData.sku; const qty = INVENTORY[sku] ?? 0; console.log(`[responder] query sku=${sku} qty=${qty} request_id=${requestId}`); const response = new CloudEvent({ type: 'com.kubemq.examples.queries.inventory-result', source: 'js-query-responder', subject: replyChannel, datacontenttype: 'application/json', data: { sku, quantity: qty }, }); const msg = HTTP.structured(response); await fetch(`${base}/ce/send/response?request_id=${requestId}`, { method: 'POST', headers: msg.headers as Record, body: msg.body as string, }); console.log('[responder] response sent.'); resolve(); }); es.addEventListener('error', (evt: MessageEvent) => { if (evt.data) { const err = JSON.parse(evt.data) as { message: string }; console.error('[responder] SSE error:', err.message); es.close(); } }); }); } async function main(): Promise { const base = serverUrl(); const channel = 'js-ce-queries.round-trip'; const responderDone = startQueryResponder(base, channel); await new Promise((r) => setTimeout(r, 500)); const event = new CloudEvent({ type: 'com.kubemq.examples.queries.inventory-check', source: 'kubemq-ce-js-sender', subject: channel, datacontenttype: 'application/json', data: { sku: 'WIDGET-100' }, }); const msg = HTTP.structured(event); console.log('[sender] sending query for sku=WIDGET-100...'); const resp = await fetch(`${base}/ce/send/query`, { method: 'POST', headers: msg.headers as Record, body: msg.body as string, }); const result = await resp.json() as { is_error: boolean; data: unknown }; console.log(`[sender] query response: status=${resp.status} is_error=${result.is_error}`); console.log(`[sender] response data: ${JSON.stringify(result.data)}`); await responderDone; } main().catch(console.error); ``` ```java package io.kubemq.examples.queries.roundtrip; import com.fasterxml.jackson.databind.ObjectMapper; import io.cloudevents.CloudEvent; import io.cloudevents.core.builder.CloudEventBuilder; import io.cloudevents.core.format.EventFormat; import io.cloudevents.core.provider.EventFormatProvider; import io.cloudevents.jackson.JsonFormat; import java.io.BufferedReader; import java.io.InputStreamReader; import java.net.HttpURLConnection; import java.net.URI; import java.net.URL; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.OffsetDateTime; import java.util.Map; import java.util.UUID; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; /** * Example: queries/round-trip * * A responder subscribes to queries, receives a query, looks up inventory, * and sends a CE response. A sender publishes a query via POST /ce/send/query * and prints the response returned directly in the HTTP response body. * * Run: mvn compile exec:java */ public class Main { static String serverUrl() { String u = System.getenv("KUBEMQ_CE_URL"); return (u != null && !u.isEmpty()) ? u : "http://localhost:9090"; } static final ObjectMapper MAPPER = new ObjectMapper(); static final Map INVENTORY = Map.of("WIDGET-100", 42, "GADGET-200", 7); public static void main(String[] args) throws Exception { String base = serverUrl(); String channel = "java-ce-queries.round-trip"; EventFormatProvider.getInstance().registerFormat(new JsonFormat()); EventFormat format = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE); HttpClient httpClient = HttpClient.newHttpClient(); BlockingQueue responderReady = new ArrayBlockingQueue<>(1); // Start query responder. String sseUrl = base + "/ce/subscribe/queries?client_id=java-query-responder&channel=" + channel; Thread.ofVirtual().start(() -> { try { HttpURLConnection conn = (HttpURLConnection) new URL(sseUrl).openConnection(); conn.setRequestProperty("Accept", "text/event-stream"); conn.setReadTimeout(30_000); try (BufferedReader reader = new BufferedReader( new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { String line; String evType = null, data = null; boolean ready = false; while ((line = reader.readLine()) != null) { if (!ready) { responderReady.offer(true); ready = true; } if (line.isEmpty()) { if ("cloudevent".equals(evType) && data != null) { Map raw = MAPPER.readValue(data, Map.class); String requestId = (String) raw.get("_kubemq_request_id"); String replyChannel = (String) raw.get("_kubemq_reply_channel"); Map qdata = (Map) raw.get("data"); String sku = qdata != null ? (String) qdata.get("sku") : ""; int qty = INVENTORY.getOrDefault(sku, 0); System.out.println("[responder] query sku=" + sku + " qty=" + qty); CloudEvent respEvent = CloudEventBuilder.v1() .withId(UUID.randomUUID().toString()) .withType("com.kubemq.examples.queries.inventory-result") .withSource(URI.create("kubemq-ce-java-responder")) .withSubject(replyChannel) .withDataContentType("application/json") .withTime(OffsetDateTime.now()) .withData("application/json", MAPPER.writeValueAsBytes(Map.of("sku", sku, "quantity", qty))) .build(); String responseUrl = base + "/ce/send/response?request_id=" + requestId; httpClient.send( HttpRequest.newBuilder().uri(URI.create(responseUrl)) .POST(HttpRequest.BodyPublishers.ofByteArray(format.serialize(respEvent))) .header("Content-Type", "application/cloudevents+json").build(), HttpResponse.BodyHandlers.ofString()); System.out.println("[responder] response sent."); return; } evType = null; data = null; } else if (line.startsWith("event:")) evType = line.substring(6).trim(); else if (line.startsWith("data:")) data = line.substring(5).trim(); } } } catch (Exception e) { System.err.println("SSE error: " + e.getMessage()); } }); responderReady.poll(5, TimeUnit.SECONDS); Thread.sleep(100); // Send query. CloudEvent query = CloudEventBuilder.v1() .withId(UUID.randomUUID().toString()) .withType("com.kubemq.examples.queries.inventory-check") .withSource(URI.create("kubemq-ce-java-sender")) .withSubject(channel) .withDataContentType("application/json") .withTime(OffsetDateTime.now()) .withData("application/json", MAPPER.writeValueAsBytes(Map.of("sku", "WIDGET-100"))) .build(); System.out.println("[sender] sending query for sku=WIDGET-100..."); HttpResponse resp = httpClient.send( HttpRequest.newBuilder().uri(URI.create(base + "/ce/send/query")) .POST(HttpRequest.BodyPublishers.ofByteArray(format.serialize(query))) .header("Content-Type", "application/cloudevents+json").build(), HttpResponse.BodyHandlers.ofString()); Map result = MAPPER.readValue(resp.body(), Map.class); System.out.println("[sender] query response: status=" + resp.statusCode() + " is_error=" + result.get("is_error")); System.out.println("[sender] response data: " + result.get("data")); } } ``` ```csharp // Example: queries/RoundTrip — RPC query with data response. // Run: dotnet run using CloudNative.CloudEvents; using CloudNative.CloudEvents.SystemTextJson; using System.Net.Http.Headers; using System.Text; using System.Text.Json; static string ServerUrl() => Environment.GetEnvironmentVariable("KUBEMQ_CE_URL") ?? "http://localhost:9090"; var base_ = ServerUrl(); var channel = "csharp-ce-queries.round-trip"; var formatter = new JsonEventFormatter(); var inventory = new Dictionary { ["WIDGET-100"] = 42, ["GADGET-200"] = 7 }; var responderDone = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); async Task RunResponder() { using var http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan }; var url = $"{base_}/ce/subscribe/queries?client_id=csharp-query-responder&channel={Uri.EscapeDataString(channel)}"; using var req = new HttpRequestMessage(HttpMethod.Get, url); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); using var resp = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead); using var reader = new StreamReader(await resp.Content.ReadAsStreamAsync(), Encoding.UTF8); string? evType = null, data = null, line; while ((line = await reader.ReadLineAsync()) != null) { if (line == "") { if (evType == "cloudevent" && data != null) { var raw = JsonSerializer.Deserialize(data); var requestId = raw.GetProperty("_kubemq_request_id").GetString()!; var replyChannel = raw.GetProperty("_kubemq_reply_channel").GetString()!; var sku = raw.GetProperty("data").GetProperty("sku").GetString()!; var qty = inventory.GetValueOrDefault(sku, 0); Console.WriteLine($"[responder] query sku={sku} qty={qty} request_id={requestId}"); var respEvent = new CloudEvent { Id = Guid.NewGuid().ToString(), Type = "com.kubemq.examples.queries.inventory-result", Source = new Uri("urn:csharp-query-responder"), Subject = replyChannel, DataContentType = "application/json", Data = new { sku, quantity = qty }, }; var bytes = formatter.EncodeStructuredModeMessage(respEvent, out var ct); using var rc = new ByteArrayContent(bytes.ToArray()); rc.Headers.ContentType = MediaTypeHeaderValue.Parse(ct.ToString()); using var sendHttp = new HttpClient(); await sendHttp.PostAsync($"{base_}/ce/send/response?request_id={requestId}", rc); Console.WriteLine("[responder] response sent."); responderDone.TrySetResult(); return; } evType = null; data = null; } else if (line.StartsWith("event:")) evType = line[6..].Trim(); else if (line.StartsWith("data:")) data = line[5..].Trim(); } } var responderTask = RunResponder(); await Task.Delay(500); // Send query. var query = new CloudEvent { Id = Guid.NewGuid().ToString(), Type = "com.kubemq.examples.queries.inventory-check", Source = new Uri("urn:kubemq-ce-csharp-sender"), Subject = channel, DataContentType = "application/json", Data = new { sku = "WIDGET-100" }, }; var qBytes = formatter.EncodeStructuredModeMessage(query, out var qCt); using var qContent = new ByteArrayContent(qBytes.ToArray()); qContent.Headers.ContentType = MediaTypeHeaderValue.Parse(qCt.ToString()); using var senderHttp = new HttpClient(); Console.WriteLine("[sender] sending query for sku=WIDGET-100..."); var queryResp = await senderHttp.PostAsync($"{base_}/ce/send/query", qContent); var queryJ = JsonSerializer.Deserialize(await queryResp.Content.ReadAsStringAsync()); Console.WriteLine($"[sender] query response: status={queryResp.StatusCode} is_error={queryJ.GetProperty("is_error")}"); Console.WriteLine($"[sender] response data: {queryJ.GetProperty("data")}"); await responderDone.Task; ``` ```ruby # Example: queries/round_trip — RPC query with data response. require "net/http"; require "uri"; require "json"; require "timeout"; require "securerandom"; require "cloud_events" def server_url = ENV.fetch("KUBEMQ_CE_URL", "http://localhost:9090") base = server_url; channel = "ruby-ce-queries.round-trip" sdk = CloudEvents::HttpBinding.default inventory = { "WIDGET-100" => 42, "GADGET-200" => 7 } ready = Queue.new responder = Thread.new do uri = URI("#{base}/ce/subscribe/queries?client_id=ruby-query-responder&channel=#{URI.encode_www_form_component(channel)}") Net::HTTP.start(uri.host, uri.port) do |http| req = Net::HTTP::Get.new(uri); req["Accept"] = "text/event-stream" http.request(req) do |resp| ready.push(true) # signal that SSE connection is established ev_type = nil; data = nil resp.read_body do |chunk| chunk.each_line do |line| line.chomp! if line.empty? if ev_type == "cloudevent" && data raw = JSON.parse(data) request_id = raw["_kubemq_request_id"] reply_channel = raw["_kubemq_reply_channel"] raw_data = raw["data"] raw_data = JSON.parse(raw_data) if raw_data.is_a?(String) sku = raw_data.is_a?(Hash) ? raw_data["sku"] : nil qty = inventory[sku] || 0 puts "[responder] query sku=#{sku} qty=#{qty} request_id=#{request_id}" resp_ev = CloudEvents::Event::V1.new( id: SecureRandom.uuid, type: "com.kubemq.examples.queries.inventory-result", source: URI("urn:ruby-query-responder"), subject: reply_channel, spec_version: "1.0", data_content_type: CloudEvents::ContentType.new("application/json"), data: JSON.generate({ sku: sku, quantity: qty })) resp_h, resp_b = sdk.encode_event(resp_ev, structured_format: "json") ruri = URI("#{base}/ce/send/response?request_id=#{URI.encode_www_form_component(request_id)}") Net::HTTP.start(ruri.host, ruri.port) do |rhttp| rreq = Net::HTTP::Post.new(ruri); resp_h.each{|k,v|rreq[k]=v}; rreq.body=resp_b rhttp.request(rreq) end puts "[responder] response sent." Thread.exit end ev_type = nil; data = nil elsif line.start_with?("event:") then ev_type = line.sub("event:","").strip elsif line.start_with?("data:") then data = line.sub("data:","").strip end end end end end end Timeout.timeout(5) { ready.pop }; sleep 0.1 ev = CloudEvents::Event::V1.new( id: SecureRandom.uuid, type: "com.kubemq.examples.queries.inventory-check", source: URI("urn:kubemq-ce-ruby-sender"), subject: channel, spec_version: "1.0", data_content_type: CloudEvents::ContentType.new("application/json"), data: JSON.generate({ sku: "WIDGET-100" })) ev_h, ev_b = sdk.encode_event(ev, structured_format: "json") uri = URI("#{base}/ce/send/query") puts "[sender] sending query for sku=WIDGET-100..." Net::HTTP.start(uri.host, uri.port) do |http| req = Net::HTTP::Post.new(uri); ev_h.each{|k,v|req[k]=v}; req.body=ev_b res = http.request(req) r = JSON.parse(res.body) puts "[sender] query response: status=#{res.code} is_error=#{r['is_error']}" puts "[sender] response data: #{r['data']}" end responder.join ``` ```rust //! Example: queries/round-trip //! //! Responder subscribes to queries, receives a query, sends a CE response. //! Sender publishes via POST /ce/send/query (blocks until response arrives). //! //! Run: cargo run -p round-trip-queries use bytes::Bytes; use cloudevents::{EventBuilder, EventBuilderV10}; use futures_util::StreamExt; use reqwest::Client; use serde_json::{json, Value}; use std::{collections::HashMap, env}; use tokio::sync::oneshot; use uuid::Uuid; fn server_url() -> String { env::var("KUBEMQ_CE_URL").unwrap_or_else(|_| "http://localhost:9090".to_string()) } async fn run_responder(base: String, channel: String, ready_tx: oneshot::Sender<()>) { let client = Client::new(); let url = format!("{}/ce/subscribe/queries?client_id=rust-query-responder&channel={}", base, channel); let resp = client.get(&url) .header("Accept", "text/event-stream") .send().await.expect("SSE connect"); // Signal ready as soon as the SSE connection is established let _ = ready_tx.send(()); let mut stream = Box::pin(resp.bytes_stream()); let mut buffer = String::new(); let mut ev_type = String::new(); let mut data_str = String::new(); let inventory: HashMap<&str, u32> = [("WIDGET-100", 42), ("GADGET-200", 7)].into(); while let Some(chunk) = stream.next().await { let chunk: Bytes = chunk.unwrap_or_default(); buffer.push_str(&String::from_utf8_lossy(&chunk)); while let Some(pos) = buffer.find('\n') { let line = buffer[..pos].trim_end_matches('\r').to_string(); buffer = buffer[pos + 1..].to_string(); if line.is_empty() { if ev_type == "cloudevent" && !data_str.is_empty() { let raw: Value = serde_json::from_str(&data_str).unwrap(); let request_id = raw["_kubemq_request_id"].as_str().unwrap_or("").to_string(); let reply_channel = raw["_kubemq_reply_channel"].as_str().unwrap_or("").to_string(); let sku = raw["data"]["sku"].as_str().unwrap_or(""); let qty = *inventory.get(sku).unwrap_or(&0); println!("[responder] query sku={} qty={} request_id={}", sku, qty, request_id); let resp_event = EventBuilderV10::new() .id(Uuid::new_v4().to_string()) .ty("com.kubemq.examples.queries.inventory-result") .source("urn:rust-query-responder") .subject(reply_channel.as_str()) .data("application/json", json!({"sku": sku, "quantity": qty})) .build().unwrap(); let body = serde_json::to_string(&resp_event).unwrap(); let r = client.post(format!("{}/ce/send/response?request_id={}", base, request_id)) .header("Content-Type", "application/cloudevents+json") .body(body).send().await.unwrap(); let rj: Value = r.json().await.unwrap(); println!("[responder] response sent: is_error={}", rj["is_error"]); return; } ev_type.clear(); data_str.clear(); } else if line.starts_with(':') { } else if let Some(v) = line.strip_prefix("event:") { ev_type = v.trim().to_string(); } else if let Some(v) = line.strip_prefix("data:") { data_str = v.trim().to_string(); } } } } #[tokio::main] async fn main() -> Result<(), Box> { let base = server_url(); let channel = "rust-ce-queries.round-trip".to_string(); let client = Client::new(); let (ready_tx, ready_rx) = oneshot::channel::<()>(); let base_clone = base.clone(); let channel_clone = channel.clone(); tokio::spawn(async move { run_responder(base_clone, channel_clone, ready_tx).await }); tokio::time::timeout(tokio::time::Duration::from_secs(5), ready_rx).await??; tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; let event = EventBuilderV10::new() .id(Uuid::new_v4().to_string()) .ty("com.kubemq.examples.queries.inventory-check") .source("urn:kubemq-ce-rust-sender") .subject(channel.as_str()) .data("application/json", json!({"sku": "WIDGET-100"})) .build()?; let body = serde_json::to_string(&event)?; println!("[sender] sending query for sku=WIDGET-100..."); let resp = client.post(format!("{}/ce/send/query", base)) .header("Content-Type", "application/cloudevents+json") .body(body).send().await?; let result: Value = resp.json().await?; println!("[sender] query response: status=200 is_error={}", result["is_error"]); println!("[sender] response data: {}", result["data"]); tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; Ok(()) } ``` Commands and queries are synchronous. The responder must be online and reply within the connector's `TimeoutSeconds` (default 60s), or the send fails with HTTP 504. The `?group=` load-balancing parameter is only available for [Events](/connectors/cloudevents/how-to/events) subscriptions. Command, query, and events-store subscriptions do not accept `group`. ## Related [#related] # Content Modes (/connectors/cloudevents/how-to/content-modes) A CloudEvent can be encoded two ways on the wire: **structured mode**, where every attribute lives in a single `application/cloudevents+json` body, and **binary mode**, where attributes ride in `ce-*` HTTP headers and the body carries only the data. The KubeMQ CloudEvents connector accepts both interchangeably and detects the mode from each request independently. ## Overview [#overview] The [CloudEvents HTTP Protocol Binding](https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/bindings/http-protocol-binding.md) defines two content modes. Both carry the same event — the same `type`, `source`, `subject`, and `data` — they differ only in how those attributes are placed in the HTTP request. | Mode | Content-Type | Attributes | Data | | ---------- | --------------------------------------------------- | ---------------------- | ----------------------------------------- | | Structured | `application/cloudevents+json` | In the JSON body | In the JSON body (`data` / `data_base64`) | | Binary | the data's own media type (e.g. `application/json`) | In `ce-*` HTTP headers | The raw request body | There is no client-side handshake or configuration: every CloudEvents send endpoint (`/ce/send/event`, `/ce/send/event-store`, `/ce/send/command`, `/ce/send/query`, `/ce/queue/send`) accepts either mode on any request. Choose structured mode for human-readable, single-serialization sends — the default for most use cases — and binary mode when your payload is already in its native format (protobuf, an image, plain text) and you want to preserve its content type without re-wrapping it. ## How it works [#how-it-works] Both encodings flow into the same connector, which uses the CloudEvents SDK to detect the mode and produce one identical KubeMQ message. *Structured and binary requests are both decoded by the CloudEvents SDK into one identical KubeMQ message.* The connector calls `cehttp.NewEventFromHTTPRequest`, which inspects the request to choose a mode: | `Content-Type` | `ce-specversion` header | Detected mode | | ------------------------------ | ----------------------- | ---------------------- | | `application/cloudevents+json` | *(ignored)* | Structured | | Any other value | Present | Binary | | Any other value | Absent | HTTP `400 Bad Request` | A request that is neither — no CloudEvents content type and no `ce-*` headers — is rejected with `400` (`invalid CloudEvent`). ## Structured mode [#structured-mode] All attributes and data are serialized into a single JSON document sent with `Content-Type: application/cloudevents+json`. ```bash title="Structured mode — wire format" curl -X POST http://localhost:9090/ce/send/event \ -H "Content-Type: application/cloudevents+json" \ -d '{ "specversion": "1.0", "type": "com.example.order.created", "source": "order-service", "id": "550e8400-e29b-41d4-a716-446655440000", "subject": "orders", "time": "2026-03-29T10:30:00Z", "datacontenttype": "application/json", "data": {"order_id": "12345", "amount": 99.99} }' ``` ## Binary mode [#binary-mode] Attributes are carried in `ce-*` HTTP headers and the body contains only the event data. `Content-Type` reflects the data's own media type. ```bash title="Binary mode — wire format" curl -X POST http://localhost:9090/ce/send/event \ -H "Content-Type: application/json" \ -H "ce-specversion: 1.0" \ -H "ce-type: com.example.order.created" \ -H "ce-source: order-service" \ -H "ce-id: 550e8400-e29b-41d4-a716-446655440000" \ -H "ce-subject: orders" \ -H "ce-time: 2026-03-29T10:30:00Z" \ -d '{"order_id": "12345", "amount": 99.99}' ``` ## Sending both modes [#sending-both-modes] Each CloudEvents SDK builds one event and serializes it to either mode with a single helper call — `to_structured` / `to_binary`, `HTTP.structured` / `HTTP.binary`, and equivalents. The example below posts the same event to `/ce/send/event` first as structured, then as binary; both return `HTTP 202 Accepted`. ```bash # Structured mode — application/cloudevents+json body curl -X POST http://localhost:9090/ce/send/event \ -H "Content-Type: application/cloudevents+json" \ -d '{ "specversion": "1.0", "type": "com.kubemq.examples.events.content-mode", "source": "kubemq-ce-curl-example", "subject": "ce-events.content-modes", "datacontenttype": "application/json", "data": {"message": "structured mode payload"} }' # Binary mode — ce-* headers, data-only body curl -X POST http://localhost:9090/ce/send/event \ -H "Content-Type: application/json" \ -H "ce-specversion: 1.0" \ -H "ce-type: com.kubemq.examples.events.content-mode" \ -H "ce-source: kubemq-ce-curl-example" \ -H "ce-subject: ce-events.content-modes" \ -d '{"message": "binary mode payload"}' ``` ```csharp // Example: events/ContentModes — structured and binary mode. // Run: dotnet run using CloudNative.CloudEvents; using CloudNative.CloudEvents.SystemTextJson; using System.Net.Http.Headers; using System.Text.Json; static string ServerUrl() => Environment.GetEnvironmentVariable("KUBEMQ_CE_URL") ?? "http://localhost:9090"; var base_ = ServerUrl(); var formatter = new JsonEventFormatter(); using var httpClient = new HttpClient(); var cloudEvent = new CloudEvent { Id = Guid.NewGuid().ToString(), Type = "com.kubemq.examples.events.content-mode", Source = new Uri("urn:kubemq-ce-csharp-example"), Subject = "csharp-ce-events.content-modes", DataContentType = "application/json", Data = new { message = "hello content modes" }, }; // Structured mode Console.WriteLine("Sending in structured mode:"); var structuredBytes = formatter.EncodeStructuredModeMessage(cloudEvent, out var structuredCt); using var sc = new ByteArrayContent(structuredBytes.ToArray()); sc.Headers.ContentType = MediaTypeHeaderValue.Parse(structuredCt.ToString()); var r1 = await httpClient.PostAsync($"{base_}/ce/send/event", sc); var j1 = JsonSerializer.Deserialize(await r1.Content.ReadAsStringAsync()); Console.WriteLine($"[structured] status={r1.StatusCode} is_error={j1.GetProperty("is_error")}"); // Binary mode Console.WriteLine("\nSending in binary mode:"); using var binaryContent = new StringContent( JsonSerializer.Serialize(new { message = "hello binary mode" }), System.Text.Encoding.UTF8, "application/json"); using var binaryRequest = new HttpRequestMessage(HttpMethod.Post, $"{base_}/ce/send/event") { Content = binaryContent }; binaryRequest.Headers.Add("ce-specversion", "1.0"); binaryRequest.Headers.Add("ce-type", cloudEvent.Type); binaryRequest.Headers.Add("ce-source", cloudEvent.Source!.ToString()); binaryRequest.Headers.Add("ce-id", cloudEvent.Id); binaryRequest.Headers.Add("ce-subject", cloudEvent.Subject); binaryRequest.Headers.Add("ce-time", DateTimeOffset.UtcNow.ToString("O")); var r2 = await httpClient.SendAsync(binaryRequest); var j2 = JsonSerializer.Deserialize(await r2.Content.ReadAsStringAsync()); Console.WriteLine($"[binary] status={r2.StatusCode} is_error={j2.GetProperty("is_error")}"); Console.WriteLine("\nBoth content modes accepted."); ``` ```go // Example: events/content-modes // Sends the same event payload in both CloudEvents content modes: // - Structured: Content-Type: application/cloudevents+json, all attrs in JSON body // - Binary: ce-* HTTP headers, raw data body // Run: go run ./events/content-modes/main.go package main import ( "bytes" "encoding/json" "fmt" "log" "net/http" "os" "time" cloudevents "github.com/cloudevents/sdk-go/v2" ) func serverURL() string { if u := os.Getenv("KUBEMQ_CE_URL"); u != "" { return u } return "http://localhost:9090" } type CEResponse struct { IsError bool `json:"is_error"` Message string `json:"message"` } func sendStructured(base string, event cloudevents.Event) error { body, err := json.Marshal(event) if err != nil { return err } req, err := http.NewRequest("POST", base+"/ce/send/event", bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/cloudevents+json") resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() var result CEResponse _ = json.NewDecoder(resp.Body).Decode(&result) fmt.Printf("[structured] status=%d is_error=%v message=%s\n", resp.StatusCode, result.IsError, result.Message) return nil } func sendBinary(base string, event cloudevents.Event) error { // In binary mode, CE attributes go into ce-* HTTP headers. // The body contains only the event data. dataJSON, err := json.Marshal(map[string]string{"message": "binary mode payload"}) if err != nil { return err } req, err := http.NewRequest("POST", base+"/ce/send/event", bytes.NewReader(dataJSON)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("ce-specversion", "1.0") req.Header.Set("ce-type", event.Type()) req.Header.Set("ce-source", event.Source()) req.Header.Set("ce-subject", event.Subject()) req.Header.Set("ce-id", event.ID()) req.Header.Set("ce-time", event.Time().Format(time.RFC3339Nano)) resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() var result CEResponse _ = json.NewDecoder(resp.Body).Decode(&result) fmt.Printf("[binary] status=%d is_error=%v message=%s\n", resp.StatusCode, result.IsError, result.Message) return nil } func main() { base := serverURL() // Build a CloudEvent to send in both modes. event := cloudevents.NewEvent() event.SetType("com.kubemq.examples.events.content-mode") event.SetSource("kubemq-ce-go-example") event.SetSubject("go-ce-events.content-modes") _ = event.SetData(cloudevents.ApplicationJSON, map[string]string{ "message": "structured mode payload", }) fmt.Println("Sending in structured mode (Content-Type: application/cloudevents+json):") if err := sendStructured(base, event); err != nil { log.Fatal("structured send:", err) } fmt.Println("\nSending in binary mode (ce-* HTTP headers):") if err := sendBinary(base, event); err != nil { log.Fatal("binary send:", err) } fmt.Println("\nBoth content modes accepted by KubeMQ CE connector.") } ``` ```java // Example: events/content-modes — structured and binary mode. // Run: mvn compile exec:java package io.kubemq.examples.events.contentmodes; import com.fasterxml.jackson.databind.ObjectMapper; import io.cloudevents.CloudEvent; import io.cloudevents.core.builder.CloudEventBuilder; import io.cloudevents.core.format.EventFormat; import io.cloudevents.core.provider.EventFormatProvider; import io.cloudevents.jackson.JsonFormat; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.OffsetDateTime; import java.util.Map; import java.util.UUID; public class Main { static String serverUrl() { String u = System.getenv("KUBEMQ_CE_URL"); return (u != null && !u.isEmpty()) ? u : "http://localhost:9090"; } static final ObjectMapper MAPPER = new ObjectMapper(); public static void main(String[] args) throws Exception { String base = serverUrl(); EventFormatProvider.getInstance().registerFormat(new JsonFormat()); EventFormat format = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE); HttpClient httpClient = HttpClient.newHttpClient(); CloudEvent event = CloudEventBuilder.v1() .withId(UUID.randomUUID().toString()) .withType("com.kubemq.examples.events.content-mode") .withSource(URI.create("kubemq-ce-java-example")) .withSubject("java-ce-events.content-modes") .withDataContentType("application/json") .withTime(OffsetDateTime.now()) .withData("application/json", MAPPER.writeValueAsBytes(Map.of("message", "hello content modes"))) .build(); // --- Structured mode --- System.out.println("Sending in structured mode:"); byte[] structuredBody = format.serialize(event); HttpResponse r1 = httpClient.send( HttpRequest.newBuilder().uri(URI.create(base + "/ce/send/event")) .POST(HttpRequest.BodyPublishers.ofByteArray(structuredBody)) .header("Content-Type", "application/cloudevents+json").build(), HttpResponse.BodyHandlers.ofString()); Map res1 = MAPPER.readValue(r1.body(), Map.class); System.out.printf("[structured] status=%d is_error=%s%n", r1.statusCode(), res1.get("is_error")); // --- Binary mode --- System.out.println("\nSending in binary mode:"); byte[] data = event.getData() != null ? event.getData().toBytes() : new byte[0]; HttpResponse r2 = httpClient.send( HttpRequest.newBuilder().uri(URI.create(base + "/ce/send/event")) .POST(HttpRequest.BodyPublishers.ofByteArray(data)) .header("Content-Type", "application/json") .header("ce-specversion", "1.0") .header("ce-type", event.getType()) .header("ce-source", event.getSource().toString()) .header("ce-id", event.getId()) .header("ce-subject", event.getSubject()) .header("ce-time", event.getTime().toString()) .build(), HttpResponse.BodyHandlers.ofString()); Map res2 = MAPPER.readValue(r2.body(), Map.class); System.out.printf("[binary] status=%d is_error=%s%n", r2.statusCode(), res2.get("is_error")); System.out.println("\nBoth content modes accepted."); } } ``` ```typescript // Example: events/content-modes // Sends the same event in structured and binary mode. // Run: npx tsx events/content-modes/index.ts import { CloudEvent, HTTP } from 'cloudevents'; function serverUrl(): string { return process.env.KUBEMQ_CE_URL ?? 'http://localhost:9090'; } async function main(): Promise { const base = serverUrl(); const event = new CloudEvent({ type: 'com.kubemq.examples.events.content-mode', source: 'kubemq-ce-js-example', subject: 'js-ce-events.content-modes', datacontenttype: 'application/json', data: { message: 'hello content modes' }, }); // Structured mode console.log('Sending in structured mode:'); const structured = HTTP.structured(event); const r1 = await fetch(`${base}/ce/send/event`, { method: 'POST', headers: structured.headers as Record, body: structured.body as string, }); const res1 = await r1.json() as { is_error: boolean }; console.log(`[structured] status=${r1.status} is_error=${res1.is_error}`); // Binary mode console.log('\nSending in binary mode:'); const binary = HTTP.binary(event); const r2 = await fetch(`${base}/ce/send/event`, { method: 'POST', headers: binary.headers as Record, body: binary.body as string, }); const res2 = await r2.json() as { is_error: boolean }; console.log(`[binary] status=${r2.status} is_error=${res2.is_error}`); console.log('\nBoth content modes accepted.'); } main().catch(console.error); ``` ```python # Example: events/content_modes — structured vs binary CloudEvents mode. # Run: python events/content_modes/main.py from __future__ import annotations import os import requests from cloudevents.v1.conversion import to_binary, to_structured from cloudevents.v1.http import CloudEvent def server_url() -> str: return os.environ.get("KUBEMQ_CE_URL", "http://localhost:9090") def main() -> None: base = server_url() event = CloudEvent( attributes={ "type": "com.kubemq.examples.events.content-mode", "source": "kubemq-ce-python-example", "subject": "python-ce-events.content-modes", "datacontenttype": "application/json", }, data={"message": "hello content modes"}, ) # --- Structured mode --- print("Sending in structured mode (Content-Type: application/cloudevents+json):") headers, body = to_structured(event) resp = requests.post(f"{base}/ce/send/event", data=body, headers=dict(headers), timeout=10) result = resp.json() print(f"[structured] status={resp.status_code} is_error={result.get('is_error')}") # --- Binary mode --- print("\nSending in binary mode (ce-* HTTP headers):") headers, body = to_binary(event) resp = requests.post(f"{base}/ce/send/event", data=body, headers=dict(headers), timeout=10) result = resp.json() print(f"[binary] status={resp.status_code} is_error={result.get('is_error')}") print("\nBoth content modes accepted by KubeMQ CE connector.") if __name__ == "__main__": main() ``` ```ruby # Example: events/content_modes — structured and binary mode. # Run: ruby events/content_modes/main.rb require "net/http" require "uri" require "json" require "time" require "securerandom" require "cloud_events" def server_url = ENV.fetch("KUBEMQ_CE_URL", "http://localhost:9090") base = server_url sdk = CloudEvents::HttpBinding.default event = CloudEvents::Event::V1.new( id: SecureRandom.uuid, type: "com.kubemq.examples.events.content-mode", source: URI("urn:kubemq-ce-ruby-example"), subject: "ruby-ce-events.content-modes", spec_version: "1.0", data_content_type: CloudEvents::ContentType.new("application/json"), data: JSON.generate({ message: "hello content modes" }) ) def post_event(base, headers_hash, body) uri = URI("#{base}/ce/send/event") Net::HTTP.start(uri.host, uri.port) do |http| req = Net::HTTP::Post.new(uri) headers_hash.each { |k, v| req[k] = v } req.body = body res = http.request(req) JSON.parse(res.body).merge("status" => res.code) end end # Structured mode puts "Sending in structured mode:" enc_headers, enc_body = sdk.encode_event(event, structured_format: "json") r1 = post_event(base, enc_headers, enc_body) puts "[structured] status=#{r1['status']} is_error=#{r1['is_error']}" # Binary mode — CE attrs in headers, data as body puts "\nSending in binary mode:" binary_headers = { "Content-Type" => "application/json", "ce-specversion" => "1.0", "ce-type" => event.type, "ce-source" => event.source.to_s, "ce-id" => event.id, "ce-subject" => event.subject, "ce-time" => Time.now.utc.iso8601(9), } r2 = post_event(base, binary_headers, event.data) puts "[binary] status=#{r2['status']} is_error=#{r2['is_error']}" puts "\nBoth content modes accepted." ``` ```rust //! Example: events/content-modes — structured and binary mode. //! Run: cargo run -p content-modes use cloudevents::{AttributesReader, EventBuilder, EventBuilderV10}; use reqwest::Client; use serde_json::{json, Value}; use std::env; use uuid::Uuid; fn server_url() -> String { env::var("KUBEMQ_CE_URL").unwrap_or_else(|_| "http://localhost:9090".to_string()) } #[tokio::main] async fn main() -> Result<(), Box> { let base = server_url(); let client = Client::new(); let event = EventBuilderV10::new() .id(Uuid::new_v4().to_string()) .ty("com.kubemq.examples.events.content-mode") .source("urn:kubemq-ce-rust-example") .subject("rust-ce-events.content-modes") .data("application/json", json!({"message": "hello content modes"})) .build()?; // Structured mode println!("Sending in structured mode:"); let body = serde_json::to_string(&event)?; let r1 = client.post(format!("{}/ce/send/event", base)) .header("Content-Type", "application/cloudevents+json") .body(body) .send().await?; let j1: Value = r1.json().await?; println!("[structured] status=202 is_error={}", j1["is_error"]); // Binary mode — CE attrs in ce-* headers, JSON data as body println!("\nSending in binary mode:"); let data_body = serde_json::to_string(&json!({"message": "binary mode payload"}))?; let r2 = client.post(format!("{}/ce/send/event", base)) .header("Content-Type", "application/json") .header("ce-specversion", "1.0") .header("ce-type", event.ty()) .header("ce-source", event.source().as_str()) .header("ce-id", event.id()) .header("ce-subject", event.subject().unwrap_or("")) .body(data_body) .send().await?; let j2: Value = r2.json().await?; println!("[binary] status=202 is_error={}", j2["is_error"]); println!("\nBoth content modes accepted."); Ok(()) } ``` The SDK helpers (`to_structured` / `to_binary` and their per-language equivalents) build the correct headers and body for each mode from one `CloudEvent` object. Reach for them rather than assembling `ce-*` headers by hand — they keep attribute names and `time` formatting spec-compliant. ## Choosing a mode [#choosing-a-mode] | Structured | Binary | | -------------------------------------------- | --------------------------------------------- | | One JSON body — simplest to read and debug | Preserves the data's native content type | | Single serialization step | Best for binary or non-JSON payloads | | Recommended default | Use when data is already in its native format | | Slightly larger requests (attributes inline) | One HTTP header per attribute | Structured mode is the recommended default. Reach for binary mode when your payload is non-JSON — an image, protobuf, or plain text — and you want to keep its native `Content-Type` rather than wrap it in a CloudEvents envelope. ## Outbound encoding: data vs data\_base64 [#outbound-encoding-data-vs-data_base64] The mode you send in does not dictate how the connector represents the event when it later delivers it to subscribers (over SSE or a queue receive). On the way out, the connector always reconstructs a structured CloudEvent JSON object and chooses where the payload goes based on whether the stored body is valid JSON: * **Valid JSON** → placed inline in the `data` attribute * **Non-JSON** (binary, plain text) → base64-encoded into the `data_base64` attribute ```json title="Inline JSON payload" { "specversion": "1.0", "type": "com.example.order.created", "source": "order-service", "subject": "orders", "data": {"order_id": "12345", "amount": 99.99} } ``` ```json title="Base64-encoded binary payload" { "specversion": "1.0", "type": "com.example.sensor.reading", "source": "sensor-gateway", "subject": "telemetry", "data_base64": "SGVsbG8gV29ybGQ=" } ``` This follows the [CloudEvents JSON format](https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/formats/json-format.md), where `data_base64` is the standard carrier for non-JSON payloads. ## Related [#related] # Events Store (/connectors/cloudevents/how-to/events-store) The events-store pattern persists every CloudEvent to a durable, sequenced log so a subscriber can replay history, start at a specific point, or resume after a disconnect. It is the CloudEvents-over-HTTP equivalent of KubeMQ's [Events Store](/learn/events-store) messaging pattern. ## Overview [#overview] Where a plain [events](/connectors/cloudevents/how-to/events) channel is fire-and-forget, an events-store channel writes each event to disk with a monotonically increasing **sequence number**. Subscribers choose a **start position** when they connect — receive only new events, replay everything from the beginning, jump to a sequence number, or seek by time. Because every Server-Sent Events (SSE) frame carries the sequence number in its `id:` field, a subscriber that drops its connection can reconnect with the `Last-Event-ID` header and the server resumes from `sequence + 1`, automatically replaying whatever it missed. You send persistent events to `POST /ce/send/event-store` and subscribe over SSE at `GET /ce/subscribe/events-store`. Both accept the same CloudEvent structured and binary [content modes](/connectors/cloudevents/how-to/content-modes) as a non-persistent event. ## How it works [#how-it-works] A publisher persists CloudEvents to the durable channel; a subscriber connects with a start position and the connector replays stored events, then streams new ones — each frame tagged with its sequence number for resume. *Persisted events get a sequence number; subscribers choose where to start and resume from the last sequence they saw.* ## Start positions [#start-positions] The `GET /ce/subscribe/events-store` endpoint accepts an `events_store_type` query parameter (default `1`). Types `4`, `5`, and `6` also read `events_store_value`. | `events_store_type` | Name | `events_store_value` | Behavior | | ------------------- | ---------------- | -------------------- | --------------------------------------------------- | | `1` | StartNewOnly | — | Only events that arrive after subscribing (default) | | `2` | StartFromFirst | — | Replay from the first stored event | | `3` | StartFromLast | — | Start from the last stored event | | `4` | StartAtSequence | sequence number | Start at a specific sequence number | | `5` | StartAtTime | Unix seconds | Start at an absolute timestamp | | `6` | StartAtTimeDelta | seconds | Start at a time delta back from now | Only events-store subscriptions emit an `id:` field on each SSE frame (the sequence number), because only persisted events can be replayed. Plain `events` subscriptions never emit `id:`. ## Resume with Last-Event-ID [#resume-with-last-event-id] When a subscriber reconnects with the `Last-Event-ID` header set to the last sequence number it processed, the server resumes the stream from `sequence + 1`. This is the standard SSE reconnection protocol — browsers and `EventSource` clients send `Last-Event-ID` automatically. If both `Last-Event-ID` and `events_store_type` are present, the **query parameter wins**. To use `Last-Event-ID` for reconnection, omit `events_store_type` from the reconnect URL. ## Send a persistent event [#send-a-persistent-event] Publish a CloudEvent to an events-store channel. The `subject` attribute resolves to the channel; the connector responds with HTTP 202. ```bash curl -X POST http://localhost:9090/ce/send/event-store \ -H "Content-Type: application/cloudevents+json" \ -d '{ "specversion": "1.0", "type": "com.example.audit.entry", "source": "audit-service", "subject": "audit-log", "datacontenttype": "application/json", "data": {"action": "user.login", "user": "admin"} }' ``` ```csharp using CloudNative.CloudEvents; using CloudNative.CloudEvents.SystemTextJson; using System.Net.Http.Headers; var base_ = "http://localhost:9090"; var channel = "audit-log"; var formatter = new JsonEventFormatter(); using var httpClient = new HttpClient(); var ev = new CloudEvent { Id = Guid.NewGuid().ToString(), Type = "com.kubemq.examples.eventsstore.stored", Source = new Uri("urn:kubemq-ce-csharp-example"), Subject = channel, DataContentType = "application/json", Data = new { msg = "hello events-store from C#!" }, }; var bytes = formatter.EncodeStructuredModeMessage(ev, out var ct); using var content = new ByteArrayContent(bytes.ToArray()); content.Headers.ContentType = MediaTypeHeaderValue.Parse(ct.ToString()); var resp = await httpClient.PostAsync($"{base_}/ce/send/event-store", content); Console.WriteLine($"Published to events-store: status={resp.StatusCode}"); ``` ```go import ( "encoding/json" "net/http" "strings" cloudevents "github.com/cloudevents/sdk-go/v2" ) func sendEventStore(base, channel, msg string) error { event := cloudevents.NewEvent() event.SetType("com.kubemq.examples.eventsstore.stored") event.SetSource("kubemq-ce-go-example") event.SetSubject(channel) _ = event.SetData(cloudevents.ApplicationJSON, map[string]string{"msg": msg}) body, _ := json.Marshal(event) req, _ := http.NewRequest("POST", base+"/ce/send/event-store", strings.NewReader(string(body))) req.Header.Set("Content-Type", "application/cloudevents+json") resp, err := http.DefaultClient.Do(req) if err != nil { return err } return resp.Body.Close() } ``` ```java EventFormatProvider.getInstance().registerFormat(new JsonFormat()); EventFormat format = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE); CloudEvent event = CloudEventBuilder.v1() .withId(UUID.randomUUID().toString()) .withType("com.kubemq.examples.eventsstore.stored") .withSource(URI.create("kubemq-ce-java-example")) .withSubject(channel) .withDataContentType("application/json") .withTime(OffsetDateTime.now()) .withData("application/json", MAPPER.writeValueAsBytes(Map.of("msg", "hello events-store from Java!"))) .build(); HttpClient httpClient = HttpClient.newHttpClient(); HttpResponse resp = httpClient.send( HttpRequest.newBuilder().uri(URI.create(base + "/ce/send/event-store")) .POST(HttpRequest.BodyPublishers.ofByteArray(format.serialize(event))) .header("Content-Type", "application/cloudevents+json").build(), HttpResponse.BodyHandlers.ofString()); System.out.println("Published to events-store: status=" + resp.statusCode()); ``` ```typescript import { CloudEvent, HTTP } from 'cloudevents'; const base = 'http://localhost:9090'; const channel = 'audit-log'; const event = new CloudEvent({ type: 'com.kubemq.examples.eventsstore.stored', source: 'kubemq-ce-js-example', subject: channel, datacontenttype: 'application/json', data: { msg: 'hello events-store from JS!' }, }); const msg = HTTP.structured(event); const resp = await fetch(`${base}/ce/send/event-store`, { method: 'POST', headers: msg.headers as Record, body: msg.body as string, }); console.log(`Published to events-store: status=${resp.status}`); ``` ```python import requests from cloudevents.v1.conversion import to_structured from cloudevents.v1.http import CloudEvent base = "http://localhost:9090" channel = "audit-log" event = CloudEvent( attributes={ "type": "com.kubemq.examples.eventsstore.stored", "source": "kubemq-ce-python-example", "subject": channel, "datacontenttype": "application/json", }, data={"msg": "hello events-store from Python!"}, ) headers, body = to_structured(event) resp = requests.post(f"{base}/ce/send/event-store", data=body, headers=dict(headers), timeout=10) print(f"Published to events-store: status={resp.status_code}") ``` ```ruby require "net/http"; require "uri"; require "json"; require "cloud_events"; require "securerandom" base = "http://localhost:9090"; channel = "audit-log" sdk = CloudEvents::HttpBinding.default event = CloudEvents::Event::V1.new( id: SecureRandom.uuid, type: "com.kubemq.examples.eventsstore.stored", source: URI("urn:kubemq-ce-ruby-example"), subject: channel, spec_version: "1.0", data_content_type: CloudEvents::ContentType.new("application/json"), data: JSON.generate({ msg: "hello events-store from Ruby!" }) ) enc_h, enc_b = sdk.encode_event(event, structured_format: "json") uri = URI("#{base}/ce/send/event-store") Net::HTTP.start(uri.host, uri.port) do |http| req = Net::HTTP::Post.new(uri); enc_h.each { |k, v| req[k] = v }; req.body = enc_b res = http.request(req) puts "Published to events-store: status=#{res.code}" end ``` ```rust use cloudevents::{EventBuilder, EventBuilderV10}; use reqwest::Client; use serde_json::json; use uuid::Uuid; let base = "http://localhost:9090"; let channel = "audit-log"; let client = Client::new(); let event = EventBuilderV10::new() .id(Uuid::new_v4().to_string()) .ty("com.kubemq.examples.eventsstore.stored") .source("urn:kubemq-ce-rust-example") .subject(channel) .data("application/json", json!({"msg": "hello events-store from Rust!"})) .build()?; let body = serde_json::to_string(&event)?; let resp = client.post(format!("{}/ce/send/event-store", base)) .header("Content-Type", "application/cloudevents+json") .body(body).send().await?; println!("Published to events-store: status={}", resp.status()); ``` ## Subscribe with StartNewOnly [#subscribe-with-startnewonly] The default subscription (`events_store_type=1`) delivers only events that arrive after the connection opens. The SSE handler reads `event: cloudevent` frames and parses each `data:` payload as a reconstructed CloudEvent. ```bash curl -N "http://localhost:9090/ce/subscribe/events-store?client_id=my-client&channel=audit-log&events_store_type=1" ``` ```csharp using System.Net.Http.Headers; using System.Text; using System.Text.Json; var url = $"{base_}/ce/subscribe/events-store?client_id=csharp-es-sub&channel={Uri.EscapeDataString(channel)}&events_store_type=1"; using var http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan }; using var req = new HttpRequestMessage(HttpMethod.Get, url); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); using var resp = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead); using var reader = new StreamReader(await resp.Content.ReadAsStreamAsync(), Encoding.UTF8); string? evType = null, data = null, line; while ((line = await reader.ReadLineAsync()) != null) { if (line == "") { if (evType == "cloudevent" && data != null) { var ce = JsonSerializer.Deserialize(data); Console.WriteLine($"Received: type={ce.GetProperty("type")} data={ce.GetProperty("data")}"); return; } evType = null; data = null; } else if (line.StartsWith("event:")) evType = line[6..].Trim(); else if (line.StartsWith("data:")) data = line[5..].Trim(); } ``` ```go // events_store_type=1 = StartNewOnly — only messages arriving after subscribe. sseURL := fmt.Sprintf( "%s/ce/subscribe/events-store?client_id=%s&channel=%s&events_store_type=1", base, clientID, channel) req, _ := http.NewRequest("GET", sseURL, nil) req.Header.Set("Accept", "text/event-stream") resp, err := (&http.Client{Timeout: 0}).Do(req) if err != nil { log.Fatal("SSE connect:", err) } defer resp.Body.Close() scanner := bufio.NewScanner(resp.Body) var evType, data string for scanner.Scan() { line := scanner.Text() if line == "" { if evType == "cloudevent" && data != "" { var ce map[string]interface{} _ = json.Unmarshal([]byte(data), &ce) fmt.Printf("Received: type=%v data=%v\n", ce["type"], ce["data"]) return } evType, data = "", "" continue } if strings.HasPrefix(line, ":") { continue } if strings.HasPrefix(line, "event:") { evType = strings.TrimSpace(strings.TrimPrefix(line, "event:")) } else if strings.HasPrefix(line, "data:") { data = strings.TrimSpace(strings.TrimPrefix(line, "data:")) } } ``` ```java // events_store_type=1 = StartNewOnly String sseUrl = base + "/ce/subscribe/events-store?client_id=java-es-sub&channel=" + channel + "&events_store_type=1"; HttpURLConnection conn = (HttpURLConnection) new URL(sseUrl).openConnection(); conn.setRequestProperty("Accept", "text/event-stream"); conn.setReadTimeout(15_000); try (BufferedReader reader = new BufferedReader( new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { String line; String evType = null, data = null; while ((line = reader.readLine()) != null) { if (line.isEmpty()) { if ("cloudevent".equals(evType) && data != null) { Map ce = MAPPER.readValue(data, Map.class); System.out.println("Received: type=" + ce.get("type") + " data=" + ce.get("data")); return; } evType = null; data = null; } else if (line.startsWith("event:")) evType = line.substring(6).trim(); else if (line.startsWith("data:")) data = line.substring(5).trim(); } } ``` ```typescript import EventSource from 'eventsource'; const url = `${base}/ce/subscribe/events-store?client_id=js-es-sub&channel=${encodeURIComponent(channel)}&events_store_type=1`; const es = new EventSource(url); es.addEventListener('cloudevent', (evt: MessageEvent) => { const ce = JSON.parse(evt.data) as Record; console.log(`Received: type=${ce.type} data=${JSON.stringify(ce.data)}`); es.close(); }); es.addEventListener('error', (err) => { console.error('SSE error:', err); es.close(); }); ``` ```python import json import requests # events_store_type=1 = StartNewOnly sse_url = (f"{base}/ce/subscribe/events-store" f"?client_id=python-es-sub&channel={channel}&events_store_type=1") with requests.get(sse_url, stream=True, timeout=None, headers={"Accept": "text/event-stream"}) as resp: ev_type = data = "" for line in resp.iter_lines(decode_unicode=True): if line == "": if ev_type == "cloudevent" and data: ce = json.loads(data) print(f"Received: type={ce.get('type')} data={ce.get('data')}") break ev_type = data = "" continue if line.startswith(":"): continue if line.startswith("event:"): ev_type = line[6:].strip() elif line.startswith("data:"): data = line[5:].strip() ``` ```ruby require "net/http"; require "uri"; require "json" uri = URI("#{base}/ce/subscribe/events-store?client_id=ruby-es-sub&channel=#{URI.encode_www_form_component(channel)}&events_store_type=1") Net::HTTP.start(uri.host, uri.port) do |http| req = Net::HTTP::Get.new(uri); req["Accept"] = "text/event-stream" http.request(req) do |resp| ev_type = nil; data = nil resp.read_body do |chunk| chunk.each_line do |line| line.chomp! if line.empty? if ev_type == "cloudevent" && data ce = JSON.parse(data) puts "Received: type=#{ce['type']} data=#{ce['data']}" end ev_type = nil; data = nil elsif line.start_with?(":") then # keepalive elsif line.start_with?("event:") then ev_type = line.sub("event:", "").strip elsif line.start_with?("data:") then data = line.sub("data:", "").strip end end end end end ``` ```rust use futures_util::StreamExt; // events_store_type=1 = StartNewOnly let sub_url = format!( "{}/ce/subscribe/events-store?client_id=rust-es-sub&channel={}&events_store_type=1", base, channel ); let stream = client.get(&sub_url) .header("Accept", "text/event-stream") .send().await?.bytes_stream(); let mut stream = Box::pin(stream); let mut ev_type = String::new(); let mut data = String::new(); let mut buffer = String::new(); while let Some(chunk) = stream.next().await { buffer.push_str(&String::from_utf8_lossy(&chunk?)); while let Some(pos) = buffer.find('\n') { let line = buffer[..pos].trim_end_matches('\r').to_string(); buffer = buffer[pos + 1..].to_string(); if line.is_empty() { if ev_type == "cloudevent" && !data.is_empty() { let ce: serde_json::Value = serde_json::from_str(&data)?; println!("Received: type={} data={}", ce["type"], ce["data"]); return Ok(()); } ev_type.clear(); data.clear(); } else if line.starts_with(':') { } else if let Some(v) = line.strip_prefix("event:") { ev_type = v.trim().to_string(); } else if let Some(v) = line.strip_prefix("data:") { data = v.trim().to_string(); } } } ``` ## Replay from the first event [#replay-from-the-first-event] Set `events_store_type=2` (StartFromFirst) to replay every stored event from the beginning of the log before streaming new ones. ```bash curl -N "http://localhost:9090/ce/subscribe/events-store?client_id=my-client&channel=audit-log&events_store_type=2" ``` ```csharp // events_store_type=2 = StartFromFirst — replay all stored events. var url = $"{base_}/ce/subscribe/events-store?client_id=csharp-es-replay&channel={Uri.EscapeDataString(channel)}&events_store_type=2"; using var http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan }; using var req = new HttpRequestMessage(HttpMethod.Get, url); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); using var resp = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead); using var reader = new StreamReader(await resp.Content.ReadAsStreamAsync(), Encoding.UTF8); string? evType = null, data = null, line; while ((line = await reader.ReadLineAsync()) != null) { if (line == "") { if (evType == "cloudevent" && data != null) { var ce = JsonSerializer.Deserialize(data); Console.WriteLine($" replayed data={ce.GetProperty("data")}"); } evType = null; data = null; } else if (line.StartsWith("event:")) evType = line[6..].Trim(); else if (line.StartsWith("data:")) data = line[5..].Trim(); } ``` ```go // events_store_type=2 = StartFromFirst: replay all stored events. sseURL := fmt.Sprintf( "%s/ce/subscribe/events-store?client_id=%s&channel=%s&events_store_type=2", base, clientID, channel) req, _ := http.NewRequest("GET", sseURL, nil) req.Header.Set("Accept", "text/event-stream") resp, err := (&http.Client{Timeout: 0}).Do(req) if err != nil { log.Fatal("SSE connect:", err) } defer resp.Body.Close() scanner := bufio.NewScanner(resp.Body) var evType, data string for scanner.Scan() { line := scanner.Text() if line == "" { if evType == "cloudevent" && data != "" { var ce map[string]interface{} _ = json.Unmarshal([]byte(data), &ce) fmt.Printf(" replayed data=%v\n", ce["data"]) } evType, data = "", "" continue } if strings.HasPrefix(line, "event:") { evType = strings.TrimSpace(strings.TrimPrefix(line, "event:")) } else if strings.HasPrefix(line, "data:") { data = strings.TrimSpace(strings.TrimPrefix(line, "data:")) } } ``` ```java // events_store_type=2 = StartFromFirst String sseUrl = base + "/ce/subscribe/events-store?client_id=java-es-replay&channel=" + channel + "&events_store_type=2"; HttpURLConnection conn = (HttpURLConnection) new URL(sseUrl).openConnection(); conn.setRequestProperty("Accept", "text/event-stream"); conn.setReadTimeout(15_000); try (BufferedReader reader = new BufferedReader( new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { String line; String evType = null, data = null; while ((line = reader.readLine()) != null) { if (line.isEmpty()) { if ("cloudevent".equals(evType) && data != null) { Map ce = MAPPER.readValue(data, Map.class); System.out.println(" replayed data=" + ce.get("data")); } evType = null; data = null; } else if (line.startsWith("event:")) evType = line.substring(6).trim(); else if (line.startsWith("data:")) data = line.substring(5).trim(); } } ``` ```typescript import EventSource from 'eventsource'; // events_store_type=2 = StartFromFirst — replay all stored events. const url = `${base}/ce/subscribe/events-store?client_id=js-replay-sub&channel=${encodeURIComponent(channel)}&events_store_type=2`; const es = new EventSource(url); es.addEventListener('cloudevent', (evt: MessageEvent) => { const ce = JSON.parse(evt.data) as Record; console.log(` replayed data=${JSON.stringify(ce.data)}`); }); es.addEventListener('error', (err) => { console.error('SSE error:', err); es.close(); }); ``` ```python import json import requests # events_store_type=2 = StartFromFirst sse_url = (f"{base}/ce/subscribe/events-store" f"?client_id=python-replay-sub&channel={channel}&events_store_type=2") with requests.get(sse_url, stream=True, timeout=None, headers={"Accept": "text/event-stream"}) as resp: ev_type = data = "" for line in resp.iter_lines(decode_unicode=True): if line == "": if ev_type == "cloudevent" and data: ce = json.loads(data) print(f" replayed data={ce.get('data')}") ev_type = data = "" continue if line.startswith("event:"): ev_type = line[6:].strip() elif line.startswith("data:"): data = line[5:].strip() ``` ```ruby require "net/http"; require "uri"; require "json" # events_store_type=2 = StartFromFirst uri = URI("#{base}/ce/subscribe/events-store?client_id=ruby-es-replay&channel=#{URI.encode_www_form_component(channel)}&events_store_type=2") Net::HTTP.start(uri.host, uri.port) do |http| req = Net::HTTP::Get.new(uri); req["Accept"] = "text/event-stream" http.request(req) do |resp| ev_type = nil; data = nil resp.read_body do |chunk| chunk.each_line do |line| line.chomp! if line.empty? if ev_type == "cloudevent" && data ce = JSON.parse(data) ce["data"] = JSON.parse(ce["data"]) if ce["data"].is_a?(String) puts " replayed seq=#{ce.dig('data', 'seq')}" end ev_type = nil; data = nil elsif line.start_with?("event:") then ev_type = line.sub("event:", "").strip elsif line.start_with?("data:") then data = line.sub("data:", "").strip end end end end end ``` ```rust use futures_util::StreamExt; // events_store_type=2 = StartFromFirst let sub_url = format!( "{}/ce/subscribe/events-store?client_id=rust-es-replay&channel={}&events_store_type=2", base, channel ); let stream = client.get(&sub_url) .header("Accept", "text/event-stream") .send().await?.bytes_stream(); let mut stream = Box::pin(stream); let mut ev_type = String::new(); let mut data = String::new(); let mut buffer = String::new(); while let Some(chunk) = stream.next().await { buffer.push_str(&String::from_utf8_lossy(&chunk?)); while let Some(pos) = buffer.find('\n') { let line = buffer[..pos].trim_end_matches('\r').to_string(); buffer = buffer[pos + 1..].to_string(); if line.is_empty() { if ev_type == "cloudevent" && !data.is_empty() { let ce: serde_json::Value = serde_json::from_str(&data)?; println!(" replayed data={}", ce["data"]); } ev_type.clear(); data.clear(); } else if let Some(v) = line.strip_prefix("event:") { ev_type = v.trim().to_string(); } else if let Some(v) = line.strip_prefix("data:") { data = v.trim().to_string(); } } } ``` ## Replay at a sequence number [#replay-at-a-sequence-number] Set `events_store_type=4` (StartAtSequence) and `events_store_value=` to start the stream at a specific sequence number. The example below publishes 10 events and then replays from sequence 5. ```bash curl -N "http://localhost:9090/ce/subscribe/events-store?client_id=my-client&channel=audit-log&events_store_type=4&events_store_value=5" ``` ```csharp // events_store_type=4 (StartAtSequence) + events_store_value=startAtSeq long startAtSeq = 5; var url = $"{base_}/ce/subscribe/events-store?client_id=csharp-es-seq&channel={Uri.EscapeDataString(channel)}&events_store_type=4&events_store_value={startAtSeq}"; using var http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan }; using var req = new HttpRequestMessage(HttpMethod.Get, url); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); using var resp = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead); using var reader = new StreamReader(await resp.Content.ReadAsStreamAsync(), Encoding.UTF8); string? evType = null, data = null, line; while ((line = await reader.ReadLineAsync()) != null) { if (line == "") { if (evType == "cloudevent" && data != null) { var ce = JsonSerializer.Deserialize(data); Console.WriteLine($" Received: seq={ce.GetProperty("data").GetProperty("seq")}"); } evType = null; data = null; } else if (line.StartsWith("event:")) evType = line[6..].Trim(); else if (line.StartsWith("data:")) data = line[5..].Trim(); } ``` ```go // events_store_type=4 (StartAtSequence), events_store_value=5 const startSeq = 5 sseURL := fmt.Sprintf( "%s/ce/subscribe/events-store?client_id=go-replay-seq-sub&channel=%s&events_store_type=4&events_store_value=%d", base, channel, startSeq) req, _ := http.NewRequest("GET", sseURL, nil) req.Header.Set("Accept", "text/event-stream") resp, err := (&http.Client{Timeout: 0}).Do(req) if err != nil { log.Fatal("SSE connect:", err) } defer resp.Body.Close() scanner := bufio.NewScanner(resp.Body) var evType, data, sseID string for scanner.Scan() { line := scanner.Text() if line == "" { if evType == "cloudevent" && data != "" { var ce map[string]interface{} _ = json.Unmarshal([]byte(data), &ce) fmt.Printf(" [seq=%s] data=%v\n", sseID, ce["data"]) } evType, data, sseID = "", "", "" continue } if strings.HasPrefix(line, "id:") { sseID = strings.TrimSpace(strings.TrimPrefix(line, "id:")) } else if strings.HasPrefix(line, "event:") { evType = strings.TrimSpace(strings.TrimPrefix(line, "event:")) } else if strings.HasPrefix(line, "data:") { data = strings.TrimSpace(strings.TrimPrefix(line, "data:")) } } ``` ```java // events_store_type=4 = StartAtSequence, events_store_value=startAtSeq long startAtSeq = 5; String sseUrl = base + "/ce/subscribe/events-store?client_id=java-es-seq&channel=" + channel + "&events_store_type=4&events_store_value=" + startAtSeq; HttpURLConnection conn = (HttpURLConnection) new URL(sseUrl).openConnection(); conn.setRequestProperty("Accept", "text/event-stream"); conn.setReadTimeout(15_000); try (BufferedReader reader = new BufferedReader( new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { String line; String evType = null, data = null; while ((line = reader.readLine()) != null) { if (line.isEmpty()) { if ("cloudevent".equals(evType) && data != null) { Map ce = MAPPER.readValue(data, Map.class); System.out.println(" Received: seq=" + ((Map) ce.get("data")).get("seq")); } evType = null; data = null; } else if (line.startsWith("event:")) evType = line.substring(6).trim(); else if (line.startsWith("data:")) data = line.substring(5).trim(); } } ``` ```typescript import EventSource from 'eventsource'; // events_store_type=4 (StartAtSequence) + events_store_value const startSeq = 5; const url = `${base}/ce/subscribe/events-store?client_id=js-seq-sub&channel=${encodeURIComponent(channel)}&events_store_type=4&events_store_value=${startSeq}`; const es = new EventSource(url); es.addEventListener('cloudevent', (evt: MessageEvent & { lastEventId: string }) => { const ce = JSON.parse(evt.data) as Record; console.log(` [seq=${evt.lastEventId}] data=${JSON.stringify(ce.data)}`); }); es.addEventListener('error', (err) => { console.error('SSE error:', err); es.close(); }); ``` ```python import json import requests # events_store_type=4 (StartAtSequence) + events_store_value start_seq = 5 sse_url = (f"{base}/ce/subscribe/events-store" f"?client_id=python-seq-sub&channel={channel}" f"&events_store_type=4&events_store_value={start_seq}") with requests.get(sse_url, stream=True, timeout=None, headers={"Accept": "text/event-stream"}) as resp: ev_type = data = sse_id = "" for line in resp.iter_lines(decode_unicode=True): if line == "": if ev_type == "cloudevent" and data: ce = json.loads(data) print(f" [seq={sse_id}] data={ce.get('data')}") ev_type = data = sse_id = "" continue if line.startswith("id:"): sse_id = line[3:].strip() elif line.startswith("event:"): ev_type = line[6:].strip() elif line.startswith("data:"): data = line[5:].strip() ``` ```ruby require "net/http"; require "uri"; require "json" # events_store_type=4 (StartAtSequence), events_store_value=start_at_seq start_at_seq = 5 uri = URI("#{base}/ce/subscribe/events-store?client_id=ruby-es-seq&channel=#{URI.encode_www_form_component(channel)}&events_store_type=4&events_store_value=#{start_at_seq}") Net::HTTP.start(uri.host, uri.port) do |http| req = Net::HTTP::Get.new(uri); req["Accept"] = "text/event-stream" http.request(req) do |resp| ev_type = nil; data = nil resp.read_body do |chunk| chunk.each_line do |line| line.chomp! if line.empty? if ev_type == "cloudevent" && data ce = JSON.parse(data) ce["data"] = JSON.parse(ce["data"]) if ce["data"].is_a?(String) puts " Received: seq=#{ce.dig('data', 'seq')}" end ev_type = nil; data = nil elsif line.start_with?("event:") then ev_type = line.sub("event:", "").strip elsif line.start_with?("data:") then data = line.sub("data:", "").strip end end end end end ``` ```rust use futures_util::StreamExt; // events_store_type=4 (StartAtSequence) + events_store_value let start_at_seq: u32 = 5; let sub_url = format!( "{}/ce/subscribe/events-store?client_id=rust-es-seq&channel={}&events_store_type=4&events_store_value={}", base, channel, start_at_seq ); let stream = client.get(&sub_url) .header("Accept", "text/event-stream") .send().await?.bytes_stream(); let mut stream = Box::pin(stream); let mut ev_type = String::new(); let mut data = String::new(); let mut buffer = String::new(); while let Some(chunk) = stream.next().await { buffer.push_str(&String::from_utf8_lossy(&chunk?)); while let Some(pos) = buffer.find('\n') { let line = buffer[..pos].trim_end_matches('\r').to_string(); buffer = buffer[pos + 1..].to_string(); if line.is_empty() { if ev_type == "cloudevent" && !data.is_empty() { let ce: serde_json::Value = serde_json::from_str(&data)?; println!(" Received: seq={}", ce["data"]["seq"]); } ev_type.clear(); data.clear(); } else if let Some(v) = line.strip_prefix("event:") { ev_type = v.trim().to_string(); } else if let Some(v) = line.strip_prefix("data:") { data = v.trim().to_string(); } } } ``` ## Reconnect and resume with Last-Event-ID [#reconnect-and-resume-with-last-event-id] To resume after a dropped connection, capture the `id:` field of each frame as you process it, then reconnect with that value in the `Last-Event-ID` header. Omit `events_store_type` on the reconnect so the header takes effect — the server resumes from `sequence + 1`. ```bash # Resume after the last sequence you processed (e.g. 42). # Omit events_store_type so Last-Event-ID takes precedence. curl -N -H "Last-Event-ID: 42" \ "http://localhost:9090/ce/subscribe/events-store?client_id=my-client&channel=audit-log" ``` ```csharp // Omit events_store_type when reconnecting with Last-Event-ID. async Task<(List events, string lastId)> Subscribe( string clientId, string? lastEventId, int maxEvents) { var events = new List(); var lastId = ""; using var http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan }; var url = lastEventId == null ? $"{base_}/ce/subscribe/events-store?client_id={clientId}&channel={Uri.EscapeDataString(channel)}&events_store_type=2" : $"{base_}/ce/subscribe/events-store?client_id={clientId}&channel={Uri.EscapeDataString(channel)}"; using var req = new HttpRequestMessage(HttpMethod.Get, url); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); if (lastEventId != null) req.Headers.Add("Last-Event-ID", lastEventId); using var resp = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead); using var reader = new StreamReader(await resp.Content.ReadAsStreamAsync(), Encoding.UTF8); string? evType = null, data = null, id = null, line; while ((line = await reader.ReadLineAsync()) != null) { if (line == "") { if (evType == "cloudevent" && data != null) { if (id != null) lastId = id; events.Add(JsonSerializer.Deserialize(data)); if (events.Count >= maxEvents) break; } evType = null; data = null; id = null; } else if (line.StartsWith("id:")) id = line[3..].Trim(); else if (line.StartsWith("event:")) evType = line[6..].Trim(); else if (line.StartsWith("data:")) data = line[5..].Trim(); } return (events, lastId); } var (first, lastId) = await Subscribe("csharp-es-reconnect-1", null, 2); var (second, _) = await Subscribe("csharp-es-reconnect-2", lastId, 2); ``` ```go func openSSE(base, channel, clientID, lastEventID string) (*http.Response, error) { // When reconnecting, omit events_store_type so Last-Event-ID takes precedence. var sseURL string if lastEventID == "" { sseURL = fmt.Sprintf( "%s/ce/subscribe/events-store?client_id=%s&channel=%s&events_store_type=2", base, clientID, channel) } else { sseURL = fmt.Sprintf( "%s/ce/subscribe/events-store?client_id=%s&channel=%s", base, clientID, channel) } req, _ := http.NewRequest("GET", sseURL, nil) req.Header.Set("Accept", "text/event-stream") if lastEventID != "" { req.Header.Set("Last-Event-ID", lastEventID) } return (&http.Client{Timeout: 0}).Do(req) } // 1) First connection: read a batch and capture the last SSE id. // 2) Reconnect: openSSE(base, channel, clientID, lastID) resumes from lastID+1. ``` ```java // Omit events_store_type when reconnecting with Last-Event-ID. String sseUrl = base + "/ce/subscribe/events-store?client_id=" + clientId + "&channel=" + channel; if (lastEventId == null) { sseUrl += "&events_store_type=2"; // initial connection: start from first } HttpURLConnection conn = (HttpURLConnection) new URL(sseUrl).openConnection(); conn.setRequestProperty("Accept", "text/event-stream"); conn.setReadTimeout(10_000); if (lastEventId != null) { conn.setRequestProperty("Last-Event-ID", lastEventId); } try (BufferedReader reader = new BufferedReader( new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { String line; String evType = null, data = null, id = null, lastId = ""; while ((line = reader.readLine()) != null) { if (line.isEmpty()) { if ("cloudevent".equals(evType) && data != null) { if (id != null) lastId = id; // capture sequence for resume Map ce = MAPPER.readValue(data, Map.class); System.out.println(" seq=" + ((Map) ce.get("data")).get("seq")); } evType = null; data = null; id = null; } else if (line.startsWith("id:")) id = line.substring(3).trim(); else if (line.startsWith("event:")) evType = line.substring(6).trim(); else if (line.startsWith("data:")) data = line.substring(5).trim(); } } ``` ```typescript // EventSource sends Last-Event-ID automatically on reconnect, but custom-header // reconnects use fetch. Capture evt.lastEventId, then resume with the header. const headers: Record = { 'Last-Event-ID': lastEventId }; const url = `${base}/ce/subscribe/events-store?client_id=js-reconnect-sub&channel=${encodeURIComponent(channel)}`; const resp = await fetch(url, { headers }); const reader = resp.body!.getReader(); const decoder = new TextDecoder(); let buffer = '', evType = '', data = '', lastId = lastEventId; for (;;) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() ?? ''; for (const line of lines) { if (line === '') { if (evType === 'cloudevent' && data) { const ce = JSON.parse(data) as Record; console.log(` id=${lastId} data=${JSON.stringify(ce.data)}`); } evType = data = ''; } else if (line.startsWith('id:')) lastId = line.slice(3).trim(); else if (line.startsWith('event:')) evType = line.slice(6).trim(); else if (line.startsWith('data:')) data = line.slice(5).trim(); } } ``` ```python import json import requests def read_n_events(base, channel, client_id, n, last_event_id=""): """Open SSE, read n events, return the last SSE id seen.""" if last_event_id: # Omit events_store_type so Last-Event-ID takes precedence. sse_url = f"{base}/ce/subscribe/events-store?client_id={client_id}&channel={channel}" extra_headers = {"Last-Event-ID": last_event_id} else: sse_url = (f"{base}/ce/subscribe/events-store" f"?client_id={client_id}&channel={channel}&events_store_type=2") extra_headers = {} headers = {"Accept": "text/event-stream", **extra_headers} last_id = "" count = 0 with requests.get(sse_url, stream=True, timeout=None, headers=headers) as resp: ev_type = data = sse_id = "" for line in resp.iter_lines(decode_unicode=True): if line == "": if ev_type == "cloudevent" and data: ce = json.loads(data) count += 1 last_id = sse_id print(f" [{count}] id={sse_id} data={ce.get('data')}") if count == n: return last_id ev_type = data = sse_id = "" continue if line.startswith("id:"): sse_id = line[3:].strip() elif line.startswith("event:"): ev_type = line[6:].strip() elif line.startswith("data:"): data = line[5:].strip() return last_id # 1) First connection captures the last id; 2) reconnect resumes from it. last_id = read_n_events(base, channel, "python-reconnect-sub", 3) read_n_events(base, channel, "python-reconnect-sub", 3, last_id) ``` ```ruby require "net/http"; require "uri"; require "json" # Open one SSE connection, collect up to max_events, return [events, last_id]. def subscribe_es(base, channel, last_event_id, max_events) # Omit events_store_type when reconnecting with Last-Event-ID. query = last_event_id.nil? ? "&events_store_type=2" : "" uri = URI("#{base}/ce/subscribe/events-store?client_id=ruby-es-reconnect&channel=#{URI.encode_www_form_component(channel)}#{query}") events = []; last_id = nil Net::HTTP.start(uri.host, uri.port, read_timeout: 12) do |http| req = Net::HTTP::Get.new(uri); req["Accept"] = "text/event-stream" req["Last-Event-ID"] = last_event_id if last_event_id http.request(req) do |resp| ev_type = nil; data = nil; id = nil resp.read_body do |chunk| chunk.each_line do |line| line.chomp! if line.empty? if ev_type == "cloudevent" && data last_id = id if id ce = JSON.parse(data) ce["data"] = JSON.parse(ce["data"]) if ce["data"].is_a?(String) events << ce return [events, last_id] if events.size >= max_events end ev_type = nil; data = nil; id = nil elsif line.start_with?("id:") then id = line.sub("id:", "").strip elsif line.start_with?("event:") then ev_type = line.sub("event:", "").strip elsif line.start_with?("data:") then data = line.sub("data:", "").strip end end end end end [events, last_id] end first_events, last_id = subscribe_es(base, channel, nil, 2) second_events, _ = subscribe_es(base, channel, last_id, 2) ``` ```rust use futures_util::StreamExt; use reqwest::Client; use serde_json::Value; // Subscribe and collect up to `max` events. Returns (events, last_id_seen). async fn subscribe_and_collect( client: &Client, url: &str, last_event_id: Option<&str>, max: usize, ) -> (Vec, String) { let mut req = client.get(url).header("Accept", "text/event-stream"); if let Some(id) = last_event_id { req = req.header("Last-Event-ID", id); } let stream = req.send().await.expect("SSE connect").bytes_stream(); let mut stream = Box::pin(stream); let mut ev_type = String::new(); let mut data = String::new(); let mut id_field = String::new(); let mut last_id = String::new(); let mut buffer = String::new(); let mut events = Vec::new(); while let Some(chunk) = stream.next().await { buffer.push_str(&String::from_utf8_lossy(&chunk.unwrap_or_default())); while let Some(pos) = buffer.find('\n') { let line = buffer[..pos].trim_end_matches('\r').to_string(); buffer = buffer[pos + 1..].to_string(); if line.is_empty() { if ev_type == "cloudevent" && !data.is_empty() { if !id_field.is_empty() { last_id = id_field.clone(); } events.push(serde_json::from_str(&data).unwrap_or(Value::Null)); if events.len() >= max { return (events, last_id); } } ev_type.clear(); data.clear(); id_field.clear(); } else if let Some(v) = line.strip_prefix("id:") { id_field = v.trim().to_string(); } else if let Some(v) = line.strip_prefix("event:") { ev_type = v.trim().to_string(); } else if let Some(v) = line.strip_prefix("data:") { data = v.trim().to_string(); } } } (events, last_id) } // First connection (events_store_type=2) captures last_id; the reconnect URL // omits events_store_type and passes Some(&last_id) to resume from sequence + 1. ``` ## Response and status codes [#response-and-status-codes] | Status | When | | --------------------------- | ----------------------------------------------------------------------------- | | `202 Accepted` | `POST /ce/send/event-store` succeeded; the send result is in `data` | | `200 OK` (stream) | `GET /ce/subscribe/events-store` opened; frames follow as `text/event-stream` | | `400 Bad Request` | Invalid CloudEvent, missing channel, or a reserved channel name | | `429 Too Many Requests` | SSE connection limit reached (`MaxSSEConnections` exceeded) | | `500 Internal Server Error` | Backend messaging error or SSE setup failure | A successful send returns the standard envelope: ```json { "is_error": false, "message": "OK", "data": { ... } } ``` Each delivered CloudEvent frame carries the sequence number in its `id:` field: ```text id: 42 event: cloudevent data: {"specversion":"1.0","type":"com.example.audit.entry","source":"audit-service","id":"...","subject":"audit-log","time":"...","data":{"action":"user.login"}} ``` ## Related [#related] # Events (/connectors/cloudevents/how-to/events) Events are **fire-and-forget** pub/sub messages over the CloudEvents connector. A publisher sends a CloudEvent with `POST /ce/send/event` and returns immediately; every subscriber connected to the channel receives every event over an SSE stream. ## Overview [#overview] The Events pattern maps the KubeMQ **Events** messaging primitive onto plain HTTP. Publishers do not wait for delivery confirmation, and events are **not persisted** — a subscriber must be connected at the moment of delivery to receive a message. Connect every subscriber to a channel and you get **fan-out** (each one receives every event); add a shared `group` and you get a **consumer group** where the server load-balances each event to exactly one member. Use this pattern for real-time notifications, telemetry, and broadcast scenarios where missed messages are acceptable. When you need durability and replay, use [Events Store](/connectors/cloudevents/how-to/events-store) instead. | Operation | Endpoint | Method | | --------- | ---------------------- | --------- | | Publish | `/ce/send/event` | POST | | Subscribe | `/ce/subscribe/events` | GET (SSE) | ## How it works [#how-it-works] A publisher posts one CloudEvent to the connector, which fans it out to every connected subscriber on the channel; subscribers in a shared group split the load round-robin. *A published CloudEvent fans out to all subscribers; a shared `group` load-balances it to one member.* ## Publishing [#publishing] Send a CloudEvent in **structured mode** (`application/cloudevents+json`). The `subject` attribute selects the KubeMQ channel; the connector replies with HTTP `202 Accepted` and a `{"is_error": false, ...}` body. Each native example below opens an SSE subscriber, publishes one event, prints it, and exits. ```bash curl -X POST http://localhost:9090/ce/send/event \ -H "Content-Type: application/cloudevents+json" \ -d '{ "specversion": "1.0", "type": "com.example.order.created", "source": "order-service", "subject": "orders", "datacontenttype": "application/json", "data": {"order_id": "12345", "amount": 99.99} }' ``` ```csharp // Example: events/BasicPubSub // Run: dotnet run using CloudNative.CloudEvents; using CloudNative.CloudEvents.SystemTextJson; using System.Net.Http.Headers; using System.Text; using System.Text.Json; static string ServerUrl() => Environment.GetEnvironmentVariable("KUBEMQ_CE_URL") ?? "http://localhost:9090"; var base_ = ServerUrl(); var channel = "csharp-ce-events.basic-pubsub"; var clientId = "kubemq-ce-csharp-example"; var received = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); // Start SSE subscriber. var subscriberTask = Task.Run(async () => { using var httpClient = new HttpClient { Timeout = Timeout.InfiniteTimeSpan }; var sseUrl = $"{base_}/ce/subscribe/events?client_id={clientId}-sub&channel={Uri.EscapeDataString(channel)}"; using var request = new HttpRequestMessage(HttpMethod.Get, sseUrl); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); request.Headers.CacheControl = new CacheControlHeaderValue { NoCache = true }; using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); using var stream = await response.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream, Encoding.UTF8); string? eventType = null, data = null; string? line; while ((line = await reader.ReadLineAsync()) != null) { if (line == "") { if (eventType == "cloudevent" && data != null) { received.TrySetResult(JsonSerializer.Deserialize(data)); return; } eventType = null; data = null; } else if (line.StartsWith(":")) { /* keepalive */ } else if (line.StartsWith("event:")) eventType = line["event:".Length..].Trim(); else if (line.StartsWith("data:")) data = line["data:".Length..].Trim(); } }); // Allow subscription to establish. await Task.Delay(500); // Build and publish CloudEvent (structured mode). var formatter = new JsonEventFormatter(); var cloudEvent = new CloudEvent { Id = Guid.NewGuid().ToString(), Type = "com.kubemq.examples.events.sent", Source = new Uri($"urn:{clientId}"), Subject = channel, DataContentType = "application/json", Data = new { message = "Hello from C# CloudEvents example!" }, }; cloudEvent.SetAttributeFromString("time", DateTimeOffset.UtcNow.ToString("O")); var eventBytes = formatter.EncodeStructuredModeMessage(cloudEvent, out var contentType); using var httpClient = new HttpClient(); using var content = new ByteArrayContent(eventBytes.ToArray()); content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType.ToString()); var resp = await httpClient.PostAsync($"{base_}/ce/send/event", content); var resultJson = await resp.Content.ReadAsStringAsync(); using var resultDoc = JsonDocument.Parse(resultJson); Console.WriteLine($"Published: status={resp.StatusCode} is_error={resultDoc.RootElement.GetProperty("is_error")}"); // Wait for the event. var ce = await received.Task; Console.WriteLine($"Received: {ce.GetProperty("type")} / {ce.GetProperty("data")}"); ``` ```go // Example: events/basic-pubsub // Run: go run ./events/basic-pubsub/main.go package main import ( "bufio" "context" "encoding/json" "fmt" "log" "net/http" "os" "strings" "time" cloudevents "github.com/cloudevents/sdk-go/v2" ) func serverURL() string { if u := os.Getenv("KUBEMQ_CE_URL"); u != "" { return u } return "http://localhost:9090" } func main() { base := serverURL() channel := "go-ce-events.basic-pubsub" clientID := "kubemq-ce-go-example" received := make(chan string, 1) // Start SSE subscriber in background goroutine. go func() { sseURL := fmt.Sprintf("%s/ce/subscribe/events?client_id=%s&channel=%s", base, clientID+"-sub", channel) req, _ := http.NewRequest("GET", sseURL, nil) req.Header.Set("Accept", "text/event-stream") req.Header.Set("Cache-Control", "no-cache") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatal("SSE connect:", err) } defer resp.Body.Close() scanner := bufio.NewScanner(resp.Body) var eventType, data string for scanner.Scan() { line := scanner.Text() if line == "" { if eventType == "cloudevent" && data != "" { received <- data return } eventType, data = "", "" continue } if strings.HasPrefix(line, ":") { continue // keepalive } if strings.HasPrefix(line, "event:") { eventType = strings.TrimSpace(strings.TrimPrefix(line, "event:")) } else if strings.HasPrefix(line, "data:") { data = strings.TrimSpace(strings.TrimPrefix(line, "data:")) } } }() // Allow SSE subscription to establish. time.Sleep(500 * time.Millisecond) // Build and send CloudEvent (structured mode). event := cloudevents.NewEvent() event.SetType("com.kubemq.examples.events.sent") event.SetSource("kubemq-ce-go-example") event.SetSubject(channel) // subject = KubeMQ channel _ = event.SetData(cloudevents.ApplicationJSON, map[string]string{ "message": "Hello from Go CloudEvents example!", }) body, _ := json.Marshal(event) req, _ := http.NewRequest("POST", base+"/ce/send/event", strings.NewReader(string(body))) req.Header.Set("Content-Type", "application/cloudevents+json") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatal("send event:", err) } defer resp.Body.Close() var result map[string]interface{} _ = json.NewDecoder(resp.Body).Decode(&result) fmt.Printf("Published: status=%d is_error=%v\n", resp.StatusCode, result["is_error"]) // Wait for subscriber to receive the event. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() select { case data := <-received: var ce map[string]interface{} _ = json.Unmarshal([]byte(data), &ce) fmt.Printf("Received: type=%v subject=%v data=%v\n", ce["type"], ce["subject"], ce["data"]) case <-ctx.Done(): log.Fatal("Timed out waiting for event") } } ``` ```java // Example: events/basic-pubsub // Run: mvn compile exec:java package io.kubemq.examples.events.basicpubsub; import com.fasterxml.jackson.databind.ObjectMapper; import io.cloudevents.CloudEvent; import io.cloudevents.core.builder.CloudEventBuilder; import io.cloudevents.core.format.EventFormat; import io.cloudevents.core.provider.EventFormatProvider; import io.cloudevents.jackson.JsonFormat; import java.io.BufferedReader; import java.io.InputStreamReader; import java.net.HttpURLConnection; import java.net.URI; import java.net.URL; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.OffsetDateTime; import java.util.Map; import java.util.UUID; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; public class Main { static String serverUrl() { String u = System.getenv("KUBEMQ_CE_URL"); return (u != null && !u.isEmpty()) ? u : "http://localhost:9090"; } static final ObjectMapper MAPPER = new ObjectMapper(); public static void main(String[] args) throws Exception { String base = serverUrl(); String channel = "java-ce-events.basic-pubsub"; String clientId = "kubemq-ce-java-example"; BlockingQueue received = new ArrayBlockingQueue<>(1); // Start SSE subscriber in background thread. String sseUrl = base + "/ce/subscribe/events?client_id=" + clientId + "-sub&channel=" + channel; Thread subscriber = Thread.ofVirtual().start(() -> { try { HttpURLConnection conn = (HttpURLConnection) new URL(sseUrl).openConnection(); conn.setRequestMethod("GET"); conn.setRequestProperty("Accept", "text/event-stream"); conn.setRequestProperty("Cache-Control", "no-cache"); conn.setDoInput(true); conn.setReadTimeout(15_000); try (BufferedReader reader = new BufferedReader( new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { String line; String eventType = null, data = null; while ((line = reader.readLine()) != null) { if (line.isEmpty()) { if ("cloudevent".equals(eventType) && data != null) { received.offer(data); return; } eventType = null; data = null; } else if (line.startsWith(":")) { // keepalive } else if (line.startsWith("event:")) { eventType = line.substring("event:".length()).trim(); } else if (line.startsWith("data:")) { data = line.substring("data:".length()).trim(); } } } } catch (Exception e) { System.err.println("SSE error: " + e.getMessage()); } }); // Allow subscription to establish. Thread.sleep(500); // Build CloudEvent (structured mode). EventFormatProvider.getInstance().registerFormat(new JsonFormat()); EventFormat format = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE); CloudEvent event = CloudEventBuilder.v1() .withId(UUID.randomUUID().toString()) .withType("com.kubemq.examples.events.sent") .withSource(URI.create(clientId)) .withSubject(channel) .withDataContentType("application/json") .withTime(OffsetDateTime.now()) .withData("application/json", MAPPER.writeValueAsBytes(Map.of("message", "Hello from Java CloudEvents example!"))) .build(); byte[] body = format.serialize(event); HttpClient httpClient = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(base + "/ce/send/event")) .POST(HttpRequest.BodyPublishers.ofByteArray(body)) .header("Content-Type", "application/cloudevents+json") .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); Map result = MAPPER.readValue(response.body(), Map.class); System.out.printf("Published: status=%d is_error=%s%n", response.statusCode(), result.get("is_error")); // Wait for event. String data = received.poll(10, TimeUnit.SECONDS); Map ce = MAPPER.readValue(data, Map.class); System.out.println("Received: " + ce.get("type") + " / " + ce.get("data")); subscriber.interrupt(); } } ``` ```typescript // Example: events/basic-pubsub // Run: npx tsx events/basic-pubsub/index.ts import { CloudEvent, HTTP } from 'cloudevents'; import EventSource from 'eventsource'; function serverUrl(): string { return process.env.KUBEMQ_CE_URL ?? 'http://localhost:9090'; } async function waitForEvent( base: string, channel: string, clientId: string, ): Promise> { return new Promise((resolve, reject) => { const sseUrl = `${base}/ce/subscribe/events?client_id=${clientId}&channel=${encodeURIComponent(channel)}`; const es = new EventSource(sseUrl); const timer = setTimeout(() => { es.close(); reject(new Error('Timed out waiting for event')); }, 10_000); es.addEventListener('cloudevent', (evt: MessageEvent) => { clearTimeout(timer); es.close(); resolve(JSON.parse(evt.data) as Record); }); }); } async function main(): Promise { const base = serverUrl(); const channel = 'js-ce-events.basic-pubsub'; const clientId = 'kubemq-ce-js-example'; // Start waiting for event (opens SSE stream). const eventPromise = waitForEvent(base, channel, `${clientId}-sub`); // Allow SSE connection to establish. await new Promise((r) => setTimeout(r, 500)); // Build and publish CloudEvent (structured mode). const event = new CloudEvent({ type: 'com.kubemq.examples.events.sent', source: clientId, subject: channel, datacontenttype: 'application/json', data: { message: 'Hello from JavaScript/TypeScript CloudEvents example!' }, }); const message = HTTP.structured(event); const resp = await fetch(`${base}/ce/send/event`, { method: 'POST', headers: message.headers as Record, body: message.body as string, }); const result = await resp.json() as { is_error: boolean }; console.log(`Published: status=${resp.status} is_error=${result.is_error}`); // Wait for subscriber. const received = await eventPromise; console.log(`Received: ${received.type} / ${JSON.stringify(received.data)}`); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```python # Example: events/basic_pubsub # Run: python events/basic_pubsub/main.py from __future__ import annotations import json import os import threading import time import requests from cloudevents.v1.conversion import to_structured from cloudevents.v1.http import CloudEvent def server_url() -> str: return os.environ.get("KUBEMQ_CE_URL", "http://localhost:9090") def subscribe(base: str, channel: str, client_id: str, received: list[str]) -> None: """Open SSE stream and collect one cloudevent.""" sse_url = ( f"{base}/ce/subscribe/events" f"?client_id={client_id}&channel={channel}" ) with requests.get(sse_url, stream=True, timeout=None, headers={"Accept": "text/event-stream", "Cache-Control": "no-cache"}) as resp: event_type = "" data = "" for line in resp.iter_lines(decode_unicode=True): if line == "": if event_type == "cloudevent" and data: received.append(data) return event_type = "" data = "" continue if line.startswith(":"): continue # keepalive if line.startswith("event:"): event_type = line[len("event:"):].strip() elif line.startswith("data:"): data = line[len("data:"):].strip() def main() -> None: base = server_url() channel = "python-ce-events.basic-pubsub" client_id = "kubemq-ce-python-example" received: list[str] = [] # Start subscriber in background thread. t = threading.Thread( target=subscribe, args=(base, channel, client_id + "-sub", received), daemon=True, ) t.start() # Allow SSE connection to establish. time.sleep(0.5) # Build and send CloudEvent (structured mode). event = CloudEvent( attributes={ "type": "com.kubemq.examples.events.sent", "source": client_id, "subject": channel, "datacontenttype": "application/json", }, data={"message": "Hello from Python CloudEvents example!"}, ) headers, body = to_structured(event) resp = requests.post( f"{base}/ce/send/event", data=body, headers=dict(headers), timeout=10, ) result = resp.json() print(f"Published: status={resp.status_code} is_error={result.get('is_error')}") # Wait for subscriber. deadline = time.time() + 10 while not received and time.time() < deadline: time.sleep(0.1) ce = json.loads(received[0]) print(f"Received: {ce.get('type')} / {ce.get('data')}") if __name__ == "__main__": main() ``` ```ruby # Example: events/basic_pubsub # Run: ruby events/basic_pubsub/main.rb require "net/http" require "uri" require "json" require "timeout" require "securerandom" require "cloud_events" def server_url ENV.fetch("KUBEMQ_CE_URL", "http://localhost:9090") end base = server_url channel = "ruby-ce-events.basic-pubsub" client_id = "kubemq-ce-ruby-example" received = Queue.new # SSE subscriber thread. subscriber = Thread.new do uri = URI("#{base}/ce/subscribe/events?client_id=#{client_id}-sub&channel=#{URI.encode_www_form_component(channel)}") Net::HTTP.start(uri.host, uri.port) do |http| req = Net::HTTP::Get.new(uri) req["Accept"] = "text/event-stream" req["Cache-Control"] = "no-cache" http.request(req) do |resp| ev_type = nil data = nil resp.read_body do |chunk| chunk.each_line do |line| line.chomp! if line.empty? if ev_type == "cloudevent" && data received.push(data) Thread.exit end ev_type = nil data = nil elsif line.start_with?(":") # keepalive elsif line.start_with?("event:") ev_type = line.sub("event:", "").strip elsif line.start_with?("data:") data = line.sub("data:", "").strip end end end end end end # Allow subscription to establish. sleep 0.5 # Build and publish CloudEvent (structured mode). sdk = CloudEvents::HttpBinding.default event = CloudEvents::Event::V1.new( id: SecureRandom.uuid, type: "com.kubemq.examples.events.sent", source: URI("urn:#{client_id}"), subject: channel, spec_version: "1.0", data_content_type: CloudEvents::ContentType.new("application/json"), data: JSON.generate({ message: "Hello from Ruby CloudEvents example!" }) ) # Encode as structured mode. headers, body = sdk.encode_event(event, structured_format: "json") uri = URI("#{base}/ce/send/event") Net::HTTP.start(uri.host, uri.port) do |http| req = Net::HTTP::Post.new(uri) req["Content-Type"] = headers["Content-Type"] req.body = body res = http.request(req) result = JSON.parse(res.body) puts "Published: status=#{res.code} is_error=#{result['is_error']}" end # Wait for subscriber. data = nil Timeout.timeout(10) { data = received.pop } ce = JSON.parse(data) puts "Received: #{ce['type']} / #{ce['data']}" ``` ```rust //! Example: events/basic-pubsub //! Run: cargo run -p basic-pubsub use bytes::Bytes; use cloudevents::{EventBuilder, EventBuilderV10}; use futures_util::StreamExt; use reqwest::Client; use serde_json::{json, Value}; use std::env; use tokio::sync::oneshot; use uuid::Uuid; fn server_url() -> String { env::var("KUBEMQ_CE_URL").unwrap_or_else(|_| "http://localhost:9090".to_string()) } /// Parse SSE lines and return the data of the first cloudevent. async fn wait_for_cloudevent( mut stream: impl futures_util::Stream> + Unpin, tx: oneshot::Sender, ) { let mut event_type = String::new(); let mut data = String::new(); let mut buffer = String::new(); while let Some(chunk) = stream.next().await { let chunk = match chunk { Ok(c) => c, Err(e) => { eprintln!("SSE read error: {}", e); break; } }; buffer.push_str(&String::from_utf8_lossy(&chunk)); while let Some(pos) = buffer.find('\n') { let line = buffer[..pos].trim_end_matches('\r').to_string(); buffer = buffer[pos + 1..].to_string(); if line.is_empty() { if event_type == "cloudevent" && !data.is_empty() { let _ = tx.send(data.clone()); return; } event_type.clear(); data.clear(); } else if line.starts_with(':') { // keepalive comment — ignore } else if let Some(v) = line.strip_prefix("event:") { event_type = v.trim().to_string(); } else if let Some(v) = line.strip_prefix("data:") { data = v.trim().to_string(); } } } } #[tokio::main] async fn main() -> Result<(), Box> { let base = server_url(); let channel = "rust-ce-events.basic-pubsub"; let client_id = "kubemq-ce-rust-example"; let client = Client::new(); // Start SSE subscriber. let (tx, rx) = oneshot::channel::(); let sub_url = format!( "{}/ce/subscribe/events?client_id={}-sub&channel={}", base, client_id, channel ); let sub_client = client.clone(); tokio::spawn(async move { let stream = sub_client .get(&sub_url) .header("Accept", "text/event-stream") .header("Cache-Control", "no-cache") .send() .await .expect("SSE connect failed") .bytes_stream(); wait_for_cloudevent(stream, tx).await; }); // Allow SSE to establish. tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; // Build CloudEvent (structured mode using cloudevents-sdk). let event = EventBuilderV10::new() .id(Uuid::new_v4().to_string()) .ty("com.kubemq.examples.events.sent") .source(format!("urn:{}", client_id)) .subject(channel) .data( "application/json", json!({"message": "Hello from Rust CloudEvents example!"}), ) .build()?; // Serialize to structured mode JSON. let body = serde_json::to_string(&event)?; let resp = client .post(format!("{}/ce/send/event", base)) .header("Content-Type", "application/cloudevents+json") .body(body) .send() .await?; let result: Value = resp.json().await?; println!("Published: status=202 is_error={}", result["is_error"]); // Wait for received event. let data = tokio::time::timeout(tokio::time::Duration::from_secs(10), rx) .await .expect("Timed out waiting for event") .expect("Channel closed"); let ce: Value = serde_json::from_str(&data)?; println!("Received: {} / {}", ce["type"], ce["data"]); Ok(()) } ``` The publish call returns `HTTP 202 Accepted`: ```json { "is_error": false, "message": "OK", "data": {} } ``` ## Subscribing [#subscribing] Open a long-lived SSE stream with `GET /ce/subscribe/events`. The connector streams `event: cloudevent` frames as messages arrive; each `data:` line holds the reconstructed CloudEvent JSON. ```bash curl -N "http://localhost:9090/ce/subscribe/events?client_id=my-client&channel=orders" ``` A delivered frame looks like: ```text event: cloudevent data: {"specversion":"1.0","type":"com.example.order.created","source":"order-service","subject":"orders","data":{"order_id":"12345","amount":99.99}} ``` Subscription accepts these query parameters: | Parameter | Type | Required | Description | | ----------- | ------ | -------- | ------------------------------------------------------------------ | | `client_id` | string | Yes | Client identifier (overridden by auth claims when auth is enabled) | | `channel` | string | Yes | Channel to subscribe to | | `group` | string | No | Load-balancing group name | ## Consumer groups [#consumer-groups] Without a `group`, every subscriber on a channel receives every event (fan-out). Add the **same** `group` to multiple subscribers and they form a load-balancing pool — each event is delivered to exactly **one** member, round-robin. ```bash # Terminal 1 — worker 1 in group "workers" curl -N "http://localhost:9090/ce/subscribe/events?client_id=w1&channel=orders&group=workers" # Terminal 2 — worker 2 in the same group curl -N "http://localhost:9090/ce/subscribe/events?client_id=w2&channel=orders&group=workers" ``` ```go // Example: events/consumer-group — each subscriber gets one of two events. func subscribe(base, clientID, channel, group string, results chan<- string, wg *sync.WaitGroup) { defer wg.Done() sseURL := fmt.Sprintf("%s/ce/subscribe/events?client_id=%s&channel=%s&group=%s", base, clientID, channel, group) req, _ := http.NewRequest("GET", sseURL, nil) req.Header.Set("Accept", "text/event-stream") req.Header.Set("Cache-Control", "no-cache") client := &http.Client{Timeout: 0} // no timeout for SSE resp, err := client.Do(req) if err != nil { log.Printf("[%s] SSE connect error: %v", clientID, err) return } defer resp.Body.Close() scanner := bufio.NewScanner(resp.Body) var eventType, data string for scanner.Scan() { line := scanner.Text() if line == "" { if eventType == "cloudevent" && data != "" { results <- fmt.Sprintf("[%s] received: %s", clientID, data) return } eventType, data = "", "" continue } if strings.HasPrefix(line, ":") { continue } if strings.HasPrefix(line, "event:") { eventType = strings.TrimSpace(strings.TrimPrefix(line, "event:")) } else if strings.HasPrefix(line, "data:") { data = strings.TrimSpace(strings.TrimPrefix(line, "data:")) } } } func main() { base := serverURL() channel := "go-ce-events.consumer-group" group := "workers" results := make(chan string, 2) var wg sync.WaitGroup // Two subscribers in the same group. for i := 1; i <= 2; i++ { wg.Add(1) go subscribe(base, fmt.Sprintf("worker-%d", i), channel, group, results, &wg) } time.Sleep(600 * time.Millisecond) // Publish two events — each subscriber should get exactly one. sendEvent(base, channel, 1) sendEvent(base, channel, 2) deadline := time.After(10 * time.Second) for received := 0; received < 2; { select { case msg := <-results: fmt.Println(msg) received++ case <-deadline: log.Fatal("Timed out waiting for events") } } } ``` ```python # Example: events/consumer_group — load-balanced subscriber groups. import threading import time import requests from cloudevents.v1.conversion import to_structured from cloudevents.v1.http import CloudEvent def subscribe_group(base, channel, group, client_id, results, stop): sse_url = (f"{base}/ce/subscribe/events" f"?client_id={client_id}&channel={channel}&group={group}") with requests.get(sse_url, stream=True, timeout=None, headers={"Accept": "text/event-stream"}) as resp: ev_type = "" data = "" for line in resp.iter_lines(decode_unicode=True): if stop.is_set(): return if line == "": if ev_type == "cloudevent" and data: results.append(f"[{client_id}] received: {data[:80]}") return ev_type = data = "" continue if line.startswith(":"): continue if line.startswith("event:"): ev_type = line[6:].strip() elif line.startswith("data:"): data = line[5:].strip() def main(): base = "http://localhost:9090" channel = "python-ce-events.consumer-group" group = "workers" results, stop = [], threading.Event() for i in range(1, 3): threading.Thread( target=subscribe_group, args=(base, channel, group, f"worker-{i}", results, stop), daemon=True, ).start() time.sleep(0.6) for seq in range(1, 3): event = CloudEvent( attributes={ "type": "com.kubemq.examples.events.grouped", "source": "kubemq-ce-python-example", "subject": channel, "datacontenttype": "application/json", }, data={"seq": seq}, ) headers, body = to_structured(event) resp = requests.post(f"{base}/ce/send/event", data=body, headers=dict(headers), timeout=10) print(f"Published event seq={seq} (status={resp.status_code})") deadline = time.time() + 10 while len(results) < 2 and time.time() < deadline: time.sleep(0.1) stop.set() for r in results: print(r) if __name__ == "__main__": main() ``` Consumer groups are server-side and ephemeral — they exist only while subscribers are connected. Because events are not persisted, a group that has no connected members when an event is published will not receive it. For durable, replayable delivery, use [Events Store](/connectors/cloudevents/how-to/events-store). ## Related [#related] # Queues (/connectors/cloudevents/how-to/queues) The CloudEvents connector exposes KubeMQ's durable **queue** pattern over plain HTTP: producers `POST` a CloudEvent to a queue, and one consumer at a time pulls messages off it with at-least-once delivery. ## Overview [#overview] A queue is a **durable, FIFO work channel**. Unlike events (fire-and-forget pub/sub), a queue message is stored until a consumer receives it, and is delivered to exactly one consumer in the group — making queues the right fit for distributing work across a pool of workers. The connector maps three control operations onto HTTP: | Operation | Endpoint | Body | Purpose | | --------- | ------------------------ | ----------------------------------------- | ------------------------------------- | | Send | `POST /ce/queue/send` | CloudEvent (structured or binary) | Enqueue one message | | Receive | `POST /ce/queue/receive` | *(ignored)* — all params via query string | Pull (and consume) messages | | Ack all | `POST /ce/queue/ack_all` | *(ignored)* — all params via query string | Drain all pending messages atomically | `receive` and `ack_all` are **control operations**: the request body is ignored and every parameter is supplied as a query string. To inspect messages without consuming them, set `is_peek=true` on a `receive` call (peek). ## How it works [#how-it-works] A producer enqueues CloudEvents; a worker polls the queue and the connector hands each stored message to exactly one consumer. *Messages are durably stored on the queue channel and delivered to one worker at a time.* ## Send and receive [#send-and-receive] Send a CloudEvent to a queue with `POST /ce/queue/send` (returns HTTP 202), then pull it back with `POST /ce/queue/receive`. The receive call is a control operation — pass `channel`, `client_id`, `max_messages`, and `wait_timeout` as query parameters. ```bash # Send a message to the queue (structured CloudEvent) curl -X POST http://localhost:9090/ce/queue/send \ -H "Content-Type: application/cloudevents+json" \ -d '{ "specversion": "1.0", "type": "com.example.job.submit", "source": "job-scheduler", "subject": "work-queue", "data": {"job_type": "report", "params": {"month": "2026-03"}} }' # Receive (consume) up to 10 messages, waiting up to 10s curl -X POST "http://localhost:9090/ce/queue/receive?channel=work-queue&client_id=worker-1&max_messages=10&wait_timeout=10" ``` ```csharp using CloudNative.CloudEvents; using CloudNative.CloudEvents.SystemTextJson; using System.Net.Http.Headers; using System.Text.Json; static string ServerUrl() => Environment.GetEnvironmentVariable("KUBEMQ_CE_URL") ?? "http://localhost:9090"; var base_ = ServerUrl(); var channel = "csharp-ce-queues.basic"; var clientId = "kubemq-ce-csharp-worker"; var formatter = new JsonEventFormatter(); using var httpClient = new HttpClient(); Console.WriteLine($"Sending 3 messages to queue '{channel}':"); for (int i = 1; i <= 3; i++) { var ev = new CloudEvent { Id = Guid.NewGuid().ToString(), Type = "com.kubemq.examples.queues.task", Source = new Uri("urn:kubemq-ce-csharp-example"), Subject = channel, DataContentType = "application/json", Data = new { task_id = i, task = "process-item" }, }; var bytes = formatter.EncodeStructuredModeMessage(ev, out var ct); using var c = new ByteArrayContent(bytes.ToArray()); c.Headers.ContentType = MediaTypeHeaderValue.Parse(ct.ToString()); var r = await httpClient.PostAsync($"{base_}/ce/queue/send", c); var j = JsonSerializer.Deserialize(await r.Content.ReadAsStringAsync()); Console.WriteLine($" Sent task {i}: is_error={j.GetProperty("is_error")}"); } Console.WriteLine("\nReceiving 3 messages:"); for (int i = 0; i < 3; i++) { var url = $"{base_}/ce/queue/receive?channel={Uri.EscapeDataString(channel)}&client_id={clientId}&max_messages=1&wait_timeout=5"; var r = await httpClient.PostAsync(url, null); var j = JsonSerializer.Deserialize(await r.Content.ReadAsStringAsync()); if (j.GetProperty("is_error").GetBoolean()) { Console.WriteLine($" Error: {j.GetProperty("message")}"); continue; } if (j.TryGetProperty("data", out var data) && data.TryGetProperty("messages", out var msgs)) { foreach (var m in msgs.EnumerateArray()) Console.WriteLine($" Received: type={m.GetProperty("type")} data={m.GetProperty("data")}"); } } ``` ```go package main import ( "encoding/json" "fmt" "log" "net/http" "os" "strings" cloudevents "github.com/cloudevents/sdk-go/v2" ) func serverURL() string { if u := os.Getenv("KUBEMQ_CE_URL"); u != "" { return u } return "http://localhost:9090" } type CEResponse struct { IsError bool `json:"is_error"` Message string `json:"message"` Data json.RawMessage `json:"data"` } type QueueReceiveData struct { MessagesReceived int `json:"messages_received"` Messages []map[string]interface{} `json:"messages"` } func sendToQueue(base, channel string, i int) { event := cloudevents.NewEvent() event.SetType("com.kubemq.examples.queues.task") event.SetSource("kubemq-ce-go-example") event.SetSubject(channel) _ = event.SetData(cloudevents.ApplicationJSON, map[string]interface{}{ "task_id": i, "task": "process-item", "priority": "normal", }) body, _ := json.Marshal(event) req, _ := http.NewRequest("POST", base+"/ce/queue/send", strings.NewReader(string(body))) req.Header.Set("Content-Type", "application/cloudevents+json") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatal("queue send:", err) } defer resp.Body.Close() var result CEResponse _ = json.NewDecoder(resp.Body).Decode(&result) fmt.Printf("Sent task %d: status=%d is_error=%v\n", i, resp.StatusCode, result.IsError) } func receiveFromQueue(base, channel, clientID string) { url := fmt.Sprintf("%s/ce/queue/receive?channel=%s&client_id=%s&max_messages=1&wait_timeout=5", base, channel, clientID) req, _ := http.NewRequest("POST", url, nil) resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatal("queue receive:", err) } defer resp.Body.Close() var result CEResponse _ = json.NewDecoder(resp.Body).Decode(&result) var data QueueReceiveData _ = json.Unmarshal(result.Data, &data) for _, msg := range data.Messages { fmt.Printf("Received: type=%v data=%v\n", msg["type"], msg["data"]) } } func main() { base := serverURL() channel := "go-ce-queues.basic" clientID := "kubemq-ce-go-worker" for i := 1; i <= 3; i++ { sendToQueue(base, channel, i) } for i := 0; i < 3; i++ { receiveFromQueue(base, channel, clientID) } } ``` ```java import com.fasterxml.jackson.databind.ObjectMapper; import io.cloudevents.CloudEvent; import io.cloudevents.core.builder.CloudEventBuilder; import io.cloudevents.core.format.EventFormat; import io.cloudevents.core.provider.EventFormatProvider; import io.cloudevents.jackson.JsonFormat; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.OffsetDateTime; import java.util.List; import java.util.Map; import java.util.UUID; public class Main { static String serverUrl() { String u = System.getenv("KUBEMQ_CE_URL"); return (u != null && !u.isEmpty()) ? u : "http://localhost:9090"; } static final ObjectMapper MAPPER = new ObjectMapper(); public static void main(String[] args) throws Exception { String base = serverUrl(); String channel = "java-ce-queues.basic"; String clientId = "kubemq-ce-java-worker"; EventFormatProvider.getInstance().registerFormat(new JsonFormat()); EventFormat format = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE); HttpClient httpClient = HttpClient.newHttpClient(); for (int i = 1; i <= 3; i++) { CloudEvent event = CloudEventBuilder.v1() .withId(UUID.randomUUID().toString()) .withType("com.kubemq.examples.queues.task") .withSource(URI.create("kubemq-ce-java-example")) .withSubject(channel) .withDataContentType("application/json") .withTime(OffsetDateTime.now()) .withData("application/json", MAPPER.writeValueAsBytes(Map.of("task_id", i, "task", "process-item"))) .build(); httpClient.send( HttpRequest.newBuilder().uri(URI.create(base + "/ce/queue/send")) .POST(HttpRequest.BodyPublishers.ofByteArray(format.serialize(event))) .header("Content-Type", "application/cloudevents+json").build(), HttpResponse.BodyHandlers.ofString()); } for (int i = 0; i < 3; i++) { String url = base + "/ce/queue/receive?channel=" + channel + "&client_id=" + clientId + "&max_messages=1&wait_timeout=5"; HttpResponse resp = httpClient.send( HttpRequest.newBuilder().uri(URI.create(url)) .POST(HttpRequest.BodyPublishers.noBody()).build(), HttpResponse.BodyHandlers.ofString()); Map r = MAPPER.readValue(resp.body(), Map.class); Map data = (Map) r.get("data"); List msgs = data != null ? (List) data.get("messages") : List.of(); for (Object m : msgs) { Map msg = (Map) m; System.out.println(" Received: type=" + msg.get("type") + " data=" + msg.get("data")); } } } } ``` ```javascript import { CloudEvent, HTTP } from 'cloudevents'; function serverUrl() { return process.env.KUBEMQ_CE_URL ?? 'http://localhost:9090'; } async function main() { const base = serverUrl(); const channel = 'js-ce-queues.basic'; const clientId = 'kubemq-ce-js-worker'; const numMessages = 3; for (let i = 1; i <= numMessages; i++) { const event = new CloudEvent({ type: 'com.kubemq.examples.queues.task', source: 'kubemq-ce-js-example', subject: channel, datacontenttype: 'application/json', data: { task_id: i, task: 'process-item' }, }); const msg = HTTP.structured(event); const resp = await fetch(`${base}/ce/queue/send`, { method: 'POST', headers: msg.headers, body: msg.body, }); const result = await resp.json(); console.log(` Sent task ${i}: status=${resp.status} is_error=${result.is_error}`); } for (let i = 0; i < numMessages; i++) { const url = `${base}/ce/queue/receive?channel=${encodeURIComponent(channel)}&client_id=${clientId}&max_messages=1&wait_timeout=5`; const resp = await fetch(url, { method: 'POST' }); const result = await resp.json(); for (const m of result.data?.messages ?? []) { console.log(` Received: type=${m.type} data=${JSON.stringify(m.data)}`); } } } main().catch(console.error); ``` ```python import os import requests from cloudevents.v1.conversion import to_structured from cloudevents.v1.http import CloudEvent def server_url() -> str: return os.environ.get("KUBEMQ_CE_URL", "http://localhost:9090") def main() -> None: base = server_url() channel = "python-ce-queues.basic" client_id = "kubemq-ce-python-worker" num_messages = 3 for i in range(1, num_messages + 1): event = CloudEvent( attributes={ "type": "com.kubemq.examples.queues.task", "source": "kubemq-ce-python-example", "subject": channel, "datacontenttype": "application/json", }, data={"task_id": i, "task": "process-item"}, ) headers, body = to_structured(event) resp = requests.post(f"{base}/ce/queue/send", data=body, headers=dict(headers), timeout=10) result = resp.json() print(f" Sent task {i}: status={resp.status_code} is_error={result.get('is_error')}") for _ in range(num_messages): resp = requests.post( f"{base}/ce/queue/receive", params={ "channel": channel, "client_id": client_id, "max_messages": 1, "wait_timeout": 5, }, timeout=10, ) result = resp.json() for msg in result.get("data", {}).get("messages", []): print(f" Received: type={msg.get('type')} data={msg.get('data')}") if __name__ == "__main__": main() ``` ```ruby require "net/http" require "uri" require "json" require "securerandom" require "cloud_events" def server_url = ENV.fetch("KUBEMQ_CE_URL", "http://localhost:9090") base = server_url channel = "ruby-ce-queues.basic" client_id = "kubemq-ce-ruby-worker" sdk = CloudEvents::HttpBinding.default (1..3).each do |i| event = CloudEvents::Event::V1.new( id: SecureRandom.uuid, type: "com.kubemq.examples.queues.task", source: URI("urn:kubemq-ce-ruby-example"), subject: channel, spec_version: "1.0", data_content_type: CloudEvents::ContentType.new("application/json"), data: JSON.generate({ task_id: i, task: "process-item" }) ) enc_headers, enc_body = sdk.encode_event(event, structured_format: "json") uri = URI("#{base}/ce/queue/send") Net::HTTP.start(uri.host, uri.port) do |http| req = Net::HTTP::Post.new(uri) enc_headers.each { |k, v| req[k] = v } req.body = enc_body res = http.request(req) r = JSON.parse(res.body) puts " Sent task #{i}: status=#{res.code} is_error=#{r['is_error']}" end end 3.times do uri = URI("#{base}/ce/queue/receive") uri.query = URI.encode_www_form(channel: channel, client_id: client_id, max_messages: 1, wait_timeout: 5) Net::HTTP.start(uri.host, uri.port) do |http| res = http.request(Net::HTTP::Post.new(uri)) r = JSON.parse(res.body) msgs = r.dig('data', 'messages') || [] msgs.each { |m| puts " Received: type=#{m['type']} data=#{m['data']}" } end end ``` ```rust use cloudevents::{EventBuilder, EventBuilderV10}; use reqwest::Client; use serde_json::{json, Value}; use std::env; use uuid::Uuid; fn server_url() -> String { env::var("KUBEMQ_CE_URL").unwrap_or_else(|_| "http://localhost:9090".to_string()) } #[tokio::main] async fn main() -> Result<(), Box> { let base = server_url(); let client = Client::new(); let channel = "rust-ce-queues.basic"; let client_id = "kubemq-ce-rust-worker"; for i in 1..=3u32 { let event = EventBuilderV10::new() .id(Uuid::new_v4().to_string()) .ty("com.kubemq.examples.queues.task") .source("urn:kubemq-ce-rust-example") .subject(channel) .data("application/json", json!({"task_id": i, "task": "process-item"})) .build()?; let body = serde_json::to_string(&event)?; let resp = client.post(format!("{}/ce/queue/send", base)) .header("Content-Type", "application/cloudevents+json") .body(body).send().await?; let r: Value = resp.json().await?; println!(" Sent task {}: is_error={}", i, r["is_error"]); } for _ in 0..3 { let url = format!( "{}/ce/queue/receive?channel={}&client_id={}&max_messages=1&wait_timeout=5", base, channel, client_id ); let resp = client.post(&url).send().await?; let r: Value = resp.json().await?; let msgs = r["data"]["messages"].as_array().cloned().unwrap_or_default(); for m in &msgs { println!(" Received: type={} data={}", m["type"], m["data"]); } } Ok(()) } ``` ### Receive parameters [#receive-parameters] | Parameter | Type | Default | Description | | -------------- | ------ | ------------ | ------------------------------------------------------------------ | | `channel` | string | *(required)* | Queue channel name | | `client_id` | string | *(required)* | Client identifier (overridden by auth claims when auth is enabled) | | `max_messages` | int | `1` | Maximum number of messages to receive (1–1000) | | `wait_timeout` | int | `5` | Wait timeout in seconds | | `is_peek` | bool | `false` | If true, peek at messages without consuming them | A successful receive returns HTTP 200. Each message that carries CE tags is reconstructed as a CloudEvent JSON object; messages without CE tags are returned in plain KubeMQ format: ```json { "is_error": false, "message": "OK", "data": { "messages_received": 1, "messages": [ { "specversion": "1.0", "type": "com.example.job.submit", "source": "job-scheduler", "subject": "work-queue", "id": "550e8400-e29b-41d4-a716-446655440000", "time": "2026-03-29T10:30:00Z", "data": {"job_type": "report", "params": {"month": "2026-03"}} } ] } } ``` ## Peek [#peek] Peeking inspects queued messages **without consuming them** — pass `is_peek=true` on a `receive` call. The same messages remain available for a later consuming receive, which makes peek useful for monitoring queue depth or previewing work before committing to it. ```bash # Peek: inspect up to 10 messages without removing them curl -X POST "http://localhost:9090/ce/queue/receive?channel=work-queue&client_id=worker-1&max_messages=10&wait_timeout=3&is_peek=true" # A normal receive (is_peek omitted) consumes the same messages curl -X POST "http://localhost:9090/ce/queue/receive?channel=work-queue&client_id=worker-1&max_messages=10&wait_timeout=3" ``` ```go func receiveOrPeek(base, channel, clientID string, isPeek bool, label string) int { isPeekStr := "false" if isPeek { isPeekStr = "true" } url := fmt.Sprintf( "%s/ce/queue/receive?channel=%s&client_id=%s&max_messages=10&wait_timeout=3&is_peek=%s", base, channel, clientID, isPeekStr) req, _ := http.NewRequest("POST", url, nil) resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatal("receive:", err) } defer resp.Body.Close() var result CEResponse _ = json.NewDecoder(resp.Body).Decode(&result) var data QueueReceiveData _ = json.Unmarshal(result.Data, &data) fmt.Printf("[%s] messages_received=%d\n", label, data.MessagesReceived) return data.MessagesReceived } // Peek twice (messages stay), then consume. receiveOrPeek(base, channel, "go-peek-client", true, "peek #1") receiveOrPeek(base, channel, "go-peek-client", true, "peek #2") receiveOrPeek(base, channel, "go-peek-client", false, "consume") ``` ```javascript async function receiveOrPeek(base, channel, clientId, isPeek, label) { const url = `${base}/ce/queue/receive?channel=${encodeURIComponent(channel)}&client_id=${clientId}&max_messages=10&wait_timeout=3&is_peek=${isPeek}`; const resp = await fetch(url, { method: 'POST' }); const result = await resp.json(); const n = result.data?.messages_received ?? 0; console.log(`[${label}] messages_received=${n}`); return n; } // Peek twice (messages stay), then consume. await receiveOrPeek(base, channel, 'js-peek-client', true, 'peek #1'); await receiveOrPeek(base, channel, 'js-peek-client', true, 'peek #2'); await receiveOrPeek(base, channel, 'js-peek-client', false, 'consume'); ``` ```python def receive_or_peek(base, channel, client_id, is_peek, label): resp = requests.post( f"{base}/ce/queue/receive", params={ "channel": channel, "client_id": client_id, "max_messages": 10, "wait_timeout": 3, "is_peek": "true" if is_peek else "false", }, timeout=10, ) result = resp.json() n = result.get("data", {}).get("messages_received", 0) print(f"[{label}] messages_received={n}") return n # Peek twice (messages stay), then consume. receive_or_peek(base, channel, "python-peek-client", True, "peek #1") receive_or_peek(base, channel, "python-peek-client", True, "peek #2") receive_or_peek(base, channel, "python-peek-client", False, "consume") ``` The query parameter is spelled `is_peek` on the wire — setting `is_peek=true` performs a non-destructive peek (messages stay queued), while omitting it (or `is_peek=false`) performs a normal consuming receive. The internal Go struct field is named `IsPeak`, but that name never appears on the wire. ## Ack all [#ack-all] `POST /ce/queue/ack_all` acknowledges (drains) **all pending messages** in a queue atomically, without receiving them one by one. This is a control operation — the body is ignored and `channel`, `client_id`, and `wait_timeout` come from query parameters. Use it to clear a backlog or reset a work channel. ```bash curl -X POST "http://localhost:9090/ce/queue/ack_all?channel=work-queue&client_id=worker-1&wait_timeout=10" ``` ```go // Drain the queue atomically. ackURL := fmt.Sprintf("%s/ce/queue/ack_all?channel=%s&client_id=%s&wait_timeout=5", base, channel, clientID) req, _ := http.NewRequest("POST", ackURL, nil) resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatal("ack_all:", err) } defer resp.Body.Close() var ackResult CEResponse _ = json.NewDecoder(resp.Body).Decode(&ackResult) fmt.Printf("ack_all: is_error=%v message=%s\n", ackResult.IsError, ackResult.Message) ``` ```python # Drain the queue atomically. ack_resp = requests.post( f"{base}/ce/queue/ack_all", params={"channel": channel, "client_id": client_id, "wait_timeout": 5}, timeout=10, ) result = ack_resp.json() print(f"ack_all: is_error={result.get('is_error')} message={result.get('message')}") ``` ### Ack-all parameters [#ack-all-parameters] | Parameter | Type | Default | Description | | -------------- | ------ | ------------ | ------------------------------------------------------------------ | | `channel` | string | *(required)* | Queue channel name | | `client_id` | string | *(required)* | Client identifier (overridden by auth claims when auth is enabled) | | `wait_timeout` | int | `5` | Wait timeout in seconds | A successful ack returns HTTP 200 with the result in `data`. `send`, `receive`, and `ack_all` are synchronous `POST` endpoints and are subject to the connector's `TimeoutSeconds` (default 60s). A request that exceeds the timeout returns HTTP 504. See [Configuration](/connectors/cloudevents/concepts/configuration-model). ## Related [#related] # SSE Behavior (/connectors/cloudevents/how-to/sse-behavior) The CloudEvents connector streams every subscription over **Server-Sent Events (SSE)** — a standard, long-lived HTTP mechanism for server-to-client delivery. This guide covers the wire format your client must parse, the keepalive and idle-timeout behavior, the connection limit, and how `Last-Event-ID` resumes an events-store stream after a disconnect. ## Overview [#overview] When a client opens any `GET /ce/subscribe/*` endpoint, the connector holds the connection open and responds with `Content-Type: text/event-stream`. Messages arriving on the subscribed channel are pushed to the client as SSE frames as they happen — there is no polling. All four subscription endpoints (`events`, `events-store`, `commands`, `queries`) share the same SSE wire format and lifecycle. The only behavioral difference is **replay**: events-store streams carry an `id:` per frame and support `Last-Event-ID` reconnection, while plain `events` streams do not (non-persistent events cannot be replayed). SSE subscription endpoints are long-lived `GET` requests and are **not** subject to the `TimeoutSeconds` (default 60s) that applies to synchronous `POST` endpoints. They stay open until idle timeout, the connection limit, a client disconnect, or a server shutdown. ## SSE wire format [#sse-wire-format] Each frame is a set of optional `id:`, `event:`, and `data:` lines terminated by a blank line. The `event:` field tells the client which kind of payload the `data:` line carries. ```text title="SSE frame — CloudEvent message" id: 42 event: cloudevent data: {"specversion":"1.0","type":"com.example.order","source":"svc","id":"abc","subject":"orders","data":{"amount":99}} ``` ```text title="SSE frame — non-CloudEvent message" event: message data: {"channel":"orders","metadata":"","tags":{"key":"val"},"data":{"amount":99}} ``` ```text title="SSE frame — error event" event: error data: {"is_error":true,"message":"stream idle timeout"} ``` ```text title="Keepalive comment (not a named event)" : keepalive ``` ### Event types [#event-types] The `event:` field distinguishes message kinds on a single stream: | `event:` value | Description | | -------------- | ------------------------------------------------------------------------------------------------------------------ | | `cloudevent` | The message carries a `ce_specversion` tag; `data:` is a reconstructed CloudEvent JSON object. | | `message` | A non-CloudEvent message; `data:` is plain JSON with native KubeMQ fields (`channel`, `metadata`, `tags`, `data`). | | `error` | A terminal error (for example, idle timeout); `data:` is `{"is_error":true,"message":"..."}`. | | *(absent)* | A keepalive comment line (`: keepalive`) — ignored by standard `EventSource` clients. | ## How reconnection works [#how-reconnection-works] The diagram below shows an events-store subscription: the client reads several frames, records the last `id:` it saw, disconnects, then reconnects with `Last-Event-ID` to resume from the next sequence — no duplicates, no gaps. *An events-store stream resumes from `sequence + 1` when the client reconnects with `Last-Event-ID`.* ## Keepalive [#keepalive] To stop proxies and load balancers from closing an idle connection, the connector sends a keepalive comment every **30 seconds**: ```text : keepalive ``` Keepalive frames are SSE comments (lines starting with `:`). Standard `EventSource` clients ignore them automatically; a manual parser should skip any line that starts with `:`. ## Idle timeout [#idle-timeout] If no message arrives for `MaxSSEIdleSeconds` (default **300 seconds**), the connector emits an `error` event and closes the connection: ```text event: error data: {"is_error":true,"message":"stream idle timeout"} ``` The idle timer resets on every received message — keepalive comments do **not** reset it. Treat the idle-timeout error as a normal lifecycle event and reconnect if you still need the stream. See [Configuration](/connectors/cloudevents/concepts/configuration-model) to tune `MaxSSEIdleSeconds`. ## Connection limits [#connection-limits] When `MaxSSEConnections` is greater than `0`, the connector caps the number of concurrent SSE connections across **all** subscription endpoints. A new connection that exceeds the limit is rejected with **HTTP 429 Too Many Requests**. The default of `0` disables the limit (unlimited connections). ## Last-Event-ID reconnection [#last-event-id-reconnection] Replay-on-reconnect is available for **events-store subscriptions only**. Each frame on an events-store stream carries an `id:` line set to the message sequence number. When a client reconnects with the `Last-Event-ID` header, the connector resumes from `sequence + 1`, automatically replaying anything missed during the disconnect. This follows the standard SSE reconnection protocol — browsers and `EventSource` clients send `Last-Event-ID` on reconnect for you. **Omit `events_store_type` when reconnecting with `Last-Event-ID`.** If both are present, the `events_store_type` query parameter takes precedence and the `Last-Event-ID` resume is ignored. Drop `events_store_type` from the reconnect URL. Plain `events` subscriptions do not emit `id:` fields and cannot be replayed. For command/query subscriptions, CloudEvent messages carry an `id:` set to the CloudEvent `id` attribute, while non-CE command/query messages have no `id:`. ## Mixed CE and non-CE messages [#mixed-ce-and-non-ce-messages] A single channel can carry both CloudEvents and native KubeMQ messages, and the connector delivers both on the same SSE stream. The `event:` field is how you tell them apart: * `event: cloudevent` — `data:` is a reconstructed CloudEvent JSON object. * `event: message` — `data:` is plain JSON with native KubeMQ fields. Always branch on the `event:` value so your consumer handles mixed-protocol channels correctly. ## Usage [#usage] The example below demonstrates the full reconnection lifecycle against `events-store`: subscribe with `events_store_type=2` (start from first), read a batch of frames while recording the last `id:`, disconnect, then reconnect with the `Last-Event-ID` header to resume. The curl tab shows the two raw requests; each language tab is the verbatim KubeMQ example that parses the SSE stream and performs the resume. ```bash # 1. Subscribe from the first stored message; note the id: on each frame. curl -N \ -H "Accept: text/event-stream" \ "http://localhost:9090/ce/subscribe/events-store?client_id=my-client&channel=audit-log&events_store_type=2" # Frames look like: # id: 1 # event: cloudevent # data: {"specversion":"1.0",...} # # : keepalive # 2. Reconnect after a disconnect — resume from sequence + 1. # Omit events_store_type so Last-Event-ID takes precedence. curl -N \ -H "Accept: text/event-stream" \ -H "Last-Event-ID: 3" \ "http://localhost:9090/ce/subscribe/events-store?client_id=my-client&channel=audit-log" ``` ```csharp // events-store/ReconnectResume — Last-Event-ID reconnect. using CloudNative.CloudEvents; using CloudNative.CloudEvents.SystemTextJson; using System.Net.Http.Headers; using System.Text; using System.Text.Json; static string ServerUrl() => Environment.GetEnvironmentVariable("KUBEMQ_CE_URL") ?? "http://localhost:9090"; var base_ = ServerUrl(); var channel = "csharp-ce-events-store.reconnect-resume"; var formatter = new JsonEventFormatter(); using var httpClient = new HttpClient(); // Publish 4 events. Console.WriteLine("Publishing 4 events..."); for (int i = 1; i <= 4; i++) { var ev = new CloudEvent { Id = Guid.NewGuid().ToString(), Type = "com.kubemq.examples.eventsstore.stored", Source = new Uri("urn:kubemq-ce-csharp-example"), Subject = channel, DataContentType = "application/json", Data = new { seq = i }, }; var bytes = formatter.EncodeStructuredModeMessage(ev, out var ct); using var c = new ByteArrayContent(bytes.ToArray()); c.Headers.ContentType = MediaTypeHeaderValue.Parse(ct.ToString()); await httpClient.PostAsync($"{base_}/ce/send/event-store", c); } // Helper: subscribe and collect up to maxEvents, return (events, lastId). async Task<(List events, string lastId)> Subscribe( string clientId, string? lastEventId, int maxEvents) { var events = new List(); var lastId = ""; using var http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan }; // Omit events_store_type when reconnecting with Last-Event-ID. var url = lastEventId == null ? $"{base_}/ce/subscribe/events-store?client_id={clientId}&channel={Uri.EscapeDataString(channel)}&events_store_type=2" : $"{base_}/ce/subscribe/events-store?client_id={clientId}&channel={Uri.EscapeDataString(channel)}"; using var req = new HttpRequestMessage(HttpMethod.Get, url); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); if (lastEventId != null) req.Headers.Add("Last-Event-ID", lastEventId); using var resp = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead); using var reader = new StreamReader(await resp.Content.ReadAsStreamAsync(), Encoding.UTF8); string? evType = null, data = null, id = null, line; using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); while ((line = await reader.ReadLineAsync().WaitAsync(cts.Token)) != null) { if (line == "") { if (evType == "cloudevent" && data != null) { if (id != null) lastId = id; events.Add(JsonSerializer.Deserialize(data)); if (events.Count >= maxEvents) break; } evType = null; data = null; id = null; } else if (line.StartsWith(":")) { } else if (line.StartsWith("id:")) id = line[3..].Trim(); else if (line.StartsWith("event:")) evType = line[6..].Trim(); else if (line.StartsWith("data:")) data = line[5..].Trim(); } return (events, lastId); } // First connection: receive 2 events. Console.WriteLine("First connection — receiving 2 events:"); var (first, lastId) = await Subscribe("csharp-es-reconnect-1", null, 2); foreach (var ce in first) Console.WriteLine($" Received: seq={ce.GetProperty("data").GetProperty("seq")}"); Console.WriteLine($" Last-Event-ID recorded: {lastId}"); // Reconnect using Last-Event-ID (new client_id; broker requires a unique active client_id). await Task.Delay(500); Console.WriteLine($"Reconnecting with Last-Event-ID={lastId}..."); var (second, _) = await Subscribe("csharp-es-reconnect-2", lastId, 2); foreach (var ce in second) Console.WriteLine($" Resumed: seq={ce.GetProperty("data").GetProperty("seq")}"); Console.WriteLine("Reconnect-resume demonstration complete."); ``` ```go // events-store/reconnect-resume — SSE reconnection with Last-Event-ID. package main import ( "bufio" "encoding/json" "fmt" "io" "log" "net/http" "os" "strings" "time" cloudevents "github.com/cloudevents/sdk-go/v2" ) func serverURL() string { if u := os.Getenv("KUBEMQ_CE_URL"); u != "" { return u } return "http://localhost:9090" } func publishAll(base, channel string, count int) { for i := 1; i <= count; i++ { event := cloudevents.NewEvent() event.SetType("com.kubemq.examples.eventsstore.reconnect") event.SetSource("kubemq-ce-go-example") event.SetSubject(channel) _ = event.SetData(cloudevents.ApplicationJSON, map[string]int{"n": i}) body, _ := json.Marshal(event) req, _ := http.NewRequest("POST", base+"/ce/send/event-store", strings.NewReader(string(body))) req.Header.Set("Content-Type", "application/cloudevents+json") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatal("send:", err) } resp.Body.Close() } fmt.Printf("Published %d events to events-store.\n", count) } // readN reads exactly n cloudevents from an SSE stream, returning the last SSE id. func readN(body io.ReadCloser, n int) (lastID string) { scanner := bufio.NewScanner(body) var evType, data, sseID string received := 0 for scanner.Scan() { line := scanner.Text() if line == "" { if evType == "cloudevent" && data != "" { received++ lastID = sseID var ce map[string]interface{} _ = json.Unmarshal([]byte(data), &ce) fmt.Printf(" [%d] id=%s data=%v\n", received, sseID, ce["data"]) if received == n { return lastID } } evType, data, sseID = "", "", "" continue } if strings.HasPrefix(line, ":") { continue } if strings.HasPrefix(line, "id:") { sseID = strings.TrimSpace(strings.TrimPrefix(line, "id:")) } else if strings.HasPrefix(line, "event:") { evType = strings.TrimSpace(strings.TrimPrefix(line, "event:")) } else if strings.HasPrefix(line, "data:") { data = strings.TrimSpace(strings.TrimPrefix(line, "data:")) } } return lastID } func openSSE(base, channel, clientID, lastEventID string) (*http.Response, error) { // When reconnecting, omit events_store_type so Last-Event-ID takes precedence. var sseURL string if lastEventID == "" { sseURL = fmt.Sprintf( "%s/ce/subscribe/events-store?client_id=%s&channel=%s&events_store_type=2", base, clientID, channel) } else { sseURL = fmt.Sprintf( "%s/ce/subscribe/events-store?client_id=%s&channel=%s", base, clientID, channel) } req, _ := http.NewRequest("GET", sseURL, nil) req.Header.Set("Accept", "text/event-stream") req.Header.Set("Cache-Control", "no-cache") if lastEventID != "" { req.Header.Set("Last-Event-ID", lastEventID) } client := &http.Client{Timeout: 0} return client.Do(req) } func main() { base := serverURL() channel := "go-ce-events-store.reconnect-resume" const totalEvents = 6 const firstBatch = 3 publishAll(base, channel, totalEvents) time.Sleep(200 * time.Millisecond) // First connection: receive first batch. fmt.Printf("\nFirst connection (StartFromFirst, reading first %d events):\n", firstBatch) resp1, err := openSSE(base, channel, "go-reconnect-sub", "") if err != nil { log.Fatal("first connect:", err) } lastID := readN(resp1.Body, firstBatch) resp1.Body.Close() fmt.Printf("Disconnected. Last-Event-ID captured: %s\n", lastID) // Second connection: resume from lastID. fmt.Printf("\nReconnecting with Last-Event-ID: %s\n", lastID) resp2, err := openSSE(base, channel, "go-reconnect-sub", lastID) if err != nil { log.Fatal("reconnect:", err) } defer resp2.Body.Close() remaining := totalEvents - firstBatch fmt.Printf("Receiving remaining %d events:\n", remaining) readN(resp2.Body, remaining) fmt.Println("\nReconnect-resume demonstration complete.") } ``` ```java // events-store/reconnect-resume — Last-Event-ID reconnect. package io.kubemq.examples.eventsstore.reconnectresume; import com.fasterxml.jackson.databind.ObjectMapper; import io.cloudevents.CloudEvent; import io.cloudevents.core.builder.CloudEventBuilder; import io.cloudevents.core.format.EventFormat; import io.cloudevents.core.provider.EventFormatProvider; import io.cloudevents.jackson.JsonFormat; import java.io.BufferedReader; import java.io.InputStreamReader; import java.net.HttpURLConnection; import java.net.URI; import java.net.URL; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.OffsetDateTime; import java.util.Map; import java.util.UUID; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; public class Main { static String serverUrl() { String u = System.getenv("KUBEMQ_CE_URL"); return (u != null && !u.isEmpty()) ? u : "http://localhost:9090"; } static final ObjectMapper MAPPER = new ObjectMapper(); /** Opens one SSE connection, reads up to maxEvents, returns lastEventID seen. */ static String subscribe(String base, String channel, String clientId, String lastEventId, int maxEvents, BlockingQueue out) throws Exception { // Build URL — omit events_store_type when reconnecting with Last-Event-ID. String sseUrl = base + "/ce/subscribe/events-store?client_id=" + clientId + "&channel=" + channel; if (lastEventId == null) { sseUrl += "&events_store_type=2"; } final String finalLastEventId = lastEventId; final String finalUrl = sseUrl; AtomicReference lastId = new AtomicReference<>(""); BlockingQueue done = new ArrayBlockingQueue<>(1); Thread.ofVirtual().start(() -> { try { HttpURLConnection conn = (HttpURLConnection) new URL(finalUrl).openConnection(); conn.setRequestProperty("Accept", "text/event-stream"); conn.setReadTimeout(10_000); if (finalLastEventId != null) { conn.setRequestProperty("Last-Event-ID", finalLastEventId); } int[] count = {0}; try (BufferedReader reader = new BufferedReader( new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { String line; String evType = null, data = null, id = null; while ((line = reader.readLine()) != null && count[0] < maxEvents) { if (line.isEmpty()) { if ("cloudevent".equals(evType) && data != null) { if (id != null) lastId.set(id); out.offer(data); count[0]++; if (count[0] >= maxEvents) break; } evType = null; data = null; id = null; } else if (line.startsWith("id:")) id = line.substring(3).trim(); else if (line.startsWith("event:")) evType = line.substring(6).trim(); else if (line.startsWith("data:")) data = line.substring(5).trim(); } } } catch (Exception e) { /* read ended */ } done.offer(true); }); done.poll(12, TimeUnit.SECONDS); return lastId.get(); } public static void main(String[] args) throws Exception { String base = serverUrl(); String channel = "java-ce-events-store.reconnect-resume"; EventFormatProvider.getInstance().registerFormat(new JsonFormat()); EventFormat format = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE); HttpClient httpClient = HttpClient.newHttpClient(); // Publish 4 events. System.out.println("Publishing 4 events..."); for (int i = 1; i <= 4; i++) { CloudEvent ev = CloudEventBuilder.v1() .withId(UUID.randomUUID().toString()) .withType("com.kubemq.examples.eventsstore.stored") .withSource(URI.create("kubemq-ce-java-example")) .withSubject(channel) .withDataContentType("application/json") .withTime(OffsetDateTime.now()) .withData("application/json", MAPPER.writeValueAsBytes(Map.of("seq", i))) .build(); httpClient.send( HttpRequest.newBuilder().uri(URI.create(base + "/ce/send/event-store")) .POST(HttpRequest.BodyPublishers.ofByteArray(format.serialize(ev))) .header("Content-Type", "application/cloudevents+json").build(), HttpResponse.BodyHandlers.ofString()); } // First connection: receive 2 events, record lastEventID. BlockingQueue received = new ArrayBlockingQueue<>(10); System.out.println("First connection — receiving 2 events:"); String lastId = subscribe(base, channel, "java-es-reconnect", null, 2, received); for (int i = 0; i < 2; i++) { String data = received.poll(5, TimeUnit.SECONDS); if (data != null) { Map ce = MAPPER.readValue(data, Map.class); System.out.println(" Received: seq=" + ((Map) ce.get("data")).get("seq")); } } System.out.println(" Last-Event-ID recorded: " + lastId); // Reconnect using Last-Event-ID — resume from next sequence. System.out.println("Reconnecting with Last-Event-ID=" + lastId + "..."); subscribe(base, channel, "java-es-reconnect", lastId, 2, received); for (int i = 0; i < 2; i++) { String data = received.poll(5, TimeUnit.SECONDS); if (data != null) { Map ce = MAPPER.readValue(data, Map.class); System.out.println(" Resumed: seq=" + ((Map) ce.get("data")).get("seq")); } } System.out.println("Reconnect-resume demonstration complete."); } } ``` ```typescript // events-store/reconnect-resume — Last-Event-ID reconnection. import { CloudEvent, HTTP } from 'cloudevents'; import EventSource from 'eventsource'; function serverUrl(): string { return process.env.KUBEMQ_CE_URL ?? 'http://localhost:9090'; } function readNEvents( base: string, channel: string, clientId: string, n: number, lastEventId?: string, ): Promise { return new Promise((resolve) => { const params = new URLSearchParams({ client_id: clientId, channel }); if (lastEventId) { // omit events_store_type so Last-Event-ID takes precedence } else { params.set('events_store_type', '2'); } const headers: Record = {}; if (lastEventId) headers['Last-Event-ID'] = lastEventId; // EventSource works for the initial connection; for the Last-Event-ID // reconnect we use fetch so we can set the header explicitly. if (!lastEventId) { const es = new EventSource(`${base}/ce/subscribe/events-store?${params}`); let count = 0; let lastId = ''; es.addEventListener('error', (err) => { console.error('SSE error:', err); es.close(); resolve(''); }); es.addEventListener('cloudevent', (evt: MessageEvent & { lastEventId: string }) => { const ce = JSON.parse(evt.data) as Record; count++; lastId = evt.lastEventId; console.log(` [${count}] id=${lastId} data=${JSON.stringify(ce.data)}`); if (count === n) { es.close(); resolve(lastId); } }); } else { const url = `${base}/ce/subscribe/events-store?${params}`; fetch(url, { headers }).then(async (resp) => { const reader = resp.body!.getReader(); const decoder = new TextDecoder(); let buffer = ''; let count = 0; let lastId = lastEventId; let evType = ''; let data = ''; while (count < n) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() ?? ''; for (const line of lines) { if (line === '') { if (evType === 'cloudevent' && data) { const ce = JSON.parse(data) as Record; count++; console.log(` [${count}] id=${lastId} data=${JSON.stringify(ce.data)}`); if (count === n) { reader.cancel(); resolve(lastId); return; } } evType = data = ''; } else if (line.startsWith('id:')) { lastId = line.slice(3).trim(); } else if (line.startsWith('event:')) { evType = line.slice(6).trim(); } else if (line.startsWith('data:')) { data = line.slice(5).trim(); } } } resolve(lastId); }); } }); } async function main(): Promise { const base = serverUrl(); const channel = 'js-ce-events-store.reconnect-resume'; const total = 6; const firstBatch = 3; for (let i = 1; i <= total; i++) { const event = new CloudEvent({ type: 'com.kubemq.examples.eventsstore.reconnect', source: 'kubemq-ce-js-example', subject: channel, datacontenttype: 'application/json', data: { n: i }, }); const msg = HTTP.structured(event); await fetch(`${base}/ce/send/event-store`, { method: 'POST', headers: msg.headers as Record, body: msg.body as string, }); } console.log(`Published ${total} events.`); await new Promise((r) => setTimeout(r, 200)); console.log(`\nFirst connection (reading first ${firstBatch} events):`); const lastId = await readNEvents(base, channel, 'js-reconnect-sub', firstBatch); console.log(`Disconnected. Last-Event-ID: ${lastId}`); console.log(`\nReconnecting with Last-Event-ID=${lastId}:`); await readNEvents(base, channel, 'js-reconnect-sub', total - firstBatch, lastId); console.log('\nReconnect-resume complete.'); } main().catch(console.error); ``` ```python """events_store/reconnect_resume — SSE reconnect with Last-Event-ID.""" from __future__ import annotations import json import os import time import requests from cloudevents.v1.conversion import to_structured from cloudevents.v1.http import CloudEvent def server_url() -> str: return os.environ.get("KUBEMQ_CE_URL", "http://localhost:9090") def read_n_events(base: str, channel: str, client_id: str, n: int, last_event_id: str = "") -> str: """Open SSE, read n events, return last SSE id.""" if last_event_id: sse_url = (f"{base}/ce/subscribe/events-store" f"?client_id={client_id}&channel={channel}") extra_headers = {"Last-Event-ID": last_event_id} else: sse_url = (f"{base}/ce/subscribe/events-store" f"?client_id={client_id}&channel={channel}&events_store_type=2") extra_headers = {} headers = {"Accept": "text/event-stream", **extra_headers} last_id = "" count = 0 with requests.get(sse_url, stream=True, timeout=None, headers=headers) as resp: ev_type = data = sse_id = "" for line in resp.iter_lines(decode_unicode=True): if line == "": if ev_type == "cloudevent" and data: ce = json.loads(data) count += 1 last_id = sse_id print(f" [{count}] id={sse_id} data={ce.get('data')}") if count == n: return last_id ev_type = data = sse_id = "" continue if line.startswith(":"): continue if line.startswith("id:"): sse_id = line[3:].strip() elif line.startswith("event:"): ev_type = line[6:].strip() elif line.startswith("data:"): data = line[5:].strip() return last_id def main() -> None: base = server_url() channel = "python-ce-events-store.reconnect-resume" total = 6 first_batch = 3 for i in range(1, total + 1): event = CloudEvent( attributes={ "type": "com.kubemq.examples.eventsstore.reconnect", "source": "kubemq-ce-python-example", "subject": channel, "datacontenttype": "application/json", }, data={"n": i}, ) headers, body = to_structured(event) requests.post(f"{base}/ce/send/event-store", data=body, headers=dict(headers), timeout=10) print(f"Published {total} events.") time.sleep(0.2) print(f"\nFirst connection (reading first {first_batch} events):") last_id = read_n_events(base, channel, "python-reconnect-sub", first_batch) print(f"Disconnected. Last-Event-ID: {last_id}") print(f"\nReconnecting with Last-Event-ID={last_id}:") read_n_events(base, channel, "python-reconnect-sub", total - first_batch, last_id) print("\nReconnect-resume complete.") if __name__ == "__main__": main() ``` ```ruby # events_store/reconnect_resume — Last-Event-ID reconnect. require "net/http"; require "uri"; require "json"; require "cloud_events"; require "securerandom" def server_url = ENV.fetch("KUBEMQ_CE_URL", "http://localhost:9090") base = server_url; channel = "ruby-ce-events-store.reconnect-resume" sdk = CloudEvents::HttpBinding.default # Publish 4 events. puts "Publishing 4 events..." (1..4).each do |i| ev = CloudEvents::Event::V1.new( id: SecureRandom.uuid, type: "com.kubemq.examples.eventsstore.stored", source: URI("urn:kubemq-ce-ruby-example"), subject: channel, spec_version: "1.0", data_content_type: CloudEvents::ContentType.new("application/json"), data: JSON.generate({ seq: i }) ) enc_h, enc_b = sdk.encode_event(ev, structured_format: "json") uri = URI("#{base}/ce/send/event-store") Net::HTTP.start(uri.host, uri.port) do |http| req = Net::HTTP::Post.new(uri); enc_h.each{|k,v|req[k]=v}; req.body=enc_b http.request(req) end end # Helper: open one SSE connection, collect up to max_events, return [events, last_id]. def subscribe_es(base, channel, last_event_id, max_events) # Omit events_store_type when reconnecting with Last-Event-ID. query = last_event_id.nil? \ ? "events_store_type=2" \ : "" # no events_store_type on reconnect uri = URI("#{base}/ce/subscribe/events-store?client_id=ruby-es-reconnect&channel=#{URI.encode_www_form_component(channel)}#{query.empty? ? '' : '&' + query}") events = []; last_id = nil Net::HTTP.start(uri.host, uri.port, read_timeout: 12) do |http| req = Net::HTTP::Get.new(uri); req["Accept"] = "text/event-stream" req["Last-Event-ID"] = last_event_id if last_event_id http.request(req) do |resp| ev_type = nil; data = nil; id = nil resp.read_body do |chunk| chunk.each_line do |line| line.chomp! if line.empty? if ev_type == "cloudevent" && data last_id = id if id ce = JSON.parse(data) ce["data"] = JSON.parse(ce["data"]) if ce["data"].is_a?(String) events << ce return [events, last_id] if events.size >= max_events end ev_type = nil; data = nil; id = nil elsif line.start_with?("id:") then id = line.sub("id:","").strip elsif line.start_with?("event:") then ev_type = line.sub("event:","").strip elsif line.start_with?("data:") then data = line.sub("data:","").strip end end end end end [events, last_id] end # First connection: receive 2 events. puts "First connection — receiving 2 events:" first_events, last_id = subscribe_es(base, channel, nil, 2) first_events.each { |ce| puts " Received: seq=#{ce.dig('data','seq')}" } puts " Last-Event-ID recorded: #{last_id}" # Reconnect using Last-Event-ID. puts "Reconnecting with Last-Event-ID=#{last_id}..." second_events, _ = subscribe_es(base, channel, last_id, 2) second_events.each { |ce| puts " Resumed: seq=#{ce.dig('data','seq')}" } puts "Reconnect-resume demonstration complete." ``` ```rust // events-store/reconnect-resume — Last-Event-ID reconnect. use bytes::Bytes; use cloudevents::{EventBuilder, EventBuilderV10}; use futures_util::StreamExt; use reqwest::Client; use serde_json::{json, Value}; use std::env; use uuid::Uuid; fn server_url() -> String { env::var("KUBEMQ_CE_URL").unwrap_or_else(|_| "http://localhost:9090".to_string()) } /// Subscribe and collect up to `max` events. Returns (events, last_id_seen). async fn subscribe_and_collect( client: &Client, url: &str, last_event_id: Option<&str>, max: usize, ) -> (Vec, String) { let mut req = client.get(url).header("Accept", "text/event-stream"); if let Some(id) = last_event_id { req = req.header("Last-Event-ID", id); } let stream = req.send().await.expect("SSE connect").bytes_stream(); let mut stream = Box::pin(stream); let mut ev_type = String::new(); let mut data = String::new(); let mut id_field = String::new(); let mut last_id = String::new(); let mut buffer = String::new(); let mut events = Vec::new(); while let Some(chunk) = stream.next().await { let chunk: Bytes = chunk.unwrap_or_default(); buffer.push_str(&String::from_utf8_lossy(&chunk)); while let Some(pos) = buffer.find('\n') { let line = buffer[..pos].trim_end_matches('\r').to_string(); buffer = buffer[pos + 1..].to_string(); if line.is_empty() { if ev_type == "cloudevent" && !data.is_empty() { if !id_field.is_empty() { last_id = id_field.clone(); } let ce: Value = serde_json::from_str(&data).unwrap_or(Value::Null); events.push(ce); if events.len() >= max { return (events, last_id); } } ev_type.clear(); data.clear(); id_field.clear(); } else if line.starts_with(':') { } else if let Some(v) = line.strip_prefix("id:") { id_field = v.trim().to_string(); } else if let Some(v) = line.strip_prefix("event:") { ev_type = v.trim().to_string(); } else if let Some(v) = line.strip_prefix("data:") { data = v.trim().to_string(); } } } (events, last_id) } #[tokio::main] async fn main() -> Result<(), Box> { let base = server_url(); let channel = "rust-ce-events-store.reconnect-resume"; let client = Client::new(); // Publish 4 events. println!("Publishing 4 events..."); for i in 1..=4u32 { let event = EventBuilderV10::new() .id(Uuid::new_v4().to_string()) .ty("com.kubemq.examples.eventsstore.stored") .source("urn:kubemq-ce-rust-example") .subject(channel) .data("application/json", json!({"seq": i})) .build()?; let body = serde_json::to_string(&event)?; client.post(format!("{}/ce/send/event-store", base)) .header("Content-Type", "application/cloudevents+json") .body(body).send().await?; } // First connection: StartFromFirst (events_store_type=2), receive 2 events. let first_url = format!( "{}/ce/subscribe/events-store?client_id=rust-es-reconnect&channel={}&events_store_type=2", base, channel ); println!("First connection — receiving 2 events:"); let (first_events, last_id) = subscribe_and_collect(&client, &first_url, None, 2).await; for ce in &first_events { println!(" Received: seq={}", ce["data"]["seq"]); } println!(" Last-Event-ID recorded: {}", last_id); // Reconnect using Last-Event-ID (no events_store_type param). let reconnect_url = format!( "{}/ce/subscribe/events-store?client_id=rust-es-reconnect&channel={}", base, channel ); println!("Reconnecting with Last-Event-ID={}...", last_id); let (second_events, _) = subscribe_and_collect(&client, &reconnect_url, Some(&last_id), 2).await; for ce in &second_events { println!(" Resumed: seq={}", ce["data"]["seq"]); } println!("Reconnect-resume demonstration complete."); Ok(()) } ``` ## Client implementation tips [#client-implementation-tips] * **Standard `EventSource` clients** (browsers, `eventsource` libraries) handle keepalive comments, `event:` dispatch, and `Last-Event-ID` resumption automatically. They cannot, however, set a custom `Last-Event-ID` on the first manual reconnect — use a raw HTTP read for that case, as the JavaScript example does. * **Manual parsers** (the Go, Java, Python, Ruby, Rust, and C# tabs) read the body line by line: an empty line dispatches the current frame, a line starting with `:` is a keepalive to skip, and `id:` / `event:` / `data:` accumulate frame state. Branch on the `event:` value so `cloudevent`, `message`, and `error` frames are each handled. * **Always handle the `error` event.** Treat `stream idle timeout` as a normal lifecycle signal and reconnect (with `Last-Event-ID` for events-store) rather than as a fatal failure. ## Related [#related] # Getting Started (/connectors/cloudevents/tutorials/getting-started) The CloudEvents connector lets you publish and subscribe to KubeMQ messages over plain HTTP using the CNCF [CloudEvents](https://cloudevents.io/) envelope — no KubeMQ SDK required. You `POST` a CloudEvent to `/ce/send/event` and open a long-lived SSE stream on `/ce/subscribe/events` to receive it. This walkthrough takes you from a running server to a verified publish-and-receive round-trip. ## Prerequisites [#prerequisites] * A running **kubemq-server** with the shared HTTP server reachable on **port 9090**. * `curl` (or one of the language clients below) to publish and subscribe. * For the language tabs, a CloudEvents SDK for your runtime (the examples use the official CNCF SDKs, sourced from `.kb/cloud-events/examples`). Confirm the shared HTTP server is live: ```bash curl http://localhost:9090/ready ``` ## Enable / disable [#enable--disable] The CloudEvents connector is **enabled by default**. Start kubemq-server and the `/ce/*` routes are live immediately — there is **no `=true` flag to set**. To **disable** CloudEvents, set its enable variable to `false`: The disable variable name is irregular by design — the config key `Connectors.CE.Enable` snake-cases to `CONNECTORSCE_ENABLE`, with **no underscore** between `CONNECTORS` and `CE`. Older docs show `CONNECTORS_CE_ENABLE`, which does **not** match the live binding. See [Shared HTTP server](/connectors/concepts/shared-http-server) for the full enable model. ## How it works [#how-it-works] A publisher `POST`s a CloudEvent to the connector, which maps it to a KubeMQ event and delivers it to every SSE subscriber on the channel. The subscriber receives it as an `event: cloudevent` SSE frame, reconstructed as a CloudEvent JSON object. *The connector maps the incoming CloudEvent to a KubeMQ event and streams it back to SSE subscribers.* ## Steps [#steps] ### Subscribe to the channel [#subscribe-to-the-channel] Open a long-lived SSE stream on `/ce/subscribe/events`. Pass a `client_id` and the `channel` to subscribe to. Run this in a separate terminal — it stays open and prints each event as it arrives. ```bash curl -N "http://localhost:9090/ce/subscribe/events?client_id=demo-subscriber&channel=notifications" ``` The connector sends a `: keepalive` comment every 30 seconds to hold the connection open; standard EventSource clients ignore it. ### Publish a CloudEvent [#publish-a-cloudevent] Send a CloudEvent in **structured mode** (`Content-Type: application/cloudevents+json`) to `/ce/send/event`. The CloudEvent `subject` becomes the KubeMQ channel, so it must match the channel you subscribed to. A successful send returns **HTTP 202**. The language tabs below each run the complete round-trip from a single program: they open the SSE subscription, publish one CloudEvent, then print the received event. ```bash curl -X POST http://localhost:9090/ce/send/event \ -H "Content-Type: application/cloudevents+json" \ -d '{ "specversion": "1.0", "type": "com.example.greeting", "source": "demo-publisher", "subject": "notifications", "datacontenttype": "application/json", "data": {"message": "Hello, CloudEvents!"} }' ``` ```csharp using CloudNative.CloudEvents; using CloudNative.CloudEvents.SystemTextJson; using System.Net.Http.Headers; using System.Text; using System.Text.Json; var base_ = Environment.GetEnvironmentVariable("KUBEMQ_CE_URL") ?? "http://localhost:9090"; var channel = "csharp-ce-events.basic-pubsub"; var clientId = "kubemq-ce-csharp-example"; var received = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); // Start SSE subscriber. var subscriberTask = Task.Run(async () => { using var httpClient = new HttpClient { Timeout = Timeout.InfiniteTimeSpan }; var sseUrl = $"{base_}/ce/subscribe/events?client_id={clientId}-sub&channel={Uri.EscapeDataString(channel)}"; using var request = new HttpRequestMessage(HttpMethod.Get, sseUrl); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); request.Headers.CacheControl = new CacheControlHeaderValue { NoCache = true }; using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); using var stream = await response.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream, Encoding.UTF8); string? eventType = null, data = null; string? line; while ((line = await reader.ReadLineAsync()) != null) { if (line == "") { if (eventType == "cloudevent" && data != null) { received.TrySetResult(JsonSerializer.Deserialize(data)); return; } eventType = null; data = null; } else if (line.StartsWith(":")) { /* keepalive */ } else if (line.StartsWith("event:")) eventType = line["event:".Length..].Trim(); else if (line.StartsWith("data:")) data = line["data:".Length..].Trim(); } }); // Allow subscription to establish. await Task.Delay(500); // Build and publish CloudEvent (structured mode). var formatter = new JsonEventFormatter(); var cloudEvent = new CloudEvent { Id = Guid.NewGuid().ToString(), Type = "com.kubemq.examples.events.sent", Source = new Uri($"urn:{clientId}"), Subject = channel, DataContentType = "application/json", Data = new { message = "Hello from C# CloudEvents example!" }, }; cloudEvent.SetAttributeFromString("time", DateTimeOffset.UtcNow.ToString("O")); var eventBytes = formatter.EncodeStructuredModeMessage(cloudEvent, out var contentType); using var httpClient = new HttpClient(); using var content = new ByteArrayContent(eventBytes.ToArray()); content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType.ToString()); var resp = await httpClient.PostAsync($"{base_}/ce/send/event", content); var resultJson = await resp.Content.ReadAsStringAsync(); using var resultDoc = JsonDocument.Parse(resultJson); Console.WriteLine($"Published: status={resp.StatusCode} is_error={resultDoc.RootElement.GetProperty("is_error")}"); // Wait for the event. using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var ce = await received.Task.WaitAsync(cts.Token); Console.WriteLine($"Received: type={ce.GetProperty("type")} data={ce.GetProperty("data")}"); ``` ```go package main import ( "bufio" "context" "encoding/json" "fmt" "log" "net/http" "strings" "time" cloudevents "github.com/cloudevents/sdk-go/v2" ) func main() { base := "http://localhost:9090" channel := "go-ce-events.basic-pubsub" clientID := "kubemq-ce-go-example" received := make(chan string, 1) // Start SSE subscriber in background goroutine. go func() { sseURL := fmt.Sprintf("%s/ce/subscribe/events?client_id=%s&channel=%s", base, clientID+"-sub", channel) req, _ := http.NewRequest("GET", sseURL, nil) req.Header.Set("Accept", "text/event-stream") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatal("SSE connect:", err) } defer resp.Body.Close() scanner := bufio.NewScanner(resp.Body) var eventType, data string for scanner.Scan() { line := scanner.Text() if line == "" { if eventType == "cloudevent" && data != "" { received <- data return } eventType, data = "", "" continue } if strings.HasPrefix(line, ":") { continue // keepalive } if strings.HasPrefix(line, "event:") { eventType = strings.TrimSpace(strings.TrimPrefix(line, "event:")) } else if strings.HasPrefix(line, "data:") { data = strings.TrimSpace(strings.TrimPrefix(line, "data:")) } } }() // Allow SSE subscription to establish. time.Sleep(500 * time.Millisecond) // Build and send CloudEvent (structured mode). event := cloudevents.NewEvent() event.SetType("com.kubemq.examples.events.sent") event.SetSource("kubemq-ce-go-example") event.SetSubject(channel) // subject = KubeMQ channel _ = event.SetData(cloudevents.ApplicationJSON, map[string]string{ "message": "Hello from Go CloudEvents example!", }) body, _ := json.Marshal(event) req, _ := http.NewRequest("POST", base+"/ce/send/event", strings.NewReader(string(body))) req.Header.Set("Content-Type", "application/cloudevents+json") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatal("send event:", err) } defer resp.Body.Close() var result map[string]interface{} _ = json.NewDecoder(resp.Body).Decode(&result) fmt.Printf("Published: status=%d is_error=%v\n", resp.StatusCode, result["is_error"]) // Wait for subscriber to receive the event. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() select { case data := <-received: var ce map[string]interface{} _ = json.Unmarshal([]byte(data), &ce) fmt.Printf("Received: type=%v data=%v\n", ce["type"], ce["data"]) case <-ctx.Done(): log.Fatal("Timed out waiting for event") } } ``` ```java import com.fasterxml.jackson.databind.ObjectMapper; import io.cloudevents.CloudEvent; import io.cloudevents.core.builder.CloudEventBuilder; import io.cloudevents.core.format.EventFormat; import io.cloudevents.core.provider.EventFormatProvider; import io.cloudevents.jackson.JsonFormat; import java.io.BufferedReader; import java.io.InputStreamReader; import java.net.HttpURLConnection; import java.net.URI; import java.net.URL; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.OffsetDateTime; import java.util.Map; import java.util.UUID; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; public class Main { static final ObjectMapper MAPPER = new ObjectMapper(); public static void main(String[] args) throws Exception { String base = "http://localhost:9090"; String channel = "java-ce-events.basic-pubsub"; String clientId = "kubemq-ce-java-example"; BlockingQueue received = new ArrayBlockingQueue<>(1); // Start SSE subscriber in background thread. String sseUrl = base + "/ce/subscribe/events?client_id=" + clientId + "-sub&channel=" + channel; Thread subscriber = Thread.ofVirtual().start(() -> { try { HttpURLConnection conn = (HttpURLConnection) new URL(sseUrl).openConnection(); conn.setRequestMethod("GET"); conn.setRequestProperty("Accept", "text/event-stream"); conn.setReadTimeout(15_000); try (BufferedReader reader = new BufferedReader( new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { String line, eventType = null, data = null; while ((line = reader.readLine()) != null) { if (line.isEmpty()) { if ("cloudevent".equals(eventType) && data != null) { received.offer(data); return; } eventType = null; data = null; } else if (line.startsWith(":")) { // keepalive } else if (line.startsWith("event:")) { eventType = line.substring("event:".length()).trim(); } else if (line.startsWith("data:")) { data = line.substring("data:".length()).trim(); } } } } catch (Exception e) { System.err.println("SSE error: " + e.getMessage()); } }); // Allow subscription to establish. Thread.sleep(500); // Build CloudEvent (structured mode). EventFormatProvider.getInstance().registerFormat(new JsonFormat()); EventFormat format = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE); CloudEvent event = CloudEventBuilder.v1() .withId(UUID.randomUUID().toString()) .withType("com.kubemq.examples.events.sent") .withSource(URI.create(clientId)) .withSubject(channel) .withDataContentType("application/json") .withTime(OffsetDateTime.now()) .withData("application/json", MAPPER.writeValueAsBytes(Map.of("message", "Hello from Java CloudEvents example!"))) .build(); byte[] body = format.serialize(event); HttpClient httpClient = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(base + "/ce/send/event")) .POST(HttpRequest.BodyPublishers.ofByteArray(body)) .header("Content-Type", "application/cloudevents+json") .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); Map result = MAPPER.readValue(response.body(), Map.class); System.out.printf("Published: status=%d is_error=%s%n", response.statusCode(), result.get("is_error")); // Wait for event. String data = received.poll(10, TimeUnit.SECONDS); if (data == null) { throw new RuntimeException("Timed out waiting for event"); } Map ce = MAPPER.readValue(data, Map.class); System.out.println("Received: type=" + ce.get("type") + " data=" + ce.get("data")); subscriber.interrupt(); } } ``` ```typescript import { CloudEvent, HTTP } from 'cloudevents'; import EventSource from 'eventsource'; const base = process.env.KUBEMQ_CE_URL ?? 'http://localhost:9090'; const channel = 'js-ce-events.basic-pubsub'; const clientId = 'kubemq-ce-js-example'; function waitForEvent(): Promise> { return new Promise((resolve, reject) => { const sseUrl = `${base}/ce/subscribe/events?client_id=${clientId}-sub&channel=${encodeURIComponent(channel)}`; const es = new EventSource(sseUrl); const timer = setTimeout(() => { es.close(); reject(new Error('Timed out waiting for event')); }, 10_000); es.addEventListener('cloudevent', (evt: MessageEvent) => { clearTimeout(timer); es.close(); resolve(JSON.parse(evt.data) as Record); }); }); } async function main(): Promise { // Start waiting for event (opens SSE stream). const eventPromise = waitForEvent(); // Allow SSE connection to establish. await new Promise((r) => setTimeout(r, 500)); // Build and publish CloudEvent (structured mode). const event = new CloudEvent({ type: 'com.kubemq.examples.events.sent', source: clientId, subject: channel, datacontenttype: 'application/json', data: { message: 'Hello from JavaScript CloudEvents example!' }, }); const message = HTTP.structured(event); const resp = await fetch(`${base}/ce/send/event`, { method: 'POST', headers: message.headers as Record, body: message.body as string, }); const result = await resp.json() as { is_error: boolean }; console.log(`Published: status=${resp.status} is_error=${result.is_error}`); // Wait for subscriber. const received = await eventPromise; console.log(`Received: type=${received.type} data=${JSON.stringify(received.data)}`); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```python import json import threading import time import requests from cloudevents.v1.conversion import to_structured from cloudevents.v1.http import CloudEvent base = "http://localhost:9090" channel = "python-ce-events.basic-pubsub" client_id = "kubemq-ce-python-example" received: list[str] = [] def subscribe() -> None: """Open SSE stream and collect one cloudevent.""" sse_url = ( f"{base}/ce/subscribe/events" f"?client_id={client_id}-sub&channel={channel}" ) with requests.get(sse_url, stream=True, timeout=None, headers={"Accept": "text/event-stream"}) as resp: event_type = "" data = "" for line in resp.iter_lines(decode_unicode=True): if line == "": if event_type == "cloudevent" and data: received.append(data) return event_type = "" data = "" continue if line.startswith(":"): continue # keepalive if line.startswith("event:"): event_type = line[len("event:"):].strip() elif line.startswith("data:"): data = line[len("data:"):].strip() # Start subscriber in background thread. threading.Thread(target=subscribe, daemon=True).start() time.sleep(0.5) # allow SSE connection to establish # Build and send CloudEvent (structured mode). event = CloudEvent( attributes={ "type": "com.kubemq.examples.events.sent", "source": client_id, "subject": channel, "datacontenttype": "application/json", }, data={"message": "Hello from Python CloudEvents example!"}, ) headers, body = to_structured(event) resp = requests.post(f"{base}/ce/send/event", data=body, headers=dict(headers), timeout=10) result = resp.json() print(f"Published: status={resp.status_code} is_error={result.get('is_error')}") # Wait for subscriber. deadline = time.time() + 10 while not received and time.time() < deadline: time.sleep(0.1) if not received: raise TimeoutError("Timed out waiting for event") ce = json.loads(received[0]) print(f"Received: type={ce.get('type')} data={ce.get('data')}") ``` ```ruby require "net/http" require "uri" require "json" require "timeout" require "securerandom" require "cloud_events" base = "http://localhost:9090" channel = "ruby-ce-events.basic-pubsub" client_id = "kubemq-ce-ruby-example" received = Queue.new # SSE subscriber thread. Thread.new do uri = URI("#{base}/ce/subscribe/events?client_id=#{client_id}-sub&channel=#{URI.encode_www_form_component(channel)}") Net::HTTP.start(uri.host, uri.port) do |http| req = Net::HTTP::Get.new(uri) req["Accept"] = "text/event-stream" http.request(req) do |resp| ev_type = nil data = nil resp.read_body do |chunk| chunk.each_line do |line| line.chomp! if line.empty? if ev_type == "cloudevent" && data received.push(data) Thread.exit end ev_type = nil data = nil elsif line.start_with?(":") # keepalive elsif line.start_with?("event:") ev_type = line.sub("event:", "").strip elsif line.start_with?("data:") data = line.sub("data:", "").strip end end end end end end sleep 0.5 # allow subscription to establish # Build and publish CloudEvent (structured mode). sdk = CloudEvents::HttpBinding.default event = CloudEvents::Event::V1.new( id: SecureRandom.uuid, type: "com.kubemq.examples.events.sent", source: URI("urn:#{client_id}"), subject: channel, spec_version: "1.0", data_content_type: CloudEvents::ContentType.new("application/json"), data: JSON.generate({ message: "Hello from Ruby CloudEvents example!" }) ) headers, body = sdk.encode_event(event, structured_format: "json") uri = URI("#{base}/ce/send/event") Net::HTTP.start(uri.host, uri.port) do |http| req = Net::HTTP::Post.new(uri) req["Content-Type"] = headers["Content-Type"] req.body = body res = http.request(req) result = JSON.parse(res.body) puts "Published: status=#{res.code} is_error=#{result['is_error']}" end # Wait for subscriber. data = nil Timeout.timeout(10) { data = received.pop } ce = JSON.parse(data) puts "Received: type=#{ce['type']} data=#{ce['data']}" ``` ```rust use bytes::Bytes; use cloudevents::{EventBuilder, EventBuilderV10}; use futures_util::StreamExt; use reqwest::Client; use serde_json::{json, Value}; use tokio::sync::oneshot; use uuid::Uuid; /// Parse SSE lines and return the data of the first cloudevent. async fn wait_for_cloudevent( mut stream: impl futures_util::Stream> + Unpin, tx: oneshot::Sender, ) { let mut event_type = String::new(); let mut data = String::new(); let mut buffer = String::new(); while let Some(chunk) = stream.next().await { let chunk = match chunk { Ok(c) => c, Err(e) => { eprintln!("SSE read error: {}", e); break; } }; buffer.push_str(&String::from_utf8_lossy(&chunk)); while let Some(pos) = buffer.find('\n') { let line = buffer[..pos].trim_end_matches('\r').to_string(); buffer = buffer[pos + 1..].to_string(); if line.is_empty() { if event_type == "cloudevent" && !data.is_empty() { let _ = tx.send(data.clone()); return; } event_type.clear(); data.clear(); } else if line.starts_with(':') { // keepalive comment — ignore } else if let Some(v) = line.strip_prefix("event:") { event_type = v.trim().to_string(); } else if let Some(v) = line.strip_prefix("data:") { data = v.trim().to_string(); } } } } #[tokio::main] async fn main() -> Result<(), Box> { let base = "http://localhost:9090"; let channel = "rust-ce-events.basic-pubsub"; let client_id = "kubemq-ce-rust-example"; let client = Client::new(); // Start SSE subscriber. let (tx, rx) = oneshot::channel::(); let sub_url = format!( "{}/ce/subscribe/events?client_id={}-sub&channel={}", base, client_id, channel ); let sub_client = client.clone(); tokio::spawn(async move { let stream = sub_client .get(&sub_url) .header("Accept", "text/event-stream") .send() .await .expect("SSE connect failed") .bytes_stream(); wait_for_cloudevent(stream, tx).await; }); // Allow SSE to establish. tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; // Build CloudEvent (structured mode using cloudevents-sdk). let event = EventBuilderV10::new() .id(Uuid::new_v4().to_string()) .ty("com.kubemq.examples.events.sent") .source(format!("urn:{}", client_id)) .subject(channel) .data( "application/json", json!({"message": "Hello from Rust CloudEvents example!"}), ) .build()?; // Serialize to structured mode JSON. let body = serde_json::to_string(&event)?; let resp = client .post(format!("{}/ce/send/event", base)) .header("Content-Type", "application/cloudevents+json") .body(body) .send() .await?; let result: Value = resp.json().await?; println!("Published: status=202 is_error={}", result["is_error"]); // Wait for received event. let data = tokio::time::timeout(tokio::time::Duration::from_secs(10), rx) .await .expect("Timed out waiting for event") .expect("Channel closed"); let ce: Value = serde_json::from_str(&data)?; println!("Received: type={} data={}", ce["type"], ce["data"]); Ok(()) } ``` ### Verify the round-trip [#verify-the-round-trip] The subscriber terminal from step 1 prints the event as an `event: cloudevent` SSE frame. Because the message carried `ce_*` tags, the connector reconstructs it as a CloudEvent JSON object on the way out: ```text event: cloudevent data: {"specversion":"1.0","type":"com.example.greeting","source":"demo-publisher","id":"550e8400-e29b-41d4-a716-446655440000","subject":"notifications","time":"2026-06-08T10:30:00Z","data":{"message":"Hello, CloudEvents!"}} ``` The publish call returns `HTTP 202` with `is_error: false`, and the subscriber receives the event with `id` and `time` auto-generated by the connector. That confirms a successful publish-and-receive round-trip through the CloudEvents connector. ## What's next [#whats-next] # CE ↔ KubeMQ Mapping (/connectors/cloudevents/reference/ce-to-kubemq-mapping) The CloudEvents connector is a faithful, round-tripping bridge: every CloudEvent attribute becomes a prefixed KubeMQ tag on the way in, and is reconstructed back into a CloudEvent on the way out. This page is the authoritative reference for that mapping — the attribute table, channel and `ClientID` resolution, how outbound messages are detected as CloudEvents, the `data` vs `data_base64` rule, and the error surface. ## Attribute mapping [#attribute-mapping] When a CloudEvent is received, each of its attributes is stored as a KubeMQ message tag with a `ce_` prefix. The `ce_` prefix is what makes the mapping reversible: any subscriber — including [CESQL routing](/connectors/cloudevents/how-to/cesql-routing) — can read the original CloudEvent attributes off the message tags. | CloudEvent attribute | KubeMQ tag key | Required | Notes | | -------------------- | -------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------- | | `specversion` | `ce_specversion` | Yes | Always `"1.0"`. Its presence is the marker the connector uses to detect a CloudEvent on outbound delivery. | | `type` | `ce_type` | Yes | Application-defined event type. | | `source` | `ce_source` | Yes | Also used as the KubeMQ `ClientID` (see [ClientID resolution](#clientid-resolution)). | | `id` | `ce_id` | Yes (auto-generated) | Also used as the `EventID` / `RequestID` / `MessageID`. | | `subject` | `ce_subject` | No | Primary [channel resolution](#channel-resolution) source. | | `time` | `ce_time` | No (auto-generated) | RFC3339Nano format. | | `datacontenttype` | `ce_datacontenttype` | No | e.g. `application/json`. | | `dataschema` | `ce_dataschema` | No | URI of the data schema. | | *(any extension)* | `ce_{name}` | No | Extension attributes get the same `ce_` prefix. | Extension attributes follow the same convention: `{"myextension": "value"}` is stored as the `ce_myextension` tag and reconstructed as `{"myextension": "value"}` on delivery. ## Auto-generation [#auto-generation] The connector fills in missing optional attributes **before** validation, so a minimal CloudEvent with only `type` and `source` is accepted: | Attribute | When missing | Generated value | | --------- | ------------ | ----------------------------------------- | | `id` | Empty | A new UUID v4. | | `time` | Zero | The current UTC time, RFC3339Nano format. | Because `id` is always populated, every accepted CloudEvent has a stable identifier for correlation and replay. ## Channel resolution [#channel-resolution] The KubeMQ destination channel is resolved in priority order: 1. **CE `subject` attribute** — if the CloudEvent carries a `subject`, it is the channel name. 2. **`?channel=` query parameter** — used only when no `subject` is set. If neither is provided, the request is rejected with **HTTP 400** (`channel is required`). ```bash # subject sets the channel curl -X POST http://localhost:9090/ce/send/event \ -H "Content-Type: application/cloudevents+json" \ -d '{"specversion":"1.0","type":"com.example.order.created","source":"order-service","subject":"orders","data":{"id":"123"}}' # or the query parameter sets it (no subject) curl -X POST "http://localhost:9090/ce/send/event?channel=orders" \ -H "Content-Type: application/cloudevents+json" \ -d '{"specversion":"1.0","type":"com.example.order.created","source":"order-service","data":{"id":"123"}}' ``` See [Channel resolution](/connectors/cloudevents/how-to/channel-resolution) for the full priority rules and SSE subscription behavior. ## ClientID resolution [#clientid-resolution] The KubeMQ `ClientID` attached to the message is determined as follows: 1. **Auth claims** — when [authentication](/connectors/cloudevents/how-to/authentication) is enabled and the request carries valid credentials, the authenticated `ClientID` from the JWT claims overrides everything else. 2. **CE `source` attribute** — used as the `ClientID` when auth is off or the claim is `anonymous`. This means the CloudEvent `source` is the effective identity for unauthenticated traffic, while authenticated traffic always carries its verified identity regardless of what `source` says. ## Outbound CE detection [#outbound-ce-detection] When delivering a message to a subscriber — over [SSE](/connectors/cloudevents/how-to/sse-behavior) or a [queue receive](/connectors/cloudevents/how-to/queues) — the connector inspects the message tags for `ce_specversion`: | Condition | Delivered as | SSE `event:` type | | ---------------------------- | -------------------------------------------------------------------------------------- | ----------------- | | `ce_specversion` **present** | A reconstructed CloudEvent JSON object with standard CE attributes. | `cloudevent` | | `ce_specversion` **absent** | A plain JSON object with KubeMQ-native fields (`channel`, `metadata`, `tags`, `data`). | `message` | Because detection is per-message, a single channel can carry **mixed** CloudEvent and non-CloudEvent traffic — each frame is shaped according to its own tags. Clients subscribing to such a channel should handle both `cloudevent` and `message` event types. ## data vs data\_base64 [#data-vs-data_base64] When reconstructing a CloudEvent for outbound delivery, the connector inspects the message body: | Body | Carried in | Encoding | | ------------------------------------------ | ------------- | ---------------------- | | Valid JSON | `data` | Inline JSON object. | | Binary or non-JSON (plain text, raw bytes) | `data_base64` | Base64-encoded string. | This follows the CloudEvents JSON format specification, where `data_base64` is the standard carrier for non-JSON payloads. ## CESQL attribute names [#cesql-attribute-names] [CESQL routing](/connectors/cloudevents/how-to/cesql-routing) expressions reference CloudEvent attribute names **without** the `ce_` prefix, even though the underlying KubeMQ tags carry it: ```sql type = 'com.example.order.created' -- matches the ce_type tag source = 'order-service' -- matches the ce_source tag ``` The router constructs a lightweight CloudEvent from the `ce_*` tags at evaluation time, so any message with `ce_*` tags — regardless of which connector produced it — is eligible for CESQL matching. ## Error codes [#error-codes] All CloudEvents endpoints return a single response envelope. Errors use the same shape with `is_error: true`: ```json { "is_error": true, "message": "descriptive error message", "data": null } ``` ### HTTP status codes [#http-status-codes] | Status | Meaning | When | | ------ | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | 200 | OK | Query response, queue receive, queue ack\_all. | | 202 | Accepted | Event send, event-store send, command, queue send, response send. | | 400 | Bad Request | Invalid CloudEvent (including unrecognized Content-Type), missing required parameters, validation failure, reserved channel name, subscription error. | | 429 | Too Many Requests | SSE connection limit (`MaxSSEConnections`) exceeded. | | 500 | Internal Server Error | Backend messaging error or SSE setup failure. | | 504 | Gateway Timeout | A synchronous request exceeded `TimeoutSeconds`. | ### Common error messages [#common-error-messages] | Message | Cause | Resolution | | --------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------- | | `channel is required` | No `subject` attribute and no `?channel=` parameter. | Set the CloudEvent `subject` attribute or pass `?channel=`. | | `invalid CloudEvent` | Malformed CE JSON or a missing required attribute. | Ensure `specversion`, `type`, and `source` are present. | | `stream idle timeout` | No messages delivered for `MaxSSEIdleSeconds`. | Reconnect; consider lowering `MaxSSEIdleSeconds`. | | `context deadline exceeded` | Request exceeded `TimeoutSeconds`. | Increase the timeout or ensure a responder is active. | | `connection limit exceeded` | `MaxSSEConnections` reached. | Raise the limit or reduce concurrent SSE connections. | | `reserved channel name` | Channel name conflicts with an internal KubeMQ channel (e.g. the `_AGENTS_.` prefix). | Use a different channel name. | ## Related [#related] # Configuration (/connectors/cloudevents/reference/configuration) This is the full field reference for the CloudEvents connector's `Connectors.CE` config section — every field, its validation rules, and how to set them via TOML, environment variables, or Docker. For the enabled-by-default model and how config keys map to environment variable names, see the [configuration model](../concepts/configuration-model). ## Config fields [#config-fields] All fields live under `[Connectors.CE]`. Defaults are taken verbatim from the server's `CeConfig` struct. ## Validation rules [#validation-rules] Validation is **skipped entirely when `Enable` is `false`**. When the connector is enabled, the server rejects an invalid config at startup with these rules: | Field | Rule | | ------------------- | -------------------------------------------- | | `TimeoutSeconds` | must be greater than `0` | | `SubBuffSize` | must be greater than `0` and at most `10000` | | `MaxSSEIdleSeconds` | must be greater than `0` | | `MaxSSEConnections` | must be greater than or equal to `0` | ## Configuring the connector [#configuring-the-connector] The same five settings can be supplied through a TOML config file, environment variables, or `docker run` flags. Pick whichever fits your deployment. ```toml title="config.toml" [Connectors.CE] Enable = true TimeoutSeconds = 60 SubBuffSize = 100 MaxSSEIdleSeconds = 300 MaxSSEConnections = 0 ``` ```bash title="cloudevents.env" CONNECTORSCE_ENABLE=true CONNECTORSCE_TIMEOUT_SECONDS=60 CONNECTORSCE_SUB_BUFF_SIZE=100 CONNECTORSCE_MAX_SSE_IDLE_SECONDS=300 CONNECTORSCE_MAX_SSE_CONNECTIONS=0 ``` The connector is already enabled, so the Docker example overrides only the SSE buffer and a connection cap. There is no `-e CONNECTORSCE_ENABLE=true` — that would be redundant. Set `CONNECTORSCE_ENABLE=false` only when you want to turn the connector off. ### Environment variable names [#environment-variable-names] Each variable below is derived from its dotted config key by the server's standard env-var transform (see [Environment variable names](../concepts/configuration-model#environment-variable-names) for the derivation rule — including why the enable variable is `CONNECTORSCE_ENABLE`, not `CONNECTORS_CE_ENABLE`). | Config key | Environment variable | | --------------------------------- | ----------------------------------- | | `Connectors.CE.Enable` | `CONNECTORSCE_ENABLE` | | `Connectors.CE.TimeoutSeconds` | `CONNECTORSCE_TIMEOUT_SECONDS` | | `Connectors.CE.SubBuffSize` | `CONNECTORSCE_SUB_BUFF_SIZE` | | `Connectors.CE.MaxSSEIdleSeconds` | `CONNECTORSCE_MAX_SSE_IDLE_SECONDS` | | `Connectors.CE.MaxSSEConnections` | `CONNECTORSCE_MAX_SSE_CONNECTIONS` | The CloudEvents connector shares the HTTP server's port (`9090`), body limit, CORS, and TLS settings. Those are configured under `Connectors.Http` and documented once in [Shared HTTP server](/connectors/concepts/shared-http-server) — not repeated here. For the conceptual explanation of the enable model, see the [configuration model](../concepts/configuration-model). ## Verifying the connector is live [#verifying-the-connector-is-live] Because the connector is enabled by default, you can confirm it is serving as soon as the server is up — send a CloudEvent and watch for an HTTP `202`. ```bash curl -i -X POST http://localhost:9090/ce/send/event \ -H 'Content-Type: application/cloudevents+json' \ -d '{ "specversion": "1.0", "type": "com.example.healthcheck", "source": "config-check", "id": "check-1", "subject": "diagnostics", "data": {"ok": true} }' ``` ```bash # Point the example client at your server, then run the basic pub/sub sample. export KUBEMQ_CE_URL=http://localhost:9090 go run ./examples/go/events/basic-pubsub/main.go ``` ```bash export KUBEMQ_CE_URL=http://localhost:9090 python examples/python/events/basic_pubsub.py ``` ```bash export KUBEMQ_CE_URL=http://localhost:9090 node examples/javascript/events/basic-pubsub.js ``` ```bash export KUBEMQ_CE_URL=http://localhost:9090 mvn -q exec:java -Dexec.mainClass=com.kubemq.ce.events.BasicPubSub ``` ```bash export KUBEMQ_CE_URL=http://localhost:9090 dotnet run --project examples/csharp/Events/BasicPubSub ``` ```bash export KUBEMQ_CE_URL=http://localhost:9090 ruby examples/ruby/events/basic_pubsub.rb ``` ```bash KUBEMQ_CE_URL=http://localhost:9090 cargo run -p basic-pubsub ``` Every CloudEvents example client reads the server base URL from the `KUBEMQ_CE_URL` environment variable, defaulting to `http://localhost:9090`. Override it to point at a remote or clustered KubeMQ deployment. ## Related [#related] # Endpoints (/connectors/cloudevents/reference/endpoints) The CloudEvents connector exposes its full surface on the shared HTTP server (default port **9090**) and is **enabled by default**. This page is the authoritative reference for every endpoint: the send and queue operations (`POST`), the SSE subscriptions (`GET`), their request bodies, success status codes, and query parameters. To disable the connector, set `CONNECTORSCE_ENABLE=false` — see [Configuration](/connectors/cloudevents/concepts/configuration-model). ## Base URL [#base-url] All endpoints are served from the shared HTTP server: ```text http://localhost:9090 ``` The port is inherited from the REST transport (default `9090`). See the [shared HTTP server](/connectors/concepts/shared-http-server) page for the middleware chain, body limits, and SSE handling that apply to every route below. ## Endpoint table [#endpoint-table] The connector groups its 12 endpoints into three families — **send** (synchronous operations across the five messaging patterns), **queue** (durable-queue control), and **subscribe** (long-lived SSE streams). | Method | Path | Purpose | Request body | Success status | | ------ | ---------------------------- | --------------------------------------- | --------------------------------- | ----------------------- | | POST | `/ce/send/event` | Fire-and-forget pub/sub event | CloudEvent (structured or binary) | 202 Accepted | | POST | `/ce/send/event-store` | Persistent, replayable event | CloudEvent (structured or binary) | 202 Accepted | | POST | `/ce/send/command` | Command (execution confirmation) | CloudEvent (structured or binary) | 202 Accepted | | POST | `/ce/send/query` | Query (data response) | CloudEvent (structured or binary) | 200 OK | | POST | `/ce/send/response` | Response to a command/query | CloudEvent + `?request_id=` | 202 Accepted | | POST | `/ce/queue/send` | Send a message to a queue | CloudEvent (structured or binary) | 202 Accepted | | POST | `/ce/queue/receive` | Receive (or peek) queue messages | None (body ignored) | 200 OK | | POST | `/ce/queue/ack_all` | Acknowledge all pending queue messages | None (body ignored) | 200 OK | | GET | `/ce/subscribe/events` | SSE pub/sub subscription | — | 200 `text/event-stream` | | GET | `/ce/subscribe/events-store` | SSE persistent subscription with replay | — | 200 `text/event-stream` | | GET | `/ce/subscribe/commands` | SSE command subscription | — | 200 `text/event-stream` | | GET | `/ce/subscribe/queries` | SSE query subscription | — | 200 `text/event-stream` | The two success statuses are deliberate: `/ce/send/query` and the queue receive/ack operations return **200 OK** because they carry a synchronous payload, while the fire-and-forget and command sends return **202 Accepted**. All SSE endpoints return **200** with `Content-Type: text/event-stream`. ## Send endpoints [#send-endpoints] All five send endpoints accept a CloudEvent in either content mode — structured (`application/cloudevents+json` body) or binary (`ce-*` headers + raw data body). See [Content modes](/connectors/cloudevents/how-to/content-modes) for the wire format, and [Channel resolution](/connectors/cloudevents/how-to/channel-resolution) for how the destination channel is derived from `subject` or `?channel=`. | Endpoint | Pattern | Blocks for a reply | Notes | | ---------------------- | ------------------- | ------------------ | --------------------------------------------------------------------------- | | `/ce/send/event` | Events | No | Pub/sub fan-out; returns the send result in `data`. | | `/ce/send/event-store` | Events Store | No | Persisted and replayable; same shape as `/ce/send/event`. | | `/ce/send/command` | Command | Yes | Waits for execution confirmation; timeout from `TimeoutSeconds`. | | `/ce/send/query` | Query | Yes | Waits for a data response (returns **200**); timeout from `TimeoutSeconds`. | | `/ce/send/response` | Command/Query reply | No | Requires `?request_id=`; `subject` is the reply channel. | The `/ce/send/response` endpoint is the only send endpoint with a required query parameter: | Parameter | Type | Required | Description | | ------------ | ------ | -------- | --------------------------------------------------------------------- | | `request_id` | string | Yes | The request ID from the received command/query, used for correlation. | Synchronous send endpoints enforce `TimeoutSeconds` (default 60s). A request that exceeds the timeout returns **HTTP 504**. SSE subscriptions are long-lived and are not subject to this timeout. ```bash # Send a fire-and-forget event (structured mode) curl -X POST http://localhost:9090/ce/send/event \ -H "Content-Type: application/cloudevents+json" \ -d '{ "specversion": "1.0", "type": "com.example.order.created", "source": "order-service", "subject": "notifications", "data": {"order_id": "12345", "amount": 99.99} }' ``` ## Queue endpoints [#queue-endpoints] The queue family adds durable, at-least-once delivery. `send` carries a CloudEvent body; `receive` and `ack_all` are **control operations** — the request body is ignored and all inputs come from query parameters. ### POST /ce/queue/send [#post-cequeuesend] Send a CloudEvent to a queue channel. Returns **202 Accepted** with the send result in `data`. ### POST /ce/queue/receive [#post-cequeuereceive] Receive (or peek at) messages from a queue. Returns **200 OK** with the received messages; each message carrying `ce_*` tags is reconstructed as a CloudEvent, others are returned in native KubeMQ form. | Parameter | Type | Required | Default | Description | | -------------- | ------ | -------- | ------- | ------------------------------------------------------------------- | | `channel` | string | Yes | — | Queue channel name. | | `client_id` | string | Yes | — | Client identifier (overridden by auth claims when auth is enabled). | | `max_messages` | int | No | `1` | Maximum messages to receive (1–1000). | | `wait_timeout` | int | No | `5` | Wait timeout in seconds. | | `is_peek` | bool | No | `false` | Peek at messages without consuming them. | ### POST /ce/queue/ack\_all [#post-cequeueack_all] Acknowledge (drain) all pending messages in a queue. Returns **200 OK** with the ack result in `data`. | Parameter | Type | Required | Default | Description | | -------------- | ------ | -------- | ------- | ------------------------------------------------------------------- | | `channel` | string | Yes | — | Queue channel name. | | `client_id` | string | Yes | — | Client identifier (overridden by auth claims when auth is enabled). | | `wait_timeout` | int | No | `5` | Wait timeout in seconds. | ```bash # Receive up to 10 messages, waiting up to 10 seconds curl -X POST "http://localhost:9090/ce/queue/receive?channel=work-queue&client_id=worker-1&max_messages=10&wait_timeout=10" ``` See [Queues](/connectors/cloudevents/how-to/queues) for the full send/receive walkthrough. ## Subscribe endpoints (SSE) [#subscribe-endpoints-sse] All four subscription endpoints are long-lived `GET` requests that respond with `Content-Type: text/event-stream` and **HTTP 200**. They have no request body — the subscription is configured entirely through query parameters. See [SSE behavior](/connectors/cloudevents/how-to/sse-behavior) for the frame format, keepalive, idle timeout, and reconnection semantics. | Endpoint | Pattern | Replay support | | ---------------------------- | ------------ | ------------------------------------------------------------------- | | `/ce/subscribe/events` | Events | No (non-persistent). | | `/ce/subscribe/events-store` | Events Store | Yes — `events_store_type`/`events_store_value` and `Last-Event-ID`. | | `/ce/subscribe/commands` | Command | No; frames include request-ID/reply-channel fields. | | `/ce/subscribe/queries` | Query | No; frames include request-ID/reply-channel fields. | ### Common SSE query parameters [#common-sse-query-parameters] All four endpoints share these base parameters: | Parameter | Type | Required | Description | | ----------- | ------ | -------- | ------------------------------------------------------------------- | | `client_id` | string | Yes | Client identifier (overridden by auth claims when auth is enabled). | | `channel` | string | Yes | Channel to subscribe to. | | `group` | string | No | Load-balancing group name. | ### Events-store replay parameters [#events-store-replay-parameters] `/ce/subscribe/events-store` accepts two additional parameters that select the replay start position: | Parameter | Type | Default | Description | | -------------------- | ----- | ------------------ | -------------------------------------------------------- | | `events_store_type` | int | `1` (StartNewOnly) | Start position (1–6); see the table below. | | `events_store_value` | int64 | `0` | Value for sequence/time-based positions (types 4, 5, 6). | | Value | Name | Description | | ----- | ---------------- | ----------------------------------------------------------------- | | 1 | StartNewOnly | Only new messages from this point forward. | | 2 | StartFromFirst | Replay from the first stored message. | | 3 | StartFromLast | Start from the last stored message. | | 4 | StartAtSequence | Start at a specific sequence number (`events_store_value`). | | 5 | StartAtTime | Start at a Unix timestamp in seconds (`events_store_value`). | | 6 | StartAtTimeDelta | Start at a time delta in seconds from now (`events_store_value`). | For events-store subscriptions, the server emits an `id:` field (the message sequence) on each frame. Reconnecting with the `Last-Event-ID` header resumes from `sequence + 1`. If both `Last-Event-ID` and `events_store_type` are present, the query parameter wins — omit `events_store_type` to use header-based reconnection. ### SSE connection limits [#sse-connection-limits] | Behavior | Setting | Status when exceeded | | -------------- | ------------------------------------------- | --------------------------------------------------------- | | Idle timeout | `MaxSSEIdleSeconds` (default 300s) | `error` event (`stream idle timeout`), connection closed. | | Connection cap | `MaxSSEConnections` (default 0 = unlimited) | **HTTP 429** Too Many Requests. | ```bash # Subscribe to events curl -N "http://localhost:9090/ce/subscribe/events?client_id=my-client&channel=notifications" # Subscribe to events-store, replaying from the first stored message curl -N "http://localhost:9090/ce/subscribe/events-store?client_id=my-client&channel=audit-log&events_store_type=2" # Reconnect to events-store, resuming after sequence 42 curl -N -H "Last-Event-ID: 42" \ "http://localhost:9090/ce/subscribe/events-store?client_id=my-client&channel=audit-log" ``` ## Response envelope [#response-envelope] Every endpoint returns the same response envelope. Successful responses carry the operation result in `data`; errors set `is_error` to `true` with a descriptive `message`: ```json { "is_error": false, "message": "OK", "data": { } } ``` ## Status codes [#status-codes] | Status | Meaning | When | | ------ | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | 200 | OK | Query response, queue receive, queue ack\_all, and all SSE streams. | | 202 | Accepted | Event send, event-store send, command send, queue send, response send. | | 400 | Bad Request | Invalid CloudEvent (including unrecognized Content-Type), missing required parameters, validation failure, reserved channel name, subscription error. | | 429 | Too Many Requests | SSE connection limit (`MaxSSEConnections`) exceeded. | | 500 | Internal Server Error | Backend messaging error or SSE setup failure. | | 504 | Gateway Timeout | A synchronous request exceeded `TimeoutSeconds`. | The full error message catalog lives in [CE ↔ KubeMQ mapping](/connectors/cloudevents/reference/ce-to-kubemq-mapping#error-codes). ## Related [#related] # Architecture (/connectors/gcp-pub-sub/concepts/architecture) The KubeMQ **Google Cloud Pub/Sub connector** is an embedded, wire-protocol bridge inside kubemq-server that speaks the genuine Pub/Sub v1 **gRPC** services on a dedicated gRPC listener (default TCP **8085**, the Pub/Sub emulator convention). The connector is **opt-in (disabled by default)** — enable it with `CONNECTORS_GCP_ENABLE=true` (Docker) or `spec.gcp.enabled: true` (Kubernetes). It runs in **emulator mode**: no authentication, no TLS, insecure gRPC — exactly like Google's local emulator. Any standard Pub/Sub client connects to it by setting one environment variable, `PUBSUB_EMULATOR_HOST` — no code changes, no library swap, no emulator to install. Two KubeMQ primitives back the model: * **A topic** maps onto a native KubeMQ **Events Store** log `gcp.{topic}` — the authoritative, cross-protocol, replayable source of truth. * **A subscription** maps onto a native KubeMQ **Queue** channel `gcp.sub.{subscription}` — one filtered copy per subscription. A publish is written **once** to the topic log, then fanned out to one Queue copy per subscription. The connector touches the **Events Store** and **Queue** primitives only — it does **not** map onto KubeMQ's commands / queries / events RPC patterns, and there is **no request/reply** anywhere. ## The gRPC emulator listener [#the-grpc-emulator-listener] A single gRPC server on `Connectors.Gcp.Port` (default **8085**) implements the real Pub/Sub v1 wire protocol — **38 RPCs** across four services. Each request passes a fixed three-stage interceptor chain — **Recovery → Logger → Traffic-gate** — and there is **no auth interceptor** (emulator mode): | Service | RPCs | What it covers | | -------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------ | | `google.pubsub.v1.Publisher` | 9 | Topics and publish (`CreateTopic`, batch `Publish`, `UpdateTopic`, `DeleteTopic`, listing). | | `google.pubsub.v1.Subscriber` | 16 | Subscriptions, `Pull` / `StreamingPull`, `Acknowledge`, `ModifyAckDeadline`, push config, snapshots, `Seek`. | | `google.pubsub.v1.SchemaService` | 10 | Avro and Protobuf schema definitions, revisions, and validation. | | `google.iam.v1.IAMPolicy` | 3 | Permissive stubs — no enforcement (emulator parity). | The **Traffic-gate** interceptor short-circuits requests with transient `UNAVAILABLE` while the broker is not ready; on a not-ready → ready transition the connector **drops all in-memory leases** (their downstream transactions are dead) and the poller rebuilds. SDKs see the transient `UNAVAILABLE` and retry. A nil auth seam is reserved so a future release can add token validation without touching handlers. See [Capabilities](/connectors/gcp-pub-sub/reference/capabilities) for the full RPC matrix. ## How topics & subscriptions map to KubeMQ [#how-topics--subscriptions-map-to-kubemq] A request arrives at the gRPC listener; the handler parses the resource, dispatches to the matching service, and lands on a KubeMQ primitive. A `Publish` writes once to the topic's Events Store log and fans out one Queue copy per bound subscription, applying each subscription's filter. *A publish writes once to the Events Store log `gcp.{topic}` and fans out one filtered Queue copy per subscription on `gcp.sub.{subscription}`, all backed by the message broker. Snapshots and schemas are registry records with no channel.* The channel mapping is the single most important mental model: | Concept | Behavior | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Listener** | A dedicated gRPC server on `Connectors.Gcp.Port` (default **8085**). Insecure gRPC, no auth, no TLS — the emulator contract. | | **Topic → Events Store log** | Topic `projects/{p}/topics/{t}` ↔ native KubeMQ **Events Store** log `gcp.{t}`. A publish writes **once** here (`Array.SendEventsStore`) — the authoritative, replayable, cross-protocol source. | | **Subscription → Queue** | Subscription `projects/{p}/subscriptions/{s}` ↔ native KubeMQ **Queue** channel `gcp.sub.{s}`. One Queue copy is fanned out per subscription (`Array.SendQueueMessage`), applying that subscription's filter. | | **Ordering-key channel** | A keyed message fans onto its own channel `gcp.sub.{sub}.k.{enc(key)}` (sha256 fallback `gcp.sub.{sub}.h.{hash}`) for at-most-one-in-flight per key. | | **Snapshots / schemas** | A per-node replicated registry record — no native channel. | | **Project segment** | `{p}` is parsed and validated but **ignored** — the connector is single-tenant, so resource ids are global across projects. | | **Reserved namespace** | Topic ids may **not** start with `sub.` — it collides with the `gcp.sub.*` subscription-queue namespace. | See [Channel mapping](/connectors/gcp-pub-sub/reference/channel-mapping) for the full grammar and [Configuration](/connectors/gcp-pub-sub/concepts/configuration) for the connector knobs. ## Publish once, then fan out [#publish-once-then-fan-out] A `Publish` is the heart of the model and follows a strict order: 1. **Validate the whole batch before enqueuing anything** (atomicity): batch size 1..1000; per message — total ≤ 10 MiB, ≤ 100 attributes, attribute key ≤ 256 B (no `goog` prefix), attribute value ≤ 1024 B, ordering key ≤ 1024 B, and `data` **or** `attributes` non-empty. Any failure rejects the entire batch with `INVALID_ARGUMENT` and nothing is published. 2. **Schema enforcement** — if the topic references a schema, every message is validated against it; the whole batch is rejected on the first non-conforming message. 3. **Topic-log write** — each message is written once to the Events Store log `gcp.{topic}` (`Array.SendEventsStore`), assigning a server message id and publish time. This single record is the authoritative, cross-protocol, replayable copy and the source for `Seek`. 4. **Fan-out** — one Queue copy per subscription (`Array.SendQueueMessage`), **applying each subscription's filter**; a filtered-out message is never enqueued. Detached subscriptions are skipped. Delivery is then driven from each subscription's queue: every delivered message gets an opaque `ack_id` under an ack-deadline lease; a 250 ms sweeper expires overdue leases, applies retry backoff, and redelivers — or dead-letters once the receive count exceeds the policy. See [Publishing](/connectors/gcp-pub-sub/how-to/publishing) and [Subscribing](/connectors/gcp-pub-sub/how-to/subscribing). ## Reserved tags & project segment [#reserved-tags--project-segment] A `PubsubMessage` becomes a KubeMQ message whose body is `data` and whose tags are the message `attributes` plus **three reserved tags** carried across the wire: | Reserved tag | Carries | | ---------------------- | ------------------------------- | | `_pubsub_message_id` | The server-assigned message id. | | `_pubsub_publish_time` | The publish timestamp. | | `_pubsub_ordering_key` | The ordering key (when set). | Native KubeMQ consumers **see** these tags; they are **stripped from `attributes`** when a message is delivered back to a Pub/Sub client. The `{p}` project segment is parsed and validated but ignored (single-tenant), and the `sub.` topic-id prefix is reserved. ## Cross-protocol interop [#cross-protocol-interop] Because every topic is a normal KubeMQ Events Store log, a Pub/Sub `Publish` to topic `orders` (written to `gcp.orders`) is consumable by a **native** KubeMQ Events Store subscriber on the same channel — and the native side sees the three reserved `_pubsub_*` tags that are stripped for Pub/Sub clients. This lets you migrate one side at a time, or run Pub/Sub-SDK producers alongside native KubeMQ consumers. *A Pub/Sub publish lands on the Events Store log `gcp.orders`; a native KubeMQ Events Store subscriber reads the same record, including the reserved `_pubsub_*` tags.* Topic, subscription, snapshot, and schema **records** are synchronized across cluster nodes with a **last-writer-wins** rule (a per-node replicated registry). Message **data** itself rides the existing Events Store / Queues replication. Exactly-once tokens and `StreamingPull` leases are **node-local** — pin an exactly-once subscription's `StreamingPull` to one node, or accept at-least-once across nodes. See [Reliability](/connectors/gcp-pub-sub/how-to/reliability). ## Related [#related] # Configuration (/connectors/gcp-pub-sub/concepts/configuration) The Pub/Sub connector is configured server-side under the `Connectors.Gcp` block of the KubeMQ server config, exposed as **thirteen `CONNECTORS_GCP_*` environment variables**. The connector is **opt-in (disabled by default)** — a stock kubemq-server does not bind gRPC port 8085 until you enable it. The only thing **clients** configure is the emulator host via the standard `PUBSUB_EMULATOR_HOST` environment variable (default `localhost:8085`) plus any `PUBSUB_PROJECT_ID`. Everything below is broker-side server configuration. ## Enable the connector [#enable-the-connector] Enable the connector with its enable variable: To turn it **off** again, set `CONNECTORS_GCP_ENABLE=false`. Setting `CONNECTORS_GCP_ENABLE=false` closes port 8085 and skips the connector entirely — a config-only rollback with no data migration. The enable variable carries the underscore in its prefix (`CONNECTORS_GCP_*`), and the port **must differ** from the server's gRPC/REST/HTTP and AWS-connector ports — a collision aborts startup. ## Security posture [#security-posture] **The connector runs in emulator mode: no authentication, no TLS, insecure gRPC — by design.** There is no Google OAuth2/JWT validation, no IAM enforcement (the IAM RPCs are permissive stubs), and no per-connector TLS option. **Do not expose port 8085 to untrusted networks.** TLS is provided by the server-wide `Security` block, not by a Pub/Sub-specific setting. DoS guards stay active regardless — the message-size cap, `MaxInflightPerSubscription`, `MaxConcurrentPolls`, `MaxSeekReplay`, and push backoff. See [Auth & security](/connectors/reference/auth-and-security) and [Connectivity & emulator mode](/connectors/gcp-pub-sub/how-to/connectivity-and-emulator-mode). ## Configuring the connector [#configuring-the-connector] The same settings can be supplied through a TOML config file, environment variables, or `docker run` flags. Every environment variable uses the `CONNECTORS_GCP_` prefix (with the underscore between `CONNECTORS` and `GCP`). For the full field-by-field table and copy-paste TOML/env/Docker examples, see [Configuration reference](../reference/configuration). ## Related [#related] # Cross-Protocol Interop (/connectors/gcp-pub-sub/concepts/cross-protocol-interop) Because every Pub/Sub **topic** is a normal KubeMQ **Events Store** log (`gcp.{topic}`), a Google Pub/Sub application and a native KubeMQ gRPC/REST client can work the **same** stream. A message published by `google-cloud-pubsub` to topic `orders` is consumable by a native KubeMQ Events Store subscriber on channel `gcp.orders` — carrying the connector's reserved `_pubsub_*` tags across the wire. This lets you **bridge a legacy native consumer during a migration**, or run Pub/Sub producers alongside native KubeMQ consumers without changing either side's protocol. ## Overview [#overview] A `Publish` writes the message **once** to the topic's Events Store log `gcp.{topic}` — the authoritative, replayable source — before fanning out per-subscription queue copies. The native side reads that topic log directly: there is no subscription on the native path, just an Events Store subscribe on `gcp.{topic}`. The Pub/Sub side speaks the v1 gRPC wire protocol to the connector (port 8085); the native side speaks gRPC/REST to the KubeMQ broker directly (default `localhost:50000`). | Direction | Producer | Consumer | What carries over | | ---------------- | ----------------------------------- | --------------------------------------------- | ------------------------------------------------ | | Pub/Sub → native | Pub/Sub `Publish` on topic `orders` | native `SubscribeToEventsStore("gcp.orders")` | Body + your attributes + the three reserved tags | The three reserved tags are **visible to native consumers** (and stripped from `attributes` for Pub/Sub clients): * `_pubsub_message_id` — the server-assigned message id (matches the `Publish` return value) * `_pubsub_publish_time` — the publish timestamp * `_pubsub_ordering_key` — the ordering key, if any ## How it works [#how-it-works] The Pub/Sub publish writes once to the Events Store log `gcp.{topic}` through the message broker. A native KubeMQ Events Store subscriber attached to the same `gcp.{topic}` channel reads exactly that message, including the reserved `_pubsub_*` tags the connector stamps on the wire. *A Pub/Sub publish writes once to the Events Store log `gcp.{topic}`; a native KubeMQ Events Store subscriber on the same channel reads that message with the reserved `_pubsub_*` tags carried across the wire.* ## The Pub/Sub publish side [#the-pubsub-publish-side] The publish half is ordinary Pub/Sub code — `CreateTopic` then `Publish` against topic `orders` (which maps to `gcp.orders`). Each client sets only `PUBSUB_EMULATOR_HOST` (default `localhost:8085`) and a project id. To make the `_pubsub_ordering_key` tag observable on the native side, the publisher enables ordering and supplies an `ordering_key`. ```go package main import ( "context" "fmt" "log" "os" "time" "cloud.google.com/go/pubsub" ) func main() { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() projectID := os.Getenv("PUBSUB_PROJECT_ID") if projectID == "" { projectID = "my-project" } // PUBSUB_EMULATOR_HOST routes the official client at the connector (insecure gRPC). client, err := pubsub.NewClient(ctx, projectID) if err != nil { log.Fatalf("NewClient: %v", err) } defer client.Close() // Topic "orders" maps to the Events Store log "gcp.orders". topic, err := client.CreateTopic(ctx, "orders") if err != nil { log.Fatalf("CreateTopic: %v", err) } defer topic.Stop() topic.EnableMessageOrdering = true // makes _pubsub_ordering_key observable natively. // Publish one message; a native consumer on gcp.orders reads it. id, err := topic.Publish(ctx, &pubsub.Message{ Data: []byte("from-gcp-pubsub"), OrderingKey: "shipments", Attributes: map[string]string{"region": "emea"}, // rides along as a plain tag. }).Get(ctx) if err != nil { log.Fatalf("Publish: %v", err) } fmt.Printf("published: %s (native channel: gcp.orders)\n", id) } ``` ```python import os from google.cloud import pubsub_v1 from google.cloud.pubsub_v1.types import PublisherOptions def main() -> None: project_id = os.environ.get("PUBSUB_PROJECT_ID", "my-project") # Ordering must be enabled to publish with an ordering key. publisher = pubsub_v1.PublisherClient( publisher_options=PublisherOptions(enable_message_ordering=True) ) topic_path = publisher.topic_path(project_id, "orders") # -> gcp.orders publisher.create_topic(request={"name": topic_path}) # Publish one message; a native consumer on gcp.orders reads it. future = publisher.publish( topic_path, b"from-gcp-pubsub", ordering_key="shipments", region="emea", # an ordinary attribute — rides along as a plain tag. ) print(f"published: {future.result(timeout=15)} (native channel: gcp.orders)") if __name__ == "__main__": main() ``` ```java import com.google.api.gax.core.NoCredentialsProvider; import com.google.api.gax.grpc.GrpcTransportChannel; import com.google.api.gax.rpc.FixedTransportChannelProvider; import com.google.api.gax.rpc.TransportChannelProvider; import com.google.cloud.pubsub.v1.TopicAdminClient; import com.google.cloud.pubsub.v1.TopicAdminSettings; import com.google.protobuf.ByteString; import com.google.pubsub.v1.PublishRequest; import com.google.pubsub.v1.PubsubMessage; import com.google.pubsub.v1.TopicName; import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; public final class Main { public static void main(String[] args) throws Exception { String emulatorHost = System.getenv().getOrDefault("PUBSUB_EMULATOR_HOST", "localhost:8085"); String projectId = System.getenv().getOrDefault("PUBSUB_PROJECT_ID", "my-project"); ManagedChannel channel = ManagedChannelBuilder.forTarget(emulatorHost).usePlaintext().build(); TransportChannelProvider channelProvider = FixedTransportChannelProvider.create(GrpcTransportChannel.create(channel)); NoCredentialsProvider noCreds = NoCredentialsProvider.create(); TopicName topic = TopicName.of(projectId, "orders"); // -> gcp.orders try (TopicAdminClient topicAdmin = TopicAdminClient.create(TopicAdminSettings.newBuilder() .setTransportChannelProvider(channelProvider).setCredentialsProvider(noCreds).build())) { topicAdmin.createTopic(topic); // Publish one message; a native consumer on gcp.orders reads it. String id = topicAdmin.publish(PublishRequest.newBuilder() .setTopic(topic.toString()) .addMessages(PubsubMessage.newBuilder() .setData(ByteString.copyFromUtf8("from-gcp-pubsub")) .setOrderingKey("shipments") .putAttributes("region", "emea") // rides along as a plain tag. .build()) .build()).getMessageIds(0); System.out.printf("published: %s (native channel: gcp.orders)%n", id); } finally { channel.shutdown(); } } } ``` ```typescript import { PubSub } from "@google-cloud/pubsub"; const projectId = process.env["PUBSUB_PROJECT_ID"] ?? "my-project"; async function main(): Promise { // The high-level client auto-detects PUBSUB_EMULATOR_HOST (insecure gRPC). const pubsub = new PubSub({ projectId }); // Topic "orders" maps to the Events Store log "gcp.orders". const [topic] = await pubsub.createTopic("orders"); topic.setPublishOptions({ messageOrdering: true }); // makes _pubsub_ordering_key observable. // Publish one message; a native consumer on gcp.orders reads it. const messageId = await topic.publishMessage({ data: Buffer.from("from-gcp-pubsub"), orderingKey: "shipments", attributes: { region: "emea" }, // rides along as a plain tag. }); console.log(`published: ${messageId} (native channel: gcp.orders)`); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using Google.Api.Gax; using Google.Cloud.PubSub.V1; using Google.Protobuf; var projectId = Environment.GetEnvironmentVariable("PUBSUB_PROJECT_ID") ?? "my-project"; var topicName = TopicName.FromProjectTopic(projectId, "orders"); // -> gcp.orders // The .NET client does NOT auto-detect the emulator — set EmulatorOnly. var publisher = await new PublisherServiceApiClientBuilder { EmulatorDetection = EmulatorDetection.EmulatorOnly, }.BuildAsync(); await publisher.CreateTopicAsync(topicName); // Publish one message; a native consumer on gcp.orders reads it. var publishResponse = await publisher.PublishAsync(topicName, new[] { new PubsubMessage { Data = ByteString.CopyFromUtf8("from-gcp-pubsub"), OrderingKey = "shipments", Attributes = { ["region"] = "emea" }, // rides along as a plain tag. }, }); Console.WriteLine($"published: {publishResponse.MessageIds[0]} (native channel: gcp.orders)"); ``` ```ruby # frozen_string_literal: true require "google/cloud/pubsub" project_id = ENV["PUBSUB_PROJECT_ID"] || "my-project" emulator_host = ENV["PUBSUB_EMULATOR_HOST"] || "localhost:8085" pubsub = Google::Cloud::PubSub.new(project_id: project_id, emulator_host: emulator_host) topic_admin = pubsub.topic_admin topic_path = pubsub.topic_path("orders") # -> gcp.orders topic = topic_admin.create_topic(name: topic_path) # ordered: true makes _pubsub_ordering_key observable on the native side. publisher = pubsub.publisher(topic.name, async: { ordered: true }) msg = publisher.publish("from-gcp-pubsub", ordering_key: "shipments", region: "emea") publisher.async_publisher.stop! puts "published: #{msg.message_id} (native channel: gcp.orders)" ``` ## The native consumer [#the-native-consumer] The other side is an ordinary KubeMQ **Events Store** subscriber talking gRPC to the broker (default `localhost:50000`) on the `gcp.orders` channel — no Pub/Sub SDK involved. Subscribe with the **"new only"** start position and confirm the stream is open **before** publishing, so the published message is in-window (Events Store subscribers attach to a stream, not a fixed offset). The message the Pub/Sub side published carries its body, your attributes, and the three reserved `_pubsub_*` tags. ```go package main import ( "context" "fmt" "log" "os" "time" kubemq "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() grpcAddress := os.Getenv("KUBEMQ_GRPC_ADDRESS") if grpcAddress == "" { grpcAddress = "localhost:50000" } // Native KubeMQ gRPC client on the shared Events Store channel gcp.orders. native, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), kubemq.WithClientId("gcp-interop-native-go"), ) if err != nil { log.Fatalf("connect native gRPC: %v", err) } defer native.Close() received := make(chan *kubemq.EventStoreReceive, 1) // Subscribe FIRST with start policy "new only"; the stream is open on return. sub, err := native.SubscribeToEventsStore(ctx, "gcp.orders", "", kubemq.StartFromNewEvents(), kubemq.WithOnEventStoreReceive(func(ev *kubemq.EventStoreReceive) { received <- ev }), kubemq.WithOnError(func(e error) { log.Printf("subscribe error: %v", e) }), ) if err != nil { log.Fatalf("SubscribeToEventsStore: %v", err) } defer sub.Cancel() fmt.Println("native SubscribeToEventsStore(gcp.orders, startAt=new) -> stream open") // (Run the Pub/Sub publish side now; it lands on gcp.orders.) select { case ev := <-received: fmt.Printf("native received %q\n", string(ev.Body)) fmt.Printf(" _pubsub_message_id=%s\n", ev.Tags["_pubsub_message_id"]) fmt.Printf(" _pubsub_ordering_key=%s\n", ev.Tags["_pubsub_ordering_key"]) fmt.Printf(" region (attribute)=%s\n", ev.Tags["region"]) case <-time.After(15 * time.Second): log.Fatal("timed out waiting for the native event") } } ``` ```python import os import queue from kubemq import EventsStoreSubscription, EventStoreReceived, PubSubClient from kubemq.pubsub.events_store_subscription import EventStoreStartPosition GRPC_ADDRESS = os.environ.get("KUBEMQ_GRPC_ADDRESS", "localhost:50000") CHANNEL = "gcp.orders" def main() -> None: received: queue.Queue[EventStoreReceived] = queue.Queue(maxsize=1) native = PubSubClient(address=GRPC_ADDRESS, client_id="gcp-interop-native-python") # Subscribe FIRST with start policy "new only". native.subscribe_to_events_store( EventsStoreSubscription( channel=CHANNEL, events_store_type=EventStoreStartPosition.StartFromNew, on_receive_event_callback=received.put, on_error_callback=lambda err: print(f"subscribe error: {err}"), ) ) print(f"native SubscribeToEventsStore({CHANNEL}, startAt=new) -> stream open") # (Run the Pub/Sub publish side now; it lands on gcp.orders.) event = received.get(timeout=15) print(f"native received {event.body.decode()!r}") print(f" _pubsub_message_id={event.tags.get('_pubsub_message_id')}") print(f" _pubsub_ordering_key={event.tags.get('_pubsub_ordering_key')}") print(f" region (attribute)={event.tags.get('region')}") native.close() if __name__ == "__main__": main() ``` ```typescript import { KubeMQClient, EventStoreStartPosition } from "kubemq-js"; import type { EventStoreReceived, KubeMQError } from "kubemq-js"; const grpcAddress = process.env["KUBEMQ_GRPC_ADDRESS"] ?? "localhost:50000"; const CHANNEL = "gcp.orders"; async function main(): Promise { // KubeMQClient.create() connects before returning a ready client. const kube = await KubeMQClient.create({ address: grpcAddress, clientId: "gcp-interop-native-js" }); const received = new Promise((resolve, reject) => { // Subscribe FIRST with start position "new only". kube.subscribeToEventsStore({ channel: CHANNEL, startFrom: EventStoreStartPosition.StartFromNew, onEvent: (event: EventStoreReceived) => resolve(event), onError: (err: KubeMQError) => reject(err), }); }); console.log(`native SubscribeToEventsStore(${CHANNEL}, startAt=new) -> stream open`); // (Run the Pub/Sub publish side now; it lands on gcp.orders.) const event = await received; console.log(`native received ${JSON.stringify(Buffer.from(event.body).toString("utf8"))}`); console.log(` _pubsub_message_id=${event.tags["_pubsub_message_id"]}`); console.log(` _pubsub_ordering_key=${event.tags["_pubsub_ordering_key"]}`); console.log(` region (attribute)=${event.tags["region"]}`); } main().catch((err) => { console.error(err); process.exit(1); }); ``` The native consumer is the **only** place a KubeMQ SDK appears in the Pub/Sub connector examples — the Pub/Sub publish half above is idiomatic Google client code in every language. The native half is shown in Go, Python, and JavaScript; where a language's native Events Store client is less mature, it can fall back to a `kubemq-go` sidecar or a REST Events Store call. ## Subscribe before publish [#subscribe-before-publish] **Establish the native subscribe stream before publishing.** An Events Store subscriber with the "new only" start position receives only events published **after** the stream is open. A naive "publish then subscribe" races — the publish can land before the subscriber attaches and be missed. Open the native subscription first, confirm the stream is up, then run the Pub/Sub publish so the message is in-window. ## Topic log, not subscription queue [#topic-log-not-subscription-queue] The native path reads the **topic log** `gcp.{topic}` directly — the authoritative, replayable source written once per publish. It does not read a subscription's Queue channel `gcp.sub.{subscription}`; those are the per-subscription fan-out copies consumed by Pub/Sub `Pull`. For point-to-point native consumption of a subscription's backlog instead, subscribe to its `gcp.sub.{subscription}` Queue channel. See the [channel mapping reference](/connectors/gcp-pub-sub/reference/channel-mapping) for the full grammar. ## Related [#related] # Migrating from ActiveMQ (/connectors/how-to/migration/from-activemq) ActiveMQ is a multi-protocol broker, so there is no single migration path. Which connector you use — and which guide you follow — depends on **which protocol your client speaks** and which ActiveMQ variant (Classic or Artemis) you run. This page is the router: it points you at the right per-protocol guide and calls out the one thing that does **not** migrate at all. KubeMQ serves ActiveMQ workloads through three existing connectors — **AMQP 1.0**, **STOMP**, and **MQTT** — depending on the protocol. All three are **opt-in** (`Enable = false` by default); you enable only the connector(s) your clients need. The per-protocol guides carry the full code; this page keeps the endpoint deltas and the cross-cutting deviations in one place. **OpenWire is NOT supported.** KubeMQ has no OpenWire wire decoder. Any client connecting over the OpenWire protocol will fail. There is no configuration option to add OpenWire support — an ActiveMQ client using the default OpenWire transport must switch to AMQP 1.0, STOMP, or MQTT before it can talk to KubeMQ. ## Choose your path [#choose-your-path] Pick the row that matches your client, then follow the linked guide for the connection snippet, destination mapping, and a working code example. | Client type | ActiveMQ variant | Connector | Guide | | ---------------------------- | ------------------------- | ------------------------------ | ------------------------------------------------------------------------ | | Java / JMS applications | Classic or Artemis | AMQP 1.0 (via Apache Qpid JMS) | [Migrating from JMS](/connectors/how-to/migration/from-jms) | | Non-Java clients using STOMP | Classic or Artemis | STOMP | [Migrating from STOMP](/connectors/stomp/scenarios/migration-from-stomp) | | Non-Java clients using MQTT | Classic or Artemis | MQTT | [Migrating from MQTT](/connectors/mqtt/scenarios/migration) | | Native AMQP 1.0 clients | Artemis (native AMQP 1.0) | AMQP 1.0 | [Migrating from AMQP 1.0](/connectors/how-to/migration/from-amqp-1-0) | Java/JMS applications keep their JMS code and swap only the `ConnectionFactory` implementation to Apache Qpid JMS — a **client-swap**. STOMP, MQTT, and native AMQP 1.0 clients are **endpoint-only**: change the broker host and credentials, nothing else. Default ports (when the connector is enabled): | Connector | Port | TLS port | Protocol | | --------- | ----- | -------------- | ---------------------------- | | AMQP 1.0 | 5672 | 5671 | AMQP 1.0 (Qpid JMS, Artemis) | | STOMP | 61613 | 61614 | STOMP 1.0 / 1.1 / 1.2 | | MQTT | 1883 | 8883 (WS 8083) | MQTT 3.1.1 / 5.0 | Enable only what you need. Each connector has its own enable variable: ```bash title="Enable variables" CONNECTORS_AMQP10_ENABLE=true # Java/JMS via Qpid JMS, and native AMQP 1.0 (Artemis) clients CONNECTORS_STOMP_ENABLE=true # STOMP clients (Classic and Artemis) CONNECTORSMQTT_ENABLE=true # MQTT clients (note: no underscore) ``` ## Compatibility Matrix [#compatibility-matrix] This matrix is self-contained for the ActiveMQ workload across all three connectors. Where a capability differs by path, the cell names the path it applies to. | Dimension | Status | Notes | | --------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------- | | Drop-in level | client-swap / endpoint | Java/JMS: client-swap (Qpid JMS); STOMP/MQTT: endpoint-only | | Point-to-point queues | ✅ | All three connectors support Queues | | Pub/sub (non-durable) | ✅ | Events pattern on all paths | | Durable subscriptions | ✅ | Via Events Store; `unsubscribe()` is node-local — see [Behavioral deviations](#behavioral-deviations) | | Request/reply (RPC) | ✅ ² | AMQP 1.0 (Qpid JMS) path only | | Ordering | ⚠️ node-local | Per-channel ordering is not preserved cluster-wide | | Transactions | ❌ | Not supported on any path | | Dead-letter / redrive | ❌ no client DLQ ⁶ | No client-settable DLQ; poison messages are silently dropped | | Selectors / filtering | ✅ (AMQP 1.0) / ❌ (STOMP) | SQL92 selectors work on the AMQP 1.0 path (Events / Events Store); no selectors on STOMP | | Auth model | PLAIN (JWT) | JWT in SASL PLAIN password (AMQP 1.0), CONNECT passcode (STOMP), or CONNECT password (MQTT) | | TLS / mTLS | ✅ | 5671 (AMQP 1.0), 61614 (STOMP), 8883 / wss 8083 (MQTT) | | Top unsupported | — | **OpenWire protocol**; transactions; selectors on the STOMP path | **Footnotes:** * ² ActiveMQ RPC via the AMQP 1.0 (Qpid JMS) path only; STOMP reply-to works but requires a pre-existing reply subscription. * ⁶ See [What Does NOT Migrate → Hard blockers](#hard-blockers) for the authoritative statement. ## Connection / Endpoint Migration [#connection--endpoint-migration] The change is the same shape on every path: point the client at the KubeMQ host and supply a KubeMQ JWT as the credential. The full code lives in the linked per-protocol guides — these tabs show only the endpoint delta. Java applications using the ActiveMQ JMS client (`ActiveMQConnectionFactory`) migrate by swapping the `ConnectionFactory` implementation to Apache Qpid JMS — the JMS application code itself does not change. Native AMQP 1.0 clients (e.g. `go-amqp`, AMQP.NET Lite, Qpid Proton) and Artemis clients that already speak AMQP 1.0 migrate by changing only the broker endpoint. ```text title="ConnectionFactory / broker URI" # Before (ActiveMQ Classic, OpenWire — must switch protocol) tcp://activemq.example.com:61616 # Before (ActiveMQ Artemis, native AMQP 1.0) amqp://artemis.example.com:5672 # After (KubeMQ) amqp://kubemq.example.com:5672 amqps://kubemq.example.com:5671 # TLS ``` For the JNDI setup, destination mapping, and the full Qpid JMS snippet, see [Migrating from JMS](/connectors/how-to/migration/from-jms). For the native AMQP 1.0 path (addressing, message translation, RPC, and the `go-amqp` snippet), see [Migrating from AMQP 1.0](/connectors/how-to/migration/from-amqp-1-0). ```text title="STOMP endpoint" # Before (ActiveMQ Classic STOMP) host: activemq.example.com port: 61613 # After (KubeMQ STOMP) host: kubemq.example.com port: 61613 # same port; TLS on 61614 ``` **Destination compatibility note:** ActiveMQ uses `/queue/NAME` and `/topic/NAME` with `.`-delimited names. KubeMQ accepts both forms — `/topic/orders.created` and `/topic/orders/created` reach the same KubeMQ channel `orders.created`. Pick one convention and apply it consistently. For destinations, ack modes, durable subscriptions, and the full `stomp.py` snippet, see [Migrating from STOMP](/connectors/stomp/scenarios/migration-from-stomp). ```text title="MQTT endpoint" # Before (ActiveMQ Classic MQTT) host: activemq.example.com port: 1883 # After (KubeMQ MQTT) host: kubemq.example.com port: 1883 # plain; TLS on 8883; WS on 8083 ``` **MQTT version note:** KubeMQ **rejects MQTT 3.1 clients** at CONNECT. Use MQTT 3.1.1 (the default for most clients, including Eclipse Paho) or MQTT 5.0. For topic→pattern mapping, QoS, and the `paho-mqtt` snippet, see [Migrating from MQTT](/connectors/mqtt/scenarios/migration). ## Concept & Destination Mapping [#concept--destination-mapping] ActiveMQ concepts map onto KubeMQ patterns through the connector chosen for each client type. ### AMQP 1.0 path (Qpid JMS / Artemis) [#amqp-10-path-qpid-jms--artemis] | ActiveMQ concept | KubeMQ pattern | Channel / address | | -------------------------- | ---------------------------------------- | -------------------------------------- | | Queue | Queues | `/queues/` | | Topic (non-durable) | Events | `/events/` | | Durable topic subscription | Events Store | `/events-store/` | | Temporary queue | Dynamic node (temp reply mailbox) | `source.dynamic = true` | | Virtual Topic / shared sub | Consumer group | link property `x-opt-kubemq-group` | | Command (request/reply) | Commands or Queries | `/commands/` / `/queries/` | | Reply-to | `/responses/` or dynamic node | — | The JMS capability hint (`queue` / `topic`) lets Qpid JMS `Queue("orders")` and `Topic("orders")` map automatically without an explicit prefix — see [Migrating from JMS](/connectors/how-to/migration/from-jms) for details. ### STOMP path [#stomp-path] | ActiveMQ destination | KubeMQ destination | Pattern | | --------------------------------------- | ----------------------- | ------------ | | `/queue/NAME` | `/queue/NAME` | Queues | | `/topic/NAME` | `/topic/NAME` | Events | | Durable topic | `/topic-store/NAME` | Events Store | | ActiveMQ Virtual Topic `VirtualTopic.X` | `/topic/VirtualTopic.X` | Events | ### MQTT path [#mqtt-path] | ActiveMQ MQTT topic prefix | KubeMQ pattern | | -------------------------- | ------------------------ | | `events/` | Events | | `store/` | Events Store | | `queues/` | Queues | | `commands/` | Commands (MQTT 5.0 only) | | `queries/` | Queries (MQTT 5.0 only) | Wildcards `+` and `#` are supported **for Events subscriptions only**. ## Security [#security] All three connectors accept the same KubeMQ JWT as the credential, but deliver it differently: | Connector | Where the JWT goes | Auth disabled | | --------- | ---------------------------------------------------------------- | -------------------------------------- | | AMQP 1.0 | SASL PLAIN **password** field (username is informational) | ANONYMOUS or bare AMQP header accepted | | STOMP | CONNECT **`passcode`** header (username recorded for audit) | Any credentials accepted | | MQTT | CONNECT **`password`** field (username recorded for audit) | Any credentials accepted | All three connectors are **opt-in** (`Enable = false`). They do not open listeners until explicitly enabled. When `Authentication.Enable = false` (the server default), listeners accept unauthenticated clients — enable authentication or firewall the ports when the server is reachable from untrusted networks. **TLS:** each connector uses the server-wide `Security` block. TLS is active on the TLS port only when `Security` is configured. mTLS (client-certificate auth) is available on the AMQP 1.0 path via SASL EXTERNAL (cert CN = ClientID). **Authorization (Casbin):** with `Authorization.Enable = true`, Write is enforced on SEND / produce, and Read on SUBSCRIBE / consume, against the resolved KubeMQ channel. See [Authentication & Security](/connectors/reference/auth-and-security) and the [configuration reference](/configure/reference/connectors). ## What Does NOT Migrate / Deviations [#what-does-not-migrate--deviations] ### Hard blockers [#hard-blockers] | Feature | Status | Detail | | ---------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **OpenWire protocol** | ❌ Not supported | See the callout at the top of this page. There is no configuration option to add OpenWire support. | | **Transactions** | ❌ Not supported | No `SESSION_TRANSACTED` / XA (JMS), no STOMP `BEGIN` / `COMMIT` / `ABORT`. Use idempotent producers and at-least-once consumers instead. | | **Message selectors (STOMP path)** | ❌ Not supported | The STOMP connector rejects `selector` headers with ERROR `selectors not supported` and closes the connection. Remove all `selector` usage from STOMP applications. | | **No client-settable DLQ** | ❌ | No dead-letter address is exposed to clients over AMQP 1.0, STOMP, or MQTT. A message that exceeds `MaxReceiveCount` is silently dropped — there is no consumable dead-letter address over any of these protocols. | ### Behavioral deviations [#behavioral-deviations] | Feature | Deviation | | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Selectors (AMQP 1.0 path)** | Supported on Events / Events Store only (SQL92 subset via `apache.org:selector-filter`). Rejected on `/queues/` links (`amqp:not-implemented`). | | **ActiveMQ durable subscriptions** | Map to Events Store via `/events-store/` (AMQP 1.0) or `/topic-store/` (STOMP). Replay-position headers control where the subscription starts. `unsubscribe()` / DETACH of the durable identity is **node-local** — connect back to the same node to cleanly detach. | | **ActiveMQ Virtual Topics** | Map to KubeMQ consumer groups via link property `x-opt-kubemq-group` (AMQP 1.0) or a shared STOMP subscription. No automatic `VirtualTopic.` prefix translation. | | **Ordering** | Per-channel ordering is node-local, not cluster-wide. MQTT ordering is QoS-dependent (QoS 0 unordered; QoS 1/2 ordered per connection only). | | **STOMP reply-to RPC** | The reply subscription must be active before the SEND carrying `reply-to` is issued. Sending without a pre-existing reply subscription produces ERROR `reply-to subscription required` and closes the connection. | | **MQTT 3.1 rejected** | KubeMQ refuses MQTT 3.1 clients at CONNECT. Use MQTT 3.1.1 or 5.0. | | **MQTT retained messages** | `RetainAvailable = 0`. Retained publishes are rejected with an audit event (not silently dropped). | | **MQTT RPC** | Request/reply (Commands / Queries) requires MQTT 5.0. Not available over MQTT 3.1.1. | ## Verification Smoke Test [#verification-smoke-test] Choose the path that matches your application, enable the relevant connector(s), and point a test client at the KubeMQ endpoint. The AMQP 1.0 and MQTT paths defer to their guides for the full client snippet; the STOMP quick check is below. * **AMQP 1.0 path (Java/JMS)** — requires the AMQP 1.0 connector enabled (`CONNECTORS_AMQP10_ENABLE=true`). See [Migrating from JMS](/connectors/how-to/migration/from-jms) for the full Qpid JMS snippet, and [Migrating from AMQP 1.0](/connectors/how-to/migration/from-amqp-1-0) for the native `go-amqp` snippet. * **MQTT path** — requires the MQTT connector enabled (`CONNECTORSMQTT_ENABLE=true`). See [Migrating from MQTT](/connectors/mqtt/scenarios/migration) for the full `paho-mqtt` snippet. **STOMP path** — requires the STOMP connector enabled (`CONNECTORS_STOMP_ENABLE=true`): ```python title="smoke_test.py" # stomp.py 8.x — publish one message, consume it, confirm arrival # Symbols: stomp.Connection, conn.connect, conn.send, conn.subscribe, # ConnectionListener.on_message, conn.ack, conn.disconnect import stomp, time class Listener(stomp.ConnectionListener): def __init__(self): self.received = [] def on_message(self, frame): self.received.append(frame.body) print(f"received: {frame.body}") listener = Listener() conn = stomp.Connection([("kubemq.example.com", 61613)]) conn.set_listener("", listener) conn.connect(login="user", passcode="", wait=True) # Subscribe before publishing (required for Events pattern) conn.subscribe("/topic/smoke-test", id=1, ack="auto") # Publish conn.send("/topic/smoke-test", body="hello from activemq migration") time.sleep(1) assert len(listener.received) == 1, "smoke test failed: no message received" print("smoke test passed") conn.disconnect() ``` ## See Also [#see-also] # Migrating from AMQP 1.0 (/connectors/how-to/migration/from-amqp-1-0) If you have an application that speaks native **AMQP 1.0 (ISO/IEC 19464)** — using a client such as Azure/go-amqp, AMQP.NET Lite, or Apache Qpid Proton — you can point it at KubeMQ's built-in AMQP 1.0 connector by changing **only the endpoint** and, where required, the SASL credentials. Address prefixes select the KubeMQ messaging pattern, so simple publish/consume needs no app-code rewrite. This is a drop-in migration at the **endpoint / client** level. If you are migrating a **JMS** application (Java) instead, see the [Migrating from JMS](/connectors/how-to/migration/from-jms) guide; for **ActiveMQ** applications routed by client type, see [Migrating from ActiveMQ](/connectors/how-to/migration/from-activemq). ## Overview [#overview] The AMQP 1.0 connector exposes KubeMQ's Queues, Events, Events Store, Commands, and Queries patterns over the native AMQP 1.0 wire protocol. It listens on the same ports as the AMQP 0-9-1 connector — **5672** (plain / SASL) and **5671** (TLS / mTLS) — because both protocols share a single listener that routes each connection by its protocol header. No separate firewall rule is needed beyond what the AMQP port already allows. * **Canonical client (this guide):** `github.com/Azure/go-amqp` **v1.7.0**. For .NET shops, **AMQP.NET Lite** is a direct alternative; the connection-string and address conventions are the same, but the snippets below target `go-amqp`. * **Opt-in default:** The connector is **disabled by default**. Set `CONNECTORS_AMQP10_ENABLE=true` (or `Enable = true` under `[Connectors.Amqp10]` in TOML) before connecting. Enable the connector before you migrate any traffic: The enable variable is **`CONNECTORS_AMQP10_ENABLE`** — the literal `10` stays attached to `AMQP` with no underscore. `CONNECTORS_AMQP_1_0_ENABLE` and `CONNECTORS_AMQP10ENABLE` do **not** bind. For Kubernetes, set `spec.amqp10.enabled: true` in the `KubemqCluster` CR. For the full wire-protocol contract, see the [AMQP connector capabilities reference](/connectors/amqp/reference/capabilities). ## Compatibility Matrix [#compatibility-matrix] The cells below describe the AMQP 1.0 column of the cross-protocol migration matrix. | Dimension | Support | Notes | | -------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Drop-in level** | endpoint / client | Change the host in the AMQP URI; prefix addresses with the pattern. No app-code rewrite for simple publish/consume. | | **Point-to-point queues** | ✅ | `/queues/` → KubeMQ Queues; competing consumers, ack/nack, visibility. | | **Pub/sub (non-durable)** | ✅ | `/events/` → Events; fire-hose fan-out, sender-settled (at-most-once). | | **Durable / persistent subscriptions** | ✅ | `/events-store/` → Events Store; backed by the persistence engine, resume from last acked position. | | **Request / reply (RPC)** | ✅ | `/commands/` / `/queries/` → Commands / Queries; hand-rolled reply receiver (see [snippet](#canonical-client-example)). | | **Ordering guarantee** | ⚠️ node-local | Within one node; ordering is not cluster-wide. | | **Transactions** | ❌ | AMQP `coordinator` / `declare` / `discharge` frames are not implemented. | | **Dead-letter / redrive** | ❌ no client DLQ | No client-settable DLQ over this protocol. Poison messages that exceed `MaxReceiveCount` are **silently dropped** by the broker, not delivered to a dead-letter address.¹ | | **Selectors / filtering** | ✅ selectors (SQL92 subset) | `apache.org:selector-filter:string` on Events / Events Store links; not supported on `/queues/` links. | | **Auth model** | PLAIN (JWT) / EXTERNAL | SASL PLAIN: password = KubeMQ JWT. SASL EXTERNAL: mTLS, cert CN = ClientID. | | **TLS / mTLS** | ✅ 5671 | Active when the top-level `Security` block is configured. | | **Top unsupported** | transactions; durable-unsub node-local | See [What Does Not Migrate](#what-does-not-migrate--deviations). | ¹ There is no client-settable DLQ over this protocol. The AMQP 1.0 connector never marks published messages for dead-lettering, so a poison message that exceeds `MaxReceiveCount` is silently dropped by the broker rather than delivered to any consumable dead-letter address. For a genuine client-facing DLQ, use the RabbitMQ (DLX) or AWS (redrive) connector. ## Connection / Endpoint Migration [#connection--endpoint-migration] Change only the host and, if required, the SASL credentials. The AMQP 1.0 port is shared with AMQP 0-9-1 on the same listener, so no firewall change is needed beyond what the AMQP port already allows. | | Before (existing broker) | After (KubeMQ) | | --------- | --------------------------- | ------------------------------------- | | **Plain** | `amqp://broker:5672` | `amqp://kubemq-host:5672` | | **TLS** | `amqps://broker:5671` | `amqps://kubemq-host:5671` | | **Auth** | Broker-specific credentials | SASL PLAIN, password = **KubeMQ JWT** | ```go // go-amqp v1.7.0 — amqp.Dial conn, err := amqp.Dial(ctx, "amqp://kubemq-host:5672", &amqp.ConnOptions{ SASLType: amqp.SASLTypePlain("svc-orders", ""), }) ``` When `Authentication.Enable = false` on the server, the connector also accepts SASL ANONYMOUS and bare AMQP headers (no SASL) — convenient for local development. ## Concept & Destination Mapping [#concept--destination-mapping] The **address prefix** of the AMQP link selects the KubeMQ messaging pattern. The leading `/` is optional (`queues/orders` ≡ `/queues/orders`). | Source concept | AMQP 1.0 address | KubeMQ pattern | Channel name | | ------------------------------ | ------------------------------------ | ----------------- | ----------------- | | Queue / P2P | `/queues/` | **Queues** | `` | | Topic / pub-sub | `/events/` | **Events** | `` | | Durable topic / persistent sub | `/events-store/` | **Events Store** | `` | | Command (fire-and-forget RPC) | `/commands/` | **Commands** | `` | | Query (request-response RPC) | `/queries/` | **Queries** | `` | | RPC reply token | `/responses/` | reply path | connection-scoped | | Temporary / dynamic node | `source.dynamic` or `target.dynamic` | in-memory mailbox | node-local | **Selectors (Events / Events Store only):** attach a filter under the `apache.org:selector-filter:string` descriptor on the receiver link source. The SQL92 subset supported includes comparisons, `AND` / `OR` / `NOT`, `BETWEEN`, `IN`, `LIKE`, `IS NULL`, and parentheses, evaluated against application-properties (and standard JMS headers). **Bare addresses:** when no prefix is present the connector resolves by JMS terminus capability hint (`queue` → Queues, `topic` → Events) or falls back to `DefaultPattern` (default: `"queues"`). **Interop with AMQP 0-9-1:** channels produced over the AMQP 0-9-1 connector use the prefix `amqp..`; an AMQP 1.0 client reaches the same data at `/queues/amqp..`. See the [address mapping reference](/connectors/amqp/reference/address-mapping) for the full grammar and the longest-prefix rule. ## From other AMQP 1.0 brokers (Solace / Azure Service Bus) [#from-other-amqp-10-brokers-solace--azure-service-bus] The connector speaks **standard AMQP 1.0**, so non-ActiveMQ AMQP 1.0 clients migrate the same way — change the endpoint, then map destinations to `/`. * **Solace PubSub+** — a Solace AMQP 1.0 sender/receiver targets a queue or topic by name. Remap the Solace destination to `queues/` (persistent) or `events/` (direct). Solace exclusive/non-exclusive durable topic endpoints map to `events-store/` durable subscriptions. Solace selectors map onto the pub/sub selector (`events/`-only). * **Azure Service Bus** — Service Bus AMQP 1.0 entities (`queues/`, `topics//subscriptions/`) remap to `queues/` and `events-store/` (durable). Azure SB sessions, scheduled/deferred delivery, dead-lettering, and transactions have **no KubeMQ equivalent** — drop those features (see [Capabilities](/connectors/amqp/reference/capabilities)). Azure SB's `amqps://` + SAS-token auth maps to KubeMQ SASL PLAIN with a JWT. For any AMQP 1.0 broker, the discipline is identical: **explicit `/` addresses**, continuous credit for at-most-once patterns, symbolic `amqp:*` error conditions (never numeric codes), and the deviations below. ## Canonical Client Example [#canonical-client-example] > **Client:** `github.com/Azure/go-amqp` **v1.7.0** > **Symbols used:** `amqp.Dial`, `conn.NewSession`, `session.NewSender`, `session.NewReceiver`, > `sender.Send`, `amqp.NewMessage`, `receiver.Receive`, `receiver.AcceptMessage`, plus > `msg.Properties.ReplyTo` / `msg.Properties.CorrelationID` for RPC. ### Publish and consume (Queues) [#publish-and-consume-queues] ```go package main import ( "context" "fmt" "log" amqp "github.com/Azure/go-amqp" // v1.7.0 ) func main() { ctx := context.Background() // amqp.Dial establishes the TCP connection and SASL handshake. conn, err := amqp.Dial(ctx, "amqp://kubemq-host:5672", &amqp.ConnOptions{ SASLType: amqp.SASLTypePlain("svc-orders", ""), }) if err != nil { log.Fatal(err) } defer conn.Close() // conn.NewSession opens an AMQP session. sess, err := conn.NewSession(ctx, nil) if err != nil { log.Fatal(err) } // --- Publish --- // session.NewSender attaches a sender link to /queues/orders. snd, err := sess.NewSender(ctx, "/queues/orders", nil) if err != nil { log.Fatal(err) } // sender.Send transfers one message; amqp.NewMessage wraps the body. if err := snd.Send(ctx, amqp.NewMessage([]byte(`{"id":"1","item":"widget"}`)), nil); err != nil { log.Fatal(err) } snd.Close(ctx) // --- Consume --- // session.NewReceiver attaches a competing-consumer receiver on the same queue. rcv, err := sess.NewReceiver(ctx, "/queues/orders", &amqp.ReceiverOptions{ Credit: 10, // grant initial link credit }) if err != nil { log.Fatal(err) } // receiver.Receive blocks until a message arrives. msg, err := rcv.Receive(ctx, nil) if err != nil { log.Fatal(err) } fmt.Printf("received: %s\n", msg.GetData()) // receiver.AcceptMessage settles the delivery (DISPOSITION accepted → broker AckRange). if err := rcv.AcceptMessage(ctx, msg); err != nil { log.Fatal(err) } rcv.Close(ctx) } ``` ### Events Store (durable subscription) [#events-store-durable-subscription] ```go // session.NewReceiver on /events-store/ → durable Events Store subscription. // The broker resumes from the last acknowledged position on reconnect. rcv, err := sess.NewReceiver(ctx, "/events-store/audit", &amqp.ReceiverOptions{ Credit: 64, Durability: amqp.DurabilityUnsettledState, // terminus expiry-policy 'never' }) ``` ### Selectors (Events / Events Store) [#selectors-events--events-store] ```go // Attach a SQL92 selector on an events receiver link source filter. // Selector is evaluated in the connector before delivery. rcv, err := sess.NewReceiver(ctx, "/events/orders", &amqp.ReceiverOptions{ Credit: 32, Filters: []amqp.LinkFilter{ amqp.NewSelectorFilter("priority > 5 AND region = 'EU'"), }, }) ``` ### RPC — hand-rolled reply receiver [#rpc--hand-rolled-reply-receiver] The AMQP 1.0 connector has **no library-level request/reply helper**. You must create a dynamic reply receiver yourself, set `msg.Properties.ReplyTo` to its address, and match responses by `CorrelationID`. The example below uses the exact `go-amqp v1.7.0` symbols. ```go // 1. Open a dynamic receiver to serve as the reply address. // session.NewReceiver with DynamicAddress=true → connector allocates // a temporary node and returns its address in the ATTACH reply. replyRcv, err := sess.NewReceiver(ctx, "", &amqp.ReceiverOptions{ Credit: 1, DynamicAddress: true, }) if err != nil { log.Fatal(err) } replyAddr := replyRcv.Address() // the connector-assigned dynamic node address // 2. Attach a sender to the command channel. snd, err := sess.NewSender(ctx, "/commands/status", nil) if err != nil { log.Fatal(err) } // 3. Build the request message. // msg.Properties.ReplyTo tells the connector where to send the response. // msg.Properties.CorrelationID allows matching the reply to the request. req := amqp.NewMessage([]byte(`{"service":"inventory"}`)) req.Properties = &amqp.MessageProperties{ ReplyTo: &replyAddr, CorrelationID: "req-001", } // sender.Send dispatches the request to the Commands channel. if err := snd.Send(ctx, req, nil); err != nil { log.Fatal(err) } // 4. receiver.Receive blocks for the reply; the connector routes it to replyAddr. reply, err := replyRcv.Receive(ctx, nil) if err != nil { log.Fatal(err) } fmt.Printf("reply correlation=%v body=%s\n", reply.Properties.CorrelationID, reply.GetData()) // receiver.AcceptMessage acknowledges the reply delivery. replyRcv.AcceptMessage(ctx, reply) replyRcv.Close(ctx) snd.Close(ctx) ``` ## Security [#security] ### Authentication [#authentication] | Mechanism | When | Credential | | ---------------- | ------------------------------------- | -------------------------------------------------------------- | | `SASL PLAIN` | Always available | password = **KubeMQ JWT**; username is recorded for audit only | | `SASL ANONYMOUS` | `Authentication.Enable = false` | No credentials; ClientID derived from `container-id` | | `SASL EXTERNAL` | mTLS with verified client certificate | Certificate CN becomes the ClientID — no JWT needed | ### TLS / mTLS [#tls--mtls] The TLS listener on port **5671** activates only when the top-level `Security` block is configured. Point clients at `amqps://kubemq-host:5671`. For mutual TLS, configure the server to request client certificates; the certificate CN then serves as the connection ClientID. ### Authorization [#authorization] With `Authorization.Enable = true`, the connection's ClientID is checked against the Casbin policy per link: * **Sender link** (client → KubeMQ): `Write` on the resolved channel, checked at ATTACH. * **Receiver link** (KubeMQ → client): `Read` on the resolved channel, checked at ATTACH. * **Anonymous-terminus sender**: per-message `Write` check against `properties.to` (1024-entry LRU cache, 60 s TTL). * **`/responses/` reply token**: no policy check (connection-scoped). ### Minimal TOML configuration [#minimal-toml-configuration] ```toml title="config.toml" [Connectors.Amqp10] Enable = true Port = 5672 # shared with [Connectors.Amqp] (0-9-1) via the same listener TlsPort = 5671 # active only when [Security] is configured ``` ## What Does NOT Migrate / Deviations [#what-does-not-migrate--deviations] ### Not supported [#not-supported] | Feature | Detail | | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **AMQP transactions** | The `coordinator`, `declare`, `discharge`, transactional acquisition, and transactional retirement performatives are not implemented. JMS `SESSION_TRANSACTED` sessions do not work; use `AUTO_ACKNOWLEDGE` or `CLIENT_ACKNOWLEDGE`. XA / two-phase commit is not available. | | **`rcv-settle-mode = second`** | Two-phase receiver settlement is not supported (DETACH `amqp:not-implemented`). Use `first` (the go-amqp default). | | **`AmqpSequence` body sections** | Rejected (`amqp:not-implemented`). Use Data sections or `AmqpValue`. | | **AMQP-over-WebSocket** | No WebSocket binding (RFC 7395). Use raw TCP on 5672 / TLS on 5671. | | **SASL SCRAM / GSSAPI / Azure CBS** | Only PLAIN, ANONYMOUS, and EXTERNAL are offered. | | **AMQP management (node create/delete)** | Use the KubeMQ REST API or dashboard instead. | | **Client-settable DLQ / redrive** | No client-settable DLQ over this protocol. See footnote ¹ in the Compatibility Matrix for the behavior and alternative-connector options (RabbitMQ DLX / AWS redrive). | ### Behavioral deviations [#behavioral-deviations] | Area | Behavior | | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Durable-subscription unsubscribe is node-local** | Durable Events Store subscription identities are tracked per node. The same durable subscription attached on two cluster nodes causes a durable-subscription client-ID conflict. An `unsubscribe()` call or durable detach is local to the node that owns the registration; if the client reconnects to a different node the durable state may not transfer cleanly. Retry the attach on conflict. | | **Pub/sub is at-most-once (sender-settled fire-hose)** | `/events/` links: events are dropped when link credit is 0 (`kubemq_amqp10_events_dropped_no_credit_total`), not buffered. Grant credit continuously for a durable-enough consumer. | | **Events Store stalled-credit link detach** | A bounded per-link buffer (`MaxUnsettledPerLink`) fronts the Events Store subscription. If the buffer fills while credit stays at 0, the link is detached (`amqp:resource-limit-exceeded`) and the buffered window is dropped. Affected positions are already acked, so a durable re-attach resumes *after* them. Size `MaxUnsettledPerLink` to match the consumer's expected burst. | | **`released` increments receive count** | The broker increments `ReceiveCount` on redelivery after a `released` / `modified{delivery-failed=true}` settlement. This counts toward `MaxReceiveCount` — a strict AMQP reading would not count a release as a delivery attempt. | | **Selectors on `/queues/` links** | Rejected (`amqp:not-implemented`). Selectors work only on Events and Events Store receivers. | | **Dynamic (temporary) nodes are node-local** | A temp reply node lives in memory on the owning node only. Direct cross-connection sends to another connection's temp node work only within the same node. RPC replies that travel through the broker path (`/responses/`) are unaffected. | | **`header.priority` does not schedule** | The field round-trips as the `amqp10.priority` tag but drives no priority ordering. | | **Config hot-reload** | Changing `Connectors.Amqp10.*` requires a server restart. | ## Verification Smoke Test [#verification-smoke-test] This recipe uses the [publish and consume snippet](#canonical-client-example) above as a copy-pasteable confirmation that the migration is working. **Prerequisites:** 1. KubeMQ is running with `Connectors.Amqp10.Enable = true`. 2. A KubeMQ JWT is available (or `Authentication.Enable = false` for local testing). **Steps:** ```go // Smoke test — publish one message to /queues/smoke-test, consume it, confirm arrival. // Run the main() from the Queues snippet above with destination "/queues/smoke-test". // Expected output: received: ``` 1. Run the snippet targeting `/queues/smoke-test`. 2. Confirm `received: ...` appears in stdout. 3. If the `amqp.Dial` call fails, check that `CONNECTORS_AMQP10_ENABLE=true` is set and port 5672 is reachable. 4. If `sender.Send` times out, verify the JWT in the SASL PLAIN password field is valid. 5. For Events / Events Store, swap the address prefix and confirm fan-out to multiple receivers. 6. For RPC, run the command snippet and confirm a reply with matching `CorrelationID` is received within the `DefaultRpcTimeoutSeconds` window (default: 30 s). ## See Also [#see-also] # Migrating from JMS (/connectors/how-to/migration/from-jms) JMS (Jakarta / Java Message Service) is a **Java API specification**, not a wire protocol. Any conforming JMS provider can be swapped for another without touching application code — your program calls `Session.createProducer`, `Session.createConsumer`, and so on regardless of the underlying transport. The migration path is therefore a **ConnectionFactory swap**: replace your current provider's factory with **Apache Qpid JMS**, which speaks AMQP 1.0 over the wire, and point it at KubeMQ's AMQP 1.0 connector. The JMS calls you already wrote stay exactly as they are. This guide covers the connection-factory migration, the concept-to-pattern mapping, a self-contained Java snippet, the security posture, and the documented gaps. For the AMQP 1.0 wire contract behind Qpid JMS — frame-level semantics, link options, message translation, selector grammar, and settlement rules — see the [Migrating from AMQP 1.0](/connectors/how-to/migration/from-amqp-1-0) guide and the [AMQP 1.0 connector capabilities](/connectors/amqp/reference/capabilities) reference. ## Overview [#overview] KubeMQ's **AMQP 1.0 connector** is the substrate. Qpid JMS connects to it over AMQP 1.0 on port **5672** (plain / SASL) or **5671** (TLS), sharing the listener with AMQP 0.9.1. | | | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | **Connector** | AMQP 1.0 | | **Ports** | 5672 (plain / SASL), 5671 (TLS) | | **Canonical client** | Apache Qpid JMS **2.x** (jakarta namespace) — `org.apache.qpid:qpid-jms-client:2.x` | | **Enable default** | Opt-in — disabled by default. Set `CONNECTORS_AMQP10_ENABLE=true` (or `connectors.amqp10.enable = true` in TOML) to turn it on | **Qpid JMS 2.x vs 1.x.** The jakarta-namespace release (`org.apache.qpid:qpid-jms-client:2.x`, artifact classifier `jakarta`) requires `jakarta.jms:jakarta.jms-api:3.x`. If your codebase still targets the **`javax.jms`** namespace (JMS 2.0), use the **`1.x`** line (`org.apache.qpid:qpid-jms-client:1.x`) instead. The Qpid URI format, the AMQP addressing, and all JNDI properties are **identical** between the two lines — only the JMS API namespace differs. The AMQP 1.0 connector is **disabled by default** — a stock kubemq-server does not bind the AMQP 1.0 listener until you turn it on. Enable it with its enable variable: The enable variable is **`CONNECTORS_AMQP10_ENABLE`** — the literal `10` stays attached to `AMQP` with no underscore. `CONNECTORS_AMQP_1_0_ENABLE` and `CONNECTORS_AMQP10ENABLE` do **not** bind. For Kubernetes, set `spec.amqp10.enabled: true` in the `KubemqCluster` CR. ## Compatibility Matrix [#compatibility-matrix] This is the JMS column of the cross-protocol matrix in the [migration hub](/connectors/how-to/migration), restated here so this guide stands on its own. | Dimension | JMS (Qpid JMS over AMQP 1.0) | Notes | | ------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------- | | **Drop-in level** | client-swap | Swap the ConnectionFactory; JMS API code unchanged | | **Point-to-point queues** | ✅ | `Queue` → `queues/` | | **Pub/sub (non-durable)** | ✅ | `Topic` → `events/` | | **Durable subscriptions** | ✅¹ | `Topic` → `events-store/` (persistence-backed) | | **Request / reply (RPC)** | ✅ | `QueueRequestor` / `TemporaryQueue` → Commands / Queries via dynamic nodes | | **Ordering guarantee** | ⚠️ node-local | Queue ordering within a single node; no cluster-wide guarantee | | **Transactions** | ❌ | `SESSION_TRANSACTED` and XA are not supported | | **Dead-letter / redrive** | ❌ no client DLQ² | Poison messages are dropped after `MaxReceiveCount`; no consumable dead-letter address | | **Selectors / filtering** | ✅ selectors (SQL92 subset) | `createConsumer(dest, selector)` works on Events / Events Store | | **Auth model** | PLAIN (JWT) / EXTERNAL | SASL PLAIN with a KubeMQ JWT as password; EXTERNAL via mTLS | | **TLS / mTLS** | ✅ 5671 | Active when the server `Security` block is configured | | **Top unsupported** | JMS transactions / XA; node-local `unsubscribe()` | See [What Does NOT Migrate](#what-does-not-migrate--deviations) | ¹ Durable subscription is backed by the persistence engine. `session.unsubscribe()` is node-local — see [What Does NOT Migrate](#what-does-not-migrate--deviations) for the full explanation. ² No client-settable DLQ — see [What Does NOT Migrate](#what-does-not-migrate--deviations) for the full explanation. ## Connection / Endpoint Migration [#connection--endpoint-migration] As shown in the [Compatibility Matrix](#compatibility-matrix) (Drop-in level: client-swap), the only required change is the **ConnectionFactory URL** and its class name. The JMS API calls (`createSession`, `createProducer`, `createConsumer`, and so on) remain unchanged. ### Using JNDI properties [#using-jndi-properties] ```properties # jndi.properties — before (ActiveMQ / Artemis example) # java.naming.factory.initial=org.apache.activemq.artemis.jndi.ActiveMQInitialContextFactory # connectionFactory.kubemq=tcp://old-broker:61616 # jndi.properties — after (Qpid JMS over KubeMQ AMQP 1.0) java.naming.factory.initial=org.apache.qpid.jms.jndi.JmsInitialContextFactory connectionfactory.kubemq=amqp://kubemq.example.com:5672 # Optional TLS endpoint # connectionfactory.kubemq=amqps://kubemq.example.com:5671 # Declare destinations (prefix drives pattern selection) queue.ordersQueue=queues/orders topic.ordersTopic=events/orders topic.auditTopic=events-store/audit ``` ### Direct instantiation (no JNDI) [#direct-instantiation-no-jndi] ```java import org.apache.qpid.jms.JmsConnectionFactory; // Before (example from any other provider): // ConnectionFactory cf = new ActiveMQConnectionFactory("tcp://old-broker:61616"); // After (Qpid JMS): ConnectionFactory cf = new JmsConnectionFactory("amqp://kubemq.example.com:5672"); ``` For TLS, use `"amqps://kubemq.example.com:5671"`. ### Maven / Gradle dependency [#maven--gradle-dependency] ```xml org.apache.qpid qpid-jms-client 2.6.0 jakarta ``` ```groovy // Gradle — jakarta namespace (JMS 3.x) implementation 'org.apache.qpid:qpid-jms-client:2.6.0:jakarta' // javax namespace (JMS 2.0), if still on javax.jms // implementation 'org.apache.qpid:qpid-jms-client:1.12.0' ``` ## Concept & Destination Mapping [#concept--destination-mapping] Qpid JMS resolves destination names against KubeMQ by the **address prefix** — the prefix is stripped and the remainder becomes the channel name. JMS `Queue` / `Topic` objects carry the full address, not just the channel name. | JMS concept | Qpid JMS address | KubeMQ pattern | Channel name | | ----------------------------------------- | ----------------------------------------- | -------------- | --------------- | | `Queue` (point-to-point) | `queues/orders` | Queues | `orders` | | `Topic` (non-durable pub/sub) | `events/notifications` | Events | `notifications` | | Durable `Topic` subscription | `events-store/audit` | Events Store | `audit` | | `QueueRequestor` / `TemporaryQueue` reply | `commands/status` + dynamic reply node | Commands | `status` | | Virtual Topic (ActiveMQ Classic) | `events-store/` + group property | Events Store | — | **JMS 2.0 shared subscriptions.** `createSharedConsumer` does **not** automatically enable consumer-group load balancing. The AMQP 1.0 connector activates group load-balancing through a wire-level AMQP link property set on the ATTACH frame — which is not expressible through the standard JMS API. The `createSharedConsumer(dest, groupName)` call by itself does **not** wire the group; to load-balance Events across a group with Qpid JMS, the link property must be set at the wire level. **Bare address (no prefix).** Qpid JMS advertises a JMS capability hint (`queue` or `topic`) in the AMQP ATTACH. The connector uses this to resolve `Queue("orders")` → Queues and `Topic("orders")` → Events automatically, without requiring the prefix. If you use addresses that lack the prefix **and** the capability hint is absent, the connector falls back to the configured default pattern (`queues`). Prefer the **explicit prefix** so resolution is never ambiguous. **Selectors.** Pass the selector expression as the second argument to `createConsumer` or `createDurableConsumer`. The connector evaluates a SQL92 subset (comparisons, `AND` / `OR` / `NOT`, `BETWEEN`, `IN`, `LIKE`, `IS NULL`) against application properties and JMS headers (`JMSPriority`, `JMSType`, `JMSCorrelationID`, `JMSMessageID`, `JMSTimestamp`). Selectors are supported on **Events and Events Store links only**; a selector on a Queue (`queues/`) link fails the ATTACH with `amqp:not-implemented`. ## Canonical Client Example [#canonical-client-example] **Client:** Apache Qpid JMS `2.6.0` (jakarta). API symbols: `JmsConnectionFactory`, `Connection.createSession`, `Session.AUTO_ACKNOWLEDGE`, `Session.createQueue`, `Session.createTopic`, `Session.createProducer`, `Session.createConsumer`, `Session.createDurableConsumer`, `Session.createTemporaryQueue`, `MessageProducer.send`, `MessageConsumer.receive`. ```java import jakarta.jms.*; import org.apache.qpid.jms.JmsConnectionFactory; public class KubeMQJmsExample { public static void main(String[] args) throws JMSException { // 1. ConnectionFactory — the only change from your previous provider ConnectionFactory cf = new JmsConnectionFactory( "amqp://kubemq.example.com:5672"); // When authentication is enabled, pass the KubeMQ JWT as the password. // The username is recorded for audit only and does not affect identity. Connection conn = cf.createConnection("svc-orders", System.getenv("KUBEMQ_JWT")); conn.start(); Session session = conn.createSession(false, Session.AUTO_ACKNOWLEDGE); // ── Point-to-point (Queues) ────────────────────────────────────────── Queue ordersQueue = session.createQueue("queues/orders"); // Produce TextMessage msg = session.createTextMessage("order-payload"); session.createProducer(ordersQueue).send(msg); // Consume (competing consumer — AUTO_ACKNOWLEDGE maps to AMQP ACCEPTED) TextMessage received = (TextMessage) session.createConsumer(ordersQueue).receive(5000); System.out.println("Received: " + received.getText()); // ── Non-durable pub/sub (Events) ───────────────────────────────────── Topic eventsTopic = session.createTopic("events/notifications"); session.createProducer(eventsTopic).send( session.createTextMessage("event-payload")); // Subscribers on events/notifications receive a copy (fan-out). // ── Durable subscription (Events Store) ────────────────────────────── Topic auditTopic = session.createTopic("events-store/audit"); // Durable identity derived from (container-id, subscription-name) — survives reconnect. MessageConsumer durableConsumer = session.createDurableConsumer(auditTopic, "audit-sub"); // ── Selector on Events Store ────────────────────────────────────────── // SQL92 subset evaluated in the connector before delivery. MessageConsumer filtered = session.createConsumer( session.createTopic("events-store/audit"), "JMSPriority > 4 AND region = 'EU'"); // ── Request / reply (Commands) ──────────────────────────────────────── // Use a TemporaryQueue as the reply destination; the connector backs it // with a dynamic AMQP node (/responses/). Queue statusCmd = session.createQueue("commands/status"); TemporaryQueue replyQ = session.createTemporaryQueue(); Message req = session.createTextMessage("get-status"); req.setJMSReplyTo(replyQ); req.setJMSCorrelationID("req-001"); session.createProducer(statusCmd).send(req); Message reply = session.createConsumer(replyQ).receive(5000); System.out.println("Reply: " + ((TextMessage) reply).getText()); conn.close(); } } ``` **Acknowledge modes and AMQP settlement.** `AUTO_ACKNOWLEDGE` → AMQP `accepted` (AckRange). `CLIENT_ACKNOWLEDGE` → deferred `accepted` on `msg.acknowledge()`. `SESSION_TRANSACTED` is **not supported** — see [What Does NOT Migrate](#what-does-not-migrate--deviations). ## Security [#security] ### Authentication [#authentication] * **Opt-in connector.** The AMQP 1.0 connector is disabled by default. Set `CONNECTORS_AMQP10_ENABLE=true` (or `connectors.amqp10.enable = true` in TOML) to start it. * **No auth (development).** When authentication is disabled (the server default), the connector accepts SASL PLAIN with any credentials and also accepts SASL ANONYMOUS. If the server is reachable from untrusted networks, enable authentication or firewall ports 5672 / 5671. * **With auth enabled.** Pass a **KubeMQ JWT as the SASL PLAIN password**: ```java Connection conn = cf.createConnection("any-username", kubemqJWT); ``` The username is recorded for audit; the JWT's `ClientID` claim becomes the connection identity for authorization checks. ### Authorization [#authorization] With authorization enabled, the ClientID derived from the JWT is checked against the policy on the resolved channel at ATTACH time: * Producers (sender links) need **Write** on the channel. * Consumers (receiver links) need **Read** on the channel. A denied ATTACH fails with `amqp:unauthorized-access`; the JMS client throws a `JMSSecurityException`. ### TLS / mTLS [#tls--mtls] * **TLS (server auth).** Change the connection URL to `amqps://kubemq.example.com:5671`. The Qpid JMS client performs the standard TLS handshake; trust the server certificate using a JVM truststore or the `transport.trustStoreLocation` / `transport.trustStorePassword` URI options: ```text amqps://kubemq.example.com:5671?transport.trustStoreLocation=/path/to/truststore.jks ``` * **mTLS (client auth).** When the server requests a client certificate, the connector offers SASL EXTERNAL; the certificate CN becomes the ClientID (no JWT needed): ```text amqps://kubemq.example.com:5671?transport.keyStoreLocation=/path/to/keystore.jks&transport.keyStorePassword=secret ``` The TLS listener on port 5671 is active only when the server `Security` block is configured. See [Auth & Security](/connectors/reference/auth-and-security) for the shared connector security model. ### SASL mechanisms [#sasl-mechanisms] | Mechanism | When offered | Credential | | ----------- | ------------------------------ | ---------------------------------------------------------------- | | `PLAIN` | always | password = KubeMQ JWT (auth enabled) or anything (auth disabled) | | `ANONYMOUS` | authentication disabled | ClientID = sanitized container-id | | `EXTERNAL` | mTLS with verified client cert | ClientID = certificate CN | ## What Does NOT Migrate / Deviations [#what-does-not-migrate--deviations] ### Not supported [#not-supported] | JMS feature | Status | Notes | | ------------------------------------------ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **`SESSION_TRANSACTED`** | ❌ not supported | JMS transacted sessions rely on an AMQP transaction coordinator (`declare` / `discharge`), which the connector does not implement. Fall back to `AUTO_ACKNOWLEDGE` or `CLIENT_ACKNOWLEDGE` with idempotent message design. | | **XA transactions** | ❌ not supported | JMS XA (`XASession`, `XAConnectionFactory`) require two-phase AMQP transactions. Not available. | | **Client-settable DLQ** | ❌ no client DLQ | When a queue message exceeds `MaxReceiveCount` (default 1024), it is **silently dropped** — not delivered to a dead-letter address. The AMQP 1.0 connector never routes poison messages to a consumable dead-letter channel. Design for idempotency and monitor `kubemq_messages_dropped_total` (CounterVec; label `cause="dropped"`). | | **`session.unsubscribe()` (cluster-wide)** | ⚠️ node-local only | `unsubscribe()` removes the durable Events Store subscription registration on the node where it is called. If the durable subscription is active on another cluster node, that registration is not removed. Use the REST / dashboard API to delete durable subscriptions cluster-wide. | | **`rcv-settle-mode=second`** | ❌ not supported | Two-phase receiver settlement (exactly-once) is not supported. The model is at-least-once (queue / durable Events Store) or at-most-once (Events fire-hose). | | **AMQP-over-WebSocket** | ❌ not supported | No WebSocket binding; use raw TCP / TLS on 5672 / 5671. | | **Selectors on Queues** | ❌ not supported | Selector expressions on `queues/` links fail the ATTACH with `amqp:not-implemented`. Selectors work on Events and Events Store links. | | **JMS start-position from Qpid JMS** | ❌ not expressible | Qpid JMS exposes no API for the `x-opt-kubemq-start` link property — you cannot set an arbitrary Events-Store start position from JMS. Use a native durable consumer with `new-only`, or a native client (Go / .NET / Python / Rust / JS) for `first` / `last` / `sequence:` / `time:`. | ### Behavioral deviations [#behavioral-deviations] | Area | Behavior | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **RPC reply shape differs by pattern** | A JMS request / reply over a dynamic reply node behaves differently depending on the target pattern: a `commands/` reply carries **executed / error** properties, while a `queries/` reply carries **body + metadata only**. Read the reply accordingly — do not expect executed/error flags on a Queries response. | | **Pub/sub events at credit 0** | `events/` subscribers are fire-hose: if the consumer grants no link credit, events are dropped (`kubemq_amqp10_events_dropped_no_credit_total`). Grant credit continuously with an async consumer. | | **Events Store stalled credit** | A per-link buffer (`MaxUnsettledPerLink`, default 1024) fronts the durable subscription. If it fills while credit stays at 0, the link is detached (`amqp:resource-limit-exceeded`) and the buffered window is dropped. A durable re-attach resumes *after* the dropped window — those messages are lost. Size `MaxUnsettledPerLink` accordingly. | | **Durable-subscription registry is node-local** | Attaching the same durable subscription (same container-id + subscription name) on two cluster nodes produces a clientID conflict in the persistence engine. One attachment should be active at a time per durable-subscription identity. | | **`JMSXGroupID` / group-sequence** | Round-trips losslessly but provides no ordering or consumer-affinity guarantee in KubeMQ. | | **`header.priority`** | Round-trips as the `amqp10.priority` tag but drives no priority scheduling. | | **`released` increments receive count** | Releasing a message (rollback / `recover()`) increments the receive count, which counts toward `MaxReceiveCount`. A strict reading of AMQP would not count a release as a delivery attempt. | | **`modified{undeliverable-here=true}`** | Treated as a delivery failure (NAckRange); there is no per-consumer exclusion, so the message may be redelivered to the same consumer. | ### What does migrate cleanly [#what-does-migrate-cleanly] * JMS `AUTO_ACKNOWLEDGE` and `CLIENT_ACKNOWLEDGE` modes. * `Queue` (point-to-point), `Topic` (fan-out), and durable `Topic` subscriptions. * `TextMessage`, `BytesMessage`, `MapMessage`, and `ObjectMessage` body types (round-trip lossless for standard properties). * Selectors on Events / Events Store. * `JMSReplyTo` + `JMSCorrelationID` for request / reply over a `TemporaryQueue`. * `JMSPriority`, `JMSTimestamp`, `JMSMessageID`, `JMSType`, and `JMSCorrelationID` headers. ## Verification Smoke Test [#verification-smoke-test] Use the point-to-point block from the [Canonical Client Example](#canonical-client-example) as a send / receive confirmation: 1. **Enable the AMQP 1.0 connector** on the target KubeMQ server (`CONNECTORS_AMQP10_ENABLE=true`) and confirm port 5672 is reachable. 2. **Produce one message** — run the snippet's "Point-to-point (Queues)" block to publish a `TextMessage` to `queues/orders`. 3. **Consume it** — run the `createConsumer(ordersQueue).receive(5000)` block and confirm the text matches. 4. **Check the dashboard** — the KubeMQ dashboard AMQP 1.0 page should show one connection, one sender link, one receiver link, and one transfer in / out. 5. **Confirm zero dropped messages** — `kubemq_amqp10_events_dropped_no_credit_total` and `kubemq_amqp10_events_store_dropped_stalled_total` should remain 0. ## See Also [#see-also] # Migration (/connectors/how-to/migration) This hub is the entry point for migrating an application from an external messaging ecosystem onto KubeMQ's **wire-protocol connectors**. Each guide covers what migrates cleanly, what migrates with caveats, and what does not migrate at all — the endpoint/connection change, the concept-to-pattern mapping, a working code example, the security posture, and a copy-pasteable smoke test. **Important framing:** the nine covered ecosystems are *ecosystems*, not nine connectors. KubeMQ has **seven wire-protocol connectors**; several ecosystems ride the same connector. For example, JMS, ActiveMQ-Java, and native AMQP 1.0 clients all use the **AMQP 1.0 connector** on port 5672. ## Ecosystem → Connector Map [#ecosystem--connector-map] Each ecosystem maps onto one (or, for ActiveMQ, several) of the seven wire-protocol connectors. Follow the connector link for that protocol's full reference; follow the guide link in the [Guides](#guides) table below for the step-by-step migration. | Ecosystem | KubeMQ connector | Default port(s) | Canonical client (this suite) | | ----------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------- | ---------------------------------------- | | JMS | [AMQP 1.0](/connectors/amqp) | 5672 / 5671 | Apache Qpid JMS | | ActiveMQ | [AMQP 1.0](/connectors/amqp) (Java/JMS); [STOMP](/connectors/stomp) & [MQTT](/connectors/mqtt) for non-Java | 5672 / 5671, 61613 / 61614, 1883 / 8883 | Apache Qpid JMS (+ STOMP / MQTT clients) | | RabbitMQ | [RabbitMQ (AMQP 0-9-1)](/connectors/rabbitmq) | 5672 / 5671 | `pika` (Python) | | AMQP 1.0 | [AMQP 1.0](/connectors/amqp) | 5672 / 5671 | `go-amqp` (canonical; AMQP.NET Lite alt) | | AWS SQS/SNS | [AWS](/connectors/aws) | 4566 (HTTP) | AWS SDK (`boto3`) | | STOMP | [STOMP](/connectors/stomp) | 61613 / 61614 | `stomp.py` | | GCP Pub/Sub | [Google Cloud Pub/Sub](/connectors/gcp-pub-sub) | 8085 (gRPC) | `google-cloud-pubsub` | | MQTT | [MQTT](/connectors/mqtt) | 1883 / 8883 / 8083 | Eclipse Paho | | Kafka | [Kafka](/connectors/kafka) | 9092 / 9093 | `kcat` | AMQP 0-9-1 (RabbitMQ) and AMQP 1.0 share ports 5672 / 5671 — the server inspects the protocol header and routes each connection to the right connector. The AWS port (4566) is a client-side endpoint convention set in `Connectors.Aws.Port`; it is not a fixed broker listener and does not appear in the standard broker port table. ## Cross-Protocol Comparison [#cross-protocol-comparison] The table below is transposed: **axes are rows, ecosystems are columns**. Each cell uses ✅ full / ⚠️ partial / ❌ none / N/A (inapplicable — a concept that does not exist in that ecosystem). ❌ means the concept exists in the source ecosystem but the KubeMQ connector does not support it. | Axis | JMS | ActiveMQ | RabbitMQ | AMQP 1.0 | AWS SQS/SNS | STOMP | GCP Pub/Sub | MQTT | Kafka | | ---------------------------------- | --------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------- | --------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | | **Drop-in level** | client-swap | client-swap / endpoint | endpoint-only | endpoint / client | endpoint-only | endpoint-only | endpoint-only⁴ | endpoint-only⁵ | endpoint-only | | **Point-to-point queues** | ✅ | ✅ | ✅ | ✅ | ✅ SQS | ✅ | N/A | ✅ `queues/*` | N/A (topic/partition model) | | **Pub/sub (non-durable)** | ✅ | ✅ | ✅ exchanges | ✅ | ✅ SNS | ✅ | ✅ topic→Events Store | ✅ `events/*`, `store/*` | ✅ native produce/consume | | **Durable / persistent subs** | ✅¹ | ✅¹ | ✅ durable queues | ✅¹ | ✅ (SQS durable) | ✅ groups + replay | ✅ sub→Queue | ⚠️ `store/*` new-only (no replay) | ✅ committed offsets (consumer groups) | | **Request / reply (RPC)** | ✅ | ✅² | ✅ Direct Reply-To | ✅ | N/A (no RPC) | ✅ reply-to | N/A | ✅ (v5 only) | N/A (no protocol-level RPC) | | **Ordering guarantee** | ⚠️ node-local | ⚠️ node-local | ✅ per-queue³ | ⚠️ node-local | ✅ FIFO | ⚠️ | ⚠️ keys node-local | ⚠️ QoS-dependent | ✅ per-partition | | **Transactions** | ❌ | ❌ | ❌ (use confirms) | ❌ | N/A | ❌ (rejected) | N/A | N/A | ✅⁷ EOS V1 wire | | **Dead-letter / redrive** | ❌ no client DLQ⁶ | ❌ no client DLQ⁶ | ✅ DLX + `x-death` | ❌ no client DLQ⁶ | ✅ redrive + move-task | ❌ no client DLQ⁶ | ✅ dead-letter topic | ❌ no client DLQ⁶ | N/A (no protocol-level DLQ) | | **Selectors / filter / wildcards** | ✅ selectors (SQL92 subset) | ✅ (AMQP 1.0) / ❌ (STOMP) | ✅ topic/headers routing | ✅ selectors (SQL92 subset) | ✅ SNS filter policies | ❌ no selectors | ✅ CEL-subset filter | ⚠️ wildcards Events-only | N/A (no broker-side filter) | | **Auth model** | PLAIN(JWT)/EXTERNAL | PLAIN(JWT) | SASL PLAIN(JWT) | PLAIN/EXTERNAL | SigV4 / accept-any | JWT (CONNECT) | ❌ none (emulator) | JWT (password) | SASL PLAIN/SCRAM/OAUTHBEARER + mTLS | | **TLS / mTLS** | ✅ 5671 | ✅ | ✅ 5671 | ✅ 5671 | ❌ connector (proxy) | ✅ 61614 | ❌ none (emulator) | ✅ 8883 / wss 8083 | ✅ 9093 | | **Top unsupported** | JMS transactions/XA; durable-unsub node-local | OpenWire protocol; transactions; selectors on STOMP path | `tx.*`; e2e bindings; immediate; recover-async; update-secret; inert: alt-exchange, max-length, x-expires | transactions; durable-unsub node-local | SNS email/SMS/Lambda/push; queue/topic IAM policies; TLS at connector | transactions; selectors | no auth/TLS; IAM stubs; BigQuery/GCS export subs; ordering/exactly-once node-local; no 24h default retention | MQTT 3.1; retained messages; clients-as-responders; wildcards on non-Events; node-local sessions | >256 partitions; RF>1; KIP-848 next-gen groups; Kerberos/GSSAPI; delegation tokens; horizontal write scale | **Footnotes:** ¹ Durable subscription via Events Store; `unsubscribe()` is node-local (the unsubscribe only takes effect on the node that owns the durable subscription). ² ActiveMQ RPC via the AMQP 1.0 (Qpid JMS) path. ³ Requeued messages re-enter at the queue **tail** — this is a deviation from RabbitMQ classic queues, which preserve near-head position. ⁴ Set `PUBSUB_EMULATOR_HOST=host:8085`; no credentials required. ⁵ MQTT **3.1 clients are rejected** at CONNECT; use MQTT 3.1.1 (Paho default) or 5.0. ⁶ No client-settable dead-letter queue over this protocol. The AMQP 1.0, STOMP, and MQTT connectors never set a redrive target on published messages, so a poison message exceeding `MaxReceiveCount` is silently dropped by the broker — it is not delivered to any consumable dead-letter address. Genuine client-facing DLQ exists only for: * **RabbitMQ** — DLX, sets a broker redrive target. * **AWS** — redrive policy, also sets a broker redrive target. * **GCP** — dead-letters at the connector level by re-publishing to the configured dead-letter topic (new message IDs), not via a broker redrive target. ⁷ Kafka transactions / exactly-once semantics (V1 wire surface) are supported at proof-tier T2 — safe for at-least-once delivery plus basic exactly-once semantics — but this is **not** a KIP-890 soundness guarantee. See the [fitness matrix](/connectors/kafka/reference/fitness-matrix) for the full tier breakdown. ## Drop-In Levels [#drop-in-levels] Each guide states its **drop-in level** — how much application change the migration requires: * **endpoint-only** — change the host/port or set an environment variable; your existing client library and code are unchanged. * **client-swap** — swap the client library (for example, switch your JMS `ConnectionFactory` to Apache Qpid JMS); application code remains mostly unchanged. * **partial-rewrite** — some application-level changes are required beyond the client and the endpoint. A guide may list a **composite** level (for example `endpoint / client`) when the level depends on the client type — the lower level covers simple publish/consume, and the higher level applies only where a specific feature needs it. ## Before You Start [#before-you-start] Every guide's smoke test assumes a running KubeMQ instance with the relevant connector turned on. All seven wire-protocol connectors are **opt-in (disabled by default)** — a stock server does not bind their listeners until you enable them. Enable a connector by setting its `CONNECTORS__ENABLE` environment variable to `true` and publishing its port. For example, to bring up a throwaway local server with the AMQP 1.0 connector enabled: Each guide lists the **exact enable variable and ports for its connector** (the MQTT variable is the irregular `CONNECTORSMQTT_ENABLE`, with no underscore). To turn a connector back off, set the same variable to `false`. No live-broker validation is required to adopt a guide — the smoke test is for your own verification after deployment. ## Guides [#guides] The drop-in level in each row tells you, at a glance, how much application change to expect. | Guide | Drop-in | One-line summary | | ------------------------------------------------------------------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Migrating from JMS](/connectors/how-to/migration/from-jms) | client-swap | Swap the JMS `ConnectionFactory` to Apache Qpid JMS over AMQP 1.0; application code is unchanged if you avoid transactions / XA. | | [Migrating from ActiveMQ](/connectors/how-to/migration/from-activemq) | client-swap / endpoint | Java/JMS apps via Qpid JMS; non-Java apps via STOMP or MQTT by endpoint; OpenWire is not supported. | | [Migrating from RabbitMQ](/connectors/rabbitmq/reference/migration-from-rabbitmq) | endpoint-only | Host-swap the AMQP 0-9-1 URI; exchanges, bindings, DLX, and publisher confirms work; `tx.*` does not. | | [Migrating from AMQP 1.0](/connectors/how-to/migration/from-amqp-1-0) | endpoint / client | Point a native AMQP 1.0 client at KubeMQ; queues, topics, durable subs, and RPC all map; no transactions. | | [Migrating from AWS SQS/SNS](/connectors/aws/reference/migration-from-aws) | endpoint-only | Override the AWS SDK endpoint to KubeMQ; SQS, SNS, FIFO, and DLQ migrate; email/SMS/Lambda do not. | | [Migrating from STOMP](/connectors/stomp/scenarios/migration-from-stomp) | endpoint-only | Change the STOMP broker host; all destination types map; no transactions or selectors. | | [Migrating from Google Cloud Pub/Sub](/connectors/gcp-pub-sub/reference/migration-from-gcp) | endpoint-only | Set `PUBSUB_EMULATOR_HOST`; topics and subscriptions map; no auth/TLS; ordering is node-local. | | [Migrating from MQTT](/connectors/mqtt/scenarios/migration) | endpoint-only | Change the MQTT broker host; 3.1.1 and 5.0 only; retained messages and MQTT 3.1 are not supported. | | [Migrating from Kafka](/connectors/kafka/how-to/migrate-from-kafka) | endpoint-only | Repoint `bootstrap.servers`; native clients connect unchanged. Assess fit with `kmq assess kafka`, then move existing topics + consumer-group offsets with the beta `kmq migrate` tool; runs on the `next` storage engine. | # Capabilities (/connectors/gcp-pub-sub/reference/capabilities) This reference defines exactly what the embedded KubeMQ Pub/Sub connector **supports**, what it **accepts-and-ignores**, and what it **rejects**. The connector implements the real Pub/Sub v1 gRPC services — **38 RPCs** total — so unmodified Google client libraries and `gcloud pubsub` talk to KubeMQ exactly as they would to Google's local emulator. Use it to decide which Pub/Sub SDK calls are safe to rely on and which ones are refused. Milestone tags below: **M1** core drop-in · **M2** advanced delivery · **M3** full parity. ## The 38-RPC matrix [#the-38-rpc-matrix] | Service | Count | | -------------------------------- | -------------------- | | `google.pubsub.v1.Publisher` | 9 | | `google.pubsub.v1.Subscriber` | 16 | | `google.pubsub.v1.SchemaService` | 10 | | `google.iam.v1.IAMPolicy` | 3 (permissive stubs) | | **Total** | **38** | ## Publisher (9) [#publisher-9] | # | RPC | M | Notes | | - | ------------------------ | -- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1 | `CreateTopic` | M1 | Validates the name; `kms_key_name` accepted-and-ignored; ingestion configs rejected (`INVALID_ARGUMENT`); retention clamped to the broker ceiling. Backing log `gcp.{t}` | | 2 | `GetTopic` | M1 | Returns the **requested (un-clamped)** retention | | 3 | `ListTopics` | M1 | Opaque page token | | 4 | `DeleteTopic` | M1 | **Tombstone** — the record is retained so existing subscriptions survive; re-creating the topic reuses the log | | 5 | `Publish` | M1 | Batch (≤ 1000); assigns id + publish-time; writes the topic log once, then fans out one queue copy per subscription (applying each sub's filter). M3 adds schema enforce-on-publish | | 6 | `ListTopicSubscriptions` | M1 | | | 7 | `UpdateTopic` | M2 | FieldMask over `labels`, `message_retention_duration`, `schema_settings` | | 8 | `ListTopicSnapshots` | M3 | | | 9 | `DetachSubscription` | M3 | Marks detached, drops backlog + leases; the subscription stays re-fillable by `Seek` | ## Subscriber (16) [#subscriber-16] | # | RPC | M | Notes | | -- | -------------------- | -- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1 | `CreateSubscription` | M1 | Binds to a topic; queue `gcp.sub.{s}` created lazily. Export subscriptions (BigQuery/Cloud Storage/Bigtable) and ingestion are rejected. Filter compiled at create-time (**immutable**) | | 2 | `GetSubscription` | M1 | Returns the requested (un-clamped) retention | | 3 | `ListSubscriptions` | M1 | | | 4 | `DeleteSubscription` | M1 | Drops backlog + leases | | 5 | `UpdateSubscription` | M2 | FieldMask over ack deadline / retention / DLQ / retry / push / exactly-once / labels. `name` and `filter` are **immutable** | | 6 | `Pull` | M1 | `max_messages ≤ 1000`; a detached subscription → `FAILED_PRECONDITION` | | 7 | `Acknowledge` | M1 | Decodes `ack_id` → acks the broker sequence. Exactly-once subs return a status for invalid/expired ids (below) | | 8 | `ModifyAckDeadline` | M1 | `0` = immediate nack/redeliver; `>0` = extend (10..600 s) | | 9 | `StreamingPull` | M1 | Bidirectional; per-stream flow control; exactly-once confirmations; periodic server-initiated close every `StreamCloseSeconds` | | 10 | `ModifyPushConfig` | M2 | Switch pull ↔ push; an empty config returns to pull | | 11 | `Seek` | M3 | To a timestamp or a snapshot; replays the topic log into the subscription queue, bounded by `MaxSeekReplay` | | 12 | `CreateSnapshot` | M3 | Cursor snapshot, 7-day default expiry | | 13 | `GetSnapshot` | M3 | | | 14 | `ListSnapshots` | M3 | | | 15 | `UpdateSnapshot` | M3 | May change `labels` and `expire_time` | | 16 | `DeleteSnapshot` | M3 | | The 250 ms lease sweeper, retry backoff, dead-letter republish, and per-key ordering cursor are connector-internal mechanics behind these RPCs — see [Subscribing](/connectors/gcp-pub-sub/how-to/subscribing) and [Reliability](/connectors/gcp-pub-sub/how-to/reliability). ## SchemaService (10, M3) [#schemaservice-10-m3] `CreateSchema`, `GetSchema` (BASIC/FULL), `ListSchemas`, `ListSchemaRevisions`, `CommitSchema`, `RollbackSchema`, `DeleteSchemaRevision` (keeps ≥ 1), `DeleteSchema`, `ValidateSchema`, `ValidateMessage` — over **Avro** and **Protobuf** definitions, each with server-assigned revision ids and full revision history. A definition that fails to parse → `INVALID_ARGUMENT`; definitions are capped at **300 KB**. See [Schema Validation](/connectors/gcp-pub-sub/how-to/schema-validation). ## IAMPolicy (3, M2) [#iampolicy-3-m2] `GetIamPolicy` returns an empty `Policy{Version: 3}`; `SetIamPolicy` and `TestIamPermissions` **echo** the request. These are **permissive stubs** — there is no IAM enforcement (emulator parity). The connector runs with **no authentication**. ## Feature support [#feature-support] | Feature | Supported? | Notes | | ----------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------- | | Basic publish / pull / ack | ● | M1 core drop-in | | Batch publish (≤ 1000) | ● | Atomic — the whole batch is validated before anything is enqueued | | StreamingPull + flow control | ● | Per-stream `max_outstanding_messages` / `_bytes`; `≤ 0` = unlimited (capped by `MaxOutstandingMessages`) | | Ordering keys | ● | `enable_message_ordering`; at most one in flight per key, in-order redelivery | | Attribute filtering | ● | CEL-**subset**, attributes-only, ≤ 256 chars, immutable, applied at fan-out | | Dead-letter topic | ● | `dead_letter_topic` + `max_delivery_attempts` (5..100) | | Push delivery | ● | Wrapped JSON envelope or `no_wrapper`; optional OIDC Bearer; HTTPS (HTTP only for localhost) | | Exactly-once delivery | ● **(node-local)** | `enable_exactly_once_delivery`; an `ack_id` is valid only on the node that minted it | | Seek to timestamp / snapshot | ● | Replays the topic log; a pre-window timestamp **clamps** to the earliest retained message | | Snapshots | ● | 7-day default expiry, swept hourly | | Schema validation (Avro / Protobuf) | ● | Enforce-on-publish; whole batch rejected on the first non-conforming message | | Cross-protocol interop | ● | Pub/Sub publish ⇄ native KubeMQ consume on Events Store `gcp.{t}` | | IAM enforcement | ○ | Permissive stubs only; no enforcement | ## Accepted-and-ignored / rejected [#accepted-and-ignored--rejected] These are documented non-goals. They are **never** used as working examples, and most are actively rejected by the connector with the [error code](/connectors/gcp-pub-sub/reference/error-codes) shown: | Operation / field | Behavior | | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | `kms_key_name` (on `CreateTopic`) | **accepted and ignored** — no KMS in emulator mode | | Project segment `projects/{p}/…` | **parsed, validated, then ignored** — single-tenant; resource ids are global across projects | | Ingestion sources (topic / subscription) | **rejected** (`INVALID_ARGUMENT`) — no KubeMQ analog | | Export subscriptions (BigQuery / Cloud Storage / Bigtable) | **rejected** (`INVALID_ARGUMENT`) | | REST / JSON v1 (grpc-gateway) | **not served** — gRPC only | | Google OAuth2 / JWT / IAM | **not validated** — emulator mode, no auth | ## The documented gotchas [#the-documented-gotchas] These behaviors deviate from real Google Cloud Pub/Sub and are easy to miss until a corner case hits production. Each is documented in depth where shown: | # | Gotcha | Where documented | | - | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1 | **Exactly-once is node-local** — an `ack_id` minted on one node is invalid on another; pin a subscription's StreamingPull to one node (sticky LB) or accept at-least-once across nodes | [Subscribing](/connectors/gcp-pub-sub/how-to/subscribing), [Reliability](/connectors/gcp-pub-sub/how-to/reliability), [Migrating from Google Cloud Pub/Sub](/connectors/gcp-pub-sub/reference/migration-from-gcp) | | 2 | **Project id parsed but ignored** — resource ids are global across projects | [Getting Started](/connectors/gcp-pub-sub/tutorials/getting-started), [Channel Mapping](/connectors/gcp-pub-sub/reference/channel-mapping) | | 3 | **Credentials cleared / insecure path when `PUBSUB_EMULATOR_HOST` is set** — the SDK skips Google auth and dials insecure gRPC | [Connectivity & Emulator Mode](/connectors/gcp-pub-sub/how-to/connectivity-and-emulator-mode) | | 4 | **Filtering is an attributes-only CEL-subset** — malformed → `INVALID_ARGUMENT` | [Message Filtering](/connectors/gcp-pub-sub/how-to/filtering), [Limits & Rules](/connectors/gcp-pub-sub/reference/limits-and-rules) | | 5 | **Retention clamped to the broker maximum** — `GetTopic` / `GetSubscription` echo the requested value; fan-out, seek, and the dashboard use the clamped value | [Limits & Rules](/connectors/gcp-pub-sub/reference/limits-and-rules), [Seek & Snapshots](/connectors/gcp-pub-sub/how-to/seek-and-snapshots) | | 6 | **`max_delivery_attempts` must be 5..100** (0 = unset) | [Reliability](/connectors/gcp-pub-sub/how-to/reliability), [Limits & Rules](/connectors/gcp-pub-sub/reference/limits-and-rules) | | 7 | **Topic ids may not start with `sub.`** (reserved namespace) | [Channel Mapping](/connectors/gcp-pub-sub/reference/channel-mapping), [Limits & Rules](/connectors/gcp-pub-sub/reference/limits-and-rules) | | 8 | **Seek of a pre-window timestamp clamps to the earliest retained message** (not an error) | [Seek & Snapshots](/connectors/gcp-pub-sub/how-to/seek-and-snapshots) | | 9 | **gRPC only — no REST/JSON v1** (grpc-gateway not used) | [Architecture](/connectors/gcp-pub-sub/concepts/architecture), this page | Two further documented deviations are surfaced in the reference docs but are not headline gotchas: the **unary invalid-ack** returns `FAILED_PRECONDITION` + `ErrorInfo(PERMANENT_FAILURE_INVALID_ACK_ID)` (matching the real SDK contract, not a literal `INVALID_ARGUMENT`), and **export subscriptions / ingestion are rejected** while `kms_key_name` is accepted-and-ignored. Both live in [Error Codes](/connectors/gcp-pub-sub/reference/error-codes) and [Migrating from Google Cloud Pub/Sub](/connectors/gcp-pub-sub/reference/migration-from-gcp). ## Related [#related] # Channel Mapping (/connectors/gcp-pub-sub/reference/channel-mapping) This is the master reference for how the embedded KubeMQ Pub/Sub connector maps Pub/Sub topics and subscriptions onto KubeMQ. A topic is backed by exactly one KubeMQ **Events Store** log; each subscription is backed by its own KubeMQ **Queue** channel. This deterministic mapping is the contract that makes cross-protocol interop work — a Pub/Sub `Publish` and a native KubeMQ consume meet on the same channel. ## Resource → channel table [#resource--channel-table] | Pub/Sub resource | KubeMQ object | Channel | | --------------------------------------------- | ---------------- | -------------- | | Topic `projects/{p}/topics/{t}` | Events Store log | `gcp.{t}` | | Subscription `projects/{p}/subscriptions/{s}` | Queue | `gcp.sub.{s}` | | Snapshot / Schema | Registry record | — (no channel) | The `{p}` **project segment is parsed and validated but ignored** — the connector is single-tenant, like the emulator, so resource ids are **global across projects**. Two clients using different project ids but the same topic id share one `gcp.{t}` log. ## Topic grammar [#topic-grammar] A topic maps to exactly one KubeMQ Events Store log: ```text gcp.{t} └┬┘ └┬┘ │ └─ the topic id (the bare id you pass to CreateTopic — NOT the projects/.../topics/ path) └─ fixed connector prefix ("gcp.") ``` | Pub/Sub topic | KubeMQ channel | | ------------- | --------------- | | `orders` | `gcp.orders` | | `events` | `gcp.events` | | `audit-log` | `gcp.audit-log` | **Topic ids may not start with `sub.`** (gotcha #7). `sub.` is the reserved namespace for the subscription queues below, so a topic id beginning `sub.` is rejected at create (`INVALID_ARGUMENT`). See [Limits & Rules](/connectors/gcp-pub-sub/reference/limits-and-rules) for the full id grammar. ## Subscription grammar [#subscription-grammar] Each subscription maps to its own KubeMQ Queue channel: ```text gcp.sub.{s} └──┬───┘ └┬┘ │ └─ the subscription id (bare, not the projects/.../subscriptions/ path) └─ fixed connector prefix + reserved sub-namespace ("gcp.sub.") ``` | Pub/Sub subscription | KubeMQ channel | | -------------------- | -------------------- | | `orders-sub` | `gcp.sub.orders-sub` | | `analytics` | `gcp.sub.analytics` | A subscription id may not contain the reserved `.k.` or `.h.` infixes — those are owned by the per-key channel grammar below. ## Per-ordering-key channels [#per-ordering-key-channels] When a subscription enables message ordering, the connector routes **each ordering key onto its own queue channel** so the broker preserves per-key order natively. The base `gcp.sub.{s}` channel continues to serve keyless messages: ```text gcp.sub.{s}.k.{enc(key)} └───┬────┘ └┬┘ └───┬────┘ │ │ └─ the ordering key, percent-encoded │ └─ fixed ".k." per-key separator └─ the subscription's base queue channel ``` `enc` percent-encodes any byte outside `[a-zA-Z0-9_-]`. If the encoded channel name would exceed its length budget, the connector falls back to a deterministic sha256 form `gcp.sub.{s}.h.{hash}` — the same key always hashes to the same channel on every node, so per-key order is still preserved (the fallback only couples multiple long keys onto one FIFO channel). | Subscription | Ordering key | KubeMQ channel | | ------------ | ----------------- | ---------------------------------- | | `orders-sub` | `tenant-a` | `gcp.sub.orders-sub.k.tenant-a` | | `orders-sub` | `order/42` | `gcp.sub.orders-sub.k.order%2F42` | | `orders-sub` | *(very long key)* | `gcp.sub.orders-sub.h.{sha256hex}` | ## Write-once-then-fan-out [#write-once-then-fan-out] A `Publish` is **not** copied to every subscription on the wire. The connector: 1. **Writes once** to the topic log `gcp.{t}` via `Array.SendEventsStore` — the authoritative, cross-protocol, replayable copy and the source for `Seek`. 2. **Fans out** one queue copy per subscription via `Array.SendQueueMessage(gcp.sub.{s})`, **applying each subscription's filter** — a filtered-out message is never enqueued (≈ auto-acked). Detached subscriptions are skipped. So the topic log holds the complete history; each subscription queue holds the filtered slice that subscription still owes its consumers. ## Reserved tags [#reserved-tags] A `PubsubMessage` becomes a KubeMQ message whose **body is `data`** and whose **Tags are the message `attributes`** plus **three reserved tags** carried across the wire: | Reserved tag | Carries | Set by | | ---------------------- | -------------------------------------- | ---------------------- | | `_pubsub_message_id` | the server-assigned message id | connector on `Publish` | | `_pubsub_publish_time` | the publish timestamp | connector on `Publish` | | `_pubsub_ordering_key` | the ordering key (only if one was set) | connector on `Publish` | **Native consumers see these tags; Pub/Sub clients do not.** When the connector delivers a message back to a Pub/Sub client it strips the three `_pubsub_*` tags out of `attributes` and surfaces them as the native fields (`messageId`, `publishTime`, `orderingKey`). A native KubeMQ consumer reading `gcp.{t}` reads them as ordinary Tags — this is how a cross-protocol consumer recovers the message id and publish time. ### Attribute ⇄ tag round-trip [#attribute--tag-round-trip] | Pub/Sub field | KubeMQ Tag | Notes | | -------------------------------- | --------------------------------- | ---------------------------------------------------------------------------- | | message `attribute {Name}` | Tag `{Name}` | round-trips losslessly; ≤ 100 attrs, key ≤ 256 B (no `goog`), value ≤ 1024 B | | `data` | message body | ≤ 10 MiB total per message | | ordering key | `_pubsub_ordering_key` (reserved) | ≤ 1024 B | | *(server-assigned)* message id | `_pubsub_message_id` (reserved) | | | *(server-assigned)* publish time | `_pubsub_publish_time` (reserved) | | Attribute keys must not start with `goog` (Google's reserved prefix); the connector enforces this at publish (`INVALID_ARGUMENT`). See [Limits & Rules](/connectors/gcp-pub-sub/reference/limits-and-rules). ## Cross-protocol interoperability [#cross-protocol-interoperability] Because the topic log is a normal KubeMQ Events Store channel, a Pub/Sub `Publish` to topic `orders` is consumable by a native gRPC/REST Events Store subscriber on channel `gcp.orders`, and a subscription's backlog is a native Queue channel `gcp.sub.{s}`. **Deterministic read.** Subscribe to the Events Store log with start policy `startAt = "new"` **before** issuing the Pub/Sub publish, so the published message is guaranteed in-window for the native consumer (no startup race). See [Cross-Protocol Interop](/connectors/gcp-pub-sub/concepts/cross-protocol-interop). ## The registry is authoritative [#the-registry-is-authoritative] Topics, subscriptions, snapshots, and schemas live in a **per-node replicated registry** (synchronized across cluster nodes with a last-writer-wins rule). Only resources created through the Pub/Sub API are visible to the Pub/Sub surface — a `Pull` from a subscription that was never `CreateSubscription`d returns `NOT_FOUND`. (A native client can still read the raw `gcp.{t}` log directly regardless of the registry.) ## Related [#related] # Configuration reference (/connectors/gcp-pub-sub/reference/configuration) All values below are verified against `[Connectors.Gcp]` (`GcpConfig`) in the KubeMQ server. For the framing behind these settings — enabling the connector and its security posture — see [Configuration concepts](../concepts/configuration). ## Configuration fields [#configuration-fields] Compound camelCase fields are snake-split in their env form — e.g. `MaxMessageBytes` → `CONNECTORS_GCP_MAX_MESSAGE_BYTES`, `DefaultAckDeadlineSeconds` → `CONNECTORS_GCP_DEFAULT_ACK_DEADLINE_SECONDS`. | Env var | Default | Meaning / validation | | ---------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `CONNECTORS_GCP_ENABLE` | `false` | **Opt-in.** `true` enables the connector and binds port 8085. | | `CONNECTORS_GCP_PORT` | `"8085"` | The gRPC listen port (the emulator convention). Must be valid and **distinct** from any enabled gRPC/REST/HTTP or AWS-connector port. | | `CONNECTORS_GCP_ADVERTISED_ENDPOINT` | `""` | Cosmetic `host:port` shown in the dashboard's `PUBSUB_EMULATOR_HOST` hint. Does not affect listening. | | `CONNECTORS_GCP_MAX_MESSAGE_BYTES` | `10485760` | Max total message size (10 MiB). Also sizes the gRPC receive frame. | | `CONNECTORS_GCP_DEFAULT_ACK_DEADLINE_SECONDS` | `10` | Default ack deadline for new subscriptions. Must be **10..600**. | | `CONNECTORS_GCP_MAX_OUTSTANDING_MESSAGES` | `1000` | Per-`StreamingPull`-stream flow-control ceiling when the client requests unlimited. | | `CONNECTORS_GCP_MAX_INFLIGHT_PER_SUBSCRIPTION` | `20000` | Hard cap on leased (un-acked) messages per subscription. | | `CONNECTORS_GCP_MAX_CONCURRENT_POLLS` | `1024` | Poller-slot budget (a DoS guard). | | `CONNECTORS_GCP_DELIVERY_SHARDS` | `16` | Size of the striped delivery-worker pool (fan-out concurrency). Must be **1..256**. | | `CONNECTORS_GCP_MAX_ACK_EXTENSION_SECONDS` | `600` | Ack-deadline keep-alive budget for the ordered head. `0` disables the keep-alive (expiry → redeliver); otherwise **10..3600**. | | `CONNECTORS_GCP_STREAM_CLOSE_SECONDS` | `1800` | Periodic `StreamingPull` close interval (forces a transparent SDK reconnect; bounds per-stream resource lifetime). | | `CONNECTORS_GCP_MAX_SEEK_REPLAY` | `1000000` | Max messages replayed by a single `Seek` (hits the cap → `WARN`, never silent loss). | | `CONNECTORS_GCP_ENABLE_REFLECTION` | `false` | Register gRPC server reflection (for debugging). | `Validate()` is a no-op when the connector is disabled. When enabled, the port must be valid and unique, the ack deadline must be 10..600, `DeliveryShards` must be 1..256, `MaxAckExtensionSeconds` must be `0` or 10..3600, and the six remaining numeric knobs must be positive. See [Limits & rules](/connectors/gcp-pub-sub/reference/limits-and-rules) for the full numeric table. ## Examples [#examples] The same settings can be supplied through a TOML config file, environment variables, or `docker run` flags. Every environment variable uses the `CONNECTORS_GCP_` prefix (with the underscore between `CONNECTORS` and `GCP`). ```toml title="config.toml" [Connectors.Gcp] Enable = true Port = "8085" AdvertisedEndpoint = "" MaxMessageBytes = 10485760 DefaultAckDeadlineSeconds = 10 MaxOutstandingMessages = 1000 MaxInflightPerSubscription = 20000 MaxConcurrentPolls = 1024 DeliveryShards = 16 MaxAckExtensionSeconds = 600 StreamCloseSeconds = 1800 MaxSeekReplay = 1000000 EnableReflection = false ``` ```bash title="gcp.env" CONNECTORS_GCP_ENABLE=true CONNECTORS_GCP_PORT=8085 CONNECTORS_GCP_ADVERTISED_ENDPOINT= CONNECTORS_GCP_MAX_MESSAGE_BYTES=10485760 CONNECTORS_GCP_DEFAULT_ACK_DEADLINE_SECONDS=10 CONNECTORS_GCP_MAX_OUTSTANDING_MESSAGES=1000 CONNECTORS_GCP_MAX_INFLIGHT_PER_SUBSCRIPTION=20000 CONNECTORS_GCP_MAX_CONCURRENT_POLLS=1024 CONNECTORS_GCP_DELIVERY_SHARDS=16 CONNECTORS_GCP_MAX_ACK_EXTENSION_SECONDS=600 CONNECTORS_GCP_STREAM_CLOSE_SECONDS=1800 CONNECTORS_GCP_MAX_SEEK_REPLAY=1000000 CONNECTORS_GCP_ENABLE_REFLECTION=false ``` The Docker example includes `CONNECTORS_GCP_ENABLE=true` — without it the connector stays disabled and port 8085 is not bound. `CONNECTORS_GCP_ADVERTISED_ENDPOINT` is a **cosmetic** dashboard hint (the `PUBSUB_EMULATOR_HOST` value to copy); it never changes the listening port. ## Related [#related] # Connections & Observability (/connectors/gcp-pub-sub/reference/connections-endpoint) This reference documents the Pub/Sub connector's observability surface: the single **gRPC connection endpoint** (the emulator listener), the **read-only management view** in the dashboard, the **Prometheus metrics**, and the **audit events**. There is **no REST/JSON v1 endpoint** (gRPC only) and no management REST API distinct from the broker's. ## Connection endpoints [#connection-endpoints] | Endpoint | Default | Transport | Who connects | How | | ------------------------- | -------- | ------------------------------------ | ------------------------------------------------ | ----------------------------------------- | | Pub/Sub emulator listener | `:8085` | gRPC, **insecure** (no TLS, no auth) | unmodified Google Pub/Sub SDKs + `gcloud pubsub` | `export PUBSUB_EMULATOR_HOST=:8085` | | Native KubeMQ broker | `:50000` | gRPC | native cross-protocol consumers on `gcp.{t}` | a KubeMQ SDK against `localhost:50000` | | KubeMQ REST | `:9090` | HTTP | *not used by this connector (gRPC only)* | — | ### The emulator drop-in [#the-emulator-drop-in] Setting `PUBSUB_EMULATOR_HOST` is the entire contract — every official client library and `gcloud` honour it: when set, the SDK **clears credentials, skips Google auth, and dials insecure gRPC**, exactly as against Google's local emulator. ```bash export PUBSUB_EMULATOR_HOST=localhost:8085 # connector default port; SDK uses the insecure path export PUBSUB_PROJECT_ID=my-project # any id; the project segment is parsed but ignored ``` `gcloud` uses an explicit endpoint override instead of the env var: ```bash gcloud config set api_endpoint_overrides/pubsub http://localhost:8085/ ``` **`AdvertisedEndpoint` hint.** The cosmetic `CONNECTORS_GCP_ADVERTISED_ENDPOINT` config value (`host:port`) is what the dashboard shows in its `PUBSUB_EMULATOR_HOST` hint — it does **not** change the listen address. See [Configuration](/connectors/gcp-pub-sub/concepts/configuration). ### Security posture [#security-posture] No authentication, no TLS — emulator mode by design. DoS guards (`MaxMessageBytes` / `MaxInflightPerSubscription` / `MaxConcurrentPolls` / `MaxSeekReplay` / push backoff) stay active. **Do not expose port 8085 to untrusted networks.** See [Connectivity & Emulator Mode](/connectors/gcp-pub-sub/how-to/connectivity-and-emulator-mode). ## Prometheus metrics [#prometheus-metrics] The connector registers these metrics; they are persisted (time-bucket history survives a restart) like every other connector. ### Counters [#counters] | Metric | Labels | Meaning | | ---------------------------------- | -------------------------------- | ------------------------------ | | `kubemq_gcp_operations_total` | `service`, `operation`, `status` | RPCs, by service / op / status | | `kubemq_gcp_push_deliveries_total` | `result` | push deliveries, by result | ### Histogram [#histogram] | Metric | Labels | Meaning | | --------------------------------------- | ---------------------- | --------------------- | | `kubemq_gcp_operation_duration_seconds` | `service`, `operation` | per-operation latency | ### Gauges [#gauges] | Metric | Meaning | | ----------------------------------- | --------------------------------------- | | `kubemq_gcp_topics` | registered topics | | `kubemq_gcp_subscriptions` | registered subscriptions | | `kubemq_gcp_snapshots` | registered snapshots | | `kubemq_gcp_schemas` | registered schemas | | `kubemq_gcp_inflight_messages` | leased (received-but-un-acked) messages | | `kubemq_gcp_streaming_pull_streams` | open StreamingPull streams | The gauge reporter refreshes every 5 s — one of the connector's three sweepers (lease 250 ms, gauges 5 s, snapshot expiry hourly). There is **no Cloud Monitoring (Stackdriver) metrics emulation** — Prometheus is the metrics surface. ## Dashboard [#dashboard] The KubeMQ web dashboard has a **Google Cloud Pub/Sub** page (route `/gcp`, in the Connectors group): summary cards; topics / subscriptions / snapshots / schemas tables; topic and subscription detail panels; an overview throughput chart; a resource gauge chart (topics / subscriptions / pull / push / inflight); and an operations table plus a per-operation chart. The operation key is the bare method name (e.g. `Publish`). A settings page edits the connector config and shows the `PUBSUB_EMULATOR_HOST` hint (from `AdvertisedEndpoint`). ## Audit events [#audit-events] Every broker-facing client the connector mints is branded with a `gcp.` prefix, so its connections are identifiable as Pub/Sub traffic in the dashboard, metrics, and audit log. Control-plane operations are audited (data-plane publishes / pulls / acks are **not**): | Event area | When | | --------------------------- | ----------------------------------------------------- | | topic lifecycle | create / delete (tombstone) / update | | subscription lifecycle | create / update / detach / delete | | snapshot & schema lifecycle | create / commit / rollback / delete | | registry conflict | a registry-sync conflict resolved by last-writer-wins | ## Cluster notes [#cluster-notes] Topic / subscription / snapshot / schema records live in a **per-node replicated registry** synchronized across cluster nodes with a last-writer-wins rule. **Message data itself is not replicated by the connector** — it rides the existing Events Store / Queues replication. **Leases and exactly-once are node-local** (gotcha #1) — see [Error Codes](/connectors/gcp-pub-sub/reference/error-codes) and [Migrating from Google Cloud Pub/Sub](/connectors/gcp-pub-sub/reference/migration-from-gcp). ## Related [#related] # Error Codes (/connectors/gcp-pub-sub/reference/error-codes) The connector speaks the **real Pub/Sub v1 gRPC services**, so it returns **genuine gRPC status codes** (`google.rpc.Code`) — standard Google client libraries surface them as the normal typed exceptions (`InvalidArgument`, `FailedPrecondition`, `NotFound`, …). There is **no REST/JSON v1** (gRPC only), so there are no XML error envelopes. The one contract that differs from a naive implementation: the **exactly-once unary invalid-ack** path returns `FAILED_PRECONDITION` + an `ErrorInfo`, *not* `INVALID_ARGUMENT` — matching the real Google SDK contract (the SDK resolves the ack result from the `ErrorInfo.reason`). ## gRPC status code table [#grpc-status-code-table] | gRPC status | Trigger | Resolution / recovery | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `INVALID_ARGUMENT` | A validation failure: bad resource id (not 3..255, not letter-led, bad charset, `goog` prefix, topic id starting `sub.`); batch > 1000; message > 10 MiB; > 100 attributes; attr key > 256 B / value > 1024 B / ordering key > 1024 B; empty `data` **and** `attributes`; malformed CEL filter (> 256 chars or syntax error); `max_delivery_attempts` outside 5..100; a schema definition that fails to parse or exceeds 300 KB; a message that fails schema enforce-on-publish; a rejected **ingestion** or **export** (BigQuery / Cloud Storage / Bigtable) subscription | **Fix the value, then retry.** Bring the resource id, batch size, message size, or attribute count inside the limits in [Limits & Rules](/connectors/gcp-pub-sub/reference/limits-and-rules); set `max_delivery_attempts` to 5..100; correct the filter syntax / length; make the payload conform to the schema; supply non-empty `data` **or** `attributes`. Do **not** blind-retry — the input is rejected deterministically. Ingestion / export subscriptions are unsupported | | `FAILED_PRECONDITION` | `Pull` on a **detached** subscription; `CreateSnapshot` of a detached subscription; **and** — on an exactly-once subscription — a **unary** `Acknowledge` / `ModifyAckDeadline` with an unparseable / expired / unknown `ack_id` (carries the `ErrorInfo` below) | **Detached:** re-attach the subscription (or target an attached one) before pulling / snapshotting. **Stale exactly-once `ack_id`:** do **not** re-ack a message you already settled — the id is spent; just **re-pull** for a fresh lease and `ack_id`. If it recurs across nodes, enable sticky load balancing (below) | | `NOT_FOUND` | Operating on a topic / subscription / snapshot / schema that is not in the registry | **Create the resource first** (`CreateTopic` / `CreateSubscription` / `CreateSnapshot` / `CreateSchema`), then retry; or correct the id. On a cluster, a `NOT_FOUND` for a record that does exist usually means you hit a different node — enable sticky load balancing | | `ALREADY_EXISTS` | Creating a resource id that already exists | **Treat as idempotent success** — the resource is already there. Get it instead of creating it, or pick a different id | | `UNAVAILABLE` | A StreamingPull stream the server **periodically closes** after `StreamCloseSeconds` (default 1800 s / 30 min) — SDKs transparently reconnect. Also the traffic-gate response when the broker is not ready | **Let the SDK auto-reconnect** — periodic stream close is normal and your `Subscribe` callback keeps running across it; no action needed. For the traffic gate, **retry with backoff** until the broker is ready, and confirm the connector is up on `:8085` (`PUBSUB_EMULATOR_HOST` correct) | **Batch atomicity.** A `Publish` validates the *entire* batch before enqueuing anything; the first offending message rejects the whole batch with `INVALID_ARGUMENT` and nothing is published. See [Publishing](/connectors/gcp-pub-sub/how-to/publishing). ## Exactly-once ack results [#exactly-once-ack-results] On a subscription with `enable_exactly_once_delivery`, ack failures are reported **differently per path**. ### StreamingPull — confirmation messages [#streamingpull--confirmation-messages] The server returns an `AcknowledgeConfirmation` / `ModifyAckDeadlineConfirmation` carrying two id lists; the SDK retries accordingly: | List | Meaning | Client action | | -------------------------- | ---------------------------------------------------------- | -------------------- | | `invalid_ack_ids` | expired / unknown / wrong-node ids — permanently unackable | give up on those ids | | `temporary_failed_ack_ids` | a transient broker failure | **retry** those ids | ### Unary `Acknowledge` / `ModifyAckDeadline` — status + ErrorInfo [#unary-acknowledge--modifyackdeadline--status--errorinfo] ```text status: FAILED_PRECONDITION details: ErrorInfo{ reason: "PERMANENT_FAILURE_INVALID_ACK_ID" } ``` This is the **real Google SDK contract** — the SDK reads the ack result from the `ErrorInfo.reason`, **not** from a literal `INVALID_ARGUMENT`. A connector that returned `INVALID_ARGUMENT` here would break the SDK's exactly-once bookkeeping. **Node-local boundary (gotcha #1).** An `ack_id` is minted with the node id baked in; presenting it to a *different* node (after a failover or a non-sticky load balancer) makes it appear as an invalid id — `invalid_ack_ids` on StreamingPull, `FAILED_PRECONDITION` + `ErrorInfo` on unary. Pin a subscription's StreamingPull to one node, or accept at-least-once across nodes. ## Common triggers by scenario [#common-triggers-by-scenario] | Scenario | Result | Resolution / recovery | | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `Pull` on an empty subscription | *none — returns with no messages* | None — expected; keep polling | | `ModifyAckDeadline(0)` | *none — immediate nack/redeliver* | None — this *is* the nack; the message redelivers | | Ack deadline expires before ack | *none — 250 ms sweeper redelivers; receive count++* | Ack within the deadline, or extend it for slow handlers | | Receive count exceeds `max_delivery_attempts` (DLQ set) | *none — republished to `dead_letter_topic`, original acked* | Subscribe to the dead-letter topic to inspect / replay | | Receive count exceeds `max_delivery_attempts` (no DLQ) | *none — dropped after exhaustion* | Attach a dead-letter topic if you cannot afford to drop poison messages | | `Seek` to a timestamp **before** the retained window | *none — **clamps** to the earliest retained message (gotcha #8)* | None — expected clamp; widen retention to replay further back | | `Seek` that would replay more than `MaxSeekReplay` | *none — stops at the cap, logs WARN (no silent loss)* | None — raise `MaxSeekReplay` (operator config) if a larger replay is required | | `Publish` to a topic with zero subscriptions | *none — succeeds; written to the topic log, no fan-out* | None — create a subscription before publishing if you need delivery | | Filtered-out message during fan-out | *none — never enqueued (≈ auto-acked)* | None — relax the subscription filter if the drop was unintended | | `kms_key_name` on `CreateTopic` | *none — accepted and ignored* | None — CMEK is a no-op on the emulator protocol | | Bad resource id / oversize batch / oversize message / bad filter / schema mismatch | `INVALID_ARGUMENT` | Fix the offending value against [Limits & Rules](/connectors/gcp-pub-sub/reference/limits-and-rules) and retry; do not blind-retry | | Ingestion source / export subscription | `INVALID_ARGUMENT` | Unsupported — use a supported subscription type (pull / push) | | `Pull` on a detached subscription | `FAILED_PRECONDITION` | Re-attach the subscription (or pull from an attached one) | | Snapshot of a detached subscription | `FAILED_PRECONDITION` | Re-attach before `CreateSnapshot` | | Exactly-once unary ack with invalid / expired / wrong-node id | `FAILED_PRECONDITION` + `ErrorInfo(PERMANENT_FAILURE_INVALID_ACK_ID)` | Do not re-ack a settled message — re-pull for a fresh `ack_id`; if cross-node, enable sticky load balancing | | Operate on a non-existent topic / sub / snapshot / schema | `NOT_FOUND` | Create the resource first (or fix the id); on a cluster, enable sticky load balancing | | Create a resource id that already exists | `ALREADY_EXISTS` | Treat as idempotent success; get the resource or choose a new id | | StreamingPull periodic close (every `StreamCloseSeconds`) | `UNAVAILABLE` (SDK reconnects) | None — let the SDK auto-reconnect; the callback keeps running | | Broker not ready (traffic gate) | `UNAVAILABLE` | Retry with backoff until ready; confirm the connector is up on `:8085` | | `data` and `attributes` both empty | `INVALID_ARGUMENT` | Supply non-empty `data` **or** at least one attribute, then retry | ## Recovery cheat-sheet [#recovery-cheat-sheet] The recovery columns above collapse to five rules: 1. **`UNAVAILABLE`** → transient. Let the SDK **auto-reconnect**; for the traffic gate, **retry with backoff** and confirm the connector is up on `:8085` (`PUBSUB_EMULATOR_HOST` correct). 2. **`INVALID_ARGUMENT`** → **fix the input, then retry** (never blind-retry). Most often: `max_delivery_attempts` back into **5..100**, a malformed CEL **filter**, an oversize **batch / message / attribute**, a **schema** mismatch, or empty `data` + `attributes`. 3. **`FAILED_PRECONDITION`** → for an exactly-once **stale `ack_id`**, **do not re-ack** the already-settled message — **re-pull** for a fresh lease; for a **detached** subscription, **re-attach** before `Pull` / `CreateSnapshot`. 4. **`NOT_FOUND`** → **create the topic / subscription / snapshot / schema first** (or fix the id). 5. **Node-local ack errors on a cluster** (`invalid_ack_ids`, or `FAILED_PRECONDITION` / `NOT_FOUND` for records that exist) → **enable sticky load balancing** (session affinity) so a subscription's StreamingPull stays pinned to one node, or accept at-least-once across nodes. ## Related [#related] # Limits & Rules (/connectors/gcp-pub-sub/reference/limits-and-rules) Two kinds of limit apply to the connector: **Google-exact rules** (fixed, not configurable — the connector enforces the same values real Pub/Sub does) and **connector config caps** (the `CONNECTORS_GCP_*` settings that tune DoS guards and defaults). Both are listed here; the full prose walkthrough of the config caps lives in [Configuration](/connectors/gcp-pub-sub/concepts/configuration). ## Google-exact rules (not configurable) [#google-exact-rules-not-configurable] | Rule | Value | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | Resource id | 3..255 chars, must start with a letter, charset `[A-Za-z0-9._~%+-]`, no `goog` prefix; **topic ids may not start with `sub.`** (reserved namespace) | | Message size | ≤ 10 MiB total (body + attributes) | | Batch size | ≤ 1000 messages | | Attributes | ≤ 100 per message; key ≤ 256 B (no `goog`); value ≤ 1024 B; ordering key ≤ 1024 B | | Ack deadline | `0` (nack) or **10..600 s** | | Retention | 10 min .. 31 days, **clamped to the broker maximum** | | `max_delivery_attempts` | **5..100** (`0` = unset) | | Filter | attributes-only CEL-subset, ≤ 256 chars, immutable (compiled at create) | | Schema definition | ≤ 300 KB | A violation of any of these is reported as a Google-shaped gRPC error (`INVALID_ARGUMENT` for most; see [Error Codes](/connectors/gcp-pub-sub/reference/error-codes)). ### Notes on the tricky ones [#notes-on-the-tricky-ones] * **Resource id grammar (gotcha #7).** Ids must *start with a letter*; a topic id that starts with `sub.` collides with the subscription-queue namespace (`gcp.sub.{s}`) and is rejected. A subscription id may not contain the reserved `.k.` / `.h.` infixes (the per-ordering-key channel namespace). The charset allows `._~%+-` but not `/` or spaces. * **Retention clamp (gotcha #5).** You may request 10 min..31 days, but the effective retention is capped at the broker's global maximum. `GetTopic` / `GetSubscription` **echo your requested value**, while fan-out, `Seek`, and the dashboard use the **clamped** value. If retention matters for `Seek`, check the broker ceiling, not the topic config. * **`max_delivery_attempts` (gotcha #6).** Dead-letter requires 5..100; values 1..4 are rejected. `0` means "no dead-letter policy" (unset), not "deliver zero times". * **Batch atomicity.** The full batch is validated before *anything* is enqueued; the first offending message rejects the entire `Publish` with `INVALID_ARGUMENT` and nothing is published. * **Attributes / ordering key sizing.** A message must have **`data` or `attributes` non-empty**. Attribute keys beginning `goog` are reserved by Google and rejected. ## Connector config caps (`CONNECTORS_GCP_*`) [#connector-config-caps-connectors_gcp_] Server-side settings in `[Connectors.Gcp]`; env vars use the `CONNECTORS_GCP_*` prefix. Defaults shown are the connector defaults. | Field | Env var | Default | Meaning / constraint | | ---------------------------- | ---------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `Enable` | `CONNECTORS_GCP_ENABLE` | `true` | Default-on; `false` disables (closes port 8085) | | `Port` | `CONNECTORS_GCP_PORT` | `"8085"` | gRPC listen port (emulator convention); must be a valid port and **distinct** from the gRPC / REST / HTTP / AWS-connector ports | | `AdvertisedEndpoint` | `CONNECTORS_GCP_ADVERTISED_ENDPOINT` | `""` | `host:port` shown in the dashboard `PUBSUB_EMULATOR_HOST` hint. **Cosmetic** — does not change listen behavior | | `MaxMessageBytes` | `CONNECTORS_GCP_MAX_MESSAGE_BYTES` | `10485760` (10 MiB) | Max total message size; also sizes the gRPC frame. Must be > 0 | | `DefaultAckDeadlineSeconds` | `CONNECTORS_GCP_DEFAULT_ACK_DEADLINE_SECONDS` | `10` | Default ack deadline; must be **10..600** | | `MaxOutstandingMessages` | `CONNECTORS_GCP_MAX_OUTSTANDING_MESSAGES` | `1000` | Per-stream flow-control ceiling for clients that request unlimited. Must be > 0 | | `MaxInflightPerSubscription` | `CONNECTORS_GCP_MAX_INFLIGHT_PER_SUBSCRIPTION` | `20000` | Hard cap on leased (un-acked) messages per subscription. Must be > 0 | | `MaxConcurrentPolls` | `CONNECTORS_GCP_MAX_CONCURRENT_POLLS` | `1024` | Poller slot budget. Must be > 0 | | `DeliveryShards` | `CONNECTORS_GCP_DELIVERY_SHARDS` | `16` | Size of the striped delivery-worker pool (fan-out concurrency). Must be **1..256** | | `MaxAckExtensionSeconds` | `CONNECTORS_GCP_MAX_ACK_EXTENSION_SECONDS` | `600` | Ack-deadline keep-alive budget for the ordered head. `0` disables the keep-alive (expiry → redeliver); otherwise **10..3600** | | `StreamCloseSeconds` | `CONNECTORS_GCP_STREAM_CLOSE_SECONDS` | `1800` | Periodic StreamingPull close interval (forces SDK reconnect; bounds per-stream lifetime). Must be > 0 | | `MaxSeekReplay` | `CONNECTORS_GCP_MAX_SEEK_REPLAY` | `1000000` | Max messages replayed by a single `Seek` (hits cap → WARN, no silent loss). Must be > 0 | | `EnableReflection` | `CONNECTORS_GCP_ENABLE_REFLECTION` | `false` | Register gRPC server reflection (debugging) | ### Validation [#validation] Validation returns nil when the connector is disabled. Otherwise the `Port` must be a valid port and distinct from the gRPC / REST / HTTP / AWS-connector ports; `DefaultAckDeadlineSeconds` must be **10..600**; `DeliveryShards` must be **1..256**; `MaxAckExtensionSeconds` must be **`0`** (disables the ordered-head keep-alive) **or 10..3600**; and the six numeric caps (`MaxMessageBytes`, `MaxOutstandingMessages`, `MaxInflightPerSubscription`, `MaxConcurrentPolls`, `StreamCloseSeconds`, `MaxSeekReplay`) must be **> 0**. ## DoS guards (always active, even in no-auth emulator mode) [#dos-guards-always-active-even-in-no-auth-emulator-mode] Even though the connector has no authentication, these guards remain on and cannot be disabled: * `MaxMessageBytes` — gRPC body cap. * `MaxInflightPerSubscription` — leased-message ceiling per subscription. * `MaxConcurrentPolls` — poller slot budget. * `MaxSeekReplay` — replay ceiling for a single `Seek`. * push delivery backoff — bounds retry pressure on push endpoints. Do **not** expose port 8085 to untrusted networks — there is no auth or TLS. See [Connections & Observability](/connectors/gcp-pub-sub/reference/connections-endpoint) and [Connectivity & Emulator Mode](/connectors/gcp-pub-sub/how-to/connectivity-and-emulator-mode). ## Related [#related] # Migrating from Google Cloud Pub/Sub (/connectors/gcp-pub-sub/reference/migration-from-gcp) **Point your existing Google Cloud Pub/Sub application at KubeMQ by changing one environment variable.** The KubeMQ GCP connector exposes an emulator-compatible Pub/Sub v1 gRPC surface, so unmodified `google-cloud-pubsub 2.x` clients, `gcloud pubsub`, and any SDK that honours `PUBSUB_EMULATOR_HOST` connect with **no application code change** — set the env var, clear the Google credentials, and you are on KubeMQ. There is no KubeMQ SDK to adopt, no proto, and no data migration: topics live in normal KubeMQ Events Store logs and subscriptions in normal Queue channels. Rollback is **config-only** (`CONNECTORS_GCP_ENABLE=false`). This is an **endpoint-only** drop-in. But several connector behaviors **deviate from real Google Pub/Sub** — read [What Does NOT Migrate / Deviations](#what-does-not-migrate--deviations) before you cut over; most are invisible until a corner case hits production. ## Overview [#overview] KubeMQ's **GCP connector** exposes an emulator-compatible gRPC surface that accepts unmodified `google-cloud-pubsub 2.x` client libraries, `gcloud pubsub`, and any other SDK that honours `PUBSUB_EMULATOR_HOST`. No application code changes are required — point the env var at KubeMQ and clear the Google credentials. | Attribute | Value | | ---------------- | ---------------------------------------------------------------------- | | Protocol | gRPC (Pub/Sub v1 — Publisher + Subscriber + SchemaService + IAMPolicy) | | Default port | **8085** (emulator convention) | | Canonical client | `google-cloud-pubsub 2.x` (`pubsub_v1` gRPC stubs) | | Drop-in level | **endpoint-only** — set `PUBSUB_EMULATOR_HOST`, clear credentials | **Opt-in default.** The connector is **disabled by default** (`Connectors.Gcp.Enable = false`). A stock kubemq-server does **not** bind gRPC port 8085 until you turn it on. Enable it with its enable variable before pointing clients at port 8085: **The enable variable is `CONNECTORS_GCP_ENABLE`** (or `Enable = true` under `[Connectors.Gcp]` in TOML). For Kubernetes, set `spec.gcp.enabled: true` in the `KubemqCluster` CR. ## Compatibility Matrix [#compatibility-matrix] | Dimension | Support | Notes | | -------------------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | **Drop-in level** | endpoint-only | `PUBSUB_EMULATOR_HOST=host:8085`; no code change | | **Point-to-point queues** | N/A | Subscriptions map to KubeMQ Queues; no separate queue API | | **Pub/sub (non-durable)** | ✅ | topic → Events Store `gcp.{topic}`; fan-out per subscription | | **Durable / persistent subscriptions** | ✅ | subscription → Queue `gcp.sub.{subscription}` (broker-durable) | | **Request / reply (RPC)** | N/A | Pub/Sub has no RPC primitive | | **Ordering guarantee** | ⚠️ node-local | Per ordering key, at-most-one-in-flight; **not** cluster-wide; lost on process restart | | **Transactions** | N/A | Pub/Sub has no transaction concept | | **Dead-letter / redrive** | ✅ | Connector-level: re-publishes to the dead-letter topic (new message IDs) when `max_delivery_attempts` is exceeded — see footnote ¹ | | **Selectors / filtering / wildcards** | ✅ | CEL-subset filter on subscription attributes; `attributes:K`, `attributes.K="v"`, `hasPrefix`, AND/OR/NOT | | **Auth model** | ❌ none (emulator) | No OAuth2 / JWT / IAM enforcement; IAM RPCs are permissive stubs | | **TLS / mTLS** | ❌ none (emulator) | Plaintext gRPC only; terminate TLS at a reverse proxy or service mesh | | **Top unsupported** | — | No auth/TLS; IAM stubs; BigQuery/GCS export subs; ordering/exactly-once node-local; no default retention | > ¹ **Dead-lettering is connector-level, not a broker redrive.** When a message's receive count > exceeds `max_delivery_attempts`, the connector republishes it to the configured dead-letter topic > through the normal fan-out path. This assigns **new message IDs** and resets the delivery counter; > the broker's own queue-redrive path is not involved. `max_delivery_attempts` must be 5..100; > leaving it unset disables the dead-letter policy. ## Connection / Endpoint Migration [#connection--endpoint-migration] The only required change is the endpoint env var. The SDK clears Google credentials and uses the insecure emulator path automatically. ### Before (real Google Cloud) [#before-real-google-cloud] ```bash title="Terminal" # Application authenticates with ADC or a service-account key. export GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json export GOOGLE_CLOUD_PROJECT=my-project ``` ```python from google.cloud import pubsub_v1 publisher = pubsub_v1.PublisherClient() subscriber = pubsub_v1.SubscriberClient() ``` ### After (KubeMQ) [#after-kubemq] ```bash title="Terminal" # Point every Google Pub/Sub SDK at KubeMQ. # Clear credentials so the library enters unauthenticated emulator mode. export PUBSUB_EMULATOR_HOST=kubemq-host:8085 export PUBSUB_PROJECT_ID=my-project # arbitrary; the connector ignores the project segment unset GOOGLE_APPLICATION_CREDENTIALS unset GOOGLE_CLOUD_PROJECT ``` No other client-code change is needed. The connector listens on port **8085** — the same port Google's emulator uses — so an existing `PUBSUB_EMULATOR_HOST` that already points at the emulator only needs its host changed. The connector validates and strips the `projects/{p}/` prefix from all resource paths but otherwise treats resource IDs as global (single-tenant, like the emulator). **Optional — enable `AdvertisedEndpoint`** so the dashboard shows the correct `PUBSUB_EMULATOR_HOST` hint: ```toml title="config.toml" [Connectors.Gcp] Enable = true Port = "8085" AdvertisedEndpoint = "kubemq.mycompany.svc:8085" ``` ## Concept & Destination Mapping [#concept--destination-mapping] | Pub/Sub concept | KubeMQ object | Channel name | | --------------------------------------------- | ------------------------------ | -------------------------- | | Topic `projects/{p}/topics/{t}` | Events Store log | `gcp.{t}` | | Subscription `projects/{p}/subscriptions/{s}` | Queue channel | `gcp.sub.{s}` | | Message `attributes` | KubeMQ Tags | passed through as tags | | Message `ordering_key` | Per-key ordered Queue delivery | embedded in lease metadata | | Snapshot / Schema | internal registry record | — (no KubeMQ channel) | **Fan-out model.** A `Publish` call writes the message exactly once to the Events Store log `gcp.{t}`, then fans out one Queue message per subscription (applying each subscription's filter at publish time). This means: * A Pub/Sub topic publish is immediately visible to native KubeMQ consumers on Events Store channel `gcp.{t}`. * A subscription's unconsumed backlog is a native KubeMQ Queue on channel `gcp.sub.{s}`. **Resource ID rules** (same as Google's): 3..255 chars, must start with a letter, charset `[A-Za-z0-9._~%+-]`, no `goog` prefix, topic IDs may not start with `sub.` (reserved broker namespace). ### Message attribute pass-through [#message-attribute-pass-through] A `PubsubMessage` with `attributes` and an `ordering_key` arrives at native consumers with three reserved tags added: | Tag | Value | | ---------------------- | ------------------------------------ | | `_pubsub_message_id` | Server-assigned message ID | | `_pubsub_publish_time` | Publish timestamp | | `_pubsub_ordering_key` | Ordering key (empty string if unset) | These tags are stripped from `attributes` when the message is delivered back to a Pub/Sub SDK client. ## Canonical Client Example [#canonical-client-example] **Client:** `google-cloud-pubsub 2.x` (Python `pubsub_v1` gRPC stubs). ```python import os from google.cloud import pubsub_v1 # google-cloud-pubsub 2.x # ── Environment ───────────────────────────────────────────────────────────── os.environ["PUBSUB_EMULATOR_HOST"] = "localhost:8085" os.environ["PUBSUB_PROJECT_ID"] = "my-project" PROJECT = "my-project" TOPIC = "orders" SUB = "orders-sub" # ── Create topic and subscription ──────────────────────────────────────────── publisher = pubsub_v1.PublisherClient() # honours PUBSUB_EMULATOR_HOST subscriber = pubsub_v1.SubscriberClient() topic_path = publisher.topic_path(PROJECT, TOPIC) # -> gcp.orders sub_path = subscriber.subscription_path(PROJECT, SUB) # -> gcp.sub.orders-sub publisher.create_topic(request={"name": topic_path}) subscriber.create_subscription( request={"name": sub_path, "topic": topic_path} ) # ── Publish ─────────────────────────────────────────────────────────────────── future = publisher.publish( # pubsub_v1.PublisherClient.publish topic_path, data=b"order-001", # bytes region="eu-west-1", # arbitrary attributes (passed as Tags) ) print("published:", future.result()) # blocks until the broker acks # ── Pull (synchronous) ──────────────────────────────────────────────────────── response = subscriber.pull( # pubsub_v1.SubscriberClient.pull request={"subscription": sub_path, "max_messages": 10} ) for msg in response.received_messages: print("received:", msg.message.data, msg.message.attributes) subscriber.acknowledge( # pubsub_v1.SubscriberClient.acknowledge request={"subscription": sub_path, "ack_ids": [msg.ack_id]} ) # ── StreamingPull (async callback) ─────────────────────────────────────────── def callback(message: pubsub_v1.subscriber.message.Message) -> None: print("streaming:", message.data, message.attributes) message.ack() # pubsub_v1.subscriber.message.Message.ack streaming_future = subscriber.subscribe(sub_path, callback=callback) try: streaming_future.result(timeout=10) except Exception: streaming_future.cancel() streaming_future.result() subscriber.close() ``` ### RPC is not a Pub/Sub primitive [#rpc-is-not-a-pubsub-primitive] Pub/Sub has no request/reply mechanism. If you need RPC, use KubeMQ's native Commands/Queries pattern over gRPC or REST rather than modelling it with a reply topic. ## Security [#security] The GCP connector runs in **emulator mode**: there is no OAuth2 validation, no JWT verification, no TLS, and IAM RPCs (`GetIamPolicy`, `SetIamPolicy`, `TestIamPermissions`) are permissive stubs that echo requests without enforcement. * **No authentication.** All connecting clients are trusted unconditionally. * **No TLS.** The gRPC listener is plaintext. For encrypted transport, terminate TLS at a reverse proxy or service mesh in front of port 8085. * **IAM stubs.** `GetIamPolicy` returns an empty `Policy{Version: 3}`. No permissions are checked. * **DoS guards remain active.** `MaxRecvMsgSize`, the per-subscription in-flight cap (`MaxInflightPerSubscription`), `MaxConcurrentPolls`, `MaxSeekReplay`, and push delivery backoff are enforced regardless of auth mode. **Do not expose port 8085 to untrusted networks.** Because there is no authentication, any client that can reach the port can create, publish to, and delete any topic or subscription. Keep the listener inside a trusted network boundary, and terminate TLS at a proxy or mesh if you need encryption. ## What Does NOT Migrate / Deviations [#what-does-not-migrate--deviations] ### Features not supported (hard rejections) [#features-not-supported-hard-rejections] | Feature | Behavior | | ---------------------------------------------------------------- | ---------------------------------------------------------------- | | **Authentication / TLS** | No auth or TLS (emulator mode). IAM is a permissive stub. | | **BigQuery / Cloud Storage / Bigtable export subscriptions** | Rejected with `INVALID_ARGUMENT` — no KubeMQ analog. | | **Ingestion sources** (Kinesis, Cloud Storage, Azure Event Hubs) | Rejected with `INVALID_ARGUMENT`. | | **KMS key names** (`kms_key_name`) | Accepted and silently ignored. | | **gRPC REST/JSON (grpc-gateway)** | gRPC only; no `https://pubsub.googleapis.com/v1/…` REST surface. | ### Behavioral deviations [#behavioral-deviations] | Area | Google Pub/Sub behavior | KubeMQ behavior | | ----------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Ordering / exactly-once scope** | Cluster-wide | **Node-local.** An `ack_id` is only valid on the node that minted it; ordering sequences and exactly-once guarantees are lost on process restart or if the client reconnects to a different cluster node. Pin a subscription's StreamingPull traffic to one node, or accept at-least-once across nodes. | | **Retention default** | 7-day default when unset | **No connector-level default.** Retention is unset unless the client supplies `message_retention_duration`. Supplied values are bounded to 10 min..31 days and then **clamped down** to the server's `Store.MaxRetention` ceiling (when that ceiling is non-zero). There is no 24-hour default. | | **Dead-letter message IDs** | Original message IDs preserved | Republishes with new message IDs; resets the counter; `max_delivery_attempts` 5..100 — see footnote ¹. | | **Push OIDC token** | Signed by Google | Signed by the emulator (not Google-verifiable). | | **CEL filter** | Full CEL | Attributes-only subset: `attributes:K`, `attributes.K="v"`, `hasPrefix(attributes.K, "p")`, `AND`/`OR`/`NOT`/`-`. `data` and metadata fields are not filterable. Malformed expressions → `INVALID_ARGUMENT`. | | **Exactly-once unary ack status** | `FAILED_PRECONDITION` + `ErrorInfo(PERMANENT_FAILURE_INVALID_ACK_ID)` | Same (matches the real SDK contract). | | **`DeleteTopic` semantics** | Topic and its subscriptions are deleted | **Tombstone only** — the Events Store log is retained so existing subscriptions survive; re-creating the topic reuses the log. | | **Resource ID namespace** | Scoped by project | **Global** (single-tenant). Project is parsed and validated but ignored. | | **`Seek` replay cap** | Unlimited | Capped at `MaxSeekReplay` (default 1 000 000 messages). Hitting the cap stops with a `WARN`; no silent loss. | | **`Seek` to timestamp before retention window** | Error | **Clamped** to the earliest retained message — not an error. | ## Verification Smoke Test [#verification-smoke-test] This recipe confirms that a basic publish → consume round-trip reaches KubeMQ and that fan-out to both the Events Store log and the subscription Queue is working. **Prerequisites:** KubeMQ running with the GCP connector enabled (`CONNECTORS_GCP_ENABLE=true`) and port 8085 reachable. ```bash title="Terminal" export PUBSUB_EMULATOR_HOST=localhost:8085 export PUBSUB_PROJECT_ID=smoke-test ``` ```python import os, time from google.cloud import pubsub_v1 # google-cloud-pubsub 2.x os.environ["PUBSUB_EMULATOR_HOST"] = "localhost:8085" os.environ["PUBSUB_PROJECT_ID"] = "smoke-test" pub = pubsub_v1.PublisherClient() sub = pubsub_v1.SubscriberClient() t = pub.topic_path("smoke-test", "smoke-topic") # -> gcp.smoke-topic s = sub.subscription_path("smoke-test", "smoke-sub") # -> gcp.sub.smoke-sub pub.create_topic(request={"name": t}) sub.create_subscription(request={"name": s, "topic": t}) # Publish one message. future = pub.publish(t, data=b"smoke-payload", env="ci") msg_id = future.result() print(f"published id={msg_id}") # Pull and confirm arrival. time.sleep(0.5) # allow fan-out resp = sub.pull(request={"subscription": s, "max_messages": 1}) assert len(resp.received_messages) == 1, "expected 1 message" rm = resp.received_messages[0] assert rm.message.data == b"smoke-payload" assert rm.message.attributes["env"] == "ci" sub.acknowledge(request={"subscription": s, "ack_ids": [rm.ack_id]}) print("smoke test PASSED — message received and acked") sub.close() ``` ### Cross-protocol fan-out check [#cross-protocol-fan-out-check] The headline behavior of the connector is that a Pub/Sub publish is **also** a native KubeMQ message. After the publish above, the same message is on the Events Store log `gcp.smoke-topic`, and the subscription backlog is a native Queue `gcp.sub.smoke-sub`. A native KubeMQ client confirms the fan-out with no Pub/Sub SDK involved: ```python # Events Store log carries the topic publish: # # SubscribeToEventsStore(channel="gcp.smoke-topic", startAt="new") # # Subscription backlog is a native Queue channel: # # ReceiveQueueMessages(channel="gcp.sub.smoke-sub", maxMessages=10) ``` For a deterministic read, subscribe to the Events Store log with start policy `startAt = "new"` **before** publishing. See [Channel Mapping](/connectors/gcp-pub-sub/reference/channel-mapping) for the full `gcp.{topic}` / `gcp.sub.{subscription}` scheme. ## See Also [#see-also] # Connectivity and emulator mode (/connectors/gcp-pub-sub/how-to/connectivity-and-emulator-mode) This guide covers how a Google Cloud Pub/Sub client reaches the connector: the standard `PUBSUB_EMULATOR_HOST` drop-in (the zero-code-change contract), the per-language emulator opt-in, the no-auth / insecure-gRPC posture, the cosmetic `AdvertisedEndpoint` dashboard hint, the gRPC-only constraint, and the sticky-load-balancer caveat for clusters. ## The emulator protocol — zero code changes [#the-emulator-protocol--zero-code-changes] The connector is a dedicated **gRPC listener** (default port **8085**, the Pub/Sub emulator convention) inside KubeMQ. Every official Pub/Sub client library (Go, Python, Java, Node.js, C#, Ruby) and `gcloud` honour the standard `PUBSUB_EMULATOR_HOST` environment variable. When that variable is set, the SDK **clears its Google credentials, skips Google auth, and dials insecure gRPC** — exactly as it would against Google's own local emulator. Pointing an unmodified Pub/Sub application at KubeMQ therefore requires **no code changes**: ```bash export PUBSUB_EMULATOR_HOST=localhost:8085 # connector default gRPC port; SDK uses the insecure path export PUBSUB_PROJECT_ID=my-project # any id; the project segment is parsed but ignored # Some clients and gcloud also read this alias: # export GOOGLE_CLOUD_PROJECT=my-project ``` There is **no bespoke wrapper variable** — the standard Google env var *is* the contract, and that zero-code-change drop-in is the connector's headline value proposition. **Project id is parsed but ignored.** The connector validates the `projects/{p}` segment but is **single-tenant** (like the emulator): resource ids are global across "projects". Any project id works; topic `orders` is always Events Store channel `gcp.orders` regardless of project. See [Channel mapping](/connectors/gcp-pub-sub/reference/channel-mapping). ## Per-language emulator opt-in [#per-language-emulator-opt-in] Most clients auto-detect the emulator from `PUBSUB_EMULATOR_HOST` with no extra code; two need an explicit flag or constructor argument: | Language | Construction (emulator) | Auto-detect? | | -------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | | Go | `pubsub.NewClient(ctx, projectID)` — reads `PUBSUB_EMULATOR_HOST` and dials insecurely. | Yes | | Python | `pubsub_v1.PublisherClient()` / `SubscriberClient()` — honours the env var. | Yes | | Node/TS | `new PubSub({ projectId })` — auto-detects the emulator from the env var. | Yes | | Java | Point a plaintext `ManagedChannel` at the emulator host with `NoCredentialsProvider` when `PUBSUB_EMULATOR_HOST` is set. | No — explicit channel | | C# | `new PublisherServiceApiClientBuilder { EmulatorDetection = EmulatorDetection.EmulatorOnly }.Build()` (or `EmulatorOrProduction`). | No — `EmulatorDetection` | | Ruby | `Google::Cloud::PubSub.new(project_id: ENV["PUBSUB_PROJECT_ID"], emulator_host: ENV["PUBSUB_EMULATOR_HOST"])`. | No — `emulator_host:` | The three clients that need explicit emulator wiring are Java, C#, and Ruby: ```java // When PUBSUB_EMULATOR_HOST is set, build a plaintext channel and clear credentials. String host = System.getenv("PUBSUB_EMULATOR_HOST"); // e.g. localhost:8085 ManagedChannel channel = ManagedChannelBuilder.forTarget(host).usePlaintext().build(); TransportChannelProvider channelProvider = FixedTransportChannelProvider.create(GrpcTransportChannel.create(channel)); CredentialsProvider credentialsProvider = NoCredentialsProvider.create(); TopicAdminClient topicAdmin = TopicAdminClient.create( TopicAdminSettings.newBuilder() .setTransportChannelProvider(channelProvider) .setCredentialsProvider(credentialsProvider) .build()); ``` ```csharp // EmulatorDetection reads PUBSUB_EMULATOR_HOST and switches to the insecure path. var publisher = await new PublisherServiceApiClientBuilder { EmulatorDetection = EmulatorDetection.EmulatorOnly }.BuildAsync(); ``` ```ruby # Ruby does not auto-detect the emulator — pass emulator_host: explicitly. require "google/cloud/pubsub" pubsub = Google::Cloud::PubSub.new( project_id: ENV["PUBSUB_PROJECT_ID"], emulator_host: ENV["PUBSUB_EMULATOR_HOST"], # e.g. localhost:8085 ) ``` ## No auth, no TLS [#no-auth-no-tls] The connector runs in **emulator mode**: no Google OAuth2/JWT validation, no IAM enforcement (the `IAMPolicy` RPCs are permissive stubs), and **no TLS**. The transport is **insecure gRPC**. **Do not expose port 8085 to untrusted networks.** This no-auth, no-TLS posture is by design and matches Google's local emulator. Run it on a trusted network or behind your own perimeter. For the shared security model across connectors, see [Auth & security](/connectors/reference/auth-and-security). DoS guards remain active even with no auth: * `CONNECTORS_GCP_MAX_MESSAGE_BYTES` — a gRPC receive-size body cap (default 10 MiB); * `CONNECTORS_GCP_MAX_INFLIGHT_PER_SUBSCRIPTION` — hard cap on leased (un-acked) messages per subscription (20,000); * `CONNECTORS_GCP_MAX_CONCURRENT_POLLS` — poller slot budget (1,024); * `CONNECTORS_GCP_MAX_SEEK_REPLAY` — max messages a single `Seek` may replay (1,000,000); * push-delivery backoff. These knobs are documented in [Configuration](/connectors/gcp-pub-sub/concepts/configuration). ## The advertised-endpoint hint [#the-advertised-endpoint-hint] `CONNECTORS_GCP_ADVERTISED_ENDPOINT` (default empty) is a **cosmetic** `host:port` string the dashboard shows as the suggested `PUBSUB_EMULATOR_HOST` value. It does not change how the listener binds or behaves; set it to the externally reachable address (e.g. `kubemq.mycompany.svc:8085`) so operators copy the right host into their env. ## gRPC only — no REST/JSON [#grpc-only--no-restjson] **The connector is gRPC-only.** There is **no REST/JSON v1** (grpc-gateway) surface. Clients and tools that speak only the Pub/Sub REST API will not work; use a gRPC client library or `gcloud` (which uses gRPC for the emulator). See [Capabilities](/connectors/gcp-pub-sub/reference/capabilities). ## Periodic stream reconnect [#periodic-stream-reconnect] A `StreamingPull` stream is closed by the server after `CONNECTORS_GCP_STREAM_CLOSE_SECONDS` (default **1800 s** / 30 min) with `UNAVAILABLE`; client libraries **transparently reconnect**. This bounds per-stream resource lifetime and is normal — your receive callback keeps running across the reconnect. See [Subscribing](/connectors/gcp-pub-sub/how-to/subscribing). ## Sticky-stream caveat (cluster) [#sticky-stream-caveat-cluster] **Node-local state needs a sticky load balancer.** Topic / subscription / snapshot / schema **records** are synchronized across cluster nodes (a per-node replicated registry, last-writer-wins), but two pieces of delivery state are **node-local**: * **Exactly-once `ack_id`s** — an `ack_id` minted on one node is invalid on another (its node id won't match), so an exactly-once subscription's `StreamingPull` traffic must be pinned to one node; * **`StreamingPull` leases / in-flight tracking** — leased messages and flow-control counters live on the node that delivered them. Cluster deployments must put a **sticky load balancer** (session affinity) in front of the connector so each subscriber sticks to one node for the lifetime of its in-flight messages. Single-node deployments are unaffected. See [Reliability](/connectors/gcp-pub-sub/how-to/reliability). Message **data** itself is not replicated by the connector — it rides the existing Events Store / Queues replication. ## Traffic gate [#traffic-gate] While the message broker is not ready, the traffic-gate interceptor short-circuits requests; on a not-ready → ready transition the connector **drops all in-memory leases** (their downstream transactions are dead) and the poller rebuilds. SDKs see a transient `UNAVAILABLE` and retry. ## Related [#related] # Fan-Out (/connectors/gcp-pub-sub/how-to/fan-out) **Fan-out** delivers one published message to many independent consumers. A single `Publish` writes the message **once** to the topic's Events Store log `gcp.{topic}`, then the connector fans out one copy to **each** subscription's Queue channel `gcp.sub.{subscription}` — applying that subscription's filter. Every subscription has its own backlog and its own ack state, so a slow or filtered consumer never blocks the others. This is the classic "one event, many consumers" pattern: an order-placed event reaches a billing subscription, a shipping subscription, and an analytics subscription from a single publish. ## Overview [#overview] `CreateTopic` once, then `CreateSubscription` for each consumer that should receive a copy. `Publish` resolves, at publish time, to **every** subscription bound to the topic and enqueues one copy per subscription (skipping ones whose filter does not match, and detached ones). Each subscription is pulled and acknowledged independently. | Step | Action | Behavior | | --------------------- | ------------------------------ | --------------------------------------------------------------------- | | Topic | `CreateTopic("events")` | Events Store log `gcp.events` | | Subscriptions | `CreateSubscription` ×N | Each binds a Queue channel `gcp.sub.{name}` | | Publish | `Publish` | One write to `gcp.events`; one Queue copy fanned out per subscription | | Filtered subscription | `CreateSubscription(filter=…)` | Receives the copy only when its filter matches | | Per-subscription pull | `Pull` / `StreamingPull` | Independent backlog + ack state per subscription | Fan-out semantics: * **One server-assigned message id per publish**, the same across every subscription's copy. * **Zero subscriptions → the publish still succeeds** and the message lands only in the topic log (no error). * **Filters apply at fan-out time** — a non-matching subscription is simply never enqueued (≈ auto-acked), and a publish that matches **no** subscription still succeeds. ## How it works [#how-it-works] A single publish is written once to the topic log `gcp.{topic}`, then the connector fans out one Queue copy to every subscription bound to the topic. Each subscription has its own queue, filter, and ack state; a filtered subscription receives the copy only when its filter matches. *One publish writes once to the topic log `gcp.{topic}` and fans out an independent Queue copy to every subscription on `gcp.sub.{subscription}`, all sharing one message id; a filtered subscription receives its copy only when the message attributes match its filter.* ## Fan one publish to many subscriptions [#fan-one-publish-to-many-subscriptions] Create a topic, attach two subscriptions, publish once, and watch both subscriptions receive the same message id from their own independent queues. Each client sets only `PUBSUB_EMULATOR_HOST` (default `localhost:8085`) and a project id. ```go package main import ( "context" "fmt" "log" "os" "time" "cloud.google.com/go/pubsub" "cloud.google.com/go/pubsub/apiv1/pubsubpb" ) func projectID() string { if v := os.Getenv("PUBSUB_PROJECT_ID"); v != "" { return v } return "my-project" } func main() { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() // PUBSUB_EMULATOR_HOST routes the official client at the connector (insecure gRPC). client, err := pubsub.NewClient(ctx, projectID()) if err != nil { log.Fatalf("NewClient: %v", err) } defer client.Close() // One topic, two independent subscriptions → gcp.sub.billing / gcp.sub.shipping. topic, err := client.CreateTopic(ctx, "events") if err != nil { log.Fatalf("CreateTopic: %v", err) } defer topic.Stop() for _, name := range []string{"billing", "shipping"} { if _, err := client.CreateSubscription(ctx, name, pubsub.SubscriptionConfig{ Topic: topic, AckDeadline: 10 * time.Second, }); err != nil { log.Fatalf("CreateSubscription %q: %v", name, err) } } // One Publish fans out to both subscriptions with a shared message id. msgID, err := topic.Publish(ctx, &pubsub.Message{Data: []byte("order #1001 placed")}).Get(ctx) if err != nil { log.Fatalf("Publish: %v", err) } fmt.Printf("published: %s\n", msgID) // Each subscription has its own backlog — pull from both. subClient, err := pubsub.NewSubscriberClient(ctx) if err != nil { log.Fatalf("NewSubscriberClient: %v", err) } defer subClient.Close() for _, name := range []string{"billing", "shipping"} { subPath := fmt.Sprintf("projects/%s/subscriptions/%s", projectID(), name) resp, err := subClient.Pull(ctx, &pubsubpb.PullRequest{Subscription: subPath, MaxMessages: 1}) if err != nil { log.Fatalf("Pull %q: %v", name, err) } rm := resp.GetReceivedMessages()[0] fmt.Printf("%s received message id=%s\n", name, rm.GetMessage().GetMessageId()) _ = subClient.Acknowledge(ctx, &pubsubpb.AcknowledgeRequest{ Subscription: subPath, AckIds: []string{rm.GetAckId()}, }) } } ``` ```python import os from google.cloud import pubsub_v1 def project_id() -> str: return os.environ.get("PUBSUB_PROJECT_ID", "my-project") def main() -> None: publisher = pubsub_v1.PublisherClient() subscriber = pubsub_v1.SubscriberClient() proj = project_id() topic_path = publisher.topic_path(proj, "events") # -> gcp.events # One topic, two independent subscriptions → gcp.sub.billing / gcp.sub.shipping. publisher.create_topic(request={"name": topic_path}) sub_paths = {} for name in ("billing", "shipping"): sub_path = subscriber.subscription_path(proj, name) subscriber.create_subscription(request={"name": sub_path, "topic": topic_path}) sub_paths[name] = sub_path # One Publish fans out to both subscriptions with a shared message id. msg_id = publisher.publish(topic_path, b"order #1001 placed").result(timeout=15) print(f"published: {msg_id}") # Each subscription has its own backlog. for name, sub_path in sub_paths.items(): resp = subscriber.pull(request={"subscription": sub_path, "max_messages": 1}, timeout=20) rm = resp.received_messages[0] assert rm.message.message_id == msg_id # same message id across subscriptions print(f"{name} received message id={rm.message.message_id}") subscriber.acknowledge(request={"subscription": sub_path, "ack_ids": [rm.ack_id]}) subscriber.close() if __name__ == "__main__": main() ``` ```java import com.google.api.gax.core.NoCredentialsProvider; import com.google.api.gax.grpc.GrpcTransportChannel; import com.google.api.gax.rpc.FixedTransportChannelProvider; import com.google.api.gax.rpc.TransportChannelProvider; import com.google.cloud.pubsub.v1.SubscriptionAdminClient; import com.google.cloud.pubsub.v1.SubscriptionAdminSettings; import com.google.cloud.pubsub.v1.TopicAdminClient; import com.google.cloud.pubsub.v1.TopicAdminSettings; import com.google.cloud.pubsub.v1.stub.GrpcSubscriberStub; import com.google.cloud.pubsub.v1.stub.SubscriberStubSettings; import com.google.protobuf.ByteString; import com.google.pubsub.v1.AcknowledgeRequest; import com.google.pubsub.v1.PublishRequest; import com.google.pubsub.v1.PubsubMessage; import com.google.pubsub.v1.PullRequest; import com.google.pubsub.v1.PullResponse; import com.google.pubsub.v1.PushConfig; import com.google.pubsub.v1.ReceivedMessage; import com.google.pubsub.v1.SubscriptionName; import com.google.pubsub.v1.TopicName; import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; import java.util.List; public final class Main { public static void main(String[] args) throws Exception { String emulatorHost = System.getenv().getOrDefault("PUBSUB_EMULATOR_HOST", "localhost:8085"); String projectId = System.getenv().getOrDefault("PUBSUB_PROJECT_ID", "my-project"); ManagedChannel channel = ManagedChannelBuilder.forTarget(emulatorHost).usePlaintext().build(); TransportChannelProvider channelProvider = FixedTransportChannelProvider.create(GrpcTransportChannel.create(channel)); NoCredentialsProvider noCreds = NoCredentialsProvider.create(); TopicName topic = TopicName.of(projectId, "events"); // -> gcp.events try (TopicAdminClient topicAdmin = TopicAdminClient.create(TopicAdminSettings.newBuilder() .setTransportChannelProvider(channelProvider).setCredentialsProvider(noCreds).build()); SubscriptionAdminClient subAdmin = SubscriptionAdminClient.create( SubscriptionAdminSettings.newBuilder() .setTransportChannelProvider(channelProvider) .setCredentialsProvider(noCreds).build()); GrpcSubscriberStub subStub = GrpcSubscriberStub.create(SubscriberStubSettings.newBuilder() .setTransportChannelProvider(channelProvider) .setCredentialsProvider(noCreds).build())) { // One topic, two independent subscriptions. topicAdmin.createTopic(topic); for (String name : List.of("billing", "shipping")) { subAdmin.createSubscription(SubscriptionName.of(projectId, name), topic, PushConfig.getDefaultInstance(), 10); } // One Publish fans out to both subscriptions with a shared message id. String msgId = topicAdmin.publish(PublishRequest.newBuilder().setTopic(topic.toString()) .addMessages(PubsubMessage.newBuilder() .setData(ByteString.copyFromUtf8("order #1001 placed")).build()) .build()).getMessageIds(0); System.out.printf("published: %s%n", msgId); // Each subscription has its own backlog. for (String name : List.of("billing", "shipping")) { String subPath = SubscriptionName.of(projectId, name).toString(); PullResponse resp = subStub.pullCallable().call(PullRequest.newBuilder() .setSubscription(subPath).setMaxMessages(1).build()); ReceivedMessage got = resp.getReceivedMessages(0); System.out.printf("%s received message id=%s%n", name, got.getMessage().getMessageId()); subStub.acknowledgeCallable().call(AcknowledgeRequest.newBuilder() .setSubscription(subPath).addAckIds(got.getAckId()).build()); } } finally { channel.shutdown(); } } } ``` ```typescript import { PubSub, v1 } from "@google-cloud/pubsub"; const projectId = process.env["PUBSUB_PROJECT_ID"] ?? "my-project"; const baseOptions = new PubSub({ projectId }).options; const options = { ...baseOptions, port: baseOptions.port === undefined ? undefined : Number(baseOptions.port) }; const publisher = new v1.PublisherClient(options); const subscriber = new v1.SubscriberClient(options); async function main(): Promise { const topic = publisher.projectTopicsPath(projectId, "events"); // -> gcp.events // One topic, two independent subscriptions → gcp.sub.billing / gcp.sub.shipping. await publisher.createTopic({ name: topic }); const subNames = ["billing", "shipping"].map((n) => subscriber.subscriptionPath(projectId, n)); for (const sub of subNames) { await subscriber.createSubscription({ name: sub, topic, ackDeadlineSeconds: 10 }); } // One Publish fans out to both subscriptions with a shared message id. const [published] = await publisher.publish({ topic, messages: [{ data: Buffer.from("order #1001 placed") }], }); const msgId = published.messageIds?.[0] ?? ""; console.log(`published: ${msgId}`); // Each subscription has its own backlog. for (const sub of subNames) { const [pull] = await subscriber.pull({ subscription: sub, maxMessages: 1 }); const rm = pull.receivedMessages![0]; console.log(`${sub.split("/").pop()} received message id=${rm.message!.messageId}`); await subscriber.acknowledge({ subscription: sub, ackIds: [rm.ackId!] }); } } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using Google.Api.Gax; using Google.Cloud.PubSub.V1; using Google.Protobuf; var projectId = Environment.GetEnvironmentVariable("PUBSUB_PROJECT_ID") ?? "my-project"; var topicName = TopicName.FromProjectTopic(projectId, "events"); // -> gcp.events // The .NET client does NOT auto-detect the emulator — set EmulatorOnly. var publisher = await new PublisherServiceApiClientBuilder { EmulatorDetection = EmulatorDetection.EmulatorOnly, }.BuildAsync(); var subscriber = await new SubscriberServiceApiClientBuilder { EmulatorDetection = EmulatorDetection.EmulatorOnly, }.BuildAsync(); // One topic, two independent subscriptions → gcp.sub.billing / gcp.sub.shipping. await publisher.CreateTopicAsync(topicName); var subNames = new[] { "billing", "shipping" } .Select(n => SubscriptionName.FromProjectSubscription(projectId, n)).ToArray(); foreach (var sub in subNames) { await subscriber.CreateSubscriptionAsync(sub, topicName, pushConfig: null, ackDeadlineSeconds: 10); } // One Publish fans out to both subscriptions with a shared message id. var publishResponse = await publisher.PublishAsync(topicName, new[] { new PubsubMessage { Data = ByteString.CopyFromUtf8("order #1001 placed") }, }); var msgId = publishResponse.MessageIds[0]; Console.WriteLine($"published: {msgId}"); // Each subscription has its own backlog. foreach (var sub in subNames) { var pull = await subscriber.PullAsync(sub, maxMessages: 1); var rm = pull.ReceivedMessages[0]; Console.WriteLine($"{sub.SubscriptionId} received message id={rm.Message.MessageId}"); await subscriber.AcknowledgeAsync(sub, new[] { rm.AckId }); } ``` ```ruby # frozen_string_literal: true require "google/cloud/pubsub" project_id = ENV["PUBSUB_PROJECT_ID"] || "my-project" emulator_host = ENV["PUBSUB_EMULATOR_HOST"] || "localhost:8085" pubsub = Google::Cloud::PubSub.new(project_id: project_id, emulator_host: emulator_host) topic_admin = pubsub.topic_admin sub_admin = pubsub.subscription_admin topic_path = pubsub.topic_path("events") # -> gcp.events # One topic, two independent subscriptions → gcp.sub.billing / gcp.sub.shipping. topic = topic_admin.create_topic(name: topic_path) sub_paths = %w[billing shipping].to_h do |name| sub_path = pubsub.subscription_path(name) sub_admin.create_subscription(name: sub_path, topic: topic_path, ack_deadline_seconds: 10) [name, sub_path] end # One Publish fans out to both subscriptions with a shared message id. publisher = pubsub.publisher(topic.name) msg_id = publisher.publish("order #1001 placed").message_id puts "published: #{msg_id}" # Each subscription has its own backlog. sub_paths.each do |name, sub_path| subscriber = pubsub.subscriber(sub_path) rcv = subscriber.pull(immediate: false, max: 1).first puts "#{name} received message id=#{rcv.message_id}" rcv.acknowledge! end ``` ## Filtered fan-out [#filtered-fan-out] Set a `filter` on a subscription so it receives only the publishes whose **attributes** match. Filters are an attributes-only CEL subset (`attributes:KEY`, `=` / `!=`, `hasPrefix`, `AND` / `OR` / `NOT`), ≤ 256 characters, compiled once at create-time, and applied at fan-out — a non-matching publish is never enqueued for that subscription, and a publish that matches no subscription still succeeds. ```python # A subscription that receives only "order" events. subscriber.create_subscription( request={ "name": subscriber.subscription_path(proj, "orders-only"), "topic": topic_path, "filter": 'attributes.eventType = "order"', } ) # A matching publish is delivered; a non-matching one is suppressed for this subscription. publisher.publish(topic_path, b"an order event", eventType="order") publisher.publish(topic_path, b"a metric event", eventType="metric") # suppressed for orders-only ``` **The filter is immutable and attributes-only.** It is compiled at `CreateSubscription` and cannot be changed afterward (`UpdateSubscription` rejects a `filter` change); a malformed filter is rejected at create-time with `INVALID_ARGUMENT`. Put the values you filter on into message **attributes**, not the body. See [Message filtering](/connectors/gcp-pub-sub/how-to/filtering). ## Independent backlogs and ack state [#independent-backlogs-and-ack-state] Each subscription is a separate Queue channel `gcp.sub.{subscription}` with its own backlog, ack-deadline leases, dead-letter policy, and retention. A consumer that lags, nacks, or dead-letters on one subscription has **no effect** on any other subscription bound to the same topic — the topic log `gcp.{topic}` is the single shared, replayable source, and each subscription replays from it independently via [Seek](/connectors/gcp-pub-sub/how-to/seek-and-snapshots). ## Related [#related] # Message filtering (/connectors/gcp-pub-sub/how-to/filtering) A subscription may carry a `filter` so it only receives messages whose **attributes** match. The connector implements a hand-written **CEL-subset** (a strict subset of Google's filter expression language) — attributes-only, compiled once at create-time, and applied at publish fan-out. ## Where the filter runs [#where-the-filter-runs] A filter is set on `CreateSubscription` and is **compiled once and immutable** thereafter (`UpdateSubscription` cannot change `filter`). It is applied at **publish fan-out**: when a `Publish` writes the topic log and fans copies out to each subscription, a message that does not match a subscription's filter is **never enqueued** for that subscription (it is effectively auto-acked for it). The topic log `gcp.{topic}` itself is unfiltered — the filter only governs which subscription queues receive a copy. See [Publishing](/connectors/gcp-pub-sub/how-to/publishing) and [Channel mapping](/connectors/gcp-pub-sub/reference/channel-mapping). ## Supported syntax [#supported-syntax] Filters operate on the message's **`attributes`** map only (not `data`, not the ordering key): ```text attributes:KEY -- attribute KEY exists (KEY may be quoted: attributes:"k") attributes.KEY = "v" -- equality attributes.KEY != "v" -- inequality hasPrefix(attributes.KEY, "p") -- value has the prefix "p" AND OR NOT - -- boolean operators (NOT and unary - both negate) ( … ) -- parentheses for grouping ``` Rules: * **Attributes-only.** There is no `data`-based filtering and no numeric / comparison operators beyond `=` / `!=` / `hasPrefix`. * **≤ 256 characters.** A filter expression longer than 256 chars is rejected. * **Immutable.** Compiled at `CreateSubscription`; cannot be changed by `UpdateSubscription`. * **Malformed → `INVALID_ARGUMENT`.** A filter that fails to parse is rejected at create-time, not silently ignored. **The filter is immutable and capped at 256 characters.** You cannot change `filter` after `CreateSubscription`; to alter the matching rule, create a new subscription. A filter longer than 256 chars, or one that fails to parse, is rejected with `INVALID_ARGUMENT`. See [Limits & rules](/connectors/gcp-pub-sub/reference/limits-and-rules). ## Examples [#examples] | Goal | Filter | | -------------------------------------- | ------------------------------------------------------------ | | Only messages tagged `region=eu` | `attributes.region = "eu"` | | Everything except `region=eu` | `attributes.region != "eu"` | | Has a `priority` attribute (any value) | `attributes:priority` | | `type=order` **and** high priority | `attributes.type = "order" AND attributes.priority = "high"` | | EU **or** US region | `attributes.region = "eu" OR attributes.region = "us"` | | Order events but not test traffic | `attributes.type = "order" AND NOT attributes:test` | | Event names starting with `user.` | `hasPrefix(attributes.event, "user.")` | | A quoted key with special chars | `attributes:"x-tenant"` | A filter is just a field on the subscription config: ```python from google.cloud import pubsub_v1 subscriber = pubsub_v1.SubscriberClient() # honours PUBSUB_EMULATOR_HOST subscriber.create_subscription(request={ "name": subscriber.subscription_path("my-project", "sub-eu"), "topic": "projects/my-project/topics/orders", "filter": 'attributes.region = "eu"', # compiled once; immutable }) ``` ## How attributes map [#how-attributes-map] Message `attributes` round-trip as KubeMQ message tags. The connector also carries three reserved tags (`_pubsub_message_id`, `_pubsub_publish_time`, `_pubsub_ordering_key`); filters operate on the **user attributes**, not the reserved tags. See [Publishing](/connectors/gcp-pub-sub/how-to/publishing). ## The fan-out pattern [#the-fan-out-pattern] Filtering is the basis of the fan-out pattern: attach **N subscriptions** to one topic, each with its own filter, and a single `Publish` is fanned out only to the subscriptions whose filter matches. See [Fan-out](/connectors/gcp-pub-sub/how-to/fan-out). ## Error quick reference [#error-quick-reference] | Trigger | Result | | --------------------------------------------------- | ------------------------------------------ | | Malformed / unparseable filter | `INVALID_ARGUMENT` at `CreateSubscription` | | Filter > 256 characters | `INVALID_ARGUMENT` | | Attempt to change `filter` via `UpdateSubscription` | rejected (immutable) | | Non-attribute (e.g. `data`-based) expression | `INVALID_ARGUMENT` | ## Related [#related] # Ordered Delivery (/connectors/gcp-pub-sub/how-to/ordered-delivery) **Ordered delivery** guarantees that messages sharing an **ordering key** are delivered in publish order, with **at most one in flight per key**. The head of a key blocks until it is acknowledged (or redelivered), and redelivery is in order — so a per-customer or per-aggregate stream is processed strictly in sequence. Messages without an ordering key are delivered unordered, and independent keys make progress in parallel. Ordering is a per-message feature with no code path beyond the standard Pub/Sub ordering-key API; the connector enforces the per-key sequencing internally. ## Overview [#overview] Ordering is enabled on **both** sides: set `enable_message_ordering` on the publisher (it serializes publishes per key) and on the subscription (it enforces one-in-flight-per-key delivery). Each publish then carries an `ordering_key`; the key rides across the wire as the reserved tag `_pubsub_ordering_key`, which the connector surfaces as the message's ordering key for Pub/Sub clients. | Step | Action | Behavior | | --------------- | ------------------------------------------------------ | ---------------------------------------------------------- | | Topic | `CreateTopic` + publisher ordering enabled | Publisher serializes publishes per key | | Subscription | `CreateSubscription(enable_message_ordering=true)` | One copy in flight per key | | Publish | `Publish(ordering_key="cust-7")` | In-order within the key; carried as `_pubsub_ordering_key` | | Pull + Ack | `Pull` → `Acknowledge` → next per-key message released | Head-of-key blocks until acked | | Keyless publish | `Publish` (no key) | Unordered; not serialized | Ordering semantics: * **Per-key total order** — within one `ordering_key`, delivery follows publish order; the next message is released only after the current one is acked or redelivered. * **At most one in flight per key** — a key is never delivered ahead of its own un-acked head. * **Independent keys run in parallel** — a round-robin cursor spreads delivery fairly across contended keys; a slow key never blocks a different one. * **Redelivery stays in order** — an ack-deadline expiry or nack redelivers the head before any later message in the same key. ## How it works [#how-it-works] The publisher serializes publishes per ordering key. The connector writes each message to the topic log `gcp.{topic}` and fans out a copy to the subscription queue `gcp.sub.{subscription}`, then releases at most one message per key at a time — the next per-key message is held until the current one is acknowledged. *Messages sharing an `ordering_key` are delivered in publish order with at most one in flight per key; the next per-key message is released only after the current one is acknowledged, while independent keys make progress in parallel.* ## Publish and consume in order [#publish-and-consume-in-order] Enable ordering on both the publisher and the subscription, publish interleaved messages across two keys (plus one keyless), and pull one at a time — acknowledging each before the next pull — so the per-key order is directly observable. Each client sets only `PUBSUB_EMULATOR_HOST` (default `localhost:8085`) and a project id. ```go package main import ( "context" "fmt" "log" "os" "time" "cloud.google.com/go/pubsub" "cloud.google.com/go/pubsub/apiv1/pubsubpb" ) func projectID() string { if v := os.Getenv("PUBSUB_PROJECT_ID"); v != "" { return v } return "my-project" } func main() { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() client, err := pubsub.NewClient(ctx, projectID()) if err != nil { log.Fatalf("NewClient: %v", err) } defer client.Close() // CreateTopic, then enable ordering on the publisher handle (it serializes // publishes per key). The subscription must enable ordering too — both sides. topic, err := client.CreateTopic(ctx, "ordered") if err != nil { log.Fatalf("CreateTopic: %v", err) } topic.EnableMessageOrdering = true defer topic.Stop() if _, err := client.CreateSubscription(ctx, "sub-ordered", pubsub.SubscriptionConfig{ Topic: topic, AckDeadline: 10 * time.Second, EnableMessageOrdering: true, }); err != nil { log.Fatalf("CreateSubscription: %v", err) } // Two keys interleaved + one keyless. Await each result so the per-key publish // order is preserved across keys. plan := []struct{ key, body string }{ {"cust-7", "A1"}, {"cust-9", "B1"}, {"cust-7", "A2"}, {"", "keyless"}, {"cust-9", "B2"}, {"cust-7", "A3"}, {"cust-9", "B3"}, } for _, p := range plan { m := &pubsub.Message{Data: []byte(p.body)} if p.key != "" { m.OrderingKey = p.key } if _, err := topic.Publish(ctx, m).Get(ctx); err != nil { log.Fatalf("Publish %q: %v", p.body, err) } } // Pull one at a time, ack before the next pull → connector releases the next // per-key message in order. subClient, err := pubsub.NewSubscriberClient(ctx) if err != nil { log.Fatalf("NewSubscriberClient: %v", err) } defer subClient.Close() subPath := fmt.Sprintf("projects/%s/subscriptions/%s", projectID(), "sub-ordered") for i := 0; i < len(plan); i++ { resp, err := subClient.Pull(ctx, &pubsubpb.PullRequest{Subscription: subPath, MaxMessages: 1}) if err != nil { log.Fatalf("Pull: %v", err) } if len(resp.GetReceivedMessages()) == 0 { i-- continue } rm := resp.GetReceivedMessages()[0] key := rm.GetMessage().GetOrderingKey() // surfaced from _pubsub_ordering_key. fmt.Printf("received body=%q ordering_key=%q\n", string(rm.GetMessage().GetData()), key) _ = subClient.Acknowledge(ctx, &pubsubpb.AcknowledgeRequest{ Subscription: subPath, AckIds: []string{rm.GetAckId()}, }) } // Per-key order is preserved: cust-7 → A1,A2,A3 and cust-9 → B1,B2,B3. } ``` ```python import os from google.cloud import pubsub_v1 from google.cloud.pubsub_v1.types import PublisherOptions def project_id() -> str: return os.environ.get("PUBSUB_PROJECT_ID", "my-project") def main() -> None: proj = project_id() # The publisher MUST enable message ordering (it serializes publishes per key). publisher = pubsub_v1.PublisherClient( publisher_options=PublisherOptions(enable_message_ordering=True) ) subscriber = pubsub_v1.SubscriberClient() topic_path = publisher.topic_path(proj, "ordered") # -> gcp.ordered sub_path = subscriber.subscription_path(proj, "sub-ordered") # -> gcp.sub.sub-ordered publisher.create_topic(request={"name": topic_path}) # The subscription must also enable ordering. subscriber.create_subscription( request={"name": sub_path, "topic": topic_path, "enable_message_ordering": True} ) # Two keys interleaved + one keyless; await each publish to preserve order. plan = [("cust-7", "A1"), ("cust-9", "B1"), ("cust-7", "A2"), ("", "keyless"), ("cust-9", "B2"), ("cust-7", "A3"), ("cust-9", "B3")] for key, body in plan: publisher.publish(topic_path, body.encode(), ordering_key=key).result(timeout=15) # Pull one at a time, ack before the next pull → next per-key message released. for _ in range(len(plan)): resp = subscriber.pull(request={"subscription": sub_path, "max_messages": 1}, timeout=20) if not resp.received_messages: continue rm = resp.received_messages[0] print(f"received body={rm.message.data.decode()!r} ordering_key={rm.message.ordering_key!r}") subscriber.acknowledge(request={"subscription": sub_path, "ack_ids": [rm.ack_id]}) subscriber.close() # cust-7 → A1,A2,A3 and cust-9 → B1,B2,B3 in order. if __name__ == "__main__": main() ``` ```java import com.google.api.gax.core.NoCredentialsProvider; import com.google.api.gax.grpc.GrpcTransportChannel; import com.google.api.gax.rpc.FixedTransportChannelProvider; import com.google.api.gax.rpc.TransportChannelProvider; import com.google.cloud.pubsub.v1.SubscriptionAdminClient; import com.google.cloud.pubsub.v1.SubscriptionAdminSettings; import com.google.cloud.pubsub.v1.TopicAdminClient; import com.google.cloud.pubsub.v1.TopicAdminSettings; import com.google.cloud.pubsub.v1.stub.GrpcSubscriberStub; import com.google.cloud.pubsub.v1.stub.SubscriberStubSettings; import com.google.protobuf.ByteString; import com.google.pubsub.v1.AcknowledgeRequest; import com.google.pubsub.v1.PublishRequest; import com.google.pubsub.v1.PubsubMessage; import com.google.pubsub.v1.PullRequest; import com.google.pubsub.v1.PullResponse; import com.google.pubsub.v1.PushConfig; import com.google.pubsub.v1.ReceivedMessage; import com.google.pubsub.v1.Subscription; import com.google.pubsub.v1.SubscriptionName; import com.google.pubsub.v1.TopicName; import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; import java.util.List; public final class Main { public static void main(String[] args) throws Exception { String emulatorHost = System.getenv().getOrDefault("PUBSUB_EMULATOR_HOST", "localhost:8085"); String projectId = System.getenv().getOrDefault("PUBSUB_PROJECT_ID", "my-project"); ManagedChannel channel = ManagedChannelBuilder.forTarget(emulatorHost).usePlaintext().build(); TransportChannelProvider channelProvider = FixedTransportChannelProvider.create(GrpcTransportChannel.create(channel)); NoCredentialsProvider noCreds = NoCredentialsProvider.create(); TopicName topic = TopicName.of(projectId, "ordered"); // -> gcp.ordered SubscriptionName sub = SubscriptionName.of(projectId, "sub-ordered"); // -> gcp.sub.sub-ordered try (TopicAdminClient topicAdmin = TopicAdminClient.create(TopicAdminSettings.newBuilder() .setTransportChannelProvider(channelProvider).setCredentialsProvider(noCreds).build()); SubscriptionAdminClient subAdmin = SubscriptionAdminClient.create( SubscriptionAdminSettings.newBuilder() .setTransportChannelProvider(channelProvider) .setCredentialsProvider(noCreds).build()); GrpcSubscriberStub subStub = GrpcSubscriberStub.create(SubscriberStubSettings.newBuilder() .setTransportChannelProvider(channelProvider) .setCredentialsProvider(noCreds).build())) { topicAdmin.createTopic(topic); // The subscription enables ordering; the publisher orders per key below. subAdmin.createSubscription(Subscription.newBuilder() .setName(sub.toString()).setTopic(topic.toString()) .setAckDeadlineSeconds(10).setEnableMessageOrdering(true).build()); // Two keys interleaved + one keyless; publish in order with an ordering key. String[][] plan = { {"cust-7", "A1"}, {"cust-9", "B1"}, {"cust-7", "A2"}, {"", "keyless"}, {"cust-9", "B2"}, {"cust-7", "A3"}, {"cust-9", "B3"}, }; for (String[] p : plan) { PubsubMessage.Builder m = PubsubMessage.newBuilder() .setData(ByteString.copyFromUtf8(p[1])); if (!p[0].isEmpty()) { m.setOrderingKey(p[0]); } topicAdmin.publish(PublishRequest.newBuilder() .setTopic(topic.toString()).addMessages(m.build()).build()); } // Pull one at a time, ack before the next pull → next per-key message released. for (int i = 0; i < plan.length; i++) { PullResponse resp = subStub.pullCallable().call(PullRequest.newBuilder() .setSubscription(sub.toString()).setMaxMessages(1).build()); if (resp.getReceivedMessagesCount() == 0) { i--; continue; } ReceivedMessage got = resp.getReceivedMessages(0); System.out.printf("received body=%s ordering_key=%s%n", got.getMessage().getData().toStringUtf8(), got.getMessage().getOrderingKey()); subStub.acknowledgeCallable().call(AcknowledgeRequest.newBuilder() .setSubscription(sub.toString()).addAckIds(got.getAckId()).build()); } // cust-7 → A1,A2,A3 and cust-9 → B1,B2,B3 in order. List.of(); // no-op to keep imports tidy } finally { channel.shutdown(); } } } ``` ```typescript import { PubSub, v1 } from "@google-cloud/pubsub"; const projectId = process.env["PUBSUB_PROJECT_ID"] ?? "my-project"; const baseOptions = new PubSub({ projectId }).options; const options = { ...baseOptions, port: baseOptions.port === undefined ? undefined : Number(baseOptions.port) }; const publisher = new v1.PublisherClient(options); const subscriber = new v1.SubscriberClient(options); async function main(): Promise { const topic = publisher.projectTopicsPath(projectId, "ordered"); // -> gcp.ordered const sub = subscriber.subscriptionPath(projectId, "sub-ordered"); // -> gcp.sub.sub-ordered await publisher.createTopic({ name: topic }); // The subscription enables ordering; each publish below carries an orderingKey. await subscriber.createSubscription({ name: sub, topic, ackDeadlineSeconds: 10, enableMessageOrdering: true }); // Two keys interleaved + one keyless; publish each in turn to preserve order. const plan: Array<[string, string]> = [ ["cust-7", "A1"], ["cust-9", "B1"], ["cust-7", "A2"], ["", "keyless"], ["cust-9", "B2"], ["cust-7", "A3"], ["cust-9", "B3"], ]; for (const [orderingKey, body] of plan) { await publisher.publish({ topic, messages: [{ data: Buffer.from(body), orderingKey }] }); } // Pull one at a time, ack before the next pull → next per-key message released. for (let i = 0; i < plan.length; i++) { const [pull] = await subscriber.pull({ subscription: sub, maxMessages: 1 }); if (!pull.receivedMessages || pull.receivedMessages.length === 0) { i--; continue; } const rm = pull.receivedMessages[0]; const body = Buffer.from(rm.message!.data as Uint8Array).toString("utf8"); console.log(`received body=${body} ordering_key=${rm.message!.orderingKey}`); await subscriber.acknowledge({ subscription: sub, ackIds: [rm.ackId!] }); } // cust-7 → A1,A2,A3 and cust-9 → B1,B2,B3 in order. } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using Google.Api.Gax; using Google.Cloud.PubSub.V1; using Google.Protobuf; var projectId = Environment.GetEnvironmentVariable("PUBSUB_PROJECT_ID") ?? "my-project"; var topicName = TopicName.FromProjectTopic(projectId, "ordered"); // -> gcp.ordered var subName = SubscriptionName.FromProjectSubscription(projectId, "sub-ordered"); // -> gcp.sub.sub-ordered var publisher = await new PublisherServiceApiClientBuilder { EmulatorDetection = EmulatorDetection.EmulatorOnly, }.BuildAsync(); var subscriber = await new SubscriberServiceApiClientBuilder { EmulatorDetection = EmulatorDetection.EmulatorOnly, }.BuildAsync(); await publisher.CreateTopicAsync(topicName); // The subscription enables ordering; each publish below carries an OrderingKey. await subscriber.CreateSubscriptionAsync(new Subscription { SubscriptionName = subName, TopicAsTopicName = topicName, AckDeadlineSeconds = 10, EnableMessageOrdering = true, }); // Two keys interleaved + one keyless; publish each in turn to preserve order. var plan = new[] { ("cust-7", "A1"), ("cust-9", "B1"), ("cust-7", "A2"), ("", "keyless"), ("cust-9", "B2"), ("cust-7", "A3"), ("cust-9", "B3"), }; foreach (var (orderingKey, body) in plan) { var msg = new PubsubMessage { Data = ByteString.CopyFromUtf8(body) }; if (orderingKey.Length > 0) msg.OrderingKey = orderingKey; await publisher.PublishAsync(topicName, new[] { msg }); } // Pull one at a time, ack before the next pull → next per-key message released. for (var i = 0; i < plan.Length; i++) { var pull = await subscriber.PullAsync(subName, maxMessages: 1); if (pull.ReceivedMessages.Count == 0) { i--; continue; } var rm = pull.ReceivedMessages[0]; Console.WriteLine($"received body={rm.Message.Data.ToStringUtf8()} ordering_key={rm.Message.OrderingKey}"); await subscriber.AcknowledgeAsync(subName, new[] { rm.AckId }); } // cust-7 → A1,A2,A3 and cust-9 → B1,B2,B3 in order. ``` ```ruby # frozen_string_literal: true require "google/cloud/pubsub" project_id = ENV["PUBSUB_PROJECT_ID"] || "my-project" emulator_host = ENV["PUBSUB_EMULATOR_HOST"] || "localhost:8085" pubsub = Google::Cloud::PubSub.new(project_id: project_id, emulator_host: emulator_host) topic_admin = pubsub.topic_admin sub_admin = pubsub.subscription_admin topic_path = pubsub.topic_path("ordered") # -> gcp.ordered sub_path = pubsub.subscription_path("sub-ordered") # -> gcp.sub.sub-ordered topic = topic_admin.create_topic(name: topic_path) # The subscription enables ordering; each publish below carries an ordering_key. sub_admin.create_subscription(name: sub_path, topic: topic_path, ack_deadline_seconds: 10, enable_message_ordering: true) # Two keys interleaved + one keyless. message_ordering: true serializes per key. publisher = pubsub.publisher(topic.name, async: { ordered: true }) plan = [["cust-7", "A1"], ["cust-9", "B1"], ["cust-7", "A2"], ["", "keyless"], ["cust-9", "B2"], ["cust-7", "A3"], ["cust-9", "B3"]] plan.each do |key, body| publisher.publish(body, ordering_key: key) end publisher.async_publisher.stop! # flush ordered publishes in order. # Pull one at a time, ack before the next pull → next per-key message released. subscriber = pubsub.subscriber(sub_path) plan.length.times do received = subscriber.pull(immediate: false, max: 1) next if received.empty? rcv = received.first puts "received body=#{rcv.data} ordering_key=#{rcv.ordering_key.inspect}" rcv.acknowledge! end # cust-7 → A1,A2,A3 and cust-9 → B1,B2,B3 in order. ``` **Enable ordering on both sides.** The publisher serializes publishes per key only when ordering is enabled on the publisher handle (`EnableMessageOrdering` / `enable_message_ordering` / `ordered: true`), and the subscription enforces one-in-flight-per-key only when it is created with ordering enabled. Enabling it on a single side is not enough. ## Why pull one at a time [#why-pull-one-at-a-time] The examples use unary `Pull` and acknowledge each message before the next pull so the **head-of-key blocks until acked** guarantee is directly observable. A high-level streaming subscriber buffers and acks asynchronously, which preserves per-key order but obscures the strict one-in-flight-per-key sequencing. The connector enforces the order regardless; pulling one at a time only makes it visible. ## Related [#related] # Publish & Subscribe (/connectors/gcp-pub-sub/how-to/publish-subscribe) **Publish & subscribe** is the core Pub/Sub round-trip: create a **topic**, attach a **subscription**, publish a message, then pull it back and acknowledge it. The connector maps this directly onto KubeMQ primitives — topic `orders` becomes the Events Store log `gcp.orders`, and subscription `sub-orders` becomes the Queue channel `gcp.sub.sub-orders`. Your Pub/Sub SDK code does not change; you only set `PUBSUB_EMULATOR_HOST` to point at the connector. ## Overview [#overview] `CreateTopic` registers the topic in the connector's registry and binds it to an Events Store log. `CreateSubscription` binds a Queue channel to that topic. `Publish` writes the message **once** to the topic log (the authoritative, replayable source) and fans out one Queue copy per subscription. `Pull` (or `StreamingPull`) delivers the message under an **ack-deadline lease** with an opaque `ack_id`; `Acknowledge(ack_id)` acks it off the subscription. If you never acknowledge, the lease expires and the message is redelivered. | Pub/Sub operation | KubeMQ mapping | Notes | | ---------------------------------- | ---------------------------------------------------------- | --------------------------------------------------- | | `CreateTopic("orders")` | Register Events Store log `gcp.orders` | Topic ids may not start with `sub.` | | `CreateSubscription("sub-orders")` | Bind Queue channel `gcp.sub.sub-orders` | Ack deadline 10..600 s (default 10) | | `Publish` | `SendEventsStore(gcp.orders)` + per-sub `SendQueueMessage` | Returns a server-assigned message id + publish time | | `Pull` / `StreamingPull` | Credit-driven `Get` from the Queue | Each message leased with an opaque `ack_id` | | `Acknowledge(ackId)` | `AckRange` — message removed | Acks the broker sequence under the lease | | ack-deadline expiry (no ack) | `NAckRange` — redelivered | A 250 ms sweeper applies retry backoff | A publish writes once to the topic log and is fanned out per subscription, so the message body and attributes round-trip losslessly. The three reserved tags — `_pubsub_message_id`, `_pubsub_publish_time`, `_pubsub_ordering_key` — are carried across the wire and stripped from `attributes` when delivered back to a Pub/Sub client. ## How it works [#how-it-works] A publisher publishes to a topic; the connector writes the message once to the Events Store log `gcp.{topic}` through the message broker, then fans out one Queue copy per subscription on `gcp.sub.{subscription}`. A subscriber pulls the message under an ack-deadline lease and acknowledges it by `ack_id`. *A publish writes once to the Events Store log `gcp.{topic}` and fans out one Queue copy per subscription on `gcp.sub.{subscription}`; a subscriber pulls each message under an ack-deadline lease and acks it by `ack_id`.* ## The full round-trip [#the-full-round-trip] The lifecycle is `CreateTopic` → `CreateSubscription` → `Publish` → `Pull` → `Acknowledge`. Each client sets only `PUBSUB_EMULATOR_HOST` (default `localhost:8085`) and a project id (parsed but ignored). Most clients auto-detect the emulator from the env var — **C#** needs `EmulatorDetection.EmulatorOnly`, **Ruby** needs an explicit `emulator_host:`, and **Java** points a plaintext `ManagedChannel` at the host with `NoCredentialsProvider`. ```go package main import ( "context" "fmt" "log" "os" "sync" "time" "cloud.google.com/go/pubsub" ) func projectID() string { if v := os.Getenv("PUBSUB_PROJECT_ID"); v != "" { return v } return "my-project" // any id; the project segment is parsed but ignored. } func main() { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() // PUBSUB_EMULATOR_HOST (default localhost:8085) routes the official client at // the connector over insecure gRPC with credentials cleared — no code change. client, err := pubsub.NewClient(ctx, projectID()) if err != nil { log.Fatalf("NewClient: %v", err) } defer client.Close() // 1. CreateTopic "orders" → Events Store log "gcp.orders". topic, err := client.CreateTopic(ctx, "orders") if err != nil { log.Fatalf("CreateTopic: %v", err) } defer topic.Stop() // 2. CreateSubscription "sub-orders" → Queue channel "gcp.sub.sub-orders". sub, err := client.CreateSubscription(ctx, "sub-orders", pubsub.SubscriptionConfig{ Topic: topic, AckDeadline: 10 * time.Second, // connector default; valid range 10..600s. }) if err != nil { log.Fatalf("CreateSubscription: %v", err) } // 3. Publish one message; the future resolves to the server-assigned id. id, err := topic.Publish(ctx, &pubsub.Message{ Data: []byte("order #4242 — 3x widget"), Attributes: map[string]string{"priority": "express"}, }).Get(ctx) if err != nil { log.Fatalf("Publish: %v", err) } fmt.Printf("published: %s\n", id) // 4. Pull exactly one message via Receive (StreamingPull), then stop the loop. recvCtx, recvCancel := context.WithTimeout(ctx, 15*time.Second) defer recvCancel() var once sync.Once err = sub.Receive(recvCtx, func(_ context.Context, m *pubsub.Message) { fmt.Printf("received: %q attr[priority]=%q\n", string(m.Data), m.Attributes["priority"]) m.Ack() // 5. Acknowledge by ack_id under its lease. once.Do(recvCancel) }) if err != nil && recvCtx.Err() == nil { log.Fatalf("Receive: %v", err) } } ``` ```python import os from google.cloud import pubsub_v1 def project_id() -> str: # Any id works — the project segment is parsed but ignored. return os.environ.get("PUBSUB_PROJECT_ID", "my-project") def main() -> None: # Both clients honour PUBSUB_EMULATOR_HOST (default localhost:8085): they clear # credentials, skip Google auth, and dial insecure gRPC. publisher = pubsub_v1.PublisherClient() subscriber = pubsub_v1.SubscriberClient() proj = project_id() topic_path = publisher.topic_path(proj, "orders") # -> gcp.orders sub_path = subscriber.subscription_path(proj, "sub-orders") # -> gcp.sub.sub-orders # 1. CreateTopic + 2. CreateSubscription. publisher.create_topic(request={"name": topic_path}) subscriber.create_subscription(request={"name": sub_path, "topic": topic_path}) # 3. Publish one message with a user attribute. future = publisher.publish(topic_path, b"order #4242 — 3x widget", priority="express") print(f"published: {future.result(timeout=15)}") # 4. Pull and read the message back. resp = subscriber.pull(request={"subscription": sub_path, "max_messages": 1}, timeout=20) msg = resp.received_messages[0] print(f"received: {msg.message.data.decode()!r} attrs={dict(msg.message.attributes)}") # 5. Acknowledge by ack_id; the message leaves the subscription queue. subscriber.acknowledge(request={"subscription": sub_path, "ack_ids": [msg.ack_id]}) subscriber.close() if __name__ == "__main__": main() ``` ```java import com.google.api.gax.core.NoCredentialsProvider; import com.google.api.gax.grpc.GrpcTransportChannel; import com.google.api.gax.rpc.FixedTransportChannelProvider; import com.google.api.gax.rpc.TransportChannelProvider; import com.google.cloud.pubsub.v1.SubscriptionAdminClient; import com.google.cloud.pubsub.v1.SubscriptionAdminSettings; import com.google.cloud.pubsub.v1.TopicAdminClient; import com.google.cloud.pubsub.v1.TopicAdminSettings; import com.google.cloud.pubsub.v1.stub.GrpcSubscriberStub; import com.google.cloud.pubsub.v1.stub.SubscriberStubSettings; import com.google.protobuf.ByteString; import com.google.pubsub.v1.AcknowledgeRequest; import com.google.pubsub.v1.PublishRequest; import com.google.pubsub.v1.PubsubMessage; import com.google.pubsub.v1.PullRequest; import com.google.pubsub.v1.PullResponse; import com.google.pubsub.v1.PushConfig; import com.google.pubsub.v1.ReceivedMessage; import com.google.pubsub.v1.SubscriptionName; import com.google.pubsub.v1.TopicName; import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; public final class Main { public static void main(String[] args) throws Exception { String emulatorHost = System.getenv().getOrDefault("PUBSUB_EMULATOR_HOST", "localhost:8085"); String projectId = System.getenv().getOrDefault("PUBSUB_PROJECT_ID", "my-project"); // Java points a plaintext gRPC channel at the host explicitly with NoCredentialsProvider. ManagedChannel channel = ManagedChannelBuilder.forTarget(emulatorHost).usePlaintext().build(); TransportChannelProvider channelProvider = FixedTransportChannelProvider.create(GrpcTransportChannel.create(channel)); NoCredentialsProvider noCreds = NoCredentialsProvider.create(); TopicName topic = TopicName.of(projectId, "orders"); // -> gcp.orders SubscriptionName sub = SubscriptionName.of(projectId, "sub-orders"); // -> gcp.sub.sub-orders try (TopicAdminClient topicAdmin = TopicAdminClient.create(TopicAdminSettings.newBuilder() .setTransportChannelProvider(channelProvider).setCredentialsProvider(noCreds).build()); SubscriptionAdminClient subAdmin = SubscriptionAdminClient.create( SubscriptionAdminSettings.newBuilder() .setTransportChannelProvider(channelProvider) .setCredentialsProvider(noCreds).build()); GrpcSubscriberStub subStub = GrpcSubscriberStub.create(SubscriberStubSettings.newBuilder() .setTransportChannelProvider(channelProvider) .setCredentialsProvider(noCreds).build())) { // 1. CreateTopic + 2. CreateSubscription (ack deadline 10s). topicAdmin.createTopic(topic); subAdmin.createSubscription(sub, topic, PushConfig.getDefaultInstance(), 10); // 3. Publish one message with a user attribute. topicAdmin.publish(PublishRequest.newBuilder() .setTopic(topic.toString()) .addMessages(PubsubMessage.newBuilder() .setData(ByteString.copyFromUtf8("order #4242 — 3x widget")) .putAttributes("priority", "express").build()) .build()); // 4. Pull exactly one message. PullResponse resp = subStub.pullCallable().call(PullRequest.newBuilder() .setSubscription(sub.toString()).setMaxMessages(1).build()); ReceivedMessage got = resp.getReceivedMessages(0); System.out.printf("received: %s%n", got.getMessage().getData().toStringUtf8()); // 5. Acknowledge by ack_id. subStub.acknowledgeCallable().call(AcknowledgeRequest.newBuilder() .setSubscription(sub.toString()).addAckIds(got.getAckId()).build()); } finally { channel.shutdown(); } } } ``` ```typescript import { PubSub, v1 } from "@google-cloud/pubsub"; const projectId = process.env["PUBSUB_PROJECT_ID"] ?? "my-project"; // The high-level PubSub client reads PUBSUB_EMULATOR_HOST (default localhost:8085) // and resolves the insecure emulator transport; reuse its options for the v1 clients. const baseOptions = new PubSub({ projectId }).options; const options = { ...baseOptions, port: baseOptions.port === undefined ? undefined : Number(baseOptions.port) }; const publisher = new v1.PublisherClient(options); const subscriber = new v1.SubscriberClient(options); async function main(): Promise { const topic = publisher.projectTopicsPath(projectId, "orders"); // -> gcp.orders const sub = subscriber.subscriptionPath(projectId, "sub-orders"); // -> gcp.sub.sub-orders // 1. CreateTopic + 2. CreateSubscription (ack deadline 10s). await publisher.createTopic({ name: topic }); await subscriber.createSubscription({ name: sub, topic, ackDeadlineSeconds: 10 }); // 3. Publish one message with a user attribute. const [published] = await publisher.publish({ topic, messages: [{ data: Buffer.from("order #4242 — 3x widget"), attributes: { priority: "express" } }], }); console.log(`published: ${published.messageIds?.[0]}`); // 4. Pull exactly one message. const [pull] = await subscriber.pull({ subscription: sub, maxMessages: 1 }); const received = pull.receivedMessages![0]; console.log(`received: ${Buffer.from(received.message!.data as Uint8Array).toString("utf8")}`); // 5. Acknowledge by ack_id. await subscriber.acknowledge({ subscription: sub, ackIds: [received.ackId!] }); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using Google.Api.Gax; using Google.Cloud.PubSub.V1; using Google.Protobuf; var projectId = Environment.GetEnvironmentVariable("PUBSUB_PROJECT_ID") ?? "my-project"; var topicName = TopicName.FromProjectTopic(projectId, "orders"); // -> gcp.orders var subName = SubscriptionName.FromProjectSubscription(projectId, "sub-orders"); // -> gcp.sub.sub-orders // The .NET client does NOT auto-detect the emulator — set EmulatorOnly so each // client reads PUBSUB_EMULATOR_HOST (default localhost:8085) and dials insecurely. var publisher = await new PublisherServiceApiClientBuilder { EmulatorDetection = EmulatorDetection.EmulatorOnly, }.BuildAsync(); var subscriber = await new SubscriberServiceApiClientBuilder { EmulatorDetection = EmulatorDetection.EmulatorOnly, }.BuildAsync(); // 1. CreateTopic + 2. CreateSubscription (ack deadline 10s). await publisher.CreateTopicAsync(topicName); await subscriber.CreateSubscriptionAsync(subName, topicName, pushConfig: null, ackDeadlineSeconds: 10); // 3. Publish one message with a user attribute. var publishResponse = await publisher.PublishAsync(topicName, new[] { new PubsubMessage { Data = ByteString.CopyFromUtf8("order #4242 — 3x widget"), Attributes = { ["priority"] = "express" }, }, }); Console.WriteLine($"published: {publishResponse.MessageIds[0]}"); // 4. Pull exactly one message. var pull = await subscriber.PullAsync(subName, maxMessages: 1); var received = pull.ReceivedMessages[0]; Console.WriteLine($"received: {received.Message.Data.ToStringUtf8()}"); // 5. Acknowledge by ack_id. await subscriber.AcknowledgeAsync(subName, new[] { received.AckId }); ``` ```ruby # frozen_string_literal: true require "google/cloud/pubsub" project_id = ENV["PUBSUB_PROJECT_ID"] || "my-project" # Ruby needs the emulator host passed explicitly (it does not always read the env var). emulator_host = ENV["PUBSUB_EMULATOR_HOST"] || "localhost:8085" pubsub = Google::Cloud::PubSub.new(project_id: project_id, emulator_host: emulator_host) topic_admin = pubsub.topic_admin sub_admin = pubsub.subscription_admin topic_path = pubsub.topic_path("orders") # -> gcp.orders sub_path = pubsub.subscription_path("sub-orders") # -> gcp.sub.sub-orders # 1. CreateTopic + 2. CreateSubscription (ack deadline 10s). topic = topic_admin.create_topic(name: topic_path) sub_admin.create_subscription(name: sub_path, topic: topic_path, ack_deadline_seconds: 10) # 3. Publish one message with a user attribute. publisher = pubsub.publisher(topic.name) msg = publisher.publish("order #4242 — 3x widget", priority: "express") puts "published: #{msg.message_id}" # 4. Pull exactly one message. subscriber = pubsub.subscriber(sub_path) rcv = subscriber.pull(immediate: false, max: 1).first puts "received: #{rcv.data.inspect} attrs=#{rcv.attributes.to_h.inspect}" # 5. Acknowledge by ack_id; the message leaves the subscription queue. rcv.acknowledge! ``` The pulled message carries only **your own attributes** — the three reserved tags (`_pubsub_message_id`, `_pubsub_publish_time`, `_pubsub_ordering_key`) are stamped on the wire for native consumers and stripped from `attributes` before delivery to a Pub/Sub client. The message id and publish time are surfaced through the SDK's own fields, not the attribute map. ## Lease, ack, and redelivery [#lease-ack-and-redelivery] Every delivered message gets an opaque `ack_id` and is held under an **ack-deadline lease** (default 10 s, range 10..600 s). Acknowledging by `ack_id` acks the broker sequence and removes the message. If the deadline passes without an ack, a 250 ms sweeper applies the retry backoff and **redelivers** — `ModifyAckDeadline(0)` is an explicit nack that redelivers immediately, while `ModifyAckDeadline(n)` extends the lease. See [Subscribing](/connectors/gcp-pub-sub/how-to/subscribing) for `Pull` vs `StreamingPull`, flow control, and exactly-once delivery. **Exactly-once and leases are node-local.** An `ack_id` minted on one node is invalid on another. In a clustered deployment, pin a subscription's `StreamingPull` traffic to one node (session-affinity load balancer) — or accept at-least-once delivery across nodes. Single-node deployments are unaffected. ## Related [#related] # Publishing (/connectors/gcp-pub-sub/how-to/publishing) This guide covers the publish surface end to end: topic lifecycle, a single publish, batch publish (≤ 1000 messages), the **atomic batch-validation** rule, ordering keys, and message attributes. Every topic is a native KubeMQ **Events Store** log `gcp.{topic}` (see [Channel mapping](/connectors/gcp-pub-sub/reference/channel-mapping)). ## Topic lifecycle [#topic-lifecycle] The `Publisher` surface ships **9 RPCs** (see [Capabilities](/connectors/gcp-pub-sub/reference/capabilities)): * `CreateTopic` — validates the name; `kms_key_name` is accepted-and-ignored; ingestion configs are **rejected** (`INVALID_ARGUMENT`); requested retention is clamped to the broker ceiling. * `GetTopic` — returns the **requested** (un-clamped) retention. * `ListTopics` — opaque page token. * `UpdateTopic` — a `FieldMask` over `labels`, `message_retention_duration`, `schema_settings`. * `DeleteTopic` — a **tombstone**: the record is retained so existing subscriptions survive, and re-creating the topic reuses the same log. * `ListTopicSubscriptions`, `ListTopicSnapshots`, `DetachSubscription`, and `Publish` (below). **Topic ids may not start with `sub.`.** That prefix is the reserved broker namespace for subscription queues (`gcp.sub.{s}`). Resource ids must be 3..255 chars, start with a letter, use the charset `[A-Za-z0-9._~%+-]`, and carry no `goog` prefix. See [Limits & rules](/connectors/gcp-pub-sub/reference/limits-and-rules). ## A single publish [#a-single-publish] `Publish` returns a server-assigned **message id**. The connector writes the message **once** to the Events Store log `gcp.{topic}` — the authoritative, cross-protocol, replayable copy and the source for `Seek` — then fans out one queue copy per subscription, applying each subscription's filter: 1. The SDK sends a `PubsubMessage { data, attributes, ordering_key }`. 2. The connector assigns a **message id** and a **publish time** and returns the id. 3. The message lands in `gcp.{topic}` via the Events Store send, then is fanned out to each subscription's queue `gcp.sub.{s}`. A filtered-out message is never enqueued for that subscription (it is effectively auto-acked); detached subscriptions are skipped. **A publish writes once to the topic log, then fans out per subscription.** The single write to `gcp.{topic}` is the source of truth; the per-subscription copies on `gcp.sub.{s}` are derived from it. A native KubeMQ consumer of `gcp.{topic}` therefore sees every published message regardless of which subscriptions exist. See [Architecture](/connectors/gcp-pub-sub/concepts/architecture). ## Batch publish [#batch-publish] `Publish` accepts a **batch of 1..1000** messages. Server-assigned ids are returned **in request order**, so a client can correlate each id with its input message. ### Atomic batch validation [#atomic-batch-validation] **The whole batch is validated before anything is enqueued.** If any message fails validation, the **entire batch** is rejected with `INVALID_ARGUMENT` and **nothing is published** — there is no partial publish. Per-message validation rules: | Rule | Limit | | ------------------ | -------------------------------------------- | | Batch size | 1..1000 messages | | Total message size | ≤ 10 MiB | | Attributes | ≤ 100 per message | | Attribute key | ≤ 256 B, no `goog` prefix | | Attribute value | ≤ 1024 B | | Ordering key | ≤ 1024 B | | Body | `data` **or** `attributes` must be non-empty | If the topic has a **schema** (see [Schema validation](/connectors/gcp-pub-sub/how-to/schema-validation)), every message is also validated against it and the whole batch is rejected on the first non-conforming message. The full limit table is in [Limits & rules](/connectors/gcp-pub-sub/reference/limits-and-rules). A batch publish that prints its server-assigned ids in order: ```python from google.cloud import pubsub_v1 publisher = pubsub_v1.PublisherClient() # honours PUBSUB_EMULATOR_HOST topic_path = publisher.topic_path("my-project", "events") futures = [publisher.publish(topic_path, f"event-{i}".encode()) for i in range(5)] for fut in futures: # ids returned in request order print(fut.result()) ``` ## Ordering keys [#ordering-keys] Set a per-message `ordering_key` and **enable ordering on the subscription** (`enable_message_ordering`). Messages sharing an `ordering_key` are then delivered in **publish order**, with **at most one in flight per key** — the head of a key blocks until it is acked or redelivered, and redelivery is in order. A round-robin cursor spreads delivery fairly across contended keys; keyless messages are delivered unordered. **Ordering is opt-in on the subscriber side.** Publishing with an `ordering_key` is necessary but not sufficient — the **subscription** must set `enable_message_ordering` for ordered delivery. The ordering key travels as the reserved tag `_pubsub_ordering_key`. See [Ordered delivery](/connectors/gcp-pub-sub/how-to/ordered-delivery). ## Message attributes [#message-attributes] A `PubsubMessage`'s `attributes` map (string → string) round-trips as KubeMQ message **tags**. On top of the user attributes the connector carries **three reserved tags** across the wire: * `_pubsub_message_id` — the server-assigned id; * `_pubsub_publish_time` — the publish timestamp; * `_pubsub_ordering_key` — the ordering key (if any). **Reserved tags are visible to native consumers, hidden from Pub/Sub clients.** A native KubeMQ consumer of `gcp.{topic}` sees all three reserved tags plus the user attributes; when the connector delivers the message back to a Pub/Sub client, the reserved tags are **stripped** from `attributes`. See [Channel mapping](/connectors/gcp-pub-sub/reference/channel-mapping). Attribute constraints (enforced in the atomic validation above): ≤ 100 attributes; key ≤ 256 B with no `goog` prefix; value ≤ 1024 B. ## Error quick reference [#error-quick-reference] | Trigger | Result | | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | Batch > 1000 messages, or any message > 10 MiB / > 100 attributes / oversize key/value | `INVALID_ARGUMENT` — **whole batch** rejected | | `data` and `attributes` both empty | `INVALID_ARGUMENT` | | Topic id starts with `sub.`, bad charset, or `goog` prefix | `INVALID_ARGUMENT` on `CreateTopic` | | Ingestion config on `CreateTopic` | `INVALID_ARGUMENT` | | Message fails the topic's schema | `INVALID_ARGUMENT` — **whole batch** rejected on first non-conforming message | ## Related [#related] # Push Delivery (/connectors/gcp-pub-sub/how-to/push-delivery) A subscription with a `push_config` is delivered **push-style**: instead of the client pulling, a per-subscription connector worker pulls from the queue `gcp.sub.{s}` and **POSTs** each message to your HTTP(S) endpoint. This guide covers the delivery worker, the envelope shape, the `no_wrapper` mode, optional OIDC auth, the HTTPS / localhost rule, and the retry → dead-letter behavior. ## Pull ↔ push [#pull--push] A subscription is either pull or push, and you switch between them at any time: * `CreateSubscription` with a `push_config`, or `ModifyPushConfig` with one, starts a per-subscription **delivery worker**. * `ModifyPushConfig` with an **empty** config returns the subscription to pull. * Workers start on `CreateSubscription` / `ModifyPushConfig` (push), stop on switch-to-pull or delete, and **drain on connector shutdown**. See [Subscribing](/connectors/gcp-pub-sub/how-to/subscribing) for the pull paths. ## The wrapped envelope [#the-wrapped-envelope] By default the worker POSTs a **wrapped JSON envelope**: ```json { "message": { "data": "", "attributes": { "key": "value" }, "messageId": "...", "publishTime": "...", "orderingKey": "..." }, "subscription": "..." } ``` * `data` is **base64-encoded** — decode it on receipt. * `attributes` are the user attributes; the reserved `_pubsub_*` tags are not surfaced here. * `messageId` / `publishTime` / `orderingKey` mirror the message metadata. ### `no_wrapper` mode [#no_wrapper-mode] When the subscription's push config sets `no_wrapper`, the worker POSTs the **raw message body** instead of the envelope, with the attributes surfaced as `x-goog-*` headers when configured. Use this for endpoints that expect the payload directly. ## Acknowledgement [#acknowledgement] The HTTP response status is the ack signal: | Endpoint response | Effect | | ------------------- | ------------------------------------------------------------------------------------------------------------------ | | `2xx` | The message is **acked**. | | Non-`2xx` / timeout | **Retried** with backoff. | | Retry exhaustion | Republished to the subscription's **dead-letter topic** if one is set, else **dropped** (and a metric increments). | The retry → dead-letter pipeline shares the same dead-letter machinery as the pull paths — see [Reliability](/connectors/gcp-pub-sub/how-to/reliability). ## OIDC authentication [#oidc-authentication] When the push config sets an `oidc_token`, the worker adds an **OIDC JWT** as `Authorization: Bearer `. The token audience defaults to the endpoint URL, letting your endpoint verify the request originated from the connector. Without `oidc_token`, no `Authorization` header is sent. ## HTTPS / localhost rule [#https--localhost-rule] **Push endpoints must be HTTPS.** Plain `http://` is allowed **only for localhost** (local development). Any non-localhost endpoint must be `https://`. This matches Google's push-endpoint requirement and keeps message data off the wire in cleartext. It is the connector's **outbound** transport rule and is independent of the inbound emulator mode — which is insecure gRPC by design. See [Connectivity & emulator mode](/connectors/gcp-pub-sub/how-to/connectivity-and-emulator-mode). ## Node-local caveat (cluster) [#node-local-caveat-cluster] **Push state is node-local.** The per-subscription worker and its in-flight retries live on the node that runs the worker. In a cluster, push subscriptions are part of the node-local family — use a sticky load balancer (session affinity) so a subscription's push worker and its retries stay on one node. Single-node deployments are unaffected. See [Reliability](/connectors/gcp-pub-sub/how-to/reliability). ## Error quick reference [#error-quick-reference] | Trigger | Result | | -------------------------------------- | ------------------------------------ | | Non-localhost endpoint over plain HTTP | rejected (HTTPS required) | | Endpoint returns non-`2xx` / times out | retried with backoff | | Retries exhausted, DLQ set | republished to the dead-letter topic | | Retries exhausted, no DLQ | dropped (+ metric) | ## Related [#related] # Reliability (/connectors/gcp-pub-sub/how-to/reliability) This guide covers the connector's delivery guarantees: the **at-least-once** baseline, **retry backoff** and **dead-letter topics**, **exactly-once** delivery and its node-local boundary, and how **retention** is clamped to the message broker. Several behaviors carry node-local caveats — read the callouts. ## At-least-once is the baseline [#at-least-once-is-the-baseline] Every subscription delivers **at least once**. A delivered message is held under an ack-deadline lease; if it is not acked before the deadline, a 250 ms sweeper applies the retry backoff and **redelivers** it (the receive count increments). An explicit nack — `ModifyAckDeadline(0)` — redelivers immediately. On a broker not-ready → ready transition the connector drops all in-memory leases and redelivers any in-flight messages. **Design consumers to be idempotent.** Without `enable_exactly_once_delivery`, a message may be delivered more than once — on deadline expiry, nack, or broker recovery. Exactly-once tightens this, but only within a single node (see below). A message is never silently lost on a graceful path; it is **redelivered** and the receive count increments. ## Retry backoff [#retry-backoff] Ack-deadline expiry redelivers with an **exponential backoff** clamped to the subscription's `[min, max]` retry policy (defaults 10 s … 600 s), so a transiently failing message gets spaced-out retries rather than a hot loop. An explicit nack (`ModifyAckDeadline(0)`) **bypasses** the backoff and redelivers immediately. Ordering keys keep their in-order guarantee across redeliveries — an ordered message redelivers before any later message for the same key. ## Dead-letter topics [#dead-letter-topics] A dead-letter topic is the connector's poison-message safety valve: a message that keeps failing is moved out of the subscription instead of redelivering forever. 1. Create a subscription with a `dead_letter_topic` and a `max_delivery_attempts` value. 2. Each delivery increments the message's receive count; a nack or an expired lease drives redelivery with backoff. 3. When the **receive count exceeds `max_delivery_attempts`**, the 250 ms sweeper **republishes** the message to the dead-letter topic (a connector-level fan-out through the normal publish path) and **acks the original** — so it leaves the source subscription. The dead-letter topic is an ordinary Pub/Sub topic backed by its own Events Store log `gcp.{dlt}`, so you attach a subscription to it and consume the failed messages like any other. **`max_delivery_attempts` must be 5..100.** A value of `0` means *unset* — no dead-lettering, so the message redelivers indefinitely. Any non-zero value **must** be in the range 5..100; outside that range the subscription create is rejected with `INVALID_ARGUMENT`. This is Google's own rule, enforced up front. See [Limits & rules](/connectors/gcp-pub-sub/reference/limits-and-rules). Push subscriptions share the same retry → dead-letter pipeline (retry on non-2xx / timeout, dead-letter on exhaustion). See [Push delivery](/connectors/gcp-pub-sub/how-to/push-delivery). ## Exactly-once delivery [#exactly-once-delivery] A subscription created with `enable_exactly_once_delivery` strengthens the contract: once a message is **successfully acknowledged**, it will not be redelivered, and the ack itself is **confirmed** so the client knows it took effect. The ack contract changes so the SDK can resolve each result: * **StreamingPull** returns an `AcknowledgeConfirmation` / `ModifyAckDeadlineConfirmation`. Expired or unknown `ack_id`s appear in `invalid_ack_ids`; transient broker failures appear in `temporary_failed_ack_ids` (the client retries those). * **Unary** `Acknowledge` / `ModifyAckDeadline` on an invalid id returns a `FAILED_PRECONDITION` status carrying an `ErrorInfo{reason: PERMANENT_FAILURE_INVALID_ACK_ID}`. **The unary invalid-ack error is `FAILED_PRECONDITION` + `ErrorInfo`, not `INVALID_ARGUMENT`.** This matches the real Google SDK contract — the client library reads the ack result from the `ErrorInfo` reason. A naive `INVALID_ARGUMENT` would break that resolution. See [Error codes](/connectors/gcp-pub-sub/reference/error-codes). ### Exactly-once is node-local [#exactly-once-is-node-local] **Exactly-once is node-local — pin StreamingPull to one node.** An `ack_id` is a token that carries the **node id** of the node that minted it. An `ack_id` minted on one node is **invalid on another** (the node id won't match), so there is no cluster-wide distributed exactly-once. In a KubeMQ **cluster** you must either **pin** an exactly-once subscription's StreamingPull traffic to a single node (a sticky load balancer with session affinity), or **accept at-least-once** across nodes. Single-node deployments are unaffected. This is the most important caveat in this connector. ## Retention is clamped to the broker [#retention-is-clamped-to-the-broker] Per-resource retention is **clamped** to the message broker's global `Store.MaxRetention` ceiling. `GetTopic` / `GetSubscription` echo the requested retention value, but fan-out, seek, and the dashboard use the **effective (clamped)** value. Because the replayable topic log is the source for `Seek`, what is actually retained — and therefore how far back you can rewind — depends on the broker ceiling, not just the value you requested. ## Node-local state summary (cluster) [#node-local-state-summary-cluster] **Three pieces of delivery state are node-local.** Topic / subscription / snapshot / schema **records** are synchronized cluster-wide, so resource **existence** is cluster-wide. But these delivery-state pieces are node-local and require a **sticky load balancer** in a cluster: exactly-once `ack_id`s (node id baked into the token); StreamingPull leases, in-flight tracking, and flow-control counters; and push-delivery workers with their in-flight retries. Single-node deployments are unaffected. ## Error quick reference [#error-quick-reference] | Trigger | Result | | ------------------------------------------------------- | --------------------------------------------------------------------- | | Ack deadline expires without ack | redelivery; receive count increments | | `ModifyAckDeadline(0)` | immediate nack / redeliver | | Receive count exceeds `max_delivery_attempts` (DLQ set) | republished to `dead_letter_topic`, original acked | | Exactly-once unary ack of an expired / unknown id | `FAILED_PRECONDITION` + `ErrorInfo(PERMANENT_FAILURE_INVALID_ACK_ID)` | | Exactly-once StreamingPull ack of an expired id | id appears in `invalid_ack_ids` | | `max_delivery_attempts` outside 5..100 | rejected (`INVALID_ARGUMENT`) | | Broker not-ready → ready transition | in-memory leases dropped; in-flight messages redelivered | ## Related [#related] # Schema Validation (/connectors/gcp-pub-sub/how-to/schema-validation) A **schema** describes the shape every message published to a topic must conform to. When a topic references a schema, the connector **enforces it on publish** — non-conforming messages are rejected before they ever reach the topic log. The connector supports the two schema types Google Cloud Pub/Sub supports: **Avro** and **Protobuf**, stored as records in a per-node replicated registry with a full revision history. The `SchemaService` ships **10 RPCs**. ## How enforcement works [#how-enforcement-works] 1. `CreateSchema` registers a schema definition — an Avro JSON/IDL definition or a Protobuf message definition — under a schema id. The definition must be **≤ 300 KB**. 2. A topic is created (or updated) with `schema_settings` referencing that schema, plus an encoding — `JSON` or `BINARY`. 3. On every `Publish` to that topic the connector validates each message's `data` against the schema. Validation is part of the atomic batch check: the **whole batch is rejected** (`INVALID_ARGUMENT`) on the **first** non-conforming message — nothing in the batch is enqueued. ```text CreateSchema(avro|protobuf, ≤ 300 KB) ──▶ registry record (revisions) CreateTopic(schema_settings → schema id, encoding) ──▶ topic bound to schema Publish(batch) ──▶ validate each message against the schema first non-conforming message ──▶ reject WHOLE batch (INVALID_ARGUMENT) all conforming ──▶ write once to gcp.{t}, then fan out ``` A conforming publish then follows the normal path — written once to the Events Store log `gcp.{t}` and fanned out one queue copy per subscription. See [Publishing](/connectors/gcp-pub-sub/how-to/publishing). **Enforcement is all-or-nothing per batch.** Because publish is atomic, one bad message rejects the entire `Publish` call and enqueues **nothing**. Validate client-side, or publish smaller batches, if you want finer-grained failure isolation. ## Defining and binding a schema [#defining-and-binding-a-schema] Register the definition, then bind a topic to it with `schema_settings`: ```go schema, _ := schemaClient.CreateSchema(ctx, &pubsubpb.CreateSchemaRequest{ Parent: "projects/" + projectID, SchemaId: "order-v1", Schema: &pubsubpb.Schema{Type: pubsubpb.Schema_AVRO, Definition: avroDef}, }) _, _ = client.CreateTopic(ctx, &pubsubpb.Topic{ Name: "projects/" + projectID + "/topics/orders", SchemaSettings: &pubsubpb.SchemaSettings{Schema: schema.Name, Encoding: pubsubpb.Encoding_JSON}, }) ``` ```python from google.cloud import pubsub_v1 from google.pubsub_v1.types import Schema, Encoding schema = schema_client.create_schema( request={"parent": f"projects/{project_id}", "schema_id": "order-v1", "schema": {"type_": Schema.Type.AVRO, "definition": avro_def}}) publisher.create_topic(request={"name": topic_path, "schema_settings": {"schema": schema.name, "encoding": Encoding.JSON}}) ``` ```java Schema schema = schemaClient.createSchema( SchemaName.of(projectId, "order-v1").getParent(), Schema.newBuilder().setType(Schema.Type.AVRO).setDefinition(avroDef).build(), "order-v1"); topicAdminClient.createTopic(Topic.newBuilder() .setName(topicName.toString()) .setSchemaSettings(SchemaSettings.newBuilder() .setSchema(schema.getName()).setEncoding(Encoding.JSON).build()) .build()); ``` ```javascript const [schema] = await pubsub.createSchema('order-v1', SchemaTypes.Avro, avroDef); await pubsub.createTopic({ name: 'orders', schemaSettings: { schema: schema.name, encoding: 'JSON' }, }); ``` ```csharp var schema = await schemaClient.CreateSchemaAsync( new ProjectName(projectId), new Schema { Type = Schema.Types.Type.Avro, Definition = avroDef }, "order-v1"); await publisher.CreateTopicAsync(new Topic { TopicName = TopicName.FromProjectTopic(projectId, "orders"), SchemaSettings = new SchemaSettings { Schema = schema.Name, Encoding = Encoding.Json }, }); ``` ```ruby schema = schema_client.create_schema parent: "projects/#{project_id}", schema_id: "order-v1", schema: { type: :AVRO, definition: avro_def } publisher.create_topic name: topic_path, schema_settings: { schema: schema.name, encoding: :JSON } ``` ## Avro vs Protobuf [#avro-vs-protobuf] | Type | How it is validated | Notes | | ------------ | ------------------------------------------------------------ | ----------------------------------------------- | | **Avro** | parsed and validated via the connector's Avro engine | the definition is an Avro JSON / IDL definition | | **Protobuf** | parsed and validated via the connector's protoreflect engine | the definition is a `proto` message definition | Both enforce at publish time with identical batch-atomic semantics; the only difference is the definition language and the encoding. A definition that fails to parse — for either type — is rejected with `INVALID_ARGUMENT` at `CreateSchema` / `CommitSchema` time. ## Revisions [#revisions] A schema is **versioned**, and the connector keeps a full revision history: * `CommitSchema` adds a new revision to an existing schema. * `RollbackSchema` creates a new revision that restores a prior definition. * `DeleteSchemaRevision` removes a revision but **always keeps at least one** — you cannot delete the last remaining revision. * `ListSchemaRevisions` / `GetSchema` (BASIC or FULL) read the history; `ValidateSchema` and `ValidateMessage` check a definition or a payload without publishing. A producer can evolve its schema across revisions without breaking topics that have not yet opted in to the new revision. **Schema definitions are capped at 300 KB.** A `CreateSchema` or `CommitSchema` with a definition larger than 300 KB is rejected. Keep schemas focused. See [Limits & rules](/connectors/gcp-pub-sub/reference/limits-and-rules). ## Related [#related] # Seek & Snapshots (/connectors/gcp-pub-sub/how-to/seek-and-snapshots) Because every topic is backed by a durable, replayable **Events Store log** `gcp.{t}`, a subscription can be **rewound**. `Seek` resets a subscription's position to a point in the past — either a **timestamp** or a saved **snapshot** — and replays the topic log from there into the subscription's queue. This is how you reprocess messages: redeploy a consumer with a bug fix, then seek the subscription back to before the bad window and let it re-consume. ## How Seek works [#how-seek-works] A `Seek` against a subscription: 1. **Resolves the start sequence** from the topic log — from a timestamp (the first message at or after that time) or from a snapshot's captured cursor. 2. **Purges the subscription queue and drops outstanding leases** — in-flight `ack_id`s become invalid (this is the reset). 3. **Replays the topic log** from the start sequence and **re-applies the subscription's filter** as it fans the replayed messages back into `gcp.sub.{s}`. ```text Seek(subscription, time | snapshot) │ resolve start seq from gcp.{t} ▼ purge sub queue + drop leases (in-flight ack_ids now invalid) │ ▼ replay gcp.{t} from start seq ──(re-apply filter)──▶ refill gcp.sub.{s} │ ▼ bounded by MaxSeekReplay (default 1,000,000) → hit cap = WARN, no silent loss ``` A `Seek` call looks the same in every client; both forms are shown here: ```go // Seek to a timestamp. _, _ = subClient.Seek(ctx, &pubsubpb.SeekRequest{ Subscription: subName, Target: &pubsubpb.SeekRequest_Time{Time: timestamppb.New(cutoff)}, }) // Seek to a snapshot. _, _ = subClient.Seek(ctx, &pubsubpb.SeekRequest{ Subscription: subName, Target: &pubsubpb.SeekRequest_Snapshot{Snapshot: snapshotName}, }) ``` ```python # Seek to a timestamp. subscriber.seek(request={"subscription": sub_path, "time": cutoff}) # Seek to a snapshot. subscriber.seek(request={"subscription": sub_path, "snapshot": snapshot_path}) ``` ```java // Seek to a timestamp. subscriptionAdminClient.seek(SeekRequest.newBuilder() .setSubscription(subName.toString()).setTime(cutoff).build()); // Seek to a snapshot. subscriptionAdminClient.seek(SeekRequest.newBuilder() .setSubscription(subName.toString()).setSnapshot(snapshotName.toString()).build()); ``` ```javascript const sub = pubSubClient.subscription('orders-sub'); // Seek to a timestamp. await sub.seek(cutoff); // Seek to a snapshot. await sub.seek('orders-snapshot'); ``` ```csharp // Seek to a timestamp. await subscriber.SeekAsync(new SeekRequest { SubscriptionAsSubscriptionName = subName, Time = Timestamp.FromDateTime(cutoff), }); // Seek to a snapshot. await subscriber.SeekAsync(new SeekRequest { SubscriptionAsSubscriptionName = subName, SnapshotAsSnapshotName = snapshotName, }); ``` ```ruby sub = pubsub_client.subscription "orders-sub" # Seek to a timestamp. sub.seek cutoff # Seek to a snapshot. sub.seek snapshot ``` ## Timestamp clamping [#timestamp-clamping] **Seeking before the retained window clamps — it is not an error.** A `Seek` to a timestamp older than the earliest retained message does not fail; it clamps to the **earliest retained message** and replays from there. Because per-resource retention is itself clamped to the broker's `Store.MaxRetention`, what is "retained" depends on the broker ceiling. Don't rely on a pre-window seek returning an error to detect "too far back" — it silently starts at the oldest available message. See [Reliability](/connectors/gcp-pub-sub/how-to/reliability). ## Replay cap [#replay-cap] A single `Seek` replays at most `CONNECTORS_GCP_MAX_SEEK_REPLAY` messages (default **1,000,000**). **Hitting the replay cap stops at the cap and logs a WARN — there is no silent loss.** You simply do not replay beyond the limit in one seek. Raise `CONNECTORS_GCP_MAX_SEEK_REPLAY`, or seek in smaller windows, if you need to replay more. ## Snapshots [#snapshots] A **snapshot** captures a subscription's current cursor so you can seek back to it later without knowing an exact timestamp: * `CreateSnapshot(subscription)` records the cursor as a registry record. * `Seek(subscription, snapshot)` rewinds to that captured cursor. * Snapshots have a **7-day default expiry** and are swept hourly. `UpdateSnapshot` may change the `labels` and `expire_time`. **You cannot snapshot a detached subscription.** `CreateSnapshot` on a subscription whose topic has been deleted or detached returns `FAILED_PRECONDITION`. Snapshot **before** you detach. ## Related [#related] # Subscribing (/connectors/gcp-pub-sub/how-to/subscribing) This guide covers the consume surface: subscription lifecycle, the `Pull` vs `StreamingPull` paths, the ack-deadline lease model (ack / nack / extend), flow control, exactly-once delivery, and the periodic server-initiated reconnect. Every subscription is a native KubeMQ **Queue** channel `gcp.sub.{subscription}` (see [Channel mapping](/connectors/gcp-pub-sub/reference/channel-mapping)). ## Subscription lifecycle [#subscription-lifecycle] The `Subscriber` surface ships **16 RPCs** (see [Capabilities](/connectors/gcp-pub-sub/reference/capabilities)): * `CreateSubscription` — binds to a topic; the queue is created lazily. A `filter` is compiled at create-time and is **immutable** thereafter. Export subscriptions (BigQuery / Cloud Storage / Bigtable) and ingestion are **rejected** (`INVALID_ARGUMENT`). * `GetSubscription` / `ListSubscriptions`. * `UpdateSubscription` — a `FieldMask` over ack deadline, retention, dead-letter, retry, push, exactly-once, and labels. **`name` and `filter` are immutable.** * `DeleteSubscription` — drops the backlog and any leases. * `Pull`, `Acknowledge`, `ModifyAckDeadline`, `StreamingPull`, `ModifyPushConfig`, `Seek`, and the five snapshot RPCs. ## Pull vs StreamingPull [#pull-vs-streamingpull] Both paths read from the subscription's queue channel through a poller and place each delivered message under an **ack-deadline lease**. ### Unary Pull [#unary-pull] `Pull` returns up to `max_messages` (≤ 1000) currently-available messages, each with an `ack_id`. You ack with `Acknowledge(ack_ids)` or nack/extend with `ModifyAckDeadline`. A `Pull` on a **detached** subscription returns `FAILED_PRECONDITION`. ### StreamingPull [#streamingpull] `StreamingPull` is a bidirectional stream: the server pushes messages as they arrive and the client sends back `ack_ids`, `modify_deadline` requests, and flow-control settings on the same stream. This is what the high-level `subscriber.Receive(...)` / `subscription.on('message', ...)` helpers use. **Leases are subscription-owned, not stream-owned.** An ack on one `StreamingPull` stream correctly resolves a message that was delivered on a **different** stream (cross-stream ack). This matters for clients that reconnect or run multiple streams. ## The ack-deadline lease [#the-ack-deadline-lease] Every delivered message gets an opaque `ack_id` — a base64-JSON token carrying the subscription, channel, node id, broker transaction id, sequence, receive count, lease id, and deadline. The message stays leased (invisible to other consumers) until the deadline: | Action | Effect | | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `Acknowledge(ack_ids)` (or `StreamingPull` `ack_ids`) | Decodes each id and acks the broker sequence — the message is removed. | | `ModifyAckDeadline(0)` | **Immediate nack / redeliver** (bypasses retry backoff). | | `ModifyAckDeadline(>0)` | **Extends** the deadline. Valid range **10..600 s**. | | Deadline expiry | A 250 ms sweeper expires the lease, applies the retry backoff, and **redelivers** — or dead-letters once the receive count exceeds the policy. | The default ack deadline is `CONNECTORS_GCP_DEFAULT_ACK_DEADLINE_SECONDS` (default **10 s**, range 10..600). **Ack deadline is 0 (nack) or 10..600 s.** A value between 1 and 9 is not valid; `0` means nack (immediate redelivery). See [Limits & rules](/connectors/gcp-pub-sub/reference/limits-and-rules). ```go // Nack immediately to force redelivery, or extend the lease while you work. sub.ReceiveSettings.MaxOutstandingMessages = 100 err := sub.Receive(ctx, func(ctx context.Context, m *pubsub.Message) { if !canProcess(m) { m.Nack() // ModifyAckDeadline(0) → immediate redeliver return } m.Ack() // Acknowledge → removed from the queue }) ``` ## Flow control [#flow-control] On a `StreamingPull` stream the client sets `max_outstanding_messages` / `max_outstanding_bytes`; the connector keeps per-stream counters keyed by the `ack_id`s that stream emitted (`≤ 0` = use the connector's `CONNECTORS_GCP_MAX_OUTSTANDING_MESSAGES` ceiling, default **1000**). Outstanding count is **released on ack / nack / expiry, and fully released on stream disconnect** — without waiting for lease expiry. A separate hard ceiling, `CONNECTORS_GCP_MAX_INFLIGHT_PER_SUBSCRIPTION` (default **20,000**), caps the total leased (un-acked) messages per subscription across all streams. These knobs are in [Configuration](/connectors/gcp-pub-sub/concepts/configuration). ## Periodic reconnect [#periodic-reconnect] A `StreamingPull` stream is closed by the server after `CONNECTORS_GCP_STREAM_CLOSE_SECONDS` (default **1800 s** / 30 min) with `UNAVAILABLE`. This is **normal** — client libraries transparently reconnect and your receive callback keeps running. It bounds per-stream resource lifetime. Do not treat the periodic `UNAVAILABLE` as an error. See [Connectivity & emulator mode](/connectors/gcp-pub-sub/how-to/connectivity-and-emulator-mode). ## Ack-deadline reset on broker recovery [#ack-deadline-reset-on-broker-recovery] On a broker not-ready → ready transition the connector **drops all in-memory leases** (their downstream transactions are dead) and the poller rebuilds. Any in-flight messages are redelivered after recovery — design consumers to be **idempotent**. See [Reliability](/connectors/gcp-pub-sub/how-to/reliability). ## Exactly-once delivery [#exactly-once-delivery] A subscription with `enable_exactly_once_delivery` changes the ack contract: * **StreamingPull** returns `AcknowledgeConfirmation` / `ModifyAckDeadlineConfirmation` messages: expired/unknown ids appear in `invalid_ack_ids`; transient broker failures in `temporary_failed_ack_ids` (the client retries those). * A **unary** `Acknowledge` / `ModifyAckDeadline` returns `FAILED_PRECONDITION` with an `ErrorInfo(reason: PERMANENT_FAILURE_INVALID_ACK_ID)` for an unparseable/expired/unknown id. This matches the **real Google SDK contract** (the SDK resolves the ack result from the `ErrorInfo` reason), and differs from a naive `INVALID_ARGUMENT`. **Exactly-once is node-local.** An `ack_id` minted on one node is invalid on another (the token's node id won't match) — by design (no cross-node distributed exactly-once). In a cluster, pin an exactly-once subscription's `StreamingPull` traffic to one node with a **sticky load balancer**, or accept at-least-once across nodes. See [Reliability](/connectors/gcp-pub-sub/how-to/reliability) and [Error codes](/connectors/gcp-pub-sub/reference/error-codes). ## Error quick reference [#error-quick-reference] | Trigger | Result | | --------------------------------------------------------------------- | --------------------------------------------------------------------- | | `Pull` / `Seek` on a detached subscription | `FAILED_PRECONDITION` | | Export-subscription / ingestion config on `CreateSubscription` | `INVALID_ARGUMENT` | | `UpdateSubscription` of `name` or `filter` | rejected (immutable) | | Exactly-once unary ack of an expired/unknown id | `FAILED_PRECONDITION` + `ErrorInfo(PERMANENT_FAILURE_INVALID_ACK_ID)` | | Leased messages exceed `CONNECTORS_GCP_MAX_INFLIGHT_PER_SUBSCRIPTION` | new deliveries throttled | ## Related [#related] # Getting Started (/connectors/gcp-pub-sub/tutorials/getting-started) Get a message flowing through the KubeMQ Google Cloud Pub/Sub connector in minutes. You point a standard Pub/Sub SDK at the connector's gRPC endpoint, create a topic and a subscription, publish a message, and pull it back — all over the genuine Pub/Sub v1 wire protocol, with no emulator to install and no KubeMQ SDK. The only change versus a real-GCP app is one environment variable: `PUBSUB_EMULATOR_HOST`. ## Prerequisites [#prerequisites] * A running **kubemq-server** with the Pub/Sub connector **enabled** and reachable on **gRPC port 8085**. The connector is **opt-in (disabled by default)** — see the enable step below. * One of the first-party Google Cloud Pub/Sub clients below for your language. There is no KubeMQ SDK; you use the official Google client with only the emulator host set. * **No credentials.** When `PUBSUB_EMULATOR_HOST` is set, the SDK clears its Google credentials, skips Google auth, and dials insecure gRPC — exactly as against Google's local emulator. ## Enable the connector [#enable-the-connector] The GCP Pub/Sub connector is **disabled by default** — a stock kubemq-server does **not** bind gRPC port 8085 until you turn it on. Enable it with its enable variable: **The enable variable is `CONNECTORS_GCP_ENABLE`.** A stock server does not serve Pub/Sub until you set this to `true`. For Kubernetes, set `spec.gcp.enabled: true` in the `KubemqCluster` CR. ## Connect the SDK [#connect-the-sdk] Every official Pub/Sub client library and `gcloud` honour the standard `PUBSUB_EMULATOR_HOST` environment variable. Export it (and any project id) before running your app: ```bash export PUBSUB_EMULATOR_HOST=localhost:8085 # connector default gRPC port; SDK uses the insecure path export PUBSUB_PROJECT_ID=my-project # any id; the project segment is parsed but ignored # Some clients and gcloud also read this alias: # export GOOGLE_CLOUD_PROJECT=my-project ``` The **project id is parsed but ignored.** The connector is single-tenant (like the emulator), so resource ids are global across projects — topic `orders` is always the Events Store log `gcp.orders` regardless of the project segment. Any project id works. ## How it works [#how-it-works] `CreateTopic("orders")` maps the topic to the KubeMQ Events Store log `gcp.orders`; `CreateSubscription("sub-orders")` maps the subscription to the Queue channel `gcp.sub.sub-orders`. `Publish` writes once to the topic log through the message broker, then fans out one Queue copy per subscription; `Pull` returns the message plus an opaque `ack_id`, and `Acknowledge` removes it from the subscription queue. *The topic `orders` maps to the Events Store log `gcp.orders`; a publish fans out one copy to the subscription queue `gcp.sub.sub-orders`, and the pull returns it with a node-local `ack_id`.* ## Steps [#steps] ### Point the SDK at the connector [#point-the-sdk-at-the-connector] Build a standard Google Cloud Pub/Sub client. Most clients auto-detect the emulator from `PUBSUB_EMULATOR_HOST` (default `localhost:8085`); three need a one-line opt-in — **C#** sets `EmulatorDetection.EmulatorOnly`, **Ruby** passes `emulator_host:`, and **Java** points a plaintext `ManagedChannel` at the host with `NoCredentialsProvider`. ```go import ( "context" "os" "cloud.google.com/go/pubsub" ) // pubsub.NewClient auto-reads PUBSUB_EMULATOR_HOST and dials insecurely — no flag. client, err := pubsub.NewClient(ctx, os.Getenv("PUBSUB_PROJECT_ID")) ``` ```python from google.cloud import pubsub_v1 # Both clients honour PUBSUB_EMULATOR_HOST automatically. publisher = pubsub_v1.PublisherClient() subscriber = pubsub_v1.SubscriberClient() ``` ```java import com.google.api.gax.core.NoCredentialsProvider; import com.google.api.gax.grpc.GrpcTransportChannel; import com.google.api.gax.rpc.FixedTransportChannelProvider; import com.google.api.gax.rpc.TransportChannelProvider; import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; String emulatorHost = System.getenv().getOrDefault("PUBSUB_EMULATOR_HOST", "localhost:8085"); // Java needs the emulator host wired explicitly: a plaintext channel + no credentials. ManagedChannel channel = ManagedChannelBuilder.forTarget(emulatorHost).usePlaintext().build(); TransportChannelProvider channelProvider = FixedTransportChannelProvider.create(GrpcTransportChannel.create(channel)); NoCredentialsProvider noCreds = NoCredentialsProvider.create(); ``` ```typescript import { PubSub, v1 } from "@google-cloud/pubsub"; const projectId = process.env["PUBSUB_PROJECT_ID"] ?? "my-project"; // The high-level client reads PUBSUB_EMULATOR_HOST; reuse its options for the v1 clients. const baseOptions = new PubSub({ projectId }).options; const options = { ...baseOptions, port: baseOptions.port === undefined ? undefined : Number(baseOptions.port) }; const publisher = new v1.PublisherClient(options); const subscriber = new v1.SubscriberClient(options); ``` ```csharp using Google.Api.Gax; using Google.Cloud.PubSub.V1; // The .NET client does NOT auto-detect the emulator — set EmulatorOnly explicitly. var publisher = await new PublisherServiceApiClientBuilder { EmulatorDetection = EmulatorDetection.EmulatorOnly, }.BuildAsync(); var subscriber = await new SubscriberServiceApiClientBuilder { EmulatorDetection = EmulatorDetection.EmulatorOnly, }.BuildAsync(); ``` ```ruby require "google/cloud/pubsub" # Ruby needs emulator_host passed explicitly (it does not always read the env var). pubsub = Google::Cloud::PubSub.new( project_id: ENV["PUBSUB_PROJECT_ID"] || "my-project", emulator_host: ENV["PUBSUB_EMULATOR_HOST"] || "localhost:8085" ) ``` ### Create a topic and publish [#create-a-topic-and-publish] `CreateTopic("orders")` registers the topic and maps it to the Events Store log `gcp.orders`; `CreateSubscription("sub-orders")` maps to the Queue channel `gcp.sub.sub-orders`. `Publish` writes the message once to the topic log and returns a server-assigned message id. ```go topic, _ := client.CreateTopic(ctx, "orders") // -> gcp.orders sub, _ := client.CreateSubscription(ctx, "sub-orders", pubsub.SubscriptionConfig{ Topic: topic, AckDeadline: 10 * time.Second, // connector default; valid range 10..600s. }) // -> gcp.sub.sub-orders id, _ := topic.Publish(ctx, &pubsub.Message{Data: []byte("hello kubemq")}).Get(ctx) fmt.Println("published:", id) ``` ```python topic_path = publisher.topic_path(proj, "orders") # -> gcp.orders sub_path = subscriber.subscription_path(proj, "sub-orders") # -> gcp.sub.sub-orders publisher.create_topic(request={"name": topic_path}) subscriber.create_subscription(request={"name": sub_path, "topic": topic_path}) future = publisher.publish(topic_path, b"hello kubemq") print("published:", future.result(timeout=15)) ``` ```java TopicName topic = TopicName.of(projectId, "orders"); // -> gcp.orders SubscriptionName sub = SubscriptionName.of(projectId, "sub-orders"); // -> gcp.sub.sub-orders topicAdmin.createTopic(topic); subAdmin.createSubscription(sub, topic, PushConfig.getDefaultInstance(), 10); topicAdmin.publish(PublishRequest.newBuilder() .setTopic(topic.toString()) .addMessages(PubsubMessage.newBuilder() .setData(ByteString.copyFromUtf8("hello kubemq")).build()) .build()); ``` ```typescript const topic = publisher.projectTopicsPath(projectId, "orders"); // -> gcp.orders const sub = subscriber.subscriptionPath(projectId, "sub-orders"); // -> gcp.sub.sub-orders await publisher.createTopic({ name: topic }); await subscriber.createSubscription({ name: sub, topic, ackDeadlineSeconds: 10 }); const [published] = await publisher.publish({ topic, messages: [{ data: Buffer.from("hello kubemq") }], }); console.log("published:", published.messageIds?.[0]); ``` ```csharp var topicName = TopicName.FromProjectTopic(projectId, "orders"); // -> gcp.orders var subName = SubscriptionName.FromProjectSubscription(projectId, "sub-orders"); // -> gcp.sub.sub-orders await publisher.CreateTopicAsync(topicName); await subscriber.CreateSubscriptionAsync(subName, topicName, pushConfig: null, ackDeadlineSeconds: 10); var published = await publisher.PublishAsync(topicName, new[] { new PubsubMessage { Data = ByteString.CopyFromUtf8("hello kubemq") }, }); Console.WriteLine($"published: {published.MessageIds[0]}"); ``` ```ruby topic_path = pubsub.topic_path("orders") # -> gcp.orders sub_path = pubsub.subscription_path("sub-orders") # -> gcp.sub.sub-orders topic = pubsub.topic_admin.create_topic(name: topic_path) pubsub.subscription_admin.create_subscription(name: sub_path, topic: topic_path, ack_deadline_seconds: 10) msg = pubsub.publisher(topic.name).publish("hello kubemq") puts "published: #{msg.message_id}" ``` ### Pull and acknowledge [#pull-and-acknowledge] `Pull` returns the message plus an opaque `ack_id`, holding it under an ack-deadline lease. `Acknowledge(ack_id)` removes it from the subscription queue. A successful run prints the body you published. ```go recvCtx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() sub.Receive(recvCtx, func(_ context.Context, m *pubsub.Message) { fmt.Printf("received: %q\n", string(m.Data)) m.Ack() // Acknowledge by ack_id under its lease. cancel() }) ``` ```python resp = subscriber.pull(request={"subscription": sub_path, "max_messages": 1}, timeout=20) msg = resp.received_messages[0] print("received:", msg.message.data.decode()) subscriber.acknowledge(request={"subscription": sub_path, "ack_ids": [msg.ack_id]}) ``` ```java PullResponse resp = subStub.pullCallable().call(PullRequest.newBuilder() .setSubscription(sub.toString()).setMaxMessages(1).build()); ReceivedMessage got = resp.getReceivedMessages(0); System.out.printf("received: %s%n", got.getMessage().getData().toStringUtf8()); subStub.acknowledgeCallable().call(AcknowledgeRequest.newBuilder() .setSubscription(sub.toString()).addAckIds(got.getAckId()).build()); ``` ```typescript const [pull] = await subscriber.pull({ subscription: sub, maxMessages: 1 }); const received = pull.receivedMessages![0]; console.log("received:", Buffer.from(received.message!.data as Uint8Array).toString("utf8")); await subscriber.acknowledge({ subscription: sub, ackIds: [received.ackId!] }); ``` ```csharp var pull = await subscriber.PullAsync(subName, maxMessages: 1); var received = pull.ReceivedMessages[0]; Console.WriteLine($"received: {received.Message.Data.ToStringUtf8()}"); await subscriber.AcknowledgeAsync(subName, new[] { received.AckId }); ``` ```ruby subscriber = pubsub.subscriber(sub_path) rcv = subscriber.pull(immediate: false, max: 1).first puts "received: #{rcv.data.inspect}" rcv.acknowledge! ``` The `ack_id` is **node-local** under `StreamingPull` — a lease minted on one node is invalid on another. In a clustered deployment, pin a subscription's `StreamingPull` traffic to one node (session-affinity load balancer), or accept at-least-once delivery across nodes. See [Subscribing](/connectors/gcp-pub-sub/how-to/subscribing). ## Next steps [#next-steps] # Architecture (/connectors/kafka/concepts/architecture) ## Overview [#overview] The KubeMQ **Kafka connector** is an embedded, wire-protocol bridge inside kubemq-server that speaks the genuine Apache Kafka binary protocol — the same length-prefixed frames, the same request/response pairs, and the same flexible-version encoding real `kafka-clients`, `librdkafka`, and `franz-go` clients already speak. The connector is **opt-in (disabled by default)** — enable it with `CONNECTORS_KAFKA_ENABLE=true` (Docker) or `spec.kafka.enabled: true` (Kubernetes) — and it opens two wire-protocol listeners once turned on. Any unmodified Kafka client connects by repointing `bootstrap.servers`: no library swap, no code change, no KubeMQ SDK. Two ideas anchor the whole model: * Every Kafka **topic partition** maps onto a native KubeMQ **Events Store** log — a persistent, ordered, replayable primitive that already exists independent of Kafka. * A Kafka **offset** is not a separate number the connector invents and tracks — it tracks that log's own `Sequence` value directly (the two are one apart — `Sequence` starts at 1, Kafka offsets at 0). Because the connector reuses an existing KubeMQ primitive instead of building a parallel storage layer, a produced record is durable, replayable by any other KubeMQ transport, and behaves identically on every node of a cluster the moment it lands. ## The wire-protocol listeners [#the-wire-protocol-listeners] A Kafka client bootstraps against one of two TCP listeners: * **Port 9092** — plaintext. A plain TCP socket that accepts raw, length-prefixed Kafka frames. * **Port 9093** — TLS. The same frame format, wrapped in a TLS listener that reuses the server's global certificate configuration — there is no Kafka-specific certificate option. Both ports stay closed until the connector is enabled. A disabled connector never binds a socket, so a client dialing 9092 or 9093 against a stock, unconfigured KubeMQ server gets connection-refused, not a half-working listener. See [Configuration](/connectors/kafka/concepts/configuration) for the opt-in flag and the security posture the listeners enforce. Once a connection is open, every request runs the same short pipeline before it ever reaches a handler: the frame is decoded into a length-prefixed Kafka request, its API key and version are checked against the connector's advertised version table (an unsupported version is rejected before any work happens), the request size is checked against a bounded cap, and — for everything except the handful of pre-authentication calls — the caller's authorization is enforced. Only then does the request reach the single **dispatch** function that routes it by API key. That dispatch function is deliberately narrow: one function, one `switch` over the Kafka API key, with more than forty explicit cases. Grouping those cases by what they do gives you the connector's real functional surface: * **Data plane** — `Produce` (key 0) and `Fetch` (key 1) carry the actual records; `ListOffsets` (key 2) and `OffsetForLeaderEpoch` (key 23) answer positional questions about a partition without moving data. * **Group coordination** — `FindCoordinator` (10), `JoinGroup` (11), `SyncGroup` (14), `Heartbeat` (12), and `LeaveGroup` (13) run the classic consumer-group protocol; `OffsetCommit` (8) and `OffsetFetch` (9) persist and read back a group's position. See [Consumer Groups](/connectors/kafka/concepts/consumer-groups) for how these five calls fit together. * **Admin** — `CreateTopics` (19), `DeleteTopics` (20), `CreatePartitions` (37), `DescribeConfigs` (32), and `DescribeCluster` (60) manage topic and cluster metadata. * **Share groups (preview)** — `ShareGroupHeartbeat` (76), `ShareFetch` (78), and `ShareAcknowledge` (79) implement the KIP-932 queue-style consumption model, alongside admin/observability counterparts. See [Share Groups](/connectors/kafka/how-to/share-groups) for the preview scope. A cluster adds one more responsibility ahead of dispatch: a request that must run on the partition leader — a produce, or a group-coordinator call for a group whose coordinator lives on another node — is transparently forwarded there rather than rejected, so a client that happens to connect to a follower still gets a correct answer. ## Topics, partitions, and channels [#topics-partitions-and-channels] Once a request clears dispatch, it lands on a channel — the same channel a native KubeMQ Events Store client would use. The mapping is deliberately simple: * **Partition 0** of a topic maps to the Events Store log `kafka.{topic}`. * **Every partition after 0** gets its own log, named `kafka.{topic}~{partition}` — the topic name, a literal `~`, and the partition number. A topic starts with one partition (unless `CreateTopics` asks for more) and can only grow — partitions are added, never removed, up to a hard ceiling of 256. Growing a topic to `N` partitions simply means `N` independent, equally-ordered Events Store logs back it, each accepting its own stream of produces. *A Kafka request clears the wire-protocol listener and dispatch, then lands on the Events Store log for its partition — `kafka.{topic}` for partition 0, `kafka.{topic}~{partition}` for every partition after it — all backed by the message broker.* The offset a client sees for a record isn't a separate number the connector invents and tracks alongside the log — it tracks that log's own `Sequence` value directly (KubeMQ numbers `Sequence` starting at 1; Kafka numbers offsets starting at 0, one apart). That has three practical consequences: an offset is **durable** — it survives a broker restart, because the log itself is durable; it is **restart-stable** — the same record always reports the same offset, because nothing recomputes it; and on a cluster it is **identical on every node** — because every node applies the same replicated log in the same order. This is also why compaction and time/size retention — covered in [Durability & Retention](/connectors/kafka/concepts/durability-and-retention) — never renumber a surviving record's offset: the offset was never a separate, movable number to begin with. This page only needs the shape of the mapping. The exact offset arithmetic, and the internal channels a consumer group's offsets and membership live on, are catalogued in [Topic Mapping](/connectors/kafka/reference/topic-mapping). ## The next storage engine [#the-next-storage-engine] The Kafka connector runs **only** on KubeMQ's `next` storage engine. That is not a configuration step you perform yourself: enabling Kafka on a fresh store **auto-selects `next`**, with no manual `store.engine` setting required. See [Storage Engines → Zero-config engine selection](/configure/reference/storage-engines#zero-config-engine-selection) for the complete decision tree — what happens on a fresh store, an existing store, and an explicit pin. This page only needs the coupling, not the rules. The coupling exists because `next` is what makes the two guarantees above possible. Every Events Store log on `next` is backed by an owned segment log replicated with Dragonboat raft — the same mechanism that makes a Kafka offset identical across every node — and `next` is also the engine that implements compaction, which several Kafka behaviors ([Compacted Topics](/connectors/kafka/how-to/compacted-topics), the Kafka Connect and Kafka Streams ecosystems) depend on. ## Related [#related] # Configuration (/connectors/kafka/concepts/configuration) ## Overview [#overview] Configuring the Kafka connector is entirely a **server-side** decision. A Kafka client configures nothing KubeMQ-specific — it just points `bootstrap.servers` at the broker and, if the deployment requires it, supplies SASL credentials or a client certificate. Everything below — whether the listeners even open, which ports they bind, which authentication mechanisms are offered, and which storage engine backs the resulting topics — is decided once, on the server, and applies to every client that connects. ## Opt-in by design [#opt-in-by-design] The connector ships **disabled by default**. A stock KubeMQ server does not bind port 9092 or 9093, does not advertise itself as a Kafka broker, and imposes zero runtime cost on a deployment that never uses Kafka. You turn it on with one flag: Setting `CONNECTORS_KAFKA_ENABLE=false` again closes both listeners immediately — a config-only rollback with nothing to migrate, because the connector never owned a separate data store to begin with: every produced record already lives in a plain Events Store log (see [Architecture](/connectors/kafka/concepts/architecture)). This opt-in-by-default posture is also why a multi-node deployment needs one operator habit up front: producers must use `acks>=1`. A single Kafka-facing Service can land a produce on any pod, and a follower forwards an `acks>=1` produce to the leader transparently — but silently **drops** an `acks=0` produce instead of forwarding it. Single-node deployments are unaffected. ## Security posture [#security-posture] At a glance, the connector supports the same authentication and authorization shape a real Kafka deployment does, at the same layers: * **SASL** — `PLAIN` and both `SCRAM-SHA-256`/`SCRAM-SHA-512` mechanisms are available once any Kafka credential is configured; a client authenticates with a username and password checked against the connector's own dedicated credential store, separate from KubeMQ's general-purpose auth. * **OAUTHBEARER** — OIDC-federated bearer tokens, validated against a configured issuer and offered **only on the TLS listener** — a bearer token is never accepted over plaintext. * **mTLS** — a client certificate presented on the TLS listener yields a principal derived from the certificate's common name, and only from a verified certificate chain. * **ACLs** — every request that reaches dispatch is authorized against KubeMQ's own policy engine, mapped onto the access level Kafka would expect: a produce or an offset commit needs write access, while a fetch or a group heartbeat needs read access. None of this is mutually exclusive — a deployment can run SASL/SCRAM on the plaintext listener for internal traffic and OAUTHBEARER plus mTLS on the TLS listener for anything crossing a trust boundary. [Authentication](/connectors/kafka/how-to/authentication) and [TLS and mTLS](/connectors/kafka/how-to/tls-and-mtls) walk through configuring each mechanism; [Configuration reference](/connectors/kafka/reference/configuration) has the copy-paste TOML/environment/Docker examples. ## How configuration maps to behavior [#how-configuration-maps-to-behavior] Every Kafka setting can be supplied three ways — a `[Connectors.Kafka]` block in a TOML config file, a `CONNECTORS_KAFKA_*` environment variable, or (on Kubernetes) a typed field under `spec.kafka` on the KubeMQ cluster resource — and all three ultimately populate the same in-memory configuration the connector reads once at startup. That single source of truth is why the connector's runtime behavior is fully predictable from its configuration: the enable flag gates whether the listeners open at all, the port fields decide what a client dials, the credential and SASL-mechanism fields decide what the security posture above actually offers, and a handful of numeric fields — maximum connections, maximum message size, per-request fan-out caps — bound how much of the shared server the connector is allowed to consume. The full field-by-field table — every setting, its default, its valid range, and its exact environment-variable and CRD names — lives in one canonical place: [the Kafka settings reference](/configure/reference/connectors#kafka). This page is orientation, not the source of truth for any individual field. ## Enabling on Kubernetes [#enabling-on-kubernetes] The Kubernetes path is the same one-liner as the Docker flag above, expressed as a typed field instead of an environment variable: ```yaml # Helm values kafka: enabled: true ``` The equivalent CRD field is `spec.kafka.enabled: true` on the `KubemqCluster` resource. On a **fresh** cluster this one field is enough — no engine choice, no networking configuration — the connector opens an in-cluster, plaintext endpoint at `-kafka..svc:9092` that any in-cluster client can dial immediately. Reaching that endpoint from **outside** the cluster needs two more fields, `advertisedHost` and `advertisedPort`, plus a `LoadBalancer` or `NodePort` Service exposure — covered in [Configuration reference](/connectors/kafka/reference/configuration) and [the Kafka settings reference](/configure/reference/connectors#kafka), not restated here. ## The next engine relationship [#the-next-engine-relationship] One configuration consequence deserves its own callout: enabling Kafka couples the deployment to KubeMQ's `next` storage engine, because Kafka's headline behaviors — compacted topics and the quorum-fsynced acknowledgment contract — exist only there. You do not configure this coupling directly. On a fresh store, enabling Kafka **auto-selects `next`** automatically; on a store that already has data under the other engine, enabling Kafka fails closed with a clear configuration error instead of silently running in a reduced mode. The full decision tree — fresh store, existing store, explicit pin — lives at [Storage Engines → Zero-config engine selection](/configure/reference/storage-engines#zero-config-engine-selection). ## Related [#related] # Consumer Groups (/connectors/kafka/concepts/consumer-groups) ## Overview [#overview] The Kafka connector implements Kafka's **classic** consumer-group protocol — the same `FindCoordinator` → `JoinGroup` → `SyncGroup` → `Heartbeat` → `LeaveGroup` sequence every mainstream Kafka client library (`kafka-clients`, `librdkafka`, `franz-go`, `sarama`) already speaks by default. A group of consumers subscribing to the same topic get their partitions divided among them automatically, their progress recorded durably per group, and their membership rebalanced whenever the group's shape changes. None of this needs a KubeMQ-specific concept: `group.id` is the only setting a client sets, exactly as it would against real Kafka. Kafka also defines a newer, broker-side **KIP-848** next-generation group protocol (`ConsumerGroupHeartbeat`/`ConsumerGroupDescribe`). The connector does not advertise it — only classic groups run today. A client configured with `group.protocol=consumer` stalls on connect; set `group.protocol=classic` — still every mainstream client's default — to use the protocol this page describes. ## The classic group protocol [#the-classic-group-protocol] Five API calls carry the whole membership lifecycle: * **FindCoordinator** locates the broker that owns a given group's state — on a standalone deployment, trivially the node itself. * **JoinGroup** is how a consumer enters the group: it names the group, the subscribed topics, and the assignment strategies it supports. The coordinator collects every member's JoinGroup and designates one of them the **group leader**. A first-time dynamic join without a member ID is briefly turned back with `MEMBER_ID_REQUIRED` (error code 79) and asked to retry with the ID the broker just minted — a two-round handshake that stops a network-retry storm from registering duplicate phantom members. Static membership, below, is the one case that skips this round-trip entirely. * **SyncGroup** is where partitions actually get assigned. The elected leader — not the coordinator — computes the assignment locally, using whichever strategy the group agreed on (range, round-robin, sticky, and so on), and sends that full assignment back through its own SyncGroup call. The coordinator then hands each *other* member its slice of that same assignment when they call SyncGroup. Assignment is therefore **leader-authoritative**: the broker distributes an assignment it did not compute. * **Heartbeat** keeps a member's membership alive between rebalances; the coordinator times a member out — and removes it — if heartbeats stop arriving within the group's session timeout. * **LeaveGroup** is the explicit, immediate departure a client sends on a clean shutdown, so the group doesn't have to wait out a session timeout to notice the member is gone. Two more calls sit alongside this lifecycle rather than inside it: **OffsetCommit** and **OffsetFetch** persist and retrieve a group's position, covered on its own below because it survives across resets of the group protocol itself. ## Leader-authoritative assignment and generations [#leader-authoritative-assignment-and-generations] Every completed rebalance bumps the group's **generation** — a monotonically increasing counter that fences stale requests. A member that heartbeats or commits using a generation number the coordinator no longer recognizes is answered with `ILLEGAL_GENERATION` (error code 22) rather than silently accepted; this is what stops a member that missed a rebalance from acting on an assignment that no longer applies. Because assignment itself is leader-computed (above), the coordinator's own responsibility across a generation is comparatively narrow: collect membership, forward the leader's assignment to everyone else, and track which generation is current. ## Durable per-group offsets [#durable-per-group-offsets] A consumer group's committed position — for every topic-partition it consumes — is stored **durably**, independent of the group's membership lifecycle. Committing an offset, whether automatically on an interval or explicitly after processing a record, survives every consumer in the group restarting, the group's coordinator failing over to another node, and the group itself going empty and refilling later with a different set of members. See [Consuming](/connectors/kafka/how-to/consuming) for the manual-vs-automatic commit mechanics. ## Rebalancing [#rebalancing] A rebalance reruns JoinGroup/SyncGroup across the whole group, and is triggered by anything that changes what "correct assignment" means: * A member joins or leaves — explicitly, via `LeaveGroup`, or implicitly, via a session-timeout. * A subscribed topic's partition count changes. Growing a topic's partitions is an explicit, operator-triggered action on KubeMQ — partitions only ever increase, never shrink or auto-reshard — and doing so deliberately triggers every subscribed group to rebalance onto the new partition count, rather than leaving some partitions unassigned. A full rebalance briefly pauses processing for the whole group while JoinGroup/SyncGroup rerun — the cost static membership (below) exists to avoid paying on every routine restart. ## Static membership (KIP-345) [#static-membership-kip-345] A consumer that sets `group.instance.id` gets a **persistent identity** the coordinator remembers across disconnects, instead of a fresh, disposable member ID every time it joins: * **Identity, not just a request field.** The coordinator maps that instance ID to a specific member ID once and reuses the mapping on every subsequent join from the same instance — the client doesn't need to cache and resend a member ID itself. * **Static join skips the extra round-trip.** A dynamic (non-static) member without a member ID gets `MEMBER_ID_REQUIRED` back, as described above, and has to retry with the ID the broker just handed it. A static member's very first `JoinGroup` — carrying `group.instance.id` — resolves straight to its known member ID and is admitted immediately, with no forced retry. * **A clean rejoin skips the rebalance entirely.** If the group is already stable, its leader hasn't changed, and a static member rejoins with the same subscription it had before — the common case: the process crashed and restarted, or reconnected after a network blip — the coordinator answers at the **same generation** with no rebalance at all. Every other member in the group is undisturbed. (One exception: if the rejoining instance is the group's current leader, a full rebalance still runs — the fast path is taken only for non-leader members.) * **A displaced or mismatched instance is fenced.** If a second connection shows up claiming an instance ID that's already mapped to a *different* member ID than the one now presenting it — the classic double-start-during-a-restart shape — the coordinator refuses it with `FENCED_INSTANCE_ID` (error code 82) rather than quietly admitting a second writer under the same identity. Static membership is supported in full — every one of the four behaviors above holds across `JoinGroup`, `SyncGroup`, `Heartbeat`, `OffsetCommit`, and `LeaveGroup`. It's the mechanism that makes a rolling restart of a consumer fleet cheap: each pod restarts with the same `group.instance.id` it had before, and the group skips a rebalance for the restart itself — unless the restarting pod is the group's current leader, which still triggers one. Kafka also has a second, queue-style consumption model — **share groups** (KIP-932) — where records are individually acquired and acknowledged instead of partition-assigned. It's a genuinely different model from everything on this page, not a variant of classic groups; see [Share Groups](/connectors/kafka/how-to/share-groups) for the contrast. ## Related [#related] # Durability & Retention (/connectors/kafka/concepts/durability-and-retention) Kafka clients control durability with one producer setting — `acks` — and control how long a record lives with two topic settings — `retention.ms` and `retention.bytes` — plus an alternative to time-based deletion, log compaction. All three map onto KubeMQ's `next` storage engine in ways that are mostly Kafka-identical, with a couple of differences worth knowing before you deploy to a multi-node cluster. ## Overview [#overview] Every Kafka topic on KubeMQ backs onto an Events Store log on the `next` engine — an owned segment log replicated with Dragonboat raft. The Kafka connector only runs on `next` (never on the legacy engine), because `next` is what makes Kafka's headline durability and compaction contracts possible in the first place. A fresh store with the Kafka connector enabled auto-selects `next` automatically — see [Zero-config engine selection](/configure/reference/storage-engines#zero-config-engine-selection) for the full selection rules; this page does not repeat them. ## The `acks` contract [#the-acks-contract] A Kafka producer's `acks` setting is the wire-level durability request attached to every `Produce`. KubeMQ accepts the three Kafka-legal values — any other value is rejected with `INVALID_REQUIRED_ACKS` before the record is ever stored: | `acks` | Behavior | Response | | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | | `0` | Fire-and-forget. The record is still appended to the store, but the connector never inspects the outcome and returns no response frame — the producer gets no confirmation, successful or not | None | | `1` | Leader acknowledgement. If the request lands on a follower node, the follower transparently forwards it to the leader; the producer waits for the write to complete | Response frame with the assigned offset (or an error) | | `-1` / `all` | All in-sync replicas. On KubeMQ today this is answered identically to `acks=1` — the in-sync-replica set is exactly the leader itself, so there is no separate follower-ack tier to wait on beyond what `next`'s own quorum commit already provides | Response frame with the assigned offset (or an error) | ## Quorum-fsynced durability on the `next` engine [#quorum-fsynced-durability-on-the-next-engine] Once a produce request reaches the leader — either directly, or forwarded there because `acks` was 1 or all — the record is committed to a **raft quorum and fsynced** before the connector replies. This is the `next` engine's durability contract, and it holds regardless of which `acks` value the client asked for: `next` never acknowledges a write that has not already cleared quorum-replication and fsync. Put plainly: **an acked message survives a node loss.** That is a real, meaningful guarantee — but it is a comparison of defaults, not a marketing claim of "zero data loss" in the absolute sense. It says nothing about a message that was never durably written in the first place, which is precisely what can happen under `acks=0` (below). "Zero acked-loss" describes what happens to a message *after* it is acknowledged, not a promise that every produced byte is retained forever regardless of the client's own settings. *Every produced record is committed to a raft quorum and fsynced on the `next` engine before the connector responds — the same durability path runs regardless of the requested `acks` level; what changes is whether the client waits for, and receives, that confirmation.* **A multi-node install requires `acks >= 1`.** The default Helm/CRD install runs `replicas: 3` fronted by a single Service, so a produce request can land on any pod. For `acks >= 1`, a follower transparently forwards the request to the leader. For `acks=0` (fire-and-forget), a follower **silently drops** the record instead of forwarding it — the producer gets no error, and the record never reaches the store. Use `acks >= 1` on any multi-node deployment; single-node/standalone deployments are unaffected because there is no follower to drop it. ## Retention: time and size [#retention-time-and-size] A topic's retention is governed by two config keys, and only one of them currently does anything on KubeMQ: | Config | Default | Enforced? | Behavior | | ----------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `retention.ms` | `604800000` (7 days) — **display only**, the value `DescribeConfigs` echoes for a topic that never set it | **Yes, but only once a topic sets a finite, positive value** | `retention.ms=-1`, `0`, or **unset** all pin the topic — records are never age-evicted. Only a finite positive value activates the leader-gated sweep, which runs every 10 seconds and evicts records older than the window by logically advancing the log's start offset. This differs from real Apache Kafka, where `0` evicts almost immediately | | `retention.bytes` | — | **No** | Accepted and echoed back if a client sets it, but there is no size-based eviction sweep today — only a finite `retention.ms` actually removes records | If you are used to real Kafka enforcing both a time and a size bound, note the gap: on KubeMQ today, a topic that never sets `retention.ms` keeps growing (the store's log-start never advances), and setting `retention.bytes` alone does not cap anything. If you need a bound, set `retention.ms`. Retention eviction never renumbers offsets — an evicted record's slot simply becomes unreadable; surviving records keep the offsets they always had. This is distinct from **offset retention** — how long a consumer group's committed offsets are kept once a group has no active members — which is a separate, connector-wide setting covered in [Limits & Rules](/connectors/kafka/reference/limits-and-rules); this page is about record retention on the topic itself, not group-offset retention. ## Log compaction [#log-compaction] Instead of (or alongside) time-based deletion, a topic can be compacted: KubeMQ keeps only the **latest record per key**, discarding older values for the same key. Compaction is controlled by `cleanup.policy`, which accepts: | Value | Meaning | | ------------------ | -------------------------------------------------------------------------- | | `delete` (default) | Time/size-based retention only, as described above — no compaction | | `compact` | Latest-value-per-key retention only | | `compact,delete` | Both — compact by key, and also age out anything older than `retention.ms` | A **`compact`-only** topic is never age-evicted even if `retention.ms` is set to a finite value — only `compact,delete` combines the two. Setting `retention.ms` on a `compact`-only topic has no effect until `cleanup.policy` also includes `delete`. A record with a **null value** and a non-null key is a **tombstone** — a marker that erases the key. Tombstones are themselves reaped (physically removed) after `delete.retention.ms` (default 24 hours, `86400000`), giving downstream consumers a window to observe the delete before it disappears. Crucially, **compaction never renumbers surviving offsets** — when a compacted-away record's offset is fetched, the read simply returns the next surviving record at or after it, exactly like real Kafka's own compacted-topic behavior, which every conformant Kafka client already knows how to handle. Log compaction is a **`next`-engine-only** capability: it runs on Kafka topic channels specifically, and since the Kafka connector itself only runs on `next`, every Kafka topic on KubeMQ is eligible. Native Queues and Events Store channels used by KubeMQ's other patterns never compact — this is a Kafka-topic-specific feature, not a general storage behavior. Compaction is what unlocks the compaction-dependent ecosystem: **Kafka Connect** and **Kafka Streams** both rely on internally compacted topics (offset storage, changelog topics), so having real `cleanup.policy=compact` support means those tools work against KubeMQ too. Compaction runs live, at any point in a topic's life — turning it on is a runtime `cleanup.policy` change, not a data-migration step. If you are moving an existing Kafka workload onto KubeMQ, that move is a separate, start-fresh adoption story with its own assess/replicate/cutover playbook — see [Migrate from Kafka](/connectors/kafka/how-to/migrate-from-kafka) rather than treating compaction as a migration blocker. ## Related [#related] # Partitions & Ordering (/connectors/kafka/concepts/partitions-and-ordering) Every Kafka topic on KubeMQ is split into one or more **partitions** — independent, ordered logs that parallelize produce and consume. A topic's partition count starts at 1, only ever grows, and is hard-capped at 256. Partition **assignment** — which partition a keyed record lands in — is decided entirely by the client, never by KubeMQ, which makes mixing client libraries on the same keyed topic a real footgun worth understanding before you rely on per-key ordering. ## Overview [#overview] A topic with `N` partitions is `N` independent ordered logs, each backed by its own Events Store channel (`kafka.~` for partition ≥ 1; partition 0 lives at `kafka.`). Producers and consumers parallelize across those `N` logs, but KubeMQ's ordering guarantee — like real Kafka's — only ever applies **within** a single partition. There is no cross-partition ordering, by design: that is exactly the trade KubeMQ's Kafka connector makes to let many producers and many consumers work a topic concurrently. ## Partition count: 1 to 256, increase-only [#partition-count-1-to-256-increase-only] A newly created topic defaults to a single partition (`NumPartitions` omitted, or set below 1, is treated as 1). From there, the only way to change the partition count is `CreatePartitions` — and it is deliberately **increase-only**: | Request | Result | | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `CreatePartitions` with `Count` > current count, ≤ 256 | Accepted — the partition count durably grows, and every consumer group already subscribed to the topic is triggered into a rebalance so members pick up the new partitions | | `CreatePartitions` with `Count` == current count | Rejected — `INVALID_PARTITIONS` (error code 37), "topic already has N partition(s)" | | `CreatePartitions` with `Count` \< current count | Rejected — `INVALID_PARTITIONS` (37), "partition count must be >= current" — **partitions never shrink** | | `CreatePartitions` with `Count` > 256 | Rejected — `INVALID_PARTITIONS` (37), "Count exceeds the maximum of 256" | The 256 ceiling is a hard, non-configurable constant in the connector — there is no setting that raises it. A `ValidateOnly` dry-run is honored for all of the above: it reports the same accept/reject outcome without making a durable change. This increase-only design is deliberate, not a missing feature. KubeMQ never auto-grows a topic's partition count in the background — every increase is an explicit, operator-triggered `CreatePartitions` call, so a partition-count change is always a visible event rather than a silent background re-shard. See [Limits & Rules](/connectors/kafka/reference/limits-and-rules) for the full numeric ceiling table and [Topic Mapping](/connectors/kafka/reference/topic-mapping) for exactly how partitions map onto Events Store channels. ## Client-side key hashing [#client-side-key-hashing] **KubeMQ never hashes a record key to choose a partition.** Every partition assignment for a keyed produce is decided **client-side** and relayed to KubeMQ opaquely — there is no server-side hashing code in the connector, ever. If a client sets an explicit partition (or uses a manual partitioner), that partition is honored verbatim; if it leaves partitioning to the library's default, the library's own `hash(key) % N` decides. This matches real Apache Kafka's own architecture, but it means the **partition a key lands in depends on which client library produced it** — different libraries ship different default partitioners: | Client family | Default partitioner | Examples | | ------------------------------ | ------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | Java `kafka-clients`, franz-go | murmur2-based hash | Java `KafkaProducer`; franz-go's `UniformBytesPartitioner`, which falls back to a murmur2 hasher when no custom hasher is set | | librdkafka and its bindings | CRC32 | `kcat`, `confluent-kafka` (Python), and other librdkafka-based clients | Java and franz-go agree with each other out of the box (same hash family), but a librdkafka-based client hashes the **same key** to a **different partition** than a Java or franz-go client would, on the same topic, with the same `N`. **Mixing producer libraries on the same keyed topic can split a key across partitions.** If one service produces with `kcat` or another librdkafka-based client, and another produces with Java or franz-go, the same key can land in two different partitions — silently. Per-key ordering only holds **within one client library's own partitioning scheme**; it is not guaranteed across two different ones. Before you rely on "same key, same partition" across services, verify every producer uses the same partitioner strategy (or route through a single producer library for that topic), or design consumers to reconcile order at the application layer with a monotonic per-key sequence embedded in the record itself. *The same key, hashed by two different client libraries' default partitioners, can land on two different partitions of the same topic — KubeMQ relays the client's partition choice opaquely and never re-hashes it.* ## The per-partition ordering guarantee [#the-per-partition-ordering-guarantee] A single partition is one ordered writer: every record a producer sends to that partition is appended in the order it arrived, and a consumer reading that partition in isolation sees records in exactly that order. A single-partition topic is therefore **totally ordered** — every record, across every producer, in one sequence. The moment a topic has `N > 1` partitions, ordering is guaranteed **only within each partition**; there is no ordering guarantee across partitions, and a consumer that needs a total order across the whole topic must read a single partition (or reconcile order itself using a per-key sequence in the record). Partitions are also KubeMQ's unit of consume parallelism: a consumer group can have at most one active consumer per partition at a time (see [Consumer Groups](/connectors/kafka/concepts/consumer-groups)), so growing `N` is how you add parallel consume capacity — at the cost of the ordering trade above. ### Growing a topic re-shards keys [#growing-a-topic-re-shards-keys] Because partition assignment is `hash(key) % N`, changing `N` changes the modulus every keyed partitioner uses — client-side murmur2, CRC32, or any custom scheme. A key that mapped to partition 1 under `N=3` can map to a completely different partition once the topic grows to `N=6`. Concretely: if all of a key's records went to partition 1 while `N=3`, the very next record for that same key, produced after a successful increase to `N=6`, might land on partition 4 instead — a consumer reading only partition 1 will not see the two batches as one continuous ordered stream. The practical takeaway: **a topic's per-key ordering guarantee holds only within one fixed-partition-count epoch** — from one partition count to the next `CreatePartitions` increase. This is exactly why `CreatePartitions` is increase-only and never automatic: an operator-triggered increase is a visible, deliberate boundary between ordering epochs, not something that happens silently underneath a running producer. If a topic's consumers depend on strict cross-epoch per-key order, either avoid growing `N` on that topic, or design the consumer to reconcile order at the application layer. One related, self-healing edge case: right after a leader-side partition-count increase in a clustered deployment, a follower node that has not yet caught up on replication can briefly answer `Metadata` with a stale, **lower** partition count than the true value. This mirrors real Apache Kafka's own controller-to-broker propagation window — it never advertises a count larger than the true one, so a client is never misdirected to a partition that does not exist, and the client's next metadata refresh self-heals it. ## Related [#related] # Capabilities (/connectors/kafka/reference/capabilities) This reference lists every Kafka API key the KubeMQ Kafka connector advertises, the version range it accepts, and how complete that support is. **✅ Full** means "point a stock Kafka client at KubeMQ; it works." **🟡 Partial** states the exact scope — read it before relying on the capability. **🔵 Preview** means the wire protocol is implemented and advertised, but the multi-client conformance matrix hasn't run yet — it is not a GA guarantee. Every key's advertised `[min, max]` version window comes from one source table inside the connector that both the `ApiVersions`(18) response and the per-request bounds check read, so the two can never drift apart. A request whose version falls outside a key's window is answered [`UNSUPPORTED_VERSION`](/connectors/kafka/reference/error-codes), encoded at that key's max version, so the client renegotiates instead of decoding garbage. ## Produce, fetch, and metadata [#produce-fetch-and-metadata] | Key | API | Versions | Status | Notes | | --- | -------------------- | -------- | ------ | ------------------------------------------------------------------------------------------------------------ | | 0 | Produce | 3–9 | ✅ Full | RecordBatch v2 only; `acks` 0/1/all; strict per-partition ordering | | 1 | Fetch | 4–12 | ✅ Full | Long-poll (`fetch.max.wait.ms`); serves RecordBatch v2 | | 2 | ListOffsets | 1–7 | ✅ Full | `earliest`/`latest`/by-timestamp; earliest tracks the live retention floor | | 3 | Metadata | 0–13 | ✅ Full | v13 carries a deterministic `TopicID` (KIP-516); `Fetch` deliberately stays name-based (v12), not UUID-keyed | | 23 | OffsetForLeaderEpoch | 0–4 | ✅ Full | Real KIP-320 leader-epoch fencing for log-truncation detection on consumer resume | ## Consumer groups (classic protocol) [#consumer-groups-classic-protocol] | Key | API | Versions | Status | Notes | | --- | --------------- | -------- | ------ | ------------------------------------------------------- | | 10 | FindCoordinator | 0–3 | ✅ Full | scalar SELF shape only | | 11 | JoinGroup | 0–5 | ✅ Full | includes the static-membership `InstanceID` field (v5+) | | 14 | SyncGroup | 0–3 | ✅ Full | | | 12 | Heartbeat | 0–4 | ✅ Full | | | 13 | LeaveGroup | 0–4 | ✅ Full | single- and batch-member leave shapes | | 15 | DescribeGroups | 0–5 | ✅ Full | authz-gated; a denied group never leaks membership | | 16 | ListGroups | 0–4 | ✅ Full | | | 42 | DeleteGroups | 0–2 | ✅ Full | | | 8 | OffsetCommit | 0–8 | ✅ Full | durable, per-group, leader-linearized | | 9 | OffsetFetch | 2–7 | ✅ Full | | | 47 | OffsetDelete | 0–0 | ✅ Full | | **Static membership (KIP-345) is Full.** A consumer that sets `group.instance.id` joins as a static member: a static rejoin within the session timeout skips a rebalance entirely, and a *second* connection presenting the same `instance.id` — a displaced or duplicate instance — is fenced with [`FENCED_INSTANCE_ID`(82)](/connectors/kafka/reference/error-codes) across Join/Sync/Heartbeat/OffsetCommit/Leave. There is no separate API key for KIP-848's next-generation consumer-group protocol — see [Not supported](#not-supported). ## Handshake and authentication [#handshake-and-authentication] | Key | API | Versions | Status | Notes | | --- | ---------------- | -------- | ------ | ------------------------------------------------------------------------- | | 18 | ApiVersions | 0–3 | ✅ Full | A request above v3 is answered at v0 with `UNSUPPORTED_VERSION` (KIP-511) | | 17 | SASLHandshake | 0–1 | ✅ Full | negotiates PLAIN vs. the modern separate-authenticate flow | | 36 | SASLAuthenticate | 0–2 | ✅ Full | carries PLAIN, SCRAM-SHA-256/512, and OAUTHBEARER (TLS listener only) | ## Admin [#admin] | Key | API | Versions | Status | Notes | | --- | ----------------------- | -------- | ---------- | --------------------------------------------------------------------------------------------------- | | 19 | CreateTopics | 0–7 | ✅ Full | auto-create on Metadata/Produce also applies | | 20 | DeleteTopics | 0–6 | ✅ Full | | | 37 | CreatePartitions | 0–3 | 🟡 Partial | increase-only — same-count, decrease, or over the 256-partition cap all reject `INVALID_PARTITIONS` | | 21 | DeleteRecords | 0–2 | 🟡 Partial | low-end log truncation only (advances the log start; never renumbers offsets) | | 32 | DescribeConfigs | 0–4 | ✅ Full | leader-authoritative overlay read | | 44 | IncrementalAlterConfigs | 0–1 | 🟡 Partial | recognizes a subset of configs; several are accepted but no-op | | 60 | DescribeCluster | 0–2 | ✅ Full | self-as-broker-and-controller | | 75 | DescribeTopicPartitions | 0–0 | 🟡 Partial | optional API; tooling can safely fall back to `Metadata` | ## Transactions and exactly-once semantics [#transactions-and-exactly-once-semantics] | Key | API | Versions | Status | Notes | | --- | ------------------ | -------- | ---------- | -------------------------------------------------------------------- | | 22 | InitProducerId | 0–4 | ✅ Full | idempotent-producer PID allocation and KIP-360 epoch bump | | 24 | AddPartitionsToTxn | 0–3 | 🟡 Partial | V1 flat, non-batched wire shape only | | 25 | AddOffsetsToTxn | 0–3 | 🟡 Partial | | | 26 | EndTxn | 0–4 | 🟡 Partial | writes a real in-log COMMIT/ABORT marker; no per-`EndTxn` epoch bump | | 28 | TxnOffsetCommit | 0–3 | 🟡 Partial | single-group shape only | Transactions are exactly-once at the **V1** wire scope: `(PID, epoch)` fencing, `read_committed` isolation, and staged-offset materialization on commit all work like real Kafka's coordinator. **KIP-890 transaction protocol V2 is not shipped** — there is no per-`EndTxn` epoch bump — and `WriteTxnMarkers`(27), the broker-internal marker RPC, is never advertised to clients (see [Not supported](#not-supported)). ## Access control (ACLs) [#access-control-acls] **ACL enforcement is Full**, independent of the three management keys below — every Produce/Fetch/group dispatch is authorization-gated at the first hop. The management keys only reflect configuration; they don't drive enforcement. **Write is coarse-grained, though** — a topic's Write permission authorizes both produce **and** destructive admin operations on that topic; there is no separate admin-only permission tier. This is a documented limitation, not a roadmap gap. | Key | API | Versions | Status | Notes | | --- | ------------ | -------- | ---------- | ------------------------------------------------------------------------------ | | 29 | DescribeACLs | 0–3 | 🟡 Partial | returns an honest empty view (or `SECURITY_DISABLED`) — no fabricated bindings | | 30 | CreateACLs | 0–3 | 🟡 Partial | | | 31 | DeleteACLs | 0–3 | 🟡 Partial | | ## Quotas and SCRAM credential admin [#quotas-and-scram-credential-admin] | Key | API | Versions | Status | Notes | | --- | ---------------------------- | -------- | ---------- | -------------------------------------------------------------------------------------------------------------------- | | 48 | DescribeClientQuotas | 0–1 | 🟡 Partial | per-principal produce/fetch token-bucket baseline; full multi-tenant quotas are a later continuation | | 49 | AlterClientQuotas | 0–1 | 🟡 Partial | | | 50 | DescribeUserScramCredentials | 0–0 | 🟡 Partial | reflects configured users' mechanisms and iteration counts from server config (never salt or keys) | | 51 | AlterUserScramCredentials | 0–0 | 🟡 Partial | honestly rejects every mutation with `SECURITY_DISABLED` — SCRAM credentials are config-managed, not runtime-mutable | ## Share groups (KIP-932) — preview [#share-groups-kip-932--preview] **Preview, not GA.** Share groups are implemented and advertised — acquire, `Accept`/`Release`/`Reject` acknowledgements, multi-record batches, and follower-to-leader forwarding are all wired and validated against a real franz-go share consumer. The full multi-client share-group conformance matrix — the share-group analogue of the transactions exit gate — has not run yet. Never read this as "fully supported." See [Share Groups](/connectors/kafka/how-to/share-groups) for what to expect today. | Key | API | Versions | Status | Notes | | --- | ------------------------- | -------- | ---------- | -------------------------------------------------------------------- | | 76 | ShareGroupHeartbeat | 0–1 | 🔵 Preview | join/leave for a share-group member | | 78 | ShareFetch | 1–1 | 🔵 Preview | acquire; advertised atomically with 76/79 — a client needs all three | | 79 | ShareAcknowledge | 1–1 | 🔵 Preview | standalone acknowledge outside a ShareFetch round-trip | | 77 | ShareGroupDescribe | 0–1 | 🔵 Preview | read-only member roster and group state | | 90 | DescribeShareGroupOffsets | 0–1 | 🔵 Preview | read-only per-partition start offset and lag | | 91 | AlterShareGroupOffsets | 0–0 | 🔵 Preview | resets an **empty** group's start offset | | 92 | DeleteShareGroupOffsets | 0–0 | 🔵 Preview | resets an **empty** group's start offset to the log start | The data-plane trio (76/78/79) is advertised as a unit — a franz-go share consumer needs all three to make any progress. `kcat`, `confluent-kafka`, `kafkajs`, `Confluent.Kafka`, and `rdkafka` (Ruby and Rust) have no share-consumer API today; only **franz-go (Go)** and Java's **preview** `KafkaShareConsumer` can drive this surface. ## Not supported [#not-supported] * **KIP-848 next-generation consumer groups** (`ConsumerGroupHeartbeat`/`ConsumerGroupDescribe`, keys 68/69) — never advertised; classic groups (`group.protocol=classic`) are the only supported path. A client that requests `group.protocol=consumer` fails client-side before sending a single heartbeat — there is no server-side fallback. * **KIP-714 client telemetry** (`GetTelemetrySubscriptions`, key 71) — never advertised; clients simply run without broker-side telemetry collection. * **`WriteTxnMarkers`** (key 27) — broker-internal only; real Kafka clients never call it directly, and this connector never advertises it. * **Schema Registry server** — the hosted REST API and its `_schemas`-backed storage aren't embedded. The wire-level magic-byte prefix passes through untouched, so Schema-Registry-aware *serializers* work unchanged. The real, **external** Confluent Schema Registry service does run against KubeMQ, though — its `_schemas` topic is just a compacted topic on the `next` engine like any other; only the embedded/hosted SR REST API isn't built in. See the [Fitness Matrix](/connectors/kafka/reference/fitness-matrix) for the proof tier. * **ksqlDB** — a separate stream-processing runtime; out of scope as an embedded engine. * **MirrorMaker 2** — cross-cluster mirroring isn't offered as a hosted tool; migration is start-fresh (see [Migrate from Kafka](/connectors/kafka/how-to/migrate-from-kafka)). * **Kerberos / GSSAPI SASL** — no KDC integration; use SASL/SCRAM, SASL/PLAIN, OAUTHBEARER, or mTLS instead. * **Delegation tokens** — `CreateDelegationToken`/`RenewDelegationToken`/`ExpireDelegationToken`/ `DescribeDelegationToken` (keys 38–41) aren't implemented; any delegation-token request is answered [`DELEGATION_TOKEN_AUTH_DISABLED`(61)](/connectors/kafka/reference/error-codes) — switch those principals to SASL/SCRAM or mTLS instead. ## Related [#related] # Configuration reference (/connectors/kafka/reference/configuration) ## Overview [#overview] The canonical field-by-field reference is [connector settings](/configure/reference/connectors#kafka) — every `Connectors.Kafka.*` field, its default, its valid range, and its `CONNECTORS_KAFKA_*` / `spec.kafka.*` names. This page is an orientation pass: the handful of settings that decide whether the connector is on, which ports it opens, and how it's secured, plus copy-paste TOML/environment/Docker examples. For the narrative behind these settings — why the connector is opt-in and what its security posture looks like — see [Configuration concepts](/connectors/kafka/concepts/configuration). ## At a glance [#at-a-glance] | Setting | Default | What it controls | | -------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `Enable` — `CONNECTORS_KAFKA_ENABLE` / `spec.kafka.enabled` | `false` | **Opt-in.** The wire listeners stay closed until this is `true`. | | `Port` — `CONNECTORS_KAFKA_PORT` / `spec.kafka.port` | `9092` | The plaintext TCP listener. | | `TlsPort` — `CONNECTORS_KAFKA_TLS_PORT` / `spec.kafka.tlsPort` | `9093` | The TLS listener — the only listener OAUTHBEARER and mTLS are enforced on. | | `Credentials` / `SaslMechanisms` | none / `[]` | Populating the SASL credential store turns on SASL/PLAIN + SCRAM auth; `SaslMechanisms` narrows which mechanisms `SaslHandshake` offers. | | `OAuthBearer.Issuer` | `""` | Non-empty activates OAUTHBEARER — there's no separate enable flag. | | `AdvertisedHost` / `AdvertisedPort` | `""` / `0` | The client-reachable address handed to Kafka clients in `Metadata`/`FindCoordinator` — set this in Kubernetes to avoid a connect-then-hang. | This table is an orientation pass, not the full settings list — the connector has roughly two dozen fields, including advanced tuning knobs (fetch wait, offsets retention, transaction timeouts, quotas) that have no CRD/Helm path yet. See [connector settings → Kafka](/configure/reference/connectors#kafka) for every field, and [Authentication](/connectors/kafka/how-to/authentication) for the full SASL/OAUTHBEARER/mTLS story. ## Examples [#examples] The same settings can be supplied through a TOML config file, environment variables, or `docker run` flags. Every environment variable uses the `CONNECTORS_KAFKA_` prefix. ```toml title="config.toml" [Connectors.Kafka] Enable = true Port = "9092" TlsPort = "9093" AdvertisedHost = "" AdvertisedPort = 0 MaxConnections = 1000 MaxMessageBytes = 1048576 ``` ```bash title="kafka.env" CONNECTORS_KAFKA_ENABLE=true CONNECTORS_KAFKA_PORT=9092 CONNECTORS_KAFKA_TLS_PORT=9093 CONNECTORS_KAFKA_ADVERTISED_HOST= CONNECTORS_KAFKA_ADVERTISED_PORT=0 CONNECTORS_KAFKA_MAX_CONNECTIONS=1000 CONNECTORS_KAFKA_MAX_MESSAGE_BYTES=1048576 ``` The Docker example includes `CONNECTORS_KAFKA_ENABLE=true` — without it the connector stays disabled and neither `9092` nor `9093` is bound. Port `50000` is the native KubeMQ gRPC listener, included so the same container also accepts KubeMQ SDK clients. ## Related [#related] # Connections & Observability (/connectors/kafka/reference/connections-endpoint) ## Overview [#overview] This reference documents the Kafka connector's observability surface: the **read-only dashboard endpoints** under `/api/kafka/*`, the **Prometheus metric families**, and the **per-connection view** the dashboard renders for every live wire connection. ## Dashboard endpoints [#dashboard-endpoints] Every route below is `GET`-only and requires at least the read-only dashboard role when RBAC auth is enabled (auth is off by default on a single-node install). List routes always answer `200` with an empty list — and `"enabled": false` — before the connector is turned on or wired; detail routes answer `404` for an unknown ID. | Endpoint | Returns | | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `/api/kafka/overview` | the cluster-combined KPI band — connections, groups, consumers, topic/partition counts, lifetime produced/fetched messages and bytes, total consumer-group lag, and produce/fetch rates | | `/api/kafka/connections` | the node-local list of live wire connections | | `/api/kafka/connections/:id` | one connection's detail — see **Per-connection view** below | | `/api/kafka/topics` | the topic list, each with its partition list | | `/api/kafka/topics/:topic` | one topic's detail — per-partition high-watermark, log-start offset, and `cleanup.policy` | | `/api/kafka/groups` | the consumer-group list — protocol type, state, committed offsets, and lag | | `/api/kafka/groups/:id` | one group's detail | | `/api/kafka/consumers` | live group members, flattened across every group | | `/api/kafka/charts?time_range=&time_zone=` | the connector-wide produce/fetch throughput series the dashboard's chart polls | The overview's `partitions` and `topics` counts are always the **real** per-topic partition-list sums — the same data `/api/kafka/topics` returns — never a topic-count stand-in. Groups, consumers, and connection **counts** are answered from the cluster-combined snapshot (summed across live nodes). The `/api/kafka/connections` **list**, by contrast, is node-local — each wire connection lives on whichever node accepted it. ## Prometheus metrics [#prometheus-metrics] The connector registers seven `kubemq_kafka_*` metric families, scraped like every other connector's metrics: | Metric | Labels | Meaning | | --------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------- | | `kubemq_kafka_operations_total` | `operation` | total Kafka connector operations (produce/fetch), by operation | | `kubemq_kafka_operation_avg_latency_ms` | `operation` | average latency in milliseconds, by operation | | `kubemq_kafka_connections` | — | current number of open Kafka wire connections | | `kubemq_kafka_messages_total` | `op` (`produced`\|`fetched`) | total produced/fetched messages | | `kubemq_kafka_message_bytes_total` | `op` (`produced`\|`fetched`) | total produced/fetched wire bytes | | `kubemq_kafka_produce_rejects_total` | `error_code` | total produce rejects, by Kafka error code — see [Error Codes](/connectors/kafka/reference/error-codes) | | `kubemq_kafka_consumer_group_lag` | `group`, `topic`, `partition` | current lag (log-end-offset minus committed offset), per group/topic/partition | `kubemq_kafka_operations_total` and `kubemq_kafka_operation_avg_latency_ms` are pull-derived from a live snapshot at scrape time, so the exported counters already reflect any restart-restored cumulative totals — no separate re-seeding step. A pre-existing, unrelated counter, `kubemq_kafka_undecodable_records_total`, tracks malformed wire records and resets on restart; it is not part of this family set. ## Per-connection view [#per-connection-view] Each row in `/api/kafka/connections` and `/api/kafka/connections/:id` carries: | Field | Meaning | | ------------------- | ---------------------------------------------------------------------------------- | | `principal` | the authenticated identity — empty string if the connection is anonymous | | `security_protocol` | `PLAINTEXT` \| `SSL` \| `SASL_PLAINTEXT` \| `SASL_SSL` | | `sasl_mechanism` | empty, `PLAIN`, `SCRAM-SHA-256`, or `SCRAM-SHA-512` — empty when SASL isn't in use | | `authed` | whether the connection completed authentication | | `source_ip` | the client's source IP | This is the fastest way to confirm which security posture a given client actually negotiated — useful when validating a SASL or mTLS rollout against [Authentication](/connectors/kafka/how-to/authentication). ## Related [#related] # Error Codes (/connectors/kafka/reference/error-codes) The KubeMQ Kafka connector answers every failure with a **standard Kafka protocol error code** — the same numeric `ErrorCode` field (top-level, per-partition, or per-resource, depending on the API) a stock Kafka client already knows how to interpret. There is no custom error vocabulary: branch your client's retry/reconfigure logic on the numeric code exactly as you would against real Kafka. **Retriable vs. non-retriable.** Codes marked **retriable** reflect a transient condition — the broker isn't ready, a race was lost, a coordinator pointer is stale — that a client's normal backoff-and-retry resolves. **Non-retriable** means resending the identical request will never succeed; something has to change first. ## Version negotiation [#version-negotiation] | Code | Error | What triggers it | | ---- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 35 | `UNSUPPORTED_VERSION` | The request's version falls outside that key's advertised `[min, max]` window (see [Capabilities](/connectors/kafka/reference/capabilities)). Encoded at the key's max version so the client can parse it and renegotiate via `ApiVersions`. | ## Produce and fetch (data plane) [#produce-and-fetch-data-plane] | Code | Error | What triggers it | | ---- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1 | `OFFSET_OUT_OF_RANGE` | `Fetch`/`ListOffsets` requested an offset below the log start or above the high watermark — never used for a valid empty fetch at offset 0. Non-retriable at that offset. | | 2 | `CORRUPT_MESSAGE` | A `Produce` batch failed decode — CRC mismatch, an incomplete/oversized batch, a bad `Magic` byte, or a negative `FirstSequence` from an idempotent producer. Non-retriable. | | 3 | `UNKNOWN_TOPIC_OR_PARTITION` | The topic or partition doesn't exist and auto-create didn't apply. | | 5 | `LEADER_NOT_AVAILABLE` | The broker isn't accepting traffic yet — still starting, or a just-promoted leader catching up on Raft. Retriable. | | 6 | `NOT_LEADER_OR_FOLLOWER` | A clustered follower answers this on `Fetch` so the client's metadata refresh re-routes it to the leader. Retriable. | | 10 | `MESSAGE_TOO_LARGE` | A `Produce` partition's `Records` exceed `MaxMessageBytes` (default 1 MiB). Non-retriable — the producer must reconfigure. | | 17 | `INVALID_TOPIC_EXCEPTION` | The topic name fails KubeMQ's channel-name validation — kept distinct from an authorization deny so it never leaks "denied" to an unauthorized caller. | | 21 | `INVALID_REQUIRED_ACKS` | `Produce`'s `Acks` is set to something other than `0`, `1`, or `-1`. Unreachable by a conformant client. Non-retriable. | | 45 | `OUT_OF_ORDER_SEQUENCE_NUMBER` | An idempotent producer's per-`(PID, partition)` sequence check found a gap. | | 46 | `DUPLICATE_SEQUENCE_NUMBER` | The same `(PID, partition)` sequence was already accepted — the connector dedups the retry. | | 59 | `UNKNOWN_PRODUCER_ID` | The batch's producer ID has no live sequence-tracking record — expired, or never seen. | | 74 | `FENCED_LEADER_EPOCH` | The client's `CurrentLeaderEpoch` is older than the partition's live epoch. Retriable. | | 75 | `UNKNOWN_LEADER_EPOCH` | The client's `CurrentLeaderEpoch` is newer than what this node knows. Retriable. | | 100 | `UNKNOWN_TOPIC_ID` | A `Metadata` v10+ request named a `TopicID` (KIP-516) that doesn't resolve to any discoverable topic. | ## Consumer-group coordinator [#consumer-group-coordinator] | Code | Error | What triggers it | | ---- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | 14 | `COORDINATOR_LOAD_IN_PROGRESS` | The durable offset store hasn't finished replaying at boot. Retriable — a background sweep retries until it succeeds. | | 15 | `COORDINATOR_NOT_AVAILABLE` | The broker isn't accepting group-coordinator traffic yet. Retriable. | | 16 | `NOT_COORDINATOR` | This node isn't the caught-up Raft leader for the group; surfaces only when the leader-proxy hop fails. Retriable. | | 22 | `ILLEGAL_GENERATION` | A Join/Sync/Heartbeat names a generation the coordinator has already moved past. | | 23 | `INCONSISTENT_GROUP_PROTOCOL` | The group's members can't agree on a common protocol/assignor during a rebalance. | | 24 | `INVALID_GROUP_ID` | An empty `group.id`. | | 25 | `UNKNOWN_MEMBER_ID` | The presented `member.id` is unknown — never joined, already reaped, or an unrecognized static-instance mapping. | | 26 | `INVALID_SESSION_TIMEOUT` | `JoinGroupRequest.SessionTimeoutMillis` falls outside the coordinator's accepted bounds. | | 27 | `REBALANCE_IN_PROGRESS` | The group is (now) rebalancing — the member must rejoin. | | 68 | `NON_EMPTY_GROUP` | `DeleteGroups` targeted a non-Empty group, or `OffsetDelete` targeted an active non-consumer-protocol group. | | 69 | `GROUP_ID_NOT_FOUND` | The named group is unknown to the registry and the durable offset store, or its state is Dead. | | 79 | `MEMBER_ID_REQUIRED` | KIP-394's two-round `JoinGroup` handshake: an empty-`MemberID` round-1 request (v4+) gets a freshly minted ID and this code; the client resends with it. | | 81 | `GROUP_MAX_SIZE_REACHED` | The group, or the coordinator-wide group cap, is already at its configured ceiling. | | 82 | `FENCED_INSTANCE_ID` | KIP-345 static membership: `group.instance.id` maps to a **different** `member.id` than the requester — a displaced or duplicate static instance. | | 86 | `GROUP_SUBSCRIBED_TO_TOPIC` | `OffsetDelete` targeted a `(topic, partition)` a live member still subscribes to — deleting it would rewind that consumer. | | 88 | `UNSTABLE_OFFSET_COMMIT` | `OffsetFetch` set `RequireStable` (v7+) while the group holds a staged, uncommitted transactional offset. Retriable. | ## Topic and partition admin [#topic-and-partition-admin] | Code | Error | What triggers it | | ---- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | 36 | `TOPIC_ALREADY_EXISTS` | `CreateTopics` named a topic that already exists. | | 37 | `INVALID_PARTITIONS` | `CreatePartitions` requested a same-or-lower count, or one above the 256-partition cap; also `CreateTopics`'s `NumPartitions` over that cap. | | 38 | `INVALID_REPLICATION_FACTOR` | A request asked for `ReplicationFactor > 1` — replication is internal to Raft, never exposed as separate Kafka replicas. | | 40 | `INVALID_CONFIG` | An unsupported config value — an invalid `cleanup.policy`, `message.timestamp.type=LogAppendTime`, or an `APPEND`/`SUBTRACT` op on a scalar config. | | 41 | `NOT_CONTROLLER` | An admin write landed on a node that isn't the caught-up leader. Retriable — the client retries against the controller. | | 42 | `INVALID_REQUEST` | A malformed field — most commonly a present-but-empty `transactional.id`, or one failing channel-name validation. | ## Transactions [#transactions] | Code | Error | What triggers it | | ---- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | 47 | `INVALID_PRODUCER_EPOCH` | A `Produce` arrived with an epoch below the live epoch on record — a zombie holding a stale epoch. Non-retriable; the producer must re-run `InitProducerId`. | | 48 | `INVALID_TXN_STATE` | A transactional batch arrived in the wrong coordinator state; also reused by `AddPartitionsToTxn`/`AddOffsetsToTxn`/`EndTxn`/`TxnOffsetCommit` for their own state violations. | | 49 | `INVALID_PRODUCER_ID_MAPPING` | A KIP-360 request named a `(transactional.id, PID)` pair with no durable record — the ID is new, or was tombstoned since the client last saw it. | | 50 | `INVALID_TRANSACTION_TIMEOUT` | `TransactionTimeoutMillis` is `<= 0` or exceeds the coordinator-wide ceiling. | | 51 | `CONCURRENT_TRANSACTIONS` | Two state/epoch-transition attempts raced for the same `transactional.id`. Retriable. | | 53 | `TRANSACTIONAL_ID_AUTHORIZATION_FAILED` | The principal lacks Write on the `transactional.id` resource. | | 55 | `OPERATION_NOT_ATTEMPTED` | `AddPartitionsToTxn` named a partition that doesn't exist alongside ones that do — the existing ones answer this instead of a false success. | | 87 | `INVALID_RECORD` | A client submitted a control batch — those are broker-internal; the only real producer is the coordinator's own commit/abort marker. | | 90 | `PRODUCER_FENCED` | A KIP-360 `InitProducerId` epoch is strictly above the live durable epoch — the only branch that answers 90. | **KIP-890 transaction protocol V2 is not shipped** — no per-`EndTxn` epoch bump. `EndTxn` writes a real in-log COMMIT/ABORT marker, but soundness stays bounded to the V1 wire scope in [Capabilities](/connectors/kafka/reference/capabilities). ## Authorization [#authorization] | Code | Error | What triggers it | | ---- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | | 29 | `TOPIC_AUTHORIZATION_FAILED` | The principal lacks the required topic ACL — Produce needs Write; Fetch/ListOffsets/OffsetFetch need Read. | | 30 | `GROUP_AUTHORIZATION_FAILED` | The principal lacks the required group ACL — OffsetCommit/OffsetDelete/DeleteGroups need Write; Join/Sync/Heartbeat/Leave/DescribeGroups need Read. | | 31 | `CLUSTER_AUTHORIZATION_FAILED` | The principal lacks Describe on the cluster resource — e.g. `DescribeCluster`(60). | Every authorization deny above is **non-retriable** — re-authenticate or request a policy grant rather than retry unchanged. ## SASL [#sasl] | Code | Error | What triggers it | | ---- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 33 | `UNSUPPORTED_SASL_MECHANISM` | The client's `SASLHandshake` names a mechanism this listener doesn't offer — for example, `OAUTHBEARER` on the plaintext listener (TLS-only). The response still lists supported mechanisms; the connection stays open. | | 34 | `ILLEGAL_SASL_STATE` | `SASLAuthenticate` arrived before a successful `SASLHandshake` selected a mechanism. | | 58 | `SASL_AUTHENTICATION_FAILED` | Credentials didn't validate — unknown user or wrong password/token, deliberately indistinguishable to the client. The connection is closed. | ## Security and resource admin [#security-and-resource-admin] | Code | Error | What triggers it | | ---- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 54 | `SECURITY_DISABLED` | A SCRAM-credential or ACL **mutation** was refused — both are config-managed (`Connectors.Kafka.Credentials`), not runtime-mutable. Also answered when authorization is off entirely. | | 61 | `DELEGATION_TOKEN_AUTH_DISABLED` | Any delegation-token request — KubeMQ does not implement delegation tokens; switch those principals to SASL/SCRAM or mTLS. | | 91 | `RESOURCE_NOT_FOUND` | `DescribeUserScramCredentials` named a user with no SCRAM credentials configured. Non-retriable. | ## Share groups (preview) [#share-groups-preview] | Code | Error | What triggers it | | ---- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 123 | `INVALID_SHARE_SESSION_EPOCH` | An incremental `ShareFetch` arrived for a share session the broker no longer holds — a failover, or the session was reaped. The client resets and reopens at epoch 0. | ## Related [#related] # Fitness matrix (/connectors/kafka/reference/fitness-matrix) This page gives an honest, per-workload fitness verdict for a Kafka workload moving to KubeMQ — whether it's coming from **Apache Kafka**, **Amazon MSK**, or **Confluent**. For a verdict scoped to your own cluster, run the read-only assessor: ```bash kmq assess kafka --bootstrap your-broker:9092 [--tls --sasl-mechanism scram-sha-256 --sasl-username … --sasl-password …] ``` `kmq assess kafka` scans your cluster **read-only** — it never produces, commits, or creates topics — and maps the same verdicts below onto your actual topics, configs, and consumer groups. See [Migrate from Kafka](/connectors/kafka/how-to/migrate-from-kafka) for the full assess-then-migrate workflow. ## Overview [#overview] Every row below carries a proof tier — the honesty guarantee that no claim is stronger than its evidence: | Tier | Meaning | | ------ | ---------------------------------------------------------------------- | | **T1** | Proven with real clients and tools, on a cluster, in a shipped release | | **T2** | Proven on the current build; shipping in the pinned version | | **T3** | On the roadmap — designed, not yet proven | | **T4** | Not supported — with the real reason | These tiers apply across Apache Kafka, Amazon MSK, and Confluent alike unless a row notes otherwise. The T1/T2 verdicts below are proven on the **v3.0.0** release train. The sections below group verdicts by outcome — 🟢 works, 🟡 caveat, 🟠 roadmap, 🔴 unsupported — and each row keeps its tier so you can see how proven a green checkmark really is. ## 🟢 Works — drop-in fit [#-works--drop-in-fit] | Capability | Tier | Notes | | ---------------------------------------------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Produce / consume, classic consumer groups, idempotent producers | T1 | The large majority of everyday Kafka usage — a straight repoint, no client changes. | | Compacted topics (`cleanup.policy=compact`) | T1 | Runs on the `next` storage engine, KubeMQ's default engine for Kafka. | | Transactions / EOS (V1 wire surface) | T2 | Safe for at-least-once delivery plus basic exactly-once semantics — **not a KIP-890 soundness guarantee**. See the durability note below for the broader ack posture. | | SASL/SCRAM + mTLS + ACLs + quotas | T1 | ACL authorization is **coarse-grained** — the Write permission covers both produce **and** destructive admin operations, a documented limitation rather than a roadmap gap. Quotas are a per-principal byte-rate baseline. | | OAUTHBEARER / OIDC federated auth | T2 | **Supported.** The broker validates the client's OIDC bearer token and uses the token's `sub` claim as the authorization principal. Enforced only on the TLS/SASL\_SSL listener — refused on plaintext. | | Schema Registry — magic-byte serializer clients | T1 | Payloads are opaque to the broker; any Avro/Protobuf/JSON-Schema serializer works unchanged. | | Schema Registry — the real Confluent service (on `_schemas`) | T2 | Runs against KubeMQ; `_schemas` is a compacted topic on the `next` engine. **Leader-only caveat — see below.** | | Kafka Connect — distributed workers | T2 | Config/offset/status internal topics plus a source→sink pipeline, proven across a broker failover. **Leader-only caveat — see below.** | | Kafka Streams — stateful (aggregations) and stateless topologies | T2 | Compacted changelog restore across restart. **Leader-only caveat — see below.** | | Static membership (KIP-345, `group.instance.id`) | T1 | **Full support.** Static join bypasses `MEMBER_ID_REQUIRED`, a static rejoin skips rebalance, and a displaced/mismatched instance is fenced `FENCED_INSTANCE_ID(82)` across Join/Sync/Heartbeat/OffsetCommit/Leave. | **Leader-only caveat (being fixed).** The Schema Registry service, Kafka Connect, and Kafka Streams verdicts above are proven only when the tool connects to the current cluster leader. A topic-creation request sent to a follower node is not yet forwarded to the leader — a fix is in active development. Until it lands, point ecosystem tools at the leader node, or run them against a single node. Produce/consume routes correctly from any node and is unaffected. ## Durability — the honest headline [#durability--the-honest-headline] With the `next` storage engine, a produce ack means the record has been **fsynced to disk on a quorum of nodes**. Apache Kafka's default posture (`acks=all`, no per-write `flush.messages` / `flush.ms`) acknowledges after quorum replication to page cache — fsync timing is left to the OS — so a correlated power loss across replicas could lose acknowledged writes that KubeMQ's default posture would not. Kafka *can* be configured to fsync on every write, at a throughput cost — this is a comparison of **defaults**, not a marketing claim. The engine's ingest figure is approximately 183k messages/sec for a single replicated group. That's an engine-layer number, not a Kafka-wire throughput figure — benchmark the wire-level throughput and latency envelope on your own hardware. ## 🟡 Works with a caveat [#-works-with-a-caveat] | Capability | Tier | Caveat | | -------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `acks=0` (fire-and-forget) | T1 | On a cluster, an `acks=0` produce sent to a non-leader node is dropped by design — there's no response channel to signal a redirect. Clients that follow cluster metadata and route to the leader are unaffected. | | Share groups (KIP-932) — queue-style acquire/acknowledge consumption | T2 | **Supported in preview (not GA).** Implemented and advertised — acquire/`Accept`/`Release`/`Reject` acknowledgements, multi-record batches, and follower→leader `ShareFetch` forwarding are proven against a real franz-go share consumer — but the multi-client share-group conformance matrix (franz-go + Java's preview `KafkaShareConsumer`) has not been run yet. Never read as "fully supported." | ## 🟠 Roadmap, not proven [#-roadmap-not-proven] | Capability | Tier | Status | | ---------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Per-topic `retention.bytes` (size) enforcement | T3 | Size-based eviction is accepted but **not enforced** — on the roadmap; don't rely on it yet. Time-based `retention.ms` **is** enforced on the `next` engine: a leader-gated sweep runs every 10 seconds and evicts records older than the window for any finite positive value — `retention.ms=-1`, `0`, or unset all **pin** the topic (never age-evicted), which differs from Apache Kafka, where `retention.ms=0` evicts almost immediately. Proven on a 3-node SIGKILL crash-gate. See [Durability & Retention](/connectors/kafka/concepts/durability-and-retention#retention-time-and-size). | *(History and committed-offset migration via `kmq migrate` is now shipped — see [History and committed-offset migration](#history-and-committed-offset-migration) below.)* ## 🔴 Not supported [#-not-supported] | Capability | Tier | Why | | ------------------------------------------------------------ | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Topics with **> 256 partitions** | T4 | KubeMQ caps a topic at 256 partitions. | | `replication.factor > 1` | T4 | Rejected at `CreateTopics` (`INVALID_REPLICATION_FACTOR`) — durability comes from the cluster itself; migrate as RF=1. | | `max.message.bytes` above the broker limit | T4 | Records larger than the broker-wide limit are rejected (`MESSAGE_TOO_LARGE`) — default 1 MiB, operator-configurable up to 1 GiB via `Connectors.Kafka.MaxMessageBytes`. The per-topic `max.message.bytes` config is echo-only and never raises the enforced limit. | | KIP-848 next-gen consumer groups (`group.protocol=consumer`) | T4 | The protocol keys aren't advertised; consumers fail client-side. **Workaround:** set `group.protocol=classic` — still the default for librdkafka and Java 3.x clients. | | Kerberos / GSSAPI | T4 | No KDC integration — switch to SASL/SCRAM or mTLS. | | Delegation tokens | T4 | Answered `DELEGATION_TOKEN_AUTH_DISABLED(61)` — switch those principals to SASL/SCRAM or mTLS. | | Horizontal write scale (per-partition leadership) | T4 | Single replicated group — documented honestly, not a hidden limitation. | | Burrow / `__consumer_offsets`-tailing lag tools | T4 | Offsets live in an internal store, not a wire-visible compacted topic — use the OffsetFetch API and the connector's lag gauge instead. | ## History and committed-offset migration [#history-and-committed-offset-migration] Migrating **historical topic data and committed consumer-group offsets** off an existing Kafka cluster — so consumers resume where they left off, with no re-processing — is now **shipped** via the [`kmq migrate`](/connectors/kafka/how-to/migrate-from-kafka) bridge, in four phases: assess → replicate → translate → cutover. Replication is byte-identical, `CreateTime`-preserved, and partition-pinned, building an exact per-record source→target offset map; cutover seeds each group's translated offsets via an empty-group offset commit, and is **fail-closed** — a group committed past the durably (quorum-fsynced) replicated watermark is refused rather than seeded past un-replicated data. Proven on a 3-node `next` cluster: a SIGKILL mid-replication loses nothing, and a kill during cutover never double-seeds. An oversized source record (over the 1 MiB target cap) blocks and reports — it's never silently skipped. **Caveat to keep in view:** cluster-level consumer-resume is proven with **franz-go**; Java/kcat/librdkafka resume is proven **single-node**, not yet across a cluster. See [Migrate from Kafka](/connectors/kafka/how-to/migrate-from-kafka) for the full assess → replicate → translate → cutover workflow, per-source auth, and rollback. ## Coming from Confluent [#coming-from-confluent] * **OAuth / OIDC identity** → KubeMQ speaks **OAUTHBEARER** against your existing IdP, over TLS. * **Schema Registry** → run the real Confluent Schema Registry against KubeMQ (the `_schemas` topic is a compacted topic on the `next` engine) — no re-platforming of your serializers. The leader-only caveat above applies. * **Kafka Streams** → works today (stateful and stateless topologies; changelog restore across restart). The leader-only caveat above applies. * **ksqlDB** → not yet — a broker-side topic-creation fix for ksqlDB's control/query topics is in progress. * **Flink / Spark** structured-streaming source/sink → on the roadmap, validated once the retention/eviction work lands. ## See Also [#see-also] # Limits & Rules (/connectors/kafka/reference/limits-and-rules) Two kinds of limit apply to the connector: **hard limits** (fixed in code, not configurable — protecting the wire protocol and the channel-injectivity guarantees `reference/topic-mapping` depends on) and **operator-configurable caps** (the `CONNECTORS_KAFKA_*` settings that bound per-topic, per-request, and per-connection resource use). Both are listed here; the full field-by-field settings reference — including fields not covered by this page, like transaction timeouts and byte-rate quotas — lives at [Configuration reference](/configure/reference/connectors#kafka). ## Hard limits (not configurable) [#hard-limits-not-configurable] | Rule | Value | Enforced at | | ---------------------- | --------------------------------------------------- | ----------------------------------- | | Partitions per topic | **256**, hard cap, **increase-only** | `CreateTopics` / `CreatePartitions` | | Message timestamp type | **`CreateTime` only** — `LogAppendTime` is rejected | Server startup (config validation) | A violation of either rule is a config-time or admin-time rejection, never a silent clamp — see [Notes on the trickier ones](#notes-on-the-trickier-ones) below. ## Operator-configurable caps (`CONNECTORS_KAFKA_*`) [#operator-configurable-caps-connectors_kafka_] Server-side settings in `[Connectors.Kafka]`; env vars use the `CONNECTORS_KAFKA_*` prefix. Defaults and ceilings below are the connector's own floor/ceiling pair — an unset or non-positive value falls back to the default (there is no "unbounded" mode on any of these), and a value above the ceiling is rejected at server startup. | Field | Env var | Default | Ceiling | What happens over the limit | | ------------------------- | --------------------------------------------- | ----------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `MaxMessageBytes` | `CONNECTORS_KAFKA_MAX_MESSAGE_BYTES` | `1048576` (1 MiB) | `1073741824` (1 GiB) | An over-size `Produce` record is rejected `MESSAGE_TOO_LARGE` — no `Append` is attempted | | `MaxConnections` | `CONNECTORS_KAFKA_MAX_CONNECTIONS` | `1000` | none (`0` = unlimited) | A new TCP connection at or over the cap is refused at accept — Kafka has no `CONNECT` frame to carry a wire-level error, so the client simply cannot connect | | `MaxGroups` | `CONNECTORS_KAFKA_MAX_GROUPS` | `10000` | `10000000` | An unseen group name over the cap is refused the retriable `COORDINATOR_NOT_AVAILABLE` at `JoinGroup`/`OffsetCommit` | | `MaxTopicsPerRequest` | `CONNECTORS_KAFKA_MAX_TOPICS_PER_REQUEST` | `10000` | `1000000` | An over-cap request closes the connection outright — a bounded, O(1) reject, never a per-topic-shaped response, enforced before authorization on `Produce`/`Fetch`/`ListOffsets`/`OffsetForLeaderEpoch`/`Metadata`/the admin topic APIs | | `MaxPartitionsPerRequest` | `CONNECTORS_KAFKA_MAX_PARTITIONS_PER_REQUEST` | `100000` | `10000000` | Same as `MaxTopicsPerRequest` — the connection is closed, not answered | | `OffsetsRetentionMinutes` | `CONNECTORS_KAFKA_OFFSETS_RETENTION_MINUTES` | `10080` (7 days) | `52560000` (100 years) | A committed consumer-group offset older than this is expired by the group reaper on its next sweep — not an error, a retention rule | | `ScramIterations` | `CONNECTORS_KAFKA_SCRAM_ITERATIONS` | `4096` | `1000000` | Applies only at server boot (SCRAM verifier derivation) — an out-of-range value fails startup with a configuration error, not a runtime request | **Fan-out caps are authorization-independent.** `MaxTopicsPerRequest` and `MaxPartitionsPerRequest` are enforced at the dispatch layer *before* the authorization check and the handler — the cap holds even with authorization disabled, since a pre-auth flood of distinct topic or partition names in one frame is exactly the fan-out this guards against. ## Notes on the trickier ones [#notes-on-the-trickier-ones] * **256 partitions is a hard ceiling with no override.** `CreateTopics` with `NumPartitions > 256` is rejected `INVALID_PARTITIONS`. `CreatePartitions` follows the same rule for growth: `Count` must strictly exceed the topic's current partition count — a same-count or lower `Count` is also rejected `INVALID_PARTITIONS` (never shrink, never no-op), and a `Count` above 256 is rejected the same way. A partition increase, once accepted, is durable and cannot be reversed. See [Partitions & Ordering](/connectors/kafka/concepts/partitions-and-ordering) for the ordering consequences of growing partition count on an existing topic. * **`MaxMessageBytes`'s 1 GiB ceiling is a safety bound, not a recommendation.** Kafka frames are length-prefixed by a 32-bit integer; the ceiling keeps `MaxMessageBytes` comfortably below the point where the frame-length arithmetic could wrap. Most deployments should raise the 1 MiB default modestly (a few MiB) rather than approach the ceiling. * **Per-topic `max.message.bytes` is echo-only.** `DescribeConfigs` and `IncrementalAlterConfigs` accept and reflect a topic-level `max.message.bytes` override, but enforcement always uses the broker-wide `MaxMessageBytes` above (default 1 MiB, operator-raisable up to the 1 GiB ceiling) — a topic config can never raise the enforced limit. * **`MaxConnections` fails closed at the TCP layer, not the Kafka protocol layer.** Because Kafka has no in-band "too many connections" response, a client at the cap sees a plain connection refusal — indistinguishable, on the wire, from the port being closed. Size `MaxConnections` for your real client fleet (each consumer, producer, and admin tool holds at least one connection) before relying on it as a guardrail. * **`OffsetsRetentionMinutes` is a retention rule, not a request-time limit.** Unlike the other rows, exceeding it produces no error at all — a consumer group's committed offset simply ages out and is treated as absent (falls back to `auto.offset.reset`) the next time that group is described or resumes. * **`ScramIterations` only matters if you run SASL/SCRAM.** It is read once, at connector construction, to derive each configured user's SCRAM verifier — raising it strengthens brute-force resistance at a one-time boot cost, never a per-authentication cost. ## Related [#related] # Topic Mapping (/connectors/kafka/reference/topic-mapping) This is the master reference for how the embedded KubeMQ Kafka connector maps Kafka topics, partitions, and offsets onto KubeMQ channels. Every topic is backed by one or more KubeMQ **Events Store** logs — one per partition — plus a small family of internal, reserved channels that hold consumer-group offsets, per-topic config, and transaction/producer state. This deterministic mapping is what lets a native gRPC/REST Events Store subscriber read the exact same data a Kafka client produced, on the same cluster, with no translation step. ## Topic grammar [#topic-grammar] A Kafka topic maps to one KubeMQ Events Store log per partition. Partition 0 gets the bare, back-compat form; every additional partition appends a reserved separator and its index: ```text kafka.{topic} └──┬─┘└──┬──┘ │ └─ the bare topic id (the name a client passes to Produce/Fetch/CreateTopics) └─ fixed connector prefix ("kafka."), partition 0 only kafka.{topic}~{partition} └──┬─┘└──┬──┘└┬┘└───┬────┘ │ │ │ └─ the partition index (1, 2, 3, …) │ │ └─ the reserved "~" partition separator │ └─ the bare topic id └─ fixed connector prefix ("kafka."), partition ≥ 1 ``` | Kafka topic | Partition | KubeMQ channel | | ----------- | --------- | ----------------- | | `orders` | 0 | `kafka.orders` | | `orders` | 1 | `kafka.orders~1` | | `orders` | 5 | `kafka.orders~5` | | `audit-log` | 0 | `kafka.audit-log` | A topic starts at 1 partition (`CreateTopics`'s `NumPartitions`, or the implicit single-partition default on auto-create) and can only ever **grow** — see [Partitions & Ordering](/connectors/kafka/concepts/partitions-and-ordering) for the increase-only mechanism and the per-key ordering guarantees that come with it. Each additional partition synthesizes as its own independent channel, with its own ordered offset space, its own log-start, and its own retention/compaction state. **`~` is reserved — a topic name may not contain it.** The connector rejects any topic name containing the partition separator at admission (`INVALID_TOPIC_EXCEPTION`), so `kafka.{topic}` and `kafka.{topic}~{partition}` can never collide: no legal topic name can produce a channel that looks like another topic's partitioned form. Kafka's own charset (`[a-zA-Z0-9._-]`) never uses `~` anyway — this only matters for hand-built clients, since the connector is the sole admission gate. ## Offsets are Events Store sequence numbers [#offsets-are-events-store-sequence-numbers] The connector keeps no separate offset index. A partition's Kafka offsets and its channel's Events Store `Sequence` numbers are the **same counter**, one apart: KubeMQ numbers `Sequence` starting at 1, Kafka numbers offsets starting at 0, so for every record: ```text offset = Sequence − 1 (Sequence = offset + 1) ``` That single fact is what makes a robust drop-in possible without KubeMQ maintaining a shadow index. Because `Sequence` is durable, restart-stable, and identical across every node of a Raft cluster, a Kafka offset inherits all three properties for free: `Fetch` at a given offset always returns the same record, a restarted broker never renumbers history, and every replica agrees on where a partition's log starts and ends. See [Architecture](/connectors/kafka/concepts/architecture) for how the Produce/Fetch dispatch path resolves an offset into a `Sequence`-bounded read. ## Consumer-group, config, and coordinator channels [#consumer-group-config-and-coordinator-channels] Beyond the per-partition data logs, the connector maintains four internal channel families — one per coordinator store — that never carry topic data and are never directly reachable by a Kafka or native client. Together with the data channels above, this is the complete channel grammar the connector produces: | Channel type | Pattern | Example | Holds | | ----------------------------- | ------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------- | | Topic log, partition 0 | `kafka.{topic}` | `kafka.orders` | Produced records for partition 0 | | Topic log, partition ≥ 1 | `kafka.{topic}~{partition}` | `kafka.orders~1` | Produced records for that partition | | Consumer-group commit log | `_KAFKA_OFFSETS_.{group}.{gen}` | `_KAFKA_OFFSETS_.billing-worker.3` | Durable per-group committed offsets (`OffsetCommit`/`OffsetFetch`), snapshotted and rolled across generations | | Per-topic config store | `_KAFKA_CONFIG_.{topic}.{gen}` | `_KAFKA_CONFIG_.orders.2` | The topic's partition count and per-topic config overlay (`cleanup.policy`, `retention.ms`, …) | | PID-block allocator | `_KAFKA_PIDS_.{gen}` | `_KAFKA_PIDS_.5` | Producer-ID blocks for the idempotent and transactional producer (keyless — one shared family, not per-topic) | | Transaction-coordinator state | `_KAFKA_TXN_.{transactionalID}.{gen}` | `_KAFKA_TXN_.checkout-svc.1` | Per-`transactional.id` coordinator state — producer epoch, open partitions, commit/abort markers | `{gen}` is an internal generation number the snapshot-and-roll mechanism advances as each store's log accumulates writes — it is not something a client ever names or negotiates. **The `_KAFKA_` prefix is a reserved, protected namespace.** Every channel above the data-log rows lives under `_KAFKA_`, a namespace the broker rejects writes and subscriptions to from any external caller — Kafka client or native gRPC/REST — regardless of authorization policy. Only the connector's own internal code path may read or write these channels. This is why `OffsetFetch`, `DescribeGroups`, and the consumer-group lag metric exist as dedicated Kafka APIs rather than "just subscribe to the offsets channel": there is no wire-visible compacted topic to tail, by design. ## Cross-protocol interoperability [#cross-protocol-interoperability] Because a topic's partition-0 log is a normal KubeMQ Events Store channel, a Kafka `Produce` to topic `orders` is consumable by a native gRPC/REST Events Store subscriber on channel `kafka.orders` — and, for a multi-partition topic, on `kafka.orders~1`, `kafka.orders~2`, and so on for every additional partition. The reverse direction is symmetric: a native `Array.SendEventsStore` write to `kafka.orders` is a legal record any Kafka consumer can `Fetch`. The four coordinator channel families above are the one asymmetry — they exist so `OffsetCommit`, group membership, and transaction state stay internal and protocol-correct, not because the underlying store can't hold them like any other channel. **Deterministic read.** Subscribe to the Events Store log with start policy `startAt = "new"` **before** a Kafka producer's first `Produce`, so the produced record is guaranteed in-window for the native consumer — the same no-startup-race pattern every Events Store subscriber follows, Kafka-sourced or not. ## Related [#related] # Authentication (/connectors/kafka/how-to/authentication) This guide explains how a Kafka client proves its identity to the KubeMQ Kafka connector, and how the connector decides what that identity is allowed to do. The connector supports four ways to authenticate — SASL/PLAIN, SASL/SCRAM-SHA-256 or SCRAM-SHA-512, OAUTHBEARER against an OIDC provider, and mutual TLS — plus a Casbin-backed ACL that authorizes every Produce, Fetch, and group-coordinator request against the resolved identity. On a stock dev broker, `Connectors.Kafka.Credentials` is empty and no SASL mechanism is enforced, so the runnable examples across these docs connect with no credentials at all. Configure a credential store (below) to turn SASL on; TLS/mTLS is a separate, additive setting — see [TLS and mTLS](/connectors/kafka/how-to/tls-and-mtls). ## Authentication mechanisms at a glance [#authentication-mechanisms-at-a-glance] | Mechanism | Activated by | Credential | Principal | | ---------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------- | -------------------------------------- | | SASL/PLAIN | `Connectors.Kafka.Credentials` non-empty | username + password, checked against the credential store | the matched username | | SASL/SCRAM-SHA-256 / SCRAM-SHA-512 | `Credentials` non-empty, mechanism allowed | RFC 5802/7677 salted challenge-response | the matched username | | SASL/OAUTHBEARER | `OAuthBearer.Issuer` set + `OAUTHBEARER` in `SaslMechanisms` | an OIDC bearer token, validated on the TLS listener only | the token's `sub` claim | | mTLS | the global `Security` block in **mTLS** mode, and SASL not required | a verified client certificate | the certificate's `Subject.CommonName` | ## SASL/PLAIN and SCRAM [#saslplain-and-scram] SASL/PLAIN and SASL/SCRAM share the same credential store: `Connectors.Kafka.Credentials`, a list of `{Username, Password}` pairs. It's **config-file/secret-only** — there is no environment variable and no CRD field, so plan how you'll deliver it (a mounted `config.yaml` or a Secret-mounted file) before you turn SASL on. The moment `Credentials` is non-empty, the connector enforces SASL on every listener — plaintext (`SASL_PLAINTEXT`) and TLS (`SASL_SSL`) alike. `SaslMechanisms` is an operator allow-list. Leave it empty and the connector offers all three password-based mechanisms — `PLAIN`, `SCRAM-SHA-256`, `SCRAM-SHA-512`; set it explicitly to restrict the handshake (for example `["SCRAM-SHA-256", "SCRAM-SHA-512"]` to drop cleartext PLAIN). SCRAM's verifier is derived from each credential's password with **PBKDF2**, using `ScramIterations` (default `4096`, the RFC 7677 minimum) — raise it for a slower, more brute-force-resistant boot-time derivation. A client that offers a mechanism outside the allow-list, or authenticates with a wrong username/password, is closed with `UNSUPPORTED_SASL_MECHANISM(33)` or `SASL_AUTHENTICATION_FAILED(58)` respectively — neither is retried in place. The wire config barely differs between PLAIN and SCRAM — only the mechanism name, and the credential derivation on the server side, change: ```bash # SASL/PLAIN, plaintext transport kcat -b localhost:9092 -L \ -X security.protocol=SASL_PLAINTEXT \ -X sasl.mechanisms=PLAIN \ -X sasl.username=alice \ -X sasl.password="$KAFKA_PASSWORD" # SASL/SCRAM-SHA-256 — same flags, different mechanism # (add -X security.protocol=SASL_SSL -X ssl.ca.location=... to run it over TLS) kcat -b localhost:9092 -L \ -X security.protocol=SASL_PLAINTEXT \ -X sasl.mechanisms=SCRAM-SHA-256 \ -X sasl.username=alice \ -X sasl.password="$KAFKA_PASSWORD" ``` ```go import ( "os" "github.com/twmb/franz-go/pkg/kgo" "github.com/twmb/franz-go/pkg/sasl/plain" "github.com/twmb/franz-go/pkg/sasl/scram" ) // SASL/PLAIN cl, err := kgo.NewClient( kgo.SeedBrokers("localhost:9092"), kgo.SASL(plain.Auth{User: "alice", Pass: os.Getenv("KAFKA_PASSWORD")}.AsMechanism()), ) // SASL/SCRAM-SHA-256 — swap the mechanism constructor, everything else is identical cl, err = kgo.NewClient( kgo.SeedBrokers("localhost:9092"), kgo.SASL(scram.Auth{User: "alice", Pass: os.Getenv("KAFKA_PASSWORD")}.AsSha256Mechanism()), ) ``` ```java Properties props = new Properties(); props.put("bootstrap.servers", "localhost:9092"); props.put("security.protocol", "SASL_PLAINTEXT"); props.put("sasl.mechanism", "PLAIN"); // or "SCRAM-SHA-256" / "SCRAM-SHA-512" String module = "org.apache.kafka.common.security.plain.PlainLoginModule"; // for SCRAM, use: "org.apache.kafka.common.security.scram.ScramLoginModule" props.put("sasl.jaas.config", module + " required username=\"alice\" password=\"" + System.getenv("KAFKA_PASSWORD") + "\";"); ``` ## OAUTHBEARER — OIDC federated tokens [#oauthbearer--oidc-federated-tokens] OAUTHBEARER activates the moment `Connectors.Kafka.OAuthBearer.Issuer` is non-empty — there's no separate enable flag. It's enforced **only on the TLS listener** (`SASL_SSL`, `TlsPort`); a client that offers OAUTHBEARER on the plaintext listener is refused `UNSUPPORTED_SASL_MECHANISM(33)`, because a bearer token must never cross an unencrypted transport. Configure the TLS listener first — see [TLS and mTLS](/connectors/kafka/how-to/tls-and-mtls). The broker validates the token against your OIDC provider and takes the token's `sub` claim as the authenticated principal — the same Casbin gate as SASL/SCRAM. `ClientID` (the OAuth2 audience) is checked unless you set `SkipClientIDCheck`; the expiry, issuer, and signature checks are **hard-rejected** if you try to disable them — `SkipExpiryCheck`, `SkipIssuerCheck`, and `InsecureSkipSignatureCheck` must all stay `false`. `SkipClientIDCheck` is the one flag Kafka lets you set `true`, for IdPs that omit or vary the audience claim. Provider discovery runs in the background at boot, so the server never blocks startup on an unreachable IdP — until the provider is ready, OAUTHBEARER auth fails closed rather than silently accepting. ```go import ( "context" "crypto/tls" "github.com/twmb/franz-go/pkg/kgo" "github.com/twmb/franz-go/pkg/sasl/oauth" ) cl, err := kgo.NewClient( kgo.SeedBrokers("localhost:9093"), // OAUTHBEARER is TLS-only kgo.DialTLSConfig(&tls.Config{}), // uses the OS trust store — load your CA into RootCAs for a private/dev cert (see TLS and mTLS) kgo.SASL(oauth.Oauth(func(ctx context.Context) (oauth.Auth, error) { token, err := fetchOIDCToken(ctx) // your IdP's client-credentials call return oauth.Auth{Token: token}, err })), ) ``` ```properties security.protocol=SASL_SSL sasl.mechanism=OAUTHBEARER sasl.login.callback.handler.class=org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginCallbackHandler sasl.oauthbearer.token.endpoint.url=https://idp.example.com/oauth2/token sasl.jaas.config=org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule required \ clientId="kafka-client" \ clientSecret="${OIDC_CLIENT_SECRET}"; ``` ## mTLS — certificate identity [#mtls--certificate-identity] mTLS resolves identity from the connection itself — no SASL exchange, no password. When the global `Security` block is in **mTLS** mode (a client CA is configured; see [TLS and mTLS](/connectors/kafka/how-to/tls-and-mtls)), the connector requires and verifies a client certificate at the TLS handshake and takes the **verified** chain's leaf certificate `Subject.CommonName` as the principal. An unverified or CN-less certificate never becomes a principal — the connection is treated as unauthenticated rather than trusting a client-asserted name. **mTLS and SASL don't stack.** There's exactly one authenticated principal per connection. The certificate CN is used only when SASL is **not** required (`Credentials` empty); if a listener has both `Credentials` and mTLS configured, the SASL identity wins and the CN is never consulted. ## Authorization — the ACL model [#authorization--the-acl-model] Once a principal is resolved, the connector maps every Kafka operation onto the same **Casbin** authorization engine every KubeMQ connector shares — see [Security → Authorization](/configure/reference/security#authorization). It never ingests real Kafka ACLs; access is authored directly as Casbin policy against `(ClientID, resource, channel)`, where `ClientID` is the principal you just authenticated. | Operation(s) | Required access | Denied with | | --------------------------------------------------------------------------------------------------------- | ---------------------- | ----------------------------------------------------------- | | `Produce`, `OffsetCommit`(8), `OffsetDelete`(47), `DeleteGroups`(42) | **Write** | `TOPIC_AUTHORIZATION_FAILED` / `GROUP_AUTHORIZATION_FAILED` | | `Fetch`, `ListOffsets`, `OffsetFetch`, `JoinGroup`/`SyncGroup`/`Heartbeat`/`LeaveGroup`, `DescribeGroups` | **Read** | same | | `AddOffsetsToTxn`(25) / `TxnOffsetCommit`(28) — the transactional offset-commit route | **Write** on the group | `GROUP_AUTHORIZATION_FAILED` | **Migrating an EOS producer from real Kafka?** `AddOffsetsToTxn`/`TxnOffsetCommit` require **Group WRITE** here — Apache Kafka itself only requires Group **Read** for that route. Grant your transactional principal Group Write, not the Kafka-default Group Read, or its first offset commit inside a transaction fails with `GROUP_AUTHORIZATION_FAILED`. See [Transactions & EOS](/connectors/kafka/how-to/transactions). When server-wide `Authorization` is disabled, every request is allowed regardless of principal. When it's enabled, a request with **no** authenticated principal (no SASL, no mTLS) is denied outright — there's no anonymous fallback once ACL enforcement is on. ## Quick reference [#quick-reference] | You want… | Do this | | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | Clone-and-run on a stock dev broker | No credentials — leave `Credentials` empty | | Username/password authentication | SASL/PLAIN or SCRAM-SHA-256/512 — configure `Credentials` | | Federate identity to your own IdP | OAUTHBEARER — set `OAuthBearer.Issuer`, enable the TLS listener | | Certificate identity, no password | mTLS — put the `Security` block in mTLS mode, leave `Credentials` empty | | Diagnose a rejected connection | `UNSUPPORTED_SASL_MECHANISM(33)` (bad mechanism / OAUTHBEARER on plaintext) or `SASL_AUTHENTICATION_FAILED(58)` (bad credential) | ## Related [#related] # Compacted Topics (/connectors/kafka/how-to/compacted-topics) Log compaction keeps only the **latest record per key** instead of aging a topic out by time. This page walks through turning it on — at topic creation or on an existing topic — producing keyed records and a tombstone, and what the background compactor does with them. For the durability and retention model compaction sits alongside, see [Durability & Retention](/connectors/kafka/concepts/durability-and-retention); this page is the practical companion. **Runtime support, not a migration path.** Turning on `cleanup.policy=compact` is a live operation on a topic that already lives on KubeMQ — nothing here moves data. Bringing an **existing** compacted topic's history over from a real Kafka cluster is a separate, start-fresh adoption story with its own assess/replicate/cutover playbook — see [Migrate from Kafka](/connectors/kafka/how-to/migrate-from-kafka) rather than treating compaction as something you "migrate." ## Create a compacted topic [#create-a-compacted-topic] Set `cleanup.policy=compact` in the topic's config at `CreateTopics` time — the natural path for a new topic, and the one every Kafka Connect internal topic and Kafka Streams changelog topic already uses. `kcat` has no topic-admin API (it only produces and consumes), so the example below uses each client library's admin surface instead; produce and consume with whichever client you like once the topic exists. ```go package main import ( "context" "log" "github.com/twmb/franz-go/pkg/kadm" "github.com/twmb/franz-go/pkg/kgo" ) func main() { ctx := context.Background() cl, err := kgo.NewClient(kgo.SeedBrokers("localhost:9092")) if err != nil { log.Fatalf("client: %v", err) } defer cl.Close() admin := kadm.NewClient(cl) resp, err := admin.CreateTopic(ctx, 1, 1, map[string]*string{ "cleanup.policy": kadm.StringPtr("compact"), }, "user-profiles") if err != nil || resp.Err != nil { log.Fatalf("create topic: %v / %v", err, resp.Err) } log.Println("created user-profiles with cleanup.policy=compact") } ``` ```python from confluent_kafka.admin import AdminClient, NewTopic admin = AdminClient({"bootstrap.servers": "localhost:9092"}) topic = NewTopic("user-profiles", num_partitions=1, replication_factor=1, config={"cleanup.policy": "compact"}) futures = admin.create_topics([topic]) for name, future in futures.items(): future.result() # raises on failure print(f"created {name} with cleanup.policy=compact") ``` ```java import org.apache.kafka.clients.admin.Admin; import org.apache.kafka.clients.admin.NewTopic; import java.util.List; import java.util.Map; import java.util.Properties; public final class CreateCompactedTopic { public static void main(String[] args) throws Exception { Properties props = new Properties(); props.put("bootstrap.servers", "localhost:9092"); try (Admin admin = Admin.create(props)) { NewTopic topic = new NewTopic("user-profiles", 1, (short) 1) .configs(Map.of("cleanup.policy", "compact")); admin.createTopics(List.of(topic)).all().get(); System.out.println("created user-profiles with cleanup.policy=compact"); } } } ``` ```javascript const { Kafka } = require("kafkajs"); const kafka = new Kafka({ brokers: ["localhost:9092"] }); const admin = kafka.admin(); async function main() { await admin.connect(); await admin.createTopics({ topics: [ { topic: "user-profiles", numPartitions: 1, replicationFactor: 1, configEntries: [{ name: "cleanup.policy", value: "compact" }], }, ], }); console.log("created user-profiles with cleanup.policy=compact"); await admin.disconnect(); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using Confluent.Kafka; using Confluent.Kafka.Admin; var config = new AdminClientConfig { BootstrapServers = "localhost:9092" }; using var admin = new AdminClientBuilder(config).Build(); await admin.CreateTopicsAsync(new[] { new TopicSpecification { Name = "user-profiles", NumPartitions = 1, ReplicationFactor = 1, Configs = new Dictionary { ["cleanup.policy"] = "compact" }, }, }); Console.WriteLine("created user-profiles with cleanup.policy=compact"); ``` ```ruby require "rdkafka" admin = Rdkafka::Config.new("bootstrap.servers" => "localhost:9092").admin admin.create_topic("user-profiles", 1, 1, { "cleanup.policy" => "compact" }) .wait(max_wait_timeout_ms: 10_000) puts "created user-profiles with cleanup.policy=compact" admin.close ``` ```rust use rdkafka::admin::{AdminClient, AdminOptions, NewTopic, TopicReplication}; use rdkafka::client::DefaultClientContext; use rdkafka::config::ClientConfig; #[tokio::main] async fn main() { let admin: AdminClient = ClientConfig::new() .set("bootstrap.servers", "localhost:9092") .create() .expect("admin client creation failed"); let topic = NewTopic::new("user-profiles", 1, TopicReplication::Fixed(1)) .set("cleanup.policy", "compact"); admin .create_topics(&[topic], &AdminOptions::new()) .await .expect("create_topics failed"); println!("created user-profiles with cleanup.policy=compact"); } ``` **`compact` requires the `next` engine.** The Kafka connector itself only runs on `next` (see [Architecture](/connectors/kafka/concepts/architecture)), so in normal operation you'll never hit this path — but the admission gate is real: a `cleanup.policy` request the connector doesn't recognize, or a `compact` request on a store that somehow isn't on `next`, is rejected `INVALID_CONFIG`(40) at `CreateTopics` — no partial topic is created. Valid values are `delete` (default), `compact`, and `compact,delete`. ## Enable compaction on an existing topic [#enable-compaction-on-an-existing-topic] To flip an existing `delete`-policy topic over to `compact` — for example, a topic you're repurposing as a Kafka Streams changelog — use `IncrementalAlterConfigs`(44), the same admin API [Capabilities](/connectors/kafka/reference/capabilities) lists as partial support (a subset of configs is recognized; `cleanup.policy` is one of them): ```go package main import ( "context" "log" "github.com/twmb/franz-go/pkg/kadm" "github.com/twmb/franz-go/pkg/kgo" ) func main() { ctx := context.Background() cl, err := kgo.NewClient(kgo.SeedBrokers("localhost:9092")) if err != nil { log.Fatalf("client: %v", err) } defer cl.Close() admin := kadm.NewClient(cl) if _, err := admin.AlterTopicConfigs(ctx, []kadm.AlterConfig{ {Op: kadm.SetConfig, Name: "cleanup.policy", Value: kadm.StringPtr("compact")}, }, "user-profiles"); err != nil { log.Fatalf("alter configs: %v", err) } log.Println("user-profiles is now cleanup.policy=compact") } ``` Every pinned client library maps to the same wire call, with two gaps worth knowing about: | Client | Incremental alter-configs call | | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Go (franz-go/kadm) | `admin.AlterTopicConfigs(ctx, configs, topic)` | | Python (confluent-kafka) | `admin.incremental_alter_configs([ConfigResource(...)])` | | Java (kafka-clients) | `admin.incrementalAlterConfigs(Map>)` | | JavaScript (kafkajs) | **No client method.** kafkajs's `admin.alterConfigs()` issues the older, whole-state `AlterConfigs`(33) request, which this connector doesn't advertise — use one of the other clients to alter an existing topic's `cleanup.policy` from Node.js. | | C# (Confluent.Kafka) | `adminClient.IncrementalAlterConfigsAsync(configs)` | | Ruby (rdkafka) | `admin.incremental_alter_configs(resources_with_configs)` | | Rust (rdkafka) | **No incremental variant.** `admin_client.alter_configs(...)` only issues the whole-state `AlterConfigs`(33) request — same gap as kafkajs. | ## Produce keyed records and a tombstone [#produce-keyed-records-and-a-tombstone] Compaction only makes sense on keyed records: the compactor groups by key and keeps the newest value. A record with a **null value** and a non-null key is a **tombstone** — a delete marker for that key. The example below produces two versions of `user-42` (so the older value is eligible for compaction) and a tombstone for `user-7` (marking it for removal): `-Z` tells `kcat` to treat an empty value (after the `-K` key separator) as `NULL` rather than an empty string — that's what makes the second line below a real tombstone, not a zero-length value: ```bash # key "user-42", two values -- the newer one supersedes the older under compaction printf 'user-42:v1\nuser-42:v2\n' | kcat -P -b localhost:9092 -t user-profiles -Z -K: # tombstone: key "user-7", NULL value echo "user-7:" | kcat -P -b localhost:9092 -t user-profiles -Z -K: ``` ```go package main import ( "context" "log" "github.com/twmb/franz-go/pkg/kgo" ) func main() { ctx := context.Background() cl, err := kgo.NewClient(kgo.SeedBrokers("localhost:9092")) if err != nil { log.Fatalf("client: %v", err) } defer cl.Close() records := []*kgo.Record{ {Topic: "user-profiles", Key: []byte("user-42"), Value: []byte("v1")}, {Topic: "user-profiles", Key: []byte("user-42"), Value: []byte("v2")}, // same key, newer value {Topic: "user-profiles", Key: []byte("user-7"), Value: nil}, // tombstone } if err := cl.ProduceSync(ctx, records...).FirstErr(); err != nil { log.Fatalf("produce: %v", err) } log.Println("produced 2 versions of user-42 and a tombstone for user-7") } ``` ```python from confluent_kafka import Producer producer = Producer({"bootstrap.servers": "localhost:9092"}) producer.produce("user-profiles", key="user-42", value="v1") producer.produce("user-profiles", key="user-42", value="v2") # same key, newer value producer.produce("user-profiles", key="user-7", value=None) # tombstone producer.flush(10) print("produced 2 versions of user-42 and a tombstone for user-7") ``` ```java import org.apache.kafka.clients.producer.KafkaProducer; import org.apache.kafka.clients.producer.ProducerRecord; import org.apache.kafka.common.serialization.StringSerializer; import java.util.Properties; public final class ProduceCompacted { public static void main(String[] args) { Properties props = new Properties(); props.put("bootstrap.servers", "localhost:9092"); props.put("key.serializer", StringSerializer.class.getName()); props.put("value.serializer", StringSerializer.class.getName()); try (KafkaProducer producer = new KafkaProducer<>(props)) { producer.send(new ProducerRecord<>("user-profiles", "user-42", "v1")); producer.send(new ProducerRecord<>("user-profiles", "user-42", "v2")); // same key, newer value producer.send(new ProducerRecord<>("user-profiles", "user-7", null)); // tombstone producer.flush(); System.out.println("produced 2 versions of user-42 and a tombstone for user-7"); } } } ``` ```javascript const { Kafka } = require("kafkajs"); const kafka = new Kafka({ brokers: ["localhost:9092"] }); const producer = kafka.producer(); async function main() { await producer.connect(); await producer.send({ topic: "user-profiles", messages: [ { key: "user-42", value: "v1" }, { key: "user-42", value: "v2" }, // same key, newer value { key: "user-7", value: null }, // tombstone ], }); console.log("produced 2 versions of user-42 and a tombstone for user-7"); await producer.disconnect(); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using Confluent.Kafka; var config = new ProducerConfig { BootstrapServers = "localhost:9092" }; using var producer = new ProducerBuilder(config).Build(); await producer.ProduceAsync("user-profiles", new Message { Key = "user-42", Value = "v1" }); await producer.ProduceAsync("user-profiles", new Message { Key = "user-42", Value = "v2" }); // same key, newer value await producer.ProduceAsync("user-profiles", new Message { Key = "user-7", Value = null }); // tombstone producer.Flush(TimeSpan.FromSeconds(10)); Console.WriteLine("produced 2 versions of user-42 and a tombstone for user-7"); ``` ```ruby require "rdkafka" producer = Rdkafka::Config.new("bootstrap.servers" => "localhost:9092").producer producer.produce(topic: "user-profiles", key: "user-42", payload: "v1").wait producer.produce(topic: "user-profiles", key: "user-42", payload: "v2").wait # same key, newer value producer.produce(topic: "user-profiles", key: "user-7", payload: nil).wait # tombstone puts "produced 2 versions of user-42 and a tombstone for user-7" producer.close ``` ```rust use rdkafka::config::ClientConfig; use rdkafka::producer::{BaseProducer, BaseRecord, Producer}; use std::time::Duration; fn main() { let producer: BaseProducer = ClientConfig::new() .set("bootstrap.servers", "localhost:9092") .create() .expect("producer creation failed"); producer .send(BaseRecord::to("user-profiles").key("user-42").payload("v1")) .expect("send failed"); producer .send(BaseRecord::to("user-profiles").key("user-42").payload("v2")) // same key, newer value .expect("send failed"); producer .send(BaseRecord::to("user-profiles").key("user-7")) // tombstone: no .payload() call => NULL value .expect("send failed"); producer.flush(Duration::from_secs(10)).expect("flush failed"); println!("produced 2 versions of user-42 and a tombstone for user-7"); } ``` Consume from the beginning right after producing, and you'll see **all three** records — the original `v1`, the newer `v2`, and the tombstone: ```bash kcat -C -b localhost:9092 -t user-profiles -o beginning -c 3 ``` That's expected: compaction is an **asynchronous background job**, not something that happens at produce time. The next section covers when it actually runs. ## What compaction does in the background [#what-compaction-does-in-the-background] A background compactor periodically scans a compacted topic's log, keeps only the latest record per key, and removes everything older for that key. On KubeMQ the compactor runs on a **fixed background interval** (a leader-gated tick every few seconds), not gated on how "dirty" the log is. Kafka's `min.cleanable.dirty.ratio` and `segment.ms` are still accepted and echoed back at `DescribeConfigs` for tooling compatibility, but they do **not** change the scan cadence on KubeMQ today. A tombstone isn't removed immediately either — it's kept for `delete.retention.ms` (default `86400000`, 24 hours) so that a consumer reading through the log has a window to observe the delete before the tombstone itself disappears. The one invariant that matters most for client code: **compaction never renumbers surviving offsets.** For the example above, after a compaction pass runs: | Offset | Key | Value | After compaction | | ------ | --------- | ------------- | ----------------------------------------------------- | | 0 | `user-42` | `v1` | Removed — superseded by offset 1 | | 1 | `user-42` | `v2` | Kept — the latest value for `user-42` | | 2 | `user-7` | *(tombstone)* | Kept until `delete.retention.ms` elapses, then reaped | A `Fetch` at offset 0 after compaction doesn't error and doesn't get handed a renumbered record — it returns the next surviving offset (1), exactly like real Kafka's own compacted-topic behavior, which every conformant Kafka client already knows how to handle. ## What compaction unlocks [#what-compaction-unlocks] Compaction is a prerequisite, not a nice-to-have, for two large parts of the Kafka ecosystem: * **Kafka Connect** — its internal config, offset, and status topics are compacted by convention; Connect refuses to start against a broker that can't honor `cleanup.policy=compact` on them. * **Kafka Streams** — a stateful topology's changelog topics are compacted so that restoring state after a restart only has to replay the latest value per key, not the topic's entire history. Because the KubeMQ connector recognizes and runs `cleanup.policy=compact` today, both tools work against it without any special-casing on their side — they see a normal compacted Kafka topic. ## Related [#related] # Consuming (/connectors/kafka/how-to/consuming) Consuming from KubeMQ over the Kafka connector is the same client code you already run against Apache Kafka: subscribe with a consumer group, poll, and commit — with no client-library swap and no code change. Every offset a consumer sees maps one-to-one onto the Events Store `Sequence` of the record it read — durable, restart-stable, and identical across every node of a cluster, so resuming a group after a restart or a rebalance lands exactly where it left off. This guide walks group subscription, committing offsets (automatically or by hand), and seeking to a specific offset or timestamp, in `kcat` and seven client libraries. ## Subscribing with a consumer group [#subscribing-with-a-consumer-group] A `group.id` joins the classic consumer-group protocol — `JoinGroup`/`SyncGroup`/`Heartbeat` coordination assigns each member a slice of the topic's partitions, and the group's committed offsets are durable and leader-linearized. Two consumers with different `group.id` values subscribed to the same topic each get their own independent copy of every record and their own offset position — groups don't compete with each other, only members **within** a group do. See [Consumer Groups](/connectors/kafka/concepts/consumer-groups) for the full protocol, generations, and static membership (`group.instance.id`), which lets a restarting consumer rejoin without triggering a rebalance at all. ## Consume, commit, and seek [#consume-commit-and-seek] Every example below joins `my-group`, polls one record from `orders`, commits its offset manually, then demonstrates seeking to a specific offset and to the first offset after a timestamp (backed by the connector's `ListOffsets` API). ```bash # Consume as part of a consumer group. kcat auto-commits on its own interval; there's no # per-message manual-commit flag on the CLI — pass -X enable.auto.commit=false to disable # commits entirely instead of committing per message. kcat -b localhost:9092 -t orders -C -G my-group -o beginning # Seek to a specific offset (non-group mode; the read starts there). kcat -b localhost:9092 -t orders -C -p 0 -o 100 -c 5 # Seek to the first offset after a timestamp (ms since epoch). kcat -b localhost:9092 -t orders -C -p 0 -o s@1700000000000 -c 5 ``` ```go package main import ( "context" "fmt" "time" "github.com/twmb/franz-go/pkg/kadm" "github.com/twmb/franz-go/pkg/kgo" ) func main() { ctx := context.Background() cl, err := kgo.NewClient( kgo.SeedBrokers("localhost:9092"), kgo.ConsumerGroup("my-group"), kgo.ConsumeTopics("orders"), kgo.DisableAutoCommit(), // manual commit below; drop this to auto-commit instead ) if err != nil { panic(err) } defer cl.Close() fetches := cl.PollFetches(ctx) fetches.EachRecord(func(r *kgo.Record) { fmt.Printf("partition=%d offset=%d value=%s\n", r.Partition, r.Offset, string(r.Value)) }) if err := cl.CommitUncommittedOffsets(ctx); err != nil { // manual commit panic(err) } // Seek to a specific offset. Safe here because no PollFetches is in flight and the // group isn't mid-rebalance — see SetOffsets' docs for the caveats. cl.SetOffsets(map[string]map[int32]kgo.EpochOffset{ "orders": {0: {Epoch: -1, Offset: 0}}, }) // Seek to the first offset after a timestamp: resolve it via ListOffsetsAfterMilli, // then feed the result back into SetOffsets. adm := kadm.NewClient(cl) oneHourAgo := time.Now().Add(-time.Hour).UnixMilli() listed, err := adm.ListOffsetsAfterMilli(ctx, oneHourAgo, "orders") if err != nil { panic(err) } offsets := make(map[string]map[int32]kgo.EpochOffset) listed.Each(func(o kadm.ListedOffset) { if offsets[o.Topic] == nil { offsets[o.Topic] = make(map[int32]kgo.EpochOffset) } offsets[o.Topic][o.Partition] = kgo.EpochOffset{Epoch: o.LeaderEpoch, Offset: o.Offset} }) cl.SetOffsets(offsets) } ``` ```python import time from confluent_kafka import Consumer, TopicPartition conf = { "bootstrap.servers": "localhost:9092", "group.id": "my-group", "enable.auto.commit": False, # manual commit below; True to auto-commit instead "auto.offset.reset": "earliest", } consumer = Consumer(conf) consumer.subscribe(["orders"]) msg = consumer.poll(timeout=10.0) if msg is not None and msg.error() is None: print(f"partition={msg.partition()} offset={msg.offset()} value={msg.value()!r}") consumer.commit(message=msg) # manual commit # Seek to a specific offset (valid once the partition is actively assigned). consumer.seek(TopicPartition("orders", 0, 0)) # Seek to the first offset after a timestamp (ms since epoch). one_hour_ago_ms = int((time.time() - 3600) * 1000) resolved = consumer.offsets_for_times([TopicPartition("orders", 0, one_hour_ago_ms)]) for tp in resolved: if tp.offset >= 0: consumer.seek(tp) consumer.close() ``` ```java import java.time.Duration; import java.util.List; import java.util.Map; import java.util.Properties; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.apache.kafka.clients.consumer.ConsumerRecords; import org.apache.kafka.clients.consumer.KafkaConsumer; import org.apache.kafka.clients.consumer.OffsetAndTimestamp; import org.apache.kafka.common.TopicPartition; public final class Main { public static void main(String[] args) { Properties props = new Properties(); props.put("bootstrap.servers", "localhost:9092"); props.put("group.id", "my-group"); props.put("enable.auto.commit", "false"); // manual commit below; "true" to auto-commit props.put("auto.offset.reset", "earliest"); props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer"); props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer"); try (KafkaConsumer consumer = new KafkaConsumer<>(props)) { consumer.subscribe(List.of("orders")); ConsumerRecords records = consumer.poll(Duration.ofSeconds(10)); for (ConsumerRecord r : records) { System.out.printf("partition=%d offset=%d value=%s%n", r.partition(), r.offset(), r.value()); } consumer.commitSync(); // manual commit of the positions just polled TopicPartition tp = new TopicPartition("orders", 0); consumer.seek(tp, 0); // seek to a specific offset // Seek to the first offset after a timestamp (ms since epoch). long oneHourAgo = System.currentTimeMillis() - 3_600_000; Map found = consumer.offsetsForTimes(Map.of(tp, oneHourAgo)); OffsetAndTimestamp match = found.get(tp); if (match != null) { consumer.seek(tp, match.offset()); } } } } ``` ```typescript import { Kafka } from "kafkajs"; async function main(): Promise { const kafka = new Kafka({ brokers: ["localhost:9092"] }); const admin = kafka.admin(); const consumer = kafka.consumer({ groupId: "my-group" }); await consumer.connect(); await consumer.subscribe({ topics: ["orders"], fromBeginning: true }); await consumer.run({ autoCommit: false, // manual commit below; true auto-commits instead eachMessage: async ({ topic, partition, message }) => { console.log(`partition=${partition} offset=${message.offset} value=${message.value?.toString()}`); await consumer.commitOffsets([ { topic, partition, offset: (Number(message.offset) + 1).toString() }, ]); }, }); // Seek to a specific offset — any in-flight batch for that partition is discarded. consumer.seek({ topic: "orders", partition: 0, offset: "0" }); // Seek to the first offset after a timestamp (ms since epoch). await admin.connect(); const oneHourAgo = Date.now() - 3_600_000; const resolved = await admin.fetchTopicOffsetsByTimestamp("orders", oneHourAgo); for (const { partition, offset } of resolved) { consumer.seek({ topic: "orders", partition, offset }); } await admin.disconnect(); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using Confluent.Kafka; var config = new ConsumerConfig { BootstrapServers = "localhost:9092", GroupId = "my-group", EnableAutoCommit = false, // manual commit below; true to auto-commit instead AutoOffsetReset = AutoOffsetReset.Earliest, }; using var consumer = new ConsumerBuilder(config).Build(); consumer.Subscribe("orders"); var result = consumer.Consume(TimeSpan.FromSeconds(10)); if (result != null) { Console.WriteLine($"partition={result.Partition.Value} offset={result.Offset.Value} value={result.Message.Value}"); consumer.Commit(result); // manual commit } // Seek to a specific offset. consumer.Seek(new TopicPartitionOffset("orders", new Partition(0), new Offset(0))); // Seek to the first offset after a timestamp (ms since epoch). var oneHourAgo = DateTime.UtcNow.AddHours(-1); var found = consumer.OffsetsForTimes( new[] { new TopicPartitionTimestamp("orders", new Partition(0), new Timestamp(oneHourAgo)) }, TimeSpan.FromSeconds(10)); if (found.Count > 0 && found[0].Offset.Value >= 0) { consumer.Seek(found[0]); } ``` ```ruby require "rdkafka" config = Rdkafka::Config.new( :"bootstrap.servers" => "localhost:9092", :"group.id" => "my-group", :"enable.auto.commit" => false, # manual commit below; true to auto-commit instead :"auto.offset.reset" => "earliest", ) consumer = config.consumer consumer.subscribe("orders") consumer.each do |message| puts "partition=#{message.partition} offset=#{message.offset} value=#{message.payload}" consumer.commit # manual commit of the current position break end # Seek to a specific offset (the next poll on that partition resumes there). consumer.seek_by("orders", 0, 0) # Seek to the first offset after a timestamp (ms since epoch). one_hour_ago_ms = (Time.now.to_i - 3600) * 1000 query = Rdkafka::Consumer::TopicPartitionList.new query.add_topic_and_partitions_with_offsets("orders", 0 => one_hour_ago_ms) consumer.offsets_for_times(query).to_h.each do |topic, partitions| partitions.each { |p| consumer.seek_by(topic, p.partition, p.offset) if p.offset } end ``` ```rust use std::time::{Duration, SystemTime, UNIX_EPOCH}; use rdkafka::config::ClientConfig; use rdkafka::consumer::{CommitMode, Consumer, StreamConsumer}; use rdkafka::message::Message; use rdkafka::topic_partition_list::{Offset, TopicPartitionList}; use rdkafka::util::Timeout; #[tokio::main] async fn main() -> Result<(), Box> { let consumer: StreamConsumer = ClientConfig::new() .set("bootstrap.servers", "localhost:9092") .set("group.id", "my-group") .set("enable.auto.commit", "false") // manual commit below; "true" to auto-commit .set("auto.offset.reset", "earliest") .create()?; consumer.subscribe(&["orders"])?; let msg = consumer.recv().await?; println!( "partition={} offset={} value={:?}", msg.partition(), msg.offset(), msg.payload().map(String::from_utf8_lossy) ); consumer.commit_message(&msg, CommitMode::Sync)?; // manual commit // Seek to a specific offset. consumer.seek("orders", 0, Offset::Offset(0), Timeout::After(Duration::from_secs(5)))?; // Seek to the first offset after a timestamp (ms since epoch). let one_hour_ago_ms = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as i64 - 3_600_000; let mut query = TopicPartitionList::new(); query.add_partition_offset("orders", 0, Offset::Offset(one_hour_ago_ms))?; let resolved = consumer.offsets_for_times(query, Timeout::After(Duration::from_secs(5)))?; for elem in resolved.elements() { if let Offset::Offset(o) = elem.offset() { consumer.seek("orders", elem.partition(), Offset::Offset(o), Timeout::After(Duration::from_secs(5)))?; } } Ok(()) } ``` ## Committing offsets: automatic or manual [#committing-offsets-automatic-or-manual] Every pinned client defaults to **automatic** commits on a periodic interval (`enable.auto.commit=true`, or kafkajs's `autoCommit` on `consumer.run`). Auto-commit is the simpler default and works fine for workloads that can tolerate re-processing a handful of records after a crash. Disabling it and committing explicitly — as every example above does — trades a small amount of code for control over exactly when a message is considered "done": commit **after** the work that consumes it completes, not right after it's delivered, so a crash between delivery and processing redelivers the message instead of silently losing it. `OffsetCommit` and `OffsetFetch` are durable and leader-linearized on KubeMQ, so a committed offset survives a restart and is visible identically from any node in a cluster. ## Reading from the beginning or latest [#reading-from-the-beginning-or-latest] Where a consumer group with no prior committed offset starts reading is a client-side reset policy, not a KubeMQ setting: | Client | From the beginning | From latest | | ------------------------ | --------------------------------------------------- | -------------------------------- | | `kcat` | `-o beginning` | `-o end` | | franz-go (Go) | `kgo.ConsumeResetOffset(kgo.NewOffset().AtStart())` | `...AtEnd()` | | confluent-kafka (Python) | `"auto.offset.reset": "earliest"` | `"latest"` | | kafka-clients (Java) | `auto.offset.reset=earliest` | `latest` | | kafkajs | `subscribe({ ..., fromBeginning: true })` | `fromBeginning: false` (default) | | Confluent.Kafka (C#) | `AutoOffsetReset.Earliest` | `.Latest` | | rdkafka (Ruby) | `"auto.offset.reset" => "earliest"` | `"latest"` | | rust-rdkafka | `"auto.offset.reset" => "earliest"` | `"latest"` | This policy only applies the **first** time a group has no committed offset for a partition — once a group has committed, it always resumes from there. ## Error quick reference [#error-quick-reference] Most consume-side failures trace back to authorization or a coordinator-wide cap rather than anything about the record being read: | Trigger | Result | | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | More than `MaxGroups` consumer groups active coordinator-wide | New group rejected — see [Limits & rules](/connectors/kafka/reference/limits-and-rules) | | A duplicate or displaced static member (`group.instance.id`) rejoins | `FENCED_INSTANCE_ID(82)` | | Fetching or committing without the required ACL grant | `TOPIC_AUTHORIZATION_FAILED` / `GROUP_AUTHORIZATION_FAILED` | The full error-code table lives in [Error codes](/connectors/kafka/reference/error-codes). ## Related [#related] # Migrate from Kafka (/connectors/kafka/how-to/migrate-from-kafka) **Drop-in level: endpoint-only.** KubeMQ speaks the native Kafka wire protocol — real `librdkafka`/`kcat`/Java clients connect unchanged; you repoint `bootstrap.servers`, with no client-library swap or code change. Moving an *existing* Apache Kafka, Amazon MSK, or Confluent cluster onto KubeMQ is more than pointing at a new endpoint if you want consumers to resume without reprocessing history — that historical topic-data and offset move is what the separate `kmq migrate` tool below handles. This guide covers the read-only fitness check, the four-phase `kmq migrate` tool, per-source auth, the large-history MirrorMaker 2 hybrid, rollback, and a post-cutover smoke test. ## Overview [#overview] Migrating an existing Kafka workload onto KubeMQ has two stages: **assess**, then **migrate**. `kmq assess kafka` scans your source cluster read-only and reports, per topic, whether it's a straight repoint or needs a workaround. `kmq migrate` then does the actual work — copying topic history and consumer-group offsets to KubeMQ in four phases (assess → replicate → translate → cutover) — so consumers resume where they left off instead of reprocessing everything from scratch. **Know `kmq migrate`'s scope before you rely on it.** Its engine — byte-fidelity replication, exact per-record offset translation, the cutover-completeness gate, and oversized-record block-and-report — is proven on a real 3-node `next` cluster: a SIGKILL mid-replication loses nothing, and a kill during cutover never double-seeds an offset. Cluster-level consumer-resume is proven with a real `franz-go` client; Java/kcat/librdkafka consumer-resume is proven **single-node** only — the full multi-client (Java / librdkafka / franz-go) cluster consumer-resume run is what remains. The MSK and Confluent auth procedures below are docs-grounded: validated against a local Apache Kafka, with cloud-specific details taken from each vendor's own documentation. **Do a staged dry-run before any production cutover.** Run `kmq migrate assess` and `kmq migrate translate` against a copy of your data first and confirm the verdicts and the offset-map preview look right — before you point a single production consumer at the KubeMQ target. ## Storage engine (zero-config) [#storage-engine-zero-config] Kafka on KubeMQ requires the **`next`** storage engine — but you don't set it manually. On a **fresh** deployment, enabling the Kafka connector with the engine left unset **auto-selects `next`** and logs a `NOTICE`; there is no separate "set `store.engine=next` first" step. | Case | Behavior | | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Fresh deployment**, Kafka enabled, engine unset | Auto-selects `next`. Zero-config — nothing to set before you enable Kafka. | | **Existing `legacy` cluster**, Kafka enabled | Cannot be retrofitted. The server fails closed at boot with a config error naming the conflicting store directory — there is no in-place engine migration between `legacy` and `next`. Stand up a new cluster on `next` instead. | | **Explicit `STORE_ENGINE=legacy`**, Kafka enabled | Rejected at boot — pinning `legacy` alongside Kafka is a configuration error. | Pinning `STORE_ENGINE=next` (or `store.engine: next` in `config.yaml`) always works and always wins — it skips the auto-select probe entirely, which is the predictable choice for IaC/GitOps. See [Storage Engines](/configure/reference/storage-engines#zero-config-engine-selection) for the full engine model, mode isolation, and durability guarantees. ## Assess fit — `kmq assess kafka` [#assess-fit--kmq-assess-kafka] Before touching anything, run the read-only assessor against your source cluster: ```bash kmq assess kafka --bootstrap your-broker:9092 [--tls --sasl-mechanism scram-sha-256 --sasl-username --sasl-password ] ``` `kmq assess kafka` never produces, commits an offset, or creates a topic — it's safe to run against a production cluster. It reports, per topic, one of four verdicts, plus a single overall `migratable` verdict: | Verdict | Meaning | | ----------- | ------------------------------------------------------------------------------------------------------------------ | | **READY** | No obstruction found — a straight repoint. | | **CAVEAT** | Migratable, with a documented constraint (see below). | | **UNKNOWN** | The topic's config couldn't be read (for example, a restricted cluster). **Fail-safe** — never assumed migratable. | | **BLOCKED** | A hard blocker (see below) — this topic won't migrate through `kmq migrate`. | **Hard BLOCKED:** * **Compacted topics** (`cleanup.policy=compact`) — compaction leaves non-contiguous offsets (compaction holes) that the exact offset-translation path can't follow. Move these via bulk data copy (the [MirrorMaker 2 hybrid](#large-histories--mirrormaker-2-hybrid) below) and rebuild consumer state, or keep them on the source. * **More than 256 partitions on a topic** — KubeMQ's per-topic partition cap. There's no auto-repartition (re-hashing would break partition-pinning and per-key ordering); repartition on the source first. **CAVEAT, not a block:** * **`max.message.bytes` above the 1 MiB target cap.** Config isn't data — your actual records may all be within the limit. A record that *is* over the cap doesn't fail assessment; it blocks and reports at replicate time instead (see [Migrate](#migrate--kmq-migrate) below). **Not an assess blocker at all:** * **`replication.factor > 1`.** This is a constraint on *how* you migrate, not a fitness verdict — KubeMQ topics run at RF=1 (durability comes from the cluster itself, not per-topic replica count), so a replicated source topic migrates as an RF=1 target. Active consumer groups on the source are tagged "requires exact offset translation at cutover" — stop their consumers before you run `kmq migrate cutover`. The verdicts above are the same rubric rendered by the [fitness matrix](/connectors/kafka/reference/fitness-matrix) (T1–T4) — `kmq assess kafka` just maps it onto your actual topics, configs, and consumer groups. ## Migrate — `kmq migrate` [#migrate--kmq-migrate] `kmq migrate` mirrors the shape of a real migration, one command per phase: | Phase | Command | Touches the source | Touches the target | Reversible | | ------------- | ----------------------- | ------------------ | ----------------------- | ------------------ | | **assess** | `kmq migrate assess` | read-only | — | Yes — read-only | | **replicate** | `kmq migrate replicate` | read-only | writes topics + records | Yes — target only | | **translate** | `kmq migrate translate` | read-only | — | Yes — preview only | | **cutover** | `kmq migrate cutover` | read-only | writes group offsets | Yes — re-runnable | **The source is only ever read.** Every phase dials the source with a read-only client — none of them produce, commit an offset, join a consumer group, or auto-create a topic there. All writes (topic creation, records, seeded offsets) land on the KubeMQ **target** only. The **assess** phase is covered in detail above — see [Assess fit — `kmq assess kafka`](#assess-fit--kmq-assess-kafka). ### Replicate — copy the history [#replicate--copy-the-history] ```bash kmq migrate replicate \ --bootstrap source-broker:9092 [source auth flags] \ --target-bootstrap kubemq-broker:9092 [target auth flags] \ --state ./migration.state ``` Copies every source topic-partition into KubeMQ **byte-for-byte** — key, value, headers, and the original `CreateTime` — **partition-pinned** (a record's source partition is preserved exactly; no key re-hash). It produces with `acks=all` and, in the same pass, records an exact per-record source-offset → target-offset map to `--state`. * **Resumable and crash-safe.** Re-running with the same `--state` resumes from the last durably-copied offset per partition; already-acked records aren't re-copied. * **Oversized records block — they never skip.** A source record larger than the target's message-size cap halts replication at that offset with a report (topic/partition/offset/size). The partition watermark doesn't advance past it, so nothing is silently dropped. Resolve the record (or raise the target's max-message-bytes, if appropriate), then re-run. * Restrict scope with `--topic` (repeatable); the default is every non-internal source topic. ### Translate — preview the offset map (no writes) [#translate--preview-the-offset-map-no-writes] ```bash kmq migrate translate --bootstrap source-broker:9092 --target-bootstrap kubemq-broker:9092 --state ./migration.state ``` For each source consumer group, shows the target resume offset each committed offset maps to, and flags any group that can't be cut over cleanly. This is a read-only preview of exactly what `cutover` would seed — the second half of the staged dry-run described in the [Overview](#overview). ### Cutover — seed the offsets [#cutover--seed-the-offsets] ```bash # stop the source consumers first, then: kmq migrate cutover --bootstrap source-broker:9092 --target-bootstrap kubemq-broker:9092 --state ./migration.state ``` Reads each source group's committed offsets, translates them through the offset map, and seeds them on the KubeMQ target via an empty-group offset commit — before any consumer joins. Point your consumers at KubeMQ and they resume at the correct position. * **Fail-closed completeness — zero tail loss on acked/committed offsets.** A group is seeded only once replication has durably reached at least that group's committed offset on every partition. If a group consumed past what's been replicated, cutover **refuses** it with a clear reason, rather than seeding a position that would silently skip the un-replicated tail. Replicate further, then re-run. * **Offset-based only.** Cutover resumes by offset, never by timestamp — a target timestamp-seek would resolve KubeMQ's own ingestion time, not the preserved source `CreateTime`, and would mis-position a consumer. * **Idempotent.** Re-running overwrites the seeded offsets safely. `--dry-run` translates and checks completeness without writing; `--force` seeds even if the source group is still active (stop its consumers first — an active group is refused by default). ## Per-source auth [#per-source-auth] The migration mechanics are identical across sources — only the **auth flags** differ, both for reading the source (`assess`/`replicate`/`translate`/`cutover`) and for the application's eventual switch to KubeMQ. | Source | Reading the source | After cutover, on KubeMQ | | ----------------------------- | ---------------------------------------------------------- | ---------------------------------------------------- | | **Amazon MSK** | MSK IAM — `--sasl-mechanism aws-msk-iam` with static keys | SASL/SCRAM or mTLS | | **Confluent Cloud** | SASL/PLAIN over TLS — API key/secret as username/password | SASL/PLAIN-over-TLS (rotate the API-key credentials) | | **Self-managed Apache Kafka** | PLAIN, SCRAM-SHA-256, or SCRAM-SHA-512 (+ TLS as required) | SASL/SCRAM, PLAIN, mTLS, or OAUTHBEARER | MSK IAM is `--sasl-mechanism aws-msk-iam` with **static keys** — the AWS default credential chain (profile / SSO / IMDS) is not used; supply the keys explicitly: ```bash kmq migrate assess \ --bootstrap b-1.your-cluster.kafka.us-east-1.amazonaws.com:9098 \ --sasl-mechanism aws-msk-iam \ --aws-access-key \ --aws-secret-key \ [--aws-session-token ] ``` The application-side switch is config-only: IAM/SigV4 on the source becomes SASL/SCRAM or mTLS on KubeMQ. Your code is unchanged beyond the `bootstrap.servers` value you were already changing at migration. Confluent Cloud is SASL/PLAIN over TLS — **not** SCRAM — with the API key/secret as the username/password: ```bash kmq migrate assess \ --bootstrap pkc-xxxxx.us-east-1.aws.confluent.cloud:9092 \ --tls \ --sasl-mechanism plain \ --sasl-username \ --sasl-password ``` After cutover, rotate the Confluent API-key credentials to KubeMQ SASL/PLAIN-over-TLS. If you run Schema Registry, it keeps working unchanged against KubeMQ. Self-managed Apache Kafka uses whichever SASL mechanism your cluster already runs: ```bash kmq migrate assess \ --bootstrap kafka.internal:9093 \ --tls --tls-ca /path/to/ca.pem \ --sasl-mechanism scram-sha-256 \ --sasl-username \ --sasl-password ``` **Kerberos / GSSAPI caveat.** The bridge can *consume* a GSSAPI-secured source where the underlying client library supports it — but **KubeMQ itself doesn't serve Kerberos**. The migrated workload authenticates to KubeMQ with SASL/SCRAM, PLAIN, mTLS, or OAUTHBEARER instead. ## Large histories — MirrorMaker 2 hybrid [#large-histories--mirrormaker-2-hybrid] `kmq migrate` is the offset-exact vehicle — tuned for correct consumer resume, not for saturating a multi-terabyte historical backfill from a single process. For a very large history, use a hybrid: * **MirrorMaker 2** for the bulk **data** copy. MM2 replicates records into KubeMQ byte-perfectly, and its distributed workers scale the throughput. Use the shippable config: pin every `*.replication.factor=1` (KubeMQ requires RF=1) and set `offset-syncs.topic.location=target` (the default writes that topic to your *source* cluster instead). * **`kmq migrate cutover`** for the **offsets**. Don't rely on MM2 for consumer-offset translation — its own checkpoint translation does not produce a correct zero-loss resume position. Use MM2 for the data, then seed offsets with `kmq migrate` against the same target. MM2 (data) + `kmq migrate` (offsets) gives you MM2's throughput **and** `kmq migrate`'s exact consumer resume. ## Rollback [#rollback] Migration is **non-destructive to the source** — every phase only reads it. Rollback is: 1. Point `bootstrap.servers` back to the original source cluster. 2. Resume the source consumers. Because the source's data and committed offsets are untouched throughout, it remains a complete, consistent fallback until you decommission it. The KubeMQ target can be discarded and the migration re-run from scratch. ## OAUTHBEARER onboarding [#oauthbearer-onboarding] If your Kafka clients already authenticate with OAUTHBEARER against an OIDC identity provider, KubeMQ's Kafka connector accepts the same mechanism natively — there's no separate credential model to bolt on for the migrated workload. See [the Kafka OAUTHBEARER settings](/configure/reference/connectors#kafka) for the six-field `OAuthBearer` config block and its environment variables. ## Verify the migration [#verify-the-migration] Use a standard Kafka client pointed at the KubeMQ target to confirm the cutover worked. 1. **Point a client at the KubeMQ target.** Repoint `bootstrap.servers` (or `kcat -b`) to the KubeMQ Kafka listener you migrated into. 2. **Produce a record** to a migrated topic and confirm it's accepted: ```bash kcat -b kubemq-broker:9092 -t orders -P <<< "smoke-test-record" ``` 3. **Consume it back** from the same topic: ```bash kcat -b kubemq-broker:9092 -t orders -C -c 1 ``` 4. **Check a consumer-group offset.** Resume a migrated group and confirm it picks up at the translated offset — not `0`: ```bash kafka-consumer-groups.sh --bootstrap-server kubemq-broker:9092 --describe --group my-group ``` The committed offset shown should match `kmq migrate translate`'s preview for that group, and the group should resume without reprocessing already-consumed records. ## See Also [#see-also] # Producing (/connectors/kafka/how-to/producing) Producing to KubeMQ over the Kafka connector is a straight repoint: every pinned client below writes to the `orders` topic on `bootstrap.servers=localhost:9092` with no client-library swap and no code change. Every produced record is written once to the topic's Events Store log, so whatever a client would do against a real Kafka broker — key-based partitioning, batching, idempotent retries — carries over unchanged. This guide covers the parts of the produce path worth understanding before you ship — keys and headers, the `acks` durability contract, client-side batching, and the idempotent producer — then walks a full keyed, headered, idempotent produce in `kcat` and seven client libraries. ## Keys, headers, and partitioning [#keys-headers-and-partitioning] A record's **key** decides which partition it lands on; its **headers** are opaque key/value pairs that ride alongside the value untouched — a natural home for tracing ids, content types, or routing metadata your consumers read without touching the payload. Partition assignment for a keyed record is always decided **client-side** — KubeMQ never hashes a key itself, it just stores whatever partition the client chose — and different client libraries ship different default hash functions (the JVM and franz-go default to murmur2; librdkafka-based clients like `kcat` default to CRC32). Mixing producer libraries against the same keyed topic can therefore land the same key on different partitions, even though every client is behaving correctly by its own rules. An unkeyed record (`key = nil`/`null`) is spread round-robin (or sticky-batched, depending on the client's partitioner) across the topic's partitions instead. See [Partitions & Ordering](/connectors/kafka/concepts/partitions-and-ordering) for the full hashing and per-key ordering guarantee, including what happens to that guarantee when a topic's partition count grows. ## Durability: the `acks` setting [#durability-the-acks-setting] `acks` controls how many replicas must confirm a write before the producer considers it acknowledged: `0` (fire-and-forget, no wait), `1` (the leader has written it, but replicas may not have caught up yet), or `all` (every in-sync replica has it). This is the single setting that trades latency for durability, and it's worth setting deliberately rather than leaving at a client's default. On the `next` storage engine an `acks=all` write is fsynced to a quorum of nodes before it's acknowledged — see [Durability & Retention](/connectors/kafka/concepts/durability-and-retention) for the full contract and how it compares to Apache Kafka's own default posture. **`acks=0` is unsafe on a multi-node cluster.** A fronting load balancer can land a produce on any pod. With `acks>=1`, a follower transparently forwards the write to the leader. With `acks=0`, a follower **silently drops** the record instead of forwarding it — there's no response channel to signal a redirect. Always use `acks>=1` on a multi-node deployment; single-node/standalone setups are unaffected. ## Batching and linger [#batching-and-linger] Client-side batching groups multiple records into one request instead of sending each individually — `batch.size` caps the bytes per batch and `linger.ms` caps how long the client waits for a batch to fill before sending anyway. A larger `linger.ms` trades a little added latency per record for materially higher throughput once you have more than a handful of producers in flight, since the connector processes one batch instead of many small requests. `kcat`, `confluent-kafka`, `kafka-clients`, `Confluent.Kafka`, and both `rdkafka` bindings all expose these two settings by name. **`kafkajs` has no `linger.ms`-equivalent micro-batching** — group multiple records into one `send()` call to get the same effect. None of this is KubeMQ-specific configuration; it's ordinary Kafka producer tuning that works unchanged against the connector. ## The idempotent producer [#the-idempotent-producer] Without idempotence, a producer that times out waiting for an ack and retries can duplicate a record the broker actually received — the client has no way to tell "lost in transit" from "acked but the ack was lost." Enabling the idempotent producer (`enable.idempotence` / `idempotent`, depending on the client) closes that gap: each producer session gets a broker-assigned producer id via `InitProducerId`, every record it sends carries a monotonically increasing per-partition sequence number, and the connector deduplicates retries by `(producer id, partition, sequence)` — proven to survive a real 3-node leader failover, so a retry after a mid-write leader change still lands exactly once. Idempotence requires `acks=all` and a bounded number of in-flight requests (so retries can't reorder past an unacked send), which every pinned client either defaults or enforces automatically once idempotence is turned on. franz-go enables idempotent writes **by default** — no flag needed, `kgo.DisableIdempotentWrite()` is the opt-out. See [Limits & rules](/connectors/kafka/reference/limits-and-rules) for the message-size ceiling idempotent retries still have to respect. ## Produce a message [#produce-a-message] Each example connects with `acks=all`, batching (`batch.size`/`linger.ms`) tuned, and the idempotent producer enabled, then produces one keyed, headered record to `orders`. ```bash # order-42:hello kafka (key:value, split by -K:) echo "order-42:hello kafka" | kcat -b localhost:9092 -t orders -P -K: \ -H "source=demo" \ -X acks=all -X enable.idempotence=true -X linger.ms=50 -X batch.size=65536 ``` ```go package main import ( "context" "fmt" "time" "github.com/twmb/franz-go/pkg/kgo" ) func main() { cl, err := kgo.NewClient( kgo.SeedBrokers("localhost:9092"), kgo.RequiredAcks(kgo.AllISRAcks()), // acks=all kgo.ProducerLinger(50*time.Millisecond), kgo.ProducerBatchMaxBytes(64<<10), // Idempotent writes are ON by default; kgo.DisableIdempotentWrite() would turn them off. ) if err != nil { panic(err) } defer cl.Close() record := &kgo.Record{ Topic: "orders", Key: []byte("order-42"), Value: []byte("hello kafka"), Headers: []kgo.RecordHeader{ {Key: "source", Value: []byte("demo")}, }, } res := cl.ProduceSync(context.Background(), record) if err := res.FirstErr(); err != nil { panic(err) } r, _ := res.First() fmt.Printf("produced to partition=%d offset=%d\n", r.Partition, r.Offset) } ``` ```python from confluent_kafka import Producer conf = { "bootstrap.servers": "localhost:9092", "acks": "all", "enable.idempotence": True, # requires acks=all; the client enforces this "linger.ms": 50, "batch.size": 65536, } producer = Producer(conf) def on_delivery(err, msg): if err is not None: raise RuntimeError(f"delivery failed: {err}") print(f"produced to partition={msg.partition()} offset={msg.offset()}") producer.produce( topic="orders", key="order-42", value="hello kafka", headers=[("source", b"demo")], callback=on_delivery, ) producer.flush(10) ``` ```java import java.nio.charset.StandardCharsets; import java.util.Properties; import org.apache.kafka.clients.producer.KafkaProducer; import org.apache.kafka.clients.producer.ProducerRecord; import org.apache.kafka.clients.producer.RecordMetadata; public final class Main { public static void main(String[] args) throws Exception { Properties props = new Properties(); props.put("bootstrap.servers", "localhost:9092"); props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer"); props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer"); props.put("acks", "all"); props.put("enable.idempotence", true); // requires acks=all; the client enforces this props.put("linger.ms", 50); props.put("batch.size", 65536); try (KafkaProducer producer = new KafkaProducer<>(props)) { ProducerRecord record = new ProducerRecord<>("orders", "order-42", "hello kafka"); record.headers().add("source", "demo".getBytes(StandardCharsets.UTF_8)); RecordMetadata meta = producer.send(record).get(); System.out.printf("produced to partition=%d offset=%d%n", meta.partition(), meta.offset()); } } } ``` ```typescript import { Kafka } from "kafkajs"; async function main(): Promise { const kafka = new Kafka({ brokers: ["localhost:9092"] }); // kafkajs has no linger.ms; sendBatch (or one messages[] array) is the batching unit. // idempotent:true requires maxInFlightRequests <= 5 and acks=-1 (all). const producer = kafka.producer({ idempotent: true, maxInFlightRequests: 5 }); await producer.connect(); const [meta] = await producer.send({ topic: "orders", acks: -1, // all messages: [ { key: "order-42", value: "hello kafka", headers: { source: "demo" }, }, ], }); console.log(`produced to partition=${meta.partition} offset=${meta.baseOffset}`); await producer.disconnect(); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using System.Text; using Confluent.Kafka; var config = new ProducerConfig { BootstrapServers = "localhost:9092", Acks = Acks.All, EnableIdempotence = true, // requires Acks.All; the client enforces this LingerMs = 50, BatchSize = 65536, }; using var producer = new ProducerBuilder(config).Build(); var headers = new Headers { { "source", Encoding.UTF8.GetBytes("demo") } }; var result = await producer.ProduceAsync("orders", new Message { Key = "order-42", Value = "hello kafka", Headers = headers, }); Console.WriteLine($"produced to partition={result.Partition.Value} offset={result.Offset.Value}"); ``` ```ruby require "rdkafka" config = Rdkafka::Config.new( :"bootstrap.servers" => "localhost:9092", :"acks" => "all", :"enable.idempotence" => true, # requires acks=all; the client enforces this :"linger.ms" => 50, :"batch.size" => 65536, ) producer = config.producer handle = producer.produce( topic: "orders", payload: "hello kafka", key: "order-42", headers: { "source" => "demo" }, ) report = handle.wait # blocks until the broker acks puts "produced to partition=#{report.partition} offset=#{report.offset}" ``` ```rust use std::time::Duration; use rdkafka::config::ClientConfig; use rdkafka::message::{Header, OwnedHeaders}; use rdkafka::producer::{FutureProducer, FutureRecord}; use rdkafka::util::Timeout; #[tokio::main] async fn main() -> Result<(), Box> { let producer: FutureProducer = ClientConfig::new() .set("bootstrap.servers", "localhost:9092") .set("acks", "all") .set("enable.idempotence", "true") // requires acks=all; the client enforces this .set("linger.ms", "50") .set("batch.size", "65536") .create()?; let headers = OwnedHeaders::new().insert(Header { key: "source", value: Some("demo") }); let record = FutureRecord::to("orders") .key("order-42") .payload("hello kafka") .headers(headers); match producer.send(record, Timeout::After(Duration::from_secs(10))).await { Ok(delivery) => println!("produced to partition={} offset={}", delivery.partition, delivery.offset), Err((err, _)) => return Err(Box::new(err)), } Ok(()) } ``` ## Error quick reference [#error-quick-reference] A produce can fail for reasons unrelated to the settings above — most commonly an authorization deny or an oversized record: | Trigger | Result | | --------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | A produced record (or the assembled batch) exceeds the message-size ceiling | `MESSAGE_TOO_LARGE` | | `acks=0` sent to a follower on a multi-node cluster | Dropped silently — no redirect signal; see the durability Callout above | | Producing without the required Write ACL grant on the topic | `TOPIC_AUTHORIZATION_FAILED` | The full error-code and numeric-limit tables live in [Limits & rules](/connectors/kafka/reference/limits-and-rules). ## Related [#related] # Share Groups (/connectors/kafka/how-to/share-groups) Kafka share groups (KIP-932) give the Kafka connector a second, queue-style way to consume a topic. Instead of assigning whole partitions to consumers, individual records are acquired, processed, and acknowledged one at a time — closer to how a KubeMQ Queue behaves than to a classic consumer group. **Share groups are supported in preview, not GA.** The data plane — `ShareGroupHeartbeat`(76), `ShareFetch`(78), `ShareAcknowledge`(79), plus the admin/observability keys `ShareGroupDescribe`(77), `DescribeShareGroupOffsets`(90), `AlterShareGroupOffsets`(91), and `DeleteShareGroupOffsets`(92) — is implemented and advertised: acquire, `Accept`/`Release`/ `Reject` acknowledgements, multi-record batches, and cluster follower→leader `ShareFetch` forwarding are all proven against a real client. What hasn't run yet is the full multi-client share-group conformance matrix — the share-group analogue of the transactions/EOS conformance matrix — so this carries a **preview** verdict, not a GA guarantee. Never treat share groups as "fully supported" until that matrix lands. Track status on the [fitness matrix](/connectors/kafka/reference/fitness-matrix) and the [capabilities reference](/connectors/kafka/reference/capabilities). ## How share groups differ from classic consumer groups [#how-share-groups-differ-from-classic-consumer-groups] A classic [consumer group](/connectors/kafka/concepts/consumer-groups) assigns whole partitions to members via Join/Sync/Heartbeat — one partition, one owning consumer at a time, with a rebalance whenever membership changes. A share group throws that model out: every member can receive records from every partition, and the unit of ownership is a **single record** (or a contiguous batch), not a partition. There's no partition-assignment protocol to reason about — just heartbeat-based membership (`ShareGroupHeartbeat`) plus per-record acquisition on fetch. That makes a share group behave much more like a KubeMQ **Queue**: multiple workers pull from a shared backlog, a record goes to exactly one worker at a time, and a worker that fails to process it releases the record back for someone else to pick up — see the acquire/acknowledge cycle below. ## Acquire, deliver, acknowledge [#acquire-deliver-acknowledge] Where a classic consumer commits offsets in bulk, a share consumer acknowledges **per record** (or per contiguous batch) with one of three outcomes: | Acknowledgement | Effect | | --------------- | ----------------------------------------------------------------------------------------------------------------- | | **Accept** | Terminal — the record is durably consumed; the group's start-offset advances past it. | | **Release** | The record becomes available for redelivery (to this or another member), with its delivery count incremented. | | **Reject** | Terminal, like Accept, but signals "skip this record" rather than "processed successfully" — it never redelivers. | A record the client never acknowledges is released automatically once the **30-second acquisition lock** — the `AcquisitionLockTimeoutMillis` the connector advertises in every `ShareFetch` response — expires. That's the same outcome as an explicit Release, and it also counts as a delivery attempt. **Redelivery has a limit.** A record redelivered (via Release or a lock timeout) more than **5** times is **archived** — the group's cursor advances past it permanently, so a single poison record can never wedge the partition for everyone else. The record's bytes are untouched; a plain `Fetch` consumer on the same topic still sees it. ## Produce and share-consume [#produce-and-share-consume] Both client libraries below drive the full flow — produce onto a plain topic, then acquire, process, and acknowledge from a share group: ```go package main import ( "context" "fmt" "log" "github.com/twmb/franz-go/pkg/kgo" ) func main() { ctx := context.Background() // A share group has no partition assignment — every member shares the // same pool of records, each one acquired individually. sc, err := kgo.NewClient( kgo.SeedBrokers("localhost:9092"), kgo.ShareGroup("orders-share-group"), kgo.ConsumeTopics("orders"), ) if err != nil { log.Fatal(err) } defer sc.Close() for { fetches := sc.PollFetches(ctx) if errs := fetches.Errors(); len(errs) > 0 { log.Fatal(errs[0].Err) } var accepted []*kgo.Record fetches.EachRecord(func(r *kgo.Record) { fmt.Printf("acquired offset=%d attempt=%d: %s\n", r.Offset, r.DeliveryCount(), r.Value) // process the record here — on failure, MarkAcks with AckRelease // or AckReject instead of AckAccept below. accepted = append(accepted, r) }) sc.MarkAcks(kgo.AckAccept, accepted...) if err := sc.FlushAcks(ctx); err != nil { log.Printf("flush acknowledgements: %v", err) } } } ``` ```java // KafkaShareConsumer is Apache Kafka's own KIP-932 preview client (early // access) — illustrative only. Its API is still evolving, so verify // the exact surface against the Apache Kafka client version you pin. Properties props = new Properties(); props.put("bootstrap.servers", "localhost:9092"); props.put("group.id", "orders-share-group"); props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer"); props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer"); try (KafkaShareConsumer consumer = new KafkaShareConsumer<>(props)) { consumer.subscribe(Collections.singleton("orders")); while (true) { ConsumerRecords records = consumer.poll(Duration.ofMillis(5000)); for (ConsumerRecord record : records) { // process the record here — on failure, acknowledge RELEASE or // REJECT instead of ACCEPT below. consumer.acknowledge(record, AcknowledgeType.ACCEPT); } consumer.commitSync(); // flushes the pending acknowledgements } } ``` **Only these two clients have a share-consumer API today.** `kcat`, `confluent-kafka` (Python), `kafkajs`, `Confluent.Kafka` (C#), and `rdkafka` (Ruby/Rust) have no KIP-932 share-consumer surface yet, so there's no gap-fallback tab to show for them. franz-go is the client the connector's own share-group support was validated against; Java's `KafkaShareConsumer` is Apache Kafka's own early-access preview client. ## Related [#related] # TLS and mTLS (/connectors/kafka/how-to/tls-and-mtls) The Kafka connector opens two listeners: `9092` (plaintext) and `9093` (TLS). This guide covers the TLS listener — server-authentication TLS, mutual TLS, and how the client certificate's common name becomes the authenticated principal when SASL isn't used. **`Port` stays required, even if you only want TLS.** A TLS-only configuration — `TlsPort` set, `Port` left empty — is **rejected**: the TLS accept path isn't wired independently of the plaintext one yet, so `Port` must stay set (and differ from `TlsPort`). Restrict access to the plaintext listener at your network layer (firewall rules, a `ClusterIP`-only Service) instead of by unsetting `Port`. ## The 9093 TLS listener [#the-9093-tls-listener] The TLS listener has no certificate configuration of its own — it reuses the server's **global `Security` block** (`Cert`/`Key`/`Ca`), the same block every other TCP connector (gRPC, REST, STOMP, AMQP, RabbitMQ) shares. There is no `Connectors.Kafka.Tls*Cert` field to set. | Setting | Env var | Default | Notes | | -------------- | ----------------------------------------------- | ------- | --------------------------------------------------------------------------- | | Plaintext port | `CONNECTORS_KAFKA_PORT` | `9092` | Must stay set — see the callout above. | | TLS port | `CONNECTORS_KAFKA_TLS_PORT` | `9093` | Set to `""` (empty) to disable the TLS listener; otherwise must be 1–65535. | | Server cert | `SECURITY_CERT_DATA` / `SECURITY_CERT_FILENAME` | `""` | Required for TLS and mTLS. | | Server key | `SECURITY_KEY_DATA` / `SECURITY_KEY_FILENAME` | `""` | Required for TLS and mTLS. | | Client CA | `SECURITY_CA_DATA` / `SECURITY_CA_FILENAME` | `""` | Presence **promotes the mode to mTLS** — see below. | The mode is auto-derived, not a flag: `Cert` + `Key` alone is server-authentication **TLS**; adding `Ca` promotes it to **mTLS**. See [Security → TLS / mTLS](/configure/reference/security#tls--mtls) for the full field reference and Docker/Helm examples. **If you expose 9093 externally, the certificate must cover `AdvertisedHost`.** The connector serves one certificate with no per-SNI selection — a certificate whose SAN covers only the in-cluster Service DNS fails TLS hostname verification for an external client connecting through the advertised address. ## Server-authentication TLS [#server-authentication-tls] With `Cert` + `Key` set (no `Ca`), the server presents its certificate and the minimum negotiated protocol is **TLS 1.2**; no client certificate is requested. The client authenticates separately — with SASL/PLAIN, SCRAM, or OAUTHBEARER over the now-encrypted channel — see [Authentication](/connectors/kafka/how-to/authentication). ```bash kcat -b localhost:9093 -L \ -X security.protocol=SSL \ -X ssl.ca.location=/path/to/ca.pem ``` ```go import ( "crypto/tls" "crypto/x509" "os" "github.com/twmb/franz-go/pkg/kgo" ) caPEM, _ := os.ReadFile("/path/to/ca.pem") pool := x509.NewCertPool() pool.AppendCertsFromPEM(caPEM) cl, err := kgo.NewClient( kgo.SeedBrokers("localhost:9093"), kgo.DialTLSConfig(&tls.Config{RootCAs: pool}), ) ``` ```properties security.protocol=SSL ssl.truststore.location=/path/to/truststore.jks ssl.truststore.password=changeit ``` ## Mutual TLS — certificate identity [#mutual-tls--certificate-identity] Adding `Ca` to the `Security` block promotes the listener to **mTLS**: the server now requires and verifies a client certificate (`RequireAndVerifyClientCert`) using that CA pool, on every connection to 9093. A client that fails the chain check never completes the handshake. When the connection carries no SASL layer, the connector reads the verified certificate's **leaf `Subject.CommonName`** as the authenticated principal — the same identity the Casbin ACL then authorizes against (see [Authentication](/connectors/kafka/how-to/authentication)). An empty CN, or a certificate whose chain didn't verify, never becomes a principal. ```bash kcat -b localhost:9093 -L \ -X security.protocol=SSL \ -X ssl.ca.location=/path/to/ca.pem \ -X ssl.key.location=/path/to/client.key \ -X ssl.certificate.location=/path/to/client.crt ``` ```go clientCert, _ := tls.LoadX509KeyPair("/path/to/client.crt", "/path/to/client.key") cl, err := kgo.NewClient( kgo.SeedBrokers("localhost:9093"), kgo.DialTLSConfig(&tls.Config{ RootCAs: pool, // the CA that signed the SERVER certificate Certificates: []tls.Certificate{clientCert}, }), ) ``` ```properties security.protocol=SSL ssl.truststore.location=/path/to/truststore.jks ssl.truststore.password=changeit ssl.keystore.location=/path/to/client-keystore.jks ssl.keystore.password=changeit ssl.key.password=changeit ``` **mTLS and SASL don't stack.** Only one identity source wins per connection: the certificate CN is used **only when SASL isn't required**. If the listener also has `Credentials` configured, the SASL identity takes precedence and the CN is never consulted — see [Authentication](/connectors/kafka/how-to/authentication). ## Related [#related] # Transactions & EOS (/connectors/kafka/how-to/transactions) Exactly-once semantics (EOS) on the Kafka connector runs on the same coordinator protocol real Kafka uses: a transactional producer completes `InitProducerId` → `AddPartitionsToTxn` → transactional `Produce` → `EndTxn(commit|abort)`, a `read_committed` consumer never sees an aborted record, and a stale producer instance is fenced rather than silently allowed to keep writing. This page is the practical how-to; the full API-key/version table and the KIP-890 scope note live at [Capabilities](/connectors/kafka/reference/capabilities), and every wire error code at [Error Codes](/connectors/kafka/reference/error-codes). **EOS is V1 scope — no KIP-890 transaction protocol V2.** `EndTxn` writes a real in-log COMMIT/ABORT control marker and gives the same `(PID, epoch)` fencing real Kafka's coordinator does, but the producer epoch is **not** bumped on every `EndTxn` the way TV2 (`transaction.version=2`) requires. The practical residual: a stray, delayed produce from the *same* epoch, arriving after that transaction's own `EndTxn` has already resolved, can still be admitted into the producer's *next* transaction — the same upstream-shared ceiling any Kafka-protocol clone inherits until it implements TV2. See [Capabilities](/connectors/kafka/reference/capabilities) for the full scope statement. ## The transactional producer round-trip [#the-transactional-producer-round-trip] Set a stable `transactional.id`, and the client library drives the coordinator handshake for you — your code only calls begin, produce, and end. `kcat` drives a transaction only as a whole batch (it begins on the first record and commits when its input stream closes), which doesn't fit this step-by-step begin/produce/end walkthrough; the pinned Ruby client (`rdkafka`, the `karafka/rdkafka-ruby` gem) doesn't expose a transactional producer API at all today — both are omitted below rather than faked. `InitProducerId` itself is never called directly: the first begin-transaction call issues it for you, allocating the `(PID, epoch)` pair every subsequent call in this session is fenced against. ```go package main import ( "context" "log" "github.com/twmb/franz-go/pkg/kgo" ) func main() { ctx := context.Background() cl, err := kgo.NewClient( kgo.SeedBrokers("localhost:9092"), kgo.TransactionalID("orders-producer"), ) if err != nil { log.Fatalf("client: %v", err) } defer cl.Close() // BeginTransaction lazily drives InitProducerId on the first call. if err := cl.BeginTransaction(); err != nil { log.Fatalf("begin transaction: %v", err) } record := &kgo.Record{Topic: "orders", Value: []byte("txn-value")} if err := cl.ProduceSync(ctx, record).FirstErr(); err != nil { _ = cl.EndTransaction(ctx, kgo.TryAbort) log.Fatalf("produce: %v", err) } if err := cl.EndTransaction(ctx, kgo.TryCommit); err != nil { log.Fatalf("commit: %v", err) } log.Println("committed: txn-value") } ``` ```python from confluent_kafka import KafkaException, Producer producer = Producer({ "bootstrap.servers": "localhost:9092", "transactional.id": "orders-producer", }) producer.init_transactions() producer.begin_transaction() try: producer.produce("orders", value=b"txn-value") producer.commit_transaction() print("committed: txn-value") except KafkaException as err: if err.args[0].txn_requires_abort(): producer.abort_transaction() else: raise ``` ```java import org.apache.kafka.clients.producer.KafkaProducer; import org.apache.kafka.clients.producer.ProducerRecord; import org.apache.kafka.common.errors.ProducerFencedException; import org.apache.kafka.common.serialization.StringSerializer; import java.util.Properties; public final class TransactionalProduce { public static void main(String[] args) { Properties props = new Properties(); props.put("bootstrap.servers", "localhost:9092"); props.put("transactional.id", "orders-producer"); props.put("key.serializer", StringSerializer.class.getName()); props.put("value.serializer", StringSerializer.class.getName()); try (KafkaProducer producer = new KafkaProducer<>(props)) { producer.initTransactions(); try { producer.beginTransaction(); producer.send(new ProducerRecord<>("orders", "txn-value")); producer.commitTransaction(); System.out.println("committed: txn-value"); } catch (ProducerFencedException fenced) { // A zombie holding a stale (PID, epoch) cannot recover from this — give up. throw fenced; } catch (Exception e) { producer.abortTransaction(); } } } } ``` ```javascript const { Kafka } = require("kafkajs"); const kafka = new Kafka({ brokers: ["localhost:9092"] }); const producer = kafka.producer({ transactionalId: "orders-producer", maxInFlightRequests: 1, idempotent: true, }); async function main() { await producer.connect(); const transaction = await producer.transaction(); try { await transaction.send({ topic: "orders", messages: [{ value: "txn-value" }] }); await transaction.commit(); console.log("committed: txn-value"); } catch (err) { await transaction.abort(); throw err; } finally { await producer.disconnect(); } } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using Confluent.Kafka; var config = new ProducerConfig { BootstrapServers = "localhost:9092", TransactionalId = "orders-producer", }; using var producer = new ProducerBuilder(config).Build(); producer.InitTransactions(TimeSpan.FromSeconds(10)); producer.BeginTransaction(); try { producer.Produce("orders", new Message { Value = "txn-value" }); producer.CommitTransaction(); Console.WriteLine("committed: txn-value"); } catch (KafkaException) { producer.AbortTransaction(); throw; } ``` ```rust use rdkafka::config::ClientConfig; use rdkafka::producer::{BaseProducer, BaseRecord, Producer}; use rdkafka::util::Timeout; use std::time::Duration; fn main() { let producer: BaseProducer = ClientConfig::new() .set("bootstrap.servers", "localhost:9092") .set("transactional.id", "orders-producer") .set("enable.idempotence", "true") .create() .expect("producer creation failed"); producer.init_transactions(Timeout::Never).expect("init_transactions failed"); producer.begin_transaction().expect("begin_transaction failed"); producer .send(BaseRecord::to("orders").payload("txn-value").key("order-1")) .expect("send failed"); producer.flush(Duration::from_secs(10)).expect("flush failed"); match producer.commit_transaction(Timeout::Never) { Ok(()) => println!("committed: txn-value"), Err(err) => { eprintln!("commit failed, aborting: {err}"); producer .abort_transaction(Duration::from_secs(10)) .expect("abort_transaction failed"); } } } ``` ## `read_committed` isolation and the Last Stable Offset [#read_committed-isolation-and-the-last-stable-offset] A `read_committed` consumer never gets handed an aborted record. Internally, `Fetch` computes a **Last Stable Offset (LSO)** — the offset up to which every transaction has already decided — and, under `read_committed`, clamps what it serves to that boundary; `ListOffsets(latest)` returns the LSO instead of the high watermark while a transaction is still open. The filtering itself happens **client-side**: the broker still serves the raw aborted batch below the LSO, tagged in `AbortedTransactions`, and a conforming `read_committed` client (Java's `Fetcher`, franz-go) drops those records itself — never a server-side record filter. Setting the isolation level is a one-line client config on any librdkafka-based client: ```python consumer = Consumer({ "bootstrap.servers": "localhost:9092", "group.id": "orders-consumer", "isolation.level": "read_committed", # default is read_uncommitted }) ``` `kcat` exposes the same librdkafka property through `-X`, which makes it a handy way to verify `read_committed` isolation behavior on the consumer side: ```bash kcat -C -b localhost:9092 -t orders -G orders-consumer -X isolation.level=read_committed ``` kafkajs takes a different shape for the same setting — a boolean `readUncommitted` option on the consumer (default `false`, i.e. `read_committed` behavior), rather than a string-valued `isolation.level`. ## Consume-transform-produce [#consume-transform-produce] A consume-transform-produce loop needs one more coordinator round-trip beyond a plain transactional produce: the consumer's **input offsets** have to commit atomically with the **output records**, or a crash between the two would either lose or double-process a batch. `AddOffsetsToTxn` adds the consumer group's offset-commit partition to the open transaction, and `TxnOffsetCommit` stages the offsets themselves — both are resolved on `EndTxn(commit)` alongside the produced records, and both are discarded together on `EndTxn(abort)`. franz-go wraps the whole pattern in `GroupTransactSession`, so application code never calls `AddOffsetsToTxn`/`TxnOffsetCommit` directly — `Begin`/`End` drive them: ```go package main import ( "context" "log" "github.com/twmb/franz-go/pkg/kgo" ) func main() { ctx := context.Background() sess, err := kgo.NewGroupTransactSession( kgo.SeedBrokers("localhost:9092"), kgo.TransactionalID("orders-etl"), kgo.ConsumerGroup("orders-group"), kgo.ConsumeTopics("orders"), ) if err != nil { log.Fatalf("session: %v", err) } defer sess.Close() for { fetches := sess.PollFetches(ctx) if errs := fetches.Errors(); len(errs) > 0 { log.Fatalf("fetch: %v", errs) } if err := sess.Begin(); err != nil { log.Fatalf("begin: %v", err) } fetches.EachRecord(func(r *kgo.Record) { sess.Produce(ctx, &kgo.Record{Topic: "orders-processed", Value: r.Value}, nil) }) // End commits the produced records AND the consumed offsets atomically // (AddOffsetsToTxn + TxnOffsetCommit happen here), or aborts both together. if _, err := sess.End(ctx, kgo.TryCommit); err != nil { log.Fatalf("end: %v", err) } } } ``` The other pinned clients expose the same two-request pattern as a single call on the producer, taking the consumer's group metadata as an argument: | Client | Offset-commit call | | ------------------------ | ----------------------------------------------------------------------------------- | | Go (franz-go) | `GroupTransactSession.End` (wraps `AddOffsetsToTxn`+`TxnOffsetCommit` internally) | | Python (confluent-kafka) | `producer.send_offsets_to_transaction(offsets, consumer.consumer_group_metadata())` | | Java (kafka-clients) | `producer.sendOffsetsToTransaction(offsets, consumer.groupMetadata())` | | JavaScript (kafkajs) | `transaction.sendOffsets({ consumerGroupId, topics })` | | C# (Confluent.Kafka) | `producer.SendOffsetsToTransaction(offsets, consumerGroupMetadata, timeout)` | | Rust (rdkafka) | `producer.send_offsets_to_transaction(&tpl, &consumer.group_metadata(), timeout)` | See [Consuming](/connectors/kafka/how-to/consuming) for manual offset control outside a transaction, and [Consumer Groups](/connectors/kafka/concepts/consumer-groups) for the group protocol underneath. **Consume-transform-produce needs consumer-group Write, not Read.** Real Kafka authorizes `AddOffsetsToTxn`/`TxnOffsetCommit` against the group's Read ACL. This connector requires **Write** on that route instead. A standard EOS client — `GroupTransactSession`, `sendOffsetsToTransaction`, `send_offsets_to_transaction` — commits its input offsets *only* through the producer's transaction and never calls plain `OffsetCommit`, so if the group principal has only Read, the first `TxnOffsetCommit` fails fatally with `GROUP_AUTHORIZATION_FAILED`(30). Grant the consumer group Write before running a consume-transform-produce workload against an authorized cluster. ## Producer fencing [#producer-fencing] Two producer instances sharing the same `transactional.id` — most commonly an application restarted without a clean shutdown of the previous instance — can't both be authoritative. Each successful `InitProducerId` bumps the epoch on record for that `transactional.id`; once a newer instance has taken over, the older one is **fenced**, not silently allowed to keep writing: | Code | Error | Where it fires | | ---- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | 47 | `INVALID_PRODUCER_EPOCH` | A `Produce` arrives carrying an epoch below the live `(PID, epoch)` on record — the zombie's own writes are the giveaway. Non-retriable. | | 90 | `PRODUCER_FENCED` | An `InitProducerId` names an epoch strictly above the live durable epoch, surfacing the same outcome on the coordinator RPC path instead of `Produce`. | Both are terminal for that producer instance: there is no retry that fixes a fenced producer short of the application creating a brand-new one. A related, non-fencing limit worth setting sensibly: the server enforces a configurable ceiling on the client's `transaction.timeout.ms` — `900000` (15 min) by default, operator-adjustable up to `86400000` (24 h) — and a negotiated timeout above that ceiling answers `INVALID_TRANSACTION_TIMEOUT`(50) at `InitProducerId`. See [Configuration reference](/configure/reference/connectors#kafka) for the exact field names, and [Error Codes](/connectors/kafka/reference/error-codes) for the rest of the transaction coordinator's error surface — `INVALID_TXN_STATE`(48), `CONCURRENT_TRANSACTIONS`(51), and `TRANSACTIONAL_ID_AUTHORIZATION_FAILED`(53) among them. ## Related [#related] # Getting Started (/connectors/kafka/tutorials/getting-started) Get a message flowing through the KubeMQ Kafka connector in minutes. You point a stock Kafka client — `kcat`, or any of seven client libraries — at KubeMQ by repointing `bootstrap.servers`, produce one record to a topic, and consume it back with a consumer group. There's no KubeMQ SDK and no client-library swap: the connector speaks the real Produce/Fetch/group-coordinator wire protocol, so any unmodified Kafka client just works. By the end of this page you'll have run a complete produce-then-consume round-trip against a local KubeMQ server, using whichever client you already have installed. ## Prerequisites [#prerequisites] * A running **kubemq-server**, with the Kafka connector **enabled** and reachable on **port 9092** (plain TCP) — the step below shows how. * One of the eight clients in the tabs below: `kcat`, or a client library for your language. There is no KubeMQ SDK — every example on this page is a stock, unmodified Kafka client. ### Enable the connector [#enable-the-connector] The Kafka connector is **disabled by default** — a stock kubemq-server does **not** bind ports `9092`/`9093` until you turn it on. Enable it with its enable variable: **The enable variable is `CONNECTORS_KAFKA_ENABLE`.** A stock server does not serve the Kafka wire protocol until you set this to `true`. For Kubernetes, set `spec.kafka.enabled: true` in the `KubemqCluster` CR (Helm: `kafka.enabled: true` in your values file). Once enabled, the connector binds two listeners — `9092` for plain TCP and `9093` for TLS — and every produced record lands on KubeMQ's auto-selected `next` storage engine. You don't need to configure this yourself: on a fresh store, enabling Kafka **auto-selects `next`** with no manual `store.engine` step. See [Storage Engines → Zero-config engine selection](/configure/reference/storage-engines#zero-config-engine-selection) for the full selection rules, including what happens on an existing store. You also don't need to pre-create the `orders` topic used below — the connector auto-creates a topic on its first `Produce` or `Fetch` call, the same as real Kafka's `auto.create.topics.enable` default. ### Produce a message [#produce-a-message] Every example below produces one record with the value `hello kubemq` to the topic `orders` on `bootstrap.servers=localhost:9092` (or, for `kcat`, `-b localhost:9092`). No KubeMQ SDK — just a stock Kafka client, repointed. `kcat` (the librdkafka CLI) needs no client code at all — pipe the payload straight to the broker: ```bash echo "hello kubemq" | kcat -b localhost:9092 -t orders -P ``` The Go example uses `franz-go`, the client this connector's own conformance harness is validated against: ```go package main import ( "context" "log" "github.com/twmb/franz-go/pkg/kgo" ) func main() { client, err := kgo.NewClient( kgo.SeedBrokers("localhost:9092"), kgo.DefaultProduceTopic("orders"), ) if err != nil { log.Fatalf("client: %v", err) } defer client.Close() record := &kgo.Record{Value: []byte("hello kubemq")} if err := client.ProduceSync(context.Background(), record).FirstErr(); err != nil { log.Fatalf("produce: %v", err) } log.Println("produced: hello kubemq") } ``` ```python from confluent_kafka import Producer producer = Producer({"bootstrap.servers": "localhost:9092"}) def delivery_report(err, msg): if err is not None: raise RuntimeError(f"delivery failed: {err}") print(f"produced: {msg.value().decode()} (partition {msg.partition()}, offset {msg.offset()})") producer.produce("orders", value=b"hello kubemq", callback=delivery_report) producer.flush(10) ``` ```java import org.apache.kafka.clients.producer.KafkaProducer; import org.apache.kafka.clients.producer.ProducerRecord; import org.apache.kafka.clients.producer.RecordMetadata; import org.apache.kafka.common.serialization.StringSerializer; import java.util.Properties; import java.util.concurrent.ExecutionException; public final class Produce { public static void main(String[] args) throws ExecutionException, InterruptedException { Properties props = new Properties(); props.put("bootstrap.servers", "localhost:9092"); props.put("key.serializer", StringSerializer.class.getName()); props.put("value.serializer", StringSerializer.class.getName()); try (KafkaProducer producer = new KafkaProducer<>(props)) { RecordMetadata meta = producer.send(new ProducerRecord<>("orders", "hello kubemq")).get(); System.out.printf("produced: hello kubemq (partition %d, offset %d)%n", meta.partition(), meta.offset()); } } } ``` ```javascript const { Kafka } = require("kafkajs"); const kafka = new Kafka({ brokers: ["localhost:9092"] }); const producer = kafka.producer(); async function main() { await producer.connect(); await producer.send({ topic: "orders", messages: [{ value: "hello kubemq" }], }); console.log("produced: hello kubemq"); await producer.disconnect(); } main().catch((err) => { console.error("produce failed:", err); process.exit(1); }); ``` ```csharp using Confluent.Kafka; var config = new ProducerConfig { BootstrapServers = "localhost:9092" }; using var producer = new ProducerBuilder(config).Build(); var result = await producer.ProduceAsync("orders", new Message { Value = "hello kubemq" }); Console.WriteLine($"produced: hello kubemq (partition {result.Partition}, offset {result.Offset})"); ``` ```ruby require "rdkafka" config = Rdkafka::Config.new("bootstrap.servers" => "localhost:9092") producer = config.producer handle = producer.produce(topic: "orders", payload: "hello kubemq") handle.wait(max_wait_timeout_ms: 10_000) puts "produced: hello kubemq" producer.close ``` ```rust use rdkafka::config::ClientConfig; use rdkafka::producer::{FutureProducer, FutureRecord}; use std::time::Duration; #[tokio::main] async fn main() { let producer: FutureProducer = ClientConfig::new() .set("bootstrap.servers", "localhost:9092") .create() .expect("producer creation failed"); let record = FutureRecord::::to("orders").payload("hello kubemq"); match producer.send(record, Duration::from_secs(10)).await { Ok((partition, offset)) => { println!("produced: hello kubemq (partition {partition}, offset {offset})") } Err((err, _)) => eprintln!("produce failed: {err}"), } } ``` Whichever client you ran, the connector auto-created the `orders` topic on that first write and appended your record to it. The next step reads it back. ### Consume and verify [#consume-and-verify] Consume the record back with a consumer group named `orders-group`. Each example joins the group, reads one record, and prints its value alongside the partition and offset the connector assigned — the offset maps one-to-one onto the underlying Events Store `Sequence`, durable and stable across a restart. `-G` puts `kcat` into consumer-group mode; `-c 1` exits after one message: ```bash kcat -b localhost:9092 -G orders-group -c 1 orders ``` ```go package main import ( "context" "fmt" "log" "time" "github.com/twmb/franz-go/pkg/kgo" ) func main() { client, err := kgo.NewClient( kgo.SeedBrokers("localhost:9092"), kgo.ConsumerGroup("orders-group"), kgo.ConsumeTopics("orders"), ) if err != nil { log.Fatalf("client: %v", err) } defer client.Close() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() fetches := client.PollFetches(ctx) if errs := fetches.Errors(); len(errs) > 0 { log.Fatalf("fetch: %v", errs) } fetches.EachRecord(func(record *kgo.Record) { fmt.Printf("consumed: %s (partition %d, offset %d)\n", record.Value, record.Partition, record.Offset) }) } ``` ```python from confluent_kafka import Consumer consumer = Consumer({ "bootstrap.servers": "localhost:9092", "group.id": "orders-group", "auto.offset.reset": "earliest", }) consumer.subscribe(["orders"]) msg = consumer.poll(10.0) if msg is None: raise SystemExit("no message received within timeout") if msg.error(): raise RuntimeError(msg.error()) print(f"consumed: {msg.value().decode()} (partition {msg.partition()}, offset {msg.offset()})") consumer.close() ``` ```java import org.apache.kafka.clients.consumer.ConsumerRecord; import org.apache.kafka.clients.consumer.ConsumerRecords; import org.apache.kafka.clients.consumer.KafkaConsumer; import org.apache.kafka.common.serialization.StringDeserializer; import java.time.Duration; import java.util.List; import java.util.Properties; public final class Consume { public static void main(String[] args) { Properties props = new Properties(); props.put("bootstrap.servers", "localhost:9092"); props.put("group.id", "orders-group"); props.put("key.deserializer", StringDeserializer.class.getName()); props.put("value.deserializer", StringDeserializer.class.getName()); props.put("auto.offset.reset", "earliest"); try (KafkaConsumer consumer = new KafkaConsumer<>(props)) { consumer.subscribe(List.of("orders")); ConsumerRecords records = consumer.poll(Duration.ofSeconds(10)); for (ConsumerRecord record : records) { System.out.printf("consumed: %s (partition %d, offset %d)%n", record.value(), record.partition(), record.offset()); } } } } ``` ```javascript const { Kafka } = require("kafkajs"); const kafka = new Kafka({ brokers: ["localhost:9092"] }); const consumer = kafka.consumer({ groupId: "orders-group" }); async function main() { await consumer.connect(); await consumer.subscribe({ topic: "orders", fromBeginning: true }); await consumer.run({ eachMessage: async ({ partition, message }) => { console.log(`consumed: ${message.value.toString()} (partition ${partition}, offset ${message.offset})`); await consumer.disconnect(); }, }); } main().catch((err) => { console.error("consume failed:", err); process.exit(1); }); ``` ```csharp using Confluent.Kafka; var config = new ConsumerConfig { BootstrapServers = "localhost:9092", GroupId = "orders-group", AutoOffsetReset = AutoOffsetReset.Earliest, }; using var consumer = new ConsumerBuilder(config).Build(); consumer.Subscribe("orders"); var result = consumer.Consume(TimeSpan.FromSeconds(10)); if (result is null) { throw new TimeoutException("no message received within timeout"); } Console.WriteLine($"consumed: {result.Message.Value} (partition {result.Partition}, offset {result.Offset})"); consumer.Close(); ``` ```ruby require "rdkafka" config = Rdkafka::Config.new( "bootstrap.servers" => "localhost:9092", "group.id" => "orders-group", "auto.offset.reset" => "earliest" ) consumer = config.consumer consumer.subscribe("orders") message = consumer.poll(10_000) raise "no message received within timeout" if message.nil? puts "consumed: #{message.payload} (partition #{message.partition}, offset #{message.offset})" consumer.close ``` ```rust use rdkafka::config::ClientConfig; use rdkafka::consumer::{BaseConsumer, Consumer}; use rdkafka::message::Message; use std::time::Duration; fn main() { let consumer: BaseConsumer = ClientConfig::new() .set("bootstrap.servers", "localhost:9092") .set("group.id", "orders-group") .set("auto.offset.reset", "earliest") .create() .expect("consumer creation failed"); consumer.subscribe(&["orders"]).expect("subscribe failed"); match consumer.poll(Duration::from_secs(10)) { Some(Ok(message)) => { let payload = message .payload() .map(|p| String::from_utf8_lossy(p).to_string()) .unwrap_or_default(); println!("consumed: {payload} (partition {}, offset {})", message.partition(), message.offset()); } Some(Err(err)) => eprintln!("consume failed: {err}"), None => eprintln!("no message received within timeout"), } } ``` A successful round-trip prints the record you produced, plus the partition and offset the connector assigned it: ```text produced: hello kubemq consumed: hello kubemq (partition 0, offset 0) ``` You just repointed a stock Kafka client at KubeMQ, produced a record, and consumed it back through a real consumer group — the same round-trip you'd run against any Kafka cluster, with no client-library swap and no code change beyond `bootstrap.servers`. From here, dig into a single feature end-to-end — producing with keys and durability guarantees, consuming with manual offset control, or how topics and partitions map onto KubeMQ's storage layer. ## Next steps [#next-steps] # Architecture (/connectors/mqtt/concepts/architecture) The KubeMQ **MQTT connector** is an embedded MQTT broker that runs inside kubemq-server. It speaks the standard MQTT protocol (3.1.1 and 5.0) on plain port **1883**, TLS port **8883**, and WebSocket port **8083** (path `/`). The connector is **opt-in (disabled by default)** — enable it with `CONNECTORSMQTT_ENABLE=true` (Docker) or `spec.mqtt.enabled: true` (Kubernetes). Any standard MQTT client connects to it with only a broker-address change — no code rewrite, no library swap, no KubeMQ SDK. Unlike the RabbitMQ (AMQP 0-9-1) connector — which bridges onto exactly one KubeMQ primitive — the MQTT connector bridges onto **all five** KubeMQ patterns: Events, Events-Store, Queues, Commands, and Queries. The **first segment of the topic** selects which pattern a publish or subscribe is bound to. That single fact drives the whole mental model. ## Protocol stack [#protocol-stack] A client connection terminates at the embedded broker. A bridge hook intercepts the MQTT CONNECT / PUBLISH / SUBSCRIBE packets, the topic mapper resolves each topic to a KubeMQ `(pattern, channel)` pair, and the request is dispatched to KubeMQ's core service, which hands it to the message broker. From there it reaches consumers on any KubeMQ transport. *The embedded broker terminates the MQTT connection; the bridge hook and topic mapper translate each packet onto a KubeMQ pattern and channel, and the message broker fans it out to every other KubeMQ transport.* ## How MQTT maps to KubeMQ [#how-mqtt-maps-to-kubemq] The first topic segment (the **prefix**) selects the KubeMQ pattern. The remaining segments become the KubeMQ channel, with `/` translated to `.`. *The topic prefix selects the KubeMQ pattern; the remaining segments form the channel with `/` translated to `.`.* | MQTT topic / filter | KubeMQ pattern | Direction | Example topic | KubeMQ channel | | ---------------------------- | -------------------------------------- | ------------------------------------- | ----------------------------- | -------------- | | `events/` | **Events** | publish + subscribe | `events/site1/temp` | `site1.temp` | | `store/` | **Events-Store** | publish + subscribe (StartNewOnly) | `store/site1/temp` | `site1.temp` | | `queues/` | **Queues** | **publish only** (produce) | `queues/jobs/email` | `jobs.email` | | `commands/` | **Commands (RPC)** | **publish only**, MQTT 5.0 only | `commands/svc/reboot` | `svc.reboot` | | `queries/` | **Queries (RPC)** | **publish only**, MQTT 5.0 only | `queries/svc/status` | `svc.status` | | `$share//queues/` | **Queues** | **subscribe only** (consume), QoS ≥ 1 | `$share/g1/queues/jobs/email` | `jobs.email` | | `$reply//` | broker-local | subscribe (own namespace only) | `$reply/client1/inbox` | not routed | | *(prefixless)* | `DefaultPattern` (`events` by default) | publish + subscribe | `site1/temp` | `site1.temp` | Resolution rules: * **Translation rule:** topic path separators `/` become KubeMQ channel dot separators `.`. `events/site1/sensors/temp` → channel `site1.sensors.temp`. * **Prefixless topics** route to the configured `DefaultPattern` (`events` by default; `store` or `none` are the alternatives). With `none`, a prefixless publish returns PUBACK `0x90` and a prefixless subscribe returns SUBACK `0x8F`. * **A literal `.` inside a topic segment is not escaped** — it passes through unchanged and conflates with `/`. Both `events/a.b/c` and `events/a/b/c` map to channel `a.b.c`. Avoid dots in topic segments. See [Topic grammar](/connectors/mqtt/reference/topic-grammar) for the master table and [Topic mapping](/connectors/mqtt/concepts/topic-mapping) for narrative guidance. ## Wildcard translation [#wildcard-translation] Wildcards are permitted **only** on Events subscriptions. A wildcard subscribe on any other pattern returns SUBACK `0xA2`. | MQTT wildcard | KubeMQ wildcard | Constraint | | ------------------ | --------------- | ------------------------- | | `+` (single level) | `*` | any segment position | | `#` (multi level) | `>` | must be the final segment | For example, `events/site1/+` subscribes to the channel filter `site1.*`, and `events/#` subscribes to `>`. A `store/#` subscribe is rejected with SUBACK `0xA2` because wildcards are Events-only. **Overlapping wildcard filters multiply delivery.** Each distinct subscribe filter creates an independent bridge registry entry. If several of a client's subscriptions match the same published message, the client receives one copy per matching entry — there is no cross-entry deduplication. Subscribing to `#`, `events/leak/x`, and `events/#` at once delivers **three copies** of a publish to `events/leak/x`. ## The bridge hook and topic mapper [#the-bridge-hook-and-topic-mapper] The embedded broker delegates every protocol decision to a single **bridge hook**: * **CONNECT** — authenticates the connection (MQTT username / password; the identity is the ClientID) and enforces the protocol-version floor. See [Authentication](/connectors/mqtt/how-to/authentication). * **PUBLISH** — the topic mapper resolves the prefix to a pattern and channel; the hook dispatches the message to the KubeMQ core (Events / Events-Store / Queues produce, or an RPC request for Commands / Queries) and translates the result into a PUBACK reason code. * **SUBSCRIBE** — the hook registers the filter in the subscription registry (with wildcard fan-out for Events), or wires a Queue consumer for a `$share//queues/` filter, and returns the SUBACK reason codes. Two dedicated bridges handle the stateful patterns: a **queue bridge** drives shared- subscription polling, PUBACK-driven acknowledgment, and redelivery for Queues; an **RPC bridge** dispatches Commands / Queries requests, routes the response back over the client's response topic, and tracks pending requests against `RpcMaxPending`. ### User Properties ↔ KubeMQ Tags [#user-properties--kubemq-tags] MQTT 5.0 User Properties map bidirectionally to KubeMQ message Tags. This mapping is **MQTT 5.0 only** — MQTT 3.1.1 has no user-properties, so nothing is carried in either direction over a v3.1.1 connection. | Direction | MQTT side | KubeMQ side | Notes | | ------------------------------ | ----------------- | ----------- | ------------------------------------------- | | Inbound (publish → KubeMQ) | `Properties.User` | `Tags` map | copied 1:1; duplicate keys: last-wins | | Outbound (KubeMQ → subscriber) | `Properties.User` | `Tags` map | injected on delivery to v5 subscribers only | Per-message caps apply to Events, Events-Store, and Queues publishes: **32 properties** and **4096 bytes total** (all keys + values). Exceeding either cap rejects the message — PUBACK `0x97` on v5, or a silent drop on v3.1.1. ## Cross-protocol interop [#cross-protocol-interop] The KubeMQ core service is the shared message bus for all KubeMQ connectors, so MQTT interoperates transparently with every other transport. An MQTT publish to `events/it/cross` is received by a gRPC `SubscribeEvents` on channel `it.cross`, and vice-versa. The same holds for Events-Store, Queues, Commands, and Queries — any connector (gRPC, REST, CloudEvents, MQTT) can interoperate on the same channels. *The same KubeMQ channel backs both connectors, so an MQTT client and a gRPC/REST client interoperate transparently.* ## Related [#related] # Configuration (/connectors/mqtt/concepts/configuration) The MQTT connector is configured server-side through the `MqttConfig` and `MqttCapabilitiesConfig` structs under the `Connectors.MQTT.*` namespace of the KubeMQ server config. The connector is **opt-in (disabled by default)** — you must explicitly enable it. It ships with sensible production defaults, so once enabled no other env var is required. See the [Configuration reference](/connectors/mqtt/reference/configuration) for the full field tables, validation rules, and TOML/env/Docker examples. The only thing **clients** configure is the broker endpoint via the `KUBEMQ_MQTT_URL` environment variable (default `tcp://localhost:1883`); the URL scheme selects the transport (`tcp://`, `tls://`, `ws://`). Everything below is broker-side server configuration. ## Enable / disable [#enable--disable] Enable the connector with its enable variable: To turn it **off** again: **The enable variable is `CONNECTORSMQTT_ENABLE` — there is no underscore between `CONNECTORS` and `MQTT`, and no `KUBEMQ_` prefix.** Every MQTT setting uses this `CONNECTORSMQTT_*` prefix. This is irregular — most other KubeMQ env vars carry a separator — so variants like `CONNECTORS_MQTT_ENABLE` or `KUBEMQ_MQTT_ENABLE` do **not** bind to the `Connectors.MQTT.Enable` field and are silently ignored. When `Enable` is `false`, no MQTT listener binds and the rest of the MQTT config is skipped. ## Forced capabilities [#forced-capabilities] Three capabilities are **always forced regardless of config** — they are not settable. See the [forced capabilities table](/connectors/mqtt/reference/configuration#forced-capabilities) for the exact values. **Retain is silently dropped.** Because `RetainAvailable=0` is advertised, a well-behaved client library refuses a retained publish at the library level. If a library does not check (or you set the flag on a raw publish), the broker strips the retain flag, returns PUBACK `0x00`, and **drops the message** — it is never delivered or stored, and the `publish.error` metric is incremented. This is **not** a DISCONNECT; CONNACK `0x9A` is returned only for a Will-retain requested at CONNECT time. See [QoS and sessions](/connectors/mqtt/concepts/qos-and-sessions). ## TLS [#tls] TLS for the MQTT connector is driven entirely by the **server-global `Security` block** (the same one the gRPC and REST listeners use), not by an MQTT-specific certificate field. When `Security` is configured, the TLS listener binds on `8883` and the WebSocket listener is upgraded to `wss://`. Without it, port `8883` is open but the listener stays inactive. See [TLS and WebSocket](/connectors/mqtt/how-to/tls-and-websocket) and [Auth & security](/connectors/reference/auth-and-security). ## Related [#related] # Protocol versions (/connectors/mqtt/concepts/protocol-versions) The KubeMQ MQTT connector supports **MQTT 3.1.1 (protocol level 4)** and **MQTT 5.0 (protocol level 5)**. MQTT 5.0 is the default and recommended version — it unlocks RPC, Queue consumption, User Properties, and richer error reporting. MQTT 3.1.1 is retained for compatibility with existing clients and tooling. MQTT 3.1 (protocol level 3) is **rejected at connect time**. Pick **MQTT 5.0** for any new client. Three KubeMQ capabilities — RPC (Commands/Queries), Queue **consume** (`$share`), and User Properties ↔ Tags — exist **only** on MQTT 5.0, because the 3.1.1 wire format lacks the properties they rely on (`ResponseTopic`, `CorrelationData`, `$share` shared subscriptions, user-properties). ## Feature matrix [#feature-matrix] | Feature | MQTT 3.1.1 | MQTT 5.0 | Notes | | ----------------------------------------------- | --------------------------- | --------------------------- | --------------------------------------------------------------------------------- | | Events publish + subscribe | Yes | Yes | | | Events-Store publish + subscribe (StartNewOnly) | Yes | Yes | No historical replay over MQTT on either version | | Queues **produce** (publish to `queues/`) | Yes | Yes | | | Queues **consume** (`$share//queues/`) | No — requires 5.0 | Yes | Needs QoS ≥ 1; `$share` shared subscriptions are 5.0 only | | RPC Commands (publish to `commands/`) | No — silently dropped | Yes | 3.1.1 PUBACK succeeds, but the message is **not** executed | | RPC Queries (publish to `queries/`) | No — silently dropped | Yes | Same silent-drop behaviour | | User Properties ↔ KubeMQ Tags | No | Yes | The wire format only carries user-properties on 5.0 | | Rich PUBACK / SUBACK reason codes | Limited | Yes | 3.1.1 PUBACK is a single `0x00` byte; detailed codes (`0x83`, `0x87`, …) need 5.0 | | `ResponseTopic` and `CorrelationData` | No | Yes | Required for RPC | | `clean_session=false` session restore | Yes | Yes (`CleanStart=false`) | Node-local only | | Will-retain at CONNECT | No — CONNACK `0x9A` | No — CONNACK `0x9A` | Retain not supported on either version | | Runtime retain publish | Silent drop — PUBACK `0x00` | Silent drop — PUBACK `0x00` | See [QoS and sessions](/connectors/mqtt/concepts/qos-and-sessions) | | MQTT 3.1 (level 3) | Rejected (CONNACK) | n/a | `MinProtocolVersion=4` | ## What is MQTT 5.0-only [#what-is-mqtt-50-only] These capabilities have **no MQTT 3.1.1 equivalent** on this connector: | 5.0 addition | KubeMQ usage | | -------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | `ResponseTopic` | RPC reply routing for [Commands](/connectors/mqtt/how-to/commands) and [Queries](/connectors/mqtt/how-to/queries) | | `CorrelationData` | RPC response correlation | | User Properties | Bidirectional KubeMQ **Tags** mapping (see [Topic mapping](/connectors/mqtt/concepts/topic-mapping)) | | Shared subscriptions (`$share/`) | Queue **consume** (see [Queues](/connectors/mqtt/how-to/queues)) | | Extended reason codes | Detailed PUBACK / SUBACK / CONNACK errors | | `CleanStart` flag | Per-connect session control | ### RPC silently drops on 3.1.1 [#rpc-silently-drops-on-311] A **3.1.1** publish to `commands/` or `queries/` receives a **success PUBACK (`0x00`)** at the wire level, but the message is **silently dropped** — it is never executed, and there is **no error visible to the publisher**. The drop exists because MQTT 3.1.1 has no `ResponseTopic` or `CorrelationData`, so the connector cannot construct an RPC request. RPC requires MQTT 5.0. To use RPC you must: 1. Connect with **MQTT 5.0** (`protocolVersion: 5` / `MQTTv5` / `Level5`). 2. Subscribe to your own `$reply//...` topic **before** publishing. 3. Set `Properties.ResponseTopic` and `Properties.CorrelationData` on every RPC publish. ## The MQTT 3.1.1 subset [#the-mqtt-311-subset] MQTT 3.1.1 clients can use: * Events publish and subscribe (including wildcards) * Events-Store publish and subscribe (StartNewOnly) * Queues **produce** * TLS and WebSocket transports * Password-as-JWT authentication They **cannot** use: | Feature | Reason | | ----------------------------- | --------------------------------------------------------------------- | | RPC (Commands / Queries) | No `ResponseTopic` / `CorrelationData` — silently dropped | | Queues consume via `$share` | `$share` shared subscriptions require 5.0 support in the client stack | | User Properties ↔ KubeMQ Tags | The 3.1.1 wire format has no user-properties field | | Extended reason codes | 3.1.1 PUBACK is a bare `0x00` success byte | ## MQTT 3.1 is rejected [#mqtt-31-is-rejected] Protocol level 3 (MQTT 3.1, the original 2010 spec) is **explicitly rejected** — connecting with protocol level 3 returns a refused CONNACK. The minimum accepted version is **level 4 (3.1.1)**, controlled by `CONNECTORSMQTT_CAPABILITIES_MIN_PROTOCOL_VERSION=4` (the default). ## Choosing a version per client library [#choosing-a-version-per-client-library] Most client libraries default to 3.1.1 and require an explicit flag to negotiate 5.0: ```go // paho.golang's autopaho speaks MQTT 5.0; paho.mqtt.golang speaks 3.1.1. // Choosing the v5 client library is how you select the protocol version. conn, err := autopaho.NewConnection(ctx, autopaho.ClientConfig{ BrokerUrls: []*url.URL{brokerURL}, KeepAlive: 30, // ... MQTT 5.0 connection ... }) ``` ```python # paho-mqtt — select the version on the client constructor. import paho.mqtt.client as mqtt from paho.mqtt.enums import CallbackAPIVersion client = mqtt.Client( callback_api_version=CallbackAPIVersion.VERSION2, protocol=mqtt.MQTTv5, # use mqtt.MQTTv311 for the 3.1.1 subset ) ``` ```typescript // mqtt.js — protocolVersion: 5 negotiates MQTT 5.0 (omit / 4 for 3.1.1). import * as mqtt from "mqtt"; const client = mqtt.connect("tcp://broker:1883", { protocolVersion: 5, clientId: "my-client", clean: true, }); ``` The Ruby `mqtt` gem is **MQTT 3.1.1 only** and cannot negotiate 5.0, so it ships the documented 3.1.1 subset (no RPC, no `$share` consume, no User Properties). For 5.0-only patterns from Ruby, use a different language or client library. ## Related [#related] # QoS and sessions (/connectors/mqtt/concepts/qos-and-sessions) The KubeMQ MQTT connector supports **QoS 0, 1, and 2**. The effective QoS interacts with KubeMQ pattern semantics in non-obvious ways — Queue consumption requires QoS ≥ 1 and uses PUBACK as its acknowledgement signal. Sessions are **in-memory and node-local**: a `clean_session=false` reconnect to the **same** broker node restores subscriptions. **Retain is silently dropped, and there are no durable subscriptions.** A runtime PUBLISH with the retain flag returns a success PUBACK (`0x00`) but the retain is ignored — no retained message is stored. A CONNECT that sets a **Will with retain** is refused with CONNACK `0x9A` (retain-not-supported). Do not design around retained messages or durable (cross-restart) subscriptions on this connector; use the **Events-Store** pattern when you need persistence. ## QoS levels [#qos-levels] | QoS | Wire semantics | KubeMQ pattern constraints | | --------------------- | ------------------------------------------------------------ | -------------------------------------------------------------- | | **0** — at-most-once | Fire-and-forget; no PUBACK | Events / Events-Store publish and subscribe: allowed | | **1** — at-least-once | Publish: PUBACK required; subscribe: PUBACK on each delivery | **Required for Queue consume** (`$share`); recommended for RPC | | **2** — exactly-once | Full PUBREC / PUBREL / PUBCOMP handshake | Supported at the MQTT layer; routed identically to QoS 1 | The broker advertises **`MaxQos = 2`** (configurable via `CONNECTORSMQTT_CAPABILITIES_MAX_QOS`). If a subscriber requests a higher QoS than the publisher used, delivery QoS is `min(subscribe QoS, publish QoS)` per standard MQTT semantics — a pure MQTT-layer operation that does not affect KubeMQ routing. ## QoS per pattern [#qos-per-pattern] ### Events and Events-Store [#events-and-events-store] Any QoS is permitted. QoS 0 is common for high-throughput telemetry where occasional loss is acceptable; QoS 1 is recommended for reliable delivery. Events-Store always stores messages durably on the KubeMQ side regardless of MQTT QoS — but over MQTT, Events-Store subscriptions are **always `StartNewOnly`**: there is **no historical replay**, regardless of QoS or session state. ### Queues [#queues] | Operation | Required QoS | Notes | | ---------------------------------- | ------------ | ------------------------------------------------------------------ | | Produce (publish to `queues/`) | Any | QoS 0 produce is accepted; no MQTT-layer acknowledgement guarantee | | Consume (`$share//queues/`) | **≥ 1** | A QoS 0 shared-queue subscribe is rejected with SUBACK `0x83` | **Ack-on-PUBACK model.** A Queue message is acknowledged to KubeMQ when the broker receives the **PUBACK** from the consuming client. Until the PUBACK arrives, the message stays in-flight. See [Queues](/connectors/mqtt/how-to/queues) for the full consume model. ### Commands and Queries (RPC) [#commands-and-queries-rpc] RPC is **MQTT 5.0 only**. QoS 1 is required on both the `$reply//...` subscribe and the `commands/` or `queries/` publish. See [Commands](/connectors/mqtt/how-to/commands) and [Queries](/connectors/mqtt/how-to/queries). ## Queue ack timeout and redelivery [#queue-ack-timeout-and-redelivery] The connector tracks one in-flight Queue message per MQTT client. If no PUBACK arrives within `QueueAckTimeoutSeconds` (default **30 s**, via `CONNECTORSMQTT_QUEUE_ACK_TIMEOUT_SECONDS`), it negatively acknowledges the message to KubeMQ and the message is redelivered to the next available consumer. | Event | KubeMQ action | | ------------------------------------------ | -------------------------------------------------- | | PUBACK received within timeout | Acknowledge — message removed from the queue | | No PUBACK within `QueueAckTimeoutSeconds` | Negative-ack → redeliver to another consumer | | Client disconnects with a pending delivery | Immediate negative-ack → requeue (no message loss) | The disconnect-with-pending behaviour means a **clean shutdown without PUBACK causes immediate requeue** — the correct durability guarantee. ### Graceful shutdown [#graceful-shutdown] When the KubeMQ server shuts down, the connector sends DISCONNECT reason code `0x8B` (server-shutting-down) to all connected clients, and any in-flight Queue messages are immediately negatively acknowledged and requeued. ## Sessions [#sessions] ### In-memory and node-local [#in-memory-and-node-local] Sessions are stored **in memory only**, on the node that holds the connection. There is **no cross-node session replication**. | Property | Value | | ------------------ | ------------------------------------------------------------------ | | Storage | In-memory | | Scope | Node-local | | Max session expiry | 3600 s (`CONNECTORSMQTT_CAPABILITIES_MAX_SESSION_EXPIRY_SECONDS`) | | Max message expiry | 86400 s (`CONNECTORSMQTT_CAPABILITIES_MAX_MESSAGE_EXPIRY_SECONDS`) | ### `clean_session=false` (3.1.1) / `CleanStart=false` (5.0) [#clean_sessionfalse-311--cleanstartfalse-50] When a client reconnects with `clean_session=false` to the **same node**, the broker restores its active subscriptions and any pending (undelivered) messages for those subscriptions. Reconnecting to a **different node** in a multi-node cluster does **not** restore the session — sessions are node-local. Clients that need durable sessions in a clustered environment should pin to the same node or use the **Events-Store** pattern for replay. ### `clean_session=true` / `CleanStart=true` [#clean_sessiontrue--cleanstarttrue] The broker discards the previous session on connect — all subscriptions and pending deliveries are cleared. This is the recommended setting for stateless consumers and is what the examples use. ### Session takeover [#session-takeover] If a client reconnects with the **same `ClientID`** while the previous connection is still active, the broker performs a takeover: the old connection is closed and the new one inherits the session state (when `clean_session=false`). ## ReceiveMaximum and inflight limits [#receivemaximum-and-inflight-limits] | Setting | Default | Env var | | ---------------- | ------- | --------------------------------------------- | | `ReceiveMaximum` | 1024 | `CONNECTORSMQTT_CAPABILITIES_RECEIVE_MAXIMUM` | | `MaxInflight` | 8192 | `CONNECTORSMQTT_CAPABILITIES_MAX_INFLIGHT` | These caps apply **per broker**, not per client. When `ReceiveMaximum` is reached on a connection, the broker stops delivering new messages until outstanding PUBACKs are received. ## Related [#related] # Topic mapping (/connectors/mqtt/concepts/topic-mapping) The KubeMQ MQTT connector maps every MQTT topic to a KubeMQ messaging pattern and channel through a well-defined grammar. The **first topic segment (the prefix)** selects the KubeMQ pattern; the **remaining segments** become the channel name with `/` replaced by `.`. Understanding this grammar is essential before writing any producer or consumer code. ## Prefix-to-pattern table [#prefix-to-pattern-table] | MQTT topic | KubeMQ pattern | Direction | Example topic | KubeMQ channel | | ---------------------------- | -------------- | ---------------------------------- | ----------------------------- | -------------------- | | `events/` | Events | publish + subscribe | `events/site1/temp` | `site1.temp` | | `store/` | Events-Store | publish + subscribe (StartNewOnly) | `store/site1/temp` | `site1.temp` | | `queues/` | Queues | publish = produce only | `queues/jobs/email` | `jobs.email` | | `$share//queues/` | Queues | subscribe = consume (QoS ≥ 1) | `$share/g1/queues/jobs/email` | `jobs.email` | | `commands/` | Commands (RPC) | publish = send (MQTT 5.0 only) | `commands/svc/reboot` | `svc.reboot` | | `queries/` | Queries (RPC) | publish = send (MQTT 5.0 only) | `queries/svc/status` | `svc.status` | | `$reply//` | (broker-local) | subscribe + RPC ResponseTopic | `$reply/c1/inbox` | not routed to KubeMQ | ## Separator conversion: `/` → `.` [#separator-conversion---] MQTT uses `/` as the path separator; KubeMQ uses `.`. The connector converts every `/` in the **channel portion** of the topic to `.`: ```text events/site1/sensors/temp ↓ strip the prefix site1/sensors/temp ↓ replace / with . site1.sensors.temp ← KubeMQ channel ``` This conversion is one-way on delivery: when the connector delivers a KubeMQ message to an MQTT subscriber, the channel's `.` separators are **not** converted back. The MQTT subscribe filter must match the dotted form. **A literal `.` in a topic segment is lossy.** A `.` inside a segment maps to `.` in the channel — **indistinguishable** from a `/`-converted `.`. So `events/a/b/c`, `events/a.b/c`, and `events/a/b.c` all resolve to the same channel `a.b.c`. Use `/` exclusively for hierarchy in MQTT topics; reserve `.` for the channel names used on gRPC/REST clients. ## Prefixless topics and `DefaultPattern` [#prefixless-topics-and-defaultpattern] A topic with no recognized prefix (e.g. `sensor/data`) is routed to the configured `DefaultPattern`: | `DefaultPattern` | Behaviour | | ------------------ | -------------------------------------------------------------------- | | `events` (default) | Routed to **Events** on channel `sensor.data` | | `store` | Routed to **Events-Store** | | `none` | Publish rejected — PUBACK `0x90`; subscribe rejected — SUBACK `0x8F` | Configure with `CONNECTORSMQTT_DEFAULT_PATTERN` (default `events`). ## Wildcard subscriptions — Events only [#wildcard-subscriptions--events-only] MQTT wildcards are supported on **Events subscriptions only**. A wildcard subscribe on any other pattern is rejected with SUBACK `0xA2` (wildcard-subscriptions-not-supported). | MQTT wildcard | KubeMQ equivalent | Example filter | Matches | | ------------------ | ----------------- | ---------------- | ----------------------------------------- | | `+` (single level) | `*` | `events/site1/+` | `events/site1/temp`, `events/site1/hum` | | `#` (multi level) | `>` | `events/site1/#` | `events/site1/temp`, `events/site1/a/b/c` | Rules: * `#` must be the **final segment** of the topic filter. * Both wildcards may appear in one filter: `events/+/sensors/#`. * A non-Events wildcard subscribe returns SUBACK `0xA2`. **Overlapping wildcard filters deliver multiple copies.** Each distinct Events subscribe filter creates an independent bridge entry, and there is **no cross-entry deduplication**. If a publish matches N of a client's overlapping filters (e.g. `#`, `events/#`, and the exact topic), the client receives **N copies**. Use non-overlapping filters when duplicate delivery is unacceptable. ## Queue consume: `$share` shared subscriptions [#queue-consume-share-shared-subscriptions] A plain queue subscribe (`queues/`) is **not allowed** — it returns SUBACK `0x83`. Queue consumption requires an MQTT 5.0 shared subscription: ```text $share//queues/ ``` | Component | Meaning | | ------------------ | ------------------------------------------------------------------- | | `$share` | MQTT 5.0 shared-subscription prefix | | `` | Group name — an **audit/metrics label only** (see the caveat below) | | `queues/` | Must resolve to the **Queues** pattern | QoS must be **≥ 1** — a QoS 0 shared-queue subscribe returns SUBACK `0x83`. **The `$share` group name is audit/metrics-only.** All groups compete in **one** shared KubeMQ queue pool — this is **not** MQTT per-group-copy semantics. `$share/A/queues/x` and `$share/B/queues/x` consume from the **same** pool (one copy total, any consumer wins), not one copy per group. For true fan-out to independent consumer groups, use **different KubeMQ channels**. ## RPC reply topics: `$reply//` [#rpc-reply-topics-replyclientidsuffix] `$reply` is a **broker-local reserved namespace** for RPC response routing. It is **never** routed to KubeMQ — it stays inside the broker. | Rule | Details | | ----------------- | ------------------------------------------------------------------------------------------------ | | Format | `$reply//` — **3 segments minimum** | | Own namespace | A client may only subscribe to / use as ResponseTopic its **own** `$reply//...` | | Foreign namespace | Using another client's `$reply` namespace → PUBACK `0x83` (publish with a foreign ResponseTopic) | | Routing | Never forwarded to KubeMQ | An RPC client subscribes to its own reply topic **before** publishing the request, then sets it as `Properties.ResponseTopic` on the publish. See [Commands](/connectors/mqtt/how-to/commands) and [Queries](/connectors/mqtt/how-to/queries) for the full flow. ## MQTT 5.0 User Properties ↔ KubeMQ Tags [#mqtt-50-user-properties--kubemq-tags] On **MQTT 5.0** connections, User Properties on a PUBLISH are carried into the KubeMQ message `Tags`, and vice versa on delivery. This mapping is **5.0 only** — MQTT 3.1.1 has no User Properties field. * **Inbound** — every `Properties.User` entry on a 5.0 PUBLISH is copied 1:1 into the KubeMQ message `Tags` (duplicate keys are last-wins). Applies to Events, Events-Store, Queues publishes, and the RPC bridge. * **Outbound** — when a KubeMQ message is delivered to a 5.0 MQTT subscriber, its `Tags` are written back as `Properties.User`. A 3.1.1 subscriber receives no user properties. Caps (per message): | Cap | Limit | Exceeded on 5.0 | Exceeded on 3.1.1 | | ----------------------------------- | ----- | ------------------------------- | ----------------------------- | | Max user-property count | 32 | PUBACK `0x97` + `publish.error` | Silent drop + `publish.error` | | Max total bytes (all keys + values) | 4096 | PUBACK `0x97` + `publish.error` | Silent drop + `publish.error` | When either cap is exceeded the message is **not** routed. ## Reason codes for invalid topics [#reason-codes-for-invalid-topics] | Trigger | Packet | Code | | ------------------------------------------- | ------ | ------------------------------------------- | | `DefaultPattern=none`, prefixless publish | PUBACK | `0x90` topic-name-invalid | | `DefaultPattern=none`, prefixless subscribe | SUBACK | `0x8F` topic-filter-invalid | | Empty channel segment | SUBACK | `0x8F` topic-filter-invalid | | Non-Events wildcard subscribe | SUBACK | `0xA2` wildcard-subscriptions-not-supported | | Plain `queues/` subscribe | SUBACK | `0x83` implementation-specific | | QoS 0 `$share/*/queues/` subscribe | SUBACK | `0x83` implementation-specific | | Foreign `$reply` namespace (publish) | PUBACK | `0x83` implementation-specific | ## Related [#related] # Authentication (/connectors/mqtt/how-to/authentication) This guide explains how a native MQTT client proves *who it is* to the KubeMQ MQTT connector and *what it may do* once connected. MQTT carries the credential in the standard **`Password`** field of the CONNECT packet — there is no MQTT-specific auth flag — so any off-the-shelf MQTT 3.1.1 / 5.0 client authenticates by setting username/password the way it already does. On a stock dev broker, authentication is **off** — every CONNECT succeeds, the `Password` field is ignored, and ACL checks are skipped. So the examples clone-and-run with no credentials. To turn auth on, configure an auth provider at the **server** level (there is no MQTT-specific auth env var); see [Auth & security](/connectors/reference/auth-and-security). ## Password-as-JWT [#password-as-jwt] The connector uses **password-as-JWT** authentication. The MQTT `Password` field carries a KubeMQ JWT; the `Username` field is accepted but is **display-only** — it is stored for the connections endpoint and never checked against the token. | MQTT CONNECT field | Role | Notes | | ------------------ | --------------------------------- | -------------------------------------------------------------------------------------------------- | | `Password` | **The credential** — a KubeMQ JWT | Must be set when auth is on; the MQTT spec requires `PasswordFlag=true` when a password is present | | `Username` | Display / audit label | Surfaced on the connections endpoint; never validated against the token | | `ClientID` | **The identity** | Becomes the KubeMQ `ClientID`; used for ACL checks and the `$reply//...` reply namespace | A CONNECT therefore looks like this — the JWT goes in the **password** slot, not the username: ```text CONNECT ClientID: my-client ← the identity Username: alice ← display label only Password: ← the actual credential PasswordFlag: true ``` Authentication is checked **once, at CONNECT time only**. There is no mid-connection token recheck — revoking a token does **not** disconnect an already-authenticated session. ## Connecting with a JWT [#connecting-with-a-jwt] Put the JWT in the **password** slot; the username is cosmetic. The same code works against an auth-disabled broker (the server simply does not validate the token). ```go // paho.mqtt.golang (MQTT 3.1.1) — username is display-only, password is the KubeMQ JWT. opts := mqtt.NewClientOptions(). AddBroker("tcp://broker:1883"). SetClientID("my-client"). SetUsername("alice"). // display-only SetPassword(os.Getenv("KUBEMQ_MQTT_JWT")) // KubeMQ JWT token client := mqtt.NewClient(opts) if token := client.Connect(); token.Wait() && token.Error() != nil { log.Fatalf("connect (bad/expired JWT? auth-enabled broker?): %v", token.Error()) } ``` ```python # paho-mqtt (MQTT 5.0) — username is display-only, password is the KubeMQ JWT. import os import paho.mqtt.client as mqtt from paho.mqtt.enums import CallbackAPIVersion client = mqtt.Client( callback_api_version=CallbackAPIVersion.VERSION2, client_id="my-client", protocol=mqtt.MQTTv5, ) client.username_pw_set("alice", os.environ["KUBEMQ_MQTT_JWT"]) # (display, JWT) client.connect("broker", 1883, keepalive=30) ``` ```typescript // mqtt.js (MQTT 5.0) — username is display-only, password is the KubeMQ JWT. import * as mqtt from "mqtt"; const client = mqtt.connect("tcp://broker:1883", { protocolVersion: 5, clientId: "my-client", username: "alice", // display-only password: process.env.KUBEMQ_MQTT_JWT, // KubeMQ JWT token clean: true, }); ``` ## CONNACK reason codes [#connack-reason-codes] What the broker returns at CONNECT time depends on whether auth is enabled and whether the JWT is valid: | Scenario | Packet | Reason code | | ------------------------------------------------------- | ------- | ---------------------------- | | Auth disabled — any password accepted | CONNACK | `0x00` success | | Auth enabled — valid JWT | CONNACK | `0x00` success | | Auth enabled — empty password (or `PasswordFlag=false`) | CONNACK | `0x86` bad username/password | | Auth enabled — invalid JWT or bad signature | CONNACK | `0x86` bad username/password | When auth is enabled, a CONNECT with an empty `Password` is refused immediately with `0x86`; the connection is never established. Clients must always supply the token in the `Password` field. ## ACL authorization [#acl-authorization] Once connected, **every publish and subscribe** is checked against the KubeMQ ACL. Rules are evaluated per `(pattern, channel, read/write)` tuple, against the identity derived from the `ClientID`. | Outcome | Packet | Reason code | | ------------------- | ------ | --------------------- | | Allowed (publish) | PUBACK | `0x00` | | Allowed (subscribe) | SUBACK | `0x01` (granted QoS) | | Denied on publish | PUBACK | `0x87` not authorized | | Denied on subscribe | SUBACK | `0x87` not authorized | Two namespace rules sit alongside the ACL: * **`$reply//`** — a client's **own** reply namespace is **always allowed**, regardless of ACL rules. This is the local topic an RPC requester subscribes to for responses; see [Topic mapping](/connectors/mqtt/concepts/topic-mapping). * **Other `$`-prefixed topics** (except `$share/`) are **denied by default**. Authorization is checked **at CONNECT for the password, then at each publish/subscribe for the ACL** — but never again on an open subscription. A denied publish returns PUBACK `0x87`; treat it as a permission error, not a transient failure to retry. ## Open (no-auth) default [#open-no-auth-default] When no auth provider is configured the broker is **open**: the `Password` field is ignored, every CONNECT succeeds, and ACL checks are skipped. This matches KubeMQ's gRPC and REST parity behaviour and is the mode every example in the docs assumes. To enable authentication, configure the KubeMQ server with an auth provider (the shared server-level auth block) — **no MQTT-specific environment variable controls auth.** Because it is a shared setting, the same JWT model applies across all KubeMQ connectors; see [Auth & security](/connectors/reference/auth-and-security). ## Quick decision guide [#quick-decision-guide] | You want… | Do this | | ------------------------------------------- | --------------------------------------------------------------------------- | | Clone-and-run on a stock dev broker | Connect with no credentials (auth is off) | | Authenticate with a KubeMQ identity | Put the **JWT in the `Password` field**; `Username` is cosmetic | | Set a stable identity for ACL / RPC replies | Set a meaningful **`ClientID`** — it is the identity, not the username | | Diagnose a rejected CONNECT | Look for CONNACK `0x86` (bad/empty JWT) | | Diagnose a rejected publish/subscribe | Look for PUBACK / SUBACK `0x87` (ACL deny) | ## Related [#related] # Commands (/connectors/mqtt/how-to/commands) Commands are **RPC with an execution acknowledgement** over the MQTT connector. An MQTT client publishes to `commands/` and a **gRPC-side responder** processes the request and returns a pass/fail result. The MQTT client receives that result on its private reply topic — there is **no body**, only an executed/error status. ## Overview [#overview] The `commands/` prefix selects the Commands pattern (`/` → `.`: `commands/device/reboot` → channel `device.reboot`). The MQTT client is always the **caller**: it subscribes to its own `$reply//` reply topic, then publishes the command with an MQTT 5.0 **response-topic** and **correlation-data**. The responder runs on the **gRPC side** — MQTT clients cannot register as responders. | Step | MQTT action | Notes | | ------------------ | ------------------------------------------------------------- | ----------------------------------------------------------------------- | | Subscribe to reply | `SUBSCRIBE $reply//` | mochi-local; never routed to the broker | | Send command | `PUBLISH commands/` + `ResponseTopic` + `CorrelationData` | `SendCommandRequest` to the gRPC responder | | Receive PUBACK | immediate `PUBACK 0x00` | acks receipt **only**, not execution | | Receive response | `PUBLISH` on the reply topic | empty body; `kubemq-executed` + optional `kubemq-error` user properties | **Commands require MQTT 5.0.** The flow relies on the MQTT 5.0 `ResponseTopic` and `CorrelationData` properties, which MQTT 3.1.1 does not have. A v3.1.1 publish to `commands/` is **silently dropped** — the connector still returns `PUBACK 0x00`, but the message never reaches a responder and no reply arrives. The Ruby `mqtt` gem is 3.1.1-only, so RPC is unavailable from Ruby; the examples below omit it. **`PUBACK` is immediate — it is not the response.** The broker returns `PUBACK` as soon as it receives your publish, before the RPC round-trip completes. You **must** implement your own response-wait timeout on the `$reply` topic; if no responder is registered, no reply ever arrives (the server audits `rpc.timeout` after `RpcTimeoutSeconds`, default 30 s). ## How it works [#how-it-works] The MQTT client subscribes to its reply topic, publishes the command with a response-topic and correlation-data, and waits. The connector bridges the request to a gRPC responder and routes the responder's pass/fail result back to the reply topic. *The MQTT client is the caller; a gRPC responder executes the command and the result returns on the client's `$reply` topic.* ## Request and response [#request-and-response] Each example subscribes to its own `$reply//inbox` topic, publishes a command to `commands/demo/cmd` with a response-topic and correlation-data, then waits for the result and reads the `kubemq-executed` user property. A gRPC responder must be running on channel `demo.cmd`. Every client reads the broker endpoint from `KUBEMQ_MQTT_URL` (default `tcp://localhost:1883`). Ruby is omitted — RPC requires MQTT 5.0 and the `mqtt` gem is 3.1.1-only. ```go package main import ( "context" "fmt" "log" "net" "os" "strings" "time" "github.com/eclipse/paho.golang/paho" "github.com/google/uuid" ) func brokerURL() string { if u := os.Getenv("KUBEMQ_MQTT_URL"); u != "" { return u } return "tcp://localhost:1883" } func tcpAddr(raw string) string { for _, pfx := range []string{"tcp://", "ws://", "tls://"} { if strings.HasPrefix(raw, pfx) { return raw[len(pfx):] } } return raw } func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() addr := tcpAddr(brokerURL()) clientID := "go-cmd-" + uuid.NewString()[:8] correlationID := uuid.NewString() replyTopic := "$reply/" + clientID + "/inbox" // own namespace only commandTopic := "commands/demo/cmd" // channel demo.cmd resp := make(chan *paho.Publish, 1) conn, err := net.Dial("tcp", addr) if err != nil { log.Fatalf("dial: %v", err) } client := paho.NewClient(paho.ClientConfig{ Conn: conn, OnPublishReceived: []func(paho.PublishReceived) (bool, error){ func(pr paho.PublishReceived) (bool, error) { select { case resp <- pr.Packet: default: } return true, nil }, }, }) ack, err := client.Connect(ctx, &paho.Connect{ClientID: clientID, KeepAlive: 30, CleanStart: true}) if err != nil || ack.ReasonCode != 0 { log.Fatalf("connect: %v (reason 0x%02X)", err, ack.ReasonCode) } // 1. Subscribe to your own reply topic BEFORE publishing. subAck, err := client.Subscribe(ctx, &paho.Subscribe{ Subscriptions: []paho.SubscribeOptions{{Topic: replyTopic, QoS: 1}}, }) if err != nil { log.Fatalf("subscribe: %v", err) } // 0x83 = reply topic not in own $reply// namespace. if subAck.Reasons[0] == 0x83 { log.Fatal("SUBACK 0x83: reply topic must be $reply//...") } // 2. Publish the command with ResponseTopic + CorrelationData. pubAck, err := client.Publish(ctx, &paho.Publish{ Topic: commandTopic, QoS: 1, Payload: []byte(`{"action":"restart","target":"service-a"}`), Properties: &paho.PublishProperties{ ResponseTopic: replyTopic, CorrelationData: []byte(correlationID), }, }) if err != nil { log.Fatalf("publish: %v", err) } // PUBACK is immediate — the execution outcome arrives later on the reply topic. if pubAck.ReasonCode != 0 { log.Fatalf("PUBACK reason=0x%02X (0x83=bad ResponseTopic, 0x97=RpcMaxPending)", pubAck.ReasonCode) } // 3. Wait for the response (empty body; kubemq-executed user property). select { case r := <-resp: var executed, errMsg string if r.Properties != nil { for _, up := range r.Properties.User { switch up.Key { case "kubemq-executed": executed = up.Value case "kubemq-error": errMsg = up.Value } } } if executed == "true" { fmt.Println("command executed successfully") } else { fmt.Printf("command not executed: %s\n", errMsg) } case <-ctx.Done(): log.Fatal("timed out — is a gRPC responder running on channel demo.cmd?") } _ = client.Disconnect(&paho.Disconnect{ReasonCode: 0}) } ``` ```python import os import threading import time import uuid import paho.mqtt.client as mqtt from paho.mqtt.enums import CallbackAPIVersion from paho.mqtt.properties import Properties from paho.mqtt.packettypes import PacketTypes CLIENT_ID = "py-command-client" COMMAND_TOPIC = "commands/demo/cmd" # channel demo.cmd REPLY_TOPIC = f"$reply/{CLIENT_ID}/inbox" # own namespace only CORRELATION = uuid.uuid4().bytes def parse_url(url: str) -> tuple[str, int]: scheme, rest = url.split("://", 1) host, _, port = rest.rstrip("/").partition(":") return host, int(port) if port else {"tcp": 1883, "tls": 8883, "ws": 8083}[scheme] def main() -> None: host, port = parse_url(os.environ.get("KUBEMQ_MQTT_URL", "tcp://localhost:1883")) response = threading.Event() props: dict[str, str] = {} def on_message(client, userdata, msg): if msg.properties and hasattr(msg.properties, "UserProperty"): for k, v in (msg.properties.UserProperty or []): props[k] = v response.set() client = mqtt.Client(CallbackAPIVersion.VERSION2, client_id=CLIENT_ID, protocol=mqtt.MQTTv5) # 1. Subscribe to your own reply topic BEFORE publishing. client.on_connect = lambda c, *_: c.subscribe(REPLY_TOPIC, qos=1) client.on_message = on_message client.connect(host, port, keepalive=30) client.loop_start() time.sleep(0.5) # 2. Publish the command with ResponseTopic + CorrelationData. pub_props = Properties(PacketTypes.PUBLISH) pub_props.ResponseTopic = REPLY_TOPIC pub_props.CorrelationData = CORRELATION payload = b'{"action": "restart", "target": "service-a"}' # PUBACK is immediate — the execution outcome arrives later on the reply topic. client.publish(COMMAND_TOPIC, payload=payload, qos=1, properties=pub_props).wait_for_publish(10) # 3. Wait for the response (empty body; kubemq-executed user property). if not response.wait(timeout=30): raise TimeoutError("no response — is a gRPC responder running on channel demo.cmd?") if props.get("kubemq-executed") == "true": print("command executed successfully") else: print(f"command not executed: {props.get('kubemq-error', '(no detail)')}") client.loop_stop() client.disconnect() if __name__ == "__main__": main() ``` ```java import java.nio.charset.StandardCharsets; import java.util.List; import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import org.eclipse.paho.mqttv5.client.MqttAsyncClient; import org.eclipse.paho.mqttv5.client.MqttCallback; import org.eclipse.paho.mqttv5.client.MqttConnectionOptions; import org.eclipse.paho.mqttv5.client.MqttDisconnectResponse; import org.eclipse.paho.mqttv5.client.IMqttToken; import org.eclipse.paho.mqttv5.common.MqttException; import org.eclipse.paho.mqttv5.common.MqttMessage; import org.eclipse.paho.mqttv5.common.packet.MqttProperties; import org.eclipse.paho.mqttv5.common.packet.UserProperty; public final class Main { public static void main(String[] args) throws Exception { String broker = System.getenv().getOrDefault("KUBEMQ_MQTT_URL", "tcp://localhost:1883"); String clientId = "java-cmd-" + UUID.randomUUID().toString().substring(0, 8); String commandTopic = "commands/demo/cmd"; // channel demo.cmd String replyTopic = "$reply/" + clientId + "/inbox"; // own namespace only byte[] correlation = ("cmd-" + UUID.randomUUID()).getBytes(StandardCharsets.UTF_8); CountDownLatch latch = new CountDownLatch(1); String[] executed = {null}, error = {null}; MqttAsyncClient client = new MqttAsyncClient(broker, clientId); client.setCallback(new MqttCallback() { public void messageArrived(String topic, MqttMessage m) { MqttProperties props = m.getProperties(); if (props != null && props.getUserProperties() != null) { for (UserProperty up : props.getUserProperties()) { if ("kubemq-executed".equals(up.getKey())) executed[0] = up.getValue(); if ("kubemq-error".equals(up.getKey())) error[0] = up.getValue(); } } latch.countDown(); } public void disconnected(MqttDisconnectResponse r) {} public void mqttErrorOccurred(MqttException e) {} public void deliveryComplete(IMqttToken t) {} public void connectComplete(boolean reconnect, String uri) {} public void authPacketArrived(int code, MqttProperties props) {} }); MqttConnectionOptions opts = new MqttConnectionOptions(); opts.setCleanStart(true); opts.setKeepAliveInterval(30); client.connect(opts).waitForCompletion(5_000); // 1. Subscribe to your own reply topic BEFORE publishing. client.subscribe(replyTopic, 1).waitForCompletion(5_000); Thread.sleep(200); // 2. Publish the command with ResponseTopic + CorrelationData. MqttProperties pubProps = new MqttProperties(); pubProps.setResponseTopic(replyTopic); pubProps.setCorrelationData(correlation); pubProps.setUserProperties(List.of(new UserProperty("language", "java"))); MqttMessage msg = new MqttMessage("execute-action".getBytes(StandardCharsets.UTF_8)); msg.setQos(1); msg.setProperties(pubProps); // PUBACK is immediate — the execution outcome arrives later on the reply topic. client.publish(commandTopic, msg).waitForCompletion(5_000); // 3. Wait for the response (empty body; kubemq-executed user property). if (!latch.await(15, TimeUnit.SECONDS)) { throw new IllegalStateException("no response — is a gRPC responder running on channel demo.cmd?"); } if ("true".equals(executed[0])) { System.out.println("command executed successfully"); } else { System.out.printf("command not executed: %s%n", error[0]); } client.disconnect().waitForCompletion(3_000); client.close(); } } ``` ```typescript import mqtt, { type MqttClient } from "mqtt"; import crypto from "node:crypto"; const CLIENT_ID = `js-cmd-${crypto.randomBytes(4).toString("hex")}`; const REPLY_TOPIC = `$reply/${CLIENT_ID}/inbox`; // own namespace only const COMMAND_TOPIC = "commands/demo/cmd"; // channel demo.cmd const CORRELATION_DATA = Buffer.from(crypto.randomUUID()); const RPC_TIMEOUT_MS = 30_000; async function main(): Promise { const url = process.env["KUBEMQ_MQTT_URL"] ?? "tcp://localhost:1883"; const client: MqttClient = mqtt.connect(url, { clientId: CLIENT_ID, protocolVersion: 5, clean: true, keepalive: 30, }); await new Promise((res, rej) => { client.once("connect", () => res()); client.once("error", rej); }); // 1. Subscribe to your own reply topic BEFORE publishing. await new Promise((resolve, reject) => { client.subscribe(REPLY_TOPIC, { qos: 1 }, (err, granted) => { if (err) return reject(err); // 0x83 = reply topic not in own $reply// namespace. if (((granted?.[0]?.qos as number) ?? -1) > 2) return reject(new Error("reply subscribe rejected")); resolve(); }); }); // 2. Arm the response handler, then publish with ResponseTopic + CorrelationData. const responded = new Promise((resolve, reject) => { const timer = setTimeout( () => reject(new Error("RPC timeout — is a gRPC responder running on channel demo.cmd?")), RPC_TIMEOUT_MS, ); client.on("message", (topic, payload, packet) => { if (topic !== REPLY_TOPIC) return; clearTimeout(timer); const userProps = (packet.properties as { userProperties?: Record } | undefined) ?.userProperties ?? {}; // Command responses have an empty body; the result is in the user properties. if (userProps["kubemq-executed"] === "true") { console.log("command executed successfully"); } else { console.log(`command not executed: ${userProps["kubemq-error"] ?? "(no detail)"}`); } resolve(); }); }); await new Promise((resolve, reject) => { client.publish( COMMAND_TOPIC, JSON.stringify({ action: "restart", target: "service-a" }), { qos: 1, properties: { responseTopic: REPLY_TOPIC, correlationData: CORRELATION_DATA } }, (err) => (err ? reject(err) : resolve()), // PUBACK is immediate, not the response ); }); await responded; await client.endAsync(); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using System.Text; using MQTTnet; using MQTTnet.Client; using MQTTnet.Protocol; static (string host, int port) Endpoint() { var url = Environment.GetEnvironmentVariable("KUBEMQ_MQTT_URL") ?? "tcp://localhost:1883"; foreach (var pfx in new[] { "tcp://", "tls://", "ws://" }) if (url.StartsWith(pfx, StringComparison.OrdinalIgnoreCase)) url = url[pfx.Length..]; var parts = url.TrimEnd('/').Split(':'); return (parts[0], parts.Length > 1 ? int.Parse(parts[1]) : 1883); } var (host, port) = Endpoint(); var clientId = $"csharp-cmd-{Guid.NewGuid():N}"[..22]; var requestId = Guid.NewGuid().ToString("N"); var replyTopic = $"$reply/{clientId}/inbox"; // own namespace only const string commandTopic = "commands/demo/cmd"; // channel demo.cmd var factory = new MqttFactory(); var responded = new TaskCompletionSource<(bool executed, string? error)>( TaskCreationOptions.RunContinuationsAsynchronously); using var client = factory.CreateMqttClient(); client.ApplicationMessageReceivedAsync += e => { string? executed = null, error = null; foreach (var p in e.ApplicationMessage.UserProperties ?? new()) { if (p.Name == "kubemq-executed") executed = p.Value; if (p.Name == "kubemq-error") error = p.Value; } responded.TrySetResult((executed == "true", error)); return Task.CompletedTask; }; var options = new MqttClientOptionsBuilder() .WithTcpServer(host, port) .WithClientId(clientId) .WithProtocolVersion(MQTTnet.Formatter.MqttProtocolVersion.V500) .WithCleanSession(true) .Build(); await client.ConnectAsync(options); // 1. Subscribe to your own reply topic BEFORE publishing. var subResult = await client.SubscribeAsync(new MqttClientSubscribeOptionsBuilder() .WithTopicFilter(f => f.WithTopic(replyTopic).WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)) .Build()); if (subResult.Items.First().ResultCode > MqttClientSubscribeResultCode.GrantedQoS2) throw new Exception("reply subscribe rejected — use $reply// namespace"); await Task.Delay(200); // 2. Publish the command with ResponseTopic + CorrelationData. var pubResult = await client.PublishAsync(new MqttApplicationMessageBuilder() .WithTopic(commandTopic) .WithPayload(Encoding.UTF8.GetBytes($"{{\"action\":\"restart\",\"requestId\":\"{requestId}\"}}")) .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce) .WithResponseTopic(replyTopic) .WithCorrelationData(Encoding.UTF8.GetBytes(requestId)) .Build()); // PUBACK is immediate — the execution outcome arrives later on the reply topic. if (pubResult.ReasonCode != MqttClientPublishReasonCode.Success) throw new Exception($"PUBACK reason: {pubResult.ReasonCode}"); // 3. Wait for the response (empty body; kubemq-executed user property). using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); var (executed, error) = await responded.Task.WaitAsync(cts.Token); Console.WriteLine(executed ? "command executed successfully" : $"command not executed: {error ?? "(no detail)"}"); await client.DisconnectAsync(); ``` ```rust use bytes::Bytes; use rumqttc::v5::mqttbytes::v5::{Packet, PublishProperties}; use rumqttc::v5::mqttbytes::QoS; use rumqttc::v5::{AsyncClient, Event, MqttOptions}; use std::env; use tokio::sync::oneshot; use tokio::time::{timeout, Duration}; use uuid::Uuid; fn parse_host_port(url: &str) -> (String, u16) { let stripped = url .trim_start_matches("tcp://") .trim_start_matches("tls://") .trim_start_matches("ws://"); let host_port = stripped.split('/').next().unwrap_or(stripped); let mut parts = host_port.splitn(2, ':'); let host = parts.next().unwrap_or("localhost").to_string(); let port: u16 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(1883); (host, port) } #[tokio::main] async fn main() -> Result<(), Box> { let url = env::var("KUBEMQ_MQTT_URL").unwrap_or_else(|_| "tcp://localhost:1883".to_string()); let (host, port) = parse_host_port(&url); let client_id = format!("rust-cmd-{}", &Uuid::new_v4().to_string()[..8]); let reply_topic = format!("$reply/{}/inbox", client_id); // own namespace only let command_topic = "commands/demo/cmd"; // channel demo.cmd let correlation = Uuid::new_v4().to_string(); let corr_bytes = Bytes::from(correlation.clone().into_bytes()); let mut opts = MqttOptions::new(&client_id, &host, port); opts.set_keep_alive(Duration::from_secs(30)); let (client, mut eventloop) = AsyncClient::new(opts, 10); let (tx, rx) = oneshot::channel::<(bool, Option)>(); let reply_clone = reply_topic.clone(); let corr_check = corr_bytes.clone(); tokio::spawn(async move { let mut tx = Some(tx); loop { match eventloop.poll().await { Ok(Event::Incoming(Packet::Publish(p))) => { if String::from_utf8_lossy(&p.topic) != reply_clone { continue; } let props = p.properties.as_ref(); // Match the response to the request via CorrelationData. if props.and_then(|pr| pr.correlation_data.clone()).as_deref() != Some(corr_check.as_ref()) { continue; } // Command responses have an empty body; the result is in the user properties. let executed = props .and_then(|pr| pr.user_properties.iter().find(|(k, _)| k == "kubemq-executed")) .map(|(_, v)| v == "true") .unwrap_or(false); let error = props .and_then(|pr| pr.user_properties.iter().find(|(k, _)| k == "kubemq-error")) .map(|(_, v)| v.clone()); if let Some(tx) = tx.take() { let _ = tx.send((executed, error)); return; } } Ok(_) => {} Err(e) => { eprintln!("event loop: {e}"); return; } } } }); // 1. Subscribe to your own reply topic BEFORE publishing. client.subscribe(&reply_topic, QoS::AtLeastOnce).await?; tokio::time::sleep(Duration::from_millis(300)).await; // 2. Publish the command with ResponseTopic + CorrelationData. let props = PublishProperties { response_topic: Some(reply_topic.clone()), correlation_data: Some(corr_bytes), ..Default::default() }; // PUBACK is immediate — the execution outcome arrives later on the reply topic. client .publish_with_properties( command_topic, QoS::AtLeastOnce, false, br#"{"action":"restart","target":"service-a"}"#.as_ref(), props, ) .await?; // 3. Wait for the response (empty body; kubemq-executed user property). let (executed, error) = timeout(Duration::from_secs(30), rx) .await .map_err(|_| "timed out — is a gRPC responder running on channel demo.cmd?")??; if executed { println!("command executed successfully"); } else { println!("command not executed: {}", error.as_deref().unwrap_or("(no detail)")); } Ok(()) } ``` ## The response shape [#the-response-shape] A command response has **no payload** — the outcome is conveyed entirely through MQTT 5.0 user properties: | User property | Value | Meaning | | ----------------- | -------------------- | -------------------------------------------------------- | | `kubemq-executed` | `"true"` / `"false"` | Whether the responder processed the command successfully | | `kubemq-error` | string (optional) | Error message, present only when `kubemq-executed=false` | The connector echoes your `CorrelationData` verbatim on the response — set a unique value (a UUID or sequence number) per request and match the reply on it when you have more than one command in flight. ## Reason codes and responders [#reason-codes-and-responders] | Code | Meaning | | ----------------------- | ----------------------------------------------------------------------------------- | | `PUBACK 0x83` | Missing or foreign `ResponseTopic` (not in your own `$reply//` namespace) | | `PUBACK 0x97` | `RpcMaxPending` quota reached (default 1024 concurrent pending RPCs) | | `PUBACK 0x00` (dropped) | v3.1.1 publish — silently discarded; the message never reaches a responder | | `SUBACK 0x83` | Subscribe to `commands/` — MQTT clients cannot be responders | MQTT clients can only be callers. Responders must use the KubeMQ gRPC API (for example, `SubscribeToCommands` + `SendCommandResponse`). ## Related [#related] # Events Store (/connectors/mqtt/how-to/events-store) Events Store is **persistent** pub/sub over the MQTT connector. Publish to a topic prefixed with `store/` and the message is durably stored by KubeMQ before delivery. Over MQTT the subscription start position is **always `StartNewOnly`** — there is no historical replay, so a subscriber receives only messages published *after* its subscription becomes active. ## Overview [#overview] The `store/` prefix selects the Events Store pattern. As with [Events](/connectors/mqtt/how-to/events), `/` in the path maps to `.` in the channel (`store/audit/log` → channel `audit.log`), publish and subscribe work on any MQTT version at any QoS, and v5 User Properties round-trip as KubeMQ `Tags`. The difference from Events is **persistence**: messages are stored, but MQTT subscribers can only stream forward from the moment they subscribe. | Operation | MQTT action | KubeMQ mapping | | ------------------ | -------------------------------- | ------------------------------------------------------- | | Publish | `PUBLISH store/` (any QoS) | `SendEvents` (`Store=true`) — persisted before delivery | | Subscribe | `SUBSCRIBE store/` (any QoS) | Durable subscription, start position `StartNewOnly` | | Wildcard subscribe | — | **Not supported** (SUBACK `0xA2`) | | Tags (v5) | `PUBLISH` User Properties | KubeMQ message `Tags` (bidirectional) | | Aspect | Events | Events Store | | ------------------ | --------- | --------------------------------------------------------- | | Persistence | No | Yes — messages stored in KubeMQ | | Offline delivery | Missed | Delivered once a subscriber connects (after publish time) | | Replay over MQTT | N/A | Not available (StartNewOnly only) | | Topic prefix | `events/` | `store/` | | Wildcard subscribe | Supported | Not supported | ## How it works [#how-it-works] A publish to `store/` is persisted by KubeMQ, then delivered to active subscribers. The connector registers every MQTT subscription with the store at `StartNewOnly`, so messages already in the store at subscribe time are never streamed to that subscriber. *Messages are durably stored, but an MQTT subscriber streams only from its subscribe point forward — pre-subscribe messages are never replayed.* **Events Store over MQTT is always `StartNewOnly` — there is no historical replay.** The connector hard-codes the subscribe start position. Messages published before the subscription was established are never delivered over MQTT, no matter how many are stored. If your application needs replay (start-from-first, start-at-sequence, start-at-time, …), use the KubeMQ gRPC or REST API instead. **Retain is silently dropped.** Setting the RETAIN flag on a `store/` publish still returns PUBACK `0x00` but the message is discarded — not delivered and not stored. The only retain-related failure is a CONNACK `0x9A` for a Will-retain flag at CONNECT. Never set retain on a message to KubeMQ. ## Publish and subscribe [#publish-and-subscribe] Each example demonstrates `StartNewOnly`: it publishes one message **before** subscribing (which a fresh subscriber never receives), subscribes to `store/`, then publishes a second message **after** subscribing and confirms only the post-subscribe message arrives. Every client reads the broker endpoint from `KUBEMQ_MQTT_URL` (default `tcp://localhost:1883`). ```go package main import ( "context" "fmt" "log" "net" "os" "strings" "time" "github.com/eclipse/paho.golang/paho" "github.com/google/uuid" ) const topic = "store/demo/s" // store/ prefix -> Events Store; channel demo.s func brokerURL() string { if u := os.Getenv("KUBEMQ_MQTT_URL"); u != "" { return u } return "tcp://localhost:1883" } func tcpAddr(raw string) string { for _, pfx := range []string{"tcp://", "ws://", "tls://"} { if strings.HasPrefix(raw, pfx) { return raw[len(pfx):] } } return raw } func dial(ctx context.Context, addr, id string, onMsg func(paho.PublishReceived) (bool, error)) *paho.Client { conn, err := net.Dial("tcp", addr) if err != nil { log.Fatalf("dial: %v", err) } cfg := paho.ClientConfig{Conn: conn} if onMsg != nil { cfg.OnPublishReceived = []func(paho.PublishReceived) (bool, error){onMsg} } c := paho.NewClient(cfg) ack, err := c.Connect(ctx, &paho.Connect{ClientID: id, KeepAlive: 30, CleanStart: true}) if err != nil { log.Fatalf("connect: %v", err) } if ack.ReasonCode != 0 { log.Fatalf("CONNACK reason=0x%02X", ack.ReasonCode) } return c } func main() { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() addr := tcpAddr(brokerURL()) sfx := uuid.NewString()[:8] // 1. PUBLISH before subscribing — this is stored but never replayed over MQTT. pub := dial(ctx, addr, "go-store-pub-"+sfx, nil) if _, err := pub.Publish(ctx, &paho.Publish{Topic: topic, QoS: 1, Payload: []byte("pre-subscribe")}); err != nil { log.Fatalf("pre-publish: %v", err) } time.Sleep(300 * time.Millisecond) // 2. SUBSCRIBE — the connector registers a StartNewOnly subscription. got := make(chan string, 1) sub := dial(ctx, addr, "go-store-sub-"+sfx, func(pr paho.PublishReceived) (bool, error) { got <- string(pr.Packet.Payload) return true, nil }) subAck, err := sub.Subscribe(ctx, &paho.Subscribe{ Subscriptions: []paho.SubscribeOptions{{Topic: topic, QoS: 1}}, }) if err != nil { log.Fatalf("subscribe: %v", err) } // Wildcards on store/ are rejected with 0xA2. if subAck.Reasons[0] > 2 { log.Fatalf("SUBACK reason=0x%02X", subAck.Reasons[0]) } time.Sleep(700 * time.Millisecond) // let the StartNewOnly subscription go live // 3. PUBLISH after subscribing — only this message is delivered. if _, err := pub.Publish(ctx, &paho.Publish{Topic: topic, QoS: 1, Payload: []byte("post-subscribe")}); err != nil { log.Fatalf("post-publish: %v", err) } select { case msg := <-got: fmt.Printf("received: %s (StartNewOnly — the pre-subscribe message was not replayed)\n", msg) case <-ctx.Done(): log.Fatal("timed out waiting for the post-subscribe message") } _ = pub.Disconnect(&paho.Disconnect{ReasonCode: 0}) _ = sub.Disconnect(&paho.Disconnect{ReasonCode: 0}) } ``` ```python import os import threading import time import paho.mqtt.client as mqtt from paho.mqtt.enums import CallbackAPIVersion TOPIC = "store/demo/s" # store/ prefix -> Events Store; channel demo.s def parse_url(url: str) -> tuple[str, int]: scheme, rest = url.split("://", 1) host, _, port = rest.rstrip("/").partition(":") return host, int(port) if port else {"tcp": 1883, "tls": 8883, "ws": 8083}[scheme] def make_client(client_id: str) -> mqtt.Client: return mqtt.Client(CallbackAPIVersion.VERSION2, client_id=client_id, protocol=mqtt.MQTTv5) def main() -> None: host, port = parse_url(os.environ.get("KUBEMQ_MQTT_URL", "tcp://localhost:1883")) # 1. PUBLISH before subscribing — stored but never replayed over MQTT. pub = make_client("py-store-pub") pub.connect(host, port, keepalive=30) pub.loop_start() time.sleep(0.3) pub.publish(TOPIC, payload=b"pre-subscribe", qos=1).wait_for_publish(10) time.sleep(0.3) # 2. SUBSCRIBE — the connector registers a StartNewOnly subscription. received = threading.Event() payload: list[bytes] = [] sub = make_client("py-store-sub") sub.on_connect = lambda c, *_: c.subscribe(TOPIC, qos=1) sub.on_message = lambda c, u, m: (payload.append(m.payload), received.set()) sub.connect(host, port, keepalive=30) sub.loop_start() time.sleep(0.7) # let the StartNewOnly subscription go live # 3. PUBLISH after subscribing — only this message is delivered. pub.publish(TOPIC, payload=b"post-subscribe", qos=1).wait_for_publish(10) if not received.wait(timeout=10): raise TimeoutError("timed out waiting for the post-subscribe message") print(f"received: {payload[0].decode()!r} " "(StartNewOnly — the pre-subscribe message was not replayed)") pub.loop_stop(); pub.disconnect() sub.loop_stop(); sub.disconnect() if __name__ == "__main__": main() ``` ```java import java.nio.charset.StandardCharsets; import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import org.eclipse.paho.mqttv5.client.MqttAsyncClient; import org.eclipse.paho.mqttv5.client.MqttCallback; import org.eclipse.paho.mqttv5.client.MqttConnectionOptions; import org.eclipse.paho.mqttv5.client.MqttDisconnectResponse; import org.eclipse.paho.mqttv5.client.IMqttToken; import org.eclipse.paho.mqttv5.common.MqttException; import org.eclipse.paho.mqttv5.common.MqttMessage; import org.eclipse.paho.mqttv5.common.packet.MqttProperties; public final class Main { // store/ prefix -> Events Store; KubeMQ channel demo.s ('/' -> '.'). private static final String TOPIC = "store/demo/s"; public static void main(String[] args) throws Exception { String broker = System.getenv().getOrDefault("KUBEMQ_MQTT_URL", "tcp://localhost:1883"); MqttConnectionOptions opts = new MqttConnectionOptions(); opts.setCleanStart(true); opts.setKeepAliveInterval(30); // 1. PUBLISH before subscribing — stored but never replayed over MQTT. MqttAsyncClient pub = new MqttAsyncClient(broker, "java-store-pub-" + UUID.randomUUID().toString().substring(0, 8)); pub.connect(opts).waitForCompletion(10_000); pub.publish(TOPIC, msg("pre-subscribe")).waitForCompletion(5_000); Thread.sleep(300); // 2. SUBSCRIBE — the connector registers a StartNewOnly subscription. CountDownLatch received = new CountDownLatch(1); String[] body = {null}; MqttAsyncClient sub = new MqttAsyncClient(broker, "java-store-sub-" + UUID.randomUUID().toString().substring(0, 8)); sub.setCallback(new MqttCallback() { public void messageArrived(String t, MqttMessage m) { body[0] = new String(m.getPayload(), StandardCharsets.UTF_8); received.countDown(); } public void disconnected(MqttDisconnectResponse r) {} public void mqttErrorOccurred(MqttException e) {} public void deliveryComplete(IMqttToken t) {} public void connectComplete(boolean reconnect, String uri) {} public void authPacketArrived(int code, MqttProperties props) {} }); sub.connect(opts).waitForCompletion(10_000); IMqttToken subToken = sub.subscribe(TOPIC, 1); subToken.waitForCompletion(10_000); // Wildcards on store/ are rejected with 0xA2. if (subToken.getGrantedQos()[0] >= 0x80) { throw new IllegalStateException("subscription rejected"); } Thread.sleep(700); // let the StartNewOnly subscription go live // 3. PUBLISH after subscribing — only this message is delivered. pub.publish(TOPIC, msg("post-subscribe")).waitForCompletion(5_000); if (!received.await(10, TimeUnit.SECONDS)) { throw new IllegalStateException("timed out waiting for the post-subscribe message"); } System.out.printf("received: %s (StartNewOnly — the pre-subscribe message was not replayed)%n", body[0]); pub.disconnect().waitForCompletion(5_000); pub.close(); sub.disconnect().waitForCompletion(5_000); sub.close(); } private static MqttMessage msg(String body) { MqttMessage m = new MqttMessage(body.getBytes(StandardCharsets.UTF_8)); m.setQos(1); m.setRetained(false); // retain is silently dropped by the connector return m; } } ``` ```typescript import mqtt, { type MqttClient } from "mqtt"; const TOPIC = "store/demo/s"; // store/ prefix -> Events Store; channel demo.s function connect(url: string, clientId: string): Promise { return new Promise((resolve, reject) => { const client = mqtt.connect(url, { clientId, protocolVersion: 5, clean: true, keepalive: 30 }); client.once("connect", () => resolve(client)); client.once("error", reject); }); } function publish(client: MqttClient, payload: string): Promise { return new Promise((resolve, reject) => { client.publish(TOPIC, payload, { qos: 1 }, (err) => (err ? reject(err) : resolve())); }); } async function main(): Promise { const url = process.env["KUBEMQ_MQTT_URL"] ?? "tcp://localhost:1883"; // 1. PUBLISH before subscribing — stored but never replayed over MQTT. const publisher = await connect(url, "js-store-pub"); await publish(publisher, "pre-subscribe"); await new Promise((r) => setTimeout(r, 300)); // 2. SUBSCRIBE — the connector registers a StartNewOnly subscription. const subscriber = await connect(url, "js-store-sub"); const received = new Promise((resolve) => { subscriber.on("message", (_topic, payload) => resolve(payload.toString())); }); await new Promise((resolve, reject) => { subscriber.subscribe(TOPIC, { qos: 1 }, (err) => (err ? reject(err) : resolve())); }); await new Promise((r) => setTimeout(r, 700)); // let the subscription go live // 3. PUBLISH after subscribing — only this message is delivered. await publish(publisher, "post-subscribe"); console.log(`received: ${await received} ` + "(StartNewOnly — the pre-subscribe message was not replayed)"); await publisher.endAsync(); await subscriber.endAsync(); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using System.Text; using MQTTnet; using MQTTnet.Client; using MQTTnet.Protocol; static (string host, int port) Endpoint() { var url = Environment.GetEnvironmentVariable("KUBEMQ_MQTT_URL") ?? "tcp://localhost:1883"; foreach (var pfx in new[] { "tcp://", "tls://", "ws://" }) if (url.StartsWith(pfx, StringComparison.OrdinalIgnoreCase)) url = url[pfx.Length..]; var parts = url.TrimEnd('/').Split(':'); return (parts[0], parts.Length > 1 ? int.Parse(parts[1]) : 1883); } var (host, port) = Endpoint(); const string topic = "store/demo/s"; // store/ prefix -> Events Store; channel demo.s var factory = new MqttFactory(); MqttClientOptions Options(string id) => new MqttClientOptionsBuilder() .WithTcpServer(host, port) .WithClientId($"{id}-{Guid.NewGuid():N}"[..26]) .WithProtocolVersion(MQTTnet.Formatter.MqttProtocolVersion.V500) .WithCleanSession(true) .Build(); async Task Publish(IMqttClient client, string body) { var result = await client.PublishAsync(new MqttApplicationMessageBuilder() .WithTopic(topic) .WithPayload(Encoding.UTF8.GetBytes(body)) .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce) .Build()); if (result.ReasonCode != MqttClientPublishReasonCode.Success) throw new Exception($"PUBACK reason: {result.ReasonCode}"); } // 1. PUBLISH before subscribing — stored but never replayed over MQTT. using var pub = factory.CreateMqttClient(); await pub.ConnectAsync(Options("csharp-store-pub")); await Publish(pub, "pre-subscribe"); await Task.Delay(300); // 2. SUBSCRIBE — the connector registers a StartNewOnly subscription. var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var sub = factory.CreateMqttClient(); sub.ApplicationMessageReceivedAsync += e => { received.TrySetResult(Encoding.UTF8.GetString(e.ApplicationMessage.PayloadSegment)); return Task.CompletedTask; }; await sub.ConnectAsync(Options("csharp-store-sub")); var subResult = await sub.SubscribeAsync(new MqttClientSubscribeOptionsBuilder() .WithTopicFilter(f => f.WithTopic(topic).WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)) .Build()); // Wildcards on store/ are rejected with 0xA2. if (subResult.Items.First().ResultCode > MqttClientSubscribeResultCode.GrantedQoS2) throw new Exception("subscription rejected"); await Task.Delay(700); // let the StartNewOnly subscription go live // 3. PUBLISH after subscribing — only this message is delivered. await Publish(pub, "post-subscribe"); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); Console.WriteLine($"received: {await received.Task.WaitAsync(cts.Token)} " + "(StartNewOnly — the pre-subscribe message was not replayed)"); await pub.DisconnectAsync(); await sub.DisconnectAsync(); ``` ```ruby # The mqtt gem speaks MQTT 3.1.1. Events Store publish/subscribe works on any # version; over MQTT the start position is always StartNewOnly regardless of client. require "mqtt" require "uri" require "securerandom" require "timeout" uri = URI.parse(ENV.fetch("KUBEMQ_MQTT_URL", "tcp://localhost:1883")) conn = { host: uri.host, port: uri.port, ssl: uri.scheme == "tls" } topic = "store/demo/s" # store/ prefix -> Events Store; channel demo.s # 1. PUBLISH before subscribing — stored but never replayed over MQTT. MQTT::Client.connect(host: conn[:host], port: conn[:port], ssl: conn[:ssl], client_id: "ruby-store-pub-#{SecureRandom.hex(4)}", clean_session: true, keep_alive: 30) do |client| client.publish(topic, "pre-subscribe", false, 1) end sleep 0.3 # 2. SUBSCRIBE — the connector registers a StartNewOnly subscription. received = Queue.new sub_ready = Queue.new subscriber = Thread.new do MQTT::Client.connect(host: conn[:host], port: conn[:port], ssl: conn[:ssl], client_id: "ruby-store-sub-#{SecureRandom.hex(4)}", clean_session: true, keep_alive: 30) do |client| client.subscribe([topic, 1]) sub_ready.push(:ready) _topic, payload = client.get received.push(payload) end end sub_ready.pop sleep 0.7 # let the StartNewOnly subscription go live # 3. PUBLISH after subscribing — only this message is delivered. MQTT::Client.connect(host: conn[:host], port: conn[:port], ssl: conn[:ssl], client_id: "ruby-store-pub2-#{SecureRandom.hex(4)}", clean_session: true, keep_alive: 30) do |client| client.publish(topic, "post-subscribe", false, 1) end payload = Timeout.timeout(10) { received.pop } puts "received: #{payload.inspect} (StartNewOnly — the pre-subscribe message was not replayed)" subscriber.kill ``` ```rust use rumqttc::v5::mqttbytes::v5::Packet; use rumqttc::v5::mqttbytes::QoS; use rumqttc::v5::{AsyncClient, Event, MqttOptions}; use std::env; use tokio::sync::oneshot; use tokio::time::{timeout, Duration}; use uuid::Uuid; const TOPIC: &str = "store/demo/s"; // store/ prefix -> Events Store; channel demo.s fn parse_host_port(url: &str) -> (String, u16) { let stripped = url .trim_start_matches("tcp://") .trim_start_matches("tls://") .trim_start_matches("ws://"); let host_port = stripped.split('/').next().unwrap_or(stripped); let mut parts = host_port.splitn(2, ':'); let host = parts.next().unwrap_or("localhost").to_string(); let port: u16 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(1883); (host, port) } #[tokio::main] async fn main() -> Result<(), Box> { let url = env::var("KUBEMQ_MQTT_URL").unwrap_or_else(|_| "tcp://localhost:1883".to_string()); let (host, port) = parse_host_port(&url); let sfx = &Uuid::new_v4().to_string()[..8]; // Single connection: publish-before-subscribe, subscribe, publish-after. let mut opts = MqttOptions::new(format!("rust-store-{sfx}"), &host, port); opts.set_keep_alive(Duration::from_secs(30)); let (client, mut eventloop) = AsyncClient::new(opts, 10); let (msg_tx, msg_rx) = oneshot::channel::(); tokio::spawn(async move { let mut msg = Some(msg_tx); loop { match eventloop.poll().await { Ok(Event::Incoming(Packet::Publish(p))) => { if let Some(tx) = msg.take() { let _ = tx.send(String::from_utf8_lossy(&p.payload).to_string()); } return; } Ok(_) => {} Err(e) => { eprintln!("event loop: {e}"); return; } } } }); // 1. PUBLISH before subscribing — stored but never replayed over MQTT. client.publish(TOPIC, QoS::AtLeastOnce, false, b"pre-subscribe".as_ref()).await?; tokio::time::sleep(Duration::from_millis(400)).await; // 2. SUBSCRIBE — the connector registers a StartNewOnly subscription. client.subscribe(TOPIC, QoS::AtLeastOnce).await?; tokio::time::sleep(Duration::from_millis(700)).await; // let it go live // 3. PUBLISH after subscribing — only this message is delivered. client.publish(TOPIC, QoS::AtLeastOnce, false, b"post-subscribe".as_ref()).await?; let body = timeout(Duration::from_secs(10), msg_rx).await??; println!("received: {body} (StartNewOnly — the pre-subscribe message was not replayed)"); Ok(()) } ``` ## What Events Store does not have over MQTT [#what-events-store-does-not-have-over-mqtt] * **No historical replay.** The start position is always `StartNewOnly`; there is no MQTT topic syntax or v5 property to request a different one. * **No wildcard subscriptions.** A wildcard filter on a `store/` topic is rejected with SUBACK `0xA2` — wildcards are an [Events](/connectors/mqtt/how-to/events)-only feature. * **No durable resumption point.** Reconnecting a session does not resume from where it left off; you start fresh at `StartNewOnly` again. For any of these, drive Events Store through the KubeMQ gRPC or REST API, which exposes the full set of start positions. ## Related [#related] # Events (/connectors/mqtt/how-to/events) Events are **fire-and-forget** pub/sub over the MQTT connector. Publish to a topic prefixed with `events/` and the connector routes the message to the KubeMQ **Events** pattern; every matching subscriber gets a copy. Nothing is persisted — a subscriber that is offline at publish time misses the message. ## Overview [#overview] The `events/` prefix selects the Events pattern. Each path segment after the prefix is joined with `.` to form the KubeMQ channel: `events/site1/temp` → channel `site1.temp`. Publishing and subscribing both work on **any MQTT version** (3.1.1 or 5.0) at **any QoS**. Events is the only pattern that accepts wildcard subscriptions and the `DefaultPattern` for bare (prefixless) topics. | Operation | MQTT action | KubeMQ mapping | | ------------------ | -------------------------------------- | --------------------------------------------- | | Publish | `PUBLISH events/` (any QoS) | `SendEvents` (`Store=false`) | | Subscribe | `SUBSCRIBE events/` (any QoS) | Fan-out delivery to every matching subscriber | | Wildcard subscribe | `+` → one segment, `#` → trailing tail | KubeMQ `*` / `>` (Events only) | | Tags (v5) | `PUBLISH` User Properties | KubeMQ message `Tags` (bidirectional) | ## How it works [#how-it-works] A published event fans out to every connected subscriber whose filter matches. There is no consumer group and no load-balancing for Events — every matching subscriber receives every message. Use [Queues](/connectors/mqtt/how-to/queues) when you need competing consumers. *Each event is copied to every subscriber whose filter matches the published topic; there is no persistence and no replay.* **Retain is silently dropped.** The connector forces `RetainAvailable=0`. If you set the RETAIN flag on a runtime `PUBLISH`, the broker strips it: the `PUBACK` still returns `0x00` (success) but the message is **not delivered and not stored**, with no error. The only retain-related failure is a CONNACK `0x9A` for a **Will-retain** flag at CONNECT. Never set retain on a message to KubeMQ. ## Publish and subscribe [#publish-and-subscribe] Each example subscribes **first** (Events have no replay — a publish that beats the subscription is lost), waits briefly for the subscription to register, then publishes and drains the message. On MQTT 5.0 connections, a publisher's User Properties round-trip as KubeMQ `Tags` and arrive back on the v5 subscriber. Every client reads the broker endpoint from `KUBEMQ_MQTT_URL` (default `tcp://localhost:1883`). ```go package main import ( "context" "fmt" "log" "net" "os" "strings" "time" "github.com/eclipse/paho.golang/paho" "github.com/google/uuid" ) // Subscribe with the single-level '+' wildcard; publish to a concrete leaf the // filter matches. '+' maps to KubeMQ '*'; '/' maps to '.' in the channel name. const subFilter = "events/demo/+" // KubeMQ channel filter demo.* const pubTopic = "events/demo/x" // KubeMQ channel demo.x func brokerURL() string { if u := os.Getenv("KUBEMQ_MQTT_URL"); u != "" { return u } return "tcp://localhost:1883" } func tcpAddr(raw string) string { for _, pfx := range []string{"tcp://", "ws://", "tls://"} { if strings.HasPrefix(raw, pfx) { return raw[len(pfx):] } } return raw } func dial(ctx context.Context, addr, id string, onMsg func(paho.PublishReceived) (bool, error)) *paho.Client { conn, err := net.Dial("tcp", addr) if err != nil { log.Fatalf("dial: %v", err) } cfg := paho.ClientConfig{Conn: conn} if onMsg != nil { cfg.OnPublishReceived = []func(paho.PublishReceived) (bool, error){onMsg} } c := paho.NewClient(cfg) ack, err := c.Connect(ctx, &paho.Connect{ClientID: id, KeepAlive: 30, CleanStart: true}) if err != nil { log.Fatalf("connect: %v", err) } if ack.ReasonCode != 0 { log.Fatalf("CONNACK reason=0x%02X", ack.ReasonCode) } return c } func main() { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() addr := tcpAddr(brokerURL()) sfx := uuid.NewString()[:8] // 1. SUBSCRIBE FIRST — Events have no replay. got := make(chan string, 1) sub := dial(ctx, addr, "go-events-sub-"+sfx, func(pr paho.PublishReceived) (bool, error) { got <- string(pr.Packet.Payload) return true, nil }) subAck, err := sub.Subscribe(ctx, &paho.Subscribe{ Subscriptions: []paho.SubscribeOptions{{Topic: subFilter, QoS: 1}}, }) if err != nil { log.Fatalf("subscribe: %v", err) } // Reason codes > 2 are rejections (0xA2 = wildcard on a non-events pattern). if subAck.Reasons[0] > 2 { log.Fatalf("SUBACK reason=0x%02X", subAck.Reasons[0]) } time.Sleep(300 * time.Millisecond) // let the subscription register // 2. PUBLISH with a v5 User Property (round-trips as a KubeMQ Tag). pub := dial(ctx, addr, "go-events-pub-"+sfx, nil) pubAck, err := pub.Publish(ctx, &paho.Publish{ Topic: pubTopic, QoS: 1, Payload: []byte("hello"), Properties: &paho.PublishProperties{ User: []paho.UserProperty{{Key: "sensor", Value: "thermometer"}}, }, }) if err != nil { log.Fatalf("publish: %v", err) } if pubAck.ReasonCode != 0 { log.Fatalf("PUBACK reason=0x%02X", pubAck.ReasonCode) } // 3. RECEIVE. select { case msg := <-got: fmt.Printf("received: %s\n", msg) case <-ctx.Done(): log.Fatal("timed out waiting for event") } _ = pub.Disconnect(&paho.Disconnect{ReasonCode: 0}) _ = sub.Disconnect(&paho.Disconnect{ReasonCode: 0}) } ``` ```python import os import threading import time import paho.mqtt.client as mqtt from paho.mqtt.enums import CallbackAPIVersion from paho.mqtt.properties import Properties from paho.mqtt.packettypes import PacketTypes SUB_FILTER = "events/demo/+" # '+' -> KubeMQ '*'; channel filter demo.* PUB_TOPIC = "events/demo/x" # KubeMQ channel demo.x def parse_url(url: str) -> tuple[str, int]: scheme, rest = url.split("://", 1) host, _, port = rest.rstrip("/").partition(":") return host, int(port) if port else {"tcp": 1883, "tls": 8883, "ws": 8083}[scheme] def main() -> None: host, port = parse_url(os.environ.get("KUBEMQ_MQTT_URL", "tcp://localhost:1883")) received = threading.Event() payload: list[bytes] = [] # 1. SUBSCRIBE FIRST — Events have no replay. sub = mqtt.Client(CallbackAPIVersion.VERSION2, client_id="py-events-sub", protocol=mqtt.MQTTv5) sub.on_connect = lambda c, *_: c.subscribe(SUB_FILTER, qos=1) sub.on_message = lambda c, u, m: (payload.append(m.payload), received.set()) sub.connect(host, port, keepalive=30) sub.loop_start() time.sleep(0.5) # let the subscription register before publishing # 2. PUBLISH with a v5 User Property (round-trips as a KubeMQ Tag). pub = mqtt.Client(CallbackAPIVersion.VERSION2, client_id="py-events-pub", protocol=mqtt.MQTTv5) pub.connect(host, port, keepalive=30) pub.loop_start() props = Properties(PacketTypes.PUBLISH) props.UserProperty = [("k1", "v1")] pub.publish(PUB_TOPIC, payload=b"hello", qos=1, properties=props).wait_for_publish(10) # 3. RECEIVE. if not received.wait(timeout=10): raise TimeoutError("timed out waiting for the event") print(f"received: {payload[0].decode()!r}") pub.loop_stop(); pub.disconnect() sub.loop_stop(); sub.disconnect() if __name__ == "__main__": main() ``` ```java import java.nio.charset.StandardCharsets; import java.util.List; import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import org.eclipse.paho.mqttv5.client.MqttAsyncClient; import org.eclipse.paho.mqttv5.client.MqttCallback; import org.eclipse.paho.mqttv5.client.MqttConnectionOptions; import org.eclipse.paho.mqttv5.client.MqttDisconnectResponse; import org.eclipse.paho.mqttv5.client.IMqttToken; import org.eclipse.paho.mqttv5.common.MqttException; import org.eclipse.paho.mqttv5.common.MqttMessage; import org.eclipse.paho.mqttv5.common.packet.MqttProperties; import org.eclipse.paho.mqttv5.common.packet.UserProperty; public final class Main { public static void main(String[] args) throws Exception { String broker = System.getenv().getOrDefault("KUBEMQ_MQTT_URL", "tcp://localhost:1883"); // '+' single-level wildcard -> KubeMQ '*'; '/' -> '.' in the channel name. String subFilter = "events/demo/+"; // channel filter demo.* String pubTopic = "events/demo/x"; // channel demo.x MqttConnectionOptions opts = new MqttConnectionOptions(); opts.setCleanStart(true); opts.setKeepAliveInterval(30); CountDownLatch received = new CountDownLatch(1); String[] body = {null}; // 1. SUBSCRIBE FIRST — Events have no replay. MqttAsyncClient sub = new MqttAsyncClient(broker, "java-events-sub-" + UUID.randomUUID().toString().substring(0, 8)); sub.setCallback(new MqttCallback() { public void messageArrived(String t, MqttMessage m) { body[0] = new String(m.getPayload(), StandardCharsets.UTF_8); received.countDown(); } public void disconnected(MqttDisconnectResponse r) {} public void mqttErrorOccurred(MqttException e) {} public void deliveryComplete(IMqttToken t) {} public void connectComplete(boolean reconnect, String uri) {} public void authPacketArrived(int code, MqttProperties props) {} }); sub.connect(opts).waitForCompletion(10_000); IMqttToken subToken = sub.subscribe(subFilter, 1); subToken.waitForCompletion(10_000); // SUBACK >= 0x80 is a rejection (0xA2 = wildcard on a non-events pattern). if (subToken.getGrantedQos()[0] >= 0x80) { throw new IllegalStateException("subscription rejected"); } Thread.sleep(300); // let the subscription register // 2. PUBLISH with a v5 User Property (round-trips as a KubeMQ Tag). MqttAsyncClient pub = new MqttAsyncClient(broker, "java-events-pub-" + UUID.randomUUID().toString().substring(0, 8)); pub.connect(opts).waitForCompletion(10_000); MqttMessage msg = new MqttMessage("hello".getBytes(StandardCharsets.UTF_8)); msg.setQos(1); MqttProperties pubProps = new MqttProperties(); pubProps.setUserProperties(List.of(new UserProperty("k1", "v1"))); msg.setProperties(pubProps); pub.publish(pubTopic, msg).waitForCompletion(10_000); // 3. RECEIVE. if (!received.await(10, TimeUnit.SECONDS)) { throw new IllegalStateException("timed out waiting for the event"); } System.out.printf("received: %s%n", body[0]); pub.disconnect().waitForCompletion(5_000); pub.close(); sub.disconnect().waitForCompletion(5_000); sub.close(); } } ``` ```typescript import mqtt, { type MqttClient } from "mqtt"; async function main(): Promise { const url = process.env["KUBEMQ_MQTT_URL"] ?? "tcp://localhost:1883"; // '+' single-level wildcard -> KubeMQ '*'; '/' -> '.' in the channel name. const subFilter = "events/demo/+"; // channel filter demo.* const pubTopic = "events/demo/x"; // channel demo.x // 1. SUBSCRIBE FIRST — Events have no replay. const subscriber: MqttClient = mqtt.connect(url, { clientId: "js-events-sub", protocolVersion: 5, clean: true, keepalive: 30, }); await new Promise((res, rej) => { subscriber.once("connect", () => res()); subscriber.once("error", rej); }); const received = new Promise((resolve) => { subscriber.on("message", (_topic, payload) => resolve(payload.toString())); }); await new Promise((resolve, reject) => { subscriber.subscribe(subFilter, { qos: 1 }, (err, granted) => { if (err) return reject(err); // A granted qos >= 0x80 is a rejection (0xA2 = wildcard on a non-events pattern). for (const g of granted ?? []) { if ((g as { qos: number }).qos >= 0x80) return reject(new Error("subscription rejected")); } resolve(); }); }); await new Promise((r) => setTimeout(r, 300)); // let the subscription register // 2. PUBLISH with a v5 User Property (round-trips as a KubeMQ Tag). const publisher: MqttClient = mqtt.connect(url, { clientId: "js-events-pub", protocolVersion: 5, clean: true, keepalive: 30, }); await new Promise((res, rej) => { publisher.once("connect", () => res()); publisher.once("error", rej); }); await new Promise((resolve, reject) => { publisher.publish( pubTopic, "hello", { qos: 1, properties: { userProperties: { k1: "v1" } } }, // never set retain (err) => (err ? reject(err) : resolve()), ); }); // 3. RECEIVE. console.log("received:", await received); await publisher.endAsync(); await subscriber.endAsync(); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using System.Text; using MQTTnet; using MQTTnet.Client; using MQTTnet.Protocol; static (string host, int port) Endpoint() { var url = Environment.GetEnvironmentVariable("KUBEMQ_MQTT_URL") ?? "tcp://localhost:1883"; foreach (var pfx in new[] { "tcp://", "tls://", "ws://" }) if (url.StartsWith(pfx, StringComparison.OrdinalIgnoreCase)) url = url[pfx.Length..]; var parts = url.TrimEnd('/').Split(':'); return (parts[0], parts.Length > 1 ? int.Parse(parts[1]) : 1883); } var (host, port) = Endpoint(); // '+' single-level wildcard -> KubeMQ '*'; '/' -> '.' in the channel name. const string subFilter = "events/demo/+"; // channel filter demo.* const string pubTopic = "events/demo/x"; // channel demo.x var factory = new MqttFactory(); var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); // 1. SUBSCRIBE FIRST — Events have no replay. using var sub = factory.CreateMqttClient(); sub.ApplicationMessageReceivedAsync += e => { received.TrySetResult(Encoding.UTF8.GetString(e.ApplicationMessage.PayloadSegment)); return Task.CompletedTask; }; var subOpts = new MqttClientOptionsBuilder() .WithTcpServer(host, port) .WithClientId($"csharp-events-sub-{Guid.NewGuid():N}"[..26]) .WithProtocolVersion(MQTTnet.Formatter.MqttProtocolVersion.V500) .WithCleanSession(true) .Build(); await sub.ConnectAsync(subOpts); var subResult = await sub.SubscribeAsync(new MqttClientSubscribeOptionsBuilder() .WithTopicFilter(f => f.WithTopic(subFilter).WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)) .Build()); // A result code beyond GrantedQoS2 is a rejection (0xA2 = wildcard on a non-events pattern). if (subResult.Items.First().ResultCode > MqttClientSubscribeResultCode.GrantedQoS2) throw new Exception("subscription rejected"); await Task.Delay(300); // let the subscription register // 2. PUBLISH with a v5 User Property (round-trips as a KubeMQ Tag). using var pub = factory.CreateMqttClient(); var pubOpts = new MqttClientOptionsBuilder() .WithTcpServer(host, port) .WithClientId($"csharp-events-pub-{Guid.NewGuid():N}"[..26]) .WithProtocolVersion(MQTTnet.Formatter.MqttProtocolVersion.V500) .WithCleanSession(true) .Build(); await pub.ConnectAsync(pubOpts); var pubResult = await pub.PublishAsync(new MqttApplicationMessageBuilder() .WithTopic(pubTopic) .WithPayload(Encoding.UTF8.GetBytes("hello")) .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce) .WithUserProperty("k1", "v1") // round-trips as a KubeMQ Tag; never set retain .Build()); if (pubResult.ReasonCode != MqttClientPublishReasonCode.Success) throw new Exception($"PUBACK reason: {pubResult.ReasonCode}"); // 3. RECEIVE. using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); Console.WriteLine($"received: {await received.Task.WaitAsync(cts.Token)}"); await pub.DisconnectAsync(); await sub.DisconnectAsync(); ``` ```ruby # The mqtt gem speaks MQTT 3.1.1 — no User Properties. Publish/subscribe on # Events works on any version; only the v5 Tag round-trip is unavailable here. require "mqtt" require "uri" require "securerandom" require "timeout" uri = URI.parse(ENV.fetch("KUBEMQ_MQTT_URL", "tcp://localhost:1883")) conn = { host: uri.host, port: uri.port, ssl: uri.scheme == "tls" } # '+' single-level wildcard -> KubeMQ '*'; '/' -> '.' in the channel name. sub_filter = "events/demo/+" # channel filter demo.* pub_topic = "events/demo/x" # channel demo.x received = Queue.new sub_ready = Queue.new # 1. SUBSCRIBE FIRST — Events have no replay. subscriber = Thread.new do MQTT::Client.connect(host: conn[:host], port: conn[:port], ssl: conn[:ssl], client_id: "ruby-events-sub-#{SecureRandom.hex(4)}", clean_session: true, keep_alive: 30) do |client| client.subscribe([sub_filter, 1]) sub_ready.push(:ready) _topic, payload = client.get received.push(payload) end end sub_ready.pop # wait until the subscription is established # 2. PUBLISH (retain MUST be false — the broker silently drops retained messages). MQTT::Client.connect(host: conn[:host], port: conn[:port], ssl: conn[:ssl], client_id: "ruby-events-pub-#{SecureRandom.hex(4)}", clean_session: true, keep_alive: 30) do |client| client.publish(pub_topic, "hello", false, 1) end # 3. RECEIVE. payload = Timeout.timeout(10) { received.pop } puts "received: #{payload.inspect}" subscriber.kill ``` ```rust use rumqttc::v5::mqttbytes::v5::{Packet, PublishProperties}; use rumqttc::v5::mqttbytes::QoS; use rumqttc::v5::{AsyncClient, Event, MqttOptions}; use std::env; use tokio::sync::oneshot; use tokio::time::{timeout, Duration}; use uuid::Uuid; fn parse_host_port(url: &str) -> (String, u16) { let stripped = url .trim_start_matches("tcp://") .trim_start_matches("tls://") .trim_start_matches("ws://"); let host_port = stripped.split('/').next().unwrap_or(stripped); let mut parts = host_port.splitn(2, ':'); let host = parts.next().unwrap_or("localhost").to_string(); let port: u16 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(1883); (host, port) } #[tokio::main] async fn main() -> Result<(), Box> { let url = env::var("KUBEMQ_MQTT_URL").unwrap_or_else(|_| "tcp://localhost:1883".to_string()); let (host, port) = parse_host_port(&url); let sfx = &Uuid::new_v4().to_string()[..8]; // '+' single-level wildcard -> KubeMQ '*'; '/' -> '.' in the channel name. let sub_filter = "events/demo/+"; // channel filter demo.* let pub_topic = "events/demo/x"; // channel demo.x // 1. SUBSCRIBE FIRST — Events have no replay. let mut sub_opts = MqttOptions::new(format!("rust-events-sub-{sfx}"), &host, port); sub_opts.set_keep_alive(Duration::from_secs(30)); let (sub, mut sub_loop) = AsyncClient::new(sub_opts, 10); let (ready_tx, ready_rx) = oneshot::channel::<()>(); let (msg_tx, msg_rx) = oneshot::channel::(); tokio::spawn(async move { let mut ready = Some(ready_tx); let mut msg = Some(msg_tx); loop { match sub_loop.poll().await { Ok(Event::Incoming(Packet::SubAck(_))) => { if let Some(tx) = ready.take() { let _ = tx.send(()); } } Ok(Event::Incoming(Packet::Publish(p))) => { if let Some(tx) = msg.take() { let _ = tx.send(String::from_utf8_lossy(&p.payload).to_string()); } return; } Ok(_) => {} Err(e) => { eprintln!("event loop: {e}"); return; } } } }); sub.subscribe(sub_filter, QoS::AtLeastOnce).await?; timeout(Duration::from_secs(5), ready_rx).await??; // SUBACK received // 2. PUBLISH with a v5 User Property (round-trips as a KubeMQ Tag). let mut pub_opts = MqttOptions::new(format!("rust-events-pub-{sfx}"), &host, port); pub_opts.set_keep_alive(Duration::from_secs(30)); let (publisher, mut pub_loop) = AsyncClient::new(pub_opts, 10); tokio::spawn(async move { while pub_loop.poll().await.is_ok() {} }); let props = PublishProperties { user_properties: vec![("k1".to_string(), "v1".to_string())], ..Default::default() }; publisher .publish_with_properties(pub_topic, QoS::AtLeastOnce, false, b"hello".as_ref(), props) .await?; // 3. RECEIVE. let body = timeout(Duration::from_secs(10), msg_rx).await??; println!("received: {body}"); Ok(()) } ``` ## Wildcard subscriptions [#wildcard-subscriptions] Wildcard filters are accepted on the **Events pattern only**. Two wildcards map onto the broker's native channel wildcards: | MQTT wildcard | KubeMQ wildcard | Matches | | ------------- | --------------- | ---------------------------------------------------------------------------------------------------- | | `+` | `*` | exactly one segment — `events/demo/+` matches `events/demo/x`, not `events/demo/a/b` | | `#` | `>` | one or more trailing segments (must be the last token) — `events/demo/#` matches `events/demo/a/b/c` | A bare `#` subscribes to all Events topics (it resolves through the `events` `DefaultPattern`). A wildcard on any non-Events prefix (`store/#`, `queues/#`, …) is rejected with SUBACK `0xA2`. **One filter per subscription.** Each distinct subscribe filter is an independent bridge entry. If several of your active filters match the same publish, you receive **one copy per matching filter** — there is no cross-filter de-duplication. Use a single, specific filter per subscription to avoid duplicate delivery. ## User Properties and Tags (MQTT 5.0) [#user-properties-and-tags-mqtt-50] On MQTT 5.0 connections, a publisher's `PUBLISH` User Properties are copied to the KubeMQ message `Tags`, and a v5 subscriber receives those `Tags` back as User Properties on delivery. Duplicate keys: last wins. The caps are **32 properties** and **4096 bytes total** (all key + value lengths); exceeding either returns PUBACK `0x97` and the message is dropped. MQTT 3.1.1 connections carry no User Properties — nothing is propagated in either direction. ## Related [#related] # Queries (/connectors/mqtt/how-to/queries) Queries are **RPC with a data response** over the MQTT connector. An MQTT client publishes to `queries/` and a **gRPC-side responder** returns a payload (body + metadata). The flow is identical to [Commands](/connectors/mqtt/how-to/commands) — subscribe to a reply topic, publish with a response-topic and correlation-data, wait for the reply — the only difference is the reply shape: **a command returns an executed/error status with no body; a query returns an actual data payload.** ## Overview [#overview] The `queries/` prefix selects the Queries pattern (`/` → `.`: `queries/inventory/check` → channel `inventory.check`). The MQTT client is always the **caller**; the responder runs on the **gRPC side** and cannot be an MQTT client. | Step | MQTT action | Notes | | ------------------ | ------------------------------------------------------------ | ------------------------------------------------------- | | Subscribe to reply | `SUBSCRIBE $reply//` | mochi-local; never routed to the broker | | Send query | `PUBLISH queries/` + `ResponseTopic` + `CorrelationData` | `SendQueryRequest` to the gRPC responder | | Receive PUBACK | immediate `PUBACK 0x00` | acks receipt **only**, not the response | | Receive response | `PUBLISH` on the reply topic | **non-empty body** + `kubemq-metadata` + responder tags | | Aspect | Commands | Queries | | ------------------- | --------------------------------- | ---------------------------------- | | MQTT prefix | `commands/` | `queries/` | | Response body | always empty | present — the responder's data | | Response user-props | `kubemq-executed`, `kubemq-error` | `kubemq-metadata` + responder tags | | Use case | "execute this; did it work?" | "give me data" | **Queries require MQTT 5.0.** Like Commands, the flow relies on the MQTT 5.0 `ResponseTopic` and `CorrelationData` properties, which MQTT 3.1.1 lacks. A v3.1.1 publish to `queries/` is **silently dropped** — the connector still returns `PUBACK 0x00`, but the message never reaches a responder and no reply arrives. The Ruby `mqtt` gem is 3.1.1-only, so RPC is unavailable from Ruby; the examples below omit it. The **immediate-PUBACK** rule applies exactly as in [Commands](/connectors/mqtt/how-to/commands#how-it-works): `PUBACK` acks receipt, not the response, so implement your own response-wait timeout on the `$reply` topic. ## How it works [#how-it-works] *The MQTT client is the caller; a gRPC responder answers the query and the response body returns on the client's `$reply` topic.* ## Request and response [#request-and-response] Each example subscribes to its own `$reply//inbox` topic, publishes a query to `queries/demo/rpc` with a response-topic and correlation-data, then reads the response **body** and `kubemq-metadata`. A gRPC responder must be running on channel `demo.rpc`. Every client reads the broker endpoint from `KUBEMQ_MQTT_URL` (default `tcp://localhost:1883`). Ruby is omitted — RPC requires MQTT 5.0 and the `mqtt` gem is 3.1.1-only. ```go package main import ( "context" "fmt" "log" "net" "os" "strings" "time" "github.com/eclipse/paho.golang/paho" "github.com/google/uuid" ) func brokerURL() string { if u := os.Getenv("KUBEMQ_MQTT_URL"); u != "" { return u } return "tcp://localhost:1883" } func tcpAddr(raw string) string { for _, pfx := range []string{"tcp://", "ws://", "tls://"} { if strings.HasPrefix(raw, pfx) { return raw[len(pfx):] } } return raw } func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() addr := tcpAddr(brokerURL()) clientID := "go-query-" + uuid.NewString()[:8] correlationID := uuid.NewString() replyTopic := "$reply/" + clientID + "/inbox" // own namespace only queryTopic := "queries/demo/rpc" // channel demo.rpc resp := make(chan *paho.Publish, 1) conn, err := net.Dial("tcp", addr) if err != nil { log.Fatalf("dial: %v", err) } client := paho.NewClient(paho.ClientConfig{ Conn: conn, OnPublishReceived: []func(paho.PublishReceived) (bool, error){ func(pr paho.PublishReceived) (bool, error) { select { case resp <- pr.Packet: default: } return true, nil }, }, }) ack, err := client.Connect(ctx, &paho.Connect{ClientID: clientID, KeepAlive: 30, CleanStart: true}) if err != nil || ack.ReasonCode != 0 { log.Fatalf("connect: %v (reason 0x%02X)", err, ack.ReasonCode) } // 1. Subscribe to your own reply topic BEFORE publishing. subAck, err := client.Subscribe(ctx, &paho.Subscribe{ Subscriptions: []paho.SubscribeOptions{{Topic: replyTopic, QoS: 1}}, }) if err != nil { log.Fatalf("subscribe: %v", err) } if subAck.Reasons[0] == 0x83 { log.Fatal("SUBACK 0x83: reply topic must be $reply//...") } // 2. Publish the query with ResponseTopic + CorrelationData. pubAck, err := client.Publish(ctx, &paho.Publish{ Topic: queryTopic, QoS: 1, Payload: []byte(`{"action":"echo","data":"hello"}`), Properties: &paho.PublishProperties{ ResponseTopic: replyTopic, CorrelationData: []byte(correlationID), }, }) if err != nil { log.Fatalf("publish: %v", err) } // PUBACK is immediate — the response arrives later on the reply topic. if pubAck.ReasonCode != 0 { log.Fatalf("PUBACK reason=0x%02X (0x83=bad ResponseTopic, 0x97=RpcMaxPending)", pubAck.ReasonCode) } // 3. Wait for the response — queries carry a body (plus kubemq-metadata). select { case r := <-resp: var metadata string if r.Properties != nil { for _, up := range r.Properties.User { if up.Key == "kubemq-metadata" { metadata = up.Value } } } fmt.Printf("query response body: %s (metadata=%s)\n", r.Payload, metadata) case <-ctx.Done(): log.Fatal("timed out — is a gRPC responder running on channel demo.rpc?") } _ = client.Disconnect(&paho.Disconnect{ReasonCode: 0}) } ``` ```python import os import threading import time import uuid import paho.mqtt.client as mqtt from paho.mqtt.enums import CallbackAPIVersion from paho.mqtt.properties import Properties from paho.mqtt.packettypes import PacketTypes CLIENT_ID = "py-query-client" QUERY_TOPIC = "queries/demo/rpc" # channel demo.rpc REPLY_TOPIC = f"$reply/{CLIENT_ID}/inbox" # own namespace only CORRELATION = uuid.uuid4().bytes def parse_url(url: str) -> tuple[str, int]: scheme, rest = url.split("://", 1) host, _, port = rest.rstrip("/").partition(":") return host, int(port) if port else {"tcp": 1883, "tls": 8883, "ws": 8083}[scheme] def main() -> None: host, port = parse_url(os.environ.get("KUBEMQ_MQTT_URL", "tcp://localhost:1883")) response = threading.Event() result: dict = {} def on_message(client, userdata, msg): props = {} if msg.properties and hasattr(msg.properties, "UserProperty"): for k, v in (msg.properties.UserProperty or []): props[k] = v result["body"] = msg.payload result["metadata"] = props.get("kubemq-metadata") response.set() client = mqtt.Client(CallbackAPIVersion.VERSION2, client_id=CLIENT_ID, protocol=mqtt.MQTTv5) # 1. Subscribe to your own reply topic BEFORE publishing. client.on_connect = lambda c, *_: c.subscribe(REPLY_TOPIC, qos=1) client.on_message = on_message client.connect(host, port, keepalive=30) client.loop_start() time.sleep(0.5) # 2. Publish the query with ResponseTopic + CorrelationData. pub_props = Properties(PacketTypes.PUBLISH) pub_props.ResponseTopic = REPLY_TOPIC pub_props.CorrelationData = CORRELATION # PUBACK is immediate — the response arrives later on the reply topic. client.publish(QUERY_TOPIC, payload=b'{"query": "hello"}', qos=1, properties=pub_props).wait_for_publish(10) # 3. Wait for the response — queries carry a body (plus kubemq-metadata). if not response.wait(timeout=30): raise TimeoutError("no response — is a gRPC responder running on channel demo.rpc?") print(f"query response body: {result['body'].decode()!r} (metadata={result['metadata']})") client.loop_stop() client.disconnect() if __name__ == "__main__": main() ``` ```java import java.nio.charset.StandardCharsets; import java.util.UUID; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; import org.eclipse.paho.mqttv5.client.MqttAsyncClient; import org.eclipse.paho.mqttv5.client.MqttCallback; import org.eclipse.paho.mqttv5.client.MqttConnectionOptions; import org.eclipse.paho.mqttv5.client.MqttDisconnectResponse; import org.eclipse.paho.mqttv5.client.IMqttToken; import org.eclipse.paho.mqttv5.common.MqttException; import org.eclipse.paho.mqttv5.common.MqttMessage; import org.eclipse.paho.mqttv5.common.packet.MqttProperties; import org.eclipse.paho.mqttv5.common.packet.UserProperty; public final class Main { public static void main(String[] args) throws Exception { String broker = System.getenv().getOrDefault("KUBEMQ_MQTT_URL", "tcp://localhost:1883"); String clientId = "java-query-" + UUID.randomUUID().toString().substring(0, 6); String queryTopic = "queries/demo/rpc"; // channel demo.rpc String replyTopic = "$reply/" + clientId + "/inbox"; // own namespace only byte[] correlation = "corr-123".getBytes(StandardCharsets.UTF_8); BlockingQueue responses = new ArrayBlockingQueue<>(1); MqttAsyncClient client = new MqttAsyncClient(broker, clientId); client.setCallback(new MqttCallback() { public void messageArrived(String t, MqttMessage m) { responses.offer(m); } public void disconnected(MqttDisconnectResponse r) {} public void mqttErrorOccurred(MqttException e) {} public void deliveryComplete(IMqttToken t) {} public void connectComplete(boolean reconnect, String uri) {} public void authPacketArrived(int code, MqttProperties props) {} }); MqttConnectionOptions opts = new MqttConnectionOptions(); opts.setCleanStart(true); opts.setKeepAliveInterval(30); client.connect(opts).waitForCompletion(5_000); // 1. Subscribe to your own reply topic BEFORE publishing. client.subscribe(replyTopic, 1).waitForCompletion(5_000); Thread.sleep(200); // 2. Publish the query with ResponseTopic + CorrelationData. MqttProperties pubProps = new MqttProperties(); pubProps.setResponseTopic(replyTopic); pubProps.setCorrelationData(correlation); MqttMessage msg = new MqttMessage("ping".getBytes(StandardCharsets.UTF_8)); msg.setQos(1); msg.setProperties(pubProps); // PUBACK is immediate — the response arrives later on the reply topic. client.publish(queryTopic, msg).waitForCompletion(5_000); // 3. Wait for the response — queries carry a body (plus kubemq-metadata). MqttMessage response = responses.poll(15, TimeUnit.SECONDS); if (response == null) { throw new IllegalStateException("no response — is a gRPC responder running on channel demo.rpc?"); } String body = new String(response.getPayload(), StandardCharsets.UTF_8); String metadata = ""; MqttProperties props = response.getProperties(); if (props != null && props.getUserProperties() != null) { for (UserProperty up : props.getUserProperties()) { if ("kubemq-metadata".equals(up.getKey())) metadata = up.getValue(); } } System.out.printf("query response body: %s (metadata=%s)%n", body, metadata); client.disconnect().waitForCompletion(3_000); client.close(); } } ``` ```typescript import mqtt, { type MqttClient } from "mqtt"; import crypto from "node:crypto"; const CLIENT_ID = `js-query-${crypto.randomBytes(4).toString("hex")}`; const REPLY_TOPIC = `$reply/${CLIENT_ID}/inbox`; // own namespace only const QUERY_TOPIC = "queries/demo/rpc"; // channel demo.rpc const CORRELATION_DATA = Buffer.from(crypto.randomUUID()); const RPC_TIMEOUT_MS = 30_000; async function main(): Promise { const url = process.env["KUBEMQ_MQTT_URL"] ?? "tcp://localhost:1883"; const client: MqttClient = mqtt.connect(url, { clientId: CLIENT_ID, protocolVersion: 5, clean: true, keepalive: 30, }); await new Promise((res, rej) => { client.once("connect", () => res()); client.once("error", rej); }); // 1. Subscribe to your own reply topic BEFORE publishing. await new Promise((resolve, reject) => { client.subscribe(REPLY_TOPIC, { qos: 1 }, (err, granted) => { if (err) return reject(err); if (((granted?.[0]?.qos as number) ?? -1) > 2) return reject(new Error("reply subscribe rejected")); resolve(); }); }); // 2. Arm the response handler, then publish with ResponseTopic + CorrelationData. const responded = new Promise((resolve, reject) => { const timer = setTimeout( () => reject(new Error("RPC timeout — is a gRPC responder running on channel demo.rpc?")), RPC_TIMEOUT_MS, ); client.on("message", (topic, payload, packet) => { if (topic !== REPLY_TOPIC) return; clearTimeout(timer); const userProps = (packet.properties as { userProperties?: Record } | undefined) ?.userProperties ?? {}; // Query responses carry a body (unlike commands). console.log(`query response body: ${payload.toString()} (metadata=${userProps["kubemq-metadata"] ?? ""})`); resolve(); }); }); await new Promise((resolve, reject) => { client.publish( QUERY_TOPIC, JSON.stringify({ request: "hello" }), { qos: 1, properties: { responseTopic: REPLY_TOPIC, correlationData: CORRELATION_DATA } }, (err) => (err ? reject(err) : resolve()), // PUBACK is immediate, not the response ); }); await responded; await client.endAsync(); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using System.Text; using MQTTnet; using MQTTnet.Client; using MQTTnet.Protocol; static (string host, int port) Endpoint() { var url = Environment.GetEnvironmentVariable("KUBEMQ_MQTT_URL") ?? "tcp://localhost:1883"; foreach (var pfx in new[] { "tcp://", "tls://", "ws://" }) if (url.StartsWith(pfx, StringComparison.OrdinalIgnoreCase)) url = url[pfx.Length..]; var parts = url.TrimEnd('/').Split(':'); return (parts[0], parts.Length > 1 ? int.Parse(parts[1]) : 1883); } var (host, port) = Endpoint(); var clientId = $"csharp-query-{Guid.NewGuid():N}"[..23]; var requestId = Guid.NewGuid().ToString("N"); var replyTopic = $"$reply/{clientId}/inbox"; // own namespace only const string queryTopic = "queries/demo/rpc"; // channel demo.rpc var factory = new MqttFactory(); var responded = new TaskCompletionSource<(string body, string metadata)>( TaskCreationOptions.RunContinuationsAsynchronously); using var client = factory.CreateMqttClient(); client.ApplicationMessageReceivedAsync += e => { var body = Encoding.UTF8.GetString(e.ApplicationMessage.PayloadSegment); var metadata = string.Empty; foreach (var p in e.ApplicationMessage.UserProperties ?? new()) if (p.Name == "kubemq-metadata") metadata = p.Value; responded.TrySetResult((body, metadata)); return Task.CompletedTask; }; var options = new MqttClientOptionsBuilder() .WithTcpServer(host, port) .WithClientId(clientId) .WithProtocolVersion(MQTTnet.Formatter.MqttProtocolVersion.V500) .WithCleanSession(true) .Build(); await client.ConnectAsync(options); // 1. Subscribe to your own reply topic BEFORE publishing. var subResult = await client.SubscribeAsync(new MqttClientSubscribeOptionsBuilder() .WithTopicFilter(f => f.WithTopic(replyTopic).WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)) .Build()); if (subResult.Items.First().ResultCode > MqttClientSubscribeResultCode.GrantedQoS2) throw new Exception("reply subscribe rejected — use $reply// namespace"); await Task.Delay(200); // 2. Publish the query with ResponseTopic + CorrelationData. var pubResult = await client.PublishAsync(new MqttApplicationMessageBuilder() .WithTopic(queryTopic) .WithPayload(Encoding.UTF8.GetBytes($"{{\"query\":\"hello\",\"requestId\":\"{requestId}\"}}")) .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce) .WithResponseTopic(replyTopic) .WithCorrelationData(Encoding.UTF8.GetBytes(requestId)) .Build()); // PUBACK is immediate — the response arrives later on the reply topic. if (pubResult.ReasonCode != MqttClientPublishReasonCode.Success) throw new Exception($"PUBACK reason: {pubResult.ReasonCode}"); // 3. Wait for the response — queries carry a body (plus kubemq-metadata). using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); var (body, metadata) = await responded.Task.WaitAsync(cts.Token); Console.WriteLine($"query response body: {body} (metadata={metadata})"); await client.DisconnectAsync(); ``` ```rust use bytes::Bytes; use rumqttc::v5::mqttbytes::v5::{Packet, PublishProperties}; use rumqttc::v5::mqttbytes::QoS; use rumqttc::v5::{AsyncClient, Event, MqttOptions}; use std::env; use tokio::sync::oneshot; use tokio::time::{timeout, Duration}; use uuid::Uuid; fn parse_host_port(url: &str) -> (String, u16) { let stripped = url .trim_start_matches("tcp://") .trim_start_matches("tls://") .trim_start_matches("ws://"); let host_port = stripped.split('/').next().unwrap_or(stripped); let mut parts = host_port.splitn(2, ':'); let host = parts.next().unwrap_or("localhost").to_string(); let port: u16 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(1883); (host, port) } #[tokio::main] async fn main() -> Result<(), Box> { let url = env::var("KUBEMQ_MQTT_URL").unwrap_or_else(|_| "tcp://localhost:1883".to_string()); let (host, port) = parse_host_port(&url); let client_id = format!("rust-query-{}", &Uuid::new_v4().to_string()[..8]); let reply_topic = format!("$reply/{}/inbox", client_id); // own namespace only let query_topic = "queries/demo/rpc"; // channel demo.rpc let correlation = Uuid::new_v4().to_string(); let corr_bytes = Bytes::from(correlation.clone().into_bytes()); let mut opts = MqttOptions::new(&client_id, &host, port); opts.set_keep_alive(Duration::from_secs(30)); let (client, mut eventloop) = AsyncClient::new(opts, 10); let (tx, rx) = oneshot::channel::<(String, Option)>(); let reply_clone = reply_topic.clone(); let corr_check = corr_bytes.clone(); tokio::spawn(async move { let mut tx = Some(tx); loop { match eventloop.poll().await { Ok(Event::Incoming(Packet::Publish(p))) => { if String::from_utf8_lossy(&p.topic) != reply_clone { continue; } let props = p.properties.as_ref(); // Match the response to the request via CorrelationData. if props.and_then(|pr| pr.correlation_data.clone()).as_deref() != Some(corr_check.as_ref()) { continue; } // Query responses carry a body (plus kubemq-metadata). let body = String::from_utf8_lossy(&p.payload).to_string(); let metadata = props .and_then(|pr| pr.user_properties.iter().find(|(k, _)| k == "kubemq-metadata")) .map(|(_, v)| v.clone()); if let Some(tx) = tx.take() { let _ = tx.send((body, metadata)); return; } } Ok(_) => {} Err(e) => { eprintln!("event loop: {e}"); return; } } } }); // 1. Subscribe to your own reply topic BEFORE publishing. client.subscribe(&reply_topic, QoS::AtLeastOnce).await?; tokio::time::sleep(Duration::from_millis(300)).await; // 2. Publish the query with ResponseTopic + CorrelationData. let props = PublishProperties { response_topic: Some(reply_topic.clone()), correlation_data: Some(corr_bytes), ..Default::default() }; // PUBACK is immediate — the response arrives later on the reply topic. client .publish_with_properties( query_topic, QoS::AtLeastOnce, false, br#"{"action":"echo","data":"hello"}"#.as_ref(), props, ) .await?; // 3. Wait for the response — queries carry a body (plus kubemq-metadata). let (body, metadata) = timeout(Duration::from_secs(30), rx) .await .map_err(|_| "timed out — is a gRPC responder running on channel demo.rpc?")??; println!("query response body: {body} (metadata={})", metadata.as_deref().unwrap_or("")); Ok(()) } ``` ## The response shape [#the-response-shape] A query response carries the responder's data: | Field | Type | Meaning | | --------------------- | ----------------- | --------------------------------------------------------------------- | | Payload | bytes | The response body returned by the gRPC responder | | `kubemq-metadata` | string (optional) | A metadata string set by the responder | | Other user properties | string | Any KubeMQ `Tags` the responder attached | | `CorrelationData` | bytes | Echoed from the request — use it to match the response to the request | Set a unique `CorrelationData` per request so you can match responses when multiple queries are in flight. The same reason codes and responder rules apply as for [Commands](/connectors/mqtt/how-to/commands#reason-codes-and-responders): `PUBACK 0x83` for a foreign `ResponseTopic`, `PUBACK 0x97` for `RpcMaxPending`, `SUBACK 0x83` for an MQTT client trying to subscribe as a responder. Responders run on the gRPC side (`SubscribeToQueries` + `SendQueryResponse`). ## Related [#related] # Queues (/connectors/mqtt/how-to/queues) Queues are durable, **point-to-point** work queues over the MQTT connector. A message is enqueued in KubeMQ and delivered to **exactly one** consumer (competing-consumer semantics). Over MQTT the produce and consume sides are asymmetric: producing is a plain publish, while consuming requires an MQTT 5.0 **shared subscription**. ## Overview [#overview] The `queues/` prefix selects the Queues pattern (`/` → `.` in the channel: `queues/jobs/email` → `jobs.email`). A producer plain-publishes to `queues/` on any MQTT version; a consumer subscribes to `$share//queues/` on **MQTT 5.0 only**. Acknowledgement is **ack-on-PUBACK**: the consumer's `PUBACK` removes the message from the queue. | Operation | MQTT action | KubeMQ mapping | | --------- | --------------------------------------------------------------- | ------------------------------------------------- | | Produce | `PUBLISH queues/` (any QoS, any version) | `SendQueueMessage` — durably enqueued | | Consume | `SUBSCRIBE $share//queues/` (QoS ≥ 1, MQTT 5.0) | Credit-driven `Get`; each message to one consumer | | Ack | `PUBACK` from the consumer | Message acknowledged and removed | | Redeliver | no PUBACK within `QueueAckTimeoutSeconds` (30 s), or disconnect | NAck → redelivered to another consumer | **Queues are publish-only over MQTT; consuming requires MQTT 5.0.** You produce with a plain `PUBLISH queues/`, but you can only consume through an MQTT 5.0 **shared subscription** `$share//queues/`. A plain `queues/` subscribe is rejected with SUBACK `0x83`, and a QoS-0 shared subscribe is rejected with SUBACK `0x83`. **MQTT 3.1.1 has no shared subscriptions, so it cannot consume Queues at all** — a 3.1.1 client can produce but never consume. The Ruby `mqtt` gem is 3.1.1-only and is therefore produce-only for Queues. ## How it works [#how-it-works] A producer enqueues messages on the plain `queues/` topic. Competing consumers attach via `$share//queues/`; the broker hands each message to exactly one of them, and the consumer's `PUBACK` acks it. *Each queued message is delivered to exactly one shared-subscription consumer; the consumer's `PUBACK` acknowledges and removes it.* **The `$share` group name is audit-only.** Unlike standard MQTT shared subscriptions, the `` token in `$share//queues/` does **not** create per-group copies. All consumers — across every group name — compete in one KubeMQ queue pool; a message goes to exactly one of them regardless of group. The group is recorded only in connection metadata and metrics. For per-group fan-out, use [Events](/connectors/mqtt/how-to/events) or [Events Store](/connectors/mqtt/how-to/events-store). ## Produce and consume [#produce-and-consume] Each example consumes through an MQTT 5.0 shared subscription `$share/g1/queues/demo/q`, then produces a plain publish to `queues/demo/q`, and confirms the message is delivered to exactly one consumer (the auto-`PUBACK` acks it). Every client reads the broker endpoint from `KUBEMQ_MQTT_URL` (default `tcp://localhost:1883`). Ruby is omitted from the consume round-trip — the `mqtt` gem is MQTT 3.1.1-only and shared subscriptions require MQTT 5.0; the produce side ([Events](/connectors/mqtt/how-to/events) shows the same plain-publish form) works from Ruby. ```go package main import ( "context" "fmt" "log" "net" "os" "strings" "time" "github.com/eclipse/paho.golang/paho" "github.com/google/uuid" ) const produceTopic = "queues/demo/q" // plain publish; channel demo.q const consumeTopic = "$share/g1/queues/demo/q" // MQTT 5.0 shared subscription func brokerURL() string { if u := os.Getenv("KUBEMQ_MQTT_URL"); u != "" { return u } return "tcp://localhost:1883" } func tcpAddr(raw string) string { for _, pfx := range []string{"tcp://", "ws://", "tls://"} { if strings.HasPrefix(raw, pfx) { return raw[len(pfx):] } } return raw } func dial(ctx context.Context, addr, id string, onMsg func(paho.PublishReceived) (bool, error)) *paho.Client { conn, err := net.Dial("tcp", addr) if err != nil { log.Fatalf("dial: %v", err) } cfg := paho.ClientConfig{Conn: conn} if onMsg != nil { cfg.OnPublishReceived = []func(paho.PublishReceived) (bool, error){onMsg} } c := paho.NewClient(cfg) ack, err := c.Connect(ctx, &paho.Connect{ClientID: id, KeepAlive: 30, CleanStart: true}) if err != nil { log.Fatalf("connect: %v", err) } if ack.ReasonCode != 0 { log.Fatalf("CONNACK reason=0x%02X", ack.ReasonCode) } return c } func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() addr := tcpAddr(brokerURL()) sfx := uuid.NewString()[:8] // 1. CONSUME — MQTT 5.0 shared subscription. Returning true sends PUBACK, // which is how KubeMQ marks the message acknowledged. got := make(chan string, 1) consumer := dial(ctx, addr, "go-q-worker-"+sfx, func(pr paho.PublishReceived) (bool, error) { got <- string(pr.Packet.Payload) return true, nil }) subAck, err := consumer.Subscribe(ctx, &paho.Subscribe{ Subscriptions: []paho.SubscribeOptions{{Topic: consumeTopic, QoS: 1}}, }) if err != nil { log.Fatalf("subscribe: %v", err) } // SUBACK 0x83 = plain queues/ subscribe, QoS-0 shared subscribe, or wrong pattern. if subAck.Reasons[0] == 0x83 { log.Fatal("SUBACK 0x83: use the $share//queues/ form at QoS >= 1") } time.Sleep(300 * time.Millisecond) // 2. PRODUCE — plain publish to queues/. producer := dial(ctx, addr, "go-q-prod-"+sfx, nil) pubAck, err := producer.Publish(ctx, &paho.Publish{ Topic: produceTopic, QoS: 1, Payload: []byte(`{"job":1}`), }) if err != nil { log.Fatalf("publish: %v", err) } if pubAck.ReasonCode != 0 { log.Fatalf("PUBACK reason=0x%02X", pubAck.ReasonCode) } // 3. RECEIVE. select { case msg := <-got: fmt.Printf("consumed: %s (acked via PUBACK)\n", msg) case <-ctx.Done(): log.Fatal("timed out waiting for the queue message") } _ = consumer.Disconnect(&paho.Disconnect{ReasonCode: 0}) _ = producer.Disconnect(&paho.Disconnect{ReasonCode: 0}) } ``` ```python import os import threading import time import paho.mqtt.client as mqtt from paho.mqtt.enums import CallbackAPIVersion PRODUCE_TOPIC = "queues/demo/q" # plain publish; channel demo.q CONSUME_TOPIC = "$share/g1/queues/demo/q" # MQTT 5.0 shared subscription def parse_url(url: str) -> tuple[str, int]: scheme, rest = url.split("://", 1) host, _, port = rest.rstrip("/").partition(":") return host, int(port) if port else {"tcp": 1883, "tls": 8883, "ws": 8083}[scheme] def main() -> None: host, port = parse_url(os.environ.get("KUBEMQ_MQTT_URL", "tcp://localhost:1883")) received = threading.Event() payload: list[bytes] = [] subscribed = threading.Event() # 1. CONSUME — MQTT 5.0 shared subscription at QoS 1 (QoS 0 -> SUBACK 0x83). consumer = mqtt.Client(CallbackAPIVersion.VERSION2, client_id="py-q-consumer", protocol=mqtt.MQTTv5) consumer.on_connect = lambda c, *_: c.subscribe(CONSUME_TOPIC, qos=1) consumer.on_subscribe = lambda *_: subscribed.set() consumer.on_message = lambda c, u, m: (payload.append(m.payload), received.set()) consumer.connect(host, port, keepalive=30) consumer.loop_start() if not subscribed.wait(timeout=10): raise TimeoutError("shared subscription not established") # 2. PRODUCE — plain publish to queues/. producer = mqtt.Client(CallbackAPIVersion.VERSION2, client_id="py-q-producer", protocol=mqtt.MQTTv5) producer.connect(host, port, keepalive=30) producer.loop_start() producer.publish(PRODUCE_TOPIC, payload=b'{"job": 1}', qos=1).wait_for_publish(10) # 3. RECEIVE. The auto-PUBACK acks the message to KubeMQ. if not received.wait(timeout=15): raise TimeoutError("timed out waiting for the queue message") print(f"consumed: {payload[0].decode()!r} (acked via PUBACK)") producer.loop_stop(); producer.disconnect() consumer.loop_stop(); consumer.disconnect() if __name__ == "__main__": main() ``` ```java import java.nio.charset.StandardCharsets; import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import org.eclipse.paho.mqttv5.client.MqttAsyncClient; import org.eclipse.paho.mqttv5.client.MqttCallback; import org.eclipse.paho.mqttv5.client.MqttConnectionOptions; import org.eclipse.paho.mqttv5.client.MqttDisconnectResponse; import org.eclipse.paho.mqttv5.client.IMqttToken; import org.eclipse.paho.mqttv5.common.MqttException; import org.eclipse.paho.mqttv5.common.MqttMessage; import org.eclipse.paho.mqttv5.common.packet.MqttProperties; public final class Main { private static final String PRODUCE_TOPIC = "queues/demo/q"; // plain publish; channel demo.q private static final String CONSUME_TOPIC = "$share/g1/queues/demo/q"; // MQTT 5.0 shared subscription public static void main(String[] args) throws Exception { String broker = System.getenv().getOrDefault("KUBEMQ_MQTT_URL", "tcp://localhost:1883"); MqttConnectionOptions opts = new MqttConnectionOptions(); opts.setCleanStart(true); opts.setKeepAliveInterval(30); CountDownLatch received = new CountDownLatch(1); String[] body = {null}; // 1. CONSUME — MQTT 5.0 shared subscription at QoS 1 (QoS 0 -> SUBACK 0x83). // Paho auto-sends PUBACK when messageArrived returns, acking the message. MqttAsyncClient consumer = new MqttAsyncClient(broker, "java-q-consumer-" + UUID.randomUUID().toString().substring(0, 6)); consumer.setCallback(new MqttCallback() { public void messageArrived(String t, MqttMessage m) { body[0] = new String(m.getPayload(), StandardCharsets.UTF_8); received.countDown(); } public void disconnected(MqttDisconnectResponse r) {} public void mqttErrorOccurred(MqttException e) {} public void deliveryComplete(IMqttToken t) {} public void connectComplete(boolean reconnect, String uri) {} public void authPacketArrived(int code, MqttProperties props) {} }); consumer.connect(opts).waitForCompletion(10_000); IMqttToken subToken = consumer.subscribe(CONSUME_TOPIC, 1); subToken.waitForCompletion(10_000); // SUBACK 0x83 = plain queues/ subscribe, QoS-0 shared subscribe, or wrong pattern. if (subToken.getGrantedQos()[0] == 0x83) { throw new IllegalStateException("SUBACK 0x83: use $share//queues/ at QoS >= 1"); } Thread.sleep(400); // 2. PRODUCE — plain publish to queues/. MqttAsyncClient producer = new MqttAsyncClient(broker, "java-q-producer-" + UUID.randomUUID().toString().substring(0, 6)); producer.connect(opts).waitForCompletion(10_000); MqttMessage msg = new MqttMessage("{\"job\":1}".getBytes(StandardCharsets.UTF_8)); msg.setQos(1); producer.publish(PRODUCE_TOPIC, msg).waitForCompletion(5_000); // 3. RECEIVE. if (!received.await(15, TimeUnit.SECONDS)) { throw new IllegalStateException("timed out waiting for the queue message"); } System.out.printf("consumed: %s (acked via PUBACK)%n", body[0]); producer.disconnect().waitForCompletion(5_000); producer.close(); consumer.disconnect().waitForCompletion(5_000); consumer.close(); } } ``` ```typescript import mqtt, { type MqttClient } from "mqtt"; const PRODUCE_TOPIC = "queues/demo/q"; // plain publish; channel demo.q const CONSUME_TOPIC = "$share/g1/queues/demo/q"; // MQTT 5.0 shared subscription function connect(url: string, clientId: string): Promise { return new Promise((resolve, reject) => { const client = mqtt.connect(url, { clientId, protocolVersion: 5, clean: true, keepalive: 30 }); client.once("connect", () => resolve(client)); client.once("error", reject); }); } async function main(): Promise { const url = process.env["KUBEMQ_MQTT_URL"] ?? "tcp://localhost:1883"; // 1. CONSUME — MQTT 5.0 shared subscription at QoS 1 (QoS 0 / plain queues/ -> SUBACK 0x83). const consumer = await connect(url, "js-q-consumer"); const received = new Promise((resolve) => { consumer.on("message", (_topic, payload) => resolve(payload.toString())); }); await new Promise((resolve, reject) => { consumer.subscribe(CONSUME_TOPIC, { qos: 1 }, (err) => err ? reject(new Error("subscribe rejected — use $share//queues/ at QoS >= 1")) : resolve(), ); }); await new Promise((r) => setTimeout(r, 300)); // 2. PRODUCE — plain publish to queues/. const producer = await connect(url, "js-q-producer"); await new Promise((resolve, reject) => { producer.publish(PRODUCE_TOPIC, JSON.stringify({ job: 1 }), { qos: 1 }, (err) => err ? reject(err) : resolve(), ); }); // 3. RECEIVE. The auto-PUBACK acks the message to KubeMQ. console.log(`consumed: ${await received} (acked via PUBACK)`); await producer.endAsync(); await consumer.endAsync(); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using System.Text; using MQTTnet; using MQTTnet.Client; using MQTTnet.Protocol; static (string host, int port) Endpoint() { var url = Environment.GetEnvironmentVariable("KUBEMQ_MQTT_URL") ?? "tcp://localhost:1883"; foreach (var pfx in new[] { "tcp://", "tls://", "ws://" }) if (url.StartsWith(pfx, StringComparison.OrdinalIgnoreCase)) url = url[pfx.Length..]; var parts = url.TrimEnd('/').Split(':'); return (parts[0], parts.Length > 1 ? int.Parse(parts[1]) : 1883); } var (host, port) = Endpoint(); const string produceTopic = "queues/demo/q"; // plain publish; channel demo.q const string consumeTopic = "$share/g1/queues/demo/q"; // MQTT 5.0 shared subscription var factory = new MqttFactory(); var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); MqttClientOptions Options(string id) => new MqttClientOptionsBuilder() .WithTcpServer(host, port) .WithClientId($"{id}-{Guid.NewGuid():N}"[..26]) .WithProtocolVersion(MQTTnet.Formatter.MqttProtocolVersion.V500) .WithCleanSession(true) .Build(); // 1. CONSUME — MQTT 5.0 shared subscription at QoS 1 (QoS 0 / plain queues/ -> SUBACK 0x83). using var consumer = factory.CreateMqttClient(); consumer.ApplicationMessageReceivedAsync += e => { received.TrySetResult(Encoding.UTF8.GetString(e.ApplicationMessage.PayloadSegment)); return Task.CompletedTask; }; await consumer.ConnectAsync(Options("csharp-q-consumer")); var subResult = await consumer.SubscribeAsync(new MqttClientSubscribeOptionsBuilder() .WithTopicFilter(f => f.WithTopic(consumeTopic).WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)) .Build()); if (subResult.Items.First().ResultCode > MqttClientSubscribeResultCode.GrantedQoS2) throw new Exception("subscribe rejected — use $share//queues/ at QoS >= 1"); await Task.Delay(300); // 2. PRODUCE — plain publish to queues/. using var producer = factory.CreateMqttClient(); await producer.ConnectAsync(Options("csharp-q-producer")); var pubResult = await producer.PublishAsync(new MqttApplicationMessageBuilder() .WithTopic(produceTopic) .WithPayload(Encoding.UTF8.GetBytes("{\"job\":1}")) .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce) .Build()); if (pubResult.ReasonCode != MqttClientPublishReasonCode.Success) throw new Exception($"PUBACK reason: {pubResult.ReasonCode}"); // 3. RECEIVE. The auto-PUBACK acks the message to KubeMQ. using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); Console.WriteLine($"consumed: {await received.Task.WaitAsync(cts.Token)} (acked via PUBACK)"); await producer.DisconnectAsync(); await consumer.DisconnectAsync(); ``` ```rust use rumqttc::v5::mqttbytes::v5::Packet; use rumqttc::v5::mqttbytes::QoS; use rumqttc::v5::{AsyncClient, Event, MqttOptions}; use std::env; use tokio::sync::oneshot; use tokio::time::{timeout, Duration}; use uuid::Uuid; const PRODUCE_TOPIC: &str = "queues/demo/q"; // plain publish; channel demo.q const CONSUME_TOPIC: &str = "$share/g1/queues/demo/q"; // MQTT 5.0 shared subscription fn parse_host_port(url: &str) -> (String, u16) { let stripped = url .trim_start_matches("tcp://") .trim_start_matches("tls://") .trim_start_matches("ws://"); let host_port = stripped.split('/').next().unwrap_or(stripped); let mut parts = host_port.splitn(2, ':'); let host = parts.next().unwrap_or("localhost").to_string(); let port: u16 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(1883); (host, port) } #[tokio::main] async fn main() -> Result<(), Box> { let url = env::var("KUBEMQ_MQTT_URL").unwrap_or_else(|_| "tcp://localhost:1883".to_string()); let (host, port) = parse_host_port(&url); let sfx = &Uuid::new_v4().to_string()[..8]; // 1. CONSUME — MQTT 5.0 shared subscription. rumqttc auto-sends PUBACK for // QoS 1, which acks the message and removes it from the queue. let mut con_opts = MqttOptions::new(format!("rust-q-con-{sfx}"), &host, port); con_opts.set_keep_alive(Duration::from_secs(30)); let (consumer, mut con_loop) = AsyncClient::new(con_opts, 10); let (msg_tx, msg_rx) = oneshot::channel::(); tokio::spawn(async move { let mut msg = Some(msg_tx); loop { match con_loop.poll().await { Ok(Event::Incoming(Packet::Publish(p))) => { if let Some(tx) = msg.take() { let _ = tx.send(String::from_utf8_lossy(&p.payload).to_string()); } return; } Ok(_) => {} Err(e) => { eprintln!("event loop: {e}"); return; } } } }); consumer.subscribe(CONSUME_TOPIC, QoS::AtLeastOnce).await?; tokio::time::sleep(Duration::from_millis(400)).await; // SUBACK round-trip // 2. PRODUCE — plain publish to queues/. let mut prod_opts = MqttOptions::new(format!("rust-q-prod-{sfx}"), &host, port); prod_opts.set_keep_alive(Duration::from_secs(30)); let (producer, mut prod_loop) = AsyncClient::new(prod_opts, 10); tokio::spawn(async move { while prod_loop.poll().await.is_ok() {} }); producer.publish(PRODUCE_TOPIC, QoS::AtLeastOnce, false, br#"{"job":1}"#.as_ref()).await?; // 3. RECEIVE. let body = timeout(Duration::from_secs(15), msg_rx).await??; println!("consumed: {body} (acked via PUBACK)"); Ok(()) } ``` ## Ack model and redelivery [#ack-model-and-redelivery] The connector acks on `PUBACK`: | Event | Outcome | | ---------------------------------------------------------------------- | --------------------------------------------------- | | Consumer sends `PUBACK` within `QueueAckTimeoutSeconds` (default 30 s) | Message acknowledged — removed from the queue | | No `PUBACK` within the timeout | NAck → message redelivered to another consumer | | Consumer disconnects with an unacked message | Immediate NAck → message requeued at once (no loss) | Because a crash before `PUBACK` requeues the message, **consumers must be idempotent** — a message can arrive more than once. Use `clean_session=false` (3.1.1) or `session_expiry_interval > 0` (5.0) so a brief reconnect restores the subscription without re-subscribing. ## Subscribe rejections [#subscribe-rejections] | SUBACK reason | Cause | | ------------- | -------------------------------------------------------------------------------------------------------- | | `0x83` | Plain `queues/` subscribe (no `$share`), QoS-0 shared subscribe, or `$share` on a non-Queues pattern | | `0xA2` | A wildcard inside a queues filter | ## Related [#related] # TLS and WebSocket (/connectors/mqtt/how-to/tls-and-websocket) The KubeMQ MQTT connector exposes up to three listeners at once: plain **TCP** on `1883`, **TLS** over TCP on `8883`, and **WebSocket** on `8083` (path `/`). All three support both MQTT 3.1.1 and MQTT 5.0. Each listener can be enabled or disabled independently. TLS is configured server-side from the top-level **`Security`** block — there is no MQTT-specific TLS field. On a stock dev broker with no `Security` config, the TLS port is open but the listener is **not started**, so the examples use plain `tcp://`. For the shared TLS/security model across KubeMQ connectors, see [Auth & security](/connectors/reference/auth-and-security). ## Listeners at a glance [#listeners-at-a-glance] | Listener | Default port | URL scheme | Env var to disable | | ----------- | ------------ | ----------------- | ---------------------------- | | TCP (plain) | 1883 | `tcp://host:1883` | `CONNECTORSMQTT_PORT=""` | | TLS | 8883 | `tls://host:8883` | `CONNECTORSMQTT_TLS_PORT=""` | | WebSocket | 8083 | `ws://host:8083/` | `CONNECTORSMQTT_WS_PORT=""` | The TCP listener is **always enabled** by default. The WebSocket listener is **always enabled** by default. The TLS listener is **active only when a `Security` configuration is present** — on a default (no-TLS-config) deployment the port is open but the listener is not started. ## The `KUBEMQ_MQTT_URL` selector [#the-kubemq_mqtt_url-selector] Every example reads a single `KUBEMQ_MQTT_URL` environment variable (default `tcp://localhost:1883`) and parses its **scheme** to select the transport — so the same example binary runs over any of the three listeners by changing only the URL: | URL scheme | Transport | Default port | | ----------------- | ------------ | ------------ | | `tcp://host:1883` | Plain TCP | `1883` | | `tls://host:8883` | TLS over TCP | `8883` | | `ws://host:8083/` | WebSocket | `8083` | ```bash # Plain TCP (default) export KUBEMQ_MQTT_URL=tcp://my-kubemq-host:1883 # TLS export KUBEMQ_MQTT_URL=tls://my-kubemq-host:8883 # WebSocket (note the trailing path) export KUBEMQ_MQTT_URL=ws://my-kubemq-host:8083/ ``` ## TLS listener — `tls://host:8883` [#tls-listener--tlshost8883] | Requirement | Details | | ------------------------ | ----------------------------------------------------------------------------------------------- | | KubeMQ `Security` config | Must be configured (`Security.CertFile`, `Security.KeyFile`); the connector derives TLS from it | | Minimum TLS version | 1.2 | | Mutual TLS (mTLS) | Supported — set `Security.CAFile`; the client must present a certificate | | MQTT protocols | Both 3.1.1 and 5.0 | ```go // paho.golang over TLS. For mTLS, load the client cert + key into tlsCfg. import ( "crypto/tls" "net/url" ) tlsCfg := &tls.Config{ // Server-only TLS in dev: set InsecureSkipVerify, or supply RootCAs. // For mTLS: also set Certificates with the client cert + key. MinVersion: tls.VersionTLS12, } brokerURL, _ := url.Parse("tls://broker:8883") conn, err := autopaho.NewConnection(ctx, autopaho.ClientConfig{ BrokerUrls: []*url.URL{brokerURL}, TlsCfg: tlsCfg, KeepAlive: 30, }) ``` ```python # paho-mqtt over TLS. Omit certfile/keyfile for server-only TLS. import ssl import paho.mqtt.client as mqtt from paho.mqtt.enums import CallbackAPIVersion client = mqtt.Client( callback_api_version=CallbackAPIVersion.VERSION2, protocol=mqtt.MQTTv5, ) client.tls_set( ca_certs="ca.crt", # server CA certificate certfile="client.crt", # for mTLS; omit for server-only TLS keyfile="client.key", tls_version=ssl.PROTOCOL_TLS_CLIENT, ) client.connect("broker", 8883, keepalive=30) ``` ```ruby # The Ruby mqtt gem supports TLS over TCP via ssl: true (MQTT 3.1.1 only). require "mqtt" client = MQTT::Client.connect( host: "broker", port: 8883, ssl: true, version: "3.1.1", ) ``` ## WebSocket listener — `ws://host:8083/` [#websocket-listener--wshost8083] The WebSocket listener accepts connections at path **`/`** (`ws://host:8083/`) and supports both MQTT 3.1.1 and 5.0. Note the trailing path — the connector serves WebSocket MQTT at `/`, so include it in the URL. ```typescript // mqtt.js over WebSocket (MQTT 5.0). import * as mqtt from "mqtt"; const client = mqtt.connect("ws://broker:8083/", { protocolVersion: 5, clientId: "my-ws-client", keepalive: 30, clean: true, }); ``` ```go // paho.golang over WebSocket — the URL scheme selects the transport. import "net/url" brokerURL, _ := url.Parse("ws://broker:8083/") conn, err := autopaho.NewConnection(ctx, autopaho.ClientConfig{ BrokerUrls: []*url.URL{brokerURL}, KeepAlive: 30, }) ``` ```python # paho-mqtt over WebSocket — transport="websockets" + ws_set_options(path="/"). import paho.mqtt.client as mqtt from paho.mqtt.enums import CallbackAPIVersion client = mqtt.Client( callback_api_version=CallbackAPIVersion.VERSION2, transport="websockets", protocol=mqtt.MQTTv5, ) client.ws_set_options(path="/") client.connect("broker", 8083, keepalive=30) ``` ```java // Eclipse Paho MQTTv5 over WebSocket. MqttConnectionOptions opts = new MqttConnectionOptions(); opts.setCleanStart(true); opts.setKeepAliveInterval(30); IMqttAsyncClient client = new MqttAsyncClient( "ws://broker:8083/", "my-ws-client", new MemoryPersistence()); client.connect(opts).waitForCompletion(); ``` The Ruby `mqtt` gem supports **TCP and TLS only** — it has **no WebSocket transport**. From Ruby, use the TLS listener for an encrypted channel. ## Disabling listeners [#disabling-listeners] Set a port to an empty string (via environment variable) to disable that listener: ```bash # Disable the TLS listener CONNECTORSMQTT_TLS_PORT="" # Disable the WebSocket listener CONNECTORSMQTT_WS_PORT="" # Disable plain TCP (requires TLS or WebSocket to remain active) CONNECTORSMQTT_PORT="" ``` **Validation:** at least one of `Port`, `TlsPort`, `WsPort` must be non-empty, and all active ports must be distinct. ## Related [#related] # Getting Started (/connectors/mqtt/tutorials/getting-started) Get a message flowing through the KubeMQ MQTT connector in minutes. You point a standard MQTT client at the broker, subscribe to an `events/` topic filter, publish a message to a matching topic, and watch it arrive — all over the native MQTT wire, with no KubeMQ SDK. This walkthrough takes you from a running server to a verified pub/sub round-trip. ## Prerequisites [#prerequisites] * A running **kubemq-server** with the MQTT connector **enabled** and reachable on **port 1883** (plain TCP). The connector is **opt-in (disabled by default)** — see the enable step below. * One of the MQTT clients below for your language (the examples pin a native client per language — there is no KubeMQ SDK). For a quick smoke test, the `mosquitto_pub` / `mosquitto_sub` command-line tools work too. ## Enable the connector [#enable-the-connector] The MQTT connector is **disabled by default** — a stock kubemq-server does **not** bind the MQTT listeners until you turn it on. Enable it with its enable variable: **The enable variable is `CONNECTORSMQTT_ENABLE` — there is no underscore between `CONNECTORS` and `MQTT`, and no `KUBEMQ_` prefix.** This is irregular: most other env vars carry a separator. Variants like `CONNECTORS_MQTT_ENABLE` do **not** bind to the `Connectors.MQTT.Enable` field and are silently ignored. For Kubernetes, set `spec.mqtt.enabled: true` in the `KubemqCluster` CR. Bring up a throwaway local broker with MQTT enabled: Every example reads a single environment variable for the broker endpoint. The scheme selects the transport — `tcp://` (1883), `tls://` (8883), or `ws://` (8083, path `/`): ```bash # default: tcp://localhost:1883 export KUBEMQ_MQTT_URL="tcp://localhost:1883" ``` To **disable** MQTT again after enabling it, set its enable variable to `false`: When `Enable` is `false`, no MQTT listener binds and the rest of the MQTT config is skipped. See [Configuration](/connectors/mqtt/concepts/configuration) for the full settings list. ## How it works [#how-it-works] A subscriber registers a topic filter; a publisher sends to a topic with the same prefix. The connector resolves the topic prefix (`events/`) to a KubeMQ pattern, translates `/` to `.` for the channel, and delivers every published message to matching subscribers. *A publish to `events/demo/x` maps to the Events channel `demo.x`; the `+` wildcard filter `events/demo/+` matches it and the connector delivers the message to the subscriber.* ## Steps [#steps] ### Connect to the broker [#connect-to-the-broker] Open an MQTT 5.0 connection to the endpoint in `KUBEMQ_MQTT_URL`. The connector accepts MQTT 3.1.1 and 5.0 on the same listener; the examples use 5.0 so User-Properties carry across as KubeMQ Tags. The language tabs across all three steps run the **complete** round-trip from a single program: connect a subscriber and a publisher, subscribe to `events/demo/+`, publish one message to `events/demo/x`, and confirm the subscriber receives it. ```go package main import ( "context" "fmt" "log" "net" "os" "strings" "time" "github.com/eclipse/paho.golang/paho" ) func brokerURL() string { if u := os.Getenv("KUBEMQ_MQTT_URL"); u != "" { return u } return "tcp://localhost:1883" } func tcpAddr(rawURL string) string { for _, pfx := range []string{"tcp://", "ws://", "tls://"} { if strings.HasPrefix(rawURL, pfx) { return rawURL[len(pfx):] } } return rawURL } func main() { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() // Subscribe with the single-level '+' wildcard (KubeMQ '*'); publish to a // concrete leaf it matches. "events/demo/x" maps to channel "demo.x". const subFilter = "events/demo/+" const pubTopic = "events/demo/x" addr := tcpAddr(brokerURL()) recvCh := make(chan string, 1) // SUBSCRIBER — connect and subscribe before publishing. subConn, err := net.Dial("tcp", addr) if err != nil { log.Fatalf("sub dial: %v", err) } subClient := paho.NewClient(paho.ClientConfig{ Conn: subConn, OnPublishReceived: []func(paho.PublishReceived) (bool, error){ func(pr paho.PublishReceived) (bool, error) { recvCh <- string(pr.Packet.Payload) return true, nil }, }, }) if _, err := subClient.Connect(ctx, &paho.Connect{ClientID: "go-sub", KeepAlive: 30, CleanStart: true}); err != nil { log.Fatalf("sub connect: %v", err) } subAck, err := subClient.Subscribe(ctx, &paho.Subscribe{ Subscriptions: []paho.SubscribeOptions{{Topic: subFilter, QoS: 1}}, }) if err != nil { log.Fatalf("subscribe: %v", err) } // Reason code > 2 is a rejection (0xA2 wildcard-not-supported, 0x83 impl-specific). if subAck.Reasons[0] > 2 { log.Fatalf("SUBACK reason=0x%02X", subAck.Reasons[0]) } fmt.Printf("[sub] subscribed to %q\n", subFilter) time.Sleep(300 * time.Millisecond) // let the subscription register // PUBLISHER — connect and publish one QoS-1 message. pubConn, err := net.Dial("tcp", addr) if err != nil { log.Fatalf("pub dial: %v", err) } pubClient := paho.NewClient(paho.ClientConfig{Conn: pubConn}) if _, err := pubClient.Connect(ctx, &paho.Connect{ClientID: "go-pub", KeepAlive: 30, CleanStart: true}); err != nil { log.Fatalf("pub connect: %v", err) } if _, err := pubClient.Publish(ctx, &paho.Publish{Topic: pubTopic, QoS: 1, Payload: []byte("hello")}); err != nil { log.Fatalf("publish: %v", err) } fmt.Printf("[pub] published to %q\n", pubTopic) // RECEIVE — wait for the subscriber to get the message. select { case payload := <-recvCh: fmt.Printf("[sub] received: %s (channel demo.x)\n", payload) case <-ctx.Done(): log.Fatal("timed out waiting for the event") } _ = subClient.Disconnect(&paho.Disconnect{ReasonCode: 0}) _ = pubClient.Disconnect(&paho.Disconnect{ReasonCode: 0}) } ``` ```python import os import threading import time import paho.mqtt.client as mqtt from paho.mqtt.enums import CallbackAPIVersion def parse_url(url: str) -> tuple[str, int]: scheme, rest = url.split("://", 1) host_port = rest.rstrip("/") if ":" in host_port: host, port = host_port.rsplit(":", 1) return host, int(port) return host_port, {"tcp": 1883, "tls": 8883, "ws": 8083}.get(scheme, 1883) def main() -> None: host, port = parse_url(os.environ.get("KUBEMQ_MQTT_URL", "tcp://localhost:1883")) # Subscribe with the single-level "+" wildcard; publish to a matching leaf. sub_topic = "events/demo/+" # KubeMQ channel demo.* pub_topic = "events/demo/x" # KubeMQ channel demo.x received = threading.Event() # SUBSCRIBER sub = mqtt.Client(callback_api_version=CallbackAPIVersion.VERSION2, client_id="python-sub", protocol=mqtt.MQTTv5) def on_connect(client, userdata, flags, rc, props): client.subscribe(sub_topic, qos=1) def on_message(client, userdata, msg): print(f"[sub] received: {msg.payload.decode()!r} (channel demo.x)") received.set() sub.on_connect = on_connect sub.on_message = on_message sub.connect(host, port, keepalive=30, clean_start=True) sub.loop_start() time.sleep(0.5) # let the subscription register # PUBLISHER pub = mqtt.Client(callback_api_version=CallbackAPIVersion.VERSION2, client_id="python-pub", protocol=mqtt.MQTTv5) pub.connect(host, port, keepalive=30, clean_start=True) pub.loop_start() pub.publish(pub_topic, payload=b"hello", qos=1).wait_for_publish(timeout=10) print(f"[pub] published to {pub_topic!r}") if not received.wait(timeout=10): raise TimeoutError("timed out waiting for the event") pub.loop_stop(); pub.disconnect() sub.loop_stop(); sub.disconnect() if __name__ == "__main__": main() ``` ```java import java.nio.charset.StandardCharsets; import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import org.eclipse.paho.mqttv5.client.MqttAsyncClient; import org.eclipse.paho.mqttv5.client.MqttCallback; import org.eclipse.paho.mqttv5.client.MqttConnectionOptions; import org.eclipse.paho.mqttv5.client.MqttDisconnectResponse; import org.eclipse.paho.mqttv5.client.IMqttToken; import org.eclipse.paho.mqttv5.client.persist.MemoryPersistence; import org.eclipse.paho.mqttv5.common.MqttException; import org.eclipse.paho.mqttv5.common.MqttMessage; import org.eclipse.paho.mqttv5.common.packet.MqttProperties; public final class Main { public static void main(String[] args) throws Exception { String broker = System.getenv().getOrDefault("KUBEMQ_MQTT_URL", "tcp://localhost:1883"); String subTopic = "events/demo/+"; // single-level '+' wildcard String pubTopic = "events/demo/x"; // KubeMQ channel demo.x MqttConnectionOptions opts = new MqttConnectionOptions(); opts.setCleanStart(true); opts.setKeepAliveInterval(30); CountDownLatch received = new CountDownLatch(1); // SUBSCRIBER MqttAsyncClient sub = new MqttAsyncClient(broker, "java-sub-" + UUID.randomUUID().toString().substring(0, 8), new MemoryPersistence()); sub.setCallback(new MqttCallback() { @Override public void messageArrived(String topic, MqttMessage msg) { System.out.printf("[sub] received: %s (channel demo.x)%n", new String(msg.getPayload(), StandardCharsets.UTF_8)); received.countDown(); } @Override public void disconnected(MqttDisconnectResponse r) { } @Override public void mqttErrorOccurred(MqttException e) { } @Override public void deliveryComplete(IMqttToken t) { } @Override public void connectComplete(boolean reconnect, String uri) { } @Override public void authPacketArrived(int code, MqttProperties p) { } }); sub.connect(opts).waitForCompletion(10_000); sub.subscribe(subTopic, 1).waitForCompletion(10_000); System.out.printf("[sub] subscribed to '%s'%n", subTopic); // PUBLISHER MqttAsyncClient pub = new MqttAsyncClient(broker, "java-pub-" + UUID.randomUUID().toString().substring(0, 8), new MemoryPersistence()); pub.connect(opts).waitForCompletion(10_000); MqttMessage msg = new MqttMessage("hello".getBytes(StandardCharsets.UTF_8)); msg.setQos(1); pub.publish(pubTopic, msg).waitForCompletion(10_000); System.out.printf("[pub] published to '%s'%n", pubTopic); if (!received.await(10, TimeUnit.SECONDS)) { throw new IllegalStateException("timed out waiting for the event"); } pub.disconnect().waitForCompletion(5_000); pub.close(); sub.disconnect().waitForCompletion(5_000); sub.close(); } } ``` ```typescript import mqtt, { type MqttClient } from "mqtt"; function brokerUrl(): string { return process.env["KUBEMQ_MQTT_URL"] ?? "tcp://localhost:1883"; } async function main(): Promise { const url = brokerUrl(); const subFilter = "events/demo/+"; // single-level '+' wildcard const pubTopic = "events/demo/x"; // KubeMQ channel demo.x // SUBSCRIBER const subscriber: MqttClient = mqtt.connect(url, { clientId: "js-sub", protocolVersion: 5, clean: true, }); await new Promise((resolve, reject) => { subscriber.once("connect", () => resolve()); subscriber.once("error", reject); }); const received = new Promise((resolve) => { subscriber.on("message", (topic, payload) => { console.log(`[sub] received: ${payload.toString()} (channel demo.x)`); resolve(); }); }); await new Promise((resolve, reject) => { subscriber.subscribe(subFilter, { qos: 1 }, (err) => (err ? reject(err) : resolve())); }); console.log(`[sub] subscribed to ${subFilter}`); await new Promise((r) => setTimeout(r, 300)); // PUBLISHER const publisher: MqttClient = mqtt.connect(url, { clientId: "js-pub", protocolVersion: 5, clean: true, }); await new Promise((resolve, reject) => { publisher.once("connect", () => resolve()); publisher.once("error", reject); }); await new Promise((resolve, reject) => { publisher.publish(pubTopic, "hello", { qos: 1, retain: false }, (err) => err ? reject(err) : resolve()); }); console.log(`[pub] published to ${pubTopic}`); await received; await publisher.endAsync(); await subscriber.endAsync(); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using System.Text; using MQTTnet; using MQTTnet.Client; using MQTTnet.Protocol; static (string host, int port) ParseEndpoint(string url) { foreach (var prefix in new[] { "tcp://", "tls://", "ws://" }) if (url.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) url = url[prefix.Length..]; var parts = url.TrimEnd('/').Split(':'); return (parts[0], parts.Length > 1 ? int.Parse(parts[1]) : 1883); } var url = Environment.GetEnvironmentVariable("KUBEMQ_MQTT_URL") ?? "tcp://localhost:1883"; var (host, port) = ParseEndpoint(url); const string subTopic = "events/demo/+"; // single-level '+' wildcard const string pubTopic = "events/demo/x"; // KubeMQ channel demo.x var factory = new MqttFactory(); var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); // SUBSCRIBER using var subClient = factory.CreateMqttClient(); subClient.ApplicationMessageReceivedAsync += e => { received.TrySetResult(Encoding.UTF8.GetString(e.ApplicationMessage.PayloadSegment)); return Task.CompletedTask; }; var subOptions = new MqttClientOptionsBuilder() .WithTcpServer(host, port) .WithClientId("csharp-sub") .WithProtocolVersion(MQTTnet.Formatter.MqttProtocolVersion.V500) .WithCleanSession(true) .Build(); await subClient.ConnectAsync(subOptions); await subClient.SubscribeAsync(new MqttClientSubscribeOptionsBuilder() .WithTopicFilter(f => f.WithTopic(subTopic).WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)) .Build()); Console.WriteLine($"[sub] subscribed to '{subTopic}'"); await Task.Delay(300); // PUBLISHER using var pubClient = factory.CreateMqttClient(); var pubOptions = new MqttClientOptionsBuilder() .WithTcpServer(host, port) .WithClientId("csharp-pub") .WithProtocolVersion(MQTTnet.Formatter.MqttProtocolVersion.V500) .WithCleanSession(true) .Build(); await pubClient.ConnectAsync(pubOptions); await pubClient.PublishAsync(new MqttApplicationMessageBuilder() .WithTopic(pubTopic) .WithPayload(Encoding.UTF8.GetBytes("hello")) .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce) .Build()); Console.WriteLine($"[pub] published to '{pubTopic}'"); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var body = await received.Task.WaitAsync(cts.Token); Console.WriteLine($"[sub] received: {body} (channel demo.x)"); await subClient.DisconnectAsync(); await pubClient.DisconnectAsync(); ``` ```ruby # The Ruby `mqtt` gem speaks MQTT 3.1.1 only. require "mqtt" require "uri" require "timeout" uri = URI.parse(ENV.fetch("KUBEMQ_MQTT_URL", "tcp://localhost:1883")) conn = { host: uri.host, port: uri.port, ssl: uri.scheme == "tls" } sub_topic = "events/demo/+" # single-level '+' wildcard pub_topic = "events/demo/x" # KubeMQ channel demo.x received = Queue.new sub_ready = Queue.new # SUBSCRIBER thread subscriber = Thread.new do MQTT::Client.connect(**conn, client_id: "ruby-sub", clean_session: true) do |client| client.subscribe([sub_topic, 1]) sub_ready.push(:ready) topic, payload = client.get received.push(payload) end end sub_ready.pop puts "[sub] subscribed to '#{sub_topic}'" # PUBLISHER MQTT::Client.connect(**conn, client_id: "ruby-pub", clean_session: true) do |client| # retain=false is mandatory — the broker silently drops retained publishes. client.publish(pub_topic, "hello", false, 1) puts "[pub] published to '#{pub_topic}'" end payload = Timeout.timeout(10) { received.pop } puts "[sub] received: #{payload.inspect} (channel demo.x)" subscriber.kill ``` ```rust use rumqttc::v5::mqttbytes::v5::Packet; use rumqttc::v5::mqttbytes::QoS; use rumqttc::v5::{AsyncClient, Event, MqttOptions}; use std::env; use std::time::Duration; use tokio::sync::oneshot; use tokio::time::timeout; fn parse_host_port(url: &str) -> (String, u16) { let stripped = url .trim_start_matches("tcp://") .trim_start_matches("tls://") .trim_start_matches("ws://"); let host_port = stripped.split('/').next().unwrap_or(stripped); let mut parts = host_port.splitn(2, ':'); let host = parts.next().unwrap_or("localhost").to_string(); let port: u16 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(1883); (host, port) } #[tokio::main] async fn main() -> Result<(), Box> { let url = env::var("KUBEMQ_MQTT_URL").unwrap_or_else(|_| "tcp://localhost:1883".to_string()); let (host, port) = parse_host_port(&url); let sub_topic = "events/demo/+"; // single-level '+' wildcard let pub_topic = "events/demo/x"; // KubeMQ channel demo.x // SUBSCRIBER let mut sub_opts = MqttOptions::new("rust-sub", &host, port); sub_opts.set_keep_alive(Duration::from_secs(30)); let (sub_client, mut sub_loop) = AsyncClient::new(sub_opts, 10); let (ready_tx, ready_rx) = oneshot::channel::<()>(); let (msg_tx, msg_rx) = oneshot::channel::(); tokio::spawn(async move { let mut ready = Some(ready_tx); let mut msg = Some(msg_tx); loop { match sub_loop.poll().await { Ok(Event::Incoming(Packet::SubAck(_))) => { if let Some(tx) = ready.take() { let _ = tx.send(()); } } Ok(Event::Incoming(Packet::Publish(p))) => { if let Some(tx) = msg.take() { let _ = tx.send(String::from_utf8_lossy(&p.payload).to_string()); } return; } Ok(_) => {} Err(_) => return, } } }); sub_client.subscribe(sub_topic, QoS::AtLeastOnce).await?; timeout(Duration::from_secs(5), ready_rx).await??; println!("[sub] subscribed to '{sub_topic}'"); // PUBLISHER let mut pub_opts = MqttOptions::new("rust-pub", &host, port); pub_opts.set_keep_alive(Duration::from_secs(30)); let (pub_client, mut pub_loop) = AsyncClient::new(pub_opts, 10); tokio::spawn(async move { while pub_loop.poll().await.is_ok() {} }); // retain = false — a retained publish is silently dropped. pub_client.publish(pub_topic, QoS::AtLeastOnce, false, b"hello".as_ref()).await?; println!("[pub] published to '{pub_topic}'"); let payload = timeout(Duration::from_secs(10), msg_rx).await??; println!("[sub] received: {payload} (channel demo.x)"); Ok(()) } ``` ### Publish a message [#publish-a-message] The publisher in the program above sends one QoS-1 message to `events/demo/x`. The prefix `events/` selects the Events pattern, and the remaining segments become the KubeMQ channel with `/` translated to `.` — so `events/demo/x` lands on channel `demo.x`. QoS 1 returns a PUBACK so you know the broker accepted the publish. **Never set the retain flag** — a retained publish returns PUBACK `0x00` but the message is silently dropped. ### Subscribe and verify [#subscribe-and-verify] The subscriber registers the filter `events/demo/+`. The single-level `+` wildcard maps to KubeMQ `*`, matching any one trailing segment — so it receives the publish to `events/demo/x`. When the message arrives the program prints it and exits: ```text [sub] subscribed to 'events/demo/+' [pub] published to 'events/demo/x' [sub] received: hello (channel demo.x) ``` Events is fire-and-forget pub/sub: subscribe **before** you publish, or the message is gone. For persistence and replay-on-reconnect, use the Events-Store pattern (`store/`) instead. Wildcards (`+` → `*`, `#` → `>`) are accepted on the **Events** pattern only — a wildcard subscribe on any other prefix returns SUBACK `0xA2`. Avoid literal `.` in topic segments: `events/a.b/c` and `events/a/b/c` both map to channel `a.b.c`. See [Topic mapping](/connectors/mqtt/concepts/topic-mapping). ## Next steps [#next-steps] # Migrating from MQTT (/connectors/mqtt/scenarios/migration) You migrate an existing MQTT application to KubeMQ by **changing only the broker endpoint** — the host:port your client already connects to. This is a drop-in, endpoint-only migration: no KubeMQ SDK and no library swap. Your standard MQTT 3.1.1 / 5.0 client keeps publishing and subscribing exactly as it does today. The one thing to plan for is KubeMQ's topic grammar — the connector maps MQTT topics onto four messaging patterns through a topic-prefix convention, and a handful of common MQTT features are intentionally unsupported. Read this guide before cutting over so you know what works, what needs a topic rename, and what does not migrate at all. ## Overview [#overview] KubeMQ ships an embedded MQTT broker that speaks **MQTT 3.1.1 and MQTT 5.0** over TCP, TLS, and WebSocket. Existing MQTT clients point at KubeMQ by changing the broker host — no library swap required. The connector maps MQTT topics onto KubeMQ's messaging patterns through a topic-prefix convention, and several common MQTT features are intentionally unsupported. * **Default ports** — `1883` (TCP), `8883` (TLS), `8083` (WebSocket — serves `ws` or `wss` depending on the `Security` config). * **Canonical client** — Eclipse Paho `paho-mqtt 2.x` (Python). The examples in this guide use it; any conformant MQTT 3.1.1 / 5.0 client works. * **Protocol versions** — MQTT 3.1.1 and MQTT 5.0. MQTT 3.1 (protocol level 3) is rejected at CONNECT. The connector is **opt-in (disabled by default)** — a stock kubemq-server does **not** bind the MQTT listeners until you turn it on. Enable it with its enable variable, then connect: **The enable variable is `CONNECTORSMQTT_ENABLE` — there is no underscore between `CONNECTORS` and `MQTT`, and no `KUBEMQ_` prefix.** Variants like `CONNECTORS_MQTT_ENABLE` do **not** bind to the `Connectors.MQTT.Enable` field and are silently ignored. For Kubernetes, set `spec.mqtt.enabled: true` in the `KubemqCluster` CR. For a first connection, see [Getting started](/connectors/mqtt/tutorials/getting-started). ## Compatibility Matrix [#compatibility-matrix] This matrix summarizes what the MQTT connector supports. It is self-contained — read the footnotes for the load-bearing caveats. | Dimension | Support | Notes | | -------------------------------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | | **Drop-in level** | endpoint-only⁵ | Host-swap only; topic names must follow the prefix convention | | **Point-to-point queues** | ✅ | `queues/*` topics → KubeMQ Queues; `$share/{g}/queues/*` for consumption | | **Pub/sub (non-durable)** | ✅ | `events/*` and prefixless topics (when `DefaultPattern=events`) | | **Durable / persistent subscriptions** | ⚠️ | `store/*` → Events Store; subscriptions are always `StartNewOnly` — no replay | | **Request/reply (RPC)** | ✅ (v5 only) | MQTT 5.0 Response Topic + Correlation Data → Commands/Queries; MQTT 3.1.1 lacks Response Topic — RPC not available | | **Ordering guarantee** | ⚠️ QoS-dependent | QoS 2 de-duplicates at the wire level; end-to-end ordering is node-local | | **Transactions** | N/A | MQTT has no transaction concept | | **Dead-letter / redrive** | ❌ no client DLQ⁶ | Queue messages past `MaxReceiveCount` are silently dropped; no consumable dead-letter address over MQTT | | **Selectors / filtering / wildcards** | ⚠️ wildcards Events-only | `+` / `#` wildcards supported on `events/*` subscriptions only; rejected (SUBACK 0xA2) on `store/`, `queues/`, `commands/`, `queries/` | | **Auth model** | JWT (password) | CONNECT password = KubeMQ JWT when auth is enabled; username is audit-only | | **TLS / mTLS** | ✅ 8883 / 8083 | Server TLS and mutual TLS on 8883; WebSocket 8083 serves `wss` when `Security` is configured (same port as plain `ws`) | | **Top unsupported** | MQTT 3.1; retained messages; clients-as-responders; wildcards on non-Events; node-local sessions | | Footnote ⁵: MQTT **3.1** clients (protocol level 3) are rejected at CONNECT. Use 3.1.1 (Paho default, protocol level 4) or 5.0. Footnote ⁶: There is no client-settable DLQ over this protocol. See [No client-settable DLQ on queue subscriptions](#no-client-settable-dlq-on-queue-subscriptions) below for details. ## Connection / Endpoint Migration [#connection--endpoint-migration] Replace the broker host and port. The MQTT protocol and your client library stay the same. **No code changes are needed for the connection itself** — only topic names may need renaming (see [Concept & Destination Mapping](#concept--destination-mapping) below). ```bash title="Broker endpoints" # Before (any MQTT broker) mqtt://broker.example.com:1883 mqtts://broker.example.com:8883 # After (KubeMQ) mqtt://kubemq.example.com:1883 mqtts://kubemq.example.com:8883 # WebSocket — single listener on 8083 # Serves plain ws:// when Security is not configured, # or wss:// (TLS) on the same port when Security is configured. ws://kubemq.example.com:8083/ # plain (no Security block) # wss://kubemq.example.com:8083/ # TLS (when Security is configured) ``` ### Protocol version requirement [#protocol-version-requirement] The connector enforces `MinProtocolVersion = 4` (MQTT 3.1.1) by default. MQTT 3.1 clients (protocol level 3) are rejected at CONNECT. Eclipse Paho 2.x defaults to MQTT 3.1.1, so no version flag is needed unless you want MQTT 5.0. ```python # paho-mqtt 2.x — explicitly request MQTT 5.0 (optional; 3.1.1 is the default) import paho.mqtt.client as mqtt client = mqtt.Client( callback_api_version=mqtt.CallbackAPIVersion.VERSION2, protocol=mqtt.MQTTv5, # omit for 3.1.1 (the Paho default) ) ``` ### Authentication [#authentication] When the KubeMQ authentication service is enabled, the CONNECT **password** must be a valid KubeMQ JWT. The username field is accepted and recorded for audit but is not validated. ```python client.username_pw_set(username="device-01", password="") ``` When authentication is disabled (the default), all connections are accepted regardless of credentials — exactly like the gRPC and REST listeners. If the MQTT ports are reachable from untrusted networks, either enable authentication or restrict access at the network layer. ## Concept & Destination Mapping [#concept--destination-mapping] The first segment of every MQTT topic selects a KubeMQ messaging pattern. The remainder of the topic path (after the prefix slash) becomes the KubeMQ **channel name**, with `/` translated to `.`. | MQTT topic | KubeMQ pattern | KubeMQ channel | Notes | | -------------------------- | ---------------- | ------------------ | -------------------------------------- | | `events/sensor/temp` | Events | `sensor.temp` | Pub/sub, at-most-once | | `store/audit/login` | Events Store | `audit.login` | Persistent, `StartNewOnly` — no replay | | `queues/orders/new` | Queues | `orders.new` | At-least-once; consume via `$share` | | `commands/device/reboot` | Commands | `device.reboot` | RPC; MQTT 5.0 only | | `queries/inventory/status` | Queries | `inventory.status` | RPC; MQTT 5.0 only | | `sensor/temp` | `DefaultPattern` | `sensor.temp` | Prefixless topic routed by config | **Prefix collision caution.** If your existing topics already use a top-level segment named `events`, `store`, `queues`, `commands`, or `queries`, those topics will be interpreted as KubeMQ pattern prefixes rather than literal topic names. Rename those segments before migrating. ### Prefixless topics [#prefixless-topics] Topics whose first segment is not a reserved prefix are routed according to `DefaultPattern` (default: `events`), so basic pub/sub keeps working with no topic renames. Set `DefaultPattern = none` to reject prefixless topics explicitly. ### Wildcard subscriptions [#wildcard-subscriptions] KubeMQ supports the two standard MQTT wildcards on **`events/` subscriptions only**: * **`+`** — single-level wildcard (matches exactly one topic segment). * **`#`** — multi-level wildcard (matches the rest of the topic tree). Wildcard use on any other pattern is rejected; exact-filter subscriptions work on all patterns. See [Wildcards on non-Events patterns — REJECTED](#wildcards-on-non-events-patterns--rejected) for the exact SUBACK codes and full behavior. ### Queue consumption (shared subscriptions) [#queue-consumption-shared-subscriptions] Plain subscriptions to `queues/*` are rejected (SUBACK 0x83). Queue consumption requires a shared subscription with QoS ≥ 1: ```text $share/{group}/queues/{path} ``` Example: `$share/workers/queues/orders/new` The group name is recorded for metrics and audit but does not create independent per-group message copies — all groups and all nodes compete in the same KubeMQ queue pool. ### `/` and `.` conflation in channel names [#-and--conflation-in-channel-names] KubeMQ uses `.` as the channel hierarchy separator. The topic mapper replaces `/` with `.`, so both `devices/a/b/temp` and `devices/a.b.temp` map to the same channel `devices.a.b.temp`. Design channel names to avoid ambiguity if you have topics that mix both separators. ## Canonical Client Example [#canonical-client-example] **Client:** Eclipse Paho `paho-mqtt 2.x` The snippets below cover the core flows you migrate: publish an event, subscribe and consume events, consume a queue via `$share`, an MQTT 5.0 RPC request, and a TLS connection. ### Install [#install] ```bash title="Terminal" pip install paho-mqtt>=2.0.0 ``` ### Publish an event [#publish-an-event] ```python import paho.mqtt.client as mqtt def on_connect(client, userdata, flags, reason_code, properties): print(f"Connected: reason_code={reason_code}") client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2) # When KubeMQ authentication is enabled, set password = KubeMQ JWT: # client.username_pw_set(username="device-01", password="") client.on_connect = on_connect client.connect("kubemq.example.com", 1883) client.loop_start() # Publish to Events pattern → channel "sensor.temp" result = client.publish("events/sensor/temp", payload='{"celsius": 21.5}', qos=1) result.wait_for_publish() client.loop_stop() client.disconnect() ``` ### Subscribe and consume events [#subscribe-and-consume-events] ```python import paho.mqtt.client as mqtt def on_connect(client, userdata, flags, reason_code, properties): print(f"Connected: reason_code={reason_code}") # Subscribe to Events with a single-level '+' wildcard (Events-only) client.subscribe("events/sensor/+", qos=1) def on_message(client, userdata, message): print(f"Topic: {message.topic} Payload: {message.payload.decode()}") client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2) client.on_connect = on_connect client.on_message = on_message client.connect("kubemq.example.com", 1883) client.loop_forever() ``` ### Consume a queue (at-least-once) [#consume-a-queue-at-least-once] Queue consumption requires a shared subscription and QoS ≥ 1. The PUBACK doubles as the queue acknowledge — the message is requeued if the PUBACK is not sent within `QueueAckTimeoutSeconds` (default 30 s). ```python import paho.mqtt.client as mqtt def on_connect(client, userdata, flags, reason_code, properties): print(f"Connected: reason_code={reason_code}") # $share group name is audit-only; all groups share the same queue pool client.subscribe("$share/workers/queues/orders/new", qos=1) def on_message(client, userdata, message): print(f"Queue message: {message.payload.decode()}") # paho-mqtt 2.x auto-sends PUBACK for QoS 1 when on_message returns. # The KubeMQ connector treats PUBACK receipt as the queue acknowledgment. client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2) client.on_connect = on_connect client.on_message = on_message client.connect("kubemq.example.com", 1883) client.loop_forever() ``` ### RPC (MQTT 5.0 only) [#rpc-mqtt-50-only] RPC requires MQTT 5.0 (Response Topic + Correlation Data). MQTT 3.1.1 does not support RPC on this connector — see [RPC over MQTT 3.1.1 — NOT AVAILABLE](#rpc-over-mqtt-311--not-available) for the exact behavior. ```python import paho.mqtt.client as mqtt from paho.mqtt.properties import Properties from paho.mqtt.packettypes import PacketTypes import uuid CLIENT_ID = "req-client-01" REPLY_TOPIC = f"$reply/{CLIENT_ID}/inventory" def on_connect(client, userdata, flags, reason_code, properties): print(f"Connected: reason_code={reason_code}") # Subscribe to our own $reply inbox before sending the request client.subscribe(REPLY_TOPIC, qos=1) def on_message(client, userdata, message): print(f"RPC response: {message.payload.decode()}") # Check user properties for command outcome: # kubemq-executed = "true"/"false" (Commands) # kubemq-metadata, kubemq-error (Queries / error detail) client = mqtt.Client( callback_api_version=mqtt.CallbackAPIVersion.VERSION2, client_id=CLIENT_ID, protocol=mqtt.MQTTv5, ) client.on_connect = on_connect client.on_message = on_message client.connect("kubemq.example.com", 1883) client.loop_start() # Build MQTT 5.0 publish properties pub_props = Properties(PacketTypes.PUBLISH) pub_props.ResponseTopic = REPLY_TOPIC pub_props.CorrelationData = str(uuid.uuid4()).encode() # The responder is a gRPC/REST/CloudEvents client subscribed to channel "inventory.status" client.publish( "queries/inventory/status", payload='{"sku":"WIDGET-100"}', qos=1, properties=pub_props, ) import time; time.sleep(5) # wait for response client.loop_stop() client.disconnect() ``` ### TLS connection [#tls-connection] ```python client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2) client.tls_set( ca_certs="/path/to/ca.crt", # CA that signed the KubeMQ server cert certfile="/path/to/client.crt", # omit for server-TLS-only (no mTLS) keyfile="/path/to/client.key", ) client.connect("kubemq.example.com", 8883) ``` For WebSocket TLS: `client.connect("kubemq.example.com", 8083, transport="websockets")` with the same `tls_set` call above. Port 8083 serves `wss://` when `Security` is configured (the same port that serves `ws://` without TLS). ## Security [#security] ### Authentication [#authentication-1] Authentication is **connect-time only**. A JWT that expires mid-connection does not terminate the connection — the token is validated once at CONNECT and cached for the connection's lifetime. | When auth is ... | Behavior | | ------------------ | -------------------------------------------------------------------------------------------------------- | | Disabled (default) | All CONNECT attempts accepted; username recorded for audit | | Enabled | CONNECT `password` must be a valid KubeMQ JWT; empty or invalid → CONNACK 0x86 (MQTT 5.0) / rc=5 (3.1.1) | ### Authorization [#authorization] When the KubeMQ authorization service (Casbin) is configured, every publish and subscribe is checked against the client's resolved identity. ACL denial returns PUBACK 0x87 / SUBACK 0x87 (MQTT 5.0) or silent drop / SUBACK 0x80 (MQTT 3.1.1); the `$reply//...` namespace is always allowed. ### TLS / mTLS [#tls--mtls] The dedicated TLS listener on **8883** activates only when the global `Security` block is configured with certificate material; without it, 8883 is skipped at startup with a warning. The **WebSocket listener on 8083** is a single listener that always starts when `WsPort` is set: it serves plain `ws://` when `Security` is not configured, and switches to `wss://` (TLS) on the same port when `Security` is configured. The plain TCP listener on **1883** starts unconditionally when the connector is enabled. * **Server TLS** — `Security.Mode = tls`: presents `Security.Cert` / `Security.Key`; clients verify the server certificate; minimum TLS 1.2. * **Mutual TLS** — `Security.Mode = mtls`: additionally requires clients to present a certificate verified against `Security.Ca`. ### Opt-in activation [#opt-in-activation] The connector is **disabled by default**. Explicitly enable it: ```toml title="config.toml" [Connectors.MQTT] Enable = true Port = "1883" ``` Or via environment variable: ```bash title="Terminal" export CONNECTORSMQTT_ENABLE=true ``` ## What Does NOT Migrate / Documented Deviations [#what-does-not-migrate--documented-deviations] ### MQTT 3.1 (protocol level 3) — REJECTED [#mqtt-31-protocol-level-3--rejected] Clients using MQTT 3.1 (protocol level 3, e.g. very old Paho 1.x defaults) receive `CONNACK` with "unsupported protocol version". Upgrade to 3.1.1 (`mqtt.MQTTv311`) or 5.0 (`mqtt.MQTTv5`). Paho 2.x defaults to 3.1.1 — no change needed for modern clients. ### Retained messages — REJECTED AND AUDITED (not silently dropped) [#retained-messages--rejected-and-audited-not-silently-dropped] `RetainAvailable` is forced to `0` at server construction. Any publish with the retain flag set is **rejected with an audit event** (`publish.error`) and is **not routed and not stored**. The MQTT 5.0 client receives a normal success PUBACK (the retain flag on a PUBACK has no standard meaning in MQTT), but the message is discarded before reaching KubeMQ. MQTT 3.1.1 clients also have their retained publishes discarded with an audit event. There is no path to convert retained messages to Events Store entries. A Will (LWT) message with the retain flag set is rejected at CONNECT (CONNACK 0x9A), since retain is unavailable. Use an LWT without the retain flag. ### MQTT clients as RPC responders — NOT SUPPORTED [#mqtt-clients-as-rpc-responders--not-supported] A `SUBSCRIBE` to `commands/*` or `queries/*` is rejected with SUBACK 0x83. MQTT clients can only **issue** RPC requests (as publishers); RPC responders must be gRPC, REST, or CloudEvents clients. This is an architectural constraint. ### RPC over MQTT 3.1.1 — NOT AVAILABLE [#rpc-over-mqtt-311--not-available] MQTT 3.1.1 has no Response Topic field. A publish to `commands/*` or `queries/*` from a 3.1.1 client is silently dropped and audited (`publish.error` + WARN). RPC is exclusively an MQTT 5.0 capability on this connector. ### Wildcards on non-Events patterns — REJECTED [#wildcards-on-non-events-patterns--rejected] Wildcard subscriptions (`+`, `#`) are supported **only** on the `events/` prefix (and on prefixless topics when `DefaultPattern=events`). Attempts to use wildcards on `store/`, `queues/`, `commands/`, or `queries/` receive SUBACK 0xA2 (MQTT 5.0) or SUBACK 0x80 (MQTT 3.1.1). Exact-filter subscriptions work on all patterns. ### Sessions — node-local, in-memory, lost on restart [#sessions--node-local-in-memory-lost-on-restart] MQTT session state (subscriptions, client-ID registry, LWT, QoS state machines) is held in memory on the node that accepted the connection. Consequences: * Process restart clears all session state, including clean-session=0 sessions. * A clean-session=0 client that reconnects to a **different cluster node** finds no session. * Duplicate client-ID from different nodes keeps both connections live (no cross-node takeover). * Cross-node RPC responses are lost: if the requester's connection migrates to another node mid-request, the response is not forwarded. ### Events Store replay — not available over MQTT [#events-store-replay--not-available-over-mqtt] Subscriptions to `store/*` are always `StartNewOnly`. Historical replay from the Events Store is not exposed over the MQTT wire protocol. Replay is available via gRPC, REST, and CloudEvents clients only. ### No client-settable DLQ on queue subscriptions [#no-client-settable-dlq-on-queue-subscriptions] The connector never sets a per-message receive limit on messages it publishes to KubeMQ Queues. A "poison" message that exceeds the server's `MaxReceiveCount` is dropped by the server; there is no consumable dead-letter address accessible over MQTT. See the AWS or RabbitMQ guides for connectors that support DLQ/redrive. ### `$SYS` topics [#sys-topics] The `$SYS` broker-internal topic tree is not available. Subscriptions to `$SYS/*` are denied. ### Events Store subscriptions and the `$share` prefix [#events-store-subscriptions-and-the-share-prefix] `$share` subscriptions are valid **only** for the `queues/*` pattern. `$share` on `store/*`, `events/*`, `commands/*`, or `queries/*` is rejected with SUBACK 0x83. ### User properties (MQTT 5.0 ↔ KubeMQ Tags) [#user-properties-mqtt-50--kubemq-tags] MQTT 5.0 user properties map to KubeMQ message tags and vice versa. Caps: **32 properties** and **4096 bytes** total. Exceeding either cap returns PUBACK 0x97. MQTT 3.1.1 clients do not support user properties — tag metadata is not surfaced to 3.1.1 subscribers. ## Verification Smoke Test [#verification-smoke-test] This recipe confirms that the connector is reachable, that topics are mapped correctly, and that basic publish/consume works. It uses `paho-mqtt 2.x` (the canonical client for this guide) as a "publish one → consume it → confirm arrival" check. ### Step 1 — Enable the connector [#step-1--enable-the-connector] ```toml title="config.toml" [Connectors.MQTT] Enable = true Port = "1883" ``` Restart KubeMQ. Confirm the log line `mqtt connector started on port 1883`. ### Step 2 — Publish and receive an event [#step-2--publish-and-receive-an-event] Run these two snippets in separate terminals. **Terminal 1 — subscriber:** ```python import paho.mqtt.client as mqtt received = [] def on_connect(client, userdata, flags, rc, props): client.subscribe("events/smoke/test", qos=1) def on_message(client, userdata, msg): received.append(msg.payload.decode()) print(f"RECEIVED: {msg.payload.decode()}") client.disconnect() client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2) client.on_connect = on_connect client.on_message = on_message client.connect("localhost", 1883) client.loop_forever() assert received == ['{"ok":true}'], f"Expected message not received: {received}" print("Smoke test PASSED") ``` **Terminal 2 — publisher (after the subscriber is connected):** ```python import paho.mqtt.client as mqtt client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2) client.connect("localhost", 1883) client.loop_start() result = client.publish("events/smoke/test", payload='{"ok":true}', qos=1) result.wait_for_publish() client.loop_stop() client.disconnect() print("Published") ``` Expected: the subscriber prints `RECEIVED: {"ok":true}` and exits with `Smoke test PASSED`. ### Step 3 — Confirm a queue round-trip [#step-3--confirm-a-queue-round-trip] **Publish to a queue:** ```python client.publish("queues/smoke/test", payload='{"job":1}', qos=1) ``` **Consume from the queue (shared subscription, QoS 1):** ```python client.subscribe("$share/smokers/queues/smoke/test", qos=1) ``` Expected: the consumer receives `{"job":1}` exactly once; no redelivery unless the consumer disconnects before the PUBACK. ## See Also [#see-also] # Capabilities (/connectors/mqtt/reference/capabilities) This reference lists every capability the KubeMQ MQTT connector advertises to clients — including the values that are **forced** regardless of configuration — plus its configurable limits, protocol-version support, session semantics, and the limitations you must design around. Use it to decide which MQTT client features are safe to rely on and which ones are refused or silently dropped. ## Forced (non-configurable) capabilities [#forced-non-configurable-capabilities] These flags are hardcoded in the connector's `CONNACK` and **cannot** be changed at runtime. They are the three load-bearing capability bits an MQTT 5.0 client reads at connect time, plus the PUBACK-property rule: | Capability | Wire value | Effect | | ---------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `RetainAvailable` | `0` | Retain is **disabled**. A retained PUBLISH at runtime is silently dropped (success PUBACK `0x00`); a `Will-retain=true` CONNECT is rejected with CONNACK `0x9A`. See the [retain gotcha](#gotcha-1-retain-is-silently-dropped-at-runtime). | | `SharedSubAvailable` | `1` | Required for `$share//queues/` queue consumption. | | `WildcardSubAvailable` | `1` | Required for Events wildcard subscriptions (`+`, `#`). | | `NoInheritedPropertiesOnAck` | `true` | PUBACK never echoes the PUBLISH user properties back to the sender. Every successful QoS 1 PUBACK is property-free. | **Retain is silently dropped (`RetainAvailable=0`).** The connector advertises that retain is unavailable, but if a client sends a retained PUBLISH anyway, the retain flag is stripped, the message is **dropped**, and the **PUBACK still succeeds** (`0x00`) — the sender gets no wire-level indication. The only retain-related error is a `Will-retain=true` CONNECT, which is rejected with CONNACK `0x9A`. There is no durable subscription support. ## Configurable limits and defaults [#configurable-limits-and-defaults] All values below are tunable via `CONNECTORSMQTT_CAPABILITIES_*` environment variables (see [Configuration](/connectors/mqtt/concepts/configuration)). | Capability | Default | Min | Max | Notes | | ------------------------------ | ---------------- | --- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `MaximumPacketSize` | `4194304` (4 MB) | 1 | — | Maximum PUBLISH payload + header size in bytes. Clients sending larger packets are disconnected. | | `ReceiveMaximum` | `1024` | 1 | 65535 | Maximum concurrent in-flight QoS 1/2 publishes per client (flow control). | | `MaximumInflight` | `8192` | 1 | — | Broker-wide in-flight QoS message cap. | | `MaximumSessionExpiryInterval` | `3600` s | 0 | — | Maximum value clients may request for `SessionExpiryInterval`. | | `MaximumMessageExpiryInterval` | `86400` s | 0 | — | Maximum allowed message expiry. **RPC publishes only**: if `MessageExpiryInterval` is set on a `commands/` or `queries/` PUBLISH and is shorter than `RpcTimeoutSeconds`, the effective RPC timeout becomes `min(RpcTimeoutSeconds, MessageExpiryInterval)`. Does not apply to queue or events publishes. | | `MaximumQos` | `2` | 0 | 2 | Highest QoS the broker grants. | | `MinimumProtocolVersion` | `4` (MQTT 3.1.1) | 4 | 5 | MQTT 3.1 (level 3) is rejected at CONNECT. | | `MaximumClients` | `0` (unlimited) | 0 | — | `0` = unlimited. | ## Protocol version support [#protocol-version-support] | Protocol | Level byte | Accepted | Notes | | ---------- | ---------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | MQTT 5.0 | `0x05` | Yes | Full feature set: user properties, reason codes, shared subscriptions, RPC flow. | | MQTT 3.1.1 | `0x04` | Yes | No user properties, no RPC, no `$share` queue consume. | | MQTT 3.1 | `0x03` | **Rejected** | CONNECT is refused for an unsupported protocol level. Because a level-3 CONNECT uses the MQTT 3.x CONNACK format, the byte returned is the v3 return code `0x01` ("unacceptable protocol version") — not one of the MQTT 5.0 [reason codes](/connectors/mqtt/reference/reason-codes). | `MinimumProtocolVersion=4` is the hardcoded floor. Set `CONNECTORSMQTT_CAPABILITIES_MIN_PROTOCOL_VERSION=5` to allow MQTT 5.0 clients only. ## Session semantics [#session-semantics] Sessions are **in-memory and node-local**: | Property | Behavior | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `clean_start=false` (v5) / `clean_session=0` (v3.1.1) | Session is preserved in the node's memory. A reconnect to the **same node** within `SessionExpiryInterval` restores subscriptions and queued QoS 1/2 messages without re-subscribing. | | Reconnect to a **different node** | Session is lost (not replicated across nodes). | | `clean_start=true` | Fresh session; no state carried over. | Restored sessions do **not** replay historical Events-Store messages — the bridge re-subscribes at start-new-only after reconnect. See the [no-replay gotcha](#gotcha-2-events-store-has-no-historical-replay). ## Transport listeners [#transport-listeners] | Listener | Default port | URL scheme | Notes | | ----------- | ------------ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | TCP (plain) | `1883` | `tcp://host:1883` | Always active when `Port` is set (default `1883`). | | TLS | `8883` | `tls://host:8883` | Active **only** when `Security` config is present. TLS min 1.2; mTLS supported. If no TLS material is configured, the listener is silently skipped with a warning. | | WebSocket | `8083` | `ws://host:8083/` | Active when `WsPort` is set. TLS WebSocket (`wss://`) when Security config present. | ## User properties (MQTT 5.0 only) and KubeMQ tags [#user-properties-mqtt-50-only-and-kubemq-tags] MQTT 5.0 user properties map bidirectionally to KubeMQ message tags for Events, Events-Store, and Queues patterns. MQTT 3.1.1 has no user properties. | Direction | Mapping | | --------------------------------- | ------------------------------------------------------------------------------------ | | PUBLISH → KubeMQ | `Properties.User` entries copied 1:1 into KubeMQ `Tags` (duplicate keys: last-wins). | | KubeMQ delivery → MQTT subscriber | KubeMQ `Tags` copied into `Properties.User` on the injected PUBLISH. | Hardcoded caps: | Cap | Value | Violation result | | ---------------------------------------------------- | -------------- | ------------------------------------------------------------------------- | | Maximum user properties per message | **32** | MQTT 5.0: PUBACK `0x97`; MQTT 3.1.1: silent drop + `publish.error` audit. | | Maximum total bytes (sum of all key + value lengths) | **4096 bytes** | Same as above. | ## Limitations [#limitations] These are the design constraints to plan around — each is a hard property of the connector, not a configuration choice: | Limitation | Detail | | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **No retain** | `RetainAvailable=0` — retained PUBLISH is dropped; `Will-retain` CONNECT rejected with `0x9A`. | | **No durable subscriptions** | Sessions are node-local and in-memory; reconnecting to a different node loses the session. | | **Queues are publish-only over MQTT** | `queues/` accepts produce; consuming requires `$share//queues/`, which is **MQTT 5.0 only** (3.1.1 has no shared subscriptions). | | **Commands / Queries are MQTT 5.0 only** | RPC uses `ResponseTopic` + `CorrelationData`; a 3.1.1 publish to `commands/`/`queries/` is silently dropped. MQTT clients can only be RPC **requesters** — responders run on the gRPC side. | | **Events-Store has no historical replay** | MQTT subscriptions always start new-only; messages stored before subscribe are never delivered. | | **Ruby client is the v3.1.1 subset** | The `mqtt` gem speaks MQTT 3.1.1 only — no RPC, no `$share` queue consume, no user properties. | ## Gotcha reference [#gotcha-reference] ### Gotcha 1: retain is silently dropped at runtime [#gotcha-1-retain-is-silently-dropped-at-runtime] `RetainAvailable=0` is advertised at CONNECT. If a client sends a retained PUBLISH anyway: * The retain flag is silently **stripped**. * The connector then **drops the message** and audits `publish.error` with text `"retain not supported"`. * The **PUBACK succeeds** (`0x00`). The sender has no indication the message was dropped. * No retained copy is stored; late subscribers receive nothing. The **only** case that yields a CONNACK error is `Will-retain=true` at CONNECT, which returns CONNACK `0x9A` (retain-not-supported), enforced before the session is established. ### Gotcha 2: Events-Store has no historical replay [#gotcha-2-events-store-has-no-historical-replay] Events-Store subscriptions over MQTT always start new-only. Unlike the REST/gRPC API (which offers six replay positions), MQTT subscribers receive **only messages published after the subscription is established**. Messages stored before a client subscribes are never delivered. ### Gotcha 3 — `$share` group name is audit-only; all groups compete in one pool [#gotcha-3--share-group-name-is-audit-only-all-groups-compete-in-one-pool] Standard MQTT brokers deliver a separate copy of each message to each `$share` group. The KubeMQ connector does **not**. All `$share` subscribers on all groups compete in a **single KubeMQ queue pool**: each message is consumed exactly once, regardless of group name. The group name is recorded in audit and metrics only. ### Gotcha 4 — RPC requires MQTT 5.0 and PUBACK is immediate [#gotcha-4--rpc-requires-mqtt-50-and-puback-is-immediate] Publishing to `commands/` or `queries/` from an MQTT 3.1.1 client is silently dropped (PUBACK `0x00`, no RPC issued). The PUBACK for a valid MQTT 5.0 RPC publish is also sent **immediately**, before the response arrives — clients must implement their own response-wait timeout. ### Gotcha 5 — literal `.` in a topic segment conflates with `/` [#gotcha-5--literal--in-a-topic-segment-conflates-with-] `events/a.b/c` and `events/a/b/c` both produce KubeMQ channel `a.b.c`. Avoid dots inside path segments. See [Topic Grammar](/connectors/mqtt/reference/topic-grammar). ### Gotcha 6 — overlapping wildcard filters deliver multiple copies [#gotcha-6--overlapping-wildcard-filters-deliver-multiple-copies] Each unique subscribe filter has its own independent bridge entry. A publish matching N active wildcard filters results in N deliveries to the client. There is no cross-entry deduplication. ## Related [#related] # Configuration (/connectors/mqtt/reference/configuration) All fields live under `[Connectors.MQTT]`. Defaults are taken verbatim from the server's `MqttConfig` struct. See [Configuration concepts](/connectors/mqtt/concepts/configuration) for the enable-variable spelling, the forced-capabilities rationale, and how TLS is derived from server Security config. ## Configuration fields [#configuration-fields] ### Capability fields (`[Connectors.MQTT.Capabilities]`) [#capability-fields-connectorsmqttcapabilities] These fields configure the protocol-level limits advertised in the MQTT `CONNACK` packet. ### Forced capabilities [#forced-capabilities] Three capabilities are **always forced regardless of config** — they are not settable: | Capability | Forced value | Why | | ---------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `RetainAvailable` | `0` | Retain is not supported. A runtime publish with retain set gets a success PUBACK (`0x00`) but is silently dropped; a Will-retain at CONNECT yields CONNACK `0x9A`. | | `SharedSubAvailable` | `1` | Required for `$share/` shared subscriptions — the mechanism for consuming a KubeMQ Queue over MQTT. | | `WildcardSubAvailable` | `1` | Required for Events wildcard subscriptions (`+` → `*`, `#` → `>`). | ## Validation rules [#validation-rules] These rules are enforced at startup. Validation is skipped entirely when `Enable` is `false` — a disabled connector is always valid. | Field | Rule | | -------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | `Port` / `TlsPort` / `WsPort` | when `Enable=true`, **at least one** must be set (non-empty). | | all set ports | must be **distinct** from each other. | | `DefaultPattern` | one of `events`, `store`, `none`. | | `SubBuffSize` | in the range `1`–`10000`. | | `QueueAckTimeoutSeconds`, `RpcTimeoutSeconds`, `RpcMaxPending` | each must be `> 0`. | | `MaxQos` | `0`, `1`, or `2`. | | `MinProtocolVersion` | `4` (3.1.1+) or `5` (5.0 only). | | `TlsPort` | silently ignored (with a warning log) when no server `Security` config is provided. | ## Configuring the connector [#configuring-the-connector] The same settings can be supplied through a TOML config file, environment variables, or `docker run` flags. Each environment variable uses the irregular `CONNECTORSMQTT_` prefix (no underscore after `CONNECTORS`); capability fields nest under `CONNECTORSMQTT_CAPABILITIES_*`. ```toml title="config.toml" [Connectors.MQTT] Enable = true Port = "1883" TlsPort = "8883" WsPort = "8083" DefaultPattern = "events" SubBuffSize = 100 QueueAckTimeoutSeconds = 30 RpcTimeoutSeconds = 30 RpcMaxPending = 1024 [Connectors.MQTT.Capabilities] MaxClients = 0 MaxPacketSizeBytes = 4194304 ReceiveMaximum = 1024 MaxInflight = 8192 MaxSessionExpirySeconds = 3600 MaxMessageExpirySeconds = 86400 MaxQos = 2 MinProtocolVersion = 4 ``` ```bash title="mqtt.env" CONNECTORSMQTT_ENABLE=true CONNECTORSMQTT_PORT=1883 CONNECTORSMQTT_TLS_PORT=8883 CONNECTORSMQTT_WS_PORT=8083 CONNECTORSMQTT_DEFAULT_PATTERN=events CONNECTORSMQTT_SUB_BUFF_SIZE=100 CONNECTORSMQTT_QUEUE_ACK_TIMEOUT_SECONDS=30 CONNECTORSMQTT_RPC_TIMEOUT_SECONDS=30 CONNECTORSMQTT_RPC_MAX_PENDING=1024 CONNECTORSMQTT_CAPABILITIES_MAX_CLIENTS=0 CONNECTORSMQTT_CAPABILITIES_MAX_PACKET_SIZE_BYTES=4194304 CONNECTORSMQTT_CAPABILITIES_RECEIVE_MAXIMUM=1024 CONNECTORSMQTT_CAPABILITIES_MAX_INFLIGHT=8192 CONNECTORSMQTT_CAPABILITIES_MAX_SESSION_EXPIRY_SECONDS=3600 CONNECTORSMQTT_CAPABILITIES_MAX_MESSAGE_EXPIRY_SECONDS=86400 CONNECTORSMQTT_CAPABILITIES_MAX_QOS=2 CONNECTORSMQTT_CAPABILITIES_MIN_PROTOCOL_VERSION=4 ``` The connector is already enabled, so the Docker example overrides only a couple of values. There is no `-e CONNECTORSMQTT_ENABLE=true` — that would be redundant. Set `CONNECTORSMQTT_ENABLE=false` only when you want to turn the connector off. Set `CONNECTORSMQTT_WS_PORT=""` to drop the WebSocket listener, or `CONNECTORSMQTT_CAPABILITIES_MIN_PROTOCOL_VERSION=5` to reject MQTT 3.1.1 clients. ## Related [#related] # Connections Endpoint (/connectors/mqtt/reference/connections-endpoint) The KubeMQ internal HTTP API exposes a **node-local** snapshot of active MQTT client connections and their subscriptions, plus three Prometheus metrics. Use this reference to observe who is connected, what they are subscribed to, and how the MQTT bridge is performing. ## Endpoint [#endpoint] ```text GET http://:8080/api/mqtt/connections ``` | Property | Value | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | Port | `8080` (internal HTTP API — network-protected, not exposed to the public internet) | | Authentication | None (internal network access control) | | Scope | **Node-local**: data reflects only the connections to the node you query. In a multi-node cluster, query each node separately and sum the totals. | | Inline clients | Filtered out — the internal bridge client is never included in the response. | ## Response envelope [#response-envelope] All responses use the standard KubeMQ API envelope: ```json { "error": false, "error_string": "", "data": { "connections": [ ], "total": 3 } } ``` | Field | Type | Notes | | ------------------ | -------- | -------------------------------------------------------------------------------------------------------------------- | | `error` | `bool` | `false` on success | | `error_string` | `string` | Empty on success; error description on failure | | `data` | object | Contains the `connections` array and `total` count | | `data.connections` | array | Connection objects (schema below). Empty array `[]` when no clients are connected or the MQTT connector is disabled. | | `data.total` | `int` | Count of entries in `data.connections` | ### Connection object schema [#connection-object-schema] Each entry in `data.connections`: | Field | Type | Notes | | ------------------ | -------- | -------------------------------------------------------------------------------------- | | `client_id` | `string` | MQTT client identifier | | `protocol_version` | `int` | `4` = MQTT 3.1.1; `5` = MQTT 5.0 | | `remote_addr` | `string` | Client IP and ephemeral port | | `connected_at` | `string` | RFC 3339 UTC timestamp of session establishment | | `clean_session` | `bool` | `true` = clean start; `false` = persistent session | | `username` | `string` | MQTT `Username` field; display-only (not used for authentication). Omitted when empty. | | `subscriptions` | array | Active subscriptions for this client. Empty array when none. | ### Subscription object schema [#subscription-object-schema] Each entry in `subscriptions`: | Field | Type | Notes | | -------------- | -------- | -------------------------------------------------------------------------------------------------------------------------- | | `filter` | `string` | Original MQTT topic filter string | | `qos` | `int` | Granted QoS (`0`, `1`, or `2`) | | `pattern` | `string` | Mapped KubeMQ pattern: `events`, `store`, `queues`, `commands`, `queries`, or empty for local (`$reply`) topics | | `channel` | `string` | Mapped KubeMQ channel (dot-separated; may contain `*` or `>` for wildcard subscriptions). Empty for `$reply` local topics. | | `shared_group` | `string` | Non-empty for `$share//queues/` subscriptions; the group name. | ## Live example [#live-example] Response with one connected MQTT 5.0 client subscribed to an Events wildcard and a shared queue: ```json { "error": false, "error_string": "", "data": { "connections": [ { "client_id": "go-example-01", "protocol_version": 5, "remote_addr": "127.0.0.1:55412", "connected_at": "2026-06-12T10:30:00Z", "clean_session": true, "subscriptions": [ { "filter": "events/site1/#", "qos": 1, "pattern": "events", "channel": "site1.>", "shared_group": "" }, { "filter": "queues/jobs/email", "qos": 1, "pattern": "queues", "channel": "jobs.email", "shared_group": "workers" } ] } ], "total": 1 } } ``` Response when no clients are connected: ```json { "error": false, "error_string": "", "data": { "connections": [], "total": 0 } } ``` ## Prometheus metrics [#prometheus-metrics] The connector exports **three** Prometheus metrics. ### `kubemq_mqtt_connections` (gauge) [#kubemq_mqtt_connections-gauge] ```text kubemq_mqtt_connections ``` Current number of active MQTT client connections on this node. Incremented on session establishment, decremented on disconnect. Session takeovers (same client ID reconnect) do not double-count. ### `kubemq_mqtt_operations_total` (counter) [#kubemq_mqtt_operations_total-counter] ```text kubemq_mqtt_operations_total{operation="", status=""} ``` Total bridge operations since process start. | Label | Values | | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `operation` | `publish_events`, `publish_store`, `publish_queues`, `rpc_command`, `rpc_query`, `deliver_events`, `deliver_store`, `deliver_queues`, `rpc_response` | | `status` | `success`, `error`, `dropped` | Operation semantics: | `operation` | Description | | ---------------- | ----------------------------------------------------------- | | `publish_events` | MQTT PUBLISH routed to KubeMQ Events | | `publish_store` | MQTT PUBLISH routed to KubeMQ Events-Store | | `publish_queues` | MQTT PUBLISH routed to KubeMQ Queues (produce) | | `rpc_command` | MQTT 5.0 PUBLISH routed to KubeMQ Commands | | `rpc_query` | MQTT 5.0 PUBLISH routed to KubeMQ Queries | | `deliver_events` | Events message injected to an MQTT subscriber | | `deliver_store` | Events-Store message injected to an MQTT subscriber | | `deliver_queues` | Queue message injected to an MQTT subscriber (PUBACK = ack) | | `rpc_response` | RPC response injected to the requester's `$reply` topic | ### `kubemq_mqtt_operation_duration_seconds` (histogram) [#kubemq_mqtt_operation_duration_seconds-histogram] ```text kubemq_mqtt_operation_duration_seconds{operation=""} ``` Latency histogram for MQTT bridge operations. Duration is recorded only for request-response operations (`rpc_command`, `rpc_query`, `rpc_response`); per-message stream deliver operations (`deliver_events`, `deliver_store`, `deliver_queues`) pass duration `0` and are counted but not histogrammed. **Buckets:** `0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60` seconds. ## curl examples [#curl-examples] List all current connections: ```bash curl -s http://127.0.0.1:8080/api/mqtt/connections | jq . ``` Count connected clients: ```bash curl -s http://127.0.0.1:8080/api/mqtt/connections | jq '.data.total' ``` List all client IDs: ```bash curl -s http://127.0.0.1:8080/api/mqtt/connections \ | jq '[.data.connections[].client_id]' ``` Filter for MQTT 5.0 clients only: ```bash curl -s http://127.0.0.1:8080/api/mqtt/connections \ | jq '[.data.connections[] | select(.protocol_version == 5)]' ``` List all active queue subscriptions with their groups: ```bash curl -s http://127.0.0.1:8080/api/mqtt/connections \ | jq '[.data.connections[].subscriptions[] | select(.pattern == "queues") | {filter, channel, shared_group}]' ``` ## Related [#related] # Reason Codes (/connectors/mqtt/reference/reason-codes) This reference lists every MQTT reason code the KubeMQ MQTT connector produces, the packet type it appears on, and the exact conditions that trigger it. **These are MQTT 5.0 reason codes.** MQTT 3.1.1 does not carry reason codes on PUBACK or SUBACK (the fields do not exist). For MQTT 3.1.1 connections the connector either drops the message silently or closes the connection without a reason code — see [MQTT 3.1.1 behavior](#mqtt-311-behavior). ## Full reason code table [#full-reason-code-table] | Code | Hex | Packet(s) | Meaning | Triggering conditions | | ------------------------------------ | ------ | ----------------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Success | `0x00` | PUBACK, SUBACK, CONNACK | Operation succeeded | Normal success. Also returned when a retained PUBLISH is **silently dropped** — the PUBACK still succeeds even though the message was discarded (see the [retain gotcha](#the-retain-silent-drop-gotcha-code-0x00)). | | Granted QoS 1 | `0x01` | SUBACK | Subscription granted at QoS 1 | The normal success code for any QoS-1 subscribe (Events, Events-Store, or `$share` Queues). Also the granted code when a QoS-2 subscribe is downgraded to QoS 1 — e.g. a `$share//queues/` subscription requested at QoS 2. | | Unspecified error | `0x80` | PUBACK, SUBACK | Broker not ready or internal error | (1) The broker is starting up or shutting down: all incoming publishes and subscribes are rejected with this code until the broker signals ready. (2) The internal message bus returned an error for a publish routed to Events, Events-Store, or Queues. No reason string is included in the PUBACK (information boundary — clients receive the code only; the actual error is in audit + logs). | | Implementation-specific error | `0x83` | SUBACK, PUBACK | Valid request, not supported by this connector | **SUBACK** — subscription rejected for a policy reason: (a) `$share/` on a non-queues topic; (b) plain `queues/` subscribe without `$share`; (c) `$share//queues/` at QoS 0; (d) subscribe to `commands/` or `queries/` (MQTT clients cannot be RPC responders); (e) subscribe to another client's `$reply//...` namespace. **PUBACK** — RPC publish rejected: (f) `ResponseTopic` is missing; (g) `ResponseTopic` is outside the publisher's own `$reply//...` namespace. | | Bad user name or password | `0x86` | CONNACK | Authentication failure | (a) Auth is enabled and the CONNECT `Password` field is empty. (b) Auth is enabled and the JWT in `Password` is invalid or expired. The `Username` field is not used for authentication (display-only). | | Not authorized | `0x87` | PUBACK, SUBACK | ACL denied the publish or subscribe | The authorization policy denied access to the resolved (pattern, channel) for this client. **PUBACK** — write (publish) was denied. **SUBACK** — read (subscribe) was denied. The resolved channel and pattern are checked but not included in the ack (information boundary). | | Server shutting down | `0x8B` | DISCONNECT | Graceful server shutdown | The KubeMQ node is stopping. The connector sends DISCONNECT `0x8B` to all connected clients before closing. Clients should reconnect to another node or retry. | | Topic filter invalid | `0x8F` | SUBACK | Topic filter is structurally malformed | (a) Any segment of the topic filter is empty (leading `/`, trailing `/`, or double `/`). (b) `DefaultPattern=none` and the filter has no recognized prefix. (c) Empty `$share` group: `$share//queues/`. | | Topic name invalid | `0x90` | PUBACK | Topic name is structurally malformed or unmappable | (a) Any segment of the topic is empty. (b) `DefaultPattern=none` and the topic has no recognized prefix. (c) The topic resolves to an empty KubeMQ channel. | | Quota exceeded | `0x97` | PUBACK | Server-side quota exhausted | (a) The in-flight RPC pending map is full: `RpcMaxPending` (default `1024`) concurrent outstanding RPC requests already exist. (b) The PUBLISH carries more than **32** MQTT 5.0 user properties, or the total byte length of all user-property key + value pairs exceeds **4096 bytes**. | | Retain not supported | `0x9A` | CONNACK | Retain is disabled; Will-retain requested | The CONNECT packet includes `Will-retain=true`. The connector rejects the connection before the session is established. A retained PUBLISH **at runtime** does **not** produce this code — it is silently dropped with a success PUBACK `0x00` (see `0x00` above). | | Wildcard subscriptions not supported | `0xA2` | SUBACK | Wildcard used on an unsupported pattern | A subscription filter contains `+` or `#` but the resolved pattern is **not** Events. Wildcards are allowed only on Events (`events/`) subscriptions. `queues/+/foo`, `store/#`, `commands/+/svc`, etc. trigger this code. | ## Per-pattern quick reference [#per-pattern-quick-reference] ### Events (`events/`) [#events-eventsch] | Scenario | Code | | ------------------------------------- | -------------------------------------- | | Publish success | `0x00` | | Subscribe success | `0x00` / `0x01` / `0x02` (granted QoS) | | Wildcard subscribe (`+`, `#`) success | `0x00` / `0x01` | | Internal-bus error on publish | `0x80` | | Broker not ready | `0x80` | | ACL deny on publish | `0x87` (PUBACK) | | ACL deny on subscribe | `0x87` (SUBACK) | | Empty channel (`events/` only) | `0x90` (publish) / `0x8F` (subscribe) | ### Events-Store (`store/`) [#events-store-storech] Same as Events, except wildcard subscribes return `0xA2` (wildcards are not allowed on Events-Store). ### Queues (`queues/` produce / `$share//queues/` consume) [#queues-queuesch-produce--sharegqueuesch-consume] | Scenario | Code | | -------------------------------------------------------- | --------------- | | Produce (publish) success | `0x00` | | `$share//queues/` subscribe at QoS 1 | `0x01` | | `$share//queues/` subscribe at QoS 2 (downgraded) | `0x01` | | Plain `queues/` subscribe | `0x83` | | `$share//queues/` at QoS 0 | `0x83` | | `$share/` on a non-queues topic | `0x83` | | Internal-bus error on produce | `0x80` | | ACL deny on produce | `0x87` (PUBACK) | | ACL deny on `$share//queues/` subscribe | `0x87` (SUBACK) | ### Commands and Queries (RPC, MQTT 5.0 only) [#commands-and-queries-rpc-mqtt-50-only] | Scenario | Code | | ----------------------------------------------------- | ---------------------------------------- | | Valid MQTT 5.0 RPC publish (acknowledged immediately) | `0x00` (PUBACK sent before response) | | Subscribe to `commands/` or `queries/` | `0x83` (MQTT clients are not responders) | | MQTT 3.1.1 publish to `commands/` or `queries/` | `0x00` (silently dropped — no RPC) | | Missing `ResponseTopic` property | `0x83` | | `ResponseTopic` outside own `$reply//` | `0x83` | | `RpcMaxPending` exceeded | `0x97` | ### RPC timeout [#rpc-timeout] There is **no reason code** for an RPC timeout. The server audits `rpc.timeout` and discards the pending entry, but the PUBACK was already sent at `0x00` when the publish was received. The client must detect the missing response via its own timeout. ## The retain silent-drop gotcha (code `0x00`) [#the-retain-silent-drop-gotcha-code-0x00] `RetainAvailable=0` is advertised to connecting clients. If a client ignores this and sends a retained PUBLISH: 1. The retain flag is silently stripped. 2. The bridge detects the retain flag and drops the message without routing it. 3. The bridge audits `publish.error` with description `"retain not supported"`. 4. The **PUBACK is `0x00` (success)**. The sender has no wire-level indication that the message was dropped. This is the only case where `0x00` does not mean the message was delivered. The **only** retain-related error code is `0x9A`, and it fires only on CONNECT when the Will message has `retain=true`. ## MQTT 3.1.1 behavior [#mqtt-311-behavior] MQTT 3.1.1 does not support reason codes on PUBACK or SUBACK. The connector handles this as follows: | Situation | v5 code | v3.1.1 behavior | | ------------------------------- | -------------------- | -------------------------------------------- | | Publish success | `0x00` | Normal PUBACK (no reason code field) | | Broker not ready | `0x80` | Message silently dropped; no wire indication | | Internal-bus error | `0x80` | Message silently dropped | | Retain publish | `0x00` (silent drop) | Message silently dropped | | RPC publish to commands/queries | `0x00` (silent drop) | Message silently dropped | | User-prop cap exceeded | `0x97` | Message silently dropped | | Wildcard on non-events | `0xA2` | Subscription silently rejected | | Unsupported subscribe pattern | `0x83` | Subscription silently rejected | | `$share` queue at QoS 0 | `0x83` | Subscription silently rejected | All rejections for v3.1.1 connections are audited as `publish.error` or `subscription.error` in the server audit log. ## Reason codes in order [#reason-codes-in-order] | Hex | Decimal | Packet | Name | | ------ | ------- | ------------------------- | ------------------------------------ | | `0x00` | 0 | PUBACK / SUBACK / CONNACK | Success | | `0x01` | 1 | SUBACK | Granted QoS 1 | | `0x80` | 128 | PUBACK / SUBACK | Unspecified error | | `0x83` | 131 | PUBACK / SUBACK | Implementation-specific error | | `0x86` | 134 | CONNACK | Bad user name or password | | `0x87` | 135 | PUBACK / SUBACK | Not authorized | | `0x8B` | 139 | DISCONNECT | Server shutting down | | `0x8F` | 143 | SUBACK | Topic filter invalid | | `0x90` | 144 | PUBACK | Topic name invalid | | `0x97` | 151 | PUBACK | Quota exceeded | | `0x9A` | 154 | CONNACK | Retain not supported | | `0xA2` | 162 | SUBACK | Wildcard subscriptions not supported | ## Related [#related] # Topic Grammar (/connectors/mqtt/reference/topic-grammar) The KubeMQ MQTT connector maps every MQTT topic (on publish) and filter (on subscribe) to one of five KubeMQ messaging patterns. The first segment of the topic string is the **pattern prefix**; the remaining segments become the **KubeMQ channel**. **The Ruby client ships the MQTT v3.1.1 subset.** The `mqtt` gem speaks MQTT 3.1.1 only, so it cannot use RPC (`commands/`, `queries/`) or `$share` queue consumption — both require MQTT 5.0. Use Ruby for Events, Events-Store, and queue **produce** only. ## Pattern prefix table [#pattern-prefix-table] | MQTT topic / filter | KubeMQ pattern | Direction | Example topic | KubeMQ channel | | ---------------------------- | --------------------- | ------------------------------------ | ----------------------------- | -------------- | | `events/` | Events | publish + subscribe | `events/site1/temp` | `site1.temp` | | `store/` | Events-Store | publish + subscribe (start-new-only) | `store/site1/temp` | `site1.temp` | | `queues/` | Queues — produce only | publish | `queues/jobs/email` | `jobs.email` | | `$share//queues/` | Queues — consume | subscribe (QoS ≥ 1) | `$share/g1/queues/jobs/email` | `jobs.email` | | `commands/` | Commands (RPC send) | publish — MQTT 5.0 only | `commands/svc/reboot` | `svc.reboot` | | `queries/` | Queries (RPC send) | publish — MQTT 5.0 only | `queries/svc/status` | `svc.status` | | `$reply//` | local (not routed) | subscribe + RPC ResponseTopic | `$reply/c1/inbox` | not routed | | *(no prefix)* | DefaultPattern | publish + subscribe | `site1/temp` | `site1.temp` | A prefixless topic is routed to `DefaultPattern` (`events` by default). When `DefaultPattern=none` the connector rejects the message: PUBACK `0x90` on publish, SUBACK `0x8F` on subscribe. ## Path-separator mapping: `/` becomes `.` [#path-separator-mapping--becomes-] Topic segments are joined with `.` to form the KubeMQ channel: ```text events/site1/factory/temp → channel: site1.factory.temp store/orders/europe → channel: orders.europe queues/jobs/email → channel: jobs.email ``` The connector splits the non-prefix segments on `/` and joins them with `.`. ### Gotcha — literal `.` in a segment conflates with `/` [#gotcha--literal--in-a-segment-conflates-with-] A literal `.` inside a segment passes through unchanged, making the channel indistinguishable from one produced by an extra `/`: ```text events/a.b/c → channel: a.b.c events/a/b/c → channel: a.b.c ← same result ``` These two topics map to **identical** KubeMQ channels. This is a **lossy round-trip**: the reverse mapping converts `.` back to `/`, so the injected delivery topic is `events/a/b/c` regardless of which form was published. Avoid dots inside individual path segments to prevent ambiguity. ## Wildcard subscriptions [#wildcard-subscriptions] MQTT wildcards are translated to KubeMQ wildcard syntax **only on Events subscriptions**. | MQTT wildcard | KubeMQ equivalent | Position rule | | ------------- | ------------------ | ------------------ | | `+` | `*` (single-level) | any segment | | `#` | `>` (multi-level) | final segment only | Examples: | MQTT subscribe filter | KubeMQ channel filter | Matches | | --------------------- | --------------------- | ---------------------------------------- | | `events/+/temp` | `*.temp` | `events/site1/temp`, `events/site2/temp` | | `events/site1/#` | `site1.>` | `events/site1/temp`, `events/site1/a/b` | | `events/#` | `>` | all events channels | **Non-Events wildcards are rejected**: subscribing to `queues/+/foo` or `store/#` returns SUBACK `0xA2` (wildcard-subscriptions-not-supported). ### Gotcha — overlapping wildcard filters deliver multiple copies [#gotcha--overlapping-wildcard-filters-deliver-multiple-copies] Each distinct subscribe filter creates an independent bridge registry entry (one subscription per `(pattern, filter)` pair). A publish that matches **N** overlapping filters fires **N** deliveries — one per matching entry. There is **no cross-entry deduplication** in the connector. Example: a client subscribes to both `events/#` and `events/site1/#`; a publish to `events/site1/temp` matches both entries and the client receives **two** copies. ## Shared subscriptions — queue consume [#shared-subscriptions--queue-consume] Queue consumption uses MQTT 5.0 shared subscriptions: ```text $share//queues/ ``` Rules: | Condition | Result | | ------------------------------------------- | ----------------------------------- | | `$share//queues/` at QoS ≥ 1 | SUBACK `0x00` / `0x01` (granted) | | `$share//queues/` at QoS 0 | SUBACK `0x83` (impl-specific error) | | Plain `queues/` subscribe (no `$share`) | SUBACK `0x83` | | `$share/` on any non-queues prefix | SUBACK `0x83` | | Empty group: `$share//queues/` | SUBACK `0x8F` (invalid) | **QoS 2 is downgraded to QoS 1**: the queue bridge operates at QoS 1 (PUBACK = acknowledge; no QoS 2 two-phase commit in the bridge). ### Gotcha — the `$share` group name is audit-only; all groups compete in one pool [#gotcha--the-share-group-name-is-audit-only-all-groups-compete-in-one-pool] The KubeMQ queue is a **single shared pool**. Unlike standard MQTT brokers — which deliver a separate copy per `$share` group — the KubeMQ MQTT connector routes **all** `$share` group subscribers into one competing-consumer pool. Every message is consumed exactly once across all subscribers, regardless of group name. The group name is recorded in audit and metrics only. ## RPC topics — MQTT 5.0 only [#rpc-topics--mqtt-50-only] Commands and Queries are **publish-only** patterns over MQTT: ```text commands/ # send a command RPC queries/ # send a query RPC ``` ### Gotcha — RPC requires MQTT 5.0; a v3.1.1 publish is silently dropped [#gotcha--rpc-requires-mqtt-50-a-v311-publish-is-silently-dropped] A v3.1.1 (or earlier) client publishing to `commands/` or `queries/` receives a **success PUBACK** (`0x00`), but the message is **silently dropped** and a `publish.error` audit event is emitted. No RPC is issued. MQTT 5.0 is required because: * The `ResponseTopic` property carries the reply address. * The `CorrelationData` property associates the response. * User Properties carry RPC metadata. ### RPC subscribe is not allowed for MQTT clients [#rpc-subscribe-is-not-allowed-for-mqtt-clients] MQTT clients **cannot** subscribe to `commands/` or `queries/` as responders. These subscriptions return SUBACK `0x83`. RPC responders must be implemented on the gRPC side using the KubeMQ Go SDK or another KubeMQ client. ### Reply namespace [#reply-namespace] ```text $reply// ``` * A client must **subscribe** to its own `$reply//` before issuing an RPC publish. * The `ResponseTopic` property of the RPC PUBLISH **must** point to the client's own `$reply//...` namespace. Using another client's `$reply` namespace returns PUBACK `0x83`. * `$reply` topics are **local** — they are never routed to the broker. ### Gotcha — PUBACK is sent immediately; the client must implement its own timeout [#gotcha--puback-is-sent-immediately-the-client-must-implement-its-own-timeout] The PUBACK for an RPC PUBLISH is sent **immediately on receipt**, before the response arrives. The response is later injected as a separate PUBLISH on the `$reply` topic. The client must implement its own timeout to detect missing responses. `RpcTimeoutSeconds` (default 30 s) is the server-side bound; after that the server audits `rpc.timeout` and discards the pending entry, but the client receives no notification. ## Empty segments and structural errors [#empty-segments-and-structural-errors] The connector rejects topics with empty segments (leading slash, trailing slash, or double slash): | Offending topic | Error | Wire code | | ----------------------------------- | ---------------------------- | ----------------------------- | | `/events/foo` | empty segment (leading `/`) | PUBACK `0x90` / SUBACK `0x8F` | | `events//foo` | empty segment (double `/`) | PUBACK `0x90` / SUBACK `0x8F` | | `events/foo/` | empty segment (trailing `/`) | PUBACK `0x90` / SUBACK `0x8F` | | `events/` (prefix only, no channel) | empty channel | PUBACK `0x90` / SUBACK `0x8F` | ## Reverse mapping: delivery topics [#reverse-mapping-delivery-topics] When the connector injects a delivery back to subscribing MQTT clients it replaces every `.` with `/` and prepends the pattern prefix: ```text channel: site1.factory.temp + pattern: events → events/site1/factory/temp channel: jobs.email + pattern: queues → queues/jobs/email ``` For wildcard subscriptions the injected topic is the **concrete per-message channel** (e.g. `events/site1/temp`), not the wildcard filter (e.g. `events/+/temp`). For exact subscriptions the injected topic is the original filter string. ## Summary [#summary] ```text MQTT publish/subscribe topic │ ├── starts with "events/" → Events ├── starts with "store/" → Events-Store ├── starts with "queues/" → Queues (produce) ├── starts with "commands/" → Commands (MQTT 5.0 only) ├── starts with "queries/" → Queries (MQTT 5.0 only) ├── starts with "$share//queues/" │ → Queues (consume, QoS ≥ 1) ├── starts with "$reply//" │ → local (no broker routing) └── no prefix → DefaultPattern (default: events) DefaultPattern=none → rejected ``` ## Related [#related] # Architecture (/connectors/rabbitmq/concepts/architecture) The KubeMQ **RabbitMQ (AMQP 0-9-1) connector** is an embedded, wire-protocol bridge inside kubemq-server that speaks the RabbitMQ dialect on plain port **5672** and TLS/AMQPS port **5671**. The connector is **opt-in (disabled by default)** — enable it with `CONNECTORS_AMQP_ENABLE=true` (Docker) or `spec.amqp.enabled: true` (Kubernetes). Any standard AMQP 0-9-1 client connects to it with **only a connection-string change** — no code changes, no library swap, no KubeMQ SDK. Unlike the AMQP 1.0 connector (which can touch all five KubeMQ patterns), the RabbitMQ connector bridges the AMQP 0-9-1 wire protocol onto exactly one KubeMQ primitive: the **Queue**. This single fact drives the entire mental model. **Everything is a Queue.** Every AMQP queue maps to exactly one KubeMQ **Queue** channel named `amqp.{vhost}.{queue}`. Exchanges and bindings are **virtual** — connector-side routing metadata resolved at publish time, not data stores. AMQP only ever touches the KubeMQ Queue primitive; there is no exchange "storage." ## How AMQP 0-9-1 maps to KubeMQ [#how-amqp-0-9-1-maps-to-kubemq] A publish always resolves to one or more KubeMQ Queue channels. The connector resolves the exchange routing (default / direct / fanout / topic / headers) into a deduplicated set of target queues **at publish time**, then writes the message to each queue's KubeMQ channel `amqp.{vhost}.{queue}` through the message broker. *A publish resolves through virtual exchange routing into a deduplicated set of target queues; each maps to a KubeMQ Queue channel `amqp.{vhost}.{queue}` and is written through the message broker.* The exchange types resolve as in RabbitMQ, but the result is always a set of KubeMQ Queue channels: | Exchange type | Routing rule | | ------------------ | --------------------------------------------------------- | | **default** (`""`) | The routing key **is** the queue name (implicit binding). | | **direct** | Exact routing-key match against the bindings. | | **fanout** | All bound queues (routing key ignored). | | **topic** | Trie match (`*` = one word, `#` = zero-or-more words). | | **headers** | `x-match` against the header bindings. | Matched queues are **deduplicated** — a message matching multiple bindings to the same queue is delivered exactly **once**. If the routed set is empty, the publish returns `basic.return (312 NO_ROUTE)` when `mandatory=true`, otherwise it is silently dropped. ### Channel mapping [#channel-mapping] The channel name is `amqp.{vhost}.{queue}`. AMQP vhost `/` maps to the configured `DefaultVhost` segment (literal `"default"`): | AMQP queue | Vhost | KubeMQ channel | | ---------- | ------------- | --------------------- | | `orders` | `/` (default) | `amqp.default.orders` | | `hello` | `/` | `amqp.default.hello` | | `jobs` | `workers` | `amqp.workers.jobs` | The full channel name (prefix + vhost + queue) is capped at 255 chars, and queue/vhost names must not contain `;`, `:`, `*`, `>`, whitespace, or end with `.`. See [Channel mapping](/connectors/rabbitmq/reference/channel-mapping) for the grammar and the property/header mapping. ## The connector stack [#the-connector-stack] The connector terminates the AMQP 0-9-1 wire protocol and translates it onto KubeMQ Queue operations. A single `amqpmux` front door classifies each connection by its 8-byte AMQP protocol header and dispatches AMQP 0-9-1 traffic to this engine (and AMQP 1.0 traffic to the AMQP 1.0 engine), so the two dialects coexist on ports 5672/5671. *The connector authenticates over SASL PLAIN, resolves virtual exchange routing, authorizes each target queue, and batch-writes to the KubeMQ Queue channel backed by the message broker's durable store.* * **SASL PLAIN only.** On a secured broker the username is cosmetic and the password carries a KubeMQ JWT; identity is `claims.ClientID`. Because the JWT travels in the SASL PLAIN password in cleartext at the AMQP layer, production deployments must use the TLS listener (5671). See [Authentication](/connectors/rabbitmq/how-to/authentication). * **Native RPC.** Request/reply uses RabbitMQ's `amq.rabbitmq.reply-to` (direct reply-to); there is **no gRPC responder** anywhere in the connector. See [RPC](/connectors/rabbitmq/how-to/rpc). ## Cross-protocol interop [#cross-protocol-interop] Because every AMQP queue is a normal KubeMQ Queue channel, AMQP and gRPC/REST clients interoperate on the same channel. *The same KubeMQ Queue channel backs both sides, so an AMQP 0-9-1 client and a gRPC/REST client interoperate transparently.* Header and metadata translation differs by direction: | Direction | What the consumer sees | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **AMQP → AMQP** | Routing context is native: a delivery shows `exchange`, `routing-key`, and the original AMQP headers. | | **AMQP → gRPC/REST** | AMQP headers are wrapped as `Metadata = {"amqp_headers":{...}}` (always set, even when empty). E.g. header `trace=abc-123` → `{"amqp_headers":{"trace":"abc-123"}}`. | | **gRPC/REST → AMQP** | Native (non-enveloped) gRPC metadata surfaces as the AMQP header `x-kubemq-metadata`. E.g. gRPC `metadata="native-metadata"` → AMQP header `x-kubemq-metadata: native-metadata`. | The `{"amqp_headers":{...}}` envelope is an **interop concern**, not a default-path surprise: a pure AMQP→AMQP round-trip surfaces headers natively. See [Channel mapping](/connectors/rabbitmq/reference/channel-mapping) for the full property/header ⇄ Tag/Metadata table. ## Related [#related] # Configuration (/connectors/rabbitmq/concepts/configuration) The RabbitMQ (AMQP 0-9-1) connector is configured server-side under the `Connectors.Amqp` block of the KubeMQ server config, exposed as **twelve `CONNECTORS_AMQP_*` environment variables**. The connector is **opt-in (disabled by default)** — you must explicitly enable it. It ships with sensible production defaults, so once enabled no other env var is required. The only thing **clients** configure is the broker endpoint via the `KUBEMQ_AMQP_URL` environment variable (default `amqp://guest:guest@localhost:5672/`); the URL scheme selects the transport (`amqp://` plain, `amqps://` TLS). Everything below is broker-side server configuration. ## Enable / disable [#enable--disable] Enable the connector with its enable variable: To turn it **off** again: **The enable variable is `CONNECTORS_AMQP_ENABLE` — spell it verbatim.** This is the **AMQP 0-9-1 (RabbitMQ)** connector; it is distinct from the AMQP 1.0 connector, whose flag is `CONNECTORS_AMQP10_ENABLE`. The two share ports `5672`/`5671` through the internal `amqpmux`, but each has its own enable flag — disabling one leaves the other reachable. Alternatively, disable just one listener with `CONNECTORS_AMQP_PORT=0` (plain) or `CONNECTORS_AMQP_TLS_PORT=0` (TLS); setting **both** to `0` also disables the connector. When `Enable` is `false`, no AMQP listener binds and the rest of this config is skipped. ### Field notes [#field-notes] * **`Port` / `TlsPort`** — when `Enable=true`, at least one of `Port` or `TlsPort` must be non-zero, otherwise the server reports `bad AMQP configuration: Enable=true requires Port or TlsPort`. The plain listener is `amqp://host:5672/`; the TLS listener is `amqps://host:5671/`. * **`MaxConnections`** — `0` means unlimited; over-limit connections are accepted then closed with code `320`. * **`DefaultVhost`** — the segment AMQP vhost `/` maps to. The default literal is `"default"` (a **reserved** vhost), not `/`. The channel mapping is `amqp.{vhost}.{queue}`. Reach the default vhost by connecting to `/`; connecting directly to a vhost literally named `default` is rejected. See the reserved-vhost callout in the [configuration reference](../reference/configuration). * **`GetBatchSize`** — bounds per-`basic.get` pulls (default `32`). * **`DeadLetterMaxHops`** — the dead-letter cycle cap per (queue, reason); default `16`. * **`MaxReceiveCount`** — the poison-message receive cap; `0` inherits the broker default. ## TLS [#tls] **TLS has no AMQP-specific configuration.** TLS/AMQPS on port `5671` is managed by the server-global `Security` block, shared with gRPC and REST. The TLS listener is active only when that block is configured (`Mode` ≠ None); TLS 1.2+ is enforced and mTLS is supported. Connecting over TLS is purely a transport swap (`KUBEMQ_AMQP_URL=amqps://host:5671/`); the AMQP frames on top are identical. See [TLS and mTLS](/connectors/rabbitmq/how-to/tls-and-mtls) and [Auth & security](/connectors/reference/auth-and-security). ## Configuring the connector [#configuring-the-connector] The same settings can be supplied through a TOML config file, environment variables, or `docker run` flags. Each environment variable uses the `CONNECTORS_AMQP_` prefix. ```toml title="config.toml" [Connectors.Amqp] Enable = true Port = 5672 TlsPort = 5671 HeartbeatSeconds = 60 FrameMax = 131072 ChannelMax = 2047 MaxConnections = 1000 MaxBodySize = 104857600 DefaultVhost = "default" GetBatchSize = 32 DeadLetterMaxHops = 16 MaxReceiveCount = 0 ``` ```bash title="rabbitmq.env" CONNECTORS_AMQP_ENABLE=true CONNECTORS_AMQP_PORT=5672 CONNECTORS_AMQP_TLS_PORT=5671 CONNECTORS_AMQP_HEARTBEAT_SECONDS=60 CONNECTORS_AMQP_FRAME_MAX=131072 CONNECTORS_AMQP_CHANNEL_MAX=2047 CONNECTORS_AMQP_MAX_CONNECTIONS=1000 CONNECTORS_AMQP_MAX_BODY_SIZE=104857600 CONNECTORS_AMQP_DEFAULT_VHOST=default CONNECTORS_AMQP_GET_BATCH_SIZE=32 CONNECTORS_AMQP_DEAD_LETTER_MAX_HOPS=16 CONNECTORS_AMQP_MAX_RECEIVE_COUNT=0 ``` The Docker example includes `CONNECTORS_AMQP_ENABLE=true` — without it the connector stays disabled and port 5672 is not bound. Set `CONNECTORS_AMQP_ENABLE=false` when you want to turn the connector off, or `CONNECTORS_AMQP_PORT=0` to drop the plain listener and serve AMQPS only. **Availability-first startup.** Unlike gRPC, the AMQP connector starts non-fatally: if the listener cannot bind or the topology store is corrupt, the server logs `error loading amqp connector, continuing without AMQP` and keeps running. After an upgrade or config change, verify the connector came up — look for the `AMQP connector started` log line or check the dashboard AMQP page / `GET /api/amqp/connections`. See [Connections endpoint](/connectors/rabbitmq/reference/connections-endpoint). ## Related [#related] # Exchanges and Routing (/connectors/rabbitmq/concepts/exchanges-and-routing) In the KubeMQ RabbitMQ connector, exchanges and bindings are **virtual connector-side routing metadata, not data stores**. There is no exchange object holding messages — a publish to an exchange is resolved into a *set of target queues* **at publish time**, and each resolved queue is written to its KubeMQ Queue channel `amqp.{vhost}.{queue}`. This guide covers the exchange types, topic wildcards, declare idempotency, and what happens to the routing result. Every AMQP queue maps to a single KubeMQ **Queue** channel `amqp.{vhost}.{queue}`. Exchanges and bindings exist only to *select which queues a publish lands in* — they are evaluated by the connector at publish time, then discarded. See [Architecture](/connectors/rabbitmq/concepts/architecture). ## How a publish routes [#how-a-publish-routes] The matcher produces a set of target queues, deduplicates it, runs a per-queue authorization check, and then writes the message to each surviving queue's KubeMQ channel. ## Exchange types [#exchange-types] | Exchange type | Routing semantics | Pre-declared (per vhost) | | ------------------ | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------- | | **default** (`""`) | Implicit per-queue binding; `routing-key` = queue name; a missing queue is unroutable | implicit | | **direct** | Exact `routing-key` match; multiple bindings on the same key all match | `amq.direct` (durable) | | **fanout** | All bound queues; routing key ignored | `amq.fanout` (durable) | | **topic** | Trie matcher; `*` = exactly one word, `#` = zero or more words, `.` = word separator | `amq.topic` (durable) | | **headers** | `x-match` ∈ {`all`, `any`, `all-with-x`, `any-with-x`} (default `all`); `x-`-prefixed headers are excluded from matching | `amq.headers` / `amq.match` (durable) | ## Pre-declared `amq.*` exchanges [#pre-declared-amq-exchanges] The `amq.direct`, `amq.fanout`, `amq.topic`, `amq.headers`, and `amq.match` exchanges are pre-declared durable per vhost. They: * **cannot be deleted** by a client → `403 access-refused`; * **cannot be redeclared with different args** → `406 precondition-failed`; * a client declaring **any** exchange with an `amq.*` prefix → `403`. ## Topic wildcards [#topic-wildcards] The topic matcher uses `.` as the word separator: | Token | Matches | | ----- | ---------------------- | | `*` | exactly **one** word | | `#` | **zero or more** words | Worked examples: | Binding pattern | Matches | Does NOT match | | --------------- | -------------------------------- | --------------------------------------- | | `stock.*.nyse` | `stock.ibm.nyse` | `stock.ibm.us.nyse` (two words for `*`) | | `stock.#` | `stock`, `stock.a`, `stock.a.b` | `stocks.a` | | `#` | any key, including the empty key | — | | `*.orange.*` | `quick.orange.rabbit` | `lazy.orange.elephant.x` | ## Bindings and declare idempotency [#bindings-and-declare-idempotency] * **Declare idempotency:** identical args → ok; **any** field mismatch (type / durable / auto-delete / internal / deep-equal arguments) → `406`. * **Passive declare** (`passive=true`): exists → ok; missing → `404 not-found`. ## The routing result [#the-routing-result] 1. The exchange matcher produces a set of target queues. 2. The set is **deduplicated** — a message matching multiple bindings to the **same** queue is delivered exactly **once**. 3. A per-queue Casbin **Write** check runs; denied queues are silently removed + `amqp.publish.denied` audit — see [Authentication](/connectors/rabbitmq/how-to/authentication). 4. If the routed set is **empty**: * `mandatory=true` → `basic.return(312 NO_ROUTE)` with the full message content; * otherwise the message is **silently dropped**. See [Reliability](/connectors/rabbitmq/how-to/reliability) for `mandatory` / `basic.return`. ## Unsupported / inert routing features [#unsupported--inert-routing-features] | Feature | Behavior | | ----------------------------------------------------------------------- | ------------------------------------------------------------------------ | | **Exchange-to-exchange bindings** (`exchange.bind` / `exchange.unbind`) | `540 not-implemented` (capability advertised `false`). | | **`alternate-exchange` argument** | Accepted and stored, but **inert** — WARN once; badged in the dashboard. | **Inert arguments.** Several exchange/queue arguments are accepted (and badged in the dashboard topology view) but **never alter behavior**: `alternate-exchange`, priority queues, `x-max-length` / overflow, `x-queue-type` / mode, single-active-consumer, `x-expires`, and consumer `x-priority`. Don't rely on them. See [Capabilities](/connectors/rabbitmq/reference/capabilities). ## Error quick reference [#error-quick-reference] | Trigger | Code | | --------------------------------------------------------------------- | ----------- | | Unmatched direct routing key, no `mandatory` | silent drop | | `mandatory=true` and unroutable | `312` | | Redeclare with mismatched args | `406` | | Passive declare of a missing exchange | `404` | | Client declares an `amq.*` exchange / deletes a pre-declared exchange | `403` | | `exchange.bind` / `exchange.unbind` | `540` | ## Related [#related] # Getting Started (/connectors/rabbitmq/tutorials/getting-started) Get a message flowing through the KubeMQ RabbitMQ (AMQP 0-9-1) connector in minutes. You point a standard RabbitMQ client at the broker, declare a queue, publish a message through the default exchange, and consume it back — all over the native AMQP 0-9-1 wire, with no KubeMQ SDK. This walkthrough takes you from a running server to a verified round-trip. ## Prerequisites [#prerequisites] * A running **kubemq-server** with the RabbitMQ connector **enabled** and reachable on **port 5672** (plain TCP). The connector is **opt-in (disabled by default)** — see the enable step below. * One of the AMQP 0-9-1 clients below for your language (the examples pin a native RabbitMQ client per language — there is no KubeMQ SDK). ## Enable the connector [#enable-the-connector] The RabbitMQ (AMQP 0-9-1) connector is **disabled by default** — a stock kubemq-server does **not** bind the AMQP listener until you turn it on. Enable it with its enable variable: **The enable variable is `CONNECTORS_AMQP_ENABLE`** — note that this is the **AMQP 0-9-1 (RabbitMQ)** connector, distinct from the AMQP 1.0 connector's `CONNECTORS_AMQP10_ENABLE`. For Kubernetes, set `spec.amqp.enabled: true` in the `KubemqCluster` CR. Bring up a throwaway local broker with the RabbitMQ connector enabled: Every example reads a single environment variable for the broker endpoint. On a development broker, auth is disabled and `guest:guest` is accepted with no JWT: ```bash # default: amqp://guest:guest@localhost:5672/ export KUBEMQ_AMQP_URL="amqp://guest:guest@localhost:5672/" ``` The AMQP vhost `/` maps to the connector's configured `DefaultVhost` segment (literal `"default"`), so queue `hello` on vhost `/` lands on the KubeMQ Queue channel `amqp.default.hello`. Connect to vhost `/`, not to a literal vhost named `default` — the `default` segment is reserved and a direct connection to it is rejected. See [Channel mapping](/connectors/rabbitmq/reference/channel-mapping). To **disable** the RabbitMQ connector after enabling it, set its enable variable to `false`: AMQP 0-9-1 and AMQP 1.0 share ports 5672/5671 but have separate enable flags — disabling RabbitMQ leaves AMQP 1.0 reachable on the same ports, and vice-versa. You can also disable just one listener by setting `CONNECTORS_AMQP_PORT=0` (plain) or `CONNECTORS_AMQP_TLS_PORT=0` (TLS). See [Configuration](/connectors/rabbitmq/concepts/configuration) for the full settings list. ## How it works [#how-it-works] A publisher declares a queue and publishes to an exchange with a routing key. The connector resolves the exchange routing to the target queue at publish time and writes the message to that queue's KubeMQ Queue channel `amqp.{vhost}.{queue}`. A consumer subscribed to the same queue receives it. *Publishing to queue `hello` on the default exchange maps to the KubeMQ Queue channel `amqp.default.hello`; a consumer on the same queue receives the message.* ## Steps [#steps] ### Connect and declare a queue [#connect-and-declare-a-queue] Open a connection to the endpoint in `KUBEMQ_AMQP_URL`, open a channel, and declare the target queue. The language tabs run the **complete** round-trip from a single program: connect, declare the `hello` queue, publish one message through the default exchange, and consume it back. ```go package main import ( "context" "log" "os" "time" amqp "github.com/rabbitmq/amqp091-go" ) func amqpURL() string { if v := os.Getenv("KUBEMQ_AMQP_URL"); v != "" { return v } return "amqp://guest:guest@localhost:5672/" } func main() { conn, err := amqp.Dial(amqpURL()) if err != nil { log.Fatalf("dial: %v", err) } defer func() { _ = conn.Close() }() ch, err := conn.Channel() if err != nil { log.Fatalf("channel: %v", err) } defer func() { _ = ch.Close() }() // Declare "hello" → KubeMQ Queue channel amqp.default.hello. q, err := ch.QueueDeclare("hello", false, false, false, false, nil) if err != nil { log.Fatalf("declare queue: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() // Publish on the default exchange — routing key = queue name. if err := ch.PublishWithContext(ctx, "", q.Name, false, false, amqp.Publishing{ ContentType: "text/plain", Body: []byte("Hello World!"), }); err != nil { log.Fatalf("publish: %v", err) } log.Println(" [x] Sent 'Hello World!'") // Consume with auto-ack and print the exact body. msgs, err := ch.Consume(q.Name, "", true, false, false, false, nil) if err != nil { log.Fatalf("consume: %v", err) } select { case d := <-msgs: log.Printf(" [x] Received %q", string(d.Body)) case <-ctx.Done(): log.Fatalf("timed out: %v", ctx.Err()) } } ``` ```python import os import pika URL = os.environ.get("KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/") QUEUE = "hello" # → KubeMQ Queue channel amqp.default.hello def main() -> None: connection = pika.BlockingConnection(pika.URLParameters(URL)) channel = connection.channel() # Default (nameless) exchange: routing key == queue name. channel.queue_declare(queue=QUEUE, durable=False, exclusive=False, auto_delete=False) channel.basic_publish( exchange="", routing_key=QUEUE, body=b"Hello World!", properties=pika.BasicProperties(content_type="text/plain"), ) print(" [x] Sent 'Hello World!'") method, _props, body = channel.basic_get(queue=QUEUE, auto_ack=True) if method is None: raise SystemExit("no message received from queue") print(f" [x] Received {body.decode()!r}") channel.close() connection.close() if __name__ == "__main__": main() ``` ```java import com.rabbitmq.client.AMQP; import com.rabbitmq.client.Channel; import com.rabbitmq.client.Connection; import com.rabbitmq.client.ConnectionFactory; import com.rabbitmq.client.DeliverCallback; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; public final class Main { private static final String QUEUE = "hello"; // → amqp.default.hello public static void main(String[] args) throws Exception { ConnectionFactory factory = new ConnectionFactory(); factory.setUri(System.getenv().getOrDefault( "KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/")); try (Connection connection = factory.newConnection(); Channel channel = connection.createChannel()) { channel.queueDeclare(QUEUE, false, false, false, null); AMQP.BasicProperties props = new AMQP.BasicProperties.Builder() .contentType("text/plain") .build(); channel.basicPublish("", QUEUE, props, "Hello World!".getBytes("UTF-8")); System.out.println("[x] Sent 'Hello World!'"); BlockingQueue received = new ArrayBlockingQueue<>(1); DeliverCallback onDeliver = (tag, delivery) -> received.offer(new String(delivery.getBody(), "UTF-8")); channel.basicConsume(QUEUE, true, onDeliver, tag -> { }); String got = received.poll(15, TimeUnit.SECONDS); if (got == null) { throw new IllegalStateException("timed out waiting for the message"); } System.out.println("[x] Received '" + got + "'"); } } } ``` ```javascript import amqp from "amqplib"; const URL = process.env.KUBEMQ_AMQP_URL ?? "amqp://guest:guest@localhost:5672/"; const QUEUE = "hello"; // → KubeMQ Queue channel amqp.default.hello async function main() { const conn = await amqp.connect(URL); const ch = await conn.createChannel(); await ch.assertQueue(QUEUE, { durable: false, autoDelete: false, exclusive: false }); const received = new Promise((resolve) => { ch.consume( QUEUE, (msg) => { if (msg === null) return; console.log(`[x] Received: ${msg.content.toString()}`); resolve(); }, { noAck: true }, ); }); ch.publish("", QUEUE, Buffer.from("Hello World!"), { contentType: "text/plain" }); console.log("[x] Sent: Hello World!"); await received; await ch.close(); await conn.close(); } main().catch((err) => { console.error("getting-started failed:", err); process.exit(1); }); ``` ```csharp using System.Text; using RabbitMQ.Client; using RabbitMQ.Client.Events; const string queueName = "hello"; // → KubeMQ Queue channel amqp.default.hello var factory = new ConnectionFactory { Uri = new Uri(Environment.GetEnvironmentVariable("KUBEMQ_AMQP_URL") ?? "amqp://guest:guest@localhost:5672/"), }; using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); var ct = cts.Token; await using var connection = await factory.CreateConnectionAsync(ct); await using var channel = await connection.CreateChannelAsync(cancellationToken: ct); await channel.QueueDeclareAsync(queueName, durable: false, exclusive: false, autoDelete: false, arguments: null, cancellationToken: ct); var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var consumer = new AsyncEventingBasicConsumer(channel); consumer.ReceivedAsync += (_, ea) => { received.TrySetResult(Encoding.UTF8.GetString(ea.Body.Span)); return Task.CompletedTask; }; await channel.BasicConsumeAsync(queueName, autoAck: true, consumer: consumer, cancellationToken: ct); var props = new BasicProperties { ContentType = "text/plain" }; await channel.BasicPublishAsync(exchange: "", routingKey: queueName, mandatory: false, basicProperties: props, body: Encoding.UTF8.GetBytes("Hello World!"), cancellationToken: ct); Console.WriteLine("[x] Sent 'Hello World!'"); var message = await received.Task.WaitAsync(ct); Console.WriteLine($"[x] Received '{message}'"); ``` ```ruby # frozen_string_literal: true require "bunny" URL = ENV.fetch("KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/") QUEUE = "hello" # → KubeMQ Queue channel amqp.default.hello conn = Bunny.new(URL) conn.start ch = conn.create_channel ch.confirm_select # publisher confirms, so the publish is routed before we consume queue = ch.queue(QUEUE, durable: false, auto_delete: false, exclusive: false) ch.default_exchange.publish("Hello World!", routing_key: queue.name, content_type: "text/plain") ch.wait_for_confirms puts " [x] Sent 'Hello World!'" body = nil deadline = Time.now + 5 while body.nil? && Time.now < deadline _info, _props, body = queue.pop(manual_ack: false) sleep 0.2 if body.nil? end puts " [x] Received '#{body}'" conn.close ``` ```rust use futures_lite::StreamExt; use lapin::{ options::{BasicConsumeOptions, BasicPublishOptions, QueueDeclareOptions}, types::FieldTable, BasicProperties, Connection, ConnectionProperties, }; const QUEUE: &str = "hello"; // → KubeMQ Queue channel amqp.default.hello // The default "/" vhost must be percent-encoded as "%2f" for lapin's URI parser. fn amqp_url() -> String { let url = std::env::var("KUBEMQ_AMQP_URL") .unwrap_or_else(|_| "amqp://guest:guest@localhost:5672/".into()); let host = url.rsplit_once('@').map_or(url.as_str(), |(_, h)| h); if host.ends_with('/') && !host.ends_with("/%2f") { format!("{url}%2f") } else { url } } #[tokio::main] async fn main() -> Result<(), Box> { let conn = Connection::connect(&amqp_url(), ConnectionProperties::default()).await?; let channel = conn.create_channel().await?; channel .queue_declare(QUEUE, QueueDeclareOptions::default(), FieldTable::default()) .await?; channel .basic_publish( "", QUEUE, BasicPublishOptions::default(), b"Hello World!", BasicProperties::default().with_content_type("text/plain".into()), ) .await? .await?; println!("[x] Sent 'Hello World!'"); let mut consumer = channel .basic_consume( QUEUE, "hello-consumer", BasicConsumeOptions { no_ack: true, ..Default::default() }, FieldTable::default(), ) .await?; if let Some(delivery) = consumer.next().await { let delivery = delivery?; println!("[x] Received '{}'", String::from_utf8_lossy(&delivery.data)); } conn.close(0, "done").await?; Ok(()) } ``` ### Publish a message [#publish-a-message] The program above publishes one `text/plain` message to the **default exchange** with the routing key set to the queue name `hello`. The default (nameless) exchange routes a message to the queue whose name equals the routing key, so the message lands in queue `hello` — the KubeMQ Queue channel `amqp.default.hello`. Because a KubeMQ Queue is durable and at-least-once, you can publish before a consumer is attached and the message waits in the queue. ### Consume and verify [#consume-and-verify] A consumer subscribed to `hello` receives the message. When it arrives the program prints it and exits: ```text [x] Sent 'Hello World!' [x] Received 'Hello World!' ``` Unlike fire-and-forget pub/sub, the Queue holds the message until a consumer acknowledges it, so order of operations is forgiving. For competing consumers and fair dispatch across a worker pool, see [Work queues](/connectors/rabbitmq/how-to/work-queues). Exchanges and bindings are **virtual** — resolved at publish time, not stored. A `fanout`, `direct`, `topic`, or `headers` exchange routes to a set of queues, and each resolved queue is a normal KubeMQ Queue channel. See [Exchanges and routing](/connectors/rabbitmq/concepts/exchanges-and-routing). ## Next steps [#next-steps] # Capabilities (/connectors/rabbitmq/reference/capabilities) This reference defines exactly what the embedded KubeMQ RabbitMQ (AMQP 0-9-1) connector **supports**, the capabilities it **forces during negotiation**, the methods it **rejects**, and the arguments it accepts but **silently ignores**. Use it to decide which client features are safe to rely on and which ones will be refused. Every claim here is grounded in the connector source and integration tests. ## Supported AMQP methods [#supported-amqp-methods] The connector implements the AMQP 0-9-1 (RabbitMQ dialect) method set needed for real applications: | Class | Methods | | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Connection** | `connection.start` / `start-ok` / `tune` / `tune-ok` / `open` / `open-ok` / `close` / `close-ok` | | **Channel** | `channel.open` / `close` / `flow` (replies `flow-ok`, no-op) | | **Exchange** | `exchange.declare` (incl. `passive`), `exchange.delete` | | **Queue** | `queue.declare` (incl. `passive`, server-named), `queue.bind`, `queue.unbind`, `queue.purge`, `queue.delete` | | **Basic** | `basic.publish`, `basic.consume`, `basic.cancel`, `basic.deliver`, `basic.get`, `basic.ack`, `basic.nack`, `basic.reject`, `basic.qos`, `basic.recover` (requeue=true), `basic.return` | | **Confirm** | `confirm.select` (publisher confirms) | Exchanges and bindings are **virtual** connector-side routing — every AMQP queue is backed by a single KubeMQ **Queue** channel (`amqp.{vhost}.{queue}`). See [Channel Mapping](/connectors/rabbitmq/reference/channel-mapping) and [Architecture](/connectors/rabbitmq/concepts/architecture). ## Forced / negotiated capabilities [#forced--negotiated-capabilities] The connector pins the connection tuning values during `connection.tune`. A client may request lower values, but cannot exceed these ceilings: | Capability | Value | | --------------- | ---------------------------------------------------------------------------- | | ChannelMax | `2047` | | FrameMax | `131072` (floor `4096`) | | Heartbeat | `60`s (final = min non-zero of both sides; 2× idle → `client.timeout`) | | MaxBodySize | `104857600` (100 MiB) | | MaxConnections | `1000` (`0` = unlimited) | | Protocol header | exactly `AMQP\x00\x00\x09\x01` (8 bytes); mismatch → echo header + TCP close | | SASL mechanism | **PLAIN only** (AMQPLAIN / EXTERNAL → `503`) | See [Configuration](/connectors/rabbitmq/concepts/configuration) to tune ChannelMax / FrameMax / Heartbeat / MaxBodySize / MaxConnections. ## Not implemented (`540`) [#not-implemented-540] These methods are **advertised as unsupported** and always return `540 not-implemented`: * `tx.*` (transactions) — on a confirm-mode channel, `tx.select` returns `406` instead; * `exchange.bind` / `exchange.unbind` (exchange-to-exchange bindings); * `basic.publish(immediate=true)`; * `basic.recover-async` (and `basic.recover(requeue=false)`); * `connection.update-secret`; * non-PLAIN SASL (AMQPLAIN / EXTERNAL) → `503`. See [Error & Reason Codes](/connectors/rabbitmq/reference/error-codes) for the full close-code table. ## Inert arguments (accepted, badged, no effect) [#inert-arguments-accepted-badged-no-effect] **Inert arguments (gotcha #7).** These exchange/queue arguments are accepted (and badged in the dashboard topology view) but **never alter behavior**. Do not rely on them — a queue declared with, e.g., `x-max-length` is not actually enforcing it. The connector's `inertArgNames` set — accepted, WARN-logged once, and badged in the topology view: * `alternate-exchange`; * priority queues (`x-max-priority`) — the `priority` property is carried as a tag, but ordering is not honored; * `x-max-length` / `x-max-length-bytes` / `x-overflow`; * `x-queue-type` / `x-queue-mode`; * single-active-consumer (`x-single-active-consumer`); * `x-expires` (queue-level TTL); * consumer `x-priority`. Two more inputs are inert but **not** in the badge set: * `x-message-ttl` (queue-level TTL) — silently ignored; only the per-message `expiration` property drives TTL (gotcha #1 / gotcha #7); * `prefetch-size` on `basic.qos` — accepted but ignored (only `prefetch-count` is honored). ## The nine gotchas [#the-nine-gotchas] These connector behaviors deviate from RabbitMQ classic. Each is a documented contract, not a bug — most stay invisible until a corner case hits production. | # | Gotcha | Where documented | | - | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | 1 | **TTL never dead-letters** — expired messages are eager-dropped even with a DLX | [Reliability](/connectors/rabbitmq/how-to/reliability), [Migrating from RabbitMQ](/connectors/rabbitmq/reference/migration-from-rabbitmq) | | 2 | **DLX rejected-trigger only** — only `reject/nack(requeue=false)` triggers DLX | [Reliability](/connectors/rabbitmq/how-to/reliability) | | 3 | **Publisher confirms have no rollback** — already-delivered queues stay; retry duplicates | [Reliability](/connectors/rabbitmq/how-to/reliability) | | 4 | **`basic.get` \~1s latency floor** — polling is slow; prefer `basic.consume` | [Queues & Consumers](/connectors/rabbitmq/how-to/queues-and-consumers) | | 5 | **Requeue at tail, not head** — fairness differs from RabbitMQ classic | [Queues & Consumers](/connectors/rabbitmq/how-to/queues-and-consumers) | | 6 | **Exclusive queues + direct reply-to are node-local** — need LB session affinity | [RPC pattern](/connectors/rabbitmq/how-to/rpc), [Migrating from RabbitMQ](/connectors/rabbitmq/reference/migration-from-rabbitmq) | | 7 | **Inert queue arguments** — priority / max-length / queue-type / etc. accepted but never apply | this page, [Exchanges & Routing](/connectors/rabbitmq/concepts/exchanges-and-routing) | | 8 | **Reserved `"default"` vhost + name charset** — `;:*>` / whitespace / trailing-`.` rejected | [Channel Mapping](/connectors/rabbitmq/reference/channel-mapping), [Migrating from RabbitMQ](/connectors/rabbitmq/reference/migration-from-rabbitmq) | | 9 | **Publish-then-close loses unconfirmed messages** — a fire-and-forget `basic.publish` then an immediate channel/connection close silently drops still-buffered publishes (≤ 64), with no client error; use a confirm channel before closing | [Reliability](/connectors/rabbitmq/how-to/reliability), [Migrating from RabbitMQ](/connectors/rabbitmq/reference/migration-from-rabbitmq) | ## Related [#related] # Channel Mapping (/connectors/rabbitmq/reference/channel-mapping) This is the master reference for how the embedded KubeMQ RabbitMQ (AMQP 0-9-1) connector maps an AMQP **queue** to a KubeMQ **Queue** channel. Every AMQP queue — regardless of which exchange or routing key delivered to it — is backed by exactly one KubeMQ Queue channel. Exchanges and bindings are **virtual** connector-side routing; the queue is the only durable object. **Everything is a Queue.** The connector mirrors AMQP routing *concepts* (direct / fanout / topic / headers exchanges, bindings, RPC) on top of the single KubeMQ **Queue** primitive. See [Architecture](/connectors/rabbitmq/concepts/architecture) for the virtual-routing model. ## Grammar [#grammar] Every AMQP queue maps to exactly one KubeMQ Queue channel: ```text amqp.{vhost}.{queue} └┬─┘ └──┬──┘ └──┬──┘ │ │ └─ the AMQP queue name │ └─ the vhost segment (AMQP "/" → DefaultVhost, literal "default") └─ fixed connector prefix ``` The mapping is `mappedChannelPrefix + vhost + "." + queue`. | AMQP queue | Vhost | KubeMQ channel | | ---------- | ------------- | ---------------------- | | `orders` | `/` (default) | `amqp.default.orders` | | `hello` | `/` | `amqp.default.hello` | | `probe.q` | `/` | `amqp.default.probe.q` | | `jobs` | `workers` | `amqp.workers.jobs` | ## Constraints [#constraints] | Constraint | Rule | Violation | | ------------------ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | | **Length** | Max 255 chars (prefix + vhost + queue). | `406 precondition-failed` at queue declaration. | | **Charset** | Queue/vhost names must NOT contain `;`, `:`, `*`, `>`, whitespace, or end with `.`. | `406` (queue) / `402` (vhost). | | **Reserved vhost** | The literal `"default"` vhost is reserved and cannot be client-created — reach it via `/`. | `402 invalid-path` (connector source); a running broker may surface `403 "no access to this vhost"` on a direct connect. | **Reserved `default` vhost + name charset (gotcha #8).** Connect to vhost `/` (which maps to the `default` segment); connecting directly to a vhost literally named `default` is rejected (`402 invalid-path` per the connector source; a running broker may surface `403 "no access to this vhost"`). Names containing `;`, `:`, `*`, `>`, whitespace, or a trailing `.` are rejected. See [Migrating from RabbitMQ](/connectors/rabbitmq/reference/migration-from-rabbitmq) for the rename-before-migration checklist. ## Cross-protocol interoperability [#cross-protocol-interoperability] Because the backing store is a normal KubeMQ Queue channel, a message published over AMQP to `amqp.default.orders` is consumable by a gRPC/REST queue client on the same channel, and vice-versa. The AMQP queue and the KubeMQ Queue channel are the same object viewed through two protocols. ## Property / header ⇄ tag / metadata mapping [#property--header--tag--metadata-mapping] ### AMQP basic properties → KubeMQ tags [#amqp-basic-properties--kubemq-tags] Tag namespace `amqp.*`. An absent property produces no tag. | AMQP property | KubeMQ Tag | Notes | | ------------------- | ----------------------------------- | --------------------------------------------------- | | content-type | `amqp.content-type` | | | content-encoding | `amqp.content-encoding` | | | delivery-mode | `amqp.delivery-mode` | | | priority | `amqp.priority` | carried as a tag; priority ordering itself is inert | | correlation-id | `amqp.correlation-id` | | | reply-to | `amqp.reply-to` | rewritten for direct-reply-to | | expiration | `amqp.expiration` | also → `Policy.ExpirationSeconds` | | message-id | `amqp.message-id` | UUID minted if absent | | timestamp | `amqp.timestamp` | Unix seconds | | type | `amqp.type` | | | user-id | `amqp.user-id` | validated vs ClientID when auth enabled | | app-id | `amqp.app-id` | | | *(routing context)* | `amqp.exchange`, `amqp.routing-key` | **always set on delivery** | ### AMQP headers ⇄ KubeMQ metadata [#amqp-headers--kubemq-metadata] | Direction | Behavior | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **AMQP → KubeMQ** | Headers are wrapped as `Metadata = {"amqp_headers":{...}}` (`metadataEnvelopeKey = "amqp_headers"`). **Always** set, even for nil/empty headers (`{"amqp_headers":{}}`). E.g. header `trace=abc-123` → metadata `{"amqp_headers":{"trace":"abc-123"}}`. | | **KubeMQ → AMQP** | Native (non-enveloped) gRPC metadata surfaces as the AMQP header `x-kubemq-metadata` (`headerKubemqMetadata = "x-kubemq-metadata"`). E.g. gRPC `metadata="native-metadata"` → AMQP delivery header `x-kubemq-metadata: native-metadata`. | Special headers: * `x-delay` → `Policy.DelaySeconds` (stripped on delivery); * `x-death` trail reconstructed for dead-lettering; * `expiration` moved to `x-death[0].original-expiration` when dead-lettered. The `{"amqp_headers":{...}}` envelope is what a **gRPC/REST** consumer sees; a pure AMQP→AMQP round-trip surfaces headers natively. Treat the envelope as an **interop concern**, not a default-path surprise. ## Related [#related] # Configuration reference (/connectors/rabbitmq/reference/configuration) All 12 fields live under `[Connectors.Amqp]` and have environment-variable overrides of the form `CONNECTORS_AMQP_*`. Defaults are taken verbatim from the server's `AmqpConfig` struct. ## Configuration fields [#configuration-fields] | Env var | Default | Type | Validation / meaning | | -------------------------------------- | --------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------- | | `CONNECTORS_AMQP_ENABLE` | `false` | bool | Opt-in; `true` enables the connector. `false` skips the connector and all its validation. | | `CONNECTORS_AMQP_PORT` | `5672` | int | `0..65535`; the plain-TCP listener. `0` disables the plain listener. | | `CONNECTORS_AMQP_TLS_PORT` | `5671` | int | `0..65535`; the TLS/AMQPS listener, active only when the server-wide Security block is configured. `0` disables it. | | `CONNECTORS_AMQP_HEARTBEAT_SECONDS` | `60` | int | `≥ 0`; the negotiated heartbeat is the min non-zero of both sides; 2× idle → `client.timeout`. | | `CONNECTORS_AMQP_FRAME_MAX` | `131072` (128 KiB) | int | `≥ 4096`; the FrameMax advertised to clients during negotiation. | | `CONNECTORS_AMQP_CHANNEL_MAX` | `2047` | int | `1..65535`; max channels per connection (the negotiated cap). | | `CONNECTORS_AMQP_MAX_CONNECTIONS` | `1000` | int | `≥ 0` (`0` = unlimited); over-limit connections complete the handshake then receive `connection.close(320, "connection limit reached")`. | | `CONNECTORS_AMQP_MAX_BODY_SIZE` | `104857600` (100 MiB) | int | `> 0`; a body larger than this at content-header time → `406`/`311` and the body frames are drained. | | `CONNECTORS_AMQP_DEFAULT_VHOST` | `"default"` | string | non-empty; no `;:*>`, whitespace, or tab; no trailing `.`. The segment AMQP vhost `/` maps to. | | `CONNECTORS_AMQP_GET_BATCH_SIZE` | `32` | int | `1..1024`; bounds per-`basic.get` pulls. | | `CONNECTORS_AMQP_DEAD_LETTER_MAX_HOPS` | `16` | int | `≥ 1`; the dead-letter cycle cap per (queue, reason). | | `CONNECTORS_AMQP_MAX_RECEIVE_COUNT` | `0` | int | `≥ 0`; poison-message receive cap. `0` inherits the broker default. | **Env-form rule — camelCase fields are snake-split.** `TlsPort` becomes `CONNECTORS_AMQP_TLS_PORT`, **not** `CONNECTORS_AMQP_TLSPORT`. The server converts each config key with `ToSnakeCase`, strips the dots, then uppercases — so insert the underscore at each camelCase boundary. For narrative context on these fields — enabling/disabling the connector, TLS, and the availability-first startup model — see [Configuration](../concepts/configuration). ## Validation rules [#validation-rules] These rules are enforced at startup; a disabled connector (`CONNECTORS_AMQP_ENABLE=false`) skips all validation. | Field | Rule | | ------------------- | ---------------------------------------------------------- | | `Port` / `TlsPort` | when `Enable=true`, **at least one** must be non-zero. | | `Port`, `TlsPort` | each in range `0..65535`. | | `FrameMax` | `≥ 4096` (the protocol floor). | | `ChannelMax` | in range `1..65535`. | | `MaxConnections` | `≥ 0` (`0` = unlimited). | | `MaxBodySize` | `> 0`. | | `DefaultVhost` | non-empty; no `;:*>`, whitespace, or tab; no trailing `.`. | | `GetBatchSize` | in range `1..1024`. | | `DeadLetterMaxHops` | `≥ 1`. | | `MaxReceiveCount` | `≥ 0`. | Two cross-config constraints are also enforced by the server's `Config.Validate()`: `Connectors.Amqp.MaxReceiveCount` ≤ `Queue.MaxReceiveCount`, and `Connectors.Amqp.GetBatchSize` ≤ `Queue.MaxNumberOfMessages`. **The literal `default` vhost is reserved.** `DefaultVhost` defaults to `"default"`. Clients reach it by connecting to vhost `/`; connecting directly to a vhost literally named `default` (e.g. `amqp://guest:guest@host:5672/default`) is rejected — the connector returns `402 invalid-path` (`"vhost \"default\" is reserved"`), and a running broker may instead surface `403 "no access to this vhost"`. Either way, reach the default vhost implicitly through `/`. See [Channel mapping](/connectors/rabbitmq/reference/channel-mapping). ## Related [#related] # Connections & Observability (/connectors/rabbitmq/reference/connections-endpoint) This reference documents the RabbitMQ (AMQP 0-9-1) connector's observability surface: the **dashboard HTTP endpoints**, the **Prometheus metric families**, the **SSE feed**, and the **audit events** the connector emits. The dashboard APIs are on the **internal API port** and are network-protected like all `/api/*` routes. ## Dashboard APIs [#dashboard-apis] ### `GET /api/amqp/connections` [#get-apiamqpconnections] Node-local live connection list with per-connection metadata: client ID, source IP, channels, consumers, and vhost. Use it to confirm the connector accepted connections after an upgrade — see [Configuration](/connectors/rabbitmq/concepts/configuration). ### `GET /api/amqp/topology` [#get-apiamqptopology] Cluster-synced view of exchanges, queues, and bindings, with message and consumer counts and **inert-argument badges** (the arguments listed under gotcha #7 are flagged here so operators can see that a queue declared with, e.g., `x-max-length` is not actually enforcing it). See [Capabilities](/connectors/rabbitmq/reference/capabilities). ## Prometheus metrics [#prometheus-metrics] ### Gauges [#gauges] | Metric | Meaning | | ------------------------- | --------------------- | | `kubemq_amqp_connections` | Live AMQP connections | | `kubemq_amqp_channels` | Live AMQP channels | | `kubemq_amqp_consumers` | Live AMQP consumers | ### Counters [#counters] | Metric | Meaning | | ------------------------------------ | --------------------------------------------------- | | `kubemq_amqp_publishes_total{vhost}` | Publishes, by vhost | | `kubemq_amqp_delivers_total{vhost}` | Deliveries, by vhost | | `kubemq_amqp_confirms_nacked_total` | Publisher-confirm nacks (gotcha #3) | | `kubemq_amqp_returns_total` | `basic.return` events (mandatory unroutable, `312`) | ## SSE feed [#sse-feed] The `connectors` SSE event carries an `"amqp"` group key with per-vhost stats: ```json { "connections": 3, "channels": 7, "consumers": 5, "publishes_total": 1024, "delivers_total": 1019, "confirms_nacked_total": 0, "returns_total": 2, "vhosts": { "default": { "publishes_total": 1024, "delivers_total": 1019 } } } ``` ## Audit events [#audit-events] Audit events use Transport `amqp` and carry `SourceIP` and `ClientID`: | Event | When | | ------------------------------------------------------------- | --------------------------------------------------------- | | `client.connected` / `client.disconnected` / `client.timeout` | Connection lifecycle | | `auth.success` / `auth.failure` | SASL PLAIN authentication outcome | | `amqp.exchange.declared` / `amqp.exchange.deleted` | Exchange lifecycle | | `amqp.queue.declared` / `amqp.queue.deleted` | Queue lifecycle | | `amqp.binding.created` / `amqp.binding.deleted` | Binding lifecycle | | `subscription.created` / `subscription.deleted` | Consumer lifecycle | | `amqp.publish.denied` | Write denial on a routed queue (publish silently dropped) | | `amqp.dlx.routed` | A message was dead-lettered | ## Related [#related] # Error & Reason Codes (/connectors/rabbitmq/reference/error-codes) The RabbitMQ (AMQP 0-9-1) connector returns standard AMQP 0-9-1 channel/connection close codes. This reference lists every code the connector emits, what triggers it, and whether it closes the offending channel or the whole connection. All codes are verified in the connector source. ## Reason code table [#reason-code-table] | Code | Constant | Meaning | Trigger | | ----- | ------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `311` | `replyContentTooLarge` | content-too-large | body > `MaxBodySize` | | `312` | `replyNoRoute` | no-route | `mandatory=true` unroutable → `basic.return` | | `313` | `replyNoConsumers` | no-consumers | **reserved** — defined but not currently emitted in non-test source | | `320` | `replyConnectionForced` | connection-forced | shutdown / conn-limit / graceful close | | `402` | `replyInvalidPath` | invalid-path | bad / reserved vhost (incl. literal `default`) | | `403` | `replyAccessRefused` | access-refused | auth fail / Read deny on consume / `amq.*` client declare / delete pre-declared / exclusive-consumer conflict | | `404` | `replyNotFound` | not-found | passive declare of a missing exchange/queue | | `405` | `replyResourceLocked` | resource-locked | exclusive-queue cross-connection access | | `406` | `replyPreconditionFailed` | precondition-failed | redeclare mismatch, `user-id` mismatch, bad `expiration`, unknown delivery tag, reply-to no-ack rule, channel-length > 255 | | `501` | `replyFrameError` | frame-error | bad frame-end, oversized frame | | `502` | `replySyntaxError` | syntax-error | malformed method / field-table | | `503` | `replyCommandInvalid` | command-invalid | unknown exchange type, out-of-state method, unsupported SASL mechanism (non-PLAIN) | | `504` | `replyChannelError` | channel-error | data-plane / array failure | | `505` | `replyUnexpectedFrame` | unexpected-frame | wire-level sequencing error | | `530` | `replyNotAllowed` | not-allowed | duplicate consumer tag on a channel (RabbitMQ-dialect connection error) | | `540` | `replyNotImplemented` | not-implemented | `tx.*`, `exchange.bind/unbind`, `immediate=true`, `basic.recover-async`, `connection.update-secret` | | `541` | `replyInternalError` | internal-error | broker not ready (gated; new ops rejected until ready) | **`313 replyNoConsumers` is reserved.** It is defined in the connector source but is not currently emitted by non-test code. It is listed here for completeness only. ## Channel vs connection errors [#channel-vs-connection-errors] The scope of a close code tells you what gets torn down: * **Channel errors** (e.g. `404`, `405`, `406`, `504`) close the offending channel; the connection survives. * **Connection errors** (e.g. `320`, `403` auth, `501`, `502`, `503`, `505`, `530`) close the whole connection. ## Common triggers by scenario [#common-triggers-by-scenario] | Scenario | Code | | -------------------------------------------------------------------------- | ---------------------------- | | Publish to a direct exchange with an unmatched key (no `mandatory`) | silent drop (no code) | | `basic.publish(mandatory=true)` unroutable | `312` | | `expiration` not `^\d+$` | `406` | | Consume `amq.rabbitmq.reply-to` with `no-ack=false` | `406` | | `user-id` ≠ `Claims.ClientID` (auth on) | `406` | | Read deny on consume | `403` | | Bad / reserved (`default`) vhost; illegal vhost charset | `402` | | Exclusive-queue cross-connection access | `405` | | Redeclare with mismatched args; unknown delivery tag; channel name > 255 | `406` | | Passive declare of a missing exchange/queue | `404` | | Client declares an `amq.*` exchange / deletes a pre-declared exchange | `403` | | Body > `MaxBodySize` | `311` / `406` | | Bad frame-end / oversized frame / malformed method | `501` / `502` | | Unsupported SASL mechanism (non-PLAIN) | `503` | | `tx.*` / `exchange.bind/unbind` / `immediate=true` / `basic.recover-async` | `540` | | Connection limit reached | `320` | | Broker not ready (gated) | `541` | | Auth failure (bad JWT) | `403` + `auth.failure` audit | ## Related [#related] # Migrating from RabbitMQ (/connectors/rabbitmq/reference/migration-from-rabbitmq) RabbitMQ applications using any standard AMQP 0-9-1 client library (`pika`, `amqp091-go`, `php-amqplib`, Spring AMQP, `amqplib` for Node.js, …) can point at KubeMQ by changing **only the connection string** — no code changes — for the supported feature set. KubeMQ speaks the RabbitMQ wire dialect, so `queue.declare`, `exchange.declare`, publisher confirms, DLX, and Direct Reply-To work as-is. This is an **endpoint-only** drop-in: same library, same code, same protocol, with no KubeMQ SDK to adopt. ## Overview [#overview] | | | | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | **Connector** | KubeMQ RabbitMQ connector (AMQP 0-9-1 RabbitMQ dialect) | | **Ports** | 5672 (AMQP plain) / 5671 (AMQPS/TLS) | | **Canonical client** | [`pika`](https://pika.readthedocs.io/) 1.x (Python) | | **Drop-in level** | Endpoint-only — change the connection string; keep all client library code unchanged for the supported feature set | | **Enable default** | Opt-in — `Connectors.Amqp.Enable = false` by default. Set `CONNECTORS_AMQP_ENABLE=true` (or `Enable = true` in TOML) to open the listener | The RabbitMQ connector is **disabled by default** — a stock kubemq-server does not bind the AMQP listener until you turn it on: **The enable variable is `CONNECTORS_AMQP_ENABLE`** — this is the **AMQP 0-9-1 (RabbitMQ)** connector, distinct from the AMQP 1.0 connector's `CONNECTORS_AMQP10_ENABLE`. For Kubernetes, set `spec.amqp.enabled: true` in the `KubemqCluster` CR. ## Compatibility Matrix [#compatibility-matrix] The cells below are the RabbitMQ column of the [migration hub](/connectors/how-to/migration)'s master cross-protocol matrix. | Dimension | RabbitMQ on KubeMQ | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Point-to-point queues** | ✅ | | **Pub/sub (non-durable)** | ✅ exchanges | | **Durable / persistent subscriptions** | ✅ durable queues | | **Request / reply (RPC)** | ✅ Direct Reply-To | | **Ordering guarantee** | ✅ per-queue¹ | | **Transactions** | ❌ (use publisher confirms) | | **Dead-letter / redrive** | ✅ DLX + x-death² | | **Selectors / filtering / wildcards** | ✅ topic/headers routing | | **Auth model** | SASL PLAIN (JWT) | | **TLS / mTLS** | ✅ 5671 | | **Top unsupported** | See [Hard rejections](#hard-rejections-connectionclose-540-not-implemented) and [Accepted-but-inert arguments](#accepted-but-inert-arguments-stored-never-applied) below | ¹ Requeued messages re-enter at the queue **tail**, not near the head — see [Requeue → tail](#what-does-not-migrate--deviations). ² Dead-lettering fires on the `rejected` trigger only; the `expired` trigger does not exist. Poison messages exceeding `MaxReceiveCount` are silently dropped — there is no consumable dead-letter address for those over this protocol. ## Connection / Endpoint Migration [#connection--endpoint-migration] Replace the RabbitMQ host and port with the KubeMQ host. The URI scheme, client library, and application code stay the same: ```text title="AMQP URI swap" # Before (RabbitMQ) amqp://user:password@rabbitmq.example.com:5672/ amqps://user:password@rabbitmq.example.com:5671/myvhost # After (KubeMQ) amqp://user:password@kubemq.example.com:5672/ amqps://user:password@kubemq.example.com:5671/myvhost ``` Key differences to be aware of at connection time: | Aspect | RabbitMQ | KubeMQ | | --------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | SASL mechanism | PLAIN, AMQPLAIN, EXTERNAL, … | **PLAIN only** | | Password | RabbitMQ user password | **KubeMQ JWT** when `Authentication.Enable = true`; any value when auth is disabled (username recorded for audit) | | Vhost | Must be pre-created | **Namespace-on-first-use** — any charset-valid vhost is accepted; `/` maps to the configured `DefaultVhost` (default `"default"`) | | Client identity | Connection name (optional) | `amqp-{connection_name\|uuid8}` — used for Casbin authorization; policies must cover `amqp-*` IDs | | TLS | Per-listener config | Active when the server's `Security` block is configured; serves the same certificates as gRPC/REST | | Queue storage | RabbitMQ queues | KubeMQ Queue channels `amqp.{vhost}.{queue}` | ## Concept & Destination Mapping [#concept--destination-mapping] AMQP's exchange/binding model is implemented connector-side as virtual routing. Only queues hold messages; exchanges and bindings are metadata resolved at publish time. | RabbitMQ concept | KubeMQ pattern | Channel / address | | -------------------------------------------- | --------------------------- | -------------------------- | | Queue | Queues | `amqp.{vhost}.{queue}` | | Exchange (direct / fanout / topic / headers) | Virtual routing to queues | — | | Binding | Routing rule (vhost-scoped) | — | | Dead-letter exchange (DLX) | Dead-letter re-routing | Target queue channel | | Direct Reply-To (`amq.rabbitmq.reply-to`) | In-connector reply shortcut | Opaque per-channel address | | Vhost `/` | `DefaultVhost` segment | `amqp.default.{queue}` | | Vhost `myvhost` | Literal segment | `amqp.myvhost.{queue}` | A message published over AMQP to queue `orders` in the default vhost lands in the KubeMQ Queue channel `amqp.default.orders`. That channel is interoperable with gRPC/REST queue clients: an AMQP producer can feed a native KubeMQ consumer on the same channel, and vice-versa. See [Channel mapping](/connectors/rabbitmq/reference/channel-mapping) for the full grammar. ## Canonical Client Example [#canonical-client-example] The examples below use [`pika`](https://pika.readthedocs.io/) 1.x. Key API symbols: `pika.BlockingConnection`, `pika.URLParameters`, `pika.ConnectionParameters`, `channel.queue_declare`, `channel.basic_publish`, `channel.basic_consume`, `channel.basic_ack`, `channel.basic_get`. ### Work queue (publish + consume) [#work-queue-publish--consume] ```python import pika # pika 1.x params = pika.URLParameters("amqp://user:YOUR_JWT@kubemq.example.com:5672/") conn = pika.BlockingConnection(params) ch = conn.channel() ch.queue_declare(queue="orders", durable=True) # Publish ch.basic_publish( exchange="", routing_key="orders", body=b'{"order_id": "ORD-1"}', ) # Consume (manual ack) def on_message(ch, method, props, body): print("received:", body) ch.basic_ack(delivery_tag=method.delivery_tag) ch.basic_qos(prefetch_count=10) ch.basic_consume(queue="orders", on_message_callback=on_message) ch.start_consuming() ``` ### Topic exchange routing [#topic-exchange-routing] ```python ch.exchange_declare(exchange="logs", exchange_type="topic") ch.queue_declare(queue="errors", durable=True) ch.queue_bind(exchange="logs", queue="errors", routing_key="*.error") ch.basic_publish(exchange="logs", routing_key="app.error", body=b"boom") # routed ch.basic_publish(exchange="logs", routing_key="app.info", body=b"fyi") # not routed ``` ### Publisher confirms [#publisher-confirms] ```python ch.confirm_delivery() ch.basic_publish( exchange="", routing_key="orders", body=b"payload", mandatory=True, ) # pika raises UnroutableError on basic.return when mandatory=True and no route ``` ### Dead-letter exchange [#dead-letter-exchange] ```python ch.exchange_declare(exchange="dlx", exchange_type="fanout") ch.queue_declare(queue="dead", durable=True) ch.queue_bind(exchange="dlx", queue="dead", routing_key="") ch.queue_declare( queue="work", durable=True, arguments={"x-dead-letter-exchange": "dlx"}, ) # A consumer that basic_nack(..., requeue=False) on "work" dead-letters to "dead" # with RabbitMQ-exact x-death headers appended. ``` ### RPC (Direct Reply-To) [#rpc-direct-reply-to] ```python import uuid reply_queue = "amq.rabbitmq.reply-to" corr_id = str(uuid.uuid4()) # Responder (subscribe to the work queue and reply) def handle_request(ch, method, props, body): response = process(body) ch.basic_publish( exchange="", routing_key=props.reply_to, properties=pika.BasicProperties(correlation_id=props.correlation_id), body=response, ) ch.basic_ack(delivery_tag=method.delivery_tag) # Requester ch.basic_consume(queue=reply_queue, on_message_callback=on_reply, auto_ack=True) ch.basic_publish( exchange="", routing_key="rpc_queue", properties=pika.BasicProperties( reply_to=reply_queue, correlation_id=corr_id, ), body=b"hello", ) ``` ### One-shot pull (basic.get) [#one-shot-pull-basicget] ```python method, props, body = ch.basic_get(queue="orders", auto_ack=False) if method: print("got:", body) ch.basic_ack(delivery_tag=method.delivery_tag) else: print("queue empty") # note: ~1s latency floor on empty queue (deviation) ``` ## Security [#security] **Authentication — opt-in, default disabled.** `Connectors.Amqp.Enable` defaults to `false`. When you enable the connector, the authentication posture depends on whether `Authentication.Enable` is set: * **Auth disabled** (default): the listener accepts **any** SASL PLAIN credentials. If KubeMQ is reachable from untrusted networks, either enable authentication or firewall ports 5672/5671. * **Auth enabled**: the SASL PLAIN **password must be a valid KubeMQ JWT**. JWT validation happens at connect time only — token expiry does not terminate an established connection. The username is recorded for audit and `user-id` checks. **Authorization.** When Casbin is enabled, per-operation checks run against the mapped channel name (`amqp.{vhost}.{queue}`). Add policies for the `amqp-*` client IDs: ```text title="Casbin policies" # Allow all AMQP clients to publish to the orders queue in the default vhost allow amqp-.* Write amqp.default.orders # Allow consumers allow amqp-.* Read amqp.default.orders ``` Publish denials silently remove the denied target from the routed set (audited as `amqp.publish.denied`). Consume/topology denials return `channel.close 403`. **TLS.** Configure the server's `Security` block; the AMQPS listener on 5671 then serves the same certificates as gRPC/REST. Set `Connectors.Amqp.Port = 0` to force TLS-only AMQP. **Environment variables:** | Variable | Purpose | | -------------------------- | ---------------------------------------------------- | | `CONNECTORS_AMQP_ENABLE` | `true` to open the listener | | `CONNECTORS_AMQP_PORT` | Plain TCP listener port (default 5672; `0` disables) | | `CONNECTORS_AMQP_TLS_PORT` | TLS listener port (default 5671; `0` disables) | See [Authentication & security](/connectors/reference/auth-and-security) for JWT issuance and Casbin policy syntax. ## What Does NOT Migrate / Deviations [#what-does-not-migrate--deviations] The connector covers the common AMQP 0-9-1 feature set, but a handful of methods are rejected outright, a handful of declare arguments are accepted but never applied, and a handful of behaviors differ intentionally. The three lists below are **distinct** — the first list errors loudly, the second silently no-ops, and the third changes observable behavior. ### Hard rejections (`connection.close 540 not-implemented`) [#hard-rejections-connectionclose-540-not-implemented] These AMQP methods are rejected immediately — your application will receive a protocol-level error and must stop using them: | Feature | Behavior | | ------------------------------------------------------------------- | ---------------------------------------------------------- | | Transactions (`tx.select`, `tx.commit`, `tx.rollback`) | `540 not-implemented` — use **publisher confirms** instead | | Exchange-to-exchange bindings (`exchange.bind` / `exchange.unbind`) | `540`; capability advertised `false` | | `connection.update-secret` | `540` | | `basic.recover-async` | `540` | | `immediate=true` on `basic.publish` | `540` | ### Accepted-but-inert arguments (stored, never applied) [#accepted-but-inert-arguments-stored-never-applied] The following queue/exchange declare arguments are accepted by the server, stored in topology metadata, surfaced in the dashboard, and logged once per entity — they are **silently stored and never alter behavior**. If your application relies on these for routing or flow control, that logic must move into the application: * Alternate exchange (`x-alternate-exchange`) * Queue length limits and overflow (`x-max-length`, `x-max-length-bytes`, `x-overflow`) * Queue expiry (`x-expires`) * Queue type (`x-queue-type`), lazy mode (`x-queue-mode`), single-active-consumer (`x-single-active-consumer`) * Message priority (`x-max-priority`) These arguments **do not error** — they are accepted and ignored. A queue declared with `x-max-length` or `x-max-priority` behaves like an ordinary queue; the limit or priority is never enforced. Audit your declares for any reliance on these before you cut over. ### Behavioral deviations [#behavioral-deviations] These behaviors differ intentionally from RabbitMQ. Review each against your application before migrating. 1. **Requeue → tail.** Requeued messages re-enter at the queue **tail**. RabbitMQ classic queues preserve near-head position. 2. **TTL expiry is an eager drop.** Expired messages are silently dropped inside the broker — they are **never** dead-lettered. The RabbitMQ `expired` DLX trigger does not fire. 3. **TTL clamped.** Per-message TTL is clamped to `Queue.MaxExpirationSeconds` (default 12h); `x-delay` is clamped to `MaxDelaySeconds`. 4. **`basic.get` latency floor.** `basic.get` on an empty queue has a \~1s latency floor (KubeMQ minimum wait). RabbitMQ returns `get-empty` immediately. Prefer `basic.consume`. 5. **`MaxReceiveCount` drop.** Messages redelivered beyond the broker `MaxReceiveCount` (default 1024) are dropped or broker-rerouted. RabbitMQ redelivers forever. 6. **DLX trigger — rejected only.** DLX fires on the `rejected` trigger (`basic.reject` / `nack` with `requeue=false`) only. The `x-death` header is synthesized for broker-rerouted poison messages. The `expired` trigger does not exist (see deviation 2). 7. **Inert queue arguments.** Priority, max-length/overflow, `x-expires`, queue-type, and single-active-consumer arguments are inert (see [Accepted-but-inert arguments](#accepted-but-inert-arguments-stored-never-applied) above). 8. **Node-local exclusivity.** Exclusive queues and Direct Reply-To are **node-local** in cluster mode. Exclusive queues from two nodes share the channel name; Direct Reply-To requires the requester and responder on the same node (use load-balancer session affinity). 9. **Reserved vhost name.** The literal vhost name `default` (the configured `DefaultVhost` value) is reserved — reach it via `/`. Queue and vhost names must not contain `; : * >`, whitespace, or a trailing `.` (`406` / `402`). 10. **Partial-routing nack.** Publisher-confirm `basic.nack` on partial routing failure does **not** roll back queues that already accepted the message. A publisher retry may duplicate messages. See [Capabilities](/connectors/rabbitmq/reference/capabilities) and [Error codes](/connectors/rabbitmq/reference/error-codes) for the exhaustive per-method error tables and wire-contract detail. ## Verification Smoke Test [#verification-smoke-test] Use the work-queue snippet above as a publish-one / consume-one confirmation. 1. **Enable the connector.** Set `CONNECTORS_AMQP_ENABLE=true` and start (or restart) KubeMQ. Confirm the log line `started insecure amqp listener` (plain TCP) or `started secure amqp listener` (TLS) appears. 2. **Publish a message.** ```python import pika conn = pika.BlockingConnection(pika.URLParameters("amqp://user:pass@kubemq:5672/")) ch = conn.channel() ch.queue_declare(queue="smoke-test", durable=True) ch.basic_publish(exchange="", routing_key="smoke-test", body=b"hello-kubemq") conn.close() print("published ok") ``` 3. **Consume the message.** ```python import pika conn = pika.BlockingConnection(pika.URLParameters("amqp://user:pass@kubemq:5672/")) ch = conn.channel() method, props, body = ch.basic_get(queue="smoke-test", auto_ack=True) assert body == b"hello-kubemq", f"unexpected body: {body!r}" conn.close() print("consume ok:", body) ``` A successful publish followed by a matching consume confirms the connector is reachable and the queue channel is live. ## See Also [#see-also] # Authentication (/connectors/rabbitmq/how-to/authentication) The KubeMQ RabbitMQ connector authenticates over **SASL PLAIN only**. The non-obvious part: the **password carries a KubeMQ JWT**, and the **username is cosmetic**. When authentication is disabled (the dev default), any credentials are accepted, so the examples clone-and-run with `guest:guest`. On a stock dev broker, authentication is **off** and any credentials are accepted — `guest:guest` completes a full round-trip. SASL **PLAIN** with a KubeMQ JWT in the password is the one credentialed form that also runs on a stock broker. To encrypt the JWT on the wire, use the TLS listener (`amqps://:5671`) — see [TLS and mTLS](/connectors/rabbitmq/how-to/tls-and-mtls). ## SASL PLAIN is the only mechanism [#sasl-plain-is-the-only-mechanism] The connector advertises and accepts **only the `PLAIN` mechanism**. A `connection.start-ok` carrying any other mechanism (`AMQPLAIN`, `EXTERNAL`, …) is rejected with `503 command-invalid` (`"unsupported SASL mechanism … only PLAIN is supported"`). The PLAIN response is parsed as the standard triple `authzid \x00 authcid \x00 passwd`. ## Password = KubeMQ JWT, username = cosmetic [#password--kubemq-jwt-username--cosmetic] The username slot is informational; the password slot carries the credential. Most AMQP 0-9-1 clients accept a `(username, password)` pair in the connection URL — put the JWT in the **password**: ```text amqp://:@host:5672/ └─ cosmetic ─┘ └─ authenticated ─┘ ``` | Field | Role | | ------------ | ------------------------------------------------------------------------------------------------------------------------------ | | **Username** | Cosmetic. Recorded for audit and `user-id` checks; **ignored for authorization**. | | **Password** | The **KubeMQ JWT** — passed to the auth service. | | **Identity** | `ClientID` from the JWT's claims. This single `ClientID` covers both connector-level and channel-level (Casbin) authorization. | The JWT is validated **at connect time only** — there is no mid-connection expiry enforcement. On auth failure the connector sends `connection.close(403)` and emits an `auth.failure` audit event. **The JWT travels in the SASL PLAIN password in cleartext at the AMQP layer.** Production deployments that use authentication MUST use the **TLS listener (`amqps://:5671`)**, otherwise the JWT is exposed on the wire. See [TLS and mTLS](/connectors/rabbitmq/how-to/tls-and-mtls) and [Auth & security](/connectors/reference/auth-and-security). Put the JWT in the password slot regardless of client library: ```go // amqp091-go — username is cosmetic, password is the KubeMQ JWT. conn, err := amqp.Dial(fmt.Sprintf( "amqp://audit-user:%s@broker:5672/", os.Getenv("KUBEMQ_AMQP_JWT"))) if err != nil { log.Fatalf("dial (bad/expired JWT? auth-disabled broker?): %v", err) } defer conn.Close() ``` ```python # pika — credentials: username audit-only, password is the KubeMQ JWT. creds = pika.PlainCredentials("audit-user", os.environ["KUBEMQ_AMQP_JWT"]) params = pika.ConnectionParameters(host="broker", port=5672, credentials=creds) conn = pika.BlockingConnection(params) ``` ```java // amqp-client — setUsername is cosmetic; setPassword carries the KubeMQ JWT. ConnectionFactory factory = new ConnectionFactory(); factory.setHost("broker"); factory.setPort(5672); factory.setUsername("audit-user"); factory.setPassword(System.getenv("KUBEMQ_AMQP_JWT")); Connection connection = factory.newConnection(); ``` ```typescript // amqplib — username audit-only, password is the KubeMQ JWT. const connection = await amqp.connect( `amqp://audit-user:${process.env.KUBEMQ_AMQP_JWT}@broker:5672/`); ``` ```csharp // RabbitMQ.Client — UserName is cosmetic; Password carries the KubeMQ JWT. var factory = new ConnectionFactory { HostName = "broker", Port = 5672, UserName = "audit-user", Password = Environment.GetEnvironmentVariable("KUBEMQ_AMQP_JWT"), }; using var connection = factory.CreateConnection(); ``` ```ruby # bunny — username audit-only, password is the KubeMQ JWT. conn = Bunny.new( host: "broker", port: 5672, user: "audit-user", password: ENV.fetch("KUBEMQ_AMQP_JWT")) conn.start ``` ```rust // lapin — username audit-only, password is the KubeMQ JWT. let uri = format!("amqp://audit-user:{}@broker:5672/", std::env::var("KUBEMQ_AMQP_JWT")?); let conn = Connection::connect(&uri, ConnectionProperties::default()).await?; ``` ## Auth disabled — the dev default [#auth-disabled--the-dev-default] When authentication is disabled (the default on a dev broker), **any credentials are accepted** — `guest:guest` completes a full round-trip. The `ClientID` is then derived from the client's sanitized `connection_name`: * `amqp-{connection_name}` if a connection name was provided, otherwise * `amqp-{uuid8}`. ## The `user-id` property [#the-user-id-property] When authentication is **enabled** and the `user-id` property is set on `basic.publish`, it MUST equal the JWT's `ClientID`; a mismatch is rejected with `406 precondition-failed`. When auth is disabled, `user-id` passes through unvalidated. ## Authorization (Casbin) [#authorization-casbin] When auth is enabled, every channel operation is checked **per-channel** against a Casbin policy using the connection's `ClientID`: | Operation | Casbin permission | On denial | | ------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | **Publish** | **Write**, per routed queue | The denied queue is **silently removed** from the routed set + an `amqp.publish.denied` audit. The publish does **not** error. | | **Consume** | **Read** | `403 access-refused`. | | **`queue.declare` / `queue.delete` / bind** | **Write** | `403 access-refused`. | When authorization is disabled, all operations are allowed. **Publish denial is silent.** Because a denied queue is removed from the routed set rather than rejected, a publish to a partially-denied fanout still succeeds for the *allowed* queues. Use `mandatory=true` if you need a `312 NO_ROUTE` when **nothing** routed — see [Reliability](/connectors/rabbitmq/how-to/reliability). ### Example policy [#example-policy] ```json {"ClientID":"amqp-authz-allowed","Channel":"amqp.default.*","Read":true,"Write":true} ``` A client with this policy can declare, publish, and consume under `amqp.default.*`. A client without **Read** on a channel gets `403` on consume; a client without **Write** has its publishes silently dropped for that queue. ## Quick decision guide [#quick-decision-guide] | You want… | Do this | | ----------------------------------- | ------------------------------------------------------------------------------------------------- | | Clone-and-run on a stock dev broker | Connect with any credentials (`guest:guest`) | | Authenticate with a KubeMQ identity | SASL **PLAIN**, JWT in the **password** slot; username is audit-only | | Encrypt the JWT on the wire | Use `amqps://:5671` — see [TLS and mTLS](/connectors/rabbitmq/how-to/tls-and-mtls) | | Diagnose a permission failure | A `403 access-refused` on consume means no **Read**; silently-dropped publishes mean no **Write** | ## Related [#related] # Pub/Sub (Fanout) (/connectors/rabbitmq/how-to/pub-sub) **Publish/subscribe** broadcasts every message to **all** interested subscribers. In AMQP this is a **fanout** exchange: every queue bound to the exchange receives a copy, and the routing key is ignored. Each subscriber typically declares its **own** server-named, exclusive queue, so subscribers are independent and their queues vanish when they disconnect. Each of those queues is an ordinary KubeMQ Queue channel — the exchange itself is **virtual connector-side routing** resolved at publish time, not a data store. ## Overview [#overview] The publisher declares a **fanout** exchange and publishes to it; the routing key is ignored. Each subscriber declares a **server-named exclusive** queue (`queue.declare("")` → an `amq.gen-*` name) and binds it to the exchange with an empty routing key. At publish time the connector resolves the fanout into the set of bound queues and writes a copy to each one's KubeMQ channel — so every subscriber gets its own copy. | Operation | AMQP action | KubeMQ mapping | | ---------------- | ------------------------------------------------- | --------------------------------------------------- | | Declare exchange | `exchange.declare("logs", "fanout")` | Virtual fanout routing entry (no storage) | | Subscribe | `queue.declare("")` + `queue.bind(q, "logs", "")` | Server-named queue channel `amqp.default.amq.gen-*` | | Publish | `basic.publish(exchange="logs", routing-key="")` | Copy fanned out to every bound queue channel | | Receive | `basic.consume(q)` (auto-ack) | One copy per subscriber | ## How it works [#how-it-works] A published message fans out to every queue bound to the exchange. Because each subscriber binds its own exclusive queue, every subscriber receives every message independently — there is no competition between them. *The virtual fanout exchange resolves to every bound queue; the connector writes a copy to each queue channel, so each subscriber receives its own copy.* ## Publish and subscribe [#publish-and-subscribe] Each example declares a non-durable fanout exchange `logs`, attaches **two** subscribers (each with its own server-named exclusive queue bound with the empty key), then broadcasts a batch of messages on a confirm channel. Each subscriber independently receives every message. Every client reads the broker endpoint from `KUBEMQ_AMQP_URL` (default `amqp://guest:guest@localhost:5672/`). ```go package main import ( "context" "fmt" "log" "os" "time" amqp "github.com/rabbitmq/amqp091-go" ) const exchange = "logs" const messages = 5 func amqpURL() string { if v := os.Getenv("KUBEMQ_AMQP_URL"); v != "" { return v } return "amqp://guest:guest@localhost:5672/" } func subscribe(conn *amqp.Connection) (string, <-chan amqp.Delivery) { ch, err := conn.Channel() if err != nil { log.Fatalf("subscriber channel: %v", err) } if err := ch.ExchangeDeclare(exchange, "fanout", false, false, false, false, nil); err != nil { log.Fatalf("declare exchange: %v", err) } q, err := ch.QueueDeclare("", false, false, true, false, nil) // server-named, exclusive if err != nil { log.Fatalf("declare queue: %v", err) } if err := ch.QueueBind(q.Name, "", exchange, false, nil); err != nil { // key ignored log.Fatalf("bind: %v", err) } msgs, err := ch.Consume(q.Name, "", true, false, false, false, nil) // auto-ack if err != nil { log.Fatalf("consume: %v", err) } return q.Name, msgs } func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() conn, err := amqp.Dial(amqpURL()) if err != nil { log.Fatalf("dial: %v", err) } defer func() { _ = conn.Close() }() nameA, subA := subscribe(conn) nameB, subB := subscribe(conn) log.Printf(" [s] bound exclusive queues %q and %q to fanout %q", nameA, nameB, exchange) // Publish on a confirm channel so every broadcast is accepted before we stop. pubCh, _ := conn.Channel() if err := pubCh.Confirm(false); err != nil { log.Fatalf("confirm select: %v", err) } confirms := pubCh.NotifyPublish(make(chan amqp.Confirmation, messages)) for i := 0; i < messages; i++ { if err := pubCh.PublishWithContext(ctx, exchange, "", false, false, amqp.Publishing{ ContentType: "text/plain", Body: []byte(fmt.Sprintf("log-%d", i)), }); err != nil { log.Fatalf("publish %d: %v", i, err) } } for i := 0; i < messages; i++ { <-confirms } log.Printf(" [x] Broadcast %d messages to fanout %q", messages, exchange) // Each subscriber independently receives all 5 copies. for label, msgs := range map[string]<-chan amqp.Delivery{"A": subA, "B": subB} { got := 0 for got < messages { select { case <-msgs: got++ case <-ctx.Done(): log.Fatalf("subscriber %s timed out (%d/%d)", label, got, messages) } } log.Printf(" [s%s] received all %d broadcasts", label, got) } } ``` ```python import os import pika URL = os.environ.get("KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/") EXCHANGE = "logs" SUBSCRIBERS = 2 MESSAGES = 5 def main() -> None: # Subscribers — each on its own connection with a server-named exclusive # queue bound to the fanout exchange. subs = [] for _ in range(SUBSCRIBERS): conn = pika.BlockingConnection(pika.URLParameters(URL)) ch = conn.channel() ch.exchange_declare(exchange=EXCHANGE, exchange_type="fanout", durable=False) queue = ch.queue_declare(queue="", exclusive=True).method.queue # amq.gen-* ch.queue_bind(exchange=EXCHANGE, queue=queue, routing_key="") # key ignored print(f" [sub] bound exclusive queue {queue!r}") subs.append((conn, ch, queue)) # Publisher — broadcast 5 messages on a confirm channel. pub_conn = pika.BlockingConnection(pika.URLParameters(URL)) pub_ch = pub_conn.channel() pub_ch.exchange_declare(exchange=EXCHANGE, exchange_type="fanout", durable=False) pub_ch.confirm_delivery() for i in range(MESSAGES): pub_ch.basic_publish(exchange=EXCHANGE, routing_key="", body=f"log-{i}".encode()) print(f" [x] Broadcast {MESSAGES} messages to {EXCHANGE!r}") # Each subscriber independently receives every copy. for idx, (conn, ch, queue) in enumerate(subs): got = 0 for method, _props, _body in ch.consume(queue, inactivity_timeout=30, auto_ack=True): if method is None: raise SystemExit(f"subscriber {idx}: timed out ({got}/{MESSAGES})") got += 1 if got >= MESSAGES: break ch.cancel() print(f" [sub {idx}] received all {got} broadcasts") ch.close() conn.close() pub_ch.close() pub_conn.close() if __name__ == "__main__": main() ``` ```java import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import com.rabbitmq.client.Channel; import com.rabbitmq.client.Connection; import com.rabbitmq.client.ConnectionFactory; public final class Main { private static final String EXCHANGE = "logs"; private static final int SUBSCRIBERS = 2; private static final int MESSAGES = 5; public static void main(String[] args) throws Exception { ConnectionFactory factory = new ConnectionFactory(); factory.setUri(System.getenv().getOrDefault("KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/")); if (factory.getVirtualHost() == null || factory.getVirtualHost().isEmpty()) { factory.setVirtualHost("/"); // trailing "/" parses to an empty vhost } Connection connection = factory.newConnection(); // Subscribers — each with a server-named exclusive queue bound to fanout. List latches = new ArrayList<>(); for (int i = 0; i < SUBSCRIBERS; i++) { Channel ch = connection.createChannel(); ch.exchangeDeclare(EXCHANGE, "fanout", false); String queue = ch.queueDeclare("", false, true, true, null).getQueue(); // amq.gen-* ch.queueBind(queue, EXCHANGE, ""); // key ignored for fanout CountDownLatch latch = new CountDownLatch(MESSAGES); ch.basicConsume(queue, true, (tag, delivery) -> latch.countDown(), tag -> { }); System.out.println("[sub] bound exclusive queue " + queue); latches.add(latch); } // Publisher — broadcast 5 messages on a confirm channel. Channel pub = connection.createChannel(); pub.exchangeDeclare(EXCHANGE, "fanout", false); pub.confirmSelect(); for (int i = 0; i < MESSAGES; i++) { pub.basicPublish(EXCHANGE, "", null, ("log-" + i).getBytes(StandardCharsets.UTF_8)); } pub.waitForConfirmsOrDie(30_000); System.out.println("[x] Broadcast " + MESSAGES + " messages to fanout '" + EXCHANGE + "'"); // Each subscriber independently receives all 5 copies. for (int i = 0; i < latches.size(); i++) { if (!latches.get(i).await(30, TimeUnit.SECONDS)) { throw new IllegalStateException("subscriber " + i + " did not receive all broadcasts"); } System.out.println("[sub " + i + "] received all " + MESSAGES + " broadcasts"); } connection.close(); } } ``` ```typescript import amqp, { type Channel, type ChannelModel } from "amqplib"; const EXCHANGE = "logs"; const MESSAGES = ["log-0", "log-1", "log-2", "log-3", "log-4"]; function url(): string { return process.env["KUBEMQ_AMQP_URL"] ?? "amqp://guest:guest@localhost:5672/"; } async function subscribe(connection: ChannelModel): Promise<{ name: string; done: Promise }> { const ch: Channel = await connection.createChannel(); await ch.assertExchange(EXCHANGE, "fanout", { durable: false }); const q = await ch.assertQueue("", { exclusive: true }); // amq.gen-* await ch.bindQueue(q.queue, EXCHANGE, ""); // routing key ignored for fanout let received = 0; const done = new Promise((resolve) => { ch.consume(q.queue, (msg) => { if (msg === null) return; if (++received === MESSAGES.length) resolve(); }, { noAck: true }); }); return { name: q.queue, done }; } async function main(): Promise { const connection = await amqp.connect(url()); const subA = await subscribe(connection); const subB = await subscribe(connection); console.log(`[sub] bound exclusive queues ${subA.name} and ${subB.name}`); // Publisher — broadcast on a confirm channel so no message is lost to an early close. const pubCh = await connection.createConfirmChannel(); await pubCh.assertExchange(EXCHANGE, "fanout", { durable: false }); for (const body of MESSAGES) pubCh.publish(EXCHANGE, "", Buffer.from(body)); await pubCh.waitForConfirms(); console.log(`[x] Broadcast ${MESSAGES.length} messages to fanout "${EXCHANGE}"`); await Promise.all([subA.done, subB.done]); console.log("both subscribers received all 5 broadcasts independently"); await connection.close(); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using System.Text; using RabbitMQ.Client; using RabbitMQ.Client.Events; const string exchange = "logs"; const int subscribers = 2; const int messages = 5; static string Url() => Environment.GetEnvironmentVariable("KUBEMQ_AMQP_URL") is { Length: > 0 } v ? v : "amqp://guest:guest@localhost:5672/"; var factory = new ConnectionFactory { Uri = new Uri(Url()) }; await using var connection = await factory.CreateConnectionAsync("pub-sub-fanout"); // Subscribers — each with a server-named exclusive queue bound to the fanout. var latches = new List(); for (var i = 0; i < subscribers; i++) { var ch = await connection.CreateChannelAsync(); await ch.ExchangeDeclareAsync(exchange, ExchangeType.Fanout, durable: false); var queue = (await ch.QueueDeclareAsync("", durable: false, exclusive: true, autoDelete: true)).QueueName; await ch.QueueBindAsync(queue, exchange, ""); // routing key ignored for fanout var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var got = 0; var consumer = new AsyncEventingBasicConsumer(ch); consumer.ReceivedAsync += (_, _) => { if (Interlocked.Increment(ref got) == messages) done.TrySetResult(); return Task.CompletedTask; }; await ch.BasicConsumeAsync(queue, autoAck: true, consumer: consumer); Console.WriteLine($"[sub] bound exclusive queue {queue}"); latches.Add(done); } // Publisher — broadcast on a confirm channel. var confirmOpts = new CreateChannelOptions( publisherConfirmationsEnabled: true, publisherConfirmationTrackingEnabled: true); await using var pub = await connection.CreateChannelAsync(confirmOpts); await pub.ExchangeDeclareAsync(exchange, ExchangeType.Fanout, durable: false); for (var i = 0; i < messages; i++) await pub.BasicPublishAsync(exchange, "", body: Encoding.UTF8.GetBytes($"log-{i}")); Console.WriteLine($"[x] Broadcast {messages} messages to fanout '{exchange}'"); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); await Task.WhenAll(latches.Select(l => l.Task.WaitAsync(cts.Token))); Console.WriteLine("both subscribers received all broadcasts independently"); ``` ```ruby # frozen_string_literal: true require "bunny" require "amq/uri" EXCHANGE = "logs" MESSAGE_COUNT = 5 opts = AMQ::URI.parse(ENV.fetch("KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/")) opts[:vhost] = "/" if opts[:vhost].nil? || opts[:vhost].to_s.empty? conn = Bunny.new(opts) conn.start pub_ch = conn.create_channel pub_ch.confirm_select exchange = pub_ch.fanout(EXCHANGE, durable: false) # Two subscribers, each with its own server-named exclusive queue. subscribers = Array.new(2) do |idx| sub_ch = conn.create_channel queue = sub_ch.queue("", exclusive: true) # amq.gen-* queue.bind(exchange) # routing key ignored for fanout received = Queue.new queue.subscribe(manual_ack: false, block: false) { |_di, _props, _body| received.push(:msg) } puts " [*] Subscriber #{idx + 1} bound exclusive queue #{queue.name}" { id: idx + 1, received: received } end # Broadcast 5 messages, then wait for confirms. MESSAGE_COUNT.times { |i| exchange.publish("log-#{i}", routing_key: "ignored") } pub_ch.wait_for_confirms puts " [x] Broadcast #{MESSAGE_COUNT} messages to fanout '#{EXCHANGE}'" # Each subscriber independently receives all 5. subscribers.each do |s| MESSAGE_COUNT.times { s[:received].pop } puts " [x] Subscriber #{s[:id]} received all #{MESSAGE_COUNT} broadcasts" end exchange.delete conn.close ``` ```rust use futures_lite::StreamExt; use lapin::{ options::{ BasicConsumeOptions, BasicPublishOptions, ConfirmSelectOptions, ExchangeDeclareOptions, QueueBindOptions, QueueDeclareOptions, }, types::FieldTable, BasicProperties, Connection, ConnectionProperties, ExchangeKind, }; const EXCHANGE: &str = "logs"; const MESSAGES: usize = 5; fn amqp_url() -> String { let url = std::env::var("KUBEMQ_AMQP_URL") .unwrap_or_else(|_| "amqp://guest:guest@localhost:5672/".into()); match url.rsplit_once('@').map_or(url.as_str(), |(_, host)| host) { host if host.ends_with('/') && !host.to_lowercase().ends_with("/%2f") => format!("{url}%2f"), _ => url, } } async fn subscribe(conn: &Connection) -> Result> { let ch = conn.create_channel().await?; ch.exchange_declare(EXCHANGE, ExchangeKind::Fanout, ExchangeDeclareOptions::default(), FieldTable::default()) .await?; let queue = ch .queue_declare("", QueueDeclareOptions { exclusive: true, ..Default::default() }, FieldTable::default()) .await?; ch.queue_bind(queue.name().as_str(), EXCHANGE, "", QueueBindOptions::default(), FieldTable::default()) .await?; let consumer = ch .basic_consume(queue.name().as_str(), "", BasicConsumeOptions { no_ack: true, ..Default::default() }, FieldTable::default()) .await?; println!("[sub] bound exclusive queue {}", queue.name()); Ok(consumer) } #[tokio::main] async fn main() -> Result<(), Box> { let conn = Connection::connect(&amqp_url(), ConnectionProperties::default()).await?; let mut sub_a = subscribe(&conn).await?; let mut sub_b = subscribe(&conn).await?; // Publisher — broadcast on a confirm channel. let pub_ch = conn.create_channel().await?; pub_ch.exchange_declare(EXCHANGE, ExchangeKind::Fanout, ExchangeDeclareOptions::default(), FieldTable::default()) .await?; pub_ch.confirm_select(ConfirmSelectOptions::default()).await?; for i in 0..MESSAGES { pub_ch .basic_publish(EXCHANGE, "", BasicPublishOptions::default(), format!("log-{i}").as_bytes(), BasicProperties::default()) .await? .await?; } println!("[x] Broadcast {MESSAGES} messages to fanout '{EXCHANGE}'"); for (label, consumer) in [("A", &mut sub_a), ("B", &mut sub_b)] { for _ in 0..MESSAGES { consumer.next().await.ok_or("subscriber stream closed early")??; } println!("[s{label}] received all {MESSAGES} broadcasts"); } conn.close(0, "done").await?; Ok(()) } ``` ## Server-named exclusive queues [#server-named-exclusive-queues] `queue.declare("")` makes the broker mint a unique name (`amq.gen-{id}`). Declaring it **exclusive** scopes it to the subscriber's connection and auto-deletes it on disconnect — exactly what you want for a transient subscriber. Each such queue is a normal KubeMQ Queue channel that lives only as long as the subscriber. **Confirm before closing a broadcast producer.** Without publisher confirms, `basic.publish` only buffers the message on the connector's per-channel executor and returns; closing the channel or connection before that buffer drains **silently abandons** the un-ingested publishes — no error, no nack. A tight broadcast-then-close loop can lose most of a batch. Enable `confirm.select` (a confirm channel) and **wait for all acks before closing**, as every example above does, or keep the connection open until subscribers have drained. **Exclusive queues are node-local in a cluster.** An exclusive (server-named) queue lives only on the node owning the subscriber's connection; the publisher must reach the same node for the broadcast to land. Single-node deployments are unaffected. ## Related [#related] # Queues and Consumers (/connectors/rabbitmq/how-to/queues-and-consumers) Every AMQP queue maps to a KubeMQ **Queue** channel `amqp.{vhost}.{queue}`. This guide covers declaring queues, consuming, acknowledging (`ack` / `nack` / `reject`), prefetch (QoS), and `basic.get`. Consumption is **at-least-once**: unacked deliveries are requeued on disconnect, so there is **zero loss even on an ungraceful disconnect**. **Exactly-once is NOT provided** — plan for redelivery (`Redelivered == true`). See [Reliability](/connectors/rabbitmq/how-to/reliability). ## Declaring queues [#declaring-queues] `queue.declare` supports `durable` / `exclusive` / `auto-delete` / `arguments`. | Aspect | Behavior | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Idempotency** | Identical args → ok; mismatch → `406 precondition-failed`. | | **Passive declare** | `passive=true`: exists → ok; missing → `404 not-found`. | | **Server-named queues** | `queue.declare("")` → the server mints `amq.gen-{uuid22}`. | | **Exclusive queues** | Connection-scoped. Cross-connection access (including passive declare) → `405 resource-locked`. Auto-deleted when the owning connection closes. **Node-local in a cluster.** | | **Auto-delete queues** | Deleted when the last (cluster-aware) consumer cancels. | **Exclusive queues are node-local.** In a cluster, an exclusive queue lives only on the node that owns the connection; cross-node access fails. For single-node deployments this is invisible. See [Reliability](/connectors/rabbitmq/how-to/reliability) and [Migration from RabbitMQ](/connectors/rabbitmq/reference/migration-from-rabbitmq). ## Consuming [#consuming] `basic.consume` registers a consumer (an auto-generated `ctag-{n}` if the tag is empty). The server then delivers `basic.deliver` + content header + body. | Trigger | Result | | ------------------------------------------ | ------------------------------------------------------ | | Duplicate consumer tag on a channel | `530 not-allowed` (connection error, RabbitMQ dialect) | | Exclusive consumer over existing consumers | `403 access-refused` | A minimal consume loop: ```go // amqp091-go — consume and manually ack each delivery. deliveries, _ := ch.Consume("orders", "", false /* autoAck */, false, false, false, nil) for d := range deliveries { process(d.Body) _ = d.Ack(false) // multiple=false } ``` ```python # pika — consume with manual ack. def on_message(ch, method, props, body): process(body) ch.basic_ack(delivery_tag=method.delivery_tag) channel.basic_consume(queue="orders", on_message_callback=on_message, auto_ack=False) channel.start_consuming() ``` ```java // amqp-client — consume with manual ack. DeliverCallback cb = (tag, delivery) -> { process(delivery.getBody()); channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false); }; channel.basicConsume("orders", false /* autoAck */, cb, t -> {}); ``` ```typescript // amqplib — consume with manual ack. await channel.consume("orders", (msg) => { if (!msg) return; process(msg.content); channel.ack(msg); }, { noAck: false }); ``` ```csharp // RabbitMQ.Client — consume with manual ack. var consumer = new EventingBasicConsumer(channel); consumer.Received += (_, ea) => { Process(ea.Body.ToArray()); channel.BasicAck(ea.DeliveryTag, multiple: false); }; channel.BasicConsume("orders", autoAck: false, consumer); ``` ```ruby # bunny — consume with manual ack. queue.subscribe(manual_ack: true, block: true) do |delivery_info, _props, body| process(body) channel.ack(delivery_info.delivery_tag) end ``` ```rust // lapin — consume with manual ack. let mut consumer = channel.basic_consume( "orders", "", BasicConsumeOptions::default(), FieldTable::default()).await?; while let Some(delivery) = consumer.next().await { let delivery = delivery?; process(&delivery.data); delivery.ack(BasicAckOptions::default()).await?; } ``` ## Ack / nack / reject [#ack--nack--reject] | Method | Effect | | ------------------------------------------------- | ------------------------------------------------- | | `basic.ack(tag, multiple)` | `AckRange` — message(s) consumed. | | `basic.nack` / `basic.reject(tag, requeue=true)` | `NAckRange` — requeued **at the tail**. | | `basic.reject` / `basic.nack(tag, requeue=false)` | Dropped, or dead-lettered if a DLX is configured. | | Unknown delivery tag | `406 precondition-failed`. | **Requeue lands at the tail.** Requeued messages re-enter at the **queue tail**, not the head (a deviation from RabbitMQ classic head-requeue). Fairness ordering therefore differs. ### At-least-once consumption [#at-least-once-consumption] Unacked deliveries are requeued on disconnect (a downstream nack-all safety net), so there is **zero loss even on an ungraceful disconnect**. **Exactly-once is NOT provided** — plan for redelivery (`Redelivered == true`). ## Prefetch (QoS) [#prefetch-qos] `basic.qos(prefetch-size, prefetch-count, global)` limits in-flight unacked deliveries. | Scope | `global` | Meaning | | ------------ | -------------------------- | --------------------------------------------------------- | | Per-consumer | `false` (RabbitMQ default) | The budget applies to each consumer. | | Per-channel | `true` | The budget is shared across all consumers on the channel. | * `prefetch-size` is accepted but **inert**. * Default = **unlimited**. * Effective allowance = `min(per-consumer budget, remaining channel-global budget)`. ## `basic.get` (pull) [#basicget-pull] `basic.get` returns `get-ok` (with a delivery tag) or `get-empty`. **`basic.get` has a \~1s latency floor.** On an empty queue, `basic.get` blocks up to \~1 second (the KubeMQ minimum wait timeout) before returning `get-empty`. Polling with `basic.get` is therefore slow — **prefer `basic.consume`** for throughput. `GetBatchSize` (default 32) bounds per-`Get` pulls. ## Recover / flow [#recover--flow] | Method | Behavior | | ------------------------------------------------------ | ----------------------------------------------------------------- | | `basic.recover(requeue=true)` | Nack-all unacked on the channel (tail requeue). | | `basic.recover(requeue=false)` / `basic.recover-async` | `540 not-implemented`. | | `channel.flow` | Replies `flow-ok`, takes no action (deprecated in the AMQP spec). | ## Purge / delete [#purge--delete] * `queue.purge` returns the exact message count purged. * `queue.delete` sends a server-initiated `basic.cancel(consumerTag)` to live consumers and returns the residual count. ## Related [#related] # Reliability (/connectors/rabbitmq/how-to/reliability) This guide covers publisher confirms, `mandatory` / `basic.return`, dead-letter exchanges (DLX), per-message TTL, delayed delivery (`x-delay`), and at-least-once delivery — plus the gotchas that bite RabbitMQ migrants. Several behaviours **deviate from RabbitMQ**; the most dangerous is the publish-then-close loss below. **Fire-and-forget publishes followed by an immediate close are silently lost** unless you use publisher confirms (or keep the connection open until the consumer has drained). This is the single most dangerous behaviour for a multi-message producer — it fails with no error, no nack, and no log on the client. See [Publish-then-close](#publish-then-close-silently-loses-unconfirmed-messages). ## Publisher confirms [#publisher-confirms] `confirm.select` enables a per-channel, monotonically-increasing publish sequence from **1**. A sequence is **acked** only after **all** routed queues accept the message: * single routed queue → after the queue send; * multiple routed queues → after the batch send for **all** queues. `multiple=true` coalesces consecutive acked sequences; confirmations may arrive **out of order**. **Publisher confirms have no rollback.** On any-queue failure the connector sends `basic.nack(seq)` — but queues that **already accepted** the message **stay delivered**. A naive retry on a nack may therefore **duplicate** the message in the queues that already got it. Design retries to be **idempotent**. ### `tx.*` is unsupported [#tx-is-unsupported] `tx.select` never succeeds, so `confirm.select` can never follow a transaction: * on a normal channel → `540 not-implemented` (connection error); * on a channel already in confirm mode → `406 precondition-failed` (channel error). ## Publish-then-close silently loses unconfirmed messages [#publish-then-close-silently-loses-unconfirmed-messages] This is the single most dangerous behaviour for a multi-message producer to get wrong, because it fails **silently** — no error, no nack, no log on the client. **Fire-and-forget publishes then an immediate close are silently lost.** A `basic.publish` **without confirms** does not block: it only hands the message to the connector's per-channel executor queue, which sends to the KubeMQ queue asynchronously. If you close the channel or connection **before** that executor has drained, every still-buffered publish is **abandoned — never sent, with no error returned to the client.** The buffer holds up to **64** pending publishes, so a tight publish-then-close loop can drop dozens of messages at once. In an internal test run against this connector, with no confirms: **30** fire-and-forget publishes immediately followed by a close lost **16**; **100** lost **84**. These exact counts are timing-dependent (they reflect a race between the executor's send rate and how soon you close) and will vary by environment — the only guarantee is "more than zero." On a **confirm channel** that waits for acks before closing, the same loops lose **0**, which is the invariant to rely on. ### The fix — pick one [#the-fix--pick-one] 1. **Use a confirm channel and wait for all acks before closing (recommended).** Call `confirm.select`, publish, then **block until every publish is acked**. A publish is acked only *after* the connector has actually sent it to the queue, so waiting for confirms forces the executor queue to drain before you close. 2. **Or keep the connection open until the consumer has drained.** If you genuinely cannot use confirms, don't close immediately after publishing — keep the channel/connection alive until you have independent evidence (a consumer ack, a queue-depth check) that the messages were ingested. Remember: confirms have **no rollback**, so make any retry idempotent. ```go // amqp091-go — confirm mode; wait for acks before closing. _ = ch.Confirm(false) confirms := ch.NotifyPublish(make(chan amqp.Confirmation, 1)) _ = ch.Publish("", "orders", false, false, amqp.Publishing{Body: body}) if c := <-confirms; !c.Ack { log.Println("nacked — retry idempotently") } // only now is it safe to close ``` ```python # pika — confirm mode; BlockingChannel raises on a nack. channel.confirm_delivery() try: channel.basic_publish(exchange="", routing_key="orders", body=body) except pika.exceptions.UnroutableError: ... # retry idempotently # publish_delivery blocks until confirmed, so it is now safe to close ``` ```java // amqp-client — confirm mode; block until all publishes are confirmed. channel.confirmSelect(); channel.basicPublish("", "orders", null, body); channel.waitForConfirmsOrDie(5_000); // drains the executor queue before close ``` ```typescript // amqplib — ConfirmChannel; await each publish callback before closing. const ch = await connection.createConfirmChannel(); await new Promise((resolve, reject) => { ch.publish("", "orders", body, {}, (err) => (err ? reject(err) : resolve())); }); // safe to close now ``` ```csharp // RabbitMQ.Client — confirm mode; wait for confirms before closing. channel.ConfirmSelect(); channel.BasicPublish("", "orders", body: body); channel.WaitForConfirmsOrDie(TimeSpan.FromSeconds(5)); ``` ```ruby # bunny — confirm mode; wait for confirms before closing. channel.confirm_select exchange.publish(body, routing_key: "orders") channel.wait_for_confirms # blocks until the executor queue drains ``` ```rust // lapin — publisher confirms; await the returned confirmation. let confirm = channel .basic_publish("", "orders", BasicPublishOptions::default(), body, BasicProperties::default()) .await? .await?; // second await resolves the confirm // confirm is now Ack/Nack — safe to close ``` ## Mandatory / return [#mandatory--return] `basic.publish(mandatory=true)` on an unroutable message returns `basic.return(312 NO_ROUTE)` with the full message content, sent **before** the ack in confirm mode. Without `mandatory`, an unroutable message is **silently dropped**. ## Dead-letter exchange (DLX) [#dead-letter-exchange-dlx] Configure with the queue arguments `x-dead-letter-exchange` / `x-dead-letter-routing-key`. **DLX is rejected-trigger only.** The **only** trigger is an explicit `basic.reject` / `basic.nack(requeue=false)`. **TTL expiry and per-queue length limits do NOT dead-letter** — RabbitMQ also dead-letters on `expired` and `maxlen`; KubeMQ does not. When a message is dead-lettered: * the `x-death` array is RabbitMQ-exact (`queue`, `reason="rejected"`, `time`, `exchange`, `routing-keys`, `count`), most-recent-first; * the `x-first-death-*` / `x-last-death-*` convenience headers are set; * the original `expiration` property is moved to `x-death[0].original-expiration`; * the cycle cap is `DeadLetterMaxHops` (16) per (queue, reason); * a **missing DLX exchange** → drop + WARN + **ack** the original. ## Per-message TTL [#per-message-ttl] Set the `expiration` property to milliseconds as a numeric string (`^\d+$`, else `406`). The connector computes `ceil(ms/1000)` seconds, clamped to a per-queue maximum (default 12h). **TTL never dead-letters.** Expired messages are **eager-dropped inside the broker, never dead-lettered** — even with a DLX configured. (RabbitMQ lazily expires at the head and dead-letters with reason `expired`; KubeMQ does neither.) Note also that **`x-message-ttl` / `x-expires` are inert** — only the per-message `expiration` property drives TTL. ## Delayed delivery (`x-delay`) [#delayed-delivery-x-delay] Set the `x-delay` **header** to milliseconds; the connector computes `ceil(ms/1000)` seconds, clamped to a per-queue maximum (12h), and **strips** the `x-delay` header on delivery (matching the RabbitMQ delayed-message-exchange plugin). ## At-least-once delivery [#at-least-once-delivery] Unacked deliveries are requeued on disconnect — **zero loss**, even on an ungraceful disconnect. Graceful shutdown sends `connection.close(320)`, nacks pending, and requeues unacked. Durable queues persist across restart with `Redelivered == true` on recovery. **Exactly-once is NOT provided.** See [Queues and consumers](/connectors/rabbitmq/how-to/queues-and-consumers). ## Node-local caveat (cluster) [#node-local-caveat-cluster] **Exclusive queues and direct reply-to are node-local.** In a cluster, exclusive queues and the `amq.rabbitmq.reply-to` pseudo-queue live only on the node owning the connection. The requester and responder (or producer and exclusive consumer) must land on the **same node** — use load-balancer session affinity, or switch to an explicit reply-queue + correlation-id. Single-node deployments are unaffected. ## Error quick reference [#error-quick-reference] | Trigger | Code | | -------------------------------------------- | ------------------------- | | `mandatory=true` + unroutable | `312` | | `expiration` not `^\d+$` | `406` | | `tx.select` (normal channel) | `540` | | `tx.select` (confirm-mode channel) | `406` | | Graceful shutdown / connection limit | `320` | | Fire-and-forget publish then immediate close | *none — silently dropped* | ## Related [#related] # Routing (Direct) (/connectors/rabbitmq/how-to/routing) **Routing** delivers a message **selectively** — only to queues whose binding key **exactly** matches the message's routing key. In AMQP this is a **direct** exchange. It sits between fanout (everyone) and topics (pattern matching). The direct exchange is **virtual connector-side routing**: at publish time the connector matches the routing key against the bindings and writes a copy to each matched queue's KubeMQ channel. ## Overview [#overview] Declare a **direct** exchange and bind queues with specific keys — for example `info` → an info queue, `error` → an error queue. Publishing with a routing key delivers the message **only** to queues bound on that exact key. Multiple bindings on the **same** key all match (a key can fan out to several queues). A routing key with **no** matching binding is **silently dropped** unless you set `mandatory=true`, which returns a `312 NO_ROUTE`. | Operation | AMQP action | KubeMQ mapping | | ---------------- | ------------------------------------------------------------ | ----------------------------------------------- | | Declare exchange | `exchange.declare("direct_logs", "direct")` | Virtual direct routing entry (no storage) | | Bind | `queue.bind(q, "direct_logs", "error")` | Maps key `error` → queue channel | | Publish | `basic.publish(exchange="direct_logs", routing-key="error")` | Copy written to each queue bound on `error` | | Unmatched key | no binding on the key | Silently dropped (or `312` if `mandatory=true`) | ## How it works [#how-it-works] A publish resolves to exactly the queues bound on the message's routing key. A key bound to nobody routes to nobody and is dropped without error. *The virtual direct exchange routes each publish only to queues bound on the exact routing key; `error` reaches the error queue, `info` the info queue, and an unbound `debug` key is silently dropped.* ## Publish and route [#publish-and-route] Each example declares a direct exchange `direct_logs`, binds one consumer on `error` and another on `info` (each via its own server-named exclusive queue), then publishes three keys: `error`, `info`, and `debug`. The `error` and `info` messages reach their respective consumers; `debug` is bound to nobody and is silently dropped. Every client reads the broker endpoint from `KUBEMQ_AMQP_URL` (default `amqp://guest:guest@localhost:5672/`). ```go package main import ( "context" "log" "os" "time" amqp "github.com/rabbitmq/amqp091-go" ) const exchange = "direct_logs" func amqpURL() string { if v := os.Getenv("KUBEMQ_AMQP_URL"); v != "" { return v } return "amqp://guest:guest@localhost:5672/" } func bindConsumer(conn *amqp.Connection, key string) <-chan amqp.Delivery { ch, err := conn.Channel() if err != nil { log.Fatalf("channel for %s: %v", key, err) } q, err := ch.QueueDeclare("", false, false, true, false, nil) // server-named, exclusive if err != nil { log.Fatalf("declare %s: %v", key, err) } if err := ch.QueueBind(q.Name, key, exchange, false, nil); err != nil { // bind on the exact key log.Fatalf("bind %s: %v", key, err) } msgs, err := ch.Consume(q.Name, "", true, false, false, false, nil) if err != nil { log.Fatalf("consume %s: %v", key, err) } return msgs } func main() { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() conn, err := amqp.Dial(amqpURL()) if err != nil { log.Fatalf("dial: %v", err) } defer func() { _ = conn.Close() }() ch, _ := conn.Channel() if err := ch.ExchangeDeclare(exchange, "direct", false, false, false, false, nil); err != nil { log.Fatalf("declare exchange: %v", err) } errorMsgs := bindConsumer(conn, "error") infoMsgs := bindConsumer(conn, "info") for key, body := range map[string]string{ "error": "an error happened", "info": "all is well", "debug": "nobody is bound to debug", // silently dropped (no binding) } { if err := ch.PublishWithContext(ctx, exchange, key, false, false, amqp.Publishing{ ContentType: "text/plain", Body: []byte(body), }); err != nil { log.Fatalf("publish key=%s: %v", key, err) } } log.Printf(" [x] Published keys: error, info, debug") select { case d := <-errorMsgs: log.Printf(" [error] received %q (key=%q)", d.Body, d.RoutingKey) case <-ctx.Done(): log.Fatal("error consumer timed out") } select { case d := <-infoMsgs: log.Printf(" [info] received %q (key=%q)", d.Body, d.RoutingKey) case <-ctx.Done(): log.Fatal("info consumer timed out") } log.Printf(" [✓] debug (unbound) → silently dropped (no consumer)") } ``` ```python import os import pika URL = os.environ.get("KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/") EXCHANGE = "direct_logs" def main() -> None: conn = pika.BlockingConnection(pika.URLParameters(URL)) ch = conn.channel() ch.exchange_declare(exchange=EXCHANGE, exchange_type="direct", durable=False) consumers = {} for key in ("error", "info"): queue = ch.queue_declare(queue="", exclusive=True).method.queue ch.queue_bind(exchange=EXCHANGE, queue=queue, routing_key=key) # bind on the exact key consumers[key] = queue for key, body in { "error": "an error happened", "info": "all is well", "debug": "nobody is bound to debug", # silently dropped (no binding) }.items(): ch.basic_publish(exchange=EXCHANGE, routing_key=key, body=body.encode()) print(" [x] Published keys: error, info, debug") for key, queue in consumers.items(): for method, _props, body in ch.consume(queue, inactivity_timeout=10, auto_ack=True): if method is None: raise SystemExit(f"{key} consumer timed out") print(f" [{key}] received {body.decode()!r} (key={method.routing_key})") break ch.cancel() print(" [✓] debug (unbound) → silently dropped (no consumer)") ch.close() conn.close() if __name__ == "__main__": main() ``` ```java import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; import com.rabbitmq.client.Channel; import com.rabbitmq.client.Connection; import com.rabbitmq.client.ConnectionFactory; public final class Main { private static final String EXCHANGE = "direct_logs"; public static void main(String[] args) throws Exception { ConnectionFactory factory = new ConnectionFactory(); factory.setUri(System.getenv().getOrDefault("KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/")); if (factory.getVirtualHost() == null || factory.getVirtualHost().isEmpty()) { factory.setVirtualHost("/"); } try (Connection connection = factory.newConnection(); Channel channel = connection.createChannel()) { channel.exchangeDeclare(EXCHANGE, "direct", false); Map> received = new LinkedHashMap<>(); for (String key : new String[] {"error", "info"}) { Channel ch = connection.createChannel(); String queue = ch.queueDeclare("", false, true, true, null).getQueue(); ch.queueBind(queue, EXCHANGE, key); // bind on the exact key BlockingQueue sink = new ArrayBlockingQueue<>(4); ch.basicConsume(queue, true, (tag, d) -> sink.offer(new String(d.getBody(), StandardCharsets.UTF_8)), tag -> { }); received.put(key, sink); } Map publishes = new LinkedHashMap<>(); publishes.put("error", "an error happened"); publishes.put("info", "all is well"); publishes.put("debug", "nobody is bound to debug"); // silently dropped for (Map.Entry e : publishes.entrySet()) { channel.basicPublish(EXCHANGE, e.getKey(), null, e.getValue().getBytes(StandardCharsets.UTF_8)); } System.out.println("[x] Published keys: error, info, debug"); for (Map.Entry> e : received.entrySet()) { String body = e.getValue().poll(10, TimeUnit.SECONDS); if (body == null) throw new IllegalStateException(e.getKey() + " consumer timed out"); System.out.println("[" + e.getKey() + "] received " + body); } System.out.println("[v] debug (unbound) → silently dropped (no consumer)"); } } } ``` ```typescript import amqp, { type Channel } from "amqplib"; const EXCHANGE = "direct_logs"; function url(): string { return process.env["KUBEMQ_AMQP_URL"] ?? "amqp://guest:guest@localhost:5672/"; } function once(ch: Channel, queue: string): Promise { return new Promise((resolve) => { ch.consume(queue, (msg) => { if (msg) resolve(msg.content.toString()); }, { noAck: true }); }); } async function main(): Promise { const connection = await amqp.connect(url()); const channel = await connection.createChannel(); await channel.assertExchange(EXCHANGE, "direct", { durable: false }); const consumers: Record> = {}; for (const key of ["error", "info"]) { const ch = await connection.createChannel(); const q = await ch.assertQueue("", { exclusive: true }); await ch.bindQueue(q.queue, EXCHANGE, key); // bind on the exact key consumers[key] = once(ch, q.queue); } const publishes: Record = { error: "an error happened", info: "all is well", debug: "nobody is bound to debug", // silently dropped (no binding) }; for (const [key, body] of Object.entries(publishes)) { channel.publish(EXCHANGE, key, Buffer.from(body)); } console.log("[x] Published keys: error, info, debug"); console.log(`[error] received ${await consumers["error"]}`); console.log(`[info] received ${await consumers["info"]}`); console.log("[v] debug (unbound) → silently dropped (no consumer)"); await connection.close(); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using System.Collections.Concurrent; using System.Text; using RabbitMQ.Client; using RabbitMQ.Client.Events; const string exchange = "direct_logs"; static string Url() => Environment.GetEnvironmentVariable("KUBEMQ_AMQP_URL") is { Length: > 0 } v ? v : "amqp://guest:guest@localhost:5672/"; var factory = new ConnectionFactory { Uri = new Uri(Url()) }; await using var connection = await factory.CreateConnectionAsync("routing-direct"); await using var channel = await connection.CreateChannelAsync(); await channel.ExchangeDeclareAsync(exchange, ExchangeType.Direct, durable: false); var received = new Dictionary>(); foreach (var key in new[] { "error", "info" }) { var ch = await connection.CreateChannelAsync(); var queue = (await ch.QueueDeclareAsync("", durable: false, exclusive: true, autoDelete: true)).QueueName; await ch.QueueBindAsync(queue, exchange, key); // bind on the exact key var sink = new BlockingCollection(); var consumer = new AsyncEventingBasicConsumer(ch); consumer.ReceivedAsync += (_, ea) => { sink.Add(Encoding.UTF8.GetString(ea.Body.Span)); return Task.CompletedTask; }; await ch.BasicConsumeAsync(queue, autoAck: true, consumer: consumer); received[key] = sink; } var publishes = new Dictionary { ["error"] = "an error happened", ["info"] = "all is well", ["debug"] = "nobody is bound to debug", // silently dropped (no binding) }; foreach (var (key, body) in publishes) await channel.BasicPublishAsync(exchange, key, body: Encoding.UTF8.GetBytes(body)); Console.WriteLine("[x] Published keys: error, info, debug"); foreach (var (key, sink) in received) Console.WriteLine($"[{key}] received {sink.Take()}"); Console.WriteLine("[v] debug (unbound) → silently dropped (no consumer)"); ``` ```ruby # frozen_string_literal: true require "bunny" require "amq/uri" EXCHANGE = "direct_logs" opts = AMQ::URI.parse(ENV.fetch("KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/")) opts[:vhost] = "/" if opts[:vhost].nil? || opts[:vhost].to_s.empty? conn = Bunny.new(opts) conn.start ch = conn.create_channel exchange = ch.direct(EXCHANGE, durable: false) consumers = {} %w[error info].each do |key| sub_ch = conn.create_channel queue = sub_ch.queue("", exclusive: true) queue.bind(exchange, routing_key: key) # bind on the exact key sink = Queue.new queue.subscribe(manual_ack: false, block: false) { |_di, _props, body| sink.push(body) } consumers[key] = sink end { "error" => "an error happened", "info" => "all is well", "debug" => "nobody is bound to debug" # silently dropped (no binding) }.each { |key, body| exchange.publish(body, routing_key: key) } puts " [x] Published keys: error, info, debug" consumers.each { |key, sink| puts " [#{key}] received #{sink.pop.inspect}" } puts " [x] debug (unbound) → silently dropped (no consumer)" conn.close ``` ```rust use futures_lite::StreamExt; use lapin::{ options::{ BasicConsumeOptions, BasicPublishOptions, ExchangeDeclareOptions, QueueBindOptions, QueueDeclareOptions, }, types::FieldTable, BasicProperties, Connection, ConnectionProperties, ExchangeKind, }; const EXCHANGE: &str = "direct_logs"; fn amqp_url() -> String { let url = std::env::var("KUBEMQ_AMQP_URL") .unwrap_or_else(|_| "amqp://guest:guest@localhost:5672/".into()); match url.rsplit_once('@').map_or(url.as_str(), |(_, host)| host) { host if host.ends_with('/') && !host.to_lowercase().ends_with("/%2f") => format!("{url}%2f"), _ => url, } } async fn bind_consumer(conn: &Connection, key: &str) -> Result> { let ch = conn.create_channel().await?; let queue = ch .queue_declare("", QueueDeclareOptions { exclusive: true, ..Default::default() }, FieldTable::default()) .await?; ch.queue_bind(queue.name().as_str(), EXCHANGE, key, QueueBindOptions::default(), FieldTable::default()) .await?; // bind on the exact key Ok(ch .basic_consume(queue.name().as_str(), "", BasicConsumeOptions { no_ack: true, ..Default::default() }, FieldTable::default()) .await?) } #[tokio::main] async fn main() -> Result<(), Box> { let conn = Connection::connect(&amqp_url(), ConnectionProperties::default()).await?; let ch = conn.create_channel().await?; ch.exchange_declare(EXCHANGE, ExchangeKind::Direct, ExchangeDeclareOptions::default(), FieldTable::default()) .await?; let mut error_c = bind_consumer(&conn, "error").await?; let mut info_c = bind_consumer(&conn, "info").await?; for (key, body) in [ ("error", "an error happened"), ("info", "all is well"), ("debug", "nobody is bound to debug"), // silently dropped (no binding) ] { ch.basic_publish(EXCHANGE, key, BasicPublishOptions::default(), body.as_bytes(), BasicProperties::default()) .await?; } println!("[x] Published keys: error, info, debug"); let err = error_c.next().await.ok_or("error consumer closed")??; println!("[error] received {}", String::from_utf8_lossy(&err.data)); let info = info_c.next().await.ok_or("info consumer closed")??; println!("[info] received {}", String::from_utf8_lossy(&info.data)); println!("[x] debug (unbound) → silently dropped (no consumer)"); conn.close(0, "done").await?; Ok(()) } ``` ## Unmatched key — silent drop [#unmatched-key--silent-drop] This is the behavior to internalize: an unroutable publish **without** `mandatory` produces **no error and no message**. The publish succeeds at the protocol level, but the connector resolves it to an empty set of queues and writes nothing. If you need to detect unroutable publishes, set `mandatory=true` and handle the returned `basic.return(312 NO_ROUTE)`. **A key can fan out to several queues.** Multiple queues bound on the **same** key all receive a copy — direct routing is not limited to one queue per key. If you need wildcard or hierarchical keys, use a [topic exchange](/connectors/rabbitmq/how-to/topics) instead of binding a long list of exact keys. ## Related [#related] # RPC (Direct Reply-To) (/connectors/rabbitmq/how-to/rpc) **RPC** turns messaging into request/response: a client sends a request and blocks for a reply. AMQP RPC on this connector is **fully native and in-protocol** — there is **no gRPC responder** anywhere. The responder is just a normal AMQP consumer of a request queue that publishes a reply. The recommended mechanism is RabbitMQ's **Direct Reply-To** (`amq.rabbitmq.reply-to`), a pseudo-queue that avoids declaring a real reply queue per request. Every queue involved — the request queue and the reply path — resolves onto ordinary KubeMQ Queue channels. ## Overview [#overview] The **requester** consumes the `amq.rabbitmq.reply-to` pseudo-queue (it declares no queue) and publishes each request to a request queue, carrying `reply-to = amq.rabbitmq.reply-to` and a unique `correlation-id`. The connector mints an opaque address (`amq.rabbitmq.reply-to.g1.{node}.{id}`) and rewrites the `reply-to` so the responder only ever sees the minted address. The **responder** is a normal consumer of the request queue; it publishes its reply to the default exchange keyed by that minted address, echoing the **same** `correlation-id`. The requester matches each reply to its request by `correlation-id`. | Operation | AMQP action | KubeMQ mapping | | -------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------- | | Requester consumes replies | `basic.consume("amq.rabbitmq.reply-to", no-ack=true)` | Pseudo-queue (no real channel declared) | | Requester sends request | `basic.publish(routing-key="rpc-queue", reply-to=..., correlation-id=...)` | `SendQueueMessage` to `amqp.default.rpc-queue` | | Responder consumes | `basic.consume("rpc-queue")` | Competing-consumer pull on the request queue | | Responder replies | `basic.publish(routing-key=req.reply-to, correlation-id=req.correlation-id)` | Reply routed to the minted reply address | | Match | requester correlates by `correlation-id` | Reply paired to its request | ## How it works [#how-it-works] The connector rewrites the requester's `reply-to` to an opaque, node-local minted address before the request reaches the responder. The responder echoes that address and the correlation-id; the requester pairs each reply to the request it issued. *The requester's `reply-to` is rewritten to a minted address; the responder echoes the correlation-id, and the requester pairs each reply to its request.* ## Request and reply [#request-and-reply] Each example runs a responder that consumes `rpc-queue` and echoes each request, and a requester that consumes `amq.rabbitmq.reply-to` (no-ack) and issues five correlated requests, matching each response by `correlation-id`. Every client reads the broker endpoint from `KUBEMQ_AMQP_URL` (default `amqp://guest:guest@localhost:5672/`). ```go package main import ( "context" "fmt" "log" "os" "strings" "time" amqp "github.com/rabbitmq/amqp091-go" ) const ( rpcQueue = "rpc-queue" replyTo = "amq.rabbitmq.reply-to" calls = 5 ) func amqpURL() string { if v := os.Getenv("KUBEMQ_AMQP_URL"); v != "" { return v } return "amqp://guest:guest@localhost:5672/" } func main() { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() conn, err := amqp.Dial(amqpURL()) if err != nil { log.Fatalf("dial: %v", err) } defer func() { _ = conn.Close() }() // Responder: consume rpc-queue, reply to the minted reply-to address. serverCh, _ := conn.Channel() if _, err := serverCh.QueueDeclare(rpcQueue, false, false, false, false, nil); err != nil { log.Fatalf("declare rpc-queue: %v", err) } requests, err := serverCh.Consume(rpcQueue, "responder", true, false, false, false, nil) if err != nil { log.Fatalf("responder consume: %v", err) } go func() { for req := range requests { // The requester's reply-to is rewritten to a minted opaque address. if !strings.HasPrefix(req.ReplyTo, "amq.rabbitmq.reply-to.g1.") { log.Printf("warning: responder expected a minted address, got %q", req.ReplyTo) } _ = serverCh.PublishWithContext(ctx, "", req.ReplyTo, false, false, amqp.Publishing{ ContentType: "text/plain", CorrelationId: req.CorrelationId, Body: append([]byte("echo:"), req.Body...), }) } }() // Requester: consume the pseudo-queue with no-ack (manual ack → 406). clientCh, _ := conn.Channel() replies, err := clientCh.Consume(replyTo, "", true, false, false, false, nil) if err != nil { log.Fatalf("consuming %s requires no-ack: %v", replyTo, err) } for i := 1; i <= calls; i++ { corr := fmt.Sprintf("corr-%d", i) body := fmt.Sprintf("request-%d", i) if err := clientCh.PublishWithContext(ctx, "", rpcQueue, false, false, amqp.Publishing{ ContentType: "text/plain", CorrelationId: corr, ReplyTo: replyTo, Body: []byte(body), }); err != nil { log.Fatalf("request %d: %v", i, err) } select { case reply := <-replies: if reply.CorrelationId != corr { log.Fatalf("call %d: correlation-id %q != %q", i, reply.CorrelationId, corr) } log.Printf(" [rpc] %s (corr=%s) → %s", body, corr, reply.Body) case <-time.After(30 * time.Second): log.Fatalf("timed out waiting for reply %d", i) } } log.Printf(" [✓] %d RPC calls round-tripped over %s, correlation-id matched", calls, replyTo) if _, err := serverCh.QueueDelete(rpcQueue, false, false, false); err != nil { log.Printf("warning: rpc-queue delete: %v", err) } } ``` ```python import os import pika URL = os.environ.get("KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/") RPC_QUEUE = "rpc-queue" REPLY_TO = "amq.rabbitmq.reply-to" CALLS = 5 def main() -> None: # Responder: consume requests, echo back to the minted reply address. server_conn = pika.BlockingConnection(pika.URLParameters(URL)) server_ch = server_conn.channel() server_ch.queue_declare(queue=RPC_QUEUE, durable=False) def on_request(ch, method, props, body): # The connector rewrites reply-to to an opaque minted address; the # responder only ever sees that, never the pseudo-queue name. ch.basic_publish( exchange="", routing_key=props.reply_to, body=b"echo:" + body, properties=pika.BasicProperties(content_type="text/plain", correlation_id=props.correlation_id), ) ch.basic_ack(method.delivery_tag) server_ch.basic_consume(queue=RPC_QUEUE, on_message_callback=on_request) # Requester: consume amq.rabbitmq.reply-to (no-ack is mandatory). client_conn = pika.BlockingConnection(pika.URLParameters(URL)) client_ch = client_conn.channel() responses: dict[str, str] = {} client_ch.basic_consume(queue=REPLY_TO, on_message_callback=lambda c, m, p, b: responses.__setitem__(p.correlation_id, b.decode()), auto_ack=True) print(" [client] issuing RPC requests over amq.rabbitmq.reply-to:") for i in range(1, CALLS + 1): corr = f"corr-{i}" client_ch.basic_publish( exchange="", routing_key=RPC_QUEUE, body=f"request-{i}".encode(), properties=pika.BasicProperties(content_type="text/plain", correlation_id=corr, reply_to=REPLY_TO), ) # Pump the responder to handle the request, then the client to receive. while corr not in responses: server_conn.process_data_events(time_limit=1) client_conn.process_data_events(time_limit=1) print(f" request-{i} [{corr}] -> {responses[corr]!r}") print(f" [x] all {CALLS} responses correlation-id matched (native AMQP RPC)") server_ch.queue_delete(queue=RPC_QUEUE) client_conn.close() server_conn.close() if __name__ == "__main__": main() ``` ```java import java.nio.charset.StandardCharsets; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.SynchronousQueue; import java.util.concurrent.TimeUnit; import com.rabbitmq.client.AMQP; import com.rabbitmq.client.Channel; import com.rabbitmq.client.Connection; import com.rabbitmq.client.ConnectionFactory; import com.rabbitmq.client.DeliverCallback; public final class Main { private static final String RPC_QUEUE = "rpc-queue"; private static final String REPLY_TO = "amq.rabbitmq.reply-to"; private static final int CALLS = 5; public static void main(String[] args) throws Exception { ConnectionFactory factory = new ConnectionFactory(); factory.setUri(System.getenv().getOrDefault("KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/")); if (factory.getVirtualHost() == null || factory.getVirtualHost().isEmpty()) { factory.setVirtualHost("/"); } try (Connection serverConn = factory.newConnection(); Connection clientConn = factory.newConnection()) { // Responder: consume requests, reply to the minted address. Channel serverCh = serverConn.createChannel(); serverCh.queueDeclare(RPC_QUEUE, false, false, false, null); DeliverCallback onRequest = (tag, req) -> { String replyAddr = req.getProperties().getReplyTo(); // minted address AMQP.BasicProperties replyProps = new AMQP.BasicProperties.Builder() .contentType("text/plain") .correlationId(req.getProperties().getCorrelationId()) .build(); byte[] body = ("echo:" + new String(req.getBody(), StandardCharsets.UTF_8)).getBytes(StandardCharsets.UTF_8); serverCh.basicPublish("", replyAddr, replyProps, body); }; serverCh.basicConsume(RPC_QUEUE, true, onRequest, tag -> { }); // Requester: consume the pseudo-queue (auto-ack), then call. Channel clientCh = clientConn.createChannel(); ConcurrentHashMap> pending = new ConcurrentHashMap<>(); DeliverCallback onReply = (tag, reply) -> { SynchronousQueue slot = pending.get(reply.getProperties().getCorrelationId()); if (slot != null) { slot.offer(new String(reply.getBody(), StandardCharsets.UTF_8)); } }; clientCh.basicConsume(REPLY_TO, true, onReply, tag -> { }); for (int i = 1; i <= CALLS; i++) { String corr = "corr-" + i; String request = "request-" + i; SynchronousQueue slot = new SynchronousQueue<>(); pending.put(corr, slot); AMQP.BasicProperties props = new AMQP.BasicProperties.Builder() .contentType("text/plain") .correlationId(corr) .replyTo(REPLY_TO) .build(); clientCh.basicPublish("", RPC_QUEUE, props, request.getBytes(StandardCharsets.UTF_8)); String reply = slot.poll(30, TimeUnit.SECONDS); pending.remove(corr); if (reply == null) throw new IllegalStateException("timed out on reply " + i); System.out.println("[rpc] " + corr + ": '" + request + "' -> '" + reply + "'"); } System.out.println("[x] " + CALLS + " RPC calls matched by correlation-id over amq.rabbitmq.reply-to"); } } } ``` ```typescript import amqp from "amqplib"; const RPC_QUEUE = "rpc-queue"; const REPLY_TO = "amq.rabbitmq.reply-to"; const CALLS = 5; function url(): string { return process.env["KUBEMQ_AMQP_URL"] ?? "amqp://guest:guest@localhost:5672/"; } async function main(): Promise { const connection = await amqp.connect(url()); // Responder: consume rpc-queue, echo back to the reply-to address. const serverCh = await connection.createChannel(); await serverCh.assertQueue(RPC_QUEUE, { durable: false }); await serverCh.consume(RPC_QUEUE, (req) => { if (req === null) return; const replyAddr = req.properties.replyTo; // minted address serverCh.publish("", replyAddr, Buffer.concat([Buffer.from("echo:"), req.content]), { contentType: "text/plain", correlationId: req.properties.correlationId, }); }, { noAck: true }); // Requester: consume amq.rabbitmq.reply-to with no-ack (manual ack → 406). const clientCh = await connection.createChannel(); const pending = new Map void>(); await clientCh.consume(REPLY_TO, (reply) => { if (reply === null) return; const resolve = pending.get(reply.properties.correlationId); if (resolve) { pending.delete(reply.properties.correlationId); resolve(reply.content.toString()); } }, { noAck: true }); for (let i = 1; i <= CALLS; i++) { const corr = `corr-${i}`; const body = `request-${i}`; const response = new Promise((resolve) => pending.set(corr, resolve)); clientCh.publish("", RPC_QUEUE, Buffer.from(body), { contentType: "text/plain", correlationId: corr, replyTo: REPLY_TO, }); console.log(`[client] ${body} (${corr}) -> ${await response}`); } console.log(`[client] completed ${CALLS} RPC round-trips, all correlation-id matched`); await serverCh.deleteQueue(RPC_QUEUE); await connection.close(); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using System.Collections.Concurrent; using System.Text; using RabbitMQ.Client; using RabbitMQ.Client.Events; const string requestQueue = "rpc-queue"; const string replyToPseudoQueue = "amq.rabbitmq.reply-to"; const int calls = 5; static string Url() => Environment.GetEnvironmentVariable("KUBEMQ_AMQP_URL") is { Length: > 0 } v ? v : "amqp://guest:guest@localhost:5672/"; var factory = new ConnectionFactory { Uri = new Uri(Url()) }; await using var connection = await factory.CreateConnectionAsync("rpc"); // Responder: consume rpc-queue, echo back to the delivered reply-to. await using var serverCh = await connection.CreateChannelAsync(); await serverCh.QueueDeclareAsync(requestQueue, durable: false, exclusive: false, autoDelete: false); var serverConsumer = new AsyncEventingBasicConsumer(serverCh); serverConsumer.ReceivedAsync += async (_, ea) => { var replyTo = ea.BasicProperties.ReplyTo ?? ""; // minted address var replyProps = new BasicProperties { ContentType = "text/plain", CorrelationId = ea.BasicProperties.CorrelationId, }; var replyBody = Encoding.UTF8.GetBytes("echo:" + Encoding.UTF8.GetString(ea.Body.Span)); await serverCh.BasicPublishAsync("", replyTo, mandatory: false, basicProperties: replyProps, body: replyBody); }; await serverCh.BasicConsumeAsync(requestQueue, autoAck: true, consumer: serverConsumer); // Requester: consume the pseudo-queue with no-ack (manual ack → 406). await using var clientCh = await connection.CreateChannelAsync(); var pending = new ConcurrentDictionary>(); var clientConsumer = new AsyncEventingBasicConsumer(clientCh); clientConsumer.ReceivedAsync += (_, ea) => { if (pending.TryRemove(ea.BasicProperties.CorrelationId ?? "", out var tcs)) tcs.TrySetResult(Encoding.UTF8.GetString(ea.Body.Span)); return Task.CompletedTask; }; await clientCh.BasicConsumeAsync(replyToPseudoQueue, autoAck: true, consumer: clientConsumer); try { for (var i = 1; i <= calls; i++) { var corr = $"corr-{i}"; var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); pending[corr] = tcs; var reqProps = new BasicProperties { ContentType = "text/plain", CorrelationId = corr, ReplyTo = replyToPseudoQueue, }; var request = $"request-{i}"; await clientCh.BasicPublishAsync("", requestQueue, mandatory: false, basicProperties: reqProps, body: Encoding.UTF8.GetBytes(request)); Console.WriteLine($"[rpc] {corr}: '{request}' → '{await tcs.Task}'"); } Console.WriteLine($"[x] All {calls} RPC calls correlation-id matched over native amq.rabbitmq.reply-to"); } finally { await serverCh.QueueDeleteAsync(requestQueue, ifUnused: false, ifEmpty: false); } ``` ```ruby # frozen_string_literal: true require "bunny" require "amq/uri" RPC_QUEUE = "rpc-queue" REPLY_TO = "amq.rabbitmq.reply-to" REQUEST_COUNT = 5 opts = AMQ::URI.parse(ENV.fetch("KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/")) opts[:vhost] = "/" if opts[:vhost].nil? || opts[:vhost].to_s.empty? conn = Bunny.new(opts) conn.start # Responder: consume rpc-queue, echo back with the correlation-id. server_ch = conn.create_channel rpc_q = server_ch.queue(RPC_QUEUE, durable: false) rpc_q.subscribe(manual_ack: false, block: false) do |_di, props, body| server_ch.default_exchange.publish("echo:#{body}", routing_key: props.reply_to, correlation_id: props.correlation_id) end # Requester: consume amq.rabbitmq.reply-to (no-ack MUST be true) and fire 5 requests. client_ch = conn.create_channel replies = {} mutex = Mutex.new cond = ConditionVariable.new client_ch.basic_consume(REPLY_TO, "", true, false) do |_di, props, body| mutex.synchronize do replies[props.correlation_id] = body cond.signal end end requests = {} REQUEST_COUNT.times do |i| cid = "corr-#{i + 1}" payload = "request-#{i + 1}" requests[cid] = payload client_ch.default_exchange.publish(payload, routing_key: RPC_QUEUE, reply_to: REPLY_TO, correlation_id: cid) puts " [>] request #{cid} body=#{payload.inspect}" end deadline = Time.now + 10 mutex.synchronize do cond.wait(mutex, 0.5) while replies.size < REQUEST_COUNT && Time.now < deadline end requests.keys.sort.each { |cid| puts " [<] response #{cid} -> #{replies[cid].inspect}" } puts " [x] All #{REQUEST_COUNT} RPC round-trips matched by correlation-id" rpc_q.delete conn.close ``` ```rust use futures_lite::StreamExt; use lapin::{ options::{BasicConsumeOptions, BasicPublishOptions, QueueDeclareOptions, QueueDeleteOptions}, types::FieldTable, BasicProperties, Connection, ConnectionProperties, }; use std::collections::BTreeMap; const RPC_QUEUE: &str = "rpc-queue"; const REPLY_TO: &str = "amq.rabbitmq.reply-to"; const REQUESTS: usize = 5; fn amqp_url() -> String { let url = std::env::var("KUBEMQ_AMQP_URL") .unwrap_or_else(|_| "amqp://guest:guest@localhost:5672/".into()); match url.rsplit_once('@').map_or(url.as_str(), |(_, host)| host) { host if host.ends_with('/') && !host.to_lowercase().ends_with("/%2f") => format!("{url}%2f"), _ => url, } } #[tokio::main] async fn main() -> Result<(), Box> { let url = amqp_url(); // Responder: consume rpc-queue and reply to each request's reply-to address. let responder_conn = Connection::connect(&url, ConnectionProperties::default()).await?; let responder_ch = responder_conn.create_channel().await?; responder_ch .queue_declare(RPC_QUEUE, QueueDeclareOptions::default(), FieldTable::default()) .await?; let mut requests = responder_ch .basic_consume(RPC_QUEUE, "responder", BasicConsumeOptions { no_ack: true, ..Default::default() }, FieldTable::default()) .await?; let responder = tokio::spawn(async move { let mut handled = 0usize; while handled < REQUESTS { let Some(delivery) = requests.next().await else { break }; let delivery = delivery?; let reply_to = delivery.properties.reply_to().as_ref().map(|s| s.to_string()).unwrap_or_default(); let corr = delivery.properties.correlation_id().clone(); let mut body = b"echo:".to_vec(); body.extend_from_slice(&delivery.data); // The responder only ever sees the minted reply-to address. responder_ch .basic_publish( "", reply_to.as_str(), BasicPublishOptions::default(), &body, BasicProperties::default().with_correlation_id(corr.unwrap_or_else(|| "".into())), ) .await? .await?; handled += 1; } Ok::<_, lapin::Error>(()) }); // Requester: consume amq.rabbitmq.reply-to (no-ack is mandatory; manual ack → 406). let requester_conn = Connection::connect(&url, ConnectionProperties::default()).await?; let requester_ch = requester_conn.create_channel().await?; let mut replies = requester_ch .basic_consume(REPLY_TO, "requester", BasicConsumeOptions { no_ack: true, ..Default::default() }, FieldTable::default()) .await?; let mut pending: BTreeMap = BTreeMap::new(); for i in 1..=REQUESTS { let corr = format!("corr-{i}"); let body = format!("request-{i}"); pending.insert(corr.clone(), format!("echo:{body}")); requester_ch .basic_publish( "", RPC_QUEUE, BasicPublishOptions::default(), body.as_bytes(), BasicProperties::default() .with_reply_to(REPLY_TO.into()) .with_correlation_id(corr.as_str().into()), ) .await? .await?; } let mut matched = 0usize; while matched < REQUESTS { let delivery = replies.next().await.ok_or("reply stream closed")??; let corr = delivery.properties.correlation_id().as_ref().map(|s| s.to_string()).unwrap_or_default(); let body = String::from_utf8_lossy(&delivery.data).into_owned(); if let Some(want) = pending.remove(&corr) { assert_eq!(body, want, "reply must echo the request"); println!("[requester] matched {corr}: {body}"); matched += 1; } } println!("[x] All {REQUESTS} RPC request/response pairs matched by correlation-id"); responder.await??; let _ = requester_ch.queue_delete(RPC_QUEUE, QueueDeleteOptions::default()).await; requester_conn.close(0, "done").await?; responder_conn.close(0, "done").await?; Ok(()) } ``` ## Reply-to must be no-ack [#reply-to-must-be-no-ack] Consuming `amq.rabbitmq.reply-to` requires `no-ack=true`. A manual-ack consume on the pseudo-queue is rejected with `406 precondition-failed` — a deliberate constraint of the direct-reply-to mechanism. The requester declares no reply queue at all; the connector mints the opaque `amq.rabbitmq.reply-to.g1.*` address per request, and the responder only ever sees that minted address, never the literal pseudo-queue name. **No gRPC responder — RPC is fully in-protocol.** Unlike some connectors where the wire-protocol client cannot act as an RPC responder (forcing an embedded gRPC responder), AMQP RPC here is entirely in-protocol. No KubeMQ SDK, no gRPC, and no embedded responder are involved — the responder is a plain AMQP consumer on a KubeMQ Queue channel. **Direct reply-to is node-local in a cluster.** The `amq.rabbitmq.reply-to` pseudo-queue is single-node. In a cluster, the requester and responder must land on the **same** node (use load-balancer session affinity), or switch to an **explicit reply queue + correlation-id** instead. Single-node deployments are unaffected. ## Related [#related] # TLS and mTLS (/connectors/rabbitmq/how-to/tls-and-mtls) The KubeMQ RabbitMQ connector exposes a TLS/AMQPS listener on port **5671**. TLS is **not** AMQP-specific: it is governed by the server-global **`Security`** block, shared with the gRPC and REST connectors. This guide documents server-auth TLS and mutual TLS (mTLS). TLS is configured server-side from the top-level **`Security`** block (not from any AMQP-specific field). The runnable examples use plain `amqp://` against a stock dev broker; to use `amqps://`, supply your own certificates and configure the `Security` block — see [Configuration](/connectors/rabbitmq/concepts/configuration). For the shared TLS/security model across KubeMQ connectors, see [Auth & security](/connectors/reference/auth-and-security). ## When TLS is active [#when-tls-is-active] The TLS listener (`TlsPort`, default `5671`) is active **only when the server-global `Security` block is configured** (`Mode` ≠ None). When it is active: * TLS **1.2+** is enforced; * the URL form is `amqps://:@host:5671/`; * mTLS (client certificates) is supported. The plain listener (`Port`, default `5672`) continues to work alongside the TLS listener. ```bash export KUBEMQ_AMQP_URL="amqps://guest:guest@localhost:5671/" ``` **Why TLS matters here.** The KubeMQ JWT travels in the SASL PLAIN password in **cleartext at the AMQP layer**. Without TLS the JWT is exposed on the wire. Production deployments that use authentication MUST use the 5671 TLS listener. See [Authentication](/connectors/rabbitmq/how-to/authentication). ## TLS (server authentication) [#tls-server-authentication] The client validates the server certificate against a trusted CA, then performs the normal SASL PLAIN handshake over the encrypted channel. The per-language idioms: | Language | TLS entry point | | -------------------- | --------------------------------------------------------------- | | Go | `amqp.DialTLS(url, tlsConfig)` | | Python (pika) | `pika.SSLOptions(ssl_context)` | | Java | `factory.useSslProtocol(sslContext)` | | JavaScript (amqplib) | TLS options passed to `connect(url, { ... })` | | C# (.NET) | `ConnectionFactory.Ssl = new SslOption { ... }` | | Ruby (bunny) | `Bunny.new("amqps://…", tls: true, tls_ca_certificates: [...])` | | Rust (lapin) | `rustls` / `native-tls` feature + TLS connection properties | A server-auth TLS connection still authenticates separately at the SASL layer (PLAIN with the JWT in the password) — see [Authentication](/connectors/rabbitmq/how-to/authentication). ## mTLS (mutual authentication) [#mtls-mutual-authentication] mTLS additionally presents a **client certificate** validated by the server's CA. Configure the server-global `Security` block to require client certs, then supply the client cert / key / CA on the connection: ```text amqps://:@host:5671/ + client certificate (cert + private key) + CA bundle that signed the server certificate + verify_peer = true ``` ## Configuration [#configuration] TLS is configured via the server-global `Security` block, not the AMQP config. The only AMQP-specific knob is the listener port: | Env var | Default | Effect | | -------------------------- | ------- | ------------------------------------------ | | `CONNECTORS_AMQP_TLS_PORT` | `5671` | TLS/AMQPS listener port; `0` disables TLS. | See [Configuration](/connectors/rabbitmq/concepts/configuration). ## Related [#related] # Topics (/connectors/rabbitmq/how-to/topics) A **topic** exchange routes by **pattern**. Routing keys are dot-separated words (`stock.usd.nyse`), and bindings use wildcards to match families of keys. It generalizes the [direct exchange](/connectors/rabbitmq/how-to/routing) (exact match) into flexible, hierarchical routing. Like every exchange here, the topic exchange is **virtual connector-side routing**: at publish time the connector matches the key against the bindings and writes a copy to each matched queue's KubeMQ channel. ## Overview [#overview] Bindings use two wildcards over the `.` word separator: | Token | Matches | | ----- | ---------------------- | | `*` | exactly **one** word | | `#` | **zero or more** words | | Binding | Matches | Does NOT match | | ------------ | -------------------------------------------- | ------------------------------------- | | `*.orange.*` | `quick.orange.rabbit`, `lazy.orange.fox` | `lazy.orange` (needs two words after) | | `*.*.rabbit` | `quick.orange.rabbit`, `lazy.pink.rabbit` | `rabbit` | | `lazy.#` | `lazy`, `lazy.brown.fox`, `lazy.pink.rabbit` | `quick.brown.fox` | | `#` | any key, including the empty key | — | If one queue is bound with **multiple** patterns that both match a key, the message is delivered to that queue exactly **once** — matched queues are deduplicated. ## How it works [#how-it-works] Each publish is matched against every binding; the resulting set of queues is deduplicated, so a queue that matches a key through two bindings still receives a single copy. A key that matches no binding is dropped. *The virtual topic exchange evaluates each binding pattern; `lazy.pink.rabbit` matches both of Q2's bindings yet is delivered to Q2 exactly once, while Q1's `*.orange.*` does not match.* ## Publish and match [#publish-and-match] Each example declares a topic exchange `topic_logs`, binds Q1 on `*.orange.*` and Q2 on **both** `*.*.rabbit` and `lazy.#`, then publishes six keys. `lazy.pink.rabbit` matches both of Q2's bindings and arrives **once**; `quick.brown.fox` matches nobody and is dropped. Every client reads the broker endpoint from `KUBEMQ_AMQP_URL` (default `amqp://guest:guest@localhost:5672/`). ```go package main import ( "context" "log" "os" "sort" "time" amqp "github.com/rabbitmq/amqp091-go" ) const exchange = "topic_logs" func amqpURL() string { if v := os.Getenv("KUBEMQ_AMQP_URL"); v != "" { return v } return "amqp://guest:guest@localhost:5672/" } func bindConsumer(conn *amqp.Connection, patterns ...string) <-chan amqp.Delivery { ch, err := conn.Channel() if err != nil { log.Fatalf("channel: %v", err) } q, err := ch.QueueDeclare("", false, false, true, false, nil) // server-named, exclusive if err != nil { log.Fatalf("declare: %v", err) } for _, p := range patterns { if err := ch.QueueBind(q.Name, p, exchange, false, nil); err != nil { log.Fatalf("bind %s: %v", p, err) } } msgs, err := ch.Consume(q.Name, "", true, false, false, false, nil) if err != nil { log.Fatalf("consume: %v", err) } return msgs } func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() conn, err := amqp.Dial(amqpURL()) if err != nil { log.Fatalf("dial: %v", err) } defer func() { _ = conn.Close() }() ch, _ := conn.Channel() if err := ch.ExchangeDeclare(exchange, "topic", false, false, false, false, nil); err != nil { log.Fatalf("declare exchange: %v", err) } q1 := bindConsumer(conn, "*.orange.*") // orange animals q2 := bindConsumer(conn, "*.*.rabbit", "lazy.#") // rabbits and everything lazy keys := []string{ "quick.orange.rabbit", // q1 + q2 "lazy.orange.elephant", // q1 + q2 (lazy.# spans two words) "quick.orange.fox", // q1 only "lazy.brown.fox", // q2 only (lazy.#) "lazy.pink.rabbit", // q2 only — matches BOTH q2 bindings, ONE copy "quick.brown.fox", // nobody → silent drop } for _, key := range keys { if err := ch.PublishWithContext(ctx, exchange, key, false, false, amqp.Publishing{ ContentType: "text/plain", Body: []byte(key), }); err != nil { log.Fatalf("publish %s: %v", key, err) } } log.Printf(" [x] Published %d keys", len(keys)) collect := func(label string, msgs <-chan amqp.Delivery, want []string) { seen := make(map[string]struct{}, len(want)) for len(seen) < len(want) { select { case d := <-msgs: seen[string(d.Body)] = struct{}{} case <-ctx.Done(): log.Fatalf("%s timed out (%d/%d)", label, len(seen), len(want)) } } out := make([]string, 0, len(seen)) for k := range seen { out = append(out, k) } sort.Strings(out) log.Printf(" [%s] matched: %v", label, out) } collect("q1", q1, []string{"quick.orange.rabbit", "lazy.orange.elephant", "quick.orange.fox"}) collect("q2", q2, []string{"quick.orange.rabbit", "lazy.orange.elephant", "lazy.brown.fox", "lazy.pink.rabbit"}) log.Printf(" [✓] lazy.pink.rabbit matched both q2 bindings → one copy; quick.brown.fox → dropped") } ``` ```python import os import pika URL = os.environ.get("KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/") EXCHANGE = "topic_logs" def main() -> None: conn = pika.BlockingConnection(pika.URLParameters(URL)) ch = conn.channel() ch.exchange_declare(exchange=EXCHANGE, exchange_type="topic", durable=False) def bind(*patterns: str) -> str: queue = ch.queue_declare(queue="", exclusive=True).method.queue for p in patterns: ch.queue_bind(exchange=EXCHANGE, queue=queue, routing_key=p) return queue q1 = bind("*.orange.*") # orange animals q2 = bind("*.*.rabbit", "lazy.#") # rabbits and everything lazy keys = [ "quick.orange.rabbit", # q1 + q2 "lazy.orange.elephant", # q1 + q2 "quick.orange.fox", # q1 only "lazy.brown.fox", # q2 only "lazy.pink.rabbit", # q2 only — matches BOTH q2 bindings, ONE copy "quick.brown.fox", # nobody → silent drop ] for key in keys: ch.basic_publish(exchange=EXCHANGE, routing_key=key, body=key.encode()) print(f" [x] Published {len(keys)} keys") def collect(label: str, queue: str, want: set[str]) -> None: seen: set[str] = set() for method, _props, body in ch.consume(queue, inactivity_timeout=30, auto_ack=True): if method is None: raise SystemExit(f"{label} timed out ({len(seen)}/{len(want)})") seen.add(body.decode()) if len(seen) >= len(want): break ch.cancel() print(f" [{label}] matched: {sorted(seen)}") collect("q1", q1, {"quick.orange.rabbit", "lazy.orange.elephant", "quick.orange.fox"}) collect("q2", q2, {"quick.orange.rabbit", "lazy.orange.elephant", "lazy.brown.fox", "lazy.pink.rabbit"}) print(" [x] lazy.pink.rabbit matched both q2 bindings → one copy; quick.brown.fox → dropped") ch.close() conn.close() if __name__ == "__main__": main() ``` ```java import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.HashSet; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import com.rabbitmq.client.Channel; import com.rabbitmq.client.Connection; import com.rabbitmq.client.ConnectionFactory; public final class Main { private static final String EXCHANGE = "topic_logs"; public static void main(String[] args) throws Exception { ConnectionFactory factory = new ConnectionFactory(); factory.setUri(System.getenv().getOrDefault("KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/")); if (factory.getVirtualHost() == null || factory.getVirtualHost().isEmpty()) { factory.setVirtualHost("/"); } Connection connection = factory.newConnection(); Channel channel = connection.createChannel(); channel.exchangeDeclare(EXCHANGE, "topic", false); Set q1 = ConcurrentHashMap.newKeySet(); Set q2 = ConcurrentHashMap.newKeySet(); bind(connection, q1, "*.orange.*"); bind(connection, q2, "*.*.rabbit", "lazy.#"); String[] keys = { "quick.orange.rabbit", // q1 + q2 "lazy.orange.elephant", // q1 + q2 "quick.orange.fox", // q1 only "lazy.brown.fox", // q2 only "lazy.pink.rabbit", // q2 only — matches BOTH q2 bindings, ONE copy "quick.brown.fox", // nobody → silent drop }; for (String key : keys) { channel.basicPublish(EXCHANGE, key, null, key.getBytes(StandardCharsets.UTF_8)); } System.out.println("[x] Published " + keys.length + " keys"); await(q1, 3); await(q2, 4); System.out.println("[q1] matched: " + new HashSet<>(q1)); System.out.println("[q2] matched: " + new HashSet<>(q2)); System.out.println("[v] lazy.pink.rabbit matched both q2 bindings → one copy; quick.brown.fox → dropped"); connection.close(); } private static void bind(Connection connection, Set sink, String... patterns) throws Exception { Channel ch = connection.createChannel(); String queue = ch.queueDeclare("", false, true, true, null).getQueue(); for (String p : patterns) { ch.queueBind(queue, EXCHANGE, p); } ch.basicConsume(queue, true, (tag, d) -> sink.add(new String(d.getBody(), StandardCharsets.UTF_8)), tag -> { }); } private static void await(Set sink, int expected) throws InterruptedException { long deadline = System.currentTimeMillis() + 30_000; while (sink.size() < expected && System.currentTimeMillis() < deadline) { Thread.sleep(50); } if (sink.size() < expected) { throw new IllegalStateException("expected " + expected + ", saw " + Arrays.toString(sink.toArray())); } } } ``` ```typescript import amqp, { type ChannelModel } from "amqplib"; const EXCHANGE = "topic_logs"; function url(): string { return process.env["KUBEMQ_AMQP_URL"] ?? "amqp://guest:guest@localhost:5672/"; } async function bind(connection: ChannelModel, want: number, ...patterns: string[]): Promise> { const ch = await connection.createChannel(); const q = await ch.assertQueue("", { exclusive: true }); for (const p of patterns) await ch.bindQueue(q.queue, EXCHANGE, p); const seen = new Set(); await ch.consume(q.queue, (msg) => { if (msg) seen.add(msg.content.toString()); }, { noAck: true }); // Returns the live set; the caller waits until it reaches `want`. return Object.assign(seen, { want }); } async function main(): Promise { const connection = await amqp.connect(url()); const channel = await connection.createChannel(); await channel.assertExchange(EXCHANGE, "topic", { durable: false }); const q1 = await bind(connection, 3, "*.orange.*"); const q2 = await bind(connection, 4, "*.*.rabbit", "lazy.#"); const keys = [ "quick.orange.rabbit", // q1 + q2 "lazy.orange.elephant", // q1 + q2 "quick.orange.fox", // q1 only "lazy.brown.fox", // q2 only "lazy.pink.rabbit", // q2 only — matches BOTH q2 bindings, ONE copy "quick.brown.fox", // nobody → silent drop ]; for (const key of keys) channel.publish(EXCHANGE, key, Buffer.from(key)); console.log(`[x] Published ${keys.length} keys`); await waitFor(q1, 3); await waitFor(q2, 4); console.log(`[q1] matched: ${[...q1].sort()}`); console.log(`[q2] matched: ${[...q2].sort()}`); console.log("[x] lazy.pink.rabbit matched both q2 bindings → one copy; quick.brown.fox → dropped"); await connection.close(); } function waitFor(set: Set, n: number): Promise { return new Promise((resolve, reject) => { const deadline = Date.now() + 30_000; const tick = setInterval(() => { if (set.size >= n) { clearInterval(tick); resolve(); } else if (Date.now() > deadline) { clearInterval(tick); reject(new Error(`expected ${n}, saw ${set.size}`)); } }, 50); }); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using System.Collections.Concurrent; using System.Text; using RabbitMQ.Client; using RabbitMQ.Client.Events; const string exchange = "topic_logs"; static string Url() => Environment.GetEnvironmentVariable("KUBEMQ_AMQP_URL") is { Length: > 0 } v ? v : "amqp://guest:guest@localhost:5672/"; var factory = new ConnectionFactory { Uri = new Uri(Url()) }; await using var connection = await factory.CreateConnectionAsync("topics"); await using var channel = await connection.CreateChannelAsync(); await channel.ExchangeDeclareAsync(exchange, ExchangeType.Topic, durable: false); async Task> Bind(params string[] patterns) { var ch = await connection.CreateChannelAsync(); var queue = (await ch.QueueDeclareAsync("", durable: false, exclusive: true, autoDelete: true)).QueueName; foreach (var p in patterns) await ch.QueueBindAsync(queue, exchange, p); var seen = new ConcurrentDictionary(); var consumer = new AsyncEventingBasicConsumer(ch); consumer.ReceivedAsync += (_, ea) => { seen.TryAdd(Encoding.UTF8.GetString(ea.Body.Span), 0); return Task.CompletedTask; }; await ch.BasicConsumeAsync(queue, autoAck: true, consumer: consumer); return seen; } var q1 = await Bind("*.orange.*"); var q2 = await Bind("*.*.rabbit", "lazy.#"); string[] keys = { "quick.orange.rabbit", // q1 + q2 "lazy.orange.elephant", // q1 + q2 "quick.orange.fox", // q1 only "lazy.brown.fox", // q2 only "lazy.pink.rabbit", // q2 only — matches BOTH q2 bindings, ONE copy "quick.brown.fox", // nobody → silent drop }; foreach (var key in keys) await channel.BasicPublishAsync(exchange, key, body: Encoding.UTF8.GetBytes(key)); Console.WriteLine($"[x] Published {keys.Length} keys"); await WaitFor(q1, 3); await WaitFor(q2, 4); Console.WriteLine($"[q1] matched: {string.Join(", ", q1.Keys.Order())}"); Console.WriteLine($"[q2] matched: {string.Join(", ", q2.Keys.Order())}"); Console.WriteLine("[v] lazy.pink.rabbit matched both q2 bindings → one copy; quick.brown.fox → dropped"); static async Task WaitFor(ConcurrentDictionary set, int n) { var deadline = DateTime.UtcNow.AddSeconds(30); while (set.Count < n && DateTime.UtcNow < deadline) await Task.Delay(50); if (set.Count < n) throw new Exception($"expected {n}, saw {set.Count}"); } ``` ```ruby # frozen_string_literal: true require "bunny" require "amq/uri" EXCHANGE = "topic_logs" opts = AMQ::URI.parse(ENV.fetch("KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/")) opts[:vhost] = "/" if opts[:vhost].nil? || opts[:vhost].to_s.empty? conn = Bunny.new(opts) conn.start ch = conn.create_channel exchange = ch.topic(EXCHANGE, durable: false) bind = lambda do |*patterns| sub_ch = conn.create_channel queue = sub_ch.queue("", exclusive: true) patterns.each { |p| queue.bind(exchange, routing_key: p) } seen = [] mutex = Mutex.new queue.subscribe(manual_ack: false, block: false) { |_di, _props, body| mutex.synchronize { seen << body } } { seen: seen, mutex: mutex } end q1 = bind.call("*.orange.*") # orange animals q2 = bind.call("*.*.rabbit", "lazy.#") # rabbits and everything lazy keys = %w[ quick.orange.rabbit lazy.orange.elephant quick.orange.fox lazy.brown.fox lazy.pink.rabbit quick.brown.fox ] keys.each { |k| exchange.publish(k, routing_key: k) } puts " [x] Published #{keys.size} keys" wait_for = lambda do |entry, n| deadline = Time.now + 30 loop do break if entry[:mutex].synchronize { entry[:seen].uniq.size } >= n || Time.now > deadline sleep 0.05 end end wait_for.call(q1, 3) wait_for.call(q2, 4) puts " [q1] matched: #{q1[:seen].uniq.sort}" puts " [q2] matched: #{q2[:seen].uniq.sort}" puts " [x] lazy.pink.rabbit matched both q2 bindings → one copy; quick.brown.fox → dropped" conn.close ``` ```rust use futures_lite::StreamExt; use lapin::{ options::{ BasicConsumeOptions, BasicPublishOptions, ExchangeDeclareOptions, QueueBindOptions, QueueDeclareOptions, }, types::FieldTable, BasicProperties, Connection, ConnectionProperties, ExchangeKind, }; use std::collections::BTreeSet; const EXCHANGE: &str = "topic_logs"; fn amqp_url() -> String { let url = std::env::var("KUBEMQ_AMQP_URL") .unwrap_or_else(|_| "amqp://guest:guest@localhost:5672/".into()); match url.rsplit_once('@').map_or(url.as_str(), |(_, host)| host) { host if host.ends_with('/') && !host.to_lowercase().ends_with("/%2f") => format!("{url}%2f"), _ => url, } } async fn bind(conn: &Connection, patterns: &[&str]) -> Result> { let ch = conn.create_channel().await?; let queue = ch .queue_declare("", QueueDeclareOptions { exclusive: true, ..Default::default() }, FieldTable::default()) .await?; for p in patterns { ch.queue_bind(queue.name().as_str(), EXCHANGE, p, QueueBindOptions::default(), FieldTable::default()) .await?; } Ok(ch .basic_consume(queue.name().as_str(), "", BasicConsumeOptions { no_ack: true, ..Default::default() }, FieldTable::default()) .await?) } async fn collect(label: &str, consumer: &mut lapin::Consumer, want: usize) -> Result<(), Box> { let mut seen = BTreeSet::new(); while seen.len() < want { let delivery = consumer.next().await.ok_or("consumer closed early")??; seen.insert(String::from_utf8_lossy(&delivery.data).into_owned()); } println!("[{label}] matched: {seen:?}"); Ok(()) } #[tokio::main] async fn main() -> Result<(), Box> { let conn = Connection::connect(&amqp_url(), ConnectionProperties::default()).await?; let ch = conn.create_channel().await?; ch.exchange_declare(EXCHANGE, ExchangeKind::Topic, ExchangeDeclareOptions::default(), FieldTable::default()) .await?; let mut q1 = bind(&conn, &["*.orange.*"]).await?; let mut q2 = bind(&conn, &["*.*.rabbit", "lazy.#"]).await?; let keys = [ "quick.orange.rabbit", // q1 + q2 "lazy.orange.elephant", // q1 + q2 "quick.orange.fox", // q1 only "lazy.brown.fox", // q2 only "lazy.pink.rabbit", // q2 only — matches BOTH q2 bindings, ONE copy "quick.brown.fox", // nobody → silent drop ]; for key in keys { ch.basic_publish(EXCHANGE, key, BasicPublishOptions::default(), key.as_bytes(), BasicProperties::default()) .await?; } println!("[x] Published {} keys", keys.len()); collect("q1", &mut q1, 3).await?; collect("q2", &mut q2, 4).await?; println!("[x] lazy.pink.rabbit matched both q2 bindings → one copy; quick.brown.fox → dropped"); conn.close(0, "done").await?; Ok(()) } ``` ## Single copy on multiple matching bindings [#single-copy-on-multiple-matching-bindings] If a queue is bound with two patterns that both match a key — like Q2's `*.*.rabbit` and `lazy.#` both matching `lazy.pink.rabbit` — the connector deduplicates the matched-queue set and delivers exactly **one** copy. You never receive a duplicate just because more than one of your own bindings matched. **A key matching no binding is dropped.** `quick.brown.fox` matches neither queue, so the connector resolves it to an empty set and writes nothing — no error, no delivery. As with [direct routing](/connectors/rabbitmq/how-to/routing), set `mandatory=true` to receive a `312 NO_ROUTE` for unroutable publishes. ## Related [#related] # Work Queues (/connectors/rabbitmq/how-to/work-queues) A **work queue** distributes time-consuming tasks across many workers. Multiple consumers compete on one queue (competing consumers), and each task goes to **exactly one** worker. With manual acknowledgment and prefetch, work is dispatched fairly and survives worker crashes. This is the most direct expression of the connector's model: an AMQP queue named `tasks` maps straight onto the KubeMQ Queue channel `amqp.default.tasks`. ## Overview [#overview] A producer publishes tasks to the **default exchange** with `routing-key` set to the queue name — the default exchange routes by queue name. Each worker calls `basic.consume` with **manual ack** and sets a **prefetch** (`basic.qos`) so the broker never dispatches more than N unacked messages to it at once. A worker acks only after the task is done; if it dies first, the unacked message is requeued and another worker picks it up. | Operation | AMQP action | KubeMQ mapping | | --------- | ------------------------------------------------------ | -------------------------------------------------- | | Declare | `queue.declare("tasks", durable=true)` | KubeMQ Queue channel `amqp.default.tasks` | | Publish | `basic.publish(exchange="", routing-key="tasks")` | `SendQueueMessage` (default exchange → queue name) | | Consume | `basic.consume("tasks")` + `basic.qos(prefetch=N)` | Competing-consumer pull with prefetch window | | Ack | `basic.ack(delivery-tag)` | Message removed from the queue | | Requeue | unacked on disconnect, or `basic.reject(requeue=true)` | Redelivered to the queue tail (`Redelivered=true`) | ## How it works [#how-it-works] Each task is moved to exactly one of the competing workers. The broker respects each worker's prefetch budget, so a slow worker is not flooded. An unacked task whose worker disconnects is redelivered to another worker. *Each task is dispatched to exactly one competing worker; `basic.ack` removes it from the queue, and an unacked task whose worker disconnects is redelivered to another worker.* ## Publish and consume [#publish-and-consume] Each example declares a durable `tasks` queue, publishes a batch of tasks on a **confirm channel** (so no publish is lost to an early close — see the callout below), then drains the queue with a manual-ack consumer that sets `prefetch=1`. Every client reads the broker endpoint from `KUBEMQ_AMQP_URL` (default `amqp://guest:guest@localhost:5672/`). ```go package main import ( "context" "fmt" "log" "os" "time" amqp "github.com/rabbitmq/amqp091-go" ) const queueName = "tasks" const total = 10 func amqpURL() string { if v := os.Getenv("KUBEMQ_AMQP_URL"); v != "" { return v } return "amqp://guest:guest@localhost:5672/" } func main() { conn, err := amqp.Dial(amqpURL()) if err != nil { log.Fatalf("dial: %v", err) } defer func() { _ = conn.Close() }() ch, err := conn.Channel() if err != nil { log.Fatalf("channel: %v", err) } defer func() { _ = ch.Close() }() // Durable queue: queue "tasks" → KubeMQ channel amqp.default.tasks. q, err := ch.QueueDeclare(queueName, true, false, false, false, nil) if err != nil { log.Fatalf("declare queue: %v", err) } // Produce — publish on a confirm channel so every task is durably enqueued // before we move on (a plain publish + immediate close can be lost; gotcha #9). if err := ch.Confirm(false); err != nil { log.Fatalf("confirm select: %v", err) } confirms := ch.NotifyPublish(make(chan amqp.Confirmation, total)) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() for i := 0; i < total; i++ { if err := ch.PublishWithContext(ctx, "", q.Name, false, false, amqp.Publishing{ ContentType: "text/plain", DeliveryMode: amqp.Persistent, Body: []byte(fmt.Sprintf("task-%02d", i)), }); err != nil { log.Fatalf("publish task %d: %v", i, err) } } for i := 0; i < total; i++ { if c := <-confirms; !c.Ack { log.Fatalf("task %d nacked by broker", c.DeliveryTag) } } log.Printf(" [x] Published %d tasks to %q", total, q.Name) // Consume — manual ack with prefetch=1 (fair dispatch); ack after the work. if err := ch.Qos(1, 0, false); err != nil { log.Fatalf("qos: %v", err) } deliveries, err := ch.Consume(q.Name, "worker", false, false, false, false, nil) if err != nil { log.Fatalf("consume: %v", err) } seen := 0 for seen < total { select { case d := <-deliveries: fmt.Printf(" [worker] %s (redelivered=%v)\n", d.Body, d.Redelivered) if err := d.Ack(false); err != nil { // ack → removed from the queue log.Fatalf("ack: %v", err) } seen++ case <-ctx.Done(): log.Fatalf("timed out after %d/%d tasks", seen, total) } } log.Printf(" [✓] Drained all %d tasks", seen) if _, err := ch.QueueDelete(q.Name, false, false, false); err != nil { log.Printf("warning: queue delete: %v", err) } } ``` ```python import os import pika URL = os.environ.get("KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/") QUEUE = "tasks" TOTAL = 10 def main() -> None: conn = pika.BlockingConnection(pika.URLParameters(URL)) ch = conn.channel() # Durable queue: queue "tasks" → KubeMQ channel amqp.default.tasks. ch.queue_declare(queue=QUEUE, durable=True) # Produce — confirm mode makes each publish block until the broker acks it, # so all tasks are durably enqueued before we consume (gotcha #9). ch.confirm_delivery() for i in range(TOTAL): ch.basic_publish( exchange="", routing_key=QUEUE, body=f"task-{i:02d}".encode(), properties=pika.BasicProperties(content_type="text/plain", delivery_mode=2), ) print(f" [x] Published {TOTAL} tasks to {QUEUE!r}") # Consume — manual ack with prefetch=1 (fair dispatch); ack after the work. ch.basic_qos(prefetch_count=1) seen = 0 for method, _props, body in ch.consume(QUEUE, inactivity_timeout=30, auto_ack=False): if method is None: raise SystemExit(f"timed out after {seen}/{TOTAL} tasks") print(f" [worker] {body.decode()} (redelivered={method.redelivered})") ch.basic_ack(method.delivery_tag) # ack → removed from the queue seen += 1 if seen >= TOTAL: break ch.cancel() print(f" [x] Drained all {seen} tasks") ch.queue_delete(queue=QUEUE) ch.close() conn.close() if __name__ == "__main__": main() ``` ```java import java.nio.charset.StandardCharsets; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import com.rabbitmq.client.Channel; import com.rabbitmq.client.Connection; import com.rabbitmq.client.ConnectionFactory; import com.rabbitmq.client.DeliverCallback; import com.rabbitmq.client.MessageProperties; public final class Main { private static final String QUEUE = "tasks"; private static final int TOTAL = 10; public static void main(String[] args) throws Exception { ConnectionFactory factory = new ConnectionFactory(); // The Java client parses a trailing "/" as an EMPTY vhost; normalize it // back to the default "/" vhost (→ KubeMQ vhost "default"). factory.setUri(System.getenv().getOrDefault("KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/")); if (factory.getVirtualHost() == null || factory.getVirtualHost().isEmpty()) { factory.setVirtualHost("/"); } try (Connection connection = factory.newConnection(); Channel channel = connection.createChannel()) { // Durable queue: queue "tasks" → KubeMQ channel amqp.default.tasks. channel.queueDeclare(QUEUE, true, false, false, null); // Produce — confirm mode so every task is durably enqueued before we // consume (a publish + immediate close can be lost; gotcha #9). channel.confirmSelect(); for (int i = 0; i < TOTAL; i++) { String task = String.format("task-%02d", i); channel.basicPublish("", QUEUE, MessageProperties.PERSISTENT_TEXT_PLAIN, task.getBytes(StandardCharsets.UTF_8)); } channel.waitForConfirmsOrDie(30_000); System.out.println("[x] Published " + TOTAL + " tasks to '" + QUEUE + "'"); // Consume — manual ack with prefetch=1 (fair dispatch). channel.basicQos(1); CountDownLatch done = new CountDownLatch(TOTAL); DeliverCallback onTask = (tag, delivery) -> { String body = new String(delivery.getBody(), StandardCharsets.UTF_8); System.out.println("[worker] " + body + " (redelivered=" + delivery.getEnvelope().isRedeliver() + ")"); channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false); // removed done.countDown(); }; channel.basicConsume(QUEUE, false, onTask, tag -> { }); if (!done.await(30, TimeUnit.SECONDS)) { throw new IllegalStateException("timed out draining the queue"); } System.out.println("[x] Drained all " + TOTAL + " tasks"); channel.queueDelete(QUEUE); } } } ``` ```typescript import amqp from "amqplib"; const QUEUE = "tasks"; const TOTAL = 10; function url(): string { return process.env["KUBEMQ_AMQP_URL"] ?? "amqp://guest:guest@localhost:5672/"; } async function main(): Promise { const connection = await amqp.connect(url()); // Produce — a confirm channel so every task is durably enqueued before we // consume (a plain publish + immediate close can be lost; gotcha #9). const prodCh = await connection.createConfirmChannel(); // Durable queue: queue "tasks" → KubeMQ channel amqp.default.tasks. await prodCh.assertQueue(QUEUE, { durable: true }); for (let i = 0; i < TOTAL; i++) { const body = `task-${String(i).padStart(2, "0")}`; prodCh.sendToQueue(QUEUE, Buffer.from(body), { persistent: true }); } await prodCh.waitForConfirms(); console.log(`[x] Published ${TOTAL} tasks to "${QUEUE}"`); await prodCh.close(); // Consume — manual ack with prefetch=1 (fair dispatch). const ch = await connection.createChannel(); await ch.assertQueue(QUEUE, { durable: true }); await ch.prefetch(1); let seen = 0; await new Promise((resolve) => { ch.consume( QUEUE, (msg) => { if (msg === null) return; console.log(`[worker] ${msg.content.toString()} (redelivered=${msg.fields.redelivered})`); ch.ack(msg); // ack → removed from the queue if (++seen === TOTAL) resolve(); }, { noAck: false }, ); }); console.log(`[x] Drained all ${seen} tasks`); await ch.deleteQueue(QUEUE); await ch.close(); await connection.close(); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using System.Text; using RabbitMQ.Client; using RabbitMQ.Client.Events; const string queueName = "tasks"; const int total = 10; static string Url() => Environment.GetEnvironmentVariable("KUBEMQ_AMQP_URL") is { Length: > 0 } v ? v : "amqp://guest:guest@localhost:5672/"; var factory = new ConnectionFactory { Uri = new Uri(Url()) }; await using var connection = await factory.CreateConnectionAsync("work-queues"); // Produce — a confirm channel so every task is durably enqueued before we // consume (a publish + immediate close can be lost; gotcha #9). var confirmOpts = new CreateChannelOptions( publisherConfirmationsEnabled: true, publisherConfirmationTrackingEnabled: true); await using var prodCh = await connection.CreateChannelAsync(confirmOpts); // Durable queue: queue "tasks" → KubeMQ channel amqp.default.tasks. await prodCh.QueueDeclareAsync(queueName, durable: true, exclusive: false, autoDelete: false); for (var i = 0; i < total; i++) { var props = new BasicProperties { ContentType = "text/plain", Persistent = true }; await prodCh.BasicPublishAsync("", queueName, mandatory: false, basicProperties: props, body: Encoding.UTF8.GetBytes($"task-{i:D2}")); } Console.WriteLine($"[x] Published {total} tasks to '{queueName}'"); // Consume — manual ack with prefetch=1 (fair dispatch). await using var ch = await connection.CreateChannelAsync(); await ch.BasicQosAsync(prefetchSize: 0, prefetchCount: 1, global: false); var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var seen = 0; var consumer = new AsyncEventingBasicConsumer(ch); consumer.ReceivedAsync += async (_, ea) => { var body = Encoding.UTF8.GetString(ea.Body.Span); Console.WriteLine($"[worker] {body} (redelivered={ea.Redelivered})"); await ch.BasicAckAsync(ea.DeliveryTag, multiple: false); // removed from the queue if (Interlocked.Increment(ref seen) == total) done.TrySetResult(); }; await ch.BasicConsumeAsync(queueName, autoAck: false, consumer: consumer); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); await done.Task.WaitAsync(cts.Token); Console.WriteLine($"[x] Drained all {total} tasks"); await ch.QueueDeleteAsync(queueName, ifUnused: false, ifEmpty: false); ``` ```ruby # frozen_string_literal: true require "bunny" require "amq/uri" QUEUE = "tasks" TOTAL = 10 opts = AMQ::URI.parse(ENV.fetch("KUBEMQ_AMQP_URL", "amqp://guest:guest@localhost:5672/")) # A bare trailing "/" parses to the EMPTY vhost; coerce it to the default "/". opts[:vhost] = "/" if opts[:vhost].nil? || opts[:vhost].to_s.empty? conn = Bunny.new(opts) conn.start ch = conn.create_channel # Durable queue: queue "tasks" → KubeMQ channel amqp.default.tasks. queue = ch.queue(QUEUE, durable: true) # Produce — confirm channel so the publishes can't be left buffered/dropped on # an immediate close (gotcha #9). ch.confirm_select TOTAL.times { |i| ch.default_exchange.publish(format("task-%02d", i), routing_key: QUEUE, persistent: true) } ch.wait_for_confirms puts " [x] Published #{TOTAL} tasks to '#{QUEUE}'" # Consume — manual ack with prefetch=1 (fair dispatch). ch.prefetch(1) seen = 0 done = Queue.new queue.subscribe(manual_ack: true, block: false) do |di, _props, body| puts " [worker] #{body} (redelivered=#{di.redelivered})" ch.ack(di.delivery_tag) # ack → removed from the queue seen += 1 done.push(:done) if seen >= TOTAL end done.pop puts " [x] Drained all #{seen} tasks" queue.delete conn.close ``` ```rust use futures_lite::StreamExt; use lapin::{ options::{ BasicAckOptions, BasicConsumeOptions, BasicPublishOptions, BasicQosOptions, ConfirmSelectOptions, QueueDeclareOptions, QueueDeleteOptions, }, publisher_confirm::Confirmation, types::FieldTable, BasicProperties, Connection, ConnectionProperties, }; const QUEUE: &str = "tasks"; const TOTAL: usize = 10; fn amqp_url() -> String { let url = std::env::var("KUBEMQ_AMQP_URL") .unwrap_or_else(|_| "amqp://guest:guest@localhost:5672/".into()); // lapin needs the default "/" vhost as "%2f"; the canonical URL ends in "/". match url.rsplit_once('@').map_or(url.as_str(), |(_, host)| host) { host if host.ends_with('/') && !host.to_lowercase().ends_with("/%2f") => format!("{url}%2f"), _ => url, } } #[tokio::main] async fn main() -> Result<(), Box> { let conn = Connection::connect(&amqp_url(), ConnectionProperties::default()).await?; let ch = conn.create_channel().await?; // Durable queue: queue "tasks" → KubeMQ channel amqp.default.tasks. ch.queue_declare(QUEUE, QueueDeclareOptions { durable: true, ..Default::default() }, FieldTable::default()) .await?; // Produce — confirm mode + wait-for-ack so every task is durably enqueued // before we consume (a publish + immediate close can be lost; gotcha #9). ch.confirm_select(ConfirmSelectOptions::default()).await?; for i in 0..TOTAL { let confirm = ch .basic_publish( "", QUEUE, BasicPublishOptions::default(), format!("task-{i:02}").as_bytes(), BasicProperties::default().with_delivery_mode(2), // persistent ) .await? .await?; assert!(!matches!(confirm, Confirmation::Nack(_)), "broker nacked task-{i:02}"); } println!("[x] Published {TOTAL} tasks to '{QUEUE}'"); // Consume — manual ack with prefetch=1 (fair dispatch). ch.basic_qos(1, BasicQosOptions::default()).await?; let mut deliveries = ch .basic_consume(QUEUE, "worker", BasicConsumeOptions::default(), FieldTable::default()) .await?; let mut seen = 0usize; while seen < TOTAL { if let Some(delivery) = deliveries.next().await { let delivery = delivery?; println!( "[worker] {} (redelivered={})", String::from_utf8_lossy(&delivery.data), delivery.redelivered ); delivery.ack(BasicAckOptions::default()).await?; // ack → removed seen += 1; } } println!("[x] Drained all {seen} tasks"); ch.queue_delete(QUEUE, QueueDeleteOptions::default()).await?; conn.close(0, "done").await?; Ok(()) } ``` ## At-least-once delivery [#at-least-once-delivery] Unacked deliveries are requeued when a worker disconnects, so **no task is lost** even if a worker dies ungracefully. The trade-off is that a task may be **redelivered** — a redelivered message arrives with `Redelivered == true`, and the same body can be processed more than once. Workers must therefore be **idempotent**. Exactly-once is not provided. **Requeue lands at the tail, not the head.** A requeued message re-enters at the **tail** of the queue, not the head — a deliberate deviation from RabbitMQ classic head-requeue. Ordering after a redelivery therefore differs from classic RabbitMQ; do not rely on strict publish order once a redelivery has occurred. **Prefer `basic.consume` over `basic.get`, and confirm before closing.** Polling with `basic.get` has a \~1-second latency floor on an empty queue — push-style `basic.consume` is the right tool for work queues. And a fire-and-forget `basic.publish` followed by an immediate channel/connection close can **silently drop** publishes still buffered in the connector (no client error). Every multi-message producer above uses a confirm channel and waits for all acks before closing. ## Related [#related] # Capabilities (/connectors/stomp/reference/capabilities) This reference defines exactly what the embedded KubeMQ STOMP connector **supports**, what it **hard-rejects**, and the **hard limits** it enforces. The connector is an embedded STOMP 1.0 / 1.1 / 1.2 server inside `kubemq-server` with its own raw-TCP / TLS listeners and a hand-rolled frame codec. It bridges STOMP onto KubeMQ's five native patterns by destination prefix — see [Destination Grammar](/connectors/stomp/reference/destination-grammar). ## Protocol versions [#protocol-versions] | Version | Supported | Notes | | ------- | ----------------- | ---------------------------------------------------------------------------------------------------- | | **1.2** | Yes (recommended) | full escaping including CR; `id`-based ACK token; `id` required on SUBSCRIBE | | **1.1** | Yes | escapes `:` / LF / `\` but **not** CR; `message-id` + `subscription` ACK; `id` required on SUBSCRIBE | | **1.0** | Yes | no header escaping; auto sub-id; ACK by `message-id`; ActiveMQ-style leniency | `accept-version` picks the **highest common** version. The list is comma-separated, order-independent, and whitespace-tolerant; unknown tokens are ignored. An **absent or empty** `accept-version` negotiates **1.0**. When there is no common version the connector emits an `ERROR` carrying `version:1.0,1.1,1.2` and closes the connection. **Recommended default: `accept-version:1.2`.** It is the only version that can represent CR in a header value — and the only one that never silently drops a CR/LF-bearing header to the subscriber. See [Protocol Versions](/connectors/stomp/how-to/protocol-versions) for the full per-version feature matrix. ## Supported client commands [#supported-client-commands] All 11 client → server STOMP commands are recognized: | Command | Supported | Behavior | | ---------------------------- | ----------------- | ------------------------------------------------------------------- | | `CONNECT` / `STOMP` | Yes | handshake; must be the first frame within 30 s | | `SEND` | Yes | routes by destination prefix to the matching pattern | | `SUBSCRIBE` | Yes | events / store / queues / `/reply/`; **not** `/command` or `/query` | | `UNSUBSCRIBE` | Yes | by `id` (1.1 / 1.2); by `destination` on 1.0 | | `ACK` | Yes | queue acknowledgement; no-op on events subscriptions | | `NACK` | Yes | queue negative-ack / requeue; honored on 1.0 too (lenient) | | `DISCONNECT` | Yes | graceful close; `RECEIPT` flushed before socket close | | `BEGIN` / `COMMIT` / `ABORT` | **Hard-rejected** | `ERROR "transactions not supported"` + close | Server → client frames are `CONNECTED`, `MESSAGE`, `RECEIPT`, and `ERROR`. A **body is allowed only on `SEND`** (client) and on **`MESSAGE` / `ERROR`** (server) — a body on any other command is a malformed frame. ## Acknowledgement modes (queues) [#acknowledgement-modes-queues] Three ack modes apply to a `SUBSCRIBE` on a `/queue/` destination: | `ack` mode | Client action | Semantics | | --------------------- | ----------------------------------------------- | ------------------------------------------------------------------------- | | `auto` (default) | none | fire-and-forget; reserve → enqueue → immediate ack | | `client-individual` | ACK / NACK one message | resolves exactly that one delivery — **the recommended reliable default** | | `client` (cumulative) | ACK a message + all earlier on the subscription | grouped per downstream transaction | * **Ack timeout = 30 s.** A 1 s sweeper NAcks / requeues the delivery; the client is **not** disconnected, and a late ACK after expiry is silently ignored. * **No client-side DLQ.** Redelivery surfaces only via the `redelivered:true` MESSAGE header; `maxReceiveCount` / DLQ is queue-channel config on the broker side, not a STOMP feature. See [ACK Modes & Receipts](/connectors/stomp/how-to/ack-modes-and-receipts) for per-version ACK / NACK token correlation and receipt rules. ## Events-Store replay (`/topic-store/` only) [#events-store-replay-topic-store-only] A `SUBSCRIBE` to a `/topic-store/` destination accepts `start-from` + `start-value` headers. Replay headers are **ignored** for plain `/topic/` Events. | `start-from` | `start-value` | Replays from | | -------------- | ----------------------------------------- | ------------------------------ | | absent / `new` | must NOT be present | new messages only (default) | | `first` | must NOT be present | the earliest stored message | | `last` | must NOT be present | the most recent stored message | | `sequence` | required, numeric ≥ 0 | the given sequence number | | `time` | required, RFC3339 **or** unix-seconds ≥ 0 | the given timestamp | | `time-delta` | required, numeric > 0 (seconds) | now minus N seconds | Bad combinations — a `start-value` present for `new` / `first` / `last`, or a missing / invalid value for `sequence` / `time` / `time-delta` — produce `ERROR "invalid subscription"` + close. See [Events-Store](/connectors/stomp/how-to/events-store). ## Hard-rejected features [#hard-rejected-features] These are documented exclusions. Each is refused deterministically with an `ERROR` frame followed by an immediate socket close — **no example ever uses them**. | Feature | Behavior | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | **STOMP transactions** | `BEGIN` / `COMMIT` / `ABORT`, **or any frame carrying a `transaction` header**, → `ERROR "transactions not supported"` + close | | **Selectors** | a `selector` header on SUBSCRIBE → `ERROR "selectors not supported"` + close (even without a transaction) | | **SUBSCRIBE to `/command` / `/query`** | → `ERROR "cannot subscribe to RPC destinations"` + close — STOMP is RPC-**requester-only** | | **SEND to `/reply/`** | → `ERROR "invalid destination"` + close — `/reply/` is a connection-local sink | **Transactions and selectors are documented exclusions, never examples.** STOMP is the RPC **requester**; the responder lives on the **gRPC side** (via the `kubemq-go/v2` SDK). A `SUBSCRIBE` to `/command` or `/query` is hard-rejected. See [Commands](/connectors/stomp/how-to/commands) and [Queries](/connectors/stomp/how-to/queries). ## V1 non-goals (not implemented) [#v1-non-goals-not-implemented] These are not refused at the frame layer (where applicable) but are simply not implemented in V1. They are listed so nothing is silently omitted: | Feature | Status | | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | **STOMP-over-WebSocket** | not in V1 — the connector is raw TCP / TLS only. This is why JS examples use `stompit` (raw TCP), not `@stomp/stompjs` (WebSocket-only). | | **Durable subscriptions** | not supported — use Events-Store `/topic-store/` + `start-from` replay for persistence | | **Temp queues / temp topics** | not supported | | **vhost semantics** | the `host` header is accepted and **ignored** (no vhost isolation) | | **Per-operation token re-validation** | auth is connect-time only; token expiry does **not** drop a live connection | | **Client-side DLQ / redelivery-limit knobs** | redelivery surfaces only as `redelivered:true`; DLQ is broker config | ## Hard limits [#hard-limits] ### Frame-codec constants (not tunable — no env var) [#frame-codec-constants-not-tunable--no-env-var] These five are package constants in the frame codec: | Limit | Value | Error on violation | | -------------------------- | ---------------------------------------------------- | ------------------------- | | Max headers / frame | **64** | `frame too large` | | Max header-block bytes | **8192** (8 KiB; command + headers share the budget) | `frame too large` | | Max destination length | **512** bytes | `invalid destination` | | Max custom tags (SEND) | **32** | `frame too large` + close | | Max tag-value bytes (SEND) | **4096** | `frame too large` + close | ### The one tunable codec limit [#the-one-tunable-codec-limit] | Limit | Default | Env var | | ----------------- | ------------------------- | -------------------------------- | | **Max body size** | **104857600** (\~100 MiB) | `CONNECTORS_STOMP_MAX_BODY_SIZE` | A body larger than `MaxBodySize` produces `frame too large`. This is the **only** config-tunable codec limit — the other five are constants. See [Configuration](/connectors/stomp/concepts/configuration). ### Connection / flow-control limits [#connection--flow-control-limits] | Limit | Value | Notes | | -------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `MaxConnections` | **1000** (`0` = unlimited) | counted **at accept** — a raw socket that never CONNECTs still consumes a slot; an over-limit `ERROR "connection limit reached"` is deferred to the handshake | | Per-channel queue inflight | **64** | not tunable | | Out-queue depth | **256** | not tunable | | Per-subscription events deliver buffer | **100** | tunable via `CONNECTORS_STOMP_SUB_BUFF_SIZE` (1..10000) | ### content-length and binary safety [#content-length-and-binary-safety] `content-length`, when present, is **authoritative and binary-safe** — the reader reads exactly N bytes then requires a NUL terminator (the **only** way to send a body with embedded NULs). The writer auto-stamps `content-length` on every bodied `MESSAGE` / `ERROR`. There is **no `content-type` default** at the frame layer, so a producer that sets no content-type tag yields a MESSAGE with no `content-type` header — the subscriber must assume binary. See [Destination Grammar](/connectors/stomp/reference/destination-grammar) for the header ⇄ tag mapping. ## Reliability [#reliability] | Pattern | Guarantee | | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | **Queues** | **at-least-once** — duplicates tolerated, never lost; NAck / requeue on full output, sweeper requeue on ack-timeout, disconnect NAcks pending | | **Events / Events-Store** | **at-most-once** — a full subscriber output buffer drops THAT delivery for THAT subscriber; the connection stays alive | **Never exactly-once.** No example or guide should promise it. ## The ten STOMP gotchas [#the-ten-stomp-gotchas] The behaviors most likely to surprise a STOMP migrant. Each is surfaced as a callout in the page it applies to: | # | Gotcha | | -- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1 | **RPC failures are a MESSAGE + `stomp-error` header, NOT an ERROR frame** — the connection stays OPEN. A logical error carries the responder's **body + tags** alongside `stomp-error`; only a transport error / timeout or nil response is empty-bodied — never assume an empty body. | | 2 | **Ack timeout = 30 s → requeue (not disconnect); NO client-side DLQ** — redelivery surfaces only as `redelivered:true`. See [Acknowledgement modes (queues)](#acknowledgement-modes-queues). | | 3 | **Queues at-least-once; events / store at-most-once** — never exactly-once. | | 4 | **No `content-type` frame default** — no content-type tag means no `content-type` header (the subscriber assumes binary). | | 5 | **CR/LF in a header value is silently dropped to 1.0 / 1.1 subscribers** — use `accept-version:1.2`; structured metadata belongs in the body. | | 6 | **Wildcards are events-only + subscribe-only**, using the message broker's native syntax (`*` = one segment, `>` = final tail); egress delivers the CONCRETE channel — **no MQTT `+` / `#`**. | | 7 | **A literal `.` in a destination segment is lossy** — `/topic/a.b` and `/topic/a/b` collide; egress emits the slash form. | | 8 | **`@stomp/stompjs` is WebSocket-only and cannot drive the raw-TCP V1 connector** — JS examples use `stompit` (raw TCP). | | 9 | **Cross-protocol interop is gRPC / array-proven only** — phrase it as "via the shared KubeMQ array, the same path gRPC uses". | | 10 | **SUBSCRIBE to `/command` / `/query` is rejected; transactions & selectors are hard-rejected (ERROR + close)** — STOMP is RPC-requester-only. | ## Related [#related] # Configuration (/connectors/stomp/reference/configuration) All values below are verified against the connector source. A disabled connector (`CONNECTORS_STOMP_ENABLE=false`) skips all validation. For the framing behind these fields — enable/disable, `DefaultPattern` semantics, ports, and TLS — see the [Configuration concepts](../concepts/configuration) page. ## Configuration fields [#configuration-fields] | Env var | Default | Type | Meaning / validation | | -------------------------------------------- | ----------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------- | | `CONNECTORS_STOMP_ENABLE` | `false` | bool | Opt-in; `true` enables the connector. `false` skips the connector and **all** its validation. | | `CONNECTORS_STOMP_PORT` | `61613` | string | `""` or `1..65535`; the plain-TCP listener. `""` disables it. | | `CONNECTORS_STOMP_TLS_PORT` | `61614` | string | `""` or `1..65535`, **must differ from `PORT`**; the TLS listener, active only when the server-wide Security block resolves to TLS. | | `CONNECTORS_STOMP_DEFAULT_PATTERN` | `events` | string | `events` \| `queues` \| `store` \| `none` — the pattern for **bare** (prefixless) destinations. There is no `commands`/`queries` default. | | `CONNECTORS_STOMP_SUB_BUFF_SIZE` | `100` | int | `1..10000`; the per-subscription Events delivery buffer. | | `CONNECTORS_STOMP_MAX_CONNECTIONS` | `1000` | int | `≥0`; `0` = unlimited; counted **at accept**. | | `CONNECTORS_STOMP_MAX_BODY_SIZE` | `104857600` | int | `>0`; \~100 MiB frame-body cap — the one tunable codec limit. | | `CONNECTORS_STOMP_HEARTBEAT_MS` | `10000` | int | `≥0`; the advertised `sx,sy`; `0` disables the server-side heartbeat. | | `CONNECTORS_STOMP_QUEUE_ACK_TIMEOUT_SECONDS` | `30` | int | `>0`; the pending-ack deadline before NAck / requeue. | | `CONNECTORS_STOMP_RPC_TIMEOUT_SECONDS` | `30` | int | `>0`; the default **and** cap for an RPC timeout. | | `CONNECTORS_STOMP_RPC_MAX_PENDING` | `1024` | int | `>0`; max in-flight RPCs per connector. | **`MaxBodySize` is the only tunable codec limit.** Other frame ceilings are fixed package constants with no env var — 64 headers per frame, an 8 KiB header-block budget (command + headers share it), a 512-byte destination limit, 32 custom tags per `SEND`, and a 4096-byte limit per tag value. A body over `MaxBodySize` is rejected with `frame too large`; the fixed limits reject with `frame too large` (or `invalid destination` for an over-length destination) and close the connection. See [Capabilities](/connectors/stomp/reference/capabilities). ## Validation rules [#validation-rules] These rules are enforced at startup. Validation is skipped entirely when `Enable` is `false` — a disabled connector is always valid. | Field | Rule | | -------------------------------------------------------------- | -------------------------------------------------------------------- | | `Port` / `TlsPort` | when `Enable=true`, **at least one** must be set (non-empty). | | `Port`, `TlsPort` | each `""` or in range `1..65535`; the two ports **must differ**. | | `DefaultPattern` | one of `events`, `queues`, `store`, `none`. | | `SubBuffSize` | in the range `1`–`10000`. | | `MaxConnections` | `≥ 0` (`0` = unlimited). | | `MaxBodySize` | `> 0`. | | `HeartbeatMs` | `≥ 0` (`0` disables the server-side heartbeat). | | `QueueAckTimeoutSeconds`, `RpcTimeoutSeconds`, `RpcMaxPending` | each must be `> 0`. | | `TlsPort` | silently skipped (info log) when the server Security mode is `none`. | An enabled connector with bad config fails **hard at load** — an empty/invalid port range, equal ports, both ports empty, an out-of-range int, or an unknown `DefaultPattern` all abort startup. ## Configuring the connector [#configuring-the-connector] The same settings can be supplied through a TOML config file, environment variables, or `docker run` flags. Each environment variable uses the `CONNECTORS_STOMP_` prefix (with the underscore between `CONNECTORS` and `STOMP`). ```toml title="config.toml" [Connectors.Stomp] Enable = true Port = "61613" TlsPort = "61614" DefaultPattern = "events" SubBuffSize = 100 MaxConnections = 1000 MaxBodySize = 104857600 HeartbeatMs = 10000 QueueAckTimeoutSeconds = 30 RpcTimeoutSeconds = 30 RpcMaxPending = 1024 ``` ```bash title="stomp.env" CONNECTORS_STOMP_ENABLE=true CONNECTORS_STOMP_PORT=61613 CONNECTORS_STOMP_TLS_PORT=61614 CONNECTORS_STOMP_DEFAULT_PATTERN=events CONNECTORS_STOMP_SUB_BUFF_SIZE=100 CONNECTORS_STOMP_MAX_CONNECTIONS=1000 CONNECTORS_STOMP_MAX_BODY_SIZE=104857600 CONNECTORS_STOMP_HEARTBEAT_MS=10000 CONNECTORS_STOMP_QUEUE_ACK_TIMEOUT_SECONDS=30 CONNECTORS_STOMP_RPC_TIMEOUT_SECONDS=30 CONNECTORS_STOMP_RPC_MAX_PENDING=1024 ``` The Docker example includes `CONNECTORS_STOMP_ENABLE=true` — without it the connector stays disabled and port 61613 is not bound. Set `CONNECTORS_STOMP_ENABLE=false` only when you want to turn the connector off, or `CONNECTORS_STOMP_PORT=""` to drop the plain-TCP listener and serve TLS only. ## Related [#related] # Connections & Observability (/connectors/stomp/reference/connections-endpoint) This reference documents how to see what the KubeMQ STOMP connector is doing: the **management HTTP API**, the **Prometheus series**, the **web dashboard**, and the **audit taxonomy**. These are the tools you use to verify STOMP is actually listening — the connector loader is **availability-first**: a bind failure logs `error loading stomp connector, continuing without STOMP` and the server keeps running without STOMP, so a clean boot does **not** prove the listener is up. ## Management API — `GET /api/stomp/*` [#management-api--get-apistomp] Two node-local, read-only HTTP endpoints, registered on the server's web API group. They are wired before the connector exists and nil-check the provider, so they return `200` with empty lists even when STOMP is disabled — UI-safe. | Method & path | Returns | | ------------------------------ | ------------------------------------------------------------ | | `GET /api/stomp/connections` | `{ "connections": [StompConnectionDTO…], "total": }` | | `GET /api/stomp/subscriptions` | `{ "subscriptions": [StompSubscriptionDTO…], "total": }` | ### `GET /api/stomp/connections` [#get-apistompconnections] Lists the live STOMP client connections on this node. ```bash curl -s http://localhost:8080/api/stomp/connections ``` ```json { "connections": [ { "client_id": "stomp-my-app", "remote_addr": "10.0.0.7:54321", "version": "1.2", "connected_at": "2026-06-15T09:41:12Z", "subscriptions": 2 } ], "total": 1 } ``` Each element is a `StompConnectionDTO`: | JSON field | Type | Notes | | --------------- | ------ | ------------------------------------------------------- | | `client_id` | string | derived session id (`stomp-` or `stomp-`) | | `remote_addr` | string | TCP peer address | | `version` | string | `1.0` / `1.1` / `1.2` (negotiated) | | `connected_at` | string | RFC3339 | | `subscriptions` | int | active subscription count | ### `GET /api/stomp/subscriptions` [#get-apistompsubscriptions] Lists the active subscriptions on this node. ```bash curl -s http://localhost:8080/api/stomp/subscriptions ``` ```json { "subscriptions": [ { "id": "sub-1", "client_id": "stomp-my-app", "destination": "/queue/jobs/email", "pattern": "queues", "channel": "jobs.email", "ack_mode": "client-individual" } ], "total": 1 } ``` Each element is a `StompSubscriptionDTO`: | JSON field | Type | Notes | | ------------- | ------ | --------------------------------------------- | | `id` | string | the SUBSCRIBE `id` (or auto-generated on 1.0) | | `client_id` | string | owning connection | | `destination` | string | the STOMP destination as subscribed | | `pattern` | string | `queues` / `events` / `store` / `reply` | | `channel` | string | resolved KubeMQ channel (slash → dot) | | `ack_mode` | string | `auto` / `client` / `client-individual` | ### Status codes [#status-codes] | Condition | Response | | ---------------------------------- | -------------------------------------------------------- | | ready | `200` + the list wrapper | | API service not ready | `503` (`api service not ready`) | | STOMP disabled / no provider wired | `200` + empty list (`{ "connections": [], "total": 0 }`) | The dashboard tables poll these endpoints every **5 s**. Both are **node-local** — in a cluster, query each node. ## Prometheus metrics — 3 series, 11-op closed set [#prometheus-metrics--3-series-11-op-closed-set] The connector exposes exactly **three** Prometheus series: | Metric | Type | Labels | Notes | | ----------------------------------------- | --------- | --------------------- | --------------------------------------------------------------------------------------------------- | | `kubemq_stomp_connections` | Gauge | — | active connections; floor-clamped at 0 | | `kubemq_stomp_operations_total` | Counter | `operation`, `status` | per-operation counts | | `kubemq_stomp_operation_duration_seconds` | Histogram | `operation` | only sampled when duration > 0 (per-message deliver ops pass duration 0 and are counted, not timed) | ### The 11-operation closed set [#the-11-operation-closed-set] `operation` is one of exactly eleven values: | Operation | Emitted on | | ----------------- | ------------------------------------------------------------ | | `connect` | a completed CONNECT handshake | | `send` | a SEND to events / store / queues / RPC | | `subscribe` | a SUBSCRIBE | | `unsubscribe` | an UNSUBSCRIBE | | `deliver` | one events / store MESSAGE delivered | | `deliver_queues` | one queue MESSAGE delivered | | `deliver_headers` | per egress header outcome (notably `dropped` — CR/LF gotcha) | | `ack` | an ACK | | `nack` | a NACK | | `rpc_request` | an RPC SEND dispatched | | `rpc_response` | an RPC reply delivered | ### The status label [#the-status-label] `status` is one of three values: `success` / `error` / `dropped`. `dropped` appears for **best-effort** paths that do not close the connection — a full output buffer dropping an event delivery (`deliver` / `deliver_queues`), an unrepresentable header (`deliver_headers` / `dropped`), or a dropped RPC reply (`rpc_response` / `dropped`). ### Verifying STOMP is up via metrics [#verifying-stomp-is-up-via-metrics] ```text # non-zero means the listener is up and at least one client connected kubemq_stomp_connections # header drops (CR/LF to 1.0/1.1 subscribers) kubemq_stomp_operations_total{operation="deliver_headers", status="dropped"} # RPC failures (timeouts / logical errors arriving as stomp-error MESSAGEs) kubemq_stomp_operations_total{operation="rpc_response", status="error"} ``` The `kubemq_stomp_connections` gauge being **present and ≥ 0** is one of the three ways to confirm the STOMP listener is actually up — the other two are the `/stomp` dashboard and `GET /api/stomp/connections`. Do **not** infer the listener from a successful server boot; the loader is availability-first. ## Web dashboard — `/stomp` [#web-dashboard--stomp] The server exposes a web route at **`/stomp`** showing live STOMP connections, subscriptions, and per-operation stats. Per-operation stats are pushed on the `connectors` Server-Sent-Events stream under the key `stomp`. The dashboard's connection and subscription tables are backed by the two `GET /api/stomp/*` endpoints above and poll every 5 s. ## Audit taxonomy — `Transport = stomp` [#audit-taxonomy--transport--stomp] Every STOMP audit event carries `Transport = "stomp"`. ### Control-plane events [#control-plane-events] | Event | Emitted on | | ---------------------- | ------------------------------------------------------------ | | `client.connected` | a successful CONNECT handshake | | `client.disconnected` | a connection closing (graceful or otherwise) | | `client.timeout` | the heartbeat watchdog force-closing a dead peer (2× cutoff) | | `auth.success` | a `passcode` JWT accepted | | `auth.failure` | a `passcode` JWT rejected | | `subscription.created` | a queue or events / store subscription activated | | `subscription.closed` | a subscription torn down | | `subscription.error` | a subscription failing during registration / activation | ### Data-plane errors [#data-plane-errors] | Event | Emitted on | | ---------------------- | --------------------------------------------------------------------------------------------------------- | | `publish.error` | an array publish call returning an error (surfaces to the client as `message rejected`) | | `queue.delivery.error` | a queue delivery failure, **including the ack-timeout sweeper requeue** (`ack timeout, message requeued`) | | `rpc.error` | an RPC transport error / timeout (delivered to the client as a `stomp-error` MESSAGE) | The ack-timeout sweeper's `queue.delivery.error` audit is the operator-side signal of the `redelivered:true` redelivery — there is no client-side DLQ. ## Related [#related] # Destination Grammar (/connectors/stomp/reference/destination-grammar) The single most important mental model for the KubeMQ STOMP connector: **a STOMP destination is parsed into a `(pattern, channel)` pair**, where the **first path segment selects the KubeMQ messaging pattern** and the remaining segments are `.`-joined into the KubeMQ channel name. Everything else on this page follows from that one rule. This is the formal reference. For a task-oriented walkthrough see [Destination Mapping](/connectors/stomp/how-to/destination-mapping). ## The primary destination prefixes (lead with these) [#the-primary-destination-prefixes-lead-with-these] A STOMP destination begins with one of six **primary** prefixes. These are the names you should write in application code: | STOMP destination | Pattern | KubeMQ channel | Delivery model | | -------------------- | ---------------------------- | -------------- | ---------------------------------- | | `/queue/orders/new` | **Queues** | `orders.new` | at-least-once, competing consumers | | `/topic/a/b/c` | **Events** | `a.b.c` | at-most-once, fan-out | | `/topic-store/audit` | **Events-Store** | `audit` | persistent, replayable | | `/command/exec` | **Commands** (RPC) | `exec` | request / reply | | `/query/lookup` | **Queries** (RPC) | `lookup` | request / reply | | `/reply/r1` | **reply** (connection-local) | `r1` | RPC reply sink | The prefix map is **case-sensitive**. The `/reply/` prefix is special — it is a connection-local RPC reply sink, never routed to the KubeMQ array (see [Commands](/connectors/stomp/how-to/commands)). ### ActiveMQ / MQTT-style aliases (accepted, but do not lead with them) [#activemq--mqtt-style-aliases-accepted-but-do-not-lead-with-them] Each pattern also accepts a single MQTT-style **alias** prefix. The aliases are accepted on ingress for compatibility, but **egress always canonicalizes to the primary name** — a subscriber that subscribed via `/events/x` receives MESSAGE frames stamped `destination:/topic/x`. Prefer the primary names everywhere. | Primary | Alias | | --------------- | ------------ | | `/queue/` | `/queues/` | | `/topic/` | `/events/` | | `/topic-store/` | `/store/` | | `/command/` | `/commands/` | | `/query/` | `/queries/` | There is **no** `/topic_store/`, `/eventstore/`, `/exchange/`, or `/amq/queue/` prefix. ## The parse algorithm (formal grammar) [#the-parse-algorithm-formal-grammar] ```text destination = [ "/" ] prefix-or-segment *( "/" segment ) prefix = "queue" / "queues" / "topic" / "events" / "topic-store" / "store" / "command" / "commands" / "query" / "queries" / "reply" segment = 1*( %x00-2E / %x30-FF ) ; any non-"/" bytes; non-empty ``` The connector resolves a destination as follows: 1. **Length gate first.** The raw destination string is rejected if it exceeds **512 bytes** — checked **on the raw string, before** the leading slash is stripped. 2. **Strip exactly one leading `/`.** Both `/queue/x` and `queue/x` resolve identically; only the first slash is removed. 3. **Split on `/`.** Any empty segment (a `//` or a trailing `/`) is rejected. 4. **First segment selects the pattern** via the case-sensitive prefix map. A known prefix consumes the first segment; an unknown first segment falls through to the configured `DefaultPattern` (see below). 5. **Remaining segments are `.`-joined** into the KubeMQ channel: `/topic/a/b/c` → channel `a.b.c`. A known prefix with **no** trailing channel segment is rejected: `/queue` → empty channel; `/queue/` → empty segment. ### Slash → dot is the channel join [#slash--dot-is-the-channel-join] This is the core transformation. Every `/` after the prefix becomes a `.` in the KubeMQ channel name. The same channel is reachable from gRPC, MQTT, AMQP, and REST under its dotted name. | STOMP destination | KubeMQ channel | | ----------------------- | ---------------- | | `/queue/jobs` | `jobs` | | `/queue/jobs/email` | `jobs.email` | | `/topic/orders/eu/west` | `orders.eu.west` | **A literal `.` inside a destination segment is lossy.** `/topic/a.b` and `/topic/a/b` **both** map to the same KubeMQ channel `a.b`, and egress always emits the **slash** form `/topic/a/b`. A destination that contains literal dots is therefore **not round-trip safe**. **Prefer slashes; avoid literal dots in destination segments.** ## Wildcard subscriptions [#wildcard-subscriptions] Wildcards use **the message broker's native wildcard syntax** and are passed through untranslated — **there is no MQTT-style `+` / `#`**. They are subject to three hard constraints: * **SUBSCRIBE-only** — a wildcard in a SEND destination (any pattern) is rejected as `invalid destination`. * **Events-only** — wildcards are allowed only on the **Events** pattern (`/topic/`, `/events/`). Queues, Events-Store, Commands, Queries, and reply destinations reject them. * **Two tokens, with the broker's native semantics** — **not** the MQTT `+` / `#`: | Token | Meaning | Position rule | | ----- | ----------------------------------------- | ----------------------------- | | `*` | matches exactly **one** segment | any position | | `>` | matches **one or more** trailing segments | **must be the final** segment | Slash → dot applies to the matched filter too: `/topic/a/*/c` → channel `a.*.c`; `/topic/orders/>` → channel `orders.>`. **Egress delivers the CONCRETE matched channel, not the filter.** A subscriber on `/topic/orders/*` that receives a message published to `orders.eu` gets a MESSAGE frame stamped `destination:/topic/orders/eu` — the concrete channel, never `/topic/orders/*`. Always read the `destination` header to learn what actually matched. ## Pattern routing (where each SEND goes) [#pattern-routing-where-each-send-goes] Once a destination resolves to a `(pattern, channel)` pair, the SEND routes to the matching KubeMQ array call: | Pattern | Array call | Notes | | ------------ | -------------------------------------- | ------------------------------ | | Queues | `array.SendQueueMessage` | at-least-once | | Events | `array.SendEvents` | at-most-once | | Events-Store | `array.SendEventsStore` (`Store=true`) | persisted | | Commands | `array.SendCommand` | RPC; reply on `/reply/` | | Queries | `array.SendQuery` | RPC; reply on `/reply/` | | reply | (none) | connection-local; never routed | A SEND to a `/reply/` destination is rejected: `invalid destination` / `cannot SEND to reply destinations`. ## `DefaultPattern` — bare (prefixless) destinations [#defaultpattern--bare-prefixless-destinations] A destination whose first segment is **not** a known prefix falls through to the connector-wide `DefaultPattern`: | `CONNECTORS_STOMP_DEFAULT_PATTERN` | Bare destination resolves to | | ---------------------------------- | ----------------------------------------------------------------------------------- | | `events` (**default**) | Events; `sensor/temp` → channel `sensor.temp` | | `queues` | Queues | | `store` | Events-Store | | `none` | **rejected** — bare destinations require an explicit prefix (`invalid destination`) | There is **no** `commands` or `queries` default — a bare destination can never resolve to an RPC pattern. **Examples and applications should always use explicit prefixes.** Relying on `DefaultPattern` couples your code to the connector's configuration; an operator who sets `DefaultPattern=none` would break every bare destination. ## Header ⇄ tag reference tables [#header--tag-reference-tables] STOMP headers map onto KubeMQ message **Tags**. There are three classes of header; the tables below are the authoritative reference (see [Destination Mapping](/connectors/stomp/how-to/destination-mapping) for the prose explanation). ### Standard headers ↔ reserved `stomp.*` tags (round-trip both directions) [#standard-headers--reserved-stomp-tags-round-trip-both-directions] Five standard STOMP headers round-trip through reserved `stomp.*` tags: | STOMP header (ingress & egress) | KubeMQ tag key | | ------------------------------- | ---------------------- | | `content-type` | `stomp.content-type` | | `correlation-id` | `stomp.correlation-id` | | `reply-to` | `stomp.reply-to` | | `priority` | `stomp.priority` | | `type` | `stomp.type` | * **Collision rule: `stomp.*` WINS** over a same-named bare custom tag on egress; exactly one header results. * **Custom headers** pass through name-as-is as tags (first-wins on duplicate). Limits: **≤32** custom tags, **≤4096 bytes** per value — a SEND that exceeds either is a **fatal** `frame too large` + close. **No `content-type` frame default.** The frame codec never defaults `content-type`. A native (gRPC / REST / MQTT / AMQP) producer that sets **no** `stomp.content-type` (or plain `content-type`) tag yields a MESSAGE with **no `content-type` header** — the STOMP subscriber must assume binary / octet-stream. `content-length` is **always** present, so the body is still framed correctly. ### Machinery headers — never become tags [#machinery-headers--never-become-tags] These frame-machinery headers are stripped on ingress and never forwarded as KubeMQ tags: ```text destination receipt transaction content-length message-id subscription ack id timeout ``` ### Protected-on-ingress headers (spoofing guard) [#protected-on-ingress-headers-spoofing-guard] Inbound header names starting with `stomp.` or equal to `x-kubemq-metadata` are **silently stripped** on ingress. A client cannot inject `stomp.*` tags directly — only through the canonical standard headers above. | Inbound header name | Action | | ------------------- | -------------------------- | | `stomp.*` (any) | stripped (logged at debug) | | `x-kubemq-metadata` | stripped (logged at debug) | ### `x-kubemq-metadata` is egress-only [#x-kubemq-metadata-is-egress-only] STOMP ingress **never** sets the KubeMQ `Metadata` field — STOMP has no canonical envelope (unlike AMQP). On **egress only**, a non-empty native `Metadata` value surfaces as the `x-kubemq-metadata` header, prepended at the highest emit priority. ### Egress representability guard [#egress-representability-guard] On delivery, a header whose name / value cannot be serialized on the **negotiated** protocol version is **dropped** (the connection stays alive; metric `deliver_headers` / `dropped`): | Version | Drops a header when… | | ------- | -------------------------------------------------------- | | 1.0 | name contains `:`, CR, or LF; or value contains CR or LF | | 1.1 | name **or** value contains CR (1.1 has no CR escape) | | 1.2 | never — always representable | **CR/LF in a header value is silently dropped to 1.0 / 1.1 subscribers.** Use `accept-version:1.2` and keep CR/LF out of header values; put structured / multi-line metadata in the **body**. ## Destination errors (sanitized wire vocabulary) [#destination-errors-sanitized-wire-vocabulary] All destination-class errors are sanitized before crossing the wire — internal error text never reaches the client. See [Error Frames](/connectors/stomp/reference/error-frames) for the full ERROR vocabulary. | Trigger | Wire `message` | | ---------------------------------------------------- | --------------------- | | raw destination > 512 bytes | `invalid destination` | | `//`, trailing `/`, or `/queue/` | `invalid destination` | | known prefix, no channel (`/queue`) | `invalid destination` | | bare destination with `DefaultPattern=none` | `invalid destination` | | wildcard on SEND, or on queues / store / RPC | `invalid destination` | | >64 headers / >8 KiB block / >32 tags / >4 KiB value | `frame too large` | ## Related [#related] # Error Frames (/connectors/stomp/reference/error-frames) This is the complete, verified vocabulary of `ERROR`-frame `message` strings the KubeMQ STOMP connector emits — the trigger for each, the receipt rules, and the one failure mode that is **not** an ERROR frame at all: the RPC `stomp-error` header. ## ERROR is terminal [#error-is-terminal] **Every protocol error produces an `ERROR` frame followed immediately by a socket close.** There is no recovery on the same connection — the client must reconnect. The `message` string is **sanitized**: internal error text, stack chains, and file paths never cross the wire. The connector has three ERROR-emit paths, all of which close the connection: | Function | Phase | | ---------------- | ---------------------------------------- | | `writeErrorSync` | handshake (before the write loop starts) | | `sendError` | post-CONNECT frame dispatch | | `sendErrorAsync` | connector shutdown broadcast | An ERROR frame carries `message:` and, when a detail is provided, a `content-type:text/plain` body with a short human-readable explanation. ## The ERROR-frame vocabulary (complete) [#the-error-frame-vocabulary-complete] Every `message` string the connector can emit, grouped by phase. The **detail** column shows the optional ERROR body where one is set. ### Handshake errors (before CONNECTED) [#handshake-errors-before-connected] | `message` | Detail body | Trigger | | -------------------------- | -------------------------------------- | -------------------------------------------------------------------------- | | `malformed frame` | `first frame must be CONNECT or STOMP` | first frame is not `CONNECT` / `STOMP` within 30 s | | `broker not ready` | — | the message broker not ready at CONNECT (gate before auth) | | `connection limit reached` | — | `MaxConnections` exceeded (counted at accept; ERROR deferred to handshake) | | `authentication failed` | — | `passcode` JWT rejected (no detail — no leakage) | | version-negotiation ERROR | — | no common `accept-version`; the ERROR carries `version:1.0,1.1,1.2` | ### Frame-codec errors (read loop) [#frame-codec-errors-read-loop] | `message` | Detail body | Trigger | | ----------------- | ----------- | ---------------------------------------------------------------------- | | `frame too large` | — | frame exceeds 64 headers / 8 KiB header block / `MaxBodySize` | | `malformed frame` | — | unparseable frame; body on a non-`SEND` command; bad escaping; lone CR | ### Dispatch errors (post-CONNECT) [#dispatch-errors-post-connect] | `message` | Detail body | Trigger | | ---------------------------- | --------------------------------------- | ------------------------------------------------------------------------------ | | `transactions not supported` | — | `BEGIN` / `COMMIT` / `ABORT`, or **any frame** carrying a `transaction` header | | `malformed frame` | `already connected` | a second `CONNECT` / `STOMP` after the handshake | | `unknown command` | — (offending command echoed, sanitized) | an unrecognized command | ### SEND errors [#send-errors] | `message` | Detail body | Trigger | | --------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------- | | `invalid destination` | `missing destination header` | SEND with no / empty `destination` | | `invalid destination` | — | bad destination grammar (empty segment, empty channel, no pattern, too long, wildcard on SEND) | | `invalid destination` | `cannot SEND to reply destinations` | SEND to a `/reply/` destination | | `access denied` | — | Casbin `Enforce(write)` denied | | `broker not ready` | — | the message broker not ready at SEND time (SENDs do **not** buffer) | | `frame too large` | `too many headers or header value too large` | >32 custom tags or a tag value >4096 bytes | | `message rejected` | — | the array publish call returned an error (sanitized; detail in server log) | ### SUBSCRIBE errors [#subscribe-errors] | `message` | Detail body | Trigger | | -------------------------------------- | ---------------------------- | --------------------------------------------------------------- | | `selectors not supported` | — | a `selector` header on SUBSCRIBE | | `invalid destination` | `missing destination header` | SUBSCRIBE with no / empty `destination` | | `invalid subscription` | `id header required` | no `id` on 1.1 / 1.2 | | `invalid subscription` | `unknown ack mode` | `ack` is not `auto` / `client` / `client-individual` | | `invalid destination` | — | bad destination grammar | | `cannot subscribe to RPC destinations` | — | SUBSCRIBE to `/command/` or `/query/` (STOMP is requester-only) | | `invalid subscription` | `duplicate subscription id` | the `id` is already in use on this connection | | `access denied` | — | Casbin `Enforce(read)` denied (queues / events / store) | | `invalid subscription` | `` | bad Events-Store `start-from` / `start-value` combination | ### UNSUBSCRIBE / ACK / NACK errors [#unsubscribe--ack--nack-errors] | `message` | Detail body | Trigger | | ---------------------- | ------------------------- | --------------------------------------------------- | | `invalid subscription` | `missing id` | UNSUBSCRIBE with no resolvable `id` | | `invalid subscription` | `unknown subscription id` | UNSUBSCRIBE for an id this connection never created | ACK / NACK **never** close on an unknown, expired, or foreign token — the late ack is silently ignored and the RECEIPT (if requested) is still sent. ### RPC SEND errors (the only RPC failures that close the connection) [#rpc-send-errors-the-only-rpc-failures-that-close-the-connection] | `message` | Detail body | Trigger | | -------------------------------- | ----------- | ------------------------------------------------------------------------------- | | `reply-to subscription required` | — | the `reply-to` does not name an active `/reply/` sub on the **same** connection | | `too many pending requests` | — | `RpcMaxPending` (default 1024) exceeded | Everything **else** about an RPC failure (timeout, logical error, dropped reply) is **not** an ERROR frame — see [RPC `stomp-error`](#rpc-stomp-error-a-failure-that-is-not-an-error-frame). ### Shutdown [#shutdown] | `message` | Detail body | Trigger | | ---------------------- | ----------- | -------------------------------------------------------------------------- | | `server shutting down` | — | connector `Close()` broadcasts to every live connection, then drains ≤10 s | ## Receipt semantics [#receipt-semantics] The `receipt` header requests a `RECEIPT` confirmation frame. The rules: * A `receipt` header is honored on **every processed client frame** (SEND, SUBSCRIBE, UNSUBSCRIBE, ACK, NACK, DISCONNECT). * `RECEIPT` is enqueued **after** the frame's processing completes: * **SEND** (events / store / queues): after the array call returns **successfully**. * **RPC SEND** (`/command` / `/query`): after **dispatch acceptance** — before the reply MESSAGE arrives. * **A RECEIPT means "KubeMQ accepted the frame", NOT "a consumer received the message."** Do not treat a SEND RECEIPT as a delivery confirmation. **Handlers that close the connection on an ERROR path NEVER send a RECEIPT.** The STOMP spec permits an `ERROR` in lieu of a `RECEIPT`. So a SEND to a bad destination that carried a `receipt` header returns an `ERROR` and **no** RECEIPT — never block forever waiting for one. A `DISCONNECT` with a `receipt` header flushes the `RECEIPT` to the wire **before** the socket closes (deterministic graceful shutdown). This is the clean way to confirm a graceful close. ## RPC `stomp-error`: a failure that is NOT an ERROR frame [#rpc-stomp-error-a-failure-that-is-not-an-error-frame] **RPC failures arrive as a MESSAGE with a `stomp-error` header, NOT an ERROR frame.** A timeout, a logical error, or a dropped reply is delivered as **data** on your `/reply/` subscription, and the connection **stays open**. Only `reply-to` violations and pending-cap overflow (above) close the connection. The reply MESSAGE has **three shapes**, only two of which are empty-bodied: | Shape | Condition | `stomp-error` value | Body | | --------------------------------- | ------------------------------------ | --------------------------------------------------- | --------------------------------------------------- | | **(a) Logical error** | `resp.Error != "" && !resp.Executed` | `` | **carries the responder's body + tags** — NOT empty | | **(b) Transport error / timeout** | `err != nil` | `timeout` / `request cancelled` / `` | empty | | **(c) Nil response** | `resp == nil` | `no response` | empty | **Detect RPC failure by the presence of the `stomp-error` header, NOT by an empty body.** A logical error (shape a) returns a fully-populated body and tags alongside `stomp-error`. Code that keys on "empty body" will misclassify logical errors. Go context errors map to stable strings: | Go error contains | `stomp-error` value | | --------------------------- | ------------------------ | | `context deadline exceeded` | `timeout` | | `context canceled` | `request cancelled` | | (anything else) | the sanitized error text | A reply MESSAGE is best-effort — if the connection's output queue is full it is dropped (metric `rpc_response` / `dropped`), again **without** closing the connection. See [Commands](/connectors/stomp/how-to/commands) and [Queries](/connectors/stomp/how-to/queries). ## Error-detail sanitization [#error-detail-sanitization] Every destination-class trigger is mapped to one of three sanitized strings before it reaches the wire: | Trigger class | Wire `message` | | ---------------------------------------------------------------------------- | --------------------- | | frame-too-large violations | `frame too large` | | empty segment / empty channel / no pattern / too long / wildcard not allowed | `invalid destination` | | anything else | `malformed frame` | Header values and command names that are echoed in an ERROR (for example, the offending command in `unknown command`) are sanitized first. ## Related [#related] # Migrating from STOMP (/connectors/stomp/scenarios/migration-from-stomp) Migrate a STOMP client application onto KubeMQ's native STOMP connector. The connector implements STOMP 1.0 / 1.1 / 1.2 over plain TCP and TLS — no broker middleware is interposed, so your STOMP client connects directly to KubeMQ. This is a **drop-in, endpoint-only** migration: change the host and port, keep the same STOMP client library and application code. The guide is **broker-agnostic** — it applies to any STOMP client driving any STOMP broker today: `stomp.py`, `@stomp/stompjs`, `go-stomp/stomp/v3`, the Ruby `stomp` gem, `Stomp.Net`, or a Spring `broker-relay` front-end. Only the library API differs; the wire protocol and the migration steps are identical. ## Overview [#overview] The KubeMQ STOMP connector is a faithful STOMP 1.0 / 1.1 / 1.2 raw-TCP endpoint. The code examples on this page target **`stomp.py` 8.x** (Python) as the canonical client; other clients connect identically. | Item | Value | | ---------------- | ----------------------------------------------------------------------------------------- | | Plain TCP port | 61613 (`Connectors.Stomp.Port`, default `"61613"`) | | TLS port | 61614 (`Connectors.Stomp.TlsPort`, default `"61614"`; active only when `Security` ≠ none) | | STOMP versions | 1.0, 1.1, 1.2 (negotiated; highest common wins) | | Default pattern | `events` (configurable via `Connectors.Stomp.DefaultPattern`) | | Canonical client | `stomp.py` 8.x (Python) | | Enable env var | `CONNECTORS_STOMP_ENABLE=true` | The connector is **opt-in** — `Connectors.Stomp.Enable` defaults to `false`, so a stock kubemq-server does **not** bind the STOMP listener until you turn it on: **The enable variable is `CONNECTORS_STOMP_ENABLE` — spell it verbatim, with the underscore between `CONNECTORS` and `STOMP`.** The form `CONNECTORSSTOMP_ENABLE` (without underscore) does **not** bind and is silently ignored. For Kubernetes, set `spec.stomp.enabled: true` in the `KubemqCluster` CR. For the full wire-protocol contract (heartbeats, receipts, header/tag mapping, ack token internals, error table, metrics catalog) see [Capabilities](/connectors/stomp/reference/capabilities), [Destination grammar](/connectors/stomp/reference/destination-grammar), and [Error frames](/connectors/stomp/reference/error-frames). For configuration fields and environment variables see the [configuration reference](/configure/reference/connectors#stomp). ## Compatibility Matrix [#compatibility-matrix] This is the STOMP column of the cross-protocol compatibility matrix in the [Migration hub](/connectors/how-to/migration). It is self-contained — read it as the at-a-glance answer to "what migrates?" | Dimension | STOMP on KubeMQ | | -------------------------------------- | -------------------------------------------------------------------- | | **Drop-in level** | endpoint-only | | **Point-to-point queues** | ✅ `/queue/*` → Queues pattern | | **Pub/sub (non-durable)** | ✅ `/topic/*` → Events pattern | | **Durable / persistent subscriptions** | ✅ via `/topic-store/*` + `start-from` replay headers | | **Request / reply (RPC)** | ✅ reply-to | | **Ordering guarantee** | ⚠️ node-local | | **Transactions** | ❌ rejected (BEGIN/COMMIT/ABORT → ERROR `transactions not supported`) | | **Dead-letter / redrive** | ❌ no client-settable DLQ; see footnote ¹ | | **Selectors / filtering / wildcards** | ❌ no selectors | | **Auth model** | JWT (CONNECT) | | **TLS / mTLS** | ✅ 61614 when `Security` configured | | **Top unsupported** | transactions; selectors | > ¹ **No client-settable DLQ over STOMP.** The STOMP connector never sets `MaxReceiveQueue` on published > messages, so a poison message that exceeds `MaxReceiveCount` is silently dropped by the broker — it is > not delivered to any consumable dead-letter address. If you need client-facing dead-letter behaviour, > use the RabbitMQ (DLX) or AWS (redrive) path instead. ## Connection / Endpoint Migration [#connection--endpoint-migration] The only change required is the host and port. The STOMP protocol remains identical. | Setting | Source broker | KubeMQ | | ---------- | ---------------------------------- | ------------------------------------------------------------------------------------- | | Host | `stomp-host` (your current broker) | `kubemq-host` | | Plain port | 61613 (STOMP default) | 61613 (same) | | TLS port | 61614 | 61614 | | Login | broker-specific username | any string (audit-only when auth disabled); or the `ClientID` value when auth enabled | | Passcode | broker password | KubeMQ JWT when `Authentication.Enable = true`; any value when auth disabled | ### stomp.py 8.x — before [#stomppy-8x--before] ```python import stomp conn = stomp.Connection([("stomp-host", 61613)]) conn.connect("user", "password", wait=True) ``` ### stomp.py 8.x — after (KubeMQ) [#stomppy-8x--after-kubemq] ```python import stomp conn = stomp.Connection([("kubemq-host", 61613)]) conn.connect("user", jwt_token, wait=True) # jwt_token ignored when auth disabled ``` For TLS, use `stomp.Connection([("kubemq-host", 61614)], use_ssl=True)`. ## Concept & Destination Mapping [#concept--destination-mapping] The STOMP `destination` header determines both the KubeMQ messaging **pattern** and the KubeMQ **channel** name. Normalization strips one leading `/`, splits on `/`, joins remaining segments with `.`. ### Prefix table [#prefix-table] | STOMP destination prefix | Aliases | KubeMQ pattern | Channel name | | ------------------------ | ---------------- | ---------------------- | ---------------------------- | | `/queue/NAME` | `/queues/NAME` | Queues | `NAME` (`.`-joined segments) | | `/topic/NAME` | `/events/NAME` | Events | `NAME` | | `/topic-store/NAME` | `/store/NAME` | Events Store | `NAME` | | `/command/NAME` | `/commands/NAME` | Commands (RPC) | `NAME` | | `/query/NAME` | `/queries/NAME` | Queries (RPC) | `NAME` | | `/reply/ID` | — | connection-local reply | not routed to any channel | A destination whose first segment does not match any prefix maps to `Connectors.Stomp.DefaultPattern` (default `events`). Setting `DefaultPattern = none` rejects those destinations with ERROR `invalid destination`. ### Destination examples [#destination-examples] | STOMP destination | KubeMQ pattern | KubeMQ channel | | --------------------------- | -------------- | ------------------ | | `/queue/orders` | Queues | `orders` | | `/queue/orders/new` | Queues | `orders.new` | | `/topic/orders.created` | Events | `orders.created` | | `/topic-store/audit-log` | Events Store | `audit-log` | | `/command/payments/process` | Commands | `payments.process` | | `/query/inventory/check` | Queries | `inventory.check` | ### Wildcards [#wildcards] The broker's native wildcards — `*` (single segment) and `>` (tail) — are supported **on SUBSCRIBE, Events pattern only** (`/topic/*` or `/topic/orders.>`). These are the STOMP destination grammar's own wildcards; there is **no** MQTT-style `+` / `#`. Wildcards on SEND, on any other pattern, or a misplaced `>` are rejected with ERROR `invalid destination`. ### Durable subscriptions → Events Store + replay headers [#durable-subscriptions--events-store--replay-headers] STOMP has no built-in durable-subscription mechanism. In KubeMQ the equivalent is subscribing to a `/topic-store/` destination with a `start-from` replay header: | `start-from` value | Meaning | | ------------------ | --------------------------------------------------------------------- | | `new` (default) | Deliver only messages published after this subscription | | `first` | Replay from the very first stored message | | `last` | Start from the most recently stored message | | `sequence` | Start at a specific sequence number (supply `start-value`) | | `time` | Start at an RFC 3339 or unix-seconds timestamp (supply `start-value`) | | `time-delta` | Start N seconds before now (supply `start-value` in seconds) | Example: subscribe from the beginning of an Events Store channel: ```python conn.subscribe( destination="/topic-store/audit-log", id="sub-1", ack="client-individual", headers={"start-from": "first"}, ) ``` ### Ack modes [#ack-modes] Set per subscription via the `ack` header on SUBSCRIBE. | `ack` value | Semantics | Applies to | | ------------------- | ------------------------------------------------------------------------------ | ---------- | | `auto` (default) | Broker auto-acks on enqueue success; NAcks on enqueue failure (requeues) | Queues | | `client-individual` | Each delivery tracked; ACK/NACK frame releases that one message | Queues | | `client` | Cumulative: ACK/NACK of delivery N acks/nacks all pending with order-index ≤ N | Queues | ACK frames on Events or Events Store subscriptions are accepted as a no-op (those patterns do not track per-message delivery state). ## Canonical Client Example (stomp.py 8.x) [#canonical-client-example-stomppy-8x] This example uses the following `stomp.py` 8.x API symbols: `stomp.Connection`, `conn.set_listener`, `conn.connect`, `conn.send`, `conn.subscribe`, `conn.ack`, `conn.disconnect`, `stomp.ConnectionListener.on_message`. ### Publish to a queue [#publish-to-a-queue] ```python import stomp KUBEMQ_HOST = "kubemq-host" KUBEMQ_PORT = 61613 JWT_TOKEN = "..." # omit or use any string when auth is disabled conn = stomp.Connection([(KUBEMQ_HOST, KUBEMQ_PORT)]) conn.connect("myapp", JWT_TOKEN, wait=True) conn.send( destination="/queue/orders", body="order payload", headers={"content-type": "text/plain"}, ) conn.disconnect() ``` ### Subscribe and consume from a queue (client-individual ack) [#subscribe-and-consume-from-a-queue-client-individual-ack] ```python import stomp KUBEMQ_HOST = "kubemq-host" KUBEMQ_PORT = 61613 JWT_TOKEN = "..." class QueueListener(stomp.ConnectionListener): def __init__(self, conn): self._conn = conn def on_message(self, frame): print("received:", frame.body) # Acknowledge the individual message self._conn.ack(frame.headers["ack"]) def on_error(self, frame): print("error:", frame.headers.get("message")) conn = stomp.Connection([(KUBEMQ_HOST, KUBEMQ_PORT)]) conn.set_listener("", QueueListener(conn)) conn.connect("myapp", JWT_TOKEN, wait=True) conn.subscribe( destination="/queue/orders", id="sub-orders", ack="client-individual", ) input("Press Enter to stop...\n") conn.disconnect() ``` ### Pub/sub via Events [#pubsub-via-events] ```python import stomp, threading KUBEMQ_HOST = "kubemq-host" KUBEMQ_PORT = 61613 received = threading.Event() class EventListener(stomp.ConnectionListener): def on_message(self, frame): print("event:", frame.body) received.set() conn = stomp.Connection([(KUBEMQ_HOST, KUBEMQ_PORT)]) conn.set_listener("", EventListener()) conn.connect(wait=True) conn.subscribe(destination="/topic/orders.created", id="sub-1", ack="auto") conn.send(destination="/topic/orders.created", body="event payload") received.wait(timeout=5) conn.disconnect() ``` ### RPC (Commands) with reply-to [#rpc-commands-with-reply-to] The reply subscription **must be created before** sending the RPC request. See [What Does NOT Migrate / Deviations](#what-does-not-migrate--deviations) for details. ```python import stomp, threading, uuid KUBEMQ_HOST = "kubemq-host" KUBEMQ_PORT = 61613 JWT_TOKEN = "..." reply_event = threading.Event() reply_body = None class RpcListener(stomp.ConnectionListener): def on_message(self, frame): global reply_body reply_body = frame.body reply_event.set() reply_dest = f"/reply/{uuid.uuid4()}" conn = stomp.Connection([(KUBEMQ_HOST, KUBEMQ_PORT)]) conn.set_listener("", RpcListener()) conn.connect("myapp", JWT_TOKEN, wait=True) # Step 1: subscribe to the reply destination BEFORE sending the request conn.subscribe(destination=reply_dest, id="reply-sub", ack="auto") # Step 2: send the RPC request with reply-to and correlation-id headers conn.send( destination="/command/payments/process", body="payment request", headers={ "reply-to": reply_dest, "correlation-id": str(uuid.uuid4()), "timeout": "5000", # ms; capped at RpcTimeoutSeconds * 1000 }, ) reply_event.wait(timeout=10) print("reply:", reply_body) conn.disconnect() ``` ## Security [#security] **Authentication (connect-time only).** When `Authentication.Enable = true`, the CONNECT frame `passcode` header must carry a valid KubeMQ JWT. The `login` header is recorded for audit purposes. Auth failure results in ERROR `authentication failed` + connection close. When authentication is disabled, any credentials are accepted — no auth is performed. Token expiry does not terminate an established connection (connect-time-only auth, consistent with the AMQP and MQTT connectors). **Authorization.** When `Authorization.Enable = true`, SEND enforces the `Write` permission and SUBSCRIBE enforces `Read` on `ControlRecord{Resource: , ClientID, Channel}`. Casbin policies must cover `stomp-*` client IDs for the mapped channels. Reply (`/reply/...`) destinations are authorization-exempt (connection-local). **No-auth exposure note.** When `Authentication.Enable = false` (the server default), the STOMP listener accepts any `login` / `passcode`. If the server is reachable from untrusted networks, enable authentication or restrict access with a firewall. **TLS.** Port 61614 is active only when `Connectors.Stomp.TlsPort` is set and `Security` is configured (mode ≠ none). Plain TCP on 61613 carries no transport encryption. **Enabling the connector.** ```toml title="config.toml" [Connectors.Stomp] Enable = true Port = "61613" TlsPort = "61614" ``` For the full auth and Casbin authorization setup, see [Authentication & Security](/connectors/reference/auth-and-security). ## What Does NOT Migrate / Deviations [#what-does-not-migrate--deviations] ### Hard failures (client will receive ERROR + connection close) [#hard-failures-client-will-receive-error--connection-close] | Feature | Behaviour on KubeMQ | | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | **STOMP transactions** — BEGIN/COMMIT/ABORT frames, or any `transaction` header | ERROR `transactions not supported` + close. Remove all `transaction` usage before migrating. | | **Message selectors** — `selector` header on SUBSCRIBE | ERROR `selectors not supported` + close. Remove selector headers. KubeMQ does not support broker-side SQL92 filtering over STOMP. | | **Subscribing to RPC destinations** — SUBSCRIBE to `/command/*` or `/query/*` | ERROR `cannot subscribe to RPC destinations`. Use the reply-to flow instead. | | **Invalid wildcards** — wildcards on SEND, on non-Events patterns, or misplaced `>` | ERROR `invalid destination` (see the [Wildcards](#wildcards) subsection). | ### Behavioural deviations [#behavioural-deviations] | Feature | Deviation | | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **RPC reply-to ordering** | The reply subscription (`/reply/ID`) **must exist on the same connection before** the SEND that carries `reply-to`. If the reply subscription is not already active, the SEND returns ERROR `reply-to subscription required` + close. This differs from brokers that buffer replies for a later subscription. | | **No client-settable DLQ** | See footnote ¹ in the [Compatibility Matrix](#compatibility-matrix). Poison messages exceeding `MaxReceiveCount` are silently dropped — there is no consumable dead-letter address over STOMP. | | **Durable subscriptions replaced by Events Store** | The `durable-subscription-name` / `activemq.subscriptionName` SUBSCRIBE header is not recognized. Use `/topic-store/NAME` with `start-from` headers for persistent, replayable subscriptions. | | **Ordering is node-local** | Message ordering within a channel is maintained on the receiving node; no cross-node total ordering is guaranteed in a clustered deployment. | | **STOMP-over-WebSocket** | Not supported in V1. Only plain TCP (61613) and TLS (61614) listeners exist. Spring STOMP-over-WebSocket front-ends require a V2 connector upgrade. | | **ActiveMQ `STOMP 1.0` selector behaviour** | KubeMQ rejects selectors loudly (ERROR + close) rather than ignoring them. Applications that set `selector` on any SUBSCRIBE must remove the header. | | **`/temp-queue/`, `/temp-topic/` destinations** | Not supported. Use a `/reply/{id}` connection-local destination for the request/reply use case. | | **Spring `/app/` destination conventions** | Not a broker concept — not supported. | ## Verification Smoke Test [#verification-smoke-test] The recipe below uses the `stomp.py` 8.x snippets from [Canonical Client Example](#canonical-client-example-stomppy-8x) and confirms that a message published to a queue arrives at a subscriber. ### Steps [#steps] 1. Start KubeMQ with the STOMP connector enabled: 2. In a terminal, run the consumer script (subscribes to `/queue/smoke-test`): ```python # consumer.py — stomp.py 8.x import stomp, time class Listener(stomp.ConnectionListener): def __init__(self, conn): self._conn = conn def on_message(self, frame): print("RECEIVED:", frame.body) self._conn.ack(frame.headers["ack"]) conn = stomp.Connection([("localhost", 61613)]) conn.set_listener("", Listener(conn)) conn.connect(wait=True) conn.subscribe(destination="/queue/smoke-test", id="s1", ack="client-individual") time.sleep(10) # wait for a message conn.disconnect() ``` 3. In a second terminal, publish one message: ```python # producer.py — stomp.py 8.x import stomp conn = stomp.Connection([("localhost", 61613)]) conn.connect(wait=True) conn.send(destination="/queue/smoke-test", body="hello from stomp") conn.disconnect() print("sent") ``` 4. Confirm the consumer terminal prints `RECEIVED: hello from stomp`. 5. To verify Events Store replay, repeat with `/topic-store/smoke-test` and `headers={"start-from": "first"}` on the subscriber. The message should be delivered even if the subscriber connects after the publisher disconnects. ## See Also [#see-also] # Architecture (/connectors/stomp/concepts/architecture) The KubeMQ **STOMP connector** is an embedded STOMP server hosted inside kubemq-server. It has its own dedicated TCP/TLS listeners (default plain TCP **61613**, TLS **61614**), a hand-rolled frame codec (no third-party STOMP server library), per-version negotiation (1.0/1.1/1.2), and a destination router that bridges the STOMP wire protocol onto KubeMQ's five patterns by **destination prefix**. It is built and started by default (`CONNECTORS_STOMP_ENABLE=true`). A STOMP frame travels from a native client library, through the embedded STOMP server, onto one of KubeMQ's five native messaging patterns, and out to the message broker — and back. A STOMP client never touches proto, never touches a KubeMQ SDK, and never knows what the internal broker is; it speaks plain STOMP over TCP and the connector does the bridging. ## The protocol stack [#the-protocol-stack] The connector is a thin, faithful STOMP endpoint in front of the KubeMQ **array**. Everything below the array — the Events/Queues/Commands/Queries machinery and the message broker — is the same engine every other KubeMQ transport (gRPC, REST, MQTT, AMQP) sits on top of. *The same array path the gRPC connector uses; the destination prefix selects the pattern and the remaining segments (slash→dot) become the KubeMQ channel.* The pieces, top to bottom: * **TCP / TLS listener** — binds all interfaces on `61613` / `61614` via an availability-first loader: a bind failure logs an error and the server continues **without** STOMP rather than crashing. * **Frame codec** — a hand-rolled codec for the STOMP frame shape (`COMMAND\n` + `name:value\n` lines + a blank line + the body + a `NUL` terminator). It applies per-version header escaping and is binary-safe via a content-length-aware reader; the writer always stamps `content-length` on egress. * **CONNECT / auth / heartbeat** — the handshake: negotiate the protocol version, check the connection limit, authenticate, derive the client id, and set up the heartbeat watchdog, then reply with `CONNECTED`. * **Handlers** — dispatch each client frame: `SEND`, `SUBSCRIBE`, `UNSUBSCRIBE`, `ACK`, `NACK`, and RPC dispatch. * **Destination router** — the heart of the bridge: the first destination segment selects the pattern and the remaining segments are `.`-joined into the channel. * **Bridges & registry** — the queue bridge, RPC bridge, and subscription registry sit between the handlers and the KubeMQ array. ## Version negotiation [#version-negotiation] The connector supports STOMP **1.0, 1.1, and 1.2** and picks the **highest common** version from the client's `CONNECT` `accept-version` header (comma-separated, order-independent, whitespace-tolerant). A missing or empty header resolves to `1.0`; no common version returns an `ERROR` frame and closes. The examples request `accept-version:1.2`, which is the recommended default — only 1.2 can always represent every header on egress (1.0 and 1.1 silently drop unrepresentable CR/LF in header values). The `CONNECTED` reply carries the negotiated `version`, the server's advertised `heart-beat` (`sx,sy`), a derived `session` id, and `server` \= `KubeMQ/`. The heartbeat is governed by `HeartbeatMs` (advertised `sx=sy`, default 10000 ms). The dead-peer cutoff is **2× the negotiated client→server interval** — the client must send a frame (or a bare newline) before that window elapses, or the watchdog force-closes the connection. Any inbound byte refreshes liveness. ## Destination → pattern → channel mapping [#destination--pattern--channel-mapping] This is the single most important mental model. The **first destination segment** selects a KubeMQ pattern (case-sensitive); the **remaining segments are `.`-joined** into the KubeMQ channel. ActiveMQ-style **primary** names are the canonical form; MQTT-style **aliases** map to the same patterns. On egress the connector **always canonicalizes back to the primary name**. *The destination prefix selects the KubeMQ pattern; the remaining segments (slash→dot) become the channel.* | STOMP destination (ingress) | Alias | Pattern | KubeMQ channel | Canonical egress (always primary) | | --------------------------- | ------------ | ----------------------------- | -------------- | --------------------------------- | | `/queue/orders/new` | `/queues/` | Queues | `orders.new` | `/queue/orders/new` | | `/topic/a/b/c` | `/events/` | Events | `a.b.c` | `/topic/a/b/c` | | `/topic-store/audit` | `/store/` | Events-Store | `audit` | `/topic-store/audit` | | `/command/exec` | `/commands/` | Commands (RPC) | `exec` | `/command/exec` | | `/query/lookup` | `/queries/` | Queries (RPC) | `lookup` | `/query/lookup` | | `/reply/r1` | *(none)* | reply (connection-local) | `r1` | `/reply/r1` | | `sensor/temp` (bare) | — | events (via `DefaultPattern`) | `sensor.temp` | `/topic/sensor/temp` | The parse algorithm: the 512-byte length limit is checked on the **raw** destination string first; strip **exactly one** leading `/`; split on `/` (any empty segment is rejected); the first segment selects the pattern via a **case-sensitive** map; the remaining segments are `.`-joined into the channel; an unknown first segment falls back to `DefaultPattern` (default `events`). There is no `/topic_store/` or `/eventstore/` — the Events-Store prefix is `/topic-store/` (primary) / `/store/` (alias). **A literal `.` in a destination segment is lossy.** `/topic/a.b` and `/topic/a/b` **both** map to channel `a.b`, and egress always emits the **slash** form `/topic/a/b`, so `/topic/a.b` is **not** round-trip safe. Prefer slashes; avoid literal dots in segments. Wildcard subscriptions are **Events-only**, **subscribe-only**, and use the message broker's native wildcard syntax (`*` = one segment, `>` = the final tail) — **there is no MQTT-style `+`/`#`**. A wildcard on `SEND`, or on queues / events-store / RPC, is rejected with `invalid destination`. See [Destination grammar](/connectors/stomp/reference/destination-grammar). ## The three-step RPC flow (Commands & Queries) [#the-three-step-rpc-flow-commands--queries] `/command/` and `/query/` are request/reply, and STOMP is **requester-only** — the responder lives on the KubeMQ (gRPC) side. A STOMP **`SUBSCRIBE` to `/command` or `/query` is rejected** (`cannot subscribe to RPC destinations` + close). The flow is: 1. **`SUBSCRIBE` to `/reply/` first** — connection-local: no array, no authz, no ack tracking. 2. **`SEND` to `/command/` or `/query/`** with a **required** `reply-to:` (an active `/reply/` subscription on the **same** connection), an optional `correlation-id:` (echoed only when set), and an optional `timeout:` in **milliseconds** (effective `min(timeout, RpcTimeoutSeconds * 1000)`, default 30000). 3. The reply arrives as a **`MESSAGE` on the `/reply/` subscription**. A timeout, logical error, or dropped reply arrives as a **`MESSAGE` carrying a `stomp-error` header** — **not** an `ERROR` frame — and the connection stays open. Only a `reply-to` violation or pending-cap overflow closes the connection. The body shape differs by failure kind: a *logical* error carries the responder's body and tags alongside `stomp-error`; only a transport error/timeout or a nil response is empty-bodied. See [Commands](/connectors/stomp/how-to/commands) and [Error frames](/connectors/stomp/reference/error-frames). ## Cross-protocol interop [#cross-protocol-interop] Because the connector bridges onto the shared KubeMQ array, a message published from STOMP is readable by any other KubeMQ transport on the **same channel**, and vice-versa — via the shared KubeMQ array, the same path gRPC uses. A STOMP `SEND` to `/topic/it/cross` (channel `it.cross`) is received by a native gRPC `SubscribeEvents` consumer on channel `it.cross`, and a native gRPC publish to `it.cross` is delivered to a STOMP subscriber on `/topic/it/cross`. *The same KubeMQ channel backs both transports, so a STOMP client and a gRPC/REST client interoperate transparently.* Cross-protocol interop is **array-proven** in both directions for STOMP ↔ gRPC. Always phrase it as "via the shared KubeMQ array, the same path gRPC uses" — there are no separately-proven STOMP ↔ MQTT or STOMP ↔ AMQP flows, though all transports share the same channels. ## Reliability at a glance [#reliability-at-a-glance] | Pattern | Delivery guarantee | What happens on a full output buffer | | --------------------- | ------------------ | ---------------------------------------------------------------------------- | | Queues | **at-least-once** | NAck → requeue (never lost; duplicates tolerated) | | Events / Events-Store | **at-most-once** | that delivery is **dropped** for that subscriber; the connection stays alive | Never promise exactly-once. See [Queues](/connectors/stomp/how-to/queues) and [Events](/connectors/stomp/how-to/events). ## Related [#related] # Configuration (/connectors/stomp/concepts/configuration) The STOMP connector is configured server-side through the `Connectors.Stomp` block of the KubeMQ server config, exposed as **eleven `CONNECTORS_STOMP_*` environment variables**. The connector is **opt-in (disabled by default)** — you must explicitly enable it. It ships with sensible production defaults, so once enabled no other env var is required. The only thing **clients** configure is the broker endpoint via the `KUBEMQ_STOMP_URL` environment variable (default `tcp://localhost:61613`); the URL scheme selects the transport (`tcp://`, `tls://`). Everything below is broker-side server configuration. ## Enable / disable [#enable--disable] Enable the connector with its enable variable: To turn it **off** again: **The enable variable is `CONNECTORS_STOMP_ENABLE` — spell it verbatim, with the underscore between `CONNECTORS` and `STOMP`.** Every STOMP setting uses this `CONNECTORS_STOMP_*` prefix. The Viper key separator is load-bearing: collapsing it to `CONNECTORSSTOMP_ENABLE` is **not** the same key and does **not** bind to the `Connectors.Stomp.Enable` field — it is silently ignored. When `Enable` is `false`, no STOMP listener binds and **all** the connector's validation is skipped. ## Ports & listeners [#ports--listeners] * Defaults: plain TCP **61613**, TLS **61614**; the connector **binds all interfaces** (`:`), not just localhost. * **At least one listener is required.** Both ports empty → load error. * **The two ports must differ.** Equal `PORT` and `TLS_PORT` → load error. There is no cross-connector port-collision detection — choosing a port already used by another connector is on you. * **The TLS port is active only** when `TLS_PORT != ""` **and** the server-wide Security block resolves to non-nil TLS. If the Security mode is `none`, the TLS port is silently skipped (info log). A runtime bind failure (a port already in use) is handled differently from a config error: the availability-first loader logs an error and the server keeps running **without** STOMP. Always [verify the listener is up](/connectors/stomp/tutorials/getting-started). ## `DefaultPattern` semantics [#defaultpattern-semantics] `CONNECTORS_STOMP_DEFAULT_PATTERN` decides which KubeMQ pattern a **bare / prefixless** destination resolves to — a destination whose first segment is **not** one of the known prefixes (`/queue/`, `/topic/`, `/topic-store/`, `/command/`, `/query/`, `/reply/`, or their aliases). | Value | Bare destination resolves to | Example: `sensor/temp` → | | ------------------------------ | ---------------------------- | ------------------------------------ | | `events` *(default)* | Events pattern | channel `sensor.temp` (Events) | | `queues` | Queues pattern | channel `sensor.temp` (Queues) | | `store` | Events-Store pattern | channel `sensor.temp` (Events-Store) | | `none` | **rejected** | `invalid destination` + close | There is **no** `commands` or `queries` default — a bare destination can never resolve to an RPC pattern. Set `none` for strict mode, where every destination must carry an explicit prefix. Best practice is to always use explicit prefixes so behavior never depends on this setting. ## TLS [#tls] **TLS has no STOMP-specific configuration.** The connector owns only **whether the TLS port is open** (`CONNECTORS_STOMP_TLS_PORT`); all certificate material, mTLS, and the minimum TLS version come from the **server-wide `Security` block**. mTLS uses `RequireAndVerifyClientCert`; the minimum is TLS 1.2. Connecting over TLS is purely a transport swap (`KUBEMQ_STOMP_URL=tls://host:61614`); the STOMP frames on top are identical. See [Connectivity and security](/connectors/stomp/how-to/connectivity-and-security) and [Auth & security](/connectors/reference/auth-and-security). For the full `CONNECTORS_STOMP_*` field table and the validation rules enforced at startup, see the [Configuration reference](../reference/configuration). ## Related [#related] # Getting Started (/connectors/stomp/tutorials/getting-started) Get a message flowing through the KubeMQ STOMP connector in minutes. You point a standard STOMP client at the broker, `SUBSCRIBE` to a `/topic/` destination, `SEND` a message to a matching destination, and watch it arrive — all over the native STOMP wire, with no KubeMQ SDK. This walkthrough takes you from a running server to a verified pub/sub round-trip. ## Prerequisites [#prerequisites] * A running **kubemq-server** with the STOMP connector **enabled** and reachable on **port 61613** (plain TCP). The connector is **opt-in (disabled by default)** — see the enable step below. * One of the STOMP clients below for your language (the examples pin a native client per language — there is no KubeMQ SDK). ## Enable the connector [#enable-the-connector] The STOMP connector is **disabled by default** — a stock kubemq-server does **not** bind the STOMP listener until you turn it on. Enable it with its enable variable: **The enable variable is `CONNECTORS_STOMP_ENABLE` — spell it verbatim, with the underscore between `CONNECTORS` and `STOMP`.** The form `CONNECTORSSTOMP_ENABLE` (without underscore) does **not** bind and is silently ignored. For Kubernetes, set `spec.stomp.enabled: true` in the `KubemqCluster` CR. Bring up a throwaway local broker with STOMP enabled: Every example reads a single environment variable for the broker endpoint. The scheme selects the transport — `tcp://` (61613) or `tls://` (61614): ```bash # default: tcp://localhost:61613 export KUBEMQ_STOMP_URL="tcp://localhost:61613" ``` **Verify the listener is actually up.** The connector loader is *availability-first* — if the port fails to bind, the server logs an error and keeps running **without** STOMP rather than crashing. Do not infer the listener from a successful server boot: confirm it via the `/stomp` dashboard, the `kubemq_stomp_connections` Prometheus gauge, or `GET /api/stomp/connections`. See [Connections endpoint](/connectors/stomp/reference/connections-endpoint). To **disable** the STOMP connector after enabling it, set its enable variable to `false`: When `Enable` is `false`, no STOMP listener binds and the rest of the STOMP config is skipped. See [Configuration](/connectors/stomp/concepts/configuration) for the full settings list. ## How it works [#how-it-works] Every connection opens with one `CONNECT` frame that negotiates the protocol version and heartbeat; the connector replies with `CONNECTED`. A subscriber registers a `/topic/` destination; a publisher `SEND`s to a destination with the same prefix. The connector resolves the prefix (`/topic/`) to the Events pattern, joins the remaining segments with `.` for the channel, and delivers every message to matching subscribers. *A `SEND` to `/topic/demo` maps to the Events channel `demo`; the connector fans the message out as a `MESSAGE` frame to every subscriber on that destination.* ## Steps [#steps] ### Connect to the broker [#connect-to-the-broker] Open a STOMP connection to the endpoint in `KUBEMQ_STOMP_URL`. The connector negotiates the highest common version of 1.0/1.1/1.2; the examples request `accept-version:1.2`. In the default no-auth mode any `login` / `passcode` works (including empty) — `login` is freeform and only derives the session id when there are no auth claims. The language tabs across all three steps run the **complete** round-trip from a single program: connect a subscriber and a publisher, subscribe to `/topic/demo`, send one message, and confirm the subscriber receives it. ```go package main import ( "fmt" "log" "os" "time" "github.com/go-stomp/stomp/v3" ) func stompAddr() string { url := os.Getenv("KUBEMQ_STOMP_URL") if url == "" { url = "tcp://localhost:61613" } return url[len("tcp://"):] } func dial(login string) (*stomp.Conn, error) { return stomp.Dial("tcp", stompAddr(), stomp.ConnOpt.AcceptVersion(stomp.V12), stomp.ConnOpt.Login(login, "")) } func main() { const destination = "/topic/demo" // Events pattern, channel "demo" // SUBSCRIBER — connect and subscribe before publishing (Events is at-most-once). sub, err := dial("go-sub") if err != nil { log.Fatalf("sub connect: %v", err) } subscription, err := sub.Subscribe(destination, stomp.AckAuto) if err != nil { log.Fatalf("subscribe: %v", err) } fmt.Printf("[sub] subscribed to %s\n", destination) time.Sleep(300 * time.Millisecond) // let the subscription register // PUBLISHER — connect and SEND one message. pub, err := dial("go-pub") if err != nil { log.Fatalf("pub connect: %v", err) } if err := pub.Send(destination, "application/json", []byte(`{"message":"hello"}`), stomp.SendOpt.Receipt); err != nil { log.Fatalf("send: %v", err) } fmt.Printf("[pub] sent to %s\n", destination) // RECEIVE — wait for the MESSAGE frame. select { case msg := <-subscription.C: fmt.Printf("[sub] received: %s (destination=%s)\n", string(msg.Body), msg.Destination) case <-time.After(10 * time.Second): log.Fatal("timed out waiting for the event") } _ = pub.Disconnect() _ = sub.Disconnect() } ``` ```python import os import queue import time import stomp def endpoint() -> tuple[str, int]: url = os.environ.get("KUBEMQ_STOMP_URL", "tcp://localhost:61613") host, port = url.split("://", 1)[1].split(":", 1) return host, int(port) class Listener(stomp.ConnectionListener): def __init__(self) -> None: self.messages: queue.Queue = queue.Queue() def on_message(self, frame) -> None: self.messages.put(frame) def connect(login: str, listener: stomp.ConnectionListener | None = None) -> stomp.Connection: conn = stomp.Connection([endpoint()], heartbeats=(10000, 10000)) if listener is not None: conn.set_listener("", listener) conn.connect(login=login, passcode="", wait=True) return conn def main() -> None: destination = "/topic/demo" # Events pattern, channel "demo" # SUBSCRIBER — connect and subscribe first. listener = Listener() sub = connect("py-sub", listener) sub.subscribe(destination=destination, id="sub-1", ack="auto") print(f"[sub] subscribed to {destination}") time.sleep(0.3) # PUBLISHER — connect and SEND. pub = connect("py-pub") pub.send(destination=destination, body='{"message":"hello"}', content_type="application/json") print(f"[pub] sent to {destination}") frame = listener.messages.get(timeout=10) body = frame.body if isinstance(frame.body, str) else frame.body.decode() print(f"[sub] received: {body} (destination={frame.headers['destination']})") pub.disconnect() sub.disconnect() if __name__ == "__main__": main() ``` ```java import java.net.URI; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; import java.lang.reflect.Type; import org.springframework.messaging.simp.stomp.StompHeaders; import org.springframework.messaging.simp.stomp.StompSession; import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; import org.springframework.messaging.simp.stomp.ReactorNettyTcpStompClient; public final class Main { public static void main(String[] args) throws Exception { String url = System.getenv().getOrDefault("KUBEMQ_STOMP_URL", "tcp://localhost:61613"); URI uri = URI.create(url); String destination = "/topic/demo"; // Events pattern, channel "demo" ReactorNettyTcpStompClient client = new ReactorNettyTcpStompClient(uri.getHost(), uri.getPort()); LinkedBlockingQueue inbox = new LinkedBlockingQueue<>(); // SUBSCRIBER StompSession sub = client.connect(new StompSessionHandlerAdapter() {}).get(); sub.subscribe(destination, new StompSessionHandlerAdapter() { @Override public Type getPayloadType(StompHeaders headers) { return byte[].class; } @Override public void handleFrame(StompHeaders headers, Object payload) { inbox.add(new String((byte[]) payload)); } }); System.out.printf("[sub] subscribed to %s%n", destination); Thread.sleep(300); // PUBLISHER StompSession pub = client.connect(new StompSessionHandlerAdapter() {}).get(); StompHeaders headers = new StompHeaders(); headers.setDestination(destination); headers.add("content-type", "application/json"); pub.send(headers, "{\"message\":\"hello\"}".getBytes()); System.out.printf("[pub] sent to %s%n", destination); String body = inbox.poll(10, TimeUnit.SECONDS); System.out.printf("[sub] received: %s%n", body); pub.disconnect(); sub.disconnect(); client.shutdown(); } } ``` ```typescript import { connect, type Client } from "stompit"; function endpoint(): { host: string; port: number } { const url = new URL(process.env["KUBEMQ_STOMP_URL"] ?? "tcp://localhost:61613"); return { host: url.hostname, port: Number(url.port) || 61613 }; } function dial(login: string): Promise { const { host, port } = endpoint(); return new Promise((resolve, reject) => { connect({ host, port, connectHeaders: { "accept-version": "1.2", "heart-beat": "10000,10000", login } }, (err, c) => (err ? reject(err) : resolve(c))); }); } async function main(): Promise { const destination = "/topic/demo"; // Events pattern, channel "demo" // SUBSCRIBER const sub = await dial("js-sub"); const received = new Promise((resolve, reject) => { sub.subscribe({ destination, ack: "auto" }, (err, message) => { if (err) return reject(err); message.readString("utf-8", (e, body) => (e ? reject(e) : resolve(body ?? ""))); }); }); console.log(`[sub] subscribed to ${destination}`); await new Promise((r) => setTimeout(r, 300)); // PUBLISHER const pub = await dial("js-pub"); const frame = pub.send({ destination, "content-type": "application/json" }); frame.write('{"message":"hello"}'); frame.end(); console.log(`[pub] sent to ${destination}`); console.log(`[sub] received: ${await received}`); pub.disconnect(); sub.disconnect(); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using System.Text; using Apache.NMS; using Stomp.Net; static string BrokerUri() { var url = Environment.GetEnvironmentVariable("KUBEMQ_STOMP_URL") ?? "tcp://localhost:61613"; var u = new Uri(url); var transport = u.Scheme == "tls" ? "ssl" : "tcp"; return $"{transport}://{u.Host}:{(u.Port > 0 ? u.Port : 61613)}"; } const string destination = "/topic/demo"; // Events pattern, channel "demo" var factory = new ConnectionFactory(BrokerUri(), new StompConnectionSettings()); var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); // SUBSCRIBER using var subConn = factory.CreateConnection(); subConn.Start(); using var subSession = subConn.CreateSession(AcknowledgementMode.AutoAcknowledge); using var consumer = subSession.CreateConsumer(subSession.GetTopic(destination)); consumer.Listener += msg => received.TrySetResult(Encoding.UTF8.GetString(((IBytesMessage)msg).Content)); Console.WriteLine($"[sub] subscribed to {destination}"); await Task.Delay(300); // PUBLISHER using var pubConn = factory.CreateConnection(); pubConn.Start(); using var pubSession = pubConn.CreateSession(AcknowledgementMode.AutoAcknowledge); using var producer = pubSession.CreateProducer(pubSession.GetTopic(destination)); var message = pubSession.CreateBytesMessage(Encoding.UTF8.GetBytes("{\"message\":\"hello\"}")); message.StompType = "application/json"; producer.Send(message); Console.WriteLine($"[pub] sent to {destination}"); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); Console.WriteLine($"[sub] received: {await received.Task.WaitAsync(cts.Token)}"); ``` ```ruby require "stomp" require "uri" require "timeout" uri = URI.parse(ENV.fetch("KUBEMQ_STOMP_URL", "tcp://localhost:61613")) destination = "/topic/demo" # Events pattern, channel "demo" def connect(uri, login) Stomp::Client.new( hosts: [{ host: uri.host, port: uri.port }], connect_headers: { "accept-version" => "1.2", "heart-beat" => "10000,10000", "login" => login, "passcode" => "" }, ) end inbox = Queue.new # SUBSCRIBER sub = connect(uri, "rb-sub") sub.subscribe(destination, id: "sub-1", ack: "auto") { |msg| inbox << msg.body } puts "[sub] subscribed to #{destination}" sleep 0.3 # PUBLISHER pub = connect(uri, "rb-pub") pub.publish(destination, '{"message":"hello"}', { "content-type" => "application/json" }) puts "[pub] sent to #{destination}" body = Timeout.timeout(10) { inbox.pop } puts "[sub] received: #{body}" pub.close sub.close ``` ```rust use async_stomp::client::Connector; use async_stomp::{FromServer, ToServer}; use futures::{SinkExt, StreamExt}; use std::time::Duration; use tokio::time::timeout; async fn dial(host_port: &str, login: &str) -> Result< impl SinkExt> + StreamExt, Box> { Ok(Connector::builder() .server(host_port) .login(login.to_string()) .passcode(String::new()) .connect() .await?) } #[tokio::main] async fn main() -> Result<(), Box> { let url = std::env::var("KUBEMQ_STOMP_URL").unwrap_or_else(|_| "tcp://localhost:61613".into()); let host_port = url.split("://").nth(1).unwrap_or("localhost:61613").to_string(); let destination = "/topic/demo"; // Events pattern, channel "demo" // SUBSCRIBER let mut sub = Connector::builder().server(&host_port) .login("rust-sub".into()).passcode(String::new()).connect().await?; sub.send(ToServer::Subscribe { destination: destination.to_string(), id: "sub-1".to_string(), ack: None, }).await?; println!("[sub] subscribed to {destination}"); tokio::time::sleep(Duration::from_millis(300)).await; // PUBLISHER let mut pubc = Connector::builder().server(&host_port) .login("rust-pub".into()).passcode(String::new()).connect().await?; pubc.send(ToServer::Send { destination: destination.to_string(), transaction: None, headers: Some(vec![("content-type".into(), "application/json".into())]), body: Some(br#"{"message":"hello"}"#.to_vec()), }).await?; println!("[pub] sent to {destination}"); // RECEIVE if let Ok(Some(Ok(msg))) = timeout(Duration::from_secs(10), sub.next()).await { if let FromServer::Message { body, .. } = msg.content { println!("[sub] received: {}", String::from_utf8_lossy(&body.unwrap_or_default())); } } pubc.send(ToServer::Disconnect { receipt: None }).await?; Ok(()) } ``` ### Send a message [#send-a-message] The publisher in the program above `SEND`s one message to `/topic/demo`. The prefix `/topic/` selects the Events pattern, and the remaining segments become the KubeMQ channel with `/` translated to `.` — so `/topic/demo` lands on channel `demo`. Attaching a `receipt:` header makes the connector return a `RECEIPT` once it has **accepted** the frame (this confirms acceptance, not consumer delivery). ### Subscribe and verify [#subscribe-and-verify] The subscriber registers the destination `/topic/demo`. When the message arrives it is delivered as a `MESSAGE` frame whose `destination` header is canonicalized to the primary form, and the program prints it and exits: ```text [sub] subscribed to /topic/demo [pub] sent to /topic/demo [sub] received: {"message":"hello"} (destination=/topic/demo) ``` Events is fire-and-forget pub/sub: subscribe **before** you publish, or the message is gone. For persistence and replay-on-reconnect, use the Events-Store pattern (`/topic-store/`) instead. Wildcard subscriptions are accepted on the **Events** pattern only, are **subscribe-only**, and use the message broker's native wildcard syntax (`*` = one segment, `>` = the final tail) — there is no MQTT-style `+`/`#`. Avoid a literal `.` in a destination segment: `/topic/a.b` and `/topic/a/b` both map to channel `a.b`, and egress always emits the slash form. See [Destination mapping](/connectors/stomp/how-to/destination-mapping). ## Next steps [#next-steps] # Ack modes and receipts (/connectors/stomp/how-to/ack-modes-and-receipts) STOMP has **no QoS levels**. Delivery reliability is controlled instead by the SUBSCRIBE **`ack` mode**, and frame acceptance is confirmed by the optional **`receipt`** header. This guide covers both — the three ack modes and which KubeMQ pattern each applies to, how the connector correlates your ACK across STOMP 1.0/1.1/1.2, the ack-timeout requeue, and what a `RECEIPT` does (and does not) mean. Ack modes apply to **Queues** consumption. Events and Events-Store deliver as **auto** (no client ack) and are **at-most-once**. For Queues, **`client-individual` is the recommended reliable default** — it gives at-least-once with the simplest mental model. ## The three ack modes [#the-three-ack-modes] A SUBSCRIBE carries an `ack` header; if it is absent the mode defaults to `auto`. The connector accepts exactly three values — anything else returns `ERROR "unknown ack mode"` and closes the connection. | `ack` mode | Pending tracked | Client action | KubeMQ downstream | | ---------------------------- | --------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `auto` *(default)* | No | none | reserve → enqueue → immediately ack the delivery; requeue (NAck) if the output buffer is full; **never dropped** | | `client-individual` | Yes | ACK/NACK **one** message | resolve that one delivery → one ack/NAck for its transaction | | `client` (cumulative) | Yes | ACK/NACK that message **and all earlier** on the subscription | collect pending with `orderIdx ≤ acked`, **group by transaction**, one ack/NAck **per transaction** | ### `auto` — fire-and-forget [#auto--fire-and-forget] The connector reserves the inflight slot, enqueues the MESSAGE, and **immediately** acks the delivery downstream. If the per-subscriber output buffer is full it **requeues (NAcks)** rather than dropping — Queues are at-least-once. No `ack` token is emitted and no pending state is tracked. ### `client-individual` — per-message ACK [#client-individual--per-message-ack] Each MESSAGE must be acknowledged by **its own** `ack` token. An ACK resolves exactly that one delivery; a NACK requeues it. This is the recommended reliable default for Queues. ```text SEND /queue/jobs/email (×3) SUBSCRIBE /queue/jobs/email ack:client-individual MESSAGE message-id=m1 ack=tok1 → ACK id:tok1 (1.2) MESSAGE message-id=m2 ack=tok2 → ACK id:tok2 MESSAGE message-id=m3 ack=tok3 → ACK id:tok3 queue drains; nothing redelivered ``` ### `client` — cumulative ACK [#client--cumulative-ack] A cumulative ACK acknowledges the named message **and all earlier deliveries on the same subscription**. The connector collects every pending entry with `orderIdx ≤` the acked delivery, groups them by downstream transaction id, and emits **one** ack/NAck **per transaction**. ```text SEND /queue/work (×10) SUBSCRIBE /queue/work ack:client ... receive m1..m5 ... ACK id: → cumulatively acks m1..m5 (grouped by transaction) ... receive m6..m10 ... ACK id: → cumulatively acks m6..m10 queue drains; nothing redelivered after drain ``` **Why one range per transaction matters.** A single subscription's pending set routinely spans multiple downstream transactions. The connector emits one ack request per transaction id so that no foreign-transaction sequence is silently dropped — this is correctness, not an optimization. It is transparent to your client code. ## Per-version ACK/NACK correlation [#per-version-acknack-correlation] How the connector matches your ACK or NACK frame to a tracked delivery depends on the **negotiated version**: | Version | ACK/NACK carries | Resolution | | ------- | ----------------------------- | ------------------------------------------------------------- | | **1.2** | `id:` | direct token lookup | | **1.1** | `message-id` + `subscription` | lookup by `(message-id, subscription)` | | **1.0** | `message-id` only | resolves to the **oldest** pending delivery on the connection | The `ack` token is a **1.2-only** opaque UUID, emitted on the MESSAGE frame and **distinct from `message-id`**. On 1.1 you ACK by `message-id` + `subscription`; on 1.0 by `message-id` alone, which the connector maps to the oldest pending delivery. ACK resolution is **connection-scoped** — a token that belongs to another connection (even a same-login one) is silently ignored, as is a **late** ACK after the ack-timeout expired, and an ACK on an **events** subscription (accepted as a no-op; the RECEIPT, if requested, still fires). **Lenient 1.0.** The connector accepts `client-individual` and NACK on 1.0 too (ActiveMQ-style leniency), even though those are not in the 1.0 spec. Don't rely on this for portability — prefer `accept-version:1.2`. See [Protocol versions](/connectors/stomp/how-to/protocol-versions). ## Ack timeout, requeue, and no client-side DLQ [#ack-timeout-requeue-and-no-client-side-dlq] If a `client-individual` or `client` delivery is **not** ACKed within the ack-timeout (default **30 s**), a 1-second sweeper **requeues (NAcks)** the delivery — it does **not** disconnect the client. A late ACK after expiry is silently ignored. The same requeue happens when the client **disconnects** mid-stream with un-ACKed deliveries. **Ack-timeout = 30 s → requeue (not disconnect); there is no client-side DLQ.** The connector surfaces redelivery **only** via the `redelivered:true` MESSAGE header. There is **no STOMP-level dead-letter queue or redelivery-limit knob** — a `maxReceiveCount` / DLQ is **broker queue-channel** configuration, not a STOMP feature. A second consumer that receives a redelivered message sees `redelivered:true`; it never arrives on a client-side DLQ. ```text SUBSCRIBE /queue/rq ack:client-individual MESSAGE job-1 (NOT ACKed) ... 30s elapse → sweeper requeues ... # a second consumer: SUBSCRIBE /queue/rq ack:client-individual MESSAGE job-1 redelivered:true ``` ## Reliability semantics [#reliability-semantics] **Queues are at-least-once; Events and Events-Store are at-most-once. Never exactly-once.** * **Queues = at-least-once.** Duplicates are tolerated, never lost: requeue on a full output buffer, sweeper requeue on ack-timeout, and a disconnect NAcks pending deliveries. Design Queue consumers to be **idempotent**. * **Events / Events-Store = at-most-once.** A full per-subscriber output buffer **drops that one delivery for that one subscriber** — the connection stays alive and no requeue happens (events have no ack channel). ## Receipts [#receipts] The `receipt` header is honored on **every processed client frame** (SEND, SUBSCRIBE, UNSUBSCRIBE, ACK, NACK, DISCONNECT). The connector enqueues a `RECEIPT receipt-id=` frame **after** the frame is processed. **A RECEIPT means "KubeMQ accepted the frame", not "a consumer received the message".** For a SEND, the RECEIPT fires after the publish returns successfully — it confirms the message was accepted by KubeMQ, **not** that any subscriber has consumed it. ```text SEND /topic/demo receipt:r-1 RECEIPT receipt-id=r-1 # KubeMQ accepted the SEND (NOT consumer delivery) ``` ### ERROR-path frames never send a RECEIPT [#error-path-frames-never-send-a-receipt] A handler that closes the connection on an **ERROR path never sends a RECEIPT** — the STOMP spec permits an `ERROR` in lieu of a RECEIPT. So a SEND to a **bad destination** carrying a `receipt:` header returns an `ERROR` frame and **no** RECEIPT. Client code that waits for a RECEIPT must also handle the ERROR-then-close case. ```text SEND /badprefix// receipt:r-2 ERROR message:invalid destination # no RECEIPT; connection closes ``` ### DISCONNECT receipt — the clean-shutdown confirmation [#disconnect-receipt--the-clean-shutdown-confirmation] A `DISCONNECT` carrying `receipt:` flushes its `RECEIPT` to the wire **before** the socket closes, deterministically. This is the clean way to confirm a graceful shutdown — the examples in this connector use it. ```text DISCONNECT receipt:bye RECEIPT receipt-id=bye # flushed before close ``` ## Broker-not-ready: SEND closes, SUBSCRIBE gates [#broker-not-ready-send-closes-subscribe-gates] When the message broker is **not ready**, the connector treats SEND and SUBSCRIBE asymmetrically — worth knowing because it surprises STOMP migrants who expect buffering: | Frame | Broker not ready | Receipt | | ----------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------ | | `SEND` | `ERROR "broker not ready"` + **close** — SENDs do **not** buffer | no RECEIPT (ERROR path) | | `SUBSCRIBE` | **gated** — accepted with an empty subscription id, then **activated** when the broker becomes ready | RECEIPT **still sent** on acceptance | So a publisher hitting a not-ready broker is disconnected (retry the connection), while a subscriber's SUBSCRIBE is held and activated transparently on recovery. ## Related [#related] # Authentication (/connectors/stomp/how-to/authentication) The KubeMQ STOMP connector authenticates a client **once, at CONNECT time**, using the STOMP `passcode` header as a KubeMQ JWT. It then authorizes every SEND and SUBSCRIBE through the server-wide Casbin authorizer. This guide covers the `login` / `passcode` part of the CONNECT frame and what the connector does with it — for the full handshake (ports, heartbeats, TLS) see [Connectivity and security](/connectors/stomp/how-to/connectivity-and-security). On a stock dev server, **both authentication and authorization are off** (the server default) — a `nil` authenticator means allow-all. So the examples connect **unauthenticated**: leave `passcode` empty (or set any value) and use a freeform `login`. ## The no-auth default [#the-no-auth-default] When the server-wide `Authentication` block is disabled, the connector is wired with a `nil` authenticator and **skips the auth check entirely** — any (or empty) `passcode` succeeds, and the derived session id comes from `login`. This is the server default, so every example connects without a token. ```text CONNECT accept-version:1.2 heart-beat:10000,10000 login:my-app # freeform; only used to derive the session id when there are no claims passcode: # empty in the default no-auth mode (any value also works) ^@ ``` ## `passcode` is a KubeMQ JWT — validated at CONNECT only [#passcode-is-a-kubemq-jwt--validated-at-connect-only] When authentication **is** enabled, the connector validates the `passcode` header as a KubeMQ JWT during the handshake, before any data frame is processed: | Aspect | Behavior | | -------------- | -------------------------------------------------------------------------------------------------------------------- | | Credential | The `passcode` header is a **KubeMQ JWT** (not a password). | | When validated | **Connect-time only** — the connector never re-validates a live connection. | | Token expiry | A JWT that expires **after** CONNECT does **not** drop the live connection; there is no per-operation re-validation. | | Failure | Audited as `auth.failure`, then `ERROR "authentication failed"` (no detail leak) + socket close. | | Success | Audited as `auth.success`; the JWT's `ClientID` claim becomes the session id. | **Auth is connect-time only.** Because the JWT is checked only during the handshake, a token that expires mid-session keeps working until the client disconnects and reconnects. Do **not** rely on a STOMP connection drop as a token-expiry signal — it will not happen. To rotate credentials, the client must reconnect with a fresh `passcode`. ## `login` derives the session id [#login-derives-the-session-id] The `login` header is **freeform** and is used purely to derive the client/session id **when JWT claims are absent**. The derivation order is: 1. **`claims.ClientID`** — when authentication is on and the JWT carries a client id. 2. **`stomp-`** — the `login` value with characters outside `[a-zA-Z0-9_\-.]` stripped, truncated to 64 characters. 3. **`stomp-`** — a random fallback when there is no usable login. The derived id is returned to the client as the **`session`** header on the `CONNECTED` frame, and is what surfaces as `client_id` in the management API and audit log. **STOMP has no client-id uniqueness rule.** Two connections may share the same `login` (and therefore the same derived id). The connector keeps all cross-connection state under a private, always-unique key, so same-login fleets are the normal, supported case — they do not collide. ## The `host` header is accepted and ignored [#the-host-header-is-accepted-and-ignored] STOMP 1.1+ defines a `host` header for vhost selection. The KubeMQ connector **accepts any value (or none) and ignores it** — there are **no vhost semantics**. Sending a `host` header will not scope, isolate, or route your traffic. ## Authorization — per-channel Casbin [#authorization--per-channel-casbin] When the server-wide authorizer is enabled, the connector runs a Casbin enforce check on every data frame: | Frame | Check | Resource | Channel | | ------------------------------------- | ----------------- | ------------------------------------------------------------------------- | --------------------------- | | `SEND` | write | the pattern name (`events` / `store` / `queues` / `commands` / `queries`) | the resolved KubeMQ channel | | `SUBSCRIBE` (queues / events / store) | read | the pattern name | the resolved KubeMQ channel | | `SUBSCRIBE` to `/reply/...` | **none — exempt** | — | — | The `Resource` is the **pattern name** (not the prefix), and the `Channel` is the **resolved KubeMQ channel** after slash→dot mapping — `/queue/orders/new` enforces resource `queues`, channel `orders.new`. A `nil` authorizer means allow-all. A denied frame produces `ERROR "access denied"` + socket close. **RPC SEND is authorized as `commands` / `queries`.** A SEND to `/command/exec` is a write enforce against resource `commands`, channel `exec`. There is no separate authorization step for the reply: it arrives on a `/reply/` subscription, which is connection-local and exempt. ### Why `/reply/` is exempt [#why-reply-is-exempt] A `/reply/` subscription is **connection-local** — it never touches a KubeMQ channel, has no downstream channel to authorize, and exists only so the RPC bridge can deliver the matching reply on the same connection. The connector therefore **never consults Casbin for `reply`**. This is what lets an authorized requester receive its command or query reply without a second, separate grant. ## Error semantics [#error-semantics] Both authentication and authorization failures are **terminal**: the connector emits an `ERROR` frame and closes the socket. The message is **sanitized** — internal error text never crosses the wire. | Condition | Wire `message` | Closes connection | | ---------------------------- | ----------------------- | ----------------- | | JWT validation fails | `authentication failed` | Yes | | Casbin denies SEND/SUBSCRIBE | `access denied` | Yes | Treat every `ERROR` frame as terminal in client code. ## Related [#related] # Commands (/connectors/stomp/how-to/commands) Commands are **request/reply RPC** over the STOMP connector. A client SENDs to a `/command/` destination and receives a single reply **MESSAGE** on a connection-local `/reply/...` subscription. A Command is the "execute this and tell me it worked" half of KubeMQ RPC — the responder returns an **execution result** (success or failure), typically without a body. For "execute this and give me the answer" with a response payload, use [Queries](/connectors/stomp/how-to/queries). **STOMP is RPC-requester-only.** A STOMP client can **send** commands but cannot **respond** to them — the responder runs on the gRPC side, using a native KubeMQ SDK. A `SUBSCRIBE /command/...` is **hard-rejected** with `ERROR "cannot subscribe to RPC destinations"` and the connection closes. To answer commands, run a responder with the gRPC SDK (`SubscribeToCommands` + `SendCommandResponse`); the connector bridges your STOMP SEND to it. ## Overview [#overview] The `/command/` prefix selects the Commands pattern; `/commands/` is an accepted alias that egress canonicalizes back to `/command/...` — **lead with `/command/` in your code**. The remaining segments are slash-to-dot joined into the KubeMQ channel: `/command/orders/exec` → channel `orders.exec`. `/reply/` is the connection-local reply destination — it has **no alias**, is authz-exempt, and is never an array subscription. | Operation | STOMP action | KubeMQ mapping | | -------------------- | ------------------------------------- | ----------------------------------------------------- | | Subscribe to replies | `SUBSCRIBE /reply/` | connection-local inbox (no array, no authz, no ack) | | Send a command | `SEND /command/` with `reply-to` | `SendCommand` (dispatched to the gRPC-side responder) | | Receive the result | `MESSAGE` on `/reply/` | the responder's execution result | ## How it works [#how-it-works] The requester subscribes to a reply inbox first, SENDs the command with a required `reply-to` header, and the execution result arrives back as a MESSAGE on that inbox. The responder is a separate process on the gRPC side. *The STOMP client is the requester only; the responder runs on the gRPC side and the connector bridges the two.* ## The 3-step flow [#the-3-step-flow] 1. **Step 1: SUBSCRIBE to `/reply/` first.** The reply destination is connection-local: no array subscription, no authz (it is authz-exempt), no ack tracking. It must be active on the **same connection** before you SEND, or the SEND is rejected. 2. **Step 2: SEND to `/command/`** with these headers: | Header | Required | Meaning | | ---------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `reply-to` | **yes** | the `/reply/` you subscribed to in step 1, on the **same** connection. Missing or inactive → `ERROR "reply-to subscription required"` and the connection closes. | | `correlation-id` | no | echoed back **verbatim** on the reply — **but only when the request set it**. | | `timeout` | no | in **milliseconds**; effective = `min(timeout, server cap)`; garbage / non-positive / over-cap → the default (30000). | 3. **Step 3: the reply arrives as a MESSAGE on `/reply/`** with `destination` = the reply-to, a fresh `message-id`, `subscription` = the reply sub id (1.1/1.2 only), and `correlation-id` echoed only when the request set it. ## Failures are a MESSAGE with a `stomp-error` header [#failures-are-a-message-with-a-stomp-error-header] This is the single most surprising RPC behavior — drill it into your client code. **RPC failures are a MESSAGE + `stomp-error` header, NOT an ERROR frame.** A timeout, a logical error, or a dropped reply arrives as **data on the `/reply/` subscription**, and the **connection stays open**. Detect a failure by the **presence of the `stomp-error` header** — not by an ERROR frame, and not by an empty body. The body shape differs by failure kind: a **logical error** (the responder ran but reported failure) carries `stomp-error` **and** the responder's body + tags; a **transport error / timeout** carries `stomp-error` and an **empty body** (`context deadline exceeded` is sanitized to `timeout`); a **nil response** carries `stomp-error:"no response"` and an empty body. Only `reply-to` violations and pending-cap overflow close the connection. ## Send a command [#send-a-command] Each example performs the 3-step requester flow against a Commands responder running on the gRPC side. It subscribes to a reply inbox, SENDs the command with `reply-to` + `correlation-id` + `timeout`, and reads the execution result — checking the `stomp-error` header to distinguish success from failure. Every client reads the connector endpoint from `KUBEMQ_STOMP_URL` (default `tcp://localhost:61613`). ```go package main import ( "fmt" "log" "net/url" "os" "time" "github.com/go-stomp/stomp/v3" ) const ( replyDest = "/reply/r1" // connection-local reply inbox commandDest = "/command/exec" // Commands pattern → channel exec ) func addr() (network, host string) { u, _ := url.Parse(os.Getenv("KUBEMQ_STOMP_URL")) if u == nil || u.Host == "" { return "tcp", "localhost:61613" } return "tcp", u.Host } func main() { network, host := addr() conn, err := stomp.Dial(network, host) if err != nil { log.Fatalf("dial: %v", err) } defer func() { _ = conn.Disconnect() }() // Step 1: SUBSCRIBE /reply/r1 FIRST (connection-local). reply, err := conn.Subscribe(replyDest, stomp.AckAuto) if err != nil { log.Fatalf("subscribe reply: %v", err) } // Step 2: SEND /command/exec with reply-to + correlation-id + timeout(ms). corrID := "abc123" if err := conn.Send(commandDest, "application/json", []byte(`{"op":"do-work"}`), stomp.SendOpt.Header("reply-to", replyDest), stomp.SendOpt.Header("correlation-id", corrID), stomp.SendOpt.Header("timeout", "5000"), ); err != nil { log.Fatalf("send command: %v", err) } // Step 3: receive the execution result on /reply/r1. select { case msg := <-reply.C: if se := msg.Header.Get("stomp-error"); se != "" { log.Fatalf("command failed: stomp-error=%q (connection stays open)", se) } fmt.Printf("command executed (correlation-id=%s)\n", msg.Header.Get("correlation-id")) case <-time.After(10 * time.Second): log.Fatal("timed out waiting for the reply") } } ``` ```python import os import queue from urllib.parse import urlparse import stomp REPLY_DEST = "/reply/r1" # connection-local reply inbox COMMAND_DEST = "/command/exec" # Commands pattern → channel exec def endpoint() -> tuple[str, int]: parsed = urlparse(os.environ.get("KUBEMQ_STOMP_URL", "tcp://localhost:61613")) return parsed.hostname or "localhost", parsed.port or 61613 class Replies(stomp.ConnectionListener): def __init__(self) -> None: self.inbox: queue.Queue = queue.Queue() def on_message(self, frame) -> None: self.inbox.put(frame) def main() -> None: host, port = endpoint() replies = Replies() conn = stomp.Connection12([(host, port)], heartbeats=(10000, 10000)) conn.set_listener("r", replies) conn.connect(wait=True) # Step 1: SUBSCRIBE /reply/r1 FIRST (connection-local). conn.subscribe(REPLY_DEST, id="r1", ack="auto") # Step 2: SEND /command/exec with reply-to + correlation-id + timeout(ms). conn.send(COMMAND_DEST, '{"op":"do-work"}', content_type="application/json", headers={"reply-to": REPLY_DEST, "correlation-id": "abc123", "timeout": "5000"}) # Step 3: receive the execution result on /reply/r1. frame = replies.inbox.get(timeout=10) if frame.headers.get("stomp-error"): raise SystemExit(f"command failed: {frame.headers['stomp-error']!r}") print(f"command executed (correlation-id={frame.headers.get('correlation-id')})") conn.disconnect() if __name__ == "__main__": main() ``` ```java import java.lang.reflect.Type; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; import org.springframework.messaging.simp.stomp.StompHeaders; import org.springframework.messaging.simp.stomp.StompSession; import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; import org.springframework.messaging.simp.stomp.ReactorNettyTcpStompClient; public final class Main { private static final String REPLY_DEST = "/reply/r1"; // connection-local reply inbox private static final String COMMAND_DEST = "/command/exec"; // Commands pattern → channel exec public static void main(String[] args) throws Exception { String url = System.getenv().getOrDefault("KUBEMQ_STOMP_URL", "tcp://localhost:61613"); java.net.URI u = java.net.URI.create(url); ReactorNettyTcpStompClient client = new ReactorNettyTcpStompClient(u.getHost(), u.getPort() > 0 ? u.getPort() : 61613); BlockingQueue inbox = new ArrayBlockingQueue<>(1); StompSession conn = client.connectAsync(new StompSessionHandlerAdapter() {}).get(10, TimeUnit.SECONDS); // Step 1: SUBSCRIBE /reply/r1 FIRST (connection-local). StompHeaders replyHeaders = new StompHeaders(); replyHeaders.setDestination(REPLY_DEST); replyHeaders.setId("r1"); replyHeaders.setAck("auto"); conn.subscribe(replyHeaders, new StompSessionHandlerAdapter() { @Override public Type getPayloadType(StompHeaders headers) { return byte[].class; } @Override public void handleFrame(StompHeaders headers, Object payload) { inbox.add(headers); } }); // Step 2: SEND /command/exec with reply-to + correlation-id + timeout(ms). StompHeaders cmd = new StompHeaders(); cmd.setDestination(COMMAND_DEST); cmd.add("content-type", "application/json"); cmd.add("reply-to", REPLY_DEST); cmd.add("correlation-id", "abc123"); cmd.add("timeout", "5000"); conn.send(cmd, "{\"op\":\"do-work\"}".getBytes()); // Step 3: receive the execution result on /reply/r1. StompHeaders reply = inbox.poll(10, TimeUnit.SECONDS); if (reply == null) throw new IllegalStateException("timed out waiting for the reply"); if (reply.getFirst("stomp-error") != null) { throw new IllegalStateException("command failed: " + reply.getFirst("stomp-error")); } System.out.printf("command executed (correlation-id=%s)%n", reply.getFirst("correlation-id")); conn.disconnect(); client.stop(); } } ``` ```typescript import { connect, type Client } from "stompit"; const REPLY_DEST = "/reply/r1"; // connection-local reply inbox const COMMAND_DEST = "/command/exec"; // Commands pattern → channel exec function endpoint(): { host: string; port: number } { const url = new URL(process.env["KUBEMQ_STOMP_URL"] ?? "tcp://localhost:61613"); return { host: url.hostname, port: Number(url.port) || 61613 }; } function open(): Promise { const { host, port } = endpoint(); return new Promise((resolve, reject) => { connect({ host, port, connectHeaders: { "accept-version": "1.2", "heart-beat": "10000,10000" } }, (err, client) => (err ? reject(err) : resolve(client))); }); } async function main(): Promise { const conn = await open(); // Step 1: SUBSCRIBE /reply/r1 FIRST (connection-local). const reply = new Promise>((resolve, reject) => { const timer = setTimeout(() => reject(new Error("timed out waiting for the reply")), 15_000); conn.subscribe({ destination: REPLY_DEST, ack: "auto" }, (err, message) => { if (err) return reject(err); message.readString("utf-8", (readErr) => { clearTimeout(timer); if (readErr) return reject(readErr); resolve(message.headers as Record); }); }); }); // Step 2: SEND /command/exec with reply-to + correlation-id + timeout(ms). const frame = conn.send({ destination: COMMAND_DEST, "content-type": "application/json", "reply-to": REPLY_DEST, "correlation-id": "abc123", timeout: "5000", }); frame.write(JSON.stringify({ op: "do-work" })); frame.end(); // Step 3: inspect the execution result on /reply/r1. const headers = await reply; if (headers["stomp-error"]) throw new Error(`command failed: ${headers["stomp-error"]}`); console.log(`command executed (correlation-id=${headers["correlation-id"]})`); await new Promise((r) => conn.disconnect(() => r())); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using System.Text; using Stomp.Net; var url = Environment.GetEnvironmentVariable("KUBEMQ_STOMP_URL") ?? "tcp://localhost:61613"; var uri = new Uri(url); const string replyDest = "/reply/r1"; // connection-local reply inbox const string commandDest = "/command/exec"; // Commands pattern → channel exec string brokerUri = $"stomp:tcp://{uri.Host}:{(uri.Port > 0 ? uri.Port : 61613)}"; var factory = new ConnectionFactory(brokerUri) { UserName = "kubemq", Password = "" }; using var conn = factory.CreateConnection(); conn.Start(); using var session = conn.CreateSession(AcknowledgementMode.AutoAcknowledge); // Step 1: SUBSCRIBE /reply/r1 FIRST (connection-local). using var replyConsumer = session.CreateConsumer(session.GetQueue(replyDest)); // Step 2: SEND /command/exec with reply-to + correlation-id + timeout(ms). using var producer = session.CreateProducer(session.GetQueue(commandDest)); var cmd = producer.CreateBytesMessage(Encoding.UTF8.GetBytes("{\"op\":\"do-work\"}")); cmd.StompType = "application/json"; cmd.Headers.SetValue("reply-to", replyDest); cmd.Headers.SetValue("correlation-id", "abc123"); cmd.Headers.SetValue("timeout", "5000"); producer.Send(cmd); // Step 3: receive the execution result on /reply/r1. var reply = replyConsumer.Receive(TimeSpan.FromSeconds(10)) ?? throw new InvalidOperationException("timed out waiting for the reply"); var stompError = reply.Headers.GetValue("stomp-error"); if (!string.IsNullOrEmpty(stompError)) throw new InvalidOperationException($"command failed: {stompError}"); Console.WriteLine($"command executed (correlation-id={reply.Headers.GetValue("correlation-id")})"); ``` ```ruby require "stomp" require "uri" require "timeout" uri = URI.parse(ENV.fetch("KUBEMQ_STOMP_URL", "tcp://localhost:61613")) hosts = [{ host: uri.host, port: uri.port || 61613, login: "kubemq", passcode: "" }] REPLY_DEST = "/reply/r1" # connection-local reply inbox COMMAND_DEST = "/command/exec" # Commands pattern → channel exec conn = Stomp::Client.new(hosts: hosts, connect_headers: { "accept-version" => "1.2", "host" => "/" }) # Step 1: SUBSCRIBE /reply/r1 FIRST (connection-local). inbox = Thread::Queue.new conn.subscribe(REPLY_DEST, id: "r1", ack: "auto") { |msg| inbox << msg } # Step 2: SEND /command/exec with reply-to + correlation-id + timeout(ms). conn.publish(COMMAND_DEST, '{"op":"do-work"}', "content-type" => "application/json", "reply-to" => REPLY_DEST, "correlation-id" => "abc123", "timeout" => "5000") # Step 3: receive the execution result on /reply/r1. reply = Timeout.timeout(10) { inbox.pop } raise "command failed: #{reply.headers['stomp-error']}" if reply.headers["stomp-error"] puts "command executed (correlation-id=#{reply.headers['correlation-id']})" conn.close ``` ```rust use std::time::Duration; use async_stomp::client::Connector; use async_stomp::{AckMode, FromServer, ToServer}; use futures::{SinkExt, StreamExt}; const REPLY_DEST: &str = "/reply/r1"; // connection-local reply inbox const COMMAND_DEST: &str = "/command/exec"; // Commands pattern → channel exec fn host_port() -> (String, u16) { let url = std::env::var("KUBEMQ_STOMP_URL").unwrap_or_else(|_| "tcp://localhost:61613".into()); let hp = url.trim_start_matches("tcp://").trim_start_matches("tls://"); let mut parts = hp.splitn(2, ':'); let host = parts.next().unwrap_or("localhost").to_string(); let port = parts.next().and_then(|p| p.parse().ok()).unwrap_or(61613); (host, port) } #[tokio::main] async fn main() -> Result<(), Box> { let (host, port) = host_port(); let mut conn = Connector::builder() .server(format!("{host}:{port}")) .virtualhost(&host) .connect() .await?; // Step 1: SUBSCRIBE /reply/r1 FIRST (connection-local). conn.send(ToServer::Subscribe { destination: REPLY_DEST.into(), id: "r1".into(), ack: Some(AckMode::Auto), }.into()) .await?; // Step 2: SEND /command/exec with reply-to + correlation-id + timeout(ms). conn.send(ToServer::Send { destination: COMMAND_DEST.into(), transaction: None, headers: Some(vec![ ("content-type".into(), "application/json".into()), ("reply-to".into(), REPLY_DEST.into()), ("correlation-id".into(), "abc123".into()), ("timeout".into(), "5000".into()), ]), body: Some(br#"{"op":"do-work"}"#.to_vec()), }.into()) .await?; // Step 3: receive the execution result on /reply/r1. let frame = tokio::time::timeout(Duration::from_secs(10), conn.next()) .await? .ok_or("stream closed")??; if let FromServer::Message { headers, .. } = frame.content { if let Some((_, err)) = headers.iter().find(|(k, _)| k == "stomp-error") { return Err(format!("command failed: {err}").into()); } let corr = headers.iter().find(|(k, _)| k == "correlation-id").map(|(_, v)| v.as_str()); println!("command executed (correlation-id={})", corr.unwrap_or("")); } Ok(()) } ``` ## Where the responder lives [#where-the-responder-lives] Because a STOMP client cannot be a responder, the Commands **responder must run on the gRPC side** — a process using a native KubeMQ SDK that does `SubscribeToCommands`, processes the request, and replies via `SendCommandResponse`. The connector bridges the STOMP requester's SEND to that responder over the broker, the same path the gRPC connector uses. The number of in-flight RPCs is bounded by a pending cap (default 1024); overflow → `ERROR "too many pending requests"` and the connection closes. ## Related [#related] # Connectivity and security (/connectors/stomp/how-to/connectivity-and-security) The KubeMQ STOMP connector is an **embedded STOMP server inside `kubemq-server`** with its own dedicated listeners — plain TCP **61613** (default) and TLS **61614** (default). An existing STOMP application connects to it by changing only the broker address. This guide covers the connection URL convention, the CONNECT handshake, heartbeats, TLS, and the connection limit. ## `KUBEMQ_STOMP_URL` — the connection convention [#kubemq_stomp_url--the-connection-convention] The connector reads `KUBEMQ_STOMP_URL` (default `tcp://localhost:61613`) and dials it. **The URL scheme selects the transport:** ```bash export KUBEMQ_STOMP_URL="tcp://localhost:61613" # default — plain TCP listener (port 61613) # export KUBEMQ_STOMP_URL="tls://kubemq.example:61614" # TLS listener (port 61614, see below) ``` | Scheme | Transport | Listener (default port) | | -------- | --------- | ------------------------------------------------------------------ | | `tcp://` | plain TCP | 61613 | | `tls://` | TLS | 61614 (active only when the server Security block resolves to TLS) | The connector **binds all interfaces** (`:`), not just localhost. Both ports are configurable via the connector configuration. **Verify the listener — do not infer it from a successful server boot.** The connector loader is availability-first: a bind failure logs a warning and the server keeps running **without** STOMP. Confirm the listener is up via the `/stomp` web dashboard, the `kubemq_stomp_connections` Prometheus gauge, or `GET /api/stomp/connections`. ## The CONNECT handshake [#the-connect-handshake] Every connection opens with the same CONNECT frame and reads the negotiated `CONNECTED` headers back: ```text CONNECT accept-version:1.2 # highest common of 1.0/1.1/1.2; absent/empty → 1.0 heart-beat:10000,10000 # client cx,cy in ms; server advertises sx=sy=HeartbeatMs (default 10000) login:my-app # freeform (session id fallback when no JWT claims) passcode: # empty/any in the default no-auth mode ^@ ``` The `CONNECTED` reply carries: | Header | Value | | ------------ | --------------------------------------------------------------------------------------------------- | | `version` | the negotiated version (`1.0` / `1.1` / `1.2`) | | `heart-beat` | the **server's advertised** `sx,sy` (both = `HeartbeatMs`), **not** the negotiated effective values | | `session` | the derived client id (see [Authentication](/connectors/stomp/how-to/authentication)) | | `server` | `KubeMQ/` | `CONNECTED` headers are **never escaped** (handshake exemption). The handshake order is: version negotiation → broker-ready gate → max-connections check → auth → derive client id → heartbeat negotiation → send `CONNECTED`. **`accept-version:1.2` is recommended.** See [Protocol versions](/connectors/stomp/how-to/protocol-versions) for the negotiation rules and the per-version feature matrix. ## Heartbeats and the 2x dead-peer cutoff [#heartbeats-and-the-2x-dead-peer-cutoff] Heartbeats keep the TCP connection alive and let the connector detect a dead peer. * The client sends `heart-beat:cx,cy` (ms). The server advertises `sx = sy = HeartbeatMs` (default **10000**; `0` disables the server side). * **Effective client→server interval = `maxNonZero(cx, sy)`**; **server→client = `maxNonZero(sx, cy)`** — where `maxNonZero(a, b)` is `0` if **either** side is `0` (that direction is disabled), otherwise `max(a, b)`. * Parsing is lenient: a missing, non-numeric, negative, or 3-field value becomes `0,0`. **The dead-peer cutoff is 2x the negotiated client→server interval.** The watchdog force-closes the connection (audited as `client.timeout`) if no inbound bytes arrive within that window. **Any inbound byte — a real frame or a bare `\n` heartbeat — refreshes liveness.** The server sends a heartbeat LF only when it has written nothing for at least half the server→client interval. **Recommendation:** send `heart-beat:10000,10000` (matches the server default). With a 10 s client→server interval the cutoff is **20 s** — a client must send a frame or a bare `\n` before that window elapses. ## TLS [#tls] **TLS is a transport swap — the STOMP protocol on top is unchanged.** Point `KUBEMQ_STOMP_URL` at `tls://host:61614` and configure the server Security block; the STOMP frames are identical. ```bash export KUBEMQ_STOMP_URL="tls://kubemq.example.com:61614" ``` * The TLS listener is the default **port 61614** (`tls://...`). * It is active **only** when the TLS port is configured **and** the **server-wide `Security` block** resolves to non-nil TLS. If the Security mode is `none`, the TLS port is **silently skipped**. * **TLS has no STOMP-specific config.** The certificate material, mTLS, and minimum version all come from the server-wide `Security` block — the connector owns only *whether the TLS port is open*. mTLS requires and verifies the client certificate; the minimum is TLS 1.2. For the shared KubeMQ JWT model and TLS/mTLS concepts across connectors, see [Auth & security](/connectors/reference/auth-and-security). ## WebSocket is not supported in V1 [#websocket-is-not-supported-in-v1] **The connector is raw TCP only.** STOMP-over-WebSocket is a documented **future** listener path, not a current feature. The popular browser/Node client `@stomp/stompjs` speaks STOMP **only over WebSocket**, so it **cannot** connect to the raw-TCP listener directly. The JavaScript/TypeScript examples therefore use `stompit` (raw TCP). ## Connection limit [#connection-limit] The connector enforces a maximum connection count (default **1000**; `0` = unlimited). The slot is **counted at accept** — a raw TCP socket that never sends CONNECT still consumes a slot — but the over-limit `ERROR "connection limit reached"` is **deferred to the handshake** so the client receives a proper frame. ## Errors are terminal [#errors-are-terminal] Every protocol error produces an `ERROR` frame followed by a socket close. The message is **sanitized** — internal error text never crosses the wire. Treat every `ERROR` frame as terminal in client code, and reconnect if appropriate. ## Related [#related] # Destination mapping (/connectors/stomp/how-to/destination-mapping) The STOMP `destination` is the single most important mental model in this connector. A destination's **first segment selects the KubeMQ pattern**; the remaining segments are **slash→dot joined** into the KubeMQ channel. This is how an unmodified STOMP application reaches all five KubeMQ patterns by changing only the address it dials. ## The grammar [#the-grammar] The connector resolves a destination in these steps: 1. The raw destination length is checked first — **≤ 512 bytes**, on the raw string **before** the leading-slash strip. 2. Strip **exactly one** leading `/` (both `/queue/x` and `queue/x` are accepted). 3. Split on `/`. Any **empty segment** (from `//`, a trailing `/`, etc.) is rejected as `invalid destination`. 4. The **first segment** selects the pattern via a **case-sensitive** map. 5. The remaining segments are **`.`-joined** into the KubeMQ channel. 6. An **unknown** first segment falls back to `DefaultPattern` (see below). ### Primary prefix → pattern → channel [#primary-prefix--pattern--channel] Lead with these **primary** names in every application and example: | STOMP destination (ingress) | Pattern | KubeMQ channel | Canonical egress (always primary) | | --------------------------- | ------------------------ | -------------- | --------------------------------- | | `/queue/orders/new` | Queues | `orders.new` | `/queue/orders/new` | | `/topic/a/b/c` | Events | `a.b.c` | `/topic/a/b/c` | | `/topic-store/audit` | Events-Store | `audit` | `/topic-store/audit` | | `/command/exec` | Commands (RPC) | `exec` | `/command/exec` | | `/query/lookup` | Queries (RPC) | `lookup` | `/query/lookup` | | `/reply/r1` | reply (connection-local) | `r1` | `/reply/r1` | The Events-Store prefix is **`/topic-store/`** (primary). There is **no** `/topic_store/` or `/eventstore/`. ## Slash → dot, and the literal-dot trap [#slash--dot-and-the-literal-dot-trap] The channel join is **slash→dot**: `/topic/a/b/c` → channel `a.b.c`. Egress reverses it: channel `a.b.c` → `/topic/a/b/c`. A **literal `.` inside a segment passes through unchanged** — which makes it lossy. **A literal `.` in a destination segment is lossy.** Both `/topic/a.b` and `/topic/a/b` map to the **same** KubeMQ channel `a.b`, and egress **always emits the slash form** `/topic/a/b`. So `/topic/a.b` is **not** round-trip safe — a subscriber will see `/topic/a/b` on the MESSAGE `destination`. **Prefer slashes; avoid literal dots in destination segments.** ```text SEND /topic/a.b ─┐ ├─► channel "a.b" ─► MESSAGE destination /topic/a/b SEND /topic/a/b ─┘ ``` ## MQTT-name aliases (never lead with them) [#mqtt-name-aliases-never-lead-with-them] For migration convenience, each primary prefix has an MQTT-style alias. They route identically, but **egress always canonicalizes to the primary name** — a subscriber never sees the alias on the delivered MESSAGE `destination`. | Primary (lead with this) | Alias | Pattern | | ------------------------ | ------------ | ------------------------ | | `/queue/` | `/queues/` | Queues | | `/topic/` | `/events/` | Events | | `/topic-store/` | `/store/` | Events-Store | | `/command/` | `/commands/` | Commands (RPC) | | `/query/` | `/queries/` | Queries (RPC) | | `/reply/` | *(none)* | reply (connection-local) | The aliases exist only so an in-flight migration from MQTT-style naming keeps working; because egress canonicalizes, a fleet that subscribes via `/events/x` and a fleet that subscribes via `/topic/x` both receive `/topic/x` on the wire. **Always lead docs, examples, and application code with the primary names.** ## DefaultPattern (bare destinations) [#defaultpattern-bare-destinations] A destination whose first segment is **not** a known prefix is treated as **bare** and routed by the connector's `DefaultPattern` config: | `DefaultPattern` | Bare `sensor/temp` resolves to | Channel | | ------------------------------------- | ------------------------------------ | ------------- | | `events` *(config default)* | Events | `sensor.temp` | | `queues` | Queues | `sensor.temp` | | `store` | Events-Store | `sensor.temp` | | `none` | **rejected** (`invalid destination`) | — | There is **no** `commands` / `queries` default — a bare destination can **never** resolve to an RPC pattern. **Examples always use explicit prefixes.** Relying on `DefaultPattern` couples your application to a server-side config you do not control. Write `/topic/sensor/temp`, not `sensor/temp`. ## Wildcards — Events only, subscribe only [#wildcards--events-only-subscribe-only] Wildcards are pass-through to the message broker's **native wildcard syntax** — the connector does **not** translate them: * `*` matches **one** segment, in any position. * `>` matches the **tail**, and **must be the final** segment. **Wildcards are Events-only and SUBSCRIBE-only, and use the broker's native syntax (`*` = one segment, `>` = final tail).** A wildcard is allowed **only** on `SUBSCRIBE` and **only** for the Events pattern. A wildcard on `SEND`, or on **Queues / Events-Store / RPC**, is rejected as `invalid destination`. **There is no MQTT-style `+` / `#`.** And **egress delivers the concrete matched channel, not the filter** — a `/topic/orders/*` subscriber receiving on `orders.eu` gets `destination:/topic/orders/eu`. ```text SUBSCRIBE /topic/orders/* → channel filter "orders.*" (Events, OK) SUBSCRIBE /topic/orders/> → channel filter "orders.>" (> must be final, OK) SEND /topic/orders/* → invalid destination SUBSCRIBE /queue/jobs/* → invalid destination # Egress delivers the concrete channel: SUBSCRIBE /topic/orders/* ──► MESSAGE destination /topic/orders/eu (NOT /topic/orders/*) ``` Slash→dot still applies inside wildcard filters: `/topic/a/*/c` → `a.*.c`. Wildcards do **not** apply to Events-Store. ## Header ⇄ tag interop [#header--tag-interop] The body is byte-exact in both directions (the writer always stamps `content-length` on egress, so it is binary-safe). **Metadata is mapped between STOMP headers and KubeMQ Tags** by a precise convention. ### The five standard headers ↔ reserved `stomp.*` tags [#the-five-standard-headers--reserved-stomp-tags] These five round-trip in **both** directions: | STOMP header (ingress & egress) | KubeMQ Tag key | | ------------------------------- | ---------------------- | | `content-type` | `stomp.content-type` | | `correlation-id` | `stomp.correlation-id` | | `reply-to` | `stomp.reply-to` | | `priority` | `stomp.priority` | | `type` | `stomp.type` | A SEND `content-type:application/json` becomes Tag `stomp.content-type=application/json`; on egress that tag is restored to a `content-type` header. ### Custom headers pass through as tags [#custom-headers-pass-through-as-tags] Any header **not** in the standard set and **not** protocol machinery passes through **name-as-is** as a KubeMQ tag. A custom `x-trace:abc` on SEND becomes Tag `x-trace=abc`, delivered back as an `x-trace` header. Duplicate header names are **first-wins**. **Tag limits are fatal on SEND.** At most **32** custom tags, at most **4096 bytes** per tag value. Exceeding either returns `ERROR "frame too large"` and closes the connection. ### Collision rule, machinery headers, and the spoofing guard [#collision-rule-machinery-headers-and-the-spoofing-guard] * **Collision rule — `stomp.*` wins.** If a native producer sets both a bare `content-type` tag and a `stomp.content-type` tag, the `stomp.*` tag wins on egress, and exactly one `content-type` header is emitted. * **Machinery headers never become tags:** `destination`, `receipt`, `transaction`, `content-length`, `message-id`, `subscription`, `ack`, `id`, `timeout`. * **Spoofing guard.** A client **cannot inject** `stomp.*` tags directly. On ingress, any header whose name starts with `stomp.` or equals `x-kubemq-metadata` is **silently stripped** — the only way to set a `stomp.*` tag is through the corresponding standard header. * **`x-kubemq-metadata` is egress-only.** STOMP ingress never sets the KubeMQ `Metadata` field. On egress, if a native producer left a non-empty `Metadata`, it surfaces as a **read-only** `x-kubemq-metadata` header. A STOMP client can observe it but cannot write it. **There is no `content-type` frame default.** A native producer (gRPC/REST/MQTT/AMQP) that sets no content-type tag produces a MESSAGE with **no `content-type` header** — the STOMP subscriber must assume binary / octet-stream. `content-length` is always present, so the body is still framed correctly. To surface a proper `content-type`, a native producer should set `Tags["stomp.content-type"]`. ## Egress representability per version [#egress-representability-per-version] Not every header value can be serialized on every negotiated STOMP version. The connector drops unrepresentable headers on egress rather than corrupting the frame: | Negotiated version | Drops on egress | | ------------------ | --------------------------------------------- | | **1.0** | `:`, CR, or LF in a name; CR or LF in a value | | **1.1** | CR (1.1 has no escape for it) | | **1.2** | nothing — always representable | **CR/LF in a header value is silently dropped to 1.0/1.1 subscribers.** STOMP 1.1 cannot represent a raw CR; the guard drops the unrepresentable header without corrupting the frame, and the connection stays alive. **Use `accept-version:1.2` and keep CR/LF out of header values; structured or multiline metadata belongs in the body.** ## Destination errors (all sanitized) [#destination-errors-all-sanitized] | Trigger | Wire `message` | | ---------------------------------------------- | --------------------- | | `//`, trailing `/`, `/queue/` (empty segment) | `invalid destination` | | `/queue` (prefix, no channel) | `invalid destination` | | Bare destination with `DefaultPattern=none` | `invalid destination` | | Raw destination > 512 bytes | `invalid destination` | | Wildcard on SEND / Queues / Events-Store / RPC | `invalid destination` | Internal error text never crosses the wire — every destination error surfaces as the single sanitized `invalid destination` message. ## Related [#related] # Events Store (/connectors/stomp/how-to/events-store) Events Store is **persistent** pub/sub over the STOMP connector. SEND to a destination prefixed with `/topic-store/` and the connector routes the message to the KubeMQ **Events Store** pattern, which **persists** it. A later SUBSCRIBE can **replay** from a chosen position — the first message, the last, a specific sequence, a wall-clock time, or a relative window — using the **`start-from`** / **`start-value`** SUBSCRIBE headers. ## Overview [#overview] The `/topic-store/` prefix selects the Events Store pattern; `/store/` is an accepted alias that egress always canonicalizes back to the primary `/topic-store/...` form — **lead with `/topic-store/` in your code**. (There is no `/topic_store/` or `/eventstore/` — those are not recognized prefixes.) The remaining segments are slash-to-dot joined into the KubeMQ channel: `/topic-store/orders/eu` → channel `orders.eu`. Events Store is the persistent sibling of [Events](/connectors/stomp/how-to/events): same fan-out, same at-most-once **live** delivery, but messages are **durable** and a subscriber can **replay** history. Unlike plain [Events](/connectors/stomp/how-to/events), **wildcards do not apply to Events Store**. A `*` or `>` in a `/topic-store/...` SUBSCRIBE destination → `ERROR "invalid destination"` and the connection closes. Subscribe to an **exact** `/topic-store/` and use the replay headers below to control history. | Operation | STOMP action | KubeMQ mapping | | ------------------ | ------------------------------------------------------------ | ------------------------------------------------- | | Publish (persist) | `SEND /topic-store/` | `SendEvents` (`Store=true`) — message is stored | | Subscribe (live) | `SUBSCRIBE /topic-store/` | `SubscribeEventsStore`, `new` (only new messages) | | Subscribe (replay) | `SUBSCRIBE /topic-store/` + `start-from` / `start-value` | Replay from the store's start position | ## How it works [#how-it-works] A SEND is persisted by the store; a SUBSCRIBE either takes only new messages (the default) or replays history from a chosen start position. Distinct replay positions on the same channel become independent subscriptions, so one consumer can replay from the beginning while another takes only new messages. *A SEND is persisted; subscribers choose a start position — `first` replays the full history, `new` (the default) takes only messages from subscription time forward.* ## The `start-from` replay table [#the-start-from-replay-table] Replay is requested at **SUBSCRIBE** time with two headers — `start-from` (the replay **mode**) and `start-value` (the replay **parameter**, required for some modes, forbidden for others): | `start-from` | `start-value` | Replays from | Notes | | -------------- | ------------------------------------------------------ | ------------------------------- | ------------------------------------------------------------------- | | absent / `new` | must **not** be present | only **new** messages from now | the default — same as plain Events live delivery | | `first` | must **not** be present | the **first** persisted message | full history from the beginning | | `last` | must **not** be present | the **last** persisted message | the most recent single message | | `sequence` | **required**, numeric **≥ 0** | the given **sequence** number | per-channel monotonic sequence | | `time` | **required**, RFC3339 **or** unix-seconds **≥ 0** | the given **timestamp** | both formats accepted (e.g. `2026-06-15T12:00:00Z` or `1750000000`) | | `time-delta` | **required**, numeric **> 0** (seconds) | **now − delta** seconds | a relative window, e.g. "last 60 seconds" | A bad combination → `ERROR "invalid subscription"` and the connection closes, **before** the subscription registers. This includes a `start-value` present for `new` / `first` / `last` (those forbid a value), and `sequence` / `time` / `time-delta` with a missing, non-numeric, or out-of-range value. An unrecognized `start-from` → `ERROR "unknown start-from"` and close. **Distinct replay positions are distinct subscriptions.** Two subscribers asking for **different** start positions on the same channel get **independent** subscriptions (unlike plain Events, which share per channel). This is what lets one consumer replay from `first` while another takes only `new`. Replay headers are **silently ignored** on a plain Events (`/topic/`) SUBSCRIBE — plain Events has no persistence to replay from. ## Persist and replay [#persist-and-replay] Each example persists a batch of messages to a `/topic-store/...` destination, then subscribes with `start-from:first` and replays the full history in order. Every client reads the connector endpoint from `KUBEMQ_STOMP_URL` (default `tcp://localhost:61613`). ```go package main import ( "fmt" "log" "net/url" "os" "time" "github.com/go-stomp/stomp/v3" ) const destination = "/topic-store/audit" // Events Store pattern → channel audit const total = 3 func addr() (network, host string) { u, _ := url.Parse(os.Getenv("KUBEMQ_STOMP_URL")) if u == nil || u.Host == "" { return "tcp", "localhost:61613" } return "tcp", u.Host } func main() { network, host := addr() // 1. Persist — SEND `total` messages to the store. pub, err := stomp.Dial(network, host) if err != nil { log.Fatalf("dial: %v", err) } for i := 1; i <= total; i++ { if err := pub.Send(destination, "text/plain", []byte(fmt.Sprintf("evt-%d", i)), stomp.SendOpt.Receipt); err != nil { log.Fatalf("send: %v", err) } } _ = pub.Disconnect() // 2. Replay — SUBSCRIBE start-from:first to read the full history in order. sub, err := stomp.Dial(network, host) if err != nil { log.Fatalf("dial: %v", err) } defer func() { _ = sub.Disconnect() }() subscription, err := sub.Subscribe(destination, stomp.AckAuto, stomp.SubscribeOpt.Header("start-from", "first")) if err != nil { log.Fatalf("subscribe: %v", err) } for got := 0; got < total; got++ { select { case msg := <-subscription.C: fmt.Printf("replayed: %s\n", string(msg.Body)) case <-time.After(10 * time.Second): log.Fatal("timed out replaying the store") } } } ``` ```python import os import queue from urllib.parse import urlparse import stomp DESTINATION = "/topic-store/audit" # Events Store pattern → channel audit TOTAL = 3 def endpoint() -> tuple[str, int]: parsed = urlparse(os.environ.get("KUBEMQ_STOMP_URL", "tcp://localhost:61613")) return parsed.hostname or "localhost", parsed.port or 61613 class Listener(stomp.ConnectionListener): def __init__(self) -> None: self.inbox: queue.Queue = queue.Queue() def on_message(self, frame) -> None: self.inbox.put(frame) def main() -> None: host, port = endpoint() # 1. Persist — SEND `total` messages to the store. pub = stomp.Connection12([(host, port)], heartbeats=(10000, 10000)) pub.connect(wait=True) for i in range(1, TOTAL + 1): pub.send(DESTINATION, f"evt-{i}", content_type="text/plain") pub.disconnect() # 2. Replay — SUBSCRIBE start-from:first to read the full history in order. listener = Listener() sub = stomp.Connection12([(host, port)], heartbeats=(10000, 10000)) sub.set_listener("l", listener) sub.connect(wait=True) sub.subscribe(DESTINATION, id="audit", ack="auto", headers={"start-from": "first"}) for _ in range(TOTAL): frame = listener.inbox.get(timeout=10) print(f"replayed: {frame.body}") sub.disconnect() if __name__ == "__main__": main() ``` ```java import java.lang.reflect.Type; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; import org.springframework.messaging.simp.stomp.StompHeaders; import org.springframework.messaging.simp.stomp.StompSession; import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; import org.springframework.messaging.simp.stomp.ReactorNettyTcpStompClient; public final class Main { private static final String DESTINATION = "/topic-store/audit"; // → channel audit private static final int TOTAL = 3; public static void main(String[] args) throws Exception { String url = System.getenv().getOrDefault("KUBEMQ_STOMP_URL", "tcp://localhost:61613"); java.net.URI u = java.net.URI.create(url); ReactorNettyTcpStompClient client = new ReactorNettyTcpStompClient(u.getHost(), u.getPort() > 0 ? u.getPort() : 61613); // 1. Persist — SEND `total` messages to the store. StompSession pub = client.connectAsync(new StompSessionHandlerAdapter() {}).get(10, TimeUnit.SECONDS); for (int i = 1; i <= TOTAL; i++) { StompHeaders h = new StompHeaders(); h.setDestination(DESTINATION); h.add("content-type", "text/plain"); pub.send(h, ("evt-" + i).getBytes()); } Thread.sleep(500); pub.disconnect(); // 2. Replay — SUBSCRIBE start-from:first to read the full history in order. BlockingQueue inbox = new ArrayBlockingQueue<>(TOTAL); StompSession sub = client.connectAsync(new StompSessionHandlerAdapter() {}).get(10, TimeUnit.SECONDS); StompHeaders subHeaders = new StompHeaders(); subHeaders.setDestination(DESTINATION); subHeaders.setId("audit"); subHeaders.setAck("auto"); subHeaders.add("start-from", "first"); sub.subscribe(subHeaders, new StompSessionHandlerAdapter() { @Override public Type getPayloadType(StompHeaders headers) { return String.class; } @Override public void handleFrame(StompHeaders headers, Object payload) { inbox.add(payload == null ? "" : payload.toString()); } }); for (int i = 0; i < TOTAL; i++) { String body = inbox.poll(10, TimeUnit.SECONDS); if (body == null) throw new IllegalStateException("timed out replaying the store"); System.out.printf("replayed: %s%n", body); } sub.disconnect(); client.stop(); } } ``` ```typescript import { connect, type Client } from "stompit"; const DESTINATION = "/topic-store/audit"; // Events Store pattern → channel audit const TOTAL = 3; function endpoint(): { host: string; port: number } { const url = new URL(process.env["KUBEMQ_STOMP_URL"] ?? "tcp://localhost:61613"); return { host: url.hostname, port: Number(url.port) || 61613 }; } function open(): Promise { const { host, port } = endpoint(); return new Promise((resolve, reject) => { connect({ host, port, connectHeaders: { "accept-version": "1.2", "heart-beat": "10000,10000" } }, (err, client) => (err ? reject(err) : resolve(client))); }); } async function main(): Promise { // 1. Persist — SEND `total` messages to the store. const pub = await open(); for (let i = 1; i <= TOTAL; i++) { const frame = pub.send({ destination: DESTINATION, "content-type": "text/plain" }); frame.write(`evt-${i}`); frame.end(); } await new Promise((r) => pub.disconnect(() => r())); // 2. Replay — SUBSCRIBE start-from:first to read the full history in order. const sub = await open(); let got = 0; await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error("timed out replaying the store")), 15_000); sub.subscribe({ destination: DESTINATION, ack: "auto", "start-from": "first" }, (err, message) => { if (err) return reject(err); message.readString("utf-8", (readErr, body) => { if (readErr) return reject(readErr); console.log(`replayed: ${body}`); if (++got === TOTAL) { clearTimeout(timer); resolve(); } }); }); }); await new Promise((r) => sub.disconnect(() => r())); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using System.Text; using Stomp.Net; var url = Environment.GetEnvironmentVariable("KUBEMQ_STOMP_URL") ?? "tcp://localhost:61613"; var uri = new Uri(url); const string destination = "/topic-store/audit"; // Events Store pattern → channel audit const int total = 3; string brokerUri = $"stomp:tcp://{uri.Host}:{(uri.Port > 0 ? uri.Port : 61613)}"; var factory = new ConnectionFactory(brokerUri) { UserName = "kubemq", Password = "" }; // 1. Persist — SEND `total` messages to the store. using (var pubConn = factory.CreateConnection()) { pubConn.Start(); using var session = pubConn.CreateSession(AcknowledgementMode.AutoAcknowledge); using var producer = session.CreateProducer(session.GetTopic(destination)); for (var i = 1; i <= total; i++) { var msg = producer.CreateBytesMessage(Encoding.UTF8.GetBytes($"evt-{i}")); msg.StompType = "text/plain"; producer.Send(msg); } } // 2. Replay — SUBSCRIBE start-from:first to read the full history in order. using var conn = factory.CreateConnection(); conn.Start(); using var consumeSession = conn.CreateSession(AcknowledgementMode.IndividualAcknowledge); // Stomp.Net forwards extra consumer headers, including the store replay directive. var topic = consumeSession.GetTopic(destination); using var consumer = consumeSession.CreateConsumer(topic, null, false, new Dictionary { ["start-from"] = "first" }); for (var i = 0; i < total; i++) { var msg = consumer.Receive(TimeSpan.FromSeconds(10)) ?? throw new InvalidOperationException("timed out replaying the store"); Console.WriteLine($"replayed: {Encoding.UTF8.GetString(msg.Content)}"); } ``` ```ruby require "stomp" require "uri" require "timeout" uri = URI.parse(ENV.fetch("KUBEMQ_STOMP_URL", "tcp://localhost:61613")) hosts = [{ host: uri.host, port: uri.port || 61613, login: "kubemq", passcode: "" }] DESTINATION = "/topic-store/audit" # Events Store pattern → channel audit TOTAL = 3 # 1. Persist — SEND `total` messages to the store. pub = Stomp::Client.new(hosts: hosts, connect_headers: { "accept-version" => "1.2", "host" => "/" }) (1..TOTAL).each { |i| pub.publish(DESTINATION, "evt-#{i}", "content-type" => "text/plain") } pub.close # 2. Replay — SUBSCRIBE start-from:first to read the full history in order. inbox = Thread::Queue.new sub = Stomp::Client.new(hosts: hosts, connect_headers: { "accept-version" => "1.2", "host" => "/" }) sub.subscribe(DESTINATION, id: "audit", ack: "auto", "start-from" => "first") { |msg| inbox << msg } TOTAL.times do msg = Timeout.timeout(10) { inbox.pop } puts "replayed: #{msg.body}" end sub.close ``` ```rust use std::time::Duration; use async_stomp::client::Connector; use async_stomp::{AckMode, FromServer, Message, ToServer}; use futures::{SinkExt, StreamExt}; const DESTINATION: &str = "/topic-store/audit"; // Events Store pattern → channel audit const TOTAL: usize = 3; fn host_port() -> (String, u16) { let url = std::env::var("KUBEMQ_STOMP_URL").unwrap_or_else(|_| "tcp://localhost:61613".into()); let hp = url.trim_start_matches("tcp://").trim_start_matches("tls://"); let mut parts = hp.splitn(2, ':'); let host = parts.next().unwrap_or("localhost").to_string(); let port = parts.next().and_then(|p| p.parse().ok()).unwrap_or(61613); (host, port) } #[tokio::main] async fn main() -> Result<(), Box> { let (host, port) = host_port(); // 1. Persist — SEND `total` messages to the store. let mut pub_conn = Connector::builder() .server(format!("{host}:{port}")) .virtualhost(&host) .connect() .await?; for i in 1..=TOTAL { pub_conn .send(ToServer::Send { destination: DESTINATION.into(), transaction: None, headers: Some(vec![("content-type".into(), "text/plain".into())]), body: Some(format!("evt-{i}").into_bytes()), }.into()) .await?; } pub_conn.send(ToServer::Disconnect { receipt: None }.into()).await?; // 2. Replay — SUBSCRIBE start-from:first via an extra header. let mut sub = Connector::builder() .server(format!("{host}:{port}")) .virtualhost(&host) .connect() .await?; let mut subscribe: Message = ToServer::Subscribe { destination: DESTINATION.into(), id: "audit".into(), ack: Some(AckMode::Auto), }.into(); subscribe.extra_headers = vec![(b"start-from".to_vec(), b"first".to_vec())]; sub.send(subscribe).await?; for _ in 0..TOTAL { let frame = tokio::time::timeout(Duration::from_secs(10), sub.next()) .await? .ok_or("stream closed")??; if let FromServer::Message { body, .. } = frame.content { println!("replayed: {}", String::from_utf8_lossy(&body.unwrap_or_default())); } } Ok(()) } ``` ## Reliability [#reliability] **Live** delivery on Events Store is **at-most-once**, exactly like plain Events: a full per-subscriber output buffer drops that one delivery and keeps the connection alive. Persistence guarantees the message is **stored** and **replayable** — it does not change the live delivery guarantee for an already-attached subscriber. Never promise exactly-once. ## Related [#related] # Events (/connectors/stomp/how-to/events) Events are **fire-and-forget** pub/sub over the STOMP connector. SEND to a destination prefixed with `/topic/` and the connector routes the message to the KubeMQ **Events** pattern; every active subscriber on the matching channel gets a copy. Nothing is persisted — a subscriber that is offline at SEND time misses the message. ## Overview [#overview] The `/topic/` prefix selects the Events pattern; `/events/` is an accepted alias that egress always canonicalizes back to the primary `/topic/...` form — **lead with `/topic/` in your code**. The remaining destination segments are slash-to-dot joined into the KubeMQ channel: `/topic/orders/eu` → channel `orders.eu`. Events is the only pattern that accepts wildcard subscriptions and the default pattern for bare (prefixless) destinations. | Operation | STOMP action | KubeMQ mapping | | ------------------ | ------------------------------------ | --------------------------------------------------- | | Publish | `SEND /topic/` | `SendEvents` (`Store=false`) | | Subscribe | `SUBSCRIBE /topic/` (`ack:auto`) | Fan-out delivery to every matching subscriber | | Wildcard subscribe | `SUBSCRIBE /topic//*` or `/>` | The broker's native channel wildcards (Events only) | Events deliver as `ack:auto` — there is no client acknowledgement, no NACK, and no redelivery. A `receipt` on a SEND confirms only that **KubeMQ accepted the SEND**, not that any subscriber consumed it. ## How it works [#how-it-works] A published event fans out to **every** active subscriber whose filter matches. There is no consumer group and no load-balancing for Events — every matching subscriber receives every message. Use [Queues](/connectors/stomp/how-to/queues) when you need competing consumers. *Each event is copied to every subscriber whose filter matches the SENT destination; there is no persistence and no replay.* ## Publish and subscribe [#publish-and-subscribe] Each example subscribes **first** (Events have no replay — a SEND that beats the subscription is lost), waits briefly for the subscription to register, then publishes and drains the message. Every client reads the connector endpoint from `KUBEMQ_STOMP_URL` (default `tcp://localhost:61613`). ```go package main import ( "fmt" "log" "net/url" "os" "time" "github.com/go-stomp/stomp/v3" ) const destination = "/topic/demo" // Events pattern → channel demo func addr() (network, host string) { u, _ := url.Parse(os.Getenv("KUBEMQ_STOMP_URL")) if u == nil || u.Host == "" { return "tcp", "localhost:61613" } return "tcp", u.Host } func main() { network, host := addr() // 1. SUBSCRIBE FIRST — Events have no replay. sub, err := stomp.Dial(network, host) if err != nil { log.Fatalf("dial: %v", err) } defer func() { _ = sub.Disconnect() }() subscription, err := sub.Subscribe(destination, stomp.AckAuto) if err != nil { log.Fatalf("subscribe: %v", err) } time.Sleep(300 * time.Millisecond) // let the subscription register // 2. PUBLISH — SEND one event with a receipt (= KubeMQ accepted the SEND). pub, err := stomp.Dial(network, host) if err != nil { log.Fatalf("dial: %v", err) } if err := pub.Send(destination, "application/json", []byte(`{"message":"hello"}`), stomp.SendOpt.Receipt); err != nil { log.Fatalf("send: %v", err) } _ = pub.Disconnect() // 3. RECEIVE. select { case msg := <-subscription.C: fmt.Printf("received: %s (destination=%s)\n", string(msg.Body), msg.Destination) case <-time.After(10 * time.Second): log.Fatal("timed out waiting for the event") } } ``` ```python import os import queue from urllib.parse import urlparse import stomp DESTINATION = "/topic/demo" # Events pattern → channel demo def endpoint() -> tuple[str, int]: parsed = urlparse(os.environ.get("KUBEMQ_STOMP_URL", "tcp://localhost:61613")) return parsed.hostname or "localhost", parsed.port or 61613 class Listener(stomp.ConnectionListener): def __init__(self) -> None: self.inbox: queue.Queue = queue.Queue() def on_message(self, frame) -> None: self.inbox.put(frame) def main() -> None: host, port = endpoint() # 1. SUBSCRIBE FIRST — Events have no replay. listener = Listener() sub = stomp.Connection12([(host, port)], heartbeats=(10000, 10000)) sub.set_listener("l", listener) sub.connect(wait=True) sub.subscribe(DESTINATION, id="demo", ack="auto") # 2. PUBLISH — SEND one event with a receipt (= KubeMQ accepted the SEND). pub = stomp.Connection12([(host, port)], heartbeats=(10000, 10000)) pub.connect(wait=True) pub.send(DESTINATION, '{"message":"hello"}', content_type="application/json") pub.disconnect() # 3. RECEIVE. frame = listener.inbox.get(timeout=10) print(f"received: {frame.body} (destination={frame.headers['destination']})") sub.disconnect() if __name__ == "__main__": main() ``` ```java import java.lang.reflect.Type; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; import org.springframework.messaging.simp.stomp.StompHeaders; import org.springframework.messaging.simp.stomp.StompSession; import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; import org.springframework.messaging.simp.stomp.ReactorNettyTcpStompClient; public final class Main { private static final String DESTINATION = "/topic/demo"; // Events pattern → channel demo public static void main(String[] args) throws Exception { String url = System.getenv().getOrDefault("KUBEMQ_STOMP_URL", "tcp://localhost:61613"); java.net.URI u = java.net.URI.create(url); ReactorNettyTcpStompClient client = new ReactorNettyTcpStompClient(u.getHost(), u.getPort() > 0 ? u.getPort() : 61613); // 1. SUBSCRIBE FIRST — Events have no replay. BlockingQueue inbox = new ArrayBlockingQueue<>(1); StompSession sub = client.connectAsync(new StompSessionHandlerAdapter() {}).get(10, TimeUnit.SECONDS); StompHeaders subHeaders = new StompHeaders(); subHeaders.setDestination(DESTINATION); subHeaders.setId("demo"); subHeaders.setAck("auto"); sub.subscribe(subHeaders, new StompSessionHandlerAdapter() { @Override public Type getPayloadType(StompHeaders headers) { return String.class; } @Override public void handleFrame(StompHeaders headers, Object payload) { inbox.add(payload == null ? "" : payload.toString()); } }); Thread.sleep(300); // let the subscription register // 2. PUBLISH — SEND one event. StompSession pub = client.connectAsync(new StompSessionHandlerAdapter() {}).get(10, TimeUnit.SECONDS); StompHeaders pubHeaders = new StompHeaders(); pubHeaders.setDestination(DESTINATION); pubHeaders.add("content-type", "application/json"); pub.send(pubHeaders, "{\"message\":\"hello\"}".getBytes()); Thread.sleep(300); pub.disconnect(); // 3. RECEIVE. String body = inbox.poll(10, TimeUnit.SECONDS); if (body == null) throw new IllegalStateException("timed out waiting for the event"); System.out.printf("received: %s%n", body); sub.disconnect(); client.stop(); } } ``` ```typescript import { connect, type Client } from "stompit"; const DESTINATION = "/topic/demo"; // Events pattern → channel demo function endpoint(): { host: string; port: number } { const url = new URL(process.env["KUBEMQ_STOMP_URL"] ?? "tcp://localhost:61613"); return { host: url.hostname, port: Number(url.port) || 61613 }; } function open(): Promise { const { host, port } = endpoint(); return new Promise((resolve, reject) => { connect({ host, port, connectHeaders: { "accept-version": "1.2", "heart-beat": "10000,10000" } }, (err, client) => (err ? reject(err) : resolve(client))); }); } async function main(): Promise { // 1. SUBSCRIBE FIRST — Events have no replay. const sub = await open(); const received = new Promise((resolve, reject) => { sub.subscribe({ destination: DESTINATION, ack: "auto" }, (err, message) => { if (err) return reject(err); message.readString("utf-8", (readErr, body) => (readErr ? reject(readErr) : resolve(body ?? ""))); }); }); await new Promise((r) => setTimeout(r, 300)); // let the subscription register // 2. PUBLISH — SEND one event. const pub = await open(); const frame = pub.send({ destination: DESTINATION, "content-type": "application/json" }); frame.write(JSON.stringify({ message: "hello" })); frame.end(); await new Promise((r) => pub.disconnect(() => r())); // 3. RECEIVE. console.log("received:", await received); await new Promise((r) => sub.disconnect(() => r())); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using System.Text; using Stomp.Net; var url = Environment.GetEnvironmentVariable("KUBEMQ_STOMP_URL") ?? "tcp://localhost:61613"; var uri = new Uri(url); const string destination = "/topic/demo"; // Events pattern → channel demo string brokerUri = $"stomp:tcp://{uri.Host}:{(uri.Port > 0 ? uri.Port : 61613)}"; var factory = new ConnectionFactory(brokerUri) { UserName = "kubemq", Password = "" }; // 1. SUBSCRIBE FIRST — Events have no replay. using var subConn = factory.CreateConnection(); subConn.Start(); using var subSession = subConn.CreateSession(AcknowledgementMode.IndividualAcknowledge); using var consumer = subSession.CreateConsumer(subSession.GetTopic(destination)); await Task.Delay(300); // let the subscription register // 2. PUBLISH — SEND one event. using (var pubConn = factory.CreateConnection()) { pubConn.Start(); using var pubSession = pubConn.CreateSession(AcknowledgementMode.AutoAcknowledge); using var producer = pubSession.CreateProducer(pubSession.GetTopic(destination)); var msg = producer.CreateBytesMessage(Encoding.UTF8.GetBytes("{\"message\":\"hello\"}")); msg.StompType = "application/json"; producer.Send(msg); } // 3. RECEIVE. var received = consumer.Receive(TimeSpan.FromSeconds(10)) ?? throw new InvalidOperationException("timed out waiting for the event"); Console.WriteLine($"received: {Encoding.UTF8.GetString(received.Content)} " + $"(channel={received.StompDestination?.PhysicalName})"); ``` ```ruby require "stomp" require "uri" require "timeout" uri = URI.parse(ENV.fetch("KUBEMQ_STOMP_URL", "tcp://localhost:61613")) hosts = [{ host: uri.host, port: uri.port || 61613, login: "kubemq", passcode: "" }] DESTINATION = "/topic/demo" # Events pattern → channel demo # 1. SUBSCRIBE FIRST — Events have no replay. inbox = Thread::Queue.new sub = Stomp::Client.new(hosts: hosts, connect_headers: { "accept-version" => "1.2", "host" => "/" }) sub.subscribe(DESTINATION, id: "demo", ack: "auto") { |msg| inbox << msg } sleep 0.3 # let the subscription register # 2. PUBLISH — SEND one event. pub = Stomp::Client.new(hosts: hosts, connect_headers: { "accept-version" => "1.2", "host" => "/" }) pub.publish(DESTINATION, '{"message":"hello"}', "content-type" => "application/json") pub.close # 3. RECEIVE. msg = Timeout.timeout(10) { inbox.pop } puts "received: #{msg.body} (destination=#{msg.headers['destination']})" sub.close ``` ```rust use std::time::Duration; use async_stomp::client::Connector; use async_stomp::{AckMode, FromServer, ToServer}; use futures::{SinkExt, StreamExt}; const DESTINATION: &str = "/topic/demo"; // Events pattern → channel demo fn host_port() -> (String, u16) { let url = std::env::var("KUBEMQ_STOMP_URL").unwrap_or_else(|_| "tcp://localhost:61613".into()); let hp = url.trim_start_matches("tcp://").trim_start_matches("tls://"); let mut parts = hp.splitn(2, ':'); let host = parts.next().unwrap_or("localhost").to_string(); let port = parts.next().and_then(|p| p.parse().ok()).unwrap_or(61613); (host, port) } #[tokio::main] async fn main() -> Result<(), Box> { let (host, port) = host_port(); // 1. SUBSCRIBE FIRST — Events have no replay. let mut sub = Connector::builder() .server(format!("{host}:{port}")) .virtualhost(&host) .connect() .await?; sub.send(ToServer::Subscribe { destination: DESTINATION.into(), id: "demo".into(), ack: Some(AckMode::Auto), }.into()) .await?; tokio::time::sleep(Duration::from_millis(300)).await; // let the subscription register // 2. PUBLISH — SEND one event. let mut publisher = Connector::builder() .server(format!("{host}:{port}")) .virtualhost(&host) .connect() .await?; publisher .send(ToServer::Send { destination: DESTINATION.into(), transaction: None, headers: Some(vec![("content-type".into(), "application/json".into())]), body: Some(br#"{"message":"hello"}"#.to_vec()), }.into()) .await?; publisher.send(ToServer::Disconnect { receipt: None }.into()).await?; // 3. RECEIVE. let frame = tokio::time::timeout(Duration::from_secs(10), sub.next()) .await? .ok_or("stream closed")??; if let FromServer::Message { body, destination, .. } = frame.content { let payload = String::from_utf8_lossy(&body.unwrap_or_default()).into_owned(); println!("received: {payload} (destination={destination})"); } Ok(()) } ``` ## Wildcard subscriptions [#wildcard-subscriptions] Wildcard filters are accepted on the **Events pattern only**, **on SUBSCRIBE only**, and use **the message broker's native wildcard syntax**: | Wildcard | Matches | | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `*` | exactly **one** segment, any position — `/topic/orders/*` matches `/topic/orders/eu` and `/topic/orders/us`, but not `/topic/orders/eu/west` | | `>` | the **tail**, and **must be the final** token — `/topic/orders/>` matches `/topic/orders/eu` and `/topic/orders/eu/west` | Delivery carries the **concrete matched channel**, not the subscription filter: a `/topic/orders/*` subscriber receiving on channel `orders.eu` gets `destination:/topic/orders/eu`. Route on the delivered `destination`, never on your filter string. **Wildcards are Events-only, subscribe-only, and use the broker's native syntax — there is no MQTT `+`/`#`.** `*` matches exactly one segment, `>` matches the final tail and must be the last token. There is **no** MQTT-style `+`/`#` translation — `+` and `#` are treated as literal segment characters, not wildcards. A wildcard on a SEND, or on any non-Events pattern (queues / store / RPC), is rejected with `ERROR "invalid destination"` and the connection closes. A literal `.` in a destination segment is **lossy** (`/topic/a.b` and `/topic/a/b` both map to channel `a.b`, and egress always re-emits the slash form) — prefer slashes, avoid literal dots. ## At-most-once delivery [#at-most-once-delivery] Events have no acknowledgement channel, so there is no NACK and no requeue. If a subscriber's per-subscriber output buffer (default 100) is full when an event arrives, the connector **drops that single delivery for that single subscriber** — the connection stays alive and **other** subscribers are unaffected. If you need every message guaranteed at least once, use [Queues](/connectors/stomp/how-to/queues) (acked, requeued) or [Events Store](/connectors/stomp/how-to/events-store) (persisted, replayable). ## Related [#related] # Protocol versions (/connectors/stomp/how-to/protocol-versions) The KubeMQ STOMP connector speaks **STOMP 1.0, 1.1, and 1.2**. The version is chosen during the CONNECT handshake from your `accept-version` header — the connector picks the **highest common** of `{1.0, 1.1, 1.2}`. A missing or empty `accept-version` defaults to **1.0**; if there is no common version the connector returns an `ERROR` and closes. **Use `accept-version:1.2`.** Every example sends `accept-version:1.2`. 1.2 is the only version that can escape `\r` (CR) in header values, uses the explicit `ack` token, and stamps the `subscription` header on MESSAGE frames. 1.0 and 1.1 are supported for compatibility with older clients. ## Version negotiation [#version-negotiation] * `accept-version` is **comma-separated, order-independent, and whitespace-tolerant** (`1.2, 1.1` and `1.1,1.2` both negotiate 1.2). * **Unknown tokens are ignored** (`1.0,9.9` → 1.0). * **No common version** → `ERROR` carrying `version:1.0,1.1,1.2` + close. The negotiated version is echoed back on the `CONNECTED` frame's `version` header and surfaces as `version` in the management API connection record. ## Feature matrix [#feature-matrix] | Feature | 1.0 | 1.1 | 1.2 | Notes | | ----------------------------------------- | :---------------------------: | :---------------------------: | :-------------------------------: | -------------------------------------------------------- | | Highest-common negotiation | Yes | Yes | Yes | missing/empty `accept-version` → **1.0** | | Header escaping | none | `\n`,`\c`,`\\` | `\n`,`\c`,`\\`,**`\r`** | applied only to non-CONNECT/STOMP frames once negotiated | | `\r` (CR) representable in a header value | No | No | Yes | 1.0/1.1 drop CR on egress | | Repeated header → first value wins | Yes | Yes | Yes | all occurrences preserved in arrival order | | SUBSCRIBE `id` required | No — auto `auto-N` | Yes | Yes | missing `id` on 1.1/1.2 → `invalid subscription` | | MESSAGE `subscription` header | omitted | Yes | Yes | identifies the matching subscription | | MESSAGE `ack` token (opaque UUID) | No | No | Yes | 1.2-only; **distinct from `message-id`** | | ACK/NACK correlation | `message-id` → oldest pending | `message-id` + `subscription` | `id:` | per-version, connection-scoped | | `client` / `client-individual` ack | Yes (lenient) | Yes | Yes | accepted on 1.0 too (ActiveMQ-style) | | NACK | Yes (lenient) | Yes | Yes | honored on 1.0 too | | UNSUBSCRIBE by `destination` (no `id`) | Yes (fallback) | No | No | 1.0-only leniency | | CRLF line endings tolerated | Yes | Yes | Yes | a trailing `\r` is stripped on read (all versions) | ## Header escaping per version [#header-escaping-per-version] Header escaping applies to **both the header name and value**, but **only on the negotiated 1.1/1.2 version and only for non-CONNECT/STOMP frames**. The handshake is exempt: **CONNECT and STOMP frames are never unescaped, and CONNECTED is never escaped**. **1.0 does no escaping at all.** | Escape sequence | Decodes to | 1.0 | 1.1 | 1.2 | | ------------------- | ---------- | :-----: | :-------------------------: | :-------: | | `\n` | LF | literal | Yes | Yes | | `\c` | `:` | literal | Yes | Yes | | `\\` | `\` | literal | Yes | Yes | | `\r` | CR | literal | **fatal** `malformed frame` | Yes | | any other (`\t`, …) | — | literal | **fatal** | **fatal** | *Columns are the STOMP version negotiated via the CONNECT `accept-version` header.* On 1.1, an `\r` escape (or any undefined escape) is a **fatal** `malformed frame`. A trailing lone backslash is fatal. A repeated header resolves to its **first** value (all occurrences are still preserved in arrival order). **CR/LF in a header value is silently dropped to 1.0/1.1 subscribers.** STOMP 1.1 has no escape for CR, and 1.0 has no escaping at all, so the egress representability guard drops any header it cannot serialize (rather than corrupting the frame) and the connection stays alive. **Use `accept-version:1.2` for any binary or special-character scenario, and keep CR/LF out of header values** — structured or multiline metadata belongs in the body. See [Destination mapping](/connectors/stomp/how-to/destination-mapping) for the full per-version representability table. ## SUBSCRIBE id rules [#subscribe-id-rules] | Version | `id` on SUBSCRIBE | Behavior | | ------------- | ----------------- | ---------------------------------------------------------------------------------- | | **1.0** | optional | auto-generated `auto-N` when absent | | **1.1 / 1.2** | **required** | missing/empty `id` → `ERROR "invalid subscription" / "id header required"` + close | ## Per-version ACK token source [#per-version-ack-token-source] The 1.2 `ack` token is an **opaque UUID** emitted on the MESSAGE frame, **distinct from `message-id`**, and the only correct way to ACK on 1.2. On 1.1 you ACK by `message-id` + `subscription`; on 1.0 by `message-id` alone, which resolves to the oldest pending delivery on the connection. | Version | MESSAGE carries | ACK/NACK by | | ------- | ----------------------------------------------- | ----------------------------- | | **1.2** | `subscription`, `ack`=UUID token, `message-id` | `id:` | | **1.1** | `subscription`, `message-id` (no `ack`) | `message-id` + `subscription` | | **1.0** | `message-id` only (no `subscription`, no `ack`) | `message-id` → oldest pending | See [Ack modes and receipts](/connectors/stomp/how-to/ack-modes-and-receipts) for the full ACK flow. ## 1.0 leniency (compatibility) [#10-leniency-compatibility] The connector applies ActiveMQ-style leniency on 1.0, beyond the strict 1.0 spec: * **Auto subscription ids** (`auto-N`) when SUBSCRIBE omits `id`. * **`client-individual` and `client` ack modes** accepted on 1.0 (not in the 1.0 spec). * **NACK** accepted and honored on 1.0. * **UNSUBSCRIBE by `destination`** (without `id`) — the connector removes the first matching subscription. These are conveniences for legacy clients. **Do not depend on them for portability** — prefer `accept-version:1.2`. ## Related [#related] # Queries (/connectors/stomp/how-to/queries) Queries are **request/reply RPC that returns a payload** over the STOMP connector. A client SENDs to a `/query/` destination and receives a single reply **MESSAGE** — carrying the responder's **response body and tags** — on a connection-local `/reply/...` subscription. A Query is the "execute this and give me the answer" half of KubeMQ RPC; the "execute this and confirm it worked" half (typically no body) is [Commands](/connectors/stomp/how-to/commands). Queries use the **exact same 3-step flow and `stomp-error` failure mode** as Commands — the only differences are the destination prefix (`/query/` vs `/command/`) and that a Query's reply carries a **response body + tags**. See [Commands](/connectors/stomp/how-to/commands) for the canonical write-up of the flow, the `reply-to` / `correlation-id` / `timeout` headers, and the three `stomp-error` failure shapes; this page focuses on the Query-specific reply payload. **STOMP is RPC-requester-only.** A STOMP client can **send** queries but cannot **respond** to them — the responder runs on the gRPC side, using a native KubeMQ SDK. A `SUBSCRIBE /query/...` is **hard-rejected** with `ERROR "cannot subscribe to RPC destinations"` and the connection closes. To answer queries, run a responder with the gRPC SDK (`SubscribeToQueries` + `SendQueryResponse`); the connector bridges your STOMP SEND to it. ## Overview [#overview] The `/query/` prefix selects the Queries pattern; `/queries/` is an accepted alias that egress canonicalizes back to `/query/...` — **lead with `/query/` in your code**. The remaining segments are slash-to-dot joined into the KubeMQ channel: `/query/users/lookup` → channel `users.lookup`. `/reply/` is the connection-local reply destination — no alias, authz-exempt, never an array subscription. | Operation | STOMP action | KubeMQ mapping | | -------------------- | ----------------------------------- | --------------------------------------------------- | | Subscribe to replies | `SUBSCRIBE /reply/` | connection-local inbox (no array, no authz, no ack) | | Send a query | `SEND /query/` with `reply-to` | `SendQuery` (dispatched to the gRPC-side responder) | | Receive the answer | `MESSAGE` on `/reply/` | the responder's **response body + tags** | ## How it works [#how-it-works] The flow is identical to [Commands](/connectors/stomp/how-to/commands): subscribe to a reply inbox first, SEND the query with a required `reply-to` header, and the answer arrives back as a MESSAGE on that inbox — except a Query's reply carries the responder's **response body**, and its response **tags** surface as MESSAGE **headers** (including `content-type` via the `stomp.*` mapping). *A Query reply carries a response payload; the responder runs on the gRPC side and the connector bridges the two.* ## The 3-step flow with a response payload [#the-3-step-flow-with-a-response-payload] 1. **SUBSCRIBE to `/reply/` first** (connection-local: no array, no authz, no ack tracking). It must be active on the **same connection** before you SEND. 2. **SEND to `/query/`** with the **required** `reply-to` (the `/reply/` from step 1, same connection — missing or inactive → `ERROR "reply-to subscription required"` and close), an optional `correlation-id` (echoed back only when set), and an optional `timeout` in **milliseconds** (effective `min(timeout, server cap)`, default 30000). 3. **The reply MESSAGE on `/reply/`** carries: * the responder's **response body** (the payload — this is what distinguishes a Query from a Command), * the responder's **response tags**, surfaced as MESSAGE **headers** (including `content-type`), * `correlation-id` echoed only when the request set it, * `destination` = the reply-to, a fresh `message-id`, and `subscription` = the reply sub id (1.1/1.2 only). ## Failures are a MESSAGE with a `stomp-error` header [#failures-are-a-message-with-a-stomp-error-header] The failure mode is **identical to Commands**. **RPC failures are a MESSAGE + `stomp-error` header, NOT an ERROR frame.** A timeout, a logical error, or a dropped reply arrives as **data on the `/reply/` subscription**, and the **connection stays open**. Detect a failure by the **presence of the `stomp-error` header** — not by an ERROR frame, and not by an empty body. For a Query this is especially relevant: a Query reply normally has a body, so a **logical error** carries `stomp-error` **and** the responder's body + tags; only a **transport error / timeout** (`stomp-error` + empty body, `context deadline exceeded` sanitized to `timeout`) and a **nil response** (`stomp-error:"no response"` + empty body) are empty-bodied. Only `reply-to` violations and pending-cap overflow close the connection. ## Send a query [#send-a-query] Each example performs the 3-step requester flow against a Queries responder running on the gRPC side. It subscribes to a reply inbox, SENDs the query with `reply-to` + `correlation-id` + `timeout`, then reads the response body (checking the `stomp-error` header first to distinguish success from failure). Every client reads the connector endpoint from `KUBEMQ_STOMP_URL` (default `tcp://localhost:61613`). ```go package main import ( "fmt" "log" "net/url" "os" "time" "github.com/go-stomp/stomp/v3" ) const ( replyDest = "/reply/q1" // connection-local reply inbox queryDest = "/query/lookup" // Queries pattern → channel lookup ) func addr() (network, host string) { u, _ := url.Parse(os.Getenv("KUBEMQ_STOMP_URL")) if u == nil || u.Host == "" { return "tcp", "localhost:61613" } return "tcp", u.Host } func main() { network, host := addr() conn, err := stomp.Dial(network, host) if err != nil { log.Fatalf("dial: %v", err) } defer func() { _ = conn.Disconnect() }() // Step 1: SUBSCRIBE /reply/q1 FIRST (connection-local). reply, err := conn.Subscribe(replyDest, stomp.AckAuto) if err != nil { log.Fatalf("subscribe reply: %v", err) } // Step 2: SEND /query/lookup with reply-to + correlation-id + timeout(ms). if err := conn.Send(queryDest, "application/json", []byte(`{"key":"user-42"}`), stomp.SendOpt.Header("reply-to", replyDest), stomp.SendOpt.Header("correlation-id", "abc123"), stomp.SendOpt.Header("timeout", "5000"), ); err != nil { log.Fatalf("send query: %v", err) } // Step 3: receive the response body on /reply/q1. select { case msg := <-reply.C: if se := msg.Header.Get("stomp-error"); se != "" { log.Fatalf("query failed: stomp-error=%q (connection stays open)", se) } fmt.Printf("query answered: %s (content-type=%s)\n", string(msg.Body), msg.Header.Get("content-type")) case <-time.After(10 * time.Second): log.Fatal("timed out waiting for the reply") } } ``` ```python import os import queue from urllib.parse import urlparse import stomp REPLY_DEST = "/reply/q1" # connection-local reply inbox QUERY_DEST = "/query/lookup" # Queries pattern → channel lookup def endpoint() -> tuple[str, int]: parsed = urlparse(os.environ.get("KUBEMQ_STOMP_URL", "tcp://localhost:61613")) return parsed.hostname or "localhost", parsed.port or 61613 class Replies(stomp.ConnectionListener): def __init__(self) -> None: self.inbox: queue.Queue = queue.Queue() def on_message(self, frame) -> None: self.inbox.put(frame) def main() -> None: host, port = endpoint() replies = Replies() conn = stomp.Connection12([(host, port)], heartbeats=(10000, 10000)) conn.set_listener("r", replies) conn.connect(wait=True) # Step 1: SUBSCRIBE /reply/q1 FIRST (connection-local). conn.subscribe(REPLY_DEST, id="q1", ack="auto") # Step 2: SEND /query/lookup with reply-to + correlation-id + timeout(ms). conn.send(QUERY_DEST, '{"key":"user-42"}', content_type="application/json", headers={"reply-to": REPLY_DEST, "correlation-id": "abc123", "timeout": "5000"}) # Step 3: receive the response body on /reply/q1. frame = replies.inbox.get(timeout=10) if frame.headers.get("stomp-error"): raise SystemExit(f"query failed: {frame.headers['stomp-error']!r}") print(f"query answered: {frame.body} (content-type={frame.headers.get('content-type')})") conn.disconnect() if __name__ == "__main__": main() ``` ```java import java.lang.reflect.Type; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; import org.springframework.messaging.simp.stomp.StompHeaders; import org.springframework.messaging.simp.stomp.StompSession; import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; import org.springframework.messaging.simp.stomp.ReactorNettyTcpStompClient; public final class Main { private static final String REPLY_DEST = "/reply/q1"; // connection-local reply inbox private static final String QUERY_DEST = "/query/lookup"; // Queries pattern → channel lookup public static void main(String[] args) throws Exception { String url = System.getenv().getOrDefault("KUBEMQ_STOMP_URL", "tcp://localhost:61613"); java.net.URI u = java.net.URI.create(url); ReactorNettyTcpStompClient client = new ReactorNettyTcpStompClient(u.getHost(), u.getPort() > 0 ? u.getPort() : 61613); BlockingQueue inbox = new ArrayBlockingQueue<>(1); StompSession conn = client.connectAsync(new StompSessionHandlerAdapter() {}).get(10, TimeUnit.SECONDS); // Step 1: SUBSCRIBE /reply/q1 FIRST (connection-local). StompHeaders replyHeaders = new StompHeaders(); replyHeaders.setDestination(REPLY_DEST); replyHeaders.setId("q1"); replyHeaders.setAck("auto"); conn.subscribe(replyHeaders, new StompSessionHandlerAdapter() { @Override public Type getPayloadType(StompHeaders headers) { return String.class; } @Override public void handleFrame(StompHeaders headers, Object payload) { inbox.add(new Object[] { headers, payload }); } }); // Step 2: SEND /query/lookup with reply-to + correlation-id + timeout(ms). StompHeaders q = new StompHeaders(); q.setDestination(QUERY_DEST); q.add("content-type", "application/json"); q.add("reply-to", REPLY_DEST); q.add("correlation-id", "abc123"); q.add("timeout", "5000"); conn.send(q, "{\"key\":\"user-42\"}".getBytes()); // Step 3: receive the response body on /reply/q1. Object[] reply = inbox.poll(10, TimeUnit.SECONDS); if (reply == null) throw new IllegalStateException("timed out waiting for the reply"); StompHeaders headers = (StompHeaders) reply[0]; if (headers.getFirst("stomp-error") != null) { throw new IllegalStateException("query failed: " + headers.getFirst("stomp-error")); } System.out.printf("query answered: %s (content-type=%s)%n", reply[1], headers.getFirst("content-type")); conn.disconnect(); client.stop(); } } ``` ```typescript import { connect, type Client } from "stompit"; const REPLY_DEST = "/reply/q1"; // connection-local reply inbox const QUERY_DEST = "/query/lookup"; // Queries pattern → channel lookup function endpoint(): { host: string; port: number } { const url = new URL(process.env["KUBEMQ_STOMP_URL"] ?? "tcp://localhost:61613"); return { host: url.hostname, port: Number(url.port) || 61613 }; } function open(): Promise { const { host, port } = endpoint(); return new Promise((resolve, reject) => { connect({ host, port, connectHeaders: { "accept-version": "1.2", "heart-beat": "10000,10000" } }, (err, client) => (err ? reject(err) : resolve(client))); }); } async function main(): Promise { const conn = await open(); // Step 1: SUBSCRIBE /reply/q1 FIRST (connection-local). const reply = new Promise<{ headers: Record; body: string }>((resolve, reject) => { const timer = setTimeout(() => reject(new Error("timed out waiting for the reply")), 15_000); conn.subscribe({ destination: REPLY_DEST, ack: "auto" }, (err, message) => { if (err) return reject(err); message.readString("utf-8", (readErr, body) => { clearTimeout(timer); if (readErr) return reject(readErr); resolve({ headers: message.headers as Record, body: body ?? "" }); }); }); }); // Step 2: SEND /query/lookup with reply-to + correlation-id + timeout(ms). const frame = conn.send({ destination: QUERY_DEST, "content-type": "application/json", "reply-to": REPLY_DEST, "correlation-id": "abc123", timeout: "5000", }); frame.write(JSON.stringify({ key: "user-42" })); frame.end(); // Step 3: inspect the response body on /reply/q1. const { headers, body } = await reply; if (headers["stomp-error"]) throw new Error(`query failed: ${headers["stomp-error"]}`); console.log(`query answered: ${body} (content-type=${headers["content-type"]})`); await new Promise((r) => conn.disconnect(() => r())); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using System.Text; using Stomp.Net; var url = Environment.GetEnvironmentVariable("KUBEMQ_STOMP_URL") ?? "tcp://localhost:61613"; var uri = new Uri(url); const string replyDest = "/reply/q1"; // connection-local reply inbox const string queryDest = "/query/lookup"; // Queries pattern → channel lookup string brokerUri = $"stomp:tcp://{uri.Host}:{(uri.Port > 0 ? uri.Port : 61613)}"; var factory = new ConnectionFactory(brokerUri) { UserName = "kubemq", Password = "" }; using var conn = factory.CreateConnection(); conn.Start(); using var session = conn.CreateSession(AcknowledgementMode.AutoAcknowledge); // Step 1: SUBSCRIBE /reply/q1 FIRST (connection-local). using var replyConsumer = session.CreateConsumer(session.GetQueue(replyDest)); // Step 2: SEND /query/lookup with reply-to + correlation-id + timeout(ms). using var producer = session.CreateProducer(session.GetQueue(queryDest)); var q = producer.CreateBytesMessage(Encoding.UTF8.GetBytes("{\"key\":\"user-42\"}")); q.StompType = "application/json"; q.Headers.SetValue("reply-to", replyDest); q.Headers.SetValue("correlation-id", "abc123"); q.Headers.SetValue("timeout", "5000"); producer.Send(q); // Step 3: receive the response body on /reply/q1. var reply = replyConsumer.Receive(TimeSpan.FromSeconds(10)) ?? throw new InvalidOperationException("timed out waiting for the reply"); var stompError = reply.Headers.GetValue("stomp-error"); if (!string.IsNullOrEmpty(stompError)) throw new InvalidOperationException($"query failed: {stompError}"); Console.WriteLine($"query answered: {Encoding.UTF8.GetString(reply.Content)} " + $"(content-type={reply.Headers.GetValue("content-type")})"); ``` ```ruby require "stomp" require "uri" require "timeout" uri = URI.parse(ENV.fetch("KUBEMQ_STOMP_URL", "tcp://localhost:61613")) hosts = [{ host: uri.host, port: uri.port || 61613, login: "kubemq", passcode: "" }] REPLY_DEST = "/reply/q1" # connection-local reply inbox QUERY_DEST = "/query/lookup" # Queries pattern → channel lookup conn = Stomp::Client.new(hosts: hosts, connect_headers: { "accept-version" => "1.2", "host" => "/" }) # Step 1: SUBSCRIBE /reply/q1 FIRST (connection-local). inbox = Thread::Queue.new conn.subscribe(REPLY_DEST, id: "q1", ack: "auto") { |msg| inbox << msg } # Step 2: SEND /query/lookup with reply-to + correlation-id + timeout(ms). conn.publish(QUERY_DEST, '{"key":"user-42"}', "content-type" => "application/json", "reply-to" => REPLY_DEST, "correlation-id" => "abc123", "timeout" => "5000") # Step 3: receive the response body on /reply/q1. reply = Timeout.timeout(10) { inbox.pop } raise "query failed: #{reply.headers['stomp-error']}" if reply.headers["stomp-error"] puts "query answered: #{reply.body} (content-type=#{reply.headers['content-type']})" conn.close ``` ```rust use std::time::Duration; use async_stomp::client::Connector; use async_stomp::{AckMode, FromServer, ToServer}; use futures::{SinkExt, StreamExt}; const REPLY_DEST: &str = "/reply/q1"; // connection-local reply inbox const QUERY_DEST: &str = "/query/lookup"; // Queries pattern → channel lookup fn host_port() -> (String, u16) { let url = std::env::var("KUBEMQ_STOMP_URL").unwrap_or_else(|_| "tcp://localhost:61613".into()); let hp = url.trim_start_matches("tcp://").trim_start_matches("tls://"); let mut parts = hp.splitn(2, ':'); let host = parts.next().unwrap_or("localhost").to_string(); let port = parts.next().and_then(|p| p.parse().ok()).unwrap_or(61613); (host, port) } #[tokio::main] async fn main() -> Result<(), Box> { let (host, port) = host_port(); let mut conn = Connector::builder() .server(format!("{host}:{port}")) .virtualhost(&host) .connect() .await?; // Step 1: SUBSCRIBE /reply/q1 FIRST (connection-local). conn.send(ToServer::Subscribe { destination: REPLY_DEST.into(), id: "q1".into(), ack: Some(AckMode::Auto), }.into()) .await?; // Step 2: SEND /query/lookup with reply-to + correlation-id + timeout(ms). conn.send(ToServer::Send { destination: QUERY_DEST.into(), transaction: None, headers: Some(vec![ ("content-type".into(), "application/json".into()), ("reply-to".into(), REPLY_DEST.into()), ("correlation-id".into(), "abc123".into()), ("timeout".into(), "5000".into()), ]), body: Some(br#"{"key":"user-42"}"#.to_vec()), }.into()) .await?; // Step 3: receive the response body on /reply/q1. let frame = tokio::time::timeout(Duration::from_secs(10), conn.next()) .await? .ok_or("stream closed")??; if let FromServer::Message { headers, body, .. } = frame.content { if let Some((_, err)) = headers.iter().find(|(k, _)| k == "stomp-error") { return Err(format!("query failed: {err}").into()); } let payload = String::from_utf8_lossy(&body.unwrap_or_default()).into_owned(); println!("query answered: {payload}"); } Ok(()) } ``` ## Where the responder lives [#where-the-responder-lives] Because a STOMP client cannot be a responder, the Queries **responder must run on the gRPC side** — a process using a native KubeMQ SDK that does `SubscribeToQueries`, computes the response, and replies via `SendQueryResponse` (the response **body + tags** are what the STOMP requester receives). The connector bridges the STOMP requester's SEND to that responder over the broker, the same path the gRPC connector uses. In-flight RPCs are bounded by a pending cap (default 1024); overflow → `ERROR "too many pending requests"` and the connection closes. ## Related [#related] # Queues (/connectors/stomp/how-to/queues) Queues are durable, **competing-consumer** work queues over the STOMP connector. SEND to a destination prefixed with `/queue/` and the connector routes the message to the KubeMQ **Queues** pattern; SUBSCRIBE to the same destination and each message is delivered to **exactly one** consumer (round-robin across the subscribers), acknowledged, and **at-least-once**. ## Overview [#overview] The `/queue/` prefix selects the Queues pattern; `/queues/` is an accepted alias that egress always canonicalizes back to the primary `/queue/...` form — **lead with `/queue/` in your code**. The remaining destination segments are slash-to-dot joined into the KubeMQ channel: `/queue/jobs/email` → channel `jobs.email`. A producer SENDs a message; a consumer SUBSCRIBEs with an `ack` header and ACKs each delivery. Many consumers can subscribe to the same `/queue/...` channel: the connector hands each message to **one** of them (round-robin competing-consumer move semantics — not fan-out). Add consumers to scale throughput; each message is still processed once. | Operation | STOMP action | KubeMQ mapping | | ------------ | ------------------------------------------ | ------------------------------------- | | Produce | `SEND /queue/` (optional `receipt`) | `SendQueueMessage` | | Consume | `SUBSCRIBE /queue/` with an `ack` mode | Credit-driven `Get` poll, round-robin | | Acknowledge | `ACK id:` | `AckRange` — message removed | | Negative-ack | `NACK id:` | `NAckRange` — requeued to the tail | The RECEIPT on a SEND fires when KubeMQ **accepts** the message — not when a consumer receives it. ## How it works [#how-it-works] A producer enqueues messages; competing consumers subscribe with an ack mode, receive a message each (round-robin), do the work, and ACK — the message is removed from the queue. An un-ACKed message is requeued (after the ack-timeout, or immediately on disconnect) and redelivered, never lost. *Each queued message is delivered to exactly one competing consumer; an `ACK` removes the message, a `NACK` (or ack-timeout) requeues it to the tail.* ## The three ack modes [#the-three-ack-modes] A `/queue/...` SUBSCRIBE carries an `ack` header; absent → `auto`. The connector accepts exactly three values; anything else → `ERROR "unknown ack mode"` and the connection closes. | `ack` mode | Client action | KubeMQ downstream | | --------------------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `auto` *(default)* | none | the connector immediately acks each delivery; a NACK/requeue happens only if the output buffer is full — **never dropped** | | `client-individual` | ACK/NACK **one** message by its token | one `AckRange`/`NAckRange` for that delivery | | `client` *(cumulative)* | ACK/NACK that message **and all earlier** on the subscription | grouped by transaction, one `AckRange`/`NAckRange` per transaction | **`client-individual` is the recommended reliable default** — every delivery is ACKed (or NACKed) on its own, the simplest at-least-once mental model. The ACK/NACK token source is version-dependent: STOMP 1.2 uses the opaque `ack` header (a UUID distinct from `message-id`); 1.1 uses `message-id` + `subscription`; 1.0 uses `message-id`. The examples below use 1.2. ## Produce and consume [#produce-and-consume] Each example SENDs a batch of jobs to a `/queue/...` destination, then subscribes with `ack:client-individual` and ACKs each delivery by its `ack` token. On the happy path every message is ACKed, so none are redelivered. Every client reads the connector endpoint from `KUBEMQ_STOMP_URL` (default `tcp://localhost:61613`). ```go package main import ( "fmt" "log" "net/url" "os" "time" "github.com/go-stomp/stomp/v3" ) const destination = "/queue/jobs/email" // Queues pattern → channel jobs.email const count = 5 func addr() (network, host string) { u, _ := url.Parse(os.Getenv("KUBEMQ_STOMP_URL")) if u == nil || u.Host == "" { return "tcp", "localhost:61613" } return "tcp", u.Host } func main() { network, host := addr() // 1. Produce — SEND `count` jobs to the queue. pub, err := stomp.Dial(network, host) if err != nil { log.Fatalf("dial: %v", err) } for i := 1; i <= count; i++ { body := fmt.Sprintf("job-%d", i) if err := pub.Send(destination, "text/plain", []byte(body)); err != nil { log.Fatalf("send: %v", err) } } _ = pub.Disconnect() // 2. Consume — SUBSCRIBE ack:client-individual; ACK each by its 1.2 token. sub, err := stomp.Dial(network, host) if err != nil { log.Fatalf("dial: %v", err) } defer func() { _ = sub.Disconnect() }() subscription, err := sub.Subscribe(destination, stomp.AckClientIndividual) if err != nil { log.Fatalf("subscribe: %v", err) } for got := 0; got < count; { select { case msg := <-subscription.C: if msg.Err != nil { log.Fatalf("receive: %v", msg.Err) } if msg.Header.Get("redelivered") == "true" { log.Fatalf("unexpected redelivery of %q", string(msg.Body)) } if err := sub.Ack(msg); err != nil { // ACK by the 1.2 ack token log.Fatalf("ack: %v", err) } got++ case <-time.After(10 * time.Second): log.Fatal("timed out draining the queue") } } fmt.Printf("drained and acked %d jobs\n", count) } ``` ```python import os import queue from urllib.parse import urlparse import stomp DESTINATION = "/queue/jobs/email" # Queues pattern → channel jobs.email COUNT = 5 def endpoint() -> tuple[str, int]: parsed = urlparse(os.environ.get("KUBEMQ_STOMP_URL", "tcp://localhost:61613")) return parsed.hostname or "localhost", parsed.port or 61613 class Consumer(stomp.ConnectionListener): def __init__(self) -> None: self.inbox: queue.Queue = queue.Queue() def on_message(self, frame) -> None: self.inbox.put(frame) def main() -> None: host, port = endpoint() # 1. Produce — SEND `count` jobs to the queue. pub = stomp.Connection12([(host, port)], heartbeats=(10000, 10000)) pub.connect(wait=True) for i in range(1, COUNT + 1): pub.send(DESTINATION, f"job-{i}", content_type="text/plain") pub.disconnect() # 2. Consume — SUBSCRIBE ack:client-individual; ACK each by its 1.2 token. consumer = Consumer() sub = stomp.Connection12([(host, port)], heartbeats=(10000, 10000)) sub.set_listener("c", consumer) sub.connect(wait=True) sub.subscribe(DESTINATION, id="jobs", ack="client-individual") for _ in range(COUNT): frame = consumer.inbox.get(timeout=10) if frame.headers.get("redelivered") == "true": raise SystemExit(f"unexpected redelivery: {frame.body!r}") sub.ack(frame.headers["ack"]) # ACK by the 1.2 ack token sub.disconnect() print(f"drained and acked {COUNT} jobs") if __name__ == "__main__": main() ``` ```java import java.lang.reflect.Type; import java.nio.charset.StandardCharsets; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; import org.springframework.messaging.simp.stomp.StompHeaders; import org.springframework.messaging.simp.stomp.StompSession; import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; import org.springframework.messaging.tcp.reactor.ReactorNettyTcpClient; import org.springframework.messaging.simp.stomp.ReactorNettyTcpStompClient; public final class Main { private static final String DESTINATION = "/queue/jobs/email"; // → channel jobs.email private static final int COUNT = 5; public static void main(String[] args) throws Exception { String url = System.getenv().getOrDefault("KUBEMQ_STOMP_URL", "tcp://localhost:61613"); java.net.URI u = java.net.URI.create(url); ReactorNettyTcpStompClient client = new ReactorNettyTcpStompClient(u.getHost(), u.getPort() > 0 ? u.getPort() : 61613); // 1. Produce — SEND `count` jobs to the queue. StompSession pub = client.connectAsync(new StompSessionHandlerAdapter() {}).get(10, TimeUnit.SECONDS); for (int i = 1; i <= COUNT; i++) { StompHeaders h = new StompHeaders(); h.setDestination(DESTINATION); h.add("content-type", "text/plain"); pub.send(h, ("job-" + i).getBytes(StandardCharsets.UTF_8)); } Thread.sleep(500); pub.disconnect(); // 2. Consume — SUBSCRIBE ack:client-individual; ACK each delivery. BlockingQueue inbox = new ArrayBlockingQueue<>(COUNT); StompSession sub = client.connectAsync(new StompSessionHandlerAdapter() {}).get(10, TimeUnit.SECONDS); StompHeaders subHeaders = new StompHeaders(); subHeaders.setDestination(DESTINATION); subHeaders.setId("jobs"); subHeaders.setAck("client-individual"); sub.subscribe(subHeaders, new StompSessionHandlerAdapter() { @Override public Type getPayloadType(StompHeaders headers) { return byte[].class; } @Override public void handleFrame(StompHeaders headers, Object payload) { inbox.add(headers); } }); for (int i = 0; i < COUNT; i++) { StompHeaders headers = inbox.poll(10, TimeUnit.SECONDS); if (headers == null) throw new IllegalStateException("timed out draining the queue"); if ("true".equals(headers.getFirst("redelivered"))) { throw new IllegalStateException("unexpected redelivery"); } sub.acknowledge(headers.getAck(), true); // ACK by the 1.2 ack token } sub.disconnect(); client.stop(); System.out.printf("drained and acked %d jobs%n", COUNT); } } ``` ```typescript import { connect, type Client, type Channel } from "stompit"; const DESTINATION = "/queue/jobs/email"; // Queues pattern → channel jobs.email const COUNT = 5; function endpoint(): { host: string; port: number } { const url = new URL(process.env["KUBEMQ_STOMP_URL"] ?? "tcp://localhost:61613"); return { host: url.hostname, port: Number(url.port) || 61613 }; } function open(): Promise { const { host, port } = endpoint(); return new Promise((resolve, reject) => { connect({ host, port, connectHeaders: { "accept-version": "1.2", "heart-beat": "10000,10000" } }, (err, client) => (err ? reject(err) : resolve(client))); }); } async function main(): Promise { // 1. Produce — SEND `count` jobs to the queue. const pub = await open(); for (let i = 1; i <= COUNT; i++) { const frame = pub.send({ destination: DESTINATION, "content-type": "text/plain" }); frame.write(`job-${i}`); frame.end(); } await new Promise((r) => pub.disconnect(() => r())); // 2. Consume — SUBSCRIBE ack:client-individual; ACK each delivery. const sub = await open(); let got = 0; await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error("timed out draining the queue")), 15_000); sub.subscribe({ destination: DESTINATION, ack: "client-individual" }, (err, message) => { if (err) return reject(err); if (message.headers["redelivered"] === "true") return reject(new Error("unexpected redelivery")); message.readString("utf-8", (readErr) => { if (readErr) return reject(readErr); sub.ack(message); // ACK by the 1.2 ack token (stompit tracks it on the frame) if (++got === COUNT) { clearTimeout(timer); resolve(); } }); }); }); await new Promise((r) => sub.disconnect(() => r())); console.log(`drained and acked ${COUNT} jobs`); } main().catch((err) => { console.error(err); process.exit(1); }); ``` ```csharp using System.Text; using Stomp.Net; var url = Environment.GetEnvironmentVariable("KUBEMQ_STOMP_URL") ?? "tcp://localhost:61613"; var uri = new Uri(url); const string destination = "/queue/jobs/email"; // Queues pattern → channel jobs.email const int count = 5; string brokerUri = $"stomp:tcp://{uri.Host}:{(uri.Port > 0 ? uri.Port : 61613)}"; var factory = new ConnectionFactory(brokerUri) { UserName = "kubemq", Password = "" }; // 1. Produce — SEND `count` jobs to the queue. using (var pubConn = factory.CreateConnection()) { pubConn.Start(); using var session = pubConn.CreateSession(AcknowledgementMode.AutoAcknowledge); using var producer = session.CreateProducer(session.GetQueue(destination)); for (var i = 1; i <= count; i++) { var msg = producer.CreateBytesMessage(Encoding.UTF8.GetBytes($"job-{i}")); msg.StompType = "text/plain"; producer.Send(msg); } } // 2. Consume — SUBSCRIBE client-individual; Acknowledge() each delivery. using var conn = factory.CreateConnection(); conn.Start(); using var consumeSession = conn.CreateSession(AcknowledgementMode.IndividualAcknowledge); using var consumer = consumeSession.CreateConsumer(consumeSession.GetQueue(destination)); for (var i = 0; i < count; i++) { var msg = consumer.Receive(TimeSpan.FromSeconds(10)) ?? throw new InvalidOperationException("timed out draining the queue"); if (msg.Headers.GetValue("redelivered") == "true") throw new InvalidOperationException("unexpected redelivery"); msg.Acknowledge(); // ACK by the 1.2 ack token } Console.WriteLine($"drained and acked {count} jobs"); ``` ```ruby require "stomp" require "uri" require "timeout" uri = URI.parse(ENV.fetch("KUBEMQ_STOMP_URL", "tcp://localhost:61613")) hosts = [{ host: uri.host, port: uri.port || 61613, login: "kubemq", passcode: "" }] DESTINATION = "/queue/jobs/email" # Queues pattern → channel jobs.email COUNT = 5 # 1. Produce — SEND `count` jobs to the queue. pub = Stomp::Client.new(hosts: hosts, connect_headers: { "accept-version" => "1.2", "host" => "/" }) (1..COUNT).each { |i| pub.publish(DESTINATION, "job-#{i}", "content-type" => "text/plain") } pub.close # 2. Consume — SUBSCRIBE ack:client-individual; ACK each delivery by its 1.2 token. inbox = Thread::Queue.new sub = Stomp::Client.new(hosts: hosts, connect_headers: { "accept-version" => "1.2", "host" => "/" }) sub.subscribe(DESTINATION, id: "jobs", ack: "client-individual") { |msg| inbox << msg } COUNT.times do msg = Timeout.timeout(10) { inbox.pop } raise "unexpected redelivery" if msg.headers["redelivered"] == "true" sub.acknowledge(msg) # ACK by the 1.2 ack token end sub.close puts "drained and acked #{COUNT} jobs" ``` ```rust use std::time::Duration; use async_stomp::client::Connector; use async_stomp::{AckMode, FromServer, ToServer}; use futures::{SinkExt, StreamExt}; const DESTINATION: &str = "/queue/jobs/email"; // Queues pattern → channel jobs.email const COUNT: usize = 5; fn host_port() -> (String, u16) { let url = std::env::var("KUBEMQ_STOMP_URL").unwrap_or_else(|_| "tcp://localhost:61613".into()); let hp = url.trim_start_matches("tcp://").trim_start_matches("tls://"); let mut parts = hp.splitn(2, ':'); let host = parts.next().unwrap_or("localhost").to_string(); let port = parts.next().and_then(|p| p.parse().ok()).unwrap_or(61613); (host, port) } #[tokio::main] async fn main() -> Result<(), Box> { let (host, port) = host_port(); // 1. Produce — SEND `count` jobs to the queue. let mut pub_conn = Connector::builder() .server(format!("{host}:{port}")) .virtualhost(&host) .connect() .await?; for i in 1..=COUNT { pub_conn .send(ToServer::Send { destination: DESTINATION.into(), transaction: None, headers: Some(vec![("content-type".into(), "text/plain".into())]), body: Some(format!("job-{i}").into_bytes()), }.into()) .await?; } pub_conn.send(ToServer::Disconnect { receipt: None }.into()).await?; // 2. Consume — SUBSCRIBE ack:client-individual; ACK each by its 1.2 token. let mut sub = Connector::builder() .server(format!("{host}:{port}")) .virtualhost(&host) .connect() .await?; sub.send(ToServer::Subscribe { destination: DESTINATION.into(), id: "jobs".into(), ack: Some(AckMode::ClientIndividual), }.into()) .await?; let mut got = 0; while got < COUNT { let frame = tokio::time::timeout(Duration::from_secs(10), sub.next()) .await? .ok_or("stream closed")??; if let FromServer::Message { headers, .. } = frame.content { let ack_token = headers.iter().find(|(k, _)| k == "ack").map(|(_, v)| v.clone()); if let Some(token) = ack_token { sub.send(ToServer::Ack { id: token, transaction: None }.into()).await?; } got += 1; } } println!("drained and acked {COUNT} jobs"); Ok(()) } ``` ## Delivery guarantees [#delivery-guarantees] Queues are **at-least-once** — an un-ACKed delivery is **requeued** and redelivered, never lost. Two things trigger a requeue: * **Ack-timeout (default 30 s).** If a `client-individual` / `client` delivery is not ACKed within the ack-timeout, a 1-second sweeper **NACKs (requeues)** it. The connector does **not** disconnect the client; a late ACK after expiry is silently ignored. * **Disconnect.** A consumer that disconnects mid-stream with un-ACKed deliveries has all of them NACKed and requeued. Because dupes are tolerated and never lost, **design consumers to be idempotent**. Redelivery surfaces **only** via the `redelivered:true` MESSAGE header (broker `ReceiveCount > 1`). There is **no STOMP-level dead-letter queue or redelivery-limit knob** — `maxReceiveCount` / DLQ is broker queue-channel config, not a STOMP feature. **SEND closes, SUBSCRIBE gates when the broker is not ready.** If the broker is not ready, a `SEND` returns `ERROR "broker not ready"` and the connection closes (SENDs do not buffer — reconnect and retry). A `SUBSCRIBE` is **gated** instead: accepted and transparently activated when the broker becomes ready (the RECEIPT still fires on acceptance). ## Queue MESSAGE frame fields [#queue-message-frame-fields] A delivered queue MESSAGE carries: `destination` (always the canonical `/queue/...` form), `message-id` (broker-supplied), `subscription` (**1.1/1.2 only**, omitted on 1.0), an `ack` token (**1.2-only** opaque UUID, distinct from `message-id`), `redelivered:true` when redelivered, custom headers + `content-type` via the `stomp.*` egress mapping, and the body. ## Related [#related] # Cache Invalidation (/learn/events/scenarios/cache-invalidation) ## Architecture [#architecture] When data changes in the source-of-truth service, a cache invalidation event is published. All services with local caches subscribe and evict stale entries in real time. *One invalidation event fans out to every cache listener; each evicts its own stale entries.* ## Implementation [#implementation] ### Cache Invalidation Publisher [#cache-invalidation-publisher] When a product is updated, publish an invalidation event with the affected cache keys. ```go title="cache_invalidation_publisher.go" package main import ( "context" "encoding/json" "log" "github.com/kubemq-io/kubemq-go/v2" ) type CacheInvalidation struct { Entity string `json:"entity"` Keys []string `json:"keys"` Action string `json:"action"` } func invalidateCache(ctx context.Context, client *kubemq.Client, inv CacheInvalidation) error { body, _ := json.Marshal(inv) return client.SendEvent(ctx, kubemq.NewEvent(). SetChannel("cache.invalidate."+inv.Entity). SetMetadata("cache.invalidate"). SetBody(body). SetTags(map[string]string{"entity": inv.Entity, "action": inv.Action}), ) } func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() err = invalidateCache(ctx, client, CacheInvalidation{ Entity: "products", Keys: []string{"product:SKU-100", "product:SKU-101"}, Action: "update", }) if err != nil { log.Fatal(err) } log.Println("Cache invalidation event published") } ``` ```python title="cache_invalidation_publisher.py" import json from kubemq.pubsub import Client as PubSubClient from kubemq.pubsub import EventMessage client = PubSubClient(address="localhost:50000") invalidation = { "entity": "products", "keys": ["product:SKU-100", "product:SKU-101"], "action": "update", } client.send_event( EventMessage( channel=f"cache.invalidate.{invalidation['entity']}", metadata="cache.invalidate", body=json.dumps(invalidation).encode("utf-8"), tags={"entity": invalidation["entity"], "action": invalidation["action"]}, ) ) print("Cache invalidation event published") client.close() ``` ```javascript title="cache_invalidation_publisher.js" import { KubeMQClient, createEventMessage } from "kubemq-js"; const client = await KubeMQClient.create({ address: "localhost:50000", clientId: "cache-invalidator", }); const invalidation = { entity: "products", keys: ["product:SKU-100", "product:SKU-101"], action: "update", }; await client.sendEvent( createEventMessage({ channel: `cache.invalidate.${invalidation.entity}`, metadata: "cache.invalidate", body: JSON.stringify(invalidation), tags: { entity: invalidation.entity, action: invalidation.action }, }), ); console.log("Cache invalidation event published"); await client.close(); ``` ```java title="CacheInvalidationPublisher.java" PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId("cache-invalidator") .build(); String body = "{\"entity\":\"products\"," + "\"keys\":[\"product:SKU-100\",\"product:SKU-101\"]," + "\"action\":\"update\"}"; client.sendEventsMessage(EventMessage.builder() .channel("cache.invalidate.products") .metadata("cache.invalidate") .body(body.getBytes()) .tags(Map.of("entity", "products", "action", "update")) .build()); System.out.println("Cache invalidation event published"); client.close(); ``` ```csharp title="CacheInvalidationPublisher.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); var invalidation = new { entity = "products", keys = new[] { "product:SKU-100", "product:SKU-101" }, action = "update" }; await client.SendEventAsync(new EventMessage { Channel = "cache.invalidate.products", Metadata = "cache.invalidate", Body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(invalidation)), Tags = new Dictionary { ["entity"] = "products", ["action"] = "update" } }); Console.WriteLine("Cache invalidation event published"); ``` ```kotlin title="CacheInvalidationPublisher.kt" val client = PubSubClient("localhost:50000") val body = """{"entity":"products","keys":["product:SKU-100","product:SKU-101"],"action":"update"}""" client.sendEvent(EventMessage( channel = "cache.invalidate.products", metadata = "cache.invalidate", body = body.toByteArray(), tags = mapOf("entity" to "products", "action" to "update"), )) println("Cache invalidation event published") client.close() ``` ```cpp title="cache_invalidation_publisher.cpp" auto client = kubemq::PubSubClient("localhost:50000"); kubemq::EventMessage event; event.channel = "cache.invalidate.products"; event.metadata = "cache.invalidate"; event.body = R"({"entity":"products","keys":["product:SKU-100","product:SKU-101"],"action":"update"})"; event.tags["entity"] = "products"; event.tags["action"] = "update"; client.sendEvent(event); std::cout << "Cache invalidation event published" << std::endl; ``` ```rust title="cache_invalidation_publisher.rs" use kubemq::prelude::*; use kubemq::EventBuilder; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .client_id("cache-invalidator") .build() .await?; let body = r#"{"entity":"products","keys":["product:SKU-100","product:SKU-101"],"action":"update"}"#; let event = EventBuilder::new() .channel("cache.invalidate.products") .metadata("cache.invalidate") .body(body.as_bytes().to_vec()) .add_tag("entity", "products") .add_tag("action", "update") .build(); client.send_event(event).await?; println!("Cache invalidation event published"); client.close().await?; Ok(()) } ``` ```ruby title="cache_invalidation_publisher.rb" require 'kubemq' client = KubeMQ::PubSubClient.new(address: 'localhost:50000', client_id: 'cache-invalidator') invalidation = '{"entity":"products","keys":["product:SKU-100","product:SKU-101"],"action":"update"}' msg = KubeMQ::PubSub::EventMessage.new( channel: 'cache.invalidate.products', metadata: 'cache.invalidate', body: invalidation, tags: { 'entity' => 'products', 'action' => 'update' } ) client.send_event(msg) puts 'Cache invalidation event published' client.close ``` ```elixir title="cache_invalidation_publisher.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "cache-invalidator") body = ~s({"entity":"products","keys":["product:SKU-100","product:SKU-101"],"action":"update"}) event = KubeMQ.Event.new( channel: "cache.invalidate.products", metadata: "cache.invalidate", body: body, tags: %{"entity" => "products", "action" => "update"} ) case KubeMQ.Client.send_event(client, event) do :ok -> IO.puts("Cache invalidation event published") {:error, err} -> IO.puts("Send failed: #{err.message}") end KubeMQ.Client.close(client) ``` ### Cache Listener [#cache-listener] Each service subscribes to invalidation events and evicts matching entries from its local cache. ```go title="cache_listener.go" type LocalCache struct { mu sync.RWMutex store map[string]interface{} } func (c *LocalCache) Evict(keys []string) { c.mu.Lock() defer c.mu.Unlock() for _, key := range keys { delete(c.store, key) fmt.Printf("[Cache] Evicted: %s\n", key) } } cache := &LocalCache{store: make(map[string]interface{})} sub, err := client.SubscribeToEvents(ctx, "cache.invalidate.>", "", kubemq.WithOnEvent(func(event *kubemq.Event) { var inv CacheInvalidation json.Unmarshal(event.Body, &inv) cache.Evict(inv.Keys) }), kubemq.WithOnError(func(err error) { log.Println("[Cache] Error:", err) }), ) ``` ```python title="cache_listener.py" import json local_cache = {} def on_invalidation(event): data = json.loads(event.body.decode("utf-8")) for key in data["keys"]: local_cache.pop(key, None) print(f"[Cache] Evicted: {key}") client.subscribe_to_events( subscription=EventsSubscription( channel="cache.invalidate.>", on_receive_event_callback=on_invalidation, on_error_callback=lambda e: print(f"[Cache] Error: {e}"), ), cancel=CancellationToken(), ) ``` ```javascript title="cache_listener.js" const localCache = new Map(); client.subscribeToEvents({ channel: "cache.invalidate.>", onEvent: (msg) => { const data = JSON.parse(Buffer.from(msg.body).toString()); for (const key of data.keys) { localCache.delete(key); console.log(`[Cache] Evicted: ${key}`); } }, onError: (err) => console.error("[Cache] Error:", err.message), }); ``` ```java title="CacheListener.java" ConcurrentHashMap localCache = new ConcurrentHashMap<>(); client.subscribeToEvents(EventsSubscription.builder() .channel("cache.invalidate.>") .onReceiveEventCallback(event -> { String body = new String(event.getBody()); // Extract keys and evict List keys = parseKeys(body); for (String key : keys) { localCache.remove(key); System.out.println("[Cache] Evicted: " + key); } }) .onErrorCallback(err -> System.err.println("[Cache] Error: " + err.getMessage())) .build()); ``` ```csharp title="CacheListener.cs" var localCache = new ConcurrentDictionary(); await foreach (var msg in client.SubscribeToEventsAsync( new EventsSubscription { Channel = "cache.invalidate.>" })) { var data = JsonSerializer.Deserialize(msg.Body.Span); foreach (var key in data.GetProperty("keys").EnumerateArray()) { localCache.TryRemove(key.GetString()!, out _); Console.WriteLine($"[Cache] Evicted: {key.GetString()}"); } } ``` ```kotlin title="CacheListener.kt" val localCache = ConcurrentHashMap() client.subscribeToEvents( channel = "cache.invalidate.>", onEvent = { event -> val body = String(event.body) val keysMatch = Regex(""""keys":\[(.*?)]""").find(body) keysMatch?.groupValues?.get(1)?.split(",")?.forEach { key -> val cleanKey = key.trim().removeSurrounding("\"") localCache.remove(cleanKey) println("[Cache] Evicted: $cleanKey") } }, onError = { err -> System.err.println("[Cache] Error: ${err.message}") } ) ``` ```cpp title="cache_listener.cpp" std::map localCache; client.subscribeToEvents("cache.invalidate.>", "", [&localCache](const kubemq::Event& event) { // Parse keys from JSON and evict // Simplified: evict all keys matching entity std::cout << "[Cache] Processing invalidation: " << event.body << std::endl; }, [](const std::string& err) { std::cerr << "[Cache] Error: " << err << std::endl; } ); ``` ```rust title="cache_listener.rs" use kubemq::prelude::*; use serde_json::Value; // Subscribe to every cache.invalidate.* channel and evict matching keys. let sub = client .subscribe_to_events( "cache.invalidate.*", "", |event| { Box::pin(async move { let data: Value = serde_json::from_slice(&event.body).unwrap_or(Value::Null); if let Some(keys) = data["keys"].as_array() { for key in keys { if let Some(k) = key.as_str() { // local_cache.remove(k); println!("[Cache] Evicted: {}", k); } } } }) }, None, ) .await?; ``` ```ruby title="cache_listener.rb" require 'json' local_cache = {} cancel = KubeMQ::CancellationToken.new sub = KubeMQ::PubSub::EventsSubscription.new(channel: 'cache.invalidate.*') client.subscribe_to_events(sub, cancellation_token: cancel, on_error: ->(e) { puts "[Cache] Error: #{e.message}" }) do |event| data = JSON.parse(event.body) data['keys'].each do |key| local_cache.delete(key) puts "[Cache] Evicted: #{key}" end end ``` ```elixir title="cache_listener.exs" # local_cache is an Agent or ETS table holding the cached entries {:ok, sub} = KubeMQ.Client.subscribe_to_events(client, "cache.invalidate.*", on_event: fn event -> %{"keys" => keys} = Jason.decode!(event.body) Enum.each(keys, fn key -> # Agent.update(local_cache, &Map.delete(&1, key)) IO.puts("[Cache] Evicted: #{key}") end) end, on_error: fn err -> IO.puts("[Cache] Error: #{err.message}") end ) ``` ## Production Considerations [#production-considerations] Events use at-most-once delivery, so a missed invalidation results in stale cache data. Mitigate this by adding a TTL (time-to-live) to all cache entries. Even if an invalidation is missed, the entry expires naturally. For critical data, add periodic full-sync reconciliation. When all service instances evict the same key simultaneously, they may all attempt to reload from the database at once. Implement a cache stampede lock (only one instance fetches, others wait) or add jitter to the eviction timing. Use hierarchical channels (e.g., `cache.invalidate.products`, `cache.invalidate.users`) so services can subscribe only to entity types they cache. This reduces unnecessary processing using [wildcard subscriptions](/learn/events/tutorials/wildcard-subscriptions). ## Related [#related] * [Wildcard Subscriptions](/learn/events/tutorials/wildcard-subscriptions) for selective cache listening * [Multicast Events](/learn/events/tutorials/multicast) for broadcasting to events and queues simultaneously * [Events Store](/learn/events-store) for guaranteed cache invalidation delivery # Live Dashboard Data Feed (/learn/events/scenarios/live-dashboard) ## Architecture [#architecture] Microservices publish operational metrics to KubeMQ. A dashboard aggregator subscribes with a wildcard and feeds real-time data to a frontend display. *Each service publishes to a hierarchical `metrics.*` channel; the aggregator subscribes once with the `metrics.>` wildcard and pushes live updates to the UI.* ## Implementation [#implementation] ### Metrics Publisher [#metrics-publisher] Each service publishes metrics to hierarchical channels for flexible subscription. ```go title="metrics_publisher.go" package main import ( "context" "fmt" "log" "math/rand" "time" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() for range ticker.C { metrics := []struct { channel string value float64 }{ {"metrics.orders.count", float64(rand.Intn(100))}, {"metrics.orders.revenue", float64(rand.Intn(10000))}, {"metrics.inventory.stock_level", float64(rand.Intn(500))}, } for _, m := range metrics { body := fmt.Sprintf(`{"value":%.2f,"timestamp":%d}`, m.value, time.Now().UnixMilli()) err := client.SendEvent(ctx, kubemq.NewEvent(). SetChannel(m.channel). SetBody([]byte(body)), ) if err != nil { log.Printf("Failed to publish %s: %v", m.channel, err) } } } } ``` ```python title="metrics_publisher.py" import json import time import random from kubemq.pubsub import Client as PubSubClient from kubemq.pubsub import EventMessage client = PubSubClient(address="localhost:50000") while True: metrics = [ ("metrics.orders.count", random.randint(0, 100)), ("metrics.orders.revenue", random.randint(0, 10000)), ("metrics.inventory.stock_level", random.randint(0, 500)), ] for channel, value in metrics: body = json.dumps({"value": value, "timestamp": int(time.time() * 1000)}) client.send_event( EventMessage(channel=channel, body=body.encode("utf-8")) ) time.sleep(2) ``` ```javascript title="metrics_publisher.js" const { KubeMQClient } = require("kubemq-js"); const client = new KubeMQClient({ address: "localhost:50000" }); setInterval(async () => { const metrics = [ { channel: "metrics.orders.count", value: Math.floor(Math.random() * 100) }, { channel: "metrics.orders.revenue", value: Math.floor(Math.random() * 10000) }, { channel: "metrics.inventory.stock_level", value: Math.floor(Math.random() * 500) }, ]; for (const m of metrics) { await client.sendEvent({ channel: m.channel, body: Buffer.from(JSON.stringify({ value: m.value, timestamp: Date.now() })), }); } }, 2000); ``` ```java title="MetricsPublisher.java" PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId("metrics-publisher") .build(); Random rng = new Random(); while (true) { String[][] metrics = { {"metrics.orders.count", String.valueOf(rng.nextInt(100))}, {"metrics.orders.revenue", String.valueOf(rng.nextInt(10000))}, {"metrics.inventory.stock_level", String.valueOf(rng.nextInt(500))}, }; for (String[] m : metrics) { String body = String.format( "{\"value\":%s,\"timestamp\":%d}", m[1], System.currentTimeMillis()); client.sendEventsMessage(EventMessage.builder() .channel(m[0]).body(body.getBytes()).build()); } Thread.sleep(2000); } ``` ```csharp title="MetricsPublisher.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); var rng = new Random(); while (true) { var metrics = new[] { ("metrics.orders.count", rng.Next(100)), ("metrics.orders.revenue", rng.Next(10000)), ("metrics.inventory.stock_level", rng.Next(500)), }; foreach (var (channel, value) in metrics) { await client.SendEventAsync(new EventMessage { Channel = channel, Body = Encoding.UTF8.GetBytes( $"{{\"value\":{value},\"timestamp\":{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}}}"), }); } await Task.Delay(2000); } ``` ```kotlin title="MetricsPublisher.kt" val client = PubSubClient("localhost:50000") val rng = java.util.Random() while (true) { val metrics = listOf( "metrics.orders.count" to rng.nextInt(100), "metrics.orders.revenue" to rng.nextInt(10000), "metrics.inventory.stock_level" to rng.nextInt(500), ) for ((channel, value) in metrics) { val body = """{"value":$value,"timestamp":${System.currentTimeMillis()}}""" client.sendEvent(EventMessage(channel = channel, body = body.toByteArray())) } Thread.sleep(2000) } ``` ```cpp title="metrics_publisher.cpp" auto client = kubemq::PubSubClient("localhost:50000"); while (true) { std::vector> metrics = { {"metrics.orders.count", rand() % 100}, {"metrics.orders.revenue", rand() % 10000}, {"metrics.inventory.stock_level", rand() % 500}, }; for (const auto& [channel, value] : metrics) { kubemq::EventMessage event; event.channel = channel; event.body = "{\"value\":" + std::to_string(value) + "}"; client.sendEvent(event); } std::this_thread::sleep_for(std::chrono::seconds(2)); } ``` ```rust title="metrics_publisher.rs" use kubemq::prelude::*; use kubemq::EventBuilder; use std::time::Duration; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; let mut ticker = tokio::time::interval(Duration::from_secs(2)); loop { ticker.tick().await; let metrics = [ ("metrics.orders.count", rand::random::() as i32), ("metrics.orders.revenue", rand::random::() as i32), ("metrics.inventory.stock_level", rand::random::() as i32 % 500), ]; for (channel, value) in metrics { let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_millis(); let body = format!(r#"{{"value":{value},"timestamp":{ts}}}"#); let event = EventBuilder::new() .channel(channel) .body(body.into_bytes()) .build(); client.send_event(event).await?; } } } ``` ```ruby title="metrics_publisher.rb" require 'kubemq' require 'json' client = KubeMQ::PubSubClient.new(address: 'localhost:50000', client_id: 'metrics-publisher') loop do metrics = [ ['metrics.orders.count', rand(100)], ['metrics.orders.revenue', rand(10_000)], ['metrics.inventory.stock_level', rand(500)], ] metrics.each do |channel, value| body = JSON.generate(value: value, timestamp: (Time.now.to_f * 1000).to_i) client.send_event(KubeMQ::PubSub::EventMessage.new(channel: channel, body: body)) end sleep 2 end ``` ```elixir title="metrics_publisher.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "metrics-publisher") metrics_loop = fn loop -> metrics = [ {"metrics.orders.count", :rand.uniform(100)}, {"metrics.orders.revenue", :rand.uniform(10_000)}, {"metrics.inventory.stock_level", :rand.uniform(500)} ] for {channel, value} <- metrics do body = Jason.encode!(%{value: value, timestamp: System.system_time(:millisecond)}) event = KubeMQ.Event.new(channel: channel, body: body) :ok = KubeMQ.Client.send_event(client, event) end Process.sleep(2000) loop.(loop) end metrics_loop.(metrics_loop) ``` ### Dashboard Aggregator [#dashboard-aggregator] Subscribe with a wildcard to capture all metrics and aggregate them for the UI. ```go title="dashboard_aggregator.go" type DashboardState struct { mu sync.RWMutex metrics map[string]float64 } state := &DashboardState{metrics: make(map[string]float64)} sub, err := client.SubscribeToEvents(ctx, "metrics.>", "", kubemq.WithOnEvent(func(event *kubemq.Event) { var data struct { Value float64 `json:"value"` } json.Unmarshal(event.Body, &data) state.mu.Lock() state.metrics[event.Channel] = data.Value state.mu.Unlock() fmt.Printf("[Dashboard] %s = %.2f\n", event.Channel, data.Value) }), kubemq.WithOnError(func(err error) { log.Println("[Dashboard] Error:", err) }), ) ``` ```python title="dashboard_aggregator.py" import json metrics_state = {} def on_event(event): data = json.loads(event.body.decode("utf-8")) metrics_state[event.channel] = data["value"] print(f"[Dashboard] {event.channel} = {data['value']}") client.subscribe_to_events( subscription=EventsSubscription( channel="metrics.>", on_receive_event_callback=on_event, on_error_callback=lambda e: print(f"[Dashboard] Error: {e}"), ), cancel=CancellationToken(), ) ``` ```javascript title="dashboard_aggregator.js" const metricsState = new Map(); client.subscribeToEvents({ channel: "metrics.>", onEvent: (msg) => { const data = JSON.parse(Buffer.from(msg.body).toString()); metricsState.set(msg.channel, data.value); console.log(`[Dashboard] ${msg.channel} = ${data.value}`); }, onError: (err) => console.error("[Dashboard] Error:", err.message), }); ``` ```java title="DashboardAggregator.java" ConcurrentHashMap metricsState = new ConcurrentHashMap<>(); client.subscribeToEvents(EventsSubscription.builder() .channel("metrics.>") .onReceiveEventCallback(event -> { String body = new String(event.getBody()); double value = Double.parseDouble( body.replaceAll(".*\"value\":(\\d+\\.?\\d*).*", "$1")); metricsState.put(event.getChannel(), value); System.out.printf("[Dashboard] %s = %.2f%n", event.getChannel(), value); }) .onErrorCallback(err -> System.err.println("[Dashboard] Error: " + err.getMessage())) .build()); ``` ```csharp title="DashboardAggregator.cs" var metricsState = new ConcurrentDictionary(); await foreach (var msg in client.SubscribeToEventsAsync( new EventsSubscription { Channel = "metrics.>" })) { var data = JsonSerializer.Deserialize(msg.Body.Span); var value = data.GetProperty("value").GetDouble(); metricsState[msg.Channel] = value; Console.WriteLine($"[Dashboard] {msg.Channel} = {value}"); } ``` ```kotlin title="DashboardAggregator.kt" val metricsState = ConcurrentHashMap() client.subscribeToEvents( channel = "metrics.>", onEvent = { event -> val body = String(event.body) val value = Regex(""""value":(\d+\.?\d*)""").find(body)?.groupValues?.get(1)?.toDouble() ?: 0.0 metricsState[event.channel] = value println("[Dashboard] ${event.channel} = $value") }, onError = { err -> System.err.println("[Dashboard] Error: ${err.message}") } ) ``` ```cpp title="dashboard_aggregator.cpp" std::map metricsState; client.subscribeToEvents("metrics.>", "", [&metricsState](const kubemq::Event& event) { // Simple JSON value extraction auto pos = event.body.find("\"value\":"); if (pos != std::string::npos) { double value = std::stod(event.body.substr(pos + 8)); metricsState[event.channel] = value; std::cout << "[Dashboard] " << event.channel << " = " << value << std::endl; } }, [](const std::string& err) { std::cerr << "[Dashboard] Error: " << err << std::endl; } ); ``` ```rust title="dashboard_aggregator.rs" use std::collections::HashMap; use std::sync::{Arc, Mutex}; let metrics_state: Arc>> = Arc::new(Mutex::new(HashMap::new())); let state = metrics_state.clone(); let sub = client .subscribe_to_events( "metrics.>", "", move |event| { let state = state.clone(); Box::pin(async move { let body = String::from_utf8_lossy(&event.body); // Extract "value" from the JSON payload. if let Some(value) = body .split("\"value\":") .nth(1) .and_then(|s| s.split([',', '}']).next()) .and_then(|s| s.trim().parse::().ok()) { state.lock().unwrap().insert(event.channel.clone(), value); println!("[Dashboard] {} = {}", event.channel, value); } }) }, None, ) .await?; ``` ```ruby title="dashboard_aggregator.rb" require 'kubemq' require 'json' metrics_state = {} cancel = KubeMQ::CancellationToken.new sub = KubeMQ::PubSub::EventsSubscription.new(channel: 'metrics.>') client.subscribe_to_events( sub, cancellation_token: cancel, on_error: ->(e) { warn "[Dashboard] Error: #{e.message}" } ) do |event| data = JSON.parse(event.body) metrics_state[event.channel] = data['value'] puts "[Dashboard] #{event.channel} = #{data['value']}" end ``` ```elixir title="dashboard_aggregator.exs" # Aggregate metrics in an Agent so the wildcard handler can update shared state. {:ok, metrics_state} = Agent.start_link(fn -> %{} end) {:ok, sub} = KubeMQ.Client.subscribe_to_events(client, "metrics.>", on_event: fn event -> %{"value" => value} = Jason.decode!(event.body) Agent.update(metrics_state, &Map.put(&1, event.channel, value)) IO.puts("[Dashboard] #{event.channel} = #{value}") end, on_error: fn err -> IO.warn("[Dashboard] Error: #{err.message}") end ) ``` ## Production Considerations [#production-considerations] Events are at-most-once delivery. If the dashboard aggregator misses a metrics publish, the value becomes stale. Implement a staleness check — if a metric hasn't been updated within a threshold (e.g., 3× the publish interval), display a warning in the UI. If you run multiple dashboard aggregator instances, use a consumer group to distribute the load. Each instance would maintain partial state. Alternatively, keep all instances ungrouped so each has a complete view. For very high-frequency metrics (sub-second), consider using [stream publishing](/learn/events/tutorials/stream-publishing) on the publisher side and implement client-side sampling or aggregation windows on the subscriber. ## Related [#related] * [Wildcard Subscriptions](/learn/events/tutorials/wildcard-subscriptions) for flexible channel pattern matching * [Stream Publishing](/learn/events/tutorials/stream-publishing) for high-throughput data feeds * [Handle Slow Consumers](/learn/events/how-to/handle-slow-consumers) for high-volume scenarios # Real-Time Notifications System (/learn/events/scenarios/real-time-notifications) ## Architecture [#architecture] An e-commerce order service publishes status updates. Multiple downstream services — email, push notifications, and analytics — each subscribe independently and process events in real time. *Each event fans out to every ungrouped subscriber; the analytics workers share one consumer group, so each event reaches exactly one of them.* ## Implementation [#implementation] ### Order Status Publisher [#order-status-publisher] When an order changes status, publish an event with the order details and status metadata. ```go title="order_status_publisher.go" package main import ( "context" "encoding/json" "log" "time" "github.com/kubemq-io/kubemq-go/v2" ) type OrderEvent struct { OrderID string `json:"orderId"` Status string `json:"status"` Customer string `json:"customer"` Amount float64 `json:"amount"` Timestamp int64 `json:"timestamp"` } func publishOrderStatus(ctx context.Context, client *kubemq.Client, event OrderEvent) error { body, _ := json.Marshal(event) return client.SendEvent(ctx, kubemq.NewEvent(). SetChannel("order-notifications"). SetMetadata("order."+event.Status). SetBody(body). SetTags(map[string]string{ "status": event.Status, "customer": event.Customer, }), ) } func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() events := []OrderEvent{ {"ORD-001", "created", "alice@example.com", 99.99, time.Now().UnixMilli()}, {"ORD-001", "confirmed", "alice@example.com", 99.99, time.Now().UnixMilli()}, {"ORD-001", "shipped", "alice@example.com", 99.99, time.Now().UnixMilli()}, } for _, event := range events { if err := publishOrderStatus(ctx, client, event); err != nil { log.Printf("Failed to publish %s: %v", event.OrderID, err) } time.Sleep(time.Second) } } ``` ```python title="order_status_publisher.py" import json import time from kubemq.pubsub import Client as PubSubClient from kubemq.pubsub import EventMessage client = PubSubClient(address="localhost:50000") events = [ {"orderId": "ORD-001", "status": "created", "customer": "alice@example.com", "amount": 99.99}, {"orderId": "ORD-001", "status": "confirmed", "customer": "alice@example.com", "amount": 99.99}, {"orderId": "ORD-001", "status": "shipped", "customer": "alice@example.com", "amount": 99.99}, ] for event in events: client.send_event( EventMessage( channel="order-notifications", metadata=f"order.{event['status']}", body=json.dumps(event).encode("utf-8"), tags={"status": event["status"], "customer": event["customer"]}, ) ) print(f"Published: {event['orderId']} -> {event['status']}") time.sleep(1) client.close() ``` ```javascript title="order_status_publisher.js" const { KubeMQClient } = require("kubemq-js"); const client = new KubeMQClient({ address: "localhost:50000" }); const events = [ { orderId: "ORD-001", status: "created", customer: "alice@example.com", amount: 99.99 }, { orderId: "ORD-001", status: "confirmed", customer: "alice@example.com", amount: 99.99 }, { orderId: "ORD-001", status: "shipped", customer: "alice@example.com", amount: 99.99 }, ]; for (const event of events) { await client.sendEvent({ channel: "order-notifications", metadata: `order.${event.status}`, body: Buffer.from(JSON.stringify(event)), tags: { status: event.status, customer: event.customer }, }); console.log(`Published: ${event.orderId} -> ${event.status}`); await new Promise((r) => setTimeout(r, 1000)); } ``` ```java title="OrderStatusPublisher.java" PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId("order-service") .build(); String[][] events = { {"ORD-001", "created", "alice@example.com", "99.99"}, {"ORD-001", "confirmed", "alice@example.com", "99.99"}, {"ORD-001", "shipped", "alice@example.com", "99.99"}, }; for (String[] event : events) { String body = String.format( "{\"orderId\":\"%s\",\"status\":\"%s\",\"customer\":\"%s\",\"amount\":%s}", event[0], event[1], event[2], event[3]); client.sendEventsMessage(EventMessage.builder() .channel("order-notifications") .metadata("order." + event[1]) .body(body.getBytes()) .tags(Map.of("status", event[1], "customer", event[2])) .build()); System.out.printf("Published: %s -> %s%n", event[0], event[1]); Thread.sleep(1000); } client.close(); ``` ```csharp title="OrderStatusPublisher.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); var events = new[] { new { OrderId = "ORD-001", Status = "created", Customer = "alice@example.com", Amount = 99.99 }, new { OrderId = "ORD-001", Status = "confirmed", Customer = "alice@example.com", Amount = 99.99 }, new { OrderId = "ORD-001", Status = "shipped", Customer = "alice@example.com", Amount = 99.99 }, }; foreach (var evt in events) { await client.SendEventAsync(new EventMessage { Channel = "order-notifications", Metadata = $"order.{evt.Status}", Body = Encoding.UTF8.GetBytes( $"{{\"orderId\":\"{evt.OrderId}\",\"status\":\"{evt.Status}\",\"amount\":{evt.Amount}}}"), Tags = new Dictionary { ["status"] = evt.Status, ["customer"] = evt.Customer } }); Console.WriteLine($"Published: {evt.OrderId} -> {evt.Status}"); await Task.Delay(1000); } ``` ```kotlin title="OrderStatusPublisher.kt" val client = PubSubClient("localhost:50000") data class OrderEvent(val orderId: String, val status: String, val customer: String, val amount: Double) val events = listOf( OrderEvent("ORD-001", "created", "alice@example.com", 99.99), OrderEvent("ORD-001", "confirmed", "alice@example.com", 99.99), OrderEvent("ORD-001", "shipped", "alice@example.com", 99.99), ) for (event in events) { val body = """{"orderId":"${event.orderId}","status":"${event.status}","amount":${event.amount}}""" client.sendEvent(EventMessage( channel = "order-notifications", metadata = "order.${event.status}", body = body.toByteArray(), tags = mapOf("status" to event.status, "customer" to event.customer), )) println("Published: ${event.orderId} -> ${event.status}") Thread.sleep(1000) } client.close() ``` ```cpp title="order_status_publisher.cpp" auto client = kubemq::PubSubClient("localhost:50000"); struct OrderEvent { std::string id, status, customer; double amount; }; std::vector events = { {"ORD-001", "created", "alice@example.com", 99.99}, {"ORD-001", "confirmed", "alice@example.com", 99.99}, {"ORD-001", "shipped", "alice@example.com", 99.99}, }; for (const auto& evt : events) { kubemq::EventMessage event; event.channel = "order-notifications"; event.metadata = "order." + evt.status; event.body = "{\"orderId\":\"" + evt.id + "\",\"status\":\"" + evt.status + "\"}"; event.tags["status"] = evt.status; event.tags["customer"] = evt.customer; client.sendEvent(event); std::cout << "Published: " << evt.id << " -> " << evt.status << std::endl; std::this_thread::sleep_for(std::chrono::seconds(1)); } ``` ```rust title="order_status_publisher.rs" use kubemq::prelude::*; use kubemq::EventBuilder; use std::time::Duration; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; let statuses = ["created", "confirmed", "shipped"]; for status in statuses { let body = format!( "{{\"orderId\":\"ORD-001\",\"status\":\"{}\",\"amount\":99.99}}", status ); let event = EventBuilder::new() .channel("order-notifications") .metadata(format!("order.{}", status)) .body(body.into_bytes()) .add_tag("status", status) .add_tag("customer", "alice@example.com") .build(); client.send_event(event).await?; println!("Published: ORD-001 -> {}", status); tokio::time::sleep(Duration::from_secs(1)).await; } client.close().await?; Ok(()) } ``` ```ruby title="order_status_publisher.rb" require 'kubemq' client = KubeMQ::PubSubClient.new(address: 'localhost:50000', client_id: 'order-service') statuses = %w[created confirmed shipped] statuses.each do |status| body = %({"orderId":"ORD-001","status":"#{status}","amount":99.99}) msg = KubeMQ::PubSub::EventMessage.new( channel: 'order-notifications', metadata: "order.#{status}", body: body, tags: { 'status' => status, 'customer' => 'alice@example.com' } ) client.send_event(msg) puts "Published: ORD-001 -> #{status}" sleep 1 end client.close ``` ```elixir title="order_status_publisher.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-service") for status <- ["created", "confirmed", "shipped"] do body = ~s({"orderId":"ORD-001","status":"#{status}","amount":99.99}) event = KubeMQ.Event.new( channel: "order-notifications", metadata: "order.#{status}", body: body, tags: %{"status" => status, "customer" => "alice@example.com"} ) :ok = KubeMQ.Client.send_event(client, event) IO.puts("Published: ORD-001 -> #{status}") Process.sleep(1_000) end KubeMQ.Client.close(client) ``` ### Email Notification Subscriber [#email-notification-subscriber] The email service receives all events and sends confirmation emails for specific status changes. ```go title="email_service.go" sub, err := client.SubscribeToEvents(ctx, "order-notifications", "", kubemq.WithOnEvent(func(event *kubemq.Event) { if event.Tags["status"] == "shipped" { fmt.Printf("[Email] Sending shipping confirmation to %s for order %s\n", event.Tags["customer"], string(event.Body)) } }), kubemq.WithOnError(func(err error) { log.Println("[Email] Error:", err) }), ) ``` ```python title="email_service.py" def on_event(event): if event.tags.get("status") == "shipped": print(f"[Email] Sending shipping confirmation to {event.tags['customer']}") client.subscribe_to_events( subscription=EventsSubscription( channel="order-notifications", on_receive_event_callback=on_event, on_error_callback=lambda e: print(f"[Email] Error: {e}"), ), cancel=CancellationToken(), ) ``` ```javascript title="email_service.js" client.subscribeToEvents({ channel: "order-notifications", onEvent: (msg) => { if (msg.tags?.status === "shipped") { console.log(`[Email] Sending shipping confirmation to ${msg.tags.customer}`); } }, onError: (err) => console.error("[Email] Error:", err.message), }); ``` ```java title="EmailService.java" client.subscribeToEvents(EventsSubscription.builder() .channel("order-notifications") .onReceiveEventCallback(event -> { if ("shipped".equals(event.getTags().get("status"))) { System.out.printf("[Email] Sending shipping confirmation to %s%n", event.getTags().get("customer")); } }) .onErrorCallback(err -> System.err.println("[Email] Error: " + err.getMessage())) .build()); ``` ```csharp title="EmailService.cs" await foreach (var msg in client.SubscribeToEventsAsync( new EventsSubscription { Channel = "order-notifications" })) { if (msg.Tags?.GetValueOrDefault("status") == "shipped") { Console.WriteLine($"[Email] Sending shipping confirmation to " + $"{msg.Tags["customer"]}"); } } ``` ```kotlin title="EmailService.kt" client.subscribeToEvents( channel = "order-notifications", onEvent = { event -> if (event.tags["status"] == "shipped") { println("[Email] Sending shipping confirmation to ${event.tags["customer"]}") } }, onError = { err -> System.err.println("[Email] Error: ${err.message}") } ) ``` ```cpp title="email_service.cpp" client.subscribeToEvents("order-notifications", "", [](const kubemq::Event& event) { if (event.tags.at("status") == "shipped") { std::cout << "[Email] Sending shipping confirmation to " << event.tags.at("customer") << std::endl; } }, [](const std::string& err) { std::cerr << "[Email] Error: " << err << std::endl; } ); ``` ```rust title="email_service.rs" // Ungrouped subscriber: receives every event on the channel. let sub = client .subscribe_to_events( "order-notifications", "", |event| { Box::pin(async move { if event.tags.get("status").map(String::as_str) == Some("shipped") { let customer = event.tags.get("customer").cloned().unwrap_or_default(); println!("[Email] Sending shipping confirmation to {}", customer); } }) }, None, ) .await?; ``` ```ruby title="email_service.rb" cancel = KubeMQ::CancellationToken.new sub = KubeMQ::PubSub::EventsSubscription.new(channel: 'order-notifications') client.subscribe_to_events(sub, cancellation_token: cancel, on_error: ->(e) { puts "[Email] Error: #{e.message}" }) do |event| if event.tags['status'] == 'shipped' puts "[Email] Sending shipping confirmation to #{event.tags['customer']}" end end ``` ```elixir title="email_service.exs" # Ungrouped subscriber: receives every event on the channel. {:ok, sub} = KubeMQ.Client.subscribe_to_events(client, "order-notifications", on_event: fn event -> if event.tags["status"] == "shipped" do IO.puts("[Email] Sending shipping confirmation to #{event.tags["customer"]}") end end ) ``` ### Analytics Subscriber (with Consumer Group) [#analytics-subscriber-with-consumer-group] Analytics workers use a consumer group for load-balanced processing. ```go title="analytics_worker.go" sub, err := client.SubscribeToEvents(ctx, "order-notifications", "analytics", kubemq.WithOnEvent(func(event *kubemq.Event) { fmt.Printf("[Analytics] Recording metric: %s\n", event.Metadata) }), kubemq.WithOnError(func(err error) { log.Println("[Analytics] Error:", err) }), ) ``` ```python title="analytics_worker.py" client.subscribe_to_events( subscription=EventsSubscription( channel="order-notifications", group="analytics", on_receive_event_callback=lambda e: print( f"[Analytics] Recording metric: {e.metadata}" ), on_error_callback=lambda e: print(f"[Analytics] Error: {e}"), ), cancel=CancellationToken(), ) ``` ```javascript title="analytics_worker.js" client.subscribeToEvents({ channel: "order-notifications", group: "analytics", onEvent: (msg) => console.log(`[Analytics] Recording metric: ${msg.metadata}`), onError: (err) => console.error("[Analytics] Error:", err.message), }); ``` ```java title="AnalyticsWorker.java" client.subscribeToEvents(EventsSubscription.builder() .channel("order-notifications") .group("analytics") .onReceiveEventCallback(event -> System.out.println("[Analytics] Recording metric: " + event.getMetadata())) .onErrorCallback(err -> System.err.println("[Analytics] Error: " + err.getMessage())) .build()); ``` ```csharp title="AnalyticsWorker.cs" await foreach (var msg in client.SubscribeToEventsAsync( new EventsSubscription { Channel = "order-notifications", Group = "analytics" })) { Console.WriteLine($"[Analytics] Recording metric: {msg.Metadata}"); } ``` ```kotlin title="AnalyticsWorker.kt" client.subscribeToEvents( channel = "order-notifications", group = "analytics", onEvent = { event -> println("[Analytics] Recording metric: ${event.metadata}") }, onError = { err -> System.err.println("[Analytics] Error: ${err.message}") } ) ``` ```cpp title="analytics_worker.cpp" client.subscribeToEvents("order-notifications", "analytics", [](const kubemq::Event& event) { std::cout << "[Analytics] Recording metric: " << event.metadata << std::endl; }, [](const std::string& err) { std::cerr << "[Analytics] Error: " << err << std::endl; } ); ``` ```rust title="analytics_worker.rs" // Same group on every worker: each event reaches exactly one worker. let sub = client .subscribe_to_events( "order-notifications", "analytics", |event| { Box::pin(async move { println!("[Analytics] Recording metric: {}", event.metadata); }) }, None, ) .await?; ``` ```ruby title="analytics_worker.rb" cancel = KubeMQ::CancellationToken.new sub = KubeMQ::PubSub::EventsSubscription.new(channel: 'order-notifications', group: 'analytics') client.subscribe_to_events(sub, cancellation_token: cancel, on_error: ->(e) { puts "[Analytics] Error: #{e.message}" }) do |event| puts "[Analytics] Recording metric: #{event.metadata}" end ``` ```elixir title="analytics_worker.exs" # Same group on every worker: each event reaches exactly one worker. {:ok, sub} = KubeMQ.Client.subscribe_to_events(client, "order-notifications", group: "analytics", on_event: fn event -> IO.puts("[Analytics] Recording metric: #{event.metadata}") end ) ``` ## Production Considerations [#production-considerations] Events use at-most-once delivery. If the email service is down when a "shipped" event is published, that notification is lost. For critical notifications, consider using [Events Store](/learn/events-store) to guarantee delivery, or implement a heartbeat/health check that alerts when a subscriber disconnects. Add an ungrouped monitor subscriber that logs all events for observability. Use tags to track event counts per status type. Monitor server-side logs for `writeDeadline` warnings that indicate slow consumers. Use consumer groups for services that can be parallelized (like analytics). Services that must see every event (like email) should remain ungrouped. Scale ungrouped services vertically or use application-level buffering. ## Related [#related] * [Consumer Groups](/learn/events/tutorials/consumer-groups) for load-balanced delivery * [Filter Events](/learn/events/how-to/filter-events) for tag-based filtering patterns * [Events Store](/learn/events-store) for guaranteed delivery scenarios # Filter Events by Tags (/learn/events/how-to/filter-events) KubeMQ Events does not have a built-in server-side filter mechanism, but you can implement effective filtering using three strategies: **channel hierarchy**, **metadata inspection**, and **tag-based routing**. The diagram below shows the tag-filter decision: every subscriber receives all events on the channel, then applies its own predicate — accepting matching events and ignoring the rest. *Tag filtering happens in the subscriber: KubeMQ delivers every event, the client keeps only those whose tags match.* ## Strategy 1: Filter by Channel Pattern [#strategy-1-filter-by-channel-pattern] The most efficient approach — organize events into hierarchical channels and use [wildcard subscriptions](/learn/events/tutorials/wildcard-subscriptions) to select the subset you need. ```go title="channel_filter.go" // Publish to specific channels client.SendEvent(ctx, kubemq.NewEvent(). SetChannel("orders.us-east.created"). SetBody([]byte(orderData))) // Subscribe to a filtered subset client.SubscribeToEvents(ctx, "orders.us-east.*", "", ...) client.SubscribeToEvents(ctx, "orders.*.created", "", ...) client.SubscribeToEvents(ctx, "orders.>", "", ...) ``` ```python title="channel_filter.py" # Publish to specific channels client.send_event(EventMessage( channel="orders.us-east.created", body=order_data )) # Subscribe to a filtered subset client.subscribe_to_events(EventsSubscription(channel="orders.us-east.*", ...)) client.subscribe_to_events(EventsSubscription(channel="orders.*.created", ...)) client.subscribe_to_events(EventsSubscription(channel="orders.>", ...)) ``` ```javascript title="channel_filter.js" // Publish to specific channels await client.sendEvent({ channel: "orders.us-east.created", body: orderData }); // Subscribe to a filtered subset client.subscribeToEvents({ channel: "orders.us-east.*", ... }); client.subscribeToEvents({ channel: "orders.*.created", ... }); client.subscribeToEvents({ channel: "orders.>", ... }); ``` ```java title="ChannelFilter.java" // Publish to specific channels client.sendEventsMessage(EventMessage.builder() .channel("orders.us-east.created") .body(orderData).build()); // Subscribe to a filtered subset client.subscribeToEvents(EventsSubscription.builder() .channel("orders.us-east.*").build()); ``` ```csharp title="ChannelFilter.cs" // Publish to specific channels await client.SendEventAsync(new EventMessage { Channel = "orders.us-east.created", Body = orderData }); // Subscribe to a filtered subset await foreach (var msg in client.SubscribeToEventsAsync( new EventsSubscription { Channel = "orders.us-east.*" })) { } ``` ```kotlin title="ChannelFilter.kt" // Publish to specific channels client.sendEvent(EventMessage( channel = "orders.us-east.created", body = orderData )) // Subscribe to a filtered subset client.subscribeToEvents(channel = "orders.us-east.*", ...) ``` ```cpp title="channel_filter.cpp" // Publish to specific channels event.channel = "orders.us-east.created"; client.sendEvent(event); // Subscribe to a filtered subset client.subscribeToEvents("orders.us-east.*", "", handler, errHandler); ``` ```rust title="channel_filter.rs" // Publish to specific channels let event = EventBuilder::new() .channel("orders.us-east.created") .body(order_data) .build(); client.send_event(event).await?; // Subscribe to a filtered subset client.subscribe_to_events("orders.us-east.*", "", handler, None).await?; client.subscribe_to_events("orders.*.created", "", handler, None).await?; client.subscribe_to_events("orders.>", "", handler, None).await?; ``` ```ruby title="channel_filter.rb" # Publish to specific channels client.send_event(KubeMQ::PubSub::EventMessage.new( channel: "orders.us-east.created", body: order_data )) # Subscribe to a filtered subset client.subscribe_to_events( KubeMQ::PubSub::EventsSubscription.new(channel: "orders.us-east.*"), cancellation_token: cancel ) { |event| handle(event) } client.subscribe_to_events( KubeMQ::PubSub::EventsSubscription.new(channel: "orders.>"), cancellation_token: cancel ) { |event| handle(event) } ``` ```elixir title="channel_filter.exs" # Publish to specific channels event = KubeMQ.Event.new(channel: "orders.us-east.created", body: order_data) KubeMQ.Client.send_event(client, event) # Subscribe to a filtered subset KubeMQ.Client.subscribe_to_events(client, "orders.us-east.*", on_event: &handle/1) KubeMQ.Client.subscribe_to_events(client, "orders.>", on_event: &handle/1) ``` This approach has **zero runtime overhead** because filtering happens at the broker subscription level before messages reach your code. ## Strategy 2: Filter by Metadata [#strategy-2-filter-by-metadata] Use the `metadata` field to carry event type information and filter in your subscriber callback. ```go title="metadata_filter.go" sub, err := client.SubscribeToEvents(ctx, "order-events", "", kubemq.WithOnEvent(func(event *kubemq.Event) { if event.Metadata != "order.created" { return } fmt.Printf("Processing new order: %s\n", string(event.Body)) }), kubemq.WithOnError(func(err error) { log.Println("Error:", err) }), ) ``` ```python title="metadata_filter.py" def on_event(event): if event.metadata != "order.created": return print(f"Processing new order: {event.body.decode('utf-8')}") client.subscribe_to_events( subscription=EventsSubscription( channel="order-events", on_receive_event_callback=on_event, on_error_callback=lambda e: print(f"Error: {e}"), ), cancel=CancellationToken(), ) ``` ```javascript title="metadata_filter.js" client.subscribeToEvents({ channel: "order-events", onEvent: (msg) => { if (msg.metadata !== "order.created") return; console.log(`Processing new order: ${Buffer.from(msg.body).toString()}`); }, onError: (err) => console.error("Error:", err.message), }); ``` ```java title="MetadataFilter.java" client.subscribeToEvents(EventsSubscription.builder() .channel("order-events") .onReceiveEventCallback(event -> { if (!"order.created".equals(event.getMetadata())) return; System.out.println("Processing new order: " + new String(event.getBody())); }) .onErrorCallback(err -> System.err.println("Error: " + err.getMessage())) .build()); ``` ```csharp title="MetadataFilter.cs" await foreach (var msg in client.SubscribeToEventsAsync( new EventsSubscription { Channel = "order-events" })) { if (msg.Metadata != "order.created") continue; Console.WriteLine($"Processing new order: " + $"{Encoding.UTF8.GetString(msg.Body.Span)}"); } ``` ```kotlin title="MetadataFilter.kt" client.subscribeToEvents( channel = "order-events", onEvent = { event -> if (event.metadata != "order.created") return@subscribeToEvents println("Processing new order: ${String(event.body)}") }, onError = { err -> System.err.println("Error: ${err.message}") } ) ``` ```cpp title="metadata_filter.cpp" client.subscribeToEvents("order-events", "", [](const kubemq::Event& event) { if (event.metadata != "order.created") return; std::cout << "Processing new order: " << event.body << std::endl; }, [](const std::string& err) { std::cerr << "Error: " << err << std::endl; } ); ``` ```rust title="metadata_filter.rs" let sub = client .subscribe_to_events( "order-events", "", |event| { Box::pin(async move { if event.metadata != "order.created" { return; } println!( "Processing new order: {}", String::from_utf8_lossy(&event.body) ); }) }, None, ) .await?; ``` ```ruby title="metadata_filter.rb" sub = KubeMQ::PubSub::EventsSubscription.new(channel: "order-events") client.subscribe_to_events( sub, cancellation_token: cancel, on_error: ->(e) { puts "Error: #{e.message}" } ) do |event| next unless event.metadata == "order.created" puts "Processing new order: #{event.body}" end ``` ```elixir title="metadata_filter.exs" KubeMQ.Client.subscribe_to_events(client, "order-events", on_event: fn event -> if event.metadata == "order.created" do IO.puts("Processing new order: #{event.body}") end end, on_error: fn err -> IO.puts("Error: #{err.message}") end ) ``` Client-side metadata filtering still delivers all events to the subscriber. Use channel-based filtering (Strategy 1) when throughput is high and you want to reduce network traffic. ## Strategy 3: Filter by Tags [#strategy-3-filter-by-tags] Tags are key-value pairs attached to events. Use them for multi-dimensional filtering when a single channel hierarchy is not sufficient. ### Publish with Tags [#publish-with-tags] ```go title="publish_with_tags.go" err = client.SendEvent(ctx, kubemq.NewEvent(). SetChannel("order-events"). SetBody([]byte(`{"orderId":"ORD-100","amount":250.00}`)). SetMetadata("order.created"). SetTags(map[string]string{ "region": "us-east", "priority": "high", "customer": "enterprise", }), ) ``` ```python title="publish_with_tags.py" client.send_event( EventMessage( channel="order-events", body=b'{"orderId":"ORD-100","amount":250.00}', metadata="order.created", tags={"region": "us-east", "priority": "high", "customer": "enterprise"}, ) ) ``` ```javascript title="publish_with_tags.js" await client.sendEvent({ channel: "order-events", body: Buffer.from('{"orderId":"ORD-100","amount":250.00}'), metadata: "order.created", tags: { region: "us-east", priority: "high", customer: "enterprise" }, }); ``` ```java title="PublishWithTags.java" client.sendEventsMessage(EventMessage.builder() .channel("order-events") .body("{\"orderId\":\"ORD-100\",\"amount\":250.00}".getBytes()) .metadata("order.created") .tags(Map.of("region", "us-east", "priority", "high", "customer", "enterprise")) .build()); ``` ```csharp title="PublishWithTags.cs" await client.SendEventAsync(new EventMessage { Channel = "order-events", Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-100\",\"amount\":250.00}"), Metadata = "order.created", Tags = new Dictionary { ["region"] = "us-east", ["priority"] = "high", ["customer"] = "enterprise" } }); ``` ```kotlin title="PublishWithTags.kt" client.sendEvent(EventMessage( channel = "order-events", body = """{"orderId":"ORD-100","amount":250.00}""".toByteArray(), metadata = "order.created", tags = mapOf("region" to "us-east", "priority" to "high", "customer" to "enterprise"), )) ``` ```cpp title="publish_with_tags.cpp" kubemq::EventMessage event; event.channel = "order-events"; event.body = R"({"orderId":"ORD-100","amount":250.00})"; event.metadata = "order.created"; event.tags["region"] = "us-east"; event.tags["priority"] = "high"; event.tags["customer"] = "enterprise"; client.sendEvent(event); ``` ```rust title="publish_with_tags.rs" use std::collections::HashMap; let tags = HashMap::from([ ("region".to_string(), "us-east".to_string()), ("priority".to_string(), "high".to_string()), ("customer".to_string(), "enterprise".to_string()), ]); let event = EventBuilder::new() .channel("order-events") .metadata("order.created") .tags(tags) .body(br#"{"orderId":"ORD-100","amount":250.00}"#.to_vec()) .build(); client.send_event(event).await?; ``` ```ruby title="publish_with_tags.rb" client.send_event(KubeMQ::PubSub::EventMessage.new( channel: "order-events", body: '{"orderId":"ORD-100","amount":250.00}', metadata: "order.created", tags: { "region" => "us-east", "priority" => "high", "customer" => "enterprise" } )) ``` ```elixir title="publish_with_tags.exs" event = KubeMQ.Event.new( channel: "order-events", body: ~s({"orderId":"ORD-100","amount":250.00}), metadata: "order.created", tags: %{"region" => "us-east", "priority" => "high", "customer" => "enterprise"} ) KubeMQ.Client.send_event(client, event) ``` ### Filter by Tags in the Subscriber [#filter-by-tags-in-the-subscriber] ```go title="tag_filter.go" sub, err := client.SubscribeToEvents(ctx, "order-events", "", kubemq.WithOnEvent(func(event *kubemq.Event) { if event.Tags["priority"] != "high" || event.Tags["customer"] != "enterprise" { return } fmt.Printf("High-priority enterprise order: %s\n", string(event.Body)) }), kubemq.WithOnError(func(err error) { log.Println("Error:", err) }), ) ``` ```python title="tag_filter.py" def on_event(event): if event.tags.get("priority") != "high" or \ event.tags.get("customer") != "enterprise": return print(f"High-priority enterprise order: {event.body.decode('utf-8')}") client.subscribe_to_events( subscription=EventsSubscription( channel="order-events", on_receive_event_callback=on_event, on_error_callback=lambda e: print(f"Error: {e}"), ), cancel=CancellationToken(), ) ``` ```javascript title="tag_filter.js" client.subscribeToEvents({ channel: "order-events", onEvent: (msg) => { if (msg.tags?.priority !== "high" || msg.tags?.customer !== "enterprise") { return; } console.log( `High-priority enterprise order: ${Buffer.from(msg.body).toString()}` ); }, onError: (err) => console.error("Error:", err.message), }); ``` ```java title="TagFilter.java" client.subscribeToEvents(EventsSubscription.builder() .channel("order-events") .onReceiveEventCallback(event -> { Map tags = event.getTags(); if (!"high".equals(tags.get("priority")) || !"enterprise".equals(tags.get("customer"))) return; System.out.println("High-priority enterprise order: " + new String(event.getBody())); }) .onErrorCallback(err -> System.err.println("Error: " + err.getMessage())) .build()); ``` ```csharp title="TagFilter.cs" await foreach (var msg in client.SubscribeToEventsAsync( new EventsSubscription { Channel = "order-events" })) { if (msg.Tags?.GetValueOrDefault("priority") != "high" || msg.Tags?.GetValueOrDefault("customer") != "enterprise") continue; Console.WriteLine($"High-priority enterprise order: " + $"{Encoding.UTF8.GetString(msg.Body.Span)}"); } ``` ```kotlin title="TagFilter.kt" client.subscribeToEvents( channel = "order-events", onEvent = { event -> if (event.tags["priority"] != "high" || event.tags["customer"] != "enterprise") return@subscribeToEvents println("High-priority enterprise order: ${String(event.body)}") }, onError = { err -> System.err.println("Error: ${err.message}") } ) ``` ```cpp title="tag_filter.cpp" client.subscribeToEvents("order-events", "", [](const kubemq::Event& event) { if (event.tags.at("priority") != "high" || event.tags.at("customer") != "enterprise") return; std::cout << "High-priority enterprise order: " << event.body << std::endl; }, [](const std::string& err) { std::cerr << "Error: " << err << std::endl; } ); ``` ```rust title="tag_filter.rs" let sub = client .subscribe_to_events( "order-events", "", |event| { Box::pin(async move { let high = event.tags.get("priority").map(String::as_str) == Some("high"); let ent = event.tags.get("customer").map(String::as_str) == Some("enterprise"); if !high || !ent { return; } println!( "High-priority enterprise order: {}", String::from_utf8_lossy(&event.body) ); }) }, None, ) .await?; ``` ```ruby title="tag_filter.rb" sub = KubeMQ::PubSub::EventsSubscription.new(channel: "order-events") client.subscribe_to_events( sub, cancellation_token: cancel, on_error: ->(e) { puts "Error: #{e.message}" } ) do |event| next unless event.tags["priority"] == "high" && event.tags["customer"] == "enterprise" puts "High-priority enterprise order: #{event.body}" end ``` ```elixir title="tag_filter.exs" KubeMQ.Client.subscribe_to_events(client, "order-events", on_event: fn event -> if event.tags["priority"] == "high" and event.tags["customer"] == "enterprise" do IO.puts("High-priority enterprise order: #{event.body}") end end, on_error: fn err -> IO.puts("Error: #{err.message}") end ) ``` ## Choosing a Strategy [#choosing-a-strategy] | Strategy | Pros | Cons | Best For | | ------------------ | ------------------------------------ | -------------------------------------------- | --------------------------------------- | | Channel patterns | Zero overhead, server-side filtering | Requires channel naming discipline | High-throughput, predictable categories | | Metadata filtering | Simple, single field to check | All events still delivered to subscriber | Event type discrimination | | Tag filtering | Multi-dimensional, flexible | All events still delivered, parsing overhead | Complex filtering criteria | For maximum efficiency, **combine strategies**: use channel hierarchy for coarse-grained filtering and tags for fine-grained filtering within a channel. ## Related [#related] * [Wildcard Subscriptions](/learn/events/tutorials/wildcard-subscriptions) for channel-based filtering patterns * [Multicast Events](/learn/events/tutorials/multicast) for routing events across channels * [Events Reference](/learn/events/reference) for tag format and validation rules # Handle Slow Consumers (/learn/events/how-to/handle-slow-consumers) When a subscriber's receive buffer is full, KubeMQ waits up to the **write deadline** before dropping the event. Understanding this behavior is critical for building reliable event-driven systems. ## How Message Drops Happen [#how-message-drops-happen] *The drop lifecycle: KubeMQ holds an event for the write deadline, then drops it for any subscriber whose buffer never clears.* ### The Write Deadline [#the-write-deadline] * Default: **2000 milliseconds** (2 seconds) * When a subscriber's buffer is full, KubeMQ waits this duration for space * If the buffer does not clear in time, the event is **dropped** for that subscriber * A warning is logged server-side with the channel, event ID, and metadata * Other subscribers are not affected ## Mitigation Strategies [#mitigation-strategies] ### Strategy 1: Process Events Quickly [#strategy-1-process-events-quickly] Offload slow work to a background worker pool. Keep the event callback fast. ```go title="fast_callback.go" workCh := make(chan []byte, 1000) go func() { for body := range workCh { processOrder(body) // slow work happens here } }() sub, err := client.SubscribeToEvents(ctx, "order-events", "", kubemq.WithOnEvent(func(event *kubemq.Event) { select { case workCh <- event.Body: default: log.Println("Local buffer full, dropping event") } }), kubemq.WithOnError(func(err error) { log.Println("Error:", err) }), ) ``` ```python title="fast_callback.py" import queue import threading work_queue = queue.Queue(maxsize=1000) def worker(): while True: body = work_queue.get() process_order(body) # slow work happens here work_queue.task_done() threading.Thread(target=worker, daemon=True).start() def on_event(event): try: work_queue.put_nowait(event.body) except queue.Full: print("Local buffer full, dropping event") client.subscribe_to_events( subscription=EventsSubscription( channel="order-events", on_receive_event_callback=on_event, on_error_callback=lambda e: print(f"Error: {e}"), ), cancel=CancellationToken(), ) ``` ```javascript title="fast_callback.js" const workQueue = []; const MAX_BUFFER = 1000; setInterval(() => { while (workQueue.length > 0) { const body = workQueue.shift(); processOrder(body); // slow work } }, 10); client.subscribeToEvents({ channel: "order-events", onEvent: (msg) => { if (workQueue.length >= MAX_BUFFER) { console.warn("Local buffer full, dropping event"); return; } workQueue.push(msg.body); }, onError: (err) => console.error("Error:", err.message), }); ``` ```java title="FastCallback.java" ExecutorService executor = Executors.newFixedThreadPool(4); BlockingQueue workQueue = new LinkedBlockingQueue<>(1000); executor.submit(() -> { while (true) { byte[] body = workQueue.take(); processOrder(body); // slow work } }); client.subscribeToEvents(EventsSubscription.builder() .channel("order-events") .onReceiveEventCallback(event -> { if (!workQueue.offer(event.getBody())) { System.err.println("Local buffer full, dropping event"); } }) .onErrorCallback(err -> System.err.println("Error: " + err.getMessage())) .build()); ``` ```csharp title="FastCallback.cs" var workQueue = Channel.CreateBounded(1000); _ = Task.Run(async () => { await foreach (var body in workQueue.Reader.ReadAllAsync()) { ProcessOrder(body); // slow work } }); await foreach (var msg in client.SubscribeToEventsAsync( new EventsSubscription { Channel = "order-events" })) { if (!workQueue.Writer.TryWrite(msg.Body.ToArray())) { Console.Error.WriteLine("Local buffer full, dropping event"); } } ``` ```kotlin title="FastCallback.kt" val workQueue = LinkedBlockingQueue(1000) thread(isDaemon = true) { while (true) { val body = workQueue.take() processOrder(body) // slow work } } client.subscribeToEvents( channel = "order-events", onEvent = { event -> if (!workQueue.offer(event.body)) { System.err.println("Local buffer full, dropping event") } }, onError = { err -> System.err.println("Error: ${err.message}") } ) ``` ```cpp title="fast_callback.cpp" std::queue workQueue; std::mutex queueMutex; const size_t MAX_BUFFER = 1000; std::thread worker([&]() { while (true) { std::string body; { std::lock_guard lock(queueMutex); if (workQueue.empty()) continue; body = workQueue.front(); workQueue.pop(); } processOrder(body); // slow work } }); client.subscribeToEvents("order-events", "", [&](const kubemq::Event& event) { std::lock_guard lock(queueMutex); if (workQueue.size() >= MAX_BUFFER) { std::cerr << "Local buffer full, dropping event" << std::endl; return; } workQueue.push(event.body); }, [](const std::string& err) { std::cerr << "Error: " << err << std::endl; } ); ``` ```rust title="fast_callback.rs" use kubemq::prelude::*; use kubemq::Subscription; use tokio::sync::mpsc; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; // Bounded local buffer; a background task does the slow work let (tx, mut rx) = mpsc::channel::>(1000); tokio::spawn(async move { while let Some(body) = rx.recv().await { process_order(body).await; // slow work happens here } }); let sub: Subscription = client .subscribe_to_events( "order-events", "", move |event| { let tx = tx.clone(); Box::pin(async move { // try_send returns immediately; never block the callback if tx.try_send(event.body).is_err() { eprintln!("Local buffer full, dropping event"); } }) }, None, ) .await?; tokio::signal::ctrl_c().await.ok(); sub.unsubscribe().await; client.close().await?; Ok(()) } ``` ```ruby title="fast_callback.rb" require 'kubemq' client = KubeMQ::PubSubClient.new(address: "localhost:50000", client_id: "order-subscriber") cancel = KubeMQ::CancellationToken.new # Bounded local buffer drained by a background worker thread work_queue = SizedQueue.new(1000) Thread.new do loop do body = work_queue.pop process_order(body) # slow work happens here end end sub = KubeMQ::PubSub::EventsSubscription.new(channel: "order-events") client.subscribe_to_events(sub, cancellation_token: cancel, on_error: ->(e) { warn "Error: #{e.message}" }) do |event| # push(..., true) is non-blocking; raises when the buffer is full begin work_queue.push(event.body, true) rescue ThreadError warn "Local buffer full, dropping event" end end sleep cancel.cancel client.close ``` ```elixir title="fast_callback.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-subscriber") # Spawn a worker process; the callback only hands work off to it worker = spawn(fn -> Stream.repeatedly(fn -> receive do {:work, body} -> process_order(body) # slow work happens here end end) |> Stream.run() end) {:ok, sub} = KubeMQ.Client.subscribe_to_events(client, "order-events", on_event: fn event -> # Guard against an unbounded mailbox; drop when the worker falls behind {:message_queue_len, len} = Process.info(worker, :message_queue_len) if len >= 1000 do IO.puts("Local buffer full, dropping event") else send(worker, {:work, event.body}) end end, on_error: fn err -> IO.puts("Error: #{err.message}") end ) Process.sleep(:infinity) KubeMQ.Subscription.cancel(sub) KubeMQ.Client.close(client) ``` ### Strategy 2: Use Consumer Groups [#strategy-2-use-consumer-groups] Distribute the load across multiple subscribers so no single consumer is overwhelmed. *A consumer group spreads the load: each event goes to just one worker, so no single subscriber is overwhelmed.* See [Consumer Groups](/learn/events/tutorials/consumer-groups) and [Scale Subscribers](/learn/events/how-to/scale-subscribers) for implementation details. ### Strategy 3: Switch to Events Store [#strategy-3-switch-to-events-store] If losing messages is unacceptable, use [Events Store](/learn/events-store) instead. Events Store provides persistence and replay, ensuring no messages are lost even when consumers are slow or temporarily offline. ## Decision Guide [#decision-guide] | Situation | Recommendation | | ------------------------------------------- | -------------------------------- | | Occasional slowdowns, some drops acceptable | Strategy 1: Buffer in app | | Consistent high volume | Strategy 2: Consumer groups | | Zero message loss required | Strategy 3: Events Store | | Latency-sensitive, best-effort delivery | Strategy 1 + Strategy 2 combined | Slow consumer drops are **silent** from the subscriber's perspective. Monitor server logs for `writeDeadline` warnings to detect when drops occur. ## Related [#related] * [Consumer Groups](/learn/events/tutorials/consumer-groups) for load-balanced delivery * [Scale Subscribers](/learn/events/how-to/scale-subscribers) for horizontal scaling * [Events Store](/learn/events-store) for guaranteed delivery # Scale Subscribers Horizontally (/learn/events/how-to/scale-subscribers) When a single subscriber cannot keep up with event throughput, KubeMQ **channel groups** let you distribute events across multiple instances. Within a group, each event is delivered to exactly one member (round-robin). ## How Channel Groups Work [#how-channel-groups-work] *Grouped workers in `processors` split the load round-robin; the ungrouped monitor still receives every event.* * **Grouped subscribers** share the load: each event goes to exactly one member * **Ungrouped subscribers** receive every event (standard fan-out) * Groups are independent per channel ## Set Up a Consumer Group [#set-up-a-consumer-group] The `group` parameter determines group membership. Subscribers with the same group name on the same channel form a group. ```go title="grouped_worker.go" package main import ( "context" "fmt" "log" "os" "github.com/kubemq-io/kubemq-go/v2" ) func main() { workerID := os.Getenv("WORKER_ID") if workerID == "" { workerID = "worker-1" } ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() sub, err := client.SubscribeToEvents(ctx, "order-events", "processors", kubemq.WithOnEvent(func(event *kubemq.Event) { fmt.Printf("[%s] Processing: %s\n", workerID, string(event.Body)) }), kubemq.WithOnError(func(err error) { log.Printf("[%s] Error: %v", workerID, err) }), ) if err != nil { log.Fatal(err) } defer sub.Unsubscribe() log.Printf("[%s] Ready in group 'processors'", workerID) <-ctx.Done() } ``` ```python title="grouped_worker.py" import os import time from kubemq.pubsub import Client as PubSubClient from kubemq.pubsub import EventsSubscription, CancellationToken worker_id = os.environ.get("WORKER_ID", "worker-1") def on_event(event): print(f"[{worker_id}] Processing: {event.body.decode('utf-8')}") client = PubSubClient(address="localhost:50000") client.subscribe_to_events( subscription=EventsSubscription( channel="order-events", group="processors", on_receive_event_callback=on_event, on_error_callback=lambda e: print(f"[{worker_id}] Error: {e}"), ), cancel=CancellationToken(), ) print(f"[{worker_id}] Ready in group 'processors'") time.sleep(300) client.close() ``` ```javascript title="grouped_worker.js" const { KubeMQClient } = require("kubemq-js"); const workerId = process.env.WORKER_ID ?? "worker-1"; const client = new KubeMQClient({ address: "localhost:50000" }); client.subscribeToEvents({ channel: "order-events", group: "processors", onEvent: (msg) => console.log( `[${workerId}] Processing: ${Buffer.from(msg.body).toString()}` ), onError: (err) => console.error(`[${workerId}] Error:`, err.message), }); console.log(`[${workerId}] Ready in group 'processors'`); ``` ```java title="GroupedWorker.java" String workerId = System.getenv().getOrDefault("WORKER_ID", "worker-1"); PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId(workerId) .build(); client.subscribeToEvents(EventsSubscription.builder() .channel("order-events") .group("processors") .onReceiveEventCallback(event -> System.out.printf("[%s] Processing: %s%n", workerId, new String(event.getBody()))) .onErrorCallback(err -> System.err.printf("[%s] Error: %s%n", workerId, err.getMessage())) .build()); System.out.printf("[%s] Ready in group 'processors'%n", workerId); Thread.sleep(300_000); client.close(); ``` ```csharp title="GroupedWorker.cs" var workerId = Environment.GetEnvironmentVariable("WORKER_ID") ?? "worker-1"; await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); Console.WriteLine($"[{workerId}] Ready in group 'processors'"); await foreach (var msg in client.SubscribeToEventsAsync( new EventsSubscription { Channel = "order-events", Group = "processors" })) { Console.WriteLine($"[{workerId}] Processing: " + $"{Encoding.UTF8.GetString(msg.Body.Span)}"); } ``` ```kotlin title="GroupedWorker.kt" val workerId = System.getenv("WORKER_ID") ?: "worker-1" val client = PubSubClient("localhost:50000") client.subscribeToEvents( channel = "order-events", group = "processors", onEvent = { event -> println("[$workerId] Processing: ${String(event.body)}") }, onError = { err -> System.err.println("[$workerId] Error: ${err.message}") } ) println("[$workerId] Ready in group 'processors'") Thread.sleep(300_000) client.close() ``` ```cpp title="grouped_worker.cpp" auto workerId = std::getenv("WORKER_ID") ? std::string(std::getenv("WORKER_ID")) : std::string("worker-1"); auto client = kubemq::PubSubClient("localhost:50000"); client.subscribeToEvents("order-events", "processors", [&workerId](const kubemq::Event& event) { std::cout << "[" << workerId << "] Processing: " << event.body << std::endl; }, [&workerId](const std::string& err) { std::cerr << "[" << workerId << "] Error: " << err << std::endl; } ); std::cout << "[" << workerId << "] Ready in group 'processors'" << std::endl; std::this_thread::sleep_for(std::chrono::seconds(300)); ``` ```rust title="grouped_worker.rs" use kubemq::prelude::*; use std::time::Duration; #[tokio::main] async fn main() -> kubemq::Result<()> { let worker_id = std::env::var("WORKER_ID") .unwrap_or_else(|_| "worker-1".to_string()); let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; // Same group "processors" on the same channel → each event goes to one member let sub = client .subscribe_to_events( "order-events", "processors", move |event| { let worker_id = worker_id.clone(); Box::pin(async move { println!( "[{}] Processing: {}", worker_id, String::from_utf8_lossy(&event.body) ); }) }, None, ) .await?; tokio::time::sleep(Duration::from_secs(300)).await; sub.unsubscribe().await; client.close().await?; Ok(()) } ``` ```ruby title="grouped_worker.rb" require 'kubemq' worker_id = ENV.fetch('WORKER_ID', 'worker-1') client = KubeMQ::PubSubClient.new(address: 'localhost:50000', client_id: worker_id) cancel = KubeMQ::CancellationToken.new subscription = KubeMQ::PubSub::EventsSubscription.new( channel: 'order-events', group: 'processors' ) client.subscribe_to_events(subscription, cancellation_token: cancel, on_error: lambda { |e| warn "[#{worker_id}] Error: #{e.message}" }) do |event| puts "[#{worker_id}] Processing: #{event.body}" end puts "[#{worker_id}] Ready in group 'processors'" sleep 300 cancel.cancel client.close ``` ```elixir title="grouped_worker.exs" worker_id = System.get_env("WORKER_ID", "worker-1") {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: worker_id) # Same group "processors" on the same channel → each event goes to one member {:ok, sub} = KubeMQ.Client.subscribe_to_events(client, "order-events", group: "processors", on_event: fn event -> IO.puts("[#{worker_id}] Processing: #{event.body}") end, on_error: fn err -> IO.puts("[#{worker_id}] Error: #{inspect(err)}") end ) IO.puts("[#{worker_id}] Ready in group 'processors'") Process.sleep(300_000) KubeMQ.Subscription.cancel(sub) KubeMQ.Client.close(client) ``` Run multiple instances with different `WORKER_ID` values: ```bash WORKER_ID=worker-A ./grouped_worker & WORKER_ID=worker-B ./grouped_worker & WORKER_ID=worker-C ./grouped_worker & ``` ## Scaling Pattern: Group Workers + Monitor [#scaling-pattern-group-workers--monitor] Combine grouped workers with an ungrouped monitor that sees all events: ```bash # Workers (group: processors) — each gets ~1/3 of events WORKER_ID=worker-A ./grouped_worker & WORKER_ID=worker-B ./grouped_worker & WORKER_ID=worker-C ./grouped_worker & # Monitor (no group) — receives ALL events ./monitor & ``` ## Scaling Guidelines [#scaling-guidelines] | Factor | Recommendation | | ----------------------- | ------------------------------------------------------------------------------- | | Number of group members | Scale horizontally based on throughput. No hard limit. | | Multiple groups | Different groups on the same channel each get full delivery. | | Slow consumers | Events are dropped after the write deadline (2s default). Keep processing fast. | | Group naming | Use descriptive names (e.g., `email-senders`, `analytics-workers`). | Channel groups provide **load balancing**, not guaranteed delivery. If a grouped subscriber disconnects, events routed to it are lost. For guaranteed delivery, use [Events Store consumer groups](/learn/events-store) or [Queues](/learn/queues). ## Related [#related] * [Consumer Groups Tutorial](/learn/events/tutorials/consumer-groups) for step-by-step group setup * [Handle Slow Consumers](/learn/events/how-to/handle-slow-consumers) for mitigation strategies * [Events Reference](/learn/events/reference) for group and subscription configuration # Consumer Groups (/learn/events/tutorials/consumer-groups) ## What You Will Build [#what-you-will-build] A publisher sending order events and three consumers in a shared group, where each event is delivered to exactly one consumer (round-robin). You will then compare this with standard fan-out behavior. *A consumer group load-balances each event to exactly one member — one channel, work split across the group.* ## Prerequisites [#prerequisites] * KubeMQ server running on `localhost:50000` * SDK installed ([Getting Started](/learn/events/getting-started)) ## Steps [#steps] ### Create the Publisher [#create-the-publisher] Send a batch of order events to a channel. ```go title="order_publisher.go" package main import ( "context" "fmt" "log" "time" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() for i := 1; i <= 9; i++ { body := fmt.Sprintf(`{"orderId":"ORD-%03d","status":"created"}`, i) err = client.SendEvent(ctx, kubemq.NewEvent(). SetChannel("order-events"). SetBody([]byte(body)), ) if err != nil { log.Printf("Failed to send ORD-%03d: %v", i, err) continue } log.Printf("Published ORD-%03d", i) time.Sleep(200 * time.Millisecond) } } ``` ```python title="order_publisher.py" import time from kubemq.pubsub import Client as PubSubClient from kubemq.pubsub import EventMessage client = PubSubClient(address="localhost:50000") for i in range(1, 10): body = f'{{"orderId":"ORD-{i:03d}","status":"created"}}' client.send_event( EventMessage(channel="order-events", body=body.encode("utf-8")) ) print(f"Published ORD-{i:03d}") time.sleep(0.2) client.close() ``` ```javascript title="order_publisher.js" const { KubeMQClient } = require("kubemq-js"); const client = new KubeMQClient({ address: "localhost:50000" }); for (let i = 1; i <= 9; i++) { const orderId = `ORD-${String(i).padStart(3, "0")}`; await client.sendEvent({ channel: "order-events", body: Buffer.from(JSON.stringify({ orderId, status: "created" })), }); console.log(`Published ${orderId}`); await new Promise((r) => setTimeout(r, 200)); } ``` ```java title="OrderPublisher.java" PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId("order-publisher") .build(); for (int i = 1; i <= 9; i++) { String body = String.format( "{\"orderId\":\"ORD-%03d\",\"status\":\"created\"}", i); client.sendEventsMessage(EventMessage.builder() .channel("order-events") .body(body.getBytes()) .build()); System.out.printf("Published ORD-%03d%n", i); Thread.sleep(200); } client.close(); ``` ```csharp title="OrderPublisher.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); for (var i = 1; i <= 9; i++) { var body = $"{{\"orderId\":\"ORD-{i:D3}\",\"status\":\"created\"}}"; await client.SendEventAsync(new EventMessage { Channel = "order-events", Body = Encoding.UTF8.GetBytes(body), }); Console.WriteLine($"Published ORD-{i:D3}"); await Task.Delay(200); } ``` ```kotlin title="OrderPublisher.kt" val client = PubSubClient("localhost:50000") for (i in 1..9) { val body = """{"orderId":"ORD-${"%03d".format(i)}","status":"created"}""" client.sendEvent(EventMessage( channel = "order-events", body = body.toByteArray(), )) println("Published ORD-${"%03d".format(i)}") Thread.sleep(200) } client.close() ``` ```cpp title="order_publisher.cpp" auto client = kubemq::PubSubClient("localhost:50000"); for (int i = 1; i <= 9; i++) { kubemq::EventMessage event; event.channel = "order-events"; event.body = "{\"orderId\":\"ORD-" + std::to_string(i) + "\",\"status\":\"created\"}"; client.sendEvent(event); std::cout << "Published ORD-" << i << std::endl; std::this_thread::sleep_for(std::chrono::milliseconds(200)); } ``` ```rust title="order_publisher.rs" use kubemq::prelude::*; use kubemq::EventBuilder; use std::time::Duration; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; for i in 1..=9 { let body = format!(r#"{{"orderId":"ORD-{:03}","status":"created"}}"#, i); let event = EventBuilder::new() .channel("order-events") .body(body.into_bytes()) .build(); client.send_event(event).await?; println!("Published ORD-{:03}", i); tokio::time::sleep(Duration::from_millis(200)).await; } client.close().await?; Ok(()) } ``` ```ruby title="order_publisher.rb" require 'kubemq' client = KubeMQ::PubSubClient.new(address: "localhost:50000", client_id: "order-publisher") (1..9).each do |i| body = format('{"orderId":"ORD-%03d","status":"created"}', i) client.send_event(KubeMQ::PubSub::EventMessage.new( channel: "order-events", body: body )) puts format("Published ORD-%03d", i) sleep 0.2 end client.close ``` ```elixir title="order_publisher.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-publisher") for i <- 1..9 do id = String.pad_leading(Integer.to_string(i), 3, "0") event = KubeMQ.Event.new( channel: "order-events", body: ~s({"orderId":"ORD-#{id}","status":"created"}) ) :ok = KubeMQ.Client.send_event(client, event) IO.puts("Published ORD-#{id}") Process.sleep(200) end KubeMQ.Client.close(client) ``` ### Create a Consumer Group [#create-a-consumer-group] Three subscribers join the same group. KubeMQ distributes events across the group in round-robin fashion. ```go title="grouped_worker.go" package main import ( "context" "fmt" "log" "os" "github.com/kubemq-io/kubemq-go/v2" ) func main() { workerID := os.Getenv("WORKER_ID") if workerID == "" { workerID = "worker-1" } ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() sub, err := client.SubscribeToEvents(ctx, "order-events", "workers", kubemq.WithOnEvent(func(event *kubemq.Event) { fmt.Printf("[%s] Processing: %s\n", workerID, string(event.Body)) }), kubemq.WithOnError(func(err error) { log.Printf("[%s] Error: %v", workerID, err) }), ) if err != nil { log.Fatal(err) } defer sub.Unsubscribe() log.Printf("[%s] Ready in group 'workers'", workerID) <-ctx.Done() } ``` ```python title="grouped_worker.py" import os import time from kubemq.pubsub import Client as PubSubClient from kubemq.pubsub import EventsSubscription, CancellationToken worker_id = os.environ.get("WORKER_ID", "worker-1") def on_event(event): print(f"[{worker_id}] Processing: {event.body.decode('utf-8')}") client = PubSubClient(address="localhost:50000") client.subscribe_to_events( subscription=EventsSubscription( channel="order-events", group="workers", on_receive_event_callback=on_event, on_error_callback=lambda e: print(f"[{worker_id}] Error: {e}"), ), cancel=CancellationToken(), ) print(f"[{worker_id}] Ready in group 'workers'") time.sleep(300) client.close() ``` ```javascript title="grouped_worker.js" const { KubeMQClient } = require("kubemq-js"); const workerId = process.env.WORKER_ID ?? "worker-1"; const client = new KubeMQClient({ address: "localhost:50000" }); client.subscribeToEvents({ channel: "order-events", group: "workers", onEvent: (msg) => console.log( `[${workerId}] Processing: ${Buffer.from(msg.body).toString()}` ), onError: (err) => console.error(`[${workerId}] Error:`, err.message), }); console.log(`[${workerId}] Ready in group 'workers'`); ``` ```java title="GroupedWorker.java" String workerId = System.getenv().getOrDefault("WORKER_ID", "worker-1"); PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId(workerId) .build(); client.subscribeToEvents(EventsSubscription.builder() .channel("order-events") .group("workers") .onReceiveEventCallback(event -> System.out.printf("[%s] Processing: %s%n", workerId, new String(event.getBody()))) .onErrorCallback(err -> System.err.printf("[%s] Error: %s%n", workerId, err.getMessage())) .build()); System.out.printf("[%s] Ready in group 'workers'%n", workerId); Thread.sleep(300_000); client.close(); ``` ```csharp title="GroupedWorker.cs" var workerId = Environment.GetEnvironmentVariable("WORKER_ID") ?? "worker-1"; await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); Console.WriteLine($"[{workerId}] Ready in group 'workers'"); await foreach (var msg in client.SubscribeToEventsAsync( new EventsSubscription { Channel = "order-events", Group = "workers" })) { Console.WriteLine($"[{workerId}] Processing: " + $"{Encoding.UTF8.GetString(msg.Body.Span)}"); } ``` ```kotlin title="GroupedWorker.kt" val workerId = System.getenv("WORKER_ID") ?: "worker-1" val client = PubSubClient("localhost:50000") client.subscribeToEvents( channel = "order-events", group = "workers", onEvent = { event -> println("[$workerId] Processing: ${String(event.body)}") }, onError = { err -> System.err.println("[$workerId] Error: ${err.message}") } ) println("[$workerId] Ready in group 'workers'") Thread.sleep(300_000) client.close() ``` ```cpp title="grouped_worker.cpp" auto workerId = std::getenv("WORKER_ID") ? std::string(std::getenv("WORKER_ID")) : std::string("worker-1"); auto client = kubemq::PubSubClient("localhost:50000"); client.subscribeToEvents("order-events", "workers", [&workerId](const kubemq::Event& event) { std::cout << "[" << workerId << "] Processing: " << event.body << std::endl; }, [&workerId](const std::string& err) { std::cerr << "[" << workerId << "] Error: " << err << std::endl; } ); std::cout << "[" << workerId << "] Ready in group 'workers'" << std::endl; std::this_thread::sleep_for(std::chrono::seconds(300)); ``` ```rust title="grouped_worker.rs" use kubemq::prelude::*; use std::time::Duration; #[tokio::main] async fn main() -> kubemq::Result<()> { let worker_id = std::env::var("WORKER_ID").unwrap_or_else(|_| "worker-1".to_string()); let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; // Subscribe with a non-empty group -- each event goes to only one member let id = worker_id.clone(); let sub = client .subscribe_to_events( "order-events", "workers", move |event| { let id = id.clone(); Box::pin(async move { println!( "[{}] Processing: {}", id, String::from_utf8_lossy(&event.body) ); }) }, None, ) .await?; println!("[{}] Ready in group 'workers'", worker_id); tokio::time::sleep(Duration::from_secs(300)).await; sub.unsubscribe().await; client.close().await?; Ok(()) } ``` ```ruby title="grouped_worker.rb" require 'kubemq' worker_id = ENV.fetch('WORKER_ID', 'worker-1') client = KubeMQ::PubSubClient.new(address: "localhost:50000", client_id: worker_id) cancel = KubeMQ::CancellationToken.new # A non-empty group makes subscribers compete -- each event goes to one member sub = KubeMQ::PubSub::EventsSubscription.new(channel: "order-events", group: "workers") client.subscribe_to_events(sub, cancellation_token: cancel, on_error: lambda { |e| puts "[#{worker_id}] Error: #{e.message}" }) do |event| puts "[#{worker_id}] Processing: #{event.body}" end puts "[#{worker_id}] Ready in group 'workers'" sleep 300 cancel.cancel client.close ``` ```elixir title="grouped_worker.exs" worker_id = System.get_env("WORKER_ID", "worker-1") {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: worker_id) # A non-empty group makes subscribers compete -- each event goes to one member {:ok, _sub} = KubeMQ.Client.subscribe_to_events(client, "order-events", group: "workers", on_event: fn event -> IO.puts("[#{worker_id}] Processing: #{event.body}") end ) IO.puts("[#{worker_id}] Ready in group 'workers'") Process.sleep(300_000) KubeMQ.Client.close(client) ``` Run three instances with different `WORKER_ID` values: ```bash WORKER_ID=worker-A ./grouped_worker & WORKER_ID=worker-B ./grouped_worker & WORKER_ID=worker-C ./grouped_worker & ``` ### Verify Load Distribution [#verify-load-distribution] Publish 9 events and observe each worker receives approximately 3 events: **Worker A** (receives \~3 events): ```text [worker-A] Processing: {"orderId":"ORD-001","status":"created"} [worker-A] Processing: {"orderId":"ORD-004","status":"created"} [worker-A] Processing: {"orderId":"ORD-007","status":"created"} ``` **Worker B** (receives \~3 events): ```text [worker-B] Processing: {"orderId":"ORD-002","status":"created"} [worker-B] Processing: {"orderId":"ORD-005","status":"created"} [worker-B] Processing: {"orderId":"ORD-008","status":"created"} ``` **Worker C** (receives \~3 events): ```text [worker-C] Processing: {"orderId":"ORD-003","status":"created"} [worker-C] Processing: {"orderId":"ORD-006","status":"created"} [worker-C] Processing: {"orderId":"ORD-009","status":"created"} ``` ## Fan-Out vs Consumer Groups [#fan-out-vs-consumer-groups] *Same channel, two behaviors: fan-out delivers every event to every subscriber; a consumer group splits the load so each event lands on exactly one member.* | Behavior | Fan-Out (no group) | Consumer Group | | --------------- | ---------------------------------------- | ------------------------------------------- | | Delivery | Every subscriber receives every event | Each event goes to exactly one group member | | Use case | Multiple independent consumers | Load-balanced processing | | Group parameter | Empty string `""` | Same group name (e.g., `"workers"`) | | Scaling effect | More subscribers = more total processing | More members = higher throughput | You can combine both patterns: grouped workers for load-balanced processing and an ungrouped monitor that sees all events. See [Scale Subscribers](/learn/events/how-to/scale-subscribers) for this pattern. ## Next Steps [#next-steps] # Multicast Events (/learn/events/tutorials/multicast) ## What You Will Build [#what-you-will-build] An order processing system where a single publish call routes to multiple channels — and even multiple messaging patterns — in one operation. *One publish call routes through the KubeMQ router and fans out to Events, Events Store, and Queues channels simultaneously.* ## Prerequisites [#prerequisites] * KubeMQ server running on `localhost:50000` * SDK installed ([Getting Started](/learn/events/getting-started)) ## Routing Syntax [#routing-syntax] | Character | Purpose | Example | | --------- | ------------------------------------------- | --------------------------------------------- | | `;` | Separate multiple channels of the same type | `orders;notifications` sends to both channels | | `:` | Specify the target pattern type | `events:orders;events_store:audit-log` | ### Channel Type Prefixes [#channel-type-prefixes] | Prefix | Pattern | | --------------- | ---------------------------- | | `events:` | Events (fire-and-forget) | | `events_store:` | Events Store (persistent) | | `queues:` | Queues (guaranteed delivery) | When no prefix is provided, the channel uses the same pattern as the original publish call. ## Steps [#steps] ### Multicast to Same-Pattern Channels [#multicast-to-same-pattern-channels] Publish one event to multiple Events channels using the `;` separator. ```go title="same_pattern_multicast.go" package main import ( "context" "log" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() err = client.SendEvent(ctx, kubemq.NewEvent(). SetChannel("orders;notifications"). SetBody([]byte(`{"orderId":"ORD-500","status":"created"}`)), ) if err != nil { log.Fatal(err) } log.Println("Multicast event sent to orders and notifications") } ``` ```python title="same_pattern_multicast.py" from kubemq.pubsub import Client as PubSubClient from kubemq.pubsub import EventMessage client = PubSubClient(address="localhost:50000") client.send_event( EventMessage( channel="orders;notifications", body=b'{"orderId":"ORD-500","status":"created"}', ) ) print("Multicast event sent to orders and notifications") client.close() ``` ```javascript title="same_pattern_multicast.js" const { KubeMQClient } = require("kubemq-js"); const client = new KubeMQClient({ address: "localhost:50000" }); await client.sendEvent({ channel: "orders;notifications", body: Buffer.from('{"orderId":"ORD-500","status":"created"}'), }); console.log("Multicast event sent to orders and notifications"); ``` ```java title="SamePatternMulticast.java" PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId("multicast-publisher") .build(); client.sendEventsMessage(EventMessage.builder() .channel("orders;notifications") .body("{\"orderId\":\"ORD-500\",\"status\":\"created\"}".getBytes()) .build()); System.out.println("Multicast event sent to orders and notifications"); client.close(); ``` ```csharp title="SamePatternMulticast.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); await client.SendEventAsync(new EventMessage { Channel = "orders;notifications", Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-500\",\"status\":\"created\"}") }); Console.WriteLine("Multicast event sent to orders and notifications"); ``` ```kotlin title="SamePatternMulticast.kt" val client = PubSubClient("localhost:50000") client.sendEvent(EventMessage( channel = "orders;notifications", body = """{"orderId":"ORD-500","status":"created"}""".toByteArray() )) println("Multicast event sent to orders and notifications") client.close() ``` ```cpp title="same_pattern_multicast.cpp" auto client = kubemq::PubSubClient("localhost:50000"); kubemq::EventMessage event; event.channel = "orders;notifications"; event.body = R"({"orderId":"ORD-500","status":"created"})"; client.sendEvent(event); std::cout << "Multicast event sent to orders and notifications" << std::endl; ``` ```rust title="same_pattern_multicast.rs" use kubemq::prelude::*; use kubemq::EventBuilder; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; let event = EventBuilder::new() .channel("orders;notifications") .body(b"{\"orderId\":\"ORD-500\",\"status\":\"created\"}".to_vec()) .build(); client.send_event(event).await?; println!("Multicast event sent to orders and notifications"); client.close().await?; Ok(()) } ``` ```ruby title="same_pattern_multicast.rb" require 'kubemq' client = KubeMQ::PubSubClient.new(address: 'localhost:50000', client_id: 'multicast-publisher') msg = KubeMQ::PubSub::EventMessage.new( channel: 'orders;notifications', body: '{"orderId":"ORD-500","status":"created"}' ) client.send_event(msg) puts 'Multicast event sent to orders and notifications' client.close ``` ```elixir title="same_pattern_multicast.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "multicast-publisher") event = KubeMQ.Event.new( channel: "orders;notifications", body: ~s({"orderId":"ORD-500","status":"created"}) ) :ok = KubeMQ.Client.send_event(client, event) IO.puts("Multicast event sent to orders and notifications") KubeMQ.Client.close(client) ``` ### Multicast Across Different Patterns [#multicast-across-different-patterns] Use the `:` prefix to route one publish to Events, Events Store, and Queues simultaneously. ```go title="cross_pattern_multicast.go" err = client.SendEvent(ctx, kubemq.NewEvent(). SetChannel("events:orders;events_store:audit-log;queues:shipping-tasks"). SetBody([]byte(`{"orderId":"ORD-600","action":"ship"}`)), ) if err != nil { log.Fatal(err) } log.Println("Cross-pattern multicast: events, events_store, queues") ``` ```python title="cross_pattern_multicast.py" client.send_event( EventMessage( channel="events:orders;events_store:audit-log;queues:shipping-tasks", body=b'{"orderId":"ORD-600","action":"ship"}', ) ) print("Cross-pattern multicast: events, events_store, queues") ``` ```javascript title="cross_pattern_multicast.js" await client.sendEvent({ channel: "events:orders;events_store:audit-log;queues:shipping-tasks", body: Buffer.from('{"orderId":"ORD-600","action":"ship"}'), }); console.log("Cross-pattern multicast: events, events_store, queues"); ``` ```java title="CrossPatternMulticast.java" client.sendEventsMessage(EventMessage.builder() .channel("events:orders;events_store:audit-log;queues:shipping-tasks") .body("{\"orderId\":\"ORD-600\",\"action\":\"ship\"}".getBytes()) .build()); System.out.println("Cross-pattern multicast: events, events_store, queues"); ``` ```csharp title="CrossPatternMulticast.cs" await client.SendEventAsync(new EventMessage { Channel = "events:orders;events_store:audit-log;queues:shipping-tasks", Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-600\",\"action\":\"ship\"}") }); Console.WriteLine("Cross-pattern multicast: events, events_store, queues"); ``` ```kotlin title="CrossPatternMulticast.kt" client.sendEvent(EventMessage( channel = "events:orders;events_store:audit-log;queues:shipping-tasks", body = """{"orderId":"ORD-600","action":"ship"}""".toByteArray() )) println("Cross-pattern multicast: events, events_store, queues") ``` ```cpp title="cross_pattern_multicast.cpp" kubemq::EventMessage event; event.channel = "events:orders;events_store:audit-log;queues:shipping-tasks"; event.body = R"({"orderId":"ORD-600","action":"ship"})"; client.sendEvent(event); std::cout << "Cross-pattern multicast: events, events_store, queues" << std::endl; ``` ```rust title="cross_pattern_multicast.rs" let event = EventBuilder::new() .channel("events:orders;events_store:audit-log;queues:shipping-tasks") .body(b"{\"orderId\":\"ORD-600\",\"action\":\"ship\"}".to_vec()) .build(); client.send_event(event).await?; println!("Cross-pattern multicast: events, events_store, queues"); ``` ```ruby title="cross_pattern_multicast.rb" msg = KubeMQ::PubSub::EventMessage.new( channel: 'events:orders;events_store:audit-log;queues:shipping-tasks', body: '{"orderId":"ORD-600","action":"ship"}' ) client.send_event(msg) puts 'Cross-pattern multicast: events, events_store, queues' ``` ```elixir title="cross_pattern_multicast.exs" event = KubeMQ.Event.new( channel: "events:orders;events_store:audit-log;queues:shipping-tasks", body: ~s({"orderId":"ORD-600","action":"ship"}) ) :ok = KubeMQ.Client.send_event(client, event) IO.puts("Cross-pattern multicast: events, events_store, queues") ``` ### Verify Delivery [#verify-delivery] Set up subscribers on each target channel. The multicast message arrives at all destinations. **Expected output:** ```text [Events] orders: {"orderId":"ORD-600","action":"ship"} [Events Store] audit-log: {"orderId":"ORD-600","action":"ship"} [Queue] shipping-tasks: {"orderId":"ORD-600","action":"ship"} ``` ## How Multicast Works Internally [#how-multicast-works-internally] 1. KubeMQ **parses** the channel string into a route map keyed by pattern type 2. **Sends the first destination synchronously** and returns its result to the caller 3. **Fans out remaining destinations asynchronously** in background goroutines 4. Routed messages are tagged with `X-KUBEMQ-ROUTED=true` automatically Only the **first destination's result** is returned to the publisher. Errors on other destinations are logged server-side but do not affect the publish response. ## Common Multicast Patterns [#common-multicast-patterns] | Channel String | Behavior | | ------------------------------------------------- | --------------------------------------------------------- | | `a;b;c` | Send as Events to channels `a`, `b`, and `c` | | `events:a;events_store:b` | Send as Event to `a` and as persistent Event Store to `b` | | `events:a;queues:task-queue` | Broadcast event and queue a task simultaneously | | `events_store:audit;queues:process;events:notify` | Fan out to all three patterns | ## Next Steps [#next-steps] # Publish & Subscribe (/learn/events/tutorials/publish-subscribe) ## What You Will Build [#what-you-will-build] A publisher sending order events with metadata and tags, and two subscribers both receiving every event (fan-out). *One publisher fans out every order event to both subscribers at-most-once.* ## Prerequisites [#prerequisites] * KubeMQ server running on `localhost:50000` * SDK installed ([Getting Started](/learn/events/getting-started)) ## Steps [#steps] ### Set Up the Publisher [#set-up-the-publisher] The publisher sends order events with a JSON body, metadata, and tags for downstream filtering. ```go title="order_publisher.go" package main import ( "context" "fmt" "log" "time" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() orders := []struct { ID string Amount float64 Region string }{ {"ORD-001", 99.99, "us-east"}, {"ORD-002", 249.50, "eu-west"}, {"ORD-003", 15.00, "us-east"}, } for _, order := range orders { body := fmt.Sprintf( `{"orderId":"%s","amount":%.2f,"region":"%s"}`, order.ID, order.Amount, order.Region) err = client.SendEvent(ctx, kubemq.NewEvent(). SetChannel("order-events"). SetMetadata("order.created"). SetBody([]byte(body)). SetTags(map[string]string{"region": order.Region}), ) if err != nil { log.Printf("Failed to send event for %s: %v", order.ID, err) continue } log.Printf("Published order event: %s", order.ID) time.Sleep(500 * time.Millisecond) } } ``` ```python title="order_publisher.py" import json import time from kubemq.pubsub import Client as PubSubClient from kubemq.pubsub import EventMessage orders = [ {"orderId": "ORD-001", "amount": 99.99, "region": "us-east"}, {"orderId": "ORD-002", "amount": 249.50, "region": "eu-west"}, {"orderId": "ORD-003", "amount": 15.00, "region": "us-east"}, ] client = PubSubClient(address="localhost:50000") for order in orders: try: client.send_event( EventMessage( channel="order-events", metadata="order.created", body=json.dumps(order).encode("utf-8"), tags={"region": order["region"]}, ) ) print(f"Published order event: {order['orderId']}") except Exception as e: print(f"Failed to send event for {order['orderId']}: {e}") time.sleep(0.5) client.close() ``` ```javascript title="order_publisher.js" const { KubeMQClient } = require("kubemq-js"); const client = new KubeMQClient({ address: "localhost:50000" }); const orders = [ { orderId: "ORD-001", amount: 99.99, region: "us-east" }, { orderId: "ORD-002", amount: 249.50, region: "eu-west" }, { orderId: "ORD-003", amount: 15.00, region: "us-east" }, ]; for (const order of orders) { try { await client.sendEvent({ channel: "order-events", metadata: "order.created", body: Buffer.from(JSON.stringify(order)), tags: { region: order.region }, }); console.log(`Published order event: ${order.orderId}`); } catch (err) { console.error(`Failed to send event for ${order.orderId}:`, err); } await new Promise((r) => setTimeout(r, 500)); } ``` ```java title="OrderPublisher.java" PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId("order-publisher") .build(); String[][] orders = { {"ORD-001", "99.99", "us-east"}, {"ORD-002", "249.50", "eu-west"}, {"ORD-003", "15.00", "us-east"}, }; for (String[] order : orders) { String body = String.format( "{\"orderId\":\"%s\",\"amount\":%s,\"region\":\"%s\"}", order[0], order[1], order[2]); try { client.sendEventsMessage(EventMessage.builder() .channel("order-events") .metadata("order.created") .body(body.getBytes()) .tags(Map.of("region", order[2])) .build()); System.out.println("Published order event: " + order[0]); } catch (Exception e) { System.err.println("Failed to send: " + e.getMessage()); } Thread.sleep(500); } client.close(); ``` ```csharp title="OrderPublisher.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); var orders = new[] { new { Id = "ORD-001", Amount = 99.99, Region = "us-east" }, new { Id = "ORD-002", Amount = 249.50, Region = "eu-west" }, new { Id = "ORD-003", Amount = 15.00, Region = "us-east" }, }; foreach (var order in orders) { var body = $"{{\"orderId\":\"{order.Id}\",\"amount\":{order.Amount},\"region\":\"{order.Region}\"}}"; await client.SendEventAsync(new EventMessage { Channel = "order-events", Metadata = "order.created", Body = Encoding.UTF8.GetBytes(body), Tags = new Dictionary { ["region"] = order.Region }, }); Console.WriteLine($"Published order event: {order.Id}"); await Task.Delay(500); } ``` ```kotlin title="OrderPublisher.kt" val client = PubSubClient("localhost:50000") data class Order(val id: String, val amount: Double, val region: String) val orders = listOf( Order("ORD-001", 99.99, "us-east"), Order("ORD-002", 249.50, "eu-west"), Order("ORD-003", 15.00, "us-east"), ) for (order in orders) { val body = """{"orderId":"${order.id}","amount":${order.amount},"region":"${order.region}"}""" client.sendEvent(EventMessage( channel = "order-events", metadata = "order.created", body = body.toByteArray(), tags = mapOf("region" to order.region), )) println("Published order event: ${order.id}") Thread.sleep(500) } client.close() ``` ```cpp title="order_publisher.cpp" #include #include #include #include auto client = kubemq::PubSubClient("localhost:50000"); struct Order { std::string id; double amount; std::string region; }; std::vector orders = { {"ORD-001", 99.99, "us-east"}, {"ORD-002", 249.50, "eu-west"}, {"ORD-003", 15.00, "us-east"}, }; for (const auto& order : orders) { kubemq::EventMessage event; event.channel = "order-events"; event.metadata = "order.created"; event.body = "{\"orderId\":\"" + order.id + "\",\"amount\":" + std::to_string(order.amount) + ",\"region\":\"" + order.region + "\"}"; event.tags["region"] = order.region; client.sendEvent(event); std::cout << "Published order event: " << order.id << std::endl; std::this_thread::sleep_for(std::chrono::milliseconds(500)); } ``` ```rust title="order_publisher.rs" use kubemq::prelude::*; use kubemq::EventBuilder; use std::collections::HashMap; use std::time::Duration; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; let orders = [ ("ORD-001", 99.99, "us-east"), ("ORD-002", 249.50, "eu-west"), ("ORD-003", 15.00, "us-east"), ]; for (id, amount, region) in orders { let body = format!( r#"{{"orderId":"{id}","amount":{amount},"region":"{region}"}}"# ); let mut tags = HashMap::new(); tags.insert("region".to_string(), region.to_string()); let event = EventBuilder::new() .channel("order-events") .metadata("order.created") .body(body.into_bytes()) .tags(tags) .build(); match client.send_event(event).await { Ok(_) => println!("Published order event: {id}"), Err(e) => eprintln!("Failed to send event for {id}: {e}"), } tokio::time::sleep(Duration::from_millis(500)).await; } client.close().await?; Ok(()) } ``` ```ruby title="order_publisher.rb" require 'kubemq' require 'json' client = KubeMQ::PubSubClient.new(address: 'localhost:50000', client_id: 'order-publisher') orders = [ { orderId: 'ORD-001', amount: 99.99, region: 'us-east' }, { orderId: 'ORD-002', amount: 249.50, region: 'eu-west' }, { orderId: 'ORD-003', amount: 15.00, region: 'us-east' }, ] orders.each do |order| begin msg = KubeMQ::PubSub::EventMessage.new( channel: 'order-events', metadata: 'order.created', body: order.to_json, tags: { 'region' => order[:region] } ) client.send_event(msg) puts "Published order event: #{order[:orderId]}" rescue KubeMQ::Error => e puts "Failed to send event for #{order[:orderId]}: #{e.message}" end sleep 0.5 end client.close ``` ```elixir title="order_publisher.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-publisher") orders = [ %{order_id: "ORD-001", amount: 99.99, region: "us-east"}, %{order_id: "ORD-002", amount: 249.50, region: "eu-west"}, %{order_id: "ORD-003", amount: 15.00, region: "us-east"} ] for order <- orders do body = ~s({"orderId":"#{order.order_id}","amount":#{order.amount},"region":"#{order.region}"}) event = KubeMQ.Event.new( channel: "order-events", metadata: "order.created", body: body, tags: %{"region" => order.region} ) case KubeMQ.Client.send_event(client, event) do :ok -> IO.puts("Published order event: #{order.order_id}") {:error, err} -> IO.puts("Failed to send event for #{order.order_id}: #{err.message}") end Process.sleep(500) end KubeMQ.Client.close(client) ``` ### Set Up Subscriber A [#set-up-subscriber-a] The notification service processes every order event. ```go title="notification_service.go" package main import ( "context" "fmt" "log" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() sub, err := client.SubscribeToEvents(ctx, "order-events", "", kubemq.WithOnEvent(func(event *kubemq.Event) { fmt.Printf("[Notification] New order: %s | metadata: %s\n", string(event.Body), event.Metadata) }), kubemq.WithOnError(func(err error) { log.Println("[Notification] Error:", err) }), ) if err != nil { log.Fatal(err) } defer sub.Unsubscribe() log.Println("[Notification] Service listening...") <-ctx.Done() } ``` ```python title="notification_service.py" import time from kubemq.pubsub import Client as PubSubClient from kubemq.pubsub import EventsSubscription, CancellationToken def on_event(event): print(f"[Notification] New order: " f"{event.body.decode('utf-8')} | metadata: {event.metadata}") client = PubSubClient(address="localhost:50000") client.subscribe_to_events( subscription=EventsSubscription( channel="order-events", on_receive_event_callback=on_event, on_error_callback=lambda e: print(f"[Notification] Error: {e}"), ), cancel=CancellationToken(), ) print("[Notification] Service listening...") time.sleep(300) client.close() ``` ```javascript title="notification_service.js" const { KubeMQClient } = require("kubemq-js"); const client = new KubeMQClient({ address: "localhost:50000" }); client.subscribeToEvents({ channel: "order-events", onEvent: (msg) => console.log( `[Notification] New order: ${Buffer.from(msg.body).toString()}` ), onError: (err) => console.error("[Notification] Error:", err.message), }); console.log("[Notification] Service listening..."); ``` ```java title="NotificationService.java" PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId("notification-service") .build(); client.subscribeToEvents(EventsSubscription.builder() .channel("order-events") .onReceiveEventCallback(event -> System.out.println("[Notification] New order: " + new String(event.getBody()))) .onErrorCallback(err -> System.err.println("[Notification] Error: " + err.getMessage())) .build()); System.out.println("[Notification] Service listening..."); Thread.sleep(300_000); client.close(); ``` ```csharp title="NotificationService.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); Console.WriteLine("[Notification] Service listening..."); await foreach (var msg in client.SubscribeToEventsAsync( new EventsSubscription { Channel = "order-events" })) { Console.WriteLine($"[Notification] New order: " + $"{Encoding.UTF8.GetString(msg.Body.Span)}"); } ``` ```kotlin title="NotificationService.kt" val client = PubSubClient("localhost:50000") client.subscribeToEvents( channel = "order-events", onEvent = { event -> println("[Notification] New order: ${String(event.body)}") }, onError = { err -> System.err.println("[Notification] Error: ${err.message}") } ) println("[Notification] Service listening...") Thread.sleep(300_000) client.close() ``` ```cpp title="notification_service.cpp" auto client = kubemq::PubSubClient("localhost:50000"); client.subscribeToEvents("order-events", "", [](const kubemq::Event& event) { std::cout << "[Notification] New order: " << event.body << std::endl; }, [](const std::string& err) { std::cerr << "[Notification] Error: " << err << std::endl; } ); std::cout << "[Notification] Service listening..." << std::endl; std::this_thread::sleep_for(std::chrono::seconds(300)); ``` ```rust title="notification_service.rs" use kubemq::prelude::*; use kubemq::Subscription; use std::time::Duration; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; // Subscribe with an empty group -- every subscriber receives every event let sub: Subscription = client .subscribe_to_events( "order-events", "", |event| { Box::pin(async move { println!( "[Notification] New order: {} | metadata: {}", String::from_utf8_lossy(&event.body), event.metadata ); }) }, None, ) .await?; println!("[Notification] Service listening..."); tokio::time::sleep(Duration::from_secs(300)).await; sub.unsubscribe().await; client.close().await?; Ok(()) } ``` ```ruby title="notification_service.rb" require 'kubemq' client = KubeMQ::PubSubClient.new(address: 'localhost:50000', client_id: 'notification-service') cancel = KubeMQ::CancellationToken.new sub = KubeMQ::PubSub::EventsSubscription.new(channel: 'order-events') client.subscribe_to_events( sub, cancellation_token: cancel, on_error: ->(e) { puts "[Notification] Error: #{e.message}" } ) do |event| puts "[Notification] New order: #{event.body} | metadata: #{event.metadata}" end puts '[Notification] Service listening...' sleep 300 cancel.cancel client.close ``` ```elixir title="notification_service.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "notification-service") {:ok, _sub} = KubeMQ.Client.subscribe_to_events(client, "order-events", on_event: fn event -> IO.puts("[Notification] New order: #{event.body} | metadata: #{event.metadata}") end, on_error: fn err -> IO.puts("[Notification] Error: #{err.message}") end ) IO.puts("[Notification] Service listening...") Process.sleep(300_000) KubeMQ.Client.close(client) ``` ### Set Up Subscriber B [#set-up-subscriber-b] A second subscriber receives the same events independently for analytics processing. ```go title="analytics_service.go" sub, err := client.SubscribeToEvents(ctx, "order-events", "", kubemq.WithOnEvent(func(event *kubemq.Event) { fmt.Printf("[Analytics] Processing: %s\n", string(event.Body)) }), kubemq.WithOnError(func(err error) { log.Println("[Analytics] Error:", err) }), ) ``` ```python title="analytics_service.py" def on_event(event): print(f"[Analytics] Processing: {event.body.decode('utf-8')}") client.subscribe_to_events( subscription=EventsSubscription( channel="order-events", on_receive_event_callback=on_event, on_error_callback=lambda e: print(f"[Analytics] Error: {e}"), ), cancel=CancellationToken(), ) print("[Analytics] Service listening...") ``` ```javascript title="analytics_service.js" client.subscribeToEvents({ channel: "order-events", onEvent: (msg) => console.log( `[Analytics] Processing: ${Buffer.from(msg.body).toString()}` ), onError: (err) => console.error("[Analytics] Error:", err.message), }); console.log("[Analytics] Service listening..."); ``` ```java title="AnalyticsService.java" client.subscribeToEvents(EventsSubscription.builder() .channel("order-events") .onReceiveEventCallback(event -> System.out.println("[Analytics] Processing: " + new String(event.getBody()))) .onErrorCallback(err -> System.err.println("[Analytics] Error: " + err.getMessage())) .build()); System.out.println("[Analytics] Service listening..."); ``` ```csharp title="AnalyticsService.cs" Console.WriteLine("[Analytics] Service listening..."); await foreach (var msg in client.SubscribeToEventsAsync( new EventsSubscription { Channel = "order-events" })) { Console.WriteLine($"[Analytics] Processing: " + $"{Encoding.UTF8.GetString(msg.Body.Span)}"); } ``` ```kotlin title="AnalyticsService.kt" client.subscribeToEvents( channel = "order-events", onEvent = { event -> println("[Analytics] Processing: ${String(event.body)}") }, onError = { err -> System.err.println("[Analytics] Error: ${err.message}") } ) println("[Analytics] Service listening...") ``` ```cpp title="analytics_service.cpp" client.subscribeToEvents("order-events", "", [](const kubemq::Event& event) { std::cout << "[Analytics] Processing: " << event.body << std::endl; }, [](const std::string& err) { std::cerr << "[Analytics] Error: " << err << std::endl; } ); std::cout << "[Analytics] Service listening..." << std::endl; ``` ```rust title="analytics_service.rs" // Same channel, empty group -- this subscriber receives the same events // independently of the notification service. let sub: Subscription = client .subscribe_to_events( "order-events", "", |event| { Box::pin(async move { println!( "[Analytics] Processing: {}", String::from_utf8_lossy(&event.body) ); }) }, None, ) .await?; println!("[Analytics] Service listening..."); ``` ```ruby title="analytics_service.rb" # Same channel, no group -- this subscriber receives the same events # independently of the notification service. sub = KubeMQ::PubSub::EventsSubscription.new(channel: 'order-events') client.subscribe_to_events( sub, cancellation_token: cancel, on_error: ->(e) { puts "[Analytics] Error: #{e.message}" } ) do |event| puts "[Analytics] Processing: #{event.body}" end puts '[Analytics] Service listening...' ``` ```elixir title="analytics_service.exs" # Same channel, no group -- this subscriber receives the same events # independently of the notification service. {:ok, _sub} = KubeMQ.Client.subscribe_to_events(client, "order-events", on_event: fn event -> IO.puts("[Analytics] Processing: #{event.body}") end, on_error: fn err -> IO.puts("[Analytics] Error: #{err.message}") end ) IO.puts("[Analytics] Service listening...") ``` ### Observe Fan-Out [#observe-fan-out] Start both subscribers in separate terminals, then run the publisher. Both subscribers receive every event. No acknowledgment needed. **Notification Service output:** ```text [Notification] New order: {"orderId":"ORD-001","amount":99.99,"region":"us-east"} [Notification] New order: {"orderId":"ORD-002","amount":249.50,"region":"eu-west"} [Notification] New order: {"orderId":"ORD-003","amount":15.00,"region":"us-east"} ``` **Analytics Service output:** ```text [Analytics] Processing: {"orderId":"ORD-001","amount":99.99,"region":"us-east"} [Analytics] Processing: {"orderId":"ORD-002","amount":249.50,"region":"eu-west"} [Analytics] Processing: {"orderId":"ORD-003","amount":15.00,"region":"us-east"} ``` ## How Fan-Out Works [#how-fan-out-works] *KubeMQ delivers each published event to every active subscriber independently — no acknowledgment, no ordering coordination between subscribers.* ## Key Points [#key-points] * **No acknowledgment needed** — fire-and-forget delivery * **Missed messages** — if a subscriber is offline, events sent while it was disconnected are lost * **Metadata and tags** — propagated to all subscribers for downstream filtering and routing If a subscriber is slow to process events, messages may be dropped after the write deadline (default 2 seconds). See [Handle Slow Consumers](/learn/events/how-to/handle-slow-consumers) for mitigation strategies. ## Next Steps [#next-steps] # Stream Publishing (/learn/events/tutorials/stream-publishing) ## What You Will Build [#what-you-will-build] This tutorial uses **Events** — ephemeral, fire-and-forget pub/sub with no persistence and no replay. For the persistent, replayable version of stream publishing, see [Events Store stream publishing](/learn/events-store/tutorials/stream-publishing). A high-throughput event publisher that uses bidirectional streaming to send large volumes of events efficiently, with backpressure handling and error recovery. A stream is a single long-lived bidirectional channel: the client opens it once, pushes many events through it, then closes it. *A persistent stream amortizes gRPC overhead across many events instead of paying it per call.* ## Prerequisites [#prerequisites] * KubeMQ server running on `localhost:50000` * SDK installed ([Getting Started](/learn/events/getting-started)) ## Stream vs Single Send [#stream-vs-single-send] | Aspect | Single Send | Stream Publishing | | ------------ | ---------------------- | -------------------------------------- | | Connection | New RPC per event | Persistent bidirectional stream | | Throughput | Moderate | High (batched I/O) | | Overhead | Per-call gRPC overhead | Amortized over stream lifetime | | Backpressure | None (fire-and-forget) | Built-in flow control | | Use case | Low-to-moderate volume | High-frequency telemetry, log shipping | ## Steps [#steps] ### Open an Event Stream [#open-an-event-stream] Create a persistent streaming connection and send events through it. ```go title="stream_publisher.go" package main import ( "context" "fmt" "log" "time" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() streamCh := make(chan *kubemq.Event, 100) resultCh := make(chan *kubemq.EventSendResult, 100) go client.StreamEvents(ctx, streamCh, resultCh) for i := 0; i < 1000; i++ { body := fmt.Sprintf(`{"orderId":"ORD-%04d","timestamp":%d}`, i, time.Now().UnixMilli()) streamCh <- kubemq.NewEvent(). SetChannel("order-stream"). SetBody([]byte(body)) select { case result := <-resultCh: if !result.Sent { log.Printf("Failed to send event %d: %s", i, result.Error) } case <-time.After(5 * time.Second): log.Printf("Timeout waiting for result on event %d", i) } } close(streamCh) log.Println("Stream publishing complete: 1000 events sent") } ``` ```python title="stream_publisher.py" import time from kubemq.pubsub import Client as PubSubClient from kubemq.pubsub import EventMessage client = PubSubClient(address="localhost:50000") stream = client.open_events_stream() for i in range(1000): body = f'{{"orderId":"ORD-{i:04d}","timestamp":{int(time.time() * 1000)}}}' result = stream.send( EventMessage(channel="order-stream", body=body.encode("utf-8")) ) if result and not result.sent: print(f"Failed to send event {i}: {result.error}") stream.close() print("Stream publishing complete: 1000 events sent") client.close() ``` ```javascript title="stream_publisher.js" const { KubeMQClient, createEventMessage } = require("kubemq-js"); const client = await KubeMQClient.create({ address: "localhost:50000" }); const stream = client.createEventStream(); stream.onError((err) => console.error("Stream error:", err.message)); for (let i = 0; i < 1000; i++) { const body = JSON.stringify({ orderId: `ORD-${String(i).padStart(4, "0")}`, timestamp: Date.now(), }); stream.send(createEventMessage({ channel: "order-stream", body: Buffer.from(body), })); } stream.close(); console.log("Stream publishing complete: 1000 events sent"); await client.close(); ``` ```java title="StreamPublisher.java" PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId("stream-publisher") .build(); EventsStream stream = client.openEventsStream(); for (int i = 0; i < 1000; i++) { String body = String.format( "{\"orderId\":\"ORD-%04d\",\"timestamp\":%d}", i, System.currentTimeMillis()); EventSendResult result = stream.send(EventMessage.builder() .channel("order-stream") .body(body.getBytes()) .build()); if (result != null && !result.isSent()) { System.err.printf("Failed to send event %d: %s%n", i, result.getError()); } } stream.close(); System.out.println("Stream publishing complete: 1000 events sent"); client.close(); ``` ```csharp title="StreamPublisher.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); var stream = client.OpenEventsStream(); for (var i = 0; i < 1000; i++) { var body = $"{{\"orderId\":\"ORD-{i:D4}\",\"timestamp\":{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}}}"; var result = await stream.SendAsync(new EventMessage { Channel = "order-stream", Body = Encoding.UTF8.GetBytes(body), }); if (result is { Sent: false }) { Console.Error.WriteLine($"Failed to send event {i}: {result.Error}"); } } stream.Close(); Console.WriteLine("Stream publishing complete: 1000 events sent"); ``` ```kotlin title="StreamPublisher.kt" val client = PubSubClient("localhost:50000") val stream = client.openEventsStream() for (i in 0 until 1000) { val body = """{"orderId":"ORD-${"%04d".format(i)}","timestamp":${System.currentTimeMillis()}}""" val result = stream.send(EventMessage( channel = "order-stream", body = body.toByteArray() )) if (result != null && !result.sent) { System.err.println("Failed to send event $i: ${result.error}") } } stream.close() println("Stream publishing complete: 1000 events sent") client.close() ``` ```cpp title="stream_publisher.cpp" auto client = kubemq::PubSubClient("localhost:50000"); auto stream = client.openEventsStream(); for (int i = 0; i < 1000; i++) { kubemq::EventMessage event; event.channel = "order-stream"; event.body = "{\"orderId\":\"ORD-" + std::to_string(i) + "\",\"timestamp\":" + std::to_string(std::chrono::system_clock::now() .time_since_epoch().count()) + "}"; auto result = stream.send(event); if (result && !result->sent) { std::cerr << "Failed to send event " << i << ": " << result->error << std::endl; } } stream.close(); std::cout << "Stream publishing complete: 1000 events sent" << std::endl; ``` ```rust title="stream_publisher.rs" use kubemq::prelude::*; use kubemq::EventBuilder; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; let mut stream = client.send_event_stream().await?; for i in 0..1000 { let body = format!( "{{\"orderId\":\"ORD-{:04}\",\"timestamp\":{}}}", i, chrono::Utc::now().timestamp_millis() ); let event = EventBuilder::new() .channel("order-stream") .body(body.into_bytes()) .build(); stream.send(event).await?; } // Drain any stream errors reported asynchronously while let Ok(err) = stream.errors().try_recv() { eprintln!("Stream error: {}", err); } stream.close(); client.close().await?; println!("Stream publishing complete: 1000 events sent"); Ok(()) } ``` ```ruby title="stream_publisher.rb" require "kubemq" client = KubeMQ::PubSubClient.new( address: "localhost:50000", client_id: "stream-publisher" ) sender = client.create_events_sender 1000.times do |i| body = %({"orderId":"ORD-#{format('%04d', i)}","timestamp":#{(Time.now.to_f * 1000).to_i}}) sender.publish( KubeMQ::PubSub::EventMessage.new(channel: "order-stream", body: body) ) end sender.close client.close puts "Stream publishing complete: 1000 events sent" ``` ```elixir title="stream_publisher.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "stream-publisher") {:ok, handle} = KubeMQ.Client.send_event_stream(client) Enum.each(0..999, fn i -> body = ~s({"orderId":"ORD-#{String.pad_leading(Integer.to_string(i), 4, "0")}",) <> ~s("timestamp":#{System.system_time(:millisecond)}}) event = KubeMQ.Event.new(channel: "order-stream", body: body) KubeMQ.EventStreamHandle.send(handle, event) end) KubeMQ.Client.close(client) IO.puts("Stream publishing complete: 1000 events sent") ``` ### Create a Subscriber [#create-a-subscriber] Subscribe to the stream channel to verify delivery. ```go title="stream_subscriber.go" counter := 0 sub, err := client.SubscribeToEvents(ctx, "order-stream", "", kubemq.WithOnEvent(func(event *kubemq.Event) { counter++ if counter%100 == 0 { fmt.Printf("Received %d events (latest: %s)\n", counter, string(event.Body)) } }), kubemq.WithOnError(func(err error) { log.Println("Error:", err) }), ) ``` ```python title="stream_subscriber.py" counter = 0 def on_event(event): global counter counter += 1 if counter % 100 == 0: print(f"Received {counter} events (latest: {event.body.decode()})") client.subscribe_to_events( subscription=EventsSubscription( channel="order-stream", on_receive_event_callback=on_event, on_error_callback=lambda e: print(f"Error: {e}"), ), cancel=CancellationToken(), ) ``` ```javascript title="stream_subscriber.js" let counter = 0; client.subscribeToEvents({ channel: "order-stream", onEvent: (msg) => { counter++; if (counter % 100 === 0) { console.log( `Received ${counter} events (latest: ${Buffer.from(msg.body).toString()})` ); } }, onError: (err) => console.error("Error:", err.message), }); ``` ```java title="StreamSubscriber.java" AtomicInteger counter = new AtomicInteger(0); client.subscribeToEvents(EventsSubscription.builder() .channel("order-stream") .onReceiveEventCallback(event -> { int count = counter.incrementAndGet(); if (count % 100 == 0) { System.out.printf("Received %d events (latest: %s)%n", count, new String(event.getBody())); } }) .onErrorCallback(err -> System.err.println("Error: " + err.getMessage())) .build()); ``` ```csharp title="StreamSubscriber.cs" var counter = 0; await foreach (var msg in client.SubscribeToEventsAsync( new EventsSubscription { Channel = "order-stream" })) { counter++; if (counter % 100 == 0) { Console.WriteLine($"Received {counter} events (latest: " + $"{Encoding.UTF8.GetString(msg.Body.Span)})"); } } ``` ```kotlin title="StreamSubscriber.kt" var counter = 0 client.subscribeToEvents( channel = "order-stream", onEvent = { event -> counter++ if (counter % 100 == 0) { println("Received $counter events (latest: ${String(event.body)})") } }, onError = { err -> System.err.println("Error: ${err.message}") } ) ``` ```cpp title="stream_subscriber.cpp" int counter = 0; client.subscribeToEvents("order-stream", "", [&counter](const kubemq::Event& event) { counter++; if (counter % 100 == 0) { std::cout << "Received " << counter << " events (latest: " << event.body << ")" << std::endl; } }, [](const std::string& err) { std::cerr << "Error: " << err << std::endl; } ); ``` ```rust title="stream_subscriber.rs" use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; let counter = Arc::new(AtomicUsize::new(0)); let counter_cb = counter.clone(); let sub = client .subscribe_to_events( "order-stream", "", move |event| { let counter = counter_cb.clone(); Box::pin(async move { let n = counter.fetch_add(1, Ordering::SeqCst) + 1; if n % 100 == 0 { println!( "Received {} events (latest: {})", n, String::from_utf8_lossy(&event.body) ); } }) }, None, ) .await?; ``` ```ruby title="stream_subscriber.rb" counter = 0 cancel = KubeMQ::CancellationToken.new sub = KubeMQ::PubSub::EventsSubscription.new(channel: "order-stream") client.subscribe_to_events( sub, cancellation_token: cancel, on_error: ->(e) { warn "Error: #{e.message}" } ) do |event| counter += 1 puts "Received #{counter} events (latest: #{event.body})" if (counter % 100).zero? end ``` ```elixir title="stream_subscriber.exs" {:ok, counter} = Agent.start_link(fn -> 0 end) {:ok, _sub} = KubeMQ.Client.subscribe_to_events(client, "order-stream", on_event: fn event -> n = Agent.get_and_update(counter, fn c -> {c + 1, c + 1} end) if rem(n, 100) == 0 do IO.puts("Received #{n} events (latest: #{event.body})") end end, on_error: fn err -> IO.puts(:stderr, "Error: #{inspect(err)}") end ) ``` ### Handle Backpressure [#handle-backpressure] When the subscriber cannot keep up, the stream provides flow control. Monitor for send errors and implement retry or throttling. Stream publishing does **not** change the at-most-once delivery guarantee. Events dropped due to slow consumers are still lost. For guaranteed delivery, use [Events Store](/learn/events-store). ## Best Practices [#best-practices] | Practice | Recommendation | | ------------------ | -------------------------------------------------------------------------------------------- | | Buffer size | Set channel buffer to match expected burst size (e.g., 100–1000) | | Error handling | Always check send results — log failures and consider retry | | Stream lifetime | Keep streams open for the duration of high-throughput phases; close when done | | Subscriber scaling | Pair with [consumer groups](/learn/events/tutorials/consumer-groups) for parallel processing | ## Next Steps [#next-steps] # Wildcard Subscriptions (/learn/events/tutorials/wildcard-subscriptions) ## What You Will Build [#what-you-will-build] A monitoring system where services publish events to hierarchical channels like `orders.created` and `payments.completed`, and wildcard subscribers capture events across categories. *One publish per channel; a single-level (`orders.*`) and a catch-all (`>`) subscriber each match a different slice of the stream.* ## Prerequisites [#prerequisites] * KubeMQ server running on `localhost:50000` * SDK installed ([Getting Started](/learn/events/getting-started)) Wildcard subscriptions are supported for **Events** only. Events Store, Queues, and RPC patterns do not support wildcards. ## Wildcard Patterns [#wildcard-patterns] | Pattern | Matches | Example | | ------- | ------------------ | --------------------------------------------------------------- | | `*` | Exactly one token | `orders.*` matches `orders.created` but not `orders.us.created` | | `>` | One or more tokens | `orders.>` matches `orders.created` and `orders.us.created` | Tokens are separated by `.` (dot). A standalone `>` subscribes to every channel. ## Steps [#steps] ### Publish Events to Multiple Channels [#publish-events-to-multiple-channels] ```go title="multi_publisher.go" package main import ( "context" "log" "time" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() events := []struct { channel string body string }{ {"orders.created", `{"orderId":"ORD-100","action":"created"}`}, {"orders.updated", `{"orderId":"ORD-100","action":"updated"}`}, {"orders.shipped", `{"orderId":"ORD-100","action":"shipped"}`}, {"payments.completed", `{"paymentId":"PAY-200","status":"completed"}`}, {"inventory.reserved", `{"sku":"ITEM-300","qty":5}`}, } for _, e := range events { err = client.SendEvent(ctx, kubemq.NewEvent(). SetChannel(e.channel). SetBody([]byte(e.body)), ) if err != nil { log.Printf("Failed to publish to %s: %v", e.channel, err) continue } log.Printf("Published to %s", e.channel) time.Sleep(300 * time.Millisecond) } } ``` ```python title="multi_publisher.py" import time from kubemq.pubsub import Client as PubSubClient from kubemq.pubsub import EventMessage events = [ ("orders.created", '{"orderId":"ORD-100","action":"created"}'), ("orders.updated", '{"orderId":"ORD-100","action":"updated"}'), ("orders.shipped", '{"orderId":"ORD-100","action":"shipped"}'), ("payments.completed", '{"paymentId":"PAY-200","status":"completed"}'), ("inventory.reserved", '{"sku":"ITEM-300","qty":5}'), ] client = PubSubClient(address="localhost:50000") for channel, body in events: client.send_event( EventMessage(channel=channel, body=body.encode("utf-8")) ) print(f"Published to {channel}") time.sleep(0.3) client.close() ``` ```javascript title="multi_publisher.js" const { KubeMQClient } = require("kubemq-js"); const client = new KubeMQClient({ address: "localhost:50000" }); const events = [ { channel: "orders.created", body: '{"orderId":"ORD-100","action":"created"}' }, { channel: "orders.updated", body: '{"orderId":"ORD-100","action":"updated"}' }, { channel: "orders.shipped", body: '{"orderId":"ORD-100","action":"shipped"}' }, { channel: "payments.completed", body: '{"paymentId":"PAY-200","status":"completed"}' }, { channel: "inventory.reserved", body: '{"sku":"ITEM-300","qty":5}' }, ]; for (const e of events) { await client.sendEvent({ channel: e.channel, body: Buffer.from(e.body) }); console.log(`Published to ${e.channel}`); await new Promise((r) => setTimeout(r, 300)); } ``` ```java title="MultiPublisher.java" PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId("multi-publisher") .build(); String[][] events = { {"orders.created", "{\"orderId\":\"ORD-100\",\"action\":\"created\"}"}, {"orders.updated", "{\"orderId\":\"ORD-100\",\"action\":\"updated\"}"}, {"orders.shipped", "{\"orderId\":\"ORD-100\",\"action\":\"shipped\"}"}, {"payments.completed", "{\"paymentId\":\"PAY-200\",\"status\":\"completed\"}"}, {"inventory.reserved", "{\"sku\":\"ITEM-300\",\"qty\":5}"}, }; for (String[] e : events) { client.sendEventsMessage(EventMessage.builder() .channel(e[0]) .body(e[1].getBytes()) .build()); System.out.println("Published to " + e[0]); Thread.sleep(300); } client.close(); ``` ```csharp title="MultiPublisher.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); var events = new[] { ("orders.created", "{\"orderId\":\"ORD-100\",\"action\":\"created\"}"), ("orders.updated", "{\"orderId\":\"ORD-100\",\"action\":\"updated\"}"), ("orders.shipped", "{\"orderId\":\"ORD-100\",\"action\":\"shipped\"}"), ("payments.completed", "{\"paymentId\":\"PAY-200\",\"status\":\"completed\"}"), ("inventory.reserved", "{\"sku\":\"ITEM-300\",\"qty\":5}"), }; foreach (var (channel, body) in events) { await client.SendEventAsync(new EventMessage { Channel = channel, Body = Encoding.UTF8.GetBytes(body), }); Console.WriteLine($"Published to {channel}"); await Task.Delay(300); } ``` ```kotlin title="MultiPublisher.kt" val client = PubSubClient("localhost:50000") val events = listOf( "orders.created" to """{"orderId":"ORD-100","action":"created"}""", "orders.updated" to """{"orderId":"ORD-100","action":"updated"}""", "orders.shipped" to """{"orderId":"ORD-100","action":"shipped"}""", "payments.completed" to """{"paymentId":"PAY-200","status":"completed"}""", "inventory.reserved" to """{"sku":"ITEM-300","qty":5}""", ) for ((channel, body) in events) { client.sendEvent(EventMessage(channel = channel, body = body.toByteArray())) println("Published to $channel") Thread.sleep(300) } client.close() ``` ```cpp title="multi_publisher.cpp" auto client = kubemq::PubSubClient("localhost:50000"); std::vector> events = { {"orders.created", R"({"orderId":"ORD-100","action":"created"})"}, {"orders.updated", R"({"orderId":"ORD-100","action":"updated"})"}, {"orders.shipped", R"({"orderId":"ORD-100","action":"shipped"})"}, {"payments.completed", R"({"paymentId":"PAY-200","status":"completed"})"}, {"inventory.reserved", R"({"sku":"ITEM-300","qty":5})"}, }; for (const auto& [channel, body] : events) { kubemq::EventMessage event; event.channel = channel; event.body = body; client.sendEvent(event); std::cout << "Published to " << channel << std::endl; std::this_thread::sleep_for(std::chrono::milliseconds(300)); } ``` ```rust title="multi_publisher.rs" let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; let events = [ ("orders.created", r#"{"orderId":"ORD-100","action":"created"}"#), ("orders.updated", r#"{"orderId":"ORD-100","action":"updated"}"#), ("orders.shipped", r#"{"orderId":"ORD-100","action":"shipped"}"#), ("payments.completed", r#"{"paymentId":"PAY-200","status":"completed"}"#), ("inventory.reserved", r#"{"sku":"ITEM-300","qty":5}"#), ]; for (channel, body) in events { let event = EventBuilder::new() .channel(channel) .body(body.as_bytes().to_vec()) .build(); client.send_event(event).await?; println!("Published to {}", channel); tokio::time::sleep(Duration::from_millis(300)).await; } ``` ```ruby title="multi_publisher.rb" client = KubeMQ::PubSubClient.new(address: "localhost:50000", client_id: "multi-publisher") events = [ ["orders.created", '{"orderId":"ORD-100","action":"created"}'], ["orders.updated", '{"orderId":"ORD-100","action":"updated"}'], ["orders.shipped", '{"orderId":"ORD-100","action":"shipped"}'], ["payments.completed", '{"paymentId":"PAY-200","status":"completed"}'], ["inventory.reserved", '{"sku":"ITEM-300","qty":5}'], ] events.each do |channel, body| client.send_event(KubeMQ::PubSub::EventMessage.new(channel: channel, body: body)) puts "Published to #{channel}" sleep 0.3 end client.close ``` ```elixir title="multi_publisher.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "multi-publisher") events = [ {"orders.created", ~s({"orderId":"ORD-100","action":"created"})}, {"orders.updated", ~s({"orderId":"ORD-100","action":"updated"})}, {"orders.shipped", ~s({"orderId":"ORD-100","action":"shipped"})}, {"payments.completed", ~s({"paymentId":"PAY-200","status":"completed"})}, {"inventory.reserved", ~s({"sku":"ITEM-300","qty":5})} ] for {channel, body} <- events do :ok = KubeMQ.Client.send_event(client, KubeMQ.Event.new(channel: channel, body: body)) IO.puts("Published to #{channel}") Process.sleep(300) end KubeMQ.Client.close(client) ``` ### Subscribe with a Single-Level Wildcard [#subscribe-with-a-single-level-wildcard] Subscribe to `orders.*` to receive only order-related events at one level of nesting. ```go title="orders_monitor.go" sub, err := client.SubscribeToEvents(ctx, "orders.*", "", kubemq.WithOnEvent(func(event *kubemq.Event) { fmt.Printf("[Orders Monitor] channel=%s body=%s\n", event.Channel, string(event.Body)) }), kubemq.WithOnError(func(err error) { log.Println("Error:", err) }), ) ``` ```python title="orders_monitor.py" client.subscribe_to_events( subscription=EventsSubscription( channel="orders.*", on_receive_event_callback=lambda e: print( f"[Orders Monitor] channel={e.channel} body={e.body.decode()}" ), on_error_callback=lambda e: print(f"Error: {e}"), ), cancel=CancellationToken(), ) ``` ```javascript title="orders_monitor.js" client.subscribeToEvents({ channel: "orders.*", onEvent: (msg) => console.log( `[Orders Monitor] channel=${msg.channel} body=${Buffer.from(msg.body).toString()}` ), onError: (err) => console.error("Error:", err.message), }); ``` ```java title="OrdersMonitor.java" client.subscribeToEvents(EventsSubscription.builder() .channel("orders.*") .onReceiveEventCallback(event -> System.out.printf("[Orders Monitor] channel=%s body=%s%n", event.getChannel(), new String(event.getBody()))) .onErrorCallback(err -> System.err.println("Error: " + err.getMessage())) .build()); ``` ```csharp title="OrdersMonitor.cs" await foreach (var msg in client.SubscribeToEventsAsync( new EventsSubscription { Channel = "orders.*" })) { Console.WriteLine($"[Orders Monitor] channel={msg.Channel} " + $"body={Encoding.UTF8.GetString(msg.Body.Span)}"); } ``` ```kotlin title="OrdersMonitor.kt" client.subscribeToEvents( channel = "orders.*", onEvent = { event -> println("[Orders Monitor] channel=${event.channel} body=${String(event.body)}") }, onError = { err -> System.err.println("Error: ${err.message}") } ) ``` ```cpp title="orders_monitor.cpp" client.subscribeToEvents("orders.*", "", [](const kubemq::Event& event) { std::cout << "[Orders Monitor] channel=" << event.channel << " body=" << event.body << std::endl; }, [](const std::string& err) { std::cerr << "Error: " << err << std::endl; } ); ``` ```rust title="orders_monitor.rs" let sub = client .subscribe_to_events( "orders.*", "", |event| { Box::pin(async move { println!( "[Orders Monitor] channel={} body={}", event.channel, String::from_utf8_lossy(&event.body) ); }) }, None, ) .await?; ``` ```ruby title="orders_monitor.rb" cancel = KubeMQ::CancellationToken.new sub = KubeMQ::PubSub::EventsSubscription.new(channel: "orders.*") client.subscribe_to_events(sub, cancellation_token: cancel, on_error: ->(e) { puts "Error: #{e.message}" }) do |event| puts "[Orders Monitor] channel=#{event.channel} body=#{event.body}" end ``` ```elixir title="orders_monitor.exs" {:ok, sub} = KubeMQ.Client.subscribe_to_events(client, "orders.*", on_event: fn event -> IO.puts("[Orders Monitor] channel=#{event.channel} body=#{event.body}") end ) ``` **Expected output** — receives 3 of 5 events (only `orders.*`): ```text [Orders Monitor] channel=orders.created body={"orderId":"ORD-100","action":"created"} [Orders Monitor] channel=orders.updated body={"orderId":"ORD-100","action":"updated"} [Orders Monitor] channel=orders.shipped body={"orderId":"ORD-100","action":"shipped"} ``` ### Subscribe with a Multi-Level Wildcard [#subscribe-with-a-multi-level-wildcard] Subscribe to `>` to receive events from all channels. ```go title="global_auditor.go" sub, err := client.SubscribeToEvents(ctx, ">", "", kubemq.WithOnEvent(func(event *kubemq.Event) { fmt.Printf("[Auditor] channel=%s body=%s\n", event.Channel, string(event.Body)) }), kubemq.WithOnError(func(err error) { log.Println("Error:", err) }), ) ``` ```python title="global_auditor.py" client.subscribe_to_events( subscription=EventsSubscription( channel=">", on_receive_event_callback=lambda e: print( f"[Auditor] channel={e.channel} body={e.body.decode()}" ), on_error_callback=lambda e: print(f"Error: {e}"), ), cancel=CancellationToken(), ) ``` ```javascript title="global_auditor.js" client.subscribeToEvents({ channel: ">", onEvent: (msg) => console.log( `[Auditor] channel=${msg.channel} body=${Buffer.from(msg.body).toString()}` ), onError: (err) => console.error("Error:", err.message), }); ``` ```java title="GlobalAuditor.java" client.subscribeToEvents(EventsSubscription.builder() .channel(">") .onReceiveEventCallback(event -> System.out.printf("[Auditor] channel=%s body=%s%n", event.getChannel(), new String(event.getBody()))) .onErrorCallback(err -> System.err.println("Error: " + err.getMessage())) .build()); ``` ```csharp title="GlobalAuditor.cs" await foreach (var msg in client.SubscribeToEventsAsync( new EventsSubscription { Channel = ">" })) { Console.WriteLine($"[Auditor] channel={msg.Channel} " + $"body={Encoding.UTF8.GetString(msg.Body.Span)}"); } ``` ```kotlin title="GlobalAuditor.kt" client.subscribeToEvents( channel = ">", onEvent = { event -> println("[Auditor] channel=${event.channel} body=${String(event.body)}") }, onError = { err -> System.err.println("Error: ${err.message}") } ) ``` ```cpp title="global_auditor.cpp" client.subscribeToEvents(">", "", [](const kubemq::Event& event) { std::cout << "[Auditor] channel=" << event.channel << " body=" << event.body << std::endl; }, [](const std::string& err) { std::cerr << "Error: " << err << std::endl; } ); ``` ```rust title="global_auditor.rs" let sub = client .subscribe_to_events( ">", "", |event| { Box::pin(async move { println!( "[Auditor] channel={} body={}", event.channel, String::from_utf8_lossy(&event.body) ); }) }, None, ) .await?; ``` ```ruby title="global_auditor.rb" cancel = KubeMQ::CancellationToken.new sub = KubeMQ::PubSub::EventsSubscription.new(channel: ">") client.subscribe_to_events(sub, cancellation_token: cancel, on_error: ->(e) { puts "Error: #{e.message}" }) do |event| puts "[Auditor] channel=#{event.channel} body=#{event.body}" end ``` ```elixir title="global_auditor.exs" {:ok, sub} = KubeMQ.Client.subscribe_to_events(client, ">", on_event: fn event -> IO.puts("[Auditor] channel=#{event.channel} body=#{event.body}") end ) ``` **Expected output** — receives all 5 events: ```text [Auditor] channel=orders.created body={"orderId":"ORD-100","action":"created"} [Auditor] channel=orders.updated body={"orderId":"ORD-100","action":"updated"} [Auditor] channel=orders.shipped body={"orderId":"ORD-100","action":"shipped"} [Auditor] channel=payments.completed body={"paymentId":"PAY-200","status":"completed"} [Auditor] channel=inventory.reserved body={"sku":"ITEM-300","qty":5} ``` ## Channel Naming Best Practices [#channel-naming-best-practices] Use a hierarchical dot-separated naming convention: ```text {domain}.{entity}.{action} orders.created orders.updated orders.us-east.created payments.completed ``` | Subscription | Receives | | ---------------- | ---------------------------------------------------------------- | | `orders.created` | Only `orders.created` events | | `orders.*` | All single-level order events | | `orders.>` | All order events, including nested like `orders.us-east.created` | | `>` | Everything across all channels | Channel names used for **publishing** must not contain `*` or `>` characters. Wildcards are only valid in subscription channel patterns. ## Next Steps [#next-steps] # Audit Trail System (/learn/events-store/scenarios/audit-trail) This scenario builds a compliance-ready audit trail that records every user action as an immutable event. Events Store's persistence, sequencing, and replay capabilities make it ideal for audit logging where data integrity and traceability are mandatory. ## Architecture [#architecture] *Every service writes to one immutable audit channel; each consumer replays from a different start position.* ### Design Decisions [#design-decisions] * **Single audit channel** — all services publish to `audit.user-actions` for a unified timeline * **Immutable events** — once stored, events cannot be modified or deleted * **Sequence numbers** — provide a tamper-evident, monotonically increasing ordering * **Multiple consumers** — compliance, security, and SIEM systems each subscribe with different start positions ## Implementation [#implementation] ### Define the Audit Event Schema [#define-the-audit-event-schema] Every audit event follows a consistent structure for compliance tooling: ```json { "eventType": "user.data.export", "actor": "user:U-1001", "resource": "orders:ORD-5001", "action": "export", "outcome": "success", "ip": "192.168.1.42", "userAgent": "Mozilla/5.0...", "timestamp": "2026-03-26T14:30:00Z", "metadata": { "exportFormat": "csv", "recordCount": 150 } } ``` ### Publish Audit Events from Services [#publish-audit-events-from-services] Each service publishes audit events as part of its request handling. ```go title="audit_logger.go" func logAuditEvent(ctx context.Context, client *kubemq.Client, event AuditEvent) error { body, _ := json.Marshal(event) result, err := client.SendEventStore(ctx, kubemq.NewEvent(). SetChannel("audit.user-actions"). SetMetadata(event.EventType). SetBody(body). SetTags(map[string]string{ "actor": event.Actor, "resource": event.Resource, "outcome": event.Outcome, }), ) if err != nil { return fmt.Errorf("audit log failed: %w", err) } log.Printf("Audit logged: %s seq=%s", event.EventType, result.EventID) return nil } ``` ```python title="audit_logger.py" def log_audit_event(client, event: dict) -> None: result = client.publish_event_store( EventStoreMessage( channel="audit.user-actions", metadata=event["eventType"], body=json.dumps(event).encode("utf-8"), tags={ "actor": event["actor"], "resource": event["resource"], "outcome": event["outcome"], }, ) ) print(f"Audit logged: {event['eventType']} ID={result.id}") ``` ```typescript title="audit_logger.ts" async function logAuditEvent(client: KubeMQClient, event: AuditEvent) { const result = await client.sendEventStore( createEventStoreMessage({ channel: 'audit.user-actions', metadata: event.eventType, body: JSON.stringify(event), tags: { actor: event.actor, resource: event.resource, outcome: event.outcome, }, }) ); console.log(`Audit logged: ${event.eventType} ID=${result.id}`); } ``` ```java title="AuditLogger.java" public void logAuditEvent(PubSubClient client, AuditEvent event) { var result = client.sendEventsStoreMessage( EventStoreMessage.builder() .channel("audit.user-actions") .metadata(event.getEventType()) .body(objectMapper.writeValueAsBytes(event)) .tags(Map.of( "actor", event.getActor(), "resource", event.getResource(), "outcome", event.getOutcome())) .build()); System.out.printf("Audit logged: %s ID=%s%n", event.getEventType(), result.getId()); } ``` ```csharp title="AuditLogger.cs" public async Task LogAuditEventAsync(KubeMQClient client, AuditEvent auditEvent) { var result = await client.SendEventStoreAsync(new EventStoreMessage { Channel = "audit.user-actions", Metadata = auditEvent.EventType, Body = JsonSerializer.SerializeToUtf8Bytes(auditEvent), Tags = { ["actor"] = auditEvent.Actor, ["resource"] = auditEvent.Resource }, }); Console.WriteLine($"Audit logged: {auditEvent.EventType} ID={result.Id}"); } ``` ```kotlin title="AuditLogger.kt" suspend fun logAuditEvent(client: KubeMQClient, event: AuditEvent) { val result = client.sendEventStore(eventStoreMessage { channel = "audit.user-actions" metadata = event.eventType body = Json.encodeToString(event).toByteArray() tags = mapOf("actor" to event.actor, "resource" to event.resource) }) println("Audit logged: ${event.eventType} ID=${result.id}") } ``` ```cpp title="audit_logger.cc" void log_audit_event(kubemq::Client& client, const AuditEvent& event) { kubemq::EventStoreMessage msg; msg.set_channel("audit.user-actions"); msg.set_metadata(event.event_type); msg.set_body(event.to_json()); msg.set_tag("actor", event.actor); msg.set_tag("resource", event.resource); auto result = client.SendEventStore(msg); if (result.ok()) { std::cout << "Audit logged: " << event.event_type << std::endl; } } ``` ```rust title="audit_logger.rs" async fn log_audit_event(client: &KubemqClient, event: &AuditEvent) -> kubemq::Result<()> { let store_event = EventStoreBuilder::new() .channel("audit.user-actions") .metadata(&event.event_type) .body(serde_json::to_vec(event).unwrap()) .add_tag("actor", &event.actor) .add_tag("resource", &event.resource) .add_tag("outcome", &event.outcome) .build(); let result = client.send_event_store(store_event).await?; println!("Audit logged: {} id={}", event.event_type, result.id); Ok(()) } ``` ```ruby title="audit_logger.rb" def log_audit_event(client, event) msg = KubeMQ::PubSub::EventStoreMessage.new( channel: 'audit.user-actions', metadata: event[:event_type], body: event.to_json, tags: { 'actor' => event[:actor], 'resource' => event[:resource], 'outcome' => event[:outcome] } ) result = client.send_event_store(msg) puts "Audit logged: #{event[:event_type]} sent=#{result.sent}" end ``` ```elixir title="audit_logger.exs" def log_audit_event(client, event) do store_event = KubeMQ.EventStore.new( channel: "audit.user-actions", metadata: event.event_type, body: Jason.encode!(event), tags: %{ "actor" => event.actor, "resource" => event.resource, "outcome" => event.outcome } ) case KubeMQ.Client.send_event_store(client, store_event) do {:ok, result} -> IO.puts("Audit logged: #{event.event_type} id=#{result.id}") {:error, err} -> IO.puts("Audit log failed: #{err.message}") end end ``` ### Subscribe for Compliance Replay [#subscribe-for-compliance-replay] The compliance dashboard replays the full audit history using `StartFromFirst`. ```go title="compliance_dashboard.go" sub, err := client.SubscribeToEventsStore(ctx, "audit.user-actions", "compliance-reader", kubemq.StartFromFirst(), kubemq.WithOnEvent(func(event *kubemq.Event) { fmt.Printf("[Compliance] seq=%d actor=%s action=%s\n", event.Sequence, event.Tags["actor"], event.Metadata) }), kubemq.WithOnError(func(err error) { log.Println("Error:", err) }), ) ``` ```python title="compliance_dashboard.py" client.subscribe_to_events_store( subscription=EventsStoreSubscription( channel="audit.user-actions", group="compliance-reader", start_position=EventStoreStartPosition.StartFromFirst, on_receive_event_callback=lambda e: print( f"[Compliance] seq={e.sequence} actor={e.tags.get('actor')} " f"action={e.metadata}" ), on_error_callback=lambda e: print(f"Error: {e}"), ), cancel=CancellationToken(), ) ``` ```typescript title="compliance_dashboard.ts" client.subscribeToEventsStore({ channel: 'audit.user-actions', group: 'compliance-reader', startPosition: EventStoreStartPosition.StartFromFirst, onEvent: (msg) => console.log(`[Compliance] seq=${msg.sequence} actor=${msg.tags?.actor} action=${msg.metadata}`), onError: (err) => console.error('Error:', err.message), }); ``` ```java title="ComplianceDashboard.java" client.subscribeToEventsStore(EventsStoreSubscription.builder() .channel("audit.user-actions") .group("compliance-reader") .startPosition(EventStoreStartPosition.StartFromFirst) .onReceiveEventCallback(event -> System.out.printf("[Compliance] seq=%d actor=%s action=%s%n", event.getSequence(), event.getTags().get("actor"), event.getMetadata())) .onErrorCallback(err -> System.err.println(err.getMessage())) .build()); ``` ```csharp title="ComplianceDashboard.cs" await foreach (var msg in client.SubscribeToEventsStoreAsync( new EventsStoreSubscription { Channel = "audit.user-actions", Group = "compliance-reader", StartPosition = EventStoreStartPosition.StartFromFirst, })) { Console.WriteLine($"[Compliance] seq={msg.Sequence} action={msg.Metadata}"); } ``` ```kotlin title="ComplianceDashboard.kt" client.subscribeToEventsStore { channel = "audit.user-actions" group = "compliance-reader" startPosition = StartPosition.StartFromFirst }.collect { msg -> println("[Compliance] seq=${msg.sequence} action=${msg.metadata}") } ``` ```cpp title="compliance_dashboard.cc" client->SubscribeToEventsStore("audit.user-actions", "compliance-reader", kubemq::StartPosition::StartFromFirst, [](const kubemq::EventStoreReceived& msg) { std::cout << "[Compliance] seq=" << msg.sequence() << " action=" << msg.metadata() << std::endl; }, [](const std::string& err) { std::cerr << err << std::endl; }); ``` ```rust title="compliance_dashboard.rs" let sub = client .subscribe_to_events_store( "audit.user-actions", "compliance-reader", EventsStoreSubscription::StartFromFirst, |event| { Box::pin(async move { println!( "[Compliance] seq={} actor={} action={}", event.sequence, event.tags.get("actor").map(String::as_str).unwrap_or(""), event.metadata, ); }) }, None, ) .await?; ``` ```ruby title="compliance_dashboard.rb" cancel = KubeMQ::CancellationToken.new sub = KubeMQ::PubSub::EventsStoreSubscription.new( channel: 'audit.user-actions', group: 'compliance-reader', start_position: KubeMQ::PubSub::EventStoreStartPosition::START_FROM_FIRST ) client.subscribe_to_events_store(sub, cancellation_token: cancel, on_error: lambda { |e| puts "Error: #{e.message}" }) do |event| puts "[Compliance] seq=#{event.sequence} actor=#{event.tags['actor']} action=#{event.metadata}" end ``` ```elixir title="compliance_dashboard.exs" {:ok, _sub} = KubeMQ.Client.subscribe_to_events_store(client, "audit.user-actions", group: "compliance-reader", start_at: :start_from_first, on_event: fn event -> IO.puts( "[Compliance] seq=#{event.sequence} " <> "actor=#{Map.get(event.tags, "actor")} action=#{event.metadata}" ) end ) ``` ## Production Considerations [#production-considerations] Set `Store.MaxRetention` to match your compliance requirements. For regulated industries, use unlimited retention (`0`) or a value exceeding your legal retention window (e.g., 7 years). Combine with adequate disk capacity. Events Store sequence numbers provide a tamper-evident ordering. If a sequence gap is detected during replay, it indicates data loss or corruption. Implement gap detection in your compliance reader. For multi-region deployments, publish to a local KubeMQ instance and use a separate process to replicate audit events across regions. This ensures audit logging doesn't add cross-region latency to the request path. Audit logging adds a single Events Store publish per request. This typically adds 1-3ms of latency. For ultra-low-latency paths, consider publishing audit events asynchronously (fire-and-forget to a local buffer that flushes to Events Store). ## Related [#related] * [Configure Retention](/learn/events-store/how-to/configure-retention) for compliance retention windows * [Event Sourcing](/learn/events-store/tutorials/event-sourcing) for state reconstruction from audit logs * [Events Store Reference](/learn/events-store/reference) for message structure details # Cross-Service State Sync (/learn/events-store/scenarios/cross-service-sync) This scenario synchronizes state across independent microservices using Events Store as a shared event log. When the Order Service creates an order, the Inventory Service, Shipping Service, and Notification Service each independently consume the event stream and update their local state. ## Architecture [#architecture] *One durable `orders.lifecycle` log fans out to independent services, each tracking its own position via a consumer group; the ungrouped Analytics subscriber sees every event.* ### Design Decisions [#design-decisions] * **Single event channel** — `orders.lifecycle` serves as the shared event log * **Consumer groups per service** — each service has its own group for independent processing and position tracking * **Durable subscriptions** — services that restart catch up from their last position automatically * **Analytics fan-out** — an ungrouped subscriber receives every event for reporting ## Implementation [#implementation] ### Publish Order Lifecycle Events [#publish-order-lifecycle-events] The Order Service publishes state changes as they occur. ```go title="order_service.go" func publishOrderEvent(ctx context.Context, client *kubemq.Client, orderId, eventType string, data map[string]interface{}) error { payload := map[string]interface{}{ "orderId": orderId, "eventType": eventType, "data": data, "timestamp": time.Now().UTC().Format(time.RFC3339), } body, _ := json.Marshal(payload) result, err := client.SendEventStore(ctx, kubemq.NewEvent(). SetChannel("orders.lifecycle"). SetMetadata(eventType). SetBody(body). SetTags(map[string]string{"orderId": orderId}), ) if err != nil { return err } log.Printf("Published %s for %s (seq: %s)", eventType, orderId, result.EventID) return nil } ``` ```python title="order_service.py" def publish_order_event(client, order_id, event_type, data): payload = { "orderId": order_id, "eventType": event_type, "data": data, "timestamp": datetime.utcnow().isoformat() + "Z", } result = client.publish_event_store( EventStoreMessage( channel="orders.lifecycle", metadata=event_type, body=json.dumps(payload).encode("utf-8"), tags={"orderId": order_id}, ) ) print(f"Published {event_type} for {order_id} (ID: {result.id})") ``` ```typescript title="order_service.ts" async function publishOrderEvent( client: KubeMQClient, orderId: string, eventType: string, data: object ) { const payload = { orderId, eventType, data, timestamp: new Date().toISOString() }; const result = await client.sendEventStore( createEventStoreMessage({ channel: 'orders.lifecycle', metadata: eventType, body: JSON.stringify(payload), tags: { orderId }, }) ); console.log(`Published ${eventType} for ${orderId} (ID: ${result.id})`); } ``` ```java title="OrderService.java" public void publishOrderEvent(PubSubClient client, String orderId, String eventType, Map data) { var payload = Map.of( "orderId", orderId, "eventType", eventType, "data", data, "timestamp", Instant.now().toString()); var body = objectMapper.writeValueAsBytes(payload); var result = client.sendEventsStoreMessage(EventStoreMessage.builder() .channel("orders.lifecycle") .metadata(eventType) .body(body) .tags(Map.of("orderId", orderId)) .build()); System.out.printf("Published %s for %s%n", eventType, orderId); } ``` ```csharp title="OrderService.cs" public async Task PublishOrderEventAsync(KubeMQClient client, string orderId, string eventType, object data) { var payload = new { orderId, eventType, data, timestamp = DateTime.UtcNow }; await client.SendEventStoreAsync(new EventStoreMessage { Channel = "orders.lifecycle", Metadata = eventType, Body = JsonSerializer.SerializeToUtf8Bytes(payload), Tags = { ["orderId"] = orderId }, }); Console.WriteLine($"Published {eventType} for {orderId}"); } ``` ```kotlin title="OrderService.kt" suspend fun publishOrderEvent(client: KubeMQClient, orderId: String, eventType: String, data: Map) { val payload = mapOf("orderId" to orderId, "eventType" to eventType, "data" to data) client.sendEventStore(eventStoreMessage { channel = "orders.lifecycle" metadata = eventType body = Json.encodeToString(payload).toByteArray() tags = mapOf("orderId" to orderId) }) println("Published $eventType for $orderId") } ``` ```cpp title="order_service.cc" void publish_order_event(kubemq::Client& client, const std::string& order_id, const std::string& event_type) { kubemq::EventStoreMessage msg; msg.set_channel("orders.lifecycle"); msg.set_metadata(event_type); msg.set_body("{\"orderId\":\"" + order_id + "\",\"eventType\":\"" + event_type + "\"}"); msg.set_tag("orderId", order_id); client.SendEventStore(msg); std::cout << "Published " << event_type << " for " << order_id << std::endl; } ``` ```rust title="order_service.rs" async fn publish_order_event( client: &KubemqClient, order_id: &str, event_type: &str, data: &str, ) -> kubemq::Result<()> { let body = format!( r#"{{"orderId":"{}","eventType":"{}","data":{}}}"#, order_id, event_type, data ); let event = EventStoreBuilder::new() .channel("orders.lifecycle") .metadata(event_type) .body(body.into_bytes()) .add_tag("orderId", order_id) .build(); let result = client.send_event_store(event).await?; println!( "Published {} for {} (id: {})", event_type, order_id, result.id ); Ok(()) } ``` ```ruby title="order_service.rb" def publish_order_event(client, order_id, event_type, data) payload = { orderId: order_id, eventType: event_type, data: data, timestamp: Time.now.utc.iso8601 } msg = KubeMQ::PubSub::EventStoreMessage.new( channel: 'orders.lifecycle', metadata: event_type, body: payload.to_json, tags: { 'orderId' => order_id } ) result = client.send_event_store(msg) puts "Published #{event_type} for #{order_id} (sent: #{result.sent})" end ``` ```elixir title="order_service.exs" def publish_order_event(client, order_id, event_type, data) do payload = Jason.encode!(%{ orderId: order_id, eventType: event_type, data: data, timestamp: DateTime.utc_now() |> DateTime.to_iso8601() }) event = KubeMQ.EventStore.new( channel: "orders.lifecycle", metadata: event_type, body: payload, tags: %{"orderId" => order_id} ) {:ok, result} = KubeMQ.Client.send_event_store(client, event) IO.puts("Published #{event_type} for #{order_id} (sent: #{result.sent})") end ``` ### Inventory Service Consumer [#inventory-service-consumer] The Inventory Service subscribes with its own group to reserve or release stock based on order events. ```go title="inventory_service.go" sub, err := client.SubscribeToEventsStore(ctx, "orders.lifecycle", "inventory-service", kubemq.StartFromFirst(), kubemq.WithOnEvent(func(event *kubemq.Event) { switch event.Metadata { case "order.created": log.Printf("[Inventory] Reserving stock for order %s", event.Tags["orderId"]) case "order.cancelled": log.Printf("[Inventory] Releasing stock for order %s", event.Tags["orderId"]) } }), kubemq.WithOnError(func(err error) { log.Println("[Inventory] Error:", err) }), ) ``` ```python title="inventory_service.py" def on_order_event(event): order_id = event.tags.get("orderId", "unknown") if event.metadata == "order.created": print(f"[Inventory] Reserving stock for {order_id}") elif event.metadata == "order.cancelled": print(f"[Inventory] Releasing stock for {order_id}") client.subscribe_to_events_store( subscription=EventsStoreSubscription( channel="orders.lifecycle", group="inventory-service", start_position=EventStoreStartPosition.StartFromFirst, on_receive_event_callback=on_order_event, on_error_callback=lambda e: print(f"[Inventory] Error: {e}"), ), cancel=CancellationToken(), ) ``` ```typescript title="inventory_service.ts" client.subscribeToEventsStore({ channel: 'orders.lifecycle', group: 'inventory-service', startPosition: EventStoreStartPosition.StartFromFirst, onEvent: (msg) => { const orderId = msg.tags?.orderId ?? 'unknown'; switch (msg.metadata) { case 'order.created': console.log(`[Inventory] Reserving stock for ${orderId}`); break; case 'order.cancelled': console.log(`[Inventory] Releasing stock for ${orderId}`); break; } }, onError: (err) => console.error('[Inventory] Error:', err.message), }); ``` ```java title="InventoryService.java" client.subscribeToEventsStore(EventsStoreSubscription.builder() .channel("orders.lifecycle") .group("inventory-service") .startPosition(EventStoreStartPosition.StartFromFirst) .onReceiveEventCallback(event -> { String orderId = event.getTags().getOrDefault("orderId", "unknown"); switch (event.getMetadata()) { case "order.created" -> System.out.printf("[Inventory] Reserving stock for %s%n", orderId); case "order.cancelled" -> System.out.printf("[Inventory] Releasing stock for %s%n", orderId); } }) .onErrorCallback(err -> System.err.println("[Inventory] Error: " + err.getMessage())) .build()); ``` ```csharp title="InventoryService.cs" await foreach (var msg in client.SubscribeToEventsStoreAsync( new EventsStoreSubscription { Channel = "orders.lifecycle", Group = "inventory-service", StartPosition = EventStoreStartPosition.StartFromFirst, })) { var orderId = msg.Tags.GetValueOrDefault("orderId", "unknown"); switch (msg.Metadata) { case "order.created": Console.WriteLine($"[Inventory] Reserving stock for {orderId}"); break; case "order.cancelled": Console.WriteLine($"[Inventory] Releasing stock for {orderId}"); break; } } ``` ```kotlin title="InventoryService.kt" client.subscribeToEventsStore { channel = "orders.lifecycle" group = "inventory-service" startPosition = StartPosition.StartFromFirst }.collect { msg -> val orderId = msg.tags["orderId"] ?: "unknown" when (msg.metadata) { "order.created" -> println("[Inventory] Reserving stock for $orderId") "order.cancelled" -> println("[Inventory] Releasing stock for $orderId") } } ``` ```cpp title="inventory_service.cc" client->SubscribeToEventsStore("orders.lifecycle", "inventory-service", kubemq::StartPosition::StartFromFirst, [](const kubemq::EventStoreReceived& msg) { if (msg.metadata() == "order.created") { std::cout << "[Inventory] Reserving stock" << std::endl; } else if (msg.metadata() == "order.cancelled") { std::cout << "[Inventory] Releasing stock" << std::endl; } }, [](const std::string& err) { std::cerr << "[Inventory] Error: " << err << std::endl; }); ``` ```rust title="inventory_service.rs" let sub = client .subscribe_to_events_store( "orders.lifecycle", "inventory-service", EventsStoreSubscription::StartFromFirst, |event| { Box::pin(async move { let order_id = event .tags .get("orderId") .map(String::as_str) .unwrap_or("unknown"); match event.metadata.as_str() { "order.created" => { println!("[Inventory] Reserving stock for {}", order_id) } "order.cancelled" => { println!("[Inventory] Releasing stock for {}", order_id) } _ => {} } }) }, Some(|err| { Box::pin(async move { eprintln!("[Inventory] Error: {}", err) }) }), ) .await?; ``` ```ruby title="inventory_service.rb" cancel = KubeMQ::CancellationToken.new sub = KubeMQ::PubSub::EventsStoreSubscription.new( channel: 'orders.lifecycle', group: 'inventory-service', start_position: KubeMQ::PubSub::EventStoreStartPosition::START_FROM_FIRST ) client.subscribe_to_events_store(sub, cancellation_token: cancel, on_error: lambda { |e| warn "[Inventory] Error: #{e.message}" }) do |event| order_id = event.tags.fetch('orderId', 'unknown') case event.metadata when 'order.created' puts "[Inventory] Reserving stock for #{order_id}" when 'order.cancelled' puts "[Inventory] Releasing stock for #{order_id}" end end ``` ```elixir title="inventory_service.exs" {:ok, sub} = KubeMQ.Client.subscribe_to_events_store(client, "orders.lifecycle", start_at: :start_from_first, group: "inventory-service", on_event: fn event -> order_id = Map.get(event.tags, "orderId", "unknown") case event.metadata do "order.created" -> IO.puts("[Inventory] Reserving stock for #{order_id}") "order.cancelled" -> IO.puts("[Inventory] Releasing stock for #{order_id}") _ -> :ok end end, on_error: fn err -> IO.warn("[Inventory] Error: #{err}") end ) ``` ### Deploy Multiple Consumers [#deploy-multiple-consumers] Each service subscribes independently with its own group. All services can be started, stopped, and scaled independently. ```bash # Each service runs independently and tracks its own position ./inventory-service & # group: inventory-service ./shipping-service & # group: shipping-service ./notification-service & # group: notification-service ./analytics-service & # no group (receives all events) ``` If a service restarts, it automatically catches up from its last processed position. ## Production Considerations [#production-considerations] Since Events Store provides at-least-once delivery, each consumer must handle duplicate events gracefully. Use the event's sequence number as an idempotency key — track the last processed sequence and skip events at or below that number. If a consumer fails to process an event, it should log the error and continue processing subsequent events. Do not block the stream for a single failed event. Implement a dead-letter mechanism by publishing failed events to a separate `orders.lifecycle.dlq` channel for manual review. Within each group, add more instances to distribute the load. For example, the Inventory Service can run 3 instances in the `inventory-service` group — each event goes to exactly one instance. Scale based on event throughput and processing latency. Include a `version` field in your event payloads. When the schema changes, consumers should handle both old and new formats. This allows gradual migration without coordinated deployments across all services. ## Related [#related] * [Consumer Groups](/learn/events-store/tutorials/consumer-groups) for scaling within a service * [Resume After Disconnect](/learn/events-store/how-to/resume-after-disconnect) for durable position tracking * [Audit Trail](/learn/events-store/scenarios/audit-trail) for compliance logging across services # Event-Driven State Machine (/learn/events-store/scenarios/event-driven-state-machine) This scenario implements an order processing state machine where each state transition is recorded as a persistent event. The full order lifecycle — from creation through delivery — is captured as an immutable event stream that can be replayed to rebuild state at any point. ## Architecture [#architecture] Each service publishes a transition to a per-order Events Store channel. The append-only stream is the source of truth; any consumer can replay it from the first sequence to rebuild the current state. *Transitions fan into one per-order channel; the persistent log is replayed to recover the latest state.* ## State Machine [#state-machine] *The legal transition graph — the rebuilder applies stored events in sequence to walk these edges.* ### Valid Transitions [#valid-transitions] | From | To | Event | | --------------- | --------------- | ------------------------ | | — | Created | `order.created` | | Created | Paid | `order.paid` | | Created | Cancelled | `order.cancelled` | | Paid | Picking | `order.picking` | | Picking | Shipped | `order.shipped` | | Shipped | Delivered | `order.delivered` | | Shipped | ReturnRequested | `order.return_requested` | | ReturnRequested | Returned | `order.returned` | ## Implementation [#implementation] ### Publish State Transition Events [#publish-state-transition-events] Each state change is published as a persistent event to a per-order channel. ```go title="order_state_machine.go" package main import ( "context" "fmt" "log" "github.com/kubemq-io/kubemq-go/v2" ) type StateTransition struct { OrderID string `json:"orderId"` FromState string `json:"fromState"` ToState string `json:"toState"` Event string `json:"event"` Actor string `json:"actor"` } func transitionOrder(ctx context.Context, client *kubemq.Client, t StateTransition) error { channel := fmt.Sprintf("order-state.%s", t.OrderID) body := fmt.Sprintf(`{"orderId":"%s","fromState":"%s","toState":"%s","event":"%s","actor":"%s"}`, t.OrderID, t.FromState, t.ToState, t.Event, t.Actor) result, err := client.SendEventStore(ctx, kubemq.NewEvent(). SetChannel(channel). SetMetadata(t.Event). SetBody([]byte(body)), ) if err != nil { return fmt.Errorf("transition failed: %w", err) } log.Printf("Transition: %s -> %s (seq: %s)", t.FromState, t.ToState, result.EventID) return nil } func main() { ctx := context.Background() client, _ := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000)) defer client.Close() transitions := []StateTransition{ {"ORD-2001", "", "created", "order.created", "customer:C-100"}, {"ORD-2001", "created", "paid", "order.paid", "payment-service"}, {"ORD-2001", "paid", "picking", "order.picking", "warehouse-worker:W-5"}, {"ORD-2001", "picking", "shipped", "order.shipped", "shipping-service"}, {"ORD-2001", "shipped", "delivered", "order.delivered", "carrier:fedex"}, } for _, t := range transitions { transitionOrder(ctx, client, t) } } ``` ```python title="order_state_machine.py" import json from kubemq import PubSubClient, EventStoreMessage def transition_order(client, order_id, from_state, to_state, event, actor): body = json.dumps({ "orderId": order_id, "fromState": from_state, "toState": to_state, "event": event, "actor": actor, }) result = client.publish_event_store( EventStoreMessage( channel=f"order-state.{order_id}", metadata=event, body=body.encode("utf-8"), ) ) print(f"Transition: {from_state} -> {to_state} (ID: {result.id})") with PubSubClient(address="localhost:50000") as client: transitions = [ ("ORD-2001", "", "created", "order.created", "customer:C-100"), ("ORD-2001", "created", "paid", "order.paid", "payment-service"), ("ORD-2001", "paid", "picking", "order.picking", "warehouse:W-5"), ("ORD-2001", "picking", "shipped", "order.shipped", "shipping-service"), ("ORD-2001", "shipped", "delivered", "order.delivered", "carrier:fedex"), ] for oid, fs, ts, evt, actor in transitions: transition_order(client, oid, fs, ts, evt, actor) ``` ```typescript title="order_state_machine.ts" import { KubeMQClient, createEventStoreMessage } from 'kubemq-js'; const client = await KubeMQClient.create({ address: 'localhost:50000' }); interface StateTransition { orderId: string; fromState: string; toState: string; event: string; actor: string; } async function transitionOrder(t: StateTransition) { const result = await client.sendEventStore( createEventStoreMessage({ channel: `order-state.${t.orderId}`, metadata: t.event, body: JSON.stringify(t), }) ); console.log(`Transition: ${t.fromState} -> ${t.toState} (ID: ${result.id})`); } const transitions: StateTransition[] = [ { orderId: 'ORD-2001', fromState: '', toState: 'created', event: 'order.created', actor: 'customer:C-100' }, { orderId: 'ORD-2001', fromState: 'created', toState: 'paid', event: 'order.paid', actor: 'payment-service' }, { orderId: 'ORD-2001', fromState: 'paid', toState: 'picking', event: 'order.picking', actor: 'warehouse:W-5' }, { orderId: 'ORD-2001', fromState: 'picking', toState: 'shipped', event: 'order.shipped', actor: 'shipping-service' }, { orderId: 'ORD-2001', fromState: 'shipped', toState: 'delivered', event: 'order.delivered', actor: 'carrier:fedex' }, ]; for (const t of transitions) await transitionOrder(t); ``` ```java title="OrderStateMachine.java" PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId("state-machine") .build(); String[][] transitions = { {"ORD-2001", "", "created", "order.created", "customer:C-100"}, {"ORD-2001", "created", "paid", "order.paid", "payment-service"}, {"ORD-2001", "paid", "picking", "order.picking", "warehouse:W-5"}, {"ORD-2001", "picking", "shipped", "order.shipped", "shipping-service"}, {"ORD-2001", "shipped", "delivered", "order.delivered", "carrier:fedex"}, }; for (String[] t : transitions) { String body = String.format( "{\"orderId\":\"%s\",\"fromState\":\"%s\",\"toState\":\"%s\",\"event\":\"%s\"}", t[0], t[1], t[2], t[3]); client.sendEventsStoreMessage(EventStoreMessage.builder() .channel("order-state." + t[0]) .metadata(t[3]) .body(body.getBytes()) .build()); System.out.printf("Transition: %s -> %s%n", t[1], t[2]); } client.close(); ``` ```csharp title="OrderStateMachine.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); var transitions = new[] { ("", "created", "order.created"), ("created", "paid", "order.paid"), ("paid", "picking", "order.picking"), ("picking", "shipped", "order.shipped"), ("shipped", "delivered", "order.delivered"), }; foreach (var (from, to, evt) in transitions) { var body = $"{{\"orderId\":\"ORD-2001\",\"fromState\":\"{from}\",\"toState\":\"{to}\"}}"; await client.SendEventStoreAsync(new EventStoreMessage { Channel = "order-state.ORD-2001", Metadata = evt, Body = Encoding.UTF8.GetBytes(body), }); Console.WriteLine($"Transition: {from} -> {to}"); } ``` ```kotlin title="OrderStateMachine.kt" val client = KubeMQClient.pubSub { address = "localhost:50000" clientId = "state-machine" } data class Transition(val from: String, val to: String, val event: String) val transitions = listOf( Transition("", "created", "order.created"), Transition("created", "paid", "order.paid"), Transition("paid", "picking", "order.picking"), Transition("picking", "shipped", "order.shipped"), Transition("shipped", "delivered", "order.delivered"), ) client.use { for (t in transitions) { client.sendEventStore(eventStoreMessage { channel = "order-state.ORD-2001" metadata = t.event body = """{"fromState":"${t.from}","toState":"${t.to}"}""".toByteArray() }) println("Transition: ${t.from} -> ${t.to}") } } ``` ```cpp title="order_state_machine.cc" kubemq::ClientOptions options; options.set_address("localhost", 50000); auto client = kubemq::Client::Create(options).value(); struct Transition { std::string from, to, event; }; std::vector transitions = { {"", "created", "order.created"}, {"created", "paid", "order.paid"}, {"paid", "picking", "order.picking"}, {"picking", "shipped", "order.shipped"}, {"shipped", "delivered", "order.delivered"}, }; for (const auto& t : transitions) { kubemq::EventStoreMessage msg; msg.set_channel("order-state.ORD-2001"); msg.set_metadata(t.event); msg.set_body("{\"fromState\":\"" + t.from + "\",\"toState\":\"" + t.to + "\"}"); client->SendEventStore(msg); std::cout << "Transition: " << t.from << " -> " << t.to << std::endl; } ``` ```rust title="order_state_machine.rs" use kubemq::prelude::*; use kubemq::EventStoreBuilder; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; let order_id = "ORD-2001"; let channel = format!("order-state.{order_id}"); let transitions = [ ("", "created", "order.created"), ("created", "paid", "order.paid"), ("paid", "picking", "order.picking"), ("picking", "shipped", "order.shipped"), ("shipped", "delivered", "order.delivered"), ]; for (from, to, event) in transitions { let body = format!( r#"{{"orderId":"{order_id}","fromState":"{from}","toState":"{to}"}}"# ); let msg = EventStoreBuilder::new() .channel(&channel) .metadata(event) .body(body.into_bytes()) .build(); let result = client.send_event_store(msg).await?; println!("Transition: {from} -> {to} (id: {}, sent: {})", result.id, result.sent); } client.close().await?; Ok(()) } ``` ```ruby title="order_state_machine.rb" require 'kubemq' client = KubeMQ::PubSubClient.new(address: 'localhost:50000', client_id: 'state-machine') order_id = 'ORD-2001' transitions = [ ['', 'created', 'order.created'], ['created', 'paid', 'order.paid'], ['paid', 'picking', 'order.picking'], ['picking', 'shipped', 'order.shipped'], ['shipped', 'delivered', 'order.delivered'] ] transitions.each do |from, to, event| body = { orderId: order_id, fromState: from, toState: to }.to_json msg = KubeMQ::PubSub::EventStoreMessage.new( channel: "order-state.#{order_id}", metadata: event, body: body ) result = client.send_event_store(msg) puts "Transition: #{from} -> #{to} (id: #{result.id}, sent: #{result.sent})" end client.close ``` ```elixir title="order_state_machine.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "state-machine") order_id = "ORD-2001" transitions = [ {"", "created", "order.created"}, {"created", "paid", "order.paid"}, {"paid", "picking", "order.picking"}, {"picking", "shipped", "order.shipped"}, {"shipped", "delivered", "order.delivered"} ] for {from, to, event} <- transitions do body = Jason.encode!(%{orderId: order_id, fromState: from, toState: to}) event_msg = KubeMQ.EventStore.new( channel: "order-state.#{order_id}", metadata: event, body: body ) {:ok, result} = KubeMQ.Client.send_event_store(client, event_msg) IO.puts("Transition: #{from} -> #{to} (id: #{result.id}, sent: #{result.sent})") end KubeMQ.Client.close(client) ``` ### Rebuild Current State [#rebuild-current-state] Subscribe with `StartFromFirst` to replay the transition history and determine the current state. ```text seq=1 order.created -> state=created seq=2 order.paid -> state=paid seq=3 order.picking -> state=picking seq=4 order.shipped -> state=shipped seq=5 order.delivered -> state=delivered Current state: delivered ``` ## Production Considerations [#production-considerations] Validate state transitions **before** publishing. Reject invalid transitions (e.g., `created -> shipped` skipping `paid`). The event store is append-only — you cannot undo an invalid event. If an invalid transition is published, append a compensating event (e.g., `order.transition_reverted`). Use a single publisher per order or implement optimistic concurrency by checking the expected sequence number before publishing. If the sequence has advanced, another process made a transition — reload state and retry. For orders with many state changes, periodically save a snapshot of the current state alongside the last processed sequence number. On startup, load the snapshot and replay only subsequent events. Use `order-state.{orderId}` for per-order channels. This provides independent replay per order and avoids cross-order interference. For aggregate queries (e.g., "all orders in shipped state"), maintain a separate read model updated by a consumer group. ## Related [#related] * [Event Sourcing](/learn/events-store/tutorials/event-sourcing) for the foundational pattern * [Replay Events](/learn/events-store/tutorials/replay-events) for state reconstruction techniques * [Cross-Service Sync](/learn/events-store/scenarios/cross-service-sync) for multi-service state coordination # Background Job Worker (/learn/queues/scenarios/background-workers) ## Architecture [#architecture] A job publisher enqueues tasks (image processing, report generation, email sending). A pool of workers competes for jobs — each job is processed by exactly one worker. Workers acknowledge jobs on success and nack on failure for automatic retry. *A pool of workers competes for jobs on a single queue — each job is delivered to exactly one worker, which acks on success or nacks to requeue for retry.* ## Job Publisher [#job-publisher] Enqueue background jobs with metadata describing the job type and priority via tags. ```go title="job_publisher.go" type Job struct { JobID string `json:"jobId"` Type string `json:"type"` Payload string `json:"payload"` } func enqueueJob(ctx context.Context, client *kubemq.Client, job Job) error { body, _ := json.Marshal(job) msg := kubemq.NewQueueMessage(). SetChannel("background-jobs"). SetBody(body). SetMetadata(job.Type). SetTags(map[string]string{ "type": job.Type, "priority": "normal", }). SetMaxReceiveCount(3). SetMaxReceiveQueue("background-jobs.dlq") _, err := client.SendQueueMessage(ctx, msg) return err } ``` ```python title="job_publisher.py" import json def enqueue_job(client, job): result = client.send_queue_message( QueueMessage( channel="background-jobs", body=json.dumps(job).encode(), metadata=job["type"], tags={"type": job["type"], "priority": "normal"}, max_receive_count=3, max_receive_queue="background-jobs.dlq", ) ) print(f"Job {job['jobId']} enqueued: id={result.id}") ``` ```typescript title="job_publisher.ts" async function enqueueJob(client: KubeMQClient, job: Job) { const result = await client.sendQueueMessage( createQueueMessage({ channel: 'background-jobs', body: JSON.stringify(job), metadata: job.type, tags: { type: job.type, priority: 'normal' }, policy: { maxReceiveCount: 3, maxReceiveQueue: 'background-jobs.dlq' }, }), ); console.log(`Job ${job.jobId} enqueued: id=${result.messageId}`); } ``` ```java title="JobPublisher.java" public void enqueueJob(QueuesClient client, Job job) throws Exception { QueueMessage msg = QueueMessage.builder() .channel("background-jobs") .body(objectMapper.writeValueAsBytes(job)) .metadata(job.getType()) .tags(Map.of("type", job.getType(), "priority", "normal")) .maxReceiveCount(3) .maxReceiveQueue("background-jobs.dlq") .build(); client.sendQueueMessage(msg); System.out.printf("Job %s enqueued%n", job.getJobId()); } ``` ```csharp title="JobPublisher.cs" async Task EnqueueJob(QueuesClient client, Job job) { await client.SendQueueMessageAsync(new QueueMessage { Channel = "background-jobs", Body = JsonSerializer.SerializeToUtf8Bytes(job), Metadata = job.Type, Tags = new Dictionary { ["type"] = job.Type, ["priority"] = "normal" }, MaxReceiveCount = 3, MaxReceiveQueue = "background-jobs.dlq" }); Console.WriteLine($"Job {job.JobId} enqueued"); } ``` ```kotlin title="JobPublisher.kt" suspend fun enqueueJob(client: QueuesClient, job: Job) { client.sendQueueMessage(QueueMessage( channel = "background-jobs", body = Json.encodeToString(job).toByteArray(), metadata = job.type, tags = mapOf("type" to job.type, "priority" to "normal"), maxReceiveCount = 3, maxReceiveQueue = "background-jobs.dlq" )) println("Job ${job.jobId} enqueued") } ``` ```cpp title="job_publisher.cpp" void enqueueJob(kubemq::QueuesClient& client, const Job& job) { kubemq::QueueMessage msg; msg.channel = "background-jobs"; msg.body = job.toJson(); msg.metadata = job.type; msg.tags = {{"type", job.type}, {"priority", "normal"}}; msg.maxReceiveCount = 3; msg.maxReceiveQueue = "background-jobs.dlq"; client.sendQueueMessage(msg); std::cout << "Job " << job.jobId << " enqueued" << std::endl; } ``` ```rust title="job_publisher.rs" use kubemq::prelude::*; use kubemq::QueueMessageBuilder; use std::collections::HashMap; async fn enqueue_job(client: &KubemqClient, job: &Job) -> kubemq::Result<()> { let mut tags = HashMap::new(); tags.insert("type".to_string(), job.job_type.clone()); tags.insert("priority".to_string(), "normal".to_string()); let msg = QueueMessageBuilder::new() .channel("background-jobs") .body(serde_json::to_vec(job).unwrap()) .metadata(job.job_type.clone()) .tags(tags) .max_receive_count(3) .max_receive_queue("background-jobs.dlq") .build(); let result = client.send_queue_message(msg).await?; println!("Job {} enqueued: id={}", job.job_id, result.message_id); Ok(()) } ``` ```ruby title="job_publisher.rb" require 'kubemq' require 'json' def enqueue_job(client, job) policy = KubeMQ::Queues::QueueMessagePolicy.new( max_receive_count: 3, max_receive_queue: 'background-jobs.dlq' ) msg = KubeMQ::Queues::QueueMessage.new( channel: 'background-jobs', body: job.to_json, metadata: job['type'], tags: { 'type' => job['type'], 'priority' => 'normal' }, policy: policy ) result = client.send_queue_message(msg) puts "Job #{job['jobId']} enqueued: id=#{result.id}" end ``` ```elixir title="job_publisher.exs" defmodule JobPublisher do def enqueue_job(client, job) do msg = KubeMQ.QueueMessage.new( channel: "background-jobs", body: Jason.encode!(job), metadata: job["type"], tags: %{"type" => job["type"], "priority" => "normal"}, policy: KubeMQ.QueuePolicy.new( max_receive_count: 3, max_receive_queue: "background-jobs.dlq" ) ) {:ok, result} = KubeMQ.Client.send_queue_message(client, msg) IO.puts("Job #{job["jobId"]} enqueued: id=#{result.message_id}") end end ``` ## Worker Pool [#worker-pool] Each worker runs in a loop, polling for jobs in batches. KubeMQ ensures each job goes to exactly one worker. ```go title="worker_pool.go" func startWorker(ctx context.Context, client *kubemq.Client, id int) { workerID := fmt.Sprintf("worker-%d", id) log.Printf("[%s] Started", workerID) for { select { case <-ctx.Done(): return default: } resp, err := client.PollQueue(ctx, &kubemq.PollRequest{ Channel: "background-jobs", MaxItems: 5, WaitTimeoutSeconds: 10, VisibilitySeconds: 300, }) if err != nil { log.Printf("[%s] Poll error: %v", workerID, err) time.Sleep(5 * time.Second) continue } for _, m := range resp.Messages { var job Job json.Unmarshal(m.Message.Body, &job) log.Printf("[%s] Processing job %s (%s)", workerID, job.JobID, job.Type) if err := executeJob(job); err != nil { log.Printf("[%s] Job %s failed: %v", workerID, job.JobID, err) } else { log.Printf("[%s] Job %s completed", workerID, job.JobID) } } resp.AckAll() } } func main() { ctx := context.Background() client, _ := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000)) defer client.Close() for i := 1; i <= 3; i++ { go startWorker(ctx, client, i) } <-ctx.Done() } ``` ```python title="worker_pool.py" import json import threading import time def start_worker(client, worker_id): print(f"[{worker_id}] Started") while True: response = client.receive_queue_messages( channel="background-jobs", max_messages=5, wait_timeout_in_seconds=10, visibility_seconds=300, ) for msg in response.messages: job = json.loads(msg.body.decode("utf-8")) print(f"[{worker_id}] Processing job {job['jobId']} ({job['type']})") try: execute_job(job) msg.ack() print(f"[{worker_id}] Job {job['jobId']} completed") except Exception as e: print(f"[{worker_id}] Job {job['jobId']} failed: {e}") msg.nack() time.sleep(1) for i in range(1, 4): threading.Thread(target=start_worker, args=(client, f"worker-{i}"), daemon=True).start() ``` ```typescript title="worker_pool.ts" async function startWorker(client: KubeMQClient, workerId: string) { console.log(`[${workerId}] Started`); while (true) { const messages = await client.receiveQueueMessages({ channel: 'background-jobs', maxMessages: 5, waitTimeoutSeconds: 10, visibilitySeconds: 300, }); for (const msg of messages) { const job = JSON.parse(new TextDecoder().decode(msg.body)); console.log(`[${workerId}] Processing job ${job.jobId} (${job.type})`); try { await executeJob(job); await msg.ack(); console.log(`[${workerId}] Job ${job.jobId} completed`); } catch (err) { console.log(`[${workerId}] Job ${job.jobId} failed:`, err); await msg.nack(); } } await new Promise((r) => setTimeout(r, 1000)); } } for (let i = 1; i <= 3; i++) { startWorker(client, `worker-${i}`); } ``` ```java title="WorkerPool.java" public void startWorker(QueuesClient client, String workerId) { System.out.printf("[%s] Started%n", workerId); while (true) { ReceiveQueueMessagesResponse response = client.receiveQueueMessages( ReceiveQueueMessagesRequest.builder() .channel("background-jobs").maxMessages(5) .waitTimeoutSeconds(10).visibilitySeconds(300).build()); for (QueueMessageReceived msg : response.getMessages()) { Job job = objectMapper.readValue(msg.getBody(), Job.class); System.out.printf("[%s] Processing job %s (%s)%n", workerId, job.getJobId(), job.getType()); try { executeJob(job); msg.ack(); } catch (Exception e) { System.out.printf("[%s] Job %s failed: %s%n", workerId, job.getJobId(), e); msg.nack(); } } Thread.sleep(1000); } } ``` ```csharp title="WorkerPool.cs" async Task StartWorker(QueuesClient client, string workerId) { Console.WriteLine($"[{workerId}] Started"); while (true) { var response = await client.ReceiveQueueMessagesAsync(new ReceiveQueueMessagesRequest { Channel = "background-jobs", MaxMessages = 5, WaitTimeoutSeconds = 10, VisibilitySeconds = 300, }); foreach (var msg in response.Messages) { var job = JsonSerializer.Deserialize(msg.Body.Span); Console.WriteLine($"[{workerId}] Processing job {job.JobId} ({job.Type})"); try { ExecuteJob(job); await msg.AckAsync(); } catch (Exception ex) { Console.WriteLine($"[{workerId}] Job {job.JobId} failed: {ex.Message}"); await msg.NAckAsync(); } } await Task.Delay(1000); } } ``` ```kotlin title="WorkerPool.kt" suspend fun startWorker(client: QueuesClient, workerId: String) { println("[$workerId] Started") while (true) { val response = client.receiveQueueMessages( channel = "background-jobs", maxMessages = 5, waitTimeoutSeconds = 10, visibilitySeconds = 300) for (msg in response.messages) { val job = Json.decodeFromString(String(msg.body)) println("[$workerId] Processing job ${job.jobId} (${job.type})") try { executeJob(job) msg.ack() } catch (e: Exception) { println("[$workerId] Job ${job.jobId} failed: ${e.message}") msg.nack() } } delay(1000) } } ``` ```cpp title="worker_pool.cpp" void startWorker(kubemq::QueuesClient& client, const std::string& workerId) { std::cout << "[" << workerId << "] Started" << std::endl; while (true) { auto response = client.receiveQueueMessages( "background-jobs", 5, 10, false, 300); for (const auto& msg : response.messages) { auto job = Job::fromJson(msg.body); std::cout << "[" << workerId << "] Processing job " << job.jobId << " (" << job.type << ")" << std::endl; try { executeJob(job); msg.ack(); } catch (const std::exception& e) { std::cerr << "[" << workerId << "] Failed: " << e.what() << std::endl; msg.nack(); } } std::this_thread::sleep_for(std::chrono::seconds(1)); } } ``` ```rust title="worker_pool.rs" use kubemq::prelude::*; use kubemq::PollRequest; async fn start_worker(client: &KubemqClient, worker_id: &str) -> kubemq::Result<()> { println!("[{}] Started", worker_id); let mut receiver = client.new_queue_downstream_receiver().await?; loop { let poll = PollRequest { channel: "background-jobs".to_string(), max_items: 5, wait_timeout_seconds: 10, auto_ack: false, }; let response = receiver.poll(poll).await?; for msg in &response.messages { let job: Job = serde_json::from_slice(&msg.message.body).unwrap(); println!("[{}] Processing job {} ({})", worker_id, job.job_id, job.job_type); match execute_job(&job) { Ok(_) => { msg.ack().await?; println!("[{}] Job {} completed", worker_id, job.job_id); } Err(e) => { println!("[{}] Job {} failed: {}", worker_id, job.job_id, e); msg.nack().await?; } } } } } ``` ```ruby title="worker_pool.rb" require 'kubemq' require 'json' def start_worker(client, worker_id) puts "[#{worker_id}] Started" receiver = client.create_downstream_receiver loop do request = KubeMQ::Queues::QueuePollRequest.new( channel: 'background-jobs', max_items: 5, wait_timeout: 10 ) response = receiver.poll(request) next if response.error? response.messages.each do |msg| job = JSON.parse(msg.body) puts "[#{worker_id}] Processing job #{job['jobId']} (#{job['type']})" begin execute_job(job) msg.ack puts "[#{worker_id}] Job #{job['jobId']} completed" rescue StandardError => e puts "[#{worker_id}] Job #{job['jobId']} failed: #{e.message}" msg.nack end end end ensure receiver&.close end ``` ```elixir title="worker_pool.exs" defmodule WorkerPool do # Elixir acks/nacks by sequence range on the poll transaction # rather than per individual message. def start_worker(client, worker_id) do IO.puts("[#{worker_id}] Started") case KubeMQ.Client.poll_queue(client, channel: "background-jobs", max_items: 5, wait_timeout: 10_000 ) do {:ok, poll} when length(poll.messages) > 0 -> {ack_seqs, nack_seqs} = Enum.reduce(poll.messages, {[], []}, fn msg, {ok, fail} -> job = Jason.decode!(msg.body) seq = msg.attributes.sequence IO.puts("[#{worker_id}] Processing job #{job["jobId"]} (#{job["type"]})") case execute_job(job) do :ok -> {[seq | ok], fail} {:error, _} -> {ok, [seq | fail]} end end) if ack_seqs != [], do: KubeMQ.PollResponse.ack_range(poll, ack_seqs) if nack_seqs != [], do: KubeMQ.PollResponse.nack_range(poll, nack_seqs) start_worker(client, worker_id) _ -> start_worker(client, worker_id) end end end ``` ## Advanced Configuration [#advanced-configuration] Increase `maxMessages` to pull more jobs per poll cycle. Workers process jobs in batches, reducing network round trips. Use separate channels for different priorities (`jobs.high`, `jobs.normal`, `jobs.low`). Workers poll high-priority channels first. Set `expirationSeconds` on time-sensitive jobs. Stale jobs are automatically discarded instead of consuming worker capacity. ## Next Steps [#next-steps] # Order Processing Pipeline (/learn/queues/scenarios/order-processing) ## Architecture [#architecture] An e-commerce platform submits orders to a queue. Worker processes pull orders, validate payment, update inventory, and send confirmations. Failed orders are retried with backoff, and permanently failed orders land in a dead letter queue for manual review. *Orders flow through a work queue to competing workers; failures retry, then dead-letter for review.* ## Order Submission [#order-submission] The API service creates an order and places it on the queue with DLQ policy and a 5-minute expiration. ```go title="order_api.go" type Order struct { OrderID string `json:"orderId"` Customer string `json:"customer"` Items int `json:"items"` Total float64 `json:"total"` CreatedAt int64 `json:"createdAt"` } func submitOrder(ctx context.Context, client *kubemq.Client, order Order) error { body, _ := json.Marshal(order) msg := kubemq.NewQueueMessage(). SetChannel("orders"). SetBody(body). SetMetadata("order.created"). SetTags(map[string]string{"customer": order.Customer}). SetMaxReceiveCount(5). SetMaxReceiveQueue("orders.dlq"). SetExpirationSeconds(300) result, err := client.SendQueueMessage(ctx, msg) if err != nil { return err } log.Printf("Order %s submitted: id=%s", order.OrderID, result.MessageID) return nil } ``` ```python title="order_api.py" import json import time def submit_order(client, order): result = client.send_queue_message( QueueMessage( channel="orders", body=json.dumps(order).encode(), metadata="order.created", tags={"customer": order["customer"]}, max_receive_count=5, max_receive_queue="orders.dlq", expiration_in_seconds=300, ) ) print(f"Order {order['orderId']} submitted: id={result.id}") return result submit_order(client, { "orderId": "ORD-2001", "customer": "alice@example.com", "items": 3, "total": 149.97, "createdAt": int(time.time() * 1000), }) ``` ```typescript title="order_api.ts" interface Order { orderId: string; customer: string; items: number; total: number; createdAt: number; } async function submitOrder(client: KubeMQClient, order: Order) { const result = await client.sendQueueMessage( createQueueMessage({ channel: 'orders', body: JSON.stringify(order), metadata: 'order.created', tags: { customer: order.customer }, policy: { maxReceiveCount: 5, maxReceiveQueue: 'orders.dlq', expirationSeconds: 300, }, }), ); console.log(`Order ${order.orderId} submitted: id=${result.messageId}`); } ``` ```java title="OrderApi.java" public void submitOrder(QueuesClient client, Order order) throws Exception { byte[] body = objectMapper.writeValueAsBytes(order); QueueMessage msg = QueueMessage.builder() .channel("orders") .body(body) .metadata("order.created") .tags(Map.of("customer", order.getCustomer())) .maxReceiveCount(5) .maxReceiveQueue("orders.dlq") .expirationSeconds(300) .build(); SendQueueMessageResult result = client.sendQueueMessage(msg); System.out.printf("Order %s submitted: id=%s%n", order.getOrderId(), result.getMessageId()); } ``` ```csharp title="OrderApi.cs" async Task SubmitOrder(QueuesClient client, Order order) { var body = JsonSerializer.SerializeToUtf8Bytes(order); var result = await client.SendQueueMessageAsync(new QueueMessage { Channel = "orders", Body = body, Metadata = "order.created", Tags = new Dictionary { ["customer"] = order.Customer }, MaxReceiveCount = 5, MaxReceiveQueue = "orders.dlq", ExpirationSeconds = 300 }); Console.WriteLine($"Order {order.OrderId} submitted: id={result.MessageId}"); } ``` ```kotlin title="OrderApi.kt" suspend fun submitOrder(client: QueuesClient, order: Order) { val body = Json.encodeToString(order).toByteArray() val result = client.sendQueueMessage(QueueMessage( channel = "orders", body = body, metadata = "order.created", tags = mapOf("customer" to order.customer), maxReceiveCount = 5, maxReceiveQueue = "orders.dlq", expirationSeconds = 300 )) println("Order ${order.orderId} submitted: id=${result.messageId}") } ``` ```cpp title="order_api.cpp" void submitOrder(kubemq::QueuesClient& client, const Order& order) { kubemq::QueueMessage msg; msg.channel = "orders"; msg.body = order.toJson(); msg.metadata = "order.created"; msg.tags = {{"customer", order.customer}}; msg.maxReceiveCount = 5; msg.maxReceiveQueue = "orders.dlq"; msg.expirationSeconds = 300; auto result = client.sendQueueMessage(msg); std::cout << "Order " << order.orderId << " submitted: id=" << result.messageId << std::endl; } ``` ```rust title="order_api.rs" use kubemq::prelude::*; use kubemq::QueueMessageBuilder; use std::collections::HashMap; async fn submit_order(client: &KubemqClient, order: &Order) -> kubemq::Result<()> { let body = serde_json::to_vec(order).unwrap(); let msg = QueueMessageBuilder::new() .channel("orders") .body(body) .metadata("order.created") .tags(HashMap::from([("customer".to_string(), order.customer.clone())])) .max_receive_count(5) .max_receive_queue("orders.dlq") .expiration_seconds(300) .build(); let result = client.send_queue_message(msg).await?; println!("Order {} submitted: id={}", order.order_id, result.message_id); Ok(()) } ``` ```ruby title="order_api.rb" require 'kubemq' require 'json' def submit_order(client, order) policy = KubeMQ::Queues::QueueMessagePolicy.new( max_receive_count: 5, max_receive_queue: 'orders.dlq', expiration_seconds: 300 ) msg = KubeMQ::Queues::QueueMessage.new( channel: 'orders', metadata: 'order.created', body: order.to_json, tags: { 'customer' => order['customer'] }, policy: policy ) result = client.send_queue_message(msg) puts "Order #{order['orderId']} submitted: id=#{result.id}" end ``` ```elixir title="order_api.exs" defp submit_order(client, order) do msg = KubeMQ.QueueMessage.new( channel: "orders", metadata: "order.created", body: Jason.encode!(order), tags: %{"customer" => order.customer}, policy: KubeMQ.QueuePolicy.new( max_receive_count: 5, max_receive_queue: "orders.dlq", expiration_seconds: 300 ) ) {:ok, result} = KubeMQ.Client.send_queue_message(client, msg) IO.puts("Order #{order.order_id} submitted: id=#{result.message_id}") end ``` ## Order Worker [#order-worker] Workers poll for orders, process them, and acknowledge on success. Failed orders are nacked for retry. ```go title="order_worker.go" func runWorker(ctx context.Context, client *kubemq.Client, workerID string) { for { resp, err := client.PollQueue(ctx, &kubemq.PollRequest{ Channel: "orders", MaxItems: 5, WaitTimeoutSeconds: 10, VisibilitySeconds: 120, }) if err != nil { log.Printf("[%s] Poll error: %v", workerID, err) time.Sleep(5 * time.Second) continue } for _, m := range resp.Messages { var order Order json.Unmarshal(m.Message.Body, &order) log.Printf("[%s] Processing order %s (attempt %d)", workerID, order.OrderID, m.Message.Attributes.ReceiveCount) if err := processOrder(order); err != nil { log.Printf("[%s] Failed: %v", workerID, err) continue } log.Printf("[%s] Order %s completed", workerID, order.OrderID) } resp.AckAll() } } ``` ```python title="order_worker.py" import json import time def run_worker(client, worker_id): while True: response = client.receive_queue_messages( channel="orders", max_messages=5, wait_timeout_in_seconds=10, visibility_seconds=120, ) for msg in response.messages: order = json.loads(msg.body.decode("utf-8")) print(f"[{worker_id}] Processing order {order['orderId']} " f"(attempt {msg.receive_count})") try: process_order(order) msg.ack() print(f"[{worker_id}] Order {order['orderId']} completed") except Exception as e: print(f"[{worker_id}] Failed: {e}") msg.nack() time.sleep(1) ``` ```typescript title="order_worker.ts" async function runWorker(client: KubeMQClient, workerId: string) { while (true) { const messages = await client.receiveQueueMessages({ channel: 'orders', maxMessages: 5, waitTimeoutSeconds: 10, visibilitySeconds: 120, }); for (const msg of messages) { const order = JSON.parse(new TextDecoder().decode(msg.body)); console.log( `[${workerId}] Processing order ${order.orderId} (attempt ${msg.receiveCount})`, ); try { await processOrder(order); await msg.ack(); console.log(`[${workerId}] Order ${order.orderId} completed`); } catch (err) { console.log(`[${workerId}] Failed:`, err); await msg.nack(); } } await new Promise((r) => setTimeout(r, 1000)); } } ``` ```java title="OrderWorker.java" public void runWorker(QueuesClient client, String workerId) { while (true) { ReceiveQueueMessagesResponse response = client.receiveQueueMessages( ReceiveQueueMessagesRequest.builder() .channel("orders").maxMessages(5) .waitTimeoutSeconds(10).visibilitySeconds(120).build()); for (QueueMessageReceived msg : response.getMessages()) { Order order = objectMapper.readValue(msg.getBody(), Order.class); System.out.printf("[%s] Processing order %s (attempt %d)%n", workerId, order.getOrderId(), msg.getReceiveCount()); try { processOrder(order); msg.ack(); System.out.printf("[%s] Order %s completed%n", workerId, order.getOrderId()); } catch (Exception e) { System.out.printf("[%s] Failed: %s%n", workerId, e.getMessage()); msg.nack(); } } Thread.sleep(1000); } } ``` ```csharp title="OrderWorker.cs" async Task RunWorker(QueuesClient client, string workerId) { while (true) { var response = await client.ReceiveQueueMessagesAsync(new ReceiveQueueMessagesRequest { Channel = "orders", MaxMessages = 5, WaitTimeoutSeconds = 10, VisibilitySeconds = 120, }); foreach (var msg in response.Messages) { var order = JsonSerializer.Deserialize(msg.Body.Span); Console.WriteLine($"[{workerId}] Processing {order.OrderId} (attempt {msg.ReceiveCount})"); try { ProcessOrder(order); await msg.AckAsync(); Console.WriteLine($"[{workerId}] Order {order.OrderId} completed"); } catch (Exception ex) { Console.WriteLine($"[{workerId}] Failed: {ex.Message}"); await msg.NAckAsync(); } } await Task.Delay(1000); } } ``` ```kotlin title="OrderWorker.kt" suspend fun runWorker(client: QueuesClient, workerId: String) { while (true) { val response = client.receiveQueueMessages( channel = "orders", maxMessages = 5, waitTimeoutSeconds = 10, visibilitySeconds = 120) for (msg in response.messages) { val order = Json.decodeFromString(String(msg.body)) println("[$workerId] Processing ${order.orderId} (attempt ${msg.receiveCount})") try { processOrder(order) msg.ack() println("[$workerId] Order ${order.orderId} completed") } catch (e: Exception) { println("[$workerId] Failed: ${e.message}") msg.nack() } } delay(1000) } } ``` ```cpp title="order_worker.cpp" void runWorker(kubemq::QueuesClient& client, const std::string& workerId) { while (true) { auto response = client.receiveQueueMessages("orders", 5, 10, false, 120); for (const auto& msg : response.messages) { auto order = Order::fromJson(msg.body); std::cout << "[" << workerId << "] Processing " << order.orderId << " (attempt " << msg.receiveCount << ")" << std::endl; try { processOrder(order); msg.ack(); std::cout << "[" << workerId << "] Order " << order.orderId << " completed" << std::endl; } catch (const std::exception& e) { std::cerr << "[" << workerId << "] Failed: " << e.what() << std::endl; msg.nack(); } } std::this_thread::sleep_for(std::chrono::seconds(1)); } } ``` ```rust title="order_worker.rs" use kubemq::prelude::*; use kubemq::PollRequest; use std::time::Duration; async fn run_worker(client: &KubemqClient, worker_id: &str) -> kubemq::Result<()> { let mut receiver = client.new_queue_downstream_receiver().await?; loop { let poll = PollRequest { channel: "orders".to_string(), max_items: 5, wait_timeout_seconds: 10, auto_ack: false, }; let response = receiver.poll(poll).await?; for msg in &response.messages { let order: Order = serde_json::from_slice(&msg.message.body).unwrap(); let attempt = msg.message.attributes.as_ref().map(|a| a.receive_count).unwrap_or(0); println!("[{}] Processing order {} (attempt {})", worker_id, order.order_id, attempt); match process_order(&order) { Ok(_) => { msg.ack().await?; println!("[{}] Order {} completed", worker_id, order.order_id); } Err(e) => { println!("[{}] Failed: {}", worker_id, e); msg.nack().await?; } } } tokio::time::sleep(Duration::from_secs(1)).await; } } ``` ```ruby title="order_worker.rb" require 'kubemq' require 'json' def run_worker(client, worker_id) receiver = client.create_downstream_receiver loop do request = KubeMQ::Queues::QueuePollRequest.new( channel: 'orders', max_items: 5, wait_timeout: 10 ) response = receiver.poll(request) response.messages.each do |m| order = JSON.parse(m.body) puts "[#{worker_id}] Processing order #{order['orderId']} (attempt #{m.attributes.receive_count})" begin process_order(order) m.ack puts "[#{worker_id}] Order #{order['orderId']} completed" rescue StandardError => e puts "[#{worker_id}] Failed: #{e.message}" m.nack end end sleep 1 end ensure receiver&.close end ``` ```elixir title="order_worker.exs" defp run_worker(client, worker_id) do case KubeMQ.Client.poll_queue(client, channel: "orders", max_items: 5, wait_timeout: 10_000) do {:ok, poll} -> # The Elixir SDK settles a poll batch as a whole: ack_all on success, # nack_all to return every message for redelivery (no per-message ack). results = Enum.map(poll.messages, fn msg -> order = Jason.decode!(msg.body) IO.puts("[#{worker_id}] Processing order #{order["orderId"]} (attempt #{msg.attributes.receive_count})") process_order(order) end) if Enum.all?(results, &(&1 == :ok)) do KubeMQ.PollResponse.ack_all(poll) IO.puts("[#{worker_id}] Batch completed") else KubeMQ.PollResponse.nack_all(poll) IO.puts("[#{worker_id}] Batch returned for retry") end {:error, err} -> IO.puts("[#{worker_id}] Poll error: #{err.message}") end Process.sleep(1_000) run_worker(client, worker_id) end ``` ## Advanced Configuration [#advanced-configuration] Use the `orderId` as an idempotency key. Before processing, check if the order was already processed. This prevents duplicate work when messages are redelivered. Set visibility timeout based on your processing time. For payment processing (5-30s), use 120s. For shipping label generation (30-60s), use 300s. Consider using [extend visibility](/learn/queues/how-to/extend-visibility) for variable-duration tasks. Poll the DLQ channel periodically to detect failed orders. Alert on DLQ message count exceeding a threshold. Inspect `reRoutedFromQueue` to identify the source queue. ## Next Steps [#next-steps] # Scheduled Task System (/learn/queues/scenarios/scheduled-tasks) ## Architecture [#architecture] A scheduler submits tasks with a delay — the task becomes available only after the delay expires. Task workers process tasks when they become due. For recurring tasks, the worker re-enqueues the task with the next delay. *A scheduler sends each task with a delay; the broker holds it until the delay expires, then a worker receives and processes it — re-enqueuing recurring tasks for their next run.* ## Schedule a Task [#schedule-a-task] Send a message with `delaySeconds` set to the desired future execution time. ```go title="scheduler.go" type ScheduledTask struct { TaskID string `json:"taskId"` Action string `json:"action"` Recurring bool `json:"recurring"` IntervalSec int `json:"intervalSec"` } func scheduleTask(ctx context.Context, client *kubemq.Client, task ScheduledTask, delaySec int) error { body, _ := json.Marshal(task) msg := kubemq.NewQueueMessage(). SetChannel("scheduled-tasks"). SetBody(body). SetMetadata(task.Action). SetTags(map[string]string{ "taskId": task.TaskID, "action": task.Action, }). SetDelaySeconds(delaySec) result, err := client.SendQueueMessage(ctx, msg) if err != nil { return err } log.Printf("Task %s scheduled in %ds: id=%s", task.TaskID, delaySec, result.MessageID) return nil } ``` ```python title="scheduler.py" import json def schedule_task(client, task, delay_sec): result = client.send_queue_message( QueueMessage( channel="scheduled-tasks", body=json.dumps(task).encode(), metadata=task["action"], tags={"taskId": task["taskId"], "action": task["action"]}, delay_in_seconds=delay_sec, ) ) print(f"Task {task['taskId']} scheduled in {delay_sec}s: id={result.id}") schedule_task(client, { "taskId": "reminder-001", "action": "send-reminder-email", "recurring": True, "intervalSec": 3600, }, delay_sec=3600) ``` ```typescript title="scheduler.ts" async function scheduleTask(client: KubeMQClient, task: ScheduledTask, delaySec: number) { const result = await client.sendQueueMessage( createQueueMessage({ channel: 'scheduled-tasks', body: JSON.stringify(task), metadata: task.action, tags: { taskId: task.taskId, action: task.action }, policy: { delaySeconds: delaySec }, }), ); console.log(`Task ${task.taskId} scheduled in ${delaySec}s: id=${result.messageId}`); } ``` ```java title="Scheduler.java" public void scheduleTask(QueuesClient client, ScheduledTask task, int delaySec) throws Exception { QueueMessage msg = QueueMessage.builder() .channel("scheduled-tasks") .body(objectMapper.writeValueAsBytes(task)) .metadata(task.getAction()) .tags(Map.of("taskId", task.getTaskId(), "action", task.getAction())) .delaySeconds(delaySec) .build(); SendQueueMessageResult result = client.sendQueueMessage(msg); System.out.printf("Task %s scheduled in %ds: id=%s%n", task.getTaskId(), delaySec, result.getMessageId()); } ``` ```csharp title="Scheduler.cs" async Task ScheduleTask(QueuesClient client, ScheduledTask task, int delaySec) { var result = await client.SendQueueMessageAsync(new QueueMessage { Channel = "scheduled-tasks", Body = JsonSerializer.SerializeToUtf8Bytes(task), Metadata = task.Action, Tags = new Dictionary { ["taskId"] = task.TaskId, ["action"] = task.Action }, DelaySeconds = delaySec }); Console.WriteLine($"Task {task.TaskId} scheduled in {delaySec}s: id={result.MessageId}"); } ``` ```kotlin title="Scheduler.kt" suspend fun scheduleTask(client: QueuesClient, task: ScheduledTask, delaySec: Int) { val result = client.sendQueueMessage(QueueMessage( channel = "scheduled-tasks", body = Json.encodeToString(task).toByteArray(), metadata = task.action, tags = mapOf("taskId" to task.taskId, "action" to task.action), delaySeconds = delaySec )) println("Task ${task.taskId} scheduled in ${delaySec}s: id=${result.messageId}") } ``` ```cpp title="scheduler.cpp" void scheduleTask(kubemq::QueuesClient& client, const ScheduledTask& task, int delaySec) { kubemq::QueueMessage msg; msg.channel = "scheduled-tasks"; msg.body = task.toJson(); msg.metadata = task.action; msg.tags = {{"taskId", task.taskId}, {"action", task.action}}; msg.delaySeconds = delaySec; auto result = client.sendQueueMessage(msg); std::cout << "Task " << task.taskId << " scheduled in " << delaySec << "s: id=" << result.messageId << std::endl; } ``` ```rust title="scheduler.rs" use kubemq::prelude::*; use kubemq::QueueMessageBuilder; use std::collections::HashMap; async fn schedule_task( client: &KubemqClient, task: &ScheduledTask, delay_sec: i32, ) -> kubemq::Result<()> { let mut tags = HashMap::new(); tags.insert("taskId".to_string(), task.task_id.clone()); tags.insert("action".to_string(), task.action.clone()); let msg = QueueMessageBuilder::new() .channel("scheduled-tasks") .body(serde_json::to_vec(task).unwrap()) .metadata(task.action.clone()) .tags(tags) .delay_seconds(delay_sec) .build(); let result = client.send_queue_message(msg).await?; println!( "Task {} scheduled in {}s: id={}", task.task_id, delay_sec, result.message_id ); Ok(()) } ``` ```ruby title="scheduler.rb" require 'kubemq' require 'json' def schedule_task(client, task, delay_sec) policy = KubeMQ::Queues::QueueMessagePolicy.new(delay_seconds: delay_sec) msg = KubeMQ::Queues::QueueMessage.new( channel: 'scheduled-tasks', metadata: task['action'], body: task.to_json, tags: { 'taskId' => task['taskId'], 'action' => task['action'] }, policy: policy ) result = client.send_queue_message(msg) puts "Task #{task['taskId']} scheduled in #{delay_sec}s: id=#{result.id}" end schedule_task(client, { 'taskId' => 'reminder-001', 'action' => 'send-reminder-email', 'recurring' => true, 'intervalSec' => 3600 }, 3600) ``` ```elixir title="scheduler.exs" defp schedule_task(client, task, delay_sec) do msg = KubeMQ.QueueMessage.new( channel: "scheduled-tasks", metadata: task.action, body: Jason.encode!(task), tags: %{"taskId" => task.task_id, "action" => task.action}, policy: KubeMQ.QueuePolicy.new(delay_seconds: delay_sec) ) {:ok, result} = KubeMQ.Client.send_queue_message(client, msg) IO.puts("Task #{task.task_id} scheduled in #{delay_sec}s: id=#{result.message_id}") end ``` ## Task Worker with Recurring Support [#task-worker-with-recurring-support] When a task completes, check if it's recurring and re-schedule it. ```go title="task_worker.go" for { resp, err := client.PollQueue(ctx, &kubemq.PollRequest{ Channel: "scheduled-tasks", MaxItems: 5, WaitTimeoutSeconds: 10, }) if err != nil { log.Printf("Poll error: %v", err) time.Sleep(5 * time.Second) continue } for _, m := range resp.Messages { var task ScheduledTask json.Unmarshal(m.Message.Body, &task) log.Printf("Executing task %s: %s", task.TaskID, task.Action) executeTask(task) if task.Recurring { scheduleTask(ctx, client, task, task.IntervalSec) log.Printf("Task %s rescheduled in %ds", task.TaskID, task.IntervalSec) } } resp.AckAll() } ``` ```python title="task_worker.py" import json import time while True: response = client.receive_queue_messages( channel="scheduled-tasks", max_messages=5, wait_timeout_in_seconds=10, ) for msg in response.messages: task = json.loads(msg.body.decode("utf-8")) print(f"Executing task {task['taskId']}: {task['action']}") execute_task(task) if task.get("recurring"): schedule_task(client, task, task["intervalSec"]) print(f"Task {task['taskId']} rescheduled in {task['intervalSec']}s") msg.ack() time.sleep(1) ``` ```typescript title="task_worker.ts" while (true) { const messages = await client.receiveQueueMessages({ channel: 'scheduled-tasks', maxMessages: 5, waitTimeoutSeconds: 10, }); for (const msg of messages) { const task = JSON.parse(new TextDecoder().decode(msg.body)); console.log(`Executing task ${task.taskId}: ${task.action}`); await executeTask(task); if (task.recurring) { await scheduleTask(client, task, task.intervalSec); console.log(`Task ${task.taskId} rescheduled in ${task.intervalSec}s`); } await msg.ack(); } await new Promise((r) => setTimeout(r, 1000)); } ``` ```java title="TaskWorker.java" while (true) { ReceiveQueueMessagesResponse response = client.receiveQueueMessages( ReceiveQueueMessagesRequest.builder() .channel("scheduled-tasks").maxMessages(5) .waitTimeoutSeconds(10).build()); for (QueueMessageReceived msg : response.getMessages()) { ScheduledTask task = objectMapper.readValue(msg.getBody(), ScheduledTask.class); System.out.printf("Executing task %s: %s%n", task.getTaskId(), task.getAction()); executeTask(task); if (task.isRecurring()) { scheduleTask(client, task, task.getIntervalSec()); } msg.ack(); } Thread.sleep(1000); } ``` ```csharp title="TaskWorker.cs" while (true) { var response = await client.ReceiveQueueMessagesAsync(new ReceiveQueueMessagesRequest { Channel = "scheduled-tasks", MaxMessages = 5, WaitTimeoutSeconds = 10, }); foreach (var msg in response.Messages) { var task = JsonSerializer.Deserialize(msg.Body.Span); Console.WriteLine($"Executing task {task.TaskId}: {task.Action}"); ExecuteTask(task); if (task.Recurring) await ScheduleTask(client, task, task.IntervalSec); await msg.AckAsync(); } await Task.Delay(1000); } ``` ```kotlin title="TaskWorker.kt" while (true) { val response = client.receiveQueueMessages( channel = "scheduled-tasks", maxMessages = 5, waitTimeoutSeconds = 10) for (msg in response.messages) { val task = Json.decodeFromString(String(msg.body)) println("Executing task ${task.taskId}: ${task.action}") executeTask(task) if (task.recurring) scheduleTask(client, task, task.intervalSec) msg.ack() } delay(1000) } ``` ```cpp title="task_worker.cpp" while (true) { auto response = client.receiveQueueMessages("scheduled-tasks", 5, 10); for (const auto& msg : response.messages) { auto task = ScheduledTask::fromJson(msg.body); std::cout << "Executing task " << task.taskId << ": " << task.action << std::endl; executeTask(task); if (task.recurring) scheduleTask(client, task, task.intervalSec); msg.ack(); } std::this_thread::sleep_for(std::chrono::seconds(1)); } ``` ```rust title="task_worker.rs" use std::time::Duration; // The unary receive auto-acknowledges due tasks as they are pulled. loop { let messages = client .receive_queue_messages("scheduled-tasks", 5, 10, false) .await?; for m in &messages { let task: ScheduledTask = serde_json::from_slice(&m.body).unwrap(); println!("Executing task {}: {}", task.task_id, task.action); execute_task(&task); if task.recurring { schedule_task(&client, &task, task.interval_sec).await?; println!("Task {} rescheduled in {}s", task.task_id, task.interval_sec); } } tokio::time::sleep(Duration::from_secs(1)).await; } ``` ```ruby title="task_worker.rb" require 'json' # receive_queue_messages auto-acknowledges due tasks as they are pulled. loop do messages = client.receive_queue_messages( channel: 'scheduled-tasks', max_messages: 5, wait_timeout_seconds: 10 ) messages.each do |m| task = JSON.parse(m.body) puts "Executing task #{task['taskId']}: #{task['action']}" execute_task(task) if task['recurring'] schedule_task(client, task, task['intervalSec']) puts "Task #{task['taskId']} rescheduled in #{task['intervalSec']}s" end end sleep 1 end ``` ```elixir title="task_worker.exs" # receive_queue_messages auto-acknowledges due tasks as they are pulled. defp work_loop(client) do case KubeMQ.Client.receive_queue_messages(client, "scheduled-tasks", max_messages: 5, wait_timeout: 10_000 ) do {:ok, result} -> Enum.each(result.messages, fn m -> task = Jason.decode!(m.body) IO.puts("Executing task #{task["taskId"]}: #{task["action"]}") execute_task(task) if task["recurring"] do schedule_task(client, task, task["intervalSec"]) IO.puts("Task #{task["taskId"]} rescheduled in #{task["intervalSec"]}s") end end) {:error, err} -> IO.puts("Receive failed: #{err.message}") end Process.sleep(1_000) work_loop(client) end ``` ## Advanced Configuration [#advanced-configuration] The server setting `MaxDelaySeconds` (default: 43,200 = 12 hours) limits the maximum delay. For longer intervals, re-enqueue with the max delay and decrement a remaining counter. Combine delay with expiration for tasks that should only run within a window. If the delay is 1 hour and expiration is 2 hours, the task must be processed within 1 hour of becoming available. ## Next Steps [#next-steps] # Reliable Webhook Delivery (/learn/queues/scenarios/webhook-delivery) ## Architecture [#architecture] An application publishes webhook events to a queue. Delivery workers attempt to POST the payload to the target URL. Failed deliveries are retried with exponential backoff using delayed requeue. Permanently failed webhooks land in a Dead Letter Queue (DLQ) for manual investigation. *The worker pulls each webhook, POSTs to the endpoint, acks on success, requeues with an exponential delay on transient failure, and routes to the DLQ once retries are exhausted.* ## Webhook Publisher [#webhook-publisher] When an event occurs, enqueue a webhook delivery request with retry policy. ```go title="webhook_publisher.go" type WebhookEvent struct { EventID string `json:"eventId"` EventType string `json:"eventType"` URL string `json:"url"` Payload string `json:"payload"` Attempt int `json:"attempt"` } func publishWebhook(ctx context.Context, client *kubemq.Client, event WebhookEvent) error { body, _ := json.Marshal(event) msg := kubemq.NewQueueMessage(). SetChannel("webhooks"). SetBody(body). SetMetadata(event.EventType). SetTags(map[string]string{ "eventId": event.EventID, "eventType": event.EventType, "url": event.URL, }). SetMaxReceiveCount(5). SetMaxReceiveQueue("webhooks.dlq"). SetExpirationSeconds(3600) _, err := client.SendQueueMessage(ctx, msg) return err } ``` ```python title="webhook_publisher.py" import json def publish_webhook(client, event): result = client.send_queue_message( QueueMessage( channel="webhooks", body=json.dumps(event).encode(), metadata=event["eventType"], tags={ "eventId": event["eventId"], "eventType": event["eventType"], "url": event["url"], }, max_receive_count=5, max_receive_queue="webhooks.dlq", expiration_in_seconds=3600, ) ) print(f"Webhook {event['eventId']} enqueued: id={result.id}") ``` ```typescript title="webhook_publisher.ts" async function publishWebhook(client: KubeMQClient, event: WebhookEvent) { const result = await client.sendQueueMessage( createQueueMessage({ channel: 'webhooks', body: JSON.stringify(event), metadata: event.eventType, tags: { eventId: event.eventId, eventType: event.eventType, url: event.url }, policy: { maxReceiveCount: 5, maxReceiveQueue: 'webhooks.dlq', expirationSeconds: 3600, }, }), ); console.log(`Webhook ${event.eventId} enqueued: id=${result.messageId}`); } ``` ```java title="WebhookPublisher.java" public void publishWebhook(QueuesClient client, WebhookEvent event) throws Exception { QueueMessage msg = QueueMessage.builder() .channel("webhooks") .body(objectMapper.writeValueAsBytes(event)) .metadata(event.getEventType()) .tags(Map.of( "eventId", event.getEventId(), "eventType", event.getEventType(), "url", event.getUrl())) .maxReceiveCount(5) .maxReceiveQueue("webhooks.dlq") .expirationSeconds(3600) .build(); client.sendQueueMessage(msg); } ``` ```csharp title="WebhookPublisher.cs" async Task PublishWebhook(QueuesClient client, WebhookEvent evt) { await client.SendQueueMessageAsync(new QueueMessage { Channel = "webhooks", Body = JsonSerializer.SerializeToUtf8Bytes(evt), Metadata = evt.EventType, Tags = new Dictionary { ["eventId"] = evt.EventId, ["eventType"] = evt.EventType, ["url"] = evt.Url }, MaxReceiveCount = 5, MaxReceiveQueue = "webhooks.dlq", ExpirationSeconds = 3600 }); } ``` ```kotlin title="WebhookPublisher.kt" suspend fun publishWebhook(client: QueuesClient, event: WebhookEvent) { client.sendQueueMessage(QueueMessage( channel = "webhooks", body = Json.encodeToString(event).toByteArray(), metadata = event.eventType, tags = mapOf( "eventId" to event.eventId, "eventType" to event.eventType, "url" to event.url), maxReceiveCount = 5, maxReceiveQueue = "webhooks.dlq", expirationSeconds = 3600 )) } ``` ```cpp title="webhook_publisher.cpp" void publishWebhook(kubemq::QueuesClient& client, const WebhookEvent& event) { kubemq::QueueMessage msg; msg.channel = "webhooks"; msg.body = event.toJson(); msg.metadata = event.eventType; msg.tags = { {"eventId", event.eventId}, {"eventType", event.eventType}, {"url", event.url}}; msg.maxReceiveCount = 5; msg.maxReceiveQueue = "webhooks.dlq"; msg.expirationSeconds = 3600; client.sendQueueMessage(msg); } ``` ```rust title="webhook_publisher.rs" use kubemq::prelude::*; use kubemq::QueueMessageBuilder; use std::collections::HashMap; async fn publish_webhook( client: &KubemqClient, event: &WebhookEvent, ) -> kubemq::Result<()> { let mut tags = HashMap::new(); tags.insert("eventId".to_string(), event.event_id.clone()); tags.insert("eventType".to_string(), event.event_type.clone()); tags.insert("url".to_string(), event.url.clone()); let msg = QueueMessageBuilder::new() .channel("webhooks") .body(serde_json::to_vec(event).unwrap()) .metadata(&event.event_type) .tags(tags) .max_receive_count(5) .max_receive_queue("webhooks.dlq") .expiration_seconds(3600) .build(); let result = client.send_queue_message(msg).await?; println!("Webhook {} enqueued: id={}", event.event_id, result.message_id); Ok(()) } ``` ```ruby title="webhook_publisher.rb" require 'kubemq' require 'json' def publish_webhook(client, event) policy = KubeMQ::Queues::QueueMessagePolicy.new( max_receive_count: 5, max_receive_queue: 'webhooks.dlq', expiration_seconds: 3600 ) msg = KubeMQ::Queues::QueueMessage.new( channel: 'webhooks', metadata: event['eventType'], body: event.to_json, tags: { 'eventId' => event['eventId'], 'eventType' => event['eventType'], 'url' => event['url'] }, policy: policy ) result = client.send_queue_message(msg) puts "Webhook #{event['eventId']} enqueued: id=#{result.id}" end ``` ```elixir title="webhook_publisher.exs" def publish_webhook(client, event) do msg = KubeMQ.QueueMessage.new( channel: "webhooks", metadata: event.event_type, body: Jason.encode!(event), tags: %{ "eventId" => event.event_id, "eventType" => event.event_type, "url" => event.url }, policy: KubeMQ.QueuePolicy.new( max_receive_count: 5, max_receive_queue: "webhooks.dlq", expiration_seconds: 3600 ) ) {:ok, result} = KubeMQ.Client.send_queue_message(client, msg) IO.puts("Webhook #{event.event_id} enqueued: id=#{result.message_id}") end ``` ## Delivery Worker with Exponential Backoff [#delivery-worker-with-exponential-backoff] Attempt HTTP delivery. On failure, requeue with exponential delay. ```go title="delivery_worker.go" for { resp, err := client.PollQueue(ctx, &kubemq.PollRequest{ Channel: "webhooks", MaxItems: 10, WaitTimeoutSeconds: 10, VisibilitySeconds: 30, }) if err != nil { log.Printf("Poll error: %v", err) time.Sleep(5 * time.Second) continue } for _, m := range resp.Messages { var event WebhookEvent json.Unmarshal(m.Message.Body, &event) httpResp, err := http.Post(event.URL, "application/json", bytes.NewReader([]byte(event.Payload))) if err == nil && httpResp.StatusCode >= 200 && httpResp.StatusCode < 300 { log.Printf("Webhook %s delivered to %s", event.EventID, event.URL) continue } attempt := event.Attempt + 1 if attempt >= 5 { log.Printf("Webhook %s failed permanently, sending to DLQ", event.EventID) continue } delay := 1 << attempt // 2, 4, 8, 16 seconds event.Attempt = attempt retryBody, _ := json.Marshal(event) client.SendQueueMessage(ctx, kubemq.NewQueueMessage(). SetChannel("webhooks"). SetBody(retryBody). SetDelaySeconds(delay). SetMaxReceiveCount(5). SetMaxReceiveQueue("webhooks.dlq")) log.Printf("Webhook %s retry %d in %ds", event.EventID, attempt, delay) } resp.AckAll() } ``` ```python title="delivery_worker.py" import json import requests import time while True: response = client.receive_queue_messages( channel="webhooks", max_messages=10, wait_timeout_in_seconds=10, visibility_seconds=30, ) for msg in response.messages: event = json.loads(msg.body.decode("utf-8")) try: resp = requests.post(event["url"], json=json.loads(event["payload"]), timeout=10) resp.raise_for_status() print(f"Webhook {event['eventId']} delivered to {event['url']}") msg.ack() except Exception as e: attempt = event.get("attempt", 0) + 1 if attempt >= 5: print(f"Webhook {event['eventId']} failed permanently") msg.requeue("webhooks.dlq") continue delay = 2 ** attempt event["attempt"] = attempt client.send_queue_message(QueueMessage( channel="webhooks", body=json.dumps(event).encode(), delay_in_seconds=delay, max_receive_count=5, max_receive_queue="webhooks.dlq", )) msg.ack() print(f"Webhook {event['eventId']} retry {attempt} in {delay}s") time.sleep(1) ``` ```typescript title="delivery_worker.ts" while (true) { const messages = await client.receiveQueueMessages({ channel: 'webhooks', maxMessages: 10, waitTimeoutSeconds: 10, visibilitySeconds: 30, }); for (const msg of messages) { const event = JSON.parse(new TextDecoder().decode(msg.body)); try { const resp = await fetch(event.url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: event.payload, }); if (resp.ok) { console.log(`Webhook ${event.eventId} delivered`); await msg.ack(); continue; } throw new Error(`HTTP ${resp.status}`); } catch (err) { const attempt = (event.attempt || 0) + 1; if (attempt >= 5) { console.log(`Webhook ${event.eventId} failed permanently`); await msg.requeue('webhooks.dlq'); continue; } const delay = Math.pow(2, attempt); event.attempt = attempt; await client.sendQueueMessage( createQueueMessage({ channel: 'webhooks', body: JSON.stringify(event), policy: { delaySeconds: delay, maxReceiveCount: 5, maxReceiveQueue: 'webhooks.dlq' }, }), ); await msg.ack(); console.log(`Webhook ${event.eventId} retry ${attempt} in ${delay}s`); } } await new Promise((r) => setTimeout(r, 1000)); } ``` ```java title="DeliveryWorker.java" while (true) { ReceiveQueueMessagesResponse response = client.receiveQueueMessages( ReceiveQueueMessagesRequest.builder() .channel("webhooks").maxMessages(10) .waitTimeoutSeconds(10).visibilitySeconds(30).build()); for (QueueMessageReceived msg : response.getMessages()) { WebhookEvent event = objectMapper.readValue(msg.getBody(), WebhookEvent.class); try { HttpResponse resp = httpClient.send( HttpRequest.newBuilder().uri(URI.create(event.getUrl())) .POST(HttpRequest.BodyPublishers.ofString(event.getPayload())).build(), HttpResponse.BodyHandlers.ofString()); if (resp.statusCode() >= 200 && resp.statusCode() < 300) { msg.ack(); continue; } throw new Exception("HTTP " + resp.statusCode()); } catch (Exception e) { int attempt = event.getAttempt() + 1; if (attempt >= 5) { msg.requeue("webhooks.dlq"); continue; } int delay = (int) Math.pow(2, attempt); event.setAttempt(attempt); client.sendQueueMessage(QueueMessage.builder() .channel("webhooks").body(objectMapper.writeValueAsBytes(event)) .delaySeconds(delay).build()); msg.ack(); } } Thread.sleep(1000); } ``` ```csharp title="DeliveryWorker.cs" while (true) { var response = await client.ReceiveQueueMessagesAsync(new ReceiveQueueMessagesRequest { Channel = "webhooks", MaxMessages = 10, WaitTimeoutSeconds = 10, VisibilitySeconds = 30, }); foreach (var msg in response.Messages) { var evt = JsonSerializer.Deserialize(msg.Body.Span); try { var resp = await httpClient.PostAsync(evt.Url, new StringContent(evt.Payload, Encoding.UTF8, "application/json")); resp.EnsureSuccessStatusCode(); await msg.AckAsync(); } catch { var attempt = evt.Attempt + 1; if (attempt >= 5) { await msg.ReQueueAsync("webhooks.dlq"); continue; } evt.Attempt = attempt; var delay = (int)Math.Pow(2, attempt); await client.SendQueueMessageAsync(new QueueMessage { Channel = "webhooks", Body = JsonSerializer.SerializeToUtf8Bytes(evt), DelaySeconds = delay }); await msg.AckAsync(); } } await Task.Delay(1000); } ``` ```kotlin title="DeliveryWorker.kt" while (true) { val response = client.receiveQueueMessages( channel = "webhooks", maxMessages = 10, waitTimeoutSeconds = 10, visibilitySeconds = 30) for (msg in response.messages) { val event = Json.decodeFromString(String(msg.body)) try { val resp = httpClient.post(event.url) { setBody(event.payload) } if (resp.status.value in 200..299) { msg.ack(); continue } throw Exception("HTTP ${resp.status}") } catch (e: Exception) { val attempt = event.attempt + 1 if (attempt >= 5) { msg.requeue("webhooks.dlq"); continue } val delay = 2.0.pow(attempt.toDouble()).toInt() val retryEvent = event.copy(attempt = attempt) client.sendQueueMessage(QueueMessage( channel = "webhooks", body = Json.encodeToString(retryEvent).toByteArray(), delaySeconds = delay)) msg.ack() } } delay(1000) } ``` ```cpp title="delivery_worker.cpp" while (true) { auto response = client.receiveQueueMessages("webhooks", 10, 10, false, 30); for (const auto& msg : response.messages) { auto event = WebhookEvent::fromJson(msg.body); auto httpResp = httpPost(event.url, event.payload); if (httpResp.statusCode >= 200 && httpResp.statusCode < 300) { msg.ack(); continue; } int attempt = event.attempt + 1; if (attempt >= 5) { msg.requeue("webhooks.dlq"); continue; } int delaySec = static_cast(std::pow(2, attempt)); event.attempt = attempt; kubemq::QueueMessage retryMsg; retryMsg.channel = "webhooks"; retryMsg.body = event.toJson(); retryMsg.delaySeconds = delaySec; client.sendQueueMessage(retryMsg); msg.ack(); } std::this_thread::sleep_for(std::chrono::seconds(1)); } ``` ```rust title="delivery_worker.rs" use kubemq::prelude::*; use kubemq::{PollRequest, QueueMessageBuilder}; let mut receiver = client.new_queue_downstream_receiver().await?; loop { let poll = PollRequest { channel: "webhooks".to_string(), max_items: 10, wait_timeout_seconds: 10, auto_ack: false, }; let response = receiver.poll(poll).await?; for m in &response.messages { let event: WebhookEvent = serde_json::from_slice(&m.message.body).unwrap(); let resp = reqwest::Client::new() .post(&event.url) .body(event.payload.clone()) .send() .await; if let Ok(r) = resp { if r.status().is_success() { m.ack().await?; println!("Webhook {} delivered to {}", event.event_id, event.url); continue; } } let attempt = event.attempt + 1; if attempt >= 5 { // Route to the dead-letter queue after exhausting retries. m.re_queue("webhooks.dlq").await?; println!("Webhook {} failed permanently, sent to DLQ", event.event_id); continue; } // Re-send with an exponential delay, then ack the original. let delay = 1 << attempt; // 2, 4, 8, 16 seconds let mut retry = event.clone(); retry.attempt = attempt; let retry_msg = QueueMessageBuilder::new() .channel("webhooks") .body(serde_json::to_vec(&retry).unwrap()) .delay_seconds(delay) .max_receive_count(5) .max_receive_queue("webhooks.dlq") .build(); client.send_queue_message(retry_msg).await?; m.ack().await?; println!("Webhook {} retry {} in {}s", event.event_id, attempt, delay); } } ``` ```ruby title="delivery_worker.rb" require 'kubemq' require 'net/http' require 'json' receiver = client.create_downstream_receiver loop do request = KubeMQ::Queues::QueuePollRequest.new( channel: 'webhooks', max_items: 10, wait_timeout: 10 ) response = receiver.poll(request) next if response.error? response.messages.each do |m| event = JSON.parse(m.body) begin uri = URI(event['url']) http_resp = Net::HTTP.post(uri, event['payload'], 'Content-Type' => 'application/json') raise "HTTP #{http_resp.code}" unless http_resp.is_a?(Net::HTTPSuccess) m.ack puts "Webhook #{event['eventId']} delivered to #{event['url']}" rescue StandardError attempt = (event['attempt'] || 0) + 1 if attempt >= 5 # Route to the dead-letter queue after exhausting retries: # re-send the event to the DLQ channel, then ack the original. client.send_queue_message(KubeMQ::Queues::QueueMessage.new( channel: 'webhooks.dlq', body: event.to_json )) m.ack puts "Webhook #{event['eventId']} failed permanently, sent to DLQ" next end # Re-send with an exponential delay, then ack the original. delay = 2**attempt event['attempt'] = attempt retry_policy = KubeMQ::Queues::QueueMessagePolicy.new( delay_seconds: delay, max_receive_count: 5, max_receive_queue: 'webhooks.dlq' ) client.send_queue_message(KubeMQ::Queues::QueueMessage.new( channel: 'webhooks', body: event.to_json, policy: retry_policy )) m.ack puts "Webhook #{event['eventId']} retry #{attempt} in #{delay}s" end end end ``` ```elixir title="delivery_worker.exs" defp worker_loop(client) do case KubeMQ.Client.poll_queue(client, channel: "webhooks", max_items: 10, wait_timeout: 10_000 ) do {:ok, poll} -> Enum.each(poll.messages, fn m -> event = Jason.decode!(m.body) attempt = Map.get(event, "attempt", 0) + 1 seq = m.attributes.sequence case deliver(event["url"], event["payload"]) do :ok -> KubeMQ.PollResponse.ack_range(poll, [seq]) IO.puts("Webhook #{event["eventId"]} delivered to #{event["url"]}") :error when attempt >= 5 -> # Route to the dead-letter queue after exhausting retries. KubeMQ.PollResponse.requeue_range(poll, [seq], "webhooks.dlq") IO.puts("Webhook #{event["eventId"]} failed permanently, sent to DLQ") :error -> # Re-send with an exponential delay, then ack the original. delay = trunc(:math.pow(2, attempt)) retry = Map.put(event, "attempt", attempt) KubeMQ.Client.send_queue_message(client, KubeMQ.QueueMessage.new( channel: "webhooks", body: Jason.encode!(retry), policy: KubeMQ.QueuePolicy.new( delay_seconds: delay, max_receive_count: 5, max_receive_queue: "webhooks.dlq" ) ) ) KubeMQ.PollResponse.ack_range(poll, [seq]) IO.puts("Webhook #{event["eventId"]} retry #{attempt} in #{delay}s") end end) {:error, err} -> IO.puts("Poll error: #{err.message}") end worker_loop(client) end ``` ## Advanced Configuration [#advanced-configuration] Set a short HTTP timeout (10-30s) per delivery attempt. Long-running endpoints should not block the worker from processing other webhooks. Use `eventId` as an idempotency key on the receiving end. The same webhook may be delivered more than once if the ack fails after successful delivery. Include an HMAC signature in the webhook payload so recipients can verify the sender. Store the signing secret in message tags or metadata. ## Next Steps [#next-steps] # Extend Visibility Timeout (/learn/queues/how-to/extend-visibility) ## When to Extend Visibility [#when-to-extend-visibility] Some tasks take longer than expected. Instead of setting an excessively long initial visibility timeout, you can extend the timeout mid-processing to prevent the message from being redelivered. *Extending the timeout before it lapses keeps a slow task hidden from other consumers until the consumer acknowledges.* ## Steps [#steps] ### Receive with Initial Visibility [#receive-with-initial-visibility] ```go title="extend_visibility.go" resp, err := client.PollQueue(ctx, &kubemq.PollRequest{ Channel: "orders", MaxItems: 1, WaitTimeoutSeconds: 5, VisibilitySeconds: 60, }) if err != nil { log.Fatal(err) } ``` ```python title="extend_visibility.py" response = client.receive_queue_messages( channel="orders", max_messages=1, wait_timeout_in_seconds=5, visibility_seconds=60, ) ``` ```typescript title="extend_visibility.ts" const messages = await client.receiveQueueMessages({ channel: 'orders', maxMessages: 1, waitTimeoutSeconds: 5, visibilitySeconds: 60, }); ``` ```java title="ExtendVisibility.java" ReceiveQueueMessagesResponse response = client.receiveQueueMessages( ReceiveQueueMessagesRequest.builder() .channel("orders") .maxMessages(1) .waitTimeoutSeconds(5) .visibilitySeconds(60) .build()); ``` ```csharp title="ExtendVisibility.cs" var response = await client.ReceiveQueueMessagesAsync(new ReceiveQueueMessagesRequest { Channel = "orders", MaxMessages = 1, WaitTimeoutSeconds = 5, VisibilitySeconds = 60, }); ``` ```kotlin title="ExtendVisibility.kt" val response = client.receiveQueueMessages( channel = "orders", maxMessages = 1, waitTimeoutSeconds = 5, visibilitySeconds = 60 ) ``` ```cpp title="extend_visibility.cpp" auto response = client.receiveQueueMessages("orders", 1, 5, false, 60); ``` ```rust title="extend_visibility.rs" // Poll the queue with explicit ack control (auto_ack = false). let (response, mut receiver) = client .poll_queue(PollRequest { channel: "orders".to_string(), max_items: 1, wait_timeout_seconds: 5, auto_ack: false, }) .await?; ``` ```ruby title="extend_visibility.rb" receiver = client.create_downstream_receiver response = receiver.poll( KubeMQ::Queues::QueuePollRequest.new( channel: 'orders', max_items: 1, wait_timeout: 5, auto_ack: false ) ) ``` ```elixir title="extend_visibility.exs" {:ok, poll} = KubeMQ.Client.poll_queue(client, channel: "orders", max_items: 1, wait_timeout: 5_000, auto_ack: false ) ``` ### Process and Extend When Needed [#process-and-extend-when-needed] Before the visibility timeout expires, extend it to get more processing time. ```go for _, m := range resp.Messages { fmt.Printf("Processing: %s\n", string(m.Message.Body)) // Phase 1: Quick validation time.Sleep(10 * time.Second) // Need more time — extend by 60 more seconds if err := m.ExtendVisibility(60); err != nil { log.Printf("Failed to extend visibility: %v", err) } fmt.Println("Visibility extended by 60 seconds") // Phase 2: Heavy processing time.Sleep(40 * time.Second) } resp.AckAll() fmt.Println("Done — all messages acknowledged") ``` ```python for msg in response.messages: print(f"Processing: {msg.body.decode('utf-8')}") # Phase 1: Quick validation time.sleep(10) # Need more time — extend by 60 more seconds msg.extend_visibility(60) print("Visibility extended by 60 seconds") # Phase 2: Heavy processing time.sleep(40) msg.ack() print("Done — all messages acknowledged") ``` ```typescript for (const msg of messages) { console.log('Processing:', new TextDecoder().decode(msg.body)); // Phase 1: Quick validation await new Promise((r) => setTimeout(r, 10000)); // Need more time — extend by 60 more seconds await msg.extendVisibility(60); console.log('Visibility extended by 60 seconds'); // Phase 2: Heavy processing await new Promise((r) => setTimeout(r, 40000)); await msg.ack(); } console.log('Done — all messages acknowledged'); ``` ```java for (QueueMessageReceived msg : response.getMessages()) { System.out.println("Processing: " + new String(msg.getBody())); Thread.sleep(10_000); msg.extendVisibility(60); System.out.println("Visibility extended by 60 seconds"); Thread.sleep(40_000); msg.ack(); } System.out.println("Done — all messages acknowledged"); ``` ```csharp foreach (var msg in response.Messages) { Console.WriteLine($"Processing: {Encoding.UTF8.GetString(msg.Body.Span)}"); await Task.Delay(10_000); await msg.ExtendVisibilityAsync(60); Console.WriteLine("Visibility extended by 60 seconds"); await Task.Delay(40_000); await msg.AckAsync(); } Console.WriteLine("Done — all messages acknowledged"); ``` ```kotlin for (msg in response.messages) { println("Processing: ${String(msg.body)}") Thread.sleep(10_000) msg.extendVisibility(60) println("Visibility extended by 60 seconds") Thread.sleep(40_000) msg.ack() } println("Done — all messages acknowledged") ``` ```cpp for (const auto& msg : response.messages) { std::cout << "Processing: " << msg.body << std::endl; std::this_thread::sleep_for(std::chrono::seconds(10)); msg.extendVisibility(60); std::cout << "Visibility extended by 60 seconds" << std::endl; std::this_thread::sleep_for(std::chrono::seconds(40)); msg.ack(); } std::cout << "Done — all messages acknowledged" << std::endl; ``` ```rust // Extending an in-flight message's visibility is not yet available in the Rust SDK — // the downstream message exposes ack(), nack(), and re_queue() only. // Set a generous initial visibility on the poll, then ack once processing completes. for msg in &response.messages { println!("Processing: {}", String::from_utf8_lossy(&msg.message.body)); // Phase 1: Quick validation tokio::time::sleep(std::time::Duration::from_secs(10)).await; // Phase 2: Heavy processing tokio::time::sleep(std::time::Duration::from_secs(40)).await; msg.ack().await?; } println!("Done — all messages acknowledged"); receiver.close().await?; ``` ```ruby # Extending an in-flight message's visibility is not yet available in the Ruby SDK — # each polled message exposes ack and nack, while requeue is settled at the # response level via response.requeue_all(channel:). # Set a generous initial visibility on the poll, then ack once processing completes. response.messages.each do |msg| puts "Processing: #{msg.body}" # Phase 1: Quick validation sleep(10) # Phase 2: Heavy processing sleep(40) msg.ack end puts 'Done — all messages acknowledged' receiver.close ``` ```elixir # Extending an in-flight message's visibility is not yet available in the Elixir SDK — # PollResponse exposes ack_all/1, nack_all/1, and requeue_all/2 only. # Set a generous initial visibility on the poll, then ack once processing completes. Enum.each(poll.messages, fn msg -> IO.puts("Processing: #{msg.body}") # Phase 1: Quick validation Process.sleep(10_000) # Phase 2: Heavy processing Process.sleep(40_000) end) {:ok, _} = KubeMQ.PollResponse.ack_all(poll) IO.puts("Done — all messages acknowledged") ``` Always extend visibility **before** the current timeout expires. Once the timeout lapses, the message may be delivered to another consumer, leading to duplicate processing. ## Next Steps [#next-steps] # Message Expiration (TTL) (/learn/queues/how-to/message-expiration) ## How Expiration Works [#how-expiration-works] Messages with `expirationSeconds > 0` are automatically discarded if not consumed before the TTL elapses. Expired messages are removed on the next receive operation. *An unconsumed message is silently discarded once its TTL elapses — the next poll never sees it.* ## Set Message Expiration [#set-message-expiration] ```go title="expiration.go" msg := kubemq.NewQueueMessage(). SetChannel("time-sensitive-orders"). SetBody([]byte(`{"orderId":"ORD-7001","type":"flash-sale"}`)). SetExpirationSeconds(300) result, err := client.SendQueueMessage(ctx, msg) if err != nil { log.Fatal(err) } fmt.Printf("Sent with 5-minute TTL: id=%s, expiresAt=%d\n", result.MessageID, result.ExpiresAt) ``` ```python title="expiration.py" result = client.send_queue_message( QueueMessage( channel="time-sensitive-orders", body=b'{"orderId":"ORD-7001","type":"flash-sale"}', expiration_in_seconds=300, ) ) print(f"Sent with 5-minute TTL: id={result.id}, expiresAt={result.expires_at}") ``` ```typescript title="expiration.ts" const result = await client.sendQueueMessage( createQueueMessage({ channel: 'time-sensitive-orders', body: JSON.stringify({ orderId: 'ORD-7001', type: 'flash-sale' }), policy: { expirationSeconds: 300 }, }), ); console.log(`Sent with 5-minute TTL: id=${result.messageId}, expiresAt=${result.expiresAt}`); ``` ```java title="Expiration.java" QueueMessage msg = QueueMessage.builder() .channel("time-sensitive-orders") .body("{\"orderId\":\"ORD-7001\",\"type\":\"flash-sale\"}".getBytes()) .expirationSeconds(300) .build(); SendQueueMessageResult result = client.sendQueueMessage(msg); System.out.printf("Sent with 5-minute TTL: id=%s, expiresAt=%d%n", result.getMessageId(), result.getExpiresAt()); ``` ```csharp title="Expiration.cs" var result = await client.SendQueueMessageAsync(new QueueMessage { Channel = "time-sensitive-orders", Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-7001\",\"type\":\"flash-sale\"}"), ExpirationSeconds = 300 }); Console.WriteLine($"Sent with 5-minute TTL: id={result.MessageId}, expiresAt={result.ExpiresAt}"); ``` ```kotlin title="Expiration.kt" val result = client.sendQueueMessage(QueueMessage( channel = "time-sensitive-orders", body = """{"orderId":"ORD-7001","type":"flash-sale"}""".toByteArray(), expirationSeconds = 300 )) println("Sent with 5-minute TTL: id=${result.messageId}, expiresAt=${result.expiresAt}") ``` ```cpp title="expiration.cpp" kubemq::QueueMessage msg; msg.channel = "time-sensitive-orders"; msg.body = R"({"orderId":"ORD-7001","type":"flash-sale"})"; msg.expirationSeconds = 300; auto result = client.sendQueueMessage(msg); std::cout << "Sent with 5-minute TTL: id=" << result.messageId << std::endl; ``` ```rust title="expiration.rs" let msg = QueueMessageBuilder::new() .channel("time-sensitive-orders") .body(br#"{"orderId":"ORD-7001","type":"flash-sale"}"#.to_vec()) .expiration_seconds(300) .build(); let result = client.send_queue_message(msg).await?; println!( "Sent with 5-minute TTL: id={}, expiration_at={}", result.message_id, result.expiration_at ); ``` ```ruby title="expiration.rb" policy = KubeMQ::Queues::QueueMessagePolicy.new(expiration_seconds: 300) msg = KubeMQ::Queues::QueueMessage.new( channel: 'time-sensitive-orders', body: '{"orderId":"ORD-7001","type":"flash-sale"}', policy: policy ) result = client.send_queue_message(msg) puts "Sent with 5-minute TTL: id=#{result.id}, expiration_at=#{result.expiration_at}" ``` ```elixir title="expiration.exs" msg = KubeMQ.QueueMessage.new( channel: "time-sensitive-orders", body: ~s({"orderId":"ORD-7001","type":"flash-sale"}), policy: KubeMQ.QueuePolicy.new(expiration_seconds: 300) ) {:ok, result} = KubeMQ.Client.send_queue_message(client, msg) IO.puts("Sent with 5-minute TTL: id=#{result.message_id}, expiration_at=#{result.expiration_at}") ``` ## What Happens to Expired Messages [#what-happens-to-expired-messages] | Behavior | Detail | | ----------------- | ------------------------------------------------------------------------------ | | Expiration check | Occurs during receive operations (not a background timer) | | Expired messages | Discarded silently — not delivered to consumers | | No DLQ routing | Expired messages are not sent to dead letter queues | | Delay interaction | If delay + expiration are both set, expiration starts **after** the delay ends | ## Delay + Expiration Interaction [#delay--expiration-interaction] | Delay | Expiration | Available At | Expires At | | ----- | ---------- | ------------ | ---------- | | 0 | 300s | Immediately | T+300s | | 60s | 300s | T+60s | T+360s | | 60s | 0 | T+60s | Never | The maximum expiration is controlled by the server setting `MaxExpirationSeconds` (default: 43,200 seconds / 12 hours). ## Next Steps [#next-steps] # Purge Queue (/learn/queues/how-to/purge-queue) **Purging is irreversible.** All messages in the queue are permanently deleted. Use with caution in production environments. ## How Purge Works [#how-purge-works] Purging a queue acknowledges every pending message at once, removing them from the channel. Consumers that poll afterward find the queue empty. *Purge acknowledges all pending messages in a single call, leaving the channel empty.* ## When to Purge [#when-to-purge] | Scenario | Description | | ---------------- | --------------------------------------------- | | **Development** | Clear test messages between iterations | | **Testing** | Reset queue state before test runs | | **Stuck queues** | Remove poison messages blocking consumers | | **Data cleanup** | Clear obsolete messages after a schema change | ## Purge All Messages [#purge-all-messages] Purging is implemented as an "acknowledge all" operation on the channel — every pending message is acked at once and removed. ```go title="purge.go" resp, err := client.AckAllQueueMessages(ctx, &kubemq.AckAllQueueMessagesRequest{ Channel: "orders", WaitTimeSeconds: 5, }) if err != nil { log.Fatal(err) } fmt.Printf("Purged %d messages from 'orders'\n", resp.AffectedMessages) ``` ```python title="purge.py" acked = client.ack_all_queue_messages("orders", wait_time_seconds=5) print(f"Purged {acked} messages from 'orders'") ``` ```typescript title="purge.ts" await client.purgeQueue('orders'); console.log("Queue 'orders' purged successfully"); ``` ```java title="Purge.java" // purgeQueue is not yet exposed by the Java v2 SDK. // Purge by acknowledging all messages on the channel instead. QueuesPollResponse response = client.receiveQueueMessages( QueuesPollRequest.builder() .channel("orders") .pollMaxMessages(1024) .pollWaitTimeoutInSeconds(2) .autoAckMessages(true) .build()); System.out.printf("Purged %d messages from 'orders'%n", response.getMessages().size()); ``` ```csharp title="Purge.cs" var result = await client.PurgeQueueAsync("orders"); Console.WriteLine($"Purged {result.AffectedMessages} messages from 'orders'"); ``` ```kotlin title="Purge.kt" val purged = client.purgeQueuesChannel("orders") println("Purged $purged messages from 'orders'") ``` ```cpp title="purge.cpp" kubemq::AckAllQueueMessagesRequest req; req.channel = "orders"; req.wait_time_seconds = 5; auto result = client->AckAllQueueMessages(req); if (result.ok() && !result->is_error) { std::cout << "Purged " << result->affected_messages << " messages from 'orders'" << std::endl; } ``` ```rust title="purge.rs" client.ack_all_queue_messages("orders").await?; println!("Queue 'orders' purged successfully"); ``` ```ruby title="purge.rb" client.purge_queue_channel(channel_name: 'orders') puts "Queue 'orders' purged successfully" ``` ```elixir title="purge.exs" :ok = KubeMQ.Client.purge_queue_channel(client, "orders") IO.puts("Queue 'orders' purged successfully") ``` ## Verify the Queue is Empty [#verify-the-queue-is-empty] After purging, poll the queue to confirm no messages remain. ```go title="verify_purge.go" resp, err := client.PollQueue(ctx, &kubemq.PollRequest{ Channel: "orders", MaxItems: 10, WaitTimeoutSeconds: 2, AutoAck: true, }) if err != nil { log.Fatal(err) } fmt.Printf("Messages remaining: %d\n", len(resp.Messages)) ``` ```python title="verify_purge.py" response = client.receive_queue_messages( channel="orders", max_messages=10, wait_timeout_in_seconds=2, auto_ack=True, ) print(f"Messages remaining: {len(response.messages)}") ``` ```typescript title="verify_purge.ts" const remaining = await client.peekQueueMessages({ channel: 'orders', maxMessages: 10, waitTimeoutSeconds: 2, }); console.log(`Messages remaining: ${remaining.length}`); ``` ```java title="VerifyPurge.java" QueuesPollResponse response = client.receiveQueueMessages( QueuesPollRequest.builder() .channel("orders") .pollMaxMessages(10) .pollWaitTimeoutInSeconds(2) .autoAckMessages(true) .build()); System.out.printf("Messages remaining: %d%n", response.getMessages().size()); ``` ```csharp title="VerifyPurge.cs" var response = await client.ReceiveQueueMessagesAsync(new QueuePollRequest { Channel = "orders", MaxMessages = 10, WaitTimeoutSeconds = 2, AutoAck = true, }); Console.WriteLine($"Messages remaining: {response.Messages.Count}"); ``` ```kotlin title="VerifyPurge.kt" val response = client.peekQueueMessages { channel = "orders" maxNumberOfMessages = 10 waitTimeSeconds = 2 } println("Messages remaining: ${response.messages.size}") ``` ```cpp title="verify_purge.cpp" kubemq::ReceiveQueueMessagesRequest req; req.channel = "orders"; req.max_number_of_messages = 10; req.wait_time_seconds = 2; req.is_peek = true; auto response = client->ReceiveQueueMessages(req); std::cout << "Messages remaining: " << response->messages.size() << std::endl; ``` ```rust title="verify_purge.rs" // receive_queue_messages(channel, max_messages, wait_seconds, is_peek) let remaining = client.receive_queue_messages("orders", 10, 2, true).await?; println!("Messages remaining: {}", remaining.len()); ``` ```ruby title="verify_purge.rb" remaining = client.receive_queue_messages( channel: 'orders', max_messages: 10, wait_timeout_seconds: 2, peek: true, ) puts "Messages remaining: #{remaining.size}" ``` ```elixir title="verify_purge.exs" {:ok, poll} = KubeMQ.Client.poll_queue(client, channel: "orders", max_items: 10, wait_timeout: 2_000 ) IO.puts("Messages remaining: #{length(poll.messages)}") ``` ## Next Steps [#next-steps] # Retry with Backoff (/learn/queues/how-to/retry-with-backoff) ## Overview [#overview] When message processing fails, you can implement retry strategies ranging from simple immediate retry to exponential backoff with DLQ fallback. KubeMQ's nack mechanism and DLQ policy provide the building blocks. The diagram below shows the lifecycle of a message that flows through a backoff retry strategy — each failure either schedules a delayed retry or, once the attempt budget is exhausted, routes the message to a Dead Letter Queue (DLQ). *Message lifecycle: each failed delivery schedules a delayed retry until the attempt budget is exhausted, then routes to the DLQ.* ## Simple Retry (Nack) [#simple-retry-nack] The simplest retry — nack the message so it becomes available again immediately. ```go title="simple_retry.go" resp, err := client.PollQueue(ctx, &kubemq.PollRequest{ Channel: "orders", MaxItems: 1, WaitTimeoutSeconds: 5, }) if err != nil { log.Fatal(err) } for _, m := range resp.Messages { err := processOrder(m.Message.Body) if err != nil { log.Printf("Processing failed (attempt %d): %v", m.Message.Attributes.ReceiveCount, err) resp.NAckAll() return } } resp.AckAll() ``` ```python title="simple_retry.py" response = client.receive_queue_messages( channel="orders", max_messages=1, wait_timeout_in_seconds=5, ) for msg in response.messages: try: process_order(msg.body) msg.ack() except Exception as e: print(f"Processing failed (attempt {msg.receive_count}): {e}") msg.nack() ``` ```typescript title="simple_retry.ts" const messages = await client.receiveQueueMessages({ channel: 'orders', maxMessages: 1, waitTimeoutSeconds: 5, }); for (const msg of messages) { try { await processOrder(msg.body); await msg.ack(); } catch (err) { console.log(`Processing failed (attempt ${msg.receiveCount}):`, err); await msg.nack(); } } ``` ```java title="SimpleRetry.java" ReceiveQueueMessagesResponse response = client.receiveQueueMessages( ReceiveQueueMessagesRequest.builder() .channel("orders").maxMessages(1).waitTimeoutSeconds(5).build()); for (QueueMessageReceived msg : response.getMessages()) { try { processOrder(msg.getBody()); msg.ack(); } catch (Exception e) { System.out.printf("Processing failed (attempt %d): %s%n", msg.getReceiveCount(), e); msg.nack(); } } ``` ```csharp title="SimpleRetry.cs" var response = await client.ReceiveQueueMessagesAsync(new ReceiveQueueMessagesRequest { Channel = "orders", MaxMessages = 1, WaitTimeoutSeconds = 5, }); foreach (var msg in response.Messages) { try { ProcessOrder(msg.Body); await msg.AckAsync(); } catch (Exception ex) { Console.WriteLine($"Processing failed (attempt {msg.ReceiveCount}): {ex.Message}"); await msg.NAckAsync(); } } ``` ```kotlin title="SimpleRetry.kt" val response = client.receiveQueueMessages( channel = "orders", maxMessages = 1, waitTimeoutSeconds = 5) for (msg in response.messages) { try { processOrder(msg.body) msg.ack() } catch (e: Exception) { println("Processing failed (attempt ${msg.receiveCount}): ${e.message}") msg.nack() } } ``` ```cpp title="simple_retry.cpp" auto response = client.receiveQueueMessages("orders", 1, 5); for (const auto& msg : response.messages) { try { processOrder(msg.body); msg.ack(); } catch (const std::exception& e) { std::cerr << "Processing failed (attempt " << msg.receiveCount << "): " << e.what() << std::endl; msg.nack(); } } ``` ```rust title="simple_retry.rs" let mut receiver = client.new_queue_downstream_receiver().await?; let response = receiver .poll(PollRequest { channel: "orders".to_string(), max_items: 1, wait_timeout_seconds: 5, auto_ack: false, }) .await?; for msg in &response.messages { let attempt = msg.message.attributes.as_ref().map_or(0, |a| a.receive_count); match process_order(&msg.message.body) { Ok(()) => msg.ack().await?, Err(e) => { eprintln!("Processing failed (attempt {}): {}", attempt, e); // nack returns the message to the queue for immediate redelivery msg.nack().await?; } } } ``` ```ruby title="simple_retry.rb" receiver = client.create_downstream_receiver request = KubeMQ::Queues::QueuePollRequest.new( channel: 'orders', max_items: 1, wait_timeout: 5) response = receiver.poll(request) response.messages.each do |msg| begin process_order(msg.body) msg.ack rescue StandardError => e attempt = msg.attributes&.receive_count puts "Processing failed (attempt #{attempt}): #{e.message}" # nack returns the message to the queue for immediate redelivery msg.nack end end ``` ```elixir title="simple_retry.exs" {:ok, poll} = KubeMQ.Client.poll_queue(client, channel: "orders", max_items: 1, wait_timeout: 5_000 ) Enum.each(poll.messages, fn msg -> attempt = if msg.attributes, do: msg.attributes.receive_count, else: 0 case process_order(msg.body) do :ok -> KubeMQ.PollResponse.ack_all(poll) {:error, reason} -> IO.puts("Processing failed (attempt #{attempt}): #{inspect(reason)}") # nack returns the message to the queue for immediate redelivery KubeMQ.PollResponse.nack_all(poll) end end) ``` ## Exponential Backoff with DLQ Fallback [#exponential-backoff-with-dlq-fallback] Combine nack-based retry with a delay requeue for exponential backoff, and a DLQ for final failure. ```go title="backoff_retry.go" resp, err := client.PollQueue(ctx, &kubemq.PollRequest{ Channel: "orders", MaxItems: 1, WaitTimeoutSeconds: 5, }) if err != nil { log.Fatal(err) } for _, m := range resp.Messages { err := processOrder(m.Message.Body) if err == nil { resp.AckAll() return } attempt := int(m.Message.Attributes.ReceiveCount) if attempt >= 5 { log.Printf("Max retries exceeded, sending to DLQ") resp.ReQueueAll("orders.dlq") return } delay := 1 << attempt // 2, 4, 8, 16 seconds log.Printf("Retry %d in %ds", attempt, delay) retryMsg := kubemq.NewQueueMessage(). SetChannel("orders"). SetBody(m.Message.Body). SetMetadata(m.Message.Metadata). SetTags(m.Message.Tags). SetDelaySeconds(delay) client.SendQueueMessage(ctx, retryMsg) resp.AckAll() } ``` ```python title="backoff_retry.py" response = client.receive_queue_messages( channel="orders", max_messages=1, wait_timeout_in_seconds=5) for msg in response.messages: try: process_order(msg.body) msg.ack() except Exception as e: attempt = msg.receive_count if attempt >= 5: print("Max retries exceeded, sending to DLQ") msg.requeue("orders.dlq") continue delay = 2 ** attempt print(f"Retry {attempt} in {delay}s") client.send_queue_message(QueueMessage( channel="orders", body=msg.body, metadata=msg.metadata, delay_in_seconds=delay, )) msg.ack() ``` ```typescript title="backoff_retry.ts" const messages = await client.receiveQueueMessages({ channel: 'orders', maxMessages: 1, waitTimeoutSeconds: 5, }); for (const msg of messages) { try { await processOrder(msg.body); await msg.ack(); } catch (err) { const attempt = msg.receiveCount; if (attempt >= 5) { console.log('Max retries exceeded, sending to DLQ'); await msg.requeue('orders.dlq'); continue; } const delay = Math.pow(2, attempt); console.log(`Retry ${attempt} in ${delay}s`); await client.sendQueueMessage( createQueueMessage({ channel: 'orders', body: msg.body, metadata: msg.metadata, policy: { delaySeconds: delay }, }), ); await msg.ack(); } } ``` ```java title="BackoffRetry.java" ReceiveQueueMessagesResponse response = client.receiveQueueMessages( ReceiveQueueMessagesRequest.builder() .channel("orders").maxMessages(1).waitTimeoutSeconds(5).build()); for (QueueMessageReceived msg : response.getMessages()) { try { processOrder(msg.getBody()); msg.ack(); } catch (Exception e) { int attempt = msg.getReceiveCount(); if (attempt >= 5) { System.out.println("Max retries exceeded, sending to DLQ"); msg.requeue("orders.dlq"); continue; } int delay = (int) Math.pow(2, attempt); System.out.printf("Retry %d in %ds%n", attempt, delay); client.sendQueueMessage(QueueMessage.builder() .channel("orders").body(msg.getBody()) .delaySeconds(delay).build()); msg.ack(); } } ``` ```csharp title="BackoffRetry.cs" var response = await client.ReceiveQueueMessagesAsync(new ReceiveQueueMessagesRequest { Channel = "orders", MaxMessages = 1, WaitTimeoutSeconds = 5, }); foreach (var msg in response.Messages) { try { ProcessOrder(msg.Body); await msg.AckAsync(); } catch (Exception) { var attempt = msg.ReceiveCount; if (attempt >= 5) { Console.WriteLine("Max retries exceeded, sending to DLQ"); await msg.ReQueueAsync("orders.dlq"); continue; } var delay = (int)Math.Pow(2, attempt); Console.WriteLine($"Retry {attempt} in {delay}s"); await client.SendQueueMessageAsync(new QueueMessage { Channel = "orders", Body = msg.Body.ToArray(), DelaySeconds = delay }); await msg.AckAsync(); } } ``` ```kotlin title="BackoffRetry.kt" val response = client.receiveQueueMessages( channel = "orders", maxMessages = 1, waitTimeoutSeconds = 5) for (msg in response.messages) { try { processOrder(msg.body) msg.ack() } catch (e: Exception) { val attempt = msg.receiveCount if (attempt >= 5) { println("Max retries exceeded, sending to DLQ") msg.requeue("orders.dlq") continue } val delay = 2.0.pow(attempt.toDouble()).toInt() println("Retry $attempt in ${delay}s") client.sendQueueMessage(QueueMessage( channel = "orders", body = msg.body, delaySeconds = delay)) msg.ack() } } ``` ```cpp title="backoff_retry.cpp" auto response = client.receiveQueueMessages("orders", 1, 5); for (const auto& msg : response.messages) { try { processOrder(msg.body); msg.ack(); } catch (const std::exception& e) { int attempt = msg.receiveCount; if (attempt >= 5) { std::cout << "Max retries exceeded, sending to DLQ" << std::endl; msg.requeue("orders.dlq"); continue; } int delay = static_cast(std::pow(2, attempt)); std::cout << "Retry " << attempt << " in " << delay << "s" << std::endl; kubemq::QueueMessage retryMsg; retryMsg.channel = "orders"; retryMsg.body = msg.body; retryMsg.delaySeconds = delay; client.sendQueueMessage(retryMsg); msg.ack(); } } ``` ```rust title="backoff_retry.rs" let mut receiver = client.new_queue_downstream_receiver().await?; let response = receiver .poll(PollRequest { channel: "orders".to_string(), max_items: 1, wait_timeout_seconds: 5, auto_ack: false, }) .await?; for msg in &response.messages { if process_order(&msg.message.body).is_ok() { msg.ack().await?; continue; } let attempt = msg.message.attributes.as_ref().map_or(0, |a| a.receive_count); if attempt >= 5 { println!("Max retries exceeded, sending to DLQ"); msg.re_queue("orders.dlq").await?; continue; } let delay = 1 << attempt; // 2, 4, 8, 16 seconds println!("Retry {} in {}s", attempt, delay); let retry_msg = QueueMessageBuilder::new() .channel("orders") .body(msg.message.body.clone()) .metadata(&msg.message.metadata) .delay_seconds(delay) .build(); client.send_queue_message(retry_msg).await?; msg.ack().await?; } ``` ```ruby title="backoff_retry.rb" receiver = client.create_downstream_receiver request = KubeMQ::Queues::QueuePollRequest.new( channel: 'orders', max_items: 1, wait_timeout: 5) response = receiver.poll(request) response.messages.each do |msg| begin process_order(msg.body) msg.ack rescue StandardError attempt = msg.attributes&.receive_count || 0 if attempt >= 5 puts 'Max retries exceeded, sending to DLQ' # Re-send the message to the DLQ channel, then ack the original. client.send_queue_message(KubeMQ::Queues::QueueMessage.new( channel: 'orders.dlq', metadata: msg.metadata, body: msg.body)) msg.ack next end delay = 2**attempt puts "Retry #{attempt} in #{delay}s" policy = KubeMQ::Queues::QueueMessagePolicy.new(delay_seconds: delay) client.send_queue_message(KubeMQ::Queues::QueueMessage.new( channel: 'orders', metadata: msg.metadata, body: msg.body, policy: policy)) msg.ack end end ``` ```elixir title="backoff_retry.exs" {:ok, poll} = KubeMQ.Client.poll_queue(client, channel: "orders", max_items: 1, wait_timeout: 5_000 ) Enum.each(poll.messages, fn msg -> case process_order(msg.body) do :ok -> KubeMQ.PollResponse.ack_all(poll) {:error, _reason} -> attempt = if msg.attributes, do: msg.attributes.receive_count, else: 0 if attempt >= 5 do IO.puts("Max retries exceeded, sending to DLQ") KubeMQ.PollResponse.requeue_all(poll, "orders.dlq") else delay = Bitwise.bsl(1, attempt) # 2, 4, 8, 16 seconds IO.puts("Retry #{attempt} in #{delay}s") retry_msg = KubeMQ.QueueMessage.new( channel: "orders", metadata: msg.metadata, body: msg.body, policy: KubeMQ.QueuePolicy.new(delay_seconds: delay) ) KubeMQ.Client.send_queue_message(client, retry_msg) KubeMQ.PollResponse.ack_all(poll) end end end) ``` ## Retry Strategy Comparison [#retry-strategy-comparison] | Strategy | Latency | Load | Complexity | Best For | | ------------------- | ----------------------- | ----------------- | ---------- | ---------------------- | | Immediate nack | Instant retry | High (tight loop) | Low | Transient errors | | Fixed delay requeue | Constant wait | Moderate | Medium | Rate-limited APIs | | Exponential backoff | Increasing wait | Low | Medium | External service calls | | Backoff + DLQ | Increasing + final stop | Lowest | Higher | Production workloads | ## Next Steps [#next-steps] # Configure Visibility Timeout (/learn/queues/how-to/visibility-timeout) ## How Visibility Timeout Works [#how-visibility-timeout-works] When a consumer receives a message, it becomes hidden from other consumers for a configurable duration. If the consumer does not acknowledge within the timeout, the message becomes available again for redelivery. *A message stays hidden from other consumers until the owner acks or the visibility timeout expires.* A single message moves through three states while it is being processed. It is **Hidden** from other consumers from the moment it is delivered. An `ack` settles it as **Acked** (removed from the queue). If the timeout expires first, it returns to **Visible** and the next poll redelivers it. *Message lifecycle under a visibility timeout: a poll hides the message, an ack settles it, and an expiry returns it for redelivery.* ## Set Visibility Timeout Per Request [#set-visibility-timeout-per-request] A per-request visibility override is exposed by the **Java** SDK via `QueuesPollRequest.visibilitySeconds`. It overrides the server default (`DefaultVisibilitySeconds`, 60 seconds) for that poll. The other SDKs do not expose a per-request override; messages stay hidden for the server-side default until you `ack`, so acknowledge before that window elapses (or use a language that surfaces the field). ```go title="visibility.go" // A per-request visibility override is not exposed in the Go SDK — the server // default (DefaultVisibilitySeconds) applies. Ack before it expires. resp, err := client.PollQueue(ctx, &kubemq.PollRequest{ Channel: "orders", MaxItems: 1, WaitTimeoutSeconds: 5, }) if err != nil { log.Fatal(err) } for _, dsMsg := range resp.Messages { fmt.Printf("Processing: %s\n", string(dsMsg.Message.Body)) } resp.AckAll() ``` ```python title="visibility.py" # A per-request visibility override is not exposed in the Python SDK — the server # default (DefaultVisibilitySeconds) applies. Ack before it expires. response = client.receive_queue_messages( channel="orders", max_messages=1, wait_timeout_in_seconds=5, ) for msg in response.messages: print(f"Processing: {msg.body.decode('utf-8')}") msg.ack() ``` ```typescript title="visibility.ts" // A per-request visibility override is not exposed in the Node.js SDK — the server // default (DefaultVisibilitySeconds) applies. Ack before it expires. const messages = await client.receiveQueueMessages({ channel: 'orders', maxMessages: 1, waitTimeoutSeconds: 5, }); for (const msg of messages) { console.log('Processing:', new TextDecoder().decode(msg.body)); await msg.ack(); } ``` ```java title="Visibility.java" // visibilitySeconds is a client-side timer: if the message is not acked or // rejected within 120s, the SDK auto-rejects it and the server redelivers. QueuesPollRequest pollRequest = QueuesPollRequest.builder() .channel("orders") .pollMaxMessages(1) .pollWaitTimeoutInSeconds(5) .autoAckMessages(false) .visibilitySeconds(120) .build(); QueuesPollResponse response = client.receiveQueueMessages(pollRequest); for (QueueMessageReceived msg : response.getMessages()) { System.out.println("Processing (120s visibility): " + new String(msg.getBody())); msg.ack(); } ``` ```csharp title="Visibility.cs" // A per-request visibility override is not exposed in the C# SDK — the server // default (DefaultVisibilitySeconds) applies. Ack before it expires. var response = await client.ReceiveQueueMessagesAsync(new QueuePollRequest { Channel = "orders", MaxMessages = 1, WaitTimeoutSeconds = 5, AutoAck = false, }); foreach (var msg in response.Messages) { Console.WriteLine($"Processing: {Encoding.UTF8.GetString(msg.Body.Span)}"); await msg.AckAsync(); } ``` ```kotlin title="Visibility.kt" // A per-request visibility override is not exposed in the Kotlin SDK — the server // default (DefaultVisibilitySeconds) applies. Ack before it expires. val response = client.receiveQueuesMessages { channel = "orders" maxItems = 1 waitTimeoutMs = 5000 autoAck = false } for (msg in response.messages) { println("Processing: ${String(msg.body)}") msg.ack() } ``` ```cpp title="visibility.cpp" // A per-request visibility override is not exposed in the C++ SDK — the server // default (DefaultVisibilitySeconds) applies. Ack before it expires. kubemq::PollRequest poll_req; poll_req.channel = "orders"; poll_req.max_items = 1; poll_req.wait_timeout_seconds = 5; auto poll_result = client->PollQueue(poll_req); for (const auto& dm : poll_result->messages()) { std::cout << "Processing: " << dm.message().body() << std::endl; dm.Ack(); } ``` ```rust title="visibility.rs" // A per-request visibility override is not exposed in the Rust SDK — the server // default (DefaultVisibilitySeconds) applies. Ack before it expires. let mut receiver = client.new_queue_downstream_receiver().await?; let poll = PollRequest { channel: "orders".to_string(), max_items: 1, wait_timeout_seconds: 5, auto_ack: false, }; let response = receiver.poll(poll).await?; for msg in &response.messages { println!("Processing: {}", String::from_utf8_lossy(&msg.message.body)); } response.ack_all().await?; ``` ```ruby title="visibility.rb" # A per-request visibility override is not exposed in the Ruby SDK — the server # default (DefaultVisibilitySeconds) applies. Ack before it expires. # Per-message ack requires the streaming receiver, not the unary receive call. receiver = client.create_downstream_receiver request = KubeMQ::Queues::QueuePollRequest.new( channel: 'orders', max_items: 1, wait_timeout: 5 ) response = receiver.poll(request) response.messages.each do |m| puts "Processing: #{m.body}" m.ack end receiver.close ``` ```elixir title="visibility.exs" # A per-request visibility override is not exposed in the Elixir SDK — the server # default (DefaultVisibilitySeconds) applies. Ack before it expires. {:ok, poll} = KubeMQ.Client.poll_queue(client, channel: "orders", max_items: 1, wait_timeout: 5_000 ) Enum.each(poll.messages, fn msg -> IO.puts("Processing: #{msg.body}") end) {:ok, _} = KubeMQ.PollResponse.ack_all(poll) ``` ## Best Practices [#best-practices] | Guideline | Recommendation | | ----------------------------------------------------------------------- | ---------------------------------- | | Set timeout to 2-3x expected processing time | Prevents premature redelivery | | Use shorter timeouts for fast operations | Reduces delay when consumers crash | | Use longer timeouts for heavy processing | Prevents duplicate work | | Consider [extending visibility](/learn/queues/how-to/extend-visibility) | For variable-duration tasks | The maximum visibility timeout is controlled by the server setting `MaxVisibilitySeconds` (default: 43,200 seconds / 12 hours). The default when not specified is `DefaultVisibilitySeconds` (60 seconds). ## Next Steps [#next-steps] # Configure Retention (/learn/events-store/how-to/configure-retention) Events Store persists messages to disk. Without retention policies, storage grows indefinitely. KubeMQ provides three types of retention controls: **time-based**, **size-based**, and **count-based**. You can combine them — the most restrictive policy wins. ## Retention Options [#retention-options] | Setting | Config Key | Default | Description | | ---------------------- | ------------------------ | ----------------- | -------------------------------------------------------------- | | Max retention time | `Store.MaxRetention` | `1440` (24 hours) | Maximum age of messages in minutes. `0` = unlimited. | | Max channel size | `Store.MaxQueueSize` | `0` (unlimited) | Maximum total bytes per channel. Oldest removed when exceeded. | | Max message count | `Store.MaxMessages` | `0` (unlimited) | Maximum messages per channel. Oldest removed when exceeded. | | Inactive channel purge | `Store.MaxPurgeInactive` | `1440` (24 hours) | Minutes of inactivity before an empty channel is purged. | ## How Retention Works [#how-retention-works] *As events accumulate, KubeMQ checks each retention threshold and purges the oldest events by time, size, or count — keeping everything that still fits within every limit.* ## Configure via Docker [#configure-via-docker] Set retention options using environment variables: This configures: * **3-day retention** (4320 minutes) * **1 GB max per channel** (1073741824 bytes) * **1 million messages max per channel** * **7-day inactive channel purge** (10080 minutes) ## Configure via Helm [#configure-via-helm] Set retention in your `values.yaml`: ```yaml title="values.yaml" store: maxRetention: 4320 maxQueueSize: 1073741824 maxMessages: 1000000 maxPurgeInactive: 10080 ``` Then install or upgrade: ```bash helm upgrade kubemq kubemq/kubemq --values values.yaml ``` ## Configure via Kubernetes Operator [#configure-via-kubernetes-operator] Set retention in the KubeMQ CRD: ```yaml title="kubemq-cluster.yaml" apiVersion: core.k8s.kubemq.io/v1beta1 kind: KubemqCluster metadata: name: kubemq spec: store: maxRetention: 4320 maxQueueSize: 1073741824 maxMessages: 1000000 maxPurgeInactive: 10080 ``` ## Common Retention Strategies [#common-retention-strategies] ### Development (Short Retention) [#development-short-retention] ```bash STORE_MAX_RETENTION=60 # 1 hour STORE_MAX_MESSAGES=10000 # 10K messages STORE_CLEAN_STORE=true # Clean on restart ``` ### Production — Event Streaming [#production--event-streaming] ```bash STORE_MAX_RETENTION=10080 # 7 days STORE_MAX_QUEUE_SIZE=5368709120 # 5 GB per channel STORE_MAX_MESSAGES=0 # Unlimited messages ``` ### Production — Event Sourcing [#production--event-sourcing] ```bash STORE_MAX_RETENTION=0 # Unlimited (no time expiry) STORE_MAX_QUEUE_SIZE=0 # Unlimited size STORE_MAX_MESSAGES=0 # Unlimited messages ``` Setting all retention values to `0` (unlimited) means events are stored indefinitely. Monitor disk usage with the [storage utilization thresholds](/learn/events-store/how-to/monitor-storage) to prevent disk exhaustion. ### Production — Compliance (Fixed Window) [#production--compliance-fixed-window] ```bash STORE_MAX_RETENTION=525600 # 365 days (1 year) STORE_MAX_QUEUE_SIZE=0 # Unlimited size STORE_MAX_MESSAGES=0 # Unlimited messages STORE_MAX_PURGE_INACTIVE=525600 # Purge inactive after 1 year ``` ## Persistence and Recovery [#persistence-and-recovery] ### Clean Start [#clean-start] To clear all stored data on startup: ### Volume Persistence [#volume-persistence] For data to survive container restarts, mount a volume to the store path: ### Recovery from Corruption [#recovery-from-corruption] If the server detects recovery errors on startup, it automatically removes and recreates the store directory. The `TruncateUnexpectedEOF` option (enabled by default) truncates corrupted file tails rather than failing. File store tuning (`WriteBufferSize`, `ReadBufferSize`, `DiskSyncSeconds`, etc.) is rarely needed. The defaults are suitable for most workloads. See the [Events Store Reference](/learn/events-store/reference) for advanced settings. ## Related [#related] * [Monitor Storage Utilization](/learn/events-store/how-to/monitor-storage) for disk usage thresholds * [Resume After Disconnect](/learn/events-store/how-to/resume-after-disconnect) for durable subscription behavior * [Events Store Reference](/learn/events-store/reference) for complete configuration # Monitor Storage Utilization (/learn/events-store/how-to/monitor-storage) KubeMQ includes a built-in storage utilization monitor that protects against disk exhaustion. When disk usage exceeds configurable thresholds, KubeMQ adjusts its behavior — from logging warnings to blocking all publish operations. ## Utilization Thresholds [#utilization-thresholds] | Utilization | Level | Polling Interval | Log Level | Publishing | | ----------- | -------- | ---------------- | --------- | ----------- | | 0–80% | Normal | 5 seconds | — | Allowed | | 80–90% | Warning | 3 seconds | WARN | Allowed | | 90–95% | Critical | 2 seconds | ERROR | Allowed | | Above 95% | Disabled | 1 second | ERROR | **Blocked** | *The storage monitor transitions through escalating levels as disk fills; publishing is blocked only above 95% and resumes automatically once utilization recovers.* ## What Happens at Each Level [#what-happens-at-each-level] ### Normal (0–80%) [#normal-080] Everything operates normally. The monitor checks disk utilization every 5 seconds. ### Warning (80–90%) [#warning-8090] Publishing continues but KubeMQ logs warnings: ```text [WARN] storage utilization at 83.2% - consider increasing disk space or adjusting retention ``` The polling interval decreases to 3 seconds for faster detection. ### Critical (90–95%) [#critical-9095] Publishing still operates but KubeMQ logs errors: ```text [ERROR] storage utilization at 92.1% - approaching disabled threshold ``` The polling interval decreases to 2 seconds. ### Disabled (Above 95%) [#disabled-above-95] All Events Store and Queue publish operations are **blocked**. Clients receive an error: ```text storage has reached to 96.5% utilization and is not allowed ``` Publishing automatically resumes when utilization drops below 95%. ## Recovery Steps [#recovery-steps] ### Identify the Issue [#identify-the-issue] Check server logs for utilization warnings: ```bash docker logs kubemq | grep "storage utilization" ``` ### Reduce Utilization [#reduce-utilization] Use one or more of these approaches: **Option 1: Reduce retention** — Lower the `Store.MaxRetention` value to purge older events faster. **Option 2: Add disk space** — Increase the volume size for the store path. **Option 3: Limit channel size** — Set `Store.MaxQueueSize` to cap each channel's disk usage. ### Verify Recovery [#verify-recovery] Once utilization drops below 95%, publishing resumes automatically. Verify in the logs: ```text [INFO] storage utilization recovered to 89.3% - publishing re-enabled ``` ## Monitoring Best Practices [#monitoring-best-practices] | Practice | Recommendation | | -------------------- | ------------------------------------------------------------------------------ | | Set alerts | Monitor for `WARNING` and `CRITICAL` log entries | | Right-size retention | Match retention to your replay window requirements | | Use volume mounts | Always mount persistent volumes in production | | Plan capacity | Calculate expected event rate × retention window × average event size | | Test thresholds | Verify your retention policies keep utilization well below 80% under peak load | The utilization monitor checks the filesystem where the store path is located. If you use a separate volume for the store, only that volume's usage counts toward the thresholds. ## Capacity Planning Example [#capacity-planning-example] For an order processing system: * **Event rate:** 100 events/second * **Average event size:** 1 KB * **Retention window:** 7 days ```text Storage needed = 100 events/s × 1 KB × 86,400 s/day × 7 days = 60.48 GB With 80% threshold target: Disk size needed = 60.48 GB / 0.80 = 75.6 GB → provision 100 GB ``` ## Related [#related] * [Configure Retention](/learn/events-store/how-to/configure-retention) for retention policy setup * [Events Store Reference](/learn/events-store/reference) for file store configuration # Resume After Disconnect (/learn/events-store/how-to/resume-after-disconnect) Events Store subscriptions are **durable by default**. When a subscriber disconnects and reconnects with the same durable name, the store resumes delivery from the last tracked position — no events are missed. ## How Position Tracking Works [#how-position-tracking-works] *The Events Store remembers the last delivered position per durable name; on reconnect it ignores the start position and resumes from where the subscriber left off.* ### Durable Name [#durable-name] Every Events Store subscription creates a durable name: ```text DurableName = "{channel}-{group}" ``` * On **first connection**, the `StartPosition` determines where to begin reading * On **subsequent connections** with the same durable name, the `StartPosition` is **ignored** — delivery resumes from the last tracked position * To force a fresh replay, use a different `group` name ## Step-by-Step [#step-by-step] ### Subscribe with a Durable Group [#subscribe-with-a-durable-group] Use a named group to enable durable position tracking. ```go title="durable_subscriber.go" package main import ( "context" "fmt" "log" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() sub, err := client.SubscribeToEventsStore(ctx, "orders.events", "order-processor", kubemq.StartFromFirst(), kubemq.WithOnEvent(func(event *kubemq.Event) { fmt.Printf("Processing seq=%d: %s\n", event.Sequence, string(event.Body)) }), kubemq.WithOnError(func(err error) { log.Println("Error:", err) }), ) if err != nil { log.Fatal(err) } defer sub.Unsubscribe() <-ctx.Done() } ``` ```python title="durable_subscriber.py" import time from kubemq import ( PubSubClient, EventsStoreSubscription, EventStoreStartPosition, CancellationToken, ) def on_event(event): print(f"Processing seq={event.sequence}: {event.body.decode('utf-8')}") with PubSubClient(address="localhost:50000") as client: client.subscribe_to_events_store( subscription=EventsStoreSubscription( channel="orders.events", group="order-processor", start_position=EventStoreStartPosition.StartFromFirst, on_receive_event_callback=on_event, on_error_callback=lambda e: print(f"Error: {e}"), ), cancel=CancellationToken(), ) time.sleep(300) ``` ```typescript title="durable_subscriber.ts" import { KubeMQClient, EventStoreStartPosition } from 'kubemq-js'; const client = await KubeMQClient.create({ address: 'localhost:50000' }); client.subscribeToEventsStore({ channel: 'orders.events', group: 'order-processor', startPosition: EventStoreStartPosition.StartFromFirst, onEvent: (msg) => console.log(`Processing seq=${msg.sequence}: ${new TextDecoder().decode(msg.body)}`), onError: (err) => console.error('Error:', err.message), }); ``` ```java title="DurableSubscriber.java" PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId("order-processor") .build(); client.subscribeToEventsStore(EventsStoreSubscription.builder() .channel("orders.events") .group("order-processor") .startPosition(EventStoreStartPosition.StartFromFirst) .onReceiveEventCallback(event -> System.out.printf("Processing seq=%d: %s%n", event.getSequence(), new String(event.getBody()))) .onErrorCallback(err -> System.err.println("Error: " + err.getMessage())) .build()); Thread.sleep(300_000); client.close(); ``` ```csharp title="DurableSubscriber.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); await foreach (var msg in client.SubscribeToEventsStoreAsync( new EventsStoreSubscription { Channel = "orders.events", Group = "order-processor", StartPosition = EventStoreStartPosition.StartFromFirst, })) { Console.WriteLine($"Processing seq={msg.Sequence}: " + $"{Encoding.UTF8.GetString(msg.Body.Span)}"); } ``` ```kotlin title="DurableSubscriber.kt" val client = KubeMQClient.pubSub { address = "localhost:50000" clientId = "order-processor" } client.use { client.subscribeToEventsStore { channel = "orders.events" group = "order-processor" startPosition = StartPosition.StartFromFirst }.collect { msg -> println("Processing seq=${msg.sequence}: ${String(msg.body)}") } } ``` ```cpp title="durable_subscriber.cc" kubemq::ClientOptions options; options.set_address("localhost", 50000); options.set_client_id("order-processor"); auto client = kubemq::Client::Create(options).value(); client->SubscribeToEventsStore("orders.events", "order-processor", kubemq::StartPosition::StartFromFirst, [](const kubemq::EventStoreReceived& msg) { std::cout << "Processing seq=" << msg.sequence() << ": " << msg.body() << std::endl; }, [](const std::string& err) { std::cerr << "Error: " << err << std::endl; }); ``` ```rust title="durable_subscriber.rs" use kubemq::prelude::*; use kubemq::EventsStoreSubscription; use std::time::Duration; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; // Named group enables durable position tracking. let sub = client .subscribe_to_events_store( "orders.events", "order-processor", EventsStoreSubscription::StartFromFirst, |event| { Box::pin(async move { println!( "Processing seq={}: {}", event.sequence, String::from_utf8_lossy(&event.body) ); }) }, None, ) .await?; tokio::time::sleep(Duration::from_secs(300)).await; sub.unsubscribe().await; client.close().await?; Ok(()) } ``` ```ruby title="durable_subscriber.rb" require 'kubemq' client = KubeMQ::PubSubClient.new(address: 'localhost:50000', client_id: 'order-processor') cancel = KubeMQ::CancellationToken.new # Named group enables durable position tracking. subscription = KubeMQ::PubSub::EventsStoreSubscription.new( channel: 'orders.events', group: 'order-processor', start_position: KubeMQ::PubSub::EventStoreStartPosition::START_FROM_FIRST ) client.subscribe_to_events_store(subscription, cancellation_token: cancel, on_error: lambda { |e| puts "Error: #{e.message}" }) do |event| puts "Processing seq=#{event.sequence}: #{event.body}" end sleep 300 cancel.cancel client.close ``` ```elixir title="durable_subscriber.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-processor") # Named group enables durable position tracking. {:ok, _sub} = KubeMQ.Client.subscribe_to_events_store(client, "orders.events", start_at: :start_from_first, group: "order-processor", on_event: fn event -> IO.puts("Processing seq=#{event.sequence}: #{event.body}") end, on_error: fn err -> IO.puts("Error: #{inspect(err)}") end ) Process.sleep(300_000) KubeMQ.Client.close(client) ``` ### Disconnect and Reconnect [#disconnect-and-reconnect] 1. Run the subscriber and let it process events up to seq=10 2. Stop the subscriber (Ctrl+C) 3. Publish more events (seq 11-15) 4. Restart the subscriber with the **same group name** **Result:** The subscriber picks up at seq=11, not seq=1. The `StartFromFirst` parameter is ignored on reconnection because the durable position already exists. ### Force a Fresh Replay [#force-a-fresh-replay] To replay from the beginning again, use a **different group name**: ```bash # Original group — resumes from last position GROUP=order-processor # New group — replays from StartFromFirst GROUP=order-processor-v2 ``` The old group's position data remains until the channel is purged or the `MaxPurgeInactive` timeout expires. ## Position Tracking Details [#position-tracking-details] | Behavior | Detail | | -------------------- | --------------------------------------------------- | | Tracking granularity | Per durable name (`{channel}-{group}`) | | Position persistence | Stored alongside the event data on disk | | First connection | Uses the `StartPosition` you specify | | Reconnection | Ignores `StartPosition`, resumes from last position | | No group specified | Empty string group still creates a durable name | | Multiple groups | Each group tracks position independently | Even with an empty `group` parameter, Events Store creates a durable subscription. The durable name becomes `{channel}-`. To create a truly ephemeral subscription, use plain [Events](/learn/events) instead. ## Related [#related] * [Consumer Groups](/learn/events-store/tutorials/consumer-groups) for distributed processing * [Replay Events](/learn/events-store/tutorials/replay-events) for all 6 start positions * [Events Store Reference](/learn/events-store/reference) for subscription parameters # Durable Consumer Groups (/learn/events-store/tutorials/consumer-groups) Consumer groups in Events Store distribute event processing across multiple subscribers while maintaining durable position tracking. Each event is delivered to exactly one member of the group, and the group's position is preserved across reconnections. For the underlying concept — why a consumer group gives you load-balancing and broadcast from the same channel — see [Scaling & flow](/learn/concepts/scaling-and-flow#consumer-groups-getting-both-from-one-channel) in the Fundamentals track. This page focuses on the Events Store specifics: durable position tracking and resume after disconnect. ## How Consumer Groups Work [#how-consumer-groups-work] *Each stored event is delivered to exactly one member of a group, while ungrouped subscribers receive every event independently.* * **Group members** share the event stream: each event goes to exactly one member * **Ungrouped subscribers** receive every event independently * **Position is durable**: if all members disconnect, the group resumes from the last position when any member reconnects * The durable name is `{channel}-{group}` ## Prerequisites [#prerequisites] * KubeMQ server running on `localhost:50000` * SDK installed ([Getting Started](/learn/events-store/getting-started)) ## Step-by-Step [#step-by-step] ### Create the Consumer Group Workers [#create-the-consumer-group-workers] Specify the `group` parameter when subscribing. Subscribers with the same group name on the same channel form a consumer group. ```go title="order_worker.go" package main import ( "context" "fmt" "log" "os" "github.com/kubemq-io/kubemq-go/v2" ) func main() { workerID := os.Getenv("WORKER_ID") if workerID == "" { workerID = "worker-1" } ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() sub, err := client.SubscribeToEventsStore(ctx, "orders.processing", "order-processors", kubemq.StartFromFirst(), kubemq.WithOnEvent(func(event *kubemq.Event) { fmt.Printf("[%s] Processing seq=%d: %s\n", workerID, event.Sequence, string(event.Body)) }), kubemq.WithOnError(func(err error) { log.Printf("[%s] Error: %v", workerID, err) }), ) if err != nil { log.Fatal(err) } defer sub.Unsubscribe() log.Printf("[%s] Ready in group 'order-processors'", workerID) <-ctx.Done() } ``` ```python title="order_worker.py" import os import time from kubemq import ( PubSubClient, EventsStoreSubscription, EventStoreStartPosition, CancellationToken, ) worker_id = os.environ.get("WORKER_ID", "worker-1") def on_event(event): print(f"[{worker_id}] Processing seq={event.sequence}: " f"{event.body.decode('utf-8')}") with PubSubClient(address="localhost:50000") as client: client.subscribe_to_events_store( subscription=EventsStoreSubscription( channel="orders.processing", group="order-processors", start_position=EventStoreStartPosition.StartFromFirst, on_receive_event_callback=on_event, on_error_callback=lambda e: print(f"[{worker_id}] Error: {e}"), ), cancel=CancellationToken(), ) print(f"[{worker_id}] Ready in group 'order-processors'") time.sleep(300) ``` ```typescript title="order_worker.ts" import { KubeMQClient, EventStoreStartPosition } from 'kubemq-js'; const workerId = process.env.WORKER_ID ?? 'worker-1'; const client = await KubeMQClient.create({ address: 'localhost:50000' }); client.subscribeToEventsStore({ channel: 'orders.processing', group: 'order-processors', startFrom: EventStoreStartPosition.StartFromFirst, onEvent: (msg) => console.log( `[${workerId}] Processing seq=${msg.sequence}: ` + `${new TextDecoder().decode(msg.body)}` ), onError: (err) => console.error(`[${workerId}] Error:`, err.message), }); console.log(`[${workerId}] Ready in group 'order-processors'`); ``` ```java title="OrderWorker.java" String workerId = System.getenv().getOrDefault("WORKER_ID", "worker-1"); PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId(workerId) .build(); client.subscribeToEventsStore(EventsStoreSubscription.builder() .channel("orders.processing") .group("order-processors") .startPosition(EventStoreStartPosition.StartFromFirst) .onReceiveEventCallback(event -> System.out.printf("[%s] Processing seq=%d: %s%n", workerId, event.getSequence(), new String(event.getBody()))) .onErrorCallback(err -> System.err.printf("[%s] Error: %s%n", workerId, err.getMessage())) .build()); System.out.printf("[%s] Ready in group 'order-processors'%n", workerId); Thread.sleep(300_000); client.close(); ``` ```csharp title="OrderWorker.cs" var workerId = Environment.GetEnvironmentVariable("WORKER_ID") ?? "worker-1"; await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); Console.WriteLine($"[{workerId}] Ready in group 'order-processors'"); await foreach (var msg in client.SubscribeToEventsStoreAsync( new EventsStoreSubscription { Channel = "orders.processing", Group = "order-processors", StartPosition = EventStoreStartPosition.StartFromFirst, })) { Console.WriteLine($"[{workerId}] Processing seq={msg.Sequence}: " + $"{Encoding.UTF8.GetString(msg.Body.Span)}"); } ``` ```kotlin title="OrderWorker.kt" val workerId = System.getenv("WORKER_ID") ?: "worker-1" val client = KubeMQClient.pubSub { address = "localhost:50000" clientId = workerId } client.use { println("[$workerId] Ready in group 'order-processors'") client.subscribeToEventsStore { channel = "orders.processing" group = "order-processors" startPosition = StartPosition.StartFromFirst }.collect { msg -> println("[$workerId] Processing seq=${msg.sequence}: ${String(msg.body)}") } } ``` ```cpp title="order_worker.cc" const char* env_id = std::getenv("WORKER_ID"); std::string worker_id = env_id ? env_id : "worker-1"; kubemq::ClientOptions options; options.set_address("localhost", 50000); options.set_client_id(worker_id); auto client = kubemq::Client::Create(options).value(); std::cout << "[" << worker_id << "] Ready in group 'order-processors'" << std::endl; client->SubscribeToEventsStore("orders.processing", "order-processors", kubemq::StartPosition::StartFromFirst, [&worker_id](const kubemq::EventStoreReceived& msg) { std::cout << "[" << worker_id << "] Processing seq=" << msg.sequence() << ": " << msg.body() << std::endl; }, [&worker_id](const std::string& err) { std::cerr << "[" << worker_id << "] Error: " << err << std::endl; }); ``` ```rust title="order_worker.rs" use kubemq::prelude::*; use kubemq::EventsStoreSubscription; use std::time::Duration; #[tokio::main] async fn main() -> kubemq::Result<()> { let worker_id = std::env::var("WORKER_ID").unwrap_or_else(|_| "worker-1".to_string()); let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; // Subscribers with the same group form a consumer group. let sub = client .subscribe_to_events_store( "orders.processing", "order-processors", EventsStoreSubscription::StartFromFirst, { let worker_id = worker_id.clone(); move |event| { let worker_id = worker_id.clone(); Box::pin(async move { println!( "[{}] Processing seq={}: {}", worker_id, event.sequence, String::from_utf8_lossy(&event.body) ); }) } }, None, ) .await?; println!("[{}] Ready in group 'order-processors'", worker_id); tokio::time::sleep(Duration::from_secs(300)).await; sub.unsubscribe().await; client.close().await?; Ok(()) } ``` ```ruby title="order_worker.rb" require 'kubemq' worker_id = ENV.fetch('WORKER_ID', 'worker-1') client = KubeMQ::PubSubClient.new(address: 'localhost:50000', client_id: worker_id) cancel = KubeMQ::CancellationToken.new # Subscribers sharing the same group name form a consumer group. subscription = KubeMQ::PubSub::EventsStoreSubscription.new( channel: 'orders.processing', group: 'order-processors', start_position: KubeMQ::PubSub::EventStoreStartPosition::START_FROM_FIRST ) client.subscribe_to_events_store(subscription, cancellation_token: cancel, on_error: ->(e) { puts "[#{worker_id}] Error: #{e.message}" }) do |event| puts "[#{worker_id}] Processing seq=#{event.sequence}: #{event.body}" end puts "[#{worker_id}] Ready in group 'order-processors'" sleep 300 ensure cancel&.cancel client&.close ``` ```elixir title="order_worker.exs" worker_id = System.get_env("WORKER_ID", "worker-1") {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: worker_id) # Subscribers sharing the same group name form a consumer group. {:ok, _sub} = KubeMQ.Client.subscribe_to_events_store(client, "orders.processing", start_at: :start_from_first, group: "order-processors", on_event: fn event -> IO.puts("[#{worker_id}] Processing seq=#{event.sequence}: #{event.body}") end, on_error: fn err -> IO.puts("[#{worker_id}] Error: #{err}") end ) IO.puts("[#{worker_id}] Ready in group 'order-processors'") Process.sleep(300_000) ``` Run multiple instances with different `WORKER_ID` values: ```bash WORKER_ID=worker-A go run order_worker.go & WORKER_ID=worker-B go run order_worker.go & WORKER_ID=worker-C go run order_worker.go & ``` ### Publish Events and Verify Distribution [#publish-events-and-verify-distribution] Publish 6 order events and observe round-robin distribution across the 3 workers. ```text [worker-A] Processing seq=1: {"orderId":"ORD-1001",...} [worker-B] Processing seq=2: {"orderId":"ORD-1002",...} [worker-C] Processing seq=3: {"orderId":"ORD-1003",...} [worker-A] Processing seq=4: {"orderId":"ORD-1004",...} [worker-B] Processing seq=5: {"orderId":"ORD-1005",...} [worker-C] Processing seq=6: {"orderId":"ORD-1006",...} ``` Each event is delivered to exactly one worker. The workload is distributed evenly. ### Verify Durable Resume After Disconnect [#verify-durable-resume-after-disconnect] 1. Workers A, B, C process events up to sequence 100 2. All three workers disconnect 3. 50 new events arrive (seq 101-150) 4. Worker A reconnects with the same group name 5. Worker A receives events starting from sequence 101 The `StartPosition` parameter is only used on the **first connection** for a durable name. Subsequent connections resume from the last tracked position. ## Groups vs Fan-Out [#groups-vs-fan-out] *Without a group every subscriber gets a full copy; within a group the channel load-balances each event to one member.* | Delivery | No Group | With Group | | ----------------- | ----------------------------------------- | ----------------------------- | | Event routing | Every subscriber gets every event | Each event goes to one member | | Use case | Independent processing (audit, analytics) | Load-balanced processing | | Position tracking | Per subscriber | Per group (shared) | ## Multiple Groups on One Channel [#multiple-groups-on-one-channel] Different groups receive independent copies of the event stream: ```bash # Group 1: Order fulfillment (3 workers sharing load) WORKER_ID=fulfill-1 GROUP=fulfillment go run worker.go WORKER_ID=fulfill-2 GROUP=fulfillment go run worker.go # Group 2: Analytics (2 workers sharing load) WORKER_ID=analytics-1 GROUP=analytics go run worker.go WORKER_ID=analytics-2 GROUP=analytics go run worker.go # No group: Auditor (receives every event) go run auditor.go ``` Each group independently tracks its position and distributes events among its members. ## Events Store Groups vs Events Groups [#events-store-groups-vs-events-groups] | Feature | Events Groups | Events Store Groups | | ------------------ | ------------------------ | ------------------------------- | | Position tracking | None (ephemeral) | Durable (survives disconnect) | | Missed messages | Lost when offline | Replayed on reconnect | | Delivery guarantee | At-most-once | At-least-once | | Replay capability | None | Full history replay | | Use case | Real-time load balancing | Reliable distributed processing | To force a fresh replay, use a different group name. The old group's position data remains until the channel is purged or the inactive purge timeout expires. ## Scaling Guidelines [#scaling-guidelines] | Factor | Recommendation | | ----------------- | ------------------------------------------------------------------- | | Number of members | Scale based on processing throughput. No hard limit. | | Slow consumers | Keep processing fast or offload to background workers. | | Group naming | Use descriptive names (e.g., `email-senders`, `report-generators`). | | Rebalancing | Adding or removing group members takes effect immediately. | ## Next Steps [#next-steps] * Learn about [stream publishing](/learn/events-store/tutorials/stream-publishing) for high throughput * Configure [retention policies](/learn/events-store/how-to/configure-retention) * Handle [resume after disconnect](/learn/events-store/how-to/resume-after-disconnect) * See the [Events Store Reference](/learn/events-store/reference) for subscription parameters # Event Sourcing Pattern (/learn/events-store/tutorials/event-sourcing) Event sourcing stores every state change as an immutable event rather than overwriting the current state. KubeMQ Events Store is a natural fit because it provides persistent, sequenced, and replayable event streams. ## What You Will Build [#what-you-will-build] An order management service that: * Stores every order state change (created, paid, shipped, delivered) as an event * Reconstructs the current order state by replaying the full event history * Supports checkpoint-based recovery to avoid full replays *Every order state change is appended to the event store; replaying the full sequence reconstructs the current order state.* ## Prerequisites [#prerequisites] * KubeMQ server running on `localhost:50000` * SDK installed ([Getting Started](/learn/events-store/getting-started)) ## Step-by-Step [#step-by-step] ### Define the Event Schema [#define-the-event-schema] Each event includes: * **type** — the event kind (e.g., `order.created`, `order.paid`) * **orderId** — the aggregate identifier * **data** — event-specific payload * **timestamp** — when the state change occurred ```json { "type": "order.created", "orderId": "ORD-1001", "data": { "customer": "C-500", "items": [{"sku": "WIDGET-A", "qty": 2, "price": 49.99}] }, "timestamp": "2026-03-26T10:00:00Z" } ``` ### Publish Order Events [#publish-order-events] Store a series of state changes for an order. Each event is immutable once stored. ```go title="order_service.go" package main import ( "context" "fmt" "log" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() channel := "order.ORD-1001" events := []string{ `{"type":"order.created","orderId":"ORD-1001","data":{"customer":"C-500","total":149.99}}`, `{"type":"order.paid","orderId":"ORD-1001","data":{"method":"credit_card","txId":"TX-789"}}`, `{"type":"order.shipped","orderId":"ORD-1001","data":{"carrier":"fedex","tracking":"FX-123"}}`, } for _, body := range events { result, err := client.SendEventStore(ctx, kubemq.NewEvent(). SetChannel(channel). SetBody([]byte(body)), ) if err != nil { log.Fatal(err) } log.Printf("Stored: %s (seq: %s)", body[:40], result.EventID) } } ``` ```python title="order_service.py" import json from kubemq import PubSubClient, EventStoreMessage events = [ {"type": "order.created", "orderId": "ORD-1001", "data": {"customer": "C-500", "total": 149.99}}, {"type": "order.paid", "orderId": "ORD-1001", "data": {"method": "credit_card", "txId": "TX-789"}}, {"type": "order.shipped", "orderId": "ORD-1001", "data": {"carrier": "fedex", "tracking": "FX-123"}}, ] with PubSubClient(address="localhost:50000") as client: for event in events: result = client.publish_event_store( EventStoreMessage( channel="order.ORD-1001", body=json.dumps(event).encode("utf-8"), ) ) print(f"Stored: {event['type']} (ID: {result.id})") ``` ```typescript title="order_service.ts" import { KubeMQClient, createEventStoreMessage } from 'kubemq-js'; const client = await KubeMQClient.create({ address: 'localhost:50000' }); const events = [ { type: 'order.created', orderId: 'ORD-1001', data: { customer: 'C-500', total: 149.99 } }, { type: 'order.paid', orderId: 'ORD-1001', data: { method: 'credit_card', txId: 'TX-789' } }, { type: 'order.shipped', orderId: 'ORD-1001', data: { carrier: 'fedex', tracking: 'FX-123' } }, ]; for (const event of events) { await client.sendEventStore( createEventStoreMessage({ channel: 'order.ORD-1001', body: JSON.stringify(event), }) ); console.log(`Stored: ${event.type}`); } ``` ```java title="OrderService.java" PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId("order-service") .build(); String[] events = { "{\"type\":\"order.created\",\"orderId\":\"ORD-1001\",\"data\":{\"customer\":\"C-500\",\"total\":149.99}}", "{\"type\":\"order.paid\",\"orderId\":\"ORD-1001\",\"data\":{\"method\":\"credit_card\"}}", "{\"type\":\"order.shipped\",\"orderId\":\"ORD-1001\",\"data\":{\"carrier\":\"fedex\"}}" }; for (String body : events) { client.sendEventsStoreMessage(EventStoreMessage.builder() .channel("order.ORD-1001") .body(body.getBytes()) .build()); System.out.println("Stored event"); } client.close(); ``` ```csharp title="OrderService.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); string[] events = { "{\"type\":\"order.created\",\"orderId\":\"ORD-1001\",\"data\":{\"customer\":\"C-500\",\"total\":149.99}}", "{\"type\":\"order.paid\",\"orderId\":\"ORD-1001\",\"data\":{\"method\":\"credit_card\"}}", "{\"type\":\"order.shipped\",\"orderId\":\"ORD-1001\",\"data\":{\"carrier\":\"fedex\"}}" }; foreach (var body in events) { await client.SendEventStoreAsync(new EventStoreMessage { Channel = "order.ORD-1001", Body = Encoding.UTF8.GetBytes(body), }); Console.WriteLine("Stored event"); } ``` ```kotlin title="OrderService.kt" val client = KubeMQClient.pubSub { address = "localhost:50000" clientId = "order-service" } val events = listOf( """{"type":"order.created","orderId":"ORD-1001","data":{"customer":"C-500","total":149.99}}""", """{"type":"order.paid","orderId":"ORD-1001","data":{"method":"credit_card"}}""", """{"type":"order.shipped","orderId":"ORD-1001","data":{"carrier":"fedex"}}""", ) client.use { for (body in events) { client.sendEventStore(eventStoreMessage { channel = "order.ORD-1001" this.body = body.toByteArray() }) println("Stored event") } } ``` ```cpp title="order_service.cc" kubemq::ClientOptions options; options.set_address("localhost", 50000); options.set_client_id("order-service"); auto client = kubemq::Client::Create(options).value(); std::vector events = { R"({"type":"order.created","orderId":"ORD-1001","data":{"customer":"C-500","total":149.99}})", R"({"type":"order.paid","orderId":"ORD-1001","data":{"method":"credit_card"}})", R"({"type":"order.shipped","orderId":"ORD-1001","data":{"carrier":"fedex"}})" }; for (const auto& body : events) { kubemq::EventStoreMessage msg; msg.set_channel("order.ORD-1001"); msg.set_body(body); client->SendEventStore(msg); std::cout << "Stored event" << std::endl; } ``` ```rust title="order_service.rs" use kubemq::prelude::*; use kubemq::EventStoreBuilder; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; let channel = "order.ORD-1001"; let events = [ r#"{"type":"order.created","orderId":"ORD-1001","data":{"customer":"C-500","total":149.99}}"#, r#"{"type":"order.paid","orderId":"ORD-1001","data":{"method":"credit_card","txId":"TX-789"}}"#, r#"{"type":"order.shipped","orderId":"ORD-1001","data":{"carrier":"fedex","tracking":"FX-123"}}"#, ]; for body in events { let event = EventStoreBuilder::new() .channel(channel) .body(body.as_bytes().to_vec()) .build(); let result = client.send_event_store(event).await?; println!("Stored: id={}, sent={}", result.id, result.sent); } client.close().await?; Ok(()) } ``` ```ruby title="order_service.rb" require 'kubemq' client = KubeMQ::PubSubClient.new(address: 'localhost:50000', client_id: 'order-service') channel = 'order.ORD-1001' events = [ '{"type":"order.created","orderId":"ORD-1001","data":{"customer":"C-500","total":149.99}}', '{"type":"order.paid","orderId":"ORD-1001","data":{"method":"credit_card","txId":"TX-789"}}', '{"type":"order.shipped","orderId":"ORD-1001","data":{"carrier":"fedex","tracking":"FX-123"}}' ] events.each do |body| result = client.send_event_store( KubeMQ::PubSub::EventStoreMessage.new(channel: channel, body: body) ) puts "Stored event: sent=#{result.sent}" end client.close ``` ```elixir title="order_service.exs" channel = "order.ORD-1001" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-service") events = [ ~s({"type":"order.created","orderId":"ORD-1001","data":{"customer":"C-500","total":149.99}}), ~s({"type":"order.paid","orderId":"ORD-1001","data":{"method":"credit_card","txId":"TX-789"}}), ~s({"type":"order.shipped","orderId":"ORD-1001","data":{"carrier":"fedex","tracking":"FX-123"}}) ] for body <- events do {:ok, result} = KubeMQ.Client.send_event_store(client, KubeMQ.EventStore.new(channel: channel, body: body)) IO.puts("Stored event: sent=#{result.sent}") end KubeMQ.Client.close(client) ``` ### Rebuild State from Event History [#rebuild-state-from-event-history] Subscribe with `StartFromFirst` to replay all events and compute the current order state. ```go title="state_rebuilder.go" package main import ( "context" "encoding/json" "fmt" "log" "sync" "time" "github.com/kubemq-io/kubemq-go/v2" ) type OrderState struct { OrderID string Status string Customer string Total float64 Carrier string Tracking string } func main() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() var mu sync.Mutex order := OrderState{OrderID: "ORD-1001"} sub, err := client.SubscribeToEventsStore(ctx, "order.ORD-1001", "", kubemq.StartFromFirst(), kubemq.WithOnEvent(func(event *kubemq.Event) { mu.Lock() defer mu.Unlock() var evt map[string]interface{} json.Unmarshal(event.Body, &evt) eventType := evt["type"].(string) data := evt["data"].(map[string]interface{}) switch eventType { case "order.created": order.Status = "created" order.Customer = data["customer"].(string) order.Total = data["total"].(float64) case "order.paid": order.Status = "paid" case "order.shipped": order.Status = "shipped" order.Carrier = data["carrier"].(string) } fmt.Printf("seq=%d %s -> status=%s\n", event.Sequence, eventType, order.Status) }), kubemq.WithOnError(func(err error) { log.Println("Error:", err) }), ) if err != nil { log.Fatal(err) } defer sub.Unsubscribe() <-ctx.Done() fmt.Printf("\nOrder State: %+v\n", order) } ``` ```python title="state_rebuilder.py" import json import time from kubemq import ( PubSubClient, EventsStoreSubscription, EventStoreStartPosition, CancellationToken, ) order = {"orderId": "ORD-1001", "status": "unknown"} def on_event(event): global order evt = json.loads(event.body.decode("utf-8")) event_type = evt["type"] data = evt.get("data", {}) if event_type == "order.created": order["status"] = "created" order["customer"] = data.get("customer") order["total"] = data.get("total") elif event_type == "order.paid": order["status"] = "paid" elif event_type == "order.shipped": order["status"] = "shipped" order["carrier"] = data.get("carrier") print(f"seq={event.sequence} {event_type} -> status={order['status']}") with PubSubClient(address="localhost:50000") as client: client.subscribe_to_events_store( subscription=EventsStoreSubscription( channel="order.ORD-1001", start_position=EventStoreStartPosition.StartFromFirst, on_receive_event_callback=on_event, on_error_callback=lambda e: print(f"Error: {e}"), ), cancel=CancellationToken(), ) time.sleep(5) print(f"\nOrder State: {order}") ``` ```typescript title="state_rebuilder.ts" import { KubeMQClient, EventStoreStartPosition } from 'kubemq-js'; const client = await KubeMQClient.create({ address: 'localhost:50000' }); const order: Record = { orderId: 'ORD-1001', status: 'unknown' }; client.subscribeToEventsStore({ channel: 'order.ORD-1001', startFrom: EventStoreStartPosition.StartFromFirst, onEvent: (msg) => { const evt = JSON.parse(new TextDecoder().decode(msg.body)); const { type, data } = evt; if (type === 'order.created') { Object.assign(order, { status: 'created', customer: data.customer, total: data.total }); } else if (type === 'order.paid') { order.status = 'paid'; } else if (type === 'order.shipped') { Object.assign(order, { status: 'shipped', carrier: data.carrier }); } console.log(`seq=${msg.sequence} ${type} -> status=${order.status}`); }, onError: (err) => console.error('Error:', err.message), }); setTimeout(() => console.log('\nOrder State:', order), 5000); ``` ```java title="StateRebuilder.java" PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId("state-rebuilder") .build(); Map order = new ConcurrentHashMap<>(); order.put("orderId", "ORD-1001"); client.subscribeToEventsStore(EventsStoreSubscription.builder() .channel("order.ORD-1001") .startPosition(EventStoreStartPosition.StartFromFirst) .onReceiveEventCallback(event -> { var evt = new ObjectMapper().readTree(event.getBody()); String type = evt.get("type").asText(); if ("order.created".equals(type)) { order.put("status", "created"); order.put("total", evt.get("data").get("total").asDouble()); } else if ("order.paid".equals(type)) { order.put("status", "paid"); } else if ("order.shipped".equals(type)) { order.put("status", "shipped"); } System.out.printf("seq=%d %s -> status=%s%n", event.getSequence(), type, order.get("status")); }) .onErrorCallback(err -> System.err.println(err.getMessage())) .build()); Thread.sleep(5000); System.out.println("\nOrder State: " + order); client.close(); ``` ```csharp title="StateRebuilder.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); var order = new Dictionary { ["orderId"] = "ORD-1001" }; await foreach (var msg in client.SubscribeToEventsStoreAsync( new EventsStoreSubscription { Channel = "order.ORD-1001", StartPosition = EventStoreStartPosition.StartFromFirst, })) { var evt = JsonSerializer.Deserialize(msg.Body.Span); var type = evt.GetProperty("type").GetString()!; order["status"] = type switch { "order.created" => "created", "order.paid" => "paid", "order.shipped" => "shipped", _ => order.GetValueOrDefault("status", "unknown")! }; Console.WriteLine($"seq={msg.Sequence} {type} -> status={order["status"]}"); } ``` ```kotlin title="StateRebuilder.kt" val client = KubeMQClient.pubSub { address = "localhost:50000" clientId = "state-rebuilder" } data class OrderState(var status: String = "unknown", var total: Double = 0.0) val order = OrderState() client.use { client.subscribeToEventsStore { channel = "order.ORD-1001" startPosition = StartPosition.StartFromFirst }.collect { msg -> val evt = JSONObject(String(msg.body)) val type = evt.getString("type") when (type) { "order.created" -> { order.status = "created"; order.total = evt.getJSONObject("data").getDouble("total") } "order.paid" -> order.status = "paid" "order.shipped" -> order.status = "shipped" } println("seq=${msg.sequence} $type -> status=${order.status}") } } ``` ```cpp title="state_rebuilder.cc" kubemq::ClientOptions options; options.set_address("localhost", 50000); auto client = kubemq::Client::Create(options).value(); std::string status = "unknown"; client->SubscribeToEventsStore("order.ORD-1001", "", kubemq::StartPosition::StartFromFirst, [&status](const kubemq::EventStoreReceived& msg) { auto body = msg.body(); if (body.find("order.created") != std::string::npos) status = "created"; else if (body.find("order.paid") != std::string::npos) status = "paid"; else if (body.find("order.shipped") != std::string::npos) status = "shipped"; std::cout << "seq=" << msg.sequence() << " -> status=" << status << std::endl; }, [](const std::string& err) { std::cerr << err << std::endl; }); ``` ```rust title="state_rebuilder.rs" use kubemq::prelude::*; use kubemq::EventsStoreSubscription; use serde_json::Value; use std::sync::{Arc, Mutex}; use std::time::Duration; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; let status = Arc::new(Mutex::new(String::from("unknown"))); let status_cb = status.clone(); // Replay every stored event from the beginning to rebuild state. let sub = client .subscribe_to_events_store( "order.ORD-1001", "", EventsStoreSubscription::StartFromFirst, move |event| { let status = status_cb.clone(); Box::pin(async move { let evt: Value = serde_json::from_slice(&event.body).unwrap_or(Value::Null); let event_type = evt["type"].as_str().unwrap_or(""); let mut s = status.lock().unwrap(); match event_type { "order.created" => *s = "created".into(), "order.paid" => *s = "paid".into(), "order.shipped" => *s = "shipped".into(), _ => {} } println!("seq={} {} -> status={}", event.sequence, event_type, *s); }) }, None, ) .await?; tokio::time::sleep(Duration::from_secs(5)).await; println!("Order State: status={}", *status.lock().unwrap()); sub.unsubscribe().await; client.close().await?; Ok(()) } ``` ```ruby title="state_rebuilder.rb" require 'kubemq' require 'json' client = KubeMQ::PubSubClient.new(address: 'localhost:50000', client_id: 'state-rebuilder') cancel = KubeMQ::CancellationToken.new order = { 'orderId' => 'ORD-1001', 'status' => 'unknown' } # Replay all stored events from the first to rebuild current state. sub = KubeMQ::PubSub::EventsStoreSubscription.new( channel: 'order.ORD-1001', start_position: KubeMQ::PubSub::EventStoreStartPosition::START_FROM_FIRST ) client.subscribe_to_events_store(sub, cancellation_token: cancel, on_error: lambda { |e| puts "Error: #{e.message}" }) do |event| evt = JSON.parse(event.body) case evt['type'] when 'order.created' then order['status'] = 'created' when 'order.paid' then order['status'] = 'paid' when 'order.shipped' then order['status'] = 'shipped' end puts "seq=#{event.sequence} #{evt['type']} -> status=#{order['status']}" end sleep 5 puts "Order State: #{order}" cancel.cancel client.close ``` ```elixir title="state_rebuilder.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "state-rebuilder") {:ok, agent} = Agent.start_link(fn -> "unknown" end) # Replay all stored events from the first to rebuild current state. {:ok, sub} = KubeMQ.Client.subscribe_to_events_store(client, "order.ORD-1001", start_at: :start_from_first, on_event: fn event -> evt = Jason.decode!(event.body) status = case evt["type"] do "order.created" -> "created" "order.paid" -> "paid" "order.shipped" -> "shipped" _ -> Agent.get(agent, & &1) end Agent.update(agent, fn _ -> status end) IO.puts("seq=#{event.sequence} #{evt["type"]} -> status=#{status}") end ) Process.sleep(5_000) IO.puts("Order State: status=#{Agent.get(agent, & &1)}") KubeMQ.Subscription.cancel(sub) KubeMQ.Client.close(client) ``` **Expected output:** ```text seq=1 order.created -> status=created seq=2 order.paid -> status=paid seq=3 order.shipped -> status=shipped Order State: {orderId=ORD-1001, status=shipped, customer=C-500, total=149.99} ``` ### Checkpoint-Based Recovery [#checkpoint-based-recovery] For aggregates with long histories, save the last processed sequence number as a checkpoint. On restart, subscribe from that sequence instead of replaying the entire history. *A checkpoint records the last processed sequence so restarts replay only the unprocessed tail instead of the full history.* The subscriber uses `StartAtSequence(lastCheckpoint + 1)` to pick up only unprocessed events. ## Event Sourcing Best Practices [#event-sourcing-best-practices] ### Channel Per Aggregate [#channel-per-aggregate] Use one channel per aggregate instance (e.g., `order.ORD-1001`, `order.ORD-1002`). This provides independent sequencing and clean replay per entity. ### Immutable Events [#immutable-events] Never modify or delete events. The event stream is the source of truth. To correct an error, append a compensating event (e.g., an `order.refunded` event). ### Snapshots for Performance [#snapshots-for-performance] For aggregates with long histories, periodically save a **snapshot** (the computed state at a sequence number). On startup, load the snapshot and replay only events after the snapshot sequence. ### Event Schema Versioning [#event-schema-versioning] Include a `version` field in your event schema. When the schema changes, handle both old and new formats in your event handler. For event sourcing, configure retention to unlimited (`Store.MaxRetention=0`) or set it longer than your maximum replay window. See [Configure Retention](/learn/events-store/how-to/configure-retention). ## Next Steps [#next-steps] * Configure [retention policies](/learn/events-store/how-to/configure-retention) for long-lived streams * Scale processing with [consumer groups](/learn/events-store/tutorials/consumer-groups) * Build an [audit trail](/learn/events-store/scenarios/audit-trail) with Events Store * See the [Events Store Reference](/learn/events-store/reference) for configuration options # Persistent Publish & Subscribe (/learn/events-store/tutorials/persistent-publish-subscribe) This tutorial demonstrates the persistent pub/sub pattern with KubeMQ Events Store. You will publish events that are stored on disk and subscribe with different start positions to control which events you receive. This is the **deep-dive** — multiple subscriber types (full-history replay vs. new-events-only) and durable subscriptions. New to Events Store? Start with the 5-minute [getting-started quickstart](/learn/events-store/getting-started) first. ## What You Will Build [#what-you-will-build] An order tracking system where: * An **order service** publishes order lifecycle events to a persistent channel * An **audit dashboard** replays the full history from the beginning * A **real-time alerter** receives only new events going forward *The order service persists each lifecycle event to the store; the audit dashboard replays the full history while the real-time alerter receives only new events.* ## Prerequisites [#prerequisites] * KubeMQ server running on `localhost:50000` * SDK installed ([Getting Started](/learn/events-store/getting-started)) ## Step-by-Step [#step-by-step] ### Create the Event Publisher [#create-the-event-publisher] The publisher sends order lifecycle events with metadata describing the event type. ```go title="order_publisher.go" package main import ( "context" "fmt" "log" "time" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() events := []struct { Action string OrderID string Detail string }{ {"order.created", "ORD-1001", "New order placed, total=$149.99"}, {"order.paid", "ORD-1001", "Payment confirmed via credit card"}, {"order.picked", "ORD-1001", "Items picked from warehouse"}, {"order.shipped", "ORD-1001", "Shipped via FedEx, tracking=FX-9876"}, {"order.delivered", "ORD-1001", "Delivered to customer"}, } for _, e := range events { body := fmt.Sprintf(`{"action":"%s","orderId":"%s","detail":"%s"}`, e.Action, e.OrderID, e.Detail) result, err := client.SendEventStore(ctx, kubemq.NewEvent(). SetChannel("orders.lifecycle"). SetMetadata(e.Action). SetBody([]byte(body)), ) if err != nil { log.Printf("Failed to store: %v", err) continue } log.Printf("Stored [%s]: %s (ID: %s)", e.Action, e.OrderID, result.EventID) time.Sleep(200 * time.Millisecond) } } ``` ```python title="order_publisher.py" import json import time from kubemq import PubSubClient, EventStoreMessage events = [ {"action": "order.created", "orderId": "ORD-1001", "detail": "New order, total=$149.99"}, {"action": "order.paid", "orderId": "ORD-1001", "detail": "Payment confirmed"}, {"action": "order.picked", "orderId": "ORD-1001", "detail": "Items picked"}, {"action": "order.shipped", "orderId": "ORD-1001", "detail": "Shipped via FedEx"}, {"action": "order.delivered", "orderId": "ORD-1001", "detail": "Delivered"}, ] with PubSubClient(address="localhost:50000") as client: for e in events: result = client.publish_event_store( EventStoreMessage( channel="orders.lifecycle", metadata=e["action"], body=json.dumps(e).encode("utf-8"), ) ) print(f"Stored [{e['action']}]: {e['orderId']} (ID: {result.id})") time.sleep(0.2) ``` ```typescript title="order_publisher.ts" import { KubeMQClient, createEventStoreMessage } from 'kubemq-js'; const client = await KubeMQClient.create({ address: 'localhost:50000' }); const events = [ { action: 'order.created', orderId: 'ORD-1001', detail: 'New order, total=$149.99' }, { action: 'order.paid', orderId: 'ORD-1001', detail: 'Payment confirmed' }, { action: 'order.picked', orderId: 'ORD-1001', detail: 'Items picked' }, { action: 'order.shipped', orderId: 'ORD-1001', detail: 'Shipped via FedEx' }, { action: 'order.delivered', orderId: 'ORD-1001', detail: 'Delivered' }, ]; for (const e of events) { const result = await client.sendEventStore( createEventStoreMessage({ channel: 'orders.lifecycle', metadata: e.action, body: JSON.stringify(e), }) ); console.log(`Stored [${e.action}]: ${e.orderId} (ID: ${result.id})`); await new Promise((r) => setTimeout(r, 200)); } ``` ```java title="OrderPublisher.java" PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId("order-publisher") .build(); String[][] events = { {"order.created", "ORD-1001", "New order, total=$149.99"}, {"order.paid", "ORD-1001", "Payment confirmed"}, {"order.picked", "ORD-1001", "Items picked"}, {"order.shipped", "ORD-1001", "Shipped via FedEx"}, {"order.delivered", "ORD-1001", "Delivered"}, }; for (String[] e : events) { String body = String.format( "{\"action\":\"%s\",\"orderId\":\"%s\",\"detail\":\"%s\"}", e[0], e[1], e[2]); EventSendResult result = client.sendEventsStoreMessage( EventStoreMessage.builder() .channel("orders.lifecycle") .metadata(e[0]) .body(body.getBytes()) .build()); System.out.printf("Stored [%s]: %s (ID: %s)%n", e[0], e[1], result.getId()); Thread.sleep(200); } client.close(); ``` ```csharp title="OrderPublisher.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); var events = new[] { ("order.created", "ORD-1001", "New order, total=$149.99"), ("order.paid", "ORD-1001", "Payment confirmed"), ("order.picked", "ORD-1001", "Items picked"), ("order.shipped", "ORD-1001", "Shipped via FedEx"), ("order.delivered", "ORD-1001", "Delivered"), }; foreach (var (action, orderId, detail) in events) { var body = $"{{\"action\":\"{action}\",\"orderId\":\"{orderId}\",\"detail\":\"{detail}\"}}"; var result = await client.SendEventStoreAsync(new EventStoreMessage { Channel = "orders.lifecycle", Metadata = action, Body = Encoding.UTF8.GetBytes(body), }); Console.WriteLine($"Stored [{action}]: {orderId} (ID: {result.Id})"); await Task.Delay(200); } ``` ```kotlin title="OrderPublisher.kt" val client = KubeMQClient.pubSub { address = "localhost:50000" clientId = "order-publisher" } data class OrderEvent(val action: String, val orderId: String, val detail: String) val events = listOf( OrderEvent("order.created", "ORD-1001", "New order, total=\$149.99"), OrderEvent("order.paid", "ORD-1001", "Payment confirmed"), OrderEvent("order.picked", "ORD-1001", "Items picked"), OrderEvent("order.shipped", "ORD-1001", "Shipped via FedEx"), OrderEvent("order.delivered", "ORD-1001", "Delivered"), ) client.use { for (e in events) { val body = """{"action":"${e.action}","orderId":"${e.orderId}","detail":"${e.detail}"}""" val result = client.sendEventStore(eventStoreMessage { channel = "orders.lifecycle" metadata = e.action this.body = body.toByteArray() }) println("Stored [${e.action}]: ${e.orderId} (ID: ${result.id})") delay(200) } } ``` ```cpp title="order_publisher.cc" kubemq::ClientOptions options; options.set_address("localhost", 50000); options.set_client_id("order-publisher"); auto client = kubemq::Client::Create(options).value(); struct OrderEvent { std::string action, orderId, detail; }; std::vector events = { {"order.created", "ORD-1001", "New order, total=$149.99"}, {"order.paid", "ORD-1001", "Payment confirmed"}, {"order.picked", "ORD-1001", "Items picked"}, {"order.shipped", "ORD-1001", "Shipped via FedEx"}, {"order.delivered", "ORD-1001", "Delivered"}, }; for (const auto& e : events) { kubemq::EventStoreMessage msg; msg.set_channel("orders.lifecycle"); msg.set_metadata(e.action); msg.set_body("{\"action\":\"" + e.action + "\",\"orderId\":\"" + e.orderId + "\"}"); auto result = client->SendEventStore(msg); if (result.ok()) { std::cout << "Stored [" << e.action << "]: " << e.orderId << std::endl; } std::this_thread::sleep_for(std::chrono::milliseconds(200)); } ``` ```rust title="order_publisher.rs" use kubemq::prelude::*; use kubemq::EventStoreBuilder; use std::time::Duration; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; let events = [ ("order.created", "ORD-1001", "New order, total=$149.99"), ("order.paid", "ORD-1001", "Payment confirmed"), ("order.picked", "ORD-1001", "Items picked"), ("order.shipped", "ORD-1001", "Shipped via FedEx"), ("order.delivered", "ORD-1001", "Delivered"), ]; for (action, order_id, detail) in events { let body = format!( r#"{{"action":"{action}","orderId":"{order_id}","detail":"{detail}"}}"# ); let event = EventStoreBuilder::new() .channel("orders.lifecycle") .metadata(action) .body(body.into_bytes()) .build(); let result = client.send_event_store(event).await?; println!("Stored [{action}]: {order_id} (ID: {})", result.id); tokio::time::sleep(Duration::from_millis(200)).await; } client.close().await?; Ok(()) } ``` ```ruby title="order_publisher.rb" require 'kubemq' client = KubeMQ::PubSubClient.new(address: 'localhost:50000', client_id: 'order-publisher') events = [ ['order.created', 'ORD-1001', 'New order, total=$149.99'], ['order.paid', 'ORD-1001', 'Payment confirmed'], ['order.picked', 'ORD-1001', 'Items picked'], ['order.shipped', 'ORD-1001', 'Shipped via FedEx'], ['order.delivered', 'ORD-1001', 'Delivered'] ] events.each do |action, order_id, detail| body = { action: action, orderId: order_id, detail: detail }.to_json msg = KubeMQ::PubSub::EventStoreMessage.new( channel: 'orders.lifecycle', metadata: action, body: body ) result = client.send_event_store(msg) puts "Stored [#{action}]: #{order_id} (sent: #{result.sent})" sleep 0.2 end client.close ``` ```elixir title="order_publisher.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-publisher") events = [ {"order.created", "ORD-1001", "New order, total=$149.99"}, {"order.paid", "ORD-1001", "Payment confirmed"}, {"order.picked", "ORD-1001", "Items picked"}, {"order.shipped", "ORD-1001", "Shipped via FedEx"}, {"order.delivered", "ORD-1001", "Delivered"} ] for {action, order_id, detail} <- events do body = Jason.encode!(%{action: action, orderId: order_id, detail: detail}) event = KubeMQ.EventStore.new(channel: "orders.lifecycle", metadata: action, body: body) case KubeMQ.Client.send_event_store(client, event) do {:ok, result} -> IO.puts("Stored [#{action}]: #{order_id} (sent: #{result.sent})") {:error, err} -> IO.puts("Store failed: #{err.message}") end Process.sleep(200) end KubeMQ.Client.close(client) ``` ### Subscribe to Full History (StartFromFirst) [#subscribe-to-full-history-startfromfirst] The audit dashboard connects after events are stored and replays the full history. ```go title="audit_dashboard.go" sub, err := client.SubscribeToEventsStore(ctx, "orders.lifecycle", "", kubemq.StartFromFirst(), kubemq.WithOnEvent(func(event *kubemq.Event) { fmt.Printf("[Audit] seq=%d action=%s body=%s\n", event.Sequence, event.Metadata, string(event.Body)) }), kubemq.WithOnError(func(err error) { log.Println("[Audit] Error:", err) }), ) ``` ```python title="audit_dashboard.py" client.subscribe_to_events_store( subscription=EventsStoreSubscription( channel="orders.lifecycle", start_position=EventStoreStartPosition.StartFromFirst, on_receive_event_callback=lambda e: print( f"[Audit] seq={e.sequence} action={e.metadata} " f"body={e.body.decode('utf-8')}" ), on_error_callback=lambda e: print(f"[Audit] Error: {e}"), ), cancel=CancellationToken(), ) ``` ```typescript title="audit_dashboard.ts" client.subscribeToEventsStore({ channel: 'orders.lifecycle', startPosition: EventStoreStartPosition.StartFromFirst, onEvent: (msg) => console.log( `[Audit] seq=${msg.sequence} action=${msg.metadata} ` + `body=${new TextDecoder().decode(msg.body)}` ), onError: (err) => console.error('[Audit] Error:', err.message), }); ``` ```java title="AuditDashboard.java" client.subscribeToEventsStore(EventsStoreSubscription.builder() .channel("orders.lifecycle") .startPosition(EventStoreStartPosition.StartFromFirst) .onReceiveEventCallback(event -> System.out.printf("[Audit] seq=%d action=%s body=%s%n", event.getSequence(), event.getMetadata(), new String(event.getBody()))) .onErrorCallback(err -> System.err.println("[Audit] Error: " + err.getMessage())) .build()); ``` ```csharp title="AuditDashboard.cs" await foreach (var msg in client.SubscribeToEventsStoreAsync( new EventsStoreSubscription { Channel = "orders.lifecycle", StartPosition = EventStoreStartPosition.StartFromFirst, })) { Console.WriteLine($"[Audit] seq={msg.Sequence} action={msg.Metadata} " + $"body={Encoding.UTF8.GetString(msg.Body.Span)}"); } ``` ```kotlin title="AuditDashboard.kt" client.subscribeToEventsStore { channel = "orders.lifecycle" startPosition = StartPosition.StartFromFirst }.collect { msg -> println("[Audit] seq=${msg.sequence} action=${msg.metadata} body=${String(msg.body)}") } ``` ```cpp title="audit_dashboard.cc" client->SubscribeToEventsStore( "orders.lifecycle", "", kubemq::StartPosition::StartFromFirst, [](const kubemq::EventStoreReceived& msg) { std::cout << "[Audit] seq=" << msg.sequence() << " action=" << msg.metadata() << " body=" << msg.body() << std::endl; }, [](const std::string& err) { std::cerr << "[Audit] Error: " << err << std::endl; }); ``` ```rust title="audit_dashboard.rs" use kubemq::EventsStoreSubscription; let sub = client .subscribe_to_events_store( "orders.lifecycle", "", EventsStoreSubscription::StartFromFirst, |event| { Box::pin(async move { println!( "[Audit] seq={} action={} body={}", event.sequence, event.metadata, String::from_utf8_lossy(&event.body) ); }) }, None, ) .await?; ``` ```ruby title="audit_dashboard.rb" cancel = KubeMQ::CancellationToken.new sub = KubeMQ::PubSub::EventsStoreSubscription.new( channel: 'orders.lifecycle', start_position: KubeMQ::PubSub::EventStoreStartPosition::START_FROM_FIRST ) client.subscribe_to_events_store(sub, cancellation_token: cancel, on_error: lambda { |e| puts "[Audit] Error: #{e.message}" }) do |event| puts "[Audit] seq=#{event.sequence} action=#{event.metadata} body=#{event.body}" end ``` ```elixir title="audit_dashboard.exs" {:ok, sub} = KubeMQ.Client.subscribe_to_events_store(client, "orders.lifecycle", start_at: :start_from_first, on_event: fn event -> IO.puts( "[Audit] seq=#{event.sequence} action=#{event.metadata} body=#{event.body}" ) end, on_error: fn err -> IO.puts("[Audit] Error: #{err.message}") end ) ``` **Expected output** — all 5 events replayed: ```text [Audit] seq=1 action=order.created body={"action":"order.created","orderId":"ORD-1001",...} [Audit] seq=2 action=order.paid body={...} [Audit] seq=3 action=order.picked body={...} [Audit] seq=4 action=order.shipped body={...} [Audit] seq=5 action=order.delivered body={...} ``` ### Subscribe to New Events Only (StartNewOnly) [#subscribe-to-new-events-only-startnewonly] The real-time alerter receives only events published after it subscribes. ```go title="realtime_alerter.go" sub, err := client.SubscribeToEventsStore(ctx, "orders.lifecycle", "", kubemq.StartNewOnly(), kubemq.WithOnEvent(func(event *kubemq.Event) { fmt.Printf("[Alert] New: %s\n", string(event.Body)) }), kubemq.WithOnError(func(err error) { log.Println("[Alert] Error:", err) }), ) ``` ```python title="realtime_alerter.py" client.subscribe_to_events_store( subscription=EventsStoreSubscription( channel="orders.lifecycle", start_position=EventStoreStartPosition.StartNewOnly, on_receive_event_callback=lambda e: print( f"[Alert] New: {e.body.decode('utf-8')}" ), on_error_callback=lambda e: print(f"[Alert] Error: {e}"), ), cancel=CancellationToken(), ) ``` ```typescript title="realtime_alerter.ts" client.subscribeToEventsStore({ channel: 'orders.lifecycle', startPosition: EventStoreStartPosition.StartNewOnly, onEvent: (msg) => console.log(`[Alert] New: ${new TextDecoder().decode(msg.body)}`), onError: (err) => console.error('[Alert] Error:', err.message), }); ``` ```java title="RealtimeAlerter.java" client.subscribeToEventsStore(EventsStoreSubscription.builder() .channel("orders.lifecycle") .startPosition(EventStoreStartPosition.StartNewOnly) .onReceiveEventCallback(event -> System.out.printf("[Alert] New: %s%n", new String(event.getBody()))) .onErrorCallback(err -> System.err.println("[Alert] Error: " + err.getMessage())) .build()); ``` ```csharp title="RealtimeAlerter.cs" await foreach (var msg in client.SubscribeToEventsStoreAsync( new EventsStoreSubscription { Channel = "orders.lifecycle", StartPosition = EventStoreStartPosition.StartNewOnly, })) { Console.WriteLine($"[Alert] New: {Encoding.UTF8.GetString(msg.Body.Span)}"); } ``` ```kotlin title="RealtimeAlerter.kt" client.subscribeToEventsStore { channel = "orders.lifecycle" startPosition = StartPosition.StartNewOnly }.collect { msg -> println("[Alert] New: ${String(msg.body)}") } ``` ```cpp title="realtime_alerter.cc" client->SubscribeToEventsStore( "orders.lifecycle", "", kubemq::StartPosition::StartNewOnly, [](const kubemq::EventStoreReceived& msg) { std::cout << "[Alert] New: " << msg.body() << std::endl; }, [](const std::string& err) { std::cerr << "[Alert] Error: " << err << std::endl; }); ``` ```rust title="realtime_alerter.rs" use kubemq::EventsStoreSubscription; let sub = client .subscribe_to_events_store( "orders.lifecycle", "", EventsStoreSubscription::StartNewOnly, |event| { Box::pin(async move { println!("[Alert] New: {}", String::from_utf8_lossy(&event.body)); }) }, None, ) .await?; ``` ```ruby title="realtime_alerter.rb" cancel = KubeMQ::CancellationToken.new sub = KubeMQ::PubSub::EventsStoreSubscription.new( channel: 'orders.lifecycle', start_position: KubeMQ::PubSub::EventStoreStartPosition::START_NEW_ONLY ) client.subscribe_to_events_store(sub, cancellation_token: cancel, on_error: lambda { |e| puts "[Alert] Error: #{e.message}" }) do |event| puts "[Alert] New: #{event.body}" end ``` ```elixir title="realtime_alerter.exs" {:ok, sub} = KubeMQ.Client.subscribe_to_events_store(client, "orders.lifecycle", start_at: :start_new_only, on_event: fn event -> IO.puts("[Alert] New: #{event.body}") end, on_error: fn err -> IO.puts("[Alert] Error: #{err.message}") end ) ``` This subscriber receives nothing from the 5 previously stored events, but will receive any new events published after subscribing. ## Key Concepts [#key-concepts] ### Persistence Guarantee [#persistence-guarantee] Events Store writes events to the underlying store before acknowledging the publish. The publish call returns after the message is confirmed stored, providing **at-least-once** delivery semantics to subscribers. ### Durable Subscriptions [#durable-subscriptions] Each Events Store subscription creates a **durable name** based on the channel and group: ```text DurableName = "{channel}-{group}" ``` If a subscriber disconnects and reconnects with the same durable name, the store resumes delivery from the last acknowledged position, regardless of the `StartPosition` specified. ### Events vs Events Store [#events-vs-events-store] | Feature | Events | Events Store | | -------------------------------- | ---------------- | ------------------------- | | Persistence | No (memory only) | Yes (disk-backed) | | Late subscriber receives history | No | Yes (via start positions) | | Delivery guarantee | At-most-once | At-least-once | | Wildcards | Yes | No | Sequence numbers are assigned per channel. Different channels have independent sequences starting from 1. ## Next Steps [#next-steps] * Learn all [replay strategies](/learn/events-store/tutorials/replay-events) in detail * Scale processing with [consumer groups](/learn/events-store/tutorials/consumer-groups) * Implement [event sourcing](/learn/events-store/tutorials/event-sourcing) patterns * Configure [retention policies](/learn/events-store/how-to/configure-retention) # Replay Events from Any Point (/learn/events-store/tutorials/replay-events) Events Store supports six subscription start positions that control where a subscriber begins reading from the event stream. This tutorial demonstrates each replay strategy with practical examples. ## Subscription Start Positions [#subscription-start-positions] *Each start position drops a subscriber at a different point in the stored stream: `StartFromFirst` rewinds to seq=1, `StartAtSequence` resumes at a chosen offset, `StartFromLast` catches the latest, and `StartNewOnly` ignores history and waits for new events.* | Start Position | Enum Value | Description | | ------------------ | ---------- | ------------------------------------------------------ | | `StartNewOnly` | 1 | Only events published **after** subscribing | | `StartFromFirst` | 2 | Replay **all** events from the beginning | | `StartFromLast` | 3 | Start from the **most recent** stored event | | `StartAtSequence` | 4 | Start from a **specific sequence number** | | `StartAtTime` | 5 | Start from a **specific timestamp** (Unix nanoseconds) | | `StartAtTimeDelta` | 6 | Start from **N seconds ago** | ## Prerequisites [#prerequisites] * KubeMQ server running on `localhost:50000` * SDK installed ([Getting Started](/learn/events-store/getting-started)) ## Step-by-Step [#step-by-step] ### Seed the Event Stream [#seed-the-event-stream] Publish 10 order events to create a history for replay. ```go title="seed_orders.go" package main import ( "context" "fmt" "log" "time" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() for i := 1; i <= 10; i++ { body := fmt.Sprintf(`{"orderId":"ORD-%04d","status":"created","total":%.2f}`, i, float64(i)*29.99) result, err := client.SendEventStore(ctx, kubemq.NewEvent(). SetChannel("orders.events"). SetBody([]byte(body)), ) if err != nil { log.Fatal(err) } log.Printf("Stored seq=%s: ORD-%04d", result.EventID, i) time.Sleep(1 * time.Second) } } ``` ```python title="seed_orders.py" import json import time from kubemq import PubSubClient, EventStoreMessage with PubSubClient(address="localhost:50000") as client: for i in range(1, 11): body = json.dumps({"orderId": f"ORD-{i:04d}", "status": "created", "total": round(i * 29.99, 2)}) result = client.publish_event_store( EventStoreMessage(channel="orders.events", body=body.encode("utf-8")) ) print(f"Stored: ORD-{i:04d}") time.sleep(1) ``` ```typescript title="seed_orders.ts" import { KubeMQClient, createEventStoreMessage } from 'kubemq-js'; const client = await KubeMQClient.create({ address: 'localhost:50000' }); for (let i = 1; i <= 10; i++) { await client.sendEventStore( createEventStoreMessage({ channel: 'orders.events', body: JSON.stringify({ orderId: `ORD-${String(i).padStart(4, '0')}`, status: 'created', total: +(i * 29.99).toFixed(2), }), }) ); console.log(`Stored: ORD-${String(i).padStart(4, '0')}`); await new Promise((r) => setTimeout(r, 1000)); } ``` ```java title="SeedOrders.java" PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId("seeder") .build(); for (int i = 1; i <= 10; i++) { String body = String.format( "{\"orderId\":\"ORD-%04d\",\"status\":\"created\",\"total\":%.2f}", i, i * 29.99); client.sendEventsStoreMessage( EventStoreMessage.builder() .channel("orders.events") .body(body.getBytes()) .build()); System.out.printf("Stored: ORD-%04d%n", i); Thread.sleep(1000); } client.close(); ``` ```csharp title="SeedOrders.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); for (var i = 1; i <= 10; i++) { var body = $"{{\"orderId\":\"ORD-{i:D4}\",\"status\":\"created\",\"total\":{i * 29.99:F2}}}"; await client.SendEventStoreAsync(new EventStoreMessage { Channel = "orders.events", Body = Encoding.UTF8.GetBytes(body), }); Console.WriteLine($"Stored: ORD-{i:D4}"); await Task.Delay(1000); } ``` ```kotlin title="SeedOrders.kt" val client = KubeMQClient.pubSub { address = "localhost:50000" clientId = "seeder" } client.use { for (i in 1..10) { val body = """{"orderId":"ORD-${"%04d".format(i)}","status":"created","total":${"%.2f".format(i * 29.99)}}""" client.sendEventStore(eventStoreMessage { channel = "orders.events" this.body = body.toByteArray() }) println("Stored: ORD-${"%04d".format(i)}") delay(1000) } } ``` ```cpp title="seed_orders.cc" kubemq::ClientOptions options; options.set_address("localhost", 50000); options.set_client_id("seeder"); auto client = kubemq::Client::Create(options).value(); for (int i = 1; i <= 10; ++i) { kubemq::EventStoreMessage msg; msg.set_channel("orders.events"); msg.set_body("{\"orderId\":\"ORD-" + std::to_string(i) + "\",\"status\":\"created\"}"); client->SendEventStore(msg); std::cout << "Stored: ORD-" << i << std::endl; std::this_thread::sleep_for(std::chrono::seconds(1)); } ``` ```rust title="seed_orders.rs" use kubemq::prelude::*; use kubemq::EventStoreBuilder; use std::time::Duration; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; for i in 1..=10 { let body = format!( r#"{{"orderId":"ORD-{:04}","status":"created","total":{:.2}}}"#, i, i as f64 * 29.99 ); let event = EventStoreBuilder::new() .channel("orders.events") .body(body.into_bytes()) .build(); let result = client.send_event_store(event).await?; println!("Stored seq=ORD-{:04}: sent={}", i, result.sent); tokio::time::sleep(Duration::from_secs(1)).await; } client.close().await?; Ok(()) } ``` ```ruby title="seed_orders.rb" require 'kubemq' client = KubeMQ::PubSubClient.new(address: 'localhost:50000', client_id: 'seeder') (1..10).each do |i| body = { orderId: format('ORD-%04d', i), status: 'created', total: (i * 29.99).round(2) }.to_json result = client.send_event_store( KubeMQ::PubSub::EventStoreMessage.new(channel: 'orders.events', body: body) ) puts "Stored: ORD-#{format('%04d', i)} sent=#{result.sent}" sleep 1 end client.close ``` ```elixir title="seed_orders.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "seeder") for i <- 1..10 do body = Jason.encode!(%{ orderId: "ORD-#{String.pad_leading(Integer.to_string(i), 4, "0")}", status: "created", total: Float.round(i * 29.99, 2) }) {:ok, _} = KubeMQ.Client.send_event_store( client, KubeMQ.EventStore.new(channel: "orders.events", body: body) ) IO.puts("Stored: ORD-#{String.pad_leading(Integer.to_string(i), 4, "0")}") Process.sleep(1_000) end KubeMQ.Client.close(client) ``` ### Replay from Beginning (StartFromFirst) [#replay-from-beginning-startfromfirst] Receive every event ever stored in the channel, starting from sequence 1. ```go title="replay_from_first.go" sub, err := client.SubscribeToEventsStore(ctx, "orders.events", "", kubemq.StartFromFirst(), kubemq.WithOnEvent(func(event *kubemq.Event) { fmt.Printf("[FromFirst] seq=%d body=%s\n", event.Sequence, string(event.Body)) }), kubemq.WithOnError(func(err error) { log.Println(err) }), ) ``` ```python title="replay_from_first.py" client.subscribe_to_events_store( subscription=EventsStoreSubscription( channel="orders.events", start_position=EventStoreStartPosition.StartFromFirst, on_receive_event_callback=lambda e: print( f"[FromFirst] seq={e.sequence} body={e.body.decode('utf-8')}" ), on_error_callback=lambda e: print(f"Error: {e}"), ), cancel=CancellationToken(), ) ``` ```typescript title="replay_from_first.ts" client.subscribeToEventsStore({ channel: 'orders.events', startPosition: EventStoreStartPosition.StartFromFirst, onEvent: (msg) => console.log(`[FromFirst] seq=${msg.sequence} body=${new TextDecoder().decode(msg.body)}`), onError: (err) => console.error(err.message), }); ``` ```java title="ReplayFromFirst.java" client.subscribeToEventsStore(EventsStoreSubscription.builder() .channel("orders.events") .startPosition(EventStoreStartPosition.StartFromFirst) .onReceiveEventCallback(event -> System.out.printf("[FromFirst] seq=%d body=%s%n", event.getSequence(), new String(event.getBody()))) .onErrorCallback(err -> System.err.println(err.getMessage())) .build()); ``` ```csharp title="ReplayFromFirst.cs" await foreach (var msg in client.SubscribeToEventsStoreAsync( new EventsStoreSubscription { Channel = "orders.events", StartPosition = EventStoreStartPosition.StartFromFirst, })) { Console.WriteLine($"[FromFirst] seq={msg.Sequence} body={Encoding.UTF8.GetString(msg.Body.Span)}"); } ``` ```kotlin title="ReplayFromFirst.kt" client.subscribeToEventsStore { channel = "orders.events" startPosition = StartPosition.StartFromFirst }.collect { msg -> println("[FromFirst] seq=${msg.sequence} body=${String(msg.body)}") } ``` ```cpp title="replay_from_first.cc" client->SubscribeToEventsStore("orders.events", "", kubemq::StartPosition::StartFromFirst, [](const kubemq::EventStoreReceived& msg) { std::cout << "[FromFirst] seq=" << msg.sequence() << " body=" << msg.body() << std::endl; }, [](const std::string& err) { std::cerr << err << std::endl; }); ``` ```rust title="replay_from_first.rs" use kubemq::EventsStoreSubscription; let sub = client .subscribe_to_events_store( "orders.events", "", EventsStoreSubscription::StartFromFirst, |event| { Box::pin(async move { println!( "[FromFirst] seq={} body={}", event.sequence, String::from_utf8_lossy(&event.body) ); }) }, None, ) .await?; ``` ```ruby title="replay_from_first.rb" cancel = KubeMQ::CancellationToken.new sub = KubeMQ::PubSub::EventsStoreSubscription.new( channel: 'orders.events', start_position: KubeMQ::PubSub::EventStoreStartPosition::START_FROM_FIRST ) client.subscribe_to_events_store(sub, cancellation_token: cancel, on_error: ->(e) { puts "Error: #{e.message}" }) do |event| puts "[FromFirst] seq=#{event.sequence} body=#{event.body}" end ``` ```elixir title="replay_from_first.exs" {:ok, sub} = KubeMQ.Client.subscribe_to_events_store(client, "orders.events", start_at: :start_from_first, on_event: fn event -> IO.puts("[FromFirst] seq=#{event.sequence} body=#{event.body}") end ) ``` **Output:** receives all 10 events (seq 1-10). ### Start from a Specific Sequence (StartAtSequence) [#start-from-a-specific-sequence-startatsequence] Resume from sequence number 7 to receive events 7-10 plus any new events. ```go title="replay_from_sequence.go" sub, err := client.SubscribeToEventsStore(ctx, "orders.events", "", kubemq.StartAtSequence(7), kubemq.WithOnEvent(func(event *kubemq.Event) { fmt.Printf("[AtSeq7] seq=%d body=%s\n", event.Sequence, string(event.Body)) }), kubemq.WithOnError(func(err error) { log.Println(err) }), ) ``` ```python title="replay_from_sequence.py" client.subscribe_to_events_store( subscription=EventsStoreSubscription( channel="orders.events", start_position=EventStoreStartPosition.StartAtSequence, start_position_value=7, on_receive_event_callback=lambda e: print( f"[AtSeq7] seq={e.sequence} body={e.body.decode('utf-8')}" ), on_error_callback=lambda e: print(f"Error: {e}"), ), cancel=CancellationToken(), ) ``` ```typescript title="replay_from_sequence.ts" client.subscribeToEventsStore({ channel: 'orders.events', startPosition: EventStoreStartPosition.StartAtSequence, startPositionValue: 7, onEvent: (msg) => console.log(`[AtSeq7] seq=${msg.sequence} body=${new TextDecoder().decode(msg.body)}`), onError: (err) => console.error(err.message), }); ``` ```java title="ReplayFromSequence.java" client.subscribeToEventsStore(EventsStoreSubscription.builder() .channel("orders.events") .startPosition(EventStoreStartPosition.StartAtSequence) .startPositionValue(7) .onReceiveEventCallback(event -> System.out.printf("[AtSeq7] seq=%d body=%s%n", event.getSequence(), new String(event.getBody()))) .onErrorCallback(err -> System.err.println(err.getMessage())) .build()); ``` ```csharp title="ReplayFromSequence.cs" await foreach (var msg in client.SubscribeToEventsStoreAsync( new EventsStoreSubscription { Channel = "orders.events", StartPosition = EventStoreStartPosition.StartAtSequence, StartPositionValue = 7, })) { Console.WriteLine($"[AtSeq7] seq={msg.Sequence} body={Encoding.UTF8.GetString(msg.Body.Span)}"); } ``` ```kotlin title="ReplayFromSequence.kt" client.subscribeToEventsStore { channel = "orders.events" startPosition = StartPosition.StartAtSequence startPositionValue = 7 }.collect { msg -> println("[AtSeq7] seq=${msg.sequence} body=${String(msg.body)}") } ``` ```cpp title="replay_from_sequence.cc" client->SubscribeToEventsStore("orders.events", "", kubemq::StartPosition::StartAtSequence, 7, [](const kubemq::EventStoreReceived& msg) { std::cout << "[AtSeq7] seq=" << msg.sequence() << " body=" << msg.body() << std::endl; }, [](const std::string& err) { std::cerr << err << std::endl; }); ``` ```rust title="replay_from_sequence.rs" use kubemq::EventsStoreSubscription; let sub = client .subscribe_to_events_store( "orders.events", "", EventsStoreSubscription::StartAtSequence(7), |event| { Box::pin(async move { println!( "[AtSeq7] seq={} body={}", event.sequence, String::from_utf8_lossy(&event.body) ); }) }, None, ) .await?; ``` ```ruby title="replay_from_sequence.rb" cancel = KubeMQ::CancellationToken.new sub = KubeMQ::PubSub::EventsStoreSubscription.new( channel: 'orders.events', start_position: KubeMQ::PubSub::EventStoreStartPosition::START_AT_SEQUENCE, start_position_value: 7 ) client.subscribe_to_events_store(sub, cancellation_token: cancel, on_error: ->(e) { puts "Error: #{e.message}" }) do |event| puts "[AtSeq7] seq=#{event.sequence} body=#{event.body}" end ``` ```elixir title="replay_from_sequence.exs" {:ok, sub} = KubeMQ.Client.subscribe_to_events_store(client, "orders.events", start_at: {:start_at_sequence, 7}, on_event: fn event -> IO.puts("[AtSeq7] seq=#{event.sequence} body=#{event.body}") end ) ``` **Output:** receives events with seq 7, 8, 9, 10, then waits for new events. ### Start from a Time Delta (StartAtTimeDelta) [#start-from-a-time-delta-startattimedelta] Receive events published in the last 30 seconds. ```go title="replay_time_delta.go" sub, err := client.SubscribeToEventsStore(ctx, "orders.events", "", kubemq.StartAtTimeDelta(30), kubemq.WithOnEvent(func(event *kubemq.Event) { fmt.Printf("[TimeDelta30s] seq=%d body=%s\n", event.Sequence, string(event.Body)) }), kubemq.WithOnError(func(err error) { log.Println(err) }), ) ``` ```python title="replay_time_delta.py" client.subscribe_to_events_store( subscription=EventsStoreSubscription( channel="orders.events", start_position=EventStoreStartPosition.StartAtTimeDelta, start_position_value=30, on_receive_event_callback=lambda e: print( f"[TimeDelta30s] seq={e.sequence} body={e.body.decode('utf-8')}" ), on_error_callback=lambda e: print(f"Error: {e}"), ), cancel=CancellationToken(), ) ``` ```typescript title="replay_time_delta.ts" client.subscribeToEventsStore({ channel: 'orders.events', startPosition: EventStoreStartPosition.StartAtTimeDelta, startPositionValue: 30, onEvent: (msg) => console.log(`[TimeDelta30s] seq=${msg.sequence} body=${new TextDecoder().decode(msg.body)}`), onError: (err) => console.error(err.message), }); ``` ```java title="ReplayTimeDelta.java" client.subscribeToEventsStore(EventsStoreSubscription.builder() .channel("orders.events") .startPosition(EventStoreStartPosition.StartAtTimeDelta) .startPositionValue(30) .onReceiveEventCallback(event -> System.out.printf("[TimeDelta30s] seq=%d body=%s%n", event.getSequence(), new String(event.getBody()))) .onErrorCallback(err -> System.err.println(err.getMessage())) .build()); ``` ```csharp title="ReplayTimeDelta.cs" await foreach (var msg in client.SubscribeToEventsStoreAsync( new EventsStoreSubscription { Channel = "orders.events", StartPosition = EventStoreStartPosition.StartAtTimeDelta, StartPositionValue = 30, })) { Console.WriteLine($"[TimeDelta30s] seq={msg.Sequence} body={Encoding.UTF8.GetString(msg.Body.Span)}"); } ``` ```kotlin title="ReplayTimeDelta.kt" client.subscribeToEventsStore { channel = "orders.events" startPosition = StartPosition.StartAtTimeDelta startPositionValue = 30 }.collect { msg -> println("[TimeDelta30s] seq=${msg.sequence} body=${String(msg.body)}") } ``` ```cpp title="replay_time_delta.cc" client->SubscribeToEventsStore("orders.events", "", kubemq::StartPosition::StartAtTimeDelta, 30, [](const kubemq::EventStoreReceived& msg) { std::cout << "[TimeDelta30s] seq=" << msg.sequence() << " body=" << msg.body() << std::endl; }, [](const std::string& err) { std::cerr << err << std::endl; }); ``` ```rust title="replay_time_delta.rs" use kubemq::EventsStoreSubscription; use std::time::Duration; let sub = client .subscribe_to_events_store( "orders.events", "", EventsStoreSubscription::StartAtTimeDelta(Duration::from_secs(30)), |event| { Box::pin(async move { println!( "[TimeDelta30s] seq={} body={}", event.sequence, String::from_utf8_lossy(&event.body) ); }) }, None, ) .await?; ``` ```ruby title="replay_time_delta.rb" cancel = KubeMQ::CancellationToken.new sub = KubeMQ::PubSub::EventsStoreSubscription.new( channel: 'orders.events', start_position: KubeMQ::PubSub::EventStoreStartPosition::START_AT_TIME_DELTA, start_position_value: 30 # seconds ) client.subscribe_to_events_store(sub, cancellation_token: cancel, on_error: ->(e) { puts "Error: #{e.message}" }) do |event| puts "[TimeDelta30s] seq=#{event.sequence} body=#{event.body}" end ``` ```elixir title="replay_time_delta.exs" # The Elixir SDK expresses the time delta in milliseconds (30s = 30_000ms). {:ok, sub} = KubeMQ.Client.subscribe_to_events_store(client, "orders.events", start_at: {:start_at_time_delta, 30_000}, on_event: fn event -> IO.puts("[TimeDelta30s] seq=#{event.sequence} body=#{event.body}") end ) ``` **Output:** receives only events published within the last 30 seconds. ### Start from the Last Event (StartFromLast) [#start-from-the-last-event-startfromlast] Receive the most recently stored event, then all new events. ```go title="replay_from_last.go" sub, err := client.SubscribeToEventsStore(ctx, "orders.events", "", kubemq.StartFromLast(), kubemq.WithOnEvent(func(event *kubemq.Event) { fmt.Printf("[FromLast] seq=%d body=%s\n", event.Sequence, string(event.Body)) }), kubemq.WithOnError(func(err error) { log.Println(err) }), ) ``` ```python title="replay_from_last.py" client.subscribe_to_events_store( subscription=EventsStoreSubscription( channel="orders.events", start_position=EventStoreStartPosition.StartFromLast, on_receive_event_callback=lambda e: print( f"[FromLast] seq={e.sequence} body={e.body.decode('utf-8')}" ), on_error_callback=lambda e: print(f"Error: {e}"), ), cancel=CancellationToken(), ) ``` ```typescript title="replay_from_last.ts" client.subscribeToEventsStore({ channel: 'orders.events', startPosition: EventStoreStartPosition.StartFromLast, onEvent: (msg) => console.log(`[FromLast] seq=${msg.sequence} body=${new TextDecoder().decode(msg.body)}`), onError: (err) => console.error(err.message), }); ``` ```java title="ReplayFromLast.java" client.subscribeToEventsStore(EventsStoreSubscription.builder() .channel("orders.events") .startPosition(EventStoreStartPosition.StartFromLast) .onReceiveEventCallback(event -> System.out.printf("[FromLast] seq=%d body=%s%n", event.getSequence(), new String(event.getBody()))) .onErrorCallback(err -> System.err.println(err.getMessage())) .build()); ``` ```csharp title="ReplayFromLast.cs" await foreach (var msg in client.SubscribeToEventsStoreAsync( new EventsStoreSubscription { Channel = "orders.events", StartPosition = EventStoreStartPosition.StartFromLast, })) { Console.WriteLine($"[FromLast] seq={msg.Sequence} body={Encoding.UTF8.GetString(msg.Body.Span)}"); } ``` ```kotlin title="ReplayFromLast.kt" client.subscribeToEventsStore { channel = "orders.events" startPosition = StartPosition.StartFromLast }.collect { msg -> println("[FromLast] seq=${msg.sequence} body=${String(msg.body)}") } ``` ```cpp title="replay_from_last.cc" client->SubscribeToEventsStore("orders.events", "", kubemq::StartPosition::StartFromLast, [](const kubemq::EventStoreReceived& msg) { std::cout << "[FromLast] seq=" << msg.sequence() << " body=" << msg.body() << std::endl; }, [](const std::string& err) { std::cerr << err << std::endl; }); ``` ```rust title="replay_from_last.rs" use kubemq::EventsStoreSubscription; let sub = client .subscribe_to_events_store( "orders.events", "", EventsStoreSubscription::StartFromLast, |event| { Box::pin(async move { println!( "[FromLast] seq={} body={}", event.sequence, String::from_utf8_lossy(&event.body) ); }) }, None, ) .await?; ``` ```ruby title="replay_from_last.rb" cancel = KubeMQ::CancellationToken.new sub = KubeMQ::PubSub::EventsStoreSubscription.new( channel: 'orders.events', start_position: KubeMQ::PubSub::EventStoreStartPosition::START_FROM_LAST ) client.subscribe_to_events_store(sub, cancellation_token: cancel, on_error: ->(e) { puts "Error: #{e.message}" }) do |event| puts "[FromLast] seq=#{event.sequence} body=#{event.body}" end ``` ```elixir title="replay_from_last.exs" {:ok, sub} = KubeMQ.Client.subscribe_to_events_store(client, "orders.events", start_at: :start_from_last, on_event: fn event -> IO.puts("[FromLast] seq=#{event.sequence} body=#{event.body}") end ) ``` **Output:** receives event with seq=10 (the last stored), then waits for new events. ### Start from a Specific Time (StartAtTime) [#start-from-a-specific-time-startattime] Receive events stored at or after a specific Unix timestamp in nanoseconds. ```go title="replay_from_time.go" targetTime := time.Now().Add(-5 * time.Minute).UnixNano() sub, err := client.SubscribeToEventsStore(ctx, "orders.events", "", kubemq.StartAtTime(targetTime), kubemq.WithOnEvent(func(event *kubemq.Event) { fmt.Printf("[AtTime] seq=%d body=%s\n", event.Sequence, string(event.Body)) }), kubemq.WithOnError(func(err error) { log.Println(err) }), ) ``` ```python title="replay_from_time.py" import time as time_mod target_time = int((time_mod.time() - 300) * 1_000_000_000) # 5 min ago in nanos client.subscribe_to_events_store( subscription=EventsStoreSubscription( channel="orders.events", start_position=EventStoreStartPosition.StartAtTime, start_position_value=target_time, on_receive_event_callback=lambda e: print( f"[AtTime] seq={e.sequence} body={e.body.decode('utf-8')}" ), on_error_callback=lambda e: print(f"Error: {e}"), ), cancel=CancellationToken(), ) ``` ```typescript title="replay_from_time.ts" const targetTime = (Date.now() - 5 * 60 * 1000) * 1_000_000; // 5 min ago in nanos client.subscribeToEventsStore({ channel: 'orders.events', startPosition: EventStoreStartPosition.StartAtTime, startPositionValue: targetTime, onEvent: (msg) => console.log(`[AtTime] seq=${msg.sequence} body=${new TextDecoder().decode(msg.body)}`), onError: (err) => console.error(err.message), }); ``` ```java title="ReplayFromTime.java" long targetTime = (System.currentTimeMillis() - 300_000) * 1_000_000L; // 5 min ago client.subscribeToEventsStore(EventsStoreSubscription.builder() .channel("orders.events") .startPosition(EventStoreStartPosition.StartAtTime) .startPositionValue(targetTime) .onReceiveEventCallback(event -> System.out.printf("[AtTime] seq=%d body=%s%n", event.getSequence(), new String(event.getBody()))) .onErrorCallback(err -> System.err.println(err.getMessage())) .build()); ``` ```csharp title="ReplayFromTime.cs" var targetTime = (DateTimeOffset.UtcNow.AddMinutes(-5)).ToUnixTimeMilliseconds() * 1_000_000; await foreach (var msg in client.SubscribeToEventsStoreAsync( new EventsStoreSubscription { Channel = "orders.events", StartPosition = EventStoreStartPosition.StartAtTime, StartPositionValue = targetTime, })) { Console.WriteLine($"[AtTime] seq={msg.Sequence} body={Encoding.UTF8.GetString(msg.Body.Span)}"); } ``` ```kotlin title="ReplayFromTime.kt" val targetTime = (System.currentTimeMillis() - 300_000) * 1_000_000L client.subscribeToEventsStore { channel = "orders.events" startPosition = StartPosition.StartAtTime startPositionValue = targetTime }.collect { msg -> println("[AtTime] seq=${msg.sequence} body=${String(msg.body)}") } ``` ```cpp title="replay_from_time.cc" auto now = std::chrono::system_clock::now(); auto target = now - std::chrono::minutes(5); auto nanos = std::chrono::duration_cast( target.time_since_epoch()).count(); client->SubscribeToEventsStore("orders.events", "", kubemq::StartPosition::StartAtTime, nanos, [](const kubemq::EventStoreReceived& msg) { std::cout << "[AtTime] seq=" << msg.sequence() << " body=" << msg.body() << std::endl; }, [](const std::string& err) { std::cerr << err << std::endl; }); ``` ```rust title="replay_from_time.rs" use kubemq::EventsStoreSubscription; use std::time::{Duration, SystemTime}; // The Rust SDK takes a SystemTime directly (5 minutes ago). let target_time = SystemTime::now() - Duration::from_secs(5 * 60); let sub = client .subscribe_to_events_store( "orders.events", "", EventsStoreSubscription::StartAtTime(target_time), |event| { Box::pin(async move { println!( "[AtTime] seq={} body={}", event.sequence, String::from_utf8_lossy(&event.body) ); }) }, None, ) .await?; ``` ```ruby title="replay_from_time.rb" cancel = KubeMQ::CancellationToken.new # The Ruby SDK takes a Unix timestamp in seconds (5 minutes ago). target_time = Time.now.to_i - 5 * 60 sub = KubeMQ::PubSub::EventsStoreSubscription.new( channel: 'orders.events', start_position: KubeMQ::PubSub::EventStoreStartPosition::START_AT_TIME, start_position_value: target_time ) client.subscribe_to_events_store(sub, cancellation_token: cancel, on_error: ->(e) { puts "Error: #{e.message}" }) do |event| puts "[AtTime] seq=#{event.sequence} body=#{event.body}" end ``` ```elixir title="replay_from_time.exs" # The Elixir SDK takes a Unix timestamp in seconds (5 minutes ago). target_time = System.system_time(:second) - 5 * 60 {:ok, sub} = KubeMQ.Client.subscribe_to_events_store(client, "orders.events", start_at: {:start_at_time, target_time}, on_event: fn event -> IO.puts("[AtTime] seq=#{event.sequence} body=#{event.body}") end ) ``` **Output:** receives events stored at or after the target timestamp. ## Choosing the Right Position [#choosing-the-right-position] | Scenario | Recommended Position | Why | | -------------------------------------- | -------------------- | ----------------------------------- | | Rebuild application state from scratch | `StartFromFirst` | Replays the entire history | | Resume after a known checkpoint | `StartAtSequence` | Picks up exactly where you left off | | Recover recent events after downtime | `StartAtTimeDelta` | Replays from a time window | | Monitor live activity only | `StartNewOnly` | Ignores history, lowest overhead | | Catch the latest event then go live | `StartFromLast` | Quick sync then real-time | | Point-in-time recovery | `StartAtTime` | Precise timestamp-based replay | `StartAtSequence` requires a value greater than 0. `StartAtTime` requires a Unix timestamp in nanoseconds greater than 0. `StartAtTimeDelta` requires a positive number of seconds. Providing 0 or negative values results in a validation error. ## Durable Replay Behavior [#durable-replay-behavior] When a subscriber with a durable name reconnects, the start position parameter is **ignored** after the first connection. The store resumes from the last acknowledged sequence for that durable name. The durable name is `{channel}-{group}`. To force a fresh replay, use a different `group` name or a different `clientId`. ## Next Steps [#next-steps] * Implement [event sourcing](/learn/events-store/tutorials/event-sourcing) using replay from first * Scale processing with [consumer groups](/learn/events-store/tutorials/consumer-groups) * Configure [retention policies](/learn/events-store/how-to/configure-retention) to manage storage * See [Events Store Reference](/learn/events-store/reference) for all subscription parameters # Stream Publishing (/learn/events-store/tutorials/stream-publishing) This tutorial uses **Events Store** — persistent, replayable event storage with a per-event acknowledgment. For the ephemeral, fire-and-forget version of stream publishing, see [Events stream publishing](/learn/events/tutorials/stream-publishing). Stream publishing uses a bidirectional gRPC stream to send persistent events at high throughput. Unlike single-event publishing, the stream keeps a persistent connection open and returns an acknowledgment for every stored event, making it ideal for bulk ingestion scenarios. *One persistent stream carries many events to the store; each is acknowledged back to the publisher as it is persisted.* ## Stream vs Single Send [#stream-vs-single-send] | Aspect | Single Send | Stream Send | | -------------- | --------------------- | ----------------------------------- | | Connection | New request per event | Persistent bidirectional stream | | Throughput | Moderate | High (batch-friendly) | | Acknowledgment | Per-call response | Async ack per event on stream | | Use case | Occasional publishes | Bulk ingestion, high-frequency data | ## Prerequisites [#prerequisites] * KubeMQ server running on `localhost:50000` * SDK installed ([Getting Started](/learn/events-store/getting-started)) ## Step-by-Step [#step-by-step] ### Open a Stream and Publish Events [#open-a-stream-and-publish-events] Open a persistent stream connection and send order events at high throughput. ```go title="stream_publisher.go" package main import ( "context" "fmt" "log" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() stream, err := client.SendEventsStoreStream(ctx, kubemq.WithOnResult(func(result *kubemq.EventResult) { log.Printf("Ack: ID=%s Sent=%v", result.EventID, result.Sent) if result.Error != "" { log.Printf("Error: %s", result.Error) } }), kubemq.WithOnStreamError(func(err error) { log.Printf("Stream error: %v", err) }), ) if err != nil { log.Fatal(err) } defer stream.Close() for i := 1; i <= 1000; i++ { body := fmt.Sprintf(`{"orderId":"ORD-%05d","item":"widget","qty":%d}`, i, i%10+1) stream.Send(kubemq.NewEvent(). SetChannel("orders.ingest"). SetBody([]byte(body)), ) } log.Println("Sent 1000 events via stream") } ``` ```python title="stream_publisher.py" import json from kubemq import PubSubClient, EventStoreMessage def on_result(result): if result.error: print(f"Error storing: {result.error}") else: print(f"Ack: ID={result.id} Sent={result.sent}") with PubSubClient(address="localhost:50000") as client: stream = client.open_events_store_stream( on_result_callback=on_result, on_error_callback=lambda e: print(f"Stream error: {e}"), ) for i in range(1, 1001): body = json.dumps({"orderId": f"ORD-{i:05d}", "item": "widget", "qty": i % 10 + 1}) stream.send(EventStoreMessage( channel="orders.ingest", body=body.encode("utf-8"), )) print("Sent 1000 events via stream") stream.close() ``` ```typescript title="stream_publisher.ts" import { KubeMQClient, createEventStoreMessage } from 'kubemq-js'; const client = await KubeMQClient.create({ address: 'localhost:50000' }); const stream = client.createEventStoreStream(); stream.onError((err) => console.error('Stream error:', err.message)); for (let i = 1; i <= 1000; i++) { // send() resolves once the server confirms persistence, rejects on failure await stream.send( createEventStoreMessage({ channel: 'orders.ingest', body: JSON.stringify({ orderId: `ORD-${String(i).padStart(5, '0')}`, item: 'widget', qty: (i % 10) + 1 }), }) ); } console.log('Sent 1000 events via stream'); stream.close(); await client.close(); ``` ```java title="StreamPublisher.java" PubSubClient client = PubSubClient.builder() .address("localhost:50000") .clientId("stream-publisher") .build(); EventStoreStream stream = client.openEventsStoreStream( result -> { if (result.getError() != null && !result.getError().isEmpty()) { System.err.printf("Error storing: %s%n", result.getError()); } else { System.out.printf("Ack: ID=%s%n", result.getId()); } }, err -> System.err.printf("Stream error: %s%n", err.getMessage()) ); for (int i = 1; i <= 1000; i++) { String body = String.format( "{\"orderId\":\"ORD-%05d\",\"item\":\"widget\",\"qty\":%d}", i, i % 10 + 1); stream.send(EventStoreMessage.builder() .channel("orders.ingest") .body(body.getBytes()) .build()); } System.out.println("Sent 1000 events via stream"); stream.close(); client.close(); ``` ```csharp title="StreamPublisher.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); var stream = client.OpenEventsStoreStream( onResult: result => { if (!string.IsNullOrEmpty(result.Error)) Console.Error.WriteLine($"Error: {result.Error}"); else Console.WriteLine($"Ack: ID={result.Id}"); }, onError: err => Console.Error.WriteLine($"Stream error: {err.Message}") ); for (var i = 1; i <= 1000; i++) { var body = $"{{\"orderId\":\"ORD-{i:D5}\",\"item\":\"widget\",\"qty\":{i % 10 + 1}}}"; stream.Send(new EventStoreMessage { Channel = "orders.ingest", Body = Encoding.UTF8.GetBytes(body), }); } Console.WriteLine("Sent 1000 events via stream"); stream.Close(); ``` ```kotlin title="StreamPublisher.kt" val client = KubeMQClient.pubSub { address = "localhost:50000" clientId = "stream-publisher" } client.use { val stream = client.openEventsStoreStream( onResult = { result -> if (result.error.isNotEmpty()) { System.err.println("Error: ${result.error}") } else { println("Ack: ID=${result.id}") } }, onError = { err -> System.err.println("Stream error: $err") } ) for (i in 1..1000) { stream.send(eventStoreMessage { channel = "orders.ingest" body = """{"orderId":"ORD-${"%05d".format(i)}","item":"widget","qty":${i % 10 + 1}}""".toByteArray() }) } println("Sent 1000 events via stream") stream.close() } ``` ```cpp title="stream_publisher.cc" kubemq::ClientOptions options; options.set_address("localhost", 50000); options.set_client_id("stream-publisher"); auto client = kubemq::Client::Create(options).value(); auto stream = client->OpenEventsStoreStream( [](const kubemq::EventResult& result) { if (!result.error().empty()) { std::cerr << "Error: " << result.error() << std::endl; } else { std::cout << "Ack: ID=" << result.id() << std::endl; } }, [](const std::string& err) { std::cerr << "Stream error: " << err << std::endl; }); for (int i = 1; i <= 1000; ++i) { kubemq::EventStoreMessage msg; msg.set_channel("orders.ingest"); msg.set_body("{\"orderId\":\"ORD-" + std::to_string(i) + "\",\"item\":\"widget\"}"); stream->Send(msg); } std::cout << "Sent 1000 events via stream" << std::endl; stream->Close(); ``` ```rust title="stream_publisher.rs" use kubemq::prelude::*; use kubemq::EventStoreBuilder; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; let mut stream = client.send_event_store_stream().await?; for i in 1..=1000 { let event = EventStoreBuilder::new() .channel("orders.ingest") .body(format!(r#"{{"orderId":"ORD-{:05}","item":"widget","qty":{}}}"#, i, i % 10 + 1).into_bytes()) .build(); stream.send(event).await?; } println!("Sent 1000 events via stream"); // Drain per-event results; `sent == false` indicates a failed store while let Ok(result) = stream.results().try_recv() { if !result.sent { eprintln!("Error: id={}, error={}", result.event_id, result.error); } } stream.close(); client.close().await?; Ok(()) } ``` ```ruby title="stream_publisher.rb" require 'kubemq' client = KubeMQ::PubSubClient.new(address: 'localhost:50000', client_id: 'stream-publisher') sender = client.create_events_store_sender (1..1000).each do |i| msg = KubeMQ::PubSub::EventStoreMessage.new( channel: 'orders.ingest', body: %({"orderId":"ORD-#{format('%05d', i)}","item":"widget","qty":#{i % 10 + 1}}) ) result = sender.publish(msg) warn "Error: id=#{result.id}" unless result.sent end puts 'Sent 1000 events via stream' sender.close client.close ``` ```elixir title="stream_publisher.exs" # The Elixir SDK does not expose a dedicated stream sender; send_event_store/2 # reuses the underlying connection and returns a confirmation per event. {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "stream-publisher") for i <- 1..1000 do event = KubeMQ.EventStore.new( channel: "orders.ingest", body: ~s({"orderId":"ORD-#{:io_lib.format("~5..0B", [i]) |> to_string()}","item":"widget","qty":#{rem(i, 10) + 1}}) ) case KubeMQ.Client.send_event_store(client, event) do {:ok, %{sent: false} = result} -> IO.puts(:stderr, "Error storing: #{result.error}") {:error, err} -> IO.puts(:stderr, "Send error: #{err.message}") _ok -> :ok end end IO.puts("Sent 1000 events via stream") KubeMQ.Client.close(client) ``` ### Handle Acknowledgments [#handle-acknowledgments] Each event sent through the stream receives an asynchronous acknowledgment. Monitor the `onResult` callback for confirmation or errors. Failed events can be retried. ```text Ack: ID=abc123 Sent=true Ack: ID=def456 Sent=true Error storing: storage has reached to 96.5% utilization and is not allowed ``` ## Best Practices [#best-practices] | Practice | Recommendation | | ------------------ | ------------------------------------------------------------------------------- | | Batch size | Send events as fast as the stream allows; backpressure is handled automatically | | Error handling | Log failed acks and retry with exponential backoff | | Stream lifecycle | Reuse a single stream for the lifetime of your publisher process | | Channel separation | Use separate channels for different event types to enable independent replay | Stream publishing is available only over gRPC (port 50000). The REST API does not support streaming. ## Next Steps [#next-steps] * Learn about [event sourcing](/learn/events-store/tutorials/event-sourcing) patterns * Configure [retention policies](/learn/events-store/how-to/configure-retention) for high-volume streams * Monitor [storage utilization](/learn/events-store/how-to/monitor-storage) thresholds * See the [Events Store Reference](/learn/events-store/reference) for stream protocol details # Ack, Nack & Requeue (/learn/queues/tutorials/ack-nack-requeue) ## What You Will Build [#what-you-will-build] A consumer that demonstrates all three message settlement options: * **Ack** — message processed successfully, remove from queue * **Nack** — processing failed, return message to queue for redelivery * **Requeue** — redirect message to a different channel *The consumer settles each message exactly one way: ack removes it, nack returns it to the same queue, requeue moves it to another channel.* ## Prerequisites [#prerequisites] * KubeMQ server running on `localhost:50000` * SDK installed ([Getting Started](../getting-started)) ## Acknowledge (Ack) [#acknowledge-ack] Use `ack` when processing completes successfully. The message is permanently removed from the queue. ```go title="ack_example.go" resp, err := client.PollQueue(ctx, &kubemq.PollRequest{ Channel: "orders", MaxItems: 1, WaitTimeoutSeconds: 5, }) if err != nil { log.Fatal(err) } for _, m := range resp.Messages { fmt.Printf("Processing: %s\n", string(m.Message.Body)) } if err := resp.AckAll(); err != nil { log.Fatal(err) } fmt.Println("Messages acknowledged and removed") ``` ```python title="ack_example.py" response = client.receive_queue_messages( channel="orders", max_messages=1, wait_timeout_in_seconds=5, ) for msg in response.messages: print(f"Processing: {msg.body.decode('utf-8')}") msg.ack() print("Message acknowledged and removed") ``` ```typescript title="ack_example.ts" const messages = await client.receiveQueueMessages({ channel: 'orders', maxMessages: 1, waitTimeoutSeconds: 5, }); for (const msg of messages) { console.log('Processing:', new TextDecoder().decode(msg.body)); await msg.ack(); console.log('Message acknowledged and removed'); } ``` ```java title="AckExample.java" ReceiveQueueMessagesResponse response = client.receiveQueueMessages( ReceiveQueueMessagesRequest.builder() .channel("orders") .maxMessages(1) .waitTimeoutSeconds(5) .build()); for (QueueMessageReceived msg : response.getMessages()) { System.out.println("Processing: " + new String(msg.getBody())); msg.ack(); System.out.println("Message acknowledged and removed"); } ``` ```csharp title="AckExample.cs" var response = await client.ReceiveQueueMessagesAsync(new ReceiveQueueMessagesRequest { Channel = "orders", MaxMessages = 1, WaitTimeoutSeconds = 5, }); foreach (var msg in response.Messages) { Console.WriteLine($"Processing: {Encoding.UTF8.GetString(msg.Body.Span)}"); await msg.AckAsync(); Console.WriteLine("Message acknowledged and removed"); } ``` ```kotlin title="AckExample.kt" val response = client.receiveQueueMessages( channel = "orders", maxMessages = 1, waitTimeoutSeconds = 5 ) for (msg in response.messages) { println("Processing: ${String(msg.body)}") msg.ack() println("Message acknowledged and removed") } ``` ```cpp title="ack_example.cpp" auto response = client.receiveQueueMessages("orders", 1, 5); for (const auto& msg : response.messages) { std::cout << "Processing: " << msg.body << std::endl; msg.ack(); std::cout << "Message acknowledged and removed" << std::endl; } ``` ```rust title="ack_example.rs" let mut receiver = client.new_queue_downstream_receiver().await?; let response = receiver .poll(PollRequest { channel: "orders".to_string(), max_items: 1, wait_timeout_seconds: 5, auto_ack: false, }) .await?; for msg in &response.messages { println!("Processing: {}", String::from_utf8_lossy(&msg.message.body)); msg.ack().await?; println!("Message acknowledged and removed"); } ``` ```ruby title="ack_example.rb" receiver = client.create_downstream_receiver request = KubeMQ::Queues::QueuePollRequest.new(channel: 'orders', max_items: 1, wait_timeout: 5) response = receiver.poll(request) response.messages.each do |msg| puts "Processing: #{msg.body}" msg.ack puts 'Message acknowledged and removed' end ``` ```elixir title="ack_example.exs" {:ok, poll} = KubeMQ.Client.poll_queue(client, channel: "orders", max_items: 1, wait_timeout: 5_000) Enum.each(poll.messages, fn msg -> IO.puts("Processing: #{msg.body}") end) {:ok, _} = KubeMQ.PollResponse.ack_all(poll) IO.puts("Messages acknowledged and removed") ``` ## Negative Acknowledge (Nack) [#negative-acknowledge-nack] Use `nack` when processing fails and you want the message returned to the queue for another delivery attempt. ```go title="nack_example.go" resp, err := client.PollQueue(ctx, &kubemq.PollRequest{ Channel: "orders", MaxItems: 1, WaitTimeoutSeconds: 5, }) if err != nil { log.Fatal(err) } for _, m := range resp.Messages { fmt.Printf("Attempting: %s\n", string(m.Message.Body)) // Simulate a processing failure } if err := resp.NAckAll(); err != nil { log.Fatal(err) } fmt.Println("Messages nacked — returned to queue") ``` ```python title="nack_example.py" response = client.receive_queue_messages( channel="orders", max_messages=1, wait_timeout_in_seconds=5, ) for msg in response.messages: print(f"Attempting: {msg.body.decode('utf-8')}") # Simulate a processing failure msg.nack() print("Message nacked — returned to queue") ``` ```typescript title="nack_example.ts" const messages = await client.receiveQueueMessages({ channel: 'orders', maxMessages: 1, waitTimeoutSeconds: 5, }); for (const msg of messages) { console.log('Attempting:', new TextDecoder().decode(msg.body)); // Simulate a processing failure await msg.nack(); console.log('Message nacked — returned to queue'); } ``` ```java title="NackExample.java" ReceiveQueueMessagesResponse response = client.receiveQueueMessages( ReceiveQueueMessagesRequest.builder() .channel("orders") .maxMessages(1) .waitTimeoutSeconds(5) .build()); for (QueueMessageReceived msg : response.getMessages()) { System.out.println("Attempting: " + new String(msg.getBody())); msg.nack(); System.out.println("Message nacked — returned to queue"); } ``` ```csharp title="NackExample.cs" var response = await client.ReceiveQueueMessagesAsync(new ReceiveQueueMessagesRequest { Channel = "orders", MaxMessages = 1, WaitTimeoutSeconds = 5, }); foreach (var msg in response.Messages) { Console.WriteLine($"Attempting: {Encoding.UTF8.GetString(msg.Body.Span)}"); await msg.NAckAsync(); Console.WriteLine("Message nacked — returned to queue"); } ``` ```kotlin title="NackExample.kt" val response = client.receiveQueueMessages( channel = "orders", maxMessages = 1, waitTimeoutSeconds = 5 ) for (msg in response.messages) { println("Attempting: ${String(msg.body)}") msg.nack() println("Message nacked — returned to queue") } ``` ```cpp title="nack_example.cpp" auto response = client.receiveQueueMessages("orders", 1, 5); for (const auto& msg : response.messages) { std::cout << "Attempting: " << msg.body << std::endl; msg.nack(); std::cout << "Message nacked — returned to queue" << std::endl; } ``` ```rust title="nack_example.rs" let mut receiver = client.new_queue_downstream_receiver().await?; let response = receiver .poll(PollRequest { channel: "orders".to_string(), max_items: 1, wait_timeout_seconds: 5, auto_ack: false, }) .await?; for msg in &response.messages { println!("Attempting: {}", String::from_utf8_lossy(&msg.message.body)); // Simulate a processing failure msg.nack().await?; println!("Message nacked — returned to queue"); } ``` ```ruby title="nack_example.rb" receiver = client.create_downstream_receiver request = KubeMQ::Queues::QueuePollRequest.new(channel: 'orders', max_items: 1, wait_timeout: 5) response = receiver.poll(request) response.messages.each do |msg| puts "Attempting: #{msg.body}" # Simulate a processing failure msg.nack puts 'Message nacked — returned to queue' end ``` ```elixir title="nack_example.exs" {:ok, poll} = KubeMQ.Client.poll_queue(client, channel: "orders", max_items: 1, wait_timeout: 5_000) Enum.each(poll.messages, fn msg -> IO.puts("Attempting: #{msg.body}") end) # Simulate a processing failure {:ok, _} = KubeMQ.PollResponse.nack_all(poll) IO.puts("Messages nacked — returned to queue") ``` ## Requeue to Another Channel [#requeue-to-another-channel] Use `requeue` to redirect a message to a different queue channel — useful for priority routing, error isolation, or manual review workflows. ```go title="requeue_example.go" resp, err := client.PollQueue(ctx, &kubemq.PollRequest{ Channel: "orders", MaxItems: 1, WaitTimeoutSeconds: 5, }) if err != nil { log.Fatal(err) } for _, m := range resp.Messages { fmt.Printf("Rerouting: %s\n", string(m.Message.Body)) } if err := resp.ReQueueAll("orders.manual-review"); err != nil { log.Fatal(err) } fmt.Println("Messages requeued to 'orders.manual-review'") ``` ```python title="requeue_example.py" response = client.receive_queue_messages( channel="orders", max_messages=1, wait_timeout_in_seconds=5, ) for msg in response.messages: print(f"Rerouting: {msg.body.decode('utf-8')}") msg.requeue("orders.manual-review") print("Message requeued to 'orders.manual-review'") ``` ```typescript title="requeue_example.ts" const messages = await client.receiveQueueMessages({ channel: 'orders', maxMessages: 1, waitTimeoutSeconds: 5, }); for (const msg of messages) { console.log('Rerouting:', new TextDecoder().decode(msg.body)); await msg.requeue('orders.manual-review'); console.log("Message requeued to 'orders.manual-review'"); } ``` ```java title="RequeueExample.java" ReceiveQueueMessagesResponse response = client.receiveQueueMessages( ReceiveQueueMessagesRequest.builder() .channel("orders") .maxMessages(1) .waitTimeoutSeconds(5) .build()); for (QueueMessageReceived msg : response.getMessages()) { System.out.println("Rerouting: " + new String(msg.getBody())); msg.requeue("orders.manual-review"); System.out.println("Message requeued to 'orders.manual-review'"); } ``` ```csharp title="RequeueExample.cs" var response = await client.ReceiveQueueMessagesAsync(new ReceiveQueueMessagesRequest { Channel = "orders", MaxMessages = 1, WaitTimeoutSeconds = 5, }); foreach (var msg in response.Messages) { Console.WriteLine($"Rerouting: {Encoding.UTF8.GetString(msg.Body.Span)}"); await msg.ReQueueAsync("orders.manual-review"); Console.WriteLine("Message requeued to 'orders.manual-review'"); } ``` ```kotlin title="RequeueExample.kt" val response = client.receiveQueueMessages( channel = "orders", maxMessages = 1, waitTimeoutSeconds = 5 ) for (msg in response.messages) { println("Rerouting: ${String(msg.body)}") msg.requeue("orders.manual-review") println("Message requeued to 'orders.manual-review'") } ``` ```cpp title="requeue_example.cpp" auto response = client.receiveQueueMessages("orders", 1, 5); for (const auto& msg : response.messages) { std::cout << "Rerouting: " << msg.body << std::endl; msg.requeue("orders.manual-review"); std::cout << "Message requeued to 'orders.manual-review'" << std::endl; } ``` ```rust title="requeue_example.rs" let mut receiver = client.new_queue_downstream_receiver().await?; let response = receiver .poll(PollRequest { channel: "orders".to_string(), max_items: 1, wait_timeout_seconds: 5, auto_ack: false, }) .await?; for msg in &response.messages { println!("Rerouting: {}", String::from_utf8_lossy(&msg.message.body)); msg.re_queue("orders.manual-review").await?; println!("Message requeued to 'orders.manual-review'"); } ``` ```ruby title="requeue_example.rb" receiver = client.create_downstream_receiver request = KubeMQ::Queues::QueuePollRequest.new(channel: 'orders', max_items: 1, wait_timeout: 5) response = receiver.poll(request) response.messages.each do |msg| puts "Rerouting: #{msg.body}" end # Requeue is settled at the poll-response level, not per message response.requeue_all(channel: 'orders.manual-review') puts "Messages requeued to 'orders.manual-review'" ``` ```elixir title="requeue_example.exs" {:ok, poll} = KubeMQ.Client.poll_queue(client, channel: "orders", max_items: 1, wait_timeout: 5_000) Enum.each(poll.messages, fn msg -> IO.puts("Rerouting: #{msg.body}") end) # Elixir settles a poll transaction as a whole — requeue_all moves them to the target channel {:ok, _} = KubeMQ.PollResponse.requeue_all(poll, "orders.manual-review") IO.puts("Messages requeued to 'orders.manual-review'") ``` ## When to Use Each Option [#when-to-use-each-option] | Option | Effect | Use When | | ----------- | ----------------------------------- | --------------------------------------------------------- | | **Ack** | Remove from queue permanently | Processing succeeded | | **Nack** | Return to same queue for redelivery | Transient failure (network, timeout) | | **Requeue** | Move to a different queue channel | Needs manual review, priority routing, or error isolation | If a message is nacked repeatedly and exceeds the `maxReceiveCount`, it is automatically routed to the dead letter queue (if configured). See [Dead Letter Queue](./dead-letter-queue) for details. ## Next Steps [#next-steps] # Batch Operations (/learn/queues/tutorials/batch-operations) ## What You Will Build [#what-you-will-build] A batch sender that publishes multiple order messages in one call, and a batch receiver that pulls and acknowledges all of them at once. *One request carries the whole batch in each direction — fewer round-trips, higher throughput.* ## Prerequisites [#prerequisites] * KubeMQ server running on `localhost:50000` * SDK installed ([Getting Started](../getting-started)) ## Steps [#steps] ### Batch Send [#batch-send] Send multiple messages in a single request. Each message can have its own body, metadata, tags, and policy. ```go title="batch_sender.go" orders := []struct { ID string Total float64 }{ {"ORD-001", 29.99}, {"ORD-002", 149.50}, {"ORD-003", 75.00}, {"ORD-004", 210.00}, {"ORD-005", 15.99}, } var messages []*kubemq.QueueMessage for _, order := range orders { body := fmt.Sprintf(`{"orderId":"%s","total":%.2f}`, order.ID, order.Total) messages = append(messages, kubemq.NewQueueMessage(). SetChannel("orders.batch"). SetBody([]byte(body)). SetMetadata("order.created"), ) } results, err := client.SendQueueMessages(ctx, messages) if err != nil { log.Fatal(err) } for _, r := range results { fmt.Printf("Sent: id=%s, error=%v\n", r.MessageID, r.IsError) } ``` ```python title="batch_sender.py" import json orders = [ {"orderId": "ORD-001", "total": 29.99}, {"orderId": "ORD-002", "total": 149.50}, {"orderId": "ORD-003", "total": 75.00}, {"orderId": "ORD-004", "total": 210.00}, {"orderId": "ORD-005", "total": 15.99}, ] messages = [ QueueMessage( channel="orders.batch", body=json.dumps(order).encode(), metadata="order.created", ) for order in orders ] results = client.send_queue_messages(messages) for r in results: print(f"Sent: id={r.id}, error={r.is_error}") ``` ```typescript title="batch_sender.ts" const orders = [ { orderId: 'ORD-001', total: 29.99 }, { orderId: 'ORD-002', total: 149.5 }, { orderId: 'ORD-003', total: 75.0 }, { orderId: 'ORD-004', total: 210.0 }, { orderId: 'ORD-005', total: 15.99 }, ]; const messages = orders.map((order) => createQueueMessage({ channel: 'orders.batch', body: JSON.stringify(order), metadata: 'order.created', }), ); const results = await client.sendQueueMessagesBatch(messages); for (const r of results) { console.log(`Sent: id=${r.messageId}, error=${r.isError}`); } ``` ```java title="BatchSender.java" List messages = List.of( QueueMessage.builder().channel("orders.batch") .body("{\"orderId\":\"ORD-001\",\"total\":29.99}".getBytes()) .metadata("order.created").build(), QueueMessage.builder().channel("orders.batch") .body("{\"orderId\":\"ORD-002\",\"total\":149.50}".getBytes()) .metadata("order.created").build(), QueueMessage.builder().channel("orders.batch") .body("{\"orderId\":\"ORD-003\",\"total\":75.00}".getBytes()) .metadata("order.created").build() ); List results = client.sendQueueMessages(messages); for (SendQueueMessageResult r : results) { System.out.printf("Sent: id=%s, error=%b%n", r.getMessageId(), r.isError()); } ``` ```csharp title="BatchSender.cs" var messages = new[] { new QueueMessage { Channel = "orders.batch", Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-001\",\"total\":29.99}"), Metadata = "order.created" }, new QueueMessage { Channel = "orders.batch", Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-002\",\"total\":149.50}"), Metadata = "order.created" }, new QueueMessage { Channel = "orders.batch", Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-003\",\"total\":75.00}"), Metadata = "order.created" }, }; var results = await client.SendQueueMessagesBatchAsync(messages); foreach (var r in results) { Console.WriteLine($"Sent: id={r.MessageId}, error={r.IsError}"); } ``` ```kotlin title="BatchSender.kt" val messages = listOf( QueueMessage(channel = "orders.batch", body = """{"orderId":"ORD-001","total":29.99}""".toByteArray(), metadata = "order.created"), QueueMessage(channel = "orders.batch", body = """{"orderId":"ORD-002","total":149.50}""".toByteArray(), metadata = "order.created"), QueueMessage(channel = "orders.batch", body = """{"orderId":"ORD-003","total":75.00}""".toByteArray(), metadata = "order.created"), ) val results = client.sendQueueMessages(messages) for (r in results) { println("Sent: id=${r.messageId}, error=${r.isError}") } ``` ```cpp title="batch_sender.cpp" std::vector messages; for (const auto& [id, total] : std::vector>{ {"ORD-001", 29.99}, {"ORD-002", 149.50}, {"ORD-003", 75.00}}) { kubemq::QueueMessage msg; msg.channel = "orders.batch"; msg.body = "{\"orderId\":\"" + id + "\",\"total\":" + std::to_string(total) + "}"; msg.metadata = "order.created"; messages.push_back(msg); } auto results = client.sendQueueMessages(messages); for (const auto& r : results) { std::cout << "Sent: id=" << r.messageId << std::endl; } ``` ```rust title="batch_sender.rs" let channel = "orders.batch"; let orders = [ ("ORD-001", 29.99), ("ORD-002", 149.50), ("ORD-003", 75.00), ("ORD-004", 210.00), ("ORD-005", 15.99), ]; let messages: Vec = orders .iter() .map(|(id, total)| { let body = format!(r#"{{"orderId":"{}","total":{}}}"#, id, total); QueueMessageBuilder::new() .channel(channel) .body(body.into_bytes()) .metadata("order.created") .build() }) .collect(); let results = client.send_queue_messages(messages).await?; for r in &results { println!("Sent: id={}, error={}", r.message_id, r.is_error); } ``` ```ruby title="batch_sender.rb" channel = 'orders.batch' orders = [ { orderId: 'ORD-001', total: 29.99 }, { orderId: 'ORD-002', total: 149.50 }, { orderId: 'ORD-003', total: 75.00 }, { orderId: 'ORD-004', total: 210.00 }, { orderId: 'ORD-005', total: 15.99 }, ] messages = orders.map do |order| KubeMQ::Queues::QueueMessage.new( channel: channel, metadata: 'order.created', body: order.to_json, ) end results = client.send_queue_messages_batch(messages) results.each do |r| puts "Sent: id=#{r.id}, error?=#{r.error?}" end ``` ```elixir title="batch_sender.exs" channel = "orders.batch" orders = [ %{orderId: "ORD-001", total: 29.99}, %{orderId: "ORD-002", total: 149.50}, %{orderId: "ORD-003", total: 75.00}, %{orderId: "ORD-004", total: 210.00}, %{orderId: "ORD-005", total: 15.99} ] messages = for order <- orders do KubeMQ.QueueMessage.new( channel: channel, body: Jason.encode!(order), metadata: "order.created" ) end {:ok, result} = KubeMQ.Client.send_queue_messages(client, messages) Enum.each(result.results, fn r -> IO.puts("Sent: id=#{r.message_id}, error=#{r.is_error}") end) ``` ### Batch Receive [#batch-receive] Receive multiple messages in one call. The `maxMessages` parameter controls how many messages to fetch. ```go title="batch_receiver.go" resp, err := client.PollQueue(ctx, &kubemq.PollRequest{ Channel: "orders.batch", MaxItems: 10, WaitTimeoutSeconds: 5, AutoAck: false, }) if err != nil { log.Fatal(err) } fmt.Printf("Received %d messages\n", len(resp.Messages)) for _, m := range resp.Messages { fmt.Printf(" %s: %s\n", m.Message.MessageID, string(m.Message.Body)) } if err := resp.AckAll(); err != nil { log.Fatal(err) } fmt.Println("All messages acknowledged") ``` ```python title="batch_receiver.py" response = client.receive_queue_messages( channel="orders.batch", max_messages=10, wait_timeout_in_seconds=5, ) print(f"Received {len(response.messages)} messages") for msg in response.messages: print(f" {msg.id}: {msg.body.decode('utf-8')}") msg.ack() print("All messages acknowledged") ``` ```typescript title="batch_receiver.ts" const messages = await client.receiveQueueMessages({ channel: 'orders.batch', maxMessages: 10, waitTimeoutSeconds: 5, }); console.log(`Received ${messages.length} messages`); for (const msg of messages) { console.log(` ${msg.messageId}: ${new TextDecoder().decode(msg.body)}`); await msg.ack(); } console.log('All messages acknowledged'); ``` ```java title="BatchReceiver.java" ReceiveQueueMessagesResponse response = client.receiveQueueMessages( ReceiveQueueMessagesRequest.builder() .channel("orders.batch") .maxMessages(10) .waitTimeoutSeconds(5) .build()); System.out.printf("Received %d messages%n", response.getMessages().size()); for (QueueMessageReceived msg : response.getMessages()) { System.out.printf(" %s: %s%n", msg.getMessageId(), new String(msg.getBody())); msg.ack(); } System.out.println("All messages acknowledged"); ``` ```csharp title="BatchReceiver.cs" var response = await client.ReceiveQueueMessagesAsync(new ReceiveQueueMessagesRequest { Channel = "orders.batch", MaxMessages = 10, WaitTimeoutSeconds = 5, }); Console.WriteLine($"Received {response.Messages.Count} messages"); foreach (var msg in response.Messages) { Console.WriteLine($" {msg.MessageId}: {Encoding.UTF8.GetString(msg.Body.Span)}"); await msg.AckAsync(); } Console.WriteLine("All messages acknowledged"); ``` ```kotlin title="BatchReceiver.kt" val response = client.receiveQueueMessages( channel = "orders.batch", maxMessages = 10, waitTimeoutSeconds = 5 ) println("Received ${response.messages.size} messages") for (msg in response.messages) { println(" ${msg.messageId}: ${String(msg.body)}") msg.ack() } println("All messages acknowledged") ``` ```cpp title="batch_receiver.cpp" auto response = client.receiveQueueMessages("orders.batch", 10, 5); std::cout << "Received " << response.messages.size() << " messages" << std::endl; for (const auto& msg : response.messages) { std::cout << " " << msg.messageId << ": " << msg.body << std::endl; msg.ack(); } std::cout << "All messages acknowledged" << std::endl; ``` ```rust title="batch_receiver.rs" // The simple queues client acks on receive; the fourth arg is auto-requeue. let messages = client .receive_queue_messages("orders.batch", 10, 5, false) .await?; println!("Received {} messages", messages.len()); for m in &messages { println!(" {}: {}", m.id, String::from_utf8_lossy(&m.body)); } println!("All messages acknowledged"); ``` ```ruby title="batch_receiver.rb" # The simple queues client acks on receive; clear any leftover with ack_all. messages = client.receive_queue_messages( channel: 'orders.batch', max_messages: 10, wait_timeout_seconds: 5, ) puts "Received #{messages.size} messages" messages.each do |m| puts " #{m.id}: #{m.body}" end client.ack_all_queue_messages(channel: 'orders.batch', wait_timeout_seconds: 5) puts 'All messages acknowledged' ``` ```elixir title="batch_receiver.exs" # The simple queues client acks on receive; clear any leftover with ack_all. {:ok, result} = KubeMQ.Client.receive_queue_messages(client, "orders.batch", max_messages: 10, wait_timeout: 5_000 ) IO.puts("Received #{result.messages_received} messages") Enum.each(result.messages, fn m -> IO.puts(" #{m.message_id}: #{m.body}") end) KubeMQ.Client.ack_all_queue_messages(client, "orders.batch", wait_timeout: 5_000) IO.puts("All messages acknowledged") ``` ## Performance: Single vs Batch [#performance-single-vs-batch] | Approach | Throughput | Network Calls | Use Case | | -------------- | ---------- | ------------- | ------------------------ | | Single send | Lower | 1 per message | Real-time, low volume | | Batch send | Higher | 1 per batch | Bulk import, high volume | | Single receive | Lower | 1 per poll | Interactive processing | | Batch receive | Higher | 1 per poll | Background workers | The maximum batch size is controlled by the server setting `MaxNumberOfMessages` (default: 1,024 messages per request). ## Next Steps [#next-steps] # Dead Letter Queue (/learn/queues/tutorials/dead-letter-queue) ## How DLQ Works [#how-dlq-works] When a message is nacked or its visibility timeout expires repeatedly, the `receiveCount` increments. Once it exceeds `maxReceiveCount`, KubeMQ automatically routes the message to the dead letter queue channel. *Messages that exhaust `maxReceiveCount` are routed from the source queue to the dead letter queue for inspection and recovery.* ## Prerequisites [#prerequisites] * KubeMQ server running on `localhost:50000` * SDK installed ([Getting Started](../getting-started)) ## Steps [#steps] ### Send a Message with DLQ Policy [#send-a-message-with-dlq-policy] Set `maxReceiveCount` and `maxReceiveQueue` on the message to enable dead letter routing. ```go title="dlq_sender.go" msg := kubemq.NewQueueMessage(). SetChannel("orders"). SetBody([]byte(`{"orderId":"ORD-9001","total":250.00}`)). SetMaxReceiveCount(3). SetMaxReceiveQueue("orders.dlq") result, err := client.SendQueueMessage(ctx, msg) if err != nil { log.Fatal(err) } fmt.Printf("Sent with DLQ policy: id=%s\n", result.MessageID) ``` ```python title="dlq_sender.py" result = client.send_queue_message( QueueMessage( channel="orders", body=b'{"orderId":"ORD-9001","total":250.00}', max_receive_count=3, max_receive_queue="orders.dlq", ) ) print(f"Sent with DLQ policy: id={result.id}") ``` ```typescript title="dlq_sender.ts" const result = await client.sendQueueMessage( createQueueMessage({ channel: 'orders', body: JSON.stringify({ orderId: 'ORD-9001', total: 250.0 }), policy: { maxReceiveCount: 3, maxReceiveQueue: 'orders.dlq', }, }), ); console.log(`Sent with DLQ policy: id=${result.messageId}`); ``` ```java title="DlqSender.java" QueueMessage msg = QueueMessage.builder() .channel("orders") .body("{\"orderId\":\"ORD-9001\",\"total\":250.00}".getBytes()) .maxReceiveCount(3) .maxReceiveQueue("orders.dlq") .build(); SendQueueMessageResult result = client.sendQueueMessage(msg); System.out.printf("Sent with DLQ policy: id=%s%n", result.getMessageId()); ``` ```csharp title="DlqSender.cs" var result = await client.SendQueueMessageAsync(new QueueMessage { Channel = "orders", Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-9001\",\"total\":250.00}"), MaxReceiveCount = 3, MaxReceiveQueue = "orders.dlq" }); Console.WriteLine($"Sent with DLQ policy: id={result.MessageId}"); ``` ```kotlin title="DlqSender.kt" val result = client.sendQueueMessage(QueueMessage( channel = "orders", body = """{"orderId":"ORD-9001","total":250.00}""".toByteArray(), maxReceiveCount = 3, maxReceiveQueue = "orders.dlq" )) println("Sent with DLQ policy: id=${result.messageId}") ``` ```cpp title="dlq_sender.cpp" kubemq::QueueMessage msg; msg.channel = "orders"; msg.body = R"({"orderId":"ORD-9001","total":250.00})"; msg.maxReceiveCount = 3; msg.maxReceiveQueue = "orders.dlq"; auto result = client.sendQueueMessage(msg); std::cout << "Sent with DLQ policy: id=" << result.messageId << std::endl; ``` ```rust title="dlq_sender.rs" let msg = QueueMessageBuilder::new() .channel("orders") .body(br#"{"orderId":"ORD-9001","total":250.00}"#.to_vec()) .max_receive_count(3) .max_receive_queue("orders.dlq") .build(); let result = client.send_queue_message(msg).await?; println!("Sent with DLQ policy: id={}", result.message_id); ``` ```ruby title="dlq_sender.rb" policy = KubeMQ::Queues::QueueMessagePolicy.new( max_receive_count: 3, max_receive_queue: 'orders.dlq' ) msg = KubeMQ::Queues::QueueMessage.new( channel: 'orders', body: '{"orderId":"ORD-9001","total":250.00}', policy: policy ) client.send_queue_message(msg) puts 'Sent with DLQ policy: orders.dlq' ``` ```elixir title="dlq_sender.exs" msg = KubeMQ.QueueMessage.new( channel: "orders", body: ~s({"orderId":"ORD-9001","total":250.00}), policy: KubeMQ.QueuePolicy.new( max_receive_count: 3, max_receive_queue: "orders.dlq" ) ) {:ok, result} = KubeMQ.Client.send_queue_message(client, msg) IO.puts("Sent with DLQ policy: id=#{result.message_id}") ``` ### Simulate Failures [#simulate-failures] Receive and nack the message 3 times to trigger DLQ routing. ```go title="dlq_nacker.go" for attempt := 1; attempt <= 3; attempt++ { resp, err := client.PollQueue(ctx, &kubemq.PollRequest{ Channel: "orders", MaxItems: 1, WaitTimeoutSeconds: 5, }) if err != nil { log.Fatal(err) } for _, m := range resp.Messages { fmt.Printf("Attempt %d: receiveCount=%d\n", attempt, m.Message.Attributes.ReceiveCount) } resp.NAckAll() time.Sleep(time.Second) } fmt.Println("Message should now be in DLQ") ``` ```python title="dlq_nacker.py" import time for attempt in range(1, 4): response = client.receive_queue_messages( channel="orders", max_messages=1, wait_timeout_in_seconds=5, ) for msg in response.messages: print(f"Attempt {attempt}: receiveCount={msg.receive_count}") msg.nack() time.sleep(1) print("Message should now be in DLQ") ``` ```typescript title="dlq_nacker.ts" for (let attempt = 1; attempt <= 3; attempt++) { const messages = await client.receiveQueueMessages({ channel: 'orders', maxMessages: 1, waitTimeoutSeconds: 5, }); for (const msg of messages) { console.log(`Attempt ${attempt}: receiveCount=${msg.receiveCount}`); await msg.nack(); } await new Promise((r) => setTimeout(r, 1000)); } console.log('Message should now be in DLQ'); ``` ```java title="DlqNacker.java" for (int attempt = 1; attempt <= 3; attempt++) { ReceiveQueueMessagesResponse response = client.receiveQueueMessages( ReceiveQueueMessagesRequest.builder() .channel("orders") .maxMessages(1) .waitTimeoutSeconds(5) .build()); for (QueueMessageReceived msg : response.getMessages()) { System.out.printf("Attempt %d: receiveCount=%d%n", attempt, msg.getReceiveCount()); msg.nack(); } Thread.sleep(1000); } System.out.println("Message should now be in DLQ"); ``` ```csharp title="DlqNacker.cs" for (int attempt = 1; attempt <= 3; attempt++) { var response = await client.ReceiveQueueMessagesAsync(new ReceiveQueueMessagesRequest { Channel = "orders", MaxMessages = 1, WaitTimeoutSeconds = 5, }); foreach (var msg in response.Messages) { Console.WriteLine($"Attempt {attempt}: receiveCount={msg.ReceiveCount}"); await msg.NAckAsync(); } await Task.Delay(1000); } Console.WriteLine("Message should now be in DLQ"); ``` ```kotlin title="DlqNacker.kt" for (attempt in 1..3) { val response = client.receiveQueueMessages( channel = "orders", maxMessages = 1, waitTimeoutSeconds = 5 ) for (msg in response.messages) { println("Attempt $attempt: receiveCount=${msg.receiveCount}") msg.nack() } Thread.sleep(1000) } println("Message should now be in DLQ") ``` ```cpp title="dlq_nacker.cpp" for (int attempt = 1; attempt <= 3; attempt++) { auto response = client.receiveQueueMessages("orders", 1, 5); for (const auto& msg : response.messages) { std::cout << "Attempt " << attempt << ": receiveCount=" << msg.receiveCount << std::endl; msg.nack(); } std::this_thread::sleep_for(std::chrono::seconds(1)); } std::cout << "Message should now be in DLQ" << std::endl; ``` ```rust title="dlq_nacker.rs" let mut receiver = client.new_queue_downstream_receiver().await?; for attempt in 1..=3 { let poll = PollRequest { channel: "orders".to_string(), max_items: 1, wait_timeout_seconds: 5, auto_ack: false, }; let response = receiver.poll(poll).await?; println!("Attempt {}: {} messages", attempt, response.messages.len()); if !response.messages.is_empty() { response.nack_all().await?; } tokio::time::sleep(std::time::Duration::from_secs(1)).await; } println!("Message should now be in DLQ"); ``` ```ruby title="dlq_nacker.rb" receiver = client.create_downstream_receiver (1..3).each do |attempt| request = KubeMQ::Queues::QueuePollRequest.new( channel: 'orders', max_items: 1, wait_timeout: 5 ) response = receiver.poll(request) puts "Attempt #{attempt}: #{response.messages.size} messages" response.nack_all if response.messages.any? sleep 1 end puts 'Message should now be in DLQ' ``` ```elixir title="dlq_nacker.exs" for attempt <- 1..3 do case KubeMQ.Client.poll_queue(client, channel: "orders", max_items: 1, wait_timeout: 5_000 ) do {:ok, poll} when length(poll.messages) > 0 -> IO.puts("Attempt #{attempt}: #{length(poll.messages)} messages") :ok = KubeMQ.PollResponse.nack_all(poll) _ -> IO.puts("Attempt #{attempt}: no message") end Process.sleep(1_000) end IO.puts("Message should now be in DLQ") ``` ### Read from the Dead Letter Queue [#read-from-the-dead-letter-queue] The failed message now sits in the DLQ channel with routing metadata attached. ```go title="dlq_reader.go" resp, err := client.PollQueue(ctx, &kubemq.PollRequest{ Channel: "orders.dlq", MaxItems: 10, WaitTimeoutSeconds: 5, }) if err != nil { log.Fatal(err) } for _, m := range resp.Messages { fmt.Printf("DLQ message: %s\n", string(m.Message.Body)) fmt.Printf(" Rerouted from: %s\n", m.Message.Attributes.ReRoutedFromQueue) fmt.Printf(" Receive count: %d\n", m.Message.Attributes.ReceiveCount) } resp.AckAll() ``` ```python title="dlq_reader.py" response = client.receive_queue_messages( channel="orders.dlq", max_messages=10, wait_timeout_in_seconds=5, ) for msg in response.messages: print(f"DLQ message: {msg.body.decode('utf-8')}") print(f" Rerouted from: {msg.rerouted_from_queue}") print(f" Receive count: {msg.receive_count}") msg.ack() ``` ```typescript title="dlq_reader.ts" const dlqMessages = await client.receiveQueueMessages({ channel: 'orders.dlq', maxMessages: 10, waitTimeoutSeconds: 5, }); for (const msg of dlqMessages) { console.log('DLQ message:', new TextDecoder().decode(msg.body)); console.log(' Rerouted from:', msg.reRoutedFromQueue); console.log(' Receive count:', msg.receiveCount); await msg.ack(); } ``` ```java title="DlqReader.java" ReceiveQueueMessagesResponse dlqResponse = client.receiveQueueMessages( ReceiveQueueMessagesRequest.builder() .channel("orders.dlq") .maxMessages(10) .waitTimeoutSeconds(5) .build()); for (QueueMessageReceived msg : dlqResponse.getMessages()) { System.out.println("DLQ message: " + new String(msg.getBody())); System.out.println(" Rerouted from: " + msg.getReRoutedFromQueue()); System.out.println(" Receive count: " + msg.getReceiveCount()); msg.ack(); } ``` ```csharp title="DlqReader.cs" var dlqResponse = await client.ReceiveQueueMessagesAsync(new ReceiveQueueMessagesRequest { Channel = "orders.dlq", MaxMessages = 10, WaitTimeoutSeconds = 5, }); foreach (var msg in dlqResponse.Messages) { Console.WriteLine($"DLQ message: {Encoding.UTF8.GetString(msg.Body.Span)}"); Console.WriteLine($" Rerouted from: {msg.ReRoutedFromQueue}"); Console.WriteLine($" Receive count: {msg.ReceiveCount}"); await msg.AckAsync(); } ``` ```kotlin title="DlqReader.kt" val dlqResponse = client.receiveQueueMessages( channel = "orders.dlq", maxMessages = 10, waitTimeoutSeconds = 5 ) for (msg in dlqResponse.messages) { println("DLQ message: ${String(msg.body)}") println(" Rerouted from: ${msg.reRoutedFromQueue}") println(" Receive count: ${msg.receiveCount}") msg.ack() } ``` ```cpp title="dlq_reader.cpp" auto dlqResponse = client.receiveQueueMessages("orders.dlq", 10, 5); for (const auto& msg : dlqResponse.messages) { std::cout << "DLQ message: " << msg.body << std::endl; std::cout << " Rerouted from: " << msg.reRoutedFromQueue << std::endl; std::cout << " Receive count: " << msg.receiveCount << std::endl; msg.ack(); } ``` ```rust title="dlq_reader.rs" let dlq_msgs = client .receive_queue_messages("orders.dlq", 10, 5, false) .await?; for msg in &dlq_msgs { println!("DLQ message: {}", String::from_utf8_lossy(&msg.body)); if let Some(attr) = &msg.attributes { println!(" Rerouted from: {}", attr.re_routed_from_queue); println!(" Receive count: {}", attr.receive_count); } } ``` ```ruby title="dlq_reader.rb" dlq_messages = client.receive_queue_messages( channel: 'orders.dlq', max_messages: 10, wait_timeout_seconds: 5 ) dlq_messages.each do |msg| puts "DLQ message: #{msg.body}" puts " Rerouted from: #{msg.attributes.re_routed_from_queue}" puts " Receive count: #{msg.attributes.receive_count}" end ``` ```elixir title="dlq_reader.exs" case KubeMQ.Client.receive_queue_messages(client, "orders.dlq", max_messages: 10, wait_timeout: 5_000 ) do {:ok, result} -> Enum.each(result.messages, fn msg -> IO.puts("DLQ message: #{msg.body}") if msg.attributes do IO.puts(" Rerouted from: #{msg.attributes.re_routed_from_queue}") IO.puts(" Receive count: #{msg.attributes.receive_count}") end end) {:error, _} -> IO.puts("No DLQ messages yet") end ``` ## DLQ Message Attributes [#dlq-message-attributes] When a message is routed to the dead letter queue: | Attribute | Value | | ------------------- | ---------------------------- | | `reRouted` | `true` | | `reRoutedFromQueue` | Original source channel name | | `receiveCount` | Reset to `0` in the DLQ | | Policy fields | Reset to server defaults | If `maxReceiveQueue` is not set on the message, messages that exceed `maxReceiveCount` are **silently discarded**. Always set a DLQ channel for critical workloads. ## Next Steps [#next-steps] # Delayed Messages (/learn/queues/tutorials/delayed-messages) ## How Delayed Messages Work [#how-delayed-messages-work] Messages sent with `delaySeconds > 0` are held in an internal delay channel (`_QUEUE_DELAY_`). A background processor checks every 500ms for expired delays and moves messages to the target queue. *A delayed message waits in the internal `_QUEUE_DELAY_` channel until its delay expires, then moves to the target queue for normal consumption.* ## Prerequisites [#prerequisites] * KubeMQ server running on `localhost:50000` * SDK installed ([Getting Started](../getting-started)) ## Steps [#steps] ### Send a Delayed Message [#send-a-delayed-message] Set the delay in seconds when creating the message. The message becomes available after the delay expires. ```go title="delayed_sender.go" msg := kubemq.NewQueueMessage(). SetChannel("scheduled-tasks"). SetBody([]byte(`{"task":"send-reminder","orderId":"ORD-5001"}`)). SetDelaySeconds(30) result, err := client.SendQueueMessage(ctx, msg) if err != nil { log.Fatal(err) } fmt.Printf("Sent delayed message: id=%s, delayedTo=%d\n", result.MessageID, result.DelayedTo) ``` ```python title="delayed_sender.py" result = client.send_queue_message( QueueMessage( channel="scheduled-tasks", body=b'{"task":"send-reminder","orderId":"ORD-5001"}', delay_in_seconds=30, ) ) print(f"Sent delayed message: id={result.id}, delayedTo={result.delayed_to}") ``` ```typescript title="delayed_sender.ts" const result = await client.sendQueueMessage( createQueueMessage({ channel: 'scheduled-tasks', body: JSON.stringify({ task: 'send-reminder', orderId: 'ORD-5001' }), policy: { delaySeconds: 30 }, }), ); console.log(`Sent delayed message: id=${result.messageId}, delayedTo=${result.delayedTo}`); ``` ```java title="DelayedSender.java" QueueMessage msg = QueueMessage.builder() .channel("scheduled-tasks") .body("{\"task\":\"send-reminder\",\"orderId\":\"ORD-5001\"}".getBytes()) .delaySeconds(30) .build(); SendQueueMessageResult result = client.sendQueueMessage(msg); System.out.printf("Sent delayed message: id=%s, delayedTo=%d%n", result.getMessageId(), result.getDelayedTo()); ``` ```csharp title="DelayedSender.cs" var result = await client.SendQueueMessageAsync(new QueueMessage { Channel = "scheduled-tasks", Body = Encoding.UTF8.GetBytes("{\"task\":\"send-reminder\",\"orderId\":\"ORD-5001\"}"), DelaySeconds = 30 }); Console.WriteLine($"Sent delayed message: id={result.MessageId}, delayedTo={result.DelayedTo}"); ``` ```kotlin title="DelayedSender.kt" val result = client.sendQueueMessage(QueueMessage( channel = "scheduled-tasks", body = """{"task":"send-reminder","orderId":"ORD-5001"}""".toByteArray(), delaySeconds = 30 )) println("Sent delayed message: id=${result.messageId}, delayedTo=${result.delayedTo}") ``` ```cpp title="delayed_sender.cpp" kubemq::QueueMessage msg; msg.channel = "scheduled-tasks"; msg.body = R"({"task":"send-reminder","orderId":"ORD-5001"})"; msg.delaySeconds = 30; auto result = client.sendQueueMessage(msg); std::cout << "Sent delayed message: id=" << result.messageId << std::endl; ``` ```rust title="delayed_sender.rs" let msg = QueueMessageBuilder::new() .channel("scheduled-tasks") .body(br#"{"task":"send-reminder","orderId":"ORD-5001"}"#.to_vec()) .delay_seconds(30) .build(); let result = client.send_queue_message(msg).await?; println!( "Sent delayed message: id={}, delayed_to={}", result.message_id, result.delayed_to ); ``` ```ruby title="delayed_sender.rb" policy = KubeMQ::Queues::QueueMessagePolicy.new(delay_seconds: 30) msg = KubeMQ::Queues::QueueMessage.new( channel: 'scheduled-tasks', body: '{"task":"send-reminder","orderId":"ORD-5001"}', policy: policy ) result = client.send_queue_message(msg) puts "Sent delayed message: id=#{result.id}, delayed_to=#{result.delayed_to}" ``` ```elixir title="delayed_sender.exs" msg = KubeMQ.QueueMessage.new( channel: "scheduled-tasks", body: ~s({"task":"send-reminder","orderId":"ORD-5001"}), policy: KubeMQ.QueuePolicy.new(delay_seconds: 30) ) {:ok, result} = KubeMQ.Client.send_queue_message(client, msg) IO.puts("Sent delayed message, delayed_to: #{result.delayed_to}") ``` ### Receive After Delay [#receive-after-delay] Poll the queue after the delay expires. The message is not visible until the delay elapses. ```go title="delayed_receiver.go" fmt.Println("Waiting for delayed message...") time.Sleep(35 * time.Second) resp, err := client.PollQueue(ctx, &kubemq.PollRequest{ Channel: "scheduled-tasks", MaxItems: 1, WaitTimeoutSeconds: 5, }) if err != nil { log.Fatal(err) } for _, m := range resp.Messages { fmt.Printf("Received delayed message: %s\n", string(m.Message.Body)) } resp.AckAll() ``` ```python title="delayed_receiver.py" import time print("Waiting for delayed message...") time.sleep(35) response = client.receive_queue_messages( channel="scheduled-tasks", max_messages=1, wait_timeout_in_seconds=5, ) for msg in response.messages: print(f"Received delayed message: {msg.body.decode('utf-8')}") msg.ack() ``` ```typescript title="delayed_receiver.ts" console.log('Waiting for delayed message...'); await new Promise((r) => setTimeout(r, 35000)); const messages = await client.receiveQueueMessages({ channel: 'scheduled-tasks', maxMessages: 1, waitTimeoutSeconds: 5, }); for (const msg of messages) { console.log('Received delayed message:', new TextDecoder().decode(msg.body)); await msg.ack(); } ``` ```java title="DelayedReceiver.java" System.out.println("Waiting for delayed message..."); Thread.sleep(35000); ReceiveQueueMessagesResponse response = client.receiveQueueMessages( ReceiveQueueMessagesRequest.builder() .channel("scheduled-tasks") .maxMessages(1) .waitTimeoutSeconds(5) .build()); for (QueueMessageReceived msg : response.getMessages()) { System.out.println("Received delayed message: " + new String(msg.getBody())); msg.ack(); } ``` ```csharp title="DelayedReceiver.cs" Console.WriteLine("Waiting for delayed message..."); await Task.Delay(35000); var response = await client.ReceiveQueueMessagesAsync(new ReceiveQueueMessagesRequest { Channel = "scheduled-tasks", MaxMessages = 1, WaitTimeoutSeconds = 5, }); foreach (var msg in response.Messages) { Console.WriteLine($"Received delayed message: {Encoding.UTF8.GetString(msg.Body.Span)}"); await msg.AckAsync(); } ``` ```kotlin title="DelayedReceiver.kt" println("Waiting for delayed message...") Thread.sleep(35000) val response = client.receiveQueueMessages( channel = "scheduled-tasks", maxMessages = 1, waitTimeoutSeconds = 5 ) for (msg in response.messages) { println("Received delayed message: ${String(msg.body)}") msg.ack() } ``` ```cpp title="delayed_receiver.cpp" std::cout << "Waiting for delayed message..." << std::endl; std::this_thread::sleep_for(std::chrono::seconds(35)); auto response = client.receiveQueueMessages("scheduled-tasks", 1, 5); for (const auto& msg : response.messages) { std::cout << "Received delayed message: " << msg.body << std::endl; msg.ack(); } ``` ```rust title="delayed_receiver.rs" println!("Waiting for delayed message..."); tokio::time::sleep(std::time::Duration::from_secs(35)).await; // receive_queue_messages(channel, max_messages, wait_time_seconds, is_peek) let messages = client .receive_queue_messages("scheduled-tasks", 1, 5, false) .await?; for msg in &messages { println!( "Received delayed message: {}", String::from_utf8_lossy(&msg.body) ); } ``` ```ruby title="delayed_receiver.rb" puts 'Waiting for delayed message...' sleep 35 received = client.receive_queue_messages( channel: 'scheduled-tasks', max_messages: 1, wait_timeout_seconds: 5 ) received.each do |msg| puts "Received delayed message: #{msg.body}" end ``` ```elixir title="delayed_receiver.exs" IO.puts("Waiting for delayed message...") Process.sleep(35_000) {:ok, result} = KubeMQ.Client.receive_queue_messages(client, "scheduled-tasks", max_messages: 1, wait_timeout: 5_000 ) Enum.each(result.messages, fn msg -> IO.puts("Received delayed message: #{msg.body}") end) ``` ## Delay + Expiration Interaction [#delay--expiration-interaction] When both `delaySeconds` and `expirationSeconds` are set, the expiration clock starts **after** the delay expires: | Delay | Expiration | Message Available | Message Expires | | ----- | ---------- | ----------------- | --------------- | | 30s | 0 | T+30s | Never | | 0 | 60s | Immediately | T+60s | | 30s | 60s | T+30s | T+90s | The maximum delay is controlled by the server setting `MaxDelaySeconds` (default: 43,200 seconds / 12 hours). ## Next Steps [#next-steps] # Peek Messages (/learn/queues/tutorials/peek-messages) ## What You Will Build [#what-you-will-build] A queue inspector that reads messages without removing them from the queue. Peeked messages remain available for normal consumers. ## Prerequisites [#prerequisites] * KubeMQ server running on `localhost:50000` * SDK installed ([Getting Started](../getting-started)) ## Peek Queue Messages [#peek-queue-messages] Set `isPeek` to `true` (or use the peek-specific API) to inspect messages without consuming them. The messages are not hidden from other consumers and no acknowledgment is needed. *Peek reads the queue without removing or hiding messages — a normal consumer still polls, receives, and acks the same messages.* ```go title="peek.go" resp, err := client.PollQueue(ctx, &kubemq.PollRequest{ Channel: "orders", MaxItems: 10, WaitTimeoutSeconds: 5, IsPeek: true, }) if err != nil { log.Fatal(err) } fmt.Printf("Queue has %d messages:\n", len(resp.Messages)) for _, m := range resp.Messages { fmt.Printf(" [%d] id=%s body=%s\n", m.Message.Attributes.Sequence, m.Message.MessageID, string(m.Message.Body)) } ``` ```python title="peek.py" response = client.receive_queue_messages( channel="orders", max_messages=10, wait_timeout_in_seconds=5, is_peek=True, ) print(f"Queue has {len(response.messages)} messages:") for msg in response.messages: print(f" [{msg.sequence}] id={msg.id} body={msg.body.decode('utf-8')}") ``` ```typescript title="peek.ts" const messages = await client.receiveQueueMessages({ channel: 'orders', maxMessages: 10, waitTimeoutSeconds: 5, isPeek: true, }); console.log(`Queue has ${messages.length} messages:`); for (const msg of messages) { console.log(` [${msg.sequence}] id=${msg.messageId} body=${new TextDecoder().decode(msg.body)}`); } ``` ```java title="Peek.java" ReceiveQueueMessagesResponse response = client.receiveQueueMessages( ReceiveQueueMessagesRequest.builder() .channel("orders") .maxMessages(10) .waitTimeoutSeconds(5) .isPeek(true) .build()); System.out.printf("Queue has %d messages:%n", response.getMessages().size()); for (QueueMessageReceived msg : response.getMessages()) { System.out.printf(" [%d] id=%s body=%s%n", msg.getSequence(), msg.getMessageId(), new String(msg.getBody())); } ``` ```csharp title="Peek.cs" var response = await client.ReceiveQueueMessagesAsync(new ReceiveQueueMessagesRequest { Channel = "orders", MaxMessages = 10, WaitTimeoutSeconds = 5, IsPeek = true, }); Console.WriteLine($"Queue has {response.Messages.Count} messages:"); foreach (var msg in response.Messages) { Console.WriteLine($" [{msg.Sequence}] id={msg.MessageId} body={Encoding.UTF8.GetString(msg.Body.Span)}"); } ``` ```kotlin title="Peek.kt" val response = client.receiveQueueMessages( channel = "orders", maxMessages = 10, waitTimeoutSeconds = 5, isPeek = true ) println("Queue has ${response.messages.size} messages:") for (msg in response.messages) { println(" [${msg.sequence}] id=${msg.messageId} body=${String(msg.body)}") } ``` ```cpp title="peek.cpp" auto response = client.receiveQueueMessages("orders", 10, 5, true); std::cout << "Queue has " << response.messages.size() << " messages:" << std::endl; for (const auto& msg : response.messages) { std::cout << " [" << msg.sequence << "] id=" << msg.messageId << " body=" << msg.body << std::endl; } ``` ```rust title="peek.rs" // receive_queue_messages(channel, max_messages, wait_seconds, is_peek) let peeked = client .receive_queue_messages("orders", 10, 5, true) .await?; println!("Queue has {} messages:", peeked.len()); for m in &peeked { println!(" id={} body={}", m.id, String::from_utf8_lossy(&m.body)); } // Messages remain in the queue — still available for a normal receive let received = client.receive_queue_messages("orders", 10, 5, false).await?; println!("Received {} messages after peek", received.len()); ``` ```ruby title="peek.rb" peeked = client.receive_queue_messages( channel: 'orders', max_messages: 10, wait_timeout_seconds: 5, peek: true ) puts "Queue has #{peeked.size} messages (not consumed):" peeked.each { |msg| puts " id=#{msg.id} body=#{msg.body}" } # Messages remain in the queue — still available for a normal receive received = client.receive_queue_messages(channel: 'orders', max_messages: 10, wait_timeout_seconds: 5) puts "Received #{received.size} messages after peek" ``` ```elixir title="peek.exs" {:ok, result} = KubeMQ.Client.receive_queue_messages(client, "orders", max_messages: 10, wait_timeout: 5_000, is_peek: true ) IO.puts("Queue has #{result.messages_received} messages (is_peek: #{result.is_peek}):") Enum.each(result.messages, fn msg -> IO.puts(" id=#{msg.id} body=#{msg.body}") end) # Messages remain in the queue — still available for a normal receive {:ok, received} = KubeMQ.Client.receive_queue_messages(client, "orders", max_messages: 10, wait_timeout: 5_000) IO.puts("Received #{received.messages_received} messages after peek") ``` ## Use Cases [#use-cases] | Use Case | Description | | --------------------- | ---------------------------------------------------------------------- | | **Monitoring** | Check queue depth and message contents without affecting consumers | | **Debugging** | Inspect message payloads and metadata to diagnose processing issues | | **Delayed decisions** | Preview messages before deciding whether to consume them | | **Queue health** | Verify messages are being produced correctly before starting consumers | Peek does not change the message state. The `receiveCount` is not incremented, and messages remain fully available for normal consumers. ## Next Steps [#next-steps] # Send & Receive Messages (/learn/queues/tutorials/send-receive) ## What You Will Build [#what-you-will-build] A producer that sends order processing tasks with metadata and tags, and a consumer that receives, processes, and acknowledges each message. The example includes error handling and message introspection. *The send-receive-acknowledge cycle: the producer sends, the consumer polls and processes, and the ack removes the message from the queue.* ## Prerequisites [#prerequisites] * KubeMQ server running on `localhost:50000` * SDK installed ([Getting Started](../getting-started)) ## Steps [#steps] ### Set Up the Client [#set-up-the-client] ```go title="main.go" package main import ( "context" "encoding/json" "fmt" "log" "time" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), kubemq.WithClientId("order-processor"), ) if err != nil { log.Fatal(err) } defer client.Close() ``` ```python title="main.py" from kubemq.queues import Client as QueuesClient from kubemq import QueueMessage client = QueuesClient( address="localhost:50000", client_id="order-processor", ) ``` ```typescript title="main.ts" import { KubeMQClient, createQueueMessage } from 'kubemq-js'; const client = await KubeMQClient.create({ address: 'localhost:50000', clientId: 'order-processor', }); ``` ```java title="Main.java" QueuesClient client = QueuesClient.builder() .address("localhost:50000") .clientId("order-processor") .build(); ``` ```csharp title="Program.cs" using KubeMQ.Sdk.Client; await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); ``` ```kotlin title="Main.kt" val client = QueuesClient("localhost:50000") ``` ```cpp title="main.cpp" #include auto client = kubemq::QueuesClient("localhost:50000"); ``` ```rust title="main.rs" use kubemq::prelude::*; use kubemq::QueueMessageBuilder; use std::collections::HashMap; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .client_id("order-processor") .build() .await?; ``` ```ruby title="main.rb" require 'kubemq' client = KubeMQ::QueuesClient.new( address: 'localhost:50000', client_id: 'order-processor', ) ``` ```elixir title="main.exs" {:ok, client} = KubeMQ.Client.start_link( address: "localhost:50000", client_id: "order-processor" ) ``` ### Send a Message with Metadata and Tags [#send-a-message-with-metadata-and-tags] Create a queue message with a JSON body, metadata string, and tags for downstream routing. ```go order := map[string]interface{}{ "orderId": "ORD-5001", "items": 3, "total": 149.97, } body, _ := json.Marshal(order) msg := kubemq.NewQueueMessage(). SetChannel("orders"). SetBody(body). SetMetadata("order.created"). SetTags(map[string]string{ "region": "us-east", "priority": "high", }) result, err := client.SendQueueMessage(ctx, msg) if err != nil { log.Fatal(err) } if result.IsError { log.Fatalf("Send failed: %s", result.Error) } fmt.Printf("Sent: id=%s, sentAt=%d\n", result.MessageID, result.SentAt) ``` ```python import json order = {"orderId": "ORD-5001", "items": 3, "total": 149.97} result = client.send_queue_message( QueueMessage( channel="orders", body=json.dumps(order).encode(), metadata="order.created", tags={"region": "us-east", "priority": "high"}, ) ) print(f"Sent: id={result.id}, sentAt={result.sent_at}") ``` ```typescript const result = await client.sendQueueMessage( createQueueMessage({ channel: 'orders', body: JSON.stringify({ orderId: 'ORD-5001', items: 3, total: 149.97 }), metadata: 'order.created', tags: { region: 'us-east', priority: 'high' }, }), ); console.log(`Sent: id=${result.messageId}, sentAt=${result.sentAt}`); ``` ```java QueueMessage msg = QueueMessage.builder() .channel("orders") .body("{\"orderId\":\"ORD-5001\",\"items\":3,\"total\":149.97}".getBytes()) .metadata("order.created") .tags(Map.of("region", "us-east", "priority", "high")) .build(); SendQueueMessageResult result = client.sendQueueMessage(msg); System.out.printf("Sent: id=%s, sentAt=%d%n", result.getMessageId(), result.getSentAt()); ``` ```csharp var result = await client.SendQueueMessageAsync(new QueueMessage { Channel = "orders", Body = Encoding.UTF8.GetBytes("{\"orderId\":\"ORD-5001\",\"items\":3,\"total\":149.97}"), Metadata = "order.created", Tags = new Dictionary { ["region"] = "us-east", ["priority"] = "high" } }); Console.WriteLine($"Sent: id={result.MessageId}, sentAt={result.SentAt}"); ``` ```kotlin val result = client.sendQueueMessage(QueueMessage( channel = "orders", body = """{"orderId":"ORD-5001","items":3,"total":149.97}""".toByteArray(), metadata = "order.created", tags = mapOf("region" to "us-east", "priority" to "high") )) println("Sent: id=${result.messageId}, sentAt=${result.sentAt}") ``` ```cpp kubemq::QueueMessage msg; msg.channel = "orders"; msg.body = R"({"orderId":"ORD-5001","items":3,"total":149.97})"; msg.metadata = "order.created"; msg.tags = {{"region", "us-east"}, {"priority", "high"}}; auto result = client.sendQueueMessage(msg); std::cout << "Sent: id=" << result.messageId << std::endl; ``` ```rust let order = r#"{"orderId":"ORD-5001","items":3,"total":149.97}"#; let mut tags = HashMap::new(); tags.insert("region".to_string(), "us-east".to_string()); tags.insert("priority".to_string(), "high".to_string()); let msg = QueueMessageBuilder::new() .channel("orders") .body(order.as_bytes().to_vec()) .metadata("order.created") .tags(tags) .build(); let result = client.send_queue_message(msg).await?; println!("Sent: id={}, sent_at={}", result.message_id, result.sent_at); ``` ```ruby msg = KubeMQ::Queues::QueueMessage.new( channel: 'orders', body: '{"orderId":"ORD-5001","items":3,"total":149.97}', metadata: 'order.created', tags: { 'region' => 'us-east', 'priority' => 'high' } ) result = client.send_queue_message(msg) puts "Sent: id=#{result.id}, error?=#{result.error?}" ``` ```elixir msg = KubeMQ.QueueMessage.new( channel: "orders", body: ~s({"orderId":"ORD-5001","items":3,"total":149.97}), metadata: "order.created", tags: %{"region" => "us-east", "priority" => "high"} ) {:ok, result} = KubeMQ.Client.send_queue_message(client, msg) IO.puts("Sent: id=#{result.message_id}") ``` ### Receive Messages [#receive-messages] Poll the queue for available messages. You control how many messages to fetch and how long to wait. ```go resp, err := client.PollQueue(ctx, &kubemq.PollRequest{ Channel: "orders", MaxItems: 10, WaitTimeoutSeconds: 5, AutoAck: false, }) if err != nil { log.Fatal(err) } fmt.Printf("Received %d messages\n", len(resp.Messages)) ``` ```python response = client.receive_queue_messages( channel="orders", max_messages=10, wait_timeout_in_seconds=5, ) print(f"Received {len(response.messages)} messages") ``` ```typescript const messages = await client.receiveQueueMessages({ channel: 'orders', maxMessages: 10, waitTimeoutSeconds: 5, }); console.log(`Received ${messages.length} messages`); ``` ```java ReceiveQueueMessagesResponse response = client.receiveQueueMessages( ReceiveQueueMessagesRequest.builder() .channel("orders") .maxMessages(10) .waitTimeoutSeconds(5) .build()); System.out.printf("Received %d messages%n", response.getMessages().size()); ``` ```csharp var response = await client.ReceiveQueueMessagesAsync(new ReceiveQueueMessagesRequest { Channel = "orders", MaxMessages = 10, WaitTimeoutSeconds = 5, }); Console.WriteLine($"Received {response.Messages.Count} messages"); ``` ```kotlin val response = client.receiveQueueMessages( channel = "orders", maxMessages = 10, waitTimeoutSeconds = 5 ) println("Received ${response.messages.size} messages") ``` ```cpp auto response = client.receiveQueueMessages("orders", 10, 5); std::cout << "Received " << response.messages.size() << " messages" << std::endl; ``` ```rust // receive_queue_messages(channel, max_items, wait_seconds, auto_ack) let messages = client .receive_queue_messages("orders", 10, 5, false) .await?; println!("Received {} messages", messages.len()); ``` ```ruby messages = client.receive_queue_messages( channel: 'orders', max_messages: 10, wait_timeout_seconds: 5 ) puts "Received #{messages.size} messages" ``` ```elixir {:ok, response} = KubeMQ.Client.receive_queue_messages(client, "orders", max_messages: 10, wait_timeout: 5_000 ) IO.puts("Received #{response.messages_received} messages") ``` ### Process and Acknowledge [#process-and-acknowledge] Inspect each message's body, metadata, and tags, then acknowledge to remove it from the queue. ```go for _, dm := range resp.Messages { fmt.Printf(" ID: %s\n", dm.Message.MessageID) fmt.Printf(" Body: %s\n", string(dm.Message.Body)) fmt.Printf(" Metadata: %s\n", dm.Message.Metadata) fmt.Printf(" Tags: %v\n", dm.Message.Tags) } if err := resp.AckAll(); err != nil { log.Fatal(err) } fmt.Println("All messages acknowledged") } ``` ```python for msg in response.messages: print(f" ID: {msg.id}") print(f" Body: {msg.body.decode('utf-8')}") print(f" Metadata: {msg.metadata}") print(f" Tags: {msg.tags}") msg.ack() print(" Acknowledged") client.close() ``` ```typescript for (const msg of messages) { console.log(' ID: ', msg.messageId); console.log(' Body: ', new TextDecoder().decode(msg.body)); console.log(' Metadata:', msg.metadata); console.log(' Tags: ', msg.tags); await msg.ack(); console.log(' Acknowledged'); } await client.close(); ``` ```java for (QueueMessageReceived msg : response.getMessages()) { System.out.printf(" ID: %s%n", msg.getMessageId()); System.out.printf(" Body: %s%n", new String(msg.getBody())); System.out.printf(" Metadata: %s%n", msg.getMetadata()); System.out.printf(" Tags: %s%n", msg.getTags()); msg.ack(); System.out.println(" Acknowledged"); } client.close(); ``` ```csharp foreach (var msg in response.Messages) { Console.WriteLine($" ID: {msg.MessageId}"); Console.WriteLine($" Body: {Encoding.UTF8.GetString(msg.Body.Span)}"); Console.WriteLine($" Metadata: {msg.Metadata}"); Console.WriteLine($" Tags: {string.Join(", ", msg.Tags)}"); await msg.AckAsync(); Console.WriteLine(" Acknowledged"); } ``` ```kotlin for (msg in response.messages) { println(" ID: ${msg.messageId}") println(" Body: ${String(msg.body)}") println(" Metadata: ${msg.metadata}") println(" Tags: ${msg.tags}") msg.ack() println(" Acknowledged") } client.close() ``` ```cpp for (const auto& msg : response.messages) { std::cout << " ID: " << msg.messageId << std::endl; std::cout << " Body: " << msg.body << std::endl; std::cout << " Metadata: " << msg.metadata << std::endl; msg.ack(); std::cout << " Acknowledged" << std::endl; } ``` ```rust // The unary receive returns plain messages; settle them with ack_all. // For per-message ack/nack, use the queue stream API. for m in &messages { println!(" ID: {}", m.id); println!(" Body: {}", String::from_utf8_lossy(&m.body)); println!(" Metadata: {}", m.metadata); println!(" Tags: {:?}", m.tags); } let ack = AckAllQueueMessagesRequest { request_id: String::new(), client_id: String::new(), channel: "orders".to_string(), wait_time_seconds: 5, }; client.ack_all_queue_messages(&ack).await?; println!("All messages acknowledged"); client.close().await?; Ok(()) } ``` ```ruby messages.each do |m| puts " ID: #{m.id}" puts " Body: #{m.body}" puts " Metadata: #{m.metadata}" puts " Tags: #{m.tags}" end # Settle the polled messages. For per-message ack/nack, use the stream receiver. affected = client.ack_all_queue_messages(channel: 'orders', wait_timeout_seconds: 5) puts "Acknowledged #{affected} messages" client.close ``` ```elixir Enum.each(response.messages, fn m -> IO.puts(" ID: #{m.id}") IO.puts(" Body: #{m.body}") IO.puts(" Metadata: #{m.metadata}") IO.puts(" Tags: #{inspect(m.tags)}") end) # Settle the received messages. For per-message ack/nack, use poll_queue (stream API). {:ok, ack} = KubeMQ.Client.ack_all_queue_messages(client, "orders", wait_timeout: 5_000) IO.puts("Acknowledged #{ack.affected_messages} messages") KubeMQ.Client.close(client) ``` ## Next Steps [#next-steps] # Stream API (Upstream/Downstream) (/learn/queues/tutorials/stream-api) ## What You Will Build [#what-you-will-build] A streaming producer that sends messages continuously through an upstream connection, and a streaming consumer that receives messages through a downstream connection with range-based acknowledgment. *One long-lived upstream stream feeds the queue; a downstream stream delivers batches that the consumer settles with a dotted range acknowledgment.* ## Prerequisites [#prerequisites] * KubeMQ server running on `localhost:50000` * SDK installed ([Getting Started](../getting-started)) ## Upstream: Stream Send [#upstream-stream-send] Open a persistent bidirectional stream for high-throughput sending. Each message gets an individual response confirming receipt. ```go title="upstream.go" stream, err := client.UpstreamQueue(ctx) if err != nil { log.Fatal(err) } for i := 0; i < 100; i++ { body := fmt.Sprintf(`{"orderId":"ORD-%04d","item":"widget"}`, i) result, err := stream.Send(ctx, kubemq.NewQueueMessage(). SetChannel("orders.stream"). SetBody([]byte(body)), ) if err != nil { log.Printf("Send error: %v", err) continue } fmt.Printf("Streamed: id=%s\n", result.MessageID) } stream.Close() ``` ```python title="upstream.py" stream = client.upstream_queue() for i in range(100): result = stream.send( QueueMessage( channel="orders.stream", body=f'{{"orderId":"ORD-{i:04d}","item":"widget"}}'.encode(), ) ) print(f"Streamed: id={result.id}") stream.close() ``` ```typescript title="upstream.ts" const upstream = client.createQueueUpstream(); for (let i = 0; i < 100; i++) { const result = await upstream.send([ createQueueMessage({ channel: 'orders.stream', body: JSON.stringify({ orderId: `ORD-${String(i).padStart(4, '0')}`, item: 'widget' }), }), ]); console.log(`Streamed: id=${result.results[0].messageId}`); } upstream.close(); ``` ```java title="Upstream.java" QueueUpstream stream = client.upstreamQueue(); for (int i = 0; i < 100; i++) { SendQueueMessageResult result = stream.send(QueueMessage.builder() .channel("orders.stream") .body(String.format("{\"orderId\":\"ORD-%04d\",\"item\":\"widget\"}", i).getBytes()) .build()); System.out.printf("Streamed: id=%s%n", result.getMessageId()); } stream.close(); ``` ```csharp title="Upstream.cs" var stream = await client.UpstreamQueueAsync(); for (int i = 0; i < 100; i++) { var result = await stream.SendAsync(new QueueMessage { Channel = "orders.stream", Body = Encoding.UTF8.GetBytes($"{{\"orderId\":\"ORD-{i:D4}\",\"item\":\"widget\"}}") }); Console.WriteLine($"Streamed: id={result.MessageId}"); } await stream.CloseAsync(); ``` ```kotlin title="Upstream.kt" val stream = client.upstreamQueue() for (i in 0 until 100) { val result = stream.send(QueueMessage( channel = "orders.stream", body = """{"orderId":"ORD-${"%04d".format(i)}","item":"widget"}""".toByteArray() )) println("Streamed: id=${result.messageId}") } stream.close() ``` ```cpp title="upstream.cpp" auto stream = client.upstreamQueue(); for (int i = 0; i < 100; i++) { kubemq::QueueMessage msg; msg.channel = "orders.stream"; msg.body = "{\"orderId\":\"ORD-" + std::to_string(i) + "\",\"item\":\"widget\"}"; auto result = stream.send(msg); std::cout << "Streamed: id=" << result.messageId << std::endl; } stream.close(); ``` ```rust title="upstream.rs" let mut upstream = client.queue_upstream().await?; // Send a batch of messages over the persistent upstream stream. let messages: Vec = (0..100) .map(|i| { QueueMessageBuilder::new() .channel("orders.stream") .body(format!(r#"{{"orderId":"ORD-{:04}","item":"widget"}}"#, i).into_bytes()) .build() }) .collect(); upstream.send("orders-batch-001", messages).await?; if let Some(result) = upstream.results().recv().await { println!( "Streamed: ref_id={}, is_error={}, items={}", result.ref_request_id, result.is_error, result.results.len() ); } upstream.close(); ``` ```ruby title="upstream.rb" sender = client.create_upstream_sender 100.times do |i| msg = KubeMQ::Queues::QueueMessage.new( channel: "orders.stream", body: %({"orderId":"ORD-#{format('%04d', i)}","item":"widget"}) ) results = sender.publish(msg) results.each { |r| puts "Streamed: id=#{r.id}, error?=#{r.error?}" } end sender.close ``` ```elixir title="upstream.exs" {:ok, handle} = KubeMQ.Client.queue_upstream(client) # Send a batch of messages over the persistent upstream stream. messages = for i <- 0..99 do body = ~s({"orderId":"ORD-#{String.pad_leading(Integer.to_string(i), 4, "0")}","item":"widget"}) KubeMQ.QueueMessage.new(channel: "orders.stream", body: body) end case KubeMQ.QueueUpstreamHandle.send(handle, messages) do {:ok, results} -> Enum.each(results, fn r -> IO.puts("Streamed: id=#{r.message_id}, error: #{r.is_error}") end) {:error, err} -> IO.puts("Stream send failed: #{err.message}") end KubeMQ.QueueUpstreamHandle.close(handle) ``` ## Downstream: Stream Receive [#downstream-stream-receive] Open a persistent downstream stream for continuous message consumption. ```go title="downstream.go" stream, err := client.DownstreamQueue(ctx, &kubemq.DownstreamRequest{ Channel: "orders.stream", MaxItems: 10, WaitTimeoutSeconds: 5, AutoAck: false, }) if err != nil { log.Fatal(err) } for resp := range stream.ResponseCh { for _, m := range resp.Messages { fmt.Printf("Received: %s\n", string(m.Message.Body)) } resp.AckAll() } ``` ```python title="downstream.py" def on_message(response): for msg in response.messages: print(f"Received: {msg.body.decode('utf-8')}") msg.ack() def on_error(err): print(f"Stream error: {err}") stream = client.downstream_queue( channel="orders.stream", max_messages=10, wait_timeout_in_seconds=5, on_message_callback=on_message, on_error_callback=on_error, ) ``` ```typescript title="downstream.ts" const stream = client.streamQueueMessages({ channel: 'orders.stream', maxMessages: 10, waitTimeoutSeconds: 5, autoAck: false, }); stream.onMessages((messages) => { for (const msg of messages) { console.log('Received:', new TextDecoder().decode(msg.body)); } // Acknowledge the whole batch at once. stream.ackAll(); }); stream.onError((err) => console.error('Stream error:', err.message)); ``` ```java title="Downstream.java" client.downstreamQueue(DownstreamRequest.builder() .channel("orders.stream") .maxMessages(10) .waitTimeoutSeconds(5) .onMessage(response -> { for (QueueMessageReceived msg : response.getMessages()) { System.out.println("Received: " + new String(msg.getBody())); msg.ack(); } }) .onError(err -> System.err.println("Stream error: " + err.getMessage())) .build()); ``` ```csharp title="Downstream.cs" await foreach (var response in client.DownstreamQueueAsync(new DownstreamRequest { Channel = "orders.stream", MaxMessages = 10, WaitTimeoutSeconds = 5, })) { foreach (var msg in response.Messages) { Console.WriteLine($"Received: {Encoding.UTF8.GetString(msg.Body.Span)}"); await msg.AckAsync(); } } ``` ```kotlin title="Downstream.kt" client.downstreamQueue( channel = "orders.stream", maxMessages = 10, waitTimeoutSeconds = 5, onMessage = { response -> for (msg in response.messages) { println("Received: ${String(msg.body)}") msg.ack() } }, onError = { err -> System.err.println("Stream error: ${err.message}") } ) ``` ```cpp title="downstream.cpp" client.downstreamQueue("orders.stream", 10, 5, [](const auto& response) { for (const auto& msg : response.messages) { std::cout << "Received: " << msg.body << std::endl; msg.ack(); } }, [](const std::string& err) { std::cerr << "Stream error: " << err << std::endl; } ); ``` ```rust title="downstream.rs" let mut receiver = client.new_queue_downstream_receiver().await?; // Poll a batch over the persistent downstream stream. let poll = PollRequest { channel: "orders.stream".to_string(), max_items: 10, wait_timeout_seconds: 5, auto_ack: false, }; let response = receiver.poll(poll).await?; for msg in &response.messages { println!("Received: {}", String::from_utf8_lossy(&msg.body)); } // Acknowledge the whole batch (range ack) in one call. response.ack_all().await?; receiver.close().await?; ``` ```ruby title="downstream.rb" receiver = client.create_downstream_receiver request = KubeMQ::Queues::QueuePollRequest.new( channel: "orders.stream", max_items: 10, wait_timeout: 5 ) response = receiver.poll(request) if response.error? puts "Stream error: #{response.error}" else response.messages.each do |msg| puts "Received: #{msg.body}" msg.ack end end receiver.close ``` ```elixir title="downstream.exs" # Poll a batch over the downstream stream. case KubeMQ.Client.poll_queue(client, channel: "orders.stream", max_items: 10, wait_timeout: 5_000 ) do {:ok, poll} -> Enum.each(poll.messages, fn msg -> IO.puts("Received: #{msg.body}") end) # Acknowledge the whole batch (range ack) in one call. {:ok, _} = KubeMQ.PollResponse.ack_all(poll) {:error, err} -> IO.puts("Stream error: #{err.message}") end ``` ## Stream vs Polling Comparison [#stream-vs-polling-comparison] | Feature | Stream API | Polling (PollQueue) | | -------------- | --------------------------------- | ------------------------------ | | Connection | Persistent bidirectional | Request/response per poll | | Latency | Lower (always connected) | Higher (new request each time) | | Throughput | Higher | Lower | | Resource usage | Holds connection open | Releases between polls | | Best for | High-volume continuous processing | Periodic batch processing | ## Next Steps [#next-steps] # Implement Circuit Breaker (/learn/rpc/how-to/circuit-breaker) ## The Problem [#the-problem] When a responder is down or slow, every RPC request blocks until the timeout expires. This cascades through the system — the sender's threads or goroutines are tied up waiting, eventually causing the sender itself to become unresponsive. ## Circuit Breaker Pattern [#circuit-breaker-pattern] A circuit breaker tracks failures and short-circuits requests when a threshold is reached, returning a fallback immediately instead of waiting for a timeout. *The three circuit states and the transitions between them.* | State | Behavior | | ------------- | ---------------------------------------------------------------------- | | **Closed** | Normal operation — requests pass through to KubeMQ | | **Open** | Fail fast — return fallback immediately without calling KubeMQ | | **Half-Open** | Probe — allow one test request to check if the responder has recovered | ## Implementation [#implementation] ### Define the Circuit Breaker [#define-the-circuit-breaker] ```go title="circuit_breaker.go" type CircuitBreaker struct { mu sync.Mutex failures int threshold int state string // "closed", "open", "half-open" lastFailureTime time.Time resetTimeout time.Duration } func NewCircuitBreaker(threshold int, resetTimeout time.Duration) *CircuitBreaker { return &CircuitBreaker{ threshold: threshold, state: "closed", resetTimeout: resetTimeout, } } ``` ```python title="circuit_breaker.py" import time import threading class CircuitBreaker: def __init__(self, threshold=5, reset_timeout=30): self.threshold = threshold self.reset_timeout = reset_timeout self.failures = 0 self.state = "closed" self.last_failure_time = 0 self._lock = threading.Lock() ``` ```javascript title="circuit_breaker.js" class CircuitBreaker { constructor(threshold = 5, resetTimeout = 30000) { this.threshold = threshold; this.resetTimeout = resetTimeout; this.failures = 0; this.state = "closed"; this.lastFailureTime = 0; } } ``` ```java title="CircuitBreaker.java" public class CircuitBreaker { private final int threshold; private final long resetTimeoutMs; private int failures = 0; private String state = "closed"; private long lastFailureTime = 0; public CircuitBreaker(int threshold, long resetTimeoutMs) { this.threshold = threshold; this.resetTimeoutMs = resetTimeoutMs; } } ``` ```csharp title="CircuitBreaker.cs" public class CircuitBreaker { private readonly int _threshold; private readonly TimeSpan _resetTimeout; private int _failures; private string _state = "closed"; private DateTime _lastFailureTime; private readonly object _lock = new(); public CircuitBreaker(int threshold = 5, TimeSpan? resetTimeout = null) { _threshold = threshold; _resetTimeout = resetTimeout ?? TimeSpan.FromSeconds(30); } } ``` ```kotlin title="CircuitBreaker.kt" class CircuitBreaker( private val threshold: Int = 5, private val resetTimeout: Long = 30000 ) { private var failures = 0 private var state = "closed" private var lastFailureTime = 0L private val lock = Any() } ``` ```cpp title="circuit_breaker.cpp" class CircuitBreaker { int threshold; int resetTimeout; int failures = 0; std::string state = "closed"; std::chrono::steady_clock::time_point lastFailureTime; std::mutex mtx; public: CircuitBreaker(int threshold = 5, int resetTimeoutSec = 30) : threshold(threshold), resetTimeout(resetTimeoutSec) {} }; ``` ```rust title="circuit_breaker.rs" use std::time::{Duration, Instant}; #[derive(Clone, Copy, PartialEq)] enum State { Closed, Open, HalfOpen, } struct CircuitBreaker { threshold: u32, reset_timeout: Duration, failures: u32, state: State, last_failure: Option, } impl CircuitBreaker { fn new(threshold: u32, reset_timeout: Duration) -> Self { Self { threshold, reset_timeout, failures: 0, state: State::Closed, last_failure: None, } } } ``` ```ruby title="circuit_breaker.rb" class CircuitBreaker def initialize(threshold: 5, reset_timeout: 30) @threshold = threshold @reset_timeout = reset_timeout @failures = 0 @state = :closed @last_failure_time = nil @mutex = Mutex.new end end ``` ```elixir title="circuit_breaker.ex" defmodule CircuitBreaker do # Backed by an Agent so the state survives across calls. defstruct threshold: 5, reset_timeout_ms: 30_000, failures: 0, state: :closed, last_failure_ms: nil def start_link(threshold \\ 5, reset_timeout_ms \\ 30_000) do Agent.start_link(fn -> %CircuitBreaker{threshold: threshold, reset_timeout_ms: reset_timeout_ms} end) end end ``` ### Track Failures and Open the Circuit [#track-failures-and-open-the-circuit] Record each failure. When consecutive failures reach the threshold, open the circuit. ```go title="track_failures.go" func (cb *CircuitBreaker) RecordFailure() { cb.mu.Lock() defer cb.mu.Unlock() cb.failures++ cb.lastFailureTime = time.Now() if cb.failures >= cb.threshold { cb.state = "open" log.Printf("Circuit OPEN after %d failures", cb.failures) } } func (cb *CircuitBreaker) RecordSuccess() { cb.mu.Lock() defer cb.mu.Unlock() cb.failures = 0 cb.state = "closed" } ``` ```python title="track_failures.py" def record_failure(self): with self._lock: self.failures += 1 self.last_failure_time = time.time() if self.failures >= self.threshold: self.state = "open" print(f"Circuit OPEN after {self.failures} failures") def record_success(self): with self._lock: self.failures = 0 self.state = "closed" ``` ```javascript title="track_failures.js" recordFailure() { this.failures++; this.lastFailureTime = Date.now(); if (this.failures >= this.threshold) { this.state = "open"; console.log(`Circuit OPEN after ${this.failures} failures`); } } recordSuccess() { this.failures = 0; this.state = "closed"; } ``` ```java title="TrackFailures.java" public synchronized void recordFailure() { failures++; lastFailureTime = System.currentTimeMillis(); if (failures >= threshold) { state = "open"; System.out.printf("Circuit OPEN after %d failures%n", failures); } } public synchronized void recordSuccess() { failures = 0; state = "closed"; } ``` ```csharp title="TrackFailures.cs" public void RecordFailure() { lock (_lock) { _failures++; _lastFailureTime = DateTime.UtcNow; if (_failures >= _threshold) { _state = "open"; Console.WriteLine($"Circuit OPEN after {_failures} failures"); } } } public void RecordSuccess() { lock (_lock) { _failures = 0; _state = "closed"; } } ``` ```kotlin title="TrackFailures.kt" fun recordFailure() = synchronized(lock) { failures++ lastFailureTime = System.currentTimeMillis() if (failures >= threshold) { state = "open" println("Circuit OPEN after $failures failures") } } fun recordSuccess() = synchronized(lock) { failures = 0 state = "closed" } ``` ```cpp title="track_failures.cpp" void recordFailure() { std::lock_guard lock(mtx); failures++; lastFailureTime = std::chrono::steady_clock::now(); if (failures >= threshold) { state = "open"; std::cout << "Circuit OPEN after " << failures << " failures" << std::endl; } } void recordSuccess() { std::lock_guard lock(mtx); failures = 0; state = "closed"; } ``` ```rust title="track_failures.rs" impl CircuitBreaker { fn record_failure(&mut self) { self.failures += 1; self.last_failure = Some(Instant::now()); if self.failures >= self.threshold { self.state = State::Open; println!("Circuit OPEN after {} failures", self.failures); } } fn record_success(&mut self) { self.failures = 0; self.state = State::Closed; } } ``` ```ruby title="track_failures.rb" def record_failure @mutex.synchronize do @failures += 1 @last_failure_time = Time.now if @failures >= @threshold @state = :open puts "Circuit OPEN after #{@failures} failures" end end end def record_success @mutex.synchronize do @failures = 0 @state = :closed end end ``` ```elixir title="track_failures.ex" def record_failure(cb) do Agent.update(cb, fn s -> failures = s.failures + 1 state = if failures >= s.threshold, do: :open, else: s.state if state == :open, do: IO.puts("Circuit OPEN after #{failures} failures") %{s | failures: failures, state: state, last_failure_ms: System.monotonic_time(:millisecond)} end) end def record_success(cb) do Agent.update(cb, fn s -> %{s | failures: 0, state: :closed} end) end ``` ### Check State Before Sending [#check-state-before-sending] Before each RPC call, check the circuit state. If open, check whether the reset timeout has expired to transition to half-open. ```go title="check_state.go" func (cb *CircuitBreaker) AllowRequest() bool { cb.mu.Lock() defer cb.mu.Unlock() switch cb.state { case "closed": return true case "open": if time.Since(cb.lastFailureTime) > cb.resetTimeout { cb.state = "half-open" log.Println("Circuit HALF-OPEN — probing...") return true } return false case "half-open": return true } return false } ``` ```python title="check_state.py" def allow_request(self): with self._lock: if self.state == "closed": return True if self.state == "open": if time.time() - self.last_failure_time > self.reset_timeout: self.state = "half-open" print("Circuit HALF-OPEN — probing...") return True return False return True # half-open allows one probe ``` ```javascript title="check_state.js" allowRequest() { if (this.state === "closed") return true; if (this.state === "open") { if (Date.now() - this.lastFailureTime > this.resetTimeout) { this.state = "half-open"; console.log("Circuit HALF-OPEN — probing..."); return true; } return false; } return true; // half-open allows one probe } ``` ```java title="CheckState.java" public synchronized boolean allowRequest() { if ("closed".equals(state)) return true; if ("open".equals(state)) { if (System.currentTimeMillis() - lastFailureTime > resetTimeoutMs) { state = "half-open"; System.out.println("Circuit HALF-OPEN — probing..."); return true; } return false; } return true; } ``` ```csharp title="CheckState.cs" public bool AllowRequest() { lock (_lock) { if (_state == "closed") return true; if (_state == "open") { if (DateTime.UtcNow - _lastFailureTime > _resetTimeout) { _state = "half-open"; Console.WriteLine("Circuit HALF-OPEN — probing..."); return true; } return false; } return true; } } ``` ```kotlin title="CheckState.kt" fun allowRequest(): Boolean = synchronized(lock) { when (state) { "closed" -> true "open" -> { if (System.currentTimeMillis() - lastFailureTime > resetTimeout) { state = "half-open" println("Circuit HALF-OPEN — probing...") true } else false } else -> true } } ``` ```cpp title="check_state.cpp" bool allowRequest() { std::lock_guard lock(mtx); if (state == "closed") return true; if (state == "open") { auto elapsed = std::chrono::steady_clock::now() - lastFailureTime; if (elapsed > std::chrono::seconds(resetTimeout)) { state = "half-open"; return true; } return false; } return true; } ``` ```rust title="check_state.rs" impl CircuitBreaker { fn allow_request(&mut self) -> bool { match self.state { State::Closed => true, State::Open => { let expired = self .last_failure .map(|t| t.elapsed() > self.reset_timeout) .unwrap_or(true); if expired { self.state = State::HalfOpen; println!("Circuit HALF-OPEN — probing..."); true } else { false } } State::HalfOpen => true, // allow one probe } } } ``` ```ruby title="check_state.rb" def allow_request? @mutex.synchronize do case @state when :closed true when :open if Time.now - @last_failure_time > @reset_timeout @state = :half_open puts "Circuit HALF-OPEN — probing..." true else false end else true # half-open allows one probe end end end ``` ```elixir title="check_state.ex" def allow_request?(cb) do Agent.get_and_update(cb, fn s -> case s.state do :closed -> {true, s} :open -> now = System.monotonic_time(:millisecond) if now - (s.last_failure_ms || 0) > s.reset_timeout_ms do IO.puts("Circuit HALF-OPEN — probing...") {true, %{s | state: :half_open}} else {false, s} end :half_open -> {true, s} # allow one probe end end) end ``` ### Use the Circuit Breaker [#use-the-circuit-breaker] Wrap your RPC calls with the circuit breaker. ```go title="usage.go" cb := NewCircuitBreaker(5, 30*time.Second) func sendCommand(ctx context.Context, client *kubemq.Client, body []byte) (*kubemq.CommandResponse, error) { if !cb.AllowRequest() { return nil, fmt.Errorf("circuit open — service unavailable") } resp, err := client.SendCommand(ctx, kubemq.NewCommand(). SetChannel("orders.process"). SetBody(body). SetTimeout(5 * time.Second)) if err != nil || !resp.Executed { cb.RecordFailure() return resp, err } cb.RecordSuccess() return resp, nil } ``` ```python title="usage.py" cb = CircuitBreaker(threshold=5, reset_timeout=30) def send_command(client, body): if not cb.allow_request(): raise RuntimeError("circuit open — service unavailable") try: response = client.send_command(CommandMessage( channel="orders.process", body=body, timeout_in_seconds=5)) if response.is_executed: cb.record_success() return response cb.record_failure() return response except Exception: cb.record_failure() raise ``` ```javascript title="usage.js" const cb = new CircuitBreaker(5, 30000); async function sendCommand(client, body) { if (!cb.allowRequest()) { throw new Error("circuit open — service unavailable"); } try { const response = await client.sendCommand({ channel: "orders.process", body: Buffer.from(body), timeoutInSeconds: 5, }); if (response.isExecuted) { cb.recordSuccess(); } else { cb.recordFailure(); } return response; } catch (err) { cb.recordFailure(); throw err; } } ``` ```java title="Usage.java" CircuitBreaker cb = new CircuitBreaker(5, 30000); CommandResponseMessage sendCommand(CQClient client, byte[] body) { if (!cb.allowRequest()) { throw new RuntimeException("circuit open — service unavailable"); } try { var resp = client.sendCommandRequest(CommandMessage.builder() .channel("orders.process").body(body).timeout(5000).build()); if (resp.isExecuted()) cb.recordSuccess(); else cb.recordFailure(); return resp; } catch (Exception e) { cb.recordFailure(); throw e; } } ``` ```csharp title="Usage.cs" var cb = new CircuitBreaker(5, TimeSpan.FromSeconds(30)); async Task SendCommand(KubeMQClient client, byte[] body) { if (!cb.AllowRequest()) throw new InvalidOperationException("circuit open — service unavailable"); try { var resp = await client.SendCommandAsync(new CommandMessage { Channel = "orders.process", Body = body, Timeout = TimeSpan.FromSeconds(5) }); if (resp.IsExecuted) cb.RecordSuccess(); else cb.RecordFailure(); return resp; } catch { cb.RecordFailure(); throw; } } ``` ```kotlin title="Usage.kt" val cb = CircuitBreaker(threshold = 5, resetTimeout = 30000) fun sendCommand(client: CQClient, body: ByteArray): CommandResponse { if (!cb.allowRequest()) throw RuntimeException("circuit open — service unavailable") return try { val resp = client.sendCommand(CommandMessage( channel = "orders.process", body = body, timeout = 5000)) if (resp.isExecuted) cb.recordSuccess() else cb.recordFailure() resp } catch (e: Exception) { cb.recordFailure() throw e } } ``` ```cpp title="usage.cpp" CircuitBreaker cb(5, 30); kubemq::CommandResponse sendCommand(kubemq::CQClient& client, const std::string& body) { if (!cb.allowRequest()) { throw std::runtime_error("circuit open — service unavailable"); } try { kubemq::CommandMessage cmd; cmd.channel = "orders.process"; cmd.body = body; cmd.timeout = 5000; auto resp = client.sendCommand(cmd); if (resp.isExecuted) cb.recordSuccess(); else cb.recordFailure(); return resp; } catch (...) { cb.recordFailure(); throw; } } ``` ```rust title="usage.rs" use kubemq::prelude::*; use kubemq::CommandBuilder; use std::error::Error; use std::time::Duration; async fn send_command( client: &KubemqClient, cb: &mut CircuitBreaker, body: Vec, ) -> Result<(), Box> { if !cb.allow_request() { return Err("circuit open — service unavailable".into()); } let command = CommandBuilder::new() .channel("orders.process") .body(body) .timeout(Duration::from_secs(5)) .build(); match client.send_command(command).await { Ok(resp) if resp.executed => { cb.record_success(); Ok(()) } Ok(_) => { cb.record_failure(); Ok(()) } Err(e) => { cb.record_failure(); Err(Box::new(e)) } } } ``` ```ruby title="usage.rb" require 'kubemq' cb = CircuitBreaker.new(threshold: 5, reset_timeout: 30) def send_command(client, cb, body) raise 'circuit open — service unavailable' unless cb.allow_request? begin msg = KubeMQ::CQ::CommandMessage.new( channel: 'orders.process', body: body, timeout: 5000 ) result = client.send_command(msg) if result.executed cb.record_success else cb.record_failure end result rescue KubeMQ::Error cb.record_failure raise end end ``` ```elixir title="usage.ex" def send_command(client, cb, body) do unless CircuitBreaker.allow_request?(cb) do {:error, :circuit_open} else command = KubeMQ.Command.new( channel: "orders.process", body: body, timeout: 5_000 ) case KubeMQ.Client.send_command(client, command) do {:ok, %{executed: true} = resp} -> CircuitBreaker.record_success(cb) {:ok, resp} {:ok, resp} -> CircuitBreaker.record_failure(cb) {:ok, resp} {:error, err} -> CircuitBreaker.record_failure(cb) {:error, err} end end end ``` ## Configuration [#configuration] | Parameter | Description | Recommended | | --------------------- | ----------------------------------------------- | ------------- | | **Failure threshold** | Consecutive failures before opening the circuit | 3–5 | | **Reset timeout** | How long the circuit stays open before probing | 15–60 seconds | | **Half-open probes** | Number of test requests before closing | 1–3 | Combine circuit breakers with [timeout configuration](/learn/rpc/how-to/timeout-configuration) and [load balancing](/learn/rpc/how-to/load-balancing) for a resilient RPC setup. # Load Balance Across Responders (/learn/rpc/how-to/load-balancing) ## How Load Balancing Works [#how-load-balancing-works] When multiple responders subscribe to the same channel with the same **group** name, KubeMQ distributes requests across them in round-robin fashion. Each request goes to exactly one responder in the group. *Responders sharing a group act as competing consumers — KubeMQ routes each request to exactly one member.* ## Steps [#steps] ### Deploy Multiple Responders with Same Group [#deploy-multiple-responders-with-same-group] Each responder subscribes with `group="order-workers"`. KubeMQ routes each request to one member of the group. ```go title="responder.go" workerId := os.Getenv("WORKER_ID") _, err := client.SubscribeToCommands(ctx, "orders.process", "order-workers", kubemq.WithOnCommandReceive(func(cmd *kubemq.CommandReceive) { fmt.Printf("[Worker %s] Processing: %s\n", workerId, cmd.Body) resp := kubemq.NewCommandReply(). SetRequestId(cmd.Id). SetResponseTo(cmd.ResponseTo). SetExecutedAt(time.Now()) _ = client.SendCommandResponse(ctx, resp) }), kubemq.WithOnError(func(err error) { log.Println("Error:", err) }), ) ``` ```python title="responder.py" import os worker_id = os.getenv("WORKER_ID", "1") def on_command(request): print(f"[Worker {worker_id}] Processing: {request.body.decode('utf-8')}") client.send_response_message( CommandResponse(command_received=request, is_executed=True) ) client.subscribe_to_commands( subscription=CommandsSubscription( channel="orders.process", group="order-workers", on_receive_command_callback=on_command, on_error_callback=lambda e: print(f"Error: {e}"), ), cancel=cancel, ) ``` ```javascript title="responder.js" const workerId = process.env.WORKER_ID || "1"; client.subscribeToCommands({ channel: "orders.process", group: "order-workers", onCommand: (cmd) => { console.log(`[Worker ${workerId}] Processing:`, Buffer.from(cmd.body).toString()); client.sendCommandResponse({ id: cmd.id, replyChannel: cmd.replyChannel, executed: true }); }, onError: (err) => console.error("Error:", err.message), }); ``` ```java title="Responder.java" String workerId = System.getenv("WORKER_ID"); client.subscribeToCommands(CommandsSubscription.builder() .channel("orders.process") .group("order-workers") .onReceiveCommandCallback(cmd -> { System.out.printf("[Worker %s] Processing: %s%n", workerId, new String(cmd.getBody())); return CommandResponseMessage.builder() .requestId(cmd.getId()).isExecuted(true).build(); }) .onErrorCallback(err -> System.err.println("Error: " + err.getMessage())) .build()); ``` ```csharp title="Responder.cs" var workerId = Environment.GetEnvironmentVariable("WORKER_ID") ?? "1"; await foreach (var cmd in client.SubscribeToCommandsAsync( new CommandsSubscription { Channel = "orders.process", Group = "order-workers" })) { Console.WriteLine($"[Worker {workerId}] Processing: " + Encoding.UTF8.GetString(cmd.Body.Span)); await client.SendCommandResponseAsync(new CommandResponse { RequestId = cmd.Id, IsExecuted = true }); } ``` ```kotlin title="Responder.kt" val workerId = System.getenv("WORKER_ID") ?: "1" client.subscribeToCommands( channel = "orders.process", group = "order-workers", onCommand = { cmd -> println("[Worker $workerId] Processing: ${String(cmd.body)}") client.sendCommandResponse(requestId = cmd.id, isExecuted = true) }, onError = { err -> System.err.println("Error: ${err.message}") } ) ``` ```cpp title="responder.cpp" std::string workerId = std::getenv("WORKER_ID") ? std::getenv("WORKER_ID") : "1"; client.subscribeToCommands("orders.process", "order-workers", [&](const kubemq::CommandReceive& cmd) { std::cout << "[Worker " << workerId << "] Processing: " << cmd.body << std::endl; client.sendCommandResponse(cmd.id, true); }, [](const std::string& err) { std::cerr << "Error: " << err << std::endl; } ); ``` ```rust title="responder.rs" let worker_id = std::env::var("WORKER_ID").unwrap_or_else(|_| "1".into()); let rc = client.clone(); let sub = client .subscribe_to_commands( "orders.process", "order-workers", // shared group → competing consumers move |cmd| { let c = rc.clone(); let id = worker_id.clone(); Box::pin(async move { println!("[Worker {}] Processing: {}", id, String::from_utf8_lossy(&cmd.body)); let reply = CommandReplyBuilder::new() .request_id(&cmd.id) .response_to(&cmd.response_to) .build(); let _ = c.send_command_response(reply).await; }) }, None, ) .await?; ``` ```ruby title="responder.rb" worker_id = ENV.fetch("WORKER_ID", "1") sub = KubeMQ::CQ::CommandsSubscription.new(channel: "orders.process", group: "order-workers") client.subscribe_to_commands(sub, cancellation_token: cancel, on_error: ->(e) { puts "Error: #{e.message}" }) do |cmd| puts "[Worker #{worker_id}] Processing: #{cmd.body}" client.send_response(KubeMQ::CQ::CommandResponseMessage.new( request_id: cmd.id, reply_channel: cmd.reply_channel, executed: true )) end ``` ```elixir title="responder.exs" worker_id = System.get_env("WORKER_ID", "1") {:ok, sub} = KubeMQ.Client.subscribe_to_commands(client, "orders.process", group: "order-workers", on_command: fn cmd -> IO.puts("[Worker #{worker_id}] Processing: #{cmd.body}") KubeMQ.CommandReply.new( request_id: cmd.id, response_to: cmd.reply_channel, executed: true) end ) ``` ### Send Requests [#send-requests] The sender does not need any changes — KubeMQ handles the distribution automatically. ```go title="sender.go" for i := 0; i < 10; i++ { resp, err := client.SendCommand(ctx, kubemq.NewCommand(). SetChannel("orders.process"). SetBody([]byte(fmt.Sprintf(`{"orderId":"ORD-%04d"}`, i))). SetTimeout(10 * time.Second)) if err != nil { log.Printf("Request %d failed: %v", i, err) continue } log.Printf("Request %d — Executed: %v", i, resp.Executed) } ``` ```python title="sender.py" for i in range(10): response = client.send_command( CommandMessage( channel="orders.process", body=f'{{"orderId":"ORD-{i:04d}"}}'.encode(), timeout_in_seconds=10, ) ) print(f"Request {i} — Executed: {response.is_executed}") ``` ```javascript title="sender.js" for (let i = 0; i < 10; i++) { const response = await client.sendCommand({ channel: "orders.process", body: Buffer.from(JSON.stringify({ orderId: `ORD-${String(i).padStart(4, "0")}` })), timeoutInSeconds: 10, }); console.log(`Request ${i} — Executed: ${response.executed}`); } ``` ```java title="Sender.java" for (int i = 0; i < 10; i++) { var resp = client.sendCommandRequest(CommandMessage.builder() .channel("orders.process") .body(String.format("{\"orderId\":\"ORD-%04d\"}", i).getBytes()) .timeout(10000).build()); System.out.printf("Request %d — Executed: %s%n", i, resp.isExecuted()); } ``` ```csharp title="Sender.cs" for (int i = 0; i < 10; i++) { var resp = await client.SendCommandAsync(new CommandMessage { Channel = "orders.process", Body = Encoding.UTF8.GetBytes($"{{\"orderId\":\"ORD-{i:D4}\"}}"), Timeout = TimeSpan.FromSeconds(10) }); Console.WriteLine($"Request {i} — Executed: {resp.IsExecuted}"); } ``` ```kotlin title="Sender.kt" repeat(10) { i -> val resp = client.sendCommand(CommandMessage( channel = "orders.process", body = """{"orderId":"ORD-${"%04d".format(i)}"}""".toByteArray(), timeout = 10000)) println("Request $i — Executed: ${resp.isExecuted}") } ``` ```cpp title="sender.cpp" for (int i = 0; i < 10; i++) { kubemq::CommandMessage cmd; cmd.channel = "orders.process"; cmd.body = R"({"orderId":"ORD-)" + std::to_string(i) + R"("})"; cmd.timeout = 10000; auto resp = client.sendCommand(cmd); std::cout << "Request " << i << " — Executed: " << resp.isExecuted << std::endl; } ``` ```rust title="sender.rs" for i in 0..10 { let command = CommandBuilder::new() .channel("orders.process") .body(format!(r#"{{"orderId":"ORD-{:04}"}}"#, i).into_bytes()) .timeout(Duration::from_secs(10)) .build(); let resp = client.send_command(command).await?; println!("Request {} — Executed: {}", i, resp.executed); } ``` ```ruby title="sender.rb" 10.times do |i| msg = KubeMQ::CQ::CommandMessage.new( channel: "orders.process", timeout: 10, body: format('{"orderId":"ORD-%04d"}', i) ) result = client.send_command(msg) puts "Request #{i} — Executed: #{result.executed}" end ``` ```elixir title="sender.exs" for i <- 0..9 do cmd = KubeMQ.Command.new( channel: "orders.process", body: ~s({"orderId":"ORD-#{String.pad_leading(to_string(i), 4, "0")}"}), timeout: 10_000 ) case KubeMQ.Client.send_command(client, cmd) do {:ok, resp} -> IO.puts("Request #{i} — Executed: #{resp.executed}") {:error, err} -> IO.puts("Request #{i} failed: #{err.message}") end end ``` ## Group Behavior [#group-behavior] | Configuration | Behavior | | --------------------- | ---------------------------------------------------------------------------------- | | Same group name | **Competing consumers** — each request goes to exactly one responder (round-robin) | | Empty group (`""`) | **Fan-out** — every responder receives every request | | Different group names | **Independent pools** — each group gets its own copy of each request | Groups work the same way for both commands and queries. Use the `group` parameter when subscribing. ## Scaling Strategy [#scaling-strategy] * **Add responders** to the same group to increase throughput * All responders must be **stateless** — any responder should handle any request * Responders can be added or removed dynamically without affecting the sender * KubeMQ automatically rebalances when group membership changes # Configure Timeouts & Retries (/learn/rpc/how-to/timeout-configuration) ## Request Timeout [#request-timeout] Every RPC request requires a timeout in milliseconds. If no response arrives before the timeout expires, the sender receives error code 301 (`Request Timeout`). The diagram below shows both outcomes for the same request: a response that arrives in time, and one where the timeout fires before the responder replies. *The sender unblocks with error 301 the moment the timeout elapses — the responder may finish later, but its reply is discarded.* ### Setting Timeout per Request [#setting-timeout-per-request] ```go title="timeout.go" resp, err := client.SendCommand(ctx, kubemq.NewCommand(). SetChannel("orders.process"). SetBody([]byte("create order")). SetTimeout(5 * time.Second)) ``` ```python title="timeout.py" response = client.send_command( CommandMessage( channel="orders.process", body=b"create order", timeout_in_seconds=5, ) ) ``` ```javascript title="timeout.js" const response = await client.sendCommand({ channel: "orders.process", body: Buffer.from("create order"), timeoutInSeconds: 5, }); ``` ```java title="Timeout.java" CommandResponseMessage response = client.sendCommandRequest( CommandMessage.builder() .channel("orders.process") .body("create order".getBytes()) .timeout(5000) // milliseconds .build()); ``` ```csharp title="Timeout.cs" var response = await client.SendCommandAsync(new CommandMessage { Channel = "orders.process", Body = Encoding.UTF8.GetBytes("create order"), Timeout = TimeSpan.FromSeconds(5) }); ``` ```kotlin title="Timeout.kt" val response = client.sendCommand(CommandMessage( channel = "orders.process", body = "create order".toByteArray(), timeout = 5000 // milliseconds )) ``` ```cpp title="timeout.cpp" kubemq::CommandMessage cmd; cmd.channel = "orders.process"; cmd.body = "create order"; cmd.timeout = 5000; // milliseconds auto response = client.sendCommand(cmd); ``` ```rust title="timeout.rs" use kubemq::prelude::*; use kubemq::CommandBuilder; use std::time::Duration; let command = CommandBuilder::new() .channel("orders.process") .body(b"create order".to_vec()) .timeout(Duration::from_secs(5)) .build(); match client.send_command(command).await { Ok(resp) => println!("executed={}, error='{}'", resp.executed, resp.error), Err(e) => println!("request timed out: {}", e), } ``` ```ruby title="timeout.rb" msg = KubeMQ::CQ::CommandMessage.new( channel: "orders.process", body: "create order", timeout: 5 # seconds ) result = client.send_command(msg) puts "executed=#{result.executed}, error=#{result.error}" ``` ```elixir title="timeout.exs" cmd = KubeMQ.Command.new( channel: "orders.process", body: "create order", timeout: 5_000 # milliseconds ) case KubeMQ.Client.send_command(client, cmd) do {:ok, response} -> IO.puts("executed: #{response.executed}") {:error, err} -> IO.puts("request timed out: #{err.message}") end ``` ### What Happens on Timeout [#what-happens-on-timeout] * The sender receives error code **301** (`Request Timeout`) * The responder may still be processing — KubeMQ does not cancel in-flight work * The request is not automatically retried ## Retry Strategies [#retry-strategies] ### Simple Retry [#simple-retry] Retry a fixed number of times on failure or timeout. ```go title="simple_retry.go" func sendWithRetry(ctx context.Context, client *kubemq.Client, cmd *kubemq.Command, maxRetries int) (*kubemq.CommandResponse, error) { var lastErr error for i := 0; i <= maxRetries; i++ { resp, err := client.SendCommand(ctx, cmd) if err == nil && resp.Executed { return resp, nil } lastErr = err if err != nil { log.Printf("Attempt %d failed: %v", i+1, err) } else { log.Printf("Attempt %d failed: %s", i+1, resp.Error) } } return nil, fmt.Errorf("all %d retries failed: %w", maxRetries+1, lastErr) } ``` ```python title="simple_retry.py" def send_with_retry(client, message, max_retries=3): last_error = None for attempt in range(max_retries + 1): try: response = client.send_command(message) if response.is_executed: return response last_error = response.error print(f"Attempt {attempt + 1} failed: {response.error}") except Exception as e: last_error = str(e) print(f"Attempt {attempt + 1} failed: {e}") raise RuntimeError(f"All {max_retries + 1} retries failed: {last_error}") ``` ```javascript title="simple_retry.js" async function sendWithRetry(client, opts, maxRetries = 3) { let lastError; for (let i = 0; i <= maxRetries; i++) { try { const response = await client.sendCommand(opts); if (response.isExecuted) return response; lastError = response.error; console.log(`Attempt ${i + 1} failed: ${response.error}`); } catch (err) { lastError = err.message; console.log(`Attempt ${i + 1} failed: ${err.message}`); } } throw new Error(`All ${maxRetries + 1} retries failed: ${lastError}`); } ``` ```java title="SimpleRetry.java" CommandResponseMessage sendWithRetry(CQClient client, CommandMessage msg, int maxRetries) throws Exception { Exception lastError = null; for (int i = 0; i <= maxRetries; i++) { try { var resp = client.sendCommandRequest(msg); if (resp.isExecuted()) return resp; System.out.printf("Attempt %d failed: %s%n", i + 1, resp.getError()); } catch (Exception e) { lastError = e; System.out.printf("Attempt %d failed: %s%n", i + 1, e.getMessage()); } } throw new RuntimeException("All retries failed", lastError); } ``` ```csharp title="SimpleRetry.cs" async Task SendWithRetry(KubeMQClient client, CommandMessage msg, int maxRetries = 3) { Exception? lastError = null; for (int i = 0; i <= maxRetries; i++) { try { var resp = await client.SendCommandAsync(msg); if (resp.IsExecuted) return resp; Console.WriteLine($"Attempt {i + 1} failed: {resp.Error}"); } catch (Exception ex) { lastError = ex; Console.WriteLine($"Attempt {i + 1} failed: {ex.Message}"); } } throw new InvalidOperationException("All retries failed", lastError); } ``` ```kotlin title="SimpleRetry.kt" fun sendWithRetry(client: CQClient, msg: CommandMessage, maxRetries: Int = 3): CommandResponse { var lastError: Exception? = null repeat(maxRetries + 1) { attempt -> try { val resp = client.sendCommand(msg) if (resp.isExecuted) return resp println("Attempt ${attempt + 1} failed: ${resp.error}") } catch (e: Exception) { lastError = e println("Attempt ${attempt + 1} failed: ${e.message}") } } throw RuntimeException("All retries failed", lastError) } ``` ```cpp title="simple_retry.cpp" kubemq::CommandResponse sendWithRetry(kubemq::CQClient& client, kubemq::CommandMessage& cmd, int maxRetries = 3) { std::string lastError; for (int i = 0; i <= maxRetries; i++) { try { auto resp = client.sendCommand(cmd); if (resp.isExecuted) return resp; lastError = resp.error; } catch (const std::exception& e) { lastError = e.what(); } } throw std::runtime_error("All retries failed: " + lastError); } ``` ```rust title="simple_retry.rs" use kubemq::prelude::*; async fn send_with_retry( client: &KubemqClient, command: Command, max_retries: u32, ) -> Result { let mut last_error = String::new(); for attempt in 0..=max_retries { match client.send_command(command.clone()).await { Ok(resp) if resp.executed => return Ok(resp), Ok(resp) => last_error = resp.error, Err(e) => last_error = e.to_string(), } println!("Attempt {} failed: {}", attempt + 1, last_error); } Err(format!("all {} retries failed: {}", max_retries + 1, last_error)) } ``` ```ruby title="simple_retry.rb" def send_with_retry(client, msg, max_retries = 3) last_error = nil (0..max_retries).each do |attempt| begin result = client.send_command(msg) return result if result.error.to_s.empty? && result.executed last_error = result.error rescue KubeMQ::Error => e last_error = e.message end puts "Attempt #{attempt + 1} failed: #{last_error}" end raise "All #{max_retries + 1} retries failed: #{last_error}" end ``` ```elixir title="simple_retry.exs" defmodule Retry do def send_with_retry(client, cmd, max_retries \\ 3) do do_send(client, cmd, 0, max_retries, nil) end defp do_send(_client, _cmd, attempt, max, last) when attempt > max do {:error, "all #{max + 1} retries failed: #{inspect(last)}"} end defp do_send(client, cmd, attempt, max, _last) do case KubeMQ.Client.send_command(client, cmd) do {:ok, %{executed: true} = resp} -> {:ok, resp} {:ok, %{error: error}} -> IO.puts("Attempt #{attempt + 1} failed: #{error}") do_send(client, cmd, attempt + 1, max, error) {:error, err} -> IO.puts("Attempt #{attempt + 1} failed: #{err.message}") do_send(client, cmd, attempt + 1, max, err.message) end end end ``` ### Exponential Backoff [#exponential-backoff] Increase the delay between retries to avoid overwhelming a recovering service. ```go title="backoff.go" func sendWithBackoff(ctx context.Context, client *kubemq.Client, cmd *kubemq.Command, maxRetries int) (*kubemq.CommandResponse, error) { for i := 0; i <= maxRetries; i++ { resp, err := client.SendCommand(ctx, cmd) if err == nil && resp.Executed { return resp, nil } if i < maxRetries { delay := time.Duration(1< ```python title="backoff.py" import time def send_with_backoff(client, message, max_retries=3): for attempt in range(max_retries + 1): try: response = client.send_command(message) if response.is_executed: return response except Exception: pass if attempt < max_retries: delay = 2 ** attempt # 1s, 2s, 4s, 8s... print(f"Retry in {delay}s...") time.sleep(delay) raise RuntimeError("All retries exhausted") ``` ```javascript title="backoff.js" async function sendWithBackoff(client, opts, maxRetries = 3) { for (let i = 0; i <= maxRetries; i++) { try { const response = await client.sendCommand(opts); if (response.isExecuted) return response; } catch {} if (i < maxRetries) { const delay = Math.pow(2, i) * 1000; // 1s, 2s, 4s, 8s... console.log(`Retry in ${delay}ms...`); await new Promise((r) => setTimeout(r, delay)); } } throw new Error("All retries exhausted"); } ``` ```java title="Backoff.java" CommandResponseMessage sendWithBackoff(CQClient client, CommandMessage msg, int maxRetries) throws Exception { for (int i = 0; i <= maxRetries; i++) { try { var resp = client.sendCommandRequest(msg); if (resp.isExecuted()) return resp; } catch (Exception ignored) {} if (i < maxRetries) { long delay = (long) Math.pow(2, i) * 1000; System.out.printf("Retry in %dms...%n", delay); Thread.sleep(delay); } } throw new RuntimeException("All retries exhausted"); } ``` ```csharp title="Backoff.cs" async Task SendWithBackoff(KubeMQClient client, CommandMessage msg, int maxRetries = 3) { for (int i = 0; i <= maxRetries; i++) { try { var resp = await client.SendCommandAsync(msg); if (resp.IsExecuted) return resp; } catch { } if (i < maxRetries) { var delay = TimeSpan.FromSeconds(Math.Pow(2, i)); Console.WriteLine($"Retry in {delay}..."); await Task.Delay(delay); } } throw new InvalidOperationException("All retries exhausted"); } ``` ```kotlin title="Backoff.kt" suspend fun sendWithBackoff(client: CQClient, msg: CommandMessage, maxRetries: Int = 3): CommandResponse { repeat(maxRetries + 1) { attempt -> try { val resp = client.sendCommand(msg) if (resp.isExecuted) return resp } catch (_: Exception) {} if (attempt < maxRetries) { val delay = (1L shl attempt) * 1000 println("Retry in ${delay}ms...") Thread.sleep(delay) } } throw RuntimeException("All retries exhausted") } ``` ```cpp title="backoff.cpp" kubemq::CommandResponse sendWithBackoff(kubemq::CQClient& client, kubemq::CommandMessage& cmd, int maxRetries = 3) { for (int i = 0; i <= maxRetries; i++) { try { auto resp = client.sendCommand(cmd); if (resp.isExecuted) return resp; } catch (...) {} if (i < maxRetries) { auto delay = std::chrono::seconds(1 << i); std::this_thread::sleep_for(delay); } } throw std::runtime_error("All retries exhausted"); } ``` ```rust title="backoff.rs" use kubemq::prelude::*; use std::time::Duration; async fn send_with_backoff( client: &KubemqClient, command: Command, max_retries: u32, ) -> Result { for attempt in 0..=max_retries { if let Ok(resp) = client.send_command(command.clone()).await { if resp.executed { return Ok(resp); } } if attempt < max_retries { let delay = Duration::from_secs(1 << attempt); // 1s, 2s, 4s, 8s... println!("Retry in {:?}...", delay); tokio::time::sleep(delay).await; } } Err("all retries exhausted".to_string()) } ``` ```ruby title="backoff.rb" def send_with_backoff(client, msg, max_retries = 3) (0..max_retries).each do |attempt| begin result = client.send_command(msg) return result if result.error.to_s.empty? && result.executed rescue KubeMQ::Error # fall through to backoff end if attempt < max_retries delay = 2**attempt # 1s, 2s, 4s, 8s... puts "Retry in #{delay}s..." sleep(delay) end end raise "All retries exhausted" end ``` ```elixir title="backoff.exs" defmodule Backoff do def send_with_backoff(client, cmd, max_retries \\ 3) do do_send(client, cmd, 0, max_retries) end defp do_send(_client, _cmd, attempt, max) when attempt > max do {:error, "all retries exhausted"} end defp do_send(client, cmd, attempt, max) do case KubeMQ.Client.send_command(client, cmd) do {:ok, %{executed: true} = resp} -> {:ok, resp} _ -> if attempt < max do delay = :math.pow(2, attempt) |> round() # 1s, 2s, 4s, 8s... IO.puts("Retry in #{delay}s...") Process.sleep(delay * 1_000) end do_send(client, cmd, attempt + 1, max) end end end ``` ## Idempotency Considerations [#idempotency-considerations] When retrying commands, the responder may receive the same request multiple times. Use the `RequestID` field to deduplicate: * KubeMQ auto-generates a unique `RequestID` (NUID) for each request * Set a custom `RequestID` to enable deduplication on the responder side * The responder should track processed request IDs and skip duplicates Commands should be **idempotent** when retries are enabled. Creating an order twice with the same ID should produce the same result, not duplicate orders. ## Best Practices [#best-practices] | Practice | Recommendation | | ------------------- | ---------------------------------------------------------------------------------- | | **Timeout value** | Set slightly longer than expected processing time | | **Max retries** | 2–3 for transient errors, 0 for known permanent failures | | **Backoff base** | Start at 1 second, cap at 30 seconds | | **Monitoring** | Log all timeout errors for alerting | | **Circuit breaker** | Use a [circuit breaker](/learn/rpc/how-to/circuit-breaker) for persistent failures | # Handle Commands (/learn/rpc/tutorials/handle-commands) ## What You Will Build [#what-you-will-build] An order processing handler that receives commands, executes business logic, and sends back success or failure responses. ## How It Works [#how-it-works] A responder subscribes to a command channel, processes each incoming command, and replies with an execution result. KubeMQ correlates the reply back to the blocked sender. *Responder flow: KubeMQ routes each command to the handler and returns its execution result to the waiting sender.* ## Steps [#steps] ### Subscribe to Commands [#subscribe-to-commands] Subscribe to the `orders.process` channel to receive incoming commands. Optionally specify a group name for load balancing across multiple responders. ```go title="subscribe.go" package main import ( "context" "encoding/json" "fmt" "log" "time" "github.com/kubemq-io/kubemq-go/v2" ) type OrderCommand struct { Action string `json:"action"` OrderID string `json:"orderId"` } func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() _, err = client.SubscribeToCommands(ctx, "orders.process", "", kubemq.WithOnCommandReceive(func(cmd *kubemq.CommandReceive) { handleCommand(ctx, client, cmd) }), kubemq.WithOnError(func(err error) { log.Println("Subscription error:", err) }), ) if err != nil { log.Fatal(err) } fmt.Println("Command handler ready...") <-ctx.Done() } ``` ```python title="subscribe.py" import json import time from kubemq.cq import ( Client as CQClient, CommandsSubscription, CommandReceived, CommandResponse, CancellationToken, ) client = CQClient(address="localhost:50000") cancel = CancellationToken() def handle_command(request: CommandReceived) -> None: order = json.loads(request.body) print(f"Handling {order['action']} for {order['orderId']}") # Process the command (see next step) client.send_response_message( CommandResponse(command_received=request, is_executed=True) ) client.subscribe_to_commands( subscription=CommandsSubscription( channel="orders.process", on_receive_command_callback=handle_command, on_error_callback=lambda e: print(f"Subscription error: {e}"), ), cancel=cancel, ) print("Command handler ready...") time.sleep(3600) ``` ```javascript title="subscribe.js" const { KubeMQClient } = require("kubemq-js"); const client = new KubeMQClient({ address: "localhost:50000" }); client.subscribeToCommands({ channel: "orders.process", onCommand: (cmd) => handleCommand(client, cmd), onError: (err) => console.error("Subscription error:", err.message), }); console.log("Command handler ready..."); ``` ```java title="Subscribe.java" CQClient client = CQClient.builder() .address("localhost:50000") .clientId("order-handler") .build(); client.subscribeToCommands(CommandsSubscription.builder() .channel("orders.process") .onReceiveCommandCallback(cmd -> handleCommand(client, cmd)) .onErrorCallback(err -> System.err.println("Subscription error: " + err.getMessage())) .build()); System.out.println("Command handler ready..."); Thread.sleep(3600000); client.close(); ``` ```csharp title="Subscribe.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); Console.WriteLine("Command handler ready..."); await foreach (var cmd in client.SubscribeToCommandsAsync( new CommandsSubscription { Channel = "orders.process" })) { await HandleCommand(client, cmd); } ``` ```kotlin title="Subscribe.kt" val client = CQClient("localhost:50000") client.subscribeToCommands( channel = "orders.process", onCommand = { cmd -> handleCommand(client, cmd) }, onError = { err -> System.err.println("Subscription error: ${err.message}") } ) println("Command handler ready...") Thread.sleep(3600000) client.close() ``` ```cpp title="subscribe.cpp" #include #include #include auto client = kubemq::CQClient("localhost:50000"); client.subscribeToCommands("orders.process", "", [&client](const kubemq::CommandReceive& cmd) { handleCommand(client, cmd); }, [](const std::string& err) { std::cerr << "Subscription error: " << err << std::endl; } ); std::cout << "Command handler ready..." << std::endl; std::this_thread::sleep_for(std::chrono::hours(1)); ``` ```rust title="subscribe.rs" use kubemq::prelude::*; use serde::Deserialize; #[derive(Deserialize)] struct OrderCommand { action: String, #[serde(rename = "orderId")] order_id: String, } #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; let rc = client.clone(); let _sub = client .subscribe_to_commands( "orders.process", "", // group: "" for no load balancing move |cmd| { let c = rc.clone(); Box::pin(async move { handle_command(c, cmd).await }) }, None, ) .await?; println!("Command handler ready..."); tokio::signal::ctrl_c().await.ok(); client.close().await?; Ok(()) } ``` ```ruby title="subscribe.rb" require 'kubemq' client = KubeMQ::CQClient.new(address: 'localhost:50000', client_id: 'order-handler') cancel = KubeMQ::CancellationToken.new sub = KubeMQ::CQ::CommandsSubscription.new(channel: 'orders.process') client.subscribe_to_commands( sub, cancellation_token: cancel, on_error: ->(e) { puts "Subscription error: #{e.message}" } ) do |cmd| handle_command(client, cmd) end puts 'Command handler ready...' cancel.wait ``` ```elixir title="subscribe.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-handler") # The on_command callback returns a CommandReply — the SDK sends it automatically {:ok, _sub} = KubeMQ.Client.subscribe_to_commands(client, "orders.process", on_command: fn cmd -> handle_command(cmd) end, on_error: fn err -> IO.puts("Subscription error: #{err.message}") end ) IO.puts("Command handler ready...") Process.sleep(:infinity) ``` ### Process the Command [#process-the-command] Parse the request body, execute business logic, and determine the result. ```go title="process.go" func handleCommand(ctx context.Context, client *kubemq.Client, cmd *kubemq.CommandReceive) { var order OrderCommand if err := json.Unmarshal(cmd.Body, &order); err != nil { sendError(ctx, client, cmd, "invalid request body") return } switch order.Action { case "create": fmt.Printf("Creating order %s\n", order.OrderID) case "cancel": fmt.Printf("Cancelling order %s\n", order.OrderID) default: sendError(ctx, client, cmd, "unknown action: "+order.Action) return } sendSuccess(ctx, client, cmd) } ``` ```python title="process.py" def handle_command(request: CommandReceived) -> None: try: order = json.loads(request.body) except json.JSONDecodeError: send_error(request, "invalid request body") return action = order.get("action") if action == "create": print(f"Creating order {order['orderId']}") elif action == "cancel": print(f"Cancelling order {order['orderId']}") else: send_error(request, f"unknown action: {action}") return send_success(request) ``` ```javascript title="process.js" function handleCommand(client, cmd) { let order; try { order = JSON.parse(Buffer.from(cmd.body).toString()); } catch { sendError(client, cmd, "invalid request body"); return; } switch (order.action) { case "create": console.log(`Creating order ${order.orderId}`); break; case "cancel": console.log(`Cancelling order ${order.orderId}`); break; default: sendError(client, cmd, `unknown action: ${order.action}`); return; } sendSuccess(client, cmd); } ``` ```java title="Process.java" private CommandResponseMessage handleCommand(CQClient client, CommandReceive cmd) { try { var order = new Gson().fromJson( new String(cmd.getBody()), OrderCommand.class); switch (order.action()) { case "create" -> System.out.println("Creating order " + order.orderId()); case "cancel" -> System.out.println("Cancelling order " + order.orderId()); default -> { return errorResponse(cmd, "unknown action: " + order.action()); } } return successResponse(cmd); } catch (Exception e) { return errorResponse(cmd, "invalid request body"); } } ``` ```csharp title="Process.cs" async Task HandleCommand(KubeMQClient client, CommandReceive cmd) { try { var order = JsonSerializer.Deserialize(cmd.Body.Span); switch (order?.Action) { case "create": Console.WriteLine($"Creating order {order.OrderId}"); break; case "cancel": Console.WriteLine($"Cancelling order {order.OrderId}"); break; default: await SendError(client, cmd, $"unknown action: {order?.Action}"); return; } await SendSuccess(client, cmd); } catch { await SendError(client, cmd, "invalid request body"); } } ``` ```kotlin title="Process.kt" fun handleCommand(client: CQClient, cmd: CommandReceive) { val order = try { Json.decodeFromString(String(cmd.body)) } catch (e: Exception) { sendError(client, cmd, "invalid request body") return } when (order.action) { "create" -> println("Creating order ${order.orderId}") "cancel" -> println("Cancelling order ${order.orderId}") else -> { sendError(client, cmd, "unknown action: ${order.action}"); return } } sendSuccess(client, cmd) } ``` ```cpp title="process.cpp" void handleCommand(kubemq::CQClient& client, const kubemq::CommandReceive& cmd) { auto order = nlohmann::json::parse(cmd.body, nullptr, false); if (order.is_discarded()) { sendError(client, cmd, "invalid request body"); return; } auto action = order["action"].get(); if (action == "create") { std::cout << "Creating order " << order["orderId"] << std::endl; } else if (action == "cancel") { std::cout << "Cancelling order " << order["orderId"] << std::endl; } else { sendError(client, cmd, "unknown action: " + action); return; } sendSuccess(client, cmd); } ``` ```rust title="process.rs" async fn handle_command(client: KubemqClient, cmd: CommandReceive) { let order: OrderCommand = match serde_json::from_slice(&cmd.body) { Ok(o) => o, Err(_) => return send_error(client, &cmd, "invalid request body").await, }; match order.action.as_str() { "create" => println!("Creating order {}", order.order_id), "cancel" => println!("Cancelling order {}", order.order_id), other => return send_error(client, &cmd, &format!("unknown action: {other}")).await, } send_success(client, &cmd).await; } ``` ```ruby title="process.rb" def handle_command(client, cmd) order = begin JSON.parse(cmd.body) rescue JSON::ParserError return send_error(client, cmd, 'invalid request body') end case order['action'] when 'create' then puts "Creating order #{order['orderId']}" when 'cancel' then puts "Cancelling order #{order['orderId']}" else return send_error(client, cmd, "unknown action: #{order['action']}") end send_success(client, cmd) end ``` ```elixir title="process.exs" def handle_command(cmd) do case Jason.decode(cmd.body) do {:ok, %{"action" => "create", "orderId" => id}} -> IO.puts("Creating order #{id}") send_success(cmd) {:ok, %{"action" => "cancel", "orderId" => id}} -> IO.puts("Cancelling order #{id}") send_success(cmd) {:ok, %{"action" => action}} -> send_error(cmd, "unknown action: #{action}") {:error, _} -> send_error(cmd, "invalid request body") end end ``` ### Send Success Response [#send-success-response] Return `Executed: true` to indicate the command was processed successfully. ```go title="success.go" func sendSuccess(ctx context.Context, client *kubemq.Client, cmd *kubemq.CommandReceive) { resp := kubemq.NewCommandReply(). SetRequestId(cmd.Id). SetResponseTo(cmd.ResponseTo). SetExecutedAt(time.Now()) _ = client.SendCommandResponse(ctx, resp) } ``` ```python title="success.py" def send_success(request: CommandReceived) -> None: client.send_response_message( CommandResponse(command_received=request, is_executed=True) ) ``` ```javascript title="success.js" function sendSuccess(client, cmd) { client.sendCommandResponse({ requestId: cmd.id, isExecuted: true }); } ``` ```java title="Success.java" private CommandResponseMessage successResponse(CommandReceive cmd) { return CommandResponseMessage.builder() .requestId(cmd.getId()) .isExecuted(true) .build(); } ``` ```csharp title="Success.cs" async Task SendSuccess(KubeMQClient client, CommandReceive cmd) => await client.SendCommandResponseAsync(new CommandResponse { RequestId = cmd.Id, IsExecuted = true }); ``` ```kotlin title="Success.kt" fun sendSuccess(client: CQClient, cmd: CommandReceive) { client.sendCommandResponse(requestId = cmd.id, isExecuted = true) } ``` ```cpp title="success.cpp" void sendSuccess(kubemq::CQClient& client, const kubemq::CommandReceive& cmd) { client.sendCommandResponse(cmd.id, true); } ``` ```rust title="success.rs" async fn send_success(client: KubemqClient, cmd: &CommandReceive) { // A reply with no error means Executed: true let reply = CommandReplyBuilder::new() .request_id(&cmd.id) .response_to(&cmd.response_to) .build(); let _ = client.send_command_response(reply).await; } ``` ```ruby title="success.rb" def send_success(client, cmd) response = KubeMQ::CQ::CommandResponseMessage.new( request_id: cmd.id, reply_channel: cmd.reply_channel, executed: true ) client.send_response(response) end ``` ```elixir title="success.exs" # The callback returns a CommandReply — the SDK sends it automatically def send_success(cmd) do KubeMQ.CommandReply.new( request_id: cmd.id, response_to: cmd.reply_channel, executed: true ) end ``` ### Send Error Response [#send-error-response] Return `Executed: false` with an error message when processing fails. ```go title="error.go" func sendError(ctx context.Context, client *kubemq.Client, cmd *kubemq.CommandReceive, errMsg string) { resp := kubemq.NewCommandReply(). SetRequestId(cmd.Id). SetResponseTo(cmd.ResponseTo). SetError(errMsg) _ = client.SendCommandResponse(ctx, resp) } ``` ```python title="error.py" def send_error(request: CommandReceived, error_msg: str) -> None: client.send_response_message( CommandResponse( command_received=request, is_executed=False, error=error_msg, ) ) ``` ```javascript title="error.js" function sendError(client, cmd, errorMsg) { client.sendCommandResponse({ requestId: cmd.id, isExecuted: false, error: errorMsg, }); } ``` ```java title="Error.java" private CommandResponseMessage errorResponse(CommandReceive cmd, String error) { return CommandResponseMessage.builder() .requestId(cmd.getId()) .isExecuted(false) .error(error) .build(); } ``` ```csharp title="Error.cs" async Task SendError(KubeMQClient client, CommandReceive cmd, string error) => await client.SendCommandResponseAsync(new CommandResponse { RequestId = cmd.Id, IsExecuted = false, Error = error }); ``` ```kotlin title="Error.kt" fun sendError(client: CQClient, cmd: CommandReceive, errorMsg: String) { client.sendCommandResponse( requestId = cmd.id, isExecuted = false, error = errorMsg ) } ``` ```cpp title="error.cpp" void sendError(kubemq::CQClient& client, const kubemq::CommandReceive& cmd, const std::string& errorMsg) { client.sendCommandResponse(cmd.id, false, errorMsg); } ``` ```rust title="error.rs" async fn send_error(client: KubemqClient, cmd: &CommandReceive, err_msg: &str) { // Setting an error marks the reply as not executed let reply = CommandReplyBuilder::new() .request_id(&cmd.id) .response_to(&cmd.response_to) .error(err_msg) .build(); let _ = client.send_command_response(reply).await; } ``` ```ruby title="error.rb" def send_error(client, cmd, error_msg) response = KubeMQ::CQ::CommandResponseMessage.new( request_id: cmd.id, reply_channel: cmd.reply_channel, executed: false, error: error_msg ) client.send_response(response) end ``` ```elixir title="error.exs" # Return a CommandReply with executed: false — the SDK sends it automatically def send_error(cmd, error_msg) do KubeMQ.CommandReply.new( request_id: cmd.id, response_to: cmd.reply_channel, executed: false, error: error_msg ) end ``` ## Responder Best Practices [#responder-best-practices] * **Keep processing fast** — the sender is blocking and waiting for your response * **Always send a response** — if you don't respond, the sender will timeout (code 301) * **Use groups for scaling** — multiple responders with the same group name share load via round-robin * **Handle unknown commands gracefully** — return `Executed: false` with a descriptive error ## Next Steps [#next-steps] # Handle Queries (/learn/rpc/tutorials/handle-queries) ## What You Will Build [#what-you-will-build] An order lookup handler that receives queries, retrieves data, and returns structured responses in the response body. Unlike command responders, query responders send back full data payloads. *The handler subscribes, looks up the data, and returns it in the response body before the sender's timeout expires.* ## Steps [#steps] ### Subscribe to Queries [#subscribe-to-queries] Subscribe to the `orders.lookup` channel. The handler receives each query, processes it, and must send a response before the sender's timeout expires. ```go title="query_handler.go" package main import ( "context" "encoding/json" "fmt" "log" "time" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() _, err = client.SubscribeToQueries(ctx, "orders.lookup", "", kubemq.WithOnQueryReceive(func(query *kubemq.QueryReceive) { handleQuery(ctx, client, query) }), kubemq.WithOnError(func(err error) { log.Println("Subscription error:", err) }), ) if err != nil { log.Fatal(err) } fmt.Println("Query handler ready on 'orders.lookup'...") <-ctx.Done() } ``` ```python title="query_handler.py" import json import time from kubemq.cq import ( Client as CQClient, QueriesSubscription, QueryReceived, QueryResponse, CancellationToken, ) client = CQClient(address="localhost:50000") cancel = CancellationToken() client.subscribe_to_queries( subscription=QueriesSubscription( channel="orders.lookup", on_receive_query_callback=handle_query, on_error_callback=lambda e: print(f"Subscription error: {e}"), ), cancel=cancel, ) print("Query handler ready on 'orders.lookup'...") time.sleep(3600) ``` ```javascript title="query_handler.js" const { KubeMQClient } = require("kubemq-js"); const client = new KubeMQClient({ address: "localhost:50000" }); client.subscribeToQueries({ channel: "orders.lookup", onQuery: (query) => handleQuery(client, query), onError: (err) => console.error("Subscription error:", err.message), }); console.log("Query handler ready on 'orders.lookup'..."); ``` ```java title="QueryHandler.java" CQClient client = CQClient.builder() .address("localhost:50000") .clientId("order-query-handler") .build(); client.subscribeToQueries(QueriesSubscription.builder() .channel("orders.lookup") .onReceiveQueryCallback(query -> handleQuery(client, query)) .onErrorCallback(err -> System.err.println("Subscription error: " + err.getMessage())) .build()); System.out.println("Query handler ready on 'orders.lookup'..."); Thread.sleep(3600000); client.close(); ``` ```csharp title="QueryHandler.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); Console.WriteLine("Query handler ready on 'orders.lookup'..."); await foreach (var query in client.SubscribeToQueriesAsync( new QueriesSubscription { Channel = "orders.lookup" })) { await HandleQuery(client, query); } ``` ```kotlin title="QueryHandler.kt" val client = CQClient("localhost:50000") client.subscribeToQueries( channel = "orders.lookup", onQuery = { query -> handleQuery(client, query) }, onError = { err -> System.err.println("Subscription error: ${err.message}") } ) println("Query handler ready on 'orders.lookup'...") Thread.sleep(3600000) client.close() ``` ```cpp title="query_handler.cpp" #include #include #include auto client = kubemq::CQClient("localhost:50000"); client.subscribeToQueries("orders.lookup", "", [&client](const kubemq::QueryReceive& query) { handleQuery(client, query); }, [](const std::string& err) { std::cerr << "Subscription error: " << err << std::endl; } ); std::cout << "Query handler ready on 'orders.lookup'..." << std::endl; std::this_thread::sleep_for(std::chrono::hours(1)); ``` ```rust title="query_handler.rs" use kubemq::prelude::*; use std::time::Duration; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; let rc = client.clone(); let _sub = client .subscribe_to_queries( "orders.lookup", "", move |query| { let c = rc.clone(); Box::pin(async move { handle_query(c, query).await }) }, None, ) .await?; println!("Query handler ready on 'orders.lookup'..."); tokio::time::sleep(Duration::from_secs(3600)).await; client.close().await?; Ok(()) } ``` ```ruby title="query_handler.rb" require 'kubemq' client = KubeMQ::CQClient.new(address: 'localhost:50000', client_id: 'order-query-handler') cancel = KubeMQ::CancellationToken.new sub = KubeMQ::CQ::QueriesSubscription.new(channel: 'orders.lookup') client.subscribe_to_queries(sub, cancellation_token: cancel, on_error: lambda { |e| puts "Subscription error: #{e.message}" }) do |query| handle_query(client, query) end puts "Query handler ready on 'orders.lookup'..." cancel.wait ``` ```elixir title="query_handler.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-query-handler") {:ok, _sub} = KubeMQ.Client.subscribe_to_queries(client, "orders.lookup", on_query: fn query -> handle_query(query) end, on_error: fn err -> IO.puts("Subscription error: #{err.message}") end ) IO.puts("Query handler ready on 'orders.lookup'...") Process.sleep(:infinity) ``` ### Parse the Query [#parse-the-query] Read the request body to determine what data the caller is asking for. ```go title="parse.go" func handleQuery(ctx context.Context, client *kubemq.Client, query *kubemq.QueryReceive) { orderId := string(query.Body) fmt.Printf("Query for order: %s\n", orderId) order := lookupOrder(orderId) if order == nil { sendNotFound(ctx, client, query, orderId) return } data, _ := json.Marshal(order) resp := kubemq.NewQueryReply(). SetRequestId(query.Id). SetResponseTo(query.ResponseTo). SetBody(data). SetMetadata("application/json"). SetExecutedAt(time.Now()) _ = client.SendQueryResponse(ctx, resp) } ``` ```python title="parse.py" def handle_query(request: QueryReceived) -> None: order_id = request.body.decode("utf-8") print(f"Query for order: {order_id}") order = lookup_order(order_id) if order is None: send_not_found(request, order_id) return client.send_response_message( QueryResponse( query_received=request, is_executed=True, body=json.dumps(order).encode(), metadata="application/json", ) ) ``` ```javascript title="parse.js" function handleQuery(client, query) { const orderId = Buffer.from(query.body).toString(); console.log("Query for order:", orderId); const order = lookupOrder(orderId); if (!order) { sendNotFound(client, query, orderId); return; } client.sendQueryResponse({ id: query.id, replyChannel: query.replyChannel, executed: true, body: Buffer.from(JSON.stringify(order)), metadata: "application/json", }); } ``` ```java title="Parse.java" private QueryResponseMessage handleQuery(CQClient client, QueryReceive query) { String orderId = new String(query.getBody()); System.out.println("Query for order: " + orderId); Order order = lookupOrder(orderId); if (order == null) { return notFoundResponse(query, orderId); } return QueryResponseMessage.builder() .requestId(query.getId()) .isExecuted(true) .body(new Gson().toJson(order).getBytes()) .metadata("application/json") .build(); } ``` ```csharp title="Parse.cs" async Task HandleQuery(KubeMQClient client, QueryReceive query) { var orderId = Encoding.UTF8.GetString(query.Body.Span); Console.WriteLine($"Query for order: {orderId}"); var order = LookupOrder(orderId); if (order is null) { await SendNotFound(client, query, orderId); return; } await client.SendQueryResponseAsync(new QueryResponse { RequestId = query.Id, IsExecuted = true, Body = JsonSerializer.SerializeToUtf8Bytes(order), Metadata = "application/json" }); } ``` ```kotlin title="Parse.kt" fun handleQuery(client: CQClient, query: QueryReceive) { val orderId = String(query.body) println("Query for order: $orderId") val order = lookupOrder(orderId) if (order == null) { sendNotFound(client, query, orderId) return } client.sendQueryResponse( requestId = query.id, isExecuted = true, body = Json.encodeToString(order).toByteArray(), metadata = "application/json" ) } ``` ```cpp title="parse.cpp" void handleQuery(kubemq::CQClient& client, const kubemq::QueryReceive& query) { std::string orderId = query.body; std::cout << "Query for order: " << orderId << std::endl; auto order = lookupOrder(orderId); if (order.empty()) { sendNotFound(client, query, orderId); return; } client.sendQueryResponse(query.id, true, order, "application/json"); } ``` ```rust title="parse.rs" use kubemq::QueryReplyBuilder; async fn handle_query(client: KubemqClient, query: QueryReceive) { let order_id = String::from_utf8_lossy(&query.body).to_string(); println!("Query for order: {}", order_id); match lookup_order(&order_id) { Some(order) => { let reply = QueryReplyBuilder::new() .request_id(&query.id) .response_to(&query.response_to) .body(order.into_bytes()) .metadata("application/json") .build(); let _ = client.send_query_response(reply).await; } None => send_not_found(&client, &query, &order_id).await, } } ``` ```ruby title="parse.rb" def handle_query(client, query) order_id = query.body puts "Query for order: #{order_id}" order = lookup_order(order_id) if order.nil? send_not_found(client, query, order_id) return end response = KubeMQ::CQ::QueryResponseMessage.new( request_id: query.id, reply_channel: query.reply_channel, executed: true, body: order.to_json, metadata: 'application/json' ) client.send_response(response) end ``` ```elixir title="parse.exs" def handle_query(query) do order_id = query.body IO.puts("Query for order: #{order_id}") case lookup_order(order_id) do nil -> send_not_found(query, order_id) order -> KubeMQ.QueryReply.new( request_id: query.id, response_to: query.reply_channel, executed: true, body: Jason.encode!(order), metadata: "application/json" ) end end ``` ### Return Data [#return-data] Set the response body with the queried data. The body and metadata are preserved and returned to the sender. ```go title="return_data.go" type Order struct { OrderID string `json:"orderId"` Status string `json:"status"` Total float64 `json:"total"` Items int `json:"items"` } func lookupOrder(id string) *Order { return &Order{ OrderID: id, Status: "shipped", Total: 149.99, Items: 3, } } ``` ```python title="return_data.py" def lookup_order(order_id: str) -> dict | None: return { "orderId": order_id, "status": "shipped", "total": 149.99, "items": 3, } ``` ```javascript title="return_data.js" function lookupOrder(orderId) { return { orderId, status: "shipped", total: 149.99, items: 3, }; } ``` ```java title="ReturnData.java" private Order lookupOrder(String id) { return new Order(id, "shipped", 149.99, 3); } record Order(String orderId, String status, double total, int items) {} ``` ```csharp title="ReturnData.cs" Order? LookupOrder(string id) => new(id, "shipped", 149.99m, 3); record Order(string OrderId, string Status, decimal Total, int Items); ``` ```kotlin title="ReturnData.kt" fun lookupOrder(id: String): Order? = Order(orderId = id, status = "shipped", total = 149.99, items = 3) data class Order(val orderId: String, val status: String, val total: Double, val items: Int) ``` ```cpp title="return_data.cpp" std::string lookupOrder(const std::string& id) { nlohmann::json order; order["orderId"] = id; order["status"] = "shipped"; order["total"] = 149.99; order["items"] = 3; return order.dump(); } ``` ```rust title="return_data.rs" use serde_json::json; fn lookup_order(id: &str) -> Option { Some( json!({ "orderId": id, "status": "shipped", "total": 149.99, "items": 3 }) .to_string(), ) } ``` ```ruby title="return_data.rb" require 'json' def lookup_order(order_id) { orderId: order_id, status: 'shipped', total: 149.99, items: 3 } end ``` ```elixir title="return_data.exs" def lookup_order(order_id) do %{ orderId: order_id, status: "shipped", total: 149.99, items: 3 } end ``` ### Handle Not Found [#handle-not-found] Return `Executed: false` with an error message when the requested data does not exist. ```go title="not_found.go" func sendNotFound(ctx context.Context, client *kubemq.Client, query *kubemq.QueryReceive, orderId string) { resp := kubemq.NewQueryReply(). SetRequestId(query.Id). SetResponseTo(query.ResponseTo). SetError(fmt.Sprintf("order %s not found", orderId)) _ = client.SendQueryResponse(ctx, resp) } ``` ```python title="not_found.py" def send_not_found(request: QueryReceived, order_id: str) -> None: client.send_response_message( QueryResponse( query_received=request, is_executed=False, error=f"order {order_id} not found", ) ) ``` ```javascript title="not_found.js" function sendNotFound(client, query, orderId) { client.sendQueryResponse({ id: query.id, replyChannel: query.replyChannel, executed: false, error: `order ${orderId} not found`, }); } ``` ```java title="NotFound.java" private QueryResponseMessage notFoundResponse(QueryReceive query, String id) { return QueryResponseMessage.builder() .requestId(query.getId()) .isExecuted(false) .error("order " + id + " not found") .build(); } ``` ```csharp title="NotFound.cs" async Task SendNotFound(KubeMQClient client, QueryReceive query, string id) => await client.SendQueryResponseAsync(new QueryResponse { RequestId = query.Id, IsExecuted = false, Error = $"order {id} not found" }); ``` ```kotlin title="NotFound.kt" fun sendNotFound(client: CQClient, query: QueryReceive, orderId: String) { client.sendQueryResponse( requestId = query.id, isExecuted = false, error = "order $orderId not found" ) } ``` ```cpp title="not_found.cpp" void sendNotFound(kubemq::CQClient& client, const kubemq::QueryReceive& query, const std::string& orderId) { client.sendQueryResponse(query.id, false, "", "order " + orderId + " not found"); } ``` ```rust title="not_found.rs" use kubemq::QueryReplyBuilder; async fn send_not_found(client: &KubemqClient, query: &QueryReceive, order_id: &str) { // Setting an error marks the reply as not executed. let reply = QueryReplyBuilder::new() .request_id(&query.id) .response_to(&query.response_to) .error(format!("order {} not found", order_id)) .build(); let _ = client.send_query_response(reply).await; } ``` ```ruby title="not_found.rb" def send_not_found(client, query, order_id) response = KubeMQ::CQ::QueryResponseMessage.new( request_id: query.id, reply_channel: query.reply_channel, executed: false, error: "order #{order_id} not found" ) client.send_response(response) end ``` ```elixir title="not_found.exs" def send_not_found(query, order_id) do KubeMQ.QueryReply.new( request_id: query.id, response_to: query.reply_channel, executed: false, error: "order #{order_id} not found" ) end ``` ## Query vs Command Response [#query-vs-command-response] | Response Field | Command Responder | Query Responder | | -------------- | ---------------------------- | -------------------------------- | | `Body` | Not set (stripped by server) | **Set response data** | | `Metadata` | Not set (stripped by server) | **Set content type or metadata** | | `Executed` | Set success/failure | Set success/failure | | `Error` | Set error message | Set error message | ## Next Steps [#next-steps] # Query Caching (/learn/rpc/tutorials/query-caching) ## What You Will Build [#what-you-will-build] A cached product lookup — the first query hits the responder, and subsequent queries are served directly from KubeMQ's in-memory cache until the TTL expires. ## How Query Caching Works [#how-query-caching-works] *The first query misses the cache and reaches the responder; KubeMQ stores the reply and serves every later query with the same cache key directly until the TTL expires.* ## Steps [#steps] ### Set Up a Query Responder [#set-up-a-query-responder] The responder handles product lookups. With caching enabled, it is only called on cache misses. ```go title="product_responder.go" package main import ( "context" "fmt" "log" "time" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() _, err = client.SubscribeToQueries(ctx, "products.lookup", "", kubemq.WithOnQueryReceive(func(query *kubemq.QueryReceive) { productId := string(query.Body) fmt.Printf("Cache MISS — fetching product %s from database\n", productId) resp := kubemq.NewQueryReply(). SetRequestId(query.Id). SetResponseTo(query.ResponseTo). SetBody([]byte(fmt.Sprintf( `{"productId":"%s","name":"Widget","price":29.99,"stock":150}`, productId))). SetExecutedAt(time.Now()) _ = client.SendQueryResponse(ctx, resp) }), kubemq.WithOnError(func(err error) { log.Println("Error:", err) }), ) if err != nil { log.Fatal(err) } fmt.Println("Product responder ready...") <-ctx.Done() } ``` ```python title="product_responder.py" import time from kubemq.cq import ( Client as CQClient, QueriesSubscription, QueryReceived, QueryResponse, CancellationToken, ) def on_query(request: QueryReceived) -> None: product_id = request.body.decode("utf-8") print(f"Cache MISS — fetching product {product_id} from database") client.send_response_message( QueryResponse( query_received=request, is_executed=True, body=f'{{"productId":"{product_id}","name":"Widget","price":29.99,"stock":150}}'.encode(), ) ) client = CQClient(address="localhost:50000") cancel = CancellationToken() client.subscribe_to_queries( subscription=QueriesSubscription( channel="products.lookup", on_receive_query_callback=on_query, on_error_callback=lambda e: print(f"Error: {e}"), ), cancel=cancel, ) print("Product responder ready...") time.sleep(3600) ``` ```javascript title="product_responder.js" const { KubeMQClient } = require("kubemq-js"); const client = new KubeMQClient({ address: "localhost:50000" }); client.subscribeToQueries({ channel: "products.lookup", onQuery: (query) => { const productId = Buffer.from(query.body).toString(); console.log(`Cache MISS — fetching product ${productId} from database`); client.sendQueryResponse({ requestId: query.id, isExecuted: true, body: Buffer.from(JSON.stringify({ productId, name: "Widget", price: 29.99, stock: 150, })), }); }, onError: (err) => console.error("Error:", err.message), }); console.log("Product responder ready..."); ``` ```java title="ProductResponder.java" CQClient client = CQClient.builder() .address("localhost:50000") .clientId("product-responder") .build(); client.subscribeToQueries(QueriesSubscription.builder() .channel("products.lookup") .onReceiveQueryCallback(query -> { String productId = new String(query.getBody()); System.out.println("Cache MISS — fetching product " + productId); return QueryResponseMessage.builder() .requestId(query.getId()) .isExecuted(true) .body(String.format( "{\"productId\":\"%s\",\"name\":\"Widget\",\"price\":29.99,\"stock\":150}", productId).getBytes()) .build(); }) .onErrorCallback(err -> System.err.println("Error: " + err.getMessage())) .build()); System.out.println("Product responder ready..."); Thread.sleep(3600000); client.close(); ``` ```csharp title="ProductResponder.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); Console.WriteLine("Product responder ready..."); await foreach (var query in client.SubscribeToQueriesAsync( new QueriesSubscription { Channel = "products.lookup" })) { var productId = Encoding.UTF8.GetString(query.Body.Span); Console.WriteLine($"Cache MISS — fetching product {productId}"); await client.SendQueryResponseAsync(new QueryResponse { RequestId = query.Id, IsExecuted = true, Body = Encoding.UTF8.GetBytes( $"{{\"productId\":\"{productId}\",\"name\":\"Widget\",\"price\":29.99,\"stock\":150}}") }); } ``` ```kotlin title="ProductResponder.kt" val client = CQClient("localhost:50000") client.subscribeToQueries( channel = "products.lookup", onQuery = { query -> val productId = String(query.body) println("Cache MISS — fetching product $productId from database") client.sendQueryResponse( requestId = query.id, isExecuted = true, body = """{"productId":"$productId","name":"Widget","price":29.99,"stock":150}""".toByteArray() ) }, onError = { err -> System.err.println("Error: ${err.message}") } ) println("Product responder ready...") Thread.sleep(3600000) client.close() ``` ```cpp title="product_responder.cpp" #include #include #include auto client = kubemq::CQClient("localhost:50000"); client.subscribeToQueries("products.lookup", "", [&client](const kubemq::QueryReceive& query) { std::cout << "Cache MISS — fetching product " << query.body << std::endl; std::string data = R"({"productId":")" + query.body + R"(","name":"Widget","price":29.99,"stock":150})"; client.sendQueryResponse(query.id, true, data); }, [](const std::string& err) { std::cerr << "Error: " << err << std::endl; } ); std::cout << "Product responder ready..." << std::endl; std::this_thread::sleep_for(std::chrono::hours(1)); ``` ```rust title="product_responder.rs" use kubemq::prelude::*; use kubemq::QueryReplyBuilder; use std::time::Duration; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; let responder = client.clone(); let sub = client .subscribe_to_queries( "products.lookup", "", move |query| { let rc = responder.clone(); Box::pin(async move { let product_id = String::from_utf8_lossy(&query.body).to_string(); println!("Cache MISS — fetching product {product_id} from database"); let reply = QueryReplyBuilder::new() .request_id(&query.id) .response_to(&query.response_to) .body( format!( r#"{{"productId":"{product_id}","name":"Widget","price":29.99,"stock":150}}"# ) .into_bytes(), ) .build(); tokio::spawn(async move { let _ = rc.send_query_response(reply).await; }); }) }, None, ) .await?; println!("Product responder ready..."); tokio::signal::ctrl_c().await.ok(); sub.unsubscribe().await; client.close().await } ``` ```ruby title="product_responder.rb" require 'kubemq' client = KubeMQ::CQClient.new(address: 'localhost:50000', client_id: 'product-responder') cancel = KubeMQ::CancellationToken.new sub = KubeMQ::CQ::QueriesSubscription.new(channel: 'products.lookup') client.subscribe_to_queries(sub, cancellation_token: cancel, on_error: lambda { |e| puts "Error: #{e.message}" }) do |query| product_id = query.body puts "Cache MISS — fetching product #{product_id} from database" response = KubeMQ::CQ::QueryResponseMessage.new( request_id: query.id, reply_channel: query.reply_channel, executed: true, body: "{\"productId\":\"#{product_id}\",\"name\":\"Widget\",\"price\":29.99,\"stock\":150}" ) client.send_response(response) end puts 'Product responder ready...' cancel.wait ``` ```elixir title="product_responder.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "product-responder") {:ok, _sub} = KubeMQ.Client.subscribe_to_queries(client, "products.lookup", on_query: fn query -> product_id = query.body IO.puts("Cache MISS — fetching product #{product_id} from database") KubeMQ.QueryReply.new( request_id: query.id, response_to: query.reply_channel, executed: true, body: ~s({"productId":"#{product_id}","name":"Widget","price":29.99,"stock":150}) ) end, on_error: fn err -> IO.puts("Error: #{err.message}") end ) IO.puts("Product responder ready...") Process.sleep(:infinity) ``` ### Send a Query with CacheKey [#send-a-query-with-cachekey] Set `CacheKey` and `CacheTTL` on the query request to enable caching. The first query is a cache miss, and subsequent queries within the TTL are served from cache. ```go title="cached_query.go" resp, err := client.SendQuery(ctx, kubemq.NewQuery(). SetChannel("products.lookup"). SetBody([]byte("PROD-123")). SetTimeout(10 * time.Second). SetCacheKey("product-PROD-123"). SetCacheTTL(60 * time.Second)) if err != nil { log.Fatal(err) } log.Printf("Product: %s (cache hit: %v)", resp.Body, resp.CacheHit) ``` ```python title="cached_query.py" from kubemq.cq import Client as CQClient, QueryMessage with CQClient(address="localhost:50000") as client: response = client.send_query( QueryMessage( channel="products.lookup", body=b"PROD-123", timeout_in_seconds=10, cache_key="product-PROD-123", cache_ttl_in_seconds=60, ) ) print(f"Product: {response.body.decode('utf-8')} (cache hit: {response.cache_hit})") ``` ```javascript title="cached_query.js" const response = await client.sendQuery({ channel: "products.lookup", body: Buffer.from("PROD-123"), timeoutInSeconds: 10, cacheKey: "product-PROD-123", cacheTTL: 60000, }); console.log("Product:", Buffer.from(response.body).toString(), "(cache hit:", response.cacheHit, ")"); ``` ```java title="CachedQuery.java" QueryResponseMessage response = client.sendQueryRequest( QueryMessage.builder() .channel("products.lookup") .body("PROD-123".getBytes()) .timeout(10000) .cacheKey("product-PROD-123") .cacheTTL(60000) .build()); System.out.println("Product: " + new String(response.getBody()) + " (cache hit: " + response.isCacheHit() + ")"); ``` ```csharp title="CachedQuery.cs" var response = await client.SendQueryAsync(new QueryMessage { Channel = "products.lookup", Body = Encoding.UTF8.GetBytes("PROD-123"), Timeout = TimeSpan.FromSeconds(10), CacheKey = "product-PROD-123", CacheTTL = TimeSpan.FromSeconds(60) }); Console.WriteLine($"Product: {Encoding.UTF8.GetString(response.Body.Span)}" + $" (cache hit: {response.CacheHit})"); ``` ```kotlin title="CachedQuery.kt" val response = client.sendQuery(QueryMessage( channel = "products.lookup", body = "PROD-123".toByteArray(), timeout = 10000, cacheKey = "product-PROD-123", cacheTTL = 60000 )) println("Product: ${String(response.body)} (cache hit: ${response.cacheHit})") ``` ```cpp title="cached_query.cpp" kubemq::QueryMessage query; query.channel = "products.lookup"; query.body = "PROD-123"; query.timeout = 10000; query.cacheKey = "product-PROD-123"; query.cacheTTL = 60000; auto response = client.sendQuery(query); std::cout << "Product: " << response.body << " (cache hit: " << response.cacheHit << ")" << std::endl; ``` ```rust title="cached_query.rs" use kubemq::QueryBuilder; use std::time::Duration; let query = QueryBuilder::new() .channel("products.lookup") .body(b"PROD-123".to_vec()) .timeout(Duration::from_secs(10)) .cache_key("product-PROD-123") .cache_ttl(Duration::from_secs(60)) .build(); let response = client.send_query(query).await?; println!( "Product: {} (cache hit: {})", String::from_utf8_lossy(&response.body), response.cache_hit ); ``` ```ruby title="cached_query.rb" msg = KubeMQ::CQ::QueryMessage.new( channel: 'products.lookup', body: 'PROD-123', timeout: 10, cache_key: 'product-PROD-123', cache_ttl: 60 ) response = client.send_query(msg) puts "Product: #{response.body} (cache hit: #{response.cache_hit})" ``` ```elixir title="cached_query.exs" query = KubeMQ.Query.new( channel: "products.lookup", body: "PROD-123", timeout: 10_000, cache_key: "product-PROD-123", cache_ttl: 60_000 ) case KubeMQ.Client.send_query(client, query) do {:ok, resp} -> IO.puts("Product: #{resp.body} (cache hit: #{resp.cache_hit})") {:error, err} -> IO.puts("Error: #{err.message}") end ``` ### Verify Cache Behavior [#verify-cache-behavior] Send the same query twice to observe the cache in action. ```go title="verify_cache.go" query := kubemq.NewQuery(). SetChannel("products.lookup"). SetBody([]byte("PROD-123")). SetTimeout(10 * time.Second). SetCacheKey("product-PROD-123"). SetCacheTTL(60 * time.Second) resp1, _ := client.SendQuery(ctx, query) log.Printf("First query — cache hit: %v", resp1.CacheHit) // false resp2, _ := client.SendQuery(ctx, query) log.Printf("Second query — cache hit: %v", resp2.CacheHit) // true ``` ```python title="verify_cache.py" query = QueryMessage( channel="products.lookup", body=b"PROD-123", timeout_in_seconds=10, cache_key="product-PROD-123", cache_ttl_in_seconds=60, ) resp1 = client.send_query(query) print(f"First query — cache hit: {resp1.cache_hit}") # False resp2 = client.send_query(query) print(f"Second query — cache hit: {resp2.cache_hit}") # True ``` ```javascript title="verify_cache.js" const queryOpts = { channel: "products.lookup", body: Buffer.from("PROD-123"), timeoutInSeconds: 10, cacheKey: "product-PROD-123", cacheTTL: 60000, }; const resp1 = await client.sendQuery(queryOpts); console.log("First query — cache hit:", resp1.cacheHit); // false const resp2 = await client.sendQuery(queryOpts); console.log("Second query — cache hit:", resp2.cacheHit); // true ``` ```java title="VerifyCache.java" QueryMessage query = QueryMessage.builder() .channel("products.lookup") .body("PROD-123".getBytes()) .timeout(10000) .cacheKey("product-PROD-123") .cacheTTL(60000) .build(); var resp1 = client.sendQueryRequest(query); System.out.println("First query — cache hit: " + resp1.isCacheHit()); // false var resp2 = client.sendQueryRequest(query); System.out.println("Second query — cache hit: " + resp2.isCacheHit()); // true ``` ```csharp title="VerifyCache.cs" var query = new QueryMessage { Channel = "products.lookup", Body = Encoding.UTF8.GetBytes("PROD-123"), Timeout = TimeSpan.FromSeconds(10), CacheKey = "product-PROD-123", CacheTTL = TimeSpan.FromSeconds(60) }; var resp1 = await client.SendQueryAsync(query); Console.WriteLine($"First query — cache hit: {resp1.CacheHit}"); // false var resp2 = await client.SendQueryAsync(query); Console.WriteLine($"Second query — cache hit: {resp2.CacheHit}"); // true ``` ```kotlin title="VerifyCache.kt" val query = QueryMessage( channel = "products.lookup", body = "PROD-123".toByteArray(), timeout = 10000, cacheKey = "product-PROD-123", cacheTTL = 60000 ) val resp1 = client.sendQuery(query) println("First query — cache hit: ${resp1.cacheHit}") // false val resp2 = client.sendQuery(query) println("Second query — cache hit: ${resp2.cacheHit}") // true ``` ```cpp title="verify_cache.cpp" kubemq::QueryMessage query; query.channel = "products.lookup"; query.body = "PROD-123"; query.timeout = 10000; query.cacheKey = "product-PROD-123"; query.cacheTTL = 60000; auto resp1 = client.sendQuery(query); std::cout << "First query — cache hit: " << resp1.cacheHit << std::endl; // 0 auto resp2 = client.sendQuery(query); std::cout << "Second query — cache hit: " << resp2.cacheHit << std::endl; // 1 ``` ```rust title="verify_cache.rs" use kubemq::QueryBuilder; use std::time::Duration; let build_query = || { QueryBuilder::new() .channel("products.lookup") .body(b"PROD-123".to_vec()) .timeout(Duration::from_secs(10)) .cache_key("product-PROD-123") .cache_ttl(Duration::from_secs(60)) .build() }; let resp1 = client.send_query(build_query()).await?; println!("First query — cache hit: {}", resp1.cache_hit); // false let resp2 = client.send_query(build_query()).await?; println!("Second query — cache hit: {}", resp2.cache_hit); // true ``` ```ruby title="verify_cache.rb" msg = KubeMQ::CQ::QueryMessage.new( channel: 'products.lookup', body: 'PROD-123', timeout: 10, cache_key: 'product-PROD-123', cache_ttl: 60 ) resp1 = client.send_query(msg) puts "First query — cache hit: #{resp1.cache_hit}" # false resp2 = client.send_query(msg) puts "Second query — cache hit: #{resp2.cache_hit}" # true ``` ```elixir title="verify_cache.exs" query = KubeMQ.Query.new( channel: "products.lookup", body: "PROD-123", timeout: 10_000, cache_key: "product-PROD-123", cache_ttl: 60_000 ) {:ok, resp1} = KubeMQ.Client.send_query(client, query) IO.puts("First query — cache hit: #{resp1.cache_hit}") # false {:ok, resp2} = KubeMQ.Client.send_query(client, query) IO.puts("Second query — cache hit: #{resp2.cache_hit}") # true ``` ## Cache Configuration [#cache-configuration] | Setting | Description | | -------------------- | ------------------------------------------------------------------------- | | `CacheKey` | Any string identifying the cached response (use `entity-type-id` pattern) | | `CacheTTL` | Time-to-live in milliseconds. Required when `CacheKey` is set. | | **Storage** | In-memory on the KubeMQ server | | **Cleanup interval** | Every 10 seconds | | **Persistence** | None — cache is cleared on server restart | Caching is only available for **Queries**. Commands do not support caching because they represent write operations. ## When to Use Caching [#when-to-use-caching] | Use Case | Cache? | Why | | ----------------------- | ---------- | ------------------------------ | | Product catalog lookups | ✅ Yes | Data changes infrequently | | Configuration values | ✅ Yes | Rarely updated | | Exchange rates | ✅ Yes | Update every few minutes | | User-specific data | ⚠️ Depends | Use unique cache keys per user | | Real-time stock prices | ❌ No | Data is stale immediately | | Authentication tokens | ❌ No | Security-sensitive | ## Next Steps [#next-steps] # Request-Reply Roundtrip (/learn/rpc/tutorials/request-reply-roundtrip) ## What You Will Build [#what-you-will-build] A complete RPC flow: a responder that handles both commands and queries on the orders domain, and a sender that creates an order (command) then retrieves its status (query). ## Steps [#steps] ### Set Up the Responder [#set-up-the-responder] The responder subscribes to both command and query channels and handles each type accordingly. ```go title="responder.go" package main import ( "context" "fmt" "log" "time" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() _, err = client.SubscribeToCommands(ctx, "orders.process", "", kubemq.WithOnCommandReceive(func(cmd *kubemq.CommandReceive) { fmt.Printf("[CMD] Processing: %s\n", cmd.Body) resp := kubemq.NewCommandReply(). SetRequestId(cmd.Id). SetResponseTo(cmd.ResponseTo). SetExecutedAt(time.Now()) _ = client.SendCommandResponse(ctx, resp) }), kubemq.WithOnError(func(err error) { log.Println("CMD error:", err) }), ) if err != nil { log.Fatal(err) } _, err = client.SubscribeToQueries(ctx, "orders.lookup", "", kubemq.WithOnQueryReceive(func(query *kubemq.QueryReceive) { orderId := string(query.Body) fmt.Printf("[QRY] Looking up: %s\n", orderId) resp := kubemq.NewQueryReply(). SetRequestId(query.Id). SetResponseTo(query.ResponseTo). SetBody([]byte(fmt.Sprintf( `{"orderId":"%s","status":"confirmed","total":149.99}`, orderId))). SetExecutedAt(time.Now()) _ = client.SendQueryResponse(ctx, resp) }), kubemq.WithOnError(func(err error) { log.Println("QRY error:", err) }), ) if err != nil { log.Fatal(err) } fmt.Println("Responder ready (commands + queries)...") <-ctx.Done() } ``` ```python title="responder.py" import time from kubemq.cq import ( Client as CQClient, CommandsSubscription, CommandReceived, CommandResponse, QueriesSubscription, QueryReceived, QueryResponse, CancellationToken, ) client = CQClient(address="localhost:50000") cancel = CancellationToken() def on_command(request: CommandReceived) -> None: print(f"[CMD] Processing: {request.body.decode('utf-8')}") client.send_response_message( CommandResponse(command_received=request, is_executed=True) ) def on_query(request: QueryReceived) -> None: order_id = request.body.decode("utf-8") print(f"[QRY] Looking up: {order_id}") client.send_response_message( QueryResponse( query_received=request, is_executed=True, body=f'{{"orderId":"{order_id}","status":"confirmed","total":149.99}}'.encode(), ) ) client.subscribe_to_commands( CommandsSubscription(channel="orders.process", on_receive_command_callback=on_command, on_error_callback=lambda e: print(f"CMD error: {e}")), cancel=cancel) client.subscribe_to_queries( QueriesSubscription(channel="orders.lookup", on_receive_query_callback=on_query, on_error_callback=lambda e: print(f"QRY error: {e}")), cancel=cancel) print("Responder ready (commands + queries)...") time.sleep(3600) ``` ```javascript title="responder.js" const { KubeMQClient } = require("kubemq-js"); const client = new KubeMQClient({ address: "localhost:50000" }); client.subscribeToCommands({ channel: "orders.process", onCommand: (cmd) => { console.log("[CMD] Processing:", Buffer.from(cmd.body).toString()); client.sendCommandResponse({ requestId: cmd.id, isExecuted: true }); }, onError: (err) => console.error("CMD error:", err.message), }); client.subscribeToQueries({ channel: "orders.lookup", onQuery: (query) => { const orderId = Buffer.from(query.body).toString(); console.log("[QRY] Looking up:", orderId); client.sendQueryResponse({ requestId: query.id, isExecuted: true, body: Buffer.from(JSON.stringify({ orderId, status: "confirmed", total: 149.99, })), }); }, onError: (err) => console.error("QRY error:", err.message), }); console.log("Responder ready (commands + queries)..."); ``` ```java title="Responder.java" CQClient client = CQClient.builder() .address("localhost:50000") .clientId("order-responder") .build(); client.subscribeToCommands(CommandsSubscription.builder() .channel("orders.process") .onReceiveCommandCallback(cmd -> { System.out.println("[CMD] Processing: " + new String(cmd.getBody())); return CommandResponseMessage.builder() .requestId(cmd.getId()).isExecuted(true).build(); }) .onErrorCallback(err -> System.err.println("CMD error: " + err.getMessage())) .build()); client.subscribeToQueries(QueriesSubscription.builder() .channel("orders.lookup") .onReceiveQueryCallback(query -> { String id = new String(query.getBody()); System.out.println("[QRY] Looking up: " + id); return QueryResponseMessage.builder() .requestId(query.getId()).isExecuted(true) .body(String.format( "{\"orderId\":\"%s\",\"status\":\"confirmed\",\"total\":149.99}", id) .getBytes()) .build(); }) .onErrorCallback(err -> System.err.println("QRY error: " + err.getMessage())) .build()); System.out.println("Responder ready (commands + queries)..."); Thread.sleep(3600000); client.close(); ``` ```csharp title="Responder.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); var cmdTask = Task.Run(async () => { await foreach (var cmd in client.SubscribeToCommandsAsync( new CommandsSubscription { Channel = "orders.process" })) { Console.WriteLine($"[CMD] Processing: {Encoding.UTF8.GetString(cmd.Body.Span)}"); await client.SendCommandResponseAsync(new CommandResponse { RequestId = cmd.Id, IsExecuted = true }); } }); var queryTask = Task.Run(async () => { await foreach (var query in client.SubscribeToQueriesAsync( new QueriesSubscription { Channel = "orders.lookup" })) { var id = Encoding.UTF8.GetString(query.Body.Span); Console.WriteLine($"[QRY] Looking up: {id}"); await client.SendQueryResponseAsync(new QueryResponse { RequestId = query.Id, IsExecuted = true, Body = Encoding.UTF8.GetBytes( $"{{\"orderId\":\"{id}\",\"status\":\"confirmed\",\"total\":149.99}}") }); } }); Console.WriteLine("Responder ready (commands + queries)..."); await Task.WhenAll(cmdTask, queryTask); ``` ```kotlin title="Responder.kt" val client = CQClient("localhost:50000") client.subscribeToCommands( channel = "orders.process", onCommand = { cmd -> println("[CMD] Processing: ${String(cmd.body)}") client.sendCommandResponse(requestId = cmd.id, isExecuted = true) }, onError = { err -> System.err.println("CMD error: ${err.message}") } ) client.subscribeToQueries( channel = "orders.lookup", onQuery = { query -> val id = String(query.body) println("[QRY] Looking up: $id") client.sendQueryResponse( requestId = query.id, isExecuted = true, body = """{"orderId":"$id","status":"confirmed","total":149.99}""".toByteArray() ) }, onError = { err -> System.err.println("QRY error: ${err.message}") } ) println("Responder ready (commands + queries)...") Thread.sleep(3600000) client.close() ``` ```cpp title="responder.cpp" #include #include #include auto client = kubemq::CQClient("localhost:50000"); client.subscribeToCommands("orders.process", "", [&client](const kubemq::CommandReceive& cmd) { std::cout << "[CMD] Processing: " << cmd.body << std::endl; client.sendCommandResponse(cmd.id, true); }, [](const std::string& err) { std::cerr << "CMD error: " << err << std::endl; } ); client.subscribeToQueries("orders.lookup", "", [&client](const kubemq::QueryReceive& query) { std::cout << "[QRY] Looking up: " << query.body << std::endl; std::string data = R"({"orderId":")" + query.body + R"(","status":"confirmed","total":149.99})"; client.sendQueryResponse(query.id, true, data); }, [](const std::string& err) { std::cerr << "QRY error: " << err << std::endl; } ); std::cout << "Responder ready (commands + queries)..." << std::endl; std::this_thread::sleep_for(std::chrono::hours(1)); ``` ```rust title="responder.rs" use kubemq::prelude::*; use kubemq::{CommandReplyBuilder, QueryReplyBuilder}; use std::time::Duration; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; // Command responder — process orders, reply with executed only let cmd_client = client.clone(); let _cmd_sub = client .subscribe_to_commands("orders.process", "", move |cmd| { let c = cmd_client.clone(); Box::pin(async move { println!("[CMD] Processing: {}", String::from_utf8_lossy(&cmd.body)); let reply = CommandReplyBuilder::new() .request_id(&cmd.id) .response_to(&cmd.response_to) .executed_at(now_millis()) .build(); tokio::spawn(async move { let _ = c.send_command_response(reply).await; }); }) }, None) .await?; // Query responder — look up orders, reply with a body payload let qry_client = client.clone(); let _qry_sub = client .subscribe_to_queries("orders.lookup", "", move |query| { let c = qry_client.clone(); Box::pin(async move { let order_id = String::from_utf8_lossy(&query.body).to_string(); println!("[QRY] Looking up: {}", order_id); let body = format!( r#"{{"orderId":"{}","status":"confirmed","total":149.99}}"#, order_id); let reply = QueryReplyBuilder::new() .request_id(&query.id) .response_to(&query.response_to) .body(body.into_bytes()) .build(); tokio::spawn(async move { let _ = c.send_query_response(reply).await; }); }) }, None) .await?; println!("Responder ready (commands + queries)..."); tokio::time::sleep(Duration::from_secs(3600)).await; client.close().await?; Ok(()) } fn now_millis() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as i64 } ``` ```ruby title="responder.rb" require 'kubemq' client = KubeMQ::CQClient.new(address: 'localhost:50000', client_id: 'order-responder') cancel = KubeMQ::CancellationToken.new # Command responder — process orders, reply with executed only cmd_sub = KubeMQ::CQ::CommandsSubscription.new(channel: 'orders.process') client.subscribe_to_commands(cmd_sub, cancellation_token: cancel, on_error: ->(e) { puts "CMD error: #{e.message}" }) do |cmd| puts "[CMD] Processing: #{cmd.body}" client.send_response(KubeMQ::CQ::CommandResponseMessage.new( request_id: cmd.id, reply_channel: cmd.reply_channel, executed: true)) end # Query responder — look up orders, reply with a body payload qry_sub = KubeMQ::CQ::QueriesSubscription.new(channel: 'orders.lookup') client.subscribe_to_queries(qry_sub, cancellation_token: cancel, on_error: ->(e) { puts "QRY error: #{e.message}" }) do |query| order_id = query.body puts "[QRY] Looking up: #{order_id}" client.send_response(KubeMQ::CQ::QueryResponseMessage.new( request_id: query.id, reply_channel: query.reply_channel, executed: true, body: "{\"orderId\":\"#{order_id}\",\"status\":\"confirmed\",\"total\":149.99}")) end puts 'Responder ready (commands + queries)...' cancel.wait ``` ```elixir title="responder.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-responder") # Command responder — process orders, reply with executed only {:ok, _cmd_sub} = KubeMQ.Client.subscribe_to_commands(client, "orders.process", on_command: fn cmd -> IO.puts("[CMD] Processing: #{cmd.body}") KubeMQ.CommandReply.new( request_id: cmd.id, response_to: cmd.reply_channel, executed: true ) end, on_error: fn err -> IO.puts("CMD error: #{err.message}") end ) # Query responder — look up orders, reply with a body payload {:ok, _qry_sub} = KubeMQ.Client.subscribe_to_queries(client, "orders.lookup", on_query: fn query -> IO.puts("[QRY] Looking up: #{query.body}") KubeMQ.QueryReply.new( request_id: query.id, response_to: query.reply_channel, executed: true, body: ~s({"orderId":"#{query.body}","status":"confirmed","total":149.99}) ) end, on_error: fn err -> IO.puts("QRY error: #{err.message}") end ) IO.puts("Responder ready (commands + queries)...") Process.sleep(3_600_000) KubeMQ.Client.close(client) ``` ### Send a Command (Create Order) [#send-a-command-create-order] In a separate terminal, send a command to create an order. ```go title="sender.go" cmdResp, err := client.SendCommand(ctx, kubemq.NewCommand(). SetChannel("orders.process"). SetBody([]byte(`{"action":"create","orderId":"ORD-9001"}`)). SetTimeout(10 * time.Second)) if err != nil { log.Fatal(err) } log.Printf("Command — Executed: %v", cmdResp.Executed) log.Printf("Command — Body: %v (always nil)", cmdResp.Body) ``` ```python title="sender.py" from kubemq.cq import Client as CQClient, CommandMessage, QueryMessage with CQClient(address="localhost:50000") as client: cmd_resp = client.send_command(CommandMessage( channel="orders.process", body=b'{"action":"create","orderId":"ORD-9001"}', timeout_in_seconds=10)) print(f"Command — Executed: {cmd_resp.is_executed}") print(f"Command — Body: {cmd_resp.body} (always empty)") ``` ```javascript title="sender.js" const cmdResp = await client.sendCommand({ channel: "orders.process", body: Buffer.from(JSON.stringify({ action: "create", orderId: "ORD-9001" })), timeoutInSeconds: 10, }); console.log("Command — Executed:", cmdResp.isExecuted); console.log("Command — Body:", cmdResp.body, "(always empty)"); ``` ```java title="Sender.java" var cmdResp = client.sendCommandRequest(CommandMessage.builder() .channel("orders.process") .body("{\"action\":\"create\",\"orderId\":\"ORD-9001\"}".getBytes()) .timeout(10000).build()); System.out.println("Command — Executed: " + cmdResp.isExecuted()); System.out.println("Command — Body: " + cmdResp.getBody() + " (always null)"); ``` ```csharp title="Sender.cs" var cmdResp = await client.SendCommandAsync(new CommandMessage { Channel = "orders.process", Body = Encoding.UTF8.GetBytes("{\"action\":\"create\",\"orderId\":\"ORD-9001\"}"), Timeout = TimeSpan.FromSeconds(10) }); Console.WriteLine($"Command — Executed: {cmdResp.IsExecuted}"); Console.WriteLine($"Command — Body length: {cmdResp.Body.Length} (always 0)"); ``` ```kotlin title="Sender.kt" val cmdResp = client.sendCommand(CommandMessage( channel = "orders.process", body = """{"action":"create","orderId":"ORD-9001"}""".toByteArray(), timeout = 10000)) println("Command — Executed: ${cmdResp.isExecuted}") println("Command — Body: ${cmdResp.body} (always empty)") ``` ```cpp title="sender.cpp" kubemq::CommandMessage cmd; cmd.channel = "orders.process"; cmd.body = R"({"action":"create","orderId":"ORD-9001"})"; cmd.timeout = 10000; auto cmdResp = client.sendCommand(cmd); std::cout << "Command — Executed: " << cmdResp.isExecuted << std::endl; std::cout << "Command — Body: " << cmdResp.body << " (always empty)" << std::endl; ``` ```rust title="sender.rs" use kubemq::CommandBuilder; use std::time::Duration; let command = CommandBuilder::new() .channel("orders.process") .body(br#"{"action":"create","orderId":"ORD-9001"}"#.to_vec()) .timeout(Duration::from_secs(10)) .build(); let cmd_resp = client.send_command(command).await?; println!("Command — Executed: {}", cmd_resp.executed); println!("Command — Error: '{}' (no body on commands)", cmd_resp.error); ``` ```ruby title="sender.rb" msg = KubeMQ::CQ::CommandMessage.new( channel: 'orders.process', timeout: 10, body: '{"action":"create","orderId":"ORD-9001"}' ) cmd_resp = client.send_command(msg) puts "Command — Executed: #{cmd_resp.executed}" puts "Command — Error: #{cmd_resp.error} (no body on commands)" ``` ```elixir title="sender.exs" command = KubeMQ.Command.new( channel: "orders.process", body: ~s({"action":"create","orderId":"ORD-9001"}), timeout: 10_000 ) case KubeMQ.Client.send_command(client, command) do {:ok, cmd_resp} -> IO.puts("Command — Executed: #{cmd_resp.executed}") {:error, err} -> IO.puts("Command failed: #{err.message}") end ``` ### Send a Query (Get Order Status) [#send-a-query-get-order-status] Now query the order status. Unlike commands, the response body is preserved. ```go title="query.go" qryResp, err := client.SendQuery(ctx, kubemq.NewQuery(). SetChannel("orders.lookup"). SetBody([]byte("ORD-9001")). SetTimeout(10 * time.Second)) if err != nil { log.Fatal(err) } log.Printf("Query — Executed: %v", qryResp.Executed) log.Printf("Query — Body: %s", qryResp.Body) ``` ```python title="query.py" qry_resp = client.send_query(QueryMessage( channel="orders.lookup", body=b"ORD-9001", timeout_in_seconds=10)) print(f"Query — Executed: {qry_resp.is_executed}") print(f"Query — Body: {qry_resp.body.decode('utf-8')}") ``` ```javascript title="query.js" const qryResp = await client.sendQuery({ channel: "orders.lookup", body: Buffer.from("ORD-9001"), timeoutInSeconds: 10, }); console.log("Query — Executed:", qryResp.isExecuted); console.log("Query — Body:", Buffer.from(qryResp.body).toString()); ``` ```java title="Query.java" var qryResp = client.sendQueryRequest(QueryMessage.builder() .channel("orders.lookup") .body("ORD-9001".getBytes()) .timeout(10000).build()); System.out.println("Query — Executed: " + qryResp.isExecuted()); System.out.println("Query — Body: " + new String(qryResp.getBody())); ``` ```csharp title="Query.cs" var qryResp = await client.SendQueryAsync(new QueryMessage { Channel = "orders.lookup", Body = Encoding.UTF8.GetBytes("ORD-9001"), Timeout = TimeSpan.FromSeconds(10) }); Console.WriteLine($"Query — Executed: {qryResp.IsExecuted}"); Console.WriteLine($"Query — Body: {Encoding.UTF8.GetString(qryResp.Body.Span)}"); ``` ```kotlin title="Query.kt" val qryResp = client.sendQuery(QueryMessage( channel = "orders.lookup", body = "ORD-9001".toByteArray(), timeout = 10000)) println("Query — Executed: ${qryResp.isExecuted}") println("Query — Body: ${String(qryResp.body)}") ``` ```cpp title="query.cpp" kubemq::QueryMessage query; query.channel = "orders.lookup"; query.body = "ORD-9001"; query.timeout = 10000; auto qryResp = client.sendQuery(query); std::cout << "Query — Executed: " << qryResp.isExecuted << std::endl; std::cout << "Query — Body: " << qryResp.body << std::endl; ``` ```rust title="query.rs" use kubemq::QueryBuilder; use std::time::Duration; let query = QueryBuilder::new() .channel("orders.lookup") .body(b"ORD-9001".to_vec()) .timeout(Duration::from_secs(10)) .build(); let qry_resp = client.send_query(query).await?; println!("Query — Executed: {}", qry_resp.executed); println!("Query — Body: {}", String::from_utf8_lossy(&qry_resp.body)); ``` ```ruby title="query.rb" msg = KubeMQ::CQ::QueryMessage.new( channel: 'orders.lookup', timeout: 10, body: 'ORD-9001' ) qry_resp = client.send_query(msg) puts "Query — Executed: #{qry_resp.executed}" puts "Query — Body: #{qry_resp.body}" ``` ```elixir title="query.exs" query = KubeMQ.Query.new( channel: "orders.lookup", body: "ORD-9001", timeout: 10_000 ) case KubeMQ.Client.send_query(client, query) do {:ok, qry_resp} -> IO.puts("Query — Executed: #{qry_resp.executed}") IO.puts("Query — Body: #{qry_resp.body}") {:error, err} -> IO.puts("Query failed: #{err.message}") end ``` ## Complete Flow [#complete-flow] *One responder, two request types: the command's response body is discarded, the query's response body is returned to the sender.* ## Key Takeaways [#key-takeaways] * **Commands for writes** — response body is stripped, only `Executed` + `Error` returned * **Queries for reads** — response body is preserved, full data returned to sender * **Same transport** — both use the same client and same timeout mechanism * **Responder must be running** — if no responder is available, the sender gets a timeout error (code 301) ## Next Steps [#next-steps] # Send Commands (/learn/rpc/tutorials/send-commands) ## What You Will Build [#what-you-will-build] An order service that sends "process order" commands to a handler and checks execution status. You will learn how command responses differ from query responses and how to handle timeouts.

A command round-trip: the sender waits for an execution acknowledgment only — no data comes back.

## Steps [#steps] ### Create a Command Responder [#create-a-command-responder] The responder subscribes to the `orders.process` channel, processes incoming commands, and sends back an execution status. ```go title="command_responder.go" package main import ( "context" "fmt" "log" "time" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() _, err = client.SubscribeToCommands(ctx, "orders.process", "", kubemq.WithOnCommandReceive(func(cmd *kubemq.CommandReceive) { fmt.Printf("Processing order: %s\n", cmd.Body) resp := kubemq.NewCommandReply(). SetRequestId(cmd.Id). SetResponseTo(cmd.ResponseTo). SetExecutedAt(time.Now()) _ = client.SendCommandResponse(ctx, resp) }), kubemq.WithOnError(func(err error) { log.Println("Error:", err) }), ) if err != nil { log.Fatal(err) } fmt.Println("Responder ready on 'orders.process'...") <-ctx.Done() } ``` ```python title="command_responder.py" import time from kubemq.cq import Client as CQClient from kubemq.cq import CommandsSubscription, CommandReceived, CommandResponse, CancellationToken def on_command(request: CommandReceived) -> None: print(f"Processing order: {request.body.decode('utf-8')}") client.send_response_message( CommandResponse(command_received=request, is_executed=True) ) client = CQClient(address="localhost:50000") cancel = CancellationToken() client.subscribe_to_commands( subscription=CommandsSubscription( channel="orders.process", on_receive_command_callback=on_command, on_error_callback=lambda e: print(f"Error: {e}"), ), cancel=cancel, ) print("Responder ready on 'orders.process'...") time.sleep(3600) ``` ```javascript title="command_responder.js" const { KubeMQClient } = require("kubemq-js"); const client = new KubeMQClient({ address: "localhost:50000" }); client.subscribeToCommands({ channel: "orders.process", onCommand: (cmd) => { console.log("Processing order:", Buffer.from(cmd.body).toString()); client.sendCommandResponse({ requestId: cmd.id, isExecuted: true }); }, onError: (err) => console.error("Error:", err.message), }); console.log("Responder ready on 'orders.process'..."); ``` ```java title="CommandResponder.java" CQClient client = CQClient.builder() .address("localhost:50000") .clientId("order-responder") .build(); client.subscribeToCommands(CommandsSubscription.builder() .channel("orders.process") .onReceiveCommandCallback(cmd -> { System.out.println("Processing order: " + new String(cmd.getBody())); return CommandResponseMessage.builder() .requestId(cmd.getId()) .isExecuted(true) .build(); }) .onErrorCallback(err -> System.err.println("Error: " + err.getMessage())) .build()); System.out.println("Responder ready on 'orders.process'..."); Thread.sleep(3600000); client.close(); ``` ```csharp title="CommandResponder.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); Console.WriteLine("Responder ready on 'orders.process'..."); await foreach (var cmd in client.SubscribeToCommandsAsync( new CommandsSubscription { Channel = "orders.process" })) { Console.WriteLine($"Processing order: {Encoding.UTF8.GetString(cmd.Body.Span)}"); await client.SendCommandResponseAsync(new CommandResponse { RequestId = cmd.Id, IsExecuted = true }); } ``` ```kotlin title="CommandResponder.kt" val client = CQClient("localhost:50000") client.subscribeToCommands( channel = "orders.process", onCommand = { cmd -> println("Processing order: ${String(cmd.body)}") client.sendCommandResponse(requestId = cmd.id, isExecuted = true) }, onError = { err -> System.err.println("Error: ${err.message}") } ) println("Responder ready on 'orders.process'...") Thread.sleep(3600000) client.close() ``` ```cpp title="command_responder.cpp" #include #include #include auto client = kubemq::CQClient("localhost:50000"); client.subscribeToCommands("orders.process", "", [&client](const kubemq::CommandReceive& cmd) { std::cout << "Processing order: " << cmd.body << std::endl; client.sendCommandResponse(cmd.id, true); }, [](const std::string& err) { std::cerr << "Error: " << err << std::endl; } ); std::cout << "Responder ready on 'orders.process'..." << std::endl; std::this_thread::sleep_for(std::chrono::hours(1)); ``` ```rust title="command_responder.rs" use kubemq::prelude::*; use kubemq::CommandReplyBuilder; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; let rc = client.clone(); let _sub = client .subscribe_to_commands("orders.process", "", move |cmd| { let c = rc.clone(); Box::pin(async move { println!("Processing order: {}", String::from_utf8_lossy(&cmd.body)); let reply = CommandReplyBuilder::new() .request_id(&cmd.id) .response_to(&cmd.response_to) .build(); let _ = c.send_command_response(reply).await; }) }, None) .await?; println!("Responder ready on 'orders.process'..."); tokio::signal::ctrl_c().await.ok(); client.close().await?; Ok(()) } ``` ```ruby title="command_responder.rb" require "kubemq" client = KubeMQ::CQClient.new(address: "localhost:50000", client_id: "order-responder") cancel = KubeMQ::CancellationToken.new sub = KubeMQ::CQ::CommandsSubscription.new(channel: "orders.process") client.subscribe_to_commands(sub, cancellation_token: cancel, on_error: ->(e) { puts "Error: #{e.message}" }) do |cmd| puts "Processing order: #{cmd.body}" response = KubeMQ::CQ::CommandResponseMessage.new( request_id: cmd.id, reply_channel: cmd.reply_channel, executed: true ) client.send_response(response) end puts "Responder ready on 'orders.process'..." cancel.wait ``` ```elixir title="command_responder.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-responder") {:ok, _sub} = KubeMQ.Client.subscribe_to_commands(client, "orders.process", on_command: fn cmd -> IO.puts("Processing order: #{cmd.body}") KubeMQ.CommandReply.new( request_id: cmd.id, response_to: cmd.reply_channel, executed: true ) end, on_error: fn err -> IO.puts("Error: #{err.message}") end ) IO.puts("Responder ready on 'orders.process'...") Process.sleep(:infinity) ``` ### Send a Command [#send-a-command] Send a command with body, metadata, and a 10-second timeout. ```go title="send_command.go" resp, err := client.SendCommand(ctx, kubemq.NewCommand(). SetChannel("orders.process"). SetBody([]byte(`{"action":"create","orderId":"ORD-5678"}`)). SetMetadata("order.create"). SetTags(map[string]string{"priority": "high"}). SetTimeout(10 * time.Second)) if err != nil { log.Fatal(err) } log.Printf("Executed: %v, Error: %s", resp.Executed, resp.Error) ``` ```python title="send_command.py" from kubemq.cq import Client as CQClient, CommandMessage with CQClient(address="localhost:50000") as client: response = client.send_command( CommandMessage( channel="orders.process", body=b'{"action":"create","orderId":"ORD-5678"}', metadata="order.create", tags={"priority": "high"}, timeout_in_seconds=10, ) ) print(f"Executed: {response.is_executed}, Error: {response.error}") ``` ```javascript title="send_command.js" const { KubeMQClient } = require("kubemq-js"); const client = new KubeMQClient({ address: "localhost:50000" }); const response = await client.sendCommand({ channel: "orders.process", body: Buffer.from(JSON.stringify({ action: "create", orderId: "ORD-5678" })), metadata: "order.create", tags: { priority: "high" }, timeoutInSeconds: 10, }); console.log("Executed:", response.isExecuted, "Error:", response.error); ``` ```java title="SendCommand.java" CommandResponseMessage response = client.sendCommandRequest( CommandMessage.builder() .channel("orders.process") .body("{\"action\":\"create\",\"orderId\":\"ORD-5678\"}".getBytes()) .metadata("order.create") .tags("priority=high") .timeout(10000) .build()); System.out.println("Executed: " + response.isExecuted() + ", Error: " + response.getError()); ``` ```csharp title="SendCommand.cs" var response = await client.SendCommandAsync(new CommandMessage { Channel = "orders.process", Body = Encoding.UTF8.GetBytes("{\"action\":\"create\",\"orderId\":\"ORD-5678\"}"), Metadata = "order.create", Tags = new Dictionary { ["priority"] = "high" }, Timeout = TimeSpan.FromSeconds(10) }); Console.WriteLine($"Executed: {response.IsExecuted}, Error: {response.Error}"); ``` ```kotlin title="SendCommand.kt" val response = client.sendCommand(CommandMessage( channel = "orders.process", body = """{"action":"create","orderId":"ORD-5678"}""".toByteArray(), metadata = "order.create", tags = mapOf("priority" to "high"), timeout = 10000 )) println("Executed: ${response.isExecuted}, Error: ${response.error}") ``` ```cpp title="send_command.cpp" kubemq::CommandMessage cmd; cmd.channel = "orders.process"; cmd.body = R"({"action":"create","orderId":"ORD-5678"})"; cmd.metadata = "order.create"; cmd.tags["priority"] = "high"; cmd.timeout = 10000; auto response = client.sendCommand(cmd); std::cout << "Executed: " << response.isExecuted << ", Error: " << response.error << std::endl; ``` ```rust title="send_command.rs" use kubemq::CommandBuilder; use std::collections::HashMap; use std::time::Duration; let command = CommandBuilder::new() .channel("orders.process") .body(br#"{"action":"create","orderId":"ORD-5678"}"#.to_vec()) .metadata("order.create") .tags(HashMap::from([("priority".to_string(), "high".to_string())])) .timeout(Duration::from_secs(10)) .build(); let response = client.send_command(command).await?; println!("Executed: {}, Error: '{}'", response.executed, response.error); ``` ```ruby title="send_command.rb" msg = KubeMQ::CQ::CommandMessage.new( channel: "orders.process", body: '{"action":"create","orderId":"ORD-5678"}', metadata: "order.create", tags: { "priority" => "high" }, timeout: 10_000 # milliseconds ) response = client.send_command(msg) puts "Executed: #{response.executed}, Error: #{response.error}" ``` ```elixir title="send_command.exs" command = KubeMQ.Command.new( channel: "orders.process", body: ~s({"action":"create","orderId":"ORD-5678"}), metadata: "order.create", tags: %{"priority" => "high"}, timeout: 10_000 ) case KubeMQ.Client.send_command(client, command) do {:ok, response} -> IO.puts("Executed: #{response.executed}, Error: #{response.error}") {:error, err} -> IO.puts("Command failed: #{err.message}") end ``` ### Check Execution Status [#check-execution-status] The response contains only `Executed` (boolean) and `Error` (string). Body and metadata are always stripped from command responses. ```go title="check_status.go" if resp.Executed { log.Println("Command executed successfully") } else { log.Printf("Command failed: %s", resp.Error) } // Body is always nil for commands log.Printf("Response body: %v", resp.Body) // nil ``` ```python title="check_status.py" if response.is_executed: print("Command executed successfully") else: print(f"Command failed: {response.error}") # Body is always empty for commands print(f"Response body: {response.body}") # b'' ``` ```javascript title="check_status.js" if (response.isExecuted) { console.log("Command executed successfully"); } else { console.log("Command failed:", response.error); } // Body is always empty for commands console.log("Response body:", response.body); // undefined ``` ```java title="CheckStatus.java" if (response.isExecuted()) { System.out.println("Command executed successfully"); } else { System.out.println("Command failed: " + response.getError()); } // Body is always null for commands System.out.println("Response body: " + response.getBody()); // null ``` ```csharp title="CheckStatus.cs" if (response.IsExecuted) Console.WriteLine("Command executed successfully"); else Console.WriteLine($"Command failed: {response.Error}"); // Body is always empty for commands Console.WriteLine($"Response body: {response.Body.Length}"); // 0 ``` ```kotlin title="CheckStatus.kt" if (response.isExecuted) { println("Command executed successfully") } else { println("Command failed: ${response.error}") } // Body is always empty for commands println("Response body: ${response.body?.size}") // null or 0 ``` ```cpp title="check_status.cpp" if (response.isExecuted) { std::cout << "Command executed successfully" << std::endl; } else { std::cout << "Command failed: " << response.error << std::endl; } // Body is always empty for commands std::cout << "Response body: " << response.body << std::endl; // "" ``` ```rust title="check_status.rs" if response.executed { println!("Command executed successfully"); } else { println!("Command failed: {}", response.error); } // CommandResponse carries no body — only command_id, executed, executed_at, error, tags println!("Command id: {}", response.command_id); ``` ```ruby title="check_status.rb" if response.executed puts "Command executed successfully" else puts "Command failed: #{response.error}" end # CommandResponse carries no body — only request_id, executed, error, timestamp, tags puts "Request id: #{response.request_id}" ``` ```elixir title="check_status.exs" if response.executed do IO.puts("Command executed successfully") else IO.puts("Command failed: #{response.error}") end # CommandResponse carries no body — only command_id, executed, executed_at, error, tags IO.puts("Command id: #{response.command_id}") ``` ### Handle Timeout [#handle-timeout] When no responder replies within the timeout, the sender receives a timeout error (code 301). ```go title="handle_timeout.go" resp, err := client.SendCommand(ctx, kubemq.NewCommand(). SetChannel("orders.process"). SetBody([]byte("test")). SetTimeout(2 * time.Second)) if err != nil { log.Printf("Command failed: %v", err) return } if !resp.Executed { log.Printf("Timeout or error: %s", resp.Error) } ``` ```python title="handle_timeout.py" try: response = client.send_command( CommandMessage( channel="orders.process", body=b"test", timeout_in_seconds=2, ) ) if not response.is_executed: print(f"Timeout or error: {response.error}") except Exception as e: print(f"Command failed: {e}") ``` ```javascript title="handle_timeout.js" try { const response = await client.sendCommand({ channel: "orders.process", body: Buffer.from("test"), timeoutInSeconds: 2, }); if (!response.isExecuted) { console.log("Timeout or error:", response.error); } } catch (err) { console.error("Command failed:", err.message); } ``` ```java title="HandleTimeout.java" try { CommandResponseMessage response = client.sendCommandRequest( CommandMessage.builder() .channel("orders.process") .body("test".getBytes()) .timeout(2000) .build()); if (!response.isExecuted()) { System.out.println("Timeout or error: " + response.getError()); } } catch (Exception e) { System.err.println("Command failed: " + e.getMessage()); } ``` ```csharp title="HandleTimeout.cs" try { var response = await client.SendCommandAsync(new CommandMessage { Channel = "orders.process", Body = Encoding.UTF8.GetBytes("test"), Timeout = TimeSpan.FromSeconds(2) }); if (!response.IsExecuted) Console.WriteLine($"Timeout or error: {response.Error}"); } catch (Exception ex) { Console.WriteLine($"Command failed: {ex.Message}"); } ``` ```kotlin title="HandleTimeout.kt" try { val response = client.sendCommand(CommandMessage( channel = "orders.process", body = "test".toByteArray(), timeout = 2000 )) if (!response.isExecuted) { println("Timeout or error: ${response.error}") } } catch (e: Exception) { println("Command failed: ${e.message}") } ``` ```cpp title="handle_timeout.cpp" try { kubemq::CommandMessage cmd; cmd.channel = "orders.process"; cmd.body = "test"; cmd.timeout = 2000; auto response = client.sendCommand(cmd); if (!response.isExecuted) { std::cout << "Timeout or error: " << response.error << std::endl; } } catch (const std::exception& e) { std::cerr << "Command failed: " << e.what() << std::endl; } ``` ```rust title="handle_timeout.rs" use kubemq::CommandBuilder; use std::time::Duration; let command = CommandBuilder::new() .channel("orders.process") .body(b"test".to_vec()) .timeout(Duration::from_secs(2)) .build(); match client.send_command(command).await { Ok(resp) if !resp.executed => println!("Timeout or error: {}", resp.error), Ok(_) => println!("Command executed"), Err(e) => println!("Command failed: {}", e), } ``` ```ruby title="handle_timeout.rb" begin msg = KubeMQ::CQ::CommandMessage.new( channel: "orders.process", body: "test", timeout: 2_000 # milliseconds ) response = client.send_command(msg) puts "Timeout or error: #{response.error}" unless response.executed rescue KubeMQ::Error => e puts "Command failed: #{e.message}" end ``` ```elixir title="handle_timeout.exs" command = KubeMQ.Command.new(channel: "orders.process", body: "test", timeout: 2_000) case KubeMQ.Client.send_command(client, command) do {:ok, response} -> unless response.executed, do: IO.puts("Timeout or error: #{response.error}") {:error, err} -> IO.puts("Command failed: #{err.message}") end ``` ## Why Command Responses Are Stripped [#why-command-responses-are-stripped] KubeMQ follows the CQRS principle: **Commands tell, they don't return data.** When you send a command, the response body, metadata, and cacheHit fields are stripped by the server before being returned to the sender. Only `Executed` and `Error` reach the caller. | Response Field | Command | Query | | -------------- | ------------------------- | --------- | | `Body` | Stripped (always `nil`) | Preserved | | `Metadata` | Stripped (always `""`) | Preserved | | `CacheHit` | Stripped (always `false`) | Preserved | If you need to return data, use a [Query](/learn/rpc/tutorials/send-queries) instead. ## Next Steps [#next-steps] # Send Queries (/learn/rpc/tutorials/send-queries) ## What You Will Build [#what-you-will-build] A client that queries order status and receives full order data in the response. You will see how query responses differ from command responses — body, metadata, and cacheHit are all preserved. *A query round-trip: the responder returns structured data, and KubeMQ delivers the full response body and metadata back to the sender.* ## Steps [#steps] ### Create a Query Responder [#create-a-query-responder] The responder subscribes to the `orders.lookup` channel and returns order data in the response body. ```go title="query_responder.go" package main import ( "context" "fmt" "log" "time" "github.com/kubemq-io/kubemq-go/v2" ) func main() { ctx := context.Background() client, err := kubemq.NewClient(ctx, kubemq.WithAddress("localhost", 50000), ) if err != nil { log.Fatal(err) } defer client.Close() _, err = client.SubscribeToQueries(ctx, "orders.lookup", "", kubemq.WithOnQueryReceive(func(query *kubemq.QueryReceive) { orderId := string(query.Body) fmt.Printf("Looking up order: %s\n", orderId) resp := kubemq.NewQueryReply(). SetRequestId(query.Id). SetResponseTo(query.ResponseTo). SetBody([]byte(fmt.Sprintf( `{"orderId":"%s","status":"shipped","total":99.99}`, orderId))). SetMetadata("application/json"). SetExecutedAt(time.Now()) _ = client.SendQueryResponse(ctx, resp) }), kubemq.WithOnError(func(err error) { log.Println("Error:", err) }), ) if err != nil { log.Fatal(err) } fmt.Println("Query responder ready on 'orders.lookup'...") <-ctx.Done() } ``` ```python title="query_responder.py" import time from kubemq.cq import Client as CQClient from kubemq.cq import QueriesSubscription, QueryReceived, QueryResponse, CancellationToken def on_query(request: QueryReceived) -> None: order_id = request.body.decode("utf-8") print(f"Looking up order: {order_id}") client.send_response_message( QueryResponse( query_received=request, is_executed=True, body=f'{{"orderId":"{order_id}","status":"shipped","total":99.99}}'.encode(), metadata="application/json", ) ) client = CQClient(address="localhost:50000") cancel = CancellationToken() client.subscribe_to_queries( subscription=QueriesSubscription( channel="orders.lookup", on_receive_query_callback=on_query, on_error_callback=lambda e: print(f"Error: {e}"), ), cancel=cancel, ) print("Query responder ready on 'orders.lookup'...") time.sleep(3600) ``` ```javascript title="query_responder.js" const { KubeMQClient } = require("kubemq-js"); const client = new KubeMQClient({ address: "localhost:50000" }); client.subscribeToQueries({ channel: "orders.lookup", onQuery: (query) => { const orderId = Buffer.from(query.body).toString(); console.log("Looking up order:", orderId); client.sendQueryResponse({ requestId: query.id, isExecuted: true, body: Buffer.from( JSON.stringify({ orderId, status: "shipped", total: 99.99 }) ), metadata: "application/json", }); }, onError: (err) => console.error("Error:", err.message), }); console.log("Query responder ready on 'orders.lookup'..."); ``` ```java title="QueryResponder.java" CQClient client = CQClient.builder() .address("localhost:50000") .clientId("order-query-responder") .build(); client.subscribeToQueries(QueriesSubscription.builder() .channel("orders.lookup") .onReceiveQueryCallback(query -> { String orderId = new String(query.getBody()); System.out.println("Looking up order: " + orderId); return QueryResponseMessage.builder() .requestId(query.getId()) .isExecuted(true) .body(String.format( "{\"orderId\":\"%s\",\"status\":\"shipped\",\"total\":99.99}", orderId).getBytes()) .metadata("application/json") .build(); }) .onErrorCallback(err -> System.err.println("Error: " + err.getMessage())) .build()); System.out.println("Query responder ready on 'orders.lookup'..."); Thread.sleep(3600000); client.close(); ``` ```csharp title="QueryResponder.cs" await using var client = new KubeMQClient(new KubeMQClientOptions()); await client.ConnectAsync(); Console.WriteLine("Query responder ready on 'orders.lookup'..."); await foreach (var query in client.SubscribeToQueriesAsync( new QueriesSubscription { Channel = "orders.lookup" })) { var orderId = Encoding.UTF8.GetString(query.Body.Span); Console.WriteLine($"Looking up order: {orderId}"); await client.SendQueryResponseAsync(new QueryResponse { RequestId = query.Id, IsExecuted = true, Body = Encoding.UTF8.GetBytes( $"{{\"orderId\":\"{orderId}\",\"status\":\"shipped\",\"total\":99.99}}"), Metadata = "application/json" }); } ``` ```kotlin title="QueryResponder.kt" val client = CQClient("localhost:50000") client.subscribeToQueries( channel = "orders.lookup", onQuery = { query -> val orderId = String(query.body) println("Looking up order: $orderId") client.sendQueryResponse( requestId = query.id, isExecuted = true, body = """{"orderId":"$orderId","status":"shipped","total":99.99}""".toByteArray(), metadata = "application/json" ) }, onError = { err -> System.err.println("Error: ${err.message}") } ) println("Query responder ready on 'orders.lookup'...") Thread.sleep(3600000) client.close() ``` ```cpp title="query_responder.cpp" #include #include #include auto client = kubemq::CQClient("localhost:50000"); client.subscribeToQueries("orders.lookup", "", [&client](const kubemq::QueryReceive& query) { std::cout << "Looking up order: " << query.body << std::endl; std::string response = R"({"orderId":")" + query.body + R"(","status":"shipped","total":99.99})"; client.sendQueryResponse(query.id, true, response, "application/json"); }, [](const std::string& err) { std::cerr << "Error: " << err << std::endl; } ); std::cout << "Query responder ready on 'orders.lookup'..." << std::endl; std::this_thread::sleep_for(std::chrono::hours(1)); ``` ```rust title="query_responder.rs" use kubemq::prelude::*; use kubemq::QueryReplyBuilder; use std::time::Duration; #[tokio::main] async fn main() -> kubemq::Result<()> { let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; let rc = client.clone(); let _sub = client .subscribe_to_queries( "orders.lookup", "", move |query| { let c = rc.clone(); Box::pin(async move { let order_id = String::from_utf8_lossy(&query.body).to_string(); println!("Looking up order: {}", order_id); let reply = QueryReplyBuilder::new() .request_id(&query.id) .response_to(&query.response_to) .body( format!( r#"{{"orderId":"{}","status":"shipped","total":99.99}}"#, order_id ) .into_bytes(), ) .metadata("application/json") .build(); tokio::spawn(async move { let _ = c.send_query_response(reply).await; }); }) }, None, ) .await?; println!("Query responder ready on 'orders.lookup'..."); tokio::time::sleep(Duration::from_secs(3600)).await; Ok(()) } ``` ```ruby title="query_responder.rb" require 'kubemq' client = KubeMQ::CQClient.new(address: 'localhost:50000', client_id: 'order-query-responder') cancel = KubeMQ::CancellationToken.new sub = KubeMQ::CQ::QueriesSubscription.new(channel: 'orders.lookup') client.subscribe_to_queries(sub, cancellation_token: cancel, on_error: lambda { |e| puts "Error: #{e.message}" }) do |query| order_id = query.body puts "Looking up order: #{order_id}" response = KubeMQ::CQ::QueryResponseMessage.new( request_id: query.id, reply_channel: query.reply_channel, executed: true, body: "{\"orderId\":\"#{order_id}\",\"status\":\"shipped\",\"total\":99.99}", metadata: 'application/json' ) client.send_response(response) end puts "Query responder ready on 'orders.lookup'..." cancel.wait ``` ```elixir title="query_responder.exs" {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "order-query-responder") {:ok, _sub} = KubeMQ.Client.subscribe_to_queries(client, "orders.lookup", on_query: fn query -> IO.puts("Looking up order: #{query.body}") KubeMQ.QueryReply.new( request_id: query.id, response_to: query.reply_channel, executed: true, body: ~s({"orderId":"#{query.body}","status":"shipped","total":99.99}), metadata: "application/json" ) end, on_error: fn err -> IO.puts("Error: #{err.message}") end ) IO.puts("Query responder ready on 'orders.lookup'...") Process.sleep(3_600_000) ``` ### Send a Query [#send-a-query] Send a query to retrieve order data. The response body and metadata are preserved. ```go title="send_query.go" resp, err := client.SendQuery(ctx, kubemq.NewQuery(). SetChannel("orders.lookup"). SetBody([]byte("ORD-1234")). SetTimeout(10 * time.Second)) if err != nil { log.Fatal(err) } log.Printf("Body: %s", resp.Body) log.Printf("Metadata: %s", resp.Metadata) log.Printf("Executed: %v", resp.Executed) ``` ```python title="send_query.py" from kubemq.cq import Client as CQClient, QueryMessage with CQClient(address="localhost:50000") as client: response = client.send_query( QueryMessage( channel="orders.lookup", body=b"ORD-1234", timeout_in_seconds=10, ) ) print(f"Body: {response.body.decode('utf-8')}") print(f"Metadata: {response.metadata}") print(f"Executed: {response.is_executed}") ``` ```javascript title="send_query.js" const response = await client.sendQuery({ channel: "orders.lookup", body: Buffer.from("ORD-1234"), timeoutInSeconds: 10, }); console.log("Body:", Buffer.from(response.body).toString()); console.log("Metadata:", response.metadata); console.log("Executed:", response.isExecuted); ``` ```java title="SendQuery.java" QueryResponseMessage response = client.sendQueryRequest( QueryMessage.builder() .channel("orders.lookup") .body("ORD-1234".getBytes()) .timeout(10000) .build()); System.out.println("Body: " + new String(response.getBody())); System.out.println("Metadata: " + response.getMetadata()); System.out.println("Executed: " + response.isExecuted()); ``` ```csharp title="SendQuery.cs" var response = await client.SendQueryAsync(new QueryMessage { Channel = "orders.lookup", Body = Encoding.UTF8.GetBytes("ORD-1234"), Timeout = TimeSpan.FromSeconds(10) }); Console.WriteLine($"Body: {Encoding.UTF8.GetString(response.Body.Span)}"); Console.WriteLine($"Metadata: {response.Metadata}"); Console.WriteLine($"Executed: {response.IsExecuted}"); ``` ```kotlin title="SendQuery.kt" val response = client.sendQuery(QueryMessage( channel = "orders.lookup", body = "ORD-1234".toByteArray(), timeout = 10000 )) println("Body: ${String(response.body)}") println("Metadata: ${response.metadata}") println("Executed: ${response.isExecuted}") ``` ```cpp title="send_query.cpp" kubemq::QueryMessage query; query.channel = "orders.lookup"; query.body = "ORD-1234"; query.timeout = 10000; auto response = client.sendQuery(query); std::cout << "Body: " << response.body << std::endl; std::cout << "Metadata: " << response.metadata << std::endl; std::cout << "Executed: " << response.isExecuted << std::endl; ``` ```rust title="send_query.rs" use kubemq::QueryBuilder; use std::time::Duration; let query = QueryBuilder::new() .channel("orders.lookup") .body(b"ORD-1234".to_vec()) .timeout(Duration::from_secs(10)) .build(); let response = client.send_query(query).await?; println!("Body: {}", String::from_utf8_lossy(&response.body)); println!("Metadata: {}", response.metadata); println!("Executed: {}", response.executed); ``` ```ruby title="send_query.rb" msg = KubeMQ::CQ::QueryMessage.new( channel: 'orders.lookup', body: 'ORD-1234', timeout: 10 ) response = client.send_query(msg) puts "Body: #{response.body}" puts "Metadata: #{response.metadata}" puts "Executed: #{response.executed}" ``` ```elixir title="send_query.exs" query = KubeMQ.Query.new( channel: "orders.lookup", body: "ORD-1234", timeout: 10_000 ) {:ok, response} = KubeMQ.Client.send_query(client, query) IO.puts("Body: #{response.body}") IO.puts("Metadata: #{response.metadata}") IO.puts("Executed: #{response.executed}") ``` ### Read Response Data [#read-response-data] Parse the structured data returned in the query response body. ```go title="read_response.go" import "encoding/json" type Order struct { OrderID string `json:"orderId"` Status string `json:"status"` Total float64 `json:"total"` } var order Order if err := json.Unmarshal(resp.Body, &order); err != nil { log.Fatal(err) } log.Printf("Order %s: status=%s, total=$%.2f", order.OrderID, order.Status, order.Total) ``` ```python title="read_response.py" import json order = json.loads(response.body) print(f"Order {order['orderId']}: status={order['status']}, total=${order['total']:.2f}") ``` ```javascript title="read_response.js" const order = JSON.parse(Buffer.from(response.body).toString()); console.log(`Order ${order.orderId}: status=${order.status}, total=$${order.total}`); ``` ```java title="ReadResponse.java" import com.google.gson.Gson; record Order(String orderId, String status, double total) {} Order order = new Gson().fromJson(new String(response.getBody()), Order.class); System.out.printf("Order %s: status=%s, total=$%.2f%n", order.orderId(), order.status(), order.total()); ``` ```csharp title="ReadResponse.cs" using System.Text.Json; var order = JsonSerializer.Deserialize(response.Body.Span); Console.WriteLine($"Order {order.OrderId}: status={order.Status}, total=${order.Total:F2}"); record Order(string OrderId, string Status, decimal Total); ``` ```kotlin title="ReadResponse.kt" import kotlinx.serialization.json.Json data class Order(val orderId: String, val status: String, val total: Double) val order = Json.decodeFromString(String(response.body)) println("Order ${order.orderId}: status=${order.status}, total=$${order.total}") ``` ```cpp title="read_response.cpp" #include auto order = nlohmann::json::parse(response.body); std::cout << "Order " << order["orderId"] << ": status=" << order["status"] << ", total=$" << order["total"] << std::endl; ``` ```rust title="read_response.rs" use serde::Deserialize; #[derive(Deserialize)] struct Order { #[serde(rename = "orderId")] order_id: String, status: String, total: f64, } let order: Order = serde_json::from_slice(&response.body)?; println!( "Order {}: status={}, total=${:.2}", order.order_id, order.status, order.total ); ``` ```ruby title="read_response.rb" require 'json' order = JSON.parse(response.body) puts format('Order %s: status=%s, total=$%.2f', order['orderId'], order['status'], order['total']) ``` ```elixir title="read_response.exs" order = Jason.decode!(response.body) IO.puts( "Order #{order["orderId"]}: status=#{order["status"]}, " <> "total=$#{:erlang.float_to_binary(order["total"] / 1, decimals: 2)}" ) ``` ## Full Response Preserved [#full-response-preserved] Unlike commands, query responses preserve all data fields: | Response Field | Command | Query | | -------------- | ---------------- | ------------- | | `Body` | Stripped (nil) | **Preserved** | | `Metadata` | Stripped ("") | **Preserved** | | `CacheHit` | Stripped (false) | **Preserved** | | `Executed` | Preserved | Preserved | | `Error` | Preserved | Preserved | Use queries for any operation that returns data. Use [commands](/learn/rpc/tutorials/send-commands) for write operations that need only an execution status. ## Next Steps [#next-steps] # Service-to-Service API Gateway (/learn/rpc/scenarios/api-gateway) ## Scenario [#scenario] An API gateway receives HTTP requests from clients and dispatches them to backend microservices via KubeMQ RPC. Commands handle write operations (create order, update inventory), while queries handle read operations (get order, list products). Each backend service subscribes to its own channel. ## Architecture [#architecture] *The gateway maps HTTP writes to Commands and HTTP reads to Queries, each routed through KubeMQ to the owning backend service.* ## Implementation [#implementation] ### API Gateway (Sender) [#api-gateway-sender] The gateway maps incoming HTTP requests to KubeMQ commands or queries. ```go title="gateway.go" package main import ( "context" "encoding/json" "log" "net/http" "time" "github.com/kubemq-io/kubemq-go/v2" ) var client *kubemq.Client func createOrder(w http.ResponseWriter, r *http.Request) { var body map[string]interface{} json.NewDecoder(r.Body).Decode(&body) data, _ := json.Marshal(body) ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) defer cancel() resp, err := client.SendCommand(ctx, kubemq.NewCommand(). SetChannel("orders.process"). SetBody(data). SetTimeout(5 * time.Second)) if err != nil || !resp.Executed { http.Error(w, "order creation failed", http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) w.Write([]byte(`{"status":"created"}`)) } func getOrder(w http.ResponseWriter, r *http.Request) { orderId := r.URL.Query().Get("id") ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) defer cancel() resp, err := client.SendQuery(ctx, kubemq.NewQuery(). SetChannel("orders.lookup"). SetBody([]byte(orderId)). SetTimeout(5 * time.Second)) if err != nil || !resp.Executed { http.Error(w, "order not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") w.Write(resp.Body) } ``` ```python title="gateway.py" from flask import Flask, request, jsonify from kubemq.cq import Client as CQClient, CommandMessage, QueryMessage app = Flask(__name__) client = CQClient(address="localhost:50000") @app.route("/orders", methods=["POST"]) def create_order(): data = request.get_json() response = client.send_command(CommandMessage( channel="orders.process", body=str(data).encode(), timeout_in_seconds=5)) if response.is_executed: return jsonify({"status": "created"}), 201 return jsonify({"error": response.error}), 500 @app.route("/orders/", methods=["GET"]) def get_order(order_id): response = client.send_query(QueryMessage( channel="orders.lookup", body=order_id.encode(), timeout_in_seconds=5)) if response.is_executed: return response.body, 200, {"Content-Type": "application/json"} return jsonify({"error": response.error}), 404 ``` ```javascript title="gateway.js" const express = require("express"); const { KubeMQClient } = require("kubemq-js"); const app = express(); app.use(express.json()); const client = new KubeMQClient({ address: "localhost:50000" }); app.post("/orders", async (req, res) => { const response = await client.sendCommand({ channel: "orders.process", body: Buffer.from(JSON.stringify(req.body)), timeoutInSeconds: 5, }); if (response.isExecuted) return res.status(201).json({ status: "created" }); res.status(500).json({ error: response.error }); }); app.get("/orders/:id", async (req, res) => { const response = await client.sendQuery({ channel: "orders.lookup", body: Buffer.from(req.params.id), timeoutInSeconds: 5, }); if (response.isExecuted) return res.json(JSON.parse(Buffer.from(response.body).toString())); res.status(404).json({ error: response.error }); }); app.listen(3000); ``` ```java title="Gateway.java" @RestController public class OrderGateway { private final CQClient client = CQClient.builder() .address("localhost:50000").clientId("api-gateway").build(); @PostMapping("/orders") public ResponseEntity createOrder(@RequestBody String body) { var resp = client.sendCommandRequest(CommandMessage.builder() .channel("orders.process").body(body.getBytes()).timeout(5000).build()); if (resp.isExecuted()) return ResponseEntity.status(201).body("{\"status\":\"created\"}"); return ResponseEntity.status(500).body("{\"error\":\"" + resp.getError() + "\"}"); } @GetMapping("/orders/{id}") public ResponseEntity getOrder(@PathVariable String id) { var resp = client.sendQueryRequest(QueryMessage.builder() .channel("orders.lookup").body(id.getBytes()).timeout(5000).build()); if (resp.isExecuted()) return ResponseEntity.ok(new String(resp.getBody())); return ResponseEntity.status(404).body("{\"error\":\"" + resp.getError() + "\"}"); } } ``` ```csharp title="Gateway.cs" app.MapPost("/orders", async (HttpContext ctx, KubeMQClient client) => { var body = await new StreamReader(ctx.Request.Body).ReadToEndAsync(); var resp = await client.SendCommandAsync(new CommandMessage { Channel = "orders.process", Body = Encoding.UTF8.GetBytes(body), Timeout = TimeSpan.FromSeconds(5) }); if (resp.IsExecuted) return Results.Created("/orders", new { status = "created" }); return Results.Problem(resp.Error); }); app.MapGet("/orders/{id}", async (string id, KubeMQClient client) => { var resp = await client.SendQueryAsync(new QueryMessage { Channel = "orders.lookup", Body = Encoding.UTF8.GetBytes(id), Timeout = TimeSpan.FromSeconds(5) }); if (resp.IsExecuted) return Results.Text(Encoding.UTF8.GetString(resp.Body.Span), "application/json"); return Results.NotFound(new { error = resp.Error }); }); ``` ```kotlin title="Gateway.kt" @RestController class OrderGateway { private val client = CQClient("localhost:50000") @PostMapping("/orders") fun createOrder(@RequestBody body: String): ResponseEntity { val resp = client.sendCommand(CommandMessage( channel = "orders.process", body = body.toByteArray(), timeout = 5000)) return if (resp.isExecuted) ResponseEntity.status(201).body("""{"status":"created"}""") else ResponseEntity.status(500).body("""{"error":"${resp.error}"}""") } @GetMapping("/orders/{id}") fun getOrder(@PathVariable id: String): ResponseEntity { val resp = client.sendQuery(QueryMessage( channel = "orders.lookup", body = id.toByteArray(), timeout = 5000)) return if (resp.isExecuted) ResponseEntity.ok(String(resp.body)) else ResponseEntity.status(404).body("""{"error":"${resp.error}"}""") } } ``` ```cpp title="gateway.cpp" auto client = kubemq::CQClient("localhost:50000"); void handleCreateOrder(const HttpRequest& req, HttpResponse& res) { kubemq::CommandMessage cmd; cmd.channel = "orders.process"; cmd.body = req.body; cmd.timeout = 5000; auto resp = client.sendCommand(cmd); if (resp.isExecuted) { res.status = 201; res.body = R"({"status":"created"})"; } else { res.status = 500; res.body = R"({"error":")" + resp.error + R"("})"; } } void handleGetOrder(const HttpRequest& req, HttpResponse& res) { kubemq::QueryMessage query; query.channel = "orders.lookup"; query.body = req.params["id"]; query.timeout = 5000; auto resp = client.sendQuery(query); if (resp.isExecuted) { res.body = resp.body; } else { res.status = 404; } } ``` ```rust title="gateway.rs" use kubemq::prelude::*; use kubemq::{CommandBuilder, QueryBuilder}; use std::time::Duration; // Build once at startup, share the client across request handlers. let client = KubemqClient::builder() .host("localhost") .port(50000) .build() .await?; // POST /orders -> command (write) async fn create_order(client: &KubemqClient, body: Vec) -> kubemq::Result { let command = CommandBuilder::new() .channel("orders.process") .body(body) .timeout(Duration::from_secs(5)) .build(); let resp = client.send_command(command).await?; Ok(resp.executed) } // GET /orders/{id} -> query (read) async fn get_order(client: &KubemqClient, id: &str) -> kubemq::Result> { let query = QueryBuilder::new() .channel("orders.lookup") .body(id.as_bytes().to_vec()) .timeout(Duration::from_secs(5)) .build(); let resp = client.send_query(query).await?; Ok(resp.body) } ``` ```ruby title="gateway.rb" require 'kubemq' # Build once at startup, share across request handlers. client = KubeMQ::CQClient.new(address: 'localhost:50000', client_id: 'api-gateway') # POST /orders -> command (write) def create_order(client, body) msg = KubeMQ::CQ::CommandMessage.new( channel: 'orders.process', timeout: 5, body: body ) result = client.send_command(msg) result.executed end # GET /orders/:id -> query (read) def get_order(client, id) msg = KubeMQ::CQ::QueryMessage.new( channel: 'orders.lookup', timeout: 5, body: id ) result = client.send_query(msg) result.executed ? result.body : nil end ``` ```elixir title="gateway.exs" # Build once at startup, share the client across request handlers. {:ok, client} = KubeMQ.Client.start_link(address: "localhost:50000", client_id: "api-gateway") # POST /orders -> command (write) def create_order(client, body) do command = KubeMQ.Command.new( channel: "orders.process", body: body, timeout: 5_000 ) case KubeMQ.Client.send_command(client, command) do {:ok, resp} -> resp.executed {:error, _} -> false end end # GET /orders/:id -> query (read) def get_order(client, id) do query = KubeMQ.Query.new( channel: "orders.lookup", body: id, timeout: 5_000 ) case KubeMQ.Client.send_query(client, query) do {:ok, %{executed: true} = resp} -> {:ok, resp.body} _ -> {:error, :not_found} end end ``` ### Order Service (Responder) [#order-service-responder] ```go title="order_service.go" client.SubscribeToCommands(ctx, "orders.process", "order-workers", ...) client.SubscribeToQueries(ctx, "orders.lookup", "order-workers", ...) ``` ```python title="order_service.py" client.subscribe_to_commands(CommandsSubscription( channel="orders.process", group="order-workers", ...)) client.subscribe_to_queries(QueriesSubscription( channel="orders.lookup", group="order-workers", ...)) ``` ```javascript title="order_service.js" client.subscribeToCommands({ channel: "orders.process", group: "order-workers", ... }); client.subscribeToQueries({ channel: "orders.lookup", group: "order-workers", ... }); ``` ```java title="OrderService.java" client.subscribeToCommands(CommandsSubscription.builder() .channel("orders.process").group("order-workers")...build()); client.subscribeToQueries(QueriesSubscription.builder() .channel("orders.lookup").group("order-workers")...build()); ``` ```csharp title="OrderService.cs" await foreach (var cmd in client.SubscribeToCommandsAsync( new CommandsSubscription { Channel = "orders.process", Group = "order-workers" })) { } await foreach (var q in client.SubscribeToQueriesAsync( new QueriesSubscription { Channel = "orders.lookup", Group = "order-workers" })) { } ``` ```kotlin title="OrderService.kt" client.subscribeToCommands(channel = "orders.process", group = "order-workers", ...) client.subscribeToQueries(channel = "orders.lookup", group = "order-workers", ...) ``` ```cpp title="order_service.cpp" client.subscribeToCommands("orders.process", "order-workers", ...); client.subscribeToQueries("orders.lookup", "order-workers", ...); ``` ```rust title="order_service.rs" // Each handler joins the "order-workers" group so commands and queries are // load-balanced across service replicas. Replies are sent back to the gateway. let _cmd_sub = client .subscribe_to_commands("orders.process", "order-workers", on_command, None) .await?; let _qry_sub = client .subscribe_to_queries("orders.lookup", "order-workers", on_query, None) .await?; ``` ```ruby title="order_service.rb" cmd_sub = KubeMQ::CQ::CommandsSubscription.new(channel: 'orders.process', group: 'order-workers') client.subscribe_to_commands(cmd_sub, cancellation_token: cancel) { |cmd| handle_command(cmd) } qry_sub = KubeMQ::CQ::QueriesSubscription.new(channel: 'orders.lookup', group: 'order-workers') client.subscribe_to_queries(qry_sub, cancellation_token: cancel) { |query| handle_query(query) } ``` ```elixir title="order_service.exs" {:ok, _cmd_sub} = KubeMQ.Client.subscribe_to_commands(client, "orders.process", group: "order-workers", on_command: &handle_command/1) {:ok, _qry_sub} = KubeMQ.Client.subscribe_to_queries(client, "orders.lookup", group: "order-workers", on_query: &handle_query/1) ``` ## Production Considerations [#production-considerations] Set the HTTP timeout longer than the KubeMQ timeout to avoid the gateway timing out before the RPC call completes. A good rule: HTTP timeout = KubeMQ timeout + 2–3 seconds for overhead. Each backend service should have its own [circuit breaker](/learn/rpc/how-to/circuit-breaker). If the order service is down, the gateway can still serve inventory and user queries. Pass a correlation ID through KubeMQ tags to trace requests across services. Use the `Tags` field to propagate OpenTelemetry trace context. # CQRS Implementation (/learn/rpc/scenarios/cqrs-implementation) ## Scenario [#scenario] An e-commerce platform separates write and read operations: **Commands** create, update, and cancel orders in the write database, while **Queries** read order data from an optimized read projection. KubeMQ's built-in distinction between commands and queries maps directly to the CQRS pattern. ## Architecture [#architecture] *Commands flow to the write side (orange) and mutate the write database; queries flow to the read side (amber) and serve from a synced projection.* ## Implementation [#implementation] ### Command Handler (Writes) [#command-handler-writes] The command handler subscribes to write operations, persists changes to the write database, and returns `Executed: true`. ```go title="write_handler.go" _, err := client.SubscribeToCommands(ctx, "orders.write", "write-handlers", kubemq.WithOnCommandReceive(func(cmd *kubemq.CommandReceive) { var order map[string]interface{} json.Unmarshal(cmd.Body, &order) err := db.SaveOrder(order) resp := kubemq.NewCommandReply(). SetRequestId(cmd.Id). SetResponseTo(cmd.ResponseTo) if err != nil { resp.SetError(err.Error()) } else { resp.SetExecutedAt(time.Now()) } client.SendCommandResponse(ctx, resp) }), kubemq.WithOnError(func(err error) { log.Println("Error:", err) }), ) ``` ```python title="write_handler.py" def on_command(request): order = json.loads(request.body) try: db.save_order(order) client.send_response_message( CommandResponse(command_received=request, is_executed=True)) except Exception as e: client.send_response_message( CommandResponse(command_received=request, is_executed=False, error=str(e))) client.subscribe_to_commands(CommandsSubscription( channel="orders.write", group="write-handlers", on_receive_command_callback=on_command, on_error_callback=lambda e: print(f"Error: {e}")), cancel=cancel) ``` ```javascript title="write_handler.js" client.subscribeToCommands({ channel: "orders.write", group: "write-handlers", onCommand: async (cmd) => { const order = JSON.parse(new TextDecoder().decode(cmd.body)); try { await db.saveOrder(order); await client.sendCommandResponse({ id: cmd.id, replyChannel: cmd.replyChannel, executed: true, }); } catch (err) { await client.sendCommandResponse({ id: cmd.id, replyChannel: cmd.replyChannel, executed: false, error: err.message, }); } }, onError: (err) => console.error("Error:", err.message), }); ``` ```java title="WriteHandler.java" client.subscribeToCommands(CommandsSubscription.builder() .channel("orders.write").group("write-handlers") .onReceiveCommandCallback(cmd -> { try { var order = new Gson().fromJson(new String(cmd.getBody()), Order.class); db.saveOrder(order); return CommandResponseMessage.builder() .requestId(cmd.getId()).isExecuted(true).build(); } catch (Exception e) { return CommandResponseMessage.builder() .requestId(cmd.getId()).isExecuted(false) .error(e.getMessage()).build(); } }) .onErrorCallback(err -> System.err.println("Error: " + err.getMessage())) .build()); ``` ```csharp title="WriteHandler.cs" await foreach (var cmd in client.SubscribeToCommandsAsync( new CommandsSubscription { Channel = "orders.write", Group = "write-handlers" })) { try { var order = JsonSerializer.Deserialize(cmd.Body.Span); await db.SaveOrderAsync(order!); await client.SendCommandResponseAsync(new CommandResponse { RequestId = cmd.Id, IsExecuted = true }); } catch (Exception ex) { await client.SendCommandResponseAsync(new CommandResponse { RequestId = cmd.Id, IsExecuted = false, Error = ex.Message }); } } ``` ```kotlin title="WriteHandler.kt" client.subscribeToCommands( channel = "orders.write", group = "write-handlers", onCommand = { cmd -> try { val order = Json.decodeFromString(String(cmd.body)) db.saveOrder(order) client.sendCommandResponse(requestId = cmd.id, isExecuted = true) } catch (e: Exception) { client.sendCommandResponse( requestId = cmd.id, isExecuted = false, error = e.message ?: "") } }, onError = { err -> System.err.println("Error: ${err.message}") } ) ``` ```cpp title="write_handler.cpp" client.subscribeToCommands("orders.write", "write-handlers", [&](const kubemq::CommandReceive& cmd) { try { auto order = nlohmann::json::parse(cmd.body); db.saveOrder(order); client.sendCommandResponse(cmd.id, true); } catch (const std::exception& e) { client.sendCommandResponse(cmd.id, false, e.what()); } }, [](const std::string& err) { std::cerr << "Error: " << err << std::endl; } ); ``` ```rust title="write_handler.rs" let rc = client.clone(); let sub = client .subscribe_to_commands("orders.write", "write-handlers", move |cmd| { let c = rc.clone(); Box::pin(async move { let order: serde_json::Value = serde_json::from_slice(&cmd.body).unwrap_or_default(); let reply = match db.save_order(&order) { Ok(_) => CommandReplyBuilder::new() .request_id(&cmd.id) .response_to(&cmd.response_to) .executed(true) .build(), Err(e) => CommandReplyBuilder::new() .request_id(&cmd.id) .response_to(&cmd.response_to) .executed(false) .error(&e.to_string()) .build(), }; tokio::spawn(async move { let _ = c.send_command_response(reply).await; }); }) }, None) .await?; ``` ```ruby title="write_handler.rb" sub = KubeMQ::CQ::CommandsSubscription.new( channel: "orders.write", group: "write-handlers") client.subscribe_to_commands(sub, cancellation_token: cancel, on_error: ->(e) { puts "Error: #{e.message}" }) do |cmd| order = JSON.parse(cmd.body) begin db.save_order(order) response = KubeMQ::CQ::CommandResponseMessage.new( request_id: cmd.id, reply_channel: cmd.reply_channel, executed: true) rescue => e response = KubeMQ::CQ::CommandResponseMessage.new( request_id: cmd.id, reply_channel: cmd.reply_channel, executed: false, error: e.message) end client.send_response(response) end ``` ```elixir title="write_handler.exs" {:ok, sub} = KubeMQ.Client.subscribe_to_commands(client, "orders.write", group: "write-handlers", on_command: fn cmd -> order = Jason.decode!(cmd.body) case Orders.save(order) do :ok -> KubeMQ.CommandReply.new( request_id: cmd.id, response_to: cmd.reply_channel, executed: true) {:error, reason} -> KubeMQ.CommandReply.new( request_id: cmd.id, response_to: cmd.reply_channel, executed: false, error: reason) end end, on_error: fn err -> IO.puts("Error: #{err.message}") end ) ``` ### Query Handler (Reads) [#query-handler-reads] The query handler reads from the optimized read projection and returns full data in the response body. ```go title="read_handler.go" _, err := client.SubscribeToQueries(ctx, "orders.read", "read-handlers", kubemq.WithOnQueryReceive(func(query *kubemq.QueryReceive) { orderId := string(query.Body) order, err := readDB.GetOrder(orderId) resp := kubemq.NewQueryReply(). SetRequestId(query.Id). SetResponseTo(query.ResponseTo) if err != nil { resp.SetError("order not found: " + orderId) } else { data, _ := json.Marshal(order) resp.SetBody(data).SetExecutedAt(time.Now()) } client.SendQueryResponse(ctx, resp) }), kubemq.WithOnError(func(err error) { log.Println("Error:", err) }), ) ``` ```python title="read_handler.py" def on_query(request): order_id = request.body.decode("utf-8") order = read_db.get_order(order_id) if order: client.send_response_message(QueryResponse( query_received=request, is_executed=True, body=json.dumps(order).encode())) else: client.send_response_message(QueryResponse( query_received=request, is_executed=False, error=f"order not found: {order_id}")) client.subscribe_to_queries(QueriesSubscription( channel="orders.read", group="read-handlers", on_receive_query_callback=on_query, on_error_callback=lambda e: print(f"Error: {e}")), cancel=cancel) ``` ```javascript title="read_handler.js" client.subscribeToQueries({ channel: "orders.read", group: "read-handlers", onQuery: async (query) => { const orderId = new TextDecoder().decode(query.body); const order = await readDB.getOrder(orderId); if (order) { await client.sendQueryResponse({ id: query.id, replyChannel: query.replyChannel, executed: true, body: new TextEncoder().encode(JSON.stringify(order)), }); } else { await client.sendQueryResponse({ id: query.id, replyChannel: query.replyChannel, executed: false, error: `order not found: ${orderId}`, }); } }, onError: (err) => console.error("Error:", err.message), }); ``` ```java title="ReadHandler.java" client.subscribeToQueries(QueriesSubscription.builder() .channel("orders.read").group("read-handlers") .onReceiveQueryCallback(query -> { String orderId = new String(query.getBody()); var order = readDB.getOrder(orderId); if (order != null) { return QueryResponseMessage.builder() .requestId(query.getId()).isExecuted(true) .body(new Gson().toJson(order).getBytes()).build(); } return QueryResponseMessage.builder() .requestId(query.getId()).isExecuted(false) .error("order not found: " + orderId).build(); }) .onErrorCallback(err -> System.err.println("Error: " + err.getMessage())) .build()); ``` ```csharp title="ReadHandler.cs" await foreach (var query in client.SubscribeToQueriesAsync( new QueriesSubscription { Channel = "orders.read", Group = "read-handlers" })) { var orderId = Encoding.UTF8.GetString(query.Body.Span); var order = await readDB.GetOrderAsync(orderId); if (order is not null) { await client.SendQueryResponseAsync(new QueryResponse { RequestId = query.Id, IsExecuted = true, Body = JsonSerializer.SerializeToUtf8Bytes(order) }); } else { await client.SendQueryResponseAsync(new QueryResponse { RequestId = query.Id, IsExecuted = false, Error = $"order not found: {orderId}" }); } } ``` ```kotlin title="ReadHandler.kt" client.subscribeToQueries( channel = "orders.read", group = "read-handlers", onQuery = { query -> val orderId = String(query.body) val order = readDB.getOrder(orderId) if (order != null) { client.sendQueryResponse(requestId = query.id, isExecuted = true, body = Json.encodeToString(order).toByteArray()) } else { client.sendQueryResponse(requestId = query.id, isExecuted = false, error = "order not found: $orderId") } }, onError = { err -> System.err.println("Error: ${err.message}") } ) ``` ```cpp title="read_handler.cpp" client.subscribeToQueries("orders.read", "read-handlers", [&](const kubemq::QueryReceive& query) { auto order = readDB.getOrder(query.body); if (!order.empty()) { client.sendQueryResponse(query.id, true, order); } else { client.sendQueryResponse(query.id, false, "", "order not found: " + query.body); } }, [](const std::string& err) { std::cerr << "Error: " << err << std::endl; } ); ``` ```rust title="read_handler.rs" let rc = client.clone(); let sub = client .subscribe_to_queries("orders.read", "read-handlers", move |query| { let c = rc.clone(); Box::pin(async move { let order_id = String::from_utf8_lossy(&query.body).to_string(); let reply = match read_db.get_order(&order_id) { Some(order) => QueryReplyBuilder::new() .request_id(&query.id) .response_to(&query.response_to) .body(serde_json::to_vec(&order).unwrap()) .build(), None => QueryReplyBuilder::new() .request_id(&query.id) .response_to(&query.response_to) .executed(false) .error(&format!("order not found: {}", order_id)) .build(), }; tokio::spawn(async move { let _ = c.send_query_response(reply).await; }); }) }, None) .await?; ``` ```ruby title="read_handler.rb" sub = KubeMQ::CQ::QueriesSubscription.new( channel: "orders.read", group: "read-handlers") client.subscribe_to_queries(sub, cancellation_token: cancel, on_error: ->(e) { puts "Error: #{e.message}" }) do |query| order_id = query.body order = read_db.get_order(order_id) response = if order KubeMQ::CQ::QueryResponseMessage.new( request_id: query.id, reply_channel: query.reply_channel, executed: true, body: order.to_json, metadata: "application/json") else KubeMQ::CQ::QueryResponseMessage.new(