Skip to content

[feature](paimon) Add Paimon 2.0 transactional writes - #66790

Open
damokelis wants to merge 26 commits into
apache:masterfrom
damokelis:codex/paimon-2.0-master
Open

[feature](paimon) Add Paimon 2.0 transactional writes#66790
damokelis wants to merge 26 commits into
apache:masterfrom
damokelis:codex/paimon-2.0-master

Conversation

@damokelis

@damokelisdamokelis commented Aug 14, 2026

Copy link
Copy Markdown

What problem does this PR solve?

Issue Number: close#66784

Related PR: #65868

Problem Summary:

The Connector SPI path on master can read Paimon tables, but it still uses Paimon 1.3.1 and does not provide a complete external-table write path. The implementation from branch-4.1 cannot be applied directly because catalog, transaction, planning, scan, and write contracts have moved to the connector modules on master.

This draft:

  • upgrades the FE connector, BE JNI scanner/writer, and shaded Hive runtime to Apache Paimon 2.0.0;
  • adds Connector SPI write planning and a Paimon table sink backed by the Paimon Java SDK;
  • aggregates BE commit messages in the FE transaction and provides acknowledgement, retry reconciliation, abort cleanup, and statement-level commit;
  • supports external catalog CREATE TABLE, DROP TABLE, INSERT INTO, INSERT OVERWRITE (full-table and static-partition), ALTER TABLE ... DROP PARTITION, and CTAS paths;
  • supports column schema evolution (ALTER TABLE ADD/DROP/RENAME/MODIFY COLUMN) for both flat and nested schemas;
  • supports row-level DELETE, UPDATE, and MERGE INTO on primary-key tables, and DELETE, UPDATE, and MERGE INTO on append-only tables with deletion vectors enabled;
  • auto-heals every FE's table cache after an out-of-band schema change from an external engine (Spark/Flink/…) sharing the same warehouse, via an opt-in background poller;
  • maps primitive and complex values through Arrow, including Paimon 2.0 BLOB, VARIANT, and VECTOR representations;
  • supports Parquet and Vortex reads, with automatic JNI fallback for schemas that the native reader cannot decode;
  • rejects invalid Vortex schemas before the remote catalog create call.

Release note

Support transactional writes to Apache Paimon 2.0 external catalogs, including CREATE/DROP, INSERT, INSERT OVERWRITE (full-table and static-partition), DROP PARTITION, CTAS, column schema evolution, row-level DELETE/UPDATE/MERGE INTO on both primary-key and append-only (deletion-vector) tables, and opt-in automatic healing after an out-of-band external schema change.

Check List (For Author)

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

Current validation:

  • FE Paimon connector: 552 tests, 0 failures, 1 skipped.
  • C++ focused unit tests: 13/13 passed.
  • Linux release BE incremental build: passed.
  • Changed-line clang-tidy for all 5 modified C++ files: passed.
  • clang-format, checkstyle, and git diff --check: passed.
  • TKE live deployment:
    • backend ID 1786733167633 remained stable and is alive on 172.21.48.181;
    • deployed image digest: sha256:8f82d5cf69147101a15e6c46f90523391c1a07a0ea28226901fd36bf677f40b6;
    • doris_be SHA256: 055313b210249c0f595f97211855590d0e95336655bbdd03de93b5bc9d9f4887;
    • Paimon scanner SHA256: 0a9b83bb92f376275ef9a428fdc333d414e60c0813a3cad4c175c4b9e44b4b8e;
    • COS Parquet+VARIANT query returned the expected rows, and EXPLAIN VERBOSE reported SplitStat [type=JNI] with force_jni_scanner=false;
    • Vortex+VARIANT create was rejected before the REST catalog call, with no residual table;
    • 28/31 continuously tiered COS tables completed exact COUNT(*) scans; the remaining three were blocked by external COS getFileStatus API timeouts, including after raising the S3A request timeout from 15s to 120s. Their Paimon snapshot/file metadata remained readable.
  • Continuous CDC remained healthy during validation:
    • both DTS-to-Fluss tasks kept advancing with queues draining to zero;
    • Fluss-to-Paimon tiering checkpoints advanced through checkpoint 2347 with no failed table epochs.

Production rollout on a disaggregated-storage cluster (2026-08-20):

The branch was built as a disaggregated-storage image set (build.sh --be --fe --cloud, docker/runtime/{be,fe,ms} entrypoints) and rolled out to a production Doris cluster (3 FE + 4 BE, shared FoundationDB metastore) that already serves other lakehouse workloads. This tracked three prior rollout attempts and their fixes:

  • Attempt 1/2 hit a be_exec_version protocol mismatch during the mixed-version rolling window and a transient brpc exchange-unavailability window (5-15s) caused by simultaneous BE pod restarts under the StatefulSet controller — both were operational/rollout-sequencing issues, not code defects. Root-caused via canary reproduction with isolated pod-kill/rolling experiments.
  • Attempt 3 hit a real code defect: static-partition INSERT OVERWRITE aborted the BE with DORIS_CHECK_EQ(column_names.size(), block.columns()). Root cause: BindSink materializes the partition literal into the projected output (full schema including the partition column), but PaimonWritePlanProvider still built TPaimonTableSink.column_names from handle.getColumns(), which excludes the partition column in the static-partition case. Fixed in 2147f234 by switching to handle.getBoundTargetColumns() (the full bound schema) when a static partition is present. Covered by a new unit test asserting column_names size matches the bound schema, not the INSERT column list.
  • Attempt 4 (this build, 2147f234) succeeded: all 7 components rolled node-by-node (BE first, then FE follower → follower → master) with the operator scaled to zero to avoid partition resets, verified Alive/zero-restart at each step, and confirmed static-partition INSERT OVERWRITE, idempotent partition re-overwrite, and full-table INSERT OVERWRITE against real COS-backed Paimon tables post-rollout. Ingestion lag stayed ≤14s throughout with no interruption.

