Skip to content

Commit ebe3b5e

Browse files
committed
Add on-disk topology cache (port of .NET TopologyLocalCache), enabled by default
Persist database and cluster topology to disk on each update and, on the first topology update, seed from disk when all initial urls are unreachable - for bootstrap/failover resilience, mirroring the .NET client (Node.js/JVM have no equivalent). Topology/ServerNode gained to_json/from_json for round-tripping. Enabled by default to match .NET (DocumentConventions.disable_topology_cache defaults False). The cache location defaults to a per-user, OS-appropriate directory (%LOCALAPPDATA%\ravendb\topology on Windows, ~/Library/Caches/ravendb /topology on macOS, $XDG_CACHE_HOME or ~/.cache/ravendb/topology elsewhere) rather than the working directory, so a dependent application never has topology files written into wherever it happens to run. The test harness disables the cache on the global test.manager store and on every per-test store, and delete_all_topology_files scrubs the default per-user dir as well as cwd, so the suite leaves no artifacts.
1 parent d8daa8e commit ebe3b5e

8 files changed

Lines changed: 223 additions & 26 deletions

File tree

ravendb/documents/conventions.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

33
import inspect
4+
import os
5+
import sys
46
import threading
57
from abc import abstractmethod, ABC
68
from datetime import timedelta, datetime
@@ -51,6 +53,10 @@ def __init__(self):
5153

5254
# Flags
5355
self.disable_topology_updates = False
56+
# On-disk topology cache (mirrors the .NET client): enabled by default. Topology is persisted to, and
57+
# - when the initial urls are unreachable on startup - seeded from topology_cache_location.
58+
self.disable_topology_cache = False
59+
self.topology_cache_location: Optional[str] = DocumentConventions._default_topology_cache_location()
5460
self._optimistic_concurrency_mode = None
5561
# Track which setter the user touched so we can reject mixing them.
5662
self._use_optimistic_concurrency_was_set = False
@@ -107,6 +113,18 @@ def freeze(self):
107113
def is_frozen(self):
108114
return self._frozen
109115

116+
@staticmethod
117+
def _default_topology_cache_location() -> str:
118+
# Per-user, OS-appropriate cache directory: stable across restarts and never clutters the directory
119+
# the application happens to run from (unlike cwd). Mirrors the intent of .NET's AppContext.BaseDirectory.
120+
if sys.platform == "win32":
121+
base = os.environ.get("LOCALAPPDATA") or os.path.join(os.path.expanduser("~"), "AppData", "Local")
122+
elif sys.platform == "darwin":
123+
base = os.path.join(os.path.expanduser("~"), "Library", "Caches")
124+
else:
125+
base = os.environ.get("XDG_CACHE_HOME") or os.path.join(os.path.expanduser("~"), ".cache")
126+
return os.path.join(base, "ravendb", "topology")
127+
110128
def get_python_class_name(self, entity_type: type):
111129
return self._find_python_class_name(entity_type)
112130

ravendb/http/request_executor.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from ravendb.http.raven_command import RavenCommand, RavenCommandResponseType
4040
from ravendb.http.server_node import ServerNode
4141
from ravendb.http.topology import Topology, NodeStatus, NodeSelector, CurrentIndexAndNode, UpdateTopologyParameters
42+
from ravendb.http import topology_local_cache
4243
from ravendb.serverwide.commands import GetDatabaseTopologyCommand, GetClusterTopologyCommand
4344

4445
from http import HTTPStatus
@@ -416,6 +417,14 @@ def __supply_async():
416417

417418
self._topology_etag = self._node_selector.topology.etag
418419

420+
if not self.conventions.disable_topology_cache and self.conventions.topology_cache_location:
421+
topology_local_cache.try_save(
422+
self.conventions.topology_cache_location,
423+
topology_local_cache.server_hash(parameters.node.url, self._database_name),
424+
self._node_selector.topology,
425+
topology_local_cache.DATABASE_TOPOLOGY_EXTENSION,
426+
)
427+
419428
self._on_topology_updated_invoke(topology)
420429
except Exception as e:
421430
if not self._disposed:
@@ -456,6 +465,16 @@ def __run(errors: list):
456465

457466
errors.append((url, e))
458467

