diff --git a/internal/users/charindex.go b/internal/users/charindex.go index d54ac0478..2d77daa6f 100644 --- a/internal/users/charindex.go +++ b/internal/users/charindex.go @@ -69,19 +69,25 @@ func (ci *CharacterIndex) Find(name string) (userId int, found bool) { return } -// Rebuild clears the index and repopulates it from every user record on disk +// Rebuild clears the index and repopulates it from every user file on disk // plus all currently online users. Only active character names are added here; // the alt-characters module is responsible for adding alt names after this // runs. func (ci *CharacterIndex) Rebuild() { - newMap := make(map[string]int) + ci.RebuildFromScan(ScanUserFiles()) +} - SearchOfflineUsers(func(u *UserRecord) bool { - if u.Character != nil && u.Character.Name != "" { - newMap[strings.ToLower(u.Character.Name)] = u.UserId +// RebuildFromScan is Rebuild fed by an existing user file scan, so startup +// can share one scan between the user index and the character index instead +// of fully parsing every user record a second time. +func (ci *CharacterIndex) RebuildFromScan(scan []UserFileScan) { + newMap := make(map[string]int, len(scan)) + + for _, s := range scan { + if s.CharacterName != "" { + newMap[strings.ToLower(s.CharacterName)] = s.UserId } - return true - }) + } for _, u := range GetAllActiveUsers() { if u.Character != nil && u.Character.Name != "" { diff --git a/internal/users/charindex_test.go b/internal/users/charindex_test.go index 8a9a74721..e5fd411b5 100644 --- a/internal/users/charindex_test.go +++ b/internal/users/charindex_test.go @@ -4,6 +4,8 @@ import ( "fmt" "sync" "testing" + + "github.com/GoMudEngine/GoMud/internal/mudlog" ) func freshCharacterIndex() *CharacterIndex { @@ -116,8 +118,12 @@ func TestCharacterIndex_MultipleUsersMultipleNames(t *testing.T) { } func TestCharacterIndex_Rebuild(t *testing.T) { + // The scan warns about the missing users directory in a test env, so the + // logger must be initialized. + mudlog.SetupLogger(nil, "", "", false) + // Swap in a fresh singleton so Rebuild exercises the real code path - // without touching disk (SearchOfflineUsers finds nothing in a test env). + // without touching disk (ScanUserFiles finds nothing in a test env). orig := characterIndex defer func() { characterIndex = orig }() @@ -127,7 +133,7 @@ func TestCharacterIndex_Rebuild(t *testing.T) { // Pre-populate with stale data that Rebuild should clear. ci.Add("stale", 99) - // Rebuild will call SearchOfflineUsers (returns nothing in test env) and + // Rebuild will call ScanUserFiles (returns nothing in test env) and // GetAllActiveUsers (returns nothing since userManager is empty). The stale // entry must be gone. ci.Rebuild() diff --git a/internal/users/index.go b/internal/users/index.go index 8546b5520..87c94192b 100644 --- a/internal/users/index.go +++ b/internal/users/index.go @@ -104,6 +104,7 @@ func (idx *UserIndex) loadRecords() { f, err := os.Open(idx.Filename) if err != nil { + mudlog.Error("UserIndex", "error", "failed to open index file", "path", idx.Filename, "details", err) return } defer f.Close() @@ -111,9 +112,11 @@ func (idx *UserIndex) loadRecords() { dataSize := idx.metaData.RecordCount * idx.metaData.RecordSize buf := make([]byte, dataSize) if _, err := f.Seek(int64(idx.metaData.MetaDataSize), io.SeekStart); err != nil { + mudlog.Error("UserIndex", "error", "failed to seek past index header", "path", idx.Filename, "details", err) return } if _, err := io.ReadFull(f, buf); err != nil { + mudlog.Error("UserIndex", "error", "index file is truncated or corrupt, a rebuild will recreate it", "path", idx.Filename, "details", err) return } @@ -209,8 +212,16 @@ func computeDirChecksum(basePath string) (uint64, error) { } // IsUpToDate returns true if the index file exists, has the current version, -// and its stored FNV-64 checksum matches the current state of the user directory. +// actually loaded every record its header claims, and its stored FNV-64 +// checksum matches the current state of the user directory. func (idx *UserIndex) IsUpToDate() bool { + basePath := util.FilePath(string(configs.GetFilePathsConfig().DataFiles), `/`, `users`) + return idx.isUpToDateForDir(basePath) +} + +// isUpToDateForDir is IsUpToDate parameterized by the directory to compare +// against, so tests can point it at a synthetic users directory. +func (idx *UserIndex) isUpToDateForDir(basePath string) bool { if !idx.Exists() { return false } @@ -218,7 +229,14 @@ func (idx *UserIndex) IsUpToDate() bool { return false } - basePath := util.FilePath(string(configs.GetFilePathsConfig().DataFiles), `/`, `users`) + // A truncated or unreadable records section leaves fewer records in + // memory than the header claims. Such an index must never be trusted: + // with empty maps, GetUniqueUserId would start handing out userids that + // already belong to existing user files. + if uint64(len(idx.records)) != idx.metaData.RecordCount { + return false + } + current, err := computeDirChecksum(basePath) if err != nil { return false @@ -226,68 +244,70 @@ func (idx *UserIndex) IsUpToDate() bool { return idx.metaData.Checksum == current } -// Rebuild recreates the index from all offline user records. -// It calls Create() internally so it is self-contained. -// After building, it computes and persists a directory checksum so that -// IsUpToDate can detect stale indexes on the next startup. +// Rebuild recreates the index from the user files on disk. It runs a +// lightweight scan (userid and username only) instead of fully loading every +// user record, then writes the whole index in one atomic pass (temp file + +// rename) with a single sync, instead of appending and syncing once per +// user. The directory checksum is folded into that same write so IsUpToDate +// can detect stale indexes on the next startup. func (idx *UserIndex) Rebuild() error { - if err := idx.Create(); err != nil { - return fmt.Errorf("index create failed: %w", err) - } - - var firstErr error - SearchOfflineUsers(func(u *UserRecord) bool { - if err := idx.AddUser(u.UserId, u.Username); err != nil { - mudlog.Error("UserIndex.Rebuild", "error", err.Error(), "userId", u.UserId, "username", u.Username) - if firstErr == nil { - firstErr = err - } - } - return true - }) - - if firstErr != nil { - return firstErr - } + return idx.RebuildFromScan(ScanUserFiles()) +} +// RebuildFromScan is Rebuild fed by an existing scan, so startup can share +// one scan between the user index and the character index. +func (idx *UserIndex) RebuildFromScan(scan []UserFileScan) error { basePath := util.FilePath(string(configs.GetFilePathsConfig().DataFiles), `/`, `users`) checksum, err := computeDirChecksum(basePath) if err != nil { return fmt.Errorf("checksum compute failed: %w", err) } - if err := idx.writeChecksum(checksum); err != nil { - return fmt.Errorf("checksum write failed: %w", err) - } - - return nil + return idx.applyScan(scan, checksum) } -// writeChecksum persists a new checksum value into the index header on disk -// and updates the in-memory metadata. -func (idx *UserIndex) writeChecksum(checksum uint64) error { +// applyScan replaces the in-memory records and lookup maps with the scan +// results, then writes the complete index to disk once. +func (idx *UserIndex) applyScan(scan []UserFileScan, checksum uint64) error { + + records := make([]IndexUserRecord, 0, len(scan)) + for _, s := range scan { + rec := IndexUserRecord{UserID: int64(s.UserId)} + copy(rec.Username[:], strings.ToLower(s.Username)) + records = append(records, rec) + } + idx.mu.Lock() defer idx.mu.Unlock() - idx.metaData.Checksum = checksum - - headerBytes, err := idx.metaData.Format() - if err != nil { - return err + idx.metaData = IndexMetaData{ + MetaDataSize: FixedHeaderTotalLength, + IndexVersion: IndexVersion, + RecordCount: uint64(len(records)), + RecordSize: IndexRecordSizeV1, + Checksum: checksum, } - f, err := os.OpenFile(idx.Filename, os.O_RDWR, 0644) - if err != nil { - return err - } - defer f.Close() + idx.records = records + idx.byUsername = make(map[string]int64, len(records)) + idx.byUserId = make(map[int64]string, len(records)) + idx.highestUserId = 0 - if _, err := f.Seek(0, io.SeekStart); err != nil { - return err + for _, rec := range records { + username := string(bytes.TrimRight(rec.Username[:], "\x00")) + idx.byUsername[username] = rec.UserID + idx.byUserId[rec.UserID] = username + if int(rec.UserID) > idx.highestUserId { + idx.highestUserId = int(rec.UserID) + } } - if _, err := f.Write(headerBytes); err != nil { - return err + + // The in-memory state is updated even if the disk write fails - the + // running process must trust what was just scanned, not a stale file. + if err := idx.writeCompleteIndex(records); err != nil { + return fmt.Errorf("index write failed: %w", err) } - return f.Sync() + + return nil } func (idx *UserIndex) GetMetaData() IndexMetaData { @@ -360,6 +380,7 @@ func (idx *UserIndex) getMetaDataFromFile() IndexMetaData { headerContent := strings.TrimSpace(string(header[:FixedHeaderTotalLength-1])) n, _ := fmt.Sscanf(headerContent, "VERSION=%d,RECORDCOUNT=%d,RECORDSIZE=%d,CHECKSUM=%d", &meta.IndexVersion, &meta.RecordCount, &meta.RecordSize, &meta.Checksum) if n < 3 { + mudlog.Error("UserIndex", "error", "index header is unparseable, a rebuild will recreate it", "path", idx.Filename) return IndexMetaData{} } diff --git a/internal/users/indexscan.go b/internal/users/indexscan.go new file mode 100644 index 000000000..76aa77e16 --- /dev/null +++ b/internal/users/indexscan.go @@ -0,0 +1,110 @@ +package users + +import ( + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/GoMudEngine/GoMud/internal/configs" + "github.com/GoMudEngine/GoMud/internal/mudlog" + "github.com/GoMudEngine/GoMud/internal/util" + "gopkg.in/yaml.v2" +) + +// UserFileScan holds the fields a lightweight pass over a user file yields. +// The user index and the character index are both built from these at +// startup, without paying for a full UserRecord unmarshal per file. +type UserFileScan struct { + UserId int + Username string + CharacterName string +} + +// userFileScanFields is the minimal unmarshal target for a scan. The file +// is still lexed in full, but decoding into this instead of a full +// UserRecord is measurably cheaper and builds no throwaway record - see +// BenchmarkScanVsFullUnmarshal. +type userFileScanFields struct { + UserId int `yaml:"userid"` + Username string `yaml:"username"` + Character struct { + Name string `yaml:"name"` + } `yaml:"character"` +} + +// ScanUserFiles runs a lightweight scan over the configured users directory. +func ScanUserFiles() []UserFileScan { + basePath := util.FilePath(string(configs.GetFilePathsConfig().DataFiles), `/`, `users`) + return scanUserFilesInDir(basePath) +} + +// scanUserFilesInDir reads the userid, username, and active character name +// of every user file under basePath. Alt files are never opened. Files that +// cannot be read or parsed are skipped with a warning instead of aborting +// the scan, and anomalies that usually mean hand-edited data (duplicate +// userids, duplicate usernames, a numeric filename that disagrees with the +// userid inside the file) are logged so they get noticed. +func scanUserFilesInDir(basePath string) []UserFileScan { + + results := []UserFileScan{} + seenIds := make(map[int]string) + seenNames := make(map[string]string) + + filepath.Walk(basePath, func(path string, info os.FileInfo, err error) error { + + if err != nil { + mudlog.Warn("ScanUserFiles", "path", path, "walk_error", err) + return nil + } + + if info.IsDir() { + return nil + } + + if !strings.HasSuffix(path, `.yaml`) || strings.HasSuffix(path, `.alts.yaml`) { + return nil + } + + fileBytes, err := os.ReadFile(path) + if err != nil { + mudlog.Warn("ScanUserFiles", "path", path, "read_error", err) + return nil + } + + var scanned userFileScanFields + if err := yaml.Unmarshal(fileBytes, &scanned); err != nil { + mudlog.Warn("ScanUserFiles", "path", path, "unmarshal_error", err) + return nil + } + + if scanned.UserId < 1 || scanned.Username == `` { + mudlog.Warn("ScanUserFiles", "info", "skipping user file missing userid or username", "path", path) + return nil + } + + if fileId, convErr := strconv.Atoi(strings.TrimSuffix(filepath.Base(path), `.yaml`)); convErr == nil && fileId != scanned.UserId { + mudlog.Warn("ScanUserFiles", "info", "filename does not match userid in file", "path", path, "userid", scanned.UserId) + } + + if otherPath, ok := seenIds[scanned.UserId]; ok { + mudlog.Warn("ScanUserFiles", "info", "duplicate userid", "userid", scanned.UserId, "path", path, "otherpath", otherPath) + } + lowerName := strings.ToLower(scanned.Username) + if otherPath, ok := seenNames[lowerName]; ok { + mudlog.Warn("ScanUserFiles", "info", "duplicate username", "username", scanned.Username, "path", path, "otherpath", otherPath) + } + seenIds[scanned.UserId] = path + seenNames[lowerName] = path + + results = append(results, UserFileScan{ + UserId: scanned.UserId, + Username: scanned.Username, + CharacterName: scanned.Character.Name, + }) + + return nil + }) + + return results +} diff --git a/internal/users/indexscan_test.go b/internal/users/indexscan_test.go new file mode 100644 index 000000000..de7d0862d --- /dev/null +++ b/internal/users/indexscan_test.go @@ -0,0 +1,302 @@ +package users + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/GoMudEngine/GoMud/internal/mudlog" + "gopkg.in/yaml.v2" +) + +// writeScanTestUser writes a user file shaped like a real record: the index +// fields up top and an embedded character block padded out so the scan pays +// a realistic lexing cost. Production user files run 10-20KB; padKB controls +// how close a test file gets to that. +func writeScanTestUser(t testing.TB, dir string, userId int, username string, charName string, padKB int) { + t.Helper() + + var sb strings.Builder + fmt.Fprintf(&sb, "userid: %d\n", userId) + fmt.Fprintf(&sb, "username: %s\n", username) + sb.WriteString("password: 52c69134f0185daafe43fa511b6d1db16e59404a7992aa0d9bfa0bea05d592a9\n") + sb.WriteString("joined: 2026-01-07T13:52:18.427407+02:00\n") + if charName != `` { + fmt.Fprintf(&sb, "character:\n name: %s\n roomid: 1\n level: 5\n experience: 1234\n", charName) + for i := 0; sb.Len() < padKB*1024; i++ { + fmt.Fprintf(&sb, " itemfiller%d: some padding value that stands in for inventory and buffs %d\n", i, i) + } + } + + if err := os.WriteFile(filepath.Join(dir, fmt.Sprintf(`%d.yaml`, userId)), []byte(sb.String()), 0644); err != nil { + t.Fatal(err) + } +} + +func newScanTestIndex(dir string) *UserIndex { + return &UserIndex{ + Filename: filepath.Join(dir, `users.idx`), + byUsername: make(map[string]int64), + byUserId: make(map[int64]string), + } +} + +// TestScanUserFilesInDir verifies the scan picks up every valid user file, +// including old-format username.yaml files, while skipping alt files, +// malformed yaml, and files without a userid. +func TestScanUserFilesInDir(t *testing.T) { + mudlog.SetupLogger(nil, "", "", false) + + dir := t.TempDir() + for i := 1; i <= 5; i++ { + writeScanTestUser(t, dir, i, fmt.Sprintf(`User_%d`, i), fmt.Sprintf(`Hero_%d`, i), 1) + } + + // A user file in the old username.yaml naming, still valid by content. + if err := os.WriteFile(filepath.Join(dir, `legacy.yaml`), []byte("userid: 77\nusername: legacy\ncharacter:\n name: Oldtimer\n"), 0644); err != nil { + t.Fatal(err) + } + + junkFiles := map[string]string{ + `3.alts.yaml`: "alts:\n- name: x\n", + `broken.yaml`: "userid: [not closed\n", + `50.yaml`: "username: idless\n", + `notes.txt`: `hello`, + } + for name, content := range junkFiles { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + + scan := scanUserFilesInDir(dir) + + if len(scan) != 6 { + t.Fatalf("expected 6 scanned users, got %d", len(scan)) + } + + byId := make(map[int]UserFileScan, len(scan)) + for _, s := range scan { + byId[s.UserId] = s + } + + if s, ok := byId[3]; !ok || s.Username != `User_3` || s.CharacterName != `Hero_3` { + t.Errorf("expected userId 3 with username 'User_3' and character 'Hero_3', got %+v", s) + } + if s, ok := byId[77]; !ok || s.Username != `legacy` || s.CharacterName != `Oldtimer` { + t.Errorf("expected old-format file to scan as userId 77 'legacy'/'Oldtimer', got %+v", s) + } +} + +// TestRebuildFromScanRoundTrip verifies applyScan builds correct lookup +// state, persists it atomically, and that a fresh UserIndex reads back the +// identical records and checksum. +func TestRebuildFromScanRoundTrip(t *testing.T) { + mudlog.SetupLogger(nil, "", "", false) + + dir := t.TempDir() + for i := 1; i <= 10; i++ { + writeScanTestUser(t, dir, i, fmt.Sprintf(`User_%d`, i), fmt.Sprintf(`Hero_%d`, i), 1) + } + + checksum, err := computeDirChecksum(dir) + if err != nil { + t.Fatal(err) + } + + idx := newScanTestIndex(dir) + if err := idx.applyScan(scanUserFilesInDir(dir), checksum); err != nil { + t.Fatalf("applyScan failed: %v", err) + } + + if userId, found := idx.FindByUsername(`user_7`); !found || userId != 7 { + t.Errorf("expected to find 'user_7' with userId 7, got %d, found=%v", userId, found) + } + if highest := idx.GetHighestUserId(); highest != 10 { + t.Errorf("expected highest userId 10, got %d", highest) + } + + reloaded := newScanTestIndex(dir) + reloaded.metaData = reloaded.getMetaDataFromFile() + reloaded.loadRecords() + + if reloaded.metaData.RecordCount != 10 { + t.Fatalf("expected 10 records after reload, got %d", reloaded.metaData.RecordCount) + } + if reloaded.metaData.Checksum != checksum { + t.Errorf("expected persisted checksum %d, got %d", checksum, reloaded.metaData.Checksum) + } + for i := 1; i <= 10; i++ { + username, found := reloaded.FindByUserId(i) + if !found || username != fmt.Sprintf(`user_%d`, i) { + t.Errorf("expected 'user_%d' for userId %d, got '%s', found=%v", i, i, username, found) + } + } +} + +// TestRebuildFromScanReplacesStaleIndex verifies index entries with no +// matching user file do not survive a rebuild. +func TestRebuildFromScanReplacesStaleIndex(t *testing.T) { + mudlog.SetupLogger(nil, "", "", false) + + dir := t.TempDir() + for i := 1; i <= 5; i++ { + writeScanTestUser(t, dir, i, fmt.Sprintf(`User_%d`, i), ``, 0) + } + + idx := newScanTestIndex(dir) + if err := idx.Create(); err != nil { + t.Fatal(err) + } + if err := idx.AddUser(999, `phantom`); err != nil { + t.Fatal(err) + } + + checksum, err := computeDirChecksum(dir) + if err != nil { + t.Fatal(err) + } + if err := idx.applyScan(scanUserFilesInDir(dir), checksum); err != nil { + t.Fatalf("applyScan failed: %v", err) + } + + if _, found := idx.FindByUsername(`phantom`); found { + t.Error("phantom user survived rebuild") + } + if highest := idx.GetHighestUserId(); highest != 5 { + t.Errorf("expected highest userId 5 after rebuild, got %d", highest) + } +} + +// TestIsUpToDateRejectsTruncatedIndex verifies the header/records +// consistency guard: an index whose records section was cut short must +// never report up to date, even when the directory checksum still matches. +func TestIsUpToDateRejectsTruncatedIndex(t *testing.T) { + mudlog.SetupLogger(nil, "", "", false) + + usersDir := t.TempDir() + + for i := 1; i <= 5; i++ { + writeScanTestUser(t, usersDir, i, fmt.Sprintf(`User_%d`, i), ``, 0) + } + + checksum, err := computeDirChecksum(usersDir) + if err != nil { + t.Fatal(err) + } + + idx := newScanTestIndex(usersDir) + if err := idx.applyScan(scanUserFilesInDir(usersDir), checksum); err != nil { + t.Fatalf("applyScan failed: %v", err) + } + if !idx.isUpToDateForDir(usersDir) { + t.Fatal("expected freshly rebuilt index to be up to date") + } + + // Cut the records section short, simulating a crash mid-write, and + // reload the way startup does. + fileBytes, err := os.ReadFile(idx.Filename) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(idx.Filename, fileBytes[:len(fileBytes)-20], 0644); err != nil { + t.Fatal(err) + } + + truncated := newScanTestIndex(usersDir) + truncated.metaData = truncated.getMetaDataFromFile() + truncated.loadRecords() + + if truncated.isUpToDateForDir(usersDir) { + t.Fatal("truncated index must not report up to date") + } +} + +// BenchmarkRebuildFromScan measures scan plus rebuild over synthetic user +// directories with realistically sized files (embedded character block). +func BenchmarkRebuildFromScan(b *testing.B) { + mudlog.SetupLogger(nil, "", "", false) + + for _, userCt := range []int{100, 1000, 10000} { + b.Run(fmt.Sprintf(`%d_users`, userCt), func(b *testing.B) { + dir := b.TempDir() + for i := 1; i <= userCt; i++ { + writeScanTestUser(b, dir, i, fmt.Sprintf(`User_%d`, i), fmt.Sprintf(`Hero_%d`, i), 8) + } + idx := newScanTestIndex(dir) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + checksum, err := computeDirChecksum(dir) + if err != nil { + b.Fatal(err) + } + if err := idx.applyScan(scanUserFilesInDir(dir), checksum); err != nil { + b.Fatal(err) + } + } + }) + } +} + +// BenchmarkScanVsFullUnmarshal isolates the parse cost the scan avoids: +// unmarshaling a real user file (the stock admin record with an embedded +// character) into the minimal scan struct versus a full UserRecord. +func BenchmarkScanVsFullUnmarshal(b *testing.B) { + mudlog.SetupLogger(nil, "", "", false) + + fileBytes, err := os.ReadFile(`../../_datafiles/world/default/users/1.yaml`) + if err != nil { + b.Skip(`stock user file not available`) + } + + b.Run(`minimal_scan`, func(b *testing.B) { + for i := 0; i < b.N; i++ { + var scanned userFileScanFields + if err := yaml.Unmarshal(fileBytes, &scanned); err != nil { + b.Fatal(err) + } + } + }) + + b.Run(`full_userrecord`, func(b *testing.B) { + for i := 0; i < b.N; i++ { + var u UserRecord + if err := yaml.Unmarshal(fileBytes, &u); err != nil { + b.Fatal(err) + } + } + }) +} + +// BenchmarkScanRealUsers scans a real users directory named by the +// BENCH_USERS_DIR environment variable. The directory is only read - the +// rebuilt index is written to the benchmark temp dir - so it is safe to +// point at a live server's users directory. Skipped when the variable is +// unset. +func BenchmarkScanRealUsers(b *testing.B) { + mudlog.SetupLogger(nil, "", "", false) + + usersDir := os.Getenv(`BENCH_USERS_DIR`) + if usersDir == `` { + b.Skip(`set BENCH_USERS_DIR to a users directory to run this benchmark`) + } + + idx := newScanTestIndex(b.TempDir()) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + checksum, err := computeDirChecksum(usersDir) + if err != nil { + b.Fatal(err) + } + if err := idx.applyScan(scanUserFilesInDir(usersDir), checksum); err != nil { + b.Fatal(err) + } + } + b.StopTimer() + + b.ReportMetric(float64(idx.GetMetaData().RecordCount), `users`) +} diff --git a/main.go b/main.go index 7e35190af..f5dd115c0 100644 --- a/main.go +++ b/main.go @@ -262,23 +262,33 @@ func main() { isCopyover := flags.CopyoverFd() >= 0 if !isCopyover { - timeStart := time.Now() idx := users.InitUserIndex() if !idx.Exists() { // Since it doesn't exist yet, that's a good indication we should do a quick format migration check users.DoUserMigrations() } + } + + // One lightweight scan of the user files (userid, username, character + // name) feeds both the user index and the character index, instead of + // each index fully parsing every user record on its own. + scanStart := time.Now() + userScan := users.ScanUserFiles() + + if !isCopyover { + idx := users.GetUserIndex() if idx.IsUpToDate() { - mudlog.Info("UserIndex", "info", "User index up to date.", "users", idx.GetMetaData().RecordCount, "time taken", time.Since(timeStart)) + mudlog.Info("UserIndex", "info", "User index up to date.", "users", idx.GetMetaData().RecordCount, "time taken", time.Since(scanStart)) } else { - idx.Create() - idx.Rebuild() - mudlog.Info("UserIndex", "info", "User index recreated.", "users", idx.GetMetaData().RecordCount, "time taken", time.Since(timeStart)) + if err := idx.RebuildFromScan(userScan); err != nil { + mudlog.Error("UserIndex", "error", "rebuild failed", "details", err) + } + mudlog.Info("UserIndex", "info", "User index recreated.", "users", idx.GetMetaData().RecordCount, "time taken", time.Since(scanStart)) } } - users.GetCharacterIndex().Rebuild() - mudlog.Info("CharacterIndex", "info", "Active character names indexed.", "characters", users.GetCharacterIndex().Len()) + users.GetCharacterIndex().RebuildFromScan(userScan) + mudlog.Info("CharacterIndex", "info", "Active character names indexed.", "characters", users.GetCharacterIndex().Len(), "time taken", time.Since(scanStart)) // Load the round count from the file if !isCopyover {