2026-10-28·作者 Sijie Wang#lucerna#software#architecture#proposal

high-availability-proposal

High availability for Lucerna — a proposal

Parent: key-designs

Status: proposal (2026-09-28). Nothing below "The plan" is built.

The "Where it stands" section is fact, checked against the code and ops/ in ~/Develop/projects/Otium. Everything after it is design reasoning: a way to answer "how would you make this highly available", not a record of something run.

Where it stands (fact)

PartState todayWhat losing it does
The hostOne Coolify server runs every Lucerna prod service — and other tenants' production too.Everything online stops at once. A host-wide action hits all tenants (on 2026-07-16 a server-wide log truncation broke logging for Lucerna prod).
Postgres (prod)One Coolify-managed container. ops/ has no backup job.Server-side data (books, notes, vocabulary, SRS state) is at risk; no point-in-time restore.
Appwrite (identity)Self-hosted 1.7.4, a 25-container stack with its own MariaDB and Redis; files on local Docker volumes; _APP_OPENSSL_KEY_V1 exists only on that machine (ops/appwrite-migration.md).Nobody can sign in or register; lose the key and every stored file is undecryptable.
mainbackend (Go)Stateless HTTP; pgxpool of 16; /api/v1/health returns 200, or 503 naming the failed upstream. Runs goose migrations at boot, without a lock (cmd/server/main.go:77).Sync, vocabulary, review stop. Two replicas booting together would race on migrations.
auth (Go)Stateless, except a cron-called guest reaper (/cron/cleanup-guests, bearer secret).Sign-in stops.
nlp-api (Python)Stateless, but loads spaCy and a translation model at start. Pilot decision (2026-10-13): M2M-100 418M, served as CTranslate2 int8 on CPU; NLLB stays on disk and remains switchable at zero cost. Benchmark (2026-10-10, FLORES-200 devtest, 200 sentences/direction): into English, NLLB wins big; into Chinese, M2M wins; NLLB-distilled-600M truncates early into Chinese. (Record corrected 2026-10-28; previously listed as a 1.3B-parameter NLLB model.)Lookup, grammar and translation stop; slow cold start. Packaging record: translation.
ClientsDesktop is offline-first: reads from local SQLite; offline writes go into an outbox in the same transaction; the server is idempotent on replay (offline-first-outbox). Web is online-first: read caches, no outbox.Desktop readers keep reading and saving through an outage; web readers lose saving.
R2 / VerdaccioR2 is managed. Verdaccio is needed only at build time.Not on the reading path.

The asymmetry matters: for a reading app, the product is mostly on the client. Ingest is client-side and an opened book lives locally. Availability is less about keeping one server up and more about three promises:

  1. Reading an opened book never needs the server.
  2. A saved word or note is never lost.
  3. Sign-in, upload, lookup and translation may degrade briefly, and recover on their own.

The plan (proposal)

Proposed targets: data loss (RPO) ≤ 5 minutes, then zero; recovery (RTO) ≤ 1 hour after stage 0, minutes after stage 2. Ordered by value per unit of effort — this is a two-person project.

Stage 0 — survive losing the host

  • Backups off the host. Postgres: continuous WAL archiving to R2 (wal-g or pgBackRest), which gives point-in-time restore. Appwrite: nightly MariaDB dump to R2, and move file storage to R2 (route A in ops/appwrite-migration.md). Escrow the Appwrite keys in the password manager — the migration doc already names this as the cheapest fix for the largest risk.
  • A restore drill, not just backups. Monthly, a script restores the latest backup into a scratch environment and runs the smoke suite. The time it takes is the RTO; an untested backup is a hope.
  • Monitoring from outside. An external checker hits /api/v1/health, auth, nlp-api and the web shell every minute and pages on failure. The health endpoint already names the failed upstream.
  • Leave the shared host, or at least cap each container's CPU and memory and rotate logs, so another tenant's mistake cannot take Lucerna down.
  • A reproducible host: the Coolify configuration (labels and env keys already live in ops/coolify) is enough to stand up a new machine from the backups.

Stage 1 — no single app instance

  • Two or more replicas of mainbackend, auth, web and nlp-api on two hosts, behind a load balancer that routes on the existing health endpoints. One host down means half the capacity, not an outage.
  • Migrations stop running unguarded at boot. Either one release job runs them, or boot takes a Postgres advisory lock (goose's Provider API supports WithSessionLocker). Migrations are written expand → migrate → contract, so old and new replicas can serve together during a rolling deploy — that is what makes deploys zero-downtime.
  • One scheduler for the guest reaper. Deleting guests older than 24 h is safe to run twice, so a duplicate run is harmless.
  • nlp-api fails soft. It gets its own nodes (the model is the heavy part), and the client wraps it in a timeout and a circuit breaker: when it is down, the reader keeps working and the word popover says lookup is unavailable.
  • Give the web shell the desktop's outbox. Web already has SQLite (wa-sqlite) caches; the Hybrid*Repository + outbox pattern from desktop extends to web, so saving works through an outage there too. For readers, this is the largest availability gain in the whole plan.

Stage 2 — no single database

  • Postgres with a standby and automatic failover: a managed service with one synchronous standby, or Patroni with etcd if it stays self-hosted; PgBouncer in front; the app sees one endpoint. After failover the app reconnects and retries. Retries are safe for replayed outbox writes because the server is idempotent; every write endpoint that web calls directly needs the same guarantee first (an idempotency key per write).
  • Take identity off the self-hosted stack. Making a 25-container Appwrite highly available means MariaDB replication, Redis failover and shared storage — not worth it for two people. Move to Appwrite Cloud (route B in the migration doc): a custom domain keeps appwrite.lucernaread.com valid for already-shipped desktop builds, and the project id can be reused — both to be verified on dev first, as the doc says.

How it is proved, not claimed

  • Kill the Postgres primary in staging during an e2e run; measure time to recover and check no write was lost.
  • Stop one replica of each service during an e2e run; the run must stay green.
  • The monthly restore drill reports its measured RTO and RPO.
  • Existing outbox e2e, run with the backend down: saves queue, then drain after recovery.

What not to do

  • Multi-region active-active. SRS scheduling has one writer per user; two writable regions buy conflict resolution, not availability.
  • Kubernetes for this team size — Coolify on two hosts plus a load balancer covers stages 0–1.
  • Self-hosted HA Appwrite — see stage 2.
  • Sharding — the data is small; the bottleneck is the model, not the database.
关于这一条

sijie 的一条 wiki 条目。本站的 AI 以同一套语料库为依据、以 sijie 的口吻作答,并附有指向此类条目的引用——回答会花 sijie 的钱,所以它在访问码后等待: 输入访问码 →