featureflip 2.4.2__tar.gz → 2.5.1__tar.gz
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.
- {featureflip-2.4.2 → featureflip-2.5.1}/CHANGELOG.md +31 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/PKG-INFO +3 -3
- {featureflip-2.4.2 → featureflip-2.5.1}/README.md +1 -1
- {featureflip-2.4.2 → featureflip-2.5.1}/pyproject.toml +1 -1
- {featureflip-2.4.2 → featureflip-2.5.1}/src/featureflip/_core.py +46 -22
- featureflip-2.5.1/src/featureflip/_events.py +304 -0
- featureflip-2.5.1/src/featureflip/_http.py +416 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/src/featureflip/client.py +5 -1
- {featureflip-2.4.2 → featureflip-2.5.1}/src/featureflip/evaluation.py +140 -21
- featureflip-2.5.1/src/featureflip/exceptions.py +45 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/src/featureflip/models.py +12 -5
- featureflip-2.5.1/tests/golden/vectors.json +3049 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_client.py +49 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_evaluation.py +29 -0
- featureflip-2.5.1/tests/test_events.py +769 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_golden.py +72 -0
- featureflip-2.5.1/tests/test_golden_malformed.py +102 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_http.py +160 -1
- featureflip-2.4.2/src/featureflip/_events.py +0 -123
- featureflip-2.4.2/src/featureflip/_http.py +0 -259
- featureflip-2.4.2/src/featureflip/exceptions.py +0 -19
- featureflip-2.4.2/tests/golden/vectors.json +0 -1044
- featureflip-2.4.2/tests/test_events.py +0 -279
- {featureflip-2.4.2 → featureflip-2.5.1}/.gitignore +0 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/LICENSE +0 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/src/featureflip/__init__.py +0 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/src/featureflip/_polling.py +0 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/src/featureflip/_semver.py +0 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/src/featureflip/_streaming.py +0 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/src/featureflip/_version.py +0 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/src/featureflip/config.py +0 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/src/featureflip/context.py +0 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/src/featureflip/detail.py +0 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/src/featureflip/inspector.py +0 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/tests/__init__.py +0 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/tests/integration/__init__.py +0 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/tests/integration/test_api.py +0 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_config.py +0 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_context.py +0 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_core.py +0 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_detail.py +0 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_exceptions.py +0 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_factory.py +0 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_inspector.py +0 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_models.py +0 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_polling.py +0 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_semver.py +0 -0
- {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_streaming.py +0 -0
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.5.1 — 2026-08-24
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- A date operand is now trimmed of exactly the whitespace the evaluation engine trims (tab, newline, vertical tab, form feed, carriage return and space), and is rejected outright if it still carries a NUL, another control character, or a non-ASCII whitespace character. Each SDK had been relying on its own language's `trim`, and no two of those cover the same set, so the same operand could match on one SDK and match nothing on another. ([#2468](https://github.com/canopy-labs/featureflip/issues/2468))
|
|
8
|
+
- The ISO-8601 grammar no longer shifts with the interpreter version. `fromisoformat` now only ever sees a canonicalised string, so a nanosecond fraction and a basic `+0500` offset parse identically on 3.10 and 3.13 — previously they were accepted on newer interpreters and rejected on 3.10. ([#2468](https://github.com/canopy-labs/featureflip/issues/2468))
|
|
9
|
+
- An ISO-8601 operand naming hour 24 (`2024-01-01T24:00:00`) matches nothing, rather than rolling over to the next day. ([#2468](https://github.com/canopy-labs/featureflip/issues/2468))
|
|
10
|
+
|
|
11
|
+
## 2.5.0 — 2026-08-24
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Analytics events survive a transient failure of the events endpoint. The queue was emptied before the POST and the batch discarded on any error, so every 503 the edge returned silently lost a batch. A retryable failure — any 5xx, a 429, or a network error or timeout — now puts the batch back at the front of the queue for the next flush. ([#2456](https://github.com/canopy-labs/featureflip/issues/2456))
|
|
16
|
+
- A permanently rejected batch is still dropped, and now says so. A 400, 401 or 403 fails identically however often it is resent, so the batch is discarded with a warning naming it as non-retryable instead of being retried forever at the expense of every later event. ([#2456](https://github.com/canopy-labs/featureflip/issues/2456))
|
|
17
|
+
- A failing endpoint no longer receives one request per tracked event. A re-queued batch leaves the queue at its flush threshold, so the batch-size trigger would otherwise have fired on every subsequent event; it now backs off for one flush interval after a retryable failure, leaving the periodic flush to do the retrying. ([#2456](https://github.com/canopy-labs/featureflip/issues/2456))
|
|
18
|
+
- `stop()` makes a single final flush attempt rather than retrying, so shutdown cannot hang while the endpoint is down. ([#2456](https://github.com/canopy-labs/featureflip/issues/2456))
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- A flush sends at most `flush_batch_size` events per request, looping until the queue is empty, rather than posting the whole queue in one body. Now that failed batches are kept, the queue can sit at its bound through an outage, and a body that large invites a 413 — which is not retryable, so the entire backlog would have been dropped by the path meant to preserve it. ([#2456](https://github.com/canopy-labs/featureflip/issues/2456))
|
|
23
|
+
- The event queue is bounded at 10,000 events, overridable with `EventProcessor(max_queue_size=...)`. Past the bound the oldest events are shed and the number dropped is logged, so an extended outage sheds the stalest analytics instead of growing the queue without limit. ([#2456](https://github.com/canopy-labs/featureflip/issues/2456))
|
|
24
|
+
|
|
25
|
+
## 2.4.3 — 2026-08-23
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- An unrecognised `FlagType` no longer fails the entire config fetch. One unknown value in one flag discarded the whole payload, so a single new server-side type could blank out every flag the SDK served. ([#2401](https://github.com/canopy-labs/featureflip/issues/2401))
|
|
30
|
+
- An unrecognised condition operator now fails closed instead of matching every user. The default arm returned `false`, which a negated condition then inverted to `true` — so a config naming an operator the SDK did not know could silently target everyone. ([#2262](https://github.com/canopy-labs/featureflip/issues/2262))
|
|
31
|
+
- An unrecognised condition operator is tolerated at deserialization instead of discarding the whole config, matching the string-typed SDKs. ([#2372](https://github.com/canopy-labs/featureflip/issues/2372))
|
|
32
|
+
- `identify()` and `track()` put the same payload on the wire as every other server SDK. The field set and shapes had drifted per language, so the same call produced different events depending on which SDK sent it. ([#2359](https://github.com/canopy-labs/featureflip/issues/2359))
|
|
33
|
+
|
|
3
34
|
## 2.4.2 — 2026-08-05
|
|
4
35
|
|
|
5
36
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: featureflip
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.1
|
|
4
4
|
Summary: Python SDK for Featureflip - a feature flag SaaS platform
|
|
5
5
|
Project-URL: Homepage, https://featureflip.io
|
|
6
6
|
Project-URL: Documentation, https://featureflip.io/docs/sdks/python/
|
|
@@ -122,7 +122,7 @@ print(detail.rule_id) # Rule ID if reason is RULE_MATCH
|
|
|
122
122
|
# Track custom events
|
|
123
123
|
client.track("checkout-completed", {"user_id": "123"}, metadata={"total": 99.99})
|
|
124
124
|
|
|
125
|
-
#
|
|
125
|
+
# Record an identify event for analytics (does not affect flag evaluation)
|
|
126
126
|
client.identify({"user_id": "123", "email": "user@example.com", "plan": "pro"})
|
|
127
127
|
|
|
128
128
|
# Force flush pending events
|
|
@@ -90,7 +90,7 @@ print(detail.rule_id) # Rule ID if reason is RULE_MATCH
|
|
|
90
90
|
# Track custom events
|
|
91
91
|
client.track("checkout-completed", {"user_id": "123"}, metadata={"total": 99.99})
|
|
92
92
|
|
|
93
|
-
#
|
|
93
|
+
# Record an identify event for analytics (does not affect flag evaluation)
|
|
94
94
|
client.identify({"user_id": "123", "email": "user@example.com", "plan": "pro"})
|
|
95
95
|
|
|
96
96
|
# Force flush pending events
|
|
@@ -39,6 +39,20 @@ logger = structlog.get_logger()
|
|
|
39
39
|
T = TypeVar("T")
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
def _compact_event(event: dict[str, Any]) -> dict[str, Any]:
|
|
43
|
+
"""Drop the optional wire fields when they carry nothing.
|
|
44
|
+
|
|
45
|
+
``userId`` and ``metadata`` are both optional on the backend DTO. Every
|
|
46
|
+
server SDK omits them rather than sending ``null`` or an empty object, so
|
|
47
|
+
the same call produces the same bytes whichever SDK made it (#2359).
|
|
48
|
+
"""
|
|
49
|
+
if event.get("userId") is None:
|
|
50
|
+
event.pop("userId", None)
|
|
51
|
+
if not event.get("metadata"):
|
|
52
|
+
event.pop("metadata", None)
|
|
53
|
+
return event
|
|
54
|
+
|
|
55
|
+
|
|
42
56
|
def _resolve_user_id(context: dict[str, Any]) -> str | None:
|
|
43
57
|
"""Resolve the built-in user id from a raw context dict.
|
|
44
58
|
|
|
@@ -493,17 +507,23 @@ class _SharedFeatureflipCore:
|
|
|
493
507
|
# Send the backend SdkEventDto shape: top-level userId, no `context`.
|
|
494
508
|
# See apps/evaluation-api Controllers/SdkController.RecordEvents — the
|
|
495
509
|
# backend has no context/value/reason field, so anything else is dropped.
|
|
496
|
-
event =
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
510
|
+
event = _compact_event(
|
|
511
|
+
{
|
|
512
|
+
"type": "Custom",
|
|
513
|
+
"flagKey": event_name,
|
|
514
|
+
"userId": _resolve_user_id(context or {}),
|
|
515
|
+
"metadata": metadata,
|
|
516
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
517
|
+
}
|
|
518
|
+
)
|
|
503
519
|
self._event_processor.queue_event(event)
|
|
504
520
|
|
|
505
521
|
def identify(self, context: dict[str, Any]) -> None:
|
|
506
|
-
"""
|
|
522
|
+
"""Record an identify event for analytics.
|
|
523
|
+
|
|
524
|
+
Does not affect flag evaluation: targeting rules and segments are matched
|
|
525
|
+
against the context passed to each variation call.
|
|
526
|
+
"""
|
|
507
527
|
if self._test_mode_values is not None or self._event_processor is None:
|
|
508
528
|
return
|
|
509
529
|
# Promote the user id to the top-level userId field and put the remaining
|
|
@@ -514,13 +534,15 @@ class _SharedFeatureflipCore:
|
|
|
514
534
|
for k, v in (context or {}).items()
|
|
515
535
|
if k not in ("user_id", "userId")
|
|
516
536
|
}
|
|
517
|
-
event =
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
537
|
+
event = _compact_event(
|
|
538
|
+
{
|
|
539
|
+
"type": "Identify",
|
|
540
|
+
"flagKey": "$identify",
|
|
541
|
+
"userId": _resolve_user_id(context or {}),
|
|
542
|
+
"metadata": attributes,
|
|
543
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
544
|
+
}
|
|
545
|
+
)
|
|
524
546
|
self._event_processor.queue_event(event)
|
|
525
547
|
|
|
526
548
|
def flush(self) -> None:
|
|
@@ -543,13 +565,15 @@ class _SharedFeatureflipCore:
|
|
|
543
565
|
"""
|
|
544
566
|
if self._event_processor is None:
|
|
545
567
|
return
|
|
546
|
-
event: dict[str, Any] =
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
568
|
+
event: dict[str, Any] = _compact_event(
|
|
569
|
+
{
|
|
570
|
+
"type": "Evaluation",
|
|
571
|
+
"flagKey": key,
|
|
572
|
+
"userId": _resolve_user_id(context or {}),
|
|
573
|
+
"variation": variation_key,
|
|
574
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
575
|
+
}
|
|
576
|
+
)
|
|
553
577
|
self._event_processor.queue_event(event)
|
|
554
578
|
|
|
555
579
|
# --- Shutdown ---
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Event processor for batching and flushing analytics events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Protocol
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import structlog
|
|
11
|
+
|
|
12
|
+
logger = structlog.get_logger()
|
|
13
|
+
|
|
14
|
+
#: Upper bound on buffered events.
|
|
15
|
+
#:
|
|
16
|
+
#: Only reachable once a failed send starts returning batches to the queue faster than
|
|
17
|
+
#: they drain -- that is, a sustained outage of the events endpoint. Past the bound the
|
|
18
|
+
#: OLDEST events are shed, which caps memory and keeps the freshest analytics. It also
|
|
19
|
+
#: means a long outage sheds the re-queued (stale) batches rather than starving the new
|
|
20
|
+
#: events behind them, so the SDK degrades to losing the oldest data instead of hoarding
|
|
21
|
+
#: data it cannot send.
|
|
22
|
+
DEFAULT_MAX_QUEUE_SIZE = 10_000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _is_retryable_send_failure(error: BaseException) -> bool:
|
|
26
|
+
"""Whether the same batch could plausibly succeed if it were sent again.
|
|
27
|
+
|
|
28
|
+
For an HTTP answer the status decides: 5xx (the production edge answers this
|
|
29
|
+
endpoint with a 503 at a low constant rate) and 429, where the server is
|
|
30
|
+
explicitly asking the caller to come back later. Anything else -- 401/403 means
|
|
31
|
+
the SDK key was rejected, 400 means the body is malformed -- will fail
|
|
32
|
+
identically next time, and retrying it forever would pin the queue at its bound
|
|
33
|
+
and starve every later event.
|
|
34
|
+
|
|
35
|
+
Everything httpx raises for a request that never got an answer (connect, read
|
|
36
|
+
and write errors, timeouts, protocol errors) derives from ``httpx.HTTPError``,
|
|
37
|
+
and none of it says the server rejected the batch, so all of it is retryable.
|
|
38
|
+
A failure that is not an ``HTTPError`` at all -- a serialization ``TypeError``,
|
|
39
|
+
a bug in a custom client -- is deterministic and is dropped rather than retried.
|
|
40
|
+
"""
|
|
41
|
+
if isinstance(error, httpx.HTTPStatusError):
|
|
42
|
+
status = error.response.status_code
|
|
43
|
+
return status >= 500 or status == 429
|
|
44
|
+
return isinstance(error, httpx.HTTPError)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class HttpClientProtocol(Protocol):
|
|
48
|
+
"""Protocol for HTTP client to allow loose coupling."""
|
|
49
|
+
|
|
50
|
+
def post_events(self, events: list[dict[str, Any]]) -> None:
|
|
51
|
+
"""Send events to the API."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class EventProcessor:
|
|
56
|
+
"""Batches and flushes analytics events.
|
|
57
|
+
|
|
58
|
+
This processor collects events in an internal queue and sends them to the API
|
|
59
|
+
either when the batch reaches a threshold size or after a time interval.
|
|
60
|
+
Events are also flushed when the processor is stopped.
|
|
61
|
+
|
|
62
|
+
A send that fails for a transient reason keeps its batch for the next flush
|
|
63
|
+
rather than discarding it, and each request carries at most one batch; see
|
|
64
|
+
:meth:`flush`. The queue is bounded so that a long outage cannot grow it
|
|
65
|
+
without limit.
|
|
66
|
+
|
|
67
|
+
Thread-safe: multiple threads can safely queue events concurrently.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
http_client: HttpClientProtocol,
|
|
73
|
+
flush_interval: float = 30.0,
|
|
74
|
+
flush_batch_size: int = 100,
|
|
75
|
+
max_queue_size: int = DEFAULT_MAX_QUEUE_SIZE,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Initialize the event processor.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
http_client: HTTP client for sending events to the API.
|
|
81
|
+
flush_interval: Seconds between automatic flushes (default 30).
|
|
82
|
+
flush_batch_size: Number of events that triggers an immediate flush (default 100).
|
|
83
|
+
max_queue_size: Most events to buffer before shedding the oldest
|
|
84
|
+
(default :data:`DEFAULT_MAX_QUEUE_SIZE`).
|
|
85
|
+
"""
|
|
86
|
+
self._http = http_client
|
|
87
|
+
self._flush_interval = flush_interval
|
|
88
|
+
# Defence-in-depth against a non-positive batch size: flush() takes
|
|
89
|
+
# flush_batch_size events per pass, so a value below 1 would take nothing
|
|
90
|
+
# and loop forever on a non-empty queue. Config.__post_init__ already
|
|
91
|
+
# rejects such values, but this class is constructed directly too.
|
|
92
|
+
self._flush_batch_size = flush_batch_size if flush_batch_size >= 1 else 1
|
|
93
|
+
# A non-positive bound would shed every event on the way in, silently turning
|
|
94
|
+
# analytics off. Fall back rather than honour it.
|
|
95
|
+
self._max_queue_size = (
|
|
96
|
+
max_queue_size if max_queue_size > 0 else DEFAULT_MAX_QUEUE_SIZE
|
|
97
|
+
)
|
|
98
|
+
self._queue: list[dict[str, Any]] = []
|
|
99
|
+
self._lock = threading.Lock()
|
|
100
|
+
self._stop_event = threading.Event()
|
|
101
|
+
self._thread: threading.Thread | None = None
|
|
102
|
+
# Monotonic deadline before which the batch-size trigger must not start
|
|
103
|
+
# another flush; 0.0 means "not backing off". See _requeue_locked.
|
|
104
|
+
self._next_auto_flush_at = 0.0
|
|
105
|
+
# True while a batch-size-triggered flush is in flight. See queue_event.
|
|
106
|
+
self._auto_flush_in_flight = False
|
|
107
|
+
# True from stop() onward: the queue is closed to re-queues because nothing
|
|
108
|
+
# will flush it again.
|
|
109
|
+
self._stopping = False
|
|
110
|
+
|
|
111
|
+
def queue_event(self, event: dict[str, Any]) -> None:
|
|
112
|
+
"""Add an event to the queue.
|
|
113
|
+
|
|
114
|
+
Thread-safe. If the queue reaches the flush_batch_size threshold,
|
|
115
|
+
an immediate flush is triggered -- unless a retryable failure has put that
|
|
116
|
+
trigger in backoff, or an earlier size-triggered flush is still running.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
event: Event dictionary to queue. Should contain at minimum a 'type' field.
|
|
120
|
+
"""
|
|
121
|
+
should_flush = False
|
|
122
|
+
with self._lock:
|
|
123
|
+
self._queue.append(event)
|
|
124
|
+
dropped = self._trim_to_bound_locked()
|
|
125
|
+
if (
|
|
126
|
+
len(self._queue) >= self._flush_batch_size
|
|
127
|
+
and not self._auto_flush_in_flight
|
|
128
|
+
and time.monotonic() >= self._next_auto_flush_at
|
|
129
|
+
):
|
|
130
|
+
# Claim the latch under the same lock that read it, so two threads
|
|
131
|
+
# tracking concurrently cannot both start a flush.
|
|
132
|
+
self._auto_flush_in_flight = True
|
|
133
|
+
should_flush = True
|
|
134
|
+
|
|
135
|
+
if dropped:
|
|
136
|
+
logger.warning(
|
|
137
|
+
"event_queue_overflow",
|
|
138
|
+
dropped=dropped,
|
|
139
|
+
max_queue_size=self._max_queue_size,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if should_flush:
|
|
143
|
+
try:
|
|
144
|
+
self.flush()
|
|
145
|
+
finally:
|
|
146
|
+
with self._lock:
|
|
147
|
+
self._auto_flush_in_flight = False
|
|
148
|
+
|
|
149
|
+
def flush(self) -> None:
|
|
150
|
+
"""Flush all queued events immediately.
|
|
151
|
+
|
|
152
|
+
Blocks until the flush is complete. If the queue is empty, no API call is
|
|
153
|
+
made. Send failures are logged and never raised: a retryable one keeps its
|
|
154
|
+
batch for the next flush, a permanent one drops it.
|
|
155
|
+
"""
|
|
156
|
+
# One request per batch rather than one for the whole queue. Re-queuing
|
|
157
|
+
# failed batches is what lets the queue grow to its bound during an outage,
|
|
158
|
+
# and posting 10,000 events in a single body risks the server rejecting it
|
|
159
|
+
# outright -- a 413 is not retryable, so the entire backlog would be dropped
|
|
160
|
+
# by the very path that exists to preserve it (#2456).
|
|
161
|
+
while True:
|
|
162
|
+
with self._lock:
|
|
163
|
+
if not self._queue:
|
|
164
|
+
return
|
|
165
|
+
# Drained BEFORE the send, so the batch has to be held here to be
|
|
166
|
+
# put back if the send fails. Without that a single transient
|
|
167
|
+
# failure discarded it outright -- and the production edge answers
|
|
168
|
+
# this endpoint with a 503 at a low constant rate, so evaluation
|
|
169
|
+
# analytics were being lost steadily rather than exceptionally.
|
|
170
|
+
events_to_send = self._drain_batch_locked(self._flush_batch_size)
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
logger.debug("flushing_events", count=len(events_to_send))
|
|
174
|
+
self._http.post_events(events_to_send)
|
|
175
|
+
logger.debug("events_flushed_successfully", count=len(events_to_send))
|
|
176
|
+
with self._lock:
|
|
177
|
+
# The endpoint is answering again; let the size trigger work.
|
|
178
|
+
self._next_auto_flush_at = 0.0
|
|
179
|
+
except Exception as e:
|
|
180
|
+
if _is_retryable_send_failure(e):
|
|
181
|
+
with self._lock:
|
|
182
|
+
dropped = self._requeue_locked(events_to_send)
|
|
183
|
+
logger.warning(
|
|
184
|
+
"event_flush_error",
|
|
185
|
+
error=str(e),
|
|
186
|
+
count=len(events_to_send),
|
|
187
|
+
dropped=dropped,
|
|
188
|
+
)
|
|
189
|
+
# Stop here. The batch is back at the head of the queue this
|
|
190
|
+
# loop is draining, so continuing would re-send it immediately
|
|
191
|
+
# and spin for as long as the endpoint stayed down.
|
|
192
|
+
return
|
|
193
|
+
logger.warning(
|
|
194
|
+
"event_flush_dropped",
|
|
195
|
+
error=str(e),
|
|
196
|
+
count=len(events_to_send),
|
|
197
|
+
)
|
|
198
|
+
# Dropped, so the queue shrank and the loop still terminates. Carry
|
|
199
|
+
# on to the next batch rather than letting one poison batch block
|
|
200
|
+
# the backlog queued behind it.
|
|
201
|
+
|
|
202
|
+
def _drain_batch_locked(self, max_count: int) -> list[dict[str, Any]]:
|
|
203
|
+
"""Take up to ``max_count`` of the OLDEST events. Caller holds ``_lock``.
|
|
204
|
+
|
|
205
|
+
Oldest-first so the wire order matches the order the events happened in.
|
|
206
|
+
"""
|
|
207
|
+
take = min(max_count, len(self._queue))
|
|
208
|
+
batch = self._queue[:take]
|
|
209
|
+
del self._queue[:take]
|
|
210
|
+
return batch
|
|
211
|
+
|
|
212
|
+
def _requeue_locked(self, events: list[dict[str, Any]]) -> int:
|
|
213
|
+
"""Return a failed batch to the FRONT of the queue. Caller holds ``_lock``.
|
|
214
|
+
|
|
215
|
+
The front, so the next flush retries it ahead of newer events and rough
|
|
216
|
+
chronological order survives an outage.
|
|
217
|
+
|
|
218
|
+
Deliberately does NOT retry inline. The batch is back in the queue the
|
|
219
|
+
caller just drained, so re-sending it here would spin against a down
|
|
220
|
+
endpoint for as long as it stayed down, on whichever thread called
|
|
221
|
+
:meth:`flush` -- an evaluation thread, in the batch-size case. The periodic
|
|
222
|
+
flush thread is the retry vehicle.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
How many events were shed to stay within the bound.
|
|
226
|
+
"""
|
|
227
|
+
# Shutting down: nothing will flush again, so buffering here would only
|
|
228
|
+
# leak. Report the whole batch as dropped instead.
|
|
229
|
+
if self._stopping:
|
|
230
|
+
return len(events)
|
|
231
|
+
|
|
232
|
+
self._queue[:0] = events
|
|
233
|
+
|
|
234
|
+
# A re-queued batch leaves the queue at or above flush_batch_size, so without
|
|
235
|
+
# this gate the next tracked event would trigger another flush -- turning a
|
|
236
|
+
# failing endpoint into one request per evaluation, which is worse for the
|
|
237
|
+
# server than the dropping this replaced. It only suppresses the size
|
|
238
|
+
# trigger between periodic flushes; the flush loop keeps retrying.
|
|
239
|
+
self._next_auto_flush_at = time.monotonic() + self._flush_interval
|
|
240
|
+
|
|
241
|
+
return self._trim_to_bound_locked()
|
|
242
|
+
|
|
243
|
+
def _trim_to_bound_locked(self) -> int:
|
|
244
|
+
"""Shed oldest-first until the queue fits the bound. Caller holds ``_lock``.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
How many events were dropped.
|
|
248
|
+
"""
|
|
249
|
+
overflow = len(self._queue) - self._max_queue_size
|
|
250
|
+
if overflow <= 0:
|
|
251
|
+
return 0
|
|
252
|
+
del self._queue[:overflow]
|
|
253
|
+
return overflow
|
|
254
|
+
|
|
255
|
+
def start(self) -> None:
|
|
256
|
+
"""Start the background flush thread.
|
|
257
|
+
|
|
258
|
+
The background thread will periodically flush events at the configured interval.
|
|
259
|
+
"""
|
|
260
|
+
self._stop_event.clear()
|
|
261
|
+
with self._lock:
|
|
262
|
+
# A restart re-opens the queue, and any backoff left over from the outage
|
|
263
|
+
# that preceded the stop is stale.
|
|
264
|
+
self._stopping = False
|
|
265
|
+
self._next_auto_flush_at = 0.0
|
|
266
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
267
|
+
self._thread.start()
|
|
268
|
+
logger.info("event_processor_started", flush_interval=self._flush_interval)
|
|
269
|
+
|
|
270
|
+
def stop(self) -> None:
|
|
271
|
+
"""Stop the background thread, make one final flush attempt, then let go.
|
|
272
|
+
|
|
273
|
+
Blocks until the thread has stopped. Exactly one attempt: retrying until the
|
|
274
|
+
queue drained would hang shutdown for as long as the endpoint stayed down.
|
|
275
|
+
Nothing will flush after this, so whatever the attempt could not deliver is
|
|
276
|
+
discarded rather than kept.
|
|
277
|
+
"""
|
|
278
|
+
self._stop_event.set()
|
|
279
|
+
if self._thread and self._thread.is_alive():
|
|
280
|
+
self._thread.join(timeout=5.0)
|
|
281
|
+
|
|
282
|
+
# Closes the queue to re-queues, so the final flush below cannot put a failed
|
|
283
|
+
# batch back into a queue no one will drain.
|
|
284
|
+
with self._lock:
|
|
285
|
+
self._stopping = True
|
|
286
|
+
|
|
287
|
+
self.flush()
|
|
288
|
+
|
|
289
|
+
# Anything a concurrent queue_event slipped in during the flush.
|
|
290
|
+
with self._lock:
|
|
291
|
+
discarded = len(self._queue)
|
|
292
|
+
self._queue = []
|
|
293
|
+
if discarded:
|
|
294
|
+
logger.warning("events_discarded_on_stop", count=discarded)
|
|
295
|
+
|
|
296
|
+
logger.info("event_processor_stopped")
|
|
297
|
+
|
|
298
|
+
def _run(self) -> None:
|
|
299
|
+
"""Main loop for the background flush thread."""
|
|
300
|
+
while not self._stop_event.is_set():
|
|
301
|
+
# Wait for either the interval or stop signal
|
|
302
|
+
self._stop_event.wait(self._flush_interval)
|
|
303
|
+
if not self._stop_event.is_set():
|
|
304
|
+
self.flush()
|