vortexpy 4.3.11__tar.gz → 4.3.13__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-4.3.11 → vortexpy-4.3.13}/PKG-INFO +1 -1
- {vortexpy-4.3.11 → vortexpy-4.3.13}/pyproject.toml +1 -1
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/DeferUtil.py +16 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexUtil.py +105 -1
- vortexpy-4.3.13/vortex/VortexUtil_test.py +292 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/__init__.py +1 -1
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/handler/TupleDataObservableHandler.py +40 -7
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/handler/TupleDataObservableHandler_test.py +46 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/handler/TupleDataObservableProxyHandler.py +3 -2
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/twisted_runtime_metrics.py +17 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortexpy.egg-info/PKG-INFO +1 -1
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortexpy.egg-info/SOURCES.txt +1 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/README.md +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/example/TwistedDeferredTimeout.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/example/__init__.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/example/run_http_upgraded_websocket_ping_client.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/example/run_http_upgraded_websocket_ping_server.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/example/run_http_upgraded_websocket_rpc_test_client.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/example/run_http_upgraded_websocket_rpc_test_server.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/example/run_websocket_rpc_test_client.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/example/run_websocket_rpc_test_server.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/example/setup_example_logging.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/example/store_and_load_json.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/setup.cfg +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/DataWrapTuple.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/DeferUtil_test.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/Jsonable.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/MtlsAuthorizedClientRegistry.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/MtlsAuthorizedClientRegistry_test.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/MtlsServerSslOptions.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/Payload.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/PayloadEndpoint.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/PayloadEndpointTest.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/PayloadEnvelope.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/PayloadFilterKeys.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/PayloadIO.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/PayloadPriority.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/PayloadResponse.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/PayloadResponseTest.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/PayloadResponse_test.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/PayloadTest.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/PerformTestActionTuple.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/SerialiseUtil.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/SerialiseUtil_test.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/TestTuple.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/Tuple.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/TupleAction.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/TupleBson.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/TupleBson_test.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/TupleDataForTest.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/TupleDocumentConverter.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/TuplePerfTest.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/TuplePolymorphicTupleTypeFieldArgTest.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/TupleRestful.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/TupleRestful_test.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/TupleSelector.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/TupleTest.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexABC.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexAccessControlContextABC.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexAuthenticatorABC.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexClientHttp.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexClientHttpTest.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexClientTcp.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexClientWebsocketFactory.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexConnectionABC.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexEndpointPermission.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexFactory.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexFactory_test.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexPayloadProtocol.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexPermission.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexPermissionError.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexServer.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexServerConnection.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexServerConnection_test.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexServerHttpResource.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexServerTcp.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexServerWebsocket.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexServerWebsocket_test.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexServer_test.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexSession.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexSession_test.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexTcpMemoryLeakPayloadTest.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexTcpMemoryLeakRawTest.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexTest.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexWritePushProducer.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/VortexWritePushProducer_test.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/data_loader/TupleDataLiveLockManager.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/data_loader/TupleDataLoader.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/data_loader/TupleDataLoaderDelegate.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/data_loader/TupleDataLoaderTupleABC.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/data_loader/TupleDataLoaderTuples.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/data_loader/__init__.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/event_bus/TupleChangeEventABC.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/event_bus/TupleChangeEventBus.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/event_bus/TupleChangeEventBusObserverABC.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/event_bus/__init__.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/handler/TupleActionProcessor.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/handler/TupleActionProcessorProxy.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/handler/TupleActionProcessorTestHandler.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/handler/TupleActionProcessor_shutdown_test.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/handler/TupleActionPushOffline.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/handler/TupleActionPushOffline_test.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/handler/TupleDataAccessGranterABC.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/handler/TupleDataActionClient.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/handler/TupleDataObservableCache.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/handler/TupleDataObservableCache_test.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/handler/TupleDataObservableProxyHandler_test.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/handler/TupleDataObservableTestHandler.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/handler/TupleDataObserverClient.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/handler/TupleDataProviderABC.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/handler/VortexJSTupleLoaderTestHandler.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/handler/__init__.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/mypy_plugin.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/payload_many_tuples_benchmark_test.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/py.typed +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/restful/GzippedDataHttpClient.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/restful/GzippedDataHttpClientTest.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/restful/GzippedPayloadHttpClient.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/restful/RestfulHttpClient.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/restful/RestfulResource.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/restful/__init__.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/restful/restful_http_client_test.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/restful/restful_resource_test.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/rpc/RPC.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/rpc/RPCTest.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/rpc/RPC_test.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/rpc/__init__.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/sqla_orm/OrmCrudHandler.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/sqla_orm/TupleProviderForDb.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/sqla_orm/VortexTupleJson.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/sqla_orm/__init__.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/storage/TupleActionStorageSqlite.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/storage/TupleStorageSqlite.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/storage/__init__.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/tuple_json_schema/__init__.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/tuple_json_schema/constants.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/tuple_json_schema/json_schema_node_tuple.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/tuple_json_schema/tuple_json_schema_builder.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/tuple_json_schema/tuple_json_schema_test.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/tuple_json_schema/tuple_json_schema_test_tuples.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/tuple_test_fixtures.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortex/twisted_runtime_metrics_test.py +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortexpy.egg-info/dependency_links.txt +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortexpy.egg-info/entry_points.txt +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/vortexpy.egg-info/requires.txt +0 -0
- {vortexpy-4.3.11 → vortexpy-4.3.13}/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 = "4.3.
|
|
7
|
+
version = "4.3.13"
|
|
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
|
|
@@ -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
|
|
@@ -244,7 +244,14 @@ class TupleDataObservableHandler:
|
|
|
244
244
|
if d:
|
|
245
245
|
d.addErrback(vortexLogFailure, logger, consumeError=True)
|
|
246
246
|
|
|
247
|
-
@limitConcurrency(
|
|
247
|
+
@limitConcurrency(
|
|
248
|
+
logger,
|
|
249
|
+
25,
|
|
250
|
+
deduplicateBasedOnKwArgsKey=_dedupProcessorCallKeys,
|
|
251
|
+
# Legitimate slow queries run to ~2 minutes; 300s bounds slot
|
|
252
|
+
# starvation to 5 minutes without killing healthy calls.
|
|
253
|
+
killOrQuarantineCallsLongerThanSeconds=300.0,
|
|
254
|
+
)
|
|
248
255
|
def _processLimitedDecorated(
|
|
249
256
|
self,
|
|
250
257
|
payloadEnvelope: PayloadEnvelope,
|
|
@@ -389,10 +396,32 @@ class TupleDataObservableHandler:
|
|
|
389
396
|
vortexUuid
|
|
390
397
|
] = vs
|
|
391
398
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
399
|
+
try:
|
|
400
|
+
vortexMsg = yield self._createVortexMsg(
|
|
401
|
+
payloadEnvelope.filt,
|
|
402
|
+
tupleSelector,
|
|
403
|
+
)
|
|
404
|
+
except Exception as e:
|
|
405
|
+
# The caller is waiting on a PayloadResponse; without an error
|
|
406
|
+
# response it waits the full client timeout for every failure.
|
|
407
|
+
logger.error(
|
|
408
|
+
"Observable:%s provider failed for TupleSelector = %s,"
|
|
409
|
+
" sending error response",
|
|
410
|
+
self._observableName,
|
|
411
|
+
tsStr,
|
|
412
|
+
)
|
|
413
|
+
logger.exception(e)
|
|
414
|
+
# DatabaseError strings include the full SQL and parameters;
|
|
415
|
+
# the leading characters carry the actual error code.
|
|
416
|
+
errorText = f"{e.__class__.__name__}: {e}"[:500]
|
|
417
|
+
errorEnvelope = PayloadEnvelope(
|
|
418
|
+
filt=payloadEnvelope.filt,
|
|
419
|
+
result=f"Tuple provider failed on the server: {errorText}",
|
|
420
|
+
)
|
|
421
|
+
errorVortexMsg = yield errorEnvelope.toVortexMsgDefer()
|
|
422
|
+
yield sendResponse(errorVortexMsg)
|
|
423
|
+
return
|
|
424
|
+
|
|
396
425
|
try:
|
|
397
426
|
yield sendResponse(vortexMsg)
|
|
398
427
|
except Exception as e:
|
|
@@ -438,7 +467,9 @@ class TupleDataObservableHandler:
|
|
|
438
467
|
|
|
439
468
|
"""
|
|
440
469
|
tupleSelectors = self._getMappedTupleSelectors(tupleSelector)
|
|
441
|
-
|
|
470
|
+
# This method runs on a thread-pool thread; callLater is
|
|
471
|
+
# reactor-thread-only, so schedule with thread-safe callFromThread.
|
|
472
|
+
reactor.callFromThread(self._notifyOfTupleUpdateInMain, tupleSelectors)
|
|
442
473
|
|
|
443
474
|
def notifyOfTupleUpdateForTuple(self, tupleName: str) -> None:
|
|
444
475
|
"""Notify of Tuple Update for Tuple
|
|
@@ -457,7 +488,9 @@ class TupleDataObservableHandler:
|
|
|
457
488
|
if data.tupleSelector.name == tupleName:
|
|
458
489
|
tupleSelectors.append(data.tupleSelector)
|
|
459
490
|
|
|
460
|
-
|
|
491
|
+
# This method runs on a thread-pool thread; callLater is
|
|
492
|
+
# reactor-thread-only, so schedule with thread-safe callFromThread.
|
|
493
|
+
reactor.callFromThread(self._notifyOfTupleUpdateInMain, tupleSelectors)
|
|
461
494
|
|
|
462
495
|
@inlineCallbacks
|
|
463
496
|
def _notifyOfTupleUpdateInMain(self, tupleSelectors: List[TupleSelector]):
|
|
@@ -390,3 +390,49 @@ async def test_notify_unsubscribe_during_provider_yield_does_not_raise() -> None
|
|
|
390
390
|
await notifyDeferred
|
|
391
391
|
|
|
392
392
|
sendVortexMsgMock.assert_not_called()
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
class _FailingProvider(TupleDataProviderABC):
|
|
396
|
+
requiredPermissions = frozenset()
|
|
397
|
+
|
|
398
|
+
def makeVortexMsg(
|
|
399
|
+
self,
|
|
400
|
+
filt: dict,
|
|
401
|
+
tupleSelector: TupleSelector,
|
|
402
|
+
) -> bytes:
|
|
403
|
+
raise ValueError("ORA-00942: table or view does not exist")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@pytest.mark.twisted
|
|
407
|
+
@ensureDeferred
|
|
408
|
+
async def test_provider_failure_sends_error_response() -> None:
|
|
409
|
+
handler = TupleDataObservableHandler("testObservable")
|
|
410
|
+
handler.addTupleProvider(_TUPLE_NAME, _FailingProvider())
|
|
411
|
+
sendResponseCalls: list[bytes] = []
|
|
412
|
+
|
|
413
|
+
def sendResponse(vortexMsg: bytes) -> Deferred[None]:
|
|
414
|
+
sendResponseCalls.append(vortexMsg)
|
|
415
|
+
return succeed(None)
|
|
416
|
+
|
|
417
|
+
with patch(
|
|
418
|
+
"vortex.handler.TupleDataObservableHandler.VortexFactory.isVortexUuidOnline",
|
|
419
|
+
return_value=True,
|
|
420
|
+
):
|
|
421
|
+
await handler._process(
|
|
422
|
+
_pollEnvelope(), _VORTEX_UUID, "client-vortex", sendResponse
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
assert len(sendResponseCalls) == 1, (
|
|
426
|
+
"provider failure must send an error response, not silence"
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
errorEnvelope = await PayloadEnvelope().fromVortexMsgDefer(sendResponseCalls[0])
|
|
430
|
+
assert errorEnvelope.result not in (None, True), (
|
|
431
|
+
"error response result must fail the client resultCheck"
|
|
432
|
+
)
|
|
433
|
+
assert "ORA-00942" in errorEnvelope.result, (
|
|
434
|
+
"error response must carry the provider error text"
|
|
435
|
+
)
|
|
436
|
+
assert (
|
|
437
|
+
errorEnvelope.filt["tupleSelector"].toJsonStr() == _TUPLE_SELECTOR.toJsonStr()
|
|
438
|
+
), "error response must preserve the request filt"
|
|
@@ -418,8 +418,9 @@ class TupleDataObservableProxyHandler(TupleDataObservableCache):
|
|
|
418
418
|
|
|
419
419
|
# Send the vortex messages
|
|
420
420
|
for vortexUuid in observingUuids:
|
|
421
|
-
|
|
422
|
-
|
|
421
|
+
# This method runs on a thread-pool thread; callLater is
|
|
422
|
+
# reactor-thread-only, so schedule with thread-safe callFromThread.
|
|
423
|
+
reactor.callFromThread(
|
|
423
424
|
self._sendVortexMsgInReactor,
|
|
424
425
|
vortexMsg=vortexMsg,
|
|
425
426
|
destVortexUuid=vortexUuid,
|