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