-
Notifications
You must be signed in to change notification settings - Fork 608
fix: Make ObjectValue thread safe. #5699
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
54f0766
411cd4c
1d3cef2
56872f4
a711b7d
0d64d28
c76b208
c18bd0c
6841850
0bf0950
255a059
4552533
9b284f6
78d1c1e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -41,6 +41,12 @@ public final class ObjectValue implements Cloneable { | |
*/ | ||
private final Map<String, Object> overlayMap = new HashMap<>(); | ||
|
||
/** | ||
* Dirty bit for when `overlayMap` is modified. The memoized `partialValue` only needs to be | ||
* updated when dirty bit is true. | ||
*/ | ||
private boolean dirty = false; | ||
|
||
public static ObjectValue fromMap(Map<String, Value> value) { | ||
return new ObjectValue( | ||
Value.newBuilder().setMapValue(MapValue.newBuilder().putAllFields(value)).build()); | ||
|
@@ -103,32 +109,41 @@ private FieldMask extractFieldMask(MapValue value) { | |
} | ||
|
||
@Nullable | ||
private Value extractNestedValue(Value value, FieldPath fieldPath) { | ||
private static Value extractNestedValue(Value value, FieldPath fieldPath) { | ||
if (fieldPath.isEmpty()) { | ||
return value; | ||
} else { | ||
for (int i = 0; i < fieldPath.length() - 1; ++i) { | ||
value = value.getMapValue().getFieldsOrDefault(fieldPath.getSegment(i), null); | ||
if (!Values.isMapValue(value)) { | ||
value = extractSegmentValue(value, fieldPath.getSegment(i)); | ||
if (value == null) { | ||
return null; | ||
} | ||
} | ||
return value.getMapValue().getFieldsOrDefault(fieldPath.getLastSegment(), null); | ||
return extractSegmentValue(value, fieldPath.getLastSegment()); | ||
} | ||
} | ||
|
||
private static Value extractSegmentValue(Value value, String segment) { | ||
return Values.isMapValue(value) ? value.getMapValue().getFieldsOrDefault(segment, null) : null; | ||
} | ||
|
||
/** | ||
* Returns the Protobuf that backs this ObjectValue. | ||
* | ||
* <p>This method applies any outstanding modifications and memoizes the result. Further | ||
* invocations are based on this memoized result. | ||
*/ | ||
private Value buildProto() { | ||
synchronized (overlayMap) { | ||
MapValue mergedResult = applyOverlay(FieldPath.EMPTY_PATH, overlayMap); | ||
if (mergedResult != null) { | ||
partialValue = Value.newBuilder().setMapValue(mergedResult).build(); | ||
overlayMap.clear(); | ||
if (dirty) { | ||
synchronized (overlayMap) { | ||
if (dirty) { | ||
MapValue mergedResult = applyOverlay(FieldPath.EMPTY_PATH, overlayMap, partialValue); | ||
if (mergedResult != null) { | ||
partialValue = Value.newBuilder().setMapValue(mergedResult).build(); | ||
} | ||
overlayMap.clear(); | ||
dirty = false; | ||
} | ||
} | ||
} | ||
return partialValue; | ||
|
@@ -142,7 +157,9 @@ private Value buildProto() { | |
*/ | ||
public void delete(FieldPath path) { | ||
hardAssert(!path.isEmpty(), "Cannot delete field for empty path on ObjectValue"); | ||
setOverlay(path, null); | ||
synchronized (overlayMap) { | ||
setOverlay(path, null); | ||
} | ||
} | ||
|
||
/** | ||
|
@@ -153,16 +170,18 @@ public void delete(FieldPath path) { | |
*/ | ||
public void set(FieldPath path, Value value) { | ||
hardAssert(!path.isEmpty(), "Cannot set field for empty path on ObjectValue"); | ||
setOverlay(path, value); | ||
synchronized (overlayMap) { | ||
setOverlay(path, value); | ||
} | ||
} | ||
|
||
public void setAll(Map<FieldPath, Value> data) { | ||
for (Map.Entry<FieldPath, Value> entry : data.entrySet()) { | ||
FieldPath path = entry.getKey(); | ||
if (entry.getValue() == null) { | ||
delete(path); | ||
} else { | ||
set(path, entry.getValue()); | ||
hardAssert( | ||
!data.containsKey(FieldPath.EMPTY_PATH), | ||
"Cannot setAll on ObjectValue because map contains empty path"); | ||
synchronized (overlayMap) { | ||
for (Map.Entry<FieldPath, Value> entry : data.entrySet()) { | ||
setOverlay(entry.getKey(), entry.getValue()); | ||
} | ||
} | ||
} | ||
|
@@ -195,7 +214,19 @@ private void setOverlay(FieldPath path, @Nullable Value value) { | |
} | ||
} | ||
|
||
currentLevel.put(path.getLastSegment(), value); | ||
String lastSegment = path.getLastSegment(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rather than just putting value every time. We more carefully only put value when there is a change. Otherwise, we will set dirty to true, causing needless work, when values actually were the same. |
||
if (value == null) { // Delete | ||
// Get will return null if entry does not exist, so we must also do containsKey. | ||
if (currentLevel.get(lastSegment) != null || !currentLevel.containsKey(lastSegment)) { | ||
currentLevel.put(lastSegment, null); | ||
dirty = true; | ||
} | ||
} else { // Set | ||
if (!value.equals(currentLevel.get(lastSegment))) { | ||
currentLevel.put(lastSegment, value); | ||
dirty = true; | ||
} | ||
} | ||
} | ||
|
||
/** | ||
|
@@ -206,18 +237,20 @@ private void setOverlay(FieldPath path, @Nullable Value value) { | |
* FieldValue.EMPTY_PATH} to represent the root. | ||
* @param currentOverlays The overlays at the current nesting level in the same format as {@code | ||
* overlayMap}. | ||
* @param currentValue The existing value at the current nesting level. | ||
* @return The merged data at `currentPath` or null if no modifications were applied. | ||
*/ | ||
private @Nullable MapValue applyOverlay( | ||
FieldPath currentPath, Map<String, Object> currentOverlays) { | ||
private static @Nullable MapValue applyOverlay( | ||
FieldPath currentPath, Map<String, Object> currentOverlays, Value currentValue) { | ||
if (currentOverlays.isEmpty()) { | ||
return null; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this return There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No. The method description states:
This basically just short circuiting logic before more heavy lifting of creating builder objects is done. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Okay, I see where this is called expects return value of |
||
} | ||
boolean modified = false; | ||
|
||
@Nullable Value existingValue = extractNestedValue(partialValue, currentPath); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adding currentValue as parameter as replacement for existingValue, such that this method can be static. This helps reason about side effects, and also eliminates redundant work of extracting existing value from root. The parameter currentValue follows same pattern as currentPath, such that each recursion passes in a child. This change isn't strictly required for thread safety, but makes the code more efficient and readable. |
||
MapValue.Builder resultAtPath = | ||
Values.isMapValue(existingValue) | ||
Values.isMapValue(currentValue) | ||
// If there is already data at the current path, base our modifications on top | ||
// of the existing data. | ||
? existingValue.getMapValue().toBuilder() | ||
? currentValue.getMapValue().toBuilder() | ||
: MapValue.newBuilder(); | ||
|
||
for (Map.Entry<String, Object> entry : currentOverlays.entrySet()) { | ||
|
@@ -227,7 +260,10 @@ private void setOverlay(FieldPath path, @Nullable Value value) { | |
if (value instanceof Map) { | ||
@Nullable | ||
MapValue nested = | ||
applyOverlay(currentPath.append(pathSegment), (Map<String, Object>) value); | ||
applyOverlay( | ||
currentPath.append(pathSegment), | ||
(Map<String, Object>) value, | ||
extractSegmentValue(currentValue, pathSegment)); | ||
if (nested != null) { | ||
resultAtPath.putFields(pathSegment, Value.newBuilder().setMapValue(nested).build()); | ||
modified = true; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Was it intentional to invert the logic for extracting a value from a MapValue? Previously the code ran
Values.isMapValue(value)
on thevalue
contained in the input map and returnednull
if it was not a map. Now that check is not performed on the extracted value. Seems like this could change what this method returns?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Semantically, this should be equivalent. The helper function is used in other contexts where value might be
null
, so I had to add check. Since this is being done in helper, it seemed like overkill to also do it in the loop. Technically the loop will work fine even without early return, but the short circuiting behavior has nice performance characteristics.It isn't the easiest code to read, so any recommendations are welcome.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the new code already is more readable. So thanks for the improvement, and clarifying comment. I don't have any recommendations for improvement, but consider a unit test if you have any concerns about equivalency.