@ably/cli
Version:
Ably CLI for Pub/Sub, Chat and Spaces
487 lines (486 loc) • 24 kB
JavaScript
import { Args } from "@oclif/core";
import chalk from "chalk";
import Table from "cli-table3";
import { AblyBaseCommand } from "../../base-command.js";
export default class BenchSubscriber extends AblyBaseCommand {
static args = {
channel: Args.string({
description: "The channel name to subscribe to",
required: true,
}),
};
static description = "Run a subscriber benchmark test";
static examples = ["$ ably bench subscriber my-channel"];
static flags = {
...AblyBaseCommand.globalFlags,
};
receivedEchoCount = 0;
checkPublisherIntervalId = null;
intervalId = null;
MAX_LOG_LINES = 10;
messageLogBuffer = []; // Buffer for the last 10 logs
realtime = null;
// Track whether a test is currently running and retain a reference to the
// table that renders live status so we can update/clear it easily.
testInProgress = false;
displayTable = null;
// Override finally to ensure resources are cleaned up
async finally(err) {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
if (this.checkPublisherIntervalId) {
clearInterval(this.checkPublisherIntervalId);
this.checkPublisherIntervalId = null;
}
if (this.realtime &&
this.realtime.connection.state !== "closed" && // Check state before closing to avoid errors if already closed
this.realtime.connection.state !== "failed") {
this.realtime.close();
}
return super.finally(err);
}
async run() {
const { args, flags } = await this.parse(BenchSubscriber);
this.realtime = await this.setupClient(flags);
if (!this.realtime)
return; // Exit if client setup failed
const client = this.realtime;
let channel = null;
const metrics = {
endToEndLatencies: [],
lastMessageTime: 0,
messagesReceived: 0,
publisherActive: false,
testDetails: null,
testId: null,
testStartTime: 0,
totalLatency: 0,
};
try {
channel = this.handleChannel(client, args.channel, flags);
// Show initial status
if (!this.shouldOutputJson(flags)) {
this.log(`Attaching to channel: ${chalk.cyan(args.channel)}...`);
}
await this.handlePresence(channel, metrics, flags);
this.subscribeToMessages(channel, metrics, flags);
await this.checkInitialPresence(channel, metrics, flags);
// Emit subscriberReady event for test automation
this.logCliEvent(flags, "benchmark", "subscriberReady", `Subscriber ready on channel: ${args.channel}`, { channel: args.channel });
// Show success message
if (!this.shouldOutputJson(flags)) {
this.log(chalk.green(`✓ Subscribed to channel: ${chalk.cyan(args.channel)}. Waiting for benchmark messages...`));
}
await this.waitForTermination(flags);
}
catch (error) {
this.logCliEvent(flags, "benchmark", "testError", `Benchmark failed: ${error instanceof Error ? error.message : String(error)}`, { error: error instanceof Error ? error.stack : String(error) });
this.error(`Benchmark failed: ${error instanceof Error ? error.message : String(error)}`);
}
finally {
// Cleanup is handled by the overridden finally method
}
}
// --- Refactored Helper Methods ---
addLogToBuffer(logMessage, flags) {
if (this.shouldOutputJson(flags))
return; // Use passed flags
this.messageLogBuffer.push(`[${new Date().toLocaleTimeString()}] ${logMessage}`);
if (this.messageLogBuffer.length > this.MAX_LOG_LINES) {
this.messageLogBuffer.shift(); // Remove the oldest log
}
}
async checkInitialPresence(channel, metrics, flags) {
const members = await channel.presence.get();
const publishers = members.filter((m) => m.data &&
typeof m.data === "object" &&
"role" in m.data &&
m.data.role === "publisher");
if (publishers.length > 0) {
this.logCliEvent(flags, "benchmark", "initialPublishersFound", `Found ${publishers.length} publisher(s) already present`);
if (!this.shouldOutputJson(flags)) {
this.log(`Found ${publishers.length} publisher(s) already present`);
}
for (const publisher of publishers) {
const { data } = publisher;
if (data &&
typeof data === "object" &&
"role" in data &&
data.role === "publisher" &&
"testDetails" in data &&
"testId" in data) {
const { testDetails, testId } = data; // Destructure
this.logCliEvent(flags, "benchmark", "activeTestFound", `Found active test from existing publisher`, { testDetails, testId });
// Update metrics only if no test is currently active or if it matches
if (!metrics.testId || metrics.testId === testId) {
metrics.testDetails = testDetails;
metrics.testId = testId;
metrics.publisherActive = true;
metrics.lastMessageTime = Date.now(); // Assume active now
// If the publisher included startTime we can use it later when inferring a test
if (typeof testDetails === "object" && "startTime" in testDetails) {
metrics.testStartTime = Number(testDetails.startTime);
}
}
if (!this.shouldOutputJson(flags)) {
this.log(`Active test ID: ${metrics.testId}`);
if (metrics.testDetails) {
this.log(`Test will send ${metrics.testDetails.messageCount} messages at ${metrics.testDetails.messageRate} msg/sec using ${metrics.testDetails.transport} transport`);
}
}
}
}
}
}
createStatusDisplay(testId) {
let table;
if (!testId) {
table = new Table({
style: {
border: [], // No additional styles for the border
},
});
table.push([chalk.yellow("Waiting for benchmark test to start...")]);
return table;
}
table = new Table({
colWidths: [20, 30], // Adjust column widths
head: [chalk.white("Benchmark Test"), chalk.white(testId)],
style: {
border: [], // No additional styles for the border
head: [], // No additional styles for the header
},
});
table.push(["Messages received", "0"], ["Average latency", "0 ms"]);
return table;
}
finishTest(flags, metrics) {
if (!metrics.testId)
return;
// Calculate final statistics before logging
const testDurationSeconds = (Date.now() - metrics.testStartTime) / 1000;
metrics.endToEndLatencies.sort((a, b) => a - b);
const avgEndToEndLatency = metrics.endToEndLatencies.length > 0
? metrics.endToEndLatencies.reduce((sum, l) => sum + l, 0) /
metrics.endToEndLatencies.length
: 0;
const e2eP50 = metrics.endToEndLatencies[Math.floor(metrics.endToEndLatencies.length * 0.5)] || 0;
const e2eP90 = metrics.endToEndLatencies[Math.floor(metrics.endToEndLatencies.length * 0.9)] || 0;
const e2eP95 = metrics.endToEndLatencies[Math.floor(metrics.endToEndLatencies.length * 0.95)] || 0;
const e2eP99 = metrics.endToEndLatencies[Math.floor(metrics.endToEndLatencies.length * 0.99)] || 0;
const results = {
latencyMs: metrics.endToEndLatencies.length > 0
? {
average: Number.parseFloat(avgEndToEndLatency.toFixed(2)),
p50: Number.parseFloat(e2eP50.toFixed(2)),
p90: Number.parseFloat(e2eP90.toFixed(2)),
p95: Number.parseFloat(e2eP95.toFixed(2)),
p99: Number.parseFloat(e2eP99.toFixed(2)),
}
: null,
messagesReceived: metrics.messagesReceived,
testDurationSeconds,
testId: metrics.testId,
};
this.logCliEvent(flags, "benchmark", "testFinished", `Benchmark test ${metrics.testId} finished`, { results });
if (this.shouldOutputJson(flags)) {
// In JSON mode, output the structured results object
this.log(this.formatJsonOutput(results, flags));
return;
}
this.log("\n" + chalk.green("Benchmark Results") + "\n");
// Create a summary table
const summaryTable = new Table({
head: [chalk.white("Metric"), chalk.white("Value")],
style: {
border: [], // No additional styles for the border
head: [], // No additional styles for the header
},
});
summaryTable.push(["Test ID", metrics.testId], ["Messages received", metrics.messagesReceived.toString()], [
"Test duration",
`${((Date.now() - metrics.testStartTime) / 1000).toFixed(2)} seconds`,
]);
this.log(summaryTable.toString());
if (metrics.endToEndLatencies.length === 0) {
this.log("\nNo messages received during the test.");
return;
}
// Create a latency table
const latencyTable = new Table({
head: [chalk.white("Latency Metric"), chalk.white("Value (ms)")],
style: {
border: [], // No additional styles for the border
head: [], // No additional styles for the header
},
});
latencyTable.push(["End-to-End Average", avgEndToEndLatency.toFixed(2)], ["End-to-End P50", e2eP50.toFixed(2)], ["End-to-End P90", e2eP90.toFixed(2)], ["End-to-End P95", e2eP95.toFixed(2)], ["End-to-End P99", e2eP99.toFixed(2)]);
this.log("\nLatency Measurements:");
this.log("(Time from message creation on publisher to receipt by subscriber)");
this.log(latencyTable.toString());
}
handleChannel(client, channelName, flags) {
const channel = client.channels.get(channelName, {
params: { rewind: "1" },
});
channel.on((stateChange) => {
this.logCliEvent(flags, "channel", stateChange.current, `Channel '${channelName}' state changed to ${stateChange.current}`, { reason: stateChange.reason });
});
return channel;
}
async handlePresence(channel, metrics, flags) {
this.logCliEvent(flags, "presence", "enteringPresence", `Entering presence as subscriber on channel: ${channel.name}`);
await channel.presence.enter({ role: "subscriber" });
this.logCliEvent(flags, "presence", "presenceEntered", `Entered presence as subscriber on channel: ${channel.name}`);
// --- Presence Enter Handler ---
channel.presence.subscribe("enter", (member) => {
const { clientId, data } = member; // Destructure member
this.logCliEvent(flags, "presence", "memberEntered", `Member entered presence: ${clientId}`, { clientId, data });
if (data &&
typeof data === "object" &&
"role" in data &&
data.role === "publisher" &&
"testDetails" in data &&
"testId" in data) {
const { testDetails, testId } = data; // Destructure data
this.logCliEvent(flags, "benchmark", "publisherDetected", `Publisher detected with test ID: ${testId}`, { testDetails, testId });
metrics.testDetails = testDetails;
metrics.publisherActive = true;
metrics.lastMessageTime = Date.now();
// Do not start a new test here, wait for the first message
if (!this.shouldOutputJson(flags)) {
this.log(`\nPublisher detected with test ID: ${testId}`);
this.log(`Test will send ${testDetails.messageCount} messages at ${testDetails.messageRate} msg/sec using ${testDetails.transport} transport`);
}
}
});
// --- Presence Leave Handler ---
channel.presence.subscribe("leave", (member) => {
const { clientId, data } = member; // Destructure member
this.logCliEvent(flags, "presence", "memberLeft", `Member left presence: ${clientId}`, { clientId });
if (data &&
typeof data === "object" &&
"role" in data &&
data.role === "publisher") {
const { testId } = data || {};
// Only finish the test if the leaving publisher matches the current test (or we don't know yet)
if (metrics.testId && testId && testId !== metrics.testId) {
return; // different test, ignore
}
this.logCliEvent(flags, "benchmark", "publisherLeft", `Publisher has left. Finishing test.`, { testId });
metrics.publisherActive = false;
this.finishTest(flags, metrics);
this.testInProgress = false;
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
if (this.checkPublisherIntervalId) {
clearInterval(this.checkPublisherIntervalId);
this.checkPublisherIntervalId = null;
}
if (this.shouldOutputJson(flags)) {
this.logCliEvent(flags, "benchmark", "waitingForTest", "Waiting for a new benchmark test to start...");
}
else {
this.log("\nWaiting for a new benchmark test to start...");
this.displayTable = this.createStatusDisplay(null);
this.log(this.displayTable.toString());
}
this.displayTable = null;
this.testInProgress = false;
}
});
}
resetDisplay(displayTable) {
// Skip terminal control in CI/test mode
if (this.shouldUseTerminalUpdates()) {
process.stdout.write("\u001B[2J\u001B[0f"); // Clear screen, move cursor
}
this.log(displayTable.toString());
this.log("\n--- Logs (Last 10) ---");
}
async setupClient(flags) {
const realtime = await this.createAblyRealtimeClient(flags);
if (!realtime) {
this.error("Failed to create Ably client. Please check your API key and try again.");
return null;
}
// Set up connection state logging
this.setupConnectionStateLogging(realtime, flags, {
includeUserFriendlyMessages: true,
});
return realtime;
}
// --- Original Private Methods ---
startNewTest(metrics, testId, startTime, flags) {
this.logCliEvent(flags, "benchmark", "newTestDetected", `New benchmark test detected with ID: ${testId}`, { testId });
metrics.messagesReceived = 0;
metrics.totalLatency = 0;
metrics.endToEndLatencies = [];
metrics.testId = testId;
metrics.testStartTime = startTime;
metrics.publisherActive = true;
metrics.lastMessageTime = startTime;
this.testInProgress = true;
// Create or reset the live status display table (only for non-JSON output)
if (!this.shouldOutputJson(flags)) {
this.displayTable = this.createStatusDisplay(testId);
this.resetDisplay(this.displayTable);
}
// Clear previous intervals if they exist
if (this.intervalId)
clearInterval(this.intervalId);
if (this.checkPublisherIntervalId)
clearInterval(this.checkPublisherIntervalId);
// Setup new progress interval
if (this.shouldOutputJson(flags)) {
this.intervalId = setInterval(() => {
this.logCliEvent(flags, "benchmark", "testProgress", "Benchmark test in progress", {
avgLatencyMs: metrics.endToEndLatencies.length > 0
? (metrics.endToEndLatencies.reduce((sum, l) => sum + l, 0) /
metrics.endToEndLatencies.length).toFixed(1)
: 0,
messagesReceived: metrics.messagesReceived,
testId: metrics.testId,
});
}, 2000);
}
else {
// Display update interval is handled implicitly by the message handler calling resetDisplay/updateStatusAndLogs
// We need an interval just to call the update function periodically if no messages are received
this.intervalId = setInterval(() => {
this.updateStatusAndLogs(this.displayTable, metrics);
}, 500);
}
}
startPublisherCheckInterval(metrics, flags, onInactive) {
if (this.checkPublisherIntervalId) {
clearInterval(this.checkPublisherIntervalId);
}
this.checkPublisherIntervalId = setInterval(() => {
const publisherInactiveTime = Date.now() - metrics.lastMessageTime;
if (publisherInactiveTime > 5000 && metrics.publisherActive) {
this.logCliEvent(flags, "benchmark", "publisherInactive", `Publisher seems inactive (no messages for ${(publisherInactiveTime / 1000).toFixed(1)}s)`, { testId: metrics.testId });
metrics.publisherActive = false;
this.finishTest(flags, metrics);
onInactive(); // Update state in the calling context
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
if (this.checkPublisherIntervalId) {
clearInterval(this.checkPublisherIntervalId);
this.checkPublisherIntervalId = null;
}
if (this.shouldOutputJson(flags)) {
this.logCliEvent(flags, "benchmark", "waitingForTest", "Waiting for a new benchmark test to start...");
}
else {
this.log("\nWaiting for a new benchmark test to start...");
this.displayTable = this.createStatusDisplay(null);
this.log(this.displayTable.toString());
}
// Clear current display so that a new test can recreate it
this.displayTable = null;
this.testInProgress = false;
}
}, 1000);
}
subscribeToMessages(channel, metrics, flags) {
channel.subscribe((message) => {
const currentTime = Date.now();
// Check if this message is the start of a new test
if (message.data.type === "start" &&
message.data.testId !== metrics.testId) {
this.startNewTest(metrics, message.data.testId, message.data.startTime, flags);
// Initialize publisher check only when a test starts
this.startPublisherCheckInterval(metrics, flags, () => this.finishTest(flags, metrics));
this.logCliEvent(flags, "benchmark", "testStarted", `Benchmark test started with ID: ${metrics.testId}`, { testId: metrics.testId });
const logMsg = `Benchmark test started: ${metrics.testId}`;
this.addLogToBuffer(logMsg, flags); // Pass flags here
}
else if (message.data.type === "message") {
// If we missed the 'start' envelope (subscriber started late), initialise on first message
if (!this.testInProgress || metrics.testId === null) {
this.startNewTest(metrics, message.data.testId, message.data.timestamp, // best approximation if startTime unknown
flags);
this.startPublisherCheckInterval(metrics, flags, () => this.finishTest(flags, metrics));
this.logCliEvent(flags, "benchmark", "testStartedLate", `Benchmark test inferred from first message with ID: ${metrics.testId}`, { testId: metrics.testId });
}
if (message.data.testId !== metrics.testId) {
// Ignore stray messages from previous/future tests
return;
}
metrics.messagesReceived += 1;
metrics.lastMessageTime = currentTime;
const endToEndLatency = currentTime - message.data.timestamp;
metrics.endToEndLatencies.push(endToEndLatency);
metrics.totalLatency += endToEndLatency;
const logMsg = `Received message ${message.id} (e2e: ${endToEndLatency}ms)`;
this.addLogToBuffer(logMsg, flags);
if (!this.shouldOutputJson(flags)) {
this.updateStatusAndLogs(this.displayTable, metrics);
}
}
else if (message.data.type === "end" &&
message.data.testId === metrics.testId) {
// Explicit end-of-test control message – finish even if testInProgress flag somehow false
this.finishTest(flags, metrics);
this.testInProgress = false;
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
});
this.logCliEvent(flags, "benchmark", "subscribedToMessages", `Subscribed to benchmark messages on channel '${channel.name}'`);
}
// New combined update function
updateStatusAndLogs(displayTable, metrics) {
if (this.shouldOutputJson({}))
return;
// Fallback to the command's stored table reference if none provided
const tableRef = displayTable ?? this.displayTable;
if (!tableRef || !metrics.testId)
return;
// Calculate average latency from most recent messages
const recentCount = Math.min(metrics.messagesReceived, 50);
const recentLatencies = metrics.endToEndLatencies.slice(-recentCount);
const avgLatency = recentLatencies.length > 0
? recentLatencies.reduce((sum, l) => sum + l, 0) /
recentLatencies.length
: 0;
// Create updated table data
const newTableData = [
["Messages received", metrics.messagesReceived.toString()],
["Average latency", `${avgLatency.toFixed(1)} ms`],
];
// Clear console and redraw
if (this.shouldUseTerminalUpdates()) {
process.stdout.write("\u001B[2J\u001B[0f"); // Clear screen, move cursor
}
// Recreate table with updated data
const updatedTable = new Table({
colWidths: [20, 30],
head: [chalk.white("Benchmark Test"), chalk.white(metrics.testId || "")],
style: {
border: [],
head: [],
},
});
updatedTable.push(...newTableData);
this.log(updatedTable.toString());
this.log("\n--- Logs (Last 10) ---");
for (const log of this.messageLogBuffer)
this.log(log);
}
async waitForTermination(_flags) {
// Keep the connection open indefinitely until Ctrl+C
await new Promise(() => {
/* Never resolves */
});
}
}