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

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


The following commit(s) were added to refs/heads/master by this push:
     new bff68004fda [fix](fe) Keep routine load job properties across replay 
and apply altered enclose and escape (#70121)
bff68004fda is described below

commit bff68004fda41c0649efc9de4c5d14df29069440
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Thu Nov 5 07:42:33 2026 +0000

    [fix](fe) Keep routine load job properties across replay and apply altered 
enclose and escape (#70121)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #70118
    
    Problem Summary:
    
    **In short.** When an FE rebuilds a routine load job from metadata (a
    follower replaying the edit log, a restart, a new master), the job loses
    `max_filter_ratio`, `exec_mem_limit`, `send_batch_parallelism`,
    `load_to_single_tablet`, `enclose`, `escape` and the
    `enable_memtable_on_sink_node` it was created with, and runs with
    defaults from then on. Separately, `ALTER ROUTINE LOAD` on `enclose` /
    `escape` never reaches the running job, and a multi-table CSV job never
    applies `enclose` / `escape` at all. This PR restores the values when
    the job is rebuilt, applies the altered ones, and hands both to the
    tasks of multi-table jobs.
    
    **Background.** A routine load job is persisted with GSON, which writes
    only the fields annotated with `@SerializedName`; these seven are plain
    fields. `gsonPostProcess` re-parses the stored CREATE statement, but
    only to rebuild the `RoutineLoadDesc` (columns, filters, separators,
    partitions, merge type), and restores the partial update settings from
    the persisted `jobProperties` map. CREATE records the other values in
    `jobProperties` as well (all but `exec_mem_limit`), and SHOW ROUTINE
    LOAD prints them from there, so the loss does not show. Found while
    making `SHOW CREATE ROUTINE LOAD` print these properties (#70118).
    
    **The problem, and what it cost.** Measured on master `856194ece0e` with
    a unit test that creates jobs from SQL and rebuilds them with a GSON
    round trip on a thread without a session, as a follower or a restarting
    FE does:
    
    | | Job as created | Job rebuilt from metadata |
    |---|---|---|
    | `max_filter_ratio` = 0.5 | 0.5 | 1.0: up to every row may be filtered
    instead of the job pausing |
    | `exec_mem_limit` = 4294967296 | 4 GB | 2 GB |
    | `send_batch_parallelism` = 3, or 4 from the creating session | 3 / 4 |
    1 |
    | `load_to_single_tablet` = true | true | false |
    | `enclose` `"`, `escape` `\` | `"` / `\` | none: quoted CSV fields are
    split at the separators inside the quotes |
    | `enable_memtable_on_sink_node` of the creating session (on by default)
    | true | false |
    | `ALTER ... ("enclose" = "'", "escape" = "\|")` | tasks keep `"` / `\`
    | SHOW ROUTINE LOAD shows the new values, tasks never use them |
    | `ALTER ... ("enclose" = "ab")` | accepted, without effect | CREATE
    rejects it |
    
    The CSV and filter ratio cases change what gets loaded, without any
    error, after an event (a failover, a restart) that has nothing to do
    with the job.
    
    A multi-table CSV job (one created without a target table) does not use
    its `enclose` and `escape` even before it is rebuilt. Each of its tasks
    is planned from the backend's per-table request, which does not carry
    them, with the job's settings laid over it by
    `NereidsStreamLoadTask#setMultiTableBaseTaskInfo`, which copies the
    separators and the JSON properties but not these two. So its quoted
    fields are split at the separators inside the quotes, whatever CREATE or
    ALTER set.
    
    **How this PR fixes it.**
    
    - `gsonPostProcess` restores `max_filter_ratio`,
    `send_batch_parallelism`, `load_to_single_tablet`, `enclose` and
    `escape` from `jobProperties`, in the loop that already restores the
    partial update settings from there. CREATE has recorded these values
    since each property was introduced, and ALTER records the new ones; a
    job created before one of them existed has no key and keeps the default,
    as today. `exec_mem_limit`, which no ALTER changes and `jobProperties`
    does not hold, is taken from the CREATE statement that `gsonPostProcess`
    already re-parses.
    - CREATE records the creating session's `enable_memtable_on_sink_node`
    in the job's persisted session variables, next to `sql_mode`, and
    `gsonPostProcess` restores it. A job created before this has no record
    and keeps `false`, which is what it has run with after every restart so
    far.
    - `ALTER ROUTINE LOAD` checks `enclose` and `escape` the way CREATE
    does, and `modifyCommonJobProperties`, which Kafka and Kinesis jobs and
    the replay of an ALTER share, installs them on the job. The two rules
    move into `CsvFileFormatProperties.parseEnclose` / `parseEscape`, which
    `analyzeFileFormatProperties` now calls without any change in behavior.
    - `NereidsStreamLoadTask#setMultiTableBaseTaskInfo` copies `enclose` and
    `escape` too, so the tasks of a multi-table job use the values CREATE or
    ALTER set, as those of a single-table job do.
    - CREATE and ALTER ROUTINE LOAD take only an ASCII character as
    `enclose` or `escape` (`CreateRoutineLoadInfo#checkCsvControlValue`),
    which is what the documentation of both properties asks for: a
    single-byte character. No other value works for a routine load. Each
    task is planned from the job's byte turned into text with `new
    String(new byte[]{b})` and parsed again (`NereidsDataDescription`, since
    #50882), which keeps every ASCII byte but turns every byte from 0x80 up
    into 0xFD as enclose and 0xEF as escape. A job created with `"escape" =
    "é"` (0xC3) has therefore run with escape 0xEF from its first task on,
    which drops the first byte of full-width characters such as `,` in every
    string cell. And routine load validates its data as UTF-8 by default,
    where a single byte from 0x80 up is absent or part of a character
    anyway: 0xC3 as escape would cut the first byte out of every `é`. CREATE
    records the byte with `new String(new byte[]{b})` as it always has,
    which keeps every value it now takes.
    - Values that an older version accepted do not break the rebuild. The
    ASCII check runs only when a statement is admitted, not when
    `gsonPostProcess` re-parses a stored CREATE statement, as the compute
    group and workload group checks beside it already do; failing there
    would turn the job CANCELLED for good. A value from an older version
    that CREATE and ALTER now reject, one that is not ASCII (an older CREATE
    recorded every byte from 0x80 up as U+FFFD) or one that an older ALTER
    stored without any check, is ignored with a warning instead of failing
    the replay, in the same way when the job is rebuilt and when the ALTER
    is replayed. The replay of such an ALTER leaves the job's value as it
    was, and a rebuilt job keeps no enclose or escape, which is what it has
    run with after every restart so far; ALTER ROUTINE LOAD can set an ASCII
    value again.
    
    No new persisted field: the only addition is one key in the job's
    existing session variable map, which older FEs ignore.
    
    **Results.**
    
    | | Before | After |
    |---|---|---|
    | declared properties above, job rebuilt from metadata | defaults | as
    declared, also in what tasks are built from |
    | `send_batch_parallelism` and `enable_memtable_on_sink_node` from the
    creating session | 1 / false | the session's values |
    | ALTER of `enclose` / `escape` | shown, never used | used by the next
    task, kept when the job is rebuilt |
    | `enclose` / `escape` of a multi-table CSV job, from CREATE or ALTER |
    never used | used by its tasks |
    | ALTER with `"enclose" = "ab"` | accepted | `enclose should not be
    longer than one byte.` |
    | CREATE or ALTER with an `enclose` / `escape` that is not ASCII (`é`,
    U+FFFD, `,`) | CREATE takes a byte of it, which tasks get as 0xFD / 0xEF
    from 0x80 up; ALTER accepts it without effect | `enclose must be an
    ASCII character` |
    | such a value, or an invalid one, stored by an older version | no
    effect | no effect, with a warning; the replay of the job and of the
    ALTER goes on |
    
    branch-4.1 and branch-4.2 have the same gaps.
---
 .../fileformat/CsvFileFormatProperties.java        |  33 ++-
 .../doris/load/routineload/RoutineLoadJob.java     |  57 ++++-
 .../doris/nereids/load/NereidsStreamLoadTask.java  |   2 +
 .../plans/commands/AlterRoutineLoadCommand.java    |  13 +-
 .../plans/commands/info/CreateRoutineLoadInfo.java |  24 ++
 .../load/routineload/RoutineLoadJobReplayTest.java | 247 +++++++++++++++++++++
 .../nereids/load/NereidsStreamLoadTaskTest.java    |  15 ++
 .../commands/AlterRoutineLoadCommandTest.java      |  44 ++++
 .../routine_load/test_routine_load_property.groovy |  18 ++
 9 files changed, 438 insertions(+), 15 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/fileformat/CsvFileFormatProperties.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/fileformat/CsvFileFormatProperties.java
index 57ac88e6b2a..ba04368c3d0 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/fileformat/CsvFileFormatProperties.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/fileformat/CsvFileFormatProperties.java
@@ -110,20 +110,13 @@ public class CsvFileFormatProperties extends 
FileFormatProperties {
             String enclosedString = getOrDefault(formatProperties, 
PROP_ENCLOSE,
                     "", isRemoveOriginProperty);
             if (!Strings.isNullOrEmpty(enclosedString)) {
-                if (enclosedString.length() > 1) {
-                    throw new AnalysisException("enclose should not be longer 
than one byte.");
-                }
-                enclose = (byte) enclosedString.charAt(0);
+                enclose = parseEnclose(enclosedString);
             }
 
             String escapeStr = getOrDefault(formatProperties, PROP_ESCAPE,
                     "", isRemoveOriginProperty);
             if (!Strings.isNullOrEmpty(escapeStr)) {
-                if (escapeStr.length() != 1) {
-                    throw new AnalysisException("escape must be single-char");
-                } else {
-                    escape = escapeStr.getBytes()[0];
-                }
+                escape = parseEscape(escapeStr);
             }
 
             trimDoubleQuotes = Boolean.valueOf(getOrDefault(formatProperties,
@@ -160,6 +153,28 @@ public class CsvFileFormatProperties extends 
FileFormatProperties {
         }
     }
 
+    // The byte an enclose property stands for, 0 for an empty one.
+    public static byte parseEnclose(String enclose) {
+        if (Strings.isNullOrEmpty(enclose)) {
+            return 0;
+        }
+        if (enclose.length() > 1) {
+            throw new AnalysisException("enclose should not be longer than one 
byte.");
+        }
+        return (byte) enclose.charAt(0);
+    }
+
+    // The byte an escape property stands for, 0 for an empty one.
+    public static byte parseEscape(String escape) {
+        if (Strings.isNullOrEmpty(escape)) {
+            return 0;
+        }
+        if (escape.length() != 1) {
+            throw new AnalysisException("escape must be single-char");
+        }
+        return escape.getBytes()[0];
+    }
+
     public void checkSupportedCompressionType(boolean isWrite) {
         // Currently, only check for write operation.
         // Because we only support a subset of compression type for writing.
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RoutineLoadJob.java
 
b/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RoutineLoadJob.java
index 68f1c320d58..d861bd6a4c8 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RoutineLoadJob.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RoutineLoadJob.java
@@ -335,6 +335,8 @@ public abstract class RoutineLoadJob
         } else {
             sessionVariables.put(SessionVariable.SQL_MODE, 
String.valueOf(SqlModeHelper.MODE_DEFAULT));
         }
+        // a snapshot of the creating session, which gsonPostProcess has no 
other way to restore
+        sessionVariables.put(SessionVariable.ENABLE_MEMTABLE_ON_SINK_NODE, 
String.valueOf(memtableOnSinkNode));
     }
 
     /**
@@ -362,6 +364,8 @@ public abstract class RoutineLoadJob
         } else {
             sessionVariables.put(SessionVariable.SQL_MODE, 
String.valueOf(SqlModeHelper.MODE_DEFAULT));
         }
+        // a snapshot of the creating session, which gsonPostProcess has no 
other way to restore
+        sessionVariables.put(SessionVariable.ENABLE_MEMTABLE_ON_SINK_NODE, 
String.valueOf(memtableOnSinkNode));
     }
 
     protected void setOptional(CreateRoutineLoadInfo info) throws 
UserException {
@@ -2064,7 +2068,10 @@ public abstract class RoutineLoadJob
                 uniqueKeyUpdateMode = TUniqueKeyUpdateMode.UPSERT;
             }
         }
-        // Process remaining properties
+        // Process remaining properties. max_filter_ratio, 
send_batch_parallelism, load_to_single_tablet, enclose and
+        // escape are fields that are not persisted: CREATE records the value 
each one was given and ALTER the new
+        // ones, so without this a follower, a restarted FE or a new master 
would run the job with defaults. A job
+        // created before one of them existed has no key and keeps the default.
         jobProperties.forEach((k, v) -> {
             if (k.equals(CreateRoutineLoadInfo.PARTIAL_COLUMNS)) {
                 // Backward compatibility: only use partial_columns if 
unique_key_update_mode is not set
@@ -2081,8 +2088,24 @@ public abstract class RoutineLoadJob
                 } else {
                     partialUpdateNewKeyPolicy = 
TPartialUpdateNewRowPolicy.APPEND;
                 }
+            } else if 
(k.equals(CreateRoutineLoadInfo.MAX_FILTER_RATIO_PROPERTY)) {
+                maxFilterRatio = Double.parseDouble(v);
+            } else if (k.equals(CreateRoutineLoadInfo.SEND_BATCH_PARALLELISM)) 
{
+                sendBatchParallelism = Integer.parseInt(v);
+            } else if (k.equals(CreateRoutineLoadInfo.LOAD_TO_SINGLE_TABLET)) {
+                loadToSingleTablet = Boolean.parseBoolean(v);
+            } else if (k.equals(CsvFileFormatProperties.PROP_ENCLOSE)) {
+                enclose = csvControlByte(k, v, enclose);
+            } else if (k.equals(CsvFileFormatProperties.PROP_ESCAPE)) {
+                escape = csvControlByte(k, v, escape);
             }
         });
+        // Snapshotted from the creating session. A job created before the 
snapshot was persisted keeps false, which
+        // is what it has run with after every restart.
+        String memtableOnSinkNodeValue = 
sessionVariables.get(SessionVariable.ENABLE_MEMTABLE_ON_SINK_NODE);
+        if (memtableOnSinkNodeValue != null) {
+            memtableOnSinkNode = Boolean.parseBoolean(memtableOnSinkNodeValue);
+        }
         try {
             ConnectContext ctx = new ConnectContext();
             
ctx.setDatabase(Env.getCurrentEnv().getInternalCatalog().getDb(dbId)
@@ -2099,7 +2122,8 @@ public abstract class RoutineLoadJob
                 CreateRoutineLoadCommand command = (CreateRoutineLoadCommand) 
nereidsParser.parseSingle(
                         origStmt.originStmt);
                 CreateRoutineLoadInfo createRoutineLoadInfo = 
command.getCreateRoutineLoadInfo();
-                // This re-parse only rebuilds the RoutineLoadDesc; it must 
not re-check resources
+                // This re-parse only rebuilds the RoutineLoadDesc and 
exec_mem_limit, which no ALTER
+                // changes and jobProperties does not hold; it must not 
re-check resources
                 // that can legitimately have disappeared since the job was 
created, because the
                 // catch below turns any failure into the final CANCELLED 
state.
                 createRoutineLoadInfo.setReplay(true);
@@ -2118,6 +2142,7 @@ public abstract class RoutineLoadJob
                 }
                 createRoutineLoadInfo.validate(ctx);
                 setRoutineLoadDesc(createRoutineLoadInfo.getRoutineLoadDesc());
+                execMemLimit = createRoutineLoadInfo.getExecMemLimit();
             } finally {
                 ctx.cleanup();
             }
@@ -2205,6 +2230,34 @@ public abstract class RoutineLoadJob
             this.jobProperties.put(CreateRoutineLoadInfo.PARTIAL_COLUMNS, 
String.valueOf(isPartialUpdate));
             
this.jobProperties.put(CreateRoutineLoadInfo.UNIQUE_KEY_UPDATE_MODE, 
uniqueKeyUpdateMode.name());
         }
+
+        // Tasks take enclose and escape from these fields, not from 
jobProperties, where the caller puts the
+        // new values
+        if (jobProperties.containsKey(CsvFileFormatProperties.PROP_ENCLOSE)) {
+            this.enclose = csvControlByte(CsvFileFormatProperties.PROP_ENCLOSE,
+                    jobProperties.get(CsvFileFormatProperties.PROP_ENCLOSE), 
this.enclose);
+        }
+        if (jobProperties.containsKey(CsvFileFormatProperties.PROP_ESCAPE)) {
+            this.escape = csvControlByte(CsvFileFormatProperties.PROP_ESCAPE,
+                    jobProperties.get(CsvFileFormatProperties.PROP_ESCAPE), 
this.escape);
+        }
+    }
+
+    // The byte an enclose or escape value stands for, as CREATE takes it. The 
metadata can hold a value that CREATE
+    // and ALTER reject now: ALTER of an older version did not check it, and 
CREATE of an older version took any
+    // character and recorded its byte, which reads back as U+FFFD from 0x80 
up. Such a value is ignored the same way
+    // when the job is rebuilt and when an ALTER is replayed: the job keeps 
the byte it has, where an exception would
+    // fail the replay of the metadata. A job rebuilt with such a value keeps 
none, as after every restart so far.
+    private byte csvControlByte(String key, String value, byte current) {
+        try {
+            CreateRoutineLoadInfo.checkCsvControlValue(key, value);
+            return key.equals(CsvFileFormatProperties.PROP_ENCLOSE)
+                    ? CsvFileFormatProperties.parseEnclose(value) : 
CsvFileFormatProperties.parseEscape(value);
+        } catch (AnalysisException | 
org.apache.doris.nereids.exceptions.AnalysisException e) {
+            LOG.warn("routine load job {} keeps its {} instead of the invalid 
value '{}': {}",
+                    id, key, value, e.getMessage());
+            return current;
+        }
     }
 
     /**
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/load/NereidsStreamLoadTask.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/load/NereidsStreamLoadTask.java
index 95e31360ced..d3a7ba9ef75 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/load/NereidsStreamLoadTask.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/load/NereidsStreamLoadTask.java
@@ -382,6 +382,8 @@ public class NereidsStreamLoadTask implements 
NereidsLoadTaskInfo {
         this.strictMode = task.isStrictMode();
         this.timezone = task.getTimezone();
         this.formatType = task.getFormatType();
+        this.enclose = task.getEnclose();
+        this.escape = task.getEscape();
         this.stripOuterArray = task.isStripOuterArray();
         this.numAsString = task.isNumAsString();
         this.jsonPaths = task.getJsonPaths();
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterRoutineLoadCommand.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterRoutineLoadCommand.java
index 3473a5e3fd3..4e52c730cab 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterRoutineLoadCommand.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterRoutineLoadCommand.java
@@ -312,14 +312,19 @@ public class AlterRoutineLoadCommand extends AlterCommand 
{
             }
         }
 
+        // Checked as CREATE checks them, since the job takes its byte from 
the value the same way
         if (jobProperties.containsKey(CsvFileFormatProperties.PROP_ENCLOSE)) {
-            analyzedJobProperties.put(CsvFileFormatProperties.PROP_ENCLOSE,
-                    jobProperties.get(CsvFileFormatProperties.PROP_ENCLOSE));
+            String enclose = 
jobProperties.get(CsvFileFormatProperties.PROP_ENCLOSE);
+            
CreateRoutineLoadInfo.checkCsvControlValue(CsvFileFormatProperties.PROP_ENCLOSE,
 enclose);
+            CsvFileFormatProperties.parseEnclose(enclose);
+            analyzedJobProperties.put(CsvFileFormatProperties.PROP_ENCLOSE, 
enclose);
         }
 
         if (jobProperties.containsKey(CsvFileFormatProperties.PROP_ESCAPE)) {
-            analyzedJobProperties.put(CsvFileFormatProperties.PROP_ESCAPE,
-                    jobProperties.get(CsvFileFormatProperties.PROP_ESCAPE));
+            String escape = 
jobProperties.get(CsvFileFormatProperties.PROP_ESCAPE);
+            
CreateRoutineLoadInfo.checkCsvControlValue(CsvFileFormatProperties.PROP_ESCAPE, 
escape);
+            CsvFileFormatProperties.parseEscape(escape);
+            analyzedJobProperties.put(CsvFileFormatProperties.PROP_ESCAPE, 
escape);
         }
 
         if 
(jobProperties.containsKey(CsvFileFormatProperties.PROP_EMPTY_FIELD_AS_NULL)) {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateRoutineLoadInfo.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateRoutineLoadInfo.java
index 94ed9345356..b528912cc76 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateRoutineLoadInfo.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateRoutineLoadInfo.java
@@ -57,6 +57,7 @@ import org.apache.doris.resource.workloadgroup.WorkloadGroup;
 import org.apache.doris.thrift.TPartialUpdateNewRowPolicy;
 import org.apache.doris.thrift.TUniqueKeyUpdateMode;
 
+import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Maps;
@@ -284,6 +285,19 @@ public class CreateRoutineLoadInfo {
         return mode;
     }
 
+    /**
+     * Rejects an enclose or escape that is not an ASCII character. Tasks are 
planned from the job's byte turned into
+     * text and parsed again (NereidsDataDescription), which keeps an ASCII 
byte but turns every byte from 0x80 up into
+     * 0xFD as enclose and 0xEF as escape, and in UTF-8 data such a byte is 
absent or part of a character anyway.
+     * CREATE and ALTER check with this; a job rebuilt from metadata ignores 
such a value that an older version took
+     * (RoutineLoadJob#csvControlByte).
+     */
+    public static void checkCsvControlValue(String key, String value) throws 
AnalysisException {
+        if (value != null && !CharMatcher.ascii().matchesAllOf(value)) {
+            throw new AnalysisException(key + " must be an ASCII character");
+        }
+    }
+
     /**
      * Checks a routine load job's workload group the way its tasks resolve it 
({@code KafkaTaskInfo#rePlan}):
      * as {@code user}, and in cloud mode in the namespace of {@code 
computeGroup}, the group the job runs in. A
@@ -707,6 +721,16 @@ public class CreateRoutineLoadInfo {
             }
         }
 
+        // Like the groups above, checked only when a statement is admitted, 
see isReplay: an older version took any
+        // character here, and failing on the metadata load path would cancel 
the job permanently. The rebuilt job
+        // takes enclose and escape from jobProperties, ignoring a value there 
that this rejects
+        // (RoutineLoadJob#csvControlByte).
+        if (!isReplay) {
+            checkCsvControlValue(CsvFileFormatProperties.PROP_ENCLOSE,
+                    jobProperties.get(CsvFileFormatProperties.PROP_ENCLOSE));
+            checkCsvControlValue(CsvFileFormatProperties.PROP_ESCAPE,
+                    jobProperties.get(CsvFileFormatProperties.PROP_ESCAPE));
+        }
         String format = 
jobProperties.getOrDefault(FileFormatProperties.PROP_FORMAT, "csv");
         fileFormatProperties = 
FileFormatProperties.createFileFormatProperties(format);
         fileFormatProperties.analyzeFileFormatProperties(jobProperties, false);
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/load/routineload/RoutineLoadJobReplayTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/load/routineload/RoutineLoadJobReplayTest.java
new file mode 100644
index 00000000000..b70acbf4602
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/load/routineload/RoutineLoadJobReplayTest.java
@@ -0,0 +1,247 @@
+// 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.doris.load.routineload;
+
+import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.datasource.property.fileformat.CsvFileFormatProperties;
+import org.apache.doris.load.routineload.kafka.KafkaRoutineLoadJob;
+import org.apache.doris.nereids.load.NereidsRoutineLoadTaskInfo;
+import org.apache.doris.nereids.parser.NereidsParser;
+import 
org.apache.doris.nereids.trees.plans.commands.info.CreateRoutineLoadInfo;
+import 
org.apache.doris.nereids.trees.plans.commands.load.CreateRoutineLoadCommand;
+import org.apache.doris.persist.AlterRoutineLoadJobOperationLog;
+import org.apache.doris.persist.gson.GsonUtils;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.OriginStatement;
+import org.apache.doris.qe.SessionVariable;
+import org.apache.doris.utframe.TestWithFeService;
+
+import com.google.common.collect.Maps;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+/**
+ * A follower replaying the edit log, a restarted FE and a new master rebuild 
a routine load job from its metadata:
+ * a GSON round trip and gsonPostProcess, on a thread without a session. The 
rebuilt job has to run as the job did
+ * before, including what the tasks are built from.
+ */
+public class RoutineLoadJobReplayTest extends TestWithFeService {
+
+    @Override
+    protected void runBeforeAll() throws Exception {
+        createDatabaseAndUse("rl_replay_db");
+        // load_to_single_tablet needs a randomly distributed table
+        createTable("CREATE TABLE rl_replay_db.dup_tbl (k1 INT, v1 
VARCHAR(32)) DUPLICATE KEY(k1) "
+                + "DISTRIBUTED BY RANDOM BUCKETS 1 PROPERTIES 
('replication_num' = '1')");
+    }
+
+    // What RoutineLoadManager#createRoutineLoadJob does, except registering 
the job.
+    private KafkaRoutineLoadJob createJob(String sql) throws Exception {
+        CreateRoutineLoadCommand command = (CreateRoutineLoadCommand) new 
NereidsParser().parseSingle(sql);
+        CreateRoutineLoadInfo info = command.getCreateRoutineLoadInfo();
+        info.validate(connectContext);
+        KafkaRoutineLoadJob job = KafkaRoutineLoadJob.fromCreateInfo(info, 
connectContext);
+        job.setOrigStmt(new OriginStatement(sql, 0));
+        return job;
+    }
+
+    private KafkaRoutineLoadJob replay(RoutineLoadJob job) {
+        String json = GsonUtils.GSON.toJson(job);
+        ConnectContext.remove();
+        try {
+            return (KafkaRoutineLoadJob) GsonUtils.GSON.fromJson(json, 
RoutineLoadJob.class);
+        } finally {
+            connectContext.setThreadLocalInfo();
+        }
+    }
+
+    @Test
+    public void testReplayKeepsTheDeclaredProperties() throws Exception {
+        KafkaRoutineLoadJob job = createJob("CREATE ROUTINE LOAD 
rl_replay_db.job_declared ON dup_tbl\n"
+                + "PROPERTIES (\n"
+                + "'max_filter_ratio' = '0.5',\n"
+                + "'exec_mem_limit' = '4294967296',\n"
+                + "'send_batch_parallelism' = '3',\n"
+                + "'load_to_single_tablet' = 'true',\n"
+                + "'enclose' = '\"',\n"
+                + "'escape' = '\\\\'\n"
+                + ")\n"
+                + "FROM KAFKA ('kafka_broker_list' = '127.0.0.1:9092', 
'kafka_topic' = 'topic_declared')");
+
+        KafkaRoutineLoadJob replayed = replay(job);
+        Assertions.assertEquals(RoutineLoadJob.JobState.NEED_SCHEDULE, 
replayed.getState());
+        Assertions.assertEquals(0.5, replayed.getMaxFilterRatio());
+        Assertions.assertEquals(4294967296L, replayed.getMemLimit());
+        Assertions.assertEquals(3, replayed.getSendBatchParallelism());
+        Assertions.assertTrue(replayed.isLoadToSingleTablet());
+        NereidsRoutineLoadTaskInfo taskInfo = 
replayed.toNereidsRoutineLoadTaskInfo();
+        Assertions.assertEquals('"', taskInfo.getEnclose());
+        Assertions.assertEquals('\\', taskInfo.getEscape());
+        Assertions.assertEquals(0.5, taskInfo.getMaxFilterRatio());
+        Assertions.assertEquals(4294967296L, taskInfo.getMemLimit());
+        Assertions.assertEquals(3, taskInfo.getSendBatchParallelism());
+        Assertions.assertTrue(taskInfo.isLoadToSingleTablet());
+    }
+
+    // What CREATE takes from the creating session when the statement leaves 
it out.
+    @Test
+    public void testReplayKeepsWhatTheCreatingSessionGave() throws Exception {
+        SessionVariable sessionVariable = connectContext.getSessionVariable();
+        int sendBatchParallelism = sessionVariable.sendBatchParallelism;
+        boolean memtableOnSinkNode = sessionVariable.enableMemtableOnSinkNode;
+        KafkaRoutineLoadJob withDefaults;
+        KafkaRoutineLoadJob withSessionValues;
+        try {
+            withDefaults = createJob("CREATE ROUTINE LOAD 
rl_replay_db.job_session_default ON dup_tbl\n"
+                    + "FROM KAFKA ('kafka_broker_list' = '127.0.0.1:9092', 
'kafka_topic' = 'topic_session')");
+            sessionVariable.sendBatchParallelism = 4;
+            sessionVariable.enableMemtableOnSinkNode = false;
+            withSessionValues = createJob("CREATE ROUTINE LOAD 
rl_replay_db.job_session_set ON dup_tbl\n"
+                    + "FROM KAFKA ('kafka_broker_list' = '127.0.0.1:9092', 
'kafka_topic' = 'topic_session')");
+        } finally {
+            sessionVariable.sendBatchParallelism = sendBatchParallelism;
+            sessionVariable.enableMemtableOnSinkNode = memtableOnSinkNode;
+        }
+
+        // enable_memtable_on_sink_node is on by default
+        Assertions.assertTrue(replay(withDefaults).isMemtableOnSinkNode());
+        KafkaRoutineLoadJob replayed = replay(withSessionValues);
+        Assertions.assertEquals(4, replayed.getSendBatchParallelism());
+        Assertions.assertFalse(replayed.isMemtableOnSinkNode());
+    }
+
+    // Without enclose and escape CREATE records the zero byte, and the replay 
gives the job none.
+    @Test
+    public void testCreateWithoutEncloseAndEscapeRecordsTheZeroByte() throws 
Exception {
+        KafkaRoutineLoadJob job = createJob("CREATE ROUTINE LOAD 
rl_replay_db.job_no_enclose ON dup_tbl\n"
+                + "FROM KAFKA ('kafka_broker_list' = '127.0.0.1:9092', 
'kafka_topic' = 'topic_no_enclose')");
+        Map<String, String> jobProperties = Deencapsulation.getField(job, 
"jobProperties");
+        Assertions.assertEquals("\u0000", 
jobProperties.get(CsvFileFormatProperties.PROP_ENCLOSE));
+        Assertions.assertEquals("\u0000", 
jobProperties.get(CsvFileFormatProperties.PROP_ESCAPE));
+        KafkaRoutineLoadJob replayed = replay(job);
+        Assertions.assertEquals(0, replayed.getEnclose());
+        Assertions.assertEquals(0, replayed.getEscape());
+    }
+
+    // CREATE of an older version took any character as enclose or escape, and 
recorded the byte it took, which reads
+    // back as U+FFFD from 0x80 up. The rebuild re-parses the stored 
statement, which can hold such a character (U+FFFD
+    // where the client did not send UTF-8), and must not reject it as CREATE 
does now, since that would cancel the
+    // job. The job keeps no enclose and no escape for the U+FFFD recorded, 
which is what it has run with after every
+    // restart.
+    @Test
+    public void testOlderCreateStatementWithNonAsciiValueReplays() throws 
Exception {
+        for (String value : new String[] {String.valueOf((char) 0xFFFD), "é"}) 
{
+            KafkaRoutineLoadJob job = createJob("CREATE ROUTINE LOAD 
rl_replay_db.job_older_create ON dup_tbl\n"
+                    + "FROM KAFKA ('kafka_broker_list' = '127.0.0.1:9092', 
'kafka_topic' = 'topic_older_create')");
+            // What an older version admitted and recorded
+            job.setOrigStmt(new OriginStatement("CREATE ROUTINE LOAD 
rl_replay_db.job_older_create ON dup_tbl\n"
+                    + "PROPERTIES ('enclose' = '" + value + "', 'escape' = '" 
+ value + "')\n"
+                    + "FROM KAFKA ('kafka_broker_list' = '127.0.0.1:9092', 
'kafka_topic' = 'topic_older_create')", 0));
+            Map<String, String> jobProperties = Deencapsulation.getField(job, 
"jobProperties");
+            jobProperties.put(CsvFileFormatProperties.PROP_ENCLOSE, 
String.valueOf((char) 0xFFFD));
+            jobProperties.put(CsvFileFormatProperties.PROP_ESCAPE, 
String.valueOf((char) 0xFFFD));
+
+            KafkaRoutineLoadJob replayed = replay(job);
+            Assertions.assertEquals(RoutineLoadJob.JobState.NEED_SCHEDULE, 
replayed.getState(), value);
+            Assertions.assertEquals(0, replayed.getEnclose(), value);
+            Assertions.assertEquals(0, replayed.getEscape(), value);
+        }
+    }
+
+    // CREATE takes only an ASCII character as enclose or escape: the tasks 
would get 0xFD or 0xEF for any byte from
+    // 0x80 up, which in UTF-8 data is absent or part of a character anyway. 
U+FFFD is what a statement whose bytes
+    // were not UTF-8 decodes to, and the full-width comma was taken as its 
low byte, 0x0C, as enclose.
+    @Test
+    public void testCreateRejectsNonAsciiEncloseAndEscape() {
+        for (String key : new String[] {CsvFileFormatProperties.PROP_ENCLOSE, 
CsvFileFormatProperties.PROP_ESCAPE}) {
+            for (String value : new String[] {"é", String.valueOf((char) 
0xFFFD), ","}) {
+                String sql = "CREATE ROUTINE LOAD rl_replay_db.job_non_ascii 
ON dup_tbl\n"
+                        + "PROPERTIES ('" + key + "' = '" + value + "')\n"
+                        + "FROM KAFKA ('kafka_broker_list' = '127.0.0.1:9092', 
'kafka_topic' = 'topic_non_ascii')";
+                Exception e = Assertions.assertThrows(Exception.class, () -> 
createJob(sql));
+                Assertions.assertTrue(e.getMessage().contains(key + " must be 
an ASCII character"), e.getMessage());
+            }
+        }
+    }
+
+    // ALTER ROUTINE LOAD installs the analyzed properties through 
modifyPropertiesInternal, as the replay of its
+    // edit log does.
+    @Test
+    public void testAlteredEncloseAndEscapeReachTheTasks() throws Exception {
+        KafkaRoutineLoadJob job = createJob("CREATE ROUTINE LOAD 
rl_replay_db.job_altered ON dup_tbl\n"
+                + "PROPERTIES ('enclose' = '\"', 'escape' = '\\\\')\n"
+                + "FROM KAFKA ('kafka_broker_list' = '127.0.0.1:9092', 
'kafka_topic' = 'topic_altered')");
+        Map<String, String> altered = Maps.newHashMap();
+        altered.put(CsvFileFormatProperties.PROP_ENCLOSE, "'");
+        altered.put(CsvFileFormatProperties.PROP_ESCAPE, "|");
+        job.replayModifyProperties(new 
AlterRoutineLoadJobOperationLog(job.getId(), altered, null));
+
+        NereidsRoutineLoadTaskInfo taskInfo = 
job.toNereidsRoutineLoadTaskInfo();
+        Assertions.assertEquals('\'', taskInfo.getEnclose());
+        Assertions.assertEquals('|', taskInfo.getEscape());
+        KafkaRoutineLoadJob replayed = replay(job);
+        Assertions.assertEquals('\'', replayed.getEnclose());
+        Assertions.assertEquals('|', replayed.getEscape());
+    }
+
+    // ALTER of an older version stored enclose and escape without checking 
them. Such a value never reached the
+    // job, and must neither now nor fail the replay of the job or of the 
ALTER.
+    @Test
+    public void testInvalidValueAnOlderAlterStoredIsIgnored() throws Exception 
{
+        KafkaRoutineLoadJob job = createJob("CREATE ROUTINE LOAD 
rl_replay_db.job_invalid ON dup_tbl\n"
+                + "PROPERTIES ('enclose' = '\"', 'escape' = '\\\\')\n"
+                + "FROM KAFKA ('kafka_broker_list' = '127.0.0.1:9092', 
'kafka_topic' = 'topic_invalid')");
+        Map<String, String> altered = Maps.newHashMap();
+        altered.put(CsvFileFormatProperties.PROP_ENCLOSE, "ab");
+        job.replayModifyProperties(new 
AlterRoutineLoadJobOperationLog(job.getId(), altered, null));
+        Assertions.assertEquals('"', job.getEnclose());
+
+        Map<String, String> jobProperties = Deencapsulation.getField(job, 
"jobProperties");
+        Assertions.assertEquals("ab", 
jobProperties.get(CsvFileFormatProperties.PROP_ENCLOSE));
+        KafkaRoutineLoadJob replayed = replay(job);
+        Assertions.assertEquals(RoutineLoadJob.JobState.NEED_SCHEDULE, 
replayed.getState());
+        Assertions.assertEquals(0, replayed.getEnclose());
+        Assertions.assertEquals('\\', replayed.getEscape());
+    }
+
+    // ALTER of an older version also stored a value that is not ASCII. 
Neither the replay of that ALTER nor a rebuild
+    // of the job, whose metadata then holds the value, applies it: the job 
keeps the byte it has.
+    @Test
+    public void testNonAsciiValueAnOlderAlterStoredIsIgnored() throws 
Exception {
+        for (String value : new String[] {"é", String.valueOf((char) 0xFFFD)}) 
{
+            KafkaRoutineLoadJob job = createJob("CREATE ROUTINE LOAD 
rl_replay_db.job_older_alter ON dup_tbl\n"
+                    + "PROPERTIES ('enclose' = '\"', 'escape' = '\\\\')\n"
+                    + "FROM KAFKA ('kafka_broker_list' = '127.0.0.1:9092', 
'kafka_topic' = 'topic_older_alter')");
+            Map<String, String> altered = Maps.newHashMap();
+            altered.put(CsvFileFormatProperties.PROP_ENCLOSE, value);
+            altered.put(CsvFileFormatProperties.PROP_ESCAPE, value);
+            job.replayModifyProperties(new 
AlterRoutineLoadJobOperationLog(job.getId(), altered, null));
+            Assertions.assertEquals('"', job.getEnclose(), value);
+            Assertions.assertEquals('\\', job.getEscape(), value);
+
+            Map<String, String> jobProperties = Deencapsulation.getField(job, 
"jobProperties");
+            Assertions.assertEquals(value, 
jobProperties.get(CsvFileFormatProperties.PROP_ENCLOSE));
+            KafkaRoutineLoadJob replayed = replay(job);
+            Assertions.assertEquals(RoutineLoadJob.JobState.NEED_SCHEDULE, 
replayed.getState(), value);
+            Assertions.assertEquals(0, replayed.getEnclose(), value);
+            Assertions.assertEquals(0, replayed.getEscape(), value);
+        }
+    }
+}
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/load/NereidsStreamLoadTaskTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/load/NereidsStreamLoadTaskTest.java
index 403766a6769..d9027f597a6 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/load/NereidsStreamLoadTaskTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/load/NereidsStreamLoadTaskTest.java
@@ -59,6 +59,21 @@ public class NereidsStreamLoadTaskTest {
         Assertions.assertTrue(streamLoadTask.isNumAsString());
     }
 
+    @Test
+    public void testMultiTableBaseTaskCopiesCsvProperties() throws Exception {
+        // The per-table request of a multi-table routine load carries no 
enclose and no escape
+        NereidsStreamLoadTask streamLoadTask = 
NereidsStreamLoadTask.fromTStreamLoadPutRequest(newRequest());
+        LoadTaskInfo routineLoadTask = Mockito.mock(LoadTaskInfo.class);
+        
Mockito.when(routineLoadTask.getFormatType()).thenReturn(TFileFormatType.FORMAT_CSV_PLAIN);
+        Mockito.when(routineLoadTask.getEnclose()).thenReturn((byte) '"');
+        Mockito.when(routineLoadTask.getEscape()).thenReturn((byte) '\\');
+
+        streamLoadTask.setMultiTableBaseTaskInfo(routineLoadTask);
+
+        Assertions.assertEquals('"', streamLoadTask.getEnclose());
+        Assertions.assertEquals('\\', streamLoadTask.getEscape());
+    }
+
     @Test
     public void testDefaultSendBatchParallelism() throws UserException {
         Assertions.assertEquals(1,
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AlterRoutineLoadCommandTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AlterRoutineLoadCommandTest.java
index 0989bc127b0..d6bace72b99 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AlterRoutineLoadCommandTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/AlterRoutineLoadCommandTest.java
@@ -21,6 +21,7 @@ import org.apache.doris.catalog.Database;
 import org.apache.doris.catalog.Env;
 import org.apache.doris.catalog.Table;
 import org.apache.doris.datasource.InternalCatalog;
+import org.apache.doris.datasource.property.fileformat.CsvFileFormatProperties;
 import org.apache.doris.load.loadv2.LoadTask;
 import org.apache.doris.load.routineload.RoutineLoadJob;
 import org.apache.doris.load.routineload.RoutineLoadManager;
@@ -115,4 +116,47 @@ public class AlterRoutineLoadCommandTest {
         
Assertions.assertTrue(command.getAnalyzedJobProperties().containsKey(CreateRoutineLoadInfo.STRICT_MODE));
         
Assertions.assertTrue(command.getAnalyzedJobProperties().containsKey(CreateRoutineLoadInfo.TIMEZONE));
     }
+
+    // The job takes the byte from enclose and escape the way CREATE does, so 
ALTER rejects what CREATE rejects.
+    @Test
+    public void testValidateChecksEncloseAndEscape() {
+        runBefore();
+        LabelNameInfo labelNameInfo = new LabelNameInfo("testDb", "label1");
+        Map<String, String> enclose = Maps.newHashMap();
+        enclose.put(CsvFileFormatProperties.PROP_ENCLOSE, "ab");
+        AlterRoutineLoadCommand badEnclose = new 
AlterRoutineLoadCommand(labelNameInfo, enclose, Maps.newHashMap());
+        Exception e = Assertions.assertThrows(Exception.class, () -> 
badEnclose.validate(connectContext));
+        Assertions.assertTrue(e.getMessage().contains("enclose should not be 
longer than one byte"), e.getMessage());
+
+        Map<String, String> escape = Maps.newHashMap();
+        escape.put(CsvFileFormatProperties.PROP_ESCAPE, "ab");
+        AlterRoutineLoadCommand badEscape = new 
AlterRoutineLoadCommand(labelNameInfo, escape, Maps.newHashMap());
+        e = Assertions.assertThrows(Exception.class, () -> 
badEscape.validate(connectContext));
+        Assertions.assertTrue(e.getMessage().contains("escape must be 
single-char"), e.getMessage());
+
+        Map<String, String> valid = Maps.newHashMap();
+        valid.put(CsvFileFormatProperties.PROP_ENCLOSE, "'");
+        valid.put(CsvFileFormatProperties.PROP_ESCAPE, "|");
+        AlterRoutineLoadCommand command = new 
AlterRoutineLoadCommand(labelNameInfo, valid, Maps.newHashMap());
+        Assertions.assertDoesNotThrow(() -> command.validate(connectContext));
+        Assertions.assertEquals("'", 
command.getAnalyzedJobProperties().get(CsvFileFormatProperties.PROP_ENCLOSE));
+        Assertions.assertEquals("|", 
command.getAnalyzedJobProperties().get(CsvFileFormatProperties.PROP_ESCAPE));
+    }
+
+    // ALTER, like CREATE, takes only an ASCII character as enclose or escape.
+    @Test
+    public void testValidateRejectsNonAsciiEncloseAndEscape() {
+        runBefore();
+        LabelNameInfo labelNameInfo = new LabelNameInfo("testDb", "label1");
+        for (String key : new String[] {CsvFileFormatProperties.PROP_ENCLOSE, 
CsvFileFormatProperties.PROP_ESCAPE}) {
+            for (String value : new String[] {"é", String.valueOf((char) 
0xFFFD), ","}) {
+                Map<String, String> properties = Maps.newHashMap();
+                properties.put(key, value);
+                AlterRoutineLoadCommand command = new 
AlterRoutineLoadCommand(labelNameInfo, properties,
+                        Maps.newHashMap());
+                Exception e = Assertions.assertThrows(Exception.class, () -> 
command.validate(connectContext));
+                Assertions.assertTrue(e.getMessage().contains(key + " must be 
an ASCII character"), e.getMessage());
+            }
+        }
+    }
 }
diff --git 
a/regression-test/suites/load_p0/routine_load/test_routine_load_property.groovy 
b/regression-test/suites/load_p0/routine_load/test_routine_load_property.groovy
index cd10af8dfe5..ea518c4a3d9 100644
--- 
a/regression-test/suites/load_p0/routine_load/test_routine_load_property.groovy
+++ 
b/regression-test/suites/load_p0/routine_load/test_routine_load_property.groovy
@@ -93,6 +93,24 @@ suite("test_routine_load_property","p0") {
         PROPERTIES ("replication_allocation" = "tag.location.default: 1");
     """
 
+    // CREATE takes only an ASCII character as enclose or escape: the tasks 
would get 0xFD or 0xEF for any byte from
+    // 0x80 up, which in UTF-8 data is absent or part of a character anyway. 
U+FFFD, the Unicode replacement
+    // character, is what a statement whose bytes were not UTF-8 decodes to. 
CREATE checks them before it connects to
+    // Kafka.
+    for (String key : ["enclose", "escape"]) {
+        for (String value : ["\uFFFD", "é"]) {
+            test {
+                sql """
+                    CREATE ROUTINE LOAD test_${key}_non_ascii ON ${tableName}
+                    COLUMNS TERMINATED BY ","
+                    PROPERTIES ("${key}" = "${value}")
+                    FROM KAFKA ("kafka_broker_list" = "127.0.0.1:9092", 
"kafka_topic" = "test_${key}_non_ascii")
+                """
+                exception "${key} must be an ASCII character"
+            }
+        }
+    }
+
     if (enabled != null && enabled.equalsIgnoreCase("true")) {
         def jobName = "test_enclose_and_escape"
         try {


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

Reply via email to