The databasePart 2 of 6

Scale-to-Zero-Postgres: ein Rust-Proxy, der deine Datenbank mitten im Connect aufweckt

Jede App auf der Plattform bekommt ihr eigenes Postgres, das im Leerlauf nichts kostet — das Compute skaliert auf null. Der Trick liegt darin, es beim nächsten Connect transparent aufzuwecken: ein Rust-Proxy spricht das Postgres-Wire-Protokoll, parkt den Client, skaliert ein K8s-Deployment 0→1 und leitet die Query weiter, sobald die Datenbank läuft. Hier kommen das Wire-Parsing, das Single-Flight-Wakeup und die tokio-Notify-Registrierungsdisziplin, die das Wakeup unter Nebenläufigkeit korrekt hält.

Scale-to-Zero-Postgres: eine Datenbank mitten im Connect aufwecken

Das Versprechen ist simpel: Jede App, die du baust, bekommt ihr eigenes echtes Postgres, und es kostet nichts, solange es niemand benutzt. In einer Welt, in der die meisten Apps halbfertige Experimente sind oder still auf ihren ersten Nutzer warten, kannst du nicht für leerlaufendes Datenbank-Compute bezahlen — davon gibt es einfach zu viel.

„Scale-to-Zero“ ist leicht gesagt und mühsam umgesetzt, denn in dem Moment, in dem du das Compute entfernst, hast du den Vertrag gebrochen, auf den jeder Postgres-Client baut: connecte und es antwortet. Also haben wir einen kleinen Rust-Service davorgesetzt — neon-conn-proxy —, der die Lücke unsichtbar macht. Er spricht das Postgres-Wire-Protokoll, und wenn ein Connect für eine schlafende Datenbank eintrifft, parkt er den Client, skaliert das Compute von 0 auf 1 und leitet die Query weiter, sobald Postgres läuft. Für die App ist das eine etwas langsame erste Query. Für die Rechnung ist es nichts, solange die App ruhig ist.

Das funktioniert, weil Neon Storage und Compute trennt: Der pageserver hält die Daten im Object Storage; das Compute ist ein zustandsloser Postgres-Pod, der Pages bei Bedarf liest. Das Compute auf null zu skalieren, kostet nichts — die Daten liegen im pageserver. Das Compute jedes Projekts ist ein K8s-Deployment, das wir zwischen 0 und 1 Replicas skalieren.

scale 0→1

forward (rewritten)

app / agent
conn to proj_abc12

neon-conn-proxy
(Rust, :5432)

K8s API

Deployment neon-compute
(adorable-abc12 ns)

neon-compute pod
stateless Postgres

pageserver (data)

Die interessanten Teile zerfallen in drei Anliegen innerhalb des Proxys: den TCP-/Wire-Pfad, die State Machine pro Projekt samt Single-Flight-Wakeup und das K8s-Skalieren. Gehen wir den Pfad eines Connects durch.


1. Genug vom Wire-Protokoll parsen, um zu routen

Der erste Zug eines Postgres-Clients ist die Startup-Message: ein längenpräfixierter Blob aus Key/Value-Parametern, darunter database und user. Es gibt kein TLS-Handshake, das wir per MITM mitschneiden müssten, und noch keine Auth — wir brauchen nur ein Feld, den Datenbanknamen, den wir überladen haben, damit er die Projekt-ID trägt: proj_{id}.

// handle_connection — the TCP/wire path
let first = postgres::read_startup(&mut client).await?;
match first {
    ClientFirstMessage::Startup(startup) => {
        let db_name = startup.database().unwrap_or("postgres");
        let project_id = match db_name.strip_prefix("proj_") {
            Some(id) => id.to_string(),
            None => { /* send a Postgres ErrorResponse and bail */ }
        };
        queue.ensure_ready(&project_id).await?;   // <-- may wake the compute
        // ... connect upstream, then forward
    }
    ClientFirstMessage::Cancel { .. } => Ok(()),  // cancel reqs carry no routing info
}

Zwei Wire-Protokoll-Details sind wichtig:

  • Cancel-Requests (CancelRequest) sehen aus wie Startup-Messages, tragen aber eine Backend-PID/-Secret statt Parametern. Sie enthalten keine Routing-Info, also verwerfen wir sie, statt zu raten.
  • Wir schreiben den Datenbanknamen vor dem Weiterleiten zurück auf postgres. Die App connectet zu proj_abc12; das echte Postgres kennt nur postgres. Also splicen wir nach dem Wakeup eine umgeschriebene Startup-Message auf den Upstream-Socket:
let rewritten = postgres::rewrite_database(&startup, &config.neon_db_name); // "postgres"
upstream.write_all(&rewritten).await?;

Der Proxy ist nie an der Auth beteiligt — er leitet das (umgeschriebene) Startup weiter und wird danach zum dummen Rohr. Der Client authentifiziert sich direkt gegen das Compute.


