feat(core): add isolation="process", where a timeout can actually stop the job#66
Open
enginerd-kr wants to merge 1 commit into
Open
feat(core): add isolation="process", where a timeout can actually stop the job#66enginerd-kr wants to merge 1 commit into
enginerd-kr wants to merge 1 commit into
Conversation
…p the job
A timeout on a thread is a verdict, not a stop button. Python cannot kill a thread, so
Chronis marks the job failed, fires on_failure and retries — while the function keeps
running, holding its sockets and memory, possibly forever. `config(isolation="process")`
runs the job in a child process instead, and there the timeout is a kill: the child dies
and the OS takes back everything it held.
Measured on the same hung job, both modes:
isolation="thread" -> FAILED, abandoned_threads=1 (still running)
isolation="process" -> FAILED, abandoned_threads=0 (actually gone)
It also covers the case threads cannot survive at all: a job spinning on the CPU holds the
GIL and drags the whole scheduler down (20 spinners = 17.6x slowdown). A spinning child is
killed in ~1ms.
Opt-in, not the default. The child is a fresh interpreter: it costs a spawn per run (~25ms,
irrelevant for the timeout=300 jobs this is for), cannot see this process's state
(connection pools, caches), and can only run what it can import. So the default stays what
most jobs want, and the jobs that reach outside your control opt out of it.
Two guardrails, enforced at create_job rather than on every run: the function must be
module-level (the child resolves it by import path, so a lambda or closure has nothing to
resolve), and async jobs are refused (asyncio.wait_for already cancels them for real —
that guarantee is what isolation exists to give sync jobs).
Implementation notes:
- spawn, never fork. Chronis is multi-threaded, and fork() in a threaded process copies
locks in whatever state they were in — a lock another thread held stays locked in the
child forever. fork would be 1.5ms instead of 25ms; not worth a deadlock.
- No timer and no outcome gate. The waiting thread joins the child with a timeout and kills
it if it is still alive; because we do the killing, that thread always returns, so the
abandonment problem cannot arise. The permit machinery is reused unchanged.
- A child that dies without reporting (OOM kill, os._exit) is a failure, not a hang. poll()
cannot tell EOF from a pending message, so the pipe's EOFError is the signal.
The timeout warning now offers isolation as one of the three ways to make a timeout real,
and stays silent for jobs that already have one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Owner
Author
Coverage reportClick to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
요약
timeout=300을 진짜 정지 버튼으로 만드는 프로토타입입니다.스레드에서 timeout은 **판정(verdict)**입니다. 파이썬은 스레드를 죽일 수 없으니 Chronis는 FAILED로 표시하고
on_failure를 부르고 재시도를 걸지만 — 함수는 계속 돕니다. 소켓과 메모리를 붙든 채, 어쩌면 영원히. (#64가 이 사실을 경고로 알리게 했고, 이 PR은 그 경고를 해결할 수단을 줍니다.)config(isolation="process")는 job을 자식 프로세스에서 돌립니다. 거기서 timeout은 kill입니다.같은 행 걸린 job, 두 모드 비교 (실측)
스레드가 아예 살아남지 못하는 케이스도 커버합니다: CPU를 태우는 job은 GIL을 쥐고 스케줄러 전체를 끌어내립니다(spinner 20개 = 17.6배 저하). 프로세스라면 ~1ms에 죽습니다.
사용법
왜 디폴트가 아닌가
자식은 새 인터프리터입니다. run당 spawn 비용(~25ms —
timeout=300짜리 job엔 0.008%라 무의미), 부모의 상태(커넥션 풀·캐시·레지스트리)를 못 봄, import 가능한 것만 실행 가능. 그래서 디폴트는 대다수 job이 원하는 것(싸고, 공유하고, in-process)으로 남기고, 내 제어 밖의 일을 하는 job만 빠져나가게 합니다.두 개의 가드레일 — 매 실행이 아니라
create_job에서asyncio.wait_for가 이미 진짜로 취소함 — 격리가 sync job에 주려는 바로 그 보장미등록 함수는 거부하지 않습니다 — 워커 파드가 갖고 있을 수 있으니까요.
구현 노트
spawn, 절대fork아님. Chronis는 이미 멀티스레드(폴러·하트비트·워커)이고, 스레드가 있는 프로세스에서fork()하면 다른 스레드가 쥔 락이 자식에 잠긴 채 복사됩니다. fork는 25ms 대신 1.5ms지만, 데드락과 바꿀 값어치가 없습니다.join(timeout=...)하고 살아있으면 죽입니다. 우리가 죽이므로 그 스레드는 반드시 반환됩니다 — 좀비 문제가 원천적으로 발생할 수 없습니다. permit 회계는 그대로 재사용.os._exit)은 hang이 아니라 failure로 처리합니다.poll()은 EOF와 대기 중인 메시지를 구분하지 못하므로 파이프의EOFError가 유일한 신호입니다. (프로브 중 실제로 잡은 버그)JobProcessError로 프로세스 경계를 넘습니다 — 원래 타입·메시지·자식의 traceback을 텍스트로 실어서. (많은 예외 타입이 pickle 불가라 그대로는 못 넘습니다.)테스트
examples/process_isolation.py— 같은 job을 두 모드로 나란히 돌려 대비를 보여줌docs/ARCHITECTURE_NOTES.md갱신: 로드맵 항목 → 구현된 기능후속
프로토타입은 job당 프로세스입니다. 상주 워커 풀(dispatch 실측 0.049ms/job vs spawn 25ms)은 격리 job이 고빈도로 돌 때 의미가 생깁니다 — 지금은 정의상 느리고 위험한 job들이라 불필요합니다. 노트에 기록해 뒀습니다.
🤖 Generated with Claude Code