Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import org.elasticsearch.search.aggregations.bucket.histogram.DateHistogramInterval;
import org.elasticsearch.test.ESSingleNodeTestCase;

import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
Expand Down Expand Up @@ -216,7 +217,8 @@ private MetadataIndexTemplateService getMetadataIndexTemplateService() {
xContentRegistry(),
EmptySystemIndices.INSTANCE,
indexSettingProviders,
DataStreamGlobalRetentionSettings.create(ClusterSettings.createBuiltInClusterSettings())
DataStreamGlobalRetentionSettings.create(ClusterSettings.createBuiltInClusterSettings()),
Instant::now
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,8 @@ private static ProjectMetadata getProjectWithDataStreamWithSettings(
Template.builder().settings(componentTemplateSettings).build(),
null,
null,
null,
null,
null
);
builder.componentTemplates(Map.of("component_template_1", componentTemplate));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
setup:
- requires:
test_runner_features: capabilities
capabilities:
- method: PUT
path: /_component_template/{id}
capabilities: [ component_template_tracking_info ]
reason: "Templates have tracking info: modified_date and created_date"

---
"Test PUT setting created_date":
- do:
catch: bad_request
cluster.put_component_template:
name: test_tracking
body:
template:
settings:
number_of_shards: 1
created_date: "2025-07-04T12:50:48.415Z"
- match: { status: 400 }
- match: { error.reason: "Validation Failed: 1: Provided a pipeline property which is managed by the system: created_date.;" }

---
"Test PUT setting modified_date":
- do:
catch: bad_request
cluster.put_component_template:
name: test_tracking
body:
template:
settings:
number_of_shards: 1
modified_date: "2025-07-04T12:50:48.415Z"
- match: { status: 400 }
- match: { error.reason: "Validation Failed: 1: Provided a pipeline property which is managed by the system: modified_date.;" }

---
"Test update preserves created_date but updates modified_date":
- do:
cluster.put_component_template:
name: test_tracking
body:
template:
settings:
number_of_shards: 1
- match: { acknowledged: true }

- do:
cluster.get_component_template:
name: test_tracking
- set: { component_templates.0.component_template.created_date: first_created }
- set: { component_templates.0.component_template.modified_date: first_modified }
- match: { $first_created: "/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$/" }
- match: { $first_modified: "/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$/" }

- do:
cluster.put_component_template:
name: test_tracking
body:
template:
settings:
number_of_shards: 2

- do:
cluster.get_component_template:
name: test_tracking
- set: { component_templates.0.component_template.created_date: second_created }
- set: { component_templates.0.component_template.modified_date: second_modified }
- match: { $second_created: $first_created }
- gt: { $second_modified: $first_modified }

Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ static TransportVersion def(int id) {
public static final TransportVersion NODE_USAGE_STATS_FOR_THREAD_POOLS_IN_CLUSTER_INFO = def(9_121_0_00);
public static final TransportVersion ESQL_CATEGORIZE_OPTIONS = def(9_122_0_00);
public static final TransportVersion ML_INFERENCE_AZURE_AI_STUDIO_RERANK_ADDED = def(9_123_0_00);
public static final TransportVersion COMPONENT_TEMPLATE_TRACKING_INFO = def(9_124_0_00);

/*
* STOP! READ THIS FIRST! No, really,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,18 @@ public ActionRequestValidationException validate() {
if (componentTemplate == null) {
validationException = addValidationError("a component template is required", validationException);
}
if (componentTemplate.createdDateMillis().isPresent()) {
validationException = addValidationError(
"Provided a pipeline property which is managed by the system: created_date.",
validationException
);
}
if (componentTemplate.modifiedDateMillis().isPresent()) {
validationException = addValidationError(
"Provided a pipeline property which is managed by the system: modified_date.",
validationException
);
}
return validationException;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ public static ComponentTemplate normalizeComponentTemplate(
template,
componentTemplate.version(),
componentTemplate.metadata(),
componentTemplate.deprecated()
componentTemplate.deprecated(),
componentTemplate.createdDateMillis().orElse(null),
componentTemplate.modifiedDateMillis().orElse(null)
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,13 @@
import org.elasticsearch.xcontent.XContentParser;

import java.io.IOException;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;

/**
* A component template is a re-usable {@link Template} as well as metadata about the template. Each
Expand All @@ -34,23 +39,43 @@
* "foo" field. These component templates make up the individual pieces composing an index template.
*/
public class ComponentTemplate implements SimpleDiffable<ComponentTemplate>, ToXContentObject {

// always output millis even if instantSource returns millis == 0
private static final DateTimeFormatter ISO8601_WITH_MILLIS_FORMATTER = new DateTimeFormatterBuilder().appendInstant(3)
.toFormatter(Locale.ROOT);

private static final ParseField TEMPLATE = new ParseField("template");
private static final ParseField VERSION = new ParseField("version");
private static final ParseField METADATA = new ParseField("_meta");
private static final ParseField DEPRECATED = new ParseField("deprecated");
private static final ParseField CREATED_DATE = new ParseField("created_date");
private static final ParseField MODIFIED_DATE = new ParseField("modified_date");

@SuppressWarnings("unchecked")
public static final ConstructingObjectParser<ComponentTemplate, Void> PARSER = new ConstructingObjectParser<>(
"component_template",
false,
a -> new ComponentTemplate((Template) a[0], (Long) a[1], (Map<String, Object>) a[2], (Boolean) a[3])
a -> {
final String createdDate = (String) a[4];
final String modifiedDate = (String) a[5];
return new ComponentTemplate(
(Template) a[0],
(Long) a[1],
(Map<String, Object>) a[2],
(Boolean) a[3],
createdDate == null ? null : Instant.parse(createdDate).toEpochMilli(),
modifiedDate == null ? null : Instant.parse(modifiedDate).toEpochMilli()
);
}
);

static {
PARSER.declareObject(ConstructingObjectParser.constructorArg(), Template.PARSER, TEMPLATE);
PARSER.declareLong(ConstructingObjectParser.optionalConstructorArg(), VERSION);
PARSER.declareObject(ConstructingObjectParser.optionalConstructorArg(), (p, c) -> p.map(), METADATA);
PARSER.declareBoolean(ConstructingObjectParser.optionalConstructorArg(), DEPRECATED);
PARSER.declareString(ConstructingObjectParser.optionalConstructorArg(), CREATED_DATE);
PARSER.declareString(ConstructingObjectParser.optionalConstructorArg(), MODIFIED_DATE);
}

private final Template template;
Expand All @@ -60,6 +85,10 @@ public class ComponentTemplate implements SimpleDiffable<ComponentTemplate>, ToX
private final Map<String, Object> metadata;
@Nullable
private final Boolean deprecated;
@Nullable
private final Long createdDateMillis;
@Nullable
private final Long modifiedDateMillis;

static Diff<ComponentTemplate> readComponentTemplateDiffFrom(StreamInput in) throws IOException {
return SimpleDiffable.readDiffFrom(ComponentTemplate::new, in);
Expand All @@ -70,19 +99,23 @@ public static ComponentTemplate parse(XContentParser parser) {
}

public ComponentTemplate(Template template, @Nullable Long version, @Nullable Map<String, Object> metadata) {
this(template, version, metadata, null);
this(template, version, metadata, null, null, null);
}

public ComponentTemplate(
Template template,
@Nullable Long version,
@Nullable Map<String, Object> metadata,
@Nullable Boolean deprecated
@Nullable Boolean deprecated,
@Nullable Long createdDateMillis,
@Nullable Long modifiedDateMillis
) {
this.template = template;
this.version = version;
this.metadata = metadata;
this.deprecated = deprecated;
this.createdDateMillis = createdDateMillis;
this.modifiedDateMillis = modifiedDateMillis;
}

public ComponentTemplate(StreamInput in) throws IOException {
Expand All @@ -98,6 +131,13 @@ public ComponentTemplate(StreamInput in) throws IOException {
} else {
deprecated = null;
}
if (in.getTransportVersion().onOrAfter(TransportVersions.COMPONENT_TEMPLATE_TRACKING_INFO)) {
this.createdDateMillis = in.readOptionalLong();
this.modifiedDateMillis = in.readOptionalLong();
} else {
this.createdDateMillis = null;
this.modifiedDateMillis = null;
}
}

public Template template() {
Expand All @@ -122,6 +162,14 @@ public boolean isDeprecated() {
return Boolean.TRUE.equals(deprecated);
}

public Optional<Long> createdDateMillis() {
return Optional.ofNullable(createdDateMillis);
}

public Optional<Long> modifiedDateMillis() {
return Optional.ofNullable(modifiedDateMillis);
}

@Override
public void writeTo(StreamOutput out) throws IOException {
this.template.writeTo(out);
Expand All @@ -135,11 +183,15 @@ public void writeTo(StreamOutput out) throws IOException {
if (out.getTransportVersion().onOrAfter(TransportVersions.V_8_12_0)) {
out.writeOptionalBoolean(this.deprecated);
}
if (out.getTransportVersion().onOrAfter(TransportVersions.COMPONENT_TEMPLATE_TRACKING_INFO)) {
out.writeOptionalLong(this.createdDateMillis);
out.writeOptionalLong(this.modifiedDateMillis);
}
}

@Override
public int hashCode() {
return Objects.hash(template, version, metadata, deprecated);
return Objects.hash(template, version, metadata, deprecated, createdDateMillis, modifiedDateMillis);
}

@Override
Expand All @@ -154,7 +206,9 @@ public boolean equals(Object obj) {
return Objects.equals(template, other.template)
&& Objects.equals(version, other.version)
&& Objects.equals(metadata, other.metadata)
&& Objects.equals(deprecated, other.deprecated);
&& Objects.equals(deprecated, other.deprecated)
&& Objects.equals(createdDateMillis, other.createdDateMillis)
&& Objects.equals(modifiedDateMillis, other.modifiedDateMillis);
}

@Override
Expand Down Expand Up @@ -184,7 +238,17 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params, @Nulla
if (this.deprecated != null) {
builder.field(DEPRECATED.getPreferredName(), this.deprecated);
}
if (this.createdDateMillis != null) {
builder.field(CREATED_DATE.getPreferredName(), formatDate(this.createdDateMillis));
}
if (this.modifiedDateMillis != null) {
builder.field(MODIFIED_DATE.getPreferredName(), formatDate(this.modifiedDateMillis));
}
builder.endObject();
return builder;
}

private static String formatDate(long epochMillis) {
return ISO8601_WITH_MILLIS_FORMATTER.format(Instant.ofEpochMilli(epochMillis));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
import org.elasticsearch.xcontent.NamedXContentRegistry;

import java.io.IOException;
import java.time.Instant;
import java.time.InstantSource;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
Expand Down Expand Up @@ -140,6 +140,7 @@ public class MetadataIndexTemplateService {
private final SystemIndices systemIndices;
private final Set<IndexSettingProvider> indexSettingProviders;
private final DataStreamGlobalRetentionSettings globalRetentionSettings;
private final InstantSource instantSource;

/**
* This is the cluster state task executor for all template-based actions.
Expand Down Expand Up @@ -193,7 +194,8 @@ public MetadataIndexTemplateService(
NamedXContentRegistry xContentRegistry,
SystemIndices systemIndices,
IndexSettingProviders indexSettingProviders,
DataStreamGlobalRetentionSettings globalRetentionSettings
DataStreamGlobalRetentionSettings globalRetentionSettings,
InstantSource instantSource
) {
this.clusterService = clusterService;
this.taskQueue = clusterService.createTaskQueue("index-templates", Priority.URGENT, TEMPLATE_TASK_EXECUTOR);
Expand All @@ -204,6 +206,7 @@ public MetadataIndexTemplateService(
this.systemIndices = systemIndices;
this.indexSettingProviders = indexSettingProviders.getIndexSettingProviders();
this.globalRetentionSettings = globalRetentionSettings;
this.instantSource = instantSource;
}

public void removeTemplates(
Expand Down Expand Up @@ -323,15 +326,37 @@ public ProjectMetadata addComponentTemplate(
}

final Template finalTemplate = Template.builder(template.template()).settings(finalSettings).mappings(wrappedMappings).build();
final ComponentTemplate finalComponentTemplate = new ComponentTemplate(
finalTemplate,
template.version(),
template.metadata(),
template.deprecated()
);

if (finalComponentTemplate.equals(existing)) {
return project;
final long now = instantSource.instant().toEpochMilli();
final ComponentTemplate finalComponentTemplate;
if (existing == null) {
finalComponentTemplate = new ComponentTemplate(
finalTemplate,
template.version(),
template.metadata(),
template.deprecated(),
now,
now
);
} else {
final ComponentTemplate templateToCompareToExisting = new ComponentTemplate(
finalTemplate,
template.version(),
template.metadata(),
template.deprecated(),
existing.createdDateMillis().orElse(null),
existing.modifiedDateMillis().orElse(null)
);
if (templateToCompareToExisting.equals(existing)) {
return project;
}
finalComponentTemplate = new ComponentTemplate(
finalTemplate,
template.version(),
template.metadata(),
template.deprecated(),
existing.createdDateMillis().orElse(null),
now
);
}

validateTemplate(finalSettings, wrappedMappings, indicesService);
Expand Down Expand Up @@ -715,7 +740,7 @@ void validateIndexTemplateV2(ProjectMetadata projectMetadata, String name, Compo
// Workaround for the fact that start_time and end_time are injected by the MetadataCreateDataStreamService upon creation,
// but when validating templates that create data streams the MetadataCreateDataStreamService isn't used.
var finalTemplate = indexTemplate.template();
final var now = Instant.now();
final var now = instantSource.instant();

final var combinedMappings = collectMappings(indexTemplate, projectMetadata.componentTemplates(), "tmp_idx");
final var combinedSettings = resolveSettings(indexTemplate, projectMetadata.componentTemplates());
Expand Down
Loading