468+
# all initial urls unreachable - fall back to the on-disk topology cache when enabled
469+
for url in initial_urls:
470+
cached_topology = self._try_load_topology_from_cache(url)
471+
if cached_topology is not None:
472+
self._node_selector = NodeSelector(cached_topology, self._thread_pool_executor)
473+
self._topology_etag = cached_topology.etag
474+
self.__initialize_update_topology_timer()
475+
self.__topology_taken_from_node = ServerNode(url, self._database_name)
476+
return
477+
459478
topology = Topology(
460479
self._topology_etag,
461480
(
@@ -476,6 +495,13 @@ def __run(errors: list):
476495

477496
return self._thread_pool_executor.submit(__run, errors)
478497

498+
def _try_load_topology_from_cache(self, url):
499+
return topology_local_cache.try_load(
500+
None if self.conventions.disable_topology_cache else self.conventions.topology_cache_location,
501+
topology_local_cache.server_hash(url, self._database_name),
502+
topology_local_cache.DATABASE_TOPOLOGY_EXTENSION,
503+
)
504+
479505
@staticmethod
480506
def validate_urls(initial_urls: List[str]) -> List[str]:
481507
# todo: implement validation
@@ -1445,6 +1471,14 @@ def __supply_async():
14451471
new_topology = Topology(results.etag, nodes)
14461472
self._topology_etag = results.etag
14471473

1474+
if not self.conventions.disable_topology_cache and self.conventions.topology_cache_location:
1475+
topology_local_cache.try_save(
1476+
self.conventions.topology_cache_location,
1477+
topology_local_cache.server_hash(parameters.node.url),
1478+
new_topology,
1479+
topology_local_cache.CLUSTER_TOPOLOGY_EXTENSION,
1480+
)
1481+
14481482
if self._node_selector is None:
14491483
self._node_selector = NodeSelector(new_topology, self._thread_pool_executor)
14501484

@@ -1469,5 +1503,12 @@ def __supply_async():
14691503

14701504
return self._thread_pool_executor.submit(__supply_async)
14711505

1506+
def _try_load_topology_from_cache(self, url):
1507+
return topology_local_cache.try_load(
1508+
None if self.conventions.disable_topology_cache else self.conventions.topology_cache_location,
1509+
topology_local_cache.server_hash(url),
1510+
topology_local_cache.CLUSTER_TOPOLOGY_EXTENSION,
1511+
)
1512+
14721513
def _throw_exceptions(self, details: str):
14731514
raise RuntimeError(f"Failed to retrieve cluster topology from all known nodes {os.linesep}{details}")

ravendb/http/server_node.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,24 @@ def __hash__(self) -> int:
4747
def last_server_version(self) -> str:
4848
return self.__last_server_version
4949

50+
def to_json(self) -> dict:
51+
return {
52+
"Url": self.url,
53+
"Database": self.database,
54+
"ClusterTag": self.cluster_tag,
55+
"ServerRole": self.server_role.value if self.server_role is not None else None,
56+
}
57+
58+
@classmethod
59+
def from_json(cls, json_dict: dict) -> "ServerNode":
60+
role = json_dict.get("ServerRole")
61+
return cls(
62+
json_dict.get("Url"),
63+
json_dict.get("Database"),
64+
json_dict.get("ClusterTag"),
65+
cls.Role(role) if role else None,
66+
)
67+
5068
@classmethod
5169
def create_from(cls, topology: "ClusterTopology"):
5270
nodes = []

ravendb/http/topology.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ def __init__(self, etag: int, nodes: List[ServerNode]):
2525
self.etag = etag
2626
self.nodes = nodes
2727

28+
def to_json(self) -> Dict:
29+
return {"Etag": self.etag, "Nodes": [node.to_json() for node in (self.nodes or [])]}
30+
31+
@classmethod
32+
def from_json(cls, json_dict: Dict) -> "Topology":
33+
return cls(
34+
json_dict.get("Etag"),
35+
[ServerNode.from_json(node_json) for node_json in (json_dict.get("Nodes") or [])],
36+
)
37+
2838

2939
class ClusterTopology:
3040
def __init__(self):
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from __future__ import annotations
2+
3+
import hashlib
4+
import json
5+
import os
6+
from typing import Optional
7+
8+
from ravendb.http.topology import Topology
9+
10+
# On-disk topology cache (mirrors the .NET client's TopologyLocalCache). The feature is
11+
# opt-in: it is active only when DocumentConventions.topology_cache_location is set.
12+
13+
DATABASE_TOPOLOGY_EXTENSION = ".raven-topology"
14+
CLUSTER_TOPOLOGY_EXTENSION = ".raven-cluster-topology"
15+
16+
17+
def server_hash(url: str, database: Optional[str] = None) -> str:
18+
key = "{0}{1}".format(url, database if database else "")
19+
return hashlib.md5(key.encode("utf-8")).hexdigest()
20+
21+
22+
def _path(location: str, topology_hash: str, extension: str) -> str:
23+
return os.path.join(location, topology_hash + extension)
24+
25+
26+
def try_save(location: Optional[str], topology_hash: str, topology: Topology, extension: str) -> None:
27+
# Best-effort: caching failures must never break a topology update.
28+
if not location or topology is None:
29+
return
30+
try:
31+
os.makedirs(location, exist_ok=True)
32+
with open(_path(location, topology_hash, extension), "w", encoding="utf-8") as stream:
33+
json.dump(topology.to_json(), stream)
34+
except Exception:
35+
pass
36+
37+
38+
def try_load(location: Optional[str], topology_hash: str, extension: str) -> Optional[Topology]:
39+
if not location:
40+
return None
41+
try:
42+
path = _path(location, topology_hash, extension)
43+
if not os.path.isfile(path):
44+
return None
45+
with open(path, "r", encoding="utf-8") as stream:
46+
return Topology.from_json(json.load(stream))
47+
except Exception:
48+
return None

ravendb/tests/driver/raven_test_driver.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ def _run_embedded_server_internal(locator: "RavenServerLocator") -> Tuple[Docume
2424
embedded_server = RavenServerRunner.get_embedded_server(locator)
2525
store = embedded_server.get_document_store("test.manager")
2626
store.conventions.disable_topology_updates = True
27+
store.conventions.disable_topology_cache = True
2728

2829
return store, embedded_server
2930

ravendb/tests/system_tests/test_system_create_topology_files.py

Lines changed: 72 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1+
import hashlib
2+
import os
3+
import unittest
4+
from shutil import rmtree
5+
16
from ravendb.documents.store.definition import DocumentStore
7+
from ravendb.http import topology_local_cache
8+
from ravendb.http.server_node import ServerNode
9+
from ravendb.http.topology import Topology
210
from ravendb.serverwide.database_record import DatabaseRecord
311
from ravendb.serverwide.operations.common import DeleteDatabaseOperation, CreateDatabaseOperation
412
from ravendb.tests.test_base import TestBase
5-
import hashlib
6-
from shutil import rmtree
7-
import os
8-
import unittest
913

1014
TOPOLOGY_FILES_DIR = os.path.join(os.getcwd(), "topology_files")
1115
DATABASE = "SystemTest"
@@ -17,17 +21,20 @@ def __init__(self, name):
1721

1822

1923
class TestSystemTopologyCreation(TestBase):
24+
def _customize_store(self, store: DocumentStore) -> None:
25+
# opt in to the on-disk topology cache for this test's stores
26+
store.conventions.topology_cache_location = TOPOLOGY_FILES_DIR
27+
2028
def tearDown(self):
21-
self.store.maintenance.server.send(DeleteDatabaseOperation(database_name=DATABASE, hard_delete=True))
29+
try:
30+
self.store.maintenance.server.send(DeleteDatabaseOperation(database_name=DATABASE, hard_delete=True))
31+
except Exception:
32+
pass
2233
super(TestSystemTopologyCreation, self).tearDown()
2334
TestBase.delete_all_topology_files()
2435
if os.path.exists(TOPOLOGY_FILES_DIR):
25-
try:
26-
rmtree(TOPOLOGY_FILES_DIR, ignore_errors=True)
27-
except OSError as ex:
28-
pass
36+
rmtree(TOPOLOGY_FILES_DIR, ignore_errors=True)
2937

30-
@unittest.skip("Topology creation")
3138
def test_topology_creation(self):
3239
created = False
3340
while not created:
@@ -38,26 +45,69 @@ def test_topology_creation(self):
3845
created = True
3946
TestBase.wait_for_database_topology(self.store, DATABASE)
4047

41-
with DocumentStore(urls=self.default_urls, database=DATABASE) as store:
48+
# the embedded server uses a random port, so hash the real server url (not the placeholder default_urls)
49+
base_url = self.store.urls[0]
50+
51+
with DocumentStore(urls=self.store.urls, database=DATABASE) as store:
52+
store.conventions.topology_cache_location = TOPOLOGY_FILES_DIR
4253
store.initialize()
4354
with store.open_session() as session:
4455
session.store(Author("Idan"))
4556
session.save_changes()
46-
topology_hash = hashlib.md5("{0}{1}".format(self.default_urls[0], DATABASE).encode("utf-8")).hexdigest()
4757

48-
cluster_topology_hash = hashlib.md5("{0}".format(self.default_urls[0]).encode("utf-8")).hexdigest()
58+
topology_hash = hashlib.md5("{0}{1}".format(base_url, DATABASE).encode("utf-8")).hexdigest()
59+
cluster_topology_hash = hashlib.md5("{0}".format(base_url).encode("utf-8")).hexdigest()
4960

61+
self.assertTrue(os.path.exists(os.path.join(TOPOLOGY_FILES_DIR, topology_hash + ".raven-topology")))
5062
self.assertTrue(
51-
os.path.exists(os.path.join(TOPOLOGY_FILES_DIR, topology_hash + ".raven-topology"))
52-
) # todo: fix from here - make sure the right folder and files appear
53-
self.assertTrue(
54-
os.path.exists(
55-
os.path.join(
56-
TOPOLOGY_FILES_DIR,
57-
cluster_topology_hash + ".raven-cluster-topology",
58-
)
59-
)
63+
os.path.exists(os.path.join(TOPOLOGY_FILES_DIR, cluster_topology_hash + ".raven-cluster-topology"))
64+
)
65+
66+
def test_topology_cache_round_trip(self):
67+
os.makedirs(TOPOLOGY_FILES_DIR, exist_ok=True)
68+
topology = Topology(3, [ServerNode("http://localhost:9999", "db1", "B", ServerNode.Role.MEMBER)])
69+
topology_hash = topology_local_cache.server_hash("http://localhost:9999", "db1")
70+
topology_local_cache.try_save(
71+
TOPOLOGY_FILES_DIR, topology_hash, topology, topology_local_cache.DATABASE_TOPOLOGY_EXTENSION
72+
)
73+
74+
loaded = topology_local_cache.try_load(
75+
TOPOLOGY_FILES_DIR, topology_hash, topology_local_cache.DATABASE_TOPOLOGY_EXTENSION
6076
)
77+
self.assertIsNotNone(loaded)
78+
self.assertEqual(3, loaded.etag)
79+
self.assertEqual(1, len(loaded.nodes))
80+
node = loaded.nodes[0]
81+
self.assertEqual("http://localhost:9999", node.url)
82+
self.assertEqual("db1", node.database)
83+
self.assertEqual("B", node.cluster_tag)
84+
self.assertEqual(ServerNode.Role.MEMBER, node.server_role)
85+
86+
def test_topology_is_loaded_from_cache_when_urls_unreachable(self):
87+
bad_url = "http://127.0.0.1:1"
88+
database = "CacheSeedTest"
89+
os.makedirs(TOPOLOGY_FILES_DIR, exist_ok=True)
90+
cached = Topology(9, [ServerNode(bad_url, database, "A", ServerNode.Role.MEMBER)])
91+
topology_local_cache.try_save(
92+
TOPOLOGY_FILES_DIR,
93+
topology_local_cache.server_hash(bad_url, database),
94+
cached,
95+
topology_local_cache.DATABASE_TOPOLOGY_EXTENSION,
96+
)
97+
98+
# the server is unreachable, so the first topology update must fall back to the on-disk cache
99+
with DocumentStore(urls=[bad_url], database=database) as store:
100+
store.conventions.topology_cache_location = TOPOLOGY_FILES_DIR
101+
store.initialize()
102+
request_executor = store.get_request_executor()
103+
request_executor._first_topology_update_task.result(30)
104+
105+
nodes = request_executor.topology_nodes
106+
self.assertEqual(1, len(nodes))
107+
self.assertEqual("A", nodes[0].cluster_tag)
108+
self.assertEqual(bad_url, nodes[0].url)
109+
self.assertEqual(ServerNode.Role.MEMBER, nodes[0].server_role)
110+
self.assertEqual(9, request_executor.topology_etag)
61111

62112

63113
if __name__ == "__main__":

ravendb/tests/test_base.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,9 @@ def _customize_db_record(self, db_record: DatabaseRecord) -> None:
253253
pass
254254

255255
def _customize_store(self, store: DocumentStore) -> None:
256-
pass
256+
# Tests don't exercise the on-disk topology cache by default (the dedicated topology test opts in),
257+
# so keep the many per-test stores from writing files into the user's cache directory.
258+
store.conventions.disable_topology_cache = True
257259

258260
@property
259261
def secured_document_store(self) -> DocumentStore:
@@ -336,10 +338,19 @@ def _discard_embedded_server(cls, secured: bool) -> None:
336338
@staticmethod
337339
def delete_all_topology_files():
338340
import os
341+
from ravendb.documents.conventions import DocumentConventions
339342

340-
file_list = [f for f in os.listdir(".") if f.endswith("topology")]
341-
for f in file_list:
342-
os.remove(f)
343+
# clean both the working dir and the default per-user topology-cache dir so tests leave no artifacts
344+
for directory in {".", DocumentConventions._default_topology_cache_location()}:
345+
try:
346+
for f in os.listdir(directory):
347+
if f.endswith("topology"):
348+
try:
349+
os.remove(os.path.join(directory, f))
350+
except OSError:
351+
pass
352+
except OSError:
353+
pass
343354

344355
@staticmethod
345356
def wait_for_database_topology(store, database_name, replication_factor=1):

0 commit comments

Comments
 (0)