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

Oracle Stale Optimizer Statistics Gather Guard

Detect Oracle tables with stale or missing optimizer statistics, gather them with DBMS_STATS inside a per run cap, then re-read DBA_TAB_STATISTICS.

Categories
DataInfrastructure

Oracle's cost based optimizer picks a plan from the statistics it has, not from the table that exists. After a bulk load, a migration or steady churn past roughly ten percent of the rows, DBA_TAB_STATISTICS.STALE_STATS flips to YES and the optimizer starts costing plans from numbers that describe a table from last month.

Then a plan regresses. A nested loop that was right for a thousand rows now drives against ten million. A query that took a second takes an hour. Nothing errors. Nothing is logged. It reaches you as "the database is slow today".

This blueprint reads the staleness flag, reports what a gather would cost before you enable it, gathers inside a cap, then proves the flag cleared.

This blueprint was created by zkasuran.

Findings

Measured on the test rig below, on a 2000 row table that then took 600 more rows.

statistics say the table holds the optimizer's own estimate
before the gather 2000 rows 2600 rows 2000 rows
after the gather 2600 rows 2600 rows 2600 rows

That last column is EXPLAIN PLAN cardinality for SELECT * FROM app.orders, read out of PLAN_TABLE. The optimizer was planning for a table 23 percent smaller than the real one, which is the whole mechanism behind the bad join order.

Two things the catalog view does not say out loud:

  • A table that was never analyzed has STALE_STATS of NULL, not YES. A detector that filters on STALE_STATS = 'YES' misses the worst case in the schema, the table the optimizer knows nothing about at all. This flow treats a null LAST_ANALYZED as a finding in its own right. include_never_analyzed controls it.
  • STALE_STATS is derived from DML counters Oracle keeps in memory and writes down periodically, which is why DBMS_STATS.FLUSH_DATABASE_MONITORING_INFO exists. On the rig the flag did not need it. 400 inserts into a 3000 row table read YES straight away. DBA_TAB_MODIFICATIONS already showed all 800 rows written since the last gather, identical before and after the flush. Oracle Database Free 23ai reads the live counters for both views. The flow still calls the flush first, because that write down interval is an implementation detail rather than a contract, the call is idempotent plus it writes no user data and no statistics.

Safety

This guard is deliberately not behind a Pause, which is a difference from the destructive guards in this family. Gathering statistics writes no user data, changes no schema plus drops nothing. It samples the table and updates the dictionary, so the old statistics are superseded rather than destroyed: Oracle keeps the previous version for DBMS_STATS.GET_STATS_HISTORY_RETENTION days (31 by default) and DBMS_STATS.RESTORE_TABLE_STATS rolls a table back to any point inside that window. The flow reads the retention plus the earliest available history on your instance and prints both, so the rollback window is a measured fact in the run log rather than an assumption. It does not write its own stats table with DBMS_STATS.EXPORT_TABLE_STATS, because the automatic history already covers the rollback and a second copy is one more thing to own. If your instance has had the retention set to 0, the history is off and an export is worth adding.

The real risk here is cost, not consent. A gather on a large table is genuine IO against a live instance. So this flow carries three cost controls instead of an approval prompt:

  • max_tables_per_run caps how many tables one pass touches. Findings past the cap are reported as DEFERRED plus picked up by the next run, so a schema with a hundred stale tables is worked through in slices rather than all at once.
  • max_table_mb is a hard ceiling. A table over it is reported, never gathered, because that one belongs in a maintenance window with its own sample size.
  • concurrencyLimit: 1 on the gather loop, so two gathers never contend for the same buffer cache plus parallel servers.

On top of those, every finding is reported with its row count, its segment size in MB plus the DML volume since the last gather. The summary adds up what the pass would sample before anything is enabled. You can see the bill before you pay it.

Two more things the flow will not do. A table whose statistics are locked is reported, never gathered: a lock is usually somebody pinning a plan on purpose, DBMS_STATS raises ORA-20005 rather than overwriting it, plus unlocking is a decision for whoever locked it. And the gather passes no_invalidate => FALSE, which invalidates the dependent cursors immediately so the next execution gets a plan from the new numbers. That is the point of the fix. It does mean a short hard parse wave on a busy instance.

