0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TypeScriptでフェンシングトークン付きリースを実装する:期限切れロックの遅延書き込みを防ぐ

0
Posted at

背景メモ: https://www.aceround.app/ja/blog/backend-developer-interview-ai

この記事は、システム設計面接でよく問われる「リースが切れたワーカーの遅延書き込みをどう防ぐか」を、最小限の TypeScript 実装に落とし込んだものです。

リースとフェンシングトークンの流れ

結論

TTL 付きのロック(リース)だけでは、期限切れした所有者の処理を止められません。

  1. ワーカー A がリースを取得する
  2. A が一時停止している間にリースが期限切れになる
  3. ワーカー B が同じキーを取得する
  4. A が遅れて書き込む

この順番では、B の正常な書き込みを A が上書きする危険があります。取得のたびに単調増加する fencing token を発行し、リソース側で「最後に受理した token より大きい書き込みだけ」を通せば、遅延した A の操作を拒否できます。

リースと token の責務を分ける

リースは「今、誰が処理を担当できるか」を短時間だけ表します。一方、fencing token は「その担当権が何世代目か」を表す番号です。

  • リースストア: ownerexpiresAt を管理する
  • リース取得: 取得成功のたびに token を増やす
  • 保護対象リソース: 直近の token を保存し、古い token を拒否する

token を単なる UUID にすると大小比較ができません。Redis の INCR やデータベースのシーケンスのように、全取得で一意かつ単調増加する値が必要です。

実装

ここでは説明を簡単にするため、リースストアと保護対象を同一プロセス内のクラスで表します。実運用では、リースストアの更新を原子的に行い、リソース側の条件付き更新まで同じ不変条件で設計してください。

type Lease = {
  owner: string;
  token: number;
  expiresAt: number;
};

class LeaseStore {
  private readonly leases = new Map<string, Lease>();
  private nextToken = 0;

  acquire(key: string, owner: string, now: number, ttlMs: number): Lease | null {
    const current = this.leases.get(key);
    if (current && current.expiresAt > now) {
      return null;
    }

    const lease: Lease = {
      owner,
      token: ++this.nextToken,
      expiresAt: now + ttlMs,
    };
    this.leases.set(key, lease);
    return lease;
  }

  renew(key: string, lease: Lease, now: number, ttlMs: number): boolean {
    const current = this.leases.get(key);
    if (
      !current ||
      current.owner !== lease.owner ||
      current.token !== lease.token ||
      current.expiresAt <= now
    ) {
      return false;
    }

    current.expiresAt = now + ttlMs;
    return true;
  }

  release(key: string, lease: Lease): boolean {
    const current = this.leases.get(key);
    if (
      !current ||
      current.owner !== lease.owner ||
      current.token !== lease.token
    ) {
      return false;
    }

    this.leases.delete(key);
    return true;
  }
}

class FencedResource {
  private lastToken = 0;
  private value = "initial";

  write(token: number, value: string): void {
    if (!Number.isSafeInteger(token) || token <= this.lastToken) {
      throw new Error(`stale fencing token: ${token}`);
    }

    this.lastToken = token;
    this.value = value;
  }

  read(): { value: string; lastToken: number } {
    return { value: this.value, lastToken: this.lastToken };
  }
}

LeaseStore は期限切れを見て次の所有者に token を渡します。重要なのは、FencedResource.write がリースの有効期限を再確認していない点です。ネットワーク遅延や停止から復帰した A は、リースストア上ではなく、書き込み先の token 比較によって拒否されます。

遅延した所有者を再現する

次のテストでは、A の token=1 が期限切れした後に B が token=2 を取得します。B の書き込み後に A が遅れて到着しても、token=1 は拒否されます。

const store = new LeaseStore();
const resource = new FencedResource();

const leaseA = store.acquire("job", "worker-a", 0, 100);
if (!leaseA) throw new Error("A should acquire the lease");

const leaseB = store.acquire("job", "worker-b", 101, 100);
if (!leaseB) throw new Error("B should acquire after expiry");
if (leaseA.token >= leaseB.token) throw new Error("token must increase");

resource.write(leaseB.token, "written-by-b");

let staleRejected = false;
try {
  resource.write(leaseA.token, "late-write-by-a");
} catch (error) {
  staleRejected = error instanceof Error && error.message.includes("stale");
}

if (!staleRejected) throw new Error("stale owner must be rejected");

console.log(resource.read());
// { value: "written-by-b", lastToken: 2 }

Bun で実行します。

bun run fencing-token.ts

失敗しやすい実装

リースの有効期限だけを信じる

A がリースを取得した直後に停止すると、A のメモリ上には「自分が所有者」という情報が残ります。再開後にその情報だけで書き込むと、B の結果を壊せます。リースの確認は必要ですが、それだけでは遅延した処理を取り消せません。

token をクライアントが生成する

クライアント時刻や UUID を token にすると、所有者間で順序を比較できません。token の発行は、競合を直列化できる単一のストアに置きます。

token 比較をアプリケーションだけで行う

「読み取り → 比較 → 書き込み」を別々の操作にすると、その間に別ワーカーが書き込めます。データベースなら、条件付き UPDATE ... WHERE fencing_token < :token のように、比較と更新を一つの原子的な操作にしてください。

期限切れした所有者の遅延書き込み

面接での説明順

  1. まず「リース切れ後に古い処理が復帰する」競合を具体例で示す
  2. リース(現在の所有権)と token(所有権の世代)を分ける
  3. 取得のたびに単調増加する token を発行する
  4. 書き込み先で token を条件にした原子的更新を行う
  5. token の保存場所、停止時の再試行、監視する期限を確認する

fencing token はロックを強くする魔法ではありません。リースストアの原子性、書き込み先の条件付き更新、そして期限切れを検知する運用監視がそろって初めて、古い所有者の書き込みを安全に止められます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?