Context Affinity
View SourceContext 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.
Why Context Affinity?
By default, each call to py:call, py:eval, or py:exec may be handled by a different worker from the pool. This means:
- Variables defined in one call are not available in the next
- Imported modules must be re-imported
- Objects created in one call cannot be accessed later
Context affinity solves this by dedicating a worker to your process, ensuring all calls go to the same Python interpreter with preserved state.
Process-Implicit Binding
The simplest approach binds the current Erlang process to a worker:
%% Bind current process to a dedicated worker
ok = py:bind(),
%% Now all calls use the same worker - state persists!
ok = py:exec(<<"counter = 0">>),
ok = py:exec(<<"counter += 1">>),
{ok, 1} = py:eval(<<"counter">>),
ok = py:exec(<<"counter += 1">>),
{ok, 2} = py:eval(<<"counter">>),
%% Release the worker back to the pool
ok = py:unbind().Checking Binding Status
false = py:is_bound(),
ok = py:bind(),
true = py:is_bound(),
ok = py:unbind(),
false = py:is_bound().Explicit Contexts
For more control, create explicit context handles. This allows multiple independent Python contexts within a single Erlang process:
%% Create two independent contexts
{ok, Ctx1} = py:bind(new),
{ok, Ctx2} = py:bind(new),
%% Each context has its own namespace
ok = py:ctx_exec(Ctx1, <<"x = 'context one'">>),
ok = py:ctx_exec(Ctx2, <<"x = 'context two'">>),
%% Values are isolated
{ok, <<"context one">>} = py:ctx_eval(Ctx1, <<"x">>),
{ok, <<"context two">>} = py:ctx_eval(Ctx2, <<"x">>),
%% Release both
ok = py:unbind(Ctx1),
ok = py:unbind(Ctx2).Context-Aware Functions
When using explicit contexts, use these functions:
| Function | Description |
|---|---|
py:ctx_call(Ctx, Module, Func, Args) | Call with context |
py:ctx_call(Ctx, Module, Func, Args, Kwargs) | Call with kwargs |
py:ctx_call(Ctx, Module, Func, Args, Kwargs, Timeout) | Call with timeout |
py:ctx_eval(Ctx, Code) | Evaluate expression |
py:ctx_eval(Ctx, Code, Locals) | Evaluate with locals |
py:ctx_eval(Ctx, Code, Locals, Timeout) | Evaluate with timeout |
py:ctx_exec(Ctx, Code) | Execute statements |
Scoped Helper
The with_context/1 function provides automatic bind/unbind with cleanup on exception:
Implicit Binding (arity-0 function)
Result = py:with_context(fun() ->
ok = py:exec(<<"total = 0">>),
ok = py:exec(<<"for i in range(10): total += i">>),
py:eval(<<"total">>)
end),
{ok, 45} = Result.
%% Process is automatically unbound hereExplicit Context (arity-1 function)
Result = py:with_context(fun(Ctx) ->
ok = py:ctx_exec(Ctx, <<"import json">>),
ok = py:ctx_exec(Ctx, <<"data = {'key': 'value'}">>),
py:ctx_eval(Ctx, <<"json.dumps(data)">>)
end),
{ok, <<"{\"key\": \"value\"}">>} = Result.Automatic Cleanup
Process Death
If a bound process dies, the worker is automatically returned to the pool:
Pid = spawn(fun() ->
ok = py:bind(),
%% Do some work...
exit(normal) %% Worker automatically returned
end).Worker Crash
If a bound worker crashes, the binding is cleaned up and a new worker is created:
ok = py:bind(),
%% If the worker crashes, binding is cleaned up
%% Next bind() will get a fresh workerUse Cases
Stateful Computation
py:with_context(fun() ->
%% Load a model once
py:exec(<<"
import pickle
with open('model.pkl', 'rb') as f:
model = pickle.load(f)
">>),
%% Use it multiple times
{ok, Pred1} = py:eval(<<"model.predict([[1, 2, 3]])">>),
{ok, Pred2} = py:eval(<<"model.predict([[4, 5, 6]])">>),
{Pred1, Pred2}
end).Database Connections
ok = py:bind(),
%% Establish connection once
py:exec(<<"
import sqlite3
conn = sqlite3.connect(':memory:')
cursor = conn.cursor()
cursor.execute('CREATE TABLE users (id INTEGER, name TEXT)')
">>),
%% Use the connection across multiple calls
py:exec(<<"cursor.execute('INSERT INTO users VALUES (1, \"Alice\")')">>),
py:exec(<<"cursor.execute('INSERT INTO users VALUES (2, \"Bob\")')">>),
{ok, Users} = py:eval(<<"cursor.execute('SELECT * FROM users').fetchall()">>),
%% Clean up
py:exec(<<"conn.close()">>),
py:unbind().Incremental Processing
{ok, Ctx} = py:bind(new),
%% Initialize accumulator
py:ctx_exec(Ctx, <<"results = []">>),
%% Process items one at a time
lists:foreach(fun(Item) ->
py:ctx_exec(Ctx, <<"results.append(process_item(item))">>,
#{item => Item})
end, Items),
%% Get final results
{ok, Results} = py:ctx_eval(Ctx, <<"results">>),
py:unbind(Ctx).Performance Considerations
- Binding overhead:
bind()requires a gen_server call to checkout a worker - Lookup overhead: Once bound, routing adds only an O(1) ETS lookup
- Pool exhaustion: Each bound context removes a worker from the pool
- Recommendation: Use
with_context/1for short-lived operations; explicitbind/unbindfor long-lived sessions
Pool Statistics
Check how many workers are bound:
Stats = py_pool:get_stats(),
#{
num_workers := 8,
available_workers := 6, %% 2 workers are checked out
checked_out := 2,
pending_requests := 0
} = Stats.Error Handling
No Workers Available
%% If all workers are bound
{error, no_workers_available} = py:bind().Context Not Bound
%% Using a context after unbind raises an error
{ok, Ctx} = py:bind(new),
ok = py:unbind(Ctx),
%% This will crash with context_not_bound
py:ctx_eval(Ctx, <<"1 + 1">>). %% error(context_not_bound)Best Practices
- Always unbind: Use
with_context/1or ensureunbindin atry/afterblock - Minimize binding time: Don't hold workers longer than necessary
- Watch pool size: Monitor
py_pool:get_stats()to avoid exhaustion - Use explicit contexts: When you need multiple independent namespaces
- Prefer implicit binding: For simple sequential operations in a single process