Skip to content

[core] Support per-request endpoint routing for io-cache - #1014

Open
sundapeng wants to merge 14 commits into
apache:mainfrom
sundapeng:io-cache-routing
Open

sundapeng wants to merge 14 commits into
apache:mainfrom
sundapeng:io-cache-routing

Conversation

@sundapeng

@sundapeng sundapeng commented Oct 3, 2026 •

Copy link
Copy Markdown
Member

Purpose

The paimon-rust side of apache/paimon#10350: when the options carry OSS cache endpoints, for example vended with a REST table token, the OpenDAL OSS FileIO sends each request to a cache endpoint or to the origin, by operation and file type. The options and rules are the same as in apache/paimon#10350 (Java) and apache/paimon#10351 (pypaimon); the table rules added in this round (Format and Object Tables on the origin, the table's own file prefixes, postpone and managed BLOB names) are being ported to both.

How a request is routed

Every request goes either to a cache target or to the origin, io-cache.origin.endpoint (by default fs.oss.endpoint):

  1. Paths that are not oss://, operations not listed in io-cache.policy (read, meta for file status, exists, write), temporary files, files rewritten in place and files named by a sequence id (snapshot-N, schema-N, changelog/changelog-N) go to the origin.
  2. The file type comes from the whole name, in the shape Paimon writes it. A data file is a data or changelog file prefix, including the table's own data-file.prefix and changelog-file.prefix, followed by {uuid}-{count}.{extension}, the same after the postpone writer's -u-{commit user}-s-{write id}-w-, or {uuid}.managed.blob for a managed BLOB pack; its file index adds .index and its BLOB references .blobref. Metadata is manifest-, manifest-list-, index-manifest- or stat- followed by {uuid}-{count}, a changelog manifest from TableCommit is manifest-{uuid}-changelog-{count}, and a manifest may add .avro.sidecar; a bucket index is index-{uuid}-{count}; a global index is <kind>-global-index-{uuid}.index. Any other name, such as an external Format Table file called manifest.parquet, goes to the origin.
  3. The type must be in io-cache.whitelist. With io-cache.routes, the first rule naming the type picks the target; without it, the first target with an endpoint takes every type. A type no rule names goes to the origin.
  4. Listings, deletes, renames, copies, atomic writes and the existence check a writer makes before writing always go to the origin. Errors from a target are returned as they are and never retried on the origin, so each endpoint is expected to be highly available.

A target only ever sees files written once under a unique name, so it can be a read cache that keeps metadata. exists is the exception: Paimon uses it to ask whether a file is still there, for example before replaying a commit on recovery, and a cache that keeps the metadata of deleted objects answers yes after the file is gone. So exists uses a target only when the policy lists it, which suits a target that answers existence as OSS does. The origin sees everything else, so it must answer every request as OSS does: OSS itself, or an endpoint that keeps no metadata cache, such as an OSS accelerator. Format Tables and Object Tables never use a target: other jobs may replace their files in place under the same name, whatever the name looks like. A Format Table that should go through an OSS accelerator gets it by making the accelerator the origin.

Endpoint As origin As target
OSS Yes Yes, with any policy
An endpoint that keeps no metadata cache and forwards every request to OSS, such as an OSS accelerator Yes Yes, with meta,read,exists,write
A read cache that keeps metadata, including that of deleted objects No Yes, with meta,read

Usage

A read cache in front of the bucket

io-cache.enabled                          = true
io-cache.endpoint                         = http://10.0.0.1:8080
io-cache.target.default.path-style-access = true
io-cache.policy                           = meta,read
io-cache.whitelist                        = *

Reads and file status of immutable files use the cache; existence checks and everything else use OSS.

An endpoint that also takes writes

io-cache.enabled               = true
io-cache.endpoint              = https://cn-hangzhou-j-internal.oss-data-acc.aliyuncs.com
io-cache.target.default.region = cn-hangzhou
io-cache.policy                = meta,read,exists,write
io-cache.whitelist             = data

Data files are read, written and checked through the OSS accelerator, whose read, write and request paths to OSS add to the direct quotas; everything else uses OSS. exists is listed because the accelerator keeps no metadata cache.

The accelerator as origin, a read cache for data

io-cache.enabled                          = true
io-cache.origin.endpoint                  = https://cn-hangzhou-j-internal.oss-data-acc.aliyuncs.com
io-cache.endpoint                         = http://10.0.0.1:8080
io-cache.target.default.path-style-access = true
io-cache.policy                           = meta,read
io-cache.whitelist                        = data,global-index

Data files and global indexes are read through the cache. Snapshot probes, hint files, manifests, listings and all writes go through the accelerator.

The accelerator as origin, data straight to OSS

