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 c6116095f7d Log a query on a single line (#19453)
c6116095f7d is described below

commit c6116095f7d3834613649c770d75fed7dd5a874a
Author: shivam-startree <[email protected]>
AuthorDate: Wed Sep 9 05:05:27 2026 +0530

    Log a query on a single line (#19453)
    
    * Log a query on a single line
    
    Collapses CR/LF runs in a query to a single space before it is logged.
    
    Clients routinely submit pretty-printed SQL -- JDBC and BI tools do it by
    default -- and the broker logs the query verbatim. A single log event then
    renders as one physical line per line of SQL as soon as anything downstream
    splits on newlines: a container runtime, a log shipper, a file tailer.
    
    Measured on one production broker, 60s, 3,995 queries:
    
      "SQL query for request <id>:" header lines      3,995
      continuation lines after it                    30,975   (7.8 per query)
      completion stats lines                          3,994
      continuation lines after those                 30,969   (7.8 per query)
      ------------------------------------------------------
      total unmarked SQL lines                       61,944   = 79.4% of that
                                                                broker's entire
                                                                log volume
    
    Collapsing newlines removes those 61,944 lines while keeping both log
    records and every field they carry -- 2 lines per query instead of 15.5.
    
    The continuation lines are worse than merely numerous. They carry no
    timestamp, level or logger name, so nothing downstream can filter, sample
    or attribute them, and a multiline log collector will attach them to
    whichever record happened to precede them -- silently corrupting an
    unrelated log entry as well as the query.
    
    Applied inside redactQuery, which every query-logging call site in the
    broker already routes through (~45 of them across QueryLogger,
    BaseSingleStageBrokerRequestHandler and MultiStageBrokerRequestHandler),
    so the broker's error and cancellation paths get the same treatment rather
    than just the two QueryLogger sites. Every caller is a log statement, so
    normalising there is consistent with the method's purpose.
    
    Only line breaks are touched. Indentation and spacing within a line are
    left alone, so the logged text stays as close to what the client sent as a
    single-line rendering allows and remains copy-pasteable. The FULL and
    LITERAL_VALUES redaction modes are unaffected -- neither returns a
    multi-line value.
    
    Adds two tests, one per log site, and a generateParams overload so a test
    can supply the query text.
    
    * Escape line breaks rather than collapsing them
    
    Review catch: replacing a newline with a space is not semantics-preserving.
    A newline terminates a line comment, and Pinot's grammar accepts both forms
    (SINGLE_LINE_COMMENT in Parser.jj: ("//"|"--")(~["\n","\r"])* ...), so
    
        SELECT a -- note
        FROM t
    
    would have been logged as
    
        SELECT a -- note FROM t
    
    which no longer parses, and no longer means what was actually run. The
    logged query is a debugging aid people copy and replay, so silently
    changing it is worse than leaving it on several lines.
    
    Escaping CR/LF as \r and \n keeps the record on one physical line without
    removing or reinterpreting a single character. Queries already on one line
    are returned untouched, so nothing changes for the common case.
    
    Checked for an existing helper first: LoggerUtils is the runtime log-level
    API, and commons-text is managed in the root pom but not a pinot-broker
    dependency -- and StringEscapeUtils.escapeJava would be too aggressive
    here, escaping double quotes and unicode and so mangling quoted
    identifiers. There is no existing precedent in the tree for escaping
    newlines in logged user-supplied text, hence the local two-character
    replace.
    
    Tests: reworked for the escaped form, plus a regression test for the line
    comment case above and one asserting a single-line query is passed through
    unchanged.
    
    * Make single-line query log escaping reversible
    
    ---------
    
    Co-authored-by: Xiang Fu <[email protected]>
---
 .../apache/pinot/broker/querylog/QueryLogger.java  |  16 ++-
 .../pinot/broker/querylog/QueryLoggerTest.java     | 133 ++++++++++++++++++++-
 2 files changed, 146 insertions(+), 3 deletions(-)

diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/querylog/QueryLogger.java 
b/pinot-broker/src/main/java/org/apache/pinot/broker/querylog/QueryLogger.java
index 2e30a87108e..6276ddeb265 100644
--- 
a/pinot-broker/src/main/java/org/apache/pinot/broker/querylog/QueryLogger.java
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/querylog/QueryLogger.java
@@ -50,7 +50,7 @@ public class QueryLogger {
   private static final String FULLY_REDACTED = "REDACTED";
 
   public enum SqlRedactionMode {
-    // Log the full SQL query text as-is.
+    // Log the full SQL query text with backslashes and line endings escaped.
     // e.g. "SELECT name FROM users WHERE id = 42 AND status = 'active'"
     NONE,
     // Replace literal values with placeholders using the query fingerprint, 
preserving query structure.
@@ -201,10 +201,22 @@ public class QueryLogger {
         return queryFingerprint != null ? queryFingerprint.getFingerprint() : 
FINGERPRINT_FAILED_QUERY_REDACTED;
       case NONE:
       default:
-        return query;
+        return toSingleLine(query);
     }
   }
 
+  /// Escapes backslashes, CR and LF as `\\`, `\r` and `\n` so the query 
occupies a single log line.
+  /// A line ending can terminate a `--` or `//` comment, so replacing it with 
a space changes SQL semantics.
+  /// Escaping existing backslashes distinguishes literal escape sequences 
from encoded line endings.
+  /// Decode these three escape sequences in a single pass before replaying 
untruncated logged SQL.
+  @Nullable
+  private static String toSingleLine(@Nullable String query) {
+    if (query == null || (query.indexOf('\\') < 0 && query.indexOf('\n') < 0 
&& query.indexOf('\r') < 0)) {
+      return query;
+    }
+    return query.replace("\\", "\\\\").replace("\r", "\\r").replace("\n", 
"\\n");
+  }
+
   private boolean shouldForceLog(@Nullable QueryLogParams params) {
     if (params == null) {
       return false;
diff --git 
a/pinot-broker/src/test/java/org/apache/pinot/broker/querylog/QueryLoggerTest.java
 
b/pinot-broker/src/test/java/org/apache/pinot/broker/querylog/QueryLoggerTest.java
index 80583db3b5f..58dd30cc3e8 100644
--- 
a/pinot-broker/src/test/java/org/apache/pinot/broker/querylog/QueryLoggerTest.java
+++ 
b/pinot-broker/src/test/java/org/apache/pinot/broker/querylog/QueryLoggerTest.java
@@ -41,10 +41,15 @@ import org.slf4j.Logger;
 import org.testng.Assert;
 import org.testng.annotations.AfterMethod;
 import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
 
 import static org.apache.pinot.broker.querylog.QueryLogger.SqlRedactionMode;
+import static org.mockito.Mockito.when;
 import static org.mockito.MockitoAnnotations.openMocks;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
 
 
 @SuppressWarnings("UnstableApiUsage")
@@ -225,6 +230,126 @@ public class QueryLoggerTest {
     Assert.assertTrue(_infoLog.get(0).contains("SQL query for request 123"));
   }
 
+  @Test
+  public void shouldLogMultiLineQueryOnASingleLine() {
+    // Given: a client that submits pretty-printed SQL, as JDBC/BI tools 
routinely do
+    when(_logRateLimiter.tryAcquire()).thenReturn(true);
+    QueryLogger queryLogger = new QueryLogger(_logRateLimiter, 10_000, true, 
true,
+        SqlRedactionMode.NONE, _logger, _droppedRateLimiter);
+    String prettyPrinted = "SELECT id, name\nFROM users\r\nWHERE id = 
42\nLIMIT 10";
+
+    // When:
+    queryLogger.logQueryReceived(123L, prettyPrinted, null);
+
+    // Then: one record, on one physical line
+    assertEquals(_infoLog.size(), 1);
+    String logged = _infoLog.get(0);
+    assertFalse(logged.contains("\n"), "logged query must not contain a line 
feed: " + logged);
+    assertFalse(logged.contains("\r"), "logged query must not contain a 
carriage return: " + logged);
+    // and the breaks are escaped, not discarded, so nothing is lost
+    assertTrue(logged.contains("SELECT id, name\\nFROM users\\r\\nWHERE id = 
42\\nLIMIT 10"), logged);
+  }
+
+  @DataProvider(name = "lineComments")
+  public Object[][] lineComments() {
+    return new Object[][]{{"--"}, {"//"}};
+  }
+
+  @Test(dataProvider = "lineComments")
+  public void shouldNotBreakLineCommentsWhenLoggingOnASingleLine(String 
commentPrefix) {
+    // Given: a query whose line comment is terminated by the newline. Pinot's 
grammar accepts both
+    // "--" and "//" line comments (SINGLE_LINE_COMMENT in Parser.jj), so 
replacing the break with a
+    // space would pull "FROM t" into the comment and change what the logged 
query means.
+    when(_logRateLimiter.tryAcquire()).thenReturn(true);
+    QueryLogger queryLogger = new QueryLogger(_logRateLimiter, 10_000, true, 
true,
+        SqlRedactionMode.NONE, _logger, _droppedRateLimiter);
+
+    // When:
+    String query = "SELECT a " + commentPrefix + " note\nFROM t";
+    queryLogger.logQueryReceived(123L, query, null);
+
+    // Then:
+    assertEquals(_infoLog.size(), 1);
+    String logged = _infoLog.get(0);
+    assertFalse(logged.contains("\n"), logged);
+    assertEquals(logged, "SQL query for request 123: SELECT a " + 
commentPrefix + " note\\nFROM t");
+    assertEquals(logged.substring("SQL query for request 123: 
".length()).translateEscapes(), query);
+    assertFalse(logged.contains(commentPrefix + " note FROM t"),
+        "the comment must not swallow the rest of the statement: " + logged);
+  }
+
+  @Test
+  public void shouldLeaveASingleLineQueryUntouched() {
+    // Given:
+    when(_logRateLimiter.tryAcquire()).thenReturn(true);
+    QueryLogger queryLogger = new QueryLogger(_logRateLimiter, 10_000, true, 
true,
+        SqlRedactionMode.NONE, _logger, _droppedRateLimiter);
+
+    // When:
+    queryLogger.logQueryReceived(123L, "SELECT a FROM t WHERE s = 'x'", null);
+
+    // Then: no escaping applied to a query that was already on one line
+    assertEquals(_infoLog.size(), 1);
+    assertTrue(_infoLog.get(0).contains("SELECT a FROM t WHERE s = 'x'"), 
_infoLog.get(0));
+  }
+
+  @Test
+  public void shouldLogMultiLineQueryOnASingleLineOnCompletion() {
+    // Given: the completion record carries the query too, so it needs the 
same treatment
+    when(_logRateLimiter.tryAcquire()).thenReturn(true);
+    QueryLogger queryLogger = new QueryLogger(_logRateLimiter, 10_000, true, 
false,
+        SqlRedactionMode.NONE, _logger, _droppedRateLimiter);
+    QueryLogger.QueryLogParams params =
+        generateParams(false, false, 0, 456, null, "SELECT id\nFROM 
users\nLIMIT 1");
+
+    // When:
+    queryLogger.logQueryCompleted(params, true);
+
+    // Then:
+    assertEquals(_infoLog.size(), 1);
+    String logged = _infoLog.get(0);
+    assertFalse(logged.contains("\n"), logged);
+    assertTrue(logged.contains("query=SELECT id\\nFROM users\\nLIMIT 1"), 
logged);
+  }
+
+  @DataProvider(name = "queriesWithEscapedCharacters")
+  public Object[][] queriesWithEscapedCharacters() {
+    return new Object[][]{
+        {"SELECT 'a\nb' FROM t", "SELECT 'a\\nb' FROM t"},
+        {"SELECT 'a\\nb' FROM t", "SELECT 'a\\\\nb' FROM t"},
+        {"SELECT 'a\rb' FROM t", "SELECT 'a\\rb' FROM t"},
+        {"SELECT 'a\\rb' FROM t", "SELECT 'a\\\\rb' FROM t"},
+        {"SELECT 'a\\\nb' FROM t", "SELECT 'a\\\\\\nb' FROM t"},
+        {"SELECT 'a\n\\b' FROM t", "SELECT 'a\\n\\\\b' FROM t"},
+        {"SELECT 'a\\\r\nb' FROM t", "SELECT 'a\\\\\\r\\nb' FROM t"},
+        {"SELECT 'a\\\\b' FROM t", "SELECT 'a\\\\\\\\b' FROM t"},
+        {"SELECT '\\d+\\s+\\w+' FROM t", "SELECT '\\\\d+\\\\s+\\\\w+' FROM t"},
+        {"SELECT '\\t\\b\\f\\141' FROM t", "SELECT '\\\\t\\\\b\\\\f\\\\141' 
FROM t"},
+        {"SELECT\t'a\tb' FROM t", "SELECT\t'a\tb' FROM t"}
+    };
+  }
+
+  @Test(dataProvider = "queriesWithEscapedCharacters")
+  public void shouldLogQueryWithReversibleEscaping(String query, String 
expected) {
+    when(_logRateLimiter.tryAcquire()).thenReturn(true);
+    QueryLogger queryLogger = new QueryLogger(_logRateLimiter, 10_000, true, 
true,
+        SqlRedactionMode.NONE, _logger, _droppedRateLimiter);
+
+    queryLogger.logQueryReceived(123L, query, null);
+    queryLogger.logQueryCompleted(generateParams(false, false, 0, 456, null, 
query), true);
+
+    assertEquals(_infoLog.size(), 2);
+    String receivedQuery = _infoLog.get(0).substring("SQL query for request 
123: ".length());
+    String completedLog = _infoLog.get(1);
+    String completedQuery = 
completedLog.substring(completedLog.indexOf(",query=") + ",query=".length());
+    for (String loggedQuery : List.of(receivedQuery, completedQuery)) {
+      assertEquals(loggedQuery, expected);
+      assertFalse(loggedQuery.contains("\n"));
+      assertFalse(loggedQuery.contains("\r"));
+      assertEquals(loggedQuery.translateEscapes(), query);
+    }
+  }
+
   @Test
   public void shouldNotLogQueryReceivedWhenRateLimited() {
     // Given: rate limiter denies
@@ -476,9 +601,15 @@ public class QueryLoggerTest {
 
   private QueryLogger.QueryLogParams generateParams(boolean 
numGroupsLimitReached, boolean numGroupsWarningLimitReached,
       int numExceptions, long timeUsedMs, QueryFingerprint queryFingerprint) {
+    return generateParams(numGroupsLimitReached, numGroupsWarningLimitReached, 
numExceptions, timeUsedMs,
+        queryFingerprint, "SELECT * FROM foo");
+  }
+
+  private QueryLogger.QueryLogParams generateParams(boolean 
numGroupsLimitReached, boolean numGroupsWarningLimitReached,
+      int numExceptions, long timeUsedMs, QueryFingerprint queryFingerprint, 
String query) {
     RequestContext requestContext = new DefaultRequestContext();
     requestContext.setRequestId(123);
-    requestContext.setQuery("SELECT * FROM foo");
+    requestContext.setQuery(query);
     requestContext.setNumUnavailableSegments(21);
 
     if (queryFingerprint != null) {


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

Reply via email to