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:
2026-07-22 06:37:40 +00:00
co-authored by Claude
parent d05ab4b402
commit e2b0732014
13 changed files with 248 additions and 29 deletions
+5 -1
View File
@@ -1,2 +1,6 @@
API_PORT=28080
# API_PORT is the port the service listens on INSIDE the container.
# With docker-compose's "28080:8080" mapping, set this to 8080 so the host
# can reach it at http://localhost:28080. Override only if you also change
# the host-side mapping / non-Docker run.
API_PORT=8080
GIN_TRUSTED_PROXY_IP=127.0.0.1
+1 -1
View File
@@ -31,7 +31,7 @@ The service will be available at `http://localhost:28080`
### Manual Installation
1. Install Go 1.22 or later
1. Install Go 1.25 or later
2. Clone and build:
```bash
git clone <repository-url>
+1 -2
View File
@@ -6,7 +6,7 @@ require (
github.com/gin-gonic/gin v1.12.0
github.com/go-resty/resty/v2 v2.17.2
github.com/joho/godotenv v1.5.1
github.com/oschwald/maxminddb-golang v1.13.1
github.com/oschwald/maxminddb-golang/v2 v2.4.1
github.com/robfig/cron/v3 v3.0.1
github.com/rs/zerolog v1.35.1
github.com/safing/mmdbmeld v0.3.0
@@ -32,7 +32,6 @@ require (
github.com/maxmind/mmdbwriter v1.2.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/oschwald/maxminddb-golang/v2 v2.4.1 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.60.0 // indirect
-2
View File
@@ -55,8 +55,6 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/oschwald/maxminddb-golang/v2 v2.4.1 h1:OffzqSABE3Sw354GdBThqDsKfpA4GWBqOY2P91V8tjI=
github.com/oschwald/maxminddb-golang/v2 v2.4.1/go.mod h1:CZK8iQQMKfy6mKOifoyUmrj4vTHnMiGVaS7hDaZZxQ0=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
+133
View File
@@ -0,0 +1,133 @@
package data
import (
"errors"
"net"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
)
// nested looks up a nested map value by successive string keys, returning "" if absent.
func nested(m map[string]any, keys ...string) string {
var cur any = m
for _, k := range keys {
mm, ok := cur.(map[string]any)
if !ok {
return ""
}
cur = mm[k]
}
s, _ := cur.(string)
return s
}
// parseIP is a test helper that panics on invalid input (used only with literals).
func parseIP(s string) net.IP {
ip := net.ParseIP(s)
if ip == nil {
panic("invalid ip in test: " + s)
}
return ip
}
// The package's data helpers use relative paths ("data/..."), so tests must
// run with the working directory at the repository root. TestMain moves there
// once for the whole package.
func TestMain(m *testing.M) {
if root, err := filepath.Abs("../.."); err == nil {
_ = os.Chdir(root)
}
os.Exit(m.Run())
}
// freshDB returns a loaded IpDb, skipping the test when no mmdb is present.
func freshDB(t *testing.T) *IpDb {
t.Helper()
db := Ins()
if !db.IsLoaded() {
t.Skip("no mmdb present, skipping test that needs real data")
}
return db
}
func TestQueryValidIP(t *testing.T) {
db := freshDB(t)
data, netRec, err := db.Query("8.8.8.8")
if err != nil {
t.Fatalf("Query(8.8.8.8) unexpected error: %v", err)
}
if data == nil {
t.Fatal("Query(8.8.8.8) returned nil data")
}
// 8.8.8.8 is Google (AS15169), US.
if got := nested(data, "country", "iso_code"); got != "US" {
t.Errorf("country.iso_code = %v, want US", got)
}
if netRec == nil {
t.Error("expected a non-nil network record")
} else if !netRec.Contains(parseIP("8.8.8.8")) {
t.Errorf("network %s should contain 8.8.8.8", netRec)
}
}
func TestQueryInvalidIP(t *testing.T) {
db := freshDB(t)
if _, _, err := db.Query("not-an-ip"); err == nil {
t.Fatal("expected error for invalid IP, got nil")
}
}
func TestQueryNotFound(t *testing.T) {
db := freshDB(t)
// 240.0.0.0/4 is reserved/unallocated and should not be in a public GeoIP DB.
_, _, err := db.Query("240.0.0.1")
if err == nil {
t.Fatal("expected not-found error, got nil")
}
if !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound sentinel, got %v", err)
}
}
// TestQueryReloadConcurrent is a regression test for the data race between
// Query (reads d.r) and Reload (reassigns d.r / closes the old reader). It
// must pass under `go test -race`.
func TestQueryReloadConcurrent(t *testing.T) {
db := freshDB(t)
var reloadOK, queries int64
var wg sync.WaitGroup
for w := 0; w < 8; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 5000; i++ {
_, _, _ = db.Query("8.8.8.8")
atomic.AddInt64(&queries, 1)
}
}()
}
for r := 0; r < 4; r++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 30; i++ {
if db.Reload() == nil {
atomic.AddInt64(&reloadOK, 1)
}
}
}()
}
wg.Wait()
if reloadOK == 0 {
t.Fatalf("expected reloads to succeed, got reloads=%d queries=%d", reloadOK, queries)
}
}
+4 -2
View File
@@ -9,7 +9,7 @@ import (
"sync"
"thuanle.me/ip-info/configs"
)
import reader "github.com/oschwald/maxminddb-golang"
import reader "github.com/oschwald/maxminddb-golang/v2"
type IpDb struct {
r *reader.Reader
@@ -61,7 +61,7 @@ func CleanupDataDir() error {
if err != nil {
return fmt.Errorf("failed to delete file %s: %w", filePath, err)
}
fmt.Printf("Deleted file: %s\n", filePath)
log.Info().Str("file", filePath).Msg("Deleted temp file")
}
}
@@ -104,6 +104,8 @@ func (d *IpDb) Reload() error {
}
func (d *IpDb) IsLoaded() bool {
d.mu.RLock()
defer d.mu.RUnlock()
return d.r != nil
}
+42 -8
View File
@@ -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}
}
+18 -5
View File
@@ -1,6 +1,7 @@
package api
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"net"
@@ -16,7 +17,7 @@ func HandleJson(c *gin.Context) {
func HandleOtherIp(c *gin.Context) {
ip := c.Param("ip")
if net.ParseIP(ip) == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ip"})
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ip", "ip": ip})
return
}
HandleIpInfo(c, ip)
@@ -25,19 +26,31 @@ func HandleOtherIp(c *gin.Context) {
func HandleIpInfo(c *gin.Context, ip string) {
if !data.Ins().IsLoaded() {
log.Error().Msg("DB is not loaded")
c.String(http.StatusInternalServerError, "Try again later")
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "service unavailable, db still loading",
"ip": ip,
})
return
}
ipData, _, err := data.Ins().Query(ip)
if err != nil {
// Distinguish "not found" (valid IP, absent from DB) from other errors.
if isNotFound(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found", "ip": ip})
return
}
log.Err(err).Msg("Failed to query IP")
c.JSON(http.StatusOK, gin.H{
"ip": ip,
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error", "ip": ip})
return
}
ipData["ip"] = ip
c.JSON(http.StatusOK, ipData)
}
// isNotFound reports whether the query error means the IP was not present in
// the DB (as opposed to an invalid IP or an internal lookup failure).
func isNotFound(err error) bool {
return errors.Is(err, data.ErrNotFound)
}
+1 -1
View File
@@ -8,6 +8,6 @@ import (
func HandleMetrics(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"updated_at": db_updater.DbUpdatedAt.UnixMilli(),
"updated_at": db_updater.DbUpdatedAtMillis(),
})
}
+9 -1
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"errors"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
@@ -8,6 +9,7 @@ import (
"os"
"thuanle.me/ip-info/configs"
"thuanle.me/ip-info/configs/key"
"time"
)
var srv *http.Server
@@ -48,6 +50,12 @@ func StartApiService() {
}
func Shutdown() {
_ = srv.Close()
// Graceful shutdown: let in-flight requests finish (bounded by a timeout)
// instead of abruptly closing connections.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Err(err).Msg("Error shutting down API service")
}
log.Info().Msg("API service stopped")
}
+5 -1
View File
@@ -7,6 +7,7 @@ import (
"os"
"path"
"thuanle.me/ip-info/configs"
"time"
)
func download(url string) bool {
@@ -16,7 +17,10 @@ func download(url string) bool {
etag, _ := readFile(filenameEtag)
client := resty.New()
client := resty.New().
SetTimeout(2 * time.Minute).
SetRetryCount(2).
SetRetryWaitTime(5 * time.Second)
resp, err := client.R().
SetHeader("If-None-Match", etag).
Get(url)
+18 -3
View File
@@ -3,12 +3,22 @@ package db_updater
import (
"github.com/robfig/cron/v3"
"github.com/rs/zerolog/log"
"sync/atomic"
"thuanle.me/ip-info/configs"
"thuanle.me/ip-info/internal/data"
"time"
)
var DbUpdatedAt time.Time
// DbUpdatedAt stores the DB last-updated time as unix milliseconds.
// Accessed concurrently by the cron goroutine (writer) and the /metrics
// handler (reader), so it must be synchronized — hence atomic.Int64.
var DbUpdatedAt atomic.Int64
// DbUpdatedAtMillis returns the last DB update time in unix milliseconds,
// or 0 if the DB has never been updated.
func DbUpdatedAtMillis() int64 {
return DbUpdatedAt.Load()
}
func StartUpdateDbService() {
c := cron.New()
@@ -25,7 +35,12 @@ func fetchDbs() {
if newFlag {
log.Info().Msg("New DB downloaded. Recreating mmdb")
_ = mergeMmdb()
if err := mergeMmdb(); err != nil {
// Keep the current (previous) DB rather than reloading a
// potentially empty/corrupt merge output.
log.Err(err).Msg("Failed to merge mmdb, keeping current DB")
return
}
err := data.Ins().Reload()
if err != nil {
@@ -33,6 +48,6 @@ func fetchDbs() {
return
}
DbUpdatedAt = time.Now()
DbUpdatedAt.Store(time.Now().UnixMilli())
}
}
+11 -2
View File
@@ -6,7 +6,7 @@ import (
)
// Copy copies a file from source to destination
func Copy(from, to string) error {
func Copy(from, to string) (err error) {
// Open the source file for reading
srcFile, err := os.Open(from)
if err != nil {
@@ -19,7 +19,16 @@ func Copy(from, to string) error {
if err != nil {
return err
}
defer dstFile.Close()
// Closing may flush buffered writes, so its error must be surfaced — a
// successful copy with a failed close means the destination is incomplete.
// Defer the close and only let it overwrite the return value when the copy
// itself succeeded; otherwise keep the first error.
defer func() {
cerr := dstFile.Close()
if err == nil {
err = cerr
}
}()
// Use a buffer to copy the file in chunks
buf := make([]byte, 1024*1024) // 1 MB buffer