🌎

Cosmos DB 継続トークンでデータのページ分割取得を実現する

2024/01/23に公開

はじめに

Cosmos DB 継続トークンの技術検討を行う機会があり、その仕組みとSDK(Node)を使った実装方法を一通りキャッチアップしたのでこの記事で紹介したいと思います。
普段の開発でAzure Cosmos DB Node.js SDKを使っていますが、個人的な感覚として継続トークンの処理実装はちょっと癖があり苦戦したのでこれから検討する方の助けになれば幸いです。

開発背景

Webアプリケーション開発の中で、ページネーション機能を持つリスト表示に向けたAPI開発を行う必要があり、Cosmos DBにてどのようなクエリを発行するべきか検討しました。
その中で、データのページ分割取得ができる「継続トークン」に辿り着き、技術検討を実施しました。
(「まとめ」にて記載しますが、今回のユースケースでは継続トークンはマッチしなかったので採用はしませんでしたが、、、、、。)

継続トークンとは何か

公式ドキュメントより引用
https://learn.microsoft.com/ja-jp/azure/cosmos-db/nosql/query/pagination

Azure Cosmos DB for NoSQL では、クエリが複数ページの結果となる場合があります。 このドキュメントでは、Azure Cosmos DB for NoSQL のクエリ エンジンがクエリ結果を複数のページに分割するかどうかを決定するために使用する基準について説明します。 複数ページにまたがるクエリ結果を管理するために、オプションで継続トークンを使用できます。

継続トークンの使いどころ

  • 取得したいアイテム件数やデータ量が大量になるためページ分割して取得したいケース
  • フロントエンド(Webやモバイルなど)での無限スクロールリスト表示に向けたバックエンド処理にて、データのページ分割取得を行いたいケース
  • など

サンプルコードとコード解説

サンプルコード

import { CosmosClient, QueryIterator } from "@azure/cosmos";
import * as dotenv from "dotenv";
dotenv.config();

// Constants
const AZ_COSMOS_CONNECTION_STRING = process.env.AZ_COSMOS_CONNECTION_STRING;
const DB_ID = process.env.AZ_COSMOS_DB_ID;
const MY_CONTAINER_ID = "MyContainer";
const MAX_ITEM_COUNT = 5;
const PARTITION_KEY_VALUE = "my-partition-key";

// Types
type MyItem = {
  id: string;
  orderBy: number;
  myPartitionKey: string;
};

// Setup
if (!AZ_COSMOS_CONNECTION_STRING || !DB_ID) {
  console.error(
    "Need to set environment variables 'AZ_COSMOS_CONNECTION_STRING' and 'AZ_COSMOS_DB_ID'."
  );
  process.exit(1);
}
const cosmosClient = new CosmosClient(AZ_COSMOS_CONNECTION_STRING);
const database = cosmosClient.database(DB_ID);
const myContainer = database.container(MY_CONTAINER_ID);

// Methods
const getQueryIterator = (
  continuationToken?: string
): QueryIterator<MyItem> => {
  return myContainer.items.query<MyItem>(
    {
      query: "SELECT * FROM r ORDER BY r.orderBy ASC",
      parameters: [],
    },
    {
      maxItemCount: MAX_ITEM_COUNT,
      partitionKey: PARTITION_KEY_VALUE, // You can set partition key value to WHERE clause in query instead of FeedOptions parameter.
      continuationToken,
    }
  );
};

(async () => {
  // Initial Query
  let nextContinuationToken: string | undefined = undefined;
  let queryIterator = getQueryIterator(nextContinuationToken);

  // Fetch iteration with continuation token
  while (true) {
    // Fetch
    const response = await queryIterator.fetchNext();

    // Result;
    console.log("\n-- ResponseResult --");
    console.log("ContinuationToken:", response.continuationToken); // > +RID:~qztdAKFwky5BAAAAAAAAAA==#RT:19#TRC:95#RTD:rWIASO3JJG5aAo15Ia0BBMBX4A==#ISV:2#IEO:65567#QCF:8
    console.log("Count:", response.resources.length); // > 5
    console.log("---------------------------");
    console.log("\n-- resources --");
    for (const resource of response.resources) {
      console.log(resource.orderBy, resource.id); // > 1 df734e60-0fba-405b-aeca-2176cc946f03
    }
    console.log("---------------------------\n\n");

    // Get next iterator
    if (!response.hasMoreResults || !response.continuationToken) break;
    nextContinuationToken = response.continuationToken;
    queryIterator = getQueryIterator(nextContinuationToken);
  }
})();

コード解説

queryメソッドの引数にFeedOptionsを渡してQueryIteratorを取得する

公式リファレンス: query(string | SqlQuerySpec, FeedOptions)
公式リファレンス: FeedOptions interface
公式リファレンス: QueryIterator class

