Query icon
OutputValues icon
Log icon
If icon
SlackIncomingWebhook icon
Pause icon
Loop icon
Fail icon
Schedule icon

Oracle Recycle Bin Space Reclaim Guard

Purge aged dropped tables from the Oracle recycle bin behind an age gate, a dry run plus an approval pause, then re-read dba_recyclebin to confirm.

Categories
DataInfrastructure

Dropping an Oracle table does not free its segments. The table is renamed to a BIN$... object in the recycle bin so FLASHBACK TABLE ... TO BEFORE DROP can bring it back. Every block it owned stays allocated in its tablespace. Its indexes go with it. Weeks of test tables, staging copies, failed loads then sit there holding space nobody can account for.

The views disagree about that space, which is why it goes unnoticed. dba_tables shows none of those tables. dba_segments shows every one of them as a BIN$... segment. dba_free_space counts the same blocks as free, because Oracle takes them back when it has to. A capacity report built on segments says the tablespace is filling, one built on free space says it is empty. Neither names a table you can go and look at. Oracle does reclaim the space on its own, but only at the moment an allocation needs it, by purging recycle bin entries oldest first, with nothing recorded and no regard for which table somebody still wanted back.

This blueprint reads the recycle bin itself. It lists the dropped tables old enough to be given up on, says how much space each one is holding, purges them with PURGE TABLE behind an age gate, a dry run plus an approval pause, then reads dba_recyclebin again and fails the run if a target survived.

This blueprint was created by zkasuran.

Safety

A purge is not a cleanup. It is the end of recovery for that table. Once PURGE TABLE "BIN$..." runs, FLASHBACK TABLE ... TO BEFORE DROP can never bring the table back, which is the only reason the recycle bin exists. So the age gate is the heart of this flow, not decoration.

  • min_age_hours defaults to 168, seven days. A table dropped minutes ago is reported in the findings but is never a purge target.
  • The gate is evaluated in SQL from dba_recyclebin.droptime, so the same predicate that selected a target selects it again in the re-read.
  • The purge runs only when auto_remediate is true, dry_run is false and a human resumes the approval_gate pause with approved set to true. Three independent conditions, all off by default.
  • Nothing in the flow calls PURGE RECYCLEBIN or PURGE DBA_RECYCLEBIN. Those take everything regardless of age. Each statement names one object.

How it works

  1. totals_before (io.kestra.plugin.jdbc.oracle.Query, read-only) reads how many recycle bin entries the checked schemas hold and how many bytes they occupy, at any age, joining dba_tablespaces for the block size.
  2. audit (read-only) lists the dropped tables whose droptime is at least min_age_hours old and whose can_purge is YES. For each one it sums the space of every recycle bin entry sharing the same base_object, so an index dropped with the table is counted. It also builds the exact PURGE TABLE statement.
  3. targets (io.kestra.plugin.core.output.OutputValues) collects those statements plus the total bytes they should release. summary logs the per-object detail.
  4. route (io.kestra.plugin.core.flow.If):
    • clean: nothing is old enough, so it logs what the recycle bin is holding and stops.
    • findings: alert posts the objects, their tablespaces plus their drop times to Slack.
      • remediate_gate runs only when auto_remediate is true and dry_run is false.
      • approval_gate (io.kestra.plugin.core.flow.Pause) holds the run. apply checks the resume answer, so an unanswered pause never reads as approval.
      • remediate (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) runs one PURGE TABLE per object, in turn.
      • reaudit re-runs the same age-gated query. totals_after re-reads the totals. verify_result fails the run through io.kestra.plugin.core.execution.Fail if any target is still there, otherwise remediated logs the before and after space.
  5. errors posts to Slack if the flow fails for any reason.

What you get

  • A read of the space that dropped tables are still holding, which dba_segments by object name will not tell you because the segment is named BIN$... now.
  • An age gate so recent drops keep their flashback recovery while old ones are released.
  • Per-object purges that include the dependent indexes dropped with each table.
  • A real verification step. The run passes only when the second read of dba_recyclebin shows every target gone, with the byte counts before and after in the log.
  • Slack alerts on the findings and on any failure.

Who it's for

  • DBAs chasing a tablespace that keeps filling while no live object explains it.
  • Platform teams whose test or ETL jobs create then drop tables, where the recycle bin grows a little on every run.
  • Anyone who wants the purge decision written down as a policy with a retention window, rather than a DBA typing PURGE RECYCLEBIN and hoping nobody needed those tables.

Why orchestrate this with Kestra

