New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[FIX] large database restore memory usage fix #17663
Conversation
Fixes MemoryError when restoring large database archive.
Fixes a memory issue when restoring large database archive.
addons/web/controllers/main.py
Outdated
dispatch_rpc('db', 'restore', [master_pwd, name, data, str2bool(copy)]) | ||
data = '' | ||
for chunk in iter(lambda: backup_file.read(8190), b''): | ||
data += base64.b64encode(chunk) dispatch_rpc('db', 'restore', [master_pwd, name, data, str2bool(copy)]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is broken.
Also data could probably be a bytearray (explicitly mutable bytes) rather than rely on CPython optimisations.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oops, my mistake.
Could you elaborate on data being a bytearray? As I see it, dispatch_rpc()
needs data
to be a base64 encoded string. Or maybe I'm missing something.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
base64 is a bytes -> bytes conversion, so data
is more or less a bytestring (if dispatch_rpc needs a native string, the base64 content should be converted correctly or it's going to blow up badly in Python 3).
Bytes are immutable, concatenating immutable string is fundamentally quadratic. This code implicitly relies on a CPython optimisation[0] to reclaim (amortised) linear behaviour. I would rather we avoid this issue and reliance, by using bytearray. The bytearray can be converted into regular bytes at the end of the loop.
[0] it does and can not exist in pypy
This discussion is interesting, but it seems to me it would be quite a bit more efficient to entirely skip the b64encode + decode steps, avoiding putting twice the whole dump in memory. That legacy |
How about this? It does save the data in a temporary file, then just pass the path to this file to the restore method. tested and works. EDIT: reread your comment @odony and decided to go ahead and remove dispatch_rpc. |
Ping? |
addons/web/controllers/main.py
Outdated
@@ -701,13 +703,17 @@ def backup(self, master_pwd, name, backup_format = 'zip'): | |||
|
|||
@http.route('/web/database/restore', type='http', auth="none", methods=['POST'], csrf=False) | |||
def restore(self, master_pwd, backup_file, name, copy=False): | |||
temp_path = tempfile.mkstemp()[1] | |||
with open(temp_path, 'w') as data_file: | |||
backup_file.save(data_file) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this step (the whole with
block) should be in the try/finally, shouldn't it?
addons/web/controllers/main.py
Outdated
@@ -701,13 +703,17 @@ def backup(self, master_pwd, name, backup_format = 'zip'): | |||
|
|||
@http.route('/web/database/restore', type='http', auth="none", methods=['POST'], csrf=False) | |||
def restore(self, master_pwd, backup_file, name, copy=False): | |||
temp_path = tempfile.mkstemp()[1] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
any specific reason to use mkstemp
rather than a NamedTempFile
, as done in exp_restore
?
@@ -211,9 +211,13 @@ def dump_db(db_name, stream, backup_format='zip'): | |||
return stdout | |||
|
|||
def exp_restore(db_name, data, copy=False): | |||
def chunks(d, n=8192): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you still need this chunking
system now?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since we don't use exp_restore
anymore in the scope of this PR, this modification could be dropped but IIRC exp_restore
is still used in the restful api and this chunking system addresses a critical point of memory usage where the whole database was loaded into RAM by .decode('base64')
And move save operation in try block.
@@ -702,12 +704,15 @@ def backup(self, master_pwd, name, backup_format = 'zip'): | |||
@http.route('/web/database/restore', type='http', auth="none", methods=['POST'], csrf=False) | |||
def restore(self, master_pwd, backup_file, name, copy=False): | |||
try: | |||
data = base64.b64encode(backup_file.read()) | |||
dispatch_rpc('db', 'restore', [master_pwd, name, data, str2bool(copy)]) | |||
with tempfile.NamedTemporaryFile(delete=False) as data_file: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not let the contextmanager delete the file?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because I wanted the file closed to avoid potential access and concurrency problems in db.restore_db()
. Otherwise, I agree this part could be rewritten to take advantage of the context manager.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because I wanted the file closed to avoid potential access and concurrency problems in
db.restore_db()
.
Indeed IIRC Windows/NTFS uses exclusive read locks by default, so if the file is still open here it won't be readable by psql
/pg_restore
/…
Closing the file should also ensure everything is properly flushed.
By the way, I believe this one is related to #12376 too but 12376 should be closed in favour of this one. |
Ping. |
@Yenthe666 look twice, the author of the code is kept in the commit |
Yep, I was wrong, I'm sorry! I'm very happy that this PR is finally being handled, it'll improve this feature a lot. Thanks guys. |
Fixes MemoryError when restoring large database archive. Closes odoo#17663 opw-788273
Fixes MemoryError when restoring large database archive. Closes #17663 opw-788273
Closed with 270b2eb |
Hooray! Thanks a lot @nim-odoo 🎉 |
Fixes MemoryError when restoring large database archive. Closes odoo#17663 opw-788273
Fixes MemoryError when restoring large database archive. Closes odoo#17663 opw-788273
When I try to restore database backup to another server (digital ocean to test server on aws), showing an error like this, I tried to change odoo base to same as of the live server.Live server has 2vcpu, 4gb, extra swap space and test server have less resource. Traceback (most recent call last): File "/odoo14/odoo14-server/odoo/api.py", line 792, in get field_cache = field_cache[record.env.cache_key(field)] KeyError: (None,) During handling of the above exception, another exception occurred: Traceback (most recent call last): File "/odoo14/odoo14-server/odoo/fields.py", line 972, in get value = env.cache.get(record,\ self) \ \ File\ "/odoo14/odoo14-server/odoo/api.py",\ line\ 796,\ in\ get \ \ \ \ raise\ CacheMiss(record,\ field) odoo.exceptions.CacheMiss:\ 'res.users(20,).image_128' During\ handling\ of\ the\ above\ exception,\ another\ exception\ occurred: Traceback\ (most\ recent\ call\ last): \ \ File\ "/odoo14/odoo14-server/odoo/api.py",\ line\ 792,\ in\ get \ \ \ \ field_cache\ =\ field_cache[record.env.cache_key(field)] KeyError:\ (None,) During\ handling\ of\ the\ above\ exception,\ another\ exception\ occurred: Traceback\ (most\ recent\ call\ last): \ \ File\ "/odoo14/odoo14-server/odoo/fields.py",\ line\ 972,\ in\ get \ \ \ \ value\ =\ env.cache.get(record,\ self) \ \ File\ "/odoo14/odoo14-server/odoo/api.py",\ line\ 796,\ in\ get \ \ \ \ raise\ CacheMiss(record,\ field) odoo.exceptions.CacheMiss:\ 'res.partner(12809,).image_128' During\ handling\ of\ the\ above\ exception,\ another\ exception\ occurred: Traceback\ (most\ recent\ call\ last): \ \ File\ "/odoo14/odoo14-server/odoo/api.py",\ line\ 792,\ in\ get \ \ \ \ field_cache\ =\ field_cache[record.env.cache_key(field)] KeyError:\ (None,\ None) During\ handling\ of\ the\ above\ exception,\ another\ exception\ occurred: Traceback\ (most\ recent\ call\ last): \ \ File\ "/odoo14/odoo14-server/odoo/fields.py",\ line\ 972,\ in\ get \ \ \ \ value\ =\ env.cache.get(record,\ self) \ \ File\ "/odoo14/odoo14-server/odoo/api.py",\ line\ 796,\ in\ get \ \ \ \ raise\ CacheMiss(record,\ field) odoo.exceptions.CacheMiss:\ 'ir.attachment(321,).datas' During\ handling\ of\ the\ above\ exception,\ another\ exception\ occurred: Traceback\ (most\ recent\ call\ last): \ \ File\ "/odoo14/odoo14-server/odoo/api.py",\ line\ 792,\ in\ get \ \ \ \ field_cache\ =\ field_cache[record.env.cache_key(field)] KeyError:\ (None,) During\ handling\ of\ the\ above\ exception,\ another\ exception\ occurred: Traceback\ (most\ recent\ call\ last): \ \ File\ "/odoo14/odoo14-server/odoo/fields.py",\ line\ 972,\ in\ get \ \ \ \ value\ =\ env.cache.get(record,\ self) \ \ File\ "/odoo14/odoo14-server/odoo/api.py",\ line\ 796,\ in\ get \ \ \ \ raise\ CacheMiss(record,\ field) odoo.exceptions.CacheMiss: 'ir.attachment(321,).raw' During handling of the above exception, another exception occurred: Traceback (most recent call last): File "/odoo14/odoo14-server/odoo/addons/base/models/ir_attachment.py", line 105, in _file_read with open(full_path, 'rb') as f: FileNotFoundError: [Errno 2] No such file or directory: '/odoo14/.local/share/Odoo/filestore/APL_2.0/c5/c54d3d5e2b1320083bf5378b7c195b0985fa04c1' |
Description of the issue/feature this PR addresses:
When restoring a large database archive with the web interface on a low memory system, a MemoryError exception can occur. For example, restoring a 3 GB archive on a 4 GB RAM system results in such error. Olivier Laurent (olt) from Odoo technical support team came up with this patch. I tested it and it works very well. Odoo memory usage dropped significantly when restoring a database.
Current behavior before PR:
Restore a 3GB database archive on a 4 GB RAM system. A lot of memory used and ultimately a MemoryError occurs.
Desired behavior after PR is merged:
Restore a 3GB database archive on a 4 GB RAM system. An acceptable amount of memory used and a successfully restored database.
Additional details:
Without this patch, it seems like Python would not use swap space even if there is plenty available. Very strange. With this patch, everything works well.
--
I confirm I have signed the CLA and read the PR guidelines at www.odoo.com/submit-pr