nandi/gleanpublic Fork 0
fb40a9c
Commits
Clone
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

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>
nandithebull committed 2026-09-20T19:36:45-07:00 Browse files
fb40a9c parent: d9430fb
modified .gitignore +4 -0
@@ -36,3 +36,7 @@ web/.svelte-kit/
3636
3737 # todo
3838 todo.md
39+
40+# python bytecode
41+__pycache__/
42+*.pyc
@@ -36,3 +36,7 @@ web/.svelte-kit/
36 36
37 # todo37 # todo
38 todo.md38 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.