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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,11 @@ for tenants that never call `mark_processed`:

| Event class | Retention window | Env var | Default |
|-------------|------------------|---------|---------|
| Processed (`mark_processed` called) | older than the window is deleted | `PURGE_AFTER_DAYS` | `7` days |
| Processed (`mark_processed` called) | older than the window is deleted | `PURGE_AFTER_DAYS` | `3` days |
| Unprocessed (never marked) | older than the window is deleted | `UNPROCESSED_PURGE_AFTER_DAYS` | `90` days |

- The longer window for unprocessed events is intentional: unprocessed means
user-unseen, so the safety margin before dropping is wide (the 7-day vs 90-day
user-unseen, so the safety margin before dropping is wide (the 3-day vs 90-day
asymmetry is by design).
- The sweep runs via a Durable Object Alarm on a daily cadence and reschedules
itself, so it fires independently of consumption. Processed events are also
Expand Down
8 changes: 4 additions & 4 deletions docs/0-requirements.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@ GitHub --POST--> Cloudflare Worker --> TenantRegistry DO
| F2.1 | イベントを UUID 付きで WebhookStore DO の SQLite に保存する |
| F2.2 | 各イベントは id, type, payload, received_at, processed フィールドを持つ |
| F2.3 | trigger_status, last_triggered_at フィールドを保持する(将来の trigger 機能用) |
| F2.4 | `mark_processed` 実行時に、`processed=1` かつ `received_at` が保持期間(`PURGE_AFTER_DAYS` 日、既定 7)より古いイベントを自動削除する。処理済み死蔵行による DO ストレージ肥大を防ぐ(即時性のための補助経路。本質的な保証は F2.5 の Alarm sweep が担う) |
| F2.5 | DO Alarm(`ctx.storage.setAlarm` + `alarm()` ハンドラ)による時間駆動の retention sweep を提供する。sweep は (a) `processed=1` かつ `received_at` が `PURGE_AFTER_DAYS` 日(既定 7)より古い処理済みイベントと、(b) `processed=0` かつ `received_at` が `UNPROCESSED_PURGE_AFTER_DAYS` 日(既定 90)より古い未処理イベントを、まとめて削除する。alarm() は実行後に次回 sweep を再スケジュールし、消費ゼロ(`mark_processed` が一度も呼ばれない放置テナント)でも周期実行(日次)される。未処理保持期間を処理済みより長く取るのは未読データ消失の安全域確保のため(7 日 / 90 日の非対称は意図的)。既知の限界(スコープ外): 時間窓は「古さ」を縛るが「量」は縛らないため、高頻度 × 無消費テナントは 90 日窓があっても Cloudflare の 1GB/DO 壁に先に到達しうる。量ベース hard cap は別 issue 候補 |
| F2.4 | `mark_processed` 実行時に、`processed=1` かつ `received_at` が保持期間(`PURGE_AFTER_DAYS` 日、既定 3)より古いイベントを自動削除する。処理済み死蔵行による DO ストレージ肥大を防ぐ(即時性のための補助経路。本質的な保証は F2.5 の Alarm sweep が担う) |
| F2.5 | DO Alarm(`ctx.storage.setAlarm` + `alarm()` ハンドラ)による時間駆動の retention sweep を提供する。sweep は (a) `processed=1` かつ `received_at` が `PURGE_AFTER_DAYS` 日(既定 3)より古い処理済みイベントと、(b) `processed=0` かつ `received_at` が `UNPROCESSED_PURGE_AFTER_DAYS` 日(既定 90)より古い未処理イベントを、まとめて削除する。alarm() は実行後に次回 sweep を再スケジュールし、消費ゼロ(`mark_processed` が一度も呼ばれない放置テナント)でも周期実行(日次)される。未処理保持期間を処理済みより長く取るのは未読データ消失の安全域確保のため(3 日 / 90 日の非対称は意図的)。既知の限界(スコープ外): 時間窓は「古さ」を縛るが「量」は縛らないため、高頻度 × 無消費テナントは 90 日窓があっても Cloudflare の 1GB/DO 壁に先に到達しうる。量ベース hard cap は別 issue 候補 |

**イベント構造:**

Expand Down Expand Up @@ -204,8 +204,8 @@ Worker は GitHub の web OAuth flow をホストする独自実装を備える
| N2.4 | カスタムドメイン | `github-webhook.smgjp.com` | Cloudflare Worker のカスタムドメインとして設定済み |
| N2.5 | 認証方式 | Worker 自前認証 | Cloudflare Access は使用しない。Worker が webhook secret + Worker-hosted web OAuth で認証を処理する |
| N2.6 | プレビューインスタンス | `preview` 環境 | 本番と同一構成の検証用インスタンス |
| N2.7 | 処理済みイベント保持期間 | `PURGE_AFTER_DAYS` 環境変数(wrangler.toml `[vars]`) | `7`(日)。`mark_processed` 時および DO Alarm sweep 時に保持期間超過の処理済みイベントを削除。`0` で処理済みを即削除 |
| N2.8 | 未処理イベント保持期間 | `UNPROCESSED_PURGE_AFTER_DAYS` 環境変数(wrangler.toml `[vars]`) | `90`(日)。DO Alarm sweep 時に保持期間超過の未処理イベントを削除。`0` で未処理を即削除。処理済み(7 日)より長いのは未読データ消失の安全域(非対称は意図的) |
| N2.7 | 処理済みイベント保持期間 | `PURGE_AFTER_DAYS` 環境変数(wrangler.toml `[vars]`) | `3`(日)。`mark_processed` 時および DO Alarm sweep 時に保持期間超過の処理済みイベントを削除。`0` で処理済みを即削除 |
| N2.8 | 未処理イベント保持期間 | `UNPROCESSED_PURGE_AFTER_DAYS` 環境変数(wrangler.toml `[vars]`) | `90`(日)。DO Alarm sweep 時に保持期間超過の未処理イベントを削除。`0` で未処理を即削除。処理済み(3 日)より長いのは未読データ消失の安全域(非対称は意図的) |

### N3. 制約

Expand Down
8 changes: 4 additions & 4 deletions docs/0-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@ GitHub ──POST──▶ Cloudflare Worker ──▶ TenantRegistry DO
| F2.1 | イベントを UUID 付きで WebhookStore DO の SQLite に保存する |
| F2.2 | 各イベントは id, type, payload, received_at, processed フィールドを持つ |
| F2.3 | trigger_status, last_triggered_at フィールドを保持する(将来の trigger 機能用) |
| F2.4 | `mark_processed` 実行時に、`processed=1` かつ `received_at` が保持期間(`PURGE_AFTER_DAYS` 日、既定 7)より古いイベントを自動削除する。処理済み死蔵行による DO ストレージ肥大を防ぐ(即時性のための補助経路。本質的な保証は F2.5 の Alarm sweep が担う) |
| F2.5 | DO Alarm(`ctx.storage.setAlarm` + `alarm()` ハンドラ)による時間駆動の retention sweep を提供する。sweep は (a) `processed=1` かつ `received_at` が `PURGE_AFTER_DAYS` 日(既定 7)より古い処理済みイベントと、(b) `processed=0` かつ `received_at` が `UNPROCESSED_PURGE_AFTER_DAYS` 日(既定 90)より古い未処理イベントを、まとめて削除する。alarm() は実行後に次回 sweep を再スケジュールし、消費ゼロ(`mark_processed` が一度も呼ばれない放置テナント)でも周期実行(日次)される。未処理保持期間を処理済みより長く取るのは未読データ消失の安全域確保のため(7 日 / 90 日の非対称は意図的)。既知の限界(スコープ外): 時間窓は「古さ」を縛るが「量」は縛らないため、高頻度 × 無消費テナントは 90 日窓があっても Cloudflare の 1GB/DO 壁に先に到達しうる。量ベース hard cap は別 issue 候補 |
| F2.4 | `mark_processed` 実行時に、`processed=1` かつ `received_at` が保持期間(`PURGE_AFTER_DAYS` 日、既定 3)より古いイベントを自動削除する。処理済み死蔵行による DO ストレージ肥大を防ぐ(即時性のための補助経路。本質的な保証は F2.5 の Alarm sweep が担う) |
| F2.5 | DO Alarm(`ctx.storage.setAlarm` + `alarm()` ハンドラ)による時間駆動の retention sweep を提供する。sweep は (a) `processed=1` かつ `received_at` が `PURGE_AFTER_DAYS` 日(既定 3)より古い処理済みイベントと、(b) `processed=0` かつ `received_at` が `UNPROCESSED_PURGE_AFTER_DAYS` 日(既定 90)より古い未処理イベントを、まとめて削除する。alarm() は実行後に次回 sweep を再スケジュールし、消費ゼロ(`mark_processed` が一度も呼ばれない放置テナント)でも周期実行(日次)される。未処理保持期間を処理済みより長く取るのは未読データ消失の安全域確保のため(3 日 / 90 日の非対称は意図的)。既知の限界(スコープ外): 時間窓は「古さ」を縛るが「量」は縛らないため、高頻度 × 無消費テナントは 90 日窓があっても Cloudflare の 1GB/DO 壁に先に到達しうる。量ベース hard cap は別 issue 候補 |

**イベント構造:**

Expand Down Expand Up @@ -204,8 +204,8 @@ Worker は GitHub の web OAuth flow をホストする独自実装を備える
| N2.4 | カスタムドメイン | `github-webhook.smgjp.com` | Cloudflare Worker のカスタムドメインとして設定済み |
| N2.5 | 認証方式 | Worker 自前認証 | Cloudflare Access は使用しない。Worker が webhook secret + Worker-hosted web OAuth で認証を処理する |
| N2.6 | プレビューインスタンス | `preview` 環境 | 本番と同一構成の検証用インスタンス |
| N2.7 | 処理済みイベント保持期間 | `PURGE_AFTER_DAYS` 環境変数(wrangler.toml `[vars]`) | `7`(日)。`mark_processed` 時および DO Alarm sweep 時に保持期間超過の処理済みイベントを削除。`0` で処理済みを即削除 |
| N2.8 | 未処理イベント保持期間 | `UNPROCESSED_PURGE_AFTER_DAYS` 環境変数(wrangler.toml `[vars]`) | `90`(日)。DO Alarm sweep 時に保持期間超過の未処理イベントを削除。`0` で未処理を即削除。処理済み(7 日)より長いのは未読データ消失の安全域(非対称は意図的) |
| N2.7 | 処理済みイベント保持期間 | `PURGE_AFTER_DAYS` 環境変数(wrangler.toml `[vars]`) | `3`(日)。`mark_processed` 時および DO Alarm sweep 時に保持期間超過の処理済みイベントを削除。`0` で処理済みを即削除 |
| N2.8 | 未処理イベント保持期間 | `UNPROCESSED_PURGE_AFTER_DAYS` 環境変数(wrangler.toml `[vars]`) | `90`(日)。DO Alarm sweep 時に保持期間超過の未処理イベントを削除。`0` で未処理を即削除。処理済み(3 日)より長いのは未読データ消失の安全域(非対称は意図的) |

**GitHub Webhook 購読イベント:**

Expand Down
4 changes: 2 additions & 2 deletions mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,10 @@ The Worker purges stored events automatically so Durable Object storage stays bo

| Event class | Retention window | Worker env var | Default |
|---|---|---|---|
| Processed (`mark_processed` called) | events older than the window are deleted | `PURGE_AFTER_DAYS` | `7` days |
| Processed (`mark_processed` called) | events older than the window are deleted | `PURGE_AFTER_DAYS` | `3` days |
| Unprocessed (never marked) | events older than the window are deleted | `UNPROCESSED_PURGE_AFTER_DAYS` | `90` days |

- The longer window for unprocessed events is intentional — unprocessed means user-unseen, so the margin before dropping is wide (the 7-day vs 90-day asymmetry is by design).
- The longer window for unprocessed events is intentional — unprocessed means user-unseen, so the margin before dropping is wide (the 3-day vs 90-day asymmetry is by design).
- Processed events are also purged immediately on `mark_processed` for promptness; the Alarm sweep is the guarantee that covers tenants that stop consuming.
- Both windows are Worker-side configuration (`worker/wrangler.toml` `[vars]`); set a value to `0` to purge that class immediately on sweep. These env vars live on the Worker, not in this proxy.
- Known limitation: the windows bound event *age*, not *volume*. A high-rate, never-consumed tenant can still hit Cloudflare's 1 GB-per-DO ceiling before the 90-day window applies.
Expand Down
6 changes: 3 additions & 3 deletions worker/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ interface StoreEnv {
}

/** Default retention window (days) for processed events when PURGE_AFTER_DAYS is unset. */
const DEFAULT_PURGE_DAYS = 7;
const DEFAULT_PURGE_DAYS = 3;

/**
* Default retention window (days) for UNPROCESSED events when
* UNPROCESSED_PURGE_AFTER_DAYS is unset. Deliberately much longer than the
* processed window: unprocessed = user-unseen, so the safety margin before
* silently dropping is wide (the 7d-vs-90d asymmetry is intentional).
* silently dropping is wide (the 3d-vs-90d asymmetry is intentional).
*/
const DEFAULT_UNPROCESSED_PURGE_DAYS = 90;

Expand Down Expand Up @@ -100,7 +100,7 @@ export class WebhookStore extends DurableObject<StoreEnv> {

/**
* Time-based retention sweep. Deletes:
* - processed events older than PURGE_AFTER_DAYS (default 7), and
* - processed events older than PURGE_AFTER_DAYS (default 3), and
* - UNPROCESSED events older than UNPROCESSED_PURGE_AFTER_DAYS (default 90).
* Returns the per-class purged counts. Bounds DO storage growth from the only
* remaining unbounded path (unprocessed rows on abandoned tenants, #236).
Expand Down
12 changes: 6 additions & 6 deletions worker/test/workers/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ describe("WebhookStore: get_event (/event?id=)", () => {
});

// received_at relative to the real wall-clock "now". The DO purges processed
// events older than PURGE_AFTER_DAYS (default 7); tests bind received_at relative
// events older than PURGE_AFTER_DAYS (default 3); tests bind received_at relative
// to now so they stay stable regardless of the absolute date.
function isoFromNow(deltaMs: number): string {
return new Date(Date.now() + deltaMs).toISOString();
Expand All @@ -195,7 +195,7 @@ const DAY_MS = 86_400_000;
describe("WebhookStore: mark-processed", () => {
it("flips processed so a within-retention event drops out of pending but remains fetchable by id, returning purged=0", async () => {
const stub = storeFor("mark-processed");
// received_at is recent (1 day ago) so it survives the default 7-day purge window
// received_at is recent (1 day ago) so it survives the default 3-day purge window
await ingest(stub, makeEvent({ id: "m1", type: "issues", received_at: isoFromNow(-1 * DAY_MS) }));

const mp = await stub.fetch(
Expand Down Expand Up @@ -223,7 +223,7 @@ describe("WebhookStore: mark-processed", () => {
describe("WebhookStore: mark-processed auto-purge", () => {
it("deletes processed events older than the retention window and reports the purged count", async () => {
const stub = storeFor("purge-old-processed");
// An old, already-processed event (30 days ago, beyond the 7-day window)
// An old, already-processed event (30 days ago, beyond the 3-day window)
await ingest(
stub,
makeEvent({ id: "old", type: "issues", received_at: isoFromNow(-30 * DAY_MS), processed: true }),
Expand Down Expand Up @@ -314,9 +314,9 @@ describe("WebhookStore: time-based sweep (DO Alarm body)", () => {
expect(status.pending_count).toBe(1);
});

it("purges PROCESSED events older than PURGE_AFTER_DAYS (default 7) in the same sweep — no mark_processed call required", async () => {
it("purges PROCESSED events older than PURGE_AFTER_DAYS (default 3) in the same sweep — no mark_processed call required", async () => {
const stub = storeFor("sweep-old-processed");
// 30 days old, already processed → beyond the 7-day window → swept by the
// 30 days old, already processed → beyond the 3-day window → swept by the
// time-based sweep without any mark_processed trigger
await ingest(stub, makeEvent({ id: "doneold", type: "issues", received_at: isoFromNow(-30 * DAY_MS), processed: true }));
// fresh processed event stays
Expand All @@ -332,7 +332,7 @@ describe("WebhookStore: time-based sweep (DO Alarm body)", () => {
expect(keptRes.status).toBe(200);
});

it("sweeps processed (>7d) and unprocessed (>90d) together while keeping in-window rows of both classes", async () => {
it("sweeps processed (>3d) and unprocessed (>90d) together while keeping in-window rows of both classes", async () => {
const stub = storeFor("sweep-mixed");
await ingest(stub, makeEvent({ id: "p-old", received_at: isoFromNow(-30 * DAY_MS), processed: true })); // swept
await ingest(stub, makeEvent({ id: "p-new", received_at: isoFromNow(-2 * DAY_MS), processed: true })); // kept
Expand Down
2 changes: 1 addition & 1 deletion worker/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ compatibility_flags = ["nodejs_compat"]
# Retention window (days) for processed webhook events. On mark_processed and on
# the periodic DO Alarm sweep, the WebhookStore DO deletes processed events older
# than this to bound DO storage. 0 = purge processed events immediately.
PURGE_AFTER_DAYS = "7"
PURGE_AFTER_DAYS = "3"
# Retention window (days) for UNPROCESSED webhook events. The DO Alarm sweep
# deletes unprocessed events older than this. Much longer than the processed
# window because unprocessed = user-unseen (the asymmetry is intentional). This
Expand Down
Loading