Created
April 2, 2026 16:11
-
-
Save eric239/6df3d6e43f07eeb674eedfd0db3c60ec to your computer and use it in GitHub Desktop.
PR #4639 agent review report
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>PR #4639 Review - TECH-6312: Asyncio infrastructure for prefork Celery workers</title> | |
| <style> | |
| :root { | |
| --bg: #0d1117; | |
| --surface: #161b22; | |
| --border: #30363d; | |
| --text: #e6edf3; | |
| --text-muted: #8b949e; | |
| --accent: #58a6ff; | |
| --green: #3fb950; | |
| --red: #f85149; | |
| --green-bg: #12261e; | |
| --red-bg: #2d1214; | |
| --yellow: #d29922; | |
| --purple: #bc8cff; | |
| --orange: #f0883e; | |
| --syn-keyword: #ff7b72; | |
| --syn-string: #a5d6ff; | |
| --syn-decorator: #d2a8ff; | |
| --syn-func: #d2a8ff; | |
| --syn-class: #ffa657; | |
| --syn-builtin: #ffa657; | |
| --syn-comment: #8b949e; | |
| --syn-self: #ff7b72; | |
| --syn-number: #79c0ff; | |
| --syn-type: #ffa657; | |
| } | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; line-height: 1.6; padding: 2rem; max-width: 1200px; margin: 0 auto; } | |
| a { color: var(--accent); text-decoration: none; } | |
| a:hover { text-decoration: underline; } | |
| .header { border-bottom: 1px solid var(--border); padding-bottom: 1.5rem; margin-bottom: 2rem; } | |
| .header h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 0.5rem; } | |
| .header .meta { color: var(--text-muted); font-size: 0.85rem; display: flex; gap: 1.5rem; flex-wrap: wrap; } | |
| .header .meta span { display: inline-flex; align-items: center; gap: 0.3rem; } | |
| .tldr { border-left: 3px solid var(--accent); background: rgba(88,166,255,0.08); padding: 1rem 1.25rem; margin-bottom: 2rem; border-radius: 0 6px 6px 0; } | |
| .tldr p { font-size: 0.92rem; } | |
| .diagram-container { margin-bottom: 2rem; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; text-align: center; } | |
| .block-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 1.5rem; overflow: hidden; } | |
| .block-header { padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 0.75rem; } | |
| .block-header .num { background: var(--accent); color: #fff; width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.8rem; font-weight: 700; flex-shrink: 0; } | |
| .block-header h2 { font-size: 1.1rem; font-weight: 600; } | |
| .block-body { display: flex; } | |
| .block-files { width: 220px; flex-shrink: 0; background: rgba(22,27,34,0.8); padding: 1rem 1.25rem; border-right: 1px solid var(--border); } | |
| .block-files .file { display: flex; align-items: center; gap: 0.5rem; font-size: 0.78rem; font-family: 'SF Mono', SFMono-Regular, Consolas, monospace; padding: 0.2rem 0; word-break: break-all; } | |
| .block-files .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } | |
| .dot-added { background: var(--green); } | |
| .dot-modified { background: var(--yellow); } | |
| .dot-removed { background: var(--red); } | |
| .block-explanation { padding: 1rem 1.25rem; flex: 1; font-size: 0.9rem; } | |
| .block-explanation p { margin-bottom: 0.75rem; } | |
| .block-explanation strong { color: var(--text); } | |
| .block-explanation ul { margin-left: 1.25rem; margin-bottom: 0.75rem; } | |
| .block-explanation li { margin-bottom: 0.3rem; } | |
| .diff-section { border-top: 1px solid var(--border); padding: 0; } | |
| .diff-section summary { padding: 0.75rem 1.25rem; cursor: pointer; font-size: 0.85rem; color: var(--text-muted); } | |
| .diff-section summary:hover { color: var(--text); } | |
| pre.diff { margin: 0; padding: 0 1.25rem 1rem; font-family: 'SF Mono', SFMono-Regular, Consolas, monospace; font-size: 0; line-height: 0; overflow-x: auto; } | |
| .diff-line { display: block; font-size: 0.72rem; line-height: 1.45; padding: 1px 0.5rem; white-space: pre; } | |
| .diff-line.add { background: var(--green-bg); color: var(--green); } | |
| .diff-line.remove { background: var(--red-bg); color: var(--red); } | |
| .diff-line.context { color: var(--text-muted); } | |
| .diff-line.ellipsis { color: var(--text-muted); text-align: center; font-style: italic; } | |
| .diff-line .kw { color: var(--syn-keyword); } | |
| .diff-line .str { color: var(--syn-string); } | |
| .diff-line .dec { color: var(--syn-decorator); } | |
| .diff-line .fn { color: var(--syn-func); } | |
| .diff-line .cls { color: var(--syn-class); } | |
| .diff-line .ty { color: var(--syn-type); } | |
| .diff-line .bi { color: var(--syn-builtin); } | |
| .diff-line .num { background: none; width: auto; height: auto; border-radius: 0; display: inline; font-weight: normal; font-size: inherit; color: var(--syn-number); } | |
| .diff-line .slf { color: var(--syn-self); font-style: italic; } | |
| .diff-line .cmt { color: var(--syn-comment); font-style: italic; } | |
| .concerns { border-top: 1px solid var(--border); padding: 1rem 1.25rem; } | |
| .concern-box { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 0.5rem; font-size: 0.85rem; } | |
| .concern-box:last-child { margin-bottom: 0; } | |
| .concern-issue { background: rgba(210,153,34,0.1); border-left: 3px solid var(--yellow); } | |
| .concern-ok { background: rgba(63,185,80,0.08); border-left: 3px solid var(--green); } | |
| .summary-table { margin-top: 2rem; } | |
| .summary-table h2 { font-size: 1.2rem; margin-bottom: 1rem; } | |
| table { width: 100%; border-collapse: collapse; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; } | |
| th { text-align: left; padding: 0.75rem 1rem; background: rgba(22,27,34,0.8); border-bottom: 1px solid var(--border); font-size: 0.8rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; } | |
| td { padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); font-size: 0.85rem; } | |
| tr:last-child td { border-bottom: none; } | |
| .severity { padding: 0.2rem 0.6rem; border-radius: 12px; font-size: 0.72rem; font-weight: 600; text-transform: uppercase; display: inline-block; } | |
| .severity.design { background: rgba(240,136,62,0.15); color: var(--orange); } | |
| .severity.minor { background: rgba(210,153,34,0.15); color: var(--yellow); } | |
| .severity.bug { background: rgba(248,81,73,0.15); color: var(--red); } | |
| .severity.question { background: rgba(188,140,255,0.15); color: var(--purple); } | |
| .severity.nit { background: rgba(139,148,158,0.15); color: var(--text-muted); } | |
| @media (max-width: 768px) { | |
| .block-body { flex-direction: column; } | |
| .block-files { width: 100%; border-right: none; border-bottom: 1px solid var(--border); } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="header"> | |
| <h1>PR #4639: TECH-6312 - Asyncio infrastructure for prefork Celery workers</h1> | |
| <div class="meta"> | |
| <span>Author: <strong>eric239</strong></span> | |
| <span>Branch: <strong>tech-6312-concurrency-infra-for-use-within-prefork-celery-tasks</strong></span> | |
| <span>Files: <strong>14</strong></span> | |
| <span><span style="color:var(--green)">+429</span> / <span style="color:var(--red)">-15</span></span> | |
| </div> | |
| </div> | |
| <div class="tldr"> | |
| <p>This PR introduces a shared asyncio event-loop infrastructure (<code>async_infra</code>) backed by anyio's <code>BlockingPortal</code>, wired into prefork Celery workers via process init/shutdown signals. It includes a new <code>shared-tests</code> library for reusable test fixtures and thorough test coverage. The design is solid with sensible lifecycle management, a fallback path for non-worker contexts, and health-check/restart resilience.</p> | |
| </div> | |
| <!-- Architecture Diagram --> | |
| <div class="diagram-container"> | |
| <svg viewBox="0 0 900 400" xmlns="http://www.w3.org/2000/svg" style="max-width:900px;"> | |
| <defs> | |
| <marker id="arrow" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="10" markerHeight="7" markerUnits="userSpaceOnUse" orient="auto"> | |
| <path d="M 0 0 L 10 3.5 L 0 7 z" fill="#8b949e"/> | |
| </marker> | |
| <marker id="arrow-green" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="10" markerHeight="7" markerUnits="userSpaceOnUse" orient="auto"> | |
| <path d="M 0 0 L 10 3.5 L 0 7 z" fill="#3fb950"/> | |
| </marker> | |
| </defs> | |
| <!-- Celery Worker Process --> | |
| <rect x="30" y="30" width="200" height="60" rx="8" fill="#161b22" stroke="#d29922" stroke-width="2"/> | |
| <text x="130" y="55" fill="#e6edf3" text-anchor="middle" font-size="13" font-weight="600">Celery Worker (prefork)</text> | |
| <text x="130" y="75" fill="#8b949e" text-anchor="middle" font-size="10">api/celery.py</text> | |
| <!-- worker_process_init signal --> | |
| <line x1="230" y1="60" x2="340" y2="60" stroke="#3fb950" stroke-width="1.5" marker-end="url(#arrow-green)"/> | |
| <text x="285" y="52" fill="#3fb950" text-anchor="middle" font-size="9">worker_process_init</text> | |
| <!-- start_async_infra --> | |
| <rect x="345" y="30" width="210" height="60" rx="8" fill="#161b22" stroke="#3fb950" stroke-width="2" onclick="scrollToCard('block-1')" style="cursor:pointer"/> | |
| <text x="450" y="55" fill="#3fb950" text-anchor="middle" font-size="13" font-weight="600">start_async_infra()</text> | |
| <text x="450" y="75" fill="#8b949e" text-anchor="middle" font-size="10">shared_utils.async_infra</text> | |
| <!-- BlockingPortal --> | |
| <line x1="450" y1="90" x2="450" y2="140" stroke="#3fb950" stroke-width="1.5" marker-end="url(#arrow-green)"/> | |
| <rect x="345" y="145" width="210" height="60" rx="8" fill="#161b22" stroke="#3fb950" stroke-width="2"/> | |
| <text x="450" y="170" fill="#3fb950" text-anchor="middle" font-size="13" font-weight="600">BlockingPortal</text> | |
| <text x="450" y="190" fill="#8b949e" text-anchor="middle" font-size="10">anyio daemon thread + asyncio loop</text> | |
| <!-- run_async / run_async_parallel --> | |
| <rect x="345" y="240" width="210" height="60" rx="8" fill="#161b22" stroke="#3fb950" stroke-width="2" onclick="scrollToCard('block-1')" style="cursor:pointer"/> | |
| <text x="450" y="265" fill="#3fb950" text-anchor="middle" font-size="13" font-weight="600">run_async() / parallel()</text> | |
| <text x="450" y="285" fill="#8b949e" text-anchor="middle" font-size="10">submit coros from sync code</text> | |
| <!-- Arrow from run_async to portal --> | |
| <line x1="450" y1="240" x2="450" y2="210" stroke="#8b949e" stroke-width="1.5" marker-end="url(#arrow)"/> | |
| <text x="480" y="228" fill="#8b949e" font-size="9">portal.call()</text> | |
| <!-- Fallback path --> | |
| <rect x="630" y="240" width="210" height="60" rx="8" fill="#161b22" stroke="#58a6ff" stroke-width="2" stroke-dasharray="5,3"/> | |
| <text x="735" y="265" fill="#58a6ff" text-anchor="middle" font-size="13" font-weight="600">asyncio.run() fallback</text> | |
| <text x="735" y="285" fill="#8b949e" text-anchor="middle" font-size="10">when _started is False</text> | |
| <line x1="555" y1="270" x2="625" y2="270" stroke="#58a6ff" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#arrow)"/> | |
| <text x="590" y="262" fill="#8b949e" font-size="9">if !started</text> | |
| <!-- get_portal resilience --> | |
| <rect x="630" y="145" width="210" height="60" rx="8" fill="#161b22" stroke="#3fb950" stroke-width="2" onclick="scrollToCard('block-1')" style="cursor:pointer"/> | |
| <text x="735" y="170" fill="#3fb950" text-anchor="middle" font-size="13" font-weight="600">get_portal()</text> | |
| <text x="735" y="190" fill="#8b949e" text-anchor="middle" font-size="10">restart-or-die resilience</text> | |
| <line x1="555" y1="175" x2="625" y2="175" stroke="#3fb950" stroke-width="1.5" marker-end="url(#arrow-green)"/> | |
| <!-- shutdown --> | |
| <rect x="30" y="330" width="200" height="50" rx="8" fill="#161b22" stroke="#d29922" stroke-width="2"/> | |
| <text x="130" y="355" fill="#d29922" text-anchor="middle" font-size="12" font-weight="600">worker_process_shutdown</text> | |
| <line x1="230" y1="355" x2="340" y2="355" stroke="#8b949e" stroke-width="1.5" marker-end="url(#arrow)"/> | |
| <rect x="345" y="330" width="210" height="50" rx="8" fill="#161b22" stroke="#3fb950" stroke-width="2"/> | |
| <text x="450" y="360" fill="#3fb950" text-anchor="middle" font-size="13" font-weight="600">stop_async_infra()</text> | |
| </svg> | |
| </div> | |
| <!-- Block 1 --> | |
| <div class="block-card" id="block-1"> | |
| <div class="block-header"> | |
| <div class="num">1</div> | |
| <h2>Core async infrastructure (<code>shared_utils.async_infra</code>)</h2> | |
| </div> | |
| <div class="block-body"> | |
| <div class="block-files"> | |
| <div class="file"><span class="dot dot-added"></span>async_infra.py</div> | |
| <div class="file"><span class="dot dot-modified"></span>shared-utils/pyproject.toml</div> | |
| </div> | |
| <div class="block-explanation"> | |
| <p>Provides module-level functions to start/stop a long-lived asyncio event loop on a daemon thread via anyio's <code>BlockingPortal</code>. <code>run_async</code> and <code>run_async_parallel</code> submit coroutines from sync code, falling back to <code>asyncio.run()</code> when the portal has never been initialized.</p> | |
| <p><code>get_portal()</code> implements restart-or-die resilience: if the portal is dead it tries one restart, then calls <code>sys.exit(1)</code>.</p> | |
| <p><strong>Design decisions:</strong></p> | |
| <ul> | |
| <li><code>_started</code> is a one-way latch (never reset). Once initialized, <code>asyncio.run()</code> fallback is permanently bypassed.</li> | |
| <li><code>sys.exit(1)</code> on unrecoverable failure is aggressive but correct for prefork workers.</li> | |
| <li><code>_wrap()</code> closure is necessary because <code>BlockingPortal.call()</code> expects a callable, not a coroutine object.</li> | |
| </ul> | |
| </div> | |
| </div> | |
| <details class="diff-section"> | |
| <summary>Show diff: async_infra.py (new file, 147 lines)</summary> | |
| <pre class="diff"><span class="diff-line add">+<span class="kw">import</span> asyncio</span> | |
| <span class="diff-line add">+<span class="kw">import</span> sys</span> | |
| <span class="diff-line add">+<span class="kw">import</span> threading</span> | |
| <span class="diff-line add">+<span class="kw">from</span> collections.abc <span class="kw">import</span> <span class="ty">Coroutine</span></span> | |
| <span class="diff-line add">+<span class="kw">from</span> contextlib <span class="kw">import</span> <span class="ty">AbstractContextManager</span></span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+<span class="kw">import</span> anyio</span> | |
| <span class="diff-line add">+<span class="kw">import</span> anyio.from_thread</span> | |
| <span class="diff-line add">+<span class="kw">import</span> structlog</span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+<span class="kw">from</span> shared_utils.types <span class="kw">import</span> ANY</span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+_logger = structlog.get_logger(__name__)</span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+_lock = threading.<span class="cls">Lock</span>()</span> | |
| <span class="diff-line add">+_started = <span class="kw">False</span></span> | |
| <span class="diff-line add">+_cm: <span class="ty">AbstractContextManager</span>[anyio.from_thread.<span class="ty">BlockingPortal</span>] | <span class="kw">None</span> = <span class="kw">None</span></span> | |
| <span class="diff-line add">+_portal: anyio.from_thread.<span class="ty">BlockingPortal</span> | <span class="kw">None</span> = <span class="kw">None</span></span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+<span class="kw">class</span> <span class="cls">AsyncInfraError</span>(<span class="bi">Exception</span>):</span> | |
| <span class="diff-line add">+ <span class="str">"""Raised when async infra is used from an incompatible calling context."""</span></span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+<span class="kw">def</span> <span class="fn">start_async_infra</span>() -> <span class="kw">None</span>:</span> | |
| <span class="diff-line add">+ <span class="kw">global</span> _cm, _portal, _started</span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+ <span class="kw">with</span> _lock:</span> | |
| <span class="diff-line add">+ <span class="kw">if</span> _portal <span class="kw">is not</span> <span class="kw">None</span>:</span> | |
| <span class="diff-line add">+ _logger.warning(<span class="str">'Async infra already started, skipping'</span>)</span> | |
| <span class="diff-line add">+ <span class="kw">return</span></span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+ cm = anyio.from_thread.start_blocking_portal(backend=<span class="str">'asyncio'</span>)</span> | |
| <span class="diff-line add">+ portal = cm.__enter__()</span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+ _cm = cm</span> | |
| <span class="diff-line add">+ _portal = portal</span> | |
| <span class="diff-line add">+ _started = <span class="kw">True</span></span> | |
| <span class="diff-line add">+ _logger.info(<span class="str">'Async infra started'</span>)</span> | |
| <span class="diff-line ellipsis">...</span> | |
| <span class="diff-line add">+<span class="kw">def</span> <span class="fn">get_portal</span>() -> anyio.from_thread.<span class="ty">BlockingPortal</span>:</span> | |
| <span class="diff-line add">+ _check_calling_context()</span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+ portal = _portal</span> | |
| <span class="diff-line add">+ <span class="kw">if</span> portal <span class="kw">is not</span> <span class="kw">None</span> <span class="kw">and</span> _is_portal_healthy(portal, is_final_check=<span class="kw">False</span>):</span> | |
| <span class="diff-line add">+ <span class="kw">return</span> portal</span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+ _logger.warning(<span class="str">'Async infra portal is dead or missing, attempting restart'</span>)</span> | |
| <span class="diff-line add">+ stop_async_infra()</span> | |
| <span class="diff-line add">+ <span class="kw">try</span>:</span> | |
| <span class="diff-line add">+ start_async_infra()</span> | |
| <span class="diff-line add">+ portal = _portal</span> | |
| <span class="diff-line add">+ <span class="kw">except</span> <span class="bi">Exception</span>:</span> | |
| <span class="diff-line add">+ _logger.exception(<span class="str">'Async infra restart failed'</span>)</span> | |
| <span class="diff-line add">+ portal = <span class="kw">None</span></span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+ <span class="kw">if</span> portal <span class="kw">is not</span> <span class="kw">None</span> <span class="kw">and</span> _is_portal_healthy(portal, is_final_check=<span class="kw">True</span>):</span> | |
| <span class="diff-line add">+ <span class="kw">return</span> portal</span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+ sys.exit(<span class="num">1</span>)</span> | |
| </pre> | |
| </details> | |
| <div class="concerns"> | |
| <div class="concern-box concern-issue"><strong>#1 Design:</strong> <code>start_async_infra</code>: if <code>cm.__enter__()</code> raises (line 36), the context manager may have already spawned a thread internally. Since <code>__enter__</code> failed, globals stay <code>None</code> (good), but <code>cm.__exit__</code> is never called, potentially leaking the thread. Consider wrapping in try/except to call <code>cm.__exit__()</code> on failure. <em>(1/2)</em></div> | |
| <div class="concern-box concern-issue"><strong>#2 Design:</strong> <code>ANY</code> TypeVar on <code>run_async</code>/<code>run_async_parallel</code> is not properly bound at the function level. Callers will lose type information. Consider a dedicated <code>T = TypeVar('T')</code> with proper generic signatures. <em>(2/2)</em></div> | |
| <div class="concern-box concern-issue"><strong>#3 Minor:</strong> <code>stop_async_infra</code> docstring should document the intentional <code>_started</code> latch behavior, i.e., that stop does not re-enable the fallback mode. <em>(1/2)</em></div> | |
| </div> | |
| </div> | |
| <!-- Block 2 --> | |
| <div class="block-card" id="block-2"> | |
| <div class="block-header"> | |
| <div class="num">2</div> | |
| <h2>Celery signal wiring</h2> | |
| </div> | |
| <div class="block-body"> | |
| <div class="block-files"> | |
| <div class="file"><span class="dot dot-modified"></span>api/celery.py</div> | |
| </div> | |
| <div class="block-explanation"> | |
| <p>Hooks <code>start_async_infra</code> into <code>worker_process_init</code> (fires for prefork/solo pools only) and <code>stop_async_infra</code> into <code>worker_process_shutdown</code>.</p> | |
| <p>Also refactors <code>install_usr1_signal</code> to early-return for non-gevent pools, moves the gevent import local (since it may not be installed in prefork environments), and renames <code>log_worker_process_shutdown</code> to <code>on_worker_process_shutdown</code>.</p> | |
| <p><strong>Design decisions:</strong></p> | |
| <ul> | |
| <li>Starting in <code>worker_process_init</code> means each forked worker gets its own event loop thread. Correct: you cannot share threads across fork boundaries.</li> | |
| <li>The gevent import was moved from top-level to local with a noqa suppression. Defensible for an optional dependency.</li> | |
| </ul> | |
| </div> | |
| </div> | |
| <details class="diff-section"> | |
| <summary>Show diff: api/celery.py</summary> | |
| <pre class="diff"><span class="diff-line context"> <span class="dec">@worker_ready.connect</span></span> | |
| <span class="diff-line context"> <span class="kw">def</span> <span class="fn">install_usr1_signal</span>(*args: <span class="ty">Any</span>, **kwargs: <span class="ty">Any</span>) -> <span class="kw">None</span>:</span> | |
| <span class="diff-line add">+ logger = structlog.getLogger(__name__)</span> | |
| <span class="diff-line add">+ <span class="kw">if not</span> <span class="bi">any</span>(pair == (<span class="str">'--pool'</span>, <span class="str">'gevent'</span>) <span class="kw">for</span> pair <span class="kw">in</span> <span class="bi">zip</span>(sys.argv[<span class="num">1</span>:], sys.argv[<span class="num">2</span>:], strict=<span class="kw">False</span>)):</span> | |
| <span class="diff-line add">+ logger.info(<span class="str">'Not installing custom USR1 signal handler -- not using "gevent" pool'</span>)</span> | |
| <span class="diff-line add">+ <span class="kw">return</span></span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+ <span class="kw">from</span> gevent <span class="kw">import</span> util <span class="kw">as</span> gevent_util <span class="cmt"># noqa: PLC0415 - only needed for gevent workers</span></span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line context"> <span class="kw">def</span> <span class="fn">dump_stacktraces</span>(*_args: <span class="ty">Any</span>, **_kwargs: <span class="ty">Any</span>) -> <span class="kw">None</span>:</span> | |
| <span class="diff-line ellipsis">...</span> | |
| <span class="diff-line remove">- logger = structlog.getLogger(__name__)</span> | |
| <span class="diff-line remove">- <span class="kw">if</span> <span class="bi">any</span>(pair == (<span class="str">'--pool'</span>, <span class="str">'gevent'</span>) ...):</span> | |
| <span class="diff-line remove">- logger.info(<span class="str">'Installing custom USR1 signal handler'</span>, pid=...)</span> | |
| <span class="diff-line remove">- signal.signal(signal.SIGUSR1, dump_stacktraces)</span> | |
| <span class="diff-line remove">- <span class="kw">else</span>:</span> | |
| <span class="diff-line remove">- logger.info(<span class="str">'Not installing custom USR1 signal handler...'</span>)</span> | |
| <span class="diff-line add">+ logger.info(<span class="str">'Installing custom USR1 signal handler'</span>, pid=psutil.<span class="cls">Process</span>().pid)</span> | |
| <span class="diff-line add">+ signal.signal(signal.SIGUSR1, dump_stacktraces)</span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+<span class="dec">@worker_process_init.connect</span> <span class="cmt"># only fires for prefork/solo pools, not gevent</span></span> | |
| <span class="diff-line add">+<span class="kw">def</span> <span class="fn">on_worker_process_init</span>(**_kwargs: <span class="ty">Any</span>) -> <span class="kw">None</span>:</span> | |
| <span class="diff-line add">+ start_async_infra()</span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line context"> <span class="dec">@worker_process_shutdown.connect</span></span> | |
| <span class="diff-line remove">-<span class="kw">def</span> <span class="fn">log_worker_process_shutdown</span>(pid: <span class="ty">int</span>, exitcode: <span class="ty">int</span>, **_kwargs: <span class="ty">Any</span>) -> <span class="kw">None</span>:</span> | |
| <span class="diff-line add">+<span class="kw">def</span> <span class="fn">on_worker_process_shutdown</span>(pid: <span class="ty">int</span>, exitcode: <span class="ty">int</span>, **_kwargs: <span class="ty">Any</span>) -> <span class="kw">None</span>:</span> | |
| <span class="diff-line add">+ stop_async_infra()</span> | |
| <span class="diff-line context"> logger = structlog.getLogger(__name__)</span> | |
| </pre> | |
| </details> | |
| <div class="concerns"> | |
| <div class="concern-box concern-ok"><strong>No concerns.</strong> The wiring is clean and the signal scope is correct.</div> | |
| </div> | |
| </div> | |
| <!-- Block 3 --> | |
| <div class="block-card" id="block-3"> | |
| <div class="block-header"> | |
| <div class="num">3</div> | |
| <h2>New <code>shared-tests</code> library</h2> | |
| </div> | |
| <div class="block-body"> | |
| <div class="block-files"> | |
| <div class="file"><span class="dot dot-added"></span>libs/shared-tests/ (all)</div> | |
| <div class="file"><span class="dot dot-modified"></span>pyproject.toml</div> | |
| <div class="file"><span class="dot dot-modified"></span>vendor-comms/pyproject.toml</div> | |
| </div> | |
| <div class="block-explanation"> | |
| <p>Introduces a new workspace package <code>shared-tests</code> to house reusable test fixtures. Currently contains <code>mock_async_infra</code>, a pytest fixture that patches the portal with synchronous <code>asyncio.run()</code> execution.</p> | |
| <p><strong>Design decisions:</strong></p> | |
| <ul> | |
| <li>Separate library keeps test utilities out of production dependencies.</li> | |
| <li>The fixture patches both <code>_started</code> and <code>get_portal</code> so callers get synchronous execution without a real portal.</li> | |
| </ul> | |
| </div> | |
| </div> | |
| <details class="diff-section"> | |
| <summary>Show diff: async_infra_fixtures.py (new fixture)</summary> | |
| <pre class="diff"><span class="diff-line add">+<span class="kw">import</span> asyncio</span> | |
| <span class="diff-line add">+<span class="kw">from</span> collections.abc <span class="kw">import</span> <span class="ty">Generator</span></span> | |
| <span class="diff-line add">+<span class="kw">from</span> unittest.mock <span class="kw">import</span> <span class="cls">MagicMock</span></span> | |
| <span class="diff-line add">+<span class="kw">from</span> unittest.mock <span class="kw">import</span> patch</span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+<span class="kw">import</span> pytest</span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+<span class="kw">from</span> shared_utils <span class="kw">import</span> async_infra</span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+<span class="dec">@pytest.fixture()</span></span> | |
| <span class="diff-line add">+<span class="kw">def</span> <span class="fn">mock_async_infra</span>() -> <span class="ty">Generator</span>[<span class="cls">MagicMock</span>]:</span> | |
| <span class="diff-line add">+ mock_portal = <span class="cls">MagicMock</span>()</span> | |
| <span class="diff-line add">+ mock_portal.call = <span class="kw">lambda</span> fn, *args, **kwargs: asyncio.run(fn(*args, **kwargs))</span> | |
| <span class="diff-line add">+ <span class="kw">with</span> patch.object(async_infra, <span class="str">'_started'</span>, <span class="kw">True</span>), patch.object(async_infra, <span class="str">'get_portal'</span>, return_value=mock_portal):</span> | |
| <span class="diff-line add">+ <span class="kw">yield</span> mock_portal</span> | |
| </pre> | |
| </details> | |
| <div class="concerns"> | |
| <div class="concern-box concern-issue"><strong>#4 Design:</strong> <code>shared-tests</code> is not listed in <code>tach.toml</code> <code>source_roots</code>. The other shared libs are all listed. Should it be added for consistency? <em>(1/2)</em></div> | |
| <div class="concern-box concern-issue"><strong>#5 Question:</strong> How will downstream consumers (e.g., the main <code>api</code> test suite) discover the <code>mock_async_infra</code> fixture? Will they need an explicit import in their root conftest? <em>(1/2)</em></div> | |
| </div> | |
| </div> | |
| <!-- Block 4 --> | |
| <div class="block-card" id="block-4"> | |
| <div class="block-header"> | |
| <div class="num">4</div> | |
| <h2>Tests for async infrastructure</h2> | |
| </div> | |
| <div class="block-body"> | |
| <div class="block-files"> | |
| <div class="file"><span class="dot dot-added"></span>test_async_infra.py</div> | |
| <div class="file"><span class="dot dot-added"></span>conftest.py</div> | |
| </div> | |
| <div class="block-explanation"> | |
| <p>Comprehensive coverage for lifecycle (start/stop/double-start), fallback mode (<code>asyncio.run</code>), portal mode, restart resilience (dead portal triggers restart, unrecoverable state exits process), and calling context validation (rejects async and gevent contexts).</p> | |
| <p><strong>Design decisions:</strong></p> | |
| <ul> | |
| <li>Uses <code>unittest.TestCase</code> per coding standards.</li> | |
| <li><code>TestGetPortalResilience.setUp</code> starts then immediately stops, leaving <code>_started=True</code> and <code>_portal=None</code>. Clean simulation of a crashed portal.</li> | |
| </ul> | |
| </div> | |
| </div> | |
| <details class="diff-section"> | |
| <summary>Show diff: test_async_infra.py (139 lines, key excerpts)</summary> | |
| <pre class="diff"><span class="diff-line add">+<span class="kw">class</span> <span class="cls">TestRunAsyncFallback</span>(unittest.<span class="cls">TestCase</span>):</span> | |
| <span class="diff-line add">+ <span class="str">"""Tests run_async / run_async_parallel without portal (asyncio.run fallback)."""</span></span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+ <span class="kw">def</span> <span class="fn">setUp</span>(<span class="slf">self</span>) -> <span class="kw">None</span>:</span> | |
| <span class="diff-line add">+ async_infra._started = <span class="kw">False</span></span> | |
| <span class="diff-line add">+ async_infra._portal = <span class="kw">None</span></span> | |
| <span class="diff-line ellipsis">...</span> | |
| <span class="diff-line add">+<span class="kw">class</span> <span class="cls">TestGetPortalResilience</span>(unittest.<span class="cls">TestCase</span>):</span> | |
| <span class="diff-line add">+ <span class="str">"""Tests get_portal restart-or-die behavior when the portal is unhealthy."""</span></span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+ <span class="kw">def</span> <span class="fn">setUp</span>(<span class="slf">self</span>) -> <span class="kw">None</span>:</span> | |
| <span class="diff-line add">+ async_infra.start_async_infra()</span> | |
| <span class="diff-line add">+ async_infra.stop_async_infra() <span class="cmt"># leave in dead state for testing</span></span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+ <span class="dec">@patch.object</span>(async_infra, <span class="str">'start_async_infra'</span>, wraps=async_infra.start_async_infra)</span> | |
| <span class="diff-line add">+ <span class="kw">def</span> <span class="fn">test_dead_portal_triggers_restart</span>(<span class="slf">self</span>, mock_start):</span> | |
| <span class="diff-line add">+ portal = async_infra.get_portal()</span> | |
| <span class="diff-line add">+ mock_start.assert_called_once()</span> | |
| <span class="diff-line add">+ <span class="slf">self</span>.assertIsNotNone(portal)</span> | |
| <span class="diff-line ellipsis">...</span> | |
| <span class="diff-line add">+<span class="kw">class</span> <span class="cls">TestCheckCallingContext</span>(unittest.<span class="cls">TestCase</span>):</span> | |
| <span class="diff-line add">+ <span class="kw">def</span> <span class="fn">test_rejects_async_context</span>(<span class="slf">self</span>) -> <span class="kw">None</span>:</span> | |
| <span class="diff-line add">+ <span class="kw">async def</span> <span class="fn">_call_from_async</span>() -> <span class="kw">None</span>:</span> | |
| <span class="diff-line add">+ async_infra._check_calling_context()</span> | |
| <span class="diff-line add">+</span> | |
| <span class="diff-line add">+ <span class="kw">with</span> <span class="slf">self</span>.assertRaises(<span class="cls">AsyncInfraError</span>, msg=<span class="str">'async context'</span>):</span> | |
| <span class="diff-line add">+ asyncio.run(_call_from_async())</span> | |
| </pre> | |
| </details> | |
| <div class="concerns"> | |
| <div class="concern-box concern-issue"><strong>#6 Minor:</strong> <code>TestRunAsyncFallback.setUp</code> resets <code>_started = False</code> manually but has no <code>tearDown</code>. If test ordering changes, one class could leak state into another. Consider adding <code>stop_async_infra()</code> in <code>tearDown</code>. <em>(2/2)</em></div> | |
| </div> | |
| </div> | |
| <!-- Block 5 --> | |
| <div class="block-card" id="block-5"> | |
| <div class="block-header"> | |
| <div class="num">5</div> | |
| <h2>Dependency and config changes</h2> | |
| </div> | |
| <div class="block-body"> | |
| <div class="block-files"> | |
| <div class="file"><span class="dot dot-modified"></span>pyproject.toml</div> | |
| <div class="file"><span class="dot dot-modified"></span>tach.toml</div> | |
| <div class="file"><span class="dot dot-modified"></span>uv.lock</div> | |
| </div> | |
| <div class="block-explanation"> | |
| <p>Adds <code>anyio>=4.13</code> as a constraint dependency. Adds <code>shared-tests</code> as workspace member and test dependency. Updates mypy paths. Excludes <code>gevent</code> from tach external checks.</p> | |
| <p>Dependencies are in alphabetical order. No migration files in this PR (expected, pure infrastructure).</p> | |
| </div> | |
| </div> | |
| <div class="concerns"> | |
| <div class="concern-box concern-ok"><strong>No concerns.</strong> Clean dependency management following established patterns.</div> | |
| </div> | |
| </div> | |
| <!-- Summary Table --> | |
| <div class="summary-table"> | |
| <h2>Summary Action Items</h2> | |
| <table> | |
| <thead> | |
| <tr><th>#</th><th>Severity</th><th>Issue</th></tr> | |
| </thead> | |
| <tbody> | |
| <tr> | |
| <td>1</td> | |
| <td><span class="severity design">Design</span></td> | |
| <td><code>start_async_infra</code>: if <code>cm.__enter__()</code> raises, the context manager is leaked. Consider try/except to call <code>cm.__exit__()</code> on failure. <em>(1/2)</em></td> | |
| </tr> | |
| <tr> | |
| <td>2</td> | |
| <td><span class="severity design">Design</span></td> | |
| <td><code>ANY</code> TypeVar is not properly bound on <code>run_async</code>/<code>run_async_parallel</code>. Consider a dedicated <code>T = TypeVar('T')</code> so callers preserve type information. <em>(2/2)</em></td> | |
| </tr> | |
| <tr> | |
| <td>3</td> | |
| <td><span class="severity minor">Minor</span></td> | |
| <td><code>stop_async_infra</code> docstring should document the intentional <code>_started</code> latch behavior. <em>(1/2)</em></td> | |
| </tr> | |
| <tr> | |
| <td>4</td> | |
| <td><span class="severity design">Design</span></td> | |
| <td><code>shared-tests</code> missing from <code>tach.toml</code> <code>source_roots</code>, unlike the other shared libs. <em>(1/2)</em></td> | |
| </tr> | |
| <tr> | |
| <td>5</td> | |
| <td><span class="severity question">Question</span></td> | |
| <td>How will downstream test suites discover <code>mock_async_infra</code>? Explicit conftest import needed? <em>(1/2)</em></td> | |
| </tr> | |
| <tr> | |
| <td>6</td> | |
| <td><span class="severity minor">Minor</span></td> | |
| <td><code>TestRunAsyncFallback</code> missing <code>tearDown</code> to prevent state leakage across test classes. <em>(2/2)</em></td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| <p style="color: var(--text-muted); font-size: 0.75rem; margin-top: 1rem;"> | |
| (N/2) = found by N of 2 independent review agents. Items found by both agents indicate consensus. | |
| </p> | |
| </div> | |
| <script> | |
| function scrollToCard(id) { | |
| document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); | |
| } | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment