Skip to content

Commit 0c9fa83

Browse files
authored
Merge pull request #304 from poissoncorp/unskip-tests
Unskip/fix the 7.2 test suite: Corax, 7.x logging, topology cache, exception-dispatcher
2 parents cfe2d77 + 8bd8b39 commit 0c9fa83

51 files changed

Lines changed: 643 additions & 259 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ravendb/changes/database_changes.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,12 @@ def _get_server_certificate(self) -> Optional[str]:
8787
def _connect_websocket_secured(self, url: str) -> None:
8888
# Get server certificate via HTTPS and prepare SSL context
8989
server_certificate = base64.b64decode(self._get_server_certificate())
90-
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
90+
# PROTOCOL_TLS_CLIENT replaces the deprecated PROTOCOL_TLSv1_2 but defaults to CA verification and
91+
# hostname checking, which the old PROTOCOL_TLSv1_2 context did not do by default. Clear both to keep
92+
# the previous behavior; a trust store, when configured, re-enables CA verification below.
93+
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
94+
ssl_context.check_hostname = False
95+
ssl_context.verify_mode = ssl.CERT_NONE
9196
ssl_context.load_cert_chain(self._request_executor.certificate_path)
9297
if self._request_executor.trust_store_path:
9398
ssl_context.verify_mode = ssl.CERT_REQUIRED

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/documents/operations/schema_validation/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
import json
4-
from datetime import datetime
4+
from datetime import datetime, timezone
55
from typing import Optional, Dict, Any, List
66

77
import requests
@@ -23,7 +23,7 @@ def __init__(
2323
):
2424
self.schema = schema
2525
self.disabled = disabled
26-
self.last_modified_time = last_modified_time or datetime.utcnow()
26+
self.last_modified_time = last_modified_time or datetime.now(timezone.utc).replace(tzinfo=None)
2727

2828
def to_json(self) -> Dict[str, Any]:
2929
return {

ravendb/documents/operations/time_series.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,8 @@ def from_json(cls, json_dict: Dict[str, Any]) -> TimeSeriesCollectionConfigurati
101101
def to_json(self) -> Dict[str, Any]:
102102
return {
103103
"Disabled": self.disabled,
104-
"Policies": [policy.to_json() for policy in self.policies],
105-
"RawPolicy": self.raw_policy.to_json(),
104+
"Policies": [policy.to_json() for policy in (self.policies or [])],
105+
"RawPolicy": self.raw_policy.to_json() if self.raw_policy is not None else None,
106106
}
107107

108108

ravendb/documents/subscriptions/worker.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -744,11 +744,12 @@ def __run_async() -> None:
744744

745745
def _assert_last_connection_failure(self) -> None:
746746
if self._last_connection_failure is None:
747-
self._last_connection_failure = datetime.datetime.utcnow()
747+
self._last_connection_failure = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
748748
return
749749

750750
if (
751-
datetime.datetime.utcnow().timestamp() - self._last_connection_failure.timestamp()
751+
datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None).timestamp()
752+
- self._last_connection_failure.timestamp()
752753
> self._options.max_erroneous_period.total_seconds()
753754
):
754755
raise SubscriptionInvalidStateException(

ravendb/http/request_executor.py

Lines changed: 42 additions & 1 deletion
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
@@ -635,7 +661,7 @@ def execute(
635661
return # we either handled this already in the unsuccessful response or we are throwing
636662
self._on_succeed_request_invoke(self._database_name, url, response, request, attempt_num)
637663
response_dispose = command.process_response(self._cache, response, url)
638-
self._last_returned_response = datetime.datetime.utcnow()
664+
self._last_returned_response = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
639665
finally:
640666
if response_dispose == ResponseDisposeHandling.AUTOMATIC:
641667
response.close()
@@ -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

0 commit comments

Comments
 (0)