Skip to content

Commit 754fb37

Browse files
committed
Java SDK: Check native Dag serialization against Airflow's
1 parent b58e27d commit 754fb37

6 files changed

Lines changed: 210 additions & 7 deletions

File tree

‎.pre-commit-config.yaml‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,23 @@ repos:
403403
^airflow-core/src/airflow/serialization/serialized_objects\.py$|
404404
^airflow-core/src/airflow/serialization/schema\.json$|
405405
^scripts/ci/lang_sdk_serialization/.*$
406+
- id: check-java-sdk-serialization-conformance
407+
name: Check the Java SDK serializes Dags the way Airflow does
408+
description: "Serialize the shared test Dags with the Java SDK and with Airflow, and compare the two"
409+
entry: ./java-sdk/scripts/ci/prek/check_serialization_conformance.py
410+
language: system
411+
pass_filenames: false
412+
require_serial: true
413+
files: >
414+
(?x)
415+
^airflow-core/src/airflow/serialization/serialized_objects\.py$|
416+
^airflow-core/src/airflow/serialization/schema\.json$|
417+
^java-sdk/scripts/ci/prek/check_serialization_conformance\.py$|
418+
^java-sdk/sdk/schema/dag-schema\.json$|
419+
^java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/(DagDef|Deps|Arg|TaskGroupRef)\.kt$|
420+
^java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Serde\.kt$|
421+
^java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/conformance/.*$|
422+
^scripts/ci/lang_sdk_serialization/.*$
406423
- id: check-go-version-in-sync
407424
name: Check Go toolchain version is consistent across build files
408425
entry: ./scripts/ci/prek/check_go_version_in_sync.py
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
#!/usr/bin/env python3
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
"""Check the Java SDK serializes Dags as Airflow does, with scripts/ci/lang_sdk_serialization/compare.py."""
19+
20+
from __future__ import annotations
21+
22+
import subprocess
23+
import sys
24+
from pathlib import Path
25+
26+
sys.path.insert(0, str(Path(__file__).resolve().parents[4] / "scripts" / "ci" / "prek"))
27+
28+
from common_prek_utils import AIRFLOW_ROOT_PATH
29+
30+
if __name__ not in ("__main__", "__mp_main__"):
31+
raise SystemExit(
32+
"This file is intended to be executed as an executable program. You cannot use it as a module."
33+
f"To run this script, run the ./{__file__} command"
34+
)
35+
36+
if __name__ == "__main__":
37+
java_sdk = AIRFLOW_ROOT_PATH / "java-sdk"
38+
classpath = (
39+
subprocess.run(
40+
[str(java_sdk / "gradlew"), "-p", str(java_sdk), "-q", ":sdk:printConformanceClasspath"],
41+
check=True,
42+
capture_output=True,
43+
text=True,
44+
)
45+
.stdout.strip()
46+
.splitlines()[-1]
47+
)
48+
compare = AIRFLOW_ROOT_PATH / "scripts" / "ci" / "lang_sdk_serialization" / "compare.py"
49+
serializer = ["java", "-cp", classpath, "org.apache.airflow.sdk.conformance.SerializeJavaKt"]
50+
command = [sys.executable, str(compare), "--sdk", "java", "--", *serializer]
51+
sys.exit(subprocess.run(command, check=False).returncode)

