Request icon
OutputValues icon
Log icon
Set icon
If icon
SlackIncomingWebhook icon
Pause icon
Loop icon
Reader icon
Fail icon
Schedule icon

Clear Pulsar backlogs held by subscriptions nobody consumes

Find Pulsar subscriptions with zero consumers holding a backlog, then skip_all or unsubscribe behind a dry run, a pause and a live re-read.

Categories
DataInfrastructure

A Pulsar subscription with zero consumers does not stop costing anything. The broker keeps every unacknowledged message for it, the backlog grows without bound. The ledgers behind those messages can never be reclaimed either, because Pulsar will not delete a ledger that any subscription still references. A service decommissioned months ago can hold terabytes this way. The only sign is a storage graph that climbs while nothing reads. This blueprint finds those subscriptions through the Pulsar admin REST API, holds them behind an age window so a consumer that is merely restarting is never touched, then clears them behind a dry run and a human approval before reading the live topic stats again to prove the backlog actually went away.

This blueprint was created by zkasuran.

Safety

The two actions are not interchangeable, so remediation_action is an explicit input and it defaults to the less destructive one.

skip_all acknowledges every pending message for that subscription. The data is gone for that consumer group, the ledgers stop being pinned. The subscription survives with an empty backlog, so a consumer that reconnects later keeps its identity and receives new messages from that point on.

unsubscribe deletes the subscription outright. The ledgers stop being pinned in the same way, but the cursor goes with it, so a consumer that reconnects under the same subscription name starts fresh at the topic's current position instead of resuming where it left off.

Both are irreversible for the messages involved. Nothing in Pulsar can un-acknowledge a skipped message or restore a deleted cursor, which is why the write sits behind auto_remediate, dry_run and a Pause. Four separate brakes sit on it:

  • The age gate. Each consumerless subscription gets a first-seen timestamp in Kestra KV. Nothing is eligible until it has been consumerless since before grace_period_hours. A subscription seen for the first time in this run is recorded and left alone, so a rolling consumer restart never looks like an abandoned service.
  • The live re-check. The namespace sweep reads the broker's cached stats snapshot, which lags by the broker's own statsUpdateFrequencyInSecs, so every target is read again from the live per-topic stats endpoint immediately before the write. If a consumer reconnected in the meantime the target is skipped and nothing is written.
  • The evidence read. Before each write the flow reads a sample of the topic's oldest retained messages through the Pulsar plugin, with no subscription and no acks, so there is a record of what the write discarded.
  • The re-read. After the write the live stats are read again and the run fails if the backlog is still above target_backlog_messages. It also fails if the subscription still exists at all when the action was unsubscribe.

How it works

  1. GET /admin/v2/persistent/{tenant}/{namespace} lists the topics the namespace holds right now.
  2. One call to /admin/v2/broker-stats/topics returns every topic the broker owns with its subscriptions, each carrying msgBacklog and its connected consumer list.
  3. A jq pass reduces that tree to one row per subscription on a topic that is still in the live list, dropping Pulsar's own system topics and system subscriptions, then keeps the rows with zero consumers and a backlog at or above min_backlog_messages.
  4. The first-seen snapshot in Kestra KV splits those into aged targets and in-grace ones. Targets are ordered biggest backlog first and capped at max_subscriptions.
  5. Findings go to Slack whether or not anything is eligible, so the detection is useful on its own.
  6. With dry_run true or auto_remediate false the run stops at a log of exactly what it would have called. That is the default.
  7. Armed, a Pause waits for a human. On approval a Loop with concurrencyLimit: 1 walks the targets one at a time: live re-check, sample through io.kestra.plugin.pulsar.Reader, then POST .../subscription/{sub}/skip_all or DELETE .../subscription/{sub}.
  8. Each target is re-read from the live stats endpoint straight after its own write. The run fails if the write did not land.

What you get

Storage you can actually reclaim. Once no subscription references a ledger, Pulsar's own retention and ledger trimming can delete it, so the broker's disk goes down instead of sideways. You also get a decision you can defend: the target list, the backlog each subscription held, the consumer counts the broker reported, the grace window each one passed, the sampled messages. It reports the live numbers from the re-read after the write.

The sampled messages land in Kestra internal storage as an ion file at {{ outputs.sample_doomed_messages.uri }}, one row per message, with {{ outputs.sample_doomed_messages.messagesCount }} as the count. The per-target before and after states are logged as JSON, for example {"present":true,"backlog":3074,"bytes":174569,"consumers":0} ahead of the write and {"present":true,"backlog":0,"bytes":0,"consumers":0} after it.

The age gate is the part that makes this safe to schedule. A backlog alert cannot tell a decommissioned exporter from a consumer fleet rolling through a deploy. Two sightings separated by the grace window can.

