From 54c9db9a413ae6f3070023892d3bf02ef9b591c8 Mon Sep 17 00:00:00 2001 From: Pooya Salehi Date: Wed, 2 Jul 2025 20:08:56 +0200 Subject: [PATCH] Record project deletions in ProjectStateRegistry (#130225) Project deletions is used to update the Stateless lease blob, as a project deletion is a notable event that that our stateless cluster consistency check should consider before acknowledging writes. Relates ES-11207 --- .../org/elasticsearch/TransportVersions.java | 1 + .../cluster/project/ProjectStateRegistry.java | 87 +++++++++++++++++- .../cluster/ClusterStateTests.java | 9 +- ...rojectStateRegistrySerializationTests.java | 89 +++++++++++++++++++ .../project/ProjectStateRegistryTests.java | 47 ++++++++++ 5 files changed, 226 insertions(+), 7 deletions(-) create mode 100644 server/src/test/java/org/elasticsearch/cluster/project/ProjectStateRegistrySerializationTests.java create mode 100644 server/src/test/java/org/elasticsearch/cluster/project/ProjectStateRegistryTests.java diff --git a/server/src/main/java/org/elasticsearch/TransportVersions.java b/server/src/main/java/org/elasticsearch/TransportVersions.java index c256c08150e9..2be70a4ea591 100644 --- a/server/src/main/java/org/elasticsearch/TransportVersions.java +++ b/server/src/main/java/org/elasticsearch/TransportVersions.java @@ -326,6 +326,7 @@ public class TransportVersions { public static final TransportVersion ML_INFERENCE_COHERE_API_VERSION = def(9_110_0_00); public static final TransportVersion ESQL_PROFILE_INCLUDE_PLAN = def(9_111_0_00); public static final TransportVersion MAPPINGS_IN_DATA_STREAMS = def(9_112_0_00); + public static final TransportVersion PROJECT_STATE_REGISTRY_RECORDS_DELETIONS = def(9_113_0_00); public static final TransportVersion ESQL_SERIALIZE_TIMESERIES_FIELD_TYPE = def(9_113_0_00); /* diff --git a/server/src/main/java/org/elasticsearch/cluster/project/ProjectStateRegistry.java b/server/src/main/java/org/elasticsearch/cluster/project/ProjectStateRegistry.java index 1ae81b8daa0e..5009cd668866 100644 --- a/server/src/main/java/org/elasticsearch/cluster/project/ProjectStateRegistry.java +++ b/server/src/main/java/org/elasticsearch/cluster/project/ProjectStateRegistry.java @@ -20,28 +20,49 @@ import org.elasticsearch.common.collect.Iterators; import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.io.stream.StreamOutput; import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.set.Sets; import org.elasticsearch.xcontent.ToXContent; import java.io.IOException; import java.util.Collections; +import java.util.HashSet; import java.util.Iterator; import java.util.Map; +import java.util.Objects; +import java.util.Set; /** * Represents a registry for managing and retrieving project-specific state in the cluster state. */ public class ProjectStateRegistry extends AbstractNamedDiffable implements ClusterState.Custom { public static final String TYPE = "projects_registry"; - public static final ProjectStateRegistry EMPTY = new ProjectStateRegistry(Collections.emptyMap()); + public static final ProjectStateRegistry EMPTY = new ProjectStateRegistry(Collections.emptyMap(), Collections.emptySet(), 0); private final Map projectsSettings; + // Projects that have been marked for deletion based on their file-based setting + private final Set projectsMarkedForDeletion; + // A counter that is incremented each time one or more projects are marked for deletion. + private final long projectsMarkedForDeletionGeneration; public ProjectStateRegistry(StreamInput in) throws IOException { projectsSettings = in.readMap(ProjectId::readFrom, Settings::readSettingsFromStream); + if (in.getTransportVersion().onOrAfter(TransportVersions.PROJECT_STATE_REGISTRY_RECORDS_DELETIONS)) { + projectsMarkedForDeletion = in.readCollectionAsImmutableSet(ProjectId::readFrom); + projectsMarkedForDeletionGeneration = in.readVLong(); + } else { + projectsMarkedForDeletion = Collections.emptySet(); + projectsMarkedForDeletionGeneration = 0; + } } - private ProjectStateRegistry(Map projectsSettings) { + private ProjectStateRegistry( + Map projectsSettings, + Set projectsMarkedForDeletion, + long projectsMarkedForDeletionGeneration + ) { this.projectsSettings = projectsSettings; + this.projectsMarkedForDeletion = projectsMarkedForDeletion; + this.projectsMarkedForDeletionGeneration = projectsMarkedForDeletionGeneration; } /** @@ -72,9 +93,11 @@ public class ProjectStateRegistry extends AbstractNamedDiffable builder.endArray()) + Iterators.single((builder, p) -> builder.endArray()), + Iterators.single((builder, p) -> builder.field("projects_marked_for_deletion_generation", projectsMarkedForDeletionGeneration)) ); } @@ -95,12 +118,44 @@ public class ProjectStateRegistry extends AbstractNamedDiffable getProjectsSettings() { + return Collections.unmodifiableMap(projectsSettings); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o instanceof ProjectStateRegistry == false) return false; + ProjectStateRegistry that = (ProjectStateRegistry) o; + return projectsMarkedForDeletionGeneration == that.projectsMarkedForDeletionGeneration + && Objects.equals(projectsSettings, that.projectsSettings) + && Objects.equals(projectsMarkedForDeletion, that.projectsMarkedForDeletion); + } + + @Override + public int hashCode() { + return Objects.hash(projectsSettings, projectsMarkedForDeletion, projectsMarkedForDeletionGeneration); + } + public static Builder builder(ClusterState original) { ProjectStateRegistry projectRegistry = original.custom(TYPE, EMPTY); return builder(projectRegistry); @@ -116,13 +171,20 @@ public class ProjectStateRegistry extends AbstractNamedDiffable projectsSettings; + private final Set projectsMarkedForDeletion; + private final long projectsMarkedForDeletionGeneration; + private boolean newProjectMarkedForDeletion = false; private Builder() { this.projectsSettings = ImmutableOpenMap.builder(); + projectsMarkedForDeletion = new HashSet<>(); + projectsMarkedForDeletionGeneration = 0; } private Builder(ProjectStateRegistry original) { this.projectsSettings = ImmutableOpenMap.builder(original.projectsSettings); + this.projectsMarkedForDeletion = new HashSet<>(original.projectsMarkedForDeletion); + this.projectsMarkedForDeletionGeneration = original.projectsMarkedForDeletionGeneration; } public Builder putProjectSettings(ProjectId projectId, Settings settings) { @@ -130,8 +192,25 @@ public class ProjectStateRegistry extends AbstractNamedDiffable { + + @Override + protected ClusterState.Custom makeTestChanges(ClusterState.Custom testInstance) { + return mutate((ProjectStateRegistry) testInstance); + } + + @Override + protected Writeable.Reader> diffReader() { + return ProjectStateRegistry::readDiffFrom; + } + + @Override + protected NamedWriteableRegistry getNamedWriteableRegistry() { + return new NamedWriteableRegistry(ClusterModule.getNamedWriteables()); + } + + @Override + protected Writeable.Reader instanceReader() { + return ProjectStateRegistry::new; + } + + @Override + protected ClusterState.Custom createTestInstance() { + return randomProjectStateRegistry(); + } + + @Override + protected ClusterState.Custom mutateInstance(ClusterState.Custom instance) throws IOException { + return mutate((ProjectStateRegistry) instance); + } + + private ProjectStateRegistry mutate(ProjectStateRegistry instance) { + if (randomBoolean() && instance.size() > 0) { + // Remove or mutate a project's settings or deletion flag + var projectId = randomFrom(instance.getProjectsSettings().keySet()); + var builder = ProjectStateRegistry.builder(instance); + builder.putProjectSettings(projectId, randomSettings()); + if (randomBoolean()) { + // mark for deletion + builder.markProjectForDeletion(projectId); + } + return builder.build(); + } else { + // add a new project + return ProjectStateRegistry.builder(instance).putProjectSettings(randomUniqueProjectId(), randomSettings()).build(); + } + } + + private static ProjectStateRegistry randomProjectStateRegistry() { + final var projects = randomSet(1, 5, ESTestCase::randomUniqueProjectId); + final var projectsUnderDeletion = randomSet(0, 5, ESTestCase::randomUniqueProjectId); + var builder = ProjectStateRegistry.builder(); + projects.forEach(projectId -> builder.putProjectSettings(projectId, randomSettings())); + projectsUnderDeletion.forEach( + projectId -> builder.putProjectSettings(projectId, randomSettings()).markProjectForDeletion(projectId) + ); + return builder.build(); + } + + public static Settings randomSettings() { + var builder = Settings.builder(); + IntStream.range(0, randomIntBetween(1, 5)).forEach(i -> builder.put(randomIdentifier(), randomIdentifier())); + return builder.build(); + } +} diff --git a/server/src/test/java/org/elasticsearch/cluster/project/ProjectStateRegistryTests.java b/server/src/test/java/org/elasticsearch/cluster/project/ProjectStateRegistryTests.java new file mode 100644 index 000000000000..a4d4dd6f2b15 --- /dev/null +++ b/server/src/test/java/org/elasticsearch/cluster/project/ProjectStateRegistryTests.java @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.cluster.project; + +import org.elasticsearch.test.ESTestCase; +import org.hamcrest.Matchers; + +import static org.elasticsearch.cluster.project.ProjectStateRegistrySerializationTests.randomSettings; + +public class ProjectStateRegistryTests extends ESTestCase { + + public void testBuilder() { + final var projects = randomSet(1, 5, ESTestCase::randomUniqueProjectId); + final var projectsUnderDeletion = randomSet(0, 5, ESTestCase::randomUniqueProjectId); + var builder = ProjectStateRegistry.builder(); + projects.forEach(projectId -> builder.putProjectSettings(projectId, randomSettings())); + projectsUnderDeletion.forEach( + projectId -> builder.putProjectSettings(projectId, randomSettings()).markProjectForDeletion(projectId) + ); + var projectStateRegistry = builder.build(); + var gen1 = projectStateRegistry.getProjectsMarkedForDeletionGeneration(); + assertThat(gen1, Matchers.equalTo(projectsUnderDeletion.isEmpty() ? 0L : 1L)); + + projectStateRegistry = ProjectStateRegistry.builder(projectStateRegistry).markProjectForDeletion(randomFrom(projects)).build(); + var gen2 = projectStateRegistry.getProjectsMarkedForDeletionGeneration(); + assertThat(gen2, Matchers.equalTo(gen1 + 1)); + + if (projectsUnderDeletion.isEmpty() == false) { + // re-adding the same projectId should not change the generation + projectStateRegistry = ProjectStateRegistry.builder(projectStateRegistry) + .markProjectForDeletion(randomFrom(projectsUnderDeletion)) + .build(); + assertThat(projectStateRegistry.getProjectsMarkedForDeletionGeneration(), Matchers.equalTo(gen2)); + } + + var unknownProjectId = randomUniqueProjectId(); + var throwingBuilder = ProjectStateRegistry.builder(projectStateRegistry).markProjectForDeletion(unknownProjectId); + assertThrows(IllegalArgumentException.class, throwingBuilder::build); + } +}