Skip to content
Merged
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
5 changes: 5 additions & 0 deletions changes/20.internal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Add `docs/roadmap.md` and `docs/async.md` recording the Phase 2/3 expansion plan, the design intent for the planned async public API (`AsyncCore` / `AsyncContext`) and framework helper distributions (`convert-sdk-django`, `convert-sdk-fastapi`, `convert-sdk-flask`), and a forward-compatibility audit of every MVP module against the planned async surface. The audit distinguishes `✅ ready` (reusable verbatim from a coroutine), `➕ sibling` (add a parallel async class), and `➕ wrap` (sync class stays as-is but async callers must reach it through an async adapter or `asyncio.to_thread()` to avoid blocking the event loop) — the in-memory event bus, in-memory data store, and tracking queue are `➕ wrap`, not "drop-in async-ready."

The "Looking ahead" panel in `docs/index.md` is gated with an explicit "planned, not yet shipped" header so readers do not try to import `AsyncCore` today. The migration-from-REST guide includes a caveat about `asyncio.to_thread()` running on the bounded default executor and a recipe for raising the executor size under burst load. The async-doc "Open questions" section is rewritten to identify which leanings are firm and which axes are still open at Phase 3 sign-off, removing the apparent contradiction between the design narrative and the open-questions list.

No code changes; sync-first MVP is unaffected.
205 changes: 205 additions & 0 deletions docs/async.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
# Async and Framework Integrations (Design Intent)

> **Status:** Phase 3 — design recorded, not yet implemented.
> The MVP is sync-first and is fully supported. This document records
> the planned shape of async and framework support so the MVP code
> stays forward-compatible and integrators know what to expect.

## Why async is not in the MVP

The MVP ships a sync API because it covers the dominant Python backend
shape (Django/Flask request handlers, scripts, batch jobs) without any
asyncio entanglement. Adding async to the MVP would have:

- doubled the public API surface to maintain
- introduced event-loop concerns into a tool that does not need them
- delayed the parity-validation work that is more load-bearing than
async support for shared cross-SDK correctness

The architecture deliberately deferred async to Phase 3 with one
constraint: **the MVP must not be async-hostile**. The audit recorded
in this PR confirms that constraint is satisfied.

## Sync–async coexistence model

When async lands, it lands as a parallel surface, not a replacement.

```text
┌──────────────────────────────┐
│ evaluation/ (pure compute) │
│ bucketing, rules, features │
└──────────────┬───────────────┘
│ shared
┌────────────────────┴────────────────────┐
│ │
┌────────▼─────────┐ ┌───────▼──────────┐
│ Core (sync) │ │ AsyncCore (async)│
│ create_context │ │ async create_... │
│ refresh_now │ │ async refresh_now│
│ close │ │ aclose │
└────────┬─────────┘ └───────┬──────────┘
│ │
┌────────▼─────────┐ ┌───────▼──────────┐
│ Context (sync) │ │ AsyncContext │
│ run_experience │ │ async run_exp... │
│ track_conversion │ │ async track_... │
└────────┬─────────┘ └───────┬──────────┘
│ │
┌────────▼─────────┐ ┌───────▼──────────┐
│ Transport │ │ AsyncTransport │
│ httpx.Client │ │ httpx.AsyncClient│
└──────────────────┘ └──────────────────┘
```

**Five frozen rules for the async surface:**

1. **Async becomes a parallel API, not a replacement.** Sync stays
first-class. There is no migration mandate.
2. **Shared evaluation core.** `bucketing`, `rules`, `segments`,
`experiences`, `features`, `entity_lookup`, and `config_snapshot`
are pure synchronous functions with no I/O. Both surfaces call
them directly.
3. **Async transport adapter.** `Transport` keeps its sync Protocol;
`AsyncTransport` is a new sibling Protocol whose methods are
`async def`. `HttpxAsyncTransport` is the bundled adapter.
4. **DataStore stays sync for MVP.** `AsyncDataStore` is a new sibling
Protocol. Sync `DataStore` adapters can be wrapped for async use
via `asyncio.to_thread()` if a host has not yet adopted an async
storage adapter.
5. **The SDK never owns the event loop.** Async callers provide their
own loop; the SDK never calls `asyncio.run()` and never spawns a
loop internally. Sync callers never encounter asyncio.

