bmanan7 opened a new pull request, #70860:
URL: https://github.com/apache/airflow/pull/70860

   ### What's wrong
   
   When a task attempt's log is uploaded to Azure Blob Storage more than once, 
the same lines get
   stored again and again. A log that should be a few MB ends up being hundreds 
of MB, or GB, of the
   same content repeated.
   
   Two things combine to cause it:
   
   1. **The local log file is never emptied after it is uploaded.** 
`WasbRemoteLogIO.upload()` reads
      the whole local file and uploads it, then only deletes it if 
`delete_local_copy` is set, and
      `[logging] delete_local_logs` defaults to `False`:
   
      ```python
      log = local_loc.read_text()
      has_uploaded = self.write(log, remote_loc)
      if has_uploaded and self.delete_local_copy:
          shutil.rmtree(os.path.dirname(local_loc))
      ```
   
   2. **The log file is opened in append mode and never truncated.** 
`open("ab")` in the Task SDK
      supervisor, `NonCachingRotatingFileHandler` (default mode `"a"`) in 
`FileTaskHandler`, and
      `open(full_path, "a").close()` in `FileTaskHandler._init_file`.
   
   So if a second run of the *same attempt* happens on a worker that still has 
the log file, the file
   already contains the previous run's lines, and `write()` appends all of them 
to a blob that
   already has them.
   
   ### When it happens
   
   The common trigger is a **reschedule-mode sensor**. `UP_FOR_RESCHEDULE` does 
not increment
   `try_number`, so every poke writes to the same `attempt=N.log` key, and 
every poke is a separate
   worker process. With N pokes, poke 1's lines are stored N times, poke 2's 
N-1 times, and so on.
   
   It only happens when a later lifecycle reuses the same local log path. That 
is the case on a
   long-lived worker (Celery, Local) or with a shared logs volume, where the 
poke finds the log file
   already on disk. Where every lifecycle starts on a fresh filesystem, such as 
one pod per task with
   no shared volume, there is no duplication at all, though the 
read-modify-write cost below still
   applies. Retries are not affected either way, since `try_number` changes and 
each attempt gets its
   own key.
   
   ### What it costs
   
   A sensor in reschedule mode with the default `poke_interval` of 60 seconds, 
waiting 24 hours, is
   1,440 poke lifecycles. Measured with a minimal reschedule-mode sensor Dag on 
Airflow 3.3, one poke
   writes about 1 KB of log (real Dags write considerably more), so the honest 
log for that attempt is
   about 1.4 MiB:
   
   | | today | with this fix | change |
   |---|---|---|---|
   | log stored in the blob | **1,013 MiB** | **1.4 MiB** | -99.9% |
   | bytes uploaded | **476 GiB** | 1,013 MiB | -99.8% |
   | bytes downloaded | **475 GiB** | 1,012 MiB | -99.8% |
   | total network transfer | **950 GiB** | 2.0 GiB | -99.8% |
   | peak worker memory for the last upload | **~2 GiB** | ~3 MiB | -99.8% |
   | storage operations | 4,320 | 4,320 | unchanged |
   
   The stored blob is **720x larger** than the log it represents. Three 
practical consequences:
   
   - **Workers run out of memory.** Every upload holds the whole blob in memory 
to append to it. Near
     the end of a day-long sensor that is gigabytes on a worker sized for a 
sensor. This is not
     hypothetical: it is exactly how the identical S3 bug was found and 
reported in #67144.
   - **Logs become slow or impossible to read.** Opening that task's log in the 
UI downloads the whole
     blob, and what you get back is the same lines repeated hundreds of times, 
which is hard to read
     even when it loads.
   - **It costs money where blob traffic is metered.** Treat this as an 
illustration only, since prices
     vary by region, tier and networking setup. At $0.01/GB each way for data 
processed through an
     Azure private endpoint, that single sensor task moves about 1,020 GB a 
day, roughly **$10 per
     sensor task per day**, versus about **2 cents** after this fix. Ten such 
sensors over a month is
     $3,000 of traffic that carries nothing new. Deployments that talk to 
storage over a free path pay
     nothing extra in transfer, but still pay in stored bytes, worker memory 
and log-view latency.
   
   ### The fix
   
   The S3 handler had this exact bug, in this exact scenario, and fixed it in 
#67144. This applies the
   same two changes to the Azure handler, which has neither:
   
   1. Empty the local log file after a successful upload, so the next lifecycle 
only sends what is new.
      This intentionally matches what #67144 did for `S3RemoteLogIO`, rather 
than inventing a different
      mechanism for Azure, so the two handlers behave the same way.
   2. Only insert a newline separator when the existing blob does not already 
end with one. Without
      this, every appended segment also gains a blank line, and the WASB output 
differs from the S3
      output for the same input.
   
   #68304, a follow-up draft to the S3 fix, noted that "the Google and Azure 
object store logging may
   have a similar memory bug due to a download, append, upload process". This 
confirms it for Azure.
   The equivalent gap in the GCS handler is left for a separate PR.
   
   ### Not fixed here
   
   The handler still downloads the whole remote log and re-uploads it on every 
write, so transfer keeps
   growing with the square of the number of uploads even once duplication is 
gone (the 1,013 MiB /
   1,012 MiB column above). That needs appending instead of rewriting, which is 
a bigger change, so it
   is out of scope here.
   
   ### Tests
   
   Three behaviours, all of which fail if you revert the change:
   
   - `test_upload_repeated_appends_no_duplication`: three lifecycles appending 
to one local log; the
     blob must contain each line exactly once. Without the fix it contains
     `cycle 1\n\ncycle 1\ncycle 2\n\ncycle 1\ncycle 2\ncycle 3\n`.
   - `test_upload_truncates_local_log_only_after_successful_write`: 
parametrized on whether the upload
     succeeded. On success the local file is emptied; **on failure it is left 
alone** so the logs can
     still be retried. The failure case is the concern raised during review of 
#68304, so it is pinned
     rather than left implicit.
   - `test_write_on_existing_log_already_newline_terminated`: no extra blank 
line when the blob
     already ends in a newline. The existing `test_write_on_existing_log` still 
pins the other
     direction, and no existing test was modified.
   
   `uv run --project providers/microsoft/azure pytest 
providers/microsoft/azure/tests/unit/microsoft/azure/log/`
   gives 24 passed. `prek` pre-commit and manual stages pass on the changed 
files.
   
   ---
   
   ##### Was generative AI tooling used to co-author this PR?
   
   - [X] Yes (Claude Code, Opus 5)
   
   Generated-by: Claude Code (Opus 5) following [the 
guidelines](https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions)
   


-- 
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