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).

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:

  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)
  2. 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.htmlwiki.htmllayout.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:

  1. app/cdn/fragment_renderer.py — Fragment generation module:

    • render_content_fragment(markdown_text, config) -> str — calls OtterwikiRenderer.markdown(), returns content HTML
    • render_sidebar_fragment(file_list, config) -> str — builds page tree, renders menutree HTML without request context
    • render_shell_template(wiki_config) -> str — generates the static shell HTML with {{SIDEBAR}} and {{CONTENT}} placeholders
  2. app/cdn/s3_publisher.py — S3 upload module:

    • publish_content(user_id, wiki_slug, page_path, html) — uploads to s3://bucket/fragments/{user_id}/{wiki_slug}/pages/{page_path}.html
    • publish_sidebar(user_id, wiki_slug, html) — uploads sidebar fragment
    • publish_shell(user_id, wiki_slug, html) — uploads shell template
    • 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)
  3. 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 TenantResolver and 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.
  4. 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 the page_saved hook's metadata indicates a new page vs edit
  5. 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):

  1. 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-page attribute, 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)
  2. infra/__main__.py additions:

    • S3 bucket for fragments (or reuse existing lambda-code-bucket)
    • SimpleLambdaComponent for 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.io at CloudFront (currently points at API Gateway)
  3. 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

  1. 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 Authorization header 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)
  2. Design decision needed: how to communicate wiki visibility to CloudFront

    • Option: S3 metadata file per wiki (fragments/{user}/{wiki}/config.json with is_public: true/false)
    • Option: CloudFront Function reads a KV store (CloudFront KeyValueStore)
    • Option: Assembly Lambda handles auth (simplest — add auth check before fragment fetch)

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

  1. 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
  2. DNS cutover:

    • Point *.wikibot.io at CloudFront instead of API Gateway
    • CloudFront routes reads to assembly Lambda, writes to API Gateway origin
    • dev.wikibot.io continues to point at API Gateway (management/admin)
  3. Verify:

    • Page reads served from CloudFront (check X-Cache header)
    • Page writes still work (MCP, API)
    • New pages appear within 30s of creation
    • Sidebar updates on page create/delete

Open Questions

  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).

  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.

  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).

  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.

  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.

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.