io-cache.enabled         = true
io-cache.origin.endpoint = https://cn-hangzhou-j-internal.oss-data-acc.aliyuncs.com
io-cache.endpoint        = https://oss-cn-hangzhou-internal.aliyuncs.com
io-cache.policy          = meta,read,exists,write
io-cache.whitelist       = data

Data files are read and written on the OSS endpoint; metadata and the other request-heavy traffic go through the accelerator, so the two quotas add up.

Two targets by file type

io-cache.enabled                           = true
io-cache.targets                           = accel,cluster
io-cache.target.accel.endpoint             = https://cn-hangzhou-j-internal.oss-data-acc.aliyuncs.com
io-cache.target.accel.region               = cn-hangzhou
io-cache.target.cluster.endpoint           = http://10.0.0.1:8080
io-cache.target.cluster.path-style-access  = true
io-cache.routes                            = meta=accel;data,global-index=cluster
io-cache.policy                            = meta,read

Manifests and statistics are read through the accelerator, data files and global indexes through the cache, and the rest, existence checks included, uses OSS.

Options

The same options and values as in apache/paimon#10350, vended with the table token or set in the catalog options. None existed in paimon-rust before this PR.

Option Meaning
io-cache.enabled Turns endpoint routing on.
io-cache.policy The operations that may use a target: read, meta (file status), exists, write; none turns it off. Whole tokens, case-insensitive.
io-cache.whitelist The file types that may use a target: meta, data, bucket-index, global-index, file-index, or * (the default).
io-cache.endpoint A single target, named default.
io-cache.targets with io-cache.target.<name>.endpoint Several named targets.
io-cache.target.<name>.region, io-cache.target.<name>.path-style-access Signing region and path-style addressing of one target, default included. OpenDAL signs without a region, so .region is accepted and ignored.
io-cache.routes Maps file types to targets; the first rule naming a type wins.
io-cache.origin.endpoint Where requests that use no target go; by default fs.oss.endpoint, which may point at a cache for clients without routing.

dlf.oss-endpoint (turns routing off) and the table options data-file.prefix / changelog-file.prefix (read from the table schema, and from the catalog or token options, to recognise data files) are existing options this PR only reads.

Brief change log

  • io/cache_routing.rs parses the options and picks the endpoint of each request. io-cache.target.<name>.region is accepted but not needed, since OpenDAL signs OSS requests without a region.
  • FileIO keeps one OpenDAL operator for the origin and one per target. Reads, stat and exists follow the read, meta and exists rules, and OutputFile writes follow the write rule; OutputFile::exists, the check a writer makes, uses the origin.
  • Each table gets its own view of the FileIO: a Format Table's and an Object Table's view never use a target, and any other table's view passes the file prefixes of its schema with each request.
  • RESTTokenFileIO: io-cache.enabled set in the catalog options overrides the token.
  • fs.oss.path-style-access for the OSS endpoint.

