[IMP] base: batch XID generation during export

* add UUIDs to XID sections (4 bytes / 8 hex digits) so it's not
  necessary to handle collisions & add a fallback generator
* use COPY for performances over executemany: executemany just
  performs an implicit loop on all statements (in psycopg2)
* ~pure SQL was possible:

      INSERT INTO ir_model_data (module, model, name, res_id)
      SELECT
          '__export__',
          '{model}',
          '{table}_' || A.id || '_' || uuid_generate_v4(),
          A.id
      FROM {table} A
      LEFT JOIN ir_model_data B
             ON A.id = B.res_id AND B.model = %s
      WHERE B.res_id IS NULL;

  but would have required installing pg extensions (problematic
  especially in stable) and performances are about the same as
  the COPY version

task 36343
Fixes #22493
This commit is contained in:
xmo-odoo
2018-04-05 10:34:48 +02:00
committed by GitHub
parent 0cdfae0e25
commit b5c660d8c0
2 changed files with 82 additions and 31 deletions
+15 -5
View File
@@ -2,6 +2,8 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details. # Part of Odoo. See LICENSE file for full copyright and licensing details.
import itertools import itertools
import unittest
from cProfile import Profile
from odoo.tests import common from odoo.tests import common
from odoo.tools import pycompat from odoo.tools import pycompat
@@ -71,6 +73,16 @@ class test_integer_field(CreatorCase):
self.export(2**31-1), self.export(2**31-1),
[[pycompat.text_type(2**31-1)]]) [[pycompat.text_type(2**31-1)]])
@unittest.skip("Only benches/profiles")
def test_xid_perfs(self):
for i in range(10000):
self.make(i)
self.model.invalidate_cache()
records = self.model.search([])
p = Profile()
p.runcall(records._export_rows, [['id'], ['value']])
p.dump_stats('xid_perfs.pstats')
class test_float_field(CreatorCase): class test_float_field(CreatorCase):
model_name = 'export.float' model_name = 'export.float'
@@ -325,11 +337,9 @@ class test_m2o(CreatorCase):
record = self.env['export.integer'].create({'value': 42}) record = self.env['export.integer'].create({'value': 42})
# Expecting the m2o target model name in the external id, # Expecting the m2o target model name in the external id,
# not this model's name # not this model's name
external_id = u'__export__.export_integer_%d' % record.id self.assertRegexpMatches(
self.assertEqual( self.export(record.id, fields=['value/id'])[0][0],
self.export(record.id, fields=['value/id']), u'__export__.export_integer_%d_[0-9a-f]{8}' % record.id)
[[external_id]])
class test_o2m(CreatorCase): class test_o2m(CreatorCase):
model_name = 'export.one2many' model_name = 'export.one2many'
+67 -26
View File
@@ -27,10 +27,12 @@ import collections
import dateutil import dateutil
import functools import functools
import itertools import itertools
import io
import logging import logging
import operator import operator
import pytz import pytz
import re import re
import uuid
from collections import defaultdict, MutableMapping, OrderedDict from collections import defaultdict, MutableMapping, OrderedDict
from contextlib import closing from contextlib import closing
from inspect import getmembers, currentframe from inspect import getmembers, currentframe
@@ -615,33 +617,67 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
def _is_an_ordinary_table(self): def _is_an_ordinary_table(self):
return tools.table_kind(self.env.cr, self._table) == 'r' return tools.table_kind(self.env.cr, self._table) == 'r'
def __export_xml_id(self): def __ensure_xml_id(self, skip=False):
""" Return a valid xml_id for the record ``self``. """ """ Create missing external ids for records in ``self``, and return an
iterator of pairs ``(record, xmlid)`` for the records in ``self``.
:rtype: Iterable[Model, str | None]
"""
if skip:
return ((record, None) for record in self)
if not self:
return iter([])
if not self._is_an_ordinary_table(): if not self._is_an_ordinary_table():
raise Exception( raise Exception(
"You can not export the column ID of model %s, because the " "You can not export the column ID of model %s, because the "
"table %s is not an ordinary table." "table %s is not an ordinary table."
% (self._name, self._table)) % (self._name, self._table))
ir_model_data = self.sudo().env['ir.model.data']
data = ir_model_data.search([('model', '=', self._name), ('res_id', '=', self.id)]) modname = '__export__'
if data:
if data[0].module: cr = self.env.cr
return '%s.%s' % (data[0].module, data[0].name) cr.execute("""
else: SELECT res_id, module, name
return data[0].name FROM ir_model_data
else: WHERE model = %s AND res_id in %s
postfix = 0 """, (self._name, tuple(self.ids)))
name = '%s_%s' % (self._table, self.id) xids = {
while ir_model_data.search([('module', '=', '__export__'), ('name', '=', name)]): res_id: (module, name)
postfix += 1 for res_id, module, name in cr.fetchall()
name = '%s_%s_%s' % (self._table, self.id, postfix) }
ir_model_data.create({
'model': self._name, # create missing xml ids
'res_id': self.id, missing = self.filtered(lambda r: r.id not in xids)
'module': '__export__', xids.update(
'name': name, (r.id, (modname, '%s_%s_%s' % (
}) r._table,
return '__export__.' + name r.id,
uuid.uuid4().hex[:8],
)))
for r in missing
)
cr.copy_from(io.StringIO(
u'\n'.join(
u"%s\t%s\t%s\t%d" % (
modname,
record._name,
xids[record.id][1],
record.id,
)
for record in missing
)),
table='ir_model_data',
columns=['module', 'model', 'name', 'res_id'],
)
self.invalidate_cache()
return (
(record, '%s.%s' % xids[record.id])
for record in self
)
@api.multi @api.multi
def _export_rows(self, fields): def _export_rows(self, fields):
@@ -659,13 +695,16 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
from the cache after it's been iterated in full from the cache after it's been iterated in full
""" """
for idx in range(0, len(rs), 1000): for idx in range(0, len(rs), 1000):
sub = rs[idx: idx+1000] sub = rs[idx:idx+1000]
for rec in sub: for rec in sub:
yield rec yield rec
rs.invalidate_cache(ids=sub.ids) rs.invalidate_cache(ids=sub.ids)
# both _ensure_xml_id and the splitter want to work on recordsets but
# neither returns one, so can't really be composed...
xids = dict(self.__ensure_xml_id(skip=['id'] not in fields))
# memory stable but ends up prefetching 275 fields (???) # memory stable but ends up prefetching 275 fields (???)
for idx, record in enumerate(splittor(self)): for record in splittor(self):
# main line of record, initially empty # main line of record, initially empty
current = [''] * len(fields) current = [''] * len(fields)
lines.append(current) lines.append(current)
@@ -685,7 +724,9 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
if name == '.id': if name == '.id':
current[i] = str(record.id) current[i] = str(record.id)
elif name == 'id': elif name == 'id':
current[i] = record.__export_xml_id() xid = xids.get(record)
assert xid, "no xid was generated for the record %s" % record
current[i] = xid
else: else:
field = record._fields[name] field = record._fields[name]
value = record[name] value = record[name]
@@ -700,7 +741,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
# in import_compat mode, m2m should always be exported as # in import_compat mode, m2m should always be exported as
# a comma-separated list of xids in a single cell # a comma-separated list of xids in a single cell
if import_compatible and field.type == 'many2many' and len(path) > 1 and path[1] == 'id': if import_compatible and field.type == 'many2many' and len(path) > 1 and path[1] == 'id':
xml_ids = [r.__export_xml_id() for r in value] xml_ids = [xid for _, xid in value.__ensure_xml_id()]
current[i] = ','.join(xml_ids) or False current[i] = ','.join(xml_ids) or False
continue continue