Skip to content

[feat](catalog) support ADBC catalog that reads external sources over Arrow - #66331

Open
morningman wants to merge 42 commits into
apache:masterfrom
morningman:wt-adbc-catalog
Open

[feat](catalog) support ADBC catalog that reads external sources over Arrow#66331
morningman wants to merge 42 commits into
apache:masterfrom
morningman:wt-adbc-catalog

Conversation

@morningman

@morningman morningman commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What problem does this PR solve?

Related Issue: #65615

Problem Summary:

Doris reads an external database through JDBC today: the source's rows become JDBC
objects and then Doris columns, one value at a time, across JNI. The source is also
read by a single backend, because JDBC has no notion of how a result is partitioned.

This PR adds a new catalog type, adbc, that reads an external source through an
Arrow Database Connectivity driver. Data arrives as
Arrow record batches and is handed to BE's scanner without a per-value conversion, and
a scan is split into the driver's own result partitions so that N backends read N
partitions in parallel. Neither is reachable through JDBC at all.

CREATE CATALOG remote_source PROPERTIES (
    "type" = "adbc",
    "driver_url" = "libadbc_driver_flightsql.so",
    "uri" = "grpc://remote-doris:8070",
    "user" = "root",
    "password" = ""
);

SELECT count(*) FROM remote_source.some_db.some_table;

Phase one targets Arrow Flight SQL sources, which makes this the intended replacement
for the remote_doris catalog. Other sources need only their driver's .so and, where
their SQL differs from ANSI, a dialect implementation -- no architectural change.

Supported features

What an ADBC catalog gives you from the outside -- what you can do with it and what it does
for you without being asked. The boundaries are in Known limits further down.

Capability What it means when you use it Control (default)
ADBC catalog CREATE CATALOG ... PROPERTIES("type" = "adbc", ...), then query the source's tables like any other external table. Phase one targets Arrow Flight SQL sources, including another Doris cluster. uri, user, password
Arrow-native transfer Rows arrive as Arrow batches and go straight into the scanner, with no per-value conversion on the way in -- which is where a JDBC catalog spends much of a wide or high-volume scan. Always on
Parallel read across backends One scan is split into the source's own result partitions and read by several BEs at once, instead of a single BE on a single connection. Falls back to one statement when the source cannot partition. partitioned_read = auto / disabled / required (auto), max_partitions (1024)
Column pruning Only the columns the query actually needs are requested from the source, and EXPLAIN prints that same statement. Always on
Predicate pushdown =, !=, <, <=, >, >=, IS [NOT] NULL, [NOT] IN and AND / OR / NOT over them become the remote WHERE. Anything else -- functions, arithmetic, LIKE, BETWEEN -- stays in Doris. Doris re-applies every predicate regardless, so pushdown changes speed only, never the rows you get. Always on
LIMIT pushdown Pushed once the whole WHERE was pushed, so the source never truncates ahead of a filter Doris still has to apply. Always on
COUNT(*) without column values A count reads no column data from the source at all. Always on
Metadata browsing SHOW DATABASES, SHOW TABLES, DESC, SHOW CREATE TABLE and information_schema over the source's own databases, tables and columns. --
Automatic type mapping Source columns arrive as Doris types, including ARRAY / MAP / STRUCT, DECIMAL, date, and timestamp with and without a zone. --
Metadata cache and REFRESH Name resolution and table schemas are remembered per catalog, so a query stops paying several remote round trips before it is even planned. Listings are still read live, so a table created on the source is visible with no refresh at all, and REFRESH CATALOG / DATABASE / TABLE drops what is remembered. meta.cache.adbc.metadata.enable / .ttl-second (600) / .capacity (1000)
The usual query surface Joins against internal tables and against other catalogs, aggregation, ORDER BY, UNION, subqueries, SELECT ... INTO OUTFILE, and an MTMV built on an ADBC table. --
Source SQL you can steer The generated SQL is conservative ANSI. The connector asks the driver which vendor it is talking to, and you can override that when the answer is unhelpful. sql_dialect (auto-detected, ansi fallback; doris provided)
Driver options passed through Anything the driver itself understands can be set on the catalog, e.g. "adbc.adbc.snowflake.sql.db" = "...". adbc.* properties
Driver placement and pinning You place the driver library (Doris ships none); a bare file name resolves under the drivers directory, and a wrong or stale build is reported at CREATE CATALOG rather than as a puzzling query failure later. driver_url, driver_checksum, driver_entrypoint

