Skip to content

[feat](fluss) Support reading Apache Fluss tables through a fluss catalog - #66399

Open
morningman wants to merge 35 commits into
apache:masterfrom
morningman:fluss-connector
Open

[feat](fluss) Support reading Apache Fluss tables through a fluss catalog#66399
morningman wants to merge 35 commits into
apache:masterfrom
morningman:fluss-connector

Conversation

@morningman

@morningman morningman commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

What problem does this PR solve?

Issue Number: close #xxx

Related PR: #66403

Problem Summary:

Doris cannot read Apache Fluss (incubating) tables. This adds a fluss catalog, reading all three shapes a fluss table can take:

Table How it is read
log table the fluss change log, one scan range per bucket
primary-key table a kv snapshot plus the change log after it, merged by key
tiered table (table.datalake.enabled) its paimon lake plus the log written after it, in one scan

For a tiered table the lake half is not re-implemented: it is planned by the paimon connector, on the snapshot the fluss coordinator pinned, so that half gets native ORC/Parquet, deletion vectors and the file cache for free — and the fluss plugin ships no paimon dependency at all (it borrows the paimon plugin through createSiblingConnector, the way hive borrows iceberg and hudi). A tiered table also exposes tbl$lake, which reads the lake alone.

For a tiered primary-key table the two halves are merged BY KEY, natively in BE: FE wraps each lake split with the key set of that bucket's log tail, the BE reader drops the lake rows that tail replaced before materialization, and the tail is replayed once as its own range.

CREATE CATALOG fluss PROPERTIES (
    "type" = "fluss",
    "fluss.bootstrap.servers" = "host:9123"
);
SELECT * FROM fluss.db.tbl;         -- lake + log, merged
SELECT * FROM fluss.db.`tbl$lake`;  -- the lake alone

Reviewing this

35 commits. Five of them are not in the fluss module and stand on their own, so they are proposed
separately in #66403 and are not the subject of this review. They are still part of this branch until
that lands; once it does, this PR is rebased on top of it and shrinks by exactly those five commits.
Listed here so a reviewer of this branch knows what they are and where they went:

Commit Lands in Why it is separate
[fix](be) Stop exporting the statically linked RocksDB symbols be/src/service/CMakeLists.txt Fixes a Doris bug, not a fluss one. doris_be exports 4840 statically linked rocksdb symbols, so any JNI library carrying its own RocksDB resolves 2576 of them into doris_be's copy — across two different libstdc++ string ABIs. An object built with one layout, used by functions compiled for another ⇒ bad_alloc through a JNI frame ⇒ the BE process aborts. Fluss's embedded frocksdbjni is simply the first library to hit it.
[fix](be) Pick the table reader per scan range, not per scan node be/src/exec/scan/file_scanner_v2.{h,cpp} _open_impl builds one _table_reader from the first split and every later split reuses it. A scan node holding two table_format_types — the shape any union read has — hands the second kind to the first kind's reader. The symptom is non-deterministic, because which splits share a scanner is the engine's choice.
[fix](paimon) Claim the table handles this connector produces fe/fe-connector/fe-connector-paimon Connector.ownsHandle defaults to false; iceberg and hudi override it, paimon never did. Any connector using paimon as a sibling is told "not mine" about every handle paimon itself produced.
[feat](paimon) Say which bucket a scan range came from fe/fe-connector/fe-connector-paimon An FE-only scan range property, not forwarded to BE, so BE is unaffected. Fluss's own lake SPI treats LakeSplit.bucket() as first class; this is the same fact on the Doris side.
[feat](connector) Let a connector name the columns its reader must read fe/fe-connector/fe-connector-api + fe/fe-core The one engine-side change. A connector whose reader merges by key needs the key columns in the scan's tuple whether or not the user selected them. This is not a new mechanism — Doris's own aggregate and merge-on-write tables do exactly this (preserveExtraStorageKeySlots + extra_key_column_slot_ids), for the same reason. The new branch sits beside that one, before the same removeIf. The SPI method defaults to an empty set, so every other connector is unaffected.

The remaining 30 commits are the connector itself, grouped so that each group compiles, runs and has its own acceptance:

  1. metadata — module, type mapping, catalog/table/partition metadata, the e2e docker environment
  2. log tables — thrift payload, FE planning, the BE java scanner, the BE C++ glue
  3. primary-key tables — kv snapshot + change log
  4. the lake$lake through the paimon sibling, and lake-plus-log for log tables
  5. merging a primary-key table's halves by key — FE planning, the tail replayer, the BE suppression reader
  6. coverage — a partition-column-type gate, and five more e2e suites

Things worth saying out loud

  • This depends on fluss 1.0-SNAPSHOT, resolved from the Apache snapshot repository fe/pom.xml already declares. Fluss 1.0 is not released and the APIs this uses are not in 0.9.1. The coordinates switch to the release when there is one. If CI goes red while this is green locally, compare the fluss snapshot timestamps first.
  • The e2e suites do not run in CI yet, on purpose. They need a fluss cluster, and the two docker images are built locally because fluss 1.0 is unreleased; enableFlussTest defaults to false and the external pipeline's conf deliberately does not set it. That switches on with the fluss release.
  • Two pre-existing bugs in neighbouring code were found by this work and are not fixed here, so as not to widen the diff. Both reproduce without fluss: an equality predicate on a microsecond TIMESTAMP pushed into paimon matches no row (a plain paimon catalog over the same warehouse behaves the same), and java-common's VARBINARY read-back decodes what it wrote as a StringView, so it always reads zeros (the write side and BE agree; only the Java read-back is wrong, and no production path takes it).
  • TIME is mapped to UNSUPPORTED rather than to a string or to elapsed millis: Doris has no type meaning what fluss's TIME means, and handing back a plausible value of a different meaning is not something a later error would catch. The paimon and iceberg connectors mark their own TIME the same way, which also keeps tbl and tbl$lake agreeing on one schema.
  • Only filesystem is supported as the paimon lake catalog for now. HMS and REST lake catalogs, predicate pass-through into the lake half, and the write path are follow-ups.

