orca-python 0.2.0__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.
- __init__.py +0 -0
- orca_python/__init__.py +3 -0
- orca_python/envs.py +24 -0
- orca_python/exceptions.py +14 -0
- orca_python/main.py +686 -0
- orca_python/py.typed +0 -0
- orca_python-0.2.0.dist-info/LICENSE +16 -0
- orca_python-0.2.0.dist-info/METADATA +125 -0
- orca_python-0.2.0.dist-info/RECORD +17 -0
- orca_python-0.2.0.dist-info/WHEEL +4 -0
- service_pb2.py +145 -0
- service_pb2.pyi +196 -0
- service_pb2_grpc.py +293 -0
- vendor/__init__.py +0 -0
- vendor/validate_pb2.py +448 -0
- vendor/validate_pb2.pyi +618 -0
- vendor/validate_pb2_grpc.py +24 -0
orca_python/main.py
ADDED
@@ -0,0 +1,686 @@
|
|
1
|
+
"""
|
2
|
+
Orca Python SDK
|
3
|
+
|
4
|
+
This SDK provides the `Processor` class, which integrates with the Orca gRPC service
|
5
|
+
to register, execute, and manage algorithms defined in Python. Algorithms can have dependencies
|
6
|
+
which are managed by Orca-core.
|
7
|
+
"""
|
8
|
+
|
9
|
+
import re
|
10
|
+
import sys
|
11
|
+
import asyncio
|
12
|
+
import logging
|
13
|
+
import traceback
|
14
|
+
|
15
|
+
logging.basicConfig(
|
16
|
+
level=logging.INFO,
|
17
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
18
|
+
handlers=[logging.StreamHandler()],
|
19
|
+
)
|
20
|
+
|
21
|
+
import time
|
22
|
+
from typing import (
|
23
|
+
Any,
|
24
|
+
Dict,
|
25
|
+
List,
|
26
|
+
TypeVar,
|
27
|
+
Callable,
|
28
|
+
Iterable,
|
29
|
+
Generator,
|
30
|
+
TypeAlias,
|
31
|
+
AsyncGenerator,
|
32
|
+
)
|
33
|
+
from concurrent import futures
|
34
|
+
from dataclasses import dataclass
|
35
|
+
|
36
|
+
import grpc
|
37
|
+
import service_pb2 as pb
|
38
|
+
import service_pb2_grpc
|
39
|
+
import google.protobuf.struct_pb2 as struct_pb2
|
40
|
+
from google.protobuf import json_format
|
41
|
+
from service_pb2_grpc import OrcaProcessorServicer
|
42
|
+
|
43
|
+
from orca_python import envs
|
44
|
+
from orca_python.exceptions import InvalidDependency, InvalidAlgorithmArgument
|
45
|
+
|
46
|
+
# Regex patterns for validation
|
47
|
+
ALGORITHM_NAME = r"^[A-Z][a-zA-Z0-9]*$"
|
48
|
+
SEMVER_PATTERN = r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$"
|
49
|
+
WINDOW_NAME = r"^[A-Z][a-zA-Z0-9]*$"
|
50
|
+
|
51
|
+
AlgorithmFn: TypeAlias = Callable[..., Any]
|
52
|
+
|
53
|
+
T = TypeVar("T", bound=AlgorithmFn)
|
54
|
+
|
55
|
+
LOGGER = logging.getLogger(__name__)
|
56
|
+
|
57
|
+
|
58
|
+
@dataclass
|
59
|
+
class Window:
|
60
|
+
time_from: int
|
61
|
+
time_to: int
|
62
|
+
name: str
|
63
|
+
version: str
|
64
|
+
origin: str
|
65
|
+
|
66
|
+
|
67
|
+
def EmitWindow(window: Window) -> None:
|
68
|
+
"""
|
69
|
+
Emits a window to Orca-core.
|
70
|
+
|
71
|
+
Raises:
|
72
|
+
grpc.RpcError: If the emit fails.
|
73
|
+
"""
|
74
|
+
LOGGER.info(f"Emitting window: {window}")
|
75
|
+
|
76
|
+
window_pb = pb.Window()
|
77
|
+
window_pb.time_to = window.time_to
|
78
|
+
window_pb.time_from = window.time_from
|
79
|
+
window_pb.window_type_name = window.name
|
80
|
+
window_pb.window_type_version = window.version
|
81
|
+
window_pb.origin = window.origin
|
82
|
+
|
83
|
+
with grpc.insecure_channel(envs.ORCASERVER) as channel:
|
84
|
+
stub = service_pb2_grpc.OrcaCoreStub(channel)
|
85
|
+
response = stub.EmitWindow(window_pb)
|
86
|
+
LOGGER.info(f"Window emitted: {response}")
|
87
|
+
|
88
|
+
|
89
|
+
@dataclass
|
90
|
+
class Algorithm:
|
91
|
+
"""
|
92
|
+
Represents a registered algorithm with metadata and execution logic.
|
93
|
+
|
94
|
+
Attributes:
|
95
|
+
name (str): The name of the algorithm (PascalCase).
|
96
|
+
version (str): Semantic version of the algorithm (e.g., "1.0.0").
|
97
|
+
window_name (str): The window type name that triggers the algorithm.
|
98
|
+
window_version (str): The version of the window type.
|
99
|
+
exec_fn (AlgorithmFn): The execution function for the algorithm.
|
100
|
+
processor (str): Name of the processor where it's registered.
|
101
|
+
runtime (str): Python runtime used for execution.
|
102
|
+
"""
|
103
|
+
|
104
|
+
name: str
|
105
|
+
version: str
|
106
|
+
window_name: str
|
107
|
+
window_version: str
|
108
|
+
exec_fn: AlgorithmFn
|
109
|
+
processor: str
|
110
|
+
runtime: str
|
111
|
+
|
112
|
+
@property
|
113
|
+
def full_name(self) -> str:
|
114
|
+
"""Returns the full name as `name_version`."""
|
115
|
+
return f"{self.name}_{self.version}"
|
116
|
+
|
117
|
+
@property
|
118
|
+
def full_window_name(self) -> str:
|
119
|
+
"""Returns the full window name as `window_name_window_version`."""
|
120
|
+
return f"{self.window_name}_{self.window_version}"
|
121
|
+
|
122
|
+
|
123
|
+
class Algorithms:
|
124
|
+
"""
|
125
|
+
Internal singleton managing all registered algorithms and their dependencies.
|
126
|
+
"""
|
127
|
+
|
128
|
+
def __init__(self) -> None:
|
129
|
+
self._flush()
|
130
|
+
|
131
|
+
def _flush(self) -> None:
|
132
|
+
"""Clears all registered algorithms and dependencies."""
|
133
|
+
LOGGER.debug("Flushing all algorithm registrations and dependencies")
|
134
|
+
self._algorithms: Dict[str, Algorithm] = {}
|
135
|
+
self._dependencies: Dict[str, List[Algorithm]] = {}
|
136
|
+
self._dependencyFns: Dict[str, List[AlgorithmFn]] = {}
|
137
|
+
self._window_triggers: Dict[str, List[Algorithm]] = {}
|
138
|
+
|
139
|
+
def _add_algorithm(self, name: str, algorithm: Algorithm) -> None:
|
140
|
+
"""
|
141
|
+
Registers a new algorithm.
|
142
|
+
|
143
|
+
Args:
|
144
|
+
name (str): Fully qualified algorithm name.
|
145
|
+
algorithm (Algorithm): Algorithm metadata and logic.
|
146
|
+
|
147
|
+
Raises:
|
148
|
+
ValueError: If the algorithm name is already registered.
|
149
|
+
"""
|
150
|
+
if name in self._algorithms:
|
151
|
+
LOGGER.error(f"Attempted to register duplicate algorithm: {name}")
|
152
|
+
raise ValueError(f"Algorithm {name} already exists")
|
153
|
+
LOGGER.info(
|
154
|
+
f"Registering algorithm: {name} (window: {algorithm.window_name}_{algorithm.window_version})"
|
155
|
+
)
|
156
|
+
self._algorithms[name] = algorithm
|
157
|
+
|
158
|
+
def _add_dependency(self, algorithm: str, dependency: AlgorithmFn) -> None:
|
159
|
+
"""
|
160
|
+
Adds a dependency to an algorithm.
|
161
|
+
|
162
|
+
Args:
|
163
|
+
algorithm (str): Target algorithm's full name.
|
164
|
+
dependency (AlgorithmFn): Dependency function already registered.
|
165
|
+
|
166
|
+
Raises:
|
167
|
+
ValueError: If the dependency function is not registered.
|
168
|
+
"""
|
169
|
+
LOGGER.debug(f"Adding dependency for algorithm: {algorithm}")
|
170
|
+
dependencyAlgo = None
|
171
|
+
for algo in self._algorithms.values():
|
172
|
+
if algo.exec_fn == dependency:
|
173
|
+
dependencyAlgo = algo
|
174
|
+
break
|
175
|
+
|
176
|
+
if not dependencyAlgo:
|
177
|
+
LOGGER.error(
|
178
|
+
f"Failed to find registered algorithm for dependency: {dependency.__name__}"
|
179
|
+
)
|
180
|
+
raise ValueError(
|
181
|
+
f"Dependency {dependency.__name__} not found in registered algorithms"
|
182
|
+
)
|
183
|
+
|
184
|
+
if algorithm not in self._dependencyFns:
|
185
|
+
self._dependencyFns[algorithm] = [dependency]
|
186
|
+
self._dependencies[algorithm] = [dependencyAlgo]
|
187
|
+
else:
|
188
|
+
self._dependencyFns[algorithm].append(dependency)
|
189
|
+
self._dependencies[algorithm].append(dependencyAlgo)
|
190
|
+
|
191
|
+
def _add_window_trigger(self, window: str, algorithm: Algorithm) -> None:
|
192
|
+
"""Associates an algorithm with a triggering window."""
|
193
|
+
if window not in self._window_triggers:
|
194
|
+
self._window_triggers[window] = [algorithm]
|
195
|
+
else:
|
196
|
+
self._window_triggers[window].append(algorithm)
|
197
|
+
|
198
|
+
def _has_algorithm_fn(self, algorithm_fn: AlgorithmFn) -> bool:
|
199
|
+
"""
|
200
|
+
Checks if a function is registered as an algorithm.
|
201
|
+
|
202
|
+
Args:
|
203
|
+
algorithm_fn (AlgorithmFn): The function to check.
|
204
|
+
|
205
|
+
Returns:
|
206
|
+
bool: True if the function is registered.
|
207
|
+
"""
|
208
|
+
for algorithm in self._algorithms.values():
|
209
|
+
if algorithm.exec_fn == algorithm_fn:
|
210
|
+
return True
|
211
|
+
return False
|
212
|
+
|
213
|
+
|
214
|
+
# the orca processor
|
215
|
+
class Processor(OrcaProcessorServicer): # type: ignore
|
216
|
+
"""
|
217
|
+
Orca gRPC Processor for algorithm registration and execution.
|
218
|
+
|
219
|
+
This class implements the gRPC `OrcaProcessor` interface and handles
|
220
|
+
the execution lifecycle of user-defined algorithms.
|
221
|
+
|
222
|
+
Args:
|
223
|
+
name (str): Unique name of the processor.
|
224
|
+
max_workers (int): Max worker threads for execution (default: 10).
|
225
|
+
"""
|
226
|
+
|
227
|
+
def __init__(self, name: str, max_workers: int = 10):
|
228
|
+
super().__init__()
|
229
|
+
self._name = name
|
230
|
+
self._processorConnStr = (
|
231
|
+
f"0.0.0.0:{envs.PORT}" # attach the processor to all network interfaces.
|
232
|
+
)
|
233
|
+
self._orcaProcessorConnStr = f"{envs.HOST}:{envs.PORT}" # tell orca-core to reference this processor by this address.
|
234
|
+
self._runtime = sys.version
|
235
|
+
self._max_workers = max_workers
|
236
|
+
self._algorithmsSingleton: Algorithms = Algorithms()
|
237
|
+
|
238
|
+
async def execute_algorithm(
|
239
|
+
self,
|
240
|
+
exec_id: str,
|
241
|
+
algorithm: pb.Algorithm,
|
242
|
+
dependencyResults: Iterable[pb.AlgorithmResult],
|
243
|
+
) -> pb.ExecutionResult:
|
244
|
+
"""
|
245
|
+
Executes a single algorithm with resolved dependencies.
|
246
|
+
|
247
|
+
Args:
|
248
|
+
exec_id (str): Unique execution ID.
|
249
|
+
algorithm (pb.Algorithm): The algorithm to execute.
|
250
|
+
dependencyResults (Iterable[pb.AlgorithmResult]): Results from dependency algorithms.
|
251
|
+
|
252
|
+
Returns:
|
253
|
+
pb.ExecutionResult: The result of the execution.
|
254
|
+
|
255
|
+
Raises:
|
256
|
+
Exception: On algorithm execution or serialization error.
|
257
|
+
"""
|
258
|
+
try:
|
259
|
+
LOGGER.debug(f"Processing algorithm: {algorithm.name}_{algorithm.version}")
|
260
|
+
algoName = f"{algorithm.name}_{algorithm.version}"
|
261
|
+
algo = self._algorithmsSingleton._algorithms[algoName]
|
262
|
+
|
263
|
+
# convert dependency results into a dict of name -> value
|
264
|
+
dependency_values = {}
|
265
|
+
for dep_result in dependencyResults:
|
266
|
+
# extract value based on which oneof field is set
|
267
|
+
dep_value = None
|
268
|
+
if dep_result.result.HasField("single_value"):
|
269
|
+
dep_value = dep_result.result.single_value
|
270
|
+
elif dep_result.result.HasField("float_values"):
|
271
|
+
dep_value = list(dep_result.result.float_values.values)
|
272
|
+
elif dep_result.result.HasField("struct_value"):
|
273
|
+
dep_value = json_format.MessageToDict(
|
274
|
+
dep_result.result.struct_value
|
275
|
+
)
|
276
|
+
|
277
|
+
dep_name = f"{dep_result.algorithm.name}_{dep_result.algorithm.version}"
|
278
|
+
dependency_values[dep_name] = dep_value
|
279
|
+
|
280
|
+
# execute in thread pool since algo.exec_fn is synchronous
|
281
|
+
loop = asyncio.get_event_loop()
|
282
|
+
|
283
|
+
algoResult = await loop.run_in_executor(
|
284
|
+
None, algo.exec_fn, dependency_values
|
285
|
+
)
|
286
|
+
|
287
|
+
# create result based on the return type
|
288
|
+
current_time = int(time.time()) # Current timestamp in seconds
|
289
|
+
|
290
|
+
if isinstance(algoResult, dict):
|
291
|
+
# For dictionary results, use struct_value
|
292
|
+
struct_value = struct_pb2.Struct()
|
293
|
+
json_format.ParseDict(algoResult, struct_value)
|
294
|
+
|
295
|
+
resultPb = pb.Result(
|
296
|
+
status=pb.ResultStatus.RESULT_STATUS_SUCEEDED, # Note: Using the actual enum from the proto
|
297
|
+
struct_value=struct_value,
|
298
|
+
timestamp=current_time,
|
299
|
+
)
|
300
|
+
elif isinstance(algoResult, float) or isinstance(algoResult, int):
|
301
|
+
# for single numeric values
|
302
|
+
resultPb = pb.Result(
|
303
|
+
status=pb.ResultStatus.RESULT_STATUS_SUCEEDED,
|
304
|
+
single_value=float(algoResult), # Convert to float as per proto
|
305
|
+
timestamp=current_time,
|
306
|
+
)
|
307
|
+
elif isinstance(algoResult, list) and all(
|
308
|
+
isinstance(x, (int, float)) for x in algoResult
|
309
|
+
):
|
310
|
+
# for lists of numeric values
|
311
|
+
float_array = pb.FloatArray(values=algoResult)
|
312
|
+
resultPb = pb.Result(
|
313
|
+
status=pb.ResultStatus.RESULT_STATUS_SUCEEDED,
|
314
|
+
float_values=float_array,
|
315
|
+
timestamp=current_time,
|
316
|
+
)
|
317
|
+
else:
|
318
|
+
# try to convert to struct as a fallback
|
319
|
+
try:
|
320
|
+
struct_value = struct_pb2.Struct()
|
321
|
+
# convert to dict if possible, otherwise use string representation
|
322
|
+
if hasattr(algoResult, "__dict__"):
|
323
|
+
result_dict = algoResult.__dict__
|
324
|
+
else:
|
325
|
+
result_dict = {"value": str(algoResult)}
|
326
|
+
|
327
|
+
json_format.ParseDict(result_dict, struct_value)
|
328
|
+
resultPb = pb.Result(
|
329
|
+
status=pb.ResultStatus.RESULT_STATUS_SUCEEDED,
|
330
|
+
struct_value=struct_value,
|
331
|
+
timestamp=current_time,
|
332
|
+
)
|
333
|
+
except Exception as conv_error:
|
334
|
+
LOGGER.error(
|
335
|
+
f"Failed to convert result to protobuf: {str(conv_error)}"
|
336
|
+
)
|
337
|
+
# create a handled failure result
|
338
|
+
resultPb = pb.Result(
|
339
|
+
status=pb.ResultStatus.RESULT_STATUS_HANDLED_FAILED,
|
340
|
+
timestamp=current_time,
|
341
|
+
)
|
342
|
+
|
343
|
+
# create the algorithm result
|
344
|
+
algoResultPb = pb.AlgorithmResult(
|
345
|
+
algorithm=algorithm, # Use the original algorithm object
|
346
|
+
result=resultPb,
|
347
|
+
)
|
348
|
+
|
349
|
+
# create the execution result
|
350
|
+
exec_result = pb.ExecutionResult(
|
351
|
+
exec_id=exec_id, algorithm_result=algoResultPb
|
352
|
+
)
|
353
|
+
|
354
|
+
LOGGER.info(f"Completed algorithm: {algorithm.name}")
|
355
|
+
return exec_result
|
356
|
+
|
357
|
+
except Exception as algo_error:
|
358
|
+
LOGGER.error(
|
359
|
+
f"Algorithm {algorithm.name} failed: {str(algo_error)}",
|
360
|
+
exc_info=True,
|
361
|
+
)
|
362
|
+
|
363
|
+
# create a failure result
|
364
|
+
current_time = int(time.time())
|
365
|
+
|
366
|
+
# create an error struct value with details
|
367
|
+
error_struct = struct_pb2.Struct()
|
368
|
+
json_format.ParseDict(
|
369
|
+
{"error": str(algo_error), "stack_trace": traceback.format_exc()},
|
370
|
+
error_struct,
|
371
|
+
)
|
372
|
+
|
373
|
+
# create the result with unhandled failed status and error info
|
374
|
+
error_result = pb.Result(
|
375
|
+
status=pb.ResultStatus.RESULT_STATUS_UNHANDLED_FAILED,
|
376
|
+
struct_value=error_struct,
|
377
|
+
timestamp=current_time,
|
378
|
+
)
|
379
|
+
|
380
|
+
# create the algorithm result
|
381
|
+
algo_result = pb.AlgorithmResult(algorithm=algorithm, result=error_result)
|
382
|
+
|
383
|
+
# create the execution result
|
384
|
+
return pb.ExecutionResult(exec_id=exec_id, algorithm_result=algo_result)
|
385
|
+
|
386
|
+
def ExecuteDagPart(
|
387
|
+
self, ExecutionRequest: pb.ExecutionRequest, context: grpc.ServicerContext
|
388
|
+
) -> Generator[pb.ExecutionResult, None, None]:
|
389
|
+
"""
|
390
|
+
Executes part of a DAG (Directed Acyclic Graph) of algorithms.
|
391
|
+
|
392
|
+
Args:
|
393
|
+
ExecutionRequest (pb.ExecutionRequest): The DAG execution request.
|
394
|
+
context (grpc.ServicerContext): gRPC context for the request.
|
395
|
+
|
396
|
+
Yields:
|
397
|
+
pb.ExecutionResult: Execution results streamed as they complete.
|
398
|
+
|
399
|
+
Raises:
|
400
|
+
grpc.RpcError: If execution fails and an internal error must be raised.
|
401
|
+
"""
|
402
|
+
|
403
|
+
LOGGER.info(
|
404
|
+
(
|
405
|
+
f"Received DAG execution request with {len(ExecutionRequest.algorithms)} "
|
406
|
+
f"algorithms and ExecId: {ExecutionRequest.exec_id}"
|
407
|
+
)
|
408
|
+
)
|
409
|
+
|
410
|
+
try:
|
411
|
+
# create an event loop if it doesn't exist
|
412
|
+
try:
|
413
|
+
loop = asyncio.get_event_loop()
|
414
|
+
except RuntimeError:
|
415
|
+
loop = asyncio.new_event_loop()
|
416
|
+
asyncio.set_event_loop(loop)
|
417
|
+
|
418
|
+
# create tasks for all algorithms
|
419
|
+
tasks = [
|
420
|
+
self.execute_algorithm(
|
421
|
+
ExecutionRequest.exec_id,
|
422
|
+
algorithm,
|
423
|
+
ExecutionRequest.algorithm_results,
|
424
|
+
)
|
425
|
+
for algorithm in ExecutionRequest.algorithms
|
426
|
+
]
|
427
|
+
|
428
|
+
# execute all tasks concurrently and yield results as they complete
|
429
|
+
async def process_results() -> AsyncGenerator[pb.ExecutionResult, None]:
|
430
|
+
for completed_task in asyncio.as_completed(tasks):
|
431
|
+
result = await completed_task
|
432
|
+
yield result
|
433
|
+
|
434
|
+
# run async generator in the event loop
|
435
|
+
async_gen = process_results()
|
436
|
+
while True:
|
437
|
+
try:
|
438
|
+
result = loop.run_until_complete(async_gen.__anext__())
|
439
|
+
yield result
|
440
|
+
except StopAsyncIteration:
|
441
|
+
break
|
442
|
+
|
443
|
+
# capture exceptions
|
444
|
+
except Exception as e:
|
445
|
+
LOGGER.error(f"DAG execution failed: {str(e)}", exc_info=True)
|
446
|
+
context.set_code(grpc.StatusCode.INTERNAL)
|
447
|
+
context.set_details(f"DAG execution failed: {str(e)}")
|
448
|
+
raise
|
449
|
+
|
450
|
+
except Exception as e:
|
451
|
+
LOGGER.error(f"DAG execution failed: {str(e)}", exc_info=True)
|
452
|
+
context.set_code(grpc.StatusCode.INTERNAL)
|
453
|
+
context.set_details(f"DAG execution failed: {str(e)}")
|
454
|
+
raise
|
455
|
+
|
456
|
+
def HealthCheck(
|
457
|
+
self, HealthCheckRequest: pb.HealthCheckRequest, context: grpc.ServicerContext
|
458
|
+
) -> pb.HealthCheckResponse:
|
459
|
+
"""
|
460
|
+
Returns health status for the processor.
|
461
|
+
|
462
|
+
Args:
|
463
|
+
HealthCheckRequest (pb.HealthCheckRequest): Incoming request.
|
464
|
+
context (grpc.ServicerContext): gRPC context.
|
465
|
+
|
466
|
+
Returns:
|
467
|
+
pb.HealthCheckResponse: Health status and optional metrics.
|
468
|
+
"""
|
469
|
+
|
470
|
+
LOGGER.debug("Received health check request")
|
471
|
+
return pb.HealthCheckResponse(
|
472
|
+
status=pb.HealthCheckResponse.STATUS_SERVING,
|
473
|
+
message="Processor is healthy",
|
474
|
+
metrics=pb.ProcessorMetrics(
|
475
|
+
active_tasks=0, memory_bytes=0, cpu_percent=0.0, uptime_seconds=0
|
476
|
+
),
|
477
|
+
)
|
478
|
+
|
479
|
+
def Register(self) -> None:
|
480
|
+
"""
|
481
|
+
Registers all supported algorithms with the Orca Core service.
|
482
|
+
|
483
|
+
Raises:
|
484
|
+
grpc.RpcError: If registration fails.
|
485
|
+
"""
|
486
|
+
LOGGER.info(f"Preparing to register processor '{self._name}' with Orca Core")
|
487
|
+
LOGGER.debug(
|
488
|
+
f"Building registration request with {len(self._algorithmsSingleton._algorithms)} algorithms"
|
489
|
+
)
|
490
|
+
registration_request = pb.ProcessorRegistration()
|
491
|
+
registration_request.name = self._name
|
492
|
+
registration_request.runtime = self._runtime
|
493
|
+
registration_request.connection_str = self._orcaProcessorConnStr
|
494
|
+
|
495
|
+
for _, algorithm in self._algorithmsSingleton._algorithms.items():
|
496
|
+
LOGGER.debug(
|
497
|
+
f"Adding algorithm to registration: {algorithm.name}_{algorithm.version}"
|
498
|
+
)
|
499
|
+
algo_msg = registration_request.supported_algorithms.add()
|
500
|
+
algo_msg.name = algorithm.name
|
501
|
+
algo_msg.version = algorithm.version
|
502
|
+
|
503
|
+
# Add window type
|
504
|
+
algo_msg.window_type.name = algorithm.window_name
|
505
|
+
algo_msg.window_type.version = algorithm.window_version
|
506
|
+
|
507
|
+
# Add dependencies if they exist
|
508
|
+
if algorithm.full_name in self._algorithmsSingleton._dependencies:
|
509
|
+
for dep in self._algorithmsSingleton._dependencies[algorithm.full_name]:
|
510
|
+
dep_msg = algo_msg.dependencies.add()
|
511
|
+
dep_msg.name = dep.name
|
512
|
+
dep_msg.version = dep.version
|
513
|
+
dep_msg.processor_name = dep.processor
|
514
|
+
dep_msg.processor_runtime = dep.runtime
|
515
|
+
|
516
|
+
with grpc.insecure_channel(envs.ORCASERVER) as channel:
|
517
|
+
stub = service_pb2_grpc.OrcaCoreStub(channel)
|
518
|
+
response = stub.RegisterProcessor(registration_request)
|
519
|
+
LOGGER.info(f"Algorithm registration response recieved: {response}")
|
520
|
+
|
521
|
+
def Start(self) -> None:
|
522
|
+
"""
|
523
|
+
Starts the gRPC server and begins serving algorithm requests.
|
524
|
+
|
525
|
+
This includes signal handling for graceful shutdown.
|
526
|
+
|
527
|
+
Raises:
|
528
|
+
Exception: On server startup failure.
|
529
|
+
"""
|
530
|
+
try:
|
531
|
+
LOGGER.info(
|
532
|
+
f"Starting Orca Processor '{self._name}' with Python {self._runtime}"
|
533
|
+
)
|
534
|
+
LOGGER.info(f"Initialising gRPC server with {self._max_workers} workers")
|
535
|
+
|
536
|
+
server = grpc.server(
|
537
|
+
futures.ThreadPoolExecutor(max_workers=self._max_workers),
|
538
|
+
options=[
|
539
|
+
("grpc.max_send_message_length", 50 * 1024 * 1024), # 50MB
|
540
|
+
("grpc.max_receive_message_length", 50 * 1024 * 1024), # 50MB
|
541
|
+
],
|
542
|
+
)
|
543
|
+
|
544
|
+
# add our servicer to the server
|
545
|
+
service_pb2_grpc.add_OrcaProcessorServicer_to_server(self, server)
|
546
|
+
|
547
|
+
# add the server port
|
548
|
+
port = server.add_insecure_port(self._processorConnStr)
|
549
|
+
if port == 0:
|
550
|
+
raise RuntimeError(f"Failed to bind to port {envs.PORT}")
|
551
|
+
|
552
|
+
LOGGER.info(f"Server listening on address {self._processorConnStr}")
|
553
|
+
|
554
|
+
# start the server
|
555
|
+
server.start()
|
556
|
+
LOGGER.info("Server started successfully")
|
557
|
+
|
558
|
+
# setup graceful shutdown
|
559
|
+
import signal
|
560
|
+
|
561
|
+
def handle_shutdown(signum: int, frame: Any) -> None:
|
562
|
+
LOGGER.info("Received shutdown signal, stopping server...")
|
563
|
+
server.stop(grace=5) # 5 seconds grace period
|
564
|
+
|
565
|
+
signal.signal(signal.SIGTERM, handle_shutdown)
|
566
|
+
signal.signal(signal.SIGINT, handle_shutdown)
|
567
|
+
|
568
|
+
# wait for termination
|
569
|
+
LOGGER.info("Server is ready for requests")
|
570
|
+
server.wait_for_termination()
|
571
|
+
|
572
|
+
except Exception as e:
|
573
|
+
LOGGER.error(f"Failed to start server: {str(e)}", exc_info=True)
|
574
|
+
raise
|
575
|
+
finally:
|
576
|
+
LOGGER.info("Server shutdown complete")
|
577
|
+
|
578
|
+
def algorithm(
|
579
|
+
self,
|
580
|
+
name: str,
|
581
|
+
version: str,
|
582
|
+
window_name: str,
|
583
|
+
window_version: str,
|
584
|
+
depends_on: List[Callable[..., Any]] = [],
|
585
|
+
) -> Callable[[T], T]:
|
586
|
+
"""
|
587
|
+
Decorator for registering a function as an Orca algorithm.
|
588
|
+
|
589
|
+
Args:
|
590
|
+
name (str): Algorithm name (PascalCase).
|
591
|
+
version (str): Semantic version (e.g., "1.0.0").
|
592
|
+
window_name (str): Triggering window name (PascalCase).
|
593
|
+
window_version (str): Semantic version of the window.
|
594
|
+
depends_on (List[Callable]): List of dependent algorithm functions.
|
595
|
+
|
596
|
+
Returns:
|
597
|
+
Callable[[T], T]: The decorated function.
|
598
|
+
|
599
|
+
Raises:
|
600
|
+
InvalidAlgorithmArgument: If naming or version format is incorrect.
|
601
|
+
InvalidDependency: If any dependency is unregistered.
|
602
|
+
"""
|
603
|
+
if not re.match(ALGORITHM_NAME, name):
|
604
|
+
raise InvalidAlgorithmArgument(
|
605
|
+
f"Algorithm name '{name}' must be in PascalCase"
|
606
|
+
)
|
607
|
+
|
608
|
+
if not re.match(SEMVER_PATTERN, version):
|
609
|
+
raise InvalidAlgorithmArgument(
|
610
|
+
f"Version '{version}' must follow basic semantic "
|
611
|
+
"versioning (e.g., '1.0.0') without release portions"
|
612
|
+
)
|
613
|
+
|
614
|
+
if not re.match(WINDOW_NAME, window_name):
|
615
|
+
raise InvalidAlgorithmArgument(
|
616
|
+
f"Window name '{window_name}' must be in PascalCase"
|
617
|
+
)
|
618
|
+
|
619
|
+
if not re.match(SEMVER_PATTERN, window_version):
|
620
|
+
raise InvalidAlgorithmArgument(
|
621
|
+
f"Window version '{window_version}' must follow basic semantic "
|
622
|
+
"versioning (e.g., '1.0.0') without release portions"
|
623
|
+
)
|
624
|
+
|
625
|
+
def inner(algo: T) -> T:
|
626
|
+
def wrapper(
|
627
|
+
dependency_values: Dict[str, Any] | None = None,
|
628
|
+
*args: Any,
|
629
|
+
**kwargs: Any,
|
630
|
+
) -> Any:
|
631
|
+
LOGGER.debug(f"Executing algorithm {name}_{version}")
|
632
|
+
try:
|
633
|
+
# setup ready for the algo
|
634
|
+
# add dependency values to kwargs if provided
|
635
|
+
if dependency_values:
|
636
|
+
kwargs["dependencies"] = dependency_values
|
637
|
+
LOGGER.debug(f"Algorithm {name}_{version} setup complete")
|
638
|
+
# TODO
|
639
|
+
|
640
|
+
# run the algo
|
641
|
+
LOGGER.info(f"Running algorithm {name}_{version}")
|
642
|
+
result = algo(*args, **kwargs)
|
643
|
+
LOGGER.debug(f"Algorithm {name}_{version} execution complete")
|
644
|
+
|
645
|
+
# tear down
|
646
|
+
# TODO
|
647
|
+
return result
|
648
|
+
except Exception as e:
|
649
|
+
LOGGER.error(
|
650
|
+
f"Algorithm {name}_{version} failed: {str(e)}", exc_info=True
|
651
|
+
)
|
652
|
+
raise
|
653
|
+
|
654
|
+
algorithm = Algorithm(
|
655
|
+
name=name,
|
656
|
+
version=version,
|
657
|
+
window_name=window_name,
|
658
|
+
window_version=window_version,
|
659
|
+
exec_fn=wrapper,
|
660
|
+
processor=self._name,
|
661
|
+
runtime=sys.version,
|
662
|
+
)
|
663
|
+
|
664
|
+
self._algorithmsSingleton._add_algorithm(algorithm.full_name, algorithm)
|
665
|
+
self._algorithmsSingleton._add_window_trigger(
|
666
|
+
algorithm.full_window_name, algorithm
|
667
|
+
)
|
668
|
+
|
669
|
+
for dependency in depends_on:
|
670
|
+
if not self._algorithmsSingleton._has_algorithm_fn(dependency):
|
671
|
+
message = (
|
672
|
+
f"Cannot add function `{dependency.__name__}` to dependency stack. All dependencies must "
|
673
|
+
"be decorated with `@algorithm` before they can be used as dependencies."
|
674
|
+
)
|
675
|
+
raise InvalidDependency(message)
|
676
|
+
self._algorithmsSingleton._add_dependency(
|
677
|
+
algorithm.full_name, dependency
|
678
|
+
)
|
679
|
+
|
680
|
+
# TODO: check for circular dependencies. It's not easy to create one in python as the function
|
681
|
+
# needs to be defined before a dependency can be created, and you can only register depencenies
|
682
|
+
# once. But when dependencies are grabbed from a server, circular dependencies will be possible
|
683
|
+
|
684
|
+
return wrapper # type: ignore
|
685
|
+
|
686
|
+
return inner
|
orca_python/py.typed
ADDED
File without changes
|
@@ -0,0 +1,16 @@
|
|
1
|
+
Copyright 2025 Predixus Ltd.
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
4
|
+
associated documentation files (the “Software”), to deal in the Software without restriction,
|
5
|
+
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
6
|
+
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
|
7
|
+
furnished to do so, subject to the following conditions:
|
8
|
+
|
9
|
+
The above copyright notice and this permission notice shall be included in all copies or
|
10
|
+
substantial portions of the Software.
|
11
|
+
|
12
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
|
13
|
+
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
14
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
15
|
+
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
16
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|