OutputValues icon
TopicList icon
ConsumerGroupList icon
DescribeLogDirs icon
If icon
ConsumerGroupDescribe icon
Loop icon
TopicDescribe icon
Set icon
Log icon
SlackIncomingWebhook icon
Pause icon
TopicDelete icon
Sleep icon
Fail icon
Schedule icon

Kafka Orphaned Topic Reclaim Guard

Detect Kafka topics nothing reads, delete them with TopicDelete behind an approval gate, then re-list and fail if a targeted topic still exists.

Categories
DataInfrastructure

A service gets decommissioned. Its topics stay. A producer ships a typo in a topic name, auto-create obliges and now the cluster carries ordres next to orders for good. Neither case has an owner who comes back to clean up, because deleting a topic is the one Kafka operation nobody wants to be the person who ran. So the topics stay. Each one keeps its partitions, its replicas on every broker, its log segments on disk and a row in every metadata scan the cluster does.

Doing it by hand is the problem. kafka-topics.sh --delete is instant and final. There is no undo, no recycle bin and no way to tell from the topic list alone whether a topic with no consumers right now is abandoned or just quiet between batches. Get it wrong and you have destroyed data that only existed there.

This blueprint makes that call with evidence instead of nerve. It lists every topic, then removes from the list anything internal, anything with a committed consumer group offset, anything assigned to a live consumer and anything on a protect list. What is left gets recorded in a KV snapshot with its byte size, partition count and replication factor. A topic becomes eligible only once that snapshot has held still for the whole grace window, so a topic that got written to, grew, shrank or was repartitioned since the last run starts its clock again. Deleting still needs auto_remediate true, dry_run false and a human resuming a paused execution. After the delete it re-lists the cluster and fails the run if a targeted topic is still there.

This blueprint was created by zkasuran.

Safety

  • Read-only by default. With the shipped inputs it lists, reports and alerts. It changes nothing.
  • A delete needs three things at once: auto_remediate true, dry_run false and a human resuming the Pause with approved=true. An unanswered resume reads as not approved.
  • Internal topics are never candidates. Anything whose name starts with _ is excluded, which covers __consumer_offsets, _schemas and the Confluent internal topics.
  • A topic with a committed offset from any consumer group is never a candidate, whatever that group's state. An Empty group still holds the offsets that prove the topic is in use.
  • A topic assigned to a live consumer group member is never a candidate, even if that member has not committed yet.
  • protected_topics are never candidates.
  • A topic whose bytes, partition count or replication factor moved since the last run has its grace window reset, so an active topic can never age into eligibility.
  • The KV cursor carries no ttl, so the window cannot quietly expire and hand a fresh topic an old first-seen date. After a confirmed delete the deleted names are pruned from the cursor, so a topic later recreated under the same name starts its own window instead of inheriting the dead one.
  • The re-list after the delete is the real gate. If a targeted topic survives, the run fails and alerts rather than reporting success. That covers a refused delete and the case where a client was still writing and auto-create put the topic straight back.

How it works

  1. freeze captures one timestamp for the run, so detect, cursor and age all agree on now.
  2. list_all (io.kestra.plugin.kafka.TopicList, read-only) returns every topic including the internal ones, so the report can say how many were skipped.
  3. list_groups (io.kestra.plugin.kafka.ConsumerGroupList, read-only) returns every consumer group whatever its state.
  4. log_dirs (io.kestra.plugin.kafka.DescribeLogDirs, read-only) returns bytes on disk per topic.
  5. groups_present guards describe_groups (io.kestra.plugin.kafka.ConsumerGroupDescribe, read-only), which reads each group's members and committed offsets. The describe is skipped on a cluster with no groups, because it rejects an empty list.
  6. analyze builds the candidate set: every topic that is not internal, has no committed offset, is not assigned to a live member and is not protected. It also totals the bytes those topics hold.
  7. route (io.kestra.plugin.core.flow.If) branches on whether any candidate exists.
    • When none exist, clear_cursor resets the first-seen record and all_in_use logs that there is nothing to reclaim. No alert.
    • When some exist:
      • describe_candidates (io.kestra.plugin.core.flow.Loop over io.kestra.plugin.kafka.TopicDescribe, read-only) reads each candidate's partition count and replication factor.
      • fingerprint combines that with the byte sizes into the state each topic has to hold still in.
      • cursor_merge and save_cursor keep the first-seen date per topic in the KV key kafka_orphan_topics_<cluster>, resetting any topic whose fingerprint moved.
      • split separates the candidates past min_idle_minutes from the ones held back, then builds the per-topic detail line.
      • log_findings logs the whole picture and alert posts it to Slack.
      • remediate_gate runs only when auto_remediate is true, dry_run is false and at least one topic is past the window.
        • approve (io.kestra.plugin.core.flow.Pause) waits for a resume carrying approved, with the irreversibility spelled out in its description.
        • When approved, remediate_delete deletes one topic per iteration with io.kestra.plugin.kafka.TopicDelete at concurrencyLimit: 1, settle waits for the controller, reverify re-lists and verify_result fails the run if any targeted topic is still present. On a clean result prune_cursor drops the deleted topics from the KV record.
        • When not approved, not_approved logs that nothing was deleted.
  8. errors posts to Slack on any failure, for example an unreachable broker or a topic that survived the delete.

What you get

  • An inventory of the topics on a cluster that nothing reads, with what each one costs in partitions, replicas and bytes.
  • A grace window that resets itself whenever a topic shows any sign of life, so a quiet topic between batches is never mistaken for a dead one.
  • A delete that is off by default and needs a named human to approve it, with the irreversibility stated at the point of approval.
  • Proof the reclaim landed: a re-list that fails the run when a targeted topic survives.

Who it's for

  • Platform teams running a shared Kafka cluster where every retired service leaves topics behind.
  • Anyone who has found a typo topic sitting next to the real one and had nobody willing to delete it.
  • Teams running close to a partition or disk ceiling who need the unused capacity back rather than more brokers.
  • Teams evaluating the Kafka plugin who want a worked example of TopicList, TopicDescribe, DescribeLogDirs, ConsumerGroupDescribe and TopicDelete with real gates around the delete.

Why orchestrate this with Kestra

Every signal this decision needs sits in a different admin call. The one that matters most, how long a topic has looked dead, does not exist on the cluster at all. Kafka can tell you a topic has no consumers right now. It cannot tell you it had none last week. Kestra supplies the schedule that keeps looking, the KV snapshot that turns a series of observations into an age, the Pause that puts a person between a measurement and an irreversible delete, the re-list that proves the result and a run history that says who approved which delete and when.

Tested end to end

On Kestra 2.0.5 OSS against Apache Kafka 3.9.1 (KRaft, single node), with a local HTTP sink standing in for Slack. The cluster carried orders (20 messages, a live orders-processor consumer), legacy-exports and abandoned-clickstream (messages, no consumer), ordres-typo and decommissioned-billing (empty, no consumer, the auto-create typo case), brand-new-topic (created minutes before the run), keep-me-forever (passed in protected_topics), _schemas and __consumer_offsets. Topic and group state was read with kafka-topics.sh --list and kafka-consumer-groups.sh before and after every run.

Run Inputs Result
1 defaults, every junk topic given a committed offset 0 candidates, all_in_use logged, no alert, nothing deleted
2 defaults, those throwaway groups deleted 3 orphans found. orders excluded on its live group, keep-me-forever on the protect list, both internal topics skipped. Nothing deleted (dry_run true). --list unchanged
3 auto_remediate: true, dry_run: true, min_idle_minutes: 1 3 past the window, no Pause, nothing deleted. --list unchanged
4 auto_remediate: true, dry_run: false, min_idle_minutes: 60 3 reported, all held as too recent, 0 past the window, no Pause. --list unchanged
5 8 messages produced to one orphan, then rerun Its bytes moved 182 to 323, first-seen reset to now, dropped from 3 targets to 2
6 min_idle_minutes: 1, resumed with no answer Paused, not_approved logged, nothing deleted. --list unchanged
7 min_idle_minutes: 1, resumed with approved=true 2 topics deleted, re-list confirmed gone, --list went 7 topics to 5
8 min_idle_minutes: 1440 against a planted first-seen cursor 5 unused, 4 deleted and 488 bytes reclaimed, brand-new-topic held as too recent. --list went 9 topics to 5, leaving only the protected, consumed and internal topics. Cursor pruned to the held topic
9 same, with a producer writing to the target through the Pause Delete landed, auto-create put the topic straight back, re-list caught it, run FAILED and the errors alert fired
10 unreachable broker Run failed on list_all, errors alert sent

Prerequisites

  • A Kafka cluster reachable from the Kestra worker, with a principal allowed to describe topics, groups and log dirs and, for remediation, to delete topics.
  • delete.topic.enable true on the brokers. It is the default. With it false the delete is refused and the re-verify fails the run, which is the honest outcome.
  • Auto-create off on the brokers. Leave it on and a client still writing to a targeted topic will have that topic recreated right after the delete. The re-verify catches that and fails the run, so it is reported rather than silently reversed.
  • Run it read-only for at least as long as your grace window before you trust the target list. The age comes from the flow's own observations, so a first run has nothing behind it.

Secrets

  • SLACK_WEBHOOK_URL: used by alert and the errors block. In Kestra OSS, provide it as the base64-encoded environment variable SECRET_SLACK_WEBHOOK_URL.

Inputs

Input Default Purpose
bootstrap_servers localhost:9092 Brokers
min_idle_minutes 10080 How long a topic must look abandoned before it can be deleted
protected_topics [] Topic names never deleted
verify_delay PT5S How long to wait before the re-list
auto_remediate false Allow TopicDelete
dry_run true Must be false for a delete to apply

Quick start

  1. Add the SLACK_WEBHOOK_URL secret.
  2. Import the flow and set bootstrap_servers.
  3. Run it with the defaults and read log_findings. Nothing is touched.
  4. Add anything that is quiet by design to protected_topics, for example a topic a quarterly job drains.
  5. Leave the weekly_scan trigger enabled in safe mode for a few windows so the first-seen dates are real.
  6. When the target list looks right, run it with auto_remediate: true and dry_run: false, then resume the paused execution with approved=true.

How to extend

  • Dump each targeted topic to object storage with a Kafka consume step before the delete, so a wrong call is recoverable.
  • Treat a topic with bytes on disk differently from an empty one: raise the window for the ones that still hold data, drop it for the empty auto-created ones.
  • Add a produce-rate check through DescribeLogDirs deltas across runs and report the growth rate beside each orphan.
  • Post the Slack message to a channel with a link to the paused execution, so the approval happens from chat.
  • Point bootstrap_servers at a different cluster from the one scheduling the flow, so one Kestra instance can sweep several clusters.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.