Skip to content

Commit f673dfc

Browse files
committed
Test tiered storage through the Python bindings
Rust integration tests do not prove that a store implemented in a foreign language can serve as TierStore's primary store. In particular, backup synchronization depends on asynchronous callbacks for exhaustive key enumeration, while normal node operation also depends on reads, writes, removals, and namespace listings crossing the FFI boundary correctly. Here, we: - Add a thread-safe in-memory Python implementation of the complete DynStoreTrait callback interface. Seed it before node construction so building the node must enumerate the Python store and backfill an empty SQLite backup. - Run a channel and payment lifecycle with the Python store as primary and separate SQLite backup and ephemeral stores. Inspect all three stores afterward to verify that channel manager, wallet, and payment data is mirrored only across the durable tiers, while the network graph is routed only to ephemeral storage. - Enable storage-tier when generating bindings in Python CI so this coverage runs against the exported tier-store builder API. Assisted-by: Amp (AI coding agent)
1 parent 07f5134 commit f673dfc

2 files changed

Lines changed: 280 additions & 1 deletion

File tree

.github/workflows/python.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313

1414
env:
1515
CARGO_TARGET_DIR: /tmp/cargo-target-ldk-node-python-ci
16-
LDK_NODE_EXTRA_FEATURES: chain-bitcoind
16+
LDK_NODE_EXTRA_FEATURES: chain-bitcoind,storage-tier
1717
LDK_NODE_PYTHON_DIR: bindings/python
1818

1919
steps:

bindings/python/src/ldk_node/test_ldk_node.py

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import asyncio
2+
import sqlite3
3+
import threading
14
import unittest
25
import tempfile
36
import time
@@ -8,11 +11,79 @@
811
import socket
912

1013
from ldk_node import *
14+
from ldk_node.ldk_node import uniffi_set_event_loop
1115

1216
DEFAULT_ESPLORA_SERVER_URL = "http://127.0.0.1:3002"
1317
DEFAULT_TEST_NETWORK = Network.REGTEST
1418
DEFAULT_BITCOIN_CLI_BIN = "bitcoin-cli"
1519

20+
class TestKvStore:
21+
"""Thread-safe in-memory store implementing the foreign DynStoreTrait."""
22+
23+
def __init__(self):
24+
self._storage = {}
25+
self._lock = threading.Lock()
26+
27+
def put(self, primary_namespace, secondary_namespace, key, value):
28+
with self._lock:
29+
namespace = self._storage.setdefault(
30+
(primary_namespace, secondary_namespace), {}
31+
)
32+
namespace[key] = bytes(value)
33+
34+
def get(self, primary_namespace, secondary_namespace, key):
35+
with self._lock:
36+
namespace = self._storage.get((primary_namespace, secondary_namespace), {})
37+
return namespace.get(key)
38+
39+
def keys(self, primary_namespace, secondary_namespace):
40+
with self._lock:
41+
namespace = self._storage.get((primary_namespace, secondary_namespace), {})
42+
return list(namespace)
43+
44+
async def read(self, primary_namespace, secondary_namespace, key):
45+
value = self.get(primary_namespace, secondary_namespace, key)
46+
if value is None:
47+
raise IoError.NotFound()
48+
return value
49+
50+
async def write(self, primary_namespace, secondary_namespace, key, buf):
51+
self.put(primary_namespace, secondary_namespace, key, buf)
52+
53+
async def remove(self, primary_namespace, secondary_namespace, key, lazy):
54+
with self._lock:
55+
namespace_key = (primary_namespace, secondary_namespace)
56+
namespace = self._storage.get(namespace_key)
57+
if namespace is None:
58+
return
59+
namespace.pop(key, None)
60+
if not namespace:
61+
del self._storage[namespace_key]
62+
63+
async def list(self, primary_namespace, secondary_namespace):
64+
return self.keys(primary_namespace, secondary_namespace)
65+
66+
async def list_paginated(
67+
self, primary_namespace, secondary_namespace, page_token
68+
):
69+
if page_token is not None:
70+
return PaginatedListResponse(keys=[], next_page_token=None)
71+
72+
keys = list(reversed(self.keys(primary_namespace, secondary_namespace)))
73+
return PaginatedListResponse(keys=keys, next_page_token=None)
74+
75+
async def list_all_keys(self):
76+
with self._lock:
77+
return [
78+
KvStoreKey(
79+
primary_namespace=primary_namespace,
80+
secondary_namespace=secondary_namespace,
81+
key=key,
82+
)
83+
for (primary_namespace, secondary_namespace), namespace in self._storage.items()
84+
for key in namespace
85+
]
86+
1687
def bitcoin_cli(cmd):
1788
args = []
1889

