First commit Go counter service
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
// Package domains reads the rspamd domains CDB (rspamd_domains_db) that holds
|
||||
// the organisation id and the daily limits of every Migadu-hosted domain.
|
||||
//
|
||||
// The file is built by the agent's extract.sh with tinycdb (`cdb -m`) from
|
||||
// lines of the form
|
||||
//
|
||||
// example.com 1049|O/i=200,O/o=20,D/i=200,D/o=20,A/i=200,A/o=20,info@/o=5
|
||||
//
|
||||
// i.e. key = lowercase domain (no NUL terminator), value =
|
||||
// "<org_id>|<comma separated limits>". Limit keys are "<level>/<flow>" with
|
||||
// level O (organisation), D (domain) or A (address default) and flow i
|
||||
// (incoming) or o (outgoing); "<localpart>@/<flow>" overrides the A default for
|
||||
// one mailbox. A level/flow without a key is unlimited. A domain that is not
|
||||
// in the file is not hosted by Migadu and is never counted.
|
||||
package domains
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/colinmarc/cdb"
|
||||
)
|
||||
|
||||
// DefaultMaxCached is the number of parsed records a DB caches when opened
|
||||
// with Open; the daemon passes its max_cached_domains instead.
|
||||
const DefaultMaxCached = 20000
|
||||
|
||||
// Limit levels and flows, as used in the CDB values and the Redis keys.
|
||||
const (
|
||||
OrganizationLevel = "O"
|
||||
DomainLevel = "D"
|
||||
AddressLevel = "A"
|
||||
|
||||
FlowIn = "i"
|
||||
FlowOut = "o"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned by Lookup when the domain is not in the CDB.
|
||||
var ErrNotFound = errors.New("domain not found in domains db")
|
||||
|
||||
// ErrMalformed wraps a Lookup error for a row that exists but cannot be
|
||||
// parsed (non-numeric org id). Callers treat it like "not hosted" and warn:
|
||||
// one bad generated row must never fail a request (review #8).
|
||||
var ErrMalformed = errors.New("malformed row in domains db")
|
||||
|
||||
// Mailbox strips a plus-address tag from the local part: "b+tag@d" → "b@d".
|
||||
// Counters are kept per mailbox, which is what the web app reads and what
|
||||
// rcptd resolves; a tag never names a different mailbox (review #7). An
|
||||
// address without "@", or whose local part would become empty, is returned
|
||||
// unchanged.
|
||||
func Mailbox(addr string) string {
|
||||
at := strings.LastIndex(addr, "@")
|
||||
if at <= 0 {
|
||||
return addr
|
||||
}
|
||||
plus := strings.Index(addr[:at], "+")
|
||||
if plus <= 0 {
|
||||
return addr
|
||||
}
|
||||
return addr[:plus] + addr[at:]
|
||||
}
|
||||
|
||||
// Limits maps limit keys ("O/i", "A/o", "alice@/o", ...) to their values.
|
||||
type Limits map[string]int64
|
||||
|
||||
// ParseLimits parses "O/i=5000, A/o=200, alice@/o=7" into a Limits map.
|
||||
// Keys are trimmed; pairs without "=", with an empty key or with a
|
||||
// non-numeric value are skipped rather than failing the whole record — the
|
||||
// file is generated, and one bad pair must not disable counting for a domain.
|
||||
func ParseLimits(s string) Limits {
|
||||
limits := Limits{}
|
||||
for _, pair := range strings.Split(s, ",") {
|
||||
k, v, ok := strings.Cut(pair, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
k = strings.TrimSpace(k)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
limits[k] = n
|
||||
}
|
||||
return limits
|
||||
}
|
||||
|
||||
// Limit resolves the configured limit for a level and flow, or (0, false)
|
||||
// when unlimited. At address level the per-mailbox override "<local>@/<flow>"
|
||||
// wins over the domain-wide default "A/<flow>" — same rule as the rspamd
|
||||
// plugin. localPart is ignored for the D and O levels.
|
||||
func (l Limits) Limit(level, flow, localPart string) (int64, bool) {
|
||||
switch level {
|
||||
case AddressLevel:
|
||||
if n, ok := l[localPart+"@/"+flow]; ok {
|
||||
return n, true
|
||||
}
|
||||
n, ok := l[AddressLevel+"/"+flow]
|
||||
return n, ok
|
||||
case DomainLevel, OrganizationLevel:
|
||||
n, ok := l[level+"/"+flow]
|
||||
return n, ok
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// String renders the limits sorted by key, e.g. "{A/i=200, A/o=20, O/i=200}",
|
||||
// the limits_map notation used in the counting transaction log.
|
||||
func (l Limits) String() string {
|
||||
keys := make([]string, 0, len(l))
|
||||
for k := range l {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var b strings.Builder
|
||||
b.WriteByte('{')
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
fmt.Fprintf(&b, "%s=%d", k, l[k])
|
||||
}
|
||||
b.WriteByte('}')
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Domain is one parsed CDB record.
|
||||
type Domain struct {
|
||||
Name string
|
||||
OrgID int64
|
||||
Limits Limits
|
||||
}
|
||||
|
||||
// ParseRecord parses a CDB value "<org_id>|<limits>" for domain. A missing
|
||||
// "|" yields a domain without limits; a missing or non-numeric org id is an
|
||||
// error because the O counters cannot be keyed without it.
|
||||
func ParseRecord(domain, value string) (*Domain, error) {
|
||||
orgPart, limitsPart, _ := strings.Cut(value, "|")
|
||||
orgPart = strings.TrimSpace(orgPart)
|
||||
orgID, err := strconv.ParseInt(orgPart, 10, 64)
|
||||
if err != nil || orgID < 0 {
|
||||
return nil, fmt.Errorf("%w: domain %s: invalid org id %q", ErrMalformed, domain, orgPart)
|
||||
}
|
||||
return &Domain{Name: domain, OrgID: orgID, Limits: ParseLimits(limitsPart)}, nil
|
||||
}
|
||||
|
||||
// DB is an open domains CDB. It is safe for concurrent use.
|
||||
//
|
||||
// Parsed records are cached per DB (domain → *Domain) up to maxCached
|
||||
// entries, because some values are huge (boxmailhere.com: 193 KB, 9 798
|
||||
// overrides; ~1 ms and 1.4 MB to parse). The cache is never invalidated: a
|
||||
// reload replaces the whole DB object, and the cache goes with it. The cached
|
||||
// *Domain is shared read-only; callers must not mutate it.
|
||||
type DB struct {
|
||||
path string
|
||||
db *cdb.CDB
|
||||
count int
|
||||
maxCached int
|
||||
cache sync.Map // domain -> *Domain
|
||||
cached atomic.Int64
|
||||
}
|
||||
|
||||
// Open opens the CDB at path with the default cache size.
|
||||
func Open(path string) (*DB, error) { return OpenWithCache(path, DefaultMaxCached) }
|
||||
|
||||
// OpenWithCache opens the CDB at path and counts its records (a few
|
||||
// milliseconds for ~100k records; the count feeds the cdb_domains gauge and
|
||||
// the reload log). maxCached bounds the parsed-record cache; 0 disables it.
|
||||
func OpenWithCache(path string, maxCached int) (*DB, error) {
|
||||
c, err := cdb.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open domains db %s: %w", path, err)
|
||||
}
|
||||
n := 0
|
||||
it := c.Iter()
|
||||
for it.Next() {
|
||||
n++
|
||||
}
|
||||
if err := it.Err(); err != nil {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("read domains db %s: %w", path, err)
|
||||
}
|
||||
if maxCached < 0 {
|
||||
maxCached = 0
|
||||
}
|
||||
return &DB{path: path, db: c, count: n, maxCached: maxCached}, nil
|
||||
}
|
||||
|
||||
// Lookup returns the record for domain (trimmed and lowercased first), or
|
||||
// ErrNotFound when the domain is not hosted here. The returned *Domain may
|
||||
// be shared with other callers and must be treated as read-only.
|
||||
func (d *DB) Lookup(domain string) (*Domain, error) {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
if domain == "" {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if hit, ok := d.cache.Load(domain); ok {
|
||||
return hit.(*Domain), nil
|
||||
}
|
||||
v, err := d.db.Get([]byte(domain))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read domains db %s: %w", d.path, err)
|
||||
}
|
||||
if v == nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
rec, err := ParseRecord(domain, string(v))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d.maxCached > 0 && d.cached.Load() < int64(d.maxCached) {
|
||||
if prev, loaded := d.cache.LoadOrStore(domain, rec); loaded {
|
||||
return prev.(*Domain), nil
|
||||
}
|
||||
d.cached.Add(1)
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// Cached returns the number of parsed records currently cached.
|
||||
func (d *DB) Cached() int { return int(d.cached.Load()) }
|
||||
|
||||
// Ping reads the first record and reports an error when the file can no
|
||||
// longer be read or holds no domains at all (nothing would ever be counted).
|
||||
func (d *DB) Ping() error {
|
||||
it := d.db.Iter()
|
||||
if it.Next() {
|
||||
return nil
|
||||
}
|
||||
if err := it.Err(); err != nil {
|
||||
return fmt.Errorf("read domains db %s: %w", d.path, err)
|
||||
}
|
||||
return fmt.Errorf("domains db %s is empty", d.path)
|
||||
}
|
||||
|
||||
// Path returns the file the DB was opened from.
|
||||
func (d *DB) Path() string { return d.path }
|
||||
|
||||
// Count returns the number of records counted at Open.
|
||||
func (d *DB) Count() int { return d.count }
|
||||
|
||||
// Close closes the underlying file; in-flight Lookups then fail with an error.
|
||||
func (d *DB) Close() error { return d.db.Close() }
|
||||
@@ -0,0 +1,389 @@
|
||||
package domains
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/colinmarc/cdb"
|
||||
)
|
||||
|
||||
// All expectations below come from the real production-shaped CDB shipped in
|
||||
// test_files/, dumped with `cdb -d -m` (tinycdb). 93 549 records, every value
|
||||
// "<org_id>|O/i,O/o,D/i,D/o,A/i,A/o[,<local>@/<flow>...]".
|
||||
const testCDB = "../../test_files/rspamd_domains_db.cdb"
|
||||
|
||||
func openTestDB(t *testing.T) *DB {
|
||||
t.Helper()
|
||||
db, err := Open(testCDB)
|
||||
if err != nil {
|
||||
t.Fatalf("Open(%s): %v", testCDB, err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func mustLookup(t *testing.T, db *DB, domain string) *Domain {
|
||||
t.Helper()
|
||||
d, err := db.Lookup(domain)
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup(%q): %v", domain, err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func TestOpen_MissingFile(t *testing.T) {
|
||||
if _, err := Open("/nonexistent/rspamd_domains_db"); err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDB_PathAndCount(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
if db.Path() != testCDB {
|
||||
t.Errorf("Path() = %q", db.Path())
|
||||
}
|
||||
if got := db.Count(); got != 93549 {
|
||||
t.Errorf("Count() = %d, want 93549", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookup_RealRecord(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
d := mustLookup(t, db, "yagolivas.com")
|
||||
if d.Name != "yagolivas.com" || d.OrgID != 1049 {
|
||||
t.Fatalf("got %+v", d)
|
||||
}
|
||||
want := Limits{"O/i": 200, "O/o": 20, "D/i": 200, "D/o": 20, "A/i": 200, "A/o": 20}
|
||||
if len(d.Limits) != len(want) {
|
||||
t.Fatalf("limits = %v, want %v", d.Limits, want)
|
||||
}
|
||||
for k, v := range want {
|
||||
if d.Limits[k] != v {
|
||||
t.Errorf("limits[%s] = %d, want %d", k, d.Limits[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookup_NotFound(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
for _, domain := range []string{"gmail.com", "example.com", "example.org", ""} {
|
||||
_, err := db.Lookup(domain)
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("Lookup(%q) err = %v, want ErrNotFound", domain, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookup_NormalizesCase(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
d := mustLookup(t, db, " YagoLivas.COM ")
|
||||
if d.OrgID != 1049 || d.Name != "yagolivas.com" {
|
||||
t.Errorf("got %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// boxmailhere.com carries a 193 KB value with 9 798 per-address overrides.
|
||||
func TestLookup_HugeRecord(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
d := mustLookup(t, db, "boxmailhere.com")
|
||||
if d.OrgID != 87716 {
|
||||
t.Errorf("OrgID = %d, want 87716", d.OrgID)
|
||||
}
|
||||
if len(d.Limits) != 9804 {
|
||||
t.Errorf("len(Limits) = %d, want 9804 (6 base + 9798 overrides)", len(d.Limits))
|
||||
}
|
||||
checkLimit(t, d.Limits, AddressLevel, FlowIn, "gangstaelmo7", 500, true)
|
||||
checkLimit(t, d.Limits, AddressLevel, FlowOut, "gangstaelmo7", 30, true)
|
||||
checkLimit(t, d.Limits, AddressLevel, FlowIn, "nobody-with-override", 250000, true)
|
||||
checkLimit(t, d.Limits, OrganizationLevel, FlowOut, "", 4000, true)
|
||||
}
|
||||
|
||||
func checkLimit(t *testing.T, l Limits, level, flow, local string, want int64, wantOK bool) {
|
||||
t.Helper()
|
||||
got, ok := l.Limit(level, flow, local)
|
||||
if got != want || ok != wantOK {
|
||||
t.Errorf("Limit(%s, %s, %q) = (%d, %v), want (%d, %v)", level, flow, local, got, ok, want, wantOK)
|
||||
}
|
||||
}
|
||||
|
||||
// crisdanshopping.com.br: A/i=500,A/o=100 plus contato@ and admin@ overrides.
|
||||
func TestLimit_AddressOverride(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
d := mustLookup(t, db, "crisdanshopping.com.br")
|
||||
if d.OrgID != 56 {
|
||||
t.Fatalf("OrgID = %d", d.OrgID)
|
||||
}
|
||||
l := d.Limits
|
||||
checkLimit(t, l, AddressLevel, FlowIn, "contato", 300, true)
|
||||
checkLimit(t, l, AddressLevel, FlowOut, "contato", 150, true)
|
||||
checkLimit(t, l, AddressLevel, FlowIn, "admin", 300, true)
|
||||
checkLimit(t, l, AddressLevel, FlowOut, "admin", 150, true)
|
||||
checkLimit(t, l, AddressLevel, FlowIn, "someone", 500, true) // falls back to A/i
|
||||
checkLimit(t, l, AddressLevel, FlowOut, "someone", 100, true)
|
||||
checkLimit(t, l, DomainLevel, FlowIn, "", 1000, true)
|
||||
checkLimit(t, l, DomainLevel, FlowOut, "", 120, true)
|
||||
checkLimit(t, l, OrganizationLevel, FlowIn, "", 1000, true)
|
||||
checkLimit(t, l, OrganizationLevel, FlowOut, "", 120, true)
|
||||
// local part is irrelevant for D and O
|
||||
checkLimit(t, l, DomainLevel, FlowIn, "contato", 1000, true)
|
||||
}
|
||||
|
||||
// meu-servidor.com has overrides whose local parts contain dots.
|
||||
func TestLimit_DottedLocalPart(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
d := mustLookup(t, db, "meu-servidor.com")
|
||||
if d.OrgID != 726 {
|
||||
t.Fatalf("OrgID = %d", d.OrgID)
|
||||
}
|
||||
checkLimit(t, d.Limits, AddressLevel, FlowIn, "sexprime.xxx", 100, true)
|
||||
checkLimit(t, d.Limits, AddressLevel, FlowIn, "cf-tgamesandroid.com", 1000, true)
|
||||
checkLimit(t, d.Limits, AddressLevel, FlowOut, "cf-tgamesandroid.com", 100, true)
|
||||
checkLimit(t, d.Limits, AddressLevel, FlowIn, "alerta", 1000, true)
|
||||
checkLimit(t, d.Limits, AddressLevel, FlowOut, "alerta", 100, true)
|
||||
checkLimit(t, d.Limits, AddressLevel, FlowIn, "nobody", 500, true)
|
||||
}
|
||||
|
||||
func TestLimit_MigaduSelf(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
d := mustLookup(t, db, "migadu.com")
|
||||
if d.OrgID != 3743 {
|
||||
t.Fatalf("OrgID = %d", d.OrgID)
|
||||
}
|
||||
checkLimit(t, d.Limits, AddressLevel, FlowIn, "admin", 10000, true)
|
||||
checkLimit(t, d.Limits, AddressLevel, FlowIn, "someone", 100000, true)
|
||||
}
|
||||
|
||||
func TestLimit_Unlimited(t *testing.T) {
|
||||
l := ParseLimits("O/i=5")
|
||||
checkLimit(t, l, OrganizationLevel, FlowIn, "", 5, true)
|
||||
checkLimit(t, l, OrganizationLevel, FlowOut, "", 0, false)
|
||||
checkLimit(t, l, DomainLevel, FlowIn, "", 0, false)
|
||||
checkLimit(t, l, AddressLevel, FlowIn, "x", 0, false)
|
||||
checkLimit(t, Limits(nil), AddressLevel, FlowIn, "x", 0, false)
|
||||
checkLimit(t, l, "Z", FlowIn, "", 0, false) // unknown level
|
||||
}
|
||||
|
||||
func TestParseLimits(t *testing.T) {
|
||||
l := ParseLimits(" O/i = 5000,O/o=1000, broken ,x=notanumber,=3,A/o=,,alice@/o=7")
|
||||
want := Limits{"O/i": 5000, "O/o": 1000, "alice@/o": 7}
|
||||
if len(l) != len(want) {
|
||||
t.Fatalf("got %v, want %v", l, want)
|
||||
}
|
||||
for k, v := range want {
|
||||
if l[k] != v {
|
||||
t.Errorf("%s = %d, want %d", k, l[k], v)
|
||||
}
|
||||
}
|
||||
if got := ParseLimits(""); len(got) != 0 {
|
||||
t.Errorf("ParseLimits(\"\") = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRecord(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, value string
|
||||
wantOrg int64
|
||||
wantLimits int
|
||||
wantErr bool
|
||||
}{
|
||||
{"normal", "1049|O/i=200,O/o=20", 1049, 2, false},
|
||||
{"spaces", " 7 | A/i=3 ", 7, 1, false},
|
||||
{"no pipe", "42", 42, 0, false}, // must not panic (the Lua plugin did)
|
||||
{"empty limits", "42|", 42, 0, false},
|
||||
{"non-numeric org", "abc|O/i=1", 0, 0, true},
|
||||
{"empty", "", 0, 0, true},
|
||||
{"negative org", "-1|O/i=1", 0, 0, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
d, err := ParseRecord("x.com", tt.value)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got %+v", d)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if d.Name != "x.com" || d.OrgID != tt.wantOrg || len(d.Limits) != tt.wantLimits {
|
||||
t.Errorf("got %+v, want org=%d limits=%d", d, tt.wantOrg, tt.wantLimits)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// String renders the Lua-like limits_map used by the transaction log, sorted.
|
||||
func TestLimits_String(t *testing.T) {
|
||||
l := ParseLimits("O/o=20,A/i=200,A/o=20,O/i=200,D/i=200,D/o=20,info@/o=5")
|
||||
want := "{A/i=200, A/o=20, D/i=200, D/o=20, O/i=200, O/o=20, info@/o=5}"
|
||||
if got := l.String(); got != want {
|
||||
t.Errorf("String() = %q, want %q", got, want)
|
||||
}
|
||||
if got := (Limits{}).String(); got != "{}" {
|
||||
t.Errorf("empty String() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDB_Ping(t *testing.T) {
|
||||
db, err := Open(testCDB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
t.Errorf("Ping() = %v", err)
|
||||
}
|
||||
db.Close()
|
||||
if err := db.Ping(); err == nil {
|
||||
t.Error("Ping() after Close() should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// A CDB written with colinmarc/cdb's Writer (as tests and tooling do) must be
|
||||
// readable through the same path as the agent-built file.
|
||||
func TestLookup_WriterBuiltCDB(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "small.cdb")
|
||||
w, err := cdb.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Put([]byte("newdomain.test"), []byte("5|A/i=1,A/o=1,D/i=2,D/o=2,O/i=3,O/o=3")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if db.Count() != 1 {
|
||||
t.Errorf("Count() = %d, want 1", db.Count())
|
||||
}
|
||||
d, err := db.Lookup("newdomain.test")
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup: %v", err)
|
||||
}
|
||||
if d.OrgID != 5 {
|
||||
t.Errorf("OrgID = %d", d.OrgID)
|
||||
}
|
||||
if _, err := db.Lookup("other.test"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("Lookup(other) err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Parsed records are cached per loaded table: a second Lookup of the same
|
||||
// domain returns the very same *Domain (no re-parse), and is safe because
|
||||
// callers never mutate it.
|
||||
func TestLookup_CachesParsedRecord(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
first := mustLookup(t, db, "boxmailhere.com")
|
||||
second := mustLookup(t, db, "BoxMailHere.com ")
|
||||
if first != second {
|
||||
t.Error("second lookup re-parsed the record instead of serving the cached *Domain")
|
||||
}
|
||||
if db.Cached() != 1 {
|
||||
t.Errorf("Cached() = %d, want 1", db.Cached())
|
||||
}
|
||||
if _, err := db.Lookup("nobody.invalid"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if db.Cached() != 1 {
|
||||
t.Errorf("not-found lookups must not be cached: Cached() = %d", db.Cached())
|
||||
}
|
||||
}
|
||||
|
||||
// The cache is bounded: beyond max entries lookups still work but parse
|
||||
// without caching; 0 disables the cache entirely.
|
||||
func TestLookup_CacheBounded(t *testing.T) {
|
||||
db, err := OpenWithCache(testCDB, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
a1 := mustLookup(t, db, "yagolivas.com")
|
||||
a2 := mustLookup(t, db, "yagolivas.com")
|
||||
b1 := mustLookup(t, db, "boxmailhere.com")
|
||||
b2 := mustLookup(t, db, "boxmailhere.com")
|
||||
if a1 != a2 {
|
||||
t.Error("first domain should be cached")
|
||||
}
|
||||
if b1 == b2 {
|
||||
t.Error("second domain exceeds max_cached_domains=1 and must not be cached")
|
||||
}
|
||||
if b1.OrgID != 87716 || db.Cached() != 1 {
|
||||
t.Errorf("uncached lookup still parses: org=%d cached=%d", b1.OrgID, db.Cached())
|
||||
}
|
||||
|
||||
off, err := OpenWithCache(testCDB, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer off.Close()
|
||||
if mustLookup(t, off, "yagolivas.com") == mustLookup(t, off, "yagolivas.com") {
|
||||
t.Error("cache disabled (0) must parse every time")
|
||||
}
|
||||
}
|
||||
|
||||
func benchLookup(b *testing.B, dom string) {
|
||||
db, err := Open(testCDB)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Lookup(dom); err != nil { // warm the cache
|
||||
b.Fatal(err)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := db.Lookup(dom); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With the cache warm, the 193 KB boxmailhere.com record costs the same as a
|
||||
// small one (review finding #5: 0.9 ms and 1.4 MB per lookup before).
|
||||
func BenchmarkLookupSmall(b *testing.B) { benchLookup(b, "yagolivas.com") }
|
||||
func BenchmarkLookupHuge(b *testing.B) { benchLookup(b, "boxmailhere.com") }
|
||||
|
||||
// A row with a non-numeric org id is reported as malformed, which callers
|
||||
// treat like "not hosted" (review #8) — it must never fail the request.
|
||||
func TestLookup_MalformedRow(t *testing.T) {
|
||||
db, err := Open("../../review/_proof_tests/b/bad.cdb")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
_, err = db.Lookup("bad.com")
|
||||
if !errors.Is(err, ErrMalformed) {
|
||||
t.Fatalf("Lookup(bad.com) err = %v, want ErrMalformed", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), `invalid org id "x12"`) {
|
||||
t.Errorf("error should name the problem: %v", err)
|
||||
}
|
||||
if d, err := db.Lookup("good.com"); err != nil || d.OrgID != 7 {
|
||||
t.Errorf("good.com = %+v, %v", d, err)
|
||||
}
|
||||
if db.Cached() != 1 {
|
||||
t.Errorf("malformed rows must not be cached: Cached() = %d", db.Cached())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailbox(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"b+1@example.com": "b@example.com",
|
||||
"b+tag+more@example.com": "b@example.com",
|
||||
"b@example.com": "b@example.com",
|
||||
"+lead@example.com": "+lead@example.com", // empty local part would be nonsense: keep as is
|
||||
"nodomain": "nodomain",
|
||||
} {
|
||||
if got := Mailbox(in); got != want {
|
||||
t.Errorf("Mailbox(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user