├── .gitignore ├── README.md ├── controlled-recursive-do ├── .dev.vars ├── README.md ├── main.ts ├── package-lock.json ├── package.json ├── tsconfig.json └── wrangler.toml ├── highly-recursive-do ├── README.md ├── package-lock.json ├── package.json ├── tsconfig.json ├── worker.ts └── wrangler.toml ├── recursive-do ├── README.md ├── package-lock.json ├── package.json ├── tsconfig.json ├── worker.ts └── wrangler.toml ├── with-alternate ├── nested_fetch_worker │ ├── main.ts │ ├── package-lock.json │ ├── package.json │ ├── tsconfig.json │ ├── worker-configuration.d.ts │ └── wrangler.toml ├── nested_fetch_worker_alternate │ ├── main.ts │ ├── package-lock.json │ ├── package.json │ ├── tsconfig.json │ ├── worker-configuration.d.ts │ └── wrangler.toml └── slow-worker │ ├── main.ts │ ├── package.json │ └── wrangler.toml ├── with-binding ├── nested_fetch_worker │ ├── main.ts │ ├── package-lock.json │ ├── package.json │ ├── tsconfig.json │ ├── worker-configuration.d.ts │ └── wrangler.toml └── slow-worker │ ├── main.ts │ ├── package.json │ └── wrangler.toml ├── with-deno ├── call-directly-alternate │ ├── deno.json │ └── main.ts ├── call-directly │ ├── deno.json │ └── main.ts ├── locally.ts └── slow-worker │ ├── main.ts │ ├── package.json │ └── wrangler.toml ├── with-do ├── README.md ├── package-lock.json ├── package.json ├── tsconfig.json ├── worker.ts └── wrangler.toml └── with-do6 ├── README.md ├── package-lock.json ├── package.json ├── tsconfig.json ├── worker.ts └── wrangler.toml /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .wrangler 3 | .DS_Store -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | I did some experiments to see if I could bypass the queue and simply directly do 1000s of fetch requests by nesting them in workers. 2 | 3 | Experiments carried out: 4 | 5 | 1. tried a nested cloudflare worker that calls itself recursively to perform parts of the fetch. this showed that the max concurrent requests will stay for both the main requests and all subrequests together, so we can't surpass it this way. 6 | 2. tried to do that same thing but using alternate workers that call each other. it seems that this causes it to crash, so isn't stable either. 7 | 3. tried deno deploy with the same code (slightly altered). this shows there is a limit of 100 concurrent fetch requests. when trying to fetch itself, we get LOOP_DETECTED. when running 10 fetches from my local machine to deno they all seem to land in the same worker because the concurrency doesn't increase. 8 | 9 | Conclusion for now: 10 | 11 | - queues remain the best way to distribute workload 12 | - in deno, a queue likely doesn't have the same concurrency (its purpose is to deliver high workloads) and may be faster than in cloudflare. i could test this at another time https://docs.deno.com/deploy/kv/manual/queue_overview/#queue-behavior 13 | 14 | Also, a conclusion is that it's actually quite annoying that it's not possible to easily do recursive requests in workers. I believe it should be simpler and you should not immediately get an error for "Loop detected" or stuff like that, because sometimes it's just important and useful to be able to do such things. 15 | 16 | And create stronger patterns that can make it possible to do more with workers. Now we are bound to and required to use things like the queue, and that's not needed, because if you can just recursively call workers, you can do these patterns in other ways that make them potentially faster, more performant, more cheap. Yeah, and I think I think queues are not perfect in every scenario. But anyway, that's just my opinion. And I think there's definitely a lot that can be improved for workers, but for now we need to do it with the queues system that we were given. 17 | 18 | # update 9 january, 2025 19 | 20 | Created 4 more experiments; see the README in the respective folders for more details. 21 | 22 | - [`with-do`](with-do) uses a cloudflare [Durable Object](https://developers.cloudflare.com/durable-objects/) to perform fetch requests. Limited to 500 23 | - [`with-do6`](with-do6) uses a cloudflare [Durable Object](https://developers.cloudflare.com/durable-objects/) to perform 6 fetch requests each. Limited to 3000 24 | - [`recursive-do`](recursive-do) uses a cloudflare [Durable Object](https://developers.cloudflare.com/durable-objects/) to create more 'creator DOs', recursively, until it can create up to 500 requester DOs. 25 | - [`highly-recursive-do`](highly-recursive-do) adds exponential backoff, better error handling, and BRANCHES_PER_LAYER to control how deep the DO's will nest, resulting in successfully do 100.000 HTTP requests to an exernal API in at least 11.6 seconds. 26 | 27 | # update january 16, 2025 28 | 29 | I've created [dodfetch](https://github.com/janwilmake/dodfetch) based off of this work. 30 | 31 | However, i've become aware that this function shall be handled with care due to the cost it can bring about. The thing is, [DO's charge a large amount for wallclock time](https://developers.cloudflare.com/durable-objects/platform/pricing/). 32 | 33 | Let's say, an api call takes on average 1 sec. 34 | 35 | 128mb x1 sec = 0.128 GB-s 36 | 37 | Durable objects cost $12.50/million GB-s + $0.15/million requests. 38 | 39 | Therefore, **the cost of dodfetch for one million requests would be around $1.75 times the average request-time in seconds**. Because of this, it's only useful to use dodfetch for queries that happen fast. Requests to LLMs, for example, are better off using a different approach, such as a queue, because it becomes too expensive. 40 | 41 | If you run 1 million fetch requests to an LLM API that take 30 seconds each, this will cost you **$52.50**. 42 | 43 | Unfortunately, I've found out the hard way... 44 | 45 | > [!CAUTION] 46 | > Use this function with care! 47 | -------------------------------------------------------------------------------- /controlled-recursive-do/.dev.vars: -------------------------------------------------------------------------------- 1 | SECRET=test -------------------------------------------------------------------------------- /controlled-recursive-do/README.md: -------------------------------------------------------------------------------- 1 | Is it possible to alter the implementation such that we can configure the amount of URLs fetched in the base case (if(this.urls.length < FETCHES_PER_DO))? 2 | 3 | And can we somehow set the max requests per time unit? 4 | 5 | A naive Ratleimiter in the DO won't work because there is a separate ratelimit in each DO and they are in different places. a central ratleimiter DO won't work either because it would be under a too high load as I want to have more than 10k RPS. but maybe we can ratelimit just on Requests per window, not on concurrency, calculate the amount of requests per 100ms, then iterate on sending a chunk each 100ms to have proper concurrency. 6 | 7 | That could be what we do in the worker fetch handler. 8 | 9 | Result: With a controlled max concurrency, I finally achieved a million requests and it took a very reliable 208 seconds, just 8 seconds after it released the last batch. 10 | 11 | - `amount=100000, ratelimit=5000`: {"results":{"200":100000},"duration":25611} 12 | - `amount=1000000, rateLimit=5000`: {"results":{"200":1000000},"duration":208854} 13 | 14 | TODO: 15 | 16 | - Limit max concurrency to 5000rps but in a way that you can set up your own ratelimit unit 17 | - Allow passing requests and immediately getting a comprised URL and an individual URL for each response back 18 | - Allow getting these URLs after it's done 19 | - Allow a callback after its done. 20 | - Also allow retrieving all responses in the response (no url needed) 21 | - Ensure each DO retains the response and is removed 1 hour after full completion. 22 | -------------------------------------------------------------------------------- /controlled-recursive-do/main.ts: -------------------------------------------------------------------------------- 1 | import { DurableObject } from "cloudflare:workers"; 2 | 3 | // Configuration constants 4 | const BRANCHES_PER_LAYER = 10; 5 | const INITIAL_BACKOFF_MS = 100; 6 | const MAX_BACKOFF_MS = 5000; 7 | const MAX_RETRIES = 10; 8 | const JITTER_MAX_MS = 50; 9 | const WINDOW_SIZE_MS = 1000; // Time window for rate limiting 10 | const FETCHES_PER_DO = 60; // Base case threshold for direct fetching 11 | 12 | export interface Env { 13 | RECURSIVE_FETCHER: DurableObjectNamespace; 14 | SECRET: string; 15 | } 16 | 17 | // Add configuration interface 18 | export interface FetcherConfig { 19 | urls: string[]; 20 | fetchesPerDO?: number; // Optional base case threshold 21 | } 22 | 23 | // Enhanced worker implementation with rate limiting 24 | export default { 25 | async fetch(request: Request, env: Env): Promise { 26 | const url = new URL(request.url); 27 | if (url.searchParams.get("secret") !== env.SECRET) { 28 | return new Response("Please provide secret"); 29 | } 30 | 31 | const amount = parseInt(url.searchParams.get("amount") || "1"); 32 | const requestsPerWindow = parseInt( 33 | url.searchParams.get("rateLimit") || "0", 34 | ); 35 | const fetchesPerDO = parseInt( 36 | url.searchParams.get("batchSize") || String(FETCHES_PER_DO), 37 | ); 38 | 39 | const t = Date.now(); 40 | if (isNaN(amount) || amount < 1) { 41 | return new Response("Invalid amount parameter", { status: 400 }); 42 | } 43 | 44 | const urls = Array.from( 45 | { length: amount }, 46 | () => 47 | `https://hacker-news.firebaseio.com/v0/item/${Math.ceil( 48 | Math.random() * 42000000, 49 | )}.json`, 50 | ); 51 | 52 | // Split URLs into time-windowed chunks if rate limiting is enabled 53 | let urlChunks: string[][] = [urls]; 54 | if (requestsPerWindow > 0) { 55 | const chunkSize = requestsPerWindow; 56 | urlChunks = []; 57 | for (let i = 0; i < urls.length; i += chunkSize) { 58 | urlChunks.push(urls.slice(i, i + chunkSize)); 59 | } 60 | } 61 | 62 | const results: Record = {}; 63 | 64 | const promises: Promise[] = []; 65 | // Process chunks with time windows 66 | for (let i = 0; i < urlChunks.length; i++) { 67 | const promise = (async () => { 68 | const chunk = urlChunks[i]; 69 | try { 70 | const id = env.RECURSIVE_FETCHER.newUniqueId(); 71 | const recursiveFetcher = env.RECURSIVE_FETCHER.get(id); 72 | console.log( 73 | `batch ${i}: ${chunk.length} urls (${new Date( 74 | Date.now(), 75 | ).toISOString()})`, 76 | ); 77 | const config: FetcherConfig = { 78 | urls: chunk, 79 | fetchesPerDO, 80 | }; 81 | 82 | const response = await recursiveFetcher.fetch("http://internal/", { 83 | method: "POST", 84 | body: JSON.stringify(config), 85 | }); 86 | 87 | if (!response.ok) { 88 | throw new Error(`DO returned status ${response.status}`); 89 | } 90 | 91 | const chunkResults = (await response.json()) as Record< 92 | string, 93 | number 94 | >; 95 | 96 | for (const [status, count] of Object.entries(chunkResults)) { 97 | results[status] = (results[status] || 0) + count; 98 | } 99 | } catch (error) { 100 | console.error("Error in worker:", error); 101 | results["500"] = (results["500"] || 0) + chunk.length; 102 | } 103 | })(); 104 | promises.push(promise); 105 | 106 | // Aggregate results 107 | 108 | // Wait for next window if rate limiting is enabled 109 | if (requestsPerWindow > 0 && i < urlChunks.length - 1) { 110 | await new Promise((resolve) => setTimeout(resolve, WINDOW_SIZE_MS)); 111 | } 112 | } 113 | 114 | await Promise.all(promises); 115 | 116 | const duration = Date.now() - t; 117 | return new Response(JSON.stringify({ results, duration }), { 118 | headers: { "Content-Type": "application/json" }, 119 | }); 120 | }, 121 | }; 122 | 123 | // Enhanced DO implementation 124 | export class RecursiveFetcherDO extends DurableObject { 125 | private activeRequests: number = 0; 126 | 127 | constructor(readonly state: DurableObjectState, readonly env: Env) { 128 | super(state, env); 129 | } 130 | 131 | async fetch(request: Request): Promise { 132 | if (request.method !== "POST") { 133 | return new Response("Method not allowed", { status: 405 }); 134 | } 135 | 136 | try { 137 | const config: FetcherConfig = await request.json(); 138 | const { urls, fetchesPerDO = FETCHES_PER_DO } = config; 139 | 140 | if (urls.length === 0) { 141 | return new Response(JSON.stringify({}), { 142 | headers: { "Content-Type": "application/json" }, 143 | }); 144 | } 145 | 146 | this.activeRequests++; 147 | 148 | try { 149 | if (urls.length <= fetchesPerDO) { 150 | return await this.handleUrls(urls); 151 | } 152 | return await this.handleMultipleUrls(urls, fetchesPerDO); 153 | } finally { 154 | this.activeRequests--; 155 | } 156 | } catch (error) { 157 | console.error("Error in DO:", error); 158 | return new Response(JSON.stringify({ "500": 1 }), { 159 | status: 500, 160 | headers: { "Content-Type": "application/json" }, 161 | }); 162 | } 163 | } 164 | 165 | private async handleUrls(urls: string[]): Promise { 166 | const results: Record = {}; 167 | 168 | for (const url of urls) { 169 | let retries = 0; 170 | let delay = INITIAL_BACKOFF_MS; 171 | 172 | while (retries < MAX_RETRIES) { 173 | try { 174 | const response = await fetch(url); 175 | const text = await response.text(); 176 | 177 | if (response.status === 429 || response.status === 503) { 178 | throw new Error(`Rate limited: ${response.status}`); 179 | } 180 | 181 | const resultText = 182 | response.status === 200 ? "200" : `${response.status}:${text}`; 183 | results[resultText] = (results[resultText] || 0) + 1; 184 | break; 185 | } catch (error) { 186 | retries++; 187 | if (retries === MAX_RETRIES) { 188 | results["Error Fetching URL"] = 189 | (results["Error Fetching URL"] || 0) + 1; 190 | break; 191 | } 192 | 193 | const jitter = Math.random() * JITTER_MAX_MS; 194 | delay = Math.min(delay * 2, MAX_BACKOFF_MS) + jitter; 195 | await new Promise((resolve) => setTimeout(resolve, delay)); 196 | } 197 | } 198 | } 199 | 200 | return new Response(JSON.stringify(results), { 201 | headers: { "Content-Type": "application/json" }, 202 | }); 203 | } 204 | 205 | private async handleMultipleUrls( 206 | urls: string[], 207 | fetchesPerDO: number, 208 | ): Promise { 209 | const chunkSize = Math.ceil( 210 | urls.length / Math.min(BRANCHES_PER_LAYER, urls.length), 211 | ); 212 | const chunks: string[][] = []; 213 | 214 | for (let i = 0; i < urls.length; i += chunkSize) { 215 | chunks.push(urls.slice(i, i + chunkSize)); 216 | } 217 | 218 | const processSingleChunk = async (chunk: string[]) => { 219 | let retries = 0; 220 | let delay = INITIAL_BACKOFF_MS; 221 | 222 | while (retries < MAX_RETRIES) { 223 | try { 224 | const id = this.env.RECURSIVE_FETCHER.newUniqueId(); 225 | const fetcher = this.env.RECURSIVE_FETCHER.get(id); 226 | 227 | const config: FetcherConfig = { 228 | urls: chunk, 229 | fetchesPerDO, 230 | }; 231 | 232 | const response = await fetcher.fetch("http://internal/", { 233 | method: "POST", 234 | body: JSON.stringify(config), 235 | }); 236 | 237 | if (response.status === 429 || response.status === 503) { 238 | throw new Error(`Rate limited: ${response.status}`); 239 | } 240 | 241 | if (!response.ok) { 242 | throw new Error(`Other status: ${response.status}`); 243 | } 244 | 245 | return (await response.json()) as Record; 246 | } catch (e: any) { 247 | retries++; 248 | if (retries === MAX_RETRIES) { 249 | return { 250 | [`500 - Failed to fetch self - ${e.message}`]: chunk.length, 251 | }; 252 | } 253 | 254 | const jitter = Math.random() * JITTER_MAX_MS; 255 | delay = Math.min(delay * 2, MAX_BACKOFF_MS) + jitter; 256 | 257 | if (this.activeRequests > BRANCHES_PER_LAYER * 2) { 258 | delay *= 1.5; 259 | } 260 | 261 | await new Promise((resolve) => setTimeout(resolve, delay)); 262 | } 263 | } 264 | 265 | return { "Max Retries Exceeded": chunk.length }; 266 | }; 267 | 268 | try { 269 | const results = await Promise.all(chunks.map(processSingleChunk)); 270 | const finalCounts: Record = {}; 271 | 272 | for (const result of results) { 273 | for (const [status, count] of Object.entries(result)) { 274 | finalCounts[status] = (finalCounts[status] || 0) + count; 275 | } 276 | } 277 | 278 | return new Response(JSON.stringify(finalCounts), { 279 | headers: { "Content-Type": "application/json" }, 280 | }); 281 | } catch (error) { 282 | console.error("Error processing chunks:", error); 283 | return new Response( 284 | JSON.stringify({ "Catch in handling multiple URLs": urls.length }), 285 | { 286 | status: 500, 287 | headers: { "Content-Type": "application/json" }, 288 | }, 289 | ); 290 | } 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /controlled-recursive-do/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "controlled-recursive-do", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "devDependencies": { 8 | "@cloudflare/workers-types": "^4.20241230.0" 9 | } 10 | }, 11 | "node_modules/@cloudflare/workers-types": { 12 | "version": "4.20250109.0", 13 | "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20250109.0.tgz", 14 | "integrity": "sha512-Y1zgSaEOOevl9ORpzgMcm4j535p3nK2lrblHHvYM2yxR50SBKGh+wvkRFAIxWRfjUGZEU+Fp6923EGioDBbobA==", 15 | "dev": true, 16 | "license": "MIT OR Apache-2.0" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /controlled-recursive-do/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": {}, 3 | "devDependencies": { 4 | "@cloudflare/workers-types": "^4.20241230.0" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /controlled-recursive-do/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts"], 3 | "compilerOptions": { 4 | "resolveJsonModule": true, 5 | "module": "NodeNext", 6 | "moduleResolution": "NodeNext", 7 | "lib": ["es2022", "DOM"], 8 | "strict": true, 9 | "types": ["@cloudflare/workers-types/2023-07-01"], 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "skipLibCheck": true, 13 | "declaration": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /controlled-recursive-do/wrangler.toml: -------------------------------------------------------------------------------- 1 | #:schema node_modules/wrangler/config-schema.json 2 | name = "controlled-recursive-do" 3 | main = "main.ts" 4 | compatibility_date = "2025-01-09" 5 | 6 | [durable_objects] 7 | bindings = [ 8 | { name = "RECURSIVE_FETCHER", class_name = "RecursiveFetcherDO" } 9 | ] 10 | 11 | [[migrations]] 12 | tag = "v1" 13 | new_classes = ["RecursiveFetcherDO"] 14 | -------------------------------------------------------------------------------- /highly-recursive-do/README.md: -------------------------------------------------------------------------------- 1 | Prompt: 2 | 3 | ``` 4 | I want to create a Cloudflare Worker with a RecursiveFetcherDO: 5 | 6 | The worker fetch handler takes a GET request with an ?amount param and generates that amount of URLs that lead to https://test.github-backup.com?random=${Math.random()} 7 | 8 | It sends that to the RecursiveFetcherDO which should ultimately return a mapped object {[status:number]:number} that counts the status of requests (or 500 if the DO creation failed) 9 | 10 | The DO will do 2 things: 11 | 12 | 1. if it takes in more than 1 URL, it will chunk the url array in up to 5 chunks (this number is a configurable constant we want to experiment with) and creates a new instance of RecursiveFetcherDO for each chunk, finally aggregating the mapped status object. 13 | 2. if it takes just 1 URL, will fetch that URL and return { [status:number]: 1 } or { 500: 1 } if it crashes 14 | 15 | Please implement this in cloudflare, typescript. 16 | ``` 17 | 18 | First I did a Naive implementation like above, without exponential backoff. 19 | 20 | Results with `BRANCHES_PER_LAYER` of 2, meaning it is highly recursive: 21 | 22 | - https://highly-recursive-do.YOURWORKERLOCATION.workers.dev/?amount=16384 returns `{"result":{"200":16384},"duration":2789}` which is a DO depth of 14. 23 | - https://highly-recursive-do.YOURWORKERLOCATION.workers.dev/?amount=16384 returns `{"result":{"500 - Failed to fetch self - Subrequest depth limit exceeded. This request recursed through Workers too many times. This can happen e.g. if you have a Worker or Durable Object that calls other Workers or objects recursively.":32768},"duration":3690}` which is a DO depth of 15. This means we cannot exceed a depth of 15 as of now on the pro plan. 24 | 25 | Results with `BRANCHES_PER_LAYER` of 3 (We won't hit a depth of 15): 26 | 27 | - 50000 requests: `{"result":{"200":49270,"500 - Failed to fetch self - Your account is generating too much load on Durable Objects. Please back off and try again later.":474,"503:error code: 1200":253,"500 - Failed to fetch self":3},"duration":17462}` 28 | - 3^10 requests (59049): `{"result":{"200":58138,"500 - Failed to fetch self - Your account is generating too much load on Durable Objects. Please back off and try again later.":911},"duration":10784}`. 29 | - 250000 requests: `{"result":{"200":115023,"503:error code: 1200":3915,"500 - Failed to fetch self - Your account is generating too much load on Durable Objects. Please back off and try again later.":131061,"500 - Failed to fetch self":1},"duration":16153}` 30 | 31 | After this, I implemented exponential backoff as can be seen in the current implementation. The results show it's very stable for 100k requests: 32 | 33 | ``` 34 | {"result":{"200":100000},"duration":13895} 35 | {"result":{"200":100000},"duration":11600} 36 | {"result":{"200":100000},"duration":12396} 37 | {"result":{"200":100000},"duration":12901} 38 | {"result":{"200":100000},"duration":56135} 39 | {"result":{"200":100000},"duration":13297} 40 | {"result":{"200":100000},"duration":14078} 41 | {"result":{"200":100000},"duration":37302} 42 | {"result":{"200":100000},"duration":12484} 43 | {"result":{"200":100000},"duration":65307} 44 | ``` 45 | 46 | 100k fetch responses in 11.6 seconds, that's an impressive feat! 47 | 48 | With 1M requests it takes for ever to answer, so there must be better things we can do. If we could handle concurrency better, it may work. Nevertheless, that's not the purpose of this experiment; My goal was to do 100k requests as fast as possible and that succeeds with decent looking reliability. 49 | 50 | If we wanted to controll max concurrency we could've just used queues and carefully ramp it up. This shows we can also immediately instantiate 100k DOs. 51 | -------------------------------------------------------------------------------- /highly-recursive-do/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "highly-recursive-do", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "devDependencies": { 8 | "@cloudflare/workers-types": "^4.20241230.0" 9 | } 10 | }, 11 | "node_modules/@cloudflare/workers-types": { 12 | "version": "4.20250109.0", 13 | "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20250109.0.tgz", 14 | "integrity": "sha512-Y1zgSaEOOevl9ORpzgMcm4j535p3nK2lrblHHvYM2yxR50SBKGh+wvkRFAIxWRfjUGZEU+Fp6923EGioDBbobA==", 15 | "dev": true, 16 | "license": "MIT OR Apache-2.0" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /highly-recursive-do/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": {}, 3 | "devDependencies": { 4 | "@cloudflare/workers-types": "^4.20241230.0" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /highly-recursive-do/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts"], 3 | "compilerOptions": { 4 | "resolveJsonModule": true, 5 | "module": "NodeNext", 6 | "moduleResolution": "NodeNext", 7 | "lib": ["es2022", "DOM"], 8 | "strict": true, 9 | "types": ["@cloudflare/workers-types/2023-07-01"], 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "skipLibCheck": true, 13 | "declaration": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /highly-recursive-do/worker.ts: -------------------------------------------------------------------------------- 1 | import { DurableObject } from "cloudflare:workers"; 2 | 3 | // Configuration constants 4 | const BRANCHES_PER_LAYER = 3; 5 | const INITIAL_BACKOFF_MS = 100; 6 | const MAX_BACKOFF_MS = 5000; 7 | const MAX_RETRIES = 10; 8 | const JITTER_MAX_MS = 50; 9 | 10 | export interface Env { 11 | RECURSIVE_FETCHER: DurableObjectNamespace; 12 | SECRET: string; 13 | } 14 | 15 | // Worker code remains the same as it doesn't need backoff logic 16 | export default { 17 | async fetch(request: Request, env: Env): Promise { 18 | const url = new URL(request.url); 19 | if (url.searchParams.get("secret") !== env.SECRET) { 20 | return new Response("Please provide secret"); 21 | } 22 | const amount = parseInt(url.searchParams.get("amount") || "1"); 23 | const t = Date.now(); 24 | if (isNaN(amount) || amount < 1) { 25 | return new Response("Invalid amount parameter", { status: 400 }); 26 | } 27 | 28 | const urls = Array.from( 29 | { length: amount }, 30 | () => 31 | `https://hacker-news.firebaseio.com/v0/item/${Math.ceil( 32 | Math.random() * 42000000, 33 | )}.json`, 34 | ); 35 | 36 | const id = env.RECURSIVE_FETCHER.newUniqueId(); 37 | const recursiveFetcher = env.RECURSIVE_FETCHER.get(id); 38 | 39 | try { 40 | const response = await recursiveFetcher.fetch("http://internal/", { 41 | method: "POST", 42 | body: JSON.stringify(urls), 43 | }); 44 | 45 | if (!response.ok) { 46 | throw new Error(`DO returned status ${response.status}`); 47 | } 48 | 49 | const result = await response.json(); 50 | const duration = Date.now() - t; 51 | 52 | return new Response(JSON.stringify({ result, duration }), { 53 | headers: { "Content-Type": "application/json" }, 54 | }); 55 | } catch (error) { 56 | console.error("Error in worker:", error); 57 | return new Response(JSON.stringify({ 500: amount }), { 58 | status: 500, 59 | headers: { "Content-Type": "application/json" }, 60 | }); 61 | } 62 | }, 63 | }; 64 | 65 | // Enhanced DO implementation 66 | export class RecursiveFetcherDO extends DurableObject { 67 | private activeRequests: number = 0; 68 | private lastRequestTime: number = 0; 69 | 70 | constructor(readonly state: DurableObjectState, readonly env: Env) { 71 | super(state, env); 72 | } 73 | 74 | async fetch(request: Request): Promise { 75 | if (request.method !== "POST") { 76 | return new Response("Method not allowed", { status: 405 }); 77 | } 78 | 79 | try { 80 | const urls: string[] = await request.json(); 81 | 82 | if (urls.length === 0) { 83 | return new Response(JSON.stringify({}), { 84 | headers: { "Content-Type": "application/json" }, 85 | }); 86 | } 87 | 88 | // Track request load 89 | this.activeRequests++; 90 | const currentTime = Date.now(); 91 | this.lastRequestTime = currentTime; 92 | 93 | try { 94 | if (urls.length === 1) { 95 | return await this.handleSingleUrl(urls[0]); 96 | } 97 | return await this.handleMultipleUrls(urls); 98 | } finally { 99 | this.activeRequests--; 100 | } 101 | } catch (error) { 102 | console.error("Error in DO:", error); 103 | return new Response(JSON.stringify({ 500: 1 }), { 104 | status: 500, 105 | headers: { "Content-Type": "application/json" }, 106 | }); 107 | } 108 | } 109 | 110 | private async handleSingleUrl(url: string): Promise { 111 | let retries = 0; 112 | let delay = INITIAL_BACKOFF_MS; 113 | 114 | while (retries < MAX_RETRIES) { 115 | try { 116 | const response = await fetch(url); 117 | const text = await response.text(); 118 | 119 | if (response.status === 429 || response.status === 503) { 120 | throw new Error(`Rate limited: ${response.status}`); 121 | } 122 | 123 | const resultText = 124 | response.status === 200 ? "200" : `${response.status}:${text}`; 125 | return new Response(JSON.stringify({ [resultText]: 1 }), { 126 | headers: { "Content-Type": "application/json" }, 127 | }); 128 | } catch (error) { 129 | retries++; 130 | if (retries === MAX_RETRIES) { 131 | return new Response(JSON.stringify({ "Error Fetching URL": 1 }), { 132 | status: 500, 133 | headers: { "Content-Type": "application/json" }, 134 | }); 135 | } 136 | 137 | // Calculate backoff with jitter 138 | const jitter = Math.random() * JITTER_MAX_MS; 139 | delay = Math.min(delay * 2, MAX_BACKOFF_MS) + jitter; 140 | 141 | await new Promise((resolve) => setTimeout(resolve, delay)); 142 | } 143 | } 144 | 145 | return new Response(JSON.stringify({ "Max Retries Exceeded": 1 }), { 146 | status: 500, 147 | headers: { "Content-Type": "application/json" }, 148 | }); 149 | } 150 | 151 | private async handleMultipleUrls(urls: string[]): Promise { 152 | const chunkSize = Math.ceil( 153 | urls.length / Math.min(BRANCHES_PER_LAYER, urls.length), 154 | ); 155 | const chunks: string[][] = []; 156 | 157 | for (let i = 0; i < urls.length; i += chunkSize) { 158 | chunks.push(urls.slice(i, i + chunkSize)); 159 | } 160 | 161 | const processSingleChunk = async (chunk: string[]) => { 162 | let retries = 0; 163 | let delay = INITIAL_BACKOFF_MS; 164 | 165 | while (retries < MAX_RETRIES) { 166 | try { 167 | const id = this.env.RECURSIVE_FETCHER.newUniqueId(); 168 | const fetcher = this.env.RECURSIVE_FETCHER.get(id); 169 | 170 | const response = await fetcher.fetch("http://internal/", { 171 | method: "POST", 172 | body: JSON.stringify(chunk), 173 | }); 174 | 175 | if (response.status === 429 || response.status === 503) { 176 | throw new Error(`Rate limited: ${response.status}`); 177 | } 178 | 179 | if (!response.ok) { 180 | throw new Error(`Other status: ${response.status}`); 181 | } 182 | 183 | return (await response.json()) as Record; 184 | } catch (e: any) { 185 | retries++; 186 | if (retries === MAX_RETRIES) { 187 | const message = e.message; 188 | return { 189 | [`500 - Failed to fetch self - ${message}`]: chunk.length, 190 | }; 191 | } 192 | 193 | // Calculate backoff with jitter 194 | const jitter = Math.random() * JITTER_MAX_MS; 195 | delay = Math.min(delay * 2, MAX_BACKOFF_MS) + jitter; 196 | 197 | // Additional backoff if we detect high load 198 | if (this.activeRequests > BRANCHES_PER_LAYER * 2) { 199 | delay *= 1.5; 200 | } 201 | 202 | await new Promise((resolve) => setTimeout(resolve, delay)); 203 | } 204 | } 205 | 206 | return { "Max Retries Exceeded": chunk.length }; 207 | }; 208 | 209 | try { 210 | const results = await Promise.all(chunks.map(processSingleChunk)); 211 | 212 | // Aggregate results 213 | const finalCounts: Record = {}; 214 | for (const result of results) { 215 | for (const [status, count] of Object.entries(result)) { 216 | finalCounts[status] = (finalCounts[status] || 0) + count; 217 | } 218 | } 219 | 220 | return new Response(JSON.stringify(finalCounts), { 221 | headers: { "Content-Type": "application/json" }, 222 | }); 223 | } catch (error) { 224 | console.error("Error processing chunks:", error); 225 | return new Response( 226 | JSON.stringify({ "Catch in handling multiple URLs": urls.length }), 227 | { 228 | status: 500, 229 | headers: { "Content-Type": "application/json" }, 230 | }, 231 | ); 232 | } 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /highly-recursive-do/wrangler.toml: -------------------------------------------------------------------------------- 1 | #:schema node_modules/wrangler/config-schema.json 2 | name = "highly-recursive-do" 3 | main = "worker.ts" 4 | compatibility_date = "2025-01-09" 5 | 6 | [durable_objects] 7 | bindings = [ 8 | { name = "RECURSIVE_FETCHER", class_name = "RecursiveFetcherDO" } 9 | ] 10 | 11 | [[migrations]] 12 | tag = "v1" 13 | new_classes = ["RecursiveFetcherDO"] 14 | -------------------------------------------------------------------------------- /recursive-do/README.md: -------------------------------------------------------------------------------- 1 | This implementation is the same as the [RequestSchedulerDO](../with-do) but also there's a CreatorDO that creates all the DO's and wait for the responses, the worker just fetches the CreatorDO. 2 | 3 | If there's more than 500 URLs going into the CreatorDO, it will create a CreatorDO instance for each set of 500 URLs (recursive case), and wait for the results to combine them. If there's 500 URLs or less it will just create a RequestSchedulerDO instance for each request. 4 | 5 | Tested and I did 100k requests just now! 6 | 7 | Using a recursive DO. 8 | 9 | The result was a 16MB response with: 10 | 11 | - "duration": 71305, 12 | - "resultCount": 100000 13 | 14 | The first time it hit an exception, the second time it worked. Unclear why. 15 | -------------------------------------------------------------------------------- /recursive-do/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-do", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "devDependencies": { 8 | "@cloudflare/workers-types": "^4.20241230.0" 9 | } 10 | }, 11 | "node_modules/@cloudflare/workers-types": { 12 | "version": "4.20250109.0", 13 | "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20250109.0.tgz", 14 | "integrity": "sha512-Y1zgSaEOOevl9ORpzgMcm4j535p3nK2lrblHHvYM2yxR50SBKGh+wvkRFAIxWRfjUGZEU+Fp6923EGioDBbobA==", 15 | "dev": true, 16 | "license": "MIT OR Apache-2.0" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /recursive-do/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": {}, 3 | "devDependencies": { 4 | "@cloudflare/workers-types": "^4.20241230.0" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /recursive-do/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts"], 3 | "compilerOptions": { 4 | "resolveJsonModule": true, 5 | "module": "NodeNext", 6 | "moduleResolution": "NodeNext", 7 | "lib": ["es2022", "DOM"], 8 | "strict": true, 9 | "types": ["@cloudflare/workers-types/2023-07-01"], 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "skipLibCheck": true, 13 | "declaration": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /recursive-do/worker.ts: -------------------------------------------------------------------------------- 1 | import { DurableObject } from "cloudflare:workers"; 2 | 3 | export interface Env { 4 | RATE_LIMITER: DurableObjectNamespace; 5 | CREATOR: DurableObjectNamespace; 6 | SECRET: string; 7 | } 8 | 9 | export interface URLRequest { 10 | url: string; 11 | id: string; 12 | } 13 | 14 | // Main worker 15 | export default { 16 | async fetch(request: Request, env: Env): Promise { 17 | try { 18 | const url = new URL(request.url); 19 | const secret = url.searchParams.get("secret"); 20 | const amount = Number(url.searchParams.get("amount") || 100); 21 | const isSecret = secret === env.SECRET; 22 | const maxAmount = isSecret ? 1000000 : 9000; 23 | 24 | if (isNaN(amount) || amount < 0 || amount > maxAmount) { 25 | return new Response( 26 | isSecret 27 | ? "Max 1mil bro, take it easy" 28 | : "If you don't know the secret, the amount cannot be over " + 29 | maxAmount, 30 | { status: 400 }, 31 | ); 32 | } 33 | 34 | const t0 = Date.now(); 35 | 36 | // Example URLs creation (you would replace this with your actual URLs) 37 | const urls = Array.from({ length: amount }, (_, i) => ({ 38 | url: `https://hacker-news.firebaseio.com/v0/item/${Math.ceil(Math.random()*42000000)}.json`, 39 | id: (i + 1).toString(), 40 | })); 41 | 42 | // Create a Creator DO instance 43 | const creatorId = env.CREATOR.newUniqueId(); 44 | const creator = env.CREATOR.get(creatorId); 45 | 46 | // Send the URLs to the Creator DO 47 | const response: Response = await creator.fetch( 48 | new Request("https://dummy-url/process", { 49 | method: "POST", 50 | headers: { 51 | "Content-Type": "application/json", 52 | }, 53 | body: JSON.stringify({ urls }), 54 | }), 55 | ); 56 | 57 | if (!response.ok) { 58 | return new Response( 59 | "Main fetch went wrong:" + (await response.text()), 60 | { status: 500 }, 61 | ); 62 | } 63 | 64 | const results: { results: any[] } = await response.json(); 65 | const t1 = Date.now(); 66 | 67 | return new Response( 68 | JSON.stringify( 69 | { 70 | duration: t1 - t0, 71 | resultCount: results.results.length, 72 | results: results.results, 73 | }, 74 | null, 75 | 2, 76 | ), 77 | { 78 | headers: { 79 | "Content-Type": "application/json", 80 | }, 81 | }, 82 | ); 83 | } catch (e: any) { 84 | return new Response("catched error: " + e.message, { status: 500 }); 85 | } 86 | }, 87 | }; 88 | 89 | // Creator DO that manages RequestSchedulerDOs 90 | export class CreatorDO extends DurableObject { 91 | constructor(state: DurableObjectState, private env: Env) { 92 | super(state, env); 93 | } 94 | 95 | async fetch(request: Request): Promise { 96 | if (request.method !== "POST") { 97 | return new Response("Method not allowed", { status: 405 }); 98 | } 99 | try { 100 | const { urls } = (await request.json()) as { urls: URLRequest[] }; 101 | 102 | // If URLs length is <= 500, create RequestSchedulerDOs directly 103 | if (urls.length === 1) { 104 | return await this.processUrlBatch(urls); 105 | } 106 | 107 | // Split URLs into batches of 500 and create multiple CreatorDOs 108 | const batches = this.splitIntoBatches(urls, 2); 109 | const results = await Promise.all( 110 | batches.map(async (batchUrls) => { 111 | const creatorId = this.env.CREATOR.newUniqueId(); 112 | const creator = this.env.CREATOR.get(creatorId); 113 | const response = await creator.fetch( 114 | new Request("https://dummy-url/process", { 115 | method: "POST", 116 | headers: { 117 | "Content-Type": "application/json", 118 | }, 119 | body: JSON.stringify({ urls: batchUrls }), 120 | }), 121 | ); 122 | if (response.ok) { 123 | return response.json(); 124 | } else { 125 | return { error: await response.text() }; 126 | } 127 | }), 128 | ); 129 | 130 | // Combine results from all batches 131 | const combinedResults = this.combineResults(results); 132 | return new Response(JSON.stringify(combinedResults), { 133 | headers: { "Content-Type": "application/json" }, 134 | }); 135 | } catch (e: any) { 136 | return new Response(`CreatorDO Error: ${e.message}`, { 137 | status: 500, 138 | }); 139 | } 140 | } 141 | 142 | private splitIntoBatches(array: T[], size: number): T[][] { 143 | const batches: T[][] = []; 144 | for (let i = 0; i < array.length; i += size) { 145 | batches.push(array.slice(i, i + size)); 146 | } 147 | return batches; 148 | } 149 | 150 | private async processUrlBatch(urls: URLRequest[]): Promise { 151 | const doIds: { id: DurableObjectId; url: string }[] = []; 152 | 153 | // Create RequestSchedulerDOs 154 | await Promise.all( 155 | urls.map(async ({ url }) => { 156 | const doId = this.env.RATE_LIMITER.newUniqueId(); 157 | doIds.push({ id: doId, url }); 158 | }), 159 | ); 160 | 161 | // First pass: Initialize fetches 162 | await Promise.all( 163 | doIds.map(async ({ id, url }) => { 164 | const instance = this.env.RATE_LIMITER.get(id); 165 | await instance.fetch(t 166 | new Request(url, { 167 | method: "POST", 168 | headers: { 169 | "Content-Type": "application/json", 170 | }, 171 | }), 172 | ); 173 | }), 174 | ); 175 | 176 | // Second pass: Get results 177 | const results = await Promise.all( 178 | doIds.map(async ({ id, url }) => { 179 | const instance = this.env.RATE_LIMITER.get(id); 180 | const response = await instance.fetch( 181 | new Request(url, { 182 | method: "GET", 183 | headers: { 184 | "Content-Type": "application/json", 185 | }, 186 | }), 187 | ); 188 | 189 | const result = await response.json(); 190 | return { 191 | doName: id.name, 192 | url, 193 | result, 194 | }; 195 | }), 196 | ); 197 | 198 | return new Response(JSON.stringify({ results }), { 199 | headers: { 200 | "Content-Type": "application/json", 201 | }, 202 | }); 203 | } 204 | 205 | private combineResults(batchResults: any[]): any { 206 | return { 207 | results: batchResults.flatMap((batch) => batch.results || batch.error), 208 | }; 209 | } 210 | } 211 | 212 | // RequestSchedulerDO implementation (mostly unchanged) 213 | export class RequestSchedulerDO extends DurableObject { 214 | private fetchPromises: Map>; 215 | private results: Map; 216 | 217 | constructor(state: DurableObjectState, env: Env) { 218 | super(state, env); 219 | this.fetchPromises = new Map(); 220 | this.results = new Map(); 221 | } 222 | 223 | async fetch(request: Request): Promise { 224 | try { 225 | const url = request.url; 226 | 227 | if (request.method === "POST") { 228 | if (!this.fetchPromises.has(url)) { 229 | const time = Date.now(); 230 | const fetchPromise = fetch(url) 231 | .then(async (response) => { 232 | const text = await response.text(); 233 | const duration = Date.now() - time; 234 | this.results.set(url, duration); 235 | return response; 236 | }) 237 | .catch((error) => { 238 | console.error(`Fetch error for ${url}:`, error); 239 | return new Response(`Error: ${error.message}`, { status: 500 }); 240 | }); 241 | 242 | this.fetchPromises.set(url, fetchPromise); 243 | } 244 | 245 | return new Response( 246 | JSON.stringify({ 247 | status: "processing", 248 | url: request.url, 249 | }), 250 | { 251 | headers: { 252 | "Content-Type": "application/json", 253 | }, 254 | }, 255 | ); 256 | } else if (request.method === "GET") { 257 | const promise = this.fetchPromises.get(url); 258 | if (!promise) { 259 | return new Response( 260 | JSON.stringify({ error: "No fetch in progress for this URL" }), 261 | { status: 404 }, 262 | ); 263 | } 264 | 265 | await promise; 266 | const result = this.results.get(url); 267 | 268 | return new Response( 269 | JSON.stringify({ 270 | status: "completed", 271 | result, 272 | }), 273 | { 274 | headers: { 275 | "Content-Type": "application/json", 276 | }, 277 | }, 278 | ); 279 | } 280 | 281 | return new Response("Invalid method", { status: 405 }); 282 | } catch (error: any) { 283 | return new Response(`RequestSchedulerDO Error: ${error.message}`, { 284 | status: 500, 285 | }); 286 | } 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /recursive-do/wrangler.toml: -------------------------------------------------------------------------------- 1 | #:schema node_modules/wrangler/config-schema.json 2 | name = "recursive-do" 3 | main = "worker.ts" 4 | compatibility_date = "2025-01-09" 5 | 6 | # Durable Object declarations 7 | [[durable_objects.bindings]] 8 | name = "RATE_LIMITER" 9 | class_name = "RequestSchedulerDO" 10 | 11 | [[durable_objects.bindings]] 12 | name = "CREATOR" 13 | class_name = "CreatorDO" 14 | 15 | # Migrations for Durable Objects 16 | [[migrations]] 17 | tag = "v1" # version tag for this migration 18 | new_classes = ["RequestSchedulerDO", "CreatorDO"] -------------------------------------------------------------------------------- /with-alternate/nested_fetch_worker/main.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | fetch: async (request: Request) => { 3 | const url = new URL(request.url); 4 | const count = url.searchParams.get("count"); 5 | const urls = 6 | request.method === "POST" 7 | ? await request.json() 8 | : new Array(Number(count || 100)).fill(null).map( 9 | // random to prevent cache 10 | (_) => `https://test.github-backup.com/?random=${Math.random()}`, 11 | ); 12 | 13 | console.log("count", urls.length); 14 | 15 | if (urls.length <= 6) { 16 | //base case 17 | const results = await Promise.all( 18 | urls.map(async (url) => { 19 | // fetch something 20 | const result = await fetch(url).then((res) => res.text()); 21 | 22 | return result; 23 | }), 24 | ); 25 | // can be done concurrent 26 | return new Response(JSON.stringify(results)); 27 | } 28 | 29 | const splitCount = Math.ceil(urls.length / 6); 30 | 31 | const results = ( 32 | await Promise.all( 33 | new Array(6).fill(null).map(async (_, index) => { 34 | // fetch self 35 | const part = urls.slice( 36 | splitCount * index, 37 | splitCount * index + splitCount, 38 | ); 39 | 40 | if (part.length === 0) { 41 | return []; 42 | } 43 | 44 | const results = fetch(`https://alternate.getrelevantcode.com/`, { 45 | method: "POST", 46 | body: JSON.stringify(part), 47 | }).then((res) => res.json()); 48 | 49 | return results; 50 | }), 51 | ) 52 | ).flat(); 53 | 54 | // can be done concurrent 55 | return new Response(JSON.stringify(results)); 56 | }, 57 | }; 58 | -------------------------------------------------------------------------------- /with-alternate/nested_fetch_worker/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "recursive-fetch", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "devDependencies": { 8 | "@cloudflare/workers-types": "^4.20241230.0" 9 | } 10 | }, 11 | "node_modules/@cloudflare/workers-types": { 12 | "version": "4.20241230.0", 13 | "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20241230.0.tgz", 14 | "integrity": "sha512-dtLD4jY35Lb750cCVyO1i/eIfdZJg2Z0i+B1RYX6BVeRPlgaHx/H18ImKAkYmy0g09Ow8R2jZy3hIxMgXun0WQ==", 15 | "dev": true, 16 | "license": "MIT OR Apache-2.0" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /with-alternate/nested_fetch_worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "types": "npx wrangler types --experimental-include-runtime" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /with-alternate/nested_fetch_worker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts"], 3 | "files": ["worker_configuration.d.ts"], 4 | "compilerOptions": { 5 | "resolveJsonModule": true, 6 | "module": "NodeNext", 7 | "moduleResolution": "NodeNext", 8 | "lib": ["es2022", "DOM"], 9 | "strict": true, 10 | "types": ["./.wrangler/types/runtime"], 11 | "noEmit": true, 12 | "esModuleInterop": true, 13 | "skipLibCheck": true, 14 | "declaration": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /with-alternate/nested_fetch_worker/worker-configuration.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by Wrangler by running `wrangler types --experimental-include-runtime` 2 | 3 | interface Env { 4 | nested_fetch_worker: Fetcher; 5 | } 6 | -------------------------------------------------------------------------------- /with-alternate/nested_fetch_worker/wrangler.toml: -------------------------------------------------------------------------------- 1 | #:schema node_modules/wrangler/config-schema.json 2 | name = "nested_fetch_worker" 3 | main = "./main.ts" 4 | compatibility_date = "2025-01-01" 5 | -------------------------------------------------------------------------------- /with-alternate/nested_fetch_worker_alternate/main.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | fetch: async (request: Request) => { 3 | const url = new URL(request.url); 4 | const count = url.searchParams.get("count"); 5 | const urls = 6 | request.method === "POST" 7 | ? await request.json() 8 | : new Array(Number(count || 100)).fill(null).map( 9 | // random to prevent cache 10 | (_) => `https://test.github-backup.com/?random=${Math.random()}`, 11 | ); 12 | 13 | console.log("count", urls.length); 14 | 15 | if (urls.length <= 6) { 16 | //base case 17 | const results = await Promise.all( 18 | urls.map(async (url) => { 19 | // fetch something 20 | const result = await fetch(url).then((res) => res.text()); 21 | 22 | return result; 23 | }), 24 | ); 25 | // can be done concurrent 26 | return new Response(JSON.stringify(results)); 27 | } 28 | 29 | const splitCount = Math.ceil(urls.length / 6); 30 | 31 | const results = ( 32 | await Promise.all( 33 | new Array(6).fill(null).map(async (_, index) => { 34 | // fetch self 35 | const part = urls.slice( 36 | splitCount * index, 37 | splitCount * index + splitCount, 38 | ); 39 | 40 | if (part.length === 0) { 41 | return []; 42 | } 43 | 44 | const results = fetch( 45 | `https://nested_fetch_worker.githuq.workers.dev`, 46 | { 47 | method: "POST", 48 | body: JSON.stringify(part), 49 | }, 50 | ).then((res) => res.json()); 51 | 52 | return results; 53 | }), 54 | ) 55 | ).flat(); 56 | 57 | // can be done concurrent 58 | return new Response(JSON.stringify(results)); 59 | }, 60 | }; 61 | -------------------------------------------------------------------------------- /with-alternate/nested_fetch_worker_alternate/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "recursive-fetch", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "devDependencies": { 8 | "@cloudflare/workers-types": "^4.20241230.0" 9 | } 10 | }, 11 | "node_modules/@cloudflare/workers-types": { 12 | "version": "4.20241230.0", 13 | "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20241230.0.tgz", 14 | "integrity": "sha512-dtLD4jY35Lb750cCVyO1i/eIfdZJg2Z0i+B1RYX6BVeRPlgaHx/H18ImKAkYmy0g09Ow8R2jZy3hIxMgXun0WQ==", 15 | "dev": true, 16 | "license": "MIT OR Apache-2.0" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /with-alternate/nested_fetch_worker_alternate/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "types": "npx wrangler types --experimental-include-runtime" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /with-alternate/nested_fetch_worker_alternate/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts"], 3 | "files": ["worker_configuration.d.ts"], 4 | "compilerOptions": { 5 | "resolveJsonModule": true, 6 | "module": "NodeNext", 7 | "moduleResolution": "NodeNext", 8 | "lib": ["es2022", "DOM"], 9 | "strict": true, 10 | "types": ["./.wrangler/types/runtime"], 11 | "noEmit": true, 12 | "esModuleInterop": true, 13 | "skipLibCheck": true, 14 | "declaration": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /with-alternate/nested_fetch_worker_alternate/worker-configuration.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by Wrangler by running `wrangler types --experimental-include-runtime` 2 | 3 | interface Env { 4 | nested_fetch_worker: Fetcher; 5 | } 6 | -------------------------------------------------------------------------------- /with-alternate/nested_fetch_worker_alternate/wrangler.toml: -------------------------------------------------------------------------------- 1 | #:schema node_modules/wrangler/config-schema.json 2 | name = "nested_fetch_worker_alternate" 3 | main = "./main.ts" 4 | compatibility_date = "2025-01-01" 5 | routes = [{ pattern = "alternate.getrelevantcode.com", custom_domain = true }] -------------------------------------------------------------------------------- /with-alternate/slow-worker/main.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | fetch: async (request: Request) => { 3 | await new Promise((resolve) => setTimeout(() => resolve(), 5000)); 4 | return new Response(String(Math.random())); 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /with-alternate/slow-worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "types": "npx wrangler types --experimental-include-runtime" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /with-alternate/slow-worker/wrangler.toml: -------------------------------------------------------------------------------- 1 | #:schema node_modules/wrangler/config-schema.json 2 | 3 | name = "slow-worker" 4 | main = "./main.ts" 5 | compatibility_date = "2025-01-01" 6 | routes = [{ pattern = "test.github-backup.com", custom_domain = true }] -------------------------------------------------------------------------------- /with-binding/nested_fetch_worker/main.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | fetch: async (request: Request, env: Env) => { 3 | const url = new URL(request.url); 4 | const count = url.searchParams.get("count"); 5 | const urls = 6 | request.method === "POST" 7 | ? await request.json() 8 | : new Array(Number(count || 100)).fill(null).map( 9 | // random to prevent cache 10 | (_) => `https://test.github-backup.com/?random=${Math.random()}`, 11 | ); 12 | 13 | console.log("count", urls.length); 14 | 15 | if (urls.length <= 6) { 16 | //base case 17 | const results = await Promise.all( 18 | urls.map(async (url) => { 19 | // fetch something 20 | const result = await fetch(url).then((res) => res.text()); 21 | 22 | return result; 23 | }), 24 | ); 25 | // can be done concurrent 26 | return new Response(JSON.stringify(results)); 27 | } 28 | 29 | const splitCount = Math.ceil(urls.length / 6); 30 | 31 | const results = ( 32 | await Promise.all( 33 | new Array(6).fill(null).map(async (_, index) => { 34 | // fetch self 35 | const part = urls.slice( 36 | splitCount * index, 37 | splitCount * index + splitCount, 38 | ); 39 | 40 | if (part.length === 0) { 41 | return []; 42 | } 43 | 44 | const results = await env.nested_fetch_worker 45 | .fetch( 46 | new Request(`https://nested_fetch_worker.com/`, { 47 | method: "POST", 48 | body: JSON.stringify(part), 49 | }), 50 | ) 51 | .then((res) => res.json()); 52 | 53 | return results; 54 | }), 55 | ) 56 | ).flat(); 57 | 58 | // can be done concurrent 59 | return new Response(JSON.stringify(results)); 60 | }, 61 | }; 62 | -------------------------------------------------------------------------------- /with-binding/nested_fetch_worker/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "recursive-fetch", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "devDependencies": { 8 | "@cloudflare/workers-types": "^4.20241230.0" 9 | } 10 | }, 11 | "node_modules/@cloudflare/workers-types": { 12 | "version": "4.20241230.0", 13 | "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20241230.0.tgz", 14 | "integrity": "sha512-dtLD4jY35Lb750cCVyO1i/eIfdZJg2Z0i+B1RYX6BVeRPlgaHx/H18ImKAkYmy0g09Ow8R2jZy3hIxMgXun0WQ==", 15 | "dev": true, 16 | "license": "MIT OR Apache-2.0" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /with-binding/nested_fetch_worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "types": "npx wrangler types --experimental-include-runtime" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /with-binding/nested_fetch_worker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts"], 3 | "files": ["worker_configuration.d.ts"], 4 | "compilerOptions": { 5 | "resolveJsonModule": true, 6 | "module": "NodeNext", 7 | "moduleResolution": "NodeNext", 8 | "lib": ["es2022", "DOM"], 9 | "strict": true, 10 | "types": ["./.wrangler/types/runtime"], 11 | "noEmit": true, 12 | "esModuleInterop": true, 13 | "skipLibCheck": true, 14 | "declaration": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /with-binding/nested_fetch_worker/worker-configuration.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by Wrangler by running `wrangler types --experimental-include-runtime` 2 | 3 | interface Env { 4 | nested_fetch_worker: Fetcher; 5 | } 6 | -------------------------------------------------------------------------------- /with-binding/nested_fetch_worker/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "nested_fetch_worker" 2 | main = "./main.ts" 3 | compatibility_date = "2025-01-01" 4 | services = [ 5 | { binding = "nested_fetch_worker", service = "nested_fetch_worker" } 6 | ] -------------------------------------------------------------------------------- /with-binding/slow-worker/main.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | fetch: async (request: Request) => { 3 | await new Promise((resolve) => setTimeout(() => resolve(), 5000)); 4 | return new Response(String(Math.random())); 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /with-binding/slow-worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "types": "npx wrangler types --experimental-include-runtime" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /with-binding/slow-worker/wrangler.toml: -------------------------------------------------------------------------------- 1 | #:schema node_modules/wrangler/config-schema.json 2 | 3 | name = "slow-worker" 4 | main = "./main.ts" 5 | compatibility_date = "2025-01-01" 6 | routes = [{ pattern = "test.github-backup.com", custom_domain = true }] -------------------------------------------------------------------------------- /with-deno/call-directly-alternate/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "deploy": { 3 | "project": "928ad665-460f-44e5-9121-1b78c72a8211", 4 | "exclude": [ 5 | "**/node_modules" 6 | ], 7 | "include": [], 8 | "entrypoint": "main.ts" 9 | } 10 | } -------------------------------------------------------------------------------- /with-deno/call-directly-alternate/main.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | fetch: async (request: Request) => { 3 | const time = Date.now(); 4 | try { 5 | const url = new URL(request.url); 6 | const count = url.searchParams.get("count"); 7 | const urls = 8 | request.method === "POST" 9 | ? await request.json() 10 | : new Array(Number(count || 100)).fill(null).map( 11 | // random to prevent cache 12 | (_) => `https://test.github-backup.com/?random=${Math.random()}`, 13 | ); 14 | 15 | console.log("count", urls.length); 16 | 17 | if (urls.length <= 100) { 18 | //base case 19 | const results = await Promise.all( 20 | urls.map(async (url) => { 21 | // fetch something 22 | const result = await fetch(url).then((res) => res.text()); 23 | 24 | return result; 25 | }), 26 | ); 27 | // can be done concurrent 28 | const ms = Date.now() - time; 29 | 30 | return new Response(JSON.stringify({ results, ms })); 31 | } 32 | 33 | const splitCount = 34 | urls.length < 10000 ? 100 : Math.ceil(urls.length / 100); 35 | const amount = urls.length < 10000 ? Math.ceil(urls.length / 100) : 100; 36 | console.log({ splitCount, amount }); 37 | const results = ( 38 | await Promise.all( 39 | new Array(amount).fill(null).map(async (_, index) => { 40 | // fetch self 41 | const part = urls.slice( 42 | splitCount * index, 43 | splitCount * index + splitCount, 44 | ); 45 | 46 | if (part.length === 0) { 47 | return []; 48 | } 49 | 50 | const results = await fetch(`https://call-directly.deno.dev/`, { 51 | method: "POST", 52 | body: JSON.stringify(part), 53 | }).then((res) => res.text()); 54 | 55 | try { 56 | return JSON.parse(results)?.results; 57 | } catch (e) { 58 | return results; 59 | } 60 | }), 61 | ) 62 | ).flat(); 63 | 64 | const ms = Date.now() - time; 65 | // can be done concurrent 66 | return new Response(JSON.stringify({ results, ms })); 67 | } catch (e) { 68 | return new Response(JSON.stringify(["Went wrong:" + e.message]), { 69 | status: 500, 70 | }); 71 | } 72 | }, 73 | }; 74 | -------------------------------------------------------------------------------- /with-deno/call-directly/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "deploy": { 3 | "project": "77b4d8f0-8082-42b5-ab32-7dfdaf868d3e", 4 | "exclude": [ 5 | "**/node_modules" 6 | ], 7 | "include": [], 8 | "entrypoint": "main.ts" 9 | } 10 | } -------------------------------------------------------------------------------- /with-deno/call-directly/main.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | fetch: async (request: Request) => { 3 | const time = Date.now(); 4 | try { 5 | const url = new URL(request.url); 6 | const count = url.searchParams.get("count"); 7 | const urls = 8 | request.method === "POST" 9 | ? await request.json() 10 | : new Array(Number(count || 100)).fill(null).map( 11 | // random to prevent cache 12 | (_) => `https://test.github-backup.com/?random=${Math.random()}`, 13 | ); 14 | 15 | console.log("count", urls.length); 16 | 17 | if (urls.length <= 100) { 18 | //base case 19 | const results = await Promise.all( 20 | urls.map(async (url) => { 21 | // fetch something 22 | const result = await fetch(url).then((res) => res.text()); 23 | 24 | return result; 25 | }), 26 | ); 27 | // can be done concurrent 28 | const ms = Date.now() - time; 29 | 30 | return new Response(JSON.stringify({ results, ms })); 31 | } 32 | 33 | const splitCount = 34 | urls.length < 10000 ? 100 : Math.ceil(urls.length / 100); 35 | const amount = urls.length < 10000 ? Math.ceil(urls.length / 100) : 100; 36 | console.log({ splitCount, amount }); 37 | const results = ( 38 | await Promise.all( 39 | new Array(amount).fill(null).map(async (_, index) => { 40 | // fetch self 41 | const part = urls.slice( 42 | splitCount * index, 43 | splitCount * index + splitCount, 44 | ); 45 | 46 | if (part.length === 0) { 47 | return []; 48 | } 49 | 50 | const results = await fetch( 51 | `https://call-directly-alternate.deno.dev/`, 52 | { 53 | method: "POST", 54 | body: JSON.stringify(part), 55 | }, 56 | ).then((res) => res.text()); 57 | 58 | try { 59 | return JSON.parse(results)?.results; 60 | } catch (e) { 61 | return results; 62 | } 63 | }), 64 | ) 65 | ).flat(); 66 | 67 | const ms = Date.now() - time; 68 | // can be done concurrent 69 | return new Response(JSON.stringify({ results, ms })); 70 | } catch (e) { 71 | return new Response(JSON.stringify(["Went wrong:" + e.message]), { 72 | status: 500, 73 | }); 74 | } 75 | }, 76 | }; 77 | -------------------------------------------------------------------------------- /with-deno/locally.ts: -------------------------------------------------------------------------------- 1 | const results = ( 2 | await Promise.all( 3 | new Array(10).fill(null).map((_) => { 4 | return fetch("https://call-directly.deno.dev/").then((res) => res.json()); 5 | }), 6 | ) 7 | ) 8 | .map((x) => x.results) 9 | .flat(); 10 | 11 | console.log({ results }); 12 | -------------------------------------------------------------------------------- /with-deno/slow-worker/main.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | fetch: async (request: Request) => { 3 | await new Promise((resolve) => setTimeout(() => resolve(), 5000)); 4 | return new Response(String(Math.random())); 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /with-deno/slow-worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "types": "npx wrangler types --experimental-include-runtime" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /with-deno/slow-worker/wrangler.toml: -------------------------------------------------------------------------------- 1 | #:schema node_modules/wrangler/config-schema.json 2 | 3 | name = "slow-worker" 4 | main = "./main.ts" 5 | compatibility_date = "2025-01-01" 6 | routes = [{ pattern = "test.github-backup.com", custom_domain = true }] -------------------------------------------------------------------------------- /with-do/README.md: -------------------------------------------------------------------------------- 1 | This implementation fetches an url 499 times. 2 | 3 | The endpoint behind the URL takes 5 seconds to load. 4 | 5 | This number is intentionally 499, because the worker crashes for 500 requests or more. I assume because the limit of 1000 sub-requests (and making the DO also counts as a request, i suppose) 6 | 7 | This is what it returns: 8 | 9 | ```json 10 | { 11 | "firstPass": 0, 12 | "secondPass": 2213, 13 | "thirdPass": 5078, 14 | "full": 7291, 15 | "results": [ 16 | { 17 | "url": "https://test.github-backup.com/?random=0.10693229688140571", 18 | "result": { 19 | "status": "completed", 20 | "result": 5058 21 | } 22 | } 23 | //..... 24 | ] 25 | } 26 | ``` 27 | 28 | This means creating 499 DO's and sending initial requests to them is done in 2.22 seconds. After that, waiting for all of them is just 5.08s, proving all requests happen in parallel. 29 | 30 | Test yourself at https://fetch-many-do.githuq.workers.dev 31 | -------------------------------------------------------------------------------- /with-do/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-do", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "devDependencies": { 8 | "@cloudflare/workers-types": "^4.20241230.0" 9 | } 10 | }, 11 | "node_modules/@cloudflare/workers-types": { 12 | "version": "4.20250109.0", 13 | "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20250109.0.tgz", 14 | "integrity": "sha512-Y1zgSaEOOevl9ORpzgMcm4j535p3nK2lrblHHvYM2yxR50SBKGh+wvkRFAIxWRfjUGZEU+Fp6923EGioDBbobA==", 15 | "dev": true, 16 | "license": "MIT OR Apache-2.0" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /with-do/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": {}, 3 | "devDependencies": { 4 | "@cloudflare/workers-types": "^4.20241230.0" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /with-do/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts"], 3 | "compilerOptions": { 4 | "resolveJsonModule": true, 5 | "module": "NodeNext", 6 | "moduleResolution": "NodeNext", 7 | "lib": ["es2022", "DOM"], 8 | "strict": true, 9 | "types": ["@cloudflare/workers-types/2023-07-01"], 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "skipLibCheck": true, 13 | "declaration": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /with-do/worker.ts: -------------------------------------------------------------------------------- 1 | // worker.ts 2 | 3 | import { DurableObject } from "cloudflare:workers"; 4 | 5 | export interface Env { 6 | RATE_LIMITER: DurableObjectNamespace; 7 | } 8 | 9 | export default { 10 | async fetch(request: Request, env: Env): Promise { 11 | const t0 = Date.now(); 12 | const urls = Array.from({ length: 499 }, (_, i) => ({ 13 | // this url takes 5s 14 | url: `https://test.github-backup.com/?random=${Math.random()}`, 15 | id: (i + 1).toString(), 16 | })); 17 | 18 | console.log("urls:", urls.length); 19 | 20 | const doIds: { id: DurableObjectId; url: string }[] = []; 21 | 22 | // First pass: Create the DOs 23 | const initPromises = urls.map(async ({ url, id }) => { 24 | console.log({ id }); 25 | const doId = env.RATE_LIMITER.newUniqueId(); 26 | doIds.push({ id: doId, url }); 27 | }); 28 | 29 | await Promise.all(initPromises); 30 | const t1 = Date.now(); 31 | 32 | const first = await Promise.all( 33 | doIds.map(async ({ id, url }) => { 34 | const instance = env.RATE_LIMITER.get(id); 35 | const response = await instance.fetch( 36 | new Request(url, { 37 | method: "POST", 38 | headers: { 39 | "Content-Type": "application/json", 40 | }, 41 | }), 42 | ); 43 | 44 | return await response.json(); 45 | }), 46 | ); 47 | const t2 = Date.now(); 48 | 49 | // Second pass: Check results from all DOs 50 | const resultPromises = doIds.map(async ({ id, url }) => { 51 | const instance = env.RATE_LIMITER.get(id); 52 | const response = await instance.fetch( 53 | new Request(url, { 54 | method: "GET", 55 | headers: { 56 | "Content-Type": "application/json", 57 | }, 58 | }), 59 | ); 60 | 61 | const result = await response.json(); 62 | 63 | return { 64 | doName: id.name, 65 | url, 66 | result, 67 | }; 68 | }); 69 | 70 | const results = await Promise.all(resultPromises); 71 | const t3 = Date.now(); 72 | 73 | return new Response( 74 | JSON.stringify( 75 | { 76 | firstPass: t1 - t0, 77 | secondPass: t2 - t1, 78 | thirdPass: t3 - t2, 79 | full: t3 - t0, 80 | results, 81 | }, 82 | null, 83 | 2, 84 | ), 85 | { 86 | headers: { 87 | "Content-Type": "application/json", 88 | }, 89 | }, 90 | ); 91 | }, 92 | }; 93 | 94 | // Durable Object implementation 95 | export class RequestSchedulerDO extends DurableObject { 96 | private state: DurableObjectState; 97 | private fetchPromises: Map>; 98 | private results: Map; // New map to store results in memory 99 | 100 | constructor(state: DurableObjectState, env: Env) { 101 | super(state, env); 102 | this.state = state; 103 | this.env = env; 104 | this.fetchPromises = new Map(); 105 | this.results = new Map(); // Initialize results map 106 | } 107 | 108 | async fetch(request: Request): Promise { 109 | try { 110 | const url = request.url; 111 | 112 | if (request.method === "POST") { 113 | // Initialize new fetch 114 | if (!this.fetchPromises.has(url)) { 115 | const time = Date.now(); 116 | const fetchPromise = fetch(url) 117 | .then(async (response) => { 118 | const text = await response.text(); 119 | const duration = Date.now() - time; 120 | this.results.set(url, duration); // Store in memory instead of DO storage 121 | return response; 122 | }) 123 | .catch((error) => { 124 | console.error(`Fetch error for ${url}:`, error); 125 | return new Response(`Error: ${error.message}`, { status: 500 }); 126 | }); 127 | 128 | this.fetchPromises.set(url, fetchPromise); 129 | } 130 | 131 | return new Response( 132 | JSON.stringify({ 133 | status: "processing", 134 | url: request.url, 135 | }), 136 | { 137 | headers: { 138 | "Content-Type": "application/json", 139 | }, 140 | }, 141 | ); 142 | } else if (request.method === "GET") { 143 | // Check status and return result 144 | const promise = this.fetchPromises.get(url); 145 | if (!promise) { 146 | return new Response( 147 | JSON.stringify({ error: "No fetch in progress for this URL" }), 148 | { status: 404 }, 149 | ); 150 | } 151 | 152 | await promise; // Wait for the fetch to complete 153 | const result = this.results.get(url); // Get result from memory 154 | 155 | return new Response( 156 | JSON.stringify({ 157 | status: "completed", 158 | result, 159 | }), 160 | { 161 | headers: { 162 | "Content-Type": "application/json", 163 | }, 164 | }, 165 | ); 166 | } 167 | 168 | return new Response("Invalid method", { status: 405 }); 169 | } catch (error: any) { 170 | return new Response(`Error: ${error.message}`, { 171 | status: 500, 172 | }); 173 | } 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /with-do/wrangler.toml: -------------------------------------------------------------------------------- 1 | #:schema node_modules/wrangler/config-schema.json 2 | name = "fetch-many-do" 3 | main = "worker.ts" 4 | compatibility_date = "2025-01-09" 5 | 6 | [durable_objects] 7 | bindings = [ 8 | { name = "RATE_LIMITER", class_name = "RequestSchedulerDO" } 9 | ] 10 | 11 | [[migrations]] 12 | tag = "v1" 13 | new_classes = ["RequestSchedulerDO"] -------------------------------------------------------------------------------- /with-do6/README.md: -------------------------------------------------------------------------------- 1 | This implementation is the same as [with-do](../with-do) but instead it does 6 requests in each worker. Interestingly enough, it works, surpassing the 1000 subrequest limit. 2 | 3 | This means the limit seems to be unrelated to what we do inside of the DO. The limit is rather the request to the DO (twice) which makes 2x500=1000. 4 | 5 | In the DO, we can do as many requests as we want. 6 | 7 | Another interesting observation is that this takes 16.5s instead of 7. This shows how fast CF scales on the pro plan. Apparently, more requests outwards also takes a longer time, and it's not just the 6 concurrent requests that matters, but more likely the server load. 8 | 9 | ```json 10 | { 11 | "firstPass": 0, 12 | "secondPass": 2262, 13 | "thirdPass": 14235, 14 | "full": 16497, 15 | "results": [ 16 | { 17 | "urls": [ 18 | "https://test.github-backup.com/?random=0.9895069871610036&request=0", 19 | "https://test.github-backup.com/?random=0.81532704150307&request=1", 20 | "https://test.github-backup.com/?random=0.6285762251415596&request=2", 21 | "https://test.github-backup.com/?random=0.8455794640554448&request=3", 22 | "https://test.github-backup.com/?random=0.5812898782659197&request=4", 23 | "https://test.github-backup.com/?random=0.7232639741259355&request=5" 24 | ], 25 | "result": { 26 | "status": "completed", 27 | "results": { 28 | "https://test.github-backup.com/?random=0.9895069871610036&request=0": 5038, 29 | "https://test.github-backup.com/?random=0.81532704150307&request=1": 5037, 30 | "https://test.github-backup.com/?random=0.6285762251415596&request=2": 5042, 31 | "https://test.github-backup.com/?random=0.8455794640554448&request=3": 5038, 32 | "https://test.github-backup.com/?random=0.5812898782659197&request=4": 5040, 33 | "https://test.github-backup.com/?random=0.7232639741259355&request=5": 5051 34 | } 35 | } 36 | } 37 | , 38 | //...498 more 39 | 40 | } 41 | ``` 42 | -------------------------------------------------------------------------------- /with-do6/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-do", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "devDependencies": { 8 | "@cloudflare/workers-types": "^4.20241230.0" 9 | } 10 | }, 11 | "node_modules/@cloudflare/workers-types": { 12 | "version": "4.20250109.0", 13 | "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20250109.0.tgz", 14 | "integrity": "sha512-Y1zgSaEOOevl9ORpzgMcm4j535p3nK2lrblHHvYM2yxR50SBKGh+wvkRFAIxWRfjUGZEU+Fp6923EGioDBbobA==", 15 | "dev": true, 16 | "license": "MIT OR Apache-2.0" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /with-do6/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": {}, 3 | "devDependencies": { 4 | "@cloudflare/workers-types": "^4.20241230.0" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /with-do6/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts"], 3 | "compilerOptions": { 4 | "resolveJsonModule": true, 5 | "module": "NodeNext", 6 | "moduleResolution": "NodeNext", 7 | "lib": ["es2022", "DOM"], 8 | "strict": true, 9 | "types": ["@cloudflare/workers-types/2023-07-01"], 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "skipLibCheck": true, 13 | "declaration": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /with-do6/worker.ts: -------------------------------------------------------------------------------- 1 | // worker.ts 2 | 3 | import { DurableObject } from "cloudflare:workers"; 4 | 5 | export interface Env { 6 | PDO: DurableObjectNamespace; 7 | } 8 | 9 | export default { 10 | async fetch(request: Request, env: Env): Promise { 11 | const t0 = Date.now(); 12 | const urls = Array.from({ length: 499 }, (_, i) => ({ 13 | urls: Array.from( 14 | { length: 6 }, 15 | (_, j) => 16 | `https://test.github-backup.com/?random=${Math.random()}&request=${j}`, 17 | ), 18 | id: (i + 1).toString(), 19 | })); 20 | 21 | console.log("urls:", urls.length); 22 | 23 | const doIds: { id: DurableObjectId; urls: string[] }[] = []; 24 | 25 | // First pass: Create the DOs 26 | const initPromises = urls.map(async ({ urls, id }) => { 27 | console.log({ id }); 28 | const doId = env.PDO.newUniqueId(); 29 | doIds.push({ id: doId, urls }); 30 | }); 31 | 32 | await Promise.all(initPromises); 33 | const t1 = Date.now(); 34 | 35 | // Second pass: Initialize fetches in all DOs 36 | const first = await Promise.all( 37 | doIds.map(async ({ id, urls }) => { 38 | const instance = env.PDO.get(id); 39 | const response = await instance.fetch( 40 | new Request(urls[0], { 41 | method: "POST", 42 | headers: { 43 | "Content-Type": "application/json", 44 | }, 45 | body: JSON.stringify({ urls }), 46 | }), 47 | ); 48 | 49 | return await response.json(); 50 | }), 51 | ); 52 | const t2 = Date.now(); 53 | 54 | // Third pass: Check results from all DOs 55 | const resultPromises = doIds.map(async ({ id, urls }) => { 56 | const instance = env.PDO.get(id); 57 | const response = await instance.fetch( 58 | new Request(urls[0], { 59 | method: "GET", 60 | headers: { 61 | "Content-Type": "application/json", 62 | }, 63 | }), 64 | ); 65 | 66 | const result = await response.json(); 67 | 68 | return { 69 | doName: id.name, 70 | urls, 71 | result, 72 | }; 73 | }); 74 | 75 | const results = await Promise.all(resultPromises); 76 | const t3 = Date.now(); 77 | 78 | return new Response( 79 | JSON.stringify( 80 | { 81 | firstPass: t1 - t0, 82 | secondPass: t2 - t1, 83 | thirdPass: t3 - t2, 84 | full: t3 - t0, 85 | results, 86 | }, 87 | null, 88 | 2, 89 | ), 90 | { 91 | headers: { 92 | "Content-Type": "application/json", 93 | }, 94 | }, 95 | ); 96 | }, 97 | }; 98 | 99 | // Durable Object implementation 100 | export class PDO extends DurableObject { 101 | private state: DurableObjectState; 102 | private fetchPromises: Map>; 103 | private results: Map; 104 | 105 | constructor(state: DurableObjectState, env: Env) { 106 | super(state, env); 107 | this.state = state; 108 | this.env = env; 109 | this.fetchPromises = new Map(); 110 | this.results = new Map(); 111 | } 112 | 113 | async fetch(request: Request): Promise { 114 | try { 115 | const url = request.url; 116 | 117 | if (request.method === "POST") { 118 | // Initialize new fetches for all URLs 119 | const { urls }: { urls: string[] } = await request.json(); 120 | 121 | for (const fetchUrl of urls) { 122 | if (!this.fetchPromises.has(fetchUrl)) { 123 | const time = Date.now(); 124 | const fetchPromise = fetch(fetchUrl) 125 | .then(async (response) => { 126 | const text = await response.text(); 127 | const duration = Date.now() - time; 128 | this.results.set(fetchUrl, duration); 129 | return response; 130 | }) 131 | .catch((error) => { 132 | console.error(`Fetch error for ${fetchUrl}:`, error); 133 | return new Response(`Error: ${error.message}`, { status: 500 }); 134 | }); 135 | 136 | this.fetchPromises.set(fetchUrl, fetchPromise); 137 | } 138 | } 139 | 140 | return new Response( 141 | JSON.stringify({ 142 | status: "processing", 143 | urls, 144 | }), 145 | { 146 | headers: { 147 | "Content-Type": "application/json", 148 | }, 149 | }, 150 | ); 151 | } else if (request.method === "GET") { 152 | // Check status and return results for all URLs 153 | const promises = Array.from(this.fetchPromises.values()); 154 | const urls = Array.from(this.fetchPromises.keys()); 155 | 156 | // Wait for all promises to complete concurrently 157 | await Promise.all(promises); 158 | 159 | const allResults = Object.fromEntries( 160 | urls.map((url) => [url, this.results.get(url)]), 161 | ); 162 | 163 | return new Response( 164 | JSON.stringify({ 165 | status: "completed", 166 | results: allResults, 167 | }), 168 | { 169 | headers: { 170 | "Content-Type": "application/json", 171 | }, 172 | }, 173 | ); 174 | } 175 | 176 | return new Response("Invalid method", { status: 405 }); 177 | } catch (error: any) { 178 | return new Response(`Error: ${error.message}`, { 179 | status: 500, 180 | }); 181 | } 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /with-do6/wrangler.toml: -------------------------------------------------------------------------------- 1 | #:schema node_modules/wrangler/config-schema.json 2 | name = "fetch-do6" 3 | main = "worker.ts" 4 | compatibility_date = "2025-01-09" 5 | 6 | [durable_objects] 7 | bindings = [ 8 | { name = "PDO", class_name = "PDO" } 9 | ] 10 | 11 | [[migrations]] 12 | tag = "v1" 13 | new_classes = ["PDO"] --------------------------------------------------------------------------------