Add a Modal deployment alongside the Railway one
Modal runs Python, so the two processes Glean needs -- the Go API and the SvelteKit node server -- go inside a container fronted by a web_server Function. The image carries the Go toolchain, bun and Node and runs the project's own `make build`, so the fts5 tag and CGO_CFLAGS come from the Makefile rather than a second copy of that logic. The databases are the awkward part. WAL mode needs a filesystem a Modal Volume isn't, so the live databases stay on local disk and the Volume holds periodic `VACUUM INTO` snapshots instead. That bounds durability rather than guaranteeing it, and pins the app to one container; deploy/modal/readme.md says so plainly. The Nix -> image -> Railway pipeline is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fb40a9c parent: d9430fb modified
.gitignore +4 -0 | @@ -36,3 +36,7 @@ web/.svelte-kit/ | ||
| 36 | 36 | |
| 37 | 37 | # todo |
| 38 | 38 | todo.md |
| 39 | + | |
| 40 | +# python bytecode | |
| 41 | +__pycache__/ | |
| 42 | +*.pyc | |
| @@ -36,3 +36,7 @@ web/.svelte-kit/ | |||
| 36 | 36 | ||
| 37 | # todo | 37 | # todo |
| 38 | todo.md | 38 | todo.md |
| 39 | + | ||
| 40 | +# python bytecode | ||
| 41 | +__pycache__/ | ||
| 42 | +*.pyc | ||
added
deploy/modal/modal_app.py +197 -0 | new file mode 100644 | ||
| @@ -0,0 +1,197 @@ | ||
| 1 | +"""Deploy Glean to Modal as a web Function. | |
| 2 | + | |
| 3 | +Glean is two processes behind one port: the Go JSON API on loopback :8080 and | |
| 4 | +the SvelteKit (adapter-node) server on :3000, which proxies /api to it. Modal | |
| 5 | +exposes a single container port, so `@modal.web_server(3000)` fronts a launcher | |
| 6 | +that starts both, exactly like the Nix image's entrypoint does. | |
| 7 | + | |
| 8 | +Storage is the awkward part. Glean's SQLite databases run in WAL mode, which | |
| 9 | +needs a real POSIX filesystem with working shared-memory locking -- a Modal | |
| 10 | +Volume is neither. So the live databases sit on the container's local disk and | |
| 11 | +the Volume is used as a snapshot store: restored on startup, and refreshed | |
| 12 | +periodically (and on shutdown) with `VACUUM INTO`, which takes a consistent | |
| 13 | +copy of a live database without stopping writers. | |
| 14 | + | |
| 15 | +That makes this a single-container, single-writer deployment with a bounded | |
| 16 | +data-loss window (SNAPSHOT_INTERVAL). See deploy/modal/readme.md. | |
| 17 | +""" | |
| 18 | + | |
| 19 | +import os | |
| 20 | +import pathlib | |
| 21 | +import shutil | |
| 22 | +import signal | |
| 23 | +import subprocess | |
| 24 | +import sys | |
| 25 | +import threading | |
| 26 | +import time | |
| 27 | + | |
| 28 | +import modal | |
| 29 | + | |
| 30 | +REPO_ROOT = pathlib.Path(__file__).parent.parent.parent | |
| 31 | + | |
| 32 | +APP_NAME = "glean" | |
| 33 | + | |
| 34 | +# Where the Volume is mounted, and where the live (WAL-mode) databases live. | |
| 35 | +VOLUME_PATH = "/data" | |
| 36 | +SNAPSHOT_DIR = f"{VOLUME_PATH}/db" | |
| 37 | +LIVE_DIR = "/livedb" | |
| 38 | +DB_BASE = f"{LIVE_DIR}/glean.db" | |
| 39 | + | |
| 40 | +# Glean appends these suffixes to GLEAN_DB. | |
| 41 | +DB_SUFFIXES = ("_users", "_articles", "_recs") | |
| 42 | + | |
| 43 | +# How often the live databases are snapshotted back to the Volume. This is the | |
| 44 | +# worst-case data-loss window if the container dies without running its exit | |
| 45 | +# handler. | |
| 46 | +SNAPSHOT_INTERVAL = 300 | |
| 47 | + | |
| 48 | +FRONTEND_PORT = 3000 | |
| 49 | +API_PORT = 8080 | |
| 50 | + | |
| 51 | +image = ( | |
| 52 | + # The Go toolchain is the heavy dependency and go.mod pins go 1.26.2, so | |
| 53 | + # start from the official Go image rather than installing it by hand. | |
| 54 | + # add_python gives the container the Python that Modal's runtime needs. | |
| 55 | + modal.Image.from_registry("golang:1.26-bookworm", add_python="3.12") | |
| 56 | + .apt_install("curl", "unzip", "git", "ca-certificates", "sqlite3", "make") | |
| 57 | + .run_commands( | |
| 58 | + # SvelteKit's adapter-node output runs on Node; bun is only the | |
| 59 | + # package manager / bundler, matching the Makefile. | |
| 60 | + "curl -fsSL https://deb.nodesource.com/setup_22.x | bash -", | |
| 61 | + "apt-get install -y nodejs", | |
| 62 | + "curl -fsSL https://bun.sh/install | bash", | |
| 63 | + ) | |
| 64 | + .add_local_dir( | |
| 65 | + REPO_ROOT, | |
| 66 | + remote_path="/src", | |
| 67 | + copy=True, | |
| 68 | + ignore=[ | |
| 69 | + ".git", | |
| 70 | + "**/node_modules", | |
| 71 | + "web/build", | |
| 72 | + "web/.svelte-kit", | |
| 73 | + "glean", | |
| 74 | + "*.db*", | |
| 75 | + "deploy/modal/__pycache__", | |
| 76 | + ], | |
| 77 | + ) | |
| 78 | + # `make build` computes the CGO_CFLAGS that mattn/go-sqlite3 and | |
| 79 | + # sqlite-vec need (internal/db/include plus the go-sqlite3 module cache) | |
| 80 | + # and applies the required `fts5` build tag. Reusing it keeps this | |
| 81 | + # deployment honest about how the project actually builds. | |
| 82 | + .run_commands( | |
| 83 | + "cd /src && PATH=/root/.bun/bin:$PATH make web-install", | |
| 84 | + "cd /src && PATH=/root/.bun/bin:$PATH make build", | |
| 85 | + ) | |
| 86 | + .env({"PATH": "/root/.bun/bin:/usr/local/go/bin:/usr/local/bin:/usr/bin:/bin"}) | |
| 87 | +) | |
| 88 | + | |
| 89 | +volume = modal.Volume.from_name("glean-data", create_if_missing=True) | |
| 90 | + | |
| 91 | +# Holds GLEAN_SESSION_KEY and any OAuth / LLM / embedding credentials. | |
| 92 | +# Create with: | |
| 93 | +# modal secret create glean-secrets GLEAN_SESSION_KEY=... GLEAN_FRONTEND_URL=... | |
| 94 | +secret = modal.Secret.from_name("glean-secrets") | |
| 95 | + | |
| 96 | +app = modal.App(APP_NAME) | |
| 97 | + | |
| 98 | + | |
| 99 | +def _snapshot_once() -> None: | |
| 100 | + """Copy each live database to the Volume with VACUUM INTO, then commit. | |
| 101 | + | |
| 102 | + VACUUM INTO writes a consistent copy of the database as of a read | |
| 103 | + transaction, so it is safe to run while Glean is serving traffic. It | |
| 104 | + refuses to overwrite, hence the temp-file-and-rename. | |
| 105 | + """ | |
| 106 | + os.makedirs(SNAPSHOT_DIR, exist_ok=True) | |
| 107 | + for suffix in DB_SUFFIXES: | |
| 108 | + live = f"{DB_BASE}{suffix}" | |
| 109 | + if not os.path.exists(live): | |
| 110 | + continue | |
| 111 | + final = f"{SNAPSHOT_DIR}/glean.db{suffix}" | |
| 112 | + tmp = f"{final}.tmp" | |
| 113 | + if os.path.exists(tmp): | |
| 114 | + os.remove(tmp) | |
| 115 | + subprocess.run( | |
| 116 | + ["sqlite3", live, f"VACUUM INTO '{tmp}'"], | |
| 117 | + check=True, | |
| 118 | + capture_output=True, | |
| 119 | + ) | |
| 120 | + os.replace(tmp, final) | |
| 121 | + volume.commit() | |
| 122 | + | |
| 123 | + | |
| 124 | +def _restore() -> None: | |
| 125 | + """Seed the local disk from the Volume's snapshot, if there is one.""" | |
| 126 | + os.makedirs(LIVE_DIR, exist_ok=True) | |
| 127 | + for suffix in DB_SUFFIXES: | |
| 128 | + snap = f"{SNAPSHOT_DIR}/glean.db{suffix}" | |
| 129 | + if os.path.exists(snap): | |
| 130 | + shutil.copy2(snap, f"{DB_BASE}{suffix}") | |
| 131 | + print(f"[glean] restored {snap}", flush=True) | |
| 132 | + | |
| 133 | + | |
| 134 | +def _snapshot_loop(stop: threading.Event) -> None: | |
| 135 | + while not stop.wait(SNAPSHOT_INTERVAL): | |
| 136 | + try: | |
| 137 | + _snapshot_once() | |
| 138 | + print("[glean] snapshotted databases to volume", flush=True) | |
| 139 | + except Exception as exc: # a failed snapshot must not kill the server | |
| 140 | + print(f"[glean] snapshot failed: {exc}", file=sys.stderr, flush=True) | |
| 141 | + | |
| 142 | + | |
| 143 | +@app.function( | |
| 144 | + image=image, | |
| 145 | + volumes={VOLUME_PATH: volume}, | |
| 146 | + secrets=[secret], | |
| 147 | + # Single writer: SQLite tolerates exactly one process writing these files, | |
| 148 | + # and the snapshot scheme assumes one container owns the data. | |
| 149 | + max_containers=1, | |
| 150 | + # Glean's background workers (Jetstream consumer, PDS sync, clustering, | |
| 151 | + # feed fetch) only run while a container is alive, so keep one alive. | |
| 152 | + min_containers=1, | |
| 153 | + scaledown_window=1200, | |
| 154 | + timeout=24 * 60 * 60, | |
| 155 | + cpu=2, | |
| 156 | + memory=4096, | |
| 157 | +) | |
| 158 | +# One container serves every request; without this Modal would queue requests | |
| 159 | +# behind a single in-flight input. | |
| 160 | +@modal.concurrent(max_inputs=200) | |
| 161 | +@modal.web_server(FRONTEND_PORT, startup_timeout=300) | |
| 162 | +def serve() -> None: | |
| 163 | + _restore() | |
| 164 | + | |
| 165 | + env = dict(os.environ) | |
| 166 | + env["GLEAN_DB"] = DB_BASE | |
| 167 | + env["GLEAN_ADDR"] = f"127.0.0.1:{API_PORT}" | |
| 168 | + env["GLEAN_API_URL"] = f"http://127.0.0.1:{API_PORT}" | |
| 169 | + # web_server routes external traffic to the container's interface, so the | |
| 170 | + # SvelteKit server must not bind loopback only. | |
| 171 | + env["HOST"] = "0.0.0.0" | |
| 172 | + env["PORT"] = str(FRONTEND_PORT) | |
| 173 | + env.setdefault("GLEAN_FRONTEND_URL", "http://localhost:3000") | |
| 174 | + | |
| 175 | + api = subprocess.Popen(["/src/glean"], env=env, cwd="/src") | |
| 176 | + web = subprocess.Popen( | |
| 177 | + ["node", "build/index.js"], env=env, cwd="/src/web" | |
| 178 | + ) | |
| 179 | + | |
| 180 | + stop = threading.Event() | |
| 181 | + threading.Thread(target=_snapshot_loop, args=(stop,), daemon=True).start() | |
| 182 | + | |
| 183 | + def shutdown(_signum, _frame): | |
| 184 | + stop.set() | |
| 185 | + for proc in (web, api): | |
| 186 | + proc.terminate() | |
| 187 | + # Best-effort final snapshot so a graceful scaledown loses nothing. | |
| 188 | + try: | |
| 189 | + time.sleep(1) | |
| 190 | + _snapshot_once() | |
| 191 | + print("[glean] final snapshot written", flush=True) | |
| 192 | + except Exception as exc: | |
| 193 | + print(f"[glean] final snapshot failed: {exc}", file=sys.stderr, flush=True) | |
| 194 | + sys.exit(0) | |
| 195 | + | |
| 196 | + signal.signal(signal.SIGTERM, shutdown) | |
| 197 | + signal.signal(signal.SIGINT, shutdown) | |
| new file mode 100644 | |||
| @@ -0,0 +1,197 @@ | |||
| 1 | +"""Deploy Glean to Modal as a web Function. | ||
| 2 | + | ||
| 3 | +Glean is two processes behind one port: the Go JSON API on loopback :8080 and | ||
| 4 | +the SvelteKit (adapter-node) server on :3000, which proxies /api to it. Modal | ||
| 5 | +exposes a single container port, so `@modal.web_server(3000)` fronts a launcher | ||
| 6 | +that starts both, exactly like the Nix image's entrypoint does. | ||
| 7 | + | ||
| 8 | +Storage is the awkward part. Glean's SQLite databases run in WAL mode, which | ||
| 9 | +needs a real POSIX filesystem with working shared-memory locking -- a Modal | ||
| 10 | +Volume is neither. So the live databases sit on the container's local disk and | ||
| 11 | +the Volume is used as a snapshot store: restored on startup, and refreshed | ||
| 12 | +periodically (and on shutdown) with `VACUUM INTO`, which takes a consistent | ||
| 13 | +copy of a live database without stopping writers. | ||
| 14 | + | ||
| 15 | +That makes this a single-container, single-writer deployment with a bounded | ||
| 16 | +data-loss window (SNAPSHOT_INTERVAL). See deploy/modal/readme.md. | ||
| 17 | +""" | ||
| 18 | + | ||
| 19 | +import os | ||
| 20 | +import pathlib | ||
| 21 | +import shutil | ||
| 22 | +import signal | ||
| 23 | +import subprocess | ||
| 24 | +import sys | ||
| 25 | +import threading | ||
| 26 | +import time | ||
| 27 | + | ||
| 28 | +import modal | ||
| 29 | + | ||
| 30 | +REPO_ROOT = pathlib.Path(__file__).parent.parent.parent | ||
| 31 | + | ||
| 32 | +APP_NAME = "glean" | ||
| 33 | + | ||
| 34 | +# Where the Volume is mounted, and where the live (WAL-mode) databases live. | ||
| 35 | +VOLUME_PATH = "/data" | ||
| 36 | +SNAPSHOT_DIR = f"{VOLUME_PATH}/db" | ||
| 37 | +LIVE_DIR = "/livedb" | ||
| 38 | +DB_BASE = f"{LIVE_DIR}/glean.db" | ||
| 39 | + | ||
| 40 | +# Glean appends these suffixes to GLEAN_DB. | ||
| 41 | +DB_SUFFIXES = ("_users", "_articles", "_recs") | ||
| 42 | + | ||
| 43 | +# How often the live databases are snapshotted back to the Volume. This is the | ||
| 44 | +# worst-case data-loss window if the container dies without running its exit | ||
| 45 | +# handler. | ||
| 46 | +SNAPSHOT_INTERVAL = 300 | ||
| 47 | + | ||
| 48 | +FRONTEND_PORT = 3000 | ||
| 49 | +API_PORT = 8080 | ||
| 50 | + | ||
| 51 | +image = ( | ||
| 52 | + # The Go toolchain is the heavy dependency and go.mod pins go 1.26.2, so | ||
| 53 | + # start from the official Go image rather than installing it by hand. | ||
| 54 | + # add_python gives the container the Python that Modal's runtime needs. | ||
| 55 | + modal.Image.from_registry("golang:1.26-bookworm", add_python="3.12") | ||
| 56 | + .apt_install("curl", "unzip", "git", "ca-certificates", "sqlite3", "make") | ||
| 57 | + .run_commands( | ||
| 58 | + # SvelteKit's adapter-node output runs on Node; bun is only the | ||
| 59 | + # package manager / bundler, matching the Makefile. | ||
| 60 | + "curl -fsSL https://deb.nodesource.com/setup_22.x | bash -", | ||
| 61 | + "apt-get install -y nodejs", | ||
| 62 | + "curl -fsSL https://bun.sh/install | bash", | ||
| 63 | + ) | ||
| 64 | + .add_local_dir( | ||
| 65 | + REPO_ROOT, | ||
| 66 | + remote_path="/src", | ||
| 67 | + copy=True, | ||
| 68 | + ignore=[ | ||
| 69 | + ".git", | ||
| 70 | + "**/node_modules", | ||
| 71 | + "web/build", | ||
| 72 | + "web/.svelte-kit", | ||
| 73 | + "glean", | ||
| 74 | + "*.db*", | ||
| 75 | + "deploy/modal/__pycache__", | ||
| 76 | + ], | ||
| 77 | + ) | ||
| 78 | + # `make build` computes the CGO_CFLAGS that mattn/go-sqlite3 and | ||
| 79 | + # sqlite-vec need (internal/db/include plus the go-sqlite3 module cache) | ||
| 80 | + # and applies the required `fts5` build tag. Reusing it keeps this | ||
| 81 | + # deployment honest about how the project actually builds. | ||
| 82 | + .run_commands( | ||
| 83 | + "cd /src && PATH=/root/.bun/bin:$PATH make web-install", | ||
| 84 | + "cd /src && PATH=/root/.bun/bin:$PATH make build", | ||
| 85 | + ) | ||
| 86 | + .env({"PATH": "/root/.bun/bin:/usr/local/go/bin:/usr/local/bin:/usr/bin:/bin"}) | ||
| 87 | +) | ||
| 88 | + | ||
| 89 | +volume = modal.Volume.from_name("glean-data", create_if_missing=True) | ||
| 90 | + | ||
| 91 | +# Holds GLEAN_SESSION_KEY and any OAuth / LLM / embedding credentials. | ||
| 92 | +# Create with: | ||
| 93 | +# modal secret create glean-secrets GLEAN_SESSION_KEY=... GLEAN_FRONTEND_URL=... | ||
| 94 | +secret = modal.Secret.from_name("glean-secrets") | ||
| 95 | + | ||
| 96 | +app = modal.App(APP_NAME) | ||
| 97 | + | ||
| 98 | + | ||
| 99 | +def _snapshot_once() -> None: | ||
| 100 | + """Copy each live database to the Volume with VACUUM INTO, then commit. | ||
| 101 | + | ||
| 102 | + VACUUM INTO writes a consistent copy of the database as of a read | ||
| 103 | + transaction, so it is safe to run while Glean is serving traffic. It | ||
| 104 | + refuses to overwrite, hence the temp-file-and-rename. | ||
| 105 | + """ | ||
| 106 | + os.makedirs(SNAPSHOT_DIR, exist_ok=True) | ||
| 107 | + for suffix in DB_SUFFIXES: | ||
| 108 | + live = f"{DB_BASE}{suffix}" | ||
| 109 | + if not os.path.exists(live): | ||
| 110 | + continue | ||
| 111 | + final = f"{SNAPSHOT_DIR}/glean.db{suffix}" | ||
| 112 | + tmp = f"{final}.tmp" | ||
| 113 | + if os.path.exists(tmp): | ||
| 114 | + os.remove(tmp) | ||
| 115 | + subprocess.run( | ||
| 116 | + ["sqlite3", live, f"VACUUM INTO '{tmp}'"], | ||
| 117 | + check=True, | ||
| 118 | + capture_output=True, | ||
| 119 | + ) | ||
| 120 | + os.replace(tmp, final) | ||
| 121 | + volume.commit() | ||
| 122 | + | ||
| 123 | + | ||
| 124 | +def _restore() -> None: | ||
| 125 | + """Seed the local disk from the Volume's snapshot, if there is one.""" | ||
| 126 | + os.makedirs(LIVE_DIR, exist_ok=True) | ||
| 127 | + for suffix in DB_SUFFIXES: | ||
| 128 | + snap = f"{SNAPSHOT_DIR}/glean.db{suffix}" | ||
| 129 | + if os.path.exists(snap): | ||
| 130 | + shutil.copy2(snap, f"{DB_BASE}{suffix}") | ||
| 131 | + print(f"[glean] restored {snap}", flush=True) | ||
| 132 | + | ||
| 133 | + | ||
| 134 | +def _snapshot_loop(stop: threading.Event) -> None: | ||
| 135 | + while not stop.wait(SNAPSHOT_INTERVAL): | ||
| 136 | + try: | ||
| 137 | + _snapshot_once() | ||
| 138 | + print("[glean] snapshotted databases to volume", flush=True) | ||
| 139 | + except Exception as exc: # a failed snapshot must not kill the server | ||
| 140 | + print(f"[glean] snapshot failed: {exc}", file=sys.stderr, flush=True) | ||
| 141 | + | ||
| 142 | + | ||
| 143 | +@app.function( | ||
| 144 | + image=image, | ||
| 145 | + volumes={VOLUME_PATH: volume}, | ||
| 146 | + secrets=[secret], | ||
| 147 | + # Single writer: SQLite tolerates exactly one process writing these files, | ||
| 148 | + # and the snapshot scheme assumes one container owns the data. | ||
| 149 | + max_containers=1, | ||
| 150 | + # Glean's background workers (Jetstream consumer, PDS sync, clustering, | ||
| 151 | + # feed fetch) only run while a container is alive, so keep one alive. | ||
| 152 | + min_containers=1, | ||
| 153 | + scaledown_window=1200, | ||
| 154 | + timeout=24 * 60 * 60, | ||
| 155 | + cpu=2, | ||
| 156 | + memory=4096, | ||
| 157 | +) | ||
| 158 | +# One container serves every request; without this Modal would queue requests | ||
| 159 | +# behind a single in-flight input. | ||
| 160 | +@modal.concurrent(max_inputs=200) | ||
| 161 | +@modal.web_server(FRONTEND_PORT, startup_timeout=300) | ||
| 162 | +def serve() -> None: | ||
| 163 | + _restore() | ||
| 164 | + | ||
| 165 | + env = dict(os.environ) | ||
| 166 | + env["GLEAN_DB"] = DB_BASE | ||
| 167 | + env["GLEAN_ADDR"] = f"127.0.0.1:{API_PORT}" | ||
| 168 | + env["GLEAN_API_URL"] = f"http://127.0.0.1:{API_PORT}" | ||
| 169 | + # web_server routes external traffic to the container's interface, so the | ||
| 170 | + # SvelteKit server must not bind loopback only. | ||
| 171 | + env["HOST"] = "0.0.0.0" | ||
| 172 | + env["PORT"] = str(FRONTEND_PORT) | ||
| 173 | + env.setdefault("GLEAN_FRONTEND_URL", "http://localhost:3000") | ||
| 174 | + | ||
| 175 | + api = subprocess.Popen(["/src/glean"], env=env, cwd="/src") | ||
| 176 | + web = subprocess.Popen( | ||
| 177 | + ["node", "build/index.js"], env=env, cwd="/src/web" | ||
| 178 | + ) | ||
| 179 | + | ||
| 180 | + stop = threading.Event() | ||
| 181 | + threading.Thread(target=_snapshot_loop, args=(stop,), daemon=True).start() | ||
| 182 | + | ||
| 183 | + def shutdown(_signum, _frame): | ||
| 184 | + stop.set() | ||
| 185 | + for proc in (web, api): | ||
| 186 | + proc.terminate() | ||
| 187 | + # Best-effort final snapshot so a graceful scaledown loses nothing. | ||
| 188 | + try: | ||
| 189 | + time.sleep(1) | ||
| 190 | + _snapshot_once() | ||
| 191 | + print("[glean] final snapshot written", flush=True) | ||
| 192 | + except Exception as exc: | ||
| 193 | + print(f"[glean] final snapshot failed: {exc}", file=sys.stderr, flush=True) | ||
| 194 | + sys.exit(0) | ||
| 195 | + | ||
| 196 | + signal.signal(signal.SIGTERM, shutdown) | ||
| 197 | + signal.signal(signal.SIGINT, shutdown) | ||
added
deploy/modal/readme.md +88 -0 | new file mode 100644 | ||
| @@ -0,0 +1,88 @@ | ||
| 1 | +# Deploying to Modal | |
| 2 | + | |
| 3 | +This is an **additional** target alongside the Nix → image → Railway pipeline in | |
| 4 | +[`docs/deploy.md`](../../docs/deploy.md), which is unchanged and remains the | |
| 5 | +primary deployment. | |
| 6 | + | |
| 7 | +``` | |
| 8 | +modal deploy deploy/modal/modal_app.py | |
| 9 | +``` | |
| 10 | + | |
| 11 | +Live at <https://codegod100--glean-serve.modal.run>. | |
| 12 | + | |
| 13 | +## How it works | |
| 14 | + | |
| 15 | +Modal runs Python, so `deploy/modal/modal_app.py` builds a container image that | |
| 16 | +carries the Go toolchain, bun and Node, and runs the project's own `make build` | |
| 17 | +inside it — same `fts5` build tag, same `CGO_CFLAGS`, so the Modal build is the | |
| 18 | +build the Makefile describes rather than a reimplementation of it. | |
| 19 | + | |
| 20 | +At runtime a `@modal.web_server(3000)` Function starts both processes the way | |
| 21 | +the Nix entrypoint does: the Go API on `127.0.0.1:8080`, the SvelteKit | |
| 22 | +`adapter-node` server on `0.0.0.0:3000` in front of it, proxying `/api`. | |
| 23 | + | |
| 24 | +## Storage | |
| 25 | + | |
| 26 | +Glean's SQLite databases run in WAL mode. WAL needs a real POSIX filesystem | |
| 27 | +with working shared-memory locking, and a Modal Volume is neither — so the | |
| 28 | +Volume cannot hold the live databases. | |
| 29 | + | |
| 30 | +Instead the live databases sit on the container's local disk (`/livedb`) and the | |
| 31 | +Volume (`glean-data`, at `/data`) is a **snapshot store**: | |
| 32 | + | |
| 33 | +- on container start, the newest snapshot is copied to local disk; | |
| 34 | +- every `SNAPSHOT_INTERVAL` (300s) and on `SIGTERM`, each database is copied | |
| 35 | + back with `VACUUM INTO`, which takes a consistent copy while Glean keeps | |
| 36 | + serving, and the Volume is committed. | |
| 37 | + | |
| 38 | +The consequence is a bounded data-loss window: if a container dies without | |
| 39 | +running its exit handler, up to five minutes of writes are gone. Lower | |
| 40 | +`SNAPSHOT_INTERVAL` to trade I/O for a tighter window. | |
| 41 | + | |
| 42 | +## Why one container | |
| 43 | + | |
| 44 | +`max_containers=1` is not tuning, it is a correctness requirement. Two | |
| 45 | +containers would mean two SQLite writers over two independent copies of the | |
| 46 | +data, and the Volume's last-write-wins semantics would silently discard one of | |
| 47 | +them. `min_containers=1` keeps that container alive because Glean's background | |
| 48 | +workers — the Jetstream websocket consumer and the PDS sync / clustering / | |
| 49 | +feed-fetch loops — only run while a container exists. | |
| 50 | + | |
| 51 | +## Secrets | |
| 52 | + | |
| 53 | +`GLEAN_SESSION_KEY` and `GLEAN_FRONTEND_URL` live in the `glean-secrets` Modal | |
| 54 | +Secret, never in the repo: | |
| 55 | + | |
| 56 | +``` | |
| 57 | +modal secret create glean-secrets \ | |
| 58 | + GLEAN_SESSION_KEY=$(openssl rand -hex 32) \ | |
| 59 | + GLEAN_FRONTEND_URL=https://<workspace>--glean-serve.modal.run | |
| 60 | +``` | |
| 61 | + | |
| 62 | +`GLEAN_FRONTEND_URL` must be the URL users actually see — the OAuth callback is | |
| 63 | +derived from it. Add the optional keys from [`.env.example`](../../.env.example) | |
| 64 | +to the same Secret to enable them: | |
| 65 | + | |
| 66 | +- `GLEAN_OAUTH_CLIENT_ID` — required for real ATProto login. Until it is set, | |
| 67 | + `/api/oauth/client-metadata` returns `{"error":"localhost client"}` and only | |
| 68 | + the logged-out views work. | |
| 69 | +- `GLEAN_EMBED_*` — content-based recommendations. | |
| 70 | +- `GLEAN_LLM_*` — language detection and digests. | |
| 71 | + | |
| 72 | +## Caveats | |
| 73 | + | |
| 74 | +Modal's model is autoscaling ephemeral containers. Glean is a stateful, | |
| 75 | +single-writer, always-on server. The mismatch is real and this deployment | |
| 76 | +manages it rather than resolving it: | |
| 77 | + | |
| 78 | +- **One container, always warm.** No horizontal scaling; `min_containers=1` | |
| 79 | + means you pay for an idle container rather than scaling to zero. | |
| 80 | +- **Snapshot durability, not continuous durability.** See the window above. | |
| 81 | +- **Container churn costs data.** Any restart that skips the exit handler — a | |
| 82 | + hard kill, an OOM, a preemption — loses the interval. | |
| 83 | +- **Redeploys are not zero-downtime.** `max_containers=1` prevents Modal from | |
| 84 | + bringing up a replacement to shift traffic across. | |
| 85 | + | |
| 86 | +If Glean's data grows past what fits comfortably on local disk, or writes need | |
| 87 | +to survive every crash, the answer is a networked database rather than a | |
| 88 | +different Modal configuration. | |
| new file mode 100644 | |||
| @@ -0,0 +1,88 @@ | |||
| 1 | +# Deploying to Modal | ||
| 2 | + | ||
| 3 | +This is an **additional** target alongside the Nix → image → Railway pipeline in | ||
| 4 | +[`docs/deploy.md`](../../docs/deploy.md), which is unchanged and remains the | ||
| 5 | +primary deployment. | ||
| 6 | + | ||
| 7 | +``` | ||
| 8 | +modal deploy deploy/modal/modal_app.py | ||
| 9 | +``` | ||
| 10 | + | ||
| 11 | +Live at <https://codegod100--glean-serve.modal.run>. | ||
| 12 | + | ||
| 13 | +## How it works | ||
| 14 | + | ||
| 15 | +Modal runs Python, so `deploy/modal/modal_app.py` builds a container image that | ||
| 16 | +carries the Go toolchain, bun and Node, and runs the project's own `make build` | ||
| 17 | +inside it — same `fts5` build tag, same `CGO_CFLAGS`, so the Modal build is the | ||
| 18 | +build the Makefile describes rather than a reimplementation of it. | ||
| 19 | + | ||
| 20 | +At runtime a `@modal.web_server(3000)` Function starts both processes the way | ||
| 21 | +the Nix entrypoint does: the Go API on `127.0.0.1:8080`, the SvelteKit | ||
| 22 | +`adapter-node` server on `0.0.0.0:3000` in front of it, proxying `/api`. | ||
| 23 | + | ||
| 24 | +## Storage | ||
| 25 | + | ||
| 26 | +Glean's SQLite databases run in WAL mode. WAL needs a real POSIX filesystem | ||
| 27 | +with working shared-memory locking, and a Modal Volume is neither — so the | ||
| 28 | +Volume cannot hold the live databases. | ||
| 29 | + | ||
| 30 | +Instead the live databases sit on the container's local disk (`/livedb`) and the | ||
| 31 | +Volume (`glean-data`, at `/data`) is a **snapshot store**: | ||
| 32 | + | ||
| 33 | +- on container start, the newest snapshot is copied to local disk; | ||
| 34 | +- every `SNAPSHOT_INTERVAL` (300s) and on `SIGTERM`, each database is copied | ||
| 35 | + back with `VACUUM INTO`, which takes a consistent copy while Glean keeps | ||
| 36 | + serving, and the Volume is committed. | ||
| 37 | + | ||
| 38 | +The consequence is a bounded data-loss window: if a container dies without | ||
| 39 | +running its exit handler, up to five minutes of writes are gone. Lower | ||
| 40 | +`SNAPSHOT_INTERVAL` to trade I/O for a tighter window. | ||
| 41 | + | ||
| 42 | +## Why one container | ||
| 43 | + | ||
| 44 | +`max_containers=1` is not tuning, it is a correctness requirement. Two | ||
| 45 | +containers would mean two SQLite writers over two independent copies of the | ||
| 46 | +data, and the Volume's last-write-wins semantics would silently discard one of | ||
| 47 | +them. `min_containers=1` keeps that container alive because Glean's background | ||
| 48 | +workers — the Jetstream websocket consumer and the PDS sync / clustering / | ||
| 49 | +feed-fetch loops — only run while a container exists. | ||
| 50 | + | ||
| 51 | +## Secrets | ||
| 52 | + | ||
| 53 | +`GLEAN_SESSION_KEY` and `GLEAN_FRONTEND_URL` live in the `glean-secrets` Modal | ||
| 54 | +Secret, never in the repo: | ||
| 55 | + | ||
| 56 | +``` | ||
| 57 | +modal secret create glean-secrets \ | ||
| 58 | + GLEAN_SESSION_KEY=$(openssl rand -hex 32) \ | ||
| 59 | + GLEAN_FRONTEND_URL=https://<workspace>--glean-serve.modal.run | ||
| 60 | +``` | ||
| 61 | + | ||
| 62 | +`GLEAN_FRONTEND_URL` must be the URL users actually see — the OAuth callback is | ||
| 63 | +derived from it. Add the optional keys from [`.env.example`](../../.env.example) | ||
| 64 | +to the same Secret to enable them: | ||
| 65 | + | ||
| 66 | +- `GLEAN_OAUTH_CLIENT_ID` — required for real ATProto login. Until it is set, | ||
| 67 | + `/api/oauth/client-metadata` returns `{"error":"localhost client"}` and only | ||
| 68 | + the logged-out views work. | ||
| 69 | +- `GLEAN_EMBED_*` — content-based recommendations. | ||
| 70 | +- `GLEAN_LLM_*` — language detection and digests. | ||
| 71 | + | ||
| 72 | +## Caveats | ||
| 73 | + | ||
| 74 | +Modal's model is autoscaling ephemeral containers. Glean is a stateful, | ||
| 75 | +single-writer, always-on server. The mismatch is real and this deployment | ||
| 76 | +manages it rather than resolving it: | ||
| 77 | + | ||
| 78 | +- **One container, always warm.** No horizontal scaling; `min_containers=1` | ||
| 79 | + means you pay for an idle container rather than scaling to zero. | ||
| 80 | +- **Snapshot durability, not continuous durability.** See the window above. | ||
| 81 | +- **Container churn costs data.** Any restart that skips the exit handler — a | ||
| 82 | + hard kill, an OOM, a preemption — loses the interval. | ||
| 83 | +- **Redeploys are not zero-downtime.** `max_containers=1` prevents Modal from | ||
| 84 | + bringing up a replacement to shift traffic across. | ||
| 85 | + | ||
| 86 | +If Glean's data grows past what fits comfortably on local disk, or writes need | ||
| 87 | +to survive every crash, the answer is a networked database rather than a | ||
| 88 | +different Modal configuration. | ||