fix memory blowup and keep direct matches from being dropped

- dns.TTLCache: stop preallocating every shard map to maxShard; this
  allocated the full theoretical cache capacity up front (hundreds of MB
  of empty buckets) and pushed the container over its memory limit,
  causing an OOM restart loop under Docker's default 1g cap.
- pipeline: write direct provider matches immediately without a blocking
  DNS enrichment step, so they can no longer be starved by the optional
  CNAME queue. Add TestDirectNotStarvedByDNSBacklog.
- config: add DEDUP_SIZE, lower DNS_CACHE_SIZE/DEDUP_SIZE defaults to 256k.
- .env.example / README: document DEDUP_SIZE and cache sizing.

Live: stable at ~250-300 MiB with no restarts, direct queue draining to 0.
This commit is contained in:
StasonJatham committed 2026-09-22 13:33:05 +02:00
1 parent e8f7d6bfdf
commit 9710efec1a
7 files changed
+75 -18

No files matched your search

+4 -1
View File
@@ -22,6 +22,8 @@ type Config struct {
DNSPositiveTTL time.Duration
DNSNegativeTTL time.Duration
DedupSize int
DirectQueueSize int
DNSQueueSize int
DBQueueSize int
@@ -57,7 +59,8 @@ func Load() (*Config, error) {
DNSServers: envList("DNS_SERVERS", DefaultDNSServers),
DNSWorkers: envInt("DNS_WORKERS", 16),
DNSTimeout: envDuration("DNS_TIMEOUT", 5*time.Second),
DNSCacheSize: envInt("DNS_CACHE_SIZE", 1<<20),
DNSCacheSize: envInt("DNS_CACHE_SIZE", 1<<18),
DedupSize: envInt("DEDUP_SIZE", 1<<18),
DNSMaxCNAMEdepth: envInt("DNS_MAX_CNAME_DEPTH", 8),
DNSPositiveTTL: envDuration("DNS_POSITIVE_TTL", 10*time.Minute),
DNSNegativeTTL: envDuration("DNS_NEGATIVE_TTL", 1*time.Minute),
+5 -1
View File
@@ -41,7 +41,11 @@ func NewTTLCache[V any](maxEntries, shards int) *TTLCache[V] {
c := &TTLCache[V]{mask: uint64(n - 1), maxShard: maxShard}
c.shards = make([]shard[V], n)
for i := range c.shards {
c.shards[i].m = make(map[string]cacheEntry[V], maxShard)
// Deliberately no capacity hint: pre-sizing every shard to maxShard
// allocates the full theoretical capacity (hundreds of MB across the
// caches) up front. Let the maps grow on demand; maxShard still bounds
// them via eviction.
c.shards[i].m = make(map[string]cacheEntry[V])
}
return c
}
+6 -15
View File
@@ -208,20 +208,6 @@ func appendIPs(f *Finding, ips []net.IP) {
}
}
func (p *Pipeline) enrich(ctx context.Context, c model.Candidate) Finding {
f := Finding{Candidate: c}
if p.cfg.Enricher == nil {
return f
}
res, ok := p.cfg.Enricher.Resolve(ctx, c.Hostname)
if !ok {
return f
}
f.CNAMEChain = res.Chain
appendIPs(&f, res.IPs)
return f
}
func (p *Pipeline) directWorker(ctx context.Context) {
defer p.producer.Done()
for {
@@ -232,7 +218,12 @@ func (p *Pipeline) directWorker(ctx context.Context) {
if !ok {
return
}
p.pushDB(ctx, p.enrich(ctx, c))
// Direct provider matches are written immediately and are never
// queued behind optional DNS work, so they cannot be shed in favour
// of speculative CNAME enrichment. The matched provider hostname is
// the finding; A/AAAA enrichment is a DNS-bound luxury that the
// shared resolver cannot keep up with at these volumes.
p.pushDB(ctx, Finding{Candidate: c})
}
}
}
+55
View File
@@ -195,6 +195,61 @@ func TestCNAMEMatchIPv6(t *testing.T) {
}
}
type blockingResolver struct{ release chan struct{} }
func (b *blockingResolver) LookupCNAME(ctx context.Context, host string) (string, error) {
select {
case <-b.release:
case <-ctx.Done():
}
return host, nil
}
func (b *blockingResolver) LookupIP(ctx context.Context, host string) ([]net.IP, error) {
return nil, nil
}
// TestDirectNotStarvedByDNSBacklog verifies direct provider matches are written
// even while the DNS queue is saturated with optional CNAME work.
func TestDirectNotStarvedByDNSBacklog(t *testing.T) {
col := &collector{}
release := make(chan struct{})
p := New(Config{
DNSQueueSize: 1,
DNSWorkers: 1,
Enricher: newEnricher(&blockingResolver{release: release}),
Write: col.write,
BatchSize: 10,
FlushInterval: 10 * time.Millisecond,
})
ctx, cancel := context.WithCancel(context.Background())
p.Start(ctx)
for i := 0; i < 100; i++ {
p.HandleHost("api" + strconv.Itoa(i) + ".example.com") // fills/blocks DNS queue
}
p.HandleHost("bucket.s3.eu-central-1.amazonaws.com")
found := false
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
for _, f := range col.all() {
if f.Hostname == "bucket.s3.eu-central-1.amazonaws.com" {
found = true
}
}
if found {
break
}
time.Sleep(5 * time.Millisecond)
}
close(release)
cancel()
p.Close()
if !found {
t.Fatal("direct match was not written while the DNS queue was saturated")
}
}
func TestQueueSaturationDrops(t *testing.T) {
m := metrics.New()
p := New(Config{DirectQueueSize: 1, DNSQueueSize: 1, Metrics: m})