Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/*
* Copyright 2026 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.openmetadata.it.tests;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.jdbi.v3.core.Jdbi;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
import org.openmetadata.it.bootstrap.TestSuiteBootstrap;
import org.openmetadata.service.jdbi3.locator.ConnectionType;
import org.openmetadata.service.util.dbtune.AutoTuner;
import org.openmetadata.service.util.dbtune.DbTuneResult;
import org.openmetadata.service.util.dbtune.MysqlAutoTuner;
import org.openmetadata.service.util.dbtune.PostgresAutoTuner;
import org.openmetadata.service.util.dbtune.TableRecommendation;

/**
* End-to-end tests for {@link AutoTuner} against the live Testcontainers database. The bootstrap
* runs every migration up to the current version, so the tracked entity tables (e.g.
* {@code storage_container_entity}) exist; we exercise the analyze → apply → analyze-one path
* against the real schema and reset the modified reloptions / table options at the end.
*
* <p>Sequential because each test mutates table-level reloptions on shared production tables;
* parallel execution would race between read-stats and apply.
*
* <p>Uses {@code entity_relationship} as the target — its tuning profile has a row-count threshold
* of zero, so the recommendation is actionable on a fresh IT bootstrap (other entity tables are
* gated behind 10k-row thresholds and would {@code SKIP} on an empty database, defeating the apply
* assertion).
*/
Comment on lines +40 to +61
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a719d47: added @execution(ExecutionMode.SAME_THREAD) on the class so the tests don't race on shared reloptions when the suite runs in parallel.

