Momento Leaderboard を試してみる。

2023/10/27に公開

Mementoのドキュメントを除いていたら、新しいサービスが生えていることに気づきました。

Cache, Topic, Vector Index, に加えてLeaderboardというものが増えています。

この記事では早速やってみたいと思います。
ドキュメント上にこの様な記載があります。

Momento Leaderboards は、数千万のアイテムと迅速な取り込み/クエリ/更新をサポートするサーバーレス リーダーボード サービスです。多くのデータベースは、ソートされたセットや範囲クエリなどの汎用データ構造を介してリーダーボードに近似しますが、Momento Leaderboard は、アプリケーションに迅速かつ簡単に統合できる一流の本格的なサービスです。

どうも大規模なゲームの順位表などを高速に計算させるためのサービスのようです。

一般的なLeader Board の難しさ

オンライン対戦ゲームなどでよくあるLeader Boardはキャッシュと非常に相性の良いサービスです。例えば複数人対戦型ゲームの場合、対戦途中の順位などは恒久的な保存が必要ないため、処理コストが高価な永続性を持つデータベースを用いるよりも揮発性を持つ処理コストが安いキャッシュを用いることでコスト効率がよくなります。永続性のあるストレージには対戦結果だけを書き込んでおけば良いのです。

例えば10人対戦のゲームを想定します。相手を倒すと2点、倒されると1点、プレイヤーは何度でも復活し一定時間経過時点での最終スコアを競う、というゲームがあるとします。

まずプレイヤーごとにほぼリアルタイムで「2を足す」「1を引く」という処理が行われることになります。これがプレイヤー分、つまり10並行で行われます。Leader Boardはその10並行処理の結果
をさらに集計し特定範囲で出力する機能を有する必要があります。

なんとなくその忙しさのイメージがつきますでしょうか。

Momento Leaderboard

Momento Leaderboard はどうもその処理を行う専用マネージドサービスのようです。
https://docs.momentohq.com/leaderboards
各アイテムは最大7日間のTTLがデフォルトで設定されるようです。Momento Cacheが24時間出すから、かなり長めに設定されているようです。また、Momentoに問い合わせることで永続性を持たせることもできるようですが一般的な使い方としては、対戦が終了した後は外部永続データベースに書き込む形がスムーズと思われます。

サンプルソースはここにありますのでそれをやってみます。
https://github.com/momentohq/client-sdk-javascript/blob/main/examples/nodejs/cache/leaderboard.ts
cacheディレクトリの下にありますので、Momento Cacheの一つの使い方と最初は思いましたが、ソースを見るとLeaderboard専用オブジェクトがあるようですので、やはり新しいサービスなのでしょう。

import {
  PreviewLeaderboardClient,
  LeaderboardConfigurations,
  CredentialProvider,
  CacheClient,
  Configurations,
} from '@gomomento/sdk';
const leaderboard = client.leaderboard('my-cache', 'my-leaderboard');

テストにあたり以下にJavascripに変換し、環境変数を使わずに済むように改造したものを記載しておきます。

import {
  PreviewLeaderboardClient,
  LeaderboardConfigurations,
  CredentialProvider,
  CacheClient,
  Configurations
} from "@gomomento/sdk"
import {
  CreateCache,
  LeaderboardDelete,
  LeaderboardFetch,
  LeaderboardLength,
  LeaderboardOrder,
  LeaderboardRemoveElements,
  LeaderboardUpsert
} from "@gomomento/sdk-core"

