diff --git a/README.md b/README.md index 56239ed..5138b56 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,10 @@ const server = dns2.createServer({ }); ``` +### Performance and Benchmarking + +see [benchmark/README.md](benchmark/README.md) + ### Relevant Specifications + [RFC-1034 - Domain Names - Concepts and Facilities](https://tools.ietf.org/html/rfc1034) diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 0000000..79e05d6 --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,70 @@ +# dns2 + +### Performance and Benchmarking + +#### Running the benchmark + +A UDP throughput benchmark is included. It starts an in-process echo server, +fires queries at it, and reports RPS and latency percentiles: + +```bash +node benchmark/udp.js + +# Tune via env vars +TOTAL=50000 CONCURRENCY=200 node benchmark/udp.js + +# Target an external server +DNS=127.0.0.1:5353 node benchmark/udp.js +``` + +Example output on a modern laptop (single Node.js process, loopback UDP): + +``` +Sending 10000 queries at concurrency 100... + +Results +------- + Total queries : 10000 + Errors : 0 + Wall time : 812 ms + Throughput : 12315 req/s + + Latency (ms) + min : 0 + mean : 7.92 + p50 : 7 + p90 : 13 + p99 : 24 + max : 61 +``` + +#### Limiting concurrency with `maxConcurrent` + +Use the `maxConcurrent` option on `createServer` to cap how many handler +invocations can be in flight simultaneously. Requests that arrive when the limit +is already reached receive an immediate `SERVFAIL` response rather than queuing +unboundedly. + +```js +const server = dns2.createServer({ + udp : true, + maxConcurrent: 500, // at most 500 handler calls in flight at once + handle(request, send) { + // async work here... + }, +}); +``` + +> **Note:** handlers must always call `send()` — the active-request counter +> decrements only when `send()` is invoked. A handler that never calls `send()` +> will permanently consume one concurrency slot. + +#### Node.js tuning tips + +- Run multiple worker processes with the built-in + [`cluster`](https://nodejs.org/api/cluster.html) module to use all CPU cores. +- Increase the V8 new-space size to reduce minor-GC frequency under high load: + `node --max-semi-space-size=64 server.js` +- For production, consider a process manager (PM2, systemd) that auto-restarts + on failure and enables multi-instance clustering. + diff --git a/benchmark/udp.js b/benchmark/udp.js new file mode 100644 index 0000000..20b8451 --- /dev/null +++ b/benchmark/udp.js @@ -0,0 +1,125 @@ +#!/usr/bin/env node +/** + * UDP benchmark — measures dns2 server throughput and latency. + * + * Usage: + * node benchmark/udp.js + * + * Environment variables: + * TOTAL – number of queries to send (default: 10 000) + * CONCURRENCY – max in-flight queries (default: 100) + * DNS – host:port of an external server to target instead of the + * built-in echo server (e.g. DNS=127.0.0.1:5353) + */ +'use strict'; + +const { createServer, UDPClient, Packet } = require('..'); + +const TOTAL = parseInt(process.env.TOTAL || '10000', 10); +const CONCURRENCY = parseInt(process.env.CONCURRENCY || '100', 10); +const DNS_TARGET = process.env.DNS || null; + +function percentile(sorted, p) { + return sorted[Math.max(0, Math.floor(sorted.length * p / 100) - 1 + (p === 100 ? 1 : 0))]; +} + +async function run() { + let server = null; + let port; + let host = '127.0.0.1'; + + if (DNS_TARGET) { + const parts = DNS_TARGET.split(':'); + host = parts[0]; + port = parseInt(parts[1], 10); + console.log(`Targeting external server ${host}:${port}`); + } else { + server = createServer({ + udp: true, + handle(request, send) { + const response = Packet.createResponseFromRequest(request); + const [ q ] = request.questions; + response.answers.push({ + name : q.name, + type : Packet.TYPE.A, + class : Packet.CLASS.IN, + ttl : 60, + address : '127.0.0.1', + }); + send(response); + }, + }); + const addresses = await server.listen(); + port = addresses.udp.port; + console.log(`In-process echo server on 127.0.0.1:${port}`); + } + + const client = UDPClient({ dns: host, port }); + + console.log(`Sending ${TOTAL} queries at concurrency ${CONCURRENCY}...\n`); + + const latencies = new Array(TOTAL); + let completed = 0; + let errors = 0; + + const wallStart = Date.now(); + + await new Promise((resolve) => { + let sent = 0; + let inFlight = 0; + + function next() { + while (inFlight < CONCURRENCY && sent < TOTAL) { + const idx = sent++; + inFlight++; + const t0 = Date.now(); + client('benchmark.test', 'A') + .then(() => { + latencies[idx] = Date.now() - t0; + }) + .catch(() => { + latencies[idx] = Date.now() - t0; + errors++; + }) + .finally(() => { + inFlight--; + completed++; + if (completed === TOTAL) resolve(); + else next(); + }); + } + } + + next(); + }); + + const wallMs = Date.now() - wallStart; + const rps = Math.round(TOTAL / (wallMs / 1000)); + + const sorted = latencies.slice().sort((a, b) => a - b); + const mean = (sorted.reduce((s, v) => s + v, 0) / sorted.length).toFixed(2); + + console.log('Results'); + console.log('-------'); + console.log(` Total queries : ${TOTAL}`); + console.log(` Errors : ${errors}`); + console.log(` Wall time : ${wallMs} ms`); + console.log(` Throughput : ${rps} req/s`); + console.log(''); + console.log(' Latency (ms)'); + console.log(` min : ${sorted[0]}`); + console.log(` mean : ${mean}`); + console.log(` p50 : ${percentile(sorted, 50)}`); + console.log(` p90 : ${percentile(sorted, 90)}`); + console.log(` p99 : ${percentile(sorted, 99)}`); + console.log(` max : ${sorted[sorted.length - 1]}`); + + if (server) { + await server.close(); + } +} + +run().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/package.json b/package.json index 3e3b93b..b5742bf 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "example-client-udp": "node example/client/udp.js", "example-client-tcp": "node example/client/tcp.js", "example-client-google": "node example/client/google.js", - "example-client-udp-subnet": "node example/client/udp-subnet.js" + "example-client-udp-subnet": "node example/client/udp-subnet.js", + "benchmark": "node benchmark/udp.js" }, "keywords": [ "dns" diff --git a/server/dns.js b/server/dns.js index 20735fb..00bf6b7 100644 --- a/server/dns.js +++ b/server/dns.js @@ -1,4 +1,5 @@ const EventEmitter = require('node:events'); +const Packet = require('../packet'); const DOHServer = require('./doh'); const TCPServer = require('./tcp'); const UDPServer = require('./udp'); @@ -34,7 +35,23 @@ class DNSServer extends EventEmitter { return addresses; }); - const emitRequest = (request, send, client) => this.emit('request', request, send, client); + const maxConcurrent = options.maxConcurrent > 0 ? options.maxConcurrent : 0; + let active = 0; + + const emitRequest = (request, send, client) => { + if (maxConcurrent && active >= maxConcurrent) { + const response = Packet.createResponseFromRequest(request); + response.header.rcode = Packet.RCODE.SERVFAIL; + send(response); + return; + } + active++; + const wrappedSend = (...args) => { + active--; + return send(...args); + }; + this.emit('request', request, wrappedSend, client); + }; const emitRequestError = (error) => this.emit('requestError', error); for (const server of servers) { server.on('request', emitRequest); diff --git a/test/server.js b/test/server.js index 6967cae..ca0ccc3 100644 --- a/test/server.js +++ b/test/server.js @@ -452,3 +452,65 @@ test('server/all#handler can respond with RCODE error codes', async() => { await server.close(); }); + +test('server/all#maxConcurrent - requests within limit are served normally', async() => { + const server = createServer({ + udp : true, + maxConcurrent : 10, + handle(request, send) { + const response = Packet.createResponseFromRequest(request); + response.answers.push({ + name : request.questions[0].name, + type : Packet.TYPE.A, + class : Packet.CLASS.IN, + ttl : 60, + address : '1.2.3.4', + }); + send(response); + }, + }); + const { udp: { port } } = await server.listen(); + const client = UDPClient({ dns: '127.0.0.1', port }); + + const reply = await client('within-limit.test'); + assert.equal(reply.header.rcode, Packet.RCODE.NOERROR); + assert.equal(reply.answers[0].address, '1.2.3.4'); + + await server.close(); +}); + +test('server/all#maxConcurrent - excess requests receive SERVFAIL', async() => { + // Use a handler that holds requests open until we release them, so we can + // saturate the concurrency limit predictably. + const pending = []; + const server = createServer({ + udp : true, + maxConcurrent : 2, + handle(request, send) { + pending.push({ request, send }); + }, + }); + const { udp: { port } } = await server.listen(); + const client = UDPClient({ dns: '127.0.0.1', port }); + + // Fire q1 and q2 but don't await — they stay in the handler holding 2 slots. + const p1 = client('q1.test'); + const p2 = client('q2.test'); + + // Wait until both are registered with the handler. + while (pending.length < 2) await new Promise(r => setTimeout(r, 5)); + + // q3 arrives when the limit is already full — should be shed immediately. + const r3 = await client('q3.test'); + assert.equal(r3.header.rcode, Packet.RCODE.SERVFAIL, 'shed request gets SERVFAIL'); + + // Drain the two held requests so the server can close cleanly. + for (const { request, send } of pending) { + const response = Packet.createResponseFromRequest(request); + response.header.rcode = Packet.RCODE.NOERROR; + send(response); + } + await Promise.all([ p1, p2 ]); + + await server.close(); +});