-
Notifications
You must be signed in to change notification settings - Fork 989
Support timeToLive attributes in DynamoDb Enhanced Client #6152
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| { | ||
| "type": "feature", | ||
| "category": "Amazon DynamoDB Enhanced Client", | ||
| "contributor": "", | ||
| "description": "Added support for TimeToLive attributes in DynamoDB Enhanced Client" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,201 @@ | ||
| /* | ||
| * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"). | ||
| * You may not use this file except in compliance with the License. | ||
| * A copy of the License is located at | ||
| * | ||
| * http://aws.amazon.com/apache2.0 | ||
| * | ||
| * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.enhanced.dynamodb.extensions; | ||
|
|
||
| import java.time.Duration; | ||
| import java.time.Instant; | ||
| import java.time.LocalDate; | ||
| import java.time.LocalDateTime; | ||
| import java.time.LocalTime; | ||
| import java.time.ZoneOffset; | ||
| import java.time.ZonedDateTime; | ||
| import java.time.temporal.ChronoUnit; | ||
| import java.time.temporal.TemporalUnit; | ||
| import java.util.Collections; | ||
| import java.util.HashMap; | ||
| import java.util.Map; | ||
| import java.util.function.Consumer; | ||
| import software.amazon.awssdk.annotations.SdkPublicApi; | ||
| import software.amazon.awssdk.annotations.ThreadSafe; | ||
| import software.amazon.awssdk.enhanced.dynamodb.AttributeValueType; | ||
| import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClientExtension; | ||
| import software.amazon.awssdk.enhanced.dynamodb.DynamoDbExtensionContext; | ||
| import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; | ||
| import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTag; | ||
| import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableMetadata; | ||
| import software.amazon.awssdk.services.dynamodb.model.AttributeValue; | ||
| import software.amazon.awssdk.utils.StringUtils; | ||
| import software.amazon.awssdk.utils.Validate; | ||
|
|
||
| @SdkPublicApi | ||
| @ThreadSafe | ||
| public final class TimeToLiveExtension implements DynamoDbEnhancedClientExtension { | ||
|
|
||
| public static final String CUSTOM_METADATA_KEY = "TimeToLiveExtension:TimeToLiveAttribute"; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1- Consider renaming this to something like, TTL_ATTRIBUTE_METADATA_KEY, current name is too vague
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| private TimeToLiveExtension() { | ||
| } | ||
|
|
||
| public static TimeToLiveExtension.Builder builder() { | ||
| return new TimeToLiveExtension.Builder(); | ||
| } | ||
|
|
||
| /** | ||
| * @return an Instance of {@link TimeToLiveExtension} | ||
| */ | ||
| public static TimeToLiveExtension create() { | ||
| return new TimeToLiveExtension(); | ||
| } | ||
|
|
||
| @Override | ||
| public WriteModification beforeWrite(DynamoDbExtensionContext.BeforeWrite context) { | ||
| Map<?, ?> customTTLMetadata = context.tableMetadata() | ||
| .customMetadataObject(CUSTOM_METADATA_KEY, Map.class).orElse(null); | ||
|
|
||
| if (customTTLMetadata != null) { | ||
| String ttlAttributeName = validateMetadataValue(customTTLMetadata, "attributeName", String.class); | ||
| String baseFieldName = validateMetadataValue(customTTLMetadata, "baseField", String.class); | ||
| long duration = validateMetadataValue(customTTLMetadata, "duration", Long.class); | ||
| TemporalUnit unit = validateMetadataValue(customTTLMetadata, "unit", TemporalUnit.class); | ||
|
|
||
| Validate.isTrue(duration >= 0, "Custom TTL metadata key 'duration' must not be negative."); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we should add baseFieldName check as well, here. It would be silent no-op when duration > 0 and baseField="". |
||
|
|
||
| Map<String, AttributeValue> items = context.items(); | ||
|
|
||
| if (!items.containsKey(ttlAttributeName) && StringUtils.isNotBlank(baseFieldName) | ||
| && items.containsKey(baseFieldName)) { | ||
| Object baseFieldValue = context.tableSchema().converterForAttribute(baseFieldName) | ||
| .transformTo(items.get(baseFieldName)); | ||
| Long ttlEpochSeconds = computeTTLFromBase(baseFieldValue, duration, unit); | ||
| Map<String, AttributeValue> itemToTransform = new HashMap<>(context.items()); | ||
| itemToTransform.put(ttlAttributeName, AttributeValue.builder().n(String.valueOf(ttlEpochSeconds)).build()); | ||
|
|
||
| return WriteModification.builder().transformedItem(Collections.unmodifiableMap(itemToTransform)).build(); | ||
| } | ||
| } | ||
|
|
||
| return WriteModification.builder().build(); | ||
| } | ||
|
|
||
| private static <T> T validateMetadataValue(Map<?, ?> metadata, String key, Class<T> expectedType) { | ||
| Object value = Validate.notNull(metadata.get(key), "Custom TTL metadata is missing required key '%s'.", key); | ||
|
|
||
| if (!expectedType.isInstance(value)) { | ||
| throw new IllegalArgumentException(String.format("Custom TTL metadata key '%s' must be of type %s, but was %s.", | ||
| key, expectedType.getName(), value.getClass().getName())); | ||
| } | ||
|
|
||
| return expectedType.cast(value); | ||
| } | ||
|
|
||
| private static Long computeTTLFromBase(Object baseValue, long duration, TemporalUnit unit) { | ||
| if (baseValue instanceof Instant) { | ||
| return ((Instant) baseValue).plus(duration, unit).getEpochSecond(); | ||
| } | ||
| if (baseValue instanceof LocalDate) { | ||
| return ((LocalDate) baseValue).atStartOfDay(ZoneOffset.UTC).plus(duration, unit).toEpochSecond(); | ||
| } | ||
| if (baseValue instanceof LocalDateTime) { | ||
| return ((LocalDateTime) baseValue).plus(duration, unit).toEpochSecond(ZoneOffset.UTC); | ||
| } | ||
| if (baseValue instanceof LocalTime) { | ||
| return LocalDate.now().atTime((LocalTime) baseValue).plus(duration, unit).toEpochSecond(ZoneOffset.UTC); | ||
| } | ||
|
Comment on lines
+114
to
+116
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we want to support local time ? if (baseValue instanceof LocalTime) {
return LocalDate.now().atTime((LocalTime) baseValue).plus(duration, unit).toEpochSecond(ZoneOffset.UTC);
}two potential problems:
If you say, LocalDate.now(ZoneOffset.UTC), it would fix problem 2, but problem 1 is still there to LocalTime having no date component, there's always a midnight boundary where the TTL jumps by 24 hours. Recommendation: You may consider removiong LocalTime support and let it fall through to the existing IllegalArgumentException if that's acceptable. The other five types (Instant, LocalDate, LocalDateTime, There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I strongly suggest we only use UTC and only support |
||
| if (baseValue instanceof ZonedDateTime) { | ||
| return ((ZonedDateTime) baseValue).plus(duration, unit).toEpochSecond(); | ||
| } | ||
| if (baseValue instanceof Long) { | ||
| return (Long) baseValue + Duration.of(duration, unit).getSeconds(); | ||
| } | ||
|
|
||
| throw new IllegalArgumentException("Unsupported base field type for TTL computation: " + baseValue.getClass().getName()); | ||
| } | ||
|
|
||
| public static final class Builder { | ||
| private Builder() { | ||
| } | ||
|
|
||
| public TimeToLiveExtension build() { | ||
| return new TimeToLiveExtension(); | ||
| } | ||
| } | ||
|
|
||
| public static final class AttributeTags { | ||
| private AttributeTags() { | ||
| } | ||
|
|
||
| /** | ||
| * Used to explicitly designate an attribute to determine the TTL on the table. | ||
| * | ||
| * <p><b>How this works</b></p> | ||
| * <ul> | ||
| * <li>If a TTL attribute is set, it takes precedence over <i>baseField</i>.</li> | ||
| * <li>If no TTL attribute is set, it checks for <i>baseField</i>.</li> | ||
| * <li>If <i>baseField</i> is present, the TTL is calculated using its value, <i>duration</i>, and <i>unit</i>.</li> | ||
| * <li>The final TTL value is converted to epoch seconds before storing in DynamoDB.</li> | ||
| * </ul> | ||
| * | ||
| * @param baseField Optional attribute name used to determine the TTL value. | ||
| * @param duration Additional long value used for TTL calculation. | ||
| * @param unit {@link ChronoUnit} value specifying the TTL duration unit. | ||
| */ | ||
| public static StaticAttributeTag timeToLiveAttribute(String baseField, long duration, ChronoUnit unit) { | ||
| return new TimeToLiveAttribute(baseField, duration, unit); | ||
| } | ||
| } | ||
|
|
||
| private static final class TimeToLiveAttribute implements StaticAttributeTag { | ||
|
|
||
| private final String baseField; | ||
| private final long duration; | ||
| private final ChronoUnit unit; | ||
|
|
||
| private TimeToLiveAttribute(String baseField, long duration, ChronoUnit unit) { | ||
| this.baseField = baseField; | ||
| this.duration = duration; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we check this is not negative somewhere?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added validation for duration in modifyMetadata and beforeWrite, I will have a look if there is a better place for this validation. |
||
| this.unit = unit; | ||
| } | ||
|
|
||
| @Override | ||
| public <R> void validateType(String attributeName, EnhancedType<R> type, | ||
| AttributeValueType attributeValueType) { | ||
|
|
||
| Validate.notNull(type, "type is null"); | ||
| Validate.notNull(type.rawClass(), "rawClass is null"); | ||
| Validate.notNull(attributeValueType, "attributeValueType is null"); | ||
|
|
||
| if (!type.rawClass().equals(Long.class)) { | ||
| throw new IllegalArgumentException(String.format( | ||
| "Attribute '%s' of type %s is not a suitable type to be used as a TTL attribute. Only type Long " + | ||
| "is supported.", attributeName, type.rawClass())); | ||
| } | ||
| } | ||
|
|
||
| @Override | ||
| public Consumer<StaticTableMetadata.Builder> modifyMetadata(String attributeName, | ||
| AttributeValueType attributeValueType) { | ||
| Validate.isTrue(duration >= 0, "duration must not be negative"); | ||
| Map<String, Object> customMetadataMap = new HashMap<>(); | ||
| customMetadataMap.put("attributeName", attributeName); | ||
| customMetadataMap.put("baseField", baseField); | ||
| customMetadataMap.put("duration", duration); | ||
| customMetadataMap.put("unit", unit); | ||
|
|
||
| return metadata -> metadata.addCustomMetadataObject(CUSTOM_METADATA_KEY, | ||
| Collections.unmodifiableMap(customMetadataMap)); | ||
|
Comment on lines
+188
to
+198
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. BeanTableSchema validates that only one TTL attribute exists, but StaticTableSchema has no such guard. Since modifyMetadata stores a Map (not a Collection) via addCustomMetadataObject, a second TTL attribute silently replaces the first with no error: StaticTableSchema.builder(MyItem.class)
.addAttribute(Long.class, a -> a.name("ttl1")
.getter(MyItem::getTTL1).setter(MyItem::setTTL1)
.tags(timeToLiveAttribute("createdAt", 30, ...)))
.addAttribute(Long.class, a -> a.name("ttl2")
.getter(MyItem::getTTL2).setter(MyItem::setTTL2)
.tags(timeToLiveAttribute("updatedAt", 7, ...)))
.build();
// ttl1 config is silently lost, only ttl2 is trackedDynamoDB only supports one TTL attribute per table, so this is always a user error, but the SDK should fail loudly rather than silently dropping one. Recommendation: You may consider moving the validation into TimeToLiveAttribute.modifyMetadata() so it works for all schema types
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agree, the validation is applied only on BeanTableSchema and I am preparing parameterized tests to cover the remaining schemas with validation updated - tests still in progress, have pretty many scenarios to cover with different record types. Also, I feel like private void mergeCustomMetaDataObject(String key, Object object) {
if (object instanceof Collection) {
this.addCustomMetadataObject(key, (Collection<Object>) object);
} else if (object instanceof Map) {
this.addCustomMetadataObject(key, (Map<Object, Object>) object);
} else {
this.addCustomMetadataObject(key, object);
}
}because the else branch throws an exception when finds duplicates: public Builder addCustomMetadataObject(String key, Object object) {
if (customMetadata.containsKey(key)) {
throw new IllegalArgumentException("Attempt to set a custom metadata object that has already been set. "
+ "Custom metadata object key: " + key);
}
customMetadata.put(key, object);
return this;
} |
||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| /* | ||
| * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"). | ||
| * You may not use this file except in compliance with the License. | ||
| * A copy of the License is located at | ||
| * | ||
| * http://aws.amazon.com/apache2.0 | ||
| * | ||
| * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.enhanced.dynamodb.extensions.annotations; | ||
|
|
||
| import java.lang.annotation.ElementType; | ||
| import java.lang.annotation.Retention; | ||
| import java.lang.annotation.RetentionPolicy; | ||
| import java.lang.annotation.Target; | ||
| import java.time.temporal.ChronoUnit; | ||
| import software.amazon.awssdk.annotations.SdkPublicApi; | ||
| import software.amazon.awssdk.enhanced.dynamodb.internal.extensions.TimeToLiveAttributeTags; | ||
| import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.BeanTableSchemaAttributeTag; | ||
|
|
||
| /** | ||
| * Annotation used to mark an attribute in a DynamoDB-enhanced client model as a Time-To-Live (TTL) field. | ||
| * <p> | ||
| * This annotation allows automatic computation and assignment of a TTL value based on another field (the {@code baseField}) | ||
| * and a time offset defined by {@code duration} and {@code unit}. The TTL value is stored in epoch seconds and | ||
| * can be configured to expire items from the table automatically. | ||
| * <p> | ||
| * To use this, the annotated method should return a {@link Long} value, which will be populated by the SDK at write time. | ||
| * The {@code baseField} can be a temporal type such as {@link java.time.Instant}, {@link java.time.LocalDate}, | ||
| * {@link java.time.LocalDateTime}, etc., or a {@link Long} representing epoch seconds directly, serving as the reference point | ||
| * for TTL calculation. | ||
| */ | ||
| @Target(ElementType.METHOD) | ||
| @Retention(RetentionPolicy.RUNTIME) | ||
| @BeanTableSchemaAttributeTag(TimeToLiveAttributeTags.class) | ||
| @SdkPublicApi | ||
| public @interface DynamoDbTimeToLiveAttribute { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Classes / Interfaces should have @ since tags |
||
|
|
||
| /** | ||
| * The name of the attribute whose value will serve as the base for TTL computation. | ||
| * This can be a temporal type (e.g., {@link java.time.Instant}, {@link java.time.LocalDateTime}) | ||
| * or a {@link Long} representing epoch seconds. | ||
| * | ||
| * @return the attribute name to use as the base timestamp for TTL | ||
| */ | ||
| String baseField() default ""; | ||
|
|
||
| /** | ||
| * The amount of time to add to the {@code baseField} when computing the TTL value. | ||
| * The resulting time will be converted to epoch seconds. | ||
| * | ||
| * @return the time offset to apply to the base field | ||
| */ | ||
| long duration() default 0; | ||
|
|
||
| /** | ||
| * The time unit associated with the {@code duration}. Defaults to {@link ChronoUnit#SECONDS}. | ||
| * | ||
| * @return the time unit to use with the duration | ||
| */ | ||
| ChronoUnit unit() default ChronoUnit.SECONDS; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
consider adding javadoc, also applies to UpdateTimeToLiveEnhancedResponse and to Async table
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
javadoc added