aind-code-ocean-pipeline-utils 0.4.2__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.
- aind_code_ocean_pipeline_utils/__init__.py +81 -0
- aind_code_ocean_pipeline_utils/cache.py +108 -0
- aind_code_ocean_pipeline_utils/cli.py +51 -0
- aind_code_ocean_pipeline_utils/diagnostics.py +202 -0
- aind_code_ocean_pipeline_utils/io.py +217 -0
- aind_code_ocean_pipeline_utils/log.py +321 -0
- aind_code_ocean_pipeline_utils/metadata.py +332 -0
- aind_code_ocean_pipeline_utils/process.py +199 -0
- aind_code_ocean_pipeline_utils/provenance.py +106 -0
- aind_code_ocean_pipeline_utils/py.typed +1 -0
- aind_code_ocean_pipeline_utils/role_dispatch.py +391 -0
- aind_code_ocean_pipeline_utils/step.py +569 -0
- aind_code_ocean_pipeline_utils/threading_utils.py +92 -0
- aind_code_ocean_pipeline_utils-0.4.2.dist-info/METADATA +443 -0
- aind_code_ocean_pipeline_utils-0.4.2.dist-info/RECORD +17 -0
- aind_code_ocean_pipeline_utils-0.4.2.dist-info/WHEEL +4 -0
- aind_code_ocean_pipeline_utils-0.4.2.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Utilities for use in Code Ocean pipelines.
|
|
2
|
+
|
|
3
|
+
Core primitives are re-exported at the package level. The rich-aware
|
|
4
|
+
helpers in :mod:`.log` (``install_rich_handler``, ``build_progress``)
|
|
5
|
+
require the optional ``[rich]`` extra and must be imported explicitly;
|
|
6
|
+
:func:`attach_file_log` is stdlib-only and re-exported here. Likewise the
|
|
7
|
+
:mod:`.metadata` helpers (``make_data_process``, ``append_process``,
|
|
8
|
+
``emit_processing``) and the :mod:`.step` ``processing.json`` decorator
|
|
9
|
+
(``capsule_step`` / ``processing_step``) require the optional ``[metadata]``
|
|
10
|
+
extra (``aind-data-schema``) and are import-only.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
14
|
+
|
|
15
|
+
from .cache import canonical_params, input_fingerprint
|
|
16
|
+
from .cli import parse_truthy
|
|
17
|
+
from .diagnostics import MemoryReporter, log_data_tree, start_memory_reporter
|
|
18
|
+
from .io import (
|
|
19
|
+
TRANSIENT_ERRNOS,
|
|
20
|
+
atomic_json_write,
|
|
21
|
+
atomic_write_text,
|
|
22
|
+
retry_on_oserror,
|
|
23
|
+
)
|
|
24
|
+
from .log import attach_file_log
|
|
25
|
+
from .process import (
|
|
26
|
+
GracefulExit,
|
|
27
|
+
check_shutdown,
|
|
28
|
+
install_shutdown_handlers,
|
|
29
|
+
is_shutdown_requested,
|
|
30
|
+
reset_shutdown_state,
|
|
31
|
+
shutdown_handler,
|
|
32
|
+
)
|
|
33
|
+
from .provenance import capsule_commit, package_version
|
|
34
|
+
from .role_dispatch import (
|
|
35
|
+
Role,
|
|
36
|
+
StreamConfigError,
|
|
37
|
+
default_sanitize,
|
|
38
|
+
find_launcher_manifest,
|
|
39
|
+
find_stream_config,
|
|
40
|
+
find_worker_manifests,
|
|
41
|
+
merge_manifests,
|
|
42
|
+
write_stream_configs,
|
|
43
|
+
)
|
|
44
|
+
from .threading_utils import submit_with_context
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
__version__ = version("aind-code-ocean-pipeline-utils")
|
|
48
|
+
except PackageNotFoundError:
|
|
49
|
+
__version__ = "0.0.0.dev0"
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"TRANSIENT_ERRNOS",
|
|
53
|
+
"GracefulExit",
|
|
54
|
+
"MemoryReporter",
|
|
55
|
+
"Role",
|
|
56
|
+
"StreamConfigError",
|
|
57
|
+
"__version__",
|
|
58
|
+
"atomic_json_write",
|
|
59
|
+
"atomic_write_text",
|
|
60
|
+
"attach_file_log",
|
|
61
|
+
"canonical_params",
|
|
62
|
+
"capsule_commit",
|
|
63
|
+
"check_shutdown",
|
|
64
|
+
"default_sanitize",
|
|
65
|
+
"find_launcher_manifest",
|
|
66
|
+
"find_stream_config",
|
|
67
|
+
"find_worker_manifests",
|
|
68
|
+
"input_fingerprint",
|
|
69
|
+
"install_shutdown_handlers",
|
|
70
|
+
"is_shutdown_requested",
|
|
71
|
+
"log_data_tree",
|
|
72
|
+
"merge_manifests",
|
|
73
|
+
"package_version",
|
|
74
|
+
"parse_truthy",
|
|
75
|
+
"reset_shutdown_state",
|
|
76
|
+
"retry_on_oserror",
|
|
77
|
+
"shutdown_handler",
|
|
78
|
+
"start_memory_reporter",
|
|
79
|
+
"submit_with_context",
|
|
80
|
+
"write_stream_configs",
|
|
81
|
+
]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Deterministic fingerprints for pipeline cache keys and resume validation.
|
|
2
|
+
|
|
3
|
+
A fingerprint is ``sha256:`` followed by the hex digest of a canonical JSON
|
|
4
|
+
encoding of the input parameters. Canonical JSON here means:
|
|
5
|
+
|
|
6
|
+
- keys are strings, sorted recursively
|
|
7
|
+
- no whitespace
|
|
8
|
+
- NaN / ``inf`` are rejected (non-standard JSON)
|
|
9
|
+
- UTF-8 encoded before hashing
|
|
10
|
+
|
|
11
|
+
Given the same Python ``dict`` — regardless of insertion order — the same
|
|
12
|
+
fingerprint comes out every time, on every Python version that supports the
|
|
13
|
+
type annotations.
|
|
14
|
+
|
|
15
|
+
Non-JSON values (ndarray, Path, dataclasses, ...) raise :class:`TypeError`
|
|
16
|
+
with a message identifying the offending key path. Callers are expected to
|
|
17
|
+
coerce at the call site (``Path → str``, ``ndarray → list`` with a size
|
|
18
|
+
ceiling). This keeps the fingerprint well-defined and reproducible rather
|
|
19
|
+
than hiding silent behavior changes inside a ``default=`` hook.
|
|
20
|
+
|
|
21
|
+
The ``sha256:`` prefix leaves room to change the algorithm later without
|
|
22
|
+
breaking consumers that string-compare fingerprints.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
__all__ = ["canonical_params", "input_fingerprint"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _validate_json(value: Any, path: str) -> None:
|
|
35
|
+
"""Walk ``value``; raise :class:`TypeError` on any non-JSON-encodable leaf.
|
|
36
|
+
|
|
37
|
+
Raising early with an explicit key path beats the opaque
|
|
38
|
+
``"Object of type X is not JSON serializable"`` that :func:`json.dumps`
|
|
39
|
+
emits without telling the caller *where* the bad value lives.
|
|
40
|
+
"""
|
|
41
|
+
if value is None or isinstance(value, (bool, int, float, str)):
|
|
42
|
+
if isinstance(value, float) and (value != value or value in (float("inf"), float("-inf"))):
|
|
43
|
+
raise TypeError(f"value at {path} is not JSON-standard: {value!r}")
|
|
44
|
+
return
|
|
45
|
+
if isinstance(value, dict):
|
|
46
|
+
for key, item in value.items():
|
|
47
|
+
if not isinstance(key, str):
|
|
48
|
+
raise TypeError(f"key at {path} is not a string: got {type(key).__name__}")
|
|
49
|
+
_validate_json(item, f"{path}.{key}" if path != "<root>" else key)
|
|
50
|
+
return
|
|
51
|
+
if isinstance(value, (list, tuple)):
|
|
52
|
+
for index, item in enumerate(value):
|
|
53
|
+
_validate_json(item, f"{path}[{index}]")
|
|
54
|
+
return
|
|
55
|
+
raise TypeError(f"value at {path} is not JSON-serializable: got {type(value).__name__}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def canonical_params(params: dict[str, Any]) -> str:
|
|
59
|
+
"""Return the canonical JSON encoding of ``params``.
|
|
60
|
+
|
|
61
|
+
Parameters
|
|
62
|
+
----------
|
|
63
|
+
params : dict[str, Any]
|
|
64
|
+
The input parameters. Must be JSON-serializable; see module
|
|
65
|
+
docstring for the allowed leaf types.
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
-------
|
|
69
|
+
str
|
|
70
|
+
Compact JSON with sorted keys, no whitespace, no NaN/inf.
|
|
71
|
+
|
|
72
|
+
Raises
|
|
73
|
+
------
|
|
74
|
+
TypeError
|
|
75
|
+
If any value (or key) is not JSON-encodable. The message identifies
|
|
76
|
+
the offending key path.
|
|
77
|
+
"""
|
|
78
|
+
_validate_json(params, "<root>")
|
|
79
|
+
return json.dumps(
|
|
80
|
+
params,
|
|
81
|
+
sort_keys=True,
|
|
82
|
+
separators=(",", ":"),
|
|
83
|
+
ensure_ascii=False,
|
|
84
|
+
allow_nan=False,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def input_fingerprint(params: dict[str, Any]) -> str:
|
|
89
|
+
"""Return a stable ``sha256:`` fingerprint of ``params``.
|
|
90
|
+
|
|
91
|
+
Equal inputs (as Python dicts, regardless of key insertion order)
|
|
92
|
+
produce equal fingerprints. Distinct inputs almost-certainly produce
|
|
93
|
+
distinct fingerprints (SHA256 collision resistance).
|
|
94
|
+
|
|
95
|
+
Parameters
|
|
96
|
+
----------
|
|
97
|
+
params : dict[str, Any]
|
|
98
|
+
The input parameters. See :func:`canonical_params` for the
|
|
99
|
+
serialization contract.
|
|
100
|
+
|
|
101
|
+
Returns
|
|
102
|
+
-------
|
|
103
|
+
str
|
|
104
|
+
``"sha256:"`` followed by 64 hex characters.
|
|
105
|
+
"""
|
|
106
|
+
canonical = canonical_params(params)
|
|
107
|
+
digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
108
|
+
return f"sha256:{digest}"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Parsers for Code Ocean app-panel parameter values.
|
|
2
|
+
|
|
3
|
+
CO's app panel passes every parameter as a string when
|
|
4
|
+
``named_parameters: true``, so bool-flag CLIs (argparse ``store_true``,
|
|
5
|
+
tyro's ``--flag``/``--no-flag``) don't round-trip: the panel always
|
|
6
|
+
emits ``--flag <string>``. Capsules end up reimplementing a
|
|
7
|
+
permissive truthy parser. The one here centralizes the accepted set
|
|
8
|
+
so consumers don't independently pick slightly different semantics.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
__all__ = ["parse_truthy"]
|
|
14
|
+
|
|
15
|
+
_TRUTHY_WORDS: frozenset[str] = frozenset({"true", "yes", "y", "t"})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_truthy(value: str) -> bool:
|
|
19
|
+
"""Parse a permissive truthy string from an app-panel named parameter.
|
|
20
|
+
|
|
21
|
+
Returns True for:
|
|
22
|
+
|
|
23
|
+
- any case variant of ``"true"`` / ``"yes"`` / ``"y"`` / ``"t"``
|
|
24
|
+
- any numeric string (int or float) whose value is non-zero:
|
|
25
|
+
``"1"``, ``"42"``, ``"-1"``, ``"3.14"``, ``"1.5e2"`` all parse as True
|
|
26
|
+
|
|
27
|
+
Returns False for everything else, including the empty string,
|
|
28
|
+
``"0"``, ``"0.0"``, ``"false"``, ``"no"``, and unrecognized words.
|
|
29
|
+
|
|
30
|
+
The numeric handling exists because CO panels sometimes pass
|
|
31
|
+
integers through as strings ("0"/"1" being a common idiom), and
|
|
32
|
+
``parse_truthy("0")`` returning True would be a subtle footgun.
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
value : str
|
|
37
|
+
The raw parameter value from the app panel.
|
|
38
|
+
|
|
39
|
+
Returns
|
|
40
|
+
-------
|
|
41
|
+
bool
|
|
42
|
+
"""
|
|
43
|
+
stripped = value.strip()
|
|
44
|
+
if not stripped:
|
|
45
|
+
return False
|
|
46
|
+
if stripped.lower() in _TRUTHY_WORDS:
|
|
47
|
+
return True
|
|
48
|
+
try:
|
|
49
|
+
return bool(float(stripped))
|
|
50
|
+
except ValueError:
|
|
51
|
+
return False
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""First-log-line diagnostics for pipeline workers.
|
|
2
|
+
|
|
3
|
+
Two helpers that drop into any capsule ``run()`` so that post-mortem
|
|
4
|
+
analysis of a killed or crashed container is tractable.
|
|
5
|
+
|
|
6
|
+
- :func:`log_data_tree` dumps a depth-bounded view of ``/data`` on
|
|
7
|
+
startup so mount-path mismatches ("where did my data asset land?")
|
|
8
|
+
are visible in the log without an interactive session.
|
|
9
|
+
- :func:`start_memory_reporter` logs RSS + cgroup limit periodically
|
|
10
|
+
from a daemon thread. When the kernel OOM killer / AWS Batch sends
|
|
11
|
+
SIGKILL, no ``except`` block runs, but the last line of the captured
|
|
12
|
+
log shows peak approach-to-limit — enough to distinguish OOM from
|
|
13
|
+
spot reclamation from application errors.
|
|
14
|
+
|
|
15
|
+
Both helpers are stdlib-only and degrade gracefully on non-Linux
|
|
16
|
+
systems where ``/proc/self/status`` or cgroup files are absent.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import threading
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"MemoryReporter",
|
|
28
|
+
"log_data_tree",
|
|
29
|
+
"start_memory_reporter",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
_logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
_PROC_STATUS = "/proc/self/status"
|
|
35
|
+
_CGROUP_LIMIT_PATHS: tuple[str, ...] = (
|
|
36
|
+
"/sys/fs/cgroup/memory.max", # cgroup v2
|
|
37
|
+
"/sys/fs/cgroup/memory/memory.limit_in_bytes", # cgroup v1
|
|
38
|
+
)
|
|
39
|
+
# Values at or above this are the kernel's "effectively unlimited"
|
|
40
|
+
# sentinels — skip logging them so "no limit" doesn't look like a
|
|
41
|
+
# meaningful ceiling.
|
|
42
|
+
_NO_LIMIT_SENTINEL = 1 << 50
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def log_data_tree(
|
|
46
|
+
root: Path | str = Path("/data"),
|
|
47
|
+
*,
|
|
48
|
+
max_depth: int = 3,
|
|
49
|
+
logger: logging.Logger | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Log a depth-bounded listing of ``root`` on startup.
|
|
52
|
+
|
|
53
|
+
Uses :func:`os.walk` with ``followlinks=True`` because Code Ocean
|
|
54
|
+
stages data via symlink chains that :meth:`Path.glob` doesn't
|
|
55
|
+
traverse by default. Each directory logs one line with its
|
|
56
|
+
``(dirs, files)`` counts. The depth cap keeps zarr chunk trees
|
|
57
|
+
from flooding the log.
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
root : Path or str, default ``Path("/data")``
|
|
62
|
+
Top of the tree. If missing, one line is logged and the
|
|
63
|
+
function returns without an error.
|
|
64
|
+
max_depth : int, default 3
|
|
65
|
+
Number of levels below ``root`` to descend.
|
|
66
|
+
logger : logging.Logger, optional
|
|
67
|
+
Target logger. Defaults to this module's logger.
|
|
68
|
+
"""
|
|
69
|
+
log = logger if logger is not None else _logger
|
|
70
|
+
base = Path(root)
|
|
71
|
+
if not base.exists():
|
|
72
|
+
log.info("data tree: %s does not exist", base)
|
|
73
|
+
return
|
|
74
|
+
log.info("data tree under %s (max depth %d, symlinks followed):", base, max_depth)
|
|
75
|
+
for cur_root, dirs, files in os.walk(base, followlinks=True):
|
|
76
|
+
cur_path = Path(cur_root)
|
|
77
|
+
depth = len(cur_path.relative_to(base).parts)
|
|
78
|
+
if depth >= max_depth:
|
|
79
|
+
dirs[:] = []
|
|
80
|
+
rel = cur_path.relative_to(base) if cur_path != base else Path(".")
|
|
81
|
+
log.info(" %s/ (%d dirs, %d files)", rel, len(dirs), len(files))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _read_vmrss_kb(status_path: str = _PROC_STATUS) -> int | None:
|
|
85
|
+
"""Read VmRSS (kB) from ``status_path``; return None on any failure."""
|
|
86
|
+
try:
|
|
87
|
+
with open(status_path) as f:
|
|
88
|
+
for line in f:
|
|
89
|
+
if line.startswith("VmRSS:"):
|
|
90
|
+
return int(line.split()[1])
|
|
91
|
+
except (OSError, ValueError):
|
|
92
|
+
return None
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _read_cgroup_limit_bytes(
|
|
97
|
+
candidate_paths: tuple[str, ...] = _CGROUP_LIMIT_PATHS,
|
|
98
|
+
) -> int | None:
|
|
99
|
+
"""Return the first readable cgroup memory limit in bytes, or None."""
|
|
100
|
+
for path in candidate_paths:
|
|
101
|
+
try:
|
|
102
|
+
with open(path) as f:
|
|
103
|
+
raw = f.read().strip()
|
|
104
|
+
except OSError:
|
|
105
|
+
continue
|
|
106
|
+
if raw == "max":
|
|
107
|
+
return None
|
|
108
|
+
try:
|
|
109
|
+
return int(raw)
|
|
110
|
+
except ValueError:
|
|
111
|
+
continue
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class MemoryReporter:
|
|
116
|
+
"""Handle for a running background memory reporter.
|
|
117
|
+
|
|
118
|
+
Returned by :func:`start_memory_reporter`. Callers can :meth:`stop`
|
|
119
|
+
the reporter explicitly (useful for deterministic test teardown
|
|
120
|
+
and for capsules that want to quiet the log before shutdown).
|
|
121
|
+
The underlying thread is a daemon, so omitting :meth:`stop` is
|
|
122
|
+
fine — the process will exit regardless.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(self, thread: threading.Thread, stop_event: threading.Event) -> None:
|
|
126
|
+
self._thread = thread
|
|
127
|
+
self._stop_event = stop_event
|
|
128
|
+
|
|
129
|
+
def stop(self, *, timeout: float | None = 1.0) -> None:
|
|
130
|
+
"""Signal the reporter to exit and join its thread.
|
|
131
|
+
|
|
132
|
+
Parameters
|
|
133
|
+
----------
|
|
134
|
+
timeout : float or None, default 1.0
|
|
135
|
+
Passed to :meth:`threading.Thread.join`. None waits
|
|
136
|
+
indefinitely.
|
|
137
|
+
"""
|
|
138
|
+
self._stop_event.set()
|
|
139
|
+
self._thread.join(timeout=timeout)
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def is_alive(self) -> bool:
|
|
143
|
+
"""Whether the reporter thread is still running."""
|
|
144
|
+
return self._thread.is_alive()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def start_memory_reporter(
|
|
148
|
+
*,
|
|
149
|
+
interval_s: float = 15.0,
|
|
150
|
+
logger: logging.Logger | None = None,
|
|
151
|
+
status_path: str = _PROC_STATUS,
|
|
152
|
+
cgroup_limit_paths: tuple[str, ...] = _CGROUP_LIMIT_PATHS,
|
|
153
|
+
) -> MemoryReporter:
|
|
154
|
+
"""Start a daemon thread that logs RSS + cgroup limit periodically.
|
|
155
|
+
|
|
156
|
+
On the first tick the cgroup limit is logged (if any). On every
|
|
157
|
+
tick — including the first — VmRSS is logged as a GB value and,
|
|
158
|
+
when a limit is known, as a percentage of that limit.
|
|
159
|
+
|
|
160
|
+
The reporter silently skips ticks on platforms where
|
|
161
|
+
``/proc/self/status`` is unreadable (macOS, Windows).
|
|
162
|
+
|
|
163
|
+
Parameters
|
|
164
|
+
----------
|
|
165
|
+
interval_s : float, default 15.0
|
|
166
|
+
Seconds between ticks. Also the worst-case delay between
|
|
167
|
+
:meth:`MemoryReporter.stop` being called and the thread exiting.
|
|
168
|
+
logger : logging.Logger, optional
|
|
169
|
+
Target logger. Defaults to this module's logger.
|
|
170
|
+
status_path : str
|
|
171
|
+
Override for ``/proc/self/status``. Primarily a test seam.
|
|
172
|
+
cgroup_limit_paths : tuple[str, ...]
|
|
173
|
+
Override for the cgroup-limit candidate list. Primarily a
|
|
174
|
+
test seam.
|
|
175
|
+
|
|
176
|
+
Returns
|
|
177
|
+
-------
|
|
178
|
+
MemoryReporter
|
|
179
|
+
Handle with a :meth:`MemoryReporter.stop` method.
|
|
180
|
+
"""
|
|
181
|
+
log = logger if logger is not None else _logger
|
|
182
|
+
stop_event = threading.Event()
|
|
183
|
+
|
|
184
|
+
def _loop() -> None:
|
|
185
|
+
limit = _read_cgroup_limit_bytes(cgroup_limit_paths)
|
|
186
|
+
if limit is not None and limit < _NO_LIMIT_SENTINEL:
|
|
187
|
+
log.info("memory: cgroup limit %.1f GB", limit / 1e9)
|
|
188
|
+
# Loop: log a tick, then wait. Using Event.wait so stop() is
|
|
189
|
+
# responsive even mid-interval.
|
|
190
|
+
while True:
|
|
191
|
+
rss_kb = _read_vmrss_kb(status_path)
|
|
192
|
+
if rss_kb is not None:
|
|
193
|
+
msg = f"memory: VmRSS {rss_kb / 1e6:.2f} GB"
|
|
194
|
+
if limit is not None and limit < _NO_LIMIT_SENTINEL:
|
|
195
|
+
msg += f" ({100 * rss_kb * 1024 / limit:.0f}% of cgroup)"
|
|
196
|
+
log.info(msg)
|
|
197
|
+
if stop_event.wait(timeout=interval_s):
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
thread = threading.Thread(target=_loop, daemon=True, name="aind-mem-reporter")
|
|
201
|
+
thread.start()
|
|
202
|
+
return MemoryReporter(thread, stop_event)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""I/O helpers: retry on transient OS errors, atomic file writes.
|
|
2
|
+
|
|
3
|
+
The two primitives here address failure modes that recur in every pipeline
|
|
4
|
+
capsule: transient network or storage hiccups, and half-written files left
|
|
5
|
+
behind when a process is killed mid-write. Both exist to turn silent
|
|
6
|
+
correctness bugs into loud, retriable failures.
|
|
7
|
+
|
|
8
|
+
Examples
|
|
9
|
+
--------
|
|
10
|
+
Retry a flaky S3 download and write the result atomically::
|
|
11
|
+
|
|
12
|
+
from aind_code_ocean_pipeline_utils.io import retry_on_oserror, atomic_json_write
|
|
13
|
+
|
|
14
|
+
download = retry_on_oserror(_raw_download, retries=5)
|
|
15
|
+
payload = download(url)
|
|
16
|
+
atomic_json_write(out_path, payload)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import errno
|
|
22
|
+
import functools
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import os
|
|
26
|
+
import tempfile
|
|
27
|
+
import time
|
|
28
|
+
from collections.abc import Callable, Generator
|
|
29
|
+
from contextlib import contextmanager
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any, TextIO, TypeVar
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"TRANSIENT_ERRNOS",
|
|
35
|
+
"atomic_json_write",
|
|
36
|
+
"atomic_write_text",
|
|
37
|
+
"retry_on_oserror",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
_logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
T = TypeVar("T")
|
|
43
|
+
|
|
44
|
+
#: errno values considered worth retrying.
|
|
45
|
+
#:
|
|
46
|
+
#: Permanent errors (ENOENT, EACCES, EINVAL, EISDIR, ...) are intentionally
|
|
47
|
+
#: excluded — retrying them hides configuration mistakes behind minutes of
|
|
48
|
+
#: exponential backoff. Callers with different failure models (e.g. a rate-
|
|
49
|
+
#: limited HTTP endpoint) can union in additional codes at the call site.
|
|
50
|
+
TRANSIENT_ERRNOS: frozenset[int] = frozenset(
|
|
51
|
+
{
|
|
52
|
+
errno.EIO,
|
|
53
|
+
errno.EAGAIN,
|
|
54
|
+
errno.EBUSY,
|
|
55
|
+
errno.ENETDOWN,
|
|
56
|
+
errno.ENETUNREACH,
|
|
57
|
+
errno.ECONNRESET,
|
|
58
|
+
errno.ETIMEDOUT,
|
|
59
|
+
errno.EHOSTUNREACH,
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def retry_on_oserror(
|
|
65
|
+
fn: Callable[..., T],
|
|
66
|
+
*,
|
|
67
|
+
retries: int = 5,
|
|
68
|
+
initial_delay: float = 1.0,
|
|
69
|
+
transient_errnos: frozenset[int] = TRANSIENT_ERRNOS,
|
|
70
|
+
) -> Callable[..., T]:
|
|
71
|
+
"""Return a wrapper that retries ``fn`` on transient :class:`OSError`.
|
|
72
|
+
|
|
73
|
+
The wrapper catches :class:`OSError` whose ``errno`` is in
|
|
74
|
+
``transient_errnos``, waits ``(2 ** attempt) * initial_delay`` seconds,
|
|
75
|
+
and retries up to ``retries`` times. Permanent errors surface
|
|
76
|
+
immediately, as does the final attempt on exhaustion.
|
|
77
|
+
|
|
78
|
+
Parameters
|
|
79
|
+
----------
|
|
80
|
+
fn : callable
|
|
81
|
+
The function to wrap. Can also be used as a bare decorator
|
|
82
|
+
(``@retry_on_oserror``) since ``fn`` is positional.
|
|
83
|
+
retries : int, default 5
|
|
84
|
+
Number of retry attempts after the initial call. Total call count
|
|
85
|
+
is ``retries + 1``.
|
|
86
|
+
initial_delay : float, default 1.0
|
|
87
|
+
Seconds to sleep before the first retry. Each subsequent retry
|
|
88
|
+
doubles the delay (exponential backoff).
|
|
89
|
+
transient_errnos : frozenset[int]
|
|
90
|
+
errno values considered transient. Defaults to
|
|
91
|
+
:data:`TRANSIENT_ERRNOS`.
|
|
92
|
+
|
|
93
|
+
Returns
|
|
94
|
+
-------
|
|
95
|
+
callable
|
|
96
|
+
A wrapper preserving ``fn``'s signature.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
@functools.wraps(fn)
|
|
100
|
+
def wrapper(*args: Any, **kwargs: Any) -> T:
|
|
101
|
+
start = time.monotonic()
|
|
102
|
+
for attempt in range(retries + 1):
|
|
103
|
+
try:
|
|
104
|
+
return fn(*args, **kwargs)
|
|
105
|
+
except OSError as exc:
|
|
106
|
+
if exc.errno not in transient_errnos or attempt == retries:
|
|
107
|
+
raise
|
|
108
|
+
delay = (2**attempt) * initial_delay
|
|
109
|
+
_logger.warning(
|
|
110
|
+
"retry_on_oserror: %s errno=%d (%s) attempt=%d/%d elapsed=%.2fs sleeping=%.2fs",
|
|
111
|
+
getattr(fn, "__qualname__", repr(fn)),
|
|
112
|
+
exc.errno,
|
|
113
|
+
os.strerror(exc.errno or 0),
|
|
114
|
+
attempt + 1,
|
|
115
|
+
retries,
|
|
116
|
+
time.monotonic() - start,
|
|
117
|
+
delay,
|
|
118
|
+
)
|
|
119
|
+
time.sleep(delay)
|
|
120
|
+
# Unreachable: either a successful return or a re-raised OSError
|
|
121
|
+
# exits the loop.
|
|
122
|
+
raise AssertionError("unreachable: retry loop exited without result")
|
|
123
|
+
|
|
124
|
+
return wrapper
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@contextmanager
|
|
128
|
+
def atomic_write_text(
|
|
129
|
+
path: Path | str,
|
|
130
|
+
*,
|
|
131
|
+
fsync: bool = True,
|
|
132
|
+
encoding: str = "utf-8",
|
|
133
|
+
) -> Generator[TextIO, None, None]:
|
|
134
|
+
"""Write ``path`` atomically via a sibling temp file + :func:`os.replace`.
|
|
135
|
+
|
|
136
|
+
A file object is yielded for the caller to write to. On clean exit the
|
|
137
|
+
buffer is flushed, optionally fsync'd, and the temp file is renamed
|
|
138
|
+
over ``path``. On any exception the temp file is removed and the
|
|
139
|
+
exception propagates; ``path`` is never left half-written.
|
|
140
|
+
|
|
141
|
+
Parameters
|
|
142
|
+
----------
|
|
143
|
+
path : Path or str
|
|
144
|
+
Destination path. Its parent directory must already exist.
|
|
145
|
+
fsync : bool, default True
|
|
146
|
+
If True, fsync the temp file before rename. This is the only way
|
|
147
|
+
to get durability guarantees on crash/power-loss; off-by-default
|
|
148
|
+
would be a silent correctness regression on spot instances.
|
|
149
|
+
encoding : str, default "utf-8"
|
|
150
|
+
Text encoding for the yielded file handle.
|
|
151
|
+
|
|
152
|
+
Yields
|
|
153
|
+
------
|
|
154
|
+
TextIO
|
|
155
|
+
A writable text file handle backed by the temp file.
|
|
156
|
+
|
|
157
|
+
Notes
|
|
158
|
+
-----
|
|
159
|
+
Uses :func:`os.replace` (not :func:`os.rename`) so the rename is atomic
|
|
160
|
+
on Windows as well as POSIX.
|
|
161
|
+
"""
|
|
162
|
+
dest = Path(path)
|
|
163
|
+
parent = dest.parent
|
|
164
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
165
|
+
dir=parent if str(parent) else None,
|
|
166
|
+
prefix=f".{dest.name}.",
|
|
167
|
+
suffix=".tmp",
|
|
168
|
+
)
|
|
169
|
+
tmp_path = Path(tmp_name)
|
|
170
|
+
handle = os.fdopen(fd, "w", encoding=encoding)
|
|
171
|
+
committed = False
|
|
172
|
+
try:
|
|
173
|
+
try:
|
|
174
|
+
yield handle
|
|
175
|
+
handle.flush()
|
|
176
|
+
if fsync:
|
|
177
|
+
os.fsync(handle.fileno())
|
|
178
|
+
finally:
|
|
179
|
+
handle.close()
|
|
180
|
+
os.replace(tmp_path, dest)
|
|
181
|
+
committed = True
|
|
182
|
+
finally:
|
|
183
|
+
if not committed:
|
|
184
|
+
try:
|
|
185
|
+
tmp_path.unlink()
|
|
186
|
+
except FileNotFoundError:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def atomic_json_write(
|
|
191
|
+
path: Path | str,
|
|
192
|
+
data: Any,
|
|
193
|
+
*,
|
|
194
|
+
fsync: bool = True,
|
|
195
|
+
indent: int | None = 2,
|
|
196
|
+
sort_keys: bool = True,
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Serialize ``data`` as JSON and write to ``path`` atomically.
|
|
199
|
+
|
|
200
|
+
Thin wrapper over :func:`atomic_write_text` for the common JSON case.
|
|
201
|
+
|
|
202
|
+
Parameters
|
|
203
|
+
----------
|
|
204
|
+
path : Path or str
|
|
205
|
+
Destination path.
|
|
206
|
+
data : Any
|
|
207
|
+
JSON-serializable value.
|
|
208
|
+
fsync : bool, default True
|
|
209
|
+
See :func:`atomic_write_text`.
|
|
210
|
+
indent : int or None, default 2
|
|
211
|
+
Pass-through to :func:`json.dump`. Use ``None`` for compact output.
|
|
212
|
+
sort_keys : bool, default True
|
|
213
|
+
Pass-through to :func:`json.dump`. Sorted keys make diffs and
|
|
214
|
+
fingerprints stable.
|
|
215
|
+
"""
|
|
216
|
+
with atomic_write_text(path, fsync=fsync) as handle:
|
|
217
|
+
json.dump(data, handle, indent=indent, sort_keys=sort_keys)
|