Skip to content

Fix stale index after auto-pulling mlcommons@mlperf-automations - #285

Open
anandhu-eng wants to merge 9 commits into
mainfrom
fix-stale-index-after-auto-pull
Open

Fix stale index after auto-pulling mlcommons@mlperf-automations#285
anandhu-eng wants to merge 9 commits into
mainfrom
fix-stale-index-after-auto-pull

Conversation

@anandhu-eng

@anandhu-eng anandhu-eng commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Summary

The original bug. call_script_module_function() refreshed self.index after a successful auto-pull, but get_index() (used by search/find/rm) reads self._index — a different attribute since PR #205's lazy-indexing refactor renamed it there but missed this call site. A search immediately after an auto-pull in the same process silently found nothing.

Fixing the attribute name alone wasn't sufficient: ScriptAction.search()/find()/rm() delegate to self.parent, not self, and a direct mlc pull repo followed by a search (no auto-pull involved) had the identical bug. The propagation needed to live in register_repo() (repo_action.py), the one place every pull already routes through — see the last commit's description for the full history of that back-and-forth.

Two follow-on hardening changes, once the above exposed them:

  1. Shared state instead of copy-back. Every Action subclass (RepoAction, ScriptAction, CacheAction, ExperimentAction, CfgAction) is a throwaway delegate — get_action() builds a fresh one per dispatch — but was previously initialized via self.__dict__.update(vars(parent)), a one-time copy of repos/_index. Any two delegates could silently diverge the moment either wrote to its copy, which is exactly what caused the bug above. Action.repos is now a property, and get_index() resolves through the new Action._state_owner(), which walks the parent chain to the single long-lived root (default_parent) reused across a process. A write from any delegate is now immediately visible to every other delegate — no more manual copy-back. Subclasses call the new self._inherit_from_parent(parent) instead of the old __dict__.update.

  2. repos.json read-modify-write wasn't safe under concurrent mlc processes. register_repo()/unregister_repo() both read the whole list, change one entry, and write it back with no lock — two concurrent mlc pull repo runs could lose one another's entry, or a reader could catch the file mid-write. Reproduced on an unpatched install: of 3 concurrent registrars, one died with JSONDecodeError and its entries vanished. Added repos_json_lock() (repo_action.py) held across the whole read-modify-write, and utils.save_json_atomic() (temp file + os.replace) so unlocked readers never see a partial file. Index's own file-lock helper was moved to utils.py so there's one locking policy instead of two, and its now-redundant wrapper method in Index was removed (call sites now call the utils helper directly).

Also added a "Comment style" section to AGENTS.md: comment why code is this way, concisely; leave "why it used to be different" to commit messages, not code comments that outlive the PR.

Test plan

  • Reproduced pre-fix: run (triggering auto-pull) followed by search in the same process returns an empty list
  • Reproduced pre-fix (2nd form): a direct mlc pull repo followed by a search, no auto-pull involved
  • Verified fix via the original repro (access() for run, then search())
  • Verified fix with run() called directly (isolating the propagation logic from access()'s extra dispatch layer)
  • Verified fix with two separate top-level mlc.action.access() calls, matching typical library usage
  • tests/test_action_state_sharing.py (new) — 8 cases pinning that repos/get_index() resolve through one shared owner; 5 of 8 fail against the pre-fix __dict__.update copy pattern
  • tests/test_repos_json_concurrency.py (new) — 3 concurrent processes registering 12 repos each; all 36 survive. Reproduced the pre-fix crash (JSONDecodeError, lost entries) with the same harness against an unpatched install
  • End-to-end on a fresh MLC_REPOS: auto-pull → register → find → run in one process, run with deps, find/rm cache, re-pull, alias-conflict pull (unregister+register, confirmed no deadlock from the new lock), rm repo
  • Both .github/scripts checks (test_repo_pull_force.py, test_error_guidance.py) pass
  • Full existing test suite passes (29 tests, up from 13 at PR open)
  • autopep8 -a --diff clean on every file touched in this PR
  • Plain CLI usage (mlcr, mlc find script, mlc reindex) unaffected

🤖 Generated with Claude Code

call_script_module_function() wrote the refreshed repos/index to
self.index after a successful auto-pull, but get_index() (used by
search/find/rm) reads self._index — a different attribute, introduced
by an unrelated lazy-indexing refactor (#205) that never updated this
call site. A search immediately after an auto-pull in the same process
would silently find nothing.

Fixing the attribute name alone wasn't enough: ScriptAction.search()/
find()/rm() delegate to self.parent, not self, so the refresh must
also propagate there.

Verified: original repro via access()+search(), an isolated run()-direct
variant, and two separate top-level access() calls (matching typical
library usage) all now find the script post-pull. Existing test suite
(13 tests) and plain CLI usage unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@anandhu-eng
anandhu-eng requested a review from a team as a code owner August 4, 2026 18:29
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

MLCommons CLA bot All contributors have signed the MLCommons CLA ✍️ ✅

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

🤖 AI PR Review Summary

This change renames the attribute 'index' to '_index' and ensures that after an auto-pull operation, the refreshed repository and index state are also propagated to the parent action if it exists and is different from self. This prevents stale state issues when performing searches immediately after pulling. The design addresses state synchronization between related action objects but introduces a private attribute '_index' that should be consistently used throughout the class to avoid confusion or bugs.

Comment thread mlc/script_action.py Outdated
Comment thread mlc/script_action.py Outdated
Addresses PR review feedback: without this, the underscore is easy to
misread as a typo rather than the intentional match to Action.get_index()'s
lazy-build attribute.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread mlc/script_action.py Outdated

if result['return'] == 0:
self.repos = self.load_repos_and_meta()
# Must be self._index, not self.index: get_index() (used by

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Index update should be part of pull action right?

@arjunsuresh pointed out the index refresh belongs in the pull action,
not duplicated in this one caller. Verified that was right: a direct
`mlc pull repo` followed by an immediate search (not going through
script_action.py's auto-pull path at all) had the exact same stale-index
bug, unfixed by the previous commit.

Moved the propagation into register_repo() (repo_action.py), which every
pull already routes through, and simplified script_action.py back down
to just refreshing self.repos (still needed there for the immediate
find_target_folder() retry) — the index rebuild and self.parent
propagation it had is now redundant with what register_repo() does at
the source.

Re-verified: the original auto-pull scenario, the general direct-pull
scenario, and the full existing test suite (13 tests) all pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread mlc/script_action.py Outdated
})

if result['return'] == 0:
# Needed on self so the find_target_folder() retry just below

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These comments no longer needed?

@anandhu-eng
anandhu-eng marked this pull request as draft August 5, 2026 06:44
anandhu-eng and others added 4 commits August 5, 2026 12:16
Two independent problems that the previous stale-index fix exposed.

1. In-memory state was copied, not shared. get_action() builds a fresh
   delegate per dispatch and each subclass __init__ did
   self.__dict__.update(vars(parent)), so a delegate's repos/_index were a
   snapshot that diverged from the long-lived root the moment either side
   changed. register_repo() worked around it by copying repos/_index back
   onto self.parent by hand - which only fixed the one path someone
   remembered to patch.

   Action.repos is now a property and get_index() caches on the object
   returned by the new Action._state_owner(), which walks the parent chain to
   the root. Subclasses call _inherit_from_parent(), which copies startup
   config but deliberately not repos/_index/parent (Action._NOT_INHERITED).
   A delegate's write is now the root's write, so the manual copy-back is
   gone. Non-Action parents (test stubs) and parent=None keep working: the
   delegate simply owns its own state.

   CacheAction/ScriptAction still call self.parent.rm()/search() to reach the
   base implementation. That is not a state workaround and must not become
   super(): Action.rm() calls self.search(), so a CacheAction receiver would
   apply the expiry filter and skip exactly the expired caches
   `mlc rm cache` exists to delete. Commented at the rm site.

2. repos.json read-modify-write was unguarded. Registering and unregistering
   both read the whole list, change one entry and write it back, so two
   concurrent `mlc pull repo` runs could lose an entry - and an unlocked
   reader could catch the file mid-write. Reproduced on an installed
   mlcflow: of three concurrent registrars one died with JSONDecodeError and
   its entries vanished.

   Both writers now hold repos_json_lock() across the whole read-modify-write
   and write via utils.save_json_atomic() (temp file + os.replace), so
   readers see either the old or the new content. Action.__init__'s
   create-if-missing path takes the same lock with a double-check. The locked
   region stays narrow because two FileLocks on one file deadlock even within
   a process - register_repo() unregisters conflicts and pulls deps before
   entering it, and reloads repos after leaving it.

   Index's lock helper now delegates to utils.file_lock_with_incremental_
   timeout so there is a single locking policy.

Tests: test_action_state_sharing.py (5 of its 8 cases fail without part 1,
including the original "registered repo invisible to the next delegate" bug)
and test_repos_json_concurrency.py (3 concurrent processes, 36 registrations,
no lost entries). Verified end to end on a fresh MLC_REPOS: auto-pull ->
register -> find -> run in one process, run with deps, find/rm cache,
re-pull, alias-conflict pull (unregister+register, no deadlock), rm repo.
Both .github/scripts checks pass. Invariants documented in AGENTS.md and
.claude/skill.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The wrapper's body was already a straight passthrough to
utils.file_lock_with_incremental_timeout() after the previous commit moved
the real implementation there; keeping it only added a layer of indirection
between the 4 call sites and the actual lock logic, for no behavioral
difference. Inlined the call at each site and dropped the now-unused
contextmanager import.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A few comments added in the state-ownership fix explained what the code used
to do before the fix ("without anything having to be copied back onto
self.parent by hand", "instead of each delegate mutating a private copy that
has to be pushed back by hand") rather than just why the current code is
this way. That framing has nothing to contrast against for a reader who
never saw the old code, and belongs in the PR description instead.

Tightened _state_owner()'s docstring, the _NOT_INHERITED comment, and
repos_json_lock()'s docstring and the register_repo() comment beside it to
state only the current design and its rationale.

Added a "Comment style" section to AGENTS.md so future contributions follow
the same rule: comment why code is this way, concisely; leave the "why it
used to be different" history to commit messages and PR descriptions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@anandhu-eng
anandhu-eng marked this pull request as ready for review August 5, 2026 08:02
@anandhu-eng anandhu-eng closed this Aug 5, 2026
@anandhu-eng anandhu-eng reopened this Aug 5, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 5, 2026
@mlcommons mlcommons unlocked this conversation Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

🤖 AI PR Review Summary

This PR refactors state ownership in Action subclasses to centralize mutable state (repos and index) on a single owner object per process, avoiding state divergence and stale data issues. It introduces _inherit_from_parent() for delegate initialization instead of copying all parent attributes, and updates CacheAction, CfgAction, and ExperimentAction to use this. It also adds detailed documentation on state ownership and locking requirements. The changes improve consistency and concurrency safety but require careful adherence to the new patterns to avoid subtle bugs.

Comment thread mlc/action.py
@@ -217,9 +274,12 @@ def load_repos(self):
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good use of a file lock when creating repos.json to avoid race conditions. Consider adding a brief comment explaining why the lock is needed here for future maintainers.

Comment thread mlc/cache_action.py
@@ -102,6 +100,10 @@ def rm(self, i):
"""
i['target_name'] = "cache"
# logger.debug(f"Removing cache with input: {i}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment explaining why rm() calls self.parent.rm() instead of super().rm() is very helpful. Consider expanding it slightly to clarify that this preserves the intended filtering behavior and prevents skipping expired caches.

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.

2 participants