# Context Affinity

Context affinity allows you to bind an Erlang process to a dedicated Python context, 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 context 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 context to your process, ensuring all calls go to the same Python interpreter with preserved state.

## Using Explicit Contexts

The simplest approach is to use explicit context handles:

```erlang
%% Get a specific context by index (1-based)
Ctx = py:context(1),

%% Now all calls to this context share state
ok = py:exec(Ctx, <<"counter = 0">>),
ok = py:exec(Ctx, <<"counter += 1">>),
{ok, 1} = py:eval(Ctx, <<"counter">>),

ok = py:exec(Ctx, <<"counter += 1">>),
{ok, 2} = py:eval(Ctx, <<"counter">>).
```

### Multiple Independent Contexts

Use multiple contexts for isolation:

```erlang
%% Get two different contexts
Ctx1 = py:context(1),
Ctx2 = py:context(2),

%% Each context has its own namespace
ok = py:exec(Ctx1, <<"x = 'context one'">>),
ok = py:exec(Ctx2, <<"x = 'context two'">>),

%% Values are isolated
{ok, <<"context one">>} = py:eval(Ctx1, <<"x">>),
{ok, <<"context two">>} = py:eval(Ctx2, <<"x">>).
```

## Binding Contexts to Processes

For automatic context routing, bind a context to the current process:

```erlang
%% Get a context
Ctx = py_context_router:get_context(),

%% Bind it to the current process
ok = py_context_router:bind_context(Ctx),

%% Now all py:call/eval/exec from this process use the bound context
ok = py:exec(<<"x = 42">>),
{ok, 42} = py:eval(<<"x">>),

%% Unbind when done
ok = py_context_router:unbind_context().
```

### Scoped Binding with try/after

Always ensure cleanup with try/after:

```erlang
run_with_context(Fun) ->
    Ctx = py_context_router:get_context(),
    ok = py_context_router:bind_context(Ctx),
    try
        Fun()
    after
        py_context_router:unbind_context()
    end.

%% Usage
Result = run_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.
```

## Context API Reference

### `py:context/0`

Get the context for the current scheduler (automatic affinity):

```erlang
Ctx = py:context().
```

### `py:context/1`

Get a specific context by index (1-based):

```erlang
Ctx = py:context(1).
```

### `py_context_router:bind_context/1`

Bind a context to the current process:

```erlang
ok = py_context_router:bind_context(Ctx).
```

### `py_context_router:unbind_context/0`

Remove the context binding for the current process:

```erlang
ok = py_context_router:unbind_context().
```

### `py_context_router:get_context/0,1`

Get a context from the pool:

```erlang
%% Get context for current scheduler
Ctx = py_context_router:get_context().

%% Get context from a specific pool
Ctx = py_context_router:get_context(io).
```

## Use Cases

### Stateful Computation

```erlang
Ctx = py:context(1),

%% Load a model once
py:exec(Ctx, <<"
import pickle
with open('model.pkl', 'rb') as f:
    model = pickle.load(f)
">>),

%% Use it multiple times
{ok, Pred1} = py:eval(Ctx, <<"model.predict([[1, 2, 3]])">>),
{ok, Pred2} = py:eval(Ctx, <<"model.predict([[4, 5, 6]])">>).
```

### Database Connections

```erlang
Ctx = py:context(1),

%% Establish connection once
py:exec(Ctx, <<"
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(Ctx, <<"cursor.execute('INSERT INTO users VALUES (1, \"Alice\")')">>),
py:exec(Ctx, <<"cursor.execute('INSERT INTO users VALUES (2, \"Bob\")')">>),
{ok, Users} = py:eval(Ctx, <<"cursor.execute('SELECT * FROM users').fetchall()">>),

%% Clean up
py:exec(Ctx, <<"conn.close()">>).
```

### Incremental Processing

```erlang
Ctx = py:context(1),

%% Initialize accumulator
py:exec(Ctx, <<"results = []">>),

%% Process items one at a time
lists:foreach(fun(Item) ->
    py:eval(Ctx, <<"results.append(item * 2)">>, #{item => Item})
end, [1, 2, 3, 4, 5]),

%% Get final results
{ok, [2, 4, 6, 8, 10]} = py:eval(Ctx, <<"results">>).
```

## Scheduler Affinity (Default Behavior)

By default, without explicit binding, calls are routed based on the current Erlang scheduler. This provides good cache locality while allowing multiple processes to share contexts:

```erlang
%% Processes on the same scheduler share a context
%% Processes on different schedulers use different contexts
{ok, Result} = py:call(math, sqrt, [16]).
```

This is usually what you want for stateless operations where isolation isn't critical.

## Performance Considerations

- **Context binding overhead**: `bind_context()` requires a gen_server call
- **Lookup overhead**: Once bound, routing adds only an O(1) ETS lookup
- **Pool exhaustion**: Each bound context removes it from round-robin rotation
- **Recommendation**: Use explicit `py:context(N)` for stateful operations; let automatic routing handle stateless calls

## Context Pool Statistics

Check pool status:

```erlang
%% Check number of contexts
N = py_context_router:num_contexts().

%% Check if a pool is started
true = py_context_router:pool_started(default).
true = py_context_router:pool_started(io).

%% Get all contexts in a pool
Contexts = py_context_router:contexts(default).
```

## Error Handling

### Context Not Available

```erlang
%% If contexts aren't started
case py:contexts_started() of
    true -> proceed();
    false -> {error, contexts_not_started}
end.
```

## Best Practices

1. **Use explicit contexts for stateful operations**: `Ctx = py:context(1)` ensures state persists
2. **Use automatic routing for stateless calls**: Let the router handle distribution
3. **Always unbind in finally blocks**: Prevent context leaks
4. **Minimize binding time**: Don't hold contexts longer than necessary
5. **Monitor pool size**: Check `py_context_router:num_contexts()` to understand capacity