Design decisions worth knowing before reviewing

  • FE and BE load the same driver .so. FE goes through the ADBC JNI bridge, which
    wraps the same C driver manager BE links statically, and BE dlopens the identical
    file. This is a measured constraint, not a preference: partition descriptors are
    driver-private bytes, and two official implementations of the same protocol already
    serialize incompatible messages and mis-parse each other silently rather than
    erroring.
  • Doris ships no ADBC driver. The library is placed by the operator, under
    adbc_drivers_dir on FE and be/plugins/adbc_drivers on every BE. driver_url
    therefore accepts local references only (bare name, file://, or an absolute path);
    remote schemes are rejected because a per-node download cannot promise the nodes
    agree. "Driver file not found" is written as a first-class error for that reason, and
    driver_checksum can pin the build.
  • The SQL sent to a source is conservative ANSI by default, generated through a
    dialect interface a source can claim by vendor name or by the sql_dialect property.
    Predicates are pushed all-or-nothing per conjunct from a whitelist; BE re-applies
    every predicate regardless, so pushdown is pure acceleration.
  • Planning has side effects on a Flight SQL source: asking the driver to partition a
    statement executes it. That makes this the first connector for which EXPLAIN had to
    be told not to plan for real -- see the SPI addition below.

Changes outside the connector

Change Why
ConnectorScanRequest.isExplainOnly() (SPI) + PluginDrivenScanNode fills it EXPLAIN reaches planScan for real (its explain level is NORMAL, so NereidsPlanner.distribute() does not return early). For this connector that would execute the very query it was asked only to describe.
PluginDrivenScanNode re-asks for the display statement with the current columns The connector properties are cached in init(), before Nereids prunes the scan tuple, so EXPLAIN's QUERY: line named more columns than the statement that actually runs. Affects any connector that renders remote SQL from the column list (adbc, jdbc). What goes to BE is unchanged.
PluginDrivenScanNode.mapFileFormatType() learns "arrow" Routes an ADBC range to BE's Arrow reader.
TTableFormatFileDesc.adbc_params The scan range's parameters; BE's ADBC reader does not read the JDBC field.
be/src/vec/exec/format/adbc/* + file_scanner_v2 gate The BE reader.
FlightSqlSchemaHelper (FE, Arrow Flight SQL server) GetTables described a DATEV2 column as date64 while BE writes date32, and described array/map/struct with placeholder children. A client that types its columns from that schema fails on the first batch. Visible to every Arrow Flight SQL client, not only this connector.
DataTypeTimeStampTzSerDe::read_column_from_arrow (BE) It never overrode the numeric serde's fixed-width path, which memcpy'd Arrow's int64 epochs into a column that stores packed date/time values -- same width, so every row was silently wrong.
thirdparty: arrow-adbc The C driver manager is linked into doris_be; the JNI bridge is built for FE (upstream's prebuilt one needs GLIBC 2.34). The SQLite and Flight SQL drivers are fetched for tests only and are not shipped.
Config.adbc_drivers_dir / adbc_driver_secure_path, conf/fe.conf JVM options, build.sh Driver placement and the FE-side plugin build/deploy wiring.

Behaviour to be aware of

  • A view on the source is not listed as a table. A Doris source ignores the base-table
    filter ADBC sends, so the filter is applied where the answer is read.
  • partitioned_read is auto by default: split the scan when the driver can, read it
    as one statement when it cannot. required forbids that downgrade, which is what
    keeps a test from going green while quietly exercising the fallback; disabled is the
    escape hatch.
  • Metadata is cached per catalog for 10 minutes by default
    (meta.cache.adbc.metadata.*), and every REFRESH statement drops it. A newly
    created remote table is reachable without any refresh.
  • Read only: INSERT, CREATE/DROP TABLE and the other write statements against an
    ADBC catalog are rejected. An MTMV over an ADBC table does build and refresh.

Test coverage

  • fe-connector-adbc: 186 unit tests, no skips. About 25 of them drive the real SQLite
    ADBC driver through the same Java -> JNI -> C driver manager -> driver .so path FE
    takes in production; fe-connector-api covers the new SPI field.
  • BE: unit tests for the ADBC reader, the driver registry, the Arrow variant normalizer,
    the scanner gate and the TIMESTAMPTZ serde.
  • End-to-end (regression-test/suites/external_table_p0/adbc/): 20 suites against a live
    catalog -- type mapping and semantics, complex and binary types, scan edges, predicate
    pushdown, column pruning, query shapes, source table models, metadata operations,
    nested catalogs, cross-source joins, large data, outfile, MTMV, negative cases, and
    partitioned vs. single-statement reads compared row for row. Each suite returns early
    when the driver is absent, so a cluster without one does not fail.

Known limits

  • Read only. No writes, DDL, statistics or aggregate pushdown.
  • Partitions are verified to be read completely and without duplication, but spreading
    them over several backends has only been reasoned from the code
    , not observed -- the
    test environment has one backend, and test_adbc_multi_backend returns early there.
  • IPV4 from a Doris source arrives as a bare INT (both sides encode it as int32), and
    a source DATETIME arrives as TIMESTAMPTZ. Both are asserted, not worked around.
  • The FE and BE copies of the driver are not checked against each other;
    driver_checksum verifies FE's copy only.

Release note

Support ADBC catalog, which reads an external source through an Arrow Database
Connectivity driver: data is transferred as Arrow record batches and a scan is split
across backends by the driver's own result partitions. Phase one targets Arrow Flight
SQL sources. The driver library is supplied by the operator; Doris ships none.

Also fixes two defects on the Arrow Flight SQL path that are visible to existing
clients: GetTables reported the wrong Arrow type for DATEV2 and placeholder children
for ARRAY/MAP/STRUCT, and a TIMESTAMPTZ column read from Arrow was decoded by
copying its bits instead of converting the epoch.

Check List (For Author)

  • Test

    • Regression test
    • Unit Test
    • Manual test (add detailed scripts or steps below)
    • No need to test or manual test. Explain why:
      • This is a refactor/code format and no logic has been changed.
      • Previous test can cover this change.
      • No code files have been changed.
      • Other reason
  • Behavior changed:

    • No.
    • Yes.
      • New catalog type adbc; no existing catalog type changes.
      • Arrow Flight SQL clients now see the Arrow types the batches actually carry for
        DATEV2 and for nested types (previously a client that trusted GetTables
        failed on the first batch).
      • EXPLAIN on a plugin-driven catalog prints the projection the scan really asks
        for; the statement sent to BE is unchanged.
  • Does this need documentation?

    • No.
    • Yes.

Check List (For Reviewer who merge this PR)

  • Confirm the release note
  • Confirm test cases
  • Confirm document
  • Add branch pick label

@hello-stephen

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Apache Doris.
Don't know what should be done next? See How to process your PR.

Please clearly describe your PR:

  1. What problem was fixed (it's best to include specific error reporting information). How it was fixed.
  2. Which behaviors were modified. What was the previous behavior, what is it now, why was it modified, and what possible impacts might there be.
  3. What features were added. Why was this function added?
  4. Which code was refactored and why was this part of the code refactored?
  5. Which functions were optimized and what is the difference before and after the optimization?

@morningman morningman changed the title [feat](catalog) Support ADBC Catalog [feat](catalog) support ADBC catalog that reads external sources over Arrow Aug 1, 2026
@morningman

Copy link
Copy Markdown
Contributor Author

run buildall

@hello-stephen

Copy link
Copy Markdown
Contributor

Cloud UT Coverage Report

Increment line coverage 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 77.57% (1912/2465)
Line Coverage 64.49% (34197/53024)
Region Coverage 64.37% (17257/26809)
Branch Coverage 53.91% (9244/17148)

@hello-stephen

Copy link
Copy Markdown
Contributor

FE UT Coverage Report

Increment line coverage 13.79% (4/29) 🎉
Increment coverage report
Complete coverage report

@hello-stephen

Copy link
Copy Markdown
Contributor

BE UT Coverage Report

Increment line coverage 80.16% (501/625) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 59.23% (25691/43373)
Line Coverage 43.33% (258032/595471)
Region Coverage 39.02% (204632/524487)
Branch Coverage 40.35% (93367/231391)

@hello-stephen

Copy link
Copy Markdown
Contributor

BE Regression && UT Coverage Report

Increment line coverage 80.32% (502/625) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 75.80% (32068/42304)
Line Coverage 60.49% (357770/591495)
Region Coverage 57.14% (300720/526254)
Branch Coverage 58.48% (135336/231429)

@hello-stephen

Copy link
Copy Markdown
Contributor

FE Regression Coverage Report

Increment line coverage 34.48% (10/29) 🎉
Increment coverage report
Complete coverage report

morningman added a commit that referenced this pull request Aug 2, 2026
### What problem does this PR solve?

Related Issue: #65615

Related PR: #66331

Problem Summary:

Split out of #66331, which adds an `adbc` catalog type that reads an
external source
through an [Arrow Database Connectivity](https://arrow.apache.org/adbc/)
driver. This PR
carries only that PR's `thirdparty/` half, so the dependency can be
reviewed and the
build-env image rebuilt before the code that links against it lands.

Nothing in the tree consumes these artifacts yet -- this PR adds one
package to the
thirdparty build and declares its license, and changes nothing else.

**What comes out of it**

| Artifact | How | Used by |
|---|---|---|
| `libadbc_driver_manager.a` | built from source | to be statically
linked into `doris_be` (#66331) |
| `libadbc_driver_jni.so` | built from source | to be loaded by the FE
ADBC connector (#66331) |
| `libadbc_driver_sqlite.so` | built from source | tests only, **not
shipped** |
| `libadbc_driver_flightsql.so` | prebuilt, from the official release
wheel | tests only, **not shipped** |

Doris ships no ADBC driver to users; a deployment supplies its own. The
two drivers above
exist so the ADBC code paths can be tested at all.

**Three things upstream does that do not carry over**

- *The SQLite driver needs a system SQLite3 development package*, which
Doris does not
ship and most build hosts lack. The source tree vendors the amalgamation
but never
references it from CMake, so it is compiled here into a scratch static
library, handed
to `FindSQLite3`, and dropped afterwards. It ends up statically inside
the driver,
  leaving no sqlite artifacts in thirdparty.

- *The JNI bridge header is generated by shelling out to Maven*
(`java/driver/jni/CMakeLists.txt` runs `mvn -Pjni,javah compile`). Doing
that would make
this the first thirdparty package to require Maven, a Maven Central
connection and a
JDK 11+, while the build-env image runs this script with `JAVA_HOME` on
JDK 8. The
`javah` output is checked in as a patch instead and `jni_wrapper.cc` is
compiled against
it directly, needing nothing but `jni.h`. The patch header records how
to regenerate it
  on a version bump.

The prebuilt JNI binary inside upstream's Maven jar is not used either:
it requires
`GLIBC_2.34` and `GLIBCXX_3.4.31`, which excludes CentOS 7/8, Rocky 8
and Ubuntu 20.04.

- *The Flight SQL driver is written in Go and no bare shared library is
published*, so it
is taken from the official release wheel -- a zip the existing download
step already
knows how to unpack -- rather than adding a Go toolchain to the
thirdparty build. It is
skipped on platforms upstream publishes no prebuilt binary for, the same
way hyperscan
  is.

**On the version pin**

The source tree is tag `apache-arrow-adbc-24`, which is release C/Go
1.12.0 (the tag
carries neither number). The prebuilt Flight SQL driver is pinned to
that same release,
and that is not cosmetic: ADBC partition descriptors are driver-private
bytes, so every
process that hands one to another must have loaded the very same driver
build.

`dist/LICENSE-dist.txt` gets the corresponding Apache-2.0 entry. It is
the only file
outside `thirdparty/` here.
morningman and others added 19 commits August 2, 2026 23:49
Nothing references the ADBC symbols yet, so the archive contributes no objects
to the binary until AdbcDriverRegistry lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Doris serdes only accept the Arrow variants Doris itself emits: the string
serde takes STRING, BINARY and FIXED_SIZE_BINARY and nothing else. Third-party
ADBC drivers emit others -- DuckDB emits string_view, Go-based drivers may emit
large_* and dictionary. Convert those before materialization, and fail with the
offending type named when no Doris column can hold it, because silently wrong
data is far worse than a loud error.

Normalization loops rather than converting once: decoding dictionary<int32,
large_utf8> leaves large_utf8 behind, which still needs converting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The driver manager dlopens on every AdbcDatabaseInit and keeps no cache of its
own, so without this every scan range would reload the driver. Load each
resolved path at most once and never dlclose: drivers carry global state and
background threads -- Go runtimes especially -- so unloading one is a
use-after-free hazard.

Failed loads are cached too, so a bad path does not retry the dlopen once per
scan range, and the failure message carries the path the user configured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follows the string-map shape jdbc_params and es_params already established. The
partition descriptor is opaque binary, so it travels base64-encoded rather than
as a typed field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mirrors remote_doris_reader: the Arrow-stream-to-Block materialization is
identical, only the stream source changes from Arrow Flight to ADBC. Each
column is normalized before materialization, since third-party drivers emit
Arrow variants the serdes reject.

The reader drives the driver's own function table rather than the driver
manager's free functions. Those re-dlopen on every AdbcDatabaseInit, which
would defeat AdbcDriverRegistry.

Databases are not pooled across scan ranges. That is a throughput optimization
which only pays off once multiple partitions run concurrently, and adding an
untestable caching layer now would only obscure the functional path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ADBC only has a v2 reader, and enable_file_scanner_v2 is a fuzzy=true session
variable the regression harness flips at random, so honoring it would make ADBC
queries fail on a coin flip. Force adbc onto v2, mirroring the existing
transactional_hive force-to-v1. Loads are excluded: there is no ADBC load path,
so widening the rule to cover them would only route them somewhere they still
cannot run.

is_supported also has to accept adbc under FORMAT_ARROW, otherwise the scanner
would refuse the very ranges the operator forces onto it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First cut of the FE side: a new connector module registered in the reactor, its
plugin-zip assembly, the ServiceLoader entry and AdbcConnectorProvider claiming
catalog type "adbc". Metadata is not wired yet -- getMetadata fails loud until
the following commits land it.

The provider carries one non-obvious obligation, pinned by
AdbcConnectorProviderIsolationTest: neither its static initializer nor its
no-arg constructor may load org.apache.arrow.adbc.*. DirectoryPluginRuntimeManager
instantiates every discovered factory before it rejects a duplicate type name, so
a connector present both as a classpath built-in and as a directory plugin gets
constructed once in a second classloader and then discarded. Reaching an ADBC
class there would run a second System.load of the JNI shim from that second
loader and throw UnsatisfiedLinkError, losing the copy that had loaded fine.
Verified by mutation: a static reference to AdbcDriver.PARAM_URI makes the test
fail and names the class.

Dependency versions go in fe/pom.xml. Three of the four are new to FE:
adbc-driver-manager comes with adbc-driver-jni (JniDriverFactory implements its
SPI) and arrow-c-data had no FE consumer before. adbc 0.24.0 pins Arrow Java
19.0.0, matching arrow.version, so no second Arrow enters the build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four pieces the connector cannot work without, none of which the module's own
pom can supply.

build.sh: the connector deployment loop is a hardcoded module list, so adbc has
to be added there by hand. Missing it is a nasty failure -- the module still
compiles and the zip is still produced, it just never reaches
output/fe/plugins/connector/, and the only symptom is CREATE CATALOG reporting
an unknown catalog type. It also creates plugins/adbc_drivers on both FE and BE
(the drop point for the driver a user supplies) and copies the thirdparty-built
JNI shim into fe/lib. The shim inside the adbc-driver-jni jar is deliberately
not used: it needs GLIBC 2.34 / GLIBCXX 3.4.31, which the supported build hosts
do not have, while the thirdparty build needs only GLIBC 2.7.

conf/fe.conf: arrow.adbc.driver.jni.library.path is set to ${DORIS_HOME}/lib.
Note this is a DIRECTORY, not a file -- adbc-driver-jni's resolver appends
System.mapLibraryName("adbc_driver_jni") to it. Verified that ${DORIS_HOME} is
already exported when start_fe.sh reads fe.conf, so the value expands (LOG_DIR
in the same file is the existing precedent).

Config.java + DefaultConnectorContext: adbc_drivers_dir and
adbc_driver_secure_path, threaded to the plugin through the connector
environment because a plugin cannot read FE Config. The JDBC equivalents are not
reusable here -- ConnectorValidationContext#validateAndResolveDriverPath
resolves against jdbc_drivers_dir and enforces a .jar grammar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
driver_url keeps the name the JDBC catalog uses, but accepts local references
only: a bare file name resolved under adbc_drivers_dir, a file:// URL, or an
absolute path. Remote schemes are refused with a message that says why, because
"unsupported" alone would read as an arbitrary limitation. The reason is that
FE and every BE must load the identical driver library -- ADBC partition
descriptors are driver-private opaque bytes, and two official implementations of
the same protocol already serialize incompatible protobuf messages that parse
into each other silently instead of erroring. A URL each node fetches for itself
cannot promise sameness, and the resulting failure surfaces as an unreadable
partition, far from its cause.

The rest of the resolver is the JDBC security rule adapted to shared libraries:
no traversal (checked on the decoded path, so %2e%2e cannot survive), a bare
name may contain no separator, file:// may carry no authority/query/fragment,
and adbc_driver_secure_path is matched by path component so that /opt/drv-evil
is not admitted by an allowance for /opt/drv.

The not-found message is deliberately long. Doris ships no ADBC drivers, so a
missing file is the most likely first experience of this catalog type, and a
bare "dlopen failed" would leave the user with nothing to act on. It names the
exact path, says every BE needs the same file, and gives both places to obtain
one.

adbc.*-prefixed properties pass through with their names intact: ADBC option
names already begin with "adbc.", so stripping the prefix would hand the driver
a name it does not know and the option would be ignored in silence. BE applies
the same rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AdbcClient owns one catalog's Arrow allocator and AdbcDatabase and lends out
short-lived connections. It goes through adbc-driver-jni rather than a pure-Java
driver so FE and BE run the same C driver manager over the same driver library;
the pure-Java Flight SQL driver would be lighter here but does not implement
getTableSchema at all, which would leave FE deriving column types from XDBC type
codes while BE reads real Arrow types.

Nothing opens in the constructor. An FE follower replaying the edit log builds
every catalog and its filesystem layout need not match the leader's, so touching
the driver file during construction would let one missing library stop FE from
starting rather than fail the single catalog that cannot work.

Errors carry the ADBC status, SQLSTATE and vendor code, and the driver's own
message is appended only when it says something: the SQLite driver answers
NOT_IMPLEMENTED with the literal text "(unknown error)", so forwarding it as the
error would name neither the operation nor the cause.

Tests run against the real JNI bridge and the real SQLite driver built by
thirdparty -- that native path is the entire reason for choosing the JNI driver,
so stubbing it would leave the risky part unverified. They skip loudly when the
libraries are absent, stating that the native path was NOT exercised, so a
skipped run cannot be misread as a pass. Two facts were confirmed empirically
while writing them: driver_entrypoint does reach the driver manager (a bogus
value fails with "dlsym(...) undefined symbol"), and the JNI library property
names a directory rather than a file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three pieces sit between what a driver reports and what Doris shows.

AdbcNamespace projects ADBC's three naming levels onto Doris's two. The uri
property is required to pin the remote catalog, which keeps this a projection
rather than a join: at most one level varies, so the Doris database name never
has to be built by concatenation. That matters because a catalog name may
legally contain a dot, so a concatenated name could not be split back
unambiguously -- which is why the remote parts are carried, never re-derived.
Empty string and null are the same absent level: SQLite reports its missing
schema level as "", other drivers as null, and treating them differently would
name the same source's database differently per driver build.

AdbcObjectsReader walks the nested getObjects result. It stops at the table
layer on purpose: the column layer carries XDBC integer type codes rather than
Arrow types, so deriving Doris column types from it would reintroduce the
two-step translation ADBC exists to remove, and would disagree with the real
Arrow arrays BE reads. It also filters rows to the requested namespace, because
getObjects filters are advisory and a driver may answer with everything it has.

AdbcTypeMapper converts Arrow types. Unsigned integers widen one step (Doris has
none, and same-width would wrap the upper half of the range with no error);
nanosecond timestamps truncate to Doris's 6 digits rather than making a table
unreadable; struct field names are lowercased at every level because BE indexes
struct children by lowercase key and a mixed-case child crashes it. Anything
with no Doris equivalent is rejected here, naming the column and the Arrow type
-- a wide table gives a user nothing to act on otherwise.

The getObjects tests run against the real SQLite driver, so the nested parsing
is checked against output a driver actually produces. That required opening
java.base/java.nio to Arrow in surefire: without it any test that materializes
Arrow data fails on DirectByteBuffer access. Same flag conf/fe.conf and
fe-core's surefire config already carry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SHOW DATABASES / SHOW TABLES / DESC now work against an ADBC source.

AdbcTableHandle keeps the remote catalog, schema and table separately. The Doris
name it also carries is for display and lookup only and is never parsed back:
either remote level may itself contain a dot, so a joined name has several
readings and the wrong one addresses a table that does not exist.

The schema fallback exists because neither way of asking is guaranteed. The ADBC
API's getTableSchema has a default that throws NOT_IMPLEMENTED and drivers do
leave it there (the Java Flight SQL driver); executeSchema is no safer in the
other direction, since the SQLite driver rejects it while implementing
getTableSchema fine. So each backs the other, and which one works is remembered
per catalog rather than re-probed per table.

Only NOT_IMPLEMENTED triggers that fallback. Writing the test for it is what
exposed why: any other status means the driver did try and the table is at fault
-- a missing table answers NOT_FOUND -- so falling back on every error turned a
plain "no such table" into "this driver implements neither method", pointing the
user at their driver instead of their table name. There is a test for exactly
that shape now.

getObjects' column layer is deliberately not a third fallback: it carries XDBC
integer type codes rather than Arrow types, so it would answer in a different
type system than the one BE reads the data in.

buildTableDescriptor emits the Hive descriptor, as the other connectors reading
through the generic file-scan path do. The SPI default (null) is not neutral
here: fe-core would fall back to SCHEMA_TABLE and BE would build a
SchemaTableDescriptor instead of the one the scan path expects. To be re-checked
against BE when the scan path lands.

Tests run the whole surface against the real SQLite driver through the JNI
bridge. One fixture detail is load-bearing and documented in place: SQLite is
dynamically typed and its ADBC driver derives the Arrow schema from the values
present, not the declared column types -- on an empty table it reports every
column as int64, so the fixture inserts a row. Found by the assertion failing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The plugin-driven scan node translates a connector's file-format name into
the thrift enum BE selects a reader with, and had no case for "arrow": a
connector that hands BE Arrow record batches rather than a file fell to the
FORMAT_JNI default.

That default is a working reader, not an error, so the mismatch produces no
failure at this layer at all -- the scan simply lands in the JNI scanner,
which has no branch for an Arrow table format, and fails there against a
message about the wrong reader.

The mapping stays connector-agnostic: it is a format name, and the switch
carries no knowledge of which connector produced it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An ADBC source is any driver implementing the C ABI, so the set of SQL
spellings this connector must produce cannot be closed the way the JDBC
connector closes it over an enum of drivers. The dialect is therefore an
interface with a registry: adding a source costs a class and a
registration, and the query builder must never grow a branch for one. A
test drives the builder with a dialect defined inside the test to keep
that true.

Only the ANSI dialect ships. The others the design names wait for a source
to verify them against; an unverified dialect is a set of guesses about
someone else's SQL, and a wrong guess is either a syntax error at scan time
or a predicate that quietly selects different rows.

The builder is conservative for the same reason. Comparisons, null tests,
IN and the boolean connectives are pushed; functions, LIKE, BETWEEN and
null-safe equality are not, because each would assert that an unidentified
source evaluates it as Doris does. A predicate is translated whole or left
behind whole -- half of a predicate is a different predicate, not a weaker
one -- and BE re-applies everything anyway, which is what makes pushing
purely a speed-up.

Two choices decide rows rather than speed:

- The projection is always explicit. BE matches returned columns to query
  slots by name and rejects one it did not ask for, so SELECT * fails the
  scan outright rather than merely over-reading. An empty projection is
  COUNT(*) pushed down and selects a constant, so a row count does not drag
  the table width across the wire.

- A row limit is emitted only when every predicate went with it. BE filters
  whatever comes back, so a source that truncated to n rows before applying
  the predicates Doris kept would answer with fewer than n.

The schema fallback in the metadata path now names its table through the
dialect too. Two spellings of one table name is how a source ends up
working for queries and failing for DESC.

Verified against the real SQLite driver: the generated statements are run
through it, so the quoting, the literal spellings and the placement of
LIMIT are checked for acceptance by a source rather than only for text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One range, one statement. The design splits a query into the driver's own
partition descriptors and hands one to each backend, but BE has no reader
for those yet, so planning several would hand out work nothing can execute.
The shape here is the one the JDBC connector has always had, and the
partitioned path replaces the body of planScan without disturbing anything
around it.

The parameter names on the range are a contract with C++: BE looks them up
literally, nothing checks the two sides agree at build time, and a rename
on one side surfaces as a scan failing at run time about a missing
parameter. Two are easy to get wrong and are asserted by name -- the
credentials travel as ADBC's own username/password rather than this
connector's user property, and a driver option keeps its "adbc." prefix,
which is part of the option name rather than a namespace this connector
added.

populateRangeParams has to be overridden rather than inherited: the
inherited one writes jdbc_params, which BE's ADBC reader never reads, so
the scan would arrive with no driver path at all.

The driver path is sent as FE resolved it. BE loads it verbatim -- it has
no drivers directory of its own to resolve a bare name against -- so FE and
every BE need the same file at the same path, which is what the deployment
already asks for and how a JDBC catalog's driver reference already behaves.

EXPLAIN never calls planScan, so it regenerates the statement; a test pins
that the two agree, because a divergence would have EXPLAIN describe a
query that is not the one run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A pushed-down COUNT(*) leaves the scan with no slots at all, so every
column the source returns is unrequested by definition and the reader's
unknown-column check rejected the first one. A query asking for nothing but
a number therefore failed, and FE cannot avoid it: no SQL statement returns
zero columns.

Only the empty projection is special-cased. An unrequested column arriving
alongside requested ones still fails, because that state means FE and this
reader disagree about the projection and this check is the only signal the
disagreement exists. Tolerating unknown columns generally would have fixed
the count case by removing that signal.

NOT COMPILED OR RUN: this machine cannot build BE. The two tests added here
have never executed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Covers the scan path end to end against this cluster's own Arrow Flight SQL
endpoint: catalog creation, SHOW/DESC, a full read, a projection, each
predicate shape in the pushable set, one outside it, a limit beside an
unpushable predicate, and COUNT(*).

NEVER RUN. It was written on a machine that cannot build BE, so nothing in
it has executed even once. Every expectation is a claim to check on the
first real run, not a passing baseline, and the suite says so at the top.
It also carries no .out: one written without running would be a guess
presented as a verified result, and a guess that happened to be right would
be indistinguishable from one that was checked.

It skips loudly, and says the ADBC path went untested, unless an ADBC
driver is configured -- Doris ships none, and FE and every BE must load the
same file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Flight SQL suite beside this one needs a driver Doris does not ship and
that is not present on any developer machine by default, so nothing has yet
exercised the scan path outside FE unit tests. SQLite is not the source
this connector targets, but it travels the identical path -- FE through the
JNI bridge to the C driver manager to a driver .so, BE through the same
driver manager to the same .so -- and thirdparty already builds it, so this
runs without downloading anything.

The properties that decide rows are asserted directly rather than through
qt_: with no baseline, a -genOut run records whatever came back, so a wrong
result would be blessed rather than caught. qt_ is left only for DESC,
where the point is to notice future drift rather than to state today's
answer.

Three fixture details are load-bearing. Every column carries at least one
non-null value, because the SQLite ADBC driver derives the Arrow schema
from the values present rather than the declared types and an all-null
column comes back as int64. One row holds a quote, without which the
escaping assertion answers zero whether the literal was escaped correctly
or never pushed at all. And the LIKE-plus-LIMIT query has exactly one match
so that a limit wrongly pushed past a predicate Doris kept shows up as a
missing row.

Nothing asserts the limit through EXPLAIN, deliberately: EXPLAIN
regenerates the statement with no limit at all, as the JDBC connector's
does, so such an assertion would hold or fail for a reason unrelated to
what it claims to test.

NEVER RUN, like the suite beside it, and it carries no .out for the same
reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…laims

Found by the first real end-to-end run: `SELECT id FROM t1 WHERE name IS
NULL` failed with "Unsupported arrow type for string column: 9" (9 =
arrow::Type::INT64) against a TEXT column.

The projection is id, but BE re-evaluates the predicate -- which is the
premise this connector's pushdown rests on -- so name is a query slot too
and FE selects it. The one surviving row has name NULL, so the column comes
back with no non-null value in it, and a source that infers Arrow types
from the VALUES it returns has nothing left to infer from. Measured on the
SQLite driver: the same column is utf8 for `SELECT id, name FROM t1` and
int64 for the same query plus `WHERE name IS NULL`.

FE cannot prevent this. It cannot know which rows a filter will leave, and
the column type it published came from the source's own schema call.

N nulls are what such an array means whatever type it claims, so they are
materialized directly. The branch is narrow on purpose: a column with even
one non-null value keeps its real type and a genuine mismatch still fails
loudly, because that is the signal that FE and the source disagree about
the schema rather than about one result set. It is also restricted to a
nullable target -- substituting defaults into a NOT NULL column would turn
a source that wrongly sent nulls into silently wrong data.

The regression suite that caught this now says why that query is
load-bearing, and carries the DESC baseline the run generated.

BE NOT COMPILED: this machine cannot build BE, so neither the fix nor the
two tests added with it have been compiled or executed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
morningman and others added 23 commits August 2, 2026 23:49
The scan path's headline feature is reading one query as several partitions
across BEs, and the only driver that implements executePartitioned is the
Flight SQL one. It could not be exercised at all: upstream writes that driver
in Go and publishes no bare shared library, so nothing on a build host has it.

Take it from the official release wheel, which is a zip the existing download
step already knows how to unpack, and install it next to the SQLite driver.
Same release as the arrow-adbc source tree built here, which is not cosmetic:
partition descriptors are driver-private bytes, so FE and BE must load the
very same file. Like the SQLite driver it is a test artifact and is not
shipped, and like hyperscan it is skipped on platforms with no prebuilt
binary.

The flight sql suite now finds it there instead of demanding a hand-placed
path. That suite still has never been run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A scan against another Doris over Flight SQL killed the BE outright:

  ../src/arrow/c/helpers.h:164:: ArrowArrayStreamRelease did not cleanup
  release callback

The Arrow C data interface requires a release callback to clear itself, and
Arrow C++ does not merely complain when one does not -- it calls abort(), so a
single scan takes the process down. The Flight SQL driver's stream does not
clear it. Measured both ways with the same query: through the ADBC driver
manager's wrapper the callback comes back cleared, through the driver's own
entry points it does not. The BE calls those directly, because the driver
registry owns driver lifetime, so it sees the raw stream. The FE never hit
this: its JNI bridge goes through the driver manager.

Wrap the driver's stream in one that honours the contract before Arrow ever
sees it, for every driver rather than this one: this connector loads
third-party drivers, and none of them should be able to abort the BE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first Flight SQL scan against another Doris sent

  SELECT "id", "name", "score" FROM "db"."t1"

and the source answered `no viable alternative at input 'FROM "db"'`. Doris
reads a double-quoted name as a string literal, so ANSI quoting there does not
just look foreign: nothing parses, and the connector cannot read the one kind
of source phase one exists to replace.

Only the ANSI dialect shipped, so the vendor name a Doris source reports --
DorisFE, its Flight SQL server name -- was claimed by nothing and fell back to
ANSI silently. Add a dialect that keeps everything ANSI renders (literals, the
two-part table name, LIMIT, all of which Doris accepts unchanged) and changes
identifier quoting, and let it claim that vendor.

Scoped to Doris alone. Backticks are the whole MySQL family's spelling and
MySQL or StarRocks would very likely work, but neither has been run against,
and a dialect claimed on family resemblance is a guess about someone else's
SQL. They can still name this dialect explicitly.

The end-to-end suite runs for the first time with this. Its baseline comes
from a fully passing run, was checked line by line against the fixture, and
then re-run without -genOut so the values are compared rather than recorded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A scan planned one range holding one statement, so one backend did all the
reading no matter how much the source could have handed out in parallel.

Ask the driver to split the statement instead: each partition it reports
becomes its own range, and Doris's existing assignment spreads them over the
cluster. A driver that answers NOT_IMPLEMENTED gets the statement path back,
and the answer is remembered per catalog rather than re-probed per query --
whether the method exists is a property of the driver.

Two failures deliberately do not fall back. More partitions than
max_partitions allows, and a driver reporting none at all, both fail the
query: by the time the descriptors exist the source has already executed the
statement, so planning it again as a statement would execute it a second time
while the first result set sits unread. Planning zero ranges would be worse
still -- a silently empty answer to a query that has one.

enable_partitioned_read turns the whole thing off for a source that pays
badly for the planning-time round trip, which on a Flight SQL source IS the
query's execution. That same fact is why EXPLAIN must never take this path,
pinned by a test that plans with a client which refuses to open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EXPLAIN plans a scan for real -- that is where its inputSplitNum comes from.
Its explain level is NORMAL, which is not a plan level, so NereidsPlanner
does not return before splitFragments, and the fragment translator finalizes
every scan node, which calls planScan. test_paimon_predict already relies on
this: it asserts inputSplitNum=9 inside an explain block.

That was harmless while planning only listed files or built a string. It is
not harmless for ADBC, where asking the driver to partition a query executes
that query on the source: EXPLAIN would run the very query it was asked to
describe, and leave the result set sitting unread on the source until it
timed out. A comment in the ADBC connector claimed EXPLAIN never reaches
planScan; it does.

Carry the fact on the scan request instead, read from the parsed statement
the explain command marks before planning. Connectors that only list files
never read it and plan identically. ADBC reads it and plans the statement,
which is what a partitioned scan splits anyway -- so EXPLAIN still describes
the real scan, only with a range count that a query nobody ran cannot have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Flight SQL driver partitions, so every query in this suite now takes the
partitioned path. Add a second catalog with enable_partitioned_read=false and
run the same queries through both, comparing values rather than a baseline --
a baseline passes when both paths break the same way, and the question here is
whether they agree.

This does not prove cross-backend parallelism. The source is a single-backend
cluster, which reports one partition per query, so the partition path is
exercised at N=1 and the comment says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A scan range now carries either the statement to run or one partition of a
statement the source has already run, and the reader takes the matching
branch: a partition is base64-decoded and read straight off the connection
with ConnectionReadPartition, creating no statement at all.

The two parameters are mutually exclusive and a range carrying both or
neither is refused up front. That is not tidiness: reading a partition means
the source has already executed the query, so a range that said both would
let this reader execute it a second time depending on which branch the code
happened to check first.

Both branches still pass their stream through enforce_stream_release_contract
before Arrow sees it -- ReadPartition's stream breaks the release contract
exactly like ExecuteQuery's does, and Arrow aborts the process over it.

Compiles clean, but NOT yet executed: the unit test build was stopped partway
by machine contention, so the three tests added here have never run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The registry hands out AdbcDriver pointers documented to stay valid for the
life of the process, and it never releases a driver: dlclosing one that owns
background threads, as a Go-based driver does, is a use-after-free.

But the registry itself was a function-local static, so static destruction
tore the map down anyway. Every pointer already handed out dangled from that
moment, and the driver manager's per-driver state -- freed only by the release
callback nobody calls -- lost its last reference. LeakSanitizer sees exactly
that and reported it, since its check runs after every static destructor.

Allocate it and never free it, which is what the class already claims to do.

This is what made the first-ever BE unit test run of this connector fail at
exit; it has been there since the registry was added, unseen because the
tests had never run. They do now: 30 pass, no leaks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
build.sh carries the connector list twice: once to pick the maven modules to
build, once to deploy each module's archive as a plugin directory. The adbc
connector was only ever added to the second one.

That is not a no-op. The deploy step takes whatever archive it finds in the
module's target/, so every build shipped the plugin left over from whenever
someone last packaged that module by hand -- and skipped it silently when
there was none. An end-to-end run against a freshly built FE therefore
exercised a connector hours older than the source tree, and passed, because
the property that would have changed its behaviour was simply unknown to the
old code and ignored.

Add adbc to the build list and say in a comment why the two must match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The downgrade line reported only that partitioned execution was unavailable,
never the exception behind it. Which layer refused -- driver, driver manager,
or the JNI bridge -- then has to be established by hand, and the same log line
appears whether the refusal is expected (SQLite has no partitions) or a
misconfiguration worth fixing.

Carry the exception out of the lambda and put its status and message in the
line, matching what the metadata path already logs when it degrades.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A driver without partitioned execution is downgraded to the single-statement
path, which returns exactly the same rows from one backend instead of many.
That invisibility is a problem for anything that depends on the parallelism:
a test written for the partitioned path passes while exercising the fallback,
and the pass is indistinguishable from the real thing. This round shipped
such a test and it took a log-timestamp comparison to tell which path it had
actually taken.

Replace enable_partitioned_read with partitioned_read = auto | disabled |
required. Three states rather than two booleans, because
enable=false + require=true contradicts itself and would need a rule to
reject. Nothing is released yet, so renaming costs nothing.

required fails the query naming the driver's own answer, on the first scan
and on every later one -- the refusal is remembered per catalog, so it must
not quietly succeed once the memo is set. EXPLAIN is exempt: it never asks
for partitions by design, and refusing to describe a query helps nobody.

The Flight SQL suite now pins its catalog to required, so a driver that stops
partitioning fails the run; the SQLite suite pins the other half, since that
driver genuinely refuses and is the only one here that can prove the mode
does what it says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The connector asks getObjects for base tables only, and a Doris source
answers with everything it has: its Flight SQL endpoint recognises the
literal "VIEW" as a type filter and treats every other value, including
the "table" ADBC sends, as no filter at all. So an ADBC catalog over
Doris listed views alongside tables, and DESC and SELECT on one of them
worked -- which is why nothing looked wrong.

The table_type each object comes back with is accurate even there, so
the filter is applied where the answer is read. A source that honours
the request has nothing left to drop.

A type that is neither TABLE nor BASE TABLE is dropped rather than kept.
The forgiving rule is the wrong one here: a leaked view scans fine, so
it never announces itself, while a source that spells its tables some
third way lists nothing at all and is noticed immediately. A missing
type is not an unrecognised one and is still kept.

The unit test drives a real SQLite driver with no type filter at all,
which reproduces through a real driver what Doris does to the filter
the connector does send. The end-to-end suite grows a view for the same
reason: Doris is the source that gets this wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1z6M8Gj9F9pErafGdfdjx
test_adbc_catalog_scan covers the rows a partitioned scan returns. What
it cannot say is how many partitions there were: it reads a three-row,
one-bucket table, and a run where the source stopped splitting scans
entirely would look exactly the same.

This suite reads the count back out of the connector instead of assuming
one. A catalog capped at one partition either answers the query, which
it can only do if there was one, or refuses and names the number it
found. Nothing here hardcodes a number or a theory of how a source
plans: on one backend the count is pinned at 1, and on several it must
be at least one per backend, which is the cross-backend case the feature
exists for. The fixture gives the table eight buckets per backend so
that premise holds.

Reading every partition is then checked against the source table rather
than against a second read of the same catalog, because the right answer
is known here and a fault shared by both read paths cannot hide in an
agreement. count(*) alone would miss a partition read twice that also
lost one, and distinct and sum would miss a duplicate of a whole
partition; together they do not.

A single-backend run says out loud that it did not cover any of that,
because every other assertion passes either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1z6M8Gj9F9pErafGdfdjx
The engine builds a ConnectorMetadata per statement, so every query on
an ADBC catalog opened with three remote round trips before planning had
begun: list the databases, list one database's tables, read one table's
schema. Two of those list every object in the source, so the cost grew
with the source rather than with the query.

The answers now live on the connector, whose lifetime is the catalog's,
in the shared meta-cache framework the other connectors use. Defaults
are on, 1000 entries, and a ten-minute TTL where the framework's own
default is a day: an ADBC source is another live database whose tables
other people's DDL alters at any time, and nothing tells Doris when, so
this bounds how long someone who forgot to REFRESH stays wrong. It is
deliberate, and the test that pins it says so.

REFRESH was a no-op for this connector until now, which was harmless
while nothing was remembered and would have been the worst possible
default once something was. All three hooks are implemented, and
REFRESH TABLE drops its database's table listing along with the table's
schema -- otherwise a table created remotely could never be brought in
by the statement a user would reach for, and their REFRESH would appear
to do nothing.

A cached listing is not allowed to answer "there is no such table".
Resolving a name that the listing lacks re-reads the listing first, so
a table created a second ago is found rather than denied; SHOW TABLES
still serves what was remembered, because a report may lag the source
while a lookup that errors may not. The remote call falls only on the
path that was about to raise an error anyway.

The native tests change the source behind Doris's back and then ask what
Doris sees, which is the only evidence that separates a remembered
answer from a fresh one -- and unlike a call counter, cannot be
satisfied by a cache that stores what it never reads. Both directions
were checked by mutation: dropping the schema entry and dropping the
re-read each fail exactly one test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1z6M8Gj9F9pErafGdfdjx
The engine does not ask the connector whether a table exists. It loads
its own name list from listTableNames and decides from that -- and when
a name is not in that list it clears the list, asks again, and only then
reports the table missing (ExternalDatabase.buildTableForInit). The same
shape one level up for databases.

Serving those two calls from the cache added yesterday made that last
chance a formality: the second listing came back from memory, identical
to the first, so a table created remotely a moment ago was unreachable
until an entry expired -- and unreachable is where it would have stayed
for a user who had no reason to suspect a cache. That is precisely the
staleness this cache was supposed not to introduce.

They are now read from the source every time, and refresh what is
remembered as they go. Nothing is lost by it: the engine caches both
listings itself, so they were never the per-query cost. What a query
actually repeats is resolving a database and a table name and reading a
schema, and those still come from memory.

Verified by mutation: pointing either listing back at the cache fails
its test, one showing a table created behind Doris's back, the other a
planted database the source does not have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1z6M8Gj9F9pErafGdfdjx
Three things the unit tests cannot reach, because each needs the engine
sitting in front of the connector.

A table created behind Doris's back, by the sqlite3 CLI the fixture is
already built with, must be queryable with no REFRESH at all. That works
only if the connector answers the engine's re-list from the source; it
is the end-to-end half of the rule, and it fails against a connector
that serves that listing from memory.

REFRESH CATALOG must reach the connector, asserted through a schema
change rather than a new table: the engine clears its own schema copy
either way, so a stale answer after the refresh can only have come from
the connector. Without the hook the catalog would serve the schema it
first read until the TTL expired, and REFRESH CATALOG does not rebuild
the connector.

The cache knobs, both directions: a catalog with the cache turned off
still works, and an unparseable ttl fails at CREATE CATALOG. The second
doubles as proof that the deployed plugin is the new one -- an older
build ignores an unknown property, so the CREATE would succeed and the
assertion would fail rather than pass quietly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1z6M8Gj9F9pErafGdfdjx
driver_checksum has been a property name with nothing behind it since
the connector was written: a user could set it and Doris would ignore
it. It is now checked at CREATE CATALOG, by MD5, against the file this
FE resolves.

It earns its place from what this catalog type asks of an operator.
Doris ships no ADBC driver, so the library is placed by hand on every
node and the copies have to stay identical -- and a wrong or stale copy
announces nothing. It loads, it answers, and whatever it does
differently arrives later as a query failure that never mentions a file.

The property remains optional and stays honest about its reach: it sees
this FE's copy and no BE's, so it does not verify that the nodes agree.
What it gives an operator is a way to state which build the catalog was
written for and have one node check itself against that. A checksum that
cannot be computed fails rather than passes, or the property would be
quietly optional on exactly the node that could not read the file.

Not routed through the validation context's own checksum service: that
one resolves against jdbc_drivers_dir and enforces a .jar grammar,
neither of which applies here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1z6M8Gj9F9pErafGdfdjx
When a source refuses the pushed-down statement, its answer arrives in
its own words -- typically a syntax error pointing at a quote character.
Nothing in that says Doris wrote the statement, and nothing says which
SQL Doris writes is a catalog property.

That is the first wall anyone pointing this connector at something other
than Doris walks into, because the default dialect is conservative ANSI
and a source that wants something else rejects the very first query. The
planning-time failure now names the dialect the statement was generated
in and the property that changes it.

Only the partitioned path can carry this: asking the driver to split a
statement executes it, so FE is where that rejection lands. The
single-statement path runs on BE and its error is BE's to improve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1z6M8Gj9F9pErafGdfdjx
EXPLAIN's QUERY line named more columns than the statement that runs.
The property cache is warmed in init() -- initSchemaParams asks for the
path partition keys, which come from these properties -- and at that
moment the scan tuple still carries every column of the table. Nereids
prunes it when the project above the scan is translated, and only then
does planScan build the statement that executes. So a connector that
renders remote SQL from the column list (adbc, jdbc) had a cached
statement that over-projects against the one BE runs, and EXPLAIN
described a query nobody would send.

The display is re-asked with the CURRENT columns and the ORIGINAL
filter. The filter has to be the original: by this point the pushed-down
conjuncts have been removed from the node's list, so rebuilding it would
drop the pushed predicates out of the WHERE clause shown. Nothing else
moves -- what goes to BE, the MVCC/rewrite/top-n pins, and the conjunct
pruning all keep using the cached result, so a connector that puts
schema dictionaries in these properties is untouched.

Costs nothing when the projection did not narrow: the slot ids are
compared first, and a connector that renders no remote statement returns
before the comparison.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1z6M8Gj9F9pErafGdfdjx
…ment

A TIMESTAMPTZ literal reaches the dialect already converted to UTC: in a
+08:00 session, ts > '2023-01-01 00:00:00' arrives as the wall clock
2022-12-31 16:00:00. Standard SQL's TIMESTAMP '...' spelling carries no
zone, so the source reads that UTC wall clock as its own local time and
compares it against its column.

On a source east of UTC that only widens the match and Doris narrows it
again on what comes back. On one west of UTC it NARROWS the match, and
the rows the source drops are rows the query wanted -- a scan cannot ask
for rows the source never sent. There is no portable spelling that says
which instant is meant, so the comparison stays with Doris, for the same
reason NaN, null-safe equality and LIKE already do.

Also records why this connector does not consult the catalog property
enable.mapping.timestamp_tz when mapping a zoned arrow timestamp:
ExternalCatalog stamps that property as "false" into every external
catalog that does not name it, so reading it cannot tell a user who
asked for wall clocks from one who said nothing, and honouring it would
force DATETIMEV2 on every adbc catalog. This connector's default is
TIMESTAMPTZ; making the property settable needs fe-core to let a
connector supply its own default first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1z6M8Gj9F9pErafGdfdjx
Twenty suites over a live catalog: type mapping and semantics, complex
and binary types, scan edges, predicate pushdown, column pruning, query
shapes, source table models, metadata operations, nested catalogs, cross
source joins, large data, outfile, MTMV, and negative cases.

Several assertions are written against what the planner actually
delivers rather than against what the SQL says, because Nereids
normalises the predicate before any connector sees it: a one-column
disjunction becomes an IN list, NOT is pushed through comparisons and
connectives by De Morgan, column-plus-literal arithmetic is folded, and
<=> against a non-null literal becomes =. Pinning the SQL spelling would
have been asserting on the optimizer, so each of those keeps a shape the
optimizer cannot rewrite -- a two-column OR, two-column arithmetic, two
nullable columns of one type -- to hold the connector's own behaviour.
Likewise count(*) is asserted as one narrow column, not zero: pruning an
empty scan tuple puts the smallest slot back.

Three expectations record losses this connector cannot avoid. IPV4
arrives as the address's 32 bits read signed, because Doris encodes it
as int32 on both sides of the wire. A source datetime arrives as
TIMESTAMPTZ, so comparisons cast it back before checking the instant.
DBL_MAX cannot reach the test client at all -- Doris prints a double
with 16 significant digits, which parses back as infinity -- so that row
is compared inside Doris with a null-safe join instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1z6M8Gj9F9pErafGdfdjx
Both of this connector's deployment-level settings move off fe.conf and into the
plugin's own adbc.conf -- the channel apache#66347 introduced -- as drivers_dir and
driver_secure_path, read through ConnectorConf.get.

Neither has an fe.conf half. The two @ConfFields they used to be
(adbc_drivers_dir, adbc_driver_secure_path) and the two
DefaultConnectorContext env entries that forwarded them are removed rather than
kept as a fallback: this connector has never shipped, so no deployment
configured them anywhere else, and a key in fe-core is an engine change per
connector setting -- which is what that channel exists to stop.

The default drivers directory is computed in the connector from the doris_home
the engine already publishes, so it stays <DORIS_HOME>/plugins/adbc_drivers.
build.sh needs no change: it seeds a live <name>.conf from any *.conf.template
found in a plugin zip.

AdbcConnectorConfTest pins the template's name against ConnectorProvider.name()
-- a template under any other name deploys a file the engine never opens, with
every setting in it silently ignored -- and pins that an environment still
carrying the old fe.conf keys does not resurrect a channel fe-core no longer
feeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RgJjuW5w4jEKF9HorENTur
@morningman
morningman marked this pull request as ready for review August 2, 2026 15:52
@morningman

Copy link
Copy Markdown
Contributor Author

run buildall

@hello-stephen

Copy link
Copy Markdown
Contributor

FE UT Coverage Report

Increment line coverage 13.79% (4/29) 🎉
Increment coverage report
Complete coverage report

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.

2 participants