Skip to content

Commit 54c9db9

Browse files
authored
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
1 parent 89f701f commit 54c9db9

File tree

5 files changed

+226
-7
lines changed

5 files changed

+226
-7
lines changed

server/src/main/java/org/elasticsearch/TransportVersions.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,7 @@ static TransportVersion def(int id) {
326326
public static final TransportVersion ML_INFERENCE_COHERE_API_VERSION = def(9_110_0_00);
327327
public static final TransportVersion ESQL_PROFILE_INCLUDE_PLAN = def(9_111_0_00);
328328
public static final TransportVersion MAPPINGS_IN_DATA_STREAMS = def(9_112_0_00);
329+
public static final TransportVersion PROJECT_STATE_REGISTRY_RECORDS_DELETIONS = def(9_113_0_00);
329330

330331
public static final TransportVersion ESQL_SERIALIZE_TIMESERIES_FIELD_TYPE = def(9_113_0_00);
331332
/*

server/src/main/java/org/elasticsearch/cluster/project/ProjectStateRegistry.java

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,28 +20,49 @@
2020
import org.elasticsearch.common.io.stream.StreamInput;
2121
import org.elasticsearch.common.io.stream.StreamOutput;
2222
import org.elasticsearch.common.settings.Settings;
23+
import org.elasticsearch.common.util.set.Sets;
2324
import org.elasticsearch.xcontent.ToXContent;
2425

2526
import java.io.IOException;
2627
import java.util.Collections;
28+
import java.util.HashSet;
2729
import java.util.Iterator;
2830
import java.util.Map;
31+
import java.util.Objects;
32+
import java.util.Set;
2933

3034
/**
3135
* Represents a registry for managing and retrieving project-specific state in the cluster state.
3236
*/
3337
public class ProjectStateRegistry extends AbstractNamedDiffable<ClusterState.Custom> implements ClusterState.Custom {
3438
public static final String TYPE = "projects_registry";
35-
public static final ProjectStateRegistry EMPTY = new ProjectStateRegistry(Collections.emptyMap());
39+
public static final ProjectStateRegistry EMPTY = new ProjectStateRegistry(Collections.emptyMap(), Collections.emptySet(), 0);
3640

3741
private final Map<ProjectId, Settings> projectsSettings;
42+
// Projects that have been marked for deletion based on their file-based setting
43+
private final Set<ProjectId> projectsMarkedForDeletion;
44+
// A counter that is incremented each time one or more projects are marked for deletion.
45+
private final long projectsMarkedForDeletionGeneration;
3846

3947
public ProjectStateRegistry(StreamInput in) throws IOException {
4048
projectsSettings = in.readMap(ProjectId::readFrom, Settings::readSettingsFromStream);
49+
if (in.getTransportVersion().onOrAfter(TransportVersions.PROJECT_STATE_REGISTRY_RECORDS_DELETIONS)) {
50+
projectsMarkedForDeletion = in.readCollectionAsImmutableSet(ProjectId::readFrom);
51+
projectsMarkedForDeletionGeneration = in.readVLong();
52+
} else {
53+
projectsMarkedForDeletion = Collections.emptySet();
54+
projectsMarkedForDeletionGeneration = 0;
55+
}
4156
}
4257

43-
private ProjectStateRegistry(Map<ProjectId, Settings> projectsSettings) {
58+
private ProjectStateRegistry(
59+
Map<ProjectId, Settings> projectsSettings,
60+
Set<ProjectId> projectsMarkedForDeletion,
61+
long projectsMarkedForDeletionGeneration
62+
) {
4463
this.projectsSettings = projectsSettings;
64+
this.projectsMarkedForDeletion = projectsMarkedForDeletion;
65+
this.projectsMarkedForDeletionGeneration = projectsMarkedForDeletionGeneration;
4566
}
4667

4768
/**
@@ -72,9 +93,11 @@ public Iterator<? extends ToXContent> toXContentChunked(ToXContent.Params params
7293
builder.startObject("settings");
7394
entry.getValue().toXContent(builder, new ToXContent.MapParams(Collections.singletonMap("flat_settings", "true")));
7495
builder.endObject();
96+
builder.field("marked_for_deletion", projectsMarkedForDeletion.contains(entry.getKey()));
7597
return builder.endObject();
7698
}),
77-
Iterators.single((builder, p) -> builder.endArray())
99+
Iterators.single((builder, p) -> builder.endArray()),
100+
Iterators.single((builder, p) -> builder.field("projects_marked_for_deletion_generation", projectsMarkedForDeletionGeneration))
78101
);
79102
}
80103

@@ -95,12 +118,44 @@ public TransportVersion getMinimalSupportedVersion() {
95118
@Override
96119
public void writeTo(StreamOutput out) throws IOException {
97120
out.writeMap(projectsSettings);
121+
if (out.getTransportVersion().onOrAfter(TransportVersions.PROJECT_STATE_REGISTRY_RECORDS_DELETIONS)) {
122+
out.writeCollection(projectsMarkedForDeletion);
123+
out.writeVLong(projectsMarkedForDeletionGeneration);
124+
} else {
125+
// There should be no deletion unless all MP nodes are at or after PROJECT_STATE_REGISTRY_RECORDS_DELETIONS
126+
assert projectsMarkedForDeletion.isEmpty();
127+
assert projectsMarkedForDeletionGeneration == 0;
128+
}
98129
}
99130

100131
public int size() {
101132
return projectsSettings.size();
102133
}
103134

135+
public long getProjectsMarkedForDeletionGeneration() {
136+
return projectsMarkedForDeletionGeneration;
137+
}
138+
139+
// visible for testing
140+
Map<ProjectId, Settings> getProjectsSettings() {
141+
return Collections.unmodifiableMap(projectsSettings);
142+
}
143+
144+
@Override
145+
public boolean equals(Object o) {
146+
if (this == o) return true;
147+
if (o instanceof ProjectStateRegistry == false) return false;
148+
ProjectStateRegistry that = (ProjectStateRegistry) o;
149+
return projectsMarkedForDeletionGeneration == that.projectsMarkedForDeletionGeneration
150+
&& Objects.equals(projectsSettings, that.projectsSettings)
151+
&& Objects.equals(projectsMarkedForDeletion, that.projectsMarkedForDeletion);
152+
}
153+
154+
@Override
155+
public int hashCode() {
156+
return Objects.hash(projectsSettings, projectsMarkedForDeletion, projectsMarkedForDeletionGeneration);
157+
}
158+
104159
public static Builder builder(ClusterState original) {
105160
ProjectStateRegistry projectRegistry = original.custom(TYPE, EMPTY);
106161
return builder(projectRegistry);
@@ -116,22 +171,46 @@ public static Builder builder() {
116171

117172
public static class Builder {
118173
private final ImmutableOpenMap.Builder<ProjectId, Settings> projectsSettings;
174+
private final Set<ProjectId> projectsMarkedForDeletion;
175+
private final long projectsMarkedForDeletionGeneration;
176+
private boolean newProjectMarkedForDeletion = false;
119177

120178
private Builder() {
121179
this.projectsSettings = ImmutableOpenMap.builder();
180+
projectsMarkedForDeletion = new HashSet<>();
181+
projectsMarkedForDeletionGeneration = 0;
122182
}
123183

124184
private Builder(ProjectStateRegistry original) {
125185
this.projectsSettings = ImmutableOpenMap.builder(original.projectsSettings);
186+
this.projectsMarkedForDeletion = new HashSet<>(original.projectsMarkedForDeletion);
187+
this.projectsMarkedForDeletionGeneration = original.projectsMarkedForDeletionGeneration;
126188
}
127189

128190
public Builder putProjectSettings(ProjectId projectId, Settings settings) {
129191
projectsSettings.put(projectId, settings);
130192
return this;
131193
}
132194

195+
public Builder markProjectForDeletion(ProjectId projectId) {
196+
if (projectsMarkedForDeletion.add(projectId)) {
197+
newProjectMarkedForDeletion = true;
198+
}
199+
return this;
200+
}
201+
133202
public ProjectStateRegistry build() {
134-
return new ProjectStateRegistry(projectsSettings.build());
203+
final var unknownButUnderDeletion = Sets.difference(projectsMarkedForDeletion, projectsSettings.keys());
204+
if (unknownButUnderDeletion.isEmpty() == false) {
205+
throw new IllegalArgumentException(
206+
"Cannot mark projects for deletion that are not in the registry: " + unknownButUnderDeletion
207+
);
208+
}
209+
return new ProjectStateRegistry(
210+
projectsSettings.build(),
211+
projectsMarkedForDeletion,
212+
newProjectMarkedForDeletion ? projectsMarkedForDeletionGeneration + 1 : projectsMarkedForDeletionGeneration
213+
);
135214
}
136215
}
137216
}

server/src/test/java/org/elasticsearch/cluster/ClusterStateTests.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -809,9 +809,11 @@ public void testToXContentWithMultipleProjects() throws IOException {
809809
"settings": {
810810
"project.setting": "42",
811811
"project.setting2": "43"
812-
}
812+
},
813+
"marked_for_deletion": true
813814
}
814-
]
815+
],
816+
"projects_marked_for_deletion_generation": 1
815817
}
816818
}
817819
""",
@@ -927,6 +929,7 @@ private static ClusterState buildMultiProjectClusterState(DiscoveryNode... nodes
927929
projectId1,
928930
Settings.builder().put(PROJECT_SETTING.getKey(), 42).put(PROJECT_SETTING2.getKey(), 43).build()
929931
)
932+
.markProjectForDeletion(projectId1)
930933
.build()
931934
)
932935
.blocks(
@@ -2226,7 +2229,7 @@ public static int expectedChunkCount(ToXContent.Params params, ClusterState clus
22262229
} else if (custom instanceof SnapshotsInProgress snapshotsInProgress) {
22272230
chunkCount += 2 + snapshotsInProgress.asStream().count();
22282231
} else if (custom instanceof ProjectStateRegistry projectStateRegistry) {
2229-
chunkCount += 2 + projectStateRegistry.size();
2232+
chunkCount += 3 + projectStateRegistry.size();
22302233
} else {
22312234
// could be anything, we have to just try it
22322235
chunkCount += Iterables.size(
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
package org.elasticsearch.cluster.project;
11+
12+
import org.elasticsearch.cluster.ClusterModule;
13+
import org.elasticsearch.cluster.ClusterState;
14+
import org.elasticsearch.cluster.Diff;
15+
import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
16+
import org.elasticsearch.common.io.stream.Writeable;
17+
import org.elasticsearch.common.settings.Settings;
18+
import org.elasticsearch.test.ESTestCase;
19+
import org.elasticsearch.test.SimpleDiffableWireSerializationTestCase;
20+
21+
import java.io.IOException;
22+
import java.util.stream.IntStream;
23+
24+
public class ProjectStateRegistrySerializationTests extends SimpleDiffableWireSerializationTestCase<ClusterState.Custom> {
25+
26+
@Override
27+
protected ClusterState.Custom makeTestChanges(ClusterState.Custom testInstance) {
28+
return mutate((ProjectStateRegistry) testInstance);
29+
}
30+
31+
@Override
32+
protected Writeable.Reader<Diff<ClusterState.Custom>> diffReader() {
33+
return ProjectStateRegistry::readDiffFrom;
34+
}
35+
36+
@Override
37+
protected NamedWriteableRegistry getNamedWriteableRegistry() {
38+
return new NamedWriteableRegistry(ClusterModule.getNamedWriteables());
39+
}
40+
41+
@Override
42+
protected Writeable.Reader<ClusterState.Custom> instanceReader() {
43+
return ProjectStateRegistry::new;
44+
}
45+
46+
@Override
47+
protected ClusterState.Custom createTestInstance() {
48+
return randomProjectStateRegistry();
49+
}
50+
51+
@Override
52+
protected ClusterState.Custom mutateInstance(ClusterState.Custom instance) throws IOException {
53+
return mutate((ProjectStateRegistry) instance);
54+
}
55+
56+
private ProjectStateRegistry mutate(ProjectStateRegistry instance) {
57+
if (randomBoolean() && instance.size() > 0) {
58+
// Remove or mutate a project's settings or deletion flag
59+
var projectId = randomFrom(instance.getProjectsSettings().keySet());
60+
var builder = ProjectStateRegistry.builder(instance);
61+
builder.putProjectSettings(projectId, randomSettings());
62+
if (randomBoolean()) {
63+
// mark for deletion
64+
builder.markProjectForDeletion(projectId);
65+
}
66+
return builder.build();
67+
} else {
68+
// add a new project
69+
return ProjectStateRegistry.builder(instance).putProjectSettings(randomUniqueProjectId(), randomSettings()).build();
70+
}
71+
}
72+
73+
private static ProjectStateRegistry randomProjectStateRegistry() {
74+
final var projects = randomSet(1, 5, ESTestCase::randomUniqueProjectId);
75+
final var projectsUnderDeletion = randomSet(0, 5, ESTestCase::randomUniqueProjectId);
76+
var builder = ProjectStateRegistry.builder();
77+
projects.forEach(projectId -> builder.putProjectSettings(projectId, randomSettings()));
78+
projectsUnderDeletion.forEach(
79+
projectId -> builder.putProjectSettings(projectId, randomSettings()).markProjectForDeletion(projectId)
80+
);
81+
return builder.build();
82+
}
83+
84+
public static Settings randomSettings() {
85+
var builder = Settings.builder();
86+
IntStream.range(0, randomIntBetween(1, 5)).forEach(i -> builder.put(randomIdentifier(), randomIdentifier()));
87+
return builder.build();
88+
}
89+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
package org.elasticsearch.cluster.project;
11+
12+
import org.elasticsearch.test.ESTestCase;
13+
import org.hamcrest.Matchers;
14+
15+
import static org.elasticsearch.cluster.project.ProjectStateRegistrySerializationTests.randomSettings;
16+
17+
public class ProjectStateRegistryTests extends ESTestCase {
18+
19+
public void testBuilder() {
20+
final var projects = randomSet(1, 5, ESTestCase::randomUniqueProjectId);
21+
final var projectsUnderDeletion = randomSet(0, 5, ESTestCase::randomUniqueProjectId);
22+
var builder = ProjectStateRegistry.builder();
23+
projects.forEach(projectId -> builder.putProjectSettings(projectId, randomSettings()));
24+
projectsUnderDeletion.forEach(
25+
projectId -> builder.putProjectSettings(projectId, randomSettings()).markProjectForDeletion(projectId)
26+
);
27+
var projectStateRegistry = builder.build();
28+
var gen1 = projectStateRegistry.getProjectsMarkedForDeletionGeneration();
29+
assertThat(gen1, Matchers.equalTo(projectsUnderDeletion.isEmpty() ? 0L : 1L));
30+
31+
projectStateRegistry = ProjectStateRegistry.builder(projectStateRegistry).markProjectForDeletion(randomFrom(projects)).build();
32+
var gen2 = projectStateRegistry.getProjectsMarkedForDeletionGeneration();
33+
assertThat(gen2, Matchers.equalTo(gen1 + 1));
34+
35+
if (projectsUnderDeletion.isEmpty() == false) {
36+
// re-adding the same projectId should not change the generation
37+
projectStateRegistry = ProjectStateRegistry.builder(projectStateRegistry)
38+
.markProjectForDeletion(randomFrom(projectsUnderDeletion))
39+
.build();
40+
assertThat(projectStateRegistry.getProjectsMarkedForDeletionGeneration(), Matchers.equalTo(gen2));
41+
}
42+
43+
var unknownProjectId = randomUniqueProjectId();
44+
var throwingBuilder = ProjectStateRegistry.builder(projectStateRegistry).markProjectForDeletion(unknownProjectId);
45+
assertThrows(IllegalArgumentException.class, throwingBuilder::build);
46+
}
47+
}

0 commit comments

Comments
 (0)