{"id":"adr-0001-storage","title":"ADR-0001 — Hybrid GCS + R2 Storage","subtitle":"Why hybrid object storage, and why we deferred Cloudflare-fronted GCS.","category":"operations","tags":["adr","infrastructure","storage"],"source":"articles/docs/decisions/0001-r2-over-bandwidth-alliance.md","lang":"en","words":900,"readMinutes":4,"toc":[{"depth":2,"text":"1. Context","id":"1-context"},{"depth":3,"text":"1.1 What exists today","id":"1-1-what-exists-today"},{"depth":3,"text":"1.2 What is missing vs. the blueprint","id":"1-2-what-is-missing-vs-the-blueprint"},{"depth":2,"text":"2. Decision","id":"2-decision"},{"depth":2,"text":"3. Consequences","id":"3-consequences"},{"depth":2,"text":"4. Action items","id":"4-action-items"},{"depth":3,"text":"4.1 In this PR (Path A scaffolding, no infra deploy)","id":"4-1-in-this-pr-path-a-scaffolding-no-infra-deploy"},{"depth":3,"text":"4.2 Deferred (requires production access)","id":"4-2-deferred-requires-production-access"},{"depth":3,"text":"4.3 Optional follow-up","id":"4-3-optional-follow-up"},{"depth":2,"text":"5. Inventory of hardcoded GCS URLs (replace in §4.2)","id":"5-inventory-of-hardcoded-gcs-urls-replace-in-4-2"},{"depth":2,"text":"6. References","id":"6-references"}],"html":"<h1 id=\"adr-0001-hybrid-gcs-r2-storage-defer-cloudflare-fronted-gcs\">ADR-0001: Hybrid GCS + R2 storage; defer Cloudflare-fronted GCS</h1><p><strong>Status:</strong> Accepted (interim) — supersedes a TBD by the migration plan in §4.\n<strong>Date:</strong> 2026-05-11\n<strong>Author:</strong> A. Welcing\n<strong>Handoff unit:</strong> 12 — &quot;GCS → Cloudflare CDN for <code>.glimbin</code> (Bandwidth Alliance)&quot;</p>\n<hr>\n<h2 id=\"1-context\">1. Context</h2><p>The (unwritten) handoff blueprint <code>docs/handoff/03_gcp_heavy_workload_blueprint.md</code> §2 calls\nfor &quot;a Cloudflare CDN layer directly in front of our GCS buckets, [utilizing] the Bandwidth\nAlliance to drastically reduce or eliminate egress fees.&quot; That blueprint was never committed;\nthe actual implementation diverged. This ADR records what we actually built, why it diverged,\nand what would close the gap.</p>\n<h3 id=\"1-1-what-exists-today\">1.1 What exists today</h3><p>Two distinct object stores, each chosen for the workload it serves:</p>\n<div class=\"table-wrap\"><table><thead><tr>\n<th>Store</th>\n<th>Bucket</th>\n<th>Workload</th>\n<th>Consumers</th>\n</tr>\n</thead><tbody><tr>\n<td data-label=\"Store\">Google Cloud Storage</td>\n<td data-label=\"Bucket\"><code>shed-489901-atlas-artifacts</code></td>\n<td data-label=\"Workload\">Large <code>.lammpstrj</code> and <code>.glimbin</code> molecular-dynamics trajectories (~0.9 GB across 16 open-data files, plus future user uploads)</td>\n<td data-label=\"Consumers\"><code>atlas-view</code> (web)</td>\n</tr>\n<tr>\n<td data-label=\"Store\">Cloudflare R2</td>\n<td data-label=\"Bucket\"><code>glim-artifacts</code> (binding <code>ARTIFACTS</code>)</td>\n<td data-label=\"Workload\">Diary entries, search caches, claim snapshots, agent state</td>\n<td data-label=\"Consumers\"><code>glim-think</code> Worker (Durable Objects + Workers AI)</td>\n</tr>\n</tbody></table></div><p>GCS objects are served from <code>https://storage.googleapis.com/shed-489901-atlas-artifacts/...</code>\ndirectly to browsers, with <code>Cache-Control: public, max-age=31536000, immutable</code> and a CORS\npolicy locked to <code>glim.lupine.dev</code>, <code>lupi.live</code>, and localhost dev ports.</p>\n<p>The <code>.glimbin</code> streaming pipeline (<code>atlas/atlas-view/packages/parsers/src/StreamingLoader.ts</code>)\nuses HTTP Range Requests to fetch only the header + observed-frame slices — a 2 GB file\ntypically transfers ~30 MB per session. This dramatically blunts egress cost even without\nBandwidth Alliance.</p>\n<h3 id=\"1-2-what-is-missing-vs-the-blueprint\">1.2 What is missing vs. the blueprint</h3><p>The blueprint asked for a Cloudflare proxy in front of GCS so that egress would route through\nthe Bandwidth Alliance (zero or near-zero egress charges between Cloudflare and GCS Standard\nin the same region). We have:</p>\n<ul>\n<li>a CORS policy that already permits the production domains,</li>\n<li>a comment in <code>setup-gcs-bucket.sh</code> that documents the <em>intended</em> Cloudflare host\n<code>https://datasets.glim.lupine.dev/datasets/file.glimbin</code>,</li>\n<li>a comment header in <code>StreamingLoader.ts</code> (&quot;Designed for GCS + Cloudflare CDN with\nBandwidth Alliance&quot;),</li>\n</ul>\n<p>…but <strong>no Worker, no DNS record, and no consumer configuration</strong> wired to a CDN base URL.\nEvery consumer hardcodes <code>storage.googleapis.com/...</code> (eight files; see §5 below).</p>\n<h2 id=\"2-decision\">2. Decision</h2><ol>\n<li><p><strong>Keep the hybrid: GCS for cold-immutable artifact blobs, R2 for hot Worker-bound state.</strong>\nThe two stores are not redundant. R2 is bound directly to glim-think&#39;s Worker via\n<code>wrangler.toml</code>, which gives in-network reads at zero latency and zero egress — there\nis no Cloudflare/GCS substitute for an R2 binding inside a Worker. GCS hosts the\ntrajectories because the upload tooling (<code>gsutil</code>, <code>glimbin_convert.py</code>) and quota\nceiling are friendlier than R2 for multi-GB scientific datasets.</p>\n</li>\n<li><p><strong>Land the Cloudflare-fronted-GCS scaffolding in code now; defer the deploy.</strong> The\nmigration is one DNS record and one Worker route away. The work that <em>can</em> be done\nin the worktree — a centralized <code>ATLAS_CDN_BASE</code> config, a deployable proxy Worker,\na runbook — lands in this PR. The remaining work (cutover the consumers, prove\negress savings) is scheduled in §4.</p>\n</li>\n<li><p><strong>Do not migrate <code>shed-489901-atlas-artifacts</code> to R2.</strong> R2&#39;s free egress already\ncovers the Worker side; the trajectories are served straight to browsers and would\nneed a public R2 bucket or a Worker proxy regardless. The benefit of moving them\nis small relative to the upload-tooling churn.</p>\n</li>\n</ol>\n<h2 id=\"3-consequences\">3. Consequences</h2><p><strong>Positive</strong></p>\n<ul>\n<li>Both stores remain canonical, no data migration risk.</li>\n<li>The CDN scaffolding (cdn.ts + Worker) is in the repo; turning it on is a config change.</li>\n<li>The ADR closes the spec gap on paper — future readers know why the blueprint diverged.</li>\n</ul>\n<p><strong>Negative</strong></p>\n<ul>\n<li>We continue to pay GCS egress on the open-data trajectories until the CDN is live.\nRange-request streaming caps the bleed at ~30 MB/session, but it is not zero.</li>\n<li>The &quot;Cloudflare CDN&quot; comment in <code>StreamingLoader.ts</code> will remain aspirational until\n§4.2 lands.</li>\n</ul>\n<p><strong>Neutral</strong></p>\n<ul>\n<li>glim-think and atlas-view continue to evolve independently. No new coupling.</li>\n</ul>\n<h2 id=\"4-action-items\">4. Action items</h2><h3 id=\"4-1-in-this-pr-path-a-scaffolding-no-infra-deploy\">4.1 In this PR (Path A scaffolding, no infra deploy)</h3><ul>\n<li><input checked=\"\" disabled=\"\" type=\"checkbox\"> <code>atlas/atlas-view/packages/core/src/cdn.ts</code> — <code>ATLAS_CDN_BASE</code> config helper,\nreads <code>import.meta.env.VITE_ATLAS_CDN_BASE</code> with a <code>storage.googleapis.com</code>\ndefault so prod traffic is unaffected.</li>\n<li><input checked=\"\" disabled=\"\" type=\"checkbox\"> <code>cloudflare/cdn-proxy/</code> — deployable Cloudflare Worker (TS) that proxies\n<code>https://cdn.lupine.dev/*</code> to <code>https://storage.googleapis.com/shed-489901-atlas-artifacts/*</code>\nwith immutable cache headers. Bundled <code>wrangler.toml</code>, README, and a smoke test.</li>\n<li><input checked=\"\" disabled=\"\" type=\"checkbox\"> <code>docs/infrastructure/cdn.md</code> — runbook covering DNS, Worker deploy, and the\n<code>VITE_ATLAS_CDN_BASE</code> cutover.</li>\n</ul>\n<h3 id=\"4-2-deferred-requires-production-access\">4.2 Deferred (requires production access)</h3><ul>\n<li><input disabled=\"\" type=\"checkbox\"> Decide between (a) a Worker proxy or (b) Cloudflare Cache Reserve in front of a\npublic GCS bucket. Worker is more flexible (auth, transforms, logging); Cache\nReserve is one-line config but has its own pricing model.</li>\n<li><input disabled=\"\" type=\"checkbox\"> Pull the last 30 days of GCS egress for <code>shed-489901-atlas-artifacts</code> from Cloud\nBilling. If monthly egress &gt; <span class=\"katex\"><span class=\"katex-mathml\"><math xmlns=\"http://www.w3.org/1998/Math/MathML\"><semantics><mrow><mn>50</mn><mo separator=\"true\">,</mo><mi>p</mi><mi>r</mi><mi>i</mi><mi>o</mi><mi>r</mi><mi>i</mi><mi>t</mi><mi>i</mi><mi>z</mi><mi>e</mi><mn>4.3</mn><mo separator=\"true\">;</mo><mi>i</mi><mi>f</mi><mo>&lt;</mo></mrow><annotation encoding=\"application/x-tex\">50, prioritize 4.3; if &lt;</annotation></semantics></math></span><span class=\"katex-html\" aria-hidden=\"true\"><span class=\"base\"><span class=\"strut\" style=\"height:0.8889em;vertical-align:-0.1944em;\"></span><span class=\"mord\">50</span><span class=\"mpunct\">,</span><span class=\"mspace\" style=\"margin-right:0.1667em;\"></span><span class=\"mord mathnormal\">p</span><span class=\"mord mathnormal\" style=\"margin-right:0.0278em;\">r</span><span class=\"mord mathnormal\">i</span><span class=\"mord mathnormal\" style=\"margin-right:0.0278em;\">or</span><span class=\"mord mathnormal\">i</span><span class=\"mord mathnormal\">t</span><span class=\"mord mathnormal\">i</span><span class=\"mord mathnormal\" style=\"margin-right:0.044em;\">z</span><span class=\"mord mathnormal\">e</span><span class=\"mord\">4.3</span><span class=\"mpunct\">;</span><span class=\"mspace\" style=\"margin-right:0.1667em;\"></span><span class=\"mord mathnormal\">i</span><span class=\"mord mathnormal\" style=\"margin-right:0.1076em;\">f</span><span class=\"mspace\" style=\"margin-right:0.2778em;\"></span><span class=\"mrel\">&lt;</span></span></span></span>10, this stays at &quot;scaffolding\nready, not deployed.&quot;</li>\n<li><input disabled=\"\" type=\"checkbox\"> Verify Bandwidth Alliance eligibility for the GCP project + Cloudflare account\n(rules: <a href=\"https://www.cloudflare.com/bandwidth-alliance/\">https://www.cloudflare.com/bandwidth-alliance/</a>).</li>\n<li><input disabled=\"\" type=\"checkbox\"> Provision Cloudflare DNS <code>cdn.lupine.dev</code> proxied to the Worker.</li>\n<li><input disabled=\"\" type=\"checkbox\"> Deploy Worker via <code>wrangler deploy</code> from <code>cloudflare/cdn-proxy/</code>.</li>\n<li><input disabled=\"\" type=\"checkbox\"> Set <code>VITE_ATLAS_CDN_BASE=https://cdn.lupine.dev</code> in the atlas-view production\nbuild (Cloud Run / Pages env) and rebuild.</li>\n<li><input disabled=\"\" type=\"checkbox\"> Update the 8 files in §5 to consume the active base via <code>cdnUrl()</code> instead of\nhardcoded <code>storage.googleapis.com/...</code> strings.</li>\n</ul>\n<h3 id=\"4-3-optional-follow-up\">4.3 Optional follow-up</h3><ul>\n<li><input disabled=\"\" type=\"checkbox\"> If egress remains high after Bandwidth Alliance, evaluate migrating <em>new</em>\nopen-data trajectories to a public R2 bucket (<code>atlas-artifacts</code> R2) and\ndouble-writing during a deprecation window for the GCS objects.</li>\n</ul>\n<h2 id=\"5-inventory-of-hardcoded-gcs-urls-replace-in-4-2\">5. Inventory of hardcoded GCS URLs (replace in §4.2)</h2><pre><code>atlas/atlas-view/tools/glimbin_convert.py\natlas/atlas-view/scripts/inject_open_md_entries.py\natlas/atlas-view/scripts/gcs/upload_open_data.sh\natlas/atlas-view/scripts/gcs/repoint_sourceurl_to_gcs.py\natlas/atlas-view/scripts/gcs/pull_open_data.sh\natlas/atlas-view/packages/ui/src/gallery-data.json\natlas/atlas-view/apps/web/public/gallery/open_data/README.md\n</code></pre>\n<p>The Python and shell tooling rewrites are batched after Worker deploy because they\ngenerate the URLs that get baked into <code>gallery-data.json</code>.</p>\n<h2 id=\"6-references\">6. References</h2><ul>\n<li><code>infra/setup-gcs-bucket.sh</code> — already references <code>datasets.glim.lupine.dev</code> in its\nclosing banner. This ADR ratifies that intent without committing to the deploy yet.</li>\n<li><code>atlas/atlas-view/packages/parsers/src/StreamingLoader.ts</code> — header comment names the\nBandwidth Alliance pairing as the design target.</li>\n<li><code>glim-think/wrangler.toml</code> lines 76–79 — R2 binding for the <em>other</em> workload.</li>\n<li>Cloudflare Bandwidth Alliance docs: <a href=\"https://www.cloudflare.com/bandwidth-alliance/\">https://www.cloudflare.com/bandwidth-alliance/</a></li>\n</ul>\n"}