Схема: Python-функция превращается в ноду, хранится локальным демоном и передаёт ndarray через NPY-адаптер
Схема: Python-функция превращается в ноду, хранится локальным демоном и передаёт ndarray через NPY-адаптер

В комментариях к прошлой статье сформулировали правило, под которым я подписываюсь обеими руками: “платить” за внепроцессный вызов имеет смысл тогда, когда сама работа дороже транспорта. clean_amount из квикстарта — десять строк, ноль зависимостей, микросекунды работы — этот порог не проходит. Свою задачу тот пример выполнил: показал механику publish/call в шесть строк. Но ноды существуют не для него: 2 + 2 через демон не нужно никому, и splime строился не для этого.

Возьмём случай, где оверхед оправдан, залезем под капот и посмотрим, что происходит между publish и call: где физически живёт нода, как Python-функция превращается в переносимый объект и как две ноды передают друг другу данные, которым не место в JSON.

Напомню контекст из прошлой статьи. splime упаковывает Python-функции в переносимые вычислительные элементы — ноды, узлы вычислительного графа, — связывает их в пайплайны и исполняет там, где вы скажете: в текущем процессе, через локальный демон или на другой машине.

Всё, что ниже, проверено на актуальной splime 0.4.9.

Где на самом деле живёт нода

Демон — это локальный сервис на 127.0.0.1:8765 с SQLite под капотом. Никакой магии: таблицы objects, object_versions, envs, environment_builds, runs и несколько служебных.

Две строчки схемы объясняют, как splime отождествляет ноды и их версии:

CREATE TABLE objects (
    ...
    UNIQUE(owner_id, library, name)
);

CREATE TABLE object_versions (
    ...
    UNIQUE(object_id, version),
    UNIQUE(object_id, content_hash)
);

Первая: нода опознаётся тройкой «владелец + библиотека + имя». Не путём к файлу, не именем модуля. Отсюда растёт всё остальное — гранты выдаются на библиотеку, владение явное, а clean_amount у вас и clean_amount у коллеги спокойно живут рядом.

Вторая таблица интереснее: новая версия появляется только тогда, когда меняется определение ноды, — а оно шире, чем код. У версии два разных хэша. yaml_sha256 — хэш текста объекта как он есть, байт-в-байт. А content_hash считается от канонического определения: канонизированное содержимое YAML, entrypoint, окружение и версия Python, runtime-конфигурация и метаданные объекта. Именно по нему работает UNIQUE(object_id, content_hash), и на практике это выглядит так:

  • опубликовали ту же функцию второй раз, ничего не меняя, — новой записи не появится, вернётся существующая версия;

  • внесли изменение и опубликовали — появилась версия 2; передумали, откатили код как было и опубликовали снова — вернётся версия 1, а не версия 3 (если совпало и остальное определение, а не только текст).

История версий отражает реальные состояния ноды, а не количество нажатий на publish. При синхронизации между машинами именно это не даёт ей распухнуть от повторных публикаций одного и того же.

Заметьте: версии живут у объекта, а не у библиотеки. Библиотека группирует объекты по именам; вызов по имени берёт текущую версию — и после перепубликации старого содержимого текущей может снова стать версия 1. Зафиксировать конкретную версию можно в ноде-ссылке: NodeRemote.locate(..., version="1").

Из содержимого версии нам сегодня понадобится одно поле: yaml_text — текст объекта в том виде, в котором он пришёл при публикации. Что в нём лежит и зачем — станет ясно к концу статьи, когда дойдём до исполнения.

Карточка matrix_pipeline в локальной библиотеке демона
Карточка matrix_pipeline в локальной библиотеке демона

Опубликованный matrix_pipeline: текущее определение и история версий.

Как Python-функция превращается в ноду

Нода — это не pickle и не слепок процесса. splime разбирает функцию на части: локальные зависимости — другие ваши функции — сериализуются кодом, сторонние пакеты — записями «дистрибутив + версия». Всё это упаковывается в YAML-файл, из которого потом можно собрать готовую к запуску ноду — не обязательно в том же процессе, в том же окружении и даже на той же машине.

Для «потрошения» функции используются два стандартных модуля Python. dis дизассемблирует её в байткод — последовательность операций виртуальной машины. ast разбирает исходный текст в синтаксическое дерево. Первый отвечает на вопрос «что функция делает», второй — «как она объявлена». Нужны оба, и вот на каком примере это видно:

import numpy as np

def foo(xs=np.arange(10)):
    return xs.sum()

Шаг 1: байткод тела. dis.Bytecode(func) даёт операции тела функции. splime идёт по ним и собирает всё, что функция подгружает извне (упрощено до сути):

import dis
from types import CodeType

def external_names(code):
    for x in dis.Bytecode(code):
        match x.opname:
            case "LOAD_GLOBAL" | "LOAD_NAME":
                yield x.argval               # внешнее имя: функция, модуль, константа
            case "LOAD_CONST":
                if isinstance(x.argval, CodeType):
                    yield from external_names(x.argval)

LOAD_GLOBAL и LOAD_NAME — это имена, которые функция читает снаружи себя: другие функции, модули, константы уровня модуля. LOAD_CONST с code-объектом внутри — это вложенная функция, лямбда или генератор: dis сам внутрь не заходит, поэтому их обходим рекурсивно. (В entities/function.py эта же логика чуть строже: она отслеживает имена, которые вложенный code-объект связывает сам, — генератор, читающий параметр своей функции, внешней зависимостью не считается.)

Теперь прогоните этот обход по foo — и он не найдёт ничего. xs — аргумент, .sum() — метод. При этом зависимость у foo есть: np.arange(10) в значении по умолчанию. Байткод тела про сигнатуру не знает.

Шаг 2: AST сигнатуры. Поэтому вторым заходом splime берёт исходник: inspect.getsource возвращает текст функции строкой, ast.parse раскладывает его в дерево — вот буквально эта строка из entities/function.py:

[node] = ast.parse(textwrap.dedent(inspect.getsource(func))).body

Из дерева достаются имена аргументов и их дефолты. Выражения дефолтов — тот самый np.arange(10) — прогоняются через тот же байткодовый обход (ast.unparse превращает их обратно в строку, а dis.Bytecode умеет дизассемблировать и строку), и np пойман.

Обратите внимание на textwrap.dedent — за ним реальный баг, который чинился в 0.2.3. inspect.getsource возвращает исходник с исходными отступами. Если функция определена не на верхнем уровне модуля, а внутри if, внутри with или внутри другой функции, — вы получаете текст с ведущими четырьмя пробелами. И ast.parse честно падает с IndentationError, потому что на верхнем уровне отступа быть не должно. Лечится одной строчкой dedent (точнее, двумя — в обоих местах, где читается исходник), но найти это можно только на живом пользователе, который решил опубликовать функцию из-под if name == "__main__":.

Собранные имена затем резолвятся в глобалах кадра вызывающего, builtins отфильтровываются, а имя, которое резолвнуть не удалось, — это ValueError: missing names: ... ещё на этапе разбора, до того как нода вообще появится в реестре.

Замыкания — хороший пример того, как эта механика взрослеет. Чтение захваченной переменной — это опкод LOAD_DEREF: в упрощённом снипете выше его нет, а в боевом обходе он есть — вместе с учётом имён, которые вложенный code-объект связывает сам. История тут показательная: раньше функция с замыканием публиковалась молча, а падала уже при исполнении как нода — NameError в развёрнутом модуле. Пробел нашёл наш собственный аудит, и с 0.4.4 такие функции отклоняются прямо на publish (вместе с positional-only и keyword-only параметрами, args и *kwargs, которые текущая схема портов не представляет) — плохую новость лучше получать при публикации, а не при исполнении.

Остаётся собрать граф зависимостей. В spl/core/ir/parse.py это делает не рекурсия, а явная стек-машина:

while len(stack):
    (stack, x) = stack_pop(stack)
    match x:
        case _set_cursor(new_cursor):
            cursor = new_cursor

        case _branch(x, mk_root, mk_dependencies):
            if (x in refs_root) and (cursor is not None):
                refs_dependencies[cursor] = [*refs_dependencies[cursor], refs_root[x]]
            else:
                ...
                stack = stack_push(stack, root, _set_cursor(cursor), _attach(dependencies))
                cursor = x

        case _attach(dependencies):
            stack = stack_push(stack, *dependencies)

        case _:
            if cursor is not None:
                refs_dependencies[cursor] = [*refs_dependencies[cursor], x]

Зачем так, если рекурсия короче? Граф зависимостей — это граф, а не дерево: одна функция может быть нужна двум другим, и её нельзя разбирать дважды — проверка if x in refs_root дедуплицирует общие узлы. Заодно глубина обхода не упирается в sys.setrecursionlimit.

