|
| 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 |
0 commit comments