|
| 1 | +import asyncio |
| 2 | +import sqlite3 |
| 3 | +import threading |
1 | 4 | import unittest |
2 | 5 | import tempfile |
3 | 6 | import time |
|
8 | 11 | import socket |
9 | 12 |
|
10 | 13 | from ldk_node import * |
| 14 | +from ldk_node.ldk_node import uniffi_set_event_loop |
11 | 15 |
|
12 | 16 | DEFAULT_ESPLORA_SERVER_URL = "http://127.0.0.1:3002" |
13 | 17 | DEFAULT_TEST_NETWORK = Network.REGTEST |
14 | 18 | DEFAULT_BITCOIN_CLI_BIN = "bitcoin-cli" |
15 | 19 |
|
| 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 | + |
16 | 87 | def bitcoin_cli(cmd): |
17 | 88 | args = [] |
18 | 89 |
|
@@ -108,6 +179,46 @@ def setup_node(tmp_dir, esplora_endpoint, listening_addresses): |
108 | 179 | builder.set_listening_addresses(listening_addresses) |
109 | 180 | return builder.build(node_entropy) |
110 | 181 |
|
| 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 | + |
111 | 222 | def get_esplora_endpoint(): |
112 | 223 | if os.environ.get('ESPLORA_ENDPOINT'): |
113 | 224 | return str(os.environ['ESPLORA_ENDPOINT']) |
@@ -365,5 +476,173 @@ def test_channel_full_cycle(self): |
365 | 476 | # Stop nodes |
366 | 477 | stop_and_cleanup(node_1, node_2, tmp_dir_1, tmp_dir_2) |
367 | 478 |
|
| 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 | + |
368 | 647 | if __name__ == '__main__': |
369 | 648 | unittest.main() |
0 commit comments