Tests

  • cache_routing.rs: the endpoint of each request with one and two targets, the policy (exists on its own included), whitelist, routes and endpoint options, the type of each file name (custom prefixes that collide with reserved names, a table's own prefixes, postpone and managed BLOB names with their .index and .blobref files, entropy and postpone directories, sidecars, temporary names, metadata directories, external names that only look like metadata or indexes such as manifest.parquet), and option parsing.
  • file_io/cache_routing_tests.rs runs a FileIO against local OSS test servers: which operations reach the target with and without write and exists in the policy, a target that still holds a file the origin deleted, a Format Table file named manifest.parquet read from the origin while the target holds an older copy, per-target operators, errors from a target, the local block cache.
  • snapshot_manager.rs: committing and discovering snapshots never touches the cache.
  • table_write.rs: a primary-key table with changelog-producer=input and manifest sidecars commits and reads its changelog incrementally; the changelog manifest and its sidecar are written and read through the target.
  • rest_token_file_io.rs: options from the token and the catalog, and a table's own prefixes through the token FileIO.
  • table/io_cache_routing_tests.rs drives FileSystemCatalog tables over local OSS test servers: a Format Table whose file the origin replaced under a Paimon-shaped name, while the target kept the old copy, reads the new rows through the loaded table, a copy and Table::from_resolved_schema, and an Object Table reads its objects from the origin; a table whose schema alone sets data-file.prefix, plus a copy with a dynamic prefix, a bucket=-2 primary-key table and a primary-key BLOB table write and read their data files, postpone files and managed packs through the target.

Verified end to end

All three clients (Java, Python, Rust) created, wrote, read, time-travelled and dropped partitions of tables against local OSS mock endpoints that log every request, with one target, two targets by file type, write routing, and an OSS accelerator as origin with a read cache or OSS as the target. Every request a target saw was one these rules send to it. The pypaimon side (apache/paimon#10351) also ran its cases through a real OSS accelerator, as target and as origin.

API and Format

No public API or storage format change; routing is internal and configured through options.

Documentation

docs/src/getting-started.md documents the options under "OSS Cache Routing".

@sundapeng sundapeng changed the title [core] Route reads to io-cache targets per file type [WIP][core] Route reads to io-cache targets per file type Oct 3, 2026
@sundapeng
sundapeng force-pushed the io-cache-routing branch 3 times, most recently from e24bc8a to 78eac7b Compare October 4, 2026 08:09
@JingsongLi
JingsongLi marked this pull request as draft October 4, 2026 14:21
When the options carry io-cache endpoints, for example vended with a REST
table token, the OpenDAL OSS FileIO now reads immutable files through a
cache target chosen by file type and keeps every other request on the
OSS endpoint. The rules are the same as in the Java Jindo FileIO and
pypaimon.

- io-cache.endpoint names one target. io-cache.targets with
  io-cache.target.<name>.endpoint (and optional .path-style-access) names
  several, and io-cache.routes maps file types to them. io-cache.whitelist
  and io-cache.policy keep their meaning, and io-cache.origin.endpoint
  names the OSS endpoint when fs.oss.endpoint points at a cache for older
  clients. io-cache.enabled set in the catalog options overrides the token.
- Only read, stat and exists requests use a target. Writes, including
  OutputFile::exists, listings, deletes, renames, copies and non-oss paths
  stay on OSS, and a client-side dlf.oss-endpoint turns routing off.
- Files rewritten in place (FileType::is_mutable) and metadata named by a
  sequence id never use a target.
- Data files are recognized by name: a data or changelog file prefix,
  including a custom one, followed by {uuid}-{count}.{extension}.
  Manifests, indexes and statistics share the {uuid}-{count} shape but
  have no extension, so a custom prefix such as manifest- cannot mix them
  up; the manifest sidecar is excluded explicitly. Other names keep their
  FileType, and names Paimon does not write stay on OSS.
- Errors from a target are returned as they are, without retrying on OSS.
- Adds fs.oss.path-style-access for the OSS endpoint and documents the
  options in getting-started.

The routing cases in testdata/io_cache are shared with the Java Jindo
FileIO and pypaimon.
Some cache endpoints, such as an OSS accelerator, also take writes and
send them to OSS over their own write path, whose bandwidth adds to the
direct one. With write in io-cache.policy, OutputFile writers of
cacheable files (data files, manifests, indexes) use the file type's
target. OutputFile::exists, the existence check a writer makes, stays on
origin, as do deletes, renames, copies, listings and writes of snapshots,
hints and temp files. A policy with only write routes writes only.
@sundapeng sundapeng changed the title [WIP][core] Route reads to io-cache targets per file type [WIP][core] Support per-request endpoint routing for io-cache Oct 6, 2026
Replace the JSON test vectors with assertions grouped by topic in the
cache_routing tests, and remove testdata/io_cache.
@sundapeng sundapeng changed the title [WIP][core] Support per-request endpoint routing for io-cache [core] Support per-request endpoint routing for io-cache Oct 6, 2026
@sundapeng
sundapeng marked this pull request as ready for review October 6, 2026 16:02

@JingsongLi JingsongLi 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.

Requirement fit: SUPPORTED. Implementation: FINDINGS at 16bc555. Per-file-type OSS routing has a practical end-to-end use with REST-vended endpoints, but commit recovery currently trusts stale cache metadata and can publish a snapshot referencing cleaned data.

Validation: all 167 IO tests passed on the PR head, including an added real Parquet table write/commit/scan probe with both meta,read and meta,read,write; hiding the newly committed snapshot in the cache did not prevent discovery. The automatic merge with current main b262b5f is clean and all 3,772 core tests passed (6 ignored), including that probe. An additional actual commit/truncate/expire/delete/replay probe reproduced the missing-file recovery regression below. Routing rules were also compared with Java #10350 at 8bb021cd. No live OSS/cache cluster was available.

Comment thread crates/paimon/src/io/file_io.rs Outdated
format!("Failed to check existence of '{path}'"),
)
})
op.operator(OpClass::Meta)

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] Keep recovery validation on origin

TableCommit::check_recovery_entries uses this method to check prepared files before replaying a commit whose identity has expired. With io-cache.policy=meta,read, that writer safety check now uses the target. Deletes go only to origin, so a cache can still return a positive HEAD after cleanup. I reproduced: writer A commits identifier 7; B truncates; snapshot 1 is expired and A's data file is deleted from origin; the cache retains the old object/status; replaying A returns Ok(()) and publishes snapshot 3 referencing the absent origin file. There is no subsequent origin existence check. This can turn a recovery into a broken committed table. Please use the origin-only FileIO for recovery existence validation (both recovery APIs), and add a stale-positive-cache regression. In the same probe, changing this safety check to origin_only().exists rejects the replay and leaves snapshot 2 current.