Post-rollout stress test (2026-08-20, 913M rows, isolated catalog against real COS): insert-select throughput peaked at 730K rows/s (780M-row backfill tranche in 1069s), with 64 output files averaging 245MB (near the 256MB target, no fragmentation). A 913M-row count(*) returned in 6.6s and a GROUP BY + count(distinct) aggregation in 9.2s. The full DML/DDL matrix (INSERT/UPDATE/DELETE on primary-key tables, MERGE INTO, DELETE on deletion-vector tables, ALTER ADD/RENAME/MODIFY/DROP COLUMN, CTAS, time travel, concurrent writes to the same table, and transaction atomicity under a server-side KILL QUERY) passed. Production stayed at zero restarts and ≤5s ingestion lag throughout.

The stress test surfaced one caching defect, since root-caused and fixed in this PR ([fix](paimon) evict the CachingCatalog's frozen Table on invalidateTable/Db/All): on a multi-FE cluster, ALTER TABLE ... ADD COLUMN left every FE other than the master serving a frozen pre-ALTER Table object out of the paimon SDK's CachingCatalog (column DDL forwards to the master, and only the mutating catalog instance self-invalidates; the default meta.cache.paimon.table.ttl-second is 24h). The metadata path already reads the latest schema live (schemaManager().latest()), so DESC/binding saw the new column — but the scan path serializes the frozen Table to the BE, and once a post-ALTER commit creates an overlapping sorted run (a JNI merged-read split), PaimonJniScanner failed every read on those FEs with The jni reader fields' size {N} is not matched with paimon fields' size {M}. REFRESH TABLE could not heal it despite correct routing on every FE, because PaimonConnector.invalidateTable dropped the three Doris-side caches but never touched the CachingCatalog. The fix routes all three invalidation scopes through the paimon catalog's own eviction API; verified on a 3-FE disaggregated cluster where the follower-read repro (create → write → ALTER → write → SELECT * on a follower) failed deterministically before the fix and passes after, plus a unit test that reproduces the frozen-Table state against a real CachingCatalog with an out-of-band ALTER. Until a deployment carries this fix, the workaround is ALTER CATALOG <name> SET PROPERTIES ("meta.cache.paimon.table.ttl-second"="0"). Re-verified the full DML/DDL matrix against a fresh 920M-row backfill after deploying this fix to production (FE only, BE unchanged): tier-4 backfill throughput held at 1075s vs. 1069s pre-fix (no regression), and the follower-ALTER repro plus a 3-round consecutive ALTER ADD/ADD/DROP-with-immediate-write-and-read stress passed cleanly.

Three follow-up gaps identified during that validation, all closed in this PR:

  • ALTER TABLE ... DROP PARTITION was rejected on every external table (not Paimon-specific — the allow-list in AlterTableCommand never included it). Paimon's own InnerTableCommit (the same commit handle INSERT OVERWRITE already uses) already exposes truncatePartitions(List<Map<String,String>>), so this was pure plumbing: an SPI seam (ConnectorTableDdlOps#dropPartitions), a PaimonConnectorMetadata implementation that resolves Doris's displayed partition name back to Paimon's native spec (reusing the same rendering SHOW PARTITIONS already shares, so IF EXISTS gets existence-checking for free), and a one-shot newCommit(user).truncatePartitions() call — no schema change, so no PaimonConnectorTransaction involvement.
  • UPDATE / MERGE INTO on append-only (no primary key) tables with deletion-vectors.enabled were rejected with "Only DELETE is supported", even though DELETE already worked there. The missing piece: an UPDATE/MERGE needs deletion-vector marks for the matched rows' old positions AND the new row values appended as fresh data, in the SAME commit. PaimonJniWriter.prepareCommitMessages() already merged the plain writer's data-file CommitMessages with the deletion-vector collector's index-file CommitMessages into one list every write — the write-side machinery for "both halves in one commit" already existed; the row-level-DML gate just refused to route non-DELETE operations there. Extending the same RowKind-tagged dispatch primary-key UPDATE/MERGE already uses (a matched-update/delete row records its locator via the existing PaimonDeletionVectorCollector, an insert/replacement row writes through the normal append path) closed it.
  • An out-of-band ALTER from an external engine (Spark/Flink/… on the same warehouse) produced no Doris event, so every FE's CachingCatalog — and the separate fe-core ExtMetaCache — stayed stale up to the 24h TTL with no path to a manual REFRESH. Paimon exposes no schema-change listener, so this needed an opt-in background poller (meta.cache.paimon.external-change-poll-interval-second, <= 0 disables it — unchanged prior behavior by default) that compares each cached table's held vs. latest schema id (one small metadata-file read, no data scan) and evicts on drift. The first wiring only reached the connector's own cache (PaimonConnector#invalidateTable) — a cluster test then showed the query-facing symptom was still only half-fixed: the connector healed internally, but a query still bound against the stale column list, because fe-core's ExtMetaCache is a separate cache the connector module architecturally cannot reach. Closed with a new SPI seam, ConnectorContext#notifyExternalTableChanged (default no-op), that DefaultConnectorContext implements by resolving the remote names to the catalog's loaded local table and calling RefreshManager.refreshTableInternal directly — the same fe-core-side refresh a manual REFRESH TABLE triggers. Re-ran the identical out-of-band-ALTER repro after this half landed: the new column appeared with zero manual REFRESH, confirmed via the FE log showing PaimonExternalChangePollerRefreshManager.refreshTableInternal in sequence.

fe-connector-spi + fe-connector-paimon: 600 tests, 0 failures (up from the 599 at the CachingCatalog-fix commit); be-java-extensions/paimon-scanner: 85 tests, 0 failures; fe-core AlterTableCommandTest (17) + DropPartitionTest (3): 0 failures. Checkstyle clean across all four touched modules. All three closed on a 3-FE + 4-BE disaggregated cluster (doris-verify): DROP PARTITION leaves sibling partitions untouched and IF EXISTS is silent on a missing one; UPDATE/MERGE INTO on an append-only deletion-vector table update in place with no row-count drift; the external-change poller genuinely self-heals a follower's stale schema with no manual REFRESH.

Requesting a committer to trigger /review: this PR is ready_for_review and CI-green on the checks that don't require repo permissions (Check large file, Sync review status), but code-review has been stuck PENDING since — per the bot's own message — "Trigger /review to start automated review" requires a MEMBER/OWNER/COLLABORATOR to post the comment. I've posted /review twice (most recently for e9ba7bf5, the CachingCatalog fix) with no effect from this account. Would appreciate anyone with the right permissions triggering it so the full buildall pipeline and automated review can run.

  • Behavior changed:

    • No.
    • Yes. Paimon external catalogs gain transactional DDL/DML writes and use Paimon 2.0.0.
  • Does this need documentation?

    • No.
    • Yes. A follow-up documentation PR will describe supported catalog properties, statements, and type mappings.

Check List (For Reviewer who merge this PR)

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

@hello-stephen

Copy link
Copy Markdown
Contributor

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

Please clearly describe your PR:

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

@damokelis
damokelisforce-pushed the codex/paimon-2.0-master branch 2 times, most recently from 48d7751 to 01d75c2CompareAugust 15, 2026 07:46
@damokelis

Copy link
Copy Markdown
Author

Pushed 4 new commits extending this PR from read/insert support to the full write surface for Paimon 2.0:

  • [fix](build) hive-shade artifact via explicit outputFile (clean builds failed with main artifact does not exist)
  • [fix](filesystem) accept the dotted s3.path.style.access alias every existing regression catalog uses (silently defaulting to false breaks hostname-addressed S3-compatible endpoints; CI never saw it because an IP endpoint force-switches the SDK to path style)
  • [feature](paimon) column schema evolution — ALTER TABLE ADD/DROP/RENAME/MODIFY COLUMN, flat and nested dotted-path, with nullability/comment change gates and a statement-fence pin that no longer time-travels the schema generation
  • [feature](paimon) row-level DELETE / UPDATE / MERGE INTO — keyed writes on primary-key tables (operation-tagged stream), deletion-vector DELETE on unaware-bucket append tables (merging with existing vectors), actionable rejections elsewhere

Capability matrix as implemented and verified end-to-end (minio + FE + BE from this branch, both new regression suites green):

table shapeALTERDELETEUPDATEMERGE
primary-key✅ flat+nested✅ keyed✅ incl. matched-delete
append-only + deletion vectors (unaware bucket)✅ DV write + vector mergerejected with hintrejected
append-only w/o vectors, bucketed appendrejected with hintrejectedrejected

Two pre-existing issues surfaced while validating and are worth separate attention: (1) several assembly descriptors reference reactor modules their pom does not declare (e.g. fe-core's make-fe-lib packaging hadoop-deps), so a clean mvn package hits isn't a file unless retried — build.sh's retry masks it; (2) ColumnPath.fromDotName has no callers — nested column DDL only enters through the parser's multipart identifier path.

@damokelis

Copy link
Copy Markdown
Author

/review

1 similar comment
@damokelis

Copy link
Copy Markdown
Author

/review

@damokelis

Copy link
Copy Markdown
Author

Found while smoke-testing the DROP PARTITION / RENAME·TRUNCATE TABLE / append-only UPDATE-MERGE features on a production disaggregated-storage Doris cluster running this branch (commit base 2147f234, images fe/be-master-paimon2-2147f234-r9/r6).

Symptom: UPDATE against a Paimon primary-key table fails when the assignment is an un-cast decimal literal targeting a DOUBLE/FLOAT column:

CREATETABLEpk_orders (id BIGINT, region STRING, amount DOUBLE, dt DATE)
ENGINE=paimon PARTITION BY LIST(dt)()
PROPERTIES ('primary-key'='id,dt', 'bucket'='4');
INSERT INTO pk_orders VALUES (1,'east',100.0,DATE'2026-08-01'); -- succeedsUPDATE pk_orders SET amount =999.0WHERE id =1; -- fails
[JNI_ERROR]JNI exception in JniPaimonWriter::write: RuntimeException: PaimonJniWriter write failed: bytes=1272 | CAUSED BY: ClassCastException: class org.apache.paimon.data.Decimal cannot be cast to class java.lang.Double (org.apache.paimon.data.Decimal is in unnamed module of loader org.apache.doris.common.classloader.JniScannerClassLoader; java.lang.Double is in module java.base of loader 'boot...

Workaround confirmed: explicit cast avoids it —

UPDATE pk_orders SET amount = CAST(999.0AS DOUBLE) WHERE id =1; -- succeeds

Failure is atomic — the target row/table is left untouched after the failed UPDATE, no partial write observed.

Scope check: this looks distinct from the schema-drift issue 9c5c6ea9 already fixed (that one was about PaimonWritePlanProvider#validateBoundColumns rejecting writes due to a DECIMALV3 type-name mismatch between bind-time and execute-time schemas). Here the write is accepted and reaches the JNI writer, but the literal's inferred DECIMALV3 runtime value is never converted to the target column's declared type before being handed to JniPaimonWriter::writeINSERT ... VALUES (..., 999.0, ...) into the same DOUBLE column works fine, so the gap seems specific to the UPDATE row-level-DML assignment-expression path (likely somewhere around PaimonWritePlanProvider / the JNI writer's value materialization, haven't pinned an exact file:line yet).

Happy to help narrow this down further if useful — let me know if a minimal repro project/branch would help.

moke-HUand others added 8 commits September 3, 2026 04:30
Paimon 2.0 stores VARIANT as a physical value/metadata struct and ships the Vortex format in a separate module. Native Parquet reads expose the physical struct instead of the logical value.
Encode legacy Doris VARIANT columns into the Paimon binary struct, decode JNI reads into both VARIANT generations, route recursive VARIANT projections through JNI, and package the Vortex runtime. Preserve scalar, SQL NULL, encoded JSON null, row tracking, and native scalar-only read behavior.
Tests: connector reactor (551 tests); Paimon scanner reactor (85 tests); Paimon regression suite; focused C++ tests; clang-format; git diff --check.
The shade plugin runs in an early phase and reads project.getArtifact().getFile(),
which is only populated at package time, so a clean build fails with 'main artifact
does not exist'. Point shade at an explicit outputFile instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
moke-HUand others added 18 commits September 3, 2026 04:30
Every existing paimon/iceberg regression catalog spells the path-style flag as
s3.path.style.access (dotted), but the typed storage properties only recognize
use_path_style and s3.path-style-access. With the flag silently defaulting to
false, an S3-compatible endpoint addressed by hostname goes virtual-hosted
(bucket.host) and fails DNS; CI never noticed because its minio endpoint is an
IP, which the AWS SDK force-switches to path style. Recognize the dotted alias
in both the S3 and MinIO property models.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…E/MODIFY COLUMN
Implement the full connector column-evolution surface for Paimon 2.0 tables,
flat and nested (dotted-path), on top of paimon SchemaChange:
- PaimonCatalogOps grows an alterTable seam (Identifier, List<SchemaChange>)
surfacing paimon's three checked catalog exceptions;
- PaimonConnectorMetadata implements the six flat and five nested column ops.
MODIFY COLUMN uses the three-arg updateColumnType(..., keepNullability=true)
plus explicit isNullableSpecified()/isCommentSpecified() gates, so a plain
type widening cannot silently reset NOT NULL or wipe a comment;
- the connector declares SUPPORTS_NESTED_COLUMN_SCHEMA_CHANGE so nereids
admits dotted-path column DDL;
- validateRowLevelDmlMode is the per-table-shape gate the row-level DML
transform consults: primary-key tables carry DELETE/UPDATE/MERGE, an
unaware-bucket append table with deletion vectors carries DELETE only
(with an actionable ALTER TABLE SET hint when vectors are off), and
bucketed append stays rejected;
- applySnapshot routes the statement-fence pin through pinOptionsToSnapshot so
it carries PRESERVE_BOUND_SCHEMA: the pin fixes the DATA version only. A bare
scan.snapshot-id would make paimon's copy() time-travel the SCHEMA to the
pinned snapshot's generation, breaking every read issued between an ALTER
and the next snapshot (the alter bumps the schema without a snapshot).
Covered by PaimonConnectorMetadataColumnEvolutionTest (19 cases), an
env-gated live-warehouse test, and the test_paimon_schema_evolution
regression suite (flat + nested + post-evolution DELETE/UPDATE).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implement row-level DML for Paimon 2.0 tables end to end, with the writer
consuming one uniform row shape per operation:
- primary-key tables: DELETE re-tags the full data row RowKind.DELETE (the
merge engine cancels it by key); UPDATE and MERGE arrive as an
operation-tagged stream ([operation, locator, data...]) whose tags the JNI
writer maps to keyed upserts (1/3/4) and deletes (2/5);
- unaware-bucket append tables with deletion vectors: DELETE records the
scanned (file, ordinal) locator into a DV index via
BaseAppendDeleteFileMaintainer, merging with existing vectors; UPDATE/MERGE
on any append shape stay rejected (they need a vector-plus-append write);
FE plan side:
- PaimonRowLevelDmlTransform + registry entry; both transforms now claim
tables by CONNECTOR IDENTITY first (catalog type), not capability alone —
with two connectors declaring row-level capabilities, registry order must
not decide whose plan shape a table gets;
- PaimonRowLevelDeletePlanBuilder projects [data columns..., locator], the
sink's column_names contract; the iceberg base keeps [operation, locator];
- PaimonRowLevelMergePlanBuilder anchors MATCHED on a primary-key column via
the new buildMatchedAnchor hook: paimon's locator is synthetic-NULL on
keyed scans, so the base rowid-IS-NOT-NULL anchor would classify every
target row as NOT MATCHED (matched UPDATE degrades into an upsert-masked
insert, matched DELETE into a silent no-op);
- RowLevelDmlRowIdUtils generalizes the rowid column name across connectors;
- PaimonScanPlanProvider preserves the statement-bound schema generation on
the fence pin (applyOptionsWithoutTimeTravel + a preserve-aware
dropCatalogLoader rebuild) so the serialized-table read matches planning;
BE side:
- a nullable synthetic __DORIS_PAIMON_ROWID_COL__ STRUCT(file_path,
row_position), materialized by the native reader from the split's data-file
path + row ordinal; a merged (keyed) task has no single backing file and
materializes NULL — the JNI scanner mirrors this for its splits;
- the JNI writer validates the DV shape (vectors on + unaware bucket),
excludes synthetic leader columns from the partial-column merge-engine
check, and decodes the locator with its declared field names;
- TPaimonWriteMode grows DELETE and MERGE.
Covered by PaimonRowLevelDeleteTest / write-provider tests, env-gated live
gate tests, and the test_paimon_row_level_delete regression suite (PK
delete/update/merge incl. matched-delete, DV delete + vector merge, and the
three rejection shapes).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l DML
Generated with -genOut against a minio-backed cluster built from this branch and
verified stable across two subsequent validation runs each. The evolution suite
refreshes the table after every ALTER and reads rows before the post-add desc:
the external schema cache reloads asynchronously, and the framework's
back-to-back execution can otherwise observe the pre-alter generation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…identity gate
rowIdColumnName falls back to the iceberg locator name when no synthetic
declaration is reachable (a partially-mocked table, or a connector that has
not appended its synthetic columns yet) instead of failing the synthesis for
a NAME the bind step re-validates anyway. The iceberg transform test now
stubs the catalog type its handles() identity gate reads, and asserts a
capable NON-iceberg (paimon) table is not claimed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…a projection
A static PARTITION(col=val) write on a connector that consumes the partition
value from the row (requiresMaterializeStaticPartitionValues, e.g. Paimon)
never reached the materialize block in BindSink: the block sits behind the
requiresFullSchemaWriteOrder gate, which a name-mapped connector does not
declare. The PARTITION-clause literal was NULL-filled, so Paimon's overwrite
commit rejected the rows as __DEFAULT_PARTITION__ (does not belong to this
partition).
Widen the gate to also take statements that actually carry a static spec on a
materializing connector. A plain INSERT keeps the connector's name-mapped
semantics (and its partial-column validation) byte-unchanged; hive, iceberg
and maxcompute already declare requiresFullSchemaWriteOrder and are untouched.
Covered by four new cases in test_paimon_row_level_delete: static-partition
overwrite replaces exactly the named partition (sibling survives), a rerun is
idempotent, and full-table overwrite keeps its replace-all semantics.
…, not the INSERT list
A static-partition INSERT OVERWRITE crashed one BE in production with
DORIS_CHECK_EQ(column_names.size(), block.columns()) failing.
BindSink materializes the PARTITION(col=val) literal into the row for any
connector that consumes the partition value from the row
(requiresMaterializeStaticPartitionValues, true for Paimon), so the actual
projected block the writer receives carries the FULL bound schema. But
PaimonWritePlanProvider#planWrite still sized/populated column_names off
handle.getColumns() — the INSERT column list, which BindSink's
selectConnectorSinkBindColumns deliberately narrows to EXCLUDE the static-
partition column (see ConnectorWriteHandle#getColumns javadoc). The two
lists disagreed in size, so BE aborted.
Switch to handle.getBoundTargetColumns() (the full bound schema, same list
PhysicalPlanTranslator projects the block from) whenever a static-partition
spec is present; ordinary INSERT/OVERWRITE keeps getColumns() unchanged.
DELETE/MERGE are unaffected — Doris has no PARTITION(...) clause on those
statements.
Covered by a new unit test that pins getColumns() and getBoundTargetColumns()
to their real-world shape (INSERT list minus the partition column vs. the
full schema) and asserts column_names matches the full schema. Verified the
fix would have caught the crash by reverting it locally and confirming the
new test fails (8 tests, 1 failure) before restoring it (581/581 pass).
Also fixes test_paimon_row_level_delete.groovy's ow_part table to drop
itself before create, so the static-partition OVERWRITE case can rerun
after an earlier failure instead of tripping "table already exists" on the
leftover from the previous run. Regenerated the .out baseline, which had
drifted out of sync with the suite's current tag names; genOut + two
verification runs passed clean against the real FE/BE/MinIO e2e environment,
including the exact statement that crashed production.
…imon 2.0.0 FormatDataSplit/FallbackReadFileStoreTable API
Merging apache/master (76d3b7e) pulled in an independent scan-planning
fix whose regression test assumed Paimon 1.3.1 shapes that no longer
compile or hold on this branch's Paimon 2.0.0 upgrade:
- FormatDataSplit lost filePath() in favor of files() returning a
List<FileMeta>, where FileMeta carries filePath(); read the first
file's path through that instead.
- FallbackReadFileStoreTable gained a third wrappedFirst constructor
argument; pass true to match this connector's existing call sites
(PaimonReaderOptions#isWrappedFirst defaults to true absent a
fallback/primary-branch option, which is the case here).
- Paimon 2.0.0's format-table planner coalesces small files into a
single FormatDataSplit with multiple FileMeta entries instead of one
split per file, so the fixture's ">= 2 splits" precondition no longer
holds. Rewrote the assertion to check file identity across all
planned splits instead of split count, preserving the test's actual
intent (a row-counted LIMIT must not stop after an empty file).
fe-connector-paimon: 588 tests, 0 failures, 3 skipped. Checkstyle clean.
…ble/Db/All
On a multi-FE cluster, ALTER COLUMN on a paimon external table left every
OTHER FE serving a frozen pre-ALTER Table object for up to the 24h default
meta.cache.paimon.table.ttl-second: column DDL forwards to the master, and
only the mutating paimon CachingCatalog instance self-invalidates. The
metadata path already reads the latest schema live (schemaManager().latest()),
so DESC and binding saw the new column — but the scan path serializes
catalogOps.getTable()'s frozen Table to the BE, and once a post-ALTER commit
creates an overlapping sorted run (a JNI merged-read split), PaimonJniScanner
fails every read with "The jni reader fields' size {N} is not matched with
paimon fields' size {M}". REFRESH TABLE could not heal it either: the
invalidation routing was already correct on every FE (the DDL hook and the
REFRESH editlog replay both reach connector.invalidateTable), but
PaimonConnector.invalidateTable only dropped the three Doris-side caches and
never touched the paimon SDK's CachingCatalog.
Fix: invalidateTable also calls Catalog.invalidateTable (an interface default
— a no-op for a non-caching catalog, an eviction for CachingCatalog, and
DelegateCatalog forwards it), without force-building an unbuilt catalog.
invalidateDb/invalidateAll get the db-scoped / cache-wide analogues via
CachingCatalog.tableCache(). The BE error's "Please refresh table and try
again" advice actually works from this change on.
Verified on a 3-FE disaggregated cluster: the follower-read repro
(create -> write -> ALTER ADD COLUMN -> write -> SELECT * on a follower)
failed deterministically before, passes after, including a DROP COLUMN round
and reads from both followers. New unit test reproduces the frozen-Table
state with a real CachingCatalog and an out-of-band ALTER, then asserts all
three eviction scopes. fe-connector-paimon: 589 tests, 0 failures;
checkstyle clean.
…tables
Doris rejected DROP PARTITION on any external table (Paimon included) with
"PLUGIN_EXTERNAL_TABLE ... do not support DROP_PARTITION clause now" — the
external-DDL allow-list in AlterTableCommand never included DropPartitionOp,
and there was no SPI seam or connector implementation to route it through.
Paimon's own write path already exposes exactly this operation:
InnerTableCommit (the same commit handle INSERT OVERWRITE already uses via
Table#newCommit) has truncatePartitions(List<Map<String,String>>), a
one-shot data-only commit — no schema change, so no PaimonConnectorTransaction
involvement, matching OVERWRITE's commit shape rather than ALTER COLUMN's.
Adds the full plumbing: AlterTableCommand's allow-list gains
DropPartitionOp; Alter.processAlterTableForExternalTable routes it to a new
CatalogIf#dropPartition default method; PluginDrivenExternalCatalog
implements it (resolve partition name -> IF EXISTS existence check against
listPartitions for symmetric name rendering and free not-found handling ->
truncatePartitions -> the same afterExternalDdl editlog+cache refresh every
other external DDL op uses); ConnectorTableDdlOps gains
dropPartitions(session, handle, partitionNames, ifExists); PaimonCatalogOps
gains truncatePartitions(Identifier, specs), implemented via a one-shot
newCommit(user).truncatePartitions() call; PaimonConnectorMetadata resolves
Doris partition display names back to Paimon's native spec (DATE ->
epoch-day, null -> __DEFAULT_PARTITION__) reusing the same rendering
collectPartitions already shares.
New test: PaimonConnectorMetadataDropPartitionTest (7 cases, a real
FileSystemCatalog) — drops leave sibling partitions untouched, IF EXISTS is
silent on a missing partition and fails loud without it, multi-partition and
mixed-existence statements, and a non-partitioned table is rejected.
fe-connector-paimon + fe-connector-spi: 596 tests, 0 failures; fe-core
AlterTableCommandTest (17) + DropPartitionTest (3): 0 failures. Checkstyle
clean on all four touched modules.
… with deletion vectors
UPDATE and MERGE INTO were rejected on any append-only (no primary key)
Paimon table with "Only DELETE is supported", even when deletion vectors
were enabled — DELETE already worked there (PaimonDeletionVectorCollector
marks removed-row positions and commits deletion-vector index files), but
the writer only implemented two disjoint modes: primary-key keyed-upsert
dispatch, and append-only pure-delete.
An UPDATE/MERGE on an append-only table needs BOTH halves of one commit:
deletion-vector marks for the matched rows' old positions, AND the new
(post-SET) row values appended as fresh data. Paimon's CommitMessage/
DataIncrement already supports carrying data-file and index-file changes
together in one commit — PaimonJniWriter.prepareCommitMessages() already
merges the plain writer's data-file CommitMessages with the deletion-vector
collector's index-file CommitMessages into one list every write. What was
missing was routing an append-only UPDATE/MERGE row stream through both
paths in the same write instead of refusing it outright.
Extends the operation-tagged merge stream (the same RowKind-driven dispatch
primary-key UPDATE/MERGE already uses) to append-only tables: a row tagged
as a matched-update/delete records its locator's position via
PaimonDeletionVectorCollector (identical to standalone DELETE), and a
tagged insert/replacement row writes through the normal append path — both
land in the same prepareCommit() call. PaimonWritePlanProvider's
row-level-DML gate now admits UPDATE/MERGE for an append-only table when
deletion-vectors.enabled is set (the same precondition DELETE already
required), instead of rejecting every non-DELETE operation on that shape.
PaimonRowLevelDeleteTest gains coverage for the append-only UPDATE/MERGE
paths alongside the existing DELETE cases.
fe-connector-paimon: verified together with the DROP PARTITION and
external-change-poller commits (599 tests total across both, 0 failures);
be-java-extensions/paimon-scanner: 85 tests, 0 failures, including
PaimonJniWriterTest's coverage of the new append-only operation-tagged
write path. Checkstyle clean.
…rnal ALTER
An ALTER on a Paimon table done outside Doris (Spark, Flink, or any other
engine writing the same warehouse) produced no Doris event at all: every
FE's paimon CachingCatalog kept serving the frozen pre-ALTER Table object
until the default 24h access-TTL, and the FE-side ExtMetaCache stayed stale
alongside it until a user manually ran REFRESH TABLE. Paimon exposes no
schema-change listener API, so detecting this requires periodic polling.
Adds PaimonExternalChangePoller: an opt-in (meta.cache.paimon.
external-change-poll-interval-second, <= 0 disables it — the exact prior
behavior, unchanged by default), per-catalog background poll that compares
each CACHED table's held schema id (in-memory, free) against its latest
schema id (one small metadata-file read, the lightest live probe the SDK
offers) and evicts on a mismatch. Only currently-cached tables are probed
(CachingCatalog.tableCache()'s live key set), so a catalog with hundreds of
never-queried tables pays nothing. A single daemon thread per connector,
scheduled at a fixed delay so a slow poll never overlaps the next, and
closed with the connector.
A first pass wired the detected-change callback to PaimonConnector#
invalidateTable alone (the same per-table eviction REFRESH TABLE drives) and
verified it evicted the frozen CachingCatalog Table — but a cluster test
against a real out-of-band ALTER showed the user-visible symptom was only
half fixed: the connector's own cache healed, yet a subsequent query still
bound against the stale column list, because fe-core's ExtMetaCache is a
SEPARATE cache the connector module cannot reach (it does not depend on
fe-core). REFRESH TABLE heals both because RefreshManager.
refreshTableInternal drops ExtMetaCache directly before calling
connector.invalidateTable; the poller had no path to that half.
Closes the gap with a new SPI seam, ConnectorContext#notifyExternalTableChanged
(default no-op, so every connector that never self-detects remote drift is
unaffected), implemented by DefaultConnectorContext: resolves the remote
db/table names against the catalog's already-loaded local tables (the same
by-remote-name pattern PluginDrivenExternalCatalog's other DDL hooks use)
and calls RefreshManager.refreshTableInternal directly — the identical
fe-core-side refresh a manual REFRESH TABLE triggers. The poller's eviction
callback now calls both invalidateTable (heals the connector/BE-facing
symptom) and notifyExternalTableChanged (heals the query-facing symptom),
so a query genuinely sees the new schema on its next read with no manual
REFRESH needed.
New tests: externalChangePollerPollOnceEvictsFrozenTable and
externalChangePollerBackgroundThreadEvicts (single synchronous poll and the
real scheduled thread each detect and evict), plus
externalChangePollerAlsoNotifiesTheEngineNotJustTheConnector — the
regression test for the fe-core gap above, asserting the callback notifies
exactly once via a recording ConnectorContext test double, not just that
the connector's own cache changed.
Verified end-to-end on a 3-node disaggregated cluster beyond unit tests:
after the first (connector-only) wiring, a real out-of-band ALTER via a
second catalog instance left the follower reading the OLD schema even past
the poll interval (evicted internally, but ExtMetaCache still stale) —
confirming the gap unit tests alone would have missed. After adding the
notifyExternalTableChanged half, the identical repro shows the new column
with no REFRESH, confirmed via the FE log showing PaimonExternalChangePoller
-> RefreshManager.refreshTableInternal in sequence.
fe-connector-spi + fe-connector-paimon: 600 tests, 0 failures (up from 599
with the DROP PARTITION and append-only commits); fe-core AlterTableCommandTest
unaffected. Checkstyle clean across fe-connector-spi, fe-connector-paimon,
and fe-core.
…e-schema round trip
ConnectorColumnConverter#toConnectorType used PrimitiveType#toString() for
DECIMALV3 columns, which returns the precision-sized enum name
(DECIMAL32/64/128/256) rather than "DECIMALV3". Every connector (Paimon,
Iceberg) always names the type "DECIMALV3" on its own side of a write plan,
so a bind-time schema captured through this fe-core path never matched the
execute-time schema read directly from the connector for the SAME unchanged
decimal column.
PaimonWritePlanProvider#validateBoundColumns compares those two schemas
byte-for-byte and rejects the write as concurrent schema drift, so any
INSERT/UPDATE/MERGE against a Paimon table with a decimal column failed
deterministically with "Paimon write metadata changed after the write was
bound; retry the statement" -- found while load-testing the DROP PARTITION /
append-only UPDATE-MERGE / external-ALTER-detection features against a
decimal-bearing table.
Canonicalize the DECIMALV3 family to "DECIMALV3" in the reverse (Column ->
ConnectorType) direction, matching what convertScalarType already accepts on
the forward direction. DECIMALV2 keeps its own distinct type name.
…pe-explicit overload
testDecimalV2TypeNameUnaffectedByV3Canonicalization used
ScalarType#createDecimalType(precision, scale), which silently upgrades to
a DECIMALV3 width whenever Config.enable_decimal_conversion is on -- the
default in this suite. That made the test assert the exact thing the prior
commit's canonicalization fix produces (DECIMALV3), not what it was meant to
guard (an untouched DECIMALV2 column keeping its own name).
Build the fixture via createDecimalType(PrimitiveType.DECIMALV2, precision,
scale) instead, which is independent of that flag.
… tables
Both operations already had a complete fe-core routing path
(PluginDrivenExternalCatalog#renameTable / #truncateTable dispatch through
the SPI's ConnectorTableDdlOps#renameTable / #truncateTable), and the SPI
itself already declared both as optional default methods. The only gap was
that the Paimon connector never overrode either, so both commands failed
deterministically with the SPI's literal default message ("RENAME TABLE not
supported" / "TRUNCATE TABLE not supported") without ever reaching Paimon.
- PaimonCatalogOps: add the renameTable / truncateTable seam methods.
renameTable forwards straight to Catalog#renameTable (already present in
the Paimon 2.0 SDK). truncateTable mirrors the existing truncatePartitions
implementation exactly (reload through getTable for the current table
generation, a fresh self-contained per-call commit user, InnerTableCommit),
calling BatchTableCommit#truncateTable instead of #truncatePartitions.
- PaimonConnectorMetadata: override both SPI methods.
renameTable resolves the source/target Identifier from the handle and
wraps the remote call in executeAuthenticated, mirroring dropTable.
truncateTable dispatches on the partitions argument: a non-empty list
reuses dropPartitions(..., ifExists = false) unchanged (TRUNCATE TABLE t
PARTITION (p) on an absent partition must error, the exact contract
dropPartitions already enforces for DROP PARTITION without IF EXISTS,
so no second partition-name-resolution path is needed); null/empty routes
to the new whole-table truncateTable seam instead.
- RecordingPaimonCatalogOps: record renameTable / truncateTable calls and
their arguments, and add configurable not-exist / already-exist throw
flags for offline seam tests.
- New PaimonConnectorMetadataRenameTruncateTest (10 cases): a REAL
FileSystemCatalog exercises rename success, rename-to-an-existing-name
rejection, whole-table truncate (both null and empty partitions), named-
partition truncate, truncating a missing named partition (must error, not
silently no-op), truncating a non-partitioned table's named partition
(rejected) vs whole-table truncate of a non-partitioned table (allowed),
plus two offline seam assertions pinning the exact call routed to the
connector.
Verified end to end on a live disaggregated-storage Doris cluster: RENAME
TABLE on a 400K-row partitioned table (data and partition layout intact
under the new name), whole-table TRUNCATE on a 1M-row table (rows gone,
schema intact), named-partition TRUNCATE (only the named partition's 100K
rows cleared, the other three untouched), and truncating a nonexistent named
partition correctly errors instead of silently no-op'ing.
…text API
Upstream commit a08f593 wrote createHmsCatalog() against Paimon
1.3.1's HiveCatalog(FileIO, HiveConf, String, CatalogContext, String)
constructor but passed the local Options variable in the CatalogContext
slot. Paimon 2.0.0 (this branch's pinned paimon.version) has the same
5-arg overload signature, so the call compiled fine on paper but failed
type-checking once rebased onto our 2.0.0 dependency. Use the
CatalogContext already available as this method's first parameter
instead of re-deriving Options from it.
Same root cause as 74ff7c0: the test constructed HiveCatalog with
a raw Options where the 5-arg overload expects CatalogContext. Wrap
via CatalogContext.create(options).
…eclaration
Upstream commit 6922ab5 ([opt](build) 1/4: Speed up BE full build
~22% by cutting hot-header include edges (apache#66400)) added a forward
declaration of clear_blocks<T>() in async_result_writer.cpp to avoid
pulling in exec/exchange/local_exchanger.h, which already declares the
same template with a default argument for memory_used_counter. C++
forbids a default argument from being repeated across declarations of
the same function/template, so under Unity Build both declarations
land in the same translation unit and -Werror turns this into a hard
"redeclaration ... may not have default arguments" error. Drop the
default from this file's forward declaration; the one in
local_exchanger.h still applies at call sites that see both headers.
@damokelis

Copy link
Copy Markdown
Author

Rebased onto current master (f054492cbb9). The only conflict was in PaimonConnector.java, from #66633 (refactor Unify external metadata cache framework) collapsing the three connector-owned caches into metaCache — resolved by keeping the new metaCache.invalidateTable/Database/Catalog() calls and re-attaching the paimon-SDK-side CachingCatalog eviction on top of them.

I have also split four self-contained fixes out of this PR so they can be reviewed independently of the Paimon 2.0 upgrade. None of them depend on any Paimon 2.0 code, and each fixes a defect that is present on master today:

PRScopeSize
#67459[fix](be) duplicate default argument on the clear_blocks forward declaration added by #66400 — a hard -Werror failure under Unity Build+1/-1
#67460[fix](filesystem) accept the dotted s3.path.style.access alias, the spelling 42 existing regression catalogs already use+3/-2
#67461[fix](build)fe-connector-hms-hive-shade clean build fails with main artifact does not exist+9
#67462[fix](nereids) gate IcebergRowLevelDmlTransform on catalog identity, so RowLevelDmlRegistry stays correct once a second connector declares row-level capabilities+17/-1

Those four are still included here as well, so this PR remains self-contained; whichever land first, I will rebase this one on top and drop the duplicates.

On scope: this PR is large because Paimon 2.0 is a breaking SDK upgrade — the connector runtime bump, the write path, and the read path cannot land separately without leaving the build broken in between. If it would help review, I am happy to split the remainder further along whatever seam you prefer (e.g. read/upgrade first, then transactional writes, then schema evolution and row-level DML). Guidance on the preferred sequencing would be very welcome.

Sign up for freeto 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.

[Enhancement] Support Paimon 2.0 table writes on master

2 participants

@damokelis@hello-stephen