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

   ### Apache Airflow version
   
   3.2.0+ (root cause is migration `0094`, `airflow_version = "3.2.0"`; buggy 
code is still present on `main`)
   
   ### What happened
   
   On a deployment using **Deadline Alerts** with an `AsyncCallback` whose 
`kwargs` contain a **nested dict**, the **scheduler enters CrashLoopBackOff**. 
Every scheduler loop crashes while deserializing an associated `callback` row 
in the deadline-processing query:
   
   ```
   File ".../airflow/jobs/scheduler_job_runner.py", line 1808, in 
_run_scheduler_loop
       for deadline in session.scalars( ... selectinload(Deadline.callback) ... 
)
   File ".../airflow/utils/sqlalchemy.py", line 221, in process_result_value
       return BaseSerialization.deserialize(value)
   File ".../airflow/serialization/serialized_objects.py", line 632, in 
deserialize
       return {k: cls.deserialize(v) for k, v in var.items()}
   File ".../airflow/serialization/serialized_objects.py", line 629, in 
deserialize
       var = encoded_var[Encoding.VAR]
   KeyError: <Encoding.VAR: '__var'>
   ```
   
   The crash happens before the scheduler heartbeats, so it presents as failing 
liveness probes / restarts with no OOM and no other logged exception — easy to 
misdiagnose as a probe or DB problem.
   
   ### Root cause
   
   Migration **`0094_3_2_0_replace_deadline_inline_callback_with_fkey.py`** 
(revision `e812941398f4`) moves the old inline deadline callback into the 
`callback` table. `callback.data` is an `ExtendedJSON` column, whose read path 
runs `BaseSerialization.deserialize` (`utils/sqlalchemy.py`), which requires 
**every nested dict to be wrapped** as `{"__type": "dict", "__var": {...}}`.
   
   But the migration hand-builds `callback.data` and only wraps the **top 
level**, embedding the old callback's `kwargs` **raw**:
   
   ```sql
   -- _upgrade_postgresql(): raw nested kwargs pulled from the old inline 
(SDK-serde) callback
   COALESCE(NULLIF(d.callback::jsonb->'__data__'->'kwargs', 'null'::jsonb), 
'{}'::jsonb) AS cb_kwargs
   ...
   INSERT INTO callback (... data ...)
   SELECT ...
     json_build_object(
         '__var', json_build_object(
             'path', b.cb_path,
             'kwargs', b.cb_kwargs,      -- <-- raw nested dict, NOT 
extended-serialized
             'prefix', :prefix,
             'dag_id', b.dag_id),
         '__type', 'dict')::json
   ```
   
   (`_upgrade_mysql_sqlite()` builds the same shape in Python.) The insert is 
done via Core (`callback_table.insert()` / `sa.text`), which also bypasses 
`ExtendedJSON.process_bind_param`, so `BaseSerialization.serialize` never runs.
   
   Result: `callback.data` = `{"__type":"dict","__var":{"path":..., "kwargs": 
{<bare nested object>}, ...}}`. On read, `deserialize` recurses into the outer 
`DAT.DICT`, reaches the bare `kwargs` (or a nested dict inside it such as 
metric `tags`), executes `var = encoded_var[Encoding.VAR]`, and throws 
`KeyError('__var')`.
   
   **Trigger:** callback `kwargs` containing a nested dict. Callbacks with only 
flat/primitive kwargs survive because the single top-level wrap is enough.
   
   ### What you think should happen instead
   
   The migration should produce the same encoding `BaseSerialization.serialize` 
would — i.e. **recursively** wrap nested dicts (`kwargs`, and any dict within 
it) as `{"__type":"dict","__var":{...}}` — so the resulting `callback.data` 
round-trips through `BaseSerialization.deserialize`.
   
   Because `0094` has already shipped (3.2.0) and run on live deployments, a 
**forward repair migration** is also needed to re-encode already-corrupted 
`callback.data` rows (deployments that ran `0094` with any nested-dict callback 
kwargs currently have a latent scheduler crash that fires when such a deadline 
becomes due).
   
   ### How to reproduce
   
   Minimal, self-contained demonstration of the serialize/deserialize invariant 
(no Airflow import needed): a correct `BaseSerialization.serialize({"path": 
"...", "kwargs": {"tags": {"a": "b"}}})` wraps `kwargs`/`tags` and round-trips, 
whereas the migration's shallow-wrapped shape 
`{"__type":"dict","__var":{"kwargs":{"tags":{...}}}}` raises 
`KeyError('__var')` in `deserialize`.
   
   End-to-end: on 3.1.x create a deadline whose `AsyncCallback` kwargs contain 
a nested dict; upgrade across `0094`; then run the scheduler (or `SELECT` the 
`callback.data`) — it is stored with unwrapped nested kwargs and the scheduler 
crashes.
   
   ### Operating System
   
   Linux (containerized)
   
   ### Versions of Apache Airflow Providers
   
   N/A (core deadline alerts + serialization)
   
   ### Deployment
   
   Other
   
   ### Anything else?
   
   Workaround to unblock a crashing scheduler (neutralize the corrupt rows so 
the `WHERE ~missed` deadline query skips them):
   
   ```sql
   UPDATE deadline d SET missed = true
   FROM callback c
   WHERE c.id = d.callback_id
     AND d.missed = false
     AND jsonb_path_exists(c.data, '$.**.__var.* ? (@.type() == "object" && 
!exists(@.__type))');
   ```
   
   ### Are you willing to submit PR?
   
   Yes.
   
   
   ---
   🤖 Filed with [Claude Code](https://claude.com/claude-code) (model: Claude 
Opus 4.8, `claude-opus-4-8[1m]`).
   


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