test(dirty): add multi-app routing tests

Add tests to verify that when multiple dirty apps are configured,
messages are correctly routed to the appropriate app based on app_path.

New files:
- tests/support_dirty_apps.py: CounterApp and EchoApp test apps
- tests/dirty/test_multi_app_routing.py: 13 routing tests covering
  app loading, routing, state separation, error handling, and
  concurrent requests
This commit is contained in:
Benoit Chesneau 2026-01-24 18:37:17 +01:00
parent 0e05c824e9
commit 62a29bd0e1
2 changed files with 758 additions and 0 deletions

View File

@ -0,0 +1,618 @@
#
# This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information.
"""Tests for routing requests to multiple dirty apps.
This module verifies that when multiple dirty apps are configured,
messages are correctly routed to the appropriate app based on app_path.
"""
import asyncio
import os
import struct
import tempfile
import pytest
from concurrent.futures import ThreadPoolExecutor
from gunicorn.config import Config
from gunicorn.dirty.worker import DirtyWorker
from gunicorn.dirty.arbiter import DirtyArbiter
from gunicorn.dirty.protocol import DirtyProtocol, make_request
from gunicorn.dirty.errors import DirtyAppNotFoundError
# App paths for test apps
COUNTER_APP_PATH = "tests.support_dirty_apps:CounterApp"
ECHO_APP_PATH = "tests.support_dirty_apps:EchoApp"
class MockLog:
"""Mock logger for testing."""
def __init__(self):
self.messages = []
def debug(self, msg, *args):
self.messages.append(("debug", msg % args if args else msg))
def info(self, msg, *args):
self.messages.append(("info", msg % args if args else msg))
def warning(self, msg, *args):
self.messages.append(("warning", msg % args if args else msg))
def error(self, msg, *args):
self.messages.append(("error", msg % args if args else msg))
def critical(self, msg, *args):
self.messages.append(("critical", msg % args if args else msg))
def exception(self, msg, *args):
self.messages.append(("exception", msg % args if args else msg))
def close_on_exec(self):
pass
def reopen_files(self):
pass
class MockStreamWriter:
"""Mock StreamWriter that captures written messages."""
def __init__(self):
self.messages = []
self._buffer = b""
self.closed = False
def write(self, data):
self._buffer += data
async def drain(self):
while len(self._buffer) >= DirtyProtocol.HEADER_SIZE:
length = struct.unpack(
DirtyProtocol.HEADER_FORMAT,
self._buffer[:DirtyProtocol.HEADER_SIZE]
)[0]
total_size = DirtyProtocol.HEADER_SIZE + length
if len(self._buffer) >= total_size:
msg_data = self._buffer[DirtyProtocol.HEADER_SIZE:total_size]
self._buffer = self._buffer[total_size:]
self.messages.append(DirtyProtocol.decode(msg_data))
else:
break
def close(self):
self.closed = True
async def wait_closed(self):
pass
def get_extra_info(self, name):
return None
class TestWorkerMultiAppLoading:
"""Tests for loading multiple apps in a worker."""
def test_worker_loads_multiple_apps(self):
"""Test that worker loads all configured apps."""
cfg = Config()
log = MockLog()
with tempfile.TemporaryDirectory() as tmpdir:
socket_path = os.path.join(tmpdir, "worker.sock")
worker = DirtyWorker(
age=1,
ppid=os.getpid(),
app_paths=[COUNTER_APP_PATH, ECHO_APP_PATH],
cfg=cfg,
log=log,
socket_path=socket_path
)
worker.load_apps()
# Both apps should be loaded
assert COUNTER_APP_PATH in worker.apps
assert ECHO_APP_PATH in worker.apps
# Apps should be initialized
counter_app = worker.apps[COUNTER_APP_PATH]
echo_app = worker.apps[ECHO_APP_PATH]
assert counter_app.initialized is True
assert echo_app.initialized is True
worker._cleanup()
def test_worker_apps_are_distinct_instances(self):
"""Test that each app is a distinct instance."""
cfg = Config()
log = MockLog()
with tempfile.TemporaryDirectory() as tmpdir:
socket_path = os.path.join(tmpdir, "worker.sock")
worker = DirtyWorker(
age=1,
ppid=os.getpid(),
app_paths=[COUNTER_APP_PATH, ECHO_APP_PATH],
cfg=cfg,
log=log,
socket_path=socket_path
)
worker.load_apps()
counter_app = worker.apps[COUNTER_APP_PATH]
echo_app = worker.apps[ECHO_APP_PATH]
# They should be different instances
assert counter_app is not echo_app
# They should be different types
assert type(counter_app).__name__ == "CounterApp"
assert type(echo_app).__name__ == "EchoApp"
worker._cleanup()
class TestWorkerMultiAppRouting:
"""Tests for routing requests to correct app based on app_path."""
@pytest.mark.asyncio
async def test_worker_routes_to_counter_app(self):
"""Test that worker routes request to CounterApp correctly."""
cfg = Config()
log = MockLog()
with tempfile.TemporaryDirectory() as tmpdir:
socket_path = os.path.join(tmpdir, "worker.sock")
worker = DirtyWorker(
age=1,
ppid=os.getpid(),
app_paths=[COUNTER_APP_PATH, ECHO_APP_PATH],
cfg=cfg,
log=log,
socket_path=socket_path
)
worker.load_apps()
worker._executor = ThreadPoolExecutor(max_workers=1)
try:
# Call increment on CounterApp
result = await worker.execute(
COUNTER_APP_PATH, "increment", [], {"amount": 5}
)
assert result == 5
# Call get_value on CounterApp
result = await worker.execute(
COUNTER_APP_PATH, "get_value", [], {}
)
assert result == 5
finally:
worker._cleanup()
@pytest.mark.asyncio
async def test_worker_routes_to_echo_app(self):
"""Test that worker routes request to EchoApp correctly."""
cfg = Config()
log = MockLog()
with tempfile.TemporaryDirectory() as tmpdir:
socket_path = os.path.join(tmpdir, "worker.sock")
worker = DirtyWorker(
age=1,
ppid=os.getpid(),
app_paths=[COUNTER_APP_PATH, ECHO_APP_PATH],
cfg=cfg,
log=log,
socket_path=socket_path
)
worker.load_apps()
worker._executor = ThreadPoolExecutor(max_workers=1)
try:
# Call echo on EchoApp
result = await worker.execute(
ECHO_APP_PATH, "echo", ["hello"], {}
)
assert result == "ECHO: hello"
# Set new prefix
result = await worker.execute(
ECHO_APP_PATH, "set_prefix", ["TEST>"], {}
)
assert result == "TEST>"
# Echo with new prefix
result = await worker.execute(
ECHO_APP_PATH, "echo", ["world"], {}
)
assert result == "TEST> world"
finally:
worker._cleanup()
@pytest.mark.asyncio
async def test_worker_routes_mixed_requests(self):
"""Test routing interleaved requests to different apps."""
cfg = Config()
log = MockLog()
with tempfile.TemporaryDirectory() as tmpdir:
socket_path = os.path.join(tmpdir, "worker.sock")
worker = DirtyWorker(
age=1,
ppid=os.getpid(),
app_paths=[COUNTER_APP_PATH, ECHO_APP_PATH],
cfg=cfg,
log=log,
socket_path=socket_path
)
worker.load_apps()
worker._executor = ThreadPoolExecutor(max_workers=1)
try:
# Interleave calls to both apps
result = await worker.execute(
COUNTER_APP_PATH, "increment", [1], {}
)
assert result == 1
result = await worker.execute(
ECHO_APP_PATH, "echo", ["first"], {}
)
assert result == "ECHO: first"
result = await worker.execute(
COUNTER_APP_PATH, "increment", [2], {}
)
assert result == 3
result = await worker.execute(
ECHO_APP_PATH, "echo", ["second"], {}
)
assert result == "ECHO: second"
# Verify final state of each app
result = await worker.execute(
COUNTER_APP_PATH, "get_value", [], {}
)
assert result == 3
result = await worker.execute(
ECHO_APP_PATH, "get_echo_count", [], {}
)
assert result == 2
finally:
worker._cleanup()
class TestAppStateSeparation:
"""Tests for verifying apps maintain independent state."""
@pytest.mark.asyncio
async def test_apps_maintain_separate_state(self):
"""Test that multiple apps maintain independent state."""
cfg = Config()
log = MockLog()
with tempfile.TemporaryDirectory() as tmpdir:
socket_path = os.path.join(tmpdir, "worker.sock")
worker = DirtyWorker(
age=1,
ppid=os.getpid(),
app_paths=[COUNTER_APP_PATH, ECHO_APP_PATH],
cfg=cfg,
log=log,
socket_path=socket_path
)
worker.load_apps()
worker._executor = ThreadPoolExecutor(max_workers=1)
try:
# Modify CounterApp state
await worker.execute(COUNTER_APP_PATH, "increment", [10], {})
await worker.execute(COUNTER_APP_PATH, "increment", [5], {})
# Modify EchoApp state
await worker.execute(ECHO_APP_PATH, "set_prefix", ["CUSTOM:"], {})
await worker.execute(ECHO_APP_PATH, "echo", ["msg1"], {})
await worker.execute(ECHO_APP_PATH, "echo", ["msg2"], {})
# Verify CounterApp state is independent
counter_val = await worker.execute(
COUNTER_APP_PATH, "get_value", [], {}
)
assert counter_val == 15
# Verify EchoApp state is independent
prefix = await worker.execute(
ECHO_APP_PATH, "get_prefix", [], {}
)
assert prefix == "CUSTOM:"
echo_count = await worker.execute(
ECHO_APP_PATH, "get_echo_count", [], {}
)
assert echo_count == 2
# Reset CounterApp and verify EchoApp unaffected
await worker.execute(COUNTER_APP_PATH, "reset", [], {})
counter_val = await worker.execute(
COUNTER_APP_PATH, "get_value", [], {}
)
assert counter_val == 0
# EchoApp should be unaffected
echo_count = await worker.execute(
ECHO_APP_PATH, "get_echo_count", [], {}
)
assert echo_count == 2
finally:
worker._cleanup()
class TestUnknownAppPath:
"""Tests for handling unknown app paths."""
@pytest.mark.asyncio
async def test_unknown_app_path_raises_error(self):
"""Test that unknown app_path raises DirtyAppNotFoundError."""
cfg = Config()
log = MockLog()
with tempfile.TemporaryDirectory() as tmpdir:
socket_path = os.path.join(tmpdir, "worker.sock")
worker = DirtyWorker(
age=1,
ppid=os.getpid(),
app_paths=[COUNTER_APP_PATH, ECHO_APP_PATH],
cfg=cfg,
log=log,
socket_path=socket_path
)
worker.load_apps()
worker._executor = ThreadPoolExecutor(max_workers=1)
try:
with pytest.raises(DirtyAppNotFoundError):
await worker.execute(
"nonexistent:App", "action", [], {}
)
finally:
worker._cleanup()
@pytest.mark.asyncio
async def test_handle_request_unknown_app_returns_error(self):
"""Test that handle_request returns error for unknown app."""
cfg = Config()
log = MockLog()
with tempfile.TemporaryDirectory() as tmpdir:
socket_path = os.path.join(tmpdir, "worker.sock")
worker = DirtyWorker(
age=1,
ppid=os.getpid(),
app_paths=[COUNTER_APP_PATH],
cfg=cfg,
log=log,
socket_path=socket_path
)
worker.load_apps()
worker._executor = ThreadPoolExecutor(max_workers=1)
try:
request = make_request(
request_id="test-unknown",
app_path="unknown:App",
action="test"
)
writer = MockStreamWriter()
await worker.handle_request(request, writer)
assert len(writer.messages) == 1
response = writer.messages[0]
assert response["type"] == DirtyProtocol.MSG_TYPE_ERROR
assert "unknown:App" in response["error"]["message"]
finally:
worker._cleanup()
class TestConcurrentMultiAppRequests:
"""Tests for concurrent requests to different apps."""
@pytest.mark.asyncio
async def test_concurrent_requests_to_different_apps(self):
"""Test concurrent requests routed to different apps."""
cfg = Config()
cfg.set("dirty_threads", 4) # Allow concurrent execution
log = MockLog()
with tempfile.TemporaryDirectory() as tmpdir:
socket_path = os.path.join(tmpdir, "worker.sock")
worker = DirtyWorker(
age=1,
ppid=os.getpid(),
app_paths=[COUNTER_APP_PATH, ECHO_APP_PATH],
cfg=cfg,
log=log,
socket_path=socket_path
)
worker.load_apps()
worker._executor = ThreadPoolExecutor(max_workers=4)
try:
# Create concurrent tasks for both apps
tasks = [
worker.execute(COUNTER_APP_PATH, "increment", [1], {}),
worker.execute(ECHO_APP_PATH, "echo", ["msg1"], {}),
worker.execute(COUNTER_APP_PATH, "increment", [2], {}),
worker.execute(ECHO_APP_PATH, "echo", ["msg2"], {}),
worker.execute(COUNTER_APP_PATH, "increment", [3], {}),
worker.execute(ECHO_APP_PATH, "echo", ["msg3"], {}),
]
results = await asyncio.gather(*tasks)
# Verify echo results are correct (regardless of order)
echo_results = [r for r in results if isinstance(r, str)]
assert len(echo_results) == 3
assert all(r.startswith("ECHO:") for r in echo_results)
# Counter results will vary based on execution order
# but final state should reflect all increments
counter_val = await worker.execute(
COUNTER_APP_PATH, "get_value", [], {}
)
assert counter_val == 6 # 1 + 2 + 3
finally:
worker._cleanup()
class TestMultiAppProtocolHandling:
"""Tests for protocol-level handling of multi-app requests."""
@pytest.mark.asyncio
async def test_handle_request_routes_correctly(self):
"""Test handle_request routes to correct app via protocol."""
cfg = Config()
log = MockLog()
with tempfile.TemporaryDirectory() as tmpdir:
socket_path = os.path.join(tmpdir, "worker.sock")
worker = DirtyWorker(
age=1,
ppid=os.getpid(),
app_paths=[COUNTER_APP_PATH, ECHO_APP_PATH],
cfg=cfg,
log=log,
socket_path=socket_path
)
worker.load_apps()
worker._executor = ThreadPoolExecutor(max_workers=1)
try:
# Request to CounterApp
request1 = make_request(
request_id="req-counter",
app_path=COUNTER_APP_PATH,
action="increment",
args=[5]
)
writer1 = MockStreamWriter()
await worker.handle_request(request1, writer1)
assert len(writer1.messages) == 1
assert writer1.messages[0]["type"] == DirtyProtocol.MSG_TYPE_RESPONSE
assert writer1.messages[0]["result"] == 5
# Request to EchoApp
request2 = make_request(
request_id="req-echo",
app_path=ECHO_APP_PATH,
action="echo",
args=["test message"]
)
writer2 = MockStreamWriter()
await worker.handle_request(request2, writer2)
assert len(writer2.messages) == 1
assert writer2.messages[0]["type"] == DirtyProtocol.MSG_TYPE_RESPONSE
assert writer2.messages[0]["result"] == "ECHO: test message"
finally:
worker._cleanup()
class TestMultiAppCleanup:
"""Tests for cleanup of multiple apps."""
def test_cleanup_closes_all_apps(self):
"""Test that cleanup closes all loaded apps."""
cfg = Config()
log = MockLog()
with tempfile.TemporaryDirectory() as tmpdir:
socket_path = os.path.join(tmpdir, "worker.sock")
worker = DirtyWorker(
age=1,
ppid=os.getpid(),
app_paths=[COUNTER_APP_PATH, ECHO_APP_PATH],
cfg=cfg,
log=log,
socket_path=socket_path
)
worker.load_apps()
counter_app = worker.apps[COUNTER_APP_PATH]
echo_app = worker.apps[ECHO_APP_PATH]
assert counter_app.closed is False
assert echo_app.closed is False
worker._cleanup()
assert counter_app.closed is True
assert echo_app.closed is True
class TestMultiAppArbiterIntegration:
"""Tests for arbiter routing with multiple apps configured."""
@pytest.mark.asyncio
async def test_arbiter_routes_no_workers_error(self):
"""Test arbiter returns error when no workers for multi-app config."""
cfg = Config()
cfg.set("dirty_workers", 0)
cfg.set("dirty_apps", [COUNTER_APP_PATH, ECHO_APP_PATH])
log = MockLog()
arbiter = DirtyArbiter(cfg=cfg, log=log)
arbiter.pid = os.getpid()
try:
# Request to CounterApp - should fail (no workers)
request = make_request(
request_id="test-counter",
app_path=COUNTER_APP_PATH,
action="increment"
)
writer = MockStreamWriter()
await arbiter.route_request(request, writer)
assert len(writer.messages) == 1
response = writer.messages[0]
assert response["type"] == DirtyProtocol.MSG_TYPE_ERROR
assert "No dirty workers available" in response["error"]["message"]
finally:
arbiter._cleanup_sync()
def test_arbiter_config_has_multiple_apps(self):
"""Test arbiter config correctly stores multiple apps."""
cfg = Config()
cfg.set("dirty_apps", [COUNTER_APP_PATH, ECHO_APP_PATH])
log = MockLog()
arbiter = DirtyArbiter(cfg=cfg, log=log)
try:
app_paths = arbiter.cfg.dirty_apps
assert COUNTER_APP_PATH in app_paths
assert ECHO_APP_PATH in app_paths
assert len(app_paths) == 2
finally:
arbiter._cleanup_sync()

140
tests/support_dirty_apps.py Normal file
View File

@ -0,0 +1,140 @@
#
# This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information.
"""Support module for multi-app dirty tests.
Provides test applications with distinct behaviors for verifying
that requests are correctly routed to the appropriate app.
"""
from gunicorn.dirty.app import DirtyApp
class CounterApp(DirtyApp):
"""App that maintains a counter.
This app demonstrates stateful behavior where instance variables
persist across requests.
"""
def __init__(self):
self.counter = 0
self.initialized = False
self.closed = False
def init(self):
"""Initialize the counter app."""
self.counter = 0
self.initialized = True
def increment(self, amount=1):
"""Increment the counter by the given amount.
Args:
amount: Amount to increment by (default: 1)
Returns:
The new counter value
"""
self.counter += amount
return self.counter
def decrement(self, amount=1):
"""Decrement the counter by the given amount.
Args:
amount: Amount to decrement by (default: 1)
Returns:
The new counter value
"""
self.counter -= amount
return self.counter
def get_value(self):
"""Get the current counter value.
Returns:
The current counter value
"""
return self.counter
def reset(self):
"""Reset the counter to zero.
Returns:
The counter value (0)
"""
self.counter = 0
return self.counter
def close(self):
"""Clean up the counter app."""
self.closed = True
self.counter = 0
class EchoApp(DirtyApp):
"""App that echoes input with a configurable prefix.
This app demonstrates a different behavior pattern from CounterApp
for verifying app routing.
"""
def __init__(self):
self.prefix = "ECHO:"
self.initialized = False
self.closed = False
self.echo_count = 0
def init(self):
"""Initialize the echo app."""
self.prefix = "ECHO:"
self.echo_count = 0
self.initialized = True
def echo(self, message):
"""Echo a message with the current prefix.
Args:
message: The message to echo
Returns:
The prefixed message
"""
self.echo_count += 1
return f"{self.prefix} {message}"
def set_prefix(self, prefix):
"""Set a new prefix for echo messages.
Args:
prefix: The new prefix to use
Returns:
The new prefix
"""
self.prefix = prefix
return prefix
def get_prefix(self):
"""Get the current prefix.
Returns:
The current prefix
"""
return self.prefix
def get_echo_count(self):
"""Get the number of echo calls made.
Returns:
The echo count
"""
return self.echo_count
def close(self):
"""Clean up the echo app."""
self.closed = True
self.echo_count = 0