Browse Source

Add proper Diff support for ProjectStateRegistry (#130917)

This change introduces proper diff support for ProjectStateRegistry instead of CompleteDiff and Entry to ProjectStateRegistry, to allow addition of more project state fields in the future
Alexey Ivanov 3 months ago
parent
commit
f664cf5926

+ 1 - 0
server/src/main/java/org/elasticsearch/TransportVersions.java

@@ -342,6 +342,7 @@ public class TransportVersions {
     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 PROJECT_STATE_REGISTRY_ENTRY = def(9_124_0_00);
 
     /*
      * STOP! READ THIS FIRST! No, really,

+ 3 - 7
server/src/main/java/org/elasticsearch/cluster/metadata/Metadata.java

@@ -835,10 +835,6 @@ public class Metadata implements Diffable<Metadata>, ChunkedToXContent {
         );
     }
 
-    private static final DiffableUtils.KeySerializer<ProjectId> PROJECT_ID_SERIALIZER = DiffableUtils.getWriteableKeySerializer(
-        ProjectId.READER
-    );
-
     private static class MetadataDiff implements Diff<Metadata> {
 
         private final long version;
@@ -880,7 +876,7 @@ public class Metadata implements Diffable<Metadata>, ChunkedToXContent {
                 multiProject = null;
             } else {
                 singleProject = null;
-                multiProject = DiffableUtils.diff(before.projectMetadata, after.projectMetadata, PROJECT_ID_SERIALIZER);
+                multiProject = DiffableUtils.diff(before.projectMetadata, after.projectMetadata, ProjectId.PROJECT_ID_SERIALIZER);
             }
 
             if (empty) {
@@ -1004,7 +1000,7 @@ public class Metadata implements Diffable<Metadata>, ChunkedToXContent {
                 singleProject = null;
                 multiProject = DiffableUtils.readJdkMapDiff(
                     in,
-                    PROJECT_ID_SERIALIZER,
+                    ProjectId.PROJECT_ID_SERIALIZER,
                     ProjectMetadata::readFrom,
                     ProjectMetadata.ProjectMetadataDiff::new
                 );
@@ -1059,7 +1055,7 @@ public class Metadata implements Diffable<Metadata>, ChunkedToXContent {
                 if (multiProject != null) {
                     multiProject.writeTo(out);
                 } else {
-                    DiffableUtils.singleEntryDiff(DEFAULT_PROJECT_ID, singleProject, PROJECT_ID_SERIALIZER).writeTo(out);
+                    DiffableUtils.singleEntryDiff(DEFAULT_PROJECT_ID, singleProject, ProjectId.PROJECT_ID_SERIALIZER).writeTo(out);
                 }
             }
         }

+ 2 - 0
server/src/main/java/org/elasticsearch/cluster/metadata/ProjectId.java

@@ -9,6 +9,7 @@
 
 package org.elasticsearch.cluster.metadata;
 
+import org.elasticsearch.cluster.DiffableUtils;
 import org.elasticsearch.common.Strings;
 import org.elasticsearch.common.io.stream.StreamInput;
 import org.elasticsearch.common.io.stream.StreamOutput;
@@ -26,6 +27,7 @@ public class ProjectId implements Writeable, ToXContent {
     private static final String DEFAULT_STRING = "default";
     public static final ProjectId DEFAULT = new ProjectId(DEFAULT_STRING);
     public static final Reader<ProjectId> READER = ProjectId::readFrom;
+    public static final DiffableUtils.KeySerializer<ProjectId> PROJECT_ID_SERIALIZER = DiffableUtils.getWriteableKeySerializer(READER);
     private static final int MAX_LENGTH = 128;
 
     private final String id;

+ 167 - 26
server/src/main/java/org/elasticsearch/cluster/project/ProjectStateRegistry.java

@@ -13,15 +13,23 @@ import org.elasticsearch.TransportVersion;
 import org.elasticsearch.TransportVersions;
 import org.elasticsearch.cluster.AbstractNamedDiffable;
 import org.elasticsearch.cluster.ClusterState;
+import org.elasticsearch.cluster.ClusterState.Custom;
+import org.elasticsearch.cluster.Diff;
+import org.elasticsearch.cluster.Diffable;
+import org.elasticsearch.cluster.DiffableUtils;
 import org.elasticsearch.cluster.NamedDiff;
+import org.elasticsearch.cluster.NamedDiffable;
+import org.elasticsearch.cluster.SimpleDiffable;
 import org.elasticsearch.cluster.metadata.ProjectId;
 import org.elasticsearch.common.collect.ImmutableOpenMap;
 import org.elasticsearch.common.collect.Iterators;
 import org.elasticsearch.common.io.stream.StreamInput;
 import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.io.stream.Writeable;
 import org.elasticsearch.common.settings.Settings;
 import org.elasticsearch.common.util.set.Sets;
 import org.elasticsearch.xcontent.ToXContent;
+import org.elasticsearch.xcontent.XContentBuilder;
 
 import java.io.IOException;
 import java.util.Collections;
@@ -30,22 +38,29 @@ import java.util.Iterator;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import java.util.stream.Collectors;
 
 /**
  * Represents a registry for managing and retrieving project-specific state in the cluster state.
  */
-public class ProjectStateRegistry extends AbstractNamedDiffable<ClusterState.Custom> implements ClusterState.Custom {
+public class ProjectStateRegistry extends AbstractNamedDiffable<Custom> implements Custom, NamedDiffable<Custom> {
     public static final String TYPE = "projects_registry";
     public static final ProjectStateRegistry EMPTY = new ProjectStateRegistry(Collections.emptyMap(), Collections.emptySet(), 0);
+    private static final Entry EMPTY_ENTRY = new Entry(Settings.EMPTY);
 
-    private final Map<ProjectId, Settings> projectsSettings;
+    private final Map<ProjectId, Entry> projectsEntries;
     // Projects that have been marked for deletion based on their file-based setting
     private final Set<ProjectId> 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_ENTRY)) {
+            projectsEntries = in.readMap(ProjectId::readFrom, Entry::readFrom);
+        } else {
+            Map<ProjectId, Settings> settingsMap = in.readMap(ProjectId::readFrom, Settings::readSettingsFromStream);
+            projectsEntries = settingsMap.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, e -> new Entry(e.getValue())));
+        }
         if (in.getTransportVersion().onOrAfter(TransportVersions.PROJECT_STATE_REGISTRY_RECORDS_DELETIONS)) {
             projectsMarkedForDeletion = in.readCollectionAsImmutableSet(ProjectId::readFrom);
             projectsMarkedForDeletionGeneration = in.readVLong();
@@ -56,11 +71,11 @@ public class ProjectStateRegistry extends AbstractNamedDiffable<ClusterState.Cus
     }
 
     private ProjectStateRegistry(
-        Map<ProjectId, Settings> projectsSettings,
+        Map<ProjectId, Entry> projectEntries,
         Set<ProjectId> projectsMarkedForDeletion,
         long projectsMarkedForDeletionGeneration
     ) {
-        this.projectsSettings = projectsSettings;
+        this.projectsEntries = projectEntries;
         this.projectsMarkedForDeletion = projectsMarkedForDeletion;
         this.projectsMarkedForDeletionGeneration = projectsMarkedForDeletionGeneration;
     }
@@ -75,7 +90,11 @@ public class ProjectStateRegistry extends AbstractNamedDiffable<ClusterState.Cus
      */
     public static Settings getProjectSettings(ProjectId projectId, ClusterState clusterState) {
         ProjectStateRegistry registry = clusterState.custom(TYPE, EMPTY);
-        return registry.projectsSettings.getOrDefault(projectId, Settings.EMPTY);
+        return registry.getProjectSettings(projectId);
+    }
+
+    public Settings getProjectSettings(ProjectId projectId) {
+        return projectsEntries.getOrDefault(projectId, EMPTY_ENTRY).settings;
     }
 
     public boolean isProjectMarkedForDeletion(ProjectId projectId) {
@@ -91,12 +110,10 @@ public class ProjectStateRegistry extends AbstractNamedDiffable<ClusterState.Cus
 
         return Iterators.concat(
             Iterators.single((builder, p) -> builder.startArray("projects")),
-            Iterators.map(projectsSettings.entrySet().iterator(), entry -> (builder, p) -> {
+            Iterators.map(projectsEntries.entrySet().iterator(), entry -> (builder, p) -> {
                 builder.startObject();
                 builder.field("id", entry.getKey());
-                builder.startObject("settings");
-                entry.getValue().toXContent(builder, new ToXContent.MapParams(Collections.singletonMap("flat_settings", "true")));
-                builder.endObject();
+                entry.getValue().toXContent(builder, params);
                 builder.field("marked_for_deletion", projectsMarkedForDeletion.contains(entry.getKey()));
                 return builder.endObject();
             }),
@@ -105,8 +122,19 @@ public class ProjectStateRegistry extends AbstractNamedDiffable<ClusterState.Cus
         );
     }
 
-    public static NamedDiff<ClusterState.Custom> readDiffFrom(StreamInput in) throws IOException {
-        return readDiffFrom(ClusterState.Custom.class, TYPE, in);
+    public static NamedDiff<Custom> readDiffFrom(StreamInput in) throws IOException {
+        if (in.getTransportVersion().onOrAfter(TransportVersions.PROJECT_STATE_REGISTRY_ENTRY)) {
+            return new ProjectStateRegistryDiff(in);
+        }
+        return readDiffFrom(Custom.class, TYPE, in);
+    }
+
+    @Override
+    public Diff<Custom> diff(Custom previousState) {
+        if (this.equals(previousState)) {
+            return SimpleDiffable.empty();
+        }
+        return new ProjectStateRegistryDiff((ProjectStateRegistry) previousState, this);
     }
 
     @Override
@@ -121,7 +149,14 @@ public class ProjectStateRegistry extends AbstractNamedDiffable<ClusterState.Cus
 
     @Override
     public void writeTo(StreamOutput out) throws IOException {
-        out.writeMap(projectsSettings);
+        if (out.getTransportVersion().onOrAfter(TransportVersions.PROJECT_STATE_REGISTRY_ENTRY)) {
+            out.writeMap(projectsEntries);
+        } else {
+            Map<ProjectId, Settings> settingsMap = projectsEntries.entrySet()
+                .stream()
+                .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().settings()));
+            out.writeMap(settingsMap);
+        }
         if (out.getTransportVersion().onOrAfter(TransportVersions.PROJECT_STATE_REGISTRY_RECORDS_DELETIONS)) {
             out.writeCollection(projectsMarkedForDeletion);
             out.writeVLong(projectsMarkedForDeletionGeneration);
@@ -133,7 +168,7 @@ public class ProjectStateRegistry extends AbstractNamedDiffable<ClusterState.Cus
     }
 
     public int size() {
-        return projectsSettings.size();
+        return projectsEntries.size();
     }
 
     public long getProjectsMarkedForDeletionGeneration() {
@@ -141,15 +176,15 @@ public class ProjectStateRegistry extends AbstractNamedDiffable<ClusterState.Cus
     }
 
     // visible for testing
-    Map<ProjectId, Settings> getProjectsSettings() {
-        return Collections.unmodifiableMap(projectsSettings);
+    Set<ProjectId> knownProjects() {
+        return projectsEntries.keySet();
     }
 
     @Override
     public String toString() {
         return "ProjectStateRegistry["
-            + "projectsSettings="
-            + projectsSettings
+            + "entities="
+            + projectsEntries
             + ", projectsMarkedForDeletion="
             + projectsMarkedForDeletion
             + ", projectsMarkedForDeletionGeneration="
@@ -163,13 +198,13 @@ public class ProjectStateRegistry extends AbstractNamedDiffable<ClusterState.Cus
         if (o instanceof ProjectStateRegistry == false) return false;
         ProjectStateRegistry that = (ProjectStateRegistry) o;
         return projectsMarkedForDeletionGeneration == that.projectsMarkedForDeletionGeneration
-            && Objects.equals(projectsSettings, that.projectsSettings)
+            && Objects.equals(projectsEntries, that.projectsEntries)
             && Objects.equals(projectsMarkedForDeletion, that.projectsMarkedForDeletion);
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(projectsSettings, projectsMarkedForDeletion, projectsMarkedForDeletionGeneration);
+        return Objects.hash(projectsEntries, projectsMarkedForDeletion, projectsMarkedForDeletionGeneration);
     }
 
     public static Builder builder(ClusterState original) {
@@ -185,26 +220,86 @@ public class ProjectStateRegistry extends AbstractNamedDiffable<ClusterState.Cus
         return new Builder();
     }
 
+    static class ProjectStateRegistryDiff implements NamedDiff<Custom> {
+        private static final DiffableUtils.DiffableValueReader<ProjectId, Entry> VALUE_READER = new DiffableUtils.DiffableValueReader<>(
+            Entry::readFrom,
+            Entry.EntryDiff::readFrom
+        );
+
+        private final DiffableUtils.MapDiff<ProjectId, Entry, Map<ProjectId, Entry>> projectsEntriesDiff;
+        private final Set<ProjectId> projectsMarkedForDeletion;
+        private final long projectsMarkedForDeletionGeneration;
+
+        ProjectStateRegistryDiff(StreamInput in) throws IOException {
+            projectsEntriesDiff = DiffableUtils.readJdkMapDiff(in, ProjectId.PROJECT_ID_SERIALIZER, VALUE_READER);
+            projectsMarkedForDeletion = in.readCollectionAsImmutableSet(ProjectId.READER);
+            projectsMarkedForDeletionGeneration = in.readVLong();
+        }
+
+        ProjectStateRegistryDiff(ProjectStateRegistry previousState, ProjectStateRegistry currentState) {
+            projectsEntriesDiff = DiffableUtils.diff(
+                previousState.projectsEntries,
+                currentState.projectsEntries,
+                ProjectId.PROJECT_ID_SERIALIZER,
+                VALUE_READER
+            );
+            projectsMarkedForDeletion = currentState.projectsMarkedForDeletion;
+            projectsMarkedForDeletionGeneration = currentState.projectsMarkedForDeletionGeneration;
+        }
+
+        @Override
+        public TransportVersion getMinimalSupportedVersion() {
+            return TransportVersions.PROJECT_STATE_REGISTRY_ENTRY;
+        }
+
+        @Override
+        public Custom apply(Custom part) {
+            return new ProjectStateRegistry(
+                projectsEntriesDiff.apply(((ProjectStateRegistry) part).projectsEntries),
+                projectsMarkedForDeletion,
+                projectsMarkedForDeletionGeneration
+            );
+        }
+
+        @Override
+        public String getWriteableName() {
+            return TYPE;
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            projectsEntriesDiff.writeTo(out);
+            out.writeCollection(projectsMarkedForDeletion);
+            out.writeVLong(projectsMarkedForDeletionGeneration);
+        }
+    }
+
     public static class Builder {
-        private final ImmutableOpenMap.Builder<ProjectId, Settings> projectsSettings;
+        private final ImmutableOpenMap.Builder<ProjectId, Entry> projectsEntries;
         private final Set<ProjectId> projectsMarkedForDeletion;
         private final long projectsMarkedForDeletionGeneration;
         private boolean newProjectMarkedForDeletion = false;
 
         private Builder() {
-            this.projectsSettings = ImmutableOpenMap.builder();
+            this.projectsEntries = ImmutableOpenMap.builder();
             projectsMarkedForDeletion = new HashSet<>();
             projectsMarkedForDeletionGeneration = 0;
         }
 
         private Builder(ProjectStateRegistry original) {
-            this.projectsSettings = ImmutableOpenMap.builder(original.projectsSettings);
+            this.projectsEntries = ImmutableOpenMap.builder(original.projectsEntries);
             this.projectsMarkedForDeletion = new HashSet<>(original.projectsMarkedForDeletion);
             this.projectsMarkedForDeletionGeneration = original.projectsMarkedForDeletionGeneration;
         }
 
         public Builder putProjectSettings(ProjectId projectId, Settings settings) {
-            projectsSettings.put(projectId, settings);
+            Entry entry = projectsEntries.get(projectId);
+            if (entry == null) {
+                entry = new Entry(settings);
+            } else {
+                entry = entry.withSettings(settings);
+            }
+            projectsEntries.put(projectId, entry);
             return this;
         }
 
@@ -216,17 +311,63 @@ public class ProjectStateRegistry extends AbstractNamedDiffable<ClusterState.Cus
         }
 
         public ProjectStateRegistry build() {
-            final var unknownButUnderDeletion = Sets.difference(projectsMarkedForDeletion, projectsSettings.keys());
+            final var unknownButUnderDeletion = Sets.difference(projectsMarkedForDeletion, projectsEntries.keys());
             if (unknownButUnderDeletion.isEmpty() == false) {
                 throw new IllegalArgumentException(
                     "Cannot mark projects for deletion that are not in the registry: " + unknownButUnderDeletion
                 );
             }
             return new ProjectStateRegistry(
-                projectsSettings.build(),
+                projectsEntries.build(),
                 projectsMarkedForDeletion,
                 newProjectMarkedForDeletion ? projectsMarkedForDeletionGeneration + 1 : projectsMarkedForDeletionGeneration
             );
         }
     }
+
+    private record Entry(Settings settings) implements Writeable, Diffable<Entry> {
+
+        public static Entry readFrom(StreamInput in) throws IOException {
+            return new Entry(Settings.readSettingsFromStream(in));
+        }
+
+        public Entry withSettings(Settings settings) {
+            return new Entry(settings);
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            out.writeWriteable(settings);
+        }
+
+        public void toXContent(XContentBuilder builder, ToXContent.Params params) throws IOException {
+            builder.startObject("settings");
+            settings.toXContent(builder, new ToXContent.MapParams(Collections.singletonMap("flat_settings", "true")));
+            builder.endObject();
+        }
+
+        @Override
+        public Diff<Entry> diff(Entry previousState) {
+            if (this == previousState) {
+                return SimpleDiffable.empty();
+            }
+            return new EntryDiff(settings.diff(previousState.settings));
+        }
+
+        private record EntryDiff(Diff<Settings> settingsDiff) implements Diff<Entry> {
+            public static EntryDiff readFrom(StreamInput in) throws IOException {
+                return new EntryDiff(Settings.readSettingsDiffFromStream(in));
+            }
+
+            @Override
+            public Entry apply(Entry part) {
+                return part.withSettings(settingsDiff.apply(part.settings));
+            }
+
+            @Override
+            public void writeTo(StreamOutput out) throws IOException {
+                out.writeWriteable(settingsDiff);
+            }
+        }
+    }
 }

+ 13 - 1
server/src/test/java/org/elasticsearch/cluster/project/ProjectStateRegistrySerializationTests.java

@@ -9,6 +9,8 @@
 
 package org.elasticsearch.cluster.project;
 
+import org.elasticsearch.TransportVersion;
+import org.elasticsearch.TransportVersions;
 import org.elasticsearch.cluster.ClusterModule;
 import org.elasticsearch.cluster.ClusterState;
 import org.elasticsearch.cluster.Diff;
@@ -17,10 +19,13 @@ import org.elasticsearch.common.io.stream.Writeable;
 import org.elasticsearch.common.settings.Settings;
 import org.elasticsearch.test.ESTestCase;
 import org.elasticsearch.test.SimpleDiffableWireSerializationTestCase;
+import org.elasticsearch.test.TransportVersionUtils;
 
 import java.io.IOException;
 import java.util.stream.IntStream;
 
+import static org.hamcrest.Matchers.equalTo;
+
 public class ProjectStateRegistrySerializationTests extends SimpleDiffableWireSerializationTestCase<ClusterState.Custom> {
 
     @Override
@@ -56,7 +61,7 @@ public class ProjectStateRegistrySerializationTests extends SimpleDiffableWireSe
     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 projectId = randomFrom(instance.knownProjects());
             var builder = ProjectStateRegistry.builder(instance);
             builder.putProjectSettings(projectId, randomSettings());
             if (randomBoolean()) {
@@ -86,4 +91,11 @@ public class ProjectStateRegistrySerializationTests extends SimpleDiffableWireSe
         IntStream.range(0, randomIntBetween(1, 5)).forEach(i -> builder.put(randomIdentifier(), randomIdentifier()));
         return builder.build();
     }
+
+    public void testProjectStateRegistryBwcSerialization() throws IOException {
+        ProjectStateRegistry projectStateRegistry = randomProjectStateRegistry();
+        TransportVersion oldVersion = TransportVersionUtils.getPreviousVersion(TransportVersions.PROJECT_STATE_REGISTRY_ENTRY);
+        ClusterState.Custom serialized = copyInstance(projectStateRegistry, oldVersion);
+        assertThat(serialized, equalTo(projectStateRegistry));
+    }
 }

+ 48 - 4
server/src/test/java/org/elasticsearch/cluster/project/ProjectStateRegistryTests.java

@@ -9,10 +9,13 @@
 
 package org.elasticsearch.cluster.project;
 
+import org.elasticsearch.cluster.metadata.ProjectId;
+import org.elasticsearch.common.settings.Settings;
 import org.elasticsearch.test.ESTestCase;
-import org.hamcrest.Matchers;
 
 import static org.elasticsearch.cluster.project.ProjectStateRegistrySerializationTests.randomSettings;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.sameInstance;
 
 public class ProjectStateRegistryTests extends ESTestCase {
 
@@ -26,22 +29,63 @@ public class ProjectStateRegistryTests extends ESTestCase {
         );
         var projectStateRegistry = builder.build();
         var gen1 = projectStateRegistry.getProjectsMarkedForDeletionGeneration();
-        assertThat(gen1, Matchers.equalTo(projectsUnderDeletion.isEmpty() ? 0L : 1L));
+        assertThat(gen1, equalTo(projectsUnderDeletion.isEmpty() ? 0L : 1L));
 
         projectStateRegistry = ProjectStateRegistry.builder(projectStateRegistry).markProjectForDeletion(randomFrom(projects)).build();
         var gen2 = projectStateRegistry.getProjectsMarkedForDeletionGeneration();
-        assertThat(gen2, Matchers.equalTo(gen1 + 1));
+        assertThat(gen2, 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));
+            assertThat(projectStateRegistry.getProjectsMarkedForDeletionGeneration(), equalTo(gen2));
         }
 
         var unknownProjectId = randomUniqueProjectId();
         var throwingBuilder = ProjectStateRegistry.builder(projectStateRegistry).markProjectForDeletion(unknownProjectId);
         assertThrows(IllegalArgumentException.class, throwingBuilder::build);
     }
