惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

H
Help Net Security
T
ThreatConnect
SecWiki News
SecWiki News
F
Future of Privacy Forum
AWS News Blog
AWS News Blog
C
Cisco Blogs
A
Arctic Wolf
Vercel News
Vercel News
The GitHub Blog
The GitHub Blog
Scott Helme
Scott Helme
V
V2EX
博客园 - 叶小钗
阮一峰的网络日志
阮一峰的网络日志
K
Kaspersky official blog
G
Google Developers Blog
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
P
Privacy International News Feed
C
Cyber Attacks, Cyber Crime and Cyber Security
N
News | PayPal Newsroom
Schneier on Security
Schneier on Security
NISL@THU
NISL@THU
Microsoft Azure Blog
Microsoft Azure Blog
量子位
The Hacker News
The Hacker News
Stack Overflow Blog
Stack Overflow Blog
Security Latest
Security Latest
M
Microsoft Research Blog - Microsoft Research
Google Online Security Blog
Google Online Security Blog
博客园_首页
C
CXSECURITY Database RSS Feed - CXSecurity.com
I
InfoQ
Google DeepMind News
Google DeepMind News
Y
Y Combinator Blog
The Cloudflare Blog
Microsoft Security Blog
Microsoft Security Blog
Martin Fowler
Martin Fowler
Cisco Talos Blog
Cisco Talos Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
T
Troy Hunt's Blog
F
Fox-IT International blog
S
Security @ Cisco Blogs
博客园 - 司徒正美
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
C
Comments on: Blog
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
L
LINUX DO - 最新话题
GbyAI
GbyAI
Project Zero
Project Zero
腾讯CDC
T
Tailwind CSS Blog

DEV Community

