Tornado-to-Asyncio Conversion: Critical Analysis
Code References
Scope
This document is a line-by-line, pattern-by-pattern comparison of the old Tornado-based codebase (commit c2c791ee) against the current asyncio-based codebase (main branch, ede78930, v8.1.0). The analysis focuses on correctness, behavioral regressions, and subtle semantic differences that may not be caught by existing tests.
Executive Summary
The conversion is broadly correct in the happy path but contains several behavioral differences, some of which change failure semantics in ways that could matter in production deployments. The most critical findings are:
group.Async task scheduling is fundamentally different -- tasks start immediately in Tornado, but are deferred in asyncio until the event loop yields. This changes timing and interleaving behavior.
TaskGroup changes error semantics -- The IAM actors switched from Tornado's yield [list] (which surfaces the first exception) to asyncio.TaskGroup (which wraps exceptions in ExceptionGroup). The BaseActor.execute() handler for ExceptionGroup only partially compensates.
- The
@dry decorator lost its gen.coroutine wrapper -- In Tornado, the @dry decorator called gen.coroutine(f) on the wrapped function, meaning the wrapped function did NOT need to be a coroutine. In asyncio, the wrapper calls await f(), which requires f to be async def. This works only because all call sites were also converted.
- The
@timer decorator has a stacking order change -- Tornado: @gen.coroutine @timer (timer wraps coroutine). Asyncio: @timer async def execute (timer wraps async). The Tornado timer internally called gen.coroutine(f), so the function it wrapped did NOT need to be a coroutine. This is subtly different.
- HTTP client completely replaced --
tornado.httpclient.AsyncHTTPClient was replaced with synchronous urllib.request.urlopen running in a ThreadPoolExecutor. This changes connection pooling, redirect behavior, timeout handling, and error types.
GenericHTTP._execute dry-run has a fire-and-forget coroutine bug in Tornado that was fixed -- The old code had raise gen.Return(self._execute_dry()) which returned the coroutine object without awaiting it. The new code correctly uses return await self._execute_dry().
Prioritized Work Plan
🔴 = fix now 🟠 = fix soon 🟡 = fix when convenient ✅ = verified correct
Detailed Findings
1. Core Framework: actors/base.py — Timeout Mechanism
#672
2. Core Framework: actors/base.py — ExceptionGroup Handler in execute()
#673
3. actors/group.py — Async Task Scheduling Semantics
#674
4. actors/utils.py — @dry and @timer Decorator Conversions
#675
5. kingpin/utils.py — Deprecated get_event_loop() Usage
#676
6. actors/misc.py — HTTP Calls Have No Timeout
#677
7. actors/aws/base.py — AWS Base Actor api_call() Conversion
#678
8. actors/aws/api_call_queue.py — Queue and Consumer Task Conversion
#679
9. HTTPBaseActor — HTTP Client Replacement (Tornado AsyncHTTPClient → urllib)
#680
10. actors/aws/cloudformation.py — CloudFormation Actor Conversion
#681
11. actors/aws/iam.py — TaskGroup Cancels Remaining Tasks on Failure (HIGH RISK)
#682
12. actors/aws/s3.py — S3 Bucket Actor Conversion
#683
13. bin/deploy.py — CLI Entry Point Shutdown Semantics
#684
Summary of Risks
HIGH Risk
| Issue |
Location |
Description |
|---|
| #682 |
iam.py |
asyncio.TaskGroup cancels remaining tasks on first failure. Tornado yield [list] let them continue. Changes partial-failure behavior. |
MEDIUM Risk
| Issue |
Location |
Description |
|---|
| #677 |
misc.py, base.py |
urllib.request.urlopen has no timeout, can hang forever |
| #673 |
base.py |
Only first exception from ExceptionGroup is re-raised; severity classification may be wrong |
| #674 |
group.py |
ensure_future schedules on next tick vs Tornado's immediate scheduling |
LOW Risk
| Issue |
Location |
Description |
|---|
| #676 |
utils.py, aws/base.py, api_call_queue.py |
Should use get_running_loop() in Python 3.10+ |
| #680 |
base.py |
urllib has no connection pooling or per-request timeouts |
| #675 |
actors/utils.py |
@dry/@timer should assert wrapped function is async |
No Risk (Correct / Improvements)
| Issue |
Location |
Description |
|---|
| #672 |
base.py |
Timeout conversion correct; asyncio.shield() preserves old behavior |
| #678 |
aws/base.py |
api_call() conversion correct |
| #679 |
api_call_queue.py |
Queue and consumer task conversion correct |
| #681 |
cloudformation.py |
Mechanical conversion, no semantic changes |
| #683 |
s3.py |
Sequential EnsurableBaseActor, no concurrency concerns |
| #684 |
deploy.py |
Stricter shutdown is an improvement; dry-run bug fixed |
Tornado-to-Asyncio Conversion: Critical Analysis
Code References
Scope
This document is a line-by-line, pattern-by-pattern comparison of the old Tornado-based codebase (commit
c2c791ee) against the current asyncio-based codebase (main branch,ede78930, v8.1.0). The analysis focuses on correctness, behavioral regressions, and subtle semantic differences that may not be caught by existing tests.Executive Summary
The conversion is broadly correct in the happy path but contains several behavioral differences, some of which change failure semantics in ways that could matter in production deployments. The most critical findings are:
group.Asynctask scheduling is fundamentally different -- tasks start immediately in Tornado, but are deferred in asyncio until the event loop yields. This changes timing and interleaving behavior.TaskGroupchanges error semantics -- The IAM actors switched from Tornado'syield [list](which surfaces the first exception) toasyncio.TaskGroup(which wraps exceptions inExceptionGroup). TheBaseActor.execute()handler forExceptionGrouponly partially compensates.@drydecorator lost itsgen.coroutinewrapper -- In Tornado, the@drydecorator calledgen.coroutine(f)on the wrapped function, meaning the wrapped function did NOT need to be a coroutine. In asyncio, the wrapper callsawait f(), which requiresfto beasync def. This works only because all call sites were also converted.@timerdecorator has a stacking order change -- Tornado:@gen.coroutine @timer(timer wraps coroutine). Asyncio:@timer async def execute(timer wraps async). The Tornadotimerinternally calledgen.coroutine(f), so the function it wrapped did NOT need to be a coroutine. This is subtly different.tornado.httpclient.AsyncHTTPClientwas replaced with synchronousurllib.request.urlopenrunning in aThreadPoolExecutor. This changes connection pooling, redirect behavior, timeout handling, and error types.GenericHTTP._executedry-run has a fire-and-forget coroutine bug in Tornado that was fixed -- The old code hadraise gen.Return(self._execute_dry())which returned the coroutine object without awaiting it. The new code correctly usesreturn await self._execute_dry().Prioritized Work Plan
TaskGroupwithasyncio.gather(return_exceptions=True)to restore Tornado's "let everything finish" semanticstimeout=30tourlopen()in_fetch()and_get_macro()UnrecoverableActorFailurebefore deciding to swallowurllib3orhttpxfor pooling + timeoutsget_event_loop()→get_running_loop()across 4 filesassert iscoroutinefunction(f)in decoratorsDetailed Findings
1. Core Framework:
actors/base.py— Timeout Mechanism#672
2. Core Framework:
actors/base.py— ExceptionGroup Handler inexecute()#673
3.
actors/group.py— Async Task Scheduling Semantics#674
4.
actors/utils.py—@dryand@timerDecorator Conversions#675
5.
kingpin/utils.py— Deprecatedget_event_loop()Usage#676
6.
actors/misc.py— HTTP Calls Have No Timeout#677
7.
actors/aws/base.py— AWS Base Actorapi_call()Conversion#678
8.
actors/aws/api_call_queue.py— Queue and Consumer Task Conversion#679
9.
HTTPBaseActor— HTTP Client Replacement (Tornado AsyncHTTPClient → urllib)#680
10.
actors/aws/cloudformation.py— CloudFormation Actor Conversion#681
11.
actors/aws/iam.py— TaskGroup Cancels Remaining Tasks on Failure (HIGH RISK)#682
12.
actors/aws/s3.py— S3 Bucket Actor Conversion#683
13.
bin/deploy.py— CLI Entry Point Shutdown Semantics#684
Summary of Risks
HIGH Risk
iam.pyasyncio.TaskGroupcancels remaining tasks on first failure. Tornadoyield [list]let them continue. Changes partial-failure behavior.MEDIUM Risk
misc.py,base.pyurllib.request.urlopenhas no timeout, can hang foreverbase.pygroup.pyensure_futureschedules on next tick vs Tornado's immediate schedulingLOW Risk
utils.py,aws/base.py,api_call_queue.pyget_running_loop()in Python 3.10+base.pyactors/utils.py@dry/@timershould assert wrapped function is asyncNo Risk (Correct / Improvements)
base.pyasyncio.shield()preserves old behavioraws/base.pyapi_call()conversion correctapi_call_queue.pycloudformation.pys3.pydeploy.py