Build deferred AWS hooks from the operator's own settings - #72171
Conversation
6509b9e to
d7f8a6e
Compare
An AwsBaseOperator/AwsBaseSensor subclass resolves region_name, verify and botocore_config in __init__, but did not hand them to the trigger it defers to. The trigger builds its own hook, so the deferred half of the task reached AWS with the default region, SSL verification silently re-enabled, and any custom botocore timeouts or retries discarded. The triggers already accept all three, so only the call sites were missing.
Every Neptune Analytics operator accepts `verify` through the shared AWS base class, but none of the seven class docstrings mentioned it, so the rendered provider docs gave users no way to discover it. Two of those docstrings even carried a stray blank line where the entry belonged. The deferral tests now compare the trigger's serialized payload rather than its attributes. Serialization is what actually crosses into the triggerer process, and it passes values through `prune_dict`, so an attribute-level assertion can pass while the setting is silently dropped on the way there. This matches the assertion style already used for the Neptune cluster operators.
d7f8a6e to
5a9b7e5
Compare
An AWS operator always carries region_name, verify and botocore_config, but on deferral the trigger builds its own hook. Most triggers accepted none of those parameters and constructed the hook from aws_conn_id alone, so the triggerer silently fell back to boto3 defaults: a different region, default SSL verification, and none of the configured timeouts or retry policy. The task changed behaviour purely by virtue of deferring, and did so without any error. Fixing this service by service would have meant editing every trigger signature as well as every call site, so the hook is now built in one place from the parameters the base trigger already serializes. Subclasses name the hook they need instead of constructing it, which is the same arrangement the operators use. The accompanying invariant test walks every defer site in the provider and fails if one does not hand its hook configuration to the trigger, so an operator added later cannot reintroduce the gap unnoticed. Three services are deliberately left for the Contributors Workshop and are named in the test's allowlist rather than skipped silently.
5a9b7e5 to
414e5d8
Compare
There was a problem hiding this comment.
Nice work — the design is right and the migration is genuinely mechanical. Two things I'd like addressed before merge, plus a merge-order hazard that isn't your fault.
aws_hook_class + _hook_parameters + a base hook() is a deliberate mirror of AwsBaseHookMixin, down to naming the botocore config config in the parameter mapping, so anyone who has worked on the operator side will recognise it immediately. EmrContainerTrigger overriding _hook_parameters to add virtual_cluster_id reads better than the hook() it replaces.
I spot-checked the removed hook() bodies for behaviour changes and found none: EmrServerlessHook(self.aws_conn_id) passed positionally still lands on aws_conn_id, and GlueJobHook is constructed without job_name both before and after, so the thick-hook defaults are unchanged. The DMS triggers are a good catch — they did pass verify/config in hook(), but their __init__ never accepted them, so those were structurally always None.
1. The failure mode moved into the triggerer
hook() now raises AttributeError when aws_hook_class is unset, but hook() is only called from run(), which runs in the triggerer. @abstractmethod used to fail at instantiation, i.e. in the worker at defer time. Now a subclass that forgets both aws_hook_class and hook() defers successfully and fails later, somewhere less visible.
The operator side doesn't have this problem: AwsBaseOperator.__init__ and AwsBaseSensor.__init__ both call validate_attributes() (operators/base_aws.py:101, sensors/base_aws.py:101). I'd add the equivalent here, ideally in __init_subclass__ so it fails at import time and doesn't depend on __init__ being reached — which matters because EksDeleteClusterTrigger bypasses super().__init__() entirely.
Related: the check is hasattr only, while the mixin's validate_attributes also asserts issubclass(..., AwsGenericHook). Worth matching — a typo'd assignment currently fails with a confusing TypeError from calling a non-class.
2. The static half of the invariant test belongs in a prek hook
The invariant is the reason a 40-file mechanical change is safe to review, so I'm glad it's here. But four of the five tests are pure AST analysis of source files with no runtime component, and there's already a home for exactly that: scripts/ci/prek/check_trigger_serialize_init.py does the same class of static check on triggers, alongside a dozen or so other AST-walking hooks in that directory. As a prek hook it runs on every commit, fails with a file:line message rather than a parametrized test id, and doesn't need the provider's optional dependencies installed.
I'd move test_defer_sites_are_discovered, test_no_defer_site_escapes_the_check, test_deferred_trigger_receives_hook_configuration and test_hand_built_trigger_hook_receives_configuration there, keeping only test_waiter_trigger_can_build_a_hook as a unit test since that one genuinely needs imports.
Concrete fragilities, wherever it ends up living:
find_waiter_triggers()imports every trigger module at collection time.triggers/eks.pyimportsKubernetesPodTriggerfromcncf.kubernetes; if that isn't installed theImportErroris a collection error for the whole file rather than a skip. The "30 collection errors locally" in your description is this failure mode.find_defer_sitesandfind_unreadable_defer_sitesduplicate ~20 lines of identicalself.defermatching. One walk returning both categories.str(path.relative_to(AWS_ROOT))yields backslashes on Windows, so every("sensors/batch.py", ...)comparison silently stops matching..as_posix()fixes it. Not a CI concern, but contributors do run these locally.find_hand_built_hooksmatches onlyast.Namecallees ending inHook, whilefind_defer_siteshandlesast.Attributetoo, so amodule.SomeHook(...)construction escapes the check."hook" in vars(trigger_class)only inspects the class's own__dict__, so a subclass inheriting an overriddenhook()from an intermediate base would fail spuriously.HAND_BUILT_HOOK_EXCEPTIONSkeys on bare filename rather than relative path. Fine today, collides the day two trigger modules share a name.
3. Merge-order hazard across three PRs
PENDING_MIGRATION asserts each entry is still needed. #72472 (the OpenSearch Serverless workshop task) is green and close to ready, and it drops the hook configuration into that exact defer site — so if it merges first, this PR's suite fails until the entry is removed. Combined with the #72098 dependency the intended order is #72098 → this → #72472, and it would help to say so explicitly in the description, since it currently only mentions #72098.
The self-detecting design does work in the other direction: assert missing in the allowlist branch means a stale entry fails loudly rather than rotting quietly. Good call.
4. Description doesn't match the code
The description says three services are reserved for the workshop and "are listed in PENDING_MIGRATION", but only two are — sensors/batch.py and sensors/opensearch_serverless.py. operators/sagemaker_unified_studio_notebook.py is instead in UNCONFIGURABLE_TRIGGERS with the rationale "takes no connection parameters at all", which reads as contradicting the paragraph above it. Worth reconciling, since #72144 points contributors at this list.
5. Changelog
This changes runtime behaviour at roughly 60 additional defer sites: the triggerer half now uses the operator's region, SSL verification and botocore config instead of boto3 defaults. That's the fix everyone wants, but it is observable — a deployment whose triggerer happened to work because it fell back to its own default region will now be pointed elsewhere. Providers don't consume newsfragments, so this warrants a line in providers/amazon/docs/changelog.rst rather than being left for the release manager to infer from git log.
Minor
- Docstring wording for the three new params drifts per file (
emr.py: "The AWS region where the resources to watch are";eks.py: "Which AWS region the connection should use"). Pre-existing variance, not worth churning. EksCreateClusterTriggerputsregion_nameinserialized_fieldsand gets it from the base'sprune_dictblock. Harmless duplicate and pre-existing, but a clean follow-up now that the base owns more of this.EksDeleteClusterTriggerstill never callssuper().__init__(), soBaseTrigger.__init__doesn't run, and it now hand-rolls the base'sprune_dictserialization too. Pre-existing, but this PR is the natural moment to note it as a follow-up.
I didn't run the suite locally; relying on the green CI here, which is the meaningful signal given your local environment was missing optional dependencies.
Drafted-by: Kiro CLI (Opus 5); reviewed by @o-nikolas before posting
o-nikolas
left a comment
There was a problem hiding this comment.
The changes are fairly mechanical and are looking good. An automated review with our magpie skill found a few things that I think you should address (see the comment above).
I'm also still trying to wrap my head around all related PRs and what their merge order should be. I think this is becoming more complicated than it needs to be. Between the three (#72098, #72171, #72472) what should the order be?
Merging main brought in apache#72557, which passes verify and botocore_config to GlueJobCompleteTrigger at the same two call sites this branch already widened. Git combined both insertions without reporting a conflict, leaving each call with the arguments repeated, which Python rejects at import time.
hook() is only reached from run(), which executes in the triggerer, so a subclass declaring neither aws_hook_class nor its own hook() would defer successfully and fail later, out of sight of the task that deferred. The operator side already gets this guarantee from validate_attributes. Checked on class creation rather than in __init__ because subclasses such as EksDeleteClusterTrigger never call super().__init__(). Also records the behaviour change in the provider changelog, since the triggerer now uses the operator's region, SSL verification and botocore configuration rather than falling back to boto3 defaults.
|
Thanks, this is a genuinely useful review. Addressed 1, 4 and 5 in the commit above; concrete answers to 2 and 3 below. 1. Failure mode moved into the triggerer: fixedYou are right, and The check accepts a subclass that declares 2. Moving the static half to a prek hook: agreed, but as a follow-upI have fixed the three that are real bugs regardless of where the code lives:
On the move itself, I would rather do it in a follow-up than here, for two reasons. This PR is already 33 files, and the invariant is the thing that makes the rest of them reviewable, so changing where it lives in the same diff means the evidence and the change move together. And a prek hook needs a new script plus a The duplicated On the collection errors: I do not think Worth flagging that moving the four AST tests to prek would not remove the import requirement either, since 3. Merge order#72098 -> this -> #72449 / #72472, those last two in either order. It is less entangled than it looks, and I should have said so in the description. #72098 to this is the only hard dependency, and it is containment rather than conflict. This PR carries a rebased copy of #72098's two commits, and all 8 of its files are a strict subset of the 33 here. So #72098 can merge whenever it is ready on its own merits, and when it does, this PR simply gets 8 files smaller on the next sync. Nothing to coordinate. This PR has no file-level overlap with either workshop PR. It does not touch The whole coupling is three lines in one allowlist:
It is symmetric, and the assertion prints the exact line to remove, so it is a one-line fix with no investigation. To keep it off your plate: I will watch all three and push the deletion myself, wherever it needs to land. Merge them in whatever order suits. 4. Description: fixedYou are right that it contradicted itself. Only two services are in 5. Changelog: addedAdded a MinorAgreed on all three, and none touched here. Drafted-by: Claude Code (Opus 5); reviewed by @SEPURI-SAI-KRISHNA before posting |
The sweep resolved a trigger= expression to the constructions it can evaluate to and returned an empty list when it could not read one. A bare reference was caught separately by matching ast.Name, but an attribute, a subscript, a conditional with one unreadable branch, or a construction whose callee cannot be named all produced nothing the checks could act on, and nothing reported their absence. The conditional was the worst of them: it still yielded the readable branch, so the site appeared in the parametrized run and read as covered. The directory filter had the same shape of problem, silently ignoring any defer site that did not sit directly in operators/ or sensors/. Reported in review by a contributor on the pull request.
|
Expanding here on the inline thread about I checked the two cases reported there and then went through the guard for the other cases in the same category instead of fixing only the reported ones.
The fifth row was the main one I wanted to fix. It ends up in I found the sixth case while doing the sweep. A construction whose callee cannot be resolved is still a I fixed this as you suggested.
I also cleaned up two things while I was there. The two almost identical walks are now a single The sweep also no longer skips files whose
There are no affected provider sites today. Apart from the allowlisted bare name in I also checked four related cases and left them unchanged:
Finally, Drafted-by: Claude Opus 5; reviewed by @SEPURI-SAI-KRISHNA before posting |
o-nikolas
left a comment
There was a problem hiding this comment.
Looking good to me. Can you get the tests green and we can be ready to merge?
The MyPy providers job is the only red check on the branch. The sweep looked up a construction's class name separately from deciding that the construction was readable, so the name stayed optional at every use even though an unnameable callee is already rejected, and the conditional branch walk could not be narrowed. Pairing each construction with the name that made it readable removes the optionality instead of asserting it away.
|
Tests are green now. The only failing job was MyPy providers: the sweep resolved a construction's class name separately from deciding the construction was readable, so the name stayed optional at every use. Pairing each construction with the name that made it readable removes the optionality. 83 checks, no failures, and the branch is mergeable. |
potiuk
left a comment
There was a problem hiding this comment.
Approving. The design is the right one — mirroring AwsBaseHookMixin means anyone who has worked on the operator side reads this without a second thought, and pushing the rejection into __init_subclass__ rather than __init__ is the correct answer to EksDeleteClusterTrigger never calling super().__init__().
I verified the three claims this change actually rests on rather than taking the description's word for them:
- No import cycle from moving
AwsGenericHookout ofTYPE_CHECKING:hooks/base_aws.pyimports nothing fromtriggers, so the new runtime import intriggers/base.pyis safe. aws_hook_class(**self._hook_parameters)really does construct for every hook named here. I checked all 13__init__signatures; each takes**kwargs, soaws_conn_id/region_name/verify/configland safely.EmrContainerHookis the one that needs more, and the_hook_parametersoverride covers it.- The changelog entry is the right mechanism —
AGENTS.mdsends provider-visible notes tochangelog.rstrather than a newsfragment, and a deferred task silently changing region is exactly the kind of thing that belongs there.
Two things for a follow-up rather than this PR; neither is worth another round trip on a change this size.
The invariant suite has one assertion that cannot fail
test_waiter_trigger_can_build_a_hook asserts a subclass declares aws_hook_class or overrides hook() — but find_waiter_triggers() imports every trigger module first, and __init_subclass__ already raises AttributeError at class creation for precisely that condition. A class that would fail the assertion cannot reach the parametrize list; collection dies first.
That matters only because of what it is named and what the description promises it does ("asserts every AwsBaseWaiterTrigger subclass can actually build a hook"). The risk it looks like it covers — a future thick hook whose __init__ does not accept the four forwarded keys — is unchecked. Nothing is broken today, as above. Detail inline.
Changelog placement after the rebase
Flagging rather than asserting, since you will know the release state better than I can infer it: the new warning sits above the first version heading, which was right against this branch's base, but main has since cut 9.36.0 and moved the Comprehend warning under it. Worth a glance after merge that the warning ended up where you intended.
Thanks for the invariant test and the merge-order write-up — 33 files is a lot to ask a reviewer to trust, and both are why this was reviewable at all.
This review was drafted by an AI-assisted tool and
confirmed by an Apache Airflow maintainer. The maintainer
approving this PR has read the findings and signed off. If
something feels off, please reply on the PR and a maintainer
will follow up.More on how Apache Airflow handles maintainer review:
contributing-docs/05_pull_requests.rst.
Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting
potiuk
left a comment
There was a problem hiding this comment.
Two fixups for the follow-ups in my review above — one applicable inline, one that needs a rebase instead.
The changelog point is no longer the "worth a glance" I called it. I computed the three-way merge of changelog.rst (merge-base bdf2abc, this head, current main) and it resolves cleanly, but it lands the new warning inside the 9.36.0 section. 9.36.0 was written by 209e34c5 ("Prepare providers release 2026-09-09"), so it is a cut release, and this change is not in it. Merging as-is publishes the warning against a version that does not have the behaviour it describes. Detail inline; it needs a rebase, so there is no suggestion button for it.
Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting
The 2026-09-09 provider release cut 9.36.0 and moved the Comprehend warning under that heading. Merging main carries this branch's warning down with it, attributing a change 9.36.0 does not contain to a released version. Put it back above the first version heading so it lands in the release that actually ships it. Generated-by: Claude Opus 5
76 providers are released and 1 is marked doc-only. The four major bumps (edge3, git, microsoft.psrp and openai) each remove or change a released public API, and every one carries a warning in its version section explaining the migration. amazon is a minor instead: the AWS team reviewed apache#72171 and considers the deferred-hook change a bug fix, so the warning about the triggerer no longer inheriting its host's boto3 defaults ships under 9.37.0 rather than a major. The wave is large because apache#73286 made every operator's connection id a template field across 61 providers, which is a user-facing feature in each of them. common.ai is prepared in a follow-up commit rather than this one, so that the rest of the wave can ship if its release is deferred.
76 providers are released and 1 is marked doc-only. The four major bumps (edge3, git, microsoft.psrp and openai) each remove or change a released public API, and every one carries a warning in its version section explaining the migration. amazon is a minor instead: the AWS team reviewed apache#72171 and considers the deferred-hook change a bug fix, so the warning about the triggerer no longer inheriting its host's boto3 defaults ships under 9.37.0 rather than a major. The wave is large because apache#73286 made every operator's connection id a template field across 61 providers, which is a user-facing feature in each of them. common.ai is prepared in a follow-up commit rather than this one, so that the rest of the wave can ship if its release is deferred.
76 providers are released and 1 is marked doc-only. The four major bumps (edge3, git, microsoft.psrp and openai) each remove or change a released public API, and every one carries a warning in its version section explaining the migration. amazon is a minor instead: the AWS team reviewed apache#72171 and considers the deferred-hook change a bug fix, so the warning about the triggerer no longer inheriting its host's boto3 defaults ships under 9.37.0 rather than a major. The wave is large because apache#73286 made every operator's connection id a template field across 61 providers, which is a user-facing feature in each of them. common.ai is prepared in a follow-up commit rather than this one, so that the rest of the wave can ship if its release is deferred.
76 providers are released and 1 is marked doc-only. The four major bumps (edge3, git, microsoft.psrp and openai) each remove or change a released public API, and every one carries a warning in its version section explaining the migration. amazon is a minor instead: the AWS team reviewed apache#72171 and considers the deferred-hook change a bug fix, so the warning about the triggerer no longer inheriting its host's boto3 defaults ships under 9.37.0 rather than a major. The wave is large because apache#73286 made every operator's connection id a template field across 61 providers, which is a user-facing feature in each of them. common.ai is prepared in a follow-up commit rather than this one, so that the rest of the wave can ship if its release is deferred.
76 providers are released and 1 is marked doc-only. The four major bumps (edge3, git, microsoft.psrp and openai) each remove or change a released public API, and every one carries a warning in its version section explaining the migration. amazon is a minor instead: the AWS team reviewed apache#72171 and considers the deferred-hook change a bug fix, so the warning about the triggerer no longer inheriting its host's boto3 defaults ships under 9.37.0 rather than a major. The wave is large because apache#73286 made every operator's connection id a template field across 61 providers, which is a user-facing feature in each of them. common.ai is prepared in a follow-up commit rather than this one, so that the rest of the wave can ship if its release is deferred.
76 providers are released and 1 is marked doc-only. The four major bumps (edge3, git, microsoft.psrp and openai) each remove or change a released public API, and every one carries a warning in its version section explaining the migration. amazon is a minor instead: the AWS team reviewed apache#72171 and considers the deferred-hook change a bug fix, so the warning about the triggerer no longer inheriting its host's boto3 defaults ships under 9.37.0 rather than a major. The wave is large because apache#73286 made every operator's connection id a template field across 61 providers, which is a user-facing feature in each of them. common.ai is prepared in a follow-up commit rather than this one, so that the rest of the wave can ship if its release is deferred.
76 providers are released and 1 is marked doc-only. The four major bumps (edge3, git, microsoft.psrp and openai) each remove or change a released public API, and every one carries a warning in its version section explaining the migration. amazon is a minor instead: the AWS team reviewed apache#72171 and considers the deferred-hook change a bug fix, so the warning about the triggerer no longer inheriting its host's boto3 defaults ships under 9.37.0 rather than a major. The wave is large because apache#73286 made every operator's connection id a template field across 61 providers, which is a user-facing feature in each of them. common.ai is prepared in a follow-up commit rather than this one, so that the rest of the wave can ship if its release is deferred.
Addresses the bulk of the deferred hook-configuration gap tracked in #72144.
AwsBaseWaiterTriggernow builds the hook itself, from the parameters it already serializes, driven by anaws_hook_classattribute, the same arrangementAwsBaseHookMixingives the operators. Subclasses name the hook they need instead of constructing it, soregion_name,verifyandbotocore_configreach the triggerer by default rather than only where a subclass remembered to thread them through.That removes 40 bespoke
hook()implementations, and takes the provider from 49 of 113 defer sites forwarding the full hook configuration to 110 of 113.A subclass that declares neither
aws_hook_classnor its ownhook()is now rejected in__init_subclass__, so it fails when the class is created rather than later in the triggerer, which is the only placehook()is reached from.Merge order
#72098 → this → #72449 / #72472 (those last two in either order).
sensors/batch.py,sensors/opensearch_serverless.pyortriggers/opensearch_serverless.py, and neither of those PRs touches the invariant test. There is no merge conflict in any ordering.verifyandbotocore_configto BatchJobTrigger in BatchSensor (#72278) #72449 / Preserve OpenSearch Serverless hook configuration when deferring #72472 merges second drops its own line fromPENDING_MIGRATION(and, for Preserve OpenSearch Serverless hook configuration when deferring #72472, fromHAND_BUILT_HOOK_EXCEPTIONS). The assertion prints the exact line to remove.What is deliberately left out
Two services are reserved for the Contributors Workshop, at the request of the workshop organiser on #72144:
sensors/batch.pyandsensors/opensearch_serverless.py. They are listed inPENDING_MIGRATIONin the invariant test, which asserts each entry is still needed, a stale line fails the suite, so the allowlist cannot outlive the work it tracks. They are covered by #72449 and #72472 respectively.Two defer sites are not instances of this bug at all, and are named in
UNCONFIGURABLE_TRIGGERSrather than passed over silently.SageMakerNotebookOperatordefers toSageMakerNotebookJobTrigger, a plainBaseTriggerwhose hook is addressed by execution name and takes no connection parameters.EksPodOperatordefers toEksPodTrigger, aKubernetesPodTriggerthat reaches the pod through a kubeconfig rather than a boto3 client.Invariant test
test_deferred_hook_configuration.pywalks everyself.defer(trigger=...)call in the provider and fails if one does not pass the hook configuration, plus asserts everyAwsBaseWaiterTriggersubclass can actually build a hook. It resolves atrigger=expression to every construction it can evaluate to, so a trigger chosen in a conditional expression is checked on both branches, that is how the twoEmrContainersites were caught. A defer site whose trigger is a bare reference is asserted against an explicit allowlist rather than skipped. An operator added later that forgets the parameters fails in CI rather than in production.Moving the four purely static checks into a prek hook is a good idea and is planned as a follow-up; the concrete correctness fixes from review (POSIX-normalised paths,
ast.Attributecallees, MRO-awarehook()detection) are already in here.Notes for review
aws_hook_classbinds the hook at class definition, so the@patch("...triggers.<module>.<Hook>")idiom no longer intercepts it for migrated triggers. No existing test needed changing as a result.EmrContainerTrigger's hook takes an extravirtual_cluster_id, so it overrides_hook_parametersrather than using the default, the escape hatch the base class keeps for exactly this.EksDeleteClusterTriggerbypasses the base__init__and rolls its ownserialize(), so it sets and serializes the two new parameters explicitly. That it skipssuper().__init__()at all is pre-existing and worth a follow-up.ModuleNotFoundError: No module named 'airflow_shared', an optional dependency missing from my local environment, in files this PR does not touch.Was generative AI tooling used to co-author this PR?
Generated-by: Claude Code (Opus 5) following the guidelines