| // Licensed to the Apache Software Foundation (ASF) under one | |
| // or more contributor license agreements. See the NOTICE file | |
| // distributed with this work for additional information | |
| // regarding copyright ownership. The ASF licenses this file | |
| // to you 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 com.cloud.api.query.dao; | |
| import java.sql.Connection; | |
| import java.sql.DriverManager; | |
| import java.sql.ResultSet; | |
| import java.sql.ResultSetMetaData; | |
| import java.sql.Statement; | |
| import java.util.LinkedHashMap; | |
| import java.util.Map; | |
| import java.util.Set; | |
| import java.util.TreeSet; | |
| import org.junit.After; | |
| import org.junit.Assume; | |
| import org.junit.Before; | |
| import org.junit.Ignore; | |
| import org.junit.Test; | |
| import static org.junit.Assert.assertEquals; | |
| import static org.junit.Assert.assertFalse; | |
| import static org.junit.Assert.assertNotNull; | |
| import static org.junit.Assert.assertTrue; | |
| /** | |
| * Integration test that EXPLAINs the Phase-1 bypass SQL versus the legacy | |
| * template_view SQL, and asserts that the bypass plan no longer trips the | |
| * "Range checked for each record" path on data_center. | |
| * | |
| * Skipped unless a MySQL URL is provided via system properties: | |
| * -Dtest.cloudstack.mysql.url=jdbc:mysql://host:3306/cloud | |
| * -Dtest.cloudstack.mysql.user=root | |
| * -Dtest.cloudstack.mysql.password= | |
| * | |
| */ | |
| public class TemplateJoinDaoImplBypassExplainTest { | |
| private static final String BYPASS_SQL = | |
| "SELECT DISTINCT CONCAT(vt.id, '_', IFNULL(dc.id, 0)) AS pair " + | |
| "FROM cloud.vm_template vt " + | |
| "JOIN cloud.account a ON a.id = vt.account_id " + | |
| "LEFT JOIN cloud.template_store_ref tsr " + | |
| " ON tsr.template_id = vt.id AND tsr.store_role = 'Image' AND tsr.destroyed = 0 " + | |
| "LEFT JOIN cloud.image_store img " + | |
| " ON img.id = tsr.store_id AND img.removed IS NULL " + | |
| "LEFT JOIN cloud.template_zone_ref tzr " + | |
| " ON tzr.template_id = vt.id AND tsr.store_id IS NULL AND tzr.removed IS NULL " + | |
| "LEFT JOIN cloud.data_center dc " + | |
| " ON dc.id = COALESCE(img.data_center_id, tzr.zone_id) " + | |
| "WHERE vt.removed IS NULL " + | |
| " AND vt.state IN ('Active','UploadAbandoned','UploadError','NotUploaded','UploadInProgress')"; | |
| /** | |
| * Bypass SQL with the conditional `JOIN cloud.domain` added. | |
| * Triggered when the request needs domain.path scoping (DA self/selfexecutable | |
| * or non-admin all + SkipProjectResources). Should still use eq_ref / PRIMARY | |
| * on data_center; domain itself is small. | |
| */ | |
| private static final String BYPASS_SQL_WITH_DOMAIN = | |
| "SELECT DISTINCT CONCAT(vt.id, '_', IFNULL(dc.id, 0)) AS pair " + | |
| "FROM cloud.vm_template vt " + | |
| "JOIN cloud.account a ON a.id = vt.account_id " + | |
| "LEFT JOIN cloud.template_store_ref tsr " + | |
| " ON tsr.template_id = vt.id AND tsr.store_role = 'Image' AND tsr.destroyed = 0 " + | |
| "LEFT JOIN cloud.image_store img " + | |
| " ON img.id = tsr.store_id AND img.removed IS NULL " + | |
| "LEFT JOIN cloud.template_zone_ref tzr " + | |
| " ON tzr.template_id = vt.id AND tsr.store_id IS NULL AND tzr.removed IS NULL " + | |
| "LEFT JOIN cloud.data_center dc " + | |
| " ON dc.id = COALESCE(img.data_center_id, tzr.zone_id) " + | |
| "JOIN cloud.domain d ON d.id = a.domain_id " + | |
| "WHERE vt.removed IS NULL " + | |
| " AND vt.state IN ('Active','UploadAbandoned','UploadError','NotUploaded','UploadInProgress') " + | |
| " AND d.path LIKE '/%' "; | |
| private static final String VIEW_SQL = | |
| "SELECT DISTINCT temp_zone_pair FROM cloud.template_view " + | |
| "WHERE template_state IN ('Active','UploadAbandoned','UploadError','NotUploaded','UploadInProgress')"; | |
| private Connection conn; | |
| @Before | |
| public void setUp() throws Exception { | |
| String url = System.getProperty("test.cloudstack.mysql.url"); | |
| String user = System.getProperty("test.cloudstack.mysql.user", "root"); | |
| String pwd = System.getProperty("test.cloudstack.mysql.password", ""); | |
| Assume.assumeNotNull("test.cloudstack.mysql.url not set; skipping EXPLAIN integration test", url); | |
| conn = DriverManager.getConnection(url, user, pwd); | |
| } | |
| @After | |
| public void tearDown() throws Exception { | |
| if (conn != null) { | |
| conn.close(); | |
| } | |
| } | |
| @Test | |
| @Ignore("Integration test - requires -Dtest.cloudstack.mysql.url; run manually against a CloudStack DB") | |
| public void bypass_dataCenter_isPkLookup_notRangeChecked() throws Exception { | |
| Map<String, Map<String, String>> rowsByTable = explain(BYPASS_SQL); | |
| Map<String, String> dc = rowsByTable.get("dc"); | |
| assertNotNull("EXPLAIN row for `dc` (data_center) missing", dc); | |
| String type = dc.get("type"); | |
| String key = dc.get("key"); | |
| String extra = dc.get("Extra") == null ? "" : dc.get("Extra"); | |
| // dc.id is the PK; COALESCE on the other side. Optimizer should pick eq_ref / ref / const. | |
| assertTrue("dc access type is " + type + ", expected eq_ref/ref/const", | |
| "eq_ref".equals(type) || "ref".equals(type) || "const".equals(type)); | |
| assertEquals("dc should use PRIMARY", "PRIMARY", key); | |
| assertFalse("dc should not use 'Range checked for each record'; Extra=" + extra, | |
| extra.contains("Range checked for each record")); | |
| } | |
| /** | |
| * Asserts the bypass plan trims the join graph the legacy view drags along. | |
| * | |
| * Portable across CIB and prod | |
| * | |
| */ | |
| @Test | |
| @Ignore("Integration test - requires -Dtest.cloudstack.mysql.url; run manually against a CloudStack DB") | |
| public void view_joinsMoreTablesThanBypass() throws Exception { | |
| Map<String, Map<String, String>> view = explain(VIEW_SQL); | |
| Map<String, Map<String, String>> bypass = explain(BYPASS_SQL); | |
| assertTrue( | |
| "expected legacy view plan (" + view.size() + " tables) to join more than bypass (" + bypass.size() + ")", | |
| view.size() > bypass.size()); | |
| // Row-multiplier tables that the bypass intentionally drops. | |
| assertTrue("expected view plan to include vm_template_details", | |
| view.containsKey("vm_template_details")); | |
| assertTrue("expected view plan to include resource_tags", | |
| view.containsKey("resource_tags")); | |
| } | |
| @Test | |
| @Ignore("Integration test - requires -Dtest.cloudstack.mysql.url; run manually against a CloudStack DB") | |
| public void bypass_doesNotJoin_resourceTags_or_vmTemplateDetails() throws Exception { | |
| Map<String, Map<String, String>> rowsByTable = explain(BYPASS_SQL); | |
| assertFalse("bypass must not join resource_tags", rowsByTable.containsKey("resource_tags")); | |
| assertFalse("bypass must not join vm_template_details", rowsByTable.containsKey("vm_template_details")); | |
| assertFalse("bypass must not join launch_permission", rowsByTable.containsKey("launch_permission")); | |
| } | |
| @Test | |
| @Ignore("Integration test - requires -Dtest.cloudstack.mysql.url; run manually against a CloudStack DB") | |
| public void bypassWithDomain_joinsDomainTable() throws Exception { | |
| Map<String, Map<String, String>> rowsByTable = explain(BYPASS_SQL_WITH_DOMAIN); | |
| assertTrue("bypass+domain plan must include domain table", rowsByTable.containsKey("d")); | |
| } | |
| @Test | |
| @Ignore("Integration test - requires -Dtest.cloudstack.mysql.url; run manually against a CloudStack DB") | |
| public void bypassWithDomain_domain_isPkLookup() throws Exception { | |
| // domain.id is the PK; the join is on a.domain_id. Optimizer should pick eq_ref / ref / const. | |
| Map<String, Map<String, String>> rowsByTable = explain(BYPASS_SQL_WITH_DOMAIN); | |
| Map<String, String> d = rowsByTable.get("d"); | |
| assertNotNull("EXPLAIN row for `d` (domain) missing", d); | |
| String type = d.get("type"); | |
| String key = d.get("key"); | |
| String extra = d.get("Extra") == null ? "" : d.get("Extra"); | |
| assertTrue("domain access type is " + type + ", expected eq_ref/ref/const/index", | |
| "eq_ref".equals(type) || "ref".equals(type) || "const".equals(type) || "index".equals(type)); | |
| assertNotNull("domain should use SOME index", key); | |
| assertFalse("domain should not be 'Range checked for each record'; Extra=" + extra, | |
| extra.contains("Range checked for each record")); | |
| } | |
| @Test | |
| @Ignore("Integration test - requires -Dtest.cloudstack.mysql.url; run manually against a CloudStack DB") | |
| public void bypassWithDomain_dataCenter_stillPkLookup() throws Exception { | |
| // Adding the domain join must not regress the data_center PK-lookup gain. | |
| Map<String, Map<String, String>> rowsByTable = explain(BYPASS_SQL_WITH_DOMAIN); | |
| Map<String, String> dc = rowsByTable.get("dc"); | |
| assertNotNull("EXPLAIN row for `dc` missing in bypass+domain plan", dc); | |
| String type = dc.get("type"); | |
| assertTrue("dc access type with domain join is " + type + ", expected eq_ref/ref/const", | |
| "eq_ref".equals(type) || "ref".equals(type) || "const".equals(type)); | |
| assertEquals("PRIMARY", dc.get("key")); | |
| } | |
| @Test | |
| @Ignore("Integration test - requires -Dtest.cloudstack.mysql.url; run manually against a CloudStack DB") | |
| public void bypassWithDomain_stillFewerTablesThanView() throws Exception { | |
| // Even with the conditional domain join, the bypass plan is still smaller than the legacy view. | |
| Map<String, Map<String, String>> bypass = explain(BYPASS_SQL_WITH_DOMAIN); | |
| Map<String, Map<String, String>> view = explain(VIEW_SQL); | |
| assertTrue( | |
| "bypass+domain plan (" + bypass.size() + " tables) should still join fewer than the view (" + view.size() + ")", | |
| bypass.size() < view.size()); | |
| } | |
| @Test | |
| @Ignore("Integration test - requires -Dtest.cloudstack.mysql.url; run manually against a CloudStack DB") | |
| public void bypassWithDomain_stillNoRowMultipliers() throws Exception { | |
| // Critical: adding the domain join must not pull in the row multipliers the bypass dropped. | |
| Map<String, Map<String, String>> rowsByTable = explain(BYPASS_SQL_WITH_DOMAIN); | |
| assertFalse("bypass+domain must not join resource_tags", rowsByTable.containsKey("resource_tags")); | |
| assertFalse("bypass+domain must not join vm_template_details", rowsByTable.containsKey("vm_template_details")); | |
| assertFalse("bypass+domain must not join launch_permission", rowsByTable.containsKey("launch_permission")); | |
| } | |
| // ============================================================================ | |
| // Timing tests | |
| // ============================================================================ | |
| /** Number of iterations per query; first 2 are warm-up. */ | |
| private static final int TIMING_ITERATIONS = 5; | |
| /** Skip first N iterations from the average (warm-up). */ | |
| private static final int TIMING_WARMUP = 2; | |
| /** | |
| * Tolerance for the timing assertion. With sparse test data both queries | |
| * complete in ~1 ms, where measurement noise can flip the order; allow the | |
| * bypass to be marginally slower without failing the test. | |
| */ | |
| private static final double TIMING_TOLERANCE_FACTOR = 2.0; | |
| /** Floor below which we don't bother asserting (pure JDBC overhead). */ | |
| private static final long TIMING_NOISE_FLOOR_NS = 5_000_000L; // 5 ms | |
| @Test | |
| @Ignore("Integration test - requires -Dtest.cloudstack.mysql.url; run manually against a CloudStack DB") | |
| public void bypass_isFasterThanLegacyView() throws Exception { | |
| long legacyAvgNs = averageQueryNanos(VIEW_SQL); | |
| long bypassAvgNs = averageQueryNanos(BYPASS_SQL); | |
| System.out.println(String.format( | |
| "Timing avg over %d iterations (warm-up %d): legacy=%.2f ms, bypass=%.2f ms (%.1fx speedup)", | |
| TIMING_ITERATIONS, TIMING_WARMUP, | |
| legacyAvgNs / 1_000_000.0, | |
| bypassAvgNs / 1_000_000.0, | |
| (double) legacyAvgNs / Math.max(bypassAvgNs, 1))); | |
| // Below the noise floor both queries are dominated by JDBC overhead; | |
| // the structural improvement is unmeasurable. Skip the strict comparison. | |
| if (legacyAvgNs < TIMING_NOISE_FLOOR_NS) { | |
| System.out.println("Skipping strict timing assertion: legacy duration below noise floor"); | |
| return; | |
| } | |
| assertTrue( | |
| String.format("bypass (%.2f ms) should be at most %.1fx legacy (%.2f ms)", | |
| bypassAvgNs / 1_000_000.0, TIMING_TOLERANCE_FACTOR, legacyAvgNs / 1_000_000.0), | |
| bypassAvgNs <= (long) (legacyAvgNs * TIMING_TOLERANCE_FACTOR)); | |
| } | |
| @Test | |
| @Ignore("Integration test - requires -Dtest.cloudstack.mysql.url; run manually against a CloudStack DB") | |
| public void bypassWithDomain_isFasterThanLegacyView() throws Exception { | |
| long legacyAvgNs = averageQueryNanos(VIEW_SQL); | |
| long bypassAvgNs = averageQueryNanos(BYPASS_SQL_WITH_DOMAIN); | |
| System.out.println(String.format( | |
| "Timing avg (bypass+domain join): legacy=%.2f ms, bypass+domain=%.2f ms (%.1fx speedup)", | |
| legacyAvgNs / 1_000_000.0, | |
| bypassAvgNs / 1_000_000.0, | |
| (double) legacyAvgNs / Math.max(bypassAvgNs, 1))); | |
| if (legacyAvgNs < TIMING_NOISE_FLOOR_NS) { | |
| System.out.println("Skipping strict timing assertion: legacy duration below noise floor"); | |
| return; | |
| } | |
| assertTrue( | |
| String.format("bypass+domain (%.2f ms) should be at most %.1fx legacy (%.2f ms)", | |
| bypassAvgNs / 1_000_000.0, TIMING_TOLERANCE_FACTOR, legacyAvgNs / 1_000_000.0), | |
| bypassAvgNs <= (long) (legacyAvgNs * TIMING_TOLERANCE_FACTOR)); | |
| } | |
| // ============================================================================ | |
| // Parity tests — bypass and legacy must return identical pair sets and counts | |
| // ============================================================================ | |
| @Test | |
| @Ignore("Integration test - requires -Dtest.cloudstack.mysql.url; run manually against a CloudStack DB") | |
| public void bypass_returnsSameTemplatePairSetAsLegacy() throws Exception { | |
| Set<String> legacy = collectColumn1(VIEW_SQL); | |
| Set<String> bypass = collectColumn1(BYPASS_SQL); | |
| assertEquals("legacy and bypass must return the same number of distinct pairs", | |
| legacy.size(), bypass.size()); | |
| assertEquals("legacy and bypass must return identical pair values", | |
| legacy, bypass); | |
| } | |
| @Test | |
| @Ignore("Integration test - requires -Dtest.cloudstack.mysql.url; run manually against a CloudStack DB") | |
| public void bypass_distinctCountMatchesLegacy() throws Exception { | |
| long legacyCount = scalarLong( | |
| "SELECT COUNT(*) FROM (" + VIEW_SQL + ") AS t"); | |
| long bypassCount = scalarLong( | |
| "SELECT COUNT(*) FROM (" + BYPASS_SQL + ") AS t"); | |
| assertEquals("legacy and bypass DISTINCT counts must match", legacyCount, bypassCount); | |
| } | |
| /** Collect column 1 of every row into a sorted set (for set equality assertion). */ | |
| private Set<String> collectColumn1(String query) throws Exception { | |
| Set<String> out = new TreeSet<>(); | |
| try (Statement st = conn.createStatement(); | |
| ResultSet rs = st.executeQuery(query)) { | |
| while (rs.next()) { | |
| out.add(rs.getString(1)); | |
| } | |
| } | |
| return out; | |
| } | |
| /** Run a query that returns one numeric scalar in column 1. */ | |
| private long scalarLong(String query) throws Exception { | |
| try (Statement st = conn.createStatement(); | |
| ResultSet rs = st.executeQuery(query)) { | |
| if (rs.next()) { | |
| return rs.getLong(1); | |
| } | |
| return 0L; | |
| } | |
| } | |
| /** | |
| * Run the query {@link #TIMING_ITERATIONS} times, drop the first | |
| * {@link #TIMING_WARMUP} as warm-up, return the average wall-clock duration | |
| * of the remaining iterations in nanoseconds. Each iteration drains the | |
| * ResultSet so we measure execution + transport, not just submission. | |
| */ | |
| private long averageQueryNanos(String query) throws Exception { | |
| long total = 0; | |
| int counted = 0; | |
| for (int i = 0; i < TIMING_ITERATIONS; i++) { | |
| long start = System.nanoTime(); | |
| try (Statement st = conn.createStatement(); | |
| ResultSet rs = st.executeQuery(query)) { | |
| while (rs.next()) { | |
| rs.getString(1); | |
| } | |
| } | |
| long elapsed = System.nanoTime() - start; | |
| if (i >= TIMING_WARMUP) { | |
| total += elapsed; | |
| counted++; | |
| } | |
| } | |
| return counted == 0 ? 0 : total / counted; | |
| } | |
| /** | |
| * Run EXPLAIN and return one map per table alias (or table name) → column→value. | |
| * Uses traditional EXPLAIN format so column names match across MySQL versions. | |
| * Also logs the raw EXPLAIN rows so test output makes plan differences visible. | |
| */ | |
| private Map<String, Map<String, String>> explain(String query) throws Exception { | |
| Map<String, Map<String, String>> out = new LinkedHashMap<>(); | |
| try (Statement st = conn.createStatement(); | |
| ResultSet rs = st.executeQuery("EXPLAIN " + query)) { | |
| ResultSetMetaData md = rs.getMetaData(); | |
| int colCount = md.getColumnCount(); | |
| StringBuilder log = new StringBuilder("\nEXPLAIN result for query:\n ").append(query).append('\n'); | |
| // header | |
| for (int i = 1; i <= colCount; i++) { | |
| log.append(md.getColumnLabel(i)).append('\t'); | |
| } | |
| log.append('\n'); | |
| while (rs.next()) { | |
| Map<String, String> row = new LinkedHashMap<>(); | |
| for (int i = 1; i <= colCount; i++) { | |
| String val = rs.getString(i); | |
| row.put(md.getColumnLabel(i), val); | |
| log.append(val == null ? "NULL" : val).append('\t'); | |
| } | |
| log.append('\n'); | |
| String tableKey = row.get("table"); | |
| if (tableKey != null) { | |
| out.put(tableKey, row); | |
| } | |
| } | |
| // uncomment if need verbose EXPLAIN output | |
| // System.out.println(log.toString()); | |
| } | |
| return out; | |
| } | |
| } |