Fix data races, robustness, and cleanup across codebase (#10-#21)
Concurrency & correctness: - #10: guard IpDb Query/IsLoaded with RLock so Reload() (which reassigns d.r and closes the old reader) cannot race with concurrent lookups. Confirmed via -race regression test. - #12: replace DbUpdatedAt time.Time with atomic.Int64 (cron writes, /metrics reads) to fix the read/write data race. - #11: graceful HTTP shutdown (srv.Shutdown with 10s timeout) instead of srv.Close() aborting in-flight requests. - #14: stop swallowing mergeMmdb() errors in fetchDbs() — keep the previous DB when a merge fails instead of reloading a possibly-empty output. - #13: add resty timeout (2m) + retry (x2) to download() so a hung CDN can't stall the daily cron forever. - #15: correct HTTP status codes (503 db loading, 404 not found via new ErrNotFound sentinel, 500 otherwise) instead of 200 on query error. Robustness: - #16: surface osx.Copy dstFile.Close() errors (flush may fail) via named return + defer. Dependency migration: - #17: migrate maxminddb-golang v1 -> v2. v2 is a breaking API (LookupNetwork -> Lookup returning Result, netip.Addr), so Query was rewritten; v1 dropped from go.mod. Tests: - #21: add internal/data unit tests (valid/invalid/not-found lookup) plus a concurrent Query/Reload race regression test. chdir to repo root in TestMain because data helpers use relative paths. Cleanup: - #18: README Go version 1.22 -> 1.25. - #19: replace stray fmt.Printf with zerolog in ipdb.go. - #20: .env.example API_PORT 28080 -> 8080 (container port, matches docker-compose 28080:8080 mapping) with an explanatory comment. Closes #10, #11, #12, #13, #14, #15, #16, #17, #18, #19, #20, #21 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+42
-8
@@ -1,23 +1,57 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned by Query when the IP parses but is not present in the DB.
|
||||
var ErrNotFound = errors.New("IP not found")
|
||||
|
||||
func (d *IpDb) Query(ipArg string) (map[string]any, *net.IPNet, error) {
|
||||
ip := net.ParseIP(ipArg)
|
||||
if ip == nil {
|
||||
// maxminddb-golang v2 requires a netip.Addr.
|
||||
addr, err := netip.ParseAddr(ipArg)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid IP: %s", ipArg)
|
||||
}
|
||||
// Get data of IP.
|
||||
anyData := make(map[string]any)
|
||||
recordNet, ok, err := d.r.LookupNetwork(ip, &anyData)
|
||||
if err != nil {
|
||||
|
||||
// Hold the read lock: Reload() reassigns d.r and closes the old reader,
|
||||
// so reading d.r / calling Lookup without synchronization is a data race.
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
|
||||
if d.r == nil {
|
||||
return nil, nil, fmt.Errorf("db not loaded")
|
||||
}
|
||||
|
||||
// Look up the record for the IP.
|
||||
result := d.r.Lookup(addr)
|
||||
if err := result.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("IP not found: %s", ipArg)
|
||||
if !result.Found() {
|
||||
return nil, nil, fmt.Errorf("%w: %s", ErrNotFound, ipArg)
|
||||
}
|
||||
|
||||
// Decode the record into a generic map.
|
||||
anyData := make(map[string]any)
|
||||
if err := result.Decode(&anyData); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Convert the matched prefix back to *net.IPNet for callers.
|
||||
var recordNet *net.IPNet
|
||||
if prefix := result.Prefix(); prefix.IsValid() {
|
||||
recordNet = prefixToIPNet(prefix)
|
||||
}
|
||||
return anyData, recordNet, nil
|
||||
}
|
||||
|
||||
// prefixToIPNet converts a netip.Prefix to a *net.IPNet.
|
||||
func prefixToIPNet(prefix netip.Prefix) *net.IPNet {
|
||||
addr := prefix.Addr().AsSlice()
|
||||
mask := net.CIDRMask(prefix.Bits(), len(addr)*8)
|
||||
return &net.IPNet{IP: addr, Mask: mask}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user