Horizontal scaling audit — capability & necessity
Date: 2026-08-25 · Branch: feat/cloud-enterprise-tenants @ d311c6f9
Method: 8 parallel code/infra audits over 207k LOC Go + 22-service compose, plus read-only recon on all three live deployments (prod dms1, Cloud VM2 dms2, demo valbox). Every load-bearing claim re-verified directly against source.
Verdict
| Question | Answer |
|---|---|
| Can Obscura scale horizontally today? | No. 15 confirmed blockers. docker compose --scale obscura=2 does not even start (port pin), and if it did, replica #2 sends every notification twice. |
| Does Obscura need to scale horizontally? | No — not for capacity, by roughly three orders of magnitude. Prod runs at 2.7% of one core on an 8-core box. |
| So is there nothing to do? | No — there are four things broken today, on one replica, found while looking for scaling bugs. Those are the real deliverable. |
ARCHITECTURE.md:38 claims "Horizontal scale = N stateless api + M worker replicas against one Postgres." That claim is aspirational. It is not true today, and the code says so in about six places — ratelimit.go:19-22, preview_session.go:1-7, handlers_office_builder.go:78-81, handlers_office_apply.go:42-46, analysis_cache.go:12-14, handlers_mcp_office.go:57-59 all explicitly document a single-process assumption.
Part 1 — NECESSITY: the measured case against
All numbers from live read-only recon on 2026-08-25.
| PROD (dms1) | Cloud/VM2 (dms2) | Demo (valbox) | |
|---|---|---|---|
| Host | 8 cores / 31 GB | 4 cores / 7 GB | 12 cores / 62 GB (shared) |
| App container CPU | 2.72% | 3.61% | 0.49% |
| App container RAM | 213 MiB / 31 GiB (0.66%) | 79 MiB (1.0%) | 28 MiB |
| Postgres | 1.10% / 168 MiB | 0.66% / 63 MiB | 0.31% / 86 MiB |
| DB size | 76 MB | 22 MB | 67 MB |
| Tenants / users / docs | 2 / 17 / 314 | 1 / 5 / 15 | – / 44 / 722 |
| DB connections | 1 active, 8 idle (max 100) | 1 active, 7 idle | 1 active, 4 idle |
| Restarts / OOM kills | 0 / 0 | 0 / 0 | 0 / 0 |
Across all three deployments combined: 1,051 documents, 66 users, 3 tenants, 165 MB of database.
Supporting evidence:
- Zero saturation events, ever. 40 containers across 3 hosts:
RestartCount=0,OOMKilled=falseeverywhere.dmesg | grep -ic "out of memory"→0. - Peak observed burst was 283 req/min (4.7 req/s) and the app absorbed it below 4% of a single core.
- Real daily active users on prod: 1–5. From
audit_eventscount(distinct actor)over 14 days — peaked at 5, was 1 on 23 and 25 Aug. - ~40% of prod "traffic" is agent traffic, not humans. Top paths:
POST /api/v1/mcp405,GET /api/v1/mcp109 out of 1,278 requests. User-agents:claude-code/2.1.x= 483 hits. Mobile app: 45 hits total. - The valbox load average of 26.69 is not Obscura — it is
obs-test-pg(552% CPU, unrelated timescaledb) and the x056 gateway. Obscura's own container there is at 0.49%. Same attribution trap as the disk-usage figure. - The product's own pricing model tops out below the problem.
docs/PRICING.md:93: "Cloud masuk akal sampai ±500 seat" — above that the stated correct answer is on-prem perpetual, i.e. a customer's own box.docs/CLIENT_DEPLOYMENT.md:16sizes a deployment at "~35 users → 8 vCPU / 32 GB". - No SLA or uptime target exists anywhere in the repo. Grepped every
.mdforSLA|uptime|99\.[59]|RTO|RPO. Nothing has been committed to. - No load-test harness is committed. Zero hits for k6, vegeta, locust, wrk, ab, hey, artillery, autocannon. Zero Go
Benchmark*..e2e/battle.cjsis aPromise.allof two requests.
Git history: has it ever fallen over from load?
No. 2,046 commits; 32 matching perf|slow|timeout|oom|memory|scale|concurren|leak. Three real resource-exhaustion events, none of them many-users-at-once:
9db62da1(08-14) — stego sidecar OOM.os.cpu_count()returned the host's 12 cores inside acpus: 3.0container, so one 2.8 MB PDF forked 11 renderers into a 4 GiB cap. One document, one request.2ec6afdf(08-04) — embed sidecar wedged five days while/readyzsaid "ok". A batch-size misconfiguration (16 passages needing 80–127s against a 60s timeout), not load.ded752d0(07-16) — a hardcoded 60s timeout aborted a 124s OCR; the hourly backfill then retried in a storm. Self-inflicted; the site kept serving throughout.
The one genuine capacity cliff (docs/CONCURRENCY-AUDIT.md, fixed in 9c622802) was found by the author generating synthetic load, before any user hit it.
Would app replicas even help?
No — because the app is a thin orchestrator. Every operation costing more than ~10 ms of wall-clock is in a sidecar or on someone else's network:
| Operation | Measured cost | Where the CPU actually is |
|---|---|---|
| Embedding on upload | 10.8s/passage; batch of 16 = 80–127s | embed-sidecar |
| OCR (scanned 25pp) | 124s | extract-sidecar |
| Stego seal (raster) | 24.3s vs 0.47s word-gap | stego-sidecar |
| PDF text extract | 2.95s at 101% CPU | extract-sidecar |
| Office→PDF | ~2.2s; 4 concurrent = 6.95s | gotenberg (LibreOffice, serial) |
| Certified sign (Peruri) | 30–60s+ | external network |
| AI chat | provider round-trip | external; holds one parked goroutine, no DB connection |
| CRUD / list / search | ~ms | Postgres |
Adding app replicas moves zero CPU-seconds of the real work. Worse, it actively harms two paths: OFFICE_CONVERT_CONCURRENCY (default 1, config.go:349) and mcpBuilderSlots (2) are per-process semaphores, so replica #2 doubles the arrival rate onto the exact queue the semaphore exists to protect.
Part 2 — CAPABILITY: 15 confirmed blockers
Ranked by blast radius. Every one verified against source.
🔴 Money / data / security
1. The outbox relay has no claim. go/internal/platform/events/events.go:80-113
`SELECT seq, ... FROM outbox_events WHERE published_at IS NULL ORDER BY seq LIMIT $1`
// ...no transaction, no FOR UPDATE, no SKIP LOCKED, no claim column
if err := r.handler(ctx, e); err != nil { ... } // ← side effects fire HERE
`UPDATE outbox_events SET published_at = now() WHERE seq = $1`
Runs on a 2-second ticker (wire.go:2555). grep -rn "SKIP LOCKED" go/ → zero hits repo-wide. notify.Notify mints a fresh kernel.NewID() per call with no idempotency key.
N replicas ⇒ every approval email, escalation, signing invite, workflow outcome, WhatsApp message, push notification and gatekeeper triage row fires N times. Not a rare race — the overlap window is the entire handler duration, including SMTP round-trips. This is the single worst finding.
2. Peruri serial claim is a process-local mutex. go/internal/esign/adapters/sealer_peruri.go:64-66
// snClaim serializes "look for an outstanding serial, then claim or buy it" so two
// concurrent affixes cannot both adopt the same paid-but-unstamped serial.
snClaim sync.Mutex
Two replicas ⇒ both adopt the same paid serial, or both buy a new one at Rp 20.000. Compounded by the known-dead SN reclaim.
3. Every security budget divides by N. All in-memory maps:
| Guard | Site | Budget | With N replicas |
|---|---|---|---|
| Login/register per IP | ratelimit.go:23, server.go:723 |
60/min | 60N — credential brute-force |
| Payment attempt (anti-carding) | ratelimit.go:80, server.go:919 |
10/user/min | 10N |
| Public-sign OTP send/submit | handlers_public_sign.go:49-51 |
5 / 10 per min | 5N / 10N |
| Step-up TOTP lockout | auth/app/service.go:38-39 |
5 fails / 15 min | 5N, and a lockout on A is invisible on B |
| Secure-folder insider-exfil alarm | securefolder.go:112-121 |
50 unwraps / 10 min | never fires — unwraps split round-robin never cross the threshold on any one replica |
ratelimit.go:19-22 already says it: "in-memory suits the single-node on-prem target. A multi-replica deployment would need a shared store."
4. Workflow instances have three blind writers. workflow/adapters/pg.go:453-460 is UPDATE workflow_instances SET state=$2 WHERE id=$1 — no status predicate, RowsAffected discarded. GetInstanceForUpdate (a real FOR UPDATE) exists and is used correctly in 9 places, but FinalizeInstance (service.go:2942), CancelInstance (:3234) and ReturnFromSubWorkflow (:3588) use plain GetInstance.
Replica A approves; replica B's cancel — which read submitted, passed its Go-side guard, and blocked on A's row lock — then stamps cancelled over approved. Reverse the order and a cancelled instance becomes approved with its signing links relit, which is precisely the invariant 47c44a50 was fixed to guarantee and verified live on prod on 08-24. Fix is three characters each.
5. Migrations race with no lock. MIGRATE_ON_BOOT defaults true (config.go:114) and compose hardcodes "true" (docker-compose.yml:470). go/migrations/embed.go:34 uses the legacy global goose.UpContext — an API that cannot lock at all — and SessionLocker appears nowhere in the tree. N replicas booting after a release all read version N and all attempt N+1 → duplicate-key on goose_db_version, non-zero exit, crash-loop on every migration-carrying deploy. In Cloud this multiplies by tenant count (MigrateAllTenants, wire.go:2214).
6. MCP office edit locks are process-local. handlers_mcp_office.go:68-73. Its own comment names the failure: "the second would land a version that silently discards the first one's work — a lost update that no dedupe can detect." There is no DB row lock behind it.
7. Licence hot-swap is per-process and per-node. licensing.go:73 (s.lic.Store) + handlers_license.go:52 (atomicWriteFile to a node-local bind mount). An admin uploads a renewal → one replica swaps. The others serve 403 module.not_licensed indefinitely, and across restarts because their bind-mounted file was never rewritten. The response reports persisted: true.
🟠 Correctness / availability
8. Three capability-token stores live in process memory and are fetched back over the network.
| Store | Site | Breaks |
|---|---|---|
previewSessions |
preview_session.go:74 → server.go:787 |
Secure Preview, share links, the public signing ceremony, letter preview, MCP preview. 6 mint sites; the browser's follow-up page fetch 404s on (N−1)/N. |
builderScripts |
handlers_office_builder.go:82 → server.go:782 |
Every MCP edit_office_document, MCP create_document, browser /office/content. |
officeLiveKeys |
handlers_office_apply.go:47 |
OnlyOffice save callback → 409 errOfficeNoSession. |
🔴 Stickiness cannot fix #2 and #3. The OnlyOffice doc-server is a separate, cookie-less HTTP client, and hashing the token doesn't work because the token is minted server-side (its hash is uncorrelated with the minting replica). In Enterprise the doc-server doesn't even traverse nginx — it dials obscura:8080 directly and Docker DNS round-robins. These require a shared store, not affinity.
9. Sign-IT OTP tokens in memory. sealer_signit.go:64-65. GET OTP on A, submit on B ⇒ empty token posted to Peruri ⇒ the external signer enters the correct emailed code and signing fails. No retry path recovers it.
10. Admin settings bound at boot, per process. Auth settings (auth/app/service.go:79, sole reload caller is boot), SMTP relay (notify/adapters/smtp.go:64), AI provider + API key (ai/app/provider.go:154-181, binds on first sight, no TTL), Enterprise rate limits (handlers_ratelimit.go:67, no TTL).
Concrete: an admin turns self-registration off — replica B keeps letting strangers register forever. A tenant rotates a leaked AI key — replicas B..N keep sending document text to the old vendor on the revoked key.
11. Two read-write volumes need RWX shared storage. office-fonts (compose:673, written by the app, read by OnlyOffice and Gotenberg — a font present on one node makes the same document render with different line breaks) and peruri_sharefolder (compose:680, a node-local named volume the single signadapter reads; a miss costs Rp 20.000 per the dead SN reclaim).
12. ResumeFolderEncryptions runs unguarded on every boot (wire.go:2007, :2184 — outside the RunsWorker gate). N replicas burn maxEncryptPasses on each other, then failEncryption clears the pending marker and leaves the folder plain.
🟡 Mechanics — it won't even start
13. The published port pins it to one. docker-compose.yml:685 ports: ["127.0.0.1:38080:8080"]. --scale obscura=2 fails on port allocation. update.sh:72 health-gates on that same fixed port.
14. The api/worker split is dead code. config.go:1522 RunsAPI() has zero call sites — verified. The HTTP server starts unconditionally, so OBSCURA_ROLE=worker is byte-identical to all. And docker-compose.yml:355 hardcodes OBSCURA_ROLE: all as a literal, not ${...} — the split is unreachable from any composed deployment. Nobody has ever run it.
15. The 15s drain is cut to 10s. wire.go:2780 drains for 15s; no service declares stop_grace_period anywhere in deploy/, so Docker SIGKILLs at 10s. There is no readiness/liveness distinction either: /healthz is a constant 200 (wire.go:2738), and the listener does not open until after migrations, so a warming replica is connection-refused rather than "not ready" — an LB cannot tell it from a crash.
Part 3 — the sidecar ceiling: what saturates first
Even with all 15 fixed, app replicas hit a downstream wall almost immediately. Ranked:
| # | Service | Saturates | Scale-out |
|---|---|---|---|
| 1 | Postgres — connections | Before replica 2. Cloud needs 64×4 + max(4,NumCPU) ≈ 264 per replica against max_connections=100 (verified live; stock config, no command:, no postgresql.conf in the repo) |
Possible — raise the limit, add PgBouncer in session mode only (transaction pooling would leak SET search_path across tenants). Read-splitting is blocked: poolFor(ctx) has no read/write intent parameter |
| 2 | OnlyOffice | At 20 concurrent editing connections, whatever N is | Blocked. documentserver:8.3 = Community/AGPL. Clustering is a licensed Enterprise feature. No external Redis/PG/AMQP wired; editing state isn't even persisted to a volume |
| 3 | Gotenberg (LibreOffice) | ~27–35 office→PDF/min for the whole deployment (measured: 2.24s mean serial; 4 concurrent inflates latency 3.6× to 6.95s) | Possible (~half a day) — stateless, but GOTENBERG_URL is a single string, so it needs an external LB |
| 4 | extract-sidecar | 3 concurrent at cpus: 3.0 — and it serves every PDF page render, filmstrip, egress watermark and upload, with zero Go-side bound |
Possible — stateless. ⚠️ pdfium is not thread-safe: making the handlers def SIGSEGVs (verified in app.py:9-27 — 1 thread survives, 3 threads exit 139). Scales by processes only |
| 5 | Postgres — pgvector CPU | Any real semantic load: ACL-scoped search deliberately abandons the HNSW index (dms/adapters/pg.go:726-732) → sequential distance scan |
Possible, but lands on the same single Postgres as #1 |
| 6 | embed-sidecar | Memory: 1.41 GiB measured per process vs mem_limit: 3g → N ≤ 2. Prod is at 84.68% of its cap right now |
Expensive; also never got the process-count fix extract did |
| 7 | wuzapi / signadapter | Not throughput — correctness | Blocked. One whatsmeow device session on SQLite (re-pairing needs a human with the phone); one unauthenticated signadapter over one shared volume |
Nothing has deploy:/replicas: anywhere. mem_limit/cpus exist on exactly 5 of 22 services — obscura, postgres, minio, onlyoffice and web are all unbounded. Sidecar quota already sums to 15 CPUs on an 8-vCPU prod box; adding app replicas onto the same host makes that strictly worse.
Part 4 — what is actually broken today, on one replica
These came out of the audit and do not depend on scaling at all.
1. 🔴 GuardStorage ignores the bytes being written. go/internal/tenancy/app/quota.go:169
func (q *Quotas) GuardStorage(ctx context.Context, incoming int64) error {
u, err := q.Usage(ctx)
if err != nil { return nil }
if !u.OverStorage() { return nil } // ← `incoming` is NEVER read
Verified: grep -n incoming quota.go returns line 169 only — the parameter appears in the signature and nowhere else. The call site passes the real size (blob/minio.go:181), and the migration bounds the intended overshoot at one file. Actual bound: one file of unlimited size. A tenant at 4.99 GiB of a 5 GiB quota can upload 500 GB. No test varies incoming. The per-user guard already does it right at resolvers.go:337. One-line fix.
(The fail-open on a measurement error above it is deliberate and correctly documented — that part is fine.)
2. 🔴 Deploys sever in-flight signatures. wire.go:2780 drains 15s; Docker kills at 10s because no stop_grace_period is declared. For a records/signature system, a routine business-hours deploy cuts an in-flight 30–60s certified Peruri signature, a 124s OCR, a 24s stego seal. That is materially worse than the 502 window itself. Ten-minute fix.
3. 🔴 Postgres is completely untuned. pgvector/pgvector:pg17 with no command:, no postgresql.conf, no tuning anywhere in the tree. Verified live: shared_buffers=128MB, work_mem=4MB, max_connections=100 — on a 32 GB host, backing relational data + pgvector HNSW + tsvector FTS + River + the outbox. Typically 2–10× on this workload for a 20-line change.
4. 🔴 The Cloud connection budget already exceeds the Postgres default. TENANT_POOL_CACHE_SIZE 64 × TENANT_POOL_MAX_CONNS 4 = 256 potential against max_connections=100. docs/CLOUD_DEPLOYMENT.md:37 says to size this before onboarding tenants; compose ships stock Postgres. This is Cloud's first hard wall — roughly 25 concurrently-active tenants — and the failure mode is a hang, not a 503 (no acquire timeout, no statement_timeout, no WriteTimeout). It is also invisible: the metrics collector reports only the base pool; DB.TenantPoolCount() has no non-test caller.
Also worth knowing: prod's running containers predate their own config. docker inspect shows all deploy-* containers at NanoCpus=0 Memory=0, and deploy-extract-sidecar-1's PID 1 is the pre-fix single-process uvicorn app:app. The compose file has been right for five weeks; these containers have not been recreated. An env file states an intention — only docker inspect states a fact.
Recommendation
Do not build horizontal scaling. Capacity is not the constraint and will not be within the product's own pricing horizon; the work is ~3–5 weeks of de-statefulling and would then hit an OnlyOffice licence wall and a single Postgres anyway.
Do this instead, in order:
- Fix
GuardStorage—u.StorageBytes+incoming >= *u.StorageQuotaBytes. One line, broken now, unbounded blast radius. (15 min) stop_grace_period: 20sonobscura. Stops deploys killing in-flight signatures. (10 min)- Tune Postgres + raise
max_connectionsabove 264. Biggest single throughput win available, and it removes Cloud's first hard wall. (2–3 hours) - Give the outbox relay a claim (
FOR UPDATE SKIP LOCKED, or copy the advisory-lock pattern already working ataudit/adapters/pg.go:250). Required before any second process, including the role split. (half a day) - Wire
RunsAPI(), interpolateOBSCURA_ROLE, run 1 API + 1 worker. Multi-process, not multi-replica — buys the worker isolation and blast-radius containment that people actually want from "scaling", and the scheduler and audit chainer are already replica-safe. (~2 hours after step 4) - Add admission control — a bounded, per-tenant semaphore in front of extract/stego/embed.
docs/CONCURRENCY-AUDIT.md:171: "There is no semaphore, worker pool orerrgroup.SetLimitin the Go tree." This is what fixes noisy-neighbour, not replicas. (1–2 days) - Commit a load harness. The one real capacity cliff was found by ad-hoc load that was then thrown away.
- Then buy a bigger VM if needed. 16 vCPU/64 GB ≈ Rp 120jt/yr against deal sizes of Rp 350jt–2 M — 1–5% of one deal. One box reaches ~10–20× today's prod, comfortably past "±500 seats, then sell them on-prem anyway."
The one thing a bigger box does not buy is availability — but neither do app replicas, while Postgres and MinIO are singletons on one VM. If HA is the actual goal, that is a different project (Postgres replication + MinIO erasure coding), and it should be scoped as one.
What the codebase already got right
Worth not breaking: sessions and refresh tokens are opaque DB rows with atomic reuse detection; WebAuthn ceremonies, step-up grants, OIDC state and share OTPs are all DB-backed; the audit hash chain takes a per-partition pg_advisory_xact_lock and cannot fork; the scheduler's ClaimDue is a correct atomic claim and says so in a comment; letter numbering is gapless under an advisory lock held across read and insert; billing runs on River with SKIP LOCKED + UniqueOpts; the preview page cache is content-addressed in Postgres + blobs with ON CONFLICT DO NOTHING; and the two things that normally make a licensed product un-scalable — node-lock and the anti-rollback clock — are both derived from the shared Postgres cluster and are already correct.
The auth, audit and billing layers are replica-ready. It is the ephemeral capability tokens, the outbox, the in-memory security counters and the connection budget that are not.