operaton-tasks 1.0.0a1__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.
- operaton/tasks/__init__.py +20 -0
- operaton/tasks/api.py +91 -0
- operaton/tasks/config.py +54 -0
- operaton/tasks/healthz.py +71 -0
- operaton/tasks/main.py +143 -0
- operaton/tasks/py.typed +0 -0
- operaton/tasks/types.py +8949 -0
- operaton/tasks/utils.py +78 -0
- operaton/tasks/worker.py +283 -0
- operaton_tasks-1.0.0a1.dist-info/METADATA +24 -0
- operaton_tasks-1.0.0a1.dist-info/RECORD +14 -0
- operaton_tasks-1.0.0a1.dist-info/WHEEL +4 -0
- operaton_tasks-1.0.0a1.dist-info/entry_points.txt +2 -0
- operaton_tasks-1.0.0a1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from operaton.tasks.api import external_task_worker
|
|
2
|
+
from operaton.tasks.api import handlers
|
|
3
|
+
from operaton.tasks.api import operaton_session
|
|
4
|
+
from operaton.tasks.api import router
|
|
5
|
+
from operaton.tasks.api import serve
|
|
6
|
+
from operaton.tasks.api import settings
|
|
7
|
+
from operaton.tasks.api import task
|
|
8
|
+
from operaton.tasks.api import task as register
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"external_task_worker",
|
|
13
|
+
"handlers",
|
|
14
|
+
"operaton_session",
|
|
15
|
+
"register",
|
|
16
|
+
"router",
|
|
17
|
+
"serve",
|
|
18
|
+
"settings",
|
|
19
|
+
"task",
|
|
20
|
+
]
|
operaton/tasks/api.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from operaton.tasks.config import handlers
|
|
2
|
+
from operaton.tasks.config import logger
|
|
3
|
+
from operaton.tasks.config import router
|
|
4
|
+
from operaton.tasks.config import settings
|
|
5
|
+
from operaton.tasks.types import ExternalTaskHandler
|
|
6
|
+
from operaton.tasks.types import ExternalTaskTopic
|
|
7
|
+
from operaton.tasks.utils import operaton_session
|
|
8
|
+
from operaton.tasks.worker import external_task_worker
|
|
9
|
+
from typing import Any
|
|
10
|
+
from typing import Callable
|
|
11
|
+
from typing import Optional
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import typer
|
|
17
|
+
import uvicorn
|
|
18
|
+
|
|
19
|
+
HAS_CLI = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
typer: Any = None # type: ignore
|
|
22
|
+
uvicorn: Any = None # type: ignore
|
|
23
|
+
|
|
24
|
+
HAS_CLI = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def task(
|
|
28
|
+
topic: str,
|
|
29
|
+
localVariables: bool = True,
|
|
30
|
+
) -> Callable[[ExternalTaskHandler], ExternalTaskHandler]:
|
|
31
|
+
"""Register function as a service task."""
|
|
32
|
+
|
|
33
|
+
def decorator(func: ExternalTaskHandler) -> ExternalTaskHandler:
|
|
34
|
+
handlers[topic] = ExternalTaskTopic(handler=func, localVariables=localVariables)
|
|
35
|
+
return func
|
|
36
|
+
|
|
37
|
+
return decorator
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if HAS_CLI:
|
|
41
|
+
cli = typer.Typer()
|
|
42
|
+
|
|
43
|
+
@cli.command(name="serve")
|
|
44
|
+
def cli_serve(
|
|
45
|
+
base_url: str = "http://localhost:8080/engine-rest",
|
|
46
|
+
authorization: Optional[str] = None,
|
|
47
|
+
timeout: int = 20,
|
|
48
|
+
poll_ttl: int = 10,
|
|
49
|
+
lock_ttl: int = 30,
|
|
50
|
+
worker_id: str = "operaton-tasks-client",
|
|
51
|
+
log_level: str = "INFO",
|
|
52
|
+
args: Optional[list[str]] = typer.Argument(
|
|
53
|
+
default=None, help="arguments passed to uvicorn"
|
|
54
|
+
),
|
|
55
|
+
) -> None:
|
|
56
|
+
"""CLI."""
|
|
57
|
+
settings.ENGINE_REST_BASE_URL = base_url
|
|
58
|
+
settings.ENGINE_REST_AUTHORIZATION = authorization
|
|
59
|
+
settings.ENGINE_REST_TIMEOUT_SECONDS = timeout
|
|
60
|
+
settings.ENGINE_REST_POLL_TTL_SECONDS = poll_ttl
|
|
61
|
+
settings.ENGINE_REST_LOCK_TTL_SECONDS = lock_ttl
|
|
62
|
+
settings.LOG_LEVEL = log_level
|
|
63
|
+
settings.TASKS_WORKER_ID = worker_id
|
|
64
|
+
settings.TASKS_MODULE = None
|
|
65
|
+
|
|
66
|
+
sys.argv = [sys.argv[0], "operaton.tasks.main:app"]
|
|
67
|
+
if args and "--no-proxy-headers" not in args:
|
|
68
|
+
sys.argv.append("--proxy-headers")
|
|
69
|
+
if args:
|
|
70
|
+
sys.argv.extend(args)
|
|
71
|
+
uvicorn.main()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def serve() -> None:
|
|
75
|
+
"""Run Operaton External Service Task Worker."""
|
|
76
|
+
if HAS_CLI:
|
|
77
|
+
cli()
|
|
78
|
+
else:
|
|
79
|
+
logger.error("operaton-tasks[cli] required")
|
|
80
|
+
exit(1)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
__all__ = [
|
|
84
|
+
"external_task_worker",
|
|
85
|
+
"handlers",
|
|
86
|
+
"operaton_session",
|
|
87
|
+
"router",
|
|
88
|
+
"serve",
|
|
89
|
+
"settings",
|
|
90
|
+
"task",
|
|
91
|
+
]
|
operaton/tasks/config.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from fastapi import APIRouter
|
|
2
|
+
from operaton.tasks.types import ExternalTaskTopic
|
|
3
|
+
from pydantic_settings import BaseSettings
|
|
4
|
+
from typing import Dict
|
|
5
|
+
from typing import Optional
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# https://pydantic-docs.helpmanual.io/usage/settings/
|
|
10
|
+
class Settings(BaseSettings):
|
|
11
|
+
ENGINE_REST_BASE_URL: str = "http://localhost:8080/engine-rest"
|
|
12
|
+
ENGINE_REST_AUTHORIZATION: Optional[str] = None
|
|
13
|
+
|
|
14
|
+
ENGINE_REST_TIMEOUT_SECONDS: int = 20
|
|
15
|
+
ENGINE_REST_POLL_TTL_SECONDS: int = 10
|
|
16
|
+
ENGINE_REST_LOCK_TTL_SECONDS: int = 30
|
|
17
|
+
|
|
18
|
+
TASKS_HEARTBEAT_TOPIC: str = "operaton.tasks.heartbeat"
|
|
19
|
+
TASKS_WORKER_ID: str = "operaton-tasks-client"
|
|
20
|
+
TASKS_MODULE: Optional[str] = None
|
|
21
|
+
|
|
22
|
+
LOG_LEVEL: str = "DEBUG"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
settings = Settings()
|
|
26
|
+
|
|
27
|
+
formatter = logging.Formatter(
|
|
28
|
+
"%(asctime)s | %(levelname)s | %(name)s:%(lineno)d | %(message)s",
|
|
29
|
+
"%d-%m-%Y %H:%M:%S",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
stream_handler = logging.StreamHandler()
|
|
33
|
+
stream_handler.setFormatter(formatter)
|
|
34
|
+
stream_handler.setLevel(settings.LOG_LEVEL)
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger("operaton.tasks")
|
|
37
|
+
logger.addHandler(stream_handler)
|
|
38
|
+
logger.setLevel(settings.LOG_LEVEL)
|
|
39
|
+
logger.propagate = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def update() -> None:
|
|
43
|
+
stream_handler.setLevel(settings.LOG_LEVEL)
|
|
44
|
+
logger.setLevel(settings.LOG_LEVEL)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Built-in FastAPI router
|
|
48
|
+
router = APIRouter()
|
|
49
|
+
|
|
50
|
+
# All topics registered using the task decorator
|
|
51
|
+
handlers: Dict[str, ExternalTaskTopic] = {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__all__ = ["logger", "settings", "router", "handlers", "update"]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from operaton.tasks import router
|
|
2
|
+
from operaton.tasks import task
|
|
3
|
+
from operaton.tasks.api import operaton_session
|
|
4
|
+
from operaton.tasks.config import settings
|
|
5
|
+
from operaton.tasks.types import CompleteExternalTaskDto
|
|
6
|
+
from operaton.tasks.types import ExternalTaskComplete
|
|
7
|
+
from operaton.tasks.types import LockedExternalTaskDto
|
|
8
|
+
from operaton.tasks.types import VariableValueDto
|
|
9
|
+
from operaton.tasks.utils import verify_response_status
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
from pydantic import Field
|
|
12
|
+
from pydantic.dataclasses import dataclass
|
|
13
|
+
from starlette.exceptions import HTTPException
|
|
14
|
+
from typing import Optional
|
|
15
|
+
import datetime
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Heartbeat(BaseModel):
|
|
19
|
+
"""Health check response."""
|
|
20
|
+
|
|
21
|
+
timestamp: Optional[str] = Field(
|
|
22
|
+
None,
|
|
23
|
+
description="UTC timestamp of the last recorded heartbeat.",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class State:
|
|
29
|
+
"""Service health check state."""
|
|
30
|
+
|
|
31
|
+
timestamp: Optional[str] = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
state = State()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@task(topic=settings.TASKS_HEARTBEAT_TOPIC)
|
|
38
|
+
async def handler(task: LockedExternalTaskDto) -> ExternalTaskComplete:
|
|
39
|
+
"""Update health check timestamp."""
|
|
40
|
+
state.timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
41
|
+
return ExternalTaskComplete(
|
|
42
|
+
task=task,
|
|
43
|
+
response=CompleteExternalTaskDto(
|
|
44
|
+
workerId=task.workerId,
|
|
45
|
+
variables={
|
|
46
|
+
"timestamp": VariableValueDto(value=state.timestamp, type="string"),
|
|
47
|
+
},
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.get(
|
|
53
|
+
"/healthz", response_model=Heartbeat, summary="Service health status", tags=["Meta"]
|
|
54
|
+
)
|
|
55
|
+
async def healthz() -> Heartbeat:
|
|
56
|
+
"""Service health status."""
|
|
57
|
+
|
|
58
|
+
# Without heartbeat external task triggered
|
|
59
|
+
if state.timestamp is None:
|
|
60
|
+
async with operaton_session() as session:
|
|
61
|
+
get = await session.get(settings.ENGINE_REST_BASE_URL + "/engine")
|
|
62
|
+
await verify_response_status(get, (200,))
|
|
63
|
+
timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
64
|
+
return Heartbeat(timestamp=timestamp)
|
|
65
|
+
|
|
66
|
+
# With heartbeat external task triggered at least once
|
|
67
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
68
|
+
if (now - datetime.timedelta(seconds=45)).isoformat() < state.timestamp:
|
|
69
|
+
return Heartbeat(timestamp=state.timestamp)
|
|
70
|
+
age = (now - datetime.datetime.fromisoformat(state.timestamp)).total_seconds()
|
|
71
|
+
raise HTTPException(status_code=500, detail=f"No heartbeat for {age} seconds")
|
operaton/tasks/main.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Operaton External Service Task Client"""
|
|
2
|
+
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from fastapi.applications import FastAPI
|
|
5
|
+
from operaton.tasks import healthz
|
|
6
|
+
from operaton.tasks.config import handlers
|
|
7
|
+
from operaton.tasks.config import router
|
|
8
|
+
from operaton.tasks.config import settings
|
|
9
|
+
from operaton.tasks.config import update
|
|
10
|
+
from operaton.tasks.worker import external_task_worker
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from starlette.requests import Request
|
|
13
|
+
from starlette.responses import Response
|
|
14
|
+
from typing import Any
|
|
15
|
+
from typing import AsyncGenerator
|
|
16
|
+
from typing import Awaitable
|
|
17
|
+
from typing import Callable
|
|
18
|
+
from typing import Optional
|
|
19
|
+
import asyncio
|
|
20
|
+
import hashlib
|
|
21
|
+
import importlib.util
|
|
22
|
+
import logging
|
|
23
|
+
import sys
|
|
24
|
+
import tempfile
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
import typer
|
|
29
|
+
import uvicorn
|
|
30
|
+
|
|
31
|
+
HAS_CLI = True
|
|
32
|
+
except ImportError:
|
|
33
|
+
typer: Any = None # type: ignore
|
|
34
|
+
uvicorn: Any = None # type: ignore
|
|
35
|
+
|
|
36
|
+
HAS_CLI = False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
assert healthz # import registers healthz task handler
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@asynccontextmanager
|
|
46
|
+
async def lifespan(app: FastAPI) -> AsyncGenerator[Any, Any]:
|
|
47
|
+
"""Start external task worker on FastAPI startup."""
|
|
48
|
+
update()
|
|
49
|
+
if settings.TASKS_MODULE:
|
|
50
|
+
module_name = hashlib.sha256(settings.TASKS_MODULE.encode("utf-8")).hexdigest()
|
|
51
|
+
spec = importlib.util.spec_from_file_location(
|
|
52
|
+
module_name, settings.TASKS_MODULE
|
|
53
|
+
)
|
|
54
|
+
if spec:
|
|
55
|
+
module = importlib.util.module_from_spec(spec)
|
|
56
|
+
if spec.loader:
|
|
57
|
+
spec.loader.exec_module(module)
|
|
58
|
+
asyncio.ensure_future(external_task_worker(handlers))
|
|
59
|
+
logger.info("Event loop: %s", asyncio.get_event_loop())
|
|
60
|
+
yield
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
app = FastAPI(
|
|
64
|
+
title="Operaton Tasks Client",
|
|
65
|
+
description="Operaton External Service Task Client",
|
|
66
|
+
version="0.1.0",
|
|
67
|
+
lifespan=lifespan,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
app.include_router(router)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.middleware("http")
|
|
75
|
+
async def cache_headers(
|
|
76
|
+
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
|
77
|
+
) -> Response:
|
|
78
|
+
"""Set cache headers."""
|
|
79
|
+
response = await call_next(request)
|
|
80
|
+
response.headers["Cache-Control"] = "no-store, max-age=0"
|
|
81
|
+
return response
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if HAS_CLI:
|
|
85
|
+
cli = typer.Typer()
|
|
86
|
+
|
|
87
|
+
@cli.command()
|
|
88
|
+
def serve(
|
|
89
|
+
module: Path,
|
|
90
|
+
base_url: str = "http://localhost:8080/engine-rest",
|
|
91
|
+
authorization: Optional[str] = None,
|
|
92
|
+
timeout: int = 20,
|
|
93
|
+
poll_ttl: int = 10,
|
|
94
|
+
lock_ttl: int = 30,
|
|
95
|
+
worker_id: str = "operaton-tasks-client",
|
|
96
|
+
log_level: str = "INFO",
|
|
97
|
+
args: Optional[list[str]] = typer.Argument(
|
|
98
|
+
default=None, help="arguments passed to uvicorn"
|
|
99
|
+
),
|
|
100
|
+
) -> None:
|
|
101
|
+
"""CLI."""
|
|
102
|
+
settings.ENGINE_REST_BASE_URL = base_url
|
|
103
|
+
settings.ENGINE_REST_AUTHORIZATION = authorization
|
|
104
|
+
settings.ENGINE_REST_TIMEOUT_SECONDS = timeout
|
|
105
|
+
settings.ENGINE_REST_POLL_TTL_SECONDS = poll_ttl
|
|
106
|
+
settings.ENGINE_REST_LOCK_TTL_SECONDS = lock_ttl
|
|
107
|
+
settings.TASKS_WORKER_ID = worker_id
|
|
108
|
+
settings.LOG_LEVEL = log_level
|
|
109
|
+
settings.TASKS_MODULE = f"{module.absolute()}"
|
|
110
|
+
|
|
111
|
+
sys.argv = [sys.argv[0], "operaton.tasks.main:app"]
|
|
112
|
+
if args and "--no-proxy-headers" not in args:
|
|
113
|
+
sys.argv.append("--proxy-headers")
|
|
114
|
+
if args:
|
|
115
|
+
sys.argv.extend(args)
|
|
116
|
+
if args and "--reload" in args:
|
|
117
|
+
with tempfile.NamedTemporaryFile(mode="w+", delete=True) as temp_file:
|
|
118
|
+
temp_file.writelines(
|
|
119
|
+
[
|
|
120
|
+
f"ENGINE_REST_BASE_URL={base_url}\n",
|
|
121
|
+
f"ENGINE_REST_AUTHORIZATION={authorization}\n",
|
|
122
|
+
f"ENGINE_REST_TIMEOUT_SECONDS={timeout}\n",
|
|
123
|
+
f"ENGINE_REST_POLL_TTL_SECONDS={poll_ttl}\n",
|
|
124
|
+
f"ENGINE_REST_LOCK_TTL_SECONDS={lock_ttl}\n",
|
|
125
|
+
f"TASKS_WORKER_ID={worker_id}\n",
|
|
126
|
+
f"TASKS_MODULE={module}\n",
|
|
127
|
+
f"LOG_LEVEL={log_level}",
|
|
128
|
+
]
|
|
129
|
+
)
|
|
130
|
+
temp_file.flush()
|
|
131
|
+
sys.argv.extend(["--env-file", temp_file.name])
|
|
132
|
+
uvicorn.main()
|
|
133
|
+
else:
|
|
134
|
+
uvicorn.main()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def main() -> None:
|
|
138
|
+
"""Main."""
|
|
139
|
+
if HAS_CLI:
|
|
140
|
+
cli()
|
|
141
|
+
else:
|
|
142
|
+
logger.error("operaton-tasks[cli] required")
|
|
143
|
+
exit(1)
|
operaton/tasks/py.typed
ADDED
|
File without changes
|