Skip to content

[fix](paimon connector) Five independent fixes a sibling-connector read depends on - #66403

Merged
morningman merged 5 commits into
apache:masterfrom
morningman:connector-prereqs
Aug 4, 2026
Merged

[fix](paimon connector) Five independent fixes a sibling-connector read depends on#66403
morningman merged 5 commits into
apache:masterfrom
morningman:connector-prereqs

Conversation

@morningman

Copy link
Copy Markdown
Contributor

What problem does this PR solve?

Issue Number: close #xxx

Related PR: #66399

Problem Summary:

Five independent fixes, none of them in one connector's own code. They were found while building the fluss catalog (#66399), which is where each one's symptom first showed up — but every one of them is a Doris bug or a Doris gap that exists without fluss, so they are proposed on their own, ahead of and separately from that connector. #66399 will be rebased on top of this and shrink by exactly these five commits.

They are unrelated to each other; there is one commit per fix and each can be reviewed alone.


1. [fix](be) Stop exporting the statically linked RocksDB symbols

be/src/service/CMakeLists.txt — one line, plus why.

doris_be sets ENABLE_EXPORTS, so the 4840 rocksdb symbols it links statically are exported into the global dynamic symbol table. The executable is the highest-priority definition for everything loaded after it, so any JNI library that carries its own RocksDB has its internal calls resolved into doris_be's copy instead — 2576 symbols with byte-identical mangled names.

That would be survivable if the two agreed on layout. They do not: such libraries are commonly built against the pre-C++11 libstdc++ string ABI (...C1ERKSs) while doris_be is built against the new one (...RKNSt7__cxx1112basic_stringE). An object constructed with one layout and used by functions compiled for the other yields a garbage length, an std::bad_alloc that escapes through the JNI frame, and an aborted BE process.

The fix hides that one archive from the dynamic symbol table, so such a library binds to its own copy. It is scoped to the archive rather than dropping ENABLE_EXPORTS, because what actually needs the exports is native UDFs (runtime/user_function_cache.cpp dlopens them) and those use the Doris UDF ABI, which has nothing to do with RocksDB. Crash stacks do not need it either — they are symbolized from debug info, which is why they can name even anonymous-namespace functions.

