pyintake 0.0.1__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.
- pyintake/__init__.py +83 -0
- pyintake/__pycache__/__init__.cpython-313.pyc +0 -0
- pyintake/__pycache__/_protocols.cpython-313.pyc +0 -0
- pyintake/__pycache__/_types.cpython-313.pyc +0 -0
- pyintake/__pycache__/errors.cpython-313.pyc +0 -0
- pyintake/__pycache__/event.cpython-313.pyc +0 -0
- pyintake/_protocols.py +145 -0
- pyintake/_types.py +135 -0
- pyintake/adapters/__init__.py +6 -0
- pyintake/api/__init__.py +3 -0
- pyintake/api/routes/__init__.py +3 -0
- pyintake/api/routes/v1/__init__.py +3 -0
- pyintake/buffer/__init__.py +6 -0
- pyintake/buffer/__pycache__/__init__.cpython-313.pyc +0 -0
- pyintake/buffer/__pycache__/_dedup.cpython-313.pyc +0 -0
- pyintake/buffer/__pycache__/_priority_buffer.cpython-313.pyc +0 -0
- pyintake/buffer/_dedup.py +110 -0
- pyintake/buffer/_priority_buffer.py +191 -0
- pyintake/config/__init__.py +3 -0
- pyintake/core/__init__.py +6 -0
- pyintake/core/__pycache__/__init__.cpython-313.pyc +0 -0
- pyintake/core/__pycache__/_drain.cpython-313.pyc +0 -0
- pyintake/core/__pycache__/_engine.cpython-313.pyc +0 -0
- pyintake/core/__pycache__/_rate_limiter.cpython-313.pyc +0 -0
- pyintake/core/_drain.py +248 -0
- pyintake/core/_engine.py +248 -0
- pyintake/core/_rate_limiter.py +87 -0
- pyintake/errors.py +205 -0
- pyintake/event.py +244 -0
- pyintake/metrics/__init__.py +7 -0
- pyintake/metrics/__pycache__/__init__.cpython-313.pyc +0 -0
- pyintake/metrics/__pycache__/_collector.cpython-313.pyc +0 -0
- pyintake/metrics/__pycache__/_models.cpython-313.pyc +0 -0
- pyintake/metrics/_collector.py +196 -0
- pyintake/metrics/_models.py +85 -0
- pyintake/py.typed +0 -0
- pyintake/validation/__init__.py +6 -0
- pyintake/validation/__pycache__/__init__.cpython-313.pyc +0 -0
- pyintake/validation/__pycache__/_chain.cpython-313.pyc +0 -0
- pyintake/validation/__pycache__/_validators.cpython-313.pyc +0 -0
- pyintake/validation/_chain.py +82 -0
- pyintake/validation/_validators.py +181 -0
- pyintake-0.0.1.dist-info/METADATA +47 -0
- pyintake-0.0.1.dist-info/RECORD +47 -0
- pyintake-0.0.1.dist-info/WHEEL +5 -0
- pyintake-0.0.1.dist-info/entry_points.txt +2 -0
- pyintake-0.0.1.dist-info/top_level.txt +1 -0
pyintake/__init__.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""PyIntake — Transport-agnostic data ingestion system.
|
|
2
|
+
|
|
3
|
+
Collects, validates, deduplicates, and buffers events from any transport,
|
|
4
|
+
then drains them into pluggable downstream consumers with priority ordering
|
|
5
|
+
and backpressure signaling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pyintake._protocols import Consumer, MetricsCollector, TransportAdapter, Validator
|
|
11
|
+
from pyintake._types import (
|
|
12
|
+
BackpressureSignal,
|
|
13
|
+
BufferEntry,
|
|
14
|
+
ConsumeResult,
|
|
15
|
+
IngestResult,
|
|
16
|
+
Priority,
|
|
17
|
+
ValidationOutcome,
|
|
18
|
+
)
|
|
19
|
+
from pyintake.buffer._dedup import SlidingWindowDedup
|
|
20
|
+
from pyintake.buffer._priority_buffer import PriorityBuffer
|
|
21
|
+
from pyintake.core._drain import AdaptiveDrainLoop
|
|
22
|
+
from pyintake.core._engine import IngestionEngine
|
|
23
|
+
from pyintake.core._rate_limiter import TokenBucketRateLimiter
|
|
24
|
+
from pyintake.errors import (
|
|
25
|
+
AdapterError,
|
|
26
|
+
BufferFullError,
|
|
27
|
+
ConfigError,
|
|
28
|
+
ConsumerError,
|
|
29
|
+
DrainError,
|
|
30
|
+
DuplicateEventError,
|
|
31
|
+
ErrorCode,
|
|
32
|
+
PyIntakeError,
|
|
33
|
+
RateLimitedError,
|
|
34
|
+
ShutdownTimeoutError,
|
|
35
|
+
ValidationError,
|
|
36
|
+
)
|
|
37
|
+
from pyintake.event import IngestEvent
|
|
38
|
+
from pyintake.metrics._collector import InMemoryMetricsCollector
|
|
39
|
+
from pyintake.metrics._models import MetricsSnapshot, SourceMetrics
|
|
40
|
+
from pyintake.validation._chain import ValidationChain
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
# Core event type
|
|
44
|
+
"IngestEvent",
|
|
45
|
+
# Value types and enums
|
|
46
|
+
"Priority",
|
|
47
|
+
"BackpressureSignal",
|
|
48
|
+
"IngestResult",
|
|
49
|
+
"ValidationOutcome",
|
|
50
|
+
"ConsumeResult",
|
|
51
|
+
"BufferEntry",
|
|
52
|
+
# Protocols
|
|
53
|
+
"TransportAdapter",
|
|
54
|
+
"Validator",
|
|
55
|
+
"Consumer",
|
|
56
|
+
"MetricsCollector",
|
|
57
|
+
# Engine and drain
|
|
58
|
+
"IngestionEngine",
|
|
59
|
+
"AdaptiveDrainLoop",
|
|
60
|
+
# Buffer
|
|
61
|
+
"PriorityBuffer",
|
|
62
|
+
"SlidingWindowDedup",
|
|
63
|
+
# Validation
|
|
64
|
+
"ValidationChain",
|
|
65
|
+
# Rate limiting
|
|
66
|
+
"TokenBucketRateLimiter",
|
|
67
|
+
# Metrics
|
|
68
|
+
"InMemoryMetricsCollector",
|
|
69
|
+
"MetricsSnapshot",
|
|
70
|
+
"SourceMetrics",
|
|
71
|
+
# Errors
|
|
72
|
+
"PyIntakeError",
|
|
73
|
+
"ErrorCode",
|
|
74
|
+
"BufferFullError",
|
|
75
|
+
"ValidationError",
|
|
76
|
+
"DuplicateEventError",
|
|
77
|
+
"RateLimitedError",
|
|
78
|
+
"AdapterError",
|
|
79
|
+
"DrainError",
|
|
80
|
+
"ConfigError",
|
|
81
|
+
"ConsumerError",
|
|
82
|
+
"ShutdownTimeoutError",
|
|
83
|
+
]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
pyintake/_protocols.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Protocol definitions for PyIntake pluggable interfaces.
|
|
2
|
+
|
|
3
|
+
Protocols define the contracts for transport adapters, validators,
|
|
4
|
+
consumers, and metrics collectors. Implementations can be swapped
|
|
5
|
+
in via dependency injection without coupling to concrete classes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from pyintake._types import ConsumeResult, ValidationOutcome
|
|
14
|
+
from pyintake.event import IngestEvent
|
|
15
|
+
from pyintake.metrics._models import MetricsSnapshot
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Transport adapter protocol
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@runtime_checkable
|
|
24
|
+
class TransportAdapter(Protocol):
|
|
25
|
+
"""Receives events from a transport and feeds them into IngestionEngine.
|
|
26
|
+
|
|
27
|
+
Implementations handle transport-specific concerns (Kafka consumer,
|
|
28
|
+
HTTP server, file watcher, etc.) and call engine.ingest() for each event.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
async def start(self, engine: Any) -> None:
|
|
32
|
+
"""Start the adapter and begin feeding events to the engine.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
engine: The IngestionEngine to feed events into.
|
|
36
|
+
"""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
async def stop(self) -> None:
|
|
40
|
+
"""Stop the adapter and release transport resources."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Validator protocol
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@runtime_checkable
|
|
50
|
+
class Validator(Protocol):
|
|
51
|
+
"""Validates an IngestEvent before it enters the buffer.
|
|
52
|
+
|
|
53
|
+
Validators run in the ingestion path (before buffering), so they
|
|
54
|
+
must be fast and synchronous. Heavy validation should be done
|
|
55
|
+
downstream by consumers.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def name(self) -> str:
|
|
60
|
+
"""Human-readable name for metrics and logging."""
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
def validate(self, event: IngestEvent) -> ValidationOutcome:
|
|
64
|
+
"""Validate a single event.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
event: The event to validate.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Outcome indicating whether the event is valid.
|
|
71
|
+
"""
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Consumer protocol
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@runtime_checkable
|
|
81
|
+
class Consumer(Protocol):
|
|
82
|
+
"""Downstream consumer that receives drained event batches.
|
|
83
|
+
|
|
84
|
+
Consumers are the output side of the ingestion pipeline. They
|
|
85
|
+
receive batches of validated, deduplicated events from the
|
|
86
|
+
drain loop. Examples: pystator orchestrator, pycharter validator,
|
|
87
|
+
file collector for offline analysis.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
async def consume(self, batch: list[IngestEvent]) -> ConsumeResult:
|
|
91
|
+
"""Process a batch of events.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
batch: List of events drained from the buffer.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Result indicating success/failure and events processed.
|
|
98
|
+
"""
|
|
99
|
+
...
|
|
100
|
+
|
|
101
|
+
async def close(self) -> None:
|
|
102
|
+
"""Release any resources held by the consumer."""
|
|
103
|
+
...
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Metrics collector protocol
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@runtime_checkable
|
|
112
|
+
class MetricsCollector(Protocol):
|
|
113
|
+
"""Collects ingestion metrics programmatically.
|
|
114
|
+
|
|
115
|
+
All methods are synchronous and must be fast — they are called
|
|
116
|
+
in the hot ingestion path.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def record_accepted(self, event: IngestEvent) -> None:
|
|
120
|
+
"""Record a successfully accepted event."""
|
|
121
|
+
...
|
|
122
|
+
|
|
123
|
+
def record_rejected(self, event: IngestEvent, reason: str) -> None:
|
|
124
|
+
"""Record a rejected event with the rejection reason."""
|
|
125
|
+
...
|
|
126
|
+
|
|
127
|
+
def record_drained(self, count: int, sources: list[str] | None = None) -> None:
|
|
128
|
+
"""Record that count events were drained to consumers."""
|
|
129
|
+
...
|
|
130
|
+
|
|
131
|
+
def record_deduplicated(self, event: IngestEvent) -> None:
|
|
132
|
+
"""Record a deduplicated (duplicate-rejected) event."""
|
|
133
|
+
...
|
|
134
|
+
|
|
135
|
+
def record_shed(self, event: IngestEvent) -> None:
|
|
136
|
+
"""Record an event shed due to buffer pressure."""
|
|
137
|
+
...
|
|
138
|
+
|
|
139
|
+
def record_dead_letter(self, event: IngestEvent) -> None:
|
|
140
|
+
"""Record an event that exceeded max retries."""
|
|
141
|
+
...
|
|
142
|
+
|
|
143
|
+
def snapshot(self) -> MetricsSnapshot:
|
|
144
|
+
"""Return a frozen point-in-time snapshot of all metrics."""
|
|
145
|
+
...
|
pyintake/_types.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Core value types for PyIntake.
|
|
2
|
+
|
|
3
|
+
Frozen dataclasses and enums used throughout the ingestion system.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from enum import IntEnum, StrEnum
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from pyintake.event import IngestEvent
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Priority(IntEnum):
|
|
18
|
+
"""Buffer priority lanes. Lower value = higher priority.
|
|
19
|
+
|
|
20
|
+
HIGH events are never shed under pressure (only rejected when
|
|
21
|
+
the buffer is completely full). LOW events are shed first.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
HIGH = 0
|
|
25
|
+
NORMAL = 1
|
|
26
|
+
LOW = 2
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BackpressureSignal(StrEnum):
|
|
30
|
+
"""Signal to callers about current buffer health.
|
|
31
|
+
|
|
32
|
+
Transport adapters use this to decide how to react:
|
|
33
|
+
- HTTP: return 429
|
|
34
|
+
- Kafka: pause partitions
|
|
35
|
+
- WebSocket: send flow-control frame
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
NONE = "none"
|
|
39
|
+
SLOW_DOWN = "slow_down"
|
|
40
|
+
REJECT = "reject"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class IngestResult:
|
|
45
|
+
"""Result returned from every ingest() call.
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
accepted: Whether the event was accepted into the buffer.
|
|
49
|
+
event_id: The event ID that was processed.
|
|
50
|
+
buffer_utilization: Current buffer utilization (0.0 - 1.0).
|
|
51
|
+
backpressure: Backpressure signal for the caller.
|
|
52
|
+
rejection_reason: Human-readable reason if rejected.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
accepted: bool
|
|
56
|
+
event_id: str
|
|
57
|
+
buffer_utilization: float
|
|
58
|
+
backpressure: BackpressureSignal
|
|
59
|
+
rejection_reason: str | None = None
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> dict[str, Any]:
|
|
62
|
+
"""Serialize to dictionary."""
|
|
63
|
+
return {
|
|
64
|
+
"accepted": self.accepted,
|
|
65
|
+
"event_id": self.event_id,
|
|
66
|
+
"buffer_utilization": self.buffer_utilization,
|
|
67
|
+
"backpressure": self.backpressure.value,
|
|
68
|
+
"rejection_reason": self.rejection_reason,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class ValidationOutcome:
|
|
74
|
+
"""Result of running a validator on an event.
|
|
75
|
+
|
|
76
|
+
Attributes:
|
|
77
|
+
is_valid: Whether the event passed validation.
|
|
78
|
+
reasons: Rejection reasons (empty if valid).
|
|
79
|
+
validator_name: Name of the validator that produced this outcome.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
is_valid: bool
|
|
83
|
+
reasons: tuple[str, ...] = ()
|
|
84
|
+
validator_name: str = ""
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def accept(cls) -> ValidationOutcome:
|
|
88
|
+
"""Create an accepted outcome."""
|
|
89
|
+
return cls(is_valid=True)
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def reject(
|
|
93
|
+
cls,
|
|
94
|
+
reasons: tuple[str, ...] | list[str],
|
|
95
|
+
validator_name: str = "",
|
|
96
|
+
) -> ValidationOutcome:
|
|
97
|
+
"""Create a rejected outcome."""
|
|
98
|
+
return cls(
|
|
99
|
+
is_valid=False,
|
|
100
|
+
reasons=tuple(reasons),
|
|
101
|
+
validator_name=validator_name,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True, slots=True)
|
|
106
|
+
class ConsumeResult:
|
|
107
|
+
"""Result from a downstream consumer processing a batch.
|
|
108
|
+
|
|
109
|
+
Attributes:
|
|
110
|
+
success: Whether the consumer processed the batch without errors.
|
|
111
|
+
processed: Number of events successfully processed.
|
|
112
|
+
errors: Error messages for failed events.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
success: bool
|
|
116
|
+
processed: int
|
|
117
|
+
errors: tuple[str, ...] = ()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(slots=True)
|
|
121
|
+
class BufferEntry:
|
|
122
|
+
"""Mutable wrapper around IngestEvent for retry tracking in the buffer.
|
|
123
|
+
|
|
124
|
+
The IngestEvent is frozen/immutable, but we need to track retry state
|
|
125
|
+
as events are requeued on consumer failure.
|
|
126
|
+
|
|
127
|
+
Attributes:
|
|
128
|
+
event: The immutable IngestEvent.
|
|
129
|
+
retry_count: Number of times this event has been requeued.
|
|
130
|
+
first_buffered_at: When the event first entered the buffer.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
event: IngestEvent
|
|
134
|
+
retry_count: int = 0
|
|
135
|
+
first_buffered_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
pyintake/api/__init__.py
ADDED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Sliding-window deduplication for PyIntake.
|
|
2
|
+
|
|
3
|
+
Time-bounded AND count-bounded dedup using OrderedDict for O(1)
|
|
4
|
+
insert/lookup with LRU eviction.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from collections import OrderedDict
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SlidingWindowDedup:
|
|
15
|
+
"""Sliding-window deduplication by event ID.
|
|
16
|
+
|
|
17
|
+
Maintains a bounded set of recently seen event IDs. An event is
|
|
18
|
+
considered a duplicate if its ID was seen within the window.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
window_seconds: Time window for dedup (seconds).
|
|
22
|
+
max_ids: Maximum number of IDs to track (prevents unbounded memory).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
window_seconds: int = 300,
|
|
28
|
+
max_ids: int = 100_000,
|
|
29
|
+
) -> None:
|
|
30
|
+
if window_seconds <= 0:
|
|
31
|
+
raise ValueError("window_seconds must be positive")
|
|
32
|
+
if max_ids <= 0:
|
|
33
|
+
raise ValueError("max_ids must be positive")
|
|
34
|
+
|
|
35
|
+
self._window_seconds = window_seconds
|
|
36
|
+
self._max_ids = max_ids
|
|
37
|
+
self._lock = threading.Lock()
|
|
38
|
+
self._seen: OrderedDict[str, float] = OrderedDict()
|
|
39
|
+
|
|
40
|
+
def is_duplicate(self, event_id: str) -> bool:
|
|
41
|
+
"""Check if event_id was seen within the sliding window.
|
|
42
|
+
|
|
43
|
+
Thread-safe.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
event_id: The event ID to check.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
True if the event is a duplicate.
|
|
50
|
+
"""
|
|
51
|
+
with self._lock:
|
|
52
|
+
self._evict_expired()
|
|
53
|
+
return event_id in self._seen
|
|
54
|
+
|
|
55
|
+
def record(self, event_id: str) -> None:
|
|
56
|
+
"""Record event_id with current timestamp.
|
|
57
|
+
|
|
58
|
+
Thread-safe. Evicts expired entries and overflow entries
|
|
59
|
+
beyond max_ids.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
event_id: The event ID to record.
|
|
63
|
+
"""
|
|
64
|
+
with self._lock:
|
|
65
|
+
now = time.monotonic()
|
|
66
|
+
self._evict_expired()
|
|
67
|
+
|
|
68
|
+
if event_id in self._seen:
|
|
69
|
+
self._seen.move_to_end(event_id)
|
|
70
|
+
self._seen[event_id] = now
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
self._seen[event_id] = now
|
|
74
|
+
|
|
75
|
+
while len(self._seen) > self._max_ids:
|
|
76
|
+
self._seen.popitem(last=False)
|
|
77
|
+
|
|
78
|
+
def _evict_expired(self) -> None:
|
|
79
|
+
"""Remove entries older than window_seconds from the front.
|
|
80
|
+
|
|
81
|
+
Must be called while holding self._lock.
|
|
82
|
+
"""
|
|
83
|
+
cutoff = time.monotonic() - self._window_seconds
|
|
84
|
+
while self._seen:
|
|
85
|
+
oldest_key = next(iter(self._seen))
|
|
86
|
+
if self._seen[oldest_key] < cutoff:
|
|
87
|
+
self._seen.popitem(last=False)
|
|
88
|
+
else:
|
|
89
|
+
break
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def size(self) -> int:
|
|
93
|
+
"""Number of event IDs currently tracked."""
|
|
94
|
+
with self._lock:
|
|
95
|
+
return len(self._seen)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def window_seconds(self) -> int:
|
|
99
|
+
"""The time window for dedup."""
|
|
100
|
+
return self._window_seconds
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def max_ids(self) -> int:
|
|
104
|
+
"""Maximum number of IDs tracked."""
|
|
105
|
+
return self._max_ids
|
|
106
|
+
|
|
107
|
+
def clear(self) -> None:
|
|
108
|
+
"""Clear all tracked event IDs."""
|
|
109
|
+
with self._lock:
|
|
110
|
+
self._seen.clear()
|