mnm-sdk 0.1.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.
- mnm_sdk/__init__.py +6 -0
- mnm_sdk/monitor.py +212 -0
- mnm_sdk/transport.py +76 -0
- mnm_sdk-0.1.1.dist-info/METADATA +73 -0
- mnm_sdk-0.1.1.dist-info/RECORD +8 -0
- mnm_sdk-0.1.1.dist-info/WHEEL +5 -0
- mnm_sdk-0.1.1.dist-info/licenses/LICENSE +21 -0
- mnm_sdk-0.1.1.dist-info/top_level.txt +1 -0
mnm_sdk/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""MNM SDK: log model predictions to MNM without slowing down inference."""
|
|
2
|
+
|
|
3
|
+
from mnm_sdk.monitor import Monitor, Stats
|
|
4
|
+
from mnm_sdk.transport import HttpTransport, Transport, TransportError
|
|
5
|
+
|
|
6
|
+
__all__ = ["HttpTransport", "Monitor", "Stats", "Transport", "TransportError"]
|
mnm_sdk/monitor.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Monitor: the object a model service uses to log predictions.
|
|
2
|
+
|
|
3
|
+
`log()` only appends to a bounded in-memory queue. A background thread sends
|
|
4
|
+
the queue to the MNM ingestion API in batches. Nothing in here may slow down
|
|
5
|
+
or break the caller's prediction path: `log()` never blocks on the network
|
|
6
|
+
and never raises.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import atexit
|
|
12
|
+
import logging
|
|
13
|
+
import threading
|
|
14
|
+
import uuid
|
|
15
|
+
from collections import deque
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from mnm_sdk.transport import HttpTransport, Transport, TransportError
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("mnm")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Stats:
|
|
28
|
+
"""Counters since the Monitor was created."""
|
|
29
|
+
|
|
30
|
+
logged: int # events accepted by log()
|
|
31
|
+
sent: int # events delivered to the ingestion API
|
|
32
|
+
dropped: int # events discarded before sending (queue full, invalid, closed)
|
|
33
|
+
failed: int # events given up on after the retries ran out
|
|
34
|
+
queued: int # events waiting in the queue right now
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Monitor:
|
|
38
|
+
"""Logs predictions of one model version to MNM in the background.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
api_url: Base URL of the MNM backend, e.g. ``http://backend:8000``.
|
|
42
|
+
model: Model name.
|
|
43
|
+
version: Model version.
|
|
44
|
+
batch_size: Send as soon as this many events are queued.
|
|
45
|
+
flush_interval: Send whatever is queued at least this often (seconds).
|
|
46
|
+
max_queue: Queue capacity. When full, the oldest event is dropped.
|
|
47
|
+
max_retries: Extra attempts for a batch that failed to send.
|
|
48
|
+
retry_backoff: Wait before the first retry (seconds); doubles each time.
|
|
49
|
+
timeout: HTTP timeout per request (seconds).
|
|
50
|
+
transport: Replace the HTTP transport (used by tests).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
api_url: str,
|
|
56
|
+
model: str,
|
|
57
|
+
version: str | int,
|
|
58
|
+
*,
|
|
59
|
+
batch_size: int = 100,
|
|
60
|
+
flush_interval: float = 1.0,
|
|
61
|
+
max_queue: int = 10_000,
|
|
62
|
+
max_retries: int = 3,
|
|
63
|
+
retry_backoff: float = 0.5,
|
|
64
|
+
timeout: float = 5.0,
|
|
65
|
+
transport: Transport | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
self._model = model
|
|
68
|
+
self._version = str(version)
|
|
69
|
+
self._batch_size = max(1, batch_size)
|
|
70
|
+
self._flush_interval = flush_interval
|
|
71
|
+
self._max_queue = max(1, max_queue)
|
|
72
|
+
self._max_retries = max(0, max_retries)
|
|
73
|
+
self._retry_backoff = retry_backoff
|
|
74
|
+
self._transport = transport or HttpTransport(api_url, timeout=timeout)
|
|
75
|
+
|
|
76
|
+
self._queue: deque[dict[str, Any]] = deque()
|
|
77
|
+
self._lock = threading.Lock()
|
|
78
|
+
self._drained = threading.Condition(self._lock)
|
|
79
|
+
self._wake = threading.Event()
|
|
80
|
+
self._closing = threading.Event()
|
|
81
|
+
self._in_flight = 0
|
|
82
|
+
self._logged = self._sent = self._dropped = self._failed = 0
|
|
83
|
+
|
|
84
|
+
self._thread = threading.Thread(
|
|
85
|
+
target=self._run, name="mnm-monitor", daemon=True
|
|
86
|
+
)
|
|
87
|
+
self._thread.start()
|
|
88
|
+
atexit.register(self.close)
|
|
89
|
+
|
|
90
|
+
# -- called on the prediction path -------------------------------------
|
|
91
|
+
|
|
92
|
+
def log(
|
|
93
|
+
self,
|
|
94
|
+
features: Mapping[str, Any],
|
|
95
|
+
output: Any,
|
|
96
|
+
latency_ms: float,
|
|
97
|
+
*,
|
|
98
|
+
request_id: str | None = None,
|
|
99
|
+
label: Any = None,
|
|
100
|
+
ts: datetime | None = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Queue one prediction. Returns immediately and never raises."""
|
|
103
|
+
try:
|
|
104
|
+
event: dict[str, Any] = {
|
|
105
|
+
"model": self._model,
|
|
106
|
+
"version": self._version,
|
|
107
|
+
"request_id": request_id or uuid.uuid4().hex,
|
|
108
|
+
"ts": (ts or datetime.now(timezone.utc)).isoformat(),
|
|
109
|
+
"features": dict(features),
|
|
110
|
+
"output": output,
|
|
111
|
+
"latency_ms": float(latency_ms),
|
|
112
|
+
}
|
|
113
|
+
if label is not None:
|
|
114
|
+
event["label"] = label
|
|
115
|
+
except Exception:
|
|
116
|
+
with self._lock:
|
|
117
|
+
self._dropped += 1
|
|
118
|
+
logger.debug("mnm: dropped an event that could not be built", exc_info=True)
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
with self._lock:
|
|
122
|
+
if self._closing.is_set():
|
|
123
|
+
self._dropped += 1
|
|
124
|
+
return
|
|
125
|
+
if len(self._queue) >= self._max_queue:
|
|
126
|
+
self._queue.popleft()
|
|
127
|
+
self._dropped += 1
|
|
128
|
+
self._queue.append(event)
|
|
129
|
+
self._logged += 1
|
|
130
|
+
batch_ready = len(self._queue) >= self._batch_size
|
|
131
|
+
if batch_ready:
|
|
132
|
+
self._wake.set()
|
|
133
|
+
|
|
134
|
+
# -- control -------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def stats(self) -> Stats:
|
|
138
|
+
with self._lock:
|
|
139
|
+
return Stats(
|
|
140
|
+
logged=self._logged,
|
|
141
|
+
sent=self._sent,
|
|
142
|
+
dropped=self._dropped,
|
|
143
|
+
failed=self._failed,
|
|
144
|
+
queued=len(self._queue),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def flush(self, timeout: float | None = None) -> bool:
|
|
148
|
+
"""Block until everything logged so far was sent or given up on.
|
|
149
|
+
|
|
150
|
+
Returns False if that did not happen within `timeout` seconds.
|
|
151
|
+
"""
|
|
152
|
+
self._wake.set()
|
|
153
|
+
with self._drained:
|
|
154
|
+
return self._drained.wait_for(
|
|
155
|
+
lambda: not self._queue and not self._in_flight, timeout
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def close(self, timeout: float = 5.0) -> None:
|
|
159
|
+
"""Send what is queued, then stop the background thread. Idempotent."""
|
|
160
|
+
self._closing.set()
|
|
161
|
+
self._wake.set()
|
|
162
|
+
self._thread.join(timeout)
|
|
163
|
+
|
|
164
|
+
# -- background thread ---------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def _run(self) -> None:
|
|
167
|
+
while True:
|
|
168
|
+
self._wake.wait(self._flush_interval)
|
|
169
|
+
self._wake.clear()
|
|
170
|
+
self._drain()
|
|
171
|
+
if self._closing.is_set():
|
|
172
|
+
self._drain() # anything logged while the last batch was sending
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
def _drain(self) -> None:
|
|
176
|
+
while True:
|
|
177
|
+
with self._lock:
|
|
178
|
+
if not self._queue:
|
|
179
|
+
self._drained.notify_all()
|
|
180
|
+
return
|
|
181
|
+
size = min(self._batch_size, len(self._queue))
|
|
182
|
+
batch = [self._queue.popleft() for _ in range(size)]
|
|
183
|
+
self._in_flight = size
|
|
184
|
+
delivered = self._send(batch)
|
|
185
|
+
with self._lock:
|
|
186
|
+
self._in_flight = 0
|
|
187
|
+
if delivered:
|
|
188
|
+
self._sent += len(batch)
|
|
189
|
+
else:
|
|
190
|
+
self._failed += len(batch)
|
|
191
|
+
self._drained.notify_all()
|
|
192
|
+
|
|
193
|
+
def _send(self, batch: list[dict[str, Any]]) -> bool:
|
|
194
|
+
"""Try to deliver one batch, retrying with backoff. Never raises."""
|
|
195
|
+
delay = self._retry_backoff
|
|
196
|
+
for attempt in range(self._max_retries + 1):
|
|
197
|
+
try:
|
|
198
|
+
self._transport.send(batch)
|
|
199
|
+
return True
|
|
200
|
+
except TransportError as error:
|
|
201
|
+
reason: Exception = error
|
|
202
|
+
if not error.retryable:
|
|
203
|
+
break
|
|
204
|
+
# A bug in a transport must not kill the logging thread.
|
|
205
|
+
except Exception as error: # noqa: BLE001
|
|
206
|
+
reason = error
|
|
207
|
+
if attempt < self._max_retries:
|
|
208
|
+
# Returns early when close() is called: do not delay shutdown.
|
|
209
|
+
self._closing.wait(delay)
|
|
210
|
+
delay *= 2
|
|
211
|
+
logger.warning("mnm: gave up on %d events: %s", len(batch), reason)
|
|
212
|
+
return False
|
mnm_sdk/transport.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""How batches leave the process: HTTP POST to the MNM ingestion API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Any, Protocol
|
|
9
|
+
|
|
10
|
+
INGEST_PATH = "/v1/ingest"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TransportError(Exception):
|
|
14
|
+
"""A batch could not be delivered.
|
|
15
|
+
|
|
16
|
+
`retryable` is False when sending the same batch again cannot succeed
|
|
17
|
+
(the server rejected it as invalid).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, message: str, *, retryable: bool = True) -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.retryable = retryable
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Transport(Protocol):
|
|
26
|
+
def send(self, events: list[dict[str, Any]]) -> None:
|
|
27
|
+
"""Deliver one batch or raise TransportError."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _json_default(value: Any) -> Any:
|
|
31
|
+
"""Encode values `json` does not know, e.g. numpy scalars and arrays."""
|
|
32
|
+
for method in ("item", "tolist"):
|
|
33
|
+
convert = getattr(value, method, None)
|
|
34
|
+
if callable(convert):
|
|
35
|
+
return convert()
|
|
36
|
+
return str(value)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def encode(events: list[dict[str, Any]]) -> bytes:
|
|
40
|
+
"""Serialise a batch as strict JSON.
|
|
41
|
+
|
|
42
|
+
NaN and +/-Infinity are not JSON. Python would write them as bare tokens,
|
|
43
|
+
which the ingestion database rejects for the whole batch, so they are sent
|
|
44
|
+
as null (a missing value). The round trip also catches the ones that only
|
|
45
|
+
appear after numpy values have been converted.
|
|
46
|
+
"""
|
|
47
|
+
loose = json.dumps(events, default=_json_default)
|
|
48
|
+
if "NaN" not in loose and "Infinity" not in loose:
|
|
49
|
+
return loose.encode("utf-8")
|
|
50
|
+
strict = json.loads(loose, parse_constant=lambda _token: None)
|
|
51
|
+
return json.dumps(strict, allow_nan=False).encode("utf-8")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class HttpTransport:
|
|
55
|
+
def __init__(self, api_url: str, *, timeout: float = 5.0) -> None:
|
|
56
|
+
self._url = api_url.rstrip("/") + INGEST_PATH
|
|
57
|
+
self._timeout = timeout
|
|
58
|
+
|
|
59
|
+
def send(self, events: list[dict[str, Any]]) -> None:
|
|
60
|
+
request = urllib.request.Request(
|
|
61
|
+
self._url,
|
|
62
|
+
data=encode(events),
|
|
63
|
+
headers={"Content-Type": "application/json"},
|
|
64
|
+
method="POST",
|
|
65
|
+
)
|
|
66
|
+
try:
|
|
67
|
+
with urllib.request.urlopen(request, timeout=self._timeout) as response:
|
|
68
|
+
response.read()
|
|
69
|
+
except urllib.error.HTTPError as error:
|
|
70
|
+
# 4xx means the batch itself is wrong, except "slow down" answers.
|
|
71
|
+
retryable = error.code >= 500 or error.code in (408, 429)
|
|
72
|
+
raise TransportError(
|
|
73
|
+
f"MNM ingest answered HTTP {error.code}", retryable=retryable
|
|
74
|
+
) from error
|
|
75
|
+
except OSError as error: # URLError, timeouts, refused connections
|
|
76
|
+
raise TransportError(f"MNM ingest unreachable: {error}") from error
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mnm-sdk
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: MNM client: log model predictions asynchronously, without slowing inference
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/Sohan-Narewadi/MNM
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
12
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
13
|
+
Requires-Dist: black>=24; extra == "dev"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# sdk/ — MNM Python client
|
|
17
|
+
|
|
18
|
+
A tiny library a model service imports to log predictions to MNM **without slowing down inference**.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
```bash
|
|
22
|
+
pip install mnm-sdk
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
```python
|
|
27
|
+
from mnm_sdk import Monitor
|
|
28
|
+
|
|
29
|
+
monitor = Monitor(api_url="http://backend:8000", model="churn", version="3")
|
|
30
|
+
|
|
31
|
+
@app.post("/predict")
|
|
32
|
+
def predict(req: Request):
|
|
33
|
+
t0 = time.perf_counter()
|
|
34
|
+
y = model.predict(req.features)
|
|
35
|
+
monitor.log(features=req.features, output=y, latency_ms=(time.perf_counter() - t0) * 1000)
|
|
36
|
+
return y
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## How it behaves
|
|
40
|
+
- `log()` only appends to an in-memory bounded queue and returns: it never waits on the network and never raises, whatever is passed in.
|
|
41
|
+
- A background daemon thread sends batches to `POST /v1/ingest` when `batch_size` events are queued or every `flush_interval` seconds, whichever comes first.
|
|
42
|
+
- A failed batch is retried with exponential backoff (`max_retries`, `retry_backoff`). Each event carries its own `request_id` and `ts`, and ingestion is idempotent on those, so a retry never stores an event twice. A batch the server rejects as invalid (4xx) is not retried.
|
|
43
|
+
- `NaN` and `Infinity` (for example a missing feature from pandas or numpy) are sent as `null`, because JSON has no such numbers.
|
|
44
|
+
- If the queue is full, the **oldest** event is dropped and counted. Nothing blocks.
|
|
45
|
+
- `close()` runs at interpreter exit (`atexit`) and sends what is still queued.
|
|
46
|
+
- `monitor.stats` reports `logged`, `sent`, `dropped`, `failed` and `queued`.
|
|
47
|
+
- No dependencies outside the standard library. Python 3.9+.
|
|
48
|
+
|
|
49
|
+
| Option | Default | Meaning |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `batch_size` | 100 | Send as soon as this many events are queued |
|
|
52
|
+
| `flush_interval` | 1.0 s | Send whatever is queued at least this often |
|
|
53
|
+
| `max_queue` | 10 000 | Queue capacity; beyond it the oldest event is dropped |
|
|
54
|
+
| `max_retries` | 3 | Extra attempts for a batch that failed |
|
|
55
|
+
| `retry_backoff` | 0.5 s | Wait before the first retry; doubles each time |
|
|
56
|
+
| `timeout` | 5.0 s | HTTP timeout per request |
|
|
57
|
+
|
|
58
|
+
`log()` also accepts `request_id=`, `label=` and `ts=`. The event format is the ingest contract in [`docs/api-spec.md`](../docs/api-spec.md).
|
|
59
|
+
|
|
60
|
+
## Development
|
|
61
|
+
```bash
|
|
62
|
+
cd sdk
|
|
63
|
+
pip install -e ".[dev]"
|
|
64
|
+
pytest # no network and no backend needed
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Layout
|
|
68
|
+
```
|
|
69
|
+
sdk/
|
|
70
|
+
├── mnm_sdk/__init__.py, monitor.py, transport.py
|
|
71
|
+
├── tests/
|
|
72
|
+
└── pyproject.toml
|
|
73
|
+
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
mnm_sdk/__init__.py,sha256=M0TvE7VTyrhpcXwmx-xc6WslKOcLP_mUg4DsZ-IO5bk,271
|
|
2
|
+
mnm_sdk/monitor.py,sha256=FMjJtqws9mOwwhAMrSCKuiYTRf3HdByF7DuwUGxaFkc,7489
|
|
3
|
+
mnm_sdk/transport.py,sha256=yNusnEleSlyxFA_ucQjhOWSAFbBbhe_kK8BOwOhgGF4,2710
|
|
4
|
+
mnm_sdk-0.1.1.dist-info/licenses/LICENSE,sha256=2CUzD54rDtWKLl7gKJeCFSHsU7oTH8jV1CIoE1W18QQ,1088
|
|
5
|
+
mnm_sdk-0.1.1.dist-info/METADATA,sha256=WTm9uYRKlIWQIZxAZC97yTp59PElGuYnYu1kUYgpORY,2849
|
|
6
|
+
mnm_sdk-0.1.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
7
|
+
mnm_sdk-0.1.1.dist-info/top_level.txt,sha256=SkAyUB74_REBoZcpRbaZuNR3oZFyAmPPb2xcHgcopeM,8
|
|
8
|
+
mnm_sdk-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sohan Narewadi and Radhesh Nair
|
|
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 @@
|
|
|
1
|
+
mnm_sdk
|