|
2 | 2 |
|
3 | 3 | from dataclasses import dataclass |
4 | 4 | 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 |
6 | 6 | import bisect |
7 | 7 |
|
8 | 8 |
|
| 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 | + |
9 | 15 | @dataclass(frozen=True) |
10 | 16 | class Edge: |
11 | 17 | src: str |
@@ -116,6 +122,111 @@ def _parse_window_size(window: str) -> timedelta: |
116 | 122 | raise ValueError(f"Unknown window unit '{unit}'. Use 'd', 'h', or 's'.") |
117 | 123 |
|
118 | 124 |
|
| 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 | + |
119 | 230 | def iter_db_snapshots( |
120 | 231 | window: str = "7d", |
121 | 232 | t0: Optional[datetime] = None, |
|
0 commit comments