Copilot commented on code in PR #2592:
URL: https://github.com/apache/phoenix/pull/2592#discussion_r3789332734
##########
phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/tasks/TransformMonitorTask.java:
##########
@@ -110,31 +159,136 @@ public TaskRegionObserver.TaskResult run(Task.TaskRecord
taskRecord) {
PTable.TransformType partialTransform =
PTable.TransformType.getPartialTransform(systemTransformRecord.getTransformType());
if (partialTransform != null) {
- // Update transform to be partial
- SystemTransformRecord.SystemTransformBuilder builder =
- new
SystemTransformRecord.SystemTransformBuilder(systemTransformRecord);
- builder.setTransformType(partialTransform);
- // Decrement retry count since TransformTool will increment it.
Should we set it to 0?
-
builder.setTransformRetryCount(systemTransformRecord.getTransformRetryCount() -
1);
- Transform.upsertTransform(builder.build(), conn);
-
- // Fix unverified rows. Running partial transform will make the
transform status go back
- // to started
- long startFromTs = 0;
- if (systemTransformRecord.getTransformLastStateTs() != null) {
- startFromTs =
systemTransformRecord.getTransformLastStateTs().getTime() - 1;
- }
- TransformTool.runTransformTool(systemTransformRecord, conf, true,
startFromTs, null, true,
- false);
-
- // In the future, if we are changing the PK structure, we need to
run indextools as well
+ // After the pointer swap, wait for clients to refresh their cached
physical-table pointer
+ // before running the partial pass. Persist the earliest time the
wait may end and move to
+ // PENDING_PARTIAL_PASS; the partial pass is NOT kicked here.
+ long waitUntilTs = EnvironmentEdgeManager.currentTimeMillis()
+ + computePartialPassWaitMs(conn, systemTransformRecord);
+ Transform.updateTransformRecord(conn, systemTransformRecord,
+ PTable.TransformStatus.PENDING_PARTIAL_PASS, waitUntilTs);
} else {
// No partial transform needed so, we update state of the transform
LOGGER.warn("No partial type of the transform is found. Completing
the transform ",
tableName);
Transform.updateTransformRecord(conn, systemTransformRecord,
PTable.TransformStatus.COMPLETED);
}
+ } else if (
+ systemTransformRecord.getTransformStatus()
+ .equals(PTable.TransformStatus.PENDING_PARTIAL_PASS.name())
+ ) {
+ Long waitUntilTs =
systemTransformRecord.getPendingPartialPassUntilTs();
+ if (waitUntilTs != null && EnvironmentEdgeManager.currentTimeMillis()
< waitUntilTs) {
+ // Still inside the client cache-refresh window; re-poll on the next
scan.
+ LOGGER.info(
+ "Transform is pending partial pass, still waiting for cache
refresh window {}",
+ tableName);
+ } else {
+ LOGGER.info("Transform wait window elapsed, starting the partial
pass {}", tableName);
+ // Make the PARTIAL_PASS_RUNNING transition authoritative and
committed BEFORE launching
+ // the partial pass. Committing here (rather than relying on the
monitor's ServerTask
+ // commit at the end of run()) means a later monitor scan can observe
+ // PARTIAL_PASS_RUNNING,
+ // and the partial pass's own reducer-committed COMPLETED (written
on a separate
+ // connection when the async MR job ends) is never clobbered by a
stale buffered upsert.
+ //
+ // Clear the inherited job id on this transition. The record still
carries the full-pass
+ // job id (the PENDING_PARTIAL_PASS record inherited it and neither
the cutover transition
+ // nor the builder copy ctor clears it). That old job id points at
the already-completed,
+ // successful full pass. If a monitor scan observes the record while
still
+ // PARTIAL_PASS_RUNNING before the kicked partial pass has
registered its own job id, the
+ // PARTIAL_PASS_RUNNING branch below would look up that stale job,
see it successful, and
+ // drive the record straight to COMPLETED -- skipping the partial
pass that repairs
+ // unverified rows. Nulling it here makes the `if (jobId != null)`
guard correctly no-op
+ // until the partial pass has registered its own job id.
+ updateTransformRecordClearingJobId(conn, systemTransformRecord,
+ PTable.TransformStatus.PARTIAL_PASS_RUNNING, waitUntilTs);
+ conn.commit();
Review Comment:
This committed transition is not restart-safe because it clears the job id
but leaves the full transform type unchanged. If the process stops after
`conn.commit()` and before `kickPartialPass`, subsequent scans enter the
full-type `PARTIAL_PASS_RUNNING` branch at lines 284-290 and only log forever;
nothing performs the kick. Persist the partial transform type in this same
transition/upsert so a restart sees partial-type + null job id and routes it
through the retry path.
##########
phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/tasks/TransformMonitorTask.java:
##########
@@ -212,6 +364,158 @@ public TaskRegionObserver.TaskResult run(Task.TaskRecord
taskRecord) {
}
}
+ /**
+ * Updates the transform record to the given status while clearing any
inherited job id. Used on
+ * the PENDING_PARTIAL_PASS -> PARTIAL_PASS_RUNNING transition so the
record does not carry the
+ * completed full-pass job id into the PARTIAL_PASS_RUNNING monitoring
branch. Mirrors
+ * {@link Transform#updateTransformRecord} (bumps last-state-ts, preserves
the wait timestamp) but
+ * additionally sets the job id to null; the kicked partial pass registers
its own job id when it
+ * launches.
+ */
+ private void updateTransformRecordClearingJobId(PhoenixConnection conn,
+ SystemTransformRecord systemTransformRecord, PTable.TransformStatus
newStatus,
+ Long pendingPartialPassUntilTs) throws SQLException {
+ SystemTransformRecord.SystemTransformBuilder builder =
+ new SystemTransformRecord.SystemTransformBuilder(systemTransformRecord);
+ builder.setTransformStatus(newStatus.name());
+ builder.setTransformJobId(null);
+ builder.setLastStateTs(new
Timestamp(EnvironmentEdgeManager.currentTimeMillis()));
+ builder.setPendingPartialPassUntilTs(pendingPartialPassUntilTs);
+ Transform.upsertTransform(builder.build(), conn);
+ }
+
+ /**
+ * Computes how long to wait after cutover before running the partial pass.
The wait is the
+ * logical (parent) table's update-cache-frequency scaled by a safety
margin, clamped to
+ * [{@link #MIN_PARTIAL_PASS_WAIT_MS}, {@link #MAX_PARTIAL_PASS_WAIT_MS}] so
a small or zero cache
+ * frequency still yields a meaningful wait and a table that never refreshes
its cache (whose
+ * update-cache-frequency resolves to Long.MAX_VALUE) does not produce an
unbounded wait that
+ * would overflow the deadline arithmetic in the caller. The returned value
is always a small,
+ * positive number of milliseconds, so adding it to the current time cannot
overflow.
+ */
+ private long computePartialPassWaitMs(PhoenixConnection conn,
+ SystemTransformRecord systemTransformRecord) {
+ long updateCacheFrequency = 0;
+ try {
+ String logicalTableName =
SchemaUtil.getTableName(systemTransformRecord.getSchemaName(),
+ systemTransformRecord.getLogicalTableName());
+ PTable logicalTable = conn.getTable(systemTransformRecord.getTenantId(),
logicalTableName);
+ updateCacheFrequency = logicalTable.getUpdateCacheFrequency();
Review Comment:
This value is not necessarily the cache lifetime used by clients. For a
table whose stored UCF is the default, Phoenix uses each connection's
`phoenix.default.updateCacheFrequency` instead (`MetaDataClient.java:702-758`),
so a client may cache for hours or `NEVER` while this server connection
computes only the 30-minute floor. That client can continue dual-writing
unverified rows after the one-shot partial pass. The lifecycle needs a
server-enforced refresh bound (or must reject/otherwise handle connection-level
and unbounded caching) rather than deriving the deadline from this server-side
`PTable` alone.
##########
phoenix-core/src/it/java/org/apache/phoenix/end2end/transform/CutoverLifecycleIT.java:
##########
@@ -0,0 +1,938 @@
+/*
+ * 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 org.apache.phoenix.end2end.transform;
+
+import static org.apache.phoenix.query.QueryConstants.UNVERIFIED_BYTES;
+import static org.apache.phoenix.util.TestUtil.TEST_PROPERTIES;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.io.IOException;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.Properties;
+import org.apache.hadoop.hbase.coprocessor.RegionCoprocessorEnvironment;
+import org.apache.hadoop.mapreduce.Job;
+import org.apache.phoenix.coprocessor.TaskRegionObserver;
+import org.apache.phoenix.coprocessor.tasks.TransformMonitorTask;
+import org.apache.phoenix.end2end.ParallelStatsDisabledIT;
+import org.apache.phoenix.end2end.ParallelStatsDisabledTest;
+import org.apache.phoenix.jdbc.PhoenixConnection;
+import org.apache.phoenix.jdbc.PhoenixDatabaseMetaData;
+import org.apache.phoenix.mapreduce.util.PhoenixConfigurationUtil;
+import org.apache.phoenix.query.QueryServices;
+import org.apache.phoenix.query.QueryServicesOptions;
+import org.apache.phoenix.schema.PTable;
+import org.apache.phoenix.schema.task.ServerTask;
+import org.apache.phoenix.schema.task.SystemTaskParams;
+import org.apache.phoenix.schema.task.Task;
+import org.apache.phoenix.schema.transform.SystemTransformRecord;
+import org.apache.phoenix.schema.transform.Transform;
+import org.apache.phoenix.util.EnvironmentEdge;
+import org.apache.phoenix.util.EnvironmentEdgeManager;
+import org.apache.phoenix.util.PropertiesUtil;
+import org.apache.phoenix.util.SchemaUtil;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.mockito.Mockito;
+
+/**
+ * Integration tests for the cutover lifecycle: after the physical-table
pointer swap the transform
+ * monitor waits for clients to refresh their cached pointer (status
PENDING_PARTIAL_PASS) before
+ * running the partial pass (status PARTIAL_PASS_RUNNING) and finally
completing. The wait uses an
+ * injectable clock so the tests never sleep for the real wait window.
+ */
+@Category(ParallelStatsDisabledTest.class)
+public class CutoverLifecycleIT extends ParallelStatsDisabledIT {
+
+ private static RegionCoprocessorEnvironment taskRegionEnvironment;
+
+ private final Properties testProps =
PropertiesUtil.deepCopy(TEST_PROPERTIES);
+
+ public CutoverLifecycleIT() throws IOException, InterruptedException {
+ testProps.put(QueryServices.DEFAULT_IMMUTABLE_STORAGE_SCHEME_ATTRIB,
"ONE_CELL_PER_COLUMN");
+ testProps.put(QueryServices.DEFAULT_COLUMN_ENCODED_BYTES_ATRRIB, "0");
+
+ taskRegionEnvironment = (RegionCoprocessorEnvironment) getUtility()
+
.getRSForFirstRegionInTable(PhoenixDatabaseMetaData.SYSTEM_TASK_HBASE_TABLE_NAME)
+
.getRegions(PhoenixDatabaseMetaData.SYSTEM_TASK_HBASE_TABLE_NAME).get(0).getCoprocessorHost()
+ .findCoprocessorEnvironment(TaskRegionObserver.class.getName());
+ }
+
+ @Before
+ public void setupTest() throws Exception {
+ try (Connection conn = DriverManager.getConnection(getUrl(), testProps)) {
+ conn.setAutoCommit(true);
+ conn.createStatement()
+ .execute("DELETE FROM " +
PhoenixDatabaseMetaData.SYSTEM_TRANSFORM_NAME);
+ conn.createStatement().execute("DELETE FROM " +
PhoenixDatabaseMetaData.SYSTEM_TASK_NAME);
+ }
+ }
+
+ @After
+ public void tearDownTest() {
+ EnvironmentEdgeManager.reset();
+ TransformMonitorTask.resetJobLookupForTesting();
+ }
+
+ /**
+ * Advances the monitor clock so it is comfortably past the persisted wait
deadline, letting the
+ * PENDING_PARTIAL_PASS branch proceed without waiting for the real
30-minute floor.
+ */
+ private static class AdvancingClock extends EnvironmentEdge {
+ private long value = System.currentTimeMillis();
+
+ @Override
+ public long currentTime() {
+ return value;
+ }
+
+ void setValue(long millis) {
+ value = millis;
+ }
+ }
+
+ private void runMonitorOnce() {
+ TaskRegionObserver.SelfHealingTask task = new
TaskRegionObserver.SelfHealingTask(
+ taskRegionEnvironment,
QueryServicesOptions.DEFAULT_TASK_HANDLING_MAX_INTERVAL_MS);
+ task.run();
+ }
+
+ private SystemTransformRecord fetch(PhoenixConnection conn, String
schemaName, String tableName,
+ String parentName) throws SQLException {
+ return Transform.getTransformRecord(schemaName, tableName, parentName,
null, conn);
+ }
+
+ /**
+ * Drives the monitor, advancing the injected clock as needed, until the
transform reaches the
+ * requested status or we run out of attempts.
+ */
+ private SystemTransformRecord driveMonitorToStatus(PhoenixConnection conn,
String schemaName,
+ String tableName, String parentName, PTable.TransformStatus target,
AdvancingClock clock)
+ throws Exception {
+ for (int i = 0; i < 60; i++) {
+ SystemTransformRecord record = fetch(conn, schemaName, tableName,
parentName);
+ if (record != null && target.name().equals(record.getTransformStatus()))
{
+ return record;
+ }
+ // If we are inside the wait window, jump the clock past the deadline so
the monitor advances.
+ if (
+ record != null
+ &&
PTable.TransformStatus.PENDING_PARTIAL_PASS.name().equals(record.getTransformStatus())
+ && record.getPendingPartialPassUntilTs() != null
+ ) {
+ clock.setValue(record.getPendingPartialPassUntilTs() + 1);
+ }
+ runMonitorOnce();
+ Thread.sleep(200);
+ }
+ SystemTransformRecord record = fetch(conn, schemaName, tableName,
parentName);
+ fail("Ran out of attempts waiting for transform status " + target + " but
it was "
+ + (record == null ? "<no record>" : record.getTransformStatus()));
+ return null;
+ }
+
+ private long countLinkRows(Connection conn, String schemaName, String
tableName)
+ throws SQLException {
+ String sql = "SELECT COUNT(*) FROM " +
PhoenixDatabaseMetaData.SYSTEM_CATALOG_NAME + " WHERE "
+ + PhoenixDatabaseMetaData.TABLE_SCHEM + " = ? AND " +
PhoenixDatabaseMetaData.TABLE_NAME
+ + " = ? AND " + PhoenixDatabaseMetaData.LINK_TYPE + " = ?";
+ try (PreparedStatement stmt = conn.prepareStatement(sql)) {
+ stmt.setString(1, schemaName);
+ stmt.setString(2, tableName);
+ stmt.setByte(3,
PTable.LinkType.TRANSFORMING_NEW_TABLE.getSerializedValue());
+ ResultSet rs = stmt.executeQuery();
+ rs.next();
+ return rs.getLong(1);
+ }
+ }
+
+ private long countUnverified(Connection conn, String physicalTableFullName)
throws Exception {
+ return org.apache.phoenix.end2end.index.ImmutableIndexExtendedIT
+ .getRowCountForEmptyColValue(conn, physicalTableFullName,
UNVERIFIED_BYTES);
+ }
+
+ /**
+ * Counts every TRANSFORMING_NEW_TABLE link row that points at the given new
physical table,
+ * regardless of which logical entity (base table or child view) owns the
row. The link's
+ * COLUMN_FAMILY holds the full new physical table name, so this catches
base-table and view links
+ * alike.
+ */
+ private long countLinksToNewPhysicalTable(Connection conn, String
newPhysicalFullName)
+ throws SQLException {
+ String sql = "SELECT COUNT(*) FROM " +
PhoenixDatabaseMetaData.SYSTEM_CATALOG_NAME + " WHERE "
+ + PhoenixDatabaseMetaData.COLUMN_FAMILY + " = ? AND " +
PhoenixDatabaseMetaData.LINK_TYPE
+ + " = ?";
+ try (PreparedStatement stmt = conn.prepareStatement(sql)) {
+ stmt.setString(1, newPhysicalFullName);
+ stmt.setByte(2,
PTable.LinkType.TRANSFORMING_NEW_TABLE.getSerializedValue());
+ ResultSet rs = stmt.executeQuery();
+ rs.next();
+ return rs.getLong(1);
+ }
+ }
+
+ private void runCutoverLifecycle(boolean createIndex, boolean isImmutable)
throws Exception {
+ AdvancingClock clock = new AdvancingClock();
+ EnvironmentEdgeManager.injectEdge(clock);
+
+ String schemaName = generateUniqueName();
+ String dataTableName = "TBL_" + generateUniqueName();
+ String dataTableFullName = SchemaUtil.getTableName(schemaName,
dataTableName);
+ String newTableName = dataTableName + "_1";
+ String indexName = "IDX_" + generateUniqueName();
+ String createIndexStmt = "CREATE INDEX %s ON " + dataTableFullName + "
(NAME) INCLUDE (ZIP) ";
+
+ try (PhoenixConnection conn =
+ (PhoenixConnection) DriverManager.getConnection(getUrl(), testProps)) {
+ conn.setAutoCommit(true);
+ int numOfRows = 10;
+ TransformToolIT.createTableAndUpsertRows(conn, dataTableFullName,
numOfRows,
+ isImmutable ? " IMMUTABLE_ROWS=true" : "");
+ if (createIndex) {
+ conn.createStatement().execute(String.format(createIndexStmt,
indexName));
+ }
+
+ // Kick off the transform. The monitor task is registered as part of the
ALTER.
+ conn.createStatement().execute("ALTER TABLE " + dataTableFullName
+ + " SET IMMUTABLE_STORAGE_SCHEME=SINGLE_CELL_ARRAY_WITH_OFFSETS,
COLUMN_ENCODED_BYTES=2");
+
+ SystemTransformRecord record = fetch(conn, schemaName, dataTableName,
null);
+ assertNotNull(record);
+
+ List<Task.TaskRecord> taskRecordList = Task.queryTaskTable(conn, null);
+ assertEquals(1, taskRecordList.size());
+ assertEquals(PTable.TaskType.TRANSFORM_MONITOR,
taskRecordList.get(0).getTaskType());
+
+ // Drive to PENDING_PARTIAL_PASS: the pointer swap has happened and the
monitor is waiting.
+ SystemTransformRecord pendingPartial = driveMonitorToStatus(conn,
schemaName, dataTableName,
+ null, PTable.TransformStatus.PENDING_PARTIAL_PASS, clock);
+
+ // The wait deadline is set and lies in the future relative to when it
was computed.
+ Long untilTs = pendingPartial.getPendingPartialPassUntilTs();
+ assertNotNull("PENDING_PARTIAL_PASS must persist a wait deadline",
untilTs);
+
+ // The pointer swap is already visible and the dual-write link row is
gone in the same cache
+ // generation (the DELETE was committed together with the pointer swap).
+ PTable swappedTable = conn.getTableNoCache(dataTableFullName);
+ assertEquals(newTableName,
swappedTable.getPhysicalName(true).getString());
+ assertEquals("TRANSFORMING_NEW_TABLE link must be deleted at cutover", 0,
+ countLinkRows(conn, schemaName, dataTableName));
+
+ // While the clock is still before the deadline, the monitor must NOT
advance.
+ clock.setValue(untilTs - 1);
+ runMonitorOnce();
+ Thread.sleep(200);
+ SystemTransformRecord stillWaiting = fetch(conn, schemaName,
dataTableName, null);
+ assertEquals("Monitor must no-op while clock < deadline",
+ PTable.TransformStatus.PENDING_PARTIAL_PASS.name(),
stillWaiting.getTransformStatus());
+
+ // Once the clock reaches the deadline the monitor advances through the
partial pass to
+ // COMPLETED.
+ clock.setValue(untilTs + 1);
+ SystemTransformRecord completed = driveMonitorToStatus(conn, schemaName,
dataTableName, null,
+ PTable.TransformStatus.COMPLETED, clock);
+ assertNotNull(completed);
+
+ // No stranded unverified rows on the new physical table after the
partial pass.
+ assertEquals("No unverified rows should remain after the partial pass",
0,
+ countUnverified(conn, completed.getNewPhysicalTableName()));
+
+ // Sanity: pointer still points at the new physical table.
+ PTable finalTable = conn.getTableNoCache(dataTableFullName);
+ assertEquals(newTableName, finalTable.getPhysicalName(true).getString());
+ }
+ }
+
+ @Test
+ public void testCutoverLifecycleMutableTableWithoutIndex() throws Exception {
+ runCutoverLifecycle(false, false);
+ }
+
+ @Test
+ public void testCutoverLifecycleImmutableTableWithoutIndex() throws
Exception {
+ runCutoverLifecycle(false, true);
+ }
+
+ @Test
+ public void testCutoverLifecycleTableWithSecondaryIndex() throws Exception {
+ runCutoverLifecycle(true, false);
+ }
+
+ /**
+ * Cutover on a base table that HAS a child view must tear down the
dual-write links installed on
+ * both the base table AND the child view. Asserts that after cutover
reaches PENDING_PARTIAL_PASS
+ * (the point at which the pointer swap and link teardown are committed) no
TRANSFORMING_NEW_TABLE
+ * link rows pointing at the new physical table remain, for the base table
or the view.
+ */
+ @Test
+ public void testCutoverTearsDownViewDualWriteLinks() throws Exception {
+ AdvancingClock clock = new AdvancingClock();
+ EnvironmentEdgeManager.injectEdge(clock);
+
+ String schemaName = generateUniqueName();
+ String dataTableName = "TBL_" + generateUniqueName();
+ String dataTableFullName = SchemaUtil.getTableName(schemaName,
dataTableName);
+ String newTableName = dataTableName + "_1";
+ String newTableFullName = SchemaUtil.getTableName(schemaName,
newTableName);
+ String viewName = "VW_" + generateUniqueName();
+ String viewFullName = SchemaUtil.getTableName(schemaName, viewName);
+
+ try (PhoenixConnection conn =
+ (PhoenixConnection) DriverManager.getConnection(getUrl(), testProps)) {
+ conn.setAutoCommit(true);
+ TransformToolIT.createTableAndUpsertRows(conn, dataTableFullName, 10,
"");
+ // Create a child view over the base table before starting the
transform, so the transform
+ // installs a dual-write link on the view as well as the base table.
+ conn.createStatement()
+ .execute("CREATE VIEW " + viewFullName + " AS SELECT * FROM " +
dataTableFullName);
+
+ conn.createStatement().execute("ALTER TABLE " + dataTableFullName
+ + " SET IMMUTABLE_STORAGE_SCHEME=SINGLE_CELL_ARRAY_WITH_OFFSETS,
COLUMN_ENCODED_BYTES=2");
+
+ SystemTransformRecord record = fetch(conn, schemaName, dataTableName,
null);
+ assertNotNull(record);
+
+ // Before cutover, links exist on both the base table and the view (2
total).
+ assertEquals("Base table and child view must each have a
TRANSFORMING_NEW_TABLE link", 2,
+ countLinksToNewPhysicalTable(conn, newTableFullName));
+
+ // Drive to PENDING_PARTIAL_PASS: pointer swap and link teardown are
committed at this point.
+ SystemTransformRecord pendingPartial = driveMonitorToStatus(conn,
schemaName, dataTableName,
+ null, PTable.TransformStatus.PENDING_PARTIAL_PASS, clock);
+ assertNotNull(pendingPartial);
+
+ PTable swappedTable = conn.getTableNoCache(dataTableFullName);
+ assertEquals(newTableName,
swappedTable.getPhysicalName(true).getString());
+
+ // No TRANSFORMING_NEW_TABLE links (base OR view) may survive cutover.
+ assertEquals("All TRANSFORMING_NEW_TABLE links (base + view) must be
deleted at cutover", 0,
+ countLinksToNewPhysicalTable(conn, newTableFullName));
+ assertEquals("Base-table TRANSFORMING_NEW_TABLE link must be deleted at
cutover", 0,
+ countLinkRows(conn, schemaName, dataTableName));
+ assertEquals("View TRANSFORMING_NEW_TABLE link must be deleted at
cutover", 0,
+ countLinkRows(conn, schemaName, viewName));
+ }
+ }
+
+ /**
+ * Seeds a PENDING_PARTIAL_PASS record plus its monitor task, then drives
the monitor to prove it
+ * honors the persisted wait deadline: while the injected clock is before
the deadline a monitor
+ * run is a no-op (still PENDING_PARTIAL_PASS), and once the clock reaches
the deadline a monitor
+ * run advances the record to PARTIAL_PASS_RUNNING. The PARTIAL_PASS_RUNNING
transition is
+ * committed before the partial-pass tool is launched, so it is observable
even though the tool
+ * run for this seeded (backing-table-less) record does not itself finish.
No real sleep is used.
+ */
+ @Test
+ public void testMonitorHonorsWaitDeadline() throws Exception {
+ AdvancingClock clock = new AdvancingClock();
+ EnvironmentEdgeManager.injectEdge(clock);
+
+ try (PhoenixConnection conn =
+ (PhoenixConnection) DriverManager.getConnection(getUrl(), testProps)) {
+ conn.setAutoCommit(true);
+ String logicalTableName = generateUniqueName();
+ long deadline = clock.currentTime() + (60L * 60L * 1000L);
+
+ SystemTransformRecord.SystemTransformBuilder builder =
+ new SystemTransformRecord.SystemTransformBuilder();
+ builder.setLogicalTableName(logicalTableName);
+ builder.setNewPhysicalTableName(logicalTableName + "_1");
+ // METADATA_TRANSFORM has a defined partial-transform variant, so the
monitor is able to move
+ // the record out of PENDING_PARTIAL_PASS once the wait window elapses.
+ builder.setTransformType(PTable.TransformType.METADATA_TRANSFORM);
+
builder.setTransformStatus(PTable.TransformStatus.PENDING_PARTIAL_PASS.name());
+ builder.setPendingPartialPassUntilTs(deadline);
+ Transform.upsertTransform(builder.build(), conn);
+
+ SystemTransformRecord readBack = fetch(conn, null, logicalTableName,
null);
+ assertNotNull(readBack);
+ // The BIGINT column round-trips exactly.
+ assertEquals(Long.valueOf(deadline),
readBack.getPendingPartialPassUntilTs());
+
+ // Register the monitor task so runMonitorOnce() dispatches to this
transform record.
+ Timestamp startTs = new
Timestamp(EnvironmentEdgeManager.currentTimeMillis());
+ ServerTask.addTask(new
SystemTaskParams.SystemTaskParamsBuilder().setConn(conn)
+
.setTaskType(PTable.TaskType.TRANSFORM_MONITOR).setTenantId(null).setSchemaName(null)
+
.setTableName(logicalTableName).setTaskStatus(PTable.TaskStatus.CREATED.toString())
+
.setData(null).setPriority(null).setStartTs(startTs).setEndTs(null).build());
+
+ // Before the deadline: a monitor run must NOT advance the record.
+ clock.setValue(deadline - 1);
+ runMonitorOnce();
+ Thread.sleep(200);
+ SystemTransformRecord stillWaiting = fetch(conn, null, logicalTableName,
null);
+ assertEquals("Monitor must no-op while clock < deadline",
+ PTable.TransformStatus.PENDING_PARTIAL_PASS.name(),
stillWaiting.getTransformStatus());
+
+ // At/after the deadline: a monitor run advances the record to
PARTIAL_PASS_RUNNING, which is
+ // committed before the (asynchronous) partial pass is launched.
+ clock.setValue(deadline + 1);
+ SystemTransformRecord running = driveMonitorToStatus(conn, null,
logicalTableName, null,
+ PTable.TransformStatus.PARTIAL_PASS_RUNNING, clock);
+ assertEquals(PTable.TransformStatus.PARTIAL_PASS_RUNNING.name(),
+ running.getTransformStatus());
+ }
+ }
+
+ /**
+ * Drives a real cutover to PENDING_PARTIAL_PASS and asserts the record
carries the completed
+ * full-pass job id at that point, then advances into PARTIAL_PASS_RUNNING
and asserts the stale
+ * full-pass job id never leaks into that state. The PENDING_PARTIAL_PASS
->
+ * PARTIAL_PASS_RUNNING transition clears the inherited full-pass job id;
the launched partial
+ * pass then registers its OWN job id under PARTIAL_PASS_RUNNING. Either way
the record must never
+ * carry the already-successful full-pass job id while PARTIAL_PASS_RUNNING,
which is what keeps
+ * the PARTIAL_PASS_RUNNING monitoring branch from mistaking that job for
the partial-pass job and
+ * prematurely completing the transform.
+ */
+ @Test
+ public void testPartialPassRunningTransitionClearsInheritedJobId() throws
Exception {
+ AdvancingClock clock = new AdvancingClock();
+ EnvironmentEdgeManager.injectEdge(clock);
+
+ String schemaName = generateUniqueName();
+ String dataTableName = "TBL_" + generateUniqueName();
+ String dataTableFullName = SchemaUtil.getTableName(schemaName,
dataTableName);
+
+ try (PhoenixConnection conn =
+ (PhoenixConnection) DriverManager.getConnection(getUrl(), testProps)) {
+ conn.setAutoCommit(true);
+ TransformToolIT.createTableAndUpsertRows(conn, dataTableFullName, 10,
"");
+ conn.createStatement().execute("ALTER TABLE " + dataTableFullName
+ + " SET IMMUTABLE_STORAGE_SCHEME=SINGLE_CELL_ARRAY_WITH_OFFSETS,
COLUMN_ENCODED_BYTES=2");
+
+ // Drive the full pass through cutover to PENDING_PARTIAL_PASS. The full
pass ran a
+ // TransformTool job, so the record carries that (now completed) job id
here.
+ SystemTransformRecord pendingPartial = driveMonitorToStatus(conn,
schemaName, dataTableName,
+ null, PTable.TransformStatus.PENDING_PARTIAL_PASS, clock);
+ String fullPassJobId = pendingPartial.getTransformJobId();
+ assertNotNull("Full-pass job id must be present on the
PENDING_PARTIAL_PASS record",
+ fullPassJobId);
+
+ // Advance past the wait deadline and into PARTIAL_PASS_RUNNING. The
transition nulls out the
+ // inherited full-pass job id, and the partial pass registers its own
job id afterward. The
+ // record's job id while PARTIAL_PASS_RUNNING is therefore either null
(before the partial
+ // pass
+ // registers) or the partial-pass job id -- but never the stale
full-pass job id.
+ clock.setValue(pendingPartial.getPendingPartialPassUntilTs() + 1);
+ SystemTransformRecord running = driveMonitorToStatus(conn, schemaName,
dataTableName, null,
+ PTable.TransformStatus.PARTIAL_PASS_RUNNING, clock);
+ assertTrue("PARTIAL_PASS_RUNNING must not carry the stale full-pass job
id",
+ running.getTransformJobId() == null ||
!fullPassJobId.equals(running.getTransformJobId()));
+ }
+ }
+
+ /**
+ * Defense-in-depth guard for the PARTIAL_PASS_RUNNING monitoring branch: a
record that is
+ * PARTIAL_PASS_RUNNING but still carries the FULL transform type together
with a stale
+ * (already-successful) full-pass job id must NOT be driven to COMPLETED.
Only a partial-type
+ * record whose own partial pass has registered may complete via that
branch. Seeds exactly that
+ * hazardous state and asserts a monitor run leaves the record untouched.
+ */
+ @Test
+ public void
testMonitorDoesNotCompletePartialPassRunningWithStaleFullPassJob() throws
Exception {
+ AdvancingClock clock = new AdvancingClock();
+ EnvironmentEdgeManager.injectEdge(clock);
+
+ try (PhoenixConnection conn =
+ (PhoenixConnection) DriverManager.getConnection(getUrl(), testProps)) {
+ conn.setAutoCommit(true);
+ String logicalTableName = generateUniqueName();
+
+ SystemTransformRecord.SystemTransformBuilder builder =
+ new SystemTransformRecord.SystemTransformBuilder();
+ builder.setLogicalTableName(logicalTableName);
+ builder.setNewPhysicalTableName(logicalTableName + "_1");
+ // FULL transform type + a stale full-pass job id is the pre-fix hazard
state: without the
+ // type gate the branch would look up that job, find it successful, and
complete the
+ // transform.
+ builder.setTransformType(PTable.TransformType.METADATA_TRANSFORM);
+
builder.setTransformStatus(PTable.TransformStatus.PARTIAL_PASS_RUNNING.name());
+ builder.setTransformJobId("job_000000000000_0001");
+ Transform.upsertTransform(builder.build(), conn);
+
+ // Register the monitor task so runMonitorOnce() dispatches to this
transform record.
+ Timestamp startTs = new
Timestamp(EnvironmentEdgeManager.currentTimeMillis());
+ ServerTask.addTask(new
SystemTaskParams.SystemTaskParamsBuilder().setConn(conn)
+
.setTaskType(PTable.TaskType.TRANSFORM_MONITOR).setTenantId(null).setSchemaName(null)
+
.setTableName(logicalTableName).setTaskStatus(PTable.TaskStatus.CREATED.toString())
+
.setData(null).setPriority(null).setStartTs(startTs).setEndTs(null).build());
+
+ runMonitorOnce();
+ Thread.sleep(200);
+
+ SystemTransformRecord after = fetch(conn, null, logicalTableName, null);
+ assertNotNull(after);
+ assertEquals(
+ "Full-type PARTIAL_PASS_RUNNING record with a stale full-pass job must
not be completed",
+ PTable.TransformStatus.PARTIAL_PASS_RUNNING.name(),
after.getTransformStatus());
+ }
+ }
+
+ /**
+ * A running partial pass must be observable in status PARTIAL_PASS_RUNNING
(partial transform
+ * type plus a non-null partial-pass job id), not in STARTED. Only then does
the
+ * PARTIAL_PASS_RUNNING branch of the monitor -- which alone can drive a
repeatedly-failing
+ * partial pass to a terminal FAILED state -- own the running partial pass.
If the running partial
+ * pass were left in STARTED (the state TransformTool.runTransform sets),
the STARTED branch,
+ * which has no terminal transition for an exhausted partial pass, would
monitor it and could
+ * strand it forever. Drives a real cutover through the partial pass and
asserts the record is
+ * observed at least once in PARTIAL_PASS_RUNNING with a partial type and a
registered job id
+ * before completing.
+ */
+ @Test
+ public void testRunningPartialPassIsObservableAsPartialPassRunning() throws
Exception {
+ AdvancingClock clock = new AdvancingClock();
+ EnvironmentEdgeManager.injectEdge(clock);
+
+ String schemaName = generateUniqueName();
+ String dataTableName = "TBL_" + generateUniqueName();
+ String dataTableFullName = SchemaUtil.getTableName(schemaName,
dataTableName);
+
+ try (PhoenixConnection conn =
+ (PhoenixConnection) DriverManager.getConnection(getUrl(), testProps)) {
+ conn.setAutoCommit(true);
+ TransformToolIT.createTableAndUpsertRows(conn, dataTableFullName, 10,
"");
+ conn.createStatement().execute("ALTER TABLE " + dataTableFullName
+ + " SET IMMUTABLE_STORAGE_SCHEME=SINGLE_CELL_ARRAY_WITH_OFFSETS,
COLUMN_ENCODED_BYTES=2");
+
+ // Drive to PENDING_PARTIAL_PASS, then past the wait deadline so the
next monitor step kicks
+ // the partial pass and re-asserts PARTIAL_PASS_RUNNING with the
partial-pass job id.
+ SystemTransformRecord pendingPartial = driveMonitorToStatus(conn,
schemaName, dataTableName,
+ null, PTable.TransformStatus.PENDING_PARTIAL_PASS, clock);
+ clock.setValue(pendingPartial.getPendingPartialPassUntilTs() + 1);
+
+ // Poll for the running partial pass. Assert the running state is
PARTIAL_PASS_RUNNING with
+ // the
+ // partial transform type and a non-null partial-pass job id -- the
exact pairing the pre-fix
+ // code never produced (a running partial pass used to live in STARTED).
+ boolean observedRunning = false;
+ for (int i = 0; i < 60; i++) {
+ SystemTransformRecord record = fetch(conn, schemaName, dataTableName,
null);
+ if (
+ record != null && PTable.TransformStatus.PARTIAL_PASS_RUNNING.name()
+ .equals(record.getTransformStatus())
+ ) {
+ assertTrue("Running partial pass must carry the partial transform
type",
+
PTable.TransformType.isPartialTransform(record.getTransformType()));
+ assertNotNull("Running partial pass must have a registered
partial-pass job id",
+ record.getTransformJobId());
+ observedRunning = true;
+ break;
+ }
+ if (
+ record != null
+ &&
PTable.TransformStatus.COMPLETED.name().equals(record.getTransformStatus())
+ ) {
+ // The partial pass finished before we polled; that is still correct
behavior. Fall
+ // through
+ // to the COMPLETED assertion below.
+ break;
+ }
+ runMonitorOnce();
+ Thread.sleep(200);
+ }
+
+ // Regardless of whether we caught the running window, the transform
must ultimately complete.
+ SystemTransformRecord completed = driveMonitorToStatus(conn, schemaName,
dataTableName, null,
+ PTable.TransformStatus.COMPLETED, clock);
+ assertNotNull(completed);
+ assertTrue(
+ "Expected to observe the running partial pass in PARTIAL_PASS_RUNNING "
+ + "or a legitimately-completed transform",
+ observedRunning
+ ||
PTable.TransformStatus.COMPLETED.name().equals(completed.getTransformStatus()));
Review Comment:
This assertion is tautological: `driveMonitorToStatus(... COMPLETED ...)`
only returns a record whose status is `COMPLETED`, so the right side of the
`||` is always true and the test passes even if the partial job incorrectly
runs entirely in `STARTED`. Use the job seam/latch to keep a partial job
running long enough to require observing `PARTIAL_PASS_RUNNING`, then assert
`observedRunning` directly.
##########
phoenix-core/src/it/java/org/apache/phoenix/end2end/transform/CutoverLifecycleIT.java:
##########
@@ -0,0 +1,938 @@
+/*
+ * 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 org.apache.phoenix.end2end.transform;
+
+import static org.apache.phoenix.query.QueryConstants.UNVERIFIED_BYTES;
+import static org.apache.phoenix.util.TestUtil.TEST_PROPERTIES;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.io.IOException;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.Properties;
+import org.apache.hadoop.hbase.coprocessor.RegionCoprocessorEnvironment;
+import org.apache.hadoop.mapreduce.Job;
+import org.apache.phoenix.coprocessor.TaskRegionObserver;
+import org.apache.phoenix.coprocessor.tasks.TransformMonitorTask;
+import org.apache.phoenix.end2end.ParallelStatsDisabledIT;
+import org.apache.phoenix.end2end.ParallelStatsDisabledTest;
+import org.apache.phoenix.jdbc.PhoenixConnection;
+import org.apache.phoenix.jdbc.PhoenixDatabaseMetaData;
+import org.apache.phoenix.mapreduce.util.PhoenixConfigurationUtil;
+import org.apache.phoenix.query.QueryServices;
+import org.apache.phoenix.query.QueryServicesOptions;
+import org.apache.phoenix.schema.PTable;
+import org.apache.phoenix.schema.task.ServerTask;
+import org.apache.phoenix.schema.task.SystemTaskParams;
+import org.apache.phoenix.schema.task.Task;
+import org.apache.phoenix.schema.transform.SystemTransformRecord;
+import org.apache.phoenix.schema.transform.Transform;
+import org.apache.phoenix.util.EnvironmentEdge;
+import org.apache.phoenix.util.EnvironmentEdgeManager;
+import org.apache.phoenix.util.PropertiesUtil;
+import org.apache.phoenix.util.SchemaUtil;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.mockito.Mockito;
+
+/**
+ * Integration tests for the cutover lifecycle: after the physical-table
pointer swap the transform
+ * monitor waits for clients to refresh their cached pointer (status
PENDING_PARTIAL_PASS) before
+ * running the partial pass (status PARTIAL_PASS_RUNNING) and finally
completing. The wait uses an
+ * injectable clock so the tests never sleep for the real wait window.
+ */
+@Category(ParallelStatsDisabledTest.class)
+public class CutoverLifecycleIT extends ParallelStatsDisabledIT {
+
+ private static RegionCoprocessorEnvironment taskRegionEnvironment;
+
+ private final Properties testProps =
PropertiesUtil.deepCopy(TEST_PROPERTIES);
+
+ public CutoverLifecycleIT() throws IOException, InterruptedException {
+ testProps.put(QueryServices.DEFAULT_IMMUTABLE_STORAGE_SCHEME_ATTRIB,
"ONE_CELL_PER_COLUMN");
+ testProps.put(QueryServices.DEFAULT_COLUMN_ENCODED_BYTES_ATRRIB, "0");
+
+ taskRegionEnvironment = (RegionCoprocessorEnvironment) getUtility()
+
.getRSForFirstRegionInTable(PhoenixDatabaseMetaData.SYSTEM_TASK_HBASE_TABLE_NAME)
+
.getRegions(PhoenixDatabaseMetaData.SYSTEM_TASK_HBASE_TABLE_NAME).get(0).getCoprocessorHost()
+ .findCoprocessorEnvironment(TaskRegionObserver.class.getName());
+ }
+
+ @Before
+ public void setupTest() throws Exception {
+ try (Connection conn = DriverManager.getConnection(getUrl(), testProps)) {
+ conn.setAutoCommit(true);
+ conn.createStatement()
+ .execute("DELETE FROM " +
PhoenixDatabaseMetaData.SYSTEM_TRANSFORM_NAME);
+ conn.createStatement().execute("DELETE FROM " +
PhoenixDatabaseMetaData.SYSTEM_TASK_NAME);
+ }
+ }
+
+ @After
+ public void tearDownTest() {
+ EnvironmentEdgeManager.reset();
+ TransformMonitorTask.resetJobLookupForTesting();
+ }
+
+ /**
+ * Advances the monitor clock so it is comfortably past the persisted wait
deadline, letting the
+ * PENDING_PARTIAL_PASS branch proceed without waiting for the real
30-minute floor.
+ */
+ private static class AdvancingClock extends EnvironmentEdge {
+ private long value = System.currentTimeMillis();
+
+ @Override
+ public long currentTime() {
+ return value;
+ }
+
+ void setValue(long millis) {
+ value = millis;
+ }
+ }
+
+ private void runMonitorOnce() {
+ TaskRegionObserver.SelfHealingTask task = new
TaskRegionObserver.SelfHealingTask(
+ taskRegionEnvironment,
QueryServicesOptions.DEFAULT_TASK_HANDLING_MAX_INTERVAL_MS);
+ task.run();
+ }
+
+ private SystemTransformRecord fetch(PhoenixConnection conn, String
schemaName, String tableName,
+ String parentName) throws SQLException {
+ return Transform.getTransformRecord(schemaName, tableName, parentName,
null, conn);
+ }
+
+ /**
+ * Drives the monitor, advancing the injected clock as needed, until the
transform reaches the
+ * requested status or we run out of attempts.
+ */
+ private SystemTransformRecord driveMonitorToStatus(PhoenixConnection conn,
String schemaName,
+ String tableName, String parentName, PTable.TransformStatus target,
AdvancingClock clock)
+ throws Exception {
+ for (int i = 0; i < 60; i++) {
+ SystemTransformRecord record = fetch(conn, schemaName, tableName,
parentName);
+ if (record != null && target.name().equals(record.getTransformStatus()))
{
+ return record;
+ }
+ // If we are inside the wait window, jump the clock past the deadline so
the monitor advances.
+ if (
+ record != null
+ &&
PTable.TransformStatus.PENDING_PARTIAL_PASS.name().equals(record.getTransformStatus())
+ && record.getPendingPartialPassUntilTs() != null
+ ) {
+ clock.setValue(record.getPendingPartialPassUntilTs() + 1);
+ }
+ runMonitorOnce();
+ Thread.sleep(200);
+ }
+ SystemTransformRecord record = fetch(conn, schemaName, tableName,
parentName);
+ fail("Ran out of attempts waiting for transform status " + target + " but
it was "
+ + (record == null ? "<no record>" : record.getTransformStatus()));
+ return null;
+ }
+
+ private long countLinkRows(Connection conn, String schemaName, String
tableName)
+ throws SQLException {
+ String sql = "SELECT COUNT(*) FROM " +
PhoenixDatabaseMetaData.SYSTEM_CATALOG_NAME + " WHERE "
+ + PhoenixDatabaseMetaData.TABLE_SCHEM + " = ? AND " +
PhoenixDatabaseMetaData.TABLE_NAME
+ + " = ? AND " + PhoenixDatabaseMetaData.LINK_TYPE + " = ?";
+ try (PreparedStatement stmt = conn.prepareStatement(sql)) {
+ stmt.setString(1, schemaName);
+ stmt.setString(2, tableName);
+ stmt.setByte(3,
PTable.LinkType.TRANSFORMING_NEW_TABLE.getSerializedValue());
+ ResultSet rs = stmt.executeQuery();
+ rs.next();
+ return rs.getLong(1);
+ }
+ }
+
+ private long countUnverified(Connection conn, String physicalTableFullName)
throws Exception {
+ return org.apache.phoenix.end2end.index.ImmutableIndexExtendedIT
+ .getRowCountForEmptyColValue(conn, physicalTableFullName,
UNVERIFIED_BYTES);
+ }
+
+ /**
+ * Counts every TRANSFORMING_NEW_TABLE link row that points at the given new
physical table,
+ * regardless of which logical entity (base table or child view) owns the
row. The link's
+ * COLUMN_FAMILY holds the full new physical table name, so this catches
base-table and view links
+ * alike.
+ */
+ private long countLinksToNewPhysicalTable(Connection conn, String
newPhysicalFullName)
+ throws SQLException {
+ String sql = "SELECT COUNT(*) FROM " +
PhoenixDatabaseMetaData.SYSTEM_CATALOG_NAME + " WHERE "
+ + PhoenixDatabaseMetaData.COLUMN_FAMILY + " = ? AND " +
PhoenixDatabaseMetaData.LINK_TYPE
+ + " = ?";
+ try (PreparedStatement stmt = conn.prepareStatement(sql)) {
+ stmt.setString(1, newPhysicalFullName);
+ stmt.setByte(2,
PTable.LinkType.TRANSFORMING_NEW_TABLE.getSerializedValue());
+ ResultSet rs = stmt.executeQuery();
+ rs.next();
+ return rs.getLong(1);
+ }
+ }
+
+ private void runCutoverLifecycle(boolean createIndex, boolean isImmutable)
throws Exception {
+ AdvancingClock clock = new AdvancingClock();
+ EnvironmentEdgeManager.injectEdge(clock);
+
+ String schemaName = generateUniqueName();
+ String dataTableName = "TBL_" + generateUniqueName();
+ String dataTableFullName = SchemaUtil.getTableName(schemaName,
dataTableName);
+ String newTableName = dataTableName + "_1";
+ String indexName = "IDX_" + generateUniqueName();
+ String createIndexStmt = "CREATE INDEX %s ON " + dataTableFullName + "
(NAME) INCLUDE (ZIP) ";
+
+ try (PhoenixConnection conn =
+ (PhoenixConnection) DriverManager.getConnection(getUrl(), testProps)) {
+ conn.setAutoCommit(true);
+ int numOfRows = 10;
+ TransformToolIT.createTableAndUpsertRows(conn, dataTableFullName,
numOfRows,
+ isImmutable ? " IMMUTABLE_ROWS=true" : "");
+ if (createIndex) {
+ conn.createStatement().execute(String.format(createIndexStmt,
indexName));
+ }
+
+ // Kick off the transform. The monitor task is registered as part of the
ALTER.
+ conn.createStatement().execute("ALTER TABLE " + dataTableFullName
+ + " SET IMMUTABLE_STORAGE_SCHEME=SINGLE_CELL_ARRAY_WITH_OFFSETS,
COLUMN_ENCODED_BYTES=2");
+
+ SystemTransformRecord record = fetch(conn, schemaName, dataTableName,
null);
+ assertNotNull(record);
+
+ List<Task.TaskRecord> taskRecordList = Task.queryTaskTable(conn, null);
+ assertEquals(1, taskRecordList.size());
+ assertEquals(PTable.TaskType.TRANSFORM_MONITOR,
taskRecordList.get(0).getTaskType());
+
+ // Drive to PENDING_PARTIAL_PASS: the pointer swap has happened and the
monitor is waiting.
+ SystemTransformRecord pendingPartial = driveMonitorToStatus(conn,
schemaName, dataTableName,
+ null, PTable.TransformStatus.PENDING_PARTIAL_PASS, clock);
+
+ // The wait deadline is set and lies in the future relative to when it
was computed.
+ Long untilTs = pendingPartial.getPendingPartialPassUntilTs();
+ assertNotNull("PENDING_PARTIAL_PASS must persist a wait deadline",
untilTs);
+
+ // The pointer swap is already visible and the dual-write link row is
gone in the same cache
+ // generation (the DELETE was committed together with the pointer swap).
+ PTable swappedTable = conn.getTableNoCache(dataTableFullName);
+ assertEquals(newTableName,
swappedTable.getPhysicalName(true).getString());
+ assertEquals("TRANSFORMING_NEW_TABLE link must be deleted at cutover", 0,
+ countLinkRows(conn, schemaName, dataTableName));
+
+ // While the clock is still before the deadline, the monitor must NOT
advance.
+ clock.setValue(untilTs - 1);
+ runMonitorOnce();
+ Thread.sleep(200);
+ SystemTransformRecord stillWaiting = fetch(conn, schemaName,
dataTableName, null);
+ assertEquals("Monitor must no-op while clock < deadline",
+ PTable.TransformStatus.PENDING_PARTIAL_PASS.name(),
stillWaiting.getTransformStatus());
+
+ // Once the clock reaches the deadline the monitor advances through the
partial pass to
+ // COMPLETED.
+ clock.setValue(untilTs + 1);
+ SystemTransformRecord completed = driveMonitorToStatus(conn, schemaName,
dataTableName, null,
+ PTable.TransformStatus.COMPLETED, clock);
+ assertNotNull(completed);
+
+ // No stranded unverified rows on the new physical table after the
partial pass.
+ assertEquals("No unverified rows should remain after the partial pass",
0,
+ countUnverified(conn, completed.getNewPhysicalTableName()));
+
+ // Sanity: pointer still points at the new physical table.
+ PTable finalTable = conn.getTableNoCache(dataTableFullName);
+ assertEquals(newTableName, finalTable.getPhysicalName(true).getString());
+ }
+ }
+
+ @Test
+ public void testCutoverLifecycleMutableTableWithoutIndex() throws Exception {
+ runCutoverLifecycle(false, false);
+ }
+
+ @Test
+ public void testCutoverLifecycleImmutableTableWithoutIndex() throws
Exception {
+ runCutoverLifecycle(false, true);
+ }
+
+ @Test
+ public void testCutoverLifecycleTableWithSecondaryIndex() throws Exception {
+ runCutoverLifecycle(true, false);
+ }
+
+ /**
+ * Cutover on a base table that HAS a child view must tear down the
dual-write links installed on
+ * both the base table AND the child view. Asserts that after cutover
reaches PENDING_PARTIAL_PASS
+ * (the point at which the pointer swap and link teardown are committed) no
TRANSFORMING_NEW_TABLE
+ * link rows pointing at the new physical table remain, for the base table
or the view.
+ */
+ @Test
+ public void testCutoverTearsDownViewDualWriteLinks() throws Exception {
+ AdvancingClock clock = new AdvancingClock();
+ EnvironmentEdgeManager.injectEdge(clock);
+
+ String schemaName = generateUniqueName();
+ String dataTableName = "TBL_" + generateUniqueName();
+ String dataTableFullName = SchemaUtil.getTableName(schemaName,
dataTableName);
+ String newTableName = dataTableName + "_1";
+ String newTableFullName = SchemaUtil.getTableName(schemaName,
newTableName);
+ String viewName = "VW_" + generateUniqueName();
+ String viewFullName = SchemaUtil.getTableName(schemaName, viewName);
+
+ try (PhoenixConnection conn =
+ (PhoenixConnection) DriverManager.getConnection(getUrl(), testProps)) {
+ conn.setAutoCommit(true);
+ TransformToolIT.createTableAndUpsertRows(conn, dataTableFullName, 10,
"");
+ // Create a child view over the base table before starting the
transform, so the transform
+ // installs a dual-write link on the view as well as the base table.
+ conn.createStatement()
+ .execute("CREATE VIEW " + viewFullName + " AS SELECT * FROM " +
dataTableFullName);
+
+ conn.createStatement().execute("ALTER TABLE " + dataTableFullName
+ + " SET IMMUTABLE_STORAGE_SCHEME=SINGLE_CELL_ARRAY_WITH_OFFSETS,
COLUMN_ENCODED_BYTES=2");
+
+ SystemTransformRecord record = fetch(conn, schemaName, dataTableName,
null);
+ assertNotNull(record);
+
+ // Before cutover, links exist on both the base table and the view (2
total).
+ assertEquals("Base table and child view must each have a
TRANSFORMING_NEW_TABLE link", 2,
+ countLinksToNewPhysicalTable(conn, newTableFullName));
+
+ // Drive to PENDING_PARTIAL_PASS: pointer swap and link teardown are
committed at this point.
+ SystemTransformRecord pendingPartial = driveMonitorToStatus(conn,
schemaName, dataTableName,
+ null, PTable.TransformStatus.PENDING_PARTIAL_PASS, clock);
+ assertNotNull(pendingPartial);
+
+ PTable swappedTable = conn.getTableNoCache(dataTableFullName);
+ assertEquals(newTableName,
swappedTable.getPhysicalName(true).getString());
+
+ // No TRANSFORMING_NEW_TABLE links (base OR view) may survive cutover.
+ assertEquals("All TRANSFORMING_NEW_TABLE links (base + view) must be
deleted at cutover", 0,
+ countLinksToNewPhysicalTable(conn, newTableFullName));
+ assertEquals("Base-table TRANSFORMING_NEW_TABLE link must be deleted at
cutover", 0,
+ countLinkRows(conn, schemaName, dataTableName));
+ assertEquals("View TRANSFORMING_NEW_TABLE link must be deleted at
cutover", 0,
+ countLinkRows(conn, schemaName, viewName));
+ }
+ }
+
+ /**
+ * Seeds a PENDING_PARTIAL_PASS record plus its monitor task, then drives
the monitor to prove it
+ * honors the persisted wait deadline: while the injected clock is before
the deadline a monitor
+ * run is a no-op (still PENDING_PARTIAL_PASS), and once the clock reaches
the deadline a monitor
+ * run advances the record to PARTIAL_PASS_RUNNING. The PARTIAL_PASS_RUNNING
transition is
+ * committed before the partial-pass tool is launched, so it is observable
even though the tool
+ * run for this seeded (backing-table-less) record does not itself finish.
No real sleep is used.
+ */
+ @Test
+ public void testMonitorHonorsWaitDeadline() throws Exception {
+ AdvancingClock clock = new AdvancingClock();
+ EnvironmentEdgeManager.injectEdge(clock);
+
+ try (PhoenixConnection conn =
+ (PhoenixConnection) DriverManager.getConnection(getUrl(), testProps)) {
+ conn.setAutoCommit(true);
+ String logicalTableName = generateUniqueName();
+ long deadline = clock.currentTime() + (60L * 60L * 1000L);
+
+ SystemTransformRecord.SystemTransformBuilder builder =
+ new SystemTransformRecord.SystemTransformBuilder();
+ builder.setLogicalTableName(logicalTableName);
+ builder.setNewPhysicalTableName(logicalTableName + "_1");
+ // METADATA_TRANSFORM has a defined partial-transform variant, so the
monitor is able to move
+ // the record out of PENDING_PARTIAL_PASS once the wait window elapses.
+ builder.setTransformType(PTable.TransformType.METADATA_TRANSFORM);
+
builder.setTransformStatus(PTable.TransformStatus.PENDING_PARTIAL_PASS.name());
+ builder.setPendingPartialPassUntilTs(deadline);
+ Transform.upsertTransform(builder.build(), conn);
+
+ SystemTransformRecord readBack = fetch(conn, null, logicalTableName,
null);
+ assertNotNull(readBack);
+ // The BIGINT column round-trips exactly.
+ assertEquals(Long.valueOf(deadline),
readBack.getPendingPartialPassUntilTs());
+
+ // Register the monitor task so runMonitorOnce() dispatches to this
transform record.
+ Timestamp startTs = new
Timestamp(EnvironmentEdgeManager.currentTimeMillis());
+ ServerTask.addTask(new
SystemTaskParams.SystemTaskParamsBuilder().setConn(conn)
+
.setTaskType(PTable.TaskType.TRANSFORM_MONITOR).setTenantId(null).setSchemaName(null)
+
.setTableName(logicalTableName).setTaskStatus(PTable.TaskStatus.CREATED.toString())
+
.setData(null).setPriority(null).setStartTs(startTs).setEndTs(null).build());
+
+ // Before the deadline: a monitor run must NOT advance the record.
+ clock.setValue(deadline - 1);
+ runMonitorOnce();
+ Thread.sleep(200);
+ SystemTransformRecord stillWaiting = fetch(conn, null, logicalTableName,
null);
+ assertEquals("Monitor must no-op while clock < deadline",
+ PTable.TransformStatus.PENDING_PARTIAL_PASS.name(),
stillWaiting.getTransformStatus());
+
+ // At/after the deadline: a monitor run advances the record to
PARTIAL_PASS_RUNNING, which is
+ // committed before the (asynchronous) partial pass is launched.
+ clock.setValue(deadline + 1);
+ SystemTransformRecord running = driveMonitorToStatus(conn, null,
logicalTableName, null,
+ PTable.TransformStatus.PARTIAL_PASS_RUNNING, clock);
+ assertEquals(PTable.TransformStatus.PARTIAL_PASS_RUNNING.name(),
+ running.getTransformStatus());
+ }
+ }
+
+ /**
+ * Drives a real cutover to PENDING_PARTIAL_PASS and asserts the record
carries the completed
+ * full-pass job id at that point, then advances into PARTIAL_PASS_RUNNING
and asserts the stale
+ * full-pass job id never leaks into that state. The PENDING_PARTIAL_PASS
->
+ * PARTIAL_PASS_RUNNING transition clears the inherited full-pass job id;
the launched partial
+ * pass then registers its OWN job id under PARTIAL_PASS_RUNNING. Either way
the record must never
+ * carry the already-successful full-pass job id while PARTIAL_PASS_RUNNING,
which is what keeps
+ * the PARTIAL_PASS_RUNNING monitoring branch from mistaking that job for
the partial-pass job and
+ * prematurely completing the transform.
+ */
+ @Test
+ public void testPartialPassRunningTransitionClearsInheritedJobId() throws
Exception {
+ AdvancingClock clock = new AdvancingClock();
+ EnvironmentEdgeManager.injectEdge(clock);
+
+ String schemaName = generateUniqueName();
+ String dataTableName = "TBL_" + generateUniqueName();
+ String dataTableFullName = SchemaUtil.getTableName(schemaName,
dataTableName);
+
+ try (PhoenixConnection conn =
+ (PhoenixConnection) DriverManager.getConnection(getUrl(), testProps)) {
+ conn.setAutoCommit(true);
+ TransformToolIT.createTableAndUpsertRows(conn, dataTableFullName, 10,
"");
+ conn.createStatement().execute("ALTER TABLE " + dataTableFullName
+ + " SET IMMUTABLE_STORAGE_SCHEME=SINGLE_CELL_ARRAY_WITH_OFFSETS,
COLUMN_ENCODED_BYTES=2");
+
+ // Drive the full pass through cutover to PENDING_PARTIAL_PASS. The full
pass ran a
+ // TransformTool job, so the record carries that (now completed) job id
here.
+ SystemTransformRecord pendingPartial = driveMonitorToStatus(conn,
schemaName, dataTableName,
+ null, PTable.TransformStatus.PENDING_PARTIAL_PASS, clock);
+ String fullPassJobId = pendingPartial.getTransformJobId();
+ assertNotNull("Full-pass job id must be present on the
PENDING_PARTIAL_PASS record",
+ fullPassJobId);
+
+ // Advance past the wait deadline and into PARTIAL_PASS_RUNNING. The
transition nulls out the
+ // inherited full-pass job id, and the partial pass registers its own
job id afterward. The
+ // record's job id while PARTIAL_PASS_RUNNING is therefore either null
(before the partial
+ // pass
+ // registers) or the partial-pass job id -- but never the stale
full-pass job id.
+ clock.setValue(pendingPartial.getPendingPartialPassUntilTs() + 1);
+ SystemTransformRecord running = driveMonitorToStatus(conn, schemaName,
dataTableName, null,
+ PTable.TransformStatus.PARTIAL_PASS_RUNNING, clock);
+ assertTrue("PARTIAL_PASS_RUNNING must not carry the stale full-pass job
id",
+ running.getTransformJobId() == null ||
!fullPassJobId.equals(running.getTransformJobId()));
+ }
+ }
+
+ /**
+ * Defense-in-depth guard for the PARTIAL_PASS_RUNNING monitoring branch: a
record that is
+ * PARTIAL_PASS_RUNNING but still carries the FULL transform type together
with a stale
+ * (already-successful) full-pass job id must NOT be driven to COMPLETED.
Only a partial-type
+ * record whose own partial pass has registered may complete via that
branch. Seeds exactly that
+ * hazardous state and asserts a monitor run leaves the record untouched.
+ */
+ @Test
+ public void
testMonitorDoesNotCompletePartialPassRunningWithStaleFullPassJob() throws
Exception {
+ AdvancingClock clock = new AdvancingClock();
+ EnvironmentEdgeManager.injectEdge(clock);
+
+ try (PhoenixConnection conn =
+ (PhoenixConnection) DriverManager.getConnection(getUrl(), testProps)) {
+ conn.setAutoCommit(true);
+ String logicalTableName = generateUniqueName();
+
+ SystemTransformRecord.SystemTransformBuilder builder =
+ new SystemTransformRecord.SystemTransformBuilder();
+ builder.setLogicalTableName(logicalTableName);
+ builder.setNewPhysicalTableName(logicalTableName + "_1");
+ // FULL transform type + a stale full-pass job id is the pre-fix hazard
state: without the
+ // type gate the branch would look up that job, find it successful, and
complete the
+ // transform.
+ builder.setTransformType(PTable.TransformType.METADATA_TRANSFORM);
+
builder.setTransformStatus(PTable.TransformStatus.PARTIAL_PASS_RUNNING.name());
+ builder.setTransformJobId("job_000000000000_0001");
+ Transform.upsertTransform(builder.build(), conn);
+
+ // Register the monitor task so runMonitorOnce() dispatches to this
transform record.
+ Timestamp startTs = new
Timestamp(EnvironmentEdgeManager.currentTimeMillis());
+ ServerTask.addTask(new
SystemTaskParams.SystemTaskParamsBuilder().setConn(conn)
+
.setTaskType(PTable.TaskType.TRANSFORM_MONITOR).setTenantId(null).setSchemaName(null)
+
.setTableName(logicalTableName).setTaskStatus(PTable.TaskStatus.CREATED.toString())
+
.setData(null).setPriority(null).setStartTs(startTs).setEndTs(null).build());
+
+ runMonitorOnce();
+ Thread.sleep(200);
+
+ SystemTransformRecord after = fetch(conn, null, logicalTableName, null);
+ assertNotNull(after);
+ assertEquals(
+ "Full-type PARTIAL_PASS_RUNNING record with a stale full-pass job must
not be completed",
+ PTable.TransformStatus.PARTIAL_PASS_RUNNING.name(),
after.getTransformStatus());
+ }
+ }
+
+ /**
+ * A running partial pass must be observable in status PARTIAL_PASS_RUNNING
(partial transform
+ * type plus a non-null partial-pass job id), not in STARTED. Only then does
the
+ * PARTIAL_PASS_RUNNING branch of the monitor -- which alone can drive a
repeatedly-failing
+ * partial pass to a terminal FAILED state -- own the running partial pass.
If the running partial
+ * pass were left in STARTED (the state TransformTool.runTransform sets),
the STARTED branch,
+ * which has no terminal transition for an exhausted partial pass, would
monitor it and could
+ * strand it forever. Drives a real cutover through the partial pass and
asserts the record is
+ * observed at least once in PARTIAL_PASS_RUNNING with a partial type and a
registered job id
+ * before completing.
+ */
+ @Test
+ public void testRunningPartialPassIsObservableAsPartialPassRunning() throws
Exception {
+ AdvancingClock clock = new AdvancingClock();
+ EnvironmentEdgeManager.injectEdge(clock);
+
+ String schemaName = generateUniqueName();
+ String dataTableName = "TBL_" + generateUniqueName();
+ String dataTableFullName = SchemaUtil.getTableName(schemaName,
dataTableName);
+
+ try (PhoenixConnection conn =
+ (PhoenixConnection) DriverManager.getConnection(getUrl(), testProps)) {
+ conn.setAutoCommit(true);
+ TransformToolIT.createTableAndUpsertRows(conn, dataTableFullName, 10,
"");
+ conn.createStatement().execute("ALTER TABLE " + dataTableFullName
+ + " SET IMMUTABLE_STORAGE_SCHEME=SINGLE_CELL_ARRAY_WITH_OFFSETS,
COLUMN_ENCODED_BYTES=2");
+
+ // Drive to PENDING_PARTIAL_PASS, then past the wait deadline so the
next monitor step kicks
+ // the partial pass and re-asserts PARTIAL_PASS_RUNNING with the
partial-pass job id.
+ SystemTransformRecord pendingPartial = driveMonitorToStatus(conn,
schemaName, dataTableName,
+ null, PTable.TransformStatus.PENDING_PARTIAL_PASS, clock);
+ clock.setValue(pendingPartial.getPendingPartialPassUntilTs() + 1);
+
+ // Poll for the running partial pass. Assert the running state is
PARTIAL_PASS_RUNNING with
+ // the
+ // partial transform type and a non-null partial-pass job id -- the
exact pairing the pre-fix
+ // code never produced (a running partial pass used to live in STARTED).
+ boolean observedRunning = false;
+ for (int i = 0; i < 60; i++) {
+ SystemTransformRecord record = fetch(conn, schemaName, dataTableName,
null);
+ if (
+ record != null && PTable.TransformStatus.PARTIAL_PASS_RUNNING.name()
+ .equals(record.getTransformStatus())
+ ) {
+ assertTrue("Running partial pass must carry the partial transform
type",
+
PTable.TransformType.isPartialTransform(record.getTransformType()));
+ assertNotNull("Running partial pass must have a registered
partial-pass job id",
+ record.getTransformJobId());
+ observedRunning = true;
+ break;
+ }
+ if (
+ record != null
+ &&
PTable.TransformStatus.COMPLETED.name().equals(record.getTransformStatus())
+ ) {
+ // The partial pass finished before we polled; that is still correct
behavior. Fall
+ // through
+ // to the COMPLETED assertion below.
+ break;
+ }
+ runMonitorOnce();
+ Thread.sleep(200);
+ }
+
+ // Regardless of whether we caught the running window, the transform
must ultimately complete.
+ SystemTransformRecord completed = driveMonitorToStatus(conn, schemaName,
dataTableName, null,
+ PTable.TransformStatus.COMPLETED, clock);
+ assertNotNull(completed);
+ assertTrue(
+ "Expected to observe the running partial pass in PARTIAL_PASS_RUNNING "
+ + "or a legitimately-completed transform",
+ observedRunning
+ ||
PTable.TransformStatus.COMPLETED.name().equals(completed.getTransformStatus()));
+ }
+ }
+
+ /**
+ * A partial pass whose job repeatedly fails must not strand the transform.
Once retries are
+ * exhausted the PARTIAL_PASS_RUNNING branch must drive the record to a
terminal FAILED state
+ * rather than re-entering PARTIAL_PASS_RUNNING (or STARTED) forever. Seeds
the exact observable
+ * state a running partial pass now produces -- PARTIAL_PASS_RUNNING,
partial transform type, a
+ * registered job id, and a retry count already at/above the maximum -- and
injects a
+ * completed-and-failed job through the monitor's job-lookup seam. Asserts a
single monitor run
+ * moves the record to FAILED.
+ */
+ @Test
+ public void testPartialPassRetriesExhaustedReachesFailed() throws Exception {
+ AdvancingClock clock = new AdvancingClock();
+ EnvironmentEdgeManager.injectEdge(clock);
+
+ // Inject a job that is complete and unsuccessful so the
PARTIAL_PASS_RUNNING branch takes the
+ // failure path. The retry count is seeded above the maximum so the branch
treats retries as
+ // exhausted and must transition to FAILED.
+ Job failedJob = Mockito.mock(Job.class);
+ Mockito.when(failedJob.isComplete()).thenReturn(true);
+ Mockito.when(failedJob.isSuccessful()).thenReturn(false);
+ TransformMonitorTask.setJobLookupForTesting((configuration, jobId) ->
failedJob);
+
+ try (PhoenixConnection conn =
+ (PhoenixConnection) DriverManager.getConnection(getUrl(), testProps)) {
+ conn.setAutoCommit(true);
+ String logicalTableName = generateUniqueName();
+
+ SystemTransformRecord.SystemTransformBuilder builder =
+ new SystemTransformRecord.SystemTransformBuilder();
+ builder.setLogicalTableName(logicalTableName);
+ builder.setNewPhysicalTableName(logicalTableName + "_1");
+ // This is the real observable state of a running partial pass: partial
transform type,
+ // PARTIAL_PASS_RUNNING, and a registered partial-pass job id.
+
builder.setTransformType(PTable.TransformType.METADATA_TRANSFORM_PARTIAL);
+
builder.setTransformStatus(PTable.TransformStatus.PARTIAL_PASS_RUNNING.name());
+ builder.setTransformJobId("job_000000000000_0002");
+ // Well above the default maximum retry count so retries are exhausted.
+ builder.setTransformRetryCount(1000);
+ Transform.upsertTransform(builder.build(), conn);
+
+ Timestamp startTs = new
Timestamp(EnvironmentEdgeManager.currentTimeMillis());
+ ServerTask.addTask(new
SystemTaskParams.SystemTaskParamsBuilder().setConn(conn)
+
.setTaskType(PTable.TaskType.TRANSFORM_MONITOR).setTenantId(null).setSchemaName(null)
+
.setTableName(logicalTableName).setTaskStatus(PTable.TaskStatus.CREATED.toString())
+
.setData(null).setPriority(null).setStartTs(startTs).setEndTs(null).build());
+
+ SystemTransformRecord failed = driveMonitorToStatus(conn, null,
logicalTableName, null,
+ PTable.TransformStatus.FAILED, clock);
+ assertEquals(
+ "A partial pass whose retries are exhausted must reach terminal
FAILED, not strand",
+ PTable.TransformStatus.FAILED.name(), failed.getTransformStatus());
+ }
+ }
+
+ /**
+ * A partial-pass job that cannot be resolved (aged out of the job-history
server,
+ * resource-manager restart, etc.) must be treated like a failed job and
routed through the
+ * retry-budgeted path, not left to strand. A job that cannot be found
cannot be confirmed
+ * successful, so the PARTIAL_PASS_RUNNING branch must NOT return SKIPPED
for it: after the
+ * pointer swap the task sits in the STARTED task state and only
CREATED/RETRY tasks are re-picked
+ * up, so a SKIPPED result would leave the already-cut-over table's
repairing partial pass forever
+ * unrun. Seeds an exhausted PARTIAL_PASS_RUNNING record and injects a null
(not-found) job
+ * through the lookup seam; asserts a monitor run drives the record to
terminal FAILED rather than
+ * stranding it in PARTIAL_PASS_RUNNING.
+ */
+ @Test
+ public void testPartialPassRunningJobNotFoundDoesNotStrand() throws
Exception {
+ AdvancingClock clock = new AdvancingClock();
+ EnvironmentEdgeManager.injectEdge(clock);
+
+ // Inject a not-found job (the lookup returns null). The retry count is
seeded above the maximum
+ // so retries are exhausted and the not-found job must be treated as a
terminal failure without
+ // needing a real TransformTool run.
+ TransformMonitorTask.setJobLookupForTesting((configuration, jobId) ->
null);
+
+ try (PhoenixConnection conn =
+ (PhoenixConnection) DriverManager.getConnection(getUrl(), testProps)) {
+ conn.setAutoCommit(true);
+ String logicalTableName = generateUniqueName();
+
+ SystemTransformRecord.SystemTransformBuilder builder =
+ new SystemTransformRecord.SystemTransformBuilder();
+ builder.setLogicalTableName(logicalTableName);
+ builder.setNewPhysicalTableName(logicalTableName + "_1");
+ // The real observable state of a running partial pass: partial
transform type,
+ // PARTIAL_PASS_RUNNING, and a registered partial-pass job id (which the
lookup cannot
+ // resolve).
+
builder.setTransformType(PTable.TransformType.METADATA_TRANSFORM_PARTIAL);
+
builder.setTransformStatus(PTable.TransformStatus.PARTIAL_PASS_RUNNING.name());
+ builder.setTransformJobId("job_000000000000_0004");
+ // Well above the default maximum retry count so retries are exhausted.
+ builder.setTransformRetryCount(1000);
+ Transform.upsertTransform(builder.build(), conn);
+
+ Timestamp startTs = new
Timestamp(EnvironmentEdgeManager.currentTimeMillis());
+ ServerTask.addTask(new
SystemTaskParams.SystemTaskParamsBuilder().setConn(conn)
+
.setTaskType(PTable.TaskType.TRANSFORM_MONITOR).setTenantId(null).setSchemaName(null)
+
.setTableName(logicalTableName).setTaskStatus(PTable.TaskStatus.CREATED.toString())
+
.setData(null).setPriority(null).setStartTs(startTs).setEndTs(null).build());
+
+ SystemTransformRecord failed = driveMonitorToStatus(conn, null,
logicalTableName, null,
+ PTable.TransformStatus.FAILED, clock);
+ assertEquals(
+ "A PARTIAL_PASS_RUNNING record whose job cannot be found must reach
terminal "
+ + "FAILED, not strand in PARTIAL_PASS_RUNNING",
+ PTable.TransformStatus.FAILED.name(), failed.getTransformStatus());
+ }
+ }
+
+ /**
+ * Regression test: a partial-pass record left in PARTIAL_PASS_RUNNING with
NO registered job id
+ * must still reach a terminal state, not no-op forever. This is the state a
failed initial
+ * partial-pass kick produces: the cutover transition commits
(PARTIAL_PASS_RUNNING, partial type,
+ * job id cleared) and then kicks the partial pass; if that first
TransformTool run fails before
+ * its STARTED transition (connection acquisition, index-table creation, or
argument validation
+ * throws, so the run returns without registering a job id and without
propagating an exception),
+ * the committed row stays partial-type/PARTIAL_PASS_RUNNING with a null job
id. The pointer swap
+ * has already happened, so the record must reach a terminal state so the
unverified rows are
+ * either repaired (via retry) or the transform is surfaced as FAILED. A
monitoring branch that
+ * only acted when a job id was present would silently no-op on every
subsequent scan, stranding
+ * the already-cut-over table with no repairing partial pass and no terminal
status.
+ * <p>
+ * Seeds exactly that observable state -- PARTIAL_PASS_RUNNING, partial
transform type, a null job
+ * id, retry count above the maximum -- and installs a job-lookup seam that
FAILS if invoked, to
+ * prove the null-job-id path reaches FAILED without ever consulting the
(irrelevant) job lookup.
+ * A single monitor run must move the record to terminal FAILED.
+ */
+ @Test
+ public void testPartialPassRunningNullJobIdDoesNotStrand() throws Exception {
+ AdvancingClock clock = new AdvancingClock();
+ EnvironmentEdgeManager.injectEdge(clock);
+
+ // The job lookup must never be consulted when no job id is registered: a
null job id is handled
+ // directly as an unconfirmable (failed) partial pass. Fail loudly if the
lookup is invoked.
+ TransformMonitorTask.setJobLookupForTesting((configuration, jobId) -> {
+ throw new AssertionError(
+ "Job lookup must not be called when no partial-pass job id is
registered");
+ });
+
+ try (PhoenixConnection conn =
+ (PhoenixConnection) DriverManager.getConnection(getUrl(), testProps)) {
+ conn.setAutoCommit(true);
+ String logicalTableName = generateUniqueName();
+
+ SystemTransformRecord.SystemTransformBuilder builder =
+ new SystemTransformRecord.SystemTransformBuilder();
+ builder.setLogicalTableName(logicalTableName);
+ builder.setNewPhysicalTableName(logicalTableName + "_1");
+ // The observable state a failed initial partial-pass kick leaves
behind: partial transform
+ // type and PARTIAL_PASS_RUNNING, but no job id was ever registered.
+
builder.setTransformType(PTable.TransformType.METADATA_TRANSFORM_PARTIAL);
+
builder.setTransformStatus(PTable.TransformStatus.PARTIAL_PASS_RUNNING.name());
+ builder.setTransformJobId(null);
+ // Well above the default maximum retry count so retries are exhausted
and the branch must
+ // move
+ // straight to terminal FAILED without launching a real TransformTool
run.
+ builder.setTransformRetryCount(1000);
+ Transform.upsertTransform(builder.build(), conn);
+
+ Timestamp startTs = new
Timestamp(EnvironmentEdgeManager.currentTimeMillis());
+ ServerTask.addTask(new
SystemTaskParams.SystemTaskParamsBuilder().setConn(conn)
+
.setTaskType(PTable.TaskType.TRANSFORM_MONITOR).setTenantId(null).setSchemaName(null)
+
.setTableName(logicalTableName).setTaskStatus(PTable.TaskStatus.CREATED.toString())
+
.setData(null).setPriority(null).setStartTs(startTs).setEndTs(null).build());
+
+ SystemTransformRecord failed = driveMonitorToStatus(conn, null,
logicalTableName, null,
+ PTable.TransformStatus.FAILED, clock);
+ assertEquals(
+ "A PARTIAL_PASS_RUNNING record with no registered job id must reach
terminal FAILED, not "
+ + "strand in PARTIAL_PASS_RUNNING",
+ PTable.TransformStatus.FAILED.name(), failed.getTransformStatus());
+ }
+ }
+
+ /**
+ * Regression test for the partial-pass retry-count accounting. A genuine
retry must strictly
+ * ADVANCE the retry count, tick by tick, so it eventually reaches the
maximum and the
+ * retries-exhausted -> terminal FAILED transition becomes reachable.
+ * <p>
+ * Mechanism under test: on the retry path {@code kickPartialPass} skips the
compensating
+ * pre-decrement it applies to the very first (non-retry) partial-pass kick.
TransformTool's
+ * STARTED transition unconditionally increments the retry count and
auto-commits it on
+ * TransformTool's own connection, so skipping the decrement lets the
persisted count strictly
+ * increase. A prior implementation cancelled that increment with a matching
decrement on the
+ * retry path too, pinning the count so the terminal FAILED transition was
unreachable and a
+ * deterministically-failing partial pass resubmitted forever; this test
fails against that
+ * implementation (the count nets back to the seed) and passes once the
decrement is skipped on
+ * the retry path.
+ * <p>
+ * The retry's kick runs a real TransformTool invocation whose pre-run
validation resolves both
+ * the logical table and the new physical table before the STARTED increment
is reached. The test
+ * therefore drives a real cutover to PENDING_PARTIAL_PASS, which creates
both backing tables
+ * through the production machinery, before forcing the retry -- so
validation passes and the
+ * increment actually fires. A backing-table-less seed would throw inside
validation before the
+ * increment and would false-fail against the correct fix.
+ */
+ @Test
+ public void testPartialPassRetryAdvancesRetryCount() throws Exception {
+ AdvancingClock clock = new AdvancingClock();
+ EnvironmentEdgeManager.injectEdge(clock);
+
+ String schemaName = generateUniqueName();
+ String dataTableName = "TBL_" + generateUniqueName();
+ String dataTableFullName = SchemaUtil.getTableName(schemaName,
dataTableName);
+
+ try (PhoenixConnection conn =
+ (PhoenixConnection) DriverManager.getConnection(getUrl(), testProps)) {
+ conn.setAutoCommit(true);
+ // Real backing tables via the production ALTER/cutover machinery, so
the retry's
+ // TransformTool
+ // run passes pre-run validation and reaches the STARTED increment.
+ TransformToolIT.createTableAndUpsertRows(conn, dataTableFullName, 10,
"");
+ conn.createStatement().execute("ALTER TABLE " + dataTableFullName
+ + " SET IMMUTABLE_STORAGE_SCHEME=SINGLE_CELL_ARRAY_WITH_OFFSETS,
COLUMN_ENCODED_BYTES=2");
+
+ // Drive the real transform through cutover to PENDING_PARTIAL_PASS
using the REAL job-lookup
+ // seam: this drive depends on the actual full-pass MR job completing
successfully. Only after
+ // reaching PENDING_PARTIAL_PASS do we swap in the failing-job seam
below -- installing it
+ // earlier would make the monitor see the real full-pass job as failed
and never cut over.
+ // At this point both the logical table and the new physical table
exist, so the partial-pass
+ // TransformTool run will pass validation.
+ SystemTransformRecord pendingPartial = driveMonitorToStatus(conn,
schemaName, dataTableName,
+ null, PTable.TransformStatus.PENDING_PARTIAL_PASS, clock);
+
+ // Now force the PARTIAL_PASS_RUNNING branch down the retry path: the
injected job is complete
+ // and unsuccessful, so the monitor retries the partial pass rather than
completing it.
+ Job failedJob = Mockito.mock(Job.class);
+ Mockito.when(failedJob.isComplete()).thenReturn(true);
+ Mockito.when(failedJob.isSuccessful()).thenReturn(false);
+ TransformMonitorTask.setJobLookupForTesting((configuration, jobId) ->
failedJob);
+
+ // Seed the observable state of a running-but-failing partial pass, one
retry below the
+ // maximum so the failure path takes a genuine retry (not the
already-exhausted
+ // straight-to-FAILED path). Build from the driven record so
schema/logical/new-physical names
+ // and the last-state timestamp carry over and the seeded record stays
consistent with the
+ // real backing tables (the partial pass validates the last-transform
time against that ts).
+ int seededRetryCount =
PhoenixConfigurationUtil.DEFAULT_TRANSFORM_RETRY_COUNT - 1;
+ SystemTransformRecord.SystemTransformBuilder builder =
+ new SystemTransformRecord.SystemTransformBuilder(pendingPartial);
+
builder.setTransformType(PTable.TransformType.METADATA_TRANSFORM_PARTIAL);
+
builder.setTransformStatus(PTable.TransformStatus.PARTIAL_PASS_RUNNING.name());
+ builder.setTransformJobId("job_000000000000_0003");
+ builder.setTransformRetryCount(seededRetryCount);
+ Transform.upsertTransform(builder.build(), conn);
+
+ // Replace the monitor task chain left by the drive with a single fresh
CREATED task, so the
+ // next runMonitorOnce() dispatches exactly once to the seeded record.
+ conn.createStatement().execute("DELETE FROM " +
PhoenixDatabaseMetaData.SYSTEM_TASK_NAME);
+ Timestamp startTs = new
Timestamp(EnvironmentEdgeManager.currentTimeMillis());
+ ServerTask.addTask(new
SystemTaskParams.SystemTaskParamsBuilder().setConn(conn)
+
.setTaskType(PTable.TaskType.TRANSFORM_MONITOR).setTenantId(null).setSchemaName(schemaName)
+
.setTableName(dataTableName).setTaskStatus(PTable.TaskStatus.CREATED.toString())
+
.setData(null).setPriority(null).setStartTs(startTs).setEndTs(null).build());
+
+ // A monitor run takes the PARTIAL_PASS_RUNNING failure path and retries
the partial pass;
+ // TransformTool's STARTED transition then increments the
(auto-committed) retry count. Poll
+ // for the persisted count to exceed the seed.
+ int observedRetryCount = seededRetryCount;
+ for (int i = 0; i < 60; i++) {
+ SystemTransformRecord record = fetch(conn, schemaName, dataTableName,
null);
+ if (record != null && record.getTransformRetryCount() >
seededRetryCount) {
+ observedRetryCount = record.getTransformRetryCount();
+ break;
+ }
+ runMonitorOnce();
+ Thread.sleep(200);
+ }
+ assertTrue("A genuine partial-pass retry must strictly advance the retry
count (seed "
+ + seededRetryCount + ", observed " + observedRetryCount
+ + ") so retries-exhausted -> FAILED is reachable", observedRetryCount
> seededRetryCount);
+ }
+ }
+
+ /**
+ * Verifies that an existing SYSTEM.TRANSFORM gains the new column and that
a value can be written
+ * to and read back from it, exercising the upgrade column-add path
indirectly (the column exists
+ * on a freshly upgraded cluster).
+ */
+ @Test
+ public void testSystemTransformHasPendingPartialPassColumn() throws
Exception {
Review Comment:
This runs against the already-created current `SYSTEM.TRANSFORM`, so it
exercises the new CREATE DDL, not the
`TableAlreadyExistsException`/`addColumnsIfNotExists` upgrade path that the
test claims to cover. Add an upgrade test that starts with the pre-change table
definition/timestamp and verifies the upgrade adds the nullable column;
otherwise the first migration of this table remains untested.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]