TCP Name-Value Store Server Protocol Design · Concurrent Server · CRUD over TCP

 

Part 4: TCP Name-Value Store Server
Protocol Design · Concurrent Server · CRUD over TCP · EmbeddedPathashala

What Are We Building?

A TCP server that stores name-value pairs in memory. Clients connect and can perform four operations: SET (add/modify), GET (retrieve), DEL (delete), and LIST (show all keys). This is essentially a simplified version of what Redis does.

The interesting challenges here are: how to design a clear text protocol over TCP, how to handle multiple clients simultaneously, and how to maintain consistent shared state between client sessions.

Protocol Design

Before writing a single line of server code, define the protocol — the exact format of messages exchanged between client and server. A well-designed protocol prevents ambiguity and makes debugging much easier.

Command Client Sends Server Responds
SET SET key value\n OK\n or ERR msg\n
GET GET key\n VALUE val\n or ERR not found\n
DEL DEL key\n OK\n or ERR not found\n
LIST LIST\n KEY key\n … END\n
QUIT QUIT\n BYE\n (then close)

Example Session
Client
SET name Ravi
↓ wait…
GET name
↓ wait…
DEL name
↓ wait…
LIST
↓ wait…
QUIT
──→
←──
──→
←──
──→
←──
──→
←──
──→
←──
Server
← recv SET
OK
← recv GET
VALUE Ravi
← recv DEL
OK
← recv LIST
END (empty)
← recv QUIT
BYE

The Data Store

We’ll use a simple fixed-size array of key-value pairs. This avoids external libraries and keeps the focus on socket programming. For production use, you would use a hash table.

/* kvstore.h — Simple key-value store */
#ifndef KVSTORE_H
#define KVSTORE_H

#define MAX_ENTRIES  256
#define MAX_KEY_LEN   64
#define MAX_VAL_LEN  256

typedef struct {
    char key[MAX_KEY_LEN];
    char val[MAX_VAL_LEN];
    int  in_use;      /* 1 if this slot is occupied, 0 if free */
} KVEntry;

/* Global store (server-wide) */
extern KVEntry store[MAX_ENTRIES];

int  kv_set(const char *key, const char *val);
int  kv_get(const char *key, char *val_out, size_t val_out_len);
int  kv_del(const char *key);
void kv_list(int connfd);

#endif
/* kvstore.c */
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include "kvstore.h"

KVEntry store[MAX_ENTRIES];

/* Find slot for key, or first free slot */
static int find_slot(const char *key)
{
    for (int i = 0; i < MAX_ENTRIES; i++) {
        if (store[i].in_use && strcmp(store[i].key, key) == 0)
            return i;
    }
    return -1;   /* Not found */
}

static int find_free(void)
{
    for (int i = 0; i < MAX_ENTRIES; i++) {
        if (!store[i].in_use)
            return i;
    }
    return -1;   /* Store full */
}

/* SET: add or update key */
int kv_set(const char *key, const char *val)
{
    int slot = find_slot(key);
    if (slot == -1) {
        slot = find_free();
        if (slot == -1)
            return -1;   /* Store full */
        strncpy(store[slot].key, key, MAX_KEY_LEN - 1);
        store[slot].in_use = 1;
    }
    strncpy(store[slot].val, val, MAX_VAL_LEN - 1);
    return 0;
}

/* GET: retrieve value for key */
int kv_get(const char *key, char *val_out, size_t len)
{
    int slot = find_slot(key);
    if (slot == -1)
        return -1;   /* Not found */
    strncpy(val_out, store[slot].val, len - 1);
    val_out[len - 1] = '\0';
    return 0;
}

/* DEL: remove key */
int kv_del(const char *key)
{
    int slot = find_slot(key);
    if (slot == -1)
        return -1;   /* Not found */
    store[slot].in_use = 0;
    memset(&store[slot], 0, sizeof(KVEntry));
    return 0;
}