## Forward-compatibility audit (MVP code, today)

| Module | Async status | Notes |
| ----------------------------------------- | ------------ | ----- |
| `evaluation/*` | ✅ ready | Pure sync compute; reused as-is. |
| `domain/config_snapshot.py` | ✅ ready | Immutable dataclass + indexes; no I/O. |
| `domain/results.py`, `domain/context_state.py` | ✅ ready | Immutable dataclasses; reused. |
| `ports/transport.py` | ➕ extend | Add `AsyncTransport` Protocol alongside `Transport`. |
| `ports/storage.py` | ➕ extend | Add `AsyncDataStore` Protocol alongside `DataStore`. |
| `ports/event_bus.py` | ✅ keep sync | Handlers schedule their own async work; bus stays sync. |
| `adapters/transport/httpx_transport.py` | ➕ sibling | Add `HttpxAsyncTransport` using `httpx.AsyncClient`. |
| `adapters/storage/in_memory.py` | ➕ wrap | Thread-safe but synchronous; from async code, call through `asyncio.to_thread()` or supply an `AsyncDataStore` adapter. Not "drop-in async-ready." |
| `adapters/events/in_memory_event_bus.py` | ➕ wrap | Sync `emit()` invokes handlers on the calling thread, which would block the event loop if invoked from a coroutine. Async-aware use requires a small wrapper that schedules handlers via `asyncio.create_task`. |
| `tracking/queue.py` | ➕ wrap | `threading.Lock`-protected; correct for sync use. From async code, calling `release()` directly would block the event loop — call it through `asyncio.to_thread()` or behind an `AsyncTrackingQueue` adapter. |
| `tracking/payloads.py`, `tracking/conversions.py` | ✅ ready | Pure compute; reused. |
| `config_loader/loader.py` | ➕ sibling | Add `async def load_config_snapshot_async` mirroring the sync function. |
| `config_loader/refresh.py` | ➕ sibling | Sync daemon-thread refresher stays; `AsyncConfigRefresher` would be an asyncio-task variant for `AsyncCore`. |
| `core.py` | ➕ sibling | Add `AsyncCore` class with async lifecycle and `async create_context()`. |
| `context.py` | ➕ sibling | Add `AsyncContext` class with async tracking calls. |
| `diagnostics.py`, `errors.py`, `events.py` | ✅ ready | No I/O; reused. |

**No MVP module needs a destructive rewrite to enable async.** The seams
above are "add a sibling" or "wrap with an async adapter", never
"rewrite the existing class." `✅ ready` means "reusable verbatim from a
coroutine"; `➕ sibling` means "add a parallel async class"; `➕ wrap`
means "the sync class stays as-is but async callers must reach it
through an async adapter or `asyncio.to_thread()` to avoid blocking the
event loop."

## Public method names already reserve async flexibility

Most MVP names — `run_experience`, `run_feature`, `run_experiences`,
`run_features`, `track_conversion`, `flush_tracking`, `refresh_now` —
work as-is for both surfaces. The async surface gets the same names on
its `AsyncContext` / `AsyncCore` classes:

```python
# Sync (today)
result = context.run_experience("checkout-flow")
context.track_conversion("purchase")
core.refresh_now()
core.close()

# Async (planned, not shipped)
result = await async_context.run_experience("checkout-flow")
await async_context.track_conversion("purchase")
await async_core.refresh_now()
await async_core.aclose()
```

`AsyncCore.aclose()` is a deliberate exception: the asyncio convention
is to spell shutdown methods with the `a*` prefix (cf.
`asyncio.StreamWriter.aclose`), and an `await core.close()` reusing the
sync name would mislead readers about what is awaitable. The async
context-manager hooks (`__aenter__` / `__aexit__`) round it out.

## Framework integrations

Framework helpers ship as separate distributions, not as part of the
core package. The core stays framework-free (NFR13) and usable in any
standard Python runtime (NFR14).

Planned distributions:

| Package | Frameworks | Provides |
| ---------------------- | ---------------- | ------------------------------------------ |
| `convert-sdk-django` | Django | Middleware, request-scoped `Context`, settings integration. |
| `convert-sdk-fastapi` | FastAPI/Starlette | Dependency-injection helpers, request-scoped `Context`. |
| `convert-sdk-flask` | Flask | Extension class, `g`-scoped `Context`. |

Each helper layers on top of the framework-agnostic `Core` /
`AsyncCore` surfaces. None of them entrench framework imports inside
the `convert_sdk` core package; uninstalling a helper does not remove
core functionality.

### Deprecation policy

When upstream frameworks make incompatible changes, the helper
distributions track them with normal semver discipline:

- Major-version bump in the upstream framework → major-version bump in
the helper, with a deprecation period of at least one minor release
on the prior major.
- Minor / patch upstream changes that affect the helper → minor /
patch bump on the helper.
- Each helper release lists the supported upstream version range in
its README and CI matrix.

## Cross-SDK parity coverage applies unchanged

Story 3.5's parity vectors (bucketing, rule, feature, state) describe
correctness contracts of the evaluation core. Because both sync and
async surfaces share that core, the same vectors test both. When
async ships, the parity job in `tests/parity/` runs the same fixtures
through `AsyncCore` / `AsyncContext` and asserts identical normalised
outcomes.

The cross-SDK diagnostic contract (Story 4.3 — `reason`,
`environment`, `bucket_value`, `variation_key`, hashed `visitor_ref`)
applies unchanged to async output.

## Open questions to resolve at Phase 3 sign-off

The intent recorded above is the leaning architecture; the items below
are the specific points that Phase 3 sign-off needs to decide. Until
sign-off, the design above can shift on these axes:

- **Async transport implementation tactic**: the leaning is a real
`HttpxAsyncTransport` built on `httpx.AsyncClient`, but `to_thread()`-
wrapping the sync transport is a documented fallback if Phase 3 is
time-constrained. The Protocol shape (`AsyncTransport` with `async
def fetch_config` / `send_tracking`) is fixed either way; the
question is which adapter ships first.
- **DataStore Protocol shape**: a separate `AsyncDataStore` Protocol
(the leaning) vs. a dual-protocol convention (one Protocol, two
suites of methods — e.g., `load_context_state` /
`aload_context_state`). Picking the dual-protocol convention removes
one type but couples the two surfaces tightly.
- **Async event bus**: keep the bus sync (the leaning) and let users
schedule async work in handlers, or add a parallel `AsyncEventBus`.
The framework-helper use cases may motivate a real async bus; the
decision waits on those concrete requirements.
- **Concurrency limit for sync→async fallback**: if the `to_thread()`
fallback is taken, the SDK should document the minimum
`asyncio.get_event_loop().set_default_executor()` configuration
needed to avoid head-of-line blocking under FastAPI burst load.

## Read next

- [Roadmap](roadmap.md) — phase boundaries and shipping status
- [Initialization § automatic config refresh](initialization.md#automatic-config-refresh-opt-in) — the Phase 2 surface that is shipped today
- [Extending](extending.md) — the Protocol-based extension model that
carries forward into the async surface
1 change: 1 addition & 0 deletions docs/extending.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,3 +232,4 @@ def make_test_core(config_payload):
- [Initialization](initialization.md) — `SDKConfig`, `TransportConfig`, `TrackingConfig`
- [Queue control](queue-control.md) — lifecycle events as an alternative to subclassing
- [Debugging](debugging.md) — diagnostic logging configuration
- [Async and framework integrations](async.md) — how the same Protocol surfaces extend to the planned async API
13 changes: 13 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ usage, operational concerns, and migration from other integration styles.
| [Migrating from raw REST](migration-from-rest.md) | Teams currently calling the Convert config and tracking endpoints directly |
| [Migrating from the JavaScript SDK](migration-from-javascript.md) | Teams porting JS backend code or sharing mental models with a JS front end |

## Looking ahead (planned, not yet shipped)

The guides below describe **future** directions only. They are not
implementation guides for the current SDK — `AsyncCore`,
`convert-sdk-django`, `convert-sdk-fastapi`, and `convert-sdk-flask`
are not importable today. If you need them now, treat them as design
input rather than instructions.

| Guide | What it covers |
|-------|----------------|
| [Roadmap](roadmap.md) | What is shipped, what is planned, and the phase boundaries |
| [Async and framework integrations](async.md) | **Phase 3 design intent** — describes the planned `AsyncCore` / `AsyncContext` API and the planned `convert-sdk-django` / `convert-sdk-fastapi` / `convert-sdk-flask` packages. Not part of the current MVP |

## Public API quick-reference

All symbols are importable from `convert_sdk`:
Expand Down
10 changes: 10 additions & 0 deletions docs/migration-from-javascript.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,9 +264,19 @@ concatenated before visitor id) with MurmurHash3 32-bit seed `9999`. If you
compute the bucket value manually in JavaScript and compare it to
`ExperienceResult.bucket_value` in Python, the values will match.

## Future async / framework support

The MVP is sync-first. An async public API (`AsyncCore` / `AsyncContext`)
and framework-specific helpers (`convert-sdk-django`,
`convert-sdk-fastapi`, `convert-sdk-flask`) are planned for Phase 3 and
will share the same evaluation core, parity contracts, and adapter
Protocols as the sync surface. See [Roadmap](roadmap.md) and
[Async and framework integrations](async.md) for the design intent.

## What to read next

- [Evaluation](evaluation.md) — full `run_experience()` / `run_feature()` reference
- [Tracking](tracking.md) — `track_conversion()` options and wire format
- [Debugging](debugging.md) — `diagnose_experience()` replaces JS SDK debug mode
- [Extending](extending.md) — replacing transport/storage adapters
- [Roadmap](roadmap.md) — what is shipped, what is planned
26 changes: 26 additions & 0 deletions docs/migration-from-rest.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,9 +211,35 @@ core = Core(SDKConfig(config_data=project_config, environment="production"))

See [Initialization](initialization.md) for the full `SDKConfig` reference.

## Future async / framework support

The MVP is sync-first. An async public API (`AsyncCore` / `AsyncContext`)
and framework-specific helpers (`convert-sdk-django`,
`convert-sdk-fastapi`, `convert-sdk-flask`) are planned for Phase 3.
If you are migrating from REST today and your service is already async
(`asyncio` / `httpx.AsyncClient`), you can call the sync SDK from
async code via `asyncio.to_thread()` until the async surface ships;
see [Async and framework integrations](async.md) for the design intent.

> ⚠️ **`asyncio.to_thread()` runs on the default event-loop thread
> executor**, which on CPython is `ThreadPoolExecutor` capped at
> `min(32, os.cpu_count() + 4)` workers by default. Under a FastAPI
> service receiving hundreds of concurrent requests, that cap can
> serialise SDK calls and produce bimodal latency. If you adopt the
> `to_thread()` bridge, raise the executor size at startup:
> ```python
> import asyncio
> from concurrent.futures import ThreadPoolExecutor
>
> loop = asyncio.get_event_loop()
> loop.set_default_executor(ThreadPoolExecutor(max_workers=128))
> ```
> or move SDK calls to a dedicated executor sized for your concurrency.

## What to read next

- [Initialization](initialization.md) — SDK key and direct config options
- [Evaluation](evaluation.md) — how the SDK replaces manual bucketing
- [Tracking](tracking.md) — conversion payload details and dedup rules
- [Queue control](queue-control.md) — flushing and batching configuration
- [Roadmap](roadmap.md) — what is shipped, what is planned
Loading
Loading