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.
- temporal_flowrensic/__init__.py +2 -0
- temporal_flowrensic/_calls.py +185 -0
- temporal_flowrensic/_ci.py +56 -0
- temporal_flowrensic/_deploy.py +65 -0
- temporal_flowrensic/_gen/flowrensic/v1/activity_metrics_pb2.py +76 -0
- temporal_flowrensic/_gen/flowrensic/v1/activity_metrics_pb2.pyi +301 -0
- temporal_flowrensic/_gen/flowrensic/v1/catalog_pb2.py +60 -0
- temporal_flowrensic/_gen/flowrensic/v1/catalog_pb2.pyi +127 -0
- temporal_flowrensic/_gen/flowrensic/v1/catch_up_pb2.py +42 -0
- temporal_flowrensic/_gen/flowrensic/v1/catch_up_pb2.pyi +71 -0
- temporal_flowrensic/_gen/flowrensic/v1/client_calls_pb2.py +49 -0
- temporal_flowrensic/_gen/flowrensic/v1/client_calls_pb2.pyi +90 -0
- temporal_flowrensic/_gen/flowrensic/v1/cost_pb2.py +52 -0
- temporal_flowrensic/_gen/flowrensic/v1/cost_pb2.pyi +107 -0
- temporal_flowrensic/_gen/flowrensic/v1/deploy_pb2.py +51 -0
- temporal_flowrensic/_gen/flowrensic/v1/deploy_pb2.pyi +92 -0
- temporal_flowrensic/_gen/flowrensic/v1/ephemeral_run_pb2.py +107 -0
- temporal_flowrensic/_gen/flowrensic/v1/ephemeral_run_pb2.pyi +344 -0
- temporal_flowrensic/_gen/flowrensic/v1/finding_pb2.py +76 -0
- temporal_flowrensic/_gen/flowrensic/v1/finding_pb2.pyi +371 -0
- temporal_flowrensic/_gen/flowrensic/v1/job_pb2.py +46 -0
- temporal_flowrensic/_gen/flowrensic/v1/job_pb2.pyi +51 -0
- temporal_flowrensic/_gen/flowrensic/v1/load_pb2.py +51 -0
- temporal_flowrensic/_gen/flowrensic/v1/load_pb2.pyi +118 -0
- temporal_flowrensic/_gen/flowrensic/v1/page_pb2.py +38 -0
- temporal_flowrensic/_gen/flowrensic/v1/page_pb2.pyi +23 -0
- temporal_flowrensic/_gen/flowrensic/v1/remediation_pb2.py +60 -0
- temporal_flowrensic/_gen/flowrensic/v1/remediation_pb2.pyi +157 -0
- temporal_flowrensic/_gen/flowrensic/v1/replay_pb2.py +84 -0
- temporal_flowrensic/_gen/flowrensic/v1/replay_pb2.pyi +309 -0
- temporal_flowrensic/_gen/flowrensic/v1/server_config_pb2.py +48 -0
- temporal_flowrensic/_gen/flowrensic/v1/server_config_pb2.pyi +97 -0
- temporal_flowrensic/_gen/flowrensic/v1/services_connect.py +3621 -0
- temporal_flowrensic/_gen/flowrensic/v1/services_pb2.py +60 -0
- temporal_flowrensic/_gen/flowrensic/v1/services_pb2.pyi +20 -0
- temporal_flowrensic/_gen/flowrensic/v1/stored_data_pb2.py +43 -0
- temporal_flowrensic/_gen/flowrensic/v1/stored_data_pb2.pyi +50 -0
- temporal_flowrensic/_gen/flowrensic/v1/workflow_history_pb2.py +47 -0
- temporal_flowrensic/_gen/flowrensic/v1/workflow_history_pb2.pyi +102 -0
- temporal_flowrensic/_gen/flowrensic/v1/workflow_types_pb2.py +55 -0
- temporal_flowrensic/_gen/flowrensic/v1/workflow_types_pb2.pyi +197 -0
- temporal_flowrensic/_names.py +42 -0
- temporal_flowrensic/_queries.py +61 -0
- temporal_flowrensic/_replay.py +378 -0
- temporal_flowrensic/_sink.py +574 -0
- temporal_flowrensic/_telemetry.py +559 -0
- temporal_flowrensic/activity.py +136 -0
- temporal_flowrensic/client.py +166 -0
- temporal_flowrensic/py.typed +0 -0
- temporal_flowrensic/workflow.py +103 -0
- temporal_flowrensic_python_sdk-0.6.63.dist-info/METADATA +74 -0
- temporal_flowrensic_python_sdk-0.6.63.dist-info/RECORD +54 -0
- temporal_flowrensic_python_sdk-0.6.63.dist-info/WHEEL +4 -0
- temporal_flowrensic_python_sdk-0.6.63.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
"""The record buffer every sender of the SDK uses: records wait in the worker's
|
|
2
|
+
memory and leave in batches on a thread of their own, so recording never waits
|
|
3
|
+
on Flowrensic."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import socket
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from collections import deque
|
|
13
|
+
from collections.abc import Iterable, Mapping
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Generic, Optional, TypeVar
|
|
16
|
+
|
|
17
|
+
from connectrpc.code import Code
|
|
18
|
+
from connectrpc.errors import ConnectError
|
|
19
|
+
from google.protobuf.message import Message
|
|
20
|
+
|
|
21
|
+
from temporal_flowrensic._gen.flowrensic.v1.activity_metrics_pb2 import (
|
|
22
|
+
ActivityAttempt,
|
|
23
|
+
IngestActivityEventsRequest,
|
|
24
|
+
WorkerBufferReport,
|
|
25
|
+
)
|
|
26
|
+
from temporal_flowrensic._gen.flowrensic.v1.services_connect import IngestServiceClientSync
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AttemptSink",
|
|
30
|
+
"BufferTarget",
|
|
31
|
+
"RecordSink",
|
|
32
|
+
"SendId",
|
|
33
|
+
"SinkCounts",
|
|
34
|
+
"approx_size",
|
|
35
|
+
"create_attempt_sink",
|
|
36
|
+
"create_record_buffer",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
T = TypeVar("T")
|
|
40
|
+
|
|
41
|
+
CURRENT_ENV_HEADER = "Flowrensic-Current-Env"
|
|
42
|
+
ENV_ACCESS_KEY_HEADER = "Flowrensic-Env-Access-Key"
|
|
43
|
+
CURRENT_EPHEMERAL_RUN_HEADER = "Flowrensic-Current-Ephemeral-Run"
|
|
44
|
+
|
|
45
|
+
DEFAULT_BATCH_SIZE = 64
|
|
46
|
+
DEFAULT_FLUSH_INTERVAL = 1.0
|
|
47
|
+
DEFAULT_TIMEOUT_MS = 5000
|
|
48
|
+
DEFAULT_MAX_WAITING = 10_000
|
|
49
|
+
DEFAULT_MAX_BATCH_SIZE = 500
|
|
50
|
+
DEFAULT_MAX_WAITING_BYTES = 16 * 1024 * 1024
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class SinkCounts:
|
|
55
|
+
"""What a sink lost and holds, over every env."""
|
|
56
|
+
|
|
57
|
+
dropped: int
|
|
58
|
+
"""Records dropped since the sink was made: past a limit, in a send that was refused, or left at close."""
|
|
59
|
+
timed_out: int
|
|
60
|
+
"""Sends given up at their timeout since the sink was made."""
|
|
61
|
+
waiting: int
|
|
62
|
+
"""Records waiting to be sent now."""
|
|
63
|
+
waiting_bytes: int
|
|
64
|
+
"""The size of the records waiting now, in bytes, as approx_size gives it."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class SendId:
|
|
69
|
+
"""Which send a call is: the buffer that sends it and the number of the
|
|
70
|
+
send. A send sent again keeps its number, so Flowrensic stores it once."""
|
|
71
|
+
|
|
72
|
+
sender: str
|
|
73
|
+
send_number: int
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class BufferTarget(Generic[T]):
|
|
77
|
+
"""What a buffer needs from the sink that owns it."""
|
|
78
|
+
|
|
79
|
+
def send(self, records: list[T], timeout_ms: int, id: SendId) -> None:
|
|
80
|
+
"""Sends one batch, and raises ConnectError when it fails."""
|
|
81
|
+
raise NotImplementedError
|
|
82
|
+
|
|
83
|
+
def env_of(self, record: T) -> str:
|
|
84
|
+
"""The env a record is counted under."""
|
|
85
|
+
return ""
|
|
86
|
+
|
|
87
|
+
def report(self, reports: list[WorkerBufferReport], timeout_ms: int, id: SendId) -> None:
|
|
88
|
+
"""Sends the counts. A target that does not send them raises
|
|
89
|
+
NotImplementedError, and the counts stay in the worker."""
|
|
90
|
+
raise NotImplementedError
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def approx_size(value: Any, depth: int = 0) -> int:
|
|
94
|
+
"""The size of a record in the worker's memory, worked out from its fields:
|
|
95
|
+
two bytes per character of each text, the length of each byte array, eight
|
|
96
|
+
bytes for each other value, and 32 bytes for the record and each message or
|
|
97
|
+
list in it. Every field counts, a field left at its default too."""
|
|
98
|
+
if isinstance(value, str):
|
|
99
|
+
return len(value) * 2
|
|
100
|
+
if isinstance(value, (bytes, bytearray)):
|
|
101
|
+
return 32 + len(value)
|
|
102
|
+
if isinstance(value, Message):
|
|
103
|
+
if depth >= 8:
|
|
104
|
+
return 32
|
|
105
|
+
total = 32
|
|
106
|
+
for field in value.DESCRIPTOR.fields:
|
|
107
|
+
total += approx_size(getattr(value, field.name), depth + 1)
|
|
108
|
+
return total
|
|
109
|
+
if isinstance(value, Mapping):
|
|
110
|
+
if depth >= 8:
|
|
111
|
+
return 32
|
|
112
|
+
return 32 + sum(approx_size(one, depth + 1) for one in value.values())
|
|
113
|
+
if isinstance(value, Iterable):
|
|
114
|
+
if depth >= 8:
|
|
115
|
+
return 32
|
|
116
|
+
return 32 + sum(approx_size(one, depth + 1) for one in value)
|
|
117
|
+
return 8
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _send_again(error: BaseException) -> bool:
|
|
121
|
+
"""True when Flowrensic did not answer or answered try again later, so the batch is sent again."""
|
|
122
|
+
if not isinstance(error, ConnectError):
|
|
123
|
+
return False
|
|
124
|
+
return error.code in (Code.UNAVAILABLE, Code.DEADLINE_EXCEEDED, Code.RESOURCE_EXHAUSTED)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _add(counts: dict[str, int], env: str, n: int) -> None:
|
|
128
|
+
now = counts.get(env, 0) + n
|
|
129
|
+
if now == 0:
|
|
130
|
+
counts.pop(env, None)
|
|
131
|
+
else:
|
|
132
|
+
counts[env] = now
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class _Entry(Generic[T]):
|
|
137
|
+
record: T
|
|
138
|
+
size: int
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class _Kept(Generic[T]):
|
|
143
|
+
entries: list[_Entry[T]]
|
|
144
|
+
id: SendId
|
|
145
|
+
size: int
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class _KeptReport:
|
|
150
|
+
reports: list[WorkerBufferReport]
|
|
151
|
+
id: SendId
|
|
152
|
+
dropped: dict[str, int]
|
|
153
|
+
timed_out: dict[str, int]
|
|
154
|
+
waiting: dict[str, int]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class RecordSink(Generic[T]):
|
|
158
|
+
"""Buffers records and sends them to a Flowrensic server in batches, on a
|
|
159
|
+
thread of its own. record never waits and never raises."""
|
|
160
|
+
|
|
161
|
+
def __init__(
|
|
162
|
+
self,
|
|
163
|
+
target: BufferTarget[T],
|
|
164
|
+
*,
|
|
165
|
+
batch_size: int = DEFAULT_BATCH_SIZE,
|
|
166
|
+
flush_interval: float = DEFAULT_FLUSH_INTERVAL,
|
|
167
|
+
timeout_ms: int = DEFAULT_TIMEOUT_MS,
|
|
168
|
+
max_waiting: int = DEFAULT_MAX_WAITING,
|
|
169
|
+
max_batch_size: int = DEFAULT_MAX_BATCH_SIZE,
|
|
170
|
+
max_waiting_bytes: int = DEFAULT_MAX_WAITING_BYTES,
|
|
171
|
+
) -> None:
|
|
172
|
+
self._target = target
|
|
173
|
+
self._batch_size = batch_size
|
|
174
|
+
self._flush_interval = flush_interval
|
|
175
|
+
self._timeout_ms = timeout_ms
|
|
176
|
+
self._max_waiting = max_waiting
|
|
177
|
+
self._max_batch_size = max_batch_size
|
|
178
|
+
self._max_waiting_bytes = max_waiting_bytes
|
|
179
|
+
|
|
180
|
+
# Guards every field below. Held only for short work, never across a send.
|
|
181
|
+
self._lock = threading.Lock()
|
|
182
|
+
self._wake = threading.Condition(self._lock)
|
|
183
|
+
# One send or report at a time, by the thread or by flush.
|
|
184
|
+
self._sending = threading.Lock()
|
|
185
|
+
|
|
186
|
+
self._queue: deque[_Entry[T]] = deque()
|
|
187
|
+
self._queued_bytes = 0
|
|
188
|
+
self._kept: Optional[_Kept[T]] = None
|
|
189
|
+
self._kept_report: Optional[_KeptReport] = None
|
|
190
|
+
sender = str(uuid.uuid4())
|
|
191
|
+
self._records_sender = f"{sender}/records"
|
|
192
|
+
self._reports_sender = f"{sender}/reports"
|
|
193
|
+
self._send_number = 0
|
|
194
|
+
self._report_number = 0
|
|
195
|
+
self._waiting_by_env: dict[str, int] = {}
|
|
196
|
+
self._dropped: dict[str, int] = {}
|
|
197
|
+
self._timed_out: dict[str, int] = {}
|
|
198
|
+
self._reported_waiting: dict[str, int] = {}
|
|
199
|
+
self._dropped_total = 0
|
|
200
|
+
self._timed_out_total = 0
|
|
201
|
+
self._failing = False
|
|
202
|
+
self._woken = False
|
|
203
|
+
self._stopped = False
|
|
204
|
+
|
|
205
|
+
self._thread = threading.Thread(target=self._run, name="flowrensic-sink", daemon=True)
|
|
206
|
+
self._thread.start()
|
|
207
|
+
|
|
208
|
+
# What the worker calls.
|
|
209
|
+
|
|
210
|
+
def record(self, one: T) -> None:
|
|
211
|
+
"""Puts one record in the buffer. Never waits on a send and never raises."""
|
|
212
|
+
try:
|
|
213
|
+
size = approx_size(one)
|
|
214
|
+
env = self._target.env_of(one)
|
|
215
|
+
with self._lock:
|
|
216
|
+
if self._stopped:
|
|
217
|
+
_add(self._dropped, env, 1)
|
|
218
|
+
self._dropped_total += 1
|
|
219
|
+
return
|
|
220
|
+
self._queue.append(_Entry(one, size))
|
|
221
|
+
self._queued_bytes += size
|
|
222
|
+
_add(self._waiting_by_env, env, 1)
|
|
223
|
+
self._drop_oldest()
|
|
224
|
+
if self._waiting_count() >= self._batch_size and not self._failing:
|
|
225
|
+
self._woken = True
|
|
226
|
+
self._wake.notify()
|
|
227
|
+
except Exception: # noqa: BLE001
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
def counts(self) -> SinkCounts:
|
|
231
|
+
with self._lock:
|
|
232
|
+
return SinkCounts(
|
|
233
|
+
dropped=self._dropped_total,
|
|
234
|
+
timed_out=self._timed_out_total,
|
|
235
|
+
waiting=self._waiting_count(),
|
|
236
|
+
waiting_bytes=self._waiting_bytes(),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def batch_size(self) -> int:
|
|
241
|
+
"""A send starts once this many records wait."""
|
|
242
|
+
return self._batch_size
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def flush_interval(self) -> float:
|
|
246
|
+
"""Seconds between two ticks, each of which sends what waits."""
|
|
247
|
+
return self._flush_interval
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def timeout_ms(self) -> int:
|
|
251
|
+
"""Milliseconds one send may take before it is given up."""
|
|
252
|
+
return self._timeout_ms
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def max_waiting(self) -> int:
|
|
256
|
+
"""How many records may wait. Past it the oldest is dropped."""
|
|
257
|
+
return self._max_waiting
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def max_batch_size(self) -> int:
|
|
261
|
+
"""How many records one send carries at most."""
|
|
262
|
+
return self._max_batch_size
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def max_waiting_bytes(self) -> int:
|
|
266
|
+
"""How many bytes the records waiting may take, as approx_size gives it. Past it the oldest is dropped."""
|
|
267
|
+
return self._max_waiting_bytes
|
|
268
|
+
|
|
269
|
+
def flush_sync(self) -> None:
|
|
270
|
+
"""Sends what waits and the counts, and returns once they landed or a
|
|
271
|
+
send went back. Blocks the calling thread."""
|
|
272
|
+
with self._sending:
|
|
273
|
+
tried = False
|
|
274
|
+
while True:
|
|
275
|
+
with self._lock:
|
|
276
|
+
if self._waiting_count() == 0 or (self._failing and tried):
|
|
277
|
+
break
|
|
278
|
+
tried = True
|
|
279
|
+
self._failing = False
|
|
280
|
+
self._send_while_answered()
|
|
281
|
+
self._report()
|
|
282
|
+
|
|
283
|
+
async def flush(self) -> None:
|
|
284
|
+
"""Sends what waits and the counts, and waits for them to land, off the event loop."""
|
|
285
|
+
import asyncio
|
|
286
|
+
|
|
287
|
+
await asyncio.get_running_loop().run_in_executor(None, self.flush_sync)
|
|
288
|
+
|
|
289
|
+
def close_sync(self) -> None:
|
|
290
|
+
"""Stops the thread, sends what waits once more, and drops what is left. Blocks the calling thread."""
|
|
291
|
+
with self._lock:
|
|
292
|
+
if self._stopped:
|
|
293
|
+
return
|
|
294
|
+
self._stopped = True
|
|
295
|
+
self._wake.notify()
|
|
296
|
+
self._thread.join()
|
|
297
|
+
self.flush_sync()
|
|
298
|
+
with self._sending:
|
|
299
|
+
with self._lock:
|
|
300
|
+
if self._kept is not None:
|
|
301
|
+
for one in self._kept.entries:
|
|
302
|
+
env = self._target.env_of(one.record)
|
|
303
|
+
_add(self._waiting_by_env, env, -1)
|
|
304
|
+
self._drop(env, 1)
|
|
305
|
+
self._kept = None
|
|
306
|
+
for one in self._take(len(self._queue)):
|
|
307
|
+
self._drop(self._target.env_of(one.record), 1)
|
|
308
|
+
self._report()
|
|
309
|
+
|
|
310
|
+
async def close(self) -> None:
|
|
311
|
+
"""Sends what waits, stops, and drops what is left, off the event loop."""
|
|
312
|
+
import asyncio
|
|
313
|
+
|
|
314
|
+
await asyncio.get_running_loop().run_in_executor(None, self.close_sync)
|
|
315
|
+
|
|
316
|
+
# Inside, all with self._lock held unless said.
|
|
317
|
+
|
|
318
|
+
def _waiting_count(self) -> int:
|
|
319
|
+
return len(self._queue) + (len(self._kept.entries) if self._kept is not None else 0)
|
|
320
|
+
|
|
321
|
+
def _waiting_bytes(self) -> int:
|
|
322
|
+
return self._queued_bytes + (self._kept.size if self._kept is not None else 0)
|
|
323
|
+
|
|
324
|
+
def _drop(self, env: str, n: int) -> None:
|
|
325
|
+
_add(self._dropped, env, n)
|
|
326
|
+
self._dropped_total += n
|
|
327
|
+
|
|
328
|
+
def _take(self, count: int) -> list[_Entry[T]]:
|
|
329
|
+
out: list[_Entry[T]] = []
|
|
330
|
+
for _ in range(count):
|
|
331
|
+
if not self._queue:
|
|
332
|
+
break
|
|
333
|
+
one = self._queue.popleft()
|
|
334
|
+
_add(self._waiting_by_env, self._target.env_of(one.record), -1)
|
|
335
|
+
self._queued_bytes -= one.size
|
|
336
|
+
out.append(one)
|
|
337
|
+
return out
|
|
338
|
+
|
|
339
|
+
def _drop_oldest(self) -> None:
|
|
340
|
+
# Past either limit the oldest queued records go, never the kept send,
|
|
341
|
+
# which Flowrensic may already hold. The kept send does not count toward
|
|
342
|
+
# the count limit, and counts toward the memory limit.
|
|
343
|
+
excess = len(self._queue) - self._max_waiting
|
|
344
|
+
if excess > 0:
|
|
345
|
+
for one in self._take(excess):
|
|
346
|
+
self._drop(self._target.env_of(one.record), 1)
|
|
347
|
+
while self._queue and self._waiting_bytes() > self._max_waiting_bytes:
|
|
348
|
+
for one in self._take(1):
|
|
349
|
+
self._drop(self._target.env_of(one.record), 1)
|
|
350
|
+
|
|
351
|
+
def _next_send(self) -> Optional[_Kept[T]]:
|
|
352
|
+
if self._kept is not None:
|
|
353
|
+
out = self._kept
|
|
354
|
+
self._kept = None
|
|
355
|
+
for one in out.entries:
|
|
356
|
+
_add(self._waiting_by_env, self._target.env_of(one.record), -1)
|
|
357
|
+
return out
|
|
358
|
+
if not self._queue:
|
|
359
|
+
return None
|
|
360
|
+
entries = self._take(min(self._max_batch_size, len(self._queue)))
|
|
361
|
+
self._send_number += 1
|
|
362
|
+
return _Kept(entries, SendId(self._records_sender, self._send_number), sum(one.size for one in entries))
|
|
363
|
+
|
|
364
|
+
# Sending, with self._sending held and self._lock not held.
|
|
365
|
+
|
|
366
|
+
def _send_while_answered(self) -> None:
|
|
367
|
+
"""Sends batch after batch while records wait, until a send goes back."""
|
|
368
|
+
while True:
|
|
369
|
+
with self._lock:
|
|
370
|
+
if self._failing:
|
|
371
|
+
return
|
|
372
|
+
send = self._next_send()
|
|
373
|
+
if send is None:
|
|
374
|
+
return
|
|
375
|
+
records = [one.record for one in send.entries]
|
|
376
|
+
try:
|
|
377
|
+
self._target.send(records, self._timeout_ms, send.id)
|
|
378
|
+
with self._lock:
|
|
379
|
+
self._failing = False
|
|
380
|
+
except BaseException as error: # noqa: BLE001
|
|
381
|
+
envs = {self._target.env_of(one) for one in records}
|
|
382
|
+
with self._lock:
|
|
383
|
+
if isinstance(error, ConnectError) and error.code == Code.DEADLINE_EXCEEDED:
|
|
384
|
+
for env in envs:
|
|
385
|
+
_add(self._timed_out, env, 1)
|
|
386
|
+
self._timed_out_total += 1
|
|
387
|
+
if _send_again(error):
|
|
388
|
+
self._failing = True
|
|
389
|
+
self._kept = send
|
|
390
|
+
for one in send.entries:
|
|
391
|
+
_add(self._waiting_by_env, self._target.env_of(one.record), 1)
|
|
392
|
+
self._drop_oldest()
|
|
393
|
+
return
|
|
394
|
+
for one in records:
|
|
395
|
+
self._drop(self._target.env_of(one), 1)
|
|
396
|
+
|
|
397
|
+
def _next_report(self) -> Optional[_KeptReport]:
|
|
398
|
+
envs = set(self._dropped) | set(self._timed_out) | set(self._waiting_by_env) | set(self._reported_waiting)
|
|
399
|
+
changed = any(
|
|
400
|
+
env in self._dropped
|
|
401
|
+
or env in self._timed_out
|
|
402
|
+
or self._waiting_by_env.get(env, 0) != self._reported_waiting.get(env, 0)
|
|
403
|
+
for env in envs
|
|
404
|
+
)
|
|
405
|
+
if not changed:
|
|
406
|
+
return None
|
|
407
|
+
dropped = dict(self._dropped)
|
|
408
|
+
timed_out = dict(self._timed_out)
|
|
409
|
+
waiting = dict(self._waiting_by_env)
|
|
410
|
+
reports = [
|
|
411
|
+
WorkerBufferReport(
|
|
412
|
+
env=env,
|
|
413
|
+
records_dropped=dropped.get(env, 0),
|
|
414
|
+
sends_timed_out=timed_out.get(env, 0),
|
|
415
|
+
records_waiting=waiting.get(env, 0),
|
|
416
|
+
)
|
|
417
|
+
for env in sorted(envs)
|
|
418
|
+
]
|
|
419
|
+
self._report_number += 1
|
|
420
|
+
return _KeptReport(reports, SendId(self._reports_sender, self._report_number), dropped, timed_out, waiting)
|
|
421
|
+
|
|
422
|
+
def _report(self) -> None:
|
|
423
|
+
"""At most one report at a time. A report that was not answered is sent
|
|
424
|
+
again as it was. Otherwise one goes only when a count changed since the
|
|
425
|
+
last report that was answered, and carries what was counted since."""
|
|
426
|
+
with self._lock:
|
|
427
|
+
if self._kept_report is None:
|
|
428
|
+
self._kept_report = self._next_report()
|
|
429
|
+
one = self._kept_report
|
|
430
|
+
if one is None:
|
|
431
|
+
return
|
|
432
|
+
try:
|
|
433
|
+
self._target.report(one.reports, self._timeout_ms, one.id)
|
|
434
|
+
except NotImplementedError:
|
|
435
|
+
with self._lock:
|
|
436
|
+
self._kept_report = None
|
|
437
|
+
return
|
|
438
|
+
except BaseException: # noqa: BLE001
|
|
439
|
+
return
|
|
440
|
+
with self._lock:
|
|
441
|
+
for env, n in one.dropped.items():
|
|
442
|
+
_add(self._dropped, env, -n)
|
|
443
|
+
for env, n in one.timed_out.items():
|
|
444
|
+
_add(self._timed_out, env, -n)
|
|
445
|
+
self._reported_waiting = one.waiting
|
|
446
|
+
self._kept_report = None
|
|
447
|
+
|
|
448
|
+
def _run(self) -> None:
|
|
449
|
+
"""The thread: a tick every flush interval sends and reports, and a full
|
|
450
|
+
batch sends at once while sends are answered."""
|
|
451
|
+
next_tick = time.monotonic() + self._flush_interval
|
|
452
|
+
while True:
|
|
453
|
+
with self._lock:
|
|
454
|
+
while not self._stopped and not self._woken and time.monotonic() < next_tick:
|
|
455
|
+
self._wake.wait(max(0.0, next_tick - time.monotonic()))
|
|
456
|
+
if self._stopped:
|
|
457
|
+
return
|
|
458
|
+
woken = self._woken
|
|
459
|
+
self._woken = False
|
|
460
|
+
tick = time.monotonic() >= next_tick
|
|
461
|
+
if tick:
|
|
462
|
+
next_tick = time.monotonic() + self._flush_interval
|
|
463
|
+
self._failing = False
|
|
464
|
+
try:
|
|
465
|
+
with self._sending:
|
|
466
|
+
if tick or woken:
|
|
467
|
+
self._send_while_answered()
|
|
468
|
+
if tick:
|
|
469
|
+
self._report()
|
|
470
|
+
except BaseException: # noqa: BLE001
|
|
471
|
+
continue
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def create_record_buffer(target: BufferTarget[T], **options: Any) -> RecordSink[T]:
|
|
475
|
+
"""A record buffer that sends through target, with the buffer options given."""
|
|
476
|
+
return RecordSink(target, **options)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
AttemptSink = RecordSink[ActivityAttempt]
|
|
480
|
+
"""Buffers activity attempts and sends them to a Flowrensic server in batches."""
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def default_worker_id() -> str:
|
|
484
|
+
return f"{socket.gethostname() or 'unknown'}-{os.getpid()}"
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def client_headers(current_env: str, env_access_key: str, current_ephemeral_run: str) -> dict[str, str]:
|
|
488
|
+
"""The headers every client call carries: the env, its access key and the ephemeral run. An empty value is not sent."""
|
|
489
|
+
headers: dict[str, str] = {}
|
|
490
|
+
if current_env:
|
|
491
|
+
headers[CURRENT_ENV_HEADER] = current_env
|
|
492
|
+
if env_access_key:
|
|
493
|
+
headers[ENV_ACCESS_KEY_HEADER] = env_access_key
|
|
494
|
+
if current_ephemeral_run:
|
|
495
|
+
headers[CURRENT_EPHEMERAL_RUN_HEADER] = current_ephemeral_run
|
|
496
|
+
return headers
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def ingest_client_sync(server_base_url: str) -> IngestServiceClientSync:
|
|
500
|
+
return IngestServiceClientSync(server_base_url.rstrip("/"), send_compression=None)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
class _AttemptTarget(BufferTarget[ActivityAttempt]):
|
|
504
|
+
def __init__(self, server_base_url: str, worker_id: str, headers: dict[str, str], worker_name: str = "") -> None:
|
|
505
|
+
self._ingest = ingest_client_sync(server_base_url)
|
|
506
|
+
self._worker_id = worker_id
|
|
507
|
+
self._worker_name = worker_name
|
|
508
|
+
self._headers = headers
|
|
509
|
+
|
|
510
|
+
def send(self, records: list[ActivityAttempt], timeout_ms: int, id: SendId) -> None:
|
|
511
|
+
self._ingest.ingest_activity_events(
|
|
512
|
+
IngestActivityEventsRequest(
|
|
513
|
+
events=records,
|
|
514
|
+
worker_id=self._worker_id,
|
|
515
|
+
worker_name=self._worker_name,
|
|
516
|
+
sender=id.sender,
|
|
517
|
+
send_number=id.send_number,
|
|
518
|
+
),
|
|
519
|
+
headers=self._headers,
|
|
520
|
+
timeout_ms=max(1, timeout_ms),
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
def env_of(self, record: ActivityAttempt) -> str:
|
|
524
|
+
return record.env
|
|
525
|
+
|
|
526
|
+
def report(self, reports: list[WorkerBufferReport], timeout_ms: int, id: SendId) -> None:
|
|
527
|
+
self._ingest.ingest_activity_events(
|
|
528
|
+
IngestActivityEventsRequest(
|
|
529
|
+
reports=reports, worker_id=self._worker_id, sender=id.sender, send_number=id.send_number
|
|
530
|
+
),
|
|
531
|
+
headers=self._headers,
|
|
532
|
+
timeout_ms=max(1, timeout_ms),
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def create_attempt_sink(
|
|
537
|
+
server_base_url: str,
|
|
538
|
+
*,
|
|
539
|
+
worker_id: str = "",
|
|
540
|
+
worker_name: str = "",
|
|
541
|
+
current_env: str = "",
|
|
542
|
+
env_access_key: str = "",
|
|
543
|
+
current_ephemeral_run: str = "",
|
|
544
|
+
batch_size: int = DEFAULT_BATCH_SIZE,
|
|
545
|
+
flush_interval: float = DEFAULT_FLUSH_INTERVAL,
|
|
546
|
+
timeout_ms: int = DEFAULT_TIMEOUT_MS,
|
|
547
|
+
max_waiting: int = DEFAULT_MAX_WAITING,
|
|
548
|
+
max_batch_size: int = DEFAULT_MAX_BATCH_SIZE,
|
|
549
|
+
max_waiting_bytes: int = DEFAULT_MAX_WAITING_BYTES,
|
|
550
|
+
) -> AttemptSink:
|
|
551
|
+
"""A sink of activity attempts that sends them to the Flowrensic server at
|
|
552
|
+
server_base_url with IngestActivityEvents. Every call carries current_env,
|
|
553
|
+
env_access_key and current_ephemeral_run in their headers. worker_id
|
|
554
|
+
names this worker; empty, the host name and the process id joined by a
|
|
555
|
+
dash. worker_name is the name of the worker, the same for every copy of
|
|
556
|
+
it, sent with each attempt; empty, the worker has no name. Times are in
|
|
557
|
+
seconds."""
|
|
558
|
+
if not server_base_url:
|
|
559
|
+
raise ValueError("flowrensic: server_base_url is required")
|
|
560
|
+
return RecordSink(
|
|
561
|
+
_AttemptTarget(
|
|
562
|
+
server_base_url,
|
|
563
|
+
worker_id or default_worker_id(),
|
|
564
|
+
client_headers(current_env, env_access_key, current_ephemeral_run),
|
|
565
|
+
worker_name,
|
|
566
|
+
),
|
|
567
|
+
batch_size=batch_size,
|
|
568
|
+
flush_interval=flush_interval,
|
|
569
|
+
timeout_ms=timeout_ms,
|
|
570
|
+
max_waiting=max_waiting,
|
|
571
|
+
max_batch_size=max_batch_size,
|
|
572
|
+
max_waiting_bytes=max_waiting_bytes,
|
|
573
|
+
)
|
|
574
|
+
|