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

Add support for Records in JDK 14 #766

Merged
merged 24 commits into from
Mar 18, 2021
Merged

Conversation

FrauBoes
Copy link
Contributor

@FrauBoes FrauBoes commented Sep 3, 2020

Here is the initial version of a record serializer. A few things to note:

  1. The serializer uses method handles instead of core reflection, i.e to invoke the record's constructor
  2. The serial form of the record is a stream of the record components in the same order as in the class file. I see FieldSerialiser orders by name, this could be done here as well.
  3. I kept plenty of debugging output in RecordSerializerTest, this can be cleaned up before integration.
  4. RecordSerializerTest was added to the default serializers. Do we needs to add a test case to SerializerCompatTest.TestData?
  5. Additional config was added to the pom files to only compile and run the new test if JDK 14+ is available. In this case all tests are compiled and run with JDK 14+ --enable-preview. I am not very familiar with Maven and ended up with this solution after asking a few questions on their mailing list. There might well be a better way of handling test compilation and test runs.
  6. The only test that acts up when run with JDK 14 is UnsafeByteBufferInputOutputTest

[INFO] Running com.esotericsoftware.kryo.io.UnsafeByteBufferInputOutputTest Streams with preallocated direct memory are not supported on this JVM java.lang.UnsupportedOperationException: No direct ByteBuffer constructor is available. at com.esotericsoftware.kryo.unsafe.UnsafeUtil.newDirectBuffer(UnsafeUtil.java:125) at com.esotericsoftware.kryo.io.UnsafeByteBufferInputOutputTest.testByteBufferOutputWithPreallocatedMemory(UnsafeByteBufferInputOutputTest.java:43)

I'm sure there are plenty of things that can be improved. Any suggestions welcome!

@theigl
Copy link
Collaborator

theigl commented Sep 4, 2020

@FrauBoes: This is great! Thanks again for taking the time to implement this.

  1. The serializer uses method handles instead of core reflection, i.e to invoke the record's constructor

That's perfectly fine with me. This is just an implementation detail and can be changed later on if necessary.

  1. The serial form of the record is a stream of the record components in the same order as in the class file. I see FieldSerialiser orders by name, this could be done here as well.

FieldSerializer orders by name because the order of fields returned by getDeclaredFields is not guaranteed to be the same across JDK implementations. Is the specification for getRecordComponents more strict? Are they always be returned in the same order?

  1. I kept plenty of debugging output in RecordSerializerTest, this can be cleaned up before integration.

👍

  1. RecordSerializerTest was added to the default serializers. Do we needs to add a test case to SerializerCompatTest.TestData

Yes, we should add a TestDataJava14 to SerializationCompatTestData and register it in SerializerCompatTest. The problem is that we cannot depend on the record class directly in the test data because it has to be compilable with JDK11. Is it possible to create a record reflectively? Alternatively, we can move the test data to the JDK14 folder and only load it when running on JDK 14+.

We can probably move to a JDK11 target and compile with JDK14+ in Kryo 6. This will make the test setup much simpler and we can get rid of all the reflection currently required for RecordSerializer.

  1. Additional config was added to the pom files to only compile and run the new test if JDK 14+ is available. In this case all tests are compiled and run with JDK 14+ --enable-preview. I am not very familiar with Maven and ended up with this solution after asking a few questions on their mailing list. There might well be a better way of handling test compilation and test runs.

This looks good to me. It is basically the same approach we currently use to run JDK11 tests while still targeting JDK8.

  1. The only test that acts up when run with JDK 14 is UnsafeByteBufferInputOutputTest

That can be fixed. We just need to use a different constructor in UnsafeUtil:

directByteBufferConstructor = buffer.getClass().getDeclaredConstructor(long.class, int.class);

instead of:

directByteBufferConstructor = buffer.getClass().getDeclaredConstructor(long.class, int.class, Object.class);

I have some other minor comments that I will add as a PR review.

pom.xml Outdated Show resolved Hide resolved
src/com/esotericsoftware/kryo/Kryo.java Outdated Show resolved Hide resolved
@theigl
Copy link
Collaborator

theigl commented Sep 4, 2020

@magro: Could you take a look at this as well?

