Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
b40953a
Update erlang_python to latest main branch
benoitc Mar 12, 2026
d56b266
Update hackney to 3.2.1
benoitc Mar 12, 2026
8346031
Update to erlang_python new API
benoitc Mar 12, 2026
e83367e
Update erlang_python to hex 2.1.0
benoitc Mar 12, 2026
02a0ce3
Add request pre-parsing and BytesIO pooling for WSGI
benoitc Mar 12, 2026
fbc1699
Add ASGIResponse pooling for reduced allocation overhead
benoitc Mar 12, 2026
c704e48
Optimize create_environ to use template and BytesIO pool
benoitc Mar 12, 2026
6902c37
Add persistent worker pool for WSGI/ASGI mounts
benoitc Mar 12, 2026
4e9170c
Stream pooled responses directly to client
benoitc Mar 12, 2026
1e56c33
Simplify heartbeat config to global constants
benoitc Mar 12, 2026
56cd3de
Use single persistent_term lookup for channel routing
benoitc Mar 12, 2026
f7d2142
Use single ETS lookup_element for channel routing
benoitc Mar 12, 2026
e415220
Simplify channel storage to one key per channel
benoitc Mar 12, 2026
209d7b2
Split channel storage into set_channel and clear_channels
benoitc Mar 12, 2026
4bdba70
Add static Python context pool with scheduler affinity
benoitc Mar 12, 2026
42ec53d
Fix HAS_ERLANG initialization in Python workers
benoitc Mar 14, 2026
fed3497
Refactor WSGI/ASGI to use context_call with schedule_inline
benoitc Mar 14, 2026
aef0889
Add WSGI streaming request body support
benoitc Mar 14, 2026
20b4ef1
Simplify WSGI to unified channel-based architecture
benoitc Mar 14, 2026
5058544
Replace py_channel with py_buffer for zero-copy WSGI body streaming
benoitc Mar 14, 2026
888371c
Rename workers to num_contexts and wire to context pool
benoitc Mar 14, 2026
568353e
Use py_event_loop:spawn_task for ASGI async execution
benoitc Mar 17, 2026
85b17f8
Optimize ASGI performance with scope template and cached send
benoitc Mar 17, 2026
1bbab18
Fix ASGI large response streaming
benoitc Mar 17, 2026
3b2c5b8
Cache lifespan state in handler state at startup
benoitc Mar 17, 2026
1c7e479
Optimize WSGI/ASGI performance with 5 targeted improvements
benoitc Mar 17, 2026
fc172ce
Fix ASGI lifespan state persistence across requests
benoitc Mar 17, 2026
aefb601
Add owngil context mode and benchmark support
benoitc Mar 17, 2026
2628fac
Fix Python ModuleNotFoundError for hornbeam_lifespan_runner
benoitc Mar 18, 2026
3dcac22
Implement ASGI-compliant mutable lifespan state
benoitc Mar 19, 2026
0b533bf
Simplify ASGI response handling, fix default status code
benoitc Mar 19, 2026
93d9457
Clean up ASGI handler, remove debug logging
benoitc Mar 19, 2026
2e83042
Add ASGI protocol compliance validation
benoitc Mar 19, 2026
ab828be
Use py_event_loop_pool for ASGI task distribution
benoitc Mar 19, 2026
e982002
Improve WSGI/ASGI protocol handling and multi-app lifespan
benoitc Mar 19, 2026
9588229
Optimize ASGI response streaming and reduce hot path overhead
benoitc Mar 19, 2026
d9ce7d5
Add threshold-based ASGI request body handling
benoitc Mar 19, 2026
5090b11
[experimental] Add protocol-style ASGI loop handler
benoitc Mar 19, 2026
05d5638
Simplify ASGI loop handler, remove response channel
benoitc Mar 20, 2026
76c17a8
Unify ASGI handler, remove old code
benoitc Mar 20, 2026
91a8977
Simplify ASGI message protocol and cache cowboy pid/streamid
benoitc Mar 23, 2026
ce1cd51
Use sys.modules instead of importlib for app lookup
benoitc Mar 23, 2026
b7434c4
Use main event loop directly in ASGI handler
benoitc Mar 23, 2026
8c73619
Use lazy state proxy with callbacks for ASGI state access
benoitc Mar 24, 2026
2357e99
Use event loop pool for ASGI task distribution
benoitc Mar 24, 2026
f0e244a
Use py_context_router for context management and cache state proxies
benoitc Mar 24, 2026
d3589c6
Reuse default py_context_router pool and remove per-mount workers
benoitc Mar 24, 2026
d2d5471
Update docs for shared context pool and ASGI performance optimizations
benoitc Mar 24, 2026
35b8ef0
Remove dead code from performance optimizations
benoitc Mar 24, 2026
ed63e6b
Bump erlang_python to 2.2.0
benoitc Mar 24, 2026
5985407
Fix edoc comments with unescaped angle brackets
benoitc Mar 24, 2026
1eda9f3
Add OTP 28 and Python 3.14 to CI matrix
benoitc Mar 24, 2026
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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ jobs:
strategy:
fail-fast: false
matrix:
otp: ['27.0', '27.1']
python: ['3.12', '3.13']
otp: ['27.0', '27.1', '28.0']
python: ['3.12', '3.13', '3.14']

steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -66,7 +66,7 @@ jobs:
- name: Set up Erlang
uses: erlef/setup-beam@v1
with:
otp-version: '27.1'
otp-version: '28.0'
rebar3-version: '3.24'

- name: Build ex_doc
Expand All @@ -82,7 +82,7 @@ jobs:
- name: Set up Erlang
uses: erlef/setup-beam@v1
with:
otp-version: '27.1'
otp-version: '28.0'
rebar3-version: '3.24'

- name: Compile
Expand Down
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed

- **Shared Context Pool**: All mounts now share the default `py_context_router` pool
- Removed per-mount `workers` option (use global pool size instead)
- Better resource utilization across multiple mounted apps
- Simplified architecture with cached NIF refs

- **ASGI Performance Optimizations**:
- Event loop pool for parallel ASGI task distribution
- Cached state proxies per mount_id (avoid allocation per request)
- Preloaded app modules via `py_import:ensure_imported`
- Lazy state proxy with ETS-backed callbacks

### Performance

- ASGI now outperforms WSGI by 11-16% across test scenarios:
- Simple requests (100 conc): ~70k req/s (+13%)
- High concurrency (500 conc): ~64k req/s (+16%)
- Sustained load (200 conc): ~71k req/s (+14%)

### Removed

- `workers` option from per-mount configuration (use shared pool)

## [1.4.1] - 2026-02-25

### Fixed
Expand Down
27 changes: 27 additions & 0 deletions benchmarks/asgi_large_response_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# ASGI app that returns large responses to test streaming

SMALL_BODY = b'x' * 1000 # 1KB - should buffer
LARGE_BODY = b'x' * 100000 # 100KB - should stream

async def app(scope, receive, send):
"""ASGI app with configurable response size."""
if scope['type'] == 'http':
path = scope.get('path', '/')

if path == '/large':
body = LARGE_BODY
else:
body = SMALL_BODY

await send({
'type': 'http.response.start',
'status': 200,
'headers': [
[b'content-type', b'text/plain'],
[b'content-length', str(len(body)).encode()],
],
})
await send({
'type': 'http.response.body',
'body': body,
})
17 changes: 17 additions & 0 deletions benchmarks/asgi_noop_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Minimal ASGI app - no processing

async def app(scope, receive, send):
"""Minimal ASGI app that returns Hello World."""
if scope['type'] == 'http':
await send({
'type': 'http.response.start',
'status': 200,
'headers': [
[b'content-type', b'text/plain'],
[b'content-length', b'13'],
],
})
await send({
'type': 'http.response.body',
'body': b'Hello, World!',
})
213 changes: 213 additions & 0 deletions benchmarks/bench_pooled_comparison.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
#!/bin/bash
# Benchmark comparison: pooled vs non-pooled workers
#
# This script compares performance between:
# - Non-pooled: Traditional per-request Python invocation
# - Pooled: Persistent worker pool with channel-based dispatch

set -e

cd "$(dirname "$0")/.."

# Check if ab is available
if ! command -v ab &> /dev/null; then
echo "Error: 'ab' (Apache Bench) not found."
echo " macOS: brew install httpd"
echo " Linux: apt-get install apache2-utils"
exit 1
fi

# Check if project is compiled
if [ ! -d "_build/default/lib" ]; then
echo "Compiling hornbeam..."
rebar3 compile
fi

# Configuration
REQUESTS=10000
CONCURRENCY=100
PORT_NONPOOLED=8765
PORT_POOLED=8766
WORKERS=4

