GitHub user zhanqian-zhang created a discussion: Request for feedback: task 
memory diagnostics design for Airflow 3

## Context and goal

Airflow 2.10 introduced task CPU and memory metrics in #39650.

These metrics are no longer emitted in Airflow 3. Their absence was reported in 
#49983, and #56690 later proposed restoring CPU and memory metrics but was not 
merged.

Before attempting another implementation, I would like to clarify what a task 
memory metric should actually mean.

The practical motivation is:

> Help users notice tasks whose memory behavior may be worth investigating or 
> optimizing.

There are multiple useful answers to that question, with substantially 
different semantic guarantees and implementation costs.

The three scopes below are **design alternatives / possible extensions, not an 
implementation commitment or a fixed roadmap**.

In particular, the smallest scope can provide positive diagnostic evidence when 
high memory is observed, but it cannot reliably rank task executions by 
historical peak.

---

## Scope 1: Python Task Runner RSS diagnostic

The smallest useful signal would follow the lightweight process-sampling idea 
from Airflow 2.10, but define the measurement much more narrowly.

For example:

```text
task.runner_rss_bytes
```

Meaning:

> Periodically sampled Resident Set Size of the Python Task Runner process.

The metric would use bytes rather than a percentage.

### Measurement boundary

Conceptually:

```text
Supervisor
    |
    +-- Python Task Runner   <-- measured
            |
            +-- task code
            +-- possible subprocesses
```

Only the Python Task Runner process itself is measured.

It would **not** mean:

- total memory consumed by the task workload;
- memory consumed by subprocesses;
- process-tree memory;
- container or Pod memory;
- host memory pressure;
- total memory of all concurrent executions of the same logical task;
- true execution peak memory.

This distinction is important because a task may delegate most of its work to 
Bash, another Python interpreter, Java, ffmpeg, or another local/remote process.

### Sampling lifecycle

I would not define this as "the entire runner lifetime", because some task 
identity and metric context becomes available only after part of startup has 
already occurred.

A more realistic contract is:

> Start resource observation once sufficient task identity is available in the 
> Python runner, and continue through finalization where feasible. The exact 
> start/stop boundaries should be defined by the implementation.

Observation failures must remain strictly best-effort and must never change 
task outcome, retries, deferral, or cleanup behavior.

### What this scope is good for

For Python-heavy workloads, runner RSS may still be a useful diagnostic for 
finding obviously large Python processes.

Its attraction is that it can remain relatively small:

- process RSS is cheap to read;
- it does not require cgroup support;
- it does not attempt workload accounting;
- it does not require introducing a general resource-aggregation subsystem.

However, this remains a small implementation **only if the export-identity 
problem below has a satisfactory solution**.

### Sampling limitation

Periodic sampling cannot guarantee peaks.

For example:

```text
0.0s    500 MiB
1.0s      8 GiB
2.0s    600 MiB
3.0s    exit
```

A five-second sampler can miss the entire execution, including the 8 GiB spike.

Therefore this signal could answer:

> "We observed this runner using a lot of memory."

It cannot reliably answer:

> "These are the top tasks by historical peak memory."

---

## The main unresolved issue for Scope 1: producer identity and concurrency

Suppose two executions of the same logical task run concurrently:

```text
dag_id=foo
task_id=transform

execution A: 900 MiB
execution B: 200 MiB
```

Useful aggregate values might be:

```text
current total = 1100 MiB
current max   = 900 MiB
active count  = 2
```

But two independent processes emitting the same logical metric do not 
automatically produce any of those meanings.

The problem is deeper than deciding whether users are willing to accept a 
non-aggregated metric.

If the two producers have the same metric-stream identity, the telemetry system 
may not have enough information to distinguish them correctly.

Conversely, adding an execution identifier such as `run_id`, PID, or execution 
UUID makes the producers unambiguous but introduces execution-level time-series 
cardinality and churn.

Different backends expose this problem differently. For example, OTel has 
explicit stream/resource identity considerations, while plain StatsD has much 
weaker identity/tag semantics.

I therefore see an unresolved Scope-1 design question:

> Can Airflow represent independent runner observations with a stable and 
> acceptable metric identity without creating execution-level cardinality?

If not, the apparently simple Scope 1 may not actually be the right 
abstraction, and aggregation before export may be preferable.

I think the semantic decision should come first; the exact StatsD/OTel 
implementation should follow from it.

---

## Scope 2: current logical-task aggregation

A stronger metric could answer:

> "How much runner memory are all currently active executions of this logical 
> task using?"

That requires aggregation before the low-cardinality metric is exported.

Conceptually:

```text
execution A ----+
                |
execution B ----+--> stable local aggregation owner
                |        |
execution C ----+        +-- group by (dag_id, task_id)
                         |
                         +-- SUM current runner RSS
                         +-- MAX current runner RSS
                         +-- COUNT active executions
```

Possible semantics would be:

```text
task.active_runner_rss_bytes
    = sum of current RSS of observed active runners

task.max_active_runner_rss_bytes
    = maximum current RSS among observed active runners

task.active_executions
    = number of known active executions
```

It may also be useful to distinguish:

```text
active_executions
observed_executions
```

For example, if three executions are known to be alive but RSS can only be read 
for two, reporting `active_executions = 2` would incorrectly redefine "active" 
as "successfully observed".

### Why a stable aggregation owner helps

The owner can maintain bounded execution-level state internally:

```text
execution identity
process identity
dag_id
task_id
```

without exporting those high-cardinality identities as metric dimensions.

This separates two concerns:

```text
execution identity
    -> local bounded state

logical task identity
    -> exported metric
```

That avoids solving writer identity by adding a new time series for every 
execution.

### Lifecycle cost

