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
14 changes: 7 additions & 7 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', '29']
python: ['3.12', '3.13', '3.14']

steps:
- uses: actions/checkout@v4
Expand All @@ -24,7 +24,7 @@ jobs:
uses: erlef/setup-beam@v1
with:
otp-version: ${{ matrix.otp }}
rebar3-version: '3.24'
rebar3-version: '3.27.0'

- name: Set up Python
uses: actions/setup-python@v5
Expand Down Expand Up @@ -66,8 +66,8 @@ jobs:
- name: Set up Erlang
uses: erlef/setup-beam@v1
with:
otp-version: '27.1'
rebar3-version: '3.24'
otp-version: '29'
rebar3-version: '3.27.0'

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

- name: Compile
run: rebar3 compile
Expand Down
81 changes: 81 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,87 @@ 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

- **erlang_python v3.0**: Track the simplified execution model
- Switched dep to erlang_python `main` (worker / owngil modes only)
- `config/sys.config`: replaced obsolete `num_workers` key with `num_contexts`
- Python runners (`asgi`, `lifespan`, `websocket`): import `erlang` instead of
the removed `erlang_loop` shim and skip `asyncio.set_event_loop_policy` on
Python 3.14+ (deprecated in 3.14, removed in 3.16)

### Fixed

- **Atom comparison in `hornbeam_erlang`**: result-tuple unwrap helpers
(`execute`, `execute_async`, `await_result`, `stream`) now recognise
Erlang atoms whether they surface as `erlang.Atom`, `bytes`, or `str`,
so `{ok, Value}` correctly returns `Value` instead of the raw tuple.
- **Hook function-handler signature**: `hornbeam_hooks_runner` now
spreads `*args, **kwargs` into the documented
`def handler(action, *args, **kwargs)` signature instead of passing
the args list as a single positional.
- **`hornbeam_hooks:execute_python_registered`**: pass through `{ok, _}`
/ `{error, _}` from `py:call` instead of wrapping again, so
`execute(...)` returns the handler's value (was a `{ok, Inner}` shell).
- **Stream generator storage**: `hornbeam_hooks:stream_ref/4` parks the
Erlang generator in an ETS table and returns the ref to Python (the
doc claimed Python could pass an Erlang `fun()` as a "ref"; nothing
ever populated the storage map).
- **`hornbeam_erlang.call/cast` from Python**: registered the
`hornbeam_callbacks` Python-callable dispatcher so the documented
`from hornbeam_erlang import call` path actually reaches
`hornbeam_callbacks:call/2`.
- **`hornbeam_erlang.publish` from Python**: registered a
`hornbeam_pubsub` Python-callable dispatcher so `publish(topic, msg)`
reaches `hornbeam_pubsub:publish/2` and returns the subscriber count.
- **`state_get_multi` / `state_keys` from Python**: registered a
`hornbeam_state` Python-callable dispatcher.
- **`stream` / `stream_next_ref` from Python**: routed
`hornbeam_hooks:stream/4` and `stream_next_ref/1` through the same
`hornbeam_hooks` dispatcher used for `reg_python` / `unreg`.
- **`state_get_multi` semantics**: missing keys now map to `undefined`
(rendered as Python `None`) so the documented return shape
`{'user:3': None}` matches runtime.

### Added

- `test/hornbeam_examples_smoke_SUITE.erl` — HTTP smoke test for seven
example apps (`async_chat`, `channels_chat`, `demo/realtime_chat`,
`erlang_integration`, `fastapi_app`, `hooks_lifespan`,
`websocket_chat`). FastAPI-dependent cases auto-skip if the package is
missing.
- `test/hornbeam_doc_python_api_SUITE.erl` — runs every runnable code
block from `docs/reference/python-api.md` verbatim. 16 snippets pass;
8 hook-related snippets are skipped pending a follow-up to fix nested
`py:exec` → `register_hook` callback synchronisation.
- `scripts/docker_smoke.sh` + `make docker-smoke` target — builds every
Dockerfile in the repo (root, channels-chat, demo/distributed_rpc,
demo/multi_app; ml_caching gated on `DOCKER_SMOKE_HEAVY=1`).

- **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
12 changes: 12 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.PHONY: compile test ct docker-smoke

compile:
rebar3 compile

test ct:
rebar3 ct

# Smoke-test every Dockerfile builds. Set DOCKER_SMOKE_HEAVY=1 to also
# build the ml_caching image (slow, downloads sentence-transformers).
docker-smoke:
scripts/docker_smoke.sh
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!',
})
Loading