19.0 vanilla

This commit is contained in:
Ernad Husremovic 2025-10-03 18:07:25 +02:00
parent 0a7ae8db93
commit 991d2234ca
416 changed files with 646602 additions and 300844 deletions

View file

@ -1,27 +1,30 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from contextlib import nullcontext, ExitStack
from datetime import datetime
import gc
import json
import logging
import sys
import time
import threading
import re
import functools
import tracemalloc
from psycopg2 import OperationalError
from odoo import tools
from odoo.tools import SQL
from .gc import disabling_gc
_logger = logging.getLogger(__name__)
# ensure we have a non patched time for profiling times when using freezegun
real_datetime_now = datetime.now
real_time = time.time.__call__
real_cpu_time = time.thread_time.__call__
def _format_frame(frame):
code = frame.f_code
@ -48,7 +51,7 @@ def _get_stack_trace(frame, limit_frame=None):
stack.append(_format_frame(frame))
frame = frame.f_back
if frame is None and limit_frame:
_logger.error("Limit frame was not found")
_logger.runbot("Limit frame was not found")
return list(reversed(stack))
@ -89,6 +92,7 @@ class Collector:
It defines default behaviors for creating an entry in the collector.
"""
name = None # symbolic name of the collector
_store = name
_registry = {} # map collector names to their class
@classmethod
@ -128,7 +132,7 @@ class Collector:
and self.profiler.entry_count() >= self.profiler.entry_count_limit:
self.profiler.end()
self.add(entry=entry, frame=frame)
self.add(entry=entry,frame=frame)
def _get_stack_trace(self, frame=None):
""" Return the stack trace to be included in a given entry. """
@ -183,26 +187,56 @@ class SQLCollector(Collector):
return super().summary() + sql_entries
class PeriodicCollector(Collector):
class _BasePeriodicCollector(Collector):
"""
Record execution frames asynchronously at most every `interval` seconds.
:param interval (float): time to wait in seconds between two samples.
"""
name = 'traces_async'
_min_interval = 0.001 # minimum interval allowed
_max_interval = 5 # maximum interval allowed
_default_interval = 0.001
def __init__(self, interval=0.01): # check duration. dynamic?
def __init__(self, interval=None): # check duration. dynamic?
super().__init__()
self.active = False
self.frame_interval = interval
self.frame_interval = interval or self._default_interval
self.__thread = threading.Thread(target=self.run)
self.last_frame = None
def start(self):
interval = self.profiler.params.get(f'{self.name}_interval')
if interval:
self.frame_interval = min(max(float(interval), self._min_interval), self._max_interval)
init_thread = self.profiler.init_thread
if not hasattr(init_thread, 'profile_hooks'):
init_thread.profile_hooks = []
init_thread.profile_hooks.append(self.progress)
self.__thread.start()
def run(self):
self.active = True
last_time = real_time()
self.last_time = real_time()
while self.active: # maybe add a check on parent_thread state?
duration = real_time() - last_time
self.progress()
time.sleep(self.frame_interval)
self._entries.append({'stack': [], 'start': real_time()}) # add final end frame
def stop(self):
self.active = False
self.__thread.join()
self.profiler.init_thread.profile_hooks.remove(self.progress)
class PeriodicCollector(_BasePeriodicCollector):
name = 'traces_async'
def add(self, entry=None, frame=None):
""" Add an entry (dict) to this collector. """
if self.last_frame:
duration = real_time() - self._last_time
if duration > self.frame_interval * 10 and self.last_frame:
# The profiler has unexpectedly slept for more than 10 frame intervals. This may
# happen when calling a C library without releasing the GIL. In that case, the
@ -210,32 +244,9 @@ class PeriodicCollector(Collector):
# the call itself does not appear in any of those frames: the duration of the call
# is incorrectly attributed to the last frame.
self._entries[-1]['stack'].append(('profiling', 0, '⚠ Profiler freezed for %s s' % duration, ''))
self.last_frame = None # skip duplicate detection for the next frame.
self.progress()
last_time = real_time()
time.sleep(self.frame_interval)
self.last_frame = None # skip duplicate detection for the next frame.
self._last_time = real_time()
self._entries.append({'stack': [], 'start': real_time()}) # add final end frame
def start(self):
interval = self.profiler.params.get('traces_async_interval')
if interval:
self.frame_interval = min(max(float(interval), 0.001), 1)
init_thread = self.profiler.init_thread
if not hasattr(init_thread, 'profile_hooks'):
init_thread.profile_hooks = []
init_thread.profile_hooks.append(self.progress)
self.__thread.start()
def stop(self):
self.active = False
self.__thread.join()
self.profiler.init_thread.profile_hooks.remove(self.progress)
def add(self, entry=None, frame=None):
""" Add an entry (dict) to this collector. """
frame = frame or get_current_frame(self.profiler.init_thread)
if frame == self.last_frame:
# don't save if the frame is exactly the same as the previous one.
@ -245,6 +256,42 @@ class PeriodicCollector(Collector):
super().add(entry=entry, frame=frame)
_lock = threading.Lock()
class MemoryCollector(_BasePeriodicCollector):
name = 'memory'
_store = 'others'
_min_interval = 0.01 # minimum interval allowed
_default_interval = 1
def start(self):
_lock.acquire()
tracemalloc.start()
super().start()
def add(self, entry=None, frame=None):
""" Add an entry (dict) to this collector. """
self._entries.append({
'start': real_time(),
'memory': tracemalloc.take_snapshot(),
})
def stop(self):
_lock.release()
tracemalloc.stop()
super().stop()
def post_process(self):
for i, entry in enumerate(self._entries):
if entry.get("memory", False):
entry_statistics = entry["memory"].statistics('traceback')
modified_entry_statistics = [{'traceback': list(statistic.traceback._frames),
'size': statistic.size} for statistic in entry_statistics]
self._entries[i] = {"memory_tracebacks": modified_entry_statistics, "start": entry['start']}
class SyncCollector(Collector):
"""
Record complete execution synchronously.
@ -299,57 +346,6 @@ class SyncCollector(Collector):
class QwebTracker():
@classmethod
def wrap_render(cls, method_render):
@functools.wraps(method_render)
def _tracked_method_render(self, template, values=None, **options):
current_thread = threading.current_thread()
execution_context_enabled = getattr(current_thread, 'profiler_params', {}).get('execution_context_qweb')
qweb_hooks = getattr(current_thread, 'qweb_hooks', ())
if execution_context_enabled or qweb_hooks:
# To have the new compilation cached because the generated code will change.
# Therefore 'profile' is a key to the cache.
options['profile'] = True
return method_render(self, template, values, **options)
return _tracked_method_render
@classmethod
def wrap_compile(cls, method_compile):
@functools.wraps(method_compile)
def _tracked_compile(self, template):
if not self.env.context.get('profile'):
return method_compile(self, template)
template_functions, def_name = method_compile(self, template)
render_template = template_functions[def_name]
def profiled_method_compile(self, values):
options = template_functions['options']
ref = options.get('ref')
ref_xml = options.get('ref_xml')
qweb_tracker = QwebTracker(ref, ref_xml, self.env.cr)
self = self.with_context(qweb_tracker=qweb_tracker)
if qweb_tracker.execution_context_enabled:
with ExecutionContext(template=ref):
return render_template(self, values)
return render_template(self, values)
template_functions[def_name] = profiled_method_compile
return (template_functions, def_name)
return _tracked_compile
@classmethod
def wrap_compile_directive(cls, method_compile_directive):
@functools.wraps(method_compile_directive)
def _tracked_compile_directive(self, el, options, directive, level):
if not options.get('profile') or directive in ('inner-content', 'tag-open', 'tag-close'):
return method_compile_directive(self, el, options, directive, level)
enter = f"{' ' * 4 * level}self.env.context['qweb_tracker'].enter_directive({directive!r}, {el.attrib!r}, {options['_qweb_error_path_xml'][0]!r})"
leave = f"{' ' * 4 * level}self.env.context['qweb_tracker'].leave_directive({directive!r}, {el.attrib!r}, {options['_qweb_error_path_xml'][0]!r})"
code_directive = method_compile_directive(self, el, options, directive, level)
return [enter, *code_directive, leave] if code_directive else []
return _tracked_compile_directive
def __init__(self, view_id, arch, cr):
current_thread = threading.current_thread() # don't store current_thread on self
self.execution_context_enabled = getattr(current_thread, 'profiler_params', {}).get('execution_context_qweb')
@ -540,6 +536,8 @@ class Profiler:
"""
self.start_time = 0
self.duration = 0
self.start_cpu_time = 0
self.cpu_duration = 0
self.profile_session = profile_session or make_session()
self.description = description
self.init_frame = None
@ -551,8 +549,9 @@ class Profiler:
self.profile_id = None
self.log = log
self.sub_profilers = []
self.entry_count_limit = int(self.params.get("entry_count_limit", 0)) # the limit could be set using a smarter way
self.entry_count_limit = int(self.params.get("entry_count_limit",0)) # the limit could be set using a smarter way
self.done = False
self.exit_stack = ExitStack()
if db is ...:
# determine database from current thread
@ -599,9 +598,10 @@ class Profiler:
self.description = f"{frame.f_code.co_name} ({code.co_filename}:{frame.f_lineno})"
if self.params:
self.init_thread.profiler_params = self.params
if self.disable_gc and gc.isenabled():
gc.disable()
if self.disable_gc:
self.exit_stack.enter_context(disabling_gc())
self.start_time = real_time()
self.start_cpu_time = real_cpu_time()
for collector in self.collectors:
collector.start()
return self
@ -617,6 +617,7 @@ class Profiler:
for collector in self.collectors:
collector.stop()
self.duration = real_time() - self.start_time
self.cpu_duration = real_cpu_time() - self.start_cpu_time
self._add_file_lines(self.init_stack_trace)
if self.db:
@ -629,12 +630,19 @@ class Profiler:
"create_date": real_datetime_now(),
"init_stack_trace": json.dumps(_format_stack(self.init_stack_trace)),
"duration": self.duration,
"cpu_duration": self.cpu_duration,
"entry_count": self.entry_count(),
"sql_count": sum(len(collector.entries) for collector in self.collectors if collector.name == 'sql')
}
others = {}
for collector in self.collectors:
if collector.entries:
values[collector.name] = json.dumps(collector.entries)
if collector._store == "others":
others[collector.name] = json.dumps(collector.entries)
else:
values[collector.name] = json.dumps(collector.entries)
if others:
values['others'] = json.dumps(others)
query = SQL(
"INSERT INTO ir_profile(%s) VALUES %s RETURNING id",
SQL(",").join(map(SQL.identifier, values)),
@ -646,15 +654,14 @@ class Profiler:
except OperationalError:
_logger.exception("Could not save profile in database")
finally:
if self.disable_gc:
gc.enable()
self.exit_stack.close()
if self.params:
del self.init_thread.profiler_params
if self.log:
_logger.info(self.summary())
def _get_cm_proxy(self):
return _Nested(self)
return Nested(self)
def _add_file_lines(self, stack):
for index, frame in enumerate(stack):
@ -722,20 +729,6 @@ class Profiler:
return result
class _Nested:
__slots__ = ("__profiler",)
def __init__(self, profiler):
self.__profiler = profiler
def __enter__(self):
self.__profiler.__enter__()
return self
def __exit__(self, *args):
return self.__profiler.__exit__(*args)
class Nested:
"""
Utility to nest another context manager inside a profiler.
@ -747,16 +740,16 @@ class Nested:
be ignored, too. This is also why Nested() does not use
contextlib.contextmanager.
"""
def __init__(self, profiler, context_manager):
self.profiler = profiler
self.context_manager = context_manager
def __init__(self, profiler, context_manager=None):
self._profiler__ = profiler
self.context_manager = context_manager or nullcontext()
def __enter__(self):
self.profiler.__enter__()
self._profiler__.__enter__()
return self.context_manager.__enter__()
def __exit__(self, exc_type, exc_value, traceback):
try:
return self.context_manager.__exit__(exc_type, exc_value, traceback)
finally:
self.profiler.__exit__(exc_type, exc_value, traceback)
self._profiler__.__exit__(exc_type, exc_value, traceback)