Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions .ci/probe_microbench.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"""Windows test-duration probe microbenchmarks (investigation branch).

Times, in isolation (outside pytest), the operations involved in per-test config
validation, to explain the ~19x slower fixture *setup* on Python 3.11 Windows.

1. socket.getaddrinfo() for the bogus host in GALILEO_CONSOLE_URL ("localtest")
2. asyncio event-loop create/close churn
3. cross-thread dispatch latency: run_coroutine_threadsafe round-trip onto a
background run_forever loop — this is exactly what galileo_core's async_run /
EventLoopThreadPool does for every validation request. THE key measurement.
4. GalileoPythonConfig.get() — the per-test autouse fixture (real, unmocked).

Set PROBE_EVENT_LOOP=selector to force the WindowsSelectorEventLoopPolicy so the
default Proactor loop can be A/B'd against it. Everything is timestamped/flushed.
"""

import asyncio
import contextlib
import datetime
import os
import socket
import sys
import threading
import time
from collections.abc import Callable

# Force the selector loop BEFORE any asyncio object is created, if requested.
_FORCED = "default(Proactor on win32)"
if (
os.environ.get("PROBE_EVENT_LOOP") == "selector"
and sys.platform == "win32"
and hasattr(asyncio, "WindowsSelectorEventLoopPolicy")
):
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
_FORCED = "forced WindowsSelectorEventLoopPolicy"

# Make `galileo` importable (installed with --no-root; pytest uses pythonpath=src).
_src = os.path.join(os.getcwd(), "src")
if os.path.isdir(_src):
sys.path.insert(0, _src)


def _ts() -> str:
return datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3]


def log(msg: str) -> None:
# Write straight to stdout (not print()) so ruff's T201 autofix can't strip it.
sys.stdout.write(f"[BENCH {_ts()}] {msg}\n")
sys.stdout.flush()


def bench(label: str, fn: Callable[[], object], n: int = 5) -> list[float]:
samples = []
last_exc = None
for _ in range(n):
t = time.perf_counter()
try:
fn()
except Exception as e:
last_exc = e
samples.append(time.perf_counter() - t)
summary = ", ".join(f"{x * 1000:.1f}ms" for x in samples)
total = sum(samples) * 1000
avg = total / len(samples)
log(f"{label}: avg={avg:.1f}ms total={total:.1f}ms exc={type(last_exc).__name__ if last_exc else None}")
log(f" samples=[{summary}]")
return samples


log(f"python {sys.version}")
log(f"platform {sys.platform}")
log(f"event loop policy: {type(asyncio.get_event_loop_policy()).__name__} ({_FORCED})")

# 1) Name resolution — the prime suspect. "localtest" is intentionally bogus.
log("--- getaddrinfo ---")
for host in ("localtest", "localhost", "127.0.0.1"):
bench(f"getaddrinfo({host!r}, 8088)", lambda host=host: socket.getaddrinfo(host, 8088), n=5)

# 2) asyncio event-loop churn (create + close).
log("--- asyncio loop churn ---")


def _loop_cycle() -> None:
loop = asyncio.new_event_loop()
loop.close()


bench("asyncio new+close", _loop_cycle, n=50)

# 3) Cross-thread dispatch latency. A background thread runs run_forever(); we
# submit coroutines from the main thread via run_coroutine_threadsafe and
# block on the result — the exact shape of galileo_core's async_run. This
# isolates the per-call wakeup cost of the event loop, which is what differs
# between the Proactor and Selector loops on Windows.
log("--- cross-thread dispatch (run_coroutine_threadsafe round-trip) ---")
_bg_loop = asyncio.new_event_loop()
log(f"background loop type: {type(_bg_loop).__name__}")
_bg_thread = threading.Thread(target=_bg_loop.run_forever, daemon=True)
_bg_thread.start()


async def _noop() -> int:
return 1


def _dispatch_noop() -> None:
asyncio.run_coroutine_threadsafe(_noop(), _bg_loop).result()


bench("dispatch noop (pure wakeup, no I/O)", _dispatch_noop, n=50)


async def _yield_chain() -> None:
for _ in range(20):
await asyncio.sleep(0)


def _dispatch_yield() -> None:
asyncio.run_coroutine_threadsafe(_yield_chain(), _bg_loop).result()


bench("dispatch 20x await sleep(0) (ready-callback iterations)", _dispatch_yield, n=50)


async def _tiny_sleeps() -> None:
# 1ms requested x10. On Windows the ~15.6ms timer tick rounds each up.
for _ in range(10):
await asyncio.sleep(0.001)


def _dispatch_tiny() -> None:
asyncio.run_coroutine_threadsafe(_tiny_sleeps(), _bg_loop).result()


bench("dispatch 10x await sleep(0.001) (timer granularity)", _dispatch_tiny, n=20)

_bg_loop.call_soon_threadsafe(_bg_loop.stop)

# 4) GalileoPythonConfig.get — the per-test autouse fixture, REAL (no mocks).
log("--- GalileoPythonConfig.get (unmocked: hits real localtest resolution) ---")
os.environ.setdefault("GALILEO_CONSOLE_URL", "http://localtest:8088")
os.environ.setdefault("GALILEO_API_KEY", "api-1234567890")
try:
from galileo.config import GalileoPythonConfig

def _config_get() -> None:
cfg = GalileoPythonConfig.get(console_url="http://localtest:8088", api_key="api-1234567890")
with contextlib.suppress(Exception):
cfg.reset()

bench("GalileoPythonConfig.get+reset", _config_get, n=2)
except Exception as e:
log(f"config import/get failed: {type(e).__name__}: {e}")

log("done")
101 changes: 101 additions & 0 deletions .ci/probe_yappi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Thread-aware profile of the per-test config validation (investigation branch).

cProfile only sees the calling thread, but galileo_core runs the 3 validation
requests on a background EventLoopThread — so we use yappi (wall-clock, all
threads, builtins) to attribute where the ~12x-more timer-quantized waits on
Python 3.11 Windows actually accrue.

Reproduces the *mocked* path (respx), i.e. the real test conditions (~685 ms on
3.11), NOT the unmocked DNS path. Profiles N config.get()+reset() cycles.
"""

import contextlib
import datetime
import os
import sys
from unittest.mock import patch
from uuid import uuid4

_src = os.path.join(os.getcwd(), "src")
if os.path.isdir(_src):
sys.path.insert(0, _src)

os.environ.setdefault("GALILEO_CONSOLE_URL", "http://localtest:8088")
os.environ.setdefault("GALILEO_API_KEY", "api-1234567890")


def _ts() -> str:
return datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3]


def log(msg: str) -> None:
sys.stdout.write(f"[YAPPI {_ts()}] {msg}\n")
sys.stdout.flush()


import respx # noqa: E402
import yappi # noqa: E402

from galileo.config import GalileoPythonConfig # noqa: E402

_USER = {"id": str(uuid4()), "email": "user@example.com", "role": "user"}
_N = 10
_ok = 0
_last_exc = None


def _one_cycle() -> None:
global _ok, _last_exc
try:
cfg = GalileoPythonConfig.get(console_url="http://localtest:8088", api_key="api-1234567890")
with contextlib.suppress(Exception):
cfg.reset()
_ok += 1
except Exception as e:
_last_exc = e


log(f"python {sys.version.split()[0]} platform {sys.platform}")

with (
patch("galileo_core.schemas.base_config.jwt_decode", return_value={"exp": float("inf")}),
respx.mock(assert_all_called=False) as router,
):
router.get(url__regex=r".*/healthcheck.*").respond(200, json={"status": "ok"})
router.post(url__regex=r".*/login/api_key.*").respond(200, json={"access_token": "secret_jwt_token"})
router.get(url__regex=r".*/current_user.*").respond(200, json=_USER)

_one_cycle() # warmup: also spins up the (one-time) EventLoopThreadPool
log(f"warmup ok={_ok} exc={type(_last_exc).__name__ if _last_exc else None}")

yappi.set_clock_type("wall")
yappi.start(builtins=True)
for _ in range(_N):
_one_cycle()
yappi.stop()

log(f"profiled {_N} cycles, ok={_ok}/{_N + 1}, last_exc={type(_last_exc).__name__ if _last_exc else None}")

# Per-thread wall time (which thread holds the cost).
log("================ THREAD STATS ================")
yappi.get_thread_stats().print_all()

# Top functions by total wall time across ALL threads. ncall reveals how many
# times each is hit per run — the 3.10 vs 3.11 delta should show as ncall.
log("================ TOP 50 FUNCTIONS BY ttot (all threads, builtins) ================")
stats = yappi.get_func_stats()
stats.sort("ttot", "desc")
for i, s in enumerate(stats):
if i >= 50:
break
sys.stdout.write(f" ttot={s.ttot * 1000:9.1f}ms tsub={s.tsub * 1000:9.1f}ms ncall={s.ncall:>8} {s.full_name}\n")
sys.stdout.flush()

# Explicitly surface the usual Windows-wait suspects regardless of rank.
log("================ WAIT/SLEEP/POLL SUSPECTS ================")
_needles = ("sleep", "select", "GetQueuedCompletionStatus", "_run_once", "getaddrinfo", "poll", "wait", "Overlapped")
for s in stats:
if any(n.lower() in s.full_name.lower() for n in _needles):
sys.stdout.write(f" ttot={s.ttot * 1000:9.1f}ms ncall={s.ncall:>8} avg={s.tavg * 1000:7.3f}ms {s.full_name}\n")
sys.stdout.flush()
log("done")
74 changes: 63 additions & 11 deletions .github/workflows/ci-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.10", "3.11", "3.12", "3.13"]
# INVESTIGATION BRANCH: trimmed to the two cells that bracket the
# Windows slowdown — 3.10 (fast: ~6 min) vs 3.11 (slow: ~28 min).
os: [windows-latest]
python-version: ["3.10", "3.11"]

runs-on: ${{ matrix.os }}
# Hard cap per matrix job — bail out fast on real hangs instead of
# burning CI minutes up to the GitHub-default 6h ceiling.
timeout-minutes: 30
# Raised from 30 -> 45 so the instrumented full run (~23 min on 3.11) plus
# the extra diagnostic steps don't get truncated by the timeout.
timeout-minutes: 45

steps:
- name: Checkout
Expand All @@ -38,25 +40,75 @@ jobs:
run: git config --system core.longpaths true

- name: Install poetry
run: pipx install poetry==2.1.3
run: pipx install poetry==2.4.1

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
cache: "poetry"
python-version: ${{ matrix.python-version }}
cache-dependency-path: "pyproject.toml"
cache-dependency-path: "poetry.lock"

- name: Install invoke
run: pipx install invoke

- name: Install Dependencies
run: invoke install

- name: Validate Types
# All diagnostic steps below run in Git-bash (present on Windows runners)
# so heredocs, pipes, and `head` behave consistently regardless of pwsh.
# Ordered cheap -> expensive: the fast probes land their data even if the
# full instrumented run later hits the timeout.

- name: "[probe] Environment + dependency dump"
if: always()
shell: bash
run: |
echo "::group::interpreter"
poetry run python -VV
poetry run python -c "import sys, asyncio, platform; print('platform:', platform.platform()); print('loop_policy:', type(asyncio.get_event_loop_policy()).__name__)"
echo "::endgroup::"
echo "::group::poetry show"
poetry show
echo "::endgroup::"

- name: "[probe] Verify Poetry Python version"
if: always()
shell: bash
run: |
poetry run python -c "
import sys
expected = tuple(map(int, '${{ matrix.python-version }}'.split('.')))
actual = sys.version_info[:len(expected)]
print('Python:', sys.version)
assert actual == expected, f'Expected Python {expected}, got {actual}'
"

# FIX VERIFICATION: the autouse set_validated_config fixture now bypasses
# the slow async validation round-trips (HYBIM-790). The conftest timing
# plugin prints the per-test "setup avg"; compare against the recorded
# pre-fix baselines (subset serial: 685ms on 3.11 / 55ms on 3.10; full
# suite parallel: 1335ms on 3.11). If the fix works, 3.11 setup collapses
# toward 3.10 and the full suite drops from ~23min to a few minutes.

- name: "[probe] Microbench (getaddrinfo / asyncio / dispatch)"
if: always()
shell: bash
run: poetry run python .ci/probe_microbench.py

- name: "[probe] Subset serial (setup avg WITH fix)"
if: always()
run: invoke type-check
shell: bash
run: |
echo "[probe $(date -u +%H:%M:%S)] subset serial — with fix"
poetry run pytest tests/test_configuration.py -o addopts= \
-p no:xdist --disable-socket --allow-hosts=127.0.0.1,localhost \
-p no:cacheprovider --durations=0 -q

- name: Run Tests
- name: "[probe] Full suite (setup avg + total WITH fix)"
if: always()
run: invoke test-report-xml
shell: bash
run: |
echo "[probe $(date -u +%H:%M:%S)] full suite start — with fix"
poetry run pytest tests --durations=0 -ra -q
echo "[probe $(date -u +%H:%M:%S)] full suite end — with fix"
2 changes: 1 addition & 1 deletion galileo-a2a/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
env = [
"GALILEO_CONSOLE_URL=http://localtest:8088",
"GALILEO_CONSOLE_URL=http://fake.test:8088",
"GALILEO_API_KEY=api-1234567890",
"GALILEO_PROJECT=test-project",
"GALILEO_LOG_STREAM=test-log-stream",
Expand Down
2 changes: 1 addition & 1 deletion galileo-a2a/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
# 3. Security - prevents real API keys from leaking into test logs
import os

os.environ["GALILEO_CONSOLE_URL"] = "http://localtest:8088"
os.environ["GALILEO_CONSOLE_URL"] = "http://fake.test:8088"
os.environ["GALILEO_API_KEY"] = "api-1234567890"
os.environ["GALILEO_PROJECT"] = "test-project"
os.environ["GALILEO_LOG_STREAM"] = "test-log-stream"
Expand Down
2 changes: 1 addition & 1 deletion galileo-adk/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
env = [
"GALILEO_CONSOLE_URL=http://localtest:8088",
"GALILEO_CONSOLE_URL=http://fake.test:8088",
"GALILEO_API_KEY=api-1234567890",
"GALILEO_PROJECT=test-project",
"GALILEO_LOG_STREAM=test-log-stream",
Expand Down
2 changes: 1 addition & 1 deletion galileo-adk/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ def set_validated_config(
# Reset any cached loggers from previous tests
GalileoLoggerSingleton().reset_all()

config = GalileoPythonConfig.get(console_url="http://localtest:8088", api_key="api-1234567890")
config = GalileoPythonConfig.get(console_url="http://fake.test:8088", api_key="api-1234567890")
yield
# Clean up after test
GalileoLoggerSingleton().reset_all()
Expand Down
Loading
Loading