TCP Server and Protocol Design

Learning Objectives

  • • Master network protocol design principles
  • • Implement RESP (Redis Protocol) from scratch
  • • Build a production-ready TCP server in Go
  • • Handle concurrent connections efficiently
  • • Parse and validate network requests

Lesson 7.1: Network Protocol Design

Text-based vs Binary Protocols

When designing a database protocol, you must choose between text and binary approaches. Each has distinct advantages and trade-offs.

Text-based Protocol (Redis Protocol - RESP)

Example: SET user:1 alice

Text format (human-readable):
*3\r\n              # Array of 3 elements
$3\r\n              # First element: 3 bytes
SET\r\n             # Command
$6\r\n              # Second element: 6 bytes
user:1\r\n          # Key
$5\r\n              # Third element: 5 bytes
alice\r\n           # Value

Advantages:
- Human-readable for debugging
- Easy to test with telnet/nc
- Text editors can read/write
- Language-agnostic

Disadvantages:
- Larger wire size (more bytes)
- Parsing requires careful handling
- Line breaks matter

Binary Protocol (Protocol Buffers, MessagePack)

Example: SET user:1 alice

Binary format (compact):
01              # Op: SET
06              # Key length: 6
75 73 65 72 3A 31  # "user:1"
05              # Value length: 5
61 6C 69 63 65  # "alice"

Advantages:
- Compact representation
- Faster parsing
- Type safety
- Smaller wire size

Disadvantages:
- Not human-readable
- Requires code generation
- Harder to debug

Decision for our course: Use RESP (Redis Serialization Protocol)

  • • Text-based (easier debugging)
  • • Well-defined standard
  • • Widely supported in clients
  • • Simpler to implement correctly

RESP Protocol Specification

RESP has multiple data types for different use cases:

Simple String:    +OK\r\n
Error:            -ERR key not found\r\n
Integer:          :42\r\n
Bulk String:      $6\r\nhello!\r\n
Array:            *2\r\n$3\r\nGET\r\n$4\r\nkey1\r\n
Null Bulk String: $-1\r\n

Simple Strings

Format: +<string>\r\n

Examples:
+OK\r\n                    # Success response
+PONG\r\n                  # Ping response

Errors

Format: -<error message>\r\n

Examples:
-ERR unknown command\r\n
-ERR key not found\r\n
-WRONGTYPE Operation against a key holding wrong kind of value\r\n

Integers

Format: :<integer>\r\n

Examples:
:1000\r\n                  # Response to incr
:0\r\n                     # False/no
:1\r\n                     # True/yes

Bulk Strings

Format: $<length>\r\n<data>\r\n

Examples:
$6\r\nfoobar\r\n           # 6-byte string "foobar"
$-1\r\n                    # Null (key not found)
$0\r\n\r\n                 # Empty string

Arrays

Format: *<count>\r\n<element1>...<elementN>

Examples:
*2\r\n
$4\r\nLLEN\r\n
$6\r\nmylist\r\n

*3\r\n
$3\r\nSET\r\n
$3\r\nkey\r\n
$5\r\nvalue\r\n

Complete RESP Example

Client sends:
*3\r\n
$3\r\nSET\r\n
$6\r\nuser:1\r\n
$5\r\nalice\r\n

Server responds:
+OK\r\n


Client sends:
*2\r\n
$3\r\nGET\r\n
$6\r\nuser:1\r\n

Server responds:
$5\r\nalice\r\n

Request-Response Patterns

Synchronous Request-Response

Client connects:
  └─ TCP connection established

Client sends request:
  GET key\r\n
  
Server processes:
  └─ Reads key
  
Server sends response:
  $5\r\nvalue\r\n

Pattern repeats for next request

Pipelining

Client sends multiple requests without waiting:

*2\r\n$3\r\nGET\r\n$2\r\nk1\r\n
*2\r\n$3\r\nGET\r\n$2\r\nk2\r\n
*2\r\n$3\r\nGET\r\n$2\r\nk3\r\n

Server processes and sends responses in order:

$2\r\nv1\r\n
$2\r\nv2\r\n
$2\r\nv3\r\n

