Properties
category: reference tags: [task, performance, cdn, architecture] last_updated: 2026-03-14 confidence: high
E-2: CDN Read Path Implementation
Status: Planned
Depends on: Phase 2 complete, Dev/E-1_Cold_Start_Benchmarks, Design/CDN_Read_Path
Branch: feat/E-2-cdn-read-path from phase-2
Problem
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.
Solution: Option A — Thin Assembly Lambda
Per Design/CDN_Read_Path, we decouple reads from the otterwiki Lambda:
Write path (unchanged):
MCP/API → API Gateway → Otterwiki Lambda (VPC+EFS) → git repo
Read path (new):
Browser → CloudFront → [cache hit] → HTML (~10-50ms)
→ [cache miss] → Assembly Lambda (no VPC) → S3 fragments (~40ms warm, ~1s cold)
Benchmarked Performance
From the assembly Lambda spike:
| Scenario | Assembly time | Wall time |
|---|---|---|
| Warm 256MB | 41ms | 144ms |
| Cold 256MB | 416ms | 1,230ms |
| Cold 128MB | 852ms | 1,708ms |
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.
Technical Feasibility (Validated)
Content fragment rendering
OtterwikiRenderer.markdown(text) is fully context-free. No Flask request context, no current_user, no url_for needed. Returns (html, toc, library_requirements).
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.
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).
Sidebar fragment rendering
SidebarPageIndex needs:
storage.list()— file list from git (available in the write Lambda at page-save time)app.config— sidebar mode, focus, max_depth, case settings (static per wiki)
It does NOT need a Flask request context. The focus-mode highlighting depends on pagepath (current page), so we have two options:
- Render a generic sidebar (all nodes collapsed/expanded per mode) and apply current-page highlighting client-side with a tiny JS snippet (~5 lines)
- Render per-page sidebars (expensive for large wikis, doesn't scale)
Decision: Option 1 — generic sidebar + client-side highlighting.
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.
Shell template
The otterwiki template hierarchy is page.html → wiki.html → layout.html.
The shell needs to include:
- CSS links:
halfmoon.min.css,otterwiki.css,print.css,fontawesome-all.min.css,pygments.css,roboto.css - JS:
halfmoon.min.js,otterwiki.js - Site name, logo, search form (static per wiki)
{{SIDEBAR}}and{{CONTENT}}placeholders
What we strip from the shell:
- Edit/Rename/Delete buttons (write operations — not on read path)
- Login/Logout dropdown (auth — not on CDN read path)
request.cookies.get('halfmoon_preferredMode')— handle dark mode client-side via JS (read cookie, apply class before first paint to avoid flash)- Flash messages (session-scoped, not applicable)
- CSRF tokens (none in page view anyway)
Plugin injection points
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.
Implementation Plan
Wave 1: Fragment Generation
Task E-2a: Write-time fragment renderer
New otterwiki plugin hook implementation or resolver middleware that fires on page save:
app/cdn/fragment_renderer.py— Fragment generation module:render_content_fragment(markdown_text, config) -> str— callsOtterwikiRenderer.markdown(), returns content HTMLrender_sidebar_fragment(file_list, config) -> str— builds page tree, renders menutree HTML without request contextrender_shell_template(wiki_config) -> str— generates the static shell HTML with{{SIDEBAR}}and{{CONTENT}}placeholders
app/cdn/s3_publisher.py— S3 upload module:publish_content(user_id, wiki_slug, page_path, html)— uploads tos3://bucket/fragments/{user_id}/{wiki_slug}/pages/{page_path}.htmlpublish_sidebar(user_id, wiki_slug, html)— uploads sidebar fragmentpublish_shell(user_id, wiki_slug, html)— uploads shell template- Uses boto3 with
ContentType: text/htmlandCacheControl: max-age=31536000(fragments are immutable — cache invalidation happens by serving new content through the assembly Lambda)
Integration point — two options:
- 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. - Option B: resolver middleware — intercept successful write responses in
TenantResolverand trigger fragment generation post-response. More complex, but doesn't require modifying the otterwiki plugin. - Decision: Option A — plugin hook is cleaner and already proven.
- Option A: otterwiki plugin hook — implement
Sidebar regeneration triggers:
- Page create (new file in tree) — regenerate sidebar
- Page delete — regenerate sidebar
- Page rename — regenerate sidebar
- Content-only edit — do NOT regenerate sidebar (file list unchanged)
- Detection: compare
storage.list()before/after, or check if thepage_savedhook's metadata indicates a new page vs edit
Shell regeneration triggers:
- Wiki settings change (site name, logo, sidebar config)
- Deploy (template/CSS changes)
- Rare — can be manual or triggered by management API
Tests: ~15 unit tests with mocked S3 and a real OtterwikiRenderer instance.
Wave 2: Assembly Lambda + Infrastructure
Task E-2b: Assembly Lambda
Based on the validated spike (app/poc/assembly_lambda.py):
app/cdn/assembly_handler.py— Production assembly Lambda:- Parse request: extract
{username}/{wiki_slug}/{page_path}from CloudFront-forwarded path - Fetch 3 fragments from S3 in parallel (ThreadPoolExecutor)
- String-substitute into shell template
- Apply sidebar current-page highlighting (inject
data-current-pageattribute, let client JS handle) - Return assembled HTML with
Cache-Control: public, max-age=30 - Handle 404 (missing fragments → page not found)
- Handle conditional rendering (MathJax/Mermaid JS inclusion based on content fragment metadata or markers)
- Parse request: extract
infra/__main__.pyadditions:- S3 bucket for fragments (or reuse existing
lambda-code-bucket) SimpleLambdaComponentfor assembly Lambda (256MB, no VPC)- IAM: S3 GetObject on
fragments/* - CloudFront distribution:
- Origin 1: Assembly Lambda (read path, default behavior)
- Origin 2: API Gateway (write path —
/admin/*, MCP, API routes) - Behaviors:
GET /{username}/{wiki}/*→ Assembly Lambda origin;POST/PUT/DELETE *→ API Gateway origin; static assets → S3 or passthrough
- CloudFront cache policy: 30s TTL, cache by path only (not cookies/headers for public wikis)
- DNS: point
*.wikibot.ioat CloudFront (currently points at API Gateway)
- S3 bucket for fragments (or reuse existing
Cache invalidation:
- On page save, the fragment renderer publishes new fragments to S3
- The assembly Lambda always fetches fresh fragments (S3 reads, not cached in Lambda memory)
- CloudFront TTL of 30s means pages are at most 30s stale — acceptable for a wiki
- Optional: explicit CloudFront invalidation via API on page save (adds complexity, saves 30s staleness)
Tests: ~10 unit tests for assembly handler. Integration test: invoke Lambda, verify HTML output.
Wave 3: Auth (Private Wikis)
Task E-2c: CloudFront auth for private wikis
CloudFront Functions (viewer-request):
- Check if wiki is public (lookup from a lightweight config file in S3, or baked into CloudFront Function config)
- Public wiki: pass through
- Private wiki: validate JWT from
Authorizationheader or cookie - JWT validation in CloudFront Functions is limited (no async, no network calls) — must use symmetric signing (HMAC) or pre-validated tokens
- Alternative: Lambda@Edge for full JWT validation (adds ~5ms latency, supports RS256)
Design decision needed: how to communicate wiki visibility to CloudFront
- Option: S3 metadata file per wiki (
fragments/{user}/{wiki}/config.jsonwithis_public: true/false) - Option: CloudFront Function reads a KV store (CloudFront KeyValueStore)
- Option: Assembly Lambda handles auth (simplest — add auth check before fragment fetch)
- Option: S3 metadata file per wiki (
Decision: Defer to implementation time. For MVP, the assembly Lambda can check auth. Move to CloudFront Functions later for performance.
Wave 4: Migration + Cutover
Task E-2d: DNS cutover and backfill
Backfill existing wiki pages:
- Script to iterate all wikis in DynamoDB, read all pages from EFS, render fragments, upload to S3
- Run once before cutover
DNS cutover:
- Point
*.wikibot.ioat CloudFront instead of API Gateway - CloudFront routes reads to assembly Lambda, writes to API Gateway origin
dev.wikibot.iocontinues to point at API Gateway (management/admin)
- Point
Verify:
- Page reads served from CloudFront (check
X-Cacheheader) - Page writes still work (MCP, API)
- New pages appear within 30s of creation
- Sidebar updates on page create/delete
- Page reads served from CloudFront (check
Open Questions
MathJax/Mermaid conditional loading: The current
page.htmlincludes MathJax/Mermaid JS only whenlibrary_requirementsindicates 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).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.
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).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.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.
Cost
- S3 fragment storage: pennies (few KB per page × number of pages)
- CloudFront: free tier (1TB/month, 10M requests/month)
- Assembly Lambda: scales to zero, ~$0/month at low traffic
- Total additional cost: ~$0/month
Estimated Effort
| Wave | Effort | Dependencies |
|---|---|---|
| E-2a: Fragment renderer | 1 day | OtterwikiRenderer API |
| E-2b: Assembly Lambda + CloudFront | 1 day | E-2a |
| E-2c: Auth | 0.5 day | E-2b |
| E-2d: Migration + cutover | 0.5 day | E-2a, E-2b |
Total: ~3 days via agent delegation model.