KumoDB, Session 1: I Built a TCP Server That Can Read a Postgres Startup Message
I know SQL. I know enough system design to draw boxes. I do not know production Go yet.
So I started KumoDB: a PostgreSQL-compatible proxy that will eventually sit in front of multiple Postgres instances — connection pooling, query routing, sharding. The long-term inspiration is PlanetScale's Neki: a fleet of routers that speak the Postgres wire protocol to the app, plan which shards run a query, and make many servers look like one database.
This post is only session 1. No sharding. No pool. No database/sql. By the end of the day I had a process that accepts TCP, refuses TLS the way psql expects, reads a length-capped StartupMessage, and logs user / database. That sounds small. It was not.
The rule I used: if I cannot explain the concurrency and the failure mode without looking at the code, it is not done — even if go test is green.
What KumoDB is (and is not)#
Neki is not a transparent TCP forwarder and it is not "PgBouncer plus hashing."
A Neki router:
- Speaks the Postgres protocol to clients (so drivers and
psqlkeep working) - Parses SQL and builds a routing plan from a JSON data topology
- Sends work to shard sidecars; Postgres still plans how each shard runs
- Exists because Postgres is process-per-connection — thousands of clients would otherwise mean thousands of backends
KumoDB will earn those ideas. Session 1 does not copy Neki's gRPC sidecars, parser, or control plane. If I had started with internal/shardmanager, I would have been drawing architecture, not learning sockets.
psql / app
│ TCP, Postgres wire
▼
KumoDB (:15432 on localhost)
│ not connected yet
▼
Postgres 17 in Docker (host :5433 → container :5432)
The right-hand arrow is Compose only. Go does not open a database driver. On purpose.
The Go I did not have#
I went in as a beginner. A few things I got wrong immediately, because they show up in this codebase:
Slices share a backing array. b := a[:2]; append(b, 9) can mutate a if there is leftover capacity. append only allocates a new array when len+needed > cap. Buffering TCP reads will hit this.
Channels have an owner. Only the sender closes. Close once. Send on closed = panic. Receive on closed = zero value. Nobody closes + range = hang forever.
A typed nil is not a nil interface. var err *os.PathError; return err makes err != nil true. Return nil literally.
main returning kills the process. listener.Close() unblocks Accept. It does not close accepted connections or wait for go handle(conn).
I am not going to pretend I already knew the Postgres wire format. I learned it by implementing eight bytes at a time.
Milestone 0 — a process that can die cleanly#
0.1 The binary#
Layout follows Organizing a Go module: one command, no pkg/, no framework.
cmd/kumodb/main.go
cmd/kumodb/main_test.go
go.mod // module github.com/Avik-creator/kumodb
The first useful program is not "hello world." It is: log start, block until SIGINT/SIGTERM, log shutdown, exit 0.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
<-ctx.Done()WHAT: signal.NotifyContext cancels a context when the OS sends a signal.
HOW: <-ctx.Done() parks main.
WHY: a server is a process that has not returned yet.
TRADEOFF: stop() unregisters the signal handler. Skipping it looks fine because the process is about to exit. It is a leak if you ever create many of these in a long-lived program.
I commented out <-ctx.Done() to see what happened. The process printed start and shutdown in the same millisecond. That is not a server. I left it commented out. That is a regression, not an experiment.
0.2 Tests that do not press Ctrl+C#
CI cannot send SIGINT. So main stays thin: flags, logger, signals. run(ctx, log) is what tests call.
Two tests:
- Context already canceled →
runreturns immediately,err == nil. Weak: an emptyreturn nilalso passes. - Start
runin a goroutine, assert it has not returned after 50ms, thencancel(), assert it does return. That firstselectis what proves this is a wait loop.
go test -race from the start. Shutdown is not a failure: run returns nil after cancel. context.Canceled is logged as reason, not os.Exit(1).
0.3 Postgres in Docker — do not connect#
Compose is a dependency, not architecture. Image postgres:17, healthcheck pg_isready, credentials local-only.
Two failures that were not Go:
Port 5432 was already taken. Another stack on the machine already owned the host port. I did not kill it. Host mapping became 5433:5432. Inside the container Postgres still listens on 5432. docker compose exec does not use the published port.
Postgres 18 Docker images changed PGDATA. I first pulled postgres:18 and mounted /var/lib/postgresql/data — the 17-era path. The 18 image stores data at /var/lib/postgresql/18/docker and refuses to start if it sees the old mount. Logs look like a lecture about pg_ctlcluster. Fix for session 1: pin 17, wipe only KumoDB's volume (docker compose down -v), up -d --wait. 18 is a later problem.
SELECT 1 via compose exec worked. The Go process still does not dial it.
Milestone 1 — TCP#
Default listen address: 127.0.0.1:15432. Not 5432 (already taken). Not 5433 (Compose).
run(addr) → net.Listen → serve(ln)
Tests that need a port call serve with net.Listen("tcp", "127.0.0.1:0") and ln.Addr(). Production default is for go run. Tests must not fight over 15432.
The Accept loop#
defer ln.Close()
go func() {
<-ctx.Done()
ln.Close() // unblocks Accept
}()
var wg sync.WaitGroup
for {
conn, err := ln.Accept()
if err != nil {
if ctx.Err() != nil {
break // shutdown, not a process failure
}
return err
}
wg.Add(1)
go func(c net.Conn) {
defer wg.Done()
handleConn(ctx, log, c)
}(conn)
}
wg.Wait()
return nilPass conn in as c. Closing over the loop variable is a classic bug: the next Accept overwrites conn and every handler shares the last socket.
Add before go. Add inside the goroutine races with Wait.
break, then Wait, then return nil. I first return ctx.Err() from inside the loop. That skips WaitGroup, treats shutdown as failure, and leaves the shutdown log unreachable. break means stop taking new clients. It does not close existing sockets.
Closing an in-flight Read#
Closing the listener does not unblock Read on connections you already accepted. Those stay in io.Copy until the peer leaves — Ctrl+C would hang.
Each handler:
ctx, cancel := context.WithCancel(ctx)
defer cancel()
defer conn.Close()
go func() {
<-ctx.Done()
conn.Close()
}()The child cancel on return so the closer goroutine does not wait on process shutdown after the client already left. Close twice is fine.
Echo, then delete it#
io.Copy(conn, conn) is a mirror. A test writes ping\n and io.ReadFulls the same bytes. Read can return short; ReadFull is the right tool for a known size.
Echo is not a proxy. A proxy is two sockets and two copies. I deleted echo the moment I needed the byte stream for Postgres. Leaving Copy in front of ReadFull eats the startup message until EOF — then there is nothing to parse. I left it in by accident. Tests for parse still passed because they never hit the wire. That is why a serve test that writes a header matters.
Milestone 2 — the wire, eight bytes at a time#
Docs: Protocol overview, Message formats.
Regular messages: 1-byte type, 4-byte big-endian length (includes the length, excludes the type). StartupMessage and SSLRequest have no type byte. First client packet is 8 bytes: uint32 length (includes itself) + uint32 version or magic.
Protocol 3.0 is 196608 (3<<16 | 0).
TCP is a stream. Read can return 3 bytes of an 8-byte header. io.ReadFull loops until the buffer is full or an error.
If the client sends 3 bytes and stays connected, ReadFull waits forever. SIGINT already Closes the conn. A stuck client while the server is otherwise healthy does not cancel ctx. That is conn.SetReadDeadline. There is no IoReadDeadline — io has no clock. Deadlines live on net.Conn.
length = binary.BigEndian.Uint32(b[0:4])
version = binary.BigEndian.Uint32(b[4:8])Little-endian here is silent garbage. Check ReadFull's error before parse. An 8-byte buffer always has len == 8; parse would succeed on zeros if you overwrite err.
SSLRequest#
psql almost always asks "TLS?" first. Same 8-byte layout: length 8, code 80877103.
The answer is one byte: 'S' (TLS follows) or 'N' (cleartext continues). Not a length-prefixed packet. I wrote 16 invented bytes that started with a length and ASCII PSQL. That is not in the protocol. Then I returned, which drops the connection before StartupMessage.
Correct path:
- Parse first header
- If SSLRequest:
SetWriteDeadline,Write([]byte{'N'}), refreshSetReadDeadline ReadFullthe next 8 bytes as startup- Stay in
handleConn
SetWriteDeadline exists because 'N' can sit in the send buffer until the client reads. SetReadDeadline does not unblock Write.
A unit test that only parses 80877103 does not prove you wrote 'N'. The test that matters: Dial, write SSLRequest, ReadFull 1 byte, assert 'N', write a real startup header.
Length caps#
The length field says how big the whole message is. Body after the 8-byte header is length-8.
If you make([]byte, length) when the client lies with 4e9, you try to allocate until the process dies. Validate first:
if length < 8 { return error }
if length > 10_000 { return error } // learning cap, not a spec
return int(length) - 8, nillength == 8 means zero body. Legal. ReadFull of 0 bytes is a no-op.
Do not log string(body). Later that is passwords. Log the count, then parsed user / database only.
Startup parameters#
After the header, the body is C strings:
user \0 avik \0 database \0 kumodb \0 \0
Pairs of key NUL value NUL. An empty key (extra NUL) ends the list.
strings.Split on \0 invents extra empty fields. Walk with bytes.IndexByte.
Two different len == 0 cases:
| When | Meaning |
|---|---|
len(body)==0 before the loop | No param bytes (length==8). Empty map. Success. |
len(rest)==0 inside the loop | We already ate a pair and ran out. Missing terminator. Error. |
user\0avik\0 is the second case. nil body is the first. I mixed them; TestParseStartupParamsEmpty failed with missing terminator. Guard empty body before the loop. Keep the in-loop check.
rest[0]==0 with len==1 is the terminator. Extra bytes after that: trailing garbage.
Why not database/sql#
database/sql (and pgx as a driver) hide this from an application. Your Go code calls Query. The driver speaks Startup, SSL, auth.
KumoDB is the server psql connects to. psql does not import database/sql. If I used sql.Open on the accept path, I would not be a Postgres-compatible endpoint.
Later, the backend hop might look like a client of :5433. A real proxy often still uses a raw net.Conn and forwards messages, because prepared statements, COPY, and session GUCs live in the protocol, not in db.Query(). Pooling in KumoDB will be Acquire/Release of those sockets — Phase 4 — not database/sql's pool.
What I shipped in this session#
cmd/kumodb/
main.go listen, serve, SSL refuse, startup parse
main_test.go shutdown, header, SSL on the wire, caps, params
docker-compose.yml postgres:17, :5433, healthcheck
Tests run with:
go test -race ./cmd/kumodb -v -count=1-count=1 because (cached) will happily hide a broken handleConn if you only changed logs.
Bugs that actually happened (not hypothetical)#
| Symptom | Cause |
|---|---|
Bind :5432 failed | Another Compose stack already published 5432 |
Container Exited (1) after looking "starting" | Postgres 18 + volume at /var/lib/postgresql/data |
exec: service not running | Raced healthcheck and the process had already died |
Shutdown test wanted nil, got context canceled | return ctx.Err() from serve |
| Parse tests green, wire dead | io.Copy(conn, conn) still above ReadFull |
ReadFull error ignored | Second := replaced err; parse of zeros succeeded |
psql would never start up | Wrote 16 garbage bytes instead of 'N'; then return |
TestParseStartupParamsEmpty | Empty body used the "truncated list" error path |
What I can explain without the file#
- A server is
mainthat has not returned. Signals cancel a context. Accept+WaitGroup+ close accepted conns on cancel.- Startup/SSLRequest are 8 big-endian bytes, no type tag.
'N'is one byte, then Startup on the same TCP conn.- Cap
lengthbeforemake. - Param list: NUL pairs, extra NUL to end; empty body ≠ missing terminator.
database/sqlis the wrong layer for the client-facing side of a proxy.
What is not done#
No AuthenticationOk. No ReadyForQuery. No query forwarding. No pool. No shards. psql still hangs or errors after startup because we close the socket.
Next session is 2.5: trust auth — write AuthenticationOk + ReadyForQuery so psql thinks it logged in. Still no database/sql. Still no dial to Docker until the frontend session is honest.
The destination is Neki-shaped. The path is one ReadFull at a time.