‎java-sdk/sdk/build.gradle.kts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -815,3 +815,12 @@ publishing {
815815
}
816816
}
817817
}
818+
819+
// Prints the classpath that runs the conformance serializer, for
820+
// java-sdk/scripts/ci/prek/check_serialization_conformance.py.
821+
tasks.register("printConformanceClasspath") {
822+
dependsOn("testClasses")
823+
// Capture early to keep compatibility to the Gradle configuration cache.
824+
val classpath = sourceSets.test.get().runtimeClasspath
825+
doLast { println(classpath.asPath) }
826+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
// Serializes the shared test Dags with this SDK, for
21+
// scripts/ci/lang_sdk_serialization/compare.py:
22+
//
23+
// java -cp <sdk test runtime classpath> org.apache.airflow.sdk.conformance.SerializeJavaKt \
24+
// scripts/ci/lang_sdk_serialization/test_dags.yaml serialized_java.json
25+
//
26+
// Each Dag is built with the interface API and written as the runtime answers
27+
// a parse request, keyed by Dag ID. A task's `upstream` becomes an ordering
28+
// edge, which a Java task serializes the same way as a data edge.
29+
package org.apache.airflow.sdk.conformance
30+
31+
import com.fasterxml.jackson.databind.JsonNode
32+
import com.fasterxml.jackson.databind.ObjectMapper
33+
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper
34+
import org.apache.airflow.sdk.Bundle
35+
import org.apache.airflow.sdk.Client
36+
import org.apache.airflow.sdk.Context
37+
import org.apache.airflow.sdk.DagDef
38+
import org.apache.airflow.sdk.Deps
39+
import org.apache.airflow.sdk.Task
40+
import org.apache.airflow.sdk.TaskGroupRef
41+
import org.apache.airflow.sdk.TaskRef
42+
import org.apache.airflow.sdk.execution.serializeDag
43+
import org.apache.airflow.sdk.internal.Field
44+
import org.apache.airflow.sdk.internal.FieldType
45+
import org.apache.airflow.sdk.internal.SchemaFields
46+
import java.io.File
47+
import java.time.Duration
48+
import java.time.OffsetDateTime
49+
50+
class ConformanceTask : Task {
51+
override fun execute(
52+
context: Context,
53+
client: Client,
54+
) = Unit
55+
}
56+
57+
fun main(args: Array<String>) {
58+
require(args.size == 2) { "usage: SerializeJava <test_dags.yaml> <output.json>" }
59+
val cases = YAMLMapper().readTree(File(args[0])).path("dags")
60+
val bundle = Bundle()
61+
cases.forEach { bundle.register(buildDag(it)) }
62+
val serialized =
63+
bundle.dags.values.associate { dag ->
64+
dag.id to mapOf("__version" to 3, "dag" to serializeDag(dag, "", "."))
65+
}
66+
ObjectMapper().writerWithDefaultPrettyPrinter().writeValue(File(args[1]), serialized)
67+
}
68+
69+
private fun buildDag(case: JsonNode): DagDef {
70+
val dag = DagDef(case.path("dag_id").asText())
71+
case.path("spec").fields().forEach { (key, value) -> dag.config(key, toValue(SchemaFields.DAG, key, value)) }
72+
73+
// A group ID is fully qualified, so its parent is whatever comes before the last dot.
74+
val groups = linkedMapOf<String, TaskGroupRef>()
75+
case.path("groups").forEach { node ->
76+
val groupId = node.asText()
77+
val parentId = groupId.substringBeforeLast('.', "")
78+
val localId = groupId.substringAfterLast('.')
79+
groups[groupId] = if (parentId.isEmpty()) dag.taskGroup(localId) else groups.getValue(parentId).taskGroup(localId)
80+
}
81+
82+
val tasks = linkedMapOf<String, TaskRef<Unit>>()
83+
case.path("tasks").forEach { task ->
84+
val groupId = task.path("group").asText("")
85+
val localId = task.path("task_id").asText()
86+
val ref =
87+
if (groupId.isEmpty()) {
88+
dag.task<Unit>(localId, ConformanceTask::class.java)
89+
} else {
90+
groups.getValue(groupId).task(localId, ConformanceTask::class.java)
91+
}
92+
task.path("spec").fields().forEach { (key, value) -> ref.config(key, toValue(SchemaFields.TASK, key, value)) }
93+
task.path("upstream").forEach { ref.after(tasks.getValue(it.asText())) }
94+
tasks[ref.def.id] = ref
95+
}
96+
97+
case.path("order_edges").forEach { edge ->
98+
val node = { id: String -> groups[id] as Deps.Flow? ?: tasks.getValue(id) }
99+
node(edge[0].asText()).before(node(edge[1].asText()))
100+
}
101+
return dag
102+
}
103+
104+
/** Reads a YAML value as the Java type the config key takes. */
105+
private fun toValue(
106+
table: Map<String, Field>,
107+
key: String,
108+
node: JsonNode,
109+
): Any =
110+
when (requireNotNull(table[key]) { "Unknown config key: '$key'" }.type) {
111+
FieldType.STRING -> node.asText()
112+
FieldType.BOOLEAN -> node.asBoolean()
113+
FieldType.INTEGER -> node.asInt()
114+
FieldType.NUMBER -> node.numberValue()
115+
// `!datetime` is an ISO 8601 timestamp, and `!timedelta` a number of seconds.
116+
FieldType.DATETIME -> OffsetDateTime.parse(node.asText())
117+
FieldType.TIMEDELTA -> Duration.ofNanos((node.asText().toDouble() * 1e9).toLong())
118+
FieldType.STRING_ARRAY -> node.map { it.asText() }
119+
}

‎scripts/ci/lang_sdk_serialization/compare.py‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@
3030
python3 scripts/ci/lang_sdk_serialization/compare.py --sdk typescript -- \
3131
pnpm --dir ts-sdk exec tsx tests/conformance/serialize_typescript.ts
3232
33+
Each SDK's own prek hook runs it this way, such as
34+
java-sdk/scripts/ci/prek/check_serialization_conformance.py for the Java SDK.
35+
3336
On a failure the files are kept, and their directory is printed.
3437
"""
3538

@@ -83,21 +86,25 @@ def get_task_defaults() -> dict[str, Any]:
8386
return {key: field["default"] for key, field in fields.items() if field.get("default") is not None}
8487

8588

86-
def normalize_as_javascript(value: Any) -> Any:
87-
"""Read a JSON value as JavaScript does: one number type, and a bool that is not a number."""
89+
def normalize_numbers(value: Any) -> Any:
90+
"""
91+
Read a JSON value with one number type, and a bool that is not a number.
92+
93+
An SDK may write ``2`` where Python writes ``2.0``, as JavaScript does, which has one number type.
94+
"""
8895
if isinstance(value, bool):
8996
return ("bool", value)
9097
if isinstance(value, (int, float)):
9198
return float(value)
9299
if isinstance(value, list):
93-
return [normalize_as_javascript(item) for item in value]
100+
return [normalize_numbers(item) for item in value]
94101
if isinstance(value, dict):
95-
return {key: normalize_as_javascript(item) for key, item in value.items()}
102+
return {key: normalize_numbers(item) for key, item in value.items()}
96103
return value
97104

98105

99106
def is_same_json(python: Any, sdk: Any) -> bool:
100-
return normalize_as_javascript(python) == normalize_as_javascript(sdk)
107+
return normalize_numbers(python) == normalize_numbers(sdk)
101108

102109

103110
def find_differences(path: str, python: Any, sdk: Any) -> list[str]:

‎scripts/ci/lang_sdk_serialization/test_dags.yaml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ dags:
6262
wait_for_past_depends_before_skipping: true
6363
wait_for_downstream: true
6464
retries: 2
65-
queue: typescript
65+
queue: workers
6666
pool: tiny
6767
pool_slots: 2
6868
execution_timeout: !timedelta 120
@@ -126,7 +126,7 @@ dags:
126126

127127
# Fan-out and fan-in, so downstream_task_ids has to be sorted rather than kept in wiring
128128
# order. The schedule is not written "@daily" because Python expands a preset before
129-
# serializing it, which serde.test.ts covers separately.
129+
# serializing it, which each SDK's own serializer tests cover separately.
130130
- dag_id: conformance_diamond
131131
spec:
132132
schedule: "0 0 * * *"

0 commit comments

Comments
 (0)