公開日 2026-10-03

PHPで使い切りファイル共有のライフサイクルを設計する(条件付きUPDATEの状態機械とcronなしGC)

使い切りファイル共有の後始末を、active→consumed→purged の一方向の状態機械で設計する。二重ダウンロードを防ぐ条件付きUPDATEの確定、実体を消せたときだけ状態を進める自己修復GC、削除失敗行のバックオフ、cronが無い環境向けのアクセス時GCをテスト付きで示す。

目次

  1. 動作を確認した構成
  2. 状態機械 — 一方向にだけ進める
  3. 使い切りを条件付きUPDATEで確定する
  4. コードのポイント
  5. PostgreSQL実装 — 遷移条件をすべてSQLに置く
  6. コードのポイント
  7. 実体を消せたときだけ状態を進める(自己修復GC)
  8. コードのポイント
  9. 認可 → 確定 → 配信 の順に組む
  10. cronが無くても回す — アクセス時GC
  11. テストで振る舞いを固定する
  12. コードのポイント
  13. まとめ

対象読者: PHP で「1回ダウンロードしたら失効し、期限が切れたら実体も消える」ファイル共有を、素の PHP と PostgreSQL で組む方。ジョブ基盤やフレームワークのスケジューラに頼らず、状態遷移と後始末を自分で設計する場面を想定します。

有効期限付きの共有リンクを作ると、次に後始末の設計が要ります。ダウンロードが終わったら失効させ、期限切れの実体は消す。ここを素朴に書くと、2つのリクエストが同時に来て二重ダウンロードを許す、DB の状態は「消した」のにファイル実体が残る(またはその逆で実体は消えたのに状態が古いまま)、cron の無い共有ホスティングで掃除そのものが回らない、といった穴が開く。この記事では、共有の状態を active → consumed → purged の一方向にだけ進む状態機械として設計し、遷移を条件付き UPDATE で確定する使い切り、実体を消せたときだけ状態を進める自己修復 GC、cron なしのアクセス時 GC の3点をコードで示します。

削除が失敗し続けたときに掃除が止まらないようにする再試行の設計と、配信中の実体を消さないための猶予も、あわせて扱う。

この記事は PHPで有効期限付き共有リンクを設計する(URLを知られただけではダウンロードさせない) の続きで、共有トークンや本人確認の設計はそちらに前提を置きます。条件付き UPDATE で競合を捌く形は PHPでワンタイム認証キー(OTP)を自作する(HMAC + pepper + 原子的UPDATE) と同じ、期限切れ行をまとめて拾う考え方は PHP + PostgreSQLでジョブキューを作る(FOR UPDATE SKIP LOCKED 最小構成) が土台です。ファイル実体の保存の詳細(docroot 外配置)は PHPでアップロードファイルをdocroot外に保存し直リンクを塞ぐ(fail-closed設計) へ委譲します。

動作を確認した構成

コードを動かして確認したのは、次の構成です。

  • PHP 8.4(pdo_pgsql 拡張、テストは PHPUnit 12)
  • PostgreSQL 17
  • Composer プロジェクト(App\ → src/ の PSR-4 オートロード)。本番コードはフレームワーク非依存

ライフサイクルに関わる列を持つ、次のテーブルを前提にします。

CREATE TABLE shares (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    file_id bigint NOT NULL UNIQUE,
    status text NOT NULL DEFAULT 'active',
    expires_at timestamptz NOT NULL,
    consumed_at timestamptz,
    purged_at timestamptz,
    purge_attempts int NOT NULL DEFAULT 0,
    next_purge_attempt_at timestamptz,
    created_at timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT shares_status_check CHECK (status IN ('active', 'consumed', 'purged'))
);

CREATE INDEX shares_purgeable_idx
    ON shares (next_purge_attempt_at NULLS FIRST, id)
 WHERE status <> 'purged';

共有リンクのトークンや宛先の列(token_hash / recipient_email)は前提記事にあるので省き、ここではライフサイクルに関わる列だけを載せています。status は3状態で、CHECK 制約が綴り違いや想定外の値を弾く。consumed_at / purged_at は、いつ使い切られ、いつ消えたかを残す監査用の時刻です。purge_attempts / next_purge_attempt_at は、実体の削除に失敗したときの再試行を制御する列で、後半の GC の節で使います。

file_id に UNIQUE を置いているのは、この記事が「1つの共有が1つの実体を占有する」モデルを前提にしているからです。GC は共有行を拾って file_id の実体を消すので、同じ実体を複数の共有が指せると、片方の回収がもう片方の有効な共有からファイルを奪う。複数の共有で1つの実体を配りたい設計にするなら、実体のライフサイクルを共有行から切り離し、未回収の参照が0件になったときだけ消す参照管理が別途要ります。

部分インデックスの WHERE status <> 'purged' は、回収済みの行をインデックスから落とすためのものです。運用が長くなるとテーブルの大半は purged になり、掃除の対象が0件のときこそ探索が重くなる。効き方は GC の節で実測値を示します。

状態機械 — 一方向にだけ進める

共有の一生を、3つの状態と一方向の遷移で表します。

stateDiagram-v2
    [*] --> active: 共有を発行
    active --> consumed: ダウンロードを確定(使い切り)
    active --> purged: 期限切れをGCが回収
    consumed --> purged: 実体を消せたらGCが回収
    consumed --> consumed: 実体の削除に失敗(バックオフして再試行)
    purged --> [*]

