tilebox-workflows 0.63.2__tar.gz → 0.64.0__tar.gz
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.
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/PKG-INFO +6 -6
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/pyproject.toml +5 -5
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/__init__.py +12 -0
- tilebox_workflows-0.64.0/tilebox/workflows/observability/_log_stream.py +172 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/observability/logging.py +76 -50
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/observability/tracing.py +8 -13
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/runner/__main__.py +10 -5
- tilebox_workflows-0.64.0/tilebox/workflows/runner/worker_server.py +128 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/runner/worker_service.py +73 -7
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/worker_connect.py +66 -0
- tilebox_workflows-0.64.0/tilebox/workflows/workflows/v1/worker_pb2.py +51 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/worker_pb2.pyi +1 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/worker_pb2_grpc.py +57 -1
- tilebox_workflows-0.63.2/tilebox/workflows/observability/_log_pipe.py +0 -124
- tilebox_workflows-0.63.2/tilebox/workflows/runner/worker_server.py +0 -67
- tilebox_workflows-0.63.2/tilebox/workflows/workflows/v1/worker_pb2.py +0 -50
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/.gitignore +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/README.md +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/_codec.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/_codecs/__init__.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/_codecs/affine.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/_codecs/async_geotiff.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/_codecs/odc.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/_codecs/pyproj.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/_codecs/rasterio.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/_codecs/shapely.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/_codecs/tilebox.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/_serialization.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/automations/__init__.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/automations/client.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/automations/cron.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/automations/service.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/automations/storage_event.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/cache.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/client.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/clusters/__init__.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/clusters/client.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/clusters/service.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/data.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/formatting/__init__.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/formatting/job.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/jobs/__init__.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/jobs/client.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/jobs/service.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/jobs/telemetry_service.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/observability/__init__.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/observability/_logging.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/runner/__init__.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/runner/executor.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/runner/runner.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/runner/task_runner.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/runner/task_service.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/task.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/__init__.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/client.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/service.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/automation_connect.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/automation_pb2.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/automation_pb2.pyi +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/automation_pb2_grpc.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/core_pb2.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/core_pb2.pyi +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/core_pb2_grpc.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/diagram_connect.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/diagram_pb2.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/diagram_pb2.pyi +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/diagram_pb2_grpc.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/job_connect.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/job_pb2.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/job_pb2.pyi +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/job_pb2_grpc.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/storage_location_connect.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/storage_location_pb2.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/storage_location_pb2.pyi +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/storage_location_pb2_grpc.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/task_connect.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/task_pb2.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/task_pb2.pyi +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/task_pb2_grpc.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/task_release_pb2.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/task_release_pb2.pyi +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/task_release_pb2_grpc.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/telemetry_connect.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/telemetry_pb2.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/telemetry_pb2.pyi +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/telemetry_pb2_grpc.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/workflows_connect.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/workflows_pb2.py +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/workflows_pb2.pyi +0 -0
- {tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/workflows/v1/workflows_pb2_grpc.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: tilebox-workflows
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.64.0
|
|
4
4
|
Summary: Workflow client and task runner for Tilebox
|
|
5
5
|
Project-URL: Homepage, https://tilebox.com
|
|
6
6
|
Project-URL: Documentation, https://docs.tilebox.com/workflows/introduction
|
|
@@ -25,11 +25,11 @@ Requires-Dist: google-auth[requests]>=2.29
|
|
|
25
25
|
Requires-Dist: grpcio>=1.84.0
|
|
26
26
|
Requires-Dist: msgspec>=0.19
|
|
27
27
|
Requires-Dist: obstore>=0.8.2
|
|
28
|
-
Requires-Dist: opentelemetry-api>=1.
|
|
29
|
-
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.
|
|
30
|
-
Requires-Dist: opentelemetry-instrumentation-logging>=0.
|
|
31
|
-
Requires-Dist: opentelemetry-proto>=1.
|
|
32
|
-
Requires-Dist: opentelemetry-sdk>=1.
|
|
28
|
+
Requires-Dist: opentelemetry-api>=1.44.0
|
|
29
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.44.0
|
|
30
|
+
Requires-Dist: opentelemetry-instrumentation-logging>=0.65b0
|
|
31
|
+
Requires-Dist: opentelemetry-proto>=1.44.0
|
|
32
|
+
Requires-Dist: opentelemetry-sdk>=1.44.0
|
|
33
33
|
Requires-Dist: tenacity>=8
|
|
34
34
|
Requires-Dist: tilebox-datasets>=0.56.0
|
|
35
35
|
Requires-Dist: tilebox-grpc>=0.28.0
|
|
@@ -27,14 +27,14 @@ dependencies = [
|
|
|
27
27
|
"grpcio>=1.84.0",
|
|
28
28
|
"google-auth[requests]>=2.29",
|
|
29
29
|
"azure-identity>=1.23",
|
|
30
|
-
"opentelemetry-api>=1.
|
|
31
|
-
"opentelemetry-exporter-otlp-proto-http>=1.
|
|
32
|
-
"opentelemetry-sdk>=1.
|
|
30
|
+
"opentelemetry-api>=1.44.0",
|
|
31
|
+
"opentelemetry-exporter-otlp-proto-http>=1.44.0",
|
|
32
|
+
"opentelemetry-sdk>=1.44.0",
|
|
33
33
|
"tenacity>=8",
|
|
34
34
|
"boto3>=1.40.2",
|
|
35
35
|
"obstore>=0.8.2",
|
|
36
|
-
"opentelemetry-proto>=1.
|
|
37
|
-
"opentelemetry-instrumentation-logging>=0.
|
|
36
|
+
"opentelemetry-proto>=1.44.0",
|
|
37
|
+
"opentelemetry-instrumentation-logging>=0.65b0",
|
|
38
38
|
"msgspec>=0.19",
|
|
39
39
|
]
|
|
40
40
|
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import os
|
|
2
2
|
from typing import TYPE_CHECKING, Any
|
|
3
3
|
|
|
4
|
+
# Apply native logging defaults before any submodule can initialize gRPC.
|
|
5
|
+
import _tilebox.grpc # noqa: F401
|
|
6
|
+
|
|
4
7
|
if TYPE_CHECKING:
|
|
5
8
|
from tilebox.workflows.client import Client
|
|
6
9
|
from tilebox.workflows.data import Job
|
|
@@ -48,8 +51,17 @@ def __dir__() -> list[str]:
|
|
|
48
51
|
def _initialize_logging_from_environment() -> None:
|
|
49
52
|
# Stage 1: current CLIs provide credentials before importing workflow code, so
|
|
50
53
|
# even import-time logs reach the API. Ordinary SDK imports stay lightweight.
|
|
54
|
+
runtime_dir = os.environ.get("TILEBOX_RUNTIME_DIR")
|
|
55
|
+
runtime_token = os.environ.get("TILEBOX_RUNTIME_TOKEN")
|
|
56
|
+
if bool(runtime_dir) != bool(runtime_token):
|
|
57
|
+
raise RuntimeError("TILEBOX_RUNTIME_DIR and TILEBOX_RUNTIME_TOKEN must be set together")
|
|
58
|
+
|
|
51
59
|
url = os.environ.get("TILEBOX_API_URL")
|
|
52
60
|
token = os.environ.get("TILEBOX_API_KEY")
|
|
61
|
+
if runtime_dir and runtime_token:
|
|
62
|
+
# Importing installs the managed local stream before workflow modules can log.
|
|
63
|
+
from tilebox.workflows.observability import logging as _runtime_logging # noqa: F401, PLC0415
|
|
64
|
+
|
|
53
65
|
if not url or not token:
|
|
54
66
|
return
|
|
55
67
|
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Bounded OTLP log streaming for CLI-managed workflow runtimes."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import traceback
|
|
8
|
+
from collections import deque
|
|
9
|
+
from collections.abc import Callable, Iterator
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from opentelemetry.instrumentation.log_utils import std_to_otel
|
|
13
|
+
from opentelemetry.proto.common.v1.common_pb2 import AnyValue, ArrayValue, KeyValue, KeyValueList
|
|
14
|
+
from opentelemetry.proto.logs.v1.logs_pb2 import LogRecord, SeverityNumber
|
|
15
|
+
from opentelemetry.semconv.attributes import exception_attributes
|
|
16
|
+
|
|
17
|
+
from tilebox.workflows.observability._logging import _record_attributes
|
|
18
|
+
|
|
19
|
+
_MAX_RECORD_SIZE = 1024 * 1024
|
|
20
|
+
_MANAGED_RUNTIME = bool(os.environ.get("TILEBOX_RUNTIME_DIR") and os.environ.get("TILEBOX_RUNTIME_TOKEN"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _any_value(value: Any) -> AnyValue: # noqa: PLR0911 -- each protobuf union arm is clearest explicitly
|
|
24
|
+
"""Convert an already-normalized JSON value to OTLP."""
|
|
25
|
+
if value is None:
|
|
26
|
+
return AnyValue()
|
|
27
|
+
if isinstance(value, bool):
|
|
28
|
+
return AnyValue(bool_value=value)
|
|
29
|
+
if isinstance(value, int):
|
|
30
|
+
if abs(value) > 2**63 - 1:
|
|
31
|
+
return AnyValue(string_value=str(value))
|
|
32
|
+
return AnyValue(int_value=value)
|
|
33
|
+
if isinstance(value, float):
|
|
34
|
+
return AnyValue(double_value=value)
|
|
35
|
+
if isinstance(value, dict):
|
|
36
|
+
return AnyValue(
|
|
37
|
+
kvlist_value=KeyValueList(values=[KeyValue(key=key, value=_any_value(item)) for key, item in value.items()])
|
|
38
|
+
)
|
|
39
|
+
if isinstance(value, list):
|
|
40
|
+
return AnyValue(array_value=ArrayValue(values=[_any_value(item) for item in value]))
|
|
41
|
+
return AnyValue(string_value=value)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _LogQueue:
|
|
45
|
+
"""Non-blocking bounded queue with explicit sealed-and-empty termination."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, capacity: int = 256, max_record_size: int = _MAX_RECORD_SIZE) -> None:
|
|
48
|
+
self._capacity = capacity
|
|
49
|
+
self._max_record_size = max_record_size
|
|
50
|
+
self._records: deque[LogRecord] = deque()
|
|
51
|
+
self._condition = threading.Condition()
|
|
52
|
+
self._subscriber = False
|
|
53
|
+
self._sealed = False
|
|
54
|
+
self._dropped = 0
|
|
55
|
+
|
|
56
|
+
def submit(self, record: LogRecord) -> None:
|
|
57
|
+
with self._condition:
|
|
58
|
+
if self._sealed:
|
|
59
|
+
return
|
|
60
|
+
if record.ByteSize() > self._max_record_size or len(self._records) >= self._capacity:
|
|
61
|
+
self._dropped += 1
|
|
62
|
+
self._condition.notify_all()
|
|
63
|
+
return
|
|
64
|
+
if self._dropped:
|
|
65
|
+
self._records.append(_drop_record(self._dropped))
|
|
66
|
+
self._dropped = 0
|
|
67
|
+
if len(self._records) >= self._capacity:
|
|
68
|
+
self._dropped += 1
|
|
69
|
+
self._condition.notify_all()
|
|
70
|
+
return
|
|
71
|
+
self._records.append(record)
|
|
72
|
+
self._condition.notify_all()
|
|
73
|
+
|
|
74
|
+
def subscribe(self, active: Callable[[], bool] = lambda: True) -> Iterator[LogRecord]:
|
|
75
|
+
with self._condition:
|
|
76
|
+
if self._subscriber:
|
|
77
|
+
raise RuntimeError("A log subscriber is already connected")
|
|
78
|
+
self._subscriber = True
|
|
79
|
+
try:
|
|
80
|
+
while active():
|
|
81
|
+
with self._condition:
|
|
82
|
+
if self._records:
|
|
83
|
+
record = self._records.popleft()
|
|
84
|
+
self._condition.notify_all()
|
|
85
|
+
elif self._dropped:
|
|
86
|
+
record = _drop_record(self._dropped)
|
|
87
|
+
self._dropped = 0
|
|
88
|
+
self._condition.notify_all()
|
|
89
|
+
elif self._sealed:
|
|
90
|
+
return
|
|
91
|
+
else:
|
|
92
|
+
self._condition.wait(timeout=0.1)
|
|
93
|
+
continue
|
|
94
|
+
yield record
|
|
95
|
+
finally:
|
|
96
|
+
with self._condition:
|
|
97
|
+
self._subscriber = False
|
|
98
|
+
self._condition.notify_all()
|
|
99
|
+
|
|
100
|
+
def seal(self) -> None:
|
|
101
|
+
with self._condition:
|
|
102
|
+
self._sealed = True
|
|
103
|
+
self._condition.notify_all()
|
|
104
|
+
|
|
105
|
+
def wait_empty(self, timeout: float) -> bool:
|
|
106
|
+
deadline = time.monotonic() + timeout
|
|
107
|
+
with self._condition:
|
|
108
|
+
while self._records or self._dropped:
|
|
109
|
+
remaining = deadline - time.monotonic()
|
|
110
|
+
if remaining <= 0:
|
|
111
|
+
return False
|
|
112
|
+
self._condition.wait(timeout=remaining)
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _drop_record(count: int) -> LogRecord:
|
|
117
|
+
return LogRecord(
|
|
118
|
+
time_unix_nano=time.time_ns(),
|
|
119
|
+
severity_number=_severity_number(logging.WARNING),
|
|
120
|
+
severity_text="WARN",
|
|
121
|
+
body=AnyValue(string_value=f"Dropped {count} local log records"),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class _OTLPQueueHandler(logging.Handler):
|
|
126
|
+
def __init__(self, records: _LogQueue) -> None:
|
|
127
|
+
super().__init__(logging.NOTSET)
|
|
128
|
+
self._records = records
|
|
129
|
+
|
|
130
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
131
|
+
try:
|
|
132
|
+
attributes = dict(_record_attributes(record))
|
|
133
|
+
trace_id = _hex_id(attributes.pop("trace_id", ""), 16)
|
|
134
|
+
span_id = _hex_id(attributes.pop("span_id", ""), 8)
|
|
135
|
+
if record.exc_info:
|
|
136
|
+
exctype, value, tb = record.exc_info
|
|
137
|
+
if exctype is not None:
|
|
138
|
+
attributes[exception_attributes.EXCEPTION_TYPE] = exctype.__name__
|
|
139
|
+
if value is not None:
|
|
140
|
+
attributes[exception_attributes.EXCEPTION_MESSAGE] = str(value)
|
|
141
|
+
if tb is not None:
|
|
142
|
+
attributes[exception_attributes.EXCEPTION_STACKTRACE] = "".join(
|
|
143
|
+
traceback.format_exception(*record.exc_info)
|
|
144
|
+
)
|
|
145
|
+
self._records.submit(
|
|
146
|
+
LogRecord(
|
|
147
|
+
time_unix_nano=int(record.created * 1e9),
|
|
148
|
+
severity_number=_severity_number(record.levelno),
|
|
149
|
+
severity_text={"WARNING": "WARN", "CRITICAL": "FATAL"}.get(record.levelname, record.levelname),
|
|
150
|
+
body=_any_value(record.getMessage()),
|
|
151
|
+
attributes=[KeyValue(key=str(key), value=_any_value(value)) for key, value in attributes.items()],
|
|
152
|
+
trace_id=trace_id,
|
|
153
|
+
span_id=span_id,
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
except Exception: # noqa: BLE001 -- logging must never break workflow execution
|
|
157
|
+
self.handleError(record)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _hex_id(value: Any, size: int) -> bytes:
|
|
161
|
+
try:
|
|
162
|
+
result = bytes.fromhex(value) if isinstance(value, str) else b""
|
|
163
|
+
except ValueError:
|
|
164
|
+
return b""
|
|
165
|
+
return result if len(result) == size else b""
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _severity_number(level: int) -> SeverityNumber.ValueType:
|
|
169
|
+
return SeverityNumber.Value(f"SEVERITY_NUMBER_{std_to_otel(level).name}")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
managed_log_queue = _LogQueue() if _MANAGED_RUNTIME else None
|
{tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/observability/logging.py
RENAMED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
# allow the logging module name which shadows the builtin:
|
|
2
|
-
import atexit
|
|
3
2
|
import contextlib
|
|
4
3
|
import logging
|
|
5
4
|
import os
|
|
@@ -8,18 +7,20 @@ import re
|
|
|
8
7
|
import sys
|
|
9
8
|
import threading
|
|
10
9
|
import traceback
|
|
10
|
+
from contextvars import ContextVar
|
|
11
11
|
from datetime import timedelta
|
|
12
12
|
from importlib.metadata import PackageNotFoundError, version
|
|
13
13
|
from typing import Any, ClassVar, TextIO
|
|
14
14
|
from uuid import UUID, uuid4
|
|
15
15
|
|
|
16
16
|
import msgspec
|
|
17
|
+
from opentelemetry._logs import LogRecord, get_logger_provider
|
|
18
|
+
from opentelemetry._logs import get_logger as get_otel_logger
|
|
17
19
|
from opentelemetry.exporter.otlp.proto.http._log_exporter import (
|
|
18
20
|
DEFAULT_LOGS_EXPORT_PATH,
|
|
19
21
|
OTLPLogExporter,
|
|
20
|
-
_append_logs_path,
|
|
21
22
|
)
|
|
22
|
-
from opentelemetry.instrumentation.
|
|
23
|
+
from opentelemetry.instrumentation.log_utils import std_to_otel
|
|
23
24
|
from opentelemetry.sdk._logs import LoggerProvider
|
|
24
25
|
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
|
|
25
26
|
from opentelemetry.sdk.resources import (
|
|
@@ -34,10 +35,9 @@ from opentelemetry.sdk.resources import (
|
|
|
34
35
|
Resource,
|
|
35
36
|
)
|
|
36
37
|
from opentelemetry.semconv.attributes import exception_attributes
|
|
37
|
-
from opentelemetry.util.types import _ExtendedAttributes
|
|
38
38
|
|
|
39
39
|
from tilebox.workflows._serialization import normalize_log_value
|
|
40
|
-
from tilebox.workflows.observability.
|
|
40
|
+
from tilebox.workflows.observability._log_stream import _OTLPQueueHandler, managed_log_queue
|
|
41
41
|
from tilebox.workflows.observability._logging import (
|
|
42
42
|
StructuredLogger as StructuredLogger, # noqa: PLC0414 -- public compatibility alias
|
|
43
43
|
)
|
|
@@ -64,13 +64,7 @@ _instance_id = str(UUID(_instance_id))
|
|
|
64
64
|
|
|
65
65
|
|
|
66
66
|
def _get_default_resource(service: str | Resource | None = None) -> Resource:
|
|
67
|
-
|
|
68
|
-
service_name = service.attributes.get(SERVICE_NAME)
|
|
69
|
-
if service_name is not None and service_name != "unknown_service":
|
|
70
|
-
# default value of SERVICE_NAME is "unknown_service", so if we have anything other than that we
|
|
71
|
-
# know it's already configured
|
|
72
|
-
return service
|
|
73
|
-
|
|
67
|
+
"""Build Tilebox defaults, then apply explicitly configured resource attributes."""
|
|
74
68
|
service_name = service if isinstance(service, str) else _DEFAULT_SERVICE_NAME
|
|
75
69
|
|
|
76
70
|
workflows_version = "dev"
|
|
@@ -78,7 +72,7 @@ def _get_default_resource(service: str | Resource | None = None) -> Resource:
|
|
|
78
72
|
workflows_version = version("tilebox-workflows")
|
|
79
73
|
|
|
80
74
|
uname = platform.uname()
|
|
81
|
-
|
|
75
|
+
resource = Resource.create(
|
|
82
76
|
attributes={
|
|
83
77
|
SERVICE_NAMESPACE: "tilebox.workflows",
|
|
84
78
|
SERVICE_NAME: service_name,
|
|
@@ -90,6 +84,7 @@ def _get_default_resource(service: str | Resource | None = None) -> Resource:
|
|
|
90
84
|
OS_TYPE: uname.system.lower(),
|
|
91
85
|
}
|
|
92
86
|
)
|
|
87
|
+
return resource.merge(service) if isinstance(service, Resource) else resource
|
|
93
88
|
|
|
94
89
|
|
|
95
90
|
def _sanitize_otel_attribute_value(
|
|
@@ -110,15 +105,43 @@ def _sanitize_otel_attributes(attributes: dict[str, Any]) -> dict[str, Any]:
|
|
|
110
105
|
return {str(key): _sanitize_otel_attribute_value(value) for key, value in attributes.items()}
|
|
111
106
|
|
|
112
107
|
|
|
113
|
-
class OTELLoggingHandler(
|
|
114
|
-
def
|
|
108
|
+
class OTELLoggingHandler(logging.Handler):
|
|
109
|
+
def __init__(self, level: int = logging.NOTSET, logger_provider: LoggerProvider | None = None) -> None:
|
|
110
|
+
super().__init__(level)
|
|
111
|
+
self._logger_provider = logger_provider or get_logger_provider()
|
|
112
|
+
self._emitting = ContextVar("tilebox_otel_emitting", default=False)
|
|
113
|
+
|
|
114
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
115
|
+
# Export processors can log themselves; do not recursively export those records.
|
|
116
|
+
if self._emitting.get():
|
|
117
|
+
return
|
|
118
|
+
token = self._emitting.set(True)
|
|
119
|
+
try:
|
|
120
|
+
get_otel_logger(record.name, logger_provider=self._logger_provider).emit(
|
|
121
|
+
LogRecord(
|
|
122
|
+
timestamp=int(record.created * 1e9),
|
|
123
|
+
severity_number=std_to_otel(record.levelno),
|
|
124
|
+
severity_text={"WARNING": "WARN", "CRITICAL": "FATAL"}.get(record.levelname, record.levelname),
|
|
125
|
+
body=self.format(record) if self.formatter else record.getMessage(),
|
|
126
|
+
attributes=self._get_attributes(record),
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
finally:
|
|
130
|
+
self._emitting.reset(token)
|
|
131
|
+
|
|
132
|
+
def flush(self) -> None:
|
|
133
|
+
if callable(force_flush := getattr(self._logger_provider, "force_flush", None)):
|
|
134
|
+
# Match OTEL's handler: flushing under the logging lock can deadlock.
|
|
135
|
+
# Do not prolong process exit after a runtime's bounded final flush.
|
|
136
|
+
threading.Thread(target=force_flush, daemon=True).start()
|
|
137
|
+
|
|
138
|
+
def _get_attributes(self, record: logging.LogRecord) -> dict[str, Any]:
|
|
115
139
|
cached = getattr(record, "_tilebox_otel_attributes", None)
|
|
116
140
|
if cached is not None:
|
|
117
141
|
return cached
|
|
118
142
|
attributes = _sanitize_otel_attributes(_record_attributes(record))
|
|
119
143
|
|
|
120
|
-
#
|
|
121
|
-
# we don't want that by default, so we override it to return an empty dict
|
|
144
|
+
# Export only task context and exception details, not stdlib logging metadata.
|
|
122
145
|
if record.exc_info:
|
|
123
146
|
exctype, value, tb = record.exc_info
|
|
124
147
|
if exctype is not None:
|
|
@@ -138,10 +161,12 @@ class OTELLoggingHandler(LoggingHandler):
|
|
|
138
161
|
|
|
139
162
|
_api_handler: OTELLoggingHandler | None = None
|
|
140
163
|
_initialization_lock = threading.Lock()
|
|
141
|
-
_writer: _PipeWriter | None = None
|
|
142
164
|
_console_handlers: list[logging.Handler] = []
|
|
143
165
|
_console_configured = False
|
|
144
166
|
|
|
167
|
+
if managed_log_queue is not None:
|
|
168
|
+
root_logger.addHandler(_OTLPQueueHandler(managed_log_queue))
|
|
169
|
+
|
|
145
170
|
|
|
146
171
|
def _remove_console_handlers() -> None:
|
|
147
172
|
for handler in _console_handlers:
|
|
@@ -158,27 +183,13 @@ def _add_console_handler(level: int, stream: TextIO, formatter: logging.Formatte
|
|
|
158
183
|
_console_handlers.append(handler)
|
|
159
184
|
|
|
160
185
|
|
|
161
|
-
def _configure_runtime_logging() ->
|
|
162
|
-
"""
|
|
163
|
-
|
|
164
|
-
The CLI pipe is runtime-owned: public configuration can add outputs but cannot
|
|
165
|
-
disable or replace it. Console opt-out applies only to console handlers.
|
|
166
|
-
"""
|
|
167
|
-
global _writer # noqa: PLW0603 -- process-owned CLI pipe
|
|
186
|
+
def _configure_runtime_logging() -> None:
|
|
187
|
+
"""Add an optional default console when logs aren't streamed to a managed CLI."""
|
|
168
188
|
with _initialization_lock:
|
|
169
|
-
|
|
170
|
-
if fd_value is not None:
|
|
171
|
-
if _writer is None:
|
|
172
|
-
_writer = _PipeWriter(int(fd_value))
|
|
173
|
-
root_logger.addHandler(_StructuredHandler(_writer))
|
|
174
|
-
atexit.register(_writer.close)
|
|
175
|
-
if not _console_configured:
|
|
176
|
-
_remove_console_handlers()
|
|
177
|
-
elif not _console_configured and not _console_handlers:
|
|
189
|
+
if managed_log_queue is None and not _console_configured and not _console_handlers:
|
|
178
190
|
_add_console_handler(
|
|
179
191
|
logging.NOTSET, sys.stdout, logging.Formatter("%(process)d: %(levelname)s: %(message)s")
|
|
180
192
|
)
|
|
181
|
-
return _writer
|
|
182
193
|
|
|
183
194
|
|
|
184
195
|
def configure_log_level(level: int = logging.INFO, *, tilebox_debug: bool = False) -> None:
|
|
@@ -191,7 +202,7 @@ def configure_log_level(level: int = logging.INFO, *, tilebox_debug: bool = Fals
|
|
|
191
202
|
filtered out by the logger.
|
|
192
203
|
|
|
193
204
|
Every call overrides both startup settings: omitting tilebox_debug disables internal
|
|
194
|
-
DEBUG logging even if TILEBOX_DEBUG was enabled. Existing API exports, the CLI
|
|
205
|
+
DEBUG logging even if TILEBOX_DEBUG was enabled. Existing API exports, the CLI stream,
|
|
195
206
|
and console handlers are unchanged and retain their own output thresholds.
|
|
196
207
|
|
|
197
208
|
Args:
|
|
@@ -215,7 +226,12 @@ def initialize_logging(url: str, token: str | None, service: str | None = None)
|
|
|
215
226
|
if _api_handler is not None:
|
|
216
227
|
return
|
|
217
228
|
|
|
218
|
-
|
|
229
|
+
# CLI runtimes own the final, bounded flush. An additional OTEL atexit
|
|
230
|
+
# shutdown could wait indefinitely on an exporter after that deadline.
|
|
231
|
+
provider = LoggerProvider(
|
|
232
|
+
resource=_get_default_resource(service),
|
|
233
|
+
shutdown_on_exit=not bool(os.environ.get("TILEBOX_WORKER_ADDRESS")),
|
|
234
|
+
)
|
|
219
235
|
processor = _otel_log_exporter(endpoint=url, headers={"Authorization": f"Bearer {token}"} if token else None)
|
|
220
236
|
provider.add_log_record_processor(processor)
|
|
221
237
|
handler = OTELLoggingHandler(level=logging.NOTSET, logger_provider=provider)
|
|
@@ -223,6 +239,20 @@ def initialize_logging(url: str, token: str | None, service: str | None = None)
|
|
|
223
239
|
_api_handler = handler
|
|
224
240
|
|
|
225
241
|
|
|
242
|
+
def flush_api_logging(timeout_millis: int = 1000) -> None:
|
|
243
|
+
"""Flush the managed API exporter without waiting indefinitely."""
|
|
244
|
+
handler = _api_handler
|
|
245
|
+
if handler is None:
|
|
246
|
+
return
|
|
247
|
+
force_flush = getattr(handler._logger_provider, "force_flush", None) # noqa: SLF001
|
|
248
|
+
if callable(force_flush):
|
|
249
|
+
# BatchLogRecordProcessor currently ignores its timeout argument.
|
|
250
|
+
# Bound our wait without making the export thread block process exit.
|
|
251
|
+
flush = threading.Thread(target=force_flush, kwargs={"timeout_millis": timeout_millis}, daemon=True)
|
|
252
|
+
flush.start()
|
|
253
|
+
flush.join(timeout=timeout_millis / 1000)
|
|
254
|
+
|
|
255
|
+
|
|
226
256
|
def _otel_log_exporter(
|
|
227
257
|
endpoint: str | None = None,
|
|
228
258
|
headers: dict[str, str] | None = None,
|
|
@@ -237,7 +267,7 @@ def _otel_log_exporter(
|
|
|
237
267
|
)
|
|
238
268
|
|
|
239
269
|
if not endpoint.endswith(DEFAULT_LOGS_EXPORT_PATH):
|
|
240
|
-
endpoint =
|
|
270
|
+
endpoint = f"{endpoint.rstrip('/')}/{DEFAULT_LOGS_EXPORT_PATH}"
|
|
241
271
|
|
|
242
272
|
if export_interval is None:
|
|
243
273
|
export_interval_env = os.environ.get(_OTEL_EXPORT_INTERVAL_ENV_VAR, None)
|
|
@@ -266,14 +296,12 @@ def configure_otel_logging(
|
|
|
266
296
|
This will configure a logging handler that will send log messages to an OTLP compatible endpoint using the
|
|
267
297
|
open telemetry protocol for exporting logs. The logging handler will be attached to the root tilebox logger.
|
|
268
298
|
All loggers created using `get_logger()` will therefore inherit this handler configuration.
|
|
269
|
-
Each call adds an export; Tilebox's API export, the CLI
|
|
299
|
+
Each call adds an export; Tilebox's API export, the CLI stream, and console outputs remain installed.
|
|
270
300
|
|
|
271
301
|
Args:
|
|
272
|
-
service: A
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
the version set to the version of the package, and the service instance id set to a combination
|
|
276
|
-
of hostname and process id.
|
|
302
|
+
service: A service name or resource whose attributes override the Tilebox defaults.
|
|
303
|
+
Defaults to service name "tilebox-python", namespace "tilebox.workflows", the installed package version,
|
|
304
|
+
and the CLI runtime ID or a process-local UUID shared by logs and traces.
|
|
277
305
|
level: The logging level to use for the OTEL handler. Only log messages with a level higher or equal to
|
|
278
306
|
this will be sent to the endpoint. Defaults to logging.DEBUG. It is typically recommended to keep this at a
|
|
279
307
|
lower level, since actual filtering of log messages to higher levels is typically done by the logger itself.
|
|
@@ -313,11 +341,9 @@ def configure_otel_logging_axiom(
|
|
|
313
341
|
configuration.
|
|
314
342
|
|
|
315
343
|
Args:
|
|
316
|
-
service: A
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
the version set to the version of the package, and the service instance id set to a combination
|
|
320
|
-
of hostname and process id.
|
|
344
|
+
service: A service name or resource whose attributes override the Tilebox defaults.
|
|
345
|
+
Defaults to service name "tilebox-python", namespace "tilebox.workflows", the installed package version,
|
|
346
|
+
and the CLI runtime ID or a process-local UUID shared by logs and traces.
|
|
321
347
|
level: The logging level to use for the Axiom log handler. Only log messages with a level higher or equal to
|
|
322
348
|
this will be sent to the endpoint. Defaults to logging.DEBUG. It is typically recommended to keep this at a
|
|
323
349
|
lower level, since actual filtering of log messages to higher levels is typically done by the logger itself.
|
|
@@ -397,7 +423,7 @@ def configure_console_logging(
|
|
|
397
423
|
configured console logging handlers will be removed. If False, the existing handlers will be kept. Useful
|
|
398
424
|
if you want to log to multiple consoles.
|
|
399
425
|
enabled: If False, remove Tilebox-managed console handlers and disable automatic console output, even if
|
|
400
|
-
called before runner startup. API exports, the CLI log
|
|
426
|
+
called before runner startup. API exports, the CLI log stream, and user-installed handlers are unaffected.
|
|
401
427
|
"""
|
|
402
428
|
global _console_configured # noqa: PLW0603 -- explicit process-wide console policy
|
|
403
429
|
with _initialization_lock:
|
{tilebox_workflows-0.63.2 → tilebox_workflows-0.64.0}/tilebox/workflows/observability/tracing.py
RENAMED
|
@@ -8,7 +8,6 @@ from typing import Any, Protocol, cast
|
|
|
8
8
|
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
|
|
9
9
|
DEFAULT_TRACES_EXPORT_PATH,
|
|
10
10
|
OTLPSpanExporter,
|
|
11
|
-
_append_trace_path,
|
|
12
11
|
)
|
|
13
12
|
from opentelemetry.sdk.resources import Resource
|
|
14
13
|
from opentelemetry.sdk.trace import TracerProvider
|
|
@@ -46,7 +45,7 @@ _OTEL_EXPORT_INTERVAL_ENV_VAR = "OTEL_EXPORT_INTERVAL"
|
|
|
46
45
|
# to extract trace_ids and spans, in case other runners / workflow clients have tracing configured.
|
|
47
46
|
# So instead we use a tracer provider without any exporters, which will still create traces and spans,
|
|
48
47
|
# but will not send them anywhere.
|
|
49
|
-
_tilebox_tracer_provider = TracerProvider()
|
|
48
|
+
_tilebox_tracer_provider = TracerProvider(resource=_get_default_resource())
|
|
50
49
|
_workflow_tracers = []
|
|
51
50
|
|
|
52
51
|
|
|
@@ -159,7 +158,7 @@ def _otel_span_exporter(
|
|
|
159
158
|
)
|
|
160
159
|
|
|
161
160
|
if not endpoint.endswith(DEFAULT_TRACES_EXPORT_PATH):
|
|
162
|
-
endpoint =
|
|
161
|
+
endpoint = f"{endpoint.rstrip('/')}/{DEFAULT_TRACES_EXPORT_PATH}"
|
|
163
162
|
|
|
164
163
|
if export_interval is None:
|
|
165
164
|
export_interval_env = os.environ.get(_OTEL_EXPORT_INTERVAL_ENV_VAR, None)
|
|
@@ -232,11 +231,9 @@ def configure_otel_tracing(
|
|
|
232
231
|
Additionally, this will also configure a logging handler that will add log messages to active spans as span events.
|
|
233
232
|
|
|
234
233
|
Args:
|
|
235
|
-
service: A
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
the version set to the version of the package, and the service instance id set to a combination
|
|
239
|
-
of hostname and process id.
|
|
234
|
+
service: A service name or resource whose attributes override the Tilebox defaults.
|
|
235
|
+
Defaults to service name "tilebox-python", namespace "tilebox.workflows", the installed package version,
|
|
236
|
+
and the CLI runtime ID or a process-local UUID shared by logs and traces.
|
|
240
237
|
endpoint: The URL of the OTLP compatible endpoint to send traces and spans to. If not provided, the environment
|
|
241
238
|
variable OTEL_TRACES_ENDPOINT will be used. If that is not set either, an error will be raised.
|
|
242
239
|
OTLP compatible endpoints typically have the path name "/v1/traces". If the specified endpoint does not
|
|
@@ -280,11 +277,9 @@ def configure_otel_tracing_axiom(
|
|
|
280
277
|
send traces and spans to Axiom.
|
|
281
278
|
|
|
282
279
|
Args:
|
|
283
|
-
service: A
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
the version set to the version of the package, and the service instance id set to a combination
|
|
287
|
-
of hostname and process id.
|
|
280
|
+
service: A service name or resource whose attributes override the Tilebox defaults.
|
|
281
|
+
Defaults to service name "tilebox-python", namespace "tilebox.workflows", the installed package version,
|
|
282
|
+
and the CLI runtime ID or a process-local UUID shared by logs and traces.
|
|
288
283
|
dataset: The name of the Axiom dataset to ingest traces into. If not provided, the environment variable
|
|
289
284
|
AXIOM_TRACES_DATASET will be used. If that is not set either, an error will be raised.
|
|
290
285
|
api_key: The API key to use for authentication. If not provided, the environment variable AXIOM_API_KEY will be
|
|
@@ -6,7 +6,7 @@ from typing import Any
|
|
|
6
6
|
|
|
7
7
|
from tilebox.workflows.observability._logging import logger
|
|
8
8
|
from tilebox.workflows.runner.runner import Runner
|
|
9
|
-
from tilebox.workflows.runner.worker_server import
|
|
9
|
+
from tilebox.workflows.runner.worker_server import WorkerServer
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
@@ -17,10 +17,15 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
17
17
|
parser.add_argument("runner", help="Runner object import path, for example 'my_workflow.runner:runner'.")
|
|
18
18
|
args = parser.parse_args(argv)
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
server = WorkerServer()
|
|
21
|
+
server.start()
|
|
22
|
+
try:
|
|
23
|
+
runner = _import_runner(args.runner)
|
|
24
|
+
server.set_runner(runner)
|
|
25
|
+
server.wait()
|
|
26
|
+
finally:
|
|
27
|
+
server.shutdown()
|
|
28
|
+
server.wait()
|
|
24
29
|
return 0
|
|
25
30
|
|
|
26
31
|
|