This is an automated email from the ASF dual-hosted git repository.
nsivabalan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git
The following commit(s) were added to refs/heads/master by this push:
new a974f447089e feat(utilities): add record-level index sizing check to
HoodieTableHealthChecker (#20153)
a974f447089e is described below
commit a974f447089eaea3d9366ee495e88d5b5a349d58
Author: Sivabalan Narayanan <[email protected]>
AuthorDate: Thu Oct 22 08:16:57 2026 -0700
feat(utilities): add record-level index sizing check to
HoodieTableHealthChecker (#20153)
* feat(utilities): add record-level index sizing check to
HoodieTableHealthChecker
Adds RecordIndexSizingCheck (--checks record-index). The record index's
file-group count is
fixed at initialization, so the check re-runs the writer's own sizing
estimate against the index
as it is now and compares the result with the file groups it has,
globally or per data partition
for a partitioned index. It also flags any file group past the configured
maximum size.
The record estimate is taken from base-file bytes, falling back to log
bytes before the first
metadata compaction. A floor-bound ideal (min == max, or an index below
one file group's worth of
records) is reported as such and never called undersized. The worst data
partition is the one
with a real shortfall, not the lowest ratio. The report echoes the
effective sizing, the
estimate basis and the ideal's source so a verdict can be audited.
The remedy is a re-bootstrap with corrected sizing; for a partitioned
index the check says why
HoodieIndexer cannot do that today (#20343) rather than naming a command
that would build the
wrong index. Part of #19262.
---
.../hudi/utilities/HoodieTableHealthChecker.java | 6 +-
.../main/java/org/apache/hudi/utilities/README.md | 44 ++
.../utilities/health/RecordIndexSizingCheck.java | 595 +++++++++++++++++++++
.../utilities/TestHoodieTableHealthChecker.java | 2 +-
.../health/TestRecordIndexSizingCheck.java | 298 +++++++++++
.../TestRecordIndexSizingCheckWithSpark.java | 428 +++++++++++++++
6 files changed, 1370 insertions(+), 3 deletions(-)
diff --git
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieTableHealthChecker.java
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieTableHealthChecker.java
index 2d6625a008b3..8a910f1a44e1 100644
---
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieTableHealthChecker.java
+++
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieTableHealthChecker.java
@@ -32,6 +32,7 @@ import org.apache.hudi.utilities.health.HealthCheckResult;
import org.apache.hudi.utilities.health.HealthStatus;
import org.apache.hudi.utilities.health.MetadataCompactionLagCheck;
import org.apache.hudi.utilities.health.MetadataTableSyncCheck;
+import org.apache.hudi.utilities.health.RecordIndexSizingCheck;
import org.apache.hudi.utilities.health.SavepointArchivalBlockCheck;
import com.beust.jcommander.JCommander;
@@ -150,7 +151,8 @@ public class HoodieTableHealthChecker implements
Serializable {
new SavepointArchivalBlockCheck(),
new ArchivalCadenceCheck(),
new MetadataTableSyncCheck(),
- new MetadataCompactionLagCheck());
+ new MetadataCompactionLagCheck(),
+ new RecordIndexSizingCheck());
}
/**
@@ -291,7 +293,7 @@ public class HoodieTableHealthChecker implements
Serializable {
public String basePath = null;
@Parameter(names = {"--checks"}, description = "Comma-separated checks to
run, or 'all'. Available: "
- + "compaction, cleaner, savepoint, archival, mdt-sync, mdt-compaction")
+ + "compaction, cleaner, savepoint, archival, mdt-sync, mdt-compaction,
record-index")
public String checks = "all";
@Parameter(names = {"--apply-all-defaults"}, description = "Evaluate
checks against Hudi default values for any "
diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/README.md
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/README.md
index f00d6072249d..73670cb5d49b 100644
--- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/README.md
+++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/README.md
@@ -200,6 +200,50 @@ rebuild the metadata table.
Governing properties: `hoodie.metadata.enable`,
`hoodie.metadata.compact.max.delta.commits`.
+### `record-index`
+
+Whether the record-level index has enough file groups for the table it serves.
Skipped when the
+table's metadata table has no `record_index` partition.
+
+The file-group count of the record index is decided once, at initialization,
from the record count
+at that moment and the writer's sizing configuration, and it never changes
afterwards. A table that
+has since grown well past that point has index file groups far larger than
intended: every index
+lookup reads a larger file, and every index write concentrates on the same few
file groups.
+
+The check re-runs the writer's own sizing estimate against the table as it is
now and compares the
+result against the file-group count the index actually has. The record count
is *estimated* from
+the index's own footprint on storage divided by the same average record size
the writer sizes with,
+so no data file is scanned; index files are compressed, so the estimate runs
low. Two signals are
+reported:
+
+- **Undersized.** The actual count is below half the ideal. The ideal already
carries the writer's
+ growth factor, so an index initialized on a smaller table routinely sits
somewhat below it; the
+ 0.5 ratio keeps ordinary growth from alerting while a table that has doubled
past its sizing
+ still does.
+- **Oversized file group.** The largest file group is past the configured
maximum size with 1.5x
+ slack. Log files push a group past the maximum transiently between metadata
compactions, so
+ alerting at exactly the maximum would fire on healthy tables.
+
+A partitioned record index is sized per data partition, so for that layout the
count comparison is
+made per data partition and the worst one is reported
(`observed.worst.data.partition`). The layout
+the writer sizes for is read from its configuration and the layout on storage
from the index's
+file-group names; if they disagree, the count comparison is skipped,
`effective.count.comparison`
+says why, and only the file-group size rule runs.
+
+Both share one remedy, and it is disruptive: the index must be
re-bootstrapped. Drop it with
+`metadata delete-record-index` in `hudi-cli` (or `HoodieIndexer --mode
dropindex --index-types
+RECORD_INDEX`), correct the sizing configuration, then rebuild with
`HoodieIndexer --mode
+scheduleAndExecute --index-types RECORD_INDEX`. The record index is
unavailable until the rebuild
+completes and the drop cannot be undone in place, so plan it as a maintenance
window.
+
+Governing properties:
`hoodie.metadata.global.record.level.index.min.filegroup.count`,
+`hoodie.metadata.global.record.level.index.max.filegroup.count`,
+`hoodie.metadata.record.index.max.filegroup.size`,
`hoodie.metadata.record.index.growth.factor`.
+When `hoodie.metadata.record.level.index.enable` is `true` the partitioned keys
+`hoodie.metadata.record.level.index.min.filegroup.count` and
+`hoodie.metadata.record.level.index.max.filegroup.count` govern instead. The
pre-rename spellings
+of the count keys are accepted.
+
## Exit codes
| Code | Meaning |
diff --git
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/health/RecordIndexSizingCheck.java
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/health/RecordIndexSizingCheck.java
new file mode 100644
index 000000000000..9028aeb1cd40
--- /dev/null
+++
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/health/RecordIndexSizingCheck.java
@@ -0,0 +1,595 @@
+/*
+ * 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.hudi.utilities.health;
+
+import org.apache.hudi.common.config.ConfigProperty;
+import org.apache.hudi.common.config.HoodieMetadataConfig;
+import org.apache.hudi.common.config.TypedProperties;
+import org.apache.hudi.common.engine.HoodieLocalEngineContext;
+import org.apache.hudi.common.model.FileSlice;
+import org.apache.hudi.common.model.HoodieBaseFile;
+import org.apache.hudi.common.table.HoodieTableConfig;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.util.ConfigUtils;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.core.index.record.HoodieRecordIndex;
+import org.apache.hudi.metadata.HoodieBackedTableMetadata;
+import org.apache.hudi.metadata.HoodieTableMetadataUtil;
+import org.apache.hudi.metadata.MetadataPartitionType;
+
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+import static
org.apache.hudi.metadata.HoodieTableMetadataUtil.PARTITION_NAME_RECORD_INDEX;
+import static
org.apache.hudi.metadata.HoodieTableMetadataUtil.RECORD_INDEX_AVERAGE_RECORD_SIZE;
+
+/**
+ * Checks whether the record-level index is sized appropriately for the table
it serves.
+ *
+ * <p>The number of file groups in the record index is decided once, when the
index is initialized,
+ * from the record count at that moment and the writer's sizing configuration.
It never changes
+ * afterwards: every subsequent index write lands in one of those same file
groups. A table that has
+ * grown well past what its index was sized for therefore has file groups that
are each far larger
+ * than the writer's configuration intended, which slows every index lookup
and concentrates every
+ * index write on the same few file groups.
+ *
+ * <p>So the check re-runs the writer's own sizing estimate against the table
as it is now and
+ * compares that ideal against the file-group count the index actually has. It
also looks at the
+ * largest file group directly, since a group past the configured maximum size
is the symptom the
+ * operator will feel regardless of how the count came out. Both signals share
one remedy: the index
+ * has to be re-bootstrapped with corrected sizing, which is disruptive and is
spelled out in the
+ * findings.
+ *
+ * <p>A partitioned record index is sized per data partition, not as a whole,
so for that layout
+ * the count comparison is made per data partition and the worst one is
reported. Which layout the
+ * writer sizes for is read from its configuration and which layout is on
storage is read from the
+ * index's file-group names; if the two disagree, the count comparison is
skipped and the report says
+ * so, while the file-group size rule still runs.
+ *
+ * <p>The record count is estimated from the index's own footprint on storage
divided by the same
+ * average record size the writer sizes with, so that no data file is scanned.
The estimate is
+ * conservative -- index files are compressed, so the true count is higher --
and it is labelled as
+ * an estimate in the report.
+ */
+public class RecordIndexSizingCheck implements HealthCheck {
+
+ static final String NAME = "record-index";
+
+ /**
+ * Actual-to-ideal file-group ratio below which the index is called
undersized. The ideal count
+ * already carries the writer's growth factor, so an index initialized on a
smaller table sits
+ * somewhat below 1.0 during ordinary growth. The slack keeps that from
alerting while a table
+ * that has grown past what its index was sized for still does.
+ */
+ static final double MIN_ACCEPTABLE_FILE_GROUP_RATIO = 0.5;
+
+ /**
+ * How far past the configured maximum a single file group may grow before
the index is called
+ * unhealthy. File groups are bounded only at initialization; between
metadata compactions, log
+ * files push a group past the configured size transiently, so alerting at
exactly the maximum
+ * would fire on healthy tables.
+ */
+ static final double MAX_FILE_GROUP_SIZE_SLACK_FACTOR = 1.5;
+
+ @Override
+ public String getName() {
+ return NAME;
+ }
+
+ @Override
+ public String getDescription() {
+ return "Whether the record-level index has enough file groups for the
table it serves";
+ }
+
+ @Override
+ public boolean appliesTo(HealthCheckContext context) {
+ return
context.getMetaClient().getTableConfig().isMetadataPartitionAvailable(MetadataPartitionType.RECORD_INDEX);
+ }
+
+ @Override
+ public HealthCheckResult check(HealthCheckContext context) throws Exception {
+ if (!appliesTo(context)) {
+ return HealthCheckResult.skipped(NAME, String.format(
+ "Table has no record-level index: '%s' in the table configuration
does not list '%s'",
+ HoodieTableConfig.TABLE_METADATA_PARTITIONS.key(),
MetadataPartitionType.RECORD_INDEX.getPartitionPath()));
+ }
+
+ TypedProperties props = context.getProps();
+ // The layout is a fact about the table, so read it from the index
definition on storage rather
+ // than from a writer property. The property describes what a writer
wants, defaults to false
+ // when unset, and would silently send a partitioned table down the global
branch -- picking the
+ // wrong sizing keys, naming the wrong keys in the skip message, and
skipping the per-data-
+ // partition comparison entirely under --apply-all-defaults.
+ Option<Boolean> layoutOnStorage =
partitionedIndexOnStorage(context.getMetaClient());
+ boolean partitionedIndex = layoutOnStorage.orElseGet(
+ () -> ConfigUtils.getBooleanWithAltKeys(props,
HoodieMetadataConfig.RECORD_LEVEL_INDEX_ENABLE_PROP));
+ ConfigProperty<Integer> minCountProp = partitionedIndex
+ ? HoodieMetadataConfig.RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP
+ :
HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP;
+ ConfigProperty<Integer> maxCountProp = partitionedIndex
+ ? HoodieMetadataConfig.RECORD_LEVEL_INDEX_MAX_FILE_GROUP_COUNT_PROP
+ :
HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_MAX_FILE_GROUP_COUNT_PROP;
+
+ Option<String> missing = firstMissingConfig(context, minCountProp,
maxCountProp,
+ HoodieMetadataConfig.RECORD_INDEX_MAX_FILE_GROUP_SIZE_BYTES_PROP,
+ HoodieMetadataConfig.RECORD_INDEX_GROWTH_FACTOR_PROP);
+ if (missing.isPresent()) {
+ return context.skipForMissingConfig(NAME, missing.get());
+ }
+
+ SizingConfig sizing = new SizingConfig(
+ Integer.parseInt(configValue(props, minCountProp)),
+ Integer.parseInt(configValue(props, maxCountProp)),
+ Float.parseFloat(configValue(props,
HoodieMetadataConfig.RECORD_INDEX_GROWTH_FACTOR_PROP)),
+ Long.parseLong(configValue(props,
HoodieMetadataConfig.RECORD_INDEX_MAX_FILE_GROUP_SIZE_BYTES_PROP)));
+
+ HealthCheckResult.Builder result = HealthCheckResult.builder(NAME)
+ .config("observed.index.layout", partitionedIndex ? "partitioned" :
"global")
+ .config("observed.index.layout.source",
+ layoutOnStorage.isPresent() ? "index definition on storage" :
"writer properties")
+ .config(minCountProp.key(), sizing.minFileGroupCount)
+ .config(maxCountProp.key(), sizing.maxFileGroupCount)
+
.config(HoodieMetadataConfig.RECORD_INDEX_MAX_FILE_GROUP_SIZE_BYTES_PROP.key(),
sizing.maxFileGroupSizeBytes)
+ .config(HoodieMetadataConfig.RECORD_INDEX_GROWTH_FACTOR_PROP.key(),
sizing.growthFactor)
+ .config("effective.average.record.size.bytes",
RECORD_INDEX_AVERAGE_RECORD_SIZE);
+
+ // A writer configured for a layout the table does not have is worth
saying out loud: the index
+ // keeps the shape it was initialized with, so the writer's sizing keys
are not the ones
+ // governing it.
+ if (layoutOnStorage.isPresent()
+ &&
props.containsKey(HoodieMetadataConfig.RECORD_LEVEL_INDEX_ENABLE_PROP.key())
+ && ConfigUtils.getBooleanWithAltKeys(props,
HoodieMetadataConfig.RECORD_LEVEL_INDEX_ENABLE_PROP)
+ != layoutOnStorage.get()) {
+ result.finding(String.format(
+ "The index on storage is %s, but the writer sets %s=%s. The layout
is fixed at "
+ + "initialization, so the index keeps the shape it has and the
%s sizing keys are the "
+ + "ones governing it.",
+ partitionedIndex ? "partitioned" : "global",
+ HoodieMetadataConfig.RECORD_LEVEL_INDEX_ENABLE_PROP.key(),
!layoutOnStorage.get(),
+ partitionedIndex ? "partitioned" : "global"));
+ }
+
+ if (sizing.maxFileGroupSizeBytes < RECORD_INDEX_AVERAGE_RECORD_SIZE) {
+ return result.status(HealthStatus.SKIPPED)
+ .summary(String.format(
+ "'%s' is %d bytes, smaller than the %d-byte average index record
the writer sizes with, so no "
+ + "file-group count can be estimated. The writer cannot size
an index with this value either.",
+
HoodieMetadataConfig.RECORD_INDEX_MAX_FILE_GROUP_SIZE_BYTES_PROP.key(),
sizing.maxFileGroupSizeBytes,
+ RECORD_INDEX_AVERAGE_RECORD_SIZE))
+ .build();
+ }
+
+ RecordIndexFootprint footprint =
measureRecordIndex(context.getMetaClient());
+ long maxHealthyFileGroupBytes = (long) (MAX_FILE_GROUP_SIZE_SLACK_FACTOR *
sizing.maxFileGroupSizeBytes);
+ result.config("effective.max.healthy.file.group.bytes",
maxHealthyFileGroupBytes)
+ .config("observed.file.group.count", footprint.fileGroupCount)
+ .config("observed.base.file.count", footprint.baseFileCount)
+ .config("observed.log.file.count", footprint.logFileCount)
+ .config("observed.total.size.bytes", footprint.totalSizeBytes)
+ .config("observed.base.file.size.bytes", footprint.baseFileSizeBytes)
+ .config("effective.estimate.basis", footprint.estimateBasis())
+ .config("observed.largest.file.group.bytes",
footprint.largestFileGroupBytes)
+ .config("observed.largest.file.group.id",
footprint.largestFileGroupId);
+
+ Option<CountComparison> comparison = compareFileGroupCount(footprint,
partitionedIndex, sizing, result);
+ boolean undersized = comparison.isPresent() &&
comparison.get().isUndersized();
+ boolean oversizedFileGroup = footprint.largestFileGroupBytes >
maxHealthyFileGroupBytes;
+
+ if (!undersized && !oversizedFileGroup) {
+ HealthCheckResult.Builder healthy = result.status(HealthStatus.HEALTHY);
+ if (comparison.isPresent() && comparison.get().idealIsFloor) {
+ CountComparison floorBound = comparison.get();
+ // Report the floor rather than passing silently: the index is below
the configured
+ // minimum, and an operator who looks at the raw counts should find
out here why that is
+ // not a problem, instead of reading it as one the check missed.
+ healthy.summary(String.format(
+ "%s has %d file group(s), fewer than the configured minimum of %d,
but its estimated %d records are "
+ + "well inside what one file group holds, so the minimum is
the only thing calling for more",
+ floorBound.subject(), floorBound.observedFileGroupCount,
sizing.minFileGroupCount,
+ floorBound.estimatedRecordCount));
+ healthy.finding(String.format(
+ "The writer's sizing resolves to %d file group(s) for this index,
but that number comes from the "
+ + "configured minimum rather than from the table: at %d bytes
on storage the estimate is below one "
+ + "file group's worth of records. There is no shortfall to act
on, and re-bootstrapping to reach "
+ + "the minimum would buy nothing.",
+ floorBound.idealFileGroupCount, floorBound.observedSizeBytes));
+ return healthy.build();
+ }
+ return healthy
+ .summary(comparison.isPresent()
+ ? String.format("%s has %d file group(s) against an ideal of %d
for an estimated %d records; largest "
+ + "file group is %d bytes", comparison.get().subject(),
comparison.get().observedFileGroupCount,
+ comparison.get().idealFileGroupCount,
comparison.get().estimatedRecordCount, footprint.largestFileGroupBytes)
+ : String.format("Record index has %d file group(s); largest file
group is %d bytes, within the "
+ + "configured maximum. File-group count was not compared
against the writer's sizing.",
+ footprint.fileGroupCount, footprint.largestFileGroupBytes))
+ .build();
+ }
+
+ result.status(HealthStatus.UNHEALTHY);
+ if (undersized) {
+ CountComparison shortfall = comparison.get();
+ result.summary(String.format(
+ "%s has %d file group(s) but the writer's sizing calls for %d
(at least %d to be healthy)",
+ shortfall.subject(), shortfall.observedFileGroupCount,
shortfall.idealFileGroupCount,
+ shortfall.minHealthyFileGroupCount()))
+ .finding(String.format(
+ "%s was initialized with %d file group(s). Its %d bytes of %s
put it at an estimated %d records "
+ + "(an estimate: those bytes divided by the %d-byte average
record the writer sizes with), which "
+ + "with the configured growth factor and maximum file-group
size calls for %d. Today each file "
+ + "group averages %.2fx of the configured maximum file-group
size; once the configured growth "
+ + "factor is applied, each is projected to hold roughly
%.1fx more keys than the sizing intends, "
+ + "so index lookups read larger files and every index write
concentrates on the same few file groups.",
+ shortfall.subject(), shortfall.observedFileGroupCount,
shortfall.estimateBytes, shortfall.estimateBasis,
+ shortfall.estimatedRecordCount,
RECORD_INDEX_AVERAGE_RECORD_SIZE, shortfall.idealFileGroupCount,
+ shortfall.averageFileGroupBytes() / (double)
sizing.maxFileGroupSizeBytes,
+ (double) shortfall.idealFileGroupCount /
Math.max(shortfall.observedFileGroupCount, 1)));
+ }
+ if (oversizedFileGroup) {
+ if (!undersized) {
+ result.summary(String.format(
+ "Record index file group %s is %d bytes, past the configured
maximum of %d bytes",
+ footprint.largestFileGroupId, footprint.largestFileGroupBytes,
sizing.maxFileGroupSizeBytes));
+ }
+ result.finding(String.format(
+ "File group %s is %d bytes, past the configured maximum of %d bytes
even with %.1fx slack. Compacting "
+ + "it takes proportionally longer and every lookup routed to it
reads that much more. A file group "
+ + "that keeps growing between metadata compactions means the
index has too few file groups for "
+ + "the table.",
+ footprint.largestFileGroupId, footprint.largestFileGroupBytes,
sizing.maxFileGroupSizeBytes,
+ MAX_FILE_GROUP_SIZE_SLACK_FACTOR));
+ }
+ result.finding(rebootstrapRemedy(partitionedIndex));
+ return result.build();
+ }
+
+ /**
+ * Compares the file-group count against the writer's sizing estimate, at
the granularity the
+ * writer sizes at: the whole index for a global record index, each data
partition for a
+ * partitioned one (the worst data partition is returned). Echoes what it
compared, or why it
+ * did not, into the result. Empty when the writer's configured layout
disagrees with the layout
+ * on storage, since neither estimate would be comparable to the observed
count then.
+ */
+ static Option<CountComparison> compareFileGroupCount(RecordIndexFootprint
footprint, boolean partitionedIndex,
+ SizingConfig sizing,
HealthCheckResult.Builder result) {
+ boolean layoutKnown = footprint.fileGroupCount > 0;
+ if (layoutKnown && footprint.partitionedLayout != partitionedIndex) {
+ result.config("effective.count.comparison", String.format(
+ "skipped: writer configuration says a %s record index but the index
on storage is %s",
+ partitionedIndex ? "partitioned" : "global",
footprint.partitionedLayout ? "partitioned" : "global"));
+ return Option.empty();
+ }
+
+ CountComparison comparison;
+ if (!partitionedIndex) {
+ result.config("effective.count.comparison", "whole index (global record
index)");
+ comparison = CountComparison.of(Option.empty(), footprint, sizing);
+ } else {
+ result.config("effective.count.comparison", "per data partition
(partitioned record index); worst data partition reported")
+ .config("observed.data.partition.count",
footprint.byDataPartition.size());
+ // The worst data partition is the one the verdict should turn on, which
is not the one with the
+ // lowest ratio: a partition that is floor-bound has a low ratio only
because the configured
+ // minimum is high, and must not win the pick over a partition with a
real shortfall. So an
+ // undersized partition outranks a healthy one, a size-derived ideal
outranks a floor-derived
+ // one, and the ratio is the tiebreak within a class.
+ CountComparison worst = null;
+ int undersizedDataPartitions = 0;
+ for (Map.Entry<String, RecordIndexFootprint> entry :
footprint.byDataPartition.entrySet()) {
+ CountComparison candidate =
CountComparison.of(Option.of(entry.getKey()), entry.getValue(), sizing);
+ if (candidate.isUndersized()) {
+ undersizedDataPartitions++;
+ }
+ if (worst == null || candidate.isWorseThan(worst)) {
+ worst = candidate;
+ }
+ }
+ comparison = worst != null ? worst : CountComparison.of(Option.empty(),
footprint, sizing);
+ result.config("observed.undersized.data.partition.count",
undersizedDataPartitions);
+ if (comparison.dataPartition.isPresent()) {
+ result.config("observed.worst.data.partition",
comparison.dataPartition.get())
+ .config("observed.worst.data.partition.file.group.count",
comparison.observedFileGroupCount);
+ }
+ }
+
+ result.config("effective.ideal.file.group.count",
comparison.idealFileGroupCount)
+ .config("effective.ideal.source", comparison.idealIsFloor ?
"configured minimum (floor)" : "size estimate")
+ .config("effective.min.healthy.file.group.count",
comparison.minHealthyFileGroupCount())
+ .config("observed.estimated.record.count",
comparison.estimatedRecordCount);
+ return Option.of(comparison);
+ }
+
+ /**
+ * The one remedy for a mis-sized record index. The file-group count is
fixed at initialization,
+ * so there is no in-place resize; the operator has to drop the index and
build it again.
+ *
+ * <p>The rebuild path differs by layout. {@code HoodieIndexer} cannot
currently rebuild a
+ * partitioned record index -- see HUDI issue #20343 -- so for that layout
the remedy says what
+ * is broken rather than naming a command that silently produces a global
index instead.
+ */
+ private static String rebootstrapRemedy(boolean partitionedIndex) {
+ String preamble = "The file-group count of the record index is fixed when
the index is initialized and cannot "
+ + "be changed in place. Resizing it means dropping the index and
building it again, which is disruptive: "
+ + "the record index is unavailable until the rebuild completes, and
the drop cannot be undone in place. "
+ + "Plan it as a maintenance window.";
+ if (partitionedIndex) {
+ return preamble + " Two things make a rebuild the wrong move here.
HoodieIndexer cannot currently rebuild a "
+ + "partitioned record index: it maps the on-storage 'record_index'
partition to the global enable flag "
+ + "unconditionally, so a rebuild produces a global index with
default sizing, and supplying the "
+ + "partitioned key in its own --props does not help because that
mapping overwrites it "
+ + "(https://github.com/apache/hudi/issues/20343). And a rebuild
would not hold anyway: data partitions "
+ + "added afterwards are always initialized at "
+ +
HoodieMetadataConfig.RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP.key() + "
file groups, never from the "
+ + "size estimate, so tomorrow's partition starts undersized again.
Raising that minimum is the lever that "
+ + "persists; it applies to partitions created from then on, not to
the ones already initialized.";
+ }
+ return preamble + " Drop it with 'metadata delete-record-index' in
hudi-cli (or HoodieIndexer --mode dropindex "
+ + "--index-types RECORD_INDEX; note the hudi-cli command takes a
backup by default), correct the sizing "
+ + "configuration, then rebuild it with HoodieIndexer --mode
scheduleAndExecute --index-types RECORD_INDEX.";
+ }
+
+ /**
+ * The index layout as recorded on storage, when the table has an index
definition for the record
+ * index. {@code HoodieRecordIndex.IS_PARTITIONED_OPTION} is written at
initialization, so it
+ * describes the index that exists rather than the one a writer is
configured for.
+ *
+ * @return empty when no record-index definition is present, in which case
the caller falls back
+ * to the writer property.
+ */
+ private static Option<Boolean>
partitionedIndexOnStorage(HoodieTableMetaClient metaClient) {
+ return metaClient.getIndexMetadata()
+ .map(metadata ->
metadata.getIndexDefinitions().get(PARTITION_NAME_RECORD_INDEX))
+ .map(HoodieRecordIndex::isPartitioned);
+ }
+
+ /**
+ * Like {@link HealthCheckContext#firstMissingConfig(String...)}, but aware
of each property's
+ * alternative keys, since the record-index sizing keys were renamed and a
writer may still supply
+ * the old spelling.
+ */
+ private static Option<String> firstMissingConfig(HealthCheckContext context,
ConfigProperty<?>... properties) {
+ if (context.isApplyAllDefaults()) {
+ return Option.empty();
+ }
+ for (ConfigProperty<?> property : properties) {
+ if (!ConfigUtils.containsConfigProperty(context.getProps(), property)) {
+ return Option.of(property.key());
+ }
+ }
+ return Option.empty();
+ }
+
+ private static String configValue(TypedProperties props, ConfigProperty<?>
property) {
+ return ConfigUtils.getStringWithAltKeys(props, property,
String.valueOf(property.defaultValue()));
+ }
+
+ /**
+ * Sizes the record index from the latest merged file slice of each of its
file groups in the
+ * metadata table, and per data partition as well when the file-group names
show a partitioned
+ * layout. The metadata reader is engine-agnostic and closed once the slices
are read.
+ */
+ private static RecordIndexFootprint measureRecordIndex(HoodieTableMetaClient
metaClient) throws Exception {
+ HoodieLocalEngineContext engineContext = new
HoodieLocalEngineContext(metaClient.getStorageConf());
+ HoodieMetadataConfig metadataConfig =
HoodieMetadataConfig.newBuilder().enable(true).build();
+ try (HoodieBackedTableMetadata metadata = new HoodieBackedTableMetadata(
+ engineContext, metaClient.getStorage(), metadataConfig,
metaClient.getBasePath().toString())) {
+ List<FileSlice> fileSlices =
metadata.getFilegroupsForPartition(MetadataPartitionType.RECORD_INDEX);
+ boolean partitionedLayout = !fileSlices.isEmpty()
+ &&
HoodieTableMetadataUtil.verifyRLIFile(fileSlices.get(0).getFileId(), true);
+
+ Map<String, RecordIndexFootprint> byDataPartition = new TreeMap<>();
+ if (partitionedLayout) {
+
metadata.getBucketizedFileGroupsForPartitionedRLI(MetadataPartitionType.RECORD_INDEX)
+ .forEach((dataPartition, slices) ->
byDataPartition.put(dataPartition, RecordIndexFootprint.of(slices)));
+ }
+ return RecordIndexFootprint.of(fileSlices).withLayout(partitionedLayout,
byDataPartition);
+ }
+ }
+
+ /** The writer's sizing configuration, as supplied or defaulted. */
+ static final class SizingConfig {
+ final int minFileGroupCount;
+ final int maxFileGroupCount;
+ final float growthFactor;
+ final long maxFileGroupSizeBytes;
+
+ SizingConfig(int minFileGroupCount, int maxFileGroupCount, float
growthFactor, long maxFileGroupSizeBytes) {
+ this.minFileGroupCount = minFileGroupCount;
+ this.maxFileGroupCount = maxFileGroupCount;
+ this.growthFactor = growthFactor;
+ this.maxFileGroupSizeBytes = maxFileGroupSizeBytes;
+ }
+ }
+
+ /** One observed-versus-ideal file-group comparison, for the whole index or
one data partition. */
+ static final class CountComparison {
+ final Option<String> dataPartition;
+ final int observedFileGroupCount;
+ final long observedSizeBytes;
+ /** The bytes the record estimate was derived from, and what they were:
see {@link RecordIndexFootprint#estimateBytes()}. */
+ final long estimateBytes;
+ final String estimateBasis;
+ final long estimatedRecordCount;
+ final int idealFileGroupCount;
+ /**
+ * Whether {@link #idealFileGroupCount} came from the configured floor
rather than from the
+ * table's own size. When it did, the ideal carries no information about
how full the index
+ * actually is, so a shortfall against it says nothing about index health.
+ */
+ final boolean idealIsFloor;
+
+ private CountComparison(Option<String> dataPartition, int
observedFileGroupCount, long observedSizeBytes,
+ long estimateBytes, String estimateBasis, long
estimatedRecordCount,
+ int idealFileGroupCount, boolean idealIsFloor) {
+ this.dataPartition = dataPartition;
+ this.observedFileGroupCount = observedFileGroupCount;
+ this.observedSizeBytes = observedSizeBytes;
+ this.estimateBytes = estimateBytes;
+ this.estimateBasis = estimateBasis;
+ this.estimatedRecordCount = estimatedRecordCount;
+ this.idealFileGroupCount = idealFileGroupCount;
+ this.idealIsFloor = idealIsFloor;
+ }
+
+ static CountComparison of(Option<String> dataPartition,
RecordIndexFootprint footprint, SizingConfig sizing) {
+ long estimateBytes = footprint.estimateBytes();
+ long estimatedRecordCount = estimateBytes /
RECORD_INDEX_AVERAGE_RECORD_SIZE;
+ int idealFileGroupCount = HoodieTableMetadataUtil.estimateFileGroupCount(
+ MetadataPartitionType.RECORD_INDEX, () -> estimatedRecordCount,
RECORD_INDEX_AVERAGE_RECORD_SIZE,
+ sizing.minFileGroupCount, sizing.maxFileGroupCount,
sizing.growthFactor, sizing.maxFileGroupSizeBytes);
+ return new CountComparison(dataPartition, footprint.fileGroupCount,
footprint.totalSizeBytes, estimateBytes,
+ footprint.estimateBasis(), estimatedRecordCount, idealFileGroupCount,
+ isFloorBound(estimatedRecordCount, sizing));
+ }
+
+ /**
+ * Mirrors the two branches in {@code
HoodieTableMetadataUtil.estimateFileGroupCount} that
+ * return the minimum without regard to table size: a fixed count, where
min and max are equal,
+ * and an estimate at or below the minimum. A 3 MB index against a 1 GiB
maximum file-group
+ * size estimates zero file groups and is clamped up to the floor, which
is how a tiny index
+ * ends up being told it needs ten.
+ */
+ private static boolean isFloorBound(long estimatedRecordCount,
SizingConfig sizing) {
+ if (sizing.minFileGroupCount == sizing.maxFileGroupCount &&
sizing.minFileGroupCount != 0) {
+ return true;
+ }
+ long expectedNumRecords = (long) Math.ceil(estimatedRecordCount *
(double) sizing.growthFactor);
+ long maxRecordsPerFileGroup = sizing.maxFileGroupSizeBytes /
Math.max(RECORD_INDEX_AVERAGE_RECORD_SIZE, 1L);
+ long estimatedFileGroupCount = expectedNumRecords /
Math.max(maxRecordsPerFileGroup, 1L);
+ return estimatedFileGroupCount <= sizing.minFileGroupCount;
+ }
+
+ int minHealthyFileGroupCount() {
+ return (int) Math.ceil(MIN_ACCEPTABLE_FILE_GROUP_RATIO *
idealFileGroupCount);
+ }
+
+ boolean isUndersized() {
+ // A shortfall against a floor-derived ideal is not evidence of
anything: the index is below
+ // the configured minimum, but the estimate says it is nowhere near a
single file group's
+ // capacity, so there is no sizing problem to re-bootstrap away.
+ return !idealIsFloor && observedFileGroupCount <
minHealthyFileGroupCount();
+ }
+
+ double ratio() {
+ return (double) observedFileGroupCount / Math.max(idealFileGroupCount,
1);
+ }
+
+ long averageFileGroupBytes() {
+ return observedSizeBytes / Math.max(observedFileGroupCount, 1);
+ }
+
+ /**
+ * Orders comparisons by what the verdict depends on. An undersized
comparison is worse than one
+ * that is not; between two that agree on that, a size-derived ideal is
worse than a floor-derived
+ * one, because only the former says anything about the index; and within
a class the lower
+ * observed-to-ideal ratio is worse. Ratio alone would let a floor-bound
partition -- low ratio,
+ * no shortfall -- win the pick and hide a partition with a real one.
+ */
+ boolean isWorseThan(CountComparison other) {
+ if (isUndersized() != other.isUndersized()) {
+ return isUndersized();
+ }
+ if (idealIsFloor != other.idealIsFloor) {
+ return !idealIsFloor;
+ }
+ return ratio() < other.ratio();
+ }
+
+ String subject() {
+ return dataPartition.map(p -> String.format("Data partition '%s' of the
record index", p))
+ .orElse("Record index");
+ }
+ }
+
+ /** What the record index occupies on storage, taken from its latest file
slices. */
+ static final class RecordIndexFootprint {
+ final int fileGroupCount;
+ final int baseFileCount;
+ final int logFileCount;
+ final long totalSizeBytes;
+ final long baseFileSizeBytes;
+ final long largestFileGroupBytes;
+ final String largestFileGroupId;
+ final boolean partitionedLayout;
+ final Map<String, RecordIndexFootprint> byDataPartition;
+
+ private RecordIndexFootprint(int fileGroupCount, int baseFileCount, int
logFileCount, long totalSizeBytes,
+ long baseFileSizeBytes, long
largestFileGroupBytes, String largestFileGroupId,
+ boolean partitionedLayout, Map<String,
RecordIndexFootprint> byDataPartition) {
+ this.fileGroupCount = fileGroupCount;
+ this.baseFileCount = baseFileCount;
+ this.logFileCount = logFileCount;
+ this.totalSizeBytes = totalSizeBytes;
+ this.baseFileSizeBytes = baseFileSizeBytes;
+ this.largestFileGroupBytes = largestFileGroupBytes;
+ this.largestFileGroupId = largestFileGroupId;
+ this.partitionedLayout = partitionedLayout;
+ this.byDataPartition = byDataPartition;
+ }
+
+ static RecordIndexFootprint of(List<FileSlice> fileSlices) {
+ int baseFiles = 0;
+ int logFiles = 0;
+ long totalBytes = 0;
+ long baseBytes = 0;
+ long largestBytes = 0;
+ String largestId = "";
+ for (FileSlice slice : fileSlices) {
+ baseFiles += slice.getBaseFile().isPresent() ? 1 : 0;
+ baseBytes +=
slice.getBaseFile().map(HoodieBaseFile::getFileSize).orElse(0L);
+ logFiles += slice.getLogFileCnt();
+ long sliceBytes = slice.getTotalFileSize();
+ totalBytes += sliceBytes;
+ if (sliceBytes > largestBytes) {
+ largestBytes = sliceBytes;
+ largestId = slice.getFileId();
+ }
+ }
+ return new RecordIndexFootprint(fileSlices.size(), baseFiles, logFiles,
totalBytes, baseBytes, largestBytes,
+ largestId, false, new TreeMap<>());
+ }
+
+ /** A footprint with the given measurements and no file slices behind it,
for tests. */
+ static RecordIndexFootprint synthetic(int fileGroupCount, int
baseFileCount, long baseFileSizeBytes,
+ long totalSizeBytes) {
+ return new RecordIndexFootprint(fileGroupCount, baseFileCount,
fileGroupCount - baseFileCount, totalSizeBytes,
+ baseFileSizeBytes, totalSizeBytes / Math.max(fileGroupCount, 1),
"synthetic", false, new TreeMap<>());
+ }
+
+ RecordIndexFootprint withLayout(boolean partitioned, Map<String,
RecordIndexFootprint> perDataPartition) {
+ return new RecordIndexFootprint(fileGroupCount, baseFileCount,
logFileCount, totalSizeBytes, baseFileSizeBytes,
+ largestFileGroupBytes, largestFileGroupId, partitioned,
perDataPartition);
+ }
+
+ /**
+ * The bytes the record estimate is derived from. Log files in the record
index carry updates
+ * and deletes for keys the base files already hold, so counting them
inflates the estimate
+ * without any new records behind it; the base files, written by metadata
compaction, are the
+ * deduplicated set. Before the first compaction there are no base files,
and the log files are
+ * the only measurement there is.
+ */
+ long estimateBytes() {
+ return baseFileCount > 0 ? baseFileSizeBytes : totalSizeBytes;
+ }
+
+ String estimateBasis() {
+ return baseFileCount > 0 ? "base files" : "log files (the index has no
base file yet)";
+ }
+ }
+}
diff --git
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHoodieTableHealthChecker.java
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHoodieTableHealthChecker.java
index 9bd07ef32fe8..6b9a9ec853f4 100644
---
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHoodieTableHealthChecker.java
+++
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/TestHoodieTableHealthChecker.java
@@ -215,7 +215,7 @@ public class TestHoodieTableHealthChecker {
JsonNode root = MAPPER.readTree(result.output);
assertEquals("UNHEALTHY", root.get("overallStatus").asText());
assertEquals(HoodieTableType.MERGE_ON_READ.name(),
root.get("tableType").asText());
- assertEquals(6, root.get("checks").size());
+ assertEquals(7, root.get("checks").size());
JsonNode compaction = root.get("checks").get(0);
assertEquals("compaction", compaction.get("name").asText());
diff --git
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/health/TestRecordIndexSizingCheck.java
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/health/TestRecordIndexSizingCheck.java
new file mode 100644
index 000000000000..6352a02c2542
--- /dev/null
+++
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/health/TestRecordIndexSizingCheck.java
@@ -0,0 +1,298 @@
+/*
+ * 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.hudi.utilities.health;
+
+import org.apache.hudi.common.config.HoodieMetadataConfig;
+import org.apache.hudi.common.config.TypedProperties;
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.testutils.HoodieTestTable;
+import org.apache.hudi.common.testutils.HoodieTestUtils;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.metadata.MetadataPartitionType;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.TreeMap;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests the paths of {@link RecordIndexSizingCheck} that need no metadata
table on disk: the check
+ * declining to run, and the count comparison on synthetic footprints, where
the healthy/undersized
+ * boundary and the floor rule can be placed exactly. Verdicts on a real
record index are covered by
+ * {@link TestRecordIndexSizingCheckWithSpark}, which has to write one.
+ */
+public class TestRecordIndexSizingCheck {
+
+ @TempDir
+ Path tempDir;
+
+ private String basePath;
+ private final RecordIndexSizingCheck check = new RecordIndexSizingCheck();
+
+ @BeforeEach
+ public void setUp() {
+ basePath = tempDir.resolve("table").toString();
+ }
+
+ private HoodieTableMetaClient tableWithRecordIndexListed() throws Exception {
+ // Only the table configuration says the partition exists; there is no
metadata table behind it.
+ // Enough for the check's fail-fast paths, which must not touch the
metadata table.
+ HoodieTableMetaClient metaClient = HoodieTestUtils.init(basePath,
HoodieTableType.COPY_ON_WRITE);
+ HoodieTestTable.of(metaClient).addCommit("001");
+ metaClient.getTableConfig().setMetadataPartitionState(
+ metaClient, MetadataPartitionType.RECORD_INDEX.getPartitionPath(),
true);
+ return HoodieTableMetaClient.reload(metaClient);
+ }
+
+ private TypedProperties globalSizingProps() {
+ TypedProperties props = new TypedProperties();
+
props.setProperty(HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP.key(),
"2");
+
props.setProperty(HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_MAX_FILE_GROUP_COUNT_PROP.key(),
"2");
+
props.setProperty(HoodieMetadataConfig.RECORD_INDEX_MAX_FILE_GROUP_SIZE_BYTES_PROP.key(),
"1073741824");
+
props.setProperty(HoodieMetadataConfig.RECORD_INDEX_GROWTH_FACTOR_PROP.key(),
"2.0");
+ return props;
+ }
+
+ @Test
+ public void tableWithoutRecordIndexIsSkippedAndSaysSo() throws Exception {
+ // given a table whose metadata table has no record index partition
+ HoodieTableMetaClient metaClient = HoodieTestUtils.init(basePath,
HoodieTableType.COPY_ON_WRITE);
+ HoodieTestTable.of(metaClient).addCommit("001");
+
+ // when the check runs with every sizing property supplied
+ HealthCheckResult result = check.check(new HealthCheckContext(metaClient,
globalSizingProps(), false));
+
+ // then it does not apply, and the reason names the partition it looked for
+ assertFalse(check.appliesTo(new HealthCheckContext(metaClient,
globalSizingProps(), false)));
+ assertEquals(HealthStatus.SKIPPED, result.getStatus());
+
assertTrue(result.getSummary().contains(MetadataPartitionType.RECORD_INDEX.getPartitionPath()),
+ "expected the skip reason to name the record index partition, got: " +
result.getSummary());
+ }
+
+ @Test
+ public void missingSizingConfigSkipsNamingTheFirstMissingProperty() throws
Exception {
+ // given a table with a record index and no writer configuration supplied
+ HoodieTableMetaClient metaClient = tableWithRecordIndexListed();
+
+ // when the check runs
+ HealthCheckResult result = check.check(new HealthCheckContext(metaClient,
new TypedProperties(), false));
+
+ // then it declines to judge and names the global minimum file-group count
first
+ assertEquals(HealthStatus.SKIPPED, result.getStatus());
+ assertTrue(result.getSummary().contains(
+
HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP.key()),
result.getSummary());
+ }
+
+ @Test
+ public void eachSizingPropertyIsRequiredInTurn() throws Exception {
+ // given a record index and a properties set missing only the growth factor
+ HoodieTableMetaClient metaClient = tableWithRecordIndexListed();
+ TypedProperties props = globalSizingProps();
+ props.remove(HoodieMetadataConfig.RECORD_INDEX_GROWTH_FACTOR_PROP.key());
+
+ // when the check runs
+ HealthCheckResult result = check.check(new HealthCheckContext(metaClient,
props, false));
+
+ // then that is the property it asks for
+ assertEquals(HealthStatus.SKIPPED, result.getStatus());
+
assertTrue(result.getSummary().contains(HoodieMetadataConfig.RECORD_INDEX_GROWTH_FACTOR_PROP.key()),
+ result.getSummary());
+ }
+
+ @Test
+ public void partitionedRecordIndexRequiresThePartitionedSizingKeys() throws
Exception {
+ // given a writer that enables the partitioned record index but only
supplies the global sizing keys
+ HoodieTableMetaClient metaClient = tableWithRecordIndexListed();
+ TypedProperties props = globalSizingProps();
+
props.setProperty(HoodieMetadataConfig.RECORD_LEVEL_INDEX_ENABLE_PROP.key(),
"true");
+
+ // when the check runs
+ HealthCheckResult result = check.check(new HealthCheckContext(metaClient,
props, false));
+
+ // then it asks for the partitioned key, since that is what the writer
sizes with
+ assertEquals(HealthStatus.SKIPPED, result.getStatus());
+ assertTrue(result.getSummary().contains(
+
HoodieMetadataConfig.RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP.key()),
result.getSummary());
+ }
+
+ @Test
+ public void
maxFileGroupSizeBelowOneRecordIsRejectedRatherThanDividedByZero() throws
Exception {
+ // given a maximum file-group size smaller than a single index record
+ HoodieTableMetaClient metaClient = tableWithRecordIndexListed();
+ TypedProperties props = globalSizingProps();
+
props.setProperty(HoodieMetadataConfig.RECORD_INDEX_MAX_FILE_GROUP_SIZE_BYTES_PROP.key(),
"10");
+
+ // when the check runs
+ HealthCheckResult result = check.check(new HealthCheckContext(metaClient,
props, false));
+
+ // then it skips with the offending value echoed, before ever opening the
metadata table
+ assertEquals(HealthStatus.SKIPPED, result.getStatus());
+
assertTrue(result.getSummary().contains(HoodieMetadataConfig.RECORD_INDEX_MAX_FILE_GROUP_SIZE_BYTES_PROP.key()),
+ result.getSummary());
+ assertEquals("10", result.getEffectiveConfigs()
+
.get(HoodieMetadataConfig.RECORD_INDEX_MAX_FILE_GROUP_SIZE_BYTES_PROP.key()));
+ }
+
+ // ---- count comparison on synthetic footprints
-------------------------------------------------
+
+ private static final long ONE_GIB = 1024L * 1024 * 1024;
+ /** Records that fit one 1 GiB file group at the writer's 48-byte average
record. */
+ private static final long RECORDS_PER_FILE_GROUP = ONE_GIB / 48;
+
+ private static RecordIndexSizingCheck.SizingConfig sizing(int minFileGroups,
int maxFileGroups) {
+ return new RecordIndexSizingCheck.SizingConfig(minFileGroups,
maxFileGroups, 2.0f, ONE_GIB);
+ }
+
+ /**
+ * Base-file bytes that make the writer's estimate land on {@code
idealFileGroups}: the estimate
+ * multiplies the record count by the 2.0 growth factor and divides by the
records per file group,
+ * so half that many records' worth of bytes per ideal file group. Aimed at
the middle of the band
+ * (ideal + 0.5) rather than its lower edge, because the writer does that
multiplication in
+ * {@code float} and a count above 2^24 records rounds by a few either way.
+ */
+ private static long bytesForIdeal(int idealFileGroups) {
+ return (long) ((idealFileGroups + 0.5) * RECORDS_PER_FILE_GROUP / 2) * 48;
+ }
+
+ private static RecordIndexSizingCheck.RecordIndexFootprint footprint(int
fileGroups, long baseFileBytes) {
+ return RecordIndexSizingCheck.RecordIndexFootprint.synthetic(fileGroups,
fileGroups, baseFileBytes, baseFileBytes);
+ }
+
+ private static RecordIndexSizingCheck.CountComparison compare(int
fileGroups, long baseFileBytes,
+
RecordIndexSizingCheck.SizingConfig sizing) {
+ return RecordIndexSizingCheck.CountComparison.of(Option.empty(),
footprint(fileGroups, baseFileBytes), sizing);
+ }
+
+ @Test
+ public void fileGroupsAtExactlyHalfTheIdealAreHealthy() {
+ // given an index whose bytes call for four file groups and which has two
+ RecordIndexSizingCheck.CountComparison comparison = compare(2,
bytesForIdeal(4), sizing(1, 1000));
+
+ // then the ideal is size-derived, the ratio sits exactly on the 0.5
boundary, and it is not undersized
+ assertEquals(4, comparison.idealFileGroupCount);
+ assertFalse(comparison.idealIsFloor);
+ assertEquals(2, comparison.minHealthyFileGroupCount());
+ assertEquals(0.5, comparison.ratio(), 1e-9);
+ assertFalse(comparison.isUndersized());
+ }
+
+ @Test
+ public void fileGroupsJustUnderHalfTheIdealAreUndersized() {
+ // given the same sizing with one file group fewer, and a five-ideal index
with two
+ RecordIndexSizingCheck.CountComparison oneOfFour = compare(1,
bytesForIdeal(4), sizing(1, 1000));
+ RecordIndexSizingCheck.CountComparison twoOfFive = compare(2,
bytesForIdeal(5), sizing(1, 1000));
+
+ // then both fall below ceil(0.5 * ideal) and are undersized
+ assertEquals(4, oneOfFour.idealFileGroupCount);
+ assertTrue(oneOfFour.isUndersized());
+ assertEquals(5, twoOfFive.idealFileGroupCount);
+ assertEquals(3, twoOfFive.minHealthyFileGroupCount());
+ assertTrue(twoOfFive.isUndersized());
+ }
+
+ @Test
+ public void floorBoundIdealIsNeverUndersized() {
+ // given a fixed count (min == max) and a minimum far above what a tiny
index needs
+ RecordIndexSizingCheck.CountComparison fixedCount = compare(1,
bytesForIdeal(4), sizing(4, 4));
+ RecordIndexSizingCheck.CountComparison tinyIndex = compare(1, 1024 * 1024,
sizing(10, 1000));
+
+ // then both ideals come from the floor and neither comparison reports a
shortfall, however low the ratio
+ assertTrue(fixedCount.idealIsFloor);
+ assertEquals(0.25, fixedCount.ratio(), 1e-9);
+ assertFalse(fixedCount.isUndersized());
+ assertTrue(tinyIndex.idealIsFloor);
+ assertEquals(10, tinyIndex.idealFileGroupCount);
+ assertEquals(0.1, tinyIndex.ratio(), 1e-9);
+ assertFalse(tinyIndex.isUndersized());
+ }
+
+ @Test
+ public void worstDataPartitionIsTheOneWithARealShortfallNotTheLowestRatio() {
+ // given a partitioned index after the minimum was raised from 1 to 10: an
old one-file-group
+ // partition is floor-bound at ratio 0.1, and a large partition has five
file groups against a
+ // size-derived ideal of twenty, ratio 0.25
+ RecordIndexSizingCheck.SizingConfig sizing = sizing(10, 1000);
+ Map<String, RecordIndexSizingCheck.RecordIndexFootprint> byDataPartition =
new TreeMap<>();
+ byDataPartition.put("2024/01/01", footprint(1, 1024 * 1024));
+ byDataPartition.put("2024/01/02", footprint(5, bytesForIdeal(20)));
+ RecordIndexSizingCheck.RecordIndexFootprint index = footprint(6,
bytesForIdeal(20) + 1024 * 1024)
+ .withLayout(true, byDataPartition);
+ HealthCheckResult.Builder result =
HealthCheckResult.builder(RecordIndexSizingCheck.NAME);
+
+ // when the worst data partition is picked
+ RecordIndexSizingCheck.CountComparison worst =
+ RecordIndexSizingCheck.compareFileGroupCount(index, true, sizing,
result).get();
+
+ // then the real shortfall wins the pick over the lower ratio, and the
verdict is undersized
+ assertEquals("2024/01/02", worst.dataPartition.get());
+ assertEquals(20, worst.idealFileGroupCount);
+ assertFalse(worst.idealIsFloor);
+ assertTrue(worst.isUndersized());
+ Map<String, String> configs = result.build().getEffectiveConfigs();
+ assertEquals("2024/01/02", configs.get("observed.worst.data.partition"));
+ assertEquals("1", configs.get("observed.undersized.data.partition.count"));
+ assertEquals("size estimate", configs.get("effective.ideal.source"));
+ }
+
+ @Test
+ public void amongHealthyDataPartitionsTheLowestSizeDerivedRatioIsReported() {
+ // given no shortfall anywhere: one floor-bound partition at ratio 0.1 and
two size-derived
+ // partitions at ratios 1.0 and 0.75
+ RecordIndexSizingCheck.SizingConfig sizing = sizing(10, 1000);
+ Map<String, RecordIndexSizingCheck.RecordIndexFootprint> byDataPartition =
new TreeMap<>();
+ byDataPartition.put("a", footprint(1, 1024 * 1024));
+ byDataPartition.put("b", footprint(20, bytesForIdeal(20)));
+ byDataPartition.put("c", footprint(15, bytesForIdeal(20)));
+ RecordIndexSizingCheck.RecordIndexFootprint index = footprint(36, 2 *
bytesForIdeal(20)).withLayout(true, byDataPartition);
+ HealthCheckResult.Builder result =
HealthCheckResult.builder(RecordIndexSizingCheck.NAME);
+
+ // when the worst data partition is picked
+ RecordIndexSizingCheck.CountComparison worst =
+ RecordIndexSizingCheck.compareFileGroupCount(index, true, sizing,
result).get();
+
+ // then the floor-bound partition does not win on ratio; the size-derived
0.75 does, and nothing is undersized
+ assertEquals("c", worst.dataPartition.get());
+ assertFalse(worst.isUndersized());
+ assertEquals("0",
result.build().getEffectiveConfigs().get("observed.undersized.data.partition.count"));
+ }
+
+ @Test
+ public void
recordEstimateUsesBaseFileBytesAndFallsBackToLogsBeforeTheFirstCompaction() {
+ // given a compacted index whose log files triple its footprint, and an
uncompacted one with logs only
+ RecordIndexSizingCheck.RecordIndexFootprint compacted =
+ RecordIndexSizingCheck.RecordIndexFootprint.synthetic(2, 2, ONE_GIB, 3
* ONE_GIB);
+ RecordIndexSizingCheck.RecordIndexFootprint logsOnly =
+ RecordIndexSizingCheck.RecordIndexFootprint.synthetic(2, 0, 0,
ONE_GIB);
+
+ // then the estimate ignores the log bytes when base files exist, and uses
them when nothing else is there
+ assertEquals(ONE_GIB, compacted.estimateBytes());
+ assertEquals("base files", compacted.estimateBasis());
+ assertEquals(ONE_GIB / 48,
RecordIndexSizingCheck.CountComparison.of(Option.empty(), compacted, sizing(1,
1000)).estimatedRecordCount);
+ assertEquals(ONE_GIB, logsOnly.estimateBytes());
+ assertEquals("log files (the index has no base file yet)",
logsOnly.estimateBasis());
+ }
+}
diff --git
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/health/TestRecordIndexSizingCheckWithSpark.java
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/health/TestRecordIndexSizingCheckWithSpark.java
new file mode 100644
index 000000000000..22b13ffdc3c1
--- /dev/null
+++
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/health/TestRecordIndexSizingCheckWithSpark.java
@@ -0,0 +1,428 @@
+/*
+ * 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.hudi.utilities.health;
+
+import org.apache.hudi.client.SparkRDDWriteClient;
+import org.apache.hudi.client.WriteStatus;
+import org.apache.hudi.common.config.HoodieMetadataConfig;
+import org.apache.hudi.common.config.TypedProperties;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.testutils.HoodieTestDataGenerator;
+import org.apache.hudi.config.HoodieIndexConfig;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.index.HoodieIndex;
+import org.apache.hudi.metadata.MetadataPartitionType;
+import org.apache.hudi.testutils.SparkClientFunctionalTestHarness;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.apache.hudi.testutils.Assertions.assertNoWriteErrors;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests {@link RecordIndexSizingCheck} verdicts against a record index
actually written through
+ * the Spark write client. The global index is initialized with a fixed two
file groups and the
+ * partitioned one with a fixed one file group per data partition, so the
check's verdict is driven
+ * entirely by the sizing configuration handed to it.
+ */
+public class TestRecordIndexSizingCheckWithSpark extends
SparkClientFunctionalTestHarness {
+
+ private static final HoodieTestDataGenerator DATA_GENERATOR = new
HoodieTestDataGenerator(0L);
+ private static final int GLOBAL_FILE_GROUPS = 2;
+ private static final int FILE_GROUPS_PER_DATA_PARTITION = 1;
+ private static final int DATA_PARTITIONS =
HoodieTestDataGenerator.DEFAULT_PARTITION_PATHS.length;
+ private static final String ONE_GIB = String.valueOf(1024L * 1024 * 1024);
+ /**
+ * A maximum file-group size small enough that the fixture index estimates
several file groups
+ * rather than clamping to the configured minimum. With ONE_GIB the estimate
is zero for any
+ * fixture-sized index, so the ideal comes from the floor and says nothing
about the table --
+ * which is the bug these tests exist to catch, not a configuration to
assert against.
+ */
+ private static final String SIXTEEN_KIB = String.valueOf(16 * 1024);
+ /** Smaller still, so the estimate clears the 0.5 healthy ratio rather than
sitting on it. */
+ private static final String FOUR_KIB = String.valueOf(4 * 1024);
+ /**
+ * Small enough that one data partition's base-file bytes alone call for
several file groups. The
+ * estimate is taken from base files, not base plus logs, so the
per-partition fixture -- a few
+ * dozen records each -- needs a limit this low to show a real shortfall
rather than the floor.
+ */
+ private static final String ONE_KIB = String.valueOf(1024);
+
+ private final RecordIndexSizingCheck check = new RecordIndexSizingCheck();
+ private HoodieTableMetaClient metaClient;
+
+ @BeforeEach
+ public void initTable() throws Exception {
+ metaClient = getHoodieMetaClient(HoodieTableType.COPY_ON_WRITE);
+ }
+
+ @AfterAll
+ public static void closeDataGenerator() {
+ DATA_GENERATOR.close();
+ }
+
+ private void writeGlobalRecordIndex() throws Exception {
+ HoodieWriteConfig writeConfig = writeConfigBuilder()
+ .withMetadataConfig(HoodieMetadataConfig.newBuilder()
+ .enable(true)
+ .withEnableGlobalRecordLevelIndex(true)
+ .withRecordIndexFileGroupCount(GLOBAL_FILE_GROUPS,
GLOBAL_FILE_GROUPS)
+ .build())
+ .build();
+ insertBatches(writeConfig, 1);
+ }
+
+ private void writePartitionedRecordIndex() throws Exception {
+ // A partitioned record index is sized from the data partitions that
exist, so its
+ // initialization is deferred past the first commit and two batches are
written.
+ HoodieWriteConfig writeConfig = writeConfigBuilder()
+
.withIndexConfig(HoodieIndexConfig.newBuilder().withIndexType(HoodieIndex.IndexType.RECORD_LEVEL_INDEX).build())
+ .withMetadataConfig(HoodieMetadataConfig.newBuilder()
+ .enable(true)
+ .withEnableRecordLevelIndex(true)
+
.withPartitionedRecordIndexFileGroupCount(FILE_GROUPS_PER_DATA_PARTITION,
FILE_GROUPS_PER_DATA_PARTITION)
+ .withDeferRliInitializationForFreshTable(true)
+ .build())
+ .build();
+ insertBatches(writeConfig, 2);
+ }
+
+ private HoodieWriteConfig.Builder writeConfigBuilder() {
+ return HoodieWriteConfig.newBuilder()
+ .withPath(basePath())
+ .withSchema(HoodieTestDataGenerator.TRIP_EXAMPLE_SCHEMA)
+ .withParallelism(2, 2)
+ .forTable("record_index_sizing");
+ }
+
+ private void insertBatches(HoodieWriteConfig writeConfig, int batches)
throws Exception {
+ try (SparkRDDWriteClient writeClient = new SparkRDDWriteClient(context(),
writeConfig)) {
+ for (int i = 0; i < batches; i++) {
+ String instant = writeClient.startCommit();
+ List<HoodieRecord> records = DATA_GENERATOR.generateInserts(instant,
100);
+ List<WriteStatus> statuses =
writeClient.upsert(jsc().parallelize(records, 1), instant).collect();
+ assertNoWriteErrors(statuses);
+ writeClient.commit(instant, jsc().parallelize(statuses));
+ }
+ }
+ metaClient = HoodieTableMetaClient.reload(metaClient);
+
assertTrue(metaClient.getTableConfig().isMetadataPartitionAvailable(MetadataPartitionType.RECORD_INDEX),
+ "fixture should have initialized a record index");
+ }
+
+ private static TypedProperties globalSizingProps(int minFileGroups, int
maxFileGroups, String maxFileGroupSizeBytes) {
+ TypedProperties props = new TypedProperties();
+
props.setProperty(HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP.key(),
+ String.valueOf(minFileGroups));
+
props.setProperty(HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_MAX_FILE_GROUP_COUNT_PROP.key(),
+ String.valueOf(maxFileGroups));
+
props.setProperty(HoodieMetadataConfig.RECORD_INDEX_MAX_FILE_GROUP_SIZE_BYTES_PROP.key(),
maxFileGroupSizeBytes);
+
props.setProperty(HoodieMetadataConfig.RECORD_INDEX_GROWTH_FACTOR_PROP.key(),
"2.0");
+ return props;
+ }
+
+ private static TypedProperties partitionedSizingProps(int minFileGroups, int
maxFileGroups, String maxFileGroupSizeBytes) {
+ TypedProperties props = new TypedProperties();
+
props.setProperty(HoodieMetadataConfig.RECORD_LEVEL_INDEX_ENABLE_PROP.key(),
"true");
+
props.setProperty(HoodieMetadataConfig.RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP.key(),
+ String.valueOf(minFileGroups));
+
props.setProperty(HoodieMetadataConfig.RECORD_LEVEL_INDEX_MAX_FILE_GROUP_COUNT_PROP.key(),
+ String.valueOf(maxFileGroups));
+
props.setProperty(HoodieMetadataConfig.RECORD_INDEX_MAX_FILE_GROUP_SIZE_BYTES_PROP.key(),
maxFileGroupSizeBytes);
+
props.setProperty(HoodieMetadataConfig.RECORD_INDEX_GROWTH_FACTOR_PROP.key(),
"2.0");
+ return props;
+ }
+
+ private HealthCheckResult runCheck(TypedProperties props, boolean
applyAllDefaults) throws Exception {
+ return check.check(new HealthCheckContext(metaClient, props,
applyAllDefaults));
+ }
+
+ private static void assertCountComparisonAbsent(Map<String, String> configs)
{
+ assertFalse(configs.containsKey("effective.ideal.file.group.count"),
configs.toString());
+ assertFalse(configs.containsKey("effective.min.healthy.file.group.count"),
configs.toString());
+ assertFalse(configs.containsKey("observed.estimated.record.count"),
configs.toString());
+
assertTrue(configs.get("effective.count.comparison").startsWith("skipped:"),
configs.toString());
+ }
+
+ @Test
+ public void recordIndexMatchingItsConfiguredSizingIsHealthy() throws
Exception {
+ // given a global index sized so the estimate derives from the index's own
bytes rather than
+ // clamping to the floor, and lands at or below what the index already has
+ writeGlobalRecordIndex();
+ TypedProperties props = globalSizingProps(1, 64, SIXTEEN_KIB);
+
+ // when the check runs
+ HealthCheckResult result = runCheck(props, false);
+
+ // then the ideal is size-derived and the index is not called undersized
+ assertEquals(HealthStatus.HEALTHY, result.getStatus(),
result.getSummary());
+ assertEquals("2",
result.getEffectiveConfigs().get("observed.file.group.count"));
+ assertEquals("whole index (global record index)",
result.getEffectiveConfigs().get("effective.count.comparison"));
+ int ideal =
Integer.parseInt(result.getEffectiveConfigs().get("effective.ideal.file.group.count"));
+ assertTrue(ideal > 1, "fixture should exercise the estimate, not the
floor; ideal was " + ideal);
+ }
+
+ @Test
+ public void
fewerFileGroupsThanTheWriterNowSizesForIsUnhealthyAndSaysToRebootstrap() throws
Exception {
+ // given a maximum file-group size small enough that the index's own bytes
call for more file
+ // groups than it was built with -- a real shortfall, not one manufactured
by the floor
+ writeGlobalRecordIndex();
+ TypedProperties props = globalSizingProps(1, 64, FOUR_KIB);
+
+ // when the check runs
+ HealthCheckResult result = runCheck(props, false);
+
+ // then it reports the index undersized, with the counts and the only
remedy spelled out
+ assertEquals(HealthStatus.UNHEALTHY, result.getStatus(),
result.getSummary());
+ int ideal =
Integer.parseInt(result.getEffectiveConfigs().get("effective.ideal.file.group.count"));
+ assertTrue(ideal > 1, "the ideal must come from the estimate, not the
floor; was " + ideal);
+ assertEquals(String.valueOf((int) Math.ceil(0.5 * ideal)),
+
result.getEffectiveConfigs().get("effective.min.healthy.file.group.count"));
+ assertEquals("2",
result.getEffectiveConfigs().get("observed.file.group.count"));
+ assertTrue(result.getFindings().stream().anyMatch(f -> f.contains("Record
index was initialized with 2 file group(s)")),
+ "expected a finding stating the observed count, got: " +
result.getFindings());
+ assertTrue(result.getFindings().stream().anyMatch(f ->
f.contains("metadata delete-record-index")),
+ "expected the hudi-cli drop command, got: " + result.getFindings());
+ assertTrue(result.getFindings().stream().anyMatch(f -> f.contains("--mode
dropindex --index-types RECORD_INDEX")),
+ "expected the HoodieIndexer drop command, got: " +
result.getFindings());
+ assertTrue(result.getFindings().stream().anyMatch(f -> f.contains("--mode
scheduleAndExecute --index-types RECORD_INDEX")),
+ "expected the HoodieIndexer rebuild command, got: " +
result.getFindings());
+ assertTrue(result.getFindings().stream().anyMatch(f -> f.contains("cannot
be changed in place")),
+ "expected the finding to say the count is fixed at initialization,
got: " + result.getFindings());
+ }
+
+ @Test
+ public void
fixedFileGroupCountAboveWhatExistsIsReportedAsTheFloorNotAsAShortfall() throws
Exception {
+ // given a writer that pins the count at four (min == max) while the index
has two; the ideal is
+ // the configured floor, not a size estimate, so the 0.5 ratio means
nothing about index health
+ // (the exact 0.5 boundary itself is covered on synthetic footprints in
TestRecordIndexSizingCheck)
+ writeGlobalRecordIndex();
+ TypedProperties props = globalSizingProps(4, 4, ONE_GIB);
+
+ // when the check runs
+ HealthCheckResult result = runCheck(props, false);
+
+ // then it is healthy, says the ideal came from the floor, and tells the
operator why that is not a shortfall
+ assertEquals(HealthStatus.HEALTHY, result.getStatus(),
result.getSummary());
+ assertEquals("4",
result.getEffectiveConfigs().get("effective.ideal.file.group.count"));
+ assertEquals("configured minimum (floor)",
result.getEffectiveConfigs().get("effective.ideal.source"));
+ assertTrue(result.getSummary().contains("fewer than the configured minimum
of 4"), result.getSummary());
+ }
+
+ @Test
+ public void fileGroupPastTheConfiguredMaximumSizeIsUnhealthy() throws
Exception {
+ // given a maximum file-group size far below what even a hundred-record
index occupies
+ writeGlobalRecordIndex();
+ TypedProperties props = globalSizingProps(GLOBAL_FILE_GROUPS,
GLOBAL_FILE_GROUPS, "512");
+
+ // when the check runs
+ HealthCheckResult result = runCheck(props, false);
+
+ // then the largest file group is called out, and the remedy is the same
re-bootstrap
+ assertEquals(HealthStatus.UNHEALTHY, result.getStatus(),
result.getSummary());
+ long largest =
Long.parseLong(result.getEffectiveConfigs().get("observed.largest.file.group.bytes"));
+ assertTrue(largest > 768, "fixture should have produced a file group past
1.5 x 512 bytes, was " + largest);
+ assertTrue(result.getFindings().stream().anyMatch(f -> f.contains("past
the configured maximum of 512 bytes")),
+ "expected a finding about the oversized file group, got: " +
result.getFindings());
+ assertTrue(result.getFindings().stream().anyMatch(f ->
f.contains("metadata delete-record-index")),
+ "expected the re-bootstrap remedy, got: " + result.getFindings());
+ }
+
+ @Test
+ public void applyAllDefaultsEvaluatesWithoutSuppliedConfig() throws
Exception {
+ // given no writer configuration at all, but defaults explicitly allowed
+ writeGlobalRecordIndex();
+
+ // when the check runs
+ HealthCheckResult result = runCheck(new TypedProperties(), true);
+
+ // then it runs rather than skipping, and reports the stock sizing it
reasoned with
+ assertNotEquals(HealthStatus.SKIPPED, result.getStatus(),
result.getSummary());
+
assertEquals(String.valueOf(HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP.defaultValue()),
+
result.getEffectiveConfigs().get(HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP.key()));
+ // A fixture-sized index against the stock 1 GiB maximum file-group size
estimates zero file
+ // groups, so the ideal is the configured minimum and carries nothing
about the table. That is
+ // not a shortfall to act on: it must not read as UNHEALTHY and must not
recommend a rebuild.
+ assertEquals(HealthStatus.HEALTHY, result.getStatus(),
result.getSummary());
+ assertTrue(result.getFindings().stream().anyMatch(f -> f.contains("comes
from the configured minimum")),
+ "expected the floor to be named as the reason, got: " +
result.getFindings());
+ assertTrue(result.getFindings().stream().noneMatch(f ->
f.contains("metadata delete-record-index")),
+ "a floor-bound ideal must not recommend a re-bootstrap, got: " +
result.getFindings());
+ }
+
+ @Test
+ public void legacySizingKeysAreAcceptedAsAlternatives() throws Exception {
+ // given a writer that still spells the sizing keys the pre-rename way
+ writeGlobalRecordIndex();
+ TypedProperties props = new TypedProperties();
+ props.setProperty("hoodie.metadata.record.index.min.filegroup.count", "2");
+ props.setProperty("hoodie.metadata.record.index.max.filegroup.count", "2");
+
props.setProperty(HoodieMetadataConfig.RECORD_INDEX_MAX_FILE_GROUP_SIZE_BYTES_PROP.key(),
ONE_GIB);
+
props.setProperty(HoodieMetadataConfig.RECORD_INDEX_GROWTH_FACTOR_PROP.key(),
"2.0");
+
+ // when the check runs
+ HealthCheckResult result = runCheck(props, false);
+
+ // then the old keys satisfy the requirement and their values are what the
check reasons with
+ assertEquals(HealthStatus.HEALTHY, result.getStatus(),
result.getSummary());
+ assertEquals("2", result.getEffectiveConfigs()
+
.get(HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP.key()));
+ }
+
+ @Test
+ public void effectiveConfigIsEchoedSoAVerdictCanBeAudited() throws Exception
{
+ // given a known sizing configuration
+ writeGlobalRecordIndex();
+ TypedProperties props = globalSizingProps(GLOBAL_FILE_GROUPS,
GLOBAL_FILE_GROUPS, ONE_GIB);
+
+ // when the check runs
+ Map<String, String> configs = runCheck(props, false).getEffectiveConfigs();
+
+ // then every supplied value, derived threshold and measurement is
reported alongside the verdict
+ assertEquals("global", configs.get("observed.index.layout"));
+ assertEquals("2",
configs.get(HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP.key()));
+ assertEquals("2",
configs.get(HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_MAX_FILE_GROUP_COUNT_PROP.key()));
+ assertEquals(ONE_GIB,
configs.get(HoodieMetadataConfig.RECORD_INDEX_MAX_FILE_GROUP_SIZE_BYTES_PROP.key()));
+ assertEquals("2.0",
configs.get(HoodieMetadataConfig.RECORD_INDEX_GROWTH_FACTOR_PROP.key()));
+ assertEquals("48", configs.get("effective.average.record.size.bytes"));
+ assertEquals("2", configs.get("effective.ideal.file.group.count"));
+ // min == max pins the count, so this fixture's ideal is the floor; the
estimate branch is exercised elsewhere
+ assertEquals("configured minimum (floor)",
configs.get("effective.ideal.source"));
+ assertEquals("1", configs.get("effective.min.healthy.file.group.count"));
+ assertEquals(String.valueOf((long) (1.5 * 1024L * 1024 * 1024)),
configs.get("effective.max.healthy.file.group.bytes"));
+ assertTrue(configs.containsKey("observed.base.file.size.bytes"),
configs.toString());
+ assertTrue(configs.get("effective.estimate.basis").startsWith("base files")
+ || configs.get("effective.estimate.basis").startsWith("log files"),
configs.toString());
+ assertEquals("2", configs.get("observed.file.group.count"));
+ assertTrue(Long.parseLong(configs.get("observed.total.size.bytes")) > 0);
+
assertTrue(Long.parseLong(configs.get("observed.largest.file.group.bytes")) >
0);
+ assertTrue(Long.parseLong(configs.get("observed.estimated.record.count"))
> 0);
+ assertTrue(Integer.parseInt(configs.get("observed.base.file.count"))
+ + Integer.parseInt(configs.get("observed.log.file.count")) > 0);
+ assertTrue(!configs.get("observed.largest.file.group.id").isEmpty());
+ }
+
+ @Test
+ public void partitionedConfigOnAGlobalIndexAsksForTheGlobalSizingKeys()
throws Exception {
+ // given a global index, but a writer configuration that says the index is
partitioned, with a
+ // maximum file-group size the index is already past
+ writeGlobalRecordIndex();
+ TypedProperties props = partitionedSizingProps(10, 10, "512");
+
+ // when the check runs
+ HealthCheckResult result = runCheck(props, false);
+
+ // then the layout comes from storage, so the check asks for the sizing
keys the index on disk
+ // actually needs rather than silently judging it against the partitioned
ones it was handed
+ assertEquals(HealthStatus.SKIPPED, result.getStatus(),
result.getSummary());
+ assertTrue(result.getSummary().contains(
+
HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP.key()),
+ "expected the global sizing key to be named, got: " +
result.getSummary());
+ // and it judges nothing until it has them -- naming the missing key beats
guessing with the
+ // wrong one, per the checker's fail-fast contract
+ assertTrue(result.getFindings().isEmpty(),
+ "a skipped check must not carry findings, got: " +
result.getFindings());
+ }
+
+ @Test
+ public void
partitionedConfigOnAGlobalIndexNeverComparesPerPartitionAgainstTheTotal()
throws Exception {
+ // given the same mismatch but a generous maximum file-group size
+ writeGlobalRecordIndex();
+ TypedProperties props = partitionedSizingProps(10, 10, ONE_GIB);
+
+ // when the check runs
+ HealthCheckResult result = runCheck(props, false);
+
+ // then no false shortfall: a per-partition count is never compared
against the whole-index
+ // total, because the global sizing keys govern an index that is global on
storage
+ assertEquals(HealthStatus.SKIPPED, result.getStatus(),
result.getSummary());
+ assertTrue(result.getSummary().contains(
+
HoodieMetadataConfig.GLOBAL_RECORD_LEVEL_INDEX_MIN_FILE_GROUP_COUNT_PROP.key()),
+ "expected the global sizing key to be named, got: " +
result.getSummary());
+ }
+
+ @Test
+ public void partitionedIndexIsComparedPerDataPartition() throws Exception {
+ // given a partitioned index with one file group in each of the
generator's three data partitions,
+ // and a writer that still sizes it at one file group per data partition
+ writePartitionedRecordIndex();
+ TypedProperties props =
partitionedSizingProps(FILE_GROUPS_PER_DATA_PARTITION,
FILE_GROUPS_PER_DATA_PARTITION, ONE_GIB);
+
+ // when the check runs
+ HealthCheckResult result = runCheck(props, false);
+
+ // then the total is reported, the comparison is per data partition, and
the index is healthy
+ assertEquals(HealthStatus.HEALTHY, result.getStatus(),
result.getSummary());
+ Map<String, String> configs = result.getEffectiveConfigs();
+ assertEquals("partitioned", configs.get("observed.index.layout"));
+ assertEquals(String.valueOf(DATA_PARTITIONS),
configs.get("observed.data.partition.count"));
+ assertEquals(String.valueOf(DATA_PARTITIONS *
FILE_GROUPS_PER_DATA_PARTITION), configs.get("observed.file.group.count"));
+ assertEquals("1",
configs.get("observed.worst.data.partition.file.group.count"));
+ assertEquals("1", configs.get("effective.ideal.file.group.count"));
+ // min == max per data partition, so every partition is floor-bound here;
the pick among
+ // size-derived partitions is covered on synthetic footprints in
TestRecordIndexSizingCheck
+ assertEquals("configured minimum (floor)",
configs.get("effective.ideal.source"));
+ assertEquals("0", configs.get("observed.undersized.data.partition.count"));
+ assertTrue(configs.get("effective.count.comparison").startsWith("per data
partition"), configs.toString());
+ assertTrue(configs.containsKey("observed.worst.data.partition"),
configs.toString());
+ }
+
+ @Test
+ public void
partitionedIndexUndersizedInADataPartitionIsUnhealthyAndNamesThePartition()
throws Exception {
+ // given a partitioned index with one file group per data partition, and a
maximum file-group
+ // size small enough that each partition's own base-file bytes call for
more than the one it has
+ writePartitionedRecordIndex();
+ TypedProperties props = partitionedSizingProps(1, 64, ONE_KIB);
+
+ // when the check runs
+ HealthCheckResult result = runCheck(props, false);
+
+ // then the shortfall is judged per data partition -- three total file
groups are not weighed
+ // against four -- and the worst partition is named in the finding with
the remedy
+ assertEquals(HealthStatus.UNHEALTHY, result.getStatus(),
result.getSummary());
+ Map<String, String> configs = result.getEffectiveConfigs();
+ int partitionedIdeal =
Integer.parseInt(configs.get("effective.ideal.file.group.count"));
+ assertTrue(partitionedIdeal > 1, "the ideal must come from the estimate,
not the floor; was " + partitionedIdeal);
+ assertEquals(String.valueOf((int) Math.ceil(0.5 * partitionedIdeal)),
+ configs.get("effective.min.healthy.file.group.count"));
+ assertEquals("1",
configs.get("observed.worst.data.partition.file.group.count"));
+ assertEquals("size estimate", configs.get("effective.ideal.source"));
+
assertTrue(Integer.parseInt(configs.get("observed.undersized.data.partition.count"))
>= 1, configs.toString());
+ String worstPartition = configs.get("observed.worst.data.partition");
+ assertTrue(result.getFindings().stream().anyMatch(f -> f.contains(
+ String.format("Data partition '%s' of the record index was initialized
with 1 file group(s)", worstPartition))),
+ "expected the finding to name the worst data partition, got: " +
result.getFindings());
+ // HoodieIndexer cannot rebuild a partitioned index (apache/hudi#20343),
so the remedy must say
+ // so rather than name a command that would silently produce a global
index instead
+ assertTrue(result.getFindings().stream().anyMatch(f ->
f.contains("issues/20343")),
+ "expected the partitioned-rebuild caveat, got: " +
result.getFindings());
+ assertTrue(result.getFindings().stream().noneMatch(f ->
f.contains("metadata delete-record-index")),
+ "the global re-bootstrap command must not be offered for a partitioned
index, got: " + result.getFindings());
+ }
+}