How it works

  1. flush_monitoring (io.kestra.plugin.jdbc.oracle.Query) calls DBMS_STATS.FLUSH_DATABASE_MONITORING_INFO so the staleness flags reflect the DML of the last few minutes rather than the last flush cycle.
  2. stats_history (read-only) reads GET_STATS_HISTORY_RETENTION plus GET_STATS_HISTORY_AVAILABILITY, the window a gather can be rolled back inside.
  3. totals_before (read-only) counts the tables in the checked schemas as current, stale, never analyzed plus statistics locked, with the oldest LAST_ANALYZED and the total row count the dictionary believes.
  4. audit (read-only) lists every table with STALE_STATS = 'YES' or no statistics at all, joined to DBA_SEGMENTS for size and DBA_TAB_MODIFICATIONS for the DML since the last gather, then builds the exact DBMS_STATS.GATHER_TABLE_STATS call for each. A ROW_NUMBER() window applies the per run cap, so the worst offenders go first: tables with no statistics, then the heaviest DML, then the smallest segment.
  5. targets (io.kestra.plugin.core.output.OutputValues) splits the findings into gatherable, deferred, locked plus over the size ceiling, then sums the rows plus MB the pass would sample. summary logs all of it.
  6. route (io.kestra.plugin.core.flow.If):
    • clean: every table is current, so it logs the counts and stops.
    • findings: alert posts the groups plus the sampling cost to Slack. state_route explains the locked, oversized plus deferred tables when there are any.
      • remediate_gate runs only when auto_remediate is true, dry_run is false plus there is at least one gatherable table.
      • mark_time reads the database clock before anything changes.
      • remediate (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) runs one DBMS_STATS.GATHER_TABLE_STATS per table with allowFailure, so one refusal does not stop the pass.
      • flush_after flushes the monitoring info again, reverify re-reads dba_tab_statistics for those exact tables, totals_after re-reads the counts.
      • verify_result fails the run through io.kestra.plugin.core.execution.Fail if any target still reads YES, still has no statistics or carries a LAST_ANALYZED older than the clock read before the pass.
  7. errors posts to Slack if the flow fails for any reason.

What you get

  • A detector that catches the table with no statistics at all, not only the one flagged YES, because STALE_STATS is null for the first case.
  • A flush before the read, so the staleness flag can never be read off a counter that has not been written down yet.
  • The cost of the fix in the report: rows, MB plus DML volume per table, totalled for the pass, before anything is enabled.
  • A per run cap that turns a hundred stale tables into a queue instead of one huge pass, with the leftovers named in the log.
  • Locked statistics plus oversized tables reported rather than forced.
  • A re-read that fails the run when a gather did not land, keyed on the database clock so a table that was already current cannot be counted as a success.
  • The rollback window for the gather, read live off the instance.
  • Slack alerts on the findings and on any failure.

Who it's for

  • Teams whose ETL finishes with a bulk load plus no gather, where the next morning's reports run on yesterday's row counts.
  • DBAs on an instance where AUTOTASK was turned off or where the maintenance window is too short to reach every table, so the automatic job never gets to the big ones.
  • Anyone chasing a query that got slow with no code change plus no data problem, where the plan flipped under them.
  • Platform teams who want the statistics policy written down as a schedule plus a cap rather than as a DBMS_SCHEDULER job nobody reads.

Why orchestrate this with Kestra

Oracle's own auto optimizer stats collection task is the usual answer. It is a good one until the maintenance window is too short, the task is disabled or a load finishes at 09:00 and the window is at 22:00. When it does skip a table, it says so inside the database, in a view nobody opens. Kestra puts the check outside: it reads DBA_TAB_STATISTICS on a schedule, posts what is stale to Slack with the cost of fixing it, gathers a bounded slice when you arm it, then proves the flag cleared by reading the dictionary again plus failing the run if it did not. Every run keeps its logs, so you can show which table was stale, by how much, plus what the gather changed.

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. Schema APP held three tables: ORDERS with 2000 rows and statistics gathered, CUSTOMERS with 500 rows and statistics gathered, AUDIT_LOG with 300 rows and never analyzed at all. Then 600 rows went into ORDERS only, which is 30 percent of it, past the default STALE_PERCENT of 10.

sqlplus confirmed the state before every run. After the inserts, ORDERS read STALE_STATS = YES with NUM_ROWS still 2000 against a real 2600, CUSTOMERS read STALE_STATS = NO with NUM_ROWS 500 and LAST_ANALYZED untouched, AUDIT_LOG had a null STALE_STATS plus a null LAST_ANALYZED. The detector returned exactly ORDERS plus AUDIT_LOG, never CUSTOMERS, which is the precision check.

The payoff was measured, not assumed. EXPLAIN PLAN FOR SELECT * FROM app.orders gave a cardinality of 2000 rows at 44000 bytes while the table held 2600. After the flow gathered, the same statement gave 2600 rows at 57200 bytes. The number the optimizer plans from moved to the truth. sqlplus read NUM_ROWS 2600 with SAMPLE_SIZE 2600 and a LAST_ANALYZED matching the clock the flow took before the pass.

Run Setup and inputs Result
1 ORDERS stale, AUDIT_LOG unanalyzed, defaults 2 findings, posted to Slack, no change, SUCCESS
2 same, auto_remediate: true, dry_run: true Same report, Slack said no change applied, remediate_gate never opened. The dry run gate held
3 same, include_never_analyzed: false 1 finding, ORDERS only, SUCCESS
4 same, max_tables_per_run: 1, armed AUDIT_LOG gathered from no statistics to 300 rows, ORDERS reported DEFERRED and left at YES, SUCCESS
5 ORDERS stale, max_table_mb: 0.01, armed Reported over the ceiling, 0 gather targets, nothing gathered, SUCCESS
6 statistics locked on ORDERS, armed Reported with stattype_locked ALL, 0 gather targets. A direct gather raised ORA-20005, SUCCESS
7 ORDERS stale, armed, defaults Gathered 2000 to 2600 rows, reverify found 0, SUCCESS
8 every table current, defaults 0 findings, clean branch, no alert, SUCCESS
9 a fourth table stale with its tablespace offline, armed Gather failed with ORA-00376, reverify reported gather did not run, run FAILED, errors block alerted Slack
10 unknown service in jdbc_url, defaults ORA-12514 on the first query, run FAILED, errors block alerted Slack

Runs 1 and 8 are the proof the check can fail: the same query returned exactly 2 rows against a drifted schema plus 0 rows once everything was current. Run 4 proves the cap leaves work for the next pass instead of dropping it, confirmed by sqlplus showing ORDERS still at YES with NUM_ROWS 2000 after that run. Run 9 is the re-verify gate doing its job: the statistics were still stale, so the run failed rather than reporting a fix that did not happen. Every STALE_STATS, LAST_ANALYZED plus NUM_ROWS value was read back with sqlplus, not only from the Kestra log.

One lever that did not work, measured rather than assumed. Setting STALE_PERCENT to 0 with DBMS_STATS.SET_TABLE_PREFS does not keep a table flagged stale after a gather, so it is not a way to force the re-verify failure. The gather resets the DML counters and the flag reads NO again.

Prerequisites

  • An Oracle Database reachable over the thin JDBC driver (jdbc:oracle:thin:@//host:1521/service).
  • A database user that can read dba_tab_statistics, dba_tables, dba_segments plus dba_tab_modifications (SELECT_CATALOG_ROLE or SELECT ANY DICTIONARY).
  • ANALYZE ANY to gather statistics on another schema's table, plus to call DBMS_STATS.FLUSH_DATABASE_MONITORING_INFO. A user working only on its own tables needs no extra privilege.
  • STATISTICS_LEVEL at TYPICAL or ALL, which is the default. Table monitoring is what populates STALE_STATS.

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
table_owners [APP] Schemas whose table statistics are checked
include_never_analyzed true Treat a table with no statistics as a finding
max_tables_per_run 5 Per run cap, the rest are reported as DEFERRED
max_table_mb 2048.0 Tables larger than this are reported, never gathered
estimate_percent AUTO Sample size, AUTO passes DBMS_STATS.AUTO_SAMPLE_SIZE
degree 1 Parallel degree, AUTO passes DBMS_STATS.AUTO_DEGREE
auto_remediate false Allow DBMS_STATS.GATHER_TABLE_STATS
dry_run true Must be false for any gather 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 table_owners.
  3. Run it with the defaults and read which tables are stale, how big they are plus how much DML they took since their last gather.
  4. Set max_tables_per_run and max_table_mb to what your instance can absorb in one pass.
  5. When you want it to fix things, run with auto_remediate: true plus dry_run: false.
  6. Enable the daily trigger, which always runs in safe mode.

How to extend

  • Call DBMS_STATS.SET_TABLE_PREFS to lower STALE_PERCENT on a table whose plans are sensitive to small changes, so it is flagged before it drifts 10 percent.
  • Add a DBMS_STATS.EXPORT_TABLE_STATS call into your own stats table before the gather, for a copy that outlives the automatic retention window.
  • Gate the run on DBA_TAB_MODIFICATIONS volume rather than the staleness flag, for a policy expressed in rows written instead of a percentage.
  • Capture EXPLAIN PLAN cardinality for a few known queries before plus after the gather, so the run records the plan change it caused.
  • Point the audit at DBA_IND_STATISTICS as well, for indexes whose statistics are stale while the table's are current.
  • Do not add an age only trigger. Oracle's staleness flag already accounts for change, so gathering a table nothing has touched spends IO for an identical result.
  • Loop over several instances to check every database from one flow.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.