temporal-flowrensic-python-sdk 0.6.63__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.
Files changed (54) hide show
  1. temporal_flowrensic/__init__.py +2 -0
  2. temporal_flowrensic/_calls.py +185 -0
  3. temporal_flowrensic/_ci.py +56 -0
  4. temporal_flowrensic/_deploy.py +65 -0
  5. temporal_flowrensic/_gen/flowrensic/v1/activity_metrics_pb2.py +76 -0
  6. temporal_flowrensic/_gen/flowrensic/v1/activity_metrics_pb2.pyi +301 -0
  7. temporal_flowrensic/_gen/flowrensic/v1/catalog_pb2.py +60 -0
  8. temporal_flowrensic/_gen/flowrensic/v1/catalog_pb2.pyi +127 -0
  9. temporal_flowrensic/_gen/flowrensic/v1/catch_up_pb2.py +42 -0
  10. temporal_flowrensic/_gen/flowrensic/v1/catch_up_pb2.pyi +71 -0
  11. temporal_flowrensic/_gen/flowrensic/v1/client_calls_pb2.py +49 -0
  12. temporal_flowrensic/_gen/flowrensic/v1/client_calls_pb2.pyi +90 -0
  13. temporal_flowrensic/_gen/flowrensic/v1/cost_pb2.py +52 -0
  14. temporal_flowrensic/_gen/flowrensic/v1/cost_pb2.pyi +107 -0
  15. temporal_flowrensic/_gen/flowrensic/v1/deploy_pb2.py +51 -0
  16. temporal_flowrensic/_gen/flowrensic/v1/deploy_pb2.pyi +92 -0
  17. temporal_flowrensic/_gen/flowrensic/v1/ephemeral_run_pb2.py +107 -0
  18. temporal_flowrensic/_gen/flowrensic/v1/ephemeral_run_pb2.pyi +344 -0
  19. temporal_flowrensic/_gen/flowrensic/v1/finding_pb2.py +76 -0
  20. temporal_flowrensic/_gen/flowrensic/v1/finding_pb2.pyi +371 -0
  21. temporal_flowrensic/_gen/flowrensic/v1/job_pb2.py +46 -0
  22. temporal_flowrensic/_gen/flowrensic/v1/job_pb2.pyi +51 -0
  23. temporal_flowrensic/_gen/flowrensic/v1/load_pb2.py +51 -0
  24. temporal_flowrensic/_gen/flowrensic/v1/load_pb2.pyi +118 -0
  25. temporal_flowrensic/_gen/flowrensic/v1/page_pb2.py +38 -0
  26. temporal_flowrensic/_gen/flowrensic/v1/page_pb2.pyi +23 -0
  27. temporal_flowrensic/_gen/flowrensic/v1/remediation_pb2.py +60 -0
  28. temporal_flowrensic/_gen/flowrensic/v1/remediation_pb2.pyi +157 -0
  29. temporal_flowrensic/_gen/flowrensic/v1/replay_pb2.py +84 -0
  30. temporal_flowrensic/_gen/flowrensic/v1/replay_pb2.pyi +309 -0
  31. temporal_flowrensic/_gen/flowrensic/v1/server_config_pb2.py +48 -0
  32. temporal_flowrensic/_gen/flowrensic/v1/server_config_pb2.pyi +97 -0
  33. temporal_flowrensic/_gen/flowrensic/v1/services_connect.py +3621 -0
  34. temporal_flowrensic/_gen/flowrensic/v1/services_pb2.py +60 -0
  35. temporal_flowrensic/_gen/flowrensic/v1/services_pb2.pyi +20 -0
  36. temporal_flowrensic/_gen/flowrensic/v1/stored_data_pb2.py +43 -0
  37. temporal_flowrensic/_gen/flowrensic/v1/stored_data_pb2.pyi +50 -0
  38. temporal_flowrensic/_gen/flowrensic/v1/workflow_history_pb2.py +47 -0
  39. temporal_flowrensic/_gen/flowrensic/v1/workflow_history_pb2.pyi +102 -0
  40. temporal_flowrensic/_gen/flowrensic/v1/workflow_types_pb2.py +55 -0
  41. temporal_flowrensic/_gen/flowrensic/v1/workflow_types_pb2.pyi +197 -0
  42. temporal_flowrensic/_names.py +42 -0
  43. temporal_flowrensic/_queries.py +61 -0
  44. temporal_flowrensic/_replay.py +378 -0
  45. temporal_flowrensic/_sink.py +574 -0
  46. temporal_flowrensic/_telemetry.py +559 -0
  47. temporal_flowrensic/activity.py +136 -0
  48. temporal_flowrensic/client.py +166 -0
  49. temporal_flowrensic/py.typed +0 -0
  50. temporal_flowrensic/workflow.py +103 -0
  51. temporal_flowrensic_python_sdk-0.6.63.dist-info/METADATA +74 -0
  52. temporal_flowrensic_python_sdk-0.6.63.dist-info/RECORD +54 -0
  53. temporal_flowrensic_python_sdk-0.6.63.dist-info/WHEEL +4 -0
  54. temporal_flowrensic_python_sdk-0.6.63.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,42 @@
1
+ """The names of workflow interceptors, as Flowrensic is sent them by a worker
2
+ and by a replay, so the two lists can be compared."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from typing import Any, Optional, Sequence
8
+
9
+ from temporal_flowrensic._gen.flowrensic.v1.replay_pb2 import WorkflowInterceptors
10
+
11
+ __all__ = ["interceptor_list", "interceptor_name"]
12
+
13
+ _EXTENSIONS = re.compile(r"\.(?:[cm]?[jt]s|[jt]sx|py)$")
14
+
15
+
16
+ def interceptor_name(interceptor: Any) -> str:
17
+ """The name of a workflow interceptor. A path or module name is named by
18
+ its file name without the folder and the extension, and a file named
19
+ index or __init__ by the name of its folder, as the Node SDK names a
20
+ module. So /app/interceptors/metrics.py, src/interceptors/metrics.py and
21
+ app.interceptors.metrics are all metrics. A class is named by its class
22
+ name, and any other object by the name of its class."""
23
+ if isinstance(interceptor, str):
24
+ parts = [part for part in re.split(r"[/\\]", interceptor) if part]
25
+ file = _EXTENSIONS.sub("", parts[-1] if parts else "")
26
+ if "/" not in interceptor and "\\" not in interceptor and "." in file:
27
+ dotted = [part for part in file.split(".") if part]
28
+ parts = dotted
29
+ file = dotted[-1] if dotted else ""
30
+ if file in ("index", "__init__") and len(parts) > 1:
31
+ return _EXTENSIONS.sub("", parts[-2])
32
+ return file
33
+ if isinstance(interceptor, type):
34
+ return interceptor.__name__
35
+ return type(interceptor).__name__
36
+
37
+
38
+ def interceptor_list(interceptors: Optional[Sequence[Any]]) -> Optional[WorkflowInterceptors]:
39
+ """The interceptors as Flowrensic is sent them, in their order. None sends no list."""
40
+ if interceptors is None:
41
+ return None
42
+ return WorkflowInterceptors(names=[interceptor_name(one) for one in interceptors])
@@ -0,0 +1,61 @@
1
+ """The buffer of the queries the workflows of a worker answered, counted per
2
+ workflow type, query name and minute when a batch is sent."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass
7
+
8
+ from temporal_flowrensic._gen.flowrensic.v1.client_calls_pb2 import IngestQueryCountsRequest, QueryCount
9
+ from temporal_flowrensic._sink import BufferTarget, SendId, client_headers, ingest_client_sync
10
+
11
+ __all__ = ["Answered", "QueryTarget"]
12
+
13
+ _MINUTE = 60
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Answered:
18
+ """One query a workflow answered, ok when its handler returned, at a time in seconds."""
19
+
20
+ namespace: str
21
+ workflow_type: str
22
+ name: str
23
+ ok: bool
24
+ at: float
25
+
26
+
27
+ class QueryTarget(BufferTarget[Answered]):
28
+ def __init__(
29
+ self, server_base_url: str, env: str, env_access_key: str, ephemeral_run: str
30
+ ) -> None:
31
+ self._ingest = ingest_client_sync(server_base_url)
32
+ self._env = env
33
+ self._headers = client_headers(env, env_access_key, ephemeral_run)
34
+
35
+ def send(self, records: list[Answered], timeout_ms: int, id: SendId) -> None:
36
+ counts: dict[tuple[str, str, str, int], QueryCount] = {}
37
+ for one in records:
38
+ minute = int(one.at // _MINUTE) * _MINUTE
39
+ key = (one.namespace, one.workflow_type, one.name, minute)
40
+ count = counts.get(key)
41
+ if count is None:
42
+ count = QueryCount(
43
+ namespace=one.namespace,
44
+ env=self._env,
45
+ workflow_type=one.workflow_type,
46
+ query_name=one.name,
47
+ )
48
+ count.minute.FromSeconds(minute)
49
+ counts[key] = count
50
+ if one.ok:
51
+ count.answered += 1
52
+ else:
53
+ count.failed += 1
54
+ self._ingest.ingest_query_counts(
55
+ IngestQueryCountsRequest(counts=list(counts.values()), sender=id.sender, send_number=id.send_number),
56
+ headers=self._headers,
57
+ timeout_ms=max(1, timeout_ms),
58
+ )
59
+
60
+ def env_of(self, record: Answered) -> str:
61
+ return self._env
@@ -0,0 +1,378 @@
1
+ """Replay before deploy: fetch the replay set Flowrensic picks for a workflow
2
+ type in each of the envs named, write it to disk the way the CLI and the Node SDK do, and send
3
+ the verdicts of a replay back. Nothing here runs a workflow; the CI job
4
+ replays with temporalio's Replayer."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import json
10
+ import os
11
+ import sys
12
+ import time
13
+ from dataclasses import dataclass, field
14
+ from datetime import datetime, timezone
15
+ from typing import Any, Optional, Sequence
16
+
17
+ from connectrpc.code import Code
18
+ from connectrpc.errors import ConnectError
19
+ from google.protobuf.timestamp_pb2 import Timestamp
20
+
21
+ from temporal_flowrensic._ci import ci_error, ci_failure
22
+ from temporal_flowrensic._gen.flowrensic.v1.replay_pb2 import (
23
+ RUN_STATUS_CANCELED,
24
+ RUN_STATUS_COMPLETED,
25
+ RUN_STATUS_CONTINUED_AS_NEW,
26
+ RUN_STATUS_FAILED,
27
+ RUN_STATUS_PAUSED,
28
+ RUN_STATUS_RUNNING,
29
+ RUN_STATUS_TERMINATED,
30
+ RUN_STATUS_TIMED_OUT,
31
+ RUN_STATUS_UNSPECIFIED,
32
+ IngestReplayResultsRequest,
33
+ ListReplayHistoriesRequest,
34
+ ListReplayHistoriesResponse,
35
+ ReplayResult,
36
+ )
37
+ from temporal_flowrensic._gen.flowrensic.v1.page_pb2 import PageRequest
38
+ from temporal_flowrensic._gen.flowrensic.v1.services_connect import IngestServiceClient, QueryServiceClient
39
+ from temporal_flowrensic._names import interceptor_list
40
+ from temporal_flowrensic._sink import client_headers
41
+
42
+ __all__ = [
43
+ "INDEX_NAME",
44
+ "LeftOutEntry",
45
+ "ReplaySet",
46
+ "ReplaySetEntry",
47
+ "ReplayVerdict",
48
+ "fetch_replay_set",
49
+ "list_history_files",
50
+ "publish_verdicts",
51
+ "read_replay_set",
52
+ ]
53
+
54
+ INDEX_NAME = "replay-set.json"
55
+ """The manifest written beside the histories, the same one the CLI writes."""
56
+
57
+ _DOWNLOAD_TIMEOUT_MS = 60_000
58
+ _PUBLISH_TIMEOUT_MS = 30_000
59
+ _SEND_AGAIN_FOR = 600.0
60
+
61
+ # A manifest reads completed, the same label the CLI writes.
62
+ _STATUS_LABELS = {
63
+ RUN_STATUS_UNSPECIFIED: "unspecified",
64
+ RUN_STATUS_RUNNING: "running",
65
+ RUN_STATUS_COMPLETED: "completed",
66
+ RUN_STATUS_FAILED: "failed",
67
+ RUN_STATUS_CANCELED: "canceled",
68
+ RUN_STATUS_TERMINATED: "terminated",
69
+ RUN_STATUS_TIMED_OUT: "timed_out",
70
+ RUN_STATUS_CONTINUED_AS_NEW: "continued_as_new",
71
+ RUN_STATUS_PAUSED: "paused",
72
+ }
73
+
74
+
75
+ def _iso(ts: Optional[Timestamp]) -> str:
76
+ seconds = 0.0 if ts is None else ts.seconds + ts.nanos / 1e9
77
+ return datetime.fromtimestamp(seconds, tz=timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
78
+
79
+
80
+ def _now() -> str:
81
+ return datetime.now(tz=timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class ReplaySetEntry:
86
+ """One history on disk, with what the server chose it by."""
87
+
88
+ run_id: str
89
+ file: str
90
+ status: str
91
+ code_versions: list[str]
92
+ events: int
93
+ started_at: str
94
+ env: Optional[str] = None
95
+ closed_at: Optional[str] = None
96
+
97
+ def to_json(self) -> dict[str, Any]:
98
+ out: dict[str, Any] = {"run_id": self.run_id, "file": self.file, "status": self.status}
99
+ if self.env is not None:
100
+ out["env"] = self.env
101
+ out["code_versions"] = list(self.code_versions)
102
+ out["events"] = self.events
103
+ out["started_at"] = self.started_at
104
+ if self.closed_at is not None:
105
+ out["closed_at"] = self.closed_at
106
+ return out
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class LeftOutEntry:
111
+ """A stored run the server could not read, so it is not in the set."""
112
+
113
+ run_id: str
114
+ reason: str
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class ReplaySet:
119
+ """The set of one workflow type in the envs named, as the manifest holds it."""
120
+
121
+ workflow_type: str
122
+ envs: list[str]
123
+ """The envs the histories were fetched from, in the order they were named."""
124
+ fetched_at: str
125
+ server: str
126
+ histories: list[ReplaySetEntry]
127
+ left_out: list[LeftOutEntry] = field(default_factory=list)
128
+ failed: Optional[str] = None
129
+ """Why the download failed, when fail_on_error was False and it did. The set is then empty."""
130
+
131
+ def to_json(self) -> dict[str, Any]:
132
+ """The manifest as the CLI and the Node SDK write it."""
133
+ out: dict[str, Any] = {"workflow_type": self.workflow_type, "envs": list(self.envs)}
134
+ out["fetched_at"] = self.fetched_at
135
+ out["server"] = self.server
136
+ out["histories"] = [one.to_json() for one in self.histories]
137
+ out["left_out"] = [{"run_id": one.run_id, "reason": one.reason} for one in self.left_out]
138
+ if self.failed is not None:
139
+ out["failed"] = self.failed
140
+ return out
141
+
142
+ @staticmethod
143
+ def from_json(value: Any) -> ReplaySet:
144
+ if not isinstance(value, dict) or not isinstance(value.get("workflow_type"), str):
145
+ raise ValueError("not a replay set manifest")
146
+ if not isinstance(value.get("histories"), list):
147
+ raise ValueError("not a replay set manifest")
148
+ return ReplaySet(
149
+ workflow_type=value["workflow_type"],
150
+ envs=[str(one) for one in value.get("envs", [])],
151
+ fetched_at=value.get("fetched_at", ""),
152
+ server=value.get("server", ""),
153
+ histories=[
154
+ ReplaySetEntry(
155
+ run_id=one["run_id"],
156
+ file=one["file"],
157
+ status=one.get("status", ""),
158
+ env=one.get("env"),
159
+ code_versions=list(one.get("code_versions", [])),
160
+ events=one.get("events", 0),
161
+ started_at=one.get("started_at", ""),
162
+ closed_at=one.get("closed_at"),
163
+ )
164
+ for one in value["histories"]
165
+ ],
166
+ left_out=[LeftOutEntry(one["run_id"], one.get("reason", "")) for one in value.get("left_out", [])],
167
+ failed=value.get("failed"),
168
+ )
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class ReplayVerdict:
173
+ """What one history did when replayed against the code under test."""
174
+
175
+ run_id: str
176
+ workflow_type: str
177
+ passed: bool
178
+ error: Optional[str] = None
179
+ """The replayer's own message, kept as it is: it names what diverged."""
180
+
181
+
182
+ def _write(path: str, data: bytes) -> None:
183
+ with open(path, "wb") as f:
184
+ f.write(data)
185
+ os.chmod(path, 0o600)
186
+
187
+
188
+ async def fetch_replay_set(
189
+ *,
190
+ server_base_url: str,
191
+ replay_history_workflow: str,
192
+ replay_history_out_dir: str,
193
+ replay_history_from_envs: Sequence[str],
194
+ current_env: str,
195
+ env_access_key: str,
196
+ current_ephemeral_run: str = "",
197
+ timeout_ms: int = _DOWNLOAD_TIMEOUT_MS,
198
+ fail_on_error: bool = True,
199
+ ) -> ReplaySet:
200
+ """Fetches the set the server picks for a workflow type in each env named,
201
+ in one request, and writes each history to replay_history_out_dir as
202
+ RUN_ID.json, as the server serves it, and the manifest replay-set.json
203
+ beside them.
204
+
205
+ replay_history_from_envs is a list of env names, such as ["prod",
206
+ "staging"]. Each must be current_env or an env it may read. A name given
207
+ twice is read once. A plain string raises ValueError. current_env,
208
+ env_access_key and current_ephemeral_run are those of the caller and go in
209
+ their headers.
210
+
211
+ The download has a time limit of timeout_ms milliseconds, 60000 by
212
+ default. When it passes or
213
+ Flowrensic refuses, it raises CIError with a message that says Flowrensic
214
+ could not be reached or what it answered. With fail_on_error False it
215
+ returns an empty set whose failed field says why, and writes nothing."""
216
+ if isinstance(replay_history_from_envs, str):
217
+ raise ValueError("flowrensic: replay_history_from_envs takes a list of env names, got a plain string")
218
+ workflow_type = replay_history_workflow
219
+ envs = list(dict.fromkeys(replay_history_from_envs))
220
+ dir = replay_history_out_dir
221
+ try:
222
+ async with QueryServiceClient(server_base_url.rstrip("/"), send_compression=None) as query:
223
+ # The answer comes in pages of whole env sets: ask until the server
224
+ # says there is no more, and keep every page in order.
225
+ found = ListReplayHistoriesResponse()
226
+ page_token = ""
227
+ while True:
228
+ page = await query.list_replay_histories(
229
+ ListReplayHistoriesRequest(
230
+ workflow_type=workflow_type, envs=envs, page=PageRequest(page_token=page_token)
231
+ ),
232
+ headers=client_headers(current_env, env_access_key, current_ephemeral_run),
233
+ timeout_ms=max(1, timeout_ms),
234
+ )
235
+ found.histories.extend(page.histories)
236
+ found.left_out.extend(page.left_out)
237
+ page_token = page.page.next_page_token
238
+ if not page_token:
239
+ break
240
+ except Exception as error: # noqa: BLE001
241
+ if fail_on_error:
242
+ raise ci_error("replay set download", server_base_url, timeout_ms, error) from error
243
+ return ReplaySet(
244
+ workflow_type=workflow_type,
245
+ envs=envs,
246
+ fetched_at=_now(),
247
+ server=server_base_url,
248
+ histories=[],
249
+ failed=ci_failure("replay set download", server_base_url, timeout_ms, error),
250
+ )
251
+
252
+ os.makedirs(dir, mode=0o750, exist_ok=True)
253
+ histories = []
254
+ for history in found.histories:
255
+ file = f"{history.run_id}.json"
256
+ # Written and renamed, so a failed write never leaves half a history.
257
+ temp = os.path.join(dir, f".fetch-{history.run_id}")
258
+ _write(temp, history.history)
259
+ os.replace(temp, os.path.join(dir, file))
260
+ histories.append(
261
+ ReplaySetEntry(
262
+ run_id=history.run_id,
263
+ file=file,
264
+ status=_STATUS_LABELS.get(history.status, "unspecified"),
265
+ env=history.env or None,
266
+ code_versions=list(history.code_versions),
267
+ events=history.events,
268
+ started_at=_iso(history.started_at if history.HasField("started_at") else None),
269
+ closed_at=_iso(history.closed_at) if history.HasField("closed_at") else None,
270
+ )
271
+ )
272
+ found_set = ReplaySet(
273
+ workflow_type=workflow_type,
274
+ envs=envs,
275
+ fetched_at=_now(),
276
+ server=server_base_url,
277
+ histories=histories,
278
+ left_out=[LeftOutEntry(one.run_id, one.reason) for one in found.left_out],
279
+ )
280
+ _write(os.path.join(dir, INDEX_NAME), (json.dumps(found_set.to_json(), indent=2) + "\n").encode("utf-8"))
281
+ _log_fetched(found_set, len(found.histories), dir)
282
+ return found_set
283
+
284
+
285
+ def _log_fetched(found_set: ReplaySet, got: int, dir: str) -> None:
286
+ """Writes to stderr what a download got, the same lines in every SDK and
287
+ the CLI: the workflow type and the envs, how many histories the answer held
288
+ and how many were saved, the run id of each, and each run left out with its
289
+ reason."""
290
+ lines = [
291
+ f"replay set: workflow {found_set.workflow_type}, envs {', '.join(found_set.envs)}",
292
+ f"got {got} histories, saved {len(found_set.histories)} in {dir}",
293
+ *(f" {one.run_id}" for one in found_set.histories),
294
+ *(f"left out {one.run_id}: {one.reason}" for one in found_set.left_out),
295
+ ]
296
+ sys.stderr.write("\n".join(lines) + "\n")
297
+ sys.stderr.flush()
298
+
299
+
300
+ def read_replay_set(dir: str) -> ReplaySet:
301
+ """Reads a manifest a fetch left behind, the CLI's or this package's."""
302
+ path = os.path.join(dir, INDEX_NAME)
303
+ with open(path, encoding="utf-8") as f:
304
+ try:
305
+ return ReplaySet.from_json(json.load(f))
306
+ except ValueError as error:
307
+ raise ValueError(f"{path} is not a replay set manifest") from error
308
+
309
+
310
+ def list_history_files(dir: str) -> list[str]:
311
+ """The histories in a directory, without the manifest."""
312
+ return sorted(name for name in os.listdir(dir) if name.endswith(".json") and name != INDEX_NAME)
313
+
314
+
315
+ def _wait_before_sending_again(error: BaseException) -> Optional[float]:
316
+ """Seconds to wait before sending again a call that Flowrensic did not
317
+ answer or answered try again later, one second. None for any other error,
318
+ which is not sent again."""
319
+ if not isinstance(error, ConnectError):
320
+ return None
321
+ if error.code not in (Code.UNAVAILABLE, Code.DEADLINE_EXCEEDED, Code.RESOURCE_EXHAUSTED):
322
+ return None
323
+ return 1.0
324
+
325
+
326
+ async def publish_verdicts(
327
+ *,
328
+ server_base_url: str,
329
+ code_version: str,
330
+ verdicts: Sequence[ReplayVerdict],
331
+ current_env: str,
332
+ env_access_key: str,
333
+ current_ephemeral_run: str = "",
334
+ workflow_interceptors: Optional[Sequence[Any]] = None,
335
+ send_again_for: float = _SEND_AGAIN_FOR,
336
+ timeout_ms: int = _PUBLISH_TIMEOUT_MS,
337
+ ) -> None:
338
+ """Reports what a replay found, against the history and the code version
339
+ replayed, so a break is put down to a code change. workflow_interceptors
340
+ are the workflow interceptors the replay ran with, sent by name; left out,
341
+ no list is sent. A call Flowrensic does not answer, or answers try again
342
+ later, is sent again for up to send_again_for seconds, 10 minutes by
343
+ default. Each call has a time limit of timeout_ms milliseconds, 30000 by
344
+ default. When it gives up or Flowrensic refuses, it raises CIError.
345
+ current_env, env_access_key and current_ephemeral_run go in their headers."""
346
+ if not verdicts:
347
+ return
348
+ interceptors = interceptor_list(workflow_interceptors)
349
+ replayed_at = Timestamp()
350
+ replayed_at.FromNanoseconds(int(time.time() * 1_000_000_000))
351
+ results = []
352
+ for v in verdicts:
353
+ one = ReplayResult(
354
+ run_id=v.run_id,
355
+ code_version=code_version,
356
+ workflow_type=v.workflow_type,
357
+ passed=v.passed,
358
+ error=v.error or "",
359
+ replayed_at=replayed_at,
360
+ )
361
+ if interceptors is not None:
362
+ one.workflow_interceptors.CopyFrom(interceptors)
363
+ results.append(one)
364
+ give_up_at = time.monotonic() + send_again_for
365
+ async with IngestServiceClient(server_base_url.rstrip("/"), send_compression=None) as ingest:
366
+ while True:
367
+ try:
368
+ await ingest.ingest_replay_results(
369
+ IngestReplayResultsRequest(results=results),
370
+ headers=client_headers(current_env, env_access_key, current_ephemeral_run),
371
+ timeout_ms=max(1, timeout_ms),
372
+ )
373
+ return
374
+ except Exception as error: # noqa: BLE001
375
+ wait = _wait_before_sending_again(error)
376
+ if wait is None or time.monotonic() + wait > give_up_at:
377
+ raise ci_error("replay results", server_base_url, timeout_ms, error) from error
378
+ await asyncio.sleep(wait)