@@ -108,6 +179,46 @@ def setup_node(tmp_dir, esplora_endpoint, listening_addresses):
108179
builder.set_listening_addresses(listening_addresses)
109180
return builder.build(node_entropy)
110181

182+
def setup_tiered_node(
183+
tmp_dir,
184+
backup_dir,
185+
ephemeral_dir,
186+
esplora_endpoint,
187+
listening_addresses,
188+
primary_store,
189+
):
190+
mnemonic = Mnemonic.generate(24)
191+
node_entropy = NodeEntropy.from_bip39_mnemonic(mnemonic, None)
192+
builder = Builder.from_config(default_config())
193+
builder.set_storage_dir_path(tmp_dir)
194+
builder.set_chain_source_esplora(esplora_endpoint, None)
195+
builder.set_network(DEFAULT_TEST_NETWORK)
196+
builder.set_listening_addresses(listening_addresses)
197+
builder.set_backup_storage_dir_path(backup_dir)
198+
builder.set_ephemeral_storage_dir_path(ephemeral_dir)
199+
return builder.build_with_store(node_entropy, primary_store)
200+
201+
def read_sqlite_value(store_dir, database_name, primary_namespace, secondary_namespace, key):
202+
database_path = os.path.join(store_dir, database_name)
203+
with sqlite3.connect(database_path) as connection:
204+
row = connection.execute(
205+
"""SELECT value FROM ldk_node_data
206+
WHERE primary_namespace = ? AND secondary_namespace = ? AND key = ?""",
207+
(primary_namespace, secondary_namespace, key),
208+
).fetchone()
209+
return None if row is None else row[0]
210+
211+
def list_sqlite_keys(store_dir, database_name, primary_namespace, secondary_namespace):
212+
database_path = os.path.join(store_dir, database_name)
213+
with sqlite3.connect(database_path) as connection:
214+
rows = connection.execute(
215+
"""SELECT key FROM ldk_node_data
216+
WHERE primary_namespace = ? AND secondary_namespace = ?
217+
ORDER BY key""",
218+
(primary_namespace, secondary_namespace),
219+
).fetchall()
220+
return [row[0] for row in rows]
221+
111222
def get_esplora_endpoint():
112223
if os.environ.get('ESPLORA_ENDPOINT'):
113224
return str(os.environ['ESPLORA_ENDPOINT'])
@@ -365,5 +476,173 @@ def test_channel_full_cycle(self):
365476
# Stop nodes
366477
stop_and_cleanup(node_1, node_2, tmp_dir_1, tmp_dir_2)
367478

479+
def test_tier_store_with_python_primary(self):
480+
loop = asyncio.new_event_loop()
481+
482+
def run_loop():
483+
asyncio.set_event_loop(loop)
484+
loop.run_forever()
485+
486+
loop_thread = threading.Thread(target=run_loop, daemon=True)
487+
loop_thread.start()
488+
uniffi_set_event_loop(loop)
489+
490+
node_1 = None
491+
node_2 = None
492+
tmp_dir_1 = tempfile.TemporaryDirectory("_ldk_node_1")
493+
tmp_dir_2 = tempfile.TemporaryDirectory("_ldk_node_2")
494+
backup_dir = tempfile.TemporaryDirectory("_ldk_node_backup")
495+
ephemeral_dir = tempfile.TemporaryDirectory("_ldk_node_ephemeral")
496+
497+
try:
498+
primary_store = TestKvStore()
499+
preexisting_value = b"preexisting durable value"
500+
primary_store.put("test", "", "preexisting", preexisting_value)
501+
502+
port_1, port_2 = find_two_free_ports()
503+
listening_addresses_1 = [f"127.0.0.1:{port_1}"]
504+
listening_addresses_2 = [f"127.0.0.1:{port_2}"]
505+
esplora_endpoint = get_esplora_endpoint()
506+
507+
node_1 = setup_tiered_node(
508+
tmp_dir_1.name,
509+
backup_dir.name,
510+
ephemeral_dir.name,
511+
esplora_endpoint,
512+
listening_addresses_1,
513+
primary_store,
514+
)
515+
node_2 = setup_node(tmp_dir_2.name, esplora_endpoint, listening_addresses_2)
516+
node_1.start()
517+
node_2.start()
518+
519+
fund_nodes(node_1, node_2, esplora_endpoint)
520+
_, channel_ready_event_2, _ = open_channel_and_wait_ready(
521+
node_1,
522+
node_2,
523+
node_2.node_id(),
524+
listening_addresses_2[0],
525+
esplora_endpoint,
526+
)
527+
528+
invoice = node_2.bolt11_payment().receive(
529+
2_500_000, Bolt11InvoiceDescription.DIRECT("tiered storage"), 9217
530+
)
531+
node_1.bolt11_payment().send(invoice, None)
532+
expect_event(node_1, Event.PAYMENT_SUCCESSFUL)
533+
expect_event(node_2, Event.PAYMENT_RECEIVED)
534+
535+
node_2.close_channel(channel_ready_event_2.user_channel_id, node_1.node_id())
536+
expect_event(node_1, Event.CHANNEL_CLOSED)
537+
expect_event(node_2, Event.CHANNEL_CLOSED)
538+
539+
node_1.stop()
540+
node_1 = None
541+
node_2.stop()
542+
node_2 = None
543+
544+
backup_database = "ldk_node_data_backup.sqlite"
545+
ephemeral_database = "ldk_node_data_ephemeral.sqlite"
546+
547+
self.assertEqual(
548+
read_sqlite_value(
549+
backup_dir.name,
550+
backup_database,
551+
"test",
552+
"",
553+
"preexisting",
554+
),
555+
preexisting_value,
556+
)
557+
558+
channel_manager = primary_store.get("", "", "manager")
559+
self.assertIsNotNone(channel_manager)
560+
self.assertEqual(
561+
read_sqlite_value(
562+
backup_dir.name,
563+
backup_database,
564+
"",
565+
"",
566+
"manager",
567+
),
568+
channel_manager,
569+
)
570+
self.assertIsNone(
571+
read_sqlite_value(
572+
ephemeral_dir.name,
573+
ephemeral_database,
574+
"",
575+
"",
576+
"manager",
577+
)
578+
)
579+
580+
wallet_descriptor = primary_store.get("bdk_wallet", "", "descriptor")
581+
self.assertIsNotNone(wallet_descriptor)
582+
self.assertEqual(
583+
read_sqlite_value(
584+
backup_dir.name,
585+
backup_database,
586+
"bdk_wallet",
587+
"",
588+
"descriptor",
589+
),
590+
wallet_descriptor,
591+
)
592+
self.assertIsNone(
593+
read_sqlite_value(
594+
ephemeral_dir.name,
595+
ephemeral_database,
596+
"bdk_wallet",
597+
"",
598+
"descriptor",
599+
)
600+
)
601+
602+
primary_payments = sorted(primary_store.keys("payments", ""))
603+
self.assertGreater(len(primary_payments), 0)
604+
self.assertEqual(
605+
list_sqlite_keys(backup_dir.name, backup_database, "payments", ""),
606+
primary_payments,
607+
)
608+
self.assertEqual(
609+
list_sqlite_keys(ephemeral_dir.name, ephemeral_database, "payments", ""),
610+
[],
611+
)
612+
613+
self.assertIsNone(primary_store.get("", "", "network_graph"))
614+
self.assertIsNone(
615+
read_sqlite_value(
616+
backup_dir.name,
617+
backup_database,
618+
"",
619+
"",
620+
"network_graph",
621+
)
622+
)
623+
self.assertIsNotNone(
624+
read_sqlite_value(
625+
ephemeral_dir.name,
626+
ephemeral_database,
627+
"",
628+
"",
629+
"network_graph",
630+
)
631+
)
632+
finally:
633+
for node in (node_1, node_2):
634+
if node is not None:
635+
try:
636+
node.stop()
637+
except NodeError:
638+
pass
639+
tmp_dir_1.cleanup()
640+
tmp_dir_2.cleanup()
641+
backup_dir.cleanup()
642+
ephemeral_dir.cleanup()
643+
loop.call_soon_threadsafe(loop.stop)
644+
loop_thread.join(timeout=5)
645+
loop.close()
646+
368647
if __name__ == '__main__':
369648
unittest.main()

0 commit comments

Comments
 (0)