Skip to content

Commit 775cc82

Browse files
committed
Add context affinity for Python worker pool
Enable Erlang processes to bind to dedicated Python workers, preserving Python state (variables, imports, objects) across multiple calls. Features: - py:bind()/unbind() - Bind current process to a worker - py:bind(new) - Create explicit context handles - py:with_context(Fun) - Scoped helper with automatic cleanup - py:ctx_call/eval/exec - Context-aware function variants - Automatic cleanup via process monitors on death - O(1) ETS-based binding lookup for minimal overhead
1 parent 54e90f2 commit 775cc82

6 files changed

Lines changed: 774 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@
44

55
### Added
66

7+
- **Context Affinity** - Bind Erlang processes to dedicated Python workers for state persistence
8+
- `py:bind()` / `py:unbind()` - Bind current process to a worker, preserving Python state
9+
- `py:bind(new)` - Create explicit context handles for multiple contexts per process
10+
- `py:with_context(Fun)` - Scoped helper with automatic bind/unbind
11+
- Context-aware functions: `py:ctx_call/4-6`, `py:ctx_eval/2-4`, `py:ctx_exec/2`
12+
- Automatic cleanup via process monitors when bound processes die
13+
- O(1) ETS-based binding lookup for minimal overhead
14+
- New test suite: `test/py_context_SUITE.erl`
15+
716
- **Python Thread Support** - Any spawned Python thread can now call `erlang.call()` without blocking
817
- Supports `threading.Thread`, `concurrent.futures.ThreadPoolExecutor`, and any other Python threads
918
- Each spawned thread lazily acquires a dedicated "thread worker" channel

