自己ベストを保ち、通過点で同点の全員を含める

RedisBeginner
オンラインで実践に進む

はじめに

Field Day の得点受付には、試行結果が順不同で届きます。Alex の 3 回の試行は 80 点、90 点、85 点です。自己ベストは 90 点です。これらは各試行そのものの結果であり、足し合わせる点数ではありません。別の選手も Alex という名前なので、名前では競技者を識別できません。3 番目の位置の得点に同点者がいれば、その得点の全員が通過します。

再利用可能な Redis の記録関数と通過者選出関数を保存し、実際の試行一覧を処理して、本当の通過者を発表します。その後、値が小さいほど良い小規模なタイムトライアルを完成させます。両演習は、準備済みの架空の識別情報と整数のスコアを使います。基本的なターミナル操作、Redis 接続と GET/SET の知識が前提です。ソート済みセットと提供された Python 関数のひな型は、使用前に説明します。Redis と Terminal は準備済みです。

/home/labex/project/score-desk で作業します。main.py と practice.py を作成し、更新するのは score:main:best、score:practice:best、score:demo: で始まる作業用キーだけです。元の CSV 一覧、名簿のハッシュ、前のラウンド、提供されたすべてのツールを保全してください。新しい一覧に試行がなくても、以前の選手のメイン結果 88 点と、以前の走者の練習結果 72 秒を残す必要があります。

1 回の試行は、別の送信と重なっても、その選手の自己ベストを安全に更新する必要があります。通過者の選出は送信受付の終了後に行います。2 回の読み取りは、変化する競技のスナップショットではありません。スコアは 1~1000000 の整数です。この実習では、記録への異議申し立て、誤った自己ベストの削除、不正行為の検出、小数の計時、永続化やフェイルオーバーは実装しません。

各試行の結果から自己ベストを保つ

このステップでは、遅れて届いた低い結果で置き換えず、各選手の試行の最高得点を保ちます。

Terminal の通常のシェルから始めます。提供された desk プログラムは、実際の一覧とデータベースを読みます。保存した関数をそのまま呼び出し、正しい自己ベストを代わりに計算したり修復したりしません。./ は現在のディレクトリのファイルを実行するという意味です。ディレクトリを移動し、一覧と名簿を確認します。

cd /home/labex/project/score-desk
./desk attempts main
./redis HGETALL score:main:names
./desk data main

player_id は安定した識別情報、score は 1 回の試行の得点そのものです。ID p01 と p02 は、どちらも Alex という名前の人物です。準備済みのメインのソート済みセットには、現在はスコア 88 の p90 だけが含まれています。この選手を残す必要があります。Redis は、選手 ID から名前への対応であるハッシュに、名前を別途保存します。HGETALL はその対応を読みます。編集は不要です。

ソート済みセットは、各メンバーを数値スコアとともに 1 回だけ保存します。ここでのメンバーは選手 ID です。Redis はスコア順にメンバーを並べます。通常の ZADD key score member は、新しいメンバーを追加するか、既存のスコアを置き換えます。使い捨てのデモ用キーで、最後に届いた結果が必ずしも自己ベストではない理由を確認します。

./redis ZADD score:demo:last 80 p01
./redis ZADD score:demo:last 90 p01
./redis ZADD score:demo:last 85 p01
./redis --raw ZSCORE score:demo:last p01

最後のコマンドが 90 を上書きしたため、最終スコアは 85 です。ZSCORE は 1 メンバーのスコアを読み、--raw は CLI に値だけの表示を指定します。ZINCRBY なら試行を加算し、この 3 値から 255 を作ります。この操作は増分には適していますが、今回の試行は増分ではありません。

GT を指定した ZADD は、新しいメンバーを追加し、既存のスコアは新しい値が大きい場合だけ変更します。比較と更新は 1 つの Redis コマンド内で行われます。クライアント側で別々に読み取ってから無条件に書き込むと、別の呼び出しと競合する場合があります。アトミックな条件を試します。

./redis ZADD score:demo:best GT 80 p01
./redis ZADD score:demo:best GT 90 p01
./redis ZADD score:demo:best GT 85 p01
./redis ZADD score:demo:best GT 90 p01
./redis --raw ZSCORE score:demo:best p01

重複した試行の後も、スコアは 90 のままです。既定では ZADD の応答は新しいメンバーの数なので、0 は既存メンバーの更新に成功した場合にも、試行を無視した場合にも返り得ます。エラーではありません。区別するには実際のスコアを読んでください。GT は新しい選手の挿入を妨げません。XX を追加すると初回の試行を登録できなくなるため、ここでは誤りです。

送信したすべての行が同じルールに従うよう、再利用可能な関数を保存します。提供された呼び出し側は、Redis クライアント、実際のキー、選手 ID、スコアを渡します。Python の def は関数を定義し、括弧内の名前がそれらの値を受け取ります。インデントした return はコマンドを送信し、実際の応答を返します。引用符付きの単語は Redis コマンドのリテラル引数で、引用符のない名前は呼び出し側の値を使います。Python の Redis パッケージをインストールする必要はありません。提供されたクライアントが Redis 本来のコマンドを送ります。

