Skip to content

Commit 4c78692

Browse files
committed
[IMP] jinja_to_qweb: avoid high memory use by lxml/libxml2
upg-2994884 Avoid `MemoryError` and/or killed process because of `malloc()` failing within `lxml` / `libxml2`. Debugging this by determining the size of involved datastructures through means of `sys.getsizeof()` showed that the overly large memory consumption has 3 different sources: 1. During conversion: The global variable `templates_to_check` grows to hundreds of MiB after the various calls to `upgrade_jinja_fields()` by upgrade scripts. 2. During conversion: The call to `cr.dictfetchall()` to gather all templates(fields) that are to be converted, already consumes hundreds of MiB. 3. At the start of function `verify_upgraded_jinja_fields()`, the process is at ~1.5GiB because of (1) and (2). While iterating over all the templates in `templates_to_check`, no significant amount of memory is allocated on top of this *by python datastructures*. But, with each call to `is_converted_template_valid()`, the size of the process increases until it hits the RLIMIT. This function calls into `lxml` multiple times, suggesting that the memory is allocated in `malloc()` calls in the `lxml` and/or `libxml2` C library/ies, evading python's memory accounting and garbage collection. Internet research shows that `lxml` has a long history of different memory leaks in C code, plus some caching mechanism *across documents* that could be responsible[^1]. More recent versions of the module seem to have been improved, but still we're stuck with old versions. This patch solves / works around (3) by running the function body of `is_converted_template_valid()` in a subprocess that is killed immediately after and thus no additional memory is ever allocated by `lxml` in the main process. [^1]: https://benbernardblog.com/tracking-down-a-freaky-python-memory-leak-part-2/ closes #288 Signed-off-by: Christophe Simonis (chs) <chs@odoo.com>
1 parent 446ff3a commit 4c78692

File tree

1 file changed

+36
-25
lines changed

1 file changed

+36
-25
lines changed

src/util/jinja_to_qweb.py

Lines changed: 36 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import html
44
import logging
55
import re
6+
from multiprocessing import Process, Queue
67

78
import babel
89
import lxml
@@ -544,32 +545,42 @@ def verify_upgraded_jinja_fields(cr):
544545

545546

546547
def is_converted_template_valid(env, template_before, template_after, model_name, record_id, engine="inline_template"):
547-
render_before = None
548-
with contextlib.suppress(Exception):
549-
render_before = _render_template_jinja(env, template_before, model_name, record_id)
550-
551-
render_after = None
552-
if render_before is not None:
553-
try:
554-
with mute_logger("odoo.addons.mail.models.mail_render_mixin"):
555-
render_after = env["mail.render.mixin"]._render_template(
556-
template_after, model_name, [record_id], engine=engine
557-
)[record_id]
558-
except Exception:
559-
pass
560-
561-
# post process qweb render to remove comments from the rendered jinja in
562-
# order to avoid false negative because qweb never render comments.
563-
if render_before and render_after and engine == "qweb":
564-
element_before = lxml.html.fragment_fromstring(render_before, create_parent="div")
565-
for comment_element in element_before.xpath("//comment()"):
566-
comment_element.getparent().remove(comment_element)
567-
render_before = lxml.html.tostring(element_before, encoding="unicode")
568-
render_after = lxml.html.tostring(
569-
lxml.html.fragment_fromstring(render_after, create_parent="div"), encoding="unicode"
570-
)
548+
def callback(q):
549+
render_before = None
550+
with contextlib.suppress(Exception):
551+
render_before = _render_template_jinja(env, template_before, model_name, record_id)
552+
553+
render_after = None
554+
if render_before is not None:
555+
try:
556+
with mute_logger("odoo.addons.mail.models.mail_render_mixin"):
557+
render_after = env["mail.render.mixin"]._render_template(
558+
template_after, model_name, [record_id], engine=engine
559+
)[record_id]
560+
except Exception:
561+
pass
562+
563+
# post process qweb render to remove comments from the rendered jinja in
564+
# order to avoid false negative because qweb never render comments.
565+
if render_before and render_after and engine == "qweb":
566+
element_before = lxml.html.fragment_fromstring(render_before, create_parent="div")
567+
for comment_element in element_before.xpath("//comment()"):
568+
comment_element.getparent().remove(comment_element)
569+
render_before = lxml.html.tostring(element_before, encoding="unicode")
570+
render_after = lxml.html.tostring(
571+
lxml.html.fragment_fromstring(render_after, create_parent="div"), encoding="unicode"
572+
)
573+
574+
q.put(render_before is not None and render_before == render_after)
571575

572-
return render_before is not None and render_before == render_after
576+
# to avoid memory leaks in external C libraries (lxml/libxml2), process in a forked child
577+
queue = Queue()
578+
proc = Process(target=callback, args=[queue])
579+
proc.start()
580+
res = queue.get(timeout=60)
581+
if proc.is_alive():
582+
proc.kill()
583+
return res
573584

574585

575586
# jinja render

0 commit comments

Comments
 (0)