Blame

3344a7 Claude (MCP) 2026-03-14 18:24:41
[mcp] Add design page for Lambda Library Mode architecture
1
---
2
category: reference
3
tags: [design, performance, lambda, architecture, cold-start]
4
last_updated: 2026-03-14
5
confidence: medium
6
---
7
8
# Lambda Library Mode: Otterwiki as a Library
9
323b58 Claude (MCP) 2026-03-15 01:20:05
[mcp] Add superseded banner to Lambda_Library_Mode
10
> **Superseded.** This page addresses Lambda cold start latency on the write path — a problem that doesn't exist on a VPS with persistent Gunicorn processes. See [[Design/VPS_Architecture]] for the current plan. The upstream contribution ideas (lazy imports, plugin entrypoint scan, app factory pattern) are still valuable for Otterwiki generally and could be submitted as PRs regardless of the deployment model.
11
3344a7 Claude (MCP) 2026-03-14 18:24:41
[mcp] Add design page for Lambda Library Mode architecture
12
**Status:** Research — queued for implementation after CDN Read Path
13
**Relates to:** [[Dev/E-1_Cold_Start_Benchmarks]], [[Design/CDN_Read_Path]], [[Design/Platform_Overview]]
14
15
## Problem
16
17
Importing `otterwiki.server` takes ~3.5s on Lambda because `server.py` is a monolithic script that executes the entire application lifecycle at import time: Flask app creation, SQLAlchemy binding, git repo opening, plugin discovery, renderer initialization, DB DDL, config queries, and route registration with all transitive dependencies. See [[Dev/E-1_Cold_Start_Benchmarks]] for the full breakdown.
18
19
The CDN Read Path ([[Design/CDN_Read_Path]]) solves the read-side cold start by bypassing the heavy Lambda entirely. This design addresses the **write-path cold start** (MCP, API) by restructuring how we load Otterwiki — treating it as a library of components rather than a monolithic application.
20
21
## Key Insight
22
23
Every module in Otterwiki imports from `otterwiki.server`:
24
25
```
26
otterwiki.models → from otterwiki.server import db
27
otterwiki.auth → from otterwiki.server import app, db
28
otterwiki.helper → from otterwiki.server import app, mail, storage, Preferences, db, app_renderer
29
otterwiki.wiki → from otterwiki.server import app, app_renderer, db, storage
30
otterwiki.views → from otterwiki.server import app, githttpserver
31
```
32
33
If we provide our own module that exports the same names but initializes lazily, all of Otterwiki's business logic works unchanged — it just resolves `otterwiki.server` to our module.
34
35
## Architecture: `sys.modules` Injection
36
37
Python's `sys.modules` dict controls what `import` returns. If we inject our own module before any Otterwiki code is imported, every `from otterwiki.server import X` resolves to our lazy version.
38
39
```
40
lambda_init.py
41
└─ import lambda_server # ~300ms: creates Flask app + SQLAlchemy, injects sys.modules
42
└─ sys.modules['otterwiki.server'] = lambda_server
43
└─ import otterwiki.views # route registration only (with upstream lazy-import PRs)
44
└─ build multi-tenant middleware
45
└─ make_lambda_handler
46
47
First request (via @app.before_request):
48
└─ GitStorage(REPOSITORY) # open git repo
49
└─ db.create_all() # DDL
50
└─ update_app_config() # DB query
51
└─ OtterwikiRenderer(config) # mistune + pygments + bs4
52
└─ plugin_manager.hook.setup() # plugin initialization
53
└─ Mail(app) # flask-mail
54
```
55
56
### The Replacement Module
57
58
`lambda_server.py` exports the same names as `otterwiki.server` but uses Werkzeug's `LocalProxy` for expensive singletons:
59
60
```python
61
import sys, os
62
from flask import Flask
63
from flask_sqlalchemy import SQLAlchemy
64
from werkzeug.local import LocalProxy
65
66
# --- Cheap: created at import time (~300ms) ---
67
68
app = Flask('otterwiki',
69
template_folder='<otterwiki package>/templates',
70
static_folder='<otterwiki package>/static')
71
app.config.update(
72
# ... same defaults as server.py lines 21-77 ...
73
)
74
app.config.from_envvar("OTTERWIKI_SETTINGS", silent=True)
75
# env overrides (same loop as server.py lines 87-97)
76
77
db = SQLAlchemy(app)
78
79
# --- Expensive: deferred via LocalProxy ---
80
81
def _get_or_init(name):
82
"""Lazy-initialize expensive singletons on first access."""
83
if not hasattr(app, f'_lazy_{name}'):
84
_do_deferred_init()
85
return getattr(app, f'_lazy_{name}')
86
87
def _do_deferred_init():
88
"""One-shot initialization of all deferred components."""
89
import otterwiki.gitstorage
90
from otterwiki.renderer import OtterwikiRenderer
91
from otterwiki.plugins import plugin_manager
92
from flask_mail import Mail
93
94
app._lazy_storage = otterwiki.gitstorage.GitStorage(app.config["REPOSITORY"])
95
app._lazy_app_renderer = OtterwikiRenderer(config=app.config)
96
app._lazy_mail = Mail(app)
97
98
# DB init
99
with app.app_context():
100
db.create_all()
101
from otterwiki.models import Preferences
102
for item in Preferences.query:
103
# same config-update logic as server.py lines 177-205
104
app.config[item.name] = item.value
105
106
# Plugin setup
107
plugin_manager.hook.setup(
108
app=app, storage=app._lazy_storage, db=db
109
)
110
111
# Git HTTP server (for completeness; may not be needed in Lambda)
112
import otterwiki.remote
113
app._lazy_githttpserver = otterwiki.remote.GitHttpServer(
114
path=app.config["REPOSITORY"]
115
)
116
117
storage = LocalProxy(lambda: _get_or_init('storage'))
118
app_renderer = LocalProxy(lambda: _get_or_init('app_renderer'))
119
mail = LocalProxy(lambda: _get_or_init('mail'))
120
githttpserver = LocalProxy(lambda: _get_or_init('githttpserver'))
121
122
# Re-export models (helper.py imports Preferences from server via wildcard)
123
from otterwiki.models import Preferences, Drafts, User, Cache
124
125
# Template filters (cheap, register at import time)
126
# ... same @app.template_filter definitions as server.py lines 239-305 ...
127
128
# Jinja globals
129
app.jinja_env.globals.update(os_getenv=os.getenv)
130
131
# --- Inject into sys.modules ---
132
sys.modules['otterwiki.server'] = sys.modules[__name__]
133
```
134
135
### Export Surface
136
137
Names that `otterwiki.server` exports and other modules depend on:
138
139
| Name | Type | Lazy? | Imported by |
140
|------|------|-------|-------------|
141
| `app` | Flask | No — must exist for decorators | everything |
142
| `db` | SQLAlchemy | No — must exist for Model class definitions | models, auth, helper, wiki, preferences |
143
| `storage` | GitStorage | **Yes** — nothing touches it until a request | helper, wiki, gitstorage, remote |
144
| `app_renderer` | OtterwikiRenderer | **Yes** — only used during markdown rendering | helper |
145
| `mail` | Flask-Mail | **Yes** — only used when sending email | helper |
146
| `githttpserver` | GitHttpServer | **Yes** — only used for git HTTP routes | views |
147
| `Preferences` | SQLAlchemy Model | Via re-export from models (needs `db`, not `storage`) | helper |
148
| `Drafts` | SQLAlchemy Model | Via re-export | wiki |
149
| `update_app_config` | function | Called internally, deferred | (internal) |
150
151
`app` and `db` must be real objects at import time because other modules use them to define models (`class Preferences(db.Model)`) and register routes (`@app.route`). Everything else can be a `LocalProxy`.
152
153
## Upstream Contributions
154
155
These changes benefit all Otterwiki deployments and make library mode cleaner. Listed in order of impact and likelihood of acceptance:
156
157
### 1. Lazy imports in `views.py` (highest value)
158
159
**Current:** `views.py` imports the entire dependency tree at module level:
160
```python
161
from otterwiki.wiki import Page, Changelog, Search, AutoRoute # triggers PIL, feedgen, unidiff, bs4
162
from otterwiki.sitemap import sitemap as generate_sitemap
163
import otterwiki.auth # triggers flask_login, werkzeug.security
164
import otterwiki.preferences
165
import otterwiki.tools
166
```
167
168
**Proposed:** Move imports into route handler function bodies:
169
```python
170
@app.route("/<path:path>")
171
def view(path="Home"):
172
from otterwiki.wiki import AutoRoute
173
p = AutoRoute(path, values=request.values)
174
return p.view()
175
```
176
177
Python caches imports, so only the first call to each handler pays the cost. This is a mechanical change — no logic changes, ~30 function edits. It keeps `import otterwiki.views` cheap (just decorator registration) and defers the heavy imports to first request.
178
179
**Estimated savings:** ~500ms off import time.
180
181
### 2. Lazy imports in `wiki.py`
182
183
**Current:** Top-level imports only used by specific methods:
184
```python
185
import PIL.Image # only used in get_attachment_thumbnail()
186
from feedgen.feed import FeedGenerator # only used in feed_rss(), feed_atom()
187
# unidiff imported via helper.patchset2urlmap
188
```
189
190
**Proposed:** Move to function bodies.
191
192
**Estimated savings:** ~200ms off import time.
193
194
### 3. Extract plugin entrypoint scan
195
196
**Current:** `plugins.py:269` calls `load_setuptools_entrypoints("otterwiki")` at module level, scanning all installed packages every time `otterwiki.plugins` is imported.
197
198
**Proposed:** Add an `init_plugins()` function that performs the scan explicitly:
199
```python
200
plugin_manager = pluggy.PluginManager("otterwiki")
201
plugin_manager.add_hookspecs(OtterWikiPluginSpec)
202
203
def init_plugins():
204
plugin_manager.load_setuptools_entrypoints("otterwiki")
205
```
206
207
Callers (server.py, our lambda_server.py) call `init_plugins()` when ready. On a 180MB Lambda package with numpy, faiss, sqlalchemy, etc., the entrypoint scan is not cheap.
208
209
**Estimated savings:** ~200ms, moved from import time to controlled init.
210
211
### 4. Remove duplicate renderer instance
212
213
**Current:** `renderer.py:632` creates `render = OtterwikiRenderer()` — a second instance of the full mistune parser chain, used only by the about page and as an unconfigured test renderer.
214
215
**Proposed:** Delete it. The about page can use `app_renderer` (or lazy-create). Tests can create their own instance.
216
217
**Estimated savings:** ~200ms off import time.
218
219
### 5. App factory pattern (longer-term)
220
221
Standard Flask best practice. Would replace the module-level globals with a `create_app()` function that returns a configured Flask app. This would make our `sys.modules` injection unnecessary — we'd just call `create_app(config)` with our own config.
222
223
This is a larger conversation and a bigger change. The other four PRs are sufficient for library mode.
224
225
## Estimated Init Timeline
226
227
### Current
228
```
229
INIT ████████████████████████████████████████████ 4,400ms
230
```
231
232
### With library mode + upstream lazy imports (PRs 1-4)
233
```
234
INIT ████████ ~800ms
235
First request (one-time): ██████████████████ ~1,800ms
236
Total first response: ██████████████████████████ ~2,600ms
237
```
238
239
### With library mode only (no upstream changes)
240
```
241
INIT ████████████████ ~1,600ms
242
First request (one-time): ██████████████ ~1,400ms
243
Total first response: ██████████████████████████████ ~3,000ms
244
```
245
246
### Breakdown of savings
247
248
| Change | Init savings | Where cost moves | Complexity |
249
|--------|-------------|-----------------|------------|
250
| `storage` → LocalProxy | ~200ms | First request | Low — 10 lines in lambda_server |
251
| `app_renderer` → LocalProxy | ~300ms | First request | Low — same pattern |
252
| Defer `db.create_all()` + config | ~300ms | First request | Low — before_request hook |
253
| Lazy imports in views.py (upstream) | ~500ms | First request handler | Medium — ~30 function edits |
254
| Lazy plugin loading | ~500ms | First request | Low — move 2 imports + init function |
255
| Lazy PIL/feedgen/unidiff (upstream) | ~200ms | First use of those routes | Low — move 3 imports |
256
| Remove duplicate renderer (upstream) | ~200ms | N/A (eliminated) | Low — delete 1 line |
257
| Defer multi-tenant middleware | ~350ms | First request | Low — already in our code |
258
259
## Tracking Upstream Compatibility
260
261
The coupling surface is the set of names exported by `server.py` and the internal APIs of `GitStorage`, `OtterwikiRenderer`, and the SQLAlchemy models. Mitigation:
262
263
1. **CI job** that runs Otterwiki's existing test suite against our replacement server module. Any export surface change (new name added to server.py, model schema change) fails the tests.
264
2. **Pin to upstream tags** in the fork, not HEAD. Review upstream changes at each version bump.
265
3. **The export surface is stable.** `app`, `db`, `storage` have been the core exports since Otterwiki's early versions. Template filters and Jinja globals change occasionally but are easy to sync.
266
267
The riskiest coupling is to `server.py`'s config defaults (lines 21-77) — if a new config key is added upstream, we need to add it to lambda_server.py. The CI job catches this because Otterwiki's tests exercise config-dependent behavior.
268
269
## Relationship to CDN Read Path
270
271
These two designs are complementary:
272
273
- **CDN Read Path** ([[Design/CDN_Read_Path]]) eliminates the heavy Lambda from the browser read path entirely. Reads are served by a thin assembly Lambda (<100ms cold start) or CloudFront cache.
274
- **Library Mode** (this document) reduces the heavy Lambda's cold start for the write path (MCP, API). From ~4.5s to ~2.6s (with upstream PRs) or ~3.0s (without).
275
276
Together, they make the platform feel responsive:
277
- Browser reads: ~10-50ms (cache hit) or ~100-300ms (cache miss)
278
- MCP/API writes (warm): single-digit ms
279
- MCP/API writes (cold): ~2.6s first response, then warm for the session
280
281
## Open Questions
282
283
1. **Does `sys.modules` injection interact with pluggy's entrypoint scanner?** Pluggy uses `importlib.metadata` to find plugins, not `import`. Should be fine, but needs verification.
284
2. **Does Flask-SQLAlchemy's `SQLAlchemy(app)` eagerly connect to the database?** If so, the DB file must exist at import time. May need `db.init_app(app)` pattern instead (deferred binding).
285
3. **Can `db.create_all()` be safely called in `before_request`?** Flask-SQLAlchemy's `create_all` needs an app context. The `before_request` hook runs inside one, so this should work.
286
4. **What happens if a `LocalProxy`-wrapped `storage` is accessed during module-level code in another otterwiki module?** Grep for module-level usage of `storage` outside of `server.py` to verify none exists. (Preliminary review: `gitstorage.py` defines `storage = None` at module level but doesn't import from server; `helper.py` imports it but only uses it in functions.)
287
5. **Template filter registration timing.** Jinja2 template filters must be registered before the first template render. Registering them at import time in lambda_server.py (as server.py does) should be safe.