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.
@@ -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,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .DS_Store
7
+ .venv/
8
+ venv/
9
+ .opencode/
10
+ .cursor/
@@ -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
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
26
+ [![Python Version](https://img.shields.io/badge/python-3.9%2B-blue.svg)](https://www.python.org/downloads/)
27
+ [![CI](https://img.shields.io/github/actions/workflow/status/lee14257/stream-velocity/test.yml?branch=main)](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
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
+ [![Python Version](https://img.shields.io/badge/python-3.9%2B-blue.svg)](https://www.python.org/downloads/)
5
+ [![CI](https://img.shields.io/github/actions/workflow/status/lee14257/stream-velocity/test.yml?branch=main)](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,5 @@
1
+ from .tracker import VelocityTracker, VelocityResult
2
+ from .ring_buffer import BucketedRingBuffer
3
+
4
+ __version__ = "0.1.0"
5
+ __all__ = ["VelocityTracker", "VelocityResult", "BucketedRingBuffer"]
@@ -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()