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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Steem Load Balancer
[![Build and Tests](https://github.com/DoctorLai/steem-load-balancer/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/DoctorLai/steem-load-balancer/actions/workflows/ci.yaml) [![Test Coverage](https://github.com/DoctorLai/steem-load-balancer/actions/workflows/coverage.yaml/badge.svg)](https://github.com/DoctorLai/steem-load-balancer/actions/workflows/coverage.yaml)
[![Build and Tests](https://github.com/DoctorLai/steem-load-balancer/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/DoctorLai/steem-load-balancer/actions/workflows/ci.yaml) [![Test Coverage](https://github.com/DoctorLai/steem-load-balancer/actions/workflows/coverage.yaml/badge.svg)](https://github.com/DoctorLai/steem-load-balancer/actions/workflows/coverage.yaml) [![Docker Pulls](https://img.shields.io/docker/pulls/justyy/steem-load-balancer)](https://hub.docker.com/r/justyy/steem-load-balancer) [![Docker Image Size](https://img.shields.io/docker/image-size/justyy/steem-load-balancer/latest)](https://hub.docker.com/r/justyy/steem-load-balancer) [![Docker Stars](https://img.shields.io/docker/stars/justyy/steem-load-balancer)](https://hub.docker.com/r/justyy/steem-load-balancer)

Here is the [AI-generated documentation](https://deepwiki.com/DoctorLai/steem-load-balancer/) by Deep-Wiki.

Expand Down
118 changes: 118 additions & 0 deletions js_tests/counters.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { Counters, REQUEST_WINDOW_MS } from "../src/counters.js";

describe("Counters", () => {
let counters;

beforeEach(() => {
counters = new Counters();
});

test("starts with empty/zeroed state", () => {
expect(counters.total).toBe(0);
expect(counters.maxJussi).toBe(-1);
expect(counters.access.size).toBe(0);
expect(counters.error.size).toBe(0);
expect(counters.notChosen.size).toBe(0);
expect(counters.jussiBehind.size).toBe(0);
expect(counters.timedOut.size).toBe(0);
expect(counters.requestTimestamps).toEqual([]);
});

test("incrementAccess counts per server", async () => {
await counters.incrementAccess("https://a.com");
await counters.incrementAccess("https://a.com");
await counters.incrementAccess("https://b.com");
expect(counters.access.get("https://a.com")).toBe(2);
expect(counters.access.get("https://b.com")).toBe(1);
});

test("per-server counters are independent", async () => {
await counters.incrementError("s");
await counters.incrementNotChosen("s");
await counters.incrementJussiBehind("s");
await counters.incrementTimedOut("s");
expect(counters.error.get("s")).toBe(1);
expect(counters.notChosen.get("s")).toBe(1);
expect(counters.jussiBehind.get("s")).toBe(1);
expect(counters.timedOut.get("s")).toBe(1);
});

test("incrementTotal increments the global counter", async () => {
await counters.incrementTotal();
await counters.incrementTotal();
expect(counters.total).toBe(2);
});

test("concurrent increments are serialized by the mutex", async () => {
await Promise.all(
Array.from({ length: 100 }, () => counters.incrementTotal()),
);
expect(counters.total).toBe(100);

await Promise.all(
Array.from({ length: 50 }, () => counters.incrementAccess("x")),
);
expect(counters.access.get("x")).toBe(50);
});

test("updateMaxJussi keeps the highest value seen", async () => {
await counters.updateMaxJussi(100);
expect(counters.maxJussi).toBe(100);
await counters.updateMaxJussi(50);
expect(counters.maxJussi).toBe(100);
await counters.updateMaxJussi(150);
expect(counters.maxJussi).toBe(150);
});

test("recordRequest drops timestamps older than the tracking window", () => {
const now = 1_000_000_000;
counters.recordRequest(now - REQUEST_WINDOW_MS - 1); // expired
counters.recordRequest(now - 1000); // recent
counters.recordRequest(now); // current -> prunes using cutoff `now - window`
expect(counters.requestTimestamps).toEqual([now - 1000, now]);
});

test("calculateRPS computes per-second rates across the windows", () => {
const now = Date.now();
for (let i = 0; i < 60; i++) {
counters.requestTimestamps.push(now);
}
const rps = counters.calculateRPS();
expect(Object.keys(rps)).toEqual(["1min", "5min", "15min"]);
expect(rps["1min"]).toBe(1); // 60 / (1 * 60)
expect(rps["5min"]).toBe(0.2); // 60 / (5 * 60)
expect(rps["15min"]).toBe(0.07); // 60 / (15 * 60), rounded
});

test("getAccessPercentages reports percentage and count per server", async () => {
await counters.incrementAccess("a");
await counters.incrementAccess("a");
await counters.incrementAccess("b");
await counters.incrementTotal();
await counters.incrementTotal();
await counters.incrementTotal();
await counters.incrementTotal();
expect(counters.getAccessPercentages()).toEqual({
a: { percent: 50, count: 2 },
b: { percent: 25, count: 1 },
});
});

test("getErrorPercentages reports error/success rates per server", async () => {
await counters.incrementAccess("a");
await counters.incrementAccess("a");
await counters.incrementError("a");
expect(counters.getErrorPercentages()).toEqual({
a: { errRate: 50, total: 2, errorCount: 1, succRate: 50 },
});
});

test("getNotChosen/getJussiBehind/getTimedOut return plain objects", async () => {
await counters.incrementNotChosen("a");
await counters.incrementJussiBehind("b");
await counters.incrementTimedOut("c");
expect(counters.getNotChosen()).toEqual({ a: 1 });
expect(counters.getJussiBehind()).toEqual({ b: 1 });
expect(counters.getTimedOut()).toEqual({ c: 1 });
});
});
207 changes: 207 additions & 0 deletions js_tests/health-check.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { createGetServerData } from "../src/health-check.js";

jest.mock("../src/network.js", () => ({
fetchWithTimeout: jest.fn(),
}));
jest.mock("../src/functions.js", () => {
const actual = jest.requireActual("../src/functions.js");
return { ...actual, log: jest.fn() };
});

import { fetchWithTimeout } from "../src/network.js";
import { Counters } from "../src/counters.js";

const SERVER = "https://node.example.com";
const TIMEOUT = 5000;
const MIN_VERSION = "0.23.0";
const MAX_JUSSI_DIFF = 100;

function makeCounters() {
const counters = new Counters();
jest.spyOn(counters, "incrementNotChosen");
jest.spyOn(counters, "incrementJussiBehind");
jest.spyOn(counters, "incrementTimedOut");
jest.spyOn(counters, "updateMaxJussi");
return counters;
}

function makeGetServerData(counters) {
return createGetServerData({
agent: false,
timeout: TIMEOUT,
userAgent: "jest-agent",
minBlockchainVersion: MIN_VERSION,
maxJussiNumberDiff: MAX_JUSSI_DIFF,
counters,
});
}

function makeOkVersionResponse(version = "0.23.1") {
return {
ok: true,
json: jest.fn().mockResolvedValue({
result: { blockchain_version: version },
}),
};
}

function makeOkJussiResponse(jussiNum = 1000200) {
return {
ok: true,
json: jest.fn().mockResolvedValue({
status: "OK",
jussi_num: jussiNum,
}),
};
}

describe("createGetServerData", () => {
let counters;
let getServerData;

beforeEach(() => {
jest.clearAllMocks();
counters = makeCounters();
getServerData = makeGetServerData(counters);
});

test("returns server info on a fully successful probe", async () => {
fetchWithTimeout
.mockResolvedValueOnce({ response: makeOkVersionResponse(), latency: 10 })
.mockResolvedValueOnce({ response: makeOkJussiResponse(), latency: 8 });

const result = await getServerData(SERVER);

expect(result.server).toBe(SERVER);
expect(result.jussi_number).toBe(1000200);
expect(typeof result.latencyMs).toBe("number");
expect(counters.incrementTimedOut).not.toHaveBeenCalled();
expect(counters.incrementNotChosen).not.toHaveBeenCalled();
expect(counters.incrementJussiBehind).not.toHaveBeenCalled();
});

test("increments notChosen and throws when version response is not ok", async () => {
fetchWithTimeout
.mockResolvedValueOnce({
response: { ok: false, status: 503 },
latency: 5,
})
.mockResolvedValueOnce({ response: makeOkJussiResponse(), latency: 5 });

await expect(getServerData(SERVER)).rejects.toThrow();
expect(counters.incrementNotChosen).toHaveBeenCalledWith(SERVER);
});

test("increments notChosen and throws when jussi response is not ok", async () => {
fetchWithTimeout
.mockResolvedValueOnce({ response: makeOkVersionResponse(), latency: 5 })
.mockResolvedValueOnce({
response: { ok: false, status: 503 },
latency: 5,
});

await expect(getServerData(SERVER)).rejects.toThrow();
expect(counters.incrementNotChosen).toHaveBeenCalledWith(SERVER);
});

test("increments notChosen and throws on empty version response", async () => {
const versionResponse = { ok: true, json: jest.fn().mockResolvedValue({}) };
fetchWithTimeout
.mockResolvedValueOnce({ response: versionResponse, latency: 5 })
.mockResolvedValueOnce({ response: makeOkJussiResponse(), latency: 5 });

await expect(getServerData(SERVER)).rejects.toThrow();
expect(counters.incrementNotChosen).toHaveBeenCalledWith(SERVER);
});

test("increments notChosen and throws when blockchain version is too low", async () => {
fetchWithTimeout
.mockResolvedValueOnce({
response: makeOkVersionResponse("0.22.0"),
latency: 5,
})
.mockResolvedValueOnce({ response: makeOkJussiResponse(), latency: 5 });

await expect(getServerData(SERVER)).rejects.toThrow();
expect(counters.incrementNotChosen).toHaveBeenCalledWith(SERVER);
});

test("increments notChosen and throws on empty jussi response", async () => {
const jussiResponse = { ok: true, json: jest.fn().mockResolvedValue(null) };
fetchWithTimeout
.mockResolvedValueOnce({ response: makeOkVersionResponse(), latency: 5 })
.mockResolvedValueOnce({ response: jussiResponse, latency: 5 });

await expect(getServerData(SERVER)).rejects.toThrow();
expect(counters.incrementNotChosen).toHaveBeenCalledWith(SERVER);
});

test("increments notChosen and throws when jussi status is not OK", async () => {
const jussiResponse = {
ok: true,
json: jest
.fn()
.mockResolvedValue({ status: "ERROR", jussi_num: 1000200 }),
};
fetchWithTimeout
.mockResolvedValueOnce({ response: makeOkVersionResponse(), latency: 5 })
.mockResolvedValueOnce({ response: jussiResponse, latency: 5 });

await expect(getServerData(SERVER)).rejects.toThrow();
expect(counters.incrementNotChosen).toHaveBeenCalledWith(SERVER);
});

test("increments jussiBehind and throws when jussi_number is 20000000", async () => {
fetchWithTimeout
.mockResolvedValueOnce({ response: makeOkVersionResponse(), latency: 5 })
.mockResolvedValueOnce({
response: makeOkJussiResponse(20000000),
latency: 5,
});

await expect(getServerData(SERVER)).rejects.toThrow();
expect(counters.incrementJussiBehind).toHaveBeenCalledWith(SERVER);
expect(counters.incrementNotChosen).not.toHaveBeenCalled();
});

test("increments jussiBehind and throws when node is too far behind", async () => {
// Set maxJussi to a high value so the node appears behind
await counters.updateMaxJussi(1001000);
// jussi_number is more than MAX_JUSSI_DIFF behind maxJussi
fetchWithTimeout
.mockResolvedValueOnce({ response: makeOkVersionResponse(), latency: 5 })
.mockResolvedValueOnce({
response: makeOkJussiResponse(1000000 - MAX_JUSSI_DIFF - 1),
latency: 5,
});

await expect(getServerData(SERVER)).rejects.toThrow();
expect(counters.incrementJussiBehind).toHaveBeenCalledWith(SERVER);
});

test("increments timedOut when fetchWithTimeout throws a timeout error (message includes 'timed out')", async () => {
const timeoutErr = new Error(
`Fetch request to ${SERVER} timed out after ${TIMEOUT} ms`,
);
fetchWithTimeout.mockRejectedValue(timeoutErr);

await expect(getServerData(SERVER)).rejects.toThrow();
expect(counters.incrementTimedOut).toHaveBeenCalledWith(SERVER);
});

test("increments timedOut when fetchWithTimeout throws an AbortError", async () => {
const abortErr = new Error("The operation was aborted");
abortErr.name = "AbortError";
fetchWithTimeout.mockRejectedValue(abortErr);

await expect(getServerData(SERVER)).rejects.toThrow();
expect(counters.incrementTimedOut).toHaveBeenCalledWith(SERVER);
});

test("does not increment timedOut for generic network errors", async () => {
fetchWithTimeout.mockRejectedValue(new Error("Network failure"));

await expect(getServerData(SERVER)).rejects.toThrow("Network failure");
expect(counters.incrementTimedOut).not.toHaveBeenCalled();
});
});
Loading
Loading