mifuha opened a new issue, #73382:
URL: https://github.com/apache/airflow/issues/73382

   ### What happened
   
   SFTP sensors can make the wrong decision about `newer_than` when the process 
timezone and Airflow's default timezone differ. A file can be accepted even 
though it is too old, or rejected even though it is new enough.
   
   I reproduced this in the single-file `SFTPTrigger` path and in the 
synchronous `SFTPSensor`, both with and without a file pattern. The 
reproduction uses the real hook conversions and sensor/trigger code, with SFTP 
I/O mocked.
   
   The file timestamp is `1704110400`, which is **2024-01-01 12:00 UTC**. All 
thresholds below are on that date and include a UTC offset. All three paths 
gave these results:
   
   - Process timezone UTC, Airflow default America/New_York:
     Threshold 14:00 UTC. Expected: reject. Actual: accept.
   
   - Process timezone America/New_York, Airflow default UTC:
     Threshold 10:00 UTC. Expected: accept. Actual: reject.
   
   - Both timezones UTC:
     Threshold 10:00 UTC. Expected: accept. Actual: accept.
   
   - Both timezones UTC:
     Threshold 14:00 UTC. Expected: reject. Actual: reject.
   
   ### Expected behavior
   
   The same file timestamp and timezone-aware threshold should give the same 
result regardless of these timezone settings. A file whose modification time 
equals the threshold should still be accepted.
   
   ### Cause
   
   Both `get_mod_time()` methods turn the timestamp into a local-time string 
without an offset. The sensor/trigger then interprets that string using 
Airflow's default timezone.
   
   For example, with the process timezone set to UTC and Airflow's default set 
to America/New_York, 12:00 UTC becomes a string representing 12:00. That is 
then interpreted as 12:00 New York time, or 17:00 UTC on this date.
   
   Relevant source: [hook 
conversions](https://github.com/apache/airflow/blob/224d34121c877c21e8dee97520b05c02f578a839/providers/sftp/src/airflow/providers/sftp/hooks/sftp.py),
 [sensor 
comparison](https://github.com/apache/airflow/blob/224d34121c877c21e8dee97520b05c02f578a839/providers/sftp/src/airflow/providers/sftp/sensors/sftp.py#L114-L145),
 [trigger 
comparison](https://github.com/apache/airflow/blob/224d34121c877c21e8dee97520b05c02f578a839/providers/sftp/src/airflow/providers/sftp/triggers/sftp.py#L87-L125).
   
   ### Reproduction
   
   Tested at main commit `224d34121c877c21e8dee97520b05c02f578a839`, using its 
locked dependencies. Save the script below as `dev/sftp_timezone_probe.py` in 
that checkout, then run:
   
   ```bash
   uv sync --frozen --project providers/sftp --python 3.12
   export AIRFLOW_HOME=/tmp/airflow-sftp-probe-home
   export _AIRFLOW_PROCESS_CONTEXT=client
   TZ=UTC AIRFLOW__CORE__DEFAULT_TIMEZONE=America/New_York uv run --frozen 
--project providers/sftp python dev/sftp_timezone_probe.py
   TZ=America/New_York AIRFLOW__CORE__DEFAULT_TIMEZONE=UTC uv run --frozen 
--project providers/sftp python dev/sftp_timezone_probe.py
   TZ=UTC AIRFLOW__CORE__DEFAULT_TIMEZONE=UTC uv run --frozen --project 
providers/sftp python dev/sftp_timezone_probe.py
   ```
   
   Each command starts a fresh process with normal Airflow initialization. The 
script prints the effective timezones and the expected and actual decisions. 
Exit code zero means the script completed, not that the decisions were correct.
   
   For the trigger, the script stops after the first unsuccessful poll by 
raising `asyncio.CancelledError` at the polling sleep. No SFTP server or 
credentials are needed.
   
   Reproduction script:
   
   ```python
   # Licensed to the Apache Software Foundation (ASF) under one
   # or more contributor license agreements.  See the NOTICE file
   # distributed with this work for additional information
   # regarding copyright ownership.  The ASF licenses this file
   # to you under the Apache License, Version 2.0 (the
   # "License"); you may not use this file except in compliance
   # with the License.  You may obtain a copy of the License at
   #
   #   http://www.apache.org/licenses/LICENSE-2.0
   #
   # Unless required by applicable law or agreed to in writing,
   # software distributed under the License is distributed on an
   # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
   # KIND, either express or implied.  See the License for the
   # specific language governing permissions and limitations
   # under the License.
   # /// script
   # requires-python = ">=3.10"
   # ///
   from __future__ import annotations
   
   import asyncio
   import calendar
   import datetime
   import json
   import os
   import platform
   import stat
   import time
   from importlib.metadata import version
   from unittest import mock
   
   import asyncssh
   import paramiko
   
   from airflow import settings
   from airflow.providers.common.compat.sdk import Connection, timezone
   from airflow.providers.sftp.hooks.sftp import SFTPHook, SFTPHookAsync
   from airflow.providers.sftp.sensors.sftp import SFTPSensor
   from airflow.providers.sftp.triggers.sftp import SFTPTrigger
   
   MTIME = 1704110400
   
   
   @mock.patch(
       "airflow.providers.sftp.triggers.sftp.asyncio.sleep", autospec=True, 
side_effect=asyncio.CancelledError
   )
   @mock.patch.object(SFTPHookAsync, "_get_conn", autospec=True)
   async def inspect_async_path(get_conn, sleep, threshold):
       client = mock.AsyncMock(spec=asyncssh.SFTPClient)
       client.stat.return_value = asyncssh.SFTPAttrs(mtime=MTIME)
       sftp_cm = mock.MagicMock()
       sftp_cm.__aenter__ = mock.AsyncMock(return_value=client)
       ssh = mock.MagicMock(spec=asyncssh.SSHClientConnection)
       ssh.__aenter__.return_value = ssh
       ssh.start_sftp_client.return_value = sftp_cm
       get_conn.return_value = ssh
       mod_time = await SFTPHookAsync().get_mod_time("/files/file.txt")
       trigger = SFTPTrigger(path="/files/file.txt", newer_than=threshold)
       generator = trigger.run()
       try:
           try:
               event = await anext(generator)
           except asyncio.CancelledError:
               event = None
           if event is None:
               sleep.assert_awaited_once_with(trigger.poke_interval)
           else:
               if event.payload["status"] != "success":
                   raise AssertionError(event)
           if client.stat.await_count != 2 or ssh.__aexit__.await_count != 2:
               raise AssertionError("Expected two stat calls and two closed 
connection contexts")
           return {"mod_time": mod_time, "accepted": event is not None}
       finally:
           await generator.aclose()
   
   
   @mock.patch.object(SFTPHook, "get_connection", spec=True)
   @mock.patch.object(SFTPHook, "get_managed_conn", autospec=True)
   def inspect_sync_paths(managed_conn, get_connection, threshold):
       get_connection.return_value = Connection(conn_id="sftp_default", 
conn_type="sftp", host="mock.invalid")
       attrs = paramiko.SFTPAttributes()
       attrs.st_mtime = MTIME
       attrs.st_mode = stat.S_IFREG | 0o644
       attrs.filename = "file.txt"
       client = mock.MagicMock(spec=paramiko.SFTPClient)
       client.stat.return_value = attrs
       client.listdir_attr.return_value = [attrs]
       managed_conn.return_value.__enter__.return_value = client
       hook = SFTPHook()
       result = {"mod_time": hook.get_mod_time("/files/file.txt")}
       for pattern in ("", "*.txt"):
           sensor = SFTPSensor(
               task_id="mtime_probe",
               path="/files" if pattern else "/files/file.txt",
               file_pattern=pattern,
               newer_than=threshold,
           )
           sensor.hook = hook
           result["pattern" if pattern else "single_file"] = {
               "get_files": sensor._get_files(),
               "poke": sensor.poke({}),
           }
       return result
   
   
   def main():
       time.tzset()
       if calendar.timegm((2024, 1, 1, 12, 0, 0)) != MTIME:
           raise AssertionError("Unexpected epoch timestamp")
       packages = [
           "apache-airflow",
           "apache-airflow-core",
           "apache-airflow-task-sdk",
           "apache-airflow-providers-sftp",
           "apache-airflow-providers-ssh",
           "apache-airflow-providers-common-compat",
           "asyncssh",
           "paramiko",
           "pendulum",
           "python-dateutil",
           "pytest",
           "pytest-asyncio",
           "tzdata",
       ]
       result = {
           "python": platform.python_version(),
           "versions": {package: version(package) for package in packages},
           "process_tz": os.environ.get("TZ"),
           "process_time": time.strftime("%Y-%m-%dT%H:%M:%S%z", 
time.localtime(MTIME)),
           "core_timezone": str(settings.TIMEZONE),
           "provider_timezone": str(timezone.datetime(2024, 1, 1).tzinfo),
           "helper_module": timezone.convert_to_utc.__module__,
           "helper_file": __import__(timezone.convert_to_utc.__module__, 
fromlist=[""]).__file__,
           "source": SFTPTrigger.run.__code__.co_filename,
           "library_optout": os.environ.get("_AIRFLOW__AS_LIBRARY"),
           "unit_test_mode": os.environ.get("AIRFLOW__CORE__UNIT_TEST_MODE"),
           "process_context": os.environ.get("_AIRFLOW_PROCESS_CONTEXT"),
           "cases": [],
       }
       for hour in (10, 14):
           threshold = datetime.datetime(2024, 1, 1, hour, 
tzinfo=datetime.timezone.utc)
           result["cases"].append(
               {
                   "threshold": threshold.isoformat(),
                   "expected": hour == 10,
                   "sync": inspect_sync_paths(threshold=threshold),
                   "async_single_file": 
asyncio.run(inspect_async_path(threshold=threshold)),
               }
           )
       print(json.dumps(result, indent=2))
   
   
   if __name__ == "__main__":
       main()
   ```
   
   ### Environment
   
   - Ubuntu 24.04.4 under WSL2; Python 3.12.3.
   - Airflow/core 3.4.0 and Task SDK 1.4.0, from the checkout above.
   - SFTP 6.0.1, SSH 6.0.1, common.compat 1.19.0.
   - AsyncSSH 2.24.0, Paramiko 5.0.0, Pendulum 3.2.0.
   - Tested in a local virtualenv; no live Airflow deployment was tested.
   
   ### Possible fix
   
   The comparison needs a raw timestamp or a timezone-aware datetime, while 
keeping compatibility with the existing `get_mod_time()` string APIs. Changing 
only the strings to UTC would still leave callers interpreting them in 
Airflow's default timezone.
   
   I also tested a local patch for the separate pattern-based trigger branch, 
where the raw timestamp is already available. That patch converts the timestamp 
directly to an aware UTC datetime. It does not change the three paths reported 
here, and all three still reproduced the results above with that patch applied.
   
   I checked open and closed issues and PRs and did not find an equivalent 
report or fix. #65442 changes timezone imports; #73312 changes callback 
behavior.
   
   — @mifuha
   
   ---
   
   AI assistance: I used Codex to help investigate this issue and prepare the 
reproduction script


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to