|
| 1 | +# Async Architecture Design |
| 2 | + |
| 3 | +## Design Philosophy |
| 4 | + |
| 5 | +The async layer follows the **inheritance-based async pattern** used by the Azure SDK for Python: |
| 6 | +a parallel class hierarchy that inherits all pure-logic from the sync classes and overrides only |
| 7 | +the three blocking operations. |
| 8 | + |
| 9 | +### Only 3 blocking operations get async overrides |
| 10 | + |
| 11 | +| Blocking operation | Sync implementation | Async implementation | |
| 12 | +|---|---|---| |
| 13 | +| Token acquisition | `credential.get_token(scope)` | `await credential.get_token(scope)` | |
| 14 | +| HTTP I/O | `requests.request(...)` | `await aiohttp.ClientSession.request(...)` | |
| 15 | +| Sleep / backoff | `time.sleep(delay)` | `await asyncio.sleep(delay)` | |
| 16 | + |
| 17 | +Everything else — URL building, OData serialization, key formatting, cache lookups, |
| 18 | +payload construction, error parsing — is **pure CPU logic** that runs in microseconds. |
| 19 | +These methods are inherited directly from the sync classes with no override needed. |
| 20 | + |
| 21 | +## Class Hierarchy |
| 22 | + |
| 23 | +``` |
| 24 | +azure.core.credentials.TokenCredential |
| 25 | + _AuthManager._acquire_token(scope) → credential.get_token(scope) |
| 26 | +
|
| 27 | +azure.core.credentials_async.AsyncTokenCredential |
| 28 | + _AsyncAuthManager._acquire_token(scope) → await credential.get_token(scope) |
| 29 | +
|
| 30 | +_HttpClient._request(method, url, **kw) → requests.request(...) |
| 31 | + _ODataClient._raw_request(...) |
| 32 | + _ODataClient._request(...) |
| 33 | + ... all sync CRUD / metadata methods |
| 34 | +
|
| 35 | +_AsyncHttpClient._request(method, url, **kw) → await aiohttp.ClientSession.request(...) |
| 36 | + _AsyncODataClient(inherits _ODataClient) |
| 37 | + override: _raw_request, _request, _headers, _merge_headers |
| 38 | + override: _create, _create_multiple, _update, _update_multiple, ... |
| 39 | + override: _entity_set_from_schema_name, _get, _get_multiple, ... |
| 40 | + inherited: _format_key, _build_alternate_key_str, _escape_odata_quotes, ... |
| 41 | + inherited: _attribute_payload, _label, _to_pascal, _normalize_cache_key, ... |
| 42 | +
|
| 43 | +DataverseClient |
| 44 | + records → RecordOperations (sync) |
| 45 | + query → QueryOperations (sync) |
| 46 | + tables → TableOperations (sync) |
| 47 | + files → FileOperations (sync) |
| 48 | +
|
| 49 | +AsyncDataverseClient |
| 50 | + records → AsyncRecordOperations (async) |
| 51 | + query → AsyncQueryOperations (async) |
| 52 | + tables → AsyncTableOperations (async) |
| 53 | + files → AsyncFileOperations (async) |
| 54 | +``` |
| 55 | + |
| 56 | +## File Map |
| 57 | + |
| 58 | +| File | Purpose | |
| 59 | +|---|---| |
| 60 | +| `core/_auth.py` | Sync `_AuthManager` (unchanged) | |
| 61 | +| `core/_async_auth.py` | Async `_AsyncAuthManager` | |
| 62 | +| `core/_http.py` | Sync `_HttpClient` using `requests` (unchanged) | |
| 63 | +| `core/_async_http.py` | Async `_AsyncHttpClient` using `aiohttp` | |
| 64 | +| `data/_odata.py` | Sync `_ODataClient` (unchanged) | |
| 65 | +| `data/_async_odata.py` | Async `_AsyncODataClient` inheriting `_ODataClient` | |
| 66 | +| `client.py` | Sync `DataverseClient` (unchanged) | |
| 67 | +| `async_client.py` | Async `AsyncDataverseClient` | |
| 68 | +| `operations/records.py` | Sync `RecordOperations` (unchanged) | |
| 69 | +| `operations/async_records.py` | Async `AsyncRecordOperations` | |
| 70 | +| `operations/query.py` | Sync `QueryOperations` (unchanged) | |
| 71 | +| `operations/async_query.py` | Async `AsyncQueryOperations` | |
| 72 | +| `operations/tables.py` | Sync `TableOperations` (unchanged) | |
| 73 | +| `operations/async_tables.py` | Async `AsyncTableOperations` | |
| 74 | +| `operations/files.py` | Sync `FileOperations` (unchanged) | |
| 75 | +| `operations/async_files.py` | Async `AsyncFileOperations` | |
| 76 | + |
| 77 | +## Async HTTP Response Wrapper |
| 78 | + |
| 79 | +`_AsyncODataClient` depends on response objects that provide `.status_code`, `.headers`, |
| 80 | +`.text`, and `.json()` synchronously (matching the `requests.Response` interface used |
| 81 | +throughout `_ODataClient`). |
| 82 | + |
| 83 | +`_AsyncResponse` achieves this by **eagerly reading the entire response body** when the |
| 84 | +aiohttp request completes. This is acceptable for Dataverse API responses (which are |
| 85 | +typically small JSON payloads). File uploads use streaming writes (not reads), so |
| 86 | +eager body reading does not affect upload performance. |
| 87 | + |
| 88 | +## Async Generator for `_get_multiple` |
| 89 | + |
| 90 | +The sync `_get_multiple` is a regular generator (`yield`). The async version is an |
| 91 | +**async generator** (`async def` with `yield`), enabling callers to iterate pages with |
| 92 | +`async for`: |
| 93 | + |
| 94 | +```python |
| 95 | +async for page in od._get_multiple("account", filter="statecode eq 0"): |
| 96 | + for row in page: |
| 97 | + print(row["name"]) |
| 98 | +``` |
| 99 | + |
| 100 | +At the public API level, `AsyncRecordOperations.get()` returns an async generator function: |
| 101 | + |
| 102 | +```python |
| 103 | +pages = await client.records.get("account", filter="statecode eq 0") |
| 104 | +async for page in pages: |
| 105 | + for record in page: |
| 106 | + print(record["name"]) |
| 107 | +``` |
| 108 | + |
| 109 | +## ContextVar Correlation IDs |
| 110 | + |
| 111 | +`_CALL_SCOPE_CORRELATION_ID` is a `ContextVar`. Python's `ContextVar` is fully compatible |
| 112 | +with asyncio — each task gets its own copy of the context. The `_call_scope()` context |
| 113 | +manager (sync `@contextmanager`) is used inside `@asynccontextmanager` (`_scoped_odata`) |
| 114 | +via a regular `with` statement — this is safe because the ContextVar set/reset is |
| 115 | +instantaneous (no I/O). |
| 116 | + |
| 117 | +## Usage Comparison |
| 118 | + |
| 119 | +### Sync (existing code — unchanged) |
| 120 | + |
| 121 | +```python |
| 122 | +from azure.identity import ClientSecretCredential |
| 123 | +from PowerPlatform.Dataverse.client import DataverseClient |
| 124 | + |
| 125 | +credential = ClientSecretCredential(tenant_id, client_id, client_secret) |
| 126 | + |
| 127 | +with DataverseClient("https://org.crm.dynamics.com", credential) as client: |
| 128 | + guid = client.records.create("account", {"name": "Contoso"}) |
| 129 | + record = client.records.get("account", guid) |
| 130 | + client.records.update("account", guid, {"telephone1": "555-0100"}) |
| 131 | + client.records.delete("account", guid) |
| 132 | +``` |
| 133 | + |
| 134 | +### Async (new) |
| 135 | + |
| 136 | +```python |
| 137 | +import asyncio |
| 138 | +from azure.identity.aio import ClientSecretCredential |
| 139 | +from PowerPlatform.Dataverse.async_client import AsyncDataverseClient |
| 140 | + |
| 141 | +credential = ClientSecretCredential(tenant_id, client_id, client_secret) |
| 142 | + |
| 143 | +async def main(): |
| 144 | + async with AsyncDataverseClient("https://org.crm.dynamics.com", credential) as client: |
| 145 | + guid = await client.records.create("account", {"name": "Contoso"}) |
| 146 | + record = await client.records.get("account", guid) |
| 147 | + await client.records.update("account", guid, {"telephone1": "555-0100"}) |
| 148 | + await client.records.delete("account", guid) |
| 149 | + |
| 150 | +asyncio.run(main()) |
| 151 | +``` |
| 152 | + |
| 153 | +## Installation |
| 154 | + |
| 155 | +### Async support (optional dependency) |
| 156 | + |
| 157 | +```bash |
| 158 | +pip install "PowerPlatform-Dataverse-Client[async]" |
| 159 | +``` |
| 160 | + |
| 161 | +This installs `aiohttp>=3.13.3` in addition to the core dependencies. The sync client |
| 162 | +continues to work without `aiohttp` installed. |
| 163 | + |
| 164 | +## Migration Guide |
| 165 | + |
| 166 | +Existing sync code does **not** need to change. Async support is purely additive: |
| 167 | + |
| 168 | +1. Change import: `from PowerPlatform.Dataverse.client import DataverseClient` |
| 169 | + → `from PowerPlatform.Dataverse.async_client import AsyncDataverseClient` |
| 170 | + |
| 171 | +2. Use `async with` instead of `with` |
| 172 | + |
| 173 | +3. Use async credentials: `azure.identity.aio.*` instead of `azure.identity.*` |
| 174 | + |
| 175 | +4. Add `await` before every operation call |
| 176 | + |
| 177 | +5. Use `async for` when iterating pages returned by `client.records.get(table, ...)` |
0 commit comments