[Bug]: one hung cascade handler stalls the whole projection while /health stays healthy
I maintainer di solito rispondono entro 1 giorno
Valutazione
- Difficoltà
- 4/5
- Tempo stimato
- 3-5 giorni
- Idoneità per principianti
- 52/100
- Tipo di issue
- Bug
- Chiarezza
- Abbastanza chiara
- Stato di attività
- Attiva
- Stack tecnologico
- python
- Ambito
- backend, observability
Direzione di ricerca
Read src/everos/memory/cascade/worker.py around CascadeWorkerHealth.reasons(), drain_once, and _process_one, then compare with src/everos/memory/cascade/orchestrator.py health reporting. Run the replay test described in the issue to confirm the stalled-worker behavior. Done means pending work with no progress is reported or a hung handler no longer blocks the loop, with the chosen behavior covered by a test.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
Area
src/everos
What happened?
CascadeWorker.drain_once claims a batch and gathers the whole batch (src/everos/memory/cascade/worker.py:687-701); _run_loop claims the next batch only when that gather returns. One handler call that does not return therefore stops every later row: the rows behind it stay pending, nothing is indexed, and this goes on for as long as that one call hangs. The worker has no time bound of its own on a handler; whatever bound exists comes from the provider's client timeouts, and a stuck LanceDB lock or a slow local model has none.
Meanwhile the health verdict stays green. CascadeWorkerHealth.reasons() (worker.py:421-442) looks at consecutive drain exceptions, the optimize failure streak and prune staleness; CascadeOrchestrator.health() (orchestrator.py:429-446) turns an empty reasons list into healthy=True and reports pending only as an informational count. A stuck drain raises nothing, so /health says healthy while pending grows and search misses every new file. Item 4 of #542, now replayed.
Expected: a projection that makes no progress while rows are pending is visible on /health (a liveness signal: time since the last completed row while pending > 0), and/or a per-handler time bound so a hung call ends as failed(retryable=True) instead of blocking the loop.
Steps to reproduce
Real worker loop (start(): drain, heartbeat and rebuild loops under the supervisor), real scanner / handler / repositories; the embedder blocks forever on one file's text.
- A file whose entry text carries
HANGis written and scanned; the worker claims it and blocks inside the handler:stuck row=processing. - Three more files are written and scanned. One second later (20 poll intervals):
counts={'processing': 1, 'pending': 3}, index rows for the three: 0,worker.health().reasons() == [], so/healthwould reporthealthy=True pending=4. - Control: the hung call is released →
counts={'done': 4}within a few polls.
replay test (~3 s, real scanner / worker / EpisodeHandler / SQLite / LanceDB; put both files in one directory and run uv run pytest <test file> -q -s)
test_replay_4_stuck_handler_health_green.py:
"""Replay of #542 item 4: one handler that never returns stalls the whole
projection, and ``/health`` stays healthy.
``drain_once`` gathers the whole claimed batch (worker.py:687-701) and
``_run_loop`` only claims the next batch when the gather returns, so a single
handler that hangs (an embedding call that never completes) stops every later
row from being processed. The health verdict (``CascadeWorkerHealth.reasons``,
worker.py:421-442; ``CascadeOrchestrator.health``, orchestrator.py:429-446)
looks at drain exceptions, optimize failures and prune staleness only; a stuck
drain raises nothing, so ``healthy`` stays ``True`` while ``pending`` grows.
Real worker loop (``start()``: drain, heartbeat and rebuild loops under the
supervisor), real scanner / handler / repos; the embedder blocks forever on one
file's text. Control: once that call is released, the backlog drains.
Run: uv run pytest .work_context/replay_542/test_replay_4_stuck_handler_health_green.py -q -s
"""
from __future__ import annotations
import asyncio
from typing import Any
from _harness import DIM, Harness
from everos.component.embedding import EmbeddingProvider
M = 1_700_000_000.0
class _Embedder(EmbeddingProvider):
"""Blocks forever on texts carrying ``HANG`` until ``release`` is set."""
dim = DIM
def __init__(self) -> None:
self.release = asyncio.Event()
self.hung = asyncio.Event()
async def embed(self, text: str) -> list[float]:
if "HANG" in text:
self.hung.set()
await self.release.wait()
return [0.1] * DIM
async def embed_batch(self, texts: Any) -> list[list[float]]:
return [await self.embed(t) for t in texts]
async def _replay() -> None:
emb = _Embedder()
h = await Harness().open(emb)
worker = h.worker(poll_interval_seconds=0.05, handler_concurrency=2)
try:
await worker.start()
await h.write("stuck", 1, "HANG content", M)
await h.scanner.scan_once()
await asyncio.wait_for(emb.hung.wait(), timeout=10)
r = await h.row("stuck")
print(f" the worker loop is inside the hung handler: stuck row={r.status}")
# the users keep writing; the scanner keeps enqueuing; nothing drains
for o in ("a", "b", "c"):
await h.write(o, 2, "fine content", M)
await h.scanner.scan_once()
await asyncio.sleep(1.0) # 20 poll intervals
counts = await h.counts()
health = worker.health()
reasons = health.reasons()
pending_public = counts.get("pending", 0) + counts.get(
"processing", 0
) # QueueSummary.pending
print(
f" 1 s later: counts={counts}; /health would say healthy={not reasons} reasons={reasons} pending={pending_public}"
)
assert counts.get("pending") == 3 and counts.get("processing") == 1
assert not reasons, "health reports no problem while nothing has drained"
for o in ("a", "b", "c"):
assert (await h.indexed(o)) == 0
# control: release the hung call -> the batch completes and the loop drains the rest
emb.release.set()
for _ in range(100):
await asyncio.sleep(0.05)
if (await h.counts()) == {"done": 4}:
break
print(f" control, after releasing the hung embed: counts={await h.counts()}")
assert (await h.counts()) == {"done": 4}
finally:
emb.release.set()
await worker.stop()
await h.close()
def test_one_hung_handler_stalls_everything_and_health_stays_green() -> None:
asyncio.run(_replay())
_harness.py:
"""Shared harness for the #542 replays: real scanner / worker / EpisodeHandler on a
temp memory root, real ``md_change_state`` repository (temp SQLite), real LanceDB
table; the embedder is injected per replay, the tokenizer is a stand-in."""
from __future__ import annotations
import datetime as dt
import shutil
import tempfile
from pathlib import Path
from typing import Any
from unittest import mock
from sqlmodel import SQLModel
import everos.component.embedding.accessor as acc
from everos.component.embedding import EmbeddingCapability, EmbeddingProvider
from everos.component.tokenizer import Tokenizer
from everos.config import LanceDBSettings, SqliteSettings
from everos.core.persistence import (
MemoryRoot,
create_session_factory,
create_system_engine,
)
from everos.core.persistence.lancedb import LanceRepoBase, open_lancedb_connection
from everos.infra.persistence.index import Episode
from everos.infra.persistence.lancedb.repos.episode import _EpisodeRepo
from everos.infra.persistence.markdown import EpisodeWriter
from everos.infra.persistence.predicate import eq
from everos.infra.persistence.sqlite.repos.md_change_state import _MdChangeStateRepo
from everos.memory.cascade import scanner as scanner_mod
from everos.memory.cascade import worker as worker_mod
from everos.memory.cascade.handlers import HandlerDeps
from everos.memory.cascade.handlers.episode import EpisodeHandler
from everos.memory.cascade.scanner import CascadeScanner
from everos.memory.cascade.worker import CascadeWorker
DATE = dt.date(2026, 5, 14)
DIM = 1024
class _Tokenizer(Tokenizer):
def tokenize(self, text: str) -> list[str]:
return [t for t in text.split() if t]
def tokenize_batch(self, texts: Any) -> list[list[str]]:
return [self.tokenize(t) for t in texts]
def entry(owner: str, n: int, content: str) -> tuple[dict[str, Any], dict[str, Any]]:
return (
{
"owner_id": owner,
"session_id": "s1",
"timestamp": "2026-05-14T10:00:00+00:00",
"parent_type": "memcell",
"parent_id": f"mc_{n}",
"sender_ids": [owner],
},
{"Subject": "T", "Summary": "S", "Content": content},
)
def rel(owner: str) -> str:
return f"default_app/default_project/users/{owner}/episodes/episode-{DATE.isoformat()}.md"
class Harness:
def __init__(self) -> None:
self.tmp = Path(tempfile.mkdtemp(prefix="replay542_"))
self.root = MemoryRoot(self.tmp / "root")
self.root.ensure()
self.engine = create_system_engine(self.root.system_db, SqliteSettings())
self.factory = create_session_factory(self.engine)
self.state_repo = _MdChangeStateRepo(session_factory=self.factory)
self.index: Any = None
self.patches: list[Any] = []
self.writer = EpisodeWriter(self.root)
self.scanner = CascadeScanner(self.root, scan_interval_seconds=3600.0)
async def open(self, embedder: EmbeddingProvider) -> Harness:
async with self.engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
LanceRepoBase._reset_locks_for_tests()
conn = await open_lancedb_connection(self.root.lancedb_dir, LanceDBSettings())
table = await conn.create_table(Episode.TABLE_NAME, schema=Episode)
self.index = _EpisodeRepo(table=table)
acc._capability = EmbeddingCapability(provider=embedder)
self.patches = [
mock.patch.object(scanner_mod, "md_change_state_repo", self.state_repo),
mock.patch.object(scanner_mod, "get_session_factory", lambda: self.factory),
mock.patch.object(worker_mod, "md_change_state_repo", self.state_repo),
mock.patch.object(EpisodeHandler, "index_repo", self.index),
]
for p in self.patches:
p.start()
return self
def handler(self) -> EpisodeHandler:
return EpisodeHandler(
HandlerDeps(memory_root=self.root, tokenizer=_Tokenizer())
)
def worker(self, **overrides: Any) -> CascadeWorker:
kw: dict[str, Any] = dict(
batch_size=10,
handler_concurrency=2,
max_retry=0,
retry_backoff_seconds=0.0,
poll_interval_seconds=3600.0,
optimize_min_interval_seconds=0.0,
optimize_heartbeat_seconds=3600.0,
optimize_rebuild_interval_seconds=3600.0,
)
kw.update(overrides)
return CascadeWorker({"episode": self.handler()}, **kw)
async def write(self, owner: str, n: int, content: str, mtime: float) -> Path:
await self.writer.append_entries(owner, [entry(owner, n, content)], date=DATE)
path = self.root.root / rel(owner)
import os
os.utime(path, (mtime, mtime))
return path
async def row(self, owner: str) -> Any:
return next(
(r for r in await self.state_repo.list_all() if r.md_path == rel(owner)),
None,
)
async def counts(self) -> dict[str, int]:
out: dict[str, int] = {}
for r in await self.state_repo.list_all():
out[r.status] = out.get(r.status, 0) + 1
return out
async def indexed(self, owner: str) -> int:
return len(await self.index.find_where(eq("md_path", rel(owner))))
async def close(self) -> None:
for p in self.patches:
p.stop()
await self.engine.dispose()
shutil.rmtree(self.tmp, ignore_errors=True)
Relation to other issues
#542 item 4.
Environment
OS: macOS 15 (Darwin 24.6.0)
Python: 3.12
Commit: 5b0979b (v1.5.1)
Possible fixes (not decided)
- A liveness reason in
CascadeWorkerHealth:pending > 0and no row completed for longer than N poll intervals. - A per-handler
asyncio.timeoutin_process_one, turning a hung call intofailed(retryable=True)so the batch completes and the loop moves on (the timeout must not cancel the shared embedding client, see theasyncio.timeoutcancellation note in #540's sibling, the 180 s optimize wait fixed in 1.5.0).
- Lingua principale
- Python
- Stelle
- 13.3k
- Fork
- 926
- Merge medio
- 19h 14m
- PR unite (30g)
- 27
Preparare l'ambiente
Questo progetto non fornisce container di sviluppo, Dockerfile né guida per i contributori, quindi l'ambiente è a tuo carico: parti dal suo README e consulta la nostra guida al primo contributo per i passaggi generali.
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di EverMind-AI/EverOS
-
Difficoltà 2/5 Mezza giornata Idoneità per principianti 62/100
EverMind-AI/EverOS#414 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 4/5 3-5 giorni Idoneità per principianti 25/100
EverMind-AI/EverOS#558 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 3/5 1-2 giorni Idoneità per principianti 67/100
EverMind-AI/EverOS#551 ·
I maintainer di solito rispondono entro 1 giorno
-
[Bug]: cascade retry budget is forgotten by a restart during the inline backoff; a row can retry forever without becoming failedForse già presa @feiiiiii5 l’ha presa 4 giorni fa. Apertabug
Difficoltà 4/5 3-5 giorni Idoneità per principianti 56/100
EverMind-AI/EverOS#549 ·
I maintainer di solito rispondono entro 1 giorno
-
[Bug]: cascade row stays processing forever when its terminal write (mark_failed / mark_done) fails onceForse già presa @feiiiiii5 l’ha presa 4 giorni fa. Apertabug
Difficoltà 4/5 3-5 giorni Idoneità per principianti 48/100
EverMind-AI/EverOS#548 ·
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di EverMind-AI/EverOS
Issue simili
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
huggingface/deep-rl-class#723 ·
-
[tool] bug: Qwen3 XML tool parser raises on `...` argument values (Ellipsis is not JSON serializable)Forse già presa @super-yuki l’ha presa oggi. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 87/100
verl-project/verl#8559 ·
I maintainer di solito rispondono entro 1 giorno
-
bug
Difficoltà 2/5 1-3 ore Idoneità per principianti 75/100
TencentCloud/octop-harness#228 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
google/osv-scalibr#2585 ·
I maintainer di solito rispondono entro 2 giorni
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 85/100
NousResearch/hermes-agent#149323 ·
I maintainer di solito rispondono entro 1 giorno