Harness Engineering: Stop Re-Prompting Your Coding Agent Every Session HTML meta referrer: canonical reference AWS MCP Server Just Gave AI Agents Your Cloud Keys — Here's Why That Should Worry You Announcing the Trust Identity Protocol (TIP): HTTPS for the AI Era We built the feature in two days. Making it reliable took two weeks. LuisCore /for-agents.json — agent bootstrap — daily syndication · 2026-05-26 A Curious Journey Into Reverse Engineering an AI-Generated Python .exe Part 2: Enterprise Decision Intelligence Architecture: AI Governance, Threshold Policy Engines, and Operational AI Systems I will continue using Devise with Rails 8! The Developer's Guide to Picking the Right AI Code Model in 2026 (I Spent $500 So You Don’t Have To) 30 Kubernetes Tasks Every CKA Candidate Should Practice Before Exam Day Why Some Websites Feel Instantly Better to Use Advanced React Patterns I Wish I Knew 5 Years Ago ¿Cómo optimizar algoritmos en arreglos y listas con la técnica de dos punteros? I scanned 8 popular open source repos with one command. Here's what I found. mcp-probe v1.6.0: Stricter GitHub Actions checks for MCP CI gates How we connect two strangers' webcams fast (and keep the TURN bill small) LLM Agents Are Now Finding Zero-Days: How AI is Autonomously Rewriting the Rules of Vulnerability Research Minimal Code Doesn’t Mean Stable Code How I manage 40+ skills across Claude Code, Codex, and .agents folders Hardening Stealth Browser Fingerprint Integrity and State Persistence Quick Tip: Benchmarking Multimodal APIs in Under 10 Minutes How I Slashed My AI API Bill by 92% in 2026 — A Cost Optimizer's Speed Benchmark Guide How I Slashed My AI API Bill by 95% — A Practical Guide for 2026 A Go outbox library that runs inside your own DB transaction How I Built a Credit Optimizer That Saves 30-75% on AI Agent Costs (Open Architecture) The Missing POP: How I Ported a Yul Contract to Huff by Reading Every Opcode The Moment the Config Parser Became the Bottleneck Churn Tool Stack by Revenue Stage ($5K to $50K+) What I Learned Exploring AI-Generated 3D: A Hands-On Tour of Meshy, Tripo, and Three.js Day 15 - Software Composition Analysis(SCA) Contributing Upstream Instead of Forking: My grape-swagger-rails Story Behind The Badge: How We Built 2,000 Hackable Badges For Temporal Replay Access Control Doesn't Scale Linearly -- Part 3 33x faster than Rust: Why I stopped waiting for my compiler and built my own. I Built My First Production AWS Project as a Career Changer Why Detecting PII Matters More Than Ever JSON Schema in 10 Minutes — Validation, Types & Real Examples Python Tasks How I Started My Cybersecurity Journey as an SQA Engineer 🔐 Why "fancy fonts" in Discord and Instagram bios turn into boxes ☁️ GKE private cluster setup — common mistakes and how to avoid them I Thought a Username Didn’t Matter… Until I Saw How Much People Care About It Claude for Small Business: 382K Day-One Buyer's Guide I Built a Diagnostic Toolkit for PyTorch Because I Was Tired of Guessing Why Models Fail How I Built an AI-Powered Incident RCA Platform with LangGraph and RAG The Paywall Was a Painted Door Sonnet hallucinated. My agent stored it as fact. How React-Style Time-Slicing Keeps UIs Responsive 这个 Princeton 开源项目让 AI 自己修 Bug,19K Stars 但 90% 的人只用了 1% 功能 🔥 SWE-agent's 5 Hidden Uses Nobody Told You About 🔥 Decompiling Serial Number U-36: Python TERCOM Reconstruction, Cryptographic Logistical Forensics, and Swarm Consensus Fault Tolerance Microservices Patterns You Cannot Outrun a Wave I Fired My Entire Node.js Stack — Rust Rebuilt It in 3 Weeks (The Ugly Truth) BoxAgnts Introduction (2) — AI Agent Toolbox Cursor 3 ships parallel AI agents. Here is the multi-agent workflow that actually works. Prisma-7 A Complete Beginners Guide (With Free Cloud Database!) Akses HDD Rumah dari Laptop Kantor Pakai Tailscale + SMB (Tanpa VPN Ribet) Content Pipeline in MonoGame: Why I Don't Use It Debug Log #1 — The Pipeline That Looked Broken Data Structures in JavaScript: When to Use What (2026) BGP Route Flap Damping: A Solution or a New Problem? First look at AWS DevOps Agent The Next Big “Cult App” Probably Isn’t Another Social Media Platform From Template to Production-Shaped: An AI-Native Dev Flow for Go Side Projects Idempotency Keys: The API Pattern That Saves You From Duplicate Payments and Phantom Records Everyone's Building Jarvis. Nobody's Even Close. The Moment the Jaeger Tracer Exhausted Itself and What We Switched To How to Fix Tool-Use Loops in Autonomous Coding Agents Months of self-testing: Citations shine, other features remain unproven. Claude Code for Canary Deployments: How I Ship to 1% of Users Before Breaking Everything Your recurring scraper is re-downloading data that didn't change. Here's the 15-line fix (conditional GET) 20 Years of GPUs in Numbers: How FLOPS & TDP Grew, and Who Led the NVIDIA vs AMD Race (open dataset, 13.5k GPUs) Espressif Reveals CoreBoard and Korvo Dev Kits for ESP32-S31 Composable Abstraction Layer: o pattern que faltava entre Pinia e seus componentes Vue Your GitHub Actions Logs Are Leaking LLM Keys and Your SIEM Isn't Catching It Solving Complex Logic with Claude and Research Papers Building TheEpicBook: A Deep Dive into a Node.js Monolithic Web Application Haber yazilimi, haber scripti, haber sistemi: ayni urun, uc ayri arama niyeti Predicting Blood Glucose Fluctuations: Building a Transformer-based CGM Forecaster with PyTorch & InfluxDB Pre-task hooks: the one-line wire-up that gives your Hono agent shared memory Concurrent writes to a shared agent memory: what we shipped, what we punted on Building a Production Serverless URL Shortener on AWS — 21 Articles, Every Test Run for Real My CKA Cheat Sheet: Commands, Aliases, and Documentation Tricks I Used During the Exam Frontend Engineering Beyond Pixels: The Architecture of Digital Accessibility VLA or IL? A Controlled Dataset for Testing Whether Finetuning Turns Your VLA into a Fancy Imitation Learner Fabric AI Functions Turn GenAI Into a Data Pipeline Step Proximate vs Ultimate: The Bug Is Never Just the Bug The Treasure Hunt Engine That Broke Before the Traffic Did Reset Windows Update: The Definitive MSP Guide to RWU Your Resume Was Never Built for This AI Writes 46% of Code Now: What Snap's Layoffs Mean for Developers in 2026 From Chatbot to Agent — Tool Calling with NVIDIA NIM Fatigue and Fracture Mechanics: Why Parts Break Below Their Yield Strength I built a token-level debugger for comparing two LLMs VCP-Virtual Private Cloud Embedding sing-box in an iOS messenger to bypass Russian DPI (no VPN) Microsoft Copilot just exfiltrated a company's files. The attack was one email. Here's the mechanism. RAG 시스템 실전 구축 (v42)
Bajándole todos los minutos posibles al CI del backend con mas de 1000 tests
Franchesco R · 2026-05-26 · via DEV Community

Esta es la historia de muchas horas de trabajo en un hoyo continuo tratando de elegir optimizaciones entre package managers, caches paralelismo, optimizaciones de PostgreSQL, advisory locks, y el golpe de realidad de darme cuenta que el cuello de botella no era nada de lo que había estado optimizando, si no algo que no había analizado.

El punto de partida

El codebase:

  1. un backend FastAPI (~50 routers, ~45 modelos SQLAlchemy)
  2. 1826 tests, desplegado vía GitHub Actions a AWS ECS Fargate en arm64.
  3. El CI corre en un self-hosted spot runner (4X concurrencia, en Graviton).

En un inicio comenzamos con un paralelismo tradicional, nada del otro mundo, solo dos shards vía pytest-split.

# .github/workflows/deploy-backend.yml — inicial
- run: cd backend && pip install --cache-dir "$RUNNER_TEMP/pip-cache" -r requirements-dev.txt

- name: Tests (shard ${{ matrix.shard }}/2)
  run: |
    cd backend
    pytest tests/ \
      --splits 2 --group ${{ matrix.shard }} \
      --cov=app --cov-report= \
      -v

Enter fullscreen mode Exit fullscreen mode

docker build estándar. Y usando pip para todo.

# backend/Dockerfile — inicial
FROM python:3.11-slim AS base
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq-dev gcc curl \
    && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

Enter fullscreen mode Exit fullscreen mode

Intento no. 1: instalar dependencias más rápido con uv

uv es el reemplazo más sencillo que existe de pip creado por Astral, normalmente de 10x hasta 100× más rápido para resolver e instalar paquetes. La migración es bastante sencilla ya que son dos
líneas en el Dockerfile y listo:

# Pin vía la imagen multi-stage oficial para que el binario sea reproducible
COPY --from=ghcr.io/astral-sh/uv:0.5.11 /uv /uvx /usr/local/bin/
RUN uv pip install --system --no-cache-dir -r requirements.txt

Enter fullscreen mode Exit fullscreen mode

requirements.txt y requirements-dev.txt quedan exactamente iguales, uv pip los lee nativo. No hay que migrar forzosamente a pyproject.toml.

Y en CI, le implementamos la action correspondiente:

- uses: astral-sh/setup-uv@v6
  with:
    version: "0.5.11"
    enable-cache: true
    cache-suffix: "shard-${{ matrix.shard }}"
- run: cd backend && uv pip install --system -r requirements-dev.txt

Enter fullscreen mode Exit fullscreen mode

En este caso el cache-suffix por shard replica el workaround del --cache-dir por shard que teníamos con pip — sin él, los dos shards que caen en el mismo runner self-hosted se pelean por el mismo tarball y uno de los dos se muere con tar exit code 2.

La comparativa, en números:

$ time uv pip install -r requirements.txt
...
uv pip install -r requirements.txt  1.23s user 1.59s system 51% cpu 5.509 total

Enter fullscreen mode Exit fullscreen mode

5.5s contra ~60s con pip y casi 80s en el runner.

Intento no. 2: BuildKit cache mounts en el Dockerfile

