New to Kestra?
Use blueprints to kickstart your first workflows.
Detect Kafka topics nothing reads, delete them with TopicDelete behind an approval gate, then re-list and fail if a targeted topic still exists.
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.
auto_remediate true, dry_run false and a human resuming the Pause with approved=true. An unanswered resume reads as not approved._ is excluded, which covers __consumer_offsets, _schemas and the Confluent internal topics.protected_topics are never candidates.freeze captures one timestamp for the run, so detect, cursor and age all agree on now.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.list_groups (io.kestra.plugin.kafka.ConsumerGroupList, read-only) returns every consumer group whatever its state.log_dirs (io.kestra.plugin.kafka.DescribeLogDirs, read-only) returns bytes on disk per topic.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.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.route (io.kestra.plugin.core.flow.If) branches on whether any candidate exists.clear_cursor resets the first-seen record and all_in_use logs that there is nothing to reclaim. No alert.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.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.not_approved logs that nothing was deleted.errors posts to Slack on any failure, for example an unreachable broker or a topic that survived the delete.TopicList, TopicDescribe, DescribeLogDirs, ConsumerGroupDescribe and TopicDelete with real gates around the delete.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.
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 |
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.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.| 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 |
SLACK_WEBHOOK_URL secret.bootstrap_servers.log_findings. Nothing is touched.protected_topics, for example a topic a quarterly job drains.weekly_scan trigger enabled in safe mode for a few windows so the first-seen dates are real.auto_remediate: true and dry_run: false, then resume the paused execution with approved=true.DescribeLogDirs deltas across runs and report the growth rate beside each orphan.bootstrap_servers at a different cluster from the one scheduling the flow, so one Kestra instance can sweep several clusters.