datalake_fdw: storage I/O over S3 and pluggable storage backends - #2044
Draft
MisterRaindrop wants to merge 10 commits into
Draft
MisterRaindrop wants to merge 10 commits into
MisterRaindrop wants to merge 10 commits into
Conversation
The format layer had an interface and no implementation. This is the Parquet one, and the conversions either side of it: PostgreSQL tuples into an Arrow batch, and an Arrow batch back into Datums. Arrow rather than libparquet alone, because libparquet is written in terms of Arrow's types, so linking one links the other. It is a build dependency now, found with pkg-config -- and whatever -std= its .pc file asks for is filtered out, because those flags land after CXXFLAGS and the Arrow project's own packages say -std=c++11. A fragment is a range of row groups rather than a whole file, so several segments can read one large file. Reading is single-threaded: a worker thread that fails has no way to report it through PostgreSQL. A reader and a writer hold a descriptor and memory from Arrow's allocator, which transaction abort does not reclaim, so both register with the resource owner -- the shape PAX uses in comm/pax_resource.cc. Types: bool, the four integers, both floats, text, varchar, char, bytea, date, timestamp, timestamptz. Anything else is refused by name. The 30-year epoch shift is range checked in both directions: PostgreSQL's range runs past what Arrow holds as microseconds from 1970, and a round trip through two unchecked halves would agree with itself. datalake_fdw_test is a second extension in the same library with the two functions this can be run with from SQL. Its regression case round-trips every supported type and checks that reading row groups separately gives back what reading the whole file does. Arrow 9.0.0 (EPEL 9) builds and passes; 17.0.0 (Rocky 10, gcc 14) and 17.0.0 and 21.0.0 (Rocky 8, gcc 8) compile without warnings. What is written here reads back correctly in pyarrow 21, which is not the implementation that wrote it.
Review of apache#1951, plus what the same pass turned up. The framework is what this settles; type coverage beyond it is left to separate issues. Memory. Every Arrow allocation now goes through one pool (format/arrow_memory_pool.cpp) that reserves with the vmem tracker before allocating and releases after freeing, so statement_mem, the resource group and gp_vmem_protect_limit see Arrow's memory as they see palloc's. The reserve runs under HOLD_INTERRUPTS, because the tracker can elog(ERROR) on its way to saying no and a longjmp out of Arrow's C++ frames is undefined behaviour; pre_buffer is switched off by name, because from Arrow 13 it defaults on and allocates from I/O threads the tracker cannot see. All five pool sites use it, the two hidden defaults included. A refusal is DL_ERR_OUT_OF_MEMORY, ERRCODE_OUT_OF_MEMORY. Files. The writer creates its file with O_CREAT|O_EXCL and hands the descriptor to Arrow, so a failed write can no longer truncate and then delete a file that was already at the path; every failure path goes through parquet_discard, which only ever removes what this writer created. Compression names are lower-cased and asked of Arrow in three steps, so the message says whether Arrow, Parquet or this build is what refused. Columns. A table's columns are matched to a file's by Iceberg field id: the writer stamps PARQUET:field_id into the Parquet schema, ProjectionSet names field ids in output order (ABI 2), a field the file lacks reads as a null-typed column, and an id-less column can never be matched. The reader also accepts the promotions the spec allows, int32 as bigint and float as double precision. Dropped attributes are skipped in the schema, the batch builder and the writer. Types. time and uuid are added; large_utf8 and large_binary are read; INT96 timestamps are coerced to microseconds. Dictionary-encoded columns are refused rather than decoded as their indexes. Both sides require a UTF8 database, and the reader verifies every string before it becomes a text. varchar(n) and char(n) are refused, with numeric and everything else the format cannot store, at CREATE TABLE, through one function the DDL hook and the writer both ask (format/format_types.h); LIKE is refused because its columns are resolved after the hook has run. Smaller: dl_resource.c uses lib/ilist.h; the format name is compared without regard to case like every other option value; the test functions gain compression and field_ids arguments and check PG_NARGS() so that a stale extension definition errors instead of crashing. Left for later, each with an issue: NUMERIC, millisecond and nanosecond timestamp columns, name mapping for files without field ids, and the CI build image.
MisterRaindrop
force-pushed
the
feature/datalake-s3
branch
from
September 23, 2026 06:22
ba3d9b5 to
d695dcc
Compare
Where a lake table's files live was decided by a switch on the URI scheme, and only s3 and hdfs were written into it. This replaces that with a contract: a backend answers one question -- given a location and its options, which arrow::fs::FileSystem reads and writes it -- and everything else stays on this extension's side of the boundary. What lands here: - The contract, in five headers that "make install" puts under $(includedir_server)/extension/datalake_fdw/, so a backend can be built outside this tree against nothing else. It carries an ABI version, the Arrow version, and a fingerprint over the compiler major and _GLIBCXX_USE_CXX11_ABI, because shared_ptr and arrow::Result cross the boundary by value. - A registry keyed by URI scheme, found through a rendezvous variable. Registration happens during preload and in any order: the registering side pulls in datalake_fdw through load_external_function, inside a PG_TRY so an ereport cannot unwind through a plug-in's C++ frames. Each process initializes a backend at its first mount and registers the matching finalizer then, because on_exit_reset() clears what a postmaster child inherits. - The facade over that filesystem: opening, listing, deleting, turning a URI into a native path, and classifying failures into DlErrCode. Backends never re-parse a URI, and an empty listing is ruled not-found here rather than by each backend, because object storage and a filesystem disagree about what an empty prefix means. - A file backend for shared mounts, which creates with O_EXCL and, if it has to give up, removes the file this writer created and nothing else. - Credential scrubbing in one place. Every value under a key containing secret, token or password is remembered per process, and dl_error_set -- the single exit every DlErrCode error goes through -- removes it from the message, so an unexpected C++ exception and a third-party backend's own status are covered by construction. - A dltest backend, so the registration contract can be tested from inside this module. The parser now accepts file:// as well as s3://, and names what it rejects against the registry rather than a fixed list, so a scheme a plug-in registered can be written into a base_path. Three DETAIL lines in iceberg_am_reject change with it. The s3 backend is adapted to the new contract here and rewritten in the commit that follows.
The s3 backend was a stub. This makes it an arrow::fs::FileSystem of our own over the AWS SDK for C++, rather than Arrow's S3FileSystem: no RPM of Arrow is built with S3 support -- EPEL's and the Arrow project's own both set use_s3 0 -- so depending on it would mean asking every user to build Arrow. A synchronous S3Client, so a cancelled query cannot leave a callback holding a backend's memory. Reads go through PreallocatedStreamBuf straight into a tracked buffer rather than the SDK's default stringstream. Writes buffer 8 MiB and begin a multipart upload only when they exceed it, so a small file is one PutObject. Cleanup depends on whether an upload is still live rather than on whether the stream was closed, so a Close() that fails at the last part still aborts instead of leaving parts to be billed for. Connect timeout 5 s, request timeout 300 s, three retries, set here rather than inherited, so a black-holed endpoint answers in about 24 seconds on every Arrow version. The SDK is found by prefix -- the one AWS_SDK_PREFIX names, or the usual places a hand-built one lands. Without it the module still builds and says so, and opening an s3:// location reports that it was left out; a prefix with no SDK in it is an error rather than a silent fallback. It links statically and adds libcurl, OpenSSL and zlib to NEEDED and nothing else.
The Parquet reader and writer opened paths through the local file system directly. They now open through the storage facade, so a fragment names a filesystem and a path relative to its mount, and the same reader works against a volume on s3 as on a shared mount. "Create only" moves with them: the writer asks the facade, which is the one place that checks a name is free, and giving up is the stream's Abort rather than a delete by path -- after a failed write the name may already belong to somebody else. A volume resolves to a location plus credentials in one place, checking USAGE on the server and reading the user mapping, the PUBLIC mapping, or neither.
Three backends that must behave identically are worth one body of tests, not three that drift. A shared SQL file is parameterised over a URI prefix and a volume and run for file://, for s3:// and for the test backend: the type round trip, field-id projection, a row-group range, listing, a rejected overwrite, and a write that fails halfway leaving nothing behind. Volume resolution gets its own case, on a file volume so it runs everywhere: no USAGE, a PUBLIC mapping, a URI outside its volume, and a volume that does not exist.
Volumes and their options, where credentials come from and in what order, what a file:// volume requires of the filesystem, how to build with S3 support, and how to write a backend -- the example is compiled against the installed headers rather than written out by hand. Plus the limits that are accepted rather than fixed.
Two independent adversarial review rounds, then a read of the whole diff against this change's own invariants. The defects worth naming: - A multipart upload was abandoned rather than aborted. Close() marked the stream closed before uploading the last part, so the Abort() that followed a failure did nothing and the parts stayed, billable. Cleanup now depends on the upload being live, and the destructor does the same. - A local file could outlive the writer that created it: the guard was released one statement before the wrapper took ownership, and the wrapper had no destructor, so an exception rather than an error code left the file behind. - Two paths skipped credential scrubbing -- an unexpected exception's what(), and the format layer's own status mapping. Scrubbing moved to dl_error_set, which every error goes through. - A bare "404" was matched inside the whole status text, so an error about a directory named 404 read as not-found. Only phrases a filesystem writes about itself are matched now, and the README said three others that never were. - ReadAt trusted the length the service returned; a paginated listing whose continuation token stopped advancing looped forever; half a credential pair silently fell back to the host's own identity; a bucket that does not exist looked like a directory. - The validator quoted a rejected base_path back verbatim in its message while the detail beside it had just stopped doing so, so s3://user:password@host/path wrote the password to the server log. And in the tests, which were also under review: - A loop of the shape "SELECT count(*) FROM (SELECT f(i) FROM generate_series(...)) w" never called f at all -- the target list is unreferenced, so it is optimised away while the count still looks right. The pagination case wrote nothing for 1100 rows and passed. Every such loop now reads the value the function returned. - The redaction case asserted that a message did not contain a secret the service never echoes, so it passed with redaction removed entirely. It uses the bucket name as the secret now. - The paginated listing case had no expected output at all, so the one way CI is meant to run it could only fail. - Nothing checked that an empty prefix reads as not found, which is a rule this change introduced. Measured while auditing: reading and writing through s3:// costs a backend about 15 MiB of resident memory more than the same file locally, and that stays flat as the object grows -- 7.9 MiB for a 2 MB file, 14.8 MiB for a 149 MB one -- so neither the response body nor the part buffer accumulates. It is outside gp_vmem_protect_limit, which the README now says.
The s3 half of contrib/datalake_fdw's regression had nothing to run against. Three things were missing. The AWS SDK for C++, which no distribution packages: built from source with BUILD_ONLY="s3;sts", and cached under the version, the distribution and the architecture, since nothing else changes it. A miss costs about three minutes; the cache is 4 MB and restores in one second. Only the build dependencies the image lacks are installed, asked for by capability -- naming a package it already has makes dnf try to upgrade it, and on Rocky 10 the newest libcurl-devel wants a libcurl no enabled repository carries. A service that speaks S3: SeaweedFS, one static binary, pinned, and started with an identity file -- without one it accepts any credentials, and the case that asserts a wrong secret is refused would pass by not being tested. Readiness waits on the S3 port itself, which begins listening seconds after the master elects itself. Its coordinates, which "su - gpadmin" drops along with the rest of the environment, so they are named on the command line. Every other test entry leaves that empty, and a leg where the service did not start skips the s3 cases rather than failing them. Arrow on Rocky 9 and 10 now comes from the Arrow project's repository pinned to 17.0.0 and 21.0.0, as it already did on Rocky 8. EPEL's moves when EPEL does, and Arrow is the library this extension's ABI is shared with.
MisterRaindrop
force-pushed
the
feature/datalake-s3
branch
from
September 23, 2026 09:26
10e940a to
dbd0f51
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
contrib/datalake_fdwreads and writes Parquet (#1951), but only on a localfile system. This adds the storage layer underneath: an
s3backend over theAWS SDK for C++, and a published contract so a backend this extension does not
ship can be added from outside it.
Closes #2009.
Decisions worth a look:
arrow::fs::FileSystemreads and writes it. Opening files, listing,classifying errors, accounting for memory and translating a URI into a native
path all stay on this extension's side of the boundary, so a backend is a
mount function and nothing else.
s3is our ownarrow::fs::FileSystemover the AWS SDK, not Arrow's.No RPM of Arrow is built with S3 support -- EPEL's and the Arrow project's
own both set
use_s3 0-- so depending on it would mean asking every user tobuild Arrow. The SDK is a
BUILD_ONLY="s3;sts"static build that takes wellunder a minute, links statically, and adds libcurl, OpenSSL and zlib to
NEEDEDand nothing else.request 300 s, three retries, so a black-holed endpoint answers in about 24
seconds rather than hanging a session, on every Arrow version.
O_EXCLand unlinks that path alone; the S3 one keys cleanup on whether anupload is still live rather than on whether the stream is closed, so a
Close()that fails at the last part still aborts the upload instead ofleaving parts to be billed for.
secret,tokenorpasswordis remembered per process, anddl_error_set-- the single exit every
DlErrCodeerror goes through -- removes it fromthe message. An unexpected C++ exception and a third-party backend's own
status are covered by construction rather than by remembering to call
something. A
base_paththat carries a password in its userinfo is redactedthe same way, in the message as well as the detail.
the parser: a backend that registered
minecan be written into abase_pathwithout this extension being changed.version, and a fingerprint over the compiler major and
_GLIBCXX_USE_CXX11_ABI.Registration happens during preload and in any order -- the registering side
pulls in
datalake_fdwthroughload_external_function, inside aPG_TRYso an
ereportcannot unwind through the plug-in's C++ frames.backend's:
mountis handed a host struct carrying it, and the fiveinstalled headers give a backend no way to reach Arrow's default pool by
accident.
Type of Change
Test Plan
One body of storage behaviour runs against every backend rather than once per
backend:
storage_conformanceparameterises a shared SQL file over a URIprefix and a volume, and runs it for
file://, fors3://and for the testbackend.
storage_localcoversthe facade and the registry: four kinds of bad registration, a duplicate
scheme, path escapes, a name already in use, a missing file.
storage_s3covers the backend against a real service: round trip,9 MB through multipart,
ListObjectsV2paging,HeadObject,DeleteObject, deleting twice, a missing key, a missing bucket, a wrongsecret (and that the message does not contain it), path style with an
endpoint override, and a black-holed endpoint inside 30 s.
storage_conformanceadds the type round trip, field-id projection, arow-group range, listing, a rejected overwrite, a write that fails
halfway leaving nothing behind, a prefix nothing was written under and
one whose objects were just deleted (both not-found, which is the point:
object storage and a filesystem disagree and the facade decides), and
volume resolution: no
USAGE, aPUBLICmapping, a URI outside itsvolume, a volume that does not exist, and a rejected
base_paththatmust not echo its own password.
make installcheck-- every category, on a three-segment clusterwith the module preloaded, against MinIO and against SeaweedFS, on
Arrow 9.0.0 and Arrow 17.0.0.
make -C src/test installcheck-cbdb-parallel(not run)Beyond the suite:
.ccfile that includesonly the five installed headers and PostgreSQL's server headers compiles to a
.sothat registers a backend; put beforedatalake_fdwinshared_preload_librariesthe cluster starts and the scheme works, andbuilt with a stale
abi_fingerprintthe registration is refused with bothvalues in the message.
Parquet file through
s3://costs about 15 MiB of resident memory more thanthrough
file://-- and that difference stays flat as the object grows(7.9 MiB for a 2 MB file, 14.8 MiB for a 149 MB one), so neither the response
body nor the multipart buffer accumulates.
statement fails; with
AWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEYin thepostmaster's environment it succeeds.
nm -Dlists the PostgreSQL entry points, oneregistration function and the test extension's UDFs; no Arrow and no AWS
symbols, on all three build variants.
Impact
Dependencies: an optional build dependency on the AWS SDK for C++. Without
it the extension builds as before and says so, and opening an
s3://locationreports that it was built without it; naming a prefix that has no SDK in it is
an error rather than a silent fallback. The module is off by default and not in
the RPM, so packaging is unchanged.
CI builds the SDK from source once per distribution and architecture and caches
it, starts SeaweedFS for the s3 cases, and now takes Arrow from the Arrow
project's own repository on Rocky 9 and 10 as well, pinned -- 17.0.0 and
21.0.0, so a version change in EPEL cannot arrive without a commit.
What that costs the
ic-datalake-fdwjob, measured on all three legs: buildingthe SDK takes 175-228 s on a cache miss and the cache restores in 1 s (it is
4 MB); SeaweedFS is up 6 s after the step starts; the s3 cases add about 35 s,
of which 22 s is one deliberate connect timeout.
User-facing changes: a volume's
base_pathnow acceptsfile://as wellas
s3://, and the scheme it rejects is named against what is registeredrather than against a fixed list. Three
iceberg_am_rejectDETAIL lines andtwo ERROR lines change wording, the latter because they used to quote back a
URI that can carry a password.
Checklist
contrib/datalake_fdw/README.mddocuments volumes, where credentials comefrom, building with S3 support, and writing a backend -- the example in it is
compiled against the installed headers rather than written out by hand. The
test extension's new functions name a path on the server's file system and are
revoked from
PUBLIClike the two that were already there.Additional Context
Known limits, deliberately rather than by oversight:
file://volume must be the same directory on every host. Nothingchecks it; the README says so.
take it. Iceberg's file names are unique by construction, so it does not
arise there.
bucket lifecycle rule that expires incomplete multipart uploads is the usual
answer; the README says so.
gp_vmem_protect_limit--the SDK client, its connection and one part buffer are allocated by the SDK
rather than through the tracked pool.
possible one.
it returns as a backend when someone needs it.