El layer cache de Docker solo ayuda cuando el COPY no invalida las
layers de abajo. Cualquier cambio en requirements.txt re-construye
todo lo que sigue. Los BuildKit cache mounts ayudan a persistir el contenido entre builds sin importar la invalidación de layers:

# backend/Dockerfile — con cache mounts
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update \
    && apt-get upgrade -y \
    && apt-get install -y --no-install-recommends \
        libpq-dev gcc curl

COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/uv \
    uv pip install --system -r requirements.txt

Enter fullscreen mode Exit fullscreen mode

Algo importante a mencionar:

  1. Quitar el rm -rf /var/lib/apt/lists. Ya que servía para reducir el tamaño de la imagen, pero con cache mounts BuildKit es dueño de esos paths y los limpia entre builds.
  2. sharing=locked serializa lecturas concurrentes. Sin eso, dos builds en paralelo en el mismo runner pueden corromper el caché.

El runner que en este caso es self-hosted trae el builder legacy de Docker por default, y el primer push después del commit de cache mounts se murió con:

the --mount option requires BuildKit. Refer to
https://docs.docker.com/go/buildkit/ to learn how to build images with
BuildKit enabled

Enter fullscreen mode Exit fullscreen mode

Lo arreglamos, de lo más fácil: instalar buildx y alias de docker build a docker buildx build:

- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v3
  with:
    install: true

Enter fullscreen mode Exit fullscreen mode

El install: true es la opción crítica. Sin eso, docker build sigue usando el builder legacy.

Buildx no carga al daemon local por default, así que el step de
docker push que ya teníamos también dejó de funcionar. Cambio a
--push directo:

- run: |
    cd backend
    docker build \
      --cache-from $IMAGE:latest \
      --cache-to type=inline \
      -t $IMAGE:sha-$TAG \
      -t $IMAGE:latest \
      --push \
      .

Enter fullscreen mode Exit fullscreen mode

El --cache-to type=inline mete la metadata del layer cache dentro
de la imagen, cojn estoel --cache-from del siguiente build lo jala de regreso el ECR.

El primer build todavía paga el costo de instalación de las dependencias y paquetes; los builds siguientes con los mismos requirements se lo brincan. Pasando de ~40s a ~10s en cache hits.

Intento no. 3: la trampa de pytest-xdist

El instinto, pensar que entre mas shards, podemos ahorrarnos mas tiempo, es decir 2 shards × 4 workers = 8 procesos en paralelo.

# backend/requirements-dev.txt
pytest-xdist==3.6.1

Enter fullscreen mode Exit fullscreen mode

# .github/workflows/deploy-backend.yml
pytest tests/ \
  --splits 2 --group ${{ matrix.shard }} \
  -n 4 --dist worksteal \
  --cov=app --cov-report= \
  -v

Enter fullscreen mode Exit fullscreen mode

Aquí es donde empieza el dilema.

Trampa 1: DROP SCHEMA entre workers

El conftest remueve y recrea el schema al inicio de cada session:

@pytest_asyncio.fixture(scope="session", autouse=True)
async def setup_db():
    async with test_engine.begin() as conn:
        await conn.execute(sa.text("DROP SCHEMA public CASCADE"))
        await conn.execute(sa.text("CREATE SCHEMA public"))
    async with test_engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)

Enter fullscreen mode Exit fullscreen mode

scope="session" significa una vez por session de pytest. Con
pytest-xdist, cada worker es su propia session.

Los cuatro workers apuntando a la misma DB myapp_test se la pasan removiendo el schema de los demás a media corrida.

Fix: una DB por worker, con sufijo de PYTEST_XDIST_WORKER:

def _suffix_dburl(url: str, worker: str) -> str:
    if "?" in url:
        url_part, _, query = url.partition("?")
        query = f"?{query}"
    else:
        url_part, query = url, ""
    if "/" not in url_part.split("@", 1)[-1]:
        return url
    prefix, dbname = url_part.rsplit("/", 1)
    return f"{prefix}/{dbname}_{worker}{query}"

