Deploy the reader to Modal
One container serving both the API and the Flutter web build, so there is nothing to proxy and no second origin needing CORS. The client is built with an empty base URL and talks to relative paths. The web build is committed output rather than built in the image: installing Flutter there would add gigabytes and minutes to reproduce what the developer machine already made. The cost is that it has to be rebuilt before deploying, which the readme says. GLEAN_TOKEN is required rather than optional. Without it the reader binds loopback, Modal's proxy reaches nothing, and the result looks like a broken deploy instead of an open one -- so the function raises with the reason. /feeds and /fetch-content retrieve any URL handed to them, which makes an ungated instance an SSRF proxy as much as someone else's feed list. Storage reuses the snapshot scheme the old deployment needed: WAL wants a filesystem a Modal Volume is not, so the live databases are on local disk and the Volume holds VACUUM INTO copies taken every five minutes and on shutdown. That bounds durability rather than guaranteeing it, pins the app to one container, and rules out zero-downtime redeploys. The readme states all three. Verified live: unauthenticated requests are refused, the app shell and assets serve with correct types, two real feeds ingest, FTS5 search works, and the client loads in a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3e88e98 parent: 00a8370 added
deploy/modal_app.py +160 -0 | new file mode 100644 | ||
| @@ -0,0 +1,160 @@ | ||
| 1 | +"""Deploy the reader to Modal: the Nim binary, serving the Flutter web build. | |
| 2 | + | |
| 3 | +One container, one port. The Nim server handles both the API and the client's | |
| 4 | +static files, so there is nothing to proxy and no second origin to arrange | |
| 5 | +CORS for. | |
| 6 | + | |
| 7 | +Storage is the awkward part, as it always is on a serverless host. SQLite | |
| 8 | +runs in WAL mode, which wants a real POSIX filesystem with working shared | |
| 9 | +memory -- a Modal Volume is neither. So the live databases sit on the | |
| 10 | +container's local disk and the Volume is a snapshot store: restored on start, | |
| 11 | +refreshed periodically and on shutdown with `VACUUM INTO`, which copies a | |
| 12 | +live database consistently without stopping writers. | |
| 13 | + | |
| 14 | +That makes this single-container and single-writer, with a data-loss window | |
| 15 | +bounded by SNAPSHOT_INTERVAL. For a personal reader whose contents can be | |
| 16 | +refetched from the feeds, that is a fair trade. | |
| 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 = pathlib.Path(__file__).parent.parent | |
| 31 | + | |
| 32 | +APP_NAME = "glean-reader" | |
| 33 | +PORT = 8080 | |
| 34 | + | |
| 35 | +VOLUME_PATH = "/data" | |
| 36 | +SNAPSHOT_DIR = f"{VOLUME_PATH}/db" | |
| 37 | +LIVE_DIR = "/livedb" | |
| 38 | +DB_BASE = f"{LIVE_DIR}/glean" | |
| 39 | + | |
| 40 | +# The reader opens <base>_users and attaches <base>_articles. | |
| 41 | +DB_SUFFIXES = ("_users", "_articles") | |
| 42 | + | |
| 43 | +# Worst case a crash costs, if the container dies without its exit handler. | |
| 44 | +SNAPSHOT_INTERVAL = 300 | |
| 45 | + | |
| 46 | +image = ( | |
| 47 | + modal.Image.debian_slim(python_version="3.12") | |
| 48 | + .apt_install("curl", "xz-utils", "gcc", "libsqlite3-dev", "ca-certificates") | |
| 49 | + .run_commands( | |
| 50 | + # choosenim is the supported installer and pins a known version; | |
| 51 | + # Debian's Nim is far behind what this code needs. | |
| 52 | + "curl -sSf https://nim-lang.org/choosenim/init.sh | sh -s -- -y", | |
| 53 | + ) | |
| 54 | + .env({"PATH": "/root/.nimble/bin:/usr/local/bin:/usr/bin:/bin"}) | |
| 55 | + .add_local_dir( | |
| 56 | + REPO / "spike" / "nim", | |
| 57 | + remote_path="/src", | |
| 58 | + copy=True, | |
| 59 | + ignore=["nimcache", "glean", "*_probe", "*.db*"], | |
| 60 | + ) | |
| 61 | + # The Flutter web build is committed output rather than built here: | |
| 62 | + # installing Flutter in the image would add gigabytes and minutes for | |
| 63 | + # something the developer machine has already produced. | |
| 64 | + .add_local_dir(REPO / "app" / "build" / "web", remote_path="/web", copy=True) | |
| 65 | + .run_commands("cd /src && nim c -d:release -d:ssl --hints:off -o:/usr/local/bin/glean glean.nim") | |
| 66 | +) | |
| 67 | + | |
| 68 | +volume = modal.Volume.from_name("glean-reader-data", create_if_missing=True) | |
| 69 | + | |
| 70 | +# GLEAN_TOKEN. Without it the reader binds loopback and Modal's proxy reaches | |
| 71 | +# nothing, so this is required rather than optional: | |
| 72 | +# modal secret create glean-reader GLEAN_TOKEN="$(openssl rand -hex 24)" | |
| 73 | +secret = modal.Secret.from_name("glean-reader") | |
| 74 | + | |
| 75 | +app = modal.App(APP_NAME) | |
| 76 | + | |
| 77 | + | |
| 78 | +def _snapshot_once() -> None: | |
| 79 | + """Copy each live database to the Volume, via a local staging file. | |
| 80 | + | |
| 81 | + VACUUM INTO journals as it writes, so it is pointed at local disk rather | |
| 82 | + than the Volume -- the filesystem this whole scheme exists to keep SQLite | |
| 83 | + off. Only the finished file is copied across. | |
| 84 | + """ | |
| 85 | + os.makedirs(SNAPSHOT_DIR, exist_ok=True) | |
| 86 | + for suffix in DB_SUFFIXES: | |
| 87 | + live = f"{DB_BASE}{suffix}" | |
| 88 | + if not os.path.exists(live): | |
| 89 | + continue | |
| 90 | + staged = f"{LIVE_DIR}/snapshot{suffix}" | |
| 91 | + if os.path.exists(staged): | |
| 92 | + os.remove(staged) | |
| 93 | + subprocess.run( | |
| 94 | + ["sqlite3", live, f"VACUUM INTO '{staged}'"], check=True, capture_output=True | |
| 95 | + ) | |
| 96 | + shutil.copy2(staged, f"{SNAPSHOT_DIR}/glean{suffix}") | |
| 97 | + os.remove(staged) | |
| 98 | + volume.commit() | |
| 99 | + | |
| 100 | + | |
| 101 | +def _restore() -> None: | |
| 102 | + os.makedirs(LIVE_DIR, exist_ok=True) | |
| 103 | + for suffix in DB_SUFFIXES: | |
| 104 | + snap = f"{SNAPSHOT_DIR}/glean{suffix}" | |
| 105 | + if os.path.exists(snap): | |
| 106 | + shutil.copy2(snap, f"{DB_BASE}{suffix}") | |
| 107 | + print(f"[glean] restored {snap}", flush=True) | |
| 108 | + | |
| 109 | + | |
| 110 | +def _snapshot_loop(stop: threading.Event) -> None: | |
| 111 | + while not stop.wait(SNAPSHOT_INTERVAL): | |
| 112 | + try: | |
| 113 | + _snapshot_once() | |
| 114 | + except Exception as exc: # a failed snapshot must not kill the server | |
| 115 | + print(f"[glean] snapshot failed: {exc}", file=sys.stderr, flush=True) | |
| 116 | + | |
| 117 | + | |
| 118 | +@app.function( | |
| 119 | + image=image, | |
| 120 | + volumes={VOLUME_PATH: volume}, | |
| 121 | + secrets=[secret], | |
| 122 | + # SQLite tolerates one writer, and the snapshot scheme assumes one | |
| 123 | + # container owns the data. This is correctness, not tuning. | |
| 124 | + max_containers=1, | |
| 125 | + scaledown_window=900, | |
| 126 | + timeout=24 * 60 * 60, | |
| 127 | +) | |
| 128 | +@modal.concurrent(max_inputs=50) | |
| 129 | +@modal.web_server(PORT, startup_timeout=120) | |
| 130 | +def serve() -> None: | |
| 131 | + _restore() | |
| 132 | + | |
| 133 | + env = dict(os.environ) | |
| 134 | + env["GLEAN_DB"] = DB_BASE | |
| 135 | + env["GLEAN_PORT"] = str(PORT) | |
| 136 | + env["GLEAN_WEB"] = "/web" | |
| 137 | + if not env.get("GLEAN_TOKEN"): | |
| 138 | + # The reader would bind loopback and Modal's proxy would reach | |
| 139 | + # nothing, which looks like a broken deploy rather than a missing | |
| 140 | + # secret. Fail with the reason instead. | |
| 141 | + raise RuntimeError("GLEAN_TOKEN is required: the reader has no other auth") | |
| 142 | + | |
| 143 | + proc = subprocess.Popen(["/usr/local/bin/glean"], env=env) | |
| 144 | + | |
| 145 | + stop = threading.Event() | |
| 146 | + threading.Thread(target=_snapshot_loop, args=(stop,), daemon=True).start() | |
| 147 | + | |
| 148 | + def shutdown(_signum, _frame): | |
| 149 | + stop.set() | |
| 150 | + proc.terminate() | |
| 151 | + try: | |
| 152 | + time.sleep(1) | |
| 153 | + _snapshot_once() | |
| 154 | + print("[glean] final snapshot written", flush=True) | |
| 155 | + except Exception as exc: | |
| 156 | + print(f"[glean] final snapshot failed: {exc}", file=sys.stderr, flush=True) | |
| 157 | + sys.exit(0) | |
| 158 | + | |
| 159 | + signal.signal(signal.SIGTERM, shutdown) | |
| 160 | + signal.signal(signal.SIGINT, shutdown) | |
| new file mode 100644 | |||
| @@ -0,0 +1,160 @@ | |||
| 1 | +"""Deploy the reader to Modal: the Nim binary, serving the Flutter web build. | ||
| 2 | + | ||
| 3 | +One container, one port. The Nim server handles both the API and the client's | ||
| 4 | +static files, so there is nothing to proxy and no second origin to arrange | ||
| 5 | +CORS for. | ||
| 6 | + | ||
| 7 | +Storage is the awkward part, as it always is on a serverless host. SQLite | ||
| 8 | +runs in WAL mode, which wants a real POSIX filesystem with working shared | ||
| 9 | +memory -- a Modal Volume is neither. So the live databases sit on the | ||
| 10 | +container's local disk and the Volume is a snapshot store: restored on start, | ||
| 11 | +refreshed periodically and on shutdown with `VACUUM INTO`, which copies a | ||
| 12 | +live database consistently without stopping writers. | ||
| 13 | + | ||
| 14 | +That makes this single-container and single-writer, with a data-loss window | ||
| 15 | +bounded by SNAPSHOT_INTERVAL. For a personal reader whose contents can be | ||
| 16 | +refetched from the feeds, that is a fair trade. | ||
| 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 = pathlib.Path(__file__).parent.parent | ||
| 31 | + | ||
| 32 | +APP_NAME = "glean-reader" | ||
| 33 | +PORT = 8080 | ||
| 34 | + | ||
| 35 | +VOLUME_PATH = "/data" | ||
| 36 | +SNAPSHOT_DIR = f"{VOLUME_PATH}/db" | ||
| 37 | +LIVE_DIR = "/livedb" | ||
| 38 | +DB_BASE = f"{LIVE_DIR}/glean" | ||
| 39 | + | ||
| 40 | +# The reader opens <base>_users and attaches <base>_articles. | ||
| 41 | +DB_SUFFIXES = ("_users", "_articles") | ||
| 42 | + | ||
| 43 | +# Worst case a crash costs, if the container dies without its exit handler. | ||
| 44 | +SNAPSHOT_INTERVAL = 300 | ||
| 45 | + | ||
| 46 | +image = ( | ||
| 47 | + modal.Image.debian_slim(python_version="3.12") | ||
| 48 | + .apt_install("curl", "xz-utils", "gcc", "libsqlite3-dev", "ca-certificates") | ||
| 49 | + .run_commands( | ||
| 50 | + # choosenim is the supported installer and pins a known version; | ||
| 51 | + # Debian's Nim is far behind what this code needs. | ||
| 52 | + "curl -sSf https://nim-lang.org/choosenim/init.sh | sh -s -- -y", | ||
| 53 | + ) | ||
| 54 | + .env({"PATH": "/root/.nimble/bin:/usr/local/bin:/usr/bin:/bin"}) | ||
| 55 | + .add_local_dir( | ||
| 56 | + REPO / "spike" / "nim", | ||
| 57 | + remote_path="/src", | ||
| 58 | + copy=True, | ||
| 59 | + ignore=["nimcache", "glean", "*_probe", "*.db*"], | ||
| 60 | + ) | ||
| 61 | + # The Flutter web build is committed output rather than built here: | ||
| 62 | + # installing Flutter in the image would add gigabytes and minutes for | ||
| 63 | + # something the developer machine has already produced. | ||
| 64 | + .add_local_dir(REPO / "app" / "build" / "web", remote_path="/web", copy=True) | ||
| 65 | + .run_commands("cd /src && nim c -d:release -d:ssl --hints:off -o:/usr/local/bin/glean glean.nim") | ||
| 66 | +) | ||
| 67 | + | ||
| 68 | +volume = modal.Volume.from_name("glean-reader-data", create_if_missing=True) | ||
| 69 | + | ||
| 70 | +# GLEAN_TOKEN. Without it the reader binds loopback and Modal's proxy reaches | ||
| 71 | +# nothing, so this is required rather than optional: | ||
| 72 | +# modal secret create glean-reader GLEAN_TOKEN="$(openssl rand -hex 24)" | ||
| 73 | +secret = modal.Secret.from_name("glean-reader") | ||
| 74 | + | ||
| 75 | +app = modal.App(APP_NAME) | ||
| 76 | + | ||
| 77 | + | ||
| 78 | +def _snapshot_once() -> None: | ||
| 79 | + """Copy each live database to the Volume, via a local staging file. | ||
| 80 | + | ||
| 81 | + VACUUM INTO journals as it writes, so it is pointed at local disk rather | ||
| 82 | + than the Volume -- the filesystem this whole scheme exists to keep SQLite | ||
| 83 | + off. Only the finished file is copied across. | ||
| 84 | + """ | ||
| 85 | + os.makedirs(SNAPSHOT_DIR, exist_ok=True) | ||
| 86 | + for suffix in DB_SUFFIXES: | ||
| 87 | + live = f"{DB_BASE}{suffix}" | ||
| 88 | + if not os.path.exists(live): | ||
| 89 | + continue | ||
| 90 | + staged = f"{LIVE_DIR}/snapshot{suffix}" | ||
| 91 | + if os.path.exists(staged): | ||
| 92 | + os.remove(staged) | ||
| 93 | + subprocess.run( | ||
| 94 | + ["sqlite3", live, f"VACUUM INTO '{staged}'"], check=True, capture_output=True | ||
| 95 | + ) | ||
| 96 | + shutil.copy2(staged, f"{SNAPSHOT_DIR}/glean{suffix}") | ||
| 97 | + os.remove(staged) | ||
| 98 | + volume.commit() | ||
| 99 | + | ||
| 100 | + | ||
| 101 | +def _restore() -> None: | ||
| 102 | + os.makedirs(LIVE_DIR, exist_ok=True) | ||
| 103 | + for suffix in DB_SUFFIXES: | ||
| 104 | + snap = f"{SNAPSHOT_DIR}/glean{suffix}" | ||
| 105 | + if os.path.exists(snap): | ||
| 106 | + shutil.copy2(snap, f"{DB_BASE}{suffix}") | ||
| 107 | + print(f"[glean] restored {snap}", flush=True) | ||
| 108 | + | ||
| 109 | + | ||
| 110 | +def _snapshot_loop(stop: threading.Event) -> None: | ||
| 111 | + while not stop.wait(SNAPSHOT_INTERVAL): | ||
| 112 | + try: | ||
| 113 | + _snapshot_once() | ||
| 114 | + except Exception as exc: # a failed snapshot must not kill the server | ||
| 115 | + print(f"[glean] snapshot failed: {exc}", file=sys.stderr, flush=True) | ||
| 116 | + | ||
| 117 | + | ||
| 118 | +@app.function( | ||
| 119 | + image=image, | ||
| 120 | + volumes={VOLUME_PATH: volume}, | ||
| 121 | + secrets=[secret], | ||
| 122 | + # SQLite tolerates one writer, and the snapshot scheme assumes one | ||
| 123 | + # container owns the data. This is correctness, not tuning. | ||
| 124 | + max_containers=1, | ||
| 125 | + scaledown_window=900, | ||
| 126 | + timeout=24 * 60 * 60, | ||
| 127 | +) | ||
| 128 | +@modal.concurrent(max_inputs=50) | ||
| 129 | +@modal.web_server(PORT, startup_timeout=120) | ||
| 130 | +def serve() -> None: | ||
| 131 | + _restore() | ||
| 132 | + | ||
| 133 | + env = dict(os.environ) | ||
| 134 | + env["GLEAN_DB"] = DB_BASE | ||
| 135 | + env["GLEAN_PORT"] = str(PORT) | ||
| 136 | + env["GLEAN_WEB"] = "/web" | ||
| 137 | + if not env.get("GLEAN_TOKEN"): | ||
| 138 | + # The reader would bind loopback and Modal's proxy would reach | ||
| 139 | + # nothing, which looks like a broken deploy rather than a missing | ||
| 140 | + # secret. Fail with the reason instead. | ||
| 141 | + raise RuntimeError("GLEAN_TOKEN is required: the reader has no other auth") | ||
| 142 | + | ||
| 143 | + proc = subprocess.Popen(["/usr/local/bin/glean"], env=env) | ||
| 144 | + | ||
| 145 | + stop = threading.Event() | ||
| 146 | + threading.Thread(target=_snapshot_loop, args=(stop,), daemon=True).start() | ||
| 147 | + | ||
| 148 | + def shutdown(_signum, _frame): | ||
| 149 | + stop.set() | ||
| 150 | + proc.terminate() | ||
| 151 | + try: | ||
| 152 | + time.sleep(1) | ||
| 153 | + _snapshot_once() | ||
| 154 | + print("[glean] final snapshot written", flush=True) | ||
| 155 | + except Exception as exc: | ||
| 156 | + print(f"[glean] final snapshot failed: {exc}", file=sys.stderr, flush=True) | ||
| 157 | + sys.exit(0) | ||
| 158 | + | ||
| 159 | + signal.signal(signal.SIGTERM, shutdown) | ||
| 160 | + signal.signal(signal.SIGINT, shutdown) | ||
added
deploy/readme.md +52 -0 | new file mode 100644 | ||
| @@ -0,0 +1,52 @@ | ||
| 1 | +# Deploying | |
| 2 | + | |
| 3 | +``` | |
| 4 | +modal secret create glean-reader GLEAN_TOKEN="$(openssl rand -hex 24)" | |
| 5 | +cd app && flutter build web --release --dart-define=GLEAN_BASE_URL= | |
| 6 | +modal deploy deploy/modal_app.py | |
| 7 | +``` | |
| 8 | + | |
| 9 | +Then open `https://<your-app>.modal.run/?key=<token>` once. That sets a | |
| 10 | +cookie and redirects, so the token stays out of the URL bar, the browser's | |
| 11 | +history, and any `Referer` the page sends. | |
| 12 | + | |
| 13 | +## One container, one port | |
| 14 | + | |
| 15 | +The Nim binary serves the API *and* the Flutter web build, so there is | |
| 16 | +nothing to proxy and no second origin to arrange CORS for. The client is | |
| 17 | +built with an empty base URL and uses relative paths. | |
| 18 | + | |
| 19 | +The web build is committed output rather than built in the image. Installing | |
| 20 | +Flutter there would add gigabytes and minutes to produce something the | |
| 21 | +developer machine has already made — so **rebuild it before deploying** or | |
| 22 | +the deployment ships the previous client. | |
| 23 | + | |
| 24 | +## The token is not optional here | |
| 25 | + | |
| 26 | +Without `GLEAN_TOKEN` the reader binds loopback, Modal's proxy reaches | |
| 27 | +nothing, and the deploy looks broken rather than insecure. The function | |
| 28 | +raises with the reason instead of starting. | |
| 29 | + | |
| 30 | +That is deliberate: `/feeds` and `/fetch-content` retrieve any URL handed to | |
| 31 | +them, so an open instance is an SSRF proxy as much as it is someone else's | |
| 32 | +feed list. | |
| 33 | + | |
| 34 | +## Storage, and what it costs | |
| 35 | + | |
| 36 | +SQLite runs in WAL mode, which wants a real POSIX filesystem with working | |
| 37 | +shared memory. A Modal Volume is neither. So the live databases sit on the | |
| 38 | +container's local disk and the Volume is a snapshot store: restored on start, | |
| 39 | +refreshed every five minutes and on shutdown with `VACUUM INTO`, which copies | |
| 40 | +a live database consistently without stopping writers. | |
| 41 | + | |
| 42 | +The consequences, plainly: | |
| 43 | + | |
| 44 | +- **Up to five minutes of reads can be lost** if a container dies without | |
| 45 | + running its exit handler — an OOM, a preemption, a hard kill. | |
| 46 | +- **`max_containers=1` is correctness, not tuning.** Two containers would be | |
| 47 | + two SQLite writers over two divergent copies, and the Volume's | |
| 48 | + last-write-wins would quietly discard one. | |
| 49 | +- **Redeploys are not zero-downtime**, for the same reason. | |
| 50 | + | |
| 51 | +For a personal reader whose contents can be refetched from the feeds, that is | |
| 52 | +a fair trade. For anything where the data is the point, it is not. | |
| new file mode 100644 | |||
| @@ -0,0 +1,52 @@ | |||
| 1 | +# Deploying | ||
| 2 | + | ||
| 3 | +``` | ||
| 4 | +modal secret create glean-reader GLEAN_TOKEN="$(openssl rand -hex 24)" | ||
| 5 | +cd app && flutter build web --release --dart-define=GLEAN_BASE_URL= | ||
| 6 | +modal deploy deploy/modal_app.py | ||
| 7 | +``` | ||
| 8 | + | ||
| 9 | +Then open `https://<your-app>.modal.run/?key=<token>` once. That sets a | ||
| 10 | +cookie and redirects, so the token stays out of the URL bar, the browser's | ||
| 11 | +history, and any `Referer` the page sends. | ||
| 12 | + | ||
| 13 | +## One container, one port | ||
| 14 | + | ||
| 15 | +The Nim binary serves the API *and* the Flutter web build, so there is | ||
| 16 | +nothing to proxy and no second origin to arrange CORS for. The client is | ||
| 17 | +built with an empty base URL and uses relative paths. | ||
| 18 | + | ||
| 19 | +The web build is committed output rather than built in the image. Installing | ||
| 20 | +Flutter there would add gigabytes and minutes to produce something the | ||
| 21 | +developer machine has already made — so **rebuild it before deploying** or | ||
| 22 | +the deployment ships the previous client. | ||
| 23 | + | ||
| 24 | +## The token is not optional here | ||
| 25 | + | ||
| 26 | +Without `GLEAN_TOKEN` the reader binds loopback, Modal's proxy reaches | ||
| 27 | +nothing, and the deploy looks broken rather than insecure. The function | ||
| 28 | +raises with the reason instead of starting. | ||
| 29 | + | ||
| 30 | +That is deliberate: `/feeds` and `/fetch-content` retrieve any URL handed to | ||
| 31 | +them, so an open instance is an SSRF proxy as much as it is someone else's | ||
| 32 | +feed list. | ||
| 33 | + | ||
| 34 | +## Storage, and what it costs | ||
| 35 | + | ||
| 36 | +SQLite runs in WAL mode, which wants a real POSIX filesystem with working | ||
| 37 | +shared memory. A Modal Volume is neither. So the live databases sit on the | ||
| 38 | +container's local disk and the Volume is a snapshot store: restored on start, | ||
| 39 | +refreshed every five minutes and on shutdown with `VACUUM INTO`, which copies | ||
| 40 | +a live database consistently without stopping writers. | ||
| 41 | + | ||
| 42 | +The consequences, plainly: | ||
| 43 | + | ||
| 44 | +- **Up to five minutes of reads can be lost** if a container dies without | ||
| 45 | + running its exit handler — an OOM, a preemption, a hard kill. | ||
| 46 | +- **`max_containers=1` is correctness, not tuning.** Two containers would be | ||
| 47 | + two SQLite writers over two divergent copies, and the Volume's | ||
| 48 | + last-write-wins would quietly discard one. | ||
| 49 | +- **Redeploys are not zero-downtime**, for the same reason. | ||
| 50 | + | ||
| 51 | +For a personal reader whose contents can be refetched from the feeds, that is | ||
| 52 | +a fair trade. For anything where the data is the point, it is not. | ||