ei-pipe-sdk 0.1.5__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.
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: ei-pipe-sdk
3
+ Version: 0.1.5
4
+ Summary: Minimal inference SDK for algorithm engineers on the ei-pipeline ei_infer node
5
+ Requires-Python: <3.15,>=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: arq>=0.26
8
+ Requires-Dist: redis>=8.0.1
9
+
10
+ # ei-pipe-sdk
11
+
12
+ 面向算法同学的极简模型接入 SDK。把你的模型写成一个 `EiInfer` 的两个方法,ei-pipeline
13
+ 就能调度它推理:**你不用关心数据怎么进来、结果怎么提交,也不用关心部署**。
14
+
15
+ ## 安装
16
+
17
+ ```bash
18
+ pip install ei-pipe-sdk
19
+ # 或先在仓库 ei_pipe_sdk/ 下本地构建
20
+ uv build && pip install dist/ei_pipe_sdk-*.whl
21
+ ```
22
+
23
+ ## 三步接入
24
+
25
+ ```python
26
+ # my_model.py
27
+ from ei_infer import EiInfer, register
28
+
29
+
30
+ class MyModel(EiInfer):
31
+ def init(self, model_cfg: dict | None = None) -> None:
32
+ # 进程启动时调用一次:加载权重、tokenizer、选设备。失败会让 worker 启动即崩溃。
33
+ self.model = load_weights(...)
34
+
35
+ def inference(self, input_path: str, output_path: str, node_info: dict) -> dict:
36
+ # input_path / output_path: worker 已恢复好的绝对路径
37
+ # = {EI_INFER_DATA_ROOT}/{time_bucket}/{pipeline_id}/<相对路径>
38
+ # input_path 是输入(文件或目录);output_path 是允许写结果文件的目录(可能为空)。
39
+ # 是否落盘由模型自己决定;返回的 dict 会被 worker 回传给 pipeline。
40
+ return {"detections": self.model(open(input_path))}
41
+
42
+
43
+ register("my-model", MyModel()) # 注册名 = 管道节点 params.name 的值
44
+ ```
45
+
46
+ ```bash
47
+ pip install ei-pipe-sdk
48
+ # 在算法部署里跑(pod 内需挂载 PFS data_root 到 EI_INFER_DATA_ROOT)
49
+ EI_INFER_DATA_ROOT=/lpai/pvc/ei-autolabel-bd-ga-infer python -m ei_infer.worker
50
+ ```
51
+
52
+ ## 协议
53
+
54
+ 调度端(ei-pipeline 的 `ei_infer` 节点)会按统一协议给你发任务:
55
+
56
+ | 字段 | 类型 | 说明 |
57
+ |---|---|---|
58
+ | `name` | str | 模型注册名,由你 `register("name", ...)` 时命名 |
59
+ | `input_path` | str | 输入路径(相对 pipe 共享目录,如 `data/`),worker 恢复为绝对路径 |
60
+ | `output_path` | str? | 允许写结果文件的目录(相对 pipe 共享目录,可选) |
61
+ | `version` | str? | 版本提示,透传保留 |
62
+
63
+ worker 用 `node_info.time_bucket + pipeline_id + EI_INFER_DATA_ROOT` 把 `input_path` /
64
+ `output_path` 恢复成绝对路径,调用 `inference(input_path, output_path, node_info)`。
65
+ **是否把结果落盘由模型自己决定**,worker 不替模型落盘,只把模型返回的 dict 原样回传。
66
+ 整个链路里**你的函数只看绝对路径 input_path / output_path 和 node_info**,其余(数据就位、
67
+ 并发、轮询)全部由 SDK 兜底。
68
+
69
+ ## 配置(环境变量)
70
+
71
+ | 变量 | 默认 | 说明 |
72
+ |---|---|---|
73
+ | `EI_INFER_REDIS_URL` | 回退 `EI_ARQ_REDIS_URL` → `EI_REDIS_URL` → `redis://localhost:6379/0` | 消费队列的 Redis |
74
+ | `EI_INFER_QUEUE_NAME` | `arq:{env}:ei:model-infer` | 消费的任务队列,默认与 ei-pipeline `ei_infer` 节点指向一致 |
75
+ | `EI_INFER_MAX_JOBS` | `4` | 每 worker 并发任务数(arq `max_jobs`) |
76
+ | `EI_INFER_MAX_THREADS` | `max(4, max_jobs)` | 跑同步推理的线程池大小 |
77
+ | `EI_INFER_DATA_ROOT` | `/lpai/pvc/ei-autolabel-bd-ga-infer` | PFS data_root 在 pod 内的挂载根 |
78
+
79
+ ## 多模型
80
+
81
+ 多个模型可在同一个 worker 进程里 `register` 多个名字;每个模型各自实现自己的一套动作。
82
+ 一个队列可由多个模型 worker 消费,任务按 `name` 路由到注册了同名模型的进程。
83
+
84
+ ## 失败模式
85
+
86
+ - `inference` 返回非 dict → 任务失败(错误信息含模型名)。
87
+ - `name` 未注册 → 任务失败,节点错误信息会指出未知模型名。
88
+ - `init` 抛异常 → worker 启动即退出,方便在你自己的日志里早发现。
89
+
90
+ ## 本地联调
91
+
92
+ ```bash
93
+ # 起一个本地 redis 后(redis-server)
94
+ python -m ei_infer.worker # 默认队列 arq:local:ei:model-infer
95
+ ```
96
+
97
+ 然后在 ei-pipeline 用 `ei_infer` 节点提交一条任务即可走通。
@@ -0,0 +1,88 @@
1
+ # ei-pipe-sdk
2
+
3
+ 面向算法同学的极简模型接入 SDK。把你的模型写成一个 `EiInfer` 的两个方法,ei-pipeline
4
+ 就能调度它推理:**你不用关心数据怎么进来、结果怎么提交,也不用关心部署**。
5
+
6
+ ## 安装
7
+
8
+ ```bash
9
+ pip install ei-pipe-sdk
10
+ # 或先在仓库 ei_pipe_sdk/ 下本地构建
11
+ uv build && pip install dist/ei_pipe_sdk-*.whl
12
+ ```
13
+
14
+ ## 三步接入
15
+
16
+ ```python
17
+ # my_model.py
18
+ from ei_infer import EiInfer, register
19
+
20
+
21
+ class MyModel(EiInfer):
22
+ def init(self, model_cfg: dict | None = None) -> None:
23
+ # 进程启动时调用一次:加载权重、tokenizer、选设备。失败会让 worker 启动即崩溃。
24
+ self.model = load_weights(...)
25
+
26
+ def inference(self, input_path: str, output_path: str, node_info: dict) -> dict:
27
+ # input_path / output_path: worker 已恢复好的绝对路径
28
+ # = {EI_INFER_DATA_ROOT}/{time_bucket}/{pipeline_id}/<相对路径>
29
+ # input_path 是输入(文件或目录);output_path 是允许写结果文件的目录(可能为空)。
30
+ # 是否落盘由模型自己决定;返回的 dict 会被 worker 回传给 pipeline。
31
+ return {"detections": self.model(open(input_path))}
32
+
33
+
34
+ register("my-model", MyModel()) # 注册名 = 管道节点 params.name 的值
35
+ ```
36
+
37
+ ```bash
38
+ pip install ei-pipe-sdk
39
+ # 在算法部署里跑(pod 内需挂载 PFS data_root 到 EI_INFER_DATA_ROOT)
40
+ EI_INFER_DATA_ROOT=/lpai/pvc/ei-autolabel-bd-ga-infer python -m ei_infer.worker
41
+ ```
42
+
43
+ ## 协议
44
+
45
+ 调度端(ei-pipeline 的 `ei_infer` 节点)会按统一协议给你发任务:
46
+
47
+ | 字段 | 类型 | 说明 |
48
+ |---|---|---|
49
+ | `name` | str | 模型注册名,由你 `register("name", ...)` 时命名 |
50
+ | `input_path` | str | 输入路径(相对 pipe 共享目录,如 `data/`),worker 恢复为绝对路径 |
51
+ | `output_path` | str? | 允许写结果文件的目录(相对 pipe 共享目录,可选) |
52
+ | `version` | str? | 版本提示,透传保留 |
53
+
54
+ worker 用 `node_info.time_bucket + pipeline_id + EI_INFER_DATA_ROOT` 把 `input_path` /
55
+ `output_path` 恢复成绝对路径,调用 `inference(input_path, output_path, node_info)`。
56
+ **是否把结果落盘由模型自己决定**,worker 不替模型落盘,只把模型返回的 dict 原样回传。
57
+ 整个链路里**你的函数只看绝对路径 input_path / output_path 和 node_info**,其余(数据就位、
58
+ 并发、轮询)全部由 SDK 兜底。
59
+
60
+ ## 配置(环境变量)
61
+
62
+ | 变量 | 默认 | 说明 |
63
+ |---|---|---|
64
+ | `EI_INFER_REDIS_URL` | 回退 `EI_ARQ_REDIS_URL` → `EI_REDIS_URL` → `redis://localhost:6379/0` | 消费队列的 Redis |
65
+ | `EI_INFER_QUEUE_NAME` | `arq:{env}:ei:model-infer` | 消费的任务队列,默认与 ei-pipeline `ei_infer` 节点指向一致 |
66
+ | `EI_INFER_MAX_JOBS` | `4` | 每 worker 并发任务数(arq `max_jobs`) |
67
+ | `EI_INFER_MAX_THREADS` | `max(4, max_jobs)` | 跑同步推理的线程池大小 |
68
+ | `EI_INFER_DATA_ROOT` | `/lpai/pvc/ei-autolabel-bd-ga-infer` | PFS data_root 在 pod 内的挂载根 |
69
+
70
+ ## 多模型
71
+
72
+ 多个模型可在同一个 worker 进程里 `register` 多个名字;每个模型各自实现自己的一套动作。
73
+ 一个队列可由多个模型 worker 消费,任务按 `name` 路由到注册了同名模型的进程。
74
+
75
+ ## 失败模式
76
+
77
+ - `inference` 返回非 dict → 任务失败(错误信息含模型名)。
78
+ - `name` 未注册 → 任务失败,节点错误信息会指出未知模型名。
79
+ - `init` 抛异常 → worker 启动即退出,方便在你自己的日志里早发现。
80
+
81
+ ## 本地联调
82
+
83
+ ```bash
84
+ # 起一个本地 redis 后(redis-server)
85
+ python -m ei_infer.worker # 默认队列 arq:local:ei:model-infer
86
+ ```
87
+
88
+ 然后在 ei-pipeline 用 `ei_infer` 节点提交一条任务即可走通。
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "ei-pipe-sdk"
3
+ version = "0.1.5"
4
+ description = "Minimal inference SDK for algorithm engineers on the ei-pipeline ei_infer node"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11,<3.15"
7
+ dependencies = [
8
+ "arq>=0.26",
9
+ # arq caps redis<6, but it is runtime-compatible with redis-py 8; aligned with
10
+ # the ei-pipeline override so both projects share one Redis client version.
11
+ "redis>=8.0.1",
12
+ ]
13
+
14
+ [build-system]
15
+ requires = ["setuptools>=61.0"]
16
+ build-backend = "setuptools.build_meta"
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["src/ei_infer", "src/ei_path"]
20
+
21
+ [tool.pytest.ini_options]
22
+ testpaths = ["tests"]
23
+ python_files = ["test_*.py"]
24
+ addopts = "--strict-markers --strict-config"
25
+ asyncio_mode = "auto"
26
+ pythonpath = [".", "src"]
27
+
28
+ [dependency-groups]
29
+ dev = [
30
+ "pytest>=8.4.1",
31
+ "pytest-asyncio>=1.1.0",
32
+ "fakeredis[lua]>=2.37.0",
33
+ "pytest-xdist>=3.8.0",
34
+ ]
35
+
36
+ [[tool.uv.index]]
37
+ name = "liauto-pypi-l5"
38
+ url = "https://artifactory.ep.chehejia.com/artifactory/api/pypi/liauto-pypi-l5/simple"
39
+ default = true
40
+
41
+ [tool.uv]
42
+ # Use only the system CPython; never download a managed (uv-managed) interpreter.
43
+ # The pipeline pod/ci image provides python 3.11+ itself, so this avoids hitting
44
+ # the network-to-fetch-CPython when the image lacks a matching interpreter.
45
+ python-preference = "only-system"
46
+ # arq caps redis<6, but it is runtime-compatible with redis-py 8; override so
47
+ # arq can coexist with redis>=8.0.1 (mirrors the ei-pipeline override).
48
+ override-dependencies = ["redis>=8.0.1"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,16 @@
1
+ """ei-infer: minimal inference SDK for algorithm models on ei-pipeline.
2
+
3
+ Public surface for an algorithm engineer:
4
+
5
+ - ``EiInfer`` base class to implement (``init`` + ``inference``)
6
+ - ``EI_REGISTER`` decorator to put a model into the in-process registry
7
+
8
+ Everything else (registry internals, task runner, worker entry) lives in
9
+ submodules and is not re-exported here on purpose.
10
+ """
11
+
12
+ from .model import EiInfer
13
+ from .registry import EI_REGISTER
14
+ from .worker import run_loop
15
+
16
+ __all__ = ["EiInfer", "EI_REGISTER", "run_loop"]
@@ -0,0 +1,80 @@
1
+ """SDK environment helpers: EI_INFER_* with fallback to the shared Redis vars."""
2
+
3
+ import os
4
+ from urllib.parse import unquote, urlparse
5
+
6
+ from arq.connections import RedisSettings
7
+
8
+ DEFAULT_DATA_ROOT = "/lpai/pvc/ei-autolabel-bd-ga-infer"
9
+
10
+
11
+ def resolve_env() -> str:
12
+ """Resolve active environment, mirroring ei-pipeline's envget_core_env."""
13
+ return os.getenv("EI_ENV") or os.getenv("LI_ENV") or "local"
14
+
15
+
16
+ def infer_redis_url() -> str:
17
+ """Redis the worker consumes from.
18
+
19
+ EI_INFER_REDIS_URL overrides; otherwise fall back to EI_ARQ_REDIS_URL, then
20
+ EI_REDIS_URL, then a local dev default. Allows a zero-config local run and
21
+ reuse of the ei-pipeline cluster in production.
22
+ """
23
+ return (
24
+ os.getenv("EI_INFER_REDIS_URL")
25
+ or os.getenv("EI_ARQ_REDIS_URL")
26
+ or os.getenv("EI_REDIS_URL")
27
+ or "redis://localhost:6379/0"
28
+ )
29
+
30
+
31
+ def redis_settings_from_url(url: str) -> RedisSettings:
32
+ """Parse a Redis URL into RedisSettings, decoding percent-encoded auth.
33
+
34
+ arq's ``RedisSettings.from_dsn`` forwards ``urlparse``'s raw username and
35
+ password through to the AUTH command, so a URL like
36
+ ``redis://user:p%40ss@host`` would authenticate with the literal bytes
37
+ ``p%40ss`` instead of ``p@ss``. Decode both fields with ``unquote`` (not
38
+ ``unquote_plus``) so a literal ``+`` in a password is preserved.
39
+ """
40
+ settings = RedisSettings.from_dsn(url)
41
+ parsed = urlparse(url)
42
+ if parsed.username is not None:
43
+ settings.username = unquote(parsed.username)
44
+ if parsed.password is not None:
45
+ settings.password = unquote(parsed.password)
46
+ return settings
47
+
48
+
49
+ def infer_queue_name() -> str:
50
+ """Task queue this worker consumes.
51
+
52
+ Default matches the ei-pipeline ei_infer node's build_default_queue_name()
53
+ (env segment hashed when > 32 chars), so a zero-config pipe and worker land
54
+ on the same queue.
55
+ """
56
+ return os.getenv("EI_INFER_QUEUE_NAME") or f"arq:{resolve_env()}:ei:model-infer"
57
+
58
+
59
+ def infer_max_jobs() -> int:
60
+ """Concurrent jobs per worker (arq max_jobs). Default: 4. Must be positive."""
61
+ value = int(os.getenv("EI_INFER_MAX_JOBS") or "4")
62
+ if value <= 0:
63
+ raise ValueError("EI_INFER_MAX_JOBS must be positive")
64
+ return value
65
+
66
+
67
+ def infer_max_threads() -> int:
68
+ """Thread pool size for sync inference, at least one per concurrent job."""
69
+ return max(4, infer_max_jobs())
70
+
71
+
72
+ def infer_data_root() -> str:
73
+ """PFS data_root mount point inside the pod.
74
+
75
+ The pipeline sends an input_path relative to this mount, and the worker
76
+ forwards it unchanged to ``model.inference(input_path, node_info)``; the
77
+ algorithm joins this root to read the input file. Default matches the
78
+ ei-pipeline PFS data root.
79
+ """
80
+ return os.getenv("EI_INFER_DATA_ROOT") or DEFAULT_DATA_ROOT
@@ -0,0 +1,46 @@
1
+ """Algorithm model abstraction: implement exactly two methods to join the pipeline."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+
6
+ class EiInfer(ABC):
7
+ """What an algorithm engineer implements to plug a model into ei-pipeline.
8
+
9
+ Convention
10
+ ----------
11
+ - ``init`` runs once per worker process (model warm-up). Fail fast here:
12
+ a model that cannot initialize must crash the worker on boot, not the
13
+ first job. The current worker calls ``init()`` with no arguments;
14
+ ``model_cfg`` is reserved for a future config loader that would pass a
15
+ parsed dict in.
16
+ - ``inference`` receives the absolute input path, an optional absolute
17
+ output directory, and the caller's ``node_info``; it **returns a dict**.
18
+ The worker returns that dict to the pipeline and does NOT persist it -
19
+ the model decides whether to write result files into ``output_path``.
20
+ """
21
+
22
+ @abstractmethod
23
+ def init(self, model_cfg: dict | None = None) -> None:
24
+ """Load model artifacts (weights, tokenizer, device).
25
+
26
+ Called once per worker process before the first inference. ``model_cfg``
27
+ is reserved for a future caller that would pass parsed config (e.g. a
28
+ yaml); today the worker calls ``init()`` with no arguments, so
29
+ implementations that need per-model config should read it themselves
30
+ (env var, local file, etc.). Raise to abort worker startup.
31
+ """
32
+ raise NotImplementedError
33
+
34
+ @abstractmethod
35
+ def inference(self, input_path: str, output_path: str, node_info: dict) -> dict:
36
+ """Run one inference and return the result dict.
37
+
38
+ ``input_path`` is the absolute path of the input the pipeline staged
39
+ (a file or a directory of material). ``output_path`` is the absolute
40
+ directory (may be empty) where the model may persist result files when
41
+ it chooses to; whether to write is the model's decision. ``node_info``
42
+ carries caller context (pipeline_id, node_name, params, ...), may be an
43
+ empty dict but is always provided. The returned dict must be
44
+ JSON-serializable; it becomes the pipeline node's result.
45
+ """
46
+ raise NotImplementedError
@@ -0,0 +1,75 @@
1
+ """Process-local, thread-safe model registry: name -> EiInfer instance."""
2
+
3
+ import threading
4
+ from collections.abc import Callable
5
+ from typing import TypeVar
6
+
7
+ from .model import EiInfer
8
+
9
+ T = TypeVar("T", bound=EiInfer)
10
+
11
+ _lock = threading.RLock()
12
+ _registry: dict[str, EiInfer] = {}
13
+
14
+
15
+ def _register_instance(name: str, model: EiInfer) -> None:
16
+ """Register ``model`` under ``name`` (internal, used by the decorator).
17
+
18
+ Re-registering the *same* instance under the same name is idempotent;
19
+ registering a *different* instance under an existing name raises, so a
20
+ running worker can never silently swap a model mid-flight.
21
+ """
22
+ if not isinstance(name, str) or not name.strip():
23
+ raise ValueError("model name must be a non-empty string")
24
+ if not isinstance(model, EiInfer):
25
+ raise TypeError("model must be an EiInfer instance")
26
+ with _lock:
27
+ existing = _registry.get(name)
28
+ if existing is not None and existing is not model:
29
+ raise ValueError(f"model '{name}' already registered")
30
+ _registry[name] = model
31
+
32
+
33
+ def EI_REGISTER(model_name: str) -> Callable[[type[T]], type[T]]:
34
+ """Register an EiInfer subclass as a decorator.
35
+
36
+ Usage::
37
+
38
+ @EI_REGISTER("example-model")
39
+ class ExampleModel(EiInfer):
40
+ ...
41
+
42
+ The model is instantiated with the model name as its sole constructor
43
+ argument (``Model(model_name)``) and put into the registry at class
44
+ definition time; the worker pre-loads it via ``init()`` on startup, so
45
+ load model artifacts in ``init()`` rather than ``__init__``. Registering a
46
+ different class under an existing name raises.
47
+ """
48
+ if not isinstance(model_name, str) or not model_name.strip():
49
+ raise ValueError("model name must be a non-empty string")
50
+
51
+ def decorator(model_cls: type[T]) -> type[T]:
52
+ _register_instance(model_name, model_cls(model_name)) # type: ignore
53
+ return model_cls
54
+
55
+ return decorator
56
+
57
+
58
+ def get_model(name: str) -> EiInfer:
59
+ """Return the registered model, raising KeyError when unknown."""
60
+ with _lock:
61
+ if name not in _registry:
62
+ raise KeyError(f"model '{name}' is not registered")
63
+ return _registry[name]
64
+
65
+
66
+ def get_registered_names() -> list[str]:
67
+ """Return sorted registered model names (for startup pre-loading)."""
68
+ with _lock:
69
+ return sorted(_registry)
70
+
71
+
72
+ def clear_registry() -> None:
73
+ """Drop all registrations. Test / restart hook only."""
74
+ with _lock:
75
+ _registry.clear()
@@ -0,0 +1,124 @@
1
+ """arq task that turns a pipeline job into a call into the model registry."""
2
+
3
+ import asyncio
4
+ from concurrent.futures.thread import ThreadPoolExecutor
5
+
6
+ from ei_path.paths import get_shared_dirpath, normalize_relative_path
7
+
8
+ from .env import infer_data_root
9
+ from .registry import get_model
10
+
11
+
12
+ class ModelTaskError(Exception):
13
+ """A deterministic job failure (bad payload / unregistered model / bad result)."""
14
+
15
+
16
+ def resolve_absolute_paths(
17
+ data: dict,
18
+ node_info: dict | None,
19
+ data_root: str,
20
+ ) -> tuple[str, str]:
21
+ """Resolve input/output to absolute paths on the shared PFS root.
22
+
23
+ The pipeline sends paths relative to the pipe shared dir; this joins them
24
+ onto {EI_INFER_DATA_ROOT}/{time_bucket}/{pipeline_id} with traversal
25
+ checks (mirrors the pipeline's pfs_storage.paths helpers). Returns
26
+ ``(absolute_input, absolute_output)``; output is "" when data has no
27
+ output_path.
28
+ """
29
+ raw_input = str(data.get("input_path") or "").strip()
30
+ if not raw_input:
31
+ raise ModelTaskError("job data requires 'input_path'")
32
+ normalized_input = normalize_relative_path(raw_input)
33
+
34
+ raw_output = data.get("output_path")
35
+ if raw_output is None:
36
+ normalized_output = ""
37
+ else:
38
+ stripped = str(raw_output).strip()
39
+ if not stripped:
40
+ normalized_output = ""
41
+ else:
42
+ normalized_output = normalize_relative_path(stripped)
43
+
44
+ base = get_shared_dirpath(data_root, node_info) # {root}/{time_bucket}/{pipe_id}
45
+ absolute_input = str(base / normalized_input)
46
+ absolute_output = (
47
+ str(base / normalized_output).rstrip("/") if normalized_output else ""
48
+ )
49
+ return absolute_input, absolute_output
50
+
51
+
52
+ class _WorkerExecutor:
53
+ """Process-wide, lazily created thread pool for running sync inference.
54
+
55
+ arq runs each job as an async task; model.inference() is synchronous and
56
+ must not block the event loop, so it is executed in this pool. Created once
57
+ and shut down at the very end of the process.
58
+ """
59
+
60
+ _pool: ThreadPoolExecutor | None = None
61
+
62
+ @classmethod
63
+ def get(cls) -> ThreadPoolExecutor:
64
+ if cls._pool is None:
65
+ from .env import infer_max_threads
66
+
67
+ cls._pool = ThreadPoolExecutor(
68
+ max_workers=infer_max_threads(), thread_name_prefix="model-infer"
69
+ )
70
+ return cls._pool
71
+
72
+ @classmethod
73
+ def shutdown(cls) -> None:
74
+ if cls._pool is not None:
75
+ cls._pool.shutdown(wait=True)
76
+ cls._pool = None
77
+
78
+
79
+ async def run_inference(ctx: dict, data: dict, node_info: dict | None = None) -> dict:
80
+ """arq task served by ``ei_infer.worker``.
81
+
82
+ Protocol (shared with the ei-pipeline ``ei_infer`` node):
83
+ data = {"name": str, "input_path": str, "output_path": str, ...}
84
+ - ``name`` model registered in the in-process registry
85
+ - ``input_path`` PFS path relative to the pipe shared dir
86
+ {time_bucket}/{pipe_id}/... (may be a file or a dir)
87
+ - ``output_path``(optional) PFS dir the model may write result files to
88
+ - ``version`` optional, passed through untouched (reserved)
89
+
90
+ The worker resolves absolute paths from node_info + EI_INFER_DATA_ROOT:
91
+ absolute = {EI_INFER_DATA_ROOT}/{time_bucket}/{pipeline_id}/<path>
92
+ then calls ``model.inference(absolute_input, absolute_output, node_info)``.
93
+ The result dict is returned to the pipeline; the worker does NOT persist it
94
+ - writing result files is up to the model (into output_path if given).
95
+
96
+ Returns ``{"status": "success", "result": <inference dict>, "name": ...}``.
97
+ """
98
+ name = data.get("name") or ""
99
+ if not name:
100
+ raise ModelTaskError("job data requires 'name'")
101
+
102
+ model = get_model(name) # KeyError when unknown -> job fails cleanly
103
+
104
+ try:
105
+ absolute_input, absolute_output = resolve_absolute_paths(
106
+ data, node_info, infer_data_root()
107
+ )
108
+ except ValueError as error:
109
+ raise ModelTaskError(f"invalid data path (name={name}): {error}") from None
110
+
111
+ loop = asyncio.get_running_loop()
112
+ result = await loop.run_in_executor(
113
+ _WorkerExecutor.get(),
114
+ model.inference,
115
+ absolute_input,
116
+ absolute_output,
117
+ node_info or {},
118
+ )
119
+ if not isinstance(result, dict):
120
+ raise ModelTaskError(
121
+ f"model '{name}' inference returned {type(result).__name__}, expected dict"
122
+ )
123
+
124
+ return {"status": "success", "result": result, "name": name}
@@ -0,0 +1,78 @@
1
+ """The ei-infer arq worker entry: ``python -m ei_infer.worker``.
2
+
3
+ The worker consumes the ``arq:{env}:ei:model-infer`` queue (override with
4
+ EI_INFER_QUEUE_NAME), pre-loads every model registered in the process on
5
+ startup, and dispatches each job to the matching model via a thread pool.
6
+
7
+ All tuning knobs come from the environment (EI_INFER_*); there are no CLI
8
+ arguments on purpose so containers only need env vars.
9
+ """
10
+
11
+ from arq import cron # noqa: F401 (documentation convenience)
12
+
13
+ from .env import (
14
+ infer_max_jobs,
15
+ infer_queue_name,
16
+ infer_redis_url,
17
+ redis_settings_from_url,
18
+ )
19
+ from .registry import get_registered_names
20
+ from .task import ModelTaskError, _WorkerExecutor, run_inference # noqa: F401
21
+
22
+ # Re-export so a project entry script can do `from ei_infer import worker`.
23
+ __all__ = ["build_worker_settings", "run_loop"]
24
+
25
+
26
+ async def startup(ctx: dict) -> None:
27
+ """Pre-load every registered model. Any init() failure crashes the worker
28
+ on boot instead of surfacing on the first job."""
29
+ from .registry import get_model
30
+
31
+ for name in get_registered_names():
32
+ model = get_model(name)
33
+ model.init()
34
+
35
+
36
+ async def shutdown(ctx: dict) -> None:
37
+ _WorkerExecutor.shutdown()
38
+
39
+
40
+ def build_worker_settings() -> type:
41
+ """Build WorkerSettings at call time so env (EI_INFER_*) is read fresh.
42
+
43
+ The worker config must track env set at boot, not import-time snapshots.
44
+ """
45
+
46
+ class _WorkerSettings:
47
+ functions = [run_inference]
48
+ queue_name = infer_queue_name()
49
+ redis_settings = redis_settings_from_url(infer_redis_url())
50
+ on_startup = startup
51
+ on_shutdown = shutdown
52
+ max_jobs = infer_max_jobs()
53
+
54
+ return _WorkerSettings
55
+
56
+
57
+ def run_loop() -> None:
58
+ """Run the worker with config read from EI_INFER_* env vars.
59
+
60
+ Entry for ``python -m ei_infer.worker``; blocks until interrupted.
61
+ """
62
+ settings = build_worker_settings()
63
+
64
+ from arq.worker import Worker
65
+
66
+ worker = Worker(
67
+ functions=settings.functions,
68
+ queue_name=settings.queue_name,
69
+ redis_settings=settings.redis_settings,
70
+ on_startup=settings.on_startup,
71
+ on_shutdown=settings.on_shutdown,
72
+ max_jobs=settings.max_jobs,
73
+ handle_signals=False, # let the container runtime own signal handling
74
+ )
75
+ try:
76
+ worker.run()
77
+ finally:
78
+ _WorkerExecutor.shutdown()
@@ -0,0 +1 @@
1
+ """Shared-dir path helpers for the algorithm worker (available as ``ei_path``)."""