Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
name: Protocol tests
on: [push, pull_request]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: [22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm test
116 changes: 48 additions & 68 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,91 +1,71 @@
# Codecrafters Redis in JavaScript
# A Redis learning implementation in JavaScript

[![Codecrafters Challenge Progress](https://backend.codecrafters.io/progress/redis/b26b2620-8a48-4af9-b624-3729b82c74a7)](https://app.codecrafters.io/users/HMarzban?r=2qF)
This started with the [CodeCrafters Redis challenge](https://codecrafters.io/challenges/redis)
and grew into an experiment with RESP framing, TCP streams and a shared store behind
Node.js cluster workers. It is an in-memory learning project, not a Redis replacement.

Welcome! This is my take on the [Codecrafters Redis Challenge](https://codecrafters.io/challenges/redis) Redis Challenge. I've delved deep into the heart of Redis and ended up crafting my own little version in JavaScript. Dive in and check it out!
## Run

## Introduction

Welcome to my solution for the [Codecrafters](https://codecrafters.io/challenges/redis) Redis challenge! In this repository, I've created a toy Redis clone that not only handles the basic commands like `PING`, `GET`, and `SET` but also includes an extended `SET` of features <u>that was not in the challenge</u>. With benchmarking tools and extensive testing, this repository is meant to be both an educational resource and a fun exploration into the world of Redis.

## Features

### SET Command

Syntax
Node.js 22+ is required. There are no npm dependencies.

```sh
SET key value [NX | XX] [GET] [EX seconds | PX milliseconds |
EXAT unix-time-seconds | PXAT unix-time-milliseconds | KEEPTTL]
npm start # one worker, 127.0.0.1:6379
WORKER_COUNT=2 PORT=6380 npm start # shared primary store, two TCP workers
npm test # isolated TCP/cluster fixtures; no Redis install
```

Supportet Options:

- `EX` seconds -- Set the specified expire time, in seconds.
- `PX` milliseconds -- Set the specified expire time, in milliseconds.
- `EXAT` timestamp-seconds -- Set the specified Unix time at which the key will expire, in seconds.
- `PXAT` timestamp-milliseconds -- Set the specified Unix time at which the key will expire, in milliseconds.
- `NX` -- Only set the key if it does not already exist.
- `XX` -- Only set the key if it already exists.
- `KEEPTTL` -- Retain the time to live associated with the key.
- `GET` -- Return the old string stored at key, or nil if key did not exist. An error is returned and SET aborted if the value stored at key is not a string.

### Other Commands

- `DEL` -- Delete a key
- `ECHO` -- Echo the given string
- `TTL` -- Get the time to live for a key
- `PING` -- Ping the server
- `EXIT` -- Quit the server

### Additional Features

- **Robust Testing**: Leveraged the `test.sh` script to ensure all command combinations operate as expected.
- **Performance Benchmarking**: Introduced `benchmark.sh` for performance measurement and analysis.
- **Scalable Workers**: Easily scale worker processes using environment variables. (nodejs, singel thread give you the best perfomance)
`HOST` changes the bind address. There is no authentication; keep the learning server
on loopback. The in-memory store disappears when the primary process exits.

## Getting Started
## Implemented scope

### Prerequisites
| Area | Behavior |
| --- | --- |
| Protocol | RESP2 arrays of bulk strings; fragmented and coalesced TCP requests; binary-safe keys/values and replies. |
| Commands | `PING [message]`, `ECHO`, `GET`, `SET`, `DEL`, `TTL`, and a custom `EXIT`. |
| SET options | `NX`, `XX`, `GET`, `EX`, `PX`, `EXAT`, `PXAT`, `KEEPTTL`; conditional writes are atomic in the primary. |
| Expiry | Checked on access; updating expiry cannot be undone by an older timer. |
| Concurrency | Ordered replies per connection and correlated worker/primary IPC requests. |
| Limits | 1 MiB request/buffer limit and 1,024 command arguments; malformed frames close the connection with an error. |

Ensure [Node.js](https://nodejs.org/) and [redis-cli](https://redis.io/docs/ui/cli/) are installed on your machine.
Not implemented: persistence, replication, Redis Cluster, pub/sub, transactions,
RESP3, inline commands, authentication, eviction or production resource management.
Expired entries that are never read again remain allocated; the store has no memory
limit. Replies do not yet implement socket backpressure. These are deliberate limits
of this learning scope, not guarantees of Redis compatibility.

### Setup & Running
## Verification

1. **Clone the Repository**:
`npm test` exercises byte splits, pipelines, binary/empty values, protocol and command
errors, expiry replacement, conditional options and concurrent clients on two workers.
GitHub Actions runs these fixtures on Node 22 and 24.

```sh
git clone https://github.com/HMarzban/codecrafters-redis-javascript.git
```

2. **Start the Application:**

```sh
npm start
```

To customize the worker count:

- 4 Workers: `npm run start:worker4`
- 2 Workers: `npm run start:worker2`
- Single Worker: `npm run start:worker1`

## Testing

Run the test script to validate the command implementations:
The earlier redis-cli shell assertions remain available:

```sh
npm run test
PORT=6380 npm run test:legacy # requires a separately running server on that port
```

## Benchmarking
## Benchmark methodology

Analyze the performance of the system with the benchmarking script:
The original harness is available as `npm run benchmark -- 1000 100`. It starts a new
redis-cli process/connection for each operation, so its mean includes client startup,
shell and network costs. It does **not** measure isolated server latency. The former
package script referenced a nonexistent filename; it now invokes `benchmark.sh`.

For a persistent-client experiment, install the Redis tools and run against a separate
learning server:

```sh
npm run benchmark
redis-benchmark -h 127.0.0.1 -p 6380 -t set,get -n 10000 -c 20 -d 100 --csv
```

> Note: Ensure the node application is hitting in the background before initiating tests or benchmarks.
Record the commit, Node version, OS/CPU/RAM, worker count, client count, payload size,
request count, throughput, latency percentiles and errors. Compare the same workload
and environment, repeat runs, and retain raw output. No benchmark results or speedup
claims are supplied by this maintenance pass.

## License

I hope you find this project insightful and get a deeper understanding of how Redis works in the background. If you're curious like me, dive in and have fun learning!
The original package manifest declares MIT, but the repository has no standalone
license file. This maintenance pass does not add or change licensing terms.
36 changes: 1 addition & 35 deletions app/commands/del.js
Original file line number Diff line number Diff line change
@@ -1,36 +1,2 @@
import db from "../db.js";

const NO_KEY_PROVIDED_ERROR = "-Error: No key provided\r\n";
const KEY_NOT_FOUND_ERROR = "-Error: Key not found\r\n";
const DELETED_SUCCESS = "+OK\r\n";

/**
* Deletes one or more keys.
* For more information: https://redis.io/commands/del/
*
* @param {object} connection - Connection object.
* @param {array} data - Data array containing the keys to be deleted.
*/
const DEL = async (connection, data) => {
if (!data || data.length === 0) {
connection.write(NO_KEY_PROVIDED_ERROR);
return;
}

let deletedKeys = 0;

for (const key of data) {
if (await db.has(key)) {
await db.delete(key);
deletedKeys++;
}
}

if (data.length > 1) {
connection.write(`:${deletedKeys}\r\n`);
} else {
connection.write(deletedKeys ? DELETED_SUCCESS : KEY_NOT_FOUND_ERROR);
}
};

export default DEL;
export default async (connection, keys) => connection.write(`:${await db.delete(keys)}\r\n`);
18 changes: 2 additions & 16 deletions app/commands/echo.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,2 @@
// Constants for response format
const BULK_STRING_FORMAT = `+{data}\r\n`;

/**
* Returns the given string.
* For more information: https://redis.io/commands/echo/
*
* @param {object} connection - Connection object.
* @param {array} data - Data array containing the string to be echoed.
*/
const ECHO = (connection, data) => {
const responseData = data.join(" ");
connection.write(BULK_STRING_FORMAT.replace("{data}", responseData));
};

export default ECHO;
import { bulk } from "../resp.js";
export default (connection, [value]) => connection.write(bulk(value));
21 changes: 2 additions & 19 deletions app/commands/get.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,3 @@
import db from "../db.js";

// Constants for responses
const NULL_BULK_STRING = "$-1\r\n";

/**
* Retrieve the value of the specified key.
* For more information: https://redis.io/commands/get/
*
* @param {object} connection - Connection object.
* @param {array} data - Data array containing key as the first element.
*/
const GET = async (connection, data) => {
const key = data.at(0);
const result = await db.get(key);

connection.write(result ? `+${result.value}\r\n` : NULL_BULK_STRING);
};

export default GET;
import { bulk } from "../resp.js";
export default async (connection, [key]) => connection.write(bulk(await db.get(key)));
40 changes: 12 additions & 28 deletions app/commands/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,19 @@ import GET from "./get.js";
import ECHO from "./echo.js";
import DEL from "./del.js";
import TTL from "./ttl.js";

// Mapping commands to their corresponding action functions
import { bulk } from "../resp.js";
const commandMap = {
ping: (connection) => connection.write("+PONG\r\n"),
echo: (connection, data) => ECHO(connection, data),
set: (connection, data) => SET(connection, data),
get: (connection, data) => GET(connection, data),
del: (connection, data) => DEL(connection, data),
ttl: (connection, data) => TTL(connection, data),
exit: (connection) => {
connection.write(`You will be disconnected, PID: ${process.pid}\r\n`);
connection.end();
},
ping: [0, 1, (connection, args) => connection.write(args.length ? bulk(args[0]) : "+PONG\r\n")],
echo: [1, 1, ECHO], set: [2, Infinity, SET], get: [1, 1, GET],
del: [1, Infinity, DEL], ttl: [1, 1, TTL],
exit: [0, 0, (connection) => connection.end("+OK\r\n")],
};

/**
* Executes the given command with the provided data.
*
* @param {string} command - The command to be executed.
* @param {array} data - The data associated with the command.
* @param {object} connection - The connection object.
*/
const commands = (command, data, connection) => {
export default function commands(command, args, connection) {
const action = commandMap[command];
if (action) {
return action(connection, data);
} else {
connection.write("-Error: Unknown command\r\n");
if (!Object.hasOwn(commandMap, command)) return connection.write("-ERR unknown command\r\n");
const [minimum, maximum, execute] = action;
if (args.length < minimum || args.length > maximum) {
return connection.write("-ERR wrong number of arguments\r\n");
}
};

export default commands;
return execute(connection, args);
}
Loading
Loading