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>
135 lines
2.5 KiB
Go
135 lines
2.5 KiB
Go
package data
|
|
|
|
import (
|
|
"fmt"
|
|
"github.com/rs/zerolog/log"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"thuanle.me/ip-info/configs"
|
|
)
|
|
import reader "github.com/oschwald/maxminddb-golang/v2"
|
|
|
|
type IpDb struct {
|
|
r *reader.Reader
|
|
mu sync.RWMutex
|
|
|
|
dbFile string
|
|
}
|
|
|
|
var (
|
|
ins *IpDb
|
|
once sync.Once
|
|
)
|
|
|
|
func Ins() *IpDb {
|
|
once.Do(func() {
|
|
ins = &IpDb{}
|
|
_ = ins.Reload()
|
|
})
|
|
return ins
|
|
}
|
|
|
|
func CleanupDataDir() error {
|
|
if _, err := os.Stat(configs.GeoDbFolder); os.IsNotExist(err) {
|
|
log.Info().Str("dir", configs.GeoDbFolder).Msg("Creating data folder")
|
|
err := os.MkdirAll(configs.GeoDbFolder, os.ModePerm)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
log.Info().Str("dir", configs.GeoDbFolder).Msg("Cleaning temp files")
|
|
dir, err := os.Open(configs.GeoDbFolder)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer dir.Close()
|
|
|
|
// List all files in the directory
|
|
files, err := dir.Readdir(-1) // -1 means read all files
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Delete each file
|
|
for _, file := range files {
|
|
if !file.IsDir() && file.Name() != configs.MmdbDbFileName {
|
|
filePath := filepath.Join(configs.GeoDbFolder, file.Name())
|
|
err := os.Remove(filePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete file %s: %w", filePath, err)
|
|
}
|
|
log.Info().Str("file", filePath).Msg("Deleted temp file")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (d *IpDb) Reload() error {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
wilDeleteFile := d.dbFile
|
|
|
|
tmpFile, err := cloneDBFile()
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to clone db file")
|
|
return err
|
|
}
|
|
|
|
d.dbFile = tmpFile
|
|
r, err := reader.Open(tmpFile)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to open mmdb")
|
|
return err
|
|
}
|
|
log.Info().Str("file", tmpFile).Msg("MMDB reloaded")
|
|
|
|
tmpR := d.r
|
|
d.r = r
|
|
|
|
if tmpR != nil {
|
|
_ = tmpR.Close()
|
|
|
|
if wilDeleteFile != "" {
|
|
log.Info().Str("file", wilDeleteFile).Msg("Deleting old mmdb")
|
|
_ = os.Remove(wilDeleteFile)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (d *IpDb) IsLoaded() bool {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
return d.r != nil
|
|
}
|
|
|
|
func cloneDBFile() (string, error) {
|
|
srcFile, err := os.Open(configs.MmdbDbFile)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer srcFile.Close()
|
|
|
|
tmpFile, err := os.CreateTemp(configs.GeoDbFolder, "geoip-v4-*.mmdb")
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create temporary file: %v", err)
|
|
}
|
|
defer tmpFile.Close()
|
|
|
|
buf := make([]byte, 1024*1024) // 1 MB buffer
|
|
|
|
// Copy the data from srcFile to dstFile
|
|
_, err = io.CopyBuffer(tmpFile, srcFile, buf)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return tmpFile.Name(), nil
|
|
}
|