mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-21 11:32:00 +02:00
18.0 vanilla
This commit is contained in:
parent
d72e748793
commit
0a7ae8db93
337 changed files with 399651 additions and 232598 deletions
|
|
@ -4,33 +4,39 @@
|
|||
""" Models registries.
|
||||
|
||||
"""
|
||||
from collections import defaultdict, deque
|
||||
from collections.abc import Mapping
|
||||
from contextlib import closing, contextmanager
|
||||
from functools import partial
|
||||
from operator import attrgetter
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import typing
|
||||
import warnings
|
||||
from collections import defaultdict, deque
|
||||
from collections.abc import Mapping
|
||||
from contextlib import closing, contextmanager, nullcontext
|
||||
from functools import partial
|
||||
from operator import attrgetter
|
||||
|
||||
import psycopg2
|
||||
|
||||
import odoo
|
||||
from odoo.modules.db import FunctionStatus
|
||||
from odoo.osv.expression import get_unaccent_wrapper
|
||||
from .. import SUPERUSER_ID
|
||||
from odoo.sql_db import TestCursor
|
||||
from odoo.tools import (
|
||||
config, existing_tables, lazy_classproperty,
|
||||
lazy_property, sql, Collector, OrderedSet, SQL,
|
||||
format_frame
|
||||
config, lazy_classproperty,
|
||||
lazy_property, sql, OrderedSet, SQL,
|
||||
remove_accents,
|
||||
)
|
||||
from odoo.tools.func import locked
|
||||
from odoo.tools.lru import LRU
|
||||
from odoo.tools.misc import Collector, format_frame
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from odoo.models import BaseModel
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_schema = logging.getLogger('odoo.schema')
|
||||
|
|
@ -43,6 +49,7 @@ _REGISTRY_CACHES = {
|
|||
'routing': 1024, # 2 entries per website
|
||||
'routing.rewrites': 8192, # url_rewrite entries
|
||||
'templates.cached_values': 2048, # arbitrary
|
||||
'groups': 1, # contains all res.groups
|
||||
}
|
||||
|
||||
# cache invalidation dependencies, as follows:
|
||||
|
|
@ -52,8 +59,19 @@ _CACHES_BY_KEY = {
|
|||
'assets': ('assets', 'templates.cached_values'),
|
||||
'templates': ('templates', 'templates.cached_values'),
|
||||
'routing': ('routing', 'routing.rewrites', 'templates.cached_values'),
|
||||
'groups': ('groups', 'templates', 'templates.cached_values'), # The processing of groups is saved in the view
|
||||
}
|
||||
|
||||
_REPLICA_RETRY_TIME = 20 * 60 # 20 minutes
|
||||
|
||||
|
||||
def _unaccent(x):
|
||||
if isinstance(x, SQL):
|
||||
return SQL("unaccent(%s)", x)
|
||||
if isinstance(x, psycopg2.sql.Composable):
|
||||
return psycopg2.sql.SQL('unaccent({})').format(x)
|
||||
return f'unaccent({x})'
|
||||
|
||||
class Registry(Mapping):
|
||||
""" Model registry for a particular database.
|
||||
|
||||
|
|
@ -82,6 +100,7 @@ class Registry(Mapping):
|
|||
|
||||
def __new__(cls, db_name):
|
||||
""" Return the registry for the given database name."""
|
||||
assert db_name, "Missing database name"
|
||||
with cls._lock:
|
||||
try:
|
||||
return cls.registries[db_name]
|
||||
|
|
@ -112,7 +131,7 @@ class Registry(Mapping):
|
|||
odoo.modules.reset_modules_state(db_name)
|
||||
raise
|
||||
except Exception:
|
||||
_logger.exception('Failed to load registry')
|
||||
_logger.error('Failed to load registry')
|
||||
del cls.registries[db_name] # pylint: disable=unsupported-delete-operation
|
||||
raise
|
||||
|
||||
|
|
@ -124,16 +143,22 @@ class Registry(Mapping):
|
|||
registry._init = False
|
||||
registry.ready = True
|
||||
registry.registry_invalidated = bool(update_module)
|
||||
registry.signal_changes()
|
||||
|
||||
_logger.info("Registry loaded in %.3fs", time.time() - t0)
|
||||
return registry
|
||||
|
||||
def init(self, db_name):
|
||||
self.models = {} # model name/model instance mapping
|
||||
self.models: dict[str, type[BaseModel]] = {} # model name/model instance mapping
|
||||
self._sql_constraints = set()
|
||||
self._init = True
|
||||
self._database_translated_fields = () # names of translated fields in database
|
||||
self._assertion_report = odoo.tests.result.OdooTestResult()
|
||||
self._database_company_dependent_fields = () # names of company dependent fields in database
|
||||
if config['test_enable'] or config['test_file']:
|
||||
from odoo.tests.result import OdooTestResult # noqa: PLC0415
|
||||
self._assertion_report = OdooTestResult()
|
||||
else:
|
||||
self._assertion_report = None
|
||||
self._fields_by_model = None
|
||||
self._ordinary_tables = None
|
||||
self._constraint_queue = deque()
|
||||
|
|
@ -145,7 +170,11 @@ class Registry(Mapping):
|
|||
self.loaded_xmlids = set()
|
||||
|
||||
self.db_name = db_name
|
||||
self._db = odoo.sql_db.db_connect(db_name)
|
||||
self._db = odoo.sql_db.db_connect(db_name, readonly=False)
|
||||
self._db_readonly = None
|
||||
self._db_readonly_failed_time = None
|
||||
if config['db_replica_host'] is not False or config['test_enable']: # by default, only use readonly pool if we have a db_replica_host defined. Allows to have an empty replica host for testing
|
||||
self._db_readonly = odoo.sql_db.db_connect(db_name, readonly=True)
|
||||
|
||||
# cursor for test mode; None means "normal" mode
|
||||
self.test_cr = None
|
||||
|
|
@ -160,6 +189,9 @@ class Registry(Mapping):
|
|||
self.field_depends_context = Collector()
|
||||
self.field_inverses = Collector()
|
||||
|
||||
# company dependent
|
||||
self.many2one_company_dependents = Collector() # {model_name: (field1, field2, ...)}
|
||||
|
||||
# cache of methods get_field_trigger_tree() and is_modifying_relations()
|
||||
self._field_trigger_trees = {}
|
||||
self._is_modifying_relations = {}
|
||||
|
|
@ -179,6 +211,9 @@ class Registry(Mapping):
|
|||
self.has_unaccent = odoo.modules.db.has_unaccent(cr)
|
||||
self.has_trigram = odoo.modules.db.has_trigram(cr)
|
||||
|
||||
self.unaccent = _unaccent if self.has_unaccent else lambda x: x
|
||||
self.unaccent_python = remove_accents if self.has_unaccent else lambda x: x
|
||||
|
||||
@classmethod
|
||||
@locked
|
||||
def delete(cls, db_name):
|
||||
|
|
@ -204,7 +239,7 @@ class Registry(Mapping):
|
|||
""" Return an iterator over all model names. """
|
||||
return iter(self.models)
|
||||
|
||||
def __getitem__(self, model_name):
|
||||
def __getitem__(self, model_name: str) -> type[BaseModel]:
|
||||
""" Return the model with the given name or raise KeyError if it doesn't exist."""
|
||||
return self.models[model_name]
|
||||
|
||||
|
|
@ -309,6 +344,7 @@ class Registry(Mapping):
|
|||
self.field_depends.clear()
|
||||
self.field_depends_context.clear()
|
||||
self.field_inverses.clear()
|
||||
self.many2one_company_dependents.clear()
|
||||
|
||||
# do the actual setup
|
||||
for model in models:
|
||||
|
|
@ -598,7 +634,7 @@ class Registry(Mapping):
|
|||
""" Create or drop column indexes for the given models. """
|
||||
|
||||
expected = [
|
||||
(sql.make_index_name(Model._table, field.name), Model._table, field, getattr(field, 'unaccent', False))
|
||||
(sql.make_index_name(Model._table, field.name), Model._table, field)
|
||||
for model_name in model_names
|
||||
for Model in [self.models[model_name]]
|
||||
if Model._auto and not Model._abstract
|
||||
|
|
@ -613,7 +649,7 @@ class Registry(Mapping):
|
|||
[tuple(row[0] for row in expected)])
|
||||
existing = dict(cr.fetchall())
|
||||
|
||||
for indexname, tablename, field, unaccent in expected:
|
||||
for indexname, tablename, field in expected:
|
||||
index = field.index
|
||||
assert index in ('btree', 'btree_not_null', 'trigram', True, False, None)
|
||||
if index and indexname not in existing and \
|
||||
|
|
@ -623,19 +659,24 @@ class Registry(Mapping):
|
|||
if field.translate:
|
||||
column_expression = f'''(jsonb_path_query_array({column_expression}, '$.*')::text)'''
|
||||
# add `unaccent` to the trigram index only because the
|
||||
# trigram indexes are mainly used for (i/=)like search and
|
||||
# trigram indexes are mainly used for (=)ilike search and
|
||||
# unaccent is added only in these cases when searching
|
||||
if unaccent and self.has_unaccent:
|
||||
if self.has_unaccent == FunctionStatus.INDEXABLE:
|
||||
column_expression = get_unaccent_wrapper(cr)(column_expression)
|
||||
else:
|
||||
warnings.warn(
|
||||
"PostgreSQL function 'unaccent' is present but not immutable, "
|
||||
"therefore trigram indexes may not be effective.",
|
||||
)
|
||||
if self.has_unaccent == FunctionStatus.INDEXABLE:
|
||||
column_expression = self.unaccent(column_expression)
|
||||
elif self.has_unaccent:
|
||||
warnings.warn(
|
||||
"PostgreSQL function 'unaccent' is present but not immutable, "
|
||||
"therefore trigram indexes may not be effective.",
|
||||
)
|
||||
expression = f'{column_expression} gin_trgm_ops'
|
||||
method = 'gin'
|
||||
where = ''
|
||||
elif index == 'btree_not_null' and field.company_dependent:
|
||||
# company dependent condition will use extra
|
||||
# `AND col IS NOT NULL` to use the index.
|
||||
expression = f'({column_expression} IS NOT NULL)'
|
||||
method = 'btree'
|
||||
where = f'{column_expression} IS NOT NULL'
|
||||
else: # index in ['btree', 'btree_not_null', True]
|
||||
expression = f'{column_expression}'
|
||||
method = 'btree'
|
||||
|
|
@ -706,7 +747,7 @@ class Registry(Mapping):
|
|||
for name, model in env.registry.items()
|
||||
if not model._abstract and model._table_query is None
|
||||
}
|
||||
missing_tables = set(table2model).difference(existing_tables(cr, table2model))
|
||||
missing_tables = set(table2model).difference(sql.existing_tables(cr, table2model))
|
||||
|
||||
if missing_tables:
|
||||
missing = {table2model[table] for table in missing_tables}
|
||||
|
|
@ -717,7 +758,7 @@ class Registry(Mapping):
|
|||
env[name].init()
|
||||
env.flush_all()
|
||||
# check again, and log errors if tables are still missing
|
||||
missing_tables = set(table2model).difference(existing_tables(cr, table2model))
|
||||
missing_tables = set(table2model).difference(sql.existing_tables(cr, table2model))
|
||||
for table in missing_tables:
|
||||
_logger.error("Model %s has no table.", table2model[table])
|
||||
|
||||
|
|
@ -817,6 +858,8 @@ class Registry(Mapping):
|
|||
self.registry_sequence, ' '.join('[Cache %s: %s]' % cs for cs in self.cache_sequences.items()))
|
||||
|
||||
def get_sequences(self, cr):
|
||||
assert cr.readonly is False, "can't use replica, sequence data is not replicated"
|
||||
|
||||
cache_sequences_query = ', '.join([f'base_cache_signaling_{cache_name}' for cache_name in _CACHES_BY_KEY])
|
||||
cache_sequences_values_query = ',\n'.join([f'base_cache_signaling_{cache_name}.last_value' for cache_name in _CACHES_BY_KEY])
|
||||
cr.execute(f"""
|
||||
|
|
@ -827,14 +870,14 @@ class Registry(Mapping):
|
|||
cache_sequences = dict(zip(_CACHES_BY_KEY, cache_sequences_values))
|
||||
return registry_sequence, cache_sequences
|
||||
|
||||
def check_signaling(self):
|
||||
def check_signaling(self, cr=None):
|
||||
""" Check whether the registry has changed, and performs all necessary
|
||||
operations to update the registry. Return an up-to-date registry.
|
||||
"""
|
||||
if self.in_test_mode():
|
||||
return self
|
||||
|
||||
with closing(self.cursor()) as cr:
|
||||
with nullcontext(cr) if cr is not None else closing(self.cursor()) as cr:
|
||||
db_registry_sequence, db_cache_sequences = self.get_sequences(cr)
|
||||
changes = ''
|
||||
# Check if the model registry must be reloaded
|
||||
|
|
@ -865,20 +908,19 @@ class Registry(Mapping):
|
|||
|
||||
def signal_changes(self):
|
||||
""" Notifies other processes if registry or cache has been invalidated. """
|
||||
if self.in_test_mode():
|
||||
if self.registry_invalidated:
|
||||
self.registry_sequence += 1
|
||||
for cache_name in self.cache_invalidated or ():
|
||||
self.cache_sequences[cache_name] += 1
|
||||
self.registry_invalidated = False
|
||||
self.cache_invalidated.clear()
|
||||
if not self.ready:
|
||||
_logger.warning('Calling signal_changes when registry is not ready is not suported')
|
||||
return
|
||||
|
||||
if self.registry_invalidated:
|
||||
_logger.info("Registry changed, signaling through the database")
|
||||
with closing(self.cursor()) as cr:
|
||||
cr.execute("select nextval('base_registry_signaling')")
|
||||
self.registry_sequence = cr.fetchone()[0]
|
||||
# If another process concurrently updates the registry,
|
||||
# self.registry_sequence will actually be out-of-date,
|
||||
# and the next call to check_signaling() will detect that and trigger a registry reload.
|
||||
# otherwise, self.registry_sequence should be equal to cr.fetchone()[0]
|
||||
self.registry_sequence += 1
|
||||
|
||||
# no need to notify cache invalidation in case of registry invalidation,
|
||||
# because reloading the registry implies starting with an empty cache
|
||||
|
|
@ -887,7 +929,11 @@ class Registry(Mapping):
|
|||
with closing(self.cursor()) as cr:
|
||||
for cache_name in self.cache_invalidated:
|
||||
cr.execute("select nextval(%s)", [f'base_cache_signaling_{cache_name}'])
|
||||
self.cache_sequences[cache_name] = cr.fetchone()[0]
|
||||
# If another process concurrently updates the cache,
|
||||
# self.cache_sequences[cache_name] will actually be out-of-date,
|
||||
# and the next call to check_signaling() will detect that and trigger cache invalidation.
|
||||
# otherwise, self.cache_sequences[cache_name] should be equal to cr.fetchone()[0]
|
||||
self.cache_sequences[cache_name] += 1
|
||||
|
||||
self.registry_invalidated = False
|
||||
self.cache_invalidated.clear()
|
||||
|
|
@ -918,10 +964,11 @@ class Registry(Mapping):
|
|||
""" Test whether the registry is in 'test' mode. """
|
||||
return self.test_cr is not None
|
||||
|
||||
def enter_test_mode(self, cr):
|
||||
def enter_test_mode(self, cr, test_readonly_enabled=True):
|
||||
""" Enter the 'test' mode, where one cursor serves several requests. """
|
||||
assert self.test_cr is None
|
||||
self.test_cr = cr
|
||||
self.test_readonly_enabled = test_readonly_enabled
|
||||
self.test_lock = threading.RLock()
|
||||
assert Registry._saved_lock is None
|
||||
Registry._saved_lock = Registry._lock
|
||||
|
|
@ -931,18 +978,39 @@ class Registry(Mapping):
|
|||
""" Leave the test mode. """
|
||||
assert self.test_cr is not None
|
||||
self.test_cr = None
|
||||
self.test_lock = None
|
||||
del self.test_readonly_enabled
|
||||
del self.test_lock
|
||||
assert Registry._saved_lock is not None
|
||||
Registry._lock = Registry._saved_lock
|
||||
Registry._saved_lock = None
|
||||
|
||||
def cursor(self):
|
||||
def cursor(self, /, readonly=False):
|
||||
""" Return a new cursor for the database. The cursor itself may be used
|
||||
as a context manager to commit/rollback and close automatically.
|
||||
|
||||
:param readonly: Attempt to acquire a cursor on a replica database.
|
||||
Acquire a read/write cursor on the primary database in case no
|
||||
replica exists or that no readonly cursor could be acquired.
|
||||
"""
|
||||
if self.test_cr is not None:
|
||||
# in test mode we use a proxy object that uses 'self.test_cr' underneath
|
||||
return TestCursor(self.test_cr, self.test_lock, current_test=odoo.modules.module.current_test)
|
||||
if readonly and not self.test_readonly_enabled:
|
||||
_logger.info('Explicitly ignoring readonly flag when generating a cursor')
|
||||
return TestCursor(self.test_cr, self.test_lock, readonly and self.test_readonly_enabled, current_test=odoo.modules.module.current_test)
|
||||
|
||||
if readonly and self._db_readonly is not None:
|
||||
if (
|
||||
self._db_readonly_failed_time is None
|
||||
or time.monotonic() > self._db_readonly_failed_time + _REPLICA_RETRY_TIME
|
||||
):
|
||||
try:
|
||||
cr = self._db_readonly.cursor()
|
||||
self._db_readonly_failed_time = None
|
||||
return cr
|
||||
except psycopg2.OperationalError:
|
||||
self._db_readonly_failed_time = time.monotonic()
|
||||
_logger.warning("Failed to open a readonly cursor, falling back to read-write cursor for %dmin %dsec", *divmod(_REPLICA_RETRY_TIME, 60))
|
||||
threading.current_thread().cursor_mode = 'ro->rw'
|
||||
return self._db.cursor()
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue