Skip to content

Commit b80b7cf

Browse files
committed
Add opt-in on-disk topology cache (port of .NET TopologyLocalCache)
The topology-file cache is absent from the nodejs/jvm clients; ported from the .NET client's TopologyLocalCache design. Opt-in via the new DocumentConventions.topology_cache_location (default None = disabled), so existing behavior is unchanged for all other stores/tests. - New ravendb/http/topology_local_cache.py: server_hash + try_save/try_load for DB (.raven-topology) and cluster (.raven-cluster-topology) topology JSON. - RequestExecutor: persist topology after each update (DB + cluster) and seed from cache before the "!" placeholder when all initial urls are unreachable; ClusterRequestExecutor overrides the loader to use the cluster file. - Unskip test_system_create_topology_files; enable the cache via _customize_store and hash the real (random-port) server url rather than the 8080 placeholder.
1 parent 200387a commit b80b7cf

4 files changed

Lines changed: 144 additions & 22 deletions

File tree

ravendb/documents/conventions.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ def __init__(self):
5151

5252
# Flags
5353
self.disable_topology_updates = False
54+
# Opt-in on-disk topology cache: directory to persist/read topology files, or None to disable.
55+
self.topology_cache_location: Optional[str] = None
5456
self._optimistic_concurrency_mode = None
5557
# Track which setter the user touched so we can reject mixing them.
5658
self._use_optimistic_concurrency_was_set = False

ravendb/http/request_executor.py

Lines changed: 40 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 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,15 @@ 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.__initialize_update_topology_timer()
474+
self.__topology_taken_from_node = ServerNode(url, self._database_name)
475+
return
476+
459477
topology = Topology(
460478
self._topology_etag,
461479
(
@@ -476,6 +494,13 @@ def __run(errors: list):
476494

477495
return self._thread_pool_executor.submit(__run, errors)
478496

497+
def _try_load_topology_from_cache(self, url):
498+
return topology_local_cache.try_load(
499+
self.conventions.topology_cache_location,
500+
topology_local_cache.server_hash(url, self._database_name),
501+
topology_local_cache.DATABASE_TOPOLOGY_EXTENSION,
502+
)
503+
479504
@staticmethod
480505
def validate_urls(initial_urls: List[str]) -> List[str]:
481506
# todo: implement validation
@@ -1445,6 +1470,14 @@ def __supply_async():
14451470
new_topology = Topology(results.etag, nodes)
14461471
self._topology_etag = results.etag
14471472

1473+
if self.conventions.topology_cache_location:
1474+
topology_local_cache.try_save(
1475+
self.conventions.topology_cache_location,
1476+
topology_local_cache.server_hash(parameters.node.url),
1477+
new_topology,
1478+
topology_local_cache.CLUSTER_TOPOLOGY_EXTENSION,
1479+
)
1480+
14481481
if self._node_selector is None:
14491482
self._node_selector = NodeSelector(new_topology, self._thread_pool_executor)
14501483

@@ -1469,5 +1502,12 @@ def __supply_async():
14691502

14701503
return self._thread_pool_executor.submit(__supply_async)
14711504

1505+
def _try_load_topology_from_cache(self, url):
1506+
return topology_local_cache.try_load(
1507+
self.conventions.topology_cache_location,
1508+
topology_local_cache.server_hash(url),
1509+
topology_local_cache.CLUSTER_TOPOLOGY_EXTENSION,
1510+
)
1511+
14721512
def _throw_exceptions(self, details: str):
14731513
raise RuntimeError(f"Failed to retrieve cluster topology from all known nodes {os.linesep}{details}")
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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.server_node import ServerNode
9+
from ravendb.http.topology import Topology
10+
11+
# On-disk topology cache (mirrors the .NET client's TopologyLocalCache). The feature is
12+
# opt-in: it is active only when DocumentConventions.topology_cache_location is set.
13+
14+
DATABASE_TOPOLOGY_EXTENSION = ".raven-topology"
15+
CLUSTER_TOPOLOGY_EXTENSION = ".raven-cluster-topology"
16+
17+
18+
def server_hash(url: str, database: Optional[str] = None) -> str:
19+
key = "{0}{1}".format(url, database if database else "")
20+
return hashlib.md5(key.encode("utf-8")).hexdigest()
21+
22+
23+
def _path(location: str, topology_hash: str, extension: str) -> str:
24+
return os.path.join(location, topology_hash + extension)
25+
26+
27+
def _topology_to_json(topology: Topology) -> dict:
28+
return {
29+
"Etag": topology.etag,
30+
"Nodes": [
31+
{
32+
"Url": node.url,
33+
"Database": node.database,
34+
"ClusterTag": node.cluster_tag,
35+
"ServerRole": node.server_role.value if node.server_role is not None else None,
36+
}
37+
for node in (topology.nodes or [])
38+
],
39+
}
40+
41+
42+
def _topology_from_json(data: dict) -> Topology:
43+
nodes = []
44+
for node_json in data.get("Nodes") or []:
45+
role = node_json.get("ServerRole")
46+
nodes.append(
47+
ServerNode(
48+
node_json.get("Url"),
49+
node_json.get("Database"),
50+
node_json.get("ClusterTag"),
51+
ServerNode.Role(role) if role else None,
52+
)
53+
)
54+
return Topology(data.get("Etag"), nodes)
55+
56+
57+
def try_save(location: Optional[str], topology_hash: str, topology: Topology, extension: str) -> None:
58+
# Best-effort: caching failures must never break a topology update.
59+
if not location or topology is None:
60+
return
61+
try:
62+
os.makedirs(location, exist_ok=True)
63+
with open(_path(location, topology_hash, extension), "w", encoding="utf-8") as stream:
64+
json.dump(_topology_to_json(topology), stream)
65+
except Exception:
66+
pass
67+
68+
69+
def try_load(location: Optional[str], topology_hash: str, extension: str) -> Optional[Topology]:
70+
if not location:
71+
return None
72+
try:
73+
path = _path(location, topology_hash, extension)
74+
if not os.path.isfile(path):
75+
return None
76+
with open(path, "r", encoding="utf-8") as stream:
77+
return _topology_from_json(json.load(stream))
78+
except Exception:
79+
return None

ravendb/tests/system_tests/test_system_create_topology_files.py

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1+
import hashlib
2+
import os
3+
import unittest
4+
from shutil import rmtree
5+
16
from ravendb.documents.store.definition import DocumentStore
27
from ravendb.serverwide.database_record import DatabaseRecord
38
from ravendb.serverwide.operations.common import DeleteDatabaseOperation, CreateDatabaseOperation
49
from ravendb.tests.test_base import TestBase
5-
import hashlib
6-
from shutil import rmtree
7-
import os
8-
import unittest
910

1011
TOPOLOGY_FILES_DIR = os.path.join(os.getcwd(), "topology_files")
1112
DATABASE = "SystemTest"
@@ -17,17 +18,20 @@ def __init__(self, name):
1718

1819

1920
class TestSystemTopologyCreation(TestBase):
21+
def _customize_store(self, store: DocumentStore) -> None:
22+
# opt in to the on-disk topology cache for this test's stores
23+
store.conventions.topology_cache_location = TOPOLOGY_FILES_DIR
24+
2025
def tearDown(self):
21-
self.store.maintenance.server.send(DeleteDatabaseOperation(database_name=DATABASE, hard_delete=True))
26+
try:
27+
self.store.maintenance.server.send(DeleteDatabaseOperation(database_name=DATABASE, hard_delete=True))
28+
except Exception:
29+
pass
2230
super(TestSystemTopologyCreation, self).tearDown()
2331
TestBase.delete_all_topology_files()
2432
if os.path.exists(TOPOLOGY_FILES_DIR):
25-
try:
26-
rmtree(TOPOLOGY_FILES_DIR, ignore_errors=True)
27-
except OSError as ex:
28-
pass
33+
rmtree(TOPOLOGY_FILES_DIR, ignore_errors=True)
2934

30-
@unittest.skip("Topology creation")
3135
def test_topology_creation(self):
3236
created = False
3337
while not created:
@@ -38,25 +42,22 @@ def test_topology_creation(self):
3842
created = True
3943
TestBase.wait_for_database_topology(self.store, DATABASE)
4044

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

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

58+
self.assertTrue(os.path.exists(os.path.join(TOPOLOGY_FILES_DIR, topology_hash + ".raven-topology")))
5059
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-
)
60+
os.path.exists(os.path.join(TOPOLOGY_FILES_DIR, cluster_topology_hash + ".raven-cluster-topology"))
6061
)
6162

6263

0 commit comments

Comments
 (0)