indexify 0.2.44__py3-none-any.whl → 0.2.46__py3-none-any.whl
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.
- indexify/__init__.py +2 -0
- indexify/cli.py +41 -80
- indexify/executor/api_objects.py +2 -0
- indexify/executor/downloader.py +23 -25
- indexify/executor/executor.py +29 -35
- indexify/executor/function_executor/function_executor.py +120 -19
- indexify/executor/function_executor/function_executor_state.py +75 -0
- indexify/executor/function_executor/invocation_state_client.py +232 -0
- indexify/executor/function_executor/server/function_executor_server.py +24 -0
- indexify/executor/function_executor/server/function_executor_server_factory.py +43 -0
- indexify/executor/function_executor/server/subprocess_function_executor_server.py +25 -0
- indexify/executor/function_executor/{process_function_executor_factory.py → server/subprocess_function_executor_server_factory.py} +21 -21
- indexify/executor/function_executor/single_task_runner.py +160 -0
- indexify/executor/function_executor/task_input.py +23 -0
- indexify/executor/function_executor/task_output.py +36 -0
- indexify/executor/task_reporter.py +10 -17
- indexify/executor/task_runner.py +104 -0
- indexify/function_executor/function_executor_service.py +22 -7
- indexify/function_executor/handlers/run_function/handler.py +13 -12
- indexify/function_executor/invocation_state/invocation_state_proxy_server.py +170 -0
- indexify/function_executor/invocation_state/proxied_invocation_state.py +24 -0
- indexify/function_executor/invocation_state/response_validator.py +29 -0
- indexify/function_executor/proto/function_executor.proto +47 -0
- indexify/function_executor/proto/function_executor_pb2.py +23 -11
- indexify/function_executor/proto/function_executor_pb2.pyi +70 -0
- indexify/function_executor/proto/function_executor_pb2_grpc.py +50 -0
- indexify/functions_sdk/graph.py +3 -3
- indexify/functions_sdk/image.py +142 -9
- indexify/functions_sdk/indexify_functions.py +45 -79
- indexify/functions_sdk/invocation_state/invocation_state.py +22 -0
- indexify/functions_sdk/invocation_state/local_invocation_state.py +30 -0
- indexify/http_client.py +0 -17
- {indexify-0.2.44.dist-info → indexify-0.2.46.dist-info}/METADATA +2 -2
- indexify-0.2.46.dist-info/RECORD +60 -0
- indexify/executor/function_executor/function_executor_factory.py +0 -26
- indexify/executor/function_executor/function_executor_map.py +0 -91
- indexify/executor/function_executor/process_function_executor.py +0 -64
- indexify/executor/function_worker.py +0 -253
- indexify-0.2.44.dist-info/RECORD +0 -50
- {indexify-0.2.44.dist-info → indexify-0.2.46.dist-info}/LICENSE.txt +0 -0
- {indexify-0.2.44.dist-info → indexify-0.2.46.dist-info}/WHEEL +0 -0
- {indexify-0.2.44.dist-info → indexify-0.2.46.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,232 @@
|
|
1
|
+
import asyncio
|
2
|
+
from typing import Any, AsyncGenerator, Optional, Union
|
3
|
+
|
4
|
+
import grpc
|
5
|
+
import httpx
|
6
|
+
|
7
|
+
from indexify.executor.downloader import serialized_object_from_http_response
|
8
|
+
from indexify.function_executor.proto.function_executor_pb2 import (
|
9
|
+
GetInvocationStateResponse,
|
10
|
+
InvocationStateRequest,
|
11
|
+
InvocationStateResponse,
|
12
|
+
SerializedObject,
|
13
|
+
SetInvocationStateResponse,
|
14
|
+
)
|
15
|
+
from indexify.function_executor.proto.function_executor_pb2_grpc import (
|
16
|
+
FunctionExecutorStub,
|
17
|
+
)
|
18
|
+
from indexify.function_executor.proto.message_validator import MessageValidator
|
19
|
+
|
20
|
+
|
21
|
+
class InvocationStateClient:
|
22
|
+
"""InvocationStateClient is a client for the invocation state server of a Function Executor.
|
23
|
+
|
24
|
+
The client initializes the Function Executor's invocation state server and executes requests
|
25
|
+
it sends to the client.
|
26
|
+
"""
|
27
|
+
|
28
|
+
def __init__(
|
29
|
+
self,
|
30
|
+
stub: FunctionExecutorStub,
|
31
|
+
base_url: str,
|
32
|
+
http_client: httpx.AsyncClient,
|
33
|
+
graph: str,
|
34
|
+
namespace: str,
|
35
|
+
logger: Any,
|
36
|
+
):
|
37
|
+
self._stub: FunctionExecutorStub = stub
|
38
|
+
self._base_url: str = base_url
|
39
|
+
self._http_client: httpx.AsyncClient = http_client
|
40
|
+
self._graph: str = graph
|
41
|
+
self._namespace: str = namespace
|
42
|
+
self._logger: Any = logger.bind(
|
43
|
+
module=__name__, graph=graph, namespace=namespace
|
44
|
+
)
|
45
|
+
self._client_response_queue: asyncio.Queue[
|
46
|
+
Union[InvocationStateResponse, str]
|
47
|
+
] = asyncio.Queue()
|
48
|
+
self._task_id_to_invocation_id: dict[str, str] = {}
|
49
|
+
self._request_loop_task: Optional[asyncio.Task] = None
|
50
|
+
|
51
|
+
async def start(self) -> None:
|
52
|
+
"""Starts the invocation state client.
|
53
|
+
|
54
|
+
This method initializes the Function Executor's invocation state server first.
|
55
|
+
This is why this method needs to be awaited before executing any tasks on the Function Executor
|
56
|
+
that might use invocation state feature."""
|
57
|
+
server_requests = self._stub.initialize_invocation_state_server(
|
58
|
+
self._response_generator()
|
59
|
+
)
|
60
|
+
self._request_loop_task = asyncio.create_task(
|
61
|
+
self._request_loop(server_requests)
|
62
|
+
)
|
63
|
+
|
64
|
+
def add_task_to_invocation_id_entry(self, task_id: str, invocation_id: str) -> None:
|
65
|
+
"""Adds a task ID to invocation ID entry to the client's internal state.
|
66
|
+
|
67
|
+
This allows to authorize requests to the invocation state server.
|
68
|
+
If a request is not comming from the task ID that was added here then it will
|
69
|
+
be rejected. It's caller's responsibility to only add task IDs that are being
|
70
|
+
executed by the Function Executor so the Function Executor can't get access to
|
71
|
+
invocation state of tasks it doesn't run."""
|
72
|
+
self._task_id_to_invocation_id[task_id] = invocation_id
|
73
|
+
|
74
|
+
def remove_task_to_invocation_id_entry(self, task_id: str) -> None:
|
75
|
+
del self._task_id_to_invocation_id[task_id]
|
76
|
+
|
77
|
+
async def destroy(self) -> None:
|
78
|
+
if self._request_loop_task is not None:
|
79
|
+
self._request_loop_task.cancel()
|
80
|
+
await self._client_response_queue.put("shutdown")
|
81
|
+
|
82
|
+
async def _request_loop(
|
83
|
+
self, server_requests: AsyncGenerator[InvocationStateRequest, None]
|
84
|
+
) -> None:
|
85
|
+
try:
|
86
|
+
async for request in server_requests:
|
87
|
+
await self._process_request_no_raise(request)
|
88
|
+
except grpc.aio.AioRpcError:
|
89
|
+
# Reading from the stream failed.
|
90
|
+
# This is a normal situation when the server is shutting down.
|
91
|
+
pass
|
92
|
+
except asyncio.CancelledError:
|
93
|
+
# This async task was cancelled by destroy(). Normal situation too.
|
94
|
+
pass
|
95
|
+
|
96
|
+
async def _process_request_no_raise(self, request: InvocationStateRequest) -> None:
|
97
|
+
try:
|
98
|
+
await self._process_request(request)
|
99
|
+
except Exception as e:
|
100
|
+
try:
|
101
|
+
await self._client_response_queue.put(
|
102
|
+
InvocationStateResponse(
|
103
|
+
request_id=request.request_id,
|
104
|
+
success=False,
|
105
|
+
)
|
106
|
+
)
|
107
|
+
except Exception as ee:
|
108
|
+
self._logger.error("failed to send error response", exc_info=ee)
|
109
|
+
|
110
|
+
self._logger.error(
|
111
|
+
"failed to process request",
|
112
|
+
exc_info=e,
|
113
|
+
request_id=request.request_id,
|
114
|
+
)
|
115
|
+
|
116
|
+
async def _process_request(
|
117
|
+
self, request: InvocationStateRequest
|
118
|
+
) -> InvocationStateResponse:
|
119
|
+
self._validate_request(request)
|
120
|
+
# This is a very important check. We don't trust invocation ID and task ID
|
121
|
+
# supplied by Function Executor. If a task ID entry doesn't exist then it's
|
122
|
+
# a privelege escalation attempt.
|
123
|
+
invocation_id: str = self._task_id_to_invocation_id[request.task_id]
|
124
|
+
if request.HasField("get"):
|
125
|
+
value: Optional[SerializedObject] = await self._get_server_state(
|
126
|
+
invocation_id, request.get.key
|
127
|
+
)
|
128
|
+
await self._client_response_queue.put(
|
129
|
+
InvocationStateResponse(
|
130
|
+
request_id=request.request_id,
|
131
|
+
success=True,
|
132
|
+
get=GetInvocationStateResponse(
|
133
|
+
key=request.get.key,
|
134
|
+
value=value,
|
135
|
+
),
|
136
|
+
)
|
137
|
+
)
|
138
|
+
elif request.HasField("set"):
|
139
|
+
await self._set_server_state(
|
140
|
+
invocation_id, request.set.key, request.set.value
|
141
|
+
)
|
142
|
+
await self._client_response_queue.put(
|
143
|
+
InvocationStateResponse(
|
144
|
+
request_id=request.request_id,
|
145
|
+
success=True,
|
146
|
+
set=SetInvocationStateResponse(),
|
147
|
+
)
|
148
|
+
)
|
149
|
+
|
150
|
+
async def _response_generator(
|
151
|
+
self,
|
152
|
+
) -> AsyncGenerator[InvocationStateResponse, None]:
|
153
|
+
while True:
|
154
|
+
response = await self._client_response_queue.get()
|
155
|
+
# Hacky cancellation of the generator.
|
156
|
+
if response == "shutdown":
|
157
|
+
break
|
158
|
+
yield response
|
159
|
+
|
160
|
+
async def _set_server_state(
|
161
|
+
self, invocation_id: str, key: str, value: SerializedObject
|
162
|
+
) -> None:
|
163
|
+
url: str = (
|
164
|
+
f"{self._base_url}/internal/namespaces/{self._namespace}/compute_graphs/{self._graph}/invocations/{invocation_id}/ctx/{key}"
|
165
|
+
)
|
166
|
+
payload = value.bytes if value.HasField("bytes") else value.string
|
167
|
+
|
168
|
+
response = await self._http_client.post(
|
169
|
+
url=url,
|
170
|
+
files=[
|
171
|
+
(
|
172
|
+
"value",
|
173
|
+
("value", payload, value.content_type),
|
174
|
+
),
|
175
|
+
],
|
176
|
+
)
|
177
|
+
|
178
|
+
try:
|
179
|
+
response.raise_for_status()
|
180
|
+
except Exception as e:
|
181
|
+
self._logger.error(
|
182
|
+
"failed to set graph invocation state",
|
183
|
+
invocation_id=invocation_id,
|
184
|
+
key=key,
|
185
|
+
status_code=response.status_code,
|
186
|
+
error=response.text,
|
187
|
+
exc_info=e,
|
188
|
+
)
|
189
|
+
raise
|
190
|
+
|
191
|
+
async def _get_server_state(
|
192
|
+
self, invocation_id: str, key: str
|
193
|
+
) -> Optional[SerializedObject]:
|
194
|
+
url: str = (
|
195
|
+
f"{self._base_url}/internal/namespaces/{self._namespace}/compute_graphs/{self._graph}/invocations/{invocation_id}/ctx/{key}"
|
196
|
+
)
|
197
|
+
|
198
|
+
response: httpx.Response = await self._http_client.get(url)
|
199
|
+
if response.status_code == 404:
|
200
|
+
return None
|
201
|
+
|
202
|
+
try:
|
203
|
+
response.raise_for_status()
|
204
|
+
except httpx.HTTPStatusError as e:
|
205
|
+
self._logger.error(
|
206
|
+
f"failed to download graph invocation state value",
|
207
|
+
invocation_id=invocation_id,
|
208
|
+
key=key,
|
209
|
+
status_code=response.status_code,
|
210
|
+
error=response.text,
|
211
|
+
exc_info=e,
|
212
|
+
)
|
213
|
+
raise
|
214
|
+
|
215
|
+
return serialized_object_from_http_response(response)
|
216
|
+
|
217
|
+
def _validate_request(self, request: InvocationStateRequest) -> None:
|
218
|
+
(
|
219
|
+
MessageValidator(request)
|
220
|
+
.required_field("request_id")
|
221
|
+
.required_field("task_id")
|
222
|
+
)
|
223
|
+
if request.HasField("get"):
|
224
|
+
(MessageValidator(request.get).required_field("key"))
|
225
|
+
elif request.HasField("set"):
|
226
|
+
(
|
227
|
+
MessageValidator(request.set)
|
228
|
+
.required_field("key")
|
229
|
+
.required_serialized_object("value")
|
230
|
+
)
|
231
|
+
else:
|
232
|
+
raise ValueError("unknown request type")
|
@@ -0,0 +1,24 @@
|
|
1
|
+
from typing import Any
|
2
|
+
|
3
|
+
import grpc
|
4
|
+
|
5
|
+
# Timeout for Function Executor Server startup in seconds. The timeout is counted from
|
6
|
+
# the moment when a server just started.
|
7
|
+
FUNCTION_EXECUTOR_SERVER_READY_TIMEOUT_SEC = 5
|
8
|
+
|
9
|
+
|
10
|
+
class FunctionExecutorServer:
|
11
|
+
"""Abstract interface for a Function Executor Server.
|
12
|
+
|
13
|
+
FunctionExecutorServer is a class that executes tasks for a particular function.
|
14
|
+
The communication with FunctionExecutorServer is typicall done via gRPC.
|
15
|
+
"""
|
16
|
+
|
17
|
+
async def create_channel(self, logger: Any) -> grpc.aio.Channel:
|
18
|
+
"""Creates a new async gRPC channel to the Function Executor Server.
|
19
|
+
|
20
|
+
The channel is in ready state. It can only be used in the same thread where the
|
21
|
+
function was called. Caller should close the channel when it's no longer needed.
|
22
|
+
|
23
|
+
Raises Exception if an error occurred."""
|
24
|
+
raise NotImplementedError
|
@@ -0,0 +1,43 @@
|
|
1
|
+
from typing import Any, Optional
|
2
|
+
|
3
|
+
from .function_executor_server import FunctionExecutorServer
|
4
|
+
|
5
|
+
|
6
|
+
class FunctionExecutorServerConfiguration:
|
7
|
+
"""Configuration for creating a FunctionExecutorServer.
|
8
|
+
|
9
|
+
This configuration only includes data that must be known
|
10
|
+
during creation of the FunctionExecutorServer. If some data
|
11
|
+
is not required during the creation then it shouldn't be here.
|
12
|
+
|
13
|
+
A particular factory implementation might ignore certain
|
14
|
+
configuration parameters or raise an exception if it can't implement
|
15
|
+
them."""
|
16
|
+
|
17
|
+
def __init__(self, image_uri: Optional[str]):
|
18
|
+
# Container image URI of the Function Executor Server.
|
19
|
+
self.image_uri: Optional[str] = image_uri
|
20
|
+
|
21
|
+
|
22
|
+
class FunctionExecutorServerFactory:
|
23
|
+
"""Abstract class for creating FunctionExecutorServers."""
|
24
|
+
|
25
|
+
async def create(
|
26
|
+
self, config: FunctionExecutorServerConfiguration, logger: Any
|
27
|
+
) -> FunctionExecutorServer:
|
28
|
+
"""Creates a new FunctionExecutorServer.
|
29
|
+
|
30
|
+
Raises an exception if the creation failed or the configuration is not supported.
|
31
|
+
Args:
|
32
|
+
config: configuration of the FunctionExecutorServer.
|
33
|
+
logger: logger to be used during the function call."""
|
34
|
+
raise NotImplementedError()
|
35
|
+
|
36
|
+
async def destroy(self, server: FunctionExecutorServer, logger: Any) -> None:
|
37
|
+
"""Destroys the FunctionExecutorServer and release all its resources.
|
38
|
+
|
39
|
+
Args:
|
40
|
+
logger: logger to be used during the function call.
|
41
|
+
FunctionExecutorServer and customer code that it's running are not notified about the destruction.
|
42
|
+
Never raises any Exceptions."""
|
43
|
+
raise NotImplementedError
|
@@ -0,0 +1,25 @@
|
|
1
|
+
import asyncio
|
2
|
+
from typing import Any
|
3
|
+
|
4
|
+
import grpc
|
5
|
+
|
6
|
+
from indexify.function_executor.proto.configuration import GRPC_CHANNEL_OPTIONS
|
7
|
+
|
8
|
+
from .function_executor_server import FunctionExecutorServer
|
9
|
+
|
10
|
+
|
11
|
+
class SubprocessFunctionExecutorServer(FunctionExecutorServer):
|
12
|
+
"""A FunctionExecutorServer that runs in a child process."""
|
13
|
+
|
14
|
+
def __init__(
|
15
|
+
self,
|
16
|
+
process: asyncio.subprocess.Process,
|
17
|
+
port: int,
|
18
|
+
address: str,
|
19
|
+
):
|
20
|
+
self._proc = process
|
21
|
+
self._port = port
|
22
|
+
self._address = address
|
23
|
+
|
24
|
+
async def create_channel(self, logger: Any) -> grpc.aio.Channel:
|
25
|
+
return grpc.aio.insecure_channel(self._address, options=GRPC_CHANNEL_OPTIONS)
|
@@ -1,26 +1,32 @@
|
|
1
1
|
import asyncio
|
2
2
|
from typing import Any, Optional
|
3
3
|
|
4
|
-
from .
|
5
|
-
|
4
|
+
from .function_executor_server_factory import (
|
5
|
+
FunctionExecutorServerConfiguration,
|
6
|
+
FunctionExecutorServerFactory,
|
7
|
+
)
|
8
|
+
from .subprocess_function_executor_server import (
|
9
|
+
SubprocessFunctionExecutorServer,
|
10
|
+
)
|
6
11
|
|
7
12
|
|
8
|
-
class
|
13
|
+
class SubprocessFunctionExecutorServerFactory(FunctionExecutorServerFactory):
|
9
14
|
def __init__(
|
10
15
|
self,
|
11
|
-
indexify_server_address: str,
|
12
16
|
development_mode: bool,
|
13
|
-
config_path: Optional[str],
|
14
17
|
):
|
15
|
-
self._indexify_server_address: str = indexify_server_address
|
16
18
|
self._development_mode: bool = development_mode
|
17
|
-
self._config_path: Optional[str] = config_path
|
18
19
|
# Registred ports range end at 49151. We start from 50000 to hopefully avoid conflicts.
|
19
20
|
self._free_ports = set(range(50000, 51000))
|
20
21
|
|
21
22
|
async def create(
|
22
|
-
self,
|
23
|
-
) ->
|
23
|
+
self, config: FunctionExecutorServerConfiguration, logger: Any
|
24
|
+
) -> SubprocessFunctionExecutorServer:
|
25
|
+
if config.image_uri is not None:
|
26
|
+
raise ValueError(
|
27
|
+
"SubprocessFunctionExecutorServerFactory doesn't support container images"
|
28
|
+
)
|
29
|
+
|
24
30
|
logger = logger.bind(module=__name__)
|
25
31
|
port: Optional[int] = None
|
26
32
|
|
@@ -30,13 +36,9 @@ class ProcessFunctionExecutorFactory(FunctionExecutorFactory):
|
|
30
36
|
"function-executor",
|
31
37
|
"--function-executor-server-address",
|
32
38
|
_server_address(port),
|
33
|
-
"--indexify-server-address",
|
34
|
-
self._indexify_server_address,
|
35
39
|
]
|
36
40
|
if self._development_mode:
|
37
41
|
args.append("--dev")
|
38
|
-
if self._config_path is not None:
|
39
|
-
args.extend(["--config-path", self._config_path])
|
40
42
|
# Run the process with our stdout, stderr. We want to see process logs and exceptions in our process output.
|
41
43
|
# This is useful for dubugging. Customer function stdout and stderr is captured and returned in the response
|
42
44
|
# so we won't see it in our process outputs. This is the right behavior as customer function stdout and stderr
|
@@ -45,12 +47,10 @@ class ProcessFunctionExecutorFactory(FunctionExecutorFactory):
|
|
45
47
|
"indexify-cli",
|
46
48
|
*args,
|
47
49
|
)
|
48
|
-
return
|
50
|
+
return SubprocessFunctionExecutorServer(
|
49
51
|
process=proc,
|
50
52
|
port=port,
|
51
53
|
address=_server_address(port),
|
52
|
-
logger=logger,
|
53
|
-
state=state,
|
54
54
|
)
|
55
55
|
except Exception as e:
|
56
56
|
if port is not None:
|
@@ -61,9 +61,11 @@ class ProcessFunctionExecutorFactory(FunctionExecutorFactory):
|
|
61
61
|
)
|
62
62
|
raise
|
63
63
|
|
64
|
-
async def destroy(
|
65
|
-
|
66
|
-
|
64
|
+
async def destroy(
|
65
|
+
self, server: SubprocessFunctionExecutorServer, logger: Any
|
66
|
+
) -> None:
|
67
|
+
proc: asyncio.subprocess.Process = server._proc
|
68
|
+
port: int = server._port
|
67
69
|
logger = logger.bind(
|
68
70
|
module=__name__,
|
69
71
|
pid=proc.pid,
|
@@ -84,8 +86,6 @@ class ProcessFunctionExecutorFactory(FunctionExecutorFactory):
|
|
84
86
|
)
|
85
87
|
finally:
|
86
88
|
self._release_port(port)
|
87
|
-
if executor._channel is not None:
|
88
|
-
await executor._channel.close()
|
89
89
|
|
90
90
|
def _allocate_port(self) -> int:
|
91
91
|
# No asyncio.Lock is required here because this operation never awaits
|
@@ -0,0 +1,160 @@
|
|
1
|
+
from typing import Any, Optional
|
2
|
+
|
3
|
+
import grpc
|
4
|
+
|
5
|
+
from indexify.function_executor.proto.function_executor_pb2 import (
|
6
|
+
InitializeRequest,
|
7
|
+
RunTaskRequest,
|
8
|
+
RunTaskResponse,
|
9
|
+
)
|
10
|
+
from indexify.function_executor.proto.function_executor_pb2_grpc import (
|
11
|
+
FunctionExecutorStub,
|
12
|
+
)
|
13
|
+
|
14
|
+
from ..api_objects import Task
|
15
|
+
from .function_executor import FunctionExecutor
|
16
|
+
from .function_executor_state import FunctionExecutorState
|
17
|
+
from .server.function_executor_server_factory import (
|
18
|
+
FunctionExecutorServerConfiguration,
|
19
|
+
FunctionExecutorServerFactory,
|
20
|
+
)
|
21
|
+
from .task_input import TaskInput
|
22
|
+
from .task_output import TaskOutput
|
23
|
+
|
24
|
+
|
25
|
+
class SingleTaskRunner:
|
26
|
+
def __init__(
|
27
|
+
self,
|
28
|
+
function_executor_state: FunctionExecutorState,
|
29
|
+
task_input: TaskInput,
|
30
|
+
function_executor_server_factory: FunctionExecutorServerFactory,
|
31
|
+
base_url: str,
|
32
|
+
config_path: Optional[str],
|
33
|
+
logger: Any,
|
34
|
+
):
|
35
|
+
self._state: FunctionExecutorState = function_executor_state
|
36
|
+
self._task_input: TaskInput = task_input
|
37
|
+
self._factory: FunctionExecutorServerFactory = function_executor_server_factory
|
38
|
+
self._base_url: str = base_url
|
39
|
+
self._config_path: Optional[str] = config_path
|
40
|
+
self._logger = logger.bind(module=__name__)
|
41
|
+
|
42
|
+
async def run(self) -> TaskOutput:
|
43
|
+
"""Runs the task in the Function Executor.
|
44
|
+
|
45
|
+
The FunctionExecutorState must be locked by the caller.
|
46
|
+
The lock is released during actual task run in the server.
|
47
|
+
The lock is relocked on return.
|
48
|
+
|
49
|
+
Raises an exception if an error occured."""
|
50
|
+
self._state.check_locked()
|
51
|
+
|
52
|
+
if self._state.function_executor is None:
|
53
|
+
self._state.function_executor = await self._create_function_executor()
|
54
|
+
|
55
|
+
return await self._run()
|
56
|
+
|
57
|
+
async def _create_function_executor(self) -> FunctionExecutor:
|
58
|
+
function_executor: FunctionExecutor = FunctionExecutor(
|
59
|
+
server_factory=self._factory, logger=self._logger
|
60
|
+
)
|
61
|
+
try:
|
62
|
+
config: FunctionExecutorServerConfiguration = (
|
63
|
+
FunctionExecutorServerConfiguration(
|
64
|
+
image_uri=self._task_input.task.image_uri,
|
65
|
+
)
|
66
|
+
)
|
67
|
+
initialize_request: InitializeRequest = InitializeRequest(
|
68
|
+
namespace=self._task_input.task.namespace,
|
69
|
+
graph_name=self._task_input.task.compute_graph,
|
70
|
+
graph_version=self._task_input.task.graph_version,
|
71
|
+
function_name=self._task_input.task.compute_fn,
|
72
|
+
graph=self._task_input.graph,
|
73
|
+
)
|
74
|
+
await function_executor.initialize(
|
75
|
+
config=config,
|
76
|
+
initialize_request=initialize_request,
|
77
|
+
base_url=self._base_url,
|
78
|
+
config_path=self._config_path,
|
79
|
+
)
|
80
|
+
return function_executor
|
81
|
+
except Exception as e:
|
82
|
+
self._logger.error(
|
83
|
+
"failed to initialize function executor",
|
84
|
+
exc_info=e,
|
85
|
+
)
|
86
|
+
await function_executor.destroy()
|
87
|
+
raise
|
88
|
+
|
89
|
+
async def _run(self) -> TaskOutput:
|
90
|
+
request: RunTaskRequest = RunTaskRequest(
|
91
|
+
graph_invocation_id=self._task_input.task.invocation_id,
|
92
|
+
task_id=self._task_input.task.id,
|
93
|
+
function_input=self._task_input.input,
|
94
|
+
)
|
95
|
+
if self._task_input.init_value is not None:
|
96
|
+
request.function_init_value.CopyFrom(self._task_input.init_value)
|
97
|
+
channel: grpc.aio.Channel = self._state.function_executor.channel()
|
98
|
+
|
99
|
+
async with _RunningTaskContextManager(
|
100
|
+
task_input=self._task_input, function_executor_state=self._state
|
101
|
+
):
|
102
|
+
response: RunTaskResponse = await FunctionExecutorStub(channel).run_task(
|
103
|
+
request
|
104
|
+
)
|
105
|
+
return _task_output(task=self._task_input.task, response=response)
|
106
|
+
|
107
|
+
|
108
|
+
class _RunningTaskContextManager:
|
109
|
+
"""Performs all the actions required before and after running a task."""
|
110
|
+
|
111
|
+
def __init__(
|
112
|
+
self, task_input: TaskInput, function_executor_state: FunctionExecutorState
|
113
|
+
):
|
114
|
+
self._task_input: TaskInput = task_input
|
115
|
+
self._state: FunctionExecutorState = function_executor_state
|
116
|
+
|
117
|
+
async def __aenter__(self):
|
118
|
+
self._state.increment_running_tasks()
|
119
|
+
self._state.function_executor.invocation_state_client().add_task_to_invocation_id_entry(
|
120
|
+
task_id=self._task_input.task.id,
|
121
|
+
invocation_id=self._task_input.task.invocation_id,
|
122
|
+
)
|
123
|
+
# Unlock the state so other tasks can act depending on it.
|
124
|
+
self._state.lock.release()
|
125
|
+
return self
|
126
|
+
|
127
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
128
|
+
await self._state.lock.acquire()
|
129
|
+
self._state.decrement_running_tasks()
|
130
|
+
self._state.function_executor.invocation_state_client().remove_task_to_invocation_id_entry(
|
131
|
+
task_id=self._task_input.task.id
|
132
|
+
)
|
133
|
+
|
134
|
+
|
135
|
+
def _task_output(task: Task, response: RunTaskResponse) -> TaskOutput:
|
136
|
+
required_fields = [
|
137
|
+
"stdout",
|
138
|
+
"stderr",
|
139
|
+
"is_reducer",
|
140
|
+
"success",
|
141
|
+
]
|
142
|
+
|
143
|
+
for field in required_fields:
|
144
|
+
if not response.HasField(field):
|
145
|
+
raise ValueError(f"Response is missing required field: {field}")
|
146
|
+
|
147
|
+
output = TaskOutput(
|
148
|
+
task=task,
|
149
|
+
stdout=response.stdout,
|
150
|
+
stderr=response.stderr,
|
151
|
+
reducer=response.is_reducer,
|
152
|
+
success=response.success,
|
153
|
+
)
|
154
|
+
|
155
|
+
if response.HasField("function_output"):
|
156
|
+
output.function_output = response.function_output
|
157
|
+
if response.HasField("router_output"):
|
158
|
+
output.router_output = response.router_output
|
159
|
+
|
160
|
+
return output
|
@@ -0,0 +1,23 @@
|
|
1
|
+
from typing import Optional
|
2
|
+
|
3
|
+
from indexify.function_executor.proto.function_executor_pb2 import (
|
4
|
+
SerializedObject,
|
5
|
+
)
|
6
|
+
|
7
|
+
from ..api_objects import Task
|
8
|
+
|
9
|
+
|
10
|
+
class TaskInput:
|
11
|
+
"""Task with all the resources required to run it."""
|
12
|
+
|
13
|
+
def __init__(
|
14
|
+
self,
|
15
|
+
task: Task,
|
16
|
+
graph: SerializedObject,
|
17
|
+
input: SerializedObject,
|
18
|
+
init_value: Optional[SerializedObject],
|
19
|
+
):
|
20
|
+
self.task: Task = task
|
21
|
+
self.graph: SerializedObject = graph
|
22
|
+
self.input: SerializedObject = input
|
23
|
+
self.init_value: Optional[SerializedObject] = init_value
|
@@ -0,0 +1,36 @@
|
|
1
|
+
from typing import Optional
|
2
|
+
|
3
|
+
from indexify.function_executor.proto.function_executor_pb2 import (
|
4
|
+
FunctionOutput,
|
5
|
+
RouterOutput,
|
6
|
+
)
|
7
|
+
|
8
|
+
from ..api_objects import Task
|
9
|
+
|
10
|
+
|
11
|
+
class TaskOutput:
|
12
|
+
"""Result of running a task."""
|
13
|
+
|
14
|
+
def __init__(
|
15
|
+
self,
|
16
|
+
task: Task,
|
17
|
+
function_output: Optional[FunctionOutput] = None,
|
18
|
+
router_output: Optional[RouterOutput] = None,
|
19
|
+
stdout: Optional[str] = None,
|
20
|
+
stderr: Optional[str] = None,
|
21
|
+
reducer: bool = False,
|
22
|
+
success: bool = False,
|
23
|
+
):
|
24
|
+
self.task = task
|
25
|
+
self.function_output = function_output
|
26
|
+
self.router_output = router_output
|
27
|
+
self.stdout = stdout
|
28
|
+
self.stderr = stderr
|
29
|
+
self.reducer = reducer
|
30
|
+
self.success = success
|
31
|
+
|
32
|
+
@classmethod
|
33
|
+
def internal_error(cls, task: Task) -> "TaskOutput":
|
34
|
+
"""Creates a TaskOutput for an internal error."""
|
35
|
+
# We are not sharing internal error messages with the customer.
|
36
|
+
return TaskOutput(task=task, stderr="Platform failed to execute the function.")
|