[26.1] Fix celery worker cold-start thundering herd (build app once, scope tool-data walk to load pass)#23048
Conversation
On a cold start with celery's thread pool (--pool threads --concurrency=N), every task's before_start hook calls get_galaxy_app() -> build_app(). The lru_cache on build_app does not collapse this herd: CPython's lru_cache runs the wrapped function outside its internal lock, so N threads all miss the empty cache simultaneously and each builds a full Galaxy app before the first result is cached. A py-spy dump of a worker showed all 16 pool threads stuck concurrently in GalaxyManagerApplication.__init__. Replace the lru_cache with an explicit double-checked lock so exactly one thread builds the app and the rest wait and reuse it.
ToolDataPathFiles re-walked the entire tool_data_path whenever its cache was older than 1 second. A full os.walk of a production tool-data tree can take longer than that, so within a single config load pass nearly every exists() check triggered a fresh full walk -- a walk storm that got dramatically worse under the concurrent celery app-build herd. The listing only needs to survive one load pass: exists() is called solely while loading or reloading tables, and all tables in a pass share one ToolDataPathFiles instance. So scope the walk to the pass with a re-entrant `cached()` context manager -- walk once on enter, share the snapshot for the duration, drop it on exit -- and wrap load_from_config_file() and reload_tables() in it. Outside a pass no listing is cached and exists() resolves each path directly via os.path.exists(), so the cache can never outlive the on-disk state it was taken from. This removes the indefinite-staleness window an "always cache" approach would open (a .loc file deleted on disk stays reported as present) and the implicit dependency on the tool-data directory watcher to re-sync the listing: every load already walks fresh. The now-redundant update_files() calls in reload_tables() and to_xml_file() are dropped.
CI statusAll failing checks on this PR are from a July-1 CI-environment regression (conda won't install: Breakdown:
This PR only touches two files, and both are clear:
Everything this PR actually affects is green (API tests, unit suites, startup, DB indexes, schema validation). Reruns don't help while the environment is broken |
|
I think it makes sense that at least one of @domgz, @gsaudade99 or @mira-miracoli take a look at this. I am especially concerned about the fact that the context manager is used within But that should be easy to figure out because we can patch the code live to count the amount of calls and log them; we should ensure that In the meantime the cheap solution seems to be saving our face. |
Assert that the tool-data tree is walked once per load pass (config load and reload) regardless of how many tables trigger exists() checks, and that exists() outside a load pass never walks (falls back to os.path.exists).
|
Thanks @domgz — a review from @domgz / @gsaudade99 / @mira-miracoli would be very welcome. One clarification first, because I think there's a small misreading that changes the risk assessment: the context manager is not used within I ran the actual startup path to get real numbers (the So 427 I also added regression tests asserting this ( |
|
Test Galaxy packages for Pulsar failure is likely legit. |
The parenthesized multi-context-manager with-statement (black's preferred form) is parsed as a single tuple context manager on Python 3.8, which the tool_util package still targets, raising AttributeError: __enter__. Switch the walk-counting tests to the pytest monkeypatch fixture, which needs no with-statement and is both 3.8-compatible and stable under black.
jmchilton
left a comment
There was a problem hiding this comment.
On a re-read - these unit tests actually seem better than I worried. There almost so good I wonder if they worth injecting a constructor argument with an actual update/exists implementation that we can use to write tests without mocking.
Route the two filesystem operations ToolDataPathFiles performs (os.walk in update_files, os.path.exists in exists/update_files) through an injectable ToolDataFilesystem protocol, defaulting to a real _OsFilesystem so all callers are unaffected. ToolDataTableManager threads an optional filesystem argument to its ToolDataPathFiles. Rework the walk-count regression tests to drive this seam with a counting CountingFilesystem instead of monkeypatching ToolDataPathFiles, per review feedback preferring an actual implementation over mocking. The tests still assert one walk per config-load pass, one per reload pass, and zero walks for exists() outside a pass.
|
Thanks @jmchilton — did exactly that in 3ae3d95. The walk-count regression tests now drive that seam with a |
|
Sorry @mvdbeek I only had time to glance at it last time. I can see now that this is carefully designed and used so that walks are minimized. A big thank you, this was quite problematic. |
On a cold start with celery's thread pool (
--pool threads --concurrency=N), every task'sbefore_starthook callsget_galaxy_app()→build_app(). A py-spy dump of a production worker showed all 16 pool threads stuck concurrently inGalaxyManagerApplication.__init__, each building a full Galaxy app — a thundering herd that multiplies cold-start time and memory. This PR removes two distinct causes.1. Build the Galaxy app once under an explicit lock
build_app()was memoized with@lru_cache(maxsize=1), but that does not collapse the herd: CPython'slru_cacheruns the wrapped function outside its internal lock, so N threads all miss the empty cache simultaneously and each builds a full app before the first result is stored. Replaced with an explicit double-checked lock so exactly one thread builds the app and the rest wait and reuse it. (Safe in CPython: the module-global assignment is atomic under the GIL, and nothing callsbuild_app.cache_clear().)2. Scope the tool-data directory walk to a load pass
ToolDataPathFilesre-walked the entiretool_data_pathwhenever its cache was older than 1 second. A fullos.walkof a production tool-data tree can take longer than that, so within a single config load pass nearly everyexists()check triggered a fresh full walk — a walk storm on its own, and dramatically worse under the app-build herd above.exists()is only ever called while loading/reloading tables, and all tables in a pass share oneToolDataPathFilesinstance, so the listing only needs to live for one pass. Introduced a re-entrantcached()context manager (walk once on enter, share the snapshot, drop it on exit) and wrappedload_from_config_file()andreload_tables()in it. Outside a pass nothing is cached andexists()resolves each path directly viaos.path.exists(), so the listing can never outlive the on-disk state it was taken from — avoiding both an indefinite staleness window (a deleted.locreported as still present) and any reliance on the tool-data directory watcher to keep the cache honest.How to test the changes?
(Select all options that apply)
test/unit/tool_util/data/andtest/unit/tool_shed/test_data_table_manager.pypass (32 tests); they exercise table loading/reloading through the newcached()scoping.test/unit/app/test_celery.pycoversbuild_app.--pool threads --concurrency=16against a config with a largetool_data_path; confirm (e.g. via py-spy) that only one thread runsGalaxyManagerApplication.__init__and the tool-data tree is walked once per load pass rather than repeatedly.License