しばやん雑記

Azure とメイドさんが大好きなプログラマーのブログ

Azure Cosmos DB の All Versions and Deletes Change Feed を試した

Build 2026 で発表された Cosmos DB の新機能の中でも、Private Preview から GA まで 6 年近くかかったのが All Versions and Deletes Change Feed です。以前は Full Fidelity Change Feed とも呼ばれていて、既に Private Preview の時に Hack Azure のイベントでも話したことがあるのですが、ようやく GA したということもあり振り返りを兼ねて試しておきます。

基本的な情報は Cosmos DB チームのブログを読んで貰えば理解できるはずです。そもそも名前からも機能は想像つくと思いますが、これまでの Change Feed とは異なっている部分が多いので、ドキュメントも参照しておくのがベストです。

名前の通り Cosmos DB で行われた全ての処理について Change Feed で読み取ることが出来るのが大きな特徴です。これまでの Change Feed は結果整合性でアーキテクチャの設計が必要でしたが、今回の All Versions and Deletes Change Feed は中間の変更や削除の情報も取れるため、正確な Event Sourcing が非常に簡単に実装できるようになります。

削除や TTL の情報が取れるので、Event Sourcing 以外にも Azure AI Search や SQL Database に Secondary Index や Materilized View を作成するシナリオでも、適用がしやすくなっています。これまでの Change Feed では削除フラグを用いた論理削除を使う必要がありましたが、設計を変えなくても物理的な削除に追従できるのがメリットです。

All Versions and Deletes Change Feed を利用するには Features から明示的に有効化する必要がありますが、前提条件として Continuous Backup の有効化が必須となっています。Continuous Backup は PITR を実現する機能となっているので、内部的には Container に対する操作の全てログを持っているはずです。この Change Feed は Continuous Backup が持っているログを読み取る機能という形だと思われます。

これまでの Incremental Change Feed はシンプルに LSN の順番通りに読むだけなので、データが存在する限りは Container が作成された時点からのリプレイが可能ですが、All Versions and Deletes Change Feed は仕組み上 Continuous Backup の保持期間内のリプレイのみ可能となっています。任意の時点から開始が出来ないのは運用時の注意点となります。

Preview 中は Azure Functions でのサポートがなく SDK を直接使う必要があったのですが、GA のタイミングで Azure Functions の CosmosDBTrigger が All Versions and Deletes Change Feed に対応しています。拡張機能 Microsoft.Azure.Functions.Worker.Extensions.CosmosDB のバージョンを 4.16.1 以降に更新すると利用できます。

新しく ChangeFeedMode プロパティが追加されているので、このプロパティで Change Feed の動作が変わります。ここからは実際に ChangeFeedMode プロパティの値を変更して動作確認を行っていきます。

LatestVersion (Incremental Change Feed)

これまでの Change Feed は ChangeFeedMode を指定しない、あるいは CosmosDBChangeFeedMode.LatestVersion を指定すると有効になります。サンプルコードは以下のようになりますが、これまで通りの書き方なので特に違いはありません。今後は明示的に ChangeFeedMode を指定した方が分かりやすいとは思います。

public class ChangeFeed(ILogger<ChangeFeed> logger)
{
    [Function(nameof(LatestVersion))]
    public void LatestVersion([CosmosDBTrigger(databaseName: "MyDatabase",
                                               containerName: "MyContainer",
                                               Connection = "CosmosConnection",
                                               LeaseContainerName = "Leases",
                                               LeaseContainerPrefix = nameof(LatestVersion),
                                               CreateLeaseContainerIfNotExists = false,
                                               ChangeFeedMode = CosmosDBChangeFeedMode.LatestVersion)]
                              IReadOnlyList<MyDocument> items)
    {
        logger.LogInformation("Latest version change feed received {Count} item(s).", items.Count);

        foreach (var item in items)
        {
            logger.LogInformation(
                "Changed item: id={Id}, title={Title}, body={Body}",
                item.Id,
                item.Title,
                item.Body);
        }
    }
}

名前の通り Change Feed を読みに行ったタイミングでの最新バージョンを取得するモードなので、読み取るまでに 1 つの項目に対して複数回のアップデートや削除が行われていた場合は検知不可能です。アーキテクチャの設計時点で結果整合性になるように組む必要がありますが、ほぼこのモードで問題ないケースが多いです。

AllVersionsAndDeletes (Full Fidelity Change Feed)

新しく追加された All Versions and Deletes Change Feed を読み取るようにするには ChangeFeedMode プロパティに CosmosDBChangeFeedMode.AllVersionsAndDeletes を指定するだけですが、注意点としては引数の型が Incremental Change Feed とは異なり IReadOnlyList<ChangeFeedItem<T>> のように ChangeFeedItem<T> で包む必要があります。

