New storage api#10693
Conversation
ea2886b to
2f25568
Compare
c3f6c61 to
44b951d
Compare
106685f to
9938ba5
Compare
88e66c9 to
ff565a0
Compare
452ccba to
c40ed7c
Compare
- WalletDB no longer inherits from JsonDB, it uses a StoredDict - JsonDB inherits from BaseDB - FileStorage is only seen by JsonDB - calling JSonDB constructor creates the storage note: this commit temporarily breaks DB upgrades
this restores DB upgrades. (DB upgrades require not converting stored classes)
Add unit test of atomicity. Use a write batch for DB upgrades.
| return wrapper | ||
| def normalize_key(x: _FLEX_KEY) -> str: | ||
| if isinstance(x, int): | ||
| return int(x) |
There was a problem hiding this comment.
Is this intended to cast x as str instead? Otherwise the annotated return type is incorrect.
| return int(x) | |
| return str(x) |
| key = key_to_str(key) | ||
| return self._db.dict_contains(self.hint, self._path, key) | ||
|
|
||
| def keys(self) -> Iterable[str]: |
There was a problem hiding this comment.
The iterator returned by the keys()/values()/items() methods is exhausted after one iteration, unlike the dictionary view objects returned by a regular dict.
This seems like a footgun as it behaves unexpected in situations like this:
diff --git a/tests/test_stored_dict.py b/tests/test_stored_dict.py
index 26e96fc63..cf8f92701 100644
--- a/tests/test_stored_dict.py
+++ b/tests/test_stored_dict.py
@@ -98,3 +98,14 @@ class TestStorage(ElectrumTestCase):
storage = DictStorage(self.path)
self.assertEqual(storage.dump(), {'a':{}})
+ async def test_show_iterator_type(self):
+ storage = DictStorage(self.path)
+ storage['a'] = {'k1': 1, 'k2': 2, 'k3': 3}
+ a = storage.get('a')
+ # len(a.keys()) # TypeError: object of type 'generator' has no len()
+ keys = a.keys()
+ b = list(keys)
+ c = list(keys)
+ self.assertEqual(len(b), len(c))
+ storage['b'] = {}
+ self.assertFalse(storage.get('b').keys()) # iterator is always TrueIn fact, _convert_version_14() already surfaces this issue:
pubkeys = self.get('keystore').get('keypairs').keys()
assert len(addresses) == len(list(pubkeys)) # drains pubkeys
d = {}
for pubkey in pubkeys: # pubkeys is empty here
addr = bitcoin.pubkey_to_address('p2pkh', pubkey)A simple and nice fix seems to be making StoredDict inherit from collections.abc.MutableMapping:
See patch (click to expand)
diff --git a/electrum/stored_dict.py b/electrum/stored_dict.py
index 1917ca478..41df28a2f 100644
--- a/electrum/stored_dict.py
+++ b/electrum/stored_dict.py
@@ -27,7 +27,8 @@ import threading
import os
from enum import IntEnum
from collections import defaultdict
-from typing import Any, Optional, Tuple, Union, Iterator, Iterable, List, Sequence
+from collections.abc import MutableMapping
+from typing import Any, Optional, Union, Iterator, List, Sequence
from .logging import Logger
@@ -272,7 +273,7 @@ class StoredObject(BaseStoredObject):
return d
-class StoredDict(BaseStoredObject):
+class StoredDict(BaseStoredObject, MutableMapping):
"""
dict-like object that queries the DB
type conversions are performed here
@@ -327,8 +328,9 @@ class StoredDict(BaseStoredObject):
key = key_to_str(key)
self._db.remove(self.hint, self._path, key)
- def __iter__(self) -> Iterator[str]:
- return self._db.iter_keys(self.hint, self._path)
+ def __iter__(self) -> Iterator[_FLEX_KEY]:
+ for k in self._db.iter_keys(self.hint, self._path):
+ yield self._convert_key(k)
def __len__(self) -> int:
return self._db.dict_len(self.hint, self._path)
@@ -339,18 +341,6 @@ class StoredDict(BaseStoredObject):
key = key_to_str(key)
return self._db.dict_contains(self.hint, self._path, key)
- def keys(self) -> Iterable[str]:
- for k in self._db.iter_keys(self.hint, self._path):
- yield self._convert_key(k)
-
- def values(self) -> Iterator[Any]:
- for k in self._db.iter_keys(self.hint, self._path):
- yield self[k]
-
- def items(self) -> Iterator[Tuple[str, Any]]:
- for k in self._db.iter_keys(self.hint, self._path):
- yield (self._convert_key(k), self[k])
-
def get(self, key: _FLEX_KEY, default: Any = None, add_if_missing=False) -> Any:
# If add_if_missing is True, create DB entry if it does not exist.
# This will return StoredDict/StoredList if default is dict/list
@@ -378,15 +368,6 @@ class StoredDict(BaseStoredObject):
del self[key]
return v
- def update(self, other=(), /, **kwargs) -> None:
- if isinstance(other, dict):
- pairs = list(other.items())
- else:
- pairs = list(other)
- pairs.extend(kwargs.items())
- for k, v in pairs:
- self[k] = v
-
def as_dict(self) -> dict:
"""used by keystore"""
return self.dump()And StoredList could similarly inherit from collections.abc.MutableSequence.
| # write file in case there was a db upgrade | ||
| if self.storage and self.storage.file_exists(): | ||
| self.write_and_force_consolidation() | ||
| self.write_and_force_consolidation() |
There was a problem hiding this comment.
On master self.write_and_force_consolidation() was only called after the wallet upgrade, so if the upgrade failed the patches weren't applied. Now it is called before applying the upgrade.
This causes test_mainnet_testnet_mixup to apply the patches in /tests/test_storage_upgrade/client_4_5_2_9dk_with_ln on each test run which modifies the test wallet and shows up in the git diff.
Can the db write be delayed until the wallet upgrade succeeds?
| b2 = a.pop('b') | ||
| self.assertEqual(type(b2), dict) | ||
| # replace item. this must not been written to db | ||
| with self.assertRaises(KeyError): |
There was a problem hiding this comment.
This only raises if self.hint is not yet populated. When e.g. accessing (__getitem__) StoredDict first and the hint property sets self.hint, subsequent __setitem__ don't raise.
This fails e.g.:
diff --git a/tests/test_stored_dict.py b/tests/test_stored_dict.py
index 26e96fc63..0e25480ad 100644
--- a/tests/test_stored_dict.py
+++ b/tests/test_stored_dict.py
@@ -88,6 +88,7 @@ class TestStorage(ElectrumTestCase):
a = storage.get('a')
b = a['b']
self.assertEqual(type(b), StoredDict)
+ print(b['c']) # reads from b, populates cache
b2 = a.pop('b')
self.assertEqual(type(b2), dict)
# replace item. this must not been written to db| # as the htlcs won't get failed due to the new SETTLING state | ||
| # unless a forwarding error is set. | ||
| recv_mpp_status[0] = 4 # RecvMPPResolution.SETTLING | ||
| mpp_sets[payment_key] = recv_mpp_status |
There was a problem hiding this comment.
Should this be moved one intendation layer out?
Otherwise the new type mpp htlcs will only be saved if a forwarding_key is set.
| channels = self.get('channels', []) | ||
| for c in channels: | ||
| # convert revocation store to dict | ||
| r = c['revocation_store'] |
There was a problem hiding this comment.
This crashes in to_default() as r['buckets'] is a StoredList.
| r = c['revocation_store'].dump() |
Could to_default() handle StoredDict/StoredList by calling dump() on them so this would be prevented?
| @with_lock | ||
| def _init_maybe_active_htlc_ids(self): | ||
| # first idx is "side who offered htlc": | ||
| self._maybe_active_htlc_ids = {LOCAL: set(), REMOTE: set()} # type: Dict[HTLCOwner, Set[int]] |
There was a problem hiding this comment.
_maybe_active_htlc_ids contains Set[int] but StoredDict.__iter__() returns Iterator[str] because it doesn't call _convert_key() on the yielded keys.
So this might mixes up int and str htlc ids in the same set.
This is also fixed by the patch in comment #10693 (comment)
| def __init__(self, log: 'StoredDict', *, initiator=None, initial_feerate=None, lock=None): | ||
|
|
||
| if len(log) == 0: | ||
| # note: "htlc_id" keys in dict are str! but due to json_db magic they can *almost* be treated as int... |
There was a problem hiding this comment.
Is this comment still up-to-date, shouldn't they should be converted to int on exposure as the keys are registered in wallet_db.py?
| db.put('gap_limit_for_change', gap_limit_for_change) | ||
| wallet = wallet_factory(db, config=config) | ||
| if db.storage: | ||
| assert not db.storage.file_exists(), "file was created too soon! plaintext keys might have been written to disk" |
There was a problem hiding this comment.
The wallet file will already get created when instantiating db = WalletDB(storage) above due to the upgrader setting the initial seed version etc and write_batch() then writing the file.
If wallet recovery then fails later on here the user ends up with an unusable wallet file.
Would it be safer to just not create a new file/call self.write() in JsonDB.write_batch() if the path doesn't exist yet and rely on the explicit call to wallet.save_db() below to create the file?
|
|
||
| def dump(self) -> dict: | ||
| data = {} | ||
| for k, v in self.items(): |
There was a problem hiding this comment.
Should StoredDict|StoredList.dump() take self.lock to prevent self from changing during the iteration? Otherwise things like Abstract_Wallet.save_backup() might end up with a weird state if something from the asyncio thread modifies the dict/list while the GUI dumps it.
| for k, v in self.items(): | |
| with self.lock: | |
| for k, v in self.items(): |
| return default | ||
| if isinstance(v, (StoredList, StoredDict)): | ||
| v = v.dump() | ||
| del self[key] |
There was a problem hiding this comment.
As this is not done under a lock i guess it could raise even if a default is given if multiple threads access/modify the dict.
| invoice = self._invoices.get(key) | ||
| if not invoice: | ||
| continue | ||
| invoice._broadcasting_status = broadcasting_status |
There was a problem hiding this comment.
Now that objects are re-instantiated on every access this probably won't work anymore as ._broadcasting_status is not persisted and then lost on the next access?
I am marking this as ready because it is advanced enough that it could use some review.
My hope was to make the first commit pass all the tests, but that seems to be impossible. Commit 4 (types conversions) restores functionality. Note that commit 2 (perf optimization) and 3 (rename) are trivial, and they could potentially be reordered after commit 4.
There are 3 remaining design issues:
should
StoredDict.get() returndict/listinstead ofStoredList/StoredDict?get() would be consistent withpop()get() would be inconsistent with__getitem__, which must returnStoredList/StoredDictStoredDict/StoredList, we would moveget_dict,get_listfromwallet_dbtostored_dict. This would make the returned types more explicit.should we remove the
init_dbparameter in the constructor, and use an explicitDictStorage.open() call instead?close() methodopenwould take the password as parameter, and behave asdecrypt(). Howver, it would need to be called even for non-encrypted wallets.currently if an exception is raised during a batch write, memory and disk become inconsistent (see FIXME)
save_db, and always write to disk if we are not in a batch.EDIT: point 3 is now fixed in #10339