Любите загадки? Событие всё ещё доступно на сайте

Зачем неофициальный php-клиент к Manticore, если есть официальный

Manticore Search — быстрый поисковый движок с полнотекстовым поиском, фасетами и векторным KNN. У него есть официальный PHP-клиент, manticoresoftware/manticoresearch-php, который поддерживает вендор и который идёт в ногу с релизами сервера. Тем не менее я написал свой. Ниже — три конкретные вещи, из-за которых это произошло, и то, чем за них пришлось заплатить.

С чего началось

Задача была обычная: полнотекстовый поиск по каталогу в приложении на Laravel. Manticore подошёл, официальный клиент поставился, первый запрос заработал за десять минут:

use Manticoresearch\Client;

$client = new Client(['host' => '127.0.0.1', 'port' => 9308]);

$result = $client->table('products')
    ->search('galaxy')
    ->filter('price', 'lte', 1000)
    ->sort('price', 'desc')
    ->limit(10)
    ->get();

foreach ($result as $hit) {
    echo $hit->title;
}

Казалось бы, все хорошо, у клиента есть “текучий” интерфейс, ResultSet реализует Iterator и Countable, а ResultHit через __get() отдаёт поля как свойства. Придраться тут не к чему.

Но чего-то все равно не хватало…

Причина намбер уан: PDO и SQL-интерфейс

Manticore говорит на двух языках. По порту 9308 — JSON поверх HTTP, по порту 9306 — SQL по протоколу MySQL. Официальный клиент умеет только первое.

В Laravel-проекте второе оказалось удобнее по четырём причинам.

PDO-расширение уже есть. Расширение ext-pdo входит в стандартную сборку PHP и заведомо включён в любом проекте, который ходит в базу через DB::. Отдельный HTTP-стек ради поиска добавлять не нужно.

Порт уже проброшен. Порт 9306 говорит на том же протоколе, что MySQL, и вписывается в готовую сетевую обвязку: те же правила фаервола, те же туннели, тот же способ пустить приложение к базе в докере. Отдельный HTTP-порт — это отдельный разговор с теми, кто эту обвязку настраивал.

Запрос можно выполнить руками. Логирование отдаёт готовый SQL, который копируется в консольный mysql -P9306 и выполняется как есть:

// глобально, на конкретное соединение или на один запрос
\ManticoreDb::setLogger(\Log::getLogger());

Это обычный цикл отладки, к которому разработчик уже привык по работе с базой. С JSON-телом запроса так не выйдет: его сначала нужно превратить во что-то, что можно скормить серверу вручную.

SQL-интерфейс шире. JSON API покрывает поиск и модификацию данных. Всё остальное — SHOW, ALTER, FLUSH, служебные CALL-процедуры — живёт только в SQL. А значит, рано или поздно понадобится “просто выполнить SQL-запрос”:

\ManticoreDb::connection()->statement('FLUSH RAMCHUNK products');

Была еще мысль, что PDO будет работать быстрее, чем HTTP API, но по факту оказалось, что это не так, во всяком случае, у меня в бенчмарках расхождение оказалось на уровне погрешности, так что этот аргумент точно мимо.

Причина вторая: плейсхолдер префикса

В Manticore нет привычных баз данных и схем. Одно плоское пространство имён на весь демон — все таблицы лежат рядом.

И пока демон один на проект, это не играет никакой роли. Как только он становится общий на несколько проектов (а у меня было так) — начинается ручная работа. Таблицы прода и дева, таблицы соседнего проекта, таблицы, которые создал в тестах и забыл, все они живут в одном списке и норовят столкнуться именами.

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

Поэтому в конфигурации есть префикс, а в имени таблицы — плейсхолдер для него:

MANTICORE_PREFIX=myapp_
// читает таблицу myapp_products
\ManticoreDb::table('?products')->match('galaxy')->get();

Знак ? в начале имени — единственное, что нужно написать. Если приложение работает только со своими таблицами, знак ? можно не писать вовсе:

MANTICORE_FORCE_PREFIX=true

Тогда префикс добавится к любому имени таблицы. Зачем тогда плейсхолдер, а не всегда безусловный префикс по умолчанию? А это отработка частного случая, когда в коде и к своим, и к чужим таблицам, и их имя трогать нельзя. Явный знак ? разделяет “свои” таблицы и “чужие” (которые читаются, как есть) прямо в коде запроса, а не в голове.

Причина третья: синтаксис Laravel

Вот пример запроса из php через официального клиента:

$result = $client->table('products')
    ->search('galaxy')
    ->filter('price', 'lte', 1000)
    ->sort('price', 'desc')
    ->limit(10)
    ->get();

Этот же запрос с помощью монй библиотеки:

$rows = \ManticoreDb::table('products')
    ->match('galaxy')
    ->where('price', '<', 1000)
    ->orderBy('price', 'desc')
    ->limit(10)
    ->get();

Строчка в строчку то же самое, но во втором случае разработчику не нужно помнить, что вместо where здесь надо писать filter, оператор сравнения пишется словом lte, а сортировка — sort. Используя библиотеку, разработчик пишет where и orderBy, потому что весь остальной код в проекте написан так. Экономия — не в символах, а в отсутствии переключения.

А дальше начинается то, ради чего нужна именно интеграция с фреймворком, а не просто похожие имена методов.

Ответ — коллекция. Метод get() возвращает Illuminate\Support\Collection, а строка — объект Row:

$rows = \ManticoreDb::table('products')->match('galaxy')->get();

$titles = $rows->map(fn ($row) => $row->title)->all();

Row при этом читается и как объект, и как массив:

$row->title;     // привычно для Laravel
$row['title'];   // так же, как отвечает ядро пакета

$row->toArray();
json_encode($rows);   // массив объектов, как и ждёт JSON API

Постраничная навигация — штатная. С теми же аргументами, что у DB::, и с тем же links() в шаблоне:

$products = \ManticoreDb::table('products')->match('galaxy')->paginate(15);
@foreach ($products as $product)
    {{ $product->title }}
@endforeach

{{ $products->links() }}

Есть и simplePaginate() — без COUNT(*), когда шаблону нужны только “вперёд” и “назад”.

Транзакции пишутся как обычно. Manticore выполняет BEGIN / COMMIT / ROLLBACK на real-time таблицах:

\ManticoreDb::transaction(function ($connection) {
    $connection->table('products')->insert($data);
    $connection->table('log')->insert($record);
});

Миграция — обычный класс миграции:

use avadim\Manticore\QueryBuilder\Schema\SchemaTable;

public function up()
{
    \ManticoreDb::create('products', function (SchemaTable $table) {
        $table->timestamp('created_at');
        $table->string('name');
        $table->text('description');
        $table->float('price');
    });
}

public function down()
{
    \ManticoreDb::drop('products', true);
}

Ошибка запроса — исключение, а не пустая выборка. Отклонённое чтение бросает QueryErrorException: неверный запрос — это дефект, и молча вернуть ноль строк здесь хуже, чем упасть.

use avadim\Manticore\QueryBuilder\QueryErrorException;

try {
    $rows = \ManticoreDb::table('products')->match($text)->get();
}
catch (QueryErrorException $e) {
    report($e);

    $rows = collect();
}

Чаще всего запрос отклоняет полнотекстовая строка, пришедшая от посетителя, — её стоит пропустить через \ManticoreDb::escapeMatch($text) до попадания в match().

Почему php-пакетов два

Я сделал два пакета: avadim/manticore-query-builder-php — ядро без единой зависимости от фреймворка, и avadim/manticore-query-builder-laravel — обвязка поверх него.

Разделение не ради красоты. Ядро отвечает обычными массивами именно потому, что Collection в нём означала бы illuminate/support в библиотеке, которая должна работать и в проекте на Symfony, и в скрипте без фреймворка вообще. А обвязка добавляет ровно то, что имеет смысл только внутри Laravel: сервис-провайдер, файл конфигурации, именованные соединения и ответы в том виде, в каком их ждёт фреймворк.

Для приложения это значит, что до одного и того же соединения ведут три равнозначные дороги:

use avadim\Manticore\Laravel\Manager;

// глобальный алиас, который регистрирует сам пакет
\ManticoreDb::table('products')->find($id);

// фасад, если глобальный алиас не по душе
ManticoreDb::table('products')->find($id);

// внедрение зависимости, если класс должен тестироваться без фреймворка вокруг
public function __construct(Manager $manticore) { … }

Грабли, о которых важно помнить

Транзакционного DDL в Manticore нет. Миграция, упавшая на середине, оставит всё, что успела создать. Писать down() нужно так, чтобы он отработал и на недостроенной таблице.

Одна операция на выражение. ADD COLUMN a, ADD COLUMN b сервер не примет — это два запроса, и падение второго оставит первый применённым.

Новая колонка не переиндексирует старые строки. Полнотекстовое поле, добавленное к работающей таблице, найдёт только то, что записано после него. Чтобы колонка что-то значила, данные нужно записать заново.

Кеш схемы живёт столько же, сколько соединение. Соединение запоминает DESCRIBE каждой таблицы, чтобы не спрашивать сервер перед каждым запросом и приводить значения к типам PHP.

Если вы меняете схему с помощью методов самого пакета – create(), alter(), addColumn(), dropColumn() и прочие – библиотека знает об этих изменениях, сбрасывает кеш схемы и и никаких проблем не возникает. Но бывают ситуации, когда кеш схемы надо сбрасывать явно:

\ManticoreDb::forgetSchema();

Условие для сборса кеша всегда одно и то же — схему изменили мимо вашего соединения, а объект Connection пережил это изменение. На практике это четыре случая.

1. ALTER через statement(). Сырой SQL проходит мимо билдера, и сбросить кеш ему нечем:

\ManticoreDb::connection()->statement('ALTER TABLE products ADD COLUMN rating int');
\ManticoreDb::forgetSchema();

2. Миграция в другом процессе. php artisan migrate отработал, а воркер Horizon или Octane, поднятый до неё, продолжает жить со старым кешом. Самый частый случай, потому что деплой обычно так и выглядит.

3. Таблицу изменил кто-то ещё — indexer, соседний сервис, другое приложение на том же демоне.

4. Другое именованное соединение того же приложения. Изменили через connection('admin'), читаете через connection('default') — это два разных пула.

Важно помнить: под php-fpm соединение живёт ровно один HTTP-запрос, поэтому в контроллере вызывать forgetSchema() бессмысленно почти всегда: кеш и так не переживёт ответ. Долгоживущие процессы — Octane, очереди, планировщик, длинные CLI-команды — единственное место, где это реально нужно.

Отдельно стоит держать в голове тесты: если тест-кейс создаёт и удаляет таблицы через statement() и переиспользует соединение между тестами, кеш надо сбрасывать в setUp(), иначе следующий тест увидит схему предыдущего.

Когда брать официальный клиент

Если вы работаете именно по HTTP JSON API — например, тот же код ходит в Manticore из нескольких языков и формат запросов общий. Если инфраструктура не пускает вас на 9306. И если важно, чтобы клиент поддерживался вендором и выходил вместе с релизами сервера.

Мой пакет — SQL-only, HTTP JSON API он не использует вовсе. Это осознанное ограничение, а не недоделка: первая из трёх причин, по которым он написан, ровно в том и состоит, что он говорит с сервером на SQL.

Где почитать подробнее

Курс по Temporal в связке Laravel + RoadRunner 🎉

Полгода делал большой курс по Temporal и наконец закончил. Хочу поделиться с сообществом – тем более что весь проект построен на знакомом стеке: Laravel + RoadRunner + Temporal PHP SDK.

Получилось 18 видео, около 16 часов контента. Если считать всё «за кадром» – подготовку, записи и монтаж в свободное время — вышло примерно 133 часа работы, больше обычного рабочего месяца.

Про что курс и чем он отличается

Это не набор толко академических видео, а бизнес-история одного проекта. Мы шаг за шагом строим агрегатор доставки еды и на каждом уроке добавляем новую возможность Temporal — от первого workflow до production-настройки воркеров. Движемся от простого к сложному, всё привязано к реальной бизнес-логике, а не к абстрактным примерам.

Если коротко, после курса вы будете понимать:

  • когда классические очереди (Redis, RabbitMQ) начинают мешать и что вместо них даёт durable execution;
  • как устроены Workflow, Activity, сигналы, queries, таймеры и детерминизм в PHP SDK;
  • как реализовать Saga-паттерн, версионирование и долгоживущие процессы;
  • как всё это тестировать, наблюдать и готовить к production на RoadRunner.

Список уроков

Основы

Реактивность

  • Signals — внешние события, их обработка и идемпотентность
  • Timers and Timeouts — таймеры и awaitWithTimeout
  • Queries — синхронное чтение состояния и best practices

Масштабирование логики

  • Child Workflows — декомпозиция, parent-child связь и политики
  • Async Activities — «параллельный» запуск, Promise::all и Promise::any

Надёжность

  • Saga pattern — распределённые транзакции, компенсации, Choreography vs Orchestration
  • Versioning — ошибка недетерминизма и getVersion
  • Continue as new — долгоживущие workflow и лимиты Event History

