onedata-lambda-sdk 1.0.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.
- onedata_lambda_sdk/__init__.py +60 -0
- onedata_lambda_sdk/_wire.py +111 -0
- onedata_lambda_sdk/job.py +119 -0
- onedata_lambda_sdk/logging.py +99 -0
- onedata_lambda_sdk/oneclient.py +45 -0
- onedata_lambda_sdk/perjob.py +122 -0
- onedata_lambda_sdk/py.typed +0 -0
- onedata_lambda_sdk/runtime.py +388 -0
- onedata_lambda_sdk/stats.py +53 -0
- onedata_lambda_sdk/streaming.py +158 -0
- onedata_lambda_sdk/testing.py +316 -0
- onedata_lambda_sdk/types.py +179 -0
- onedata_lambda_sdk-1.0.0.dist-info/METADATA +103 -0
- onedata_lambda_sdk-1.0.0.dist-info/RECORD +16 -0
- onedata_lambda_sdk-1.0.0.dist-info/WHEEL +4 -0
- onedata_lambda_sdk-1.0.0.dist-info/licenses/LICENSE.txt +24 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SDK for writing Onedata Automation lambdas.
|
|
3
|
+
|
|
4
|
+
Public surface: the handler API (`Job`, `JobContext`, `JobException`), the contract
|
|
5
|
+
runtime (`run`, `per_job`), the Onedata wire/contract field types (the `Atm*` names),
|
|
6
|
+
the time-series measurement builder, and the mounted-Oneclient helpers.
|
|
7
|
+
|
|
8
|
+
Naming convention: `Atm*` marks the Onedata Automation *contract* -- the JSON shapes that
|
|
9
|
+
cross the wire to/from the provider (`AtmFile`, `AtmException`, …). Everything the SDK adds
|
|
10
|
+
purely as authoring ergonomics (`Job`, `JobContext`, `JobException`, `ResultStreamer`,
|
|
11
|
+
`TimeSeriesMeasurementBuilder`, …) is SDK-local and carries no prefix.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .job import Job, JobContext, JobException
|
|
15
|
+
from .oneclient import mount_point, mounted_file_path
|
|
16
|
+
from .perjob import DEFAULT_MAX_WORKERS, per_job
|
|
17
|
+
from .runtime import run
|
|
18
|
+
from .stats import TimeSeriesMeasurementBuilder
|
|
19
|
+
from .types import (
|
|
20
|
+
AtmDataset,
|
|
21
|
+
AtmException,
|
|
22
|
+
AtmFile,
|
|
23
|
+
AtmGroup,
|
|
24
|
+
AtmObject,
|
|
25
|
+
AtmRange,
|
|
26
|
+
AtmTimeSeriesMeasurement,
|
|
27
|
+
FileType,
|
|
28
|
+
InheritancePath,
|
|
29
|
+
LogLevel,
|
|
30
|
+
ProtectionFlag,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
# runtime
|
|
36
|
+
"run",
|
|
37
|
+
"per_job",
|
|
38
|
+
"DEFAULT_MAX_WORKERS",
|
|
39
|
+
# handler API
|
|
40
|
+
"Job",
|
|
41
|
+
"JobContext",
|
|
42
|
+
"JobException",
|
|
43
|
+
# mounted Oneclient helpers
|
|
44
|
+
"mount_point",
|
|
45
|
+
"mounted_file_path",
|
|
46
|
+
# wire/contract field types (Atm*)
|
|
47
|
+
"AtmException",
|
|
48
|
+
"AtmObject",
|
|
49
|
+
"AtmDataset",
|
|
50
|
+
"AtmFile",
|
|
51
|
+
"AtmGroup",
|
|
52
|
+
"AtmRange",
|
|
53
|
+
"AtmTimeSeriesMeasurement",
|
|
54
|
+
"FileType",
|
|
55
|
+
"ProtectionFlag",
|
|
56
|
+
"InheritancePath",
|
|
57
|
+
"LogLevel",
|
|
58
|
+
# builders
|
|
59
|
+
"TimeSeriesMeasurementBuilder",
|
|
60
|
+
]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Internal wire types: the raw JSON shapes exchanged between Oneprovider and a lambda.
|
|
3
|
+
|
|
4
|
+
These model the OpenFaaS job-batch request/response protocol verbatim (camelCase, as
|
|
5
|
+
sent on the wire). They are an implementation detail parsed by `run()` and are NOT
|
|
6
|
+
part of the public handler API -- handlers work with `Job` / `JobContext`
|
|
7
|
+
instead (see `job`). The full request context remains reachable as an escape hatch
|
|
8
|
+
via `JobContext.raw`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__author__ = "Bartosz Walkowicz"
|
|
12
|
+
__copyright__ = "Copyright (C) 2026 Onedata (onedata.org)"
|
|
13
|
+
__license__ = "This software is released under the MIT license cited in LICENSE.txt"
|
|
14
|
+
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from typing import TypedDict
|
|
17
|
+
|
|
18
|
+
from .types import AtmException, LogLevel
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AtmJobBatchRequestCtx[C](TypedDict):
|
|
22
|
+
"""
|
|
23
|
+
JSON object describing job batch request context.
|
|
24
|
+
|
|
25
|
+
Fields
|
|
26
|
+
--------
|
|
27
|
+
userId
|
|
28
|
+
Id of the scheduling user.
|
|
29
|
+
spaceId
|
|
30
|
+
Id of the space in context of which the workflow is executed.
|
|
31
|
+
atmWorkflowExecutionId
|
|
32
|
+
Workflow execution Id.
|
|
33
|
+
oneproviderDomain
|
|
34
|
+
Domain of job scheduling Oneprovider.
|
|
35
|
+
oneproviderId
|
|
36
|
+
Id of job scheduling Oneprovider.
|
|
37
|
+
onezoneDomain
|
|
38
|
+
The domain of the Onezone service that manages the Onedata environment.
|
|
39
|
+
accessToken
|
|
40
|
+
Token used to authorize operations in Onedata.
|
|
41
|
+
|
|
42
|
+
Refer to the API specification for more information:
|
|
43
|
+
https://onedata.org/#/home/documentation/latest/doc/using_onedata/tokens[access-tokens].html
|
|
44
|
+
heartbeatUrl
|
|
45
|
+
The target url for sending heartbeats.
|
|
46
|
+
timeoutSeconds
|
|
47
|
+
The time interval from last heartbeat after which job batch is marked as failed.
|
|
48
|
+
logLevel
|
|
49
|
+
Level controlling the amount of information recorded in audit logs as only logs
|
|
50
|
+
with severity equal or higher to this level will be stored.
|
|
51
|
+
config
|
|
52
|
+
User defined task configuration.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
userId: str
|
|
56
|
+
spaceId: str
|
|
57
|
+
atmWorkflowExecutionId: str
|
|
58
|
+
oneproviderDomain: str
|
|
59
|
+
oneproviderId: str
|
|
60
|
+
onezoneDomain: str
|
|
61
|
+
accessToken: str
|
|
62
|
+
heartbeatUrl: str
|
|
63
|
+
timeoutSeconds: int
|
|
64
|
+
logLevel: LogLevel
|
|
65
|
+
config: C
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AtmJobMeta(TypedDict):
|
|
69
|
+
"""Per-job metadata the framework attaches to each job's args object on the wire."""
|
|
70
|
+
|
|
71
|
+
traceId: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# Functional syntax because `__meta` is a dunder key (a class-body annotation would be
|
|
75
|
+
# name-mangled). Each wire args object also carries the lambda's own argument keys
|
|
76
|
+
# alongside `__meta`; the runtime strips `__meta` off (-> Job.trace_id) and hands the
|
|
77
|
+
# rest to the handler as the job's args.
|
|
78
|
+
AtmJobArgs = TypedDict("AtmJobArgs", {"__meta": AtmJobMeta})
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class AtmJobBatchRequest[C](TypedDict):
|
|
82
|
+
"""
|
|
83
|
+
JSON object describing job batch request.
|
|
84
|
+
|
|
85
|
+
Fields
|
|
86
|
+
--------
|
|
87
|
+
ctx
|
|
88
|
+
Context of job batch request.
|
|
89
|
+
argsBatch
|
|
90
|
+
List of per-job args objects. Each carries a `__meta` envelope (the job's
|
|
91
|
+
`traceId`) alongside the lambda's own input arguments.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
ctx: AtmJobBatchRequestCtx[C]
|
|
95
|
+
argsBatch: list[AtmJobArgs]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class AtmJobBatchResponse[R](TypedDict):
|
|
99
|
+
"""
|
|
100
|
+
JSON object describing job batch response.
|
|
101
|
+
|
|
102
|
+
Fields
|
|
103
|
+
--------
|
|
104
|
+
resultsBatch
|
|
105
|
+
List of JSON objects, each carrying results of a single job (lambda output).
|
|
106
|
+
The length of this list must be equal to the length of the corresponding
|
|
107
|
+
AtmJobBatchRequest.argsBatch. However, if the lambda returns empty results for
|
|
108
|
+
each job, the resultsBatch can be set to None.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
resultsBatch: Sequence[AtmException | R | None] | None
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The handler API: the per-job unit (`Job`), the shared batch context (`JobContext`), and
|
|
3
|
+
the failure signal (`JobException`) -- what a handler signs against.
|
|
4
|
+
|
|
5
|
+
These are SDK-local authoring abstractions, not the wire contract, so they carry no `Atm`
|
|
6
|
+
prefix (unlike `AtmFile`, `AtmException`, … which mirror what crosses the wire).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__author__ = "Bartosz Walkowicz"
|
|
10
|
+
__copyright__ = "Copyright (C) 2026 Onedata (onedata.org)"
|
|
11
|
+
__license__ = "This software is released under the MIT license cited in LICENSE.txt"
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from .types import LogLevel
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from ._wire import AtmJobBatchRequestCtx
|
|
22
|
+
from .logging import Logger
|
|
23
|
+
from .streaming import ResultStreamer
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class JobException(Exception):
|
|
27
|
+
"""
|
|
28
|
+
Raised by a handler to fail a job with a known, message-only error.
|
|
29
|
+
|
|
30
|
+
The SDK turns it into an `AtmException` entry carrying just `str(exception)` -- no
|
|
31
|
+
traceback -- so an expected failure (bad config, a failed REST call)
|
|
32
|
+
reads cleanly in the results. Any *other* exception is treated as an unexpected bug
|
|
33
|
+
and reported with a full traceback instead.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Job[A]:
|
|
39
|
+
"""
|
|
40
|
+
A single unit of work passed to a handler.
|
|
41
|
+
|
|
42
|
+
`args` is the job's input (the lambda's typed arguments). `trace_id` identifies this
|
|
43
|
+
job across logs and streamed results; it comes from the wire (the framework assigns
|
|
44
|
+
it) and lives on the job -- not on the shared context, which is common to all jobs of
|
|
45
|
+
a batch, including in parallel.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
args: A
|
|
49
|
+
trace_id: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class JobContext[C]:
|
|
53
|
+
"""
|
|
54
|
+
The shared, per-batch context handed to a handler alongside its jobs.
|
|
55
|
+
|
|
56
|
+
Carries the (typed) task `config`, read-only identity/infrastructure fields, and the
|
|
57
|
+
runtime services. Services (`logger(name)`, `heartbeat()`, `result_streamer(name)`)
|
|
58
|
+
are vended by the context rather than imported globally, so they can hook into the
|
|
59
|
+
runtime (heartbeat-on-flush, the shared streaming flusher, dev-mode). They are
|
|
60
|
+
thread-safe; per-job state must never be stored here -- it travels on `Job`.
|
|
61
|
+
|
|
62
|
+
There is no default log stream: a lambda picks its stream names itself (and declares
|
|
63
|
+
them in its workflow schema), so `logger(name)` requires the name explicitly -- just
|
|
64
|
+
like `result_streamer(name)`. "logs" is only a common convention, not a requirement.
|
|
65
|
+
|
|
66
|
+
`raw` is an escape hatch to the full wire request context for fields not surfaced
|
|
67
|
+
here. The runtime (`run()`) constructs this; handlers only read it.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
*,
|
|
73
|
+
config: C,
|
|
74
|
+
user_id: str,
|
|
75
|
+
space_id: str,
|
|
76
|
+
workflow_execution_id: str,
|
|
77
|
+
oneprovider_id: str,
|
|
78
|
+
oneprovider_domain: str,
|
|
79
|
+
onezone_domain: str,
|
|
80
|
+
access_token: str,
|
|
81
|
+
timeout_seconds: int,
|
|
82
|
+
log_level: LogLevel,
|
|
83
|
+
heartbeat: Callable[[], None],
|
|
84
|
+
result_streamer_factory: "Callable[..., ResultStreamer]",
|
|
85
|
+
logger_factory: "Callable[[str], Logger]",
|
|
86
|
+
raw: "AtmJobBatchRequestCtx[C]",
|
|
87
|
+
) -> None:
|
|
88
|
+
self.config = config
|
|
89
|
+
self.user_id = user_id
|
|
90
|
+
self.space_id = space_id
|
|
91
|
+
self.workflow_execution_id = workflow_execution_id
|
|
92
|
+
self.oneprovider_id = oneprovider_id
|
|
93
|
+
self.oneprovider_domain = oneprovider_domain
|
|
94
|
+
self.onezone_domain = onezone_domain
|
|
95
|
+
self.access_token = access_token
|
|
96
|
+
self.timeout_seconds = timeout_seconds
|
|
97
|
+
self.log_level = log_level
|
|
98
|
+
self.raw = raw
|
|
99
|
+
self._heartbeat = heartbeat
|
|
100
|
+
self._result_streamer_factory = result_streamer_factory
|
|
101
|
+
self._logger_factory = logger_factory
|
|
102
|
+
|
|
103
|
+
def heartbeat(self) -> None:
|
|
104
|
+
"""Signal progress for the current job (throttled by the runtime)."""
|
|
105
|
+
self._heartbeat()
|
|
106
|
+
|
|
107
|
+
def result_streamer(self, name: str, *, buffered: bool = True) -> "ResultStreamer":
|
|
108
|
+
"""Open a result/measurement stream by name (buffered by default)."""
|
|
109
|
+
return self._result_streamer_factory(name, buffered=buffered)
|
|
110
|
+
|
|
111
|
+
def logger(self, name: str) -> "Logger":
|
|
112
|
+
"""
|
|
113
|
+
Open an audit-log stream by name, filtered to the batch's log level.
|
|
114
|
+
|
|
115
|
+
The name is the lambda's choice (declare it in the workflow schema); "logs" is a
|
|
116
|
+
common convention, not a requirement. Logs are written unbuffered, so they are
|
|
117
|
+
kept even if the handler crashes mid-batch.
|
|
118
|
+
"""
|
|
119
|
+
return self._logger_factory(name)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Audit logging for lambdas: `Logger`.
|
|
3
|
+
|
|
4
|
+
A thin layer over a result stream that tags each entry with a severity and drops entries
|
|
5
|
+
below the configured level. Vended by `JobContext.logger(name)`; the runtime builds it
|
|
6
|
+
on the lambda's chosen log stream with the batch's `log_level` and wires it to the shared
|
|
7
|
+
streaming machinery (so it inherits the streamer's thread-safety).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__author__ = "Bartosz Walkowicz"
|
|
11
|
+
__copyright__ = "Copyright (C) 2023-2026 Onedata (onedata.org)"
|
|
12
|
+
__license__ = "This software is released under the MIT license cited in LICENSE.txt"
|
|
13
|
+
|
|
14
|
+
from collections.abc import Callable, Iterable
|
|
15
|
+
from typing import Any, Final
|
|
16
|
+
|
|
17
|
+
from .streaming import ResultStreamer, StreamFlusher
|
|
18
|
+
from .types import AtmObject
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Severity -> numeric level. Higher number == more verbose; a logger keeps entries
|
|
22
|
+
# whose level is at or below its threshold (i.e. of equal-or-higher importance).
|
|
23
|
+
LOG_LEVELS: Final[dict[str, int]] = {
|
|
24
|
+
"debug": 7,
|
|
25
|
+
"info": 6,
|
|
26
|
+
"notice": 5,
|
|
27
|
+
"warning": 4,
|
|
28
|
+
"error": 3,
|
|
29
|
+
"critical": 2,
|
|
30
|
+
"alert": 1,
|
|
31
|
+
"emergency": 0,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
DEFAULT_LOG_LEVEL: Final[str] = "info"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def normalize_severity(severity: str) -> str:
|
|
38
|
+
return severity if severity in LOG_LEVELS else DEFAULT_LOG_LEVEL
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Logger(ResultStreamer[AtmObject]):
|
|
42
|
+
"""A severity-filtered result streamer for `{severity, content}` log entries."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
result_name: str,
|
|
48
|
+
buffered: bool,
|
|
49
|
+
flusher: StreamFlusher,
|
|
50
|
+
heartbeat: Callable[[], None],
|
|
51
|
+
out_dir: str | None = None,
|
|
52
|
+
log_level: str = DEFAULT_LOG_LEVEL,
|
|
53
|
+
) -> None:
|
|
54
|
+
super().__init__(
|
|
55
|
+
result_name=result_name,
|
|
56
|
+
buffered=buffered,
|
|
57
|
+
flusher=flusher,
|
|
58
|
+
heartbeat=heartbeat,
|
|
59
|
+
out_dir=out_dir,
|
|
60
|
+
)
|
|
61
|
+
self._threshold = LOG_LEVELS[normalize_severity(log_level)]
|
|
62
|
+
|
|
63
|
+
def stream_items(self, items: Iterable[AtmObject]) -> None:
|
|
64
|
+
kept = [item for item in items if LOG_LEVELS[self._severity_of(item)] <= self._threshold]
|
|
65
|
+
super().stream_items(kept)
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def _severity_of(item: AtmObject) -> str:
|
|
69
|
+
try:
|
|
70
|
+
return normalize_severity(item["severity"])
|
|
71
|
+
except (KeyError, TypeError):
|
|
72
|
+
return DEFAULT_LOG_LEVEL
|
|
73
|
+
|
|
74
|
+
def log(self, severity: str, content: Any) -> None:
|
|
75
|
+
self.stream_item({"severity": severity, "content": content})
|
|
76
|
+
|
|
77
|
+
def debug(self, content: Any) -> None:
|
|
78
|
+
self.log("debug", content)
|
|
79
|
+
|
|
80
|
+
def info(self, content: Any) -> None:
|
|
81
|
+
self.log("info", content)
|
|
82
|
+
|
|
83
|
+
def notice(self, content: Any) -> None:
|
|
84
|
+
self.log("notice", content)
|
|
85
|
+
|
|
86
|
+
def warning(self, content: Any) -> None:
|
|
87
|
+
self.log("warning", content)
|
|
88
|
+
|
|
89
|
+
def error(self, content: Any) -> None:
|
|
90
|
+
self.log("error", content)
|
|
91
|
+
|
|
92
|
+
def critical(self, content: Any) -> None:
|
|
93
|
+
self.log("critical", content)
|
|
94
|
+
|
|
95
|
+
def alert(self, content: Any) -> None:
|
|
96
|
+
self.log("alert", content)
|
|
97
|
+
|
|
98
|
+
def emergency(self, content: Any) -> None:
|
|
99
|
+
self.log("emergency", content)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Helpers for lambdas that read files through a mounted Oneclient.
|
|
3
|
+
|
|
4
|
+
The mount point is a deployment fact: the platform mounts Oneclient at
|
|
5
|
+
`ONECLIENT_MOUNT_POINT` -- the same variable the runtime waits on before handing a batch
|
|
6
|
+
to a mounted lambda (see `runtime._await_oneclient_mount`). Reading it here, in one place,
|
|
7
|
+
together with Oneclient's id-based path convention, keeps every mounted lambda agreeing
|
|
8
|
+
with the runtime instead of hardcoding a path that can silently drift.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__author__ = "Bartosz Walkowicz"
|
|
12
|
+
__copyright__ = "Copyright (C) 2026 Onedata (onedata.org)"
|
|
13
|
+
__license__ = "This software is released under the MIT license cited in LICENSE.txt"
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from typing import Final
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Environment variable carrying the Oneclient mount point (set by the platform).
|
|
20
|
+
ENV_MOUNT_POINT: Final[str] = "ONECLIENT_MOUNT_POINT"
|
|
21
|
+
|
|
22
|
+
# Fallback used only for local dev / tests; in production the platform always sets the env.
|
|
23
|
+
DEFAULT_MOUNT_POINT: Final[str] = "/mnt/onedata"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def mount_point() -> str:
|
|
27
|
+
"""
|
|
28
|
+
The Oneclient mount point, read from the environment at call time.
|
|
29
|
+
|
|
30
|
+
Falls back to `DEFAULT_MOUNT_POINT` for local dev/tests; in production the platform
|
|
31
|
+
always sets `ONECLIENT_MOUNT_POINT`. Read at call time (not import time) so tests can
|
|
32
|
+
point the mount elsewhere via the environment.
|
|
33
|
+
"""
|
|
34
|
+
return os.environ.get(ENV_MOUNT_POINT) or DEFAULT_MOUNT_POINT
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def mounted_file_path(file_id: str) -> str:
|
|
38
|
+
"""
|
|
39
|
+
Absolute path of a file, by its id, under the Oneclient mount.
|
|
40
|
+
|
|
41
|
+
Oneclient exposes every file at a stable id-based path
|
|
42
|
+
(`<mount>/.__onedata__file_id__<id>`), so a lambda can open a file knowing only its id,
|
|
43
|
+
without resolving its logical location.
|
|
44
|
+
"""
|
|
45
|
+
return f"{mount_point()}/.__onedata__file_id__{file_id}"
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The per-job handler adapter: `@per_job`.
|
|
3
|
+
|
|
4
|
+
Lets a handler be written in the natural per-job style -- `handle(job, ctx) -> result`
|
|
5
|
+
-- and adapts it into the batch-level handler `run()` calls, so the runtime stays
|
|
6
|
+
agnostic about the style used. Sibling of `runtime.run` (the batch entry point).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__author__ = "Bartosz Walkowicz"
|
|
10
|
+
__copyright__ = "Copyright (C) 2022-2026 Onedata (onedata.org)"
|
|
11
|
+
__license__ = "This software is released under the MIT license cited in LICENSE.txt"
|
|
12
|
+
|
|
13
|
+
import functools
|
|
14
|
+
import traceback
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from .job import Job, JobContext, JobException
|
|
20
|
+
from .types import AtmException
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# A per-job handler: one job in, one result out.
|
|
24
|
+
PerJobHandler = Callable[[Job[Any], JobContext[Any]], Any]
|
|
25
|
+
# A batch-level handler (what the runtime calls).
|
|
26
|
+
BatchHandler = Callable[[list[Job[Any]], JobContext[Any]], list[Any]]
|
|
27
|
+
# A once-per-batch precondition: validate config / set up shared state before any job runs.
|
|
28
|
+
Precondition = Callable[[JobContext[Any]], None]
|
|
29
|
+
|
|
30
|
+
# Default worker count for parallel `@per_job`. A deliberate fixed value, *not* derived
|
|
31
|
+
# from `os.cpu_count()`: that returns the host's cores and ignores the pod's cgroup CPU
|
|
32
|
+
# limit (and there is no `os.process_cpu_count()` on 3.12), and peak memory grows with the
|
|
33
|
+
# worker count (each job may hold a read buffer). Lambdas whose profile differs pass their
|
|
34
|
+
# own `max_workers`.
|
|
35
|
+
DEFAULT_MAX_WORKERS = 10
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def per_job(
|
|
39
|
+
handle: PerJobHandler | None = None,
|
|
40
|
+
*,
|
|
41
|
+
max_workers: int = 1,
|
|
42
|
+
precondition: Precondition | None = None,
|
|
43
|
+
) -> Any:
|
|
44
|
+
"""
|
|
45
|
+
Adapt a per-job `handle(job, ctx) -> result` into the batch handler `run()` calls.
|
|
46
|
+
|
|
47
|
+
The SDK owns the batch loop: it runs each job, turns a per-job exception into an
|
|
48
|
+
`AtmException` entry (so one bad job fails only itself), assembles results in input
|
|
49
|
+
order, and heartbeats as each job completes. Sequential by default; `max_workers > 1`
|
|
50
|
+
runs jobs on a thread pool (the context services are thread-safe) -- pass
|
|
51
|
+
`DEFAULT_MAX_WORKERS` for the SDK's standard degree of parallelism. Usable bare
|
|
52
|
+
(`@per_job`) or parameterized (`@per_job(max_workers=4)`).
|
|
53
|
+
|
|
54
|
+
`precondition`, if given, runs **once** before any job is dispatched -- the place for
|
|
55
|
+
task-level validation (e.g. a config check) or one-time setup. Raising `JobException`
|
|
56
|
+
there fails the whole batch with a single clean top-level `{"exception": ...}` (via
|
|
57
|
+
`run()`), rather than repeating a per-job failure across every job.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def decorate(handle_one: PerJobHandler) -> BatchHandler:
|
|
61
|
+
# Copy name/qualname/doc/module so tracebacks and introspection point at the
|
|
62
|
+
# user's handler -- but NOT __annotations__: run_batch has the batch signature
|
|
63
|
+
# (jobs, ctx), which differs from handle_one's per-job (job, ctx).
|
|
64
|
+
@functools.wraps(
|
|
65
|
+
handle_one,
|
|
66
|
+
assigned=("__module__", "__name__", "__qualname__", "__doc__"),
|
|
67
|
+
)
|
|
68
|
+
def run_batch(jobs: list[Job[Any]], ctx: JobContext[Any]) -> list[Any]:
|
|
69
|
+
if precondition is not None:
|
|
70
|
+
# Runs once for the batch; a JobException here propagates to run()'s
|
|
71
|
+
# top-level handler -> clean whole-batch {"exception": ...}.
|
|
72
|
+
precondition(ctx)
|
|
73
|
+
if max_workers <= 1:
|
|
74
|
+
return _run_sequential(handle_one, jobs, ctx)
|
|
75
|
+
return _run_parallel(handle_one, jobs, ctx, max_workers)
|
|
76
|
+
|
|
77
|
+
return run_batch
|
|
78
|
+
|
|
79
|
+
# Support both bare `@per_job` and parameterized `@per_job(max_workers=N)`.
|
|
80
|
+
if handle is not None:
|
|
81
|
+
return decorate(handle)
|
|
82
|
+
|
|
83
|
+
return decorate
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _run_sequential(
|
|
87
|
+
handle_one: PerJobHandler, jobs: list[Job[Any]], ctx: JobContext[Any]
|
|
88
|
+
) -> list[Any]:
|
|
89
|
+
results = []
|
|
90
|
+
for job in jobs:
|
|
91
|
+
results.append(_run_one(handle_one, job, ctx))
|
|
92
|
+
ctx.heartbeat()
|
|
93
|
+
return results
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _run_parallel(
|
|
97
|
+
handle_one: PerJobHandler,
|
|
98
|
+
jobs: list[Job[Any]],
|
|
99
|
+
ctx: JobContext[Any],
|
|
100
|
+
max_workers: int,
|
|
101
|
+
) -> list[Any]:
|
|
102
|
+
results: list[Any] = [None] * len(jobs)
|
|
103
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
104
|
+
future_to_index = {
|
|
105
|
+
executor.submit(_run_one, handle_one, job, ctx): index for index, job in enumerate(jobs)
|
|
106
|
+
}
|
|
107
|
+
for future in as_completed(future_to_index):
|
|
108
|
+
index = future_to_index[future]
|
|
109
|
+
results[index] = future.result() # _run_one never raises
|
|
110
|
+
ctx.heartbeat()
|
|
111
|
+
return results
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _run_one(handle_one: PerJobHandler, job: Job[Any], ctx: JobContext[Any]) -> Any:
|
|
115
|
+
try:
|
|
116
|
+
return handle_one(job, ctx)
|
|
117
|
+
except JobException as ex:
|
|
118
|
+
# A known, expected failure -- report just the message, no traceback noise.
|
|
119
|
+
return AtmException(exception=str(ex))
|
|
120
|
+
except Exception:
|
|
121
|
+
# An unexpected bug -- keep the full traceback for debugging.
|
|
122
|
+
return AtmException(exception=traceback.format_exc())
|
|
File without changes
|