@sundapeng sundapeng Oct 7, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks, this is right, and the Java client has the same check (TableCommitImpl.checkFilesExistence before replaying committables), as do manifest cleanup and changelog expiration there. Rather than special-casing each caller, ed39f90 gives exists its own token in io-cache.policy: meta now covers file status only, and FileIO::exists / InputFile::exists use a target only when the policy lists exists. Without it, including the meta,read policy of your probe, both recovery paths check existence on the origin. The server is expected to list exists only for targets that answer existence as OSS does, that is, ones that keep no metadata of deleted objects.

test_exists_ignores_a_cache_that_kept_a_deleted_file covers a target that still holds a file the origin no longer has, at the FileIO level. apache/paimon#10350 (421917d084) and apache/paimon#10351 (26fc70998b) apply the same rule.

exists answers whether a file is still there: commit recovery checks the
files it replays. A cache that keeps the metadata of deleted objects
answers yes after the file was deleted on origin, so a replay could
publish a snapshot that references a deleted file. meta now covers file
status only; FileIO::exists and InputFile::exists use a target only when
io-cache.policy lists exists, as in the Java and Python clients.

@JingsongLi JingsongLi 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.

Requirement fit: SUPPORTED. Implementation: FINDINGS. The new exists policy fixes the previous stale-positive recovery finding: a real Parquet write/commit/read, truncate, snapshot expiration, origin deletion and replay probe now rejects both recovery APIs and leaves snapshot 2 current even while the target retains the deleted data. All 188 IO, REST-token and snapshot tests pass.

A separate actual Format Table read reproduces the stale-row regression below: after replacing manifest.parquet on origin, a direct scan reads [2] while the routed scan reads [1]. No live OSS/cache deployment was available. The same Format Table regression reproduces after clean integration with main dd25596; a temporary origin-only FormatTable read control makes both probes pass.

Comment thread crates/paimon/src/io/cache_routing.rs Outdated
}
match FileType::classify(path) {
FileType::Data => None,
file_type => Some(file_type),

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] Keep mutable external Format Table files on origin

This fallback trusts the broad FileType::classify result: any name containing "manifest" or starting "stat-" becomes Meta, even when it is an ordinary external data file rather than a uniquely named Paimon object. Format Table discovery accepts such names and uses this FileIO. I reproduced with a real Parquet Format Table at oss://bkt/db.db/review_external/manifest.parquet and independent OSS mock origin/cache endpoints: retain the file containing id=1 in the target, replace the origin file with id=2, then scan again with io-cache.policy=meta,read and the default whitelist. The direct origin scan returns [2]; the routed scan returns [1], and request logs show its Parquet GETs going to the target. No local block cache is enabled. This silently returns stale data after a supported external replacement. Please require actual immutable Paimon metadata/index filename shapes before routing, or bypass this routing for mutable Format Table files, and add the overwrite/read regression.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks, agreed: the fallback trusted names Paimon does not write. 33f4fb9 sends metadata and index files to a target only when the whole name has the shape Paimon writes: (manifest|manifest-list|index-manifest|stat)-{uuid}-{n}, manifest-{uuid}-{n}.avro.sidecar, index-{uuid}-{n} (bucket, deletion vector and primary-key index files) and <kind>-global-index-{uuid}.index; file indexes only through the data-file shape. Any other name, including manifest.parquet and stat-2024.parquet in a Format Table, goes to the origin.

test_format_table_file_named_like_a_manifest_uses_origin mirrors your probe (target holds id=1, origin id=2): the routed read returns id=2 and the target sees no request; the type tests add those names as well. apache/paimon#10350 (1e6598aafb) and apache/paimon#10351 (da72147920) apply the same rule.

…s them

The broad FileType classifier also matched names such as manifest.parquet,
stat-2024.parquet or foo.index, which a Format Table may hold as ordinary
files and replace in place, so a routed scan could return the old file
from a cache. A metadata or index file now uses a target only under the
exact name Paimon writes: manifest-, manifest-list-, index-manifest- and
stat-{uuid}-{n} (a manifest also with .avro.sidecar), index-{uuid}-{n},
and {kind}-global-index-{uuid}.index. Data files and their file indexes
keep the existing name rule; every other name goes to the origin.
test_format_table_file_named_like_a_manifest_uses_origin replaces such a
file on the origin while a target still holds the old one.
# Conflicts:
#	crates/paimon/src/io/file_io.rs

@JingsongLi JingsongLi 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.

Requirement fit: SUPPORTED. Implementation: FINDINGS at 98ae24a. The previous exists-policy and manifest.parquet findings are fixed. Independent actual Parquet write/commit/read and both recovery API probes pass with meta,read and meta,read,write; an origin-deleted file retained by a separate cache is rejected and snapshot 2 remains current.