_XDIST_WORKER = os.environ.get("PYTEST_XDIST_WORKER")
if _XDIST_WORKER:
    for key in ("DATABASE_URL", "TEST_DATABASE_URL"):
        if val := os.environ.get(key):
            os.environ[key] = _suffix_dburl(val, _XDIST_WORKER)

Enter fullscreen mode Exit fullscreen mode

El worker gw0 agarra myapp_test_gw0, gw1 agarra _gw1, y así de manera secuencial.

Trampa 2: max_locks_per_transaction

Primera corrida con -n auto (10 workers en el runner self-hosted):

================== 31 passed, 11 warnings, 8 errors in 14.73s ==================

Enter fullscreen mode Exit fullscreen mode

DROP SCHEMA CASCADE sobre ~50 tablas toma un relation-level lock por cada una.

10 workers × 50 = 500 locks.

El default de PostgreSQL para max_locks_per_transaction es 64

Como lo arreglamos, bajamos el limite/cap a 4 workers por shard en vez de auto. 4 × 2 shards = 8 procesos paralelos.

Trampa 3: la divergencia del DATABASE_URL

Después de limitar a los workers, un test empezó a fallar en CI:

test_comp_expiry_worker_skips_stripe_managed
  sqlalchemy.exc.ProgrammingError: relation "subscriptions" does not
  exist

Enter fullscreen mode Exit fullscreen mode

¿Por qué? El test invoca un background worker:

async def test_comp_expiry_worker_skips_stripe_managed(client, db, admin):
    member = await _seed_user(db, tier="TEST")
    sub = await _seed_sub(db, member, tier="TEST", ...)
    await db.commit()

    from app.workers.comp_expiry_worker import expire_comp_subscriptions
    await expire_comp_subscriptions()  # ← abre su propio AsyncSessionLocal

Enter fullscreen mode Exit fullscreen mode

Fix: alinear DATABASE_URL con TEST_DATABASE_URL antes de que
app.main importe lo que sea:

# backend/tests/conftest.py — top del archivo, antes de importar app.main
_test_db = os.environ.get("TEST_DATABASE_URL")
if _test_db:
    os.environ["DATABASE_URL"] = _test_db

_XDIST_WORKER = os.environ.get("PYTEST_XDIST_WORKER")
if _XDIST_WORKER:
    for key in ("DATABASE_URL", "TEST_DATABASE_URL"):
        if val := os.environ.get(key):
            os.environ[key] = _suffix_dburl(val, _XDIST_WORKER)

from app.main import app  # ← engine construido con la URL correcta desde el inicio

Enter fullscreen mode Exit fullscreen mode

Intento no. 4: template database de PostgreSQL

Cada worker todavía usa un DROP SCHEMA + CREATE TABLE × 50 al
inicio de la session.
En el runner ARM eso son ~5 segundos por worker.

PostgreSQL tiene un truco: CREATE DATABASE ... TEMPLATE
clona una DB vía copia a nivel de archivo en ~100ms en lugar de
ejecutar todo el SQL.

Construyes el schema una vez en una DB template dedicada, y luego cada worker la clona:

# backend/tests/conftest.py
TEMPLATE_DB_NAME = "myapp_test_template"
_TEMPLATE_LOCK_ID = 7321456789012345  # int arbitrario 

async def _ensure_template_db(admin_url: str, template_url: str) -> None:
    admin_engine = create_async_engine(
        admin_url, isolation_level="AUTOCOMMIT", poolclass=NullPool
    )
    try:
        async with admin_engine.connect() as conn:
            exists = (await conn.execute(
                sa.text("SELECT 1 FROM pg_database WHERE datname = :n"),
                {"n": TEMPLATE_DB_NAME},
            )).scalar_one_or_none()
            if not exists:
                await conn.execute(
                    sa.text(f'CREATE DATABASE "{TEMPLATE_DB_NAME}"')
                )
    finally:
        await admin_engine.dispose()

    tmpl_engine = create_async_engine(template_url, poolclass=NullPool)
    try:
        async with tmpl_engine.connect() as conn:
            has_schema = (await conn.execute(sa.text(
                "SELECT 1 FROM information_schema.tables "
                "WHERE table_schema = 'public' AND table_name = 'users' "
                "LIMIT 1"
            ))).scalar_one_or_none()
        if not has_schema:
            async with tmpl_engine.begin() as conn:
                await conn.run_sync(Base.metadata.create_all)
    finally:
        await tmpl_engine.dispose()


async def _clone_template_to(admin_url: str, dbname: str) -> None:
    admin_engine = create_async_engine(
        admin_url, isolation_level="AUTOCOMMIT", poolclass=NullPool
    )
    try:
        async with admin_engine.connect() as conn:
            # Matar conexiones stale para que el DROP no se bloquee
            await conn.execute(sa.text(
                "SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
                "WHERE datname = :n AND pid <> pg_backend_pid()"
            ), {"n": dbname})
            await conn.execute(sa.text(f'DROP DATABASE IF EXISTS "{dbname}"'))
            await conn.execute(sa.text(
                f'CREATE DATABASE "{dbname}" TEMPLATE "{TEMPLATE_DB_NAME}"'
            ))
    finally:
        await admin_engine.dispose()

Enter fullscreen mode Exit fullscreen mode

Trampa 4: filelock se cuelga

Los workers se pelean por crear el template. El primer intento usaba
filelock:

# No hagas esto
import filelock
lock = filelock.FileLock("/tmp/myapp_test_template.lock", timeout=120)
await asyncio.to_thread(lock.acquire)

Enter fullscreen mode Exit fullscreen mode

Los workers consistentemente llegan a el timeout de 120s.

Con un cambio a un advisory lock de PostgreSQL, se soluciona. Es el mismo recurso compartido que ya necesitamos, se auto libera al cerrar la conexión (así que un worker que truene no puede dejar el lock huérfano como pasa con file locks):

async def _setup_db_via_template() -> None:
    split = _split_db_url(TEST_DATABASE_URL)
    if split is None:
        return
    prefix, dbname = split
    admin_url = f"{prefix}/postgres"
    template_url = f"{prefix}/{TEMPLATE_DB_NAME}"

    admin_engine = create_async_engine(
        admin_url, isolation_level="AUTOCOMMIT", poolclass=NullPool
    )
    try:
        async with admin_engine.connect() as conn:
            # Bloquea hasta conseguirlo; se auto-libera al cerrar la conexión
            await conn.execute(
                sa.text("SELECT pg_advisory_lock(:id)"),
                {"id": _TEMPLATE_LOCK_ID},
            )
            try:
                await _ensure_template_db(admin_url, template_url)
            finally:
                await conn.execute(
                    sa.text("SELECT pg_advisory_unlock(:id)"),
                    {"id": _TEMPLATE_LOCK_ID},
                )
    finally:
        await admin_engine.dispose()

    await _clone_template_to(admin_url, dbname)

Enter fullscreen mode Exit fullscreen mode

El primer worker que agarra el lock construye el template (~5s); los demás esperan y luego clonan en ~100ms cada uno.

Después de todo esto — DBs por worker, alineación de DATABASE_URL,
template clones, advisory locks — corriendo un smoke local de 7
archivos:

88 passed, 5 warnings in 12.28s

Enter fullscreen mode Exit fullscreen mode

Bajó de ~2 minutos con el setup roto inicial de xdist.

Intento no. 5: la medición que rompió el supuesto

El wall time por shard seguía como en 7 minutos.
El CI total en 11m una mejora modesta sobre el baseline, no la bajada dramática que sugería el smoke local.

Hora de medir en serio. Agregué un step diagnóstico que corre una vez
y reporta tiempos:

- name: Diagnose pytest startup cost
  if: matrix.shard == 1
  run: |
    cd backend
    echo "::group::A — solo collection de pytest, sin coverage"
    time pytest tests/ --co -q --no-header --no-cov
    echo "::endgroup::"
    echo "::group::B — solo collection de pytest CON coverage"
    time pytest tests/ --co -q --no-header --cov=app --cov-report=
    echo "::endgroup::"
    echo "::group::C — corrida chiquita serial, sin coverage, sin xdist"
    time pytest tests/test_critical.py -q --no-header --no-cov
    echo "::endgroup::"

Enter fullscreen mode Exit fullscreen mode

Los resultados en el runner ARM self-hosted fueron:

A — solo collection, sin coverage          real    0m18.909s
B — solo collection CON coverage           real    0m23.810s
C — corrida chica serial, sin nada         real    0m21.481s

Enter fullscreen mode Exit fullscreen mode

La observación clave, hacer collection de 1826 tests y
correr un solo archivo chico sin coverage tardan lo mismo.

O sea, el costo no es la collection. No es coverage (solo 5s de
diferencia).
No es la ejecución de los tests. Es el startup de pytest + el import del conftest.
Específicamente el from app.main import app en el conftest que jala ~45 modelos, ~50 routers, middleware, settings — todo de un jalón. Veinte segundos de cold-import en este runner.
Cada vez.

Con xdist, cada uno de los 4 workers paga este costo de 20s
independiente. Spawn en paralelo, pero el runner solo tiene ciertos
cores; los cold imports de modelos pydantic y mappers de SQLAlchemy se pelean por CPU.

Ahí estaban los ~80s perdidos.

Round 6: soltar xdist, irse a 4 shards

Si cada proceso de pytest usa 20s fijos de startup, la optimización
más barata es usarlo menos veces.
2 shards × 4 workers de xdist = 10 startups de pytest (2 controllers + 8 workers).
4 shards × 1 proceso serial = 4 startups de pytest.

test:
  runs-on: ${{ inputs.runner || 'self-hosted' }}
  needs: lint
  strategy:
    fail-fast: false
    matrix:
      shard: [1, 2, 3, 4]
  services:
    postgres:
      image: postgres:15
      env: { POSTGRES_DB: myapp_test, POSTGRES_USER: appuser, POSTGRES_PASSWORD: testpass }
      options: >-
        --health-cmd pg_isready --health-interval 10s
        --health-timeout 5s --health-retries 5
      ports: ["5432"]
  steps:
    - uses: actions/checkout@v5
    - uses: actions/setup-python@v6
      with: { python-version: "3.11" }
    - uses: astral-sh/setup-uv@v6
      with:
        version: "0.5.11"
        enable-cache: true
        cache-suffix: "shard-${{ matrix.shard }}"
    - run: cd backend && uv pip install --system -r requirements-dev.txt
    - name: Tests (shard ${{ matrix.shard }}/4)
      env:
        DATABASE_URL: postgresql://someappuser:sometestpass@localhost:${{ job.services.postgres.ports[5432] }}/myapp_test
        TEST_DATABASE_URL: postgresql+asyncpg://someappuser:sometestpass@localhost:${{ job.services.postgres.ports[5432] }}/myapp_test
        SECRET_KEY: ci-test-secret-key-32bytes-minimum-length
        ENV: test
        COVERAGE_FILE: .coverage.${{ matrix.shard }}
      run: |
        cd backend
        pytest tests/ \
          --splits 4 --group ${{ matrix.shard }} \
          --cov=app --cov-report= \
          -q --no-header

Enter fullscreen mode Exit fullscreen mode

Cada shard corre ~450 tests en serial con un solo proceso depytest.

Sin fan-out de xdist, sin re-import de conftest por worker, sin contención de CPU en los cold imports.

El runner self-hosted anuncia 4x de concurrencia disponible, así que los cuatro shards corren en paralelo.

El coverage se extiende a cuatro archivos:

coverage:
  needs: test
  steps:
    - uses: actions/checkout@v5
    - uses: actions/setup-python@v6
      with: { python-version: "3.11" }
    - uses: astral-sh/setup-uv@v6
      with: { version: "0.5.11" }
    - run: uv pip install --system coverage==7.6.1
    - uses: actions/download-artifact@v5
      with:
        pattern: coverage-*
        path: backend/
        merge-multiple: true
    - run: |
        cd backend
        coverage combine .coverage.1 .coverage.2 .coverage.3 .coverage.4
        coverage report --fail-under=60

Enter fullscreen mode Exit fullscreen mode

También quité el -v y lo cambié por -q --no-header. Con xdist, el -v bufferea el output por worker hasta que termina un test, -q tiene output instantáneo y muestra la salida de inmediato.

El resultado

Corrida real del CI después del push:

✓ lint              in 1m6s
✓ test (1)          in 6m46s
✓ test (2)          in 6m44s
✓ test (3)          in 6m57s
✓ test (4)          in 6m57s
✓ coverage          in 10s
✓ build-and-deploy  in 1m53s

Enter fullscreen mode Exit fullscreen mode

Timeline del test step del shard 1:

16:31:44  inicio del step
16:32:57  [pytest-split] Running group 1/4   ← 1m13s adentro
16:33:25  ........... [ 15%]                  ← primer punto a 1m41s
16:34:18  ........... [ 45%]
16:37:04  ........... [ 91%]
16:37:47  ........... [100%]  472 passed in 5m21s

Enter fullscreen mode Exit fullscreen mode

Tiempo al primer output: 1m13s vs 2m37s antes.
Más o menos a la mitad.

CI total: 10m16s vs ~20m del baseline antes de cualquier optimización.

Otras cosas que probe y que definitivamente no ayudaron

  • Precompile de pyc (python -m compileall). Medición local: 13.0s en frío vs 12.6s en caliente.
  • pytest-xdist --dist worksteal está bueno cuando cada worker tiene un costo de setup parecido. Cuando el setup es ~20s y los tests son mayormente rápidos, el impuesto de startup por worker se come la ganancia de paralelismo.
  • filelock para serializar entre procesos. No me serializaba bien los workers en mi setup. Me cambié a advisory locks de PG.
  • El flag -v. Causaba 2 minutos de buffering de output bajo xdist sin beneficio en performance.

Qué sí ayudaría a futuro

  1. Mejorar el conftest. El from app.main import app es el costo más grande en mi caso. La app importa cada router, cada modelo, cada service al arranque. Partirla (lazy router registration, o romper el import monolítico) bajaría a 20s de startup.
  2. Una segunda pasada en los shards. pytest-split balancea por duración. Si un shard consistentemente va 30s atrás, re-balancea:
   pytest --splits 4 --group 1 --store-durations

Enter fullscreen mode Exit fullscreen mode

comitea un .test_durations nuevo contra el que los futuros runs
se balancean.

  1. Sacar coverage del hot path. Coverage solo agregó ~5s en ARM en nuestro benchmark, pero a ~5s × 4 shards = 20s ahorrados. Trade-off: o lo aceptas en el job de tests o agregas un job de coverage no-paralelo aparte. Nosotros lo dejamos en el path.

Lecciones

  1. Mide antes de optimizar. Varias horas de trabajo en xdist, template DBs y BuildKit fueron útiles, pero el verdadero unlock vino de un step diagnóstico de 30 segundos que me dijo que el cuello de botella era el startup de pytest, no nada de lo que llevaba atacando.
  2. El pytest más rápido es uno que no inicias dos veces. Cada invocación de pytest paga un costo de startup fijo. Con un conftest pesado, ese costo domina todo lo demás. Más paralelismo = más startups = más costo. Menos shards más grandes le ganan a más shards chiquitos pasado un umbral.
  3. pytest-xdist no es gratis. Funciona bien cuando el costo por test >> el costo de startup. Cuando el startup es 20s y los tests son de 500ms, la ecuación se invierte.
  4. Recursos por worker necesitan aislamiento por worker. El bug sutil fue que los background workers abrían su propio AsyncSessionLocal apuntando a la DB equivocada. El fix no estaba en la aplicación — estaba en el conftest de tests, alineando las env vars antes de que la app importara nada.
  5. PostgreSQL tiene las primitivas. Advisory locks para sincronizar entre procesos, CREATE DATABASE ... TEMPLATE para bootstrap de schema. Las dos me salvaron de inventar mecanismos más débiles encima.
  6. Los BuildKit cache mounts siguen sub-utilizados. Dos líneas en un Dockerfile (--mount=type=cache para los cachés de apt y uv/pip) bajaron los docker builds repetidos de ~40s a ~10s, pero solo después de cambiarse del builder legacy de Docker vía setup-buildx-action.