Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions docs/0-requirements.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,17 @@ Responsibilities:

各 invocation は独立した subrequest 予算を持つ。dispatch は `controller.cron` で `handleScheduled` 内で行う。未知の cron 表現は no-op log で silent regression を防止する。

**ETag の据え置き.** 条件付き request を出す surface は 3 つある — issue / PR(issues list への `If-None-Match`)、releases(releases list)、docs(repository tree)— そしてこの 3 面は 1 つの規則に従う: **やり残しがある run は、受け取った ETag を保存しない。** やり残しとは retrieval surface に載らなかった項目のことで、理由は問わない — per-run cap が見送った場合も、embed が失敗した場合も同じ。どちらでも項目には retry の印が付き(空の `bodyHash`、または store 側で進んでいない `blobSha`)、次 run がその項目を見に来ることが前提になる。ところが ETag を保存すると、次 run の条件付き request は 304 を返して項目を見る前に return してしまい、印の付いた項目は同じ surface の**無関係な**項目が変更されるまで待たされる。`lastPolledAt` は進めるので、据え置いた run 自体は観測できる。

条件を 3 面それぞれに持たせず 1 つに揃えているのは、面ごとの条件が離れていくからである。releases 面は upsert cap だけを、docs 面は 2 本の cap だけを見ていたため、どちらも embed 失敗では ETag を更新してしまい retry が止まっていた(issue #211)。この一般形に最初に到達したのは issue / PR 面(issue #215)。*据え置き*の実装は releases / docs 面が「直前に保存した ETag を保持」、issue / PR 面が「何も保存しない」だが、この 2 つは等価である。保存済み ETag を送って 200 が返った run は、その surface が既にその ETag と異なることを証明しているので、次の request はどちらの形でも 200 になる。

残りの取り込み surface がこの規則の外にあるのは、書き漏らしではなく構造上の理由である。comment poller は条件付き request を出さない(毎 run 直近の parent を歩き直す)。wiki poller の `etag` watermark 列が持っているのは HTTP ETag ではなく walk の cursor である。

issue / PR poller は `(lastPolledAt, now]` を `updated_at` 昇順で取得する。1 run あたり最大 `MAX_PAGES_PER_RUN` × 100 = 200 件を fetch し、そのうち embed するのは最大 `MAX_EMBEDDINGS_PER_RUN` = 50 件。この surface は commit diff と同じ watermark 不変条件に従う: **その run が retrieval surface に載せられなかった最も古い項目を watermark が追い越さない** — embedding 予算で見送った項目と、embed に失敗した項目の両方が対象。2 つの境界は別物として扱う。fetch がどこまで届いたか(poll 開始時刻、pagination が打ち切られた場合は最後に fetch した項目の `updated_at`)は上限にすぎず、取り込み境界がその下に watermark を留める。留める位置は境界の 1 秒手前で、GitHub の `since` filter が境界の項目自身を再び含むようにするため。

この pin が無い間、この surface は「fetch 件数 − embed 件数」の速度で取りこぼしていた。旧実装の watermark はどちらの分岐でも見送った項目より**新しい**位置に着地する — batch は昇順なので、予算はいつもその新しい端で尽きる — 一方で見送った項目に付く空の `bodyHash` は retry の印であって、以後どの `since` window もその項目を fetch しない。2026-08-03 の実測で、索引対象 repository の issue / pull request 履歴の約 55% が索引に載っていなかった。欠落が連続した番号帯ではなく散発に見えるのは、脱落が番号順ではなく `updated_at` 順に起きるため(issue #210)。**ETag も同じ条件で書き戻さない。** docs poller が tree ETag を保持するのと同じ理由で、ETag を保存すると次 run の条件付き request が 304 を返し、残りを見る前に return してしまう。
この pin が無い間、この surface は「fetch 件数 − embed 件数」の速度で取りこぼしていた。旧実装の watermark はどちらの分岐でも見送った項目より**新しい**位置に着地する — batch は昇順なので、予算はいつもその新しい端で尽きる — 一方で見送った項目に付く空の `bodyHash` は retry の印であって、以後どの `since` window もその項目を fetch しない。2026-08-03 の実測で、索引対象 repository の issue / pull request 履歴の約 55% が索引に載っていなかった。欠落が連続した番号帯ではなく散発に見えるのは、脱落が番号順ではなく `updated_at` 順に起きるため(issue #210)。**ETag も同じ条件で書き戻さない**(上記「ETag の据え置き」に従う)。

releases poller は releases list 全体を 1 回の条件付き request で読み、1 repo 1 run あたり最大 `MAX_RELEASE_UPSERTS_PER_REPO_PER_RUN` = 10 件を upsert する。list は有界で毎回全件読み直すため、この面は watermark を持たない。したがって ETag の据え置きがこの面唯一のやり残し機構である。cap が見送った release も、embed に失敗した release も、どちらも空の `bodyHash` で保存され、次 run で拾えるのは ETag を据え置いたからにすぎない(issue #149 / #211)。

commit diff poller は 2-phase 構成:

Expand All @@ -157,7 +165,9 @@ commit diff poller は 2-phase 構成:

docs poller は `If-None-Match` 付きの条件付きリクエストで repository tree を読み、保存済みの doc record と差分を取る。blob SHA が動いた entry は re-embed し、store にあって tree に無い entry は削除する。**削除は 3 面を teardown する** — Vectorize / D1 FTS5 / structured store — それぞれ独立に実行するので、Vectorize の失敗が実際に retrieval される D1 行を取り残すことはない。wiki 側の 4 面に対してここが 3 面なのは、doc vector ID が `doc_edges` の端点になりえないため。`indexWikiEdges` が唯一の writer であり、src 側も算出される dst 側も wiki vector ID になる。この不変条件が変わったら、ここに edge の teardown を足すこと(issue #203)。なお削除が届くのは現行の vector ID 世代だけで、移行前世代は構造上到達できず、別経路で掃除する(Vector Store の「移行前世代」を参照)。

**削除の枠.** 削除は 1 repo 1 run あたり `MAX_DOC_DELETIONS_PER_REPO_PER_RUN`(既定 5)で cap する。wiki の削除と同じ guard で、1 件あたり 3 subrequest かかるため、大量削除に対して上限なく回すと light cron の invocation 予算を単独で食い潰し、後ろに並ぶ repo を飢えさせうる — 1 つの PR が `.md` を 66 件削除すれば、その全件が 1 run に集中する。削除済み doc の store 行は消えるので残りの集合は縮む一方であり、drain は単調。したがってこの surface には per-run cap が 2 本あり、**どちらが効いても tree ETag は据え置く**: fetch 枠は未処理の変更 doc を残し(issue #149)、削除枠は未削除の doc を残す(issue #203)。どちらの場合も ETag を進めてしまうと次 run が 304 で返り、残りを見ないまま終わる。`lastPolledAt` は進めるので run 自体は観測できる。
**削除の枠.** 削除は 1 repo 1 run あたり `MAX_DOC_DELETIONS_PER_REPO_PER_RUN`(既定 5)で cap する。wiki の削除と同じ guard で、1 件あたり 3 subrequest かかるため、大量削除に対して上限なく回すと light cron の invocation 予算を単独で食い潰し、後ろに並ぶ repo を飢えさせうる — 1 つの PR が `.md` を 66 件削除すれば、その全件が 1 run に集中する。削除済み doc の store 行は消えるので残りの集合は縮む一方であり、drain は単調。したがってこの surface にはやり残しの経路が 3 本あり、**どれが効いても tree ETag は据え置く**(上記「ETag の据え置き」に従う): fetch 枠は未処理の変更 doc を残し(issue #149)、削除枠は未削除の doc を残し(issue #203)、embed 失敗は store 行が古い `blobSha` を保持したままの doc を残す(issue #211)。

docs / releases のどちらの loop も、自前の embedding cap を持たない。両者の fan-out cap(各 10)が `MAX_EMBEDDINGS_PER_RUN` = 50 より小さく、embed 件数を構造的に抑えているため、かつて置かれていた guard は到達不能だった。到達不能な分岐は「それも ETag の据え置きを負うべきか」を読み手が判断できない状態を残し、issue #211 の穴が欠陥ではなく曖昧さとして読めてしまう原因になっていた。**この定数関係はテストで検証している**——各 cap の位置のコメントに委ねてはいない。この repo の fan-out cap は実際に調整される(issue #134 は comment fetch cap を 30 から 10 に下げた)ので、50 より上に引き上げた時に、その面の embed 上限が黙って消えてしまうからである。assertion が落ちることは「新しい cap が誤り」という判定ではない。その loop に embedding guard を戻し、見送った項目が ETag を据え置けるよう、その loop の ETag 据え置き条件に組み込む必要がある、という意味である。

wiki poller は `:45` cron 専属で、GitHub Wiki content の唯一の取り込み経路。Wiki は別 git repo (`{repo}.wiki.git`) に存在し、REST API も webhook event も持たないため、poller が repo ごとに 3 段の HTTP 呼び出しで処理する:

Expand Down
14 changes: 12 additions & 2 deletions docs/0-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,17 @@ The poller runs hourly in the current deployment, split across four cron trigger

Dispatch is performed inside `handleScheduled` by inspecting `controller.cron`. Unknown cron expressions fall through to a no-op log to prevent silent regressions when triggers are added later.

**The ETag hold.** Three surfaces make conditional requests — issue / PR (`If-None-Match` on the issues list), releases (on the releases list), and docs (on the repository tree) — and one rule governs all three: **a run that left work behind does not store the ETag it just received.** Work is left behind whenever an item did not reach the retrieval surfaces, whatever the reason — a per-run cap deferred it, or its embed failed. Either way the item carries a retry marker (an empty `bodyHash`, or a `blobSha` the store never advanced) and needs the next run to look at it again; a stored ETag makes that run's conditional request answer 304 and return before it does, leaving the marked item to wait for some unrelated item on the same surface to change. `lastPolledAt` still advances, so a holding run stays observable.

The rule is deliberately one condition rather than three, because per-surface conditions drift apart: the releases branch watched its upsert cap alone and the docs branch watched its two caps, so on both an embed failure still refreshed the ETag and stalled the retry (issue #211). The issue / PR surface reached the general form first (issue #215). *Holding* is expressed as "keep the previously stored ETag" on the releases and docs surfaces and as "store none" on the issue / PR surface; the two are equivalent, because a run that received a 200 for the stored ETag has proved the surface already differs from it, so the next request is answered 200 either way.

The remaining ingestion surfaces are outside this rule by construction, not by omission: the comment poller issues no conditional request (it re-walks the most recent parents every run), and the wiki poller's `etag` watermark column holds its walk cursor rather than an HTTP ETag.

The issue / PR poller fetches `(lastPolledAt, now]` sorted by `updated_at` ascending, at most `MAX_PAGES_PER_RUN` × 100 = 200 items, and embeds at most `MAX_EMBEDDINGS_PER_RUN` = 50 of them. It obeys the same watermark invariant as the commit-diff surface: **the watermark never advances past the earliest item the run left off the retrieval surfaces** — deferred by the embedding budget, or failed to embed. The two bounds are separate: how far the fetch reached (the poll start time, or the last fetched item when pagination capped) is an upper bound, and the ingest boundary pins the watermark below it, one second earlier so GitHub's `since` filter still re-includes the boundary item.

Without that pin the surface leaked at a rate of fetched-minus-embedded per run. Both of the old watermark branches landed *above* every deferred item — the batch is ascending, so the budget always runs out on its newest end — and the deferred item's empty `bodyHash` marked it for a retry that no later `since` window would ever fetch. Measured 2026-08-03, that left about 55% of the issue and pull request history of the indexed repositories absent from the index, scattered rather than in contiguous ranges because the loss follows `updated_at` order and not number order (issue #210). **The ETag is withheld on the same condition**, for the reason the docs poller withholds its tree ETag: a stored ETag makes the next run's conditional request answer 304 and return before it looks at the leftover.
Without that pin the surface leaked at a rate of fetched-minus-embedded per run. Both of the old watermark branches landed *above* every deferred item — the batch is ascending, so the budget always runs out on its newest end — and the deferred item's empty `bodyHash` marked it for a retry that no later `since` window would ever fetch. Measured 2026-08-03, that left about 55% of the issue and pull request history of the indexed repositories absent from the index, scattered rather than in contiguous ranges because the loss follows `updated_at` order and not number order (issue #210). **The ETag is withheld on the same condition**, per the ETag hold above.

The releases poller reads the whole releases list in one conditional request and upserts at most `MAX_RELEASE_UPSERTS_PER_REPO_PER_RUN` = 10 of them per repo per run. It needs no watermark of its own — the list is bounded and re-read in full — so the ETag hold is its only leftover mechanism: releases the cap deferred, and releases whose embed failed, are both stored with an empty `bodyHash` and are reachable next run only because the ETag was held (issue #149 / #211).

The commit-diff poller runs in two phases:

Expand All @@ -158,7 +166,9 @@ The tradeoff is liveness: a commit that fails on every attempt blocks its phase'

The docs poller reads the repository tree with a conditional `If-None-Match` request and diffs it against the stored doc records: entries whose blob SHA moved are re-embedded, entries present in the store but absent from the tree are reaped. **The reap tears down three surfaces** — Vectorize, D1 FTS5, and the structured store — each independently, so a Vectorize failure cannot strand the D1 rows users actually retrieve. It is three and not the wiki reap's four because a doc vector ID is never a `doc_edges` endpoint: `indexWikiEdges` is the only writer and both the source and the computed destination IDs are wiki vector IDs. Add the edge teardown here if that invariant changes (issue #203). The reap reaches only the current vector-ID generation; the pre-migration generation is unreachable by construction and is cleaned out separately (see Vector Store, "Pre-migration generation").

**Reap budget.** The reap is capped at `MAX_DOC_DELETIONS_PER_REPO_PER_RUN` (default 5) per repo per run, the same guard the wiki reap carries: at 3 subrequests per deletion an unbounded loop over a mass deletion could exhaust the light cron's invocation budget on its own and starve every repo behind it — a single PR removing 66 `.md` files puts all of them on one run. The drain is monotonic, since a reaped doc's store row is gone and the leftover set only shrinks. Two per-run caps therefore exist on this surface, and **either one holds the tree ETag back**: the fetch cap leaves changed docs unprocessed (issue #149) and the delete cap leaves deletions unreaped (issue #203), and in both cases advancing the ETag would make the next run answer 304 and return before it looked at the leftover. `lastPolledAt` still advances so the run stays observable.
**Reap budget.** The reap is capped at `MAX_DOC_DELETIONS_PER_REPO_PER_RUN` (default 5) per repo per run, the same guard the wiki reap carries: at 3 subrequests per deletion an unbounded loop over a mass deletion could exhaust the light cron's invocation budget on its own and starve every repo behind it — a single PR removing 66 `.md` files puts all of them on one run. The drain is monotonic, since a reaped doc's store row is gone and the leftover set only shrinks. This surface therefore has three ways to leave work behind, and **any one of them holds the tree ETag back** per the ETag hold above: the fetch cap leaves changed docs unprocessed (issue #149), the delete cap leaves deletions unreaped (issue #203), and a failed embed leaves a doc whose store row still carries the old `blobSha` (issue #211).

Neither the docs nor the releases loop carries an embedding cap of its own. Their fan-out caps (10 each) sit below `MAX_EMBEDDINGS_PER_RUN` = 50 and bound the embed count by construction, so the guards those loops used to hold were unreachable — and unreachable code left the reader unable to tell whether it also owed the ETag hold, which is how the issue #211 hole read as ambiguous rather than as a defect. **That constant relation is asserted by a test**, not left to the comments at the cap sites: fan-out caps on this repo get retuned (issue #134 took the comment fetch cap from 30 to 10), and raising one past 50 would otherwise remove the surface's embed bound in silence. A failing assertion is not a verdict that the new cap is wrong — it says the loop now needs its embedding guard back, folded into that loop's ETag-hold condition so a deferred item still holds the ETag.

The wiki poller runs in the `:45` cron and is the only ingestion path for GitHub Wiki content. Wiki pages live in a separate git repo (`{repo}.wiki.git`) that GitHub does not expose through the REST API or webhook events; the poller therefore performs three lightweight HTTP calls per repo:

Expand Down
Loading
Loading