The main decision we have to make is if we want to include support for records in Kryo 5 or not. If we set the baseline for Kryo 6 to JDK11 and compile using JDK14+, the implementation of the serializer would be significantly simpler.

@theigl
Copy link
Collaborator

theigl commented Sep 4, 2020

Jackson chose a slightly different (and simpler) approach for separating tests for different JDK versions: FasterXML/jackson-databind#2714

They use a different root folder for tests that depend on a certain JDK version:

<source>src/test-jdk14/java</source>

I think we should adopt the same approach.

@ChrisHegarty
Copy link

Is the specification for getRecordComponents more strict? Are they always be returned in the same order?

@theigl Yes, Class::getRecordComponents is specified to return the record components in the same order. More specifically, from Class.java:

Returns an array of RecordComponent objects representing all the record
components of this record class, or null if this class is not a record class.

The components are returned in the same order that they are declared in
the record header. The array is empty if this record class has no
components. If the class is not a record class, that is isRecord() returns
false, then this method returns null. Conversely, if isRecord() returns true,
then this method returns a non-null value.

@FrauBoes
Copy link
Contributor Author

FrauBoes commented Sep 4, 2020

One advantage of ordering record components by name is that it facilitates duck typing of different record type versions. It's not required but provides more flexibility.

@theigl
Copy link
Collaborator

theigl commented Sep 7, 2020

@ChrisHegarty: Thanks for clarifying this!

One advantage of ordering record components by name is that it facilitates duck typing of different record type versions. It's not required but provides more flexibility.

@FrauBoes: Either way is fine for me as long as we don't need to break serialization compatibility later on.

What I don't quite understand is this: How would you invoke the constructor with the right order of arguments if they are sorted by name?

@@ -124,7 +125,7 @@ public static ByteBuffer newDirectBuffer (long address, int size) {
if (directByteBufferConstructor == null)
throw new UnsupportedOperationException("No direct ByteBuffer constructor is available.");
try {
return directByteBufferConstructor.newInstance(address, size, null);
return directByteBufferConstructor.newInstance(address, size);
} catch (Exception ex) {
throw new KryoException("Error creating a ByteBuffer at address: " + address, ex);

Choose a reason for hiding this comment

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

This change is unrelated to records, but is clearly required to successfully run on JDK 14+, since the non-Public constructors of DirectByteBuffer has been refactored / changed.

@FrauBoes
Copy link
Contributor Author

FrauBoes commented Sep 7, 2020

@theigl Thanks for your comments so far.

Yes, we should add a TestDataJava14 to SerializationCompatTestData and register it in SerializerCompatTest. The problem is that we cannot depend on the record class directly in the test data because it has to be compilable with JDK11. Is it possible to create a record reflectively? Alternatively, we can move the test data to the JDK14 folder and only load it when running on JDK 14+.

That's indeed not that straight-forward. I have a version working that uses an in-memory compiler and a custom classloader to instantiate a record at runtime. It's a lot of utility code for a single test case so I would suggest to put a stripped down copy of SerializationCompatTest/TestData that only covers the record test case into the dedicated jdk14 test directory.

Jackson chose a slightly different (and simpler) approach for separating tests for different JDK versions: FasterXML/jackson-databind#2714

They use a different root folder for tests that depend on a certain JDK version:

<source>src/test-jdk14/java</source>

I think we should adopt the same approach.

This is definitely an option. It's based on the fact that in this location maven ignores the test-jdk14 directory during default-compile and default-testCompile, but it does require an additional plugin build-helper-maven-plugin to add these test sources. I did make a change to the poms and moved all the configuration into the root pom, which is neater. Also, if we do add a jdk14 version of SerializationCompatTest, it would make sense to keep it in the test directory to have access to KryoTestCase etc.

What I don't quite understand is this: How would you invoke the constructor with the right order of arguments if they are sorted by name?

The idea would be to order by name before serialization and expect the same order at deserialization. The order can be stored as part of the RecordComponent helper class in the RecordSerializer.
While this adds a bit more code for the ordering, it supports record type versioning as mentioned. It is also in line with regular types, which means future type transitions from regular type to record type are facilitated.

@theigl
Copy link
Collaborator

theigl commented Sep 8, 2020

That's indeed not that straight-forward. I have a version working that uses an in-memory compiler and a custom classloader to instantiate a record at runtime. It's a lot of utility code for a single test case so I would suggest to put a stripped down copy of SerializationCompatTest/TestData that only covers the record test case into the dedicated jdk14 test directory.

Let's go for the simplest approach! I think it should be possible to put only the TestData into the jdk14 folder and add it to the test datas in SerializationCompatTest via Class.forName for JDK >= 14. If that doesn't work, we could create a temporary, stripped down version of SerializationCompatTest as you suggested.

This is definitely an option. It's based on the fact that in this location maven ignores the test-jdk14 directory during default-compile and default-testCompile, but it does require an additional plugin build-helper-maven-plugin to add these test sources. I did make a change to the poms and moved all the configuration into the root pom, which is neater. Also, if we do add a jdk14 version of SerializationCompatTest, it would make sense to keep it in the test directory to have access to KryoTestCase etc.

Your current solution looks good to me! Everything is in one place and we do not need any another plugin.

The idea would be to order by name before serialization and expect the same order at deserialization. The order can be stored as part of the RecordComponent helper class in the RecordSerializer.
While this adds a bit more code for the ordering, it supports record type versioning as mentioned. It is also in line with regular types, which means future type transitions from regular type to record type are facilitated.

That sounds reasonable. Let's go for the ordered implementation if the code for ordering does not greatly increase the complexity of the serializer.

@FrauBoes
Copy link
Contributor Author

FrauBoes commented Sep 9, 2020

Added TestDataJava14 and ordering to RecordSerializer. Let me know if there is anything else that needs attention.

@FrauBoes
Copy link
Contributor Author

@theigl I tried to build with the IntelliJ Maven integration and it works fine with JDK14. What I do:

jdk14ge profile is auto-selected (see screenshot)
mvn clean
mvn install

If the profile is not selected, compilation with JDK14 fails. Could that be the issue? I'm still happy to make the change, but want to understand the issue.

Screenshot 2020-09-16 at 11 44 03

@theigl
Copy link
Collaborator

theigl commented Sep 16, 2020

@FrauBoes: The problem is not the IntelliJ Maven Integration. That can be made to work. I was referring to opening any test in IntelliJ and simply running it. IntelliJ then tries to compile all classes and fails:

image

image

This is because IntelliJ infers the language level from Maven when importing the project. In the Jackson setup, they override the default language level for the JDK14 profile and force IntelliJ to use JDK14. This, and upgrading the Maven shade plugin allowed me to run individual tests in IntelliJ as described above.

If we now switch the project SDK to JDK11 and try to run a test from within IntelliJ, it fails. This is because IntelliJ does not understand Maven excludes and tries to compile RecordSerializerTest.

To summarize:

The goal is that any user on JDK11 - 15 should be able to checkout the project, build it with Maven, and run individual unit tests directly in IntelliJ. Our current approach fails in the last requirement.

@FrauBoes
Copy link
Contributor Author

@theigl Gotcha, now I understand the requirements. I'll look into making the change. Thanks!

int[] versions = new int[] {parseInt(strVersions[0]), parseInt(strVersions[1])};
JAVA_VERSION = versions[0] > 1 ? versions[0] : versions[1];
if (strVersions.length == 1) {
JAVA_VERSION = parseInt(strVersions[0]);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Unrelated change to better support version strings

@FrauBoes
Copy link
Contributor Author

FrauBoes commented Sep 18, 2020

Made the change we discussed. I'm able to build with Maven and run individual tests in IntelliJ with JDK 11, 14 and 15. The only caveat is that jdk14 tests are not compiled automatically when the project SDK is set to JDK14+, one has to build with Maven first. I'm not sure if that's related to my specific IDE build configurations.
Let me know if it works as expected on your end. Thanks!

@theigl
Copy link
Collaborator

theigl commented Sep 22, 2020

Made the change we discussed. I'm able to build with Maven and run individual tests in IntelliJ with JDK 11, 14 and 15. The only caveat is that jdk14 tests are not compiled automatically when the project SDK is set to JDK14+, one has to build with Maven first. I'm not sure if that's related to my specific IDE build configurations.

@FrauBoes: This works great! I had the same issue with the jdk14 tests but I don't think this is that much of a problem.

The only thing that bothers me now is that we use a different approach for jdk11. I think we should consolidate this and use a custom test-jdk11 folder with a jdk11ge profile instead of the current profile that excludes the test for older JDKs. WDYT?

Please also remove the println statements from the tests now that we are almost finished with the PR.

@theigl
Copy link
Collaborator

theigl commented Sep 22, 2020

If you can, please import code format and import order from the eclipse folder using IntelliJ's "Eclipse Code Formatter" plugin and apply it to the Java files you touched.

@FrauBoes
Copy link
Contributor Author

Agreed, I moved the jdk11 tests to the root and added a jdk11ge profile similar to our approach for jdk14. Debugging statements are removed and the imports are now in line with the rest of the project.

@theigl
Copy link
Collaborator

theigl commented Sep 25, 2020

Agreed, I moved the jdk11 tests to the root and added a jdk11ge profile similar to our approach for jdk14. Debugging statements are removed and the imports are now in line with the rest of the project.

@FrauBoes: Thanks a lot! I think this PR turned out great! 👍

There is only one last thing: The copyright headers. Nathan and I don't feel comfortable with this. We would greatly prefer it if you add yourself or Oracle as @author instead.

@FrauBoes
Copy link
Contributor Author

Thanks for reviewing the contribution, @theigl, it definitely made for a better result. With regards to the copyright header, let me look into this and get back to you.

@theigl
Copy link
Collaborator

theigl commented Nov 23, 2020

@FrauBoes: Did you get any feedback on the copyright headers?

Kryo 5.0.0 has been released and is quite stable, so we could apply this PR now.

@FrauBoes
Copy link
Contributor Author

@theigl We are still on it. I'll get back to you as soon as I know more.

@FrauBoes
Copy link
Contributor Author

Hi @theigl, sorry for the delay. We’re still working through getting the approvals necessary to contribute. Would it be possible for you to modify the last sentence in the CONTRIBUTING.md from

“Whatever content You Contribute will be provided under the Project License(s).”

to something like the following, which we feel will make the connection clearer between the contribution, license, and copyright:

“Whatever content You Contribute will be under the copyright and license listed in LICENSE.md”

?

@theigl
Copy link
Collaborator

theigl commented Feb 4, 2021

@FrauBoes: Thanks for the update!

I'll ask @NathanSweet and get back to you.

@theigl
Copy link
Collaborator

theigl commented Feb 4, 2021

@FrauBoes: The suggested change would be OK for us.

@FrauBoes
Copy link
Contributor Author

FrauBoes commented Mar 9, 2021

Hi @theigl, we got the green light and can move ahead with the contribution \o/. I've added @author tags to the relevant files and updated the CONTRIBUTING.md. Does the PR look good to you?

@FrauBoes FrauBoes changed the title Records Add support for Records in JDK 14 Mar 9, 2021
@theigl
Copy link
Collaborator

theigl commented Mar 12, 2021

@FrauBoes: Thanks a lot for the updated PR!

I just released the current master as Kryo 5.0.4 and will review (and most likely merge) your PR in the next couple of days.

@FrauBoes
Copy link
Contributor Author

@theigl That's great news. Very happy to see Kryo support records! Thanks again for your input and engagement on the PR, as said before it made for a better result. If anything else comes up along the way, let me know.

@theigl theigl merged commit 15067d5 into EsotericSoftware:master Mar 18, 2021
@theigl
Copy link
Collaborator

theigl commented Mar 18, 2021

Merged! 🎉

I will create a release (or RC) with your changes in the next couple of days.

@theigl
Copy link
Collaborator

theigl commented Mar 26, 2021

@FrauBoes: I realized today that the MethodHandles used for the RecordSerializer will most likely break our (already brittle) support for Android API <26.

I think we should switch to core reflection for the time being. Is this something you could help with? Sorry that I didn't think about this before merging your PR!

@FrauBoes
Copy link
Contributor Author

@theigl I can help with that. I'll create a new PR in the next few days.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

Java 14 records : how to deal with them?
4 participants