Skip to content

perf(users): sync the user index incrementally, carry character names in index records - #639

Draft
MorquinDevlar wants to merge 2 commits into
GoMudEngine:masterfrom
MorquinDevlar:morq-incremental-index-sync
Draft

perf(users): sync the user index incrementally, carry character names in index records#639
MorquinDevlar wants to merge 2 commits into
GoMudEngine:masterfrom
MorquinDevlar:morq-incremental-index-sync

Conversation

@MorquinDevlar

Copy link
Copy Markdown
Contributor

What this does

Stacked on #638 - contains its commit; will rebase once that lands.

The directory checksum only matches when no user file changed since the last boot. Any login updates lastlogin and any logout saves the file, so on a live server the checksum misses at essentially every boot and the full rebuild runs anyway. Copyovers always pay the full character-name scan regardless.

This PR makes startup cost proportional to how many users were active since the last restart instead of total accounts:

  • The index record format (now version 3) additionally stores the active character name plus the mtime and size the user file had when it was indexed.
  • Startup does one directory listing and compares each file against its stored record. Unchanged files keep their entry without being opened. Only new or changed files get parsed. Records whose files are gone are dropped. If nothing changed, the index file is not rewritten at all.
  • The character index is rebuilt straight from the index records, so an idle boot - including every copyover - reads zero user files.

Numbers (Apple M5 Pro, 1000 users, 8KB files, warm cache)

Startup shape master after #638 this PR
Nothing changed (incl. copyover) ~120ms ~120ms 2.4ms
50 users active since last restart ~4s ~120ms 18ms
Everything changed (worst case) ~4s ~120ms ~120ms

10,000 users, nothing changed: 30ms (one readdir + stat pass).

Reproduce with:

go test -bench 'BenchmarkSync' -run '^$' ./internal/users/

Upgrade and compatibility

  • An index in the old format (or one that fails to load cleanly) falls back to one full scan and is rewritten in the new format. The first boot after upgrading pays one full scan; every boot after that syncs incrementally. No manual migration.
  • Runtime AddUser writes a stub record (zero mtime/size). The next sync sees the mismatch, re-parses that one file, and completes the record - the register-then-save ordering is preserved.
  • Records are matched to files by the <userid>.yaml convention. Old-format username.yaml files still get indexed correctly but are re-parsed every boot since they never match a record - running the format migration avoids that cost.

Behavior changes worth review

  • The index is now synced (and written, when files changed) during copyover startup too. Previously copyover never touched the index file. The write is atomic (temp file + rename) and the sync is what lets the character index skip reading user files.
  • IsUpToDate is retained for compatibility but startup no longer calls it - the per-file comparison subsumes the whole-directory checksum.

Tests

  • No-change sync parses nothing and leaves the index file untouched.
  • Changed, new, and deleted files are each detected and applied precisely.
  • Old-format index triggers a clean full upgrade to version 3.
  • Character index resolves names from records alone (verified by deleting every user file first).
  • The runtime AddUser stub is completed by the next sync.

Startup previously paid two full passes over every user file: the user
index rebuild unmarshaled each file into a complete UserRecord and then
appended records one at a time (an fsync per user, ~3.7ms each), and
CharacterIndex.Rebuild ran a second full unmarshal of every file on every
boot, including copyovers.

Both indexes are now fed by a single lightweight scan that decodes only
userid, username, and the active character name, and the user index is
written in one atomic pass (temp file + rename, single fsync) with the
directory checksum folded into the same write. At 1000 users this takes a
boot-after-activity rebuild from roughly 4s to roughly 0.1s.

The scan also hardens rebuild behavior: unreadable or malformed user files
are skipped with a warning instead of silently aborting the walk, and
anomalies that usually mean hand-edited data (duplicate userids, duplicate
usernames, a filename that disagrees with the userid inside) are logged.

Also fixes a silent-failure edge in index loading: loadRecords now logs
read failures, and IsUpToDate refuses to trust an index whose records
section is shorter than its header claims. Previously a truncated index
with an intact header could pass the checksum test and boot with empty
maps, which would make GetUniqueUserId hand out userids that already
belong to existing user files.
…every file

Widens the index record (version 3) to carry the active character name
plus the mtime and size the user file had when it was indexed. Startup
then syncs instead of rebuilding: one directory listing is compared
against the stored records, unchanged files keep their entry without
being opened, and only files that are new or changed since the last
index write get parsed. Records whose files are gone are dropped. When
nothing changed the index file is not rewritten at all.

The character index is now rebuilt straight from the index records, so
a boot where nothing changed - including every copyover - reads zero
user files. Startup cost becomes proportional to how many users were
active since the last restart instead of total accounts.

Measured at 1000 users (8KB files, warm cache): a no-change sync takes
2.4ms and a 50-file churn sync 18ms, versus roughly 120ms for the full
scan this replaces and roughly 4s for the original per-user rebuild.

An index in the old format, or one that fails to load cleanly, falls
back to one full scan and is rewritten in the new format - the first
boot after upgrading pays one full scan, every boot after that syncs.
Runtime AddUser writes a stub record (zero mtime), which the next sync
re-parses and completes, preserving the register-then-save ordering.
pruuk added a commit to pruuk/DOGMud that referenced this pull request Jul 28, 2026
… write

Port of upstream GoMud PR GoMudEngine#638 (MorquinDevlar). The old Rebuild fully
YAML-decoded every user via SearchOfflineUsers and appended records
one-at-a-time (a file open + header rewrite per user). Worse, a single
malformed user file returned an error from the walk callback and ABORTED
the scan — every user after the bad file silently vanished from the
index and could no longer log in.

Rebuild now walks the users dir once, decodes only userid/username into
a two-field struct, collects records in memory, and writes header +
records to a temp file renamed over users.idx (a crash mid-write can
never leave a truncated index). Malformed/unreadable files skip with a
warning; duplicate userids/usernames skip (first wins) and are logged;
filename/content userid mismatches are logged. On-disk V1 format is
unchanged.

Upstream GoMudEngine#639 (mtime/size incremental sync, index v3) deliberately NOT
ported: it is still a draft upstream, our copyover path already skips
the rebuild entirely (state rides the pipe), and at ~53 prod users the
single minimal scan is already sub-millisecond. Revisit at scale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant