New to Kestra?
Use blueprints to kickstart your first workflows.
Route Apache Pulsar multi-tenant event streams by priority tier and quarantine backlog messages with Slack alerts.
A single shared Pulsar topic serving many tenants has no built-in way to treat an
enterprise customer's traffic differently from a free-tier customer's. This blueprint
drains the shared topic on a schedule, reads the tenant_tier field carried in each
message's JSON payload, and branches: ENTERPRISE traffic is logged as priority-handled,
everything else is republished to a quarantine topic instead of being processed inline or
silently dropped. One Slack message reports the outcome of every run.
hourly_backlog_drain (io.kestra.plugin.core.trigger.Schedule) runs once an hour. Shipped disabled so you can validate a manual run first.consume_tenant_events (io.kestra.plugin.pulsar.Consume) batch-receives up to maxRecords: 50 JSON messages from tenant_topic under subscription kestra-backlog-router. Its own output is only messagesCount and a storage uri to an Ion file, one record per message, each shaped {key, value, properties, topic, eventTime, messageId}, not a messages list.extract_tenant_json (io.kestra.plugin.serdes.json.IonToJson) converts that Ion file to a JSON array so its contents can be queried with jq.route_by_tenant_tier (io.kestra.plugin.core.flow.Switch) reads {{ read(outputs.extract_tenant_json.uri) | jq('.[0].value.tenant_tier') | first | default('FREE_TIER') }}, the first consumed message's tenant_tier field, and branches:ENTERPRISE: process_enterprise_event logs priority handling.FREE_TIER: quarantine_free_tier (io.kestra.plugin.pulsar.Produce) republishes the same consumed records to quarantine_topic.log_unclassified_tenant logs that the resolved value was neither ENTERPRISE nor FREE_TIER (an unrecognized tier string; see Pitfalls for what an empty batch resolves to instead).notify_slack posts one summary per run: messages consumed and the tier that was routed.quarantine_topic) rather than discarding them.default branch that surfaces batches with no recognizable tier instead of silently falling through.Pulsar's own subscription and topic model has no concept of "read this field and branch my pipeline on it", that decision, and what happens in each branch, has to live outside the broker. Kestra's Switch makes that branch a first-class, inspectable step; the schedule and execution history mean every drain (priority-handled, quarantined, or unclassified) is reviewable after the fact, and the same flow can fan out to more than two branches or add new tiers by adding cases entries.
docker run -p 6650:6650 -p 8081:8080 apachepulsar/pulsar:latest bin/pulsar standalone, the web admin port is remapped to host port 8081 (-p 8081:8080) specifically to avoid colliding with Kestra's own UI on localhost:8080; the client protocol port 6650 is unaffected and is what pulsar_uri connects to.tenant_topic and quarantine_topic must already exist (or auto-topic-creation enabled on the namespace) and carry messages whose JSON body includes a tenant_tier field, e.g. {"tenant_tier": "ENTERPRISE", ...}.SLACK_WEBHOOK_URL: Slack incoming webhook used by notify_slack.SECRET_, base64-encoded, and read back in flows with {{ secret('NAME') }}, for example SECRET_SLACK_WEBHOOK_URL=$(echo -n 'https://hooks.slack.com/...' | base64). This keeps the webhook out of the flow YAML but, per Kestra's own documentation, offers no encryption at rest or access control beyond the host environment; use the Enterprise secrets backend for stronger guarantees.pulsar_uri (STRING, default pulsar://localhost:6650): Pulsar broker connection string.tenant_topic (STRING, default persistent://public/default/tenant-inbound): multi-tenant topic to drain.quarantine_topic (STRING, default persistent://public/default/tenant-quarantine): destination for non-priority batches.outputs.consume_tenant_events.messagesCount / .uri: how many messages were pulled this run, and the raw Ion file of consumed records.outputs.extract_tenant_json.uri: the same records as a JSON file, used for the routing decision.outputs.quarantine_free_tier.messagesCount: set only when the FREE_TIER branch ran, the number of records republished to quarantine_topic.SLACK_WEBHOOK_URL secret.tenant_tier field onto tenant_topic.hourly_backlog_drain once a manual run looks correct.io.kestra.plugin.core.flow.ForEach over the JSON array) if different messages in the same batch carry different tiers.cases (e.g. PRO_TIER) as your tenant model grows past two tiers.quarantine_free_tier's output into a billing or throttling system instead of only Pulsar.Schedule trigger for io.kestra.plugin.pulsar.RealtimeTrigger for per-message routing instead of hourly batches, trading the batch efficiency of Consume for per-event latency.route_by_tenant_tier's value reads only .[0], the first record in the batch. A batch mixing ENTERPRISE and FREE_TIER messages is routed entirely by whichever happened to be first. This mirrors a deliberate simplification in this blueprint's routing key, not a bug in the Switch task; see "How to extend" for a per-message alternative.Consume's output has no messages field. It only exposes messagesCount (an Integer) and uri (an Ion file). Any expression written against outputs.consume_tenant_events.messages will fail to render, always go through extract_tenant_json's JSON file first.jq('.[0].value.tenant_tier') evaluates .[0] of an empty array to null, so first yields null; whether Kestra's default('FREE_TIER') filter treats that rendered null as "absent" (substituting FREE_TIER) or passes it through (landing in defaults instead) was not tested against a running Kestra instance. Either outcome still avoids an error, but if an empty poll reaching quarantine_free_tier and calling Produce against zero source records is not acceptable, branch on outputs.consume_tenant_events.messagesCount == 0 before the Switch instead of relying on this fallback.subscriptionName is shared and durable. Every run uses the same kestra-backlog-router subscription, so Pulsar tracks one cursor across runs, this is what makes the hourly drain incremental rather than replaying the same backlog. Changing the subscription name resets that cursor.