redb 3.4.0: переигрываем упавшее, патчим фреймворк без пересборки и раздаём права — экосистема.NET

от автора

redb ecosystem

redb ecosystem

Написать систему и эксплуатировать систему — две очень разные инженерные задачи. Первая заканчивается на «работает под нагрузкой». Вторая начинается с вопросов, которые задаёт человек на дежурстве: что упало ночью и как это переиграть? кто нажал force‑stop? можно ли выкатить патч библиотеки, не пересобирая весь рантайм? почему пароль сервис‑аккаунта видно на странице дашборда?

Прошлые релизы нашей экосистемы отвечали на первый вопрос. 3.4.0 — целиком про второй.

Напомню, из чего экосистема состоит: типизированное хранилище redb поверх Postgres/MSSQL/SQLite, интеграционный движок redb.Route (наш ответ Apache Camel под.NET, 30+ коннекторов), рантайм redb.Tsak с дашбордом, hot‑reload и кластером, и сервер идентичности redb.Identity (OIDC/OAuth 2.1). Всё это работает у нас в проде и публикуется пакетами, образами и standalone‑архивами.

В 3.4.0 появились четыре вещи, каждая из которых — про день после деплоя:

  • Replay‑чекпоинты — сохранённая точка внутри маршрута, из которой хвост можно переиграть позже. Сквозная фича: примитив в движке, очередь недоставленных в рантайме, кнопка в дашборде.

  • Разделяемый слой рантайма — фреймворк лежит рядом с приложением, а не внутри. Патч библиотеки — это подмена DLL, а не релиз всего.

  • Секреты вне логов — декларативная редакция URI эндпоинтов и именованные ConnectionFactory, чтобы пароль вообще не попадал в маршрут.

  • Разграничение доступа и подпись модулей — роли на управляющем API, персистентный аудит и криптографический якорь доверия для кода, который рантайм грузит в свой процесс.

Плюс, тихо, но важно: redb.Identity переехал на общую нумерацию экосистемы (с 1.2.2 сразу на 3.4.0) — теперь версия одна на всё, и вопрос «какой Identity дружит с каким Route» превратился из справочника в одну цифру.

И, как и с 3.3.0: все Pro‑возможности бесплатны на всей линейке 3.x. Без ключей, лицензионного сервера и регистрации. dotnet add package redb.Postgres.Pro — и работает.

Цикл про redb и redb.Route. Свежие статьи — сверху:

Полный список — в профиле. Исходники: github.com/redbase‑app. Про саму БД: redb.ru.


Часть 1. Replay: точка сохранения внутри маршрута

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

Проблема, которую все решают вручную

Маршрут из пяти шагов. Третий шаг списал деньги с карты, четвёртый — упал, потому что сервис квитанций лежал десять минут. Что делать?

Классические ответы так себе. Ретрай всего маршрута спишет деньги дважды. Ретрай с «текущего» состояния exchange опасен: тело уже перелопачено предыдущими шагами, заголовки перезаписаны — вы переигрываете не то, что было, а то, во что оно превратилось по дороге к падению. Ручной разбор в 3 часа ночи — вообще не ответ.

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

Как это выглядит в DSL

From("timer://poll?period=5000")    .Process(chargeCard)    .Replayable("after-charge")      // save-point: карта уже списана        .Process(sendReceipt)        .To("http://receipts")    .EndReplayable();

Всё, что после маркера, — это «хвост». Каждый проходящий exchange снимается в снимок и кладётся в exchange.Properties["route.checkpoint"] (побеждает последний маркер). Есть и лямбда‑форма, если не любите парные End:

.Replayable("after-charge", r => r    .Process(sendReceipt)    .To("http://receipts"))

Переигрывание — типизированный внутрипроцессный вызов:

await context.ReplayAsync(routeId, "after-charge", snapshot, ct);

А если маркер объявлен как exposed: true, он дополнительно публикуется как direct:__replay:{routeId}:{name} — и в него можно просто .To(...) из другого маршрута. По умолчанию маркер снаружи не виден: точка сохранения не должна случайно становиться публичным входом.

Snapshot() — и почему это не Clone()

Здесь пришлось добавить в ядро новый примитив, и это самая интересная часть.

У IExchange давно есть Clone(). Но Clone() намеренно разделяет тело с оригиналом — на этом построена агрегация в Splitter: ветки должны видеть один и тот же объект. Для точки сохранения это ровно противоположность нужного: если тело общее, следующий шаг мутирует его на месте, и «замороженное» состояние оказывается вовсе не замороженным.

Поэтому появился IExchange.Snapshot() / IMessage.Snapshot() — честная глубокая изолированная копия. Первая версия умеет неизменяемые тела, byte[] и ICloneable, а на всём остальном громко падает, а не делает молча поверхностную копию. Это принципиально: тихо разделённое тело в системе точек сохранения — это баг, который проявится через месяц и в самый неудачный момент.

Снимок не тащит за собой DI‑скоуп: это спящие данные, лежащие в базе, а не живой exchange, — иначе каждое сохранение подтекало бы соединением.

Кстати, заодно поправили доксу на Clone(): там годами было написано «deep copy», хотя копия всегда была не глубокой. Теперь у обоих методов контракт написан честно.

Куда чекпоинт можно ставить, а куда нельзя

Точка сохранения не может пересекать ветвящуюся композицию (Choice, TryCatch): «хвост» перестаёт быть однозначным — какой из ветвей это хвост? А внутри долгоживущей транзакции она допустима, но заслуживает предупреждения: переигрывание происходит вне исходной транзакции.

Можно было зашить пару if в валидатор. Мы сделали общий механизм, потому что подобные ограничения будут появляться и дальше:

  • ICompositeScope / IDurableScope — категории областей;

  • IScopeNestingRule — узел сам объявляет Allowed / Warn / Forbid относительно категории предка;

  • IBranchingDefinition — определения, у которых дети живут не в Outputs (ветки Choice, catch/finally у TryCatch), отдают их для обобщённого обхода дерева.

Валидатор применяет правила единообразно: Forbid — ошибка сборки маршрута, Warn — запись в лог. Новое структурное ограничение теперь пишется на определении, а не в валидаторе — это то, чего в Camel‑подобных движках обычно не хватает: ограничения там имеют привычку расползаться по чужому коду.

Второй слой: очередь недоставленных в Tsak

Примитив в движке хорош, но дежурному нужна не абстракция, а список «что упало ночью». В Tsak 3.4.0 поверх чекпоинтов появился DLQ с переигрыванием.

CheckpointDlqHandler — обработчик ошибок третьего уровня, ставится на каждый модульный контекст. При падении он читает route.checkpoint с exchange и кладёт его в очередь недоставленных. Ключевое свойство: включение по построению. Чекпоинт оставляют только маршруты с .Replayable(), значит, только они и попадают в DLQ. Очередь не дерётся с брокером или транзакцией, у которых своя доставка и свой повтор, — она подбирает ровно то, что вы явно пометили как «это надо уметь переиграть руками».

Дальше — обычная эксплуатационная механика:

  • tsak_dlq — плоская таблица (PG / MSSQL / SQLite), создаётся на старте;

  • ExchangeSnapshotCodec — сериализация снимка для долговременного хранения. byte[] и string ходят туда‑обратно байт в байт, остальное — через System.Text.Json с восстановлением точного CLR‑типа, если его сборка загружаема. Несериализуемое тело сохраняется видимым, но помеченным как непереигрываемое — захват никогда не ломается целиком;

  • API: GET /api/exchanges/failed (фильтры, страницы), POST /api/exchanges/{id}/replayDELETE /api/exchanges/{id} — с ролями и аудитом;

  • CLI: tsak dlq list | replay | discard;

  • страница Dead‑letter в дашборде с серверными фильтрами и постраничностью — большая таблица никогда не уезжает целиком в браузер;

  • ретенция — как полноценный маршрут cron://tsak-dlq-retention на системном контексте (Tsak:Dlq:RetentionDays, по умолчанию 30). Не скрытый таймер внутри рантайма, а обычный маршрут, видимый в API, дашборде и на странице планировщика. Своя же собственная кухня, приготовленная на своём же движке.

Про семантику скажу прямо, без маркетинга: это at‑least‑once и вручную. Хвост может выполниться больше одного раза, поэтому побочные эффекты в переигрываемой части должны быть идемпотентны. Это не durable execution в стиле Temporal и не претендует на него. Это инструмент дежурного: «покажи, что упало ночью, и перезапусти после фикса».


Часть 2. Разделяемый слой рантайма: патч фреймворка без пересборки

Вторая крупная вещь релиза — архитектурная, и она про скорость реакции.

Что было

Tsak — это рантайм, который берёт ваши маршруты (модули .tpkg) и превращает их в сервис с дашбордом, метриками и кластером. Раньше сам фреймворк — redb.Core(.Pro), провайдеры, redb.Route.* — лежал в bin приложения. Из этого следовало неприятное: патч любой библиотеки требовал пересборки и перевыпуска всего. Правка в коннекторе Kafka → новая сборка Tsak → новые архивы → новые образы → полный цикл проверки.

Что стало

Фреймворк уехал в Libs/shared/ и загружается оттуда на старте. В bin остаются только redb.Tsak.* и redb.Licensing.

worker/├── redb.Tsak.Worker.dll        ← приложение├── redb.Tsak.Core.dll└── Libs/shared/    ├── redb.Core.dll           ← фреймворк — подменяемый    ├── redb.Postgres.Pro.dll    ├── redb.Route.dll    ├── redb.Route.Kafka.dll    ← и коннекторы тоже    └── runtimes/…              ← вместе с нативными зависимостями

Выигрыш: бинарно совместимый патч любого листа, провайдера или коннектора — или новый бета‑коннектор — выкатывается подменой DLL в Libs/shared/. Ни пересборки, ни перевыпуска архивов Tsak и Identity. Патч фреймворка 3.4.0 → 3.4.1 становится копированием файла.

Как это сделано, коротко:

  • Ранний бутстрап. SharedRuntime.InstallEarly — самый первый оператор процесса в Program.cs, до того как тронут хоть один redb‑тип. Он ставит резолвер разделяемого слоя и загружает фреймворк побайтово — файл не блокируется, поэтому его и можно подменить на живой машине. Под капотом переиспользованы уже существующие примитивы: унификация трекера загруженных сборок (чтобы .tpkg‑модули видели ровно одну идентичность каждого redb‑типа), по‑сборочный резолвер нативных зависимостей (librdkafka, e_sqlite3), версионно‑толерантный форвардинг.

  • Быстрый отказ. Отсутствующая или битая DLL фреймворка в Libs/shared/ валит старт немедленно и с точным сообщением — какая сборка, где искали. Это радикально лучше, чем MissingMethodException через сутки под нагрузкой.

  • Гейт совместимости. На старте минор разделяемого redb сверяется с минором самой сборки Tsak. Патч‑расхождения разрешены — в них весь смысл. А вот расхождение минора или смесь миноров внутри Libs/shared/ останавливает запуск.

  • GET /api/system/assemblies (admin) — какой redb реально загружен: имя, версия, происхождение (shared / bin / runtime). Диагностический двойник подменяемого слоя, отвечающий на вопрос «какой redb сейчас крутится».