2. Single-Flight-Wakeup: eine winzige State Machine pro Projekt

Das Wakeup selbst darf nicht naiv sein. Wenn zehn Connects gleichzeitig auf eine schlafende Datenbank treffen, willst du ein Wakeup, nicht zehn Rennen um das Skalieren desselben Deployments. Jedes Projekt hat also einen Eintrag mit einer Drei-Zustands-Machine und einem tokio::sync::Notify, auf dem Nachzügler warten:

first connection (do_wake)

compute ready → notify_waiters()

wake failed → notify_waiters(err)

idle sweep (0 conns, timed out)

subsequent connections (verify + forward)

Sleeping

Waking

Running

// per-project state machine
struct ProjectEntry {
    state: Mutex<(ProjectState, Option<String>)>, // state + last wake error, one lock
    wake_notify: Notify,
    active_conns: AtomicUsize,
    queued_conns: AtomicUsize,
    last_wake_at: Mutex<Option<Instant>>,         // idle-sweep grace period
}

ensure_ready liest den Zustand und dispatcht:

  • Running → verifizieren, dass es tatsächlich läuft (ein veralteter Pod könnte uns unter den Füßen weggeräumt worden sein), dann weitermachen.
  • Sleepingwir sind der Wecker: do_wake.
  • Waking → jemand anderes weckt es gerade: wait_for_wake (auf dem Notify parken).

Der Wecker (do_wake) kippt den Zustand auf Waking, führt den Wakeup-Body aus und kippt bei Abschluss auf Running (oder mit einem Fehler zurück auf Sleeping) und ruft notify_waiters(), um alle Geparkten freizugeben. Die Zustandsübergänge leben an einer Stelle, sodass jeder Fehlerpfad — inklusive ?-Early-Returns bei fehlenden Credentials — den Eintrag sauber zurücklässt. (Eine frühere Version reichte ? einfach durch und ließ Einträge für immer in Waking hängen; das produzierte Zombie-Projekte, die nichts mehr aufwecken konnte.)

Connection-Limits werden per Compare-and-Swap durchgesetzt, damit Check-and-Increment unter Nebenläufigkeit atomar ist:

loop {
    let current = entry.active_conns.load(Acquire);
    if current >= max { return Err("too many connections".into()); }
    if entry.active_conns.compare_exchange_weak(current, current+1, AcqRel, Relaxed).is_ok() {
        break;
    }
}

3. Der Wakeup-Body: das Deployment skalieren, auf Postgres pollen

do_wake_body holt die Branch-Credentials (Timeline + Passwort) und ruft die Compute-Skalierungs-Schicht auf — alles unter einem wake_timeout (15 s):

// start_compute_inner — the K8s scaling
let state = check_state(cfg, project_id).await;
if state == ComputeState::Running {
    wait_for_ready(cfg, project_id, 30s, /*skip_k8s_check*/ true).await?; // fast path
    return Ok(());
}
let spec = build_compute_spec(cfg, tenant_id, timeline_id, password); // compute_ctl spec
update_configmap(&client, &ns, &spec_json).await?;                    // mount the spec
patch_replicas(&client, &ns, COMPUTE_DEPLOY_NAME, 1).await?;          // scale 0 → 1
wait_for_ready(cfg, project_id, 60s, /*skip_k8s_check*/ false).await?;// TCP-poll :55432

wait_for_ready macht den Cold Start auf die clevere Art: Für einen frischen Pod prüft es zuerst die K8s-Pod-Readiness (poll keinen Pod per TCP, der noch Pending ist), dann connectet es per TCP, um zu bestätigen, dass Postgres tatsächlich Verbindungen annimmt. Für ein cgroup-Thaw-Wakeup (Pod bereits Running) überspringt es den K8s-Roundtrip und geht direkt auf TCP.

Sobald es bereit ist, wird der Upstream-Socket geöffnet, und der Proxy bridged bidirektional — mit einem Idle-Timeout, damit ein verstummter Connect das Compute nicht für immer wach hält und Scale-to-Zero blockiert:

// bridge loop, simplified
tokio::time::timeout(idle_timeout, async {
    tokio::select! {
        r = client_read.read(&mut buf)   => { /* n==0 → closed; else write upstream */ }
        r = upstream_read.read(&mut buf2)=> { /* n==0 → closed; else write client   */ }
    }
}).await   // Err(_) → "connection idle timeout", close → lets the idle sweep reclaim it

Ein Hintergrund-Idle-Sweep erledigt die andere Hälfte: Jede Minute scannt er Projekte im Zustand Running mit null aktiven Verbindungen, und wenn sie länger als das Timeout untätig waren (und nicht gepinnt sind — z. B. mitten in einer Generierung), skaliert er sie zurück auf 0. Eine kurze Gnadenfrist nach dem Wakeup (last_wake_at) hält ihn davon ab, ein Compute, das gerade fertig aufgewacht ist, sofort wieder abzuwürgen.


Single-Flight-Wakeup hat eine Korrektheitsregel

Die wartende Hälfte hängt an einer subtilen Eigenschaft von tokio::sync::Notify, und es lohnt sich, hier präzise zu sein: notify_waiters() weckt nur Futures, die zum Zeitpunkt des Aufrufs bereits registriert sind.

Die naive Form also — das Notified-Future faul am .await zu erzeugen — hat eine Lücke. Zwischen dem Moment, in dem ensure_ready state == Waking liest, und dem, in dem der Waiter tatsächlich .notified() pollt, kann der Wecker notify_waiters() feuern. Ein noch nicht registrierter Waiter verpasst es und blockiert dann für den vollen wake_timeout:

// naive — the Notified future registers at the await point (too late)
let result = tokio::time::timeout(
    wake_timeout + 5s,
    entry.wake_notify.notified(),
).await;
late waiterwaker (do_wake)late waiterwaker (do_wake)naive — registers too lateensure_ready sees state=Wakingcompute ready → notify_waiters()now calls .notified() — misses the signalblocks until wake_timeout

Die korrekte Form ist das dokumentierte tokio-Idiom: das Notified-Future binden und enable() aufrufen, bevor du die Bedingung erneut prüfst, damit du zuerst ein registrierter Waiter bist und erst dann auf den Zustand schaust:

let notified = entry.wake_notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();                 // register interest NOW

{
    let guard = entry.state.lock().await;
    match guard.0 {
        ProjectState::Running  => return Ok(()),                 // already done
        ProjectState::Sleeping => return Err(/* wake error */),  // already failed
        ProjectState::Waking   => {}                              // still waking → await below
    }
}

let result = tokio::time::timeout(wake_timeout + 5s, notified).await;

Jetzt gibt es keine Lücke: Entweder sieht die erneute Zustandsprüfung, dass das Wakeup bereits fertig ist (sofortiger Return), oder wir sind ein registrierter Waiter, und notify_waiters() erreicht uns. Die Regel, schlicht formuliert: Bei Notify zuerst Interesse registrieren, dann die Bedingung prüfen, dann awaiten — vor dem Registrieren zu prüfen ist jedes Mal ein Race.

late waiterwakerlate waiterwakercorrect — register, then checkalt[already Running][still Waking]notified().enable() — registeredre-check statereturn immediatelynotify_waiters() → reaches registered waiter

Warum der Aufwand: die Ökonomie

Storage und Compute zu trennen und einen Wake-Proxy davorzustellen, stellt das Kostenmodell auf den Kopf. Ein Projekt, das niemand benutzt, kostet null Compute — der Pod ist weg, nicht im Leerlauf. Starte hundert Experimente; bezahle für die Handvoll, die Traffic bekommt. Die erste Query nach einer Ruhephase frisst einen Cold Start (einstellige Sekunden); jede Query danach geht direkt durch (der Proxy ist ein Pass-through, sobald die Bridge steht). Genau dieser Kompromiss — eine langsame erste Query für eine im Leerlauf kostenlose Datenbank — ist der, den du willst, wenn ein KI-Agent Datenbanken zu Tausenden hochfährt. (Die App-Pods über diesen Datenbanken bekommen dieselbe Behandlung durch einen anderen Mechanismus — den cgroup-v2-Freezer —, denn für einen warmen Dev-Server ist, anders als bei einem zustandslosen Compute, das billigste Wakeup das, bei dem nichts neu booten muss.)

Kompromisse

  • Cold-Start-Latenz. Der erste Connect nach dem Schlaf zahlt die Kosten für Scale-0→1 + Postgres-Boot. Das Parken des Connects versteckt sie, aber sie sind real; latenzempfindliche Pfade halten das Compute warm (oder pinnen es).
  • Der Wake-Pfad ist der Hot Path. Jeder Connect zu jedem Projekt läuft durch ensure_ready, sein Nebenläufigkeitsmodell ist also über jede Datenbank hinweg tragend — genau deshalb ist die Waiter-Registrierungsregel oben so präzise formuliert.
  • Idle-Sweep vs. langlebige Idle-Verbindungen. Eine offene, aber stumme Verbindung würde das Compute wach halten; der Per-Connection-Idle-Timeout in der Bridge ist das, was dem Sweep erlaubt, sie zurückzuholen — abgestimmt auf das Keepalive-Verhalten des Clients.

Bau auf der Plattform, die diese Beiträge beschreiben.

Beschreib deine App in einfachem Deutsch — Adorable schreibt den Code, richtet die Datenbank ein und bringt sie live.

Kostenlos loslegen