1fanwang opened a new pull request, #73931:
URL: https://github.com/apache/airflow/pull/73931
A client can't change just the slots of a pool. `PATCH
/api/v2/pools/{pool_name}?update_mask=slots` with `{"slots": 8}` returns 422
because the pool name and `include_deferred` are still required. A bulk update
with the same mask fails too, and only `default_pool` honours the mask. So
clients resend `include_deferred` every time, and `airflowctl pools update
--pool etl --slots 16` sends false, which turns deferred slots off on a pool
that had them on.
With this change, a PATCH or bulk update with a mask needs only the masked
fields and keeps the rest. Masked fields are still validated (`{"slots": null}`
is still a 422), and a PATCH without a mask still needs the full body.
The service validated the body against the full pool schema before applying
the mask. Now it validates only the fields it will write and takes the other
required ones from the stored pool.
closes: https://github.com/apache/airflow/issues/73768
related: https://github.com/apache/airflow/pull/71220 edits the same
validation block. They are compatible; the second to merge needs a rebase there.
### Testing Done
The reproducer below drives a real API server (SQLite, simple auth manager)
run with `airflow` from `uv sync --project airflow-core`, once at main 18fe1c6
and once at this branch.
Before, main at 18fe1c6:
```
$ bash pool-mask-e2e.sh .venv/bin/airflow
$ airflow version # the server
3.4.0
$ curl -X POST http://127.0.0.1:64263/api/v2/pools -d '{"name": "etl",
"slots": 4, "description": "nightly loads", "include_deferred": true}'
{"name":"etl","slots":4,"description":"nightly
loads","include_deferred":true,"occupied_slots":0,"running_slots":0,"queued_slots":0,"scheduled_slots":0,"open_slots":4,"deferred_slots":0,"team_name":null}
HTTP 201
$ curl -X PATCH 'http://127.0.0.1:64263/api/v2/pools/etl?update_mask=slots'
-d '{"slots": 8}'
{"detail":[{"type":"missing","loc":["pool"],"msg":"Field
required","input":{"slots":8},"url":"https://errors.pydantic.dev/2.13/v/missing"},{"type":"missing","loc":["include_deferred"],"msg":"Field
required","input":{"slots":8},"url":"https://errors.pydantic.dev/2.13/v/missing"}]}
HTTP 422
$ curl -X PATCH http://127.0.0.1:64263/api/v2/pools -d '{"actions":
[{"action": "update", "entities": [{"name": "etl", "slots": 12}],
"update_mask": ["slots"]}]}'
{"detail":[{"type":"missing","loc":["include_deferred"],"msg":"Field
required","input":{"slots":12,"pool":"etl"},"url":"https://errors.pydantic.dev/2.13/v/missing"}]}
HTTP 422
$ curl http://127.0.0.1:64263/api/v2/pools/etl
{"name":"etl","slots":4,"description":"nightly
loads","include_deferred":true,"occupied_slots":0,"running_slots":0,"queued_slots":0,"scheduled_slots":0,"open_slots":4,"deferred_slots":0,"team_name":null}
HTTP 200
$ curl -X PATCH 'http://127.0.0.1:64263/api/v2/pools/etl?update_mask=slots'
-d '{"slots": null}' # a masked field is still validated
{"detail":[{"type":"missing","loc":["pool"],"msg":"Field
required","input":{"slots":null},"url":"https://errors.pydantic.dev/2.13/v/missing"},{"type":"int_type","loc":["slots"],"msg":"Input
should be a valid
integer","input":null,"url":"https://errors.pydantic.dev/2.13/v/int_type"},{"type":"missing","loc":["include_deferred"],"msg":"Field
required","input":{"slots":null},"url":"https://errors.pydantic.dev/2.13/v/missing"}]}
HTTP 422
$ curl -X PATCH http://127.0.0.1:64263/api/v2/pools/etl -d '{"slots": 8}'
# without update_mask the full body is still required
{"detail":[{"type":"missing","loc":["pool"],"msg":"Field
required","input":{"slots":8},"url":"https://errors.pydantic.dev/2.13/v/missing"},{"type":"missing","loc":["include_deferred"],"msg":"Field
required","input":{"slots":8},"url":"https://errors.pydantic.dev/2.13/v/missing"}]}
HTTP 422
```
After, this branch:
```
$ bash pool-mask-e2e.sh .venv/bin/airflow
$ airflow version # the server
3.4.0
$ curl -X POST http://127.0.0.1:64318/api/v2/pools -d '{"name": "etl",
"slots": 4, "description": "nightly loads", "include_deferred": true}'
{"name":"etl","slots":4,"description":"nightly
loads","include_deferred":true,"occupied_slots":0,"running_slots":0,"queued_slots":0,"scheduled_slots":0,"open_slots":4,"deferred_slots":0,"team_name":null}
HTTP 201
$ curl -X PATCH 'http://127.0.0.1:64318/api/v2/pools/etl?update_mask=slots'
-d '{"slots": 8}'
{"name":"etl","slots":8,"description":"nightly
loads","include_deferred":true,"occupied_slots":0,"running_slots":0,"queued_slots":0,"scheduled_slots":0,"open_slots":8,"deferred_slots":0,"team_name":null}
HTTP 200
$ curl -X PATCH http://127.0.0.1:64318/api/v2/pools -d '{"actions":
[{"action": "update", "entities": [{"name": "etl", "slots": 12}],
"update_mask": ["slots"]}]}'
{"create":null,"update":{"success":["etl"],"errors":[]},"delete":null}
HTTP 200
$ curl http://127.0.0.1:64318/api/v2/pools/etl
{"name":"etl","slots":12,"description":"nightly
loads","include_deferred":true,"occupied_slots":0,"running_slots":0,"queued_slots":0,"scheduled_slots":0,"open_slots":12,"deferred_slots":0,"team_name":null}
HTTP 200
$ curl -X PATCH 'http://127.0.0.1:64318/api/v2/pools/etl?update_mask=slots'
-d '{"slots": null}' # a masked field is still validated
{"detail":[{"type":"int_type","loc":["slots"],"msg":"Input should be a valid
integer","input":null,"url":"https://errors.pydantic.dev/2.13/v/int_type"}]}
HTTP 422
$ curl -X PATCH http://127.0.0.1:64318/api/v2/pools/etl -d '{"slots": 8}'
# without update_mask the full body is still required
{"detail":[{"type":"missing","loc":["pool"],"msg":"Field
required","input":{"slots":8},"url":"https://errors.pydantic.dev/2.13/v/missing"},{"type":"missing","loc":["include_deferred"],"msg":"Field
required","input":{"slots":8},"url":"https://errors.pydantic.dev/2.13/v/missing"}]}
HTTP 422
```
<details>
<summary>Reproducer source: pool-mask-e2e.sh</summary>
```bash
#!/usr/bin/env bash
# Partial pool PATCH requests with and without update_mask against a real
Airflow API server (SQLite, simple auth manager).
# Usage: pool-mask-e2e.sh <airflow executable for the server>
set -u
AIRFLOW=${1:?usage: pool-mask-e2e.sh <airflow>}
PORT=$(python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1",
0)); print(s.getsockname()[1])')
URL="http://127.0.0.1:$PORT"
SERVER_HOME="$(mktemp -d)"
SERVER_ENV=(
AIRFLOW_HOME="$SERVER_HOME"
AIRFLOW__CORE__LOAD_EXAMPLES=False
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_USERS=admin:admin
)
say() { printf '\n$ %s\n' "$1"; }
env "${SERVER_ENV[@]}" "$AIRFLOW" db migrate > "$SERVER_HOME/setup.log" 2>&1
|| { cat "$SERVER_HOME/setup.log"; exit 1; }
env "${SERVER_ENV[@]}" "$AIRFLOW" api-server --port "$PORT" --workers 1 >
"$SERVER_HOME/api-server.log" 2>&1 &
SERVER_PID=$!
trap 'kill "$SERVER_PID" 2>/dev/null; wait "$SERVER_PID" 2>/dev/null; rm -rf
"$SERVER_HOME"' EXIT
PASSWORDS="$SERVER_HOME/simple_auth_manager_passwords.json.generated"
for _ in $(seq 90); do
curl -sf "$URL/api/v2/monitor/health" > /dev/null && [ -s "$PASSWORDS" ]
&& break
sleep 1
done
PASSWORD=$(python3 -c 'import json, sys;
print(json.load(open(sys.argv[1]))["admin"])' "$PASSWORDS")
TOKEN=$(curl -s -X POST "$URL/auth/token" -H 'Content-Type:
application/json' \
-d "{\"username\": \"admin\", \"password\": \"$PASSWORD\"}" | python3 -c
'import json, sys; print(json.load(sys.stdin)["access_token"])')
api() { curl -s -w '\nHTTP %{http_code}\n' -H "Authorization: Bearer $TOKEN"
-H 'Content-Type: application/json' "$@"; }
say 'airflow version # the server'
env "${SERVER_ENV[@]}" "$AIRFLOW" version 2> /dev/null
CREATE='{"name": "etl", "slots": 4, "description": "nightly loads",
"include_deferred": true}'
say "curl -X POST $URL/api/v2/pools -d '$CREATE'"
api -X POST "$URL/api/v2/pools" -d "$CREATE"
say "curl -X PATCH '$URL/api/v2/pools/etl?update_mask=slots' -d '{\"slots\":
8}'"
api -X PATCH "$URL/api/v2/pools/etl?update_mask=slots" -d '{"slots": 8}'
BULK='{"actions": [{"action": "update", "entities": [{"name": "etl",
"slots": 12}], "update_mask": ["slots"]}]}'
say "curl -X PATCH $URL/api/v2/pools -d '$BULK'"
api -X PATCH "$URL/api/v2/pools" -d "$BULK"
say "curl $URL/api/v2/pools/etl"
api "$URL/api/v2/pools/etl"
say "curl -X PATCH '$URL/api/v2/pools/etl?update_mask=slots' -d '{\"slots\":
null}' # a masked field is still validated"
api -X PATCH "$URL/api/v2/pools/etl?update_mask=slots" -d '{"slots": null}'
say "curl -X PATCH $URL/api/v2/pools/etl -d '{\"slots\": 8}' # without
update_mask the full body is still required"
api -X PATCH "$URL/api/v2/pools/etl" -d '{"slots": 8}'
```
</details>
---
##### Was generative AI tooling used to co-author this PR?
- [X] Yes (please specify the tool below)
Generated-by: GitHub Copilot CLI (Claude Opus 5.5) following [the
guidelines](https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions)
---
* Read the **[Pull Request
Guidelines](https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#pull-request-guidelines)**
for more information. Note: commit author/co-author name and email in commits
become permanently public when merged.
* For fundamental code changes, an Airflow Improvement Proposal
([AIP](https://cwiki.apache.org/confluence/display/AIRFLOW/Airflow+Improvement+Proposals))
is needed.
* When adding dependency, check compliance with the [ASF 3rd Party License
Policy](https://www.apache.org/legal/resolved.html#category-x).
* For significant user-facing changes create newsfragment:
`{pr_number}.significant.rst`, in
[airflow-core/newsfragments](https://github.com/apache/airflow/tree/main/airflow-core/newsfragments).
You can add this file in a follow-up commit after the PR is created so you
know the PR number.
--
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]