Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions polylogue/daemon/parse_prefetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,17 @@

logger = get_logger(__name__)

_DEFAULT_MAX_INFLIGHT_BYTES = 64 * 1024 * 1024 # 64 MiB
# Floor/ceiling for the adaptive whale-memory budget below. The original
# fixed 64 MiB default starved bulk-scale warm on whale corpora: measured
# live 2026-07-20 on the 50K-raw archive, a 2000-raw page warmed 139 raws in
# 376s (0.37 raws/s, pool stalled on cache admission) under 64 MiB versus
# 500 raws in 8.8s (56.7 raws/s) with the budget raised — the workers were
# blocked on `try_admit`, not on parsing. The budget's purpose is bounding
# transient memory beside a live daemon, so it scales with the machine
# instead of a one-size constant: 1/16 of physical RAM, clamped to
# [64 MiB, 2 GiB].
_MIN_MAX_INFLIGHT_BYTES = 64 * 1024 * 1024 # 64 MiB
_MAX_MAX_INFLIGHT_BYTES = 2 * 1024 * 1024 * 1024 # 2 GiB

# CodeRabbit (PR #3168): as_completed()/future.result() had no timeout, so one
# hung worker (e.g. an unresponsive filesystem read) would block warm()
Expand Down Expand Up @@ -86,10 +96,23 @@ def daemon_parse_stage_worker_count() -> int:
return max(1, (os.cpu_count() or 2) - 1)


def _physical_memory_bytes() -> int | None:
try:
pages = os.sysconf("SC_PHYS_PAGES")
page_size = os.sysconf("SC_PAGE_SIZE")
except (ValueError, OSError, AttributeError):
return None
if pages <= 0 or page_size <= 0:
return None
return pages * page_size


def daemon_parse_stage_max_inflight_bytes() -> int:
"""Whale-memory budget for parsed sessions held in the prefetch cache.

Override with ``POLYLOGUE_DAEMON_PARSE_STAGE_MAX_INFLIGHT_BYTES``.
Adaptive: 1/16 of physical RAM clamped to [64 MiB, 2 GiB] (see the
constants above for the measured starvation the old fixed 64 MiB default
caused). Override with ``POLYLOGUE_DAEMON_PARSE_STAGE_MAX_INFLIGHT_BYTES``.
"""
raw = os.environ.get("POLYLOGUE_DAEMON_PARSE_STAGE_MAX_INFLIGHT_BYTES")
if raw is not None:
Expand All @@ -99,7 +122,10 @@ def daemon_parse_stage_max_inflight_bytes() -> int:
value = 0
if value > 0:
return value
return _DEFAULT_MAX_INFLIGHT_BYTES
physical = _physical_memory_bytes()
if physical is None:
return _MIN_MAX_INFLIGHT_BYTES
return max(_MIN_MAX_INFLIGHT_BYTES, min(_MAX_MAX_INFLIGHT_BYTES, physical // 16))
Comment on lines +125 to +128

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bound the cache by the process memory limit

When either parse-stage feature runs on a host with at least 32 GiB RAM under the documented Nix deployment, sysconf reports host RAM and this selects a 2 GiB cache even though nix/lib/settings.nix defaults the entire daemon to MemoryMax=2G. The cache accounts source payload sizes rather than the larger parsed Python object graph, and polylogue/daemon/cli.py can create separate trickle and bulk caches, so a whale backlog can now exceed the service limit and OOM-kill the daemon. Derive the adaptive budget from the effective cgroup/process limit (with headroom for the rest of the daemon), not physical host RAM alone.

Useful? React with 👍 / 👎.



def daemon_parse_stage_warm_timeout_seconds() -> float:
Expand Down
33 changes: 33 additions & 0 deletions tests/unit/daemon/test_parse_prefetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,36 @@ def hanging_worker(*args: object, **kwargs: object) -> object:
# warm() returned close to its own timeout, not after the hung worker's
# sleep -- proving the wait is genuinely bounded, not merely reordered.
assert elapsed < 0.3


def test_max_inflight_bytes_default_is_adaptive_and_clamped(monkeypatch: pytest.MonkeyPatch) -> None:
"""The whale-memory budget scales with physical RAM, clamped to [64MiB, 2GiB].

The old fixed 64 MiB default starved bulk-scale warm on whale corpora
(measured live: 0.37 raws/s stalled on cache admission vs 56.7 raws/s
with an adequate budget) — the budget must grow on capable machines
while keeping the 64 MiB floor semantics on small ones.
"""
from polylogue.daemon import parse_prefetch as pp

monkeypatch.delenv("POLYLOGUE_DAEMON_PARSE_STAGE_MAX_INFLIGHT_BYTES", raising=False)

# 32 GiB machine -> hits the 2 GiB ceiling (32 GiB / 16 = 2 GiB).
monkeypatch.setattr(pp, "_physical_memory_bytes", lambda: 32 * 1024**3)
assert pp.daemon_parse_stage_max_inflight_bytes() == 2 * 1024**3

# 512 MiB machine -> clamped up to the 64 MiB floor.
monkeypatch.setattr(pp, "_physical_memory_bytes", lambda: 512 * 1024**2)
assert pp.daemon_parse_stage_max_inflight_bytes() == 64 * 1024**2

# 8 GiB machine -> proportional (8 GiB / 16 = 512 MiB).
monkeypatch.setattr(pp, "_physical_memory_bytes", lambda: 8 * 1024**3)
assert pp.daemon_parse_stage_max_inflight_bytes() == 512 * 1024**2

# Unknown physical memory -> conservative floor.
monkeypatch.setattr(pp, "_physical_memory_bytes", lambda: None)
assert pp.daemon_parse_stage_max_inflight_bytes() == 64 * 1024**2

# Explicit env override always wins.
monkeypatch.setenv("POLYLOGUE_DAEMON_PARSE_STAGE_MAX_INFLIGHT_BYTES", "123456789")
assert pp.daemon_parse_stage_max_inflight_bytes() == 123456789