Who it's for

Platform and streaming teams running Pulsar for more than one product team, where subscriptions outlive the services that created them. If broker storage keeps climbing while message rates are flat, this is usually why.

Why orchestrate this with Kestra

The job is a sequence of admin calls with a decision in the middle, which is what a flow is good at. Kestra supplies the parts a cron script would have to grow for itself: a KV store for the first-seen clock that survives restarts, a Pause that puts a named human in front of an irreversible call, secrets for the admin token, a re-read that can fail the run. It keeps a log of every number the decision used. The Pulsar plugin reads from the same broker in the same flow, so the evidence and the remediation share one credential set and one audit trail.

Tested end to end

Run against Apache Pulsar 3.3.1 in standalone mode on Kestra 2.0.5. One topic held 2000 messages with several subscriptions on it: orphaned ones with zero consumers and a backdated first-seen, one with a live consumer. One more was freshly created and consumerless inside the grace window.

  • Healthy namespace, every subscription consumed: 0 candidates with backlogs well above the threshold, no Slack alert, no write. That proves the detector keys on the consumer count rather than the backlog.
  • Degraded namespace: the sweep reported the orphans at msgBacklog 2000 with 0 consumers, left the subscription with a live consumer alone. It left the fresh orphan to the age gate.
  • dry_run: true: the plan named the target and the admin API showed the backlog unchanged.
  • auto_remediate: true with dry_run: false and approval: skip_all took the backlog from 2000 to 0 and the live re-read confirmed it. unsubscribe removed the subscription and the re-read confirmed it was gone.
  • Approval declined: nothing was called and the backlog stayed where it was.
  • A consumer that reconnected during the pause: the live re-check skipped the target and nothing was written.
  • Re-verify failure: with a producer still writing to the topic, skip_all could not hold the backlog at the target, the live re-read caught it and the run failed instead of reporting success.
  • Failure path: an unreachable admin URL routed to the errors block and posted to Slack.

Every before and after number was read from the Pulsar admin API directly, not from the Kestra log.

Prerequisites

A reachable Pulsar admin REST endpoint, an account with namespace-level admin rights on the tenant. The broker service URL is needed for the plugin read. The admin role needs skip_all and unsubscribe permission on the namespace.

Secrets

  • PULSAR_ADMIN_TOKEN: bearer token for the Pulsar admin REST API. Used while auth_mode is token.
  • SLACK_WEBHOOK_URL: incoming webhook for findings and failures.

Inputs

  • admin_url: base URL of the Pulsar admin REST API, for example http://pulsar-broker:8080.
  • broker_uri: broker service URL for the plugin read, for example pulsar://pulsar-broker:6650.
  • auth_mode: token to send the bearer token, none for a cluster with authentication disabled.
  • tenant, pulsar_namespace: the namespace to sweep.
  • min_backlog_messages: backlog at or above which a consumerless subscription is a candidate.
  • grace_period_hours: how long a subscription must stay consumerless before it can be touched.
  • target_backlog_messages: backlog the live re-read must show after a skip_all.
  • remediation_action: skip_all keeps the subscription with an empty backlog, unsubscribe deletes it.
  • max_subscriptions: cap on how many are acted on per run.
  • sample_messages: how many messages to read as evidence before each write.
  • dry_run, auto_remediate: the safety gates. Defaults are read-only.

Quick start

  1. Add PULSAR_ADMIN_TOKEN and SLACK_WEBHOOK_URL to your namespace secrets.
  2. Run it with the defaults against one namespace. Nothing is written, so read the log and see what it found.
  3. Lower min_backlog_messages until the audit lines up with what you know about that namespace, then raise it to the level you want paged on.
  4. Leave it on the disabled nightly schedule for a few days so the first-seen clock fills in and the grace window means something.
  5. When a target looks right, run it once with auto_remediate: true and dry_run: false and approve the pause by hand.

How to extend

  • /admin/v2/broker-stats/topics reports the topics the broker you called owns. On a multi-broker cluster run the flow once per broker admin URL. The alternative is one run per namespace behind the proxy, looping the namespaces.
  • The live per-subscription stats also carry lastConsumedTimestamp and lastAckedTimestamp. Fold either into the eligibility test for a second idle signal next to the KV first-seen clock.
  • Swap the Slack task for PagerDuty or Opsgenie if the reclaim sits on an incident path.
  • Add a backlog quota on the namespace with POST /admin/v2/namespaces/{tenant}/{ns}/backlogQuota so new subscriptions cannot repeat the growth. Keep this flow for the ones that already did.
  • Point the pause at the team that owns the namespace by sending the target list to their channel first.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.