0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

非同期ジョブの停止・タイムアウト・失敗が重なっても、利用回数を1回だけ返す——PostgREST の条件付き更新を「早い者勝ちの確定」に使う

0
Posted at

結論

利用回数(クォータ)を先に予約してから非同期ジョブを走らせる構成では、「失敗したら回数を返す」処理が複数の場所に散らばります。生成の失敗、ユーザーによる停止、タイムアウト判定。これらが同時に起きると、回数を二重に返したり、返し忘れたりします。

対策として効いたのは、次の1つのルールです。

ジョブの状態を「処理中 → 失敗」に変えることに成功した呼び出しだけが、回数を返す。

状態の変更は、PostgREST(Supabase の REST API)の条件付き PATCH で行います。

PATCH /rest/v1/generation_jobs?id=eq.<jobId>&status=eq.processing
Prefer: return=representation

status=eq.processing を条件に入れておくと、同時に何人が呼んでも、実際に行を書き換えられるのは最初の1人だけです。更新された行が返ってきたかどうか(rows.length > 0)で、自分が「確定した側」かが分かります。返却はその側だけが行います。

あわせて、成功時の「完了」書き込みにも同じ条件を付ける必要がありました。付けないと、停止して回数を返したジョブが、後から「完了」に上書きされます。

この記事では、個人開発の Web サービスで実際に起きていた返却漏れと二重返却を、このルールで直した経緯を書きます。


前提の構成

題材は、LLM で文書を生成する Next.js(App Router)+ Supabase のアプリです。生成に数十秒かかるため、非同期ジョブにしています。

POST /api/generate
  1. 利用回数を予約する(RPC で「上限未満なら +1」を 1 トランザクションで)
  2. generation_jobs に status='processing' の行を作る
  3. 自分の /api/generate/process を fetch で呼び出す(待たない)
  4. 202 を返す

POST /api/generate/process   … LLM を呼んで結果を保存し、ジョブを completed にする
GET  /api/generate/jobs/:id  … クライアントが 1.5 秒ごとに状態を見る。15 分経っても processing なら打ち切る
DELETE /api/generate/jobs/:id … ユーザーが停止する

回数はリクエストの時点で先に減らします。後で減らす方式だと、同時に複数のリクエストが来たときに上限を超えてしまうためです。そのかわり、「生成しなかったのに回数だけ減った」状態を作らないよう、失敗した経路では必ず返す必要があります。

DB にはサーバーから service_role で PostgREST を叩いてアクセスしています。


何が起きていたか

コードを読み直すと、返却まわりに次の問題がありました。

1. 停止とタイムアウトが「先に返して、後で失敗にする」順番だった

// 修正前(停止 API)
const rolledBack = await rollbackReservedQuota(userId, period, periodStart); // 先に返す
await failGenerationJobIfProcessing({ jobId, userId });                     // 後で失敗にする

この順番だと、停止のちょうどその時に生成側も失敗して返却処理に入っていた場合、両方が返却します。返却 RPC は「回数を 1 減らす」だけなので、1 回しか予約していないのに 2 回分戻ります。

2. 予約した後の途中失敗で、返却されない経路があった

  • 同時実行数の上限で止めたとき(予約の後に判定していたのに、返却していなかった)
  • ジョブの行を作るのに失敗したとき(null が返っても、そのまま 202 を返していた)
  • /api/generate/process の呼び出し自体に失敗したとき(ログに出すだけだった)

どれも「生成は始まっていないのに、回数だけ減っている」状態になります。無料プランは 1 日 1 回なので、ユーザーから見るとその日はもう使えなくなります。

3. 成功時の「完了」書き込みが無条件だった

これは修正の途中で、コードレビューで指摘されて気づきました。

// 修正前(生成成功時)
await saveGeneration(...);            // 結果を保存(数秒かかる)
await completeGenerationJob({         // 無条件で completed に
  jobId, status: 'completed', ...
});

保存の数秒の間にユーザーが停止すると、停止側が「失敗」に変えて回数を返します。その直後に生成側が無条件で completed を書き込むので、ジョブは完了になり、回数も戻っている状態になります。


ルール:状態の変更に成功した側だけが、副作用を起こす

返却が二重になるのも、完了が上書きされるのも、根っこは同じです。「誰がこのジョブの結末を決めるのか」が決まっていないことでした。

そこで、ジョブの結末(失敗 or 完了)を決める操作を、すべて「status = processing のときだけ成功する更新」にしました。

  • 最初に更新できた呼び出しが、そのジョブの結末を決める
  • 回数の返却は「失敗」を確定できた呼び出しだけが行う
  • 後から来た呼び出しは、更新できなかった(0 行)ことを見て、何もしない

条件付き PATCH で「確定」する

PostgREST では、更新の条件を URL のクエリで書きます。status=eq.processing を足すだけで、「処理中のときだけ書き換える」更新になります。Prefer: return=representation を付けると更新された行が返るので、0 行なら負けです。

export async function failGenerationJobIfProcessing(args: {
  jobId: string;
  userId: string;
  failureReason: string;
}): Promise<boolean> {
  const res = await fetch(
    `${supabaseUrl}/rest/v1/generation_jobs` +
      `?id=eq.${encodeURIComponent(args.jobId)}` +
      `&user_id=eq.${encodeURIComponent(args.userId)}` +
      `&status=eq.processing`, // ← この条件が「早い者勝ち」を作る
    {
      method: 'PATCH',
      headers: dbHeaders({ Prefer: 'return=representation' }),
      body: JSON.stringify({
        status: 'failed',
        failure_reason: args.failureReason,
        completed_at: new Date().toISOString(),
      }),
    }
  );
  if (!res.ok) return false;
  const rows = (await res.json()) as unknown[];
  return rows.length > 0; // 自分が書き換えたときだけ true
}

PostgreSQL の UPDATE ... WHERE status = 'processing' は行ロックを取って条件を評価し直すので、同時に 2 つの更新が来ても、条件を満たして書き換えられるのは片方だけです。ロックを自分で取る必要も、別のロック用テーブルを作る必要もありません。

返却は「確定できた側」だけ

返却の手順は 1 つの関数にまとめ、停止・タイムアウト・生成失敗・呼び出し失敗のすべてがここを通るようにしました。DB 操作は引数で受け取り、手順だけを純粋な関数にしています(テストのため。後述)。

export type FailJobDeps = {
  /** status=processing のときだけ failed に変える。変えられたら true */
  claimFailed: () => Promise<boolean>;
  rollbackQuota: () => Promise<boolean>;
  markQuotaRolledBack: (rolledBack: boolean) => Promise<void>;
};

export async function failJobAndRefund(
  deps: FailJobDeps,
  quotaReserved: boolean,
): Promise<{ claimed: boolean; quotaRolledBack: boolean }> {
  let claimed = false;
  try {
    claimed = await deps.claimFailed();
  } catch {
    claimed = false;
  }
  if (!claimed) return { claimed: false, quotaRolledBack: false }; // 負けたら何もしない
  if (!quotaReserved) return { claimed: true, quotaRolledBack: false };

  let rolledBack = false;
  try {
    rolledBack = await deps.rollbackQuota();
  } catch {
    rolledBack = false;
  }
  try {
    await deps.markQuotaRolledBack(rolledBack);
  } catch {
    // 記録に失敗しても返却そのものは済んでいる
  }
  return { claimed: true, quotaRolledBack: rolledBack };
}

順番が修正前と逆になっている点が大事です。先に確定してから返す。確定できなければ、返さない。

「完了」も同じ条件で書く

成功時の書き込みにも status=eq.processing を付け、更新できなかったら成功として扱わないようにしました。

const completed = await completeGenerationJob({
  jobId,
  status: 'completed',
  output: result,
  onlyIfProcessing: true, // 内部で &status=eq.processing と return=representation を付ける
});
if (!completed) {
  // 保存中に停止・タイムアウトで失敗が確定していた。失敗(と回数の返却)を優先する
  console.warn(`[generate] job ${jobId} was no longer processing at completion; kept as failed`);
  return;
}

このとき、すでに保存した生成結果をどうするかは判断が分かれます。私は消さずに残すことにしました。ユーザーは回数を消費せずに結果を得ることになりますが、損をする方向ではありませんし、消す処理を足すと、その処理の失敗まで考える必要が出てくるためです。


予約の段階での返し忘れも、手順を 1 か所にまとめる

予約からジョブ作成までの途中で止まる経路は、「予約 → 同時実行の判定 → ジョブ作成」の手順を 1 つの関数にまとめ、予約した後に止めるときは必ず返す形にしました。

export async function reserveGenerationSlot(deps: ReserveSlotDeps): Promise<ReserveSlotOutcome> {
  const plan = await deps.loadPlan();
  if (plan.status === 'error') {
    // プランが分からないときは無料プランとして数えない(有料ユーザーを誤って制限しない)
    return { kind: 'plan_unavailable', reason: plan.reason };
  }

  const quota = await deps.reserveQuota(plan);
  if (quota === 'db_error') return { kind: 'quota_db_error' };
  if (quota === 'exceeded') return { kind: 'quota_exceeded' /* ... */ };

  // ここから先で止めるときは、予約した分を返す
  const refund = () => deps.rollbackQuota(plan.period, plan.periodStart).catch(() => false);

  if ((await deps.countActiveJobs()) >= deps.maxConcurrentJobs(plan.plan)) {
    return { kind: 'concurrency_limited', quotaRolledBack: await refund() };
  }

  const jobId = await deps.startJob(true).catch(() => null);
  if (!jobId) {
    return { kind: 'job_start_failed', quotaRolledBack: await refund() };
  }

  return { kind: 'reserved', jobId /* ... */ };
}

ジョブの行ができた後の失敗(/process の呼び出し失敗など)は、前の節の failJobAndRefund を通します。ジョブの行がある以上、「確定してから返す」が使えるからです。

ついでに、同時実行の上限で止めたときのステータスを 429 から 409 に変えました。クライアントが 429 を「回数の上限」と解釈して、アップグレードの案内を出していたためです。

最終的に、返却の経路は次のように整理されました。

経路 返却するのは
同時実行の上限 予約の手順(ジョブの行が無いので確定は不要)
ジョブの行の作成に失敗 予約の手順
/process の呼び出しに失敗・2xx 以外 failJobAndRefund(確定できた場合のみ)
LLM の呼び出しに失敗 failJobAndRefund
ユーザーの停止 failJobAndRefund
15 分経っても終わらない failJobAndRefund

テスト:同時に呼んでも返却が 1 回か

DB 操作を引数で受け取る形にしたので、手順そのものは DB なしでテストできます。node --test で、2 つの呼び出しを同時に走らせても返却が 1 回であることを確かめています。

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { failJobAndRefund, reserveGenerationSlot } from '../lib/generation-slot.ts';

test('停止と失敗が同時に起きても、返却は 1 回だけ', async () => {
  let claimedOnce = false;
  let rollbacks = 0;
  const deps = {
    // 条件付き PATCH の振る舞いを真似る: 最初の 1 回だけ成功する
    claimFailed: async () => {
      if (claimedOnce) return false;
      claimedOnce = true;
      return true;
    },
    rollbackQuota: async () => {
      rollbacks += 1;
      return true;
    },
    markQuotaRolledBack: async () => {},
  };

  const [a, b] = await Promise.all([
    failJobAndRefund(deps, true),
    failJobAndRefund(deps, true),
  ]);

  assert.equal(rollbacks, 1);
  assert.equal([a, b].filter((o) => o.claimed).length, 1);
});

test('同時実行の上限で止めたら、予約した回数を 1 回だけ返す', async () => {
  const rollbacks = [];
  const outcome = await reserveGenerationSlot({
    loadPlan: async () => ({ status: 'ok', plan: 'free', limit: 1, period: 'daily', periodStart: '2026-10-02' }),
    reserveQuota: async () => 'allowed',
    countActiveJobs: async () => 2,
    maxConcurrentJobs: () => 2,
    startJob: async () => 'job-1',
    rollbackQuota: async (period, start) => { rollbacks.push(`${period}:${start}`); return true; },
  });
  assert.equal(outcome.kind, 'concurrency_limited');
  assert.deepEqual(rollbacks, ['daily:2026-10-02']);
});

ここでテストしているのは「確定できた側だけが返す」という手順です。条件付き PATCH が本当に 1 人しか通さないことは PostgreSQL の更新の性質に任せていて、テストでは「最初の 1 回だけ true を返す」偽物で代わりにしています。


まだ残っている限界

この方式で二重返却は無くなりましたが、すべてが解決したわけではありません。

  • 返却の RPC 自体が失敗したら、返らない。 失敗は記録しますが、自動の再試行はしていません
  • 15 分のタイムアウト判定は、クライアントが状態を見に来たときにしか走らない。 /process が応答なしで落ちて、ユーザーも画面を閉じていた場合、次に開くまで回数が返りません。定期的に古いジョブを片付ける仕組みがあれば解決しますが、今はありません
  • 確定と返却は、1 つのトランザクションではない。 「失敗に変えた直後にサーバーが落ちた」場合、失敗にはなったが返却されていない状態が残ります。ジョブに quota_rolled_back を記録しているので、後から SQL で拾うことはできます。完全に防ぐなら、状態の変更と返却を 1 つの RPC(PostgreSQL の関数)にまとめるのが確実です

まとめ

  • 先に回数を予約する構成では、「失敗したら返す」処理が複数の場所に散らばり、同時に起きると二重返却や返し忘れになる
  • ジョブの結末を決める更新を、すべて status=eq.processing 付きの条件付き更新にする。 更新できた 1 人だけが副作用(返却)を起こす
  • 順番は**「確定してから返す」**。修正前の「返してから確定する」は、同時に起きると両方が返す
  • 成功の「完了」書き込みにも同じ条件を付ける。 付けないと、停止して返却したジョブが完了に上書きされる
  • 手順は DB 操作を引数で受け取る純粋な関数にまとめると、「同時に呼んでも 1 回」を DB なしでテストできる

PostgREST の条件付き更新は、ロック用のテーブルや分散ロックを用意しなくても「早い者勝ちで 1 人だけ通す」を作れるので、Supabase で非同期処理を組むときにはかなり使える道具だと思います。


題材にしたのは、曖昧な開発依頼から要件整理の下書きを作る Web サービス「ReqLite」です。
https://reqlite.indexaxial.com/?utm_source=qiita&utm_campaign=qiita-03

0
1
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
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?