Skip to content

Improve Controller ingestFromURI filesystem validation - #19238

Open
xiangfu0 wants to merge 1 commit into
apache:masterfrom
xiangfu0:xiangfu0/ingest-uri-local-fs-hardening-master
Open

Improve Controller ingestFromURI filesystem validation#19238
xiangfu0 wants to merge 1 commit into
apache:masterfrom
xiangfu0:xiangfu0/ingest-uri-local-fs-hardening-master

Conversation

@xiangfu0

@xiangfu0 xiangfu0 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Summary

  • build on the existing default-off ingestFromURI local-filesystem policy by resolving and validating the exact filesystem before ingestion work starts
  • reject local path, subclass, alias, and delegate forms while keeping configured and request-provided remote filesystems working
  • keep request filesystem lifecycle ownership explicit, return generic API errors, and document the compatibility opt-in
  • ensure independently created Hadoop filesystem adapters own the clients they close

Existing behavior on master

Master already contains dd6520c7267 / #18660, which introduced the default-off setting and the initial direct URI and class checks. This change builds on that implementation to cover remaining delegate, lifecycle, ordering, and error-handling cases.

Behavior before this change

Filesystem selection could happen during copying, and endpoint-provided implementations were registered in shared factory state. Rejected requests could also create Controller staging directories before source validation.

Review focus

This PR has an additive pinot-spi surface and filesystem lifecycle contract change:

  • PinotFSFactory.isFileSystemInstanceOf(...) recursively checks Pinot non-closing delegates without exposing the delegate
  • PinotFS.close() documents that implementations close only resources owned by that instance
  • HadoopPinotFS uses an independently owned Hadoop client because the adapter closes it

Filesystem-plugin and Controller maintainers should review these ownership and compatibility details.

Testing

  • 56 focused tests across PinotFSFactoryTest, ControllerConfTest, FileIngestionHelperTest, PinotIngestionRestletResourceStatelessTest, and HadoopPinotFSTest
  • Spotless, Checkstyle, license formatting, and license checks for pinot-spi, pinot-controller, and pinot-hdfs

@codecov-commenter

codecov-commenter commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.71014% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.98%. Comparing base (2aa3aad) to head (dab1605).

Files with missing lines Patch % Lines
...che/pinot/controller/util/FileIngestionHelper.java 80.95% 9 Missing and 3 partials ⚠️
...r/api/resources/PinotIngestionRestletResource.java 50.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff            @@
##             master   #19238   +/-   ##
=========================================
  Coverage     66.97%   66.98%           
  Complexity     1423     1423           
=========================================
  Files          3453     3453           
  Lines        218936   218975   +39     
  Branches      34802    34802           
=========================================
+ Hits         146638   146672   +34     
- Misses        60588    60592    +4     
- Partials      11710    11711    +1     
Flag Coverage Δ
integration 100.00% <ø> (ø)
integration1 100.00% <ø> (ø)
integration2 0.00% <ø> (ø)
java-25 66.98% <79.71%> (+<0.01%) ⬆️
lane-a 100.00% <ø> (ø)
lane-b 0.00% <ø> (ø)
temurin 66.98% <79.71%> (+<0.01%) ⬆️
unittests 66.97% <79.71%> (+<0.01%) ⬆️
unittests1 57.72% <100.00%> (-0.02%) ⬇️
unittests2 39.04% <78.26%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@xiangfu0
xiangfu0 force-pushed the xiangfu0/ingest-uri-local-fs-hardening-master branch 2 times, most recently from f02926f to 036d56b Compare August 14, 2026 05:01
Validate filesystem policy before creating ingestion state, and avoid registering request-provided filesystems in shared factory state.

Request-scoped filesystem instances own their lifecycle. Hadoop-backed instances use independent clients so closing one request does not affect another.
@xiangfu0
xiangfu0 force-pushed the xiangfu0/ingest-uri-local-fs-hardening-master branch from 036d56b to dab1605 Compare August 14, 2026 05:07
@xiangfu0
xiangfu0 marked this pull request as ready for review August 14, 2026 08:11

@yashmayya yashmayya left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read through the whole change. The ordering and the filesystem lifecycle look right to me:

  • Resolving the filesystem before anything else means a rejected request creates no working directory, and holding the resolved instance removes the window where a factory change could swap it out mid-request.
  • Dropping PinotFSFactory.register() from the request path is a good side effect: request threads no longer mutate the static, unsynchronized PINOT_FS_MAP.
  • Checking the class before construction works as intended. I confirmed PluginManager.loadClass only links the class, it does not initialize it, so nothing in a rejected class runs.
  • isFileSystemInstanceOf walking the full NoClosePinotFS chain covers the nested delegate case.
  • LocalPinotFS is the only bundled implementation that can read controller-local paths. HadoopPinotFS cannot be aimed at one, because Hadoop's checkPath requires the URI scheme to match the filesystem, and the file scheme is rejected earlier.

Test coverage is thorough. A few comments below. The HadoopPinotFS one is the only one I would like handled before or soon after merge; the rest are small.

Comment on lines +203 to +208
} catch (IllegalArgumentException | URISyntaxException e) {
asyncResponse.resume(
new ControllerApplicationException(LOGGER, "Invalid ingestFromURI request", Response.Status.BAD_REQUEST));
} catch (Exception | LinkageError e) {
asyncResponse.resume(new ControllerApplicationException(LOGGER, "Failed to ingest from URI",
Response.Status.INTERNAL_SERVER_ERROR));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both branches drop e. The 3-arg ControllerApplicationException(logger, message, status) logs only the message (logger.info(message) for 4xx, logger.error(message) for 5xx), so the cause never reaches the log and a failure here is not debuggable.

Please pass e to the 4-arg constructor. The response body is built from message by WebApplicationExceptionMapper, so it stays generic either way.

Comment on lines +179 to +184
if (payload._payloadType == PayloadType.URI) {
LOGGER.error("Failed URI ingestion for table: {}, exception type: {}", tableNameWithType,
e.getClass().getName());
} else {
LOGGER.error("Caught exception when ingesting file to table: {}", tableNameWithType, e);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For URI payloads this keeps only the exception class name. Note also that failures from resolveSourceFileSystem are thrown before this try block, so they are not logged here at all.

This log is server side and is not returned to the caller, so I would log the full exception in both branches.

public static void copyURIToLocal(Map<String, String> batchConfigMap, URI sourceFileURI, File destFile,
boolean allowLocalFileSystem)
throws Exception {
try (ResolvedFileSystem sourceFileSystem =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 3-arg copyURIToLocal just above (line 192) passes allowLocalFileSystem = true and now has no callers anywhere, in main or test code. Please delete it so callers always state the flag.

Comment on lines +80 to +82
// Hadoop's FileSystem.get() returns a process-cached instance. HadoopPinotFS closes its filesystem, so it must
// own a distinct instance to avoid one PinotFS closing a client that is still in use elsewhere.
_hadoopFS = org.apache.hadoop.fs.FileSystem.newInstance(_hadoopConf);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right ownership fix, but newInstance() also leaves the shared cache, so every instance must now be closed or it stays alive.

PinotFSFactory.register() overwrites the map entry without closing the value it replaces, and two callers re-register in a loop inside a long-lived JVM:

  • SparkSegmentGenerationJobRunner registers all pinotFSSpecs once per input path inside pathRDD.foreach, on the executor.
  • HadoopSegmentCreationMapper does the same per mapper.

With get() these all shared one cached client, so overwriting was harmless. Now each call builds a client that nothing closes. Hadoop's newInstance() goes through Cache.getUnique(), which keeps the instance in the static cache until it is closed, so this holds heap, RPC connections and lease renewer threads.

Can PinotFSFactory.register() close the instance it replaces? That covers every implementation, not only this one. S3PinotFS has the same shape today.

Comment on lines +487 to +494
try (HadoopPinotFS first = new HadoopPinotFS(); HadoopPinotFS second = new HadoopPinotFS()) {
first.init(new PinotConfiguration());
second.init(new PinotConfiguration());

Field hadoopFileSystemField = HadoopPinotFS.class.getDeclaredField("_hadoopFS");
hadoopFileSystemField.setAccessible(true);
Assert.assertNotSame(hadoopFileSystemField.get(first), hadoopFileSystemField.get(second));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reads a private field by reflection. The behaviour that matters is that closing one instance leaves the other usable. Close first, then call something on second and assert it still works. That tests the contract directly and survives a field rename.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants