redb 3.7: свой gRPC-протокол, отсечение props до агрегата и релиз, отозванный через день
За три дня у экосистемы сменилось три номера: 3.7.0, 3.7.1 и 3.7.2. Первый отозван, живой номер 3.7.2. Ниже разбор того, что в этой линии вышло, в порядке зависимостей: интеграционный движок redb.Route, хранилище redb.Core, рантайм redb.Tsak и OpenID-сервер redb.Identity. Все четыре продукта идут на одном номере, 66 пакетов.
Начну с того, почему номеров три.
Почему 3.7.0 отозван
3.7.0 собран на .NET 9 и несёт в зависимостях уязвимости высокой важности. Все пакеты 3.7.0 сняты с публикации на nuget.org, теги v3.7.0 удалены из зеркал. Снятая с публикации версия всё ещё ставится по точному номеру, но смысла в этом нет: 3.7.1 заменяет её целиком и не меняет ни одного публичного API.
Интереснее, как эти уязвимости нашлись, потому что механизм универсальный.
Приложения и артефакты собирались на net9, тогда как redb.Core и redb.Route давно мультитаргетились в net8.0;net9.0;net10.0. Расхождение всплыло на 3.7.0: образы и архивы уехали как net9. Смена TFM заставила пересобрать всё с нуля, и на полной сборке заговорил NuGet-аудит. На инкрементальной сборке он молчит. Именно поэтому всё перечисленное ниже спокойно доехало до публикации.
Что он нашёл:
SSH.NET2025.1.0 вredb.Route.Sftp, прямой зависимостью (GHSA-q939-rpr3-3284). Поднят до 2026.0.0, собственный набор коннектора на нём проходит 207 из 207.- тот же
SSH.NET, версии 2024.2.0, транзитивно в тестовых проектах Kafka, RabbitMQ и Redis. Корнем оказалсяTestcontainers.*4.3.0 при доступных 4.14.0, так что подняли версию, а не заплатали симптом в трёх местах. SQLitePCLRaw.lib.e_sqlite32.1.10 вredb.Export, транзитивно черезMicrosoft.Data.Sqlite9.0.3.redb.SQLiteпиновался в обход этого давно, аredb.Exportсидел на старомMicrosoft.Data.Sqliteи проскочил мимо защиты.System.Security.Cryptography.Xml9.0.4 вredb.Identity.DataProtection: семь advisories разом, транзитивно изMicrosoft.AspNetCore.DataProtection9.0.4. Библиотека XML-криптографии внутри продукта, который занимается аутентификацией. Запинена на 9.0.18, патч линии 9.x.Microsoft.Bcl.Memory9.0.0 вredb.Identity.Http, транзитивно изOpenIddict.Abstractions. Запинена на 9.0.19.
Линия 9.x, а не 10.x, везде, где библиотека мультитаргетится вниз до net8.0: 10.x туда не ставится.
Заодно сборка переехала на .NET 10. Библиотеки redb.Tsak.* и redb.Identity.* теперь объявляют net8.0;net9.0;net10.0, ровно как redb.Core и redb.Route; хостовые приложения и тесты закреплены на одном net10.0, образы и архивы получили тег -net10. .NET 8 и .NET 9 снимаются с поддержки в один день, 10 ноября 2026 года: Microsoft подтянула дату STS 9 к LTS 8. .NET 10 живёт до 14 ноября 2028 года. redb.CLI с этого релиза требует .NET 10, потому что roll-forward умеет только вверх.
3.7.2 вышел следом из-за регрессии в redb.Core, про неё дальше. 3.7.1 остаётся опубликованным: он не отозван, а превзойдён.
redb.Route: адрес gRPC-метода стал маршрутом
Самое крупное в релизе. Коннектор redb.Route.Grpc переписан.
Как было: каждый From("grpc:host:port") поднимал собственный Kestrel. Второй gRPC-маршрут на том же порту не мог его занять и падал на биндинге, поэтому фасад из нескольких операций приходилось делать одним маршрутом с Choice() внутри и селектором по приватному заголовку.
Как стало: консьюмер регистрирует адрес метода (/package.Service/Method) как путевой маршрут на общем Kestrel-хосте, том самом SharedHttpServerManager, который уже обслуживает Http, As2 и Soap, и сам говорит на проводном протоколе gRPC. Это GrpcWire: кадрирование с префиксом длины, трейлеры grpc-status и grpc-message, дедлайны из grpc-timeout.
// Один порт, два метода, два маршрута.
From(GrpcDsl.Listen("0.0.0.0:5001").Method("/identity.v1.Identity/Token"))
.RouteId("grpc-token")
.To("direct-vm://identity-token");
From(GrpcDsl.Listen("0.0.0.0:5001").Method("/identity.v1.Identity/Introspect"))
.RouteId("grpc-introspect")
.To("direct-vm://identity-introspect");
Каждая операция получает собственный идентификатор маршрута, собственные политики, метрики и жизненный цикл. Маршрут выбирает адрес метода, а не заголовок. URI без адреса метода по-прежнему обслуживает встроенный RedbService/Process и ProcessStream, тут ничего не изменилось.
Что приехало вместе с этим.
Настоящие gRPC-статусы. Транспортно-нейтральный status.code, который пишет любой диспетчер контроллеров, отображается на статус gRPC: 401 в Unauthenticated, 403 в PermissionDenied, 404 в NotFound, 429 в ResourceExhausted. Заголовки redbGrpc.Trailer.* становятся трейлерами ответа. Не-OK статус отдаётся trailers-only, потому что клиенты выбрасывают тело неудачного вызова.
Типизированные .proto-сервисы без генерации серверных заглушек. Envelope=Auto сохраняет обёртку RedbMessage для встроенного адреса и отдаёт сырые байты protobuf для любого другого, так что клиент, сгенерированный из настоящего .proto, вызывает redb-маршрут напрямую.
Стриминг в обе стороны. Тело-IAsyncEnumerable пишется по кадру на каждый yield. На стороне клиента .Streaming() делает вызов серверно-стримовым и кладёт IAsyncEnumerable в Out.Body, так что gRPC-поток втекает прямо в стримовый консьюмер (HTTP-овый превращает его в SSE или chunked) без буферизации посередине. Паритет с producerStrategy=STREAMING из camel-grpc.
mTLS, health, gzip. .ClientCertificates(mode, thumbprints…) требует и пинит клиентские сертификаты, выкладывая redbGrpc.ClientCert*. .Health() обслуживает grpc.health.v1.Health/Check для проб Kubernetes, Consul и Envoy. Сжатые запросы принимаются и раздуваются, причём лимит размера перепроверяется после раздувания, чтобы маленький кадр не развернулся в произвольно большой буфер.
Паритет по URI с camel-grpc: grpc://host:port/my.Service?method=Call работает наравне с полным адресом, плюс maxMessageSize и negotiationType=PLAINTEXT|TLS. В redb.Route.Controllers появился [GrpcMethod("Name")], закрепляющий имя, по которому диспетчеризуются вызовы, чтобы переименование C#-метода не стало ломающим изменением.
Из зависимостей ушёл Grpc.AspNetCore: серверного стека больше нет. Остался слой сообщений (Google.Protobuf плюс Grpc.Tools на время сборки) и клиентский канал Grpc.Net.Client. Сгенерированный redb_service.proto теперь эмитит GrpcServices="Client", потому что сервер наш.
Что читать перед обновлением
Два изменения поведения, оба в redb.Route.Grpc.
Продюсер бросает исключение на неудачном вызове (ThrowOnError, по умолчанию true). Раньше он записывал RpcException в обмен и возвращался, но это поле никто в конвейере не читает, поэтому .OnException(...), ретраи и dead-letter про сбой не узнавали, а маршрут ехал дальше с пустым Out. Теперь как у HTTP и SOAP. Старое поведение возвращается через throwOnError=false.
Ошибки доезжают до клиентов статусами, а не как OK с документом об ошибке в теле. Вызывающая сторона, которая игнорировала статус и парсила тело, теперь увидит RpcException. Обратно включается через suppressStatusMapping=true.
Ещё три мелочи того же рода: конфликтующие настройки слушателя на одном порту теперь бросают исключение вместо тихого отбрасывания (раньше это ставило gRPC-маршрут, которому нужен HTTP/2, на слушатель HTTP/1.1 и валило каждый вызов нечитаемой ошибкой кадрирования); консьюмер открывает свой спан grpc receive; GrpcEndpoint.BuildProducerAddress() собирается из хоста и порта, поэтому URI без явного порта (grpc:myhost) резолвится в http://myhost:50051.
Проверка чужим стеком
Раз протокол теперь наш, корректность проверяется не своими же тестами. Интероп-набор гоняет Node.js @grpc/grpc-js в контейнере, в обе стороны и между процессами: чужой сервер разбирает наши кадры, чужой клиент принимает наши ответы, трейлеры, статус PERMISSION_DENIED, серверный поток, gzip-запрос и настоящее mTLS-рукопожатие с пиненным клиентским сертификатом. Контракт при этом типизированный .proto, то есть те же тесты доказывают, что сгенерированный клиент вызывает redb-маршрут без единой серверной заглушки на нашей стороне. Набор гейтится контейнером (--filter Category=Interop), как фикстуры SOAP и AS2.
Четыре дефекта из критического ревью коннектора
Все четыре одной формы: недоверенный или пришедший сверху ввод попадал в код, который стоит вне try-блока обработчика, поэтому сбой вываливался мимо контракта ошибок маршрута.
Заголовок grpc-timeout мог убить запрос на уровне хоста. Микросекундная ветка умножала присланный long на 10 без проверки: 1000000000000000000u заворачивался в отрицательное число тиков, и CancelAfter бросал ArgumentOutOfRangeException ещё до try. Хуже того, catch ловил только OverflowException, а TimeSpan.FromHours и родственники бросают ArgumentOutOfRangeException, так что 9223372036854775807H вылетал из метода целиком. Теперь ловятся оба, умножение стало checked, и нечитаемый дедлайн означает ровно то, что всегда обещала документация: дедлайн не применяется, вызов идёт.
Имя заголовка, которое провод не умеет выразить, роняло вызов. Продюсер копировал все заголовки обмена в метаданные gRPC, у которых алфавит ключей заметно уже: Metadata.Add бросает на пробелах, не-ASCII и на любом суффиксе -bin, а trace-bin это совершенно легальное имя HTTP-заголовка. Цикл шёл до try, поэтому один странный заголовок из HTTP-консьюмера сверху ронял весь вызов через ArgumentException без всякого статуса. Теперь непредставимые ключи отбрасываются с записью в лог, остальные едут.
Битый конверт объявлялся нашей виной: мусор в присланных байтах падал в catch-all как INTERNAL, то есть «проблема на сервере, повторите», хотя починить это может только вызывающая сторона. Теперь INVALID_ARGUMENT с указанием, чего ждали.
Серверный поток, оборвавшийся на середине, был невидим. Сбои стриминга всплывают во время перечисления, много позже возврата из Process, поэтому .OnException, ретраи и dead-letter их не видят: это свойство ленивого стриминга. Но их и не записывал никто, так что поток, обрывавшийся каждый раз, выглядел как поток, который просто рано кончился. Теперь обрыв логируется, считается на эндпоинте и перебрасывается дальше, чтобы читатель узнал, что поток не дошёл до конца.
redb.Route: SOAP, Control Bus и Claim Check
SOAP приехал отдельным коннектором redb.Route.Soap, схемы soap и soaps, с ориентиром на camel-cxf: конверты 1.1 и 1.2, две плоскости заголовков (транспортные HTTP и блок <soap:Header>), WS-Security с UsernameToken, подписью и шифрованием тела, три формата данных (Payload, Message, Pojo), MTOM/XOP вложения и публикация WSDL по ?wsdl. Криптография проверена независимым стеком: Node.js поверх OpenSSL. Подробный разбор в отдельной статье про SOAP.
Control Bus это управление маршрутами сообщением: start, stop, suspend, resume, restart, status, stats, fail по routeId, где current адресует отправителя. Плюс наше расширение поверх Camel, которого у Camel нет: controlbus:notify как консьюмер событий жизненного цикла, так что события маршрутов и контекста втекают в обычный маршрут и обрабатываются полным конвейером EIP.
From("kafka://ingest")
.Choice().When(Overloaded).ControlBus(ControlBusAction.Suspend, "current", async: true).End()
.To("direct://process");
From("controlbus:notify").Filter(IsError).To("telegram://ops");
Остановка текущего маршрута автоматически откладывается через асинхронную диспетчеризацию, чтобы маршрут мог остановить сам себя и не встать в клинч на собственном обмене. Разбор с полным списком опций в статье про Control Bus.
Claim Check это случай, поучительный сам по себе. Процессор, пять операций, заголовки и два хранилища были написаны и покрыты тестами, но ClaimCheckDefinition никто не конструировал, поэтому из маршрута паттерн был недоступен в принципе. Теперь доступен:
.ClaimCheck(ClaimCheckOperation.Set, "order-42") // тело в хранилище, по маршруту едет ключ
.To("kafka://orders") // брокер несёт ключ, а не полезную нагрузку
.ClaimCheck(ClaimCheckOperation.Get, "order-42") // ключ обратно в тело, исходным типом
Push и Pop работают на стеке в области видимости обмена и вкладываются друг в друга, так что тело можно припарковать вокруг вызова обогащения и вернуть после. Хранилище резолвится на этапе компиляции, а неизвестное имя падает на старте с указанием отсутствующей регистрации, а не на первом сообщении.
redb.Route: файлы, которые терялись молча
Критическое ревью файловых транспортов дало четыре дефекта, три из которых теряли данные без единой строчки в логе. Все три пережили полностью зелёный набор, потому что тесты проверяли опции по одной, а дефекты жили в их сочетаниях.
readLock=Rename вечно отдавал пустые файлы: стратегия переименовывает файл в сторону, чтобы его застолбить, а консьюмер продолжал читать исходный путь. readLock=FileLock делал то же самое по противоположной причине: стратегия держала файл открытым с FileShare.None, и собственное второе открытие консьюмера отвергалось его же блокировкой. idempotent вместе с любым readLock мог потерять файл навсегда: ключ занимался до взятия блокировки, а ветка проигрыша гонки возвращалась, не освободив его. Свой idempotentKey использовался как литерал, поэтому все файлы получали один ключ и все, кроме первого, молча пропускались.
Общий знаменатель у первых трёх один: CreateExchangeAsync ловил любую ошибку чтения, писал предупреждение и подставлял Array.Empty<byte>(), после чего постобработка бодро удаляла или архивировала файл. Ошибка чтения теперь это сбой обработки: ключ освобождается, применяется moveFailed, файл остаётся на месте, остаток пачки обрабатывается дальше.
Отдельно: репозиторий идемпотентности сворачивал регистр, поэтому Order.csv затенял order.csv. На любом SFTP- и FTP-сервере и на любой не-Windows файловой системе это два разных файла, и второй пропускался как дубликат, которым не был. Полный разбор с воспроизведением в статье про файловый коннектор.
redb.Route: безопасность
Файловый продюсер писал туда, куда скажет входящее сообщение. Имя целевого файла обычно приезжает заголовком redbFile.Name, то есть от того, кто сформировал сообщение: имя загруженного файла, имя партнёрского файла, поле полезной нагрузки. Локальный продюсер его не проверял вообще, ValidatePath был пустышкой в общей базе, и переопределяли его только удалённые транспорты. Наружу из каталога эндпоинта вели два пути: относительный ../escaped.txt и абсолютный, который Path.Combine молча уважает, выбрасывая базу. Теперь локальный продюсер сажает цель в клетку так же, как SFTP и FTP, под тем же именем опции jailStartingDirectory со значением по умолчанию true.
Сама клетка при этом сравнивала голый строковый префикс. При базе /upload/in цель /upload/instructions/x начинается с базы и проходила проверку, попадая в каталог, к которому эндпоинт отношения не имеет. Теперь сравнение идёт по границе каталога, общим GenericFileUtils.IsWithinDirectory, и туда же переведён FileClaimCheckRepository.GetSafePath.
Просьба к gRPC-продюсеру включить TLS оставляла его в открытом виде. .Ssl() выставляет ssl=true, но адрес назначения продюсер собирает из Plaintext, отдельной опции со значением по умолчанию true, которую с ssl ничто не связывало. То есть GrpcDsl.Call("host:443").Ssl() давал http://host:443: очевидное написание «используй TLS» игнорировалось, молча, на ноге, которая выносит наружу учётные данные. Теперь ssl=true влечёт plaintext=false, а явный plaintext по-прежнему выигрывает, чтобы локальная отладка не лишилась своего рычага.
В SOAP закрыт обход через signature wrapping. Анти-wrapping-проверка искала защищаемый Body через GetElementsByTagName, то есть в порядке документа и на любой глубине, тогда как рабочий путь читает Body, который является прямым потомком Envelope. Атакующий вкладывал по-настоящему подписанный Body внутрь <Header> и клал неподписанный прямым потомком: подпись валидировалась над оригиналом, а маршрут потреблял содержимое атакующего с redbSoap.signatureValid=true. Теперь проверка резолвит тот же самый прямой Body и отвергает конверт, в котором Body больше одного. Там же XML-шифрование перестало отдавать лишних детей Body открытым текстом: шифровался только первый, поэтому document/literal тело с несколькими элементами отправляло остальные незашифрованными.
В AS2 приёмник наконец применяет требования партнёрства. signatureValid вычислялся и не использовался: неподписанное сообщение (или такое, чья подпись не прошла) доезжало до маршрута и получало положительный MDN. Криптография была верной изначально, игнорировался именно результат. Симметрично: MdnParser ставил SignatureValid в true по умолчанию, понижая только для подписанного MDN, поэтому multipart/report со снятой подписью или собранный вручную читался как валидная подписанная квитанция. Плюс заблокирован SSRF через Receipt-Delivery-Option: URL асинхронной квитанции POST-ился как есть и мог указывать на link-local метаданные вроде 169.254.169.254.
И общее для HTTP и gRPC: клиент больше не может подделать транспортные заголовки. HttpConsumer ставил redbHttp.RemoteAddress из соединения, а затем копировал поверх заголовки запроса, так что клиент, приславший заголовок с буквально таким именем (а это валидный HTTP-токен), подменял адрес сокета. Это вход для лимитера по IP, блокировки перебора и записей аудита. Входящие заголовки с транспортно-зарезервированным префиксом (redbHttp., redbGrpc., redbSoap., redbSignalR., redbMail., redbAs2.) теперь отбрасываются; gRPC-консьюмер применяет то же правило к метаданным и заголовкам конверта, с явным и логируемым отказом от защиты через allowClientReservedHeaders=true.
redb.Route: два отчёта из GitHub
Необработанное исключение в SEDA-консьюмере убивало воркер (issue #6). Цикл воркера ловил только отмену и закрытие канала, поэтому один упавший обмен (скажем, нарушение уникальности в БД без .OnException) навсегда и молча завершал цикл: продюсер продолжал класть в очередь, маршрут перестал потреблять, а исключение всплывало только на выключении. Теперь ProcessWithTracking логирует необработанный сбой обмена и роняет его, а консьюмер продолжает разгребать очередь, как DefaultErrorHandler плюс SedaConsumer в Apache Camel. Починка накрывает всех консьюмеров на ProcessWithTracking: seda, direct-vm, timer и поллеры S3, Elasticsearch, Firebase и LDAP.
AddRouteBuilder<T>() и AddComponent<T>() роняли старт хоста (issue #5): регистрация шла только под базовым типом, а конфигуратор резолвит конкретный, поэтому старт падал с InvalidOperationException: No service for type … has been registered. То есть задокументированный путь входа в фреймворк не работал.
redb.Core(.Pro): смена типа свойства могла тихо потерять значения
Самый неприятный дефект релиза, все провайдеры, и Free, и Pro.
Синхронизация схемы мигрирует хранимые значения структуры, когда меняется тип CLR-свойства, и только потом переключает _structures._id_type. Миграция вызывалась с числовым идентификатором старого типа, переданным в поиск по имени. Поиск не находил ничего, откатывался на литерал "unknown", и каждый провайдер отвечал «неизвестный исходный тип». Ответ выбрасывался, тип переключался всё равно, значения оставались в старой колонке, читались как null и физически удалялись следующим сохранением при стратегии DeleteInsert по умолчанию.
Чтобы это молчало, должны были совпасть четыре независимых слоя: идентификатор в роли имени, ?? "unknown", глотающий промах, провайдеры, сообщающие о сбое данными, а не исключением, и вызывающий код, выбрасывающий результат. Закрыты все четыре: тип резолвится по идентификатору, отсутствующий тип поднимает исключение, результат проверяется, а _id_type меняется только после миграции, которая действительно завершилась.
Миграция, которая не может завершиться, теперь бросает RedbTypeMigrationException и останавливает синхронизацию. Это сделано намеренно: альтернативные исходы это либо структура, чьи значения читаются как отсутствующие, либо CLR-класс и структура, которые тихо разошлись. Исключение несёт схему, свойство, оба имени типов, сколько значений переехало и сколько нет, и SQL для ручной миграции. Структуры без хранимых значений это не затрагивает: терять нечего, такие смены типа проходят.
Рядом лежали ещё два дефекта того же семейства. SQLite отказывал во всех межколоночных миграциях оптом, хотя bool в int это просто копирование значения: _Boolean и _Long там оба INTEGER. Теперь SQLite реализует ту же матрицу, что PostgreSQL, для скаляров, а текстовые источники проверяются построчно, потому что у SQLite аффинность, а не типы, и CAST('abc' AS INTEGER) это 0, а не ошибка. Ссылочные колонки (_ListItem, _Object) остаются под запретом: внешний ключ из скаляра не сделать.
Миграция String в Boolean на PostgreSQL и MSSQL уничтожала значения, которые не смогла прочитать: неузнанный токен отображался в NULL через CASE, а тот же оператор очищал _String. Значение исчезало, и, поскольку успех считается количеством обновлённых строк, это ещё и рапортовалось как успех. Все соседние текстовые конверсии были защищены предикатом, эта одна не была, на обоих провайдерах.
И организационное следствие: migrate_structure_type лежал в sql/, а туда попадает только redb_init.sql, который применяется, когда таблиц ещё нет, то есть исключительно к свежим базам. Любая правка этой функции была недостижима для всех уже работающих баз. Функция переехала в версионируемый модуль, поэтому проверка версии перевыкатывает её при следующем старте, как и всё остальное в модуле.
redb.Core(.Pro): регистр букв работал только для латиницы
Contains(needle, OrdinalIgnoreCase) находил HELLO, но не находил ПРИВЕТ. То же самое с греческим, венгерским, польским, чешским и французским.
Сворачивание регистра берётся из правил самой базы. У SQLite оно безусловно ASCII-only: и LIKE, и lower(), и upper(), и даже COLLATE NOCASE. У PostgreSQL так происходит всякий раз, когда база создана с LC_CTYPE=C. У SQL Server этого нет никогда, его коллация по умолчанию сворачивает любой алфавит.
Новая опция RedbServiceConfiguration.StringCollation чинит все алфавиты, у которых отображение регистра идёт «один символ в один», в одном месте и без работы на каждый язык. Она накрывает всё семейство сразу: ContainsIgnoreCase, StartsWithIgnoreCase, EndsWithIgnoreCase, ToLower, ToUpper и регистронезависимый regex, чтобы поиск никогда не разошёлся со сравнением.
Реализация разная по провайдерам, потому что провайдеры различаются по существу. PostgreSQL цепляет COLLATE к свёрнутому операнду: в Pro это делается в C#, во Free через новую pvt_fold_case(), читающую GUC redb.string_collation, так что ни одна сигнатура функции не поменялась. У SQLite цеплять коллацию не к чему, поэтому он подменяет встроенные like, lower и upper на Unicode-осведомлённые прямо на соединении, тем же приёмом, что использует родное ICU-расширение SQLite, и без пересборки нативной части. SQL Server не требует ничего.
Две оговорки задокументированы, а не спрятаны. На PostgreSQL операнд с коллацией не может пользоваться индексом, построенным с коллацией базы, поэтому триграммный поиск деградирует в полный скан, пока не создан подходящий выражательный индекс (DDL лежит в COLLATION.md, сам redb его не создаёт). И диакритика, немецкая ß против SS и турецкая İ это не сворачивание регистра, они не чинятся; что именно делают последние два случая, зависит от провайдера, и набор тестов теперь закрепляет это по каждому провайдеру, а не предполагает общее поведение.
Заодно вскрылось, что Pro на PostgreSQL выбрасывал сконфигурированный диалект. ProRedbService резолвил из контейнера и ISqlDialect, и ISqlDialectPro, но передавал в ProQueryableProvider только базовый; дальше ProQueryProvider сужал его через as ProPostgreSqlDialect, базовый экземпляр этот каст не проходил, и запасная ветка молча конструировала new ProPostgreSqlDialect() вообще без конфигурации. Дефект был латентным ровно до тех пор, пока диалект не нёс настроек. StringCollation стала первой, и она исчезала на каждом Pro-запросе, работая при этом на Free.
redb.Core(.Pro): время
DateTime в redb не несёт часового пояса: записали 14:00, читаете 14:00, на любом хосте. Материализация объектов это соблюдала, аналитический путь нет. JsonValueConverter разбирал ISO-строку с зоной при DateTimeStyles по умолчанию, а это переводит значение в локальную зону вызывающего, поэтому MinRedbAsync, GroupBy, Window и скалярные проекции отвечали не тем же значением, что объект по тому же полю.
Отдельно: DateOnly не переживал круговой обход ни на одном провайдере. Он засеивался с _db_type = 'DateTime', значением, которого не знает ни одна ветка get_object_json, поэтому колонка писалась правильно и терялась на выходе, а каждое свойство DateOnly материализовалось как 0001-01-01. Перетипировано в DateTimeOffset, что уводит его в ветку, которая и так есть везде: ни одна SQL-функция не изменилась, версия модуля не бампалась, нативная часть SQLite не пересобиралась. Миграционные скрипты для существующих баз лежат в репозитории.
Там же починено: DateOnly, TimeOnly и TimeSpan уезжали в _values._String коротким форматом текущей культуры и так же читались обратно, поэтому строка, записанная под ru-RU, переставала загружаться под en-US. JSON-форма TimeSpan теряла компоненту дней и знак (3.02:00:00 возвращался как 02:00:00). А фильтру требовалось то же инвариантное написание в обоих путях, Free и Pro, иначе сохранённая строка была ненаходима по собственному значению. Весь временной текст теперь идёт через единый RedbTemporalFormat. Контракт, таблица точности и оставленные открытыми границы описаны в DATETIME.md.
redb.Core.Pro: отсечение props до агрегата
Фильтр по props компилировался в условие над GROUP BY, то есть к моменту проверки движок уже прочитал и свернул значения всех объектов схемы. Стоимость запроса не зависела от избирательности: поиск одного редкого номера заказа стоил ровно столько же, сколько поиск, не находящий ничего.
Префильтр сужает набор объектов до агрегата и строится как надмножество: может пропустить лишнее, не имеет права потерять нужное. Включается флагом EnablePvtPrefilter, по умолчанию выключен. На PostgreSQL диапазон по дате ускорился стократно, на SQLite в шестнадцать раз, на MS SQL Server полная выборка в 5,3 раза. Подробные замеры, границы применимости и разбор двух защит планировщика в отдельной статье.
В 3.7.2 планировщик получил три формы, которые раньше отвергал зря: дизъюнкцию, вложенную в конъюнкцию (обычная форма строки поиска, ограниченной поддеревом), несколько веток над одной структурой и ListItem.Id. Последний живёт в собственной колонке _listitem строки, то есть Status.Id == 42 это буквально v._listitem = 42, никакого join не нужно.
И к нему приделана диагностика: применённый план объясняет себя в ToSqlStringAsync, а отказ называет защиту, которая его остановила.
-- PVT prefilter: Row form, 2 branch(es) over 2 structure(s), score 70
-- branch: structure 1000032, column _string, Contains, score 70
-- branch: structure 1000034, column _string, Contains, score 70
-- PVT prefilter: not applied, reason PivotNotCovered
-- detail: pivot column(s) Age (structure 1000030) have no branch
Комментарии строит только предпросмотр SQL, и в исполняемый оператор они не попадают никогда: комментарий, меняющийся от запроса к запросу, это отдельный ключ кэша планов и в PostgreSQL, и в SQL Server, то есть обмен диагностики на кэш, который никогда не попадает.
redb.Core 3.7.2: array.Contains перестал разбираться на .NET 10
Регрессия, ради которой и вышел 3.7.2. Фильтр вида .Where(x => x.Tags.Contains("urgent")) бросал NotSupportedException, как только Tags объявлен T[]?, а это ровно то, что Nullable enable даёт любому необязательному массиву.
C# резолвит array.Contains(x) в перегрузку по ReadOnlySpan, и парсер это преобразование уже разворачивал. Но на .NET 10 с nullable-аннотациями компилятор оборачивает коллекцию дважды: op_Implicit вокруг Convert вокруг доступа к члену. Снятие только внешнего слоя оставляло Convert, который не является MemberExpression, поэтому обе ветки трансляции промахивались и метод доезжал до финального throw. Теперь преобразования снимаются циклом с обоих операндов.
Занесено в тесты эквивалентности префильтра на всех трёх провайдерах и отдельно проверено на шести формах Contains, включая обе формы IN по константной коллекции. Полный прогон на .NET 10.0.8: 1942 из 1944, два намеренных пропуска, дважды подряд, с включённым и выключенным префильтром, потому что префильтр это надмножество и результат менять не имеет права. Шесть провайдерных коллекций (Postgres, MSSql, SQLite, каждый в Free и Pro) плюс модульные: 1920 из 1920 в обоих режимах.
Что ещё в redb.Core
Покрывающий индекс по _objects(_id_parent), несущий _hash, появился на PostgreSQL и SQLite (у MSSQL он был). Их ближайший индекс обрывался на (_id_parent, _id_scheme, _id) и _hash не нёс, поэтому обход дерева, читающий хеш объекта (а именно это делает прозрачный кэш), уходил из индекса в кучу на каждой строке. PostgreSQL 18.1 теперь планирует такой запрос как Index Only Scan, SQLite 3.46.1 как SEARCH … USING COVERING INDEX. Новые базы получают это из скрипта схемы, существующие нет: скрипт инициализации применяется, только когда таблиц ещё нет.
Строковый индекс SQLite потерял ограничение по длине. IX__values__String_not_null нёс AND length(_String) < 2000 рядом с IS NOT NULL, но это ограничение принадлежит PostgreSQL, где ключ btree примерно за 2700 байт переполняет страницу. У SQLite такого предела нет, а эффект был обратный желаемому: SQLite не доказывает следствий между запросом и предикатом частичного индекса, он ищет совпадающий терм, а length(_String) < 2000 не пишет ни один запрос. Индекс просто не использовался. CREATE INDEX IF NOT EXISTS существующий индекс не заменяет, так что в базах, созданных раньше, его придётся пересоздать один раз вручную.
Скан pivot перестал перепроверять схему: каждый PVT-CTE нёс AND v._id_object IN (SELECT _id FROM _objects WHERE _id_scheme = @p0), хотя строки _values выбираются по _id_structure, структура принадлежит ровно одной схеме, а внешний SELECT проверку и так повторяет. Проверено сверкой отсортированных наборов идентификаторов до и после на всех трёх движках, плоско и деревом: везде одинаково. На SQLite плоская форма при этом стала на 28% быстрее.
И три отчёта из GitHub. ChangePasswordAsync бросал UnauthorizedAccessException на неверном текущем пароле вместо false, хотя метод объявлен Task<bool> с описанием «true, если сменён»: форма смены пароля, написанная по контракту, отвечала 500 на самую частую пользовательскую ошибку. На SQLite любая смена типа свойства роняла InitializeAsync с no such table: migrate_structure_type. И правки в документации: описание EnablePropsCache утверждало, что кэш работает только при включённой ленивой загрузке, чего код не требует нигде.
Из публичной поверхности удалены ExpressionSqlCache и CompiledQuery: кэш SQL-шаблонов, который не был подключён ни к одному пути выполнения запроса, и запись, которую он возвращал. Формально это удаление публичного API, которому по строгому SemVer место в мажоре, но мажор переключил бы LicensePolicy.FreeThroughMajor и включил бы плату за Pro, так что это осознанно уехало минором.
redb.Tsak: закрыт по умолчанию
Два переключения значений по умолчанию, чтобы свежий Tsak не был нараспашку в момент, когда до его порта дотянулись.
Управляющий API слушает 127.0.0.1 вместо 0.0.0.0. Локальные запуски работают из коробки, а выставление управляющей плоскости на внешний адрес стало явным решением. Если хост всё же поднят не на loopback и при этом Tsak:Auth:Enabled=false, в лог идёт громкое предупреждение о том, что порт раздаёт полный неаутентифицированный админский доступ: остановить и удалить контексты, залить модуль, выпустить ключ, выгрузить конфигурацию.
API-ключи без ролей теперь отвергаются, а не считаются админскими. Совместимость возвращается переключателем RolelessKeysAreAdmin, но как временный мост до перевыпуска ключей с явными ролями.
Дашборд получил настоящую серверную сессию. Раньше он аутентифицировал только внутри Blazor-контура, флагом в памяти на контур. Всё, до чего можно дотянуться вне контура, гейта не имело, и главным пунктом здесь был BFF-прокси скачивания логов: обычный HTTP-эндпоинт, отдававший файлы логов воркера (строки подключения, токены, персональные данные) с админским ключом сервера любому, кто дотянулся до порта. И поскольку флаг контура не был настоящим субъектом безопасности, авторизация в дашборде была косметической: <AuthorizedView> всего лишь прятал разметку.
Теперь это нормальный BFF: cookie-аутентификация ASP.NET Core (tsak.auth, HttpOnly, SameSite=Strict), вход и выход настоящими эндпоинтами, страницы под AuthorizeRouteView, роль читается из подписанного принципала, прокси скачивания под RequireAuthorization плюс защита от обхода пути в имени файла. Пароль дашборда сравнивался с открытым значением из конфигурации через !=, то есть выдавал себя временем ответа, и никакого ограничения частоты попыток при этом не было. Теперь предпочитается BCrypt-хеш Tsak:Web:AdminPasswordHash, сравнение открытого пароля идёт постоянным временем, а общий LoginThrottle блокирует вход после N неудач в окне.
Скачивание целого файла логов при этом поднято с Operator до Admin: в логе могут лежать сырые URI эндпоинтов с паролями и фрагменты полезной нагрузки. Живой хвост GET /api/logs остался на Operator, это тот же массив данных, но интерактивный путь, который операторам нужен. Корневая причина (логирующий слой redb.Route пишет URI и полезную нагрузку без редактирования) лежит вне Tsak и заведена отдельно.
Эндпоинт эффективной конфигурации перестал утекать. GET /api/system/config маскировал Password= и Pwd= только внутри значений под префиксом ConnectionStrings: плюс значения, у которых листовое имя ключа выглядело чувствительным. Мимо проезжали …Webhook:Url (сам URL и есть предъявительский секрет), …Headers:Authorization, брокерский …Endpoint:Uri вида amqp://user:pass@host и любая строка подключения, лежащая вне ConnectionStrings:. Теперь маркеры сопоставляются со всем путём ключа, Password= вычищается в любом значении, а userinfo в URI маскируется везде, оставляя видимыми хост и порт.
Ещё два пункта гигиены. Перебор API-ключей раньше гейтился только по префиксу пути /api/auth/, тогда как проверка ключа идёт на каждом непубличном запросе, так что подбор просто уходил на любой другой эндпоинт. Заменено блокировкой по неудачным попыткам, которая считает провалы ключевой аутентификации по всей поверхности и знает про прокси: берётся самый правый хоп X-Forwarded-For, тот, который дописал доверенный прокси и который клиент подделать не может, и только при TrustProxyHeaders=true. А chart.js больше не грузится с публичного CDN без проверки целостности, он лежит у нас.
redb.Tsak: кластер
Два детерминированных дефекта, из-за которых координатор терял маршруты на плановых сливах и запускал их дважды на переключении.
ReleaseLockAsync был гарантированной пустышкой при cordon-drain: ClusteredRoutePolicy сбрасывал _isLeader до вызова, а внутри стояло if (!_isLeader) return;. То есть узел, выведенный из ротации, останавливал свой консьюмер, но держал блокировку до истечения TTL, и маршрут при каждом сливе не работал ни на одном узле в течение TTL. Исключение в watch-цикле давало зеркальную картину: узел продолжал потреблять, не продлевая, и после TTL маршрут шёл на двух узлах сразу.
Самопродление обходило epoch fencing. Ветка «блокировка наша, продлеваем» продлевала безусловно по первоначальному чтению вне транзакции, последней записью выигрывая, поэтому узел, чью просроченную блокировку только что перехватили с более высокой эпохой, мог затереть перехват своей старой эпохой и отрапортовать Acquired=true. Теперь самопродление идёт через атомарную операцию с блокировкой строки и перечитыванием.
Разделены циклы. Раньше heartbeat, продление лидерской блокировки, детект мёртвых узлов, ребалансировка и запуск с остановкой модулей крутились в одном последовательном цикле с одной задержкой. Модуль, стартующий дольше TTL аренды, отодвигал следующее продление за TTL: блокировка истекала, избирался сосед, и ребалансировку делали два узла сразу. Тот же затык задерживал heartbeat, так что живой узел могли посчитать мёртвым. Теперь по канону аренд (leader-election в Kubernetes, keepalive аренд в etcd и Consul, session ping в ZooKeeper) отдельный быстрый цикл на PeriodicTimer с периодом min(interval, TTL/3) делает только heartbeat и продление, а тяжёлые обязанности живут в своём цикле. Добавлен добровольный уход с лидерства по renew-deadline: если продление продолжает бросать (скажем, хранилище недоступно), узел слагает с себя полномочия локально, а не держит протухшее лидерство до конца TTL.
Отозванные API-ключи отвергаются за секунды, а не за пять минут. Ключ кэшировался на полный CacheTtl, и локальный кэш чистило только собственное RevokeKeyAsync, поэтому ключ, отозванный на узле A или прямо в базе, принимался узлом B до пяти минут. Теперь кэш переподтверждает ещё живой ключ в хранилище раз в RevocationCheckInterval (по умолчанию 30 секунд), что ограничивает окно отзыва по всему кластеру этим интервалом, без чтения хранилища на каждый запрос.
Реплей из очереди недоставленных сообщений стал атомарным. ReplayAsync читал запись, проигрывал её и потом безусловно помечал replayed, поэтому два оператора (или двойной клик) читали одну и ту же pending запись и проигрывали бизнес-обмен дважды. Теперь реплей сначала берёт заявку условным UPDATE … WHERE entry_id=@id AND status='pending': база сериализует, ровно один вызывающий видит affected == 1. При сбое реплея заявка возвращается в pending, а падение между заявкой и реплеем оставляет запись видимой в replaying, а не молча потерянной.
И ещё: суточные подметания аудита и DLQ стали кластерными синглтонами через .Cluster(true), то есть первыми встроенными маршрутами Tsak, которые едят собственную догфуд-политику.
redb.Tsak: изоляция модулей и горячая замена
TsakCoordinator подписывался на синхронные события реестра async-лямбдами, то есть через async void. Любое исключение после первого await, а проще всего это коллизия имён контекстов, всплывало как необработанное исключение на потоке пула и завершало весь процесс. Два горячо добавленных модуля, претендующих на одно имя контекста, роняли узел вместо записи об ошибке. Теперь каждый обработчик обёрнут, а коллизия имени это логируемый пропуск: виновный модуль пропускается, остальная пачка грузится, узел стоит.
Дальше цепочка сделана связно асинхронной: события ставятся в очередь, один фоновый потребитель дожидается каждого обработчика в порядке FIFO, персистентность реестра и снимок heartbeat перестали блокироваться через .GetAwaiter().GetResult().
Три утечки в Default ALC. Приватная зависимость модуля из probe-пути грузилась байтами в Default ALC, что пришпиливало её в памяти (с коллекционируемым модулем такое не выгружается) и заставляло два модуля с разными версиями одной приватной зависимости молча делить ту, что загрузилась первой. Обнаружение по голым DLL грузило собственную сборку модуля и в Default, и в изолированный ALC, то есть две разные копии типов модуля, тогда как работал модуль из ALC. И горячая замена с откатом публиковали собственную входную сборку модуля в общий трекер, который делает Assembly.Load второй копии в невыгружаемый Default ALC, причём замена уже работала из нового ALC, так что эта копия не обслуживала никого.
Горячая замена теперь откатывается на любом сбое старта новой версии, а не только на таймауте. Раньше прочие сбои (бросок из CreateContext, ошибка DI) проваливались во внешний catch, который реестр не восстанавливал, новый ALC не выгружал и писал в лог обманчивое «остаёмся на текущей версии», оставляя старый модуль незарегистрированным, а реестр смотрящим на сломанный новый. Общий трекер сборок переключается на новую сборку только после успешного старта.
Горячая раскладка .tpkg перестала пропускать пакет навсегда. Сканер записывал время последней записи до попытки открыть и проверить пакет, поэтому пакет, упавший на этом скане (ещё копирующийся полузаписанный ZIP, или подпись, которая приедет мгновением позже), помечался как «виденный» и больше не рассматривался, пока не изменится mtime. Время теперь пишется только после успешного открытия. Плюс дебаунс стабильности копирования: новый или изменившийся .tpkg должен продержать одинаковый размер и mtime N сканов подряд, прежде чем его откроют.
Отдельный отчёт от @MegasomaWT: RouteBuilderModule не регистрировал свои маршруты. Initialize вызывал _builder.Configure(context), что заполняет только собственный список определений строителя, тогда как контекст компилирует лишь строителей, зарегистрированных через RouteContext.AddRoutes(...). Маршруты до контекста не доезжали, а контекст стартовал с нулём эндпоинтов и без единой ошибки: «started successfully: all 0 endpoints operational». Прежний тест на моках проверял, что Configure был вызван, а не что он подействовал, поэтому оставался зелёным всё время, пока баг ехал в релизах.
Ещё два пункта про сборку. Гейт дымового теста образов не работал: -All не выставлял $Test, поэтому этап пропускался на каждом полном прогоне конвейера, тот самый этап, который поймал умирающий с SIGSEGV воркер в 3.4.0. Возврат гейта сразу вскрыл сломанную пробу: профиль стека проверял pidof supervisord, но supervisord это python-скрипт, так что имя процесса python3 и проба не могла совпасть никогда. И redb.Route.Soap отсутствовал в shared-manifest.psd1, едином источнике правды об общем слое коннекторов, поэтому модуль с эндпоинтом soap:// не нашёл бы компонента в воркере Tsak.
redb.Tsak 3.7.2: 22 настройки redb.Core из 37 не доезжали
Tsak:Redb:StringCollation и Tsak:Redb:EnablePvtPrefilter, обе добавленные в redb.Core в 3.7, не делали ничего. Ни в context.json, ни через Tsak__Redb__*: поведение не менялось, диагностики не было, узнать об этом можно было только чтением исходников Tsak.
Ничего особенного в них не было. Оба пути конфигурации копировали свойства по одной рукописной строке, и списки протухли: из 37 публичных свойств RedbServiceConfiguration именованный путь нёс 15, безымянный 12, и эти множества не были подмножествами друг друга, так что один и тот же ключ вёл себя по-разному в зависимости от того, есть ли у экземпляра имя. Среди недостижимых были все четыре DefaultCheckPermissionsOn*, SystemUserId, MissingObjectStrategy, IdResetStrategy и ThrowOnSchemeMismatch, то есть поведение и контроль доступа, а не тюнинг.
Оба пути теперь сначала прогоняют биндер на рефлексии, поэтому новое свойство RedbServiceConfiguration конфигурируется в тот момент, когда оно появилось; все рукописные присваивания при этом продолжают выполняться следом и остаются главными, так что старое поведение не изменилось, включая устаревшие написания ключей с *Minutes и подсекцию Tsak:Redb:Cache со своим словарём.
Настоящим дефектом была тишина. Неузнанный ключ исчезал без единого слова, поэтому опечатка и отсутствующая функция выглядели одинаково. Теперь неизвестные ключи сообщаются, а применённые настройки пишутся в лог на старте.
При обновлении это стоит держать в голове: ключи, которые раньше не делали ничего, теперь делают. Если в развёрнутом context.json лежит что-нибудь вроде DefaultCheckPermissionsOnQuery: true, до 3.7.2 оно было инертным, а после нет.
redb.Identity: второй фасад и общая середина
Про redb.Identity не раз говорилось, что он транспортно-агностичен: логика живёт в redb.Identity.Core за адресами direct-vm://identity-*, а HTTP это фасад поверх них. Проверить утверждение было нечем, потому что фасад был ровно один.
Теперь их два. Рядом с HTTP встал gRPC: те же маршруты redb.Identity.Core, тот же издатель, тот же реестр клиентов, то же хранилище токенов, операции Token, Introspect, Revoke, UserInfo, Discovery, Jwks. Плюс сорок административных операций на управляющем порту, каждая своим адресом метода и своим идентификатором маршрута. Контракт identity.v1.proto едет в пакете redb.Identity.Contracts, так что не-.NET клиент берёт его из NuGet, а не из репозитория. Подробный разбор с конфигурацией и границами в отдельной статье про gRPC-фасад.
Ради второго фасада переехало две вещи, и обе интереснее самого фасада.
Управляющие контроллеры выделены в пакет redb.Identity.Management: 33 файла, 142 действия и ни строки бизнес-логики, каждое действие валидирует свой DTO и форвардит в маршрут direct-vm://identity-manage-*. Они жили в HTTP-фасаде, откуда второй транспорт не мог до них дотянуться, не сломав изоляцию фасадов. Теперь оба фасада ссылаются на пакет, а не друг на друга, и пакет не ссылается на redb.Identity.Core. SCIM намеренно остался в HTTP-фасаде: RFC 7644 определён поверх HTTP, а ScimControllerBase читает redbHttp.Url, чтобы строить заголовки Location.
Таблица гранулярных скоупов переехала в redb.Identity.Core, за адрес direct-vm://identity-authz-check. Она жила в HTTP-фасаде, а второму транспорту требовались те же решения. Отказ у двух копий таблицы авторизации выглядит не как громкое расхождение, а как тихое «одна из копий даёт больше прав, чем другая», причём на той поверхности, где это важнее всего. Перевезена дословно: тот же порядок, то же правило «запись подразумевает чтение», та же ветка identity:account, тот же deny по умолчанию и та же формулировка отказа, потому что негативная матрица HTTP читает именно эту формулировку, а переписывание ожиданий это способ превратить регрессионную сеть в декорацию. Маршрут регистрируется безусловно: фасад, зовущий отсутствующий адрес, отказал бы открыто, а это единственное направление, в котором гейт авторизации ошибаться не должен.
Гейт, который принимал отсутствие отказа за успех
Самый поучительный пункт по безопасности в релизе.
Развёртывание можно собрать без управляющего auth-процессора, и тогда direct-vm://identity-auth-management остаётся незарегистрированным. Хоп бросает «No consumer registered», обработчик исключений уровня контекста помечает это обработанным и подменяет тело собственным документом об ошибке. Гейт видит ровно то же, что при успехе: отказа не записано, кода не-2xx нет.
Вызов при этом всё равно не выполнялся, но лишь потому, что этот обработчик случайно заканчивал конвейер, и потому, что проводной кодировщик случайно отказался кодировать оставшийся словарь. Две несвязанные страховки, ни одна из которых не является решением об аутентификации. Убери любую, и каждая административная операция выполняется неаутентифицированной.
Теперь гейт после хопа аутентификации требует предъявить identity:management-principal, который оставляет за собой auth-процессор, и отвечает UNAUTHENTICATED, когда его нет. Покрыто фикстурой, которая поднимает redb.Identity.Core без процессора и проверяет и статус, и главное: что в базу не записалось ни строки.
Родственный сюжет: несколько процессоров redb.Identity.Core заканчивают работу вызовом exchange.Stop(), среди них лимитер по IP и гранулярный гейт скоупов, а To отдаёт ему собственный обмен фасада. Флаг заканчивал и конвейер фасада, поэтому ответ уезжал сырым словарём, минуя и отображение статусов, и кодирование в protobuf. То есть статус теряли ровно те ответы, которые важны для безопасности. Хоп в redb.Identity.Core теперь идёт через Enrich по CloneLinked-обмену: тот же идентификатор обмена, тот же DI-скоуп, те же свойства, собственный флаг остановки.
Что ещё починено
Дубликат это конфликт, а не авария базы. Обработчик DbException уровня строителя считал любую ошибку БД временной: нарушение уникального ограничения ретраилось трижды с откатом (на третьей попытке оно совпадает ровно так же, как на первой) и заканчивалось ответом 503 "Database temporarily unavailable", то есть «зайдите позже» про то, что зависит только от вызывающей стороны. Теперь 409 с error: duplicate. Нашлось через SCIM-демо, которое переиспользовало один адрес почты и было отрапортовано как авария базы, откуда и начался поиск.
DDL аудита научился приводить существующую таблицу в порядок вместо того, чтобы молча её пропускать. CREATE TABLE IF NOT EXISTS ничего не делает, когда таблица уже есть, поэтому база, созданная до появления колонки, её никогда не получала. Запрос аудита выбирает category и login, на такой базе он падал, обработчик DbException ретраил трижды и отвечал «база временно недоступна», что читается как авария, а не как расхождение схемы. Скрипты PostgreSQL и MSSQL теперь идемпотентно добавляют недостающие колонки перед индексами, а Postgres дополнительно чинит два типа, разъехавшихся после первого релиза: details из jsonb в text (диалект-агностичное связывание параметров передаёт строки, а jsonb их отвергает, поэтому падала каждая запись аудита) и user_id из varchar в bigint, причём второе только тогда, когда все существующие значения числовые, иначе таблица остаётся нетронутой с уведомлением, потому что обнуление неразбираемых идентификаторов это потеря данных, на которую никто не соглашался.
И правка документации, стоившая бы дорого при эксплуатации: README и doc/DEPLOYMENT.md описывали опцию под именем, которому не соответствует ни один тип, и врали про её значение по умолчанию. Документация обещала, что общее хранилище ключей подписи включено из коробки (в таблице проверки стояло «(default)»), тогда как в коде оно выключено. Оператор, поднявший больше одной реплики, читал, что JWKS общий, хотя реплики по умолчанию расходятся по ключам. Исправлено на факт, с явной пометкой «выключено по умолчанию, включайте для нескольких реплик». Архивные документы (записи законченных спринтов) при этом намеренно оставлены как есть: переписывать имена внутри них означало бы фальсифицировать запись.
Наконец, 3.7.1 починил то, из-за чего gRPC-фасад в 3.7.0 доезжал не до всех. build-modules собирает три .tpkg, но список того, что копируется в артефакты, был захардкожен как Core.Module плюс Http: в build-archives.ps1 в трёх местах и в трёх Dockerfile. Пакет redb.Identity.Grpc был опубликован и объявлен, а его .tpkg собирался и молча выбрасывался, так что при установке из архива или образа gRPC-фасада не было вовсе. Теперь скрипт перечисляет *.tpkg из выхода сборки и печатает, что именно кладёт, а Dockerfile копируют по маске. Новый модуль едет потому, что он существует, а не потому, что кто-то вспомнил дописать строчку.
Мелочь, заметная всем
redb.Tsak и redb.Identity начали класть в пакеты XML-документацию. GenerateDocumentationFile не был включён ни там, ни там, поэтому потребитель redb.Tsak.Client или redb.Identity.Core не получал вообще никакого IntelliSense: в пакете лежала голая .dll. Теперь lib/<tfm>/*.xml едет с каждым пакетом, и только у redb.Identity.Core это 670 КБ.
CS1591 (публичный член без XML-комментария) при этом подавлен, в отличие от redb.Route, где он намеренно оставлен включённым: без подавления включение файла документации заливает сборку предупреждениями на членах, написанных до этой политики. А вот предупреждения о синтаксисе комментариев оставлены видимыми, потому что они помечают настоящие дефекты, и таких хватает: только redb.Identity.Core даёт 218 штук, из них 128 нерезолвящихся cref и 100 методов без тега param.
Как поставить
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.Grpc
dotnet add package redb.Route.Soap
Актуальный номер линии 3.7.2. Библиотеки таргетят net8.0, net9.0 и net10.0; хостовые приложения, образы и архивы собраны на .NET 10 и помечены тегом -net10. Pro остаётся проприетарным, но бесплатным и без лицензионного ключа на всей линии 3.x.
Исходники: github.com/redbase-app. Про хранилище: redb.ru.
Что дальше
Несколько пунктов этого релиза приехали отчётами снаружи, и часть из них была не там, где выглядела. Это самый дорогой вид входящего, дороже любого пожелания, поэтому спасибо всем, кто написал вместо того, чтобы молча уйти. Журнал отзывов ведётся в репозитории, и туда попадает каждый внешний сигнал: валидный, спорный и отклонённый, с оценкой и ссылкой на то, куда он зафиксирован.
Если было полезно, ⭐ на GitHub поможет другим это найти.
Другие мои статьи — redb.ru/articles, ещё — на Хабре.