async function main() {
  const cacheClient = await CacheClient.create({
    configuration: Configurations.Laptop.v1(),
    credentialProvider: CredentialProvider.fromString({
      apiKey: "<key>",
      }),
    defaultTtlSeconds: 60
  })

  const createCacheResponse = await cacheClient.createCache("my-cache")
  if (createCacheResponse instanceof CreateCache.AlreadyExists) {
    console.log("cache already exists")
  } else if (createCacheResponse instanceof CreateCache.Error) {
    throw createCacheResponse.innerException()
  }

  const client = new PreviewLeaderboardClient({
    configuration: LeaderboardConfigurations.Laptop.v1(),
    credentialProvider: CredentialProvider.fromString({
      apiKey: "<key>",
        })
  })

  // Create a leaderboard with given cache and leaderboard names
  const leaderboard = client.leaderboard("my-cache", "my-leaderboard")

  // Upsert elements as a Map
  const elements1 = new Map([
    [123, 100.0],
    [456, 200.0],
    [789, 300.0]
  ])
  const upsertResp1 = await leaderboard.upsert(elements1)
  if (upsertResp1 instanceof LeaderboardUpsert.Success) {
    console.log("Upsert attempt 1: success")
  } else if (upsertResp1 instanceof LeaderboardUpsert.Error) {
    console.log("Upsert error:", upsertResp1.message())
  }

  // Or upsert elements using a Record
  const elements2 = {
    1234: 111,
    5678: 222
  }
  const upsertResp2 = await leaderboard.upsert(elements2)
  if (upsertResp2 instanceof LeaderboardUpsert.Success) {
    console.log("Upsert attempt 2: success")
  } else if (upsertResp2 instanceof LeaderboardUpsert.Error) {
    console.log("Upsert error:", upsertResp2.message())
  }

  // Fetch by score example specifying all options.
  const fetchByScore = await leaderboard.fetchByScore({
    minScore: 100,
    maxScore: 500,
    order: LeaderboardOrder.Ascending,
    offset: 10,
    count: 100
  })
  if (fetchByScore instanceof LeaderboardFetch.Success) {
    console.log("Fetch by score success:", fetchByScore.values())
  } else if (fetchByScore instanceof LeaderboardFetch.Error) {
    console.log("Fetch by score error:", fetchByScore.message())
  }

  // Fetch by rank can be used to page through the leaderboard
  // by requesting N elements at a time (maximum 8192 at a time).
  // This example is using N=2 for this small leaderboard.
  for (let rank = 0; rank < 5; rank += 2) {
    const startRank = rank
    const endRank = rank + 2
    const fetchByRank = await leaderboard.fetchByRank(startRank, endRank, {
      order: LeaderboardOrder.Ascending
    })
    if (fetchByRank instanceof LeaderboardFetch.Success) {
      console.log("Fetch by rank success:", fetchByRank.values())
    } else if (fetchByRank instanceof LeaderboardFetch.Error) {
      console.log("Fetch by rank error:", fetchByRank.message())
    }
  }

  // Get rank fetches elements given a list of element IDs.
  const getRank = await leaderboard.getRank([123, 1234], {
    order: LeaderboardOrder.Ascending
  })
  if (getRank instanceof LeaderboardFetch.Success) {
    console.log("Get rank success:", getRank.values())
  } else if (getRank instanceof LeaderboardFetch.Error) {
    console.log("Get rank error:", getRank.message())
  }

  // Length returns length of a leaderboard. Returns 0 if
  // leaderboard is empty or doesn't exist.
  const lengthResp = await leaderboard.length()
  if (lengthResp instanceof LeaderboardLength.Success) {
    console.log("Get leaderboard length success:", lengthResp.length())
  } else if (lengthResp instanceof LeaderboardLength.Error) {
    console.log("Get leaderboard length error:", lengthResp.message())
  }

  // Remove elements by providing a list of element IDs.
  const removeResp = await leaderboard.removeElements([123, 456, 789])
  if (removeResp instanceof LeaderboardRemoveElements.Success) {
    console.log("Remove elements success")
  } else if (removeResp instanceof LeaderboardRemoveElements.Error) {
    console.log("Remove elements error:", removeResp.message())
  }

  // Delete will remove theh entire leaderboard.
  // Leaderboard items have no TTL so make sure to clean up
  // all unnecessary elements when no longer needed.
  const deleteResp = await leaderboard.delete()
  if (deleteResp instanceof LeaderboardDelete.Success) {
    console.log("Delete leaderboard success")
  } else if (deleteResp instanceof LeaderboardDelete.Error) {
    console.log("Delete leaderboard error:", deleteResp.message())
  }
}


main()
  .then(() => {
    console.log("Leaderboard example completed!")
  })
  .catch(e => {
    console.error(`Uncaught exception while running example: ${e.message}`)
    throw e
  })

<key>の部分を単純にAPI Keyに置き換えてnode sample.jsで実行させるだけです。
成功すると以下のような出力が出ます。

