async-task-api-worker 0.1.0rc2__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.
- async_task_api_worker/__init__.py +42 -0
- async_task_api_worker/__main__.py +5 -0
- async_task_api_worker/_runtime/__init__.py +1 -0
- async_task_api_worker/_runtime/amqp_transport.py +251 -0
- async_task_api_worker/_runtime/declare.py +40 -0
- async_task_api_worker/_runtime/engine.py +543 -0
- async_task_api_worker/_runtime/inflight.py +38 -0
- async_task_api_worker/_runtime/lifecycle.py +463 -0
- async_task_api_worker/_runtime/log_context.py +20 -0
- async_task_api_worker/_runtime/observer.py +78 -0
- async_task_api_worker/_runtime/progress.py +143 -0
- async_task_api_worker/_runtime/retry.py +29 -0
- async_task_api_worker/_runtime/sync_bridge.py +68 -0
- async_task_api_worker/_runtime/transport.py +62 -0
- async_task_api_worker/_version.py +11 -0
- async_task_api_worker/cli.py +88 -0
- async_task_api_worker/context.py +70 -0
- async_task_api_worker/errors.py +116 -0
- async_task_api_worker/observability/__init__.py +1 -0
- async_task_api_worker/observability/health.py +299 -0
- async_task_api_worker/observability/logging.py +89 -0
- async_task_api_worker/observability/loop_lag.py +56 -0
- async_task_api_worker/observability/metrics.py +188 -0
- async_task_api_worker/protocol/__init__.py +41 -0
- async_task_api_worker/protocol/envelope.py +103 -0
- async_task_api_worker/protocol/json_types.py +7 -0
- async_task_api_worker/protocol/messages.py +121 -0
- async_task_api_worker/protocol/version.py +11 -0
- async_task_api_worker/py.typed +0 -0
- async_task_api_worker/settings.py +127 -0
- async_task_api_worker/testing/__init__.py +27 -0
- async_task_api_worker/testing/_context.py +93 -0
- async_task_api_worker/testing/_harness.py +251 -0
- async_task_api_worker/testing/_memory_broker.py +162 -0
- async_task_api_worker/worker.py +254 -0
- async_task_api_worker-0.1.0rc2.dist-info/METADATA +269 -0
- async_task_api_worker-0.1.0rc2.dist-info/RECORD +40 -0
- async_task_api_worker-0.1.0rc2.dist-info/WHEEL +4 -0
- async_task_api_worker-0.1.0rc2.dist-info/entry_points.txt +2 -0
- async_task_api_worker-0.1.0rc2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Inbound side of the protocol: the task envelope the API publishes on ``<service>_queue_in``.
|
|
2
|
+
|
|
3
|
+
Parsing only. Every model ignores unknown fields so that additive protocol changes never break a worker.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, ValidationError
|
|
12
|
+
|
|
13
|
+
from async_task_api_worker.protocol.json_types import JsonValue
|
|
14
|
+
|
|
15
|
+
_KEY_TASK_ID: Final[str] = "task_id"
|
|
16
|
+
_KEY_DATA: Final[str] = "data"
|
|
17
|
+
_KEY_BODY: Final[str] = "body"
|
|
18
|
+
_DEFAULT_MESSAGE_TYPE: Final[str] = "submission"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InputRef(BaseModel):
|
|
22
|
+
"""A presigned input the API hands to the worker (optional; ignored when absent)."""
|
|
23
|
+
|
|
24
|
+
model_config = ConfigDict(frozen=True, extra="ignore")
|
|
25
|
+
|
|
26
|
+
url: AnyHttpUrl
|
|
27
|
+
size_bytes: int | None = Field(default=None, ge=0)
|
|
28
|
+
content_type: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class EnvelopeContext(BaseModel):
|
|
32
|
+
"""``data.context``: optional, absent unless the API provides it."""
|
|
33
|
+
|
|
34
|
+
model_config = ConfigDict(frozen=True, extra="ignore")
|
|
35
|
+
|
|
36
|
+
client_id: str | None = None
|
|
37
|
+
input_refs: dict[str, InputRef] = Field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SubmissionData(BaseModel):
|
|
41
|
+
model_config = ConfigDict(frozen=True, extra="ignore")
|
|
42
|
+
|
|
43
|
+
# Optional in the protocol; defaults to ``submission``. A missing value must not turn a task into poison.
|
|
44
|
+
message_type: str = _DEFAULT_MESSAGE_TYPE
|
|
45
|
+
body: dict[str, JsonValue]
|
|
46
|
+
context: EnvelopeContext | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TaskEnvelope(BaseModel):
|
|
50
|
+
model_config = ConfigDict(frozen=True, extra="ignore")
|
|
51
|
+
|
|
52
|
+
task_id: str = Field(min_length=1)
|
|
53
|
+
data: SubmissionData
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PoisonReason(StrEnum):
|
|
57
|
+
"""Why a delivery cannot be turned into a task. Replaying it would never help, so it is acked."""
|
|
58
|
+
|
|
59
|
+
NOT_UTF8 = "NOT_UTF8"
|
|
60
|
+
NOT_JSON = "NOT_JSON"
|
|
61
|
+
NOT_AN_OBJECT = "NOT_AN_OBJECT"
|
|
62
|
+
NO_TASK_ID = "NO_TASK_ID"
|
|
63
|
+
NO_DATA = "NO_DATA"
|
|
64
|
+
NO_BODY = "NO_BODY"
|
|
65
|
+
MALFORMED = "MALFORMED"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class PoisonDelivery:
|
|
70
|
+
"""A delivery that cannot become a task.
|
|
71
|
+
|
|
72
|
+
``task_id`` is kept whenever it could be read, for logging. It comes from an untrusted body (any length, may
|
|
73
|
+
contain control characters): bound and escape it before it reaches a log line.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
reason: PoisonReason
|
|
77
|
+
task_id: str | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def decode_envelope(raw: bytes) -> TaskEnvelope | PoisonDelivery: # noqa: PLR0911 - one early return per poison reason
|
|
81
|
+
"""Decode a delivery body. Total: every failure is a ``PoisonDelivery``, nothing is ever raised."""
|
|
82
|
+
try:
|
|
83
|
+
text = raw.decode("utf-8")
|
|
84
|
+
except UnicodeDecodeError:
|
|
85
|
+
return PoisonDelivery(PoisonReason.NOT_UTF8)
|
|
86
|
+
try:
|
|
87
|
+
loaded: object = json.loads(text)
|
|
88
|
+
except (ValueError, RecursionError):
|
|
89
|
+
return PoisonDelivery(PoisonReason.NOT_JSON)
|
|
90
|
+
if not isinstance(loaded, dict):
|
|
91
|
+
return PoisonDelivery(PoisonReason.NOT_AN_OBJECT)
|
|
92
|
+
task_id = loaded.get(_KEY_TASK_ID)
|
|
93
|
+
if not isinstance(task_id, str) or not task_id:
|
|
94
|
+
return PoisonDelivery(PoisonReason.NO_TASK_ID)
|
|
95
|
+
data = loaded.get(_KEY_DATA)
|
|
96
|
+
if not isinstance(data, dict):
|
|
97
|
+
return PoisonDelivery(PoisonReason.NO_DATA, task_id)
|
|
98
|
+
if not isinstance(data.get(_KEY_BODY), dict):
|
|
99
|
+
return PoisonDelivery(PoisonReason.NO_BODY, task_id)
|
|
100
|
+
try:
|
|
101
|
+
return TaskEnvelope.model_validate(loaded)
|
|
102
|
+
except ValidationError:
|
|
103
|
+
return PoisonDelivery(PoisonReason.MALFORMED, task_id)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""JSON value aliases shared by the protocol models and the public API."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
|
|
5
|
+
type JsonValue = str | int | float | bool | list[JsonValue] | dict[str, JsonValue] | None
|
|
6
|
+
type JsonObject = dict[str, JsonValue]
|
|
7
|
+
type ProgressPayload = Mapping[str, JsonValue]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Outbound side of the protocol: what a worker publishes on ``<service>_queue_out``.
|
|
2
|
+
|
|
3
|
+
Every byte goes through :class:`MessageEncoder`. The serialization is ``json.dumps`` of the model dump, with
|
|
4
|
+
the default separators and ``ensure_ascii``, which is the wire format of the protocol. It is **not**
|
|
5
|
+
``model_dump_json()``, whose compact separators would change the bytes (the golden tests pin this).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import socket
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from typing import Final, Literal
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
17
|
+
|
|
18
|
+
from async_task_api_worker.errors import DEFAULT_PUBLIC_MESSAGES, ErrorCode, WorkerError, validate_public_message
|
|
19
|
+
from async_task_api_worker.protocol.json_types import JsonObject, JsonValue, ProgressPayload
|
|
20
|
+
|
|
21
|
+
# A progress fraction lives in [0, 1): the terminal success or failure message is the "100%".
|
|
22
|
+
_PROGRESS_UPPER_BOUND: Final[float] = 1.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class InvalidProgressError(ValueError):
|
|
26
|
+
"""The progress fraction is not a finite number in ``[0, 1)``."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _OutModel(BaseModel):
|
|
30
|
+
model_config = ConfigDict(frozen=True, allow_inf_nan=False)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class StartedData(_OutModel):
|
|
34
|
+
message_type: Literal["started"] = "started"
|
|
35
|
+
hostname: str
|
|
36
|
+
started_at: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ProgressData(_OutModel):
|
|
40
|
+
message_type: Literal["progress"] = "progress"
|
|
41
|
+
progress: float
|
|
42
|
+
response: JsonObject | None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SuccessData(_OutModel):
|
|
46
|
+
message_type: Literal["success"] = "success"
|
|
47
|
+
response: JsonObject
|
|
48
|
+
ended_at: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ErrorDetail(_OutModel):
|
|
52
|
+
"""Structured failure. The listener also accepts extra keys, which the SDK never sends."""
|
|
53
|
+
|
|
54
|
+
code: str
|
|
55
|
+
message: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class FailureData(_OutModel):
|
|
59
|
+
message_type: Literal["failure"] = "failure"
|
|
60
|
+
error_message: ErrorDetail
|
|
61
|
+
ended_at: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class OutMessage(_OutModel):
|
|
65
|
+
task_id: str
|
|
66
|
+
data: StartedData | ProgressData | SuccessData | FailureData = Field(discriminator="message_type")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _utc_now() -> datetime:
|
|
70
|
+
return datetime.now(UTC)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class MessageEncoder:
|
|
75
|
+
"""Builds the exact bytes of every outbound message.
|
|
76
|
+
|
|
77
|
+
The worker dates its own messages (UTC, ``isoformat()``): the listener may process them out of order.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
clock: Callable[[], datetime] = _utc_now
|
|
81
|
+
hostname: str = field(default_factory=socket.gethostname)
|
|
82
|
+
|
|
83
|
+
def _timestamp(self) -> str:
|
|
84
|
+
return self.clock().astimezone(UTC).isoformat()
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def _encode(message: OutMessage) -> bytes:
|
|
88
|
+
# ``allow_nan=False``: NaN and Infinity are not JSON, strict consumers downstream would choke on them.
|
|
89
|
+
return json.dumps(message.model_dump(mode="python"), allow_nan=False).encode()
|
|
90
|
+
|
|
91
|
+
def started(self, task_id: str) -> bytes:
|
|
92
|
+
data = StartedData(hostname=self.hostname, started_at=self._timestamp())
|
|
93
|
+
return self._encode(OutMessage(task_id=task_id, data=data))
|
|
94
|
+
|
|
95
|
+
def progress(self, task_id: str, fraction: float, payload: ProgressPayload | None) -> bytes:
|
|
96
|
+
"""Encode a progress message. ``fraction`` must be finite and in ``[0, 1)``; it is always sent as a float."""
|
|
97
|
+
if isinstance(fraction, bool) or not math.isfinite(fraction) or not 0.0 <= fraction < _PROGRESS_UPPER_BOUND:
|
|
98
|
+
msg = f"progress must be a finite number in [0, {_PROGRESS_UPPER_BOUND}), got {fraction!r}"
|
|
99
|
+
raise InvalidProgressError(msg)
|
|
100
|
+
response: Mapping[str, JsonValue] | None = payload
|
|
101
|
+
data = ProgressData(progress=float(fraction), response=None if response is None else dict(response))
|
|
102
|
+
return self._encode(OutMessage(task_id=task_id, data=data))
|
|
103
|
+
|
|
104
|
+
def success(self, task_id: str, response: Mapping[str, JsonValue]) -> bytes:
|
|
105
|
+
data = SuccessData(response=dict(response), ended_at=self._timestamp())
|
|
106
|
+
return self._encode(OutMessage(task_id=task_id, data=data))
|
|
107
|
+
|
|
108
|
+
def failure(self, task_id: str, error: WorkerError | ErrorCode, message: str | None = None) -> bytes:
|
|
109
|
+
"""Encode a failure from a ``WorkerError``, or from an SDK ``ErrorCode`` with its fixed public text."""
|
|
110
|
+
if isinstance(error, WorkerError):
|
|
111
|
+
if message is not None:
|
|
112
|
+
msg = "message cannot be combined with a WorkerError: it carries its own public message"
|
|
113
|
+
raise ValueError(msg)
|
|
114
|
+
detail = ErrorDetail(code=error.code, message=error.public_message)
|
|
115
|
+
else:
|
|
116
|
+
if message is None:
|
|
117
|
+
message = DEFAULT_PUBLIC_MESSAGES[error]
|
|
118
|
+
validate_public_message(message)
|
|
119
|
+
detail = ErrorDetail(code=error.value, message=message)
|
|
120
|
+
data = FailureData(error_message=detail, ended_at=self._timestamp())
|
|
121
|
+
return self._encode(OutMessage(task_id=task_id, data=data))
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Wire protocol version spoken by this SDK.
|
|
2
|
+
|
|
3
|
+
- ``0.1.0``: failure ``error_message`` is either the ``{"code", "message"}`` object (what the SDK sends) or a
|
|
4
|
+
``"<CODE>: <message>"`` string (accepted from other producers).
|
|
5
|
+
|
|
6
|
+
The protocol stays in 0.x until the contract is validated in production.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
PROTOCOL_VERSION: Final[str] = "0.1.0"
|
|
File without changes
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Worker settings, read from the environment. The variable names are the SDK configuration contract."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from typing import Final, Self
|
|
5
|
+
|
|
6
|
+
from pydantic import Field, ModelWrapValidatorHandler, SecretStr, ValidationError, field_validator, model_validator
|
|
7
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
8
|
+
|
|
9
|
+
from async_task_api_worker.errors import WorkerConfigurationError
|
|
10
|
+
|
|
11
|
+
DEFAULT_WORKER_CONCURRENCY: Final[int] = 1
|
|
12
|
+
DEFAULT_HEALTH_CHECK_HOST: Final[str] = "0.0.0.0" # noqa: S104 - the probe must be reachable from the pod network
|
|
13
|
+
DEFAULT_HEALTH_CHECK_PORT: Final[int] = 8083
|
|
14
|
+
MAX_TCP_PORT: Final[int] = 65535
|
|
15
|
+
DEFAULT_SHUTDOWN_GRACE_S: Final[float] = 25.0
|
|
16
|
+
# Mirrors ``rebut.deliveryLimit`` in ``helm/values.yaml``; calibrated against the broker policy in a later PR.
|
|
17
|
+
DEFAULT_DELIVERY_LIMIT: Final[int] = 5
|
|
18
|
+
# Mirrors ``rebut.consumerTimeoutMs`` (30 minutes) in ``helm/values.yaml``.
|
|
19
|
+
DEFAULT_CONSUMER_TIMEOUT_S: Final[float] = 1800.0
|
|
20
|
+
DEFAULT_RETRY_BACKOFF_BASE_S: Final[float] = 2.0
|
|
21
|
+
DEFAULT_RETRY_BACKOFF_MAX_S: Final[float] = 30.0
|
|
22
|
+
DEFAULT_PROBE_TIMEOUT_S: Final[float] = 3.0
|
|
23
|
+
DEFAULT_PROGRESS_MIN_INTERVAL_S: Final[float] = 1.0
|
|
24
|
+
DEFAULT_TASK_OVERRUN_GRACE_S: Final[float] = 60.0
|
|
25
|
+
DEFAULT_BROKER_RECONNECT_INTERVAL_S: Final[float] = 5.0
|
|
26
|
+
# One budget for all the shutdown hooks together (not one per hook, which would depend on how many there are): it is a
|
|
27
|
+
# term of the shutdown bound documented in ADR 0005.
|
|
28
|
+
DEFAULT_SHUTDOWN_HOOKS_TIMEOUT_S: Final[float] = 5.0
|
|
29
|
+
DEFAULT_JOB_MAX_TASKS: Final[int] = 1
|
|
30
|
+
# The queue held a message when the Job was created, and a ready message is dispatched within milliseconds of the
|
|
31
|
+
# consumer registering: a longer wait only keeps an idle pod (and its resources) alive. See ADR 0005.
|
|
32
|
+
DEFAULT_JOB_IDLE_TIMEOUT_S: Final[float] = 10.0
|
|
33
|
+
# Job mode only: how long the broker may stay unreachable before the pod gives up and exits non-zero, so that the Job's
|
|
34
|
+
# ``backoffLimit`` applies. Several reconnect attempts, short of what a broker restart usually takes. See ADR 0005.
|
|
35
|
+
DEFAULT_JOB_CONNECT_TIMEOUT_S: Final[float] = 30.0
|
|
36
|
+
# In job mode a pod takes one delivery at a time, whatever ``WORKER_CONCURRENCY`` says.
|
|
37
|
+
JOB_PREFETCH: Final[int] = 1
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class LogLevel(StrEnum):
|
|
41
|
+
DEBUG = "DEBUG"
|
|
42
|
+
INFO = "INFO"
|
|
43
|
+
WARNING = "WARNING"
|
|
44
|
+
ERROR = "ERROR"
|
|
45
|
+
CRITICAL = "CRITICAL"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class WorkerMode(StrEnum):
|
|
49
|
+
"""How a deployment runs the same worker: a long-lived consumer, or a one-shot Job."""
|
|
50
|
+
|
|
51
|
+
SERVICE = "service"
|
|
52
|
+
JOB = "job"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class WorkerSettings(BaseSettings):
|
|
56
|
+
"""Environment-driven settings. An invalid value raises ``WorkerConfigurationError``: the worker must not start.
|
|
57
|
+
|
|
58
|
+
Each field is read from the upper-cased environment variable of the same name (``broker_url`` ->
|
|
59
|
+
``BROKER_URL``). No ``.env`` file is read by default.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
model_config = SettingsConfigDict(extra="ignore", frozen=True)
|
|
63
|
+
|
|
64
|
+
broker_url: SecretStr = Field(min_length=1)
|
|
65
|
+
in_queue_name: str = Field(min_length=1)
|
|
66
|
+
out_queue_name: str = Field(min_length=1)
|
|
67
|
+
worker_concurrency: int = Field(default=DEFAULT_WORKER_CONCURRENCY, ge=1)
|
|
68
|
+
health_check_host: str = DEFAULT_HEALTH_CHECK_HOST
|
|
69
|
+
health_check_port: int = Field(default=DEFAULT_HEALTH_CHECK_PORT, ge=1, le=MAX_TCP_PORT)
|
|
70
|
+
log_level: LogLevel = LogLevel.INFO
|
|
71
|
+
worker_task_timeout_s: float | None = Field(default=None, gt=0)
|
|
72
|
+
worker_shutdown_grace_s: float = Field(default=DEFAULT_SHUTDOWN_GRACE_S, ge=0)
|
|
73
|
+
worker_delivery_limit: int = Field(default=DEFAULT_DELIVERY_LIMIT, ge=1)
|
|
74
|
+
worker_consumer_timeout_s: float = Field(default=DEFAULT_CONSUMER_TIMEOUT_S, gt=0)
|
|
75
|
+
worker_retry_backoff_base_s: float = Field(default=DEFAULT_RETRY_BACKOFF_BASE_S, gt=0)
|
|
76
|
+
worker_retry_backoff_max_s: float = Field(default=DEFAULT_RETRY_BACKOFF_MAX_S, gt=0)
|
|
77
|
+
worker_probe_timeout_s: float = Field(default=DEFAULT_PROBE_TIMEOUT_S, gt=0)
|
|
78
|
+
worker_progress_min_interval_s: float = Field(default=DEFAULT_PROGRESS_MIN_INTERVAL_S, ge=0)
|
|
79
|
+
worker_task_overrun_grace_s: float = Field(default=DEFAULT_TASK_OVERRUN_GRACE_S, ge=0)
|
|
80
|
+
worker_broker_reconnect_interval_s: float = Field(default=DEFAULT_BROKER_RECONNECT_INTERVAL_S, gt=0)
|
|
81
|
+
worker_shutdown_hooks_timeout_s: float = Field(default=DEFAULT_SHUTDOWN_HOOKS_TIMEOUT_S, gt=0)
|
|
82
|
+
worker_mode: WorkerMode = WorkerMode.SERVICE
|
|
83
|
+
worker_job_max_tasks: int = Field(default=DEFAULT_JOB_MAX_TASKS, ge=1)
|
|
84
|
+
worker_job_idle_timeout_s: float = Field(default=DEFAULT_JOB_IDLE_TIMEOUT_S, gt=0)
|
|
85
|
+
worker_job_connect_timeout_s: float = Field(default=DEFAULT_JOB_CONNECT_TIMEOUT_S, gt=0)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def is_job(self) -> bool:
|
|
89
|
+
return self.worker_mode is WorkerMode.JOB
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def prefetch(self) -> int:
|
|
93
|
+
"""How many unacknowledged deliveries the broker may hand this process."""
|
|
94
|
+
return JOB_PREFETCH if self.is_job else self.worker_concurrency
|
|
95
|
+
|
|
96
|
+
@field_validator("worker_mode", mode="before")
|
|
97
|
+
@classmethod
|
|
98
|
+
def _lowercase_worker_mode(cls, value: object) -> object:
|
|
99
|
+
return value.strip().lower() if isinstance(value, str) else value
|
|
100
|
+
|
|
101
|
+
@field_validator("log_level", mode="before")
|
|
102
|
+
@classmethod
|
|
103
|
+
def _uppercase_log_level(cls, value: object) -> object:
|
|
104
|
+
return value.strip().upper() if isinstance(value, str) else value
|
|
105
|
+
|
|
106
|
+
@model_validator(mode="after")
|
|
107
|
+
def _backoff_base_must_not_exceed_max(self) -> Self:
|
|
108
|
+
if self.worker_retry_backoff_base_s > self.worker_retry_backoff_max_s:
|
|
109
|
+
msg = "WORKER_RETRY_BACKOFF_BASE_S must not exceed WORKER_RETRY_BACKOFF_MAX_S"
|
|
110
|
+
raise ValueError(msg)
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
@model_validator(mode="wrap")
|
|
114
|
+
@classmethod
|
|
115
|
+
def _refuse_to_start_on_invalid_settings(cls, data: object, handler: ModelWrapValidatorHandler[Self]) -> Self:
|
|
116
|
+
try:
|
|
117
|
+
return handler(data)
|
|
118
|
+
except ValidationError as error:
|
|
119
|
+
# Never echo ``input`` or the raw error: BROKER_URL carries credentials.
|
|
120
|
+
problems = "; ".join(
|
|
121
|
+
f"{location}: {issue['msg']}"
|
|
122
|
+
if (location := ".".join(map(str, issue["loc"])).upper())
|
|
123
|
+
else issue["msg"]
|
|
124
|
+
for issue in error.errors()
|
|
125
|
+
)
|
|
126
|
+
msg = f"Invalid worker settings: {problems}"
|
|
127
|
+
raise WorkerConfigurationError(msg) from None
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Test a worker without a broker.
|
|
2
|
+
|
|
3
|
+
- :class:`WorkerHarness` runs a worker's task in memory through the real engine and returns what it published and
|
|
4
|
+
what it did to the delivery (:class:`TaskRun`, :class:`Outcome`).
|
|
5
|
+
- :func:`make_task_context` and :func:`make_sync_task_context` build the context a handler receives, to call a handler
|
|
6
|
+
directly; :class:`ProgressRecorder` collects what it reports.
|
|
7
|
+
|
|
8
|
+
This is the first part of the testing kit: the conformance suite extends it in later releases.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from async_task_api_worker.testing._context import (
|
|
12
|
+
ProgressRecorder,
|
|
13
|
+
ProgressUpdate,
|
|
14
|
+
make_sync_task_context,
|
|
15
|
+
make_task_context,
|
|
16
|
+
)
|
|
17
|
+
from async_task_api_worker.testing._harness import Outcome, TaskRun, WorkerHarness
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Outcome",
|
|
21
|
+
"ProgressRecorder",
|
|
22
|
+
"ProgressUpdate",
|
|
23
|
+
"TaskRun",
|
|
24
|
+
"WorkerHarness",
|
|
25
|
+
"make_sync_task_context",
|
|
26
|
+
"make_task_context",
|
|
27
|
+
]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Build the context a handler receives, to call a handler directly in a unit test."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Final, NamedTuple
|
|
9
|
+
|
|
10
|
+
from async_task_api_worker.context import SyncTaskContext, TaskContext
|
|
11
|
+
from async_task_api_worker.protocol.envelope import InputRef
|
|
12
|
+
from async_task_api_worker.protocol.json_types import ProgressPayload
|
|
13
|
+
|
|
14
|
+
DEFAULT_TASK_ID: Final[str] = "test-task"
|
|
15
|
+
DEFAULT_TIMEOUT_S: Final[float] = 60.0
|
|
16
|
+
_LOGGER_NAME: Final[str] = "async_task_api_worker.testing"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ProgressUpdate(NamedTuple):
|
|
20
|
+
"""One call of ``ctx.progress``."""
|
|
21
|
+
|
|
22
|
+
fraction: float
|
|
23
|
+
payload: ProgressPayload | None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class ProgressRecorder:
|
|
28
|
+
"""Collects what a handler reports through ``ctx.progress``, untouched: no throttling, no validation."""
|
|
29
|
+
|
|
30
|
+
updates: list[ProgressUpdate] = field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
async def _record(self, fraction: float, payload: ProgressPayload | None = None) -> None:
|
|
33
|
+
self.updates.append(ProgressUpdate(fraction, payload))
|
|
34
|
+
|
|
35
|
+
def _record_from_thread(self, fraction: float, payload: ProgressPayload | None = None) -> None:
|
|
36
|
+
self.updates.append(ProgressUpdate(fraction, payload))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _logger(task_id: str, attempt: int) -> logging.LoggerAdapter[logging.Logger]:
|
|
40
|
+
return logging.LoggerAdapter(logging.getLogger(_LOGGER_NAME), {"task_id": task_id, "attempt": attempt})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def make_task_context(
|
|
44
|
+
*,
|
|
45
|
+
task_id: str = DEFAULT_TASK_ID,
|
|
46
|
+
client_id: str | None = None,
|
|
47
|
+
attempt: int = 1,
|
|
48
|
+
timeout_s: float = DEFAULT_TIMEOUT_S,
|
|
49
|
+
inputs: Mapping[str, InputRef] | None = None,
|
|
50
|
+
recorder: ProgressRecorder | None = None,
|
|
51
|
+
) -> TaskContext:
|
|
52
|
+
"""The context of an ``async def`` handler, to call it directly.
|
|
53
|
+
|
|
54
|
+
``timeout_s`` sets ``ctx.remaining()``; ``recorder`` collects the progress updates the handler reports (a recorder
|
|
55
|
+
of its own is used when none is given).
|
|
56
|
+
"""
|
|
57
|
+
sink = recorder if recorder is not None else ProgressRecorder()
|
|
58
|
+
return TaskContext(
|
|
59
|
+
task_id,
|
|
60
|
+
client_id,
|
|
61
|
+
attempt,
|
|
62
|
+
time.monotonic() + timeout_s,
|
|
63
|
+
_logger(task_id, attempt),
|
|
64
|
+
dict(inputs or {}),
|
|
65
|
+
sink._record,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def make_sync_task_context(
|
|
70
|
+
*,
|
|
71
|
+
task_id: str = DEFAULT_TASK_ID,
|
|
72
|
+
client_id: str | None = None,
|
|
73
|
+
attempt: int = 1,
|
|
74
|
+
timeout_s: float = DEFAULT_TIMEOUT_S,
|
|
75
|
+
inputs: Mapping[str, InputRef] | None = None,
|
|
76
|
+
recorder: ProgressRecorder | None = None,
|
|
77
|
+
cancel_event: threading.Event | None = None,
|
|
78
|
+
) -> SyncTaskContext:
|
|
79
|
+
"""The context of a ``def`` handler, to call it directly.
|
|
80
|
+
|
|
81
|
+
Set ``cancel_event`` to make ``ctx.cancelled()`` true, as a timeout or a shutdown would.
|
|
82
|
+
"""
|
|
83
|
+
sink = recorder if recorder is not None else ProgressRecorder()
|
|
84
|
+
return SyncTaskContext(
|
|
85
|
+
task_id,
|
|
86
|
+
client_id,
|
|
87
|
+
attempt,
|
|
88
|
+
time.monotonic() + timeout_s,
|
|
89
|
+
_logger(task_id, attempt),
|
|
90
|
+
dict(inputs or {}),
|
|
91
|
+
sink._record_from_thread,
|
|
92
|
+
cancel_event if cancel_event is not None else threading.Event(),
|
|
93
|
+
)
|