This is an automated email from the ASF dual-hosted git repository.

xiangfu0 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git


The following commit(s) were added to refs/heads/master by this push:
     new ff1981c45f4 Use PostgreSQL function volatility for ingestion 
transforms (#18932)
ff1981c45f4 is described below

commit ff1981c45f4f8724d558541463f78d970b254ff6
Author: Xiang Fu <[email protected]>
AuthorDate: Tue Jul 28 23:03:16 2026 -0700

    Use PostgreSQL function volatility for ingestion transforms (#18932)
---
 .../apache/pinot/common/function/FunctionInfo.java |  39 +++++++
 .../pinot/common/function/FunctionRegistry.java    |   4 +-
 .../pinot/common/function/PinotScalarFunction.java |   6 +-
 .../function/scalar/ArithmeticFunctions.java       |   3 +-
 .../common/function/scalar/DateTimeFunctions.java  |   9 +-
 .../common/function/scalar/InternalFunctions.java  |  12 +-
 .../pinot/common/function/FunctionUtilsTest.java   | 124 +++++++++++++++++++++
 .../api/resources/PinotSchemaRestletResource.java  |   3 +-
 .../api/resources/PinotTableRestletResource.java   |  37 +++---
 .../api/resources/TableConfigValidationUtils.java  |   9 +-
 .../api/resources/TableConfigsRestletResource.java |   7 +-
 .../api/PinotSchemaRestletResourceTest.java        |  41 +++++++
 .../api/PinotTableRestletResourceTest.java         |  63 +++++++++++
 .../api/TableConfigsRestletResourceTest.java       | 103 +++++++++++++++++
 .../apache/pinot/core/util/SchemaUtilsTest.java    |  48 ++++++++
 .../tests/custom/CLPEncodingRealtimeTest.java      |   2 +-
 .../pinot/query/QueryPlannerRuleOptionsTest.java   |   7 +-
 .../pinot/segment/local/utils/SchemaUtils.java     |  43 ++++++-
 .../segment/local/utils/TableConfigUtils.java      | 115 ++++++++++++++++++-
 .../ExpressionTransformerTest.java                 |  26 +++++
 .../segment/local/utils/TableConfigUtilsTest.java  | 100 +++++++++++++++++
 .../pinot/spi/annotations/FunctionVolatility.java  |  37 ++++++
 .../pinot/spi/annotations/ScalarFunction.java      |  16 ++-
 .../fineFoodReviews_realtime_table_config.json     |   2 +-
 ...neFoodReviews_part_0_realtime_table_config.json |   2 +-
 ...neFoodReviews_part_1_realtime_table_config.json |   2 +-
 26 files changed, 812 insertions(+), 48 deletions(-)

diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/function/FunctionInfo.java 
b/pinot-common/src/main/java/org/apache/pinot/common/function/FunctionInfo.java
index 16cd3997bb5..e2236d2736b 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/function/FunctionInfo.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/function/FunctionInfo.java
@@ -19,6 +19,8 @@
 package org.apache.pinot.common.function;
 
 import java.lang.reflect.Method;
+import java.util.Objects;
+import org.apache.pinot.spi.annotations.FunctionVolatility;
 import org.apache.pinot.spi.annotations.ScalarFunction;
 
 
@@ -27,16 +29,24 @@ public class FunctionInfo {
   private final Class<?> _clazz;
   private final boolean _nullableParameters;
   private final boolean _deterministic;
+  private final FunctionVolatility _volatility;
 
   public FunctionInfo(Method method, Class<?> clazz, boolean 
nullableParameters) {
     this(method, clazz, nullableParameters, true);
   }
 
   public FunctionInfo(Method method, Class<?> clazz, boolean 
nullableParameters, boolean deterministic) {
+    this(method, clazz, nullableParameters, deterministic,
+        deterministic ? resolveVolatility(method, clazz) : 
FunctionVolatility.VOLATILE);
+  }
+
+  public FunctionInfo(Method method, Class<?> clazz, boolean 
nullableParameters, boolean deterministic,
+      FunctionVolatility volatility) {
     _method = method;
     _clazz = clazz;
     _nullableParameters = nullableParameters;
     _deterministic = deterministic;
+    _volatility = Objects.requireNonNull(volatility, "volatility must not be 
null");
   }
 
   public Method getMethod() {
@@ -55,10 +65,39 @@ public class FunctionInfo {
     return _deterministic;
   }
 
+  public FunctionVolatility getVolatility() {
+    return _volatility;
+  }
+
   public static FunctionInfo fromMethod(Method method) {
     ScalarFunction annotation = method.getAnnotation(ScalarFunction.class);
     boolean nullableParameters = annotation != null && 
annotation.nullableParameters();
     boolean deterministic = annotation == null || annotation.isDeterministic();
     return new FunctionInfo(method, method.getDeclaringClass(), 
nullableParameters, deterministic);
   }
+
+  private static FunctionVolatility resolveVolatility(Method method, Class<?> 
clazz) {
+    ScalarFunction methodAnnotation = 
method.getAnnotation(ScalarFunction.class);
+    ScalarFunction classAnnotation = clazz.getAnnotation(ScalarFunction.class);
+    FunctionVolatility methodVolatility = getVolatility(methodAnnotation);
+    FunctionVolatility classVolatility = getVolatility(classAnnotation);
+    return mostVolatile(methodVolatility, classVolatility);
+  }
+
+  private static FunctionVolatility getVolatility(ScalarFunction annotation) {
+    if (annotation == null) {
+      return FunctionVolatility.IMMUTABLE;
+    }
+    return annotation.isDeterministic() ? annotation.volatility() : 
FunctionVolatility.VOLATILE;
+  }
+
+  private static FunctionVolatility mostVolatile(FunctionVolatility first, 
FunctionVolatility second) {
+    if (first == FunctionVolatility.VOLATILE || second == 
FunctionVolatility.VOLATILE) {
+      return FunctionVolatility.VOLATILE;
+    }
+    if (first == FunctionVolatility.STABLE || second == 
FunctionVolatility.STABLE) {
+      return FunctionVolatility.STABLE;
+    }
+    return FunctionVolatility.IMMUTABLE;
+  }
 }
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/function/FunctionRegistry.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/function/FunctionRegistry.java
index 395c49e8ca8..69fd3f190a8 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/function/FunctionRegistry.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/function/FunctionRegistry.java
@@ -128,9 +128,7 @@ public class FunctionRegistry {
       }
       ScalarFunction scalarFunction = 
method.getAnnotation(ScalarFunction.class);
       if (scalarFunction.enabled()) {
-        FunctionInfo functionInfo =
-            new FunctionInfo(method, method.getDeclaringClass(), 
scalarFunction.nullableParameters(),
-                scalarFunction.isDeterministic());
+        FunctionInfo functionInfo = FunctionInfo.fromMethod(method);
         int numArguments = scalarFunction.isVarArg() ? VAR_ARG_KEY : 
method.getParameterCount();
         String[] names = scalarFunction.names();
         if (names.length == 0) {
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/function/PinotScalarFunction.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/function/PinotScalarFunction.java
index 1074d6c4f3e..47aac639d55 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/function/PinotScalarFunction.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/function/PinotScalarFunction.java
@@ -96,7 +96,11 @@ public interface PinotScalarFunction {
   static PinotScalarFunction fromMethod(Method method, boolean isVarArg, 
boolean supportNullArgs,
       @Nullable String... names) {
     int numArguments = isVarArg ? FunctionRegistry.VAR_ARG_KEY : 
method.getParameterCount();
-    FunctionInfo functionInfo = new FunctionInfo(method, 
method.getDeclaringClass(), supportNullArgs);
+    FunctionInfo annotationFunctionInfo = FunctionInfo.fromMethod(method);
+    // Preserve the historical dynamic UDF compile-time policy while 
propagating volatility independently.
+    FunctionInfo functionInfo =
+        new FunctionInfo(method, method.getDeclaringClass(), supportNullArgs, 
true,
+            annotationFunctionInfo.getVolatility());
     Map<Integer, FunctionInfo> functionInfoMap = Map.of(numArguments, 
functionInfo);
 
     List<String> nameList = names != null && names.length > 0
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/function/scalar/ArithmeticFunctions.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/function/scalar/ArithmeticFunctions.java
index d23b4fb62ed..693cfc94275 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/function/scalar/ArithmeticFunctions.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/function/scalar/ArithmeticFunctions.java
@@ -21,6 +21,7 @@ package org.apache.pinot.common.function.scalar;
 import java.math.BigDecimal;
 import java.math.RoundingMode;
 import java.util.concurrent.ThreadLocalRandom;
+import org.apache.pinot.spi.annotations.FunctionVolatility;
 import org.apache.pinot.spi.annotations.ScalarFunction;
 
 
@@ -155,7 +156,7 @@ public class ArithmeticFunctions {
     return Math.signum(a) * Math.floor(Math.abs(a));
   }
 
-  @ScalarFunction(isDeterministic = false)
+  @ScalarFunction(isDeterministic = false, volatility = 
FunctionVolatility.VOLATILE)
   public static double rand() {
     return ThreadLocalRandom.current().nextDouble();
   }
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/function/scalar/DateTimeFunctions.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/function/scalar/DateTimeFunctions.java
index 4da99e84cb3..dc498c48c21 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/function/scalar/DateTimeFunctions.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/function/scalar/DateTimeFunctions.java
@@ -27,6 +27,7 @@ import 
org.apache.pinot.common.function.DateTimePatternHandler;
 import org.apache.pinot.common.function.DateTimeUtils;
 import org.apache.pinot.common.function.FunctionUtils;
 import org.apache.pinot.common.function.TimeZoneKey;
+import org.apache.pinot.spi.annotations.FunctionVolatility;
 import org.apache.pinot.spi.annotations.ScalarFunction;
 import org.apache.pinot.spi.utils.TimeUtils;
 import org.joda.time.DateTime;
@@ -573,12 +574,12 @@ public class DateTimeFunctions {
    * Return current time as epoch millis
    * TODO: Consider changing the return type to Timestamp
    */
-  @ScalarFunction
+  @ScalarFunction(volatility = FunctionVolatility.VOLATILE)
   public static long now() {
     return System.currentTimeMillis();
   }
 
-  @ScalarFunction
+  @ScalarFunction(volatility = FunctionVolatility.VOLATILE)
   public static long sleep(long millis) {
     try {
       if (FunctionUtils.isAssertEnabled()) {
@@ -603,13 +604,13 @@ public class DateTimeFunctions {
    *           "-P6H3M"    -- parses as "-6 hours and -3 minutes"
    *           "-P-6H+3M"  -- parses as "+6 hours and -3 minutes"
    */
-  @ScalarFunction
+  @ScalarFunction(volatility = FunctionVolatility.VOLATILE)
   public static long ago(String periodString) {
     Duration period = Duration.parse(periodString);
     return System.currentTimeMillis() - period.toMillis();
   }
 
-  @ScalarFunction
+  @ScalarFunction(volatility = FunctionVolatility.VOLATILE)
   public static long[] agoMV(String[] periodString) {
     long[] results = new long[periodString.length];
     for (int i = 0; i < periodString.length; i++) {
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/function/scalar/InternalFunctions.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/function/scalar/InternalFunctions.java
index c2b2c0e4bee..235c861b990 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/function/scalar/InternalFunctions.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/function/scalar/InternalFunctions.java
@@ -18,10 +18,12 @@
  */
 package org.apache.pinot.common.function.scalar;
 
+import org.apache.pinot.spi.annotations.FunctionVolatility;
 import org.apache.pinot.spi.annotations.ScalarFunction;
 import org.apache.pinot.spi.query.QueryThreadContext;
 
 
+@ScalarFunction(enabled = false, volatility = FunctionVolatility.STABLE)
 public class InternalFunctions {
   private InternalFunctions() {
   }
@@ -33,7 +35,7 @@ public class InternalFunctions {
   /// input.
   ///
   /// This is mostly useful for test and internal usage
-  @ScalarFunction
+  @ScalarFunction(volatility = FunctionVolatility.VOLATILE)
   public static String cid(String input) {
     return QueryThreadContext.get().getExecutionContext().getCid();
   }
@@ -57,7 +59,7 @@ public class InternalFunctions {
   /// input.
   ///
   /// This is mostly useful for test and internal usage and should be close to 
now()
-  @ScalarFunction
+  @ScalarFunction(volatility = FunctionVolatility.VOLATILE)
   public static long startTime(String input) {
     return QueryThreadContext.get().getExecutionContext().getStartTimeMs();
   }
@@ -69,7 +71,7 @@ public class InternalFunctions {
   /// input.
   ///
   /// This is mostly useful for test and internal usage
-  @ScalarFunction
+  @ScalarFunction(volatility = FunctionVolatility.VOLATILE)
   public static long endTime(String input) {
     return 
QueryThreadContext.get().getExecutionContext().getActiveDeadlineMs();
   }
@@ -102,7 +104,7 @@ public class InternalFunctions {
   /// input.
   ///
   /// This is mostly useful for test and internal usage
-  @ScalarFunction
+  @ScalarFunction(volatility = FunctionVolatility.VOLATILE)
   public static int stageId(String input) {
     QueryThreadContext.MseWorkerInfo mseWorkerInfo = 
QueryThreadContext.get().getMseWorkerInfo();
     return mseWorkerInfo != null ? mseWorkerInfo.getStageId() : -1;
@@ -116,7 +118,7 @@ public class InternalFunctions {
   /// input.
   ///
   /// This is mostly useful for test and internal usage
-  @ScalarFunction
+  @ScalarFunction(volatility = FunctionVolatility.VOLATILE)
   public static int workerId(String input) {
     QueryThreadContext.MseWorkerInfo mseWorkerInfo = 
QueryThreadContext.get().getMseWorkerInfo();
     return mseWorkerInfo != null ? mseWorkerInfo.getWorkerId() : -1;
diff --git 
a/pinot-common/src/test/java/org/apache/pinot/common/function/FunctionUtilsTest.java
 
b/pinot-common/src/test/java/org/apache/pinot/common/function/FunctionUtilsTest.java
index ef87afa6687..3d1b6a61e3c 100644
--- 
a/pinot-common/src/test/java/org/apache/pinot/common/function/FunctionUtilsTest.java
+++ 
b/pinot-common/src/test/java/org/apache/pinot/common/function/FunctionUtilsTest.java
@@ -26,14 +26,44 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.UUID;
 import org.apache.pinot.common.utils.DataSchema.ColumnDataType;
+import org.apache.pinot.spi.annotations.FunctionVolatility;
+import org.apache.pinot.spi.annotations.ScalarFunction;
 import org.apache.pinot.spi.utils.PinotDataType;
 import org.testng.annotations.Test;
 
 import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNotNull;
 import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertTrue;
 
 
 public class FunctionUtilsTest {
+  /**
+   * Test fixture for class-level function volatility.
+   */
+  @ScalarFunction(enabled = false, volatility = FunctionVolatility.STABLE)
+  private static class AnnotatedFunction {
+    @ScalarFunction(enabled = false)
+    public static long classAnnotated() {
+      return 0L;
+    }
+  }
+
+  /**
+   * Test fixture for method-level and legacy volatility metadata.
+   */
+  private static class VolatilityAnnotatedFunction {
+    @ScalarFunction(enabled = false, isDeterministic = false)
+    public static long legacyVolatile() {
+      return 0L;
+    }
+
+    @ScalarFunction(enabled = false, volatility = FunctionVolatility.STABLE)
+    public static long stable() {
+      return 0L;
+    }
+  }
 
   @Test
   public void testGetArgumentType() {
@@ -56,6 +86,100 @@ public class FunctionUtilsTest {
     assertEquals(FunctionUtils.getArgumentType((short) 1), 
PinotDataType.SHORT);
   }
 
+  @Test
+  public void testFunctionVolatilityMetadata() {
+    FunctionInfo now = FunctionRegistry.lookupFunctionInfo("now", 0);
+    assertTrue(now.isDeterministic());
+    assertEquals(now.getVolatility(), FunctionVolatility.VOLATILE);
+
+    FunctionInfo ago = FunctionRegistry.lookupFunctionInfo("ago", 1);
+    assertTrue(ago.isDeterministic());
+    assertEquals(ago.getVolatility(), FunctionVolatility.VOLATILE);
+
+    FunctionInfo agoMv = FunctionRegistry.lookupFunctionInfo("agomv", 1);
+    assertTrue(agoMv.isDeterministic());
+    assertEquals(agoMv.getVolatility(), FunctionVolatility.VOLATILE);
+
+    FunctionInfo random = FunctionRegistry.lookupFunctionInfo("rand", 0);
+    assertFalse(random.isDeterministic());
+    assertEquals(random.getVolatility(), FunctionVolatility.VOLATILE);
+
+    FunctionInfo seededRandom = FunctionRegistry.lookupFunctionInfo("rand", 1);
+    assertTrue(seededRandom.isDeterministic());
+    assertEquals(seededRandom.getVolatility(), FunctionVolatility.IMMUTABLE);
+
+    FunctionInfo sleep = FunctionRegistry.lookupFunctionInfo("sleep", 1);
+    assertTrue(sleep.isDeterministic());
+    assertEquals(sleep.getVolatility(), FunctionVolatility.VOLATILE);
+
+    FunctionInfo cid = FunctionRegistry.lookupFunctionInfo("cid", 1);
+    assertTrue(cid.isDeterministic());
+    assertEquals(cid.getVolatility(), FunctionVolatility.VOLATILE);
+
+    FunctionInfo requestId = FunctionRegistry.lookupFunctionInfo("reqid", 1);
+    assertTrue(requestId.isDeterministic());
+    assertEquals(requestId.getVolatility(), FunctionVolatility.STABLE);
+
+    FunctionInfo startTime = FunctionRegistry.lookupFunctionInfo("starttime", 
1);
+    assertTrue(startTime.isDeterministic());
+    assertEquals(startTime.getVolatility(), FunctionVolatility.VOLATILE);
+
+    FunctionInfo workerId = FunctionRegistry.lookupFunctionInfo("workerid", 1);
+    assertTrue(workerId.isDeterministic());
+    assertEquals(workerId.getVolatility(), FunctionVolatility.VOLATILE);
+  }
+
+  @Test
+  public void testFunctionVolatilityResolution()
+      throws NoSuchMethodException {
+    FunctionInfo classAnnotated =
+        
FunctionInfo.fromMethod(AnnotatedFunction.class.getMethod("classAnnotated"));
+    assertTrue(classAnnotated.isDeterministic());
+    assertEquals(classAnnotated.getVolatility(), FunctionVolatility.STABLE);
+
+    FunctionInfo classAnnotationAwareConstructor =
+        new FunctionInfo(AnnotatedFunction.class.getMethod("classAnnotated"), 
AnnotatedFunction.class, false);
+    assertTrue(classAnnotationAwareConstructor.isDeterministic());
+    assertEquals(classAnnotationAwareConstructor.getVolatility(), 
FunctionVolatility.STABLE);
+
+    FunctionInfo legacyVolatile =
+        
FunctionInfo.fromMethod(VolatilityAnnotatedFunction.class.getMethod("legacyVolatile"));
+    assertFalse(legacyVolatile.isDeterministic());
+    assertEquals(legacyVolatile.getVolatility(), FunctionVolatility.VOLATILE);
+
+    FunctionInfo annotationAwareConstructor =
+        new 
FunctionInfo(VolatilityAnnotatedFunction.class.getMethod("legacyVolatile"),
+            VolatilityAnnotatedFunction.class, false, true);
+    assertTrue(annotationAwareConstructor.isDeterministic());
+    assertEquals(annotationAwareConstructor.getVolatility(), 
FunctionVolatility.VOLATILE);
+
+    FunctionInfo deterministicOverride =
+        new FunctionInfo(VolatilityAnnotatedFunction.class.getMethod("stable"),
+            VolatilityAnnotatedFunction.class, false, false);
+    assertFalse(deterministicOverride.isDeterministic());
+    assertEquals(deterministicOverride.getVolatility(), 
FunctionVolatility.VOLATILE);
+
+    FunctionInfo explicitVolatilityOverride = new FunctionInfo(
+        AnnotatedFunction.class.getMethod("classAnnotated"), 
AnnotatedFunction.class, false, true,
+        FunctionVolatility.IMMUTABLE);
+    assertTrue(explicitVolatilityOverride.isDeterministic());
+    assertEquals(explicitVolatilityOverride.getVolatility(), 
FunctionVolatility.IMMUTABLE);
+
+    PinotScalarFunction scalarFunction =
+        
PinotScalarFunction.fromMethod(VolatilityAnnotatedFunction.class.getMethod("stable"),
 false, true);
+    FunctionInfo dynamicFunctionInfo = scalarFunction.getFunctionInfo(0);
+    assertNotNull(dynamicFunctionInfo);
+    assertTrue(dynamicFunctionInfo.isDeterministic());
+    assertEquals(dynamicFunctionInfo.getVolatility(), 
FunctionVolatility.STABLE);
+
+    PinotScalarFunction legacyDynamicFunction =
+        
PinotScalarFunction.fromMethod(VolatilityAnnotatedFunction.class.getMethod("legacyVolatile"),
 false, true);
+    FunctionInfo legacyDynamicFunctionInfo = 
legacyDynamicFunction.getFunctionInfo(0);
+    assertNotNull(legacyDynamicFunctionInfo);
+    assertTrue(legacyDynamicFunctionInfo.isDeterministic());
+    assertEquals(legacyDynamicFunctionInfo.getVolatility(), 
FunctionVolatility.VOLATILE);
+  }
+
   @Test
   public void testGetArgumentTypeForVendorTimestampSubclass() {
     // Vendor JDBC drivers commonly return Timestamp subclasses (e.g. BigQuery 
Simba's TimestampTz).
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotSchemaRestletResource.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotSchemaRestletResource.java
index 7cf819a9cd6..f7bc6d349ef 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotSchemaRestletResource.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotSchemaRestletResource.java
@@ -409,7 +409,8 @@ public class PinotSchemaRestletResource {
     try {
       List<TableConfig> tableConfigs = 
_pinotHelixResourceManager.getTableConfigsForSchema(schema.getSchemaName());
       boolean isIgnoreCase = 
_pinotHelixResourceManager.getTableCache().isIgnoreCase();
-      SchemaUtils.validate(schema, tableConfigs, isIgnoreCase);
+      Schema existingSchema = 
_pinotHelixResourceManager.getSchema(schema.getSchemaName());
+      SchemaUtils.validate(schema, tableConfigs, isIgnoreCase, existingSchema);
     } catch (Exception e) {
       throw new ControllerApplicationException(LOGGER,
           "Invalid schema: " + schema.getSchemaName() + ". Reason: " + 
e.getMessage(), Response.Status.BAD_REQUEST, e);
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotTableRestletResource.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotTableRestletResource.java
index 035c36a1243..550fc3e132f 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotTableRestletResource.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotTableRestletResource.java
@@ -339,15 +339,24 @@ public class PinotTableRestletResource {
 
       boolean hasOffline = tableConfigNode.has(TableType.OFFLINE.name());
       boolean hasRealtime = tableConfigNode.has(TableType.REALTIME.name());
-      if (hasOffline && !hasRealtime) {
-        throw new IllegalStateException("pure offline table copy not supported 
yet");
-      }
+      TableConfig realtimeTableConfig;
+      try {
+        if (hasOffline && !hasRealtime) {
+          throw new IllegalStateException("pure offline table copy not 
supported yet");
+        }
 
-      ObjectNode realtimeTableConfigNode = (ObjectNode) 
tableConfigNode.get(TableType.REALTIME.name());
-      tweakRealtimeTableConfig(realtimeTableConfigNode, copyTablePayload);
-      TableConfig realtimeTableConfig = 
JsonUtils.jsonNodeToObject(realtimeTableConfigNode, TableConfig.class);
-      if (realtimeTableConfig.getUpsertConfig() != null) {
-        throw new IllegalStateException("upsert table copy not supported");
+        ObjectNode realtimeTableConfigNode = (ObjectNode) 
tableConfigNode.get(TableType.REALTIME.name());
+        tweakRealtimeTableConfig(realtimeTableConfigNode, copyTablePayload);
+        realtimeTableConfig = 
JsonUtils.jsonNodeToObject(realtimeTableConfigNode, TableConfig.class);
+        if (realtimeTableConfig.getUpsertConfig() != null) {
+          throw new IllegalStateException("upsert table copy not supported");
+        }
+        // Run the complete validation stack before dry-run returns and before 
the schema is persisted. This also
+        // prevents copying a legacy non-immutable transform into a new 
ingestion pipeline.
+        TableConfigValidationUtils.validateTableConfig(
+            realtimeTableConfig, schema, null, _pinotHelixResourceManager, 
_controllerConf, _pinotTaskManager);
+      } catch (ConfigValidationException | IllegalArgumentException | 
IllegalStateException e) {
+        throw new ControllerApplicationException(LOGGER, e.getMessage(), 
Response.Status.BAD_REQUEST, e);
       }
       LOGGER.info("[copyTable] Successfully fetched and tweaked table config 
for table: {}", tableName);
 
@@ -360,8 +369,6 @@ public class PinotTableRestletResource {
 
       _pinotHelixResourceManager.addSchema(schema, true, false);
       LOGGER.info("[copyTable] Successfully added schema for table: {}", 
tableName);
-      TableConfigValidationUtils.validateTableConfig(
-          realtimeTableConfig, schema, null, _pinotHelixResourceManager, 
_controllerConf, _pinotTaskManager);
       // Add the table with designated starting kafka offset and segment 
sequence number to create consuming segments
       _pinotHelixResourceManager.addTable(realtimeTableConfig, 
streamMetadataList);
       LOGGER.info("[copyTable] Successfully added table config: {} with 
designated high watermark", tableName);
@@ -376,8 +383,8 @@ public class PinotTableRestletResource {
         response.setWatermarkInductionResult(watermarkInductionResult);
       }
       return response;
-    } catch (ConfigValidationException e) {
-      throw new ControllerApplicationException(LOGGER, e.getMessage(), 
Response.Status.BAD_REQUEST, e);
+    } catch (ControllerApplicationException e) {
+      throw e;
     } catch (Exception e) {
       LOGGER.error("[copyTable] Error copying table: {}", tableName, e);
       throw new ControllerApplicationException(LOGGER, "Error copying table: " 
+ e.getMessage(),
@@ -795,7 +802,8 @@ public class PinotTableRestletResource {
       schema = _pinotHelixResourceManager.getTableSchema(tableNameWithType);
       Preconditions.checkState(schema != null, "Failed to find schema for 
table: %s", tableNameWithType);
       TableConfigValidationUtils.validateTableConfig(
-          tableConfig, schema, typesToSkip, _pinotHelixResourceManager, 
_controllerConf, _pinotTaskManager);
+          tableConfig, schema, typesToSkip, _pinotHelixResourceManager, 
_controllerConf, _pinotTaskManager,
+          _pinotHelixResourceManager.getTableConfig(tableNameWithType));
     } catch (Exception e) {
       String msg = String.format("Invalid table config: %s with error: %s", 
tableName, e.getMessage());
       throw new ControllerApplicationException(LOGGER, msg, 
Response.Status.BAD_REQUEST, e);
@@ -864,7 +872,8 @@ public class PinotTableRestletResource {
       if (schema == null) {
         throw new SchemaNotFoundException("Failed to find schema for table: " 
+ tableNameWithType);
       }
-      TableConfigUtils.validate(tableConfig, schema, typesToSkip);
+      TableConfigUtils.validate(tableConfig, schema, typesToSkip,
+          _pinotHelixResourceManager.getTableConfig(tableNameWithType));
       TaskConfigUtils.validateTaskConfigs(tableConfig, schema, 
_pinotTaskManager, typesToSkip);
       ObjectNode tableConfigValidateStr = JsonUtils.newObjectNode();
       if (tableConfig.getTableType() == TableType.OFFLINE) {
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/TableConfigValidationUtils.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/TableConfigValidationUtils.java
index ec51fbccec7..f3d96d3c741 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/TableConfigValidationUtils.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/TableConfigValidationUtils.java
@@ -59,8 +59,15 @@ public final class TableConfigValidationUtils {
   public static void validateTableConfig(TableConfig tableConfig, Schema 
schema,
       @Nullable String typesToSkip, PinotHelixResourceManager resourceManager,
       ControllerConf controllerConf, @Nullable PinotTaskManager taskManager) {
+    validateTableConfig(tableConfig, schema, typesToSkip, resourceManager, 
controllerConf, taskManager, null);
+  }
+
+  public static void validateTableConfig(TableConfig tableConfig, Schema 
schema,
+      @Nullable String typesToSkip, PinotHelixResourceManager resourceManager,
+      ControllerConf controllerConf, @Nullable PinotTaskManager taskManager,
+      @Nullable TableConfig existingTableConfig) {
     validateEnvironmentVariables(tableConfig);
-    TableConfigUtils.validate(tableConfig, schema, typesToSkip);
+    TableConfigUtils.validate(tableConfig, schema, typesToSkip, 
existingTableConfig);
     TableConfigUtils.validateTableName(tableConfig);
     TableConfigUtils.ensureMinReplicas(tableConfig, 
controllerConf.getDefaultTableMinReplicas());
     TableConfigUtils.ensureStorageQuotaConstraints(tableConfig, 
controllerConf.getDimTableMaxSize());
diff --git 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/TableConfigsRestletResource.java
 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/TableConfigsRestletResource.java
index 17e7dab0471..a74d52fc9ad 100644
--- 
a/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/TableConfigsRestletResource.java
+++ 
b/pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/TableConfigsRestletResource.java
@@ -583,6 +583,7 @@ public class TableConfigsRestletResource {
 
       Preconditions.checkState(rawTableName.equals(schemaName),
           "'tableName': %s must be equal to 'schemaName' from 'schema': %s", 
rawTableName, schema.getSchemaName());
+      SchemaUtils.validateIngestionTransformVolatility(schema, 
_pinotHelixResourceManager.getSchema(schemaName));
       SchemaUtils.validate(schema);
       if (offlineTableConfig != null) {
         String offlineRawTableName = DatabaseUtils.translateTableName(
@@ -590,7 +591,8 @@ public class TableConfigsRestletResource {
         Preconditions.checkState(offlineRawTableName.equals(rawTableName),
             "Name in 'offline' table config: %s must be equal to 'tableName': 
%s", offlineRawTableName, rawTableName);
         TableConfigUtils.validateTableName(offlineTableConfig);
-        TableConfigUtils.validate(offlineTableConfig, schema, typesToSkip);
+        TableConfigUtils.validate(offlineTableConfig, schema, typesToSkip,
+            
_pinotHelixResourceManager.getTableConfig(TableNameBuilder.OFFLINE.tableNameWithType(rawTableName)));
         TaskConfigUtils.validateTaskConfigs(tableConfigs.getOffline(), schema, 
_pinotTaskManager, typesToSkip);
         TableConfigValidatorRegistry.validate(offlineTableConfig, schema);
       }
@@ -600,7 +602,8 @@ public class TableConfigsRestletResource {
         Preconditions.checkState(realtimeRawTableName.equals(rawTableName),
             "Name in 'realtime' table config: %s must be equal to 'tableName': 
%s", realtimeRawTableName, rawTableName);
         TableConfigUtils.validateTableName(realtimeTableConfig);
-        TableConfigUtils.validate(realtimeTableConfig, schema, typesToSkip);
+        TableConfigUtils.validate(realtimeTableConfig, schema, typesToSkip,
+            
_pinotHelixResourceManager.getTableConfig(TableNameBuilder.REALTIME.tableNameWithType(rawTableName)));
         TaskConfigUtils.validateTaskConfigs(tableConfigs.getRealtime(), 
schema, _pinotTaskManager, typesToSkip);
         TableConfigValidatorRegistry.validate(realtimeTableConfig, schema);
       }
diff --git 
a/pinot-controller/src/test/java/org/apache/pinot/controller/api/PinotSchemaRestletResourceTest.java
 
b/pinot-controller/src/test/java/org/apache/pinot/controller/api/PinotSchemaRestletResourceTest.java
index 2dc7f7356c6..0e41edc7cbd 100644
--- 
a/pinot-controller/src/test/java/org/apache/pinot/controller/api/PinotSchemaRestletResourceTest.java
+++ 
b/pinot-controller/src/test/java/org/apache/pinot/controller/api/PinotSchemaRestletResourceTest.java
@@ -260,6 +260,47 @@ public class PinotSchemaRestletResourceTest {
         "Expected TimeFieldSpec error, got: " + validateCause.getMessage());
   }
 
+  @Test
+  public void testNonDeterministicSchemaTransformCreateAndLegacyUpdate()
+      throws Exception {
+    PinotAdminClient adminClient = TEST_INSTANCE.getOrCreateAdminClient();
+    String schemaName = "legacyNonDeterministicSchemaTransform";
+    Schema schema = TEST_INSTANCE.createDummySchema(schemaName);
+    DimensionFieldSpec eventTimeField = new DimensionFieldSpec("eventTimeMs", 
DataType.LONG, true);
+    eventTimeField.setTransformFunction("now()");
+    schema.addField(eventTimeField);
+    try {
+      expectValidationExceptionWithMessage(
+          () -> 
adminClient.getSchemaClient().createSchema(schema.toSingleLineJsonString()),
+          "Function 'now' has VOLATILE volatility");
+      expectValidationExceptionWithMessage(
+          () -> 
adminClient.getSchemaClient().validateSchema(schema.toSingleLineJsonString()),
+          "Function 'now' has VOLATILE volatility");
+
+      // Seed below the REST validation layer to model a schema persisted 
before this validation existed.
+      TEST_INSTANCE.getHelixResourceManager().addSchema(schema, false, false);
+
+      Schema update = 
adminClient.getSchemaClient().getSchemaObject(schemaName);
+      update.addField(new DimensionFieldSpec("newColumn", DataType.STRING, 
true));
+      
adminClient.getSchemaClient().validateSchema(update.toSingleLineJsonString());
+      adminClient.getSchemaClient().updateSchema(schemaName, 
update.toSingleLineJsonString());
+
+      Schema stored = 
adminClient.getSchemaClient().getSchemaObject(schemaName);
+      assertTrue(stored.hasColumn("newColumn"));
+      
assertEquals(stored.getFieldSpecFor("eventTimeMs").getTransformFunction(), 
"now()");
+
+      update.getFieldSpecFor("eventTimeMs").setTransformFunction("plus(now(), 
1)");
+      expectValidationExceptionWithMessage(
+          () -> 
adminClient.getSchemaClient().validateSchema(update.toSingleLineJsonString()),
+          "Function 'now' has VOLATILE volatility");
+      expectValidationExceptionWithMessage(
+          () -> adminClient.getSchemaClient().updateSchema(schemaName, 
update.toSingleLineJsonString()),
+          "Function 'now' has VOLATILE volatility");
+    } finally {
+      TEST_INSTANCE.getHelixResourceManager().deleteSchema(schemaName);
+    }
+  }
+
   @Test
   public void testSchemaDeletionWithLogicalTable()
       throws Exception {
diff --git 
a/pinot-controller/src/test/java/org/apache/pinot/controller/api/PinotTableRestletResourceTest.java
 
b/pinot-controller/src/test/java/org/apache/pinot/controller/api/PinotTableRestletResourceTest.java
index cb29afe3d20..33b62aa4621 100644
--- 
a/pinot-controller/src/test/java/org/apache/pinot/controller/api/PinotTableRestletResourceTest.java
+++ 
b/pinot-controller/src/test/java/org/apache/pinot/controller/api/PinotTableRestletResourceTest.java
@@ -58,6 +58,8 @@ import 
org.apache.pinot.spi.config.table.assignment.InstanceConstraintConfig;
 import org.apache.pinot.spi.config.table.assignment.InstancePartitionsType;
 import 
org.apache.pinot.spi.config.table.assignment.InstanceReplicaGroupPartitionConfig;
 import org.apache.pinot.spi.config.table.assignment.InstanceTagPoolConfig;
+import org.apache.pinot.spi.config.table.ingestion.IngestionConfig;
+import org.apache.pinot.spi.config.table.ingestion.TransformConfig;
 import org.apache.pinot.spi.data.FieldSpec;
 import org.apache.pinot.spi.data.LogicalTableConfig;
 import org.apache.pinot.spi.data.Schema;
@@ -530,6 +532,67 @@ public class PinotTableRestletResourceTest extends 
ControllerTest {
     }
   }
 
+  @Test
+  public void testNonDeterministicTransformCreateAndLegacyUpdate()
+      throws Exception {
+    String tableName = "legacyNonDeterministicTransform";
+    DEFAULT_INSTANCE.addDummySchema(tableName);
+
+    IngestionConfig ingestionConfig = new IngestionConfig();
+    ingestionConfig.setTransformConfigs(List.of(new TransformConfig("dimA", 
"now()")));
+    TableConfig legacyTableConfig = getOfflineTableBuilder(tableName)
+        .setIngestionConfig(ingestionConfig)
+        .build();
+
+    IOException createError =
+        expectThrows(IOException.class, () -> 
createTable(legacyTableConfig.toJsonString()));
+    assertHasStatus(createError, 400);
+    assertTrue(createError.getMessage().contains("Function 'now' has VOLATILE 
volatility"), createError.getMessage());
+
+    // Seed the config below the REST validation layer to model a table 
persisted before this validation existed.
+    DEFAULT_INSTANCE.getHelixResourceManager().addTable(legacyTableConfig);
+
+    TableConfig update = getTableConfig(tableName, "OFFLINE");
+    update.getValidationConfig().setRetentionTimeValue("10");
+    JsonNode validationResponse = 
JsonUtils.stringToJsonNode(tableClient().validateTableConfig(update.toJsonString()));
+    assertTrue(validationResponse.has("OFFLINE"));
+
+    JsonNode response = JsonUtils.stringToJsonNode(updateTable(tableName, 
update.toJsonString()));
+    assertTrue(response.has("status"));
+
+    TableConfig stored = getTableConfig(tableName, "OFFLINE");
+    assertEquals(stored.getValidationConfig().getRetentionTimeValue(), "10");
+    
assertEquals(stored.getIngestionConfig().getTransformConfigs().get(0).getTransformFunction(),
 "now()");
+
+    IngestionConfig changedIngestionConfig = new IngestionConfig();
+    changedIngestionConfig.setTransformConfigs(List.of(new 
TransformConfig("dimA", "plus(now(), 1)")));
+    update.setIngestionConfig(changedIngestionConfig);
+    PinotAdminException validationError =
+        expectThrows(PinotAdminException.class, () -> 
tableClient().validateTableConfig(update.toJsonString()));
+    assertTrue(validationError.getMessage().contains("Function 'now' has 
VOLATILE volatility"),
+        validationError.getMessage());
+
+    IOException updateError = expectThrows(IOException.class, () -> 
updateTable(tableName, update.toJsonString()));
+    assertHasStatus(updateError, 400);
+    assertTrue(updateError.getMessage().contains("Function 'now' has VOLATILE 
volatility"), updateError.getMessage());
+  }
+
+  @Test
+  public void testCreateTableRejectsNonDeterministicSchemaTransform()
+      throws Exception {
+    String tableName = "nonDeterministicSchemaTransform";
+    Schema schema = DEFAULT_INSTANCE.createDummySchema(tableName);
+    schema.getFieldSpecFor("dimA").setTransformFunction("now()");
+
+    // Seed below the schema REST validation layer to model a schema persisted 
before this validation existed.
+    DEFAULT_INSTANCE.getHelixResourceManager().addSchema(schema, false, false);
+
+    TableConfig tableConfig = getOfflineTableBuilder(tableName).build();
+    IOException createError = expectThrows(IOException.class, () -> 
createTable(tableConfig.toJsonString()));
+    assertHasStatus(createError, 400);
+    assertTrue(createError.getMessage().contains("Function 'now' has VOLATILE 
volatility"), createError.getMessage());
+  }
+
   private void deleteAllTables()
       throws IOException {
     List<String> tables = getTableNames("offline", null, null, null);
diff --git 
a/pinot-controller/src/test/java/org/apache/pinot/controller/api/TableConfigsRestletResourceTest.java
 
b/pinot-controller/src/test/java/org/apache/pinot/controller/api/TableConfigsRestletResourceTest.java
index b7d6268ba56..7537fa65e66 100644
--- 
a/pinot-controller/src/test/java/org/apache/pinot/controller/api/TableConfigsRestletResourceTest.java
+++ 
b/pinot-controller/src/test/java/org/apache/pinot/controller/api/TableConfigsRestletResourceTest.java
@@ -33,7 +33,10 @@ import 
org.apache.pinot.spi.config.table.SegmentsValidationAndRetentionConfig;
 import org.apache.pinot.spi.config.table.TableConfig;
 import org.apache.pinot.spi.config.table.TableType;
 import org.apache.pinot.spi.config.table.TunerConfig;
+import org.apache.pinot.spi.config.table.ingestion.IngestionConfig;
+import org.apache.pinot.spi.config.table.ingestion.TransformConfig;
 import org.apache.pinot.spi.data.DateTimeFieldSpec;
+import org.apache.pinot.spi.data.DimensionFieldSpec;
 import org.apache.pinot.spi.data.FieldSpec;
 import org.apache.pinot.spi.data.LogicalTableConfig;
 import org.apache.pinot.spi.data.MetricFieldSpec;
@@ -512,6 +515,106 @@ public class TableConfigsRestletResourceTest extends 
ControllerTest {
     adminClient.getTableClient().deleteTableConfigs(tableName, null);
   }
 
+  @Test
+  public void testNonDeterministicTransformCreateAndLegacyUpdate()
+      throws Exception {
+    PinotAdminClient adminClient = getOrCreateAdminClient();
+    String tableName = "legacyNonDeterministicTableConfigs";
+    Schema schema = createDummySchema(tableName);
+
+    IngestionConfig ingestionConfig = new IngestionConfig();
+    ingestionConfig.setTransformConfigs(List.of(new TransformConfig("dimA", 
"now()")));
+    TableConfig legacyOfflineConfig = getBaseTableConfigBuilder(tableName, 
TableType.OFFLINE)
+        .setIngestionConfig(ingestionConfig)
+        .build();
+    TableConfigs tableConfigs = new TableConfigs(tableName, schema, 
legacyOfflineConfig, null);
+    String tableNameWithType = 
TableNameBuilder.OFFLINE.tableNameWithType(tableName);
+    try {
+      String createError = Assert.expectThrows(Exception.class,
+              () -> 
adminClient.getTableClient().createTableConfigs(tableConfigs.toPrettyJsonString(),
 null, null))
+          .getMessage();
+      Assert.assertTrue(createError.contains("Function 'now' has VOLATILE 
volatility"), createError);
+
+      // Seed the config below the REST validation layer to model a table 
persisted before this validation existed.
+      DEFAULT_INSTANCE.addSchema(schema);
+      DEFAULT_INSTANCE.getHelixResourceManager().addTable(legacyOfflineConfig);
+
+      TableConfigs update = 
adminClient.getTableClient().getTableConfigsObject(tableName);
+      update.getOffline().getValidationConfig().setRetentionTimeValue("10");
+      adminClient.getTableClient()
+          .updateTableConfigs(tableName, update.toPrettyJsonString(), null, 
false, false);
+
+      TableConfigs stored = 
adminClient.getTableClient().getTableConfigsObject(tableName);
+      
Assert.assertEquals(stored.getOffline().getValidationConfig().getRetentionTimeValue(),
 "10");
+      
Assert.assertEquals(stored.getOffline().getIngestionConfig().getTransformConfigs().get(0).getTransformFunction(),
+          "now()");
+
+      IngestionConfig changedIngestionConfig = new IngestionConfig();
+      changedIngestionConfig.setTransformConfigs(List.of(new 
TransformConfig("dimA", "plus(now(), 1)")));
+      update.getOffline().setIngestionConfig(changedIngestionConfig);
+      String updateError = Assert.expectThrows(Exception.class,
+              () -> adminClient.getTableClient()
+                  .updateTableConfigs(tableName, update.toPrettyJsonString(), 
null, false, false))
+          .getMessage();
+      Assert.assertTrue(updateError.contains("Function 'now' has VOLATILE 
volatility"), updateError);
+    } finally {
+      if 
(DEFAULT_INSTANCE.getHelixResourceManager().hasTable(tableNameWithType)) {
+        adminClient.getTableClient().deleteTableConfigs(tableName, null);
+      } else if 
(DEFAULT_INSTANCE.getHelixResourceManager().getSchema(tableName) != null) {
+        adminClient.getSchemaClient().deleteSchema(tableName);
+      }
+    }
+  }
+
+  @Test
+  public void testNonDeterministicSchemaTransformCreateAndLegacyUpdate()
+      throws Exception {
+    PinotAdminClient adminClient = getOrCreateAdminClient();
+    String tableName = "legacyNonDeterministicSchemaTableConfigs";
+    Schema schema = createDummySchema(tableName);
+    schema.getFieldSpecFor("dimA").setTransformFunction("now()");
+    TableConfig legacyOfflineConfig = getBaseTableConfigBuilder(tableName, 
TableType.OFFLINE).build();
+    TableConfigs tableConfigs = new TableConfigs(tableName, schema, 
legacyOfflineConfig, null);
+    String tableNameWithType = 
TableNameBuilder.OFFLINE.tableNameWithType(tableName);
+    try {
+      String createError = Assert.expectThrows(Exception.class,
+              () -> 
adminClient.getTableClient().createTableConfigs(tableConfigs.toPrettyJsonString(),
 null, null))
+          .getMessage();
+      Assert.assertTrue(createError.contains("Function 'now' has VOLATILE 
volatility"), createError);
+
+      // Seed below the REST validation layer to model a table and schema 
persisted before this validation existed.
+      DEFAULT_INSTANCE.getHelixResourceManager().addSchema(schema, false, 
false);
+      DEFAULT_INSTANCE.getHelixResourceManager().addTable(legacyOfflineConfig);
+
+      TableConfigs update = 
adminClient.getTableClient().getTableConfigsObject(tableName);
+      update.getSchema().addField(new DimensionFieldSpec("newColumn", 
FieldSpec.DataType.STRING, true));
+      
adminClient.getTableClient().validateTableConfigs(update.toPrettyJsonString(), 
null);
+      adminClient.getTableClient()
+          .updateTableConfigs(tableName, update.toPrettyJsonString(), null, 
false, false);
+
+      TableConfigs stored = 
adminClient.getTableClient().getTableConfigsObject(tableName);
+      Assert.assertTrue(stored.getSchema().hasColumn("newColumn"));
+      
Assert.assertEquals(stored.getSchema().getFieldSpecFor("dimA").getTransformFunction(),
 "now()");
+
+      
update.getSchema().getFieldSpecFor("dimA").setTransformFunction("plus(now(), 
1)");
+      String validationError = Assert.expectThrows(Exception.class,
+              () -> 
adminClient.getTableClient().validateTableConfigs(update.toPrettyJsonString(), 
null))
+          .getMessage();
+      Assert.assertTrue(validationError.contains("Function 'now' has VOLATILE 
volatility"), validationError);
+      String updateError = Assert.expectThrows(Exception.class,
+              () -> adminClient.getTableClient()
+                  .updateTableConfigs(tableName, update.toPrettyJsonString(), 
null, false, false))
+          .getMessage();
+      Assert.assertTrue(updateError.contains("Function 'now' has VOLATILE 
volatility"), updateError);
+    } finally {
+      if 
(DEFAULT_INSTANCE.getHelixResourceManager().hasTable(tableNameWithType)) {
+        adminClient.getTableClient().deleteTableConfigs(tableName, null);
+      } else if 
(DEFAULT_INSTANCE.getHelixResourceManager().getSchema(tableName) != null) {
+        adminClient.getSchemaClient().deleteSchema(tableName);
+      }
+    }
+  }
+
   @Test
   public void testForceUpdateTableSchemaAndConfigs()
       throws Exception {
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/util/SchemaUtilsTest.java 
b/pinot-core/src/test/java/org/apache/pinot/core/util/SchemaUtilsTest.java
index f2d4f07df81..c17e1b69b6e 100644
--- a/pinot-core/src/test/java/org/apache/pinot/core/util/SchemaUtilsTest.java
+++ b/pinot-core/src/test/java/org/apache/pinot/core/util/SchemaUtilsTest.java
@@ -26,6 +26,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
 import org.apache.pinot.segment.local.utils.SchemaUtils;
+import org.apache.pinot.segment.local.utils.TableConfigUtils;
 import org.apache.pinot.spi.config.table.TableConfig;
 import org.apache.pinot.spi.config.table.TableType;
 import org.apache.pinot.spi.config.table.ingestion.IngestionConfig;
@@ -189,6 +190,53 @@ public class SchemaUtilsTest {
     SchemaUtils.validate(schema, Lists.newArrayList(tableConfig));
   }
 
+  @Test
+  public void testCompatibilityGrandfathersExistingNonDeterministicTransform() 
{
+    Schema schema =
+        new 
Schema.SchemaBuilder().setSchemaName(TABLE_NAME).addMetric("eventTimeMs", 
DataType.LONG).build();
+    IngestionConfig ingestionConfig = new IngestionConfig();
+    ingestionConfig.setTransformConfigs(List.of(new 
TransformConfig("eventTimeMs", "now()")));
+    TableConfig tableConfig =
+        new 
TableConfigBuilder(TableType.OFFLINE).setTableName(TABLE_NAME).setIngestionConfig(ingestionConfig).build();
+
+    // A new table using this transform is still rejected.
+    Assert.expectThrows(IllegalStateException.class, () -> 
TableConfigUtils.validate(tableConfig, schema));
+
+    // Schema validation checks an already-associated, unchanged table config, 
so the legacy transform is
+    // grandfathered while the rest of table/schema compatibility validation 
continues to run.
+    SchemaUtils.validate(schema, List.of(tableConfig));
+  }
+
+  @Test
+  public void testSchemaTransformVolatility()
+      throws Exception {
+    Schema schema =
+        new 
Schema.SchemaBuilder().setSchemaName(TABLE_NAME).addMetric("eventTimeMs", 
DataType.LONG).build();
+    schema.getFieldSpecFor("eventTimeMs").setTransformFunction("now()");
+
+    // Runtime validation remains permissive so a legacy schema already stored 
in ZK can still be loaded.
+    SchemaUtils.validate(schema);
+
+    IllegalStateException nonDeterministicError = 
Assert.expectThrows(IllegalStateException.class,
+        () -> SchemaUtils.validate(schema, List.of()));
+    Assert.assertTrue(nonDeterministicError.getMessage().contains("Function 
'now' has VOLATILE volatility"),
+        nonDeterministicError.getMessage());
+
+    Schema existingSchema = Schema.fromString(schema.toString());
+    Schema unchangedTransform = Schema.fromString(schema.toString());
+    unchangedTransform.addField(new DimensionFieldSpec("newColumn", 
DataType.STRING, true));
+    SchemaUtils.validate(unchangedTransform, List.of(), false, existingSchema);
+
+    
unchangedTransform.getFieldSpecFor("eventTimeMs").setTransformFunction("plus(now(),
 1)");
+    nonDeterministicError = Assert.expectThrows(IllegalStateException.class,
+        () -> SchemaUtils.validate(unchangedTransform, List.of(), false, 
existingSchema));
+    Assert.assertTrue(nonDeterministicError.getMessage().contains("Function 
'now' has VOLATILE volatility"),
+        nonDeterministicError.getMessage());
+
+    
unchangedTransform.getFieldSpecFor("eventTimeMs").setTransformFunction("rand(123)");
+    SchemaUtils.validate(unchangedTransform, List.of(), false, existingSchema);
+  }
+
   private Map<String, String> getStreamConfigs() {
     Map<String, String> streamConfigs = new HashMap<>();
     streamConfigs.put("streamType", "kafka");
diff --git 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/CLPEncodingRealtimeTest.java
 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/CLPEncodingRealtimeTest.java
index 68556333a24..356864cddc4 100644
--- 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/CLPEncodingRealtimeTest.java
+++ 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/CLPEncodingRealtimeTest.java
@@ -116,7 +116,7 @@ public class CLPEncodingRealtimeTest extends 
CustomDataQueryClusterIntegrationTe
   @Override
   protected IngestionConfig getIngestionConfig() {
     List<TransformConfig> transforms = new ArrayList<>();
-    transforms.add(new TransformConfig("timestampInEpoch", "now()"));
+    transforms.add(new TransformConfig("timestampInEpoch", "1704067200000"));
 
     IngestionConfig ingestionConfig = new IngestionConfig();
     ingestionConfig.setTransformConfigs(transforms);
diff --git 
a/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryPlannerRuleOptionsTest.java
 
b/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryPlannerRuleOptionsTest.java
index 004175b17de..b9de8291778 100644
--- 
a/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryPlannerRuleOptionsTest.java
+++ 
b/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryPlannerRuleOptionsTest.java
@@ -222,10 +222,13 @@ public class QueryPlannerRuleOptionsTest extends 
QueryEnvironmentTestBase {
   }
 
   @Test
-  public void testRandFunctionNotEvaluatedInMultiStagePlanner() {
-    String query = "EXPLAIN PLAN FOR SELECT rand() FROM b";
+  public void 
testFunctionVolatilityDoesNotChangeLiteralEvaluationInMultiStagePlanner() {
+    String query = "EXPLAIN PLAN FOR SELECT now(), ago('PT1H'), rand(), 
rand(123) FROM b";
     String explain = _queryEnvironment.explainQuery(query, 
RANDOM_REQUEST_ID_GEN.nextLong());
     assertTrue(explain.contains("RAND()"), "Expected RAND() to remain in 
logical plan");
+    assertFalse(explain.contains("NOW()"), "Expected NOW() to be evaluated 
into a literal");
+    assertFalse(explain.contains("AGO("), "Expected AGO('PT1H') to be 
evaluated into a literal");
+    assertFalse(explain.contains("RAND(123)"), "Expected RAND(123) to be 
evaluated into a literal");
   }
 
   @Test
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/SchemaUtils.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/SchemaUtils.java
index 99ec4d9ddc2..2147d64c5d8 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/SchemaUtils.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/SchemaUtils.java
@@ -25,7 +25,9 @@ import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
+import javax.annotation.Nullable;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.pinot.common.evaluator.FunctionEvaluatorFactory;
 import org.apache.pinot.spi.config.table.TableConfig;
@@ -71,21 +73,55 @@ public class SchemaUtils {
    * @param tableConfigs table configs associated with this schema (table 
configs with raw name = schema name)
    */
   public static void validate(Schema schema, List<TableConfig> tableConfigs) {
-    validate(schema, tableConfigs, false);
+    validate(schema, tableConfigs, false, null);
   }
 
   public static void validate(Schema schema, List<TableConfig> tableConfigs, 
boolean isIgnoreCase) {
+    validate(schema, tableConfigs, isIgnoreCase, null);
+  }
+
+  public static void validate(Schema schema, List<TableConfig> tableConfigs, 
boolean isIgnoreCase,
+      @Nullable Schema existingSchema) {
     // The deprecation reject is intentionally placed only in this REST-driven 
overload (not in the single-arg
     // `validate(Schema)` that server-side table loading uses), so existing 
legacy schemas in ZK keep
     // loading. New / updated schemas submitted via the controller REST API 
are blocked.
     // See https://github.com/apache/pinot/issues/2756 for the TimeFieldSpec 
deprecation plan.
     rejectDeprecatedTimeFieldSpec(schema);
+    validateIngestionTransformVolatility(schema, existingSchema);
     for (TableConfig tableConfig : tableConfigs) {
       validateCompatibilityWithTableConfig(schema, tableConfig);
     }
     validate(schema, isIgnoreCase);
   }
 
+  /**
+   * Validates that new or changed legacy schema-level transform functions are 
immutable. An exact transform on the
+   * same column in the stored schema is grandfathered so unrelated updates do 
not strand existing schemas.
+   */
+  public static void validateIngestionTransformVolatility(Schema schema, 
@Nullable Schema existingSchema) {
+    validateIngestionTransformVolatility(schema, existingSchema, Set.of(), 
Set.of());
+  }
+
+  static void validateIngestionTransformVolatility(Schema schema, @Nullable 
Schema existingSchema,
+      Set<String> overriddenColumns, Set<String> existingOverriddenColumns) {
+    for (FieldSpec fieldSpec : schema.getAllFieldSpecs()) {
+      if (fieldSpec.isVirtualColumn() || 
overriddenColumns.contains(fieldSpec.getName())) {
+        continue;
+      }
+      String transformFunction = fieldSpec.getTransformFunction();
+      if (transformFunction == null) {
+        continue;
+      }
+      FieldSpec existingFieldSpec =
+          existingSchema != null ? 
existingSchema.getFieldSpecFor(fieldSpec.getName()) : null;
+      if (!existingOverriddenColumns.contains(fieldSpec.getName()) && 
existingFieldSpec != null
+          && Objects.equals(transformFunction, 
existingFieldSpec.getTransformFunction())) {
+        continue;
+      }
+      
TableConfigUtils.validateIngestionTransformFunctionVolatility(transformFunction);
+    }
+  }
+
   @SuppressWarnings("deprecation")
   private static void rejectDeprecatedTimeFieldSpec(Schema schema) {
     if (schema.getTimeFieldSpec() != null) {
@@ -197,7 +233,10 @@ public class SchemaUtils {
    */
   private static void validateCompatibilityWithTableConfig(Schema schema, 
TableConfig tableConfig) {
     try {
-      TableConfigUtils.validate(tableConfig, schema);
+      // The associated table config already exists and is not being changed 
by schema validation. Pass it as the
+      // existing config so unchanged legacy transforms remain grandfathered 
while all schema-dependent validation
+      // still runs against the proposed schema.
+      TableConfigUtils.validate(tableConfig, schema, null, tableConfig);
     } catch (Exception e) {
       throw new IllegalStateException(
           "Schema is incompatible with tableConfig with name: " + 
tableConfig.getTableName() + " and type: "
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/TableConfigUtils.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/TableConfigUtils.java
index 31fbf48dc6c..403ec65deeb 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/TableConfigUtils.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/TableConfigUtils.java
@@ -39,6 +39,8 @@ import org.apache.commons.collections4.CollectionUtils;
 import org.apache.commons.collections4.MapUtils;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.pinot.common.evaluator.FunctionEvaluatorFactory;
+import org.apache.pinot.common.function.FunctionInfo;
+import org.apache.pinot.common.function.FunctionRegistry;
 import org.apache.pinot.common.request.context.ExpressionContext;
 import org.apache.pinot.common.request.context.FunctionContext;
 import org.apache.pinot.common.request.context.RequestContextUtils;
@@ -57,6 +59,7 @@ import org.apache.pinot.segment.spi.index.IndexType;
 import org.apache.pinot.segment.spi.index.StandardIndexes;
 import 
org.apache.pinot.segment.spi.index.multicolumntext.MultiColumnTextMetadata;
 import 
org.apache.pinot.segment.spi.index.startree.AggregationFunctionColumnPair;
+import org.apache.pinot.spi.annotations.FunctionVolatility;
 import org.apache.pinot.spi.config.table.ColumnPartitionConfig;
 import org.apache.pinot.spi.config.table.DedupConfig;
 import org.apache.pinot.spi.config.table.FieldConfig;
@@ -172,15 +175,20 @@ public final class TableConfigUtils {
    * TODO: Add more validations for each section (e.g. validate conditions are 
met for aggregateMetrics)
    */
   public static void validate(TableConfig tableConfig, Schema schema, 
@Nullable String typesToSkip) {
+    validate(tableConfig, schema, typesToSkip, null);
+  }
+
+  public static void validate(TableConfig tableConfig, Schema schema, 
@Nullable String typesToSkip,
+      @Nullable TableConfig existingTableConfig) {
     Preconditions.checkArgument(schema != null, "Schema should not be null for 
table: %s", tableConfig.getTableName());
     Set<ValidationType> skipTypes = parseTypesToSkipString(typesToSkip);
-    validateEffectiveTableConfig(tableConfig, schema, skipTypes);
+    validateEffectiveTableConfig(tableConfig, schema, skipTypes, 
existingTableConfig);
     if (skipTypes.contains(ValidationType.ALL) || 
!hasConsumingSegmentTierOverwriteForRealtimeTable(tableConfig)) {
       return;
     }
     try {
       TableConfig consumingTableConfig = 
overwriteTableConfigForConsumingSegmentTier(tableConfig);
-      validateEffectiveTableConfig(consumingTableConfig, schema, skipTypes);
+      validateEffectiveTableConfig(consumingTableConfig, schema, skipTypes, 
existingTableConfig);
     } catch (RuntimeException e) {
       throw new IllegalStateException(
           "tierOverwrites.consuming produces an invalid table config: " + 
e.getMessage(), e);
@@ -188,7 +196,7 @@ public final class TableConfigUtils {
   }
 
   private static void validateEffectiveTableConfig(TableConfig tableConfig, 
Schema schema,
-      Set<ValidationType> skipTypes) {
+      Set<ValidationType> skipTypes, @Nullable TableConfig 
existingTableConfig) {
     // Sanitize the table config before validation
     sanitize(tableConfig);
 
@@ -196,9 +204,18 @@ public final class TableConfigUtils {
       return;
     }
 
+    // Schema-level transforms are active only when the table config does not 
override the same destination. A
+    // non-immutable transform is grandfathered only when it was already 
active for this table; removing an existing
+    // override must not silently activate it.
+    Set<String> overriddenColumns = getTransformColumns(tableConfig);
+    Set<String> existingOverriddenColumns = 
getTransformColumns(existingTableConfig);
+    Schema existingSchema = existingTableConfig != null ? schema : null;
+    SchemaUtils.validateIngestionTransformVolatility(schema, existingSchema, 
overriddenColumns,
+        existingOverriddenColumns);
+
     validateValidationConfig(tableConfig, schema);
     validateSegmentAssignmentConfig(tableConfig);
-    validateIngestionConfig(tableConfig, schema);
+    validateIngestionConfig(tableConfig, schema, existingTableConfig);
     if (tableConfig.getTableType() == TableType.REALTIME) {
       validateStreamConfigMaps(tableConfig);
     }
@@ -209,7 +226,7 @@ public final class TableConfigUtils {
     validateInstanceAssignmentConfigs(tableConfig);
 
     if (!skipTypes.contains(ValidationType.UPSERT)) {
-      validateUpsertAndDedupConfig(tableConfig, schema);
+      validateUpsertAndDedupConfig(tableConfig, schema, existingTableConfig);
     }
 
     validateTaskConfig(tableConfig);
@@ -459,6 +476,12 @@ public final class TableConfigUtils {
   /// - Schema-conforming transformer config.
   @VisibleForTesting
   public static void validateIngestionConfig(TableConfig tableConfig, Schema 
schema) {
+    validateIngestionConfig(tableConfig, schema, null);
+  }
+
+  @VisibleForTesting
+  static void validateIngestionConfig(TableConfig tableConfig, Schema schema,
+      @Nullable TableConfig existingTableConfig) {
     // All metrics-aggregation validation lives here; it returns the columns 
referenced as aggregation sources, which
     // a transform config is allowed to target as its destination (see the 
transform validation below).
     Set<String> aggregationSourceColumns = 
validateMetricsAggregation(tableConfig, schema);
@@ -566,6 +589,7 @@ public final class TableConfigUtils {
     // Transform configs
     List<TransformConfig> transformConfigs = 
ingestionConfig.getTransformConfigs();
     if (transformConfigs != null) {
+      List<TransformConfig> existingTransformConfigs = 
getTransformConfigs(existingTableConfig);
       // Pre-pass: collect every column referenced as a transform-function 
argument. A transform whose destination
       // is not in the schema is still valid when another transform consumes 
it as an input - i.e. it is an
       // intermediate ("derived") column. This enables chained / parse-once 
transforms, e.g.
@@ -611,6 +635,7 @@ public final class TableConfigUtils {
                   + columnName + "'");
         }
         try {
+          validateIngestionTransformFunctionVolatility(transformConfig, 
existingTransformConfigs);
           expressionEvaluator = 
FunctionEvaluatorFactory.getExpressionEvaluator(transformFunction);
         } catch (Exception e) {
           throw new IllegalStateException(
@@ -649,6 +674,77 @@ public final class TableConfigUtils {
     }
   }
 
+  @Nullable
+  private static List<TransformConfig> getTransformConfigs(@Nullable 
TableConfig tableConfig) {
+    IngestionConfig ingestionConfig = tableConfig != null ? 
tableConfig.getIngestionConfig() : null;
+    return ingestionConfig != null ? ingestionConfig.getTransformConfigs() : 
null;
+  }
+
+  private static Set<String> getTransformColumns(@Nullable TableConfig 
tableConfig) {
+    List<TransformConfig> transformConfigs = getTransformConfigs(tableConfig);
+    return transformConfigs != null
+        ? 
transformConfigs.stream().map(TransformConfig::getColumnName).filter(Objects::nonNull)
+            .collect(Collectors.toSet())
+        : Set.of();
+  }
+
+  @Nullable
+  private static List<TransformConfig> 
getPostPartialUpsertTransformConfigs(@Nullable TableConfig tableConfig) {
+    UpsertConfig upsertConfig = tableConfig != null ? 
tableConfig.getUpsertConfig() : null;
+    return upsertConfig != null ? 
upsertConfig.getPostPartialUpsertTransformConfigs() : null;
+  }
+
+  private static void 
validateIngestionTransformFunctionVolatility(TransformConfig transformConfig,
+      @Nullable List<TransformConfig> existingTransformConfigs) {
+    if (hasSameTransformConfig(existingTransformConfigs, transformConfig)) {
+      return;
+    }
+    
validateIngestionTransformFunctionVolatility(transformConfig.getTransformFunction());
+  }
+
+  static void validateIngestionTransformFunctionVolatility(String 
transformFunction) {
+    if (FunctionEvaluatorFactory.isGroovyExpression(transformFunction)) {
+      return;
+    }
+    
validateIngestionTransformVolatility(RequestContextUtils.getExpression(transformFunction),
+        transformFunction);
+  }
+
+  private static boolean hasSameTransformConfig(@Nullable 
List<TransformConfig> existingTransformConfigs,
+      TransformConfig transformConfig) {
+    if (existingTransformConfigs == null) {
+      return false;
+    }
+    for (TransformConfig existingTransformConfig : existingTransformConfigs) {
+      if (Objects.equals(existingTransformConfig.getColumnName(), 
transformConfig.getColumnName())
+          && Objects.equals(existingTransformConfig.getTransformFunction(), 
transformConfig.getTransformFunction())) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private static void validateIngestionTransformVolatility(ExpressionContext 
expression,
+      String transformFunction) {
+    if (expression.getType() != ExpressionContext.Type.FUNCTION) {
+      return;
+    }
+    FunctionContext function = expression.getFunction();
+    List<ExpressionContext> arguments = function.getArguments();
+    for (ExpressionContext argument : arguments) {
+      validateIngestionTransformVolatility(argument, transformFunction);
+    }
+    String functionName = function.getFunctionName();
+    String canonicalName = FunctionRegistry.canonicalize(functionName);
+    FunctionInfo functionInfo = 
FunctionRegistry.lookupFunctionInfo(canonicalName, arguments.size());
+    if (functionInfo != null && functionInfo.getVolatility() != 
FunctionVolatility.IMMUTABLE) {
+      throw new IllegalStateException(
+          String.format("Function '%s' has %s volatility and is not allowed in 
ingestion transform function: %s; "
+                  + "only IMMUTABLE functions are allowed", functionName, 
functionInfo.getVolatility(),
+              transformFunction));
+    }
+  }
+
   /// Validates all metrics-aggregation configuration for both mechanisms (the 
`aggregateMetrics` flag and ingestion
   /// `aggregationConfigs`) in one place, and returns the set of source 
columns referenced by the aggregation functions
   /// (empty when aggregation is disabled). Consuming-segment rollup keys each 
row on the dictionary ids of the
@@ -912,6 +1008,11 @@ public final class TableConfigUtils {
   /// - Dedup: delegates to [#validateTTLForDedupConfig]; rejects MD5 when 
disabled.
   @VisibleForTesting
   static void validateUpsertAndDedupConfig(TableConfig tableConfig, Schema 
schema) {
+    validateUpsertAndDedupConfig(tableConfig, schema, null);
+  }
+
+  static void validateUpsertAndDedupConfig(TableConfig tableConfig, Schema 
schema,
+      @Nullable TableConfig existingTableConfig) {
     boolean upsertEnabled = tableConfig.isUpsertEnabled();
     boolean dedupEnabled = tableConfig.isDedupEnabled();
     if (!upsertEnabled && !dedupEnabled) {
@@ -1039,6 +1140,8 @@ public final class TableConfigUtils {
       List<TransformConfig> postPartialUpsertTransformConfigs =
           upsertConfig.getPostPartialUpsertTransformConfigs();
       if (postPartialUpsertTransformConfigs != null) {
+        List<TransformConfig> existingPostPartialUpsertTransformConfigs =
+            getPostPartialUpsertTransformConfigs(existingTableConfig);
         Preconditions.checkState(upsertConfig.getMode() == 
UpsertConfig.Mode.PARTIAL,
             "postPartialUpsertTransformConfigs can only be configured for 
PARTIAL upsert tables");
         Set<String> primaryKeyColumns = new 
HashSet<>(schema.getPrimaryKeyColumns());
@@ -1072,6 +1175,8 @@ public final class TableConfigUtils {
                     + columnName + "' in postPartialUpsertTransformConfigs");
           }
           try {
+            validateIngestionTransformFunctionVolatility(transformConfig,
+                existingPostPartialUpsertTransformConfigs);
             FunctionEvaluator expressionEvaluator =
                 
FunctionEvaluatorFactory.getExpressionEvaluator(transformFunction);
             List<String> arguments = expressionEvaluator.getArguments();
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/recordtransformer/ExpressionTransformerTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/recordtransformer/ExpressionTransformerTest.java
index e465874aae1..593ec59d190 100644
--- 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/recordtransformer/ExpressionTransformerTest.java
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/recordtransformer/ExpressionTransformerTest.java
@@ -245,6 +245,32 @@ public class ExpressionTransformerTest {
     Assert.assertFalse(row.isNullValue("payload"));
   }
 
+  @Test
+  public void 
testLegacyNonDeterministicTransformFunctionRemainsRuntimeCompatible() {
+    Schema schema = new Schema.SchemaBuilder()
+        .addSingleValueDimension("eventTimeMs", FieldSpec.DataType.LONG)
+        .build();
+    IngestionConfig ingestionConfig = new IngestionConfig();
+    ingestionConfig.setTransformConfigs(List.of(new 
TransformConfig("eventTimeMs", "now()")));
+    TableConfig tableConfig = new TableConfigBuilder(TableType.REALTIME)
+        
.setTableName("testNonDeterministicTransformFunctionStillRunsAtRuntime")
+        .setIngestionConfig(ingestionConfig)
+        .build();
+    ExpressionTransformer expressionTransformer = new 
ExpressionTransformer(tableConfig, schema);
+
+    GenericRow row = new GenericRow();
+    long lowerBound = System.currentTimeMillis();
+    expressionTransformer.transform(row);
+    long upperBound = System.currentTimeMillis();
+
+    Object value = row.getValue("eventTimeMs");
+    Assert.assertTrue(value instanceof Long, "Expected now() transform to 
produce a LONG value");
+    long eventTimeMs = (Long) value;
+    long toleranceMs = 1000;
+    Assert.assertTrue(eventTimeMs >= lowerBound - toleranceMs);
+    Assert.assertTrue(eventTimeMs <= upperBound + toleranceMs);
+  }
+
   /**
    * If destination field already exists in the row, do not execute transform 
function
    */
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/TableConfigUtilsTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/TableConfigUtilsTest.java
index 178b7cb8a7d..0b36f9dd52c 100644
--- 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/TableConfigUtilsTest.java
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/TableConfigUtilsTest.java
@@ -386,6 +386,60 @@ public class TableConfigUtilsTest {
     ingestionConfig.setTransformConfigs(List.of(new TransformConfig("myCol", 
"reverse(anotherCol)")));
     TableConfigUtils.validate(tableConfig, schema);
 
+    Schema transformSchema = schema;
+    ingestionConfig.setTransformConfigs(List.of(new TransformConfig("myCol", 
"now()")));
+    IllegalStateException nonDeterministicError =
+        expectThrows(IllegalStateException.class, () -> 
TableConfigUtils.validate(tableConfig, transformSchema));
+    assertTrue(nonDeterministicError.getMessage().contains("Function 'now' has 
VOLATILE volatility"));
+
+    IngestionConfig existingIngestionConfig = new IngestionConfig();
+    existingIngestionConfig.setTransformConfigs(List.of(new 
TransformConfig("myCol", "now()")));
+    TableConfig existingTableConfig = new 
TableConfigBuilder(TableType.OFFLINE).setTableName(TABLE_NAME)
+        .setIngestionConfig(existingIngestionConfig)
+        .build();
+    TableConfigUtils.validate(tableConfig, schema, null, existingTableConfig);
+
+    ingestionConfig.setTransformConfigs(List.of(new TransformConfig("myCol", 
"plus(now(), 1)")));
+    nonDeterministicError = expectThrows(IllegalStateException.class,
+        () -> TableConfigUtils.validate(tableConfig, transformSchema, null, 
existingTableConfig));
+    assertTrue(nonDeterministicError.getMessage().contains("Function 'now' has 
VOLATILE volatility"));
+
+    ingestionConfig.setTransformConfigs(List.of(new TransformConfig("myCol", 
"rand()")));
+    nonDeterministicError =
+        expectThrows(IllegalStateException.class, () -> 
TableConfigUtils.validate(tableConfig, transformSchema));
+    assertTrue(nonDeterministicError.getMessage().contains("Function 'rand' 
has VOLATILE volatility"));
+
+    ingestionConfig.setTransformConfigs(List.of(new TransformConfig("myCol", 
"reqId('unused')")));
+    nonDeterministicError =
+        expectThrows(IllegalStateException.class, () -> 
TableConfigUtils.validate(tableConfig, transformSchema));
+    assertTrue(nonDeterministicError.getMessage().contains("Function 'reqid' 
has STABLE volatility"),
+        nonDeterministicError.getMessage());
+
+    ingestionConfig.setTransformConfigs(List.of(new TransformConfig("myCol", 
"rand(123)")));
+    TableConfigUtils.validate(tableConfig, schema);
+
+    // Legacy schema-level transforms are also part of the ingestion pipeline. 
A new table must reject them, while
+    // validation of an existing table stays permissive so unrelated config 
updates are not stranded.
+    ingestionConfig.setTransformConfigs(List.of());
+    schema.getFieldSpecFor("myCol").setTransformFunction("now()");
+    nonDeterministicError =
+        expectThrows(IllegalStateException.class, () -> 
TableConfigUtils.validate(tableConfig, transformSchema));
+    assertTrue(nonDeterministicError.getMessage().contains("Function 'now' has 
VOLATILE volatility"));
+    TableConfigUtils.validate(tableConfig, schema, null, tableConfig);
+    ingestionConfig.setTransformConfigs(List.of(new TransformConfig("myCol", 
"rand(123)")));
+    TableConfigUtils.validate(tableConfig, schema);
+
+    IngestionConfig noOverrideIngestionConfig = new IngestionConfig();
+    noOverrideIngestionConfig.setTransformConfigs(List.of());
+    TableConfig noOverrideTableConfig = new 
TableConfigBuilder(TableType.OFFLINE).setTableName(TABLE_NAME)
+        .setIngestionConfig(noOverrideIngestionConfig)
+        .build();
+    nonDeterministicError = expectThrows(IllegalStateException.class,
+        () -> TableConfigUtils.validate(noOverrideTableConfig, 
transformSchema, null, tableConfig));
+    assertTrue(nonDeterministicError.getMessage().contains("Function 'now' has 
VOLATILE volatility"));
+
+    schema.getFieldSpecFor("myCol").setTransformFunction(null);
+
     // valid transform configs
     schema = new Schema.SchemaBuilder().setSchemaName(TABLE_NAME)
         .addSingleValueDimension("myCol", DataType.STRING)
@@ -4317,6 +4371,52 @@ public class TableConfigUtilsTest {
     }
   }
 
+  @Test
+  public void testPostPartialUpsertTransformVolatility() {
+    Schema schema = new Schema.SchemaBuilder().setSchemaName(TABLE_NAME)
+        .addSingleValueDimension("pk", DataType.STRING)
+        .addMetric("total", DataType.DOUBLE)
+        .addDateTime(TIME_COLUMN, DataType.LONG, "1:MILLISECONDS:EPOCH", 
"1:MILLISECONDS")
+        .setPrimaryKeyColumns(List.of("pk"))
+        .build();
+    Map<String, String> streamConfigs = getStreamConfigs();
+
+    TableConfig nonDeterministicConfig = 
buildPostPartialUpsertTransformTableConfig(streamConfigs, "now()");
+    IllegalStateException nonDeterministicError = 
expectThrows(IllegalStateException.class,
+        () -> 
TableConfigUtils.validateUpsertAndDedupConfig(nonDeterministicConfig, schema));
+    assertTrue(nonDeterministicError.getMessage().contains("Function 'now' has 
VOLATILE volatility"));
+
+    TableConfig existingConfig = 
buildPostPartialUpsertTransformTableConfig(streamConfigs, "now()");
+    TableConfigUtils.validateUpsertAndDedupConfig(nonDeterministicConfig, 
schema, existingConfig);
+
+    TableConfig changedConfig = 
buildPostPartialUpsertTransformTableConfig(streamConfigs, "plus(now(), 1)");
+    nonDeterministicError = expectThrows(IllegalStateException.class,
+        () -> TableConfigUtils.validateUpsertAndDedupConfig(changedConfig, 
schema, existingConfig));
+    assertTrue(nonDeterministicError.getMessage().contains("Function 'now' has 
VOLATILE volatility"));
+
+    TableConfig randomConfig = 
buildPostPartialUpsertTransformTableConfig(streamConfigs, "rand()");
+    nonDeterministicError = expectThrows(IllegalStateException.class,
+        () -> TableConfigUtils.validateUpsertAndDedupConfig(randomConfig, 
schema));
+    assertTrue(nonDeterministicError.getMessage().contains("Function 'rand' 
has VOLATILE volatility"));
+
+    TableConfig seededRandomConfig = 
buildPostPartialUpsertTransformTableConfig(streamConfigs, "rand(123)");
+    TableConfigUtils.validateUpsertAndDedupConfig(seededRandomConfig, schema);
+  }
+
+  private static TableConfig 
buildPostPartialUpsertTransformTableConfig(Map<String, String> streamConfigs,
+      String transformFunction) {
+    UpsertConfig upsertConfig = new UpsertConfig(UpsertConfig.Mode.PARTIAL);
+    upsertConfig.setPostPartialUpsertTransformConfigs(
+        List.of(new TransformConfig("total", transformFunction)));
+    return new TableConfigBuilder(TableType.REALTIME).setTableName(TABLE_NAME)
+        .setTimeColumnName(TIME_COLUMN)
+        .setUpsertConfig(upsertConfig)
+        .setNullHandlingEnabled(true)
+        .setStreamConfigs(streamConfigs)
+        .setRoutingConfig(STRICT_REPLICA_ROUTING_CONFIG)
+        .build();
+  }
+
   @Test
   public void 
testValidateBackwardCompatibilityAllowsPartialUpsertStrategyChanges() {
     // Build two PARTIAL upsert table configs that differ only in 
partialUpsertStrategies and
diff --git 
a/pinot-spi/src/main/java/org/apache/pinot/spi/annotations/FunctionVolatility.java
 
b/pinot-spi/src/main/java/org/apache/pinot/spi/annotations/FunctionVolatility.java
new file mode 100644
index 00000000000..08d8f2827f8
--- /dev/null
+++ 
b/pinot-spi/src/main/java/org/apache/pinot/spi/annotations/FunctionVolatility.java
@@ -0,0 +1,37 @@
+/**
+ * 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.pinot.spi.annotations;
+
+
+/// Describes how a scalar function's result or side effects can vary across 
invocations.
+///
+/// The categories follow
+/// [PostgreSQL's function volatility 
model](https://www.postgresql.org/docs/current/xfunc-volatility.html):
+///
+/// - `IMMUTABLE`: the result depends only on the explicit arguments and never 
changes for the same inputs.
+/// - `STABLE`: the result is constant for the same arguments within one 
query, but can change between queries.
+/// - `VOLATILE`: the result can change on every invocation, or the function 
has side effects.
+///
+/// This semantic metadata is interpreted by context-specific policies and 
does not replace
+/// [ScalarFunction#isDeterministic()], Pinot's existing compile-time query 
evaluation hint.
+public enum FunctionVolatility {
+  IMMUTABLE,
+  STABLE,
+  VOLATILE
+}
diff --git 
a/pinot-spi/src/main/java/org/apache/pinot/spi/annotations/ScalarFunction.java 
b/pinot-spi/src/main/java/org/apache/pinot/spi/annotations/ScalarFunction.java
index f78b364ee57..88b573a46ff 100644
--- 
a/pinot-spi/src/main/java/org/apache/pinot/spi/annotations/ScalarFunction.java
+++ 
b/pinot-spi/src/main/java/org/apache/pinot/spi/annotations/ScalarFunction.java
@@ -62,10 +62,20 @@ public @interface ScalarFunction {
    */
   boolean isVarArg() default false;
 
-  /**
-   * Whether the scalar function should be treated as deterministic (eligible 
for compile-time evaluation).
-   */
+  /// Whether the scalar function is eligible for compile-time query 
evaluation.
+  ///
+  /// This is independent of [#volatility()] so that wall-clock functions can 
retain their existing once-per-query
+  /// evaluation behavior. For backward compatibility, `false` is also treated 
as [FunctionVolatility#VOLATILE].
   boolean isDeterministic() default true;
 
+  /// Returns the semantic volatility category used by context-specific 
policies.
+  ///
+  /// Persisted ingestion transforms require [FunctionVolatility#IMMUTABLE] 
functions. Unlike PostgreSQL, Pinot
+  /// defaults this category to `IMMUTABLE` for compatibility with existing 
UDFs, so function authors must explicitly
+  /// classify functions that read mutable or external state. This 
compatibility default does not imply that every
+  /// existing function has been audited for environment-sensitive behavior. 
When class-level and method-level
+  /// declarations coexist, the most volatile category wins.
+  FunctionVolatility volatility() default FunctionVolatility.IMMUTABLE;
+
   @Deprecated boolean isPlaceholder() default false;
 }
diff --git 
a/pinot-tools/src/main/resources/examples/stream/fineFoodReviews/fineFoodReviews_realtime_table_config.json
 
b/pinot-tools/src/main/resources/examples/stream/fineFoodReviews/fineFoodReviews_realtime_table_config.json
index 2fe1b6a0751..22d718fae0f 100644
--- 
a/pinot-tools/src/main/resources/examples/stream/fineFoodReviews/fineFoodReviews_realtime_table_config.json
+++ 
b/pinot-tools/src/main/resources/examples/stream/fineFoodReviews/fineFoodReviews_realtime_table_config.json
@@ -40,7 +40,7 @@
     "transformConfigs": [
       {
         "columnName": "ts",
-        "transformFunction": "now()"
+        "transformFunction": "1704067200000"
       }
     ]
   },
diff --git 
a/pinot-tools/src/main/resources/examples/stream/fineFoodReviews_part_0/fineFoodReviews_part_0_realtime_table_config.json
 
b/pinot-tools/src/main/resources/examples/stream/fineFoodReviews_part_0/fineFoodReviews_part_0_realtime_table_config.json
index dd84b3e1752..3fe48d17bbb 100644
--- 
a/pinot-tools/src/main/resources/examples/stream/fineFoodReviews_part_0/fineFoodReviews_part_0_realtime_table_config.json
+++ 
b/pinot-tools/src/main/resources/examples/stream/fineFoodReviews_part_0/fineFoodReviews_part_0_realtime_table_config.json
@@ -41,7 +41,7 @@
     "transformConfigs": [
       {
         "columnName": "ts",
-        "transformFunction": "now()"
+        "transformFunction": "1704067200000"
       }
     ]
   },
diff --git 
a/pinot-tools/src/main/resources/examples/stream/fineFoodReviews_part_1/fineFoodReviews_part_1_realtime_table_config.json
 
b/pinot-tools/src/main/resources/examples/stream/fineFoodReviews_part_1/fineFoodReviews_part_1_realtime_table_config.json
index 58e0967c856..2b2e2a47d64 100644
--- 
a/pinot-tools/src/main/resources/examples/stream/fineFoodReviews_part_1/fineFoodReviews_part_1_realtime_table_config.json
+++ 
b/pinot-tools/src/main/resources/examples/stream/fineFoodReviews_part_1/fineFoodReviews_part_1_realtime_table_config.json
@@ -41,7 +41,7 @@
     "transformConfigs": [
       {
         "columnName": "ts",
-        "transformFunction": "now()"
+        "transformFunction": "1704067200000"
       }
     ]
   },


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to