[2023-10-27T08:32:55.354Z] INFO (Momento: CacheControlClient): Creating cache: my-cache
cache already exists
Upsert attempt 1: success
Upsert attempt 2: success
Fetch by score success: []
Fetch by rank success: [ { id: 123, score: 100, rank: 0 }, { id: 1234, score: 111, rank: 1 } ]
Fetch by rank success: [ { id: 456, score: 200, rank: 2 }, { id: 5678, score: 222, rank: 3 } ]
Fetch by rank success: [ { id: 789, score: 300, rank: 4 } ]
Get rank success: [ { id: 123, score: 100, rank: 0 }, { id: 1234, score: 111, rank: 1 } ]
Get leaderboard length success: 5
Remove elements success
Delete leaderboard success
Leaderboard example completed!

中身を見ていきましょう

import部分にはLeaderboard専用の複数オブジェクトが存在していることがわかります。
https://docs.momentohq.com/leaderboards/develop/api-reference
この辺りに一部解説はありますが、まだまだ記載が足りないようです。今後充実していくはずです。

いつも通りCache Clientを作成し、Cacheを作成した後、以下の行でLeaderboard専用クライアントを作成しています。

const client = new PreviewLeaderboardClient({
<snip>
const leaderboard = client.leaderboard("my-cache", "my-leaderboard")

その後2種類の方式でレコードを書き込んでいます。Map形式とRecord形式に対応しているようです。のちのコード解説でも出てきますが、この2つは読み込む時点での書式が異なるだけで、読み込まれた後は同じ属性として取り扱われています。

Map
  const elements1 = new Map([
    [123, 100.0],
    [456, 200.0],
    [789, 300.0]
  ])
  const upsertResp1 = await leaderboard.upsert(elements1)
Record
  const elements2 = {
    1234: 111,
    5678: 222
  }
  const upsertResp2 = await leaderboard.upsert(elements2)

1231234がユーザーID、100.0111が得点という想定です。

  const fetchByScore = await leaderboard.fetchByScore({
    minScore: 100,
    maxScore: 500,
    order: LeaderboardOrder.Ascending,
    offset: 10,
    count: 100
  })

fetchByScoreファンクションで先ほど読み込んだ5つのユーザーに対して検索を行います。
Fetch by score success: []として値が出力されません。関数を以下に書き直すと正しく検索されます。

  const fetchByScore = await leaderboard.fetchByScore({
    minScore: 50,
    maxScore: 500,
    order: LeaderboardOrder.Ascending,
    offset: 0,
    count: 10
  })
result
Fetch by score success: [
  { id: 123, score: 100, rank: 0 },
  { id: 1234, score: 111, rank: 1 },
  { id: 456, score: 200, rank: 2 },
  { id: 5678, score: 222, rank: 3 },
  { id: 789, score: 300, rank: 4 }
]

どうもサンプルではoffsetに値が正しくないようです。
次に以下の部分でランク検索をおこなっています。

  for (let rank = 0; rank < 5; rank += 2) {
    const startRank = rank
    const endRank = rank + 2
    const fetchByRank = await leaderboard.fetchByRank(startRank, endRank, {

先ほど2つの形式で読み込んだ5つのアイテムが等しく扱われ以下のように順位で出力されます。

Fetch by rank success: [ { id: 123, score: 100, rank: 0 }, { id: 1234, score: 111, rank: 1 } ]
Fetch by rank success: [ { id: 456, score: 200, rank: 2 }, { id: 5678, score: 222, rank: 3 } ]
Fetch by rank success: [ { id: 789, score: 300, rank: 4 } ]

次に行われているのが特定ユーザーIDに対する順位順出力です。

const getRank = await leaderboard.getRank([123, 1234], {
Get rank success: [ { id: 123, score: 100, rank: 0 }, { id: 1234, score: 111, rank: 1 } ]

いかがでしょうか。簡単にランキング表が作れそうです。当然実際に利用するためには今日読み込んだデータ[123, 100.0]1234: 111を作成するためにユーザーごとの集計作業を実装する必要がありますので、ここまでシンプルにはいかなそうですが、Order by,Group by等を多用するSQLがいかに実行速度が遅いかはご存じだと思います。それを考えるとかなり処理が高速化できそうですし、今後のドキュメントの充実に期待です!

Discussion