Merge lp:~camptocamp/openerp-connector-magento/7.0-export-dependency+lock into lp:~openerp-connector-core-editors/openerp-connector-magento/7.0

Proposed by Guewen Baconnier @ Camptocamp
Status: Work in progress
Proposed branch: lp:~camptocamp/openerp-connector-magento/7.0-export-dependency+lock
Merge into: lp:~openerp-connector-core-editors/openerp-connector-magento/7.0
Diff against target: 208 lines (+167/-1)
1 file modified
magentoerpconnect/unit/export_synchronizer.py (+167/-1)
To merge this branch: bzr merge lp:~camptocamp/openerp-connector-magento/7.0-export-dependency+lock
Reviewer Review Type Date Requested Status
OpenERP Connector Core Editors Pending
Review via email: mp+223508@code.launchpad.net

Description of the change

Prevent concurrent exports to run on the same binding record by acquiring a lock on the binding record before exporting it
Add a 'export_dependency' helper method in the Exporter to ease the export of dependencies

To post a comment you must log in.

Unmerged revisions

1004. By Guewen Baconnier @ Camptocamp

Add a 'export_dependency' helper method in the Exporter to ease the export of dependencies

1003. By Guewen Baconnier @ Camptocamp

Prevent concurrent exports to run on the same binding record by acquiring a lock on the binding record before exporting it

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'magentoerpconnect/unit/export_synchronizer.py'
--- magentoerpconnect/unit/export_synchronizer.py 2014-05-26 09:37:00 +0000
+++ magentoerpconnect/unit/export_synchronizer.py 2014-06-18 07:34:17 +0000
@@ -20,12 +20,19 @@
20##############################################################################20##############################################################################
2121
22import logging22import logging
23
24from contextlib import contextmanager
23from datetime import datetime25from datetime import datetime
26
27import psycopg2
28
29from openerp import SUPERUSER_ID
24from openerp.tools.translate import _30from openerp.tools.translate import _
25from openerp.tools import DEFAULT_SERVER_DATETIME_FORMAT31from openerp.tools import DEFAULT_SERVER_DATETIME_FORMAT
26from openerp.addons.connector.queue.job import job, related_action32from openerp.addons.connector.queue.job import job, related_action
27from openerp.addons.connector.unit.synchronizer import ExportSynchronizer33from openerp.addons.connector.unit.synchronizer import ExportSynchronizer
28from openerp.addons.connector.exception import IDMissingInBackend34from openerp.addons.connector.exception import (IDMissingInBackend,
35 RetryableJobError)
29from .import_synchronizer import import_record36from .import_synchronizer import import_record
30from ..connector import get_environment37from ..connector import get_environment
31from ..related_action import unwrap_binding38from ..related_action import unwrap_binding
@@ -116,6 +123,11 @@
116 result = self._run(*args, **kwargs)123 result = self._run(*args, **kwargs)
117124
118 self.binder.bind(self.magento_id, self.binding_id)125 self.binder.bind(self.magento_id, self.binding_id)
126 # Commit so we keep the external ID when there are several
127 # exports (due to dependencies) and one of them fails.
128 # The commit will also release the lock acquired on the binding
129 # record
130 self.session.commit()
119 return result131 return result
120132
121 def _run(self):133 def _run(self):
@@ -134,10 +146,160 @@
134 super(MagentoExporter, self).__init__(environment)146 super(MagentoExporter, self).__init__(environment)
135 self.binding_record = None147 self.binding_record = None
136148
149 def _lock(self):
150 """ Lock the binding record.
151
152 Lock the binding record so we are sure that only one export
153 job is running for this record if concurrent jobs have to export the
154 same record.
155
156 When concurrent jobs try to export the same record, the first one
157 will lock and proceed, the others will fail to lock and will be
158 retried later.
159
160 This behavior works also when the export becomes multilevel
161 with :meth:`_export_dependencies`. Each level will set its own lock
162 on the binding record it has to export.
163
164 """
165 sql = ("SELECT id FROM %s WHERE ID = %%s FOR UPDATE NOWAIT" %
166 self.model._table)
167 try:
168 self.session.cr.execute(sql, (self.binding_id, ),
169 log_exceptions=False)
170 except psycopg2.OperationalError:
171 _logger.info('A concurrent job is already exporting the same '
172 'record (%s with id %s). Job delayed later.',
173 self.model._name, self.binding_id)
174 raise RetryableJobError(
175 'A concurrent job is already exporting the same record '
176 '(%s with id %s). The job will be retried later.' %
177 (self.model._name, self.binding_id))
178
137 def _has_to_skip(self):179 def _has_to_skip(self):
138 """ Return True if the export can be skipped """180 """ Return True if the export can be skipped """
139 return False181 return False
140182
183 @contextmanager
184 def _retry_unique_violation(self):
185 """ Context manager: catch Unique constraint error and retry the
186 job later.
187
188 When we execute several jobs workers concurrently, it happens
189 that 2 jobs are creating the same record at the same time (binding
190 record created by :meth:`_export_dependency`), resulting in:
191
192 IntegrityError: duplicate key value violates unique
193 constraint "magento_product_product_openerp_uniq"
194 DETAIL: Key (backend_id, openerp_id)=(1, 4851) already exists.
195
196 In that case, we'll retry the import just later.
197
198 .. warning:: The unique constraint must be created on the
199 binding record to prevent 2 bindings to be created
200 for the same Magento record.
201
202 """
203 try:
204 yield
205 except psycopg2.IntegrityError as err:
206 if err.pgcode == psycopg2.errorcodes.UNIQUE_VIOLATION:
207 raise RetryableJobError(
208 'A database error caused the failure of the job:\n'
209 '%s\n\n'
210 'Likely due to 2 concurrent jobs wanting to create '
211 'the same record. The job will be retried later.' % err)
212 else:
213 raise
214
215 def _export_dependency(self, relation, binding_model, exporter_class=None,
216 binding_field='magento_bind_ids'):
217 """
218 Export a dependency. The exporter class is a subclass of
219 ``MagentoExporter``. If a more precise class need to be defined,
220 it can be passed to the ``exporter_class`` keyword argument.
221
222 .. warning:: a commit is done at the end of the export of each
223 dependency. The reason for that is that we pushed a record
224 on the backend and we absolutely have to keep its ID.
225
226 So you *must* take care not to modify the OpenERP
227 database during an export, excepted when writing
228 back the external ID or eventually to store
229 external data that we have to keep on this side.
230
231 You should call this method only at the beginning
232 of the exporter synchronization,
233 in :meth:`~._export_dependencies`.
234
235 :param relation: record to export if not already exported
236 :type relation: :py:class:`openerp.osv.orm.browse_record`
237 :param binding_model: name of the binding model for the relation
238 :type binding_model: str | unicode
239 :param exporter_cls: :py:class:`openerp.addons.connector.connector.ConnectorUnit`
240 class or parent class to use for the export.
241 By default: MagentoExporter
242 :type exporter_cls: :py:class:`openerp.addons.connector.connector.MetaConnectorUnit`
243 :param binding_field: name of the one2many field on a normal
244 record that points to the binding record
245 (default: magento_bind_ids).
246 It is used only when the relation is not
247 a binding but is a normal record.
248 :type binding_field: str | unicode
249 """
250 if not relation:
251 return
252 if exporter_class is None:
253 exporter_class = MagentoExporter
254 rel_binder = self.get_binder_for_model(binding_model)
255 # wrap is typically True if the relation is for instance a
256 # 'product.product' record but the binding model is
257 # 'magento.product.product'
258 wrap = relation._model._name != binding_model
259
260 if wrap and hasattr(relation, binding_field):
261 domain = [('openerp_id', '=', relation.id),
262 ('backend_id', '=', self.backend_record.id)]
263 binding_ids = self.session.search(binding_model, domain)
264 if binding_ids:
265 assert len(binding_ids) == 1, (
266 'only 1 binding for a backend is '
267 'supported in _export_dependency')
268 binding_id = binding_ids[0]
269 # we are working with a unwrapped record (e.g.
270 # product.category) and the binding does not exist yet.
271 # Example: I created a product.product and its binding
272 # magento.product.product and we are exporting it, but we need to
273 # create the binding for the product.category on which it
274 # depends.
275 else:
276 with self.session.change_context({'connector_no_export': True}):
277 with self.session.change_user(SUPERUSER_ID):
278 bind_values = {'backend_id': self.backend_record.id,
279 'openerp_id': relation.id}
280 # If 2 jobs create it at the same time, retry
281 # one later. A unique constraint (backend_id,
282 # openerp_id) should exist on the binding model
283 with self._retry_unique_violation():
284 binding_id = self.session.create(binding_model,
285 bind_values)
286 # Eager commit to avoid having 2 jobs
287 # exporting at the same time. The constraint
288 # will pop if an other job already created
289 # the same binding. It will be caught and
290 # raise a RetryableJobError.
291 self.session.commit()
292 else:
293 # If magento_bind_ids does not exist we are typically in a
294 # "direct" binding (the binding record is the same record).
295 # If wrap is True, relation is already a binding record.
296 binding_id = relation.id
297
298 if rel_binder.to_backend(binding_id) is None:
299 exporter = self.get_connector_unit_for_model(exporter_class,
300 binding_model)
301 exporter.run(binding_id)
302
141 def _export_dependencies(self):303 def _export_dependencies(self):
142 """ Export the dependencies for the record"""304 """ Export the dependencies for the record"""
143 return305 return
@@ -194,6 +356,10 @@
194 # export the missing linked resources356 # export the missing linked resources
195 self._export_dependencies()357 self._export_dependencies()
196358
359 # prevent other jobs to export the same record
360 # will be released on commit (or rollback)
361 self._lock()
362
197 map_record = self._map_data()363 map_record = self._map_data()
198364
199 if self.magento_id:365 if self.magento_id: