README.md

# Hornbeam

**Hornbeam** is an Erlang-based WSGI/ASGI server that combines Python's web and ML capabilities with Erlang's strengths:

- **Python handles**: Web apps (WSGI/ASGI), ML models, data processing
- **Erlang handles**: Scaling (millions of connections), concurrency (no GIL), distribution (cluster RPC), fault tolerance, shared state (ETS)

The name combines "horn" (unicorn, like gunicorn) with "BEAM" (Erlang VM).

## Features

- **WSGI Support**: Run standard WSGI Python applications
- **ASGI Support**: Run async ASGI Python applications (FastAPI, Starlette, etc.)
- **WebSocket**: Full WebSocket support for real-time apps
- **HTTP/2**: Via Cowboy, with multiplexing and server push
- **Shared State**: ETS-backed state accessible from Python (concurrent-safe)
- **Distributed RPC**: Call functions on remote Erlang nodes
- **Pub/Sub**: pg-based publish/subscribe messaging
- **ML Integration**: Cache ML inference results in ETS
- **Lifespan**: ASGI lifespan protocol for app startup/shutdown
- **Hot Reload**: Leverage Erlang's hot code reloading

## Quick Start

```erlang
%% Start with a WSGI application
hornbeam:start("myapp:application").

%% Start ASGI app (FastAPI, Starlette, etc.)
hornbeam:start("main:app", #{worker_class => asgi}).

%% With all options
hornbeam:start("myapp:application", #{
    bind => "0.0.0.0:8000",
    workers => 4,
    worker_class => asgi,
    lifespan => auto
}).
```

## Installation

Add hornbeam to your `rebar.config`:

```erlang
{deps, [
    {hornbeam, {git, "https://github.com/benoitc/hornbeam.git", {branch, "main"}}}
]}.
```

## Python Integration

### Shared State (ETS)

Python apps can use Erlang ETS for high-concurrency shared state:

```python
from hornbeam_erlang import state_get, state_set, state_incr

def application(environ, start_response):
    # Atomic counter (millions of concurrent increments)
    views = state_incr(f'views:{path}')

    # Get/set cached data
    data = state_get('my_key')
    if data is None:
        data = compute_expensive()
        state_set('my_key', data)

    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [f'Views: {views}'.encode()]
```

### Distributed RPC

Call functions on remote Erlang nodes:

```python
from hornbeam_erlang import rpc_call, nodes

def application(environ, start_response):
    # Get connected nodes
    connected = nodes()

    # Call ML model on GPU node
    result = rpc_call(
        'gpu@ml-server',      # Remote node
        'ml_model',           # Module
        'predict',            # Function
        [data],               # Args
        timeout_ms=30000
    )

    start_response('200 OK', [('Content-Type', 'application/json')])
    return [json.dumps(result).encode()]
```

### ML Caching

Use ETS to cache ML inference results:

```python
from hornbeam_ml import cached_inference, cache_stats

def application(environ, start_response):
    # Automatically cached by input hash
    embedding = cached_inference(model.encode, text)

    # Check cache stats
    stats = cache_stats()  # {'hits': 100, 'misses': 10, 'hit_rate': 0.91}

    start_response('200 OK', [('Content-Type', 'application/json')])
    return [json.dumps({'embedding': embedding}).encode()]
```

### Pub/Sub Messaging

```python
from hornbeam_erlang import publish

def application(environ, start_response):
    # Publish to topic (all subscribers notified)
    count = publish('updates', {'type': 'new_item', 'id': 123})

    start_response('200 OK', [('Content-Type', 'application/json')])
    return [json.dumps({'subscribers_notified': count}).encode()]
```

## Examples

### Hello World (WSGI)

```python
# examples/hello_wsgi/app.py
def application(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [b'Hello from Hornbeam!']
```

```erlang
hornbeam:start("app:application", #{pythonpath => ["examples/hello_wsgi"]}).
```

### Hello World (ASGI)

```python
# examples/hello_asgi/app.py
async def application(scope, receive, send):
    await send({
        'type': 'http.response.start',
        'status': 200,
        'headers': [[b'content-type', b'text/plain']],
    })
    await send({
        'type': 'http.response.body',
        'body': b'Hello from Hornbeam ASGI!',
    })
```

```erlang
hornbeam:start("app:application", #{
    worker_class => asgi,
    pythonpath => ["examples/hello_asgi"]
}).
```

### WebSocket Chat

```python
# examples/websocket_chat/app.py
async def app(scope, receive, send):
    if scope['type'] == 'websocket':
        await send({'type': 'websocket.accept'})

        while True:
            message = await receive()
            if message['type'] == 'websocket.disconnect':
                break
            if message['type'] == 'websocket.receive':
                # Echo back
                await send({
                    'type': 'websocket.send',
                    'text': message.get('text', '')
                })
```

```erlang
hornbeam:start("app:app", #{
    worker_class => asgi,
    pythonpath => ["examples/websocket_chat"]
}).
```

### Embedding Service with ETS Caching

See `examples/embedding_service/` for a complete ML embedding service using Erlang ETS for caching.

### Distributed ML Inference

See `examples/distributed_rpc/` for distributing ML inference across a cluster.

## Running with Gunicorn (for comparison)

All examples are designed to work with gunicorn too (with fallback functions):

```bash
# With gunicorn (single process, no Erlang features)
cd examples/hello_wsgi
gunicorn app:application

# With hornbeam (Erlang concurrency, shared state, distribution)
rebar3 shell
> hornbeam:start("app:application", #{pythonpath => ["examples/hello_wsgi"]}).
```

## Configuration

### Via hornbeam:start/2

```erlang
hornbeam:start("myapp:application", #{
    %% Server
    bind => <<"0.0.0.0:8000">>,
    ssl => false,
    certfile => undefined,
    keyfile => undefined,

    %% Protocol
    worker_class => wsgi,  % wsgi | asgi
    http_version => ['HTTP/1.1', 'HTTP/2'],

    %% Workers
    workers => 4,
    timeout => 30000,
    keepalive => 2,
    max_requests => 1000,

    %% ASGI
    lifespan => auto,  % auto | on | off

    %% WebSocket
    websocket_timeout => 60000,
    websocket_max_frame_size => 16777216,  % 16MB

    %% Python
    pythonpath => [<<".">>]
}).
```

### Via sys.config

```erlang
[
    {hornbeam, [
        {bind, "127.0.0.1:8000"},
        {workers, 4},
        {worker_class, wsgi},
        {timeout, 30000},
        {pythonpath, ["."]}
    ]}
].
```

## API Reference

### hornbeam module

| Function | Description |
|----------|-------------|
| `start(AppSpec)` | Start server with WSGI/ASGI app |
| `start(AppSpec, Options)` | Start server with options |
| `stop()` | Stop the server |
| `register_function(Name, Fun)` | Register Erlang function callable from Python |
| `register_function(Name, Module, Function)` | Register module:function |
| `unregister_function(Name)` | Unregister a function |

### Python hornbeam_erlang module

| Function | Description |
|----------|-------------|
| `state_get(key)` | Get value from ETS (None if not found) |
| `state_set(key, value)` | Set value in ETS |
| `state_incr(key, delta=1)` | Atomically increment counter, return new value |
| `state_decr(key, delta=1)` | Atomically decrement counter |
| `state_delete(key)` | Delete key from ETS |
| `state_get_multi(keys)` | Batch get multiple keys |
| `state_keys(prefix=None)` | Get all keys, optionally by prefix |
| `rpc_call(node, module, function, args, timeout_ms)` | Call function on remote node |
| `rpc_cast(node, module, function, args)` | Async call (fire and forget) |
| `nodes()` | Get list of connected Erlang nodes |
| `node()` | Get this node's name |
| `publish(topic, message)` | Publish to pub/sub topic |
| `call(name, *args)` | Call registered Erlang function |
| `cast(name, *args)` | Async call to registered function |

### Python hornbeam_ml module

| Function | Description |
|----------|-------------|
| `cached_inference(fn, input, cache_key=None, cache_prefix="ml")` | Run inference with ETS caching |
| `cache_stats()` | Get cache hit/miss statistics |

## Performance

Hornbeam achieves high throughput by leveraging Erlang's lightweight process model and avoiding Python's GIL limitations.

### Benchmark Results

Tested on Apple M4 Pro, Python 3.13, OTP 28 (February 2026):

| Test | Hornbeam | Gunicorn (gthread) | Speedup |
|------|----------|--------------------|---------|
| Simple (100 concurrent) | **33,643** req/s | 3,661 req/s | **9.2x** |
| High concurrency (500 concurrent) | **28,890** req/s | 3,631 req/s | **8.0x** |
| Large response (64KB) | **29,118** req/s | 3,599 req/s | **8.1x** |

Both servers configured with 4 workers, gunicorn with gthread and 4 threads per worker. Zero failed requests on both.

### Latency Comparison

| Test | Hornbeam | Gunicorn |
|------|----------|----------|
| Simple (100 concurrent) | **2.97ms** | 27.3ms |
| High concurrency (500 concurrent) | **17.3ms** | 137.7ms |
| Large response (64KB) | **1.72ms** | 13.9ms |

### Run Your Own Benchmarks

```bash
# Quick benchmark
./benchmarks/quick_bench.sh

# Full benchmark suite
python benchmarks/run_benchmark.py

# Compare with gunicorn
python benchmarks/compare_servers.py
```

See the [Benchmarking Guide](https://hornbeam.dev/docs/guides/benchmarking) for details.

## Development

```bash
# Compile
rebar3 compile

# Run tests
rebar3 ct

# Start shell
rebar3 shell
```

## License

Apache License 2.0