This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new a8abfafbba [core][rest] Report partition statistics when registering
partitions (#9295)
a8abfafbba is described below
commit a8abfafbbadeafad5723a51a7c888285e5d6208a
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Thu Aug 20 09:47:08 2026 +0800
[core][rest] Report partition statistics when registering partitions (#9295)
---
docs/static/rest-catalog-open-api.yaml | 8 +
.../main/java/org/apache/paimon/rest/RESTApi.java | 41 ++++-
.../rest/requests/CreatePartitionsRequest.java | 66 ++++++-
.../paimon/rest/HttpClientRetrySafetyTest.java | 109 +++++++----
.../org/apache/paimon/catalog/CachingCatalog.java | 9 +-
.../java/org/apache/paimon/catalog/Catalog.java | 41 ++++-
.../org/apache/paimon/catalog/DelegateCatalog.java | 9 +-
.../java/org/apache/paimon/rest/RESTCatalog.java | 11 +-
.../format/CatalogFormatTablePartitionManager.java | 103 ++++++++++-
.../paimon/table/format/FormatTableCommit.java | 4 +-
.../table/format/FormatTablePartitionManager.java | 28 ++-
.../apache/paimon/catalog/CachingCatalogTest.java | 26 ++-
.../apache/paimon/catalog/DelegateCatalogTest.java | 87 +++++++++
.../apache/paimon/rest/MockRESTCatalogTest.java | 160 ++++++++++++++++
.../org/apache/paimon/rest/RESTApiJsonTest.java | 49 +++++
.../org/apache/paimon/rest/RESTCatalogServer.java | 114 +++++++++++-
.../org/apache/paimon/rest/RESTCatalogTest.java | 5 +-
.../CatalogFormatTablePartitionManagerTest.java | 202 ++++++++++++++++++++-
.../format/CatalogManagedPartitionScanTest.java | 6 +-
.../format/FormatTablePartitionRepairTest.java | 7 +-
.../FormatTablePartitionDdlPlanningTest.scala | 41 +++--
.../FormatTablePartitionManagementTest.scala | 50 +++--
.../CatalogManagedPartitionMsckRepairTest.scala | 13 +-
23 files changed, 1070 insertions(+), 119 deletions(-)
diff --git a/docs/static/rest-catalog-open-api.yaml
b/docs/static/rest-catalog-open-api.yaml
index 452919f53f..8550851f50 100644
--- a/docs/static/rest-catalog-open-api.yaml
+++ b/docs/static/rest-catalog-open-api.yaml
@@ -2489,6 +2489,14 @@ components:
ignoreIfExists:
type: boolean
default: true
+ partitionStatistics:
+ description: Statistics of the partitions being created, matched to
partitionSpecs by their spec rather than by position, so they may cover only
some of them. A field left negative was never measured, which is not the same
as zero.
+ type: [ array, "null" ]
+ items:
+ $ref: '#/components/schemas/PartitionStatistics'
+ replaceStatistics:
+ description: Whether partitionStatistics replace the stored values
rather than add to them; required whenever partitionStatistics is present, and
absent otherwise. Replacing overwrites recordCount, fileSizeInBytes, fileCount
and lastFileCreationTime; adding sums the three counts and keeps the later
lastFileCreationTime, since two timestamps do not add. A field reported as
unknown leaves the stored one alone either way, and totalBuckets is never
combined. A client that reports o [...]
+ type: [ boolean, "null" ]
CreatePartitionsResponse:
type: object
required:
diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java
b/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java
index 401921c8d1..238a3969ff 100644
--- a/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java
+++ b/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java
@@ -872,16 +872,39 @@ public class RESTApi {
restAuthFunction);
}
- /** Create partitions for table, ignoring partitions which already exist.
*/
- public CreatePartitionsResponse createPartitions(
- Identifier identifier, List<Map<String, String>> partitions) {
- return createPartitions(identifier, partitions, true);
- }
-
- /** Create partitions for table. */
+ /**
+ * Create partitions for table, optionally reporting their statistics in
the same request, so
+ * that a partition is never registered by a request whose statistics
failed on their own. A
+ * server that stores no statistics still registers the partitions.
+ *
+ * <p>How a report combines with the stored values is per field. Replacing
overwrites all four
+ * of recordCount, fileSizeInBytes, fileCount and lastFileCreationTime;
adding sums the three
+ * counts and keeps the later creation time, since two timestamps do not
add. A field reported
+ * as unknown leaves the stored one alone either way, and a report never
creates or removes a
+ * partition row.
+ *
+ * @param identifier database name and table name
+ * @param partitions partitions to be created
+ * @param ignoreIfExists if false, fail when any partition already exists
and apply none of the
+ * batch
+ * @param statistics statistics to report, matched to {@code partitions}
by {@link
+ * PartitionStatistics#spec()} rather than by position, or null to
report none
+ * @param replaceStatistics whether the report replaces the stored values
rather than adding to
+ * them; ignored when {@code statistics} is null, and not sent at all
in that case
+ * @return the partitions the server created and the ones it already held
+ */
public CreatePartitionsResponse createPartitions(
- Identifier identifier, List<Map<String, String>> partitions,
boolean ignoreIfExists) {
- CreatePartitionsRequest request = new
CreatePartitionsRequest(partitions, ignoreIfExists);
+ Identifier identifier,
+ List<Map<String, String>> partitions,
+ boolean ignoreIfExists,
+ @Nullable List<PartitionStatistics> statistics,
+ boolean replaceStatistics) {
+ CreatePartitionsRequest request =
+ new CreatePartitionsRequest(
+ partitions,
+ ignoreIfExists,
+ statistics,
+ statistics == null ? null : replaceStatistics);
return client.post(
resourcePaths.partitions(identifier.getDatabaseName(),
identifier.getObjectName()),
request,
diff --git
a/paimon-api/src/main/java/org/apache/paimon/rest/requests/CreatePartitionsRequest.java
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/CreatePartitionsRequest.java
index 506c66406e..dadf1f9faa 100644
---
a/paimon-api/src/main/java/org/apache/paimon/rest/requests/CreatePartitionsRequest.java
+++
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/CreatePartitionsRequest.java
@@ -18,11 +18,14 @@
package org.apache.paimon.rest.requests;
+import org.apache.paimon.partition.PartitionStatistics;
import org.apache.paimon.rest.RESTRequest;
import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonCreator;
import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGetter;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonIgnore;
import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonInclude;
import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonProperty;
import javax.annotation.Nullable;
@@ -30,12 +33,20 @@ import javax.annotation.Nullable;
import java.util.List;
import java.util.Map;
-/** Request for creating partitions. */
+/**
+ * Request for creating partitions.
+ *
+ * <p>Statistics ride along optionally, matched to {@code partitionSpecs} by
{@link
+ * PartitionStatistics#spec()} rather than by position, so they may cover only
some of them. Both
+ * statistics fields are absent unless the client reports.
+ */
@JsonIgnoreProperties(ignoreUnknown = true)
public class CreatePartitionsRequest implements RESTRequest {
private static final String FIELD_PARTITION_SPECS = "partitionSpecs";
private static final String FIELD_IGNORE_IF_EXISTS = "ignoreIfExists";
+ private static final String FIELD_PARTITION_STATISTICS =
"partitionStatistics";
+ private static final String FIELD_REPLACE_STATISTICS = "replaceStatistics";
@JsonProperty(FIELD_PARTITION_SPECS)
private final List<Map<String, String>> partitionSpecs;
@@ -43,16 +54,36 @@ public class CreatePartitionsRequest implements RESTRequest
{
@JsonProperty(FIELD_IGNORE_IF_EXISTS)
private final boolean ignoreIfExists;
+ @JsonProperty(FIELD_PARTITION_STATISTICS)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ @Nullable
+ private final List<PartitionStatistics> partitionStatistics;
+
+ @JsonProperty(FIELD_REPLACE_STATISTICS)
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ @Nullable
+ private final Boolean replaceStatistics;
+
public CreatePartitionsRequest(List<Map<String, String>> partitionSpecs) {
this(partitionSpecs, true);
}
+ public CreatePartitionsRequest(
+ List<Map<String, String>> partitionSpecs, @Nullable Boolean
ignoreIfExists) {
+ this(partitionSpecs, ignoreIfExists, null, null);
+ }
+
@JsonCreator
public CreatePartitionsRequest(
@JsonProperty(FIELD_PARTITION_SPECS) List<Map<String, String>>
partitionSpecs,
- @JsonProperty(FIELD_IGNORE_IF_EXISTS) @Nullable Boolean
ignoreIfExists) {
+ @JsonProperty(FIELD_IGNORE_IF_EXISTS) @Nullable Boolean
ignoreIfExists,
+ @JsonProperty(FIELD_PARTITION_STATISTICS) @Nullable
+ List<PartitionStatistics> partitionStatistics,
+ @JsonProperty(FIELD_REPLACE_STATISTICS) @Nullable Boolean
replaceStatistics) {
this.partitionSpecs = partitionSpecs;
this.ignoreIfExists = ignoreIfExists == null || ignoreIfExists;
+ this.partitionStatistics = partitionStatistics;
+ this.replaceStatistics = replaceStatistics;
}
@JsonGetter(FIELD_PARTITION_SPECS)
@@ -64,4 +95,35 @@ public class CreatePartitionsRequest implements RESTRequest {
public boolean ignoreIfExists() {
return ignoreIfExists;
}
+
+ /** Reported statistics, or null when the client reports none. */
+ @JsonGetter(FIELD_PARTITION_STATISTICS)
+ @Nullable
+ public List<PartitionStatistics> getPartitionStatistics() {
+ return partitionStatistics;
+ }
+
+ /**
+ * Whether the reported statistics replace what the catalog holds rather
than adding to it; null
+ * when none are reported.
+ */
+ @JsonGetter(FIELD_REPLACE_STATISTICS)
+ @Nullable
+ public Boolean replaceStatistics() {
+ return replaceStatistics;
+ }
+
+ /**
+ * Registering is an upsert and replacing lands on the same value twice,
so both survive being
+ * sent again. Adding does not: a second delivery is counted again. A
request that reports no
+ * statistics increments nothing and so keeps its retry, which is the
shape batching a create
+ * leaves behind.
+ */
+ @JsonIgnore
+ @Override
+ public boolean isRetrySafe() {
+ return partitionStatistics == null
+ || partitionStatistics.isEmpty()
+ || Boolean.TRUE.equals(replaceStatistics);
+ }
}
diff --git
a/paimon-api/src/test/java/org/apache/paimon/rest/HttpClientRetrySafetyTest.java
b/paimon-api/src/test/java/org/apache/paimon/rest/HttpClientRetrySafetyTest.java
index 8aca4d9947..d6c836dfcc 100644
---
a/paimon-api/src/test/java/org/apache/paimon/rest/HttpClientRetrySafetyTest.java
+++
b/paimon-api/src/test/java/org/apache/paimon/rest/HttpClientRetrySafetyTest.java
@@ -18,8 +18,10 @@
package org.apache.paimon.rest;
+import org.apache.paimon.partition.PartitionStatistics;
import org.apache.paimon.rest.exceptions.ServiceUnavailableException;
-import org.apache.paimon.rest.responses.ListDatabasesResponse;
+import org.apache.paimon.rest.requests.CreatePartitionsRequest;
+import org.apache.paimon.rest.responses.CreatePartitionsResponse;
import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGetter;
@@ -33,21 +35,26 @@ import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
- * Tests that a POST declaring itself unsafe to replay is sent exactly once,
and that every other
- * POST keeps the 429/503 retry it has always had.
+ * Tests that a POST the server cannot absorb twice is sent exactly once, and
that every other POST
+ * keeps the 429/503 retry it has always had.
*
* <p>The server here refuses only the first attempt, so a retried request
succeeds on its second
* one: the request count separates "sent once" from "sent again" without
waiting out five backoffs.
*/
public class HttpClientRetrySafetyTest {
- private static final String PATH = "/databases";
+ private static final String PATH = "/partitions";
+
+ private static final Map<String, String> SPEC =
Collections.singletonMap("dt", "20260728");
private HttpServer server;
private HttpClient client;
@@ -61,10 +68,10 @@ public class HttpClientRetrySafetyTest {
exchange -> {
if (requests.incrementAndGet() == 1) {
// A proxy answering 503 says nothing about whether
the server applied the
- // request; this is exactly the shape that applies a
request twice.
+ // request; this is exactly the shape that double
counts an ADD report.
respond(exchange, 503,
"{\"message\":\"busy\",\"code\":503}");
} else {
- respond(exchange, 200, "{\"databases\":[\"db\"]}");
+ respond(exchange, 200,
"{\"created\":[],\"existed\":[]}");
}
});
server.start();
@@ -79,20 +86,34 @@ public class HttpClientRetrySafetyTest {
}
@Test
- public void testARequestThatDeclaresItselfUnsafeIsNotRetried() {
- assertThatThrownBy(() -> post(new UnsafeToRetry()))
+ public void testAnAddingReportIsNotRetried() {
+ assertThatThrownBy(() -> post(request(false, statistics())))
.isInstanceOf(ServiceUnavailableException.class);
- // Retrying would apply the same request a second time, and nothing
downstream could see it.
+ // Retrying would add the same increment a second time.
assertThat(requests.get()).isEqualTo(1);
}
@Test
- public void testARequestThatNeverHeardOfRetrySafetyKeepsItsRetry() {
- // The regression this guards against is the global one: every request
type implements
- // RESTRequest and rides the interface default, so the case above
would still pass if the
- // default flipped to false and silently took 429/503 retry away from
commits, database
- // creation and every other POST in the catalog.
+ public void testAReplacingReportIsRetried() {
+ assertThat(post(request(true, statistics()))).isNotNull();
+
+ // Replacing lands on the same value, so a second delivery changes
nothing.
+ assertThat(requests.get()).isEqualTo(2);
+ }
+
+ @Test
+ public void testARequestCarryingNoReportIsRetried() {
+ assertThat(post(new
CreatePartitionsRequest(Collections.singletonList(SPEC)))).isNotNull();
+
+ assertThat(requests.get()).isEqualTo(2);
+ }
+
+ @Test
+ public void testARequestThatNeverHeardOfReportsKeepsItsRetry() {
+ // Nearly every request type rides the interface default, so every
case above would still
+ // pass if that default flipped to false and took 429/503 retry away
from every other POST
+ // in the catalog. This is the only case that rides it.
assertThat(new DefaultRetrySafety().isRetrySafe()).isTrue();
assertThat(post(new DefaultRetrySafety())).isNotNull();
@@ -101,37 +122,57 @@ public class HttpClientRetrySafetyTest {
@Test
public void testRetrySafetyNeverReachesTheWire() {
- // isRetrySafe is how the client treats the request, not something the
server is told. It is
- // a getter on a serialized type, so without @JsonIgnore it would show
up in the body.
- assertThat(RESTUtil.encodedBody(new
UnsafeToRetry())).doesNotContain("retrySafe");
+ // isRetrySafe is how the client treats the request, not something the
server is told.
+ // Without the @JsonIgnore on the interface default it would show up
in every body.
assertThat(RESTUtil.encodedBody(new
DefaultRetrySafety())).doesNotContain("retrySafe");
}
- /** A request that leaves {@link RESTRequest#isRetrySafe()} at its
default, as all others do. */
- private static class DefaultRetrySafety implements RESTRequest {
+ @Test
+ public void testAReportOfNoStatisticsIsRetried() {
+ // Splitting a large create leaves batches with an empty statistics
list; they increment
+ // nothing, so they keep their retry.
+ assertThat(post(request(false, Collections.emptyList()))).isNotNull();
- @JsonGetter("name")
- public String getName() {
- return "db";
- }
+ assertThat(requests.get()).isEqualTo(2);
}
- /** A request that must reach the server at most once. */
- private static class UnsafeToRetry implements RESTRequest {
+ @Test
+ public void testOnlyANonEmptyAddingReportDeclaresItselfUnsafeToRetry() {
+ assertThat(request(false, statistics()).isRetrySafe()).isFalse();
+ assertThat(request(false,
Collections.emptyList()).isRetrySafe()).isTrue();
+ assertThat(request(true, statistics()).isRetrySafe()).isTrue();
+ assertThat(new
CreatePartitionsRequest(Collections.singletonList(SPEC)).isRetrySafe())
+ .isTrue();
+ // A request that leaves the flag out reports nothing, so it is
replayable whatever the
+ // flag would have said.
+ assertThat(
+ new CreatePartitionsRequest(
+ Collections.singletonList(SPEC), true,
null, null)
+ .isRetrySafe())
+ .isTrue();
+ }
- @JsonGetter("name")
- public String getName() {
- return "db";
- }
+ /** A request that leaves {@link RESTRequest#isRetrySafe()} at its
default, as nearly all do. */
+ private static class DefaultRetrySafety implements RESTRequest {
- @Override
- public boolean isRetrySafe() {
- return false;
+ @JsonGetter("partitionSpecs")
+ public List<Map<String, String>> getPartitionSpecs() {
+ return Collections.singletonList(SPEC);
}
}
- private ListDatabasesResponse post(RESTRequest request) {
- return client.post(PATH, request, ListDatabasesResponse.class, null);
+ private CreatePartitionsResponse post(RESTRequest request) {
+ return client.post(PATH, request, CreatePartitionsResponse.class,
null);
+ }
+
+ private static CreatePartitionsRequest request(
+ boolean replaceStatistics, List<PartitionStatistics> statistics) {
+ return new CreatePartitionsRequest(
+ Collections.singletonList(SPEC), true, statistics,
replaceStatistics);
+ }
+
+ private static List<PartitionStatistics> statistics() {
+ return Collections.singletonList(new PartitionStatistics(SPEC, 3L,
300L, 1L, 1000L, -1));
}
private static void respond(HttpExchange exchange, int statusCode, String
body)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/catalog/CachingCatalog.java
b/paimon-core/src/main/java/org/apache/paimon/catalog/CachingCatalog.java
index 01f04cbba3..e2a2558dc0 100644
--- a/paimon-core/src/main/java/org/apache/paimon/catalog/CachingCatalog.java
+++ b/paimon-core/src/main/java/org/apache/paimon/catalog/CachingCatalog.java
@@ -346,9 +346,14 @@ public class CachingCatalog extends DelegateCatalog {
@Override
public void createPartitions(
- Identifier identifier, List<Map<String, String>> partitions,
boolean ignoreIfExists)
+ Identifier identifier,
+ List<Map<String, String>> partitions,
+ boolean ignoreIfExists,
+ @Nullable List<PartitionStatistics> statistics,
+ boolean replaceStatistics)
throws TableNotExistException {
- wrapped.createPartitions(identifier, partitions, ignoreIfExists);
+ wrapped.createPartitions(
+ identifier, partitions, ignoreIfExists, statistics,
replaceStatistics);
if (partitionCache != null) {
partitionCache.invalidate(identifier);
}
diff --git a/paimon-core/src/main/java/org/apache/paimon/catalog/Catalog.java
b/paimon-core/src/main/java/org/apache/paimon/catalog/Catalog.java
index 5b437efbab..9653c67d01 100644
--- a/paimon-core/src/main/java/org/apache/paimon/catalog/Catalog.java
+++ b/paimon-core/src/main/java/org/apache/paimon/catalog/Catalog.java
@@ -31,6 +31,7 @@ import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.rest.responses.GetTagResponse;
import org.apache.paimon.schema.Schema;
import org.apache.paimon.schema.SchemaChange;
+import org.apache.paimon.table.CatalogEnvironment;
import org.apache.paimon.table.Instant;
import org.apache.paimon.table.Table;
import org.apache.paimon.table.TableSnapshot;
@@ -1040,21 +1041,23 @@ public interface Catalog extends AutoCloseable {
// ==================== Partition Modifications ==========================
/**
- * Whether this catalog supports partition modification for tables.
+ * Whether committing a table through this catalog maintains that table's
partitions in the
+ * catalog.
*
- * <p>If not, following methods will do nothing:
+ * <p>What this gates is the handler a table is given: {@link
+ * CatalogEnvironment#partitionModification()} is null for a catalog that
says false, so the
+ * commits of a table loaded from it register, alter and drop nothing. It
does not disable the
+ * methods themselves, which is how a Format Table with catalog-managed
partitions registers
+ * what a commit wrote even though its catalog reports false here:
*
* <ul>
* <li>{@link #createPartitions(Identifier, List)}.
* <li>{@link #alterPartitions(Identifier, List)}.
- * </ul>
- *
- * <p>If not, following method will be exactly the same as directly using
{@link
- * BatchTableCommit#truncatePartitions}:
- *
- * <ul>
* <li>{@link #dropPartitions(Identifier, List)}.
* </ul>
+ *
+ * <p>A catalog that keeps no partitions of its own inherits defaults that
match: creating and
+ * altering do nothing, and dropping is exactly {@link
BatchTableCommit#truncatePartitions}.
*/
boolean supportsPartitionModification();
@@ -1069,16 +1072,34 @@ public interface Catalog extends AutoCloseable {
throws TableNotExistException {}
/**
- * Create partitions of the specify table with explicit existence
semantics.
+ * Create partitions of the specify table, with explicit existence
semantics and optionally
+ * reporting statistics for them in the same call.
+ *
+ * <p>The statistics are matched to {@code partitions} by {@link
PartitionStatistics#spec()}, so
+ * they may cover only some of them, and {@code replaceStatistics} says
whether they replace
+ * what the catalog already holds or add to it. What decides whether they
survive is whether a
+ * catalog overrides this method: one that does not registers the
partitions exactly as {@link
+ * #createPartitions(Identifier, List)} does and drops the report, however
much of it the
+ * catalog could have stored, and for a catalog that keeps no partitions
at all that means it
+ * does nothing.
*
* @param identifier path of the table to create partitions
* @param partitions partitions to be created
* @param ignoreIfExists if false, fail when any partition already exists
and apply none of the
* batch; if true, behave like {@link #createPartitions(Identifier,
List)}
+ * @param statistics statistics to report, or null to report none
+ * @param replaceStatistics whether the report replaces the stored values
rather than adding to
+ * them; ignored when {@code statistics} is null
* @throws TableNotExistException if the table does not exist
+ * @throws UnsupportedOperationException if {@code ignoreIfExists} is
false and the catalog does
+ * not implement strict creation, which is what the default here does
*/
default void createPartitions(
- Identifier identifier, List<Map<String, String>> partitions,
boolean ignoreIfExists)
+ Identifier identifier,
+ List<Map<String, String>> partitions,
+ boolean ignoreIfExists,
+ @Nullable List<PartitionStatistics> statistics,
+ boolean replaceStatistics)
throws TableNotExistException {
if (!ignoreIfExists) {
throw new UnsupportedOperationException(
diff --git
a/paimon-core/src/main/java/org/apache/paimon/catalog/DelegateCatalog.java
b/paimon-core/src/main/java/org/apache/paimon/catalog/DelegateCatalog.java
index 13faef105c..6c691e6cee 100644
--- a/paimon-core/src/main/java/org/apache/paimon/catalog/DelegateCatalog.java
+++ b/paimon-core/src/main/java/org/apache/paimon/catalog/DelegateCatalog.java
@@ -327,9 +327,14 @@ public abstract class DelegateCatalog implements Catalog {
@Override
public void createPartitions(
- Identifier identifier, List<Map<String, String>> partitions,
boolean ignoreIfExists)
+ Identifier identifier,
+ List<Map<String, String>> partitions,
+ boolean ignoreIfExists,
+ @Nullable List<PartitionStatistics> statistics,
+ boolean replaceStatistics)
throws TableNotExistException {
- wrapped.createPartitions(identifier, partitions, ignoreIfExists);
+ wrapped.createPartitions(
+ identifier, partitions, ignoreIfExists, statistics,
replaceStatistics);
}
@Override
diff --git a/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java
b/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java
index f1b5d94cc1..a24fb23549 100644
--- a/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java
+++ b/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java
@@ -747,15 +747,20 @@ public class RESTCatalog implements Catalog {
@Override
public void createPartitions(Identifier identifier, List<Map<String,
String>> partitions)
throws TableNotExistException {
- createPartitions(identifier, partitions, true);
+ createPartitions(identifier, partitions, true, null, false);
}
@Override
public void createPartitions(
- Identifier identifier, List<Map<String, String>> partitions,
boolean ignoreIfExists)
+ Identifier identifier,
+ List<Map<String, String>> partitions,
+ boolean ignoreIfExists,
+ @Nullable List<PartitionStatistics> statistics,
+ boolean replaceStatistics)
throws TableNotExistException {
try {
- api.createPartitions(identifier, partitions, ignoreIfExists);
+ api.createPartitions(
+ identifier, partitions, ignoreIfExists, statistics,
replaceStatistics);
} catch (NoSuchResourceException e) {
throw new TableNotExistException(identifier);
} catch (ForbiddenException e) {
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManager.java
b/paimon-core/src/main/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManager.java
index 4d1009f196..1f4d4575a4 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManager.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManager.java
@@ -23,6 +23,7 @@ import org.apache.paimon.catalog.Catalog;
import org.apache.paimon.catalog.CatalogLoader;
import org.apache.paimon.catalog.Identifier;
import org.apache.paimon.partition.Partition;
+import org.apache.paimon.partition.PartitionStatistics;
import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.utils.FunctionWithException;
import org.apache.paimon.utils.StringUtils;
@@ -31,6 +32,7 @@ import javax.annotation.Nullable;
import java.util.ArrayList;
import java.util.Collections;
+import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
@@ -58,7 +60,7 @@ class CatalogFormatTablePartitionManager implements
FormatTablePartitionManager
CatalogFormatTablePartitionManager(
Identifier identifier, List<String> partitionKeys, CatalogLoader
catalogLoader) {
this.identifier = identifier;
- // Copied: the caller's list must not be able to change what counts as
a leading prefix.
+ // Copied so the caller cannot change what counts as a leading prefix.
this.partitionKeys = Collections.unmodifiableList(new
ArrayList<>(partitionKeys));
this.catalogLoader = catalogLoader;
}
@@ -107,8 +109,7 @@ class CatalogFormatTablePartitionManager implements
FormatTablePartitionManager
do {
PagedList<Partition> page = pageSupplier.page(pageToken);
if (page == null) {
- // A missing page cannot be told apart from an empty listing;
failing loudly
- // beats silently reading fewer partitions.
+ // A missing page cannot be told apart from an empty listing,
so fail loudly.
throw new IllegalStateException(
String.format(
"Catalog returned a null partition page for
format table %s.",
@@ -154,7 +155,14 @@ class CatalogFormatTablePartitionManager implements
FormatTablePartitionManager
}
@Override
- public void createPartitions(List<Map<String, String>> partitions, boolean
ignoreIfExists) {
+ public void createPartitions(
+ List<Map<String, String>> partitions,
+ boolean ignoreIfExists,
+ @Nullable List<PartitionStatistics> statistics,
+ boolean replaceStatistics) {
+ // Validated before the empty check: returning early would swallow a
malformed report.
+ Map<Map<String, String>, PartitionStatistics> statisticsBySpec =
+ validateAndIndexStatistics(statistics, partitions);
if (partitions.isEmpty()) {
return;
}
@@ -163,19 +171,89 @@ class CatalogFormatTablePartitionManager implements
FormatTablePartitionManager
if (!ignoreIfExists) {
// Rejecting the whole batch when any partition exists
is only meaningful
// if the batch stays one request, so a strict create
is never split.
- catalog.createPartitions(identifier, partitions,
false);
+ catalog.createPartitions(
+ identifier, partitions, false, statistics,
replaceStatistics);
return null;
}
- // An idempotent create is safe to split: a rerun
converges from a partially
- // applied batch.
+ // isRetrySafe() bounds the transport retry only: a
caller-level rerun of a
+ // multi-batch ADD still double counts the batches that
already landed.
for (List<Map<String, String>> batch :
batches(partitions)) {
- catalog.createPartitions(identifier, batch, true);
+ // A partition and its statistics travel in the same
request.
+ catalog.createPartitions(
+ identifier,
+ batch,
+ true,
+ statisticsOf(batch, statisticsBySpec),
+ replaceStatistics);
}
return null;
},
"create partitions");
}
+ /**
+ * Indexes the reported statistics by the partition they describe,
rejecting any that describes
+ * a partition this call does not register: a spec typo would otherwise
account for nothing, or
+ * for the wrong partition.
+ *
+ * <p>A spec that appears twice in one request is rejected too, in the
partitions as much as in
+ * the report: batching would apply the repeats once, twice, or once each.
Registration alone is
+ * an idempotent upsert, so a repeat there is harmless.
+ */
+ @Nullable
+ private Map<Map<String, String>, PartitionStatistics>
validateAndIndexStatistics(
+ @Nullable List<PartitionStatistics> statistics, List<Map<String,
String>> partitions) {
+ if (statistics == null) {
+ return null;
+ }
+ // Taken once: a repair of a pre-existing table reaches here with
every partition of the
+ // table, and getFullName() formats a string on every call.
+ String tableName = identifier.getFullName();
+ Set<Map<String, String>> registered = capacityFor(partitions.size());
+ for (Map<String, String> spec : partitions) {
+ checkArgument(
+ registered.add(spec),
+ "Partition %s of table %s is registered twice in one
request that reports "
+ + "statistics; report each partition once.",
+ spec,
+ tableName);
+ }
+ Map<Map<String, String>, PartitionStatistics> bySpec =
+ new HashMap<>(hashCapacity(statistics.size()));
+ for (PartitionStatistics statistic : statistics) {
+ checkArgument(
+ registered.contains(statistic.spec()),
+ "Statistics were reported for partition %s of table %s,
which this request "
+ + "does not register.",
+ statistic.spec(),
+ tableName);
+ checkArgument(
+ bySpec.put(statistic.spec(), statistic) == null,
+ "Statistics were reported twice for partition %s of table
%s; report each "
+ + "partition once.",
+ statistic.spec(),
+ tableName);
+ }
+ return bySpec;
+ }
+
+ @Nullable
+ private static List<PartitionStatistics> statisticsOf(
+ List<Map<String, String>> batch,
+ @Nullable Map<Map<String, String>, PartitionStatistics>
statisticsBySpec) {
+ if (statisticsBySpec == null) {
+ return null;
+ }
+ List<PartitionStatistics> ofBatch = new ArrayList<>(batch.size());
+ for (Map<String, String> spec : batch) {
+ PartitionStatistics statistic = statisticsBySpec.get(spec);
+ if (statistic != null) {
+ ofBatch.add(statistic);
+ }
+ }
+ return ofBatch;
+ }
+
@Override
public void dropPartitions(List<Map<String, String>> partitions) {
if (partitions.isEmpty()) {
@@ -193,6 +271,15 @@ class CatalogFormatTablePartitionManager implements
FormatTablePartitionManager
"drop partitions");
}
+ private static <T> Set<T> capacityFor(int size) {
+ return new HashSet<>(hashCapacity(size));
+ }
+
+ /** Room for {@code size} entries without a rehash, at the default load
factor. */
+ private static int hashCapacity(int size) {
+ return (int) (size / 0.75f) + 1;
+ }
+
private static boolean matchesPrefix(Partition partition, Map<String,
String> prefix) {
for (Map.Entry<String, String> entry : prefix.entrySet()) {
if
(!entry.getValue().equals(partition.spec().get(entry.getKey()))) {
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableCommit.java
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableCommit.java
index 278e4c4f36..152baa3ba0 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableCommit.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableCommit.java
@@ -166,8 +166,8 @@ public class FormatTableCommit implements BatchTableCommit {
committer.clean(this.fileIO);
}
if (partitionManager != null && !partitionSpecs.isEmpty()) {
- // Concurrent writers may touch the same partition, so
registration is an
- // idempotent ADD rather than a strict create.
+ // Concurrent writers may touch the same partition, so
registration ignores the
+ // ones that already exist rather than failing the commit.
partitionManager.createPartitions(new
ArrayList<>(partitionSpecs), true);
}
for (Map<String, String> partitionSpec : partitionSpecs) {
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionManager.java
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionManager.java
index 259f88bbfb..0e2c5c9385 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionManager.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionManager.java
@@ -22,6 +22,7 @@ import org.apache.paimon.annotation.Experimental;
import org.apache.paimon.catalog.CatalogLoader;
import org.apache.paimon.catalog.Identifier;
import org.apache.paimon.partition.Partition;
+import org.apache.paimon.partition.PartitionStatistics;
import org.apache.paimon.predicate.Predicate;
import javax.annotation.Nullable;
@@ -61,10 +62,31 @@ public interface FormatTablePartitionManager extends
Serializable {
List<Partition> listPartitionsByNames(List<Map<String, String>>
partitions);
/**
- * Register partitions. With {@code ignoreIfExists=false} the whole batch
is rejected when any
- * partition already exists, so such a request is never split.
+ * Register partitions, reporting no statistics for them. With {@code
ignoreIfExists=false} the
+ * whole batch is rejected when any partition already exists, so such a
request is never split.
*/
- void createPartitions(List<Map<String, String>> partitions, boolean
ignoreIfExists);
+ default void createPartitions(List<Map<String, String>> partitions,
boolean ignoreIfExists) {
+ createPartitions(partitions, ignoreIfExists, null, false);
+ }
+
+ /**
+ * Register partitions and report statistics for them in the same call, so
a partition is never
+ * registered by a request whose statistics failed on their own.
+ *
+ * <p>Statistics are matched to {@code partitions} by {@link
PartitionStatistics#spec()} and may
+ * cover only some of them; {@code replaceStatistics} says whether they
replace what the catalog
+ * holds or add to it, and is ignored when {@code statistics} is null.
Reporting never
+ * unregisters a partition.
+ *
+ * <p>This is the method an implementation provides, so that none can
report nothing by
+ * accident: a decorator that forwards only the two-argument form would
otherwise drop every
+ * report and leave the caller no way to notice.
+ */
+ void createPartitions(
+ List<Map<String, String>> partitions,
+ boolean ignoreIfExists,
+ @Nullable List<PartitionStatistics> statistics,
+ boolean replaceStatistics);
/** Unregister partitions. Metadata only; missing partitions are ignored.
*/
void dropPartitions(List<Map<String, String>> partitions);
diff --git
a/paimon-core/src/test/java/org/apache/paimon/catalog/CachingCatalogTest.java
b/paimon-core/src/test/java/org/apache/paimon/catalog/CachingCatalogTest.java
index 0961050f11..37b991fc68 100644
---
a/paimon-core/src/test/java/org/apache/paimon/catalog/CachingCatalogTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/catalog/CachingCatalogTest.java
@@ -25,6 +25,7 @@ import org.apache.paimon.fs.Path;
import org.apache.paimon.options.MemorySize;
import org.apache.paimon.options.Options;
import org.apache.paimon.partition.Partition;
+import org.apache.paimon.partition.PartitionStatistics;
import org.apache.paimon.schema.Schema;
import org.apache.paimon.schema.SchemaChange;
import org.apache.paimon.table.Table;
@@ -363,11 +364,34 @@ class CachingCatalogTest extends CatalogTestBase {
when(wrapped.listPartitions(identifier)).thenReturn(emptyList(),
singletonList(created));
assertThat(catalog.listPartitions(identifier)).isEmpty();
- catalog.createPartitions(identifier, singletonList(spec), false);
+ catalog.createPartitions(identifier, singletonList(spec), false, null,
false);
assertThat(catalog.listPartitions(identifier)).containsExactly(created);
}
+ @Test
+ public void
testCreatePartitionsWithStatisticsForwardsAndInvalidatesPartitionCache()
+ throws Exception {
+ Catalog wrapped = Mockito.mock(Catalog.class);
+ TestableCachingCatalog catalog =
+ new TestableCachingCatalog(wrapped, EXPIRATION_TTL, ticker);
+ Identifier identifier = new Identifier("db", "tbl");
+ Map<String, String> spec = singletonMap("dt", "20260717");
+ Partition created = new Partition(spec, 3, 300, 1, 1000, -1, false);
+ List<PartitionStatistics> statistics =
+ singletonList(new PartitionStatistics(spec, 3, 300, 1, 1000,
-1));
+ when(wrapped.listPartitions(identifier)).thenReturn(emptyList(),
singletonList(created));
+
+ assertThat(catalog.listPartitions(identifier)).isEmpty();
+ catalog.createPartitions(identifier, singletonList(spec), true,
statistics, false);
+
+ // Dropping the forward would leave the statistics unreported and
nothing else would say so.
+ Mockito.verify(wrapped)
+ .createPartitions(identifier, singletonList(spec), true,
statistics, false);
+ // A report changes what a partition holds, so the cached listing is
stale after it.
+
assertThat(catalog.listPartitions(identifier)).containsExactly(created);
+ }
+
@Test
public void testDeadlock() throws Exception {
Catalog underlyCatalog = this.catalog;
diff --git
a/paimon-core/src/test/java/org/apache/paimon/catalog/DelegateCatalogTest.java
b/paimon-core/src/test/java/org/apache/paimon/catalog/DelegateCatalogTest.java
new file mode 100644
index 0000000000..ec230c8504
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/catalog/DelegateCatalogTest.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.catalog;
+
+import org.apache.paimon.partition.PartitionStatistics;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests for {@link DelegateCatalog}. A missing forward does not fail: the
interface default drops
+ * down to the call that predates statistics, so only the report disappears.
+ */
+class DelegateCatalogTest {
+
+ private static final Identifier IDENTIFIER = Identifier.create("db", "t");
+
+ @Test
+ void testCreatePartitionsCarriesStatisticsAndModeToTheWrappedCatalog()
throws Exception {
+ Catalog wrapped = mock(Catalog.class);
+ Catalog delegating = new TestDelegateCatalog(wrapped);
+ List<Map<String, String>> specs =
+ Arrays.asList(
+ Collections.singletonMap("dt", "20260728"),
+ Collections.singletonMap("dt", "20260729"));
+ List<PartitionStatistics> statistics =
+ Collections.singletonList(
+ new PartitionStatistics(specs.get(0), 3L, 300L, 1L,
1000L, -1));
+
+ delegating.createPartitions(IDENTIFIER, specs, true, statistics,
false);
+
+ verify(wrapped).createPartitions(IDENTIFIER, specs, true, statistics,
false);
+ // Falling through to the two-argument call is how the statistics
would go missing.
+ verify(wrapped, never()).createPartitions(any(), anyList());
+ }
+
+ @Test
+ void testCreatePartitionsCarriesTheAbsenceOfStatisticsThrough() throws
Exception {
+ Catalog wrapped = mock(Catalog.class);
+ Catalog delegating = new TestDelegateCatalog(wrapped);
+ List<Map<String, String>> specs =
+ Collections.singletonList(Collections.singletonMap("dt",
"20260728"));
+
+ delegating.createPartitions(IDENTIFIER, specs, false, null, false);
+
+ verify(wrapped).createPartitions(IDENTIFIER, specs, false, null,
false);
+ }
+
+ /** {@link DelegateCatalog} forwards every operation; these tests never
rebuild one. */
+ private static class TestDelegateCatalog extends DelegateCatalog {
+
+ TestDelegateCatalog(Catalog wrapped) {
+ super(wrapped);
+ }
+
+ @Override
+ public CatalogLoader catalogLoader() {
+ return wrapped.catalogLoader();
+ }
+ }
+}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/rest/MockRESTCatalogTest.java
b/paimon-core/src/test/java/org/apache/paimon/rest/MockRESTCatalogTest.java
index a68b7933e5..093c8f7bf5 100644
--- a/paimon-core/src/test/java/org/apache/paimon/rest/MockRESTCatalogTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/rest/MockRESTCatalogTest.java
@@ -31,6 +31,7 @@ import org.apache.paimon.fs.local.LocalFileIO;
import org.apache.paimon.options.CatalogOptions;
import org.apache.paimon.options.Options;
import org.apache.paimon.partition.Partition;
+import org.apache.paimon.partition.PartitionStatistics;
import org.apache.paimon.predicate.FieldRef;
import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.predicate.PredicateBuilder;
@@ -303,6 +304,165 @@ class MockRESTCatalogTest extends RESTCatalogTest {
.containsExactly(partition);
}
+ @Test
+ void testReportedPartitionStatisticsAreStoredAndReadBack() throws
Exception {
+ Identifier identifier =
createFormatTableWithCatalogManagedPartitions();
+ Map<String, String> spec = Collections.singletonMap("dt", "20260717");
+ List<Map<String, String>> specs = Collections.singletonList(spec);
+ FormatTablePartitionManager partitionManager =
+ ((FormatTable)
restCatalog.getTable(identifier)).partitionManager();
+ assertThat(partitionManager).isNotNull();
+
+ // A registration on its own measures nothing, so everything starts
out unknown.
+ restCatalog.createPartitions(identifier, specs);
+ Partition registered = onlyPartition(identifier);
+
assertThat(PartitionStatistics.isKnown(registered.recordCount())).isFalse();
+
assertThat(PartitionStatistics.isKnown(registered.fileCount())).isFalse();
+
+ // ADD onto a partition nobody measured yet: the report becomes what
it holds.
+ restCatalog
+ .api()
+ .createPartitions(
+ identifier,
+ specs,
+ true,
+ Collections.singletonList(
+ new PartitionStatistics(spec, 3L, 300L, 1L,
1000L, -1)),
+ false);
+ assertStatistics(identifier, 3L, 300L, 1L, 1000L);
+
+ // ADD again, through the partition manager a writer commits with: the
counts accumulate
+ // and an older file does not move the newest one backwards.
+ partitionManager.createPartitions(
+ specs,
+ true,
+ Collections.singletonList(new PartitionStatistics(spec, 4L,
400L, 2L, 500L, -1)),
+ false);
+ assertStatistics(identifier, 7L, 700L, 3L, 1000L);
+
+ // A field reported as unknown leaves the stored one alone rather than
zeroing it.
+ restCatalog.createPartitions(
+ identifier,
+ specs,
+ true,
+ Collections.singletonList(
+ new PartitionStatistics(
+ spec,
+ PartitionStatistics.UNKNOWN,
+ 100L,
+ PartitionStatistics.UNKNOWN,
+ PartitionStatistics.UNKNOWN,
+ -1)),
+ false);
+ assertStatistics(identifier, 7L, 800L, 3L, 1000L);
+
+ // SET is the whole partition now: every reported field is replaced,
including a creation
+ // time that moves backwards because the newer files are gone.
+ restCatalog
+ .api()
+ .createPartitions(
+ identifier,
+ specs,
+ true,
+ Collections.singletonList(
+ new PartitionStatistics(spec, 5L, 500L, 1L,
700L, -1)),
+ true);
+ assertStatistics(identifier, 5L, 500L, 1L, 700L);
+
+ // Unknown is skipped under SET too: it reports nothing about that
field, not a zero.
+ restCatalog.createPartitions(
+ identifier,
+ specs,
+ true,
+ Collections.singletonList(
+ new PartitionStatistics(
+ spec,
+ PartitionStatistics.UNKNOWN,
+ 900L,
+ PartitionStatistics.UNKNOWN,
+ PartitionStatistics.UNKNOWN,
+ -1)),
+ true);
+ assertStatistics(identifier, 5L, 900L, 1L, 700L);
+
+ // Reporting never registers or unregisters anything.
+ assertThat(restCatalog.listPartitions(identifier)).hasSize(1);
+ }
+
+ @Test
+ void testStatisticsOfAnUnstoredPartitionAreDropped() throws Exception {
+ Identifier identifier =
createFormatTableWithCatalogManagedPartitions();
+ Map<String, String> spec = Collections.singletonMap("dt", "20260717");
+
+ // The statistics describe a partition this request does not register,
so the server drops
+ // them and keeps the registration.
+ restCatalog.createPartitions(
+ identifier,
+ Collections.singletonList(spec),
+ true,
+ Collections.singletonList(
+ new PartitionStatistics(
+ Collections.singletonMap("dt", "20260718"),
+ 9L,
+ 900L,
+ 3L,
+ 1000L,
+ -1)),
+ false);
+
+ assertThat(restCatalog.listPartitions(identifier))
+ .extracting(Partition::spec)
+ .containsExactly(spec);
+
assertThat(PartitionStatistics.isKnown(onlyPartition(identifier).recordCount())).isFalse();
+ }
+
+ @Test
+ void testAReportThatOnlyPartlyMatchesIsNotAppliedAtAll() throws Exception {
+ Identifier identifier =
createFormatTableWithCatalogManagedPartitions();
+ Map<String, String> stored = Collections.singletonMap("dt",
"20260717");
+ Map<String, String> absent = Collections.singletonMap("dt",
"20260718");
+ restCatalog.createPartitions(identifier,
Collections.singletonList(stored));
+
+ restCatalog.createPartitions(
+ identifier,
+ Collections.singletonList(stored),
+ true,
+ Arrays.asList(
+ new PartitionStatistics(stored, 3L, 300L, 1L, 1000L,
-1),
+ new PartitionStatistics(absent, 9L, 900L, 3L, 2000L,
-1)),
+ false);
+
+ // Applying the half that matched would count it twice on the next
report.
+ Partition partition = onlyPartition(identifier);
+
assertThat(PartitionStatistics.isKnown(partition.recordCount())).isFalse();
+
assertThat(PartitionStatistics.isKnown(partition.fileSizeInBytes())).isFalse();
+
assertThat(PartitionStatistics.isKnown(partition.fileCount())).isFalse();
+
assertThat(PartitionStatistics.isKnown(partition.lastFileCreationTime())).isFalse();
+ }
+
+ private Partition onlyPartition(Identifier identifier) throws Exception {
+ List<Partition> partitions = restCatalog.listPartitions(identifier);
+ assertThat(partitions).hasSize(1);
+ return partitions.get(0);
+ }
+
+ private void assertStatistics(
+ Identifier identifier,
+ long recordCount,
+ long fileSizeInBytes,
+ long fileCount,
+ long lastFileCreationTime)
+ throws Exception {
+ Partition partition = onlyPartition(identifier);
+ assertThat(
+ Arrays.asList(
+ partition.recordCount(),
+ partition.fileSizeInBytes(),
+ partition.fileCount(),
+ partition.lastFileCreationTime()))
+ .containsExactly(recordCount, fileSizeInBytes, fileCount,
lastFileCreationTime);
+ }
+
@Test
void testFilteredListingPreservesNextTokenAcrossSparsePage() throws
Exception {
Identifier identifier =
createFormatTableWithCatalogManagedPartitions();
diff --git
a/paimon-core/src/test/java/org/apache/paimon/rest/RESTApiJsonTest.java
b/paimon-core/src/test/java/org/apache/paimon/rest/RESTApiJsonTest.java
index 4a8b4ed964..44449fcabc 100644
--- a/paimon-core/src/test/java/org/apache/paimon/rest/RESTApiJsonTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/rest/RESTApiJsonTest.java
@@ -19,6 +19,7 @@
package org.apache.paimon.rest;
import org.apache.paimon.function.FunctionChange;
+import org.apache.paimon.partition.PartitionStatistics;
import org.apache.paimon.rest.requests.AlterDatabaseRequest;
import org.apache.paimon.rest.requests.AlterFunctionRequest;
import org.apache.paimon.rest.requests.AlterTableRequest;
@@ -64,6 +65,7 @@ import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Test for {@link RESTApi} json. */
@@ -326,6 +328,53 @@ public class RESTApiJsonTest {
CreatePartitionsRequest parsedExplicitRequest =
RESTApi.fromJson(explicitRequestJson,
CreatePartitionsRequest.class);
assertFalse(parsedExplicitRequest.ignoreIfExists());
+
+ // A client that reports nothing sends neither field, so an older
server sees exactly the
+ // request it saw before.
+ assertFalse(explicitRequestJson.contains("partitionStatistics"));
+ assertFalse(explicitRequestJson.contains("replaceStatistics"));
+ assertNull(defaultRequest.getPartitionStatistics());
+ assertNull(defaultRequest.replaceStatistics());
+ }
+
+ @Test
+ public void createPartitionsRequestCarriesStatisticsTest() throws
Exception {
+ Map<String, String> spec = Collections.singletonMap("dt", "20260728");
+ PartitionStatistics statistics =
+ new PartitionStatistics(spec, 7L, 4096L, 2L, 1753660800000L,
-1);
+ CreatePartitionsRequest request =
+ new CreatePartitionsRequest(
+ Collections.singletonList(spec),
+ true,
+ Collections.singletonList(statistics),
+ true);
+
+ String json = RESTApi.toJson(request);
+ // Asserted literally: renaming both ends together would keep every
parse below passing.
+ assertTrue(json.contains("\"partitionStatistics\""));
+ assertTrue(json.contains("\"replaceStatistics\":true"));
+ // How the client treats its own request; the server is not told and
would reject the
+ // field. It is a getter on a serialized type, so it only stays off
the wire on purpose.
+ assertFalse(json.contains("retrySafe"));
+
+ CreatePartitionsRequest parsed = RESTApi.fromJson(json,
CreatePartitionsRequest.class);
+ assertEquals(Boolean.TRUE, parsed.replaceStatistics());
+ assertEquals(Collections.singletonList(statistics),
parsed.getPartitionStatistics());
+
+ // An unknown field stays unknown across the wire rather than turning
into a zero.
+ PartitionStatistics unknown = PartitionStatistics.unknown(spec);
+ CreatePartitionsRequest unknownRequest =
+ new CreatePartitionsRequest(
+ Collections.singletonList(spec),
+ true,
+ Collections.singletonList(unknown),
+ false);
+ PartitionStatistics parsedUnknown =
+ RESTApi.fromJson(RESTApi.toJson(unknownRequest),
CreatePartitionsRequest.class)
+ .getPartitionStatistics()
+ .get(0);
+ assertFalse(PartitionStatistics.isKnown(parsedUnknown.recordCount()));
+ assertFalse(PartitionStatistics.isKnown(parsedUnknown.fileCount()));
}
@Test
diff --git
a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServer.java
b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServer.java
index c571b19c38..733b4b1c9f 100644
--- a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServer.java
+++ b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServer.java
@@ -1942,18 +1942,114 @@ public class RESTCatalogServer {
List<Map<String, String>> existed = new ArrayList<>();
for (Map<String, String> spec : request.getPartitionSpecs()) {
if (existingSpecs.add(spec)) {
- storedPartitions.add(new Partition(spec, 0, 0, 0, 0,
-1, false));
+ // A registration measures nothing, so a new partition
starts unknown.
+ storedPartitions.add(
+ new Partition(
+ spec,
+ PartitionStatistics.UNKNOWN,
+ PartitionStatistics.UNKNOWN,
+ PartitionStatistics.UNKNOWN,
+ PartitionStatistics.UNKNOWN,
+
PartitionStatistics.UNKNOWN_TOTAL_BUCKETS,
+ false));
created.add(spec);
} else {
existed.add(spec);
}
}
+ applyPartitionStatistics(
+ storedPartitions,
+ request.getPartitionStatistics(),
+ request.replaceStatistics());
return mockResponse(new CreatePartitionsResponse(created,
existed), 200);
default:
return new MockResponse().setResponseCode(404);
}
}
+ /**
+ * Folds reported statistics into the stored partitions, the way a catalog
server does:
+ * replacing overwrites, adding accumulates, a field reported as unknown
leaves the stored one
+ * alone, and no report adds or removes a partition row.
+ *
+ * <p>All or nothing: if any reported spec names a partition this table
does not hold, none of
+ * the report is applied, since a reporter sending it again would count
the applied part twice.
+ */
+ private static void applyPartitionStatistics(
+ List<Partition> storedPartitions,
+ @Nullable List<PartitionStatistics> statistics,
+ @Nullable Boolean replaceStatistics) {
+ if (statistics == null) {
+ return;
+ }
+ boolean accumulate = !Boolean.TRUE.equals(replaceStatistics);
+ Map<Map<String, String>, PartitionStatistics> reported = new
HashMap<>();
+ for (PartitionStatistics statistic : statistics) {
+ reported.put(statistic.spec(), statistic);
+ }
+ Set<Map<String, String>> storedSpecs =
+
storedPartitions.stream().map(Partition::spec).collect(Collectors.toSet());
+ if (!storedSpecs.containsAll(reported.keySet())) {
+ // Applying the half that matched would count it twice on the next
report.
+ return;
+ }
+ for (int i = 0; i < storedPartitions.size(); i++) {
+ Partition stored = storedPartitions.get(i);
+ PartitionStatistics update = reported.get(stored.spec());
+ if (update == null) {
+ continue;
+ }
+ storedPartitions.set(
+ i,
+ new Partition(
+ stored.spec(),
+ combine(stored.recordCount(),
update.recordCount(), accumulate),
+ combine(stored.fileSizeInBytes(),
update.fileSizeInBytes(), accumulate),
+ combine(stored.fileCount(), update.fileCount(),
accumulate),
+ combineLastFileCreationTime(
+ stored.lastFileCreationTime(),
+ update.lastFileCreationTime(),
+ accumulate),
+ stored.totalBuckets(),
+ stored.done()));
+ }
+ }
+
+ /**
+ * Folds a snapshot commit's report onto a stored value. That report is a
delta, so a negative
+ * is a decrement rather than an unknown, but a value nobody has measured
is replaced rather
+ * than added to: a partition registered and not yet measured holds
UNKNOWN, and UNKNOWN plus a
+ * count is a count short by one.
+ */
+ private static long accumulateDelta(long stored, long reported) {
+ return PartitionStatistics.isKnown(stored) ? stored + reported :
reported;
+ }
+
+ private static long combine(long stored, long reported, boolean
accumulate) {
+ if (!PartitionStatistics.isKnown(reported)) {
+ return stored;
+ }
+ if (!accumulate || !PartitionStatistics.isKnown(stored)) {
+ return reported;
+ }
+ return stored + reported;
+ }
+
+ /**
+ * Folds a reported creation time in: adding takes the later of the two,
setting takes what the
+ * report says even when that moves the time backwards.
+ */
+ private static long combineLastFileCreationTime(
+ long stored, long reported, boolean accumulate) {
+ if (!PartitionStatistics.isKnown(reported)) {
+ return stored;
+ }
+ if (!accumulate) {
+ return reported;
+ }
+ return Math.max(stored, reported);
+ }
+
private MockResponse dropPartitionsHandle(String data, Identifier
tableIdentifier)
throws Exception {
DropPartitionsRequest request = RESTApi.fromJson(data,
DropPartitionsRequest.class);
@@ -2868,12 +2964,16 @@ public class RESTCatalogServer {
}
return new Partition(
oldPartition.spec(),
-
oldPartition.recordCount()
- +
stats.recordCount(),
-
oldPartition.fileSizeInBytes()
- +
stats.fileSizeInBytes(),
-
oldPartition.fileCount()
- +
stats.fileCount(),
+
accumulateDelta(
+
oldPartition.recordCount(),
+
stats.recordCount()),
+
accumulateDelta(
+
oldPartition
+
.fileSizeInBytes(),
+
stats.fileSizeInBytes()),
+
accumulateDelta(
+
oldPartition.fileCount(),
+
stats.fileCount()),
Math.max(
oldPartition
.lastFileCreationTime(),
diff --git
a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogTest.java
b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogTest.java
index 8fc5e38a25..87116e3e45 100644
--- a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogTest.java
@@ -1567,7 +1567,7 @@ public abstract class RESTCatalogTest extends
CatalogTestBase {
List<Map<String, String>> partitionSpecs =
Arrays.asList(singletonMap("dt", "20260714"),
singletonMap("dt", "20260715"));
CreatePartitionsResponse response =
- restCatalog.api().createPartitions(identifier, partitionSpecs);
+ restCatalog.api().createPartitions(identifier, partitionSpecs,
true, null, false);
assertThat(response.getCreated()).containsExactlyInAnyOrderElementsOf(partitionSpecs);
assertThat(response.getExisted()).isEmpty();
@@ -1581,7 +1581,8 @@ public abstract class RESTCatalogTest extends
CatalogTestBase {
() ->
restCatalog
.api()
- .createPartitions(identifier,
conflictingSpecs, false))
+ .createPartitions(
+ identifier, conflictingSpecs,
false, null, false))
.isInstanceOf(AlreadyExistsException.class)
.hasMessageContaining("dt=20260714");
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManagerTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManagerTest.java
index dfcc422be3..0c558973b7 100644
---
a/paimon-core/src/test/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManagerTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManagerTest.java
@@ -24,6 +24,7 @@ import org.apache.paimon.catalog.CatalogLoader;
import org.apache.paimon.catalog.Identifier;
import org.apache.paimon.data.BinaryString;
import org.apache.paimon.partition.Partition;
+import org.apache.paimon.partition.PartitionStatistics;
import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.predicate.PredicateBuilder;
import org.apache.paimon.types.DataType;
@@ -446,6 +447,188 @@ class CatalogFormatTablePartitionManagerTest {
verifyNoInteractions(listCatalog);
}
+ // ------------------------------------------------------------------------
+ // reported statistics
+ // ------------------------------------------------------------------------
+
+ @Test
+ void testStatisticsRideInTheRequestOfTheirOwnPartitions() throws Exception
{
+ Catalog catalog = mock(Catalog.class);
+ List<Map<String, String>> specs = specs(2500);
+ // Astride both split points, where a partition and its statistics
could come apart.
+ List<PartitionStatistics> statistics =
+ Arrays.asList(
+ statistics(specs.get(0), 1L),
+ statistics(specs.get(999), 2L),
+ statistics(specs.get(1000), 3L),
+ statistics(specs.get(2499), 4L));
+
+ partitionManager(catalog).createPartitions(specs, true, statistics,
false);
+
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor<List<Map<String, String>>> specCaptor =
ArgumentCaptor.forClass(List.class);
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor<List<PartitionStatistics>> statisticsCaptor =
+ ArgumentCaptor.forClass(List.class);
+ verify(catalog, times(3))
+ .createPartitions(
+ eq(IDENTIFIER),
+ specCaptor.capture(),
+ eq(true),
+ statisticsCaptor.capture(),
+ eq(false));
+ List<List<Map<String, String>>> requestedSpecs =
specCaptor.getAllValues();
+ List<List<PartitionStatistics>> requestedStatistics =
statisticsCaptor.getAllValues();
+
assertThat(requestedSpecs).extracting(List::size).containsExactly(1000, 1000,
500);
+ assertThat(flatten(requestedSpecs)).isEqualTo(specs);
+ for (int request = 0; request < requestedSpecs.size(); request++) {
+ List<Map<String, String>> registered = requestedSpecs.get(request);
+ for (PartitionStatistics statistic :
requestedStatistics.get(request)) {
+ assertThat(registered).contains(statistic.spec());
+ }
+ }
+ // Split apart, never dropped or duplicated.
+
assertThat(requestedStatistics).extracting(List::size).containsExactly(2, 1, 1);
+
assertThat(requestedStatistics.stream().flatMap(List::stream).collect(Collectors.toList()))
+ .containsExactlyElementsOf(statistics);
+ }
+
+ @Test
+ void testABatchThatReportsNothingSendsAnEmptyListNotNull() throws
Exception {
+ Catalog catalog = mock(Catalog.class);
+ List<Map<String, String>> specs = specs(2500);
+ // All in the first request, so the two that follow are the ones that
report nothing.
+ List<PartitionStatistics> statistics =
+ Arrays.asList(statistics(specs.get(0), 1L),
statistics(specs.get(999), 2L));
+
+ partitionManager(catalog).createPartitions(specs, true, statistics,
false);
+
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor<List<PartitionStatistics>> statisticsCaptor =
+ ArgumentCaptor.forClass(List.class);
+ verify(catalog, times(3))
+ .createPartitions(
+ eq(IDENTIFIER), anyList(), eq(true),
statisticsCaptor.capture(), eq(false));
+ List<List<PartitionStatistics>> requestedStatistics =
statisticsCaptor.getAllValues();
+
assertThat(requestedStatistics.get(0)).containsExactlyElementsOf(statistics);
+ // Null would mean "this client does not report", which is the call
that predates
+ // statistics; empty means "this request measures nothing", which is
what happened.
+ assertThat(requestedStatistics.get(1)).isNotNull().isEmpty();
+ assertThat(requestedStatistics.get(2)).isNotNull().isEmpty();
+ }
+
+ @Test
+ void testStrictCreateWithStatisticsStaysOneRequest() throws Exception {
+ Catalog catalog = mock(Catalog.class);
+ List<Map<String, String>> specs = specs(2500);
+ List<PartitionStatistics> statistics =
+ Arrays.asList(statistics(specs.get(0), 1L),
statistics(specs.get(2499), 2L));
+
+ partitionManager(catalog).createPartitions(specs, false, statistics,
true);
+
+ verify(catalog).createPartitions(IDENTIFIER, specs, false, statistics,
true);
+ }
+
+ @Test
+ void testStatisticsForAnUnregisteredPartitionAreRejected() {
+ Catalog catalog = mock(Catalog.class);
+ FormatTablePartitionManager partitionManager =
partitionManager(catalog);
+ List<Map<String, String>> specs =
Collections.singletonList(spec("2025", "01"));
+ // A spec typo would otherwise account for nothing at all, silently.
+ List<PartitionStatistics> statistics =
+ Collections.singletonList(statistics(spec("2025", "02"), 7L));
+
+ assertThatThrownBy(() -> partitionManager.createPartitions(specs,
true, statistics, false))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("does not register")
+ .hasMessageContaining("month=02")
+
.hasMessageContaining("catalog_partition_db.catalog_partition_table");
+
+ verifyNoInteractions(catalog);
+ }
+
+ @Test
+ void testStatisticsReportedWithNoPartitionsAreRejected() {
+ Catalog catalog = mock(Catalog.class);
+ FormatTablePartitionManager partitionManager =
partitionManager(catalog);
+ // Nothing is registered here, so every reported spec is one this
request does not
+ // register. Returning quietly because the list is empty would swallow
the whole report.
+ List<PartitionStatistics> statistics =
+ Collections.singletonList(statistics(spec("2025", "01"), 7L));
+
+ assertThatThrownBy(
+ () ->
+ partitionManager.createPartitions(
+ Collections.emptyList(), true,
statistics, false))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("does not register")
+
.hasMessageContaining("catalog_partition_db.catalog_partition_table");
+
+ verifyNoInteractions(catalog);
+ }
+
+ @Test
+ void testAWellFormedEmptyReportWithNoPartitionsTouchesNoCatalog() {
+ // Validation comes first, but a report that describes nothing and
registers nothing is
+ // still nothing to send.
+ Catalog catalog = mock(Catalog.class);
+
+ partitionManager(catalog)
+ .createPartitions(Collections.emptyList(), true,
Collections.emptyList(), false);
+
+ verifyNoInteractions(catalog);
+ }
+
+ @Test
+ void testRepeatedPartitionWithStatisticsIsRejected() {
+ Catalog catalog = mock(Catalog.class);
+ FormatTablePartitionManager partitionManager =
partitionManager(catalog);
+ Map<String, String> repeated = spec("2025", "01");
+ List<Map<String, String>> specs =
+ Arrays.asList(repeated, spec("2025", "02"), spec("2025",
"01"));
+ List<PartitionStatistics> statistics =
Collections.singletonList(statistics(repeated, 7L));
+
+ assertThatThrownBy(() -> partitionManager.createPartitions(specs,
true, statistics, false))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("registered twice")
+ .hasMessageContaining("month=01")
+
.hasMessageContaining("catalog_partition_db.catalog_partition_table");
+
+ verifyNoInteractions(catalog);
+ }
+
+ @Test
+ void testRepeatedStatisticsForOnePartitionAreRejected() {
+ Catalog catalog = mock(Catalog.class);
+ FormatTablePartitionManager partitionManager =
partitionManager(catalog);
+ Map<String, String> spec = spec("2025", "01");
+ List<PartitionStatistics> statistics =
+ Arrays.asList(statistics(spec, 7L), statistics(spec, 9L));
+
+ assertThatThrownBy(
+ () ->
+ partitionManager.createPartitions(
+ Collections.singletonList(spec), true,
statistics, false))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("reported twice")
+ .hasMessageContaining("month=01")
+
.hasMessageContaining("catalog_partition_db.catalog_partition_table");
+
+ verifyNoInteractions(catalog);
+ }
+
+ @Test
+ void testRepeatedPartitionWithoutStatisticsIsAccepted() throws Exception {
+ // A bare registration is an idempotent upsert: repeating one
registers the same partition
+ // again and changes nothing, so it is not worth failing a commit over.
+ Catalog catalog = mock(Catalog.class);
+ List<Map<String, String>> specs = Arrays.asList(spec("2025", "01"),
spec("2025", "01"));
+
+ partitionManager(catalog).createPartitions(specs, true);
+
+ assertThat(capturedCreates(catalog, true, 1).get(0)).isEqualTo(specs);
+ }
+
// ------------------------------------------------------------------------
// catalog lifecycle
// ------------------------------------------------------------------------
@@ -523,7 +706,9 @@ class CatalogFormatTablePartitionManagerTest {
void testRuntimeExceptionIsRethrownUnchanged() throws Exception {
Catalog catalog = mock(Catalog.class);
RuntimeException failure = new IllegalStateException("catalog
unavailable");
- doThrow(failure).when(catalog).createPartitions(any(), anyList(),
anyBoolean());
+ doThrow(failure)
+ .when(catalog)
+ .createPartitions(any(), anyList(), anyBoolean(), any(),
anyBoolean());
FormatTablePartitionManager partitionManager =
partitionManager(catalog);
Throwable thrown = catchThrowable(() ->
partitionManager.createPartitions(specs(1), true));
@@ -559,12 +744,14 @@ class CatalogFormatTablePartitionManagerTest {
return FormatTablePartitionManager.create(IDENTIFIER, PARTITION_KEYS,
() -> catalog);
}
+ /** The specs of every request a create that reports nothing sent. */
private static List<List<Map<String, String>>> capturedCreates(
Catalog catalog, boolean ignoreIfExists, int expectedRequests)
throws Exception {
@SuppressWarnings("unchecked")
ArgumentCaptor<List<Map<String, String>>> captor =
ArgumentCaptor.forClass(List.class);
verify(catalog, times(expectedRequests))
- .createPartitions(eq(IDENTIFIER), captor.capture(),
eq(ignoreIfExists));
+ .createPartitions(
+ eq(IDENTIFIER), captor.capture(), eq(ignoreIfExists),
isNull(), eq(false));
return captor.getAllValues();
}
@@ -627,6 +814,17 @@ class CatalogFormatTablePartitionManagerTest {
return spec;
}
+ /** Statistics of one partition, told apart by their record count. */
+ private static PartitionStatistics statistics(Map<String, String> spec,
long recordCount) {
+ return new PartitionStatistics(
+ spec,
+ recordCount,
+ recordCount * 1024,
+ 1L,
+ 1753660800000L,
+ PartitionStatistics.UNKNOWN_TOTAL_BUCKETS);
+ }
+
private static List<Map<String, String>> specs(int count) {
List<Map<String, String>> specs = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/format/CatalogManagedPartitionScanTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/format/CatalogManagedPartitionScanTest.java
index 176dbf8291..56b7a18493 100644
---
a/paimon-core/src/test/java/org/apache/paimon/table/format/CatalogManagedPartitionScanTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/table/format/CatalogManagedPartitionScanTest.java
@@ -30,6 +30,7 @@ import org.apache.paimon.fs.local.LocalFileIO;
import org.apache.paimon.manifest.PartitionEntry;
import org.apache.paimon.partition.Partition;
import org.apache.paimon.partition.PartitionPredicate;
+import org.apache.paimon.partition.PartitionStatistics;
import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.predicate.PredicateBuilder;
import org.apache.paimon.table.FormatTable;
@@ -400,7 +401,10 @@ class CatalogManagedPartitionScanTest {
@Override
public void createPartitions(
- List<Map<String, String>> partitions, boolean
ignoreIfExists) {
+ List<Map<String, String>> partitions,
+ boolean ignoreIfExists,
+ @Nullable List<PartitionStatistics> statistics,
+ boolean replaceStatistics) {
throw new UnsupportedOperationException();
}
diff --git
a/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/format/FormatTablePartitionRepairTest.java
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/format/FormatTablePartitionRepairTest.java
index bc6e24d219..033285c46e 100644
---
a/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/format/FormatTablePartitionRepairTest.java
+++
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/format/FormatTablePartitionRepairTest.java
@@ -25,6 +25,7 @@ import org.apache.paimon.fs.FileStatus;
import org.apache.paimon.fs.Path;
import org.apache.paimon.fs.local.LocalFileIO;
import org.apache.paimon.partition.Partition;
+import org.apache.paimon.partition.PartitionStatistics;
import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.table.FormatTable;
import org.apache.paimon.table.format.FormatTablePartitionManager;
@@ -444,7 +445,11 @@ class FormatTablePartitionRepairTest {
}
@Override
- public void createPartitions(List<Map<String, String>> partitions,
boolean ignoreIfExists) {
+ public void createPartitions(
+ List<Map<String, String>> partitions,
+ boolean ignoreIfExists,
+ @Nullable List<PartitionStatistics> statistics,
+ boolean replaceStatistics) {
createdPartitions.add(new ArrayList<>(partitions));
createIgnoreFlags.add(ignoreIfExists);
}
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/execution/FormatTablePartitionDdlPlanningTest.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/execution/FormatTablePartitionDdlPlanningTest.scala
index 7968c8d49c..8c24e43247 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/execution/FormatTablePartitionDdlPlanningTest.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/execution/FormatTablePartitionDdlPlanningTest.scala
@@ -22,7 +22,7 @@ import org.apache.paimon.catalog.{CatalogContext, Identifier}
import org.apache.paimon.fs.{FileIO, Path}
import org.apache.paimon.fs.local.LocalFileIO
import org.apache.paimon.options.Options
-import org.apache.paimon.partition.Partition
+import org.apache.paimon.partition.{Partition, PartitionStatistics}
import org.apache.paimon.predicate.Predicate
import org.apache.paimon.spark.PaimonSparkTestWithRestCatalogBase
import org.apache.paimon.spark.catalyst.plans.logical.PaimonDropPartitions
@@ -368,7 +368,9 @@ class FormatTablePartitionDdlPlanningTest extends
PaimonSparkTestWithRestCatalog
val gateway = new FormatTablePartitionManager {
override def createPartitions(
partitions: JList[JMap[String, String]],
- ignoreIfExists: Boolean): Unit = {}
+ ignoreIfExists: Boolean,
+ statistics: JList[PartitionStatistics],
+ replaceStatistics: Boolean): Unit = {}
override def dropPartitions(partitions: JList[JMap[String, String]]):
Unit = dropCalls += 1
@@ -419,7 +421,9 @@ class FormatTablePartitionDdlPlanningTest extends
PaimonSparkTestWithRestCatalog
val gateway = new FormatTablePartitionManager {
override def createPartitions(
partitions: JList[JMap[String, String]],
- ignoreIfExists: Boolean): Unit = {}
+ ignoreIfExists: Boolean,
+ statistics: JList[PartitionStatistics],
+ replaceStatistics: Boolean): Unit = {}
override def dropPartitions(partitions: JList[JMap[String, String]]):
Unit = {
dropped = partitions.asScala.map(_.asScala.toMap).toSeq
@@ -486,7 +490,9 @@ class FormatTablePartitionDdlPlanningTest extends
PaimonSparkTestWithRestCatalog
def newGateway(): FormatTablePartitionManager = new
FormatTablePartitionManager {
override def createPartitions(
partitions: JList[JMap[String, String]],
- ignoreIfExists: Boolean): Unit = {
+ ignoreIfExists: Boolean,
+ statistics: JList[PartitionStatistics],
+ replaceStatistics: Boolean): Unit = {
val specs = partitions.asScala.map(_.asScala.toMap).toSeq
compensationCreates :+= ((specs, ignoreIfExists))
registered ++= specs
@@ -551,7 +557,9 @@ class FormatTablePartitionDdlPlanningTest extends
PaimonSparkTestWithRestCatalog
def newGateway(): FormatTablePartitionManager = new
FormatTablePartitionManager {
override def createPartitions(
partitions: JList[JMap[String, String]],
- ignoreIfExists: Boolean): Unit = {
+ ignoreIfExists: Boolean,
+ statistics: JList[PartitionStatistics],
+ replaceStatistics: Boolean): Unit = {
val specs = partitions.asScala.map(_.asScala.toMap).toSeq
compensationCreates :+= ((specs, ignoreIfExists))
registered ++= specs
@@ -615,7 +623,9 @@ class FormatTablePartitionDdlPlanningTest extends
PaimonSparkTestWithRestCatalog
val gateway = new FormatTablePartitionManager {
override def createPartitions(
partitions: JList[JMap[String, String]],
- ignoreIfExists: Boolean): Unit = {}
+ ignoreIfExists: Boolean,
+ statistics: JList[PartitionStatistics],
+ replaceStatistics: Boolean): Unit = {}
override def dropPartitions(partitions: JList[JMap[String, String]]):
Unit = {
dropped = partitions.asScala.map(_.asScala.toMap).toSeq
@@ -657,7 +667,9 @@ class FormatTablePartitionDdlPlanningTest extends
PaimonSparkTestWithRestCatalog
val gateway = new FormatTablePartitionManager {
override def createPartitions(
partitions: JList[JMap[String, String]],
- ignoreIfExists: Boolean): Unit = {}
+ ignoreIfExists: Boolean,
+ statistics: JList[PartitionStatistics],
+ replaceStatistics: Boolean): Unit = {}
override def dropPartitions(partitions: JList[JMap[String, String]]):
Unit = {
dropped = partitions.asScala.map(_.asScala.toMap).toSeq
@@ -713,7 +725,9 @@ class FormatTablePartitionDdlPlanningTest extends
PaimonSparkTestWithRestCatalog
val gateway = new FormatTablePartitionManager {
override def createPartitions(
partitions: JList[JMap[String, String]],
- ignoreIfExists: Boolean): Unit = {}
+ ignoreIfExists: Boolean,
+ statistics: JList[PartitionStatistics],
+ replaceStatistics: Boolean): Unit = {}
override def dropPartitions(partitions: JList[JMap[String, String]]):
Unit = dropCalls += 1
@@ -869,8 +883,7 @@ class FormatTablePartitionDdlPlanningTest extends
PaimonSparkTestWithRestCatalog
private def registerPartitions(tableName: String, specs: Map[String,
String]*): Unit =
paimonCatalog.createPartitions(
Identifier.create(dbName0, tableName),
- specs.map(_.asJava).asJava,
- true)
+ specs.map(_.asJava).asJava)
private def registeredPartitionSpecs(tableName: String): Set[Map[String,
String]] =
paimonCatalog
@@ -908,7 +921,9 @@ class FormatTablePartitionDdlPlanningTest extends
PaimonSparkTestWithRestCatalog
override def createPartitions(
partitions: JList[JMap[String, String]],
- ignoreIfExists: Boolean): Unit = {
+ ignoreIfExists: Boolean,
+ statistics: JList[PartitionStatistics],
+ replaceStatistics: Boolean): Unit = {
createCalls += 1
created = partitions.asScala.toSeq
this.ignoreIfExists = ignoreIfExists
@@ -941,7 +956,9 @@ class FormatTablePartitionDdlPlanningTest extends
PaimonSparkTestWithRestCatalog
override def createPartitions(
partitionsToCreate: JList[JMap[String, String]],
- ignoreIfExists: Boolean): Unit = synchronized {
+ ignoreIfExists: Boolean,
+ statistics: JList[PartitionStatistics],
+ replaceStatistics: Boolean): Unit = synchronized {
val batch = partitionsToCreate.asScala.map(_.asScala.toMap).toSeq
batches :+= batch
val duplicates = batch.filter(partitions.contains)
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/format/FormatTablePartitionManagementTest.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/format/FormatTablePartitionManagementTest.scala
index 7c36fd09be..8febee0e0a 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/format/FormatTablePartitionManagementTest.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/format/FormatTablePartitionManagementTest.scala
@@ -22,7 +22,7 @@ import org.apache.paimon.catalog.{CatalogContext, Identifier}
import org.apache.paimon.fs.{FileIO, Path}
import org.apache.paimon.fs.local.LocalFileIO
import org.apache.paimon.options.Options
-import org.apache.paimon.partition.Partition
+import org.apache.paimon.partition.{Partition, PartitionStatistics}
import org.apache.paimon.predicate.Predicate
import org.apache.paimon.table.FormatTable
import org.apache.paimon.table.format.FormatTablePartitionManager
@@ -51,7 +51,9 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
val gateway = new FormatTablePartitionManager {
override def createPartitions(
partitions: JList[JMap[String, String]],
- ignoreIfExists: Boolean): Unit = {
+ ignoreIfExists: Boolean,
+ statistics: JList[PartitionStatistics],
+ replaceStatistics: Boolean): Unit = {
forwardedPartitions = partitions.asScala.toSeq
forwardedIgnoreIfExists = ignoreIfExists
}
@@ -108,7 +110,9 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
val gateway = new FormatTablePartitionManager {
override def createPartitions(
partitions: JList[JMap[String, String]],
- ignoreIfExists: Boolean): Unit = {}
+ ignoreIfExists: Boolean,
+ statistics: JList[PartitionStatistics],
+ replaceStatistics: Boolean): Unit = {}
override def dropPartitions(partitions: JList[JMap[String, String]]):
Unit = dropCalls += 1
@@ -141,7 +145,9 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
val gateway = new FormatTablePartitionManager {
override def createPartitions(
partitions: JList[JMap[String, String]],
- ignoreIfExists: Boolean): Unit = createCalls += 1
+ ignoreIfExists: Boolean,
+ statistics: JList[PartitionStatistics],
+ replaceStatistics: Boolean): Unit = createCalls += 1
override def dropPartitions(partitions: JList[JMap[String, String]]):
Unit = {}
@@ -173,7 +179,9 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
val gateway = new FormatTablePartitionManager {
override def createPartitions(
partitions: JList[JMap[String, String]],
- ignoreIfExists: Boolean): Unit = createCalls += 1
+ ignoreIfExists: Boolean,
+ statistics: JList[PartitionStatistics],
+ replaceStatistics: Boolean): Unit = createCalls += 1
override def dropPartitions(partitions: JList[JMap[String, String]]):
Unit = {}
@@ -242,7 +250,9 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
val gateway = new FormatTablePartitionManager {
override def createPartitions(
partitions: JList[JMap[String, String]],
- ignoreIfExists: Boolean): Unit = {}
+ ignoreIfExists: Boolean,
+ statistics: JList[PartitionStatistics],
+ replaceStatistics: Boolean): Unit = {}
override def dropPartitions(partitions: JList[JMap[String, String]]):
Unit = {
// Ordering contract: the directory must still exist when the catalog
unregisters.
@@ -294,7 +304,9 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
val gateway = new FormatTablePartitionManager {
override def createPartitions(
partitions: JList[JMap[String, String]],
- ignoreIfExists: Boolean): Unit = {}
+ ignoreIfExists: Boolean,
+ statistics: JList[PartitionStatistics],
+ replaceStatistics: Boolean): Unit = {}
override def dropPartitions(partitions: JList[JMap[String, String]]):
Unit = {
dropped = partitions.asScala.map(_.asScala.toMap).toSeq
@@ -410,7 +422,9 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
val gateway = new FormatTablePartitionManager {
override def createPartitions(
partitions: JList[JMap[String, String]],
- ignoreIfExists: Boolean): Unit = {}
+ ignoreIfExists: Boolean,
+ statistics: JList[PartitionStatistics],
+ replaceStatistics: Boolean): Unit = {}
override def dropPartitions(partitions: JList[JMap[String, String]]):
Unit = {
dropped = partitions.asScala.map(_.asScala.toMap).toSeq
@@ -453,7 +467,9 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
val gateway = new FormatTablePartitionManager {
override def createPartitions(
partitions: JList[JMap[String, String]],
- ignoreIfExists: Boolean): Unit = {}
+ ignoreIfExists: Boolean,
+ statistics: JList[PartitionStatistics],
+ replaceStatistics: Boolean): Unit = {}
override def dropPartitions(partitions: JList[JMap[String, String]]):
Unit = dropCalls += 1
@@ -501,7 +517,9 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
val gateway = new FormatTablePartitionManager {
override def createPartitions(
partitions: JList[JMap[String, String]],
- ignoreIfExists: Boolean): Unit =
+ ignoreIfExists: Boolean,
+ statistics: JList[PartitionStatistics],
+ replaceStatistics: Boolean): Unit =
throw new AssertionError("An ambiguous DROP must not recreate catalog
partitions")
override def dropPartitions(partitions: JList[JMap[String, String]]):
Unit = {
@@ -611,7 +629,9 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
new FormatTablePartitionManager {
override def createPartitions(
partitions: JList[JMap[String, String]],
- ignoreIfExists: Boolean): Unit = {}
+ ignoreIfExists: Boolean,
+ statistics: JList[PartitionStatistics],
+ replaceStatistics: Boolean): Unit = {}
override def dropPartitions(partitions: JList[JMap[String, String]]):
Unit = {}
@@ -638,7 +658,9 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
override def createPartitions(
partitions: JList[JMap[String, String]],
- ignoreIfExists: Boolean): Unit = synchronized {
+ ignoreIfExists: Boolean,
+ statistics: JList[PartitionStatistics],
+ replaceStatistics: Boolean): Unit = synchronized {
val requested = partitions.asScala.map(_.asScala.toMap).toSeq
requests :+= ((requested, ignoreIfExists))
if (!ignoreIfExists) {
@@ -676,7 +698,9 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
override def createPartitions(
partitions: JList[JMap[String, String]],
- ignoreIfExists: Boolean): Unit = {}
+ ignoreIfExists: Boolean,
+ statistics: JList[PartitionStatistics],
+ replaceStatistics: Boolean): Unit = {}
override def dropPartitions(partitions: JList[JMap[String, String]]): Unit
= dropCalls += 1
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CatalogManagedPartitionMsckRepairTest.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CatalogManagedPartitionMsckRepairTest.scala
index 24e42b9c86..586099b73b 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CatalogManagedPartitionMsckRepairTest.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CatalogManagedPartitionMsckRepairTest.scala
@@ -20,7 +20,7 @@ package org.apache.paimon.spark.sql
import org.apache.paimon.catalog.Identifier
import org.apache.paimon.fs.Path
-import org.apache.paimon.partition.Partition
+import org.apache.paimon.partition.{Partition, PartitionStatistics}
import org.apache.paimon.predicate.Predicate
import org.apache.paimon.spark.{PaimonSparkTestWithRestCatalogBase,
SparkCatalog}
import org.apache.paimon.spark.execution.PaimonRepairFormatTablePartitionsExec
@@ -503,8 +503,7 @@ class CatalogManagedPartitionMsckRepairTest extends
PaimonSparkTestWithRestCatal
private def registerPartitions(tableName: String, partitions: String*): Unit
=
paimonCatalog.createPartitions(
tableIdentifier(tableName),
- partitions.map(value => Map("dt" -> value).asJava).asJava,
- true)
+ partitions.map(value => Map("dt" -> value).asJava).asJava)
private def registeredPartitions(tableName: String): Set[String] =
paimonCatalog
@@ -557,7 +556,9 @@ private[sql] class StatefulFaultCatalog(initial:
Set[Map[String, String]] = Set.
override def createPartitions(
partitions: JList[JMap[String, String]],
- ignoreIfExists: Boolean): Unit = {
+ ignoreIfExists: Boolean,
+ statistics: JList[PartitionStatistics],
+ replaceStatistics: Boolean): Unit = {
createCalls += 1
state ++= partitions.asScala.map(_.asScala.toMap)
if (failCreateAfterApply) {
@@ -644,7 +645,9 @@ private[sql] class
FaultInjectingFormatTablePartitionManager(delegate: FormatTab
override def createPartitions(
partitions: JList[JMap[String, String]],
- ignoreIfExists: Boolean): Unit = {
+ ignoreIfExists: Boolean,
+ statistics: JList[PartitionStatistics],
+ replaceStatistics: Boolean): Unit = {
delegate.createPartitions(partitions, ignoreIfExists)
MsckFaultInjection.createCalls += 1
}