Skip to content

Commit

Permalink
Refactor HTTP server and add mote tests
Browse files Browse the repository at this point in the history
 - Add health route
 - Handle server close gracefully
 - Add new methods to storage getMaxStorageSize and getDefaultTTL
 - Add tests got HTTP server
 - Add more API tests
 - Improve test tools
 - Run tests in one thread
 - Add placeholder for performance tests
 - Update express
  • Loading branch information
w666 committed Oct 16, 2024
1 parent 504fffd commit b8822bc
Show file tree
Hide file tree
Showing 13 changed files with 360 additions and 50 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/nodejs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ jobs:
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm run test
- run: npm run test:coverage
7 changes: 4 additions & 3 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import type { Config } from 'jest';

const config: Config = {
verbose: true,
testEnvironment: "node",
testEnvironment: 'node',
transform: {
"^.+.tsx?$": ["ts-jest", {}],
'^.+.tsx?$': ['ts-jest', {}],
},
maxWorkers: 1,
};

export default config;
export default config;
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
"build": "tsc -p .",
"start": "node ./dist/index.js",
"dev": "npm run build && npm run start",
"test": "jest --coverage"
"test": "jest --detectOpenHandles",
"test:coverage": "jest --coverage --detectOpenHandles"

},
"repository": {
"type": "git",
Expand All @@ -23,7 +25,7 @@
"author": "Vasily Martynov",
"license": "MIT",
"dependencies": {
"express": "^4.21.0",
"express": "^4.21.1",
"typescript": "^5.6.2"
},
"devDependencies": {
Expand Down
77 changes: 63 additions & 14 deletions src/kvHttpServer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import express from 'express';
import express, { Request, Response, NextFunction } from 'express';
import http from 'http';
import KVStore from './kvStore';
import { GetResponse, InvalidRequestError, PutResponse } from './types';
import { GetResponse, InvalidRequestError, PutResponse, Stats } from './types';
import { AddressInfo } from 'net';

class KVServer {
Expand All @@ -17,43 +17,78 @@ class KVServer {
this.store = new KVStore(maxStorageSize, defaultTTL);
}

private handlePutResponse(res: Response, putRes: PutResponse) {
if (putRes.putResult) {
console.log(`return 1`);
res.status(201).json(putRes);
return;
}
console.log(`return 2`);
res.status(432).json(putRes);
}

private registerRoutes() {
this.app.use(express.json());
this.app.use((_req, res, next) => {
this.app.use((_req: Request, res: Response, next: NextFunction) => {
res.header('Content-Type', 'application/json');
next();
});

this.app.route('/kv/v1/get/:key').get((req, res) => {
this.app.route('/kv/v1/health').get((_req: Request, res: Response) => {
const healthResp: Stats = {
defaultTTL: this.store.getDefaultTTL(),
maxStorageSize: this.store.getMaxStorageSize(),
storageUsed: this.store.getSize(),
};
res.json(healthResp);
});

this.app.route('/kv/v1/get/:key').get((req: Request, res: Response) => {
const key = req.params['key'];

if (!key) {
const errResp: InvalidRequestError = {
error: `Invalid request`,
message: `key must be string or number`,
};
res.status(400).json(errResp);
return;
}

const getResp: GetResponse = {
data: this.store.get(req.params.key) || null,
data: this.store.get(key) || null,
};
if (getResp.data) {
res.status(200).json(getResp);
return;
}
res.status(200).json(getResp);
res.json(getResp);
});

this.app.route('/kv/v1/put/:key').post((req, res) => {
this.app.route('/kv/v1/put/:key').post((req, res: Response) => {
const putResp: PutResponse = {
result: this.store.set(req.params.key, req.body),
putResult: this.store.set(req.params.key, req.body),
};
res.json(putResp);
this.handlePutResponse(res, putResp);
});

this.app.route('/kv/v1/put/:key/:ttl').post((req, res) => {
this.app.route('/kv/v1/put/:key/:ttl').post((req, res: Response) => {
const ttl = Number(req.params.ttl);

if (isNaN(ttl)) {
const errResp: InvalidRequestError = {
code: 400,
error: `Invalid request`,
message: `ttl parameter must be a number`,
};
res.status(400).json(errResp);
}

const putResp: PutResponse = {
result: this.store.set(req.params.key, req.body, ttl),
putResult: this.store.set(req.params.key, req.body, ttl),
};
res.json(putResp);

this.handlePutResponse(res, putResp);
});
}

Expand All @@ -67,8 +102,22 @@ class KVServer {
);
}

public stop() {
this.server?.close();
public async stop() {
if (this.server) {
let isClosed = false;

this.server.close((err: unknown) => {
if (err) {
console.log(`Failed to stop server`, err);
}
isClosed = true;
});

const waitUntil = Date.now() + 5000;
while (waitUntil > Date.now() && !isClosed) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
}

public getInstance() {
Expand Down
8 changes: 8 additions & 0 deletions src/kvStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ class KVStore {
public getSize(): number {
return this.store.size;
}

public getMaxStorageSize(): number {
return this.maxStorageSize;
}

public getDefaultTTL(): number {
return this.defaultTTL;
}
}

export default KVStore;
9 changes: 7 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@ export type GetResponse = {
};

export type PutResponse = {
result: boolean;
putResult: boolean;
};

export type InvalidRequestError = {
code: 400;
error: string;
message: string;
};

export type Stats = {
defaultTTL: number;
maxStorageSize: number;
storageUsed: number;
};
65 changes: 65 additions & 0 deletions test/performance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import KVStore from '../src/kvStore';

function getPercentile(latencyArr: number[], percentile: number) {
latencyArr.sort((a, b) => a - b);
const index = Math.ceil((percentile / 100) * latencyArr.length) - 1;
return latencyArr[index];
}

describe('Storage performance', () => {
test.skip('Add 1M items', async () => {
const store = new KVStore(1000000);

console.log(`>>> memory usage`, process.memoryUsage());

for (let i = 0; i < 1000000; i++) {
store.set(i, { someData: 'test data' });
}

console.log(`>>> memory usage`, process.memoryUsage());

const latency: number[] = [];

for (let i = 0; i < 1000000; i++) {
const start = performance.now();
store.set(i, { someData: 'test data' });
const end = performance.now();

latency.push(Math.ceil(end - start));
}

console.log(`>>> memory usage`, process.memoryUsage());

const p90 = getPercentile(latency, 90);
console.log(`90th percentile: ${p90} ms`);
const p95 = getPercentile(latency, 95);
console.log(`95th percentile: ${p95} ms`);
const p99 = getPercentile(latency, 99);
console.log(`99th percentile: ${p99} ms`);
});

test.skip('Get 1M items', async () => {
const store = new KVStore(1000000);

for (let i = 0; i < 1000000; i++) {
store.set(i, { someData: 'test data' });
}

const latency: number[] = [];

for (let i = 0; i < 1000000; i++) {
const start = performance.now();
store.get(i);
const end = performance.now();

latency.push(Math.ceil(end - start));
}

const p90 = getPercentile(latency, 90);
console.log(`90th percentile: ${p90} ms`);
const p95 = getPercentile(latency, 95);
console.log(`95th percentile: ${p95} ms`);
const p99 = getPercentile(latency, 99);
console.log(`99th percentile: ${p99} ms`);
});
});
Loading

0 comments on commit b8822bc

Please sign in to comment.