| author | |
| committer | |
| log | 7b32b2ca39892bc59e1d8cfff7aea2a8fbefa73a |
| tree | 857b9827d18d5c603e7806548f13dc97750ffe56 |
| parent | 18c1210810cbdb798d28edda1826fa4de91992d1 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>4 files changed, 218 insertions(+), 1 deletions(-)
infra/relay/package.json+1| ... | ... | @@ -6,6 +6,7 @@ |
| 6 | 6 | "deploy": "alchemy deploy", |
| 7 | 7 | "push:android:smoke": "node scripts/android-push-smoke.ts", |
| 8 | 8 | "push:android:watch": "node scripts/android-push-watch.ts", |
| 9 | "tunnels:census": "node scripts/tunnel-census.ts", | |
| 9 | 10 | "destroy": "alchemy destroy", |
| 10 | 11 | "test": "vp test run --config ../../vite.config.ts --dir .", |
| 11 | 12 | "typecheck": "tsc --noEmit" |
infra/relay/scripts/tunnel-census.ts created+97| ... | ... | @@ -0,0 +1,97 @@ |
| 1 | // Read-only count of Cloudflare tunnels in the managed-endpoint account, | |
| 2 | // grouped by relay stage, status, and how long they have been idle. The reaper | |
| 3 | // only sees its own stage's prefix, so this shows how much of the backlog | |
| 4 | // belongs to other stages. | |
| 5 | // | |
| 6 | // CLOUDFLARE_ACCOUNT_ID=... CLOUDFLARE_API_TOKEN=... node scripts/tunnel-census.ts | |
| 7 | // | |
| 8 | // The token needs Cloudflare Tunnel read access. Nothing is modified. | |
| 9 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | |
| 10 | import * as Clock from "effect/Clock"; | |
| 11 | import * as Config from "effect/Config"; | |
| 12 | import * as Console from "effect/Console"; | |
| 13 | import * as Effect from "effect/Effect"; | |
| 14 | import * as Redacted from "effect/Redacted"; | |
| 15 | import * as Schema from "effect/Schema"; | |
| 16 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; | |
| 17 | import * as HttpClient from "effect/http/HttpClient"; | |
| 18 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; | |
| 19 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; | |
| 20 | ||
| 21 | const PREFIX = "t3coderelay-managedendpoint-"; | |
| 22 | const PAGE_SIZE = 1_000; | |
| 23 | const DAY_MS = 86_400_000; | |
| 24 | ||
| 25 | // Cloudflare documents every field here as optional, so a tunnel missing one | |
| 26 | // must not fail the whole page. | |
| 27 | const Tunnel = Schema.Struct({ | |
| 28 | name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 29 | status: Schema.optional(Schema.NullOr(Schema.String)), | |
| 30 | created_at: Schema.optional(Schema.NullOr(Schema.String)), | |
| 31 | conns_inactive_at: Schema.optional(Schema.NullOr(Schema.String)), | |
| 32 | }); | |
| 33 | type Tunnel = typeof Tunnel.Type; | |
| 34 | const TunnelPage = Schema.Struct({ result: Schema.Array(Tunnel) }); | |
| 35 | ||
| 36 | // "t3coderelay-managedendpoint-<stage>-<16 hex>"; stages may contain hyphens. | |
| 37 | const stageOf = (name: string | null | undefined) => | |
| 38 | name ? name.slice(PREFIX.length).replace(/-[a-f0-9]{16}$/u, "") : "unknown"; | |
| 39 | ||
| 40 | // Like the reaper: a down tunnel is aged from when it lost its connector, and a | |
| 41 | // never-connected (inactive) one from when it was created. | |
| 42 | const ageBucket = (tunnel: Tunnel, now: number) => { | |
| 43 | const since = Date.parse( | |
| 44 | (tunnel.status === "down" ? tunnel.conns_inactive_at : tunnel.created_at) ?? "", | |
| 45 | ); | |
| 46 | if (Number.isNaN(since)) return "unknown"; | |
| 47 | const days = (now - since) / DAY_MS; | |
| 48 | if (days > 90) return ">90d"; | |
| 49 | if (days > 30) return ">30d"; | |
| 50 | if (days > 7) return ">7d"; | |
| 51 | return "<=7d"; | |
| 52 | }; | |
| 53 | ||
| 54 | const main = Effect.gen(function* () { | |
| 55 | const accountId = yield* Config.String("CLOUDFLARE_ACCOUNT_ID"); | |
| 56 | const token = yield* Config.Redacted("CLOUDFLARE_API_TOKEN"); | |
| 57 | const client = yield* HttpClient.HttpClient; | |
| 58 | const now = yield* Clock.currentTimeMillis; | |
| 59 | ||
| 60 | const listPage = (page: number) => | |
| 61 | HttpClientRequest.get(`https://api.cloudflare.com/client/v4/accounts/${accountId}/cfd_tunnel`, { | |
| 62 | urlParams: { | |
| 63 | is_deleted: "false", | |
| 64 | include_prefix: PREFIX, | |
| 65 | per_page: String(PAGE_SIZE), | |
| 66 | page: String(page), | |
| 67 | }, | |
| 68 | }).pipe( | |
| 69 | HttpClientRequest.bearerToken(Redacted.value(token)), | |
| 70 | client.execute, | |
| 71 | Effect.flatMap(HttpClientResponse.filterStatusOk), | |
| 72 | Effect.flatMap(HttpClientResponse.schemaBodyJson(TunnelPage)), | |
| 73 | Effect.map((body) => body.result), | |
| 74 | ); | |
| 75 | ||
| 76 | const counts = new Map<string, number>(); | |
| 77 | let total = 0; | |
| 78 | for (let page = 1; ; page += 1) { | |
| 79 | const tunnels = yield* listPage(page); | |
| 80 | for (const tunnel of tunnels) { | |
| 81 | total += 1; | |
| 82 | const idle = | |
| 83 | tunnel.status === "down" || tunnel.status === "inactive" ? ageBucket(tunnel, now) : "-"; | |
| 84 | const key = `${stageOf(tunnel.name)}\t${tunnel.status ?? "unknown"}\t${idle}`; | |
| 85 | counts.set(key, (counts.get(key) ?? 0) + 1); | |
| 86 | } | |
| 87 | if (tunnels.length < PAGE_SIZE) break; | |
| 88 | } | |
| 89 | ||
| 90 | yield* Console.log("stage\tstatus\tidle\tcount"); | |
| 91 | for (const [key, count] of [...counts].sort(([a], [b]) => a.localeCompare(b))) { | |
| 92 | yield* Console.log(`${key}\t${count}`); | |
| 93 | } | |
| 94 | yield* Console.log(`total\t\t\t${total}`); | |
| 95 | }).pipe(Effect.provide(FetchHttpClient.layer)); | |
| 96 | ||
| 97 | NodeRuntime.runMain(main); |
infra/relay/src/environments/ManagedEndpointReaper.test.ts+61| ... | ... | @@ -436,6 +436,60 @@ describe("ManagedEndpointReaper", () => { |
| 436 | 436 | }).pipe(Effect.provide(state.layer)); |
| 437 | 437 | }); |
| 438 | 438 | |
| 439 | it.effect("measures legacy age, uncounted skips, and Cloudflare totals", () => { | |
| 440 | const down = (id: string, suffix: string, timestamp: string) => | |
| 441 | tunnel({ id, suffix, status: "down", timestamp }); | |
| 442 | const tunnels = [ | |
| 443 | // Legacy owners, down for 1, 10, 40, and 100 days, plus one just past | |
| 444 | // seven days, which must already count as over seven. | |
| 445 | down("legacy-1d", "1111111111111111", "2026-08-24T11:00:00.000Z"), | |
| 446 | down("legacy-7d1h", "7777777777777777", "2026-08-18T11:00:00.000Z"), | |
| 447 | down("legacy-10d", "2222222222222222", "2026-08-15T11:00:00.000Z"), | |
| 448 | down("legacy-40d", "3333333333333333", "2026-07-16T11:00:00.000Z"), | |
| 449 | down("legacy-100d", "4444444444444444", "2026-05-17T11:00:00.000Z"), | |
| 450 | // The allocation now records a different tunnel under this name. | |
| 451 | down("stale", "5555555555555555", "2026-08-25T11:00:00.000Z"), | |
| 452 | // The allocation has not recorded a tunnel yet. | |
| 453 | down("pending", "6666666666666666", "2026-08-25T11:00:00.000Z"), | |
| 454 | ]; | |
| 455 | const state = harness({ | |
| 456 | tunnels, | |
| 457 | allocations: [ | |
| 458 | allocation({ tunnelId: "legacy-1d", recoveryEnabled: false }), | |
| 459 | allocation({ tunnelId: "legacy-7d1h", recoveryEnabled: false }), | |
| 460 | allocation({ tunnelId: "legacy-10d", recoveryEnabled: false }), | |
| 461 | allocation({ tunnelId: "legacy-40d", recoveryEnabled: false }), | |
| 462 | allocation({ tunnelId: "legacy-100d", recoveryEnabled: false }), | |
| 463 | { | |
| 464 | ...allocation({ tunnelId: "current", recoveryEnabled: true }), | |
| 465 | tunnelName: `${PREFIX}5555555555555555`, | |
| 466 | }, | |
| 467 | { | |
| 468 | ...allocation({ tunnelId: null, recoveryEnabled: false }), | |
| 469 | tunnelName: `${PREFIX}6666666666666666`, | |
| 470 | }, | |
| 471 | ], | |
| 472 | cleanupMode: "dry-run", | |
| 473 | }); | |
| 474 | ||
| 475 | return Effect.gen(function* () { | |
| 476 | yield* TestClock.setTime(NOW_MILLIS); | |
| 477 | const reaper = yield* ManagedEndpointReaper.ManagedEndpointReaper; | |
| 478 | expect(yield* reaper.sweep).toMatchObject({ | |
| 479 | scanned: 7, | |
| 480 | skippedLegacy: 5, | |
| 481 | legacyOver7Days: 4, | |
| 482 | legacyOver30Days: 2, | |
| 483 | legacyOver90Days: 1, | |
| 484 | skippedReplaced: 1, | |
| 485 | skippedUnrecorded: 1, | |
| 486 | totalDown: 7, | |
| 487 | totalInactive: 0, | |
| 488 | wouldDelete: 0, | |
| 489 | }); | |
| 490 | }).pipe(Effect.provide(state.layer)); | |
| 491 | }); | |
| 492 | ||
| 439 | 493 | it.effect("does not count a tunnel that was replaced before its release", () => { |
| 440 | 494 | const state = harness({ |
| 441 | 495 | tunnels: [ |
| ... | ... | @@ -649,7 +703,14 @@ describe("ManagedEndpointReaper", () => { |
| 649 | 703 | deleted: 0, |
| 650 | 704 | wouldDelete: 0, |
| 651 | 705 | skippedLegacy: 0, |
| 706 | legacyOver7Days: 0, | |
| 707 | legacyOver30Days: 0, | |
| 708 | legacyOver90Days: 0, | |
| 652 | 709 | skippedOrphan: 0, |
| 710 | skippedReplaced: 0, | |
| 711 | skippedUnrecorded: 0, | |
| 712 | totalDown: null, | |
| 713 | totalInactive: null, | |
| 653 | 714 | failed: 0, |
| 654 | 715 | truncated: false, |
| 655 | 716 | }); |
infra/relay/src/environments/ManagedEndpointReaper.ts+59-1| ... | ... | @@ -16,6 +16,9 @@ export const MANAGED_ENDPOINT_INACTIVE_GRACE_PERIOD_MINUTES = 60; |
| 16 | 16 | export const MANAGED_ENDPOINT_SWEEP_PAGE_SIZE = 100; |
| 17 | 17 | export const MANAGED_ENDPOINT_SWEEP_ATTEMPT_LIMIT = 100; |
| 18 | 18 | export const MANAGED_ENDPOINT_SWEEP_LIST_REQUEST_LIMIT = 10; |
| 19 | // Age buckets for legacy candidates, in days since the tunnel went down (or | |
| 20 | // was created, for one that never connected). | |
| 21 | const MANAGED_ENDPOINT_LEGACY_AGE_BUCKET_DAYS = [7, 30, 90] as const; | |
| 19 | 22 | |
| 20 | 23 | export interface ManagedEndpointSweepResult { |
| 21 | 24 | readonly mode: RelayConfiguration.ManagedEndpointCleanupMode; |
| ... | ... | @@ -25,7 +28,18 @@ export interface ManagedEndpointSweepResult { |
| 25 | 28 | readonly deleted: number; |
| 26 | 29 | readonly wouldDelete: number; |
| 27 | 30 | readonly skippedLegacy: number; |
| 31 | /** Legacy candidates, by days since they went down: over 7, 30, and 90. */ | |
| 32 | readonly legacyOver7Days: number; | |
| 33 | readonly legacyOver30Days: number; | |
| 34 | readonly legacyOver90Days: number; | |
| 28 | 35 | readonly skippedOrphan: number; |
| 36 | /** The allocation row records a different tunnel under this name. */ | |
| 37 | readonly skippedReplaced: number; | |
| 38 | /** The allocation row has not recorded a tunnel yet. */ | |
| 39 | readonly skippedUnrecorded: number; | |
| 40 | /** Cloudflare's count of matching tunnels, before page limits. */ | |
| 41 | readonly totalDown: number | null; | |
| 42 | readonly totalInactive: number | null; | |
| 29 | 43 | readonly failed: number; |
| 30 | 44 | readonly truncated: boolean; |
| 31 | 45 | } |
| ... | ... | @@ -70,6 +84,19 @@ function isExpiredManagedTunnel(input: { |
| 70 | 84 | return Option.isSome(timestamp) && timestamp.value.epochMilliseconds <= cutoff.epochMilliseconds; |
| 71 | 85 | } |
| 72 | 86 | |
| 87 | /** Days (fractional) since the tunnel went down, or was created if it never connected. */ | |
| 88 | function inactiveDaysAt( | |
| 89 | tunnel: ManagedEndpointProvider.ManagedEndpointTunnel, | |
| 90 | status: "down" | "inactive", | |
| 91 | now: DateTime.Utc, | |
| 92 | ): number | null { | |
| 93 | const since = status === "down" ? tunnel.connsInactiveAt : tunnel.createdAt; | |
| 94 | if (typeof since !== "string") return null; | |
| 95 | const timestamp = DateTime.make(since); | |
| 96 | if (Option.isNone(timestamp)) return null; | |
| 97 | return (now.epochMilliseconds - timestamp.value.epochMilliseconds) / 86_400_000; | |
| 98 | } | |
| 99 | ||
| 73 | 100 | function isRateLimited(cause: unknown): boolean { |
| 74 | 101 | if (typeof cause !== "object" || cause === null) { |
| 75 | 102 | return false; |
| ... | ... | @@ -112,7 +139,14 @@ const emptyResult = ( |
| 112 | 139 | deleted: 0, |
| 113 | 140 | wouldDelete: 0, |
| 114 | 141 | skippedLegacy: 0, |
| 142 | legacyOver7Days: 0, | |
| 143 | legacyOver30Days: 0, | |
| 144 | legacyOver90Days: 0, | |
| 115 | 145 | skippedOrphan: 0, |
| 146 | skippedReplaced: 0, | |
| 147 | skippedUnrecorded: 0, | |
| 148 | totalDown: null, | |
| 149 | totalInactive: null, | |
| 116 | 150 | failed: 0, |
| 117 | 151 | truncated: false, |
| 118 | 152 | }); |
| ... | ... | @@ -142,6 +176,7 @@ export const make = Effect.gen(function* () { |
| 142 | 176 | ); |
| 143 | 177 | let listRequests = 0; |
| 144 | 178 | let truncated = false; |
| 179 | const totals: Record<"down" | "inactive", number | null> = { down: null, inactive: null }; | |
| 145 | 180 | const expired: Array<{ |
| 146 | 181 | readonly tunnel: ManagedEndpointProvider.ManagedEndpointTunnel & { |
| 147 | 182 | readonly id: string; |
| ... | ... | @@ -169,6 +204,7 @@ export const make = Effect.gen(function* () { |
| 169 | 204 | const first = yield* listPage(1); |
| 170 | 205 | const totalCount = |
| 171 | 206 | typeof first.resultInfo?.totalCount === "number" ? first.resultInfo.totalCount : undefined; |
| 207 | totals[status] = totalCount ?? null; | |
| 172 | 208 | const pages = rotatedPages({ |
| 173 | 209 | totalCount, |
| 174 | 210 | slot, |
| ... | ... | @@ -213,7 +249,12 @@ export const make = Effect.gen(function* () { |
| 213 | 249 | let deleted = 0; |
| 214 | 250 | let wouldDelete = 0; |
| 215 | 251 | let skippedLegacy = 0; |
| 252 | const legacyOverDays = new Map<number, number>( | |
| 253 | MANAGED_ENDPOINT_LEGACY_AGE_BUCKET_DAYS.map((days) => [days, 0]), | |
| 254 | ); | |
| 216 | 255 | let skippedOrphan = 0; |
| 256 | let skippedReplaced = 0; | |
| 257 | let skippedUnrecorded = 0; | |
| 217 | 258 | let failed = 0; |
| 218 | 259 | |
| 219 | 260 | for (const { tunnel, status, cutoff } of uniqueExpired) { |
| ... | ... | @@ -224,14 +265,24 @@ export const make = Effect.gen(function* () { |
| 224 | 265 | allocation.tunnelId !== null && |
| 225 | 266 | allocation.tunnelId !== tunnel.id |
| 226 | 267 | ) { |
| 268 | skippedReplaced += 1; | |
| 227 | 269 | continue; |
| 228 | 270 | } |
| 229 | 271 | const owner = allocation?.tunnelId === tunnel.id ? allocation : undefined; |
| 230 | 272 | if (owner !== undefined && !owner.recoveryEnabled) { |
| 231 | 273 | skippedLegacy += 1; |
| 274 | const inactiveDays = inactiveDaysAt(tunnel, status, now); | |
| 275 | for (const days of MANAGED_ENDPOINT_LEGACY_AGE_BUCKET_DAYS) { | |
| 276 | if (inactiveDays !== null && inactiveDays > days) { | |
| 277 | legacyOverDays.set(days, (legacyOverDays.get(days) ?? 0) + 1); | |
| 278 | } | |
| 279 | } | |
| 280 | continue; | |
| 281 | } | |
| 282 | if (allocation !== undefined && owner === undefined) { | |
| 283 | skippedUnrecorded += 1; | |
| 232 | 284 | continue; |
| 233 | 285 | } |
| 234 | if (allocation !== undefined && owner === undefined) continue; | |
| 235 | 286 | // A tunnel with no allocation row cannot be claimed, so a relink that |
| 236 | 287 | // adopts it by name races any delete here. Count it and leave it for a |
| 237 | 288 | // manual sweep instead. |
| ... | ... | @@ -284,7 +335,14 @@ export const make = Effect.gen(function* () { |
| 284 | 335 | deleted, |
| 285 | 336 | wouldDelete, |
| 286 | 337 | skippedLegacy, |
| 338 | legacyOver7Days: legacyOverDays.get(7) ?? 0, | |
| 339 | legacyOver30Days: legacyOverDays.get(30) ?? 0, | |
| 340 | legacyOver90Days: legacyOverDays.get(90) ?? 0, | |
| 287 | 341 | skippedOrphan, |
| 342 | skippedReplaced, | |
| 343 | skippedUnrecorded, | |
| 344 | totalDown: totals.down, | |
| 345 | totalInactive: totals.inactive, | |
| 288 | 346 | failed, |
| 289 | 347 | truncated, |
| 290 | 348 | }; |