/* LIST: send all keys to connfd */
void kv_list(int connfd)
{
    char line[MAX_KEY_LEN + 8];
    for (int i = 0; i < MAX_ENTRIES; i++) {
        if (store[i].in_use) {
            snprintf(line, sizeof(line), "KEY %s\n", store[i].key);
            write(connfd, line, strlen(line));
        }
    }
    write(connfd, "END\n", 4);
}

The Concurrent Server

Each client gets its own child process using fork(). This is the simplest approach to handle multiple concurrent clients. Each child handles one client and then exits. The parent never touches client data — it just accepts and forks.

fork-per-client Architecture
Parent: accept() loop
Child 1
Client A
Child 2
Client B
Child 3
Client C
⚠ Each child has a COPY of the store — changes are not shared between children. (Shared state requires shared memory or a database.)
/* nv_server.c — Name-Value Store Server */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include "rlbuf.h"
#include "inet_sockets.h"
#include "kvstore.h"

#define PORT     "54321"
#define BACKLOG  50
#define MAXLINE  512

/* Reap zombie children */
static void sigchld_handler(int sig)
{
    while (waitpid(-1, NULL, WNOHANG) > 0)
        ;
}

/* Parse and handle one command line from a client */
static void handle_command(int connfd, const char *line)
{
    char cmd[16], key[MAX_KEY_LEN], val[MAX_VAL_LEN];
    char resp[MAX_VAL_LEN + 16];
    int n;

    n = sscanf(line, "%15s %63s %255[^\n]", cmd, key, val);

    if (n < 1) {
        write(connfd, "ERR empty command\n", 18);
        return;
    }

    if (strcmp(cmd, "SET") == 0) {
        if (n < 3) {
            write(connfd, "ERR SET needs key and value\n", 28);
        } else if (kv_set(key, val) == 0) {
            write(connfd, "OK\n", 3);
        } else {
            write(connfd, "ERR store full\n", 15);
        }

    } else if (strcmp(cmd, "GET") == 0) {
        if (n < 2) {
            write(connfd, "ERR GET needs key\n", 18);
        } else if (kv_get(key, val, sizeof(val)) == 0) {
            snprintf(resp, sizeof(resp), "VALUE %s\n", val);
            write(connfd, resp, strlen(resp));
        } else {
            write(connfd, "ERR not found\n", 14);
        }

    } else if (strcmp(cmd, "DEL") == 0) {
        if (n < 2) {
            write(connfd, "ERR DEL needs key\n", 18);
        } else if (kv_del(key) == 0) {
            write(connfd, "OK\n", 3);
        } else {
            write(connfd, "ERR not found\n", 14);
        }

    } else if (strcmp(cmd, "LIST") == 0) {
        kv_list(connfd);   /* Sends "KEY k\n" for each key, then "END\n" */

    } else {
        snprintf(resp, sizeof(resp), "ERR unknown command: %s\n", cmd);
        write(connfd, resp, strlen(resp));
    }
}

int main(void)
{
    int listenfd, connfd;
    socklen_t addrlen;
    struct sigaction sa;
    RlBuf rlbuf;
    char  line[MAXLINE];

    /* Reap zombie children automatically */
    sa.sa_handler = sigchld_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;
    sigaction(SIGCHLD, &sa, NULL);

    listenfd = inetListen(PORT, BACKLOG, &addrlen);
    if (listenfd == -1) { perror("inetListen"); exit(EXIT_FAILURE); }

    printf("Name-Value server listening on port %s\n", PORT);

    for (;;) {
        connfd = accept(listenfd, NULL, NULL);
        if (connfd == -1) { perror("accept"); continue; }

        switch (fork()) {
        case -1:
            perror("fork");
            close(connfd);
            break;

        case 0:
            /* Child process: handle this client */
            close(listenfd);   /* Child doesn't need listen fd */
            initRlBuf(connfd, &rlbuf);

            write(connfd, "Welcome to KV Store. Commands: SET GET DEL LIST QUIT\n", 53);

            while (readLineBuf(&rlbuf, line, sizeof(line)) > 0) {
                /* Trim trailing newline */
                size_t len = strlen(line);
                while (len > 0 && (line[len-1] == '\n' || line[len-1] == '\r'))
                    line[--len] = '\0';

                if (strcmp(line, "QUIT") == 0) {
                    write(connfd, "BYE\n", 4);
                    break;
                }

                handle_command(connfd, line);
            }

            close(connfd);
            exit(EXIT_SUCCESS);

        default:
            /* Parent: close connfd (child has it) and loop */
            close(connfd);
            break;
        }
    }

    return 0;
}

