アーキテクチャ
Stage 0 — 現在の到達点
Section titled “Stage 0 — 現在の到達点”flowchart LR
U((呼び出し元)) -->|"POST /api/crawls"| W
PG[("Postgres<br/>capture_targets / crawls / crawl_pages")] <--> W
subgraph W["capture-ledger (Node 24 / TypeScript)"]
direction TB
API["api/crawls.ts<br/>範囲・重複排除・上限"]
DIS["crawl/dispatch.ts<br/>webhook を 1 回 POST"]
API --> DIS
end
DIS -->|"webhook + token"| WM["Windmill の flow<br/>(capture-scheduler)"]
WM -->|Capture| BH1
WM -->|Capture| BH2
subgraph BH["BrowserHive — browser 1 台に 1 プロセス"]
BH1[browserhive-1]
BH2[browserhive-2]
end
BH1 -->|CDP| C1[chromium-1]
BH2 -->|CDP| C2[chromium-2]
BH1 -->|PutObject| S3[(SeaweedFS<br/>bucket: browserhive)]
BH2 -->|PutObject| S3
WM -->|"POST /api/crawls/:id/pages"| API
S3 -.->|".links.json / .result.json"| API
投げてから、結末を回収するまで
Section titled “投げてから、結末を回収するまで”capture-ledger は BrowserHive とまったく話しません。 POST /captures を呼んで答えを
待ち、報告するのは Windmill の flow で、capture-ledger は次の段を組み立てて記録します。
capture-ledger の設定に BrowserHive の在り処は 1 つも入っていません。BrowserHive は
1 プロセスが browser 1 台を持ち、取り込みは 1 度に 1 つ —— 走行中なら
429 Busy で断る —— ので、空いている口を選ぶのも flow の仕事です。
1 段が 1 往復です。flow は渡された URL を取り込み、POST /api/crawls/:id/pages に
ページごとの taskId と状態を載せて返します。capture-ledger は crawl_pages を書き、
帰属を記録し、見つかったリンクを範囲で絞り、上限を当て、次の段を答えます ——
あるいは stopReason を返します。
段の報告に成果物の在り処は載っていません。そこで capture-ledger は、BrowserHive が
成果物の隣に書く {taskId}_{correlationId}[_{labels}].result.json を読み直します。
この file は BrowserHive の再起動にも耐えるので、ここで使えます。
知っておくべき帰結:
failedと報告されたページでも、アーカイブができていることがあります。 flow が待っている間に結果がキャッシュから溢れると、flow はNOT_FOUNDを 受け取って失敗と報告しますが、manifest は bucket に在るので capture-ledger が拾い直し、crawl_pagesの記録を直します。- まだ manifest が無い
taskIdは、投げずに飛ばします。ここで投げると、書き込みが 少し遅れただけで段の報告が 500 になり、クロールが止まります。遅れたぶんは reconcile が拾います。 capture_submissionsはtaskIdを持つ全件に書きます(成功したものだけでは ありません)。後で reconciler が要るのは、まさにこの帰属と manifest の鍵です。
上の図は投げるところまでです。取り込みが終わったあと、capture-ledger は結果を台帳に 入れ、それを読んでよい相手だけに署名付き URL を配ります。ここが OpenFGA の 出番です。
flowchart LR
S3[(SeaweedFS<br/>bucket: browserhive)]
subgraph W["capture-ledger"]
direction TB
REG["watch.ts → register.ts<br/>結果を待って登録"]
RC["reconcile.ts<br/>書き留めた manifest の鍵を読む"]
OW["outbox-worker.ts<br/>タプルを配送"]
API["api/routes.ts<br/>署名エンドポイント"]
end
S3 -.->|.result.json| REG
S3 -.->|.result.json| RC
PG -.->|未登録の manifest の鍵| RC
REG --> PG
RC --> PG
PG[("Postgres<br/>archives + fga_outbox")]
PG -->|FOR UPDATE SKIP LOCKED| OW
OW -->|Write tuples| FGA[("OpenFGA<br/>専用の Postgres")]
U((利用者)) -->|"POST /api/archives/:id/url"| API
API -->|"Check can_view<br/>+ contextual tuple"| FGA
FGA -->|allowed| API
API -->|"署名付き URL (300 秒)"| U
U -.->|GET| S3
読み方の要点:
archivesとfga_outboxは 1 つのトランザクションで書かれます。 Postgres と OpenFGA に跨がるトランザクションが無いので、タプルは「送る予定」として 同じ箱に入れ、outbox-workerが受理されるまで再送します。- OpenFGA は別の Postgres を持ちます。 capture-ledger のものと共用しないのは、 タプルの書き込みがアプリのトランザクションに入れられないこと —— だから outbox が要ること —— を目に見えるようにするためです。
- 所属は保存しません。 「この人はどの組織か」はトークンから来て、Check のたびに contextual tuple として渡されます。
- 台帳へ入る道は 2 本あります。クロールが段で取り込んだぶんを載せる経路
(速い。
src/crawl/admit-level.ts)と、reconcileが段の報告の書き留めた manifest の鍵を 読む経路(速い経路で入らなかったものを拾う)。どちらもadmitArchiveを通り、(bucket, object_key)が unique なので、同じ取り込みに 2 度届いても増えません。
詳しくはアーカイブ台帳を参照してください。
モジュール構成
Section titled “モジュール構成”| モジュール | 責務 |
|---|---|
src/api/server.ts |
唯一の入口。取り込みの設定を起動時に 1 度読み、route を登録し、outbox をタイマーで掃き出す。 |
src/api/crawls.ts |
クロールを始める / 段を受ける / 次の段を返す。認可・方針・記録。 |
src/crawl/ |
範囲、上限、段の台帳登録、Windmill への dispatch。 |
src/config/capture-formats.ts |
CAPTURE_LEDGER_CAPTURE_FORMATS / _SIGNING を、毎回載せる captureFormats にする。 |
src/data/url-source.ts |
唯一の SQL クエリ。capture_targets から、頼んだ組織のぶんだけ { url }[] を返す。 |
src/archive/ |
manifest、台帳への受け入れ、reconciler。 |
src/rpc/generated/ |
BrowserHive の契約(submodule から写した openapi.json)と、そこから作った型。manifest は CaptureResultReport で、台帳が読む前にこれに照らす。手で編集しない。 |
src/db/ |
プール、Kysely の配線、マイグレーション、seed。 |
エラーの扱い
Section titled “エラーの扱い”拒否と障害の見分けは、いまや flow の仕事です —— BrowserHive を呼んでいるのが
あちらだからです。capture-ledger の側の関心事は、crawls の行がどうなるかです。
投げられなかった dispatch は、その行をメッセージ付きで failed にして締めます。
running のまま放置しません。 部分 unique index が効いているので、締め忘れた
行は以後のクロールを永久に塞ぎますし、生きているものとの区別が行に無いからです。
API 側の予期しない失敗は、素の 500 { "error": "internal error" } で返します。
Fastify の既定は err.message と err.code をそのまま返しますが、Postgres の
エラーはそこに列の型と値の成れの果てを載せてくるので、client 側の誤りが
サーバ内部の形を教える窓になります。4xx はそのまま通します —— あれは client に
向けて書かれた文言です。
ネットワーク構成
Section titled “ネットワーク構成”スタックは Apple Container 上で動きます。プロジェクト名がそのまま DNS ドメインに
なるので、各サービスは <service>.capture-ledger で、コンテナどうしはこの名前で呼び合います。
capture-ledger 自身はホストで動き、下の表の「ホストポート」(127.0.0.1 に publish した口)を使います。
| サービス | DNS 名 | コンテナポート | ホストポート |
|---|---|---|---|
| postgres | postgres.capture-ledger |
5432 | 5432 |
| chromium-1 | chromium-1.capture-ledger |
9222 (CDP) | 9222 |
| chromium-2 | chromium-2.capture-ledger |
9222 (CDP) | 9223 |
| browserhive-1 | browserhive-1.capture-ledger |
50051 (HTTP) | 50051 |
| browserhive-2 | browserhive-2.capture-ledger |
50051 (HTTP) | 50052 |
| capture-ledger | —(ホストで動く) | — | — |
成果物ストア(SeaweedFS)はこのスタックには居ません。crawler の 3 つの repo が
共有する 1 つの store で、seaweedfs.crawler-storage:8333(filer は :8888)です。
起こすのは seaweedfs の
scripts/stack.sh(submodule なので .upstream/seaweedfs/scripts/stack.sh up でも同じ)。
中身を空にしたいときは pnpm run store:wipe。
compose ファイルは 1 つです。migrate / seed の一発ジョブはサービスではなく、
scripts/prod-smoke.sh が container run --rm で起動します(container-compose に
run が無いため)。イメージの ENTRYPOINT は node dist/api/server.js なので、
これらのジョブはそれを上書きして走らせます。
バケットの作成は独立したサービスではありません。BrowserHive の SeaweedFS
entrypoint が weed server と並行して init-bucket.sh を起動し、そのリトライ
ループが待機を担います。browserhive には WAIT_FOR_S3 を渡してあり、
entrypoint が S3 リスナーの応答を待ってからサーバを起動します。起動時の
HeadBucket は fatal で、depends_on は起動順しか保証しないためです。
上流のピン留め
Section titled “上流のピン留め”capture-ledger は submodule ポインタ 1 つで BrowserHive の版を固定しています。 BrowserHive の更新を参照してください (そのページは現在の値をリポジトリから読んでおり、ここに再掲しません)。