Skip to content

New storage api#10693

Open
ecdsa wants to merge 5 commits into
masterfrom
new_storage_api
Open

New storage api#10693
ecdsa wants to merge 5 commits into
masterfrom
new_storage_api

Conversation

@ecdsa

@ecdsa ecdsa commented Jun 7, 2026

Copy link
Copy Markdown
Member

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:

  1. should StoredDict.get() return dict/list instead of StoredList/StoredDict ?

    • pro: get() would be consistent with pop()
    • con: get() would be inconsistent with __getitem__ , which must return StoredList/StoredDict
    • pro: in order to access/create StoredDict/StoredList, we would move get_dict, get_list from wallet_db to stored_dict. This would make the returned types more explicit.
    • pro: this would simplify DB upgrades, and code in general
  2. should we remove the init_db parameter in the constructor, and use an explicit DictStorage.open() call instead?

    • probably cleaner, there is already a close() method
    • open would take the password as parameter, and behave as decrypt(). Howver, it would need to be called even for non-encrypted wallets.
  3. currently if an exception is raised during a batch write, memory and disk become inconsistent (see FIXME)

    • ideally, we should get rid of save_db, and always write to disk if we are not in a batch.
    • this would be easy if we had partial writes always enabled (not implemented yet with encrypted wallets)

EDIT: point 3 is now fixed in #10339

@ecdsa
ecdsa force-pushed the new_storage_api branch 9 times, most recently from ea2886b to 2f25568 Compare June 15, 2026 09:17
@ecdsa
ecdsa force-pushed the new_storage_api branch 9 times, most recently from c3f6c61 to 44b951d Compare June 22, 2026 15:36
@ecdsa
ecdsa force-pushed the new_storage_api branch 12 times, most recently from 106685f to 9938ba5 Compare June 24, 2026 09:26
@ecdsa
ecdsa force-pushed the new_storage_api branch 3 times, most recently from 88e66c9 to ff565a0 Compare June 24, 2026 17:07
@ecdsa
ecdsa marked this pull request as ready for review June 24, 2026 17:47
@ecdsa
ecdsa force-pushed the new_storage_api branch 3 times, most recently from 452ccba to c40ed7c Compare July 3, 2026 15:17
ecdsa added 5 commits July 14, 2026 12:42
  - 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.
@ecdsa
ecdsa force-pushed the new_storage_api branch from c40ed7c to 93d18a1 Compare July 14, 2026 10:42
Comment thread electrum/stored_dict.py
return wrapper
def normalize_key(x: _FLEX_KEY) -> str:
if isinstance(x, int):
return int(x)

@f321x f321x Jul 20, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this intended to cast x as str instead? Otherwise the annotated return type is incorrect.

Suggested change
return int(x)
return str(x)

Comment thread electrum/stored_dict.py
key = key_to_str(key)
return self._db.dict_contains(self.hint, self._path, key)

def keys(self) -> Iterable[str]:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 True

In 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.

Comment thread electrum/json_db.py
# 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()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread tests/test_stored_dict.py
b2 = a.pop('b')
self.assertEqual(type(b2), dict)
# replace item. this must not been written to db
with self.assertRaises(KeyError):

@f321x f321x Jul 20, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread electrum/wallet_db.py
# 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be moved one intendation layer out?
Otherwise the new type mpp htlcs will only be saved if a forwarding_key is set.

Comment thread electrum/wallet_db.py
channels = self.get('channels', [])
for c in channels:
# convert revocation store to dict
r = c['revocation_store']

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This crashes in to_default() as r['buckets'] is a StoredList.

Suggested change
r = c['revocation_store'].dump()

Could to_default() handle StoredDict/StoredList by calling dump() on them so this would be prevented?

Comment thread electrum/lnhtlc.py
@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]]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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)

Comment thread electrum/lnhtlc.py
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...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread electrum/wallet.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"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread electrum/stored_dict.py

def dump(self) -> dict:
data = {}
for k, v in self.items():

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
for k, v in self.items():
with self.lock:
for k, v in self.items():

Comment thread electrum/stored_dict.py
return default
if isinstance(v, (StoredList, StoredDict)):
v = v.dump()
del self[key]

@f321x f321x Jul 21, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 simultaneously.

Comment thread electrum/wallet.py
Comment on lines 3037 to 3040
invoice = self._invoices.get(key)
if not invoice:
continue
invoice._broadcasting_status = broadcasting_status

@f321x f321x Jul 21, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 db access when a fresh Invoice is instantiated?

Comment thread tests/test_jsondb.py

from . import ElectrumTestCase

from electrum.json_db import JsonDB

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing the json_db import prevents the monkeypatch from redacting the secrets, causing the test to fail if test_jsondb.py is tested on its own (otherwise jsondb gets imported in test_commands.py and the monkeypatch applies).

====================================================================== short test summary info ======================================================================
SUBFAILED[replace_dict_inner_key_missing] tests/test_jsondb.py::TestJsonpatch::test_patch_does_not_leak_privatekeys - AssertionError: 'dictlevel' unexpectedly found in "can't replace a non-existent object 'dictlevelX2'"
...

Comment thread electrum/stored_dict.py



class BaseDB(Logger):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could BaseDB be made a proper abstract base class defining all the methods that it is supposed to implement (get/put/replace/remove/clear/iter_keys/dict_len/dict_contains/get_hint/list_append/etc...)?

This would add some boilerplate code but would also be nice to understand what BaseDB is actually supposed to support when implementing support for a new DB and would look better with a type checking IDE.

Comment thread electrum/json_db.py
@modifier
def put(self, d, path, key, value):
is_new = key not in d
if not is_new and d[key] == value:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When storing a tuple in a StoredDict the to_default() call doesn't convert it to a json array but keeps it as tuple.
When the value is still a list after loading the data from file this comparison
will be False as [1, 2] != (1, 2) and a useless replace patch is appended.
Could to_default() simply return a list for passed tuples?

@f321x

f321x commented Jul 22, 2026

Copy link
Copy Markdown
Member

should StoredDict.get() return dict/list instead of StoredList/StoredDict ?

I think this sounds good as StoredDict.get() already is a bit ambiguous with the add_if_missing parameter. However it might be worth to change the name to something else, e.g. get_copy(), to prevent confusion and make it explicit that the returned object is not persisted.

should we remove the init_db parameter in the constructor, and use an explicit DictStorage.open() call instead?

It seems cleaner, I think it would make it easier to reason about what happens when and might allow for better error handling. E.g. when reading the file fails, the caller could still check db.file_exists() as there is a instantiated JsonDB object, if the constructor raises there is no object at all. Might also be better for testability.
Also right now there are 3 places where _is_closed gets set False, with an open() method that could be consolidated in a single place and the API would be simpler as open()/decrypt() could be a single thing.

Comment thread electrum/stored_dict.py
Comment on lines +421 to +432
def __getitem__(self, s: slice) -> Any:
n = self._db.list_len(self.hint, self._path)
if type(s) is int:
s = n + s if s < 0 else s
return self._get_list_item(s)
elif type(s) is slice:
start = 0 if s.start is None else s.start if s.start >= 0 else n + s.start
stop = n if s.stop is None else s.stop if s.stop >= 0 else n + s.stop
step = 1 if s.step is None else s.step
return [self._get_list_item(i) for i in range(start, stop, step)]
else:
raise Exception()

@f321x f321x Jul 22, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be simplified by using the builtin .indices() method of the slices type instead of manually calculating start, stop and step. Also the builtin list __getitem__ raises IndexError for out of bound access.

Suggested change
def __getitem__(self, s: slice) -> Any:
n = self._db.list_len(self.hint, self._path)
if type(s) is int:
s = n + s if s < 0 else s
return self._get_list_item(s)
elif type(s) is slice:
start = 0 if s.start is None else s.start if s.start >= 0 else n + s.start
stop = n if s.stop is None else s.stop if s.stop >= 0 else n + s.stop
step = 1 if s.step is None else s.step
return [self._get_list_item(i) for i in range(start, stop, step)]
else:
raise Exception()
def __getitem__(self, s: int | slice) -> Any:
n = self._db.list_len(self.hint, self._path)
if isinstance(s, int):
if s < 0:
s += n
if not 0 <= s < n:
raise IndexError(f'list index out of range: {s}')
return self._get_list_item(s)
elif isinstance(s, slice):
return [self._get_list_item(i) for i in range(*s.indices(n))]
raise TypeError(f'indices must be integers or slices, not {type(s).__name__}')

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants