feldera 0.131.0__py3-none-any.whl → 0.192.0__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 feldera might be problematic. Click here for more details.
- feldera/__init__.py +2 -2
- feldera/_callback_runner.py +64 -88
- feldera/_helpers.py +8 -2
- feldera/enums.py +145 -92
- feldera/output_handler.py +16 -4
- feldera/pipeline.py +413 -152
- feldera/pipeline_builder.py +15 -8
- feldera/rest/_helpers.py +32 -1
- feldera/rest/_httprequests.py +365 -219
- feldera/rest/config.py +44 -33
- feldera/rest/errors.py +16 -0
- feldera/rest/feldera_client.py +395 -203
- feldera/rest/pipeline.py +15 -0
- feldera/runtime_config.py +4 -0
- feldera/stats.py +4 -1
- feldera/tests/test_datafusionize.py +38 -0
- feldera/testutils.py +382 -0
- feldera/testutils_oidc.py +368 -0
- feldera-0.192.0.dist-info/METADATA +163 -0
- feldera-0.192.0.dist-info/RECORD +26 -0
- feldera-0.131.0.dist-info/METADATA +0 -102
- feldera-0.131.0.dist-info/RECORD +0 -23
- {feldera-0.131.0.dist-info → feldera-0.192.0.dist-info}/WHEEL +0 -0
- {feldera-0.131.0.dist-info → feldera-0.192.0.dist-info}/top_level.txt +0 -0
feldera/rest/feldera_client.py
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
|
-
import
|
|
2
|
-
from typing import Any, Dict, Optional
|
|
1
|
+
import json
|
|
3
2
|
import logging
|
|
3
|
+
import pathlib
|
|
4
4
|
import time
|
|
5
|
-
import json
|
|
6
5
|
from decimal import Decimal
|
|
7
|
-
from typing import Generator, Mapping
|
|
6
|
+
from typing import Any, Dict, Generator, Mapping, Optional
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
import requests
|
|
8
10
|
|
|
11
|
+
from feldera.enums import BootstrapPolicy, PipelineFieldSelector, PipelineStatus
|
|
12
|
+
from feldera.rest._helpers import determine_client_version
|
|
13
|
+
from feldera.rest._httprequests import HttpRequests
|
|
9
14
|
from feldera.rest.config import Config
|
|
15
|
+
from feldera.rest.errors import FelderaAPIError, FelderaTimeoutError
|
|
10
16
|
from feldera.rest.feldera_config import FelderaConfig
|
|
11
|
-
from feldera.rest.errors import FelderaTimeoutError, FelderaAPIError
|
|
12
17
|
from feldera.rest.pipeline import Pipeline
|
|
13
|
-
from feldera.rest._httprequests import HttpRequests
|
|
14
|
-
from feldera.rest._helpers import client_version
|
|
15
18
|
|
|
16
19
|
|
|
17
20
|
def _validate_no_none_keys_in_map(data):
|
|
@@ -35,34 +38,51 @@ def _prepare_boolean_input(value: bool) -> str:
|
|
|
35
38
|
|
|
36
39
|
class FelderaClient:
|
|
37
40
|
"""
|
|
38
|
-
A client for the Feldera HTTP API
|
|
41
|
+
A client for the Feldera HTTP API.
|
|
39
42
|
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
The client is initialized with the configuration needed for interacting with the
|
|
44
|
+
Feldera HTTP API, which it uses in its calls. Its methods are implemented
|
|
45
|
+
by issuing one or more HTTP requests to the API, and as such can provide higher
|
|
46
|
+
level operations (e.g., support waiting for the success of asynchronous HTTP API
|
|
47
|
+
functionality).
|
|
42
48
|
"""
|
|
43
49
|
|
|
44
50
|
def __init__(
|
|
45
51
|
self,
|
|
46
|
-
url: str,
|
|
52
|
+
url: Optional[str] = None,
|
|
47
53
|
api_key: Optional[str] = None,
|
|
48
54
|
timeout: Optional[float] = None,
|
|
49
55
|
connection_timeout: Optional[float] = None,
|
|
50
|
-
requests_verify: bool =
|
|
56
|
+
requests_verify: Optional[bool | str] = None,
|
|
51
57
|
) -> None:
|
|
52
58
|
"""
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
:param
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
Constructs a Feldera client.
|
|
60
|
+
|
|
61
|
+
:param url: (Optional) URL to the Feldera API.
|
|
62
|
+
The default is read from the `FELDERA_HOST` environment variable;
|
|
63
|
+
if the variable is not set, the default is `"http://localhost:8080"`.
|
|
64
|
+
:param api_key: (Optional) API key to access Feldera (format: `"apikey:..."`).
|
|
65
|
+
The default is read from the `FELDERA_API_KEY` environment variable;
|
|
66
|
+
if the variable is not set, the default is `None` (no API key is provided).
|
|
67
|
+
:param timeout: (Optional) Duration in seconds that the client will wait to receive
|
|
68
|
+
a response of an HTTP request, after which it times out.
|
|
69
|
+
The default is `None` (wait indefinitely; no timeout is enforced).
|
|
70
|
+
:param connection_timeout: (Optional) Duration in seconds that the client will wait
|
|
71
|
+
to establish the connection of an HTTP request, after which it times out.
|
|
72
|
+
The default is `None` (wait indefinitely; no timeout is enforced).
|
|
73
|
+
:param requests_verify: (Optional) The `verify` parameter passed to the `requests` library,
|
|
74
|
+
which is used internally to perform HTTP requests.
|
|
75
|
+
See also: https://requests.readthedocs.io/en/latest/user/advanced/#ssl-cert-verification .
|
|
76
|
+
The default is based on the `FELDERA_HTTPS_TLS_CERT` or the `FELDERA_TLS_INSECURE` environment variable.
|
|
77
|
+
By setting `FELDERA_HTTPS_TLS_CERT` to a path, the default becomes the CA bundle it points to.
|
|
78
|
+
By setting `FELDERA_TLS_INSECURE` to `"1"`, `"true"` or `"yes"` (all case-insensitive), the default becomes
|
|
79
|
+
`False` which means to disable TLS verification by default. If both variables are set, the former takes
|
|
80
|
+
priority over the latter. If neither variable is set, the default is `True`.
|
|
61
81
|
"""
|
|
62
82
|
|
|
63
83
|
self.config = Config(
|
|
64
|
-
url,
|
|
65
|
-
api_key,
|
|
84
|
+
url=url,
|
|
85
|
+
api_key=api_key,
|
|
66
86
|
timeout=timeout,
|
|
67
87
|
connection_timeout=connection_timeout,
|
|
68
88
|
requests_verify=requests_verify,
|
|
@@ -70,12 +90,12 @@ class FelderaClient:
|
|
|
70
90
|
self.http = HttpRequests(self.config)
|
|
71
91
|
|
|
72
92
|
try:
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if
|
|
93
|
+
client_version = determine_client_version()
|
|
94
|
+
server_config = self.get_config()
|
|
95
|
+
if client_version != server_config.version:
|
|
76
96
|
logging.warning(
|
|
77
|
-
f"
|
|
78
|
-
f"{
|
|
97
|
+
f"Feldera client is on version {client_version} while server is at "
|
|
98
|
+
f"{server_config.version}. There could be incompatibilities."
|
|
79
99
|
)
|
|
80
100
|
except Exception as e:
|
|
81
101
|
logging.error(f"Failed to connect to Feldera API: {e}")
|
|
@@ -89,35 +109,46 @@ class FelderaClient:
|
|
|
89
109
|
|
|
90
110
|
return FelderaClient(f"http://127.0.0.1:{port}")
|
|
91
111
|
|
|
92
|
-
def get_pipeline(
|
|
112
|
+
def get_pipeline(
|
|
113
|
+
self, pipeline_name: str, field_selector: PipelineFieldSelector
|
|
114
|
+
) -> Pipeline:
|
|
93
115
|
"""
|
|
94
116
|
Get a pipeline by name
|
|
95
117
|
|
|
96
118
|
:param pipeline_name: The name of the pipeline
|
|
119
|
+
:param field_selector: Choose what pipeline information to refresh; see PipelineFieldSelector enum definition.
|
|
97
120
|
"""
|
|
98
121
|
|
|
99
|
-
resp = self.http.get(
|
|
122
|
+
resp = self.http.get(
|
|
123
|
+
f"/pipelines/{pipeline_name}?selector={field_selector.value}"
|
|
124
|
+
)
|
|
100
125
|
|
|
101
126
|
return Pipeline.from_dict(resp)
|
|
102
127
|
|
|
103
|
-
def get_runtime_config(self, pipeline_name) ->
|
|
128
|
+
def get_runtime_config(self, pipeline_name) -> Mapping[str, Any]:
|
|
104
129
|
"""
|
|
105
130
|
Get the runtime config of a pipeline by name
|
|
106
131
|
|
|
107
132
|
:param pipeline_name: The name of the pipeline
|
|
108
133
|
"""
|
|
109
134
|
|
|
110
|
-
resp:
|
|
135
|
+
resp: Mapping[str, Any] = self.http.get(f"/pipelines/{pipeline_name}")
|
|
111
136
|
|
|
112
|
-
|
|
137
|
+
runtime_config: Mapping[str, Any] | None = resp.get("runtime_config")
|
|
138
|
+
if runtime_config is None:
|
|
139
|
+
raise ValueError(f"Pipeline {pipeline_name} has no runtime config")
|
|
113
140
|
|
|
114
|
-
|
|
141
|
+
return runtime_config
|
|
142
|
+
|
|
143
|
+
def pipelines(
|
|
144
|
+
self, selector: PipelineFieldSelector = PipelineFieldSelector.STATUS
|
|
145
|
+
) -> list[Pipeline]:
|
|
115
146
|
"""
|
|
116
147
|
Get all pipelines
|
|
117
148
|
"""
|
|
118
149
|
|
|
119
150
|
resp = self.http.get(
|
|
120
|
-
path="/pipelines",
|
|
151
|
+
path=f"/pipelines?selector={selector.value}",
|
|
121
152
|
)
|
|
122
153
|
|
|
123
154
|
return [Pipeline.from_dict(pipeline) for pipeline in resp]
|
|
@@ -126,12 +157,14 @@ class FelderaClient:
|
|
|
126
157
|
wait = ["Pending", "CompilingSql", "SqlCompiled", "CompilingRust"]
|
|
127
158
|
|
|
128
159
|
while True:
|
|
129
|
-
p = self.get_pipeline(name)
|
|
160
|
+
p = self.get_pipeline(name, PipelineFieldSelector.STATUS)
|
|
130
161
|
status = p.program_status
|
|
131
162
|
|
|
132
163
|
if status == "Success":
|
|
133
|
-
return
|
|
164
|
+
return self.get_pipeline(name, PipelineFieldSelector.ALL)
|
|
134
165
|
elif status not in wait:
|
|
166
|
+
p = self.get_pipeline(name, PipelineFieldSelector.ALL)
|
|
167
|
+
|
|
135
168
|
# error handling for SQL compilation errors
|
|
136
169
|
if status == "SqlError":
|
|
137
170
|
sql_errors = p.program_error["sql_compilation"]["messages"]
|
|
@@ -159,12 +192,92 @@ class FelderaClient:
|
|
|
159
192
|
logging.debug("still compiling %s, waiting for 100 more milliseconds", name)
|
|
160
193
|
time.sleep(0.1)
|
|
161
194
|
|
|
162
|
-
def
|
|
195
|
+
def __wait_for_pipeline_state(
|
|
196
|
+
self,
|
|
197
|
+
pipeline_name: str,
|
|
198
|
+
state: str,
|
|
199
|
+
timeout_s: Optional[float] = None,
|
|
200
|
+
start: bool = True,
|
|
201
|
+
):
|
|
202
|
+
start_time = time.monotonic()
|
|
203
|
+
|
|
204
|
+
while True:
|
|
205
|
+
if timeout_s is not None:
|
|
206
|
+
elapsed = time.monotonic() - start_time
|
|
207
|
+
if elapsed > timeout_s:
|
|
208
|
+
raise TimeoutError(
|
|
209
|
+
f"Timed out waiting for pipeline {pipeline_name} to "
|
|
210
|
+
f"transition to '{state}' state"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
resp = self.get_pipeline(pipeline_name, PipelineFieldSelector.STATUS)
|
|
214
|
+
status = resp.deployment_status
|
|
215
|
+
|
|
216
|
+
if status.lower() == state.lower():
|
|
217
|
+
break
|
|
218
|
+
elif (
|
|
219
|
+
status == "Stopped"
|
|
220
|
+
and len(resp.deployment_error or {}) > 0
|
|
221
|
+
and resp.deployment_desired_status == "Stopped"
|
|
222
|
+
):
|
|
223
|
+
err_msg = "Unable to START the pipeline:\n" if start else ""
|
|
224
|
+
raise RuntimeError(
|
|
225
|
+
f"""{err_msg}Unable to transition the pipeline to '{state}'.
|
|
226
|
+
Reason: The pipeline is in a STOPPED state due to the following error:
|
|
227
|
+
{resp.deployment_error.get("message", "")}"""
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
logging.debug(
|
|
231
|
+
"still starting %s, waiting for 100 more milliseconds", pipeline_name
|
|
232
|
+
)
|
|
233
|
+
time.sleep(0.1)
|
|
234
|
+
|
|
235
|
+
def __wait_for_pipeline_state_one_of(
|
|
236
|
+
self,
|
|
237
|
+
pipeline_name: str,
|
|
238
|
+
states: list[str],
|
|
239
|
+
timeout_s: float | None = None,
|
|
240
|
+
start: bool = True,
|
|
241
|
+
) -> PipelineStatus:
|
|
242
|
+
start_time = time.monotonic()
|
|
243
|
+
states = [state.lower() for state in states]
|
|
244
|
+
|
|
245
|
+
while True:
|
|
246
|
+
if timeout_s is not None:
|
|
247
|
+
elapsed = time.monotonic() - start_time
|
|
248
|
+
if elapsed > timeout_s:
|
|
249
|
+
raise TimeoutError(
|
|
250
|
+
f"Timed out waiting for pipeline {pipeline_name} to"
|
|
251
|
+
f"transition to one of the states: {states}"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
resp = self.get_pipeline(pipeline_name, PipelineFieldSelector.STATUS)
|
|
255
|
+
status = resp.deployment_status
|
|
256
|
+
|
|
257
|
+
if status.lower() in states:
|
|
258
|
+
return PipelineStatus.from_str(status)
|
|
259
|
+
elif (
|
|
260
|
+
status == "Stopped"
|
|
261
|
+
and len(resp.deployment_error or {}) > 0
|
|
262
|
+
and resp.deployment_desired_status == "Stopped"
|
|
263
|
+
):
|
|
264
|
+
err_msg = "Unable to START the pipeline:\n" if start else ""
|
|
265
|
+
raise RuntimeError(
|
|
266
|
+
f"""{err_msg}Unable to transition the pipeline to one of the states: {states}.
|
|
267
|
+
Reason: The pipeline is in a STOPPED state due to the following error:
|
|
268
|
+
{resp.deployment_error.get("message", "")}"""
|
|
269
|
+
)
|
|
270
|
+
logging.debug(
|
|
271
|
+
"still starting %s, waiting for 100 more milliseconds", pipeline_name
|
|
272
|
+
)
|
|
273
|
+
time.sleep(0.1)
|
|
274
|
+
|
|
275
|
+
def create_pipeline(self, pipeline: Pipeline, wait: bool = True) -> Pipeline:
|
|
163
276
|
"""
|
|
164
277
|
Create a pipeline if it doesn't exist and wait for it to compile
|
|
165
278
|
|
|
166
|
-
|
|
167
|
-
:
|
|
279
|
+
:param pipeline: The pipeline to create
|
|
280
|
+
:param wait: Whether to wait for the pipeline to compile. True by default
|
|
168
281
|
"""
|
|
169
282
|
|
|
170
283
|
body = {
|
|
@@ -182,12 +295,21 @@ class FelderaClient:
|
|
|
182
295
|
body=body,
|
|
183
296
|
)
|
|
184
297
|
|
|
298
|
+
if not wait:
|
|
299
|
+
return pipeline
|
|
300
|
+
|
|
185
301
|
return self.__wait_for_compilation(pipeline.name)
|
|
186
302
|
|
|
187
|
-
def create_or_update_pipeline(
|
|
303
|
+
def create_or_update_pipeline(
|
|
304
|
+
self, pipeline: Pipeline, wait: bool = True
|
|
305
|
+
) -> Pipeline:
|
|
188
306
|
"""
|
|
189
307
|
Create a pipeline if it doesn't exist or update a pipeline and wait for
|
|
190
308
|
it to compile
|
|
309
|
+
|
|
310
|
+
:param pipeline: The pipeline to create or update
|
|
311
|
+
:param wait: Whether to wait for the pipeline to compile. True by default
|
|
312
|
+
:return: The created or updated pipeline
|
|
191
313
|
"""
|
|
192
314
|
|
|
193
315
|
body = {
|
|
@@ -205,6 +327,9 @@ class FelderaClient:
|
|
|
205
327
|
body=body,
|
|
206
328
|
)
|
|
207
329
|
|
|
330
|
+
if not wait:
|
|
331
|
+
return pipeline
|
|
332
|
+
|
|
208
333
|
return self.__wait_for_compilation(pipeline.name)
|
|
209
334
|
|
|
210
335
|
def patch_pipeline(
|
|
@@ -235,6 +360,21 @@ class FelderaClient:
|
|
|
235
360
|
},
|
|
236
361
|
)
|
|
237
362
|
|
|
363
|
+
def testing_force_update_platform_version(self, name: str, platform_version: str):
|
|
364
|
+
self.http.post(
|
|
365
|
+
path=f"/pipelines/{name}/testing",
|
|
366
|
+
params={"set_platform_version": platform_version},
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
def update_pipeline_runtime(self, name: str):
|
|
370
|
+
"""
|
|
371
|
+
Recompile a pipeline with the Feldera runtime version included in the currently installed Feldera platform.
|
|
372
|
+
|
|
373
|
+
:param name: The name of the pipeline
|
|
374
|
+
"""
|
|
375
|
+
|
|
376
|
+
self.http.post(path=f"/pipelines/{name}/update_runtime")
|
|
377
|
+
|
|
238
378
|
def delete_pipeline(self, name: str):
|
|
239
379
|
"""
|
|
240
380
|
Deletes a pipeline by name
|
|
@@ -275,130 +415,161 @@ class FelderaClient:
|
|
|
275
415
|
yield chunk.decode("utf-8")
|
|
276
416
|
|
|
277
417
|
def activate_pipeline(
|
|
278
|
-
self, pipeline_name: str, wait: bool = True, timeout_s: Optional[float] =
|
|
279
|
-
):
|
|
418
|
+
self, pipeline_name: str, wait: bool = True, timeout_s: Optional[float] = None
|
|
419
|
+
) -> Optional[PipelineStatus]:
|
|
280
420
|
"""
|
|
281
421
|
|
|
282
422
|
:param pipeline_name: The name of the pipeline to activate
|
|
283
|
-
:param wait: Set True to wait for the pipeline to activate. True by
|
|
284
|
-
|
|
285
|
-
|
|
423
|
+
:param wait: Set True to wait for the pipeline to activate. True by
|
|
424
|
+
default
|
|
425
|
+
:param timeout_s: The amount of time in seconds to wait for the
|
|
426
|
+
pipeline to activate.
|
|
286
427
|
"""
|
|
287
428
|
|
|
288
|
-
if timeout_s is None:
|
|
289
|
-
timeout_s = 300
|
|
290
|
-
|
|
291
429
|
self.http.post(
|
|
292
430
|
path=f"/pipelines/{pipeline_name}/activate",
|
|
293
431
|
)
|
|
294
432
|
|
|
295
433
|
if not wait:
|
|
296
|
-
return
|
|
434
|
+
return None
|
|
297
435
|
|
|
298
|
-
|
|
436
|
+
return self.__wait_for_pipeline_state_one_of(
|
|
437
|
+
pipeline_name, ["running", "AwaitingApproval"], timeout_s
|
|
438
|
+
)
|
|
299
439
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
440
|
+
def _inner_start_pipeline(
|
|
441
|
+
self,
|
|
442
|
+
pipeline_name: str,
|
|
443
|
+
initial: str = "running",
|
|
444
|
+
bootstrap_policy: Optional[BootstrapPolicy] = None,
|
|
445
|
+
wait: bool = True,
|
|
446
|
+
timeout_s: Optional[float] = None,
|
|
447
|
+
) -> Optional[PipelineStatus]:
|
|
448
|
+
"""
|
|
307
449
|
|
|
308
|
-
|
|
309
|
-
|
|
450
|
+
:param pipeline_name: The name of the pipeline to start
|
|
451
|
+
:param initial: The initial state to start the pipeline in. "running"
|
|
452
|
+
by default.
|
|
453
|
+
:param wait: Set True to wait for the pipeline to start. True by default
|
|
454
|
+
:param timeout_s: The amount of time in seconds to wait for the
|
|
455
|
+
pipeline to start.
|
|
456
|
+
"""
|
|
310
457
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
status == "Stopped"
|
|
315
|
-
and len(resp.deployment_error or {}) > 0
|
|
316
|
-
and resp.deployment_desired_status == "Stopped"
|
|
317
|
-
):
|
|
318
|
-
raise RuntimeError(
|
|
319
|
-
f"""Unable to ACTIVATE the pipeline.
|
|
320
|
-
Reason: The pipeline is in a STOPPED state due to the following error:
|
|
321
|
-
{resp.deployment_error.get("message", "")}"""
|
|
322
|
-
)
|
|
458
|
+
params = {"initial": initial}
|
|
459
|
+
if bootstrap_policy is not None:
|
|
460
|
+
params["bootstrap_policy"] = bootstrap_policy.value
|
|
323
461
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
462
|
+
self.http.post(
|
|
463
|
+
path=f"/pipelines/{pipeline_name}/start",
|
|
464
|
+
params=params,
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
if not wait:
|
|
468
|
+
return None
|
|
469
|
+
|
|
470
|
+
return self.__wait_for_pipeline_state_one_of(
|
|
471
|
+
pipeline_name, [initial, "AwaitingApproval"], timeout_s
|
|
472
|
+
)
|
|
328
473
|
|
|
329
474
|
def start_pipeline(
|
|
330
|
-
self,
|
|
331
|
-
|
|
475
|
+
self,
|
|
476
|
+
pipeline_name: str,
|
|
477
|
+
bootstrap_policy: Optional[BootstrapPolicy] = None,
|
|
478
|
+
wait: bool = True,
|
|
479
|
+
timeout_s: Optional[float] = None,
|
|
480
|
+
) -> Optional[PipelineStatus]:
|
|
332
481
|
"""
|
|
333
482
|
|
|
334
483
|
:param pipeline_name: The name of the pipeline to start
|
|
335
|
-
:param wait: Set True to wait for the pipeline to start.
|
|
336
|
-
|
|
337
|
-
|
|
484
|
+
:param wait: Set True to wait for the pipeline to start.
|
|
485
|
+
True by default
|
|
486
|
+
:param timeout_s: The amount of time in seconds to wait for the
|
|
487
|
+
pipeline to start.
|
|
338
488
|
"""
|
|
339
489
|
|
|
340
|
-
|
|
341
|
-
|
|
490
|
+
return self._inner_start_pipeline(
|
|
491
|
+
pipeline_name, "running", bootstrap_policy, wait, timeout_s
|
|
492
|
+
)
|
|
342
493
|
|
|
343
|
-
|
|
344
|
-
|
|
494
|
+
def start_pipeline_as_paused(
|
|
495
|
+
self,
|
|
496
|
+
pipeline_name: str,
|
|
497
|
+
bootstrap_policy: Optional[BootstrapPolicy] = None,
|
|
498
|
+
wait: bool = True,
|
|
499
|
+
timeout_s: float | None = None,
|
|
500
|
+
) -> Optional[PipelineStatus]:
|
|
501
|
+
"""
|
|
502
|
+
:param pipeline_name: The name of the pipeline to start as paused.
|
|
503
|
+
:param wait: Set True to wait for the pipeline to start as pause.
|
|
504
|
+
True by default
|
|
505
|
+
:param timeout_s: The amount of time in seconds to wait for the
|
|
506
|
+
pipeline to start.
|
|
507
|
+
"""
|
|
508
|
+
|
|
509
|
+
return self._inner_start_pipeline(
|
|
510
|
+
pipeline_name, "paused", bootstrap_policy, wait, timeout_s
|
|
345
511
|
)
|
|
346
512
|
|
|
347
|
-
|
|
348
|
-
|
|
513
|
+
def start_pipeline_as_standby(
|
|
514
|
+
self,
|
|
515
|
+
pipeline_name: str,
|
|
516
|
+
bootstrap_policy: Optional[BootstrapPolicy] = None,
|
|
517
|
+
wait: bool = True,
|
|
518
|
+
timeout_s: Optional[float] = None,
|
|
519
|
+
):
|
|
520
|
+
"""
|
|
521
|
+
:param pipeline_name: The name of the pipeline to start as standby.
|
|
522
|
+
:param wait: Set True to wait for the pipeline to start as standby.
|
|
523
|
+
True by default
|
|
524
|
+
:param timeout_s: The amount of time in seconds to wait for the
|
|
525
|
+
pipeline to start.
|
|
526
|
+
"""
|
|
349
527
|
|
|
350
|
-
|
|
528
|
+
self._inner_start_pipeline(
|
|
529
|
+
pipeline_name, "standby", bootstrap_policy, wait, timeout_s
|
|
530
|
+
)
|
|
351
531
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
532
|
+
def resume_pipeline(
|
|
533
|
+
self,
|
|
534
|
+
pipeline_name: str,
|
|
535
|
+
wait: bool = True,
|
|
536
|
+
timeout_s: Optional[float] = None,
|
|
537
|
+
):
|
|
538
|
+
"""
|
|
539
|
+
Resume a pipeline
|
|
359
540
|
|
|
360
|
-
|
|
361
|
-
|
|
541
|
+
:param pipeline_name: The name of the pipeline to stop
|
|
542
|
+
:param wait: Set True to wait for the pipeline to pause. True by default
|
|
543
|
+
:param timeout_s: The amount of time in seconds to wait for the pipeline
|
|
544
|
+
to pause.
|
|
545
|
+
"""
|
|
362
546
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
status == "Stopped"
|
|
367
|
-
and len(resp.deployment_error or {}) > 0
|
|
368
|
-
and resp.deployment_desired_status == "Stopped"
|
|
369
|
-
):
|
|
370
|
-
raise RuntimeError(
|
|
371
|
-
f"""Unable to START the pipeline.
|
|
372
|
-
Reason: The pipeline is in a STOPPED state due to the following error:
|
|
373
|
-
{resp.deployment_error.get("message", "")}"""
|
|
374
|
-
)
|
|
547
|
+
self.http.post(
|
|
548
|
+
path=f"/pipelines/{pipeline_name}/resume",
|
|
549
|
+
)
|
|
375
550
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
551
|
+
if not wait:
|
|
552
|
+
return
|
|
553
|
+
|
|
554
|
+
self.__wait_for_pipeline_state(pipeline_name, "running", timeout_s)
|
|
380
555
|
|
|
381
556
|
def pause_pipeline(
|
|
382
557
|
self,
|
|
383
558
|
pipeline_name: str,
|
|
384
|
-
error_message: str = None,
|
|
385
559
|
wait: bool = True,
|
|
386
|
-
timeout_s: Optional[float] =
|
|
560
|
+
timeout_s: Optional[float] = None,
|
|
387
561
|
):
|
|
388
562
|
"""
|
|
389
|
-
|
|
563
|
+
Pause a pipeline
|
|
390
564
|
|
|
391
565
|
:param pipeline_name: The name of the pipeline to stop
|
|
392
566
|
:param error_message: The error message to show if the pipeline is in
|
|
393
567
|
STOPPED state due to a failure.
|
|
394
568
|
:param wait: Set True to wait for the pipeline to pause. True by default
|
|
395
569
|
:param timeout_s: The amount of time in seconds to wait for the pipeline
|
|
396
|
-
to pause.
|
|
570
|
+
to pause.
|
|
397
571
|
"""
|
|
398
572
|
|
|
399
|
-
if timeout_s is None:
|
|
400
|
-
timeout_s = 300
|
|
401
|
-
|
|
402
573
|
self.http.post(
|
|
403
574
|
path=f"/pipelines/{pipeline_name}/pause",
|
|
404
575
|
)
|
|
@@ -406,46 +577,22 @@ Reason: The pipeline is in a STOPPED state due to the following error:
|
|
|
406
577
|
if not wait:
|
|
407
578
|
return
|
|
408
579
|
|
|
409
|
-
|
|
410
|
-
error_message = "Unable to PAUSE the pipeline.\n"
|
|
411
|
-
|
|
412
|
-
start_time = time.monotonic()
|
|
413
|
-
|
|
414
|
-
while True:
|
|
415
|
-
if timeout_s is not None:
|
|
416
|
-
elapsed = time.monotonic() - start_time
|
|
417
|
-
if elapsed > timeout_s:
|
|
418
|
-
raise TimeoutError(
|
|
419
|
-
f"Timed out waiting for pipeline {pipeline_name} to pause"
|
|
420
|
-
)
|
|
421
|
-
|
|
422
|
-
resp = self.get_pipeline(pipeline_name)
|
|
423
|
-
status = resp.deployment_status
|
|
424
|
-
|
|
425
|
-
if status == "Paused":
|
|
426
|
-
break
|
|
427
|
-
elif (
|
|
428
|
-
status == "Stopped"
|
|
429
|
-
and len(resp.deployment_error or {}) > 0
|
|
430
|
-
and resp.deployment_desired_status == "Stopped"
|
|
431
|
-
):
|
|
432
|
-
raise RuntimeError(
|
|
433
|
-
error_message
|
|
434
|
-
+ f"""Reason: The pipeline is in a STOPPED state due to the following error:
|
|
435
|
-
{resp.deployment_error.get("message", "")}"""
|
|
436
|
-
)
|
|
580
|
+
self.__wait_for_pipeline_state(pipeline_name, "paused", timeout_s)
|
|
437
581
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
582
|
+
def approve_pipeline(
|
|
583
|
+
self,
|
|
584
|
+
pipeline_name: str,
|
|
585
|
+
):
|
|
586
|
+
self.http.post(
|
|
587
|
+
path=f"/pipelines/{pipeline_name}/approve",
|
|
588
|
+
)
|
|
442
589
|
|
|
443
590
|
def stop_pipeline(
|
|
444
591
|
self,
|
|
445
592
|
pipeline_name: str,
|
|
446
593
|
force: bool,
|
|
447
594
|
wait: bool = True,
|
|
448
|
-
timeout_s: Optional[float] =
|
|
595
|
+
timeout_s: Optional[float] = None,
|
|
449
596
|
):
|
|
450
597
|
"""
|
|
451
598
|
Stop a pipeline
|
|
@@ -455,12 +602,9 @@ Reason: The pipeline is in a STOPPED state due to the following error:
|
|
|
455
602
|
Set False to automatically checkpoint before stopping.
|
|
456
603
|
:param wait: Set True to wait for the pipeline to stop. True by default
|
|
457
604
|
:param timeout_s: The amount of time in seconds to wait for the pipeline
|
|
458
|
-
to stop.
|
|
605
|
+
to stop.
|
|
459
606
|
"""
|
|
460
607
|
|
|
461
|
-
if timeout_s is None:
|
|
462
|
-
timeout_s = 300
|
|
463
|
-
|
|
464
608
|
params = {"force": str(force).lower()}
|
|
465
609
|
|
|
466
610
|
self.http.post(
|
|
@@ -473,8 +617,15 @@ Reason: The pipeline is in a STOPPED state due to the following error:
|
|
|
473
617
|
|
|
474
618
|
start = time.monotonic()
|
|
475
619
|
|
|
476
|
-
while
|
|
477
|
-
|
|
620
|
+
while True:
|
|
621
|
+
if timeout_s is not None and time.monotonic() - start > timeout_s:
|
|
622
|
+
raise FelderaTimeoutError(
|
|
623
|
+
f"timeout error: pipeline '{pipeline_name}' did not stop in {timeout_s} seconds"
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
status = self.get_pipeline(
|
|
627
|
+
pipeline_name, PipelineFieldSelector.STATUS
|
|
628
|
+
).deployment_status
|
|
478
629
|
|
|
479
630
|
if status == "Stopped":
|
|
480
631
|
return
|
|
@@ -485,30 +636,29 @@ Reason: The pipeline is in a STOPPED state due to the following error:
|
|
|
485
636
|
)
|
|
486
637
|
time.sleep(0.1)
|
|
487
638
|
|
|
488
|
-
|
|
489
|
-
f"timeout error: pipeline '{pipeline_name}' did not stop in {timeout_s} seconds"
|
|
490
|
-
)
|
|
491
|
-
|
|
492
|
-
def clear_storage(self, pipeline_name: str, timeout_s: Optional[float] = 300):
|
|
639
|
+
def clear_storage(self, pipeline_name: str, timeout_s: Optional[float] = None):
|
|
493
640
|
"""
|
|
494
641
|
Clears the storage from the pipeline.
|
|
495
642
|
This operation cannot be canceled.
|
|
496
643
|
|
|
497
644
|
:param pipeline_name: The name of the pipeline
|
|
498
645
|
:param timeout_s: The amount of time in seconds to wait for the storage
|
|
499
|
-
to clear.
|
|
646
|
+
to clear.
|
|
500
647
|
"""
|
|
501
|
-
if timeout_s is None:
|
|
502
|
-
timeout_s = 300
|
|
503
|
-
|
|
504
648
|
self.http.post(
|
|
505
649
|
path=f"/pipelines/{pipeline_name}/clear",
|
|
506
650
|
)
|
|
507
651
|
|
|
508
652
|
start = time.monotonic()
|
|
509
653
|
|
|
510
|
-
while
|
|
511
|
-
|
|
654
|
+
while True:
|
|
655
|
+
if timeout_s is not None and time.monotonic() - start > timeout_s:
|
|
656
|
+
raise FelderaTimeoutError(
|
|
657
|
+
f"timeout error: pipeline '{pipeline_name}' did not clear storage in {timeout_s} seconds"
|
|
658
|
+
)
|
|
659
|
+
status = self.get_pipeline(
|
|
660
|
+
pipeline_name, PipelineFieldSelector.STATUS
|
|
661
|
+
).storage_status
|
|
512
662
|
|
|
513
663
|
if status == "Cleared":
|
|
514
664
|
return
|
|
@@ -519,10 +669,6 @@ Reason: The pipeline is in a STOPPED state due to the following error:
|
|
|
519
669
|
)
|
|
520
670
|
time.sleep(0.1)
|
|
521
671
|
|
|
522
|
-
raise FelderaTimeoutError(
|
|
523
|
-
f"timeout error: pipeline '{pipeline_name}' did not clear storage in {timeout_s} seconds"
|
|
524
|
-
)
|
|
525
|
-
|
|
526
672
|
def start_transaction(self, pipeline_name: str) -> int:
|
|
527
673
|
"""
|
|
528
674
|
Start a new transaction.
|
|
@@ -562,7 +708,7 @@ Reason: The pipeline is in a STOPPED state due to the following error:
|
|
|
562
708
|
:raises RuntimeError: If there is currently no transaction in progress.
|
|
563
709
|
:raises ValueError: If the provided `transaction_id` does not match the current transaction.
|
|
564
710
|
:raises TimeoutError: If the transaction does not commit within the specified timeout (when `wait` is True).
|
|
565
|
-
:raises FelderaAPIError: If the pipeline fails to
|
|
711
|
+
:raises FelderaAPIError: If the pipeline fails to commit a transaction.
|
|
566
712
|
"""
|
|
567
713
|
|
|
568
714
|
# TODO: implement this without using /stats when we have a better pipeline status reporting API.
|
|
@@ -718,15 +864,15 @@ Reason: The pipeline is in a STOPPED state due to the following error:
|
|
|
718
864
|
_validate_no_none_keys_in_map(datum.get("insert", {}))
|
|
719
865
|
_validate_no_none_keys_in_map(datum.get("delete", {}))
|
|
720
866
|
else:
|
|
721
|
-
data:
|
|
867
|
+
data: Mapping[str, Any] = data
|
|
722
868
|
_validate_no_none_keys_in_map(data.get("insert", {}))
|
|
723
869
|
_validate_no_none_keys_in_map(data.get("delete", {}))
|
|
724
870
|
else:
|
|
725
871
|
_validate_no_none_keys_in_map(data)
|
|
726
872
|
|
|
727
873
|
# python sends `True` which isn't accepted by the backend
|
|
728
|
-
array = _prepare_boolean_input(array)
|
|
729
|
-
force = _prepare_boolean_input(force)
|
|
874
|
+
array: str = _prepare_boolean_input(array)
|
|
875
|
+
force: str = _prepare_boolean_input(force)
|
|
730
876
|
|
|
731
877
|
params = {
|
|
732
878
|
"force": force,
|
|
@@ -747,7 +893,7 @@ Reason: The pipeline is in a STOPPED state due to the following error:
|
|
|
747
893
|
data = bytes(str(data), "utf-8")
|
|
748
894
|
|
|
749
895
|
resp = self.http.post(
|
|
750
|
-
path=f"/pipelines/{pipeline_name}/ingress/{table_name}",
|
|
896
|
+
path=f"/pipelines/{quote(pipeline_name, safe='')}/ingress/{quote(table_name, safe='')}",
|
|
751
897
|
params=params,
|
|
752
898
|
content_type=content_type,
|
|
753
899
|
body=data,
|
|
@@ -765,8 +911,37 @@ Reason: The pipeline is in a STOPPED state due to the following error:
|
|
|
765
911
|
|
|
766
912
|
return token
|
|
767
913
|
|
|
914
|
+
def completion_token_processed(self, pipeline_name: str, token: str) -> bool:
|
|
915
|
+
"""
|
|
916
|
+
Check whether the pipeline has finished processing all inputs received from the connector before
|
|
917
|
+
the token was generated.
|
|
918
|
+
|
|
919
|
+
:param pipeline_name: The name of the pipeline
|
|
920
|
+
:param token: The token to check for completion
|
|
921
|
+
:return: True if the pipeline has finished processing all inputs represented by the token, False otherwise
|
|
922
|
+
"""
|
|
923
|
+
|
|
924
|
+
params = {
|
|
925
|
+
"token": token,
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
resp = self.http.get(
|
|
929
|
+
path=f"/pipelines/{quote(pipeline_name, safe='')}/completion_status",
|
|
930
|
+
params=params,
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
status: Optional[str] = resp.get("status")
|
|
934
|
+
|
|
935
|
+
if status is None:
|
|
936
|
+
raise FelderaAPIError(
|
|
937
|
+
f"got empty status when checking for completion status for token: {token}",
|
|
938
|
+
resp,
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
return status.lower() == "complete"
|
|
942
|
+
|
|
768
943
|
def wait_for_token(
|
|
769
|
-
self, pipeline_name: str, token: str, timeout_s: Optional[float] =
|
|
944
|
+
self, pipeline_name: str, token: str, timeout_s: Optional[float] = None
|
|
770
945
|
):
|
|
771
946
|
"""
|
|
772
947
|
Blocks until all records represented by this completion token have
|
|
@@ -775,13 +950,9 @@ Reason: The pipeline is in a STOPPED state due to the following error:
|
|
|
775
950
|
:param pipeline_name: The name of the pipeline
|
|
776
951
|
:param token: The token to check for completion
|
|
777
952
|
:param timeout_s: The amount of time in seconds to wait for the pipeline
|
|
778
|
-
to process these records.
|
|
953
|
+
to process these records.
|
|
779
954
|
"""
|
|
780
955
|
|
|
781
|
-
params = {
|
|
782
|
-
"token": token,
|
|
783
|
-
}
|
|
784
|
-
|
|
785
956
|
start = time.monotonic()
|
|
786
957
|
end = start + timeout_s if timeout_s else None
|
|
787
958
|
initial_backoff = 0.1
|
|
@@ -794,22 +965,11 @@ Reason: The pipeline is in a STOPPED state due to the following error:
|
|
|
794
965
|
if time.monotonic() > end:
|
|
795
966
|
raise FelderaTimeoutError(
|
|
796
967
|
f"timeout error: pipeline '{pipeline_name}' did not"
|
|
797
|
-
f" process records represented by token {token} within"
|
|
798
|
-
f" {timeout_s}"
|
|
968
|
+
+ f" process records represented by token {token} within"
|
|
969
|
+
+ f" {timeout_s}"
|
|
799
970
|
)
|
|
800
971
|
|
|
801
|
-
|
|
802
|
-
path=f"/pipelines/{pipeline_name}/completion_status", params=params
|
|
803
|
-
)
|
|
804
|
-
|
|
805
|
-
status: Optional[str] = resp.get("status")
|
|
806
|
-
if status is None:
|
|
807
|
-
raise FelderaAPIError(
|
|
808
|
-
f"got empty status when checking for completion status for token: {token}",
|
|
809
|
-
resp,
|
|
810
|
-
)
|
|
811
|
-
|
|
812
|
-
if status.lower() == "complete":
|
|
972
|
+
if self.completion_token_processed(pipeline_name, token):
|
|
813
973
|
break
|
|
814
974
|
|
|
815
975
|
elapsed = time.monotonic() - start
|
|
@@ -830,6 +990,7 @@ Reason: The pipeline is in a STOPPED state due to the following error:
|
|
|
830
990
|
backpressure: bool = True,
|
|
831
991
|
array: bool = False,
|
|
832
992
|
timeout: Optional[float] = None,
|
|
993
|
+
case_sensitive: bool = False,
|
|
833
994
|
):
|
|
834
995
|
"""
|
|
835
996
|
Listen for updates to views for pipeline, yields the chunks of data
|
|
@@ -845,6 +1006,7 @@ Reason: The pipeline is in a STOPPED state due to the following error:
|
|
|
845
1006
|
"json" format, the default value is False
|
|
846
1007
|
|
|
847
1008
|
:param timeout: The amount of time in seconds to listen to the stream for
|
|
1009
|
+
:param case_sensitive: True if the table name is case sensitive or a reserved keyword, False by default
|
|
848
1010
|
"""
|
|
849
1011
|
|
|
850
1012
|
params = {
|
|
@@ -855,8 +1017,10 @@ Reason: The pipeline is in a STOPPED state due to the following error:
|
|
|
855
1017
|
if format == "json":
|
|
856
1018
|
params["array"] = _prepare_boolean_input(array)
|
|
857
1019
|
|
|
858
|
-
|
|
859
|
-
|
|
1020
|
+
table_name = f'"{table_name}"' if case_sensitive else table_name
|
|
1021
|
+
|
|
1022
|
+
resp: requests.Response = self.http.post(
|
|
1023
|
+
path=f"/pipelines/{quote(pipeline_name, safe='')}/egress/{quote(table_name, safe='')}",
|
|
860
1024
|
params=params,
|
|
861
1025
|
stream=True,
|
|
862
1026
|
)
|
|
@@ -960,7 +1124,7 @@ Reason: The pipeline is in a STOPPED state due to the following error:
|
|
|
960
1124
|
|
|
961
1125
|
def query_as_json(
|
|
962
1126
|
self, pipeline_name: str, query: str
|
|
963
|
-
) -> Generator[
|
|
1127
|
+
) -> Generator[Mapping[str, Any], None, None]:
|
|
964
1128
|
"""
|
|
965
1129
|
Executes an ad-hoc query on the specified pipeline and returns the result as a generator that yields
|
|
966
1130
|
rows of the query as Python dictionaries.
|
|
@@ -982,7 +1146,7 @@ Reason: The pipeline is in a STOPPED state due to the following error:
|
|
|
982
1146
|
stream=True,
|
|
983
1147
|
)
|
|
984
1148
|
|
|
985
|
-
for chunk in resp.iter_lines(chunk_size=
|
|
1149
|
+
for chunk in resp.iter_lines(chunk_size=1024):
|
|
986
1150
|
if chunk:
|
|
987
1151
|
yield json.loads(chunk, parse_float=Decimal)
|
|
988
1152
|
|
|
@@ -1034,7 +1198,7 @@ Reason: The pipeline is in a STOPPED state due to the following error:
|
|
|
1034
1198
|
|
|
1035
1199
|
def get_config(self) -> FelderaConfig:
|
|
1036
1200
|
"""
|
|
1037
|
-
|
|
1201
|
+
Retrieves the general Feldera server configuration.
|
|
1038
1202
|
"""
|
|
1039
1203
|
|
|
1040
1204
|
resp = self.http.get(path="/config")
|
|
@@ -1069,3 +1233,31 @@ Reason: The pipeline is in a STOPPED state due to the following error:
|
|
|
1069
1233
|
buffer += chunk
|
|
1070
1234
|
|
|
1071
1235
|
return buffer
|
|
1236
|
+
|
|
1237
|
+
def generate_completion_token(
|
|
1238
|
+
self, pipeline_name: str, table_name: str, connector_name: str
|
|
1239
|
+
) -> str:
|
|
1240
|
+
"""
|
|
1241
|
+
Generate a completion token that can be passed to :meth:`.FelderaClient.completion_token_processed` to
|
|
1242
|
+
check whether the pipeline has finished processing all inputs received from the connector before
|
|
1243
|
+
the token was generated.
|
|
1244
|
+
|
|
1245
|
+
:param pipeline_name: The name of the pipeline
|
|
1246
|
+
:param table_name: The name of the table associated with this connector.
|
|
1247
|
+
:param connector_name: The name of the connector.
|
|
1248
|
+
|
|
1249
|
+
:raises FelderaAPIError: If the connector cannot be found, or if the pipeline is not running.
|
|
1250
|
+
"""
|
|
1251
|
+
|
|
1252
|
+
resp = self.http.get(
|
|
1253
|
+
path=f"/pipelines/{pipeline_name}/tables/{table_name}/connectors/{connector_name}/completion_token",
|
|
1254
|
+
)
|
|
1255
|
+
|
|
1256
|
+
token: str | None = resp.get("token")
|
|
1257
|
+
|
|
1258
|
+
if token is None:
|
|
1259
|
+
raise ValueError(
|
|
1260
|
+
"got invalid response from feldera when generating completion token"
|
|
1261
|
+
)
|
|
1262
|
+
|
|
1263
|
+
return token
|