backupMmdb() could return an error but the code still proceeded into mergeMmdb(), which truncates the canonical mmdb. With no backup, a later merge/reload failure left the file corrupt and unrecoverable on restart. Now we skip the rebuild entirely when the backup fails, and rollBackMmdb no longer takes the backup error (the only failure path returns early). Co-Authored-By: Claude <noreply@anthropic.com>
145 lines
4.6 KiB
Go
145 lines
4.6 KiB
Go
package db_updater
|
|
|
|
import (
|
|
"github.com/robfig/cron/v3"
|
|
"github.com/rs/zerolog/log"
|
|
"os"
|
|
"path"
|
|
"sync/atomic"
|
|
"thuanle.me/ip-info/configs"
|
|
"thuanle.me/ip-info/internal/data"
|
|
"thuanle.me/ip-info/pkg/osx"
|
|
)
|
|
|
|
// 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()
|
|
_, _ = c.AddFunc("@daily", fetchDbs)
|
|
c.Start()
|
|
fetchDbs()
|
|
}
|
|
|
|
func fetchDbs() {
|
|
newFlag := false
|
|
for _, url := range configs.GeoDbSourcePaths {
|
|
newFlag = download(url) || newFlag
|
|
}
|
|
|
|
if newFlag {
|
|
log.Info().Msg("New DB downloaded. Recreating mmdb")
|
|
// mmdbmeld.WriteMMDB truncates the canonical mmdb before writing. If
|
|
// the merge fails the file would be left empty/corrupt — and because
|
|
// download() already persisted the etag, the next daily run would get
|
|
// 304 Not Modified and never retry, leaving the service broken until a
|
|
// restart (which then fails to open the empty file). Back the file up
|
|
// first so we can restore it on failure.
|
|
backup, backupErr := backupMmdb()
|
|
if backupErr != nil {
|
|
// We could not back up the current canonical mmdb. Do NOT proceed:
|
|
// mergeMmdb() truncates the canonical file before writing, and with
|
|
// no backup a later merge/reload failure would leave it corrupt
|
|
// (unrecoverable on restart). Keep serving the current DB and let
|
|
// the next run retry.
|
|
log.Err(backupErr).Msg("Failed to back up mmdb, skipping rebuild")
|
|
return
|
|
}
|
|
if err := mergeMmdb(); err != nil {
|
|
log.Err(err).Msg("Failed to merge mmdb")
|
|
rollBackMmdb(backup)
|
|
return
|
|
}
|
|
|
|
err := data.Ins().Reload()
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to reload mmdb")
|
|
// Reload failed on the newly merged file: restore the previous
|
|
// canonical mmdb so we keep serving known-good data.
|
|
rollBackMmdb(backup)
|
|
return
|
|
}
|
|
|
|
// Both merge and reload succeeded: the new canonical file is live, the
|
|
// backup is no longer needed.
|
|
cleanupMmdbBackup(backup)
|
|
|
|
// Stamp the update time from the freshly written canonical file.
|
|
stampDbUpdatedAt()
|
|
return
|
|
}
|
|
|
|
// No new download: on startup this is the path that loads an existing DB.
|
|
// Ensure the timestamp reflects the file we are actually serving.
|
|
if DbUpdatedAt.Load() == 0 {
|
|
stampDbUpdatedAt()
|
|
}
|
|
}
|
|
|
|
// stampDbUpdatedAt records the mtime of the canonical mmdb (unix ms) as the
|
|
// last DB update time. This covers both initial load (pre-existing files) and
|
|
// post-download reloads, so /metrics never reports 0 while serving real data.
|
|
func stampDbUpdatedAt() {
|
|
info, err := os.Stat(configs.MmdbDbFile)
|
|
if err != nil {
|
|
log.Err(err).Str("file", configs.MmdbDbFile).Msg("Failed to stat mmdb")
|
|
return
|
|
}
|
|
DbUpdatedAt.Store(info.ModTime().UnixMilli())
|
|
}
|
|
|
|
// backupMmdb copies the canonical mmdb to a sibling backup file. It returns
|
|
// ("", nil) when there is no canonical file yet (first-ever build), in which
|
|
// case there is nothing to restore on failure.
|
|
func backupMmdb() (string, error) {
|
|
if _, err := os.Stat(configs.MmdbDbFile); os.IsNotExist(err) {
|
|
return "", nil
|
|
}
|
|
backup := configs.MmdbDbFile + ".bak"
|
|
if err := osx.Copy(configs.MmdbDbFile, backup); err != nil {
|
|
return "", err
|
|
}
|
|
return backup, nil
|
|
}
|
|
|
|
// rollBackMmdb restores the canonical mmdb from backup (if any) and deletes
|
|
// the etag files so the next cron run re-downloads and retries the merge.
|
|
// backup is "" on a first-ever build (no canonical file existed to back up),
|
|
// in which case there is nothing to restore.
|
|
func rollBackMmdb(backup string) {
|
|
if backup != "" {
|
|
if err := osx.Copy(backup, configs.MmdbDbFile); err != nil {
|
|
log.Err(err).Msg("Failed to restore mmdb backup")
|
|
} else {
|
|
log.Info().Str("file", configs.MmdbDbFile).Msg("Restored mmdb from backup")
|
|
}
|
|
_ = os.Remove(backup)
|
|
}
|
|
// Drop etags so a failed merge is retried on the next run instead of being
|
|
// masked by a 304 Not Modified.
|
|
for _, url := range configs.GeoDbSourcePaths {
|
|
etag := configs.GeoDbFolder + path.Base(url) + ".etag"
|
|
if err := os.Remove(etag); err != nil && !os.IsNotExist(err) {
|
|
log.Err(err).Str("file", etag).Msg("Failed to remove etag")
|
|
}
|
|
}
|
|
}
|
|
|
|
// cleanupMmdbBackup removes a successful merge's backup file.
|
|
func cleanupMmdbBackup(backup string) {
|
|
if backup == "" {
|
|
return
|
|
}
|
|
if err := os.Remove(backup); err != nil && !os.IsNotExist(err) {
|
|
log.Err(err).Str("file", backup).Msg("Failed to remove mmdb backup")
|
|
}
|
|
}
|