cleanup() {
echo "Cleaning up..."
kill $PID_NONPOOLED 2>/dev/null || true
kill $PID_POOLED 2>/dev/null || true
wait $PID_NONPOOLED 2>/dev/null || true
wait $PID_POOLED 2>/dev/null || true
}
trap cleanup EXIT

echo "=============================================="
echo " Hornbeam Pooled vs Non-Pooled Benchmark"
echo "=============================================="
echo ""
echo "Configuration:"
echo " Requests: $REQUESTS"
echo " Concurrency: $CONCURRENCY"
echo " Workers (pooled): $WORKERS"
echo ""

# Start non-pooled server (single-app mode)
echo "Starting non-pooled server on port $PORT_NONPOOLED..."
erl -pa _build/default/lib/*/ebin \
-noshell \
-eval "
application:ensure_all_started(hornbeam),
hornbeam:start(<<\"simple_app:application\">>, #{
bind => <<\"127.0.0.1:$PORT_NONPOOLED\">>,
worker_class => wsgi,
pythonpath => [<<\"benchmarks\">>]
}).
" > /dev/null 2>&1 &
PID_NONPOOLED=$!

# Start pooled server (multi-app mode with pool_enabled)
echo "Starting pooled server on port $PORT_POOLED..."
erl -pa _build/default/lib/*/ebin \
-noshell \
-eval "
application:ensure_all_started(hornbeam),
py:exec(<<\"import sys; sys.path.insert(0, 'benchmarks')\">>),
hornbeam:start(#{
bind => <<\"127.0.0.1:$PORT_POOLED\">>,
mounts => [
{<<\"/\">>, <<\"simple_app:application\">>, #{
worker_class => wsgi,
workers => $WORKERS,
pool_enabled => true,
timeout => 30000
}}
]
}).
" > /dev/null 2>&1 &
PID_POOLED=$!

# Wait for servers to be ready
echo "Waiting for servers to start..."
sleep 4

# Verify servers are running
for port in $PORT_NONPOOLED $PORT_POOLED; do
for i in {1..10}; do
if curl -s http://127.0.0.1:$port/ > /dev/null 2>&1; then
echo " Server on port $port is ready"
break
fi
if [ $i -eq 10 ]; then
echo " WARNING: Server on port $port may not be ready"
fi
sleep 0.5
done
done

echo ""

# Warmup
echo "Warming up servers..."
ab -n 1000 -c 50 -k http://127.0.0.1:$PORT_NONPOOLED/ > /dev/null 2>&1 || true
ab -n 1000 -c 50 -k http://127.0.0.1:$PORT_POOLED/ > /dev/null 2>&1 || true
sleep 1

echo ""
echo "=============================================="
echo " Test 1: Simple Requests ($REQUESTS req, $CONCURRENCY concurrent)"
echo "=============================================="

echo ""
echo "--- Non-Pooled ---"
RESULT_NP1=$(ab -n $REQUESTS -c $CONCURRENCY -k http://127.0.0.1:$PORT_NONPOOLED/ 2>&1)
RPS_NP1=$(echo "$RESULT_NP1" | grep "Requests per second" | awk '{print $4}')
LAT_NP1=$(echo "$RESULT_NP1" | grep "Time per request" | head -1 | awk '{print $4}')
FAIL_NP1=$(echo "$RESULT_NP1" | grep "Failed requests" | awk '{print $3}')
echo " Requests/sec: $RPS_NP1"
echo " Latency: ${LAT_NP1}ms"
echo " Failed: $FAIL_NP1"

echo ""
echo "--- Pooled ---"
RESULT_P1=$(ab -n $REQUESTS -c $CONCURRENCY -k http://127.0.0.1:$PORT_POOLED/ 2>&1)
RPS_P1=$(echo "$RESULT_P1" | grep "Requests per second" | awk '{print $4}')
LAT_P1=$(echo "$RESULT_P1" | grep "Time per request" | head -1 | awk '{print $4}')
FAIL_P1=$(echo "$RESULT_P1" | grep "Failed requests" | awk '{print $3}')
echo " Requests/sec: $RPS_P1"
echo " Latency: ${LAT_P1}ms"
echo " Failed: $FAIL_P1"

echo ""
echo "=============================================="
echo " Test 2: High Concurrency (5000 req, 500 concurrent)"
echo "=============================================="

echo ""
echo "--- Non-Pooled ---"
RESULT_NP2=$(ab -n 5000 -c 500 -k http://127.0.0.1:$PORT_NONPOOLED/ 2>&1)
RPS_NP2=$(echo "$RESULT_NP2" | grep "Requests per second" | awk '{print $4}')
LAT_NP2=$(echo "$RESULT_NP2" | grep "Time per request" | head -1 | awk '{print $4}')
FAIL_NP2=$(echo "$RESULT_NP2" | grep "Failed requests" | awk '{print $3}')
echo " Requests/sec: $RPS_NP2"
echo " Latency: ${LAT_NP2}ms"
echo " Failed: $FAIL_NP2"

echo ""
echo "--- Pooled ---"
RESULT_P2=$(ab -n 5000 -c 500 -k http://127.0.0.1:$PORT_POOLED/ 2>&1)
RPS_P2=$(echo "$RESULT_P2" | grep "Requests per second" | awk '{print $4}')
LAT_P2=$(echo "$RESULT_P2" | grep "Time per request" | head -1 | awk '{print $4}')
FAIL_P2=$(echo "$RESULT_P2" | grep "Failed requests" | awk '{print $3}')
echo " Requests/sec: $RPS_P2"
echo " Latency: ${LAT_P2}ms"
echo " Failed: $FAIL_P2"

echo ""
echo "=============================================="
echo " Test 3: Sustained Load (20000 req, 200 concurrent)"
echo "=============================================="

echo ""
echo "--- Non-Pooled ---"
RESULT_NP3=$(ab -n 20000 -c 200 -k http://127.0.0.1:$PORT_NONPOOLED/ 2>&1)
RPS_NP3=$(echo "$RESULT_NP3" | grep "Requests per second" | awk '{print $4}')
LAT_NP3=$(echo "$RESULT_NP3" | grep "Time per request" | head -1 | awk '{print $4}')
FAIL_NP3=$(echo "$RESULT_NP3" | grep "Failed requests" | awk '{print $3}')
echo " Requests/sec: $RPS_NP3"
echo " Latency: ${LAT_NP3}ms"
echo " Failed: $FAIL_NP3"

echo ""
echo "--- Pooled ---"
RESULT_P3=$(ab -n 20000 -c 200 -k http://127.0.0.1:$PORT_POOLED/ 2>&1)
RPS_P3=$(echo "$RESULT_P3" | grep "Requests per second" | awk '{print $4}')
LAT_P3=$(echo "$RESULT_P3" | grep "Time per request" | head -1 | awk '{print $4}')
FAIL_P3=$(echo "$RESULT_P3" | grep "Failed requests" | awk '{print $3}')
echo " Requests/sec: $RPS_P3"
echo " Latency: ${LAT_P3}ms"
echo " Failed: $FAIL_P3"

echo ""
echo "=============================================="
echo " Summary"
echo "=============================================="
echo ""
printf "%-25s %15s %15s %10s\n" "Test" "Non-Pooled" "Pooled" "Diff"
printf "%-25s %15s %15s %10s\n" "-------------------------" "---------------" "---------------" "----------"

# Calculate differences (using awk for floating point)
if [ -n "$RPS_NP1" ] && [ -n "$RPS_P1" ]; then
DIFF1=$(echo "$RPS_P1 $RPS_NP1" | awk '{if($2>0) printf "%.1f%%", (($1-$2)/$2)*100; else print "N/A"}')
printf "%-25s %12s/s %12s/s %10s\n" "Simple (100 conc)" "$RPS_NP1" "$RPS_P1" "$DIFF1"
fi

if [ -n "$RPS_NP2" ] && [ -n "$RPS_P2" ]; then
DIFF2=$(echo "$RPS_P2 $RPS_NP2" | awk '{if($2>0) printf "%.1f%%", (($1-$2)/$2)*100; else print "N/A"}')
printf "%-25s %12s/s %12s/s %10s\n" "High conc (500 conc)" "$RPS_NP2" "$RPS_P2" "$DIFF2"
fi

if [ -n "$RPS_NP3" ] && [ -n "$RPS_P3" ]; then
DIFF3=$(echo "$RPS_P3 $RPS_NP3" | awk '{if($2>0) printf "%.1f%%", (($1-$2)/$2)*100; else print "N/A"}')
printf "%-25s %12s/s %12s/s %10s\n" "Sustained (200 conc)" "$RPS_NP3" "$RPS_P3" "$DIFF3"
fi

echo ""
echo "Done!"
Loading
Loading