mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-10-20 14:47:18 +02:00
perf: avoid needless downloads and track request latency (#876)
* perf: avoid needless downloads and track request latency * fix: correct request timing and monitoring revalidation
This commit is contained in:
1 parent
db966356ec
commit
a9760eab39
28 files changed
+1208
-73
No files matched your search
@@ -0,0 +1,107 @@
|
||||
const { expect } = require("chai");
|
||||
const express = require("express");
|
||||
const http = require("node:http");
|
||||
const { Readable } = require("node:stream");
|
||||
require("ts-node/register/transpile-only");
|
||||
const utils = require("../src/server/routes/route-utils");
|
||||
const AnonymizedFile = require("../src/core/AnonymizedFile").default;
|
||||
const fileRouter = require("../src/server/routes/file").default;
|
||||
const webRouter = require("../src/server/routes/webview").default;
|
||||
const config = require("../src/config").default;
|
||||
|
||||
describe("download headers and website revalidation", function () {
|
||||
let server, restore, repo, transfers, blobSha;
|
||||
beforeEach(async function () {
|
||||
const originalGetRepo = utils.getRepo;
|
||||
const originals = Object.fromEntries(["originalPath", "sha", "size", "send", "getFileInfo", "anonymizedContent"].map(key => [key, AnonymizedFile.prototype[key]]));
|
||||
restore = () => { utils.getRepo = originalGetRepo; Object.assign(AnonymizedFile.prototype, originals); };
|
||||
transfers = 0; blobSha = "first";
|
||||
repo = { repoId: "fixture", options: { terms: ["author"], image: true, pdf: true, page: true, pageSource: { branch: "main", path: "/" } },
|
||||
model: { source: { commit: "commit", branch: "main" }, treeGeneration: "first" }, isReady: async () => true, countView: async () => {} };
|
||||
utils.getRepo = async () => repo;
|
||||
AnonymizedFile.prototype.originalPath = async function () { return this.anonymizedPath; };
|
||||
AnonymizedFile.prototype.sha = async () => blobSha;
|
||||
AnonymizedFile.prototype.size = async () => 10;
|
||||
AnonymizedFile.prototype.getFileInfo = async () => ({ name: "page.md", path: "", size: 10 });
|
||||
AnonymizedFile.prototype.send = async function (res) { transfers++; res.send("fixture content"); };
|
||||
AnonymizedFile.prototype.anonymizedContent = async () => { transfers++; return Readable.from(["# Anonymized page"]); };
|
||||
const app = express(); app.use("/api/repo", fileRouter); app.use("/w", webRouter);
|
||||
server = await new Promise(resolve => { const s = app.listen(0, "127.0.0.1", () => resolve(s)); });
|
||||
});
|
||||
afterEach(async function () { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); restore(); });
|
||||
function fetch(path, method = "GET", headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.request({ host: "127.0.0.1", port: server.address().port, path, method, headers }, res => {
|
||||
let body = ""; res.on("data", chunk => { body += chunk; });
|
||||
res.on("end", () => resolve({ status: res.statusCode, headers: res.headers, body }));
|
||||
}).on("error", reject).end();
|
||||
});
|
||||
}
|
||||
it("serves file HEAD metadata without reading content", async function () {
|
||||
const response = await fetch("/api/repo/fixture/file/data.txt", "HEAD");
|
||||
expect(response.status).to.equal(200); expect(response.body).to.equal("");
|
||||
expect(response.headers["etag"]).to.match(/^"f-/); expect(response.headers["content-type"]).to.include("text/plain");
|
||||
expect(transfers).to.equal(0);
|
||||
});
|
||||
it("preserves content gates for HEAD", async function () {
|
||||
repo.options.pdf = false;
|
||||
expect((await fetch("/api/repo/fixture/file/private.pdf", "HEAD")).status).to.equal(403);
|
||||
expect(transfers).to.equal(0);
|
||||
});
|
||||
it("rejects oversized file HEAD without downloading", async function () {
|
||||
AnonymizedFile.prototype.size = async () => config.MAX_FILE_SIZE + 1;
|
||||
expect((await fetch("/api/repo/fixture/file/huge.txt", "HEAD")).status).to.equal(413); expect(transfers).to.equal(0);
|
||||
});
|
||||
it("accepts weak and multiple validators without transferring file content", async function () {
|
||||
const first = await fetch("/api/repo/fixture/file/data.txt", "HEAD");
|
||||
const response = await fetch("/api/repo/fixture/file/data.txt", "GET", { "If-None-Match": `"unrelated", W/${first.headers.etag}` });
|
||||
expect(response.status).to.equal(304); expect(transfers).to.equal(0);
|
||||
});
|
||||
it("revalidates a Markdown website before fetching or rendering it again", async function () {
|
||||
const first = await fetch("/w/fixture/page.md");
|
||||
expect(first.status).to.equal(200); expect(first.body).to.include("Anonymized page"); expect(transfers).to.equal(1);
|
||||
const second = await fetch("/w/fixture/page.md", "GET", { "If-None-Match": `W/${first.headers.etag}` });
|
||||
expect(second.status).to.equal(304); expect(second.body).to.equal(""); expect(transfers).to.equal(1);
|
||||
expect(second.headers["content-security-policy"]).to.include("sandbox");
|
||||
});
|
||||
it("renders Markdown again after a transient failure served the raw fallback", async function () {
|
||||
const marked = require("marked"), render = marked.marked;
|
||||
let first;
|
||||
try {
|
||||
marked.marked = () => { throw Error("transient rendering failure"); };
|
||||
first = await fetch("/w/fixture/page.md");
|
||||
expect(first.status).to.equal(200); expect(first.body).to.equal("fixture content");
|
||||
} finally { marked.marked = render; }
|
||||
const recovered = await fetch("/w/fixture/page.md", "GET", { "If-None-Match": first.headers.etag });
|
||||
expect(recovered.status).to.equal(200); expect(recovered.body).to.include("Anonymized page");
|
||||
expect(recovered.headers.etag).not.to.equal(first.headers.etag);
|
||||
const revalidated = await fetch("/w/fixture/page.md", "GET", { "If-None-Match": recovered.headers.etag });
|
||||
expect(revalidated.status).to.equal(304); expect(transfers).to.equal(3);
|
||||
});
|
||||
it("serves website HEAD without reading or rendering Markdown", async function () {
|
||||
const response = await fetch("/w/fixture/page.md", "HEAD");
|
||||
expect(response.status).to.equal(200); expect(response.headers["content-type"]).to.include("text/html"); expect(transfers).to.equal(0);
|
||||
});
|
||||
it("serves TypeScript website HEAD with the same text MIME policy as GET", async function () {
|
||||
const response = await fetch("/w/fixture/code.ts", "HEAD");
|
||||
expect(response.status).to.equal(200); expect(response.headers["content-type"]).to.include("text/plain"); expect(transfers).to.equal(0);
|
||||
});
|
||||
for (const change of ["terms", "commit", "tree", "cache", "blob", "source name"]) {
|
||||
it(`invalidates the website validator when ${change} changes`, async function () {
|
||||
const first = await fetch("/w/fixture/page.md");
|
||||
if (change === "terms") repo.options.terms = ["new author"];
|
||||
if (change === "commit") repo.model.source.commit = "new commit";
|
||||
if (change === "tree") repo.model.treeGeneration = "new tree";
|
||||
if (change === "cache") repo.model.contentCacheRevision = "new cache";
|
||||
if (change === "blob") blobSha = "new blob";
|
||||
if (change === "source name") repo.model.source.repositoryName = "new/source";
|
||||
const second = await fetch("/w/fixture/page.md", "GET", { "If-None-Match": first.headers.etag });
|
||||
expect(second.status).to.equal(200); expect(second.headers.etag).not.to.equal(first.headers.etag); expect(transfers).to.equal(2);
|
||||
});
|
||||
}
|
||||
it("checks website permissions before honoring an old validator", async function () {
|
||||
const first = await fetch("/w/fixture/page.md"); repo.options.page = false;
|
||||
expect((await fetch("/w/fixture/page.md", "GET", { "If-None-Match": first.headers.etag })).status).to.equal(400);
|
||||
expect(transfers).to.equal(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
const { expect } = require("chai");
|
||||
const express = require("express");
|
||||
const http = require("node:http");
|
||||
require("ts-node/register/transpile-only");
|
||||
const utils = require("../src/server/routes/route-utils");
|
||||
const admin = require("../src/server/routes/admin").default;
|
||||
|
||||
describe("performance report access", function () {
|
||||
let server, original;
|
||||
before(async function () {
|
||||
original = utils.getUser;
|
||||
utils.getUser = async req => ({ isAdmin: req.get("x-fixture-user") === "admin", model: { id: "fixture" } });
|
||||
const app = express();
|
||||
app.use((req, _res, next) => { req.isAuthenticated = () => !!req.get("x-fixture-user"); next(); });
|
||||
app.use("/api/admin", admin);
|
||||
server = await new Promise(resolve => { const s = app.listen(0, "127.0.0.1", () => resolve(s)); });
|
||||
});
|
||||
after(async function () { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); utils.getUser = original; });
|
||||
async function request(user) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get({ host: "127.0.0.1", port: server.address().port, path: "/api/admin/performance", headers: user ? { "x-fixture-user": user } : {} }, res => {
|
||||
let body = ""; res.on("data", chunk => { body += chunk; }); res.on("end", () => resolve({ status: res.statusCode, body, headers: res.headers }));
|
||||
}).on("error", reject);
|
||||
});
|
||||
}
|
||||
it("rejects anonymous requests", async function () { expect((await request()).status).to.equal(401); });
|
||||
it("rejects non-admin users", async function () { expect((await request("regular")).status).to.equal(401); });
|
||||
it("allows admins and disables HTTP caching", async function () {
|
||||
const result = await request("admin"); expect(result.status).to.equal(200);
|
||||
expect(JSON.parse(result.body).available).to.equal(false); expect(result.headers["cache-control"]).to.equal("no-store");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
const { expect } = require("chai");
|
||||
const { createClient } = require("redis");
|
||||
const net = require("node:net");
|
||||
const { setTimeout } = require("node:timers");
|
||||
require("ts-node/register/transpile-only");
|
||||
const config = require("../src/config").default;
|
||||
const { requestMetrics } = require("../src/core/request-monitoring");
|
||||
const { startPerformanceMonitoring, flushPerformanceMetrics, performanceReport } = require("../src/core/performance-monitoring");
|
||||
const port = Number(process.env.PERFORMANCE_REDIS_PORT);
|
||||
|
||||
(port ? describe : describe.skip)("request monitoring persistence", function () {
|
||||
this.timeout(15000);
|
||||
let stop, redis, previous;
|
||||
before(async function () {
|
||||
previous = { host: config.REDIS_HOSTNAME, port: config.REDIS_PORT };
|
||||
config.REDIS_HOSTNAME = "127.0.0.1"; config.REDIS_PORT = port;
|
||||
redis = createClient({ socket: { host: "127.0.0.1", port, reconnectStrategy: false } }); await redis.connect();
|
||||
});
|
||||
beforeEach(async function () {
|
||||
const keys = [];
|
||||
for await (const key of redis.scanIterator({ MATCH: "performance:v1:*" })) keys.push(key);
|
||||
if (keys.length) await redis.del(keys);
|
||||
requestMetrics.drain();
|
||||
});
|
||||
afterEach(async function () { if (stop) { await stop(); stop = null; } requestMetrics.drain(); });
|
||||
after(async function () { await redis.disconnect(); config.REDIS_HOSTNAME = previous.host; config.REDIS_PORT = previous.port; });
|
||||
async function start(service) {
|
||||
stop = startPerformanceMonitoring(service, () => ({ running: 0, waiting: 0 }));
|
||||
const deadline = Date.now() + 4000;
|
||||
while (!(await performanceReport(15)).available) {
|
||||
if (Date.now() > deadline) throw Error("Monitoring did not connect");
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
}
|
||||
}
|
||||
it("batches latency, failures, interruptions and resource history with bounded retention", async function () {
|
||||
await start("api");
|
||||
requestMetrics.observe("request", "/api/repo/:repoId/zip", "GET", 8000, { bytes: 12345 });
|
||||
requestMetrics.observe("request", "/api/repo/:repoId/zip", "GET", 1500, { aborted: true, error: true });
|
||||
requestMetrics.observe("upstream", "all", "all", 90);
|
||||
await flushPerformanceMetrics();
|
||||
const result = await performanceReport(15);
|
||||
expect(result.available).to.equal(true); expect(result.routes).to.have.length(1);
|
||||
expect(result.routes[0]).to.include({ count: 2, aborted: 1, errors: 1, bytes: 12345, avgMs: 4750, p95UpperMs: 10000 });
|
||||
expect(result.stages[0]).to.include({ metric: "upstream", count: 1 });
|
||||
expect(result.instances).to.have.length(1); expect(result.instances[0].memory.rss).to.be.greaterThan(0);
|
||||
expect(result.runtimeSeries).to.have.length(1); expect(result.series[0].requests).to.equal(2);
|
||||
const minuteKey = `performance:v1:minute:api:${Math.floor(Date.now() / 60000) * 60000}`;
|
||||
expect(await redis.ttl(minuteKey)).to.be.within(7100, 7200);
|
||||
expect(await redis.ttl(`performance:v1:instance:${result.instances[0].instance}`)).to.be.within(80, 90);
|
||||
await flushPerformanceMetrics(); expect((await performanceReport(15)).routes[0].count).to.equal(2);
|
||||
});
|
||||
it("retains counters across process restarts and combines all streamers", async function () {
|
||||
await start("api"); requestMetrics.observe("request", "/api/repo/:repoId/file/:path", "GET", 20);
|
||||
await flushPerformanceMetrics(); await stop(); stop = null;
|
||||
await start("streamer"); requestMetrics.observe("request", "/api", "POST", 10); await flushPerformanceMetrics();
|
||||
const result = await performanceReport(60);
|
||||
expect(result.routes.map(row => row.service).sort()).to.deep.equal(["api", "streamer"]);
|
||||
expect(result.runtimeSeries.map(row => row.service).sort()).to.deep.equal(["api", "streamer"]);
|
||||
expect(result.windowMinutes).to.equal(60);
|
||||
});
|
||||
it("bounds requested history and excludes expired process heartbeats", async function () {
|
||||
await start("api"); await flushPerformanceMetrics();
|
||||
await redis.zAdd("performance:v1:instances", { score: Date.now() - 100000, value: "retired" });
|
||||
await redis.set("performance:v1:instance:retired", '{"instance":"retired"}');
|
||||
const result = await performanceReport(1000000);
|
||||
expect(result.windowMinutes).to.equal(15); expect(result.instances).to.have.length(1);
|
||||
expect(result.instances[0].instance).not.to.equal("retired");
|
||||
});
|
||||
it("returns unavailable promptly and stays bounded when the Redis endpoint never replies", async function () {
|
||||
const connections = [];
|
||||
const blackhole = net.createServer(socket => { connections.push(socket); socket.on("error", () => {}); });
|
||||
await new Promise(resolve => blackhole.listen(0, "127.0.0.1", resolve));
|
||||
config.REDIS_PORT = blackhole.address().port;
|
||||
try {
|
||||
stop = startPerformanceMonitoring("api");
|
||||
for (let i = 0; i < 10000; i++) requestMetrics.observe("request", "other", "GET", 10);
|
||||
const started = Date.now(); await flushPerformanceMetrics();
|
||||
expect((await performanceReport(60)).available).to.equal(false);
|
||||
expect(Date.now() - started).to.be.lessThan(500); expect(requestMetrics.size).to.equal(0);
|
||||
await stop(); stop = null;
|
||||
} finally {
|
||||
connections.forEach(socket => socket.destroy()); await new Promise(resolve => blackhole.close(resolve)); config.REDIS_PORT = port;
|
||||
}
|
||||
});
|
||||
it("cuts off an in-flight batch when a connected Redis server stops responding", async function () {
|
||||
await start("api");
|
||||
requestMetrics.observe("request", "other", "GET", 15);
|
||||
await redis.sendCommand(["CLIENT", "PAUSE", "2500", "ALL"]);
|
||||
const started = Date.now();
|
||||
await flushPerformanceMetrics();
|
||||
expect(Date.now() - started).to.be.within(1800, 2800);
|
||||
expect(requestMetrics.size).to.equal(0);
|
||||
expect((await performanceReport(15)).available).to.equal(false);
|
||||
});
|
||||
});
|
||||
@@ -217,6 +217,7 @@ describe("production regressions", function () {
|
||||
|
||||
function response() {
|
||||
return { headers: {}, header(key, value) { this.headers[key] = value; return this; },
|
||||
set(key, value) { return this.header(key, value); },
|
||||
contentType() { return this; }, status(value) { this.statusCode = value; return this; }, end() {}, send() {} };
|
||||
}
|
||||
function fileRoute(originalName) {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
const { expect } = require("chai");
|
||||
const express = require("express");
|
||||
const compression = require("compression");
|
||||
const http = require("node:http");
|
||||
const net = require("node:net");
|
||||
const { setTimeout } = require("node:timers");
|
||||
const { gunzipSync } = require("node:zlib");
|
||||
const { Readable } = require("node:stream");
|
||||
const { setTimeout: delay } = require("node:timers/promises");
|
||||
require("ts-node/register/transpile-only");
|
||||
const { RequestMetrics, monitorRequests, requestRoute, requestHeaders, startStage, requestMetrics, activeRequestCount } = require("../src/core/request-monitoring");
|
||||
const { percentileUpperBound, socketSnapshot, performanceReport } = require("../src/core/performance-monitoring");
|
||||
const GitHubStream = require("../src/core/source/GitHubStream").default;
|
||||
const storage = require("../src/core/storage").default;
|
||||
|
||||
function fetch(server, path, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.request({ host: "127.0.0.1", port: server.address().port, path, ...options }, res => {
|
||||
const chunks = []; res.on("data", chunk => chunks.push(chunk)); res.on("error", reject);
|
||||
res.on("end", () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));
|
||||
}).on("error", reject).end();
|
||||
});
|
||||
}
|
||||
|
||||
describe("bounded request monitoring", function () {
|
||||
let server, metrics, logs, app;
|
||||
beforeEach(function () {
|
||||
metrics = new RequestMetrics(); logs = []; requestMetrics.drain();
|
||||
app = express(); app.use(monitorRequests("api", metrics, { info: (...args) => logs.push(args), warn: (...args) => logs.push(args) }));
|
||||
app.use(compression({ filter: () => true }));
|
||||
});
|
||||
afterEach(async function () {
|
||||
if (server) { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); server = null; }
|
||||
requestMetrics.drain();
|
||||
});
|
||||
async function listen() { server = await new Promise(resolve => { const s = app.listen(0, "127.0.0.1", () => resolve(s)); }); }
|
||||
|
||||
it("keeps labels bounded and removes source identities and query values", function () {
|
||||
expect(requestRoute("/api/repo/private-name/file/secret.txt?token=abc", "api")).to.equal("/api/repo/:repoId/file/:path");
|
||||
expect(requestRoute("/api/repo/secret-owner/secret-repo/readme?branch=secret", "api")).to.equal("/api/repo/:owner/:repo/readme");
|
||||
expect(requestRoute("/api/repo/id/files/counts", "api")).to.equal("/api/repo/:repoId/files/counts");
|
||||
const labels = new Set(Array.from({ length: 10000 }, (_, i) => requestRoute(`/unknown-${i}/private-${i}`, "api")));
|
||||
expect([...labels]).to.deep.equal(["other"]);
|
||||
expect(requestRoute("/healthcheck", "streamer")).to.equal("/healthcheck");
|
||||
const bounded = new RequestMetrics(3);
|
||||
for (let i = 0; i < 1000; i++) bounded.observe("request", String(i), "GET", 10);
|
||||
expect(bounded.size).to.equal(3); expect(bounded.dropped).to.equal(997);
|
||||
});
|
||||
|
||||
it("records histogram upper bounds and releases the batch after flushing", function () {
|
||||
[1, 20, 51, 150, 501, 50000, 70000].forEach(ms => metrics.observe("request", "other", "GET", ms));
|
||||
const row = metrics.drain()[0].data;
|
||||
expect(row.count).to.equal(7); expect(row.sumMs).to.equal(120723);
|
||||
expect(percentileUpperBound(row.buckets, .5)).to.equal(250);
|
||||
expect(percentileUpperBound(row.buckets, .95)).to.equal(null);
|
||||
expect(metrics.size).to.equal(0); expect(metrics.drain()).to.deep.equal([]);
|
||||
});
|
||||
|
||||
for (const gzip of [false, true]) {
|
||||
it(`preserves backpressure and ${gzip ? "gzip" : "plain"} bytes`, async function () {
|
||||
this.timeout(10000);
|
||||
const body = Buffer.alloc(2 * 1024 * 1024, "content-");
|
||||
let drains = 0, callbacks = 0;
|
||||
app.get("/api/repo/:repoId/file/*path", async (_req, res) => {
|
||||
res.type("text/plain");
|
||||
for (let i = 0; i < body.length; i += 16384) {
|
||||
if (!res.write(body.subarray(i, i + 16384), () => callbacks++)) {
|
||||
drains++;
|
||||
await new Promise(resolve => {
|
||||
const drained = () => { emitter.removeListener("drain", drained); resolve(); };
|
||||
const emitter = res.on("drain", drained);
|
||||
});
|
||||
}
|
||||
}
|
||||
res.end();
|
||||
});
|
||||
await listen();
|
||||
const result = await fetch(server, "/api/repo/private/file/private.txt?token=secret", { headers: { "Accept-Encoding": gzip ? "gzip" : "identity" } });
|
||||
expect(gzip ? gunzipSync(result.body) : result.body).to.deep.equal(body);
|
||||
expect(drains).to.be.greaterThan(0);
|
||||
// Compression omits write callbacks; the plain writable must retain them.
|
||||
expect(callbacks).to.equal(gzip ? 0 : body.length / 16384);
|
||||
const row = metrics.drain().find(row => row.data.metric === "request").data;
|
||||
expect(row.bytes).to.equal(result.body.length); expect(row.aborted).to.equal(0); expect(row.count).to.equal(1);
|
||||
expect(JSON.stringify(logs)).not.to.include("private.txt"); expect(JSON.stringify(logs)).not.to.include("secret");
|
||||
expect(logs[0][1].requestId).to.equal(result.headers["x-request-id"]);
|
||||
expect(activeRequestCount()).to.equal(0);
|
||||
});
|
||||
}
|
||||
|
||||
it("counts HEAD as a zero-byte response even when a handler writes a body", async function () {
|
||||
app.get("/", (_req, res) => res.send("body omitted by Node")); await listen();
|
||||
const result = await fetch(server, "/", { method: "HEAD" });
|
||||
expect(result.body.length).to.equal(0);
|
||||
expect(metrics.drain().find(row => row.data.metric === "request").data.bytes).to.equal(0);
|
||||
});
|
||||
|
||||
for (const headersSent of [false, true]) {
|
||||
it(`records a disconnect ${headersSent ? "after" : "before"} headers exactly once`, async function () {
|
||||
let arrived; const ready = new Promise(resolve => { arrived = resolve; });
|
||||
let finished; const terminal = new Promise(resolve => { finished = resolve; });
|
||||
app.get("/", (_req, res) => { if (headersSent) { res.type("text/plain"); res.write("partial"); } arrived(); res.on("close", finished); });
|
||||
await listen();
|
||||
const client = http.get({ host: "127.0.0.1", port: server.address().port, path: "/" }); client.on("error", () => {});
|
||||
await ready; client.destroy(); await terminal;
|
||||
expect(logs).to.have.length(1); expect(logs[0][0]).to.equal("request interrupted");
|
||||
expect(logs[0][1].outcome).to.equal("interrupted");
|
||||
const row = metrics.drain().find(row => row.data.metric === "request").data;
|
||||
expect(row.count).to.equal(1); expect(row.aborted).to.equal(1); expect(activeRequestCount()).to.equal(0);
|
||||
if (!headersSent) expect(logs[0][1].firstByteMs).to.equal(null);
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps overlapping request IDs and asynchronous stages isolated", async function () {
|
||||
let doneA, doneB;
|
||||
app.get("/a", (_req, res) => { doneA = startStage("upstream"); setTimeout(() => { doneA(); doneA(); res.json(requestHeaders()); }, 30); });
|
||||
app.get("/b", (_req, res) => { doneB = startStage("repository"); setTimeout(() => { doneB(); res.json(requestHeaders()); }, 10); });
|
||||
await listen(); const results = await Promise.all([fetch(server, "/a"), fetch(server, "/b")]);
|
||||
for (const result of results) expect(JSON.parse(result.body)["x-request-id"]).to.equal(result.headers["x-request-id"]);
|
||||
expect(results[0].headers["x-request-id"]).not.to.equal(results[1].headers["x-request-id"]);
|
||||
expect(logs.filter(row => row[1].stages.upstream)).to.have.length(1);
|
||||
expect(logs.filter(row => row[1].stages.repository)).to.have.length(1);
|
||||
expect(requestMetrics.drain().reduce((count, row) => count + row.data.count, 0)).to.equal(2);
|
||||
expect(requestHeaders()).to.deep.equal({});
|
||||
});
|
||||
|
||||
it("covers delays and errors before route handling", async function () {
|
||||
app.use((_req, _res, next) => setTimeout(next, 35)); app.use(express.json());
|
||||
app.get("/", (_req, res) => res.status(503).json({ error: "unavailable" })); await listen();
|
||||
const response = await fetch(server, "/"); expect(response.status).to.equal(503);
|
||||
const row = metrics.drain().find(row => row.data.metric === "request").data;
|
||||
expect(row.errors).to.equal(1); expect(row.sumMs).to.be.at.least(30);
|
||||
});
|
||||
|
||||
for (const failure of [false, true]) {
|
||||
it(`includes GitHub response-header waits in upstream timing${failure ? " on failure" : ""}`, async function () {
|
||||
const originals = { fileInfo: storage.fileInfo, write: storage.write, read: storage.read };
|
||||
const source = new GitHubStream({ repoId: `headers-${failure}`, organization: "owner", repoName: "repo", commit: "abc", getToken: async () => "token" });
|
||||
const upstreamError = Error("headers failed");
|
||||
source.downloadWithFallback = async () => {
|
||||
await delay(120);
|
||||
if (failure) throw upstreamError;
|
||||
return Readable.from([Buffer.from("content")]);
|
||||
};
|
||||
storage.fileInfo = async () => { throw Error("cache miss"); };
|
||||
storage.write = async (_repo, _path, input) => { for await (const chunk of input) void chunk; };
|
||||
storage.read = async () => Readable.from([Buffer.from("content")]);
|
||||
app.get("/", async (_req, res) => {
|
||||
try {
|
||||
const input = await source.getFileContentCache("file.txt", source.data.repoId, () => ({ sha: "blob", size: 7 }));
|
||||
input.destroy(); res.send("ok");
|
||||
} catch (error) { res.status(502).send(error === upstreamError ? "original error" : "unexpected error"); }
|
||||
});
|
||||
try {
|
||||
await listen(); const response = await fetch(server, "/");
|
||||
expect(response.status).to.equal(failure ? 502 : 200);
|
||||
if (failure) expect(response.body.toString()).to.equal("original error");
|
||||
expect(logs[0][1].stages.upstream).to.be.at.least(100);
|
||||
const row = requestMetrics.drain().find(entry => entry.data.metric === "upstream").data;
|
||||
expect(row.count).to.equal(1); expect(row.errors).to.equal(Number(failure));
|
||||
} finally { Object.assign(storage, originals); }
|
||||
});
|
||||
}
|
||||
|
||||
it("counts PID-owned network sockets without blocking the event loop", async function () {
|
||||
const listener = net.createServer(socket => socket.end()); await new Promise(resolve => listener.listen(0, "127.0.0.1", resolve));
|
||||
try {
|
||||
const snapshot = await socketSnapshot();
|
||||
if (process.platform === "linux") { expect(snapshot.listening).to.be.at.least(1); expect(snapshot.truncated).to.equal(false); }
|
||||
} finally { await new Promise(resolve => listener.close(resolve)); }
|
||||
});
|
||||
|
||||
it("reports missing monitoring as unavailable", async function () {
|
||||
expect((await performanceReport(60000)).available).to.equal(false);
|
||||
});
|
||||
});
|
||||
@@ -446,6 +446,59 @@ describe("Vue 3 UI", function () {
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("shows latency bounds, interruptions and resource samples on the admin overview", async function () {
|
||||
const sample = { instance: "streamer:fixture", service: "streamer", sampledAt: Date.now(), cpuPercent: 2,
|
||||
memory: { rss: 16 * 1024 * 1024 }, memoryLimitBytes: 128 * 1024 * 1024,
|
||||
eventLoop: { p95Ms: 20 }, activeRequests: 2, sockets: { descriptors: 15, closeWait: 1 },
|
||||
workers: { running: 1, waiting: 2 }, droppedBatches: 0, droppedMetrics: 0 };
|
||||
const row = { service: "api", method: "GET", route: "/api/repo/:repoId/file/:path", count: 10,
|
||||
avgMs: 120, p95UpperMs: 250, p99UpperMs: 500, aborted: 1, errors: 0, slow: 0, firstByteCount: 10, firstByteP95UpperMs: 50 };
|
||||
ui = await browser("/", { "/api/user": { username: "admin", isAdmin: true },
|
||||
"/api/admin/overview": { history: [] },
|
||||
"/api/admin/performance": { available: true, instances: [sample], routes: [row], stages: [],
|
||||
runtimeSeries: [{ instance: sample.instance, rss: 20 * 1024 * 1024 }] } });
|
||||
await ui.go("/admin/");
|
||||
const text = ui.window.document.querySelector(".app-view").textContent;
|
||||
expect(text).to.include("Request performance"); expect(text).to.include("\u2264 250 ms");
|
||||
expect(text).to.include("\u2264 50 ms"); expect(text).to.include("streamer:fixture"); expect(text).to.include("20.0 MB");
|
||||
expect(ui.window.document.querySelector(".table-warning")).not.to.equal(null);
|
||||
await ui.input('select[aria-label="Performance time range"]', "60", "change");
|
||||
expect(ui.requests.filter(request => request.url.pathname === "/api/admin/performance").at(-1).url.searchParams.get("minutes")).to.equal("60");
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("shows missing monitoring as unavailable on the admin overview", async function () {
|
||||
ui = await browser("/", { "/api/user": { username: "admin", isAdmin: true },
|
||||
"/api/admin/overview": { history: [] }, "/api/admin/performance": { available: false } });
|
||||
await ui.go("/admin/");
|
||||
expect(ui.window.document.querySelector(".app-view").textContent).to.include("Performance monitoring is unavailable");
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
|
||||
for (const failure of [false, true]) {
|
||||
it(`refreshes the selected performance window after an old request ${failure ? "fails" : "completes"}`, async function () {
|
||||
let finishOld, finishNew;
|
||||
const report = minutes => ({ available: true, windowMinutes: minutes, instances: [], stages: [], runtimeSeries: [],
|
||||
routes: [{ service: "api", method: "GET", route: `window-${minutes}`, count: 1, avgMs: 1, p95UpperMs: 10, p99UpperMs: 10 }] });
|
||||
ui = await browser("/", { "/api/user": { username: "admin", isAdmin: true }, "/api/admin/overview": { history: [] },
|
||||
"/api/admin/performance": request => new Promise(resolve => {
|
||||
if (request.url.searchParams.get("minutes") === "15") finishOld = () => resolve(failure ? { __status: 503, body: {} } : report(15));
|
||||
else finishNew = () => resolve(report(60));
|
||||
}) });
|
||||
await ui.go("/admin/");
|
||||
await ui.input('select[aria-label="Performance time range"]', "60", "change");
|
||||
expect(ui.requests.filter(request => request.url.pathname === "/api/admin/performance")).to.have.length(1);
|
||||
finishOld(); await delay(20);
|
||||
const calls = ui.requests.filter(request => request.url.pathname === "/api/admin/performance");
|
||||
expect(calls.map(request => request.url.searchParams.get("minutes"))).to.deep.equal(["15", "60"]);
|
||||
expect(ui.window.document.querySelector(".app-view").textContent).not.to.include("window-15");
|
||||
expect(ui.window.document.querySelector(".app-view").textContent).not.to.include("Could not refresh performance monitoring");
|
||||
finishNew(); await delay(20);
|
||||
expect(ui.window.document.querySelector(".app-view").textContent).to.include("window-60");
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
}
|
||||
|
||||
it("offers one App sign-in for new and existing accounts", async function () {
|
||||
ui = await browser("/signin", { "/api/options": { GITHUB_APP_ENABLED: true, GITHUB_OAUTH_ENABLED: true } });
|
||||
expect(ui.window.document.querySelector('a[href="/github/app/login"]')).not.to.equal(null);
|
||||
|
||||
@@ -42,6 +42,29 @@ describe("ZIP proxy failures", function () {
|
||||
function request(onResponse, headers = {}) {
|
||||
return http.get(`http://127.0.0.1:${server.address().port}/test/zip`, { headers }, onResponse);
|
||||
}
|
||||
it("answers HEAD without counting a view, obtaining a token or starting a ZIP stream", async function () {
|
||||
utils.getRepo = async () => ({ repoId: "test", countView: () => { throw Error("view counted"); },
|
||||
getToken: () => { throw Error("token requested"); }, generateAnonymizeTransformer: () => { throw Error("transform requested"); } });
|
||||
got.stream = () => { throw Error("archive requested"); };
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
http.request(`http://127.0.0.1:${server.address().port}/test/zip`, { method: "HEAD" }, res => {
|
||||
const chunks = []; res.on("data", chunk => chunks.push(chunk));
|
||||
res.on("end", () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));
|
||||
}).on("error", reject).end();
|
||||
});
|
||||
expect(result.status).to.equal(200);
|
||||
expect(result.headers["content-disposition"]).to.include("test.zip");
|
||||
expect(result.body.length).to.equal(0);
|
||||
});
|
||||
it("still denies HEAD when downloads are disabled", async function () {
|
||||
config.ENABLE_DOWNLOAD = false;
|
||||
const status = await new Promise((resolve, reject) => {
|
||||
http.request(`http://127.0.0.1:${server.address().port}/test/zip`, { method: "HEAD" }, res => {
|
||||
res.resume(); res.on("end", () => resolve(res.statusCode));
|
||||
}).on("error", reject).end();
|
||||
});
|
||||
expect(status).to.equal(403);
|
||||
});
|
||||
it("aborts a partial ZIP when the upstream fails after headers", async function () {
|
||||
const result = new Promise((resolve, reject) => {
|
||||
const client = request(response => {
|
||||
|
||||
Reference in new issue
Block a user