gooddata-flight-server 1.34.1.dev1__py3-none-any.whl
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.
Potentially problematic release.
This version of gooddata-flight-server might be problematic. Click here for more details.
- gooddata_flight_server/__init__.py +23 -0
- gooddata_flight_server/_version.py +7 -0
- gooddata_flight_server/cli.py +137 -0
- gooddata_flight_server/config/__init__.py +1 -0
- gooddata_flight_server/config/config.py +536 -0
- gooddata_flight_server/errors/__init__.py +1 -0
- gooddata_flight_server/errors/error_code.py +209 -0
- gooddata_flight_server/errors/error_info.py +475 -0
- gooddata_flight_server/exceptions.py +16 -0
- gooddata_flight_server/health/__init__.py +1 -0
- gooddata_flight_server/health/health_check_http_server.py +103 -0
- gooddata_flight_server/health/server_health_monitor.py +83 -0
- gooddata_flight_server/metrics.py +16 -0
- gooddata_flight_server/py.typed +1 -0
- gooddata_flight_server/server/__init__.py +1 -0
- gooddata_flight_server/server/auth/__init__.py +1 -0
- gooddata_flight_server/server/auth/auth_middleware.py +83 -0
- gooddata_flight_server/server/auth/token_verifier.py +62 -0
- gooddata_flight_server/server/auth/token_verifier_factory.py +55 -0
- gooddata_flight_server/server/auth/token_verifier_impl.py +41 -0
- gooddata_flight_server/server/base.py +63 -0
- gooddata_flight_server/server/default.logging.ini +28 -0
- gooddata_flight_server/server/flight_rpc/__init__.py +1 -0
- gooddata_flight_server/server/flight_rpc/flight_middleware.py +162 -0
- gooddata_flight_server/server/flight_rpc/flight_server.py +228 -0
- gooddata_flight_server/server/flight_rpc/flight_service.py +279 -0
- gooddata_flight_server/server/flight_rpc/server_methods.py +200 -0
- gooddata_flight_server/server/server_base.py +321 -0
- gooddata_flight_server/server/server_main.py +116 -0
- gooddata_flight_server/tasks/__init__.py +1 -0
- gooddata_flight_server/tasks/base.py +21 -0
- gooddata_flight_server/tasks/metrics.py +115 -0
- gooddata_flight_server/tasks/task.py +193 -0
- gooddata_flight_server/tasks/task_error.py +60 -0
- gooddata_flight_server/tasks/task_executor.py +96 -0
- gooddata_flight_server/tasks/task_result.py +363 -0
- gooddata_flight_server/tasks/temporal_container.py +247 -0
- gooddata_flight_server/tasks/thread_task_executor.py +639 -0
- gooddata_flight_server/utils/__init__.py +1 -0
- gooddata_flight_server/utils/libc_utils.py +35 -0
- gooddata_flight_server/utils/logging.py +158 -0
- gooddata_flight_server/utils/methods_discovery.py +98 -0
- gooddata_flight_server/utils/otel_tracing.py +142 -0
- gooddata_flight_server-1.34.1.dev1.data/scripts/gooddata-flight-server +10 -0
- gooddata_flight_server-1.34.1.dev1.dist-info/LICENSE.txt +7 -0
- gooddata_flight_server-1.34.1.dev1.dist-info/METADATA +749 -0
- gooddata_flight_server-1.34.1.dev1.dist-info/RECORD +49 -0
- gooddata_flight_server-1.34.1.dev1.dist-info/WHEEL +5 -0
- gooddata_flight_server-1.34.1.dev1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# (C) 2024 GoodData Corporation
|
|
2
|
+
from typing import Any, Callable, Optional
|
|
3
|
+
|
|
4
|
+
import opentelemetry.context as otelctx
|
|
5
|
+
import opentelemetry.propagate as otelpropagate
|
|
6
|
+
import pyarrow.flight
|
|
7
|
+
import structlog
|
|
8
|
+
from opentelemetry import trace
|
|
9
|
+
from opentelemetry.semconv.trace import SpanAttributes
|
|
10
|
+
from opentelemetry.trace import SpanKind, StatusCode, use_span
|
|
11
|
+
from typing_extensions import TypeAlias
|
|
12
|
+
|
|
13
|
+
from gooddata_flight_server.utils.otel_tracing import SERVER_TRACER
|
|
14
|
+
|
|
15
|
+
_LOGGER = structlog.get_logger("gooddata_flight_server.rpc")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CallInfo(pyarrow.flight.ServerMiddleware):
|
|
19
|
+
"""
|
|
20
|
+
Call Info middleware holds information about the current call:
|
|
21
|
+
|
|
22
|
+
- RPC method info
|
|
23
|
+
- headers used on RPC Call
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
MiddlewareName = "call_info"
|
|
27
|
+
|
|
28
|
+
def __init__(self, info: pyarrow.flight.CallInfo, headers: dict[str, list[str]]):
|
|
29
|
+
super().__init__()
|
|
30
|
+
|
|
31
|
+
self._info = info
|
|
32
|
+
self._headers = headers
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def info(self) -> pyarrow.flight.CallInfo:
|
|
36
|
+
"""
|
|
37
|
+
:return: Flight's CallInfo with detail about the current call
|
|
38
|
+
"""
|
|
39
|
+
return self._info
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def headers(self) -> dict[str, list[str]]:
|
|
43
|
+
"""
|
|
44
|
+
:return: headers provided by the caller
|
|
45
|
+
"""
|
|
46
|
+
return self._headers
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
OnEndCallbackFn: TypeAlias = Callable[[Optional[pyarrow.ArrowException]], Any]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CallFinalizer(pyarrow.flight.ServerMiddleware):
|
|
53
|
+
"""
|
|
54
|
+
Call Finalizer middleware can be used by method implementations to register
|
|
55
|
+
functions that should be called after the entire Flight RPC call finishes.
|
|
56
|
+
|
|
57
|
+
This may be important especially for the DoGet and DoExchange methods where the
|
|
58
|
+
method implementation prepares the stream and hands it over to PyArrow's Flight RPC.
|
|
59
|
+
|
|
60
|
+
It is often the case that the stream is backed by data which has its lifecycle
|
|
61
|
+
managed by the server. In these cases, the server needs to know the data is actually not
|
|
62
|
+
used anymore (so that it can release locks, free up the data or do whatever it needs
|
|
63
|
+
to do).
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
MiddlewareName = "call_finalizer"
|
|
67
|
+
|
|
68
|
+
def __init__(self) -> None:
|
|
69
|
+
super().__init__()
|
|
70
|
+
|
|
71
|
+
self._on_end: list[OnEndCallbackFn] = []
|
|
72
|
+
|
|
73
|
+
def register_on_end(self, fun: OnEndCallbackFn) -> None:
|
|
74
|
+
"""
|
|
75
|
+
Register a function that should be called once the call completes. It is possible to call this
|
|
76
|
+
multiple times and register multiple functions.
|
|
77
|
+
|
|
78
|
+
IMPORTANT: the function that you register here should be quick, do minimal blocking and have low chance
|
|
79
|
+
of hanging. The function will be called out from the gRPC server's thread; if the call hangs, server's thread
|
|
80
|
+
will be blocked.
|
|
81
|
+
|
|
82
|
+
:param fun: function to register, it will be called with one argument: exception,
|
|
83
|
+
which is either None on success or pyarrow.ArrowException on failure
|
|
84
|
+
:return: nothing
|
|
85
|
+
"""
|
|
86
|
+
self._on_end.append(fun)
|
|
87
|
+
|
|
88
|
+
def call_completed(self, exception: Optional[pyarrow.lib.ArrowException]) -> None:
|
|
89
|
+
try:
|
|
90
|
+
for fun in self._on_end:
|
|
91
|
+
fun(exception)
|
|
92
|
+
except Exception:
|
|
93
|
+
_LOGGER.critical("call_finalization_failed", exc_info=True)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class OtelMiddleware(pyarrow.flight.ServerMiddleware):
|
|
97
|
+
MiddlewareName = "otel_middleware"
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self, info: pyarrow.flight.CallInfo, headers: dict[str, list[str]], extract_context: bool = False
|
|
101
|
+
) -> None:
|
|
102
|
+
super().__init__()
|
|
103
|
+
method_name = info.method.name
|
|
104
|
+
|
|
105
|
+
if extract_context:
|
|
106
|
+
self._otel_ctx = otelpropagate.extract(headers)
|
|
107
|
+
else:
|
|
108
|
+
self._otel_ctx = otelctx.get_current()
|
|
109
|
+
|
|
110
|
+
self._otel_span = SERVER_TRACER.start_span(
|
|
111
|
+
f"{method_name}",
|
|
112
|
+
kind=SpanKind.SERVER,
|
|
113
|
+
context=self._otel_ctx,
|
|
114
|
+
attributes={
|
|
115
|
+
SpanAttributes.RPC_SYSTEM: "grpc",
|
|
116
|
+
SpanAttributes.RPC_SERVICE: "FlightRPC",
|
|
117
|
+
SpanAttributes.RPC_METHOD: method_name,
|
|
118
|
+
},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# note: the code does not set context / use span at this point because
|
|
122
|
+
# the middleware creation is done in a separate call, before the actual
|
|
123
|
+
# method invocation.
|
|
124
|
+
#
|
|
125
|
+
# the context has to be set & span used during the actual Flight RPC
|
|
126
|
+
# method handling
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def call_tracing(self) -> tuple[otelctx.Context, trace.Span]:
|
|
130
|
+
"""
|
|
131
|
+
:return: tracing context & span for the current call
|
|
132
|
+
"""
|
|
133
|
+
return self._otel_ctx, self._otel_span
|
|
134
|
+
|
|
135
|
+
def call_completed(self, exception: Optional[pyarrow.lib.ArrowException]) -> None:
|
|
136
|
+
# OpenTelemetry context/span restore;
|
|
137
|
+
#
|
|
138
|
+
# this has to happen because this method is done from thread managed
|
|
139
|
+
# by gRPC server / Flight & during a separate call after the request handling
|
|
140
|
+
# completes - any context juggling done previously is lost.
|
|
141
|
+
#
|
|
142
|
+
# - attach context extracted over the wire (see constructor)
|
|
143
|
+
# - use current method call's span (created during middleware init)
|
|
144
|
+
#
|
|
145
|
+
# code can then complete the span accordingly and finally detach the context
|
|
146
|
+
old_ctx = otelctx.attach(self._otel_ctx)
|
|
147
|
+
try:
|
|
148
|
+
with use_span(
|
|
149
|
+
self._otel_span,
|
|
150
|
+
end_on_exit=False,
|
|
151
|
+
record_exception=False,
|
|
152
|
+
set_status_on_exception=False,
|
|
153
|
+
):
|
|
154
|
+
if exception is None:
|
|
155
|
+
self._otel_span.set_status(StatusCode.OK)
|
|
156
|
+
else:
|
|
157
|
+
self._otel_span.set_status(StatusCode.ERROR)
|
|
158
|
+
self._otel_span.record_exception(exception)
|
|
159
|
+
|
|
160
|
+
self._otel_span.end()
|
|
161
|
+
finally:
|
|
162
|
+
otelctx.detach(old_ctx)
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# (C) 2024 GoodData Corporation
|
|
2
|
+
"""
|
|
3
|
+
This is a thin wrapper around the PyArrow FlightServerBase. It exists to provide a typed interface.
|
|
4
|
+
|
|
5
|
+
There are two main pieces:
|
|
6
|
+
|
|
7
|
+
- FlightServer - this is an extension of pyarrow.flight.FlightServerBase; the sole purpose here is to decouple
|
|
8
|
+
implementation of the technical parts and the actual handling of Flight RPC Methods
|
|
9
|
+
- FlightServerMethods - base class containing typed definitions of all Flight RPC Methods
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from collections.abc import Generator
|
|
13
|
+
from typing import Any, Callable, Optional, TypeVar, Union
|
|
14
|
+
|
|
15
|
+
import opentelemetry.context as otelctx
|
|
16
|
+
import opentelemetry.trace as oteltrace
|
|
17
|
+
import pyarrow.flight
|
|
18
|
+
from typing_extensions import Concatenate, ParamSpec, TypeAlias
|
|
19
|
+
|
|
20
|
+
from gooddata_flight_server.server.flight_rpc.flight_middleware import OtelMiddleware
|
|
21
|
+
from gooddata_flight_server.server.flight_rpc.server_methods import (
|
|
22
|
+
FlightServerMethods,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
FlightServerLocation: TypeAlias = Union[str, bytes, Optional[tuple[str, int]], pyarrow.flight.Location]
|
|
26
|
+
FlightTlsCertificates: TypeAlias = list[tuple[bytes, bytes]]
|
|
27
|
+
FlightMiddlewares: TypeAlias = dict[str, pyarrow.flight.ServerMiddlewareFactory]
|
|
28
|
+
|
|
29
|
+
T = TypeVar("T")
|
|
30
|
+
P = ParamSpec("P")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def rpc_decorator() -> Callable[
|
|
34
|
+
[Callable[Concatenate[Any, pyarrow.flight.ServerCallContext, P], T]],
|
|
35
|
+
Callable[Concatenate[Any, pyarrow.flight.ServerCallContext, P], T],
|
|
36
|
+
]:
|
|
37
|
+
def _factory(
|
|
38
|
+
fun: Callable[Concatenate[Any, pyarrow.flight.ServerCallContext, P], T],
|
|
39
|
+
) -> Callable[Concatenate[Any, pyarrow.flight.ServerCallContext, P], T]:
|
|
40
|
+
def _decorator(
|
|
41
|
+
self: Any,
|
|
42
|
+
context: pyarrow.flight.ServerCallContext,
|
|
43
|
+
*args: P.args,
|
|
44
|
+
**kwargs: P.kwargs,
|
|
45
|
+
) -> T:
|
|
46
|
+
otel_middleware = context.get_middleware(OtelMiddleware.MiddlewareName)
|
|
47
|
+
if otel_middleware is not None:
|
|
48
|
+
otel_ctx, otel_span = otel_middleware.call_tracing
|
|
49
|
+
else:
|
|
50
|
+
otel_ctx = otelctx.get_current()
|
|
51
|
+
otel_span = oteltrace.INVALID_SPAN
|
|
52
|
+
|
|
53
|
+
old_otel_ctx = otelctx.attach(otel_ctx)
|
|
54
|
+
try:
|
|
55
|
+
with oteltrace.use_span(
|
|
56
|
+
otel_span,
|
|
57
|
+
end_on_exit=False,
|
|
58
|
+
record_exception=False,
|
|
59
|
+
set_status_on_exception=False,
|
|
60
|
+
):
|
|
61
|
+
return fun(self, context, *args, **kwargs)
|
|
62
|
+
finally:
|
|
63
|
+
otelctx.detach(old_otel_ctx)
|
|
64
|
+
|
|
65
|
+
return _decorator
|
|
66
|
+
|
|
67
|
+
return _factory
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class FlightServer(pyarrow.flight.FlightServerBase):
|
|
71
|
+
"""
|
|
72
|
+
Creates AND starts the Flight RPC Server. To handle the RPC methods, the server will call out to the
|
|
73
|
+
`methods` object. The `methods` object can be switched from outside, thus allowing caller code to switch
|
|
74
|
+
implementation of the RPC methods at its own discretion. Typical use case for this is to keep returning
|
|
75
|
+
`UNAVAILABLE` until the entire server starts, then switch to the real implementation.
|
|
76
|
+
|
|
77
|
+
Please note, that the Flight RPC Server actually starts as soon as this class is created. The `serve` and
|
|
78
|
+
`serve_with_signals` are merely a synchronization mechanism to wait until the server gets stopped.
|
|
79
|
+
|
|
80
|
+
When managing the lifecycle of the Flight RPC from outside, it is better not to use the serve() methods.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
methods: Optional[FlightServerMethods] = None,
|
|
86
|
+
location: Optional[FlightServerLocation] = None,
|
|
87
|
+
auth_handler: Optional[pyarrow.flight.ServerAuthHandler] = None,
|
|
88
|
+
tls_certificates: Optional[FlightTlsCertificates] = None,
|
|
89
|
+
verify_client: Optional[bool] = None,
|
|
90
|
+
root_certificates: Optional[bytes] = None,
|
|
91
|
+
middleware: Optional[FlightMiddlewares] = None,
|
|
92
|
+
):
|
|
93
|
+
"""
|
|
94
|
+
Create Flight server
|
|
95
|
+
|
|
96
|
+
:param methods: implementation of Flight RPC methods to use; when None provided, will use implementation
|
|
97
|
+
that raises NotImplementedError for any method call
|
|
98
|
+
:param location : str, tuple or Location optional, default None
|
|
99
|
+
Location to serve on. Either a gRPC URI like `grpc://localhost:port`,
|
|
100
|
+
a tuple of (host, port) pair, or a Location instance.
|
|
101
|
+
If None is passed then the server will be started on localhost with a
|
|
102
|
+
system provided random port.
|
|
103
|
+
:param auth_handler : ServerAuthHandler optional, default None
|
|
104
|
+
An authentication mechanism to use. May be None.
|
|
105
|
+
:param tls_certificates : list optional, default None
|
|
106
|
+
A list of (certificate, key) pairs.
|
|
107
|
+
:param verify_client : boolean optional, default False
|
|
108
|
+
If True, then enable mutual TLS: require the client to present
|
|
109
|
+
a client certificate, and validate the certificate.
|
|
110
|
+
:param root_certificates : bytes optional, default None
|
|
111
|
+
If enabling mutual TLS, this specifies the PEM-encoded root
|
|
112
|
+
certificate used to validate client certificates.
|
|
113
|
+
:param middleware : list optional, default None
|
|
114
|
+
A dictionary of :class:`ServerMiddlewareFactory` items. The
|
|
115
|
+
keys are used to retrieve the middleware instance during calls
|
|
116
|
+
(see :meth:`ServerCallContext.get_middleware`).
|
|
117
|
+
"""
|
|
118
|
+
super().__init__(
|
|
119
|
+
location=location,
|
|
120
|
+
auth_handler=auth_handler,
|
|
121
|
+
tls_certificates=tls_certificates,
|
|
122
|
+
verify_client=verify_client,
|
|
123
|
+
root_certificates=root_certificates,
|
|
124
|
+
middleware=middleware,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
self._methods = methods or FlightServerMethods()
|
|
128
|
+
|
|
129
|
+
def switch_methods(self, methods: FlightServerMethods) -> None:
|
|
130
|
+
"""
|
|
131
|
+
Switch methods used to handle Flight RPC requests. New requests will be handled using these methods.
|
|
132
|
+
|
|
133
|
+
:param methods: new set of methods to use
|
|
134
|
+
:return:
|
|
135
|
+
"""
|
|
136
|
+
self._methods = methods
|
|
137
|
+
|
|
138
|
+
def serve(self) -> None:
|
|
139
|
+
"""
|
|
140
|
+
Serve waits until the underlying server has stopped. This blocks current thread.
|
|
141
|
+
|
|
142
|
+
Note that the underlying Flight RPC Service starts up immediately when this class is constructed so this is
|
|
143
|
+
really just a synchronization mechanism.
|
|
144
|
+
|
|
145
|
+
:return: nothing
|
|
146
|
+
"""
|
|
147
|
+
self.wait()
|
|
148
|
+
|
|
149
|
+
def serve_with_signals(self) -> None:
|
|
150
|
+
"""
|
|
151
|
+
Serve waits until the underlying server has stopped OR until INT or TERM signal.
|
|
152
|
+
This blocks current thread.
|
|
153
|
+
|
|
154
|
+
When signal is received, the server will shut down as soon as current requests are
|
|
155
|
+
done and this method will return.
|
|
156
|
+
|
|
157
|
+
Note that the underlying Flight RPC Service starts up immediately when this class is
|
|
158
|
+
constructed so this is really just a synchronization mechanism.
|
|
159
|
+
|
|
160
|
+
:return: nothing
|
|
161
|
+
"""
|
|
162
|
+
return self.serve()
|
|
163
|
+
|
|
164
|
+
#
|
|
165
|
+
# Delegates to methods impl
|
|
166
|
+
#
|
|
167
|
+
|
|
168
|
+
@rpc_decorator()
|
|
169
|
+
def list_flights(
|
|
170
|
+
self, context: pyarrow.flight.ServerCallContext, criteria: bytes
|
|
171
|
+
) -> Generator[pyarrow.flight.FlightInfo, None, None]:
|
|
172
|
+
return self._methods.list_flights(context, criteria)
|
|
173
|
+
|
|
174
|
+
@rpc_decorator()
|
|
175
|
+
def get_flight_info(
|
|
176
|
+
self,
|
|
177
|
+
context: pyarrow.flight.ServerCallContext,
|
|
178
|
+
descriptor: pyarrow.flight.FlightDescriptor,
|
|
179
|
+
) -> pyarrow.flight.FlightInfo:
|
|
180
|
+
return self._methods.get_flight_info(context, descriptor)
|
|
181
|
+
|
|
182
|
+
@rpc_decorator()
|
|
183
|
+
def get_schema(
|
|
184
|
+
self,
|
|
185
|
+
context: pyarrow.flight.ServerCallContext,
|
|
186
|
+
descriptor: pyarrow.flight.FlightDescriptor,
|
|
187
|
+
) -> pyarrow.flight.SchemaResult:
|
|
188
|
+
return self._methods.get_schema(context, descriptor)
|
|
189
|
+
|
|
190
|
+
@rpc_decorator()
|
|
191
|
+
def do_put(
|
|
192
|
+
self,
|
|
193
|
+
context: pyarrow.flight.ServerCallContext,
|
|
194
|
+
descriptor: pyarrow.flight.FlightDescriptor,
|
|
195
|
+
reader: pyarrow.flight.MetadataRecordBatchReader,
|
|
196
|
+
writer: pyarrow.flight.FlightMetadataWriter,
|
|
197
|
+
) -> None:
|
|
198
|
+
return self._methods.do_put(context, descriptor, reader, writer)
|
|
199
|
+
|
|
200
|
+
@rpc_decorator()
|
|
201
|
+
def do_get(
|
|
202
|
+
self,
|
|
203
|
+
context: pyarrow.flight.ServerCallContext,
|
|
204
|
+
ticket: pyarrow.flight.Ticket,
|
|
205
|
+
) -> pyarrow.flight.FlightDataStream:
|
|
206
|
+
return self._methods.do_get(context, ticket)
|
|
207
|
+
|
|
208
|
+
@rpc_decorator()
|
|
209
|
+
def do_exchange(
|
|
210
|
+
self,
|
|
211
|
+
context: pyarrow.flight.ServerCallContext,
|
|
212
|
+
descriptor: pyarrow.flight.FlightDescriptor,
|
|
213
|
+
reader: pyarrow.flight.MetadataRecordBatchReader,
|
|
214
|
+
writer: pyarrow.flight.MetadataRecordBatchWriter,
|
|
215
|
+
) -> None:
|
|
216
|
+
return self._methods.do_exchange(context, descriptor, reader, writer)
|
|
217
|
+
|
|
218
|
+
@rpc_decorator()
|
|
219
|
+
def list_actions(self, context: pyarrow.flight.ServerCallContext) -> list[tuple[str, str]]:
|
|
220
|
+
return self._methods.list_actions(context)
|
|
221
|
+
|
|
222
|
+
@rpc_decorator()
|
|
223
|
+
def do_action(
|
|
224
|
+
self,
|
|
225
|
+
context: pyarrow.flight.ServerCallContext,
|
|
226
|
+
action: pyarrow.flight.Action,
|
|
227
|
+
) -> Generator[pyarrow.flight.Result, None, None]:
|
|
228
|
+
return self._methods.do_action(context, action)
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# (C) 2024 GoodData Corporation
|
|
2
|
+
#
|
|
3
|
+
# mypy: no-strict-optional
|
|
4
|
+
|
|
5
|
+
from threading import Thread
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import pyarrow.flight
|
|
9
|
+
import structlog
|
|
10
|
+
|
|
11
|
+
from gooddata_flight_server.config.config import AuthenticationMethod, ServerConfig
|
|
12
|
+
from gooddata_flight_server.errors.error_code import ErrorCode
|
|
13
|
+
from gooddata_flight_server.errors.error_info import ErrorInfo
|
|
14
|
+
from gooddata_flight_server.server.auth.auth_middleware import TokenAuthMiddleware, TokenAuthMiddlewareFactory
|
|
15
|
+
from gooddata_flight_server.server.auth.token_verifier_factory import create_token_verification_strategy
|
|
16
|
+
from gooddata_flight_server.server.base import ServerContext
|
|
17
|
+
from gooddata_flight_server.server.flight_rpc.flight_middleware import (
|
|
18
|
+
CallFinalizer,
|
|
19
|
+
CallInfo,
|
|
20
|
+
OtelMiddleware,
|
|
21
|
+
)
|
|
22
|
+
from gooddata_flight_server.server.flight_rpc.flight_server import FlightServer
|
|
23
|
+
from gooddata_flight_server.server.flight_rpc.server_methods import (
|
|
24
|
+
FlightServerMethods,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_flight_server_locations(config: ServerConfig) -> tuple[str, str]:
|
|
29
|
+
transport = "grpc+tls" if config.use_tls else "grpc"
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
f"{transport}://{config.listen_host}:{config.listen_port}",
|
|
33
|
+
f"{transport}://{config.advertise_host}:{config.advertise_port}",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _AvailabilityMiddlewareFactory(pyarrow.flight.ServerMiddlewareFactory):
|
|
38
|
+
"""
|
|
39
|
+
Optionally rejects calls with FlightUnavailableError & some reason.
|
|
40
|
+
|
|
41
|
+
If unavailable_reason is set -> reject. Otherwise, let the request through.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, unavailable_reason: Optional[ErrorInfo] = None):
|
|
45
|
+
super().__init__()
|
|
46
|
+
|
|
47
|
+
self.unavailable_reason: Optional[ErrorInfo] = unavailable_reason
|
|
48
|
+
|
|
49
|
+
def start_call(
|
|
50
|
+
self, info: pyarrow.flight.CallInfo, headers: dict[str, list[str]]
|
|
51
|
+
) -> Optional[pyarrow.flight.ServerMiddleware]:
|
|
52
|
+
if self.unavailable_reason is None:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
raise self.unavailable_reason.to_unavailable_error()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _CallInfoMiddlewareFactory(pyarrow.flight.ServerMiddlewareFactory):
|
|
59
|
+
def start_call(
|
|
60
|
+
self, info: pyarrow.flight.CallInfo, headers: dict[str, list[str]]
|
|
61
|
+
) -> Optional[pyarrow.flight.ServerMiddleware]:
|
|
62
|
+
return CallInfo(info, headers)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _CallFinalizerMiddlewareFactory(pyarrow.flight.ServerMiddlewareFactory):
|
|
66
|
+
def start_call(
|
|
67
|
+
self, info: pyarrow.flight.CallInfo, headers: dict[str, list[str]]
|
|
68
|
+
) -> Optional[pyarrow.flight.ServerMiddleware]:
|
|
69
|
+
return CallFinalizer()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _OtelMiddlewareFactory(pyarrow.flight.ServerMiddlewareFactory):
|
|
73
|
+
def __init__(self, extract_context: bool) -> None:
|
|
74
|
+
super().__init__()
|
|
75
|
+
self._extract_context = extract_context
|
|
76
|
+
|
|
77
|
+
def start_call(
|
|
78
|
+
self, info: pyarrow.flight.CallInfo, headers: dict[str, list[str]]
|
|
79
|
+
) -> Optional[pyarrow.flight.ServerMiddleware]:
|
|
80
|
+
return OtelMiddleware(info, headers, self._extract_context)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class FlightRpcService:
|
|
84
|
+
"""
|
|
85
|
+
Service that exposes Flight RPC.
|
|
86
|
+
|
|
87
|
+
Handles Flight server start/stop & importantly also allows switching running server from
|
|
88
|
+
available to unavailable state; typical use case is to reject any RPC during startup/shutdown
|
|
89
|
+
of the server or loss of connection to the cluster.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
config: ServerConfig,
|
|
95
|
+
methods: FlightServerMethods = FlightServerMethods(),
|
|
96
|
+
):
|
|
97
|
+
self._config = config
|
|
98
|
+
self._methods = methods
|
|
99
|
+
|
|
100
|
+
(
|
|
101
|
+
self._listen_url,
|
|
102
|
+
self._client_url,
|
|
103
|
+
) = _get_flight_server_locations(config=config)
|
|
104
|
+
|
|
105
|
+
self._logger = structlog.get_logger("gooddata_flight_server.rpc")
|
|
106
|
+
|
|
107
|
+
self._availability = _AvailabilityMiddlewareFactory(
|
|
108
|
+
unavailable_reason=ErrorInfo.for_reason(ErrorCode.NOT_READY, "Try again later.")
|
|
109
|
+
)
|
|
110
|
+
# internal mutable state
|
|
111
|
+
# server starts immediately when constructed (PyArrow stuff); thus defer
|
|
112
|
+
# construction until start() is called
|
|
113
|
+
self._server: Optional[FlightServer] = None
|
|
114
|
+
self._flight_shutdown_thread: Optional[Thread] = None
|
|
115
|
+
self._stopped = False
|
|
116
|
+
|
|
117
|
+
def _initialize_authentication(
|
|
118
|
+
self, ctx: ServerContext
|
|
119
|
+
) -> Optional[tuple[str, pyarrow.flight.ServerMiddlewareFactory]]:
|
|
120
|
+
if self._config.authentication_method == AuthenticationMethod.NoAuth:
|
|
121
|
+
if self._config.use_mutual_tls:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
if "127.0.0.1" not in self._config.listen_host and "localhost" not in self._config.listen_host:
|
|
125
|
+
print("!" * 72)
|
|
126
|
+
print("!!! Your server is configured without authentication and ")
|
|
127
|
+
print("!!! it seems it is listening on a non-loopback interface. ")
|
|
128
|
+
print("!!! The server may be reachable from public network. ")
|
|
129
|
+
print(f"!!! Listening on: {self._config.listen_host}. ")
|
|
130
|
+
print("!" * 72)
|
|
131
|
+
|
|
132
|
+
self._logger.warning("insecure_warning", listen_url=self._config.listen_host)
|
|
133
|
+
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
verification = create_token_verification_strategy(ctx)
|
|
137
|
+
|
|
138
|
+
return TokenAuthMiddleware.MiddlewareName, TokenAuthMiddlewareFactory(
|
|
139
|
+
ctx.config.token_header_name, verification
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def _initialize_otel_tracing(
|
|
143
|
+
self, ctx: ServerContext
|
|
144
|
+
) -> Optional[tuple[str, pyarrow.flight.ServerMiddlewareFactory]]:
|
|
145
|
+
if self._config.otel_config.exporter_type is None:
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
return OtelMiddleware.MiddlewareName, _OtelMiddlewareFactory(
|
|
149
|
+
self._config.otel_config.extract_context_from_headers
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def start(self, ctx: ServerContext) -> None:
|
|
153
|
+
"""
|
|
154
|
+
Starts the server. This will start the Flight RPC Server bound to configured host and port.
|
|
155
|
+
The server will be returning UNAVAILABLE for all methods until it is switched to serving.
|
|
156
|
+
|
|
157
|
+
See `switch_to_serving`.
|
|
158
|
+
|
|
159
|
+
:return: nothing
|
|
160
|
+
"""
|
|
161
|
+
# massaging before sending these out to PyArrow
|
|
162
|
+
tls_certificates = (
|
|
163
|
+
[self._config.tls_cert_and_key]
|
|
164
|
+
if self._config.use_tls and self._config.tls_cert_and_key is not None
|
|
165
|
+
else None
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
self._logger.info(
|
|
169
|
+
"flight_service_start",
|
|
170
|
+
listen_url=self._listen_url,
|
|
171
|
+
client_url=self._client_url,
|
|
172
|
+
tls=tls_certificates is not None,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
middleware = {
|
|
176
|
+
"_availability": self._availability,
|
|
177
|
+
CallInfo.MiddlewareName: _CallInfoMiddlewareFactory(),
|
|
178
|
+
CallFinalizer.MiddlewareName: _CallFinalizerMiddlewareFactory(),
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
auth_middleware = self._initialize_authentication(ctx)
|
|
182
|
+
if auth_middleware is not None:
|
|
183
|
+
middleware[auth_middleware[0]] = auth_middleware[1]
|
|
184
|
+
|
|
185
|
+
otel_middleware = self._initialize_otel_tracing(ctx)
|
|
186
|
+
if otel_middleware is not None:
|
|
187
|
+
middleware[otel_middleware[0]] = otel_middleware[1]
|
|
188
|
+
|
|
189
|
+
# server starts right as it is constructed
|
|
190
|
+
# the serve() method does not have to be called; moreover, it should not be called by the server
|
|
191
|
+
# as it makes PyArrow to install signal handlers that interfere with quiver server's handlers
|
|
192
|
+
#
|
|
193
|
+
# see: https://github.com/apache/arrow/issues/11932
|
|
194
|
+
self._server = FlightServer(
|
|
195
|
+
methods=self._methods,
|
|
196
|
+
location=self._listen_url,
|
|
197
|
+
tls_certificates=tls_certificates,
|
|
198
|
+
verify_client=self._config.use_mutual_tls,
|
|
199
|
+
root_certificates=self._config.tls_root_cert,
|
|
200
|
+
middleware=middleware,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def switch_to_serving(self, methods: FlightServerMethods) -> None:
|
|
204
|
+
"""
|
|
205
|
+
Switches the Flight RPC server to serving mode.
|
|
206
|
+
|
|
207
|
+
:param methods: implementation of the Flight RPC methods
|
|
208
|
+
:return: nothing
|
|
209
|
+
"""
|
|
210
|
+
if self._server is None:
|
|
211
|
+
raise AssertionError("Flight server was never started")
|
|
212
|
+
|
|
213
|
+
self._methods = methods
|
|
214
|
+
self._server.switch_methods(methods)
|
|
215
|
+
self._availability.unavailable_reason = None
|
|
216
|
+
|
|
217
|
+
def switch_to_unavailable(self, err: ErrorInfo) -> None:
|
|
218
|
+
"""
|
|
219
|
+
Switches the Flight RPC server to unavailable mode. All new Flight RPC method
|
|
220
|
+
calls will be returning UNAVAILABLE from now on.
|
|
221
|
+
|
|
222
|
+
:param err: error info to include in the FlightUnavailableError
|
|
223
|
+
:return: nothing
|
|
224
|
+
"""
|
|
225
|
+
if self._server is None:
|
|
226
|
+
raise AssertionError("Flight server was not started")
|
|
227
|
+
|
|
228
|
+
self._availability.unavailable_reason = err
|
|
229
|
+
|
|
230
|
+
def stop(self) -> None:
|
|
231
|
+
"""
|
|
232
|
+
Stops service. This method will switch the Flight RPC server to return
|
|
233
|
+
'UNAVAILABLE' for all following calls and then initiates shutdown of the server.
|
|
234
|
+
|
|
235
|
+
The shutdown is done asynchronously. Use `wait_for_stop` to sync for completion.
|
|
236
|
+
Since the server shutdown will wait until existing clients finish it may take longer.
|
|
237
|
+
|
|
238
|
+
:return:
|
|
239
|
+
"""
|
|
240
|
+
if self._server is None:
|
|
241
|
+
raise AssertionError("Flight server was never started")
|
|
242
|
+
|
|
243
|
+
if self._flight_shutdown_thread is not None:
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
# do not handle any more new requests
|
|
247
|
+
self.switch_to_unavailable(ErrorInfo.for_reason(ErrorCode.SHUTTING_DOWN, "Server is shutting down."))
|
|
248
|
+
|
|
249
|
+
# trigger server shutdown in separate thread because shutdown blocks and violates the contract
|
|
250
|
+
# for quiver's long-running services
|
|
251
|
+
self._flight_shutdown_thread = Thread(
|
|
252
|
+
name="flight_service_shutdown",
|
|
253
|
+
daemon=True,
|
|
254
|
+
target=self._shutdown_server,
|
|
255
|
+
)
|
|
256
|
+
self._flight_shutdown_thread.start()
|
|
257
|
+
|
|
258
|
+
def _shutdown_server(self) -> None:
|
|
259
|
+
self._logger.info("flight_service_shutdown")
|
|
260
|
+
# this will block until server stops
|
|
261
|
+
self._server.shutdown()
|
|
262
|
+
self._logger.info("flight_service_finished")
|
|
263
|
+
|
|
264
|
+
def wait_for_stop(self, timeout: Optional[float] = None) -> bool:
|
|
265
|
+
if self._flight_shutdown_thread is None:
|
|
266
|
+
# this is really some mess in the caller code.. did not call stop() but tries to wait for it..
|
|
267
|
+
raise AssertionError("Flight server stop() was not issued yet attempting to wait for the server to stop.")
|
|
268
|
+
|
|
269
|
+
if self._flight_shutdown_thread.is_alive():
|
|
270
|
+
self._flight_shutdown_thread.join(timeout=timeout)
|
|
271
|
+
|
|
272
|
+
return not self._flight_shutdown_thread.is_alive()
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def client_url(self) -> str:
|
|
276
|
+
"""
|
|
277
|
+
:return: location URL to advertise to clients
|
|
278
|
+
"""
|
|
279
|
+
return self._client_url
|