Files
Maksim Totmin 9b85df9f6c test(e2e): add a prod-scale Novosibirsk suite
- process-level tests: seed a real EДДС Bulk NDJSON, start the daemon, run
  the full-registry selfcheck, canonical HTTP sample, real address-log
  queries, strict house identity, input limits, load and graceful shutdown
- production data stays outside the repo (ADDRKIT_E2E_REGISTRY/LOG); all
  temporary files live in t.TempDir()
- add `make e2e` and `make check` (vet + race tests + dependency guard)
2026-10-27 14:36:08 +07:00

1076 lines
30 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build e2e
// Package e2e contains process-level tests against a real registry snapshot.
//
// They build the CLI and the daemon, seed a temporary SQLite index from an
// EДДС Bulk NDJSON dump, start the HTTP service and exercise it with the whole
// registry (selfcheck) plus a large concurrent sample over HTTP.
//
// The data is production-derived and contains personal data. The tests never
// copy it into the repository: the input path is supplied through the
// ADDRKIT_E2E_REGISTRY environment variable, and every temporary file lives in
// t.TempDir().
//
// Run with:
//
// ADDRKIT_E2E_REGISTRY=/path/to/address_registry.ndjson \
// ADDRKIT_E2E_LOG=/path/to/edds_address_log.ndjson \
// go test -tags e2e -v ./test/e2e
package e2e
import (
"bytes"
"database/sql"
"encoding/json"
"fmt"
"io"
"math/rand"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"syscall"
"testing"
"time"
"git.totmin.ru/en2zmax/addrkit/internal/adapters/registrystore"
"git.totmin.ru/en2zmax/addrkit/internal/domain/address"
"git.totmin.ru/en2zmax/addrkit/internal/domain/match"
)
const (
defaultCity = "Новосибирск"
// The service must answer canonical registry rows quickly. The limit is
// deliberately generous: it catches pathological regressions, not jitter
// on a shared CI machine.
maxP99 = 100 * time.Millisecond
maxRSS = 1 << 30 // 1 GiB
)
type serviceConfig struct {
Addr string `json:"addr"`
DataPath string `json:"data_path"`
LogLevel string `json:"log_level"`
Cities []cityConfig `json:"cities"`
DefaultCity string `json:"default_city"`
Resolve resolveConfig `json:"resolve"`
LLM llmConfig `json:"llm"`
}
type cityConfig struct {
Name string `json:"name"`
Aliases []string `json:"aliases,omitempty"`
}
type resolveConfig struct {
FuzzyThreshold float64 `json:"fuzzy_threshold"`
AmbiguityMargin float64 `json:"ambiguity_margin"`
MaxCandidates int `json:"max_candidates"`
}
type llmConfig struct {
Enabled bool `json:"enabled"`
}
type seedOutput struct {
Entries int `json:"entries"`
Stats struct {
Total int `json:"total"`
Parsed int `json:"parsed"`
Unparsed int `json:"unparsed"`
Duplicates int `json:"duplicates"`
DistinctStreets int `json:"distinct_streets"`
Cities map[string]int `json:"cities"`
UnparsedSample []string `json:"unparsed_sample"`
} `json:"stats"`
}
type selfcheckReport struct {
Entries int `json:"entries"`
Exact int `json:"exact"`
ExactRate float64 `json:"exact_rate"`
NonExact map[string]int `json:"non_exact"`
Failures []string `json:"failures"`
}
type env struct {
root string
binDir string
addrkit string
addrkitd string
db string
addr string
city string
parser *address.Parser
httpClient *http.Client
seedReport *seedOutput
}
func TestProdNovosibirsk(t *testing.T) {
registry := os.Getenv("ADDRKIT_E2E_REGISTRY")
if registry == "" {
t.Skip("ADDRKIT_E2E_REGISTRY is not set")
}
if _, err := os.Stat(registry); err != nil {
t.Fatalf("registry dump: %v", err)
}
e := newEnv(t)
entries := e.seed(t, registry)
if len(entries) < 70_000 {
t.Fatalf("seeded only %d entries; expected a full registry", len(entries))
}
if got := e.seedReport.Stats.Cities[e.city]; got != len(entries) {
t.Fatalf("city %q covers %d of %d entries", e.city, got, len(entries))
}
t.Logf("seeded: total=%d parsed=%d unparsed=%d duplicates=%d streets=%d entries=%d",
e.seedReport.Stats.Total, e.seedReport.Stats.Parsed, e.seedReport.Stats.Unparsed,
e.seedReport.Stats.Duplicates, e.seedReport.Stats.DistinctStreets, len(entries))
if e.seedReport.Stats.Unparsed*1000 > e.seedReport.Stats.Total {
t.Errorf("unparsed rows = %d of %d, want <= 0.1%%", e.seedReport.Stats.Unparsed, e.seedReport.Stats.Total)
}
// Source rows without a house are indexed for street lookups but cannot
// round-trip to "found"; the known EДДС formats ("27с1", "5 киоск") are
// counted by selfcheck and excluded from the exact round-trip sample.
resolvable := make([]address.Entry, 0, len(entries))
for _, en := range entries {
if en.House != "" {
resolvable = append(resolvable, en)
}
}
d := e.startDaemon(t, e.writeConfig(t, e.db, e.city))
t.Run("CanonicalSelfcheck", func(t *testing.T) { e.checkSelfcheck(t, len(entries)) })
t.Run("HTTPCanonicalSample", func(t *testing.T) { e.checkCanonicalHTTP(t, resolvable) })
t.Run("RealEДДСQueries", func(t *testing.T) { e.checkRealQueries(t) })
t.Run("CityAliases", func(t *testing.T) { e.checkCityAliases(t, resolvable) })
t.Run("StrictHouseIdentity", func(t *testing.T) { e.checkStrictHouse(t, resolvable) })
t.Run("InputLimits", func(t *testing.T) { e.checkInputLimits(t) })
t.Run("LoadAndSafety", func(t *testing.T) { e.checkLoad(t, resolvable, d) })
// The service must shut down cleanly on SIGTERM.
if err := d.stop(t); err != nil {
t.Fatalf("graceful shutdown: %v", err)
}
}
func TestRejectsIncompatibleDeployments(t *testing.T) {
registry := os.Getenv("ADDRKIT_E2E_REGISTRY")
if registry == "" {
t.Skip("ADDRKIT_E2E_REGISTRY is not set")
}
e := newEnv(t)
// The binary must be built even when the fixtures below are synthetic.
e.db = filepath.Join(t.TempDir(), "missing.db")
t.Run("OldSchema", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "old.db")
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatalf("open: %v", err)
}
if _, err := db.Exec(`CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`); err != nil {
t.Fatalf("create meta: %v", err)
}
if _, err := db.Exec(`INSERT INTO meta(key, value) VALUES ('schema_version', '1')`); err != nil {
t.Fatalf("insert schema: %v", err)
}
db.Close()
code, logs := e.runDaemonExpectFailure(t, e.writeConfig(t, path, e.city))
if code == 0 {
t.Fatalf("daemon accepted a schema-v1 database")
}
if !strings.Contains(logs, "unsupported index schema") {
t.Fatalf("unexpected failure reason:\n%s", tail(logs, 20))
}
})
t.Run("UnknownConfigField", func(t *testing.T) {
cfg := e.writeConfig(t, e.db, e.city)
raw, err := os.ReadFile(cfg)
if err != nil {
t.Fatalf("read config: %v", err)
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("decode config: %v", err)
}
m["portal"] = map[string]string{"url": "http://example"}
out, err := json.Marshal(m)
if err != nil {
t.Fatalf("encode config: %v", err)
}
bad := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(bad, out, 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
code, logs := e.runDaemonExpectFailure(t, bad)
if code == 0 {
t.Fatalf("daemon accepted an unknown config field")
}
if !strings.Contains(logs, "unknown field") {
t.Fatalf("unexpected failure reason:\n%s", tail(logs, 20))
}
})
t.Run("EmptyIndex", func(t *testing.T) {
in := filepath.Join(t.TempDir(), "empty.ndjson")
if err := os.WriteFile(in, []byte("{\"address\":\"!!!\"}\n"), 0o600); err != nil {
t.Fatalf("write input: %v", err)
}
out := filepath.Join(t.TempDir(), "empty.db")
cmd := exec.Command(e.addrkit, "seed", "-in", in, "-out", out, "-city", e.city)
var logs bytes.Buffer
cmd.Stdout = &logs
cmd.Stderr = &logs
if err := cmd.Run(); err == nil {
t.Fatalf("seed accepted an unparseable dump")
}
if !strings.Contains(logs.String(), "no parseable entries") {
t.Fatalf("unexpected failure reason:\n%s", tail(logs.String(), 20))
}
})
}
func newEnv(t *testing.T) *env {
t.Helper()
root := repoRoot(t)
binDir := t.TempDir()
e := &env{
root: root,
binDir: binDir,
addrkit: filepath.Join(binDir, "addrkit"),
addrkitd: filepath.Join(binDir, "addrkitd"),
city: envOr("ADDRKIT_E2E_CITY", defaultCity),
httpClient: &http.Client{Timeout: 10 * time.Second},
}
e.parser = address.NewParser(address.NewDictionary([]address.City{{
Name: e.city,
Aliases: []string{"нск", "новосиб", "новосибирская область", "нсо"},
}}), e.city)
e.build(t)
return e
}
func repoRoot(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
return filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
}
func (e *env) build(t *testing.T) {
t.Helper()
for _, target := range []struct{ out, pkg string }{
{e.addrkit, "./cmd/addrkit"},
{e.addrkitd, "./cmd/addrkitd"},
} {
cmd := exec.Command("go", "build", "-trimpath", "-o", target.out, target.pkg)
cmd.Dir = e.root
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("build %s: %v\n%s", target.pkg, err, out)
}
}
}
func (e *env) seed(t *testing.T, registry string) []address.Entry {
t.Helper()
e.db = filepath.Join(t.TempDir(), "registry.db")
cmd := exec.Command(e.addrkit, "seed",
"-in", registry, "-out", e.db, "-version", "e2e-prod", "-city", e.city)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("seed: %v\n%s", err, tail(string(out), 30))
}
var report seedOutput
if err := json.Unmarshal(out, &report); err != nil {
t.Fatalf("decode seed output: %v\n%s", err, tail(string(out), 10))
}
e.seedReport = &report
if report.Stats.Parsed == 0 {
t.Fatalf("seed produced no parsed rows")
}
_, entries, err := registrystore.Load(e.db)
if err != nil {
t.Fatalf("load seeded db: %v", err)
}
return entries
}
func (e *env) writeConfig(t *testing.T, dbPath, city string) string {
t.Helper()
cfg := serviceConfig{
Addr: e.freeAddr(t),
DataPath: dbPath,
LogLevel: "error", // access logs would drown the e2e output
Cities: []cityConfig{{Name: city, Aliases: []string{"нск", "новосиб", "новосибирская область", "нсо"}}},
DefaultCity: city,
Resolve: resolveConfig{
FuzzyThreshold: 0.82,
AmbiguityMargin: 0.06,
MaxCandidates: 5,
},
LLM: llmConfig{Enabled: false},
}
path := filepath.Join(t.TempDir(), "config.json")
writeJSON(t, path, cfg)
return path
}
func (e *env) freeAddr(t *testing.T) string {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("free port: %v", err)
}
addr := l.Addr().String()
l.Close()
e.addr = addr
return addr
}
func (e *env) startDaemon(t *testing.T, cfgPath string) *daemon {
t.Helper()
d := startProcess(t, e.addrkitd, "-config", cfgPath)
t.Cleanup(func() { _ = d.stop(t) })
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
if d.exited() {
t.Fatalf("daemon exited during startup:\n%s", tail(d.logs.String(), 30))
}
resp, err := e.httpClient.Get("http://" + e.addr + "/v1/readyz")
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return d
}
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("daemon did not become ready:\n%s", tail(d.logs.String(), 30))
return nil
}
func (e *env) runDaemonExpectFailure(t *testing.T, cfgPath string) (int, string) {
t.Helper()
d := startProcess(t, e.addrkitd, "-config", cfgPath)
err, ok := d.wait(15 * time.Second)
if !ok {
d.cmd.Process.Kill()
t.Fatalf("daemon did not exit:\n%s", tail(d.logs.String(), 30))
}
code := 0
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
code = ee.ExitCode()
} else {
code = -1
}
}
return code, d.logs.String()
}
func (e *env) checkSelfcheck(t *testing.T, wantEntries int) {
t.Helper()
cmd := exec.Command(e.addrkit, "selfcheck", "-db", e.db)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("selfcheck: %v\n%s", err, tail(string(out), 20))
}
var rep selfcheckReport
if err := json.Unmarshal(out, &rep); err != nil {
t.Fatalf("decode selfcheck: %v", err)
}
t.Logf("selfcheck: entries=%d exact=%d rate=%.6f non_exact=%v failures=%d",
rep.Entries, rep.Exact, rep.ExactRate, rep.NonExact, len(rep.Failures))
if rep.Entries != wantEntries {
t.Errorf("selfcheck entries = %d, want %d", rep.Entries, wantEntries)
}
if rep.ExactRate < 0.999 {
t.Errorf("exact rate = %.6f, want >= 0.999", rep.ExactRate)
}
if rep.NonExact["not_found"] > 0 || rep.NonExact["ambiguous"] > 0 {
t.Errorf("canonical rows must not be not_found/ambiguous: %v", rep.NonExact)
}
if len(rep.Failures) > 20 {
t.Errorf("too many known-corrupt source rows: %d", len(rep.Failures))
}
}
func (e *env) checkCanonicalHTTP(t *testing.T, entries []address.Entry) {
t.Helper()
n := envInt("ADDRKIT_E2E_SAMPLE", 2000)
if n > len(entries) {
n = len(entries)
}
idx := sampleIndexes(len(entries), n, 7)
var mu sync.Mutex
var firstErr error
setErr := func(err error) {
mu.Lock()
if firstErr == nil {
firstErr = err
}
mu.Unlock()
}
runConcurrent(32, len(idx), func(i int) {
want := entries[idx[i]]
code, body, err := e.do("/v1/resolve", map[string]string{"address": want.Address})
if err != nil {
setErr(fmt.Errorf("%q: %v", want.Address, err))
return
}
if code != http.StatusOK {
setErr(fmt.Errorf("%q: HTTP %d: %s", want.Address, code, tail(string(body), 3)))
return
}
var res match.Result
if err := json.Unmarshal(body, &res); err != nil {
setErr(fmt.Errorf("%q: decode: %v", want.Address, err))
return
}
if res.Status != address.StatusFound || res.Address != want.Address || res.City != e.city {
setErr(fmt.Errorf("%q: got status=%s address=%q city=%q", want.Address, res.Status, res.Address, res.City))
}
})
mu.Lock()
err := firstErr
mu.Unlock()
if err != nil {
t.Fatal(err)
}
t.Logf("canonical HTTP sample: %d/%d exact", n, n)
}
func (e *env) checkRealQueries(t *testing.T) {
t.Helper()
path := os.Getenv("ADDRKIT_E2E_LOG")
if path == "" {
t.Skip("ADDRKIT_E2E_LOG is not set")
}
logs, err := readAddressLog(path)
if err != nil {
t.Fatalf("read address log: %v", err)
}
if len(logs) == 0 {
t.Skip("address log is empty")
}
// Canonical requests are what the old pipeline passed to Elasticsearch:
// addrkit must handle all of them.
seen := map[string]struct{}{}
var canonical []string
for _, rec := range logs {
if rec.Request == "" {
continue
}
if _, ok := seen[rec.Request]; ok {
continue
}
seen[rec.Request] = struct{}{}
canonical = append(canonical, rec.Request)
}
for _, q := range canonical {
code, body := e.post(t, "/v1/resolve", map[string]string{"address": q})
res := match.Result{}
_ = json.Unmarshal(body, &res)
if code != http.StatusOK || res.Status != address.StatusFound {
t.Errorf("canonical EДДС request %q: HTTP %d status=%s", q, code, res.Status)
}
}
// Raw utterances also contain spelled-out numbers and typos which the old
// pipeline fixed with SEE before calling the normalizer. They are
// informational, but the found rate must not collapse.
counts := map[address.Status]int{}
for _, rec := range logs {
if rec.Utterance == "" {
continue
}
_, body := e.post(t, "/v1/resolve", map[string]string{"address": rec.Utterance})
var res match.Result
_ = json.Unmarshal(body, &res)
counts[res.Status]++
}
total := 0
for _, n := range counts {
total += n
}
foundRate := float64(counts[address.StatusFound]) / float64(max(total, 1))
t.Logf("real EДДС queries: canonical=%d raw=%d statuses=%v found_rate=%.2f",
len(canonical), total, counts, foundRate)
if foundRate < 0.5 {
t.Errorf("raw utterance found rate = %.2f, want >= 0.50", foundRate)
}
}
func (e *env) checkCityAliases(t *testing.T, entries []address.Entry) {
t.Helper()
base := entries[0].Address
for _, prefix := range []string{"нск, ", "г. Новосибирск, ", "Новосибирская область, ", "НСО, "} {
code, body := e.post(t, "/v1/resolve", map[string]string{"address": prefix + base})
var res match.Result
_ = json.Unmarshal(body, &res)
if code != http.StatusOK || res.Status != address.StatusFound || res.Address != base {
t.Errorf("%q prefix: HTTP %d status=%s address=%q", strings.TrimSpace(prefix), code, res.Status, res.Address)
}
}
code, body := e.post(t, "/v1/resolve", map[string]string{"address": base, "city": e.city})
var res match.Result
_ = json.Unmarshal(body, &res)
if code != http.StatusOK || res.Status != address.StatusFound || res.City != e.city {
t.Errorf("city hint: HTTP %d status=%s city=%q", code, res.Status, res.City)
}
for _, q := range []string{"нск", "г. Новосибирск", "Новосибирская область", "НСО"} {
code, body := e.post(t, "/v1/city", map[string]string{"query": q})
var got struct {
Status string `json:"status"`
City string `json:"city"`
}
_ = json.Unmarshal(body, &got)
if code != http.StatusOK || got.Status != "found" || got.City != e.city {
t.Errorf("/v1/city %q: HTTP %d %+v", q, code, got)
}
}
code, body = e.get(t, "/v1/cities")
var cities struct {
Cities []string `json:"cities"`
}
_ = json.Unmarshal(body, &cities)
if code != http.StatusOK || len(cities.Cities) != 1 || cities.Cities[0] != e.city {
t.Errorf("/v1/cities: HTTP %d %+v", code, cities)
}
code, body = e.post(t, "/v1/resolve", map[string]string{"address": "Томск, " + base})
_ = json.Unmarshal(body, &res)
if res.Status == address.StatusFound {
t.Errorf("unknown city must not resolve: %+v", res)
}
}
func (e *env) checkStrictHouse(t *testing.T, entries []address.Entry) {
t.Helper()
withStructure := firstWhere(entries, func(en address.Entry) bool { return en.Structure != "" })
if withStructure == nil {
t.Fatal("no entry with structure in the registry")
}
if res := e.resolve(t, withStructure.Address); res.Status != address.StatusFound || res.Address != withStructure.Address {
t.Fatalf("exact structure: %+v", res)
}
wrong := strings.Replace(withStructure.Address, "стр. "+withStructure.Structure, "стр. 999", 1)
if wrong == withStructure.Address {
wrong = withStructure.Address + " стр. 999"
}
if res := e.resolve(t, wrong); res.Status == address.StatusFound {
t.Errorf("wrong structure resolved to %q", res.Address)
}
withHouse := firstWhere(entries, func(en address.Entry) bool {
return en.House != "" && en.HouseFrac == "" && en.HouseLetter == ""
})
if withHouse == nil {
t.Fatal("no entry with a house in the registry")
}
badHouse := replaceHouse(withHouse.Address, withHouse.House, "99999")
if res := e.resolve(t, badHouse); res.Status == address.StatusFound {
t.Errorf("wrong house resolved to %q", res.Address)
}
withBuilding := firstWhere(entries, func(en address.Entry) bool { return en.Building != "" })
if withBuilding != nil {
bad := strings.Replace(withBuilding.Address, "к"+withBuilding.Building, "к999", 1)
if res := e.resolve(t, bad); res.Status == address.StatusFound {
t.Errorf("wrong building resolved to %q", res.Address)
}
}
}
func (e *env) checkInputLimits(t *testing.T) {
t.Helper()
long := strings.Repeat("а", 600)
if code, _ := e.post(t, "/v1/resolve", map[string]string{"address": long}); code != http.StatusBadRequest {
t.Errorf("oversized address: HTTP %d, want 400", code)
}
if code, _ := e.post(t, "/v1/resolve", map[string]string{}); code != http.StatusBadRequest {
t.Errorf("empty address: HTTP %d, want 400", code)
}
if code, _ := e.post(t, "/v1/resolve", "not-json"); code != http.StatusBadRequest {
t.Errorf("invalid json: HTTP %d, want 400", code)
}
if code, _ := e.post(t, "/v1/city", map[string]string{}); code != http.StatusBadRequest {
t.Errorf("empty city query: HTTP %d, want 400", code)
}
}
type loadStats struct {
mu sync.Mutex
latencies []time.Duration
canonical int
canonicalOK int
safety int
httpErrors int
found int
statuses map[address.Status]int
}
func (s *loadStats) record(lat time.Duration, canonical bool, res match.Result, err error) {
s.mu.Lock()
defer s.mu.Unlock()
s.latencies = append(s.latencies, lat)
if canonical {
s.canonical++
}
if err != nil {
s.httpErrors++
return
}
if res.Status == address.StatusFound {
s.found++
}
if s.statuses == nil {
s.statuses = map[address.Status]int{}
}
s.statuses[res.Status]++
if canonical && res.Status == address.StatusFound {
s.canonicalOK++
}
}
func (e *env) checkLoad(t *testing.T, entries []address.Entry, d *daemon) {
t.Helper()
total := envInt("ADDRKIT_E2E_LOAD", 10_000)
workers := 64
rng := rand.New(rand.NewSource(42))
type job struct {
query string
canonical bool
want address.Entry
}
jobs := make([]job, total)
for i := range jobs {
en := entries[rng.Intn(len(entries))]
q := en.Address
canonical := true
switch {
case i%10 == 9: // 10% typos
q = insertTypo(q, rng)
canonical = false
case i%17 == 0: // every 17th query carries a city alias
q = "нск, " + q
}
jobs[i] = job{query: q, canonical: canonical, want: en}
}
stats := &loadStats{latencies: make([]time.Duration, 0, total)}
runConcurrent(workers, len(jobs), func(i int) {
j := jobs[i]
start := time.Now()
code, body, reqErr := e.do("/v1/resolve", map[string]string{"address": j.query})
lat := time.Since(start)
var res match.Result
err := reqErr
if err == nil && code != http.StatusOK {
err = fmt.Errorf("HTTP %d", code)
}
if err == nil {
err = json.Unmarshal(body, &res)
}
if err == nil {
if verr := e.verifySafety(j.query, res); verr != nil {
stats.mu.Lock()
stats.safety++
stats.mu.Unlock()
t.Errorf("safety violation: %v", verr)
}
}
stats.record(lat, j.canonical, res, err)
})
stats.mu.Lock()
defer stats.mu.Unlock()
sort.Slice(stats.latencies, func(i, j int) bool { return stats.latencies[i] < stats.latencies[j] })
p := func(q float64) time.Duration {
if len(stats.latencies) == 0 {
return 0
}
i := int(float64(len(stats.latencies)-1) * q)
return stats.latencies[i]
}
rss := processRSS(d.cmd.Process.Pid)
t.Logf("load: n=%d canonical=%d canonical_found=%d safety=%d http_errors=%d statuses=%v p50=%s p95=%s p99=%s rss=%dMB",
total, stats.canonical, stats.canonicalOK, stats.safety, stats.httpErrors, stats.statuses,
p(0.50), p(0.95), p(0.99), rss/(1<<20))
if stats.httpErrors > 0 {
t.Errorf("HTTP errors: %d", stats.httpErrors)
}
if stats.canonicalOK != stats.canonical {
t.Errorf("canonical found = %d/%d", stats.canonicalOK, stats.canonical)
}
if stats.safety > 0 {
t.Errorf("safety violations: %d", stats.safety)
}
if stats.found == 0 {
t.Error("no found results during load")
}
if p(0.99) > maxP99 {
t.Errorf("p99 latency = %s, want <= %s", p(0.99), maxP99)
}
if rss > maxRSS {
t.Errorf("RSS = %d MB, want <= %d MB", rss/(1<<20), maxRSS/(1<<20))
}
}
// verifySafety enforces the core promise: a "found" answer must be a real
// registry row and must not change the requested house unit.
func (e *env) verifySafety(query string, res match.Result) error {
if res.Status != address.StatusFound {
return nil
}
qp := e.parser.ParseInCity(query, e.city)
rp := e.parser.ParseInCity(res.Address, e.city)
if qp.HasHouse && rp.HasHouse {
if qp.House != rp.House || qp.HouseFrac != rp.HouseFrac || qp.HouseLetter != rp.HouseLetter ||
qp.Building != rp.Building || qp.BuildingFrac != rp.BuildingFrac || qp.BuildingLetter != rp.BuildingLetter ||
qp.Structure != rp.Structure {
return fmt.Errorf("%q -> %q changes the house unit", query, res.Address)
}
}
return nil
}
func (e *env) resolve(t *testing.T, q string) match.Result {
t.Helper()
code, body := e.post(t, "/v1/resolve", map[string]string{"address": q})
if code != http.StatusOK {
t.Fatalf("resolve %q: HTTP %d", q, code)
}
var res match.Result
if err := json.Unmarshal(body, &res); err != nil {
t.Fatalf("decode: %v", err)
}
return res
}
func (e *env) post(t *testing.T, path string, payload any) (int, []byte) {
t.Helper()
code, body, err := e.do(path, payload)
if err != nil {
t.Fatalf("POST %s: %v", path, err)
}
return code, body
}
// do performs an HTTP request without touching *testing.T, so it is safe to
// call from worker goroutines.
func (e *env) do(path string, payload any) (int, []byte, error) {
var r io.Reader
switch v := payload.(type) {
case string:
r = strings.NewReader(v)
default:
b, err := json.Marshal(v)
if err != nil {
return 0, nil, err
}
r = bytes.NewReader(b)
}
resp, err := e.httpClient.Post("http://"+e.addr+path, "application/json", r)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return resp.StatusCode, body, nil
}
func (e *env) get(t *testing.T, path string) (int, []byte) {
t.Helper()
resp, err := e.httpClient.Get("http://" + e.addr + path)
if err != nil {
return 0, nil
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return resp.StatusCode, body
}
type daemon struct {
cmd *exec.Cmd
logs *lockedBuffer
finished chan struct{}
once sync.Once
err error
}
func startProcess(t *testing.T, name string, args ...string) *daemon {
t.Helper()
cmd := exec.Command(name, args...)
logs := &lockedBuffer{}
cmd.Stdout = logs
cmd.Stderr = logs
if err := cmd.Start(); err != nil {
t.Fatalf("start %s: %v", name, err)
}
d := &daemon{cmd: cmd, logs: logs, finished: make(chan struct{})}
go func() {
err := cmd.Wait()
d.once.Do(func() {
d.err = err
close(d.finished)
})
}()
return d
}
func (d *daemon) exited() bool {
select {
case <-d.finished:
return true
default:
return false
}
}
func (d *daemon) wait(timeout time.Duration) (error, bool) {
select {
case <-d.finished:
return d.err, true
case <-time.After(timeout):
return nil, false
}
}
// stop asks the daemon to terminate and returns its exit status. Calling it
// twice is safe; a daemon that already exited is not signalled again.
func (d *daemon) stop(t *testing.T) error {
t.Helper()
if !d.exited() {
_ = d.cmd.Process.Signal(syscall.SIGTERM)
}
err, ok := d.wait(10 * time.Second)
if !ok {
_ = d.cmd.Process.Kill()
err, _ = d.wait(5 * time.Second)
}
return err
}
type lockedBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *lockedBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *lockedBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
type addressLogRecord struct {
Utterance string
Request string
}
// readAddressLog reads the EДДС address log (bulk NDJSON) and extracts the
// canonical request and the raw user utterance. It deliberately does not keep
// dialog ids or phone numbers.
func readAddressLog(path string) ([]addressLogRecord, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var out []addressLogRecord
dec := json.NewDecoder(f)
for {
var raw json.RawMessage
if err := dec.Decode(&raw); err != nil {
if err == io.EOF {
break
}
return nil, err
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
continue
}
if _, ok := obj["index"]; ok {
continue // bulk action line
}
var rec struct {
Address string `json:"address"`
Request string `json:"request"`
}
_ = json.Unmarshal(raw, &rec)
item := addressLogRecord{Utterance: strings.TrimSpace(rec.Address)}
var req struct {
Address string `json:"address"`
}
if json.Unmarshal([]byte(rec.Request), &req) == nil {
item.Request = strings.TrimSpace(req.Address)
}
out = append(out, item)
}
return out, nil
}
func runConcurrent(workers, n int, fn func(i int)) {
var wg sync.WaitGroup
ch := make(chan int)
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := range ch {
fn(i)
}
}()
}
for i := 0; i < n; i++ {
ch <- i
}
close(ch)
wg.Wait()
}
func sampleIndexes(n, sample int, seed int64) []int {
rng := rand.New(rand.NewSource(seed))
perm := rng.Perm(n)
out := make([]int, sample)
copy(out, perm[:sample])
return out
}
func firstWhere(entries []address.Entry, pred func(address.Entry) bool) *address.Entry {
for i := range entries {
if pred(entries[i]) {
return &entries[i]
}
}
return nil
}
func replaceHouse(addressText, old, new string) string {
if old == "" {
return addressText
}
// Replace the house token only at the end of the street phrase.
idx := strings.LastIndex(addressText, " "+old)
if idx < 0 {
return addressText
}
return addressText[:idx] + " " + new + addressText[idx+1+len(old):]
}
// insertTypo swaps two adjacent runes in the longest Cyrillic word, emulating a
// citizen typo without changing the house number.
func insertTypo(s string, rng *rand.Rand) string {
tokens := strings.Fields(s)
best := -1
for i, tok := range tokens {
if !isCyrillic(tok) || len([]rune(tok)) < 5 {
continue
}
if best < 0 || len([]rune(tok)) > len([]rune(tokens[best])) {
best = i
}
}
if best < 0 {
return s
}
r := []rune(tokens[best])
i := 1 + rng.Intn(len(r)-2)
r[i], r[i+1] = r[i+1], r[i]
tokens[best] = string(r)
return strings.Join(tokens, " ")
}
func isCyrillic(s string) bool {
for _, r := range s {
if r < 'а' || r > 'я' {
return false
}
}
return len(s) > 0
}
func processRSS(pid int) int64 {
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
if err != nil {
return 0
}
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "VmRSS:") {
var kb int64
fmt.Sscanf(strings.TrimSpace(strings.TrimPrefix(line, "VmRSS:")), "%d", &kb)
return kb * 1024
}
}
return 0
}
func writeJSON(t *testing.T, path string, v any) {
t.Helper()
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
t.Fatalf("marshal json: %v", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func envOr(name, fallback string) string {
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
return v
}
return fallback
}
func envInt(name string, fallback int) int {
v := strings.TrimSpace(os.Getenv(name))
if v == "" {
return fallback
}
var n int
if _, err := fmt.Sscanf(v, "%d", &n); err != nil || n <= 0 {
return fallback
}
return n
}
func tail(s string, n int) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return strings.Join(lines, "\n")
}