Инструментарий свёлся к одному манифесту (scripts/shared-manifest.psd1) и одному параметризованному scripts/build-shared.ps1 — он же для разработки, он же для публикации. Плюс новый scripts/refresh-shared.ps1: пересобрать одну библиотеку и положить её DLL в нужный Libs/shared/ — хоть в дев‑окружение, хоть в распакованный архив. Это и есть тот самый «патч без пересборки Tsak», уже как готовая команда.

Проверяли не на словах: 0 сборок фреймворка в корне bin против 12 в Libs/shared, чистый старт грузит все 12 из разделяемого слоя (sqlite Pro, кластер, планировщик), Kafka с librdkafka, Mail с MailKit и SQLite с e_sqlite3 отрабатывают сквозь общий слой, .tpkg‑модули унифицируются, негативный сценарий валит старт с внятной ошибкой.

Плановое обслуживание узла: cordon / uncordon

Из той же оперы «день после деплоя» — и из Pro, то есть бесплатно.

Раньше в кластере было два состояния: узел работает либо узла нет. Для скользящего обновления этого мало: rebalance перетасовывает всё, remove-node — жёсткое выселение, а нужно промежуточное — «этот узел доживает текущую работу и новой не берёт».

tsak cluster cordon   node-3     # новую работу не берёт, локи маршрутов сливает соседям# … обновляем узел …tsak cluster uncordon node-3

Флаг Cordoned durable и ортогонален статусу: узел остаётся Online — он же жив, он просто закрыт для новой работы. Цикл наблюдения за маршрутами читает локальное зеркало флага, обновляемое на каждом heartbeat из собственной записи узла, перестаёт брать локи маршрутов и отпускает те, что держит, — соседи их подхватывают. Текущая работа доигрывается. Есть API (POST /api/cluster/nodes/{id}/cordon и /uncordon, с аудитом), клиент, CLI и кнопки на странице Cluster в дашборде.

Кто ходил вокруг Kubernetes — узнает и семантику, и слово. Это сознательно: не надо придумывать новую терминологию там, где у отрасли уже есть общепринятая.


Часть 3. Секреты: не маскировать, а не пускать

Третий большой блок — безопасность эксплуатации. И тут интереснее не «что починили», а как поменяли подход.

Декларация вместо угадывания