The usual answer is PURGE RECYCLEBIN, which takes everything including the table somebody dropped by mistake an hour ago. Kestra turns it into a policy: run on a schedule, scope it to the schemas you own, keep anything inside the flashback window, post the candidates to Slack, hold the destructive step behind a human approval, then prove the space came back by reading dba_recyclebin again. Every run keeps its logs, so you can show what was purged, when it had been dropped plus how many bytes it freed.

Tested end to end

On Kestra 2.0.5 OSS against Oracle Database Free 23ai (gvenzl/oracle-free, service FREEPDB1), with a local HTTP sink standing in for Slack. The test schema APP held five tables, each 20000 rows plus a secondary index, all created before any drop. T1 through T4 were dropped as the aged batch. T5 was dropped immediately before the gated runs so it stayed inside every age window. Each dropped table leaves three recycle bin entries (the table, its secondary index, its primary key index) totalling 1464 blocks or 11993088 bytes at an 8 KB block size.

Run Setup and inputs Result
1 empty recycle bin, defaults 0 entries, clean branch, SUCCESS
2 4 dropped tables, min_age_hours: 0.05 4 detected with 47972352 bytes, posted to Slack, no change, SUCCESS
3 same, auto_remediate: true, dry_run: true Same report, Slack said no change applied, recycle bin unchanged. The dry run gate held
4 same, auto_remediate: true, dry_run: false, default min_age_hours: 168.0 0 targets, clean branch, nothing purged. Fully armed, held by the age gate alone
5 4 aged plus the fresh T5, min_age_hours: 0.05, armed, pause resumed approved: true 4 purged, reaudit found 0, SUCCESS. 15 entries and 59965440 bytes went to 3 entries and 11993088 bytes, which is T5 still there
6 T5 now aged, armed, pause resumed approved: false Purge declined, nothing purged, SUCCESS
7 same target, a user with SELECT ANY DICTIONARY but no DROP ANY TABLE, armed, approved PURGE refused with ORA-01031, reaudit still found the target, the run FAILED and the errors block alerted Slack

Runs 1 and 2 are the proof the check can fail: the same query returned 0 against an empty recycle bin and 4 against the loaded one. Runs 4 and 5 are the proof the age gate holds: run 4 armed the purge and the shipped seven day default still returned 0 targets, then run 5 purged the four aged tables at a three minute threshold while the table dropped a minute earlier stayed. Every count plus byte figure was read back from dba_recyclebin with sqlplus, not only from the Kestra log.

Prerequisites

  • An Oracle Database reachable over the thin JDBC driver (jdbc:oracle:thin:@//host:1521/service).
  • A database user that can read dba_recyclebin (SELECT_CATALOG_ROLE or SELECT ANY DICTIONARY) and read dba_tablespaces. Purging another schema's dropped table needs DROP ANY TABLE. A user purging only its own dropped tables needs no extra privilege.
  • recyclebin left at its default ON. With it OFF a drop frees space at once, there is no recycle bin to read plus nothing to flashback.

Secrets

  • ORACLE_PASSWORD: the database password. It is the default for the password input.
  • SLACK_WEBHOOK_URL: used by alert and the errors block.
  • In Kestra OSS, provide each one as a base64-encoded environment variable prefixed SECRET_.

Inputs

Input Default Purpose
jdbc_url jdbc:oracle:thin:@//localhost:1521/FREEPDB1 The Oracle instance to check
username SYSTEM The database user
password {{ secret('ORACLE_PASSWORD') }} The database password
object_owners [APP] Schema owners whose dropped tables are checked
min_age_hours 168.0 A drop younger than this is reported but never purged
auto_remediate false Allow PURGE TABLE
dry_run true Must be false for a purge to run

Quick start

  1. Add the ORACLE_PASSWORD and SLACK_WEBHOOK_URL secrets.
  2. Import the flow, set jdbc_url plus username, then list the schemas in object_owners.
  3. Run it with the defaults and read what the recycle bin is holding per schema.
  4. Decide your retention window and set min_age_hours to it.
  5. When you want it to reclaim, run with auto_remediate: true plus dry_run: false, then resume the pause with approved: true.
  6. Enable the daily trigger, which always runs in safe mode.

How to extend

  • Swap the per-object loop for one PURGE TABLESPACE <name> USER <owner> when you only care about a single tablespace and accept giving up flashback for everything in it. That form ignores drop time, so it drops the age gate with it.
  • Add a tablespace filter to the audit query to work one tablespace at a time.
  • Fail the run as soon as the audit finds anything, for a read-only policy check with no purge.
  • Compare the reclaimable bytes against the tablespace free space first, so the flow only purges when the tablespace is actually under pressure.
  • Loop over several instances to check every database from one flow.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.