alitycs 1.0.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.
- alitycs/__init__.py +63 -0
- alitycs/batch.py +500 -0
- alitycs/client.py +507 -0
- alitycs/config.py +85 -0
- alitycs/context.py +85 -0
- alitycs/persistence.py +317 -0
- alitycs/session.py +81 -0
- alitycs/transport.py +308 -0
- alitycs/types.py +298 -0
- alitycs/utils.py +161 -0
- alitycs-1.0.0.dist-info/METADATA +155 -0
- alitycs-1.0.0.dist-info/RECORD +14 -0
- alitycs-1.0.0.dist-info/WHEEL +4 -0
- alitycs-1.0.0.dist-info/licenses/LICENSE +21 -0
alitycs/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Official Alitycs analytics SDK for Python servers.
|
|
2
|
+
|
|
3
|
+
Quickstart::
|
|
4
|
+
|
|
5
|
+
import alitycs
|
|
6
|
+
|
|
7
|
+
alitycs.init("pk_...", flush_size=20, flush_interval=2.0)
|
|
8
|
+
alitycs.identify("usr_123", {"plan": "pro"})
|
|
9
|
+
alitycs.track("checkout_completed", {"total": "19.99"})
|
|
10
|
+
alitycs.shutdown() # bounded drain; configure persistence for restart safety
|
|
11
|
+
|
|
12
|
+
Zero runtime dependencies — HTTP goes through ``urllib.request``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .client import (
|
|
16
|
+
Alitycs,
|
|
17
|
+
capture_error,
|
|
18
|
+
flush,
|
|
19
|
+
get_default_instance,
|
|
20
|
+
identify,
|
|
21
|
+
init,
|
|
22
|
+
page,
|
|
23
|
+
reset,
|
|
24
|
+
set_global_properties,
|
|
25
|
+
shutdown,
|
|
26
|
+
track,
|
|
27
|
+
track_revenue,
|
|
28
|
+
)
|
|
29
|
+
from .config import DEFAULT_ENDPOINT, AlitycsConfig
|
|
30
|
+
from .types import (
|
|
31
|
+
AnalyticsEvent,
|
|
32
|
+
BatchPayload,
|
|
33
|
+
EventContext,
|
|
34
|
+
EventType,
|
|
35
|
+
RevenueError,
|
|
36
|
+
RevenuePayload,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__version__ = "1.0.0"
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"Alitycs",
|
|
43
|
+
"AlitycsConfig",
|
|
44
|
+
"AnalyticsEvent",
|
|
45
|
+
"BatchPayload",
|
|
46
|
+
"DEFAULT_ENDPOINT",
|
|
47
|
+
"EventContext",
|
|
48
|
+
"EventType",
|
|
49
|
+
"RevenueError",
|
|
50
|
+
"RevenuePayload",
|
|
51
|
+
"__version__",
|
|
52
|
+
"capture_error",
|
|
53
|
+
"flush",
|
|
54
|
+
"get_default_instance",
|
|
55
|
+
"identify",
|
|
56
|
+
"init",
|
|
57
|
+
"page",
|
|
58
|
+
"reset",
|
|
59
|
+
"set_global_properties",
|
|
60
|
+
"shutdown",
|
|
61
|
+
"track",
|
|
62
|
+
"track_revenue",
|
|
63
|
+
]
|
alitycs/batch.py
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
"""Queueing and batch dispatch on a background flusher thread.
|
|
2
|
+
|
|
3
|
+
Design notes, in the shadow of the ``@alitycs/core`` flush-lock defect this SDK must
|
|
4
|
+
never repeat (see .agents/plans/phase-0-harness.md §1.1):
|
|
5
|
+
|
|
6
|
+
- A single daemon worker thread owns size-triggered and timer-triggered dispatch,
|
|
7
|
+
taking exactly ``flush_size`` events per batch.
|
|
8
|
+
- :meth:`flush` does not signal the worker and hope — it drains the queue itself on
|
|
9
|
+
the calling thread, ``flush_size`` events at a time, and resolves only once nothing
|
|
10
|
+
is queued **and** no send is in flight. Waiting against an in-flight send is
|
|
11
|
+
precisely the spot where ``core`` used to no-op and lose whatever was queued
|
|
12
|
+
behind it.
|
|
13
|
+
- Delivery is honest: :meth:`flush` returns ``True`` only when everything drained was
|
|
14
|
+
delivered. A whole-batch rejection (HTTP 400 — one invalid event poisons the entire
|
|
15
|
+
batch) splits the payload in half and re-sends each half; a transient failure
|
|
16
|
+
re-queues survivors at the head of the queue preserving order. Drained-but-
|
|
17
|
+
undelivered events are never silently dropped.
|
|
18
|
+
- :meth:`shutdown` marks the manager draining and gives the worker a bounded window.
|
|
19
|
+
When that deadline expires, queued work is persisted when durability is enabled;
|
|
20
|
+
callers can pass ``join_timeout=None`` when they explicitly want an unbounded drain.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from collections import deque
|
|
28
|
+
from typing import Callable, List, Optional, Set, Tuple, Union
|
|
29
|
+
|
|
30
|
+
from .transport import SendFailed, SendRejected, SendSuccess
|
|
31
|
+
from .types import AnalyticsEvent, BatchPayload
|
|
32
|
+
from .utils import debug_warn, generate_id, now_ms, warn
|
|
33
|
+
|
|
34
|
+
#: Legacy fakes may return ``None``; that is treated as success.
|
|
35
|
+
SendFn = Callable[[BatchPayload], Optional[Union[SendSuccess, SendRejected, SendFailed]]]
|
|
36
|
+
DeadlineSendFn = Callable[
|
|
37
|
+
[BatchPayload, Optional[float]], Optional[Union[SendSuccess, SendRejected, SendFailed]]
|
|
38
|
+
]
|
|
39
|
+
Clock = Callable[[], float]
|
|
40
|
+
RecoverFn = Callable[[Optional[float]], bool]
|
|
41
|
+
DurablePendingFn = Callable[[], int]
|
|
42
|
+
DurablePendingSnapshotFn = Callable[[List[str]], Tuple[int, int]]
|
|
43
|
+
PersistFn = Callable[[BatchPayload], bool]
|
|
44
|
+
|
|
45
|
+
# Outcome of one dispatched batch.
|
|
46
|
+
_OK = "ok" # every event delivered
|
|
47
|
+
_REJECTED = "rejected" # server permanently refused; events dropped loudly
|
|
48
|
+
_TRANSIENT = "transient" # transport failure; events re-queued at the head
|
|
49
|
+
|
|
50
|
+
# Pause for the background worker after a transient failure so it does not
|
|
51
|
+
# hot-loop over re-queued events; explicit flushes retry immediately instead.
|
|
52
|
+
_WORKER_RETRY_BACKOFF_SECONDS = 0.5
|
|
53
|
+
|
|
54
|
+
# Whole-batch rejection isolation is useful but must not amplify one response into an
|
|
55
|
+
# unbounded request storm when a caller configures a very large flush size.
|
|
56
|
+
_MAX_SPLIT_SENDS = 64
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class BatchManager:
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
flush_size: int,
|
|
63
|
+
flush_interval: Optional[float],
|
|
64
|
+
max_queue_size: int,
|
|
65
|
+
send_fn: SendFn,
|
|
66
|
+
debug: bool = False,
|
|
67
|
+
clock: Optional[Clock] = None,
|
|
68
|
+
recover_fn: Optional[RecoverFn] = None,
|
|
69
|
+
durable_pending_fn: Optional[DurablePendingFn] = None,
|
|
70
|
+
durable: bool = False,
|
|
71
|
+
persist_fn: Optional[PersistFn] = None,
|
|
72
|
+
send_with_deadline_fn: Optional[DeadlineSendFn] = None,
|
|
73
|
+
durable_pending_snapshot_fn: Optional[DurablePendingSnapshotFn] = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
self._flush_size = flush_size
|
|
76
|
+
self._flush_interval = flush_interval
|
|
77
|
+
self._max_queue_size = max_queue_size
|
|
78
|
+
self._send_fn = send_fn
|
|
79
|
+
self._debug = debug
|
|
80
|
+
self._clock: Clock = clock if clock is not None else time.monotonic
|
|
81
|
+
self._recover_fn = recover_fn
|
|
82
|
+
self._durable_pending_fn = durable_pending_fn
|
|
83
|
+
self._durable = durable
|
|
84
|
+
self._persist_fn = persist_fn
|
|
85
|
+
self._send_with_deadline_fn = send_with_deadline_fn
|
|
86
|
+
self._durable_pending_snapshot_fn = durable_pending_snapshot_fn
|
|
87
|
+
|
|
88
|
+
self._cv = threading.Condition()
|
|
89
|
+
self._queue: "deque[AnalyticsEvent]" = deque()
|
|
90
|
+
self._inflight = 0
|
|
91
|
+
self._active_batch_ids: Set[str] = set()
|
|
92
|
+
self._stopping = False
|
|
93
|
+
self._closed = False
|
|
94
|
+
self._finite_shutdown_complete = False
|
|
95
|
+
self._thread: Optional[threading.Thread] = None
|
|
96
|
+
self._next_tick = self._clock() + flush_interval if flush_interval else None
|
|
97
|
+
|
|
98
|
+
self._delivered_count = 0
|
|
99
|
+
self._requeued_count = 0
|
|
100
|
+
self._lost_count = 0
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def pending(self) -> int:
|
|
104
|
+
"""Events queued, in flight, or persisted for restart."""
|
|
105
|
+
with self._cv:
|
|
106
|
+
memory_pending = len(self._queue) + self._inflight
|
|
107
|
+
active_batch_ids = list(self._active_batch_ids)
|
|
108
|
+
inflight = self._inflight
|
|
109
|
+
if self._durable_pending_snapshot_fn is not None:
|
|
110
|
+
durable_pending, overlap = self._durable_pending_snapshot_fn(active_batch_ids)
|
|
111
|
+
return memory_pending + durable_pending - min(overlap, inflight)
|
|
112
|
+
durable_pending = 0 if self._durable_pending_fn is None else self._durable_pending_fn()
|
|
113
|
+
return memory_pending + durable_pending
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def closed(self) -> bool:
|
|
117
|
+
"""True once :meth:`shutdown` stopped this manager from accepting events."""
|
|
118
|
+
with self._cv:
|
|
119
|
+
return self._closed
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def delivered_total(self) -> int:
|
|
123
|
+
"""Events confirmed delivered since this manager was created."""
|
|
124
|
+
with self._cv:
|
|
125
|
+
return self._delivered_count
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def requeued_total(self) -> int:
|
|
129
|
+
"""Events re-queued at the head after transient send failures."""
|
|
130
|
+
with self._cv:
|
|
131
|
+
return self._requeued_count
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def lost_total(self) -> int:
|
|
135
|
+
"""Events permanently lost: dropped by the server or overflowed from the queue."""
|
|
136
|
+
with self._cv:
|
|
137
|
+
return self._lost_count
|
|
138
|
+
|
|
139
|
+
def add(self, event: AnalyticsEvent) -> bool:
|
|
140
|
+
"""Enqueue one event, dispatching when the queue reaches ``flush_size``.
|
|
141
|
+
Returns ``False`` when the event was dropped (queue full or shut down)."""
|
|
142
|
+
with self._cv:
|
|
143
|
+
if self._closed:
|
|
144
|
+
if self._debug:
|
|
145
|
+
debug_warn("Queue closed — dropping event")
|
|
146
|
+
return False
|
|
147
|
+
if len(self._queue) >= self._max_queue_size:
|
|
148
|
+
self._lost_count += 1
|
|
149
|
+
warn("Queue full — dropping event")
|
|
150
|
+
return False
|
|
151
|
+
self._queue.append(event)
|
|
152
|
+
self._cv.notify_all()
|
|
153
|
+
self._ensure_worker()
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
def flush(self, timeout: Optional[float] = None) -> bool:
|
|
157
|
+
"""Drain fully: send everything queued and wait for in-flight sends to land.
|
|
158
|
+
Returns ``True`` only when the drain finished with every event delivered;
|
|
159
|
+
``False`` when a send failed (survivors stay queued), or ``timeout`` elapsed.
|
|
160
|
+
Safe to call concurrently — callers share in-flight sends instead of
|
|
161
|
+
duplicating them."""
|
|
162
|
+
deadline = None if timeout is None else self._clock() + timeout
|
|
163
|
+
with self._cv:
|
|
164
|
+
# A caller has explicitly resumed draining after a previous finite shutdown.
|
|
165
|
+
# Late failures must become pending work for this caller, not terminal loss.
|
|
166
|
+
self._finite_shutdown_complete = False
|
|
167
|
+
if not self._recover_durable(deadline):
|
|
168
|
+
return False
|
|
169
|
+
self._ensure_worker()
|
|
170
|
+
all_delivered = True
|
|
171
|
+
while True:
|
|
172
|
+
batch: List[AnalyticsEvent] = []
|
|
173
|
+
cv = self._cv # bind: reset_for_child may swap the condition mid-flight
|
|
174
|
+
with cv:
|
|
175
|
+
while True:
|
|
176
|
+
now = self._clock()
|
|
177
|
+
if not self._queue and self._inflight == 0:
|
|
178
|
+
durable_pending = (
|
|
179
|
+
0 if self._durable_pending_fn is None else self._durable_pending_fn()
|
|
180
|
+
)
|
|
181
|
+
return all_delivered and durable_pending == 0
|
|
182
|
+
if deadline is not None and now >= deadline:
|
|
183
|
+
return False
|
|
184
|
+
if self._queue:
|
|
185
|
+
# Drain inline rather than depend on the worker waking up;
|
|
186
|
+
# this is the path shutdown relies on. Take flush_size-sized
|
|
187
|
+
# chunks so payloads match the size-triggered batches.
|
|
188
|
+
batch = self._pop(min(self._flush_size, len(self._queue)))
|
|
189
|
+
self._advance_tick(now)
|
|
190
|
+
break
|
|
191
|
+
# Nothing queued but a send is in flight: wait for its completion
|
|
192
|
+
# notification (or the worker picking up newly queued events).
|
|
193
|
+
cv.wait(None if deadline is None else max(0.0, deadline - now))
|
|
194
|
+
if not batch:
|
|
195
|
+
continue
|
|
196
|
+
outcome = self._send_batch(batch, deadline)
|
|
197
|
+
if outcome == _OK:
|
|
198
|
+
continue
|
|
199
|
+
all_delivered = False
|
|
200
|
+
if outcome == _REJECTED:
|
|
201
|
+
# Those events are gone (loudly); keep draining the rest of the queue.
|
|
202
|
+
continue
|
|
203
|
+
# Transient failure: survivors were re-queued at the head. Retrying inside
|
|
204
|
+
# this loop would hot-spin against the same failure — wait out any other
|
|
205
|
+
# in-flight sends and report honestly; a later flush retries.
|
|
206
|
+
self._wait_for_inflight(deadline)
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
def shutdown(self, join_timeout: Optional[float] = 30.0) -> None:
|
|
210
|
+
"""Stop accepting events and wait up to ``join_timeout`` for the worker.
|
|
211
|
+
|
|
212
|
+
When the deadline expires, queued events are persisted when durability is
|
|
213
|
+
enabled instead of extending process shutdown with unbounded network waits.
|
|
214
|
+
Passing ``None`` preserves the fully blocking drain contract.
|
|
215
|
+
"""
|
|
216
|
+
deadline = None if join_timeout is None else self._clock() + max(0.0, join_timeout)
|
|
217
|
+
with self._cv:
|
|
218
|
+
if deadline is None:
|
|
219
|
+
self._finite_shutdown_complete = False
|
|
220
|
+
self._stopping = True
|
|
221
|
+
self._closed = True
|
|
222
|
+
self._cv.notify_all()
|
|
223
|
+
thread = self._thread
|
|
224
|
+
if thread is not None and thread is not threading.current_thread():
|
|
225
|
+
remaining = None if deadline is None else max(0.0, deadline - self._clock())
|
|
226
|
+
thread.join(remaining)
|
|
227
|
+
|
|
228
|
+
if deadline is not None:
|
|
229
|
+
# The worker owns network delivery. A finite shutdown must never re-enter
|
|
230
|
+
# that path inline because HTTP retries and Retry-After sleeps can outlive
|
|
231
|
+
# the caller's deadline. Any queued remainder is safe on disk when durable;
|
|
232
|
+
# an in-flight durable batch was persisted by the transport before sending.
|
|
233
|
+
with self._cv:
|
|
234
|
+
self._finite_shutdown_complete = True
|
|
235
|
+
self._persist_queued_for_shutdown()
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
# An explicitly unbounded shutdown retains the full drain guarantee.
|
|
239
|
+
self._wait_for_inflight(None)
|
|
240
|
+
if not self.flush():
|
|
241
|
+
self._persist_queued_for_shutdown()
|
|
242
|
+
|
|
243
|
+
def reset_for_child(self) -> None:
|
|
244
|
+
"""Post-``os.fork`` repair, run in the child only.
|
|
245
|
+
|
|
246
|
+
Locks are replaced because a parent thread may have held them at fork time. The
|
|
247
|
+
inherited queue is discarded: the parent still owns those events, and sending
|
|
248
|
+
the child's copy would double-count them.
|
|
249
|
+
"""
|
|
250
|
+
self._cv = threading.Condition()
|
|
251
|
+
with self._cv:
|
|
252
|
+
self._thread = None
|
|
253
|
+
self._inflight = 0 # the parent's in-flight send can never complete here
|
|
254
|
+
self._active_batch_ids.clear()
|
|
255
|
+
self._queue.clear()
|
|
256
|
+
self._next_tick = self._clock() + self._flush_interval if self._flush_interval else None
|
|
257
|
+
|
|
258
|
+
# Internals ------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
def _ensure_worker(self) -> None:
|
|
261
|
+
with self._cv:
|
|
262
|
+
if self._closed:
|
|
263
|
+
return
|
|
264
|
+
if self._thread is not None and self._thread.is_alive():
|
|
265
|
+
return
|
|
266
|
+
self._thread = threading.Thread(target=self._run, name="alitycs-flusher", daemon=True)
|
|
267
|
+
# Start under the lock so two racing callers cannot spawn duplicates.
|
|
268
|
+
self._thread.start()
|
|
269
|
+
|
|
270
|
+
def _worker_alive(self) -> bool:
|
|
271
|
+
return self._thread is not None and self._thread.is_alive()
|
|
272
|
+
|
|
273
|
+
def _run(self) -> None:
|
|
274
|
+
try:
|
|
275
|
+
while True:
|
|
276
|
+
cv = self._cv # bind: reset_for_child may swap the condition mid-flight
|
|
277
|
+
with cv:
|
|
278
|
+
batch = self._take_batch(cv)
|
|
279
|
+
if batch is None:
|
|
280
|
+
return
|
|
281
|
+
outcome = self._send_batch(batch)
|
|
282
|
+
if outcome == _TRANSIENT:
|
|
283
|
+
with self._cv:
|
|
284
|
+
if self._stopping:
|
|
285
|
+
return
|
|
286
|
+
# Survivors were re-queued at the head; back off before retrying so
|
|
287
|
+
# a downed endpoint does not turn into a hot loop.
|
|
288
|
+
time.sleep(_WORKER_RETRY_BACKOFF_SECONDS)
|
|
289
|
+
finally:
|
|
290
|
+
with self._cv:
|
|
291
|
+
self._cv.notify_all()
|
|
292
|
+
|
|
293
|
+
def _take_batch(self, cv: threading.Condition) -> Optional[List[AnalyticsEvent]]:
|
|
294
|
+
"""Pop the next batch under the lock, or ``None`` when fully drained and
|
|
295
|
+
shutting down. Caller holds ``cv`` (bound by :meth:`_run`, not re-read from
|
|
296
|
+
``self._cv`` which ``reset_for_child`` may have replaced)."""
|
|
297
|
+
while True:
|
|
298
|
+
now = self._clock()
|
|
299
|
+
if self._stopping and not self._queue:
|
|
300
|
+
return None
|
|
301
|
+
if self._stopping:
|
|
302
|
+
return self._pop(min(self._flush_size, len(self._queue)))
|
|
303
|
+
if len(self._queue) >= self._flush_size:
|
|
304
|
+
return self._pop(self._flush_size)
|
|
305
|
+
if self._timer_due(now):
|
|
306
|
+
self._advance_tick(now)
|
|
307
|
+
if self._queue:
|
|
308
|
+
return self._pop(min(self._flush_size, len(self._queue)))
|
|
309
|
+
continue # timer fired with an empty queue: skip the missed tick
|
|
310
|
+
cv.wait(self._time_until_tick(now))
|
|
311
|
+
|
|
312
|
+
def _pop(self, count: int) -> List[AnalyticsEvent]:
|
|
313
|
+
batch: List[AnalyticsEvent] = []
|
|
314
|
+
while self._queue and len(batch) < count:
|
|
315
|
+
batch.append(self._queue.popleft())
|
|
316
|
+
self._inflight += len(batch)
|
|
317
|
+
return batch
|
|
318
|
+
|
|
319
|
+
def _timer_due(self, now: float) -> bool:
|
|
320
|
+
return self._next_tick is not None and now >= self._next_tick
|
|
321
|
+
|
|
322
|
+
def _advance_tick(self, now: float) -> None:
|
|
323
|
+
if self._flush_interval:
|
|
324
|
+
self._next_tick = now + self._flush_interval
|
|
325
|
+
|
|
326
|
+
def _time_until_tick(self, now: float) -> Optional[float]:
|
|
327
|
+
if self._next_tick is None:
|
|
328
|
+
return None
|
|
329
|
+
return max(0.0, self._next_tick - now)
|
|
330
|
+
|
|
331
|
+
def _wait_for_inflight(self, deadline: Optional[float]) -> bool:
|
|
332
|
+
"""Block until no send is in flight. Returns True when the queue and inflight
|
|
333
|
+
are both empty, False otherwise (timeout or undelivered work remains)."""
|
|
334
|
+
with self._cv:
|
|
335
|
+
while True:
|
|
336
|
+
now = self._clock()
|
|
337
|
+
if self._inflight == 0:
|
|
338
|
+
return not self._queue
|
|
339
|
+
if deadline is not None and now >= deadline:
|
|
340
|
+
return False
|
|
341
|
+
self._cv.wait(None if deadline is None else max(0.0, deadline - now))
|
|
342
|
+
|
|
343
|
+
def _send_batch(
|
|
344
|
+
self, batch: List[AnalyticsEvent], deadline: Optional[float] = None
|
|
345
|
+
) -> str:
|
|
346
|
+
"""Dispatch one batch. Returns ``_OK``, ``_REJECTED``, or ``_TRANSIENT``
|
|
347
|
+
(with survivors re-queued at the head). Never raises."""
|
|
348
|
+
active_batch_ids: List[str] = []
|
|
349
|
+
try:
|
|
350
|
+
if not self._recover_durable(deadline):
|
|
351
|
+
self._requeue_at_head(batch)
|
|
352
|
+
result = _TRANSIENT
|
|
353
|
+
else:
|
|
354
|
+
result = self._deliver(
|
|
355
|
+
list(batch), [_MAX_SPLIT_SENDS], deadline, active_batch_ids
|
|
356
|
+
)
|
|
357
|
+
except Exception as exc: # noqa: BLE001 - delivery must never crash the host
|
|
358
|
+
warn(f"Batch dispatch failed ({type(exc).__name__}: {exc})")
|
|
359
|
+
self._requeue_at_head(batch)
|
|
360
|
+
result = _TRANSIENT
|
|
361
|
+
finally:
|
|
362
|
+
with self._cv:
|
|
363
|
+
for batch_id in active_batch_ids:
|
|
364
|
+
self._active_batch_ids.discard(batch_id)
|
|
365
|
+
self._inflight -= len(batch)
|
|
366
|
+
self._cv.notify_all()
|
|
367
|
+
return result
|
|
368
|
+
|
|
369
|
+
def _recover_durable(self, deadline: Optional[float] = None) -> bool:
|
|
370
|
+
if self._recover_fn is None:
|
|
371
|
+
return True
|
|
372
|
+
try:
|
|
373
|
+
return self._recover_fn(deadline)
|
|
374
|
+
except Exception as exc: # noqa: BLE001 - persistence/network failures are reported
|
|
375
|
+
warn(f"Durable batch recovery failed ({type(exc).__name__}: {exc})")
|
|
376
|
+
return False
|
|
377
|
+
|
|
378
|
+
def _persist_queued_for_shutdown(self) -> None:
|
|
379
|
+
"""Persist queued work FIFO when an older durable batch blocks recovery."""
|
|
380
|
+
with self._cv:
|
|
381
|
+
queued = list(self._queue)
|
|
382
|
+
self._queue.clear()
|
|
383
|
+
|
|
384
|
+
if not queued:
|
|
385
|
+
return
|
|
386
|
+
if not self._durable or self._persist_fn is None:
|
|
387
|
+
with self._cv:
|
|
388
|
+
self._lost_count += len(queued)
|
|
389
|
+
warn(f"Shutdown could not deliver {len(queued)} queued event(s) — persistence unavailable")
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
for index, event in enumerate(queued):
|
|
393
|
+
payload = BatchPayload(
|
|
394
|
+
batch_id=f"batch_{generate_id()}",
|
|
395
|
+
sent_at=now_ms(),
|
|
396
|
+
events=[event],
|
|
397
|
+
)
|
|
398
|
+
try:
|
|
399
|
+
persisted = self._persist_fn(payload)
|
|
400
|
+
except Exception as exc: # noqa: BLE001 - count the unresolved suffix honestly
|
|
401
|
+
warn(f"Shutdown persistence failed ({type(exc).__name__}: {exc})")
|
|
402
|
+
persisted = False
|
|
403
|
+
if persisted:
|
|
404
|
+
continue
|
|
405
|
+
|
|
406
|
+
lost = len(queued) - index
|
|
407
|
+
with self._cv:
|
|
408
|
+
self._lost_count += lost
|
|
409
|
+
warn(f"Shutdown persistence failed — {lost} queued event(s) lost")
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
def _deliver(
|
|
413
|
+
self,
|
|
414
|
+
events: List[AnalyticsEvent],
|
|
415
|
+
remaining_sends: List[int],
|
|
416
|
+
deadline: Optional[float] = None,
|
|
417
|
+
active_batch_ids: Optional[List[str]] = None,
|
|
418
|
+
) -> str:
|
|
419
|
+
if remaining_sends[0] <= 0:
|
|
420
|
+
with self._cv:
|
|
421
|
+
self._lost_count += len(events)
|
|
422
|
+
warn(
|
|
423
|
+
f"Batch rejection split limit reached — dropping {len(events)} unresolved event(s)"
|
|
424
|
+
)
|
|
425
|
+
return _REJECTED
|
|
426
|
+
remaining_sends[0] -= 1
|
|
427
|
+
payload = BatchPayload(
|
|
428
|
+
batch_id=f"batch_{generate_id()}",
|
|
429
|
+
sent_at=now_ms(),
|
|
430
|
+
events=list(events),
|
|
431
|
+
)
|
|
432
|
+
if active_batch_ids is not None:
|
|
433
|
+
with self._cv:
|
|
434
|
+
self._active_batch_ids.add(payload.batch_id)
|
|
435
|
+
active_batch_ids.append(payload.batch_id)
|
|
436
|
+
try:
|
|
437
|
+
outcome = (
|
|
438
|
+
self._send_fn(payload)
|
|
439
|
+
if self._send_with_deadline_fn is None
|
|
440
|
+
else self._send_with_deadline_fn(payload, deadline)
|
|
441
|
+
)
|
|
442
|
+
except Exception as exc: # noqa: BLE001 - legacy send fns raise instead of outcomes
|
|
443
|
+
warn(f"Batch send failed ({type(exc).__name__}: {exc})")
|
|
444
|
+
outcome = SendFailed(f"{type(exc).__name__}: {exc}")
|
|
445
|
+
|
|
446
|
+
if isinstance(outcome, SendRejected):
|
|
447
|
+
if outcome.is_batch_reject and len(events) > 1:
|
|
448
|
+
# The whole batch bounced because one event violated a limit. Split in
|
|
449
|
+
# half and retry each side so valid events still land; depth is bounded
|
|
450
|
+
# by log2(len(events)).
|
|
451
|
+
mid = len(events) // 2
|
|
452
|
+
left = self._deliver(
|
|
453
|
+
events[:mid], remaining_sends, deadline, active_batch_ids
|
|
454
|
+
)
|
|
455
|
+
right = self._deliver(
|
|
456
|
+
events[mid:], remaining_sends, deadline, active_batch_ids
|
|
457
|
+
)
|
|
458
|
+
if _TRANSIENT in (left, right):
|
|
459
|
+
return _TRANSIENT
|
|
460
|
+
return _OK if _REJECTED not in (left, right) else _REJECTED
|
|
461
|
+
with self._cv:
|
|
462
|
+
self._lost_count += len(events)
|
|
463
|
+
warn(
|
|
464
|
+
f"Server rejected {len(events)} event(s) with HTTP {outcome.status} — "
|
|
465
|
+
"dropped, not retried"
|
|
466
|
+
)
|
|
467
|
+
return _REJECTED
|
|
468
|
+
|
|
469
|
+
if isinstance(outcome, SendFailed):
|
|
470
|
+
if self._durable and outcome.durable:
|
|
471
|
+
warn(f"Transport failure ({outcome.reason}) — exact batch retained for restart")
|
|
472
|
+
else:
|
|
473
|
+
warn(f"Transport failure ({outcome.reason}) — re-queueing {len(events)} event(s)")
|
|
474
|
+
self._requeue_at_head(events)
|
|
475
|
+
return _TRANSIENT
|
|
476
|
+
|
|
477
|
+
if self._debug and outcome is not None and not isinstance(outcome, SendSuccess):
|
|
478
|
+
debug_warn(f"Unexpected send outcome {outcome!r} — treating as delivered")
|
|
479
|
+
|
|
480
|
+
with self._cv:
|
|
481
|
+
self._delivered_count += len(events)
|
|
482
|
+
return _OK
|
|
483
|
+
|
|
484
|
+
def _requeue_at_head(self, events: List[AnalyticsEvent]) -> None:
|
|
485
|
+
abandoned = False
|
|
486
|
+
with self._cv:
|
|
487
|
+
if self._finite_shutdown_complete:
|
|
488
|
+
self._lost_count += len(events)
|
|
489
|
+
abandoned = True
|
|
490
|
+
else:
|
|
491
|
+
self._requeued_count += len(events)
|
|
492
|
+
# extendleft reverses its argument, so pass the reversed list to keep the
|
|
493
|
+
# original order at the head of the queue.
|
|
494
|
+
self._queue.extendleft(reversed(events))
|
|
495
|
+
self._cv.notify_all()
|
|
496
|
+
if abandoned:
|
|
497
|
+
warn(
|
|
498
|
+
f"Transport failed after the shutdown deadline — {len(events)} "
|
|
499
|
+
"in-flight event(s) lost"
|
|
500
|
+
)
|