Add a proto string interner to cache commonly used content ID strings in PersistentFeedStore.

PiperOrigin-RevId: 246600776
Change-Id: I89ceaacbfeb202634968e36983633910679e4f8f
diff --git a/src/main/java/com/google/android/libraries/feed/common/intern/BUILD b/src/main/java/com/google/android/libraries/feed/common/intern/BUILD
index d4f9b92..8f653f2 100644
--- a/src/main/java/com/google/android/libraries/feed/common/intern/BUILD
+++ b/src/main/java/com/google/android/libraries/feed/common/intern/BUILD
@@ -8,6 +8,7 @@
     deps = [
         "//src/main/java/com/google/android/libraries/feed/common",
         "@com_google_code_findbugs_jsr305//jar",
+        "@com_google_protobuf_javalite//:protobuf_java_lite",
         "@maven//:com_android_support_collections",
     ],
 )
diff --git a/src/main/java/com/google/android/libraries/feed/common/intern/ProtoStringInternerBase.java b/src/main/java/com/google/android/libraries/feed/common/intern/ProtoStringInternerBase.java
new file mode 100644
index 0000000..1913bfd
--- /dev/null
+++ b/src/main/java/com/google/android/libraries/feed/common/intern/ProtoStringInternerBase.java
@@ -0,0 +1,116 @@
+// Copyright 2019 The Feed Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.android.libraries.feed.common.intern;
+
+import com.google.protobuf.GeneratedMessageLite;
+import com.google.protobuf.MessageLite;
+import java.util.ArrayList;
+import java.util.List;
+import javax.annotation.concurrent.ThreadSafe;
+
+/**
+ * A string-specific {@link Interner} base implementation that provides common helper methods to
+ * help in proto string interning.
+ */
+@ThreadSafe
+public abstract class ProtoStringInternerBase<P extends MessageLite> implements Interner<P> {
+
+  private final Interner<String> interner;
+
+  protected ProtoStringInternerBase(Interner<String> interner) {
+    this.interner = interner;
+  }
+
+  protected interface SingleStringFieldGetter<T extends GeneratedMessageLite<T, ?>> {
+    String getField(T input);
+  }
+
+  protected interface SingleStringFieldSetter<B extends GeneratedMessageLite.Builder<?, B>> {
+    void setField(B builder, String value);
+  }
+
+  @SuppressWarnings("ReferenceEquality") // Intentional reference comparison for interned != orig
+  /*@Nullable*/
+  protected <T extends GeneratedMessageLite<T, B>, B extends GeneratedMessageLite.Builder<T, B>>
+      B internSingleStringField(
+          T input,
+          /*@Nullable*/ B builder,
+          SingleStringFieldGetter<T> singleStringFieldGetter,
+          SingleStringFieldSetter<B> singleStringFieldSetter) {
+    String orig = singleStringFieldGetter.getField(input);
+    String interned = interner.intern(orig);
+    if (interned != orig) {
+      builder = ensureBuilder(input, builder);
+      singleStringFieldSetter.setField(builder, interned);
+    }
+    return builder;
+  }
+
+  protected interface RepeatedStringFieldGetter<T extends GeneratedMessageLite<T, ?>> {
+    List<String> getField(T input);
+  }
+
+  protected interface RepeatedStringFieldClearer<B extends GeneratedMessageLite.Builder<?, B>> {
+    void clearField(B builder);
+  }
+
+  protected interface RepeatedStringFieldAllAdder<B extends GeneratedMessageLite.Builder<?, B>> {
+    void addAllField(B builder, List<String> value);
+  }
+
+  @SuppressWarnings("ReferenceEquality") // Intentional reference comparison for interned != orig
+  /*@Nullable*/
+  protected <T extends GeneratedMessageLite<T, B>, B extends GeneratedMessageLite.Builder<T, B>>
+      B internRepeatedStringField(
+          T input,
+          /*@Nullable*/ B builder,
+          RepeatedStringFieldGetter<T> repeatedStringFieldGetter,
+          RepeatedStringFieldClearer<B> repeatedStringFieldClearer,
+          RepeatedStringFieldAllAdder<B> repeatedStringFieldAllAdder) {
+    boolean modified = false;
+    List<String> internedValues = new ArrayList<>();
+    for (String orig : repeatedStringFieldGetter.getField(input)) {
+      String interned = interner.intern(orig);
+      internedValues.add(interned);
+      if (interned != orig) {
+        modified = true;
+      }
+    }
+    if (modified) {
+      builder = ensureBuilder(input, builder);
+      repeatedStringFieldClearer.clearField(builder);
+      repeatedStringFieldAllAdder.addAllField(builder, internedValues);
+    }
+    return builder;
+  }
+
+  protected <T extends GeneratedMessageLite<T, B>, B extends GeneratedMessageLite.Builder<T, B>>
+      B ensureBuilder(T input, /*@Nullable*/ B builder) {
+    if (builder == null) {
+      builder = input.toBuilder();
+    }
+    return builder;
+  }
+
+  @Override
+  public void clear() {
+    interner.clear();
+  }
+
+  @Override
+  public int size() {
+    return interner.size();
+  }
+}
diff --git a/src/main/java/com/google/android/libraries/feed/feedstore/internal/BUILD b/src/main/java/com/google/android/libraries/feed/feedstore/internal/BUILD
index 9569b7f..4d11408 100644
--- a/src/main/java/com/google/android/libraries/feed/feedstore/internal/BUILD
+++ b/src/main/java/com/google/android/libraries/feed/feedstore/internal/BUILD
@@ -8,6 +8,7 @@
     deps = [
         "//src/main/java/com/google/android/libraries/feed/common",
         "//src/main/java/com/google/android/libraries/feed/common/functional",
+        "//src/main/java/com/google/android/libraries/feed/common/intern",
         "//src/main/java/com/google/android/libraries/feed/common/logging",
         "//src/main/java/com/google/android/libraries/feed/common/protoextensions",
         "//src/main/java/com/google/android/libraries/feed/common/time",
diff --git a/src/main/java/com/google/android/libraries/feed/feedstore/internal/PersistentFeedStore.java b/src/main/java/com/google/android/libraries/feed/feedstore/internal/PersistentFeedStore.java
index 72b8aac..4860cf3 100644
--- a/src/main/java/com/google/android/libraries/feed/feedstore/internal/PersistentFeedStore.java
+++ b/src/main/java/com/google/android/libraries/feed/feedstore/internal/PersistentFeedStore.java
@@ -23,6 +23,9 @@
 import android.util.Base64;
 import com.google.android.libraries.feed.common.Result;
 import com.google.android.libraries.feed.common.functional.Supplier;
+import com.google.android.libraries.feed.common.intern.Interner;
+import com.google.android.libraries.feed.common.intern.InternerWithStats;
+import com.google.android.libraries.feed.common.intern.WeakPoolInterner;
 import com.google.android.libraries.feed.common.logging.Dumpable;
 import com.google.android.libraries.feed.common.logging.Dumper;
 import com.google.android.libraries.feed.common.logging.Logger;
@@ -82,6 +85,15 @@
   private final Clock clock;
   private final FeedStoreHelper storeHelper;
 
+  // We use a common string interner pool because the same content IDs are reused across different
+  // protos. The actual proto interners below are backed by thisl common pool.
+  private final InternerWithStats<String> contentIdStringInterner =
+      new InternerWithStats<>(new WeakPoolInterner<>());
+  private final Interner<StreamStructure> streamStructureInterner =
+      new StreamStructureInterner(contentIdStringInterner);
+  private final Interner<StreamPayload> streamPayloadInterner =
+      new StreamPayloadInterner(contentIdStringInterner);
+
   public PersistentFeedStore(
       TimingUtils timingUtils,
       FeedExtensionRegistry extensionRegistry,
@@ -116,7 +128,9 @@
     for (Map.Entry<String, byte[]> entry : contentResult.getValue().entrySet()) {
       try {
         StreamPayload streamPayload =
-            StreamPayload.parseFrom(entry.getValue(), extensionRegistry.getExtensionRegistry());
+            streamPayloadInterner.intern(
+                StreamPayload.parseFrom(
+                    entry.getValue(), extensionRegistry.getExtensionRegistry()));
         payloads.add(new PayloadWithId(entry.getKey(), streamPayload));
       } catch (InvalidProtocolBufferException e) {
         Logger.e(TAG, "Couldn't parse content proto for id %s", entry.getKey());
@@ -168,7 +182,7 @@
         continue;
       }
       try {
-        streamStructures.add(StreamStructure.parseFrom(bytes));
+        streamStructures.add(streamStructureInterner.intern(StreamStructure.parseFrom(bytes)));
       } catch (InvalidProtocolBufferException e) {
         Logger.e(TAG, e, "Error parsing stream structure.");
       }
@@ -618,6 +632,14 @@
     } else {
       dumper.forKey("journalStorage").value("not dumpable");
     }
+    dumper
+        .forKey("contentIdStringInternerSize")
+        .value(contentIdStringInterner.size())
+        .compactPrevious();
+    dumper
+        .forKey("contentIdStringInternerStats")
+        .value(contentIdStringInterner.getStats())
+        .compactPrevious();
   }
 
   /**
diff --git a/src/main/java/com/google/android/libraries/feed/feedstore/internal/StreamPayloadInterner.java b/src/main/java/com/google/android/libraries/feed/feedstore/internal/StreamPayloadInterner.java
new file mode 100644
index 0000000..31b9795
--- /dev/null
+++ b/src/main/java/com/google/android/libraries/feed/feedstore/internal/StreamPayloadInterner.java
@@ -0,0 +1,92 @@
+// Copyright 2019 The Feed Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.android.libraries.feed.feedstore.internal;
+
+import com.google.android.libraries.feed.common.intern.Interner;
+import com.google.android.libraries.feed.common.intern.ProtoStringInternerBase;
+import com.google.search.now.feed.client.StreamDataProto.StreamFeature;
+import com.google.search.now.feed.client.StreamDataProto.StreamPayload;
+import com.google.search.now.feed.client.StreamDataProto.StreamSharedState;
+import com.google.search.now.feed.client.StreamDataProto.StreamToken;
+
+/** Interner that interns content ID related strings from StreamPayload protos. */
+public class StreamPayloadInterner extends ProtoStringInternerBase<StreamPayload> {
+
+  StreamPayloadInterner(Interner<String> interner) {
+    super(interner);
+  }
+
+  @Override
+  public StreamPayload intern(StreamPayload input) {
+    if (input.hasStreamFeature()) {
+      StreamFeature.Builder builder = internStreamFeature(input.getStreamFeature());
+      // If builder is not null we memoized something and  we need to replace the proto with the
+      // modified proto.
+      if (builder != null) {
+        return input.toBuilder().setStreamFeature(builder).build();
+      }
+    } else if (input.hasStreamSharedState()) {
+      StreamSharedState.Builder builder = internStreamSharedState(input.getStreamSharedState());
+      // If builder is not null we memoized something and  we need to replace the proto with the
+      // modified proto.
+      if (builder != null) {
+        return input.toBuilder().setStreamSharedState(builder).build();
+      }
+    } else if (input.hasStreamToken()) {
+      StreamToken.Builder builder = internStreamToken(input.getStreamToken());
+      // If builder is not null we memoized something and  we need to replace the proto with the
+      // modified proto.
+      if (builder != null) {
+        return input.toBuilder().setStreamToken(builder).build();
+      }
+    }
+
+    // If we got here we did not memoized anything.
+    return input;
+  }
+
+  private StreamFeature./*@Nullable*/ Builder internStreamFeature(StreamFeature input) {
+    StreamFeature.Builder builder = null;
+    builder =
+        internSingleStringField(
+            input, builder, StreamFeature::getContentId, StreamFeature.Builder::setContentId);
+    builder =
+        internSingleStringField(
+            input, builder, StreamFeature::getParentId, StreamFeature.Builder::setParentId);
+    return builder;
+  }
+
+  private StreamSharedState./*@Nullable*/ Builder internStreamSharedState(StreamSharedState input) {
+    StreamSharedState.Builder builder = null;
+    builder =
+        internSingleStringField(
+            input,
+            builder,
+            StreamSharedState::getContentId,
+            StreamSharedState.Builder::setContentId);
+    return builder;
+  }
+
+  private StreamToken./*@Nullable*/ Builder internStreamToken(StreamToken input) {
+    StreamToken.Builder builder = null;
+    builder =
+        internSingleStringField(
+            input, builder, StreamToken::getContentId, StreamToken.Builder::setContentId);
+    builder =
+        internSingleStringField(
+            input, builder, StreamToken::getParentId, StreamToken.Builder::setParentId);
+    return builder;
+  }
+}
diff --git a/src/main/java/com/google/android/libraries/feed/feedstore/internal/StreamStructureInterner.java b/src/main/java/com/google/android/libraries/feed/feedstore/internal/StreamStructureInterner.java
new file mode 100644
index 0000000..a66ab4d
--- /dev/null
+++ b/src/main/java/com/google/android/libraries/feed/feedstore/internal/StreamStructureInterner.java
@@ -0,0 +1,48 @@
+// Copyright 2019 The Feed Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.android.libraries.feed.feedstore.internal;
+
+import com.google.android.libraries.feed.common.intern.Interner;
+import com.google.android.libraries.feed.common.intern.ProtoStringInternerBase;
+import com.google.search.now.feed.client.StreamDataProto.StreamStructure;
+
+/**
+ * Interner that interns content ID related strings from StreamStructure protos. The reason is that
+ * there is a great potential to reuse there, many content IDs are identical.
+ */
+public class StreamStructureInterner extends ProtoStringInternerBase<StreamStructure> {
+
+  StreamStructureInterner(Interner<String> interner) {
+    super(interner);
+  }
+
+  @Override
+  public StreamStructure intern(StreamStructure input) {
+    StreamStructure.Builder builder = null;
+    builder =
+        internSingleStringField(
+            input, builder, StreamStructure::getContentId, StreamStructure.Builder::setContentId);
+    builder =
+        internSingleStringField(
+            input,
+            builder,
+            StreamStructure::getParentContentId,
+            StreamStructure.Builder::setParentContentId);
+
+    // If builder is not null we memoized something and  we need to replace the proto with the
+    // modified proto.
+    return (builder != null) ? builder.build() : input;
+  }
+}
diff --git a/src/test/java/com/google/android/libraries/feed/feedstore/internal/BUILD b/src/test/java/com/google/android/libraries/feed/feedstore/internal/BUILD
index 61d19a9..9f27441 100644
--- a/src/test/java/com/google/android/libraries/feed/feedstore/internal/BUILD
+++ b/src/test/java/com/google/android/libraries/feed/feedstore/internal/BUILD
@@ -92,3 +92,39 @@
         "@robolectric//bazel:android-all",
     ],
 )
+
+android_local_test(
+    name = "StreamPayloadInternerTest",
+    size = "small",
+    timeout = "moderate",
+    srcs = ["StreamPayloadInternerTest.java"],
+    aapt_version = "aapt2",
+    manifest_values = DEFAULT_ANDROID_LOCAL_TEST_MANIFEST,
+    deps = [
+        "//src/main/java/com/google/android/libraries/feed/common/intern",
+        "//src/main/java/com/google/android/libraries/feed/feedstore/internal",
+        "//src/main/proto/com/google/android/libraries/feed/internalapi/proto:client_feed_java_proto_lite",
+        "//third_party:robolectric",
+        "@com_google_protobuf_javalite//:protobuf_java_lite",
+        "@maven//:com_google_truth_truth",
+        "@robolectric//bazel:android-all",
+    ],
+)
+
+android_local_test(
+    name = "StreamStructureInternerTest",
+    size = "small",
+    timeout = "moderate",
+    srcs = ["StreamStructureInternerTest.java"],
+    aapt_version = "aapt2",
+    manifest_values = DEFAULT_ANDROID_LOCAL_TEST_MANIFEST,
+    deps = [
+        "//src/main/java/com/google/android/libraries/feed/common/intern",
+        "//src/main/java/com/google/android/libraries/feed/feedstore/internal",
+        "//src/main/proto/com/google/android/libraries/feed/internalapi/proto:client_feed_java_proto_lite",
+        "//third_party:robolectric",
+        "@com_google_protobuf_javalite//:protobuf_java_lite",
+        "@maven//:com_google_truth_truth",
+        "@robolectric//bazel:android-all",
+    ],
+)
diff --git a/src/test/java/com/google/android/libraries/feed/feedstore/internal/StreamPayloadInternerTest.java b/src/test/java/com/google/android/libraries/feed/feedstore/internal/StreamPayloadInternerTest.java
new file mode 100644
index 0000000..bcfdc82
--- /dev/null
+++ b/src/test/java/com/google/android/libraries/feed/feedstore/internal/StreamPayloadInternerTest.java
@@ -0,0 +1,94 @@
+// Copyright 2019 The Feed Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.android.libraries.feed.feedstore.internal;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.android.libraries.feed.common.intern.WeakPoolInterner;
+import com.google.search.now.feed.client.StreamDataProto.StreamFeature;
+import com.google.search.now.feed.client.StreamDataProto.StreamLegacyPayload;
+import com.google.search.now.feed.client.StreamDataProto.StreamPayload;
+import com.google.search.now.feed.client.StreamDataProto.StreamSharedState;
+import com.google.search.now.feed.client.StreamDataProto.StreamToken;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+/** Tests of the {@link StreamPayloadInterner} class. */
+@RunWith(RobolectricTestRunner.class)
+public class StreamPayloadInternerTest {
+
+  private final StreamPayloadInterner interner =
+      new StreamPayloadInterner(new WeakPoolInterner<>());
+
+  @Test
+  public void intern() {
+    StreamPayload first =
+        StreamPayload.newBuilder()
+            .setStreamFeature(
+                StreamFeature.newBuilder()
+                    .setContentId(newString("foo"))
+                    .setParentId(newString("bar"))
+                    .setLegacyContent(StreamLegacyPayload.newBuilder().setType("type")))
+            .build();
+    StreamPayload second =
+        StreamPayload.newBuilder()
+            .setStreamSharedState(StreamSharedState.newBuilder().setContentId(newString("foo")))
+            .build();
+    StreamPayload third =
+        StreamPayload.newBuilder()
+            .setStreamToken(
+                StreamToken.newBuilder()
+                    .setContentId(newString("bar"))
+                    .setParentId(newString("foo")))
+            .build();
+
+    // Sanity check for the newString correct working.
+    assertThat(first.getStreamFeature().getContentId())
+        .isNotSameInstanceAs(second.getStreamSharedState().getContentId());
+    assertThat(first.getStreamFeature().getContentId())
+        .isEqualTo(second.getStreamSharedState().getContentId());
+
+    // Pool is empty so first is added/returned.
+    StreamPayload internedFirst = interner.intern(first);
+    assertThat(interner.size()).isEqualTo(2); // {foo, bar}.
+    assertThat(internedFirst).isSameInstanceAs(first);
+
+    // Pool already has the "foo" content ID, which is reused.
+    StreamPayload internedSecond = interner.intern(second);
+    assertThat(internedSecond).isNotSameInstanceAs(second);
+    assertThat(internedSecond).isEqualTo(second);
+    // Content ID is the same as the one from first.
+    assertThat(interner.size()).isEqualTo(2); // {foo, bar}.
+    assertThat(internedSecond.getStreamSharedState().getContentId())
+        .isSameInstanceAs(internedFirst.getStreamFeature().getContentId());
+
+    // Pool already has both "foo" and "bar" content IDs, which are reused.
+    StreamPayload internedThird = interner.intern(third);
+    assertThat(internedThird).isNotSameInstanceAs(third);
+    assertThat(internedThird).isEqualTo(third);
+    // Content IDs are both reused.
+    assertThat(interner.size()).isEqualTo(2); // {foo, bar}.
+    assertThat(internedThird.getStreamToken().getContentId())
+        .isSameInstanceAs(internedFirst.getStreamFeature().getParentId());
+    assertThat(internedThird.getStreamToken().getParentId())
+        .isSameInstanceAs(internedFirst.getStreamFeature().getContentId());
+  }
+
+  // "new String()" below is called on purpose to generate different String objects.
+  private String newString(String input) {
+    return new String(input);
+  }
+}
diff --git a/src/test/java/com/google/android/libraries/feed/feedstore/internal/StreamStructureInternerTest.java b/src/test/java/com/google/android/libraries/feed/feedstore/internal/StreamStructureInternerTest.java
new file mode 100644
index 0000000..f6e9030
--- /dev/null
+++ b/src/test/java/com/google/android/libraries/feed/feedstore/internal/StreamStructureInternerTest.java
@@ -0,0 +1,85 @@
+// Copyright 2019 The Feed Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.android.libraries.feed.feedstore.internal;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.android.libraries.feed.common.intern.WeakPoolInterner;
+import com.google.search.now.feed.client.StreamDataProto.StreamStructure;
+import com.google.search.now.feed.client.StreamDataProto.StreamStructure.Operation;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+/** Tests of the {@link StreamStructureInterner} class. */
+@RunWith(RobolectricTestRunner.class)
+public class StreamStructureInternerTest {
+
+  private final StreamStructureInterner interner =
+      new StreamStructureInterner(new WeakPoolInterner<>());
+
+  @Test
+  public void intern() {
+    StreamStructure first =
+        StreamStructure.newBuilder()
+            .setContentId(newString("foo"))
+            .setParentContentId(newString("bar"))
+            .setOperation(Operation.UPDATE_OR_APPEND)
+            .build();
+    StreamStructure second =
+        StreamStructure.newBuilder()
+            .setContentId(newString("foo"))
+            .setParentContentId(newString("baz"))
+            .setOperation(Operation.UPDATE_OR_APPEND)
+            .build();
+    StreamStructure third =
+        StreamStructure.newBuilder()
+            .setContentId(newString("bar"))
+            .setParentContentId(newString("foo"))
+            .setOperation(Operation.UPDATE_OR_APPEND)
+            .build();
+
+    // Sanity check for the newString correct working.
+    assertThat(first.getContentId()).isNotSameInstanceAs(second.getContentId());
+    assertThat(first.getContentId()).isEqualTo(second.getContentId());
+
+    // Pool is empty so first is added/returned.
+    StreamStructure internedFirst = interner.intern(first);
+    assertThat(interner.size()).isEqualTo(2); // {foo, bar}.
+    assertThat(internedFirst).isSameInstanceAs(first);
+
+    // Pool already has the "foo" content ID, which is reused.
+    StreamStructure internedSecond = interner.intern(second);
+    assertThat(internedSecond).isNotSameInstanceAs(second);
+    assertThat(internedSecond).isEqualTo(second);
+    // Content ID is the same as the one from first.
+    assertThat(interner.size()).isEqualTo(3); // {foo, bar, baz}.
+    assertThat(internedSecond.getContentId()).isSameInstanceAs(internedFirst.getContentId());
+
+    // Pool already has both "foo" and "bar" content IDs, which are reused.
+    StreamStructure internedThird = interner.intern(third);
+    assertThat(internedThird).isNotSameInstanceAs(third);
+    assertThat(internedThird).isEqualTo(third);
+    // Content IDs are both reused.
+    assertThat(interner.size()).isEqualTo(3); // {foo, bar, baz}.
+    assertThat(internedThird.getContentId()).isSameInstanceAs(internedFirst.getParentContentId());
+    assertThat(internedThird.getParentContentId()).isSameInstanceAs(internedFirst.getContentId());
+  }
+
+  // "new String()" below is called on purpose to generate different String objects.
+  private String newString(String input) {
+    return new String(input);
+  }
+}