Skip to content

Commit 7e0a623

Browse files
perf: optimize copy on write attributes (#74)
**Requirements** - [x] I have added test coverage for new or changed functionality - [x] I have followed the repository's [pull request submission guidelines](../blob/main/CONTRIBUTING.md#submitting-pull-requests) - [x] I have validated my changes against all supported platform versions **Related issues** none **Describe the solution you've provided** i have modified the copy on write mechanism for custom attributes to avoid having to copy the whole attribute map. instead i chain to the parent map. this does not change the public API or outwards behavior. the implementation was complicated because of the need to support the "remove" operation. additionally, i leveraged the equals and hashCode of Map. note: hashCode from Map is not order dependent since it uses addition across all of the entries and addition is commutative. **Describe alternatives you've considered** i was also playing around with a lazy LDValue provider such that we do not need to create all of the attributes if we do not use them but that seemed like a lot of rework and hard to do in a codebase currently targeting jdk 7. **Additional context** i was doing flame graph analysis and found that there were a lot of copies of the hash maps going on, so i thought i could try to eliminate those copies. my use case involves the case in the new test case i added, but it is located in a hotspot in my codebase. the parent context has dozens of attributes and is shared across multiple evaluations. however the child contexts add on only a handful of attributes. the current implementation was copying over all of the attributes for each new build since the parent context was tainted. this root/parent chaining and reuse is a common paradigm over the codebase ``` controller(){ LDContext rootContext = LDContext.builder(key).set(...).set(...)......build(); ... if (ldClient.boolVariation(rootContext, ...)) { ... } ... for(a lot of iterations) { LDContext innerContext = LDContext.builderFromContext(rootContext).set(....).build() if(ldClient.boolVariation(innerContext, ...)) { ... } // LDContext.builderFromContext(innerContext) may be called as well ... } ... } ``` Co-authored-by: Todd Anderson <[email protected]>
1 parent 393eddd commit 7e0a623

File tree

5 files changed

+133
-31
lines changed

5 files changed

+133
-31
lines changed
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package com.launchdarkly.sdk;
2+
3+
import java.util.HashMap;
4+
import java.util.Map;
5+
6+
final class AttributeMap {
7+
private final AttributeMap parent;
8+
private final Map<String, LDValue> map;
9+
10+
AttributeMap() {
11+
this(null);
12+
}
13+
14+
AttributeMap(AttributeMap parent) {
15+
this.parent = parent;
16+
this.map = new HashMap<>();
17+
}
18+
19+
LDValue get(String key) {
20+
AttributeMap current = this;
21+
while (current != null) {
22+
LDValue value = current.map.get(key);
23+
if (value != null) {
24+
if (value.isNull()) {
25+
break;
26+
}
27+
return value;
28+
}
29+
current = current.parent;
30+
}
31+
return null;
32+
}
33+
34+
void put(String key, LDValue value) {
35+
map.put(key, value);
36+
}
37+
38+
@Override
39+
public int hashCode() {
40+
return flatten().hashCode();
41+
}
42+
43+
@Override
44+
public boolean equals(Object other) {
45+
if (this == other) {
46+
return true;
47+
}
48+
if (!(other instanceof AttributeMap)) {
49+
return false;
50+
}
51+
AttributeMap o = (AttributeMap) other;
52+
return flatten().equals(o.flatten());
53+
}
54+
55+
Map<String, LDValue> flatten() {
56+
if (parent == null) {
57+
return map;
58+
}
59+
Map<String, LDValue> out = new HashMap<>();
60+
flattenRecursive(out);
61+
return out;
62+
}
63+
64+
private void flattenRecursive(Map<String, LDValue> out) {
65+
if (parent != null) {
66+
parent.flattenRecursive(out);
67+
}
68+
for (Map.Entry<String, LDValue> entry : map.entrySet()) {
69+
String key = entry.getKey();
70+
LDValue value = entry.getValue();
71+
if (value.isNull()) {
72+
out.remove(key);
73+
} else {
74+
out.put(key, value);
75+
}
76+
}
77+
}
78+
79+
void remove(String key) {
80+
if (parent == null) {
81+
map.remove(key);
82+
return;
83+
}
84+
// we need to hide the value from the parents
85+
map.put(key, LDValue.ofNull());
86+
}
87+
}

lib/shared/common/src/main/java/com/launchdarkly/sdk/ContextBuilder.java

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
package com.launchdarkly.sdk;
22

33
import java.util.ArrayList;
4-
import java.util.HashMap;
54
import java.util.List;
6-
import java.util.Map;
75

86
/**
97
* A mutable object that uses the builder pattern to specify properties for {@link LDContext}.
@@ -34,7 +32,7 @@ public final class ContextBuilder {
3432
private ContextKind kind;
3533
private String key;
3634
private String name;
37-
private Map<String, LDValue> attributes;
35+
private AttributeMap attributes;
3836
private boolean anonymous;
3937
private List<AttributeRef> privateAttributes;
4038
private boolean copyOnWriteAttributes;
@@ -303,7 +301,7 @@ public boolean trySet(String attributeName, LDValue value) {
303301
return false;
304302
default:
305303
if (copyOnWriteAttributes) {
306-
attributes = new HashMap<>(attributes);
304+
attributes = new AttributeMap(attributes);
307305
copyOnWriteAttributes = false;
308306
}
309307
if (value == null || value.isNull()) {
@@ -312,7 +310,7 @@ public boolean trySet(String attributeName, LDValue value) {
312310
}
313311
} else {
314312
if (attributes == null) {
315-
attributes = new HashMap<>();
313+
attributes = new AttributeMap();
316314
}
317315
attributes.put(attributeName, value);
318316
}

lib/shared/common/src/main/java/com/launchdarkly/sdk/LDContext.java

Lines changed: 11 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public final class LDContext implements JsonSerializable {
5858
final String key;
5959
final String fullyQualifiedKey;
6060
final String name;
61-
final Map<String, LDValue> attributes;
61+
final AttributeMap attributes;
6262
final boolean anonymous;
6363
final List<AttributeRef> privateAttributes;
6464

@@ -68,7 +68,7 @@ private LDContext(
6868
String key,
6969
String fullyQualifiedKey,
7070
String name,
71-
Map<String, LDValue> attributes,
71+
AttributeMap attributes,
7272
boolean anonymous,
7373
List<AttributeRef> privateAttributes
7474
) {
@@ -100,7 +100,7 @@ static LDContext createSingle(
100100
ContextKind kind,
101101
String key,
102102
String name,
103-
Map<String, LDValue> attributes,
103+
AttributeMap attributes,
104104
boolean anonymous,
105105
List<AttributeRef> privateAttributes,
106106
boolean allowEmptyKey // allowEmptyKey is true only when deserializing old-style user JSON
@@ -300,22 +300,22 @@ public static LDContext fromUser(LDUser user) {
300300
return failed(Errors.CONTEXT_NO_KEY);
301301
}
302302
}
303-
Map<String, LDValue> attributes = null;
303+
AttributeMap attributes = null;
304304
for (UserAttribute a: UserAttribute.OPTIONAL_STRING_ATTRIBUTES) {
305305
if (a == UserAttribute.NAME) {
306306
continue;
307307
}
308308
LDValue value = user.getAttribute(a);
309309
if (!value.isNull()) {
310310
if (attributes == null) {
311-
attributes = new HashMap<>();
311+
attributes = new AttributeMap();
312312
}
313313
attributes.put(a.getName(), value);
314314
}
315315
}
316316
if (user.custom != null && !user.custom.isEmpty()) {
317317
if (attributes == null) {
318-
attributes = new HashMap<>();
318+
attributes = new AttributeMap();
319319
}
320320
for (Map.Entry<UserAttribute, LDValue> kv: user.custom.entrySet()) {
321321
attributes.put(kv.getKey().getName(), kv.getValue());
@@ -671,7 +671,7 @@ public LDValue getValue(AttributeRef attributeRef) {
671671
* @return an iterable of strings (may be empty, but will never be null)
672672
*/
673673
public Iterable<String> getCustomAttributeNames() {
674-
return attributes == null ? Collections.<String>emptyList() : attributes.keySet();
674+
return attributes == null ? Collections.<String>emptyList() : attributes.flatten().keySet();
675675
}
676676

677677
/**
@@ -853,17 +853,9 @@ public boolean equals(Object other) {
853853
if (!Objects.equals(key, o.key) || !Objects.equals(name, o.name) || anonymous != o.anonymous) {
854854
return false;
855855
}
856-
if ((attributes == null ? 0 : attributes.size()) !=
857-
(o.attributes == null ? 0 : o.attributes.size())) {
856+
if (!Objects.equals(attributes, o.attributes)) {
858857
return false;
859858
}
860-
if (attributes != null) {
861-
for (Map.Entry<String, LDValue> kv: attributes.entrySet()) {
862-
if (!Objects.equals(o.attributes.get(kv.getKey()), kv.getValue())) {
863-
return false;
864-
}
865-
}
866-
}
867859
if (getPrivateAttributeCount() != o.getPrivateAttributeCount()) {
868860
return false;
869861
}
@@ -886,22 +878,16 @@ public boolean equals(Object other) {
886878

887879
@Override
888880
public int hashCode() {
889-
// This implementation of hashCode() is inefficient due to the need to create a predictable ordering
890-
// of attribute names. That's necessary just for the sake of aligning with the behavior of equals(),
891-
// which is insensitive to ordering. However, using an LDContext as a map key is not an anticipated
892-
// or recommended use case.
881+
// This implementation of hashCode() is inefficient due to the need to flatten the attributes map.
882+
// However, using an LDContext as a map key is not an anticipated or recommended use case.
893883
int h = Objects.hash(error, kind, key, name, anonymous);
894884
if (multiContexts != null) {
895885
for (LDContext c: multiContexts) {
896886
h = h * 17 + c.hashCode();
897887
}
898888
}
899889
if (attributes != null) {
900-
String[] names = attributes.keySet().toArray(new String[attributes.size()]);
901-
Arrays.sort(names);
902-
for (String name: names) {
903-
h = (h * 17 + name.hashCode()) * 17 + attributes.get(name).hashCode();
904-
}
890+
h = h * 17 + attributes.hashCode();
905891
}
906892
if (privateAttributes != null) {
907893
AttributeRef[] refs = privateAttributes.toArray(new AttributeRef[privateAttributes.size()]);

lib/shared/common/src/main/java/com/launchdarkly/sdk/LDContextTypeAdapter.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ private void writeSingleKind(JsonWriter out, LDContext c, boolean includeKind) t
5151
out.name(ATTR_ANONYMOUS).value(c.isAnonymous());
5252
}
5353
if (c.attributes != null) {
54-
for (Map.Entry<String, LDValue> kv: c.attributes.entrySet()) {
54+
for (Map.Entry<String, LDValue> kv: c.attributes.flatten().entrySet()) {
5555
out.name(kv.getKey());
5656
LDValueTypeAdapter.INSTANCE.write(out, kv.getValue());
5757
}

lib/shared/common/src/test/java/com/launchdarkly/sdk/ContextBuilderTest.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
import org.junit.Test;
44

5+
import java.util.HashMap;
56
import java.util.List;
7+
import java.util.Map;
68

79
import static com.launchdarkly.sdk.LDContextTest.kind1;
810
import static com.launchdarkly.sdk.LDContextTest.kind2;
@@ -75,6 +77,35 @@ public void copyOnWriteAttributes() {
7577
assertThat(c1.getValue("b"), equalTo(LDValue.ofNull()));
7678
assertThat(c2.getValue("a"), equalTo(LDValue.of(2)));
7779
assertThat(c2.getValue("b"), equalTo(LDValue.of(3)));
80+
Map<String, LDValue> c1Map = new HashMap<>();
81+
c1Map.put("a", LDValue.of(1));
82+
assertThat(c1.attributes.flatten(), equalTo(c1Map));
83+
Map<String, LDValue> c2Map = new HashMap<>();
84+
c2Map.put("a", LDValue.of(2));
85+
c2Map.put("b", LDValue.of(3));
86+
assertThat(c2.attributes.flatten(), equalTo(c2Map));
87+
}
88+
89+
90+
@Test
91+
public void copyOnWriteAttributes2() {
92+
LDContext c1 = LDContext.builder("key").set("a", 1).set("c", LDValue.ofNull()).set("fsdf", 12).build();
93+
LDContext c2 = LDContext.builderFromContext(c1).set("a", 2).set("b", 3).set("fsdf", LDValue.ofNull()).build();
94+
95+
assertThat(c1.getValue("a"), equalTo(LDValue.of(1)));
96+
assertThat(c1.getValue("b"), equalTo(LDValue.ofNull()));
97+
assertThat(c1.getValue("fsdf"), equalTo(LDValue.of(12)));
98+
assertThat(c2.getValue("a"), equalTo(LDValue.of(2)));
99+
assertThat(c2.getValue("b"), equalTo(LDValue.of(3)));
100+
assertThat(c2.getValue("fsdf"), equalTo(LDValue.ofNull()));
101+
Map<String, LDValue> c1Map = new HashMap<>();
102+
c1Map.put("a", LDValue.of(1));
103+
c1Map.put("fsdf", LDValue.of(12));
104+
assertThat(c1.attributes.flatten(), equalTo(c1Map));
105+
Map<String, LDValue> c2Map = new HashMap<>();
106+
c2Map.put("a", LDValue.of(2));
107+
c2Map.put("b", LDValue.of(3));
108+
assertThat(c2.attributes.flatten(), equalTo(c2Map));
78109
}
79110

80111
@Test

0 commit comments

Comments
 (0)