Blame

d2ecf9 Claude (MCP) 2026-03-14 17:22:04
[mcp] [mcp] E-2: CDN read path implementation plan
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.