Release note

Support reading Apache Fluss tables through a new fluss catalog, including tables tiered into a paimon lake, which are read as the lake plus the change log written after it.

Check List (For Author)

  • Test

    • Regression test
    • Unit Test

    regression-test/suites/external_table_p0/fluss — 12 suites, 0 skipped, green on two consecutive runs against a real fluss + flink + paimon cluster (docker/thirdparties/docker-compose/fluss): catalog, log tables, primary-key tables, lake-only, lake+log, primary-key merge, nested complex types, partition-column types, 100k rows, compound predicates, and empty/negative/misc.

    Unit tests: fe-connector-fluss 192, fe-connector-paimon 511, fluss-scanner 36, fe-connector-api + fe-core (the must-read-columns chain) 36 — all 0 skipped. BE: FileScannerV2*:FileScannerTest*:Fluss* 55 and Paimon*:*Iceberg*:*EqualityDelete* 227, all passing.

    Every new behaviour was mutation-tested: the change was inverted and the suite had to go red. That includes the end-to-end ones — the merge was mutated in C++, in BE-java and in FE, rebuilt and redeployed each time, and each mutation turned a suite red.

  • Behavior changed:

    • No.
  • Does this need documentation?

    • Yes.

morningman and others added 30 commits August 4, 2026 04:02
Introduces fe-connector-fluss: the plugin identity (provider, services entry,
plugin zip), the catalog property contract, and FlussAdminOps -- the single
interface through which this connector talks to a fluss cluster.

FlussAdminOps exists so that metadata mapping and, later, split generation stay
pure functions of what the cluster reports. That is what lets a unit test drive
the states that decide correctness (an empty bucket, a lake offset that caught up
with the log, a partition that only the lake still has) without a mocking
framework, which the fe-connector modules do not carry.

Notes on the fluss client that shape the pom:
- it ships as one unrelocated fat jar carrying fluss-common/-rpc, frocksdbjni,
  commons-lang3 and zstd, so fluss-common must NOT be declared separately and
  frocksdbjni cannot be excluded;
- it declares Arrow `provided`, so a consumer reaching fluss's ARROW log format
  has to add Arrow itself. Metadata and planning do not, so this module does not.

Fluss errors travel unwrapped: scan planning has to discriminate on
LakeTableSnapshotNotExistException to decide whether a union read is possible,
and wrapping would turn that into string matching. The TCCL pin sits at
connection creation because that is where the client's own threads are born and
inherit a context classloader; pinning once there covers them for life.

Version is pinned by one property in fe/pom.xml for every fluss consumer: the FE
plans splits against the same client the BE scanner will read with, and the
scanner links fluss classes marked @internal, so a skew breaks the read path at
runtime rather than at build time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds FlussTypeMapping, a DataTypeVisitor over fluss's 19 type roots, so a
type added by a future fluss release breaks this compile instead of
degrading a column silently.

Every rule is the composition of fluss's own FlussDataTypeToPaimonDataType
with the paimon connector's PaimonTypeMapping: a datalake table is readable
both as `tbl` (this mapping) and as `tbl$lake` (delegated to the paimon
connector), and one table must not show two schemas. That is what fixes
CHAR over 255 collapsing to STRING, the microsecond clamp on timestamps,
and the spelling and defaults of the two mapping switches.

TIME is marked UNSUPPORTED rather than reinterpreted: Doris has no storable
TIME column, and both substitutes other engines use (STRING, elapsed-millis
INT) hand back a value whose meaning differs from the source. The marker
degrades one leaf, so a wide table with one TIME field still loads.

MAP is mapped, not marked unsupported. Fluss reads it in the ARROW log
format, in the compacted KV format behind primary-key tables, and converts
it on the way into the lake, so refusing it here would only mean `tbl$lake`
showing a MAP that `tbl` refuses to project.

The two mapping switches reuse the unprefixed, engine-wide names the hive,
paimon and iceberg catalogs already answer to; being unprefixed also keeps
them out of the fluss client configuration for free.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements the metadata surface: the table handle, the Doris schema and
column handles, partition listing, statistics and the table descriptor.

The handle snapshots what split planning will need — primary key, bucket
count and keys, partition keys, lake format, and the fluss table property
map, which for a datalake table is where the fluss coordinator merges the
lake catalog's own connection settings (the Doris catalog is configured
with bootstrap servers and nothing else). Taking them one at a time later
could straddle an ALTER; taking them together cannot.

Column comments are read from the fluss schema, not from the table's row
type: fluss rebuilds that row type from the schema and drops every field
description on the way, so reading the row type would report every column
as undocumented. Columns are reported nullable throughout even though
fluss marks primary-key columns NOT NULL — propagating it would let the
planner fold null-rejecting predicates on this path while the same table
read through its lake sibling (the paimon connector, which reports
everything nullable) keeps them.

Partitions are rendered in the Hive-style `k=v/k=v` naming fe-core parses
back, not fluss's own `v$v`. No escaping is needed: fluss rejects any
partition value outside ASCII alphanumerics, `_` and `-`, so no value can
contain the separators, and none can be SQL NULL.

One statement reads a table once. The handle, the schema, the column
handles and the comment share a per-statement memo, which beyond the round
trips is what stops a concurrent ALTER from being visible to half of one
plan.

Tests come in two layers. The unit layer drives the awkward states a
cluster will not produce on request — a table that vanished, a coordinator
that is down, a partition spec whose map iterates in a different order
than the partition columns. Over it, FlussMetadataClusterTest starts a
real coordinator, tablet server and ZooKeeper in the test JVM and reads
tables created through the fluss client, which is the only way to check
the premises the unit layer assumes. It is named ...Test rather than
...ITCase because surefire's default includes do not match *ITCase, and
that name would leave the class unexecuted under a green build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The regression suites need a fluss cluster to read from. This brings up
ZooKeeper, a fluss coordinator and tablet server, and a Flink cluster whose
SQL client creates the fixture tables once and then idles, so that
`compose up --wait` gates on the fixtures being complete rather than on the
containers merely running.