И маленькая деталь, за которую отдельно люблю Python. В конце функции стоит комментарий:

# python 3.7+ maintains order of insertions, we rely on it
return list(zip(refs_root.values(), refs_dependencies.values(), strict=True))

Два обычных dict, а порядок узлов и порядок их зависимостей совпадают только потому, что с 3.7 порядок вставки в словарь — это гарантия языка. Мы опираемся на неё сознательно и написали об этом прямо в коде, чтобы через год никто не «оптимизировал».

Граница, которую мы проводим сами. Всё это работает ровно потому, что в Python есть интроспекция живых объектов: можно взять функцию в памяти, спросить у неё исходник, байткод и глобалы. Ни на Go, ни на Rust это не переносится — там нет живого объекта, у которого можно спросить getsource. Поэтому, когда мы дойдём до других языков, рекурсивного обхода не будет: будет декларация. Асимметрия «Python — глубоко, остальные — по декларации» у нас не долг, а осознанная позиция. Но это тема одной из последующих статей.

Транспорт: почему ребро между нодами — это файл

Теперь пример с транспортом. Две ноды: первая генерирует матрицу, вторая её описывает. Между ними едет np.ndarray — значение, которому не место в JSON: превратить его в списки можно, но dtype пропадёт, а форма останется лишь неявной вложенностью — пустое измерение её уже не переживёт.

Матрица 2×3 — намеренно крошечная, чтобы её можно было разглядеть в статье. Подставьте на её место тензор на сотни мегабайт или батч документов — механика не изменится ни на строчку, а экономика из первого абзаца начнёт сходиться.

Для повторения примера установите NumPy — он не входит в зависимости splime — и оставьте демон запущенным в отдельном терминале:

python -m pip install "splime==0.4.9" numpy
spl-daemon serve
import numpy as np
from spl import Deployment, SPLClient, lift

def make_matrix(seed: int = 7) -> np.ndarray:
    return np.random.default_rng(seed).integers(0, 10, size=(2, 3))

def summarize_matrix(matrix: np.ndarray) -> dict:
    return {"shape": list(matrix.shape), "sum": int(matrix.sum())}

def save_ndarray(path, arr):
    with open(path, "wb") as f:
        np.save(f, arr, allow_pickle=False)

def load_ndarray(path):
    with open(path, "rb") as f:
        return np.load(f, allow_pickle=False)

p = (
    lift(summarize_matrix)
    .bind(matrix=lift(make_matrix))       # выход make_matrix → вход matrix
    .alias("summary")
    .render("matrix_pipeline")
    .add_adapter(np.ndarray, "npy", save=save_ndarray, load=load_ndarray)
)

# Прогон в текущем процессе — удобно для отладки:
print(Deployment(p).run(output="summary"))
# {'shape': [2, 3], 'sum': 41}

# А теперь через демон:
client = SPLClient()
client.register_env()                     # один раз на свежем демоне
client.publish(p, name="matrix_pipeline")
print(client.call("matrix_pipeline", output="summary").output)
# {'shape': [2, 3], 'sum': 41}

Разница между двумя запусками принципиальная. Deployment(p).run(...) исполняет граф прямо в вашем процессе — удобно для отладки (клиент в конструкторе Deployment нужен только нодам-ссылкам NodeRemote, обычные функции он через демон не отправляет). Маршрут через демон — publish + call: объект уезжает в реестр и исполняется уже там, по имени, в своём окружении.

Что в обоих случаях произошло с матрицей. Она не поехала «по сети как объект» и не была запиклена. Она была сохранена в файл адаптером save_ndarray, а вторая нода прочитала этот файл через load_ndarray. Ребро между нодами — это файл плюс метка формата. Файл настоящий: у локального Deployment.run(..., keep=True) он лежит в ~/.splime/runs/<run-id>/artifacts/, у демонского пайплайна — в ~/.spl-daemon/runs/<run-id>/pipeline-state/<attempt>/artifacts/. Дефолты хранения у этих двух маршрутов разные: локальный Deployment.run живёт по политике keep="on_failure" — успешный запуск чистится, упавший остаётся, — а демонский запуск без явного keep сохраняет всё. Второе — сознательное решение 0.4.5: старые воркеры всегда хранили демонские запуски, и менять дефолт под ногами у существующих пользователей мы не стали. Что и когда сохраняется — тема третьей статьи.

Причём адаптер — это на самом деле две половинки. Save-половина пишет файл и объявляет тэг; load-половина объявляет, какие тэги принимает. В нашем вызове add_adapter(np.ndarray, "npy", ...) рождается ключ numpy.ndarray@npy и тэг npy.

Здесь важно различать ключ и тэг:

Ключ (py_type@format) — это питоновская подсказка. тэг — это контракт.

Ключ говорит «в Python такое значение резолвится вот так». тэг говорит «в файле лежит вот это». И проверяется именно тэг — до того, как будет прочитан хотя бы один байт. Если save-половина написала csv, а load-половина умеет только tsv, вы получите громкую ошибку на сравнении тэгов, а не молча разъехавшиеся данные тремя нодами ниже. Тихая несовместимость форматов превращается в мгновенную и явную — это, пожалуй, главная боль, которую тут удалось закрыть.

В коде это два рубежа. Первый — статический: пайплайн сверяет тэги рёбер уже при сборке и при публикации и предупреждает заранее (AdapterCompatibilityWarning, с подсказкой, чем чинить: .as_format(), override на уровне запуска или конвертер-нода). Второй — жёсткий, в момент чтения: decode() сначала сравнивает тэг артефакта со списком accepted_tags load-половины и падает с внятным

artifact tag `csv` from `numpy.ndarray@csv` is not accepted by load adapter `load_ndarray` (accepted tags: npy)

— и только после этого файл идёт в работу: перед десериализацией сверяются также его размер и sha256, так что битый или подменённый артефакт до load-половины не доедет.

Резолвится адаптер по иерархии из четырёх уровней: дефолт порта → адаптер, зарегистрированный на пайплайне → override на конкретном ребре (.as_format("parquet")) → override на уровне запуска. Выбранный уровень не теряется: в отчёте запуска источник каждого адаптера виден как port-default / pipeline / edge / run-override. Последний уровень означает, что поменять адаптер можно, не перевыпуская ноду, — в локальных запусках Deployment.run. У SPLClient.call() и submit() параметр adapters отвечает за другой рубеж: как входы и результаты проходят между клиентом и запуском. Он принимает закрытые секции inputs / outputs с пресетами, библиотечными селекторами или пользовательскими transport-адаптерами, если это разрешено политикой демона; внутренние рёбра графа он не меняет. Старый tuple-key mapping для override конкретного ребра остаётся локальным API Deployment.run и в удалённом вызове отклоняется. Замену адаптера на живом запуске покажем в третьей статье.

Граф matrix_pipeline из нод make_matrix и summarize_matrix
Граф matrix_pipeline из нод make_matrix и summarize_matrix

Две ноды и файловое ребро между ними; тэг npy записывается в манифесте запуска.

Некрасивый код по расчёту: ADR-002

Файловый транспорт нужен не всегда. Если нода вернула int, записывать его в файл бессмысленно. Поэтому JSON-native значения (None, bool, int, float, str, dict, list) передаются прямо в JSON.

Формально splime мог бы всё равно вызывать встроенный адаптер json: с 0.4.0 он считается адаптером по умолчанию для каждого ребра. Код получился бы чище, но обычные скаляры стали бы обрабатываться заметно дольше. Поэтому у них остался отдельный короткий путь:

if adapter_format is None and type(value) in _JSON_NATIVE_TYPES:
    validate_json_value(value)
    return value

Альтернативу мы согласились бы принять, если бы она добавляла к медианному времени работы не больше 5%. На 0.4.9 результат зависит от типа значения. validate_json_value() рекурсивно проверяет контейнеры, и на больших словарях и списках дополнительный поиск адаптера почти ничего не меняет. На int и str разница по-прежнему хорошо видна.

Значение

Напрямую

Через адаптер

Замедление

int

0,43 мкс/оп

1,19 мкс/оп

2,73×

str

1,92 мкс/оп

2,72 мкс/оп

1,42×

словарь из 64 записей

0,829 мс/оп

0,838 мс/оп

1,01×

список из 16 384 чисел

6,60 мс/оп

6,59 мс/оп

1,00×

Для каждого варианта сделано девять замеров с отключённым GC: по 300 000 итераций для скаляров, 1 000 для словаря и 100 для большого списка. Бенчмарк в репозитории использует 300 000 итераций во всех случаях, но после появления рекурсивной проверки такой прогон для контейнеров стал слишком долгим.

