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

caolu pushed a commit to branch kylin5
in repository https://gitbox.apache.org/repos/asf/kylin.git


The following commit(s) were added to refs/heads/kylin5 by this push:
     new 0327be455c KYLIN-6048 limit command line output size to avoid OOM
0327be455c is described below

commit 0327be455c33fb67f2f2c98a61cfd9b29ab8b736
Author: 夏旭晨 <[email protected]>
AuthorDate: Wed Nov 13 18:38:52 2024 +0800

    KYLIN-6048 limit command line output size to avoid OOM
    
    Co-authored-by: Xuchen Xia <[email protected]>
---
 .../org/apache/kylin/common/KylinConfigBase.java   |   7 ++
 .../kylin/common/util/CliCommandExecutor.java      |   7 +-
 .../org/apache/kylin/common/util/HeadBuilder.java  | 106 ++++++++++++++++
 .../org/apache/kylin/common/util/SSHClient.java    |   5 +-
 .../kylin/common/util/StringBuilderHelper.java     |  97 +++++++++++++++
 .../org/apache/kylin/common/util/TailBuilder.java  | 135 +++++++++++++++++++++
 .../kylin/common/util/CliCommandExecutorTest.java  |  57 +++++++++
 .../apache/kylin/common/util/HeadBuilderTest.java  | 111 +++++++++++++++++
 .../apache/kylin/common/util/SSHClientTest.java    |  66 ++++++++++
 .../kylin/common/util/StringBuilderHelperTest.java | 125 +++++++++++++++++++
 .../apache/kylin/common/util/TailBuilderTest.java  | 104 ++++++++++++++++
 11 files changed, 817 insertions(+), 3 deletions(-)

diff --git 
a/src/core-common/src/main/java/org/apache/kylin/common/KylinConfigBase.java 
b/src/core-common/src/main/java/org/apache/kylin/common/KylinConfigBase.java
index f9a7229318..947ffd8f20 100644
--- a/src/core-common/src/main/java/org/apache/kylin/common/KylinConfigBase.java
+++ b/src/core-common/src/main/java/org/apache/kylin/common/KylinConfigBase.java
@@ -134,6 +134,7 @@ public abstract class KylinConfigBase implements 
Serializable {
     public static final String SERVER_NAME_STRING = "spring.application.name";
 
     protected static final Map<String, String> STATIC_SYSTEM_ENV = new 
ConcurrentHashMap<>(System.getenv());
+    public static final int BYTES_PER_CHAR = 2;
 
     /*
      * DON'T DEFINE CONSTANTS FOR PROPERTY KEYS!
@@ -1813,6 +1814,12 @@ public abstract class KylinConfigBase implements 
Serializable {
         return Boolean.parseBoolean(getOptional("kylin.streaming.enabled", 
FALSE));
     }
 
+    public int getMaxCommandLineOutputLength() {
+        // default 10MB, if the command line output length over this value
+        // the output will be truncated as 5MB head and 5MB tail.
+        return Integer.parseInt(getOptional("kylin.command.max-output-bytes", 
String.valueOf(10 * 1024 * 1024))) / BYTES_PER_CHAR;
+    }
+
     public boolean isStreamingEnabled() {
         return isStreamingConfigEnabled() && 
KylinInfoExtension.getFactory().checkKylinInfo();
     }
diff --git 
a/src/core-common/src/main/java/org/apache/kylin/common/util/CliCommandExecutor.java
 
b/src/core-common/src/main/java/org/apache/kylin/common/util/CliCommandExecutor.java
index 487f2fd1e6..70d680636a 100644
--- 
a/src/core-common/src/main/java/org/apache/kylin/common/util/CliCommandExecutor.java
+++ 
b/src/core-common/src/main/java/org/apache/kylin/common/util/CliCommandExecutor.java
@@ -26,6 +26,7 @@ import java.nio.charset.StandardCharsets;
 import java.util.Arrays;
 
 import org.apache.commons.io.FileUtils;
+import org.apache.kylin.common.KylinConfig;
 import org.apache.kylin.common.scheduler.EventBusFactory;
 import org.apache.kylin.guava30.shaded.common.annotations.VisibleForTesting;
 import org.slf4j.LoggerFactory;
@@ -191,8 +192,10 @@ public class CliCommandExecutor {
             pid = ProcessUtils.getPid(proc);
             logger.info("sub process {} on behalf of job {}, start to run...", 
pid, jobId);
             EventBusFactory.getInstance().postSync(new ProcessStart(pid, 
jobId));
-
-            StringBuilder result = new StringBuilder();
+            int maxCommandLineOutputLength = 
KylinConfig.getInstanceFromEnv().getMaxCommandLineOutputLength();
+            int headSize = maxCommandLineOutputLength / 2;
+            StringBuilderHelper result = StringBuilderHelper.headTail(headSize,
+                    maxCommandLineOutputLength - headSize);
             try (BufferedReader reader = new BufferedReader(
                     new InputStreamReader(proc.getInputStream(), 
StandardCharsets.UTF_8))) {
                 String line;
diff --git 
a/src/core-common/src/main/java/org/apache/kylin/common/util/HeadBuilder.java 
b/src/core-common/src/main/java/org/apache/kylin/common/util/HeadBuilder.java
new file mode 100644
index 0000000000..42133b12c3
--- /dev/null
+++ 
b/src/core-common/src/main/java/org/apache/kylin/common/util/HeadBuilder.java
@@ -0,0 +1,106 @@
+/*
+ * 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.kylin.common.util;
+
+import lombok.Getter;
+
+/**
+ * Limit the capacity of StringBuilder to avoid OOM
+ * If the capacity of StringBuilder would exceed the maxCapacity, the 
StringBuilder will be recreated to maxCapacity
+ */
+public class HeadBuilder {
+
+    private StringBuilder builder;
+
+    @Getter
+    private final int maxCapacity;
+
+    public HeadBuilder(int initialCapacity, int maxCapacity) {
+        if (initialCapacity < 0) {
+            throw new IllegalArgumentException("initialCapacity must greater 
than 0");
+        }
+        if (maxCapacity < 0) {
+            throw new IllegalArgumentException("maxCapacity must greater than 
0");
+        }
+        this.builder = new StringBuilder(Math.min(initialCapacity, 
maxCapacity));
+        this.maxCapacity = maxCapacity;
+    }
+
+    public HeadBuilder(int maxCapacity) {
+        this(16, maxCapacity);
+    }
+
+    private int ensureCapacity(int appendLen) {
+        if (builder.capacity() >= maxCapacity) {
+            return Math.min(builder.capacity() - builder.length(), appendLen);
+        }
+        if (builder.capacity() > builder.length() + appendLen) {
+            return appendLen;
+        }
+        // expand capacity
+        int newCapacity = builder.length() << 1 + 2;
+        if (newCapacity > maxCapacity) {
+            // create a new builder with maxCapacity to ensure maxCapacity 
limit
+            String origin = builder.toString();
+            builder = new StringBuilder(maxCapacity);
+            builder.append(origin);
+        } else {
+            builder.ensureCapacity(Math.max(newCapacity, builder.length() + 
appendLen));
+        }
+        return Math.min(builder.capacity() - builder.length(), appendLen);
+    }
+
+    public int capacity() {
+        return builder.capacity();
+    }
+
+    public int length() {
+        return builder.length();
+    }
+
+    public HeadBuilder append(String str) {
+        return append(str, 0, str.length());
+    }
+
+    public HeadBuilder append(CharSequence csq) {
+        return append(csq, 0, csq.length());
+    }
+
+    public HeadBuilder append(CharSequence csq, int start, int end) {
+        int appendSize = ensureCapacity(end - start);
+        if (appendSize == 0) {
+            return this;
+        }
+        builder.append(csq, start, start + appendSize);
+        return this;
+    }
+
+    public HeadBuilder append(char c) {
+        if (ensureCapacity(1) == 0) {
+            return this;
+        }
+        builder.append(c);
+        return this;
+    }
+
+    @Override
+    public String toString() {
+        return builder.toString();
+    }
+
+}
diff --git 
a/src/core-common/src/main/java/org/apache/kylin/common/util/SSHClient.java 
b/src/core-common/src/main/java/org/apache/kylin/common/util/SSHClient.java
index a93363c0ff..86daf0e198 100644
--- a/src/core-common/src/main/java/org/apache/kylin/common/util/SSHClient.java
+++ b/src/core-common/src/main/java/org/apache/kylin/common/util/SSHClient.java
@@ -32,6 +32,7 @@ import java.nio.charset.Charset;
 
 import org.apache.commons.io.IOUtils;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.kylin.common.KylinConfig;
 import org.slf4j.LoggerFactory;
 
 import com.jcraft.jsch.Channel;
@@ -314,7 +315,9 @@ public class SSHClient {
         try {
             logger.info("[" + username + "@" + hostname + "] Execute command: 
" + command);
 
-            StringBuilder text = new StringBuilder();
+            int maxCommandLineOutputLength = 
KylinConfig.getInstanceFromEnv().getMaxCommandLineOutputLength();
+            StringBuilderHelper text = 
StringBuilderHelper.headTail(maxCommandLineOutputLength / 2,
+                    maxCommandLineOutputLength - maxCommandLineOutputLength / 
2);
             int exitCode = -1;
 
             Session session = newJSchSession();
diff --git 
a/src/core-common/src/main/java/org/apache/kylin/common/util/StringBuilderHelper.java
 
b/src/core-common/src/main/java/org/apache/kylin/common/util/StringBuilderHelper.java
new file mode 100644
index 0000000000..e165716b20
--- /dev/null
+++ 
b/src/core-common/src/main/java/org/apache/kylin/common/util/StringBuilderHelper.java
@@ -0,0 +1,97 @@
+/*
+ * 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.kylin.common.util;
+
+/**
+ * slice the string head and tail
+ * merge head and tail within the total size
+ */
+public class StringBuilderHelper {
+
+    private final int totalSize;
+
+    private final HeadBuilder headBuilder;
+
+    private final TailBuilder tailBuilder;
+
+    private int appendedSize = 0;
+
+    public static StringBuilderHelper head(int headSize) {
+        return new StringBuilderHelper(headSize, 0);
+    }
+
+    public static StringBuilderHelper tail(int tailSize) {
+        return new StringBuilderHelper(0, tailSize);
+    }
+
+    public static StringBuilderHelper headTail(int headSize, int tailSize) {
+        return new StringBuilderHelper(headSize, tailSize);
+    }
+
+    private StringBuilderHelper(int headSize, int tailSize) {
+        this.headBuilder = new HeadBuilder(headSize);
+        this.tailBuilder = new TailBuilder(tailSize);
+        this.totalSize = headSize + tailSize;
+    }
+
+    public StringBuilderHelper append(CharSequence csq) {
+        headBuilder.append(csq);
+        tailBuilder.append(csq);
+        appendedSize += csq.length();
+        return this;
+    }
+
+    public StringBuilderHelper append(char c) {
+        headBuilder.append(c);
+        tailBuilder.append(c);
+        appendedSize++;
+        return this;
+    }
+
+    private String merge() {
+        if (appendedSize < totalSize) {
+            // concat appended string
+            char[] res = new char[appendedSize];
+            headBuilder.toString().getChars(0, headBuilder.length(), res, 0);
+            int least = appendedSize - headBuilder.length();
+            tailBuilder.toString().getChars(tailBuilder.length() - least, 
tailBuilder.length(),
+                    res, headBuilder.length());
+            return new String(res);
+        } else {
+            // concat head and tail
+            char[] res = new char[totalSize];
+            int headLength = headBuilder.length();
+            headBuilder.toString().getChars(0, headLength, res, 0);
+            tailBuilder.toString().getChars(0, tailBuilder.length(),
+                    res, headLength);
+            return new String(res);
+        }
+    }
+
+    public int length() {
+        return Math.min(appendedSize, totalSize);
+    }
+
+    /**
+     * if appended string size <= totalSize return total string
+     * if append string size > totalSize return head + tail
+     */
+    public String toString() {
+        return merge();
+    }
+}
diff --git 
a/src/core-common/src/main/java/org/apache/kylin/common/util/TailBuilder.java 
b/src/core-common/src/main/java/org/apache/kylin/common/util/TailBuilder.java
new file mode 100644
index 0000000000..8968604131
--- /dev/null
+++ 
b/src/core-common/src/main/java/org/apache/kylin/common/util/TailBuilder.java
@@ -0,0 +1,135 @@
+/*
+ * 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.kylin.common.util;
+
+import java.util.Arrays;
+import java.util.LinkedList;
+
+import lombok.Getter;
+
+public class TailBuilder {
+
+    static final int ENTRY_SIZE = 1024;
+
+    private LinkedList<char[]> data = new LinkedList<>();
+
+    @Getter
+    private final int maxCapacity;
+
+    // last segment left size
+    private int writableSize = 0;
+
+    private int length = 0;
+
+    public int length() {
+        return Math.min(length, maxCapacity);
+    }
+
+    public TailBuilder(int maxCapacity) {
+        if (maxCapacity < 0) {
+            throw new IllegalArgumentException("maxCapacity must >= 0");
+        }
+        this.maxCapacity = maxCapacity;
+    }
+
+    public TailBuilder append(CharSequence csq) {
+        if (maxCapacity == 0) {
+            return this;
+        }
+        if (csq == null || csq.length() == 0) {
+            return this;
+        }
+        // only need tail part
+        if (csq.length() > maxCapacity) {
+            csq = csq.subSequence(csq.length() - maxCapacity, csq.length());
+        }
+        int size2Append = csq.length();
+        char[] charArray = csq.toString().toCharArray();
+        while (size2Append > 0) {
+            int copySize = Math.min(writableSize, size2Append);
+            System.arraycopy(charArray, charArray.length - size2Append, 
getWriteEntry(),
+                    ENTRY_SIZE - writableSize, copySize);
+            writableSize -= copySize;
+            size2Append -= copySize;
+            length += copySize;
+        }
+        return this;
+    }
+
+    private char[] getWriteEntry() {
+        ensureWriteEntry();
+        return data.getLast();
+    }
+
+    public TailBuilder append(char c) {
+        if (maxCapacity == 0) {
+            return this;
+        }
+        getWriteEntry()[ENTRY_SIZE - writableSize] = c;
+        writableSize--;
+        length++;
+        return this;
+    }
+
+    /**
+     * ensure the last entry having space to write
+     * allocate a new entry if the last entry is full
+     * we would recycle the first entry to allocate
+     * if the (sizeof(data) - 1) * ENTRY_SIZE > maxCapacity so the first entry 
is useless
+     * because data[0] is not include any char we want(remain last maxCapacity 
chars)
+     */
+    private void ensureWriteEntry() {
+        if (writableSize > 0) {
+            return;
+        }
+        char[] allocated = null;
+        // recycle first entry
+        if (ENTRY_SIZE * (data.size() - 1) >= maxCapacity) {
+            allocated = data.removeFirst();
+            Arrays.fill(allocated, (char) 0);
+            length -= ENTRY_SIZE;
+        }
+        if (allocated == null) {
+            allocated = new char[ENTRY_SIZE];
+        }
+        data.add(allocated);
+        writableSize = ENTRY_SIZE;
+    }
+
+    /**
+     * @return a String contains tail maxCapacity chars, if length < 
maxCapacity, return all
+     */
+    public String toString() {
+        int totalSize = Math.min(length, maxCapacity);
+        char[] result = new char[totalSize];
+        int skipSize = Math.max(0, length - maxCapacity);
+        int pos = 0;
+        for (int i = 0; i < data.size() - 1; i++) {
+            if (skipSize >= ENTRY_SIZE) {
+                skipSize -= ENTRY_SIZE;
+                continue;
+            }
+            char[] src = data.get(i);
+            System.arraycopy(src, skipSize, result, pos, ENTRY_SIZE - 
skipSize);
+            pos += ENTRY_SIZE - skipSize;
+            skipSize = 0;
+        }
+        System.arraycopy(getWriteEntry(), skipSize, result, pos, ENTRY_SIZE - 
writableSize - skipSize);
+        return new String(result);
+    }
+}
diff --git 
a/src/core-common/src/test/java/org/apache/kylin/common/util/CliCommandExecutorTest.java
 
b/src/core-common/src/test/java/org/apache/kylin/common/util/CliCommandExecutorTest.java
index 8b22159738..7d83931961 100644
--- 
a/src/core-common/src/test/java/org/apache/kylin/common/util/CliCommandExecutorTest.java
+++ 
b/src/core-common/src/test/java/org/apache/kylin/common/util/CliCommandExecutorTest.java
@@ -17,10 +17,15 @@
  */
 package org.apache.kylin.common.util;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
 import java.io.File;
 import java.lang.reflect.Method;
 
 import org.apache.commons.io.FileUtils;
+import org.apache.kylin.common.KylinConfig;
+import org.apache.kylin.junit.annotation.MetadataInfo;
 import org.junit.Assert;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.TestInfo;
@@ -29,6 +34,7 @@ import org.mockito.Mockito;
 
 import lombok.val;
 
+@MetadataInfo(onlyProps = true)
 public class CliCommandExecutorTest {
 
     @Test
@@ -77,6 +83,57 @@ public class CliCommandExecutorTest {
         Assert.assertEquals(fileList[0].getName(), tempFile.getName());
 
     }
+
+    @Test
+    void testLargeOutputSlice() throws Exception {
+        // These test assert the kylin.command.max-output-length is 5M chars 
as 10 MB size
+        int maxCommandLineOutputLength = 
KylinConfig.getInstanceFromEnv().getMaxCommandLineOutputLength();
+        assertEquals(5 * 1024 * 1024, maxCommandLineOutputLength);
+        CliCommandExecutor cliCommandExecutor = new CliCommandExecutor();
+        // generate a large output and expect the output is truncated
+        CliCommandExecutor.CliCmdExecResult res = cliCommandExecutor.execute(
+                "echo 'testhead' && line=$(printf 'a%.0s' {1..1251}) && yes 
\"$line\" 2>/dev/null | head -n 5505 && echo 'testtail'",
+                null);
+        assertEquals(maxCommandLineOutputLength, res.getCmd().length());
+        assertTrue(res.getCmd().startsWith("testhead"));
+        assertTrue(res.getCmd().endsWith("testtail\n"));
+        // multiline long string
+        res = cliCommandExecutor
+                .execute("echo 'testhead' && line=$(printf 'a%.0s' {1..111}) 
&& yes \"$line\" 2>/dev/null | head -n " + 1024 * 52
+                        + "&& echo 'testtail'", null);
+        assertEquals(maxCommandLineOutputLength, res.getCmd().length());
+        assertTrue(res.getCmd().startsWith("testhead"));
+        assertTrue(res.getCmd().endsWith("testtail\n"));
+        // multiline short string
+        res = cliCommandExecutor.execute("echo 'testhead' && line=$(printf 
'a%.0s' {1..9}) && yes \"$line\" 2>/dev/null | head -n "
+                + 1024 * 513 + "&& echo 'testtail'", null);
+        assertEquals(maxCommandLineOutputLength, res.getCmd().length());
+        assertTrue(res.getCmd().startsWith("testhead"));
+        assertTrue(res.getCmd().endsWith("testtail\n"));
+
+        // generate large output less than 10 mb and expect the same output
+        res = cliCommandExecutor.execute(
+                "echo 'testhead' && line=$(printf 'a%.0s' {1..1251}) && yes 
\"$line\" 2>/dev/null | head -n 1000 && echo 'testtail'",
+                null);
+        final int HEAD_TAIL_LEN = 18;
+        assertEquals(1252 * 1000 + HEAD_TAIL_LEN, res.getCmd().length());
+        assertTrue(res.getCmd().startsWith("testhead"));
+        assertTrue(res.getCmd().endsWith("testtail\n"));
+
+        // multiline long string
+        res = cliCommandExecutor
+                .execute("echo 'testhead' && line=$(printf 'a%.0s' {1..111}) 
&& yes \"$line\" 2>/dev/null | head -n " + 1024 * 10
+                        + "&& echo 'testtail'", null);
+        assertEquals(112 * 10 * 1024 + HEAD_TAIL_LEN, res.getCmd().length());
+        assertTrue(res.getCmd().startsWith("testhead"));
+        assertTrue(res.getCmd().endsWith("testtail\n"));
+        // multiline short string
+        res = cliCommandExecutor.execute("echo 'testhead' && line=$(printf 
'a%.0s' {1..9}) && yes \"$line\" 2>/dev/null | head -n "
+                + 1024 * 100 + "&& echo 'testtail'", null);
+        assertEquals(10 * 100 * 1024 + HEAD_TAIL_LEN, res.getCmd().length());
+        assertTrue(res.getCmd().startsWith("testhead"));
+        assertTrue(res.getCmd().endsWith("testtail\n"));
+    }
 }
 
 class MockSSHClient extends SSHClient {
diff --git 
a/src/core-common/src/test/java/org/apache/kylin/common/util/HeadBuilderTest.java
 
b/src/core-common/src/test/java/org/apache/kylin/common/util/HeadBuilderTest.java
new file mode 100644
index 0000000000..0123d50b4b
--- /dev/null
+++ 
b/src/core-common/src/test/java/org/apache/kylin/common/util/HeadBuilderTest.java
@@ -0,0 +1,111 @@
+/*
+ * 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.kylin.common.util;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class HeadBuilderTest {
+
+    private static boolean isFull(HeadBuilder stringWriter) {
+        return stringWriter.length() == stringWriter.capacity();
+    }
+
+    @Test
+    void testWriteLimited() throws IOException {
+        HeadBuilder stringWriter = new HeadBuilder(10);
+        stringWriter.append('1');
+        assertEquals("1", stringWriter.toString());
+        assertEquals(10, stringWriter.capacity());
+        assertFalse(isFull(stringWriter));
+        stringWriter.append('2');
+        assertEquals("12", stringWriter.toString());
+        assertEquals(10, stringWriter.capacity());
+        assertEquals(2, stringWriter.length());
+        assertFalse(isFull(stringWriter));
+        stringWriter.append("345");
+        assertEquals("12345", stringWriter.toString());
+        assertFalse(isFull(stringWriter));
+        stringWriter.append("678910");
+        assertWhenFull(stringWriter);
+        stringWriter.append('a');
+        assertWhenFull(stringWriter);
+        stringWriter.append("aa", 1, 2);
+        assertWhenFull(stringWriter);
+        testFull(20, 12345);
+        testFull(16, 12345);
+        testFull(1204, 10024);
+        testFull(1, 52431);
+    }
+
+    @Test
+    void testConstructor() {
+        // legal capacity
+        new HeadBuilder(0);
+        Assertions.assertThrows(IllegalArgumentException.class, () -> new 
HeadBuilder(-1));
+        Assertions.assertThrows(IllegalArgumentException.class, () -> new 
HeadBuilder(-1, 0));
+    }
+
+    private static void testFull(int initCapacity, int maxCapacity) {
+        HeadBuilder stringWriter;
+        stringWriter = new HeadBuilder(initCapacity, maxCapacity);
+        final String APPEND_STR = new String(new char[123]);
+        final char APPEND_CHAR = 'a';
+        final int SUB_APPEND_LENGTH = 89;
+        while (!isFull(stringWriter)) {
+            append(stringWriter, APPEND_STR, APPEND_CHAR, SUB_APPEND_LENGTH);
+        }
+        // append when full to test capacity not increase
+        append(stringWriter, APPEND_STR, APPEND_CHAR, SUB_APPEND_LENGTH);
+        assertEquals(maxCapacity, stringWriter.capacity());
+        assertEquals(maxCapacity, stringWriter.length());
+    }
+
+    private static void append(HeadBuilder stringWriter, String appendStr, 
char appendChar, int subAppendLength) {
+        int length;
+        length = stringWriter.length();
+        stringWriter.append(appendStr);
+        if (!isFull(stringWriter)) {
+            assertEquals(123, stringWriter.length() - length);
+        }
+        length = stringWriter.length();
+        stringWriter.append(appendChar);
+        if (!isFull(stringWriter)) {
+            assertEquals(1, stringWriter.length() - length);
+        }
+        length = stringWriter.length();
+        stringWriter.append(appendStr, 0, subAppendLength);
+        if (!isFull(stringWriter)) {
+            assertEquals(subAppendLength, stringWriter.length() - length);
+        }
+    }
+
+    private static void assertWhenFull(HeadBuilder stringWriter) {
+        assertEquals("1234567891", stringWriter.toString());
+        assertEquals(10, stringWriter.capacity());
+        assertEquals(10, stringWriter.length());
+        assertTrue(isFull(stringWriter));
+    }
+
+}
diff --git 
a/src/core-common/src/test/java/org/apache/kylin/common/util/SSHClientTest.java 
b/src/core-common/src/test/java/org/apache/kylin/common/util/SSHClientTest.java
index 0db4bcf395..e5af7f54c7 100644
--- 
a/src/core-common/src/test/java/org/apache/kylin/common/util/SSHClientTest.java
+++ 
b/src/core-common/src/test/java/org/apache/kylin/common/util/SSHClientTest.java
@@ -18,6 +18,9 @@
 
 package org.apache.kylin.common.util;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
 import java.io.File;
 import java.io.IOException;
 import java.lang.reflect.Method;
@@ -72,6 +75,69 @@ public class SSHClientTest {
         Assert.assertEquals("hello\n", output.getText());
     }
 
+    @Test
+    public void testCmdWithLargeOutput() throws Exception {
+        if (!isRemote)
+            return;
+        //  These tests assert that the output from SSH commands are truncated 
correctly
+        //        based on the 'kylin.command.max-output-length' configuration 
which is 10 MB
+        final int maxCommandLineOutputLength = 
KylinConfig.getInstanceFromEnv().getMaxCommandLineOutputLength();
+        assertEquals(5 * 1024 * 1024, maxCommandLineOutputLength);
+        final int HEAD_TAIL_LEN = 18; // considering the length of 
'testhead\ntesttail\n'
+        SSHClient ssh = new SSHClient(this.hostname, this.port, this.username, 
this.password);
+        SSHClientOutput sshClientOutput;
+
+        // Test large output and check it is truncated
+        sshClientOutput = ssh.execCommand(
+                "echo 'testhead' && line=$(printf 'a%.0s' {1..1251}) && yes 
\"$line\" 2>/dev/null | head -n 5505 && echo 'testtail'");
+        String result = sshClientOutput.getText();
+        assertEquals(maxCommandLineOutputLength, result.length());
+        assertTrue(result.startsWith("testhead"));
+        assertTrue(result.endsWith("testtail\n"));
+
+        // Test multiline long string
+        sshClientOutput = ssh
+                .execCommand("echo 'testhead' && line=$(printf 'a%.0s' 
{1..111}) && yes \"$line\" 2>/dev/null | head -n "
+                        + 1024 * 52 + " && echo 'testtail'");
+        result = sshClientOutput.getText();
+        assertEquals(maxCommandLineOutputLength, result.length());
+        assertTrue(result.startsWith("testhead"));
+        assertTrue(result.endsWith("testtail\n"));
+
+        // Test multiline short string (larger than 10 MB)
+        sshClientOutput = ssh.execCommand("echo 'testhead' && line=$(printf 
'a%.0s' {1..9}) && yes \"$line\" 2>/dev/null | head -n "
+                + 1024 * 513 + " && echo 'testtail'");
+        result = sshClientOutput.getText();
+        assertEquals(maxCommandLineOutputLength, result.length());
+        assertTrue(result.startsWith("testhead"));
+        assertTrue(result.endsWith("testtail\n"));
+
+        // Generate large output less than 10 MB and expect the same output
+        sshClientOutput = ssh.execCommand(
+                "echo 'testhead' && line=$(printf 'a%.0s' {1..1251}) && yes 
\"$line\" 2>/dev/null | head -n 1000 && echo 'testtail'");
+        result = sshClientOutput.getText();
+        assertEquals(1252 * 1000 + HEAD_TAIL_LEN, result.length());
+        assertTrue(result.startsWith("testhead"));
+        assertTrue(result.endsWith("testtail\n"));
+
+        // Multiline long string, less than 10 MB
+        sshClientOutput = ssh
+                .execCommand("echo 'testhead' && line=$(printf 'a%.0s' 
{1..111}) && yes \"$line\" 2>/dev/null | head -n "
+                        + 1024 * 10 + " && echo 'testtail'");
+        result = sshClientOutput.getText();
+        assertEquals(112 * 10 * 1024 + HEAD_TAIL_LEN, result.length());
+        assertTrue(result.startsWith("testhead"));
+        assertTrue(result.endsWith("testtail\n"));
+
+        // Multiline short string, less than 10 MB
+        sshClientOutput = ssh.execCommand("echo 'testhead' && line=$(printf 
'a%.0s' {1..9}) && yes \"$line\" 2>/dev/null | head -n "
+                + 1024 * 100 + " && echo 'testtail'");
+        result = sshClientOutput.getText();
+        assertEquals(10 * 100 * 1024 + HEAD_TAIL_LEN, result.length());
+        assertTrue(result.startsWith("testhead"));
+        assertTrue(result.endsWith("testtail\n"));
+    }
+
     @Test
     public void testScpFileToRemote() throws Exception {
         if (!isRemote)
diff --git 
a/src/core-common/src/test/java/org/apache/kylin/common/util/StringBuilderHelperTest.java
 
b/src/core-common/src/test/java/org/apache/kylin/common/util/StringBuilderHelperTest.java
new file mode 100644
index 0000000000..65f8f05a71
--- /dev/null
+++ 
b/src/core-common/src/test/java/org/apache/kylin/common/util/StringBuilderHelperTest.java
@@ -0,0 +1,125 @@
+/*
+ * 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.kylin.common.util;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.Random;
+
+import org.junit.jupiter.api.Test;
+
+public class StringBuilderHelperTest {
+
+    @Test
+    public void testHead() {
+        StringBuilderHelper helper = StringBuilderHelper.head(4);
+        helper.append("abc");
+        assertEquals("abc", helper.toString());
+        helper.append('d');
+        assertEquals("abcd", helper.toString());
+
+        helper = StringBuilderHelper.head(4);
+        helper.append("abcd");
+        assertEquals("abcd", helper.toString());
+        // append after full
+        helper.append('e');
+        assertEquals("abcd", helper.toString());
+        helper.append("fg");
+        assertEquals("abcd", helper.toString());
+    }
+
+    @Test
+    public void testTail() {
+        StringBuilderHelper helper = StringBuilderHelper.tail(4);
+        helper.append("abc");
+        assertEquals("abc", helper.toString());
+        helper.append('d');
+        assertEquals("abcd", helper.toString());
+        // append after full
+        helper.append('e');
+        assertEquals("bcde", helper.toString());
+        helper.append("fg");
+        assertEquals("defg", helper.toString());
+    }
+
+    @Test
+    public void testHeadAndTail() {
+        StringBuilderHelper helper1 = StringBuilderHelper.headTail(3, 2);
+        helper1.append("abc");
+        assertEquals("abc", helper1.toString());
+        helper1.append('d');
+        assertEquals("abcd", helper1.toString());
+        helper1.append('e');
+        assertEquals("abcde", helper1.toString());
+        helper1.append("fg");
+        assertEquals("abcfg", helper1.toString());
+        helper1.append('h');
+        assertEquals("abcgh", helper1.toString());
+    }
+
+    @Test
+    public void testHeadAndTailWithLargeSize() {
+        // expected truncate
+        testWithLength(3000, 3000, 10000, 0, 1, "TEST HEAD", "TEST TAIL");
+        testWithLength(3000, 3000, 10000, 10, 100, "TEST HEAD", "TEST TAIL");
+        testWithLength(3000, 3000, 10000, 10, TailBuilder.ENTRY_SIZE + 10, 
"TEST HEAD", "TEST TAIL");
+        testWithLength(3000, 3000, 10000, TailBuilder.ENTRY_SIZE, 
TailBuilder.ENTRY_SIZE + 10, "TEST HEAD",
+                "TEST TAIL");
+
+        // expected not truncate
+        testWithLength(3000, 3000, 1500, 0, 1, "TEST HEAD", "TEST TAIL");
+        testWithLength(3000, 3000, 1500, 10, 100, "TEST HEAD", "TEST TAIL");
+        testWithLength(3000, 3000, 1500, 10, TailBuilder.ENTRY_SIZE + 10, 
"TEST HEAD", "TEST TAIL");
+        testWithLength(3000, 3000, 1500, TailBuilder.ENTRY_SIZE, 
TailBuilder.ENTRY_SIZE + 10, "TEST HEAD", "TEST TAIL");
+
+    }
+
+    private void testWithLength(int headSize, int tailSize, int appendTotal, 
int strMinLengthExclusive,
+            int strMaxLength, String head, String tail) {
+        StringBuilder stringBuilder = new StringBuilder();
+        StringBuilderHelper helper1 = StringBuilderHelper.headTail(headSize, 
tailSize);
+        helper1.append(head);
+        stringBuilder.append(head);
+        assertEquals(head, helper1.toString());
+        int totalSize = headSize + tailSize;
+        Random random = new Random();
+        int appended = 0;
+        while (appended < appendTotal) {
+            int length = Math.min(random.nextInt(strMaxLength - 
strMinLengthExclusive + 1) + strMinLengthExclusive,
+                    appendTotal - appended);
+            String str = random.ints('a', 'z' + 1).limit(length)
+                    .collect(StringBuilder::new, 
StringBuilder::appendCodePoint, StringBuilder::append).toString();
+            if (str.length() == 1) {
+                // test append char
+                helper1.append(str.charAt(0));
+            } else {
+                helper1.append(str);
+            }
+            stringBuilder.append(str);
+            appended += str.length();
+        }
+        helper1.append(tail);
+        stringBuilder.append(tail);
+        String string = stringBuilder.toString();
+        if (string.length() > totalSize) {
+            string = string.substring(0, headSize) + 
string.substring(string.length() - tailSize);
+        }
+        assertEquals(string, helper1.toString());
+    }
+
+}
diff --git 
a/src/core-common/src/test/java/org/apache/kylin/common/util/TailBuilderTest.java
 
b/src/core-common/src/test/java/org/apache/kylin/common/util/TailBuilderTest.java
new file mode 100644
index 0000000000..0a17c841d4
--- /dev/null
+++ 
b/src/core-common/src/test/java/org/apache/kylin/common/util/TailBuilderTest.java
@@ -0,0 +1,104 @@
+/*
+ * 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.kylin.common.util;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+public class TailBuilderTest {
+
+    @Test
+    public void testConstructor() {
+        assertThrows(IllegalArgumentException.class, () -> new 
TailBuilder(-1));
+    }
+
+    @Test
+    public void testAppendCharSequence() {
+        TailBuilder tailBuilder = new TailBuilder(10);
+
+        // empty string
+        tailBuilder.append("");
+        assertEquals(0, tailBuilder.length());
+
+        // str size < maxCapacity
+        tailBuilder.append("abc");
+        assertEquals(3, tailBuilder.length());
+
+        // total size > maxCapacity
+        tailBuilder.append("12345678");
+        assertEquals("bc12345678", tailBuilder.toString());
+        assertEquals(10, tailBuilder.length());
+
+        // str size = maxCapacity
+        tailBuilder = new TailBuilder(10);
+        tailBuilder.append("1234567890");
+        assertEquals("1234567890", tailBuilder.toString());
+        assertEquals(10, tailBuilder.length());
+
+        // str size > maxCapacity
+        tailBuilder = new TailBuilder(6);
+        tailBuilder.append("12345678901");
+        assertEquals("678901", tailBuilder.toString());
+        assertEquals(6, tailBuilder.length());
+    }
+
+    @Test
+    public void testAppendChar() {
+        TailBuilder tailBuilder = new TailBuilder(10);
+        tailBuilder.append('a');
+        assertEquals(1, tailBuilder.length());
+    }
+
+    @Test
+    public void testEnsureWriteEntry() {
+        TailBuilder tailBuilder = new TailBuilder(10);
+
+        for (int i = 0; i < 10; i++) {
+            tailBuilder.append('a');
+        }
+        tailBuilder.append('b');
+        assertEquals(10, tailBuilder.length());
+        assertEquals("aaaaaaaaab", tailBuilder.toString());
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    public void testLargeStr(boolean appendHead) {
+        StringBuilder stringBuilder = new StringBuilder();
+        TailBuilder tailBuilder = new TailBuilder(TailBuilder.ENTRY_SIZE / 2);
+        if (appendHead) {
+            stringBuilder.append("test head");
+            tailBuilder.append("test head");
+        }
+        char[] chars = new char[TailBuilder.ENTRY_SIZE + 100];
+        for (int i = 0; i < TailBuilder.ENTRY_SIZE + 100; i++) {
+            chars[i] = (char) ('a' + (i % 26));
+        }
+        String testStr = new String(chars);
+        stringBuilder.append(testStr);
+        tailBuilder.append(testStr);
+        assertEquals(TailBuilder.ENTRY_SIZE / 2, tailBuilder.length());
+        assertEquals(stringBuilder.substring(stringBuilder.length() - 
TailBuilder.ENTRY_SIZE / 2),
+                tailBuilder.toString());
+    }
+
+}


Reply via email to