Fluss 1.0 is not released, so neither image can be pulled: build-images.sh
builds both from a local fluss checkout, which is also why the component is
not part of the default component set yet.

Two things the servers need because Doris runs outside the compose network:
the advertised listeners are the host address plus the published port, and
remote.data.dir is bind mounted at the same absolute path on both sides,
since BE will read the kv snapshots written there directly.

Also adds fluss to both connector lists in build.sh. They were missing it,
so the plugin was never packaged or deployed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A fluss scan range is one bucket of one partition, read as a log range, a
full primary-key range, or a primary-key range unioned with the lake. This
adds the range itself and the two thrift maps that carry it.

The payload is an untyped string map rather than a thrift struct of its
own. The C++ layer holds no fluss logic -- it hands the map straight to
the java scanner in be-java-extensions -- so a struct would only add a
field-by-field transcription in the middle, which is what paimon pays for
in paimon_jni_reader.cpp. es_params and jdbc_params are the same shape for
the same reason, and it keeps a plugin's parameters out of the core IDL.

Only what varies per split rides in the range. Bootstrap servers, table
identity and the client and table options are the same for every split of
a scan, so they go once into TFileScanRangeParams.fluss_properties, next
to the paimon and ES properties already there for that reason; a table
with 100 partitions and 128 buckets would otherwise serialize them 12800
times. Nothing populates the scan-level map yet -- the split planner does,
in the next change.

Partition columns are declared to the engine rather than returned by the
scanner. That is forced by the union read: the lake half of a union is
paimon ranges, the paimon connector already declares its partition keys
that way, and the file-slot / partition-slot split is decided once per
scan node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reads a log table one bucket at a time, from the earliest offset fluss
still holds up to the offset the log had reached when planning ran. That
stopping offset is taken once per partition so every bucket of a partition
stops at the same view of the table; a bucket nobody has written to yields
no range at all.

Partition naming moves into FlussPartitions, shared with the metadata
listing. The pruned partition names the engine hands to planning are the
ones that listing produced, so the two have to render them identically --
rendered separately, planning matches none of them and scans nothing,
which looks like an empty table rather than a bug.

What this cannot serve yet it refuses by name: a primary-key table, and a
table whose tiering has committed to the lake. Reading the latter as
fluss-only would return whatever the log still holds and drop everything
already tiered away -- a query that succeeds with rows missing.
fluss.union_read.mode=disabled is how a user asks for that read on
purpose, and a lake table that has never tiered still falls back to the
log alone, because then the log is the whole table.

Planning only reads metadata, so it is safe under EXPLAIN, which does
reach planScan. The EXPLAIN line it appends is what a regression test has
to read to tell a union read from a silent fallback to a fluss-only one.

The cluster tests that write rows need --add-opens java.nio for Arrow,
fluss's default log format; the BE JVM that will host the scanner already
gets that flag from bin/start_be.sh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Turns one scan range -- one bucket of one partition over a bounded offset
range -- into rows in Doris's vector table, by driving a fluss log scanner
and converting each fluss row column by column.

Where the read stops needs all three of fluss's own conditions, taken from
its bounded reader in KvSnapshotAndLogBatchScanner: drop a record at or
past the stopping offset, stop right after the record before it, and stop
when the fetch has consumed up to the stopping offset without yielding a
record there. The second exists because the record AT the stopping offset
may never be written -- that offset is where the log had got to, not a row
-- so polling on would block forever. The third covers a tail of control
records, which take offsets but are never handed to a scanner.

Byte-valued columns dispatch on the FLUSS type, not the Doris one. The two
do not line up: fluss BYTES maps to Doris STRING unless the catalog opts
into VARBINARY, so one column may be asked for either way, and CHAR and
BINARY are stored fixed-width and read back as nothing without their
declared length.

fluss-client already carries a relocated Arrow, allocator included, so
nothing here declares one; the arrow coordinates its pom marks provided
are not the ones it uses. What it does need is the java.nio add-opens,
which bin/start_be.sh already passes.

Tests run against a real cluster because both ends of the conversion are
someone else's format. They read values back through the STRING mapping
rather than VARBINARY: java-common's getBytesVarbinary decodes a
StringView layout that neither appendVarbinary nor BE's
_fill_varbinary_column writes, so that read-back path cannot check a byte
value. Production is unaffected -- the write side and BE agree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FileScannerV2 gains a fluss table format. The reader holds no fluss logic:
it merges the scan-level fluss_properties with the per-range fluss_params -
the range wins, being the specific half - and hands the result to
org.apache.doris.fluss.FlussJniScanner.

Partition columns are left out of what the scanner is asked for. FE declares
them as path_partition_keys and ships their values on each range, so reading
them per row would repeat what the split already states once, and would lay
the block out differently from the paimon half of a union read.

Only FileScannerV2 is wired. With enable_file_scanner_v2=false the legacy
scanner reports "Not supported create reader for table format: fluss", so a
fluss regression suite has to pin the session variable - the fuzzy session
mode randomizes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VCPjzhwMQuP7nTgdvGVbM
Reads the docker fixtures through a fluss catalog: whole table, projection in
a different order than the schema, predicates, every mapped type including the
all-NULL row, and the partition columns BE materializes from each range rather
than reads.

Two shapes the planner has to get right are pinned through the plan: pruning a
partition must shrink the ranges, not just the partition=N/M line, and a table
that was never written to must plan no range at all. The fixture set gains an
empty table for the latter.

