Skip to content
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

fix: do not crash on unexpected map format in GenericMapTypeHandler #5062

Merged
merged 14 commits into from
Aug 22, 2022

Conversation

jdrueckert
Copy link
Member

@jdrueckert jdrueckert commented Aug 15, 2022

Contains

  • detect incorrect map format (object instead of array) early, log expected format
    "mapName": [
      { "key": "...", "value": "..." }
    ]
    
  • add null check to avoid crashing if key "key" not found in case of incorrect map format (no map entry for "key")
  • add null check to avoid crashing if key "value" not found in case of incorrect map format (no map entry for "value")

How to test

Can be tested in combination with Terasology/PlantPack#20

Outstanding before merging

  • judge whether refactoring actually improves readability
  • check that refactoring doesn't break anything
    • discuss the return value in case of mismatched key / value handler

- detect incorrect map format (object instead of array) early, log expected format
  ```
  "mapName": [
    { "key": "...", "value": "..." }
  ]
  ```
- add null check to avoid crashing if key "key" not found in case of incorrect map format (no map entry for "key")
- add null check to avoid crashing if key "value" not found in case of incorrect map format (no map entry for "value")
@jdrueckert jdrueckert added Type: Bug Issues reporting and PRs fixing problems Status: Needs Discussion Requires help discussing a reported issue or provided PR Status: Needs Testing Requires to be tested in-game for reproducibility Size: S Small effort likely only affecting a single area and requiring little to no research Category: Crash Requests, Issues and Changes targeting unexpected terminations, segfaults, etc. labels Aug 15, 2022
@github-actions github-actions bot added the Category: Build/CI Requests, Issues and Changes targeting gradle, groovy, Jenkins, etc. label Aug 15, 2022
final Optional<K> key = keyHandler.deserialize(rawKey);
if (key.isEmpty()) {
logger.warn("Could not deserialize key '{}' as '{}'", rawKey, keyHandler.getClass().getSimpleName());
return Optional.empty();
Copy link
Member Author

@jdrueckert jdrueckert Aug 15, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@keturn I saw the test cases in GenericMapTypeHandlerTest.java and wanted to ask why it's desirable to have an empty map returned in case of a mismatched key / value handler...? My current assumption is that we might want that because failing to deserialize the ChangingBlocks component should not result in the whole prefab deserialization failing (although I'm not even sure that would be the case when returning Optional.empty() instead 🤔

Can you confirm this assumption or otherwise explain the reason for expecting an empty map in this case?

My intention with using Optional.empty() here is to indicate that the serialization failed but not return a half-way deserialized map as might happen for instance in case the prefab holds:

myMap: [
  { "key": "myKey", "value": "myValue" },
  { "key2": "myKey2", "value2": "myValue2" }
]

With returning Optional.of(result), I would expect the first map entry to be put into the result map and the deserialization failing for the second entry, resulting in a half-way deserialized map which might lead to weird and potentially hard to debug behavior in game.
Taking the growing plant feature, for example, the plant would never grow to the second stage as the second stage never ended up in game because its deserialization failed.

Please correct me if I got any of the type handling logic etc. wrong leading to incorrect assumptions/expectations 😅

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like I wrote those test cases after @skaldarnar wrote the implementation, so we should ask that guy too.

I'm struggling with how to answer these questions because I think you raise some good points, and—

  • I'm not sure how to give advice on the best use of Optional.empty here, because I feel like Optional is bad at communicating error states and I've felt frustrated with trying to use it here myself, and
  • You don't want the result to be a partly-filled data structure. I empathize, but I think a partial response like that would be more consistent with the way the other deserialization methods are written. If I understand correctly, it seems to be working with a best-effort approach, where it'll do its best to return something even if it might have holes in it.

Copy link
Member

@keturn keturn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a test for the case that was crashing.

@skaldarnar
Copy link
Member

The type handler interface documents that Optional.empty corresponds to a failed deserialization. It also has a couple of convenience methods to return null instead, or use a default value, or throw an exception DeserializationException.

/**
* Deserializes a single value to the type {@link T}.
*
* @param data The persisted data to deserialize from.
* @return The deserialized value. {@link Optional#empty()} if the value could not be deserialized.
*/
public abstract Optional<T> deserialize(PersistedData data);

When we talked about this offline, I raised similar concerns and comments to what @keturn mentioned above. Half-parsed components or prefabs are most likely no good to anybody, so being more strict is likely a good choice. To avoid a situation where we change it for one thing but not the others I'd like to take a bit of time to figure out how we actually want this code to behave and look like, and then do a sweep over it to improve all (or most) places where we use type handlers.

In any case, I'd like to be able to assemble errors (and error message) bottom up to be able to present the user with a more meaningful error message on where they have to look to fix broken prefab.

Coming from functional programming in Scala and ReasonML I'm fond of using types to express logic. In this particular case, I'm looking at the Result type, c.f. its variant in Kotlin. I think the "more Java" way is to use plain exceptions...
In both cases, we should avoid logging way down in the stack (very similar to Log and Throw anti-pattern).

I believe for starters we can try to work with what we already have in the type handler interface, and gradually improve it (with a bit of focus on it for the next week(s)). Let's make this type handler more strict, and also apply it to others. Try to make better use of the information that something went wrong higher hup in the stack.

@keturn @jdrueckert what would be your take on this?

@jdrueckert
Copy link
Member Author

Please add a test for the case that was crashing.

done ✔️

@jdrueckert
Copy link
Member Author

I believe for starters we can try to work with what we already have in the type handler interface, and gradually improve it (with a bit of focus on it for the next week(s)). Let's make this type handler more strict, and also apply it to others. Try to make better use of the information that something went wrong higher hup in the stack.

@keturn @jdrueckert what would be your take on this?

Sounds fair to me. I adjusted the existing test case and added some more incl. a test case to test that a half-way valid map is still not half-way returned according to your "let's make this type handler more strict".
I'm also very much in favor to apply it to other type handlers as well as soon as possible.
I don't see it happening across all type handlers before the next playtest, but I'd like to get as far as possible with the capacity I have.

.idea/kotlinc.xml Outdated Show resolved Hide resolved
Comment on lines 66 to 70
"Expected\n" +
" \"mapName\": [\n" +
" { \"key\": \"...\", \"value\": \"...\" }\n" +
" ]\n" +
"but found \n'{}'", data);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Used in multiple places, maybe create a helper method to be used here and below.