docs/context-affinity.md

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
# Context Affinity
2+
3+
Context affinity allows you to bind an Erlang process to a dedicated Python worker, preserving Python state (variables, imports, objects) across multiple `py:call/eval/exec` invocations.
4+
5+
## Why Context Affinity?
6+
7+
By default, each call to `py:call`, `py:eval`, or `py:exec` may be handled by a different worker from the pool. This means:
8+
9+
- Variables defined in one call are not available in the next
10+
- Imported modules must be re-imported
11+
- Objects created in one call cannot be accessed later
12+
13+
Context affinity solves this by dedicating a worker to your process, ensuring all calls go to the same Python interpreter with preserved state.
14+
15+
## Process-Implicit Binding
16+
17+
The simplest approach binds the current Erlang process to a worker:
18+
19+
```erlang
20+
%% Bind current process to a dedicated worker
21+
ok = py:bind(),
22+
23+
%% Now all calls use the same worker - state persists!
24+
ok = py:exec(<<"counter = 0">>),
25+
ok = py:exec(<<"counter += 1">>),
26+
{ok, 1} = py:eval(<<"counter">>),
27+
28+
ok = py:exec(<<"counter += 1">>),
29+
{ok, 2} = py:eval(<<"counter">>),
30+
31+
%% Release the worker back to the pool
32+
ok = py:unbind().
33+
```
34+
35+
### Checking Binding Status
36+
37+
```erlang
38+
false = py:is_bound(),
39+
ok = py:bind(),
40+
true = py:is_bound(),
41+
ok = py:unbind(),
42+
false = py:is_bound().
43+
```
44+
45+
## Explicit Contexts
46+
47+
For more control, create explicit context handles. This allows multiple independent Python contexts within a single Erlang process:
48+
49+
```erlang
50+
%% Create two independent contexts
51+
{ok, Ctx1} = py:bind(new),
52+
{ok, Ctx2} = py:bind(new),
53+
54+
%% Each context has its own namespace
55+
ok = py:ctx_exec(Ctx1, <<"x = 'context one'">>),
56+
ok = py:ctx_exec(Ctx2, <<"x = 'context two'">>),
57+
58+
%% Values are isolated
59+
{ok, <<"context one">>} = py:ctx_eval(Ctx1, <<"x">>),
60+
{ok, <<"context two">>} = py:ctx_eval(Ctx2, <<"x">>),
61+
62+
%% Release both
63+
ok = py:unbind(Ctx1),
64+
ok = py:unbind(Ctx2).
65+
```
66+
67+
### Context-Aware Functions
68+
69+
When using explicit contexts, use these functions:
70+
71+
| Function | Description |
72+
|----------|-------------|
73+
| `py:ctx_call(Ctx, Module, Func, Args)` | Call with context |
74+
| `py:ctx_call(Ctx, Module, Func, Args, Kwargs)` | Call with kwargs |
75+
| `py:ctx_call(Ctx, Module, Func, Args, Kwargs, Timeout)` | Call with timeout |
76+
| `py:ctx_eval(Ctx, Code)` | Evaluate expression |
77+
| `py:ctx_eval(Ctx, Code, Locals)` | Evaluate with locals |
78+
| `py:ctx_eval(Ctx, Code, Locals, Timeout)` | Evaluate with timeout |
79+
| `py:ctx_exec(Ctx, Code)` | Execute statements |
80+
81+
## Scoped Helper
82+
83+
The `with_context/1` function provides automatic bind/unbind with cleanup on exception:
84+
85+
### Implicit Binding (arity-0 function)
86+
87+
```erlang
88+
Result = py:with_context(fun() ->
89+
ok = py:exec(<<"total = 0">>),
90+
ok = py:exec(<<"for i in range(10): total += i">>),
91+
py:eval(<<"total">>)
92+
end),
93+
{ok, 45} = Result.
94+
%% Process is automatically unbound here
95+
```
96+
97+
### Explicit Context (arity-1 function)
98+
99+
```erlang
100+
Result = py:with_context(fun(Ctx) ->
101+
ok = py:ctx_exec(Ctx, <<"import json">>),
102+
ok = py:ctx_exec(Ctx, <<"data = {'key': 'value'}">>),
103+
py:ctx_eval(Ctx, <<"json.dumps(data)">>)
104+
end),
105+
{ok, <<"{\"key\": \"value\"}">>} = Result.
106+
```
107+
108+
## Automatic Cleanup
109+
110+
### Process Death
111+
112+
If a bound process dies, the worker is automatically returned to the pool:
113+
114+
```erlang
115+
Pid = spawn(fun() ->
116+
ok = py:bind(),
117+
%% Do some work...
118+
exit(normal) %% Worker automatically returned
119+
end).
120+
```
121+
122+
### Worker Crash
123+
124+
If a bound worker crashes, the binding is cleaned up and a new worker is created:
125+
126+
```erlang
127+
ok = py:bind(),
128+
%% If the worker crashes, binding is cleaned up
129+
%% Next bind() will get a fresh worker
130+
```
131+
132+
## Use Cases
133+
134+
### Stateful Computation
135+
136+
```erlang
137+
py:with_context(fun() ->
138+
%% Load a model once
139+
py:exec(<<"
140+
import pickle
141+
with open('model.pkl', 'rb') as f:
142+
model = pickle.load(f)
143+
">>),
144+
145+
%% Use it multiple times
146+
{ok, Pred1} = py:eval(<<"model.predict([[1, 2, 3]])">>),
147+
{ok, Pred2} = py:eval(<<"model.predict([[4, 5, 6]])">>),
148+
{Pred1, Pred2}
149+
end).
150+
```
151+
152+
### Database Connections
153+
154+
```erlang
155+
ok = py:bind(),
156+
157+
%% Establish connection once
158+
py:exec(<<"
159+
import sqlite3
160+
conn = sqlite3.connect(':memory:')
161+
cursor = conn.cursor()
162+
cursor.execute('CREATE TABLE users (id INTEGER, name TEXT)')
163+
">>),
164+
165+
%% Use the connection across multiple calls
166+
py:exec(<<"cursor.execute('INSERT INTO users VALUES (1, \"Alice\")')">>),
167+
py:exec(<<"cursor.execute('INSERT INTO users VALUES (2, \"Bob\")')">>),
168+
{ok, Users} = py:eval(<<"cursor.execute('SELECT * FROM users').fetchall()">>),
169+
170+
%% Clean up
171+
py:exec(<<"conn.close()">>),
172+
py:unbind().
173+
```
174+
175+
### Incremental Processing
176+
177+
```erlang
178+
{ok, Ctx} = py:bind(new),
179+
180+
%% Initialize accumulator
181+
py:ctx_exec(Ctx, <<"results = []">>),
182+
183+
%% Process items one at a time
184+
lists:foreach(fun(Item) ->
185+
py:ctx_exec(Ctx, <<"results.append(process_item(item))">>,
186+
#{item => Item})
187+
end, Items),
188+
189+
%% Get final results
190+
{ok, Results} = py:ctx_eval(Ctx, <<"results">>),
191+
192+
py:unbind(Ctx).
193+
```
194+
195+
## Performance Considerations
196+
197+
- **Binding overhead**: `bind()` requires a gen_server call to checkout a worker
198+
- **Lookup overhead**: Once bound, routing adds only an O(1) ETS lookup
199+
- **Pool exhaustion**: Each bound context removes a worker from the pool
200+
- **Recommendation**: Use `with_context/1` for short-lived operations; explicit `bind/unbind` for long-lived sessions
201+
202+
## Pool Statistics
203+
204+
Check how many workers are bound:
205+
206+
```erlang
207+
Stats = py_pool:get_stats(),
208+
#{
209+
num_workers := 8,
210+
available_workers := 6, %% 2 workers are checked out
211+
checked_out := 2,
212+
pending_requests := 0
213+
} = Stats.
214+
```
215+
216+
## Error Handling
217+
218+
### No Workers Available
219+
220+
```erlang
221+
%% If all workers are bound
222+
{error, no_workers_available} = py:bind().
223+
```
224+
225+
### Context Not Bound
226+
227+
```erlang
228+
%% Using a context after unbind raises an error
229+
{ok, Ctx} = py:bind(new),
230+
ok = py:unbind(Ctx),
231+
%% This will crash with context_not_bound
232+
py:ctx_eval(Ctx, <<"1 + 1">>). %% error(context_not_bound)
233+
```
234+
235+
## Best Practices
236+
237+
1. **Always unbind**: Use `with_context/1` or ensure `unbind` in a `try/after` block
238+
2. **Minimize binding time**: Don't hold workers longer than necessary
239+
3. **Watch pool size**: Monitor `py_pool:get_stats()` to avoid exhaustion
240+
4. **Use explicit contexts**: When you need multiple independent namespaces
241+
5. **Prefer implicit binding**: For simple sequential operations in a single process

docs/getting-started.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ def roll_dice(sides=6):
6666

6767
Note: Definitions made with `exec` are local to the worker that executes them.
6868
Subsequent calls may go to different workers. Use [Shared State](#shared-state) to
69-
share data between workers.
69+
share data between workers, or [Context Affinity](#context-affinity) to bind to a
70+
dedicated worker.
7071

7172
## Working with Timeouts
7273

@@ -180,6 +181,36 @@ Values are automatically converted between Erlang and Python:
180181
{ok, none} = py:eval(<<"None">>).
181182
```
182183

184+
## Context Affinity
185+
186+
By default, each call may go to a different worker. To preserve Python state across
187+
calls (variables, imports, objects), bind to a dedicated worker:
188+
189+
```erlang
190+
%% Bind current process to a worker
191+
ok = py:bind(),
192+
193+
%% State persists across calls
194+
ok = py:exec(<<"counter = 0">>),
195+
ok = py:exec(<<"counter += 1">>),
196+
{ok, 1} = py:eval(<<"counter">>),
197+
198+
%% Release the worker
199+
ok = py:unbind().
200+
```
201+
202+
Or use the scoped helper for automatic cleanup:
203+
204+
```erlang
205+
Result = py:with_context(fun() ->
206+
ok = py:exec(<<"x = 10">>),
207+
py:eval(<<"x * 2">>)
208+
end),
209+
{ok, 20} = Result.
210+
```
211+
212+
See [Context Affinity](context-affinity.md) for explicit contexts and advanced usage.
213+
183214
## Execution Mode and Scalability
184215

185216
Check the current execution mode:
@@ -271,6 +302,7 @@ This demonstrates basic calls, data conversion, callbacks, parallel processing (
271302
## Next Steps
272303

273304
- See [Type Conversion](type-conversion.md) for detailed type mapping
305+
- See [Context Affinity](context-affinity.md) for preserving Python state
274306
- See [Streaming](streaming.md) for working with generators
275307
- See [Memory Management](memory.md) for GC and debugging
276308
- See [Scalability](scalability.md) for parallelism and performance

0 commit comments

Comments
 (0)