Values are asserted explicitly instead of through a .out baseline, so an
expectation and the fixture that produces it stay in one file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VCPjzhwMQuP7nTgdvGVbM
The build cache extension takes a dependency's type for its file extension
when it hashes a module's inputs, so type "test-jar" sent it looking for a
fluss-server-1.0-SNAPSHOT.test-jar that exists in no repository and the
frontend build failed before compiling anything. The classifier spelling
resolves the very same file. These were the only two modules in the tree
depending on a foreign test-jar, which is why nothing caught it earlier:
the module tests were always run with the build cache turned off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VCPjzhwMQuP7nTgdvGVbM
A query over a partitioned table can need none of the columns the scanner
reads: "select dt, count(*) ... group by dt" wants only the partition
column, and BE materializes that one from the range itself. Fluss rejects
an empty projection outright, so opening the scanner threw and the query
failed. Stand the narrowest legal request in for it; nothing reads the
column that comes back, since getNext iterates over the required fields and
reports the row count alone.

count(*) alone did not reach this: the planner keeps the first column as a
placeholder slot, so only a projection made entirely of partition columns
empties out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VCPjzhwMQuP7nTgdvGVbM
A fluss primary-key table cannot be read the way a log table is: its log is
a change log, so replaying it verbatim returns superseded and deleted rows.
Each bucket is planned instead as its latest kv snapshot plus the change log
that followed it, which the scanner merges by key. A bucket fluss has never
snapshotted carries -1 and the earliest sentinel, and its state is rebuilt by
replaying the whole change log -- equally correct, only slower.

Snapshots are asked for before offsets, and the order is load-bearing: a
snapshot committed between the two calls ends past the offset planning stopped
at, and that bucket would then be read from a snapshot already containing rows
written after the query started while every other bucket stopped where planning
saw it. Log offsets only move forward, so this order keeps every snapshot at or
behind the stopping offset.

EXPLAIN counts primary-key ranges apart from log ranges. The two are read by
different code, and a single total cannot tell a primary-key table planned the
right way from one planned the wrong way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VCPjzhwMQuP7nTgdvGVbM
A primary-key table's log is a change log, so replaying it verbatim returns
superseded and deleted rows. A PK_FULL range is read instead through fluss's
own KvSnapshotAndLogBatchScanner, which merges the bucket's kv snapshot with
the change log that followed it, by key.

Both reads are now fluss BatchScanners -- the bounded log read moves into one,
carrying its three stop conditions verbatim -- so the row loop no longer knows
which kind of range it is draining. The projection differs between them and
that is deliberate: the log path still needs a stand-in column when the
projection is empty, because fluss rejects an empty one, while the primary-key
reader appends the key columns to what it fetches and projects the merged row
back down, so it never asks for an empty projection at all.

The C++ glue needs no change: it merges the two parameter maps and forwards
them, so fluss.kv_snapshot_id reaches the scanner without anything in between
knowing what it means.

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

A fluss primary-key table with no kv snapshot is read by replaying its whole
change log, which is correct and takes a completely different code path from
the one this environment exists to cover: BE reads snapshot FILES that the
fluss container wrote, from the host, at an absolute path both sides share.
With the default ten-minute interval whether a suite exercised that path was
down to when it happened to run.

The server now snapshots every ten seconds and startup does not report the
environment ready until every primary-key fixture has one on disk. That does
not pile up files -- a tablet whose log has not advanced since its last
snapshot is skipped -- and the wait proves a snapshot was taken, not that the
coordinator has committed it (completion is registered in ZooKeeper and the
directory here is created before the upload). Losing that millisecond costs a
suite nothing: the read falls back to the change log and still returns the
right rows.

Adds pk_part, a partitioned primary-key table with an update in one partition
and a delete in another: a partitioned table is snapshotted per partition, so
a merge that crossed partitions loses or resurrects one of them.

KNOWN FAILING, and the reason is not in this commit: reading a kv snapshot
inside BE aborts the process. The RocksDB JNI library that fluss-client
bundles and doris_be define 2576 rocksdb symbols under identical mangled
names, and the executable wins the lookup, so the library's calls land in a
different RocksDB built against a different libstdc++ string ABI. See
plan-doc/HANDOFF.md. The same scanner code passes 10/10 against a real cluster
in a plain JVM, which is the A/B.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VCPjzhwMQuP7nTgdvGVbM
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
A datalake-enabled fluss table now exposes a "lake" system table, so
tbl$lake reads the paimon table its tiering service writes. The read is
delegated whole: the handle comes from an embedded paimon sibling
connector, the engine routes that handle's scan to the sibling's own
planner, and this connector's metadata forwards every per-handle call.

The sibling is built through ConnectorContext.createSiblingConnector, so
the fluss plugin bundles no paimon class at all -- the same contract the
hive gateway uses for its iceberg and hudi tables, and the reason its
zip is unchanged at four artifacts.

Its catalog properties come from the fluss TABLE's properties, where the
coordinator injects the cluster's datalake.paimon.* settings; only a
filesystem catalog is served for now, and anything else fails loud
rather than half-configuring a catalog.

Exactly one sibling per catalog, which is a correctness constraint and
not thrift: two paimon siblings answer "is this handle yours?" with the
same class test, so a second one could never be routed apart from the
first and a table would silently read the wrong warehouse. A second lake
configuration is refused with a message asking for a catalog refresh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VCPjzhwMQuP7nTgdvGVbM
The engine pins the context classloader to the plugin whose object it is
about to call, so everything the fluss connector runs sees the fluss
plugin's loader. That is right until the connector calls its lake sibling:
the sibling's SDK is loaded child-first by ITS plugin, and the parts of it
that discover implementations by ServiceLoader — catalog factories, file
IO, file formats — look them up through the context classloader, where
none of them exist. The failure would surface as a NoClassDefFoundError at
the first lake table read rather than at wiring time.