状態は active(配布中)・consumed(ダウンロード済みで失効)・purged(実体を削除済み)の3つ。矢印は前へしか向かない。二重ダウンロードは active → consumed が1回しか起きないことで防ぎ、巻き戻り(purged から consumed へ戻るなど)は各遷移の条件で防ぎます。要点は、この「1回だけ」「前進だけ」を PHP 側のフラグ管理ではなく、SQL の WHERE に埋め込むこと。読み出してから書き戻すまでの隙間に別のリクエストが割り込む余地を、そもそも作らない。

consumed の自己ループは、実体の削除に失敗して状態を進められなかった回を表します。この場合は状態を動かさず、あとで同じ行をもう一度拾う。

使い切りを条件付きUPDATEで確定する

ダウンロードを1回に絞る確定処理を CompleteDownload に置きます。src/Lifecycle/CompleteDownload.php です。

<?php
declare(strict_types=1);

namespace App\Lifecycle;

final class CompleteDownload
{
    public function __construct(
        private readonly ShareLifecycleRepository $repository,
    ) {
    }

    /**
     * ダウンロードを1回だけ確定する。最初の呼び出しだけ true を返す。
     *
     * 配信の前に呼び、true のときだけファイルを返す。false は「既に使い切り済み」。
     * 呼び出し側でトランザクションを開いてはいけない(配信前にコミットを終える)。
     */
    public function claim(int $shareId): bool
    {
        return $this->repository->markConsumed($shareId);
    }
}

サービスが依存するのはインターフェースです。src/Lifecycle/ShareLifecycleRepository.php に置きます。使い切りの確定・回収対象の取得・回収の確定・回収失敗の記録の4つを持つ。

<?php
declare(strict_types=1);

namespace App\Lifecycle;

interface ShareLifecycleRepository
{
    public function markConsumed(int $shareId): bool;

    /** @return list<PurgeableShare> */
    public function selectPurgeable(int $limit): array;

    public function markPurged(int $shareId): bool;

    public function markPurgeFailed(int $shareId): void;
}

コードのポイント

① claim() が true のときだけ配信する

    public function claim(int $shareId): bool
    {
        return $this->repository->markConsumed($shareId);
    }

claim() は配信の前に呼び、true(この呼び出しが確定できた)のときだけファイルを返します。false は「すでに使い切られている」なので、配信へ進ませない。順序を入れ替えて配信を先にすると、確定に失敗したリクエストにも中身を返してしまうため、確定を先に置きます。

この順序は、確定を配信の完了確認より前に済ませるという割り切りでもあります。回線が切れてもプロセスが落ちても、共有は consumed のままで再取得はできない。保証されるのは「2回は配信しない」であって「1回は必ず受け取れる」ではありません。取り直しを許す運用にするなら、共有を再発行する導線を用意するか、受領確認を受けてから確定する二段構えにする(後者は確定漏れによる二重配信のリスクを引き受けることになる)。claim() が false のときは 410 Gone を返すと、期限切れとも認可失敗とも区別がつきます。

もう一点、claim() は autocommit の単文で完結させ、配信を始める前にコミットまで終わらせてください。リクエスト全体を1つのトランザクションで包むミドルウェアの中にこのコードを置くと、true を受け取った時点ではまだ確定していない。大きなファイルの配信中にプロセスが落ちてロールバックすれば、状態は active に戻り、同じ共有をもう一度ダウンロードできてしまいます。行ロックを配信のあいだ握り続ける点も具合が悪い。

PostgreSQL実装 — 遷移条件をすべてSQLに置く

src/Lifecycle/DbShareLifecycleRepository.php です。4つの遷移すべてを、条件付き UPDATE と1本の SELECT に閉じ込めます。

<?php
declare(strict_types=1);

namespace App\Lifecycle;

use PDO;

final class DbShareLifecycleRepository implements ShareLifecycleRepository
{
    public function __construct(
        private readonly PDO $pdo,
        private readonly int $deliveryGraceMinutes = 15,
        private readonly int $purgeRetryMinutes = 60,
    ) {
    }

    public function markConsumed(int $shareId): bool
    {
        $statement = $this->pdo->prepare(<<<'SQL'
            UPDATE shares
               SET status = 'consumed', consumed_at = now()
             WHERE id = :id
               AND status = 'active'
               AND expires_at > now()
            SQL);
        $statement->bindValue(':id', $shareId, PDO::PARAM_INT);
        $statement->execute();

        return $statement->rowCount() === 1;
    }

    /** @return list<PurgeableShare> */
    public function selectPurgeable(int $limit): array
    {
        $statement = $this->pdo->prepare(<<<'SQL'
            SELECT id, file_id
              FROM shares
             WHERE status <> 'purged'
               AND (next_purge_attempt_at IS NULL OR next_purge_attempt_at <= now())
               AND (
                     (status = 'consumed' AND consumed_at <= now() - make_interval(mins => :grace))
                     OR (status <> 'consumed' AND expires_at <= now())
                   )
             ORDER BY next_purge_attempt_at NULLS FIRST, id
             LIMIT :limit
            SQL);
        $statement->bindValue(':grace', $this->deliveryGraceMinutes, PDO::PARAM_INT);
        $statement->bindValue(':limit', $limit, PDO::PARAM_INT);
        $statement->execute();

        $shares = [];
        foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) {
            $shares[] = new PurgeableShare(id: (int) $row['id'], fileId: (int) $row['file_id']);
        }

        return $shares;
    }

    public function markPurged(int $shareId): bool
    {
        $statement = $this->pdo->prepare(<<<'SQL'
            UPDATE shares
               SET status = 'purged', purged_at = now()
             WHERE id = :id
               AND status <> 'purged'
               AND (status = 'consumed' OR expires_at <= now())
            SQL);
        $statement->bindValue(':id', $shareId, PDO::PARAM_INT);
        $statement->execute();

        return $statement->rowCount() === 1;
    }

    public function markPurgeFailed(int $shareId): void
    {
        $statement = $this->pdo->prepare(<<<'SQL'
            UPDATE shares
               SET purge_attempts = purge_attempts + 1,
                   next_purge_attempt_at =
                       now() + make_interval(mins => (:base * power(2, least(purge_attempts, 6)))::int)
             WHERE id = :id
               AND status <> 'purged'
            SQL);
        $statement->bindValue(':base', $this->purgeRetryMinutes, PDO::PARAM_INT);
        $statement->bindValue(':id', $shareId, PDO::PARAM_INT);
        $statement->execute();
    }
}

コードのポイント

① 「まだ active か」を WHERE で確かめてから状態を進める

            UPDATE shares
               SET status = 'consumed', consumed_at = now()
             WHERE id = :id
               AND status = 'active'
               AND expires_at > now()

status = 'active' と expires_at > now() を WHERE に置き、その場で consumed へ進めます。条件に合わなければ更新は0件で、rowCount() は0を返す。「読んで、PHP で判定して、別の UPDATE で書く」の3ステップに分けると、2つのリクエストが同じ active を読んで両方が先へ進む隙間ができますが、1本の条件付き UPDATE なら行ロックで直列化され、先に consumed にした方だけが勝ちます。あとから来た更新は status = 'active' に合致せず0件になる。この競合の捌き方は OTP 記事 の試行回数制限と同じ形です。

この「待たされたあとに WHERE を評価し直す」挙動は、PostgreSQL 既定の READ COMMITTED のものです。接続やフレームワークの設定で REPEATABLE READ / SERIALIZABLE に上げている場合、同じ競合は0件更新ではなく直列化失敗(ERROR: could not serialize access due to concurrent update、SQLSTATE 40001)の例外になります。その構成では例外を捕まえてトランザクションごと再試行してください。使い切り自体は破れませんが、false だけを失敗として扱う実装だと未処理の例外で 500 になる。この記事の markPurged() / markPurgeFailed() も同じ形なので、GC 側では例外がバッチ全体を落とす点にも注意が要ります。

② 回収対象は「配信の猶予を過ぎた使い切り」か「未消費の期限切れ」だけ

             WHERE status <> 'purged'
               AND (next_purge_attempt_at IS NULL OR next_purge_attempt_at <= now())
               AND (
                     (status = 'consumed' AND consumed_at <= now() - make_interval(mins => :grace))
                     OR (status <> 'consumed' AND expires_at <= now())
                   )
             ORDER BY next_purge_attempt_at NULLS FIRST, id

status <> 'purged' で回収済みを除き、使い切った行か、未消費のまま期限切れになった行を拾います。まだ配布中で期限も残る active は対象外。

consumed の側に猶予(consumed_at <= now() - :grace)を挟んでいるのは、確定と配信のあいだに GC が割り込むのを防ぐためです。claim() は配信の前に consumed を確定するので、猶予が無いと、大きなファイルを配信している最中に別のリクエストが起こした GC がその実体を消しにかかる。ファイルを開いたままなら読み切れる環境もありますが、チャンクごとに開き直す配信や X-Sendfile 系の委譲では途中で壊れます。配信にかかる最大時間より長い猶予を置いてください。

期限切れの側を status <> 'consumed' に絞っているのも、同じ割り込みを塞ぐためです。残り時間がわずかな共有を consumed にした直後は、配信が終わらないうちに expires_at を跨ぐことがある。この条件が無いと、猶予を挟んだはずの使い切り行を期限切れの分岐が拾ってしまい、第1分岐の猶予を素通りして配信中の実体を消しにかかる。期限切れで即時に回収してよいのは、まだ配信の始まっていない未消費(active)の行だけです。

next_purge_attempt_at の条件と ORDER BY は、削除に失敗した行を一定時間だけ候補から外すためのものです(次のポイント)。

③ purged にできるのは回収資格のある行だけ

            UPDATE shares
               SET status = 'purged', purged_at = now()
             WHERE id = :id
               AND status <> 'purged'
               AND (status = 'consumed' OR expires_at <= now())

markPurged() の WHERE には、前進のみを保証する status <> 'purged' に加えて、回収資格そのものを再確認する条件を置いています。ここを status <> 'purged' だけにすると、まだ配布中で期限も残る active の行をこのメソッド単体で purged に落とせてしまう。実体が残ったまま状態だけ purged になれば、selectPurgeable() が二度と拾わないので、その実体は永久に回収されません。管理画面の「この共有を無効化する」といった別経路からこのメソッドが呼ばれる余地を、SQL の側で塞いでおきます。

条件を selectPurgeable() と揃えておけば、対象を選んでから更新するまでのあいだに資格が変わった行も弾ける。

④ 削除に失敗した行はバックオフして候補から外す

               SET purge_attempts = purge_attempts + 1,
                   next_purge_attempt_at =
                       now() + make_interval(mins => (:base * power(2, least(purge_attempts, 6)))::int)
             WHERE id = :id
               AND status <> 'purged'

実体の削除に失敗した行は、失敗回数を数えて次の試行時刻を先送りします。待ち時間は purge_attempts に対して指数的に伸び、least(..., 6) で上限を打つ(基準60分なら最大で64倍)。

この列が無いと、GC は取得件数の上限に達するまで常に同じ先頭の行を拾い続けます。パーミッションの誤り、読み取り専用になったファイルシステム、他プロセスが掴んだままのファイルなど、削除が恒久的に失敗する行が id の小さい位置にあると、その行だけを毎回拾って毎回スキップし、後続には一度も到達しない。バックオフ無しでこの状況を作ると、runAccessTimeGc()(1回5件)は次のように何度回しても1件も回収できませんでした。

round1: runAccessTimeGc()=0
round2: runAccessTimeGc()=0
round3: runAccessTimeGc()=0
round4: runAccessTimeGc()=0
round5: runAccessTimeGc()=0
最終: id1=active/file=yes id2=active/file=yes ... id8=active/file=yes

削除に失敗するのは id 1〜5 の5件だけで、id 6〜8 は消せる状態でしたが、先頭の5件が枠を占有するため後続は候補に入りません。バックオフを入れると、2回目の呼び出しで後続へ進みます(実測は後述のテスト)。

purge_attempts が伸び続ける行は、運用側で気づけるようにしておくのが安全です。この記事では隔離用の状態は足していないので、値の大きい行を定期的に確認するか、markPurgeFailed() の呼び出し元でログを残してください。

実体を消せたときだけ状態を進める(自己修復GC)

回収の対象を表す小さな値オブジェクトと、ファイル実体を消す口を用意します。src/Lifecycle/PurgeableShare.php と src/Lifecycle/FileBin.php です。

<?php
declare(strict_types=1);

namespace App\Lifecycle;

final readonly class PurgeableShare
{
    public function __construct(
        public int $id,
        public int $fileId,
    ) {
    }
}
<?php
declare(strict_types=1);

namespace App\Lifecycle;

interface FileBin
{
    /**
     * ファイル実体を削除する。冪等であること(複数回呼ばれても壊れない)。
     *
     * 呼び出し後にファイルが存在しなければ true(元から無い場合も含む)。
     * 物理削除に失敗してファイルが残っているときだけ false を返す。
     */
    public function delete(int $fileId): bool;
}

ローカルファイルシステム向けの実装は src/Lifecycle/LocalFileBin.php です。

<?php
declare(strict_types=1);

namespace App\Lifecycle;

final class LocalFileBin implements FileBin
{
    public function __construct(
        private readonly string $baseDir,
    ) {
    }

    public function delete(int $fileId): bool
    {
        $path = $this->baseDir . DIRECTORY_SEPARATOR . $fileId;

        clearstatcache(true, $path);
        if (!is_file($path)) {
            return true; // 既に無い=冪等に成功扱い
        }

        if (@unlink($path)) {
            return true;
        }

        $error = error_get_last();

        // 別ワーカーが先に消した場合も unlink は false を返す。実在を確かめ直す。
        clearstatcache(true, $path);
        if (!is_file($path)) {
            return true;
        }

        error_log(sprintf('failed to delete share file %s: %s', $path, $error['message'] ?? 'unknown'));

        return false;
    }
}

回収本体は src/Lifecycle/ShareGc.php に置く。

<?php
declare(strict_types=1);

namespace App\Lifecycle;

final class ShareGc
{
    private const ACCESS_TIME_BATCH = 5;

    public function __construct(
        private readonly ShareLifecycleRepository $repository,
        private readonly FileBin $files,
    ) {
    }

    public function collect(int $limit): int
    {
        $purged = 0;

        foreach ($this->repository->selectPurgeable($limit) as $share) {
            // 実体を先に消す。消えた(元から無い場合も含む)ときだけ status を進める。
            if (!$this->files->delete($share->fileId)) {
                // 削除に失敗した行は status を動かさず、バックオフして次回以降に再試行する。
                $this->repository->markPurgeFailed($share->id);
                continue;
            }

            if ($this->repository->markPurged($share->id)) {
                $purged++;
            }
        }

        return $purged;
    }

    /** cron が無い共有ホスティング向け。リクエストのたびに少量だけ回収する。 */
    public function runAccessTimeGc(): int
    {
        return $this->collect(self::ACCESS_TIME_BATCH);
    }
}

コードのポイント

① 実体削除 → 状態更新の順番を守る

        foreach ($this->repository->selectPurgeable($limit) as $share) {
            if (!$this->files->delete($share->fileId)) {
                $this->repository->markPurgeFailed($share->id);
                continue;
            }

            if ($this->repository->markPurged($share->id)) {

先に delete() で実体を消し、成功したときだけ markPurged() で purged へ進めます。順番が逆だと、状態を purged にしたあとに削除が失敗した場合、DB 上は消えたことになっているのに実体が残る「孤児ファイル」が生まれ、二度と回収されません。削除を先に置けば、失敗した行は状態が consumed(や期限切れの active)のまま残り、再試行の時刻を過ぎたあとに同じ行をもう一度拾う。FileBin::delete() が「既に無ければ true」を返す契約なので、削除の直後にクラッシュして状態更新だけ飛んでも、次回は「実体は無い=成功」と見なして状態を先へ進められます。これが一過性の障害から自力で復帰する仕組みです。

契約の裏返しとして、実体のパスを組み立て損ねると全件が黙って成功扱いになる点には注意が要ります。baseDir の設定を誤る、実際の保存名が {fileId}.bin のように別の規則になっている、といった場合は is_file() が常に false を返し、GC のログは健全に見えるのにファイルは1バイトも消えていない。本番へ載せる前に、実体を1件置いて実際に消えることを確かめてください。

② 戻り値は「回収できた件数」

        return $purged;

collect() が返すのは markPurged() が true を返した回数です。0 は「回収対象が無かった」を意味しません。削除に失敗した行があった場合も、複数のワーカーが同じ行を取り合って更新に負けた場合も 0 になる。監視の指標に使うなら、purge_attempts の伸びとあわせて見てください。

markPurged() の条件が前進のみを保証するので、複数のワーカーが同じ行を拾っても purged にできるのは1つだけで、二重計上は起きません。ただし実体の削除そのものは重複して実行されます。ローカルファイルなら2つ目が「既に無い」で true を返すだけですが、FileBin をオブジェクトストレージや監査ログ付きの実装に差し替えるなら、削除 API が冪等であることを確かめておく必要がある。

なお、この SELECT に FOR UPDATE SKIP LOCKED を足してもワーカーの取り合いは避けられません。行ロックはトランザクションの終了で解放されるため、selectPurgeable() のような単独の SELECT(autocommit)では、メソッドから戻る頃にロックが残っていない。2つの接続から順に実行して確かめると、次のようになりました。

C-1 autocommit: workerA=[1,2,3] workerB=[1,2,3]  → 同じ行を重複取得
C-2 明示TX  : workerA=[1,2,3] workerB=[]  → 分離できた

掴み続けるには明示的なトランザクションで開いたままにする必要がありますが、その場合はファイル削除という外部 I/O のあいだトランザクションが伸びます。取り合いを本気で避けるなら、短いトランザクションで候補を「回収中」の状態へ原子的に移してからコミットし、その後で実体を消す形になる(→ SKIP LOCKED のジョブキュー記事)。この記事では、重複削除が無害であることを前提に、markPurged() の条件付き UPDATE だけで正しさを保つ構成を採っています。

③ 部分インデックスで「対象が0件のとき」を軽くする

selectPurgeable() はアクセス時 GC のたびに走ります。運用が長くなると purged の行がテーブルの大半を占めるため、インデックスが無いと回収対象を探すだけで全行を読むことになる。purged を20万行、対象外の active を5行だけ置いた状態で実行計画を比べました。

--- 部分インデックス無し ---
Limit (actual rows=0 loops=1)
  ->  Sort (actual rows=0 loops=1)
        Sort Key: next_purge_attempt_at NULLS FIRST, id
        ->  Gather (actual rows=0 loops=1)
              Workers Planned: 1
              Workers Launched: 1
              ->  Parallel Seq Scan on shares (actual rows=0 loops=2)
                    Rows Removed by Filter: 100002
Execution Time: 10.368 ms

--- 部分インデックス有り ---
Limit (actual rows=0 loops=1)
  ->  Index Scan using shares_purgeable_idx on shares (actual rows=0 loops=1)
        Rows Removed by Filter: 5
Execution Time: 0.018 ms

インデックスが無いと、掃除するものが1件も無いのに毎回20万行を読んで 10.368 ms かかる。WHERE status <> 'purged' の部分インデックスを張ると回収済みの行がインデックスから落ち、0.018 ms になりました。GC を全リクエストに相乗りさせる設計では、この差がそのままレスポンスに乗ります。

認可 → 確定 → 配信 の順に組む

claim() に渡す shareId は、認可の戻り値からだけ取ってください。前提記事の authorizeDownload() がグラントを検証して Share を返すので、その配線に確定を1行挟む形になります。

$share = $access->authorizeDownload($_GET['token'] ?? '');   // 前提記事: 認可
if (!$completeDownload->claim($share->id)) {                 // 本記事: 確定(1回に絞る)
    http_response_code(410);
    exit;
}
streamDownload($files->pathFor($share->fileId));             // 委譲先: 配信

claim($_GET['share_id']) のようにリクエスト入力から ID を取ると、自分の共有で正規にグラントを得た相手が ID を他人の共有へ差し替えられます。実体は取れないので情報は漏れませんが、他人の共有を consumed に落として使えなくする妨害が成立する。前提記事が fileId について置いている警告と同じ形です。

前提記事の設計では、グラントを得たセッションは期限まで何度でもダウンロードできました。claim() を挟むと、その挙動が1回で失効するものに変わる。

cronが無くても回す — アクセス時GC

共有ホスティングでは cron を置けないことがあります。その場合は、runAccessTimeGc() をリクエストの処理に紛れ込ませ、1回あたり数件だけ回収する。

    public function runAccessTimeGc(): int
    {
        return $this->collect(self::ACCESS_TIME_BATCH);
    }

ACCESS_TIME_BATCH(ここでは5件)で1リクエストの負担を抑え、LIMIT で切った少量だけを消す。掃除しきれなかった分は、次のリクエストが続きを拾います。まとまった掃除を回せる環境なら、同じ collect() を大きめの LIMIT で定期実行すればよく、入口が変わるだけで回収のロジックは共通です。

呼び出すときは、本来の処理を巻き込まない形にしてください。GC は best-effort であるべきで、掃除の失敗が掃除と無関係な利用者のリクエストを 500 にしてよい理由はありません。

// レスポンスを返し切ってから best-effort で掃除する
fastcgi_finish_request();   // 無い環境では ignore_user_abort() + flush() で近似する

try {
    $gc->runAccessTimeGc();
} catch (\Throwable $e) {
    error_log('access-time gc failed: ' . $e->getMessage());
}

collect() は PDOException も FileBin 実装の例外も素通しするので、捕まえるのは呼び出し側の仕事になる。レスポンス送出後に置けば、SELECT 1回と最大5件ぶんの削除・更新にかかる時間を利用者に見せずに済みます。

この方式の限界も押さえておきます。回収はアクセスに従属するので、アクセスが来ない期間は掃除が止まる。金曜に発行した共有が使い切られ、週末サイトへの訪問がゼロなら、期限切れの実体は月曜まで残ります。cron を置けない環境はトラフィックの少ないサイトであることも多く、社外秘のファイルを扱うなら「期限が切れてから実体が消えるまでの時間」に上限が無いのは弱点になる。残置時間に上限を課したいなら、この方式だけに頼らず、外形監視の定期 GET やホスティングの簡易スケジューラを併用してください。

テストで振る舞いを固定する

DB につなぐテストで、使い切りと GC の性質を固定します。tests/ShareLifecycleTest.php です。ファイル実体は、物理削除の成否を差し込めるフェイクに置き換える。

<?php
declare(strict_types=1);

namespace Tests;

use App\Lifecycle\CompleteDownload;
use App\Lifecycle\DbShareLifecycleRepository;
use App\Lifecycle\FileBin;
use App\Lifecycle\ShareGc;
use PDO;
use PHPUnit\Framework\TestCase;

/** テスト用のファイル置き場。物理削除の成否を差し込める。 */
final class FakeFileBin implements FileBin
{
    /** @var array<int, true> 実体が存在する fileId */
    private array $present = [];

    /** @var array<int, true> 削除に失敗させる fileId */
    private array $failing = [];

    public function place(int $fileId): void
    {
        $this->present[$fileId] = true;
    }

    public function failOn(int $fileId): void
    {
        $this->failing[$fileId] = true;
    }

    public function clearFailure(int $fileId): void
    {
        unset($this->failing[$fileId]);
    }

    public function exists(int $fileId): bool
    {
        return isset($this->present[$fileId]);
    }

    public function delete(int $fileId): bool
    {
        if (isset($this->failing[$fileId]) && isset($this->present[$fileId])) {
            return false; // 物理削除に失敗、実体は残る
        }

        unset($this->present[$fileId]);

        return true; // 削除成功 or 元から無い
    }
}

final class ShareLifecycleTest extends TestCase
{
    private PDO $pdo;

    private DbShareLifecycleRepository $repository;

    private CompleteDownload $completeDownload;

    private FakeFileBin $files;

    private ShareGc $gc;

    protected function setUp(): void
    {
        $this->pdo = new PDO(
            (string) getenv('DB_DSN'),
            (string) getenv('DB_USER'),
            (string) getenv('DB_PASSWORD'),
            [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION],
        );
        $this->pdo->exec('TRUNCATE shares RESTART IDENTITY');

        // 既定のテストでは猶予・バックオフを 0 にして、遷移そのものを見る。
        $this->repository = new DbShareLifecycleRepository($this->pdo, deliveryGraceMinutes: 0, purgeRetryMinutes: 0);
        $this->completeDownload = new CompleteDownload($this->repository);
        $this->files = new FakeFileBin();
        $this->gc = new ShareGc($this->repository, $this->files);
    }

    public function testFirstDownloadIsClaimedAndSecondIsRefused(): void
    {
        $id = $this->insertShare(fileId: 10, ttlMinutes: 60);

        self::assertTrue($this->completeDownload->claim($id));
        self::assertFalse($this->completeDownload->claim($id));
        self::assertSame('consumed', $this->fetchStatus($id));
    }

    public function testExpiredShareCannotBeClaimed(): void
    {
        $id = $this->insertShare(fileId: 10, ttlMinutes: -1);

        self::assertFalse($this->completeDownload->claim($id));
        self::assertSame('active', $this->fetchStatus($id));
    }

    public function testGcPurgesConsumedShare(): void
    {
        $id = $this->insertShare(fileId: 10, ttlMinutes: 60);
        $this->files->place(10);
        $this->completeDownload->claim($id);

        $purged = $this->gc->collect(limit: 10);

        self::assertSame(1, $purged);
        self::assertFalse($this->files->exists(10));
        self::assertSame('purged', $this->fetchStatus($id));
    }

    public function testGcPurgesExpiredShareThatWasNeverDownloaded(): void
    {
        $id = $this->insertShare(fileId: 20, ttlMinutes: -1);
        $this->files->place(20);

        $purged = $this->gc->collect(limit: 10);

        self::assertSame(1, $purged);
        self::assertFalse($this->files->exists(20));
        self::assertSame('purged', $this->fetchStatus($id));
    }

    public function testGcLeavesActiveNonExpiredShareUntouched(): void
    {
        $id = $this->insertShare(fileId: 30, ttlMinutes: 60);
        $this->files->place(30);

        $purged = $this->gc->collect(limit: 10);

        self::assertSame(0, $purged);
        self::assertTrue($this->files->exists(30));
        self::assertSame('active', $this->fetchStatus($id));
    }

    public function testConsumedShareIsProtectedDuringDeliveryGrace(): void
    {
        $id = $this->insertShare(fileId: 35, ttlMinutes: 60);
        $this->files->place(35);
        $this->completeDownload->claim($id);

        // 猶予 15 分の GC は、確定直後の行を回収しない(配信中に実体を消さない)。
        $withGrace = new DbShareLifecycleRepository($this->pdo, deliveryGraceMinutes: 15, purgeRetryMinutes: 0);
        self::assertSame(0, (new ShareGc($withGrace, $this->files))->collect(limit: 10));
        self::assertTrue($this->files->exists(35));
        self::assertSame('consumed', $this->fetchStatus($id));

        // 猶予を過ぎれば回収される。
        self::assertSame(1, $this->gc->collect(limit: 10));
        self::assertSame('purged', $this->fetchStatus($id));
    }

    public function testConsumedShareNearExpiryIsNotPurgedByExpiryBranchDuringGrace(): void
    {
        // 残り時間がわずかな時点で使い切り、配信が終わらないうちに期限を跨いだ行。
        $id = $this->insertShare(fileId: 36, ttlMinutes: 60);
        $this->files->place(36);
        $this->completeDownload->claim($id);
        // 確定直後(consumed_at = now、猶予内)に、期限だけ過ぎた状態を作る。
        $this->pdo->exec("UPDATE shares SET expires_at = now() - interval '1 minute' WHERE id = {$id}");

        // 猶予 15 分の GC は、期限切れの分岐でこの行を拾ってはならない(配信中の実体を消さない)。
        $withGrace = new ShareGc(
            new DbShareLifecycleRepository($this->pdo, deliveryGraceMinutes: 15, purgeRetryMinutes: 0),
            $this->files,
        );
        self::assertSame(0, $withGrace->collect(limit: 10));
        self::assertTrue($this->files->exists(36));
        self::assertSame('consumed', $this->fetchStatus($id));

        // 猶予を過ぎれば、consumed の分岐で回収される。
        $this->pdo->exec("UPDATE shares SET consumed_at = now() - interval '16 minutes' WHERE id = {$id}");
        self::assertSame(1, $withGrace->collect(limit: 10));
        self::assertFalse($this->files->exists(36));
        self::assertSame('purged', $this->fetchStatus($id));
    }

    public function testGcSelfHealsWhenDeleteFails(): void
    {
        $id = $this->insertShare(fileId: 40, ttlMinutes: 60);
        $this->files->place(40);
        $this->files->failOn(40);
        $this->completeDownload->claim($id);

        // 削除失敗の回は status を進めない(実体が残るので purged にしない)。
        self::assertSame(0, $this->gc->collect(limit: 10));
        self::assertSame('consumed', $this->fetchStatus($id));
        self::assertTrue($this->files->exists(40));
        self::assertSame(1, $this->fetchPurgeAttempts($id));

        // 障害が直れば、次回の GC が同じ行を回収する(自己修復)。
        $this->files->clearFailure(40);
        self::assertSame(1, $this->gc->collect(limit: 10));
        self::assertSame('purged', $this->fetchStatus($id));
    }

    public function testFailingRowDoesNotBlockFollowingRows(): void
    {
        // id 1..5 は削除が恒久的に失敗し、id 6..8 は消せる。すべて期限切れ。
        $ids = [];
        for ($fileId = 1; $fileId <= 8; $fileId++) {
            $ids[$fileId] = $this->insertShare(fileId: $fileId, ttlMinutes: -1);
            $this->files->place($fileId);
            if ($fileId <= 5) {
                $this->files->failOn($fileId);
            }
        }

        $withBackoff = new ShareGc(
            new DbShareLifecycleRepository($this->pdo, deliveryGraceMinutes: 0, purgeRetryMinutes: 60),
            $this->files,
        );

        // 1回目は先頭5件を掴んで全部失敗する。
        self::assertSame(0, $withBackoff->runAccessTimeGc());

        // 失敗行はバックオフで候補から外れるため、2回目は後続の健全な行へ進む。
        self::assertSame(3, $withBackoff->runAccessTimeGc());
        self::assertSame('purged', $this->fetchStatus($ids[6]));
        self::assertSame('purged', $this->fetchStatus($ids[8]));
        self::assertSame('active', $this->fetchStatus($ids[1]));
    }

    public function testFailedPurgeIsRetriedAfterBackoffElapses(): void
    {
        $id = $this->insertShare(fileId: 45, ttlMinutes: -1);
        $this->files->place(45);
        $this->files->failOn(45);

        $withBackoff = new ShareGc(
            new DbShareLifecycleRepository($this->pdo, deliveryGraceMinutes: 0, purgeRetryMinutes: 60),
            $this->files,
        );
        self::assertSame(0, $withBackoff->collect(limit: 10));

        // バックオフ中は再取得されない。
        $this->files->clearFailure(45);
        self::assertSame(0, $withBackoff->collect(limit: 10));
        self::assertSame('active', $this->fetchStatus($id));

        // 再試行時刻を過ぎれば、また候補に戻る。
        $this->pdo->exec("UPDATE shares SET next_purge_attempt_at = now() - interval '1 minute' WHERE id = {$id}");
        self::assertSame(1, $withBackoff->collect(limit: 10));
        self::assertSame('purged', $this->fetchStatus($id));
    }

    public function testGcAdvancesWhenFileWasAlreadyAbsent(): void
    {
        // 実体を place しない = 直前のクラッシュ等で既に消えている状況。
        $id = $this->insertShare(fileId: 50, ttlMinutes: 60);
        $this->completeDownload->claim($id);

        $purged = $this->gc->collect(limit: 10);

        self::assertSame(1, $purged);
        self::assertSame('purged', $this->fetchStatus($id));
    }

    public function testActiveShareCannotBePurgedDirectly(): void
    {
        // 配布中で期限も残る行は、markPurged() を直接呼んでも進まない(孤児ファイルを作らせない)。
        $id = $this->insertShare(fileId: 55, ttlMinutes: 60);

        self::assertFalse($this->repository->markPurged($id));
        self::assertSame('active', $this->fetchStatus($id));
    }

    public function testPurgedShareIsForwardOnly(): void
    {
        $id = $this->insertShare(fileId: 60, ttlMinutes: 60);
        $this->files->place(60);
        $this->completeDownload->claim($id);
        $this->gc->collect(limit: 10);

        // 一度 purged にした行は、もう回収対象にも巻き戻しの対象にもならない。
        self::assertSame(0, $this->gc->collect(limit: 10));
        self::assertFalse($this->repository->markPurged($id));
        self::assertFalse($this->completeDownload->claim($id));
    }

    public function testAccessTimeGcIsBounded(): void
    {
        for ($fileId = 100; $fileId < 108; $fileId++) {
            $this->insertShare(fileId: $fileId, ttlMinutes: -1);
            $this->files->place($fileId);
        }

        // アクセス時 GC は1回あたり少量(5件)だけ回収する。
        self::assertSame(5, $this->gc->runAccessTimeGc());
        self::assertSame(3, $this->gc->runAccessTimeGc());
        self::assertSame(0, $this->gc->runAccessTimeGc());
    }

    private function insertShare(int $fileId, int $ttlMinutes): int
    {
        $statement = $this->pdo->prepare(<<<'SQL'
            INSERT INTO shares (file_id, expires_at)
            VALUES (:file_id, now() + make_interval(mins => :ttl))
            RETURNING id
            SQL);
        $statement->bindValue(':file_id', $fileId, PDO::PARAM_INT);
        $statement->bindValue(':ttl', $ttlMinutes, PDO::PARAM_INT);
        $statement->execute();

        return (int) $statement->fetchColumn();
    }

    private function fetchStatus(int $shareId): string
    {
        $statement = $this->pdo->prepare('SELECT status FROM shares WHERE id = :id');
        $statement->execute(['id' => $shareId]);

        return (string) $statement->fetchColumn();
    }

    private function fetchPurgeAttempts(int $shareId): int
    {
        $statement = $this->pdo->prepare('SELECT purge_attempts FROM shares WHERE id = :id');
        $statement->execute(['id' => $shareId]);

        return (int) $statement->fetchColumn();
    }
}

コードのポイント

① 削除に失敗した行が後続を塞がないことを固定する

        // 1回目は先頭5件を掴んで全部失敗する。
        self::assertSame(0, $withBackoff->runAccessTimeGc());

        // 失敗行はバックオフで候補から外れるため、2回目は後続の健全な行へ進む。
        self::assertSame(3, $withBackoff->runAccessTimeGc());
        self::assertSame('purged', $this->fetchStatus($ids[6]));

testFailingRowDoesNotBlockFollowingRows は、先頭5件の削除が恒久的に失敗する状況で、後続の健全な3件が回収されることを押さえます。バックオフを外すとこのテストは落ちる(2回目も同じ5件を拾って0件のまま)ので、head-of-line blocking の回帰テストとして機能します。testFailedPurgeIsRetriedAfterBackoffElapses のほうは、再試行時刻を過ぎれば失敗行も候補に戻ることを固定している。

② 削除失敗のときに status が進まないことを固定する

        // 削除失敗の回は status を進めない(実体が残るので purged にしない)。
        self::assertSame(0, $this->gc->collect(limit: 10));
        self::assertSame('consumed', $this->fetchStatus($id));
        self::assertTrue($this->files->exists(40));
        self::assertSame(1, $this->fetchPurgeAttempts($id));

物理削除が失敗している間は purged へ進めず、実体が残ったままであることと、失敗回数が数えられていることを確かめます。testGcAdvancesWhenFileWasAlreadyAbsent は逆に、実体が既に無い(削除の直後にクラッシュした想定)ときは冪等に成功扱いで purged まで進むこと、testActiveShareCannotBePurgedDirectly は配布中の行を markPurged() で落とせないこと、testConsumedShareIsProtectedDuringDeliveryGrace は確定直後の行が猶予のあいだ回収されないことを固定しています。testConsumedShareNearExpiryIsNotPurgedByExpiryBranchDuringGrace は、残り時間がわずかな時点で確定して期限を跨いだ行が、期限切れの分岐から猶予を素通りして消されないことを押さえます。回収資格の条件を selectPurgeable() の第2分岐から status <> 'consumed' を外すと、このテストが落ちます。

実行結果は次のとおりです。

PHPUnit 12.5.31 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.4.23
Configuration: /app/phpunit.xml

..............                                                    14 / 14 (100%)

Time: 00:00.3xx, Memory: 14.00 MB

OK (14 tests, 51 assertions)

並行した2リクエストで使い切りが破れないことは、この一式では逐次呼び出しでしか確かめていません。条件付き UPDATE が行ロックで直列化される機構そのものは OTP 記事 で2セッションを使って実測しているので、根拠はそちらを参照してください。

状態が active → consumed → purged と一方向に進み、purged で実体が消えることは、LocalFileBin を挿した小さなデモでも確かめた。

issued : status=active / file_exists=yes
claim#1: returned=true / status=consumed
claim#2: returned=false / status=consumed
gc     : purged=1 / status=purged / file_exists=no

まとめ

使い切りファイル共有の後始末を、状態機械と条件付き UPDATE で組む判断を、コードで示しました。

  • 共有の一生を active → consumed → purged の一方向の状態機械にし、遷移の可否は PHP のフラグではなく SQL の WHERE に埋め込む。markPurged() にも回収資格を書いておくと、別経路から孤児ファイルを作られない
  • ダウンロードの確定は条件付き UPDATE で1回に絞る。claim() が true のときだけ配信し、二重リクエストは行ロックで直列化する。確定は配信の前にコミットまで終える
  • GC は実体を消せたときだけ状態を進める。削除を先に置けば、実体が既に無いときは冪等に前進し、孤児ファイルを残さない
  • 削除が恒久的に失敗する行は再試行時刻でバックオフさせる。これが無いと、先頭の失敗行が枠を占有して GC 全体が止まる
  • cron が無い環境では runAccessTimeGc() でリクエストごとに少量ずつ回収する。ただし回収はアクセスに従属するので、残置時間に上限を課したい用途では別の経路を併用する

条件付き UPDATE で状態を安全に進める考え方は、ジョブの回収や在庫引当でも同じ形で使える。回収漏れや二重処理を状態設計で防ぐ話は PostgreSQLのstuck job回収設計(timeout / reclaim / 再実行の安全な流れ) にもつながります。