- BrainTools - https://www.braintools.ru -
Отчёт пользователя вскрыл утечку между диалогами в query-провайдере ядра — 6 из 6. Что ещё в 3.6.0: AS2/EDI, общий Kestrel, Camel-паритет.
Год стек рос на наших собственных задачах: мы писали то, что нужно было нам, и проверяли на своей проде. С весны им начали пользоваться посторонние люди — и характер входящих сообщений изменился. Вместо «а поддерживаете ли вы X» приходят разборы: воспроизведение, номера строк в наших исходниках, обходные пути, которые человек уже написал у себя, пока ждал ответа.
Это самое ценное, что может случиться с проектом. Спасибо всем, кто не поленился написать вместо того, чтобы молча уйти на другую библиотеку. Один такой отчёт лежит в основе половины этого релиза — и вскрытый им дефект оказался вовсе не там, где его нашли.
Итак, redb 3.6.0: ядро, интеграционный движок redb.Route, рантайм redb.Tsak и OIDC-сервер redb.Identity — все на одном номере, 63 пакета.
Разработчик чат-продукта на redb.Route.Llm прислал: «первый ход нового человека приезжает в модель с историей другого человека, а его первое сообщение прицепляется в чужое дерево».
Звучит как баг LLM-коннектора — истории диалогов хранятся деревьями, что-то напутали с загрузкой ветки. Мы пошли смотреть и обнаружили, что коннектор ни при чём.
В query-провайдере ядра WhereLeaves() замещал корневой CTE вместо того, чтобы работать предикатом поверх него. То есть:
// Намерение: листья ЭТОГО дерева
var leaves = await redb.TreeQuery<MessageProps>(root)
.WhereLeaves()
.ToListAsync();
на деле означало «самые свежие листья схемы во всей базе». Фильтра по корню в запросе просто не оставалось.
Дальше арифметика простая. В многопользовательском проде «самый свежий лист схемы» — это транскрипт того, кто писал последним глобально. Не только на первом ходу: на любом. Все диалоги схлопываются в одну ленту. И хуже: следующее сообщение прицепляется родителем в чужое дерево, то есть данные не только читаются криво — они криво пишутся.
Дефект оказался кросс-провайдерным: шесть из шести — Postgres, MSSql, SQLite, каждый в Free и Pro. Чинили одинаково на всех: tree_leaves/tree_roots теперь засеиваются от корня, а не подменяют его.
Отдельная часть этой истории — SQLite. Free-тир исполняет запросы в нативном расширении, и правка живёт в redb_pvt.c, а не только в C#. Пакет со старыми бинарями отдал бы исправление на managed-стороне и оставил утечку в Free — молча, без единой ошибки [1] в логе. Поэтому расширение пересобрано под все три RID (win-x64, linux-x64, linux-arm64), и это проверено сверкой хешей против предыдущего релиза, а не по датам файлов: размеры linux-сборок совпали до байта, и метки времени тут ничего не доказывали.
Мораль, которую стоит забрать даже если вы не пользуетесь redb: фильтр, который «не сработал», и фильтр, который «заменил собой область поиска», выглядят в коде одинаково, а на данных различаются катастрофически. Второй не даёт ошибки — он даёт правдоподобный ответ не про то.
Формулировка автора: «тулза получает обмен без контекста запроса — и единственный простой способ дать ей user id или tenant — просить модель передать его аргументом, что открывает prompt-injection-эскалацию».
Это точная постановка проблемы безопасности. Если инструмент узнаёт, от чьего имени его вызвали, из аргумента, который сформировала модель, — значит достаточно уговорить модель подставить туда чужой идентификатор.
Что было: движок копировал в обмен инструмента жёстко зашитый список из трёх заголовков и ничего больше. Что стало:
маршруты инструментов исполняются дочерним обменом агентского, а не «голым» — контекст, скоуп и трассировка наследуются;
принципал и аудит-теги доезжают до инструментов — причём как значения, вычисленные для прогона, а не как сырые заголовки. Разница существенная: ?user=${header.X-User-Id} и ?audit= разрешаются движком, и простое копирование заголовков их бы не поймало;
добавлен .PropagateToolHeaders(...) — явный список дополнительных заголовков, которые нужно пробросить; * на конце работает как префикс. Список по умолчанию пуст: проброс — осознанное решение, а не поведение [2] «на всякий случай».
route.From("http://0.0.0.0:8080/chat")
.Llm("llm://openai/gpt-4o?user=${header.X-User-Id}&audit=tenant:${header.X-Tenant}")
.PropagateToolHeaders("X-Tenant", "X-Request-*")
.To("direct://reply");
Именно из-за этого метода релиз минорный, а не патч: новая публичная поверхность номером патча не выпускается.
Крупная розница, 3PL-операторы, банки с host-to-host каналом, автопром — двадцать лет обмениваются заказами, счетами и уведомлениями об отгрузке по AS2: подписанный и зашифрованный S/MIME поверх HTTP с подписанной квиткой в ответ. В .NET до сих пор это означало коммерческий шлюз или Java-сервер сбоку от вашей интеграции.
Теперь AS2 — обычный коннектор redb.Route, схемы as2: и as2s:. Обе стороны, синхронный и асинхронный MDN, подписанная квитанция, полная матрица алгоритмов (sha-1/256/384/512 × aes-128/192/256-cbc, 3des, опциональное сжатие RFC 3274). Криптография — MimeKit поверх Bouncy Castle, то есть тот же фундамент, на котором AS2-индустрия и совместима.
Интероперабельность проверена против живого OpenAS2 v4.9.0 в обе стороны — подписанное и зашифрованное сообщение, положительный MDN, сверка MIC.
Подробный разбор с маршрутами, партнёрскими профилями и разницей со шлюзом — в отдельной статье про AS2 [3].
AS2-приёмник — это HTTP-сервер. У вас уже есть HTTP-маршруты. Партнёр хочет один эндпоинт, TLS терминируется один раз — значит и то, и другое должно жить на одном порту.
Раньше мультиплексор Kestrel (один сервер на пару host:port) лежал внутри redb.Route.Http. Чтобы AS2 мог им пользоваться, ему пришлось бы зависеть от коннектора общего назначения — а это тянет в приложение весь HTTP-транспорт ради одного класса и выворачивает направление зависимостей наизнанку.
Поэтому SharedHttpServerManager вынесен в отдельный пакет redb.Route.Http.Hosting [4]. Оба коннектора зависят от него, друг от друга — нет; регистрация идемпотентна, каждый резолвит один и тот же синглтон. Типы остались в namespace redb.Route.Http, так что существующий код править не нужно.
Здесь стоит рассказать честную часть, потому что она поучительна. Вынос приехал уже после того, как redb.Route.Http был опубликован на nuget.org [5]. Заменить опубликованную версию нельзя. А redb.Route.As [6]2 ссылается на Hosting, но не на Http — значит резолвер NuGet никогда бы сам не поднял Http до исправленной версии, и у пользователя связки «старый Http + As2» тихо оказались бы два независимых Kestrel: ровно та проблема, ради которой вынос и делался.
Лечится это не хитростью, а честным перевыпуском: вся линия redb.Route ушла одним номером, следом Tsak и Identity, чтобы их пины тоже переехали. Никакая комбинация 3.6.0 не соберёт битую пару.
Message History — след, который обмен оставил в маршруте: каждый узел с временем, идентификатором и меткой; при исчерпании ретраев след выгружается в лог, и видно, на каком шаге всё встало.
XSLT — .Xslt(...), .XsltContent(...) и компонент xslt:. Нужен ровно там, где нужен: чужие форматы, которые проще преобразовать таблицей стилей, чем кодом.
Routing Slip — .RoutingSlip(...): список эндпоинтов вычисляется во время выполнения, обмен идёт по нему последовательно.
Плейсхолдеры в URI эндпоинтов — {{key}} и {{key:default}}, как в Apache Camel. Один и тот же маршрут работает в трёх окружениях без ветвлений в коде.
Не в этом релизе, а в предыдущем, но заслуживает упоминания, потому что цифра говорит сама.
Штатный консьюмер опрашивал очередь блокирующим MQGET-WAIT на managed-клиенте IBM.WMQ, у которого внутренний тик ~500 мс и он не зависит от waitInterval. На простаивающей очереди сообщение доезжало через 250–500 мс после появления. Публичного async-API у managed-клиента нет ни в одной версии 9.4.x.
Приём переведён на событийную модель через MessageListener XMS — задержка упала до единиц миллисекунд. Режим включается явно; поллинг остался режимом по умолчанию.
Регистрация работала так: посмотреть, есть ли пользователь с таким адресом → если есть, отдать 409 → если нет, создать. Между проверкой и вставкой две одновременные регистрации обе видят «свободно» и обе пишут один адрес. То есть RequireUniqueEmail под конкурентностью не держался. У логина такой проблемы не было никогда — там реляционный UNIQUE.
Теперь гарантию даёт база: частичный уникальный индекс UX_users_email на users(email) с условием WHERE _email IS NOT NULL. Частичный не для красоты — пустых адресов много, а у SQL Server обычный UNIQUE допускает лишь один NULL. Адрес нормализуется (trim + lower-invariant) перед проверкой и вставкой, иначе A@x.com [7] и a@x.com [8] были бы для индекса разными людьми. Нарушение индекса отдаётся как 409 duplicate, а не как 500.
Проверка перед вставкой осталась — но теперь она то, чем и должна быть: быстрый детерминированный ранний отказ, а не сама гарантия.
Заодно из горячего пути регистрации убраны отладочные леса: секундомер с двумя десятками вызовов-пустышек и VERIFY-блок, который после каждого создания слал два лишних запроса в базу, чтобы накормить эти пустышки. Минус два обращения к БД на регистрацию.
Очередь недоставленных сообщений писалась и читалась через Sql-компонент, и на PostgreSQL ломалась дважды.
Захват не сохранялся вообще: флаг replayable биндился как int 1/0 в колонку BOOLEAN, а в Postgres нет неявного приведения integer → boolean — весь INSERT падал с 42804. И собственный catch захвата это исключение проглатывал, так что недоставленные сообщения просто исчезали.
Второе: временные метки биндились ISO-строками против нативных timestamptz. Неявного text → timestamptz в операторе сравнения тоже нет, поэтому DELETE ... WHERE occurred_at < @cutoff падал с 42883 — то есть суточная джоба ретенции и любой запрос дашборда с фильтром по дате.
Оба лечения одинаковые по духу: биндить типами, а не текстом. bool → boolean/bit/0-1, DateTimeOffset → нативный timestamp.
SumRedbAsync и AverageRedbAsync кидали InvalidOperationException на пустой выборке. SUM/AVG без строк — это NULL в SQL, а результат читался через JsonElement.GetDecimal, которому нужен Number. Путь стал достижимым только после того, как в 3.5.0 агрегаты по базовым полям начали учитывать фильтр: до этого WhereRedb(...) терялся, агрегат всегда шёл по всей схеме и NULL не получался никогда. Один фикс открыл дорогу другому багу — обычная история, если фильтр раньше молча игнорировался.
Fluent DSL и парсер URI разошлись в кодировании: строитель кодировал значения не так, как парсер их потом разбирал, и значения с пробелами не переживали круговой обход. Теперь обе стороны используют Uri.EscapeDataString.
dotnet add package redb.Core
dotnet add package redb.Postgres # или redb.MSSql / redb.SQLite
dotnet add package redb.Postgres.Pro # Pro — бесплатно, без ключа
dotnet add package redb.Route
dotnet add package redb.Route.As2
Pro остаётся проприетарным, но бесплатным и без лицензионного ключа на всей линии 3.x. Всё собрано под .NET 9.
Исходники — github.com/redbase-app [9], про хранилище — redb.ru [10].
Продолжаем разбирать входящее. Журнал отзывов ведётся в репозитории, и туда попадает каждый внешний сигнал — валидный, спорный и отклонённый, с оценкой и ссылкой на то, куда он зафиксирован. Если вы нашли что-то похожее на два дефекта выше — пишите: разбор с воспроизведением стоит дороже любого пожелания.
Другие мои статьи — redb.ru/articles [11], ещё — на Хабре [12].
If this was useful — a ⭐ on GitHub helps others find it.
Автор: grelikt
Источник [13]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/34378
URLs in this post:
[1] ошибки: http://www.braintools.ru/article/4192
[2] поведение: http://www.braintools.ru/article/9372
[3] отдельной статье про AS2: https://redb.ru/articles/as2-edi
[4] redb.Route.Http.Hosting: http://redb.Route.Http.Hosting
[5] nuget.org: http://nuget.org
[6] redb.Route.As: http://redb.Route.As
[7] A@x.com: mailto:A@x.com
[8] a@x.com: mailto:a@x.com
[9] github.com/redbase-app: https://github.com/redbase-app/
[10] redb.ru: http://redb.ru
[11] redb.ru/articles: https://redb.ru/articles
[12] Хабре: https://habr.com/ru/users/grelikt/articles/
[13] Источник: https://habr.com/ru/articles/1070192/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1070192
Нажмите здесь для печати.