haystack-ml-stack 0.1.0__tar.gz → 0.1.2__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.

Potentially problematic release.


This version of haystack-ml-stack might be problematic. Click here for more details.

Files changed (22) hide show
  1. {haystack_ml_stack-0.1.0 → haystack_ml_stack-0.1.2}/PKG-INFO +1 -1
  2. {haystack_ml_stack-0.1.0 → haystack_ml_stack-0.1.2}/pyproject.toml +1 -1
  3. {haystack_ml_stack-0.1.0 → haystack_ml_stack-0.1.2}/src/haystack_ml_stack/__init__.py +1 -1
  4. haystack_ml_stack-0.1.2/src/haystack_ml_stack/dynamo.py +144 -0
  5. {haystack_ml_stack-0.1.0 → haystack_ml_stack-0.1.2}/src/haystack_ml_stack.egg-info/PKG-INFO +1 -1
  6. {haystack_ml_stack-0.1.0 → haystack_ml_stack-0.1.2}/src/haystack_ml_stack.egg-info/SOURCES.txt +7 -1
  7. haystack_ml_stack-0.1.2/src/haystack_ml_stack.egg-info/top_level.txt +2 -0
  8. haystack_ml_stack-0.1.2/src/haystack_test_package/__init__.py +4 -0
  9. haystack_ml_stack-0.1.2/src/haystack_test_package/app.py +158 -0
  10. haystack_ml_stack-0.1.2/src/haystack_test_package/cache.py +19 -0
  11. {haystack_ml_stack-0.1.0/src/haystack_ml_stack → haystack_ml_stack-0.1.2/src/haystack_test_package}/dynamo.py +43 -9
  12. haystack_ml_stack-0.1.2/src/haystack_test_package/model_store.py +36 -0
  13. haystack_ml_stack-0.1.2/src/haystack_test_package/settings.py +22 -0
  14. haystack_ml_stack-0.1.0/src/haystack_ml_stack.egg-info/top_level.txt +0 -1
  15. {haystack_ml_stack-0.1.0 → haystack_ml_stack-0.1.2}/README.md +0 -0
  16. {haystack_ml_stack-0.1.0 → haystack_ml_stack-0.1.2}/setup.cfg +0 -0
  17. {haystack_ml_stack-0.1.0 → haystack_ml_stack-0.1.2}/src/haystack_ml_stack/app.py +0 -0
  18. {haystack_ml_stack-0.1.0 → haystack_ml_stack-0.1.2}/src/haystack_ml_stack/cache.py +0 -0
  19. {haystack_ml_stack-0.1.0 → haystack_ml_stack-0.1.2}/src/haystack_ml_stack/model_store.py +0 -0
  20. {haystack_ml_stack-0.1.0 → haystack_ml_stack-0.1.2}/src/haystack_ml_stack/settings.py +0 -0
  21. {haystack_ml_stack-0.1.0 → haystack_ml_stack-0.1.2}/src/haystack_ml_stack.egg-info/dependency_links.txt +0 -0
  22. {haystack_ml_stack-0.1.0 → haystack_ml_stack-0.1.2}/src/haystack_ml_stack.egg-info/requires.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: haystack-ml-stack
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Functions related to Haystack ML
5
5
  Author-email: Oscar Vega <oscar@haystack.tv>
6
6
  License: MIT
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "haystack-ml-stack"
8
- version = "0.1.0"
8
+ version = "0.1.2"
9
9
  description = "Functions related to Haystack ML"
10
10
  readme = "README.md"
11
11
  authors = [{ name = "Oscar Vega", email = "oscar@haystack.tv" }]
@@ -1,4 +1,4 @@
1
1
  from .app import create_app
2
2
 
3
3
  __all__ = ["create_app"]
4
- __version__ = "0.1.0"
4
+ __version__ = "0.1.2"
@@ -0,0 +1,144 @@
1
+ from typing import Any, Dict, List
2
+ import logging
3
+
4
+ import aiobotocore.session
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ async def async_batch_get(
10
+ dynamo_client, table_name: str, keys: List[Dict[str, Any]]
11
+ ) -> List[Dict[str, Any]]:
12
+ """
13
+ Asynchronous batch_get_item with chunking for requests > 100 keys
14
+ and handling for unprocessed keys.
15
+ """
16
+ all_items: List[Dict[str, Any]] = []
17
+ # DynamoDB's BatchGetItem has a 100-item limit per request.
18
+ CHUNK_SIZE = 100
19
+
20
+ # Split the keys into chunks of 100
21
+ for i in range(0, len(keys), CHUNK_SIZE):
22
+ chunk_keys = keys[i : i + CHUNK_SIZE]
23
+ to_fetch = {table_name: {"Keys": chunk_keys}}
24
+
25
+ # Inner loop to handle unprocessed keys for the current chunk
26
+ # Max retries of 3
27
+ retries = 3
28
+ while to_fetch and retries > 0:
29
+ retries -= 1
30
+ try:
31
+ resp = await dynamo_client.batch_get_item(RequestItems=to_fetch)
32
+
33
+ if "Responses" in resp and table_name in resp["Responses"]:
34
+ all_items.extend(resp["Responses"][table_name])
35
+
36
+ unprocessed = resp.get("UnprocessedKeys", {})
37
+ # If there are unprocessed keys, set them to be fetched in the next iteration
38
+ if unprocessed and unprocessed.get(table_name):
39
+ logger.warning(
40
+ "Retrying %d unprocessed keys.",
41
+ len(unprocessed[table_name]["Keys"]),
42
+ )
43
+ to_fetch = unprocessed
44
+ else:
45
+ # All keys in the chunk were processed, exit the inner loop
46
+ to_fetch = {}
47
+
48
+ except Exception as e:
49
+ logger.error("Error during batch_get_item for a chunk: %s", e)
50
+ # Stop trying to process this chunk on error and move to the next
51
+ to_fetch = {}
52
+
53
+ return all_items
54
+
55
+
56
+ def parse_dynamo_item(item: Dict[str, Any]) -> Dict[str, Any]:
57
+ """Parse a DynamoDB attribute map (low-level) to Python types."""
58
+ out: Dict[str, Any] = {}
59
+ for k, v in item.items():
60
+ if "N" in v:
61
+ out[k] = float(v["N"])
62
+ elif "S" in v:
63
+ out[k] = v["S"]
64
+ elif "SS" in v:
65
+ out[k] = v["SS"]
66
+ elif "NS" in v:
67
+ out[k] = [float(n) for n in v["NS"]]
68
+ elif "BOOL" in v:
69
+ out[k] = v["BOOL"]
70
+ elif "NULL" in v:
71
+ out[k] = None
72
+ elif "L" in v:
73
+ out[k] = [parse_dynamo_item({"value": i})["value"] for i in v["L"]]
74
+ elif "M" in v:
75
+ out[k] = parse_dynamo_item(v["M"])
76
+ return out
77
+
78
+
79
+ async def set_stream_features(
80
+ *,
81
+ streams: List[Dict[str, Any]],
82
+ stream_features: List[str],
83
+ features_cache,
84
+ features_table: str,
85
+ stream_pk_prefix: str,
86
+ cache_sep: str,
87
+ aio_session: aiobotocore.session.Session | None = None,
88
+ ) -> None:
89
+ """Fetch missing features for streams from DynamoDB and fill them into streams."""
90
+ if not streams or not stream_features:
91
+ return
92
+
93
+ cache_miss: Dict[str, Dict[str, Any]] = {}
94
+ for f in stream_features:
95
+ for s in streams:
96
+ key = f"{s['streamUrl']}{cache_sep}{f}"
97
+ if key in features_cache:
98
+ # Only set if value is not None
99
+ cached = features_cache.get(key)
100
+ if cached["value"] is not None:
101
+ s[f] = cached["value"]
102
+ else:
103
+ cache_miss[key] = s
104
+
105
+ if not cache_miss:
106
+ return
107
+
108
+ logger.info("Cache miss for %d items", len(cache_miss))
109
+
110
+ # Prepare keys
111
+ keys = []
112
+ for k in cache_miss.keys():
113
+ stream_url, sk = k.split(cache_sep, 1)
114
+ pk = f"{stream_pk_prefix}{stream_url}"
115
+ keys.append({"pk": {"S": pk}, "sk": {"S": sk}})
116
+
117
+ session = aio_session or aiobotocore.session.get_session()
118
+ async with session.create_client("dynamodb") as dynamodb:
119
+ try:
120
+ items = await async_batch_get(dynamodb, features_table, keys)
121
+ except Exception as e:
122
+ logger.error("DynamoDB batch_get failed: %s", e)
123
+ return
124
+
125
+ updated_keys = set()
126
+ for item in items:
127
+ stream_url = item["pk"]["S"].removeprefix(stream_pk_prefix)
128
+ feature_name = item["sk"]["S"]
129
+ cache_key = f"{stream_url}{cache_sep}{feature_name}"
130
+ parsed = parse_dynamo_item(item)
131
+
132
+ features_cache[cache_key] = {
133
+ "value": parsed.get("value"),
134
+ "cache_ttl_in_seconds": int(parsed.get("cache_ttl_in_seconds", -1)),
135
+ }
136
+ if cache_key in cache_miss:
137
+ cache_miss[cache_key][feature_name] = parsed.get("value")
138
+ updated_keys.add(cache_key)
139
+
140
+ # Save keys that were not found in DynamoDB with None value
141
+ if len(updated_keys) < len(cache_miss):
142
+ missing_keys = set(cache_miss.keys()) - updated_keys
143
+ for k in missing_keys:
144
+ features_cache[k] = {"value": None, "cache_ttl_in_seconds": 300}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: haystack-ml-stack
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Functions related to Haystack ML
5
5
  Author-email: Oscar Vega <oscar@haystack.tv>
6
6
  License: MIT
@@ -10,4 +10,10 @@ src/haystack_ml_stack.egg-info/PKG-INFO
10
10
  src/haystack_ml_stack.egg-info/SOURCES.txt
11
11
  src/haystack_ml_stack.egg-info/dependency_links.txt
12
12
  src/haystack_ml_stack.egg-info/requires.txt
13
- src/haystack_ml_stack.egg-info/top_level.txt
13
+ src/haystack_ml_stack.egg-info/top_level.txt
14
+ src/haystack_test_package/__init__.py
15
+ src/haystack_test_package/app.py
16
+ src/haystack_test_package/cache.py
17
+ src/haystack_test_package/dynamo.py
18
+ src/haystack_test_package/model_store.py
19
+ src/haystack_test_package/settings.py
@@ -0,0 +1,2 @@
1
+ haystack_ml_stack
2
+ haystack_test_package
@@ -0,0 +1,4 @@
1
+ from .app import create_app
2
+
3
+ __all__ = ["create_app"]
4
+ __version__ = "0.1.3"
@@ -0,0 +1,158 @@
1
+ import logging
2
+ import os
3
+ import random
4
+ import sys
5
+ from http import HTTPStatus
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ import aiobotocore.session
9
+ from fastapi import FastAPI, HTTPException, Request, Response
10
+ from fastapi.encoders import jsonable_encoder
11
+
12
+ from .cache import make_features_cache
13
+ from .dynamo import set_stream_features
14
+ from .model_store import download_and_load_model
15
+ from .settings import Settings
16
+
17
+ logging.basicConfig(
18
+ level=logging.INFO,
19
+ format="[%(levelname)s] [%(process)d] %(name)s : %(message)s",
20
+ handlers=[logging.StreamHandler(sys.stdout)],
21
+ force=True,
22
+ )
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def create_app(
28
+ settings: Optional[Settings] = None,
29
+ *,
30
+ preloaded_model: Optional[Dict[str, Any]] = None,
31
+ ) -> FastAPI:
32
+ """
33
+ Build a FastAPI app with injectable settings and model.
34
+ If `preloaded_model` is None, the app will load from S3 on startup.
35
+ """
36
+ cfg = settings or Settings()
37
+
38
+ app = FastAPI(
39
+ title="ML Stream Scorer",
40
+ description="Scores video streams using a pre-trained ML model and DynamoDB features.",
41
+ version="1.0.0",
42
+ )
43
+
44
+ # Mutable state: cache + model
45
+ features_cache = make_features_cache(cfg.cache_maxsize)
46
+ state: Dict[str, Any] = {
47
+ "model": preloaded_model,
48
+ "session": aiobotocore.session.get_session(),
49
+ "model_name": (
50
+ os.path.basename(cfg.s3_model_path) if cfg.s3_model_path else None
51
+ ),
52
+ }
53
+
54
+ @app.on_event("startup")
55
+ async def _startup() -> None:
56
+ if state["model"] is not None:
57
+ logger.info("Using preloaded model.")
58
+ return
59
+
60
+ if not cfg.s3_model_path:
61
+ logger.critical("S3_MODEL_PATH not set; service will be unhealthy.")
62
+ return
63
+
64
+ try:
65
+ state["model"] = await download_and_load_model(
66
+ cfg.s3_model_path, aio_session=state["session"]
67
+ )
68
+ state["stream_features"] = state["model"].get("stream_features", [])
69
+ logger.info("Model loaded on startup.")
70
+ except Exception as e:
71
+ logger.critical("Failed to load model: %s", e)
72
+
73
+ @app.get("/health", status_code=HTTPStatus.OK)
74
+ async def health():
75
+ model_ok = state["model"] is not None
76
+ if not model_ok:
77
+ raise HTTPException(
78
+ status_code=HTTPStatus.SERVICE_UNAVAILABLE,
79
+ detail="ML Model not loaded",
80
+ )
81
+ return {
82
+ "status": "ok",
83
+ "model_loaded": True,
84
+ "cache_size": len(features_cache),
85
+ "model_name": state.get("model_name"),
86
+ "stream_features": state.get("stream_features", []),
87
+ }
88
+
89
+ @app.post("/score", status_code=HTTPStatus.OK)
90
+ async def score_stream(request: Request, response: Response):
91
+ if state["model"] is None:
92
+ raise HTTPException(
93
+ status_code=HTTPStatus.SERVICE_UNAVAILABLE,
94
+ detail="ML Model not loaded",
95
+ )
96
+
97
+ try:
98
+ data = await request.json()
99
+ except Exception:
100
+ raise HTTPException(
101
+ status_code=HTTPStatus.BAD_REQUEST, detail="Invalid JSON payload"
102
+ )
103
+
104
+ user = data.get("user", {})
105
+ streams: List[Dict[str, Any]] = data.get("streams", [])
106
+ playlist = data.get("playlist", {})
107
+
108
+ if not streams:
109
+ logger.warning("No streams provided for user %s", user.get("userid", ""))
110
+ return {}
111
+
112
+ # Feature fetch (optional based on model)
113
+ model = state["model"]
114
+ stream_features = model.get("stream_features", []) or []
115
+ if stream_features:
116
+ logger.info("Fetching stream features for user %s", user.get("userid", ""))
117
+ await set_stream_features(
118
+ aio_session=state["session"],
119
+ streams=streams,
120
+ stream_features=stream_features,
121
+ features_cache=features_cache,
122
+ features_table=cfg.features_table,
123
+ stream_pk_prefix=cfg.stream_pk_prefix,
124
+ cache_sep=cfg.cache_separator,
125
+ )
126
+
127
+ # Sampling logs
128
+ if random.random() < cfg.logs_fraction:
129
+ logger.info("User %s streams: %s", user.get("userid", ""), streams)
130
+
131
+ # Synchronous model execution (user code)
132
+ try:
133
+ model_input = model["preprocess"](
134
+ user, streams, playlist, model.get("params")
135
+ )
136
+ model_output = model["predict"](model_input, model.get("params"))
137
+ except Exception as e:
138
+ logger.error("Model prediction failed: %s", e)
139
+ raise HTTPException(
140
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
141
+ detail="Model prediction failed",
142
+ )
143
+
144
+ if model_output:
145
+ return jsonable_encoder(model_output)
146
+
147
+ raise HTTPException(
148
+ status_code=HTTPStatus.NOT_FOUND, detail="No model output generated"
149
+ )
150
+
151
+ @app.get("/", status_code=HTTPStatus.OK)
152
+ async def root():
153
+ return {
154
+ "message": "ML Scoring Service is running.",
155
+ "model_name": state.get("model_name"),
156
+ }
157
+
158
+ return app
@@ -0,0 +1,19 @@
1
+ from typing import Any
2
+
3
+ from cachetools import TLRUCache
4
+
5
+
6
+ def _ttu(_, value: Any, now: float) -> float:
7
+ """Time-To-Use policy: allow per-item TTL via 'cache_ttl_in_seconds' or fallback."""
8
+ ONE_YEAR = 365 * 24 * 60 * 60
9
+ try:
10
+ ttl = int(value.get("cache_ttl_in_seconds", -1))
11
+ if ttl > 0:
12
+ return now + ttl
13
+ except Exception:
14
+ pass
15
+ return now + ONE_YEAR
16
+
17
+
18
+ def make_features_cache(maxsize: int) -> TLRUCache:
19
+ return TLRUCache(maxsize=maxsize, ttu=_ttu)
@@ -9,15 +9,46 @@ logger = logging.getLogger(__name__)
9
9
  async def async_batch_get(
10
10
  dynamo_client, table_name: str, keys: List[Dict[str, Any]]
11
11
  ) -> List[Dict[str, Any]]:
12
- """Asynchronous batch_get_item with unprocessed keys handling."""
12
+ """
13
+ Asynchronous batch_get_item with chunking for requests > 100 keys
14
+ and handling for unprocessed keys.
15
+ """
13
16
  all_items: List[Dict[str, Any]] = []
14
- to_fetch = {table_name: {"Keys": keys}}
15
-
16
- while to_fetch:
17
- resp = await dynamo_client.batch_get_item(RequestItems=to_fetch)
18
- all_items.extend(resp["Responses"].get(table_name, []))
19
- unprocessed = resp.get("UnprocessedKeys", {})
20
- to_fetch = unprocessed if unprocessed.get(table_name) else {}
17
+ # DynamoDB's BatchGetItem has a 100-item limit per request.
18
+ CHUNK_SIZE = 100
19
+
20
+ # Split the keys into chunks of 100
21
+ for i in range(0, len(keys), CHUNK_SIZE):
22
+ chunk_keys = keys[i : i + CHUNK_SIZE]
23
+ to_fetch = {table_name: {"Keys": chunk_keys}}
24
+
25
+ # Inner loop to handle unprocessed keys for the current chunk
26
+ # Max retries of 3
27
+ retries = 3
28
+ while to_fetch and retries > 0:
29
+ retries -= 1
30
+ try:
31
+ resp = await dynamo_client.batch_get_item(RequestItems=to_fetch)
32
+
33
+ if "Responses" in resp and table_name in resp["Responses"]:
34
+ all_items.extend(resp["Responses"][table_name])
35
+
36
+ unprocessed = resp.get("UnprocessedKeys", {})
37
+ # If there are unprocessed keys, set them to be fetched in the next iteration
38
+ if unprocessed and unprocessed.get(table_name):
39
+ logger.warning(
40
+ "Retrying %d unprocessed keys.",
41
+ len(unprocessed[table_name]["Keys"]),
42
+ )
43
+ to_fetch = unprocessed
44
+ else:
45
+ # All keys in the chunk were processed, exit the inner loop
46
+ to_fetch = {}
47
+
48
+ except Exception as e:
49
+ logger.error("Error during batch_get_item for a chunk: %s", e)
50
+ # Stop trying to process this chunk on error and move to the next
51
+ to_fetch = {}
21
52
 
22
53
  return all_items
23
54
 
@@ -80,6 +111,7 @@ async def set_stream_features(
80
111
  stream_url, sk = k.split(cache_sep, 1)
81
112
  pk = f"{stream_pk_prefix}{stream_url}"
82
113
  keys.append({"pk": {"S": pk}, "sk": {"S": sk}})
114
+ logger.info("Keys prepared for DynamoDB: %s", keys)
83
115
 
84
116
  session = aio_session or aiobotocore.session.get_session()
85
117
  async with session.create_client("dynamodb") as dynamodb:
@@ -88,16 +120,18 @@ async def set_stream_features(
88
120
  except Exception as e:
89
121
  logger.error("DynamoDB batch_get failed: %s", e)
90
122
  return
123
+ logger.info("DynamoDB returned %d items", len(items))
91
124
 
92
125
  for item in items:
93
126
  stream_url = item["pk"]["S"].removeprefix(stream_pk_prefix)
94
127
  feature_name = item["sk"]["S"]
95
128
  cache_key = f"{stream_url}{cache_sep}{feature_name}"
96
129
  parsed = parse_dynamo_item(item)
130
+ logger.info("DynamoDB item parsed: %s for %s", parsed, cache_key)
97
131
 
98
132
  features_cache[cache_key] = {
99
133
  "value": parsed.get("value"),
100
134
  "cache_ttl_in_seconds": int(parsed.get("cache_ttl_in_seconds", -1)),
101
135
  }
102
136
  if cache_key in cache_miss:
103
- cache_miss[cache_key][feature_name] = parsed.get("value")
137
+ cache_miss[cache_key][feature_name] = parsed.get("value")
@@ -0,0 +1,36 @@
1
+ import logging
2
+ import os
3
+ from typing import Any, Dict
4
+
5
+ import aiobotocore.session
6
+ import cloudpickle
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ async def download_and_load_model(
12
+ s3_url: str, aio_session: aiobotocore.session.Session | None = None
13
+ ) -> Dict[str, Any]:
14
+ """
15
+ Downloads cloudpickled model dict from S3 and loads it.
16
+ Expected keys: 'preprocess', 'predict', 'params', optional 'stream_features'.
17
+ """
18
+ if not s3_url or not s3_url.startswith("s3://"):
19
+ raise ValueError("S3_MODEL_PATH must be a valid s3:// URL")
20
+
21
+ bucket, key = s3_url.replace("s3://", "").split("/", 1)
22
+ pid = os.getpid()
23
+ local_path = f"/tmp/model_{pid}.pkl"
24
+
25
+ session = aio_session or aiobotocore.session.get_session()
26
+ async with session.create_client("s3") as s3:
27
+ logger.info("Downloading model from %s...", s3_url)
28
+ resp = await s3.get_object(Bucket=bucket, Key=key)
29
+ data = await resp["Body"].read()
30
+ with open(local_path, "wb") as f:
31
+ f.write(data)
32
+ logger.info("Model downloaded to %s", local_path)
33
+
34
+ with open(local_path, "rb") as f:
35
+ model: Dict[str, Any] = cloudpickle.load(f)
36
+ return model
@@ -0,0 +1,22 @@
1
+ from pydantic_settings import BaseSettings
2
+ from pydantic import Field
3
+
4
+ class Settings(BaseSettings):
5
+ # Logging
6
+ logs_fraction: float = Field(0.01, alias="LOGS_FRACTION")
7
+
8
+ # Model (S3)
9
+ s3_model_path: str | None = Field(default=None, alias="S3_MODEL_PATH")
10
+
11
+ # DynamoDB
12
+ features_table: str = Field("features", alias="FEATURES_TABLE")
13
+ stream_pk_prefix: str = "STREAM#"
14
+
15
+ # Cache
16
+ cache_maxsize: int = 50_000
17
+ cache_separator: str = "--"
18
+
19
+ class Config:
20
+ env_file = ".env"
21
+ env_file_encoding = "utf-8"
22
+ extra = "ignore"
@@ -1 +0,0 @@
1
- haystack_ml_stack