Skip to content

Commit 5867e3a

Browse files
authored
Merge pull request #221 from chinelo002/feat/req-docs-logging-graph-memory-187-195-199
docs+chore: clean requirements, central logging, streaming graph builder
2 parents 96db96c + 0b31e91 commit 5867e3a

6 files changed

Lines changed: 374 additions & 31 deletions

File tree

‎REQUIREMENTS.md‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Python requirements files
2+
3+
Three requirements files live at the repo root. Pick the one that matches
4+
your environment — they are *not* meant to be combined.
5+
6+
## Decision tree
7+
8+
```
9+
Need to train models (GPU/CUDA available)?
10+
└─ yes → pip install -r requirements.txt
11+
└─ no
12+
└─ Need to run training or feature jobs (CPU only)?
13+
└─ yes → pip install -r requirements-cpu.txt
14+
└─ no
15+
└─ Just want to load Hydra config / parse dataframes /
16+
run unit tests that don't touch torch?
17+
└─ yes → pip install -r requirements-minimal.txt
18+
```
19+
20+
## What each file ships
21+
22+
### `requirements.txt` — full GPU training stack
23+
The everything-on-board file. Pulls the full GPU `torch` wheel,
24+
`pytorch-lightning`, `mlflow`, the feature-store stack (redis, pyarrow,
25+
fastparquet, networkx, click, rich), visualization (matplotlib, seaborn),
26+
notebooks, and dev tooling (pytest + black + flake8 + mypy).
27+
28+
Use this on GPU CI runners and developer machines that build dashboards or
29+
notebooks.
30+
31+
### `requirements-cpu.txt` — CPU-only training stack
32+
Same shape as `requirements.txt` but pins the **CPU-only** torch wheels
33+
from the official PyTorch CPU index:
34+
35+
```
36+
torch>=2.0.0+cpu --index-url https://download.pytorch.org/whl/cpu
37+
```
38+
39+
Drops `mlflow`, `scikit-learn` standalone (still pulled transitively via
40+
some libs), the feature-store stack, visualization, dev tooling, and
41+
notebooks — they're not needed for headless CPU jobs. Pick this when:
42+
43+
- You're building the Docker image for production / CI.
44+
- You're running batch ingestion or model serving on a CPU box.
45+
- You want the fastest possible `pip install` for a smoke test.
46+
47+
### `requirements-minimal.txt` — Hydra + dataframes only
48+
The smallest viable set: `numpy`, `pandas`, `polars`, `pyyaml`,
49+
`hydra-core`, `omegaconf`. Nothing else. Use it when:
50+
51+
- You just want to import `astroml.config` and resolve a Hydra schema.
52+
- You're running config-only unit tests in CI.
53+
- You're embedding a small piece of astroml into another service and want
54+
to keep the install footprint tiny.
55+
56+
## Pin policy
57+
58+
Where a package appears in more than one file, the lower bound is held in
59+
sync across all of them. The actual lower bounds in use:
60+
61+
| package | pin | files |
62+
|------------------|--------------------|-------------------------------------------------|
63+
| `numpy` | `>=1.24` | requirements.txt, -cpu.txt, -minimal.txt |
64+
| `pandas` | `>=2.0` | requirements.txt, -cpu.txt, -minimal.txt |
65+
| `polars` | `>=1.0` | requirements.txt, -cpu.txt, -minimal.txt |
66+
| `pyyaml` | `>=6.0` | requirements.txt, -cpu.txt, -minimal.txt |
67+
| `hydra-core` | `>=1.3.0` | requirements.txt, -cpu.txt, -minimal.txt |
68+
| `omegaconf` | `>=2.3.0` | requirements.txt, -cpu.txt, -minimal.txt |
69+
| `torch` | `>=2.0.0` / `+cpu` | requirements.txt (GPU), -cpu.txt (CPU) |
70+
| `torch-geometric`| `>=2.3.0` | requirements.txt, -cpu.txt |
71+
| `sqlalchemy` | `>=2.0` | requirements.txt, -cpu.txt |
72+
| `psycopg2-binary`| `>=2.9` | requirements.txt, -cpu.txt |
73+
| `aiohttp` | `>=3.9` | requirements.txt, -cpu.txt |
74+
| `stellar-sdk` | `>=9.0.0` | requirements.txt, -cpu.txt |
75+
76+
If you bump one, run `grep -E "^<package>\b" requirements*.txt` to confirm
77+
you've bumped them in lockstep.