const getQueryIterator = (
  continuationToken?: string
): QueryIterator<MyItem> => {
  return myContainer.items.query<MyItem>(
    {
      query: "SELECT * FROM r ORDER BY r.orderBy ASC",
      parameters: [],
    },
    {
      maxItemCount: MAX_ITEM_COUNT,
      partitionKey: PARTITION_KEY_VALUE, // You can set partition key value to WHERE clause in query instead of FeedOptions parameter.
      continuationToken,
    }
  );
};

queryメソッドの第2引数に渡すFeedOptionsにて、以下3つのプロパティを設定してください。

  • maxItemCount: 取得したいアイテム件数
  • partitionKey: パーティションキーの値(注意:キー名ではなく値)
  • continuousToken: クエリレスポンスから取得した継続トークンの値(初期値はundefined)

fetchNextメソッドの結果のクエリレスポンス(FeedResponse)から継続トークンを取得する

公式リファレンス: fetchNext()
公式リファレンス: FeedResponse class

    const response = await queryIterator.fetchNext();

    // Result;
    console.log("\n-- ResponseResult --");
    console.log("ContinuationToken:", response.continuationToken); // > +RID:~qztdAKFwky5BAAAAAAAAAA==#RT:19#TRC:95#RTD:rWIASO3JJG5aAo15Ia0BBMBX4A==#ISV:2#IEO:65567#QCF:8

fetchNext()の結果で受け取ったFeedResponseに含まれるcontinuousTokenプロパティの値が継続トークンです。

こちらのような値となります。
+RID:~qztdAKFwky5BAAAAAAAAAA==#RT:19#TRC:95#RTD:rWIASO3JJG5aAo15Ia0BBMBX4A==#ISV:2#IEO:65567#QCF:8

継続トークンが存在する場合は次のQueryIteratorを取得する

公式リファレンス: hasMoreresults

    // Get next iterator
    if (!response.hasMoreResults || !response.continuationToken) break;
    nextContinuationToken = response.continuationToken;
    queryIterator = getQueryIterator(nextContinuationToken);

hasMoreResultsプロパティがtrueの場合、未取得のアイテムが存在するため、QueryIteratorを取得して次のアイテムを取得してください。

継続トークンを使う際のポイント

OFFSET LIMITではなく継続トークンを使う?

公式ドキュメントより引用

OFFSET LIMIT を使用するクエリの RU 料金は、オフセットされる用語の数が増えるにつれて増加します。 複数の結果ページがあるクエリの場合は、通常、継続トークンを使用することをお勧めします。 継続トークンとは、後でクエリを再開できる場所の "ブックマーク" です。 OFFSET LIMIT を使用する場合、"ブックマーク" はありません。 クエリの次のページを返す場合は、最初から開始する必要があります。

RDBのSQLと同様に、Cosmos DBのOFFSET LIMITも大量データを深いページまで読み込む場合、クエリコストが高くなる可能性があります(実際に全アイテムを読み込んでから、取得したいページまで読み飛ばすため)。一方で、継続トークンは、1ページ目から順次読み込んでいくため、読み飛ばしは発生しないため、ユースケースによってはクエリコストが抑えられるメリットがあります。

継続トークンはステートレス

公式ドキュメントより引用

Azure Cosmos DB for NoSQL のクエリ実行は、サーバー側ではステートレスであり、継続トークンを使用していつでも再開できます。

継続トークンを使うことで、取得済みデータの続きを取得することが可能となります。
また、同じ継続トークンを使用すると、再度同じデータを取得することができます。

SDKによってはパーティションキーの指定が必須

公式ドキュメントより引用

Python SDK の場合、後続トークンは単一パーティション クエリでのみサポートされます。 パーティション キーはクエリ自体に含めるには十分でないため、オプション オブジェクトで指定する必要があります。

公式ドキュメントではPython SDKに関して、クエリにパーティションキーの指定が必要であると記載がありましたが、Node SDKに関してもパーティションキーが必要でした。

まとめ

冒頭で「今回のユースケースでは継続トークンはマッチしなかったので採用はしませんでしたが、、、、、。」と記載しました。
今回のユースケースは、フロントエンドのUIにてページ番号をスキップする機能があり、継続トークンを使う場合は1ぺージ目から順次読んでいく仕組みとなるため、マッチしない結論となりました。
(仕様的に深いページへのアクセスはあまり想定されなかったため、OFFSET LIMITを使った実装を採用しました)

継続トークンは、大量データの読み込む時に1回のクエリコストが大きくなりすぎる際などに利用できます。
また、Webアプリケーション開発のユースケースだと、無限スクロール系のリスト表示のためのバックエンド実装では使用できると想定できました。その場合、1ページ目から必要なページを順次読み込む形になるのでクエリコストの面でもメリットがると感じております。

株式会社log build

Discussion