prefect-aca-sessions 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- prefect_aca_sessions-1.0.0/PKG-INFO +148 -0
- prefect_aca_sessions-1.0.0/README.md +136 -0
- prefect_aca_sessions-1.0.0/pyproject.toml +31 -0
- prefect_aca_sessions-1.0.0/pyproject.toml.orig +30 -0
- prefect_aca_sessions-1.0.0/src/prefect_aca_sessions/__init__.py +7 -0
- prefect_aca_sessions-1.0.0/src/prefect_aca_sessions/client.py +98 -0
- prefect_aca_sessions-1.0.0/src/prefect_aca_sessions/py.typed +0 -0
- prefect_aca_sessions-1.0.0/src/prefect_aca_sessions/snippets.py +55 -0
- prefect_aca_sessions-1.0.0/src/prefect_aca_sessions/worker.py +144 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: prefect-aca-sessions
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Prefect worker that runs flow runs in Azure Container Apps dynamic sessions
|
|
5
|
+
Author: Rafael Guimaraes Siqueira
|
|
6
|
+
Author-email: Rafael Guimaraes Siqueira <rafasiqueira@gmail.com>
|
|
7
|
+
Requires-Dist: azure-identity>=1.26.0
|
|
8
|
+
Requires-Dist: httpx>=0.28.1
|
|
9
|
+
Requires-Dist: prefect>=3.8.8
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# prefect-aca-sessions
|
|
14
|
+
|
|
15
|
+
A Prefect worker that runs flow runs in
|
|
16
|
+
[Azure Container Apps dynamic sessions](https://learn.microsoft.com/en-us/azure/container-apps/sessions-code-interpreter)
|
|
17
|
+
(code interpreter session pools). Worker type: `azure-container-apps-sessions`.
|
|
18
|
+
|
|
19
|
+
> Status: unit-tested only. It has not yet been verified against a real session pool.
|
|
20
|
+
|
|
21
|
+
## How it works
|
|
22
|
+
|
|
23
|
+
The worker is a long-running process that polls a Prefect work pool. For each flow run it:
|
|
24
|
+
|
|
25
|
+
1. Authenticates to the pool's management endpoint with a Microsoft Entra token
|
|
26
|
+
(`DefaultAzureCredential`, audience `https://dynamicsessions.io`).
|
|
27
|
+
2. Starts `prefect flow-run execute` as a detached process inside a session (after
|
|
28
|
+
`pip install`-ing `pip_packages`). The session identifier defaults to the flow run ID.
|
|
29
|
+
3. Polls the session, forwards the process output to the flow run logs, and reports the exit code.
|
|
30
|
+
4. Deletes the session (unless `delete_session_on_completion` is `false`).
|
|
31
|
+
|
|
32
|
+
A single `/executions` call is limited to 220 seconds, which is why the flow runs as a
|
|
33
|
+
background process that is polled rather than inside one call.
|
|
34
|
+
|
|
35
|
+
## Prerequisites
|
|
36
|
+
|
|
37
|
+
- A self-hosted Prefect server reachable from **both** the worker and the sessions.
|
|
38
|
+
- An Azure Container Apps **code interpreter session pool**
|
|
39
|
+
(`az containerapp sessionpool create --container-type PythonLTS ...`). Note its
|
|
40
|
+
*pool management endpoint*:
|
|
41
|
+
`az containerapp sessionpool show -n <pool> -g <rg> --query properties.poolManagementEndpoint -o tsv`
|
|
42
|
+
- An identity for the worker with the **Azure ContainerApps Session Executor** role on the pool.
|
|
43
|
+
- Sessions with network egress to your Prefect API (the session pool's network status must
|
|
44
|
+
be `EgressEnabled`) and to PyPI, or a pool with the packages you need already available.
|
|
45
|
+
- Python 3.12+ and [uv](https://docs.astral.sh/uv/).
|
|
46
|
+
|
|
47
|
+
## 1. Install the worker
|
|
48
|
+
|
|
49
|
+
The package must be installed in the environment where `prefect` runs, because Prefect
|
|
50
|
+
discovers worker types through the `prefect.collections` entry point.
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
uv pip install git+https://github.com/<you>/prefect-aca-sessions # or: uv pip install -e .
|
|
54
|
+
prefect worker start --help # sanity check
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## 2. Point the CLI at your server
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
export PREFECT_API_URL=https://prefect.example.com/api
|
|
61
|
+
# export PREFECT_API_KEY=... # only if your server has basic auth / a gateway key
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## 3. Create the work pool
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
prefect work-pool create aca-sessions --type azure-container-apps-sessions
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Then open the pool in the UI (Work Pools → `aca-sessions` → Edit) and set the default
|
|
71
|
+
**Pool management endpoint**, or leave it empty and set it per deployment.
|
|
72
|
+
|
|
73
|
+
## 4. Authenticate the worker to Azure
|
|
74
|
+
|
|
75
|
+
`DefaultAzureCredential` is used, so any of these work:
|
|
76
|
+
|
|
77
|
+
- **Managed identity** (worker on an Azure VM, ACA, AKS): assign the Session Executor role to it.
|
|
78
|
+
- **Service principal**: `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_CLIENT_SECRET`.
|
|
79
|
+
- **Local development**: `az login`.
|
|
80
|
+
|
|
81
|
+
## 5. Start the worker
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
prefect worker start --pool aca-sessions --type azure-container-apps-sessions
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Run it under a process manager (systemd, a container, etc.) for production.
|
|
88
|
+
|
|
89
|
+
## 6. Deploy a flow to the pool
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from prefect import flow
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@flow(log_prints=True)
|
|
96
|
+
def hello():
|
|
97
|
+
print("running in an ACA session")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
hello.from_source(
|
|
102
|
+
source="https://github.com/<you>/<repo>",
|
|
103
|
+
entrypoint="flows.py:hello",
|
|
104
|
+
).deploy(
|
|
105
|
+
name="hello-aca",
|
|
106
|
+
work_pool_name="aca-sessions",
|
|
107
|
+
job_variables={
|
|
108
|
+
"pool_management_endpoint": "https://<region>.dynamicsessions.io/subscriptions/<sub>/resourceGroups/<rg>/sessionPools/<pool>",
|
|
109
|
+
"pip_packages": ["prefect", "pandas"],
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The session has to fetch the flow code itself (git source, or a pull step), because nothing
|
|
115
|
+
is shared with the worker's filesystem. Add private-repo credentials via the deployment's
|
|
116
|
+
pull steps and `env`.
|
|
117
|
+
|
|
118
|
+
## Job variables
|
|
119
|
+
|
|
120
|
+
| Variable | Default | Description |
|
|
121
|
+
| --- | --- | --- |
|
|
122
|
+
| `pool_management_endpoint` | required | Session pool management endpoint URL |
|
|
123
|
+
| `api_version` | `2025-10-02-preview` | Sessions data-plane API version |
|
|
124
|
+
| `session_identifier` | flow run ID | Session to run in. A fixed value reuses one session across runs, so runs can see each other's files |
|
|
125
|
+
| `pip_packages` | `["prefect"]` | Installed in the session before the run. Pin the Prefect version to match your server |
|
|
126
|
+
| `poll_interval_seconds` | `10` | Seconds between status polls |
|
|
127
|
+
| `delete_session_on_completion` | `true` | Delete the session after the run |
|
|
128
|
+
| `env` | `{}` | Extra environment variables for the flow run process |
|
|
129
|
+
| `command` | `prefect flow-run execute` | Command run in the session |
|
|
130
|
+
|
|
131
|
+
## Security notes
|
|
132
|
+
|
|
133
|
+
- The flow run's environment (including `PREFECT_API_URL` and any `PREFECT_API_KEY`) is sent
|
|
134
|
+
to the session inside the execution request. Use a dedicated, least-privilege API key.
|
|
135
|
+
- Session identifiers are sensitive; don't share a fixed identifier across tenants.
|
|
136
|
+
|
|
137
|
+
## Known limitations
|
|
138
|
+
|
|
139
|
+
- Cancelling a flow run does not stop the process in the session (`kill_infrastructure` is not
|
|
140
|
+
implemented); delete the session to stop it.
|
|
141
|
+
- Session lifetime and idle limits are set on the pool and apply to long flow runs.
|
|
142
|
+
|
|
143
|
+
## Development
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
uv sync
|
|
147
|
+
uv run pytest
|
|
148
|
+
```
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# prefect-aca-sessions
|
|
2
|
+
|
|
3
|
+
A Prefect worker that runs flow runs in
|
|
4
|
+
[Azure Container Apps dynamic sessions](https://learn.microsoft.com/en-us/azure/container-apps/sessions-code-interpreter)
|
|
5
|
+
(code interpreter session pools). Worker type: `azure-container-apps-sessions`.
|
|
6
|
+
|
|
7
|
+
> Status: unit-tested only. It has not yet been verified against a real session pool.
|
|
8
|
+
|
|
9
|
+
## How it works
|
|
10
|
+
|
|
11
|
+
The worker is a long-running process that polls a Prefect work pool. For each flow run it:
|
|
12
|
+
|
|
13
|
+
1. Authenticates to the pool's management endpoint with a Microsoft Entra token
|
|
14
|
+
(`DefaultAzureCredential`, audience `https://dynamicsessions.io`).
|
|
15
|
+
2. Starts `prefect flow-run execute` as a detached process inside a session (after
|
|
16
|
+
`pip install`-ing `pip_packages`). The session identifier defaults to the flow run ID.
|
|
17
|
+
3. Polls the session, forwards the process output to the flow run logs, and reports the exit code.
|
|
18
|
+
4. Deletes the session (unless `delete_session_on_completion` is `false`).
|
|
19
|
+
|
|
20
|
+
A single `/executions` call is limited to 220 seconds, which is why the flow runs as a
|
|
21
|
+
background process that is polled rather than inside one call.
|
|
22
|
+
|
|
23
|
+
## Prerequisites
|
|
24
|
+
|
|
25
|
+
- A self-hosted Prefect server reachable from **both** the worker and the sessions.
|
|
26
|
+
- An Azure Container Apps **code interpreter session pool**
|
|
27
|
+
(`az containerapp sessionpool create --container-type PythonLTS ...`). Note its
|
|
28
|
+
*pool management endpoint*:
|
|
29
|
+
`az containerapp sessionpool show -n <pool> -g <rg> --query properties.poolManagementEndpoint -o tsv`
|
|
30
|
+
- An identity for the worker with the **Azure ContainerApps Session Executor** role on the pool.
|
|
31
|
+
- Sessions with network egress to your Prefect API (the session pool's network status must
|
|
32
|
+
be `EgressEnabled`) and to PyPI, or a pool with the packages you need already available.
|
|
33
|
+
- Python 3.12+ and [uv](https://docs.astral.sh/uv/).
|
|
34
|
+
|
|
35
|
+
## 1. Install the worker
|
|
36
|
+
|
|
37
|
+
The package must be installed in the environment where `prefect` runs, because Prefect
|
|
38
|
+
discovers worker types through the `prefect.collections` entry point.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uv pip install git+https://github.com/<you>/prefect-aca-sessions # or: uv pip install -e .
|
|
42
|
+
prefect worker start --help # sanity check
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## 2. Point the CLI at your server
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
export PREFECT_API_URL=https://prefect.example.com/api
|
|
49
|
+
# export PREFECT_API_KEY=... # only if your server has basic auth / a gateway key
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## 3. Create the work pool
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
prefect work-pool create aca-sessions --type azure-container-apps-sessions
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Then open the pool in the UI (Work Pools → `aca-sessions` → Edit) and set the default
|
|
59
|
+
**Pool management endpoint**, or leave it empty and set it per deployment.
|
|
60
|
+
|
|
61
|
+
## 4. Authenticate the worker to Azure
|
|
62
|
+
|
|
63
|
+
`DefaultAzureCredential` is used, so any of these work:
|
|
64
|
+
|
|
65
|
+
- **Managed identity** (worker on an Azure VM, ACA, AKS): assign the Session Executor role to it.
|
|
66
|
+
- **Service principal**: `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_CLIENT_SECRET`.
|
|
67
|
+
- **Local development**: `az login`.
|
|
68
|
+
|
|
69
|
+
## 5. Start the worker
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
prefect worker start --pool aca-sessions --type azure-container-apps-sessions
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Run it under a process manager (systemd, a container, etc.) for production.
|
|
76
|
+
|
|
77
|
+
## 6. Deploy a flow to the pool
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from prefect import flow
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@flow(log_prints=True)
|
|
84
|
+
def hello():
|
|
85
|
+
print("running in an ACA session")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
hello.from_source(
|
|
90
|
+
source="https://github.com/<you>/<repo>",
|
|
91
|
+
entrypoint="flows.py:hello",
|
|
92
|
+
).deploy(
|
|
93
|
+
name="hello-aca",
|
|
94
|
+
work_pool_name="aca-sessions",
|
|
95
|
+
job_variables={
|
|
96
|
+
"pool_management_endpoint": "https://<region>.dynamicsessions.io/subscriptions/<sub>/resourceGroups/<rg>/sessionPools/<pool>",
|
|
97
|
+
"pip_packages": ["prefect", "pandas"],
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The session has to fetch the flow code itself (git source, or a pull step), because nothing
|
|
103
|
+
is shared with the worker's filesystem. Add private-repo credentials via the deployment's
|
|
104
|
+
pull steps and `env`.
|
|
105
|
+
|
|
106
|
+
## Job variables
|
|
107
|
+
|
|
108
|
+
| Variable | Default | Description |
|
|
109
|
+
| --- | --- | --- |
|
|
110
|
+
| `pool_management_endpoint` | required | Session pool management endpoint URL |
|
|
111
|
+
| `api_version` | `2025-10-02-preview` | Sessions data-plane API version |
|
|
112
|
+
| `session_identifier` | flow run ID | Session to run in. A fixed value reuses one session across runs, so runs can see each other's files |
|
|
113
|
+
| `pip_packages` | `["prefect"]` | Installed in the session before the run. Pin the Prefect version to match your server |
|
|
114
|
+
| `poll_interval_seconds` | `10` | Seconds between status polls |
|
|
115
|
+
| `delete_session_on_completion` | `true` | Delete the session after the run |
|
|
116
|
+
| `env` | `{}` | Extra environment variables for the flow run process |
|
|
117
|
+
| `command` | `prefect flow-run execute` | Command run in the session |
|
|
118
|
+
|
|
119
|
+
## Security notes
|
|
120
|
+
|
|
121
|
+
- The flow run's environment (including `PREFECT_API_URL` and any `PREFECT_API_KEY`) is sent
|
|
122
|
+
to the session inside the execution request. Use a dedicated, least-privilege API key.
|
|
123
|
+
- Session identifiers are sensitive; don't share a fixed identifier across tenants.
|
|
124
|
+
|
|
125
|
+
## Known limitations
|
|
126
|
+
|
|
127
|
+
- Cancelling a flow run does not stop the process in the session (`kill_infrastructure` is not
|
|
128
|
+
implemented); delete the session to stop it.
|
|
129
|
+
- Session lifetime and idle limits are set on the pool and apply to long flow runs.
|
|
130
|
+
|
|
131
|
+
## Development
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
uv sync
|
|
135
|
+
uv run pytest
|
|
136
|
+
```
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "prefect-aca-sessions"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Prefect worker that runs flow runs in Azure Container Apps dynamic sessions"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"azure-identity>=1.26.0",
|
|
9
|
+
"httpx>=0.28.1",
|
|
10
|
+
"prefect>=3.8.8",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[[project.authors]]
|
|
14
|
+
name = "Rafael Guimaraes Siqueira"
|
|
15
|
+
email = "rafasiqueira@gmail.com"
|
|
16
|
+
|
|
17
|
+
[project.entry-points."prefect.collections"]
|
|
18
|
+
prefect_aca_sessions = "prefect_aca_sessions"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["uv_build>=0.11.30,<0.12.0"]
|
|
22
|
+
build-backend = "uv_build"
|
|
23
|
+
|
|
24
|
+
[dependency-groups]
|
|
25
|
+
dev = [
|
|
26
|
+
"pytest>=9.1.1",
|
|
27
|
+
"pytest-asyncio>=1.4.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "prefect-aca-sessions"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Prefect worker that runs flow runs in Azure Container Apps dynamic sessions"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Rafael Guimaraes Siqueira", email = "rafasiqueira@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"azure-identity>=1.26.0",
|
|
12
|
+
"httpx>=0.28.1",
|
|
13
|
+
"prefect>=3.8.8",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["uv_build>=0.11.30,<0.12.0"]
|
|
18
|
+
build-backend = "uv_build"
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = [
|
|
22
|
+
"pytest>=9.1.1",
|
|
23
|
+
"pytest-asyncio>=1.4.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.entry-points."prefect.collections"]
|
|
27
|
+
prefect_aca_sessions = "prefect_aca_sessions"
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Minimal async client for the Azure Container Apps code interpreter management endpoint."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from azure.core.credentials_async import AsyncTokenCredential
|
|
9
|
+
from azure.identity.aio import DefaultAzureCredential
|
|
10
|
+
|
|
11
|
+
SESSIONS_TOKEN_SCOPE = "https://dynamicsessions.io/.default"
|
|
12
|
+
REQUEST_TIMEOUT_SECONDS = 300.0 # a single execution may run up to 220s
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SessionExecutionError(RuntimeError):
|
|
16
|
+
"""The sessions endpoint rejected a request or the executed code failed."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ExecutionResult:
|
|
21
|
+
status: str
|
|
22
|
+
stdout: str
|
|
23
|
+
stderr: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SessionsClient:
|
|
27
|
+
"""Runs inline Python code in a session of a code interpreter session pool."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
pool_management_endpoint: str,
|
|
32
|
+
api_version: str,
|
|
33
|
+
credential: AsyncTokenCredential | None = None,
|
|
34
|
+
http_client: httpx.AsyncClient | None = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
self._endpoint = pool_management_endpoint.rstrip("/")
|
|
37
|
+
self._api_version = api_version
|
|
38
|
+
self._credential = credential or DefaultAzureCredential()
|
|
39
|
+
self._http = http_client or httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS)
|
|
40
|
+
|
|
41
|
+
async def __aenter__(self) -> "SessionsClient":
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
45
|
+
await self._http.aclose()
|
|
46
|
+
await self._credential.close()
|
|
47
|
+
|
|
48
|
+
async def _headers(self) -> dict[str, str]:
|
|
49
|
+
token = await self._credential.get_token(SESSIONS_TOKEN_SCOPE)
|
|
50
|
+
return {"Authorization": f"Bearer {token.token}"}
|
|
51
|
+
|
|
52
|
+
def _params(self, identifier: str) -> dict[str, str]:
|
|
53
|
+
return {"api-version": self._api_version, "identifier": identifier}
|
|
54
|
+
|
|
55
|
+
async def execute(self, identifier: str, code: str) -> ExecutionResult:
|
|
56
|
+
response = await self._http.post(
|
|
57
|
+
f"{self._endpoint}/executions",
|
|
58
|
+
params=self._params(identifier),
|
|
59
|
+
headers=await self._headers(),
|
|
60
|
+
json={
|
|
61
|
+
"properties": {
|
|
62
|
+
"codeInputType": "inline",
|
|
63
|
+
"executionType": "synchronous",
|
|
64
|
+
"code": code,
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
if response.is_error:
|
|
69
|
+
raise SessionExecutionError(
|
|
70
|
+
f"Session pool returned HTTP {response.status_code}: {response.text}"
|
|
71
|
+
)
|
|
72
|
+
return _parse_execution(response.json())
|
|
73
|
+
|
|
74
|
+
async def delete_session(self, identifier: str) -> None:
|
|
75
|
+
response = await self._http.delete(
|
|
76
|
+
f"{self._endpoint}/session",
|
|
77
|
+
params=self._params(identifier),
|
|
78
|
+
headers=await self._headers(),
|
|
79
|
+
)
|
|
80
|
+
if response.is_error and response.status_code != 404:
|
|
81
|
+
raise SessionExecutionError(
|
|
82
|
+
f"Deleting session failed with HTTP {response.status_code}: {response.text}"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _parse_execution(body: dict[str, Any]) -> ExecutionResult:
|
|
87
|
+
properties = body.get("properties", {})
|
|
88
|
+
result = ExecutionResult(
|
|
89
|
+
status=properties.get("status", "Unknown"),
|
|
90
|
+
stdout=properties.get("stdout", ""),
|
|
91
|
+
stderr=properties.get("stderr", ""),
|
|
92
|
+
)
|
|
93
|
+
if result.status != "Succeeded":
|
|
94
|
+
raise SessionExecutionError(
|
|
95
|
+
f"Code execution ended with status {result.status!r}: "
|
|
96
|
+
f"{json.dumps(properties.get('result'))} {result.stderr}"
|
|
97
|
+
)
|
|
98
|
+
return result
|
|
File without changes
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Python snippets executed inside the session to launch and observe the flow run process."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
RUN_DIR = "/mnt/data/prefect-run"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_start_code(command: str, env: dict[str, str], pip_packages: list[str]) -> str:
|
|
9
|
+
"""Code that starts `command` as a detached process and returns immediately."""
|
|
10
|
+
spec = json.dumps({"command": command, "env": env, "pip": pip_packages})
|
|
11
|
+
return f"""
|
|
12
|
+
import json, os, subprocess
|
|
13
|
+
spec = json.loads({spec!r})
|
|
14
|
+
run_dir = {RUN_DIR!r}
|
|
15
|
+
os.makedirs(run_dir, exist_ok=True)
|
|
16
|
+
for name in ("exit_code", "output.log"):
|
|
17
|
+
path = os.path.join(run_dir, name)
|
|
18
|
+
if os.path.exists(path):
|
|
19
|
+
os.remove(path)
|
|
20
|
+
steps = []
|
|
21
|
+
if spec["pip"]:
|
|
22
|
+
steps.append("pip install --quiet " + " ".join(spec["pip"]))
|
|
23
|
+
steps.append(spec["command"])
|
|
24
|
+
script = " && ".join("(" + s + ")" for s in steps) + "; echo $? > " + run_dir + "/exit_code"
|
|
25
|
+
log = open(run_dir + "/output.log", "ab")
|
|
26
|
+
proc = subprocess.Popen(
|
|
27
|
+
["sh", "-c", script],
|
|
28
|
+
env={{**os.environ, **spec["env"]}},
|
|
29
|
+
stdout=log,
|
|
30
|
+
stderr=subprocess.STDOUT,
|
|
31
|
+
start_new_session=True,
|
|
32
|
+
)
|
|
33
|
+
print(proc.pid)
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_poll_code(log_offset: int) -> str:
|
|
38
|
+
"""Code printing JSON: new log text, next log offset and the exit code (or null)."""
|
|
39
|
+
return f"""
|
|
40
|
+
import json, os
|
|
41
|
+
run_dir = {RUN_DIR!r}
|
|
42
|
+
offset = {log_offset}
|
|
43
|
+
exit_path = run_dir + "/exit_code"
|
|
44
|
+
# read the exit code first so a run finishing mid-poll never loses its last log lines
|
|
45
|
+
exit_code = int(open(exit_path).read().strip()) if os.path.exists(exit_path) else None
|
|
46
|
+
with open(run_dir + "/output.log", "rb") as f:
|
|
47
|
+
f.seek(offset)
|
|
48
|
+
chunk = f.read()
|
|
49
|
+
print(json.dumps({{
|
|
50
|
+
"log": chunk.decode("utf-8", "replace"),
|
|
51
|
+
"offset": offset + len(chunk),
|
|
52
|
+
"exit_code": exit_code,
|
|
53
|
+
}}))
|
|
54
|
+
"""
|
|
55
|
+
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import anyio
|
|
5
|
+
import anyio.abc
|
|
6
|
+
from prefect.client.schemas.objects import FlowRun
|
|
7
|
+
from prefect.workers.base import (
|
|
8
|
+
BaseJobConfiguration,
|
|
9
|
+
BaseVariables,
|
|
10
|
+
BaseWorker,
|
|
11
|
+
BaseWorkerResult,
|
|
12
|
+
)
|
|
13
|
+
from pydantic import Field
|
|
14
|
+
|
|
15
|
+
from prefect_aca_sessions.client import SessionsClient
|
|
16
|
+
from prefect_aca_sessions.snippets import build_poll_code, build_start_code
|
|
17
|
+
|
|
18
|
+
DEFAULT_API_VERSION = "2025-10-02-preview"
|
|
19
|
+
DEFAULT_POLL_INTERVAL_SECONDS = 10
|
|
20
|
+
FAILURE_EXIT_CODE = -1
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ACASessionsJobConfiguration(BaseJobConfiguration):
|
|
24
|
+
"""Job configuration for flow runs executed in an ACA code interpreter session."""
|
|
25
|
+
|
|
26
|
+
pool_management_endpoint: str = Field(
|
|
27
|
+
description=(
|
|
28
|
+
"Management endpoint of the code interpreter session pool, e.g. "
|
|
29
|
+
"https://<region>.dynamicsessions.io/subscriptions/<id>/resourceGroups/<rg>"
|
|
30
|
+
"/sessionPools/<pool>"
|
|
31
|
+
),
|
|
32
|
+
json_schema_extra=dict(template="{{ pool_management_endpoint }}"),
|
|
33
|
+
)
|
|
34
|
+
api_version: str = Field(
|
|
35
|
+
default=DEFAULT_API_VERSION,
|
|
36
|
+
description="Sessions data-plane API version.",
|
|
37
|
+
json_schema_extra=dict(template="{{ api_version }}"),
|
|
38
|
+
)
|
|
39
|
+
session_identifier: str | None = Field(
|
|
40
|
+
default=None,
|
|
41
|
+
description="Session identifier. Defaults to the flow run ID (one session per run).",
|
|
42
|
+
json_schema_extra=dict(template="{{ session_identifier }}"),
|
|
43
|
+
)
|
|
44
|
+
pip_packages: list[str] = Field(
|
|
45
|
+
default_factory=lambda: ["prefect"],
|
|
46
|
+
description="Packages pip-installed in the session before the flow run starts.",
|
|
47
|
+
json_schema_extra=dict(template="{{ pip_packages }}"),
|
|
48
|
+
)
|
|
49
|
+
poll_interval_seconds: int = Field(
|
|
50
|
+
default=DEFAULT_POLL_INTERVAL_SECONDS,
|
|
51
|
+
ge=1,
|
|
52
|
+
description="Seconds between checks of the flow run process.",
|
|
53
|
+
json_schema_extra=dict(template="{{ poll_interval_seconds }}"),
|
|
54
|
+
)
|
|
55
|
+
delete_session_on_completion: bool = Field(
|
|
56
|
+
default=True,
|
|
57
|
+
description="Delete the session once the flow run process exits.",
|
|
58
|
+
json_schema_extra=dict(template="{{ delete_session_on_completion }}"),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ACASessionsVariables(BaseVariables):
|
|
63
|
+
pool_management_endpoint: str = Field(
|
|
64
|
+
description="Management endpoint of the code interpreter session pool.",
|
|
65
|
+
)
|
|
66
|
+
api_version: str = Field(default=DEFAULT_API_VERSION, description="Sessions API version.")
|
|
67
|
+
session_identifier: str | None = Field(
|
|
68
|
+
default=None, description="Session identifier. Defaults to the flow run ID."
|
|
69
|
+
)
|
|
70
|
+
pip_packages: list[str] = Field(
|
|
71
|
+
default_factory=lambda: ["prefect"],
|
|
72
|
+
description="Packages pip-installed in the session before the run.",
|
|
73
|
+
)
|
|
74
|
+
poll_interval_seconds: int = Field(
|
|
75
|
+
default=DEFAULT_POLL_INTERVAL_SECONDS, ge=1, description="Seconds between status checks."
|
|
76
|
+
)
|
|
77
|
+
delete_session_on_completion: bool = Field(
|
|
78
|
+
default=True, description="Delete the session after the run."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ACASessionsWorkerResult(BaseWorkerResult):
|
|
83
|
+
"""Result of a flow run executed in an ACA session."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ACASessionsWorker(BaseWorker):
|
|
87
|
+
type = "azure-container-apps-sessions"
|
|
88
|
+
job_configuration = ACASessionsJobConfiguration
|
|
89
|
+
job_configuration_variables = ACASessionsVariables
|
|
90
|
+
_description = "Runs flow runs in Azure Container Apps dynamic sessions (code interpreter)."
|
|
91
|
+
_documentation_url = (
|
|
92
|
+
"https://learn.microsoft.com/en-us/azure/container-apps/sessions-code-interpreter"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
async def run(
|
|
96
|
+
self,
|
|
97
|
+
flow_run: FlowRun,
|
|
98
|
+
configuration: ACASessionsJobConfiguration,
|
|
99
|
+
task_status: anyio.abc.TaskStatus | None = None,
|
|
100
|
+
) -> ACASessionsWorkerResult:
|
|
101
|
+
identifier = configuration.session_identifier or str(flow_run.id)
|
|
102
|
+
logger = self.get_flow_run_logger(flow_run)
|
|
103
|
+
|
|
104
|
+
async with SessionsClient(
|
|
105
|
+
configuration.pool_management_endpoint, configuration.api_version
|
|
106
|
+
) as client:
|
|
107
|
+
try:
|
|
108
|
+
exit_code = await self._execute(client, identifier, configuration, logger, task_status)
|
|
109
|
+
except Exception:
|
|
110
|
+
logger.exception("Flow run failed in session %s", identifier)
|
|
111
|
+
exit_code = FAILURE_EXIT_CODE
|
|
112
|
+
finally:
|
|
113
|
+
if configuration.delete_session_on_completion:
|
|
114
|
+
await self._delete_quietly(client, identifier, logger)
|
|
115
|
+
|
|
116
|
+
return ACASessionsWorkerResult(status_code=exit_code, identifier=identifier)
|
|
117
|
+
|
|
118
|
+
async def _execute(self, client, identifier, configuration, logger, task_status) -> int:
|
|
119
|
+
await client.execute(
|
|
120
|
+
identifier,
|
|
121
|
+
build_start_code(
|
|
122
|
+
configuration.command or "", configuration.env, configuration.pip_packages
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
logger.info("Started flow run process in session %s", identifier)
|
|
126
|
+
if task_status is not None:
|
|
127
|
+
task_status.started(identifier)
|
|
128
|
+
|
|
129
|
+
offset = 0
|
|
130
|
+
while True:
|
|
131
|
+
await asyncio.sleep(configuration.poll_interval_seconds)
|
|
132
|
+
poll = json.loads((await client.execute(identifier, build_poll_code(offset))).stdout)
|
|
133
|
+
offset = poll["offset"]
|
|
134
|
+
if poll["log"]:
|
|
135
|
+
logger.info(poll["log"].rstrip())
|
|
136
|
+
if poll["exit_code"] is not None:
|
|
137
|
+
return poll["exit_code"]
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
async def _delete_quietly(client, identifier, logger) -> None:
|
|
141
|
+
try:
|
|
142
|
+
await client.delete_session(identifier)
|
|
143
|
+
except Exception:
|
|
144
|
+
logger.warning("Could not delete session %s", identifier, exc_info=True)
|