‎astroml/features/graph/snapshot.py‎

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,16 @@
22

33
from dataclasses import dataclass
44
from datetime import datetime, timedelta, timezone
5-
from typing import Generator, Iterable, List, Optional, Sequence, Set, Tuple
5+
from typing import Generator, Iterable, Iterator, List, Optional, Sequence, Set, Tuple
66
import bisect
77

88

9+
# Issue #199 — default chunk size for the streaming graph builder. SQLAlchemy
10+
# fetches rows from the DB in batches of this many; the iterator yields each
11+
# edge individually so callers never see a fully-materialised window list.
12+
DEFAULT_STREAM_CHUNK_SIZE = 5_000
13+
14+
915
@dataclass(frozen=True)
1016
class Edge:
1117
src: str
@@ -116,6 +122,111 @@ def _parse_window_size(window: str) -> timedelta:
116122
raise ValueError(f"Unknown window unit '{unit}'. Use 'd', 'h', or 's'.")
117123

118124

125+
@dataclass(frozen=True)
126+
class SnapshotMeta:
127+
"""Window metadata without the edge payload — issue #199.
128+
129+
Yielded alongside a fresh edge iterator by
130+
:func:`iter_db_snapshot_edges` so callers can decide how (or whether)
131+
to buffer the edges, instead of being forced to hold a fully-built
132+
``List[Edge]`` in RAM.
133+
"""
134+
135+
index: int
136+
start: datetime
137+
end: datetime
138+
139+
140+
def iter_db_snapshot_edges(
141+
window: str = "7d",
142+
t0: Optional[datetime] = None,
143+
t_now: Optional[datetime] = None,
144+
step: Optional[str] = None,
145+
session=None,
146+
chunk_size: int = DEFAULT_STREAM_CHUNK_SIZE,
147+
) -> Generator[Tuple["SnapshotMeta", Iterator["Edge"]], None, None]:
148+
"""Streaming variant of :func:`iter_db_snapshots` — issue #199.
149+
150+
Each yielded ``(meta, edges)`` pair gives the window bounds plus a
151+
fresh generator that pulls rows from the database in chunks of
152+
``chunk_size`` via SQLAlchemy's ``yield_per`` and converts each row
153+
into an :class:`Edge` lazily. Peak memory per window is bounded by
154+
``chunk_size`` (default 5 000 edges ≈ a few MB) regardless of how
155+
many edges the window actually contains.
156+
157+
The edge iterator MUST be drained or discarded before advancing to
158+
the next ``(meta, edges)`` pair — the underlying SQLAlchemy result
159+
will be reused. The function does not yield a ``nodes`` set; build
160+
it incrementally if you need one.
161+
162+
Use this in place of :func:`iter_db_snapshots` whenever a window may
163+
plausibly contain enough edges to risk OOM on the training machine.
164+
"""
165+
from astroml.db.schema import NormalizedTransaction
166+
from sqlalchemy import func as sqlfunc, select
167+
168+
if session is None:
169+
from astroml.db.session import get_session
170+
session = get_session()
171+
172+
win_delta = _parse_window_size(window)
173+
step_delta = _parse_window_size(step) if step else win_delta
174+
175+
if t_now is None:
176+
t_now = datetime.now(timezone.utc)
177+
178+
if t0 is None:
179+
result = session.execute(
180+
select(sqlfunc.min(NormalizedTransaction.timestamp))
181+
).scalar()
182+
if result is None:
183+
return # empty DB
184+
t0 = result if result.tzinfo else result.replace(tzinfo=timezone.utc)
185+
186+
if t_now.tzinfo is None:
187+
t_now = t_now.replace(tzinfo=timezone.utc)
188+
if t0.tzinfo is None:
189+
t0 = t0.replace(tzinfo=timezone.utc)
190+
191+
window_start = t0
192+
index = 0
193+
194+
while window_start < t_now:
195+
window_end = min(window_start + win_delta, t_now)
196+
197+
result = session.execute(
198+
select(
199+
NormalizedTransaction.sender,
200+
NormalizedTransaction.receiver,
201+
NormalizedTransaction.timestamp,
202+
)
203+
.where(
204+
NormalizedTransaction.timestamp >= window_start,
205+
NormalizedTransaction.timestamp <= window_end,
206+
NormalizedTransaction.receiver.isnot(None),
207+
NormalizedTransaction.sender != NormalizedTransaction.receiver,
208+
)
209+
.order_by(NormalizedTransaction.timestamp)
210+
.execution_options(yield_per=chunk_size, stream_results=True)
211+
)
212+
213+
def _edges_iter(_result=result) -> Iterator[Edge]:
214+
for row in _result:
215+
yield Edge(
216+
src=row.sender,
217+
dst=row.receiver,
218+
timestamp=int(row.timestamp.timestamp()),
219+
)
220+
221+
yield (
222+
SnapshotMeta(index=index, start=window_start, end=window_end),
223+
_edges_iter(),
224+
)
225+
226+
window_start += step_delta
227+
index += 1
228+
229+
119230
def iter_db_snapshots(
120231
window: str = "7d",
121232
t0: Optional[datetime] = None,

‎astroml/ingestion/enhanced_service.py‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -266,13 +266,16 @@ def get_all_stats(self) -> Dict[str, any]:
266266
# ---------------------------------------------------------------------------
267267

268268
def _configure_logging() -> None:
269-
"""Configure structured logging."""
270-
logging.basicConfig(
271-
level=logging.INFO,
272-
format="%(asctime)s %(levelname)-8s [%(name)s] %(message)s",
273-
datefmt="%Y-%m-%d %H:%M:%S",
274-
stream=sys.stderr,
275-
)
269+
"""Configure structured logging.
270+
271+
Delegates to :func:`astroml.utils.logging.configure_logging` so log
272+
level (``ASTROML_LOG_LEVEL``) and format (``ASTROML_LOG_FORMAT=
273+
text|json``) are consistent across every astroml entry point. See
274+
issue #195.
275+
"""
276+
from astroml.utils.logging import configure_logging
277+
278+
configure_logging()
276279

277280

278281
async def run_single_stream(config: EnhancedStreamConfig) -> None:

‎astroml/ingestion/stellar_ledger.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,11 @@ async def main():
132132

133133
args = parser.parse_args()
134134

135-
logging.basicConfig(level=logging.INFO)
135+
# Issue #195 — central logging config (level + text/json format)
136+
# via ASTROML_LOG_LEVEL / ASTROML_LOG_FORMAT env vars.
137+
from astroml.utils.logging import configure_logging
138+
139+
configure_logging()
136140

137141
async with StellarLedgerDownloader() as downloader:
138142
try:

‎astroml/utils/logging.py‎

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""Centralized logging configuration for astroml (issue #195).
2+
3+
Every CLI entry point and long-running service should call
4+
:func:`configure_logging` early in startup. It standardises:
5+
6+
- Log level (set via ``ASTROML_LOG_LEVEL`` env var; default ``INFO``).
7+
- Output format (set via ``ASTROML_LOG_FORMAT`` env var: ``text`` for
8+
human-readable, ``json`` for structured aggregator-friendly output;
9+
default ``text``).
10+
11+
Modules should keep using ``logging.getLogger(__name__)`` as they already
12+
do — calling :func:`configure_logging` once at startup is the only
13+
required change for the structured output to take effect everywhere.
14+
15+
Replacement for ad-hoc ``logging.basicConfig(...)`` calls scattered
16+
through ingestion services.
17+
"""
18+
from __future__ import annotations
19+
20+
import json
21+
import logging
22+
import os
23+
import sys
24+
from typing import Optional
25+
26+
27+
_DEFAULT_LEVEL = "INFO"
28+
_DEFAULT_FORMAT = "text"
29+
_TEXT_FORMAT = "%(asctime)s %(levelname)-7s %(name)s — %(message)s"
30+
31+
# Guard so importing this module twice (or `configure_logging` being called
32+
# from both a library and a CLI entry point) doesn't pile multiple
33+
# StreamHandlers onto the root logger.
34+
_CONFIGURED = False
35+
36+
37+
class _JsonFormatter(logging.Formatter):
38+
"""One JSON object per log record, structured for log aggregators.
39+
40+
Avoids a hard dependency on ``python-json-logger`` (which isn't pinned
41+
in any of the requirements files). The fields are the ones aggregators
42+
like Datadog / Loki / CloudWatch surface by default.
43+
"""
44+
45+
def format(self, record: logging.LogRecord) -> str: # noqa: D401
46+
payload = {
47+
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
48+
"level": record.levelname,
49+
"logger": record.name,
50+
"message": record.getMessage(),
51+
}
52+
if record.exc_info:
53+
payload["exc_info"] = self.formatException(record.exc_info)
54+
# Surface any structured `extra={...}` fields the caller passed.
55+
for key, value in record.__dict__.items():
56+
if key in payload:
57+
continue
58+
if key in {
59+
"args",
60+
"asctime",
61+
"created",
62+
"exc_info",
63+
"exc_text",
64+
"filename",
65+
"funcName",
66+
"levelname",
67+
"levelno",
68+
"lineno",
69+
"module",
70+
"msecs",
71+
"msg",
72+
"name",
73+
"pathname",
74+
"process",
75+
"processName",
76+
"relativeCreated",
77+
"stack_info",
78+
"thread",
79+
"threadName",
80+
"taskName",
81+
}:
82+
continue
83+
# Only include serialisable values; fall back to repr().
84+
try:
85+
json.dumps(value)
86+
payload[key] = value
87+
except (TypeError, ValueError):
88+
payload[key] = repr(value)
89+
return json.dumps(payload, default=str)
90+
91+
92+
def configure_logging(
93+
level: Optional[str] = None,
94+
format: Optional[str] = None, # noqa: A002 - matches argparse arg name
95+
force: bool = False,
96+
) -> None:
97+
"""Configure the root logger.
98+
99+
Parameters
100+
----------
101+
level:
102+
Log level string (``DEBUG``, ``INFO``, ``WARNING``, ``ERROR``,
103+
``CRITICAL``). Falls back to ``ASTROML_LOG_LEVEL`` env var, then
104+
``INFO``.
105+
format:
106+
Either ``"text"`` (human-readable single line) or ``"json"``
107+
(one JSON object per line). Falls back to ``ASTROML_LOG_FORMAT``
108+
env var, then ``text``.
109+
force:
110+
If True, reconfigure even if :func:`configure_logging` has been
111+
called already in this process.
112+
"""
113+
global _CONFIGURED
114+
if _CONFIGURED and not force:
115+
return
116+
117+
resolved_level = (
118+
level
119+
or os.environ.get("ASTROML_LOG_LEVEL")
120+
or _DEFAULT_LEVEL
121+
).upper()
122+
resolved_format = (
123+
format
124+
or os.environ.get("ASTROML_LOG_FORMAT")
125+
or _DEFAULT_FORMAT
126+
).lower()
127+
128+
handler = logging.StreamHandler(stream=sys.stderr)
129+
if resolved_format == "json":
130+
handler.setFormatter(_JsonFormatter())
131+
else:
132+
handler.setFormatter(logging.Formatter(_TEXT_FORMAT))
133+
134+
root = logging.getLogger()
135+
# Clear any previously installed handlers so we don't get duplicate
136+
# lines when a library called `logging.basicConfig(...)` first.
137+
for existing in list(root.handlers):
138+
root.removeHandler(existing)
139+
root.addHandler(handler)
140+
root.setLevel(resolved_level)
141+
142+
_CONFIGURED = True
143+
144+
145+
__all__ = ["configure_logging"]

0 commit comments

Comments
 (0)