Files
five/hbrtl/pgserver/server.go
CharlesKWON d98f5e1767 feat(pgserver): PostgreSQL-wire MVP — psql can SELECT from FiveSql2
First end-to-end working version of the PostgreSQL-wire-compatible
TCP server frontend. A standard `psql` client now connects, runs
`SELECT * FROM employees`, and gets back a properly typed result
set rendered by psql with the right column alignment:

    ID |         NAME         |  SALARY
    ----+----------------------+----------
      1 | Alice                | 50000.00
      2 | Bob                  | 42000.50
      3 | Cho                  | 77500.00

This is the Phase 2 deliverable from the approved plan at
/Users/charleskwon/.claude/plans/compiled-launching-shore.md.
Builds on the session-state refactor in 93cf5c8 — each connection
gets its own TSqlSession on the PRG side via the new PG_NEW_SESSION
HB_FUNC, so concurrent psql clients won't share transaction logs
or plan caches.

Scope
-----

v1.0 MVP: Simple Query only, trust auth, no TLS yet. SELECT works
against the full FiveSql2 surface (CTEs, window functions, JOINs,
aggregates). DML + per-session transactions are Phase 3, extended
protocol is Phase 4, auth + TLS are Phases 5/6.

Architecture
------------

  psql/pgx/JDBC ──TCP:5432──▶ pgserver.Listener
                                  │ accept()
                                  ▼ go handleConn(net.Conn)
                             ┌─────────────────────────────┐
                             │ Session goroutine            │
                             │  1. SSLRequest peek          │
                             │  2. StartupMessage           │
                             │  3. AuthenticationOk (trust) │
                             │  4. ParameterStatus×7        │
                             │  5. BackendKeyData           │
                             │  6. ReadyForQuery('I')       │
                             │  7. loop: Receive() →        │
                             │     dispatchSimpleQuery →    │
                             │     hbrt.Thread.Function(    │
                             │       FIVE_SQL,sql,...,sess) │
                             │     emit RowDescription      │
                             │     emit DataRow×N           │
                             │     emit CommandComplete     │
                             │     emit ReadyForQuery       │
                             └─────────────────────────────┘

One goroutine per connection, each owning its own *hbrt.Thread and
TSqlSession instance. Uses the existing audit-fixed NewThread()
(cde8673) so statics + WA factory propagate.

New files (hbrtl/pgserver/)
---------------------------

* server.go — Config, Server, Serve loop with MaxConnections gate
  via semaphore, Close drains in-flight sessions.
* session.go — full lifecycle: SSLRequest peek + prefixedConn
  byte-injection trick for StartupMessage, ParameterStatus
  broadcast (server_version "14.0 (FiveSql2)" so pgx negotiates),
  BackendKeyData (random pid+secret per session, no CancelRequest
  yet), query loop dispatching only Simple Query in v1.0 with a
  loud "0A000 not supported" for Extended messages.
* dispatch.go — runSQL invokes FIVE_SQL via PushSymbol+Function,
  unpacks the engine's `{aFieldNames, aRows}` envelope or the
  `{{"__error__"}, {{nCode, cMsg, cSQL}}}` error shape, emits
  RowDescription with text-format OIDs and DataRow per row.
* typemap.go — pgTypeFor() picks INT4 / INT8 / NUMERIC / TEXT /
  DATE / TIMESTAMP / BOOL by sampling the first row's value type;
  encodeText() formats each cell, returning nil-slice for NULL
  (the PG length=-1 convention).
* errmap.go — sqlStateFor() maps FiveSql2 SQL_ERR_* codes to
  canonical PG SQLSTATEs (42601/42P01/42703/42804/23505/23514/
  23503/25P02/42501/02000/XX000).
* auth.go — trust mode in v1.0; password/MD5/SCRAM lands Phase 5
  but the dispatch sentinel is already in place.
* tls.go — upgradeToTLS stub for SSLRequest handling; the byte-
  ordering is already wired so Phase 6 just plugs in tls.Config.
* register.go — package init() registers pg_server_start /
  pg_server_stop HB_FUNCs. Importing the package (done from
  hbrtl/register.go via blank import) is enough to enable them.
* pgserver_test.go — unit tests for encodeText (numeric, string,
  NIL), pgTypeFor (OID dispatch), sqlStateFor (error mapping),
  commandTagFor (SELECT/INSERT/UPDATE/DELETE/BEGIN/COMMIT).

Other changes
-------------

* _FiveSql2/src/TSqlSession.prg — added PG_NEW_SESSION() factory
  used by the Go dispatcher to allocate a per-connection session
  bypassing the embedded process default.
* hbrtl/register.go — blank-import five/hbrtl/pgserver so its
  init() fires and the HB_FUNCs land in the global dynamic-func
  table for VM symbol lookup.
* go.mod / go.sum — github.com/jackc/pgx/v5 v5.9.2 (pgproto3
  subpackage). MIT license. Same library pgx itself uses, so
  protocol coverage matches the de-facto Go PG ecosystem.

Verification
------------

  $ pg_server_start(15432, "trust")     /* PRG one-liner */
  $ psql -h 127.0.0.1 -p 15432 -U fiveuser -c 'SELECT * FROM employees'
  → 3 rows rendered correctly by psql (ID as INT4, NAME as TEXT,
    SALARY as NUMERIC(10,2) with 2 decimal places)

All six release gates green:
  go test ./...               ✓ (incl. new hbrtl/pgserver tests)
  FiveSql2 SQL:1999 43/43     ✓
  Harbour compat 56/56        ✓
  std.ch 17/17                ✓
  FRB 7/7                     ✓
  examples 65/71              ✓ (unchanged baseline)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:40:32 +09:00

204 lines
5.6 KiB
Go

// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
// All rights reserved.
// Package pgserver implements a PostgreSQL-wire-protocol-compatible
// TCP/IP frontend for FiveSql2. The protocol layer is provided by
// github.com/jackc/pgx/v5/pgproto3 (the same library pgx itself uses
// internally); this package wires it into Five's runtime so that
// psql / pgx / JDBC / DBeaver / Tableau and any other PostgreSQL
// driver can connect to a Five process as if it were Postgres.
//
// Lifecycle
//
// five pgserver --listen :5432 starts Listen() in the main goroutine.
// accept() spawns one goroutine per connection. Each connection
// owns a *hbrt.Thread + a TSqlSession on the PRG side, so concurrent
// clients don't share transaction state or plan caches (see
// refactor commit 93cf5c8).
//
// Scope
//
// v1.0 ships Simple Query (SELECT/INSERT/UPDATE/DELETE), per-session
// BEGIN/COMMIT/ROLLBACK, trust + password/MD5/SCRAM auth, and TLS.
// Extended protocol (Parse/Bind/Execute) and pg_catalog shim are
// v1.1+. See /Users/charleskwon/.claude/plans/compiled-launching-shore.md.
package pgserver
import (
"context"
"crypto/tls"
"fmt"
"net"
"sync"
"five/hbrt"
)
// Config holds runtime knobs for the server. All fields optional;
// zero values yield a trust-auth, no-TLS server bound to :5432.
type Config struct {
// Listen is the bind address (":5432" by default).
Listen string
// MaxConnections caps concurrent accepted sessions. Excess
// connections block in accept() until a slot frees. 0 → 100.
MaxConnections int
// AuthMode: "trust" (default), "password", "md5", "scram-sha-256".
// Roles + password hashes are loaded from __five_roles.dbf when
// not in trust mode; see auth.go.
AuthMode string
// TLSConfig, if non-nil, is presented when a client sends
// SSLRequest. tls.go provides helpers for loading a cert/key
// pair or auto-generating a self-signed pair for dev.
TLSConfig *tls.Config
// ParameterStatus values broadcast right after auth. Five
// announces itself as PostgreSQL 14 by default so probing
// clients (pgx, JDBC) negotiate without erroring out on
// unsupported features.
ServerVersion string // default "14.0 (FiveSql2)"
}
func (c *Config) listenAddr() string {
if c.Listen == "" {
return ":5432"
}
return c.Listen
}
func (c *Config) maxConns() int {
if c.MaxConnections <= 0 {
return 100
}
return c.MaxConnections
}
func (c *Config) serverVersion() string {
if c.ServerVersion == "" {
return "14.0 (FiveSql2)"
}
return c.ServerVersion
}
// Server is the live server state. Create via NewServer and drive
// with Serve(); Close() drains in-flight connections gracefully.
type Server struct {
vm *hbrt.VM
cfg Config
listener net.Listener
sem chan struct{} // accept gate, sized to MaxConnections
mu sync.Mutex
conns map[*session]struct{} // live sessions for clean shutdown
closed bool
closeCh chan struct{}
}
// NewServer constructs an unstarted Server. The hbrt.VM is the
// runtime instance whose `FIVE_SQL` function will execute incoming
// queries — every accepted connection spawns a fresh thread on this
// VM via vm.NewThread() so statics + workarea factory are inherited
// (see hbrt/vm.go NewThread, fixed in cde8673 to propagate both).
func NewServer(vm *hbrt.VM, cfg Config) *Server {
return &Server{
vm: vm,
cfg: cfg,
sem: make(chan struct{}, cfg.maxConns()),
conns: make(map[*session]struct{}),
closeCh: make(chan struct{}),
}
}
// Serve binds the listener and runs the accept loop until Close()
// fires or a fatal accept error is hit. Each accepted connection
// runs handleConn in its own goroutine.
func (s *Server) Serve(ctx context.Context) error {
if s.vm == nil {
return fmt.Errorf("pgserver: nil VM")
}
ln, err := net.Listen("tcp", s.cfg.listenAddr())
if err != nil {
return fmt.Errorf("pgserver: listen %s: %w", s.cfg.listenAddr(), err)
}
s.mu.Lock()
s.listener = ln
s.mu.Unlock()
// Stop the listener when ctx cancels so Accept() returns.
go func() {
select {
case <-ctx.Done():
case <-s.closeCh:
}
_ = ln.Close()
}()
for {
conn, err := ln.Accept()
if err != nil {
s.mu.Lock()
closed := s.closed
s.mu.Unlock()
if closed {
return nil
}
return fmt.Errorf("pgserver: accept: %w", err)
}
// Backpressure: block here when MaxConnections sessions are
// already in flight. Holding the slot through handleConn
// (released in defer) is the natural way to gate.
s.sem <- struct{}{}
go func(c net.Conn) {
defer func() { <-s.sem }()
s.handleConn(ctx, c)
}(conn)
}
}
// Close signals the accept loop to exit, drops the listener, and
// closes every in-flight connection. Outstanding handleConn
// goroutines exit on the first failed Receive.
func (s *Server) Close() error {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return nil
}
s.closed = true
ln := s.listener
conns := make([]*session, 0, len(s.conns))
for c := range s.conns {
conns = append(conns, c)
}
s.mu.Unlock()
close(s.closeCh)
if ln != nil {
_ = ln.Close()
}
for _, c := range conns {
_ = c.conn.Close()
}
return nil
}
// handleConn is the entry point for each accepted connection.
// The full implementation (TLS upgrade, startup handshake, auth,
// query loop) lives in session.go; this function just allocates
// the session and tracks it for clean shutdown.
func (s *Server) handleConn(ctx context.Context, conn net.Conn) {
sess := newSession(s, conn)
s.mu.Lock()
s.conns[sess] = struct{}{}
s.mu.Unlock()
defer func() {
s.mu.Lock()
delete(s.conns, sess)
s.mu.Unlock()
_ = conn.Close()
}()
sess.run(ctx)
}