Продвинутое и Production

  • Schedules — аналог cron в Temporal
  • Side Effects and Updates — детерминизм и сравнение Updates с Signals/Queries
  • Testing — отладка, ускорение времени, replay-механизмы и их оптимизации
  • Observability — логи, метрики, трейсинг и Search Attributes
  • Worker Tuning — настройка воркеров PHP/Go SDK и Graceful Shutdown

Весь материал доступен плейлистом на YouTube. Исходный код каждого урока лежит на GitHub.

Что дальше

Материала по Temporal ещё много, и останавливаться я не планирую. Но думаю над форматом продолжения — что было бы полезнее сообществу: разборы реальных кейсов, глубокие технические дайвы во внутренности SDK или что-то ещё? Буду рад мнению в комментариях.

Если курс окажется полезным — заходите на YouTube и в телеграм, там я продолжаю писать про Temporal и смежные темы. Отдельно собрал подборку материалов: чаты, статьи и ресурсы по Temporal.

Буду благодарен за конструктивную критику и вопросы.

1

~/.claude в Git — это половина задачи. Вторая половина — общая память Claude Code

Тут недавно был отличный пост про то, как вынести ~/.claude в git-репозиторий. Я делаю так же — но у git-подхода есть слепое пятно: он версионирует то, что Claude умеет, а не то, что он узнал про твои проекты. Вот как я закрыл это для команды.


Тот самый пост — про то, как превратить ~/.claude в git-репозиторий: скиллы, агенты, слэш-команды, MCP-конфиг, хуки под симлинками через make install, синк между машинами, не теряется при блокировке аккаунта. Подход правильный, сам так делаю.

Но за 2 года на нескольких проектах я упёрся в то, что git-конфиг не решает.

Статика vs динамика

Git версионирует статику — то, что Claude умеет: твои скиллы, агентов, команды, правила. Это здорово и переносимо.

Чего там нет — это динамики: того, что Claude узнал про конкретный проект за прошлые сессии. Например:

  • здесь миграции катятся не через migrate, а через свою обёртку над artisan;
  • вот этот «временный» модуль в платёжке трогать нельзя, на нём прод;
  • у этого юрлица специально задан невалидный БИК, через него работает интеграция с банком.

Это не скилл и не правило — это накопленный контекст по проекту. В Claude Code он копится через auto-memory, но живёт в отдельной базе, а не в ~/.claude/*.md. Поэтому в git он не попадает. А значит:

  • новая сессия на другом ноуте начинает с нуля;
  • новый человек в команде не наследует то, что Claude уже понял про проект — переоткрывает те же грабли;
  • даже у тебя одного контекст «вчерашнего дня» не переезжает между машинами, хотя конфиг — переехал.

Git-pull этого по своей природе не лечит: он пофайловый и point-in-time. А память по проекту нужна живой, общей и искомой по смыслу, а не «закоммить — запушь — подтяни».

Что я сделал

Вынес слой памяти на сервер. ruflo-hub — небольшая Docker-обёртка: берёт MCP-сервер памяти, оборачивает stdio в HTTP, и Claude Code у всех в команде ходит в одно общее хранилище памяти. Один человек разобрался в особенности проекта — и у остальных Claude это уже знает.

Сразу честно, иначе про этот проект нельзя. В основе — ruflo (форк claude-flow), и у него репутация. Я сам проверял: большая часть из «300+ MCP-инструментов» — нерабочие заглушки. swarm_init оставляет agentCount: 0, neural_train возвращает Math.random(), «агенты» — это markdown-файлы. Как swarm-оркестратор это в основном театр (об этом же — обсуждение на r/ClaudeAI).

Реально работает ровно одна часть — слой памяти: настоящая ONNX-модель (MiniLM) + HNSW-индекс, SQLite-персист и auto-memory-хук. ruflo-hub — тонкая обёртка, которая отдаёт по сети только этот слой и мостит его в Claude Code. Никакого swarm/neural и сотен заглушек в контексте — мы их просто не грузим. То есть это ровно тот рабочий ~1% от ruflo, без остального.

Как выглядит на практике

Поднять сервер:

docker compose up -d
curl http://localhost:3000/health

Память хранится в SQLite (memory.db, WAL-режим). PostgreSQL в комплекте опционален — он не основное хранилище, а нужен только под ruflo ruvector import/export.

Подключение проекта — самоконфигурирующийся скрипт с того же сервера:

curl http://<сервер>:3000/setup | bash

Да, это curl | bash со своего сервера — скрипт отдаётся в открытом виде, гляньте перед запуском. Он кладёт хелперы в .claude/helpers/ (auto-memory-хук + statusline) и правит .claude/settings.json (хуки SessionStart → import / Stop → sync). Дальше Claude Code пишет и читает память на сервере сам: memory_store на находки, memory_search — по смыслу (не grep: найдёт «паттерн JWT-авторизации» по запросу «token-based login flow»). В statusline видно число векторов и статус MCP.

Честно про эксплуатацию (раз уж про честность)

  • Была реальная утечка. WASM-ФС у sql.js копил по полному образу БД на каждое открытие — прод-инстанс дорос до ~36 ГБ RSS за 6 недель. Нашли heap-снимком (V8-heap плоский — течёт нативка), корень — в реестре контроллеров @claude-flow/memory. Зарепортили апстриму (#2432), и в ruflo 3.14.2 это уже исправлено; у себя держим ещё RSS-watchdog как страховку.
  • Про версию — без самообмана. Образ собирается на ruflo@latest и пересобирается еженедельно, так что :latest со временем уедет вперёд. Аудит и фиксы ниже я делал на 3.14.2. Хотите воспроизводимость — берите конкретный тег (:1.3.0 или :<sha>), а не :latest, и проверяйте docker exec … ruflo --version.
  • Security-история. В старых версиях ruflo (3.1.0-alpha.55 – 3.5.2) был #1375: вредоносный preinstall-скрипт и скрытая инъекция в описаниях инструментов. Раз хаб ставит пакет и раздаёт описания клиентам — перепроверил версию, которую шиплю (3.14.2): preinstall-хуков нет, все 305 описаний чистые. В этой версии вылечено — но фиксируйте версию и проверяйте сами.
  • Бэкап WAL-безопасныйmemory.db в WAL-режиме, бэкапим том целиком (memory.db + -wal + -shm); копия одного memory.db даёт database disk image is malformed.

Что обычно спрашивают первым

Когда я кидал это в один Laravel-чат, первым прилетело не «как», а «а как с безопасностью общей памяти» и «чем это лучше папки docs + claude.md». Отвечу честно.

«А если кто-то накидает в общую память инъекций? Джун насыплет своих инструкций? А если они нужны только в одном проекте, а в другом нет?»

Память разводится по namespace — Claude сам заводит их по проектам, плюс можно явно сказать «сохрани/прочитай в такой-то namespace». Если у проектов разные trust-зоны и утечки между ними недопустимы — это не «одна общая помойка»: поднимаешь отдельный ruflo-hub под бизнес-скоуп (один сервер = один периметр доверия), личное — на своём.

А вот честное ограничение: per-user ACL и автофильтра инъекций из коробки пока нет. Сейчас это организационно — общая память это общая граница доверия: подключаешь тех, кому доверяешь, ревью памяти на тимлиде. Технического «джун не может писать сюда» я ещё не сделал — и не буду делать вид, что сделал. Если у тебя в команде это критично — пока только разводка по отдельным серверам.

«Чем лучше папки docs/ и claude.md

Они никуда не деваются, и память их не заменяет. Разница в двух вещах. Первое — часть рабочих вещей в гит класть нельзя или не нужно, а память не в гите. Второе — память живая и сразу доступна агенту через хуки + поиск по смыслу. Часто в ней лежат как раз ссылки на эти доки: Claude, просматривая память, сам быстро находит «а, про это есть док вот тут» — даже если конкретный разработчик не знает, что кто-то положил эту доку сто лет назад. То есть docs/claude.md — статика для людей, а память — индекс по опыту для агента, который на эту статику ссылается.

Когда это НЕ нужно

Зеркалю мысль из того поста — не ради инфраструктуры:

  • соло-разработчику или на 1–2 проекта — оверкилл; git-конфига из того поста хватит за глаза;
  • если ты осознанно начинаешь каждую сессию с чистого листа — тоже мимо.

Нужно — когда команда и много проектов, и хочется, чтобы то, что Claude понял на одном проекте или у одного человека, доходило до всех без пересказа.

Итого

~/.claude в git и общая память — про разные половины одной задачи: статика (что Claude умеет) едет в git, динамика (что он узнал про твои проекты) — в общий сетевой слой памяти. У меня работает связка из обоих.

  • Код: jazz-max/ruflo-hub (образ на Docker Hub — jazzmax/ruflo-hub; да, ник на GitHub и в Docker Hub исторически разный)
  • Апстрим-следы: issue #2432 (утечка, уже зафикшена), discussion #2433