次のヒアドキュメントでファイル全体を書き込みます。cat > main.py はそのファイルを置き換えます。引用符付きの PY マーカーにより、シェルは本文を解釈しません。閉じる PY は単独行にします。return の前の 4 個の空白を保ってください。ブロック全体を Terminal に貼り付けます。

cat > main.py <<'PY'
def record(client, key, player, score):
    return client.command("ZADD", key, "GT", score, player)
PY

準備済みの一覧を、自分の関数を通して再生します。その後もう一度再生し、遅れて届いた低い試行を再送します。

./desk replay main
./desk data main
./desk replay main
./desk submit main p01 85
./redis --raw ZSCORE score:main:best p01
./redis ZREVRANK score:main:best p01

p01 は 90 のままで、もう 1 人の Alex である p02 も、スコア 90 の別メンバーとして残ります。セット全体には p05=100、p03=95、p06=90、p04=70 と、以前の p90=88 も含まれます。data は、Redis の昇順で ID とスコア文字列を交互に表示します。

ZREVRANK は、降順でのメンバーのゼロ始まりの位置を返します。最初の位置は 0 です。同点では ID のバイト順の逆順を使うため、ここでの p01 の位置は 4 です。この位置によって、通過点で同点の選手が落選するわけではありません。通過者の選出には、固定個数の ID ではなくスコアを使います。

ファイル名やコマンドを打ち間違えたら、エラーを読み、ls で実際のファイルを確認して、修正したコマンドを再試行します。Score desk error は正常な空の結果とは異なります。誤った得点ルールを適用した場合は、まず main.py を直し、元の一覧を確認し、通常の ZADD で影響を受けた各選手の本当の自己ベストを復元します。たとえば ./redis ZADD score:main:best 90 p01 です。一覧や名簿の編集、ラウンドの消去、以前の選手の除外はしないでください。実際の結果を確認してから、ステップのチェックを実行します。

通過点で同点の全員を含める

このステップでは、位置による境界を、同点を含むスコア境界に変換し、同点の全選手が通過できるようにします。

Terminal と score-desk ディレクトリで続けます。この部分では送信受付は終了しています。ルールは、先頭 3 位置の選手に加え、3 番目の位置の得点で同点の全員を含めることです。その得点を見つける判断と、それを満たす全選手を含める判断は別々です。

ZRANGE はソート済みセットを読みます。スコア指定のオプションがなければ、境界は両端を含む、ゼロ始まりの位置です。REV はスコアの順序を逆にし、WITHSCORES は各メンバーのスコアを含めます。3 番目の位置のインデックスは 2 です。

./redis --raw ZRANGE score:main:best 0 2 REV WITHSCORES
./redis --raw ZRANGE score:main:best 2 2 REV WITHSCORES

最初のコマンドは p05、p03、p06 だけを返します。2 つ目は p06 と 90 を返すので、通過点は 90 です。3 人で止めると、明示された同点ルールでは通過する p02 と p01 が抜け落ちます。