This stronger semantic requires significantly more lifecycle work:

- register/unregister;
- retries;
- defer/resume;
- unexpected process death;
- stale registrations;
- PID reuse;
- worker restart;
- collection racing with execution termination.

A PID alone may not be a sufficient long-lived process identity because PIDs 
can be reused.

### Executor implications

The metric contract could remain executor-independent, while the concrete 
aggregation owner may differ by execution architecture.

For example:

- a long-lived Celery worker domain is a plausible aggregation boundary for 
concurrent Celery executions;
- LocalExecutor may have a suitable long-lived management/parent boundary, 
which would need code-level validation;
- KubernetesExecutor is structurally different because an execution is 
typically associated with its own worker Pod, so there is no equivalent 
cross-execution owner inside that Pod.

A practical first implementation of this scope might therefore support only 
execution paths for which a correct aggregation owner has been identified.

Unsupported paths should not emit the same metric name with weaker semantics.

---

## Scope 3: execution-workload accounting

Scopes 1 and 2 still measure **runner processes**.

That can significantly underestimate workloads such as:

```text
Task Runner       300 MiB
   |
   +-- Python     700 MiB
   +-- Java       1.5 GiB
   +-- ffmpeg     2.0 GiB
```

If the intended question is instead:

> "How much local memory pressure did this task execution workload cause?"

then the measurement boundary has to change.

### Possible process-discovery approach

One possible approach is to discover processes associated with the execution, 
for example using parent/child relationships or an execution-related 
process-group boundary where one exists.

This could improve coverage of common subprocess workloads, but it should 
**not** be treated as complete workload accounting.

Limitations include:

- subprocesses may detach or otherwise escape the discovered hierarchy/group;
- another coordinator/runtime may use a different process model;
- simply summing process RSS double-counts shared resident pages;
- PSS offers different attribution semantics but is more expensive and 
platform-dependent.

So I would view process-group/process-tree inspection as:

> a possible process-discovery strategy with incomplete attribution,

not as a ready-made workload accounting model.

### Dedicated per-execution cgroup

A stronger Linux-specific design would establish a dedicated cgroup for an 
execution before its workload starts:

```text
worker
 |
 +-- execution A cgroup
 |     +-- runner
 |     +-- bash
 |     +-- ffmpeg
 |
 +-- execution B cgroup
       +-- runner
       +-- java
```

The kernel would then account memory against the execution resource group and 
its descendants.

Relevant cgroup-v2 information may include:

```text
memory.current
memory.stat
memory.peak
memory.events
```

A working-set-style value could also be considered if that is the desired 
semantic.

The advantage is not that cgroups provide one universally "correct" definition 
of memory. Shared memory, cache and charge ownership still have semantics that 
need documentation.

The major improvement is the boundary:

> memory is accounted to an execution workload rather than inferred from one 
> selected process.

### Per-execution peaks

A dedicated cgroup may also provide a better way to capture short-lived memory 
peaks than periodic polling.

For example, `memory.peak` can retain a peak that occurred between sampling 
intervals.

However, this still requires correct lifecycle handling:

> a surviving observer must read and export the peak before the execution 
> cgroup is removed.

A naive "emit one peak observation when the task completes successfully" design 
would be insufficient because failed, killed, or OOM executions are precisely 
some of the most important cases.

Peak collection therefore needs a lifecycle owner that can survive long enough 
to observe abnormal termination as well.

If this can be made reliable, a separate historical metric could represent one 
peak observation per observed execution, potentially enabling distributions 
such as P50/P95/P99 peak memory.

That is different from periodically putting RSS samples into a Histogram:

```text
periodic RSS histogram
    -> distribution of samples

per-execution peak histogram
    -> distribution of execution peaks
```

The latter is much closer to historical memory-sizing analysis.

### Platform cost

A dedicated cgroup design is much more invasive and platform-dependent.

It would require answers around:

- cgroup v2 availability;
- delegation/permissions;
- running inside containers;
- lifecycle and cleanup;
- unsupported platforms;
- abnormal execution termination.

It should not silently fall back to runner RSS under the same metric name when 
cgroup accounting is unavailable.

---

## These scopes should not silently share one semantic

The following values answer different questions:

```text
runner RSS

aggregate RSS of active runners

process-tree/process-group approximation

execution-cgroup memory
```

They should not all be hidden behind a generic metric such as:

```text
task.memory_usage
```

if the meaning changes depending on executor or platform.

Similarly, changing a published metric from:

```text
one runner observation
```

to:

```text
sum of all active runners
```

would change dashboard and alert semantics.

If more than one scope is eventually useful, separate metrics or an explicit 
migration strategy would be safer.

---

## Questions for the community

The main questions I would like feedback on are:

1. **Is a narrowly defined Python Task Runner RSS diagnostic useful in Airflow 
core at all?**

2. **If so, what producer/export identity would make independent concurrent 
runner observations well-defined without introducing unacceptable 
execution-level cardinality?**

3. **Should a `(dag_id, task_id)` memory metric be expected to represent 
correctly aggregated concurrent state from its first introduction?**  
   If yes, the aggregation-owner design may be the appropriate minimum scope.

4. **Longer term, is there value in a distinct execution-workload memory signal 
in addition to runner diagnostics?**

5. **For workload-level accounting, is an Airflow-managed per-execution cgroup 
something worth investigating, or is that responsibility better left to the 
deployment/container layer?**

My current preference is to keep any first contribution as small as possible, 
but not at the cost of publishing a metric whose identity or semantics are 
ambiguous.

GitHub link: https://github.com/apache/airflow/discussions/73623

----
This is an automatically sent email for [email protected].
To unsubscribe, please send an email to: [email protected]

Reply via email to