anshul98ks123 opened a new pull request, #19716:
URL: https://github.com/apache/pinot/pull/19716

   Suggested labels: `bugfix`, `observability`, `release-notes` (new controller 
config key; error text changes)
   
   ## Problem
   
   A controller API that fails usually knows why, but only the controller log 
hears it. The error response carries the message of the outermost exception, 
and that is often just a wrapper.
   
   For example, upload a CSV through `/ingestFromFile` where a single-value 
column holds a `;`, the CSV reader's default multi-value delimiter. The 
response is:
   
   ```
   {"code":500,"error":"Caught exception when ingesting file into table: 
foo_OFFLINE. Caught exception while reading data"}
   ```
   
   The root cause, `Cannot read single-value from Object[]: [a, b] for column: 
name`, is logged and dropped. Neither the caller nor any tool built on the API 
can say which column or which value failed.
   
   This is not specific to ingestion:
   - `ControllerApplicationException` calls `super(message, status)`, so it 
passes its throwable to the logger only. 199 call sites hand it a cause that 
never reaches a response.
   - 32 more sites build the message from `e.getMessage()` but never pass `e`.
   - A throwable that isn't a `WebApplicationException` reaches 
`WebApplicationExceptionMapper`, which answers with only the outermost 
`t.getMessage()`.
   
   ## Change
   
   The fix is where every error body is built, so no call site has to change 
how it reports failures.
   
   - **`ExceptionUtils.appendCauses(message, cause, maxCauses, 
maxCauseLength)`** (pinot-common) appends the cause chain's messages to a 
message, each after ` -> `. It is bounded and doesn't repeat what is already 
said:
     - A cause whose message already appears as a whole in the text emitted so 
far is skipped. Examples: the `"…: " + e.getMessage()` idiom, a wrapper that 
repeats its cause, and the `cause.toString()` message that `new 
RuntimeException(cause)` generates.
     - Repeats are dropped before the bound applies. At most 5 causes are kept: 
the outermost and the innermost 4, with `...` between them. The root cause is 
never dropped.
     - Each cause is abbreviated **in the middle** to 1024 characters, so a 
trailing `for column: X` survives a long value dump. Surrogate pairs are never 
split.
     - A root cause without a message is named by its simple class name. Cycles 
terminate, and suppressed exceptions are ignored.
   - **`ControllerApplicationException`** keeps the throwable as its cause in 
`ExceptionLogMode.FULL`, the default for the 4-arg constructors. `getMessage()` 
is unchanged.
     - New `LOG_ONLY` logs the throwable but does not keep it.
     - Like `TYPE_ONLY` (#19238), it keeps the causes out of the response.
     - A 4xx whose message already contains `e.getMessage()` now logs that 
message once instead of `"<msg> exception: <msg>"`.
   - **`WebApplicationExceptionMapper`** answers with 
`appendCauses(t.getMessage(), t.getCause(), 5, 1024)`. This covers 
`ControllerApplicationException`, other `WebApplicationException`s (e.g. 
Jersey's `ParamException` with its `NumberFormatException`) and unexpected 
throwables. An unexpected throwable without a message is named by its type. 
`code` is unchanged.
   - **`controller.api.error.response.include.causes`** (default `true`). Set 
it to `false` to answer with the message alone, byte for byte as before. A test 
flips it on a running controller.
   - The 32 sites that dropped their cause now pass it. In total, 223 call 
sites now keep a cause for the response.
   
   ### What keeps its causes out of the response
   
   Some causes describe something the caller must not learn, so these paths use 
`LOG_ONLY`:
   
   - **Permission checks, before the caller is authorized:** 
`AccessControlUtils.validatePermission`, `FineGrainedAuthUtils` (whose 
throwable is already logged), and the table-access check in segment download. 
The causes come from the access control plugin and can name an IdP, internal 
hosts or classpath conflicts.
   - **Segment upload catch-alls** (single, re-ingested and batch): a segment 
can be fetched from a caller-supplied `DOWNLOAD_URI`. Specific failures thrown 
inside, such as invalid segment metadata, still carry their causes.
   - **`/ingestFromFile` failures other than record failures.**
     - A batch config can make a record reader open a controller-local file 
(e.g. `recordReader.prop.descriptorFile`), so a setup failure's causes could 
reveal whether that file exists.
     - The new `RecordProcessingException` (pinot-segment-spi) marks record 
failures at the two row-level wrap sites, in the stats pass and the indexing 
pass. Only those failures keep their cause.
     - `RecordProcessingException` is a `RuntimeException`, and the wrapper 
messages are unchanged.
   - **`/ingestFromURI`** keeps its generic responses (`TYPE_ONLY`, #19238).
   
   For the upload above, the response now reads:
   
   ```
   Caught exception when ingesting file into table: foo_OFFLINE. Caught 
exception while reading data -> Caught exception while transforming data type 
for column: name -> Cannot read single-value from Object[]: [cooper, max] for 
column: name
   ```
   
   ## Disclosure, and the default
   
   Beyond the paths above, callers of every controller endpoint now see the 
deeper causes of failures. Many sites already put `e.getMessage()` in the 
response.
   
   What I checked:
   - **Access-control users:** causes come from ZK and user-config parsing. 
Jackson 2.22 leaves `INCLUDE_SOURCE_IN_LOCATION` off, so parse errors don't 
echo the request body.
   - **Log download:** it validates the path under the log root and already 
reports a missing file.
   - **Page-cache warmup:** it answers with fixed messages.
   - **Record failures from Parquet or ORC readers:** these can name the 
staging copy of the uploaded file under the controller's temp dir.
   
   The independent review preferred making this opt-in per call site, or 
default-off. I kept it on by default because the point is that the root cause 
reaches callers without every site having to opt in. The kill switch restores 
the previous bodies fleet-wide, and any site can opt out with `LOG_ONLY` or 
`TYPE_ONLY`. I'm happy to flip the default if reviewers prefer.
   
   ## Tests
   
   - `ExceptionUtilsTest` (38):
     - de-dup against the emitted text, whole-word matching only (`"5"` is not 
already said by `"table_5"`), and repeats dropped before the bound;
     - a middle abbreviation that keeps the tail;
     - blank and generated messages;
     - bounds, including `maxCauses = 1` keeping the root;
     - surrogate pairs and cycles.
   - `WebApplicationExceptionMapperTest` (12):
     - with and without a cause;
     - `LOG_ONLY` and `TYPE_ONLY`;
     - Jersey-style `WebApplicationException`, and unexpected throwables with 
and without messages;
     - bounds;
     - the config key, including byte-for-byte old bodies with it off.
   - `ControllerApplicationExceptionTest` (+7): which modes keep the cause; 
logging for 4xx and 5xx.
   - `PinotIngestionRestletResourceTest` (9): record failures keep their cause 
and setup failures don't, on the 400 and 500 branches; cyclic chains; bounds.
   - `RecordProcessingExceptionTest` (2): both row-level passes throw the 
marker.
   - `AccessControlUtilsTest`, `FineGrainedAuthUtilsTest`: a throwing plugin's 
failure does not become the cause.
   - `PinotIngestionRestletResourceStatelessTest` (+4), against a real 
controller over HTTP:
     - the exact body above;
     - the old body with the kill switch off;
     - the illegal-argument 400 unchanged;
     - `/ingestFromURI` still generic.
   - The full `pinot-controller` suite passes. No integration test compares an 
error body exactly; they all check with `contains`.
   
   🤖 Generated with [Claude Code](https://claude.com/claude-code)
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to