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
42 changes: 34 additions & 8 deletions bin/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import hdbLog from '../utility/logging/harper_logger.ts';
import * as systemInformation from '../utility/environment/systemInformation.ts';
import * as envMgr from '../utility/environment/environmentManager.ts';
import * as installation from '../utility/installation.ts';
import { prettyDuration } from '../utility/common_utils.ts';
envMgr.initSync();

const STATUSES = {
Expand All @@ -22,6 +23,23 @@ let hdbRoot;

export default status;

/** Uptime in ms between two epoch-ms timestamps. Both are absolute (UTC) instants, so the result is
* timezone- and DST-independent; negative spans clamp to 0. */
export function processUptimeMs(startMs: number, nowMs: number): number {
return Math.max(0, Math.round(nowMs - startMs));
}

/** Format uptime, then print the status object as YAML. */
function report(status: any): void {
if (typeof status.harperdb.uptime === 'number') {
status.harperdb.uptime = prettyDuration(status.harperdb.uptime);
}
console.log(YAML.stringify(status));
// Set exitCode rather than calling process.exit(0), which can truncate buffered stdout when the
// output is piped; the event loop drains and exits 0 on its own.
process.exitCode = 0;
Comment thread
cb1kenobi marked this conversation as resolved.
}

async function status() {
let status: any = {
harperdb: {
Expand All @@ -31,20 +49,19 @@ async function status() {

if (!installation.isHdbInstalled(envMgr, hdbLog)) {
status.harperdb.status = STATUSES.NOT_INSTALLED;
console.log(YAML.stringify(status));
return;
return report(status);
}

hdbRoot = envMgr.get(hdbTerms.CONFIG_PARAMS.ROOTPATH);
const pidFile = path.join(hdbRoot, hdbTerms.HDB_PID_FILE);
let hdbPid;
try {
hdbPid = Number.parseInt(await fs.readFile(path.join(hdbRoot, hdbTerms.HDB_PID_FILE), 'utf8'));
hdbPid = Number.parseInt(await fs.readFile(pidFile, 'utf8'));
} catch (err) {
if (err.code === hdbTerms.NODE_ERROR_CODES.ENOENT) {
hdbLog.info('`harperdb status` did not find a hdb.pid file');
// A missing pid file is the normal stopped state (clean shutdown removes it), not an error.
status.harperdb.status = STATUSES.STOPPED;
console.log(YAML.stringify(status));
return;
return report(status);
}

throw err;
Expand All @@ -56,10 +73,19 @@ async function status() {
if (proc.pid === hdbPid) {
status.harperdb.status = STATUSES.RUNNING;
status.harperdb.pid = hdbPid;
// `status` is a separate short-lived CLI process, so `process.uptime()` would report its own
// age, not the server's. The pid file is written once at startup, so its mtime is an epoch
// timestamp for when the server started — a numeric UTC instant, so subtracting it from now
// is DST-safe (no local-time string to round-trip, unlike systeminformation's `proc.started`).
try {
const { mtimeMs } = await fs.stat(pidFile);
status.harperdb.uptime = processUptimeMs(mtimeMs, Date.now());
} catch (err) {
hdbLog.warn(`\`harperdb status\` could not determine uptime: ${err}`);
}
break;
}
}

console.log(YAML.stringify(status));
process.exit(0);
return report(status);
}
58 changes: 39 additions & 19 deletions unitTests/bin/status.test.js
Original file line number Diff line number Diff line change
@@ -1,35 +1,46 @@
'use strict';

const chai = require('chai');
const assert = require('assert');
const sinon = require('sinon');
const { expect } = chai;
const fs = require('fs-extra');
const env_mgr = require('#src/utility/environment/environmentManager');
const sys_info = require('#src/utility/environment/systemInformation');
const hdb_terms = require('#src/utility/hdbTerms');
const installation = require('#src/utility/installation');
const status = require('#src/bin/status').default;
const status_module = require('#src/bin/status');
const status = status_module.default;
const { processUptimeMs } = status_module;

describe('processUptimeMs', () => {
it('derives uptime in ms between two epoch timestamps', () => {
assert.strictEqual(processUptimeMs(1_000_000, 1_000_000 + 97_702_000), 97_702_000);
});

it('rounds to the nearest ms', () => {
assert.strictEqual(processUptimeMs(0, 1500.6), 1501);
});

it('clamps a future start time to 0', () => {
assert.strictEqual(processUptimeMs(5000, 0), 0);
});
});

describe('Test status module', () => {
const sandbox = sinon.createSandbox();
const STARTED_MS = 1_700_000_000_000; // pid-file mtime (epoch ms)
let console_log_stub;
let get_hdb_process_info_stub;
let fs_stat_stub;

const fake_hdb_process_info = {
core: [
{
pid: 62076,
},
{
pid: 55297,
},
],
core: [{ pid: 62076 }, { pid: 55297 }],
};

before(() => {
console_log_stub = sandbox.stub(console, 'log');
env_mgr.setProperty(hdb_terms.CONFIG_PARAMS.ROOTPATH, 'unit-test');
sandbox.stub(fs, 'readFile').resolves('62076');
fs_stat_stub = sandbox.stub(fs, 'stat').resolves({ mtimeMs: STARTED_MS });
get_hdb_process_info_stub = sandbox.stub(sys_info, 'getHDBProcessInfo').resolves(fake_hdb_process_info);
sandbox.stub(installation, 'isHdbInstalled').returns(true);
});
Expand All @@ -40,21 +51,30 @@ describe('Test status module', () => {

afterEach(() => {
sandbox.resetHistory();
get_hdb_process_info_stub.resolves(fake_hdb_process_info);
fs_stat_stub.resolves({ mtimeMs: STARTED_MS });
});

it('Test status is returned as expected', async () => {
const process_exit_stub = sandbox.stub(process, 'exit');
it('reports running, pid, and a formatted uptime', async () => {
await status();
const output = console_log_stub.args[0][0];
assert.match(output, /status: running/);
assert.match(output, /pid: 62076/);
// Uptime is present and non-empty; the exact derivation is covered by the processUptimeMs tests.
assert.match(output, /uptime: \S/);
});

it('omits uptime when the pid file cannot be stat-ed but still reports running + pid', async () => {
fs_stat_stub.rejects(new Error('stat failed'));

await status();
process_exit_stub.restore();
expect(console_log_stub.args[0][0]).to.eql('harperdb:\n status: running\n pid: 62076\n');
assert.strictEqual(console_log_stub.args[0][0], 'harperdb:\n status: running\n pid: 62076\n');
});

it('Test status when nothing is running', async () => {
const process_exit_stub = sandbox.stub(process, 'exit');
it('reports stopped when nothing is running', async () => {
get_hdb_process_info_stub.resolves({ core: [] });

await status();
process_exit_stub.restore();
expect(console_log_stub.args[0][0]).to.eql('harperdb:\n status: stopped\n');
assert.strictEqual(console_log_stub.args[0][0], 'harperdb:\n status: stopped\n');
});
});
22 changes: 22 additions & 0 deletions unitTests/utility/common_utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,28 @@ describe('Test common_utils module', () => {
});
});

describe('Test prettyDuration', () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These newly added tests continue the legacy Chai style, while the repository requires new tests to import bare node:assert and use assert.strictEqual or assert.deepStrictEqual where needed. Please convert the added assertions while leaving the older suite untouched.

— KrAIs (Codex)

it('drops only leading zero units', () => {
assert.strictEqual(cu.prettyDuration(5000), '5s');
assert.strictEqual(cu.prettyDuration(90000), '1m 30s');
assert.strictEqual(cu.prettyDuration(97702000), '1d 3h 8m 22s');
});
it('keeps interior zero units once a larger unit is present', () => {
assert.strictEqual(cu.prettyDuration(86405000), '1d 0h 0m 5s');
});
it('floors sub-second and non-positive values to 0s', () => {
assert.strictEqual(cu.prettyDuration(0), '0s');
assert.strictEqual(cu.prettyDuration(999), '0s');
assert.strictEqual(cu.prettyDuration(-5000), '0s');
});
it('floors non-finite values to 0s', () => {
assert.strictEqual(cu.prettyDuration(NaN), '0s');
assert.strictEqual(cu.prettyDuration(Infinity), '0s');
assert.strictEqual(cu.prettyDuration(-Infinity), '0s');
assert.strictEqual(cu.prettyDuration(cu.convertToMS('abc')), '0s');
});
});

describe('Test httpRequest timeout', () => {
const http = require('node:http');
let server, port;
Expand Down
8 changes: 7 additions & 1 deletion unitTests/utility/environment/systemInformation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ const EXPECTED_PROPERTIES = {
'node_version',
'npm_version',
],
time: ['current', 'uptime', 'timezone', 'timezoneName'],
time: ['current', 'uptime', 'timezone', 'timezoneName', 'process_uptime'],
cpu: [
'manufacturer',
'brand',
Expand Down Expand Up @@ -253,6 +253,12 @@ describe('test systemInformation module', () => {
it('test getTimeInfo function', () => {
const results = system_information.getTimeInfo();
assert.deepEqual(Object.keys(results).sort(), EXPECTED_PROPERTIES.time.sort());
// process_uptime is process.uptime() in seconds (matching the sibling `uptime`), not ms.
assert.equal(typeof results.process_uptime, 'number');
assert.ok(
Math.abs(results.process_uptime - process.uptime()) <= 2,
'process_uptime should be process uptime in seconds'
);
});

it('test getCPUInfo function', async () => {
Expand Down
30 changes: 30 additions & 0 deletions utility/common_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -869,4 +869,34 @@ export function convertToMS(interval: any) {
}
return seconds * 1000;
}

/**
* Render a millisecond duration as a compact human-readable string, e.g. `1d 3h 8m 22s`. Drops only
* the leading zero units (`5000` → `5s`, `90000` → `1m 30s`) and floors sub-second, negative, and
* non-finite values (`NaN`/`Infinity`, e.g. from `convertToMS('abc')`) to `0s`. Roughly the inverse
* of {@link convertToMS}, but caps at days — years/months are ambiguous spans (leap years, 30-day
* months) and not meaningful for the elapsed-time readouts this serves.
*/
export function prettyDuration(ms: number): string {
if (!Number.isFinite(ms)) return '0s';
let seconds = Math.max(0, Math.floor(ms / 1000));
const days = Math.floor(seconds / 86400);
seconds %= 86400;
const hours = Math.floor(seconds / 3600);
seconds %= 3600;
const minutes = Math.floor(seconds / 60);
seconds %= 60;
const units: [number, string][] = [
[days, 'd'],
[hours, 'h'],
[minutes, 'm'],
[seconds, 's'],
];
const firstIdx = units.findIndex(([value]) => value > 0);
const start = firstIdx === -1 ? units.length - 1 : firstIdx;
return units
.slice(start)
.map(([value, unit]) => `${value}${unit}`)
.join(' ');
}
import * as hdbErrors from './errors/commonErrors.ts';
9 changes: 6 additions & 3 deletions utility/environment/systemInformation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,16 @@ export class SystemInformationResponse {
}
}

type TimeData = si.Systeminformation.TimeData;
type TimeData = si.Systeminformation.TimeData & { process_uptime: number };

/**
* Returns the current local time, uptime, timezone, and timezone name.
* Returns the current local time, timezone, and two uptimes in seconds: `uptime` (host uptime, from
* `si.time()`) and `process_uptime` (this Harper process's uptime). `process.uptime()` is accurate
* here because this operation runs in the Harper server process — unlike `harper status`, a separate
* CLI process.
*/
export function getTimeInfo(): TimeData {
return si.time();
return { ...si.time(), process_uptime: Math.round(process.uptime()) };
}

type CpuInfo = Pick<
Expand Down
Loading