Validation: all 3,913 core tests pass (6 ignored), including the original routing/REST/snapshot tests. Additional real Format Table reads against independent origin/cache endpoints confirm manifest.parquet now returns current rows, but a valid external file named data-{uuid}-1.parquet still returns stale rows; the origin-only control returns current rows. An actual PK input-changelog commit and incremental read also show the generated changelog manifest PUT/GET bypassing the configured metadata target. Both remaining findings reproduce after clean integration with main 2ec5871. No live OSS/cache deployment was available.

Please also update the getting-started io-cache.policy documentation: meta now means file status only; exists is a separate supported token.

Comment thread crates/paimon/src/io/cache_routing.rs Outdated
if temp || FileType::is_mutable(path) {
return None;
}
if is_data_file_name(name, data_prefixes) {

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] Keep mutable Format Table files on origin even with Paimon-shaped names

A whole-name match still does not establish that an external Format Table object is immutable. Format Table discovery accepts copied/imported Paimon Parquet filenames and upstream jobs may replace those external files under the same name. I reproduced with a real Parquet Format Table at oss://bkt/db.db/review_external/data-123e4567-e89b-12d3-a456-426614174000-1.parquet: the independent cache retains id=1 and origin contains its replacement id=2. With meta,read and default whitelist, the routed table scan returns [1]; the same table through origin_only returns [2]. No local block cache is enabled. The manifest.parquet control now correctly returns [2]. Please preserve the ownership distinction at the Format Table read boundary (for example origin-only IO for mutable external sources); filename shape alone leaves the silent stale-row regression.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed: a Paimon-shaped name does not make a Format Table file immutable. 129fad9 gives a Format Table the origin-only view of its FileIO in Table::new and Table::from_resolved_schema, so its scans, file status lookups and writes all use the origin whatever io-cache.policy says; other tables keep per-request routing.

format_table_reads_replaced_files_from_origin_with_io_cache mirrors your probe: the target keeps id=1 at review_external/data-{uuid}-1.parquet and the origin holds the replacement id=2. The scan returns [2], an append also stays on the origin, and the target sees no request. Without the change the same test returns [1].

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Following up: after another look I reverted 129fad9 in b85e26b and leave this decision to the catalog that vends the targets.

A Format Table is a Hive-style table whose files scheduled jobs may replace in place, but whether that returns old rows depends on the target. One that forwards every request to OSS, such as an OSS accelerator, or a cache that revalidates with OSS, never returns the replaced content, and routing Format Table reads through it is useful. Only a target that serves cached content without asking OSS can return the old copy, and FileIO cannot tell the two kinds apart, while the REST catalog knows which kind it vends. It already resolves io-cache.policy and io-cache.whitelist per table, database and catalog, so it can leave the io-cache options out for such tables, the same way exists is only vended for targets that answer it as OSS does.

The getting-started section now says that the catalog decides per table whether a Format Table uses a target. The changelog manifest fix (b63dae4) and the docs fix (6ed10b4) stay.

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 rechecked this on b85e26b through the actual FileSystemCatalog create/load/scan APIs with catalog-level io-cache.enabled=true and the documented meta,read policy. After replacing an external Parquet object on origin from row 7 to row 9 while the target retains row 7, the table scan still returns row 7; listing reaches origin, but the data GETs reach the target. The same table with origin-only FileIO returns row 9. No local block cache is enabled.

The proposed per-table catalog decision is absent on the built-in paths: FileSystemCatalog shares its catalog FileIO across tables (filesystem.rs:241–245), and external REST tables explicitly skip table tokens (data_token_enabled && !is_external, rest_env.rs:373) and build FileIO from catalog options (513–518). Thus a mixed catalog using the advertised read cache cannot safely load these external files through a per-table token override. Please retain a safe default for mutable external sources or wire an actual per-table decision before enabling their cache reads, with a catalog load/replace/read regression.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed, the per-table decision has no way through these loads. 194d85d gives a Format Table the origin-only view of its FileIO again, in Table::new, Table::from_resolved_schema and copy_with_resolved_schema, so FileSystemCatalog tables, REST external tables and token-backed tables all read, stat and write their files on the origin whatever io-cache.policy says. ObjectTable keeps an origin-only view as well, since other writers manage its objects too. A Format Table that should go through an OSS accelerator gets it by making the accelerator the origin with io-cache.origin.endpoint.

format_table_reads_replaced_files_from_origin follows your catalog probe: FileSystemCatalog create/load/scan with catalog-level io-cache.enabled=true and meta,read, the target keeping row 7 and the origin holding its replacement, row 9, under a Paimon-shaped name. The loaded table, a copy_with_options copy and a from_resolved_schema table all read 9 and the target sees no request; without the change they read 7. object_table_reads_objects_from_origin covers an Object Table's FileIO.

