Skip to content

feat(core): add isolation="process", where a timeout can actually stop the job#66

Open
enginerd-kr wants to merge 1 commit into
mainfrom
feat/process-isolation
Open

feat(core): add isolation="process", where a timeout can actually stop the job#66
enginerd-kr wants to merge 1 commit into
mainfrom
feat/process-isolation

Conversation

@enginerd-kr

Copy link
Copy Markdown
Owner

요약

timeout=300진짜 정지 버튼으로 만드는 프로토타입입니다.

스레드에서 timeout은 **판정(verdict)**입니다. 파이썬은 스레드를 죽일 수 없으니 Chronis는 FAILED로 표시하고 on_failure를 부르고 재시도를 걸지만 — 함수는 계속 돕니다. 소켓과 메모리를 붙든 채, 어쩌면 영원히. (#64가 이 사실을 경고로 알리게 했고, 이 PR은 그 경고를 해결할 수단을 줍니다.)

config(isolation="process")는 job을 자식 프로세스에서 돌립니다. 거기서 timeout은 kill입니다.

같은 행 걸린 job, 두 모드 비교 (실측)

isolation="thread"   -> FAILED, abandoned_threads=1   ← 여전히 돌고 있음
isolation="process"  -> FAILED, abandoned_threads=0   ← 진짜로 사라짐

스레드가 아예 살아남지 못하는 케이스도 커버합니다: CPU를 태우는 job은 GIL을 쥐고 스케줄러 전체를 끌어내립니다(spinner 20개 = 17.6배 저하). 프로세스라면 ~1ms에 죽습니다.

사용법

def call_partner_api(**kwargs):          # 모듈 레벨 함수여야 함
    requests.get("https://partner.example.com/...")   # 응답을 안 줄 수도 있음

scheduler.every(minutes=5).config(timeout=300, isolation="process").run("call_partner_api")

왜 디폴트가 아닌가

자식은 새 인터프리터입니다. run당 spawn 비용(~25ms — timeout=300짜리 job엔 0.008%라 무의미), 부모의 상태(커넥션 풀·캐시·레지스트리)를 못 봄, import 가능한 것만 실행 가능. 그래서 디폴트는 대다수 job이 원하는 것(싸고, 공유하고, in-process)으로 남기고, 내 제어 밖의 일을 하는 job만 빠져나가게 합니다.

두 개의 가드레일 — 매 실행이 아니라 create_job에서

거부 이유
람다 / 클로저 / 지역 함수 자식이 import 경로로 해상하는데 해상할 경로가 없음
async job asyncio.wait_for이미 진짜로 취소함 — 격리가 sync job에 주려는 바로 그 보장

미등록 함수는 거부하지 않습니다 — 워커 파드가 갖고 있을 수 있으니까요.

구현 노트

  • spawn, 절대 fork 아님. Chronis는 이미 멀티스레드(폴러·하트비트·워커)이고, 스레드가 있는 프로세스에서 fork()하면 다른 스레드가 쥔 락이 자식에 잠긴 채 복사됩니다. fork는 25ms 대신 1.5ms지만, 데드락과 바꿀 값어치가 없습니다.
  • Timer도 outcome gate도 없습니다. 대기 스레드가 자식을 join(timeout=...)하고 살아있으면 죽입니다. 우리가 죽이므로 그 스레드는 반드시 반환됩니다 — 좀비 문제가 원천적으로 발생할 수 없습니다. permit 회계는 그대로 재사용.
  • 보고 없이 죽은 자식(OOM kill, os._exit)은 hang이 아니라 failure로 처리합니다. poll()은 EOF와 대기 중인 메시지를 구분하지 못하므로 파이프의 EOFError가 유일한 신호입니다. (프로브 중 실제로 잡은 버그)
  • 예외는 JobProcessError로 프로세스 경계를 넘습니다 — 원래 타입·메시지·자식의 traceback을 텍스트로 실어서. (많은 예외 타입이 pickle 불가라 그대로는 못 넘습니다.)

테스트

  • 584개 통과 (unit + integration + e2e + postgres), ruff · mypy 통과
  • 신규 통합 테스트 13개: 행 걸린 job kill / CPU-spin kill / thread는 새고 process는 안 새는 대비 / 인자 전달 / 예외 전파 / 보고 없이 죽은 자식 / 람다·async 거부 / 경고 무음화
  • examples/process_isolation.py — 같은 job을 두 모드로 나란히 돌려 대비를 보여줌
  • docs/ARCHITECTURE_NOTES.md 갱신: 로드맵 항목 → 구현된 기능

후속

프로토타입은 job당 프로세스입니다. 상주 워커 풀(dispatch 실측 0.049ms/job vs spawn 25ms)은 격리 job이 고빈도로 돌 때 의미가 생깁니다 — 지금은 정의상 느리고 위험한 job들이라 불필요합니다. 노트에 기록해 뒀습니다.

🤖 Generated with Claude Code

…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>
@enginerd-kr

Copy link
Copy Markdown
Owner Author

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  chronis
  type_defs.py
  chronis/core
  defaults.py
  chronis/core/base
  scheduler.py
  chronis/core/execution
  coordinator.py 421
  process_runner.py 41-52, 124, 133-139
  chronis/core/jobs
  definition.py
  chronis/core/schedulers
  fluent_builders.py 270
  polling_scheduler.py
Project Total  

This report was generated by python-coverage-comment-action

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant