vortexpy 6.2.4__tar.gz → 6.2.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {vortexpy-6.2.4 → vortexpy-6.2.6}/PKG-INFO +1 -1
- {vortexpy-6.2.4 → vortexpy-6.2.6}/pyproject.toml +1 -1
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/DeferUtil.py +16 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexServer.py +21 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexServer_test.py +38 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexSession.py +7 -2
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexUtil.py +105 -1
- vortexpy-6.2.6/vortex/VortexUtil_test.py +292 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/__init__.py +1 -1
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/handler/TupleDataObservableHandler.py +34 -5
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/handler/TupleDataObservableHandler_test.py +46 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/handler/TupleDataObservableProxyHandler.py +3 -2
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/twisted_runtime_metrics.py +17 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortexpy.egg-info/PKG-INFO +1 -1
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortexpy.egg-info/SOURCES.txt +1 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/README.md +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/example/TwistedDeferredTimeout.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/example/__init__.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/example/run_http_upgraded_websocket_ping_client.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/example/run_http_upgraded_websocket_ping_server.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/example/run_http_upgraded_websocket_rpc_test_client.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/example/run_http_upgraded_websocket_rpc_test_server.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/example/run_websocket_rpc_test_client.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/example/run_websocket_rpc_test_server.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/example/setup_example_logging.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/example/store_and_load_json.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/setup.cfg +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/DataWrapTuple.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/DeferUtil_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/Jsonable.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/MtlsAuthorizedClientRegistry.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/MtlsAuthorizedClientRegistry_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/MtlsServerSslOptions.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/Payload.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/PayloadEndpoint.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/PayloadEndpointTest.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/PayloadEnvelope.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/PayloadFilterKeys.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/PayloadIO.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/PayloadPriority.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/PayloadResponse.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/PayloadResponseTest.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/PayloadResponse_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/PayloadTest.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/PerformTestActionTuple.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/SerialiseUtil.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/SerialiseUtil_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/TestTuple.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/Tuple.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/TupleAction.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/TupleBson.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/TupleBson_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/TupleDataForTest.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/TupleDocumentConverter.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/TuplePerfTest.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/TuplePolymorphicTupleTypeFieldArgTest.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/TupleRestful.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/TupleRestful_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/TupleSelector.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/TupleTest.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexABC.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexAccessControlContextABC.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexAuthenticatorABC.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexClientHttp.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexClientHttpTest.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexClientTcp.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexClientWebsocketFactory.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexConnectionABC.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexEndpointPermission.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexFactory.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexFactory_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexPayloadProtocol.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexPermission.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexPermissionError.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexServerConnection.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexServerConnection_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexServerHttpResource.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexServerTcp.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexServerTcp_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexServerWebsocket.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexServerWebsocket_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexSession_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexTcpMemoryLeakPayloadTest.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexTcpMemoryLeakRawTest.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexTest.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexUserDetailsABC.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexWritePushProducer.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/VortexWritePushProducer_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/data_loader/TupleDataLiveLockManager.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/data_loader/TupleDataLiveLockManager_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/data_loader/TupleDataLoader.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/data_loader/TupleDataLoaderDelegate.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/data_loader/TupleDataLoaderTupleABC.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/data_loader/TupleDataLoaderTuples.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/data_loader/__init__.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/event_bus/TupleChangeEventABC.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/event_bus/TupleChangeEventBus.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/event_bus/TupleChangeEventBusObserverABC.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/event_bus/__init__.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/handler/TupleActionProcessor.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/handler/TupleActionProcessorProxy.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/handler/TupleActionProcessorTestHandler.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/handler/TupleActionProcessor_shutdown_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/handler/TupleActionProcessor_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/handler/TupleActionPushOffline.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/handler/TupleActionPushOffline_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/handler/TupleDataAccessGranterABC.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/handler/TupleDataActionClient.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/handler/TupleDataActionClient_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/handler/TupleDataObservableCache.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/handler/TupleDataObservableCache_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/handler/TupleDataObservableProxyHandler_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/handler/TupleDataObservableTestHandler.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/handler/TupleDataObserverClient.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/handler/TupleDataOfflineObserverService.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/handler/TupleDataOfflineObserverService_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/handler/TupleDataProviderABC.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/handler/VortexJSTupleLoaderTestHandler.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/handler/__init__.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/mypy_plugin.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/payload_many_tuples_benchmark_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/py.typed +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/restful/GzippedDataHttpClient.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/restful/GzippedDataHttpClientTest.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/restful/GzippedPayloadHttpClient.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/restful/RestfulHttpClient.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/restful/RestfulResource.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/restful/__init__.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/restful/restful_http_client_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/restful/restful_resource_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/rpc/RPC.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/rpc/RPCTest.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/rpc/RPC_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/rpc/__init__.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/sqla_orm/OrmCrudHandler.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/sqla_orm/TupleProviderForDb.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/sqla_orm/VortexTupleJson.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/sqla_orm/__init__.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/storage/TupleActionStorageSqlite.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/storage/TupleOfflineStorageNameService.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/storage/TupleOfflineStorageSecureService.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/storage/TupleOfflineStorageSecureService_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/storage/TupleOfflineStorageService.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/storage/TupleStorageServiceABC.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/storage/TupleStorageSqlite.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/storage/TupleStorageSqliteService.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/storage/TupleStorageSqliteService_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/storage/__init__.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/tuple_json_schema/__init__.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/tuple_json_schema/constants.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/tuple_json_schema/json_schema_node_tuple.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/tuple_json_schema/tuple_json_schema_builder.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/tuple_json_schema/tuple_json_schema_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/tuple_json_schema/tuple_json_schema_test_tuples.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/tuple_test_fixtures.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortex/twisted_runtime_metrics_test.py +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortexpy.egg-info/dependency_links.txt +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortexpy.egg-info/entry_points.txt +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortexpy.egg-info/requires.txt +0 -0
- {vortexpy-6.2.4 → vortexpy-6.2.6}/vortexpy.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "vortexpy"
|
|
7
|
-
version = "6.2.
|
|
7
|
+
version = "6.2.6"
|
|
8
8
|
license = {text = "MIT"}
|
|
9
9
|
authors = [{name = "Flying Ion Pty Ltd", email = "contact@flyingion.com"}]
|
|
10
10
|
description = "Flying Ion's observable, routable, data serialisation and transport code."
|
|
@@ -16,6 +16,7 @@ import twisted
|
|
|
16
16
|
from twisted.internet import reactor
|
|
17
17
|
from twisted.internet.defer import (
|
|
18
18
|
maybeDeferred,
|
|
19
|
+
CancelledError,
|
|
19
20
|
Deferred,
|
|
20
21
|
inlineCallbacks,
|
|
21
22
|
ensureDeferred,
|
|
@@ -239,6 +240,17 @@ def deferToThreadWrapWithLogger(
|
|
|
239
240
|
)
|
|
240
241
|
|
|
241
242
|
def handleFailure(failure):
|
|
243
|
+
if failure.check(CancelledError):
|
|
244
|
+
# Cancellation is designed control flow (for example a
|
|
245
|
+
# limitConcurrency timeout quarantining the thread), not
|
|
246
|
+
# a threaded call failure.
|
|
247
|
+
loggerArg.debug(
|
|
248
|
+
"Threaded call cancelled in %s.%s",
|
|
249
|
+
funcToWrap.__module__,
|
|
250
|
+
funcToWrap.__qualname__,
|
|
251
|
+
)
|
|
252
|
+
return True if consumeError else failure
|
|
253
|
+
|
|
242
254
|
if logException:
|
|
243
255
|
return vortexLogFailure(
|
|
244
256
|
failure, loggerArg, consumeError=consumeError
|
|
@@ -253,6 +265,10 @@ def deferToThreadWrapWithLogger(
|
|
|
253
265
|
return True if consumeError else failure
|
|
254
266
|
|
|
255
267
|
d: Deferred[_R] = deferToThread(timedFuncToRun, *args, **kwargs)
|
|
268
|
+
# Marks this Deferred as threaded for limitConcurrency: a timed
|
|
269
|
+
# out threaded call cannot be killed, so the limiter accumulates
|
|
270
|
+
# marked Deferreds in its quarantine registry.
|
|
271
|
+
d._isVortexThreadedCall = True # type: ignore[attr-defined]
|
|
256
272
|
d.addCallback(checkForInlineCallbackYield)
|
|
257
273
|
d.addErrback(handleFailure)
|
|
258
274
|
return d
|
|
@@ -27,6 +27,7 @@ from zope.interface.declarations import implementer
|
|
|
27
27
|
from .DeferUtil import isMainThread
|
|
28
28
|
from .DeferUtil import vortexLogFailure
|
|
29
29
|
from .VortexSession import IVortexSession
|
|
30
|
+
from .VortexSession import VortexTcpSession
|
|
30
31
|
from .PayloadEnvelope import PayloadEnvelope
|
|
31
32
|
from .PayloadEnvelope import VortexMsgList
|
|
32
33
|
from .PayloadFilterKeys import rapuiServerEcho
|
|
@@ -394,3 +395,23 @@ class VortexSession(object):
|
|
|
394
395
|
|
|
395
396
|
|
|
396
397
|
registerAdapter(VortexSession, Session, VortexSessionI)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _tcpSessionVortexSessionIAdapter(session: VortexTcpSession) -> VortexSession:
|
|
401
|
+
"""Adapt a :class:`VortexTcpSession` to :class:`VortexSessionI`.
|
|
402
|
+
|
|
403
|
+
Twisted ``Session`` instances cache adapters through
|
|
404
|
+
``Componentized.__conform__``; the TCP stub is not a ``Componentized``,
|
|
405
|
+
so the adapter is cached on the stub's own component store — without
|
|
406
|
+
this, every adaptation would return a fresh adapter with an empty
|
|
407
|
+
``connections`` dict and ``connectionOpened`` registrations would be
|
|
408
|
+
lost.
|
|
409
|
+
"""
|
|
410
|
+
adapter = session.getComponent(VortexSessionI)
|
|
411
|
+
if adapter is None:
|
|
412
|
+
adapter = VortexSession(session)
|
|
413
|
+
session.setComponent(VortexSessionI, adapter)
|
|
414
|
+
return adapter
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
registerAdapter(_tcpSessionVortexSessionIAdapter, VortexTcpSession, VortexSessionI)
|
|
@@ -14,6 +14,7 @@ from vortex.VortexServer import (
|
|
|
14
14
|
VortexSessionI,
|
|
15
15
|
)
|
|
16
16
|
from vortex.VortexServerWebsocket import VortexWebsocketServerProtocol
|
|
17
|
+
from vortex.VortexSession import VortexTcpSession
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
class _MockServerConnection:
|
|
@@ -217,6 +218,43 @@ class VortexSessionAdapterTimeoutTest(SynchronousTestCase):
|
|
|
217
218
|
self.assertEqual(session.sessionTimeout, HEART_BEAT_TIMEOUT)
|
|
218
219
|
|
|
219
220
|
|
|
221
|
+
class VortexTcpSessionAdapterTest(SynchronousTestCase):
|
|
222
|
+
def testTcpStubAdaptsToVortexSessionI(self) -> None:
|
|
223
|
+
stub = VortexTcpSession(ip="1.2.3.4", vortexName="remote")
|
|
224
|
+
|
|
225
|
+
adapter = VortexSessionI(stub)
|
|
226
|
+
|
|
227
|
+
self.assertEqual(adapter.connections, {})
|
|
228
|
+
|
|
229
|
+
def testTcpStubAdaptationIsCached(self) -> None:
|
|
230
|
+
stub = VortexTcpSession(ip="1.2.3.4", vortexName="remote")
|
|
231
|
+
|
|
232
|
+
first = VortexSessionI(stub)
|
|
233
|
+
first.connections["uuid-a"] = object()
|
|
234
|
+
|
|
235
|
+
self.assertIs(VortexSessionI(stub), first)
|
|
236
|
+
self.assertIn("uuid-a", VortexSessionI(stub).connections)
|
|
237
|
+
|
|
238
|
+
def testTcpStubTimeoutRaisedToHeartbeatFloor(self) -> None:
|
|
239
|
+
stub = VortexTcpSession(ip="1.2.3.4", vortexName="remote")
|
|
240
|
+
stub.sessionTimeout = 5
|
|
241
|
+
|
|
242
|
+
VortexSessionI(stub)
|
|
243
|
+
|
|
244
|
+
self.assertEqual(stub.sessionTimeout, HEART_BEAT_TIMEOUT)
|
|
245
|
+
|
|
246
|
+
def testConnectionOpenedAndClosedWithTcpStub(self) -> None:
|
|
247
|
+
vortexServer = VortexServer("test-server")
|
|
248
|
+
stub = VortexTcpSession(ip="1.2.3.4", vortexName="remote")
|
|
249
|
+
conn = _MockServerConnection(vortexServer, "uuid-a", "remote", stub)
|
|
250
|
+
|
|
251
|
+
vortexServer.connectionOpened(stub, conn)
|
|
252
|
+
self.assertIn("uuid-a", VortexSessionI(stub).connections)
|
|
253
|
+
|
|
254
|
+
vortexServer.connectionClosed(conn)
|
|
255
|
+
self.assertNotIn("uuid-a", VortexSessionI(stub).connections)
|
|
256
|
+
|
|
257
|
+
|
|
220
258
|
class VortexWebsocketServerProtocolConnectionLostTest(SynchronousTestCase):
|
|
221
259
|
def testConnectionLostTwiceDoesNotRaise(self) -> None:
|
|
222
260
|
vortex_server = VortexServer("test-server")
|
|
@@ -65,12 +65,17 @@ class VortexTcpSession:
|
|
|
65
65
|
|
|
66
66
|
TCP connections have no HTTP session. This stub exposes the subset of
|
|
67
67
|
the Twisted ``Session`` API that vortex code uses (``uid``, ``touch``,
|
|
68
|
-
``notifyOnExpire``, ``expire``, ``getComponent``, ``setComponent
|
|
69
|
-
the rest of the codebase can treat all sessions
|
|
68
|
+
``notifyOnExpire``, ``expire``, ``getComponent``, ``setComponent``,
|
|
69
|
+
``sessionTimeout``) so the rest of the codebase can treat all sessions
|
|
70
|
+
uniformly.
|
|
70
71
|
|
|
71
72
|
A :class:`VortexSession` is attached immediately on construction.
|
|
72
73
|
"""
|
|
73
74
|
|
|
75
|
+
# Mirrors twisted.web.server.Session.sessionTimeout; the
|
|
76
|
+
# VortexSessionI adapter factory in VortexServer reads and raises it.
|
|
77
|
+
sessionTimeout = 900
|
|
78
|
+
|
|
74
79
|
def __init__(self, ip: str, vortexName: str):
|
|
75
80
|
self.uid: bytes = uuid4().bytes
|
|
76
81
|
self._expireCallbacks = []
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
# Support : contact@flyingion.com
|
|
8
8
|
|
|
9
9
|
import json
|
|
10
|
+
from collections import deque
|
|
10
11
|
from collections import namedtuple
|
|
11
12
|
from datetime import datetime
|
|
12
13
|
from datetime import timedelta
|
|
@@ -20,10 +21,10 @@ from uuid import uuid4
|
|
|
20
21
|
|
|
21
22
|
import pytz
|
|
22
23
|
from twisted.internet import reactor
|
|
24
|
+
from twisted.internet.defer import CancelledError
|
|
23
25
|
from twisted.internet.defer import Deferred
|
|
24
26
|
from twisted.internet.defer import fail
|
|
25
27
|
from twisted.internet.defer import maybeDeferred
|
|
26
|
-
from twisted.python.failure import Failure
|
|
27
28
|
|
|
28
29
|
from vortex.PayloadEnvelope import PayloadEnvelope
|
|
29
30
|
from vortex.PayloadEnvelope import VortexMsgList
|
|
@@ -37,6 +38,20 @@ _LimitConcurrencyArgsTuple = namedtuple(
|
|
|
37
38
|
("deferred", "funcSelf", "args", "kwargs", "queuedTime"),
|
|
38
39
|
)
|
|
39
40
|
|
|
41
|
+
_LimitConcurrencyQuarantinedCallTuple = namedtuple(
|
|
42
|
+
"_LimitConcurrencyQuarantinedCallTuple",
|
|
43
|
+
("methodQualname", "callKey", "startDate", "timeoutDate", "deferred"),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class LimitConcurrencyTimeoutError(Exception):
|
|
48
|
+
"""Limit Concurrency Timeout Error
|
|
49
|
+
|
|
50
|
+
Raised on the caller's Deferred when a call started by
|
|
51
|
+
L{limitConcurrency} exceeds C{killOrQuarantineCallsLongerThanSeconds}
|
|
52
|
+
and is cancelled.
|
|
53
|
+
"""
|
|
54
|
+
|
|
40
55
|
|
|
41
56
|
def _dedupProcessorCallKeys(
|
|
42
57
|
payloadEnvelope: PayloadEnvelope,
|
|
@@ -56,6 +71,7 @@ def limitConcurrency(
|
|
|
56
71
|
concurrency: int,
|
|
57
72
|
logTaskTimeGreaterThanSeconds=5.0,
|
|
58
73
|
deduplicateBasedOnKwArgsKey: Optional[Callable] = None,
|
|
74
|
+
killOrQuarantineCallsLongerThanSeconds: Optional[float] = None,
|
|
59
75
|
):
|
|
60
76
|
"""Limit Concurrency to a call, decorator
|
|
61
77
|
|
|
@@ -66,6 +82,17 @@ def limitConcurrency(
|
|
|
66
82
|
of strings. If this argument is provided,
|
|
67
83
|
then duplicate calls with the same values for these arguments will
|
|
68
84
|
be deduplicated. Duplicate calls will use the newest set of arguments.
|
|
85
|
+
:param killOrQuarantineCallsLongerThanSeconds: When set, any started call
|
|
86
|
+
still running after this many seconds is cancelled: @inlineCallbacks
|
|
87
|
+
callees are killed (CancelledError reaches the generator), threaded
|
|
88
|
+
callees are quarantined (the caller's Deferred is released while the
|
|
89
|
+
thread runs on). An error is logged, the limiter's alarm count is
|
|
90
|
+
incremented in TwistedRuntimeMetrics, the caller's Deferred
|
|
91
|
+
errbacks with LimitConcurrencyTimeoutError. Timed out threaded
|
|
92
|
+
calls (deferToThreadWrapWithLogger Deferreds) are accumulated with
|
|
93
|
+
their Deferred reference in the limiter's bounded _quarantinedCalls
|
|
94
|
+
deque for manhole inspection; killed @inlineCallbacks callees die
|
|
95
|
+
and are not registered.
|
|
69
96
|
|
|
70
97
|
# As of python 3.6, dicts are ordered.
|
|
71
98
|
|
|
@@ -97,6 +124,8 @@ def limitConcurrency(
|
|
|
97
124
|
self._callQueue: dict[Any, _LimitConcurrencyArgsTuple] = {}
|
|
98
125
|
self._runningCount = 0
|
|
99
126
|
self._processedCount = 0
|
|
127
|
+
self._callTimeoutCount = 0
|
|
128
|
+
self._quarantinedCalls: deque = deque(maxlen=50)
|
|
100
129
|
self._lastLogTime = None
|
|
101
130
|
self._lastQueueProcessingDelta = None
|
|
102
131
|
TwistedRuntimeMetrics._collector()._registerLimitConcurrencyLimiter(
|
|
@@ -146,12 +175,87 @@ def limitConcurrency(
|
|
|
146
175
|
)
|
|
147
176
|
d = maybeDeferred(callWithArgs)
|
|
148
177
|
d.addBoth(self._callComplete, now, queueWaitMs)
|
|
178
|
+
|
|
179
|
+
if killOrQuarantineCallsLongerThanSeconds is not None and not d.called:
|
|
180
|
+
self._utilArmCallTimeout(
|
|
181
|
+
d, killOrQuarantineCallsLongerThanSeconds, nextKey, now
|
|
182
|
+
)
|
|
183
|
+
|
|
149
184
|
d.chainDeferred(args.deferred)
|
|
150
185
|
|
|
151
186
|
self._logQueueSize()
|
|
152
187
|
self._f.running = self._runningCount
|
|
153
188
|
self._f.waiting = len(self._callQueue)
|
|
154
189
|
|
|
190
|
+
def _utilArmCallTimeout(self, d, killSeconds, callKey, startDate):
|
|
191
|
+
def onTimeout(d=d, callKey=callKey, startDate=startDate):
|
|
192
|
+
if d.called:
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
# A deferToThreadWrapWithLogger-marked Deferred means the
|
|
196
|
+
# callee is a thread that cannot be killed: it is
|
|
197
|
+
# quarantined, and the Deferred is accumulated in the
|
|
198
|
+
# bounded registry for manhole inspection. maybeDeferred
|
|
199
|
+
# passes a threaded callee's Deferred through as d; an
|
|
200
|
+
# @inlineCallbacks callee pauses d on it as d.result.
|
|
201
|
+
# Killed @inlineCallbacks callees die and are not registered.
|
|
202
|
+
if getattr(d, "_isVortexThreadedCall", False):
|
|
203
|
+
quarantinedDeferred = d
|
|
204
|
+
else:
|
|
205
|
+
awaitedDeferred = getattr(d, "result", None)
|
|
206
|
+
quarantinedDeferred = (
|
|
207
|
+
awaitedDeferred
|
|
208
|
+
if getattr(awaitedDeferred, "_isVortexThreadedCall", False)
|
|
209
|
+
else None
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
self._callTimeoutCount += 1
|
|
213
|
+
if quarantinedDeferred is not None:
|
|
214
|
+
self._quarantinedCalls.append(
|
|
215
|
+
_LimitConcurrencyQuarantinedCallTuple(
|
|
216
|
+
methodQualname=self._methodQualname,
|
|
217
|
+
callKey=callKey,
|
|
218
|
+
startDate=startDate,
|
|
219
|
+
timeoutDate=datetime.now(pytz.utc),
|
|
220
|
+
deferred=quarantinedDeferred,
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
TwistedRuntimeMetrics._collector()._recordLimitConcurrencyCallTimeout(
|
|
224
|
+
self._methodQualname
|
|
225
|
+
)
|
|
226
|
+
decoratorLogger.error(
|
|
227
|
+
"%s call exceeded killOrQuarantineCallsLongerThanSeconds"
|
|
228
|
+
" of %ss (key=%s, timeout count=%s),"
|
|
229
|
+
" killing or quarantining the call",
|
|
230
|
+
self._methodQualname,
|
|
231
|
+
killSeconds,
|
|
232
|
+
callKey,
|
|
233
|
+
self._callTimeoutCount,
|
|
234
|
+
)
|
|
235
|
+
d.cancel()
|
|
236
|
+
|
|
237
|
+
timeoutCall = reactor.callLater(killSeconds, onTimeout)
|
|
238
|
+
|
|
239
|
+
def cancelTimeoutCall(result):
|
|
240
|
+
if timeoutCall.active():
|
|
241
|
+
timeoutCall.cancel()
|
|
242
|
+
return result
|
|
243
|
+
|
|
244
|
+
def convertCancelledToTimeout(failure):
|
|
245
|
+
# The watchdog's own `called` flag marks whether this
|
|
246
|
+
# cancellation came from it: DelayedCall.called is only set
|
|
247
|
+
# once onTimeout begins running.
|
|
248
|
+
if not timeoutCall.called:
|
|
249
|
+
return failure
|
|
250
|
+
failure.trap(CancelledError)
|
|
251
|
+
raise LimitConcurrencyTimeoutError(
|
|
252
|
+
"%s call exceeded killOrQuarantineCallsLongerThanSeconds"
|
|
253
|
+
" of %ss" % (self._methodQualname, killSeconds)
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
d.addBoth(cancelTimeoutCall)
|
|
257
|
+
d.addErrback(convertCancelledToTimeout)
|
|
258
|
+
|
|
155
259
|
def call(self, funcSelf, *args, **kwargs):
|
|
156
260
|
if deduplicateBasedOnKwArgsKey:
|
|
157
261
|
key = deduplicateBasedOnKwArgsKey(*args, **kwargs)
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# Copyright (c) 2016-2026 Flying Ion Pty Ltd
|
|
2
|
+
# This software is open source; the MIT license applies.
|
|
3
|
+
# See LICENSE for the full license text.
|
|
4
|
+
#
|
|
5
|
+
# Website : https://flyingion.com
|
|
6
|
+
# Support : contact@flyingion.com
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import gc
|
|
11
|
+
import logging
|
|
12
|
+
import threading
|
|
13
|
+
from collections.abc import Generator
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
from pytest_twisted import ensureDeferred # type: ignore[import-untyped]
|
|
17
|
+
from twisted.internet import threads
|
|
18
|
+
from twisted.internet.defer import CancelledError
|
|
19
|
+
from twisted.internet.defer import Deferred
|
|
20
|
+
from twisted.internet.defer import inlineCallbacks
|
|
21
|
+
from twisted.internet.task import Clock
|
|
22
|
+
from twisted.python.failure import Failure
|
|
23
|
+
|
|
24
|
+
from vortex import VortexUtil
|
|
25
|
+
from vortex.DeferUtil import deferToThreadWrapWithLogger
|
|
26
|
+
from vortex.VortexUtil import LimitConcurrencyTimeoutError
|
|
27
|
+
from vortex.VortexUtil import limitConcurrency
|
|
28
|
+
from vortex.twisted_runtime_metrics import TwistedRuntimeMetrics
|
|
29
|
+
from vortex.twisted_runtime_metrics_test import (
|
|
30
|
+
utilResetTwistedRuntimeMetricsSingletonForTests,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
_KILL_SECONDS = 10.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture(autouse=True)
|
|
39
|
+
def resetMetricsSingleton() -> Generator[None, None, None]:
|
|
40
|
+
utilResetTwistedRuntimeMetricsSingletonForTests()
|
|
41
|
+
yield
|
|
42
|
+
utilResetTwistedRuntimeMetricsSingletonForTests()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.fixture
|
|
46
|
+
def clock(monkeypatch: pytest.MonkeyPatch) -> Clock:
|
|
47
|
+
clock = Clock()
|
|
48
|
+
monkeypatch.setattr(VortexUtil, "reactor", clock)
|
|
49
|
+
return clock
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _SwitchableWorker:
|
|
53
|
+
"""One decorated method: parks forever or completes, by flag."""
|
|
54
|
+
|
|
55
|
+
def __init__(self) -> None:
|
|
56
|
+
self.behaviour = "stuck"
|
|
57
|
+
self.cancelledCount = 0
|
|
58
|
+
|
|
59
|
+
@limitConcurrency(logger, 1, killOrQuarantineCallsLongerThanSeconds=_KILL_SECONDS)
|
|
60
|
+
@inlineCallbacks
|
|
61
|
+
def work(self) -> Generator[Deferred, object, str]:
|
|
62
|
+
if self.behaviour == "fast":
|
|
63
|
+
return "done"
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
yield Deferred() # Never fires, the call is stuck
|
|
67
|
+
except CancelledError:
|
|
68
|
+
self.cancelledCount += 1
|
|
69
|
+
raise
|
|
70
|
+
return "unreachable"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _ConcurrencyTwoWorker:
|
|
74
|
+
def __init__(self) -> None:
|
|
75
|
+
self.cancelledCount = 0
|
|
76
|
+
|
|
77
|
+
@limitConcurrency(logger, 2, killOrQuarantineCallsLongerThanSeconds=_KILL_SECONDS)
|
|
78
|
+
@inlineCallbacks
|
|
79
|
+
def work(self) -> Generator[Deferred, object, str]:
|
|
80
|
+
try:
|
|
81
|
+
yield Deferred() # Never fires, the call is stuck
|
|
82
|
+
except CancelledError:
|
|
83
|
+
self.cancelledCount += 1
|
|
84
|
+
raise
|
|
85
|
+
return "unreachable"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _NoTimeoutWorker:
|
|
89
|
+
@limitConcurrency(logger, 1)
|
|
90
|
+
@inlineCallbacks
|
|
91
|
+
def work(self) -> Generator[Deferred, object, str]:
|
|
92
|
+
yield Deferred() # Never fires, the call is stuck
|
|
93
|
+
return "unreachable"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class _ThreadedWorker:
|
|
97
|
+
"""A limiter-wrapped call that blocks in a real threadpool thread."""
|
|
98
|
+
|
|
99
|
+
def __init__(self) -> None:
|
|
100
|
+
self.behaviour = "stuck"
|
|
101
|
+
self.threadStarted = threading.Event()
|
|
102
|
+
self.threadRelease = threading.Event()
|
|
103
|
+
self.threadFinished = threading.Event()
|
|
104
|
+
self.threadCompleted = False
|
|
105
|
+
|
|
106
|
+
@limitConcurrency(logger, 1, killOrQuarantineCallsLongerThanSeconds=_KILL_SECONDS)
|
|
107
|
+
def work(self) -> Deferred:
|
|
108
|
+
return self._threadedWork()
|
|
109
|
+
|
|
110
|
+
@deferToThreadWrapWithLogger(logger)
|
|
111
|
+
def _threadedWork(self) -> str:
|
|
112
|
+
if self.behaviour == "fast":
|
|
113
|
+
return "done"
|
|
114
|
+
|
|
115
|
+
self.threadStarted.set()
|
|
116
|
+
self.threadRelease.wait(timeout=30)
|
|
117
|
+
self.threadCompleted = True
|
|
118
|
+
self.threadFinished.set()
|
|
119
|
+
return "thread-done"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _callerFailure(caller: Deferred) -> Failure:
|
|
123
|
+
assert caller.called, "caller Deferred must have fired"
|
|
124
|
+
assert isinstance(caller.result, Failure), (
|
|
125
|
+
f"caller must errback, got {caller.result!r}"
|
|
126
|
+
)
|
|
127
|
+
return caller.result
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_stuck_call_times_out_kills_generator_and_frees_slot(clock) -> None:
|
|
131
|
+
worker = _SwitchableWorker()
|
|
132
|
+
|
|
133
|
+
caller1 = worker.work()
|
|
134
|
+
assert not caller1.called, "stuck call must not fire before the timeout"
|
|
135
|
+
|
|
136
|
+
clock.advance(_KILL_SECONDS)
|
|
137
|
+
|
|
138
|
+
failure = _callerFailure(caller1)
|
|
139
|
+
failure.trap(LimitConcurrencyTimeoutError)
|
|
140
|
+
assert worker.cancelledCount == 1, (
|
|
141
|
+
"CancelledError must reach the inlineCallbacks generator"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
worker.behaviour = "fast"
|
|
145
|
+
caller2 = worker.work()
|
|
146
|
+
assert caller2.called and caller2.result == "done", (
|
|
147
|
+
"the timed out call must free its concurrency slot"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
snapshot = TwistedRuntimeMetrics.snapshotAndResetWindow()
|
|
151
|
+
assert snapshot.limitConcurrencyCallTimeoutCount == 1, (
|
|
152
|
+
"one call timeout must be recorded in the alarm metrics"
|
|
153
|
+
)
|
|
154
|
+
assert snapshot.limitConcurrencyLastCallTimeoutMethodName is not None
|
|
155
|
+
assert snapshot.limitConcurrencyLastCallTimeoutMethodName.endswith("work")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_fast_call_is_not_timed_out(clock) -> None:
|
|
159
|
+
worker = _SwitchableWorker()
|
|
160
|
+
worker.behaviour = "fast"
|
|
161
|
+
|
|
162
|
+
caller = worker.work()
|
|
163
|
+
clock.advance(_KILL_SECONDS * 2)
|
|
164
|
+
|
|
165
|
+
assert caller.called and caller.result == "done", (
|
|
166
|
+
"fast call must complete normally and cancel its watchdog"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
snapshot = TwistedRuntimeMetrics.snapshotAndResetWindow()
|
|
170
|
+
assert snapshot.limitConcurrencyCallTimeoutCount == 0, (
|
|
171
|
+
"no timeout may be recorded for a fast call"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_timeouts_are_per_call_at_concurrency_two(clock) -> None:
|
|
176
|
+
worker = _ConcurrencyTwoWorker()
|
|
177
|
+
|
|
178
|
+
caller1 = worker.work()
|
|
179
|
+
caller2 = worker.work()
|
|
180
|
+
clock.advance(_KILL_SECONDS)
|
|
181
|
+
|
|
182
|
+
_callerFailure(caller1).trap(LimitConcurrencyTimeoutError)
|
|
183
|
+
_callerFailure(caller2).trap(LimitConcurrencyTimeoutError)
|
|
184
|
+
assert worker.cancelledCount == 2, "each started call must have its own watchdog"
|
|
185
|
+
|
|
186
|
+
snapshot = TwistedRuntimeMetrics.snapshotAndResetWindow()
|
|
187
|
+
assert snapshot.limitConcurrencyCallTimeoutCount == 2
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_no_timeout_when_parameter_not_set(clock) -> None:
|
|
191
|
+
worker = _NoTimeoutWorker()
|
|
192
|
+
|
|
193
|
+
caller = worker.work()
|
|
194
|
+
clock.advance(1000)
|
|
195
|
+
|
|
196
|
+
assert not caller.called, (
|
|
197
|
+
"without killOrQuarantineCallsLongerThanSeconds, calls must not be timed out"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
snapshot = TwistedRuntimeMetrics.snapshotAndResetWindow()
|
|
201
|
+
assert snapshot.limitConcurrencyCallTimeoutCount == 0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_timed_out_call_is_registered_for_manhole(clock) -> None:
|
|
205
|
+
# A function-local class gets its own limiter Wrap, isolated from the
|
|
206
|
+
# module-level workers' shared Wraps.
|
|
207
|
+
class _ManholeWorker:
|
|
208
|
+
@limitConcurrency(
|
|
209
|
+
logger, 1, killOrQuarantineCallsLongerThanSeconds=_KILL_SECONDS
|
|
210
|
+
)
|
|
211
|
+
@inlineCallbacks
|
|
212
|
+
def work(self) -> Generator[Deferred, object, str]:
|
|
213
|
+
yield Deferred() # Never fires, the call is stuck
|
|
214
|
+
return "unreachable"
|
|
215
|
+
|
|
216
|
+
worker = _ManholeWorker()
|
|
217
|
+
|
|
218
|
+
caller1 = worker.work()
|
|
219
|
+
clock.advance(_KILL_SECONDS)
|
|
220
|
+
_callerFailure(caller1).trap(LimitConcurrencyTimeoutError)
|
|
221
|
+
|
|
222
|
+
# Reach the limiter the way manhole inspection does in production.
|
|
223
|
+
wraps = [
|
|
224
|
+
o
|
|
225
|
+
for o in gc.get_objects()
|
|
226
|
+
if type(o).__name__ == "Wrap"
|
|
227
|
+
and hasattr(o, "_callQueue")
|
|
228
|
+
and any(
|
|
229
|
+
entry.methodQualname.endswith("_ManholeWorker.work")
|
|
230
|
+
for entry in o._quarantinedCalls
|
|
231
|
+
)
|
|
232
|
+
]
|
|
233
|
+
assert wraps == [], (
|
|
234
|
+
"a killed inlineCallbacks call must not be quarantine-registered;"
|
|
235
|
+
" only timed out threaded calls accumulate"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@pytest.mark.twisted
|
|
240
|
+
@ensureDeferred
|
|
241
|
+
async def test_threaded_call_is_quarantined_not_killed(clock) -> None:
|
|
242
|
+
worker = _ThreadedWorker()
|
|
243
|
+
|
|
244
|
+
caller1 = worker.work()
|
|
245
|
+
await threads.deferToThread(worker.threadStarted.wait, 5)
|
|
246
|
+
|
|
247
|
+
clock.advance(_KILL_SECONDS)
|
|
248
|
+
|
|
249
|
+
with pytest.raises(LimitConcurrencyTimeoutError):
|
|
250
|
+
await caller1
|
|
251
|
+
|
|
252
|
+
worker.behaviour = "fast"
|
|
253
|
+
result = await worker.work()
|
|
254
|
+
assert result == "done", (
|
|
255
|
+
"a quarantined call must be moved out of the limited count:"
|
|
256
|
+
" the freed slot serves the next call while the thread runs on"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
assert worker.threadCompleted is False, (
|
|
260
|
+
"the quarantined thread must still be blocked; it cannot be killed"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
worker.threadRelease.set()
|
|
264
|
+
await threads.deferToThread(worker.threadFinished.wait, 5)
|
|
265
|
+
assert worker.threadCompleted is True, (
|
|
266
|
+
"the late thread result must be discarded without AlreadyCalledError"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# The timed out threaded Deferred is accumulated in the limiter's
|
|
270
|
+
# quarantine registry, reachable the way manhole inspection works.
|
|
271
|
+
wraps = [
|
|
272
|
+
o
|
|
273
|
+
for o in gc.get_objects()
|
|
274
|
+
if type(o).__name__ == "Wrap"
|
|
275
|
+
and hasattr(o, "_callQueue")
|
|
276
|
+
and any(
|
|
277
|
+
entry.methodQualname.endswith("_ThreadedWorker.work")
|
|
278
|
+
for entry in o._quarantinedCalls
|
|
279
|
+
)
|
|
280
|
+
]
|
|
281
|
+
assert len(wraps) == 1, "one limiter must hold the quarantined thread"
|
|
282
|
+
|
|
283
|
+
entry = wraps[0]._quarantinedCalls[0]
|
|
284
|
+
assert entry.startDate <= entry.timeoutDate, (
|
|
285
|
+
"quarantine entry must carry the call start and timeout dates"
|
|
286
|
+
)
|
|
287
|
+
assert getattr(entry.deferred, "_isVortexThreadedCall", False), (
|
|
288
|
+
"the registry must hold the threaded Deferred itself"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
snapshot = TwistedRuntimeMetrics.snapshotAndResetWindow()
|
|
292
|
+
assert snapshot.limitConcurrencyCallTimeoutCount == 1
|
|
@@ -271,7 +271,14 @@ class TupleDataObservableHandler:
|
|
|
271
271
|
if d:
|
|
272
272
|
d.addErrback(vortexLogFailure, logger, consumeError=True)
|
|
273
273
|
|
|
274
|
-
@limitConcurrency(
|
|
274
|
+
@limitConcurrency(
|
|
275
|
+
logger,
|
|
276
|
+
25,
|
|
277
|
+
deduplicateBasedOnKwArgsKey=_dedupProcessorCallKeys,
|
|
278
|
+
# Legitimate slow queries run to ~2 minutes; 300s bounds slot
|
|
279
|
+
# starvation to 5 minutes without killing healthy calls.
|
|
280
|
+
killOrQuarantineCallsLongerThanSeconds=300.0,
|
|
281
|
+
)
|
|
275
282
|
def _processLimitedDecorated(
|
|
276
283
|
self,
|
|
277
284
|
payloadEnvelope: PayloadEnvelope,
|
|
@@ -394,10 +401,32 @@ class TupleDataObservableHandler:
|
|
|
394
401
|
vortexUuid
|
|
395
402
|
] = accessControlContext
|
|
396
403
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
404
|
+
try:
|
|
405
|
+
vortexMsg = yield self._createVortexMsg(
|
|
406
|
+
payloadEnvelope.filt,
|
|
407
|
+
tupleSelector,
|
|
408
|
+
)
|
|
409
|
+
except Exception as e:
|
|
410
|
+
# The caller is waiting on a PayloadResponse; without an error
|
|
411
|
+
# response it waits the full client timeout for every failure.
|
|
412
|
+
logger.error(
|
|
413
|
+
"Observable:%s provider failed for TupleSelector = %s,"
|
|
414
|
+
" sending error response",
|
|
415
|
+
self._observableName,
|
|
416
|
+
tsStr,
|
|
417
|
+
)
|
|
418
|
+
logger.exception(e)
|
|
419
|
+
# DatabaseError strings include the full SQL and parameters;
|
|
420
|
+
# the leading characters carry the actual error code.
|
|
421
|
+
errorText = f"{e.__class__.__name__}: {e}"[:500]
|
|
422
|
+
errorEnvelope = PayloadEnvelope(
|
|
423
|
+
filt=payloadEnvelope.filt,
|
|
424
|
+
result=f"Tuple provider failed on the server: {errorText}",
|
|
425
|
+
)
|
|
426
|
+
errorVortexMsg = yield errorEnvelope.toVortexMsgDefer()
|
|
427
|
+
yield sendResponse(errorVortexMsg)
|
|
428
|
+
return
|
|
429
|
+
|
|
401
430
|
try:
|
|
402
431
|
yield sendResponse(vortexMsg)
|
|
403
432
|
except Exception as e:
|