この ChangeFeedItem<T> は Cosmos DB SDK 側に用意された型で、この型を使わないと正しく Change Feed を読み取れないので Incremental Change Feed から切り替える場合は忘れないようにしましょう。

public class ChangeFeed(ILogger<ChangeFeed> logger)
{
    [Function(nameof(AllVersionsAndDeletes))]
    public void AllVersionsAndDeletes([CosmosDBTrigger(databaseName: "MyDatabase",
                                                       containerName: "MyContainer",
                                                       Connection = "CosmosConnection",
                                                       LeaseContainerName = "Leases",
                                                       LeaseContainerPrefix = nameof(AllVersionsAndDeletes),
                                                       CreateLeaseContainerIfNotExists = false,
                                                       ChangeFeedMode = CosmosDBChangeFeedMode.AllVersionsAndDeletes)]
                                      IReadOnlyList<ChangeFeedItem<MyDocument>> changes)
    {
        logger.LogInformation("All versions and deletes change feed received {Count} change(s).", changes.Count);

        foreach (var change in changes)
        {
            switch (change.Metadata.OperationType)
            {
                case ChangeFeedOperationType.Create:
                    logger.LogInformation(
                        "Created item: id={Id}, title={Title}, body={Body}",
                        change.Current.Id,
                        change.Current.Title,
                        change.Current.Body);
                    break;
                case ChangeFeedOperationType.Replace:
                    logger.LogInformation(
                        "Replaced item: id={Id}, title={Title}, body={Body}",
                        change.Current.Id,
                        change.Current.Title,
                        change.Current.Body);
                    break;
                case ChangeFeedOperationType.Delete:
                    if (change.Metadata.IsTimeToLiveExpired)
                    {
                        logger.LogInformation("Deleted item by TTL expiration.");
                    }
                    else
                    {
                        logger.LogInformation("Deleted item.");
                    }
                    break;
                default:
                    logger.LogWarning("Unsupported change feed operation: {OperationType}", change.Metadata.OperationType);
                    break;
            }
        }
    }
}

読み取った ChangeFeedItem<T> には CurrentMetadata が含まれているので、基本的には Metadata.OperationType に従って処理を分岐させることになります。Current にはこれまでと同じように現在の項目が入ってきます。

作成と置換は分かりやすいですが、削除の場合は Current が入ってこないので Metadata に含まれる内容だけで処理を行う必要があります。具体的には IdPartitionKey が含まれているので、この 2 つで処理を行うことになります。

ChangeFeedItem<T> には Current の他に Previous が存在していますが、この変更前の項目も同時に読み取る機能は現在 Private Preview になっているため、参加していない場合は常に null が入ってきます。

LatestVersion と AllVersionsAndDeletes の動作の違い

まずはシンプルに Container に対して 2 つの項目を新規追加して確認していきます。新規追加なので LatestVersion と AllVersionsAndDeletes のそれぞれで 2 件のログが出力されていることが確認できます。

LatestVersion と AllVersionsAndDeletes の両方で同じ変更を検知できていることは確認できますが、LatestVersion では変更が発生したということしか分からないので Changed 扱いですが、AllVersionsAndDeletes では明確に作成と変更を区別できるので Created となっています。

次は 1 つの項目を作成した後にその項目に対して複数回の更新を行って確認をしてみます。Change Feed の読み取りを跨いで更新を行うと意図した動作にならないため、今回は更新を短時間の間に行うようにしています。そのため LatestVersion では 1 回の変更のみ検知されていることが分かります。

一方 AllVersionsAndDeletes では最初の項目作成から 4 回の更新を含めて全て検知できていることが確認できます。ほぼ同時に更新を行っていますが全ての処理は記録されていて、更新順序は担保されているので順番通りに Change Feed も読み取れていることが分かりますね。

特徴としては 1 回目は項目の作成なので Created になっていて、それ以降は更新即ち Cosmos DB としては置換扱いになるので Replaced になっています。これは AllVersionsAndDeletes でしか実現できない挙動です。

最後に削除周りの確認ですが、LatestVersion では削除された項目は検知できないですが、AllVersionsAndDeletes では TTL による削除も含めて項目の削除も検知できるようになっています。以下は TTL で削除された場合ですが、正しく Change Feed で読めていることが確認できます。

削除は明示的な削除と TTL による削除の 2 種類が存在していますが、区別する必要がある場合は Metadata.IsTimeToLiveExpired を参照することで区別できます。現実的には TTL かどうかで処理を変えることは少ないと思いますが、監査系では必要になるケースもあると思います。

今回の All Versions and Deletes Change Feed は Integrated Embedding を利用する際の必須要件となっていることから、今後追加される機能では Continuous Backup と All Versions and Deletes Change Feed が内部的に活用されていきそうです。

ちなみに Global Secondary Index も Change Feed を使って実現されていますが、GSI は結果整合性で問題がないので Incremental Change Feed が使われているようです。