Итог простой: контейнеры укладываются в порог 5%, скаляры — нет. Короткая ветка нужна именно для часто передаваемых простых значений; обещанного когда-то шестикратного выигрыша для любого JSON-объекта здесь нет.

Сколько стоит вызов на самом деле

Оптимизация выше экономит микросекунды, но сама по себе ничего не говорит о цене запуска. Для сквозного замера использован один двухузловой пайплайн seed → double с JSON-native значениями. В режимах venv-subprocess и docker отдельный рантайм получает только нода consumer; seed остаётся native.

Демон и окружения заранее прогреты. Вызовы идут через loopback, без внешней сети. Docker warm pool выключен, поэтому контейнер поднимается заново для каждого запуска.

Режим

Весь вызов, медиана

p90 (90-й процентиль)

Работа демона, медиана

In-process (Deployment.run)

2,57 мс

3,18 мс

Демон, native

708,48 мс

760,60 мс

394,61 мс

Демон, venv-subprocess

711,24 мс

956,92 мс

543,44 мс

Демон, docker

1 689,37 мс

1 773,08 мс

1 415,75 мс

Хост: MacBook Pro, Apple M5 Max (18 ядер), 128 ГБ RAM; macOS 26.6; Python 3.13.14; splime 0.4.9; Docker 29.5.3, образ python:3.13-slim (linux/arm64); N = 50 таймированных вызовов после 5 прогревочных на режим.

native и venv-subprocess выглядят почти одинаково только в столбце «Весь вызов». RemoteRun.wait проверяет состояние раз в 0,25 секунды, поэтому небольшая разница между рантаймами скрывается за этим интервалом. Внутри демона она видна: venv-subprocess каждый раз запускает отдельный процесс и потому медленнее. Docker требует ещё и нового контейнера, поэтому его стоимость заметна даже снаружи.

Воркер, в котором нет splime

Последний кусок локального уровня — как нода вообще исполняется.

Раньше демон подкидывал воркеру свой src в PYTHONPATH, а воркер компенсировал побочные эффекты хаком переупорядочивания sys.path — функция так и называлась: preferruntime_env_over_pythonpath_site_packages, по длине имени понятно, сколько ей приходилось компенсировать. Работало. Но означало неприятное: нода исполнима, только если рядом лежит совместимый чекаут splime. Для проекта, который обещает портируемые ноды, такое противоречие недопустимо. Мы его убрали — с основного пути; на legacy-маршруте, о котором ниже, этот хак жив по сей день, и это одна из причин, почему legacy остался фолбэком, а не нормой.

Сейчас (с 0.3.0) это устроено так:

  1. Демон читает сохранённый и проверенный object.yaml — тот самый yaml_text из начала статьи.

  2. Через IR-unparse разворачивает его в плоский Python-модуль — обычный .py, без единого импорта из splime. Модуль детерминирован и кэшируется по content-hash версии объекта: повторный запуск той же версии не генерирует его заново.

  3. Копирует в run-директорию один файл раннера.

  4. Запускает интерпретатор окружения ноды с этим раннером.

Раннер (spl_free_runner.py) — stdlib-only. Вся его импорт-секция: argparse, importlib.metadata, importlib.util, json, math, os, re, shutil, sys, pathlib и typing-обвязка (collections.abc, types, typing). Ноль splime. Протокол — файловый: input.json → вызов → артефакты → result.json; окружение раннер проверяет сам, через importlib.metadata, не импортируя ни одного модуля splime.

Из-за этого случилась история, которую я люблю показывать. В раннере есть функция validate_name, и она дословно продублирована из пакетной реализации, которая теперь живёт в spl.daemon.name_validation и импортируется в storage_base. Комментарий раннера по-прежнему ссылается на прежнюю точку входа:

# Keep this rule in sync with spl.daemon.storage_base.validate_name;
# the runner duplicates it intentionally to stay stdlib-only.

DRY нарушен сознательно. Импортировать общую версию — значит втащить splime в окружение ноды и убить весь смысл затеи. Иногда правильный инженерный ответ — это «продублируй и напиши, почему».

Отдельный vendored-микропакет spl_runtime проблему тоже не решает: он возвращает зависимость окружения ноды от splime-артефакта, просто в миниатюре, и не масштабируется на чужие языки.

Не всё, впрочем, ездит по новому пути. Пайплайны, async-функции, декорированные функции и функции, которые сами импортируют spl, отправляются на прежний legacy-воркер — не молча: в отчёте запуска есть поля worker_runtime / worker_runtime_reason. У ноды на новом пути там spl-free-runner / supported_function_node; у legacy-маршрута — legacy-spl-worker и причина: pipeline_node, async_function, decorated_function, imports_spl, docker_runtime, а с 0.4.8 ещё runtime_port_adapters — запуски с транспортом границы вызова тоже едут через legacy-воркер. Всегда видно, какая нода поехала каким маршрутом и почему.

Определяется это по AST-узлам. Соблазн профильтровать строкой ('from spl.core' not in line) был, и он отвергнут: ломается и на многострочных импортах, и на строковом литерале, внутри которого случайно встретилось spl.core.

Почему не pickle через pipe

Соблазнительная альтернатива: запикливаем значение, шлём в subprocess через пайп, там распикливаем. Коротко и знакомо. Но у этой схемы три самостоятельных места, где легко получить тихую порчу или дэдлок:

  • неблокирующая запись в переполненный пайп пишет частично или возвращает EAGAIN; без фрейминга и обратного давления хвост большого значения просто терялся — молча;

  • если читать stdout только после завершения процесса, вывод больше ёмкости пайпа блокирует процесс на записи — communicate() это обходит, самодельный протокол обязан решать отдельно;

  • если распикливать результат до проверки returncode, ошибка процесса маскируется ошибкой десериализации.

Каждый пункт лечится: фрейминг, параллельное чтение, правильный порядок проверок. Но вместе это уже собственный транспортный протокол поверх пайпов — ровно та сложность, от которой хотелось уйти. Мы предпочли файл с тэгом: скучнее, зато протокол читается глазами, отлаживается ls-ом и cat-ом и переживёт появление ноды не на Python.

Где всё это не нужно

Вернёмся к правилу применимости из начала статьи.

Локальный вызов идёт через loopback — это всё ещё TCP, но без внешнего сетевого взаимодействия, так что «плюс десять миллисекунд сети» тут нет. Настоящая цена в другом: на изолированных рантаймах (venv-subprocess, docker) по умолчанию на каждый запуск поднимается процесс или контейнер, и это заметно дороже сокета — в сквозной таблице выше разницу для venv-subprocess прячет квант поллинга, но внутри демона она есть (с 0.4.5 у docker есть пул тёплых контейнеров — включается явно, --docker-pool-enabled). Цена здесь — изоляция, и она того стоит, когда есть что изолировать.

Отсюда прямое следствие. Оверхед оправдан, когда работа ноды существенно дороже транспорта: разбор документа, скоринг модели, тяжёлый препроцессинг, всё, что тащит за собой зависимости, которые вы не хотите видеть у вызывающего. И он не оправдан для функции, считающей 2 + 2, — даже если очень хочется её переиспользовать. Ещё один способ вернуть оверхед в разумные рамки — батчинг: clean_amount(строка) — не нода, clean_amounts(10 000 строк) — хорошая нода.

Чтобы не собирать это по комментариям, зафиксирую явно, где чьи задачи. Лёгкий чистый код, нужный всем, — публикуйте обычным пакетом, это правильный инструмент. Распределённые вычисления — это Ray, мы с ним не соревнуемся. splime — про версионируемые единицы работы с тяжёлыми зависимостями или привязкой к месту исполнения. И про доверие, о котором справедливо спрашивали: ноды — это код, которому ваша команда доверяет. С 0.4.5 даже локальный API демона закрыт аутентификацией (Bearer-токен); по умолчанию демон слушает только 127.0.0.1, другой адрес включается явно (serve --host ...). А venv-subprocess — изоляция зависимостей и процесса, но не песочница безопасности: код исполняется под тем же пользователем ОС, что и демон.

Что дальше

Мы разобрали, где живёт нода, как функция превращается в объект и как две ноды передают друг другу то, чему не место в JSON. Осталось самое неочевидное — окружение, в котором всё это исполняется.

В следующей статье: как демон собирает изолированный venv через uv, как на исполняющей машине выбирается интерпретатор и как назначить рантайм — native, venv-subprocess или docker — отдельно каждой ноде.

Для старта достаточно команд перед примером выше; нужен Python 3.13+. Реализация локального движка открыта: github.com/yastrebovks/splime. Если найдёте, где мы неправы, — заводите issue. Документация и статус: splime.io.

splime — alpha: публичный API ещё может меняться. Половина уточнений в этой статье выросла из комментариев к предыдущей — сейчас обратная связь особенно полезна.