Skip to content

[core] Introduce portable TableDescriptor for cross-language table construction#8819

Open
JunRuiLee wants to merge 1 commit into
apache:masterfrom
JunRuiLee:feat/table-descriptor
Open

[core] Introduce portable TableDescriptor for cross-language table construction#8819
JunRuiLee wants to merge 1 commit into
apache:masterfrom
JunRuiLee:feat/table-descriptor

Conversation

@JunRuiLee

Copy link
Copy Markdown
Contributor

Purpose

Introduce a portable, versioned JSON description of a table — TableDescriptor — so a non-Java runtime (e.g. a paimon-rust / paimon-cpp reader embedded in a compute engine) can rebuild a table for scan/read without a catalog lookup.

Today a Java engine can serialize a Table via native Java serialization, but that blob is not consumable by non-Java readers (it carries JVM-only state such as FileIO / CatalogEnvironment). As a result, an embedding engine has to re-resolve the table through a catalog on the reader side, which can observe a different schema/snapshot than the one the planner used, and ties the reader to a derived filesystem-catalog layout.

This PR adds a slim, language-neutral contract that captures only what a reader needs:

  • org.apache.paimon.table.TableDescriptor (paimon-api, @Experimental): version, path, the full TableSchema (identical to an on-disk schema/schema-N), optional options / database / name / branch. Serialized through JsonSerdeUtil so the nested TableSchema uses Paimon's custom serde; unknown fields are ignored for forward compatibility; null fields are omitted to keep the payload slim.
  • org.apache.paimon.table.TableDescriptorSerializer (paimon-core, @Experimental): builds a descriptor from a resolved FileStoreTable (location() + schema()), and serializes it to JSON. A non-main branch is carried verbatim so a reader can fail fast rather than silently read main; database/name must be set together or not at all.

The reader side (in the paimon-rust cross-language reader) consumes the same JSON and reconstructs the table directly; a byte-identical golden fixture (table-descriptor-v1.json) pins the wire form on both sides.

Tests

  • TableDescriptorTest (paimon-api): JSON round-trip; nested TableSchema uses Paimon's custom serde; unknown fields ignored.
  • TableDescriptorSerializerTest (paimon-core): build from a FileStoreTable; round-trip; partial-identifier rejection; non-main branch carried through.
  • TableDescriptorGoldenTest (paimon-core): producer output equals the checked-in canonical golden, and the golden parses back — the exact wire form the cross-language reader is tested against.

API and Format

  • New @Experimental public API: TableDescriptor (paimon-api) and TableDescriptorSerializer (paimon-core).
  • New JSON descriptor format, versioned (version = 1). No change to any existing on-disk or wire format.

Documentation

None yet (experimental API). Docs can follow once the contract stabilizes.


Motivated by the Apache Doris Paimon-external-table integration (apache/doris#65883), where the compute node currently re-resolves the table through a catalog on the backend; this contract lets it consume the FE-planned table instead.

@JingsongLi

Copy link
Copy Markdown
Contributor
  1. [P2] Composite tables are silently downgraded to regular tables

    • TableDescriptorSerializer.java:67 only records location + schema.
    • The location() and schema() methods of FallbackReadFileStoreTable and ChainGroupReadTable are directly delegated to the primary table, so the descriptors generated for composite tables are exactly the same as those for regular primary tables.
    • After reconstruction on the Rust side, the partition-level merge/fallback semantics for dual branches are lost, which may result in silently skipping or incorrectly reading data.
    • I wrote a temporary test to confirm that the JSON of fallback tables and regular tables is byte-for-byte identical.
    • It is recommended that v1 at least explicitly reject these wrappers; tableKind and branch/composition information can be added in future versions.
  2. [P2] Dynamic configurations and credentials are included in plain text within the descriptor

    • AbstractFileStoreTable.copyInternal merges all dynamic options into tableSchema.options, which is then exported in its entirety by TableDescriptorSerializer.java:67.
    • Testing confirmed that the supported fs.s3a.secret.key=review-secret appears verbatim in the JSON.
    • The Rust FFI already accepts storage_options separately, so the descriptor should no longer carry AK/SK.
    • It is recommended to use a persistent schema and include necessary dynamic read options via an explicit allowlist; filter out storage, catalog, and sensitive configurations.

There is also a contract issue: the top-level options is a public field, but the Java producer always sets it to null, and Rust v1 explicitly ignores it; it is best to remove it or implement fail-fast behavior for non-null values to prevent users from mistakenly believing it will take effect.

@JunRuiLee
JunRuiLee force-pushed the feat/table-descriptor branch 2 times, most recently from 463b20a to d8bdaed Compare July 23, 2026 06:58
@JunRuiLee

Copy link
Copy Markdown
Contributor Author
  1. [P2] Composite tables are silently downgraded to regular tables

    • TableDescriptorSerializer.java:67 only records location + schema.
    • The location() and schema() methods of FallbackReadFileStoreTable and ChainGroupReadTable are directly delegated to the primary table, so the descriptors generated for composite tables are exactly the same as those for regular primary tables.
    • After reconstruction on the Rust side, the partition-level merge/fallback semantics for dual branches are lost, which may result in silently skipping or incorrectly reading data.
    • I wrote a temporary test to confirm that the JSON of fallback tables and regular tables is byte-for-byte identical.
    • It is recommended that v1 at least explicitly reject these wrappers; tableKind and branch/composition information can be added in future versions.
  2. [P2] Dynamic configurations and credentials are included in plain text within the descriptor

    • AbstractFileStoreTable.copyInternal merges all dynamic options into tableSchema.options, which is then exported in its entirety by TableDescriptorSerializer.java:67.
    • Testing confirmed that the supported fs.s3a.secret.key=review-secret appears verbatim in the JSON.
    • The Rust FFI already accepts storage_options separately, so the descriptor should no longer carry AK/SK.
    • It is recommended to use a persistent schema and include necessary dynamic read options via an explicit allowlist; filter out storage, catalog, and sensitive configurations.

There is also a contract issue: the top-level options is a public field, but the Java producer always sets it to null, and Rust v1 explicitly ignores it; it is best to remove it or implement fail-fast behavior for non-null values to prevent users from mistakenly believing it will take effect.

Thanks for the careful review, @JingsongLi! All three addressed:

  1. Composite tables silently downgraded — Fixed. TableDescriptorSerializer.from now rejects DelegatedFileStoreTable (covers fallback / chain / privileged) with an UnsupportedOperationException, so a composite table can no longer be serialized as if it were a plain one.

  2. Dynamic options / credentials leaked in plaintext — Fixed. The serializer now ships the persisted schema (table.schemaManager().schema(table.schema().id())) instead of table.schema(), so the dynamic options merged in by copyInternal (incl. fs.s3a.secret.key and the injected path) no longer appear in the descriptor. Storage credentials are passed to the reader out of band. Added a test asserting fs.s3a.secret.key does not appear in the JSON.

  3. Misleading top-level options — Removed the field from the DTO entirely (it was always null and ignored by the reader).

One addition on top of the review: instead of rejecting non-main branches, v1 now supports a specific branch of a plain table — the branch travels in a dedicated branch field (omitted for main), and since schemaManager() is branch-scoped the persisted schema is read from the branch. Composite/fallback/chain tables remain out of scope.

The reader side (paimon-rust) will be updated to consume this in its own PR once this contract lands.

@JingsongLi

Copy link
Copy Markdown
Contributor
  • TableDescriptorSerializer.java:80 has been modified to read the persisted schema-N, which effectively prevents the leakage of dynamic credentials and correctly rejects delegated/composite tables and non-main branches.
  • However, the input table may be a copy containing scan.snapshot-id, scan.tag-name, or scan.timestamp[-millis]; these dynamic options are removed by the persisted schema, and the descriptor lacks snapshot/time-travel fields.
  • The Rust reader reconstructs regular tables based solely on the descriptor and ultimately reads the latest snapshot rather than the one seen by the planner, which constitutes silent reading of incorrect data.

@JunRuiLee
JunRuiLee force-pushed the feat/table-descriptor branch 2 times, most recently from 6829c77 to 3d7352e Compare July 23, 2026 11:17
…nstruction

Add org.apache.paimon.table.TableDescriptor (paimon-api) and
TableDescriptorSerializer (paimon-core): a slim, versioned JSON wire form that
serializes a resolved FileStoreTable into {path, full TableSchema, options} so a
non-Java reader (e.g. paimon-rust / paimon-cpp) can rebuild the table for
scan/read without a catalog lookup or JVM-only state. Non-main branches are
carried verbatim so a reader can fail fast rather than silently read main.

Motivated by the Doris Paimon-external-table integration (apache/doris#65883),
where the compute node re-resolves the table through a catalog; this lets it
consume the FE-planned table instead.
@JunRuiLee
JunRuiLee force-pushed the feat/table-descriptor branch from 3d7352e to c3cdb60 Compare July 23, 2026 11:43
@JunRuiLee

Copy link
Copy Markdown
Contributor Author

Thanks @JingsongLi. Now the serializer resolves the snapshot at serialize time (TimeTravelUtil.tryTravelToSnapshot) and carries only the resolved snapshotId (null for ordinary scans) — carrying the resolved id rather than raw scan.* avoids duplicating TimeTravelUtil.SCAN_KEYS / selector drift and keeps storage/sensitive options out. The reader (paimon-rust) will pin snapshotId in its own PR; on the split-read path the split already pins files.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants