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

refactor XML deserialization #1042

Merged
merged 25 commits into from
Feb 28, 2024
Merged

refactor XML deserialization #1042

merged 25 commits into from
Feb 28, 2024

Conversation

aajtodd
Copy link
Contributor

@aajtodd aajtodd commented Feb 26, 2024

Issue #

aws-sdk-kotlin#1220

Description of changes

The context for this issue is in aws-sdk-kotlin#1220. Essentially we made a bad assumption that flat collections would always be serialized sequentially. In reality services are returning flat collections interspersed with other XML elements.

Our original approach to deserialization followed closely with kotlinx.serialization where we have a common Serializer and Deserializer interface. Each format we want to support (xml, json, etc) implements those and then codegen is the same across all types. The issue is (1) we end up duplicating information already in the model (field traits) and (2) we have to bend over backwards to make the format work within the interface instead of just creating a runtime type that more closely matches the medium. We discussed as a team our options for addressing this issue and decided to just refactor the way we do XML deserialization to closer match that of Go + Rust. This was something we had discussed prior to GA and just didn't have time to do. Rather than implement a one off workaround tailored specifically to this issue we're going to move in the desired end state which is to generate serializers/deserializers specific to each format (starting with just XML deserialization).

This is a large PR so I'm going to try and summarize the important bits for easier review. In particular because a lot of this PR is net new test code.

  • XmlParserGenerator guts were replaced to no longer use the common struct/union deserializer and instead generate something directly off the lower level serde-xml types. See Codegen Output below for example output and differences.
    • Some of the biggest structural differences are that we generate dedicated functions for list/map deserialization rather than doing it inline. This is partially to help with readability but also to maintain the correct deserializer state by construction since they require one or more inner loops themselves. Flattened collections accumulate values into the member such that every time a flattened member tag is hit we don't replace the previous collection (fixing the bug in aws-sdk-kotlin#1220).
  • XmlTagReader - new type that sits on top of XmlStreamReader and provides some small conveniences for iterating tokens and maintaining deserializer state.
  • Removed all previous XML deserializer unit tests in favor of a new module tests/codegen/serde-tests. This new module has a bit of overlap with the existing protocol tests but the iteration time is quicker and is independent of the protocols. This new module has greater coverage than our previous unit tests and I even found some bugs with how we were generating nested lists/maps inside union types as well as found Union members targeting smithy.api#Unit generates extraneous structures #1040. The idea is the same as protocol tests, use the generated code to test with rather than hand writing tests that mimic the structure of generated code. This approach is both easier to write tests for but more accurate as it is testing the real codegen output as opposed to hand written versions of what we expect codegen to output.
  • The serde-benchmarks project contained a companion module serde-codegen-support that implemented a custom dummy protocol for json + xml. There wasn't anything specific to benchmarks here though so I refactored it to remove notion of benchmarks and moved it to tests/serde as a common module that can be re-used for both the serde benchmarks and the new codegen integration tests

Codegen Output

For the S3 ListObjectVersions output type:

Previously

private fun deserializeListObjectVersionsOperationBody(builder: ListObjectVersionsResponse.Builder, payload: ByteArray) {
    val deserializer = XmlDeserializer(payload)
    val COMMONPREFIXES_DESCRIPTOR = SdkFieldDescriptor(SerialKind.List, XmlSerialName("CommonPrefixes"), Flattened)
    val DELETEMARKERS_DESCRIPTOR = SdkFieldDescriptor(SerialKind.List, XmlSerialName("DeleteMarker"), Flattened)
    val DELIMITER_DESCRIPTOR = SdkFieldDescriptor(SerialKind.String, XmlSerialName("Delimiter"))
    val ENCODINGTYPE_DESCRIPTOR = SdkFieldDescriptor(SerialKind.Enum, XmlSerialName("EncodingType"))
    val ISTRUNCATED_DESCRIPTOR = SdkFieldDescriptor(SerialKind.Boolean, XmlSerialName("IsTruncated"))
    val KEYMARKER_DESCRIPTOR = SdkFieldDescriptor(SerialKind.String, XmlSerialName("KeyMarker"))
    val MAXKEYS_DESCRIPTOR = SdkFieldDescriptor(SerialKind.Integer, XmlSerialName("MaxKeys"))
    val NAME_DESCRIPTOR = SdkFieldDescriptor(SerialKind.String, XmlSerialName("Name"))
    val NEXTKEYMARKER_DESCRIPTOR = SdkFieldDescriptor(SerialKind.String, XmlSerialName("NextKeyMarker"))
    val NEXTVERSIONIDMARKER_DESCRIPTOR = SdkFieldDescriptor(SerialKind.String, XmlSerialName("NextVersionIdMarker"))
    val PREFIX_DESCRIPTOR = SdkFieldDescriptor(SerialKind.String, XmlSerialName("Prefix"))
    val VERSIONIDMARKER_DESCRIPTOR = SdkFieldDescriptor(SerialKind.String, XmlSerialName("VersionIdMarker"))
    val VERSIONS_DESCRIPTOR = SdkFieldDescriptor(SerialKind.List, XmlSerialName("Version"), Flattened)
    val OBJ_DESCRIPTOR = SdkObjectDescriptor.build {
        trait(XmlSerialName("ListVersionsResult"))
        trait(XmlNamespace("http://s3.amazonaws.com/doc/2006-03-01/"))
        field(COMMONPREFIXES_DESCRIPTOR)
        field(DELETEMARKERS_DESCRIPTOR)
        field(DELIMITER_DESCRIPTOR)
        field(ENCODINGTYPE_DESCRIPTOR)
        field(ISTRUNCATED_DESCRIPTOR)
        field(KEYMARKER_DESCRIPTOR)
        field(MAXKEYS_DESCRIPTOR)
        field(NAME_DESCRIPTOR)
        field(NEXTKEYMARKER_DESCRIPTOR)
        field(NEXTVERSIONIDMARKER_DESCRIPTOR)
        field(PREFIX_DESCRIPTOR)
        field(VERSIONIDMARKER_DESCRIPTOR)
        field(VERSIONS_DESCRIPTOR)
    }

    deserializer.deserializeStruct(OBJ_DESCRIPTOR) {
        loop@while (true) {
            when (findNextFieldIndex()) {
                COMMONPREFIXES_DESCRIPTOR.index -> builder.commonPrefixes =
                    deserializer.deserializeList(COMMONPREFIXES_DESCRIPTOR) {
                        val col0 = mutableListOf<CommonPrefix>()
                        while (hasNextElement()) {
                            val el0 = if (nextHasValue()) { deserializeCommonPrefixDocument(deserializer) } else { deserializeNull(); continue }
                            col0.add(el0)
                        }
                        col0
                    }
                DELETEMARKERS_DESCRIPTOR.index -> builder.deleteMarkers =
                    deserializer.deserializeList(DELETEMARKERS_DESCRIPTOR) {
                        val col0 = mutableListOf<DeleteMarkerEntry>()
                        while (hasNextElement()) {
                            val el0 = if (nextHasValue()) { deserializeDeleteMarkerEntryDocument(deserializer) } else { deserializeNull(); continue }
                            col0.add(el0)
                        }
                        col0
                    }
                DELIMITER_DESCRIPTOR.index -> builder.delimiter = deserializeString()
                ENCODINGTYPE_DESCRIPTOR.index -> builder.encodingType = deserializeString().let { EncodingType.fromValue(it) }
                ISTRUNCATED_DESCRIPTOR.index -> builder.isTruncated = deserializeBoolean()
                KEYMARKER_DESCRIPTOR.index -> builder.keyMarker = deserializeString()
                MAXKEYS_DESCRIPTOR.index -> builder.maxKeys = deserializeInt()
                NAME_DESCRIPTOR.index -> builder.name = deserializeString()
                NEXTKEYMARKER_DESCRIPTOR.index -> builder.nextKeyMarker = deserializeString()
                NEXTVERSIONIDMARKER_DESCRIPTOR.index -> builder.nextVersionIdMarker = deserializeString()
                PREFIX_DESCRIPTOR.index -> builder.prefix = deserializeString()
                VERSIONIDMARKER_DESCRIPTOR.index -> builder.versionIdMarker = deserializeString()
                VERSIONS_DESCRIPTOR.index -> builder.versions =
                    deserializer.deserializeList(VERSIONS_DESCRIPTOR) {
                        val col0 = mutableListOf<ObjectVersion>()
                        while (hasNextElement()) {
                            val el0 = if (nextHasValue()) { deserializeObjectVersionDocument(deserializer) } else { deserializeNull(); continue }
                            col0.add(el0)
                        }
                        col0
                    }
                null -> break@loop
                else -> skipValue()
            }
        }
    }
}

After:

private fun deserializeListObjectVersionsOperationBody(builder: ListObjectVersionsResponse.Builder, payload: ByteArray) {
    val root = xmlTagReader(payload)

    loop@while(true) {
        val curr = root.nextTag() ?: break@loop
        when(curr.tag.name) {
            // CommonPrefixes smithy.kotlin.synthetic.s3#ListObjectVersionsResponse$CommonPrefixes
            "CommonPrefixes" -> builder.commonPrefixes = run {
                val el = deserializeCommonPrefixDocument(curr)
                createOrAppend(builder.commonPrefixes, el)
            }
            // DeleteMarkers smithy.kotlin.synthetic.s3#ListObjectVersionsResponse$DeleteMarkers
            "DeleteMarker" -> builder.deleteMarkers = run {
                val el = deserializeDeleteMarkerEntryDocument(curr)
                createOrAppend(builder.deleteMarkers, el)
            }
            // Delimiter smithy.kotlin.synthetic.s3#ListObjectVersionsResponse$Delimiter
            "Delimiter" -> builder.delimiter = curr.tryData()
                .getOrDeserializeErr { "expected (string: `com.amazonaws.s3#Delimiter`)" }
            // EncodingType smithy.kotlin.synthetic.s3#ListObjectVersionsResponse$EncodingType
            "EncodingType" -> builder.encodingType = curr.tryData()
                .parse { EncodingType.fromValue(it) }
                .getOrDeserializeErr { "expected (enum: `com.amazonaws.s3#EncodingType`)" }
            // IsTruncated smithy.kotlin.synthetic.s3#ListObjectVersionsResponse$IsTruncated
            "IsTruncated" -> builder.isTruncated = curr.tryData()
                .parseBoolean()
                .getOrDeserializeErr { "expected (boolean: `com.amazonaws.s3#IsTruncated`)" }
            // KeyMarker smithy.kotlin.synthetic.s3#ListObjectVersionsResponse$KeyMarker
            "KeyMarker" -> builder.keyMarker = curr.tryData()
                .getOrDeserializeErr { "expected (string: `com.amazonaws.s3#KeyMarker`)" }
            // MaxKeys smithy.kotlin.synthetic.s3#ListObjectVersionsResponse$MaxKeys
            "MaxKeys" -> builder.maxKeys = curr.tryData()
                .parseInt()
                .getOrDeserializeErr { "expected (integer: `com.amazonaws.s3#MaxKeys`)" }
            // Name smithy.kotlin.synthetic.s3#ListObjectVersionsResponse$Name
            "Name" -> builder.name = curr.tryData()
                .getOrDeserializeErr { "expected (string: `com.amazonaws.s3#BucketName`)" }
            // NextKeyMarker smithy.kotlin.synthetic.s3#ListObjectVersionsResponse$NextKeyMarker
            "NextKeyMarker" -> builder.nextKeyMarker = curr.tryData()
                .getOrDeserializeErr { "expected (string: `com.amazonaws.s3#NextKeyMarker`)" }
            // NextVersionIdMarker smithy.kotlin.synthetic.s3#ListObjectVersionsResponse$NextVersionIdMarker
            "NextVersionIdMarker" -> builder.nextVersionIdMarker = curr.tryData()
                .getOrDeserializeErr { "expected (string: `com.amazonaws.s3#NextVersionIdMarker`)" }
            // Prefix smithy.kotlin.synthetic.s3#ListObjectVersionsResponse$Prefix
            "Prefix" -> builder.prefix = curr.tryData()
                .getOrDeserializeErr { "expected (string: `com.amazonaws.s3#Prefix`)" }
            // VersionIdMarker smithy.kotlin.synthetic.s3#ListObjectVersionsResponse$VersionIdMarker
            "VersionIdMarker" -> builder.versionIdMarker = curr.tryData()
                .getOrDeserializeErr { "expected (string: `com.amazonaws.s3#VersionIdMarker`)" }
            // Versions smithy.kotlin.synthetic.s3#ListObjectVersionsResponse$Versions
            "Version" -> builder.versions = run {
                val el = deserializeObjectVersionDocument(curr)
                createOrAppend(builder.versions, el)
            }
            else -> {}
        }
        curr.drop()
    }
}

Effect on Artifact Sizes

The 1.0.64 S3 release was 5,072,329 bytes. Local builds are coming in at 5,039,276 bytes (~0.6% smaller).

Benchmarks

I've updated the benchmarks. They are included inline here for easy review. The tl;dr is that the generated deserializers are adding less overhead to raw token lexing than before and as a result is faster.

jvm summary:
Benchmark                                                         (sourceFilename)  Mode  Cnt   Score   Error  Units
a.s.k.b.s.xml.XmlDeserializerBenchmark.deserializeBenchmark                    N/A  avgt    5  33.566 ± 0.074  ms/op
a.s.k.b.s.xml.XmlLexerBenchmark.deserializeBenchmark          countries-states.xml  avgt    5  25.200 ± 0.079  ms/op
a.s.k.b.s.xml.XmlLexerBenchmark.deserializeBenchmark            kotlin-article.xml  avgt    5   0.846 ± 0.003  ms/op
a.s.k.b.s.xml.XmlSerializerBenchmark.serializeBenchmark                        N/A  avgt    5  21.714 ± 0.385  ms/op

The lexer internals didn't change so they are nearly the same as the prior baseline. The deserialize benchmark came
in at 33.566 ms/op compared to the prior 90.697 ms/op (62% faster).

Binary Compatibility

This change intentionally breaks binary compatibility on a few @InternalApi APIs:

  • XmlDeserializer - removed completely as it's no longer used
  • parseRestXmlError/parseEc2QueryError - removed erroneous suspend
  • XmlToken and XmlToken.QualifiedName - renamed fields to improve readability

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

@aajtodd aajtodd requested a review from a team as a code owner February 26, 2024 14:06
writer.deserializeLoop(serdeCtx) { innerCtx ->
members.forEach { member ->
val name = member.getTrait<XmlNameTrait>()?.value ?: member.memberName
write("// ${member.memberName} ${escape(member.id.toString())}")
Copy link
Contributor

Choose a reason for hiding this comment

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

opinion; I don't think these member name comments are super useful, did you mean to include them or just used for debugging?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I meant to include them, I think they are helpful if you have to debug something it points you exactly to the model shape.

Comment on lines 73 to 75
return nextTok?.tagReader(reader).also { newScope ->
last = newScope
}
Copy link
Contributor

Choose a reason for hiding this comment

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

question: not questioning the correctness but why is last set to the newly created newScope reader rather than the old input reader?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's the last tag reader we dispensed via nextTag, we use it to ensure that when nextTag is invoked again that we have the correct state.

@@ -8,20 +8,20 @@ This project contains micro benchmarks for the serialization implementation(s).
./gradlew :runtime:serde:serde-benchmarks:jvmBenchmark
```

Baseline `0.7.8-beta` on EC2 **[m5.4xlarge](https://aws.amazon.com/ec2/instance-types/m5/)** in **OpenJK 1.8.0_312**:
Baseline on EC2 **[m5.4xlarge](https://aws.amazon.com/ec2/instance-types/m5/)** in **Corretto-17.0.10.8.1**:
Copy link
Contributor

Choose a reason for hiding this comment

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

We probably want to keep the SDK version here, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was going to but it's kind of a chicken and an egg problem. In a branch we aren't on a tagged version so we either guess what that version is going to be, use a commit sha/PR number, or just let the commit history speak for itself. I chose let the commit history speak for itself.

Comment on lines +65 to +67
// FIXME - this task up-to-date checks are wrong, likely something is not setup right with inputs/outputs somewhere
// for now just always run it
outputs.upToDateWhen { false }
Copy link
Contributor

Choose a reason for hiding this comment

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

question: is there a backlog task for this?

Comment on lines +269 to +271
// FIXME - https://github.com/awslabs/smithy-kotlin/issues/1040
// @Test
// fun testUnitField() { }
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this be uncommented and filled out? Even if failing, we can add an @Ignore and then ensure it passes once the bug is fixed

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The question is what to write as a test? Nothing we put here is going to be right at the moment.

Comment on lines 107 to 128
renderDeserializerBody(ctx, shape, members.toList(), writer)
writer.write("return value ?: throw #T(#S)", RuntimeTypes.Serde.DeserializationException, "Deserialized union value unexpectedly null: ${symbol.name}")
renderDeserializerBody(ctx, serdeCtx, shape, members.toList(), writer)
writer.write("return value ?: throw #T(#S)", Serde.DeserializationException, "Deserialized union value unexpectedly null: ${symbol.name}")
Copy link
Contributor

Choose a reason for hiding this comment

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

Style: I generally find non-top-level imports to be confusing and would rather read RuntimeTypes.Serde.DeserializationException than Serde.DeserializationException, even though the latter is shorter.

Comment on lines +66 to +71
var cand = nextToken()
while (cand != null && cand !is XmlToken.BeginElement) {
cand = nextToken()
}

val nextTok = cand as? XmlToken.BeginElement
Copy link
Contributor

Choose a reason for hiding this comment

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

Style: Might be simpler with a sequence:

val nextTok = generateSequence(::nextToken)
    .filterIsInstance<XmlToken.BeginElement>()
    .firstOrNull()

Copy link

sonarcloud bot commented Feb 28, 2024

Quality Gate Failed Quality Gate failed

Failed conditions
3.6% Duplication on New Code (required ≤ 3%)

See analysis details on SonarCloud

@@ -14,7 +14,7 @@ internal data class Ec2QueryErrorResponse(val errors: List<Ec2QueryError>, val r
internal data class Ec2QueryError(val code: String?, val message: String?)

@InternalApi
public fun parseEc2QueryErrorResponse(payload: ByteArray): ErrorDetails {
public suspend fun parseEc2QueryErrorResponse(payload: ByteArray): ErrorDetails {
Copy link
Contributor

Choose a reason for hiding this comment

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

question: this suspend is now unnecessary but I'm assuming you kept it for backwards-compatibility. should we also deprecate this suspend fun and create a new non-suspending function?

same for parseRestXmlErrorResponse

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We could I chose not to for now but don't feel strongly. Yes I kept it for binary compat, this wasn't necessary for some time I don't think since our deserializers haven't been suspend for a very long time.

@aajtodd aajtodd merged commit 4a20344 into main Feb 28, 2024
12 of 14 checks passed
@aajtodd aajtodd deleted the fix-xml-deserialize branch February 28, 2024 20:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants