haystack-ml-stack 0.3.4__tar.gz → 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {haystack_ml_stack-0.3.4 → haystack_ml_stack-0.4.1}/PKG-INFO +3 -1
  2. {haystack_ml_stack-0.3.4 → haystack_ml_stack-0.4.1}/pyproject.toml +3 -2
  3. {haystack_ml_stack-0.3.4 → haystack_ml_stack-0.4.1}/src/haystack_ml_stack/__init__.py +3 -3
  4. haystack_ml_stack-0.4.1/src/haystack_ml_stack/_kafka.py +88 -0
  5. haystack_ml_stack-0.4.1/src/haystack_ml_stack/_version.py +1 -0
  6. {haystack_ml_stack-0.3.4 → haystack_ml_stack-0.4.1}/src/haystack_ml_stack/app.py +51 -18
  7. {haystack_ml_stack-0.3.4 → haystack_ml_stack-0.4.1}/src/haystack_ml_stack/settings.py +8 -2
  8. {haystack_ml_stack-0.3.4 → haystack_ml_stack-0.4.1}/src/haystack_ml_stack.egg-info/PKG-INFO +3 -1
  9. {haystack_ml_stack-0.3.4 → haystack_ml_stack-0.4.1}/src/haystack_ml_stack.egg-info/SOURCES.txt +2 -0
  10. {haystack_ml_stack-0.3.4 → haystack_ml_stack-0.4.1}/src/haystack_ml_stack.egg-info/requires.txt +2 -0
  11. {haystack_ml_stack-0.3.4 → haystack_ml_stack-0.4.1}/README.md +0 -0
  12. {haystack_ml_stack-0.3.4 → haystack_ml_stack-0.4.1}/setup.cfg +0 -0
  13. {haystack_ml_stack-0.3.4 → haystack_ml_stack-0.4.1}/src/haystack_ml_stack/_serializers.py +0 -0
  14. {haystack_ml_stack-0.3.4 → haystack_ml_stack-0.4.1}/src/haystack_ml_stack/cache.py +0 -0
  15. {haystack_ml_stack-0.3.4 → haystack_ml_stack-0.4.1}/src/haystack_ml_stack/dynamo.py +0 -0
  16. {haystack_ml_stack-0.3.4 → haystack_ml_stack-0.4.1}/src/haystack_ml_stack/exceptions.py +0 -0
  17. {haystack_ml_stack-0.3.4 → haystack_ml_stack-0.4.1}/src/haystack_ml_stack/generated/__init__.py +0 -0
  18. {haystack_ml_stack-0.3.4 → haystack_ml_stack-0.4.1}/src/haystack_ml_stack/generated/v1/__init__.py +0 -0
  19. {haystack_ml_stack-0.3.4 → haystack_ml_stack-0.4.1}/src/haystack_ml_stack/generated/v1/features_pb2.py +0 -0
  20. {haystack_ml_stack-0.3.4 → haystack_ml_stack-0.4.1}/src/haystack_ml_stack/generated/v1/features_pb2.pyi +0 -0
  21. {haystack_ml_stack-0.3.4 → haystack_ml_stack-0.4.1}/src/haystack_ml_stack/model_store.py +0 -0
  22. {haystack_ml_stack-0.3.4 → haystack_ml_stack-0.4.1}/src/haystack_ml_stack/utils.py +0 -0
  23. {haystack_ml_stack-0.3.4 → haystack_ml_stack-0.4.1}/src/haystack_ml_stack.egg-info/dependency_links.txt +0 -0
  24. {haystack_ml_stack-0.3.4 → haystack_ml_stack-0.4.1}/src/haystack_ml_stack.egg-info/top_level.txt +0 -0
  25. {haystack_ml_stack-0.3.4 → haystack_ml_stack-0.4.1}/tests/test_serializers.py +0 -0
  26. {haystack_ml_stack-0.3.4 → haystack_ml_stack-0.4.1}/tests/test_utils.py +0 -0
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: haystack-ml-stack
3
- Version: 0.3.4
3
+ Version: 0.4.1
4
4
  Summary: Functions related to Haystack ML
5
5
  Author-email: Oscar Vega <oscar@haystack.tv>
6
6
  License: MIT
7
7
  Requires-Python: >=3.11
8
8
  Description-Content-Type: text/markdown
9
9
  Requires-Dist: protobuf==6.33.2
10
+ Requires-Dist: orjson==3.11.7
10
11
  Provides-Extra: server
11
12
  Requires-Dist: pydantic==2.5.0; extra == "server"
12
13
  Requires-Dist: cachetools==5.5.2; extra == "server"
@@ -15,6 +16,7 @@ Requires-Dist: aioboto3==12.0.0; extra == "server"
15
16
  Requires-Dist: fastapi==0.104.1; extra == "server"
16
17
  Requires-Dist: pydantic-settings==2.2; extra == "server"
17
18
  Requires-Dist: newrelic==11.1.0; extra == "server"
19
+ Requires-Dist: confluent-kafka==2.13.0; extra == "server"
18
20
 
19
21
  # Haystack ML Stack
20
22
 
@@ -5,13 +5,13 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "haystack-ml-stack"
8
- version = "0.3.4"
8
+ version = "0.4.1"
9
9
  description = "Functions related to Haystack ML"
10
10
  readme = "README.md"
11
11
  authors = [{ name = "Oscar Vega", email = "oscar@haystack.tv" }]
12
12
  requires-python = ">=3.11"
13
13
  dependencies = [
14
- "protobuf==6.33.2",
14
+ "protobuf==6.33.2", "orjson==3.11.7"
15
15
  ]
16
16
  license = { text = "MIT" }
17
17
 
@@ -24,4 +24,5 @@ server = [
24
24
  "fastapi==0.104.1",
25
25
  "pydantic-settings==2.2",
26
26
  "newrelic==11.1.0",
27
+ "confluent-kafka==2.13.0"
27
28
  ]
@@ -4,11 +4,11 @@ try:
4
4
  from .app import create_app
5
5
 
6
6
  __all__ = ["create_app"]
7
- except ImportError:
7
+ except ImportError as e:
8
8
  pass
9
9
 
10
+
10
11
  from ._serializers import SerializerRegistry, FeatureRegistryId
12
+ from ._version import __version__
11
13
 
12
14
  __all__ = [*__all__, "SerializerRegistry", "FeatureRegistryId"]
13
-
14
- __version__ = "0.3.4"
@@ -0,0 +1,88 @@
1
+ from confluent_kafka.aio import AIOProducer
2
+ import orjson
3
+ from google.protobuf.message import Message
4
+ import base64
5
+ import os
6
+ import logging
7
+ from .settings import Settings
8
+ from ._version import __version__
9
+ import hashlib
10
+
11
+ logger = logging.getLogger(__name__)
12
+ SECURITY_PROTOCOL = "SASL_SSL"
13
+ SASL_MECHANISM = "SCRAM-SHA-512"
14
+
15
+
16
+ async def send_to_kafka(
17
+ producer: AIOProducer,
18
+ topic: str,
19
+ user: dict,
20
+ streams: list[dict],
21
+ playlist: dict,
22
+ state: dict,
23
+ model_output: dict,
24
+ monitoring_meta: dict,
25
+ ) -> None:
26
+ if topic is None or producer is None:
27
+ return
28
+ message = {
29
+ "userid": user.get("userid"),
30
+ "client_os": playlist.get("clientOs"),
31
+ "model_input": {"user": user, "streams": streams, "playlist": playlist},
32
+ "model_output": model_output,
33
+ "model_name": state["model_name"].replace(".pkl", "")
34
+ if state["model_name"]
35
+ else None,
36
+ "model_type": "streams",
37
+ "meta": {
38
+ "monitoring": monitoring_meta,
39
+ "haystack_ml_stack_version": __version__,
40
+ "playlist_category": playlist.get("category"),
41
+ "user_features": state.get("user_features", []),
42
+ "stream_features": state.get("stream_features", []),
43
+ },
44
+ }
45
+ delivery_future = await producer.produce(
46
+ topic, orjson.dumps(message, default=default_serialization)
47
+ )
48
+ await delivery_future
49
+ return
50
+
51
+
52
+ def default_serialization(obj):
53
+ if isinstance(obj, Message):
54
+ return {
55
+ "version": obj.version,
56
+ "proto": base64.b64encode(obj.SerializeToString()).decode("ascii"),
57
+ }
58
+ raise orjson.JSONEncodeError("Unknown data type to serialize!")
59
+
60
+
61
+ def initialize_kafka_producer(app_config: Settings) -> AIOProducer:
62
+ secret_keys = orjson.loads(os.getenv("SECRET_KEYS") or "{}")
63
+ if not secret_keys:
64
+ raise ValueError("No Kafka credentials found.")
65
+ with open("/tmp/ca.pem", "w") as f:
66
+ f.write(base64.b64decode(secret_keys["KAFKA_BROKER_CA_CERTIFICATE"]).decode())
67
+ kafka_config = {
68
+ "bootstrap.servers": app_config.kafka_bootstrap_servers,
69
+ "security.protocol": SECURITY_PROTOCOL,
70
+ "sasl.username": secret_keys["KAFKA_WRITER_USER"],
71
+ "sasl.password": secret_keys["KAFKA_WRITER_PASSWORD"],
72
+ "sasl.mechanism": SASL_MECHANISM,
73
+ "ssl.ca.location": "/tmp/ca.pem",
74
+ "compression.type": "lz4",
75
+ }
76
+ logger.info(
77
+ "Initializing kafka producer pushing to topic %s", app_config.kafka_topic
78
+ )
79
+ producer = AIOProducer(kafka_config)
80
+ logger.info("Producer initialized!")
81
+ return producer
82
+
83
+
84
+ def should_log_user(userid: str, kafka_fraction: float) -> bool:
85
+ if not userid:
86
+ return False
87
+ hash_value = int(hashlib.sha256(userid.encode()).hexdigest(), 16) / (2**256)
88
+ return hash_value < kafka_fraction
@@ -0,0 +1 @@
1
+ __version__ = "0.4.1"
@@ -8,20 +8,21 @@ import time
8
8
  from contextlib import asynccontextmanager, AsyncExitStack
9
9
  import traceback
10
10
  import json
11
+ import asyncio
11
12
 
12
13
  import aiobotocore.session
13
14
  from aiobotocore.config import AioConfig
14
- from fastapi import FastAPI, HTTPException, Request, Response
15
+ from fastapi import FastAPI, HTTPException, Request, Response, BackgroundTasks
15
16
  from fastapi.encoders import jsonable_encoder
16
17
  import newrelic.agent
17
18
 
18
-
19
19
  from .cache import make_features_cache
20
20
  from .dynamo import set_all_features, FeatureRetrievalMeta
21
21
  from .model_store import download_and_load_model
22
22
  from .settings import Settings
23
23
  from . import exceptions
24
24
  from ._serializers import SerializerRegistry
25
+ from ._kafka import send_to_kafka, initialize_kafka_producer, should_log_user
25
26
  from google.protobuf import text_format
26
27
 
27
28
  logging.basicConfig(
@@ -123,6 +124,10 @@ def create_app(
123
124
  # 1. Load ML Model
124
125
  if state["model"] is None:
125
126
  await load_model(state, cfg)
127
+ kafka_producer = None
128
+ if cfg.kafka_bootstrap_servers is not None:
129
+ kafka_producer = initialize_kafka_producer(app_config=cfg)
130
+ state["kafka_producer"] = kafka_producer
126
131
  async with AsyncExitStack() as stack:
127
132
  # 2. Initialize DynamoDB Client (Persistent Pool)
128
133
  session = state["session"]
@@ -139,6 +144,17 @@ def create_app(
139
144
  # 3. Shutdown Logic
140
145
  # The AsyncExitStack automatically closes the DynamoDB client pool here
141
146
  logger.info("Shutting down: Connection pools closed.")
147
+ logger.info("Shutting down: Flushing Kafka queue.")
148
+ if kafka_producer is not None:
149
+ try:
150
+ await kafka_producer.flush()
151
+ except Exception:
152
+ logger.error(
153
+ "Unknown exception while flushing kafka queue, shutting down producer.\n%s",
154
+ traceback.format_exc(),
155
+ )
156
+ finally:
157
+ await kafka_producer.close()
142
158
 
143
159
  app = FastAPI(
144
160
  title="ML Stream Scorer",
@@ -166,7 +182,9 @@ def create_app(
166
182
  }
167
183
 
168
184
  @app.post("/score", status_code=HTTPStatus.OK)
169
- async def score_stream(request: Request, response: Response):
185
+ async def score_stream(
186
+ request: Request, response: Response, background_tasks: BackgroundTasks
187
+ ):
170
188
  if state["model"] is None:
171
189
  raise HTTPException(
172
190
  status_code=HTTPStatus.SERVICE_UNAVAILABLE,
@@ -187,7 +205,8 @@ def create_app(
187
205
  ) from e
188
206
  except Exception as e:
189
207
  logger.error(
190
- "Unexpected exception when parsing request.\n %s", traceback.format_exc()
208
+ "Unexpected exception when parsing request.\n %s",
209
+ traceback.format_exc(),
191
210
  )
192
211
  raise HTTPException(
193
212
  status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Unknown exception"
@@ -274,22 +293,24 @@ def create_app(
274
293
  status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
275
294
  detail="Model prediction failed",
276
295
  ) from e
277
-
296
+ monitoring_meta = {
297
+ "cache_misses": retrieval_meta.cache_misses,
298
+ "user_cache_misses": retrieval_meta.user_cache_misses,
299
+ "stream_cache_misses": retrieval_meta.stream_cache_misses,
300
+ "user_cache_size": len(user_features_cache),
301
+ "stream_cache_size": len(stream_features_cache),
302
+ "retrieval_success": int(retrieval_meta.success),
303
+ "cache_delay_minutes": retrieval_meta.cache_delay_minutes,
304
+ "dynamo_ms": retrieval_meta.dynamo_ms,
305
+ "dynamo_parse_ms": retrieval_meta.parsing_ms,
306
+ "retrieval_ms": retrieval_meta.retrieval_ms,
307
+ "preprocess_ms": (predict_start - preprocess_start) * 1e-6,
308
+ "predict_ms": (predict_end - predict_start) * 1e-6,
309
+ "total_streams": len(model_output),
310
+ }
278
311
  newrelic.agent.record_custom_event(
279
312
  "Inference",
280
- {
281
- "cache_misses": retrieval_meta.cache_misses,
282
- "user_cache_misses": retrieval_meta.user_cache_misses,
283
- "stream_cache_misses": retrieval_meta.stream_cache_misses,
284
- "retrieval_success": int(retrieval_meta.success),
285
- "cache_delay_minutes": retrieval_meta.cache_delay_minutes,
286
- "dynamo_ms": retrieval_meta.dynamo_ms,
287
- "dynamo_parse_ms": retrieval_meta.parsing_ms,
288
- "retrieval_ms": retrieval_meta.retrieval_ms,
289
- "preprocess_ms": (predict_start - preprocess_start) * 1e-6,
290
- "predict_ms": (predict_end - predict_start) * 1e-6,
291
- "total_streams": len(model_output),
292
- },
313
+ monitoring_meta,
293
314
  )
294
315
  if model_output:
295
316
  if random_number < cfg.logs_fraction:
@@ -298,6 +319,18 @@ def create_app(
298
319
  userid,
299
320
  model_output,
300
321
  )
322
+ if should_log_user(userid=userid, kafka_fraction=cfg.kafka_fraction):
323
+ background_tasks.add_task(
324
+ send_to_kafka,
325
+ producer=state["kafka_producer"],
326
+ topic=cfg.kafka_topic,
327
+ user=user,
328
+ streams=streams,
329
+ playlist=playlist,
330
+ state=state,
331
+ model_output=model_output,
332
+ monitoring_meta=monitoring_meta,
333
+ )
301
334
  return jsonable_encoder(model_output)
302
335
 
303
336
  raise HTTPException(
@@ -1,9 +1,15 @@
1
1
  from pydantic_settings import BaseSettings
2
2
  from pydantic import Field
3
3
 
4
+
4
5
  class Settings(BaseSettings):
5
6
  # Logging
6
7
  logs_fraction: float = Field(0.01, alias="LOGS_FRACTION")
8
+ kafka_bootstrap_servers: str | None = Field(
9
+ default=None, alias="KAFKA_BOOTSTRAP_SERVERS"
10
+ )
11
+ kafka_fraction: float = Field(0.01, alias="KAFKA_FRACTION")
12
+ kafka_topic: str = Field(default=None, alias="KAFKA_TOPIC")
7
13
 
8
14
  # Model (S3)
9
15
  s3_model_path: str | None = Field(default=None, alias="S3_MODEL_PATH")
@@ -14,10 +20,10 @@ class Settings(BaseSettings):
14
20
 
15
21
  # Cache
16
22
  stream_cache_maxsize: int = 50_000
17
- user_cache_maxsize: int = 500_000
23
+ user_cache_maxsize: int = 80_000
18
24
  cache_separator: str = "--"
19
25
 
20
26
  class Config:
21
27
  env_file = ".env"
22
28
  env_file_encoding = "utf-8"
23
- extra = "ignore"
29
+ extra = "ignore"
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: haystack-ml-stack
3
- Version: 0.3.4
3
+ Version: 0.4.1
4
4
  Summary: Functions related to Haystack ML
5
5
  Author-email: Oscar Vega <oscar@haystack.tv>
6
6
  License: MIT
7
7
  Requires-Python: >=3.11
8
8
  Description-Content-Type: text/markdown
9
9
  Requires-Dist: protobuf==6.33.2
10
+ Requires-Dist: orjson==3.11.7
10
11
  Provides-Extra: server
11
12
  Requires-Dist: pydantic==2.5.0; extra == "server"
12
13
  Requires-Dist: cachetools==5.5.2; extra == "server"
@@ -15,6 +16,7 @@ Requires-Dist: aioboto3==12.0.0; extra == "server"
15
16
  Requires-Dist: fastapi==0.104.1; extra == "server"
16
17
  Requires-Dist: pydantic-settings==2.2; extra == "server"
17
18
  Requires-Dist: newrelic==11.1.0; extra == "server"
19
+ Requires-Dist: confluent-kafka==2.13.0; extra == "server"
18
20
 
19
21
  # Haystack ML Stack
20
22
 
@@ -1,7 +1,9 @@
1
1
  README.md
2
2
  pyproject.toml
3
3
  src/haystack_ml_stack/__init__.py
4
+ src/haystack_ml_stack/_kafka.py
4
5
  src/haystack_ml_stack/_serializers.py
6
+ src/haystack_ml_stack/_version.py
5
7
  src/haystack_ml_stack/app.py
6
8
  src/haystack_ml_stack/cache.py
7
9
  src/haystack_ml_stack/dynamo.py
@@ -1,4 +1,5 @@
1
1
  protobuf==6.33.2
2
+ orjson==3.11.7
2
3
 
3
4
  [server]
4
5
  pydantic==2.5.0
@@ -8,3 +9,4 @@ aioboto3==12.0.0
8
9
  fastapi==0.104.1
9
10
  pydantic-settings==2.2
10
11
  newrelic==11.1.0
12
+ confluent-kafka==2.13.0