Route every forward through one helper that pins the loader and restores
the caller's, including on a throw. Building the sibling's metadata is
inside the pin too: that call already opens the lake catalog. The helper
also owns the per-statement metadata memo, so the pin and the shared
instance cannot be forgotten by a new caller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VCPjzhwMQuP7nTgdvGVbM
A table that fluss tiers into a lake was refused outright: reading it as
fluss-only would return just the rows tiering has not moved yet, which is
a successful query with rows missing. It is now read as the union of the
two halves — the lake at the snapshot fluss reports as readable, plus each
bucket's log from exactly the offset that snapshot recorded, so the halves
meet with no row read twice and none skipped.

The lake half is not planned here. It is planned by the paimon sibling
connector this catalog already builds for `tbl$lake`, on a handle pinned
through the SPI's own snapshot hook, and its ranges are mixed into the
same scan node — BE builds a reader per range, so the two kinds coexist.
That keeps the plugin free of any paimon dependency (its zip is unchanged:
connector jar, fluss-client, fe-foundation, jsr305) and gives the lake
half paimon's native readers, deletion vectors and file cache for free.
Nothing paimon-specific is named: the pin is a snapshot id with no
connector options, and what fluss records as the lake snapshot IS the id
the lake returned when tiering committed it.

Three things the halves must agree on. The lake snapshot is read BEFORE
the log's stopping offsets, so it can only be at or behind them; asked the
other way round, a snapshot committed in between would cover rows past
where the log half stops. The lake half is planned on the LAKE's own
column handles — the sibling projects by its own handle type and ignores
anything else, so fluss's handles would leave it reading every column,
the three system columns tiering appends included. And the node properties
of both halves travel in one map, since the engine populates the scan
params once; the shared keys must already be equal, so a difference is
raised rather than resolved by picking a side.

A primary-key table tiered into a lake is still refused: its two halves
have to be merged by key rather than concatenated.

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
Every guard in the metadata gateway tells a lake handle from a fluss one by
asking the sibling whether the handle is its own. Connector.ownsHandle
defaults to false, so a sibling that never overrode it answers "not mine"
about handles it has just produced: every guard falls through, and the first
cast throws a ClassCastException naming two connectors and no cause.

Checked once now, where the handle is born, in both places one is obtained —
the $lake system-table handle and the union read's pinned lake handle. The
message names the sibling's own class, so the fix is on the reader's screen
instead of several layers away.

The test double gains a switch for a sibling that inherits the default,
because that is the shape of a real connector before it is first used as a
sibling — and the only shape a double cannot have by accident.

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

The fluss regression cluster gains lakehouse storage: a paimon warehouse on
a bind mount, the tiering service submitted as a Flink job while the
fixtures are built, and five lake tables.

Building them takes three steps, and both of the unobvious ones are
load-bearing. The rows that belong in paimon are written first; the paimon
row COUNTS are then polled until they match, rather than waiting for a
snapshot to exist, because tiering commits what it has consumed so far and a
fixture frozen half-tiered would leave a lake-only table with a log tail on
some runs and not others. Only then is the tiering job cancelled, and only
then is the log tail written — left running, the service would keep
consuming the tail, and a suite asserting that a table reads as "lake plus
log" would decay into one asserting "lake only" as the environment aged.

test_fluss_lake_only reads the lake through the sibling: its rows, the three
system columns fluss adds, and the type identity between a table and its
$lake — required to hold, and until now checked only by reading both
mappings side by side rather than against the paimon connector running.

test_fluss_union_log leans on comparing the two read modes. required reads
lake plus log; disabled replays the whole fluss log, which still holds
everything because tiering copies rather than moves. Two entirely different
readers over one table, so a seam that is off by a row in either direction
makes them disagree — an assertion a single-mode suite cannot make, and one
the hand-written sibling in the unit tests cannot make either.

Four environment details that each cost a full restart to find are recorded
where they were needed: paimon builds a hadoop Configuration even for a
directory warehouse; the fluss image chowns /opt/fluss to uid 9999 but never
declares USER, so it runs as root and its paimon directories lock out the
flink containers; tiering a primary-key table reads kv snapshot FILES, so
the flink cluster needs remote.data.dir mounted; and a warehouse path with
no scheme is read as HDFS, which fails at scan time rather than at catalog
creation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VCPjzhwMQuP7nTgdvGVbM
A primary-key table that is tiered into a lake was refused outright,
which left a plain SELECT on it failing. Merging its lake with its
change log BY KEY is still not implemented -- the two cannot simply be
concatenated, since the log carries updates and deletes of rows the lake
already holds -- but refusing is the wrong answer, because reading such a
table from fluss alone returns the WHOLE table: fluss keeps a
primary-key table's state in its own kv store, and tiering copies rows
into the lake rather than moving them out. That is the opposite of a log
table, where whatever tiering has aged out of the log lives only in the
lake and a fluss-only read silently loses it.

So auto and disabled now read it the way an untiered primary-key table is
read, from the latest kv snapshot plus the log that followed. What is
lost is speed, not rows. required still refuses it: that mode exists so a
union-read test cannot pass without a union read.

The decision is taken BEFORE the lake snapshot is asked for, because it
cannot depend on the answer -- asked afterwards, a table whose tiering
has not committed yet would be refused under required with "wait for the
tiering service to commit", which is a dead end rather than a reason.

The old refusal also told the reader that switching the lake off returns
less than the whole table. That is true of a log table and false here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VCPjzhwMQuP7nTgdvGVbM
The fixture's primary-key lake table now disagrees with its lake in all
three ways a change log can: one tiered row is updated, another is
deleted, and a key that the lake never saw is added. The last one is new
here, and it is the case a merge of the two halves is most likely to get
wrong -- the rows that exist only in the log have to be emitted exactly
once no matter how many lake files the bucket has.

The suite records what that table reads as today, next to what its lake
holds, so the two can be seen not to be the same thing. Reading it from
fluss alone is complete, so this baseline is also the answer a future
lake+log merge has to reproduce row for row: when that lands, these
recorded results must not change.

