stream-velocity 0.1.0__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.
- stream_velocity-0.1.0/.github/workflows/test.yml +36 -0
- stream_velocity-0.1.0/.gitignore +10 -0
- stream_velocity-0.1.0/Dockerfile +17 -0
- stream_velocity-0.1.0/LICENSE +21 -0
- stream_velocity-0.1.0/PKG-INFO +193 -0
- stream_velocity-0.1.0/README.md +171 -0
- stream_velocity-0.1.0/pyproject.toml +37 -0
- stream_velocity-0.1.0/src/stream_velocity/__init__.py +5 -0
- stream_velocity-0.1.0/src/stream_velocity/ring_buffer.py +88 -0
- stream_velocity-0.1.0/src/stream_velocity/tracker.py +125 -0
- stream_velocity-0.1.0/tests/benchmark_latency.py +34 -0
- stream_velocity-0.1.0/tests/benchmark_memory.py +75 -0
- stream_velocity-0.1.0/tests/test_velocity.py +96 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
name: CI Test Suite
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main, master]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main, master]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.9", "3.11"]
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- name: Set up Python
|
|
20
|
+
uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: ${{ matrix.python-version }}
|
|
23
|
+
|
|
24
|
+
- name: Install package
|
|
25
|
+
run: |
|
|
26
|
+
python -m pip install --upgrade pip
|
|
27
|
+
pip install -e .
|
|
28
|
+
|
|
29
|
+
- name: Run unit tests
|
|
30
|
+
run: python -m unittest discover -s tests -v
|
|
31
|
+
|
|
32
|
+
- name: Run SLA benchmark
|
|
33
|
+
run: python tests/benchmark_latency.py
|
|
34
|
+
|
|
35
|
+
- name: Run memory benchmark
|
|
36
|
+
run: python tests/benchmark_memory.py
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# A small, self-contained image that installs the library and runs its
|
|
2
|
+
# benchmarks. Built on Alpine Linux so the image itself stays lightweight.
|
|
3
|
+
|
|
4
|
+
FROM python:3.11-alpine
|
|
5
|
+
|
|
6
|
+
WORKDIR /app
|
|
7
|
+
|
|
8
|
+
# Copy the project files into the image.
|
|
9
|
+
COPY pyproject.toml README.md LICENSE ./
|
|
10
|
+
COPY src ./src
|
|
11
|
+
COPY tests ./tests
|
|
12
|
+
|
|
13
|
+
# Install the library from source.
|
|
14
|
+
RUN pip install --no-cache-dir .
|
|
15
|
+
|
|
16
|
+
# When the container starts, run the speed benchmark to confirm it stays fast.
|
|
17
|
+
CMD ["python", "tests/benchmark_latency.py"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Woo Chan Lee
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: stream-velocity
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A zero-dependency, bounded-memory Python library for sub-5ms sliding-window transaction velocity aggregations.
|
|
5
|
+
Project-URL: Homepage, https://github.com/lee14257/stream-velocity
|
|
6
|
+
Project-URL: Repository, https://github.com/lee14257/stream-velocity
|
|
7
|
+
Author: Woo Chan Lee
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: feature-store,fintech,fraud-detection,streaming,velocity
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# stream-velocity
|
|
24
|
+
|
|
25
|
+
[](https://opensource.org/licenses/MIT)
|
|
26
|
+
[](https://www.python.org/downloads/)
|
|
27
|
+
[](https://github.com/lee14257/stream-velocity/actions/workflows/test.yml)
|
|
28
|
+
|
|
29
|
+
`stream-velocity` is a small, dependency-free Python library that answers one question in real time: **"how many events happened in the last few minutes, and how much did they total?"** — per account, card, or any other entity.
|
|
30
|
+
|
|
31
|
+
It exists because small fraud and risk teams often need this exact check but can't justify a Redis cluster or a $10k/month streaming platform. `stream-velocity` gives them a fast, in-process alternative that uses a fixed amount of memory no matter how much data flows through it.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install -e .
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
> A public PyPI release is in progress. Until then, install from source as shown above.
|
|
40
|
+
|
|
41
|
+
## Quick Start
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from stream_velocity import VelocityTracker
|
|
45
|
+
|
|
46
|
+
# Track a 5-minute window, split into 5-second buckets
|
|
47
|
+
tracker = VelocityTracker(window_seconds=300, bucket_seconds=5)
|
|
48
|
+
|
|
49
|
+
# Record incoming events for an account
|
|
50
|
+
tracker.record_event(entity_id="acc_990142", amount=150.00)
|
|
51
|
+
tracker.record_event(entity_id="acc_990142", amount=45.50)
|
|
52
|
+
tracker.record_event(entity_id="acc_990142", amount=210.00)
|
|
53
|
+
|
|
54
|
+
# Read the current rolling totals
|
|
55
|
+
velocity = tracker.get_velocity("acc_990142")
|
|
56
|
+
|
|
57
|
+
print(velocity.count) # 3
|
|
58
|
+
print(velocity.sum_amount) # 405.5
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## API Reference
|
|
62
|
+
|
|
63
|
+
### `VelocityTracker`
|
|
64
|
+
|
|
65
|
+
The main entry point. Tracks rolling velocity across many entities.
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
VelocityTracker(
|
|
69
|
+
window_seconds=300,
|
|
70
|
+
bucket_seconds=5,
|
|
71
|
+
idle_timeout=None,
|
|
72
|
+
cleanup_interval=60.0,
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
| Parameter | Type | Default | Description |
|
|
77
|
+
| --- | --- | --- | --- |
|
|
78
|
+
| `window_seconds` | `int` | `300` | Length of the rolling window, in seconds. |
|
|
79
|
+
| `bucket_seconds` | `int` | `5` | Width of each bucket, in seconds. `window_seconds` must be evenly divisible by `bucket_seconds`. |
|
|
80
|
+
| `idle_timeout` | `float` or `None` | `None` | Drop an entity if it records no events for this many seconds. `None` disables eviction. Should be at least `window_seconds`. |
|
|
81
|
+
| `cleanup_interval` | `float` | `60.0` | How often (seconds) the idle sweep runs automatically. |
|
|
82
|
+
|
|
83
|
+
**Methods**
|
|
84
|
+
|
|
85
|
+
- `record_event(entity_id, amount=0.0, timestamp=None)` → `None` — record one event.
|
|
86
|
+
- `timestamp` defaults to the current time; pass an explicit value for replay or testing.
|
|
87
|
+
- `get_velocity(entity_id, timestamp=None)` → `VelocityResult` — current rolling totals for an entity. Returns zeros if the entity is unknown or has been evicted.
|
|
88
|
+
- `cleanup(now=None)` → `None` — immediately drop entities idle longer than `idle_timeout`. The sweep also runs automatically every `cleanup_interval` seconds.
|
|
89
|
+
- `len(tracker)` → `int` — number of entities currently tracked.
|
|
90
|
+
|
|
91
|
+
### `VelocityResult`
|
|
92
|
+
|
|
93
|
+
A frozen (immutable) dataclass returned by `get_velocity`.
|
|
94
|
+
|
|
95
|
+
| Field | Type | Description |
|
|
96
|
+
| --- | --- | --- |
|
|
97
|
+
| `entity_id` | `str` | The entity the result is for. |
|
|
98
|
+
| `window_seconds` | `int` | The window length used. |
|
|
99
|
+
| `count` | `int` | Number of events in the window. |
|
|
100
|
+
| `sum_amount` | `float` | Sum of `amount` values in the window. |
|
|
101
|
+
|
|
102
|
+
### `BucketedRingBuffer` (lower-level)
|
|
103
|
+
|
|
104
|
+
The underlying single-entity counter. Prefer `VelocityTracker` unless you need to manage one entity manually.
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
buffer = BucketedRingBuffer(window_seconds=300, bucket_seconds=5)
|
|
108
|
+
buffer.record(timestamp=1_000_000.0, amount=10.0)
|
|
109
|
+
metrics = buffer.get_metrics(current_timestamp=1_000_010.0) # {"window_seconds": 300, "count": 1, "sum_amount": 10.0}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Features
|
|
113
|
+
|
|
114
|
+
- **No dependencies** — pure Python 3.9+, no database, no C extensions.
|
|
115
|
+
- **Always fast** — updates and lookups take the same time whether you process 10 or 10 million events (this is what "O(1)" means).
|
|
116
|
+
- **Fixed memory** — counters are created once and reused forever, so memory use stays flat no matter how many events arrive.
|
|
117
|
+
- **Bounded tracking** — set `idle_timeout` to drop idle entities automatically, so millions of distinct accounts don't grow memory forever.
|
|
118
|
+
- **Low latency** — reads stay under 5 ms while processing 100,000+ events.
|
|
119
|
+
|
|
120
|
+
## Limitations
|
|
121
|
+
|
|
122
|
+
- **Single process** — the tracker lives in memory and is not shared across processes or machines. Run one instance per worker, or pair it with an external store for distributed setups.
|
|
123
|
+
- **Not persistent** — state is lost on restart; there is no disk or external storage.
|
|
124
|
+
- **Not thread-safe** — use one tracker per thread or worker, or add external locking if you share a tracker across threads.
|
|
125
|
+
|
|
126
|
+
## How It Works
|
|
127
|
+
|
|
128
|
+
A rolling window is split into a fixed number of buckets. For a 5-minute window with 5-second buckets, that's 60 buckets.
|
|
129
|
+
|
|
130
|
+
- **Finding the bucket** — an event's timestamp maps to a bucket with one division: `slot = (timestamp // bucket_seconds) % num_buckets`.
|
|
131
|
+
- **Reusing buckets** — as time passes, expired buckets are wiped clean and reused, so the total number of buckets never changes.
|
|
132
|
+
- **Running totals** — two totals (count and amount) are kept up to date on every write, so reading the answer means just returning the totals — no scanning.
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
events ──► slot = (timestamp // 5) % 60
|
|
136
|
+
│
|
|
137
|
+
▼
|
|
138
|
+
┌───┬───┬───┬───┬───┬───┬───┐
|
|
139
|
+
│ 0 │ 1 │ 2 │...│ 57│ 58│ 59│ ◄── fixed ring of buckets, reused forever
|
|
140
|
+
└───┴───┴───┴───┴───┴───┴───┘
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Only counts and sums are stored — no raw transaction details.
|
|
144
|
+
|
|
145
|
+
## Benchmarks
|
|
146
|
+
|
|
147
|
+
### Latency
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
python3 tests/benchmark_latency.py
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Representative output (Apple M-series MacBook Air, Python 3.10). Absolute timings vary by machine; the `< 5.0 ms` SLA is enforced in CI on every push.
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
--- Running stream-velocity benchmark: 100,000 events ---
|
|
157
|
+
Total write time for 100,000 events: 61.88 ms
|
|
158
|
+
Average write latency per event: 0.62 µs
|
|
159
|
+
Read query latency: 0.0418 ms
|
|
160
|
+
Aggregation result: 100000 events, $1,000,000.00
|
|
161
|
+
SLA Target Passed: Query execution < 5.0 ms
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Memory
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
python3 tests/benchmark_memory.py
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The library creates its counters once and reuses them, so memory stays flat as events pile up. A typical 5-minute window uses only a few kilobytes — far below the 100 MB ceiling a lightweight fraud service can run within.
|
|
171
|
+
|
|
172
|
+
## Docker
|
|
173
|
+
|
|
174
|
+
A minimal Alpine image installs the library and runs the speed benchmark:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
docker build -t stream-velocity .
|
|
178
|
+
docker run --rm stream-velocity
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Development
|
|
182
|
+
|
|
183
|
+
Run the test suite and benchmarks:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
python3 -m unittest discover -s tests
|
|
187
|
+
python3 tests/benchmark_latency.py
|
|
188
|
+
python3 tests/benchmark_memory.py
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# stream-velocity
|
|
2
|
+
|
|
3
|
+
[](https://opensource.org/licenses/MIT)
|
|
4
|
+
[](https://www.python.org/downloads/)
|
|
5
|
+
[](https://github.com/lee14257/stream-velocity/actions/workflows/test.yml)
|
|
6
|
+
|
|
7
|
+
`stream-velocity` is a small, dependency-free Python library that answers one question in real time: **"how many events happened in the last few minutes, and how much did they total?"** — per account, card, or any other entity.
|
|
8
|
+
|
|
9
|
+
It exists because small fraud and risk teams often need this exact check but can't justify a Redis cluster or a $10k/month streaming platform. `stream-velocity` gives them a fast, in-process alternative that uses a fixed amount of memory no matter how much data flows through it.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install -e .
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
> A public PyPI release is in progress. Until then, install from source as shown above.
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from stream_velocity import VelocityTracker
|
|
23
|
+
|
|
24
|
+
# Track a 5-minute window, split into 5-second buckets
|
|
25
|
+
tracker = VelocityTracker(window_seconds=300, bucket_seconds=5)
|
|
26
|
+
|
|
27
|
+
# Record incoming events for an account
|
|
28
|
+
tracker.record_event(entity_id="acc_990142", amount=150.00)
|
|
29
|
+
tracker.record_event(entity_id="acc_990142", amount=45.50)
|
|
30
|
+
tracker.record_event(entity_id="acc_990142", amount=210.00)
|
|
31
|
+
|
|
32
|
+
# Read the current rolling totals
|
|
33
|
+
velocity = tracker.get_velocity("acc_990142")
|
|
34
|
+
|
|
35
|
+
print(velocity.count) # 3
|
|
36
|
+
print(velocity.sum_amount) # 405.5
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## API Reference
|
|
40
|
+
|
|
41
|
+
### `VelocityTracker`
|
|
42
|
+
|
|
43
|
+
The main entry point. Tracks rolling velocity across many entities.
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
VelocityTracker(
|
|
47
|
+
window_seconds=300,
|
|
48
|
+
bucket_seconds=5,
|
|
49
|
+
idle_timeout=None,
|
|
50
|
+
cleanup_interval=60.0,
|
|
51
|
+
)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
| Parameter | Type | Default | Description |
|
|
55
|
+
| --- | --- | --- | --- |
|
|
56
|
+
| `window_seconds` | `int` | `300` | Length of the rolling window, in seconds. |
|
|
57
|
+
| `bucket_seconds` | `int` | `5` | Width of each bucket, in seconds. `window_seconds` must be evenly divisible by `bucket_seconds`. |
|
|
58
|
+
| `idle_timeout` | `float` or `None` | `None` | Drop an entity if it records no events for this many seconds. `None` disables eviction. Should be at least `window_seconds`. |
|
|
59
|
+
| `cleanup_interval` | `float` | `60.0` | How often (seconds) the idle sweep runs automatically. |
|
|
60
|
+
|
|
61
|
+
**Methods**
|
|
62
|
+
|
|
63
|
+
- `record_event(entity_id, amount=0.0, timestamp=None)` → `None` — record one event.
|
|
64
|
+
- `timestamp` defaults to the current time; pass an explicit value for replay or testing.
|
|
65
|
+
- `get_velocity(entity_id, timestamp=None)` → `VelocityResult` — current rolling totals for an entity. Returns zeros if the entity is unknown or has been evicted.
|
|
66
|
+
- `cleanup(now=None)` → `None` — immediately drop entities idle longer than `idle_timeout`. The sweep also runs automatically every `cleanup_interval` seconds.
|
|
67
|
+
- `len(tracker)` → `int` — number of entities currently tracked.
|
|
68
|
+
|
|
69
|
+
### `VelocityResult`
|
|
70
|
+
|
|
71
|
+
A frozen (immutable) dataclass returned by `get_velocity`.
|
|
72
|
+
|
|
73
|
+
| Field | Type | Description |
|
|
74
|
+
| --- | --- | --- |
|
|
75
|
+
| `entity_id` | `str` | The entity the result is for. |
|
|
76
|
+
| `window_seconds` | `int` | The window length used. |
|
|
77
|
+
| `count` | `int` | Number of events in the window. |
|
|
78
|
+
| `sum_amount` | `float` | Sum of `amount` values in the window. |
|
|
79
|
+
|
|
80
|
+
### `BucketedRingBuffer` (lower-level)
|
|
81
|
+
|
|
82
|
+
The underlying single-entity counter. Prefer `VelocityTracker` unless you need to manage one entity manually.
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
buffer = BucketedRingBuffer(window_seconds=300, bucket_seconds=5)
|
|
86
|
+
buffer.record(timestamp=1_000_000.0, amount=10.0)
|
|
87
|
+
metrics = buffer.get_metrics(current_timestamp=1_000_010.0) # {"window_seconds": 300, "count": 1, "sum_amount": 10.0}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Features
|
|
91
|
+
|
|
92
|
+
- **No dependencies** — pure Python 3.9+, no database, no C extensions.
|
|
93
|
+
- **Always fast** — updates and lookups take the same time whether you process 10 or 10 million events (this is what "O(1)" means).
|
|
94
|
+
- **Fixed memory** — counters are created once and reused forever, so memory use stays flat no matter how many events arrive.
|
|
95
|
+
- **Bounded tracking** — set `idle_timeout` to drop idle entities automatically, so millions of distinct accounts don't grow memory forever.
|
|
96
|
+
- **Low latency** — reads stay under 5 ms while processing 100,000+ events.
|
|
97
|
+
|
|
98
|
+
## Limitations
|
|
99
|
+
|
|
100
|
+
- **Single process** — the tracker lives in memory and is not shared across processes or machines. Run one instance per worker, or pair it with an external store for distributed setups.
|
|
101
|
+
- **Not persistent** — state is lost on restart; there is no disk or external storage.
|
|
102
|
+
- **Not thread-safe** — use one tracker per thread or worker, or add external locking if you share a tracker across threads.
|
|
103
|
+
|
|
104
|
+
## How It Works
|
|
105
|
+
|
|
106
|
+
A rolling window is split into a fixed number of buckets. For a 5-minute window with 5-second buckets, that's 60 buckets.
|
|
107
|
+
|
|
108
|
+
- **Finding the bucket** — an event's timestamp maps to a bucket with one division: `slot = (timestamp // bucket_seconds) % num_buckets`.
|
|
109
|
+
- **Reusing buckets** — as time passes, expired buckets are wiped clean and reused, so the total number of buckets never changes.
|
|
110
|
+
- **Running totals** — two totals (count and amount) are kept up to date on every write, so reading the answer means just returning the totals — no scanning.
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
events ──► slot = (timestamp // 5) % 60
|
|
114
|
+
│
|
|
115
|
+
▼
|
|
116
|
+
┌───┬───┬───┬───┬───┬───┬───┐
|
|
117
|
+
│ 0 │ 1 │ 2 │...│ 57│ 58│ 59│ ◄── fixed ring of buckets, reused forever
|
|
118
|
+
└───┴───┴───┴───┴───┴───┴───┘
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Only counts and sums are stored — no raw transaction details.
|
|
122
|
+
|
|
123
|
+
## Benchmarks
|
|
124
|
+
|
|
125
|
+
### Latency
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
python3 tests/benchmark_latency.py
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Representative output (Apple M-series MacBook Air, Python 3.10). Absolute timings vary by machine; the `< 5.0 ms` SLA is enforced in CI on every push.
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
--- Running stream-velocity benchmark: 100,000 events ---
|
|
135
|
+
Total write time for 100,000 events: 61.88 ms
|
|
136
|
+
Average write latency per event: 0.62 µs
|
|
137
|
+
Read query latency: 0.0418 ms
|
|
138
|
+
Aggregation result: 100000 events, $1,000,000.00
|
|
139
|
+
SLA Target Passed: Query execution < 5.0 ms
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Memory
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
python3 tests/benchmark_memory.py
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The library creates its counters once and reuses them, so memory stays flat as events pile up. A typical 5-minute window uses only a few kilobytes — far below the 100 MB ceiling a lightweight fraud service can run within.
|
|
149
|
+
|
|
150
|
+
## Docker
|
|
151
|
+
|
|
152
|
+
A minimal Alpine image installs the library and runs the speed benchmark:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
docker build -t stream-velocity .
|
|
156
|
+
docker run --rm stream-velocity
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Development
|
|
160
|
+
|
|
161
|
+
Run the test suite and benchmarks:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
python3 -m unittest discover -s tests
|
|
165
|
+
python3 tests/benchmark_latency.py
|
|
166
|
+
python3 tests/benchmark_memory.py
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## License
|
|
170
|
+
|
|
171
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "stream-velocity"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A zero-dependency, bounded-memory Python library for sub-5ms sliding-window transaction velocity aggregations."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Woo Chan Lee" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["fraud-detection", "streaming", "velocity", "feature-store", "fintech"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules"
|
|
24
|
+
]
|
|
25
|
+
dependencies = []
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=7.0.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/lee14257/stream-velocity"
|
|
34
|
+
Repository = "https://github.com/lee14257/stream-velocity"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/stream_velocity"]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from typing import Dict, Any
|
|
2
|
+
|
|
3
|
+
class BucketedRingBuffer:
|
|
4
|
+
"""
|
|
5
|
+
Fixed-memory ring buffer using time-bucketed aggregation for constant-time O(1)
|
|
6
|
+
velocity calculations over a sliding time window.
|
|
7
|
+
|
|
8
|
+
Writes map timestamps to a slot index via integer division (O(1)). Reads return
|
|
9
|
+
pre-computed running totals after lazily expiring stale buckets, making both
|
|
10
|
+
operations constant-time regardless of the number of buckets in the window.
|
|
11
|
+
"""
|
|
12
|
+
__slots__ = (
|
|
13
|
+
'window_seconds', 'bucket_seconds', 'num_buckets',
|
|
14
|
+
'counts', 'sums', 'last_bucket_idx',
|
|
15
|
+
'running_count', 'running_sum', 'trailing_bucket',
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
def __init__(self, window_seconds: int = 300, bucket_seconds: int = 5):
|
|
19
|
+
if window_seconds % bucket_seconds != 0:
|
|
20
|
+
raise ValueError("window_seconds must be evenly divisible by bucket_seconds")
|
|
21
|
+
|
|
22
|
+
self.window_seconds = window_seconds
|
|
23
|
+
self.bucket_seconds = bucket_seconds
|
|
24
|
+
self.num_buckets = window_seconds // bucket_seconds
|
|
25
|
+
|
|
26
|
+
self.counts = [0] * self.num_buckets
|
|
27
|
+
self.sums = [0.0] * self.num_buckets
|
|
28
|
+
self.last_bucket_idx = [-1] * self.num_buckets
|
|
29
|
+
|
|
30
|
+
self.running_count = 0
|
|
31
|
+
self.running_sum = 0.0
|
|
32
|
+
self.trailing_bucket = -1
|
|
33
|
+
|
|
34
|
+
def _get_current_slot(self, timestamp: float) -> tuple[int, int]:
|
|
35
|
+
total_buckets = int(timestamp // self.bucket_seconds)
|
|
36
|
+
slot_idx = total_buckets % self.num_buckets
|
|
37
|
+
return slot_idx, total_buckets
|
|
38
|
+
|
|
39
|
+
def _evict_stale(self, current_total_buckets: int) -> None:
|
|
40
|
+
min_valid_bucket = current_total_buckets - self.num_buckets + 1
|
|
41
|
+
|
|
42
|
+
# First observation: nothing can be stale yet, so fast-forward the
|
|
43
|
+
# trailing edge instead of scanning an empty buffer bucket-by-bucket.
|
|
44
|
+
if self.trailing_bucket == -1:
|
|
45
|
+
self.trailing_bucket = min_valid_bucket - 1
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
# Out-of-order guard: never regress the trailing edge backwards.
|
|
49
|
+
if min_valid_bucket <= self.trailing_bucket:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
while self.trailing_bucket < min_valid_bucket:
|
|
53
|
+
self.trailing_bucket += 1
|
|
54
|
+
slot_idx = self.trailing_bucket % self.num_buckets
|
|
55
|
+
if self.last_bucket_idx[slot_idx] == self.trailing_bucket:
|
|
56
|
+
self.running_count -= self.counts[slot_idx]
|
|
57
|
+
self.running_sum -= self.sums[slot_idx]
|
|
58
|
+
self.counts[slot_idx] = 0
|
|
59
|
+
self.sums[slot_idx] = 0.0
|
|
60
|
+
self.last_bucket_idx[slot_idx] = -1
|
|
61
|
+
|
|
62
|
+
def record(self, timestamp: float, amount: float = 0.0) -> None:
|
|
63
|
+
slot_idx, total_buckets = self._get_current_slot(timestamp)
|
|
64
|
+
self._evict_stale(total_buckets)
|
|
65
|
+
|
|
66
|
+
# Evict stale data if slot belongs to a previous window iteration.
|
|
67
|
+
if self.last_bucket_idx[slot_idx] != total_buckets:
|
|
68
|
+
if self.last_bucket_idx[slot_idx] != -1:
|
|
69
|
+
self.running_count -= self.counts[slot_idx]
|
|
70
|
+
self.running_sum -= self.sums[slot_idx]
|
|
71
|
+
self.counts[slot_idx] = 0
|
|
72
|
+
self.sums[slot_idx] = 0.0
|
|
73
|
+
self.last_bucket_idx[slot_idx] = total_buckets
|
|
74
|
+
|
|
75
|
+
self.counts[slot_idx] += 1
|
|
76
|
+
self.sums[slot_idx] += amount
|
|
77
|
+
self.running_count += 1
|
|
78
|
+
self.running_sum += amount
|
|
79
|
+
|
|
80
|
+
def get_metrics(self, current_timestamp: float) -> Dict[str, Any]:
|
|
81
|
+
current_total_buckets = int(current_timestamp // self.bucket_seconds)
|
|
82
|
+
self._evict_stale(current_total_buckets)
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
"window_seconds": self.window_seconds,
|
|
86
|
+
"count": self.running_count,
|
|
87
|
+
"sum_amount": self.running_sum,
|
|
88
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
from .ring_buffer import BucketedRingBuffer
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class VelocityResult:
|
|
9
|
+
"""A snapshot of one entity's rolling-window velocity at a point in time."""
|
|
10
|
+
|
|
11
|
+
entity_id: str
|
|
12
|
+
window_seconds: int
|
|
13
|
+
count: int
|
|
14
|
+
sum_amount: float
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class VelocityTracker:
|
|
18
|
+
"""
|
|
19
|
+
In-memory manager that tracks rolling velocity across many entities.
|
|
20
|
+
|
|
21
|
+
Memory is reclaimed lazily: when `idle_timeout` is set, any entity that has
|
|
22
|
+
not recorded an event for `idle_timeout` seconds is dropped on the next
|
|
23
|
+
`cleanup()` call or the periodic automatic sweep. This keeps the registry
|
|
24
|
+
bounded instead of growing forever with distinct entities.
|
|
25
|
+
|
|
26
|
+
`idle_timeout` should be at least as large as `window_seconds`; by the time
|
|
27
|
+
an entity has been idle for a full window, its counters are already empty,
|
|
28
|
+
so dropping it loses no current-velocity information.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
window_seconds: int = 300,
|
|
34
|
+
bucket_seconds: int = 5,
|
|
35
|
+
idle_timeout: Optional[float] = None,
|
|
36
|
+
cleanup_interval: float = 60.0,
|
|
37
|
+
):
|
|
38
|
+
if idle_timeout is not None and idle_timeout <= 0:
|
|
39
|
+
raise ValueError("idle_timeout must be greater than 0")
|
|
40
|
+
|
|
41
|
+
self.window_seconds = window_seconds
|
|
42
|
+
self.bucket_seconds = bucket_seconds
|
|
43
|
+
self.idle_timeout = idle_timeout
|
|
44
|
+
self.cleanup_interval = cleanup_interval
|
|
45
|
+
|
|
46
|
+
self.entities: Dict[str, BucketedRingBuffer] = {}
|
|
47
|
+
self._last_seen: Dict[str, float] = {}
|
|
48
|
+
self._last_cleanup: float = 0.0
|
|
49
|
+
|
|
50
|
+
def __len__(self) -> int:
|
|
51
|
+
return len(self.entities)
|
|
52
|
+
|
|
53
|
+
def record_event(
|
|
54
|
+
self,
|
|
55
|
+
entity_id: str,
|
|
56
|
+
amount: float = 0.0,
|
|
57
|
+
timestamp: Optional[float] = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
if timestamp is None:
|
|
60
|
+
timestamp = time.time()
|
|
61
|
+
|
|
62
|
+
self._maybe_cleanup(timestamp)
|
|
63
|
+
|
|
64
|
+
ring = self.entities.get(entity_id)
|
|
65
|
+
if ring is None:
|
|
66
|
+
ring = BucketedRingBuffer(
|
|
67
|
+
window_seconds=self.window_seconds,
|
|
68
|
+
bucket_seconds=self.bucket_seconds,
|
|
69
|
+
)
|
|
70
|
+
self.entities[entity_id] = ring
|
|
71
|
+
|
|
72
|
+
ring.record(timestamp=timestamp, amount=amount)
|
|
73
|
+
self._last_seen[entity_id] = timestamp
|
|
74
|
+
|
|
75
|
+
def get_velocity(
|
|
76
|
+
self,
|
|
77
|
+
entity_id: str,
|
|
78
|
+
timestamp: Optional[float] = None,
|
|
79
|
+
) -> VelocityResult:
|
|
80
|
+
if timestamp is None:
|
|
81
|
+
timestamp = time.time()
|
|
82
|
+
|
|
83
|
+
self._maybe_cleanup(timestamp)
|
|
84
|
+
|
|
85
|
+
ring = self.entities.get(entity_id)
|
|
86
|
+
if ring is None:
|
|
87
|
+
return VelocityResult(
|
|
88
|
+
entity_id=entity_id,
|
|
89
|
+
window_seconds=self.window_seconds,
|
|
90
|
+
count=0,
|
|
91
|
+
sum_amount=0.0,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
metrics = ring.get_metrics(current_timestamp=timestamp)
|
|
95
|
+
return VelocityResult(
|
|
96
|
+
entity_id=entity_id,
|
|
97
|
+
window_seconds=metrics["window_seconds"],
|
|
98
|
+
count=metrics["count"],
|
|
99
|
+
sum_amount=metrics["sum_amount"],
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def cleanup(self, now: Optional[float] = None) -> None:
|
|
103
|
+
"""Drop entities that have been idle longer than `idle_timeout`."""
|
|
104
|
+
if self.idle_timeout is None:
|
|
105
|
+
return
|
|
106
|
+
if now is None:
|
|
107
|
+
now = time.time()
|
|
108
|
+
|
|
109
|
+
expired = [
|
|
110
|
+
entity_id
|
|
111
|
+
for entity_id, seen in self._last_seen.items()
|
|
112
|
+
if now - seen > self.idle_timeout
|
|
113
|
+
]
|
|
114
|
+
for entity_id in expired:
|
|
115
|
+
del self.entities[entity_id]
|
|
116
|
+
del self._last_seen[entity_id]
|
|
117
|
+
|
|
118
|
+
self._last_cleanup = now
|
|
119
|
+
|
|
120
|
+
def _maybe_cleanup(self, now: float) -> None:
|
|
121
|
+
if self.idle_timeout is None:
|
|
122
|
+
return
|
|
123
|
+
if now - self._last_cleanup < self.cleanup_interval:
|
|
124
|
+
return
|
|
125
|
+
self.cleanup(now)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from stream_velocity import VelocityTracker
|
|
3
|
+
|
|
4
|
+
def run_benchmark(event_count=100000):
|
|
5
|
+
tracker = VelocityTracker(window_seconds=300, bucket_seconds=5)
|
|
6
|
+
entity_id = "bench_user_1"
|
|
7
|
+
start_time = time.time()
|
|
8
|
+
|
|
9
|
+
print(f"--- Running stream-velocity benchmark: {event_count:,} events ---")
|
|
10
|
+
|
|
11
|
+
t0 = time.perf_counter()
|
|
12
|
+
for i in range(event_count):
|
|
13
|
+
tracker.record_event(entity_id=entity_id, amount=10.0, timestamp=start_time + (i * 0.001))
|
|
14
|
+
t1 = time.perf_counter()
|
|
15
|
+
|
|
16
|
+
write_total_ms = (t1 - t0) * 1000
|
|
17
|
+
avg_write_us = (write_total_ms / event_count) * 1000
|
|
18
|
+
|
|
19
|
+
t2 = time.perf_counter()
|
|
20
|
+
metrics = tracker.get_velocity(entity_id=entity_id, timestamp=start_time + 100)
|
|
21
|
+
t3 = time.perf_counter()
|
|
22
|
+
|
|
23
|
+
query_latency_ms = (t3 - t2) * 1000
|
|
24
|
+
|
|
25
|
+
print(f"Total write time for {event_count:,} events: {write_total_ms:.2f} ms")
|
|
26
|
+
print(f"Average write latency per event: {avg_write_us:.2f} µs")
|
|
27
|
+
print(f"Read query latency: {query_latency_ms:.4f} ms")
|
|
28
|
+
print(f"Aggregation result: {metrics.count} events, ${metrics.sum_amount:,.2f}")
|
|
29
|
+
|
|
30
|
+
assert query_latency_ms < 5.0, f"SLA Violation: Query took {query_latency_ms:.2f}ms (target <5ms)"
|
|
31
|
+
print("SLA Target Passed: Query execution < 5.0 ms")
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
run_benchmark()
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Prove that stream-velocity uses a fixed amount of memory, no matter how many
|
|
3
|
+
events it processes.
|
|
4
|
+
|
|
5
|
+
The idea is simple: the library sets up a fixed number of "buckets" when it
|
|
6
|
+
starts (one bucket for each slice of the time window). As time moves forward,
|
|
7
|
+
old buckets are wiped clean and reused, so nothing new is ever allocated.
|
|
8
|
+
|
|
9
|
+
This script records a small number of events, measures the memory in use, then
|
|
10
|
+
records ten times as many events and measures again. If the library is doing
|
|
11
|
+
its job, the two numbers are the same.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import tracemalloc
|
|
15
|
+
from stream_velocity import VelocityTracker
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def measure_used_bytes(event_count: int) -> int:
|
|
19
|
+
"""Record `event_count` events and return how many bytes the library holds."""
|
|
20
|
+
tracker = VelocityTracker(window_seconds=300, bucket_seconds=5)
|
|
21
|
+
start_time = 1_000_000.0
|
|
22
|
+
|
|
23
|
+
tracemalloc.start()
|
|
24
|
+
for i in range(event_count):
|
|
25
|
+
# Space events 1 ms apart so they flow through the time window.
|
|
26
|
+
tracker.record_event(
|
|
27
|
+
entity_id="account_1",
|
|
28
|
+
amount=10.0,
|
|
29
|
+
timestamp=start_time + (i * 0.001),
|
|
30
|
+
)
|
|
31
|
+
current_bytes, _ = tracemalloc.get_traced_memory()
|
|
32
|
+
tracemalloc.stop()
|
|
33
|
+
|
|
34
|
+
return current_bytes
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main() -> None:
|
|
38
|
+
small = measure_used_bytes(100_000)
|
|
39
|
+
large = measure_used_bytes(1_000_000)
|
|
40
|
+
|
|
41
|
+
print("--- stream-velocity memory benchmark ---")
|
|
42
|
+
print(f"Memory after 100,000 events: {small / 1024:>8,.1f} KB")
|
|
43
|
+
print(f"Memory after 1,000,000 events: {large / 1024:>8,.1f} KB")
|
|
44
|
+
|
|
45
|
+
# Ten times the events should not mean ten times the memory.
|
|
46
|
+
slack_bytes = 64 * 1024 # allow 64 KB of measurement noise
|
|
47
|
+
assert large <= small + slack_bytes, (
|
|
48
|
+
f"Memory grew with more events: {small / 1024:.1f} KB -> {large / 1024:.1f} KB"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# The whole library must stay far below the 100 MB ceiling.
|
|
52
|
+
assert large < 100 * 1024 * 1024, (
|
|
53
|
+
f"Memory exceeded 100 MB: {large / 1024 / 1024:.1f} MB"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Even a much larger window (1 hour, 1-second buckets = 3600 buckets)
|
|
57
|
+
# should still be tiny.
|
|
58
|
+
big_window = VelocityTracker(window_seconds=3600, bucket_seconds=1)
|
|
59
|
+
start_time = 1_000_000.0
|
|
60
|
+
tracemalloc.start()
|
|
61
|
+
for i in range(10_000):
|
|
62
|
+
big_window.record_event(
|
|
63
|
+
entity_id="account_1",
|
|
64
|
+
amount=10.0,
|
|
65
|
+
timestamp=start_time + (i * 0.001),
|
|
66
|
+
)
|
|
67
|
+
big_window_bytes, _ = tracemalloc.get_traced_memory()
|
|
68
|
+
tracemalloc.stop()
|
|
69
|
+
print(f"Memory for a 1-hour window (3600 buckets): {big_window_bytes / 1024:>8,.1f} KB")
|
|
70
|
+
|
|
71
|
+
print("PASS: memory stayed flat and is far below 100 MB")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
main()
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
import time
|
|
3
|
+
from stream_velocity import VelocityTracker
|
|
4
|
+
|
|
5
|
+
class TestVelocityTracker(unittest.TestCase):
|
|
6
|
+
|
|
7
|
+
def test_basic_aggregation(self):
|
|
8
|
+
tracker = VelocityTracker(window_seconds=60, bucket_seconds=1)
|
|
9
|
+
now = 1000.0
|
|
10
|
+
|
|
11
|
+
tracker.record_event(entity_id="acc_1", amount=50.0, timestamp=now)
|
|
12
|
+
tracker.record_event(entity_id="acc_1", amount=25.0, timestamp=now + 5)
|
|
13
|
+
|
|
14
|
+
metrics = tracker.get_velocity(entity_id="acc_1", timestamp=now + 10)
|
|
15
|
+
|
|
16
|
+
self.assertEqual(metrics.count, 2)
|
|
17
|
+
self.assertEqual(metrics.sum_amount, 75.0)
|
|
18
|
+
|
|
19
|
+
def test_window_expiration(self):
|
|
20
|
+
tracker = VelocityTracker(window_seconds=10, bucket_seconds=1)
|
|
21
|
+
now = 1000.0
|
|
22
|
+
|
|
23
|
+
tracker.record_event(entity_id="acc_2", amount=100.0, timestamp=now)
|
|
24
|
+
|
|
25
|
+
# Check inside window
|
|
26
|
+
metrics_before = tracker.get_velocity(entity_id="acc_2", timestamp=now + 5)
|
|
27
|
+
self.assertEqual(metrics_before.count, 1)
|
|
28
|
+
|
|
29
|
+
# Check outside window (15 seconds later)
|
|
30
|
+
metrics_after = tracker.get_velocity(entity_id="acc_2", timestamp=now + 15)
|
|
31
|
+
self.assertEqual(metrics_after.count, 0)
|
|
32
|
+
|
|
33
|
+
def test_out_of_order_events(self):
|
|
34
|
+
tracker = VelocityTracker(window_seconds=60, bucket_seconds=1)
|
|
35
|
+
now = 1000.0
|
|
36
|
+
|
|
37
|
+
tracker.record_event(entity_id="acc_3", amount=10.0, timestamp=now)
|
|
38
|
+
tracker.record_event(entity_id="acc_3", amount=10.0, timestamp=now + 10)
|
|
39
|
+
|
|
40
|
+
# Event arriving late with an older timestamp must not regress the window
|
|
41
|
+
tracker.record_event(entity_id="acc_3", amount=20.0, timestamp=now - 5)
|
|
42
|
+
|
|
43
|
+
metrics = tracker.get_velocity(entity_id="acc_3", timestamp=now + 20)
|
|
44
|
+
self.assertEqual(metrics.count, 3)
|
|
45
|
+
self.assertEqual(metrics.sum_amount, 40.0)
|
|
46
|
+
|
|
47
|
+
def test_many_bucket_window(self):
|
|
48
|
+
tracker = VelocityTracker(window_seconds=100000, bucket_seconds=1)
|
|
49
|
+
now = 1_000_000.0
|
|
50
|
+
|
|
51
|
+
tracker.record_event(entity_id="acc_4", amount=1.0, timestamp=now)
|
|
52
|
+
tracker.record_event(entity_id="acc_4", amount=1.0, timestamp=now + 50_000)
|
|
53
|
+
|
|
54
|
+
metrics = tracker.get_velocity(entity_id="acc_4", timestamp=now + 60_000)
|
|
55
|
+
self.assertEqual(metrics.count, 2)
|
|
56
|
+
self.assertEqual(metrics.sum_amount, 2.0)
|
|
57
|
+
|
|
58
|
+
# Event far outside the 100000s window must be expired
|
|
59
|
+
expired = tracker.get_velocity(entity_id="acc_4", timestamp=now + 200_000)
|
|
60
|
+
self.assertEqual(expired.count, 0)
|
|
61
|
+
|
|
62
|
+
def test_idle_entity_eviction(self):
|
|
63
|
+
tracker = VelocityTracker(
|
|
64
|
+
window_seconds=60,
|
|
65
|
+
bucket_seconds=1,
|
|
66
|
+
idle_timeout=10.0,
|
|
67
|
+
cleanup_interval=0.0,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
tracker.record_event(entity_id="acc_a", amount=10.0, timestamp=1000.0)
|
|
71
|
+
self.assertIn("acc_a", tracker.entities)
|
|
72
|
+
|
|
73
|
+
# Advance time well beyond the idle timeout by recording another entity.
|
|
74
|
+
tracker.record_event(entity_id="acc_b", amount=5.0, timestamp=2000.0)
|
|
75
|
+
|
|
76
|
+
# acc_a has been idle for 1000s > idle_timeout, so it is evicted.
|
|
77
|
+
self.assertNotIn("acc_a", tracker.entities)
|
|
78
|
+
self.assertIn("acc_b", tracker.entities)
|
|
79
|
+
|
|
80
|
+
# Querying an evicted entity reports an empty window, not an error.
|
|
81
|
+
result = tracker.get_velocity(entity_id="acc_a", timestamp=2000.0)
|
|
82
|
+
self.assertEqual(result.count, 0)
|
|
83
|
+
self.assertEqual(result.sum_amount, 0.0)
|
|
84
|
+
|
|
85
|
+
def test_idle_timeout_disabled_by_default(self):
|
|
86
|
+
tracker = VelocityTracker(window_seconds=60, bucket_seconds=1)
|
|
87
|
+
|
|
88
|
+
tracker.record_event(entity_id="acc_c", amount=10.0, timestamp=1000.0)
|
|
89
|
+
tracker.record_event(entity_id="acc_d", amount=5.0, timestamp=1_000_000.0)
|
|
90
|
+
|
|
91
|
+
# With no idle_timeout, entities are never evicted.
|
|
92
|
+
self.assertIn("acc_c", tracker.entities)
|
|
93
|
+
self.assertIn("acc_d", tracker.entities)
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
unittest.main()
|