@Execution(ExecutionMode.SAME_THREAD)
class DbTuneIT {

private static final String TEST_TABLE = "entity_relationship";

@Test
void analyzeReturnsRecommendationsForKnownTables() {
AutoTuner tuner = currentTuner();
Jdbi jdbi = TestSuiteBootstrap.getJdbi();

DbTuneResult result = jdbi.withHandle(tuner::analyze);

assertNotNull(result);
assertNotNull(result.engineVersion());
assertFalse(result.tableRecommendations().isEmpty(), "Expected at least one recommendation");
assertTrue(
result.tableRecommendations().stream().anyMatch(r -> TEST_TABLE.equals(r.tableName())),
"storage_container_entity should be in the recommendations");
Comment thread
harshach marked this conversation as resolved.
Outdated
}

@Test
void applyChangesReloptionsAndIsIdempotent() {
AutoTuner tuner = currentTuner();
Jdbi jdbi = TestSuiteBootstrap.getJdbi();
ConnectionType connType = currentConnectionType();
TableRecommendation rec = recommendationFor(tuner, jdbi, TEST_TABLE);

try {
jdbi.useHandle(handle -> tuner.apply(handle, rec));
Map<String, String> after = currentSettingsFor(tuner, jdbi, TEST_TABLE);
assertSettingsMatch(rec.recommendedSettings(), after);

// Apply twice — must be a no-op
jdbi.useHandle(handle -> tuner.apply(handle, rec));
Map<String, String> afterSecond = currentSettingsFor(tuner, jdbi, TEST_TABLE);
assertEquals(after, afterSecond, "Apply should be idempotent");
} finally {
resetTableSettings(jdbi, TEST_TABLE, connType, rec.recommendedSettings().keySet());
}
}

Comment on lines +120 to +129
@Test
void analyzeOneRunsWithoutError() {
AutoTuner tuner = currentTuner();
Jdbi jdbi = TestSuiteBootstrap.getJdbi();

jdbi.useHandle(handle -> tuner.analyzeOne(handle, TEST_TABLE));
}

@Test
void dryRunDoesNotMutateReloptions() {
AutoTuner tuner = currentTuner();
Jdbi jdbi = TestSuiteBootstrap.getJdbi();

Map<String, String> before = currentSettingsFor(tuner, jdbi, TEST_TABLE);

DbTuneResult result = jdbi.withHandle(tuner::analyze);
assertNotNull(result);

Map<String, String> after = currentSettingsFor(tuner, jdbi, TEST_TABLE);
assertEquals(before, after, "Analyze (dry-run) must not change table settings");
}

// ---- helpers ----

private AutoTuner currentTuner() {
return currentConnectionType() == ConnectionType.POSTGRES
? new PostgresAutoTuner()
: new MysqlAutoTuner();
}

private ConnectionType currentConnectionType() {
return "mysql".equalsIgnoreCase(System.getProperty("databaseType", "postgres"))
? ConnectionType.MYSQL
: ConnectionType.POSTGRES;
}

private TableRecommendation recommendationFor(
final AutoTuner tuner, final Jdbi jdbi, final String tableName) {
return jdbi.withHandle(tuner::analyze).tableRecommendations().stream()
.filter(r -> tableName.equals(r.tableName()))
.findFirst()
.orElseThrow(() -> new IllegalStateException("No recommendation for " + tableName));
}

/**
* Re-runs analyze and projects out the {@link TableRecommendation#currentSettings()} for the
* named table. Going through the same code path that built the original recommendation keeps the
* assertion stable across either dialect's parsing rules.
*/
private Map<String, String> currentSettingsFor(
final AutoTuner tuner, final Jdbi jdbi, final String tableName) {
return jdbi.withHandle(tuner::analyze).tableRecommendations().stream()
.filter(r -> tableName.equals(r.tableName()))
.findFirst()
.map(TableRecommendation::currentSettings)
.orElse(Map.of());
}

private void assertSettingsMatch(
final Map<String, String> expected, final Map<String, String> actual) {
for (Map.Entry<String, String> e : expected.entrySet()) {
String got = actual.get(e.getKey());
assertNotNull(got, "Missing setting after apply: " + e.getKey());
assertEquals(
Double.parseDouble(e.getValue()),
Double.parseDouble(got),
0.0,
"Setting "
+ e.getKey()
+ " did not take effect (expected "
+ e.getValue()
+ ", got "
+ got
+ ")");
}
}

private void resetTableSettings(
final Jdbi jdbi,
final String tableName,
final ConnectionType connType,
final Set<String> keys) {
if (keys.isEmpty()) {
return;
}
jdbi.useHandle(
handle -> {
if (connType == ConnectionType.POSTGRES) {
String resetList = String.join(", ", keys);
handle.execute("ALTER TABLE \"" + tableName + "\" RESET (" + resetList + ")");
} else {
String resetList =
keys.stream().map(k -> k + "=DEFAULT").collect(Collectors.joining(", "));
handle.execute("ALTER TABLE `" + tableName + "` " + resetList);
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@
import org.openmetadata.service.secrets.SecretsManagerUpdateService;
import org.openmetadata.service.security.auth.SecurityConfigurationManager;
import org.openmetadata.service.security.jwt.JWTTokenGenerator;
import org.openmetadata.service.util.dbtune.AutoTuner;
import org.openmetadata.service.util.dbtune.DbTuneReport;
import org.openmetadata.service.util.dbtune.DbTuneResult;
import org.openmetadata.service.util.dbtune.MysqlAutoTuner;
import org.openmetadata.service.util.dbtune.PostgresAutoTuner;
import org.openmetadata.service.util.dbtune.TableRecommendation;
import org.openmetadata.service.util.jdbi.DatabaseAuthenticationProviderFactory;
import org.openmetadata.service.util.jdbi.JdbiUtils;
import org.slf4j.LoggerFactory;
Expand Down Expand Up @@ -175,9 +181,12 @@ public Integer call() {
+ "'drop-create', 'changelog', 'migrate', 'migrate-secrets', 'reindex', 'reembed', 'reindex-rdf', 'reindexdi', 'deploy-pipelines', "
+ "'dbServiceCleanup', 'relationshipCleanup', 'tagUsageCleanup', 'drop-indexes', 'remove-security-config', 'create-indexes', "
+ "'setOpenMetadataUrl', 'configureEmailSettings', 'get-security-config', 'update-security-config', 'install-app', 'delete-app', 'create-user', 'reset-password', "
+ "'syncAlertOffset', 'analyze-tables', 'cleanup-flowable-history', 'regenerate-bot-tokens'");
+ "'syncAlertOffset', 'analyze-tables', 'db-tune', 'cleanup-flowable-history', 'regenerate-bot-tokens'");
LOG.info(
"Use 'reindex --auto-tune' for automatic performance optimization based on cluster capabilities");
LOG.info(
"Use 'db-tune' for a per-table autovacuum / InnoDB stats tuning report; add --apply to "
+ "execute the recommendations and --analyze to refresh planner stats on changed tables");
LOG.info(
"Use 'cleanup-flowable-history --delete --runtime-batch-size=1000 --history-batch-size=1000' for Flowable cleanup with custom options");
LOG.info(
Expand Down Expand Up @@ -2469,6 +2478,107 @@ public Integer analyzeTables() {
}
}

@Command(
name = "db-tune",
description =
"Generate a per-table autovacuum / InnoDB stats tuning report and optionally apply it. "
+ "Default mode is read-only — pass --apply to execute the ALTER TABLE statements "
+ "and --analyze to refresh planner stats on changed tables.")
public Integer dbTune(
Comment on lines +2486 to +2493
@Option(
names = {"--apply"},
defaultValue = "false",
description =
"Apply the recommendations. Without this flag the command only prints the report.")
boolean apply,
@Option(
names = {"--yes", "-y"},
defaultValue = "false",
description = "Skip the interactive confirmation when applying.")
boolean skipPrompt,
@Option(
names = {"--analyze"},
defaultValue = "false",
description =
"After --apply, run ANALYZE on each changed table so planner stats reflect the new settings.")
boolean runAnalyze) {
try {
parseConfig();
String driverClass = config.getDataSourceFactory().getDriverClass();
ConnectionType connType = ConnectionType.from(driverClass);
if (connType == null) {
LOG.error(
"db-tune does not support driver class '{}'. Only the bundled MySQL and PostgreSQL drivers are recognised.",
driverClass);
return 1;
}
AutoTuner tuner = autoTunerFor(connType);
DbTuneResult result = jdbi.withHandle(tuner::analyze);
LOG.info("\n{}", DbTuneReport.render(result));
if (!apply) {
return 0;
}
List<TableRecommendation> actionable = result.actionableRecommendations();
if (actionable.isEmpty()) {
LOG.info(
"Nothing to apply — every tracked table already matches its recommended settings.");
Comment thread
harshach marked this conversation as resolved.
Outdated
return 0;
}
if (!skipPrompt && !confirmApply(tuner, actionable)) {
LOG.info("Operation cancelled.");
return 0;
}
applyRecommendations(tuner, actionable, runAnalyze);
return 0;
} catch (Exception e) {
LOG.error("db-tune failed due to ", e);
return 1;
}
}

private AutoTuner autoTunerFor(final ConnectionType connType) {
return switch (connType) {
case POSTGRES -> new PostgresAutoTuner();
case MYSQL -> new MysqlAutoTuner();
};
}

private boolean confirmApply(final AutoTuner tuner, final List<TableRecommendation> actionable) {
LOG.info("About to apply {} ALTER statements:", actionable.size());
LOG.info("\n{}", DbTuneReport.renderAlterStatements(tuner, actionable));
@SuppressWarnings("resource")
Scanner scanner = new Scanner(System.in);
LOG.info("Apply now? [y/N]: ");
String input = scanner.hasNext() ? scanner.next().trim().toLowerCase() : "";
return input.equals("y") || input.equals("yes");
}

private void applyRecommendations(
final AutoTuner tuner, final List<TableRecommendation> actionable, final boolean runAnalyze) {
List<List<String>> rows = new ArrayList<>();
for (TableRecommendation rec : actionable) {
rows.add(applyOne(tuner, rec, runAnalyze));
}
printToAsciiTable(
List.of("Table", "Action", "Status", "Details"), rows, "No recommendations applied");
}

private List<String> applyOne(
final AutoTuner tuner, final TableRecommendation rec, final boolean runAnalyze) {
try {
jdbi.useHandle(handle -> tuner.apply(handle, rec));
if (runAnalyze) {
jdbi.useHandle(handle -> tuner.analyzeOne(handle, rec.tableName()));
return List.of(rec.tableName(), rec.action().name(), "OK", "Applied + analyzed");
}
return List.of(rec.tableName(), rec.action().name(), "OK", "Applied");
} catch (Exception e) {
LOG.error("Failed to apply recommendation for {}: {}", rec.tableName(), e.getMessage(), e);
String detail = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
return List.of(rec.tableName(), rec.action().name(), "FAILED", detail);
}
}

/**
* Unlike most ops commands (e.g. deploy-pipelines) that delegate to the server API, this command
* operates directly on the database. This is intentional: when JWT signing keys have been rotated,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Copyright 2026 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.openmetadata.service.util.dbtune;

public enum Action {
APPLY,
TIGHTEN,
RELAX,
OK,
SKIP;

public boolean isActionable() {
return this == APPLY || this == TIGHTEN || this == RELAX;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* Copyright 2026 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.openmetadata.service.util.dbtune;

import org.jdbi.v3.core.Handle;

/**
* Engine-specific auto-tuner. Implementations:
*
* <ul>
* <li>Read observed table stats and current parameter-group settings from the database.
* <li>Compute a recommended table-level reloption set per table (pure logic — see
* {@link #recommend(TableStats)}).
* <li>Apply the recommendations via {@code ALTER TABLE ... SET (...)} when the operator opts in.
* <li>Optionally refresh planner stats on tables that were changed.
* </ul>
*/
public interface AutoTuner {

/** Read stats + settings, then turn them into recommendations. Mixes I/O and pure logic. */
DbTuneResult analyze(Handle handle);

/**
* Pure decision function. Given observed table stats, return the recommendation. Exposed
* separately so unit tests can assert the heuristic without hitting a database.
*/
TableRecommendation recommend(TableStats stats);

/**
* Apply a single actionable recommendation. No-op for non-actionable actions. Idempotent — safe
* to re-run.
*/
void apply(Handle handle, TableRecommendation recommendation);

/** Refresh planner stats for one table after a settings change. */
void analyzeOne(Handle handle, String tableName);

/** Build the {@code ALTER TABLE} statement for a recommendation. Engine-specific syntax. */
String buildAlterStatement(TableRecommendation recommendation);
}
Loading
Loading