Skip to content
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

Add support for collection expiration to @TimeSeries. #4114

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
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
Expand Up @@ -15,6 +15,7 @@
*/
package org.springframework.data.mongodb.core;

import java.time.Duration;
import java.util.Optional;

import org.springframework.data.mongodb.core.mapping.Field;
Expand All @@ -37,6 +38,7 @@
* @author Christoph Strobl
* @author Mark Paluch
* @author Andreas Zink
* @author Ben Foster
*/
public class CollectionOptions {

Expand Down Expand Up @@ -444,13 +446,15 @@ public static class TimeSeriesOptions {

private final GranularityDefinition granularity;

private TimeSeriesOptions(String timeField, @Nullable String metaField, GranularityDefinition granularity) {
private final long expireAfterSeconds;

private TimeSeriesOptions(String timeField, @Nullable String metaField, GranularityDefinition granularity, long expireAfterSeconds) {
Assert.hasText(timeField, "Time field must not be empty or null");

this.timeField = timeField;
this.metaField = metaField;
this.granularity = granularity;
this.expireAfterSeconds = expireAfterSeconds;
}

/**
Expand All @@ -462,7 +466,7 @@ private TimeSeriesOptions(String timeField, @Nullable String metaField, Granular
* @return new instance of {@link TimeSeriesOptions}.
*/
public static TimeSeriesOptions timeSeries(String timeField) {
return new TimeSeriesOptions(timeField, null, Granularity.DEFAULT);
return new TimeSeriesOptions(timeField, null, Granularity.DEFAULT, -1);
}

/**
Expand All @@ -475,7 +479,7 @@ public static TimeSeriesOptions timeSeries(String timeField) {
* @return new instance of {@link TimeSeriesOptions}.
*/
public TimeSeriesOptions metaField(String metaField) {
return new TimeSeriesOptions(timeField, metaField, granularity);
return new TimeSeriesOptions(timeField, metaField, granularity, expireAfterSeconds);
}

/**
Expand All @@ -486,7 +490,17 @@ public TimeSeriesOptions metaField(String metaField) {
* @see Granularity
*/
public TimeSeriesOptions granularity(GranularityDefinition granularity) {
return new TimeSeriesOptions(timeField, metaField, granularity);
return new TimeSeriesOptions(timeField, metaField, granularity, expireAfterSeconds);
}

/**
* Select the expire parameter to define automatic removal of documents older than a specified
* duration.
*
* @return new instance of {@link TimeSeriesOptions}.
*/
public TimeSeriesOptions expireAfter(Duration timeout) {
return new TimeSeriesOptions(timeField, metaField, granularity, timeout.getSeconds());
}

/**
Expand All @@ -511,5 +525,12 @@ public String getMetaField() {
public GranularityDefinition getGranularity() {
return granularity;
}

/**
* @return {@literal -1} if not specified
*/
public long getExpireAfterSeconds() {
return expireAfterSeconds;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@
*/
package org.springframework.data.mongodb.core;

import java.time.Duration;
import java.util.Collection;
import java.util.Iterator;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;

import org.bson.Document;

Expand All @@ -36,10 +38,8 @@
import org.springframework.data.mongodb.core.convert.MongoJsonSchemaMapper;
import org.springframework.data.mongodb.core.convert.MongoWriter;
import org.springframework.data.mongodb.core.convert.QueryMapper;
import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity;
import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty;
import org.springframework.data.mongodb.core.mapping.MongoSimpleTypes;
import org.springframework.data.mongodb.core.mapping.TimeSeries;
import org.springframework.data.mongodb.core.index.DurationStyle;
import org.springframework.data.mongodb.core.mapping.*;
import org.springframework.data.mongodb.core.query.Collation;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
Expand All @@ -48,7 +48,13 @@
import org.springframework.data.projection.EntityProjection;
import org.springframework.data.projection.EntityProjectionIntrospector;
import org.springframework.data.projection.ProjectionFactory;
import org.springframework.data.spel.EvaluationContextProvider;
import org.springframework.data.util.Optionals;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.ParserContext;
import org.springframework.expression.common.LiteralExpression;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
Expand All @@ -67,6 +73,7 @@
* @author Oliver Gierke
* @author Mark Paluch
* @author Christoph Strobl
* @author Ben Foster
* @since 2.1
* @see MongoTemplate
* @see ReactiveMongoTemplate
Expand All @@ -82,6 +89,8 @@ class EntityOperations {

private final MongoJsonSchemaMapper schemaMapper;

private EvaluationContextProvider evaluationContextProvider = EvaluationContextProvider.DEFAULT;

EntityOperations(MongoConverter converter) {
this(converter, new QueryMapper(converter));
}
Expand Down Expand Up @@ -258,7 +267,7 @@ public <T> TypedOperations<T> forType(@Nullable Class<T> entityClass) {
MongoPersistentEntity<?> entity = context.getPersistentEntity(entityClass);

if (entity != null) {
return new TypedEntityOperations(entity);
return new TypedEntityOperations(entity, evaluationContextProvider);
}

}
Expand Down Expand Up @@ -331,6 +340,10 @@ public CreateCollectionOptions convertToCreateCollectionOptions(@Nullable Collec
options.granularity(TimeSeriesGranularity.valueOf(it.getGranularity().name().toUpperCase()));
}

if (it.getExpireAfterSeconds() >= 0) {
result.expireAfter(it.getExpireAfterSeconds(), TimeUnit.SECONDS);
}

result.timeSeriesOptions(options);
});

Expand Down Expand Up @@ -865,10 +878,13 @@ public TimeSeriesOptions mapTimeSeriesOptions(TimeSeriesOptions options) {
*/
static class TypedEntityOperations<T> implements TypedOperations<T> {

private static final SpelExpressionParser PARSER = new SpelExpressionParser();
private final MongoPersistentEntity<T> entity;
private final EvaluationContextProvider evaluationContextProvider;

protected TypedEntityOperations(MongoPersistentEntity<T> entity) {
protected TypedEntityOperations(MongoPersistentEntity<T> entity, EvaluationContextProvider evaluationContextProvider) {
this.entity = entity;
this.evaluationContextProvider = evaluationContextProvider;
}

@Override
Expand Down Expand Up @@ -916,6 +932,26 @@ public CollectionOptions getCollectionOptions() {
if (!Granularity.DEFAULT.equals(timeSeries.granularity())) {
options = options.granularity(timeSeries.granularity());
}

if (timeSeries.expireAfterSeconds() >= 0) {
options = options.expireAfter(Duration.ofSeconds(timeSeries.expireAfterSeconds()));
}

if (StringUtils.hasText(timeSeries.expireAfter())) {

if (timeSeries.expireAfterSeconds() >= 0) {
throw new IllegalStateException(String.format(
"@TimeSeries already defines an expiration timeout of %s seconds via TimeSeries#expireAfterSeconds; Please make to use either expireAfterSeconds or expireAfter",
timeSeries.expireAfterSeconds()));
}

Duration timeout = computeIndexTimeout(timeSeries.expireAfter(),
getEvaluationContextForProperty(entity));
if (!timeout.isZero() && !timeout.isNegative()) {
options = options.expireAfter(timeout);
}
}

collectionOptions = collectionOptions.timeSeries(options);
}

Expand All @@ -930,13 +966,99 @@ public TimeSeriesOptions mapTimeSeriesOptions(TimeSeriesOptions source) {
if (StringUtils.hasText(source.getMetaField())) {
target = target.metaField(mappedNameOrDefault(source.getMetaField()));
}
return target.granularity(source.getGranularity());
return target.granularity(source.getGranularity())
.expireAfter(Duration.ofSeconds(source.getExpireAfterSeconds()));
}

private String mappedNameOrDefault(String name) {
MongoPersistentProperty persistentProperty = entity.getPersistentProperty(name);
return persistentProperty != null ? persistentProperty.getFieldName() : name;
}


/**
* Compute the index timeout value by evaluating a potential
* {@link org.springframework.expression.spel.standard.SpelExpression} and parsing the final value.
*
* @param timeoutValue must not be {@literal null}.
* @param evaluationContext must not be {@literal null}.
* @return never {@literal null}
* @since 2.2
* @throws IllegalArgumentException for invalid duration values.
*/
private static Duration computeIndexTimeout(String timeoutValue, EvaluationContext evaluationContext) {

Object evaluatedTimeout = evaluate(timeoutValue, evaluationContext);

if (evaluatedTimeout == null) {
return Duration.ZERO;
}

if (evaluatedTimeout instanceof Duration) {
return (Duration) evaluatedTimeout;
}

String val = evaluatedTimeout.toString();

if (val == null) {
return Duration.ZERO;
}

return DurationStyle.detectAndParse(val);
}

@Nullable
private static Object evaluate(String value, EvaluationContext evaluationContext) {

Expression expression = PARSER.parseExpression(value, ParserContext.TEMPLATE_EXPRESSION);
if (expression instanceof LiteralExpression) {
return value;
}

return expression.getValue(evaluationContext, Object.class);
}


/**
* Get the {@link EvaluationContext} for a given {@link PersistentEntity entity} the default one.
*
* @param persistentEntity can be {@literal null}
* @return
*/
private EvaluationContext getEvaluationContextForProperty(@Nullable PersistentEntity<?, ?> persistentEntity) {

if (!(persistentEntity instanceof BasicMongoPersistentEntity)) {
return getEvaluationContext();
}

EvaluationContext contextFromEntity = ((BasicMongoPersistentEntity<?>) persistentEntity).getEvaluationContext(null);

if (!EvaluationContextProvider.DEFAULT.equals(contextFromEntity)) {
return contextFromEntity;
}

return getEvaluationContext();
}

/**
* Get the default {@link EvaluationContext}.
*
* @return never {@literal null}.
* @since 2.2
*/
protected EvaluationContext getEvaluationContext() {
return evaluationContextProvider.getEvaluationContext(null);
}
}

/**
* Set the {@link EvaluationContextProvider} used for obtaining the {@link EvaluationContext} used to compute
* {@link org.springframework.expression.spel.standard.SpelExpression expressions}.
*
* @param evaluationContextProvider must not be {@literal null}.
* @since 2.2
*/
public void setEvaluationContextProvider(EvaluationContextProvider evaluationContextProvider) {
this.evaluationContextProvider = evaluationContextProvider;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
* @author Phillip Webb
* @since 2.2
*/
enum DurationStyle {
public enum DurationStyle {

/**
* Simple formatting, for example '1s'.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
* Identifies a domain object to be persisted to a MongoDB Time Series collection.
*
* @author Christoph Strobl
* @author Ben Foster
* @since 3.3
* @see <a href="https://docs.mongodb.com/manual/core/timeseries-collections">https://docs.mongodb.com/manual/core/timeseries-collections</a>
*/
Expand Down Expand Up @@ -83,4 +84,42 @@
@AliasFor(annotation = Document.class, attribute = "collation")
String collation() default "";

/**
* Configures the number of seconds after which the document should expire. Defaults to -1 for no expiry.
*
* @return {@literal -1} by default.
* @see <a href=
* "https://www.mongodb.com/docs/manual/core/timeseries/timeseries-automatic-removal/#set-up-automatic-removal-for-time-series-collections--ttl-</a>
*/
int expireAfterSeconds() default -1;


/**
* Alternative for {@link #expireAfterSeconds()} to configure the timeout after which the document should expire.
* Defaults to an empty {@link String} for no expiry. Accepts numeric values followed by their unit of measure:
* <ul>
* <li><b>d</b>: Days</li>
* <li><b>h</b>: Hours</li>
* <li><b>m</b>: Minutes</li>
* <li><b>s</b>: Seconds</li>
* <li>Alternatively: A Spring {@literal template expression}. The expression can result in a
* {@link java.time.Duration} or a valid expiration {@link String} according to the already mentioned
* conventions.</li>
* </ul>
* Supports ISO-8601 style.
*
* <pre class="code">
*
* &#0064;Indexed(expireAfter = "10s") String expireAfterTenSeconds;
*
* &#0064;Indexed(expireAfter = "1d") String expireAfterOneDay;
*
* &#0064;Indexed(expireAfter = "P2D") String expireAfterTwoDays;
*
* &#0064;Indexed(expireAfter = "#{&#0064;mySpringBean.timeout}") String expireAfterTimeoutObtainedFromSpringBean;
* </pre>
*
* @return empty by default.
*/
String expireAfter() default "";
}
Loading