Verified by symbol table rather than by argument: after the change 61 rocksdb symbols remain exported (compiler-instantiated inline/template members that landed in Doris's own objects, which an archive-level exclusion cannot reach). 29 of those share a name with a JNI library's, but readelf -r shows none of them in that library's relocation table, so it never looks them up. The zstd/lz4/snappy/bzip2/zlib duplicates are left alone deliberately: those are C ABIs, stable and layout-free, unlike RocksDB's C++ objects.

⚠️ This changes BE's link behaviour, so it wants a full relink and a BE regression run — tablet metadata itself lives in RocksDB.

2. [fix](be) Pick the table reader per scan range, not per scan node

be/src/exec/scan/file_scanner_v2.{h,cpp} + unit test.

_open_impl builds one _table_reader from the first scan range; _prepare_next_split then reuses it for every range that follows, and never revisits the choice. The reader is format-specific, so a scan node holding ranges of two different table_format_types hands the second kind to the first kind's reader.

That does not fail cleanly. It fails as whatever the wrong reader makes of a foreign range — e.g. paimon's reader reporting an unsupported file format for a range that carries no paimon parameters at all. And which ranges end up in the same scanner is the engine's assignment, so the same query succeeds or fails depending on how the ranges happened to be dealt out, and changing the projection can change the outcome.

The fix records the format the reader was built for and rebuilds when a range disagrees. The expression contexts are deliberately not rebuilt: they are per-scanner and format-independent, and _init_expr_ctxes is not idempotent.

A scan node mixing formats is what a connector reading a table as "a lake plus the log written after it" produces — its lake half planned by a sibling connector, its own half by itself — but nothing in the scanner assumes that, and the fix is a general one.

New unit test TheTableReaderIsRebuiltWhenARangeChangesTableFormat: same format reuses the reader, a different format replaces it, and the formats really do map to different reader types (otherwise the first two assertions would hold for a scanner that never rebuilt anything). Reverting the comparison to the pre-fix behaviour turns it red.

3. [fix](paimon) Claim the table handles this connector produces

fe/fe-connector/fe-connector-paimon + unit tests.

Connector.ownsHandle defaults to false. The iceberg and hudi connectors override it — they are already used as siblings behind the hms gateway — but paimon never did. Any gateway connector that embeds paimon therefore asks "is this handle yours?" about a handle paimon itself produced and is told no, so every one of the gateway's type guards fails open and the first cast throws ClassCastException.

One method, same implementation as the two siblings that already have it.

4. [feat](paimon) Say which bucket a scan range came from

fe/fe-connector/fe-connector-paimon + unit tests.

Adds paimon.bucket = DataSplit.bucket() to the scan range properties, so a connector that plans paimon splits on behalf of its own table can line them up with its own per-bucket state.

FE-only: populateRangeParams does not forward it, so BE is unaffected. Set on every DataSplit-backed range, native and JNI alike, so which reader BE ends up using cannot change what a caller can learn about the split. Deliberately not set on the collapsed COUNT(*) range (it stands for splits from several buckets, so any single number would be a lie) nor on a non-DataSplit system split (there is no bucket). Consumers are expected to fail loud when it is absent on a range they meant to bind, since treating that as "no state for this bucket" is a wrong-results bug rather than a degradation.

5. [feat](connector) Let a connector name the columns its reader must read

fe/fe-connector/fe-connector-api + fe/fe-core + unit tests. The only engine-side change here.

A connector whose BE-side reader merges, suppresses or otherwise identifies rows by key needs those key columns to be READ, whether or not the query selected them. Today the plugin scan's tuple is pruned to the projection, so the reader is handed a scan without the column it needs.

This is not a new mechanism. Doris does exactly this for its own aggregate and merge-on-read unique-key tables: PhysicalPlanTranslator.preserveExtraStorageKeySlots keeps the key slots and ships them as extra_key_column_slot_ids, because BE merges by key regardless of what was selected. The new branch sits beside that one, before the same removeIf, and only widens the scan's tuple — the project above it was already given its own output tuple, so a preserved column is read and then dropped and never reaches the query's output.

Three names:

  • SPI ConnectorScanPlanProvider.getMustReadColumns(session, handle)defaults to an empty set, so every existing connector prunes exactly as before
  • PluginDrivenScanNode.mustReadColumnsFromConnector() — same memoized provider the rest of planning uses, with the plugin classloader pinned
  • PhysicalPlanTranslator.preserveConnectorMustReadSlots()

A returned name that matches no slot fails the query loud rather than being skipped: it means the connector and the engine disagree about the table, and reading on would hand the connector's reader a scan missing a column it said it needs — silently wrong rows, not an error.

Release note

None

Check List (For Author)

  • Test

    • Unit Test
    • Manual test (add detailed scripts or steps below)

    Unit tests, all 0 skipped: fe-connector-api 113, fe-connector-paimon 511 (1 pre-existing skip), fe-core neighbourhood 153 (PluginDrivenScanNode*, PhysicalPlanTranslator*, CountStarSmallestSlotTest, which starts a real FE and exercises the OLAP pruning path this change sits next to). BE: FileScannerV2*:FileScannerTest* 26 and Paimon*:*Iceberg*:*EqualityDelete* 227.

    Every new behaviour was mutation-tested — the change inverted, rebuilt, and the test required to go red. For 注释英文拼写错误 #2 that is the reader-rebuild comparison; for Support bulk loading from S3 compatible distributed storage #3, 居然是Java还是ant #4 and 修改为maven就好了 #5 the mutations are listed in the individual commit messages.

    能公开一些公开数据集上的性能测试数据吗? #1 cannot be covered by a unit test — it is a link-time property. It was verified against a real BE: the failure reproduced twice before the change (BE abort with Java_org_rocksdb_RocksDB_openROnlyColumnFamilyDescriptor::ColumnFamilyDescriptor on the stack), the same Java code passed in a plain JVM, and after a full relink the same workload runs and BE's own tablet metadata survives a restart. Residual exported symbols were checked with nm -D and readelf -r as described above.

  • Behavior changed:

    • No.
  • Does this need documentation?

    • No.

morningman and others added 5 commits August 4, 2026 07:26
Exporting them makes this executable the definition every later-loaded
library binds to, so a JNI library carrying its own RocksDB runs half on ours.
The fluss scanner bundles frocksdbjni, whose librocksdbjni.so defines 2576
rocksdb symbols under names identical to ours but was built against the
pre-C++11 libstdc++ string ABI: objects laid out by one copy and used by the
other yield a garbage length, an std::bad_alloc that escapes the JNI frame,
and an aborted BE. Reading any fluss primary-key table with a kv snapshot
killed the process, reproducibly.

Scoped to the archive rather than dropping ENABLE_EXPORTS, because what needs
the exports is native UDFs (runtime/user_function_cache.cpp dlopens them) and
those use the Doris UDF ABI, which has nothing to do with RocksDB. Crash
stacks do not need it either -- they are symbolized from debug info, which is
why they name even anonymous-namespace functions.

61 rocksdb symbols remain exported: inline and template members the compiler
emitted into Doris's own objects, which no archive exclusion can reach. 29 of
those still share a name with the JNI library, but none appear in its
relocation table -- it never resolves them at load time, so they cannot be
interposed. The library also duplicates zstd, lz4, snappy, bzip2 and zlib
symbols; those are C ABIs, stable and layout-free, and are left alone.

Verified: the fluss primary-key suite passes with BE alive (it aborted before);
all three fluss suites green; an internal table survives write, BE restart and
read, which is the tablet metadata RocksDB itself round-tripping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VCPjzhwMQuP7nTgdvGVbM
Connector.ownsHandle defaults to false, and this connector never overrode
it. That was invisible while paimon was only ever a front-door catalog: the
predicate exists so a GATEWAY connector can embed another as a sibling and
route a foreign handle back to whoever made it, since the sibling's concrete
handle type cannot be named across the plugin classloader split.

The fluss connector reads a lake table by delegating to this one, so it asks
that question about every handle it gets back — and got "not mine" about
handles paimon had just produced. Every guard on the gateway side then falls
through, and the first cast throws a ClassCastException naming the GATEWAY's
handle type and two class loaders, with nothing to suggest the missing piece
is a method here.

Same one-liner the iceberg and hudi siblings behind the hms gateway already
carry. No unit test could have caught this: a hand-written test double
implements ownsHandle precisely because it has to, so the double is more
capable than the real connector. It took an end-to-end read to surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VCPjzhwMQuP7nTgdvGVbM
FileScannerV2 built its table reader once, from the first range, and reused
it for every range after that. One scan node can be given ranges of more
than one table format: a fluss union read plans the table's lake half
through the paimon connector and its log half itself, and both arrive as
ranges of the same scan.

Whichever range came first then decided the reader for all of them, and the
other format's ranges were handed to a reader that does not understand them.
That does not fail cleanly — it fails as whatever that reader makes of a
foreign range. Here it was paimon's, reporting an unsupported file format
for a fluss range that carries no paimon parameters at all.

Which ranges share a scanner is up to the engine's assignment, so the same
query succeeded or failed by how the ranges happened to be dealt out, and
changing the projected columns could flip it either way.

The reader now follows the range's table format. The expression contexts are
deliberately not rebuilt: they are per-scanner and format-independent, and
_init_expr_ctxes is not idempotent.

Verified by disabling the rebuild and rerunning the suites: only the union
read fails, with exactly the original error, and the four fluss suites that
do not mix formats stay green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VCPjzhwMQuP7nTgdvGVbM
A scan range this connector plans is opaque about its origin: the JNI arm
carries a serialized split and nothing else, the native arm a file path and
a byte interval. That is fine while the only reader is BE, which just reads
what it is handed.

It stops being fine once another connector plans splits here on behalf of
its own table. The fluss connector does exactly that: a fluss table tiered
into paimon keeps a bucket-identical layout, and reading it means pairing
the lake data of bucket b with the log tail of bucket b that has not been
tiered yet. Nothing on the range says b. Parsing it out of the data-file
path would work only on the native arm and only by depending on this
connector's directory layout.

So carry it: paimon.bucket = DataSplit.bucket(), on the native and JNI arms
alike -- which BE reader a split lands on is a session-level escape hatch
the sibling does not control, and it must not change what the sibling can
learn. FE-only; populateRangeParams does not forward it, so BE sees nothing
new.

Two ranges deliberately do NOT carry it. The collapsed COUNT(*) range
stands for the splits of every bucket, so any single number on it would be
a lie. A non-DataSplit system split has no bucket at all. A consumer that
needs the binding must fail loud on an absent bucket rather than read it as
"no state for this bucket" -- that reading turns a broken contract into
duplicated rows.

The fixture is two-bucket on purpose: with one bucket every range reads "0"
and a hard-coded constant passes. Four mutations checked red -- constant
bucket on the native arm, no bucket on the JNI arm, a bucket on the system
split, a bucket on the count range.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VCPjzhwMQuP7nTgdvGVbM
A connector whose BE-side reader merges or suppresses rows by key needs
that key read whether or not the query selected it. Doris already keeps
those columns for its own aggregate and merge-on-read unique-key tables
-- preserveExtraStorageKeySlots, four lines above where the scan's slots
are pruned -- for exactly that reason. A plugin connector had no way to
say the same thing, and BE cannot read a column the plan never asked for.

So ask it: getMustReadColumns, answered per scan, empty by default, so
nothing changes for a connector that needs only what the query projects.
The answer arrives during plan translation, after the scan node is
initialized and before splits are planned, and widens the scan's tuple
only -- the project above it already has its own output tuple, so the
column is read and then dropped rather than returned.

The question goes through the same memoized provider that will plan the
splits, because the two have to come from one decision: a connector that
answers "no extra columns" here and then plans a read that needs them
leaves BE looking for a column that is not in the projection. A name that
matches no slot fails the query and says which name, rather than being
skipped -- skipping turns a disagreement about the table into silently
wrong rows.

Checked red by six mutations: dropping the branch, skipping unknown
names, stopping after the first match, dropping the null answer guard,
resolving a fresh provider to ask, and a non-empty SPI default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VCPjzhwMQuP7nTgdvGVbM
@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

Copy link
Copy Markdown
Contributor Author

run buildall

@github-actions github-actions Bot added the approved Indicates a PR has been approved by one committer. label Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

PR approved by at least one committer and no changes requested.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

PR approved by anyone and no changes requested.

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-H: Total hot run time: 28461 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpch-tools
Tpch sf100 test result on commit c5ffeef95db258bd55b4617849619f9a55076557, data reload: false

------ Round 1 ----------------------------------
============================================
q1	17837	4020	3932	3932
q2	1992	318	200	200
q3	10328	1345	817	817
q4	4684	471	341	341
q5	7508	838	549	549
q6	177	170	142	142
q7	746	800	593	593
q8	9359	1636	1561	1561
q9	5804	4067	4007	4007
q10	6806	1650	1377	1377
q11	521	348	333	333
q12	727	580	451	451
q13	18083	3262	2733	2733
q14	270	264	239	239
q15	q16	740	725	658	658
q17	1012	1078	931	931
q18	6699	5669	5417	5417
q19	1250	1223	933	933
q20	777	675	569	569
q21	5893	2643	2383	2383
q22	430	352	295	295
Total cold run time: 101643 ms
Total hot run time: 28461 ms

----- Round 2, with runtime_filter_mode=off -----
============================================
q1	4243	4161	4157	4157
q2	290	315	211	211
q3	4496	4866	4347	4347
q4	2164	2256	1428	1428
q5	4187	4087	4071	4071
q6	223	177	133	133
q7	1740	1586	1438	1438
q8	2761	2143	2108	2108
q9	7231	7298	7220	7220
q10	4306	4315	3900	3900
q11	583	416	362	362
q12	723	734	520	520
q13	3112	3498	3016	3016
q14	308	306	268	268
q15	q16	746	722	628	628
q17	1299	1273	1278	1273
q18	7836	7317	7366	7317
q19	1094	1051	1028	1028
q20	2198	2188	1934	1934
q21	5311	4684	4472	4472
q22	526	469	414	414
Total cold run time: 55377 ms
Total hot run time: 50245 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-DS: Total hot run time: 169721 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpcds-tools
TPC-DS sf100 test result on commit c5ffeef95db258bd55b4617849619f9a55076557, data reload: false

query5	4336	630	469	469
query6	473	226	208	208
query7	4832	570	344	344
query8	330	182	173	173
query9	8788	4032	3982	3982
query10	502	339	302	302
query11	5802	2222	2050	2050
query12	163	95	98	95
query13	1247	569	436	436
query14	6089	4633	4379	4379
query14_1	3831	3765	3755	3755
query15	203	194	177	177
query16	1030	466	446	446
query17	1096	669	540	540
query18	2426	448	333	333
query19	205	194	146	146
query20	111	101	106	101
query21	232	148	133	133
query22	13097	13014	12824	12824
query23	17235	16286	15955	15955
query23_1	16110	16081	16117	16081
query24	7545	1674	1210	1210
query24_1	1249	1259	1238	1238
query25	554	463	419	419
query26	1337	343	217	217
query27	2650	578	392	392
query28	4509	2038	2018	2018
query29	1077	594	474	474
query30	335	264	233	233
query31	1094	1055	951	951
query32	103	61	60	60
query33	522	321	258	258
query34	1168	1147	624	624
query35	736	743	640	640
query36	771	775	733	733
query37	150	104	92	92
query38	1841	1658	1621	1621
query39	826	834	782	782
query39_1	804	779	789	779
query40	242	163	141	141
query41	67	63	64	63
query42	95	91	89	89
query43	310	319	270	270
query44	1403	789	770	770
query45	186	176	169	169
query46	1021	1211	708	708
query47	1512	1497	1405	1405
query48	412	413	304	304
query49	579	398	289	289
query50	1064	441	335	335
query51	10579	10445	10440	10440
query52	87	87	80	80
query53	260	270	197	197
query54	295	252	241	241
query55	78	75	70	70
query56	330	320	315	315
query57	1079	1047	985	985
query58	306	266	269	266
query59	1572	1669	1424	1424
query60	322	291	270	270
query61	179	173	176	173
query62	403	331	292	292
query63	239	198	206	198
query64	2972	1151	960	960
query65	3886	3855	3783	3783
query66	1868	475	380	380
query67	28452	28191	28078	28078
query68	3242	1497	1057	1057
query69	411	305	275	275
query70	895	794	789	789
query71	384	330	320	320
query72	3137	2612	2520	2520
query73	878	878	442	442
query74	4616	4504	4339	4339
query75	2393	2351	2014	2014
query76	2339	1122	739	739
query77	327	357	282	282
query78	11095	11112	10548	10548
query79	2054	1161	768	768
query80	1302	559	469	469
query81	561	329	286	286
query82	598	152	117	117
query83	363	329	294	294
query84	288	158	131	131
query85	974	602	521	521
query86	407	255	210	210
query87	1797	1805	1708	1708
query88	3745	2819	2808	2808
query89	387	316	286	286
query90	1891	204	200	200
query91	201	189	170	170
query92	68	60	57	57
query93	1705	1496	978	978
query94	721	333	307	307
query95	804	504	575	504
query96	1075	796	358	358
query97	2480	2456	2343	2343
query98	206	200	195	195
query99	720	732	615	615
Total cold run time: 257469 ms
Total hot run time: 169721 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
ClickBench: Total hot run time: 23.85 s
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/clickbench-tools
ClickBench test result on commit c5ffeef95db258bd55b4617849619f9a55076557, data reload: false

query1	0.01	0.01	0.01
query2	0.08	0.04	0.04
query3	0.25	0.13	0.12
query4	1.61	0.13	0.15
query5	0.24	0.24	0.23
query6	1.16	0.83	0.81
query7	0.04	0.01	0.00
query8	0.06	0.04	0.04
query9	0.36	0.32	0.34
query10	0.56	0.55	0.54
query11	0.19	0.13	0.14
query12	0.19	0.14	0.14
query13	0.46	0.46	0.46
query14	1.01	1.03	1.00
query15	0.60	0.58	0.58
query16	0.32	0.32	0.33
query17	1.11	1.11	1.06
query18	0.21	0.20	0.20
query19	2.01	1.93	2.05
query20	0.02	0.01	0.01
query21	15.45	0.20	0.13
query22	4.92	0.05	0.05
query23	16.14	0.30	0.12
query24	2.98	0.40	0.30
query25	0.11	0.05	0.05
query26	0.73	0.21	0.14
query27	0.04	0.03	0.02
query28	3.52	0.79	0.36
query29	12.48	4.09	3.19
query30	0.27	0.16	0.15
query31	2.77	0.54	0.32
query32	3.21	0.58	0.49
query33	3.33	3.19	3.16
query34	15.70	3.95	3.31
query35	3.23	3.22	3.21
query36	0.55	0.43	0.42
query37	0.09	0.06	0.06
query38	0.05	0.05	0.03
query39	0.04	0.03	0.03
query40	0.17	0.16	0.15
query41	0.08	0.03	0.03
query42	0.04	0.03	0.03
query43	0.05	0.03	0.03
Total cold run time: 96.44 s
Total hot run time: 23.85 s

@morrySnow morrySnow changed the title [fix](be)(paimon)(connector) Five independent fixes a sibling-connector read depends on [fix](paimon connector) Five independent fixes a sibling-connector read depends on Aug 4, 2026
@hello-stephen

Copy link
Copy Markdown
Contributor

FE UT Coverage Report

Increment line coverage 95.24% (20/21) 🎉
Increment coverage report
Complete coverage report

@hello-stephen

Copy link
Copy Markdown
Contributor

BE Regression && UT Coverage Report

Increment line coverage 88.89% (16/18) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 75.83% (32078/42301)
Line Coverage 60.50% (357877/591531)
Region Coverage 57.16% (300747/526123)
Branch Coverage 58.55% (135449/231331)

@morningman
morningman merged commit 6d7992f into apache:master Aug 4, 2026
29 of 32 checks passed
@hello-stephen

Copy link
Copy Markdown
Contributor

FE Regression Coverage Report

Increment line coverage 0.00% (0/362) 🎉
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

approved Indicates a PR has been approved by one committer. reviewed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants