Skip to content

Commit 72115ec

Browse files
authored
feat: Cache DNS lookups (#256)
1 parent 1643323 commit 72115ec

File tree

4 files changed

+344
-10
lines changed

4 files changed

+344
-10
lines changed

lib/valid_email2/address.rb

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
require "resolv"
55
require "mail"
66
require "unicode/emoji"
7+
require "valid_email2/dns_records_cache"
78

89
module ValidEmail2
910
class Address
@@ -25,9 +26,7 @@ def initialize(address, dns_timeout = 5, dns_nameserver = nil)
2526
@parse_error = false
2627
@raw_address = address
2728
@dns_timeout = dns_timeout
28-
29-
@resolv_config = Resolv::DNS::Config.default_config_hash
30-
@resolv_config[:nameserver] = dns_nameserver if dns_nameserver
29+
@dns_nameserver = dns_nameserver
3130

3231
begin
3332
@address = Mail::Address.new(address)
@@ -137,10 +136,24 @@ def address_contain_emoticons?
137136
@raw_address.scan(Unicode::Emoji::REGEX).length >= 1
138137
end
139138

139+
def resolv_config
140+
@resolv_config ||= begin
141+
config = Resolv::DNS::Config.default_config_hash
142+
config[:nameserver] = @dns_nameserver if @dns_nameserver
143+
config
144+
end
145+
146+
@resolv_config
147+
end
148+
140149
def mx_servers
141-
@mx_servers ||= Resolv::DNS.open(@resolv_config) do |dns|
142-
dns.timeouts = @dns_timeout
143-
dns.getresources(address.domain, Resolv::DNS::Resource::IN::MX)
150+
@mx_servers_cache ||= ValidEmail2::DnsRecordsCache.new
151+
152+
@mx_servers_cache.fetch(address.domain.downcase) do
153+
Resolv::DNS.open(resolv_config) do |dns|
154+
dns.timeouts = @dns_timeout
155+
dns.getresources(address.domain, Resolv::DNS::Resource::IN::MX)
156+
end
144157
end
145158
end
146159

@@ -149,10 +162,14 @@ def null_mx?
149162
end
150163

151164
def mx_or_a_servers
152-
@mx_or_a_servers ||= Resolv::DNS.open(@resolv_config) do |dns|
153-
dns.timeouts = @dns_timeout
154-
(mx_servers.any? && mx_servers) ||
155-
dns.getresources(address.domain, Resolv::DNS::Resource::IN::A)
165+
@mx_or_a_servers_cache ||= ValidEmail2::DnsRecordsCache.new
166+
167+
@mx_or_a_servers_cache.fetch(address.domain.downcase) do
168+
Resolv::DNS.open(resolv_config) do |dns|
169+
dns.timeouts = @dns_timeout
170+
(mx_servers.any? && mx_servers) ||
171+
dns.getresources(address.domain, Resolv::DNS::Resource::IN::A)
172+
end
156173
end
157174
end
158175
end

lib/valid_email2/dns_records_cache.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
module ValidEmail2
2+
class DnsRecordsCache
3+
MAX_CACHE_SIZE = 1_000
4+
5+
def initialize
6+
# Cache structure: { domain (String): { records: [], cached_at: Time, ttl: Integer } }
7+
@cache = {}
8+
end
9+
10+
def fetch(domain, &block)
11+
prune_cache if @cache.size > MAX_CACHE_SIZE
12+
13+
cache_entry = @cache[domain]
14+
15+
if cache_entry && (Time.now - cache_entry[:cached_at]) < cache_entry[:ttl]
16+
return cache_entry[:records]
17+
else
18+
@cache.delete(domain)
19+
end
20+
21+
records = block.call
22+
23+
if records.any?
24+
ttl = records.map(&:ttl).min
25+
@cache[domain] = { records: records, cached_at: Time.now, ttl: ttl }
26+
end
27+
28+
records
29+
end
30+
31+
def prune_cache
32+
entries_sorted_by_cached_at_asc = (@cache.sort_by { |_domain, data| data[:cached_at] }).flatten
33+
entries_to_remove = entries_sorted_by_cached_at_asc.first(@cache.size - MAX_CACHE_SIZE)
34+
entries_to_remove.each { |domain| @cache.delete(domain) }
35+
end
36+
end
37+
end

spec/address_spec.rb

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,283 @@
3939
expect(address.valid?).to eq true
4040
end
4141
end
42+
43+
describe "caching" do
44+
let(:email_address) { "[email protected]" }
45+
let(:email_instance) { described_class.new(email_address) }
46+
let(:dns_records_cache_instance) { ValidEmail2::DnsRecordsCache.new }
47+
let(:ttl) { 1_000 }
48+
let(:mock_resolv_dns) { instance_double(Resolv::DNS) }
49+
let(:mock_mx_records) { [double("MX", exchange: "mx.ymail.com", preference: 10, ttl: ttl)] }
50+
51+
before do
52+
allow(email_instance).to receive(:null_mx?).and_return(false)
53+
allow(Resolv::DNS).to receive(:open).and_yield(mock_resolv_dns)
54+
allow(mock_resolv_dns).to receive(:timeouts=)
55+
end
56+
57+
describe "#valid_strict_mx?" do
58+
let(:cached_at) { Time.now }
59+
let(:mock_cache_data) { { email_instance.address.domain => { records: mock_mx_records, cached_at: cached_at, ttl: ttl } } }
60+
61+
before do
62+
allow(mock_resolv_dns).to receive(:getresources)
63+
.with(email_instance.address.domain, Resolv::DNS::Resource::IN::MX)
64+
.and_return(mock_mx_records)
65+
end
66+
67+
it "calls the MX servers lookup when the email is not cached" do
68+
result = email_instance.valid_strict_mx?
69+
70+
expect(Resolv::DNS).to have_received(:open).once
71+
expect(result).to be true
72+
end
73+
74+
it "does not call the MX servers lookup when the email is cached" do
75+
email_instance.valid_strict_mx?
76+
email_instance.valid_strict_mx?
77+
78+
expect(Resolv::DNS).to have_received(:open).once
79+
end
80+
81+
it "returns the cached result for subsequent calls" do
82+
first_result = email_instance.valid_strict_mx?
83+
expect(first_result).to be true
84+
85+
allow(mock_resolv_dns).to receive(:getresources)
86+
.with(email_instance.address.domain, Resolv::DNS::Resource::IN::MX)
87+
.and_return([])
88+
89+
second_result = email_instance.valid_strict_mx?
90+
expect(second_result).to be true
91+
end
92+
93+
describe "ttl" do
94+
before do
95+
dns_records_cache_instance.instance_variable_set(:@cache, mock_cache_data)
96+
allow(ValidEmail2::DnsRecordsCache).to receive(:new).and_return(dns_records_cache_instance)
97+
allow(dns_records_cache_instance).to receive(:fetch).with(email_instance.address.domain).and_call_original
98+
end
99+
100+
context "when the time since last lookup is less than the cached ttl entry" do
101+
let(:cached_at) { Time.now }
102+
103+
it "does not call the MX servers lookup" do
104+
email_instance.valid_strict_mx?
105+
106+
expect(Resolv::DNS).not_to have_received(:open)
107+
end
108+
end
109+
110+
context "when the time since last lookup is greater than the cached ttl entry" do
111+
let(:cached_at) { Time.now - ttl }
112+
113+
it "calls the MX servers lookup" do
114+
email_instance.valid_strict_mx?
115+
116+
expect(Resolv::DNS).to have_received(:open).once
117+
end
118+
end
119+
end
120+
121+
describe "cache size" do
122+
before do
123+
dns_records_cache_instance.instance_variable_set(:@cache, mock_cache_data)
124+
allow(ValidEmail2::DnsRecordsCache).to receive(:new).and_return(dns_records_cache_instance)
125+
allow(dns_records_cache_instance).to receive(:fetch).with(email_instance.address.domain).and_call_original
126+
end
127+
128+
context "when the cache size is less than or equal to the max cache size" do
129+
before do
130+
stub_const("ValidEmail2::DnsRecordsCache::MAX_CACHE_SIZE", 1)
131+
end
132+
133+
it "does not prune the cache" do
134+
expect(dns_records_cache_instance).not_to receive(:prune_cache)
135+
136+
email_instance.valid_strict_mx?
137+
end
138+
139+
it "does not call the MX servers lookup" do
140+
email_instance.valid_strict_mx?
141+
142+
expect(Resolv::DNS).not_to have_received(:open)
143+
end
144+
145+
context "and there are older cached entries" do
146+
let(:mock_cache_data) { { "another_domain.com" => { records: mock_mx_records, cached_at: cached_at - 100, ttl: ttl } } }
147+
148+
it "does not prune those entries" do
149+
email_instance.valid_strict_mx?
150+
151+
expect(dns_records_cache_instance.instance_variable_get(:@cache).keys.size).to eq 2
152+
expect(dns_records_cache_instance.instance_variable_get(:@cache).keys).to match_array([email_instance.address.domain, "another_domain.com"])
153+
end
154+
end
155+
end
156+
157+
context "when the cache size is greater than the max cache size" do
158+
before do
159+
stub_const("ValidEmail2::DnsRecordsCache::MAX_CACHE_SIZE", 0)
160+
end
161+
162+
it "prunes the cache" do
163+
expect(dns_records_cache_instance).to receive(:prune_cache).once
164+
165+
email_instance.valid_strict_mx?
166+
end
167+
168+
it "calls the the MX servers lookup" do
169+
email_instance.valid_strict_mx?
170+
171+
expect(Resolv::DNS).to have_received(:open).once
172+
end
173+
174+
context "and there are older cached entries" do
175+
let(:mock_cache_data) { { "another_domain.com" => { records: mock_mx_records, cached_at: cached_at - 100, ttl: ttl } } }
176+
177+
it "prunes those entries" do
178+
email_instance.valid_strict_mx?
179+
180+
expect(dns_records_cache_instance.instance_variable_get(:@cache).keys.size).to eq 1
181+
expect(dns_records_cache_instance.instance_variable_get(:@cache).keys).to match_array([email_instance.address.domain])
182+
end
183+
end
184+
end
185+
end
186+
end
187+
188+
describe "#valid_mx?" do
189+
let(:cached_at) { Time.now }
190+
let(:mock_cache_data) { { email_instance.address.domain => { records: mock_a_records, cached_at: cached_at, ttl: ttl } } }
191+
let(:mock_a_records) { [double("A", address: "192.168.1.1", ttl: ttl)] }
192+
193+
before do
194+
allow(email_instance).to receive(:mx_servers).and_return(mock_mx_records)
195+
allow(mock_resolv_dns).to receive(:getresources)
196+
.with(email_instance.address.domain, Resolv::DNS::Resource::IN::A)
197+
.and_return(mock_a_records)
198+
end
199+
200+
it "calls the MX or A servers lookup when the email is not cached" do
201+
result = email_instance.valid_mx?
202+
203+
expect(Resolv::DNS).to have_received(:open).once
204+
expect(result).to be true
205+
end
206+
207+
it "does not call the MX or A servers lookup when the email is cached" do
208+
email_instance.valid_mx?
209+
email_instance.valid_mx?
210+
211+
expect(Resolv::DNS).to have_received(:open).once
212+
end
213+
214+
it "returns the cached result for subsequent calls" do
215+
first_result = email_instance.valid_mx?
216+
expect(first_result).to be true
217+
218+
allow(mock_resolv_dns).to receive(:getresources)
219+
.with(email_instance.address.domain, Resolv::DNS::Resource::IN::A)
220+
.and_return([])
221+
222+
second_result = email_instance.valid_mx?
223+
expect(second_result).to be true
224+
end
225+
226+
describe "ttl" do
227+
before do
228+
dns_records_cache_instance.instance_variable_set(:@cache, mock_cache_data)
229+
allow(ValidEmail2::DnsRecordsCache).to receive(:new).and_return(dns_records_cache_instance)
230+
allow(dns_records_cache_instance).to receive(:fetch).with(email_instance.address.domain).and_call_original
231+
end
232+
233+
context "when the time since last lookup is less than the cached ttl entry" do
234+
let(:cached_at) { Time.now }
235+
236+
it "does not call the MX or A servers lookup" do
237+
email_instance.valid_mx?
238+
239+
expect(Resolv::DNS).not_to have_received(:open)
240+
end
241+
end
242+
243+
context "when the time since last lookup is greater than the cached ttl entry" do
244+
let(:cached_at) { Time.now - ttl }
245+
246+
it "calls the MX or A servers lookup " do
247+
email_instance.valid_mx?
248+
249+
expect(Resolv::DNS).to have_received(:open).once
250+
end
251+
end
252+
end
253+
254+
describe "cache size" do
255+
before do
256+
dns_records_cache_instance.instance_variable_set(:@cache, mock_cache_data)
257+
allow(ValidEmail2::DnsRecordsCache).to receive(:new).and_return(dns_records_cache_instance)
258+
allow(dns_records_cache_instance).to receive(:fetch).with(email_instance.address.domain).and_call_original
259+
end
260+
261+
context "when the cache size is less than or equal to the max cache size" do
262+
before do
263+
stub_const("ValidEmail2::DnsRecordsCache::MAX_CACHE_SIZE", 1)
264+
end
265+
266+
it "does not prune the cache" do
267+
expect(email_instance).not_to receive(:prune_cache)
268+
269+
email_instance.valid_mx?
270+
end
271+
272+
it "does not call the MX or A servers lookup" do
273+
email_instance.valid_mx?
274+
275+
expect(Resolv::DNS).not_to have_received(:open)
276+
end
277+
278+
context "and there are older cached entries" do
279+
let(:mock_cache_data) { { "another_domain.com" => { records: mock_a_records, cached_at: cached_at - 100, ttl: ttl } } }
280+
281+
it "does not prune those entries" do
282+
email_instance.valid_mx?
283+
284+
expect(dns_records_cache_instance.instance_variable_get(:@cache).keys.size).to eq 2
285+
expect(dns_records_cache_instance.instance_variable_get(:@cache).keys).to match_array([email_instance.address.domain, "another_domain.com"])
286+
end
287+
end
288+
end
289+
290+
context "when the cache size is greater than the max cache size" do
291+
before do
292+
stub_const("ValidEmail2::DnsRecordsCache::MAX_CACHE_SIZE", 0)
293+
end
294+
295+
it "prunes the cache" do
296+
expect(dns_records_cache_instance).to receive(:prune_cache).once
297+
298+
email_instance.valid_mx?
299+
end
300+
301+
it "calls the MX or A servers lookup" do
302+
email_instance.valid_mx?
303+
304+
expect(Resolv::DNS).to have_received(:open).once
305+
end
306+
307+
context "and there are older cached entries" do
308+
let(:mock_cache_data) { { "another_domain.com" => { records: mock_a_records, cached_at: cached_at - 100, ttl: ttl } } }
309+
310+
it "prunes those entries" do
311+
email_instance.valid_mx?
312+
313+
expect(dns_records_cache_instance.instance_variable_get(:@cache).keys.size).to eq 1
314+
expect(dns_records_cache_instance.instance_variable_get(:@cache).keys).to match_array([email_instance.address.domain])
315+
end
316+
end
317+
end
318+
end
319+
end
320+
end
42321
end

spec/spec_helper.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
require 'rspec-benchmark'
66
RSpec.configure do |config|
77
config.include RSpec::Benchmark::Matchers
8+
config.default_formatter = 'doc'
89
end
910
RSpec::Benchmark.configure do |config|
1011
config.disable_gc = true

0 commit comments

Comments
 (0)