+
+    public void testDiff() {
+        ProjectStateRegistry originalRegistry = ProjectStateRegistry.builder()
+            .putProjectSettings(randomUniqueProjectId(), randomSettings())
+            .putProjectSettings(randomUniqueProjectId(), randomSettings())
+            .putProjectSettings(randomUniqueProjectId(), randomSettings())
+            .build();
+
+        ProjectId newProjectId = randomUniqueProjectId();
+        Settings newSettings = randomSettings();
+        ProjectId projectToMarkForDeletion = randomFrom(originalRegistry.knownProjects());
+        ProjectId projectToModifyId = randomFrom(originalRegistry.knownProjects());
+        Settings modifiedSettings = randomSettings();
+
+        ProjectStateRegistry modifiedRegistry = ProjectStateRegistry.builder(originalRegistry)
+            .putProjectSettings(newProjectId, newSettings)
+            .markProjectForDeletion(projectToMarkForDeletion)
+            .putProjectSettings(projectToModifyId, modifiedSettings)
+            .build();
+
+        var diff = modifiedRegistry.diff(originalRegistry);
+        var appliedRegistry = (ProjectStateRegistry) diff.apply(originalRegistry);
+
+        assertThat(appliedRegistry, equalTo(modifiedRegistry));
+        assertThat(appliedRegistry.size(), equalTo(originalRegistry.size() + 1));
+        assertTrue(appliedRegistry.knownProjects().contains(newProjectId));
+        assertTrue(appliedRegistry.isProjectMarkedForDeletion(projectToMarkForDeletion));
+        assertThat(appliedRegistry.getProjectSettings(newProjectId), equalTo(newSettings));
+        assertThat(appliedRegistry.getProjectSettings(projectToModifyId), equalTo(modifiedSettings));
+    }
+
+    public void testDiffNoChanges() {
+        ProjectStateRegistry originalRegistry = ProjectStateRegistry.builder()
+            .putProjectSettings(randomUniqueProjectId(), randomSettings())
+            .build();
+
+        var diff = originalRegistry.diff(originalRegistry);
+        var appliedRegistry = (ProjectStateRegistry) diff.apply(originalRegistry);
+
+        assertThat(appliedRegistry, sameInstance(originalRegistry));
+    }
 }