Comment thread crates/paimon/src/io/cache_routing.rs Outdated
// in place.
static META_FILE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(
r"^(?:(?:manifest|manifest-list|index-manifest|stat)-{UUID}-[0-9]+|manifest-{UUID}-[0-9]+\.avro\.sidecar)$"

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] Recognize the changelog manifest names produced by TableCommit

TableCommit names input-changelog manifests manifest-{uuid}-changelog-{count} (and can add .avro.sidecar), but this regex only accepts manifest-{uuid}-{count}. Those immutable metadata files therefore always go to origin. In an actual PK table with changelog-producer=input and io-cache.policy=meta,read,write, the commit and incremental Changelog read send both PUT and GET for manifest-...-changelog-0 to origin, while the ordinary manifest, manifest lists and changelog data use the target. This leaves a normal CDC metadata path outside the configured route. Please include the actual writer naming variant and add a real changelog commit/read routing regression, including its sidecar form.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks, b63dae4 accepts manifest-{uuid}-changelog-{count} and its .avro.sidecar form (the infix only for manifests, not manifest lists). test_input_changelog_manifests_use_the_io_cache_target commits a primary-key table with changelog-producer=input and manifest.sidecar.enabled=true under meta,read,write, reads the changelog incrementally, and checks that the changelog manifest PUT and GET and the sidecar PUT go to the target and none reach the origin; it fails on the previous rule. apache/paimon#10350 (bd41c99320) and apache/paimon#10351 (b449c5e266) accept the same names, so Java and Python readers of these tables route them alike.

The getting-started section now lists exists as its own policy token, with meta covering file status only (6ed10b4).

A Format Table may hold files that other writers copy in or replace under
the same name, so even a Paimon-shaped name such as
data-{uuid}-1.parquet does not make its content immutable, and a routed
scan could return the old copy a cache still holds. Table::new and
Table::from_resolved_schema now give a Format Table the origin-only view
of its FileIO, so its reads, file status lookups and writes all use the
origin whatever io-cache.policy says; other tables keep the per-request
routing.

format_table_reads_replaced_files_from_origin_with_io_cache reproduces
the review probe: the target keeps id=1, the origin holds the
replacement id=2, the scan returns [2], an append stays on the origin,
and the target sees no request.
TableCommit names input-changelog manifests
manifest-{uuid}-changelog-{count}, optionally with .avro.sidecar, but the
metadata name rule only accepted manifest-{uuid}-{count}, so these
immutable files always went to the origin. The rule now accepts the
-changelog infix for manifests (not for manifest lists).

test_input_changelog_manifests_use_the_io_cache_target commits a
primary-key table with changelog-producer=input and manifest sidecars
under meta,read,write, reads the changelog incrementally, and checks
that the changelog manifest and its sidecar are written and read
through the target and never reach the origin.
meta now covers file status only and exists is a separate token, which
the OSS Cache Routing section still described as one.
This reverts 129fad9. Format Table files may be replaced in place by
scheduled jobs, but whether that can return old content depends on the
target: one that forwards every request to OSS, such as an OSS
accelerator, never does. The REST catalog that vends the targets knows
which kind it vends and already resolves the io-cache options per table,
database and catalog, so it decides whether a Format Table uses a target,
as it does for exists. FileIO routes Format Table files by name like
those of any other table again; the docs say who decides.

@JingsongLi JingsongLi 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.

Requirement fit: SUPPORTED. Per-request routing has end-to-end value for separating immutable data/cache traffic from mutable metadata and combining OSS endpoint capacity.

Implementation: FINDINGS. Two new routing gaps below were reproduced through real FileSystemCatalog create/load/write/commit/read APIs. The existing mutable Format Table stale-read finding also still reproduces on this head; I posted the current catalog-path evidence in its original thread.

Validation on this head integrated with current main: 173 IO tests, 8 REST token FileIO tests, snapshot commit/discovery and actual input-changelog manifest/sidecar tests all pass (183 standard targeted tests). An additional real commit-recovery probe passes: after the original snapshot and data are deleted on origin while retained by the target, both recovery APIs reject the replay and the latest snapshot remains unchanged. This confirms the exists-policy fix. Independent writer-routing and external-Parquet replacement assertions fail as described. These use local HTTP OSS-compatible endpoints and actual Arrow/Parquet/BLOB data; a live OSS accelerator/STS deployment was not available.

fn data_prefixes(props: &HashMap<String, String>) -> Vec<String> {
let mut prefixes: Vec<String> = DEFAULT_DATA_PREFIXES.map(String::from).to_vec();
for key in [DATA_FILE_PREFIX, CHANGELOG_FILE_PREFIX] {
if let Some(prefix) = props.get(key).filter(|prefix| !prefix.trim().is_empty()) {

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] Pass the loaded table's custom prefixes into the router

These props come from FileIO/catalog or REST-token options, but normal table data-file.prefix / changelog-file.prefix settings live in the loaded schema. FileSystemCatalog passes its shared FileIO unchanged to Table::new, and TableWrite obtains the prefixes from that schema, so the table-specific values never reach this parser. I created and loaded a real catalog table with schema data-file.prefix=custom-, enabled meta,read,write on a data target, and wrote/committed/scanned a row: all PUT/GETs for custom-<uuid>-0.parquet went to origin, while the identical default-prefix control used the target. This silently defeats the advertised custom-prefix/data routing unless each table prefix is manually duplicated into catalog/token properties. Please propagate the resolved table prefixes into its FileIO routing context and cover a schema-only custom prefix through the actual catalog API.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks, 6341ba4 gives each table a view of its FileIO that carries the data-file.prefix and changelog-file.prefix of its schema, built in Table::new and Table::from_resolved_schema, and every request passes them to the router, through RESTTokenFileIO as well. copy_with_options and copy_with_resolved_schema add the prefixes of the new schema, so files written under a dynamic prefix use the target while earlier files keep using it.

data_files_named_with_the_schema_prefix_use_the_target creates and loads a FileSystemCatalog table with data-file.prefix=custom- only in its schema, under meta,read,write and the data whitelist, then writes, commits and scans it: the PUT and GET of custom-<uuid>-0.parquet go to the target and none to the origin, and the same holds for a copy with a dynamic- prefix. It fails without the change. test_token_file_io_routes_files_named_with_table_prefixes covers the REST token path.

Comment thread crates/paimon/src/io/cache_routing.rs Outdated
// Data files are named {prefix}{uuid}-{count}.{extension}; this matches what follows the prefix.
const UUID: &str = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
static DATA_FILE_SUFFIX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(r"^{UUID}-[0-9]+\..+$")).expect("valid data file pattern")

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] Recognize the immutable names emitted by the supported writers

The {uuid}-{count}.{extension} restriction excludes actual Paimon data files, even with the default data- prefix: the postpone writer emits data--u-<commit-user>-s-<write-id>-w-<uuid>-0.parquet (TableWrite::create_postpone_writer), and ManagedBlobWriter emits data-<uuid>.managed.blob. Through real catalog tables with meta,read,write and a data target, a bucket=-2 primary-key write sent its Parquet PUT to origin, and a primary-key BLOB table sent its managed-pack PUT and subsequent GET to origin; the ordinary Parquet control used the target. These unique, immutable payloads therefore miss both the cache reads and the extra write path/quotas this feature is intended to provide. Please recognize the existing writer formats (and their relevant companions) with routing tests driven by real writers, while preserving the separate protection needed for mutable external Format Table files.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks, 6c2f5c4 accepts the postpone writer's {prefix}-u-{commit user}-s-{write id}-w-{uuid}-{count}.{extension} and managed BLOB packs {prefix}{uuid}.managed.blob as data files, with the .index and .blobref files named after them. I also went through the other names the crate writes on current main (data, changelog and blob files, bucket and deletion vector indexes, global indexes including vector, full-text and Lumina, manifests, statistics); they already match.

postpone_bucket_files_use_the_target writes a bucket=-2 primary-key table and managed_blob_packs_use_the_target writes and reads a primary-key BLOB table through FileSystemCatalog under meta,read,write: the postpone file and the packs are written and read on the target and never on the origin. Both fail without the change. Format Table files keep using the origin whatever their names (194d85d).

…che routing

Other jobs may replace the files of a Format Table or an Object Table in
place under the same name, so a Paimon-shaped name does not make them
immutable. Leaving the choice to the catalog that vends the targets does
not hold: FileSystemCatalog shares one FileIO across its tables, and REST
external tables build theirs from the catalog options without a table
token, so a catalog with a read cache had no way to exempt them.

Table::new, Table::from_resolved_schema and copy_with_resolved_schema give
a Format Table the origin-only view of its FileIO again, and ObjectTable
keeps one for its objects, so their reads, file status lookups and writes
use the origin whatever io-cache.policy says. A Format Table that should go
through an OSS accelerator gets it by making the accelerator the origin
with io-cache.origin.endpoint.

format_table_reads_replaced_files_from_origin creates and loads a Format
Table through FileSystemCatalog with catalog-level io-cache and meta,read.
The target keeps row 7 and origin holds its replacement, row 9, under a
Paimon-shaped name; the loaded table, a copy and a table built from the
resolved schema all read 9, and the target sees no request. Without the
change they read 7. object_table_reads_objects_from_origin does the same
for an Object Table's FileIO.
A table's data-file.prefix and changelog-file.prefix live in its schema,
but the io-cache router only read them from the FileIO options, so a table
with a custom prefix sent every request for its data files to origin
unless the prefix was copied into the catalog or token options.

Each table's view of its FileIO now carries the prefixes of its schema,
and every request passes them to the router, through RESTTokenFileIO too.
copy_with_options and copy_with_resolved_schema add the prefixes of the new
schema, so files written under a dynamic prefix use the target while
earlier files keep using it.

data_files_named_with_the_schema_prefix_use_the_target writes, commits and
scans a FileSystemCatalog table whose schema alone sets data-file.prefix to
custom-, under meta,read,write and the data whitelist: the PUT and GET of
its data files, and of a copy with a dynamic- prefix, go to the target and
none to origin. test_token_file_io_routes_files_named_with_table_prefixes
covers the REST token path.
…ting

The postpone writer names its files
{prefix}-u-{commit user}-s-{write id}-w-{uuid}-{count}.{extension} and the
managed BLOB writer names its packs {prefix}{uuid}.managed.blob. Both are
written once under a unique name, but neither matched
{prefix}{uuid}-{count}.{extension}, so their reads and writes always used
origin. Both are now data files, with the .index and .blobref files named
after them. The other names the crate writes on current main (data,
changelog, blob, bucket and deletion vector indexes, global indexes,
manifests, statistics) already match.

postpone_bucket_files_use_the_target writes a bucket=-2 primary-key table
and managed_blob_packs_use_the_target writes and reads a primary-key BLOB
table through FileSystemCatalog under meta,read,write: the postpone file
and the packs are written and read on the target and never on origin.

@JingsongLi JingsongLi 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.

Requirement fit: SUPPORTED. Implementation: FINDINGS at 6c2f5c4. Per-request OSS endpoint routing has end-to-end value. The previous mutable Format Table stale-read finding and schema-prefix / postpone / managed-pack naming findings are fixed on their covered paths. Two additional production cases remain below.

Validation on the clean integration with main 272cce9: 174 IO tests, 9 REST-token FileIO tests, 5 real catalog/table routing tests, snapshot commit/discovery, and actual input-changelog routing pass (190 tests). Three independent real HTTP/Parquet/BLOB writer, external replacement, and commit-recovery probes also pass (193 total). Two further actual catalog/write/read probes reproduce the findings: old-prefix routing after persisted ALTER/reload, and stale external source bytes permanently copied into a managed BLOB pack. Retaining the old prefix in the reloaded FileIO context fixes the first probe; using origin-only FileIO for descriptor-source ingestion fixes the second. The temporary controls were restored after verification.

These checks use local HTTP OSS-compatible endpoints and actual persisted Arrow/Parquet/BLOB data. A live OSS accelerator / production STS deployment was not available.

if options.is_format_table() {
return file_io.origin_only();
}
file_io.with_file_prefixes([options.data_file_prefix(), options.changelog_file_prefix()])

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] Preserve retained data-file routing across table reloads

Only the latest loaded schema's prefixes reach this view. I reproduced with a real FileSystemCatalog and HTTP OSS endpoints under meta,read,write / whitelist=data: create data-file.prefix=older-, write/commit/read through the target, ALTER that option to newer-, then load, append, and load again. The fresh scan returns both rows correctly, but the old committed older--0.parquet GET goes only to origin; target sees no request for it. These files remain immutable Paimon data, so a supported prefix change/restart silently loses the configured cache/accelerator traffic allocation for existing data. The same risk exists for dynamic prefixes whose write options are not persisted. The current test retains one FileIO clone that remembers old prefixes, which does not exercise fresh loads. Please recover trusted routing context for committed files across reloads, rather than only the latest in-memory schema prefixes, and add the persisted ALTER/reload regression. Retaining older- in the reloaded context makes the actual endpoint assertion pass. No row-data loss was observed in this case.

}),
FileIOBackend::Storage(storage) => {
let (op, relative_path) = self.create_static(path)?;
let (op, relative_path) = self.create_routed_static(path, &self.file_prefixes)?;

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] Read independently owned BLOB ingestion sources from origin

The new routing also reaches BlobFormatWriter's external descriptor source reads through UriInput, using only the source basename to infer immutability. I reproduced with policy=read / whitelist=data: external oss://bkt/import/data--1.png contains NEW on origin and retained OLD on target; pass BlobDescriptor(uri,0,3) as a PK BLOB input and perform normal TableWrite / prepare / commit. The source GET reaches target only, and the newly committed managed pack permanently contains OLD. Reading that pack through an origin-only table still returns OLD, so this is incorrect persisted payload. External descriptor URIs are supported inputs (see primary_key_blob_copies_input_descriptor_into_managed_pack) and have no immutable-source contract; the Format/Object Table guards do not protect this source owned outside the normal table. Please use origin for independently owned descriptor-source ingestion in both BLOB writer paths, or establish trusted source ownership before routing. Preserve routing for the new immutable outputs. An origin-only input FileIO control copies NEW and passes the actual write/read regression.

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