BYSCORE を指定すると、境界は位置ではなくスコアになります。逆順では上限を先に指定します。+inf は上限がないことを意味します。通常のスコア境界は等しい値も含みます。( で始まる境界は等しい値を除外するため、通過点で同点の全員を誤って落としてしまいます。通過者の集合全体を読み取ります。

./redis --raw ZRANGE score:main:best +inf 90 BYSCORE REV WITHSCORES

5 つの ID すべてが、p05、p03、p06、p02、p01 の順に現れます。同点時の ID 順は Redis による表示上の慣例であり、同点者を除外したり、名前を並べ替えたりするルールではありません。

任意の正の k に対応させます。クライアントは、文字列が交互に並ぶ Python のリストを返します。境界の選手 1 人なら ["p06", "90"] です。リストのインデックス 0 は ID、インデックス 1 はスコアです。そのため boundary[1] は Redis の実際のスコアを使います。k - 1 は指定された位置をゼロ始まりのインデックスに変換します。代入 = は、その応答をローカル変数 boundary に保存します。

if not boundary は、リストが空かどうかを調べます。k が選手数より大きい場合は全員が通過します。位置 0 から -1 までを読みます。-1 は最後のメンバーを意味します。空のラウンドでも、これで該当なしを正直に示す空のリストが返ります。この代替処理はサンプルの答えではなく、実際の Redis セットを使います。

次の関数を main.py に追記します。ここでの >> は既存の記録関数を置き換えずに追記します。Python は if の下の追加のインデントで、条件付きの return を識別します。

cat >> main.py <<'PY'

def qualify(client, key, k):
    boundary = client.command("ZRANGE", key, k - 1, k - 1, "REV", "WITHSCORES")
    if not boundary:
        return client.command("ZRANGE", key, 0, -1, "REV", "WITHSCORES")
    return client.command("ZRANGE", key, "+inf", boundary[1], "BYSCORE", "REV", "WITHSCORES")
PY

提供された board アクションは qualify を呼び、実際の名簿から名前を付けます。返されたメンバー、値、順序をそのまま保ちます。再度の絞り込み、欠けた同点者の追加、正しい通過点への置換は行いません。異なるリクエストを試します。

./desk board main 3
./desk board main 1
./desk board main 100

最初のリクエストは 5 人の選手オブジェクトを返します。両方の Alex のレコードが、異なる player_id 値で現れます。2 つ目はスコア 100 の p05 だけを返します。最後は 88 の p90 と 70 の p04 も含む 7 人全員を返します。ボードの読み取りで、新しい試行は送信されません。

この関数の 2 回の読み取りは、受付終了後のラウンドを想定しています。送信が続く間の一貫したスナップショットを保証するものではありません。記録用の単一の条件付きコマンドはアトミックですが、これは別の性質です。記録関数を動作する状態に保ち、ステップのチェックを実行します。

同じルールをタイムトライアルに適用する

このステップでは、独立した練習ラウンドで、値が小さいほど良い場合の記録と通過者選出の方法を完成させ、使用します。

Terminal と同じディレクトリで続けます。短い練習レースは、正の整数の経過秒数を記録するので、小さいスコアほど良い結果です。独立した一覧には新しい ID があり、以前の走者 t90 はすでに 72 秒を記録しています。書き込む前に確認します。

./desk attempts practice
./redis HGETALL score:practice:names
./desk data practice

t01 の試行は 70 秒、60 秒、65 秒です。自己ベストは 65 ではなく 60 です。LT は、新しい値が小さい場合だけ既存のスコアを変更し、新しいメンバーも追加します。作業用キーで同じ 3 試行を比較します。

./redis ZADD score:demo:time LT 70 t01
./redis ZADD score:demo:time LT 60 t01
./redis ZADD score:demo:time LT 65 t01
./redis --raw ZSCORE score:demo:time t01

結果は 60 のままです。アトミックな更新の考え方と重複に対する安全性は GT と同じで、良いとする方向だけが変わります。

小さい値ほど良いボードでは、REV を省いて昇順を使います。境界の位置 k - 1 で、そのスコア以下の全員を含めます。順方向のスコア範囲は、下限がないことを意味する -inf から始めます。完全な独立したひな型を practice.py に保存します。関数インターフェースはメインのラウンドとまったく同じです。

cat > practice.py <<'PY'
def record(client, key, player, score):
    return client.command("ZADD", key, "LT", score, player)

def qualify(client, key, k):
    boundary = client.command("ZRANGE", key, k - 1, k - 1, "WITHSCORES")
    if not boundary:
        return client.command("ZRANGE", key, 0, -1, "WITHSCORES")
    return client.command("ZRANGE", key, "-inf", boundary[1], "BYSCORE", "WITHSCORES")
PY

練習の一覧を送信し、Redis 本来の境界と、同点を含む範囲を確認してから、実際のボード表示プログラムに結果を求めます。

./desk replay practice
./redis --raw ZRANGE score:practice:best 1 1 WITHSCORES
./redis --raw ZRANGE score:practice:best -inf 60 BYSCORE WITHSCORES
./desk board practice 2
./desk replay practice
./desk submit practice t01 65
./desk board practice 2
./desk board practice 100
./desk board main 3

境界はスコア 60 の t01 です。上位 2 位置とその同点者を含むボードには、t03=50、t01=60、t02=60、t04=60 が含まれます。同じ名前の Sam は異なる ID の人物です。遅れて届いた 65 は 60 を置き換えられず、一覧全体を再生しても自己ベストは変わりません。大きな境界値では t90=72 を含む 6 人全員が表示されます。メインの 5 人の通過結果も引き続き利用できます。

同じタイムでは、Redis は ID のバイト順の昇順に並べます。高得点を競うメインのボードは、REV によってバイト順の降順になります。どちらの慣例も、同じスコアの参加者を除外するものではありません。今回の準備済み ASCII ID では、この順序は安定しています。

これで、両方向に対応する完全な記録・通過者選出の方法ができました。両ファイル、両ラウンド、元の一覧、名簿、以前のラウンドの結果を保全してください。チェックは、変更していない関数を、別の ID、値、到着順、境界値でも試します。実際の結果を確認してから最終チェックを実行します。

まとめ

選手を安定した識別情報で区別し、Redis のアトミックな条件付き更新で各試行の自己ベストを保存し、スコアの良し悪しの方向と表示順を分けました。実際の k 番目のスコアを使い、同名の選手も含めて境界で同点の全員を通過者に含め、以前の結果を保全しました。保存した関数は、高得点を競う種目と整数秒のタイムトライアルの両方で動作します。通過者選出の読み取りは、送信受付の終了を前提とします。永続化、異議申し立て、進行中のスナップショット取得は別の作業です。