Skip to content

Commit b301702

Browse files
committed
feat(partition-ttl): Introduce KeepByEventTimeStrategy to expire partitions by event time
1 parent 95ac693 commit b301702

9 files changed

Lines changed: 622 additions & 2 deletions

File tree

hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieTTLConfig.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,32 @@ public class HoodieTTLConfig extends HoodieConfig {
8484
.sinceVersion("1.0.0")
8585
.withDocumentation("max partitions to delete in partition ttl management");
8686

87+
public static final ConfigProperty<String> EVENT_TIME_FORMAT = ConfigProperty
88+
.key(PARTITION_TTL_STRATEGY_PARAM_PREFIX + "event.time.format")
89+
.defaultValue("yyyy-MM-dd")
90+
.markAdvanced()
91+
.sinceVersion("1.3.0")
92+
.withDocumentation("Used by KEEP_BY_EVENT_TIME. Date-time pattern for the event time encoded in the partition path. "
93+
+ "A '/' in the pattern means the time spans multiple path segments. Examples: 'yyyy-MM-dd' (default), "
94+
+ "'yyyyMMdd', 'yyyy-MM-dd/HH', 'yyyyMMdd/HH'.");
95+
96+
public static final ConfigProperty<Integer> EVENT_TIME_PARTITION_START_INDEX = ConfigProperty
97+
.key(PARTITION_TTL_STRATEGY_PARAM_PREFIX + "event.time.partition.start.index")
98+
.defaultValue(0)
99+
.markAdvanced()
100+
.sinceVersion("1.3.0")
101+
.withDocumentation("Used by KEEP_BY_EVENT_TIME. 0-based index of the first path segment that carries the event time. "
102+
+ "Defaults to 0 for pure time partitions like 'dt=2026-04-24'. Set to a higher value when non-time prefix segments exist, "
103+
+ "e.g. 1 for 'region=us/20260424/05'.");
104+
105+
public static final ConfigProperty<Boolean> EVENT_TIME_DELETE_HIVE_DEFAULT_PARTITION = ConfigProperty
106+
.key(PARTITION_TTL_STRATEGY_PARAM_PREFIX + "event.time.delete.hive.default.partition")
107+
.defaultValue(false)
108+
.markAdvanced()
109+
.sinceVersion("1.3.0")
110+
.withDocumentation("When true, KEEP_BY_EVENT_TIME treats partitions containing __HIVE_DEFAULT_PARTITION__ as expired and removes them. "
111+
+ "Defaults to false so such partitions are skipped (with a WARN log) and the user keeps explicit control over their lifecycle.");
112+
87113
public static class Builder {
88114
private final HoodieTTLConfig ttlConfig = new HoodieTTLConfig();
89115

@@ -112,6 +138,21 @@ public HoodieTTLConfig.Builder withTTLStrategyType(PartitionTTLStrategyType ttlS
112138
return this;
113139
}
114140

141+
public HoodieTTLConfig.Builder withEventTimeFormat(String format) {
142+
ttlConfig.setValue(EVENT_TIME_FORMAT, format);
143+
return this;
144+
}
145+
146+
public HoodieTTLConfig.Builder withEventTimePartitionStartIndex(int startIndex) {
147+
ttlConfig.setValue(EVENT_TIME_PARTITION_START_INDEX, Integer.toString(startIndex));
148+
return this;
149+
}
150+
151+
public HoodieTTLConfig.Builder withDeleteHiveDefaultPartition(boolean enable) {
152+
ttlConfig.setValue(EVENT_TIME_DELETE_HIVE_DEFAULT_PARTITION, Boolean.toString(enable));
153+
return this;
154+
}
155+
115156
public HoodieTTLConfig.Builder fromProperties(Properties props) {
116157
this.ttlConfig.getProps().putAll(props);
117158
return this;

hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieWriteConfig.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3056,6 +3056,23 @@ public Integer getPartitionTTLMaxPartitionsToDelete() {
30563056
return getInt(HoodieTTLConfig.MAX_PARTITION_TO_DELETE);
30573057
}
30583058

3059+
// The three event-time TTL getters all use *OrDefault flavors so the ConfigProperty defaults
3060+
// are honored even on paths that bypass HoodieTTLConfig.Builder (e.g. raw properties injected
3061+
// straight into HoodieWriteConfig without the builder's setDefaults pass).
3062+
// Note: getBoolean already short-circuits to getBooleanOrDefault when a default exists, so
3063+
// shouldDeleteHiveDefaultPartitionForEventTimeTTL keeps the plain call.
3064+
public String getPartitionTTLEventTimeFormat() {
3065+
return getStringOrDefault(HoodieTTLConfig.EVENT_TIME_FORMAT);
3066+
}
3067+
3068+
public int getPartitionTTLEventTimePartitionStartIndex() {
3069+
return getIntOrDefault(HoodieTTLConfig.EVENT_TIME_PARTITION_START_INDEX);
3070+
}
3071+
3072+
public boolean shouldDeleteHiveDefaultPartitionForEventTimeTTL() {
3073+
return getBoolean(HoodieTTLConfig.EVENT_TIME_DELETE_HIVE_DEFAULT_PARTITION);
3074+
}
3075+
30593076
public boolean isSecondaryIndexEnabled() {
30603077
return metadataConfig.isSecondaryIndexEnabled();
30613078
}

hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/ttl/strategy/HoodiePartitionTTLStrategyFactory.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ public static String getPartitionTTLStrategyFromType(PartitionTTLStrategyType ty
7979
return KeepByTimeStrategy.class.getName();
8080
case KEEP_BY_CREATION_TIME:
8181
return KeepByCreationTimeStrategy.class.getName();
82+
case KEEP_BY_EVENT_TIME:
83+
return KeepByEventTimeStrategy.class.getName();
8284
default:
8385
throw new HoodieException("Unsupported PartitionTTLStrategy Type " + type);
8486
}
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
package org.apache.hudi.table.action.ttl.strategy;
20+
21+
import org.apache.hudi.common.table.timeline.HoodieInstantTimeGenerator;
22+
import org.apache.hudi.common.util.PartitionPathEncodeUtils;
23+
import org.apache.hudi.table.HoodieTable;
24+
25+
import lombok.extern.slf4j.Slf4j;
26+
27+
import java.text.ParseException;
28+
import java.time.ZoneOffset;
29+
import java.time.format.DateTimeFormatter;
30+
import java.time.format.DateTimeParseException;
31+
import java.time.temporal.TemporalAccessor;
32+
import java.util.List;
33+
import java.util.stream.Collectors;
34+
35+
/**
36+
* Event-time based partition TTL strategy: lifetime is read from the partition path, not from
37+
* commit metadata. Late-arriving writes into an old partition do not extend its lifetime, and
38+
* backfilled historic partitions are still considered old. Compare with {@link KeepByTimeStrategy}
39+
* (last commit time) and {@link KeepByCreationTimeStrategy} (creation commit time).
40+
*
41+
* <h3>Supported path shapes</h3>
42+
* The first-class, tested shapes are day and hour granularity. Each example shows the partition
43+
* path on the left and the required {@code startIndex} on the right (see
44+
* <i>Locating the time block</i> below); non-time segments may appear before and/or after the
45+
* time block.
46+
* <ul>
47+
* <li>Day, {@code format=yyyy-MM-dd}
48+
* <ul>
49+
* <li>time only: {@code 2026-06-27}, {@code dt=2026-06-27} — startIndex {@code 0}</li>
50+
* <li>prefix + time: {@code region=us/2026-06-27}, {@code region=us/dt=2026-06-27} — startIndex {@code 1}</li>
51+
* <li>time + suffix: {@code 2026-06-27/source=app}, {@code dt=2026-06-27/source=app} — startIndex {@code 0}</li>
52+
* <li>prefix + time + suffix: {@code region=us/dt=2026-06-27/source=app} — startIndex {@code 1}</li>
53+
* </ul>
54+
* </li>
55+
* <li>Day, {@code format=yyyyMMdd}
56+
* <ul>
57+
* <li>time only: {@code 20260627}, {@code dt=20260627} — startIndex {@code 0}</li>
58+
* <li>prefix + time: {@code region=us/20260627}, {@code region=us/dt=20260627} — startIndex {@code 1}</li>
59+
* <li>time + suffix: {@code 20260627/source=app}, {@code dt=20260627/source=app} — startIndex {@code 0}</li>
60+
* <li>prefix + time + suffix: {@code region=us/dt=20260627/source=app} — startIndex {@code 1}</li>
61+
* </ul>
62+
* </li>
63+
* <li>Hour, {@code format=yyyy-MM-dd/HH}
64+
* <ul>
65+
* <li>time only: {@code 2026-06-27/12}, {@code dt=2026-06-27/hh=12} — startIndex {@code 0}</li>
66+
* <li>prefix + time: {@code region=us/2026-06-27/12}, {@code region=us/dt=2026-06-27/hh=12} — startIndex {@code 1}</li>
67+
* <li>time + suffix: {@code 2026-06-27/12/source=app}, {@code dt=2026-06-27/hh=12/source=app} — startIndex {@code 0}</li>
68+
* <li>prefix + time + suffix: {@code region=us/dt=2026-06-27/hh=12/source=app} — startIndex {@code 1}</li>
69+
* </ul>
70+
* </li>
71+
* <li>Hour, {@code format=yyyyMMdd/HH}
72+
* <ul>
73+
* <li>time only: {@code 20260627/12}, {@code dt=20260627/hh=12} — startIndex {@code 0}</li>
74+
* <li>prefix + time: {@code region=us/20260627/12}, {@code region=us/dt=20260627/hh=12} — startIndex {@code 1}</li>
75+
* <li>time + suffix: {@code 20260627/12/source=app}, {@code dt=20260627/hh=12/source=app} — startIndex {@code 0}</li>
76+
* <li>prefix + time + suffix: {@code region=us/dt=20260627/hh=12/source=app} — startIndex {@code 1}</li>
77+
* </ul>
78+
* </li>
79+
* </ul>
80+
* Hive-style key names are not constrained: {@code dt=}, {@code day=}, {@code event_date=},
81+
* {@code hh=}, {@code hour=} all work; only the value after {@code =} is parsed.
82+
* <p>
83+
* Any {@link java.time.format.DateTimeFormatter} pattern works as long as the resulting
84+
* {@link java.time.temporal.TemporalAccessor} can be resolved to either an {@link java.time.Instant}
85+
* or a {@link java.time.LocalDate} (day-only patterns are anchored at UTC start-of-day). A
86+
* {@code /} in the pattern means the time value spans that many consecutive path segments.
87+
* Patterns missing day-of-month, e.g. month-only {@code yyyy-MM}, cannot be resolved and will
88+
* raise the standard parse-failure error at runtime.
89+
*
90+
* <h3>Locating the time block</h3>
91+
* The time block must occupy a <i>contiguous</i> segment range of the partition path. Only the
92+
* segments before it count toward {@code startIndex}; segments after are ignored. Interleaved
93+
* layouts such as {@code dt=20260627/source=app/hh=12} are not supported -- the time block must
94+
* be in one piece.
95+
*
96+
* <h3>Time zone</h3>
97+
* Both the partition's event time and the cutoff derived from {@code instantTime} are interpreted
98+
* in UTC. Set {@code hoodie.table.timeline.timezone=UTC} so the timeline writes instants under the
99+
* same convention; otherwise the cutoff drifts by the JVM's UTC offset -- a boundary effect at
100+
* day granularity, a full-offset shift at hour granularity.
101+
*
102+
* <h3>Configuration</h3>
103+
* All three knobs come with defaults, so a table whose partition path is purely a date in
104+
* {@code yyyy-MM-dd} form works out of the box.
105+
* <ul>
106+
* <li>{@link org.apache.hudi.config.HoodieTTLConfig#EVENT_TIME_FORMAT} — date-time pattern of
107+
* the time block in the partition path. Default {@code yyyy-MM-dd}. A {@code /} in the
108+
* pattern means the time block spans that many consecutive segments.</li>
109+
* <li>{@link org.apache.hudi.config.HoodieTTLConfig#EVENT_TIME_PARTITION_START_INDEX} —
110+
* 0-based index of the first segment that carries the time block. Default {@code 0}.
111+
* Raise it when non-time segments come before the time block (see examples above).</li>
112+
* <li>{@link org.apache.hudi.config.HoodieTTLConfig#EVENT_TIME_DELETE_HIVE_DEFAULT_PARTITION} —
113+
* whether to treat partitions containing {@code __HIVE_DEFAULT_PARTITION__} (i.e. data
114+
* whose event-time column was {@code NULL}) as expired. Default {@code false}, i.e. such
115+
* partitions are skipped with a WARN and the user keeps explicit control over them.</li>
116+
* </ul>
117+
*/
118+
@Slf4j
119+
public class KeepByEventTimeStrategy extends KeepByTimeStrategy {
120+
121+
private final String eventTimeFormat;
122+
private final int startIndex;
123+
private final boolean deleteHiveDefaultPartition;
124+
private final boolean hiveStylePartitioning;
125+
126+
public KeepByEventTimeStrategy(HoodieTable hoodieTable, String instantTime) {
127+
super(hoodieTable, instantTime);
128+
// Defaults: format='yyyy-MM-dd', startIndex=0, deleteHiveDefaultPartition=false. The two
129+
// guards below catch users who set the values explicitly to obviously-broken inputs.
130+
this.eventTimeFormat = writeConfig.getPartitionTTLEventTimeFormat();
131+
if (eventTimeFormat == null || eventTimeFormat.isEmpty()) {
132+
throw new IllegalArgumentException(
133+
"hoodie.partition.ttl.strategy.event.time.format must not be empty.");
134+
}
135+
this.startIndex = writeConfig.getPartitionTTLEventTimePartitionStartIndex();
136+
if (startIndex < 0) {
137+
throw new IllegalArgumentException(
138+
"hoodie.partition.ttl.strategy.event.time.partition.start.index must be >= 0, got " + startIndex);
139+
}
140+
this.deleteHiveDefaultPartition = writeConfig.shouldDeleteHiveDefaultPartitionForEventTimeTTL();
141+
// Hive-style partitioning is a table-level property recorded at table creation; trust it
142+
// rather than guessing per-segment from a stray '=' character in a value.
143+
this.hiveStylePartitioning = Boolean.parseBoolean(
144+
hoodieTable.getMetaClient().getTableConfig().getHiveStylePartitioningEnable());
145+
}
146+
147+
@Override
148+
protected List<String> getExpiredPartitionsForTimeStrategy(List<String> partitionPathsForTTL) {
149+
long cutoffMillis = resolveCutoffMillis(instantTime, ttlInMilis);
150+
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(eventTimeFormat).withZone(ZoneOffset.UTC);
151+
int segCount = segmentCount(eventTimeFormat);
152+
return partitionPathsForTTL.stream().parallel()
153+
.filter(path -> isPartitionExpiredByEventTime(
154+
path, formatter, startIndex, segCount, cutoffMillis, deleteHiveDefaultPartition, hiveStylePartitioning))
155+
.collect(Collectors.toList());
156+
}
157+
158+
/**
159+
* Number of '/'-separated path segments the configured format occupies.
160+
* Example: {@code yyyy-MM-dd/HH} -> 2.
161+
*/
162+
static int segmentCount(String format) {
163+
return format.split("/").length;
164+
}
165+
166+
/**
167+
* Resolve the "now" reference timestamp from {@code instantTime}, in UTC.
168+
* <p>
169+
* Anchoring on {@code instantTime} keeps the strategy idempotent across retries of the same
170+
* replace commit. We parse it in UTC so the cutoff and the partition's event time (also parsed
171+
* in UTC above) sit on the same axis -- otherwise expiry drifts by the JVM's UTC offset, which
172+
* is negligible at day granularity but a full-offset shift at hour granularity.
173+
*/
174+
static long resolveCutoffMillis(String instantTime, long ttlInMillis) {
175+
try {
176+
return HoodieInstantTimeGenerator.parseDateFromInstantTime(instantTime, ZoneOffset.UTC).getTime() - ttlInMillis;
177+
} catch (ParseException e) {
178+
throw new IllegalStateException("Failed to parse instant time " + instantTime, e);
179+
}
180+
}
181+
182+
/**
183+
* Decide whether a partition path is expired.
184+
* <p>
185+
* The strategy treats any partition that cannot be parsed under the configured format /
186+
* start-index / hive-style as a hard error. Reasoning: this class derives lifetime from the
187+
* path itself, so a partition we cannot parse has no defined lifetime, and silently skipping
188+
* it would leave it in the table forever while the rest of TTL appears to succeed. Switch to
189+
* {@code KEEP_BY_TIME} or {@code KEEP_BY_CREATION_TIME} (which key off commit metadata, not
190+
* the path) if the table contains partitions that don't conform to a single event-time shape.
191+
* <p>
192+
* Package-private so unit tests can exercise it directly without spinning up a HoodieTable.
193+
*/
194+
static boolean isPartitionExpiredByEventTime(String partitionPath,
195+
DateTimeFormatter formatter,
196+
int startIndex,
197+
int segCount,
198+
long cutoffMillis,
199+
boolean deleteHiveDefaultPartition,
200+
boolean hiveStylePartitioning) {
201+
String[] segments = partitionPath.split("/");
202+
if (segments.length < startIndex + segCount) {
203+
throw new IllegalArgumentException(String.format(
204+
"Partition '%s' has %d segment(s) but the configured event time spans %d segment(s) starting at index %d. "
205+
+ "Check hoodie.partition.ttl.strategy.event.time.format and event.time.partition.start.index, "
206+
+ "or switch to KEEP_BY_TIME / KEEP_BY_CREATION_TIME if not all partitions of this table follow an event-time shape.",
207+
partitionPath, segments.length, segCount, startIndex));
208+
}
209+
210+
String[] timeSegs = new String[segCount];
211+
for (int i = 0; i < segCount; i++) {
212+
String seg = segments[startIndex + i];
213+
if (hiveStylePartitioning) {
214+
int eq = seg.indexOf('=');
215+
if (eq < 0) {
216+
throw new IllegalArgumentException(String.format(
217+
"Partition '%s' segment '%s' has no hive-style 'field=value' prefix but "
218+
+ "hoodie.datasource.write.hive_style_partitioning=true on the table. "
219+
+ "Switch to KEEP_BY_TIME / KEEP_BY_CREATION_TIME if such legacy partitions must coexist.",
220+
partitionPath, seg));
221+
}
222+
timeSegs[i] = seg.substring(eq + 1);
223+
} else {
224+
timeSegs[i] = seg;
225+
}
226+
if (PartitionPathEncodeUtils.DEFAULT_PARTITION_PATH.equals(timeSegs[i])) {
227+
// Hive default partition is explicitly user-controlled (see config above); not a parse error.
228+
if (deleteHiveDefaultPartition) {
229+
log.info("Partition '{}' contains {} and delete switch is on; marking expired",
230+
partitionPath, PartitionPathEncodeUtils.DEFAULT_PARTITION_PATH);
231+
return true;
232+
}
233+
log.warn("Skipping partition '{}': contains {} (set hoodie.partition.ttl.strategy."
234+
+ "event.time.delete.hive.default.partition=true to delete)",
235+
partitionPath, PartitionPathEncodeUtils.DEFAULT_PARTITION_PATH);
236+
return false;
237+
}
238+
}
239+
240+
String timeStr = String.join("/", timeSegs);
241+
Long eventMillis = parseEventMillis(timeStr, formatter);
242+
if (eventMillis == null) {
243+
throw new IllegalArgumentException(String.format(
244+
"Partition '%s': cannot parse '%s' with pattern '%s'. "
245+
+ "Fix hoodie.partition.ttl.strategy.event.time.format, or switch to "
246+
+ "KEEP_BY_TIME / KEEP_BY_CREATION_TIME if such partitions must remain in the table.",
247+
partitionPath, timeStr, formatter));
248+
}
249+
return eventMillis < cutoffMillis;
250+
}
251+
252+
/**
253+
* Parse {@code timeStr} into epoch millis. Tries full date-time first; falls back to a
254+
* date-only parse anchored at UTC start-of-day so day-level patterns (e.g. {@code yyyy-MM-dd})
255+
* also work.
256+
*/
257+
static Long parseEventMillis(String timeStr, DateTimeFormatter formatter) {
258+
TemporalAccessor parsed;
259+
try {
260+
parsed = formatter.parse(timeStr);
261+
} catch (DateTimeParseException e) {
262+
return null;
263+
}
264+
try {
265+
return java.time.Instant.from(parsed).toEpochMilli();
266+
} catch (java.time.DateTimeException ignore) {
267+
// Day-level pattern with no time-of-day: anchor at UTC start of day.
268+
try {
269+
return java.time.LocalDate.from(parsed).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli();
270+
} catch (java.time.DateTimeException e) {
271+
return null;
272+
}
273+
}
274+
}
275+
}

hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/ttl/strategy/PartitionTTLStrategyType.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@
3636
*/
3737
public enum PartitionTTLStrategyType {
3838
KEEP_BY_TIME("org.apache.hudi.table.action.ttl.strategy.KeepByTimeStrategy"),
39-
KEEP_BY_CREATION_TIME("org.apache.hudi.table.action.ttl.strategy.KeepByCreationTimeStrategy");
39+
KEEP_BY_CREATION_TIME("org.apache.hudi.table.action.ttl.strategy.KeepByCreationTimeStrategy"),
40+
KEEP_BY_EVENT_TIME("org.apache.hudi.table.action.ttl.strategy.KeepByEventTimeStrategy");
4041

4142
@Getter
4243
private final String className;

0 commit comments

Comments
 (0)