Suggested change
"Expected\n" +
" \"mapName\": [\n" +
" { \"key\": \"...\", \"value\": \"...\" }\n" +
" ]\n" +
"but found \n'{}'", data);
getUsageInfo(data));
private String getUsageInfo(PersistedData data) {
	return "Expected\n" +
                    "  \"mapName\": [\n" +
                    "    { \"key\": \"...\", \"value\": \"...\" }\n" +
                    "  ]\n" +
                    "but found \n'" + data + "'";
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done ✔️

Comment on lines 82 to 86
"Expected\n" +
" \"mapName\": [\n" +
" { \"key\": \"...\", \"value\": \"...\" }\n" +
" ]\n" +
"but found \n'{}'", data);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"Expected\n" +
" \"mapName\": [\n" +
" { \"key\": \"...\", \"value\": \"...\" }\n" +
" ]\n" +
"but found \n'{}'", data);
getUsageInfo(data));

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done ✔️

@@ -32,6 +31,39 @@ GenericMapTypeHandler.VALUE, new PersistedLong(TEST_VALUE)
))
));

private final PersistedData testDataMalformatted = new PersistedValueArray(List.of(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can add the JSON equivalent we want to express here in a comment? Probably helps in both understanding the test case and figuring out if the data strucutre represents the thing we want to test.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done ✔️

Copy link
Member

@keturn keturn Aug 21, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😟 It's also the textbook example of "comment that's out of date as soon as you change the code."

If you think it helps despite that, go ahead and keep it, but I'm a little concerned.

Also note Javadoc does not have triple-quote code blocks. Either change these to javadoc syntax, or do not start the comment with the double-asterisk /** so IntelliJ and javdoc don't treat it as javadoc.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've replaced the triple-quotes.

@jdrueckert jdrueckert dismissed skaldarnar’s stale review August 21, 2022 22:54

requested changes added

keturn
keturn previously approved these changes Aug 22, 2022
Copy link
Member

@keturn keturn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, lets go ahead with this.

I appreciate that you've covered a range of cases with the tests, but I'm curious: which case was it that you initially bumped in to and prompted this PR?

Comment on lines +190 to +197
@DisplayName("A map containing both, correctly and incorrectly formatted data, fails deserialization (returns empty `Optional`)")
void testDeserializeWithValidAndInvalidEntries() {
var th = new GenericMapTypeHandler<>(
new StringTypeHandler(),
new LongTypeHandler()
);

assertThat(th.deserialize(testDataValidAndInvalidMix)).isEmpty();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the case I'm a little worried about, since it's a change from the previous behavior. Not that the previous behavior was better, but it's one of those things where something might have been implicitly depending on it.

But this has a test case now, and the hypothetical I'm worrying about doesn't, so this wins.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think (or hope) something was depending on it. Rather, we'll see some things either work out better or even worse than before, hopefully just epxosing the underlying problem better.

Copy link
Member Author

@jdrueckert jdrueckert Aug 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My expectation is that if this makes something work worse than before, then it's mainly making the underlying issue more visible than before.

I'm curious: which case was it that you initially bumped in to and prompted this PR?

I ran into the crash after making ChangingBlocks use uris instead of strings for the map key.
The prefabs in PlantPack that I tested this were working before with the basic string key type map deserialization but didn't have the format required by the generic map type handler (outer array, entries with "key" and "value" keys). So the three malformatting test cases are covering this now.

Edit: Just saw that Skal already answered to this and with way more detail 😅 Thanks for that! ❤️

skaldarnar
skaldarnar previously approved these changes Aug 22, 2022
Copy link
Member

@skaldarnar skaldarnar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@keturn The issue we found was due to the prefab using the JSON object format to specify the mapping after switching the type to be SimpleUri.

{
  "plantpack:radish1": 3000,
  "plantpack:radish2": 2000
}

We currently have two variants for defining maps:

  • String -> V if the keys are Strings, value can be anything, handled by a different type handler

        {
          "plantpack:radish1": 3000,
          "plantpack:radish2": 2000
        }  
  • K -> V where both keys and values can be arbitrary objects that can be de-/serialized

        [
          { "key": "plantpack:radish1": , "value": 3000 },
          { "key": "plantpack:radish2": , "value": 2000 },
        ]  

    In the future, we probabyl want to add a third option (or replace/enhance the first one) if the key type K can be parsed from a plain String:

  • K -> V if the keys are Strings which can be parsed to some type K (e.g., all the URIs and Names), value can be anything, handled by a different type handler

        {
          "plantpack:radish1": 3000,
          "plantpack:radish2": 2000
        }  

Co-authored-by: Tobias Nett <skaldarnar@googlemail.com>
@jdrueckert jdrueckert changed the title fix: do not crash when facing unexpected map format fix: do not crash on unexpected map format in GenericMapTypeHandler Aug 22, 2022
@jdrueckert jdrueckert merged commit f3f9d92 into develop Aug 22, 2022
@jdrueckert jdrueckert deleted the fix/generic-map-typehandler-resilience branch August 22, 2022 22:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Category: Build/CI Requests, Issues and Changes targeting gradle, groovy, Jenkins, etc. Category: Crash Requests, Issues and Changes targeting unexpected terminations, segfaults, etc. Size: S Small effort likely only affecting a single area and requiring little to no research Status: Needs Discussion Requires help discussing a reported issue or provided PR Status: Needs Testing Requires to be tested in-game for reproducibility Type: Bug Issues reporting and PRs fixing problems
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants