Internal Documentation internal
TalkIDE internal documentation

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/rolpassword je na DO Managed PostgreSQL nečitelné i pro doadmin (DO nedává superuser) → auth_query NEMŮŽE číst nativní PG password store; (2) pgcat auth_query je MD5-only — src/auth_passthrough.rs vyžaduje prefix md5, 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_query proti SECURITY DEFINER funkci (pgbouncer/pgbouncer#508 CLOSED). Zavedena vlastní credential tabulka dataplane_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-pg beze změny. Zrušeny pooly talkide-dataplane-tx a talkide-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):

ZdrojConnections (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ů:

  1. 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é.
  2. 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-level SET search_path. PgBouncer v transaction módu nevynuluje role atributy mezi transakcemi — žádný currentSchema trick, žá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 + liquibaseSchemaName nastaveny na tk_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, env dev → schema + role tk_t1_pdemo_dev
  • tenant 42, projekt my-awesome-saas, env prod → schema + role tk_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 / endpointModePG connectionsÚčel
PgBouncer (port 5432, K8s Service)transactionkonfigurovatelný (výchozí 20)Runtime provoz user-app podů (Hibernate / HikariCP)
Direct port 25060 clusteru BadminLiquibase 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_size per 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 po server_idle_timeout se automaticky zavře.
  • server_idle_timeout nastavit 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í):

  1. CREATE SCHEMA dataplane_auth
  2. CREATE TABLE dataplane_auth.credentials (...) (viz DDL výše)
  3. CREATE OR REPLACE FUNCTION dataplane_auth.dataplane_get_auth(...) (viz DDL výše)
  4. REVOKE ALL ON SCHEMA public FROM PUBLIC
  5. GRANT USAGE ON SCHEMA dataplane_auth TO dataplane_authenticatorbez USAGE na schématu funkce není volatelná i přes EXECUTE grant (permission denied for schema dataplane_auth)
  6. GRANT EXECUTE ON FUNCTION dataplane_auth.dataplane_get_auth(TEXT) TO dataplane_authenticator
  7. ALTER ROLE talkide_provisioner CREATEROLE — nutné, jinak runtime per-app CREATE ROLE padne
  8. GRANT USAGE ON SCHEMA dataplane_auth TO talkide_provisionerbez USAGE na schématu provisioner dostane permission denied for schema dataplane_auth i při platném DML grantu
  9. GRANT 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 ROLE atd. — 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:

  1. Vygeneruj kryptograficky náhodné plaintext heslo.
  2. Spočítej SCRAM-SHA-256 verifier v Kotlinu: random salt (≥ 16 bytes), iterace ≥ 4096 → verifier string ve formátu SCRAM-SHA-256$<iter>:<base64-salt>$<base64-storedKey>:<base64-serverKey>.
  3. 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ě.
  4. INSERT INTO dataplane_auth.credentials(rolname, secret) VALUES ('<app-role>', '<verifier>').
  5. 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_dataplane se schema-per-app.
  • Pooler: self-host PgBouncer (mainline) v K8s (transaction mód, auth_type = scram-sha-256, auth_query přes dataplane_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=0 jednou 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ěhuauth_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 doadmin na 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í

  1. Schema izolace slabší než DB izolace. Per-app PG role + REVOKE cross-schema snižuje riziko, ale není ekvivalentní DB-level izolaci. Mitigace: REVOKE ALL ON SCHEMA public FROM <role>, ALTER DEFAULT PRIVILEGES per schema, revize v code-review scaffoldu. Silná izolace = explicitní upgrade na dedikovaný cluster (escape hatch, sekce 7).

  2. 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).

  3. Backup a export granularita. pg_dump -n <schema> pro per-app export (místo pg_dump -d <db>). DROP SCHEMA <schema> CASCADE + DROP ROLE <role> + DELETE FROM dataplane_auth.credentials pro delete (místo DROP DATABASE). Nutno aktualizovat runbooky a DeleteProjectUseCase teardown.

  4. 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_query flexibilitu a nulový reload overhead. Alpha HA: 1 replika + liveness probe (viz §5).

  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).

  6. 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í mez repliky × počet_souběžně_aktivních_per-app_rolí × default_pool_size. Mitigace: default_pool_size per role 2–3, min_pool_size = 0, server_idle_timeout ní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)

KomponentaZměna
PostgresDatabaseProvisionerCREATE DATABASE + USERCREATE 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}-dbSPRING_DATASOURCE_URL míří na PgBouncer K8s Service (port 5432), ne na direct port; přidán prepareThreshold=0
DeleteProjectUseCase teardownDROP DATABASE + DROP USERDROP SCHEMA <schema> CASCADE + DROP ROLE + DELETE FROM dataplane_auth.credentials
Scaffold templateddl-auto: validate → none; prepareThreshold=0 v JDBC URL; Liquibase defaultSchemaName + liquibaseSchemaName
InfraProvisioning 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

AlternativaDůvod odmítnutí
Upgrade DO PG node RAMOddá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 PgBouncerDynamický 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 PgBouncerProduktově 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/pgbouncer nebo edoburu/pgbouncer, pgcat.tomlpgbouncer.ini, fn vrací SCRAM verifier místo md5 prefixu, credential tabulka schema dataplane_auth).
  • Provisioner user talkide_provisioner existuje 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 ROLE v talkide_dataplane + USAGE ON SCHEMA dataplane_auth + INSERT, DELETE na dataplane_auth.credentials.
  • dataplane_authenticator role na clusteru B: low-priv, pouze LOGIN + EXECUTE na dataplane_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 PgBouncer auth_user.
  • Lokální dev: NoopDatabaseProvisioner zů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šeny talkide-dataplane-tx a talkide-dataplane-session DO 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.

Was this page helpful?

Thanks for the feedback.