Nothing in the result distinguishes a fluss-only read from a correct
merge -- which is what makes it a baseline -- so the plan is asserted
separately for which path actually ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VCPjzhwMQuP7nTgdvGVbM
These suites asserted every result inline. That was a choice made before
there was a cluster to run them on: a .out cannot be generated without
one, and a hand-written .out is worse than none. There is a cluster now,
so the results are recorded the way the rest of the regression tests
record them.

What is NOT a result stays in the code: the EXPLAIN anchors saying how a
scan was planned, the refusal messages, the range-count bounds (which
bucket a row lands in is fluss's choice, so they cannot be exact), and
the two invariants that are comparisons rather than expectations -- that
lake+log and fluss-only return the same rows, and that a table and its
$lake report the same column types.

Three values are deliberately left unrecorded because they are not
reproducible: __bucket and __offset, which depend on the writer's bucket
choice, and __timestamp, which is a wall clock. What is recorded of them
is that every row has all three, within the range each must be in. The
session time zone is now pinned, because TIMESTAMP_LTZ renders through
it and the baseline holds what it rendered as.

Recording the types tables whole also pins how a map, a struct and a
decimal print, which the predicate-based assertions deliberately avoided.
That is the trade: a rendering change now lands here and has to be
re-recorded on purpose. In exchange the type coverage got stronger --
desc now pins the mapped types, and the same row read through fluss,
through paimon and through a kv snapshot produces three baseline lines
that are identical character for character.

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
Reading a tiered primary-key table means BE has to read the key columns of
the log tail whether or not the query projects them -- they are what tells
it which lake rows a newer log record has already replaced. That projection
does not come from FE as slot descriptors the way every projection so far
has; BE assembles it from names and types.

Whether the reader accepts such a projection was an open question, and the
answer decides the shape of the whole feature: if it does not, the key
columns have to be driven straight into the JNI bridge as parameter strings
instead. It does. The reader reads nothing off the columns but their name,
type and partition flag, and both strings the Java scanner projects by are
derived from that -- so a projection with no query behind it drives it the
same as a planned one.

Pins the two derived strings and the block C++ receives the columns into,
because nothing between here and a wrong-columns read would notice. Checked
red by skipping the derivation.

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
…tail

A tiered primary-key table was read from fluss alone: correct, because fluss
keeps such a table's state in full, but it left the lake's columnar files
unread. It is now planned as three parts, per bucket:

  - the sibling's lake splits, wrapped with the offsets of the log tail that
    supersedes part of them (BE drops the lake rows whose keys the tail names),
  - one PK_TAIL range producing the tail's own surviving state, exactly once,
  - PK_FULL for a bucket the lake has never seen, as before.

A split is bound to the tail of its OWN bucket, using the bucket the paimon
connector now reports on each range. Three ways that can go wrong all fail
loud, because each would silently duplicate rows: a split that names no bucket
(an older paimon plugin), a bucket this table does not have (the two are not
bucketed alike), a bucket the lake holds files for but fluss records no tiering
offset for (their metadata disagrees).

Two conditions make the union unsafe rather than merely awkward, and under auto
both give up the lake half instead of failing — the fluss-only read they fall
back to is the whole table:

  - a key or partition column whose values may not compare or render the same
    way on both sides (float, a timestamp Doris rounds, a non-string partition
    column, all of which fluss itself allows). Settled from the schema alone,
    before anything is asked of the lake, so that the answer is the same at plan
    translation time as it is when the ranges are planned;
  - a tail the log no longer holds. Fluss deletes old log segments on a timer
    that does not wait for tiering, and a primary-key table cannot re-read its
    log from the lake, so planning verifies the tail is still there.

required refuses both, as it must: that mode exists so a test can assert the
lake was actually read. EXPLAIN gains suppressedLakeSplits, pkTailRanges and a
degraded= reason, which is otherwise the only thing distinguishing "there is no
lake" from "there is one this query could not use".

The scan keeps the key columns in its tuple through the SPI added for it, so BE
reads them as ordinary projected columns; the projection above the scan drops
them again. Both questions are answered from one memoized resolution, which is
a correctness requirement rather than a saving.

Removes the UNION_PK range that an earlier design would have used.

182 tests, 0 skipped. Ten mutations were confirmed to fail the new assertions,
including binding splits by bucket while ignoring the partition, reading a
missing bucket property as "no tail", skipping the tail guard, and resolving the
union read twice instead of reusing the memo.

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

A primary-key table read as its lake plus its log tail needs the fluss half of
that read: the change log after the offset the lake was tiered at, replayed by
key. Returning that log as it stands returns every intermediate state, so a key
written three times comes back three times.

The three stopping rules of a bounded log range now live in one place
(BoundedLogRecords) instead of two: the log reader drops the change type and
takes the row, the tail reader replays the change types by key. The rule they
exist for -- a fetch that consumed up to the stop without yielding a record
there -- only ever shows itself as a query that never returns, which is exactly
the kind of thing a second copy gets wrong.

What the tail owes is narrower than a whole-bucket read, and every test turns on
it: a key nobody touched in the tail must NOT come back (the lake has it), a key
updated must come back once with the later value, a key deleted must come back
not at all. A delete leaves a tombstone rather than removing the key -- BE's C++
side hides the lake rows for every key this range touched, deleted ones
included, so the tombstone is what makes "this row disappeared on purpose"
countable instead of indistinguishable from a key never written.

Replay needs the primary key whether or not the query selects it, so the reader
appends the missing key columns to what it asks fluss for and keeps the
requested columns at the front; the row loop reads those positionally and never
learns anything was added. The tail is held in memory in a process shared by
every query on this BE, so fluss.union_read.max_tail_rows bounds it and a tail
that outgrew it is refused rather than read.

12 tests against a real cluster, 0 skipped; the 24 existing log and primary-key
tests stay green through the refactor. Mutation run 7 of 8 red: ignoring
deletes, removing the key instead of tombstoning it, reading from the start of
the log, never reaching the ceiling, dropping the primary-key guard, accepting
an empty range, and putting the key columns anywhere but the front. The eighth
-- admitting a record AT the stopping offset -- survives because it is
unreachable over a contiguous log: the rule below it stops at stop-1 first, so
only a gap (control records) can reach it and a client cannot produce one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VCPjzhwMQuP7nTgdvGVbM
morningman and others added 5 commits August 4, 2026 04:02
…tail replaced

Planning already produces the lake half of a tiered primary-key table wrapped
with the offsets of the log tail that supersedes part of it. This is the reader
that honours that: it reads the tail's keys once per bucket and drops the lake
rows those keys name. What the tail ended up saying about each key arrives
separately, as its own range, so every row is produced exactly once and a key
the tail ended by deleting is produced not at all.

The lake half is read entirely by the paimon sibling's own reader stack -- every
split is forwarded to it untouched, so native ORC/Parquet, serialized JNI
splits, deletion vectors, schema evolution and split scheduling all stay its
answer. This reader only removes rows.

The filter runs on the block that comes back, in table-schema terms, rather than
inside the sibling's file scan request. The key columns are ordinary projected
columns there (planning keeps them in the tuple for this read), already resolved
by the column mapper whichever way the lake table needs -- by name or by field
id. Pushing the predicate down into the file request would save decoding the
non-key columns of the suppressed rows, which is bounded by the size of the
tail, and would cost a second place that resolves key columns against a file
schema plus a separate implementation for the JNI child.

The tail is read as a plain bounded log range, not as its surviving state:
a lake row whose key the tail deleted has to disappear just as surely as one it
updated, and the surviving state would not name it. One bucket's tail is one
read however many of its splits this BE was given, held by the scan node's
split cache under the tail's own offsets, so an unpartitioned table's bucket
cannot collide with a partition's. Its size is bounded by the same limit and
counted the same way as the Java half counts it -- change log records over the
same offsets -- so a table over the limit is over it on both sides rather than
in whichever half happened to run first.

Three things fail loud instead of degrading, because each returns the superseded
rows a second time and no row count reveals it: a key column the scan does not
project (planning promised it would), a wrapped split with no tail bound to it,
and a block read before a tail was bound. Aggregate pushdown is withheld from
the lake half for the same reason: a COUNT answered from paimon's file metadata
would count the rows this reader exists to remove, without ever producing a
block to remove them from.

22 tests, 0 skipped; the 226 paimon, iceberg and table-reader tests around them
stay green. Twelve mutations were confirmed red, including suppressing nothing,
waving through a missing suppression, one cache entry for every bucket, a limit
that never fires, an empty tail range, a skipped key column, forwarded aggregate
pushdown, reading the tail as its surviving state, and reading the tail of a
pruned split.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VCPjzhwMQuP7nTgdvGVbM
A fluss primary-key table read as its lake plus its log tail plans its lake half
as the paimon sibling's own split with a suppression descriptor attached, under
its own table format name. Without a branch for that name the scanner refuses
it outright, and the half of the read that the descriptor exists for never
happens.

Whether such a split can be read at all is the paimon answer, asked of the
paimon payload it carries, rather than a second rule that could start
disagreeing with paimon's after the sibling changes how it plans a split. That
also keeps the C++ paimon reader's splits on the V1 fallback, where there is no
branch for this format -- a clean refusal rather than a lake half read without
its suppression.

The reader-follows-range logic already covers a scan node holding all three
kinds of range at once, which this one does.

Checked red by removing the format from the accepted set.

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

test_fluss_lake_pk already recorded what such a table reads as, off a read that
went to fluss alone -- an answer the merge of lake and log tail has to reproduce
row for row. It does: that recorded file is unchanged here, which is the whole
point of having recorded it before the merge existed.

What a single-bucket fixture cannot show is WHERE each half comes from, since
merging per bucket and merging per table are the same arrangement when there is
one bucket. Three fixtures separate them: lake_pk_multi spreads nine keys over
three buckets and gives only some of them a tail, lake_pk_part stands its three
partitions differently towards the lake -- one lake plus tail, one lake alone,
one written after tiering stopped and therefore read whole from fluss inside the
same scan -- and lake_pk_cold keeps nothing in the log at all. Binding a tail to
the wrong bucket suppresses nothing, because a key lives in exactly one bucket,
and the rows that tail was meant to replace come back beside their replacements;
of the suites here only the multi-bucket one notices.

There is deliberately no deletion-vector fixture. Fluss does forward a
'paimon.deletion-vectors.enabled' table property into the paimon table it
creates, which was verified against this environment, but its tiering service
then writes data files and no index -- and paimon reads such a table as empty. A
fixture that reads as empty asserts nothing.

Two suites change with the code that landed before this one. `required` no
longer refuses a primary-key table, so test_fluss_lake_pk asserts the merge and
compares all three modes instead of expecting a refusal, and the refusal block in
test_fluss_union_log becomes an assertion that the two table kinds still plan
differently. Retrying the fixture build is fixed as well: init.sql drops its
fluss database but leaves the paimon tables behind, and fluss refuses to create a
lake table over a paimon table that already holds rows, so any attempt that
failed halfway used to doom every attempt after it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VCPjzhwMQuP7nTgdvGVbM
A fluss partition carries its value nowhere but in its own name, and fluss
allows only ASCII letters, digits, '_' and '-' there -- so a value holding
anything else is rewritten on the way in. A FLOAT 1.5 is named 1_5; a
TIMESTAMP 2026-01-01 01:02:03 is named 2026-01-01-01-02-03. The substitution
is many-to-one and Doris is handed the name, not the value, so nothing reads
those back.

Fluss creates such a table without complaint and DESC looks ordinary, so
until now the first sign of trouble came from fe-core's partition parser:
"failed to convert partition [1_5] to list partition" -- naming neither the
column, nor its type, nor fluss, nor what to do instead.

Decide it from the table's schema instead, before the partitions are listed,
so the answer is the same whether the table has any yet or not, and say which
column and which type. CHAR, STRING, BOOLEAN, the integer family and DATE are
kept verbatim and pass; BINARY and BYTES are named with the hex text of their
bytes, which reads back as the text it is unless enable.mapping.varbinary
asks for a VARBINARY column, so their verdict is the one the catalog decides.
Every verdict was established against a fluss cluster rather than reasoned
from the naming rules, and the switch is exhaustive over fluss's type roots:
a type a future release adds to its partition-key whitelist is refused with a
message rather than waved through into the parser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VCPjzhwMQuP7nTgdvGVbM
…usand rows

The seven suites so far each follow one path in depth, over fixtures small
enough that a wrong answer is visible by reading it. Five things that leaves
uncovered, and twelve fixtures for them:

- Nesting. The type fixtures carry one column per fluss type and never more
  than one level, but an element decoder is chosen per level: being right
  about MAP<STRING,INT> says nothing about the MAP<STRING,ROW<..>> beside it.
  log_nested, pk_nested and lake_nested carry every combination of the three
  constructors, three levels deep, through all three row formats -- arrow,
  compacted and parquet-through-paimon -- with rows that are NULL at the outer
  level and rows whose collections hold NULL elements, which is a different
  thing to get wrong.

- Partition column types. Everything so far partitions by STRING, which is
  also the one type that cannot tell a rendering bug from a working one.
  part_types partitions by one column of every type whose value survives
  fluss's partition naming and prunes on each of them; part_ts is the
  counter-case, and lake_pk_part_int is the primary-key table whose halves
  must not be matched by a non-STRING partition value -- covering the
  fallback that until now only ever fired on tables with no lake at all.

- Size. big_log and big_pk hold 100000 rows each, tiered, with a tail. A scan
  that loses its last partial batch, a stopping offset applied to one bucket,
  a suppression set built per split rather than per bucket: none of those can
  show up on nine rows, where wrong and right are the same arrangement. Each
  is read through a union-read catalog and a disabled one, so one fixture
  answers the log, primary-key and union-read cases and the two paths are
  compared against each other. Every column is derived from a sequence, so
  the aggregates are closed forms rather than transcriptions.

- Compound predicates. A disjunction over a partition column has to keep
  every partition it names; a predicate that reached only one half of a union
  read returns an answer short by exactly the other half's matching rows, and
  a plausible one. Three-valued logic gets its own block.

- The rest of what a catalog answers for: tables with nothing in them
  (pk_empty, lake_empty, and the aggregates that are 0 and NULL at once), the
  fluss TIME column Doris has nowhere to put, the ways a query is supposed to
  fail, and the ordinary SQL a user reaches for -- joins, subqueries, set
  operations, CTAS and insert-select into an internal table.

Every recorded block was checked against the fixture's own literals, and each
new baseline was perturbed by one value to confirm it fails when it should.
The existing baselines change only by the new table names in two listings;
no recorded data row moved.

Co-Authored-By: Claude Opus 5 <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?

@Gabriel39 Gabriel39 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I found two current CI blockers and one scan-wide memory-bound issue in the new union-read path. Details are inline.

# The same library also duplicates zstd, lz4, snappy, bzip2 and zlib symbols. Those are C
# ABIs, stable across versions and layout-free, so they are left alone until something
# shows otherwise -- unlike RocksDB, whose C++ objects are what actually corrupt.
target_link_options(doris_be PRIVATE "-Wl,--exclude-libs,librocksdb.a")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Please make this linker option platform-specific. --exclude-libs is a GNU/ELF linker option; Apple ld rejects it with ld: unknown options: --exclude-libs, which is exactly why the current BE UT (macOS) check fails while linking doris_be. Gate this option to the supported ELF platforms (and use a Darwin-specific solution only if the symbol-hiding behavior is needed there) so macOS can still build.

@@ -0,0 +1 @@
org.apache.doris.connector.fluss.FlussConnectorProvider

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Please add the ASF license header using # comments, as the existing connector service descriptors do. This new one-line descriptor currently makes License Check fail with an invalid/missing license header, so the PR cannot merge as-is.

const auto cache_key = fmt::format("fluss_union_tail:{}:{}", tail.spec.size(), tail.spec);
Status read_status = Status::OK();
bool cache_hit = false;
auto* cached = options.cache->get<SuppressionKeys>(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Please bound or release these cached tail-key blocks at the scan level. fluss.union_read.max_tail_rows limits one partition/bucket tail, but ShardedKVCache has no eviction and lives for the entire FileScanLocalState, so every tail touched by every partition and bucket remains resident until the scan finishes. A partitioned table can therefore retain partition_count * bucket_count * max_tail_rows key records (the default is two million per tail), even after all lake splits for earlier tails have completed, and hit the query/BE memory limit although the input can otherwise be streamed. Consider a scan-wide byte/row budget plus eviction/ref-counting after the last split using a tail, and deduplicate touched keys before retaining them.

return table_format == "NotSet" || table_format == "tvf" || table_format == "hive" ||
table_format == "iceberg" || table_format == "paimon" || table_format == "hudi";
table_format == "iceberg" || table_format == "paimon" || table_format == "hudi" ||
// A lake split of a fluss primary-key table read as its lake plus its log tail. It is the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Please handle the legacy-scanner selection explicitly. All Fluss reader dispatch added by this PR exists only in FileScannerV2, but FileScanLocalState::_should_use_file_scanner_v2 still honors enable_file_scanner_v2=false; in that supported session configuration the scan falls into FileScanner, whose FORMAT_JNI dispatch has no fluss/fluss_union branch and returns Not supported create reader for table format. Either force V2 for Fluss ranges (if the variable is only a preference for scans supported by both implementations), or fail during planning with a clear requirement; add a test with the variable disabled so this does not remain a late BE runtime surprise.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants