AWS, Core: Read S3 files via catalog-vended HTTPS URLs - #17457
AWS, Core: Read S3 files via catalog-vended HTTPS URLs#17457williamhyun wants to merge 13 commits into
Conversation
singhpk234
left a comment
There was a problem hiding this comment.
Thanks for the work @williamhyun !
Added first round of comments
|
Thank you @singhpk234 for the comments, I have pushed with the most recent changes, please feel free to review again! |
steveloughran
left a comment
There was a problem hiding this comment.
Commented.
-
Error handling is a lot more complex and s3FileIO has historically been weak here; not seen enough errors or layers above have recovered and people haven't noticed/complained about the performance penalties. Factoring it all out into one place lines up for better maintenance and adding handlers for the failure situations which haven't been handled yet.
-
Curious whether that 4MB block size came from; I think 8 MB is what others use.
-
It would be really interesting to consider the merits of prefetching or not. Parquet files do NOT want prefetching, they want parallel ranged reads in separate threads. If puffin file readers are using vector read APIs that is what they will want too. Otherwise, reading those files may benefit from prefetching. Avro files do, FWIW.
| public boolean exists() { | ||
| try { | ||
| HttpGet request = new HttpGet(url); | ||
| request.setHeader(HttpHeaders.RANGE, "bytes=0-0"); |
There was a problem hiding this comment.
Nice trick, curious if there's any performance penalty against aws s3 though
There was a problem hiding this comment.
Thanks Steve. As far as I'm aware I don't expect a material performance penalty against S3 for this. Since GET and HEAD requests are categorized under the same per-request pricing. A partial GET that returns only one byte should be limited in extra cost. pricing
One thing to note is that GET can incur data-retrieval charges for storage classes such as Standard-IA, One Zone-IA, and Glacier Instant Retrieval, and bytes=0-0 is not satisfiable for a zero-byte object. For normal S3 Standard objects, though, I don’t expect a meaningful performance or cost difference. S3 storage classes
There was a problem hiding this comment.
why don't we just use HEAD call here?
There was a problem hiding this comment.
Hey @xndai, there are a couple things in our model of catalog-vended presigned URLs that limit our use of HEAD requests.
- S3's remote signed header is method-bound
- The catalog holds the credentials to sign the object and provide it to the client via URL.
Hence the catalog-vended URL is method bound to GET in our read use case and to do a HEAD request on the same object, we would need the catalog to vend a new, separate URL signed for the HEAD request. Effectively doubling sign calls, response payload, and introducing additional spec changes for carrying two URLs.
|
Thank you @steveloughran for the thoughtful comments,
Puffin is more nuanced to my knowledge:
For this PR, I'd propose we keep the prefetch for the sequential consumers (Avro + Puffin bulk scans) and leave |
|
The 8 MB came from AWS S3 engineers, so I'd go with that. As for the other comments, unambitious is more likely to get reviewed by a committer. I am not one, no longer actively doing any software dev, just doing spare time reviewing. I would at least recommend designing the http response handling such that if it were factored out then it's easy to find. And that 403 response is probably the one to go for. The SDK will retry internally, briefly, but under very heavy load or if something odd is happening like shard rebalancing capacity will drop and suddenly it will become very visible. If the other stuff is comparable to what's already there (i.e. not a regression, w.r.t. vector reads), then all is good, separate pr work |
|
Thank you @steveloughran that's a helpful way to scope it! To summarize what changed:
Thanks for the great context on throttling 403s. Since the pre-signed path bypasses the SDK, its brief internal retries don't apply here; but because the response handler already has the whole response in scope, a store-aware refiner (e.g. in the aws module) can later read the body and reclassify a throttling 403 as transient as follow-up work, without touching these core call sites. [1] |
|
Thank you @xndai for the comments, Changes made:
|
|
@williamhyun I like the classify() code design, FWIW. Elegant |
| public boolean exists() { | ||
| try { | ||
| HttpGet request = new HttpGet(url); | ||
| request.setHeader(HttpHeaders.RANGE, "bytes=0-0"); |
There was a problem hiding this comment.
why don't we just use HEAD call here?
| private static final int MAX_RETRIES = 3; | ||
| private static final int MIN_RETRY_WAIT_MS = 100; | ||
| private static final int MAX_RETRY_WAIT_MS = 5_000; | ||
| private static final int MAX_RETRY_DURATION_MS = 30_000; | ||
| private static final double RETRY_SCALE_FACTOR = 2.0; |
There was a problem hiding this comment.
can we make these configurable?
|
|
||
| @Override | ||
| public InputFile newInputFile(String path) { | ||
| if (HttpUrlClient.isHttpUrl(path)) { |
There was a problem hiding this comment.
This should probably be pushed up to a BaseFileIO implementation as opposed to duplicating these checks across all implementations.
| * schemes s3a, s3n are also treated as s3 file paths. HTTP(S) URL locations (e.g. a catalog-vended | ||
| * pre-signed URL) are read directly over HTTP(S) instead of through the native, credentialed S3 | ||
| * client; see {@link #newInputFile(String)}. Using this FileIO with other schemes will result in | ||
| * {@link org.apache.iceberg.exceptions.ValidationException}. |
There was a problem hiding this comment.
I don't think this clarification is necessary. We're exposing implementation which isn't necessary. The existing documentation states https is supported, so we don't need this addition context.
| private static final String ROOT_PREFIX = "s3"; | ||
| // Default HTTP read chunk size for pre-signed URL reads, tuned for S3; overridable via the | ||
| // io.http.read.chunk-size-bytes property. | ||
| private static final int HTTP_READ_CHUNK_SIZE_BYTES_DEFAULT = 8 * 1024 * 1024; // 8 MB |
There was a problem hiding this comment.
This shouldn't be in S3FileIO since it's a property of the HTTP implementation, not the S3 implementation.
There was a problem hiding this comment.
This is saying s3 prefers 8 MB read ... it might happen ADLS prefers 64 MB ... the generic HTTPInputStream is configurable to what the objectstore provider FileIO prefers, so s3 here is sending this HTTPInputStream it need to read it 8 MB chunks ... rather than HTTPInputstream fixing the http_read_chunk_bytes for everyone.
| private SerializableMap<String, String> properties = null; | ||
| private MetricsContext metrics = MetricsContext.nullMetrics(); | ||
| private final AtomicBoolean isResourceClosed = new AtomicBoolean(false); | ||
| private transient volatile HttpUrlClient httpUrlClient; |
There was a problem hiding this comment.
The http client shouldn't be at the S3FileIO, but rather managed by the HTTPInput/Output Class structure.
|
|
||
| @Override | ||
| public InputFile newInputFile(String path, long length) { | ||
| if (HttpUrlClient.isHttpUrl(path)) { |
There was a problem hiding this comment.
Similar to above, this should be delegated to a superclass implementation that can be shared across IO implementations.
| @Override | ||
| public void initialize(Map<String, String> props) { | ||
| this.properties = SerializableMap.copyOf(props); | ||
| // reset so the next access rebuilds from the new properties |
There was a problem hiding this comment.
This should be pushed to a super class as well.
| static final Set<String> PRESIGNED_READ_ALLOWED_HOSTS_DEFAULT = | ||
| Collections.unmodifiableSet(Sets.newHashSet("amazonaws.com", "amazonaws.com.cn")); |
There was a problem hiding this comment.
Why is this necessary? We shouldn't be prescribing the hosts or managing them from the client. The host is a catalog concern, the client should only process the request.
| public Set<String> presignedReadAllowedHosts() { | ||
| return presignedReadAllowedHosts; | ||
| } | ||
|
|
||
| private static Set<String> parsePresignedReadAllowedHosts( | ||
| Map<String, String> properties, String endpoint) { | ||
| Set<String> hosts = Sets.newHashSet(); | ||
| String configured = properties.get(PRESIGNED_READ_ALLOWED_HOSTS); | ||
| if (configured == null || configured.trim().isEmpty()) { | ||
| hosts.addAll(PRESIGNED_READ_ALLOWED_HOSTS_DEFAULT); | ||
| } else { | ||
| for (String suffix : Splitter.on(',').trimResults().omitEmptyStrings().split(configured)) { | ||
| hosts.add(suffix.toLowerCase(Locale.ROOT)); | ||
| } | ||
| } | ||
|
|
||
| // Always trust the configured S3 endpoint host, so custom, VPC, or S3-compatible endpoints | ||
| // work without extra configuration. | ||
| if (endpoint != null) { | ||
| try { | ||
| String endpointHost = URI.create(endpoint).getHost(); | ||
| if (endpointHost != null) { | ||
| hosts.add(endpointHost.toLowerCase(Locale.ROOT)); | ||
| } | ||
| } catch (IllegalArgumentException e) { | ||
| // An unparseable endpoint is validated elsewhere; ignore it for host allow-listing. | ||
| } | ||
| } | ||
|
|
||
| return hosts; | ||
| } | ||
|
|
There was a problem hiding this comment.
This isn't necessary and we should remove it.
| /** | ||
| * Restricts catalog-vended pre-signed reads to HTTPS URLs on operator-trusted hosts, before {@link | ||
| * S3FileIO} fetches them over HTTP with no S3 credentials. This bounds a compromised or malicious | ||
| * catalog to naming files on the expected storage hosts rather than turning the reader into a | ||
| * general-purpose fetcher of arbitrary URLs. | ||
| */ | ||
| class S3PresignedReadValidation { |
There was a problem hiding this comment.
We shouldn't be protecting from the catalog. This is unnecessary protection because the catalog is the one delegating access.
There was a problem hiding this comment.
I was worried about this before because I'm terrified of driving a client to a uri that somehow gives access to the client. But I now think we can't actually do anything intelligent here because we don't really now what the endpoints should be.
| * | ||
| * <p>A length that cannot be determined is reported as {@link #UNKNOWN_LENGTH}. | ||
| */ | ||
| class HttpHeaderUtil { |
There was a problem hiding this comment.
This util class doesn't strike me as necessary. This is logic that should be part of the HTTPInputFile implementation. I don't think we need a separate class for this functionality?
Generally, utility classes are created when there are multiple independent use cases, but that doesn't seem to be the case here.
| if (length == HttpHeaderUtil.UNKNOWN_LENGTH) { | ||
| this.length = fetchContentLength(); | ||
| } | ||
|
|
||
| return length; |
There was a problem hiding this comment.
This logic doesn't look quite right. If we see UNKNOWN_LENGTH then we try to fetch the content length, but that can result in an UNKNOWN_LENGTH which would then produce the incorrect length from this call.
If can't establish the length, we should throw.
| * status that is actually transient (for example a throttling {@code 403}, which some object stores | ||
| * return under heavy load) so that it is retried too. | ||
| */ | ||
| enum HttpStatusCategory { |
There was a problem hiding this comment.
I don't understand why we need this class. If this is all isolated to the Http implementation, we don't need an agnostic mapping (we just use the codes from the client).
There was a problem hiding this comment.
This is doing a classification of the error code to states ... for example which as transient (throttling) / which are persistent (403..) and based on that its taking action in stream .... i wonder why can't we do this in the https://github.com/apache/iceberg/pull/17457/changes#diff-88dbd57947faabb967a76beac0e51b21b52df326a25086cde68f983c43256708R238 ?
will think this more
| * When the property is not set, the default passed to {@link #HttpUrlClient(Map, int)} is used, | ||
| * letting a {@link org.apache.iceberg.io.FileIO} supply a value tuned for its backing object store. | ||
| */ | ||
| public class HttpUrlClient implements Serializable { |
There was a problem hiding this comment.
This class doesn't make sense to me. We already have a HttpClient interface in the project and this seems to create a separate wrapper interface over a specific implementation.
I don't think we should have this. The client can be initialized and held by the HttpInputFile class directly. This gives the impression that we need a separate standard way of building a client, but it doesn't abstract the underlying implementation and seems like an unnecessary and exposes things in a strange way. It's not actually an HTTP client and only eposes access to InputFile.
Summary
Adds support for reading object-store files over catalog-vended HTTP(S) URLs without requiring native cloud credentials on the reader, and wires it into
S3FileIO.HttpUrlClient,HttpInputFile, andHttpInputStream— a seekable, range-readable HTTPS input path built on thehttpclient5dependencyiceberg-corealready depends on. Reads are served via chunked range GETs whose size is configurable viaio.http.read.chunk-size-bytes(default 8 MB forS3FileIO); range fetches are retried with exponential backoff on transient network and retryable HTTP errors (e.g.429/503), while terminal statuses (403/404) fail fast. Content length is probed viaRange: bytes=0-0when unknown, since pre-signed GET URLs typically rejectHEAD. Pre-signed URLs are treated as bearer secrets and are redacted in logs.S3FileIO#newInputFile(String)/newInputFile(String, long)now detect an HTTP(S) location and read it viaHttpUrlClient, after validating (S3PresignedReadValidation) that it is HTTPS on an allow-listed host —s3.presigned-read.allowed-hosts(defaultamazonaws.com,amazonaws.com.cn, plus the configured S3 endpoint host); plainhttp://or an untrusted host is rejected withValidationException.s3:///s3a:///s3n://reads stay on the native, credentialed S3 client path unchanged.S3FileIO#close()now also closes the shared HTTP client.TestS3FileIOHttpUrlDispatchinjects a throwing S3 supplier to prove the native client is never invoked and thathttp:///untrusted hosts are rejected); host-allow-list validation (TestS3PresignedReadValidation);HttpInputFile/HttpInputStreambehavior against a real localHttpServer(TestHttpInputFile, including multi-chunk reads crossing the chunk-size boundary, retry/backoff on transient statuses, terminal-status handling, and redirect-not-followed); chunk-size property parsing, per-FileIOdefault precedence, redaction, and client config (TestHttpUrlClient); status classification and content-length parsing (TestHttpStatusCategory,TestHttpHeaderUtil); and an optional live-AWS integration test (TestS3FileIOLivePresignedUrls) gated on AWS credential env vars.This is the
FileIObuilding block for catalogs that vend a pre-signed URL as a file'sfile-pathin scan planning, so readers can fetch bytes over HTTPS with auth encoded in the URL itself. Delete-file handling, scan-planning integration, and other cloud providers are out of scope for this PR (see below).Scope / compatibility notes
file-path); the client uses that location unchanged as the fetch URL. There is no separate signing lookup or location-to-URL mapping in this PR.s3.presigned-read.allowed-hosts, bounding a compromised or malicious catalog to naming files on the expected storage hosts rather than turning the reader into a general-purpose URL fetcher.S3FileIOis updated.ResolvingFileIOand the GCS/ADLS equivalents are unchanged — a catalog that wants this behavior today needs to vendio-impl: S3FileIOper-table via the RESTLoadTableResponse.config()map (the specified mechanism for a catalog to select aFileIOfor a table), rather than relying onResolvingFileIO's scheme-based dispatch. Extending the other cloudFileIOs to the same short-circuit pattern is a natural follow-up.SplitScanTasksare already unsupported on the remote scan-plan read path, as are multiple deletion vectors in the same deletion file. These are known limitations of the remote scan-plan endpoint and are an orthogonal fix. (Core, REST: Fix delete file references for DVs in the same Puffin file #17497)HttpStatusCategory.classify(int), mapping raw status codes into coarse categories; only transient categories are retried (retry is driven by exception type). A cloud-specific refiner could later inspect error-response bodies to reclassify an otherwise-terminal status (e.g. a throttling403) as retryable, without exposing the package-private enum.Test plan
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_REGION, andAWS_TEST_BUCKETset:./gradlew :iceberg-aws:integrationTest --tests org.apache.iceberg.aws.s3.TestS3FileIOLivePresignedUrlsAI Disclosure
S3FileIO(a shared coreHttpUrlClientplus per-FileIOdispatch) so readers can fetch bytes from catalog-vended pre-signed URLs without native S3 credentials, with an HTTPS host allow-list and unit/integration/live-AWS test coverage.