This is an automated email from the ASF dual-hosted git repository.
lokiore pushed a commit to branch PHOENIX-7904-feature
in repository https://gitbox.apache.org/repos/asf/phoenix.git
The following commit(s) were added to refs/heads/PHOENIX-7904-feature by this
push:
new 87278a8865 PHOENIX-7906 :- Add UNKNOWN forward-compat sentinel for
TransformType (#2563)
87278a8865 is described below
commit 87278a886561a2f608152022138a525f27fad651
Author: Lokesh Khurana <[email protected]>
AuthorDate: Wed Aug 12 09:28:56 2026 -0700
PHOENIX-7906 :- Add UNKNOWN forward-compat sentinel for TransformType
(#2563)
* PHOENIX-7906 :- Add UNKNOWN forward-compat sentinel for TransformType
Add a TransformType.UNKNOWN((byte) -1) sentinel so a binary that reads a
SYSTEM.TRANSFORM row written by a newer binary (one that introduced a new
transform type this binary does not recognize) resolves the unknown
serialized
value to UNKNOWN instead of throwing or silently mis-mapping it.
fromSerializedValue
returns UNKNOWN for any unrecognized byte; UNKNOWN is never produced
through its own
serialized value on the normal write path.
Callers that can observe UNKNOWN now handle it defensively rather than
proceeding
against a record they cannot reason about:
- Transform.doCutover: skip with an operator-readable WARN and return, so
the
monitor task stays alive and other transforms are not starved.
- Transform.doForceCutover: the COMPLETED update + commit is factored into a
package-private finishForceCutover, which skips when the type is UNKNOWN
so an
unrun cutover is not falsely marked COMPLETED.
- TransformTool.validateTransform: fail fast with an actionable message
instructing
the operator to upgrade the binary or remove the row.
- TransformMonitorTask: skip the record (SKIPPED result) with an operator
message
via package-private skipIfUnknownTransformType.
Unit tests cover the enum resolution and each guard site.
Generated-by: Claude Code (Opus 4.8 (1M context))
---
.../java/org/apache/phoenix/schema/PTable.java | 37 +++++++-
.../phoenix/schema/transform/TransformClient.java | 25 +++++
.../coprocessor/tasks/TransformMonitorTask.java | 27 ++++++
.../phoenix/mapreduce/transform/TransformTool.java | 15 +++
.../apache/phoenix/schema/transform/Transform.java | 33 +++++++
.../tasks/TransformMonitorTaskUnknownTypeTest.java | 71 +++++++++++++++
...nsformToolValidateTransformUnknownTypeTest.java | 78 ++++++++++++++++
.../apache/phoenix/schema/TransformTypeTest.java | 101 +++++++++++++++++++++
.../transform/TransformClientUnknownTypeTest.java | 70 ++++++++++++++
.../TransformDoCutoverUnknownTypeTest.java | 52 +++++++++++
.../TransformForceCutoverUnknownTypeTest.java | 79 ++++++++++++++++
11 files changed, 584 insertions(+), 4 deletions(-)
diff --git
a/phoenix-core-client/src/main/java/org/apache/phoenix/schema/PTable.java
b/phoenix-core-client/src/main/java/org/apache/phoenix/schema/PTable.java
index effd7773ec..c5c3793d28 100644
--- a/phoenix-core-client/src/main/java/org/apache/phoenix/schema/PTable.java
+++ b/phoenix-core-client/src/main/java/org/apache/phoenix/schema/PTable.java
@@ -267,7 +267,16 @@ public interface PTable extends PMetaDataEntity {
public enum TransformType {
METADATA_TRANSFORM((byte) 1),
- METADATA_TRANSFORM_PARTIAL((byte) 2);
+ METADATA_TRANSFORM_PARTIAL((byte) 2),
+ /**
+ * Forward-compatibility sentinel returned by {@link
#fromSerializedValue(int)} when the
+ * serialized byte read from SYSTEM.TRANSFORM does not correspond to any
known
+ * {@code TransformType} constant. This allows an older binary to
deserialize a row written by a
+ * newer binary that introduced an additional transform type without
crashing the calling code
+ * path; callers that observe {@code UNKNOWN} must handle it defensively
(skip, block, or
+ * fail-fast with an operator-readable message) rather than treat it as a
known type.
+ */
+ UNKNOWN((byte) -1);
private final byte[] byteValue;
private final int serializedValue;
@@ -281,7 +290,18 @@ public interface PTable extends PMetaDataEntity {
return byteValue;
}
+ /**
+ * Returns the serialized byte value to persist in SYSTEM.TRANSFORM.
Throws if called on the
+ * UNKNOWN sentinel, which is a read-only forward-compatibility
placeholder and must never be
+ * written to storage. A newer binary introduced a transform type this
binary does not
+ * recognize.
+ */
public int getSerializedValue() {
+ if (this == UNKNOWN) {
+ throw new IllegalStateException(this.name()
+ + " is a read-only forward-compatibility sentinel and cannot be
serialized. "
+ + "This transform type was written by a newer binary and is not
recognized by this version.");
+ }
return this.serializedValue;
}
@@ -289,11 +309,20 @@ public interface PTable extends PMetaDataEntity {
return METADATA_TRANSFORM;
}
+ /**
+ * Resolve a serialized transform type to its enum constant. Returns
{@link #UNKNOWN} for any
+ * value that does not match a known constant (including the sentinel
value itself), so that
+ * rows written by a newer binary do not crash an older binary. The
sentinel constant
+ * {@code UNKNOWN} itself is never returned via its own serialized value
through normal write
+ * paths — it is only produced when the persisted value is unrecognized.
+ */
public static TransformType fromSerializedValue(int serializedValue) {
- if (serializedValue < 1 || serializedValue >
TransformType.values().length) {
- throw new IllegalArgumentException("Invalid TransformType " +
serializedValue);
+ for (TransformType type : TransformType.values()) {
+ if (type != UNKNOWN && type.serializedValue == serializedValue) {
+ return type;
+ }
}
- return TransformType.values()[serializedValue - 1];
+ return UNKNOWN;
}
public static TransformType getPartialTransform(TransformType
transformType) {
diff --git
a/phoenix-core-client/src/main/java/org/apache/phoenix/schema/transform/TransformClient.java
b/phoenix-core-client/src/main/java/org/apache/phoenix/schema/transform/TransformClient.java
index f37c414f5b..5b335b11de 100644
---
a/phoenix-core-client/src/main/java/org/apache/phoenix/schema/transform/TransformClient.java
+++
b/phoenix-core-client/src/main/java/org/apache/phoenix/schema/transform/TransformClient.java
@@ -152,6 +152,7 @@ public class TransformClient {
if (isTransformNeeded) {
SystemTransformRecord existingTransform =
getTransformRecord(schemaName, logicalTableName, parentTableName,
tenantId, connection);
+ enforceKnownTransformType(existingTransform, schemaName,
logicalTableName);
if (existingTransform != null && existingTransform.isActive()) {
throw new SQLExceptionInfo.Builder(
SQLExceptionCode.CANNOT_TRANSFORM_ALREADY_TRANSFORMING_TABLE)
@@ -162,6 +163,30 @@ public class TransformClient {
return isTransformNeeded;
}
+ /**
+ * Forward-compatibility guard: throw
+ * {@link SQLExceptionCode#CANNOT_TRANSFORM_ALREADY_TRANSFORMING_TABLE} when
an existing
+ * SYSTEM.TRANSFORM row carries a transform type this binary does not
recognise. Such a row was
+ * very likely written by a newer binary; this binary cannot reason about
whether the on-disk
+ * schema state is consistent with a new ALTER, so it blocks defensively
regardless of the row's
+ * status — even a {@code COMPLETED} row of an unknown type may have left
the schema in a state we
+ * do not understand. Package-private to allow direct unit testing without a
live SYSTEM.TRANSFORM
+ * table.
+ */
+ static void enforceKnownTransformType(SystemTransformRecord
existingTransform, String schemaName,
+ String logicalTableName) throws SQLException {
+ if (
+ existingTransform != null
+ && existingTransform.getTransformType() == PTable.TransformType.UNKNOWN
+ ) {
+ throw new SQLExceptionInfo.Builder(
+ SQLExceptionCode.CANNOT_TRANSFORM_ALREADY_TRANSFORMING_TABLE)
+ .setMessage(" Existing transform record has an unrecognized
transform type "
+ + "(likely written by a newer binary); blocking new transform on
this table ")
+
.setSchemaName(schemaName).setTableName(logicalTableName).build().buildException();
+ }
+ }
+
protected static SystemTransformRecord getTransformRecord(PhoenixConnection
connection,
PTableType tableType, PName schemaName, PName tableName, PName
dataTableName, PName tenantId,
PName parentLogicalName) throws SQLException {
diff --git
a/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/tasks/TransformMonitorTask.java
b/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/tasks/TransformMonitorTask.java
index bd808d5650..d58d070682 100644
---
a/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/tasks/TransformMonitorTask.java
+++
b/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/tasks/TransformMonitorTask.java
@@ -78,6 +78,14 @@ public class TransformMonitorTask extends BaseTask {
return new
TaskRegionObserver.TaskResult(TaskRegionObserver.TaskResultCode.FAIL,
"No transform record is found");
}
+ // Forward-compatibility guard: if the persisted transform type was
written by a newer binary
+ // that introduced a new TransformType constant, this binary will see
UNKNOWN. Skip the row
+ // without retrying — the monitor task cannot make a safe decision about
how to advance it.
+ TaskRegionObserver.TaskResult unknownTypeResult =
+ skipIfUnknownTransformType(systemTransformRecord);
+ if (unknownTypeResult != null) {
+ return unknownTypeResult;
+ }
String tableName =
SchemaUtil.getTableName(systemTransformRecord.getSchemaName(),
systemTransformRecord.getLogicalTableName());
@@ -218,4 +226,23 @@ public class TransformMonitorTask extends BaseTask {
// We don't need to check MR job result here since the job itself changes
task state.
return null;
}
+
+ /**
+ * If the supplied transform record carries an {@link
PTable.TransformType#UNKNOWN} transform type
+ * (typically because the row was written by a newer binary that introduced
a new transform type
+ * this binary does not understand), return a {@link
TaskRegionObserver.TaskResultCode#SKIPPED}
+ * result with an operator-readable message. Otherwise return {@code null}
to signal that normal
+ * processing should continue. Package-private to keep the gate
independently testable without
+ * standing up a full coprocessor environment.
+ */
+ static TaskRegionObserver.TaskResult
+ skipIfUnknownTransformType(SystemTransformRecord systemTransformRecord) {
+ if (systemTransformRecord.getTransformType() ==
PTable.TransformType.UNKNOWN) {
+ String msg = "Skipping transform monitor for record with UNKNOWN
transform type; "
+ + "likely written by a newer binary. " +
systemTransformRecord.getString();
+ LOGGER.warn(msg);
+ return new
TaskRegionObserver.TaskResult(TaskRegionObserver.TaskResultCode.SKIPPED, msg);
+ }
+ return null;
+ }
}
diff --git
a/phoenix-core-server/src/main/java/org/apache/phoenix/mapreduce/transform/TransformTool.java
b/phoenix-core-server/src/main/java/org/apache/phoenix/mapreduce/transform/TransformTool.java
index daca9a0461..53183e611a 100644
---
a/phoenix-core-server/src/main/java/org/apache/phoenix/mapreduce/transform/TransformTool.java
+++
b/phoenix-core-server/src/main/java/org/apache/phoenix/mapreduce/transform/TransformTool.java
@@ -440,6 +440,21 @@ public class TransformTool extends Configured implements
Tool {
"ALTER statement has not been run and the transform has not been
created for this table");
}
+ // Forward-compatibility guard: the persisted transform type does not
correspond to any
+ // TransformType known to this binary. Fail fast with an operator-readable
message rather than
+ // attempt a transform we cannot reason about. The operator must upgrade
this binary to a
+ // version that recognises the type, or remove the SYSTEM.TRANSFORM row,
before re-running.
+ if (transformRecord.getTransformType() == PTable.TransformType.UNKNOWN) {
+ throw new IllegalStateException(String.format(
+ "TransformTool cannot run for SYSTEM.TRANSFORM record with an
unrecognized transform type. "
+ + "This row was likely written by a newer Phoenix binary that
introduced a new "
+ + "TransformType this binary does not understand. Upgrade this
binary to a version that "
+ + "supports the new type, or remove the SYSTEM.TRANSFORM row, before
retrying. "
+ + "schema=%s, logicalTable=%s, newPhysicalTable=%s, tenantId=%s",
+ transformRecord.getSchemaName(), transformRecord.getLogicalTableName(),
+ transformRecord.getNewPhysicalTableName(),
transformRecord.getTenantId()));
+ }
+
if (pDataTable != null && pIndexTable != null) {
if (!IndexTool.isValidIndexTable(connection, qDataTable, indexTable,
tenantId)) {
throw new IllegalArgumentException(String
diff --git
a/phoenix-core-server/src/main/java/org/apache/phoenix/schema/transform/Transform.java
b/phoenix-core-server/src/main/java/org/apache/phoenix/schema/transform/Transform.java
index 86e3233831..d919e4a56f 100644
---
a/phoenix-core-server/src/main/java/org/apache/phoenix/schema/transform/Transform.java
+++
b/phoenix-core-server/src/main/java/org/apache/phoenix/schema/transform/Transform.java
@@ -138,6 +138,17 @@ public class Transform extends TransformClient {
*/
public static void doCutover(PhoenixConnection connection,
SystemTransformRecord systemTransformRecord) throws Exception {
+ // Forward-compatibility guard: a row written by a newer binary may carry
a transform type this
+ // binary does not recognize. Skipping the cutover (rather than throwing)
keeps the monitor task
+ // alive so that other transforms are not starved while the operator
decides how to act.
+ if (systemTransformRecord.getTransformType() ==
PTable.TransformType.UNKNOWN) {
+ LOGGER.warn(
+ "Skipping cutover for transform with UNKNOWN transform type (likely
written by a newer "
+ + "binary). Schema={}, logicalTable={}, newPhysicalTable={},
tenantId={}",
+ systemTransformRecord.getSchemaName(),
systemTransformRecord.getLogicalTableName(),
+ systemTransformRecord.getNewPhysicalTableName(),
systemTransformRecord.getTenantId());
+ return;
+ }
String tenantId = systemTransformRecord.getTenantId();
String schema = systemTransformRecord.getSchemaName();
String tableName = systemTransformRecord.getLogicalTableName();
@@ -507,6 +518,28 @@ public class Transform extends TransformClient {
SystemTransformRecord transformRecord = getTransformRecord(schemaName,
logicaTableName,
logicalParentName, tenantId, phoenixConnection);
Transform.doCutover(phoenixConnection, transformRecord);
+ finishForceCutover(phoenixConnection, transformRecord);
+ }
+
+ /**
+ * Marks the transform record COMPLETED and commits after a successful force
cutover. This must be
+ * skipped when the record carries {@link PTable.TransformType#UNKNOWN}: in
that case
+ * {@link #doCutover(PhoenixConnection, SystemTransformRecord)}
short-circuited without performing
+ * any cutover, so marking the record COMPLETED here would falsely report
success for a transform
+ * this binary could not run. Mirrors the skip-with-warn behaviour of the
other forward-compat
+ * guards. Package-private so the gate is independently testable without
standing up a Reducer.
+ */
+ static void finishForceCutover(PhoenixConnection phoenixConnection,
+ SystemTransformRecord transformRecord) throws SQLException {
+ if (transformRecord.getTransformType() == PTable.TransformType.UNKNOWN) {
+ LOGGER.warn(
+ "Skipping COMPLETED update for force cutover of transform with UNKNOWN
transform type "
+ + "(likely written by a newer binary); cutover did not run.
Schema={}, logicalTable={}, "
+ + "newPhysicalTable={}, tenantId={}",
+ transformRecord.getSchemaName(), transformRecord.getLogicalTableName(),
+ transformRecord.getNewPhysicalTableName(),
transformRecord.getTenantId());
+ return;
+ }
updateTransformRecord(phoenixConnection, transformRecord,
PTable.TransformStatus.COMPLETED);
phoenixConnection.commit();
}
diff --git
a/phoenix-core/src/test/java/org/apache/phoenix/coprocessor/tasks/TransformMonitorTaskUnknownTypeTest.java
b/phoenix-core/src/test/java/org/apache/phoenix/coprocessor/tasks/TransformMonitorTaskUnknownTypeTest.java
new file mode 100644
index 0000000000..a76c8c2f31
--- /dev/null
+++
b/phoenix-core/src/test/java/org/apache/phoenix/coprocessor/tasks/TransformMonitorTaskUnknownTypeTest.java
@@ -0,0 +1,71 @@
+/*
+ * 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.coprocessor.tasks;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import org.apache.phoenix.coprocessor.TaskRegionObserver;
+import org.apache.phoenix.schema.PTable;
+import org.apache.phoenix.schema.transform.SystemTransformRecord;
+import org.junit.Test;
+
+/**
+ * Unit test for the forward-compatibility gate in
+ * {@link
TransformMonitorTask#skipIfUnknownTransformType(SystemTransformRecord)}: when
the
+ * persisted transform type is unrecognised, the monitor task must
short-circuit with a SKIPPED
+ * result rather than treat the row as a known transform and either crash or
push it into a wrong
+ * state branch.
+ */
+public class TransformMonitorTaskUnknownTypeTest {
+
+ @Test
+ public void skipIfUnknownTransformTypeReturnsSkippedForUnknownType() {
+ SystemTransformRecord record = mock(SystemTransformRecord.class);
+ when(record.getTransformType()).thenReturn(PTable.TransformType.UNKNOWN);
+ when(record.getString()).thenReturn("schema=S logicalTable=T
newPhysical=S.T_2");
+
+ TaskRegionObserver.TaskResult result =
TransformMonitorTask.skipIfUnknownTransformType(record);
+
+ assertNotNull("UNKNOWN type must produce a non-null TaskResult", result);
+ assertEquals(TaskRegionObserver.TaskResultCode.SKIPPED,
result.getResultCode());
+ }
+
+ @Test
+ public void skipIfUnknownTransformTypeReturnsNullForKnownType() {
+ SystemTransformRecord record = mock(SystemTransformRecord.class);
+
when(record.getTransformType()).thenReturn(PTable.TransformType.METADATA_TRANSFORM);
+
+ TaskRegionObserver.TaskResult result =
TransformMonitorTask.skipIfUnknownTransformType(record);
+
+ assertNull("Known transform types must let normal processing proceed",
result);
+ }
+
+ @Test
+ public void skipIfUnknownTransformTypeReturnsNullForKnownPartialType() {
+ SystemTransformRecord record = mock(SystemTransformRecord.class);
+
when(record.getTransformType()).thenReturn(PTable.TransformType.METADATA_TRANSFORM_PARTIAL);
+
+ TaskRegionObserver.TaskResult result =
TransformMonitorTask.skipIfUnknownTransformType(record);
+
+ assertNull("Partial transform type must let normal processing proceed",
result);
+ }
+}
diff --git
a/phoenix-core/src/test/java/org/apache/phoenix/mapreduce/transform/TransformToolValidateTransformUnknownTypeTest.java
b/phoenix-core/src/test/java/org/apache/phoenix/mapreduce/transform/TransformToolValidateTransformUnknownTypeTest.java
new file mode 100644
index 0000000000..3ffd39dc3a
--- /dev/null
+++
b/phoenix-core/src/test/java/org/apache/phoenix/mapreduce/transform/TransformToolValidateTransformUnknownTypeTest.java
@@ -0,0 +1,78 @@
+/*
+ * 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.mapreduce.transform;
+
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import org.apache.phoenix.schema.PTable;
+import org.apache.phoenix.schema.PTableType;
+import org.apache.phoenix.schema.transform.SystemTransformRecord;
+import org.junit.Test;
+
+/**
+ * Unit test for the forward-compatibility fail-fast guard in
+ * {@link TransformTool#validateTransform}: when the SYSTEM.TRANSFORM record
carries an unrecognised
+ * transform type, the tool must refuse to run with an operator-readable
+ * {@link IllegalStateException} rather than attempt a transform it cannot
reason about.
+ */
+public class TransformToolValidateTransformUnknownTypeTest {
+
+ @Test
+ public void validateTransformFailsFastForUnknownTransformType() throws
Exception {
+ PTable dataTable = mock(PTable.class);
+ when(dataTable.getType()).thenReturn(PTableType.TABLE);
+ when(dataTable.isTransactional()).thenReturn(false);
+
+ SystemTransformRecord record = mock(SystemTransformRecord.class);
+ when(record.getTransformType()).thenReturn(PTable.TransformType.UNKNOWN);
+ when(record.getSchemaName()).thenReturn("S");
+ when(record.getLogicalTableName()).thenReturn("T");
+ when(record.getNewPhysicalTableName()).thenReturn("S.T_2");
+ when(record.getTenantId()).thenReturn(null);
+
+ TransformTool tool = new TransformTool();
+ try {
+ tool.validateTransform(dataTable, null, record);
+ fail("Expected IllegalStateException for UNKNOWN transform type");
+ } catch (IllegalStateException e) {
+ String msg = e.getMessage();
+ assertTrue("Expected operator-readable message; got: " + msg,
+ msg != null && msg.contains("unrecognized transform type"));
+ assertTrue("Expected message to mention the schema; got: " + msg,
msg.contains("S"));
+ assertTrue("Expected message to mention the logical table; got: " + msg,
msg.contains("T"));
+ }
+ }
+
+ @Test
+ public void validateTransformAllowsKnownTransformType() throws Exception {
+ PTable dataTable = mock(PTable.class);
+ when(dataTable.getType()).thenReturn(PTableType.TABLE);
+ when(dataTable.isTransactional()).thenReturn(false);
+
+ SystemTransformRecord record = mock(SystemTransformRecord.class);
+
when(record.getTransformType()).thenReturn(PTable.TransformType.METADATA_TRANSFORM);
+
+ TransformTool tool = new TransformTool();
+ // No exception — a known transform type passes the forward-compat guard.
The remaining
+ // index-table-shape checks inside validateTransform are skipped because
argIndexTable is null.
+ tool.validateTransform(dataTable, null, record);
+ }
+}
diff --git
a/phoenix-core/src/test/java/org/apache/phoenix/schema/TransformTypeTest.java
b/phoenix-core/src/test/java/org/apache/phoenix/schema/TransformTypeTest.java
new file mode 100644
index 0000000000..97a68c2669
--- /dev/null
+++
b/phoenix-core/src/test/java/org/apache/phoenix/schema/TransformTypeTest.java
@@ -0,0 +1,101 @@
+/*
+ * 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.schema;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThrows;
+
+import org.apache.phoenix.schema.PTable.TransformType;
+import org.junit.Test;
+
+/**
+ * Unit tests for {@link PTable.TransformType#fromSerializedValue(int)}.
Verifies that known
+ * serialized values still resolve to their constants and that unrecognised
values return the
+ * {@link PTable.TransformType#UNKNOWN} sentinel rather than throwing — this
is the forward-
+ * compatibility guarantee required when an older binary deserializes a
SYSTEM.TRANSFORM row written
+ * by a newer binary that introduced a new transform type.
+ */
+public class TransformTypeTest {
+
+ @Test
+ public void fromSerializedValueResolvesMetadataTransform() {
+ assertSame(TransformType.METADATA_TRANSFORM,
TransformType.fromSerializedValue(1));
+ }
+
+ @Test
+ public void fromSerializedValueResolvesMetadataTransformPartial() {
+ assertSame(TransformType.METADATA_TRANSFORM_PARTIAL,
TransformType.fromSerializedValue(2));
+ }
+
+ @Test
+ public void fromSerializedValueReturnsUnknownForFutureValue() {
+ // Simulates a SYSTEM.TRANSFORM row written by a newer binary that
introduced a third type.
+ TransformType resolved = TransformType.fromSerializedValue(3);
+ assertSame(TransformType.UNKNOWN, resolved);
+ }
+
+ @Test
+ public void fromSerializedValueReturnsUnknownForZero() {
+ // Zero is not a valid serialized value for any known transform type.
+ assertSame(TransformType.UNKNOWN, TransformType.fromSerializedValue(0));
+ }
+
+ @Test
+ public void fromSerializedValueReturnsUnknownForNegativeNonSentinel() {
+ assertSame(TransformType.UNKNOWN, TransformType.fromSerializedValue(-42));
+ }
+
+ @Test
+ public void unknownSentinelIsSelfConsistent() {
+ // The UNKNOWN constant's sentinel value (-1) resolves to UNKNOWN: the
lookup table only
+ // contains known constants, and the sentinel is the fallback for
everything else.
+ assertSame(TransformType.UNKNOWN, TransformType.fromSerializedValue(-1));
+ }
+
+ @Test
+ public void unknownHasNonCollidingSerializedValue() {
+ // No known type serializes to the UNKNOWN sentinel value (-1).
+ for (TransformType type : TransformType.values()) {
+ if (type != TransformType.UNKNOWN) {
+ assertFalse(type.getSerializedValue() == -1);
+ }
+ }
+ }
+
+ @Test
+ public void getDefaultIsNotUnknown() {
+ assertSame(TransformType.METADATA_TRANSFORM, TransformType.getDefault());
+ }
+
+ @Test
+ public void getSerializedValueThrowsForUnknown() {
+ // UNKNOWN is a read-only forward-compat sentinel and must never be
persisted.
+ assertThrows(IllegalStateException.class, () ->
TransformType.UNKNOWN.getSerializedValue());
+ }
+
+ @Test
+ public void knownTypesRoundTripThroughSerialization() {
+ // Each known type can be serialized and deserialized without loss.
+ for (TransformType type : TransformType.values()) {
+ if (type != TransformType.UNKNOWN) {
+ assertSame(type,
TransformType.fromSerializedValue(type.getSerializedValue()));
+ }
+ }
+ }
+}
diff --git
a/phoenix-core/src/test/java/org/apache/phoenix/schema/transform/TransformClientUnknownTypeTest.java
b/phoenix-core/src/test/java/org/apache/phoenix/schema/transform/TransformClientUnknownTypeTest.java
new file mode 100644
index 0000000000..6e15c1c0c3
--- /dev/null
+++
b/phoenix-core/src/test/java/org/apache/phoenix/schema/transform/TransformClientUnknownTypeTest.java
@@ -0,0 +1,70 @@
+/*
+ * 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.schema.transform;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.sql.SQLException;
+import org.apache.phoenix.exception.SQLExceptionCode;
+import org.apache.phoenix.schema.PTable;
+import org.junit.Test;
+
+/**
+ * Unit test for
+ * {@link TransformClient#enforceKnownTransformType(SystemTransformRecord,
String, String)} — the
+ * forward-compatibility gate used by {@link
TransformClient#checkIsTransformNeeded}. An existing
+ * SYSTEM.TRANSFORM row with an unrecognised transform type must defensively
block a new ALTER on
+ * the same table, regardless of the row's status, since this binary cannot
reason about whether the
+ * on-disk schema is in a state we understand.
+ */
+public class TransformClientUnknownTypeTest {
+
+ @Test
+ public void enforceKnownTransformTypeBlocksWhenExistingRecordIsUnknown() {
+ SystemTransformRecord record = mock(SystemTransformRecord.class);
+ when(record.getTransformType()).thenReturn(PTable.TransformType.UNKNOWN);
+
+ try {
+ TransformClient.enforceKnownTransformType(record, "S", "T");
+ fail("Expected SQLException for UNKNOWN transform type on existing
record");
+ } catch (SQLException e) {
+
assertEquals(SQLExceptionCode.CANNOT_TRANSFORM_ALREADY_TRANSFORMING_TABLE.getErrorCode(),
+ e.getErrorCode());
+ }
+ }
+
+ @Test
+ public void enforceKnownTransformTypeAllowsKnownType() throws SQLException {
+ SystemTransformRecord record = mock(SystemTransformRecord.class);
+
when(record.getTransformType()).thenReturn(PTable.TransformType.METADATA_TRANSFORM);
+
+ // Must not throw — a known transform type lets the rest of
checkIsTransformNeeded run its
+ // normal isActive() check.
+ TransformClient.enforceKnownTransformType(record, "S", "T");
+ }
+
+ @Test
+ public void enforceKnownTransformTypeAllowsNullExistingRecord() throws
SQLException {
+ // Null means there is no existing transform; the guard must be a no-op so
the caller can
+ // proceed to create a new transform.
+ TransformClient.enforceKnownTransformType(null, "S", "T");
+ }
+}
diff --git
a/phoenix-core/src/test/java/org/apache/phoenix/schema/transform/TransformDoCutoverUnknownTypeTest.java
b/phoenix-core/src/test/java/org/apache/phoenix/schema/transform/TransformDoCutoverUnknownTypeTest.java
new file mode 100644
index 0000000000..b4f8e09ebe
--- /dev/null
+++
b/phoenix-core/src/test/java/org/apache/phoenix/schema/transform/TransformDoCutoverUnknownTypeTest.java
@@ -0,0 +1,52 @@
+/*
+ * 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.schema.transform;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import org.apache.phoenix.jdbc.PhoenixConnection;
+import org.apache.phoenix.schema.PTable;
+import org.junit.Test;
+
+/**
+ * Unit test for the forward-compatibility guard at the front of
+ * {@link Transform#doCutover(PhoenixConnection, SystemTransformRecord)}: when
the record carries
+ * {@link PTable.TransformType#UNKNOWN}, the cutover must short-circuit with a
warn log and perform
+ * no further work against the connection.
+ */
+public class TransformDoCutoverUnknownTypeTest {
+
+ @Test
+ public void doCutoverSkipsAndDoesNotTouchConnectionWhenTypeIsUnknown()
throws Exception {
+ PhoenixConnection conn = mock(PhoenixConnection.class);
+ SystemTransformRecord record = mock(SystemTransformRecord.class);
+ when(record.getTransformType()).thenReturn(PTable.TransformType.UNKNOWN);
+ when(record.getSchemaName()).thenReturn("S");
+ when(record.getLogicalTableName()).thenReturn("T");
+ when(record.getNewPhysicalTableName()).thenReturn("S.T_2");
+ when(record.getTenantId()).thenReturn(null);
+
+ Transform.doCutover(conn, record);
+
+ // The early return must not have triggered any SQL or DDL via the
connection. Any interaction
+ // here would mean the guard let the cutover proceed against a record we
cannot reason about.
+ verifyNoInteractions(conn);
+ }
+}
diff --git
a/phoenix-core/src/test/java/org/apache/phoenix/schema/transform/TransformForceCutoverUnknownTypeTest.java
b/phoenix-core/src/test/java/org/apache/phoenix/schema/transform/TransformForceCutoverUnknownTypeTest.java
new file mode 100644
index 0000000000..627231264b
--- /dev/null
+++
b/phoenix-core/src/test/java/org/apache/phoenix/schema/transform/TransformForceCutoverUnknownTypeTest.java
@@ -0,0 +1,79 @@
+/*
+ * 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.schema.transform;
+
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.sql.PreparedStatement;
+import org.apache.phoenix.jdbc.PhoenixConnection;
+import org.apache.phoenix.schema.PTable;
+import org.junit.Test;
+
+/**
+ * Unit test for the forward-compatibility guard in
+ * {@link Transform#finishForceCutover(PhoenixConnection,
SystemTransformRecord)}, the completion
+ * step of {@code doForceCutover}. When the record carries {@link
PTable.TransformType#UNKNOWN} the
+ * preceding cutover short-circuits, so this step must NOT mark the record
COMPLETED and must NOT
+ * commit; otherwise a transform this binary could not run would be falsely
reported as finished. A
+ * recognized transform type must still be marked COMPLETED and committed.
+ */
+public class TransformForceCutoverUnknownTypeTest {
+
+ @Test
+ public void finishForceCutoverDoesNotCompleteOrCommitWhenTypeIsUnknown()
throws Exception {
+ PhoenixConnection conn = mock(PhoenixConnection.class);
+ SystemTransformRecord record = mock(SystemTransformRecord.class);
+ when(record.getTransformType()).thenReturn(PTable.TransformType.UNKNOWN);
+ when(record.getSchemaName()).thenReturn("S");
+ when(record.getLogicalTableName()).thenReturn("T");
+ when(record.getNewPhysicalTableName()).thenReturn("S.T_2");
+ when(record.getTenantId()).thenReturn(null);
+
+ Transform.finishForceCutover(conn, record);
+
+ // No COMPLETED upsert and no commit may reach the connection: any
interaction would mean the
+ // record was being marked COMPLETED despite the cutover having been
skipped.
+ verifyNoInteractions(conn);
+ }
+
+ @Test
+ public void finishForceCutoverCompletesAndCommitsForKnownType() throws
Exception {
+ PhoenixConnection conn = mock(PhoenixConnection.class);
+ PreparedStatement stmt = mock(PreparedStatement.class);
+ when(conn.prepareStatement(anyString())).thenReturn(stmt);
+
+ SystemTransformRecord record = mock(SystemTransformRecord.class);
+
when(record.getTransformType()).thenReturn(PTable.TransformType.METADATA_TRANSFORM);
+ when(record.getSchemaName()).thenReturn("S");
+ when(record.getLogicalTableName()).thenReturn("T");
+ when(record.getNewPhysicalTableName()).thenReturn("S.T_2");
+ when(record.getTenantId()).thenReturn(null);
+
+ Transform.finishForceCutover(conn, record);
+
+ // The guard must not interfere with the normal path: the record is
upserted (COMPLETED) and
+ // the transaction is committed.
+ verify(conn).prepareStatement(anyString());
+ verify(conn, times(1)).commit();
+ }
+}