This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-4-test by this push:
new 36e9a46b483 [v3-4-test] Java SDK: Send log timestamps in UTC (#74783)
(#75186)
36e9a46b483 is described below
commit 36e9a46b48383dde01bb0f525b8ffc9491b0ba9e
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Tue Oct 20 14:08:39 2026 +0530
[v3-4-test] Java SDK: Send log timestamps in UTC (#74783) (#75186)
* Java SDK: Send log timestamps in UTC
* Fix Java SDK log time zone and a supervisor crash on bad timestamps
---------
(cherry picked from commit 9091cd0c7b0cb2e957bfa8faf88f087b73d69ae2)
Signed-off-by: PoAn Yang <[email protected]>
Co-authored-by: PoAn Yang <[email protected]>
---
contributing-docs/30_new_language_sdk.rst | 12 ++++-
java-sdk/gradle/verification-metadata.xml | 13 -----
java-sdk/sdk/build.gradle.kts | 1 -
.../org/apache/airflow/sdk/execution/Logger.kt | 16 +++---
.../org/apache/airflow/sdk/execution/LogTest.kt | 62 ++++++++++++++++++++++
.../src/airflow/sdk/execution_time/supervisor.py | 10 +++-
.../task_sdk/execution_time/test_supervisor.py | 24 +++++++++
7 files changed, 114 insertions(+), 24 deletions(-)
diff --git a/contributing-docs/30_new_language_sdk.rst
b/contributing-docs/30_new_language_sdk.rst
index 86c65efc716..0be264e97c7 100644
--- a/contributing-docs/30_new_language_sdk.rst
+++ b/contributing-docs/30_new_language_sdk.rst
@@ -293,13 +293,21 @@ is a structlog-style event:
.. code-block:: text
- {"event": "Starting extraction", "level": "info", "logger":
"com.example.SalesPipeline", "timestamp": "2026-06-22T12:00:00", "rows": 42}
+ {"event": "Starting extraction", "level": "info", "logger":
"com.example.SalesPipeline", "timestamp": "2026-06-22T12:00:00Z", "rows": 42}
* ``event`` is the log message.
* ``level`` is the level **name** in lower case. The following levels are
supported: ``critical``, ``error``, ``warning``, ``info``, ``debug``, and
``notset``. A line carrying any other level is dropped.
-* ``timestamp`` is an ISO-8601 timestamp.
+* ``timestamp`` is an RFC 3339 timestamp with a UTC offset, such as
+ ``2026-06-22T12:00:00Z``. Airflow assumes that a timestamp without an offset
+ is in the time zone set by ``[core] default_timezone``, which is UTC by
+ default. If the worker's time zone differs from that setting, records without
+ an offset show the wrong time and appear out of order in the task log.
+ Include the seconds even when they are zero. The supervisor cannot parse a
+ timestamp without seconds, such as ``2026-06-22T12:00``. It moves that value
+ to an ``invalid_timestamp`` field and uses the time it received the record
+ instead.
* Any remaining keys are forwarded as structured fields on the log record.
See Python documentation on logging for details on log levels. Since log level
diff --git a/java-sdk/gradle/verification-metadata.xml
b/java-sdk/gradle/verification-metadata.xml
index c474c7afdf4..170dd07993b 100644
--- a/java-sdk/gradle/verification-metadata.xml
+++ b/java-sdk/gradle/verification-metadata.xml
@@ -2478,19 +2478,6 @@
<sha256
value="ff6a22da40040938751db0ae21177e76517dbf126a76796f5426727bf76c1228"
origin="Generated by Gradle"/>
</artifact>
</component>
- <component group="org.jetbrains.kotlinx" name="kotlinx-datetime"
version="0.8.0">
- <artifact name="kotlinx-datetime-0.8.0.module">
- <sha256
value="34793c0991afd4d6cd0df8737d5c380fb84da097147c70ecec1a1d68b06ccc63"
origin="Generated by Gradle"/>
- </artifact>
- </component>
- <component group="org.jetbrains.kotlinx" name="kotlinx-datetime-jvm"
version="0.8.0">
- <artifact name="kotlinx-datetime-jvm-0.8.0.jar">
- <sha256
value="ee44b51f8977c2f51d575299a4fc6782ab3700eba4cfc7fad548cced1543936a"
origin="Generated by Gradle"/>
- </artifact>
- <artifact name="kotlinx-datetime-jvm-0.8.0.module">
- <sha256
value="5fe52da3877cebe3eae765f9dea8758009a894d2441cdd6e8ac6fba538f171e7"
origin="Generated by Gradle"/>
- </artifact>
- </component>
<component group="org.jetbrains.kotlinx" name="kotlinx-html-jvm"
version="0.9.1">
<artifact name="kotlinx-html-jvm-0.9.1.jar">
<sha256
value="b68f39cd9ad7b46de0a0e77e7f5908d4e7661f3d0c85d2d9171543fcd5b156fb"
origin="Generated by Gradle"/>
diff --git a/java-sdk/sdk/build.gradle.kts b/java-sdk/sdk/build.gradle.kts
index 88226f5c57b..2ba31187168 100644
--- a/java-sdk/sdk/build.gradle.kts
+++ b/java-sdk/sdk/build.gradle.kts
@@ -59,7 +59,6 @@ dependencies {
implementation("com.xenomachina:kotlin-argparser:2.0.7")
implementation("io.ktor:ktor-network:3.6.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
- implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
implementation("org.msgpack:msgpack-core:0.9.12")
implementation("org.msgpack:jackson-dataformat-msgpack:0.9.12")
diff --git
a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Logger.kt
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Logger.kt
index 04757386aa4..975cc5ac768 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Logger.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Logger.kt
@@ -22,17 +22,14 @@ package org.apache.airflow.sdk.execution
import io.ktor.utils.io.ByteWriteChannel
import io.ktor.utils.io.writeString
import kotlinx.coroutines.runBlocking
-import kotlinx.datetime.LocalDateTime
-import kotlinx.datetime.TimeZone
-import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
+import java.time.Instant
import java.util.concurrent.ConcurrentLinkedDeque
import kotlin.reflect.KClass
-import kotlin.time.Clock
// Adapted from Python logging.
enum class Level(
@@ -179,7 +176,7 @@ internal data class LogMessage(
val arguments: Map<String, Any?>,
val logger: String,
val level: Level,
- val timestamp: LocalDateTime =
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()),
+ val timestamp: Instant = Instant.now(),
)
/**
@@ -245,6 +242,11 @@ internal object LogSender {
}
}
+ internal fun reset() {
+ writer = null
+ messages.clear()
+ }
+
private fun sendTo(
writer: ByteWriteChannel,
message: LogMessage,
@@ -253,7 +255,7 @@ internal object LogSender {
map["event"] = message.event
map["level"] = message.level.name.lowercase()
map["logger"] = message.logger
- map["timestamp"] = message.timestamp
+ map["timestamp"] = message.timestamp.toString()
// TODO: Can this be done asynchronously instead?
runBlocking { writer.writeString("${map.toJsonElement()}\n") }
}
@@ -270,5 +272,5 @@ private fun Any?.toJsonElement(): JsonElement =
is Number -> JsonPrimitive(this)
is String -> JsonPrimitive(this)
null -> JsonNull
- else -> JsonPrimitive(toString()) // Also correctly handles Kotlinx
DateTime.
+ else -> JsonPrimitive(toString())
}
diff --git
a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/LogTest.kt
b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/LogTest.kt
index e1d7ebf0f63..5bef1223f2d 100644
--- a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/LogTest.kt
+++ b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/LogTest.kt
@@ -19,11 +19,23 @@
package org.apache.airflow.sdk.execution
+import io.ktor.utils.io.ByteChannel
+import io.ktor.utils.io.readLine
+import kotlinx.coroutines.runBlocking
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Timeout
+import java.time.Instant
+import java.util.TimeZone
+import java.util.concurrent.TimeUnit
class LogTest {
@AfterEach
@@ -86,4 +98,54 @@ class LogTest {
// "foobar" is not a child of "foo"; it must fall back to the global level.
assertTrue(Log.isEnabledForLevel(Level.INFO, "foobar"))
}
+
+ @Test
+ @DisplayName("Should send the timestamp in UTC when the JVM runs in a
non-UTC time zone")
+ @Timeout(value = 30, unit = TimeUnit.SECONDS)
+ fun shouldSendTimestampInUtc() {
+ val defaultZone = TimeZone.getDefault()
+ try {
+ TimeZone.setDefault(TimeZone.getTimeZone("Asia/Taipei"))
+ val before = Instant.now()
+ val message = LogMessage("hello", emptyMap(), "test", Level.INFO)
+ val after = Instant.now()
+
+ val timestamp = sendAndRead(message)["timestamp"]!!.jsonPrimitive.content
+ assertTrue(timestamp.endsWith("Z"), "timestamp should be in UTC with a Z
suffix: $timestamp")
+ // The extra second on each side allows for the wall clock being
adjusted during the test. A local
+ // time sent as UTC would still be hours off.
+ assertTrue(
+ Instant.parse(timestamp) in
before.minusSeconds(1)..after.plusSeconds(1),
+ "timestamp should be when the message was created: $timestamp",
+ )
+ } finally {
+ TimeZone.setDefault(defaultZone)
+ }
+ }
+
+ @Test
+ @DisplayName("Should keep the seconds of a timestamp that falls on a whole
minute")
+ @Timeout(value = 30, unit = TimeUnit.SECONDS)
+ fun shouldKeepSecondsOnWholeMinute() {
+ val message = LogMessage("hello", emptyMap(), "test", Level.INFO,
Instant.parse("2026-10-13T09:41:00Z"))
+
+ // The supervisor cannot parse a timestamp without seconds, such as
2026-10-13T09:41.
+ assertEquals("2026-10-13T09:41:00Z",
sendAndRead(message)["timestamp"]!!.jsonPrimitive.content)
+ }
+
+ /** Sends [message] through [LogSender] and returns the JSON object that
[LogSender] writes for the message. */
+ private fun sendAndRead(message: LogMessage): JsonObject {
+ val channel = ByteChannel(autoFlush = true)
+ try {
+ LogSender.reset()
+ LogSender.configure(channel)
+ LogSender.send(message)
+ val line = runBlocking { Json.parseToJsonElement(channel.readLine()!!)
}.jsonObject
+ assertEquals(message.event, line["event"]!!.jsonPrimitive.content, "the
first line should be the message just sent")
+ return line
+ } finally {
+ // Other test classes read LogSender.messages, which LogSender fills
only while it has no channel.
+ LogSender.reset()
+ }
+ }
}
diff --git a/task-sdk/src/airflow/sdk/execution_time/supervisor.py
b/task-sdk/src/airflow/sdk/execution_time/supervisor.py
index 8b23068ee50..388075858bd 100644
--- a/task-sdk/src/airflow/sdk/execution_time/supervisor.py
+++ b/task-sdk/src/airflow/sdk/execution_time/supervisor.py
@@ -2907,7 +2907,15 @@ def process_log_messages_from_subprocess(
if ts := event.get("timestamp"):
# We use msgspec to decode the timestamp as it does it orders of
magnitude quicker than
# datetime.strptime cn
- event["timestamp"] = msgspec.json.decode(f'"{ts}"', type=datetime)
+ try:
+ event["timestamp"] = msgspec.json.decode(f'"{ts}"',
type=datetime)
+ except msgspec.DecodeError:
+ # The subprocess sends the timestamp field, and msgspec cannot
always parse its value. For
+ # example, user code can call self.log.info("...",
timestamp=1697190085).
+ # An uncaught DecodeError would stop the task supervisor
before it reports the
+ # task state, or stop the whole Dag processor during Dag
parsing. Moving the value to
+ # invalid_timestamp lets MaybeTimeStamper add the time the
supervisor received the line.
+ event["invalid_timestamp"] = event.pop("timestamp")
if exc := event.pop("exception", None):
# TODO: convert the dict back to a pretty stack trace
diff --git a/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
b/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
index ae5c2af97d7..12a76d25a1b 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
@@ -5548,6 +5548,30 @@ def
test_process_log_messages_from_subprocess(monkeypatch, caplog):
]
[email protected](
+ "bad_timestamp",
+ [
+ pytest.param(1697190085, id="epoch-number"),
+ pytest.param("2026-10-13T17:41", id="no-seconds"),
+ # msgspec raises a plain DecodeError (not a ValidationError) for the
newline, so this case
+ # fails if the except clause only catches ValidationError.
+ pytest.param("2026-10-13\n17:41:25", id="newline"),
+ ],
+)
+def test_process_log_messages_keeps_line_with_invalid_timestamp(bad_timestamp,
caplog, time_machine):
+ time_machine.move_to(timezone.datetime(2026, 10, 15, 8, 0), tick=False)
+
+ gen =
process_log_messages_from_subprocess(loggers=(structlog.get_logger(),))
+ next(gen)
+
+ gen.send(
+ msgspec.json.encode({"level": "info", "event": "Batch done",
"timestamp": bad_timestamp}) + b"\n"
+ )
+
+ assert caplog.messages == ["Batch done"]
+ assert {"timestamp": "2026-10-15T08:00:00Z", "invalid_timestamp":
bad_timestamp} in caplog
+
+
@pytest.mark.parametrize(
"error_message",
["write to closed file", "I/O operation on closed file"],