fronta 0.1.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.
- fronta/__init__.py +58 -0
- fronta/cli.py +134 -0
- fronta/codec.py +111 -0
- fronta/config.py +62 -0
- fronta/definitions.py +229 -0
- fronta/errors.py +53 -0
- fronta/executors.py +174 -0
- fronta/model.py +289 -0
- fronta/py.typed +0 -0
- fronta/runtime.py +105 -0
- fronta/sandbox.py +564 -0
- fronta/schema.sql +53 -0
- fronta/server/__init__.py +5 -0
- fronta/server/api.py +104 -0
- fronta/server/app.py +189 -0
- fronta/server/mcp.py +107 -0
- fronta/server/service.py +170 -0
- fronta/server/static/alpine.LICENSE.md +21 -0
- fronta/server/static/alpine.min.js +5 -0
- fronta/server/templates/index.html +236 -0
- fronta/store.py +584 -0
- fronta/worker.py +756 -0
- fronta-0.1.0.dist-info/METADATA +226 -0
- fronta-0.1.0.dist-info/RECORD +28 -0
- fronta-0.1.0.dist-info/WHEEL +4 -0
- fronta-0.1.0.dist-info/entry_points.txt +3 -0
- fronta-0.1.0.dist-info/licenses/LICENSE +21 -0
- fronta-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +30 -0
fronta/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Fronta: distributed task processing on PostgreSQL with sandboxed process execution."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from fronta.config import Settings
|
|
6
|
+
from fronta.definitions import Context, ProcessTaskDefinition, TaskDefinition, process_task, task
|
|
7
|
+
from fronta.errors import (
|
|
8
|
+
ConfigurationError,
|
|
9
|
+
FrontaError,
|
|
10
|
+
InputValidationError,
|
|
11
|
+
InvalidInput,
|
|
12
|
+
NonRetryableError,
|
|
13
|
+
NotCancellable,
|
|
14
|
+
PayloadTooLarge,
|
|
15
|
+
ProgressTooLarge,
|
|
16
|
+
ResultSerializationError,
|
|
17
|
+
SandboxError,
|
|
18
|
+
TaskNotFound,
|
|
19
|
+
UnknownTaskType,
|
|
20
|
+
)
|
|
21
|
+
from fronta.model import Backoff, Policy, Sandbox, State, TaskRow, TaskSummary, TaskTypeRow
|
|
22
|
+
from fronta.runtime import close_pool, configure, open_pool
|
|
23
|
+
from fronta.worker import Worker
|
|
24
|
+
|
|
25
|
+
__version__: str = version("fronta")
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"Backoff",
|
|
29
|
+
"ConfigurationError",
|
|
30
|
+
"Context",
|
|
31
|
+
"FrontaError",
|
|
32
|
+
"InputValidationError",
|
|
33
|
+
"InvalidInput",
|
|
34
|
+
"NonRetryableError",
|
|
35
|
+
"NotCancellable",
|
|
36
|
+
"PayloadTooLarge",
|
|
37
|
+
"Policy",
|
|
38
|
+
"ProcessTaskDefinition",
|
|
39
|
+
"ProgressTooLarge",
|
|
40
|
+
"ResultSerializationError",
|
|
41
|
+
"Sandbox",
|
|
42
|
+
"SandboxError",
|
|
43
|
+
"Settings",
|
|
44
|
+
"State",
|
|
45
|
+
"TaskDefinition",
|
|
46
|
+
"TaskNotFound",
|
|
47
|
+
"TaskRow",
|
|
48
|
+
"TaskSummary",
|
|
49
|
+
"TaskTypeRow",
|
|
50
|
+
"UnknownTaskType",
|
|
51
|
+
"Worker",
|
|
52
|
+
"__version__",
|
|
53
|
+
"close_pool",
|
|
54
|
+
"configure",
|
|
55
|
+
"open_pool",
|
|
56
|
+
"process_task",
|
|
57
|
+
"task",
|
|
58
|
+
]
|
fronta/cli.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""`fronta` command line: `db init`, `worker`, `server`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import importlib
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
import psycopg
|
|
14
|
+
from pydantic import ValidationError
|
|
15
|
+
|
|
16
|
+
from fronta import store
|
|
17
|
+
from fronta.config import Settings
|
|
18
|
+
from fronta.errors import ConfigurationError
|
|
19
|
+
from fronta.worker import Worker
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
SERVER_EXTRA_MODULES = frozenset({"fastapi", "starlette", "uvicorn", "jinja2", "mcp", "jsonschema"})
|
|
24
|
+
"""Top-level modules provided by the `server` extra."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def configure_logging() -> None:
|
|
28
|
+
"""Our loggers follow `LOG_LEVEL_OURS` (default INFO), libraries `LOG_LEVEL_LIBS` (WARNING)."""
|
|
29
|
+
logging.basicConfig(
|
|
30
|
+
level=os.environ.get("LOG_LEVEL_LIBS", "WARNING").upper(),
|
|
31
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
32
|
+
stream=sys.stderr,
|
|
33
|
+
)
|
|
34
|
+
logging.getLogger("fronta").setLevel(os.environ.get("LOG_LEVEL_OURS", "INFO").upper())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_object(target: str, expected: type) -> object:
|
|
38
|
+
"""Import `module:attr` (the current directory is importable, as with uvicorn)."""
|
|
39
|
+
module_name, sep, attr = target.partition(":")
|
|
40
|
+
if not sep or not module_name or not attr:
|
|
41
|
+
msg = f"expected module:attr, got {target!r}"
|
|
42
|
+
raise click.BadParameter(msg)
|
|
43
|
+
cwd = str(Path.cwd())
|
|
44
|
+
if cwd not in sys.path:
|
|
45
|
+
sys.path.insert(0, cwd)
|
|
46
|
+
try:
|
|
47
|
+
module = importlib.import_module(module_name)
|
|
48
|
+
except ImportError as exc:
|
|
49
|
+
msg = f"cannot import {module_name!r}: {exc}"
|
|
50
|
+
raise click.BadParameter(msg) from exc
|
|
51
|
+
try:
|
|
52
|
+
obj = getattr(module, attr)
|
|
53
|
+
except AttributeError as exc:
|
|
54
|
+
msg = f"{module_name!r} has no attribute {attr!r}"
|
|
55
|
+
raise click.BadParameter(msg) from exc
|
|
56
|
+
if not isinstance(obj, expected):
|
|
57
|
+
msg = f"{target} is {type(obj).__name__}, expected {expected.__name__}"
|
|
58
|
+
raise click.BadParameter(msg)
|
|
59
|
+
return obj
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@click.group()
|
|
63
|
+
@click.version_option(package_name="fronta")
|
|
64
|
+
def main() -> None:
|
|
65
|
+
"""Fronta: distributed task processing on PostgreSQL."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@main.group()
|
|
69
|
+
def db() -> None:
|
|
70
|
+
"""Database administration."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@db.command("init")
|
|
74
|
+
@click.option("--dsn", envvar="FRONTA_DSN", required=True, help="PostgreSQL DSN (or FRONTA_DSN).")
|
|
75
|
+
def db_init(dsn: str) -> None:
|
|
76
|
+
"""Create the `fronta` schema (idempotent)."""
|
|
77
|
+
|
|
78
|
+
async def run() -> None:
|
|
79
|
+
async with await psycopg.AsyncConnection.connect(dsn, autocommit=True) as conn:
|
|
80
|
+
await store.init_schema(conn)
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
asyncio.run(run())
|
|
84
|
+
except psycopg.Error as exc:
|
|
85
|
+
raise click.ClickException(f"database error: {exc}") from exc
|
|
86
|
+
click.echo("fronta schema is ready")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@main.command()
|
|
90
|
+
@click.argument("target")
|
|
91
|
+
def worker(target: str) -> None:
|
|
92
|
+
"""Run the worker TARGET (`module:attr`, a `fronta.Worker`) until SIGTERM/SIGINT."""
|
|
93
|
+
configure_logging()
|
|
94
|
+
instance = load_object(target, Worker)
|
|
95
|
+
assert isinstance(instance, Worker) # noqa: S101 # narrowed by load_object
|
|
96
|
+
try:
|
|
97
|
+
settings = instance.settings # FRONTA_* is read here, not when the module was imported
|
|
98
|
+
except ValidationError as exc:
|
|
99
|
+
raise click.ClickException(f"invalid settings: {exc}") from exc
|
|
100
|
+
log.info("worker target %s, concurrency %d", target, settings.concurrency)
|
|
101
|
+
try:
|
|
102
|
+
sys.exit(asyncio.run(instance.run()))
|
|
103
|
+
except psycopg.OperationalError as exc: # could not reach the database at start
|
|
104
|
+
raise click.ClickException(f"database unavailable: {exc}") from exc
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@main.command()
|
|
108
|
+
@click.option(
|
|
109
|
+
"--host", default=None, help="Bind address (default FRONTA_SERVER_HOST or 127.0.0.1)."
|
|
110
|
+
)
|
|
111
|
+
@click.option("--port", default=None, type=int, help="Port (default FRONTA_SERVER_PORT or 8000).")
|
|
112
|
+
def server(host: str | None, port: int | None) -> None:
|
|
113
|
+
"""Serve the REST API, the MCP endpoint and the dashboard (needs `fronta[server]`)."""
|
|
114
|
+
configure_logging()
|
|
115
|
+
try:
|
|
116
|
+
from fronta.server import serve # noqa: PLC0415 # optional dependency, loaded on demand
|
|
117
|
+
except ImportError as exc:
|
|
118
|
+
if (exc.name or "").split(".")[0] not in SERVER_EXTRA_MODULES:
|
|
119
|
+
raise # a genuine broken import, not a missing extra
|
|
120
|
+
msg = f"the server needs the optional dependencies: pip install 'fronta[server]' ({exc})"
|
|
121
|
+
raise click.ClickException(msg) from exc
|
|
122
|
+
overrides = {
|
|
123
|
+
k: v for k, v in {"server_host": host, "server_port": port}.items() if v is not None
|
|
124
|
+
}
|
|
125
|
+
try:
|
|
126
|
+
settings = Settings() # type: ignore[call-arg] # pydantic-settings reads FRONTA_DSN
|
|
127
|
+
if overrides:
|
|
128
|
+
settings = Settings(**{**settings.model_dump(), **overrides}) # re-validated
|
|
129
|
+
except ValidationError as exc:
|
|
130
|
+
raise click.ClickException(f"invalid settings: {exc}") from exc
|
|
131
|
+
try:
|
|
132
|
+
serve(settings)
|
|
133
|
+
except ConfigurationError as exc:
|
|
134
|
+
raise click.ClickException(str(exc)) from exc
|
fronta/codec.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""JSON encoding with byte caps and error-metadata shaping.
|
|
2
|
+
|
|
3
|
+
Caps count UTF-8 bytes of the compact JSON encoding (`separators=(",", ":")`, non-ASCII kept as is).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import traceback
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from fronta.model import JSON
|
|
14
|
+
|
|
15
|
+
_JSON_KW: dict[str, Any] = {"separators": (",", ":"), "ensure_ascii": False, "allow_nan": False}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Unstorable(ValueError):
|
|
19
|
+
"""The value cannot live in a `jsonb` column (NUL characters, NaN, infinity)."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OverCap(ValueError):
|
|
23
|
+
"""The encoding exceeds its byte cap."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def encode(value: JSON) -> str:
|
|
27
|
+
"""Compact JSON.
|
|
28
|
+
|
|
29
|
+
Raises `Unstorable` for NaN/inf and for NUL characters (which `jsonb` rejects), and
|
|
30
|
+
`TypeError` for anything that is not JSON: unknown objects and non-string mapping keys
|
|
31
|
+
(`json.dumps` would silently turn `{1: ...}` into `{"1": ...}`).
|
|
32
|
+
"""
|
|
33
|
+
_check(value)
|
|
34
|
+
try:
|
|
35
|
+
return json.dumps(value, **_JSON_KW)
|
|
36
|
+
except ValueError as exc:
|
|
37
|
+
raise Unstorable(str(exc)) from exc
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _check(value: object) -> None:
|
|
41
|
+
if isinstance(value, str):
|
|
42
|
+
if "\x00" in value:
|
|
43
|
+
msg = "NUL characters cannot be stored in jsonb"
|
|
44
|
+
raise Unstorable(msg)
|
|
45
|
+
elif isinstance(value, dict):
|
|
46
|
+
for key, item in value.items():
|
|
47
|
+
if not isinstance(key, str):
|
|
48
|
+
msg = f"mapping keys must be strings, got {type(key).__name__}"
|
|
49
|
+
raise TypeError(msg)
|
|
50
|
+
_check(key)
|
|
51
|
+
_check(item)
|
|
52
|
+
elif isinstance(value, list | tuple):
|
|
53
|
+
for item in value:
|
|
54
|
+
_check(item)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def sanitize(text: str) -> str:
|
|
58
|
+
"""Make text storable: NUL is the one code point `jsonb` rejects."""
|
|
59
|
+
return text.replace("\x00", "\ufffd")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def utf8_len(text: str) -> int:
|
|
63
|
+
return len(text.encode("utf-8"))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def encode_capped(value: JSON, cap: int, what: str) -> str:
|
|
67
|
+
"""Encode and enforce the cap. Raises `OverCap` with a message naming `what`."""
|
|
68
|
+
text = encode(value)
|
|
69
|
+
size = utf8_len(text)
|
|
70
|
+
if size > cap:
|
|
71
|
+
msg = f"{what} is {size} bytes, cap is {cap} bytes"
|
|
72
|
+
raise OverCap(msg)
|
|
73
|
+
return text
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def error_metadata(exc: BaseException, cap: int) -> dict[str, Any]:
|
|
77
|
+
"""Structured error for a failed attempt: type, message, traceback — truncated to `cap`."""
|
|
78
|
+
tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
|
79
|
+
return truncate(
|
|
80
|
+
{"type": type(exc).__qualname__, "message": sanitize(str(exc)), "traceback": sanitize(tb)},
|
|
81
|
+
cap,
|
|
82
|
+
keep_tail=("traceback",),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def truncate(error: dict[str, Any], cap: int, keep_tail: tuple[str, ...] = ()) -> dict[str, Any]:
|
|
87
|
+
"""Shrink the longest string fields until the encoding fits `cap`; marks `truncated: true`.
|
|
88
|
+
|
|
89
|
+
Fields in `keep_tail` keep their end (the useful part of a traceback or a log stream); other
|
|
90
|
+
fields keep their start. Slicing happens on code points, so the output stays valid UTF-8.
|
|
91
|
+
Always terminates: a field that cannot shrink further is emptied, and when nothing is left
|
|
92
|
+
to cut only the type survives.
|
|
93
|
+
"""
|
|
94
|
+
data = dict(error)
|
|
95
|
+
if utf8_len(encode(data)) <= cap:
|
|
96
|
+
return data
|
|
97
|
+
data["truncated"] = True
|
|
98
|
+
while utf8_len(encode(data)) > cap:
|
|
99
|
+
name, text = max(
|
|
100
|
+
((k, v) for k, v in data.items() if isinstance(v, str) and v),
|
|
101
|
+
key=lambda kv: len(kv[1]),
|
|
102
|
+
default=(None, ""),
|
|
103
|
+
)
|
|
104
|
+
if name is None:
|
|
105
|
+
return {"type": str(error.get("type", ""))[:64], "truncated": True}
|
|
106
|
+
keep = len(text) // 2
|
|
107
|
+
if keep == 0:
|
|
108
|
+
data[name] = ""
|
|
109
|
+
else:
|
|
110
|
+
data[name] = text[-keep:] if name in keep_tail else text[:keep]
|
|
111
|
+
return data
|
fronta/config.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Typed configuration: parsed once from `FRONTA_*` environment variables, validated at startup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import Field, model_validator
|
|
6
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
|
+
|
|
8
|
+
KIB = 1024
|
|
9
|
+
MIB = 1024 * KIB
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Settings(BaseSettings):
|
|
13
|
+
"""Runtime settings. Durations are seconds; caps are UTF-8 bytes of the JSON encoding."""
|
|
14
|
+
|
|
15
|
+
model_config = SettingsConfigDict(env_prefix="FRONTA_", extra="ignore", frozen=True)
|
|
16
|
+
|
|
17
|
+
dsn: str = Field(min_length=1, description="PostgreSQL connection string")
|
|
18
|
+
|
|
19
|
+
# Liveness
|
|
20
|
+
lease_s: float = Field(30.0, gt=0)
|
|
21
|
+
heartbeat_s: float = Field(10.0, gt=0)
|
|
22
|
+
reaper_interval_s: float = Field(15.0, gt=0)
|
|
23
|
+
poll_interval_s: float = Field(5.0, gt=0)
|
|
24
|
+
grace_s: float = Field(30.0, ge=0)
|
|
25
|
+
kill_timeout_s: float = Field(5.0, gt=0)
|
|
26
|
+
|
|
27
|
+
# Worker
|
|
28
|
+
concurrency: int = Field(10, ge=1)
|
|
29
|
+
pool_size: int = Field(4, ge=1)
|
|
30
|
+
connect_timeout_s: float = Field(10.0, ge=1)
|
|
31
|
+
statement_timeout_s: float = Field(30.0, ge=0.001)
|
|
32
|
+
|
|
33
|
+
# Retention
|
|
34
|
+
retention_s: float = Field(7 * 86400.0, ge=0)
|
|
35
|
+
purge_interval_s: float = Field(600.0, gt=0)
|
|
36
|
+
purge_batch: int = Field(1000, ge=1)
|
|
37
|
+
|
|
38
|
+
# Caps
|
|
39
|
+
payload_cap: int = Field(MIB, ge=KIB)
|
|
40
|
+
result_cap: int = Field(MIB, ge=KIB)
|
|
41
|
+
progress_cap: int = Field(64 * KIB, ge=64)
|
|
42
|
+
error_cap: int = Field(64 * KIB, ge=KIB)
|
|
43
|
+
list_page_size: int = Field(50, ge=1)
|
|
44
|
+
list_page_max: int = Field(200, ge=1)
|
|
45
|
+
|
|
46
|
+
# Server
|
|
47
|
+
server_host: str = "127.0.0.1"
|
|
48
|
+
server_port: int = Field(8000, ge=1, le=65535)
|
|
49
|
+
server_token: str | None = Field(None, min_length=1) # an empty secret is a misconfiguration
|
|
50
|
+
|
|
51
|
+
# Sandbox
|
|
52
|
+
bwrap_path: str = "bwrap"
|
|
53
|
+
|
|
54
|
+
@model_validator(mode="after")
|
|
55
|
+
def _check_relations(self) -> Settings:
|
|
56
|
+
if self.heartbeat_s >= self.lease_s:
|
|
57
|
+
msg = f"heartbeat_s ({self.heartbeat_s}) must be shorter than lease_s ({self.lease_s})"
|
|
58
|
+
raise ValueError(msg)
|
|
59
|
+
if self.list_page_size > self.list_page_max:
|
|
60
|
+
msg = "list_page_size must not exceed list_page_max"
|
|
61
|
+
raise ValueError(msg)
|
|
62
|
+
return self
|
fronta/definitions.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Task definitions: the `task` / `process_task` decorators, `Sandbox`, and `enqueue`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from fronta import codec, runtime, store
|
|
12
|
+
from fronta.errors import PayloadTooLarge
|
|
13
|
+
from fronta.model import Backoff, Executor, NewTask, Policy, Sandbox, TaskTypeSpec
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
import asyncio
|
|
17
|
+
import logging
|
|
18
|
+
|
|
19
|
+
from fronta.model import JSON
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Context[StateT](Protocol):
|
|
23
|
+
"""What a handler receives. Implemented by the worker."""
|
|
24
|
+
|
|
25
|
+
task_id: int
|
|
26
|
+
attempt: int
|
|
27
|
+
state: StateT
|
|
28
|
+
log: logging.LoggerAdapter[logging.Logger]
|
|
29
|
+
cancelled: asyncio.Event
|
|
30
|
+
|
|
31
|
+
async def progress(self, value: JSON) -> None:
|
|
32
|
+
"""Store progress (any JSON value up to the progress cap; over-cap raises)."""
|
|
33
|
+
|
|
34
|
+
async def enqueue[I: BaseModel]( # noqa: PLR0913 # public signature fixed by SPEC.md
|
|
35
|
+
self,
|
|
36
|
+
task: TaskDefinition[I, Any],
|
|
37
|
+
input: I,
|
|
38
|
+
*,
|
|
39
|
+
priority: int = 0,
|
|
40
|
+
run_at: datetime | None = None,
|
|
41
|
+
key: str | None = None,
|
|
42
|
+
concurrency_key: str | None = None,
|
|
43
|
+
) -> int:
|
|
44
|
+
"""Enqueue another task, immediately and independently of this task's outcome."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
type Handler[InputT: BaseModel, OutputT] = Callable[[Context[Any], InputT], Awaitable[OutputT]]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _seconds(value: float | timedelta) -> float:
|
|
51
|
+
return value.total_seconds() if isinstance(value, timedelta) else float(value)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TaskDefinition[InputT: BaseModel, OutputT]:
|
|
55
|
+
"""An asyncio task type: name, models, policy and the handler."""
|
|
56
|
+
|
|
57
|
+
executor = Executor.ASYNCIO
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
name: str,
|
|
62
|
+
*,
|
|
63
|
+
input_model: type[InputT],
|
|
64
|
+
output_model: type[BaseModel] | None,
|
|
65
|
+
policy: Policy,
|
|
66
|
+
handler: Handler[InputT, OutputT] | None,
|
|
67
|
+
) -> None:
|
|
68
|
+
store.check_name(name)
|
|
69
|
+
self.name = name
|
|
70
|
+
self.input_model = input_model
|
|
71
|
+
self.output_model = output_model
|
|
72
|
+
self.policy = policy
|
|
73
|
+
self.handler = handler
|
|
74
|
+
|
|
75
|
+
def __repr__(self) -> str:
|
|
76
|
+
return f"<{type(self).__name__} {self.name!r}>"
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def spec(self) -> TaskTypeSpec:
|
|
80
|
+
return TaskTypeSpec(
|
|
81
|
+
name=self.name,
|
|
82
|
+
executor=self.executor,
|
|
83
|
+
input_schema=self.input_model.model_json_schema(mode="validation"),
|
|
84
|
+
output_schema=(
|
|
85
|
+
None
|
|
86
|
+
if self.output_model is None
|
|
87
|
+
else self.output_model.model_json_schema(mode="serialization")
|
|
88
|
+
),
|
|
89
|
+
policy=self.policy,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def encode_input(self, input: InputT | Mapping[str, Any], cap: int) -> str:
|
|
93
|
+
"""Validate against the input model and encode; enforce the payload cap."""
|
|
94
|
+
model = (
|
|
95
|
+
input if isinstance(input, self.input_model) else self.input_model.model_validate(input)
|
|
96
|
+
)
|
|
97
|
+
value = model.model_dump(mode="json")
|
|
98
|
+
if not isinstance(value, dict):
|
|
99
|
+
msg = f"input of {self.name!r} must serialize to a JSON object"
|
|
100
|
+
raise TypeError(msg)
|
|
101
|
+
try:
|
|
102
|
+
return codec.encode_capped(value, cap, "payload")
|
|
103
|
+
except codec.OverCap as exc:
|
|
104
|
+
raise PayloadTooLarge(str(exc)) from exc
|
|
105
|
+
|
|
106
|
+
async def enqueue( # noqa: PLR0913 # public signature fixed by SPEC.md section 3
|
|
107
|
+
self,
|
|
108
|
+
input: InputT | Mapping[str, Any],
|
|
109
|
+
*,
|
|
110
|
+
conn: store.Conn | None = None,
|
|
111
|
+
priority: int = 0,
|
|
112
|
+
run_at: datetime | None = None,
|
|
113
|
+
key: str | None = None,
|
|
114
|
+
concurrency_key: str | None = None,
|
|
115
|
+
) -> int:
|
|
116
|
+
"""Enqueue and return the task id (or the id of the active task with the same key).
|
|
117
|
+
|
|
118
|
+
With `conn` the insert joins the caller's transaction (never committed by Fronta);
|
|
119
|
+
without it the process-global pool is used and the insert is committed here.
|
|
120
|
+
"""
|
|
121
|
+
settings = runtime.get_settings()
|
|
122
|
+
new_task = NewTask(
|
|
123
|
+
type=self.name,
|
|
124
|
+
input_json=self.encode_input(input, settings.payload_cap),
|
|
125
|
+
policy=self.policy,
|
|
126
|
+
priority=priority,
|
|
127
|
+
run_at=run_at,
|
|
128
|
+
key=key,
|
|
129
|
+
concurrency_key=concurrency_key,
|
|
130
|
+
)
|
|
131
|
+
deadline = settings.statement_timeout_s
|
|
132
|
+
if conn is not None:
|
|
133
|
+
return await store.enqueue(conn, new_task, deadline_s=deadline)
|
|
134
|
+
pool = await runtime.get_pool()
|
|
135
|
+
async with pool.connection() as own_conn, own_conn.transaction():
|
|
136
|
+
return await store.enqueue(own_conn, new_task, deadline_s=deadline)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ProcessTaskDefinition[InputT: BaseModel](TaskDefinition[InputT, dict[str, Any]]):
|
|
140
|
+
"""A sandboxed executable: input on stdin, result `{exit_code, stdout, stderr, truncated}`."""
|
|
141
|
+
|
|
142
|
+
executor = Executor.PROCESS
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
name: str,
|
|
147
|
+
*,
|
|
148
|
+
argv: tuple[str, ...],
|
|
149
|
+
input_model: type[InputT],
|
|
150
|
+
policy: Policy,
|
|
151
|
+
sandbox: Sandbox,
|
|
152
|
+
) -> None:
|
|
153
|
+
if not argv:
|
|
154
|
+
msg = "argv must not be empty"
|
|
155
|
+
raise ValueError(msg)
|
|
156
|
+
super().__init__(
|
|
157
|
+
name, input_model=input_model, output_model=None, policy=policy, handler=None
|
|
158
|
+
)
|
|
159
|
+
self.argv = argv
|
|
160
|
+
self.sandbox = sandbox
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _policy(
|
|
164
|
+
max_attempts: int,
|
|
165
|
+
attempt_timeout: float | timedelta,
|
|
166
|
+
backoff: Backoff,
|
|
167
|
+
max_concurrency: int | None,
|
|
168
|
+
max_concurrency_per_key: int | None,
|
|
169
|
+
) -> Policy:
|
|
170
|
+
return Policy(
|
|
171
|
+
max_attempts=max_attempts,
|
|
172
|
+
attempt_timeout_s=_seconds(attempt_timeout),
|
|
173
|
+
backoff=backoff,
|
|
174
|
+
max_concurrency=max_concurrency,
|
|
175
|
+
max_concurrency_per_key=max_concurrency_per_key,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def task[InputT: BaseModel, OutputT]( # noqa: PLR0913 # public signature fixed by SPEC.md
|
|
180
|
+
name: str,
|
|
181
|
+
*,
|
|
182
|
+
input: type[InputT],
|
|
183
|
+
output: type[BaseModel] | None = None,
|
|
184
|
+
max_attempts: int = 3,
|
|
185
|
+
attempt_timeout: float | timedelta = 3600.0,
|
|
186
|
+
backoff: Backoff | None = None,
|
|
187
|
+
max_concurrency: int | None = None,
|
|
188
|
+
max_concurrency_per_key: int | None = None,
|
|
189
|
+
) -> Callable[[Handler[InputT, OutputT]], TaskDefinition[InputT, OutputT]]:
|
|
190
|
+
"""Declare an asyncio task type. Decorates `async def handler(ctx, input) -> output`."""
|
|
191
|
+
policy = _policy(
|
|
192
|
+
max_attempts,
|
|
193
|
+
attempt_timeout,
|
|
194
|
+
backoff or Backoff(),
|
|
195
|
+
max_concurrency,
|
|
196
|
+
max_concurrency_per_key,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def decorate(handler: Handler[InputT, OutputT]) -> TaskDefinition[InputT, OutputT]:
|
|
200
|
+
return TaskDefinition(
|
|
201
|
+
name, input_model=input, output_model=output, policy=policy, handler=handler
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return decorate
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def process_task[InputT: BaseModel]( # noqa: PLR0913 # public signature fixed by SPEC.md
|
|
208
|
+
name: str,
|
|
209
|
+
argv: tuple[str, ...] | list[str],
|
|
210
|
+
*,
|
|
211
|
+
input: type[InputT],
|
|
212
|
+
sandbox: Sandbox | None = None,
|
|
213
|
+
max_attempts: int = 3,
|
|
214
|
+
attempt_timeout: float | timedelta = 3600.0,
|
|
215
|
+
backoff: Backoff | None = None,
|
|
216
|
+
max_concurrency: int | None = None,
|
|
217
|
+
max_concurrency_per_key: int | None = None,
|
|
218
|
+
) -> ProcessTaskDefinition[InputT]:
|
|
219
|
+
"""Declare a sandboxed process task type. `argv[0]` is resolved inside the sandbox."""
|
|
220
|
+
policy = _policy(
|
|
221
|
+
max_attempts,
|
|
222
|
+
attempt_timeout,
|
|
223
|
+
backoff or Backoff(),
|
|
224
|
+
max_concurrency,
|
|
225
|
+
max_concurrency_per_key,
|
|
226
|
+
)
|
|
227
|
+
return ProcessTaskDefinition(
|
|
228
|
+
name, argv=tuple(argv), input_model=input, policy=policy, sandbox=sandbox or Sandbox()
|
|
229
|
+
)
|
fronta/errors.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Domain exceptions.
|
|
2
|
+
|
|
3
|
+
They live in one bottom-layer module because they form the public API surface (`fronta.X`) and are
|
|
4
|
+
raised and handled across layers (codec -> executors -> worker/server).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FrontaError(Exception):
|
|
9
|
+
"""Base class of every Fronta error."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfigurationError(FrontaError):
|
|
13
|
+
"""Invalid settings or definition parameters."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PayloadTooLarge(FrontaError):
|
|
17
|
+
"""Enqueue input exceeds the payload cap (UTF-8 bytes of the JSON encoding)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProgressTooLarge(FrontaError):
|
|
21
|
+
"""`ctx.progress()` value exceeds the progress cap."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InputValidationError(FrontaError):
|
|
25
|
+
"""Stored input does not match the input model at claim time. Fails the task without retry."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ResultSerializationError(FrontaError):
|
|
29
|
+
"""Result is not JSON, not finite, over the cap, or violates the output model. No retry."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NonRetryableError(FrontaError):
|
|
33
|
+
"""Raised by a handler to fail the task without retry."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class UnknownTaskType(FrontaError):
|
|
37
|
+
"""The task type has not been published by any worker."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TaskNotFound(FrontaError):
|
|
41
|
+
"""No task with this id."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class NotCancellable(FrontaError):
|
|
45
|
+
"""The task is already terminal."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class InvalidInput(FrontaError):
|
|
49
|
+
"""Server-side input does not match the published JSON schema."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SandboxError(FrontaError):
|
|
53
|
+
"""The sandbox cannot be set up on this host (probe failed) or a spawn failed."""
|