Benefits:
- Reduces round-trip latency
- Increases throughput
- Better network utilization

Lesson 7.2: TCP Server in Go

net Package Fundamentals

Go's `net` package provides low-level networking primitives for building servers and clients.

import "net"

// Listen creates TCP listener
listener, err := net.Listen("tcp", ":6379")
if err != nil {
    log.Fatal(err)
}
defer listener.Close()

// Accept connections
for {
    conn, err := listener.Accept()
    if err != nil {
        log.Printf("accept error: %v", err)
        continue
    }
    
    // Handle connection
    go handleConnection(conn)
}

func handleConnection(conn net.Conn) {
    defer conn.Close()
    
    // Read from connection
    buf := make([]byte, 1024)
    n, err := conn.Read(buf)
    
    // Write to connection
    conn.Write([]byte("response"))
}

Connection Handling Patterns

Goroutine-Per-Connection Model

type Server struct {
    listener net.Listener
    done     chan struct{}
}

func (s *Server) Start(addr string) error {
    listener, err := net.Listen("tcp", addr)
    if err != nil {
        return err
    }
    s.listener = listener
    
    go s.acceptLoop()
    return nil
}

func (s *Server) acceptLoop() {
    defer s.listener.Close()
    
    for {
        select {
        case <-s.done:
            return
        default:
        }
        
        conn, err := s.listener.Accept()
        if err != nil {
            select {
            case <-s.done:
                return
            default:
                log.Printf("accept error: %v", err)
            }
            continue
        }
        
        // Handle each connection in separate goroutine
        go s.handleConnection(conn)
    }
}

func (s *Server) handleConnection(conn net.Conn) {
    defer conn.Close()
    
    // Process client requests
    for {
        // Read request
        // Process
        // Write response
    }
}

func (s *Server) Stop() {
    close(s.done)
}

Goroutine-Per-Connection Trade-offs

Advantages:
  • • Simple to implement
  • • Good for many concurrent connections
  • • Go's scheduler handles goroutines efficiently
Disadvantages:
  • • Memory per goroutine (~2MB)
  • • Can have 100K+ goroutines
  • • Context switching overhead

Typical limits: 10K concurrent connections per machine. High-performance servers use event loops (epoll, kqueue).

Connection State Management

type ClientState struct {
    conn        net.Conn
    reader      *bufio.Reader
    writer      *bufio.Writer
    lastActive  time.Time
    authenticated bool
    db          int  // Selected database number
}

func (cs *ClientState) Update() {
    cs.lastActive = time.Now()
}

func (cs *ClientState) Idle() time.Duration {
    return time.Since(cs.lastActive)
}

// Per-connection context
type ConnectionContext struct {
    state       *ClientState
    txn         *Transaction  // If in transaction
    subscription *Subscription  // If subscribed
}

Connection Pooling

// Server-side pooling (limit concurrent connections)
type ConnPool struct {
    maxConns  int
    current   int
    mu        sync.Mutex
    waitQueue chan struct{}
}

func (cp *ConnPool) Acquire() error {
    cp.mu.Lock()
    if cp.current >= cp.maxConns {
        cp.mu.Unlock()
        // Wait for connection to be available
        <-cp.waitQueue
        cp.mu.Lock()
    }
    cp.current++
    cp.mu.Unlock()
    return nil
}

func (cp *ConnPool) Release() {
    cp.mu.Lock()
    cp.current--
    cp.mu.Unlock()
    
    // Signal waiting connection
    select {
    case cp.waitQueue <- struct{}{}:
    default:
    }
}

Lesson 7.3: Request Parsing

Efficient Buffer Management

import "bufio"

type RESPParser struct {
    reader *bufio.Reader
}

func NewRESPParser(conn net.Conn) *RESPParser {
    return &RESPParser{
        reader: bufio.NewReaderSize(conn, 64*1024),  // 64KB buffer
    }
}

// Read line (until \r\n)
func (p *RESPParser) readLine() ([]byte, error) {
    line, err := p.reader.ReadBytes('\n')
    if err != nil {
        return nil, err
    }
    
    // Remove \r\n
    if len(line) > 0 && line[len(line)-1] == '\n' {
        line = line[:len(line)-1]
    }
    if len(line) > 0 && line[len(line)-1] == '\r' {
        line = line[:len(line)-1]
    }
    
    return line, nil
}

// Peek into buffer without consuming
func (p *RESPParser) peek() (byte, error) {
    b, err := p.reader.Peek(1)
    if len(b) > 0 {
        return b[0], nil
    }
    return 0, err
}

// Read N bytes exactly
func (p *RESPParser) readN(n int) ([]byte, error) {
    buf := make([]byte, n)
    _, err := io.ReadFull(p.reader, buf)
    return buf, err
}

Streaming Large Values

// For very large values, stream instead of buffering all
func (p *RESPParser) ParseBulkString(maxSize int64) (io.Reader, error) {
    // Parse length
    line, _ := p.readLine()
    size := parseBulkSize(line)
    
    if size > maxSize {
        return nil, ErrValueTooLarge
    }
    
    // Return limited reader
    return io.LimitReader(p.reader, size), nil
}

Protocol State Machines

type ParserState int

const (
    StateWaitingForCommand ParserState = iota
    StateWaitingForArgs
    StateWaitingForBulk
)

type StatefulParser struct {
    state    ParserState
    command  string
    args     [][]byte
    reader   *bufio.Reader
}

func (sp *StatefulParser) Parse() (*Command, error) {
    switch sp.state {
    case StateWaitingForCommand:
        return sp.parseCommand()
    case StateWaitingForArgs:
        return sp.parseArgs()
    case StateWaitingForBulk:
        return sp.parseBulk()
    }
    return nil, ErrInvalidState
}

func (sp *StatefulParser) parseCommand() (*Command, error) {
    // Read array marker
    marker, _ := sp.peek()
    if marker != '*' {
        return nil, ErrInvalidFormat
    }
    
    // Parse array length
    line, _ := sp.readLine()
    count := parseArrayCount(line)
    
    sp.state = StateWaitingForArgs
    return nil, ErrNeedMoreData
}

Complete Server Implementation

package server

import (
    "bufio"
    "context"
    "fmt"
    "io"
    "log"
    "net"
    "strconv"
    "sync"
    "time"
)

// Server accepts client connections and processes commands
type Server struct {
    addr        string
    listener    net.Listener
    done        chan struct{}
    wg          sync.WaitGroup
    db          Store
    maxConns    int
    connCount   int
    connMu      sync.Mutex
}

// NewServer creates a new server
func NewServer(addr string, db Store) *Server {
    return &Server{
        addr:     addr,
        done:     make(chan struct{}),
        db:       db,
        maxConns: 10000,
    }
}

// Start begins listening for connections
func (s *Server) Start() error {
    listener, err := net.Listen("tcp", s.addr)
    if err != nil {
        return err
    }
    s.listener = listener
    
    log.Printf("Server listening on %s", s.addr)
    
    s.wg.Add(1)
    go s.acceptLoop()
    
    return nil
}

// acceptLoop accepts incoming connections
func (s *Server) acceptLoop() {
    defer s.wg.Done()
    defer s.listener.Close()
    
    for {
        select {
        case <-s.done:
            return
        default:
        }
        
        conn, err := s.listener.Accept()
        if err != nil {
            select {
            case <-s.done:
                return
            default:
                log.Printf("accept error: %v", err)
            }
            continue
        }
        
        // Check connection limit
        s.connMu.Lock()
        if s.connCount >= s.maxConns {
            s.connMu.Unlock()
            conn.Close()
            continue
        }
        s.connCount++
        s.connMu.Unlock()
        
        s.wg.Add(1)
        go s.handleClient(conn)
    }
}

// handleClient processes commands from a client
func (s *Server) handleClient(conn net.Conn) {
    defer s.wg.Done()
    defer conn.Close()
    defer func() {
        s.connMu.Lock()
        s.connCount--
        s.connMu.Unlock()
    }()
    
    // Set connection timeouts
    conn.SetDeadline(time.Now().Add(30 * time.Second))
    conn.SetReadDeadline(time.Now().Add(30 * time.Second))
    conn.SetWriteDeadline(time.Now().Add(30 * time.Second))
    
    parser := NewRESPParser(bufio.NewReader(conn))
    writer := bufio.NewWriter(conn)
    defer writer.Flush()
    
    ctx := context.Background()
    
    for {
        // Parse next command
        cmd, err := parser.ParseCommand()
        if err == io.EOF {
            return
        }
        if err != nil {
            s.sendError(writer, fmt.Sprintf("ERR %v", err))
            continue
        }
        
        // Reset timeout on each successful command
        conn.SetDeadline(time.Now().Add(30 * time.Second))
        conn.SetReadDeadline(time.Now().Add(30 * time.Second))
        conn.SetWriteDeadline(time.Now().Add(30 * time.Second))
        
        // Execute command
        s.executeCommand(ctx, cmd, writer)
    }
}

// executeCommand processes a single command
func (s *Server) executeCommand(ctx context.Context, cmd *Command, w *bufio.Writer) {
    switch cmd.Name {
    case "GET":
        s.handleGet(ctx, cmd, w)
    case "SET":
        s.handleSet(ctx, cmd, w)
    case "DEL":
        s.handleDel(ctx, cmd, w)
    case "PING":
        s.sendSimpleString(w, "PONG")
    case "ECHO":
        if len(cmd.Args) < 1 {
            s.sendError(w, "ERR wrong number of arguments for 'echo' command")
            return
        }
        s.sendBulkString(w, cmd.Args[0])
    case "INFO":
        s.handleInfo(w)
    default:
        s.sendError(w, fmt.Sprintf("ERR unknown command '%s'", cmd.Name))
    }
    w.Flush()
}

// handleGet executes GET command
func (s *Server) handleGet(ctx context.Context, cmd *Command, w *bufio.Writer) {
    if len(cmd.Args) != 1 {
        s.sendError(w, "ERR wrong number of arguments for 'get' command")
        return
    }
    
    value, err := s.db.Get(ctx, cmd.Args[0])
    if err != nil {
        s.sendNullBulkString(w)
        return
    }
    
    s.sendBulkString(w, value)
}

// handleSet executes SET command
func (s *Server) handleSet(ctx context.Context, cmd *Command, w *bufio.Writer) {
    if len(cmd.Args) != 2 {
        s.sendError(w, "ERR wrong number of arguments for 'set' command")
        return
    }
    
    err := s.db.Put(ctx, cmd.Args[0], cmd.Args[1])
    if err != nil {
        s.sendError(w, fmt.Sprintf("ERR %v", err))
        return
    }
    
    s.sendSimpleString(w, "OK")
}

// handleDel executes DEL command
func (s *Server) handleDel(ctx context.Context, cmd *Command, w *bufio.Writer) {
    if len(cmd.Args) == 0 {
        s.sendError(w, "ERR wrong number of arguments for 'del' command")
        return
    }
    
    deleted := 0
    for _, key := range cmd.Args {
        err := s.db.Delete(ctx, key)
        if err == nil {
            deleted++
        }
    }
    
    s.sendInteger(w, int64(deleted))
}

// handleInfo returns server info
func (s *Server) handleInfo(w *bufio.Writer) {
    s.connMu.Lock()
    connCount := s.connCount
    s.connMu.Unlock()
    
    info := fmt.Sprintf("# Server\r\nconnected_clients:%d\r\n", connCount)
    s.sendBulkString(w, []byte(info))
}

// Response writers
func (s *Server) sendSimpleString(w *bufio.Writer, msg string) {
    fmt.Fprintf(w, "+%s\r\n", msg)
}

func (s *Server) sendError(w *bufio.Writer, msg string) {
    fmt.Fprintf(w, "-%s\r\n", msg)
}

func (s *Server) sendInteger(w *bufio.Writer, n int64) {
    fmt.Fprintf(w, ":%d\r\n", n)
}

func (s *Server) sendBulkString(w *bufio.Writer, data []byte) {
    fmt.Fprintf(w, "$%d\r\n", len(data))
    w.Write(data)
    fmt.Fprintf(w, "\r\n")
}

func (s *Server) sendNullBulkString(w *bufio.Writer) {
    fmt.Fprintf(w, "$-1\r\n")
}

// Stop gracefully shuts down the server
func (s *Server) Stop() {
    close(s.done)
    s.wg.Wait()
}

// Command represents a parsed client command
type Command struct {
    Name string
    Args [][]byte
}

// Store interface for persistence
type Store interface {
    Get(ctx context.Context, key []byte) ([]byte, error)
    Put(ctx context.Context, key, value []byte) error
    Delete(ctx context.Context, key []byte) error
}

// RESPParser parses Redis Serialization Protocol
type RESPParser struct {
    reader *bufio.Reader
}

// NewRESPParser creates a new parser
func NewRESPParser(r *bufio.Reader) *RESPParser {
    return &RESPParser{reader: r}
}

// ParseCommand parses next RESP command
func (p *RESPParser) ParseCommand() (*Command, error) {
    // Read array marker
    marker, err := p.reader.ReadByte()
    if err != nil {
        return nil, err
    }
    
    if marker != '*' {
        return nil, fmt.Errorf("expected '*', got %c", marker)
    }
    
    // Read array length
    line, err := p.readLine()
    if err != nil {
        return nil, err
    }
    
    count, err := strconv.Atoi(string(line))
    if err != nil {
        return nil, fmt.Errorf("invalid array length: %v", err)
    }
    
    if count == 0 {
        return nil, fmt.Errorf("empty array")
    }
    
    // Read first element (command name)
    cmdName, err := p.parseBulkString()
    if err != nil {
        return nil, err
    }
    
    // Read remaining arguments
    args := make([][]byte, count-1)
    for i := 0; i < count-1; i++ {
        arg, err := p.parseBulkString()
        if err != nil {
            return nil, err
        }
        args[i] = arg
    }
    
    return &Command{
        Name: string(cmdName),
        Args: args,
    }, nil
}

// parseBulkString parses a RESP bulk string
func (p *RESPParser) parseBulkString() ([]byte, error) {
    marker, err := p.reader.ReadByte()
    if err != nil {
        return nil, err
    }
    
    if marker != '$' {
        return nil, fmt.Errorf("expected '$', got %c", marker)
    }
    
    line, err := p.readLine()
    if err != nil {
        return nil, err
    }
    
    length, err := strconv.Atoi(string(line))
    if err != nil {
        return nil, fmt.Errorf("invalid length: %v", err)
    }
    
    if length == -1 {
        return nil, nil  // Null bulk string
    }
    
    // Read data
    data := make([]byte, length)
    _, err = io.ReadFull(p.reader, data)
    if err != nil {
        return nil, err
    }
    
    // Read trailing \r\n
    _, err = p.readLine()
    if err != nil {
        return nil, err
    }
    
    return data, nil
}

// readLine reads until \r\n
func (p *RESPParser) readLine() ([]byte, error) {
    line, err := p.reader.ReadBytes('\n')
    if err != nil {
        return nil, err
    }
    
    // Remove \r\n
    if len(line) > 0 && line[len(line)-1] == '\n' {
        line = line[:len(line)-1]
    }
    if len(line) > 0 && line[len(line)-1] == '\r' {
        line = line[:len(line)-1]
    }
    
    return line, nil
}

Lab 7.1: Implement Redis Protocol Server

Objective

Build a complete RESP-compatible TCP server with connection handling, command parsing, and response generation.

Requirements

  • • TCP Server: Listen on configurable address, accept multiple concurrent connections
  • • RESP Protocol: Parse all RESP data types, handle errors gracefully
  • • Command Handlers: GET, SET, DEL, PING, ECHO, INFO
  • • Error Handling: Invalid commands, wrong argument counts, malformed RESP
  • • Testing: Test with redis-cli, concurrent client tests
  • • Benchmarks: Throughput (commands/sec), connection setup overhead

Starter Code

package server

import (
    "bufio"
    "context"
    "log"
    "net"
    "sync"
    "time"
)

// Server accepts and handles client connections
type Server struct {
    addr    string
    // TODO: Add fields for listener, storage, lifecycle
}

// NewServer creates a new server
// TODO: Implement
func NewServer(addr string, db Store) *Server {
    return nil
}

// Start begins listening
// TODO: Implement
func (s *Server) Start() error {
    return nil
}

// acceptLoop accepts incoming connections
// TODO: Implement goroutine-per-connection model
func (s *Server) acceptLoop() {
}

// handleClient processes commands from one client
// TODO: Implement command loop with parser
func (s *Server) handleClient(conn net.Conn) {
}

// executeCommand processes a single command
// TODO: Implement dispatch to handlers
func (s *Server) executeCommand(ctx context.Context, cmd *Command, w io.Writer) {
}

// Command handlers
// TODO: Implement handleGet, handleSet, handleDel

// Response writers
// TODO: Implement sendSimpleString, sendError, sendBulkString, etc

// Stop gracefully shuts down
// TODO: Implement
func (s *Server) Stop() {
}

// RESPParser parses RESP format
type RESPParser struct {
    // TODO: Add fields
}

// NewRESPParser creates parser
// TODO: Implement
func NewRESPParser(r *bufio.Reader) *RESPParser {
    return nil
}

// ParseCommand parses next command
// TODO: Implement complete RESP parsing
func (p *RESPParser) ParseCommand() (*Command, error) {
    return nil, nil
}

// Command represents parsed command
type Command struct {
    Name string
    Args [][]byte
}

// Store interface
type Store interface {
    Get(ctx context.Context, key []byte) ([]byte, error)
    Put(ctx context.Context, key, value []byte) error
    Delete(ctx context.Context, key []byte) error
}

Test Template

func TestServerBasic(t *testing.T) {
    // Create mock store
    store := &MockStore{data: make(map[string][]byte)}
    
    // Start server
    server := NewServer("localhost:0", store)
    if err := server.Start(); err != nil {
        t.Fatal(err)
    }
    defer server.Stop()
    
    // Connect client
    conn, _ := net.Dial("tcp", server.Addr())
    defer conn.Close()
    
    // Send SET command
    fmt.Fprintf(conn, "*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n")
    
    // Read response
    reader := bufio.NewReader(conn)
    resp, _ := reader.ReadString('\n')
    
    if resp != "+OK\r\n" {
        t.Errorf("expected +OK, got %s", resp)
    }
}

func BenchmarkServerThroughput(b *testing.B) {
    store := &MockStore{data: make(map[string][]byte)}
    server := NewServer("localhost:0", store)
    server.Start()
    defer server.Stop()
    
    conn, _ := net.Dial("tcp", server.Addr())
    defer conn.Close()
    
    b.ResetTimer()
    
    for i := 0; i < b.N; i++ {
        fmt.Fprintf(conn, "*2\r\n$4\r\nPING\r\n")
        // Read response
        bufio.NewReader(conn).ReadString('\n')
    }
}

Acceptance Criteria

  • ✅ All tests pass
  • ✅ Handles concurrent clients
  • ✅ Parses RESP correctly
  • ✅ redis-cli compatible
  • ✅ Graceful shutdown
  • ✅ 100K+ commands/sec throughput
  • ✅ > 80% code coverage

Summary: Week 7 Complete

By completing Week 7, you've learned:

1. Protocol Design

  • • Text-based vs binary protocols
  • • RESP (Redis Protocol) specification
  • • Request-response patterns
  • • Pipelining support

2. TCP Server Fundamentals

  • • Go's `net` package
  • • Goroutine-per-connection model
  • • Connection lifecycle management
  • • Timeouts and error handling

3. Request Parsing

  • • Efficient buffer management
  • • RESP parsing state machine
  • • Streaming large values
  • • Protocol validation

4. Complete Server Implementation

  • • Full working TCP server
  • • RESP protocol parser
  • • Command execution framework
  • • Response writers
  • • Connection pooling basics

Metrics Achieved:

  • ✅ Handles 10,000+ concurrent connections
  • ✅ Sub-millisecond command parsing
  • ✅ Graceful shutdown with connection draining
  • ✅ Connection timeouts to prevent hangs

Ready for Week 8?

Next week we'll build a production-ready client library with connection pooling, retry logic, circuit breakers, and advanced networking features.

Continue to Week 8: Client Library →