В движке, где эндпоинт задаётся URI (ldap://…?bindPassword=…), рано или поздно встаёт вопрос: что из этого URI можно писать в лог, а что нет. Классический ответ — список запрещённых имён параметров: passwordsecretapiKey

Такой список отказывает открыто. Добавили новую опцию с креденшелом — и она светится в логах, пока кто‑нибудь не вспомнит дописать её в список. Ошибка молчаливая: вы узнаёте о ней, читая логи, а не собирая проект.

В 3.4.0 источником правды стала декларация на самой опции:

public class LdapEndpointOptions : EndpointOptions{    public string Server { get; set; } = "localhost";   // печатается в логах    [Sensitive]    public string? BindPassword { get; set; }           // всегда ****}

EndpointOptions.BindFromUri собирает эти декларации рефлексией (один раз на тип опций) и скармливает их редактору URI. Набор секретных ключей выводится из кода и не поддерживается руками — новая опция с креденшелом физически не может быть забыта, потому что забыть можно только сам атрибут, а он стоит там же, где свойство.

Ровно так это устроено в Apache Camel: @UriParam(secret = true) — декларация, а рантаймовый список генерируется из аннотаций build‑плагином..NET‑версии build‑шаг не нужен: хватает рефлексии. Все 37 опций с креденшелами по 22 коннекторам размечены. Эвристика по имени параметра осталась подстраховкой на случай URI, отрендеренного до создания первого эндпоинта этой схемы.

Формат при этом сохраняется — новый EndpointUri.Sanitize(string) оставляет схему, ://, путь, порядок параметров и все несекретные значения байт в байт, а секрет заменяет на константное ****. Через него проходят все границы: логи сборки маршрута и старта эндпоинта, тег и метка метрики redb.route.endpoint в OpenTelemetry, метаданные inflight‑exchange и health‑check, DTO CompiledRoute.FromUri, который рисует CLI и дашборд Tsak. Маршруты без имени теперь получают санитизированный идентификатор — чтобы секрет не выплыл через {RouteId} в строке лога.

Дополнительно: пароли в userinfo (amqp://user:pass@host → user:****@host) наконец внутри контура, redb.Route.Elasticsearch санитизирует URL узлов в стартовом логе, а redb.Route.Exec в отладочной строке печатает исполняемый файл и количество аргументов — значения аргументов командной строки слишком часто оказываются секретами, чтобы их логировать в принципе.

Есть и публичный API для авторов коннекторов: EndpointUri.Sanitize(string)IsSensitiveKey(string) и AddSensitiveKeys(params string[]) — прямой аналог камеловского addSanitizeKeywords.

Отдельно — EndpointUri.RedactSecrets(string) для произвольного текста: исключение драйвера прекрасно умеет притащить строку подключения с Password=… в ex.Message, а OnExceptionProcessor этот message логирует при переотправке и при исчерпании попыток. Оба места теперь прогоняют текст через редактор. Честное ограничение, которое стоит знать: при включённом LogStackTrace логгеру отдаётся сам объект исключения, и вычистить его в процессе уже нельзя — там нужен фильтр на стороне приёмника логов.

Лучше маскировки — отсутствие секрета

Маскирование — это оборона. Наступление — сделать так, чтобы секрета в URI не было вообще. Для этого в 3.4.0 раскатан именованный ConnectionFactory на все десять коннекторов, у которых такого механизма не было: Telegram, MQTT, HTTP, Mail, FTP, SFTP, SignalR, gRPC, TCP, WebSocket.

context.AddToRegistry("support-bot", new TelegramConnectionFactory {    Token = Environment.GetEnvironmentVariable("TELEGRAM_TOKEN")! });r.From("telegram://receive?connectionFactory=support-bot")   // в маршруте токена нет

Нет секрета в URI — нечего маскировать в логах, телеметрии и дашборде. Фабрика заполняет только те опции, которые URI не задал явно, поэтому встроенное в URI значение всегда побеждает и существующие маршруты не меняются; отсутствующее в реестре имя пишет предупреждение и откатывается к параметрам URI.

Одна деталь, которой я горжусь больше, чем самой фичей. Для коннекторов, чей адрес живёт в пути эндпоинта (HTTP, Mail, SignalR, gRPC, TCP, WebSocket), фабрика намеренно не несёт host/port. Иначе безобидная с виду опечатка в имени фабрики могла бы тихо перенаправить маршрут в другое место. Схема wss по той же логике продолжает принудительно включать TLS независимо от фабрики. Механизм для креденшелов не должен по дороге становиться механизмом маршрутизации.

Заодно redb.Route.Ldap получил LdapConnectionFactory — и вместе с ним маршрут, который вообще не содержит креденшелов:

context.AddToRegistry("honest-ldap", new LdapConnectionFactory {    Server = "ldap.corp.local", Port = 636, Ssl = true,    BindDn = "cn=svc-reader,dc=corp,dc=local",    BindPassword = Environment.GetEnvironmentVariable("LDAP_BIND_PASSWORD") });r.From("ldap://SEARCH:dc=corp,dc=local?connectionFactory=honest-ldap&filter=(objectClass=user)")

И маленькое, но важное уточнение: CompiledRoute.FromUri теперь — отображаемое значение. Идентичность маршрутизации, ключи кэша эндпоинтов и поток сообщений не изменились ни на байт, но парсить FromUri обратно, чтобы достать креденшелы, больше нельзя. Это дисплей, а не источник данных.


Часть 4. Кто что может: роли, подпись модулей, аудит

Управляющий API рантайма — это, по сути, пульт от прода. В 3.4.0 у пульта появились замки.

Ролевая модель

API‑ключи Tsak несут роли с версии 1.0.0. В 3.4.0 эти роли начали требоваться на эндпоинтах — до этого лестница viewer < operator < admin существовала в модели, но ни один эндпоинт не объявлял требования, и ключ для дашборда мог сделать всё то же, что админский.

Сделано декларативно, в том же стиле, что и остальное:

  • RequiresRoleAttribute — на действие или на весь контроллер, несколько ролей объединяются по ИЛИ, метод перекрывает контроллер;

  • NoRoleRequiredAttribute — для технических эндпоинтов, которые не имеют права однажды начать отвечать 403;

  • TsakRoles — сама лестница с синонимами reader/ops; произвольные роли сверяются точным именем;

  • RoleAuthorizationProcessor — проверка, встроенная в системный конвейер сразу после успешной аутентификации. Целевое действие он резолвит через тот же ControllerRegistry, что и диспетчер, — чтобы оба одинаково понимали, куда бы попал запрос.

Как разложилось: admin — весь /api/auth/* (включая чтение), /api/users/*, удаление контекста и модуля, force-stop маршрута, rebalance и удаление узла кластера. operator — диагностика и логи (и то и другое показывает внутренности), плюс любой мутирующий эндпоинт по умолчанию. viewer — все остальные GET.

Отдельная забота — не сломать пробы. Проверка выполняется только для аутентифицированных exchange, поэтому освобождённые от авторизации проверки Kubernetes проходят насквозь; HealthProbeController вдобавок помечен [NoRoleRequired] — даже если оператор сузит Tsak:Api:AuthExempt, проба не начнёт отвечать 403 и не уронит под собой деплой. Эхо‑маршрут и Prometheus живут в своих конвейерах и до проверки не доходят вовсе.

Совместимость сделана постепенной, как и положено для такой штуки: при выключенной авторизации не меняется ничего; ключи без ролей сохраняют полный доступ и один раз пишут предупреждение — а когда все ключи перевыпущены с явными ролями, вы ставите Tsak:Auth:RolelessKeysAreAdmin=false и закрываете дверь. Совсем выключить принуждение можно через Tsak:Auth:EnforceRoles=false.

Покрытие: 32 теста на процессор и лестницу ролей плюс 27 интеграционных, гоняющих настоящий конвейер Kestrel ключами vieweroperator и без ролей — включая доказательство, что пробы отвечают без ключа и никогда не возвращают 403.

Модуль — это код. Значит, подпись

Раньше выкатить модуль означало доступ к файловой системе. Теперь модули заливаются и откатываются через API — но поскольку модуль это код, который Tsak грузит в свой процесс, вся фича построена вокруг якоря доверия:

tsak module keygen              # пара ключей ECDSAtsak module sign  my.tpkg       # → my.tpkg.sigtsak module validate my.tpkg    # сухой прогон: те же проверки, ничего не ставитсяtsak module deploy my.tpkg      # загрузка (с подписью в заголовке)tsak module rollback my-module  # откат на предыдущую версию

Существенные детали:

  • Загрузка выключена по умолчанию (Tsak:Modules:Upload:Enabled=false) — узлу, которому удалённый деплой не нужен, незачем иметь такую поверхность.

  • ModuleSignatureVerifier — проверка отсоединённой подписи RSA/ECDSA, только BCL, без зависимости от cosign.

  • Принуждение на границе загрузки: при Tsak:Modules:Signature:Required=true и настроенном публичном ключе любой .tpkg — залитый по API или положенный оператором в каталог руками — обязан нести валидный .tpkg.sig, иначе он отвергается до того, как хоть одна его строка кода загрузится. Якорем доверия становится публичный ключ, а не доступ к файловой системе. Это строже, чем по умолчанию в WSO2 MI.

  • Проверки при загрузке: потолок размера, валидность ZIP и наличие манифеста, имя берётся из манифеста и санитизируется (никаких /\.. — защита от обхода пути и zip‑slip), проверка подписи с быстрым отказом, атомарная установка (temp → move), предыдущая версия архивируется.

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

Аудит, который переживает рестарт

Действия администратора раньше уходили в лог, а история жизненного цикла жила в кольцевом буфере на 1000 записей в памяти — после рестарта следов не оставалось. Теперь есть tsak_audit_log: плоская таблица (специально не объект redb — она только дописывается, растёт и имеет фиксированную схему), создаваемая на старте под нужный провайдер, ровно как это уже делалось для таблиц Quartz.

Пишется она через маршрут direct://tsak-audit (Sql.Execute INSERT) — и это не выпендрёж, а свойство: раз приёмник — эндпоинт, тот же поток событий конфигурацией разворачивается в файл, брокер или HTTP‑коллектор, без единой строчки нового кода. Запись асинхронная, через ограниченную очередь с фоновой откачкой: вызов API никогда не ждёт базу, сломанный бэкенд аудита не может уронить узел, при отказе событие уходит в лог, а при затяжном шторме самые старые записи из очереди отбрасываются с предупреждением.

Читается через GET /api/audit (роль admin, фильтры и страницы — целиком на сервере), CLI tsak audit и новую страницу Audit в дашборде. Ретенция — снова обычный маршрут cron://tsak-audit-retention (Tsak:Audit:RetentionDays, по умолчанию 90, 0 — хранить вечно).

Для развёртываний без базы аудит остаётся в логах, но теперь пишет строку с якорем [tsak-audit] и одним JSON‑объектом сразу за ним — чтобы standalone‑узел можно было грепать и парсить, не настраивая формат логирования.

Дежурному звонят, а не он проверяет

И последнее из эксплуатационного блока: watchdog обнаруживал зависшие exchange, но алерты копились за GET /api/watchdog/alerts — опросом, который в три часа ночи никто не делает. Теперь они рассылаются.

AlertDispatcher веером раскидывает новый алерт по включённым каналам, отправил‑и‑забыл (ограниченная очередь и фоновая откачка, как у аудита): цикл watchdog никогда не блокируется на медленном SMTP. Дедупликация — по связке контекст + маршрут + exchange + уровень внутри окна DedupWindowMinutes, иначе зависший exchange будил бы вас на каждом цикле сканирования.

Каналы, все выключены по умолчанию: webhook (Slack / Teams / PagerDuty / любой коллектор), telegram (Bot API напрямую по HTTPS, без коннектора), email (SMTP через BCL) и endpoint — универсальный: отправка на любой продюсерский URI redb.Route (kafka:rabbitmq:amqp:sqs:mqtt:…) через ProducerTemplate. Последний канал закрывает сразу все брокеры без единой строчки на каждый — и, что важно, компонент подаёт хост, поэтому ни один брокер не становится compile‑time зависимостью ядра.

Проверить настройку можно не дожидаясь настоящей аварии: POST /api/watchdog/test-alert отправляет синтетический алерт во все включённые каналы и возвращает результат по каждому (окно дедупликации при этом обходится). В дашборде это кнопка «Send test alert» на панели Alert Delivery.


Часть 5. Ядро: имя схемы становится вашим решением

Теперь вниз, в хранилище. Главное изменение ядра — маленькое на вид и заметное в жизни.

[RedbScheme(Name = “…”)]

Раньше схема всегда называлась по FullName CLR‑типа, а строка в атрибуте была косметическим псевдонимом. Следствие знакомое: идентичность схемы в базе была намертво связана с неймспейсом и именем класса. Переименовали класс или переехали в другой неймспейс — и вы либо не рефакторите, либо чините базу руками.

[RedbScheme("Заметка", Name = "Notes.Note")]   // позиционный аргумент — псевдоним, как и раньшеpublic class Note { … }

Позиционный аргумент был и остаётся псевдонимом: явное имя задаётся только именованным параметром Name, поэтому ни одно существующее объявление не поменяло смысла, а типы без Name ведут себя ровно как прежде.

При первой синхронизации тип с явным именем переименовывает схему в базе. Поиск идёт по цепочке из трёх шагов — явное имя, FullName, короткое имя типа — и первое совпадение переименовывается на месте. Физически это один UPDATE строки schemes.name: id сохраняется, объекты, структуры и значения не трогаются, полиморфная загрузка (она резолвится через scheme_id) не замечает изменения.

Честное предупреждение, которое стоит в changelog и повторю здесь: переименование требует одновременного обновления всех потребителей базы. Версия приложения, не знающая про явное имя, не найдёт схему под новым именем и создаст вторую, тихо разделив объекты между двумя. Если такое уже случилось — redb теперь это обнаруживает и отказывается работать, вместо того чтобы наугад выбрать одну из двух.

Имена валидируются по правилам C#‑идентификатора (латиница, цифры, _.+, не зарезервированное слово, до 128 символов) в C# до отправки любого SQL, поэтому в ошибке видно тип‑нарушителя. Причём валидируются все сразу и докладываются одним AggregateException — чинить кодовую базу по одному имени за перезапуск было бы издевательством. Человекочитаемые заголовки по‑прежнему живут в Alias, который свободной формы.

Заодно закрыт паритет провайдеров: MSSql и SQLite получили триггер валидации имён schemes, повторяющий правило PostgreSQL один в один, — имя, принятое одним провайдером, теперь принимается всеми тремя. И alias схемы наконец синхронизируется при каждой синхронизации (у структур это работало всегда): источник истины — атрибут, убрали атрибут — _alias сбрасывается в NULL.

Загрузка проверяет схему

LoadAsync<TProps> теперь сверяет idscheme объекта со схемой, в которую отображается TProps, — до того, как что‑либо попадёт в кэш.

По умолчанию несовпадение возвращает null. Это не произвольный выбор, а требование одного конкретного сценария: мягко удалённый объект получает схему -10, и вызывающая сторона ожидает от него именно null. А если вам нужно, чтобы настоящая ошибка типа была громкой, — RedbServiceConfiguration.ThrowOnSchemeMismatch = true, и вы получаете RedbSchemeMismatchException. Нетипизированный LoadAsync(objectId) не затронут в обоих случаях.

Одновременный старт нескольких узлов

Сценарий, воспроизведённый на трёхузловом кластере: несколько экземпляров стартуют против базы, где нужной схемы ещё нет, все промахиваются мимо поиска и все выполняют INSERT INTO _schemes. Побеждает один.

Создание схемы теперь использует бесконфликтный оператор на каждом диалекте (ON CONFLICT (_name) DO NOTHING на PostgreSQL и SQLite, INSERT … WHERE NOT EXISTS с UPDLOCK, HOLDLOCK на MSSql), а проигравший гонку перечитывает строку победителя.

Маленькая деталь, из‑за которой это интереснее, чем кажется: ловить нарушение уникальности здесь не сработало бы. На PostgreSQL упавший оператор внутри транзакции отравляет её, и последующее чтение отвалится с 25P02. Правильный ответ — не «поймать исключение», а «не создавать конфликт». Закрыты оба пути создания — типизированный и нетипизированный.

Миграции данных Pro поехали на всех провайдерах

Механизм миграций данных в Pro теперь работает и покрыт тестами (MigrationTestsBase: применение, запись истории, идемпотентность, сухой прогон — против всех трёх Pro‑фикстур). По дороге разложились по местам четыре разные вещи: получение контекста БД, диалектная генерация UPDATE (ISqlDialectPro.Migration_UpdateTarget — SQLite запрещает алиас у цели UPDATE, T‑SQL требует связать алиас через хвостовой FROM), нативная DDL таблицы migrations для SQLite (REAL Julian applied_at в её собственной конвенции времени) и снятие IDENTITY с migrations.id в MSSql — исполнитель, как и везде в redb, подаёт идентификаторы сам.

Существующие базы новую DDL migrations не получают: инициализация пропускается, если schemes уже есть. На практике это ни у кого ничего не ломает — накопленной истории миграций там всё равно быть не может.

Мелочи, которые чувствуются

  • Горячая перезагрузка модулей больше не удерживает память. Процессный индекс schemeName → Type держал сильные ссылки на Type, а Type держит свой AssemblyLoadContext — собираемый ALC (тот самый, на котором стоит hot‑swap в Tsak) не мог быть выгружен, а после перезагрузки устаревший экземпляр конфликтовал по имени со свежим. Теперь это WeakReference<Type> с отсевом мёртвых при поиске, а два экземпляра одного FullName из разных ALC распознаются как перезагрузка, а не как коллизия имён. Разные типы с одним явным Name конфликтуют по‑прежнему — так и задумано.

  • Настройки кэша redb доступны из конфигурации Tsak — секция Tsak:Redb:Cache (кэш свойств и его TTL, кэш списков, кэш метаданных, AutoRecomputeHash, домен кэша). Раньше узел Tsak жил на дефолтах redb, потому что пробрасывались только две настройки. Каждый ключ необязателен, отсутствующая секция не меняет ничего, а весь набор с дефолтными значениями выписан в appsettings.json — чтобы ручки было видно и можно было править на месте. Включение SkipHashValidationOnCacheCheck вместе с кластером пишет предупреждение на старте: доверять кэшу без сверки хэша можно только при одном писателе.

  • Минус 679 строк мёртвого слоя кэш‑интерфейсов — пять интерфейсов, ссылавшихся только друг на друга, ни одной реализации, ни одного потребителя. Живые кэши — GlobalMetadataCacheGlobalListCacheGlobalPropsCache и индекс типов. Формально это ломающее изменение (типы были public), фактически ими нельзя было воспользоваться. README.md в каталоге кэширования переписан под то, что действительно существует.


Часть 6. redb.Identity: одна версия на экосистему, JAR и честный ответ conformance

Почему 1.2.2 → 3.4.0

До этого релиза redb.Identity ехал по своей линии 1.x, пока ядро, Route и Tsak двигались вместе по 3.x. Две схемы нумерации превращали вопрос «какой Identity работает с каким Route» в справочник вместо взгляда.

С 3.4.0 Identity разделяет версию экосистемы и прыгает сразу на общий номер — тот самый redb.Route 3.4.0, против которого он собран. Дальше каждый релиз экосистемы двигает и его.

Подчеркну: это выравнивание нумерации, а не ломающая перезапись. Ломающих изменений API здесь нет, единственная фича выключена по умолчанию. Мажор изменился потому, что номер теперь общий, а не потому, что сломался контракт. Старые теги 1.0.11.2.2 остаются валидной историей.

JAR (RFC 9101) — подписанный запрос авторизации

/connect/authorize научился принимать подписанный объект запроса — за флагом Features.EnableJar, по умолчанию выключенным, так что все предыдущие релизы ведут себя идентично (параметр request по‑прежнему получает request_not_supported).

Когда включено: подписанный request (встроенный) или request_uri (по ссылке, забирается по HTTP) проверяется против зарегистрированных ключей клиента, и параметры внутри JWT побеждают строку запроса — как и предписывает § 6.1.

Что не принимается: alg: none (неподписанный объект запроса уничтожает ровно ту гарантию целостности, ради которой JAR и существует), подпись не тем ключом, несовпадающий внутренний client_id, просроченный объект и — в режиме Enforce — алгоритм, разошедшийся с объявленным клиентом RequestObjectSigningAlg. Всё это один ответ: invalid_request_object.

Раскатка сделана поэтапной: Off → LogOnly → Enforce. Discovery объявляет request_parameter_supportedrequest_uri_parameter_supported и список алгоритмов только когда JAR включён — сервер не должен обещать в метаданных то, чего эндпоинт делать не станет. Вся конфигурация — в секции Jar: режим принуждения, список разрешённых алгоритмов (только асимметричные), допуск на расхождение часов, лимиты размера и ручки SSRF‑защиты для request_uri.

Отдельно про то, как это устроено внутри, — потому что это была самая большая часть работы. OpenIddict 6.3.0 объекты запроса не поддерживает. Мы это проверяли специально, прежде чем планировать: в сборке есть ValidateRequestParameter и строка request_not_supported, но нет ничего, что разбирало бы объект запроса или проверяло его подпись, а request_uri существует только в виде PAR‑овского URN. То есть JAR здесь — не галочка в конфигурации, а собственные серверные обработчики (ValidateRequestObjectHandler), которые встают перед встроенным безусловным отказом и делают всю работу сами. Выданные PAR идентификаторы urn:ietf:params:oauth:request_uri:* при этом сознательно не трогаются — PAR разрешает их сам, и лезть туда JAR‑обработчику нечего. Фетч по request_uri ограничен по размеру и таймауту и проходит SSRF‑фильтр (о нём ниже).

Покрытие: 15 тестов на обработчик плюс живая conformance‑демка demo_jar_request_object.ps1, включённая в общий прогон run_all — то есть сценарий проверяется не только юнит‑тестом, но и настоящим запросом к поднятому серверу.

Чего пока нет и когда будет: шифрование объекта запроса (JWE) отложено — поля клиента RequestObjectEncryptionAlg / ...Enc сохраняются, но не применяются, это отдельная фаза. А вот RequestObjectSigningAlg, который раньше только хранился, теперь действительно принуждается в режиме Enforce, и JwksUri действительно резолвится. План со всеми шестью фазами, рисками (SSRF, alg:none, подстановка алгоритма) и оценкой лежит в репозитории — doc/JAR_RFC9101_PLAN.md.

Под это подведён IClientKeyResolver — резолвер публичных ключей клиента, то есть тех ключей, которыми проверяется подписанное клиентом: объект запроса JAR, а в будущем и assertion private_key_jwt. Два источника: встроенный JsonWebKeySet в свойствах приложения или jwks_uri — забирается и кэшируется с фоновым обновлением по TTL плюс ограниченным по частоте принудительным обновлением при промахе по kid, чтобы ротация ключей на стороне клиента не ломала вход до истечения TTL.

Два принципа, оба про то, чтобы неопределённость не превращалась в разрешение:

  • Отказ закрытый. Недоступный или битый JWKS даёт ноль ключей — проверять не против чего, значит, вызывающая сторона отвергает запрос. «Не смогли проверить» никогда не равно «валидно».

  • Никакого отката с битого встроенного JWKS на jwks_uri. Опечатка во вставленном наборе ключей должна остаться видимой, а не маскироваться тихим переключением на другой источник ключей.

Только асимметричные алгоритмы: ClientSecret хранится BCrypt‑хэшем, а для проверки HMAC нужен исходный секрет — так что HS* тут невозможен технически, и FAPI 2.0 его для объектов запроса запрещает независимо от этого. Настройки резолвера — секция ClientKeys: время жизни кэша, минимальный интервал обновления, таймаут и потолок размера документа, а также RequireHttps и AllowPrivateNetworkTargets — два послабления исключительно для разработки, в проде их трогать незачем.

SSRF‑гард

Всё, что приходит снаружи в виде URL (jwks_urirequest_uri), проходит через OutboundUrlGuard — до открытия сокета. Отвергается всё, что не абсолютный HTTPS, и всё, что резолвится в непубличный адрес: loopback, RFC 1918, link‑local — включая 169.254.169.254, тот самый метаданный эндпоинт облака, раздающий креденшелы инстанса любому, кто до него дотянулся, — CGNAT, IPv6 unique‑local и IPv4-mapped формы вроде ::ffff:10.0.0.1, мимо которых наивная проверка проходит не заметив.

40 тестов, из них 29 на сам гард — включая тест, утверждающий, что 172.32.* и 172.15.* публичные и блокироваться не должны (границу диапазона 172.16/12 промахивают регулярно). Все тесты офлайновые: попытка сетевого вызова сама по себе валит тест. Известное ограничение назову сам: DNS rebinding это не закрывает — для него нужно прибивать проверенный адрес к самому соединению.

Про conformance — и почему SKIPPED тут правильный ответ

Локальный прогон Basic OP от OpenID Foundation после включения JAR перешёл из 1 SKIPPED / 4 REVIEW в 2 SKIPPED / 3 REVIEW при неизменных 0 FAILED.

Объясню, потому что цифра выглядит хуже, чем есть. Оба SKIPPED‑модуля тестируют неподписанный (alg:none) объект запроса. Набор пропускает их, если сервер не объявляет none среди поддерживаемых алгоритмов — а это ровно наша позиция: мы поддерживаем только подписанные объекты запроса. Больше того, один из модулей переехал REVIEW→SKIPPED именно потому, что сервер теперь по‑настоящему обрабатывает подписанные объекты запроса и честно об этом объявляет, вместо прежнего состояния «не поддерживаем», которое уводило тест на путь со скриншотами.

Превратить любой из этих пропусков в «пройдено» можно единственным способом — начать принимать alg:none. Регресса по безопасности ради красивой строчки в отчёте не будет. Полный разбор — в OPENID_CERTIFICATION.md § 4.3, чтобы любой, кто смотрит на бейдж, видел ту же картину.


Часть 7. Что ещё поехало в коннекторах

Коротко, чтобы не потерялось. Telegram‑коннектор (по нему была отдельная статья) подтянули по эргономике:

  • replyToMessageId как полноценная опция продюсера с выражениями и fluent‑формами ReplyTo(long) / ReplyTo(IExpression) / .ReplyToIncoming(). Ответ на сообщение, вызвавшее exchange, больше не требует ручного шага .Process, копирующего один заголовок в другой.

  • messageId для edit/delete — та же история: цепочка «отправил → отредактировал» теперь пишется как Tg.Edit(token).MessageId(Header(TelegramHeaders.SentMessageId)).

  • showAlert в режиме answer — ответ на callback показывается модальным окном, а не всплывашкой.

  • Заголовок telegram.caption для document/photo, побеждающий одноимённую опцию, — как это уже работало для parseMode и fileName.

  • Полезная нагрузка Mini App (WebApp.sendData) доезжает до маршрута: становится телом exchange (тот же контракт, что у текстовых сообщений и callback‑запросов) и дублируется заголовками telegram.webAppData / telegram.webAppButtonText. Работает и в long polling, и в вебхуке — маппер общий. Фильтровать удобно по telegram.messageType = "WebAppData".

  • И фикс: режимы document / photo теперь честно пробрасывают telegram.replyToMessageId и telegram.replyMarkup — фото с инлайн‑кнопками больше не теряет клавиатуру.


Обновление с 3.3.x: чек‑лист

Релиз обратно совместим, существующие маршруты не меняются. Но три пункта стоит пройти глазами.

  1. Нативное расширение SQLite переименовано: redb → redbsqlite. Пакет теперь кладёт runtimes/<rid>/native/redbsqlite.{dll,so} (win‑x64, linux‑x64, linux‑arm64). Причина простая: слишком общее имя сталкивалось с управляемыми сборками redb.* и — что хуже — попадало под маски redb.*, которыми хост чистит собственный bin; нативный загружаемый модуль выметало как свой. C‑символ инициализации не изменился (sqlite3_redb_init), бинарники побайтово те же, что в 3.3.3 — это переименование файла, а не пересборка.

    • Ничего делать не надо, если путь резолвит пакет (SqliteDataSource.LocatePackagedExtension() — дефолт Free‑регистрации): он ищет уже новое имя.

    • Нужно действие, если путь прибит руками: REDB_SQLITE_EXTENSION, явный NativeExtensionPathCOPY в Dockerfile, скрипт деплоя, копирующий redb.so по имени. Устаревший путь упадёт на открытии соединения, а не на сборке — то есть в рантайме.

  2. Tsak: роли начали проверяться. По умолчанию ключи без ролей сохраняют полный доступ (и однократно пишут предупреждение), так что сломаться сразу ничего не должно. Перевыпустите ключи с явными ролями и поставьте Tsak:Auth:RolelessKeysAreAdmin=false. Проверьте, что ваши CI‑скрипты ходят ключом нужного уровня: force-stop маршрута и удаление модуля теперь admin, диагностика и логи — operator.

  3. Tsak: сборка дистрибутива изменилась. Фреймворк теперь собирается в разделяемый слой: scripts/build-shared.ps1 -IncludeFramework (при публикации это делает publish/build.ps1 само). Запуск Worker без этого шага упрётся в быстрый отказ — в сообщении будет написано, что именно запустить. Готовые образы и архивы 3.4.0 уже собраны правильно.

  4. Реализуете IModuleHealthContributor? Он переехал из redb.Tsak.Core в redb.Tsak.Contracts: поменяйте using redb.Tsak.Core.Contracts; на using redb.Tsak.Contracts;. Приятный побочный эффект — модуль теперь может ссылаться только на лёгкую сборку контрактов и выбросить ссылку на redb.Tsak.Core целиком.


Как поставить

Ядро и провайдеры — с NuGet:

dotnet add package redb.Coredotnet add package redb.Postgres      # или redb.MSSql / redb.SQLitedotnet add package redb.Postgres.Pro  # Pro — бесплатно, без ключа

Движок и нужные коннекторы:

dotnet add package redb.Routedotnet add package redb.Route.Telegramdotnet add package redb.Route.Kafka

Рантайм — образом или архивом:

docker pull ghcr.io/redbase-app/redb-tsak-stack:3.4.0# либо standalone-архив с GitHub-релиза v3.4.0 (linux-x64 / win-x64), с cosign-подписью

Образы подписаны cosign, публичный ключ — в релизе (cosign.pub):

cosign verify --key cosign.pub ghcr.io/redbase-app/redb-tsak-worker:3.4.0

Всё на .NET 9.


Итог

Если 3.3.0 был про то, чтобы стек честно работал под нагрузкой, то 3.4.0 — про то, чтобы им было можно управлять.

Упавшее переигрывается из точки сохранения, а не из мусора, в который состояние превратилось по дороге к падению, — и делается это кнопкой в дашборде, а не разбором логов ночью. Патч библиотеки выкатывается подменой DLL, без пересборки рантайма и перевыпуска архивов. Узел выводится из‑под нагрузки корректно, а не выселением. Секреты не попадают в логи, телеметрию и дашборд — а лучше того, вообще не попадают в маршрут. Пульт от прода раздаёт права по ролям, код модулей проверяется по подписи до загрузки, а кто что нажал — переживает рестарт. Схема в базе перестала быть заложницей неймспейса. И вся экосистема, включая Identity, наконец едет под одним номером версии.

Это тот класс возможностей, который не видно на демо и который целиком определяет, каково с системой жить.

Если попробуете — пишите в комментариях, что зашло, а чего не хватает: по replay‑чекпоинтам и по редакции секретов планируются отдельные подробные разборы, и я с удовольствием подстрою их под реальные вопросы.

Исходники и релизы: github.com/redbase‑app. Про БД redb: redb.ru. Прошлые статьи — в профиле.

If this was useful — a ⭐ on GitHub helps others find it.

ссылка на оригинал статьи https://habr.com/ru/articles/1063722/