Blame
|
1 | --- |
||||||
| 2 | category: reference |
|||||||
| 3 | tags: [task, performance, cdn, architecture] |
|||||||
| 4 | last_updated: 2026-03-14 |
|||||||
| 5 | confidence: high |
|||||||
| 6 | --- |
|||||||
| 7 | ||||||||
| 8 | # E-2: CDN Read Path Implementation |
|||||||
| 9 | ||||||||
| 10 | **Status:** Planned |
|||||||
| 11 | **Depends on:** Phase 2 complete, [[Dev/E-1_Cold_Start_Benchmarks]], [[Design/CDN_Read_Path]] |
|||||||
| 12 | **Branch:** `feat/E-2-cdn-read-path` from `phase-2` |
|||||||
| 13 | ||||||||
| 14 | ## Problem |
|||||||
| 15 | ||||||||
| 16 | The otterwiki Lambda cold starts in ~4.5s (see [[Dev/E-1_Cold_Start_Benchmarks]]). 79% of that is `from otterwiki.server import app`. Wiki pages are read-heavy, write-light. The read path should not depend on the heavy Lambda. |
|||||||
| 17 | ||||||||
| 18 | ## Solution: Option A — Thin Assembly Lambda |
|||||||
| 19 | ||||||||
| 20 | Per [[Design/CDN_Read_Path]], we decouple reads from the otterwiki Lambda: |
|||||||
| 21 | ||||||||
| 22 | ``` |
|||||||
| 23 | Write path (unchanged): |
|||||||
| 24 | MCP/API → API Gateway → Otterwiki Lambda (VPC+EFS) → git repo |
|||||||
| 25 | ||||||||
| 26 | Read path (new): |
|||||||
| 27 | Browser → CloudFront → [cache hit] → HTML (~10-50ms) |
|||||||
| 28 | → [cache miss] → Assembly Lambda (no VPC) → S3 fragments (~40ms warm, ~1s cold) |
|||||||
| 29 | ``` |
|||||||
| 30 | ||||||||
| 31 | ### Benchmarked Performance |
|||||||
| 32 | ||||||||
| 33 | From the assembly Lambda spike: |
|||||||
| 34 | ||||||||
| 35 | | Scenario | Assembly time | Wall time | |
|||||||
| 36 | |----------|-------------|-----------| |
|||||||
| 37 | | Warm 256MB | 41ms | 144ms | |
|||||||
| 38 | | Cold 256MB | 416ms | 1,230ms | |
|||||||
| 39 | | Cold 128MB | 852ms | 1,708ms | |
|||||||
| 40 | ||||||||
| 41 | Behind CloudFront with 30s TTL, most reads are cache hits (~10-50ms). Cache misses hit the assembly Lambda, which stays warm with any traffic. Worst case (cold Lambda + cache miss) is ~1s — still 4x better than the otterwiki Lambda. |
|||||||
| 42 | ||||||||
| 43 | ## Technical Feasibility (Validated) |
|||||||
| 44 | ||||||||
| 45 | ### Content fragment rendering |
|||||||
| 46 | ||||||||
| 47 | `OtterwikiRenderer.markdown(text)` is fully context-free. No Flask request context, no `current_user`, no `url_for` needed. Returns `(html, toc, library_requirements)`. |
|||||||
| 48 | ||||||||
| 49 | Wiki links (`[[PageName]]`) render as plain `<a href="/PageName">` tags — no page-exists lookup, no red/blue coloring. Config flags `WIKILINK_STYLE` and `TREAT_UNDERSCORE_AS_SPACE_FOR_TITLES` affect rendering but are static per wiki. |
|||||||
| 50 | ||||||||
| 51 | Plugin hooks (`renderer_markdown_preprocess`, `renderer_html_postprocess`) are no-ops for our installed plugins (otterwiki-api and otterwiki-semantic-search inject nothing into rendered HTML). |
|||||||
| 52 | ||||||||
| 53 | ### Sidebar fragment rendering |
|||||||
| 54 | ||||||||
| 55 | `SidebarPageIndex` needs: |
|||||||
| 56 | - `storage.list()` — file list from git (available in the write Lambda at page-save time) |
|||||||
| 57 | - `app.config` — sidebar mode, focus, max_depth, case settings (static per wiki) |
|||||||
| 58 | ||||||||
| 59 | It does NOT need a Flask request context. The focus-mode highlighting depends on `pagepath` (current page), so we have two options: |
|||||||
| 60 | 1. Render a generic sidebar (all nodes collapsed/expanded per mode) and apply current-page highlighting client-side with a tiny JS snippet (~5 lines) |
|||||||
| 61 | 2. Render per-page sidebars (expensive for large wikis, doesn't scale) |
|||||||
| 62 | ||||||||
| 63 | **Decision: Option 1 — generic sidebar + client-side highlighting.** |
|||||||
| 64 | ||||||||
| 65 | The sidebar shortcut links (Home, A-Z, Create Page) are gated by `has_permission('READ')` and `has_permission('WRITE')`. For the CDN read path, we show the READ-level shortcuts only. Write-level features (Create Page, Edit button) are not relevant to the read path. |
|||||||
| 66 | ||||||||
| 67 | ### Shell template |
|||||||
| 68 | ||||||||
| 69 | The otterwiki template hierarchy is `page.html` → `wiki.html` → `layout.html`. |
|||||||
| 70 | ||||||||
| 71 | The shell needs to include: |
|||||||
| 72 | - CSS links: `halfmoon.min.css`, `otterwiki.css`, `print.css`, `fontawesome-all.min.css`, `pygments.css`, `roboto.css` |
|||||||
| 73 | - JS: `halfmoon.min.js`, `otterwiki.js` |
|||||||
| 74 | - Site name, logo, search form (static per wiki) |
|||||||
| 75 | - `{{SIDEBAR}}` and `{{CONTENT}}` placeholders |
|||||||
| 76 | ||||||||
| 77 | What we strip from the shell: |
|||||||
| 78 | - Edit/Rename/Delete buttons (write operations — not on read path) |
|||||||
| 79 | - Login/Logout dropdown (auth — not on CDN read path) |
|||||||
| 80 | - `request.cookies.get('halfmoon_preferredMode')` — handle dark mode client-side via JS (read cookie, apply class before first paint to avoid flash) |
|||||||
| 81 | - Flash messages (session-scoped, not applicable) |
|||||||
| 82 | - CSRF tokens (none in page view anyway) |
|||||||
| 83 | ||||||||
| 84 | ### Plugin injection points |
|||||||
| 85 | ||||||||
| 86 | Both otterwiki-api and otterwiki-semantic-search implement zero template injection hooks. The four injection points (`plugin_html_head_inject`, `plugin_html_body_inject`, `plugin_sidebar_left_inject`, `plugin_sidebar_right_inject`) all return empty strings. Safe to omit from shell. |
|||||||
| 87 | ||||||||
| 88 | ## Implementation Plan |
|||||||
| 89 | ||||||||
| 90 | ### Wave 1: Fragment Generation |
|||||||
| 91 | ||||||||
| 92 | **Task E-2a: Write-time fragment renderer** |
|||||||
| 93 | ||||||||
| 94 | New otterwiki plugin hook implementation or resolver middleware that fires on page save: |
|||||||
| 95 | ||||||||
| 96 | 1. `app/cdn/fragment_renderer.py` — Fragment generation module: |
|||||||
| 97 | - `render_content_fragment(markdown_text, config) -> str` — calls `OtterwikiRenderer.markdown()`, returns content HTML |
|||||||
| 98 | - `render_sidebar_fragment(file_list, config) -> str` — builds page tree, renders menutree HTML without request context |
|||||||
| 99 | - `render_shell_template(wiki_config) -> str` — generates the static shell HTML with `{{SIDEBAR}}` and `{{CONTENT}}` placeholders |
|||||||
| 100 | ||||||||
| 101 | 2. `app/cdn/s3_publisher.py` — S3 upload module: |
|||||||
| 102 | - `publish_content(user_id, wiki_slug, page_path, html)` — uploads to `s3://bucket/fragments/{user_id}/{wiki_slug}/pages/{page_path}.html` |
|||||||
| 103 | - `publish_sidebar(user_id, wiki_slug, html)` — uploads sidebar fragment |
|||||||
| 104 | - `publish_shell(user_id, wiki_slug, html)` — uploads shell template |
|||||||
| 105 | - Uses boto3 with `ContentType: text/html` and `CacheControl: max-age=31536000` (fragments are immutable — cache invalidation happens by serving new content through the assembly Lambda) |
|||||||
| 106 | ||||||||
| 107 | 3. Integration point — two options: |
|||||||
| 108 | - **Option A: otterwiki plugin hook** — implement `page_saved(app, storage, pagepath, message)` hook. Fires after every page save. Natural integration point, already used by semantic-search plugin. |
|||||||
| 109 | - **Option B: resolver middleware** — intercept successful write responses in `TenantResolver` and trigger fragment generation post-response. More complex, but doesn't require modifying the otterwiki plugin. |
|||||||
| 110 | - **Decision: Option A** — plugin hook is cleaner and already proven. |
|||||||
| 111 | ||||||||
| 112 | 4. Sidebar regeneration triggers: |
|||||||
| 113 | - Page create (new file in tree) — regenerate sidebar |
|||||||
| 114 | - Page delete — regenerate sidebar |
|||||||
| 115 | - Page rename — regenerate sidebar |
|||||||
| 116 | - Content-only edit — do NOT regenerate sidebar (file list unchanged) |
|||||||
| 117 | - Detection: compare `storage.list()` before/after, or check if the `page_saved` hook's metadata indicates a new page vs edit |
|||||||
| 118 | ||||||||
| 119 | 5. Shell regeneration triggers: |
|||||||
| 120 | - Wiki settings change (site name, logo, sidebar config) |
|||||||
| 121 | - Deploy (template/CSS changes) |
|||||||
| 122 | - Rare — can be manual or triggered by management API |
|||||||
| 123 | ||||||||
| 124 | **Tests:** ~15 unit tests with mocked S3 and a real OtterwikiRenderer instance. |
|||||||
| 125 | ||||||||
| 126 | ### Wave 2: Assembly Lambda + Infrastructure |
|||||||
| 127 | ||||||||
| 128 | **Task E-2b: Assembly Lambda** |
|||||||
| 129 | ||||||||
| 130 | Based on the validated spike (`app/poc/assembly_lambda.py`): |
|||||||
| 131 | ||||||||
| 132 | 1. `app/cdn/assembly_handler.py` — Production assembly Lambda: |
|||||||
| 133 | - Parse request: extract `{username}/{wiki_slug}/{page_path}` from CloudFront-forwarded path |
|||||||
| 134 | - Fetch 3 fragments from S3 in parallel (ThreadPoolExecutor) |
|||||||
| 135 | - String-substitute into shell template |
|||||||
| 136 | - Apply sidebar current-page highlighting (inject `data-current-page` attribute, let client JS handle) |
|||||||
| 137 | - Return assembled HTML with `Cache-Control: public, max-age=30` |
|||||||
| 138 | - Handle 404 (missing fragments → page not found) |
|||||||
| 139 | - Handle conditional rendering (MathJax/Mermaid JS inclusion based on content fragment metadata or markers) |
|||||||
| 140 | ||||||||
| 141 | 2. `infra/__main__.py` additions: |
|||||||
| 142 | - S3 bucket for fragments (or reuse existing `lambda-code-bucket`) |
|||||||
| 143 | - `SimpleLambdaComponent` for assembly Lambda (256MB, no VPC) |
|||||||
| 144 | - IAM: S3 GetObject on `fragments/*` |
|||||||
| 145 | - CloudFront distribution: |
|||||||
| 146 | - Origin 1: Assembly Lambda (read path, default behavior) |
|||||||
| 147 | - Origin 2: API Gateway (write path — `/admin/*`, MCP, API routes) |
|||||||
| 148 | - Behaviors: `GET /{username}/{wiki}/*` → Assembly Lambda origin; `POST/PUT/DELETE *` → API Gateway origin; static assets → S3 or passthrough |
|||||||
| 149 | - CloudFront cache policy: 30s TTL, cache by path only (not cookies/headers for public wikis) |
|||||||
| 150 | - DNS: point `*.wikibot.io` at CloudFront (currently points at API Gateway) |
|||||||
| 151 | ||||||||
| 152 | 3. Cache invalidation: |
|||||||
| 153 | - On page save, the fragment renderer publishes new fragments to S3 |
|||||||
| 154 | - The assembly Lambda always fetches fresh fragments (S3 reads, not cached in Lambda memory) |
|||||||
| 155 | - CloudFront TTL of 30s means pages are at most 30s stale — acceptable for a wiki |
|||||||
| 156 | - Optional: explicit CloudFront invalidation via API on page save (adds complexity, saves 30s staleness) |
|||||||
| 157 | ||||||||
| 158 | **Tests:** ~10 unit tests for assembly handler. Integration test: invoke Lambda, verify HTML output. |
|||||||
| 159 | ||||||||
| 160 | ### Wave 3: Auth (Private Wikis) |
|||||||
| 161 | ||||||||
| 162 | **Task E-2c: CloudFront auth for private wikis** |
|||||||
| 163 | ||||||||
| 164 | 1. CloudFront Functions (viewer-request): |
|||||||
| 165 | - Check if wiki is public (lookup from a lightweight config file in S3, or baked into CloudFront Function config) |
|||||||
| 166 | - Public wiki: pass through |
|||||||
| 167 | - Private wiki: validate JWT from `Authorization` header or cookie |
|||||||
| 168 | - JWT validation in CloudFront Functions is limited (no async, no network calls) — must use symmetric signing (HMAC) or pre-validated tokens |
|||||||
| 169 | - Alternative: Lambda@Edge for full JWT validation (adds ~5ms latency, supports RS256) |
|||||||
| 170 | ||||||||
| 171 | 2. Design decision needed: how to communicate wiki visibility to CloudFront |
|||||||
| 172 | - Option: S3 metadata file per wiki (`fragments/{user}/{wiki}/config.json` with `is_public: true/false`) |
|||||||
| 173 | - Option: CloudFront Function reads a KV store (CloudFront KeyValueStore) |
|||||||
| 174 | - Option: Assembly Lambda handles auth (simplest — add auth check before fragment fetch) |
|||||||
| 175 | ||||||||
| 176 | **Decision: Defer to implementation time.** For MVP, the assembly Lambda can check auth. Move to CloudFront Functions later for performance. |
|||||||
| 177 | ||||||||
| 178 | ### Wave 4: Migration + Cutover |
|||||||
| 179 | ||||||||
| 180 | **Task E-2d: DNS cutover and backfill** |
|||||||
| 181 | ||||||||
| 182 | 1. Backfill existing wiki pages: |
|||||||
| 183 | - Script to iterate all wikis in DynamoDB, read all pages from EFS, render fragments, upload to S3 |
|||||||
| 184 | - Run once before cutover |
|||||||
| 185 | ||||||||
| 186 | 2. DNS cutover: |
|||||||
| 187 | - Point `*.wikibot.io` at CloudFront instead of API Gateway |
|||||||
| 188 | - CloudFront routes reads to assembly Lambda, writes to API Gateway origin |
|||||||
| 189 | - `dev.wikibot.io` continues to point at API Gateway (management/admin) |
|||||||
| 190 | ||||||||
| 191 | 3. Verify: |
|||||||
| 192 | - Page reads served from CloudFront (check `X-Cache` header) |
|||||||
| 193 | - Page writes still work (MCP, API) |
|||||||
| 194 | - New pages appear within 30s of creation |
|||||||
| 195 | - Sidebar updates on page create/delete |
|||||||
| 196 | ||||||||
| 197 | ## Open Questions |
|||||||
| 198 | ||||||||
| 199 | 1. **MathJax/Mermaid conditional loading:** The current `page.html` includes MathJax/Mermaid JS only when `library_requirements` indicates the page uses them. The assembly Lambda needs this signal — either embed it as a data attribute in the content fragment, or always include the JS (adds ~100KB to page weight but simplifies assembly). |
|||||||
| 200 | ||||||||
| 201 | 2. **Search:** The search form POSTs to the otterwiki Lambda. With CloudFront in front, search requests need to route to the API Gateway origin. This is a CloudFront behavior routing question. |
|||||||
| 202 | ||||||||
| 203 | 3. **Static assets:** otterwiki serves CSS/JS/fonts from Flask's static file handler. These should be served from S3/CloudFront for performance. Either extract otterwiki's static directory to S3 at build time, or configure CloudFront to forward `/static/*` to the API Gateway origin (slower but simpler for MVP). |
|||||||
| 204 | ||||||||
| 205 | 4. **TOC (Table of Contents):** The right sidebar "On this page" TOC is generated from the page content. It could be included in the content fragment as a separate `{{TOC}}` placeholder, or rendered client-side from heading elements. |
|||||||
| 206 | ||||||||
| 207 | 5. **Page history/blame/diff:** These dynamic views cannot be pre-rendered. They should route to the otterwiki Lambda via API Gateway origin. CloudFront behavior: `/-/*` → API Gateway. |
|||||||
| 208 | ||||||||
| 209 | ## Cost |
|||||||
| 210 | ||||||||
| 211 | - S3 fragment storage: pennies (few KB per page × number of pages) |
|||||||
| 212 | - CloudFront: free tier (1TB/month, 10M requests/month) |
|||||||
| 213 | - Assembly Lambda: scales to zero, ~$0/month at low traffic |
|||||||
| 214 | - **Total additional cost: ~$0/month** |
|||||||
| 215 | ||||||||
| 216 | ## Estimated Effort |
|||||||
| 217 | ||||||||
| 218 | | Wave | Effort | Dependencies | |
|||||||
| 219 | |------|--------|-------------| |
|||||||
| 220 | | E-2a: Fragment renderer | 1 day | OtterwikiRenderer API | |
|||||||
| 221 | | E-2b: Assembly Lambda + CloudFront | 1 day | E-2a | |
|||||||
| 222 | | E-2c: Auth | 0.5 day | E-2b | |
|||||||
| 223 | | E-2d: Migration + cutover | 0.5 day | E-2a, E-2b | |
|||||||
| 224 | ||||||||
| 225 | Total: ~3 days via agent delegation model. |
|||||||