Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Expand Up @@ -23,6 +23,7 @@
import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedRequest;
import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedResponse;
import software.amazon.awssdk.enhanced.dynamodb.model.DescribeTableEnhancedResponse;
import software.amazon.awssdk.enhanced.dynamodb.model.DescribeTimeToLiveEnhancedResponse;
import software.amazon.awssdk.enhanced.dynamodb.model.GetItemEnhancedRequest;
import software.amazon.awssdk.enhanced.dynamodb.model.GetItemEnhancedResponse;
import software.amazon.awssdk.enhanced.dynamodb.model.Page;
Expand All @@ -34,6 +35,7 @@
import software.amazon.awssdk.enhanced.dynamodb.model.ScanEnhancedRequest;
import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedRequest;
import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedResponse;
import software.amazon.awssdk.enhanced.dynamodb.model.UpdateTimeToLiveEnhancedResponse;
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
import software.amazon.awssdk.services.dynamodb.model.ConsumedCapacity;
import software.amazon.awssdk.services.dynamodb.model.DescribeTableRequest;
Expand Down Expand Up @@ -947,4 +949,12 @@ default CompletableFuture<Void> deleteTable() {
default CompletableFuture<DescribeTableEnhancedResponse> describeTable() {
throw new UnsupportedOperationException();
}

default CompletableFuture<DescribeTimeToLiveEnhancedResponse> describeTimeToLive() {
throw new UnsupportedOperationException();
}

default CompletableFuture<UpdateTimeToLiveEnhancedResponse> updateTimeToLive(boolean enabled) {
throw new UnsupportedOperationException();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedRequest;
import software.amazon.awssdk.enhanced.dynamodb.model.DeleteItemEnhancedResponse;
import software.amazon.awssdk.enhanced.dynamodb.model.DescribeTableEnhancedResponse;
import software.amazon.awssdk.enhanced.dynamodb.model.DescribeTimeToLiveEnhancedResponse;
import software.amazon.awssdk.enhanced.dynamodb.model.GetItemEnhancedRequest;
import software.amazon.awssdk.enhanced.dynamodb.model.GetItemEnhancedResponse;
import software.amazon.awssdk.enhanced.dynamodb.model.Page;
Expand All @@ -34,6 +35,7 @@
import software.amazon.awssdk.enhanced.dynamodb.model.ScanEnhancedRequest;
import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedRequest;
import software.amazon.awssdk.enhanced.dynamodb.model.UpdateItemEnhancedResponse;
import software.amazon.awssdk.enhanced.dynamodb.model.UpdateTimeToLiveEnhancedResponse;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.ConsumedCapacity;
import software.amazon.awssdk.services.dynamodb.model.DescribeTableRequest;
Expand Down Expand Up @@ -926,4 +928,46 @@ default void deleteTable() {
default DescribeTableEnhancedResponse describeTable() {
throw new UnsupportedOperationException();
}

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

Copy link
Contributor

Choose a reason for hiding this comment

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

javadoc added

/**
* Describes the time to live (TTL) configuration of the table with the name defined for this
* {@link DynamoDbTable}.
* <p>
* This operation calls the low-level DynamoDB API {@code DescribeTimeToLive} operation.
* <p>
* Example:
* <pre>
* {@code
*
* DescribeTimeToLiveEnhancedResponse response = mappedTable.describeTimeToLive();
* }
* </pre>
*
* @return The TTL description returned by DynamoDB.
*/
default DescribeTimeToLiveEnhancedResponse describeTimeToLive() {
throw new UnsupportedOperationException();
}

/**
* Updates the time to live (TTL) configuration of the table with the name defined for this
* {@link DynamoDbTable}.
* <p>
* This operation calls the low-level DynamoDB API {@code UpdateTimeToLive} operation and uses the
* TTL attribute configured in this table's schema metadata.
* <p>
* Example:
* <pre>
* {@code
*
* UpdateTimeToLiveEnhancedResponse response = mappedTable.updateTimeToLive(true);
* }
* </pre>
*
* @param enabled Whether TTL should be enabled or disabled for the table.
* @return The TTL specification returned by DynamoDB after the update request is accepted.
*/
default UpdateTimeToLiveEnhancedResponse updateTimeToLive(boolean enabled) {
throw new UnsupportedOperationException();
}
}
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";

Choose a reason for hiding this comment

The 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
2- This Key is under @SdkPublicApi class, because the class is @SdkPublicApi, everything public on it becomes part of the public API contract, but it is only used from UpdateTimeToLiveOperation which is an SdkInternalAPI for any backward compatibility issues going forward. Consider either making the field package-private (so UpdateTimeToLiveOperation in the same package can still access it), or moving the constant to an @SdkInternalApi shared utility class...

Copy link
Contributor

Choose a reason for hiding this comment

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

  1. Agree, but this is the pattern used for the other extensions.
  2. I would go with the last approach, and create an @SdkInternalApi shared utility class - and place there all *_ATTRIBUTE_METADATA_KEY for extensions. Would make sense? wouldn't make too much sense to create the class only for TimeToLive extension attributes... what do you think?


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.");
Copy link

Choose a reason for hiding this comment

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

I think we should add baseFieldName check as well, here.
What happens if User does @DynamoDbTimeToLiveAttribute(duration=30, unit=DAYS) — forgot baseField.

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

Choose a reason for hiding this comment

The 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:

  1. Midnight jump: LocalDate.now() defines what "today" means, and that changes at midnight. Items with the same LocalTime base value and duration get TTLs that differ by a day depending on which side of midnight the write lands:
  • Write at 11:59 PM March 10 -> LocalDate.now() = March 10-> TTL = March 11 00:05 (expires in ~6 minutes)
  • Write at 12:01 AM March 11 -> LocalDate.now() = March 11-> TTL = March 12 00:05 (expires in ~24 hours)
  1. JVM timezone dependency: LocalDate.now() uses ZoneId.systemDefault(), but the epoch conversion uses ZoneOffset.UTC. If the JVM runs in America/New_York, LocalDate.now() could return a different date than UTC, shifting the TTL by hours depending on where the server runs.

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,
ZonedDateTime, Long) are all deterministic and cover every real TTL use case. Users who need time-of-day-based TTL can compute the epoch seconds themselves with explicit control over the date and timezone.

Copy link

@valeriodelbello-amazon valeriodelbello-amazon Mar 11, 2026

Choose a reason for hiding this comment

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

I strongly suggest we only use UTC and only support Instant with a Clock parameter like in AutoGeneratedTimestampRecordExtension.

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;

Choose a reason for hiding this comment

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

Should we check this is not negative somewhere?

Copy link
Contributor

Choose a reason for hiding this comment

The 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

Choose a reason for hiding this comment

The 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 tracked

DynamoDB 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

Copy link
Contributor

Choose a reason for hiding this comment

The 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 addCustomMetadataObject is not consistent in terms of validation:

       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 {
Copy link

@ysunio ysunio Mar 12, 2026

Choose a reason for hiding this comment

The 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;
}
Loading