Online Judgeをゼロから再構築:完全なフルスタックリファクタリングの記録
あるプロジェクトは静かに負債を蓄積する。Leverage OJはそうではなかった——コンテスト中に凍結するランキングシステム、PM2クラスタリング下で壊れる認証システム、リクエストごとに提出テーブル全体をスキャンするリーダーボード、そして設定が1つ漏洩すれば完全な認証情報ダンプになるパスワードハッシュスキームという形で、大声で蓄積していた。
これは完全な書き換えの物語だ:何を置き換えたか、なぜ、そしてその過程で何を学んだか。
1. リファクタリングではなく書き換えを選んだ理由
技術的負債の棚卸し
オリジナルのLeverage OJは、実際の制約の下で高速に進むプロジェクトだった。開発者たちは思慮深かった——アーキテクチャの決定からそれがわかる。しかし6年間の機能追加、深夜の修正、「私のマシンでは動く」パッチが不安定な塔として積み重なっていた。
コードレビューで29の異なるバグが浮上した。最悪のものをいくつか:
PM2クラスタ競合: ランキングシステムはメモリ内のpendingSetを使用して、どのコンテスト部門を再構築する必要があるかを追跡していた。PM2クラスタモードでは、各プロセスが独自のpendingSetを持っていた。プロセスAに到着した提出は部門を保留としてマークするが、プロセスBで実行されるcronジョブは空のセットを持っていた。ランキングはコンテスト中に更新を停止した——静かに、エラーなしで。
全テーブルスキャン: すべてのランキング再構築はrebuildSaAndRank()を呼び出し、データベースからすべての提出を読み込み、メモリ内でソートし(O(N log N))、N個の個別のUPDATEステートメントで結果を書き戻した。数万件の提出がある複数日のセッションでは、これはNode.jsイベントループを数分間ピン留めした。1つのブロッキング操作;他のすべてのリクエストが停止した。
パスワード問題: ハッシュスキームはグローバルで固定のHMACキーを持つHMAC-SHA256(MD5(password))で、ユーザーごとのソルトがなかった。ソルトがないということは、キーが漏洩すれば、ユーザーテーブル全体を1回のGPU実行で解読できることを意味する。キーは設定ファイルにあった。リポジトリ内に。
chenjingyuチェック:
// main.ts — 本番コード
if (process.env.USER !== 'chenjingyu') {
await initService.init()
}ローカル開発中に初期化をスキップするためのハードコードされたユーザー名が本番環境に入っていた。サーバーが別のユーザーとして実行される場合、初期化は静かに発生しなかった。
なぜインクリメンタルではないのか?
これらの問題のほとんどについて、インクリメンタルリファクタリングは理にかなっていた。しかし認証システム、キューシステム、ランキングシステムは深く絡み合っていた。セッションベースの認証を置き換えるにはすべてのルートに触れる必要があった。キューを置き換えるにはジャッジャーコールバックの動作方法を再考する必要があった。ランキングシステムを置き換えるには新しい提出フローが先に整っている必要があった。
また、ゼロテストからのスタートだった。既存のコードベースにテストを追加することは可能だったが、書いたすべてのテストがより多くの解きほぐすべき結合を露呈した。ある時点で、インクリメンタルな改善のコストは、初日からテストを持つクリーンな書き換えのコストを超えた。
2. バックエンド:アーキテクチャのアップグレード
NestJSレイヤードアーキテクチャ
新しいバックエンドは関心の明確な分離を持つNestJS上に構築されている:
Controller (HTTP境界)
└── Service (ビジネスロジック)
└── Repository / TypeORM (データアクセス)
└── MariaDB / Redis各モジュールはアプリケーションの独自のスライスを所有する:auth、problem、submission、heng、receive、rank、contest、course、user、compete、media、metrics、health。モジュール間の直接データベースアクセスはない——モジュールはサービスインターフェースを通じて相互に通信する。
JWTデュアルトークン認証
セッションベースの認証はデュアルトークンJWTシステムに置き換えられた:
- アクセストークン:15分、HS256、ステートレス。すべてのリクエストヘッダーに含まれる。
- リフレッシュトークン:7日、失効のためにRedisに保存。新しいアクセストークンを取得するためにのみ使用。
ロール重みは階層を定義する(低いほど特権が高い):
| ロール | 重み |
|---|---|
sa | 0 |
admin | 1 |
supervisor | 2 |
user | 3 |
contest-user | 4 |
@Roles(Role.Supervisor)は「重み ≤ 2」に解決されるので、adminとsaも自動的に通過する。
サーバーはアクセストークンの検証において真にステートレスになった。水平スケーリングはコンテナを追加するだけだ。
BullMQが手作りキューを置き換える
オリジナルのカスタムQueue<T>クラスにはリトライ、デッドレターキュー、可観測性がなかった。新しいシステムは2つのキューでBullMQを使用する:
judge-tx— 提出をheng-controllerに送信(HMAC署名付きHTTP)judge-rx—heng-controllerからのコールバックを受信(非同期デカップリング)
失敗したジョブは指数バックオフでリトライする。最大リトライを超えたジョブはデッドレターキューに入る。bull-boardダッシュボードがリアルタイムの可視性を提供する。以前は不透明だった提出パイプライン全体が観測可能になった。
Redis Sorted Setが全テーブルスキャンを置き換える
これは最もインパクトのある単一の変更だ。古いランキングアルゴリズム:
- DBからすべての提出を読み込む → ソート → ランキングテーブルを再構築 → N個のUPDATEステートメント
- 複雑さ:O(N)読み込み + O(N log N)ソート + O(N)書き込み
- cronとクラスタリング下で壊れるメモリ内状態によってトリガー
新しいアルゴリズム:
// AC提出時 — ReceiveServiceから呼び出される
async updateRanking(userId: number, acCount: number, penalty: number) {
const score = acCount * 1_000_000_000 - penalty;
await this.redis.zadd('rank:global', score, String(userId));
}
// ランク照会時 — O(log N)
async getUserRank(userId: number): Promise<number> {
const rank = await this.redis.zrevrank('rank:global', String(userId));
return rank === null ? -1 : rank + 1;
}
// トップNリーダーボード — O(log N + K)
async getLeaderboard(top: number) {
return this.redis.zrevrange('rank:global', 0, top - 1, 'WITHSCORES');
}リアルタイム、常に正確、操作ごとにO(log N)。cronジョブなし。メモリ内状態なし。クラスタリング問題なし。同じパターンがコンテストランキングとコースランキングに適用される。
テストカバレッジ:0 → 85%+
オリジナルのコードベースにはちょうどゼロのテストファイルがあった。新しいバックエンドには:
| レイヤー | 数 | テスト内容 |
|---|---|---|
| ユニット | 572 | Jestモックで分離されたサービスロジック |
| 統合 | 42 | SQLite + ioredis-mockでDB + Redis |
| E2E | 25 | testcontainersで完全なHTTPスタック |
合計:639テスト、クリティカルパスで≥85%カバレッジ。
3. フロントエンド:Nuxt 4書き換え
フロントエンドも書き換える理由
古いフロントエンドはVue 2で、2023年12月にEOL。API呼び出しは抽象化レイヤーなしでコンポーネント全体に散らばっていた。認証トークンは提出中に静かに期限切れになった。型安全性なし——すべてがanyだった。
バックエンドAPIが変更されると、フロントエンドは非常に多くの場所で更新が必要になり、ターゲットを絞ったリファクタリングは実質的にすべてに触れることになった。その時点で、ゼロから始める方が理にかなっていた。
スタック決定
Nuxt 4、SPAモード。 OJプラットフォームにはSSRの意味のある用途がない——ほぼすべてのページが認証を必要とし、検索エンジンはログインウォールの背後にある問題文をインデックスする必要がない。SPAモードはNuxtのプロジェクト構造、自動インポート、ルーティング、ビルドツールを、ハイドレーションの複雑さなしで提供する。
Naive UI。 一貫性があり完全で、Vue 3 Composition APIとうまく連携する。古いコードベースにはElement PlusとNaive UIコンポーネントが混在していた——その不整合は今はない。
CodeMirror 6。 コードエディタはOJフロントエンドで最も重要なコンポーネントだ。バンドルサイズと柔軟性のためにMonacoではなくCodeMirror 6を選択した。拡張モデルにより必要なものを正確に構成できる:C/C++/Python/Java/TypeScriptの構文ハイライト、vimキーバインディング、One Darkテーマ。
KaTeXで数学レンダリング。競技プログラミングの問題文は数学が多い。KaTeXは同期的にレンダリングし、MathJaxより劇的に速い。
APIコンポーザブルレイヤー
すべてのAPI操作はcomposables/api/のモジュール固有のコンポーザブルを通過する。バックエンドモジュールごとに1つのコンポーザブル。useApi()は認証ヘッダーの添付、トークンリフレッシュのトリガー、エラーの正規化の単一ポイントだ。他のものは直接axiosに触れない。
Playwrightが見つけた4つのバグ
Playwright E2Eテストは完全なユーザージャーニーを実行した:ログイン、問題閲覧、コード提出、結果確認。手動テストをすべてすり抜けた4つのバグが浮上した。
バグ1 — Naive UIコンポーネント登録: コンポーネントは粗い手段としてapp.vueでグローバルにインポートされていた。NSelect(提出フォームの言語ドロップダウン)がリストになかった。コンソールエラーなしで空の<div>としてレンダリングされた。
バグ2 — 非同期ページでのNuxtLayoutアンマウント: useAsyncDataを持つページでレイアウト(ナビバー、サイドバー)がフラッシュした:レンダリング → 消失 → 再出現。根本原因:ページレベルでdefinePageMetaを介して定義されたレイアウトは、app.vueで定義されたレイアウトとは異なる方法で非同期ページと相互作用する。
バグ3 — imports.dirsがネストされたコンポーザブルをカバーしない: Nuxtの自動インポートはデフォルトでcomposables/を1レベル深くしかカバーしない。composables/api/はスキャンされなかった。開発では動作し(ViteのHMRはより寛容)、ビルド出力では失敗した。
バグ4 — axios res.data二重アンラップ: useApiコンポーザブルはresponse.dataを返した。個々のAPI関数もreturn response.dataを行った。{ data: { ... } }エンベロープを返すエンドポイントでは、最終値はresponse.data.dataだった。
Playwrightスイートは今やすべてのプッシュで実行される:完全なユーザージャーニーをカバーする30のE2Eテスト。
4. エンジニアリング品質の改善
TypeORMマイグレーション:synchronize: trueからバージョン管理されたスキーマへ
オリジナルのコードベースはsynchronize: trueを使用していた——起動時にエンティティ定義に合わせてデータベーススキーマを自動的に変更するTypeORMの開発便利機能。開発では問題ない。本番では危険だ。
新しいシステムはマイグレーションのみを使用する。すべてのスキーマ変更は今やバージョン管理された、可逆的なマイグレーションファイルとして機能と一緒にコミットされる。ロールバックはコマンドであり、危機ではない。
セキュリティ監査:パーミッションマトリックス
書き換え中に50以上の新しいエンドポイントを追加した後、パーミッションマトリックスですべてのルートを監査した:各エンドポイントをその予想されるアクセスレベルと実際のガード設定にマッピングするテーブル。
マトリックスは2つの問題を発見した:
問題1 — rejudge権限昇格: POST /submissions/:id/rejudgeエンドポイントは@Roles(Role.Supervisor)ではなく@Roles(Role.Admin)でガードされていた。スーパーバイザーは自分のコンテストで提出を再評価できなかった——403を受け取った。
問題2 — ガードなしのFIXME: GET /admin/config/rawエンドポイントにはガードがまったくない// FIXME: add authコメントがあった。URLを知っている誰にでも完全なシステム設定を公開していた。
両方修正された。パーミッションマトリックスは今やエンドポイントを追加または変更するすべてのPRに対してチェックされる生きたドキュメントだ。
入力検証とレート制限
DTOは全体を通してclass-validator制約で強化された。ThrottlerModuleを介してログインレート制限が追加された:IPごとに毎分5回の試行。別のミドルウェアレイヤーを導入せずにブルートフォース耐性を実現。
5. 三層テストアーキテクチャ
639のテストは3つの異なるレイヤーで編成されている:
┌─────────────────────────────────────────────────────────┐
│ E2Eテスト (25) │
│ testcontainers:実際のMariaDB + 実際のRedis │
│ 完全なHTTPスタック、実際のネットワーク呼び出し │
├─────────────────────────────────────────────────────────┤
│ 統合テスト (42) │
│ SQLiteインメモリ + ioredis-mock │
│ サービスレイヤー + DB、HTTP境界なし │
├─────────────────────────────────────────────────────────┤
│ ユニットテスト (572) │
│ Jestモック、完全に分離 │
│ 1つの関数、1つの関心事 │
└─────────────────────────────────────────────────────────┘ユニットテストはJestのモックシステムを積極的に使用する。統合テストはデータベーステストにSQLiteインメモリ、RedisにioredisΜockを使用する。E2Eテストはtestcontainersを使用して各テスト実行ごとに実際のMariaDBとRedisインスタンスを起動する。
6. 主要な決定と教訓
synchronize: trueはプロトタイピングのみ
本番でsynchronize: trueを維持する誘惑は本物だ——便利だし、初期にはデータベーススキーマが頻繁に変更される。しかし失いたくない実際のユーザーデータがある瞬間、synchronize: trueは負債だ。
ルール: synchronize: trueは開発のみ。それ以外——ステージング、CI、本番——はマイグレーションを使用する。
セキュリティ監査は機能完了直後に行うべき
パーミッションマトリックス監査はrejudge権限の退行とコードベースにずっとあったガードなしのエンドポイントを発見した。監査が書き換え全体の終わりではなく各スプリントの終わりに行われていれば、両方ともすぐに捕捉されていただろう。
ルール: エンドポイントを追加または変更するすべてのスプリントの後にパーミッションマトリックスを実行する。プロジェクトの終わりではない。
後付けのテストでも書く価値がある
すでに書かれたコードに対して639のテストを書いた——伝統的な意味でのテスト駆動開発ではない。価値は依然としてあった:テストは出荷前にrejudgeの退行を捕捉し、テストはランキングシステムをリファクタリングする自信を与え、テストは各モジュールがどのように動作すべきかを文書化した。
ルール: コードがすでに存在していてもテストを書く。カバレッジとドキュメント価値は努力に値する。
7. 現在の位置
本番準備チェックリスト
| 項目 | ステータス |
|---|---|
| トークン失効付きJWT認証 | ✅ |
| BullMQ提出キュー | ✅ |
| Redisリーダーボード | ✅ |
| TypeORMマイグレーション | ✅ |
| PBKDF2パスワードハッシュ | ✅ |
| ログインレート制限 | ✅ |
| 全体を通じたDTO検証 | ✅ |
| パーミッションマトリックス監査済み | ✅ |
| ユニット + 統合 + E2Eテスト | ✅ |
Prometheus /metricsエンドポイント | ✅ |
| ヘルスチェックエンドポイント | ✅ |
| データベースインデックス最適化済み | ✅ |
ユーザーAPIキーシステム(lev_プレフィックス) | ✅ |
| botzone-neoジャッジ統合 | ✅ |
| マルチプレイヤー(Nプレイヤー)サポート | ✅ |
| MCPサーバー(13ツール) | ✅ |
| 本番デプロイ | 🔲 保留中 |
次のステップ
- 本番デプロイ — Nginxリバースプロキシ、TLS、環境固有の設定
- shimmy上流PR — サンドボックス改善をlambda-feedback/shimmyに提出
- Sandlock Phase 2 — SandlockBackendでのLinux cgroupsメモリ強制
- 負荷テスト — 現実的なコンテスト規模のトラフィックでk6
基盤は堅固だ。ここからの興味深い問題はアーキテクチャ的なものではなく運用的なものだ。
完全な書き換えはターゲットを絞った修正よりも時間がかかったが、個別にパッチするのではなく、バグのカテゴリ全体を排除した。PM2クラスタリングの問題は今やアーキテクチャ的に不可能だ——新しい設計はステートレスだ。全テーブルスキャンのリーダーボードはもう存在しない。パスワードハッシュは正しい。テストスイートはユーザーに届く前に退行を捕捉する。
書き換えに値するコードベースがある。これはその1つだった。
