コンテンツにスキップ

アーキテクチャ

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 度届いても増えません。

詳しくはアーカイブ台帳を参照してください。

モジュール 責務
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。

拒否と障害の見分けは、いまや flow の仕事です —— BrowserHive を呼んでいるのが あちらだからです。capture-ledger の側の関心事は、crawls の行がどうなるかです。

投げられなかった dispatch は、その行をメッセージ付きで failed にして締めます。 running のまま放置しません。 部分 unique index が効いているので、締め忘れた 行は以後のクロールを永久に塞ぎますし、生きているものとの区別が行に無いからです。

API 側の予期しない失敗は、素の 500 { "error": "internal error" } で返します。 Fastify の既定は err.message と err.code をそのまま返しますが、Postgres の エラーはそこに列の型と値の成れの果てを載せてくるので、client 側の誤りが サーバ内部の形を教える窓になります。4xx はそのまま通します —— あれは client に 向けて書かれた文言です。

スタックは 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 は起動順しか保証しないためです。

capture-ledger は submodule ポインタ 1 つで BrowserHive の版を固定しています。 BrowserHive の更新を参照してください (そのページは現在の値をリポジトリから読んでおり、ここに再掲しません)。