The Client

/* nv_client.c — Interactive Name-Value Store Client */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "rlbuf.h"
#include "inet_sockets.h"

#define PORT    "54321"
#define MAXLINE  512

int main(int argc, char *argv[])
{
    int   sockfd;
    RlBuf rlbuf;
    char  cmd[MAXLINE];
    char  resp[MAXLINE];

    if (argc != 2) {
        fprintf(stderr, "Usage: %s host\n", argv[0]);
        exit(EXIT_FAILURE);
    }

    sockfd = inetConnect(argv[1], PORT, SOCK_STREAM);
    if (sockfd == -1) { perror("inetConnect"); exit(EXIT_FAILURE); }

    initRlBuf(sockfd, &rlbuf);

    /* Read and print server welcome message */
    if (readLineBuf(&rlbuf, resp, sizeof(resp)) > 0)
        printf("Server: %s", resp);

    /* Interactive loop */
    while (fgets(cmd, sizeof(cmd), stdin) != NULL) {
        size_t len = strlen(cmd);

        /* Send command to server */
        if (write(sockfd, cmd, len) != (ssize_t)len) {
            perror("write"); break;
        }

        /* Read response lines until we get a final line */
        /* For LIST: read until "END\n"; for others: read one line */
        do {
            if (readLineBuf(&rlbuf, resp, sizeof(resp)) <= 0) goto done;
            printf("%s", resp);
        } while (strncmp(resp, "KEY ", 4) == 0);   /* LIST responses */

        if (strncmp(cmd, "QUIT", 4) == 0) break;
    }

done:
    close(sockfd);
    return 0;
}

Build and Demo Session

gcc -Wall -o nv_server nv_server.c kvstore.c inet_sockets.c rlbuf.c
gcc -Wall -o nv_client nv_client.c inet_sockets.c rlbuf.c

# Terminal 1: start server
./nv_server
Name-Value server listening on port 54321

# Terminal 2: connect client
./nv_client localhost
Server: Welcome to KV Store. Commands: SET GET DEL LIST QUIT

SET city Hyderabad
OK
SET lang C
OK
GET city
VALUE Hyderabad
LIST
KEY city
KEY lang
END
DEL city
OK
LIST
KEY lang
END
QUIT
BYE

The Shared State Problem

The fork-per-client design has a fundamental limitation: each child gets a copy of the parent’s memory, not a reference. Two clients running simultaneously see different independent stores. Fixes:

Option 1: Shared Memory (shmget / mmap)
Map the store into a shared memory segment. All children see the same data. Requires a mutex (semaphore) to prevent concurrent write corruption.
Option 2: Single-process with select()/epoll()
Use I/O multiplexing so one process handles all clients. No fork, no shared-state problem. More complex code but very scalable. This is what Redis and nginx do.
Option 3: Serialize through a pipe to parent
Children don’t touch the store directly. They send requests to the parent via a pipe, parent executes them and sends back results. The parent is the single point of access.
Option 4: Persistent storage (SQLite / file)
Each operation reads/writes a file or embedded database. Automatic persistence across restarts. SQLite is thread-safe and supports this pattern well.

Basic Security: Creator-Only Delete

The exercise asks for an optional security mechanism where only the client that created a key can delete or modify it. We can track the IP address of the creator:

/* Extended KVEntry with owner tracking */
typedef struct {
    char  key[MAX_KEY_LEN];
    char  val[MAX_VAL_LEN];
    int   in_use;
    char  owner_ip[INET6_ADDRSTRLEN];   /* IP of creator */
    uid_t owner_uid;                     /* UID (for UNIX socket clients) */
} KVEntry;

/* Get client IP address from accept() */
int get_client_ip(int connfd, char *ip_out, size_t len)
{
    struct sockaddr_storage peer_addr;
    socklen_t peer_len = sizeof(peer_addr);

    if (getpeername(connfd, (struct sockaddr *)&peer_addr, &peer_len) == -1)
        return -1;

    /* Convert to string */
    return getnameinfo((struct sockaddr *)&peer_addr, peer_len,
                       ip_out, len, NULL, 0, NI_NUMERICHOST);
}

/* In kv_del — check ownership */
int kv_del_secure(const char *key, const char *client_ip)
{
    int slot = find_slot(key);
    if (slot == -1)
        return -1;   /* Not found */

    /* Reject if requester is not the owner */
    if (strcmp(store[slot].owner_ip, client_ip) != 0)
        return -2;   /* Permission denied */

    store[slot].in_use = 0;
    return 0;
}

/*
 * NOTE: IP-based ownership is weak security — NAT and proxies mean
 * multiple users can share an IP. For real security, use authentication
 * (e.g. a shared secret or token-based auth before allowing mutations).
 */

Interview Questions & Answers

Q1. Why is protocol design the first step before writing server code?

The protocol defines the contract between client and server. If you write server code first, you’ll likely need to redesign it when edge cases emerge (what if the key has spaces? what if the value is missing?). Defining the protocol first — including error responses — prevents rework and makes both sides independently testable. You can test with telnet before writing the client.

Q2. In a fork-per-client server, why does the parent close connfd after fork()?

After fork(), both parent and child have a copy of the file descriptor. A TCP connection only closes when ALL copies of its fd are closed. If the parent doesn’t close its copy of connfd, the client will never see EOF even after the child exits — because the parent’s copy keeps the connection open. Always close the fd you’re not using.

Q3. Why does the child close listenfd?

The child doesn’t need to accept any new connections — that’s the parent’s job. File descriptors are a limited resource (typically 1024 per process). If the child keeps listenfd open, it wastes an fd and could interfere with the parent’s ability to accept new connections in some edge cases. Good practice: close everything you don’t need.

Q4. What is a zombie process and how do we prevent them in a concurrent server?

When a child process exits, the kernel keeps its exit status until the parent calls waitpid(). Until then, the process entry remains — a “zombie”. In a server that forks many children, accumulating zombies wastes process table entries. We prevent this by installing a SIGCHLD handler that calls waitpid(-1, NULL, WNOHANG) in a loop to reap all available children without blocking.

Q5. Why doesn’t fork-per-client work for shared state?

fork() uses copy-on-write to give each child a copy of the parent’s address space. Changes made by one child to its copy of the store are invisible to other children. To share mutable state between processes, you need shared memory (mmap(MAP_SHARED) or POSIX shared memory) protected by a semaphore, or a single-process event-driven design.

Q6. What is the select()/epoll() alternative to fork and when is it better?

Instead of forking a child per client, a single process uses select() or epoll() to monitor multiple file descriptors simultaneously. When any fd becomes readable, the process handles that client. No fork overhead, no shared state problem, and very high scalability. This is how Redis handles thousands of clients in a single thread. The tradeoff: more complex code, and a slow command blocks all other clients (unlike fork where each child is isolated).

Q7. How do you test a TCP server without writing a client?

Use telnet hostname port or nc (netcat). Both connect to a TCP server and let you type commands and see responses directly: nc localhost 54321 then type SET foo bar and press Enter. This is why line-based text protocols are popular for application-layer protocols — they’re easy to debug manually.

Q8. What does sscanf() do in the command parser and what are its limitations?

sscanf(line, "%15s %63s %255[^\n]", cmd, key, val) extracts up to three space-separated tokens from the line. The format %[^\n] captures everything until newline, allowing spaces in the value. Limitations: it doesn’t handle quoted strings with embedded spaces as single tokens, and it’s fragile if the client sends malformed input. Production servers usually use a proper parser. Also, sscanf doesn’t distinguish between “no third token” and “empty third token” cleanly.

Leave a Reply

Your email address will not be published. Required fields are marked *