diff --git a/ravendb/documents/session/document_session_operations/in_memory_document_session_operations.py b/ravendb/documents/session/document_session_operations/in_memory_document_session_operations.py index c1802819..d8eed440 100644 --- a/ravendb/documents/session/document_session_operations/in_memory_document_session_operations.py +++ b/ravendb/documents/session/document_session_operations/in_memory_document_session_operations.py @@ -469,6 +469,7 @@ def __init__(self, store: "DocumentStore", key: uuid.UUID, options: SessionOptio self._deleted_entities: Union[ Set[DeletedEntitiesHolder.DeletedEntitiesEnumeratorResult], DeletedEntitiesHolder ] = DeletedEntitiesHolder() + self._pending_key_deletes: List[Dict] = [] self._deferred_commands: List[CommandData] = [] self._deferred_commands_map: Dict[IdTypeAndName, CommandData] = {} self._ids_for_creating_forced_revisions: Dict[str, ForceRevisionStrategy] = CaseInsensitiveDict() @@ -542,14 +543,14 @@ def remove_after_save_changes(self, event: Callable[[AfterSaveChangesEventArgs], def add_before_delete(self, event: Callable[[BeforeDeleteEventArgs], None]): self._before_delete.append(event) - def remove_before_delete_entity(self, event: Callable[[BeforeDeleteEventArgs], None]): + def remove_before_delete(self, event: Callable[[BeforeDeleteEventArgs], None]): self._before_delete.remove(event) def add_before_query(self, event: Callable[[BeforeQueryEventArgs], None]): self._before_query.append(event) def remove_before_query(self, event: Callable[[BeforeQueryEventArgs], None]): - self._before_query.append(event) + self._before_query.remove(event) def before_store_invoke(self, before_store_event_args: BeforeStoreEventArgs): for event in self._before_store: @@ -837,12 +838,14 @@ def delete(self, key_or_entity: Union[str, object], expected_change_vector: Opti change_vector = change_vector if self._use_optimistic_concurrency else None if self._counters_by_doc_id: self._counters_by_doc_id.pop(key, None) - self.defer( - DeleteCommandData( - key, - expected_change_vector or change_vector, - expected_change_vector or (document_info.change_vector if document_info is not None else None), - ) + self._pending_key_deletes.append( + { + "key": key, + "cv": expected_change_vector or change_vector, + "etag_cv": expected_change_vector + or (document_info.change_vector if document_info is not None else None), + "entity": document_info.entity if document_info is not None else None, + } ) return @@ -902,6 +905,11 @@ def __store_internal( f"Document id:{key}" ) + if any(e["key"] == key for e in self._pending_key_deletes): + raise InvalidOperationException( + f"Can't store document, it was already deleted in this session. Document id: {key}" + ) + if entity in self._deleted_entities: raise RuntimeError(f"Can't store object, it was already deleted in this session. Document id {key}") @@ -954,11 +962,24 @@ def _store_entity_in_unit_of_work( if key is not None: self._documents_by_id[key] = document_info + def __prepare_for_key_deletes(self, result: "InMemoryDocumentSessionOperations.SaveChangesData") -> None: + """Fire OnBeforeDelete for string-key deletes and queue their commands. + + Mirrors C#'s PrepareForEntitiesDeletion timing: the event fires during save_changes() + preparation (not during session.delete()), keeping both delete paths consistent. + """ + for entry in self._pending_key_deletes: + self.before_delete_invoke(BeforeDeleteEventArgs(self, entry["key"], entry["entity"])) + result.session_commands.append(DeleteCommandData(entry["key"], entry["cv"], entry["etag_cv"])) + if self._pending_key_deletes: + result.on_success.clear_pending_key_deletes() + def prepare_for_save_changes(self) -> SaveChangesData: result = InMemoryDocumentSessionOperations.SaveChangesData(self) deferred_commands_count = len(self._deferred_commands) self.__prepare_for_entities_deletion(result, None) + self.__prepare_for_key_deletes(result) self.__prepare_for_entities_puts(result) self.__prepare_for_creating_revisions_from_ids(result) self.__prepare_compare_exchange_entities(result) @@ -1173,7 +1194,11 @@ def has_changes(self) -> bool: if self._entity_changed(document, entity.value, None): return True - return not len(self._deleted_entities) == 0 or not len(self._deferred_commands) == 0 + return ( + not len(self._deleted_entities) == 0 + or not len(self._deferred_commands) == 0 + or bool(self._pending_key_deletes) + ) def _what_changed(self) -> Dict[str, List[DocumentsChanges]]: changes = {} @@ -1913,6 +1938,7 @@ def __init__(self, session: InMemoryDocumentSessionOperations): self.__documents_by_entity_to_remove: List = [] self.__document_infos_to_update: List[Tuple[DocumentInfo, dict]] = [] self.__clear_deleted_entities: bool = False + self.__clear_pending_key_deletes: bool = False def remove_document_by_id(self, key: str): self.__documents_by_id_to_remove.append(key) @@ -1939,8 +1965,14 @@ def clear_session_state_after_successful_save_changes(self): if self.__clear_deleted_entities: self.__session._deleted_entities.clear() + if self.__clear_pending_key_deletes: + self.__session._pending_key_deletes.clear() + self.__session._deferred_commands.clear() self.__session._deferred_commands_map.clear() def clear_deleted_entities(self) -> None: self.__clear_deleted_entities = True + + def clear_pending_key_deletes(self) -> None: + self.__clear_pending_key_deletes = True diff --git a/ravendb/documents/store/definition.py b/ravendb/documents/store/definition.py index d1bbd7e8..0dabc890 100644 --- a/ravendb/documents/store/definition.py +++ b/ravendb/documents/store/definition.py @@ -175,6 +175,30 @@ def operations(self) -> OperationExecutor: def open_session(self, database: Optional[str] = None, session_options: Optional = None): pass + def add_before_store(self, event: Callable[[BeforeStoreEventArgs], None]): + self.__before_store.append(event) + + def remove_before_store(self, event: Callable[[BeforeStoreEventArgs], None]): + self.__before_store.remove(event) + + def add_after_save_changes(self, event: Callable[[AfterSaveChangesEventArgs], None]): + self.__after_save_changes.append(event) + + def remove_after_save_changes(self, event: Callable[[AfterSaveChangesEventArgs], None]): + self.__after_save_changes.remove(event) + + def add_before_delete(self, event: Callable[[BeforeDeleteEventArgs], None]): + self.__before_delete.append(event) + + def remove_before_delete(self, event: Callable[[BeforeDeleteEventArgs], None]): + self.__before_delete.remove(event) + + def add_before_query(self, event: Callable[[BeforeQueryEventArgs], None]): + self.__before_query.append(event) + + def remove_before_query(self, event: Callable[[BeforeQueryEventArgs], None]): + self.__before_query.remove(event) + def add_on_session_creation(self, event: Callable[[SessionCreatedEventArgs], None]): self.__on_session_creation.append(event) @@ -313,7 +337,7 @@ def __init__(self, urls: Union[str, List[str]] = None, database: Optional[str] = self.urls = [urls] if isinstance(urls, str) else urls self.database = database self.__request_executors: Dict[str, Lazy[RequestExecutor]] = CaseInsensitiveDict() - # todo: aggressive cache + self.__aggressive_cache_changes: Dict[str, "DocumentStore._AggressiveCacheEviction"] = {} self.__maintenance_operation_executor: Optional[MaintenanceOperationExecutor] = None self.__operation_executor: Optional[OperationExecutor] = None # todo: database smuggler @@ -379,7 +403,9 @@ def close(self): for event in self.__before_close: event() - # todo: evict items from cache based on changes + for eviction in list(self.__aggressive_cache_changes.values()): + eviction.close() + self.__aggressive_cache_changes.clear() while len(self.__database_changes) > 0: self.__database_changes.popitem()[1].close() @@ -529,7 +555,122 @@ def initialize(self) -> DocumentStore: self._initialized = True return self - # todo: aggressively cache + def aggressively_cache_for( + self, + cache_duration: datetime.timedelta, + database: Optional[str] = None, + mode: Optional["AggressiveCacheMode"] = None, + ) -> "DocumentStore._AggressiveCacheContext": + from ravendb.http.misc import AggressiveCacheOptions, AggressiveCacheMode + + self.assert_initialized() + database = self.get_effective_database(database) + if mode is None: + mode = AggressiveCacheMode.TRACK_CHANGES + request_executor = self.get_request_executor(database) + options = AggressiveCacheOptions(cache_duration, mode) + if mode != AggressiveCacheMode.DO_NOT_TRACK_CHANGES: + self._listen_to_changes_and_update_cache(database) + return DocumentStore._AggressiveCacheContext(request_executor, options) + + def _listen_to_changes_and_update_cache(self, database: str) -> None: + # Fast path: already registered. + if database in self.__aggressive_cache_changes: + return + # Create the eviction object BEFORE acquiring the lock. + # _AggressiveCacheEviction.__init__ calls store.changes() which also + # acquires __add_change_lock, so constructing inside the lock would deadlock + # (threading.Lock is not reentrant). If two threads race here both will + # build an eviction object, but only one will be stored; the loser is + # discarded via close(). + eviction = DocumentStore._AggressiveCacheEviction(self, database) + with self.__add_change_lock: + if database not in self.__aggressive_cache_changes: + self.__aggressive_cache_changes[database] = eviction + else: + eviction.close() # another thread won the race + + def disable_aggressive_caching(self, database: Optional[str] = None) -> "DocumentStore._AggressiveCacheContext": + self.assert_initialized() + database = self.get_effective_database(database) + request_executor = self.get_request_executor(database) + return DocumentStore._AggressiveCacheContext(request_executor, None) + + class _AggressiveCacheContext: + def __init__(self, request_executor, options): + self._request_executor = request_executor + self._options = options + self._old_options = None + + def __enter__(self): + self._old_options = self._request_executor.aggressive_caching + self._request_executor.aggressive_caching = self._options + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self._request_executor.aggressive_caching = self._old_options + + class _AggressiveCacheEviction: + """Subscribes to document/index changes and invalidates the HTTP cache on relevant events. + + Mirrors C# EvictItemsFromCacheBasedOnChanges. The mechanism is simple: increment + cache.generation on every relevant change. Any ReleaseCacheItem already held by an + in-flight execute() will then have might_have_been_modified == True (its captured + generation no longer matches the current one), causing TRACK_CHANGES mode to fall + through to the server instead of short-circuiting. + + On connection error we also increment — if we can't hear the server we have to assume + something changed. It's better to do one extra round-trip than to serve stale data. + """ + + def __init__(self, store: "DocumentStore", database: str): + from ravendb.changes.observers import ActionObserver + from ravendb.changes.types import DocumentChangeType, IndexChangeTypes + + self._request_executor = store.get_request_executor(database) + self._changes = store.changes(database) + self._unsubscribers: List[Callable[[], None]] = [] + + # Capture by reference so the lambdas below always see the live cache object, + # even if RequestExecutor.cache is replaced. (It isn't today, but be explicit.) + cache_ref = self._request_executor.cache + + def _invalidate() -> None: + cache_ref.generation += 1 + + def on_document_change(change) -> None: + # Only Put and Delete affect query results; ConflictResolved etc. do not. + if change.type_of_change in (DocumentChangeType.PUT, DocumentChangeType.DELETE): + _invalidate() + + def on_index_change(change) -> None: + # BatchCompleted means new index results are available; IndexRemoved means + # stale queries might have been using it. + if change.type_of_change in (IndexChangeTypes.BATCH_COMPLETED, IndexChangeTypes.INDEX_REMOVED): + _invalidate() + + # subscribe_with_observer (not subscribe) so we can attach an on_error callback. + # subscribe() creates an ActionObserver with no on_error, which means a WebSocket + # disconnect silently swallows the error — cache.generation is never bumped and + # the aggressive cache serves stale data indefinitely after the connection dies. + self._unsubscribers.append( + self._changes.for_all_documents().subscribe_with_observer( + ActionObserver(on_next=on_document_change, on_error=lambda e: _invalidate()) + ) + ) + self._unsubscribers.append( + self._changes.for_all_indexes().subscribe_with_observer( + ActionObserver(on_next=on_index_change, on_error=lambda e: _invalidate()) + ) + ) + + def close(self) -> None: + for unsub in self._unsubscribers: + try: + unsub() + except Exception: + pass + self._unsubscribers.clear() def bulk_insert(self, database_name: str = None, options: BulkInsertOptions = None) -> BulkInsertOperation: self.assert_initialized() diff --git a/ravendb/http/http_cache.py b/ravendb/http/http_cache.py index 92ac7c89..ba59ea9b 100644 --- a/ravendb/http/http_cache.py +++ b/ravendb/http/http_cache.py @@ -41,6 +41,8 @@ def age(self) -> datetime.timedelta: @property def might_have_been_modified(self) -> bool: + if self.item is None: + return True return self.item.generation != self.__cache_generation def close(self): @@ -73,7 +75,7 @@ def __setitem__(self, key, value): self.__items.__setitem__(key, value) def __getitem__(self, item): - self.__items.__getitem__(item) + return self.__items.__getitem__(item) def close(self): self.__items.clear() @@ -108,29 +110,3 @@ def set_not_found(self, url: str, aggressively_cached: bool) -> None: {ItemFlags.AGGRESSIVELY_CACHED, ItemFlags.NOT_FOUND} if aggressively_cached else {ItemFlags.NOT_FOUND} ) self.__items[url] = http_cache_item - - class ReleaseCacheItem: - def __init__(self, item: HttpCacheItem = None): - self.item: Union[None, HttpCacheItem] = item - self.__cache_generation = item.cache.generation if item else 0 - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - pass - - def not_modified(self) -> None: - if self.item is not None: - self.item.last_server_update = datetime.datetime.now() - self.item.generation = self.__cache_generation - - @property - def age(self) -> datetime.timedelta: - if self.item is None: - return datetime.timedelta.max - return datetime.datetime.now() - self.item.last_server_update - - @property - def might_have_been_modified(self) -> bool: - return self.item.generation != self.__cache_generation diff --git a/ravendb/http/request_executor.py b/ravendb/http/request_executor.py index 45a51333..a3f5517e 100644 --- a/ravendb/http/request_executor.py +++ b/ravendb/http/request_executor.py @@ -8,7 +8,7 @@ from concurrent.futures import ThreadPoolExecutor, Future, FIRST_COMPLETED, wait, ALL_COMPLETED import uuid from json import JSONDecodeError -from threading import Timer, Semaphore, Lock +from threading import Timer, Semaphore, Lock, local import requests from copy import copy @@ -27,8 +27,14 @@ from ravendb.exceptions.raven_exceptions import ClientVersionMismatchException -from ravendb.http.http_cache import HttpCache -from ravendb.http.misc import ReadBalanceBehavior, ResponseDisposeHandling, LoadBalanceBehavior, Broadcast +from ravendb.http.http_cache import HttpCache, ItemFlags, ReleaseCacheItem +from ravendb.http.misc import ( + ReadBalanceBehavior, + ResponseDisposeHandling, + LoadBalanceBehavior, + Broadcast, + AggressiveCacheMode, +) from ravendb.http.raven_command import RavenCommand, RavenCommandResponseType from ravendb.http.server_node import ServerNode from ravendb.http.topology import Topology, NodeStatus, NodeSelector, CurrentIndexAndNode, UpdateTopologyParameters @@ -106,6 +112,7 @@ def __init__( self._disposed: Union[None, bool] = None self.__synchronized_lock = Lock() + self._aggressive_caching_local = local() # --- events --- self._on_before_request: List[Callable[[BeforeRequestEventArgs], Any]] = [] @@ -113,6 +120,17 @@ def __init__( self.__on_succeed_request: List[Callable[[SucceedRequestEventArgs], None]] = [] self._on_topology_updated: List[Callable[[Topology], None]] = [] + @property + def aggressive_caching(self) -> Optional["AggressiveCacheOptions"]: + # threading.local mirrors C#'s AsyncLocal: each thread + # (each request context) gets its own setting. Without this, one thread enabling + # aggressive caching would bleed into every other thread on the same executor. + return getattr(self._aggressive_caching_local, "value", None) + + @aggressive_caching.setter + def aggressive_caching(self, value: Optional["AggressiveCacheOptions"]) -> None: + self._aggressive_caching_local.value = value + def __enter__(self): return self @@ -522,7 +540,38 @@ def execute( no_caching = session_info.no_caching if session_info else False cached_item, change_vector, cached_value = self._get_from_cache(command, not no_caching, url) - # todo: if change_vector exists try get from cache - aggressive caching + + # Aggressive-cache short-circuit: serve from local cache without touching the server. + # All five conditions must hold: + # 1. Session didn't disable caching. + # 2. Aggressive caching is active on this thread. + # 3. The command doesn't opt out (streaming commands set can_cache_aggressively=False). + # 4. The item is actually in cache AND young enough. + # 5. Under TRACK_CHANGES mode, the cache generation must not have advanced since we + # retrieved the item — if it has, _AggressiveCacheEviction saw a server change and + # bumped the generation, so we must revalidate. + if ( + not no_caching + and self.aggressive_caching is not None + and command.can_cache_aggressively + and cached_item.item is not None + and cached_item.age < self.aggressive_caching.duration + and ( + not cached_item.might_have_been_modified + or self.aggressive_caching.mode != AggressiveCacheMode.TRACK_CHANGES + ) + ): + if ItemFlags.NOT_FOUND in cached_item.item.flags: + # Cached 404: only trust it when it was itself received inside an aggressive- + # cache context (AGGRESSIVELY_CACHED flag set by set_not_found). A 404 cached + # outside aggressive mode might have been a transient error; re-fetch it. + if ItemFlags.AGGRESSIVELY_CACHED in cached_item.item.flags: + command.set_response(None, True) + return + elif cached_value is not None: + command.set_response(cached_value, True) + return + with cached_item: # todo: try get from cache self._set_request_headers(session_info, change_vector, request) @@ -785,7 +834,7 @@ def _set_request_headers( def _get_from_cache( self, command: RavenCommand, use_cache: bool, url: str - ) -> Tuple[HttpCache.ReleaseCacheItem, Optional[str], Optional[str]]: + ) -> Tuple[ReleaseCacheItem, Optional[str], Optional[str]]: if ( use_cache and command.can_cache @@ -794,7 +843,7 @@ def _get_from_cache( ): return self._cache.get(url) - return HttpCache.ReleaseCacheItem(), None, None + return ReleaseCacheItem(), None, None @staticmethod def __try_get_server_version(response: requests.Response) -> Union[None, str]: @@ -1014,7 +1063,7 @@ def _handle_unsuccessful_response( should_retry: bool, ) -> bool: if response.status_code == HTTPStatus.NOT_FOUND: - self._cache.set_not_found(url, False) # todo : check if aggressively cached, don't just pass False + self._cache.set_not_found(url, self.aggressive_caching is not None) if command.response_type == RavenCommandResponseType.EMPTY: return True elif command.response_type == RavenCommandResponseType.OBJECT: diff --git a/ravendb/tests/issue_tests/test_RDBC_1035.py b/ravendb/tests/issue_tests/test_RDBC_1035.py new file mode 100644 index 00000000..35f993a9 --- /dev/null +++ b/ravendb/tests/issue_tests/test_RDBC_1035.py @@ -0,0 +1,858 @@ +""" +RDBC-1035 + RDBC-1036: store.aggressively_cache_for() / disable_aggressive_caching() + and store-level event registration methods. + +C# reference: IDocumentStore.AggressivelyCacheFor() / DisableAggressiveCaching() + IDocumentStore.OnBeforeStore / OnAfterSaveChanges / OnBeforeDelete / OnBeforeQuery +""" + +import datetime +import threading +import unittest + +from ravendb.documents.store.definition import DocumentStoreBase +from ravendb.tests.test_base import TestBase + + +class TestStoreApiUnit(unittest.TestCase): + """Unit tests — no server required.""" + + def _make_store_base(self): + base = DocumentStoreBase.__new__(DocumentStoreBase) + DocumentStoreBase.__init__(base) + return base + + # --- store events --- + + def test_add_before_store_method_exists(self): + store = self._make_store_base() + self.assertTrue(hasattr(store, "add_before_store")) + self.assertTrue(callable(store.add_before_store)) + + def test_add_after_save_changes_method_exists(self): + store = self._make_store_base() + self.assertTrue(hasattr(store, "add_after_save_changes")) + + def test_add_before_delete_method_exists(self): + store = self._make_store_base() + self.assertTrue(hasattr(store, "add_before_delete")) + + def test_add_before_query_method_exists(self): + store = self._make_store_base() + self.assertTrue(hasattr(store, "add_before_query")) + + def test_remove_methods_exist(self): + store = self._make_store_base() + self.assertTrue(hasattr(store, "remove_before_store")) + self.assertTrue(hasattr(store, "remove_after_save_changes")) + self.assertTrue(hasattr(store, "remove_before_delete")) + self.assertTrue(hasattr(store, "remove_before_query")) + + def test_add_and_remove_before_store_roundtrip(self): + store = self._make_store_base() + handler = lambda e: None + store.add_before_store(handler) + store.remove_before_store(handler) + + # --- aggressive cache --- + + def test_get_from_cache_returns_empty_release_cache_item_for_non_cacheable_command(self): + """_get_from_cache must return a valid ReleaseCacheItem (not HttpCache.ReleaseCacheItem) + when the command is non-cacheable, so that execute() can enter its 'with cached_item:' + context manager without AttributeError.""" + from ravendb.http.request_executor import RequestExecutor + from ravendb.http.http_cache import ReleaseCacheItem + + executor = RequestExecutor.__new__(RequestExecutor) + executor._cache = __import__("ravendb.http.http_cache", fromlist=["HttpCache"]).HttpCache() + + command = self._make_command(can_cache_aggressively=False) + command._can_cache = False + + cached_item, change_vector, cached_value = executor._get_from_cache(command, False, "http://host/docs/1") + + self.assertIsInstance(cached_item, ReleaseCacheItem) + self.assertIsNone(cached_item.item) + self.assertIsNone(change_vector) + self.assertIsNone(cached_value) + # Verify the context manager protocol works (was broken when HttpCache.ReleaseCacheItem was used) + with cached_item: + pass + + def test_aggressively_cache_for_method_exists_on_document_store(self): + from ravendb.documents.store.definition import DocumentStore + + self.assertTrue(hasattr(DocumentStore, "aggressively_cache_for")) + + def test_disable_aggressive_caching_method_exists_on_document_store(self): + from ravendb.documents.store.definition import DocumentStore + + self.assertTrue(hasattr(DocumentStore, "disable_aggressive_caching")) + + def test_aggressive_caching_is_thread_local(self): + """aggressive_caching must be per-thread (AsyncLocal equivalent), not shared across threads.""" + from unittest.mock import MagicMock + from ravendb.http.misc import AggressiveCacheOptions, AggressiveCacheMode + from ravendb.http.request_executor import RequestExecutor + + executor = RequestExecutor.__new__(RequestExecutor) + executor._aggressive_caching_local = threading.local() + + options = AggressiveCacheOptions(datetime.timedelta(minutes=5), AggressiveCacheMode.TRACK_CHANGES) + executor.aggressive_caching = options + + other_thread_value = [] + + def read_from_other_thread(): + other_thread_value.append(executor.aggressive_caching) + + t = threading.Thread(target=read_from_other_thread) + t.start() + t.join() + + # Main thread sees the set value; other thread sees None (thread-local isolation) + self.assertEqual(options, executor.aggressive_caching) + self.assertIsNone(other_thread_value[0]) + + def _make_executor_with_caching(self, mode): + from ravendb.http.misc import AggressiveCacheOptions, AggressiveCacheMode + from ravendb.http.request_executor import RequestExecutor + + executor = RequestExecutor.__new__(RequestExecutor) + executor._aggressive_caching_local = threading.local() + executor.aggressive_caching = AggressiveCacheOptions(datetime.timedelta(hours=1), mode) + return executor + + def _make_cached_item(self, might_have_been_modified: bool): + """Build a real ReleaseCacheItem. Increment cache.generation before get() to simulate modification.""" + from ravendb.http.http_cache import HttpCache + + cache = HttpCache() + cache.set("http://host/docs/1", "cv-1", '{"Name":"Alice"}') + if might_have_been_modified: + cache.generation += 1 + cached_item, _, cached_value = cache.get("http://host/docs/1") + return cached_item, cached_value + + def _make_command(self, can_cache_aggressively: bool): + from ravendb.http.raven_command import RavenCommand, RavenCommandResponseType + + class _MinimalCommand(RavenCommand): + def create_request(self, node): + pass + + def is_read_request(self): + return True + + def set_response(self, response, from_cache): + pass + + cmd = _MinimalCommand() + cmd._can_cache_aggressively = can_cache_aggressively + return cmd + + def _check_aggressive_cache_guard(self, executor, cached_item, cached_value, command, no_caching=False): + """Mirrors the aggressive-cache short-circuit in execute(). + + Matches the exact condition in RequestExecutor.execute(): + not no_caching + and self.aggressive_caching is not None + and command.can_cache_aggressively + and cached_item.item is not None + and cached_item.age < self.aggressive_caching.duration + and (not cached_item.might_have_been_modified + or self.aggressive_caching.mode != AggressiveCacheMode.TRACK_CHANGES) + """ + from ravendb.http.misc import AggressiveCacheMode + from ravendb.http.http_cache import ItemFlags + + if not ( + not no_caching + and executor.aggressive_caching is not None + and command.can_cache_aggressively + and cached_item.item is not None + and cached_item.age < executor.aggressive_caching.duration + and ( + not cached_item.might_have_been_modified + or executor.aggressive_caching.mode != AggressiveCacheMode.TRACK_CHANGES + ) + ): + return False + + if ItemFlags.NOT_FOUND in cached_item.item.flags: + return ItemFlags.AGGRESSIVELY_CACHED in cached_item.item.flags + return cached_value is not None + + def test_can_cache_aggressively_false_prevents_cache_hit(self): + """commands with can_cache_aggressively=False must not be served from aggressive cache.""" + from ravendb.http.misc import AggressiveCacheMode + + executor = self._make_executor_with_caching(AggressiveCacheMode.TRACK_CHANGES) + cached_item, cached_value = self._make_cached_item(might_have_been_modified=False) + command = self._make_command(can_cache_aggressively=False) + + self.assertFalse(self._check_aggressive_cache_guard(executor, cached_item, cached_value, command)) + + def test_might_have_been_modified_prevents_track_changes_cache_hit(self): + """when mode=TRACK_CHANGES and item might_have_been_modified, must not serve from cache.""" + from ravendb.http.misc import AggressiveCacheMode + + executor = self._make_executor_with_caching(AggressiveCacheMode.TRACK_CHANGES) + cached_item, cached_value = self._make_cached_item(might_have_been_modified=True) + command = self._make_command(can_cache_aggressively=True) + + self.assertFalse(self._check_aggressive_cache_guard(executor, cached_item, cached_value, command)) + + def test_do_not_track_changes_mode_ignores_might_have_been_modified(self): + """when mode=DO_NOT_TRACK_CHANGES, a modified item is still served from aggressive cache.""" + from ravendb.http.misc import AggressiveCacheMode + + executor = self._make_executor_with_caching(AggressiveCacheMode.DO_NOT_TRACK_CHANGES) + cached_item, cached_value = self._make_cached_item(might_have_been_modified=True) + command = self._make_command(can_cache_aggressively=True) + + self.assertTrue(self._check_aggressive_cache_guard(executor, cached_item, cached_value, command)) + + def test_set_not_found_marks_aggressively_cached_when_caching_active(self): + """set_not_found must pass aggressively_cached=True when aggressive caching is active.""" + from ravendb.http.http_cache import HttpCache, ItemFlags + + cache = HttpCache() + cache.set_not_found("http://host/docs/missing", aggressively_cached=True) + + cached_item, _, _ = cache.get("http://host/docs/missing") + self.assertIsNotNone(cached_item.item) + self.assertIn(ItemFlags.NOT_FOUND, cached_item.item.flags) + self.assertIn(ItemFlags.AGGRESSIVELY_CACHED, cached_item.item.flags) + + def test_set_not_found_without_aggressive_caching_not_marked(self): + """set_not_found with aggressively_cached=False must NOT set AGGRESSIVELY_CACHED flag.""" + from ravendb.http.http_cache import HttpCache, ItemFlags + + cache = HttpCache() + cache.set_not_found("http://host/docs/missing", aggressively_cached=False) + + cached_item, _, _ = cache.get("http://host/docs/missing") + self.assertNotIn(ItemFlags.AGGRESSIVELY_CACHED, cached_item.item.flags) + + def test_not_found_aggressively_cached_is_served_from_cache(self): + """a 404 cached inside an aggressive-cache context must be served from cache.""" + from ravendb.http.misc import AggressiveCacheMode + from ravendb.http.http_cache import HttpCache, ItemFlags + + executor = self._make_executor_with_caching(AggressiveCacheMode.TRACK_CHANGES) + command = self._make_command(can_cache_aggressively=True) + + cache = HttpCache() + cache.set_not_found("http://host/docs/missing", aggressively_cached=True) + cached_item, _, cached_value = cache.get("http://host/docs/missing") + + # 404 cached aggressively: should be served from cache (short-circuit) + self.assertIsNone(cached_value) # no payload for 404 + self.assertIn(ItemFlags.NOT_FOUND, cached_item.item.flags) + self.assertIn(ItemFlags.AGGRESSIVELY_CACHED, cached_item.item.flags) + self.assertTrue(self._check_aggressive_cache_guard(executor, cached_item, cached_value, command)) + + def test_not_found_not_aggressively_cached_is_not_served_from_cache(self): + """a 404 NOT cached aggressively must NOT be served from aggressive cache.""" + from ravendb.http.misc import AggressiveCacheMode + from ravendb.http.http_cache import HttpCache + + executor = self._make_executor_with_caching(AggressiveCacheMode.TRACK_CHANGES) + command = self._make_command(can_cache_aggressively=True) + + cache = HttpCache() + cache.set_not_found("http://host/docs/missing", aggressively_cached=False) + cached_item, _, cached_value = cache.get("http://host/docs/missing") + + self.assertFalse(self._check_aggressive_cache_guard(executor, cached_item, cached_value, command)) + + def test_aggressive_cache_eviction_class_exists(self): + """DocumentStore._AggressiveCacheEviction must exist.""" + from ravendb.documents.store.definition import DocumentStore + + self.assertTrue(hasattr(DocumentStore, "_AggressiveCacheEviction")) + + def test_aggressively_cache_for_track_changes_starts_eviction_listener(self): + """aggressively_cache_for with TRACK_CHANGES must register an eviction listener per database.""" + from ravendb.documents.store.definition import DocumentStore + from ravendb.http.misc import AggressiveCacheMode + + store = DocumentStore.__new__(DocumentStore) + store._initialized = True + store._disposed = None + store._database = "testdb" + store._urls = ["http://localhost:8080"] + store._DocumentStore__aggressive_cache_changes = {} + + eviction_created = [] + + def fake_get_request_executor(db=None): + from ravendb.http.request_executor import RequestExecutor + + re = RequestExecutor.__new__(RequestExecutor) + re._aggressive_caching_local = threading.local() + return re + + def fake_get_effective_database(db): + return db or "testdb" + + def fake_listen_to_changes_and_update_cache(db): + eviction_created.append(db) + + store.get_request_executor = fake_get_request_executor + store.get_effective_database = fake_get_effective_database + store._listen_to_changes_and_update_cache = fake_listen_to_changes_and_update_cache + + ctx = store.aggressively_cache_for(datetime.timedelta(minutes=5), mode=AggressiveCacheMode.TRACK_CHANGES) + + self.assertEqual(["testdb"], eviction_created) + + def test_aggressively_cache_for_do_not_track_skips_eviction_listener(self): + """aggressively_cache_for with DO_NOT_TRACK_CHANGES must NOT start a change listener.""" + from ravendb.documents.store.definition import DocumentStore + from ravendb.http.misc import AggressiveCacheMode + + store = DocumentStore.__new__(DocumentStore) + store._initialized = True + store._disposed = None + store._database = "testdb" + store._urls = ["http://localhost:8080"] + store._DocumentStore__aggressive_cache_changes = {} + + eviction_created = [] + + def fake_get_request_executor(db=None): + from ravendb.http.request_executor import RequestExecutor + + re = RequestExecutor.__new__(RequestExecutor) + re._aggressive_caching_local = threading.local() + return re + + def fake_get_effective_database(db): + return db or "testdb" + + def fake_listen(db): + eviction_created.append(db) + + store.get_request_executor = fake_get_request_executor + store.get_effective_database = fake_get_effective_database + store._listen_to_changes_and_update_cache = fake_listen + + store.aggressively_cache_for(datetime.timedelta(minutes=5), mode=AggressiveCacheMode.DO_NOT_TRACK_CHANGES) + + self.assertEqual([], eviction_created) + + def test_cache_generation_incremented_on_document_put(self): + """_AggressiveCacheEviction increments cache.generation on document PUT/DELETE.""" + from ravendb.changes.types import DocumentChange, DocumentChangeType + from ravendb.http.http_cache import HttpCache + + cache = HttpCache() + initial_generation = cache.generation + + # Simulate what _AggressiveCacheEviction does + def on_document_change(change): + if change.type_of_change in (DocumentChangeType.PUT, DocumentChangeType.DELETE): + cache.generation += 1 + + put_change = DocumentChange(DocumentChangeType.PUT, "docs/1", "Users", "A:1") + on_document_change(put_change) + + self.assertEqual(initial_generation + 1, cache.generation) + + def test_cache_generation_incremented_on_index_batch_completed(self): + """_AggressiveCacheEviction increments cache.generation on BatchCompleted index change.""" + from ravendb.changes.types import IndexChange, IndexChangeTypes + from ravendb.http.http_cache import HttpCache + + cache = HttpCache() + initial_generation = cache.generation + + def on_index_change(change): + if change.type_of_change in (IndexChangeTypes.BATCH_COMPLETED, IndexChangeTypes.INDEX_REMOVED): + cache.generation += 1 + + batch_change = IndexChange(IndexChangeTypes.BATCH_COMPLETED, "Orders/ByCompany") + on_index_change(batch_change) + + self.assertEqual(initial_generation + 1, cache.generation) + + def test_eviction_on_error_increments_generation(self): + """When the WebSocket drops, Observable.error() must increment cache.generation. + + subscribe() creates an ActionObserver with no on_error, silently swallowing the + disconnect. subscribe_with_observer() + ActionObserver(on_error=...) is required so + a connection drop forces revalidation rather than serving stale data indefinitely. + """ + from concurrent.futures import ThreadPoolExecutor + from ravendb.changes.observers import Observable, ActionObserver + from ravendb.http.http_cache import HttpCache + + cache = HttpCache() + initial_generation = cache.generation + + obs = Observable(executor=ThreadPoolExecutor(max_workers=1)) + obs._filter = lambda x: True + + # Verify the bug: subscribe() gives no on_error, error is swallowed + obs.subscribe(lambda v: None) + obs.error(Exception("connection dropped")) + self.assertEqual( + initial_generation, cache.generation, "subscribe() must not increment — confirming the bug exists" + ) + + # subscribe_with_observer + on_error must invalidate the cache on connection errors. + cache2 = HttpCache() + obs2 = Observable(executor=ThreadPoolExecutor(max_workers=1)) + obs2._filter = lambda x: True + + observer = ActionObserver( + on_next=lambda v: None, + on_error=lambda e: setattr(cache2, "generation", cache2.generation + 1), + ) + obs2.subscribe_with_observer(observer) + obs2.error(Exception("connection dropped")) + self.assertEqual(initial_generation + 1, cache2.generation, "subscribe_with_observer() must increment on error") + + def test_listen_to_changes_thread_safe_no_duplicate_listeners(self): + """Concurrent calls to _listen_to_changes_and_update_cache must create exactly + one _AggressiveCacheEviction per database, not one per racing thread. + + The guard uses __add_change_lock so the check-then-set is atomic. The eviction + object is constructed OUTSIDE the lock (to avoid a deadlock: _AggressiveCacheEviction + calls store.changes() which also acquires __add_change_lock, and threading.Lock is + not reentrant). Losers call eviction.close() to discard the extra subscription. + """ + from ravendb.documents.store.definition import DocumentStore + from unittest.mock import MagicMock, patch + + store = DocumentStore.__new__(DocumentStore) + store._initialized = True + store._disposed = None + store._database = "testdb" + store._DocumentStore__aggressive_cache_changes = {} + store._DocumentStore__add_change_lock = threading.Lock() + + created = [] + + # Stub _AggressiveCacheEviction so no network calls are made, but keep the + # real _listen_to_changes_and_update_cache lock logic intact. + def make_fake_eviction(s, db): + ev = MagicMock() + created.append(db) + return ev + + with patch.object(DocumentStore, "_AggressiveCacheEviction", side_effect=make_fake_eviction): + threads = [ + threading.Thread(target=store._listen_to_changes_and_update_cache, args=("testdb",)) for _ in range(20) + ] + for t in threads: + t.start() + for t in threads: + t.join() + + # Exactly one eviction must survive in the dict regardless of how many were + # created (losers are closed and discarded). + self.assertEqual( + 1, + len(store._DocumentStore__aggressive_cache_changes), + "Expected exactly 1 eviction listener in the dict", + ) + + def test_before_delete_fires_during_save_changes_not_during_delete(self): + """OnBeforeDelete for key-based session.delete() must fire during save_changes(), + not at the time delete() is called. Matches C# PrepareForEntitiesDeletion timing + and keeps both delete paths (key vs entity) consistent.""" + from ravendb.documents.session.document_session_operations.in_memory_document_session_operations import ( + InMemoryDocumentSessionOperations, + ) + from unittest.mock import MagicMock + from ravendb.documents.session.misc import SessionOptions, TransactionMode + from ravendb.documents.conventions import DocumentConventions + from ravendb.http.request_executor import RequestExecutor + import uuid as _uuid + + conventions = DocumentConventions() + re = MagicMock(spec=RequestExecutor) + re.conventions = conventions + re.conventions.use_optimistic_concurrency = False + re.conventions.max_number_of_requests_per_session = 32 + re.conventions.should_ignore_entity_changes = None + + store = MagicMock() + store.database = "test" + store.get_request_executor.return_value = re + + opts = SessionOptions() + opts.database = "test" + opts.request_executor = re + opts.no_tracking = False + opts.transaction_mode = TransactionMode.SINGLE_NODE + opts.disable_atomic_document_writes_in_cluster_wide_transaction = False + + session = InMemoryDocumentSessionOperations(store, _uuid.uuid4(), opts) + + fired = [] + + def on_before_delete(args): + fired.append(("save_changes", args.key)) + + session.add_before_delete(on_before_delete) + + # delete() must NOT fire the event immediately + session.delete("docs/test/1") + self.assertEqual([], fired, "before_delete must not fire during delete() — only during save_changes()") + + # prepare_for_save_changes() must fire the event + session.prepare_for_save_changes() + self.assertEqual( + [("save_changes", "docs/test/1")], fired, "before_delete must fire during prepare_for_save_changes()" + ) + + def test_might_have_been_modified_returns_true_when_item_is_none(self): + """might_have_been_modified must return True for an empty ReleaseCacheItem, + not raise AttributeError. Mirrors the same None-guard that already existed on age.""" + from ravendb.http.http_cache import ReleaseCacheItem + + empty = ReleaseCacheItem() + self.assertIsNone(empty.item) + # An empty ReleaseCacheItem must handle item=None without raising. + self.assertTrue(empty.might_have_been_modified) + + def test_no_caching_session_bypasses_aggressive_cache(self): + """When session.no_caching is True, aggressive cache must not short-circuit execute(). + The first condition in the guard — 'not no_caching' — must block the path even when + all other conditions (caching active, item fresh, etc.) are satisfied.""" + from ravendb.http.misc import AggressiveCacheMode + + executor = self._make_executor_with_caching(AggressiveCacheMode.TRACK_CHANGES) + cached_item, cached_value = self._make_cached_item(might_have_been_modified=False) + command = self._make_command(can_cache_aggressively=True) + + # All other conditions hold, but no_caching=True must block the cache hit + self.assertFalse( + self._check_aggressive_cache_guard(executor, cached_item, cached_value, command, no_caching=True) + ) + # Sanity: same inputs with no_caching=False do produce a cache hit + self.assertTrue( + self._check_aggressive_cache_guard(executor, cached_item, cached_value, command, no_caching=False) + ) + + def test_http_cache_getitem_returns_cached_item(self): + """HttpCache.__getitem__ must return the stored HttpCacheItem, + not None.""" + from ravendb.http.http_cache import HttpCache, HttpCacheItem + + cache = HttpCache() + cache.set("http://host/docs/1", "cv-42", '{"Name":"Alice"}') + + item = cache["http://host/docs/1"] + + self.assertIsNotNone(item) + self.assertIsInstance(item, HttpCacheItem) + self.assertEqual("cv-42", item.change_vector) + self.assertEqual('{"Name":"Alice"}', item.payload) + + def test_session_remove_before_query_removes_not_adds(self): + """remove_before_query on a session must remove the handler, not append a duplicate. + This keeps handlers unregisterable from an open session.""" + from ravendb.documents.session.document_session_operations.in_memory_document_session_operations import ( + InMemoryDocumentSessionOperations, + ) + from unittest.mock import MagicMock + from ravendb.documents.session.misc import SessionOptions, TransactionMode + from ravendb.documents.conventions import DocumentConventions + from ravendb.http.request_executor import RequestExecutor + import uuid as _uuid + + conventions = DocumentConventions() + re = MagicMock(spec=RequestExecutor) + re.conventions = conventions + re.conventions.use_optimistic_concurrency = False + re.conventions.max_number_of_requests_per_session = 32 + re.conventions.should_ignore_entity_changes = None + + store = MagicMock() + store.database = "test" + store.get_request_executor.return_value = re + + opts = SessionOptions() + opts.database = "test" + opts.request_executor = re + opts.no_tracking = False + opts.transaction_mode = TransactionMode.SINGLE_NODE + opts.disable_atomic_document_writes_in_cluster_wide_transaction = False + + session = InMemoryDocumentSessionOperations(store, _uuid.uuid4(), opts) + + handler = lambda args: None + session.add_before_query(handler) + self.assertEqual(1, len(session._before_query)) + + session.remove_before_query(handler) + self.assertEqual(0, len(session._before_query), "remove_before_query must remove the handler, not add it again") + + def test_session_remove_before_delete_removes_handler(self): + """remove_before_delete on a session must remove the handler. + The public method name should follow the standard remove_* convention used by + the session and store-level APIs.""" + from ravendb.documents.session.document_session_operations.in_memory_document_session_operations import ( + InMemoryDocumentSessionOperations, + ) + from unittest.mock import MagicMock + from ravendb.documents.session.misc import SessionOptions, TransactionMode + from ravendb.documents.conventions import DocumentConventions + from ravendb.http.request_executor import RequestExecutor + import uuid as _uuid + + conventions = DocumentConventions() + re = MagicMock(spec=RequestExecutor) + re.conventions = conventions + re.conventions.use_optimistic_concurrency = False + re.conventions.max_number_of_requests_per_session = 32 + re.conventions.should_ignore_entity_changes = None + + store = MagicMock() + store.database = "test" + store.get_request_executor.return_value = re + + opts = SessionOptions() + opts.database = "test" + opts.request_executor = re + opts.no_tracking = False + opts.transaction_mode = TransactionMode.SINGLE_NODE + opts.disable_atomic_document_writes_in_cluster_wide_transaction = False + + session = InMemoryDocumentSessionOperations(store, _uuid.uuid4(), opts) + + handler = lambda args: None + session.add_before_delete(handler) + self.assertEqual(1, len(session._before_delete)) + + session.remove_before_delete(handler) + self.assertEqual(0, len(session._before_delete)) + self.assertFalse(hasattr(session, "remove_before_delete_entity"), "remove_before_delete_entity must not exist") + + +class TestStoreApiIntegration(TestBase): + """Integration tests — require a live server.""" + + def setUp(self): + super().setUp() + self.store = self.get_document_store() + + def tearDown(self): + super().tearDown() + self.store.close() + + # --- store events --- + + def test_add_before_store_fires_on_save(self): + fired = [] + + def on_before_store(args): + fired.append(args.entity) + + self.store.add_before_store(on_before_store) + + class Doc: + def __init__(self, name): + self.name = name + + with self.store.open_session() as session: + session.store(Doc("hello"), "docs/1") + session.save_changes() + + self.assertEqual(1, len(fired)) + self.store.remove_before_store(on_before_store) + + def test_before_store_event_args_entity_is_the_stored_object(self): + """args.entity in OnBeforeStore must be the exact object passed to session.store(), + not a copy. Mirrors C# Events.cs:Before_Store_Listener which accesses e.Entity.""" + fired_entities = [] + + def on_before_store(args): + fired_entities.append(args.entity) + + self.store.add_before_store(on_before_store) + + class Doc: + def __init__(self, name): + self.name = name + + doc = Doc("identity-check") + with self.store.open_session() as session: + session.store(doc, "docs/event-args/1") + session.save_changes() + + self.assertEqual(1, len(fired_entities)) + self.assertIs(doc, fired_entities[0], "args.entity must be the same object, not a copy") + self.store.remove_before_store(on_before_store) + + def test_add_after_save_changes_fires(self): + fired = [] + + def on_after(args): + fired.append(True) + + self.store.add_after_save_changes(on_after) + + class Doc: + pass + + with self.store.open_session() as session: + session.store(Doc(), "docs/2") + session.save_changes() + + self.assertGreater(len(fired), 0) + self.store.remove_after_save_changes(on_after) + + def test_add_before_delete_fires(self): + fired = [] + + def on_before_delete(args): + fired.append(True) + + self.store.add_before_delete(on_before_delete) + + class Doc: + pass + + with self.store.open_session() as session: + session.store(Doc(), "docs/3") + session.save_changes() + + with self.store.open_session() as session: + session.delete("docs/3") + session.save_changes() + + self.assertEqual(1, len(fired)) + self.store.remove_before_delete(on_before_delete) + + def test_before_delete_event_args_key_matches_deleted_key(self): + """args.key in OnBeforeDelete must equal the key passed to session.delete(str). + Mirrors C# BeforeDeleteEventArgs.DocumentId check.""" + fired_keys = [] + + def on_before_delete(args): + fired_keys.append(args.key) + + self.store.add_before_delete(on_before_delete) + + class Doc: + pass + + with self.store.open_session() as session: + session.store(Doc(), "docs/event-args/del") + session.save_changes() + + with self.store.open_session() as session: + session.delete("docs/event-args/del") + session.save_changes() + + self.assertEqual(["docs/event-args/del"], fired_keys) + self.store.remove_before_delete(on_before_delete) + + def test_add_before_query_fires(self): + fired = [] + + def on_before_query(args): + fired.append(True) + + self.store.add_before_query(on_before_query) + + with self.store.open_session() as session: + list(session.query()) + + self.assertEqual(1, len(fired)) + self.store.remove_before_query(on_before_query) + + # --- aggressive cache --- + + def test_aggressively_cache_for_sets_options_on_executor(self): + with self.store.aggressively_cache_for(datetime.timedelta(minutes=5)): + request_executor = self.store.get_request_executor() + self.assertIsNotNone(request_executor.aggressive_caching) + self.assertIsNone(request_executor.aggressive_caching) + + def test_disable_aggressive_caching_clears_options(self): + with self.store.aggressively_cache_for(datetime.timedelta(minutes=5)): + request_executor = self.store.get_request_executor() + self.assertIsNotNone(request_executor.aggressive_caching) + with self.store.disable_aggressive_caching(): + self.assertIsNone(request_executor.aggressive_caching) + self.assertIsNotNone(request_executor.aggressive_caching) + + # --- combined: events + aggressive cache --- + + def test_before_store_event_fires_inside_cache_context(self): + """Store events still fire correctly while aggressive caching is active.""" + fired = [] + + def on_before_store(args): + fired.append(args.entity) + + self.store.add_before_store(on_before_store) + + class Doc: + def __init__(self, name): + self.name = name + + with self.store.aggressively_cache_for(datetime.timedelta(minutes=5)): + with self.store.open_session() as session: + session.store(Doc("cached_doc"), "docs/combined/1") + session.save_changes() + + self.assertEqual(1, len(fired)) + self.store.remove_before_store(on_before_store) + + def test_event_handlers_survive_cache_context_exit(self): + """Event handlers registered before aggressively_cache_for still work after the context exits.""" + fired = [] + + def on_after(args): + fired.append(True) + + self.store.add_after_save_changes(on_after) + + class Doc: + pass + + # Fire once inside cache context + with self.store.aggressively_cache_for(datetime.timedelta(minutes=1)): + with self.store.open_session() as session: + session.store(Doc(), "docs/combined/2") + session.save_changes() + + # Fire again after cache context is gone + with self.store.open_session() as session: + session.store(Doc(), "docs/combined/3") + session.save_changes() + + self.assertEqual(2, len(fired)) + self.store.remove_after_save_changes(on_after) + + def test_cache_context_does_not_affect_event_registration(self): + """Entering/exiting the cache context does not remove registered event handlers.""" + store = self.store + captured = [] + + def on_before_query(args): + captured.append(True) + + store.add_before_query(on_before_query) + + with store.aggressively_cache_for(datetime.timedelta(minutes=5)): + pass # enter and exit immediately + + # Handler should still be registered + with store.open_session() as session: + list(session.query()) + + self.assertEqual(1, len(captured)) + store.remove_before_query(on_before_query) + + +if __name__ == "__main__": + unittest.main()