BaseModuleWatchdog subclasses (ModuleWatchdog, DebuggerModuleWatchdog,
ModuleCodeCollector, SymbolDatabaseUploader, and the errortracking
watchdogs) each inserted themselves into sys.meta_path and, on every
find_spec/find_module call, scanned the rest of sys.meta_path to find
the real underlying finder. With N watchdogs installed simultaneously
(realistic in production: debugger + coverage + symbol DB + error
tracking can all be enabled on one service), this produced O(N^2)
find_spec invocations per import.
A standalone benchmark against the unrefactored code confirmed the
call count follows the exact triangular number N(N+1)/2 (e.g. 21
calls for N=6, where 1 would do), and wall-clock import time grew
correspondingly (~1.95x baseline at N=6, unbounded as feature count
grows). Module imports sit on every process-startup hot path, so this
was worth fixing.
This introduces _UniversalModuleWatchdog: the single object ever
inserted into sys.meta_path for watchdog purposes, mirroring the
existing _UniversalWrappingContext/WrappingContext pattern in
ddtrace/internal/wrapping/context.py. Concrete watchdog subclasses
keep their own singleton instance and state (still read directly as
production API, e.g. ModuleCodeCollector._instance), but register as
participants with the one real finder instead of installing their
own. The real find_spec/find_module lookup now happens once per
import regardless of N; only the O(N) per-participant callback/
transformer registration remains.
Re-running the adapted benchmark against the refactored code confirms
find_spec calls are now flat at 1 per import for N in {1..30}, versus
the prior triangular growth.
As part of unifying hook resolution through the single participant
list, _pre_exec_module_hooks/_import_exception_hooks resolution now
uses first-registered-wins (iterating _participants in registration
order) instead of the previous most-recently-installed-wins (an
accident of meta_path insertion order). after_import/transform
callbacks are unaffected: they already fired in registration order.
Also wraps each after_import callback invocation in try/except so one
misbehaving hook can't prevent the rest from running, folding in a
fix that was previously written but unmerged on
chore/handle-after-import-exceptions.
Description
BaseModuleWatchdog subclasses (ModuleWatchdog, DebuggerModuleWatchdog, ModuleCodeCollector, SymbolDatabaseUploader, and the errortracking watchdogs) each inserted themselves into sys.meta_path and, on every find_spec/find_module call, scanned the rest of sys.meta_path to find the real underlying finder. With N watchdogs installed simultaneously (realistic in production: debugger + coverage + symbol DB + error tracking can all be enabled on one service), this produced O(N^2) find_spec invocations per import.
A standalone benchmark against the unrefactored code confirmed the call count follows the exact triangular number N(N+1)/2 (e.g. 21 calls for N=6, where 1 would do), and wall-clock import time grew correspondingly (~1.95x baseline at N=6, unbounded as feature count grows). Module imports sit on every process-startup hot path, so this was worth fixing.
This introduces _UniversalModuleWatchdog: the single object ever inserted into sys.meta_path for watchdog purposes, mirroring the existing _UniversalWrappingContext/WrappingContext pattern in ddtrace/internal/wrapping/context.py. Concrete watchdog subclasses keep their own singleton instance and state (still read directly as production API, e.g. ModuleCodeCollector._instance), but register as participants with the one real finder instead of installing their own. The real find_spec/find_module lookup now happens once per import regardless of N; only the O(N) per-participant callback/ transformer registration remains.
Re-running the adapted benchmark against the refactored code confirms find_spec calls are now flat at 1 per import for N in {1..30}, versus the prior triangular growth.
As part of unifying hook resolution through the single participant list, _pre_exec_module_hooks/_import_exception_hooks resolution now uses first-registered-wins (iterating _participants in registration order) instead of the previous most-recently-installed-wins (an accident of meta_path insertion order). after_import/transform callbacks are unaffected: they already fired in registration order.