音声投稿機能 設計書(初版)
実装後の現状はpipeline・works-and-uploadsを参照。本書は実装前に書かれた初版設計書で、当時の検討過程の記録として残す。
Worker側の実変換処理(Opusエンコードの実装そのもの)は対象外とし、ジョブキュー連携のインターフェースまでを設計範囲とする。
全体の流れ
音声投稿は、下書きの作成、ファイルのアップロード、メタデータの編集、公開という四つの段階からなる。
各段階の主体と、それに伴うデータの変化を時系列で示す。
- クライアントが
POST /worksを呼び、空の下書き(works行、status = draft)を作成する。レスポンスでwork_idを受け取る - クライアントは
work_idをUpload-Metadataに含めてTUSアップロードを開始する - tusdの
PreCreateフックがwork_idを検証し、uploads行を作成してworks行と紐づける - アップロードが進行している間、クライアントは
PATCH /works/:idでタイトルなどのメタデータを随時上書きする - TUSアップロードが完了すると、Backendが同一トランザクション内で
uploads.statusを更新し、変換ジョブをRiverのキューにinsertする - Workerがジョブを取り出し、変換結果を自分のDB接続で
uploadsとworksに直接書き込む - クライアントが
POST /works/:id/publishを呼ぶと、変換が完了済みなら即座にpublishedになり、未完了ならpublish_pendingで待機し、Worker完了時に自動でpublishedへ進む
データモデル
works
投稿そのものを表すテーブルである。
物理ファイルの変換パイプラインを表すuploadsテーブルとはライフサイクルが異なるため、別テーブルとして分離する。
| 列名 | 型 | 説明 |
|---|---|---|
| id | uuid | 主キー。gen_random_uuid()で発行する |
| user_id | text | 投稿者のFirebase UID |
| title | text | タイトル。公開の必須項目 |
| description | text | 説明文。任意 |
| thumbnail_key | text | Cocot Storage上のサムネイル画像キー。未設定時はクライアント側でデフォルト画像を表示する |
| status | work_status | draft / publish_pending / published / deleted |
| upload_id | text | uploads.idへの外部キー。下書き作成直後はNULLで、TUSアップロード開始時に設定する |
| published_at | timestamptz | 公開日時。公開前はNULL |
| created_at | timestamptz | 作成日時 |
| updated_at | timestamptz | 更新日時 |
CREATE TYPE work_status AS ENUM ( 'draft', -- メタデータ入力中、アップロード未完了 'publish_pending', -- ユーザーが公開操作を行ったが、変換が未完了 'published', -- 公開済み 'deleted' -- 投稿者が削除済み);
CREATE TABLE works ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id text NOT NULL, title text NOT NULL DEFAULT '', description text, thumbnail_key text, status work_status NOT NULL DEFAULT 'draft', upload_id text REFERENCES uploads(id), published_at timestamptz, created_at timestamptz NOT NULL DEFAULT NOW(), updated_at timestamptz NOT NULL DEFAULT NOW());
CREATE INDEX idx_works_user_id ON works(user_id);CREATE INDEX idx_works_status ON works(status);CREATE INDEX idx_works_upload_id ON works(upload_id);titleをNOT NULLかつデフォルト空文字にしているのは、下書き作成時点ではタイトルが未入力であることを許し、公開時のバリデーションでのみ必須にするためである。
uploads(既存テーブルへの追加)
既存のuploadsテーブルに、対応するworks行を辿るための列を追加する。
ALTER TABLE uploads ADD COLUMN work_id uuid REFERENCES works(id);CREATE INDEX idx_uploads_work_id ON uploads(work_id);works.upload_idとuploads.work_idの双方向参照になる点に注意する。
works.upload_idはアプリケーションが「この投稿の現在のアップロードはどれか」を参照する経路であり、uploads.work_idはWorkerが変換完了時に「このアップロードはどの投稿に属するか」を参照する経路である。
用途が異なるため、どちらか一方を省略せず両方を維持する。
tags / work_tags
タグは正規化し、worksとの対応を中間テーブルで持つ。
CREATE TABLE tags ( id bigserial PRIMARY KEY, name text NOT NULL UNIQUE);
CREATE TABLE work_tags ( work_id uuid NOT NULL REFERENCES works(id) ON DELETE CASCADE, tag_id bigint NOT NULL REFERENCES tags(id), PRIMARY KEY (work_id, tag_id));
CREATE INDEX idx_work_tags_tag_id ON work_tags(tag_id);タグ名の正規化(大文字小文字や表記揺れの統一)についてはまだ検討していない。未確定事項として後述する。
API設計
エンドポイントはすべて/api配下に置き、Firebase認証ミドルウェアを通す。
POST /works
下書きを作成する。
リクエストボディは不要とする(タイトル等は空で作成し、直後のPATCHで埋める運用にする)。
レスポンスでwork_idと初期状態(status = draft)を返す。
PATCH /works/:id
タイトル、説明、タグを更新する。
リクエストボディに含まれるフィールドだけを更新する部分更新とする。
statusがpublishedまたはdeletedの投稿に対する更新は拒否する。
タグの更新は、送信された配列でwork_tagsの中間テーブルを丸ごと置き換える(差分計算はしない)。
PUT /works/:id/thumbnail
サムネイル画像をバイナリで直接受け取り、Cocot Storageに保存してthumbnail_keyを更新する。
POST /works/:id/publish
公開を試みる。
titleが空の場合はエラーを返す。
対応するuploads.statusがdoneであれば、works.statusをpublishedにし、published_atを記録する。
doneでなければworks.statusをpublish_pendingにし、Worker側の完了処理を待つ。
DELETE /works/:id
投稿を削除する。
物理データを即座に消去せず、works.statusをdeletedにする論理削除とする。
Cocot Storage上のファイル自体を物理削除するかどうかは、未確定事項として後述する。
GET /works/:id, GET /works(一覧・検索)
投稿の詳細取得、およびキーワード・タグによる一覧取得を提供する。
status = publishedの投稿のみを対象とする(下書きや削除済みは本人以外に見せない)。
TUSアップロードとの連携
PreCreateフック
クライアントは、TUSアップロード開始リクエストのUpload-Metadataにwork_idを含める。
サーバーはPreCreateフックで次を検証する。
work_idが存在し、リクエストの認証ユーザーが所有していること- 対応する
works.statusがdraftであること(公開済みや削除済みの投稿への再アップロードを防ぐ) works.upload_idがまだ設定されていないこと(同じ下書きに対する二重アップロードを防ぐ)
検証を通過したら、uploads行を作成し、work_id列にひも付ける。
同時にworks.upload_idをこのuploads.idで更新する。
この二つの書き込みは同一トランザクションで行う。
アップロード完了時(CompleteUploadsフック)
既存のmain.goにあるCompleteUploadsチャンネルの受信処理を拡張する。
同一トランザクション内で、次の二つを行う。
uploads.statusをtranscodingに更新する- Riverの変換ジョブをinsertする(
river.Client.InsertTxを使い、pgxのトランザクションを共有する)
ジョブのペイロードには、最低限upload_idを含める。
work_idはWorker側でuploads.work_idから辿れるため、ペイロードに重複して持たせる必要はない。
トランザクションをコミットした後にRiverキューへ通知することで、DB更新とジョブ投入の不整合(更新だけ成功してジョブが投入されない、あるいはその逆)を防ぐ。
Worker側の完了処理
Workerはジョブを受け取ったら、変換処理の実装(本書の対象外)を経て、次のDB更新を単一トランザクションで行う。
uploads.statusをdone(失敗時はfailed、error_messageに理由を記録)に更新し、converted_keyを記録するuploads.work_idから対応するworks行を取得するworks.statusがpublish_pendingであれば、publishedに更新しpublished_atを記録する。draftのままであればworks.statusは変更しない(ユーザーがまだ公開操作をしていないため)
sqlc構成
Backend、Workerともに単一のGoモジュール(cocot/backend)に属するため、sqlcの生成コードは共有できる。
backend/db/配下に、次の構成を想定する。
backend/db/ schema/ -- マイグレーション用DDL(既存のリネーム連番方式を継続) queries/ -- sqlc用のクエリ定義(*.sql) sqlc.yml -- 生成設定sqlc.ymlは現状空であるため、queries/配下にクエリを書き始める段階で合わせて設定する。
生成先パッケージはBackend、Worker双方からimportできる場所(例: backend/db/gen)に置く。
未確定事項
- タグ名の表記揺れ(大文字小文字、全角半角)をどう正規化するか
- 投稿削除時、Cocot Storage上の音声ファイルとサムネイルを物理削除するか、保持したまま参照だけ切るか
- アップロード中の音声ファイル差し替え(一度TUSアップロードを開始した後、選び直したい場合の挙動)
- Riverジョブの再試行回数やタイムアウトの方針
PATCH /works/:idの更新頻度が高い場合(アップロード中の自動保存)のレート制限の要否