Skip to content

Commit 20060e4

Browse files
committed
feat: cache dns lookups in memory for 60 seconds
This reduces the load on the DNS server, even if it runs on the same machine or the OS caches it for us.
1 parent 247af02 commit 20060e4

19 files changed

Lines changed: 351 additions & 132 deletions

src/http/clients/SimpleHttpClient.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { container } from 'tsyringe';
22
import * as Undici from 'undici';
33
import { IS_PRODUCTION } from '../../constants.js';
4-
import UnicastOnlyDnsResolver from '../dns/UnicastOnlyDnsResolver.js';
4+
import UnicastOnlyDnsResolver from '../dns/resolver/UnicastOnlyDnsResolver.js';
55
import HttpResponse from '../HttpResponse.js';
66
import UserAgentGenerator from '../UserAgentGenerator.js';
77
import HttpClient, { FullRequestOptions, GetRequestOptions, PostRequestOptions } from './HttpClient.js';
@@ -64,10 +64,11 @@ export default class SimpleHttpClient extends HttpClient {
6464
}
6565

6666
protected getDefaultAgentOptions(): Undici.Agent.Options {
67+
const dnsResolver = container.resolve(UnicastOnlyDnsResolver);
6768
return {
6869
...super.getDefaultAgentOptions(),
6970
connect: {
70-
lookup: container.resolve(UnicastOnlyDnsResolver).lookup,
71+
lookup: (hostname, options, callback) => dnsResolver.lookup(hostname, options, callback),
7172
},
7273
};
7374
}

src/http/clients/SocksProxyAgentFactory.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as Undici from 'undici';
44
import { SocksProxyServer } from '../../net/proxy/ProxyServerConfigurationProvider.js';
55
import SocksProxyServerConnector from '../../net/proxy/SocksProxyServerConnector.js';
66
import ResolvedToNonUnicastIpError from '../dns/errors/ResolvedToNonUnicastIpError.js';
7-
import UnicastOnlyDnsResolver from '../dns/UnicastOnlyDnsResolver.js';
7+
import UnicastOnlyDnsResolver from '../dns/resolver/UnicastOnlyDnsResolver.js';
88

99
@singleton()
1010
export default class SocksProxyAgentFactory {

src/http/dns/UnicastOnlyDnsResolver.ts

Lines changed: 0 additions & 50 deletions
This file was deleted.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import Dns from 'node:dns';
2+
import { singleton } from 'tsyringe';
3+
import MapWithTtl from '../../../util/MapWithTtl.js';
4+
import DnsResolver from './DnsResolver.js';
5+
import DnsResolverInterface, { LookupAsyncResult } from './DnsResolverInterface.js';
6+
7+
@singleton()
8+
export default class CachedDnsResolver implements DnsResolverInterface {
9+
private readonly cache = MapWithTtl.create<string, LookupAsyncResult>(60);
10+
11+
constructor(
12+
private readonly dnsResolver: DnsResolver,
13+
) {
14+
}
15+
16+
lookup(
17+
hostname: string,
18+
options: Dns.LookupOptions,
19+
callback: (err: NodeJS.ErrnoException | null, address: string | Dns.LookupAddress[], family?: number) => void,
20+
): void {
21+
this.lookupAsync(hostname, options)
22+
.then(result => callback(null, result.address, result.family))
23+
.catch((err) => callback(err, [], undefined));
24+
}
25+
26+
async lookupAsync(hostname: string, options: Dns.LookupOptions): Promise<LookupAsyncResult> {
27+
const cacheKey = `${hostname}${options.family}${options.hints}${options.all}${options.order}${options.verbatim}`;
28+
29+
let lookupResult = this.cache.get(cacheKey);
30+
if (lookupResult == null) {
31+
lookupResult = await this.dnsResolver.lookupAsync(hostname, options);
32+
this.cache.set(cacheKey, lookupResult);
33+
}
34+
35+
return lookupResult;
36+
}
37+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import Dns from 'node:dns';
2+
import { singleton } from 'tsyringe';
3+
import DnsResolverInterface, { type LookupAsyncResult } from './DnsResolverInterface.js';
4+
5+
@singleton()
6+
export default class DnsResolver implements DnsResolverInterface {
7+
lookup(
8+
hostname: string,
9+
options: Dns.LookupOptions,
10+
callback: (err: NodeJS.ErrnoException | null, address: string | Dns.LookupAddress[], family?: number) => void,
11+
): void {
12+
Dns.lookup(hostname, options, (err, address, family): void => {
13+
return callback(err, address, family);
14+
});
15+
}
16+
17+
lookupAsync(hostname: string, options: Dns.LookupOptions): Promise<LookupAsyncResult> {
18+
return new Promise((resolve, reject) => {
19+
this.lookup(hostname, options, (err, address, family): void => {
20+
if (err) {
21+
return reject(err);
22+
}
23+
resolve({ address, family });
24+
});
25+
});
26+
}
27+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type Dns from 'node:dns';
2+
3+
export type LookupAsyncResult = {
4+
address: string | Dns.LookupAddress[],
5+
family?: number,
6+
}
7+
8+
export default interface DnsResolverInterface {
9+
lookup(
10+
hostname: string,
11+
options: Dns.LookupOptions,
12+
callback: (err: NodeJS.ErrnoException | null, address: string | Dns.LookupAddress[], family?: number) => void,
13+
): void;
14+
15+
lookupAsync(hostname: string, options: Dns.LookupOptions): Promise<LookupAsyncResult>;
16+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import IpAddrJs from 'ipaddr.js';
2+
import Dns from 'node:dns';
3+
import { singleton } from 'tsyringe';
4+
import CachedDnsResolver from './CachedDnsResolver.js';
5+
import DnsResolverInterface, { LookupAsyncResult } from './DnsResolverInterface.js';
6+
import ResolvedToNonUnicastIpError from '../errors/ResolvedToNonUnicastIpError.js';
7+
8+
@singleton()
9+
export default class UnicastOnlyDnsResolver implements DnsResolverInterface {
10+
constructor(
11+
private readonly dnsResolver: CachedDnsResolver,
12+
) {
13+
}
14+
15+
lookup(
16+
hostname: string,
17+
options: Dns.LookupOptions,
18+
callback: (err: (NodeJS.ErrnoException | null), address: (string | Dns.LookupAddress[]), family?: number) => void,
19+
): void {
20+
this.lookupAsync(hostname, options)
21+
.then(result => callback(null, result.address, result.family))
22+
.catch((err) => callback(err, [], undefined));
23+
}
24+
25+
async lookupAsync(hostname: string, options: Dns.LookupOptions): Promise<LookupAsyncResult> {
26+
const result = await this.dnsResolver.lookupAsync(hostname, options);
27+
28+
const addressesToCheck = Array.isArray(result.address) ? result.address : [{ address: result.address }];
29+
for (const addressItem of addressesToCheck) {
30+
const parsedHost = IpAddrJs.parse(addressItem.address);
31+
if (parsedHost.range() !== 'unicast') {
32+
throw new ResolvedToNonUnicastIpError(parsedHost.range());
33+
}
34+
}
35+
36+
return result;
37+
}
38+
39+
async resolvesToUnicastIp(hostname: string): Promise<boolean> {
40+
try {
41+
await this.lookupAsync(hostname, { all: true });
42+
return true;
43+
} catch (err) {
44+
if (err instanceof ResolvedToNonUnicastIpError) {
45+
return false;
46+
}
47+
48+
throw err;
49+
}
50+
}
51+
}

src/minecraft/SetWithTtl.ts

Lines changed: 8 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,29 @@
1-
import { container } from 'tsyringe';
2-
import ClearExpiredEntriesInSetsWithTtlTask from '../task_queue/tasks/ClearExpiredEntriesInSetsWithTtlTask.js';
1+
import MapWithTtl from '../util/MapWithTtl.js';
32

43
export default class SetWithTtl<T> {
5-
private readonly values = new Map<T, number>();
6-
private readonly ttlInMilliseconds: number;
4+
private readonly values: MapWithTtl<T, null>;
75

8-
private constructor(ttlInSeconds: number) {
9-
this.ttlInMilliseconds = ttlInSeconds * 1000;
6+
constructor(ttlInSeconds: number) {
7+
this.values = MapWithTtl.create(ttlInSeconds);
108
}
119

1210
add(key: T): void {
13-
this.values.set(key, Date.now() + this.ttlInMilliseconds);
11+
this.values.set(key, null);
1412
}
1513

1614
has(key: T): boolean {
17-
const expiration = this.values.get(key);
18-
return expiration != null && !this.isExpired(expiration);
15+
return this.values.has(key);
1916
}
2017

2118
getAgeInSeconds(key: T): number {
22-
const expiration = this.values.get(key);
23-
if (expiration == null || this.isExpired(expiration)) {
24-
return 0;
25-
}
26-
27-
const creationTime = expiration - this.ttlInMilliseconds;
28-
return Math.floor((Date.now() - creationTime) / 1000);
19+
return this.values.getAgeInSeconds(key);
2920
}
3021

3122
clear(): void {
3223
this.values.clear();
3324
}
3425

3526
clearExpired(): void {
36-
for (const [value, expiration] of this.values.entries()) {
37-
if (this.isExpired(expiration)) {
38-
this.values.delete(value);
39-
}
40-
}
41-
}
42-
43-
private isExpired(expiration: number): boolean {
44-
return expiration < Date.now();
45-
}
46-
47-
static create<T>(ttlInSeconds: number): SetWithTtl<T> {
48-
const setWithTTL = new SetWithTtl<T>(ttlInSeconds);
49-
container.resolve(ClearExpiredEntriesInSetsWithTtlTask).registerSet(setWithTTL);
50-
return setWithTTL;
27+
this.values.clearExpired();
5128
}
5229
}

src/minecraft/profile/MinecraftProfileService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export type Profile = {
1414

1515
@singleton()
1616
export default class MinecraftProfileService {
17-
private readonly nullProfileCache = SetWithTtl.create<string>(60);
17+
private readonly nullProfileCache = new SetWithTtl<string>(60);
1818
private readonly inFlightRequests = new Map<string, Promise<Profile | null>>();
1919

2020
constructor(

src/minecraft/server/ping/MinecraftServerStatusService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export type CachedServerStatus = {
1616

1717
@singleton()
1818
export default class MinecraftServerStatusService {
19-
private readonly offlineServerCache = SetWithTtl.create<string>(60);
19+
private readonly offlineServerCache = new SetWithTtl<string>(60);
2020

2121
constructor(
2222
private readonly minecraftServerStatusPinger: MinecraftServerStatusPinger,

0 commit comments

Comments
 (0)