Start Debugging

Миграция приложения EF Core с Cosmos DB после изменения экранирования генерируемых id в EF Core 11

EF Core 11 перестает экранировать '/', '\', '?' и '#' в генерируемых значениях id для Cosmos DB, поэтому документы, записанные EF Core 8-10, больше не находятся по ключу. Как понять, затронуты ли вы, когда включать переключатель EscapeIllegalCosmosIdCharacters и как безопасно переписать id с помощью транзакционного пакета.

EF Core 11 меняет то, как провайдер Azure Cosmos DB строит id документа, когда этот id составлен из нескольких значений. EF Core 8, 9 и 10 заменяли /, \, ? и # в каждой части на ^2F, ^5C, ^3F и ^23. EF Core 11 (изменение вышло в 11.0 preview 5, и я проверил его на 11.0.0 RC 1) вместо этого записывает исходные символы. Если ни одно из значений ваших составных ключей не содержит этих четырех символов, обновление ничего не меняет, и после следующего раздела можно дальше не читать. Если содержат, после обновления FindAsync и поиск по ключу перестают находить эти документы. Тогда у вас два варианта: включить переключатель Microsoft.EntityFrameworkCore.EscapeIllegalCosmosIdCharacters до запуска приложения или переписать затронутые id. Для ключей, содержащих / или \, работает только переключатель, потому что Cosmos DB не допускает эти символы в id. На аудит уйдет около часа, и большинство команд обнаружит, что делать ничего не нужно.

Кого это на самом деле затрагивает

Экранирование всегда применялось только к составным id. Я прочитал JsonIdDefinition на теге EF Core 11 RC 1: ключ из одного значения записывается как есть, а экранирование происходит только тогда, когда несколько значений соединяются через |. Свойства ключа секции исключаются из id до этой проверки. Поэтому тип сущности попадает под изменение, только если выполняется одно из условий:

Кроме того, одно из значений ключа должно содержать /, \, ? или #. В ключах GUID и целочисленных ключах их быть не может. Проблема возникает на строковых ключах со свободным текстом или в виде путей (артикулы вроде shoes/red, slug вроде 2026/09/hello, имена файлов, URL).

Я прогнал одни и те же сущности через EF Core 10.0.12 и EF Core 11.0.0 RC 1 без базы данных. Добавление сущности в DbContext запускает генератор значений id, поэтому сгенерированный id можно прочитать прямо из трекера изменений:

Значения ключаEF Core 10.0.12EF Core 11 RC 1EF Core 11 RC 1 + переключатель
o-1, shoes/redo-1|shoes^2Fredo-1|shoes/redo-1|shoes^2Fred
o-1, shoes^2Fredo-1|shoes^2Fredo-1|shoes^2Fredo-1|shoes^2Fred
o-1, C:\temp?#1o-1|C:^5Ctemp^3F^231o-1|C:\temp?#1o-1|C:^5Ctemp^3F^231
o-1, a|bo-1|a^|bo-1|a^|bo-1|a^|b
одиночный ключ shoes/redshoes/redshoes/redshoes/red
HasDiscriminatorInJsonId, 2026/09/helloLegacyPost|2026^2F09^2FhelloLegacyPost|2026/09/helloLegacyPost|2026^2F09^2Fhello

Первые две строки и есть причина изменения (dotnet/efcore#38244). Сам символ экранирования ^ никогда не экранировался, поэтому shoes/red и shoes^2Fred получали один и тот же id, и вторая вставка молча перезаписывала первый документ. Команда EF решила прекратить экранирование вместо того, чтобы экранировать еще и ^, потому что экранирование ^ сломало бы каждый существующий id, содержащий этот символ. Исправление находится в dotnet/efcore#38245, слито 2026-05-08. Обратите внимание, что разделитель | по-прежнему экранируется как ^| в обеих версиях, так что эта часть ваших id не меняется.

Что ломается

ОбластьИзменение после обновления до EF Core 11Серьезность
FindAsync и запросы, фильтрующие по полному ключу плюс ключу секцииEF превращает их в точечное чтение по заново сгенерированному id, поэтому документы, сохраненные с экранированным id, возвращаются как null или как пустой результатвысокая
Вставка сущности, ключ которой совпадает со старым документомНовый id отличается, поэтому для ? и # вместо конфликта появляется второй документ с теми же значениями ключавысокая
Новые ключи, содержащие / или \EF 11 теперь отправляет эти символы в id, а служба Cosmos DB не допускает / и \ в id (ограничения службы), поэтому запись завершается ошибкойвысокая
Обновление и удаление сущностей, загруженных запросомПо-прежнему работают: SaveChanges использует значение __id, материализованное из документа, а не сгенерированное зановонет
Запросы, не фильтрующие по полному ключуПо-прежнему работают, они не используют idнет

Опасна вторая строка. Распространенный шаблон “найти, а если нет, добавить” возвращает null для старого документа и создает рядом с ним дубликат. Никакого исключения не выбрасывается.

Предварительный чек-лист

Шаги миграции

  1. Составьте список типов сущностей с составными id. Этот код обходит модель EF через публичные API метаданных Cosmos и применяет то же правило, что и провайдер:

    // EF Core 11.0 RC 1, .NET 11 RC 1
    foreach (var entityType in db.Model.GetEntityTypes().Where(t => !t.IsOwned()))
    {
        var key = entityType.FindPrimaryKey()!;
        var partitionKeyNames = entityType.GetPartitionKeyPropertyNames();
        var idParts = key.Properties.Count(p => !partitionKeyNames.Contains(p.Name));
        var discriminatorInId = entityType.GetDiscriminatorInKey()
            is IdDiscriminatorMode.EntityType or IdDiscriminatorMode.RootEntityType;
    
        Console.WriteLine($"{entityType.DisplayName(),-12} container={entityType.GetContainer(),-8} " +
            $"id parts={idParts} discriminator in id={discriminatorInId} " +
            $"-> {(idParts > 1 || discriminatorInId ? "AFFECTED" : "not affected")}");
    }

    Для модели с секционированным OrderLine (ключ TenantId, OrderId, Sku) и Product с одиночным ключом он выводит:

    OrderLine    container=Orders   id parts=2 discriminator in id=False -> AFFECTED
    Product      container=Catalog  id parts=1 discriminator in id=False -> not affected

    Проверка: у каждого типа сущности с пометкой AFFECTED есть хотя бы одно свойство ключа типа string. Если все они Guid, int или long, вы закончили. Для таких ключей EF Core 11 генерирует те же id, что и EF Core 10.

  2. Подсчитайте документы, которые действительно содержат escape-последовательность. Выполните это в Data Explorer для каждого контейнера, в котором хранится затронутый тип:

    -- Azure Cosmos DB for NoSQL
    SELECT c.id, c["$type"] FROM c
    WHERE CONTAINS(c.id, "^2F") OR CONTAINS(c.id, "^5C")
       OR CONTAINS(c.id, "^3F") OR CONTAINS(c.id, "^23")

    $type это имя дискриминатора в JSON, которое EF записывает начиная с EF Core 9. EF Core 11 переименовал свойство модели в Discriminator, но в JSON имя по-прежнему $type. Проверка: ноль строк означает, что ни у одного сохраненного документа id не изменится. Тогда остается открытым только вопрос, могут ли эти символы содержать новые ключи, и к ним шаг 3 по-прежнему применим.

  3. Решите: оставить старое экранирование или перейти на исходные id. Используйте такое правило:

    • Ключи могут содержать / или \: оставьте старое экранирование с помощью переключателя (шаг 4). Исходные id с этими символами нельзя сохранить в Cosmos DB, так что мигрировать некуда. Единственная альтернатива заключается в изменении самих значений ключей.
    • Ключи могут содержать только ? или #: вы можете переписать id (шаг 5) и навсегда отказаться от старого экранирования. Это заодно устраняет ошибку с коллизиями.
    • Значения ключей могут вводить пользователи: учтите, что при включенном переключателе пользователь, отправивший буквальную строку shoes^2Fred, может перезаписать shoes/red. Если это важно, проверяйте ввод ключей так, чтобы он никогда не содержал ^.

    Проверка: запишите решение для каждого типа сущности. Смешанному контейнеру может понадобиться и то, и другое.

  4. Если оставляете экранирование, включите переключатель до загрузки EF. Провайдер читает переключатель один раз, в поле static readonly класса JsonIdDefinition. Самое надежное место для него это файл проекта, потому что MSBuild записывает его в runtimeconfig.json:

    <!-- EF Core 11.0, .NET 11 -->
    <ItemGroup>
      <RuntimeHostConfigurationOption Include="Microsoft.EntityFrameworkCore.EscapeIllegalCosmosIdCharacters"
                                      Value="true" />
    </ItemGroup>

    AppContext.SetSwitch("Microsoft.EntityFrameworkCore.EscapeIllegalCosmosIdCharacters", true) тоже работает, если это первая строка Program.cs, до создания любого DbContext. Проверяйте с помощью самого генератора id (шаг 6). Я протестировал на RC 1 и запись в runtimeconfig.json, и вызов SetSwitch при запуске, и оба варианта дали o-1|shoes^2Fred.

  5. Если переходите на исходные id, перепишите документы на месте. Cosmos DB не умеет переименовывать id, поэтому каждый документ создается заново под новым id, а старый удаляется. У обоих документов один и тот же ключ секции, поэтому транзакционный пакет делает замену атомарной. Вычисляйте новый id самим EF Core 11, а не реализуйте формат заново: добавьте экземпляр, содержащий только ключ, в одноразовый контекст и прочитайте __id. Переписывайте через исходный JSON, а не через EF, потому что EF Core 11 больше не сохраняет свойства JSON, не отображенные в модели, и сохранение через EF их бы потеряло.

    // EF Core 11.0 RC 1, Microsoft.Azure.Cosmos 3.x, .NET 11 RC 1
    // Run with the switch OFF, so EF generates the new ids.
    using var client = new CosmosClient(connectionString);
    var container = client.GetContainer("shop", "Orders");
    var query = new QueryDefinition(
        """
        SELECT * FROM c
        WHERE c["$type"] = "OrderLine"
          AND (CONTAINS(c.id, "^2F") OR CONTAINS(c.id, "^5C") OR CONTAINS(c.id, "^3F") OR CONTAINS(c.id, "^23"))
        """);
    
    await using var idContext = new ShopContext(connectionString); // never saved
    using var iterator = container.GetItemQueryStreamIterator(query);
    while (iterator.HasMoreResults)
    {
        using var page = await iterator.ReadNextAsync();
        page.EnsureSuccessStatusCode();
        var documents = JsonNode.Parse(page.Content)!["Documents"]!.AsArray();
    
        foreach (var doc in documents.Select(d => d!.AsObject()))
        {
            var oldId = (string)doc["id"]!;
            var line = new OrderLine
            {
                TenantId = (string)doc["TenantId"]!,
                OrderId = (string)doc["OrderId"]!,
                Sku = (string)doc["Sku"]!,
            };
            var entry = idContext.Add(line);
            var newId = (string)entry.Property("__id").CurrentValue!;
            entry.State = EntityState.Detached;
    
            if (newId == oldId) continue; // the key really contains "^2F"
            if (newId.Contains('/') || newId.Contains('\\'))
            {
                Console.WriteLine($"BLOCKED  {oldId} -> {newId}");
                continue;
            }
    
            Console.WriteLine($"{(apply ? "REWRITE " : "DRY RUN ")} {oldId} -> {newId}");
            if (!apply) continue;
    
            var etag = (string)doc["_etag"]!;
            foreach (var system in new[] { "_rid", "_self", "_etag", "_attachments", "_ts" })
                doc.Remove(system);
            doc["id"] = newId;
    
            using var body = new MemoryStream(JsonSerializer.SerializeToUtf8Bytes(doc));
            using var result = await container
                .CreateTransactionalBatch(new PartitionKey(line.TenantId))
                .CreateItemStream(body)
                .DeleteItem(oldId, new TransactionalBatchItemRequestOptions { IfMatchEtag = etag })
                .ExecuteAsync();
    
            if (!result.IsSuccessStatusCode)
                Console.WriteLine($"  FAILED {result.StatusCode}: {result.ErrorMessage}");
        }
    }

    Ключ читается из собственных свойств документа, а не восстанавливается из экранированного id. Именно это делает инструмент безопасным в случае коллизии: документ, сохраненный как o-1|shoes^2Fred, у которого Sku действительно равен shoes^2Fred, на EF 11 дает тот же id, поэтому пропускается. Я прогнал логику вычисления id офлайн на RC 1 на четырех тестовых документах. Она вывела BLOCKED o-1|shoes^2Fred -> o-1|shoes/red, DRY RUN o-1|gift^23card -> o-1|gift#card, DRY RUN o-1|what^3F -> o-1|what? и пропустила документ с буквальным shoes^2Fred. Сам пакет я не запускал ни на рабочей учетной записи, ни в эмуляторе, поэтому сначала выполните пробный прогон, а затем --apply на восстановленной копии базы данных. IfMatchEtag на удалении заставляет пакет завершиться ошибкой, а не потерять параллельную запись. Если это произошло, запустите инструмент еще раз. Проверка: запрос из шага 2 возвращает только документы BLOCKED или ничего.

  6. Зафиксируйте формат id тестом. Генератор id срабатывает при Add, поэтому модульный тест может проверить формат без Cosmos DB:

    // EF Core 11.0 RC 1, xUnit v3
    [Theory]
    [InlineData("shoes/red", "o-1|shoes^2Fred")] // expected with the switch on
    [InlineData("gift#card", "o-1|gift^23card")]
    public void Generated_id_matches_stored_documents(string sku, string expectedId)
    {
        using var db = new ShopContext("AccountEndpoint=https://localhost:8081/;AccountKey=dGVzdA==");
        var entry = db.Add(new OrderLine { TenantId = "t1", OrderId = "o-1", Sku = sku });
        Assert.Equal(expectedId, entry.Property("__id").CurrentValue);
    }

    Проверка: тест проходит в CI с тем же runtimeconfig.json, с которым поставляется приложение. Если кто-то удалит RuntimeHostConfigurationOption, упадет этот тест, а не продакшен.

Проверка

После развертывания EF Core 11 проверьте на реальных данных три вещи:

План отката

Вариант с переключателем полностью обратим: откатите обновление пакета, и EF Core 10 будет генерировать те же id, что и всегда. Переписывание id повторным развертыванием не откатить. EF Core 10 снова генерирует экранированные id и не находит переписанные документы. Поэтому переписывание привязывает вас к EF Core 11. Если нужен путь назад, либо восстановите контейнер из резервной копии, сделанной перед переписыванием, либо разверните EF Core 11 с включенным переключателем (старые id при нем остаются валидными) и запустите инструмент в обратном направлении. Проверьте этот путь заранее, прежде чем на него полагаться.

Подводные камни

Связанные материалы

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад