Status: Accepted Datum: 2026-05-16 Oblast: Infrastruktura / Data-plane / DB provisioning Supersedes: ADR-016 (Per-app DB provisioning) Navazuje na: ADR-015 (Per-tenant K8s namespace provisioning)
Revize 3 (2026-05-16): Revize 2 §2.1 byla věcně mylná — pgcat jako data-plane pooler odmítnut ze dvou nezávislých, zdrojákově ověřených důvodů: (1)
pg_authid/rolpasswordje na DO Managed PostgreSQL nečitelné i prodoadmin(DO nedává superuser) → auth_query NEMŮŽE číst nativní PG password store; (2) pgcatauth_queryje MD5-only —src/auth_passthrough.rsvyžaduje prefixmd5, SCRAM-SHA-256 není implementováno (postgresml/pgcat#255, postgresml/pgcat#624, obě OPEN, maintainer 10/2025: „work hasn’t started”). Data-plane pooler: pgcat → self-host PgBouncer (mainline). PgBouncer od 1.14 podporuje klient→pooler SCRAM-SHA-256 +auth_queryproti SECURITY DEFINER funkci (pgbouncer/pgbouncer#508 CLOSED). Zavedena vlastní credential tabulkadataplane_auth.credentials(SCRAM-SHA-256 verifier, ne plaintext/MD5). Architektonický koncept §2 (low-priv authenticator role, SECURITY DEFINER fn, jeden statický pool, nula reloadů) zůstává beze změny — mění se jen binárka a config syntax. §2.1 nahrazena novou verzí; Alternatives a Consequences doplněny.
Revize 2 (2026-05-16): data-plane runtime pooler změněn z DO Managed PgBouncer na self-host pgcat (auth_query) — DO Managed PgBouncer neumí per-role passthrough (ověřeno z DO offic. dokumentace, viz §2). Control-plane PgBouncer na
talkide-prod-pgbeze změny. Zrušeny poolytalkide-dataplane-txatalkide-dataplane-session(DO pooly pro data-plane). Přidána sekce o 4 architektonických rozhodnutích pgcat integrace.
Pozn.: §2.1 Revize 2 (pgcat auth_query + SCRAM) je věcně mylná — superseduje ji Revize 3. Architektonický záměr sekce §2.1 (auth_query mechanismus) zůstává platný; implementační detail (binárka a config syntax) byl opraven.
Context
Problém: vyčerpání connection slotů
DO Managed Postgres Basic cluster má ~25 connections (~21 použitelných user slots). Architektura
zavedená v ADR-016 (DB-per-app) generovala pro každý user-app Spring Boot pod HikariCP connection
pool s výchozími 10 connections. Tyto pody se připojovaly přímo na port 25060 (přímý PG port)
na control-plane clusteru (talkide-prod-pg), zcela mimo DO Managed PgBouncer. Platform BE
přitom routuje provoz přes PgBouncer (~21 conn) na stejném clusteru.
Výsledek v ADR-016 světě (vše na jednom clusteru, direct 25060 pro user-app pody):
| Zdroj | Connections (na talkide-prod-pg) |
|---|---|
| Platform BE (přes PgBouncer) | ~21 |
| foo-bar-dev pod (direct 25060) | ~10 |
| foo-bar-prod pod (direct 25060) | ~10 |
| Reserved superuser | ~3 |
| Celkem (2 user apps + platform) | ~44 / 25 max |
Výsledkem je FATAL: remaining connection slots reserved for non-replication superuser → crash loop
při helm upgrade. Blokuje produkční retest Stopy B.
ADR-023 tento problém řeší dvěma způsoby naráz: (a) schema-per-app v jedné sdílené data-plane DB eliminuje per-app connection bouři přes sdílený pooler, a (b) data-plane DB žije na fyzicky odděleném clusteru — control-plane a data-plane nikdy nesoupeří o stejný connection budget.
Navíc talkide-be#97 identifikoval, že ddl-auto: validate ve scaffold template způsobuje startup
crash přes transaction pooler: Hibernate getCatalog() vrátí klientský pool name (PgBouncer název),
current_database() vrátí skutečný DB name — mismatch → JPA validation crash. Stejná lekce
jako talkide-be#96 (platform BE) — řeší se zde systémově pro všechny user apps.
Proč ne DO Managed PgBouncer pro data-plane?
Ověření oficiální DO dokumentace ukázalo zásadní omezení: DO Managed PgBouncer neumí
auth_query ani per-role passthrough. Jeden pool je striktně vázán na jednu kombinaci
(DB, user) — pro každou per-app PG roli by bylo nutné vytvořit samostatný DO pool. Tento
přístup selže ze dvou důvodů:
- Kapacitní strop: Basic cluster povoluje maximálně 21 poolů celkem — při schema-per-app architektuře to stropne na ~21 user apps, což je nepřijatelné.
- Operační coupling: každý
CREATE ROLE(při provisioning nové appky) by vyžadoval DO API volání pro vytvoření nového pool záznamu a reload pgbounceru — křehká závislost na DO API v kritické cestě deploy.
Kombinace per-app role + DO Managed PgBouncer je tedy technicky neproveditelná pro schema-per-app model. Data-plane pooler musí být self-host — není to “kdyby”, je to jediná cesta, která funguje. Zvolená technologie: PgBouncer (viz §2 Revize 3).
Decision
1. Schema-per-app v jedné sdílené data-plane DB
Nahrazuje DB-per-app z ADR-016 pro default tier.
Každá user-app dostane vlastní schema v jediné sdílené data-plane DB (talkide_dataplane),
nikoliv vlastní databázi. Izolace je zajištěna:
- Dedikovaná PG role per app (naming konvence:
tk_t{tenantId}_p{slug}_{env}). ALTER ROLE <role> SET search_path = <schema>— toto nastavení přežije transaction pooling, protože jde o default role atribut, nikoliv session-levelSET search_path. PgBouncer v transaction módu nevynuluje role atributy mezi transakcemi — žádnýcurrentSchematrick, žádný spike.REVOKE ALL ON SCHEMA public FROM <role>+REVOKE ALL ON SCHEMA <other> FROM <role>— zabránění cross-schema přístupu na DB úrovni.- Liquibase migruje v app schématu:
defaultSchemaName+liquibaseSchemaNamenastaveny natk_t{tenantId}_p{slug}_{env}v JDBC URL nebo Liquibase config.
Naming konvence schématu = naming konvence role = naming konvence z ADR-016 (beze změny):
tk_t{tenantId}_p{slug}_{env} (limit 63 znaků — analýza z ADR-016 sekce 1 platí beze změny).
DO Managed PgBouncer per-app roli (klíčový mechanismus izolace) nezvládl kombinovat s jedním sdíleným pool endpointem — právě proto data-plane přechází na self-host PgBouncer (viz §2).
Příklady:
- tenant 1, projekt
demo, envdev→ schema + roletk_t1_pdemo_dev - tenant 42, projekt
my-awesome-saas, envprod→ schema + roletk_t42_pmy-awesome-saas_prod
2. Pooling: self-host PgBouncer (data-plane) + DO Managed PgBouncer (control-plane beze změny)
Dvě pooler technologie — každá tam kde dává smysl.
Control-plane: DO Managed PgBouncer (beze změny, talkide-be#96)
Cluster talkide-prod-pg zůstává jak je. Pooly talkide-tx (transaction, size 18) +
talkide (session, size 3 pro Liquibase) fungují pro platform BE. Žádné změny.
Data-plane: self-host PgBouncer v K8s
Na clusteru talkide-dataplane-pg je runtime pooler PgBouncer (mainline), deployovaný jako
K8s Deployment v namespace talkide (vedle platform BE). PgBouncer je nakonfigurován s
auth_query mechanismem a auth_type = scram-sha-256 (viz §2.1 níže).
| Pool / endpoint | Mode | PG connections | Účel |
|---|---|---|---|
| PgBouncer (port 5432, K8s Service) | transaction | konfigurovatelný (výchozí 20) | Runtime provoz user-app podů (Hibernate / HikariCP) |
| Direct port 25060 clusteru B | — | admin | Liquibase migrace user apps (deploy-time, viz §2.2) |
Klíčový payoff schema-per-app + PgBouncer: PgBouncer má jediný statický [databases] záznam
v configu, který se s příchodem a odchodem tenantů nikdy nemění. Per-app role je řešena
dynamicky přes auth_query — PgBouncer ji vidí automaticky bez reloadu. Nula PgBouncer reloadů
za běhu. To je architektonický důvod, proč schema-per-app + PgBouncer s auth_query tvoří
přirozenou dvojici.
Connection-budget invariant: PgBouncer s auth_query tvoří server-side pool per-(database, user)
— default_pool_size se aplikuje na každou per-app roli zvlášť. Při N souběžně aktivních per-app
rolích PgBouncer otevře až repliky × N × default_pool_size server-side konekcí do DO clusteru.
Správný invariant: repliky × Σ(aktivní per-app pooly) ≤ ~25 (DO Basic budget), kde horní mez je
repliky × počet_souběžně_aktivních_per-app_rolí × default_pool_size.
Mitigace (rozhodnutí zakotvené v ADR):
default_pool_sizeper role 2–3 (ne výchozích 20 vztažených k celému PgBouncer procesu — to by byl součet přes všechny aktivní role, ne na roli).min_pool_size = 0— idle/neaktivní app drží 0 server-side konekcí; pool se otevře on-demand a poserver_idle_timeoutse automaticky zavře.server_idle_timeoutnastavit rozumně nízko (výchozí 600 s je vhodný, snížit na 60–120 s dle potřeby) — uvolní konexi neaktivního poolu dříve.- Alpha kontext: 1–3 tenanti se sporadickým provozem → reálný součet souběžně aktivních poolů
nízký; budget hlídat monitoringem (
SHOW POOLS/ PgBouncer Prometheus exporter). - Škálování počtu tenantů = trigger pro vyšší DO DB tier (vyšší conn budget), ne automatika.
DO 21-pool cap byl artefaktem DO Managed produktu — self-host PgBouncer žádný strop na počet per-app rolí nemá.
2.1 auth_query mechanismus — PgBouncer + SCRAM-SHA-256 + vlastní credential tabulka
(Nahrazuje §2.1 z Revize 2, která předpokládala pgcat + čtení z pg_authid — oboje zdrojákově vyvráceno, viz hlavička Revize 3.)
Bloker 1: pg_authid nečitelné na DO Managed PG.
pg_authid a pg_shadow (sloupec rolpassword) jsou superuser-only systemové katalogy.
DO Managed PostgreSQL neposkytuje superuser roli ani doadmin — viz
DO docs: pg_dumpall permission denied for pg_authid.
auth_query tedy NEMŮŽE číst nativní PG password store. Platí pro doadmin i pro
talkide_provisioner.
Bloker 2: pgcat auth_query je MD5-only.
Ve zdrojáku src/auth_passthrough.rs (postgresml/pgcat) musí hash mít prefix md5.
Klient→pgcat SCRAM-SHA-256 není implementováno (issue postgresml/pgcat#255 a
postgresml/pgcat#624, obě OPEN; maintainer v říjnu 2025: „work hasn’t started”). Pgcat by
vynutil deprecated MD5 napříč data-plane — nepřijatelné. pgcat je proto odmítnut jako
data-plane pooler.
Zvolené řešení: mainline PgBouncer.
PgBouncer od verze 1.14 podporuje klient→pooler SCRAM-SHA-256 + auth_query proti
libovolné SECURITY DEFINER funkci — issue pgbouncer/pgbouncer#508 (CLOSED). Zdroje:
pgbouncer.org/config.html,
Crunchy Data: PgBouncer SCRAM authentication.
Vlastní credential tabulka (místo pg_authid).
Protože pg_authid není čitelné, vede se vlastní tabulka v talkide_dataplane:
CREATE SCHEMA dataplane_auth;
CREATE TABLE dataplane_auth.credentials (
rolname TEXT PRIMARY KEY,
secret TEXT NOT NULL -- SCRAM-SHA-256 verifier string, NIKDY plaintext ani MD5
);
Formát secret: SCRAM-SHA-256$<iter>:<base64-salt>$<base64-storedKey>:<base64-serverKey>
(standardní RFC 5802 / RFC 7677 verifier string, totožný s tím co PG ukládá interně do pg_authid).
SECURITY DEFINER funkce (neutrální název, nezávislý na pooler binárce):
CREATE OR REPLACE FUNCTION dataplane_auth.dataplane_get_auth(p_rolname TEXT)
RETURNS TABLE(rolname TEXT, secret TEXT)
LANGUAGE sql SECURITY DEFINER AS $
SELECT rolname, secret
FROM dataplane_auth.credentials
WHERE rolname = p_rolname
-- guard: nikdy nevracej authenticator / admin / superuser role
AND rolname LIKE 'tk_t%'
AND rolname NOT IN ('dataplane_authenticator', 'talkide_provisioner', 'doadmin');
$;
GRANT EXECUTE ON FUNCTION dataplane_auth.dataplane_get_auth(TEXT) TO dataplane_authenticator;
pgbouncer.ini klíčová konfigurace:
[databases]
talkide_dataplane = host=<cluster-b-private> port=25060 dbname=talkide_dataplane
[pgbouncer]
listen_port = 5432
pool_mode = transaction
auth_type = scram-sha-256
auth_user = dataplane_authenticator
auth_query = SELECT rolname, secret FROM dataplane_auth.dataplane_get_auth($1)
default_pool_size = 20
Role dataplane_authenticator — low-priv, pouze EXECUTE na dataplane_get_auth, žádný
přístup k user datům. Zakládá se jako jednorázový bootstrap krok (ne provisioner za běhu).
Bootstrap jako doadmin (ne talkide_provisioner): doadmin má CREATEROLE a ve svých DB
zvládne (v tomto pořadí):
CREATE SCHEMA dataplane_authCREATE TABLE dataplane_auth.credentials (...)(viz DDL výše)CREATE OR REPLACE FUNCTION dataplane_auth.dataplane_get_auth(...)(viz DDL výše)REVOKE ALL ON SCHEMA public FROM PUBLICGRANT USAGE ON SCHEMA dataplane_auth TO dataplane_authenticator— bez USAGE na schématu funkce není volatelná i přes EXECUTE grant (permission denied for schema dataplane_auth)GRANT EXECUTE ON FUNCTION dataplane_auth.dataplane_get_auth(TEXT) TO dataplane_authenticatorALTER ROLE talkide_provisioner CREATEROLE— nutné, jinak runtime per-appCREATE ROLEpadneGRANT USAGE ON SCHEMA dataplane_auth TO talkide_provisioner— bez USAGE na schématu provisioner dostanepermission denied for schema dataplane_authi při platném DML grantuGRANT INSERT, DELETE ON dataplane_auth.credentials TO talkide_provisioner
Poznámka: CREATE ROLE dataplane_authenticator WITH LOGIN PASSWORD '...' se zakládá před body
1–9 (role musí existovat, než ji GRANTujeme).
2.2 Liquibase mimo pooler — direct port
(Beze změny oproti Revizi 2.)
Liquibase migrace user apps neprobíhají přes PgBouncer. Běží přes přímý port 25060 clusteru B admin spojením v deploy-time (provisioner má direct admin přístup na cluster).
Důvody:
- Liquibase vyžaduje
pg_advisory_lock, což je session-level stav — nekompatibilní s transaction pooler módem. - PgBouncer v transaction módu garantovaně rozbije advisory lock (lock se uvolní při odpojení session, PgBouncer session neperzistuje mezi transakcemi).
- Provisioner stejně drží admin DataSource na cluster B pro
CREATE SCHEMA,CREATE ROLEatd. — přirozeně ho využije i pro Liquibase.
Důsledek: žádný session pooler pro data-plane Liquibase není potřeba. Liquibase jde vždy přes direct admin conn.
2.3 SCRAM verifier — tok provisioner (be#109)
Provisioner (Kotlin, talkide_provisioner role) generuje SCRAM-SHA-256 verifier sám v
aplikačním kódu — NEČTE zpět z pg_authid (na DO Managed to nejde ani provisionerovi).
Tok pro novou appku:
- Vygeneruj kryptograficky náhodné plaintext heslo.
- Spočítej SCRAM-SHA-256 verifier v Kotlinu:
random salt (≥ 16 bytes), iterace ≥ 4096→ verifier string ve formátuSCRAM-SHA-256$<iter>:<base64-salt>$<base64-storedKey>:<base64-serverKey>. CREATE ROLE <app-role> WITH LOGIN PASSWORD 'SCRAM-SHA-256$...'— předáš PG verifier verbatim; PG ho uloží do pg_authid 1:1. Role a credential tabulka sedí bit-přesně.INSERT INTO dataplane_auth.credentials(rolname, secret) VALUES ('<app-role>', '<verifier>').- Plaintext heslo ulož do K8s Secret
app-{slug}-{env}-db(pro přímý JDBC bez pooleru — lokální dev, Liquibase). Verifier nikam jinam nevytéká.
Teardown: DELETE FROM dataplane_auth.credentials WHERE rolname = '<app-role>' + DROP ROLE.
Kritická invarianta: provisioner NIKDY nesmí předat plaintext heslo v auth_query ani ho
ukládat do credential tabulky. Credential tabulka obsahuje výhradně SCRAM-SHA-256 verifier string.
3. Scaffold template: ddl-auto: none + prepareThreshold=0
(Beze změny oproti Revizi 2.)
Oprava talkide-be#97 systémově pro všechny user apps.
Scaffold template (Mara-generované Spring Boot apps) se změní takto:
spring:
jpa:
hibernate:
ddl-auto: none # nikdy validate — i validate dělá startup introspekci co crashuje přes pooler
datasource:
url: "jdbc:postgresql://...?prepareThreshold=0&..."
prepareThreshold=0 zakazuje server-side prepared statements — ty rozbíjejí transaction pooler
(prepared statement je vázaný na session, transaction mode je mezi requesty přehazuje). Toto
platí pro PgBouncer transaction mód stejně jako pro DO Managed PgBouncer (talkide-be#97 constraint
je obecný, není DO-specifický).
Liquibase = single source of truth pro schema. Schema drift se hlídá liquibase validate v CI,
nikoliv Hibernate startup introspection.
4. Control-plane vs. data-plane separace — dva fyzicky oddělené clustery + dvě pooler technologie
Control-plane a data-plane jsou od začátku na dvou samostatných DO Managed PG clusterech. Nejde o dvě databáze na jednom clusteru — jde o dvě nezávislé infrastrukturní jednotky, každá se svým vlastním connection budgetem.
Separace platí i na úrovni pooler technologie: control-plane používá DO Managed PgBouncer
(single role, žádný auth_query — nepotřebuje ho), data-plane používá self-host PgBouncer
(mainline) (per-app role, auth_query nutnost). Každý cluster dostane pooler, který jeho
požadavkům přesně sedí.
Cluster A — talkide-prod-pg (existující, beze změny):
- Obsahuje control-plane DB
talkide(tenant metadata, projekty, uživatelé, billing, orchestrace). - Pooler: DO Managed PgBouncer —
talkide-tx(transaction, size 18) +talkide(session, size 3 pro Liquibase platform BE). - Provisioner přistupuje k tomuto clusteru přes vlastní admin DataSource (direct port 25060 clusteru A).
- Žádné user-app pody se na tento cluster nepřipojují — jen platform BE.
Cluster B — talkide-dataplane-pg (nový):
- Obsahuje sdílenou data-plane DB
talkide_dataplanese schema-per-app. - Pooler: self-host PgBouncer (mainline) v K8s (transaction mód,
auth_type = scram-sha-256,auth_querypřesdataplane_authenticator+dataplane_auth.dataplane_get_auth). - Credential store:
dataplane_auth.credentials(SCRAM-SHA-256 verifier, ne pg_authid). - Liquibase user apps: přes direct port 25060 clusteru B (admin conn v deploy-time).
- User-app pody se připojují výhradně přes PgBouncer (K8s Service, port 5432) — nikdy přes direct port clusteru B.
- Provisioner přistupuje k direct portu 25060 pro admin operace (CREATE SCHEMA, CREATE ROLE, INSERT do credential tabulky, DROP…).
Žádné přímé cross-DB joins na aplikační úrovni. Provisioner role talkide_provisioner existuje
na obou clusterech zvlášť — každý cluster má vlastní provisioner přihlašovací údaje.
5. PgBouncer HA posture (alpha)
Vědomé alpha riziko — dokumentováno, akceptováno.
Alpha fáze: 1 replika PgBouncer v K8s Deployment + K8s liveness/readiness probe + rychlý
restart (výpadek = HikariCP retry během re-schedule podu, řádově sekundy). PgBouncer s
auth_query je principiálně stateless (žádný session state, auth_query dotazuje DB
dynamicky) — horizontální škálování na N replik za K8s Service je možné. Pozor však na
connection-budget invariant (viz §2 a Consequences bod 6): každá replika vlastní svůj
server-side pool, víc replik bez sticky routingu zvyšuje celkový server-side conn počet vůči
DO clusteru. Repliky škálovat koordinovaně s DB conn budgetem (resize DB tieru), ne naslepo.
Single-thread posture: PgBouncer je single-process, jedno jádro; pod ~32–64 MB RAM, ~50–100m CPU. Jedno jádro zvládne tisíce klientských konekcí — strop je propustnost dotazů (ne počet appek). Škálování přes více K8s replik (stateless s auth_query), každá replika s vlastním server-side pool. Počet user-app schémat není omezen žádným PgBouncer limitem.
Škálování na >1 repliku = pre-stable hardening, ne alpha prerekvizita. Dokud je TalkIDE v alpha (1–10 tenantů), 1 replika + rychlý restart splňuje SLA akceptovatelné pro tuto fázi.
6. Invariant: apps mluví vždy na pooler
Appka nikdy nesmí znát zdrojový DB endpoint — jen PgBouncer K8s Service endpoint. K8s Secret
app-{slug}-{env}-db vždy obsahuje PgBouncer URL. Pokud se v budoucnosti přejde na dedikovaný
cluster per tenant (escape hatch níže), změní se jen obsah Secretu; aplikační kód se nemění.
7. Escape hatch: dedikovaný DB cluster per tenant-environment (NEimplementováno)
Dokumentovaný upgrade path pro PREMIUM plán — NEimplementuje se teď.
Toto je per-tenant-environment dedikovaný cluster jako produkt/plán feature pro silnou izolaci (odlišný zákaznický segment). Jde o jiný koncept než oddělení control-plane vs. data-plane (§4) — to je baseline architektura od začátku, nikoliv escape hatch.
Vyšší plán = dedikovaný DO Managed Postgres cluster per tenant-environment pro premium zákazníky. User-app pody stále přistupují přes pooler (invariant sekce 6). Spustí se až když:
- talkide-infra#16 kapacitní plán identifikuje konkrétního zákazníka pro izolovaný tier, nebo
- základní shared tier přestane splňovat SLA.
Consequences
Pozitiva
- Connection bottleneck odstraněn na dvou úrovních — (a) jeden transaction pool (PgBouncer) pro N user apps (ne N×10 direct connections); (b) control-plane a data-plane mají každý svůj vlastní conn budget, nikdy nesoupeří. Při výchozích 20 runtime connections na PgBounceru: prakticky neomezený počet concurrent idle user-app podů bez dopadu na platform BE.
- Scaffold template fix systémový —
ddl-auto: none+prepareThreshold=0jednou pro vždy eliminuje startup crash přes transaction pooler (talkide-be#97). Constraint platí pro PgBouncer i DO Managed PgBouncer shodně. - Nula PgBouncer reloadů za běhu —
auth_query+ schema-per-app = statický PgBouncer config. Provisioning nové appky nevyžaduje žádný zásah do PgBouncer konfigurace ani jeho restart. - Konzistence s platform BE — obě vrstvy (platform i user apps) sdílejí transaction mód pro runtime; Liquibase jde vždy přes direct admin conn (platform na clusteru A, user apps na clusteru B).
- Invariant pooler — appka nikdy neví co je za poolerem; upgrade path na dedikovaný cluster nevyžaduje změny v aplikačním kódu.
- Eliminace session poolu pro Liquibase — Liquibase přes direct port je čistší řešení (žádný advisory lock vs. transaction pooler problém) a zároveň snižuje počet spravovaných poolerů.
- SCRAM-SHA-256 bez ústupku — žádný MD5 v data-plane; PgBouncer mainline SCRAM podpora produkčně ověřena (pgbouncer/pgbouncer#508 CLOSED od 1.14); klientský i server-side handshake plně SHA-256.
- Credential tabulka jako explicitní password store — protože pg_authid je nedostupné i pro
doadminna DO Managed PG, je vlastní tabulka přirozeným řešením; navíc dává plnou kontrolu nad životním cyklem credentialů (provisioner sám počítá, insertuje, maže); žádná závislost na interních PG katalozích.
Rizika a omezení
-
Schema izolace slabší než DB izolace. Per-app PG role +
REVOKEcross-schema snižuje riziko, ale není ekvivalentní DB-level izolaci. Mitigace:REVOKE ALL ON SCHEMA public FROM <role>,ALTER DEFAULT PRIVILEGESper schema, revize v code-review scaffoldu. Silná izolace = explicitní upgrade na dedikovaný cluster (escape hatch, sekce 7). -
Noisy neighbor na sdílené DB. Jedna výpočetně náročná query degraduje ostatní tenaty. Mitigace: transaction pooling drží idle connections nízko; plan tiering jako escape hatch. Akceptovatelné pro alpha (1–10 tenantů, manuální ops).
-
Backup a export granularita.
pg_dump -n <schema>pro per-app export (místopg_dump -d <db>).DROP SCHEMA <schema> CASCADE+DROP ROLE <role>+DELETE FROM dataplane_auth.credentialspro delete (místoDROP DATABASE). Nutno aktualizovat runbooky aDeleteProjectUseCaseteardown. -
PgBouncer self-host zátěž. Na rozdíl od DO Managed PgBouncer je PgBouncer naše zodpovědnost — deployment, monitoring, upgrade. Akceptovatelná cena za
auth_queryflexibilitu a nulový reload overhead. Alpha HA: 1 replika + liveness probe (viz §5). -
PgBouncer SPOF v alpha. 1 replika = výpadek podu způsobí HikariCP retry window (řádově sekundy do K8s re-schedule). Vědomé riziko pro alpha fázi — mitigace: škálování na N replik je triviální (PgBouncer je stateless s auth_query, viz §5).
-
Connection-budget invariant. PgBouncer pooluje per-(database, user) — každá aktivní per-app role má vlastní pool. Platný invariant:
repliky × Σ(aktivní per-app pooly) ≤ ~25 (DO Basic budget), horní mezrepliky × počet_souběžně_aktivních_per-app_rolí × default_pool_size. Mitigace:default_pool_sizeper role 2–3,min_pool_size = 0,server_idle_timeoutnízký — idle app drží 0 server-side konekcí. Alpha (1–3 tenanti, sporadický provoz) → součet aktivních poolů reálně nízký; hlídat monitoringem (SHOW POOLS). Škálování tenantů = trigger pro vyšší DO DB tier, ne automatika.
Refactor delta pro Stopu B (implementační dopady)
| Komponenta | Změna |
|---|---|
PostgresDatabaseProvisioner | CREATE DATABASE + USER → CREATE SCHEMA + ROLE + ALTER ROLE SET search_path + REVOKE + výpočet SCRAM verifieru v Kotlinu + INSERT INTO dataplane_auth.credentials na clusteru B (direct admin conn 25060) |
K8s Secret app-{slug}-{env}-db | SPRING_DATASOURCE_URL míří na PgBouncer K8s Service (port 5432), ne na direct port; přidán prepareThreshold=0 |
DeleteProjectUseCase teardown | DROP DATABASE + DROP USER → DROP SCHEMA <schema> CASCADE + DROP ROLE + DELETE FROM dataplane_auth.credentials |
| Scaffold template | ddl-auto: validate → none; prepareThreshold=0 v JDBC URL; Liquibase defaultSchemaName + liquibaseSchemaName |
| Infra | Provisioning nového DO Managed PG clusteru talkide-dataplane-pg (talkide-infra#18, 3/5 hotovo) + PgBouncer (mainline) deployment v talkide-infra (Helm/manifesty, auth_type=scram-sha-256, auth_query, dataplane_auth schema+tabulka+fn, dataplane_authenticator role, monitoring — talkide-infra#19) + verifikace se syntetickými per-app rolemi PŘED Stopa B test kolečkem |
| K8s namespace (compute) | Beze změny — ADR-015 platí, namespace per tenant-environment je separátní workstream |
Alternatives Considered
| Alternativa | Důvod odmítnutí |
|---|---|
| Upgrade DO PG node RAM | Oddálí strop, ale neřeší architekturu; drahé, neadresuje ani PgBouncer bypass |
| Per-app PgBouncer/pooler instance | „100 appek = 100 poolerů” — provozní peklo, dynamický reload config, nepřiměřená složitost |
| Per-app DB s wildcard DO Managed PgBouncer | Dynamický pooler config + reload-on-provision coupling, křehké; DO Managed PgBouncer to neumí (ověřeno z DO docs) — pool-per-app stropne na 21 poolech/cluster |
| DO Managed PgBouncer pro data-plane (sdílený pool) | Technicky neproveditelné: DO Managed PgBouncer neumí auth_query ani per-role passthrough (ověřeno z DO offic. dokumentace). Jeden pool = jedna (DB, user) kombinace. Per-app role + sdílený pool = vzájemně se vylučující požadavky v DO Managed implementaci |
| pgcat (self-host) | Odmítnut v Revizi 3 ze dvou nezávislých blockerů: (1) pg_authid.rolpassword je superuser-only — DO nedává superuser ani doadmin, auth_query nemůže číst nativní PG password store (DO docs); (2) pgcat auth_query akceptuje pouze MD5 hash (prefix md5 v src/auth_passthrough.rs) — SCRAM-SHA-256 klient→pgcat není implementováno (postgresml/pgcat#255 OPEN, postgresml/pgcat#624 OPEN, maintainer 10/2025: „work hasn’t started”). Použití pgcat by vynutil deprecated MD5 napříč data-plane |
| Supavisor (self-host) | Zvážen, odmítnut. Zvládá SCRAM + auth_query, ale: Elixir/BEAM runtime je ~300–500 MB (vs. PgBouncer ~32–64 MB) — resource overhead nepřiměřený alpha škále; nulová týmová zkušenost s Elixir = provozní riziko; mladší projekt s méně provozními zkušenostmi v komunitě. Výhody (multi-thread, sharding) nepotřebné dokud je TalkIDE pod 50 tenantů. Dokumentovaný escape hatch kdyby single-core PgBouncer v budoucnu nestačil propustností |
| Odyssey místo PgBouncer | Produktově aktivněji udržovaný (Yandex), SCRAM podporu má. Odmítnut jako výchozí: méně dokumentace a komunitních zkušeností pro K8s self-host nasazení; PgBouncer je de-facto standard s ověřenou stabilitou. Dokumentovaná záloha jako druhou nejlépe ozkoušenou alternativu, pokud PgBouncer mainline narazí na bloker |
PgBouncer (mainline) je ZVOLENÉ řešení pro data-plane, nikoliv “budoucí escape hatch”. Přechod z pgcat je motivován zdrojákově ověřenými blockery (viz §2.1 a Alternatives výše). Architektonický koncept (auth_query + low-priv authenticator + statický pool config + nula reloadů) zůstává identický — mění se jen binárka a config syntax.
Implementation Notes
- Blokuje: produkční retest Stopy B (talkide-be#97, conn limit crash loop).
- Nahrazuje: ADR-016 sekce 1–4 (naming, env strategie, K8s Secret formát, wrapper architektura); ostatní sekce ADR-016 (idempotence, error handling, test strategie, wrapper pattern) zůstávají v platnosti — aplikují se na schema provisioning místo DB provisioning.
- Navazuje na: ADR-015 (namespace provisioning), talkide-be#96 (platform BE PgBouncer fix).
- Related issues: talkide-be#97 (ddl-auto: validate crash), talkide-be#109 (provisioner SCRAM
verifier + credential tabulka), talkide-infra#16 (capacity plan), talkide-infra#18 (provisioning
nového data-plane clusteru + DB
talkide_dataplane— prerekvizita Stopy B kroku 1), talkide-infra#19 (~70 % recykluje z pgcat plánu: K8s Deployment/Service/probe topologie, namespace, monitoring koncept, low-priv authenticator role, SECURITY DEFINER fn pattern, Liquibase-mimo-pooler, verifikační plán; ~30 % přepsat: image →bitnami/pgbouncerneboedoburu/pgbouncer,pgcat.toml→pgbouncer.ini, fn vrací SCRAM verifier místo md5 prefixu, credential tabulka schemadataplane_auth). - Provisioner user
talkide_provisionerexistuje na obou clusterech zvlášť — cluster A (control-plane) i cluster B (data-plane); na clusteru B musí mít:CREATEROLE,CREATE SCHEMA,DROP SCHEMA,DROP ROLEvtalkide_dataplane+USAGE ON SCHEMA dataplane_auth+INSERT,DELETEnadataplane_auth.credentials. dataplane_authenticatorrole na clusteru B: low-priv, pouze LOGIN + EXECUTE nadataplane_auth.dataplane_get_auth(text)funkci. Zakládá se jako jednorázový bootstrap krok (doadmin) — ne provisioner za běhu. Heslo do K8s Secret pro PgBouncerauth_user.- Lokální dev:
NoopDatabaseProvisionerzůstává; lokální Postgres17 není dotčen. PgBouncer se lokálně NEspouští — lokální dev jde přes direct JDBC na lokální PG. - Revize 1 (2026-05-16): data-plane vyčleněn na samostatný DO PG cluster
talkide-dataplane-pg(viz §4); původní formulace ADR implikovala dvě DB na jednom clusteru — opraveno. - Revize 2 (2026-05-16): data-plane pooler změněn z DO Managed PgBouncer na self-host pgcat
s
auth_query— DO Managed PgBouncer neumí per-role passthrough (ověřeno z DO offic. dokumentace); control-plane DO Managed PgBouncer beze změny; zrušenytalkide-dataplane-txatalkide-dataplane-sessionDO pooly; přidány sekce §2.1 (auth_query), §2.2 (Liquibase direct), §5 (pgcat HA posture).§2.1 Revize 2 je supersedována Revizí 3 (pgcat/pg_authid blockery). - Revize 3 (2026-05-16): data-plane pooler změněn z pgcat na self-host PgBouncer (mainline);
§2.1 kompletně přepsána (pgcat MD5-only + pg_authid nečitelné → PgBouncer SCRAM + vlastní
credential tabulka
dataplane_auth.credentials); přidána §2.3 (SCRAM verifier tok provisioner); talkide-infra#19 refactor delta upřesněna (~70/30 split); Alternatives doplněny o pgcat odmítnutí (src ref), Supavisor (zvážen/odmítnut, escape hatch), Odyssey (záloha); Consequences doplněny o connection-budget invariant a SCRAM positivum; §4 a §5 aktualizovány na PgBouncer terminologii.
Thanks for the feedback.