
    jY                    |   U d Z ddlmZ ddlZddlZddlZddlZddlZddlm	Z	 ddl
m
Z
mZ ddlmZ ddlmZmZmZ  ej$                  e      Z ej*                         Zded	<    ej0                         Z ej4                         ad
ed<    e ej:                               aded<   d,dZd-dZ d.dZ! G d dee      Z" e	d       G d d             Z#eeeef   Z$ G d d      Z% e%       Z&d/dZ'd0dZ(dddddd	 	 	 	 	 	 	 	 	 	 	 	 	 d1dZ)dZ*d2d3dZ+ G d  d!      Z,da-d"ed#<   d$d%d4d&Z.d5d'Z/d(Z0d)Z1d.d*Z2g d+Z3y)6u*  스텝 락의 소유자 신원과 살아있음 — 죽음을 추측하지 않고 확인한다.

## 왜 있나

`step_run.status='running'` 한 칸이 락이었다. 그 칸은 **누가 잡았는지**도
**그가 살아있는지**도 적지 않아서, 시스템은 죽음을 **경과 시간으로 추측**했다
(`step_running_timeout_seconds`, 기본 3600초). 그래서:

- 프로세스를 강제 종료하면 `running` 행이 남고 한 시간을 기다려야 했다
  (2026-08-26 새벽 실측 손실 40분)
- `force` 로도 못 뺏었다 (`_try_claim_running` 의 `WHERE status != 'running'`)
- 다섯 달치 시체가 쌓였다 (2026-03-25 부터 14행)

이 모듈은 락에 **소유자 신원**과 **하트비트**를 실어, 죽음을 추측 대신
**확인**하게 한다.

## 판정 순서 (`judge_owner`)

위에서부터 첫 일치에서 멈춘다.

| # | 조건 | 판정 |
|---|---|---|
| 1 | boot_id 가 이 프로세스 **and** 등록부에 없다 | DEAD |
| 2 | boot_id 가 이 프로세스 **and** 등록부에 있다 | ALIVE |
| 3 | host 가 이 호스트 **and** PID 가 죽은 프로세스 | DEAD |
| 4 | host 가 이 호스트 **and** PID 는 살아있으나 boot_id 다름 | 하트비트로 |
| 5 | 다른 호스트 / 신원 미상 | 하트비트 lease 로 |
| 6 | 신원·하트비트 둘 다 없음 (구 행) | UNKNOWN — 호출자가 경과 시간으로 |

`UNKNOWN` 은 「모른다」다. 호출자는 이를 **살아있음으로 취급**하거나 기존
경과 시간 경로로 떨어뜨린다 — 절대 죽음으로 읽지 않는다 (fail-closed).
    )annotationsN)	dataclass)datetimetimezone)Enum)DictOptionalTuplestrPROCESS_HOSTint_IDENTITY_PID_IDENTITY_BOOT_IDc                 (   t        j                         } t        5  t        | k7  rI| at	        t        j                               at        j                          t        j                  d|        t        t        t        fcddd       S # 1 sw Y   yxY w)u  (host, pid, boot_id). fork 로 PID 가 바뀌었으면 신원을 새로 만든다.

    ★신원을 새로 만들 때 **등록부도 비운다.** fork 직후 자식은 부모의 등록부를
     통째로 물려받는데, 그 내용은 전부 거짓이다 — 자식은 그 스텝들을 하고
     있지 않다. 안 비우면 자식이 부모의 락을 「내가 잡고 있다」고 읽는다.
    uV   프로세스 신원 재생성 (PID %s) — fork 로 보인다. 등록부를 비웠다.N)osgetpid_IDENTITY_LOCKr   r   uuiduuid4r   REGISTRYresetloggerwarningr   )pids    H/Users/manta/Documents/Projects/TheRoad-I1/backend/app/core/step_lock.pyprocess_identityr   A   sc     ))+C	CM #DJJL 1NNNNh ],== 
s   A#BBc                     t               d   S )N   r        r   process_boot_idr"   W       a  r!   c                     t               d   S )N   r   r    r!   r   process_pidr&   [   r#   r!   c                      e Zd ZdZdZdZdZy)OwnerVerdictu.   소유자가 살아있는가에 대한 판정.alivedeadunknownN)__name__
__module____qualname____doc__ALIVEDEADUNKNOWNr    r!   r   r(   r(   _   s    8EDGr!   r(   T)frozenc                      e Zd ZU dZdZded<   dZded<   dZded<   dZded<   dZ	ded	<   e
dd
       Zedd       ZddZy)	LockOwneru   `step_run` 행에서 읽은 소유자 신원.

    전부 nullable — 신원 칸이 생기기 전에 만들어진 행은 모두 None 이다.
    NOptional[str]hostOptional[int]r   boot_idheartbeat_atrun_idc           	        |j                  d      }	 |t        |      nd} | |j                  d      ||j                  d      |j                  d      |j                  d            S # t        t        f$ r d}Y \w xY w)u:   `_get_step_run()` 이 준 dict 에서 신원만 뽑는다.	owner_pidN
owner_hostowner_boot_idr:   r;   )r7   r   r9   r:   r;   )getr   	TypeError
ValueError)clsrowraw_pidr   s       r   from_rowzLockOwner.from_rowt   s     ''+&	")"5#g,4C &GGO,0778$
 	
 :& 	C	s   A+ +A?>A?c                ^    t        | j                        xr | j                  t               k(  S N)boolr9   r"   selfs    r   is_this_processzLockOwner.is_this_process   s"    DLL!Gdllo6G&GGr!   c                    | j                   s| j                  syd| j                  xs d d| j                  | j                  nd d| j                   xs dd d  S )Nu   owner=<신원 없음>zowner=?:   )r9   r7   r   rJ   s    r   describezLockOwner.describe   s[    ||DII*TYY%#&aDHH4Hc'R$bq)*,	
r!   )rD   r   returnz'LockOwner')rR   rI   rR   r   )r,   r-   r.   r/   r7   __annotations__r   r9   r:   r;   classmethodrF   propertyrL   rQ   r    r!   r   r5   r5   g   sh    
 D-C!G]!"&L-& FM 
 
 H H
r!   r5   c                  B    e Zd ZdZd	dZd
dZd
dZdddZddZd	dZ	y)HeldLockRegistryu  이 프로세스가 **지금 실제로 일하고 있는** 락의 목록.

    DB 가 「이 프로세스가 소유자」라고 말하는데 여기에 없으면, 그 일을 하던
    스레드는 사라진 것이다 — 프로세스 안의 일은 프로세스가 안다.

    ★등록은 **claim 보다 먼저** 한다. claim 성공 직후·등록 직전의 창에서
     다른 스레드가 판정하면 살아있는 락을 죽었다고 읽기 때문이다. claim 이
     실패하면 등록을 되돌린다.

    ★한 키에 run_id 를 **하나만** 담으면 안 된다. 같은 스텝을 노리는 두
     스레드 A·B 가 있을 때, 나중에 등록한 B 가 A 를 덮어쓰고, claim 에 진 B 가
     물러나며 지우면 **A 의 항목까지 사라진다.** 그 뒤 C 가 판정하면
     「DB 는 이 프로세스가 소유자라는데 등록부에 없다」 → 살아있는 A 를 죽었다고
     읽는다. 그래서 키마다 run_id **집합**을 담는다 — 진 쪽의 퇴장이 산 쪽을
     건드리지 않는다.
    c                D    t        j                         | _        i | _        y rH   )	threadingLock_lock_heldrJ   s    r   __init__zHeldLockRegistry.__init__   s    ^^%
)+
r!   c                    | j                   5  | j                  j                  |t                     j	                  |       d d d        y # 1 sw Y   y xY wrH   )r\   r]   
setdefaultsetadd)rK   keyr;   s      r   registerzHeldLockRegistry.register   s4    ZZJJ!!#su-11&9 ZZs   4A

Ac                    | j                   5  | j                  j                  |      }|s
	 ddd       y|j                  |       |s| j                  |= ddd       y# 1 sw Y   yxY w)uS   내 run_id 만 뺀다 — 같은 키의 다른 소유자를 건드리지 않는다.N)r\   r]   r@   discardrK   rc   r;   run_idss       r   
unregisterzHeldLockRegistry.unregister   sN    ZZjjnnS)G Z OOF#JJsO ZZs   A AA'Nc                    | j                   5  | j                  j                  |      }|s
	 d d d        y|dn||v cd d d        S # 1 sw Y   y xY w)NFT)r\   r]   r@   rg   s       r   holdszHeldLockRegistry.holds   sB    ZZjjnnS)G Z ">4v/@	 ZZs   AAAc           	         | j                   5  | j                  j                         D ci c]  \  }}|t        |       c}}cd d d        S c c}}w # 1 sw Y   y xY wrH   )r\   r]   itemsra   )rK   rc   rh   s      r   snapshotzHeldLockRegistry.snapshot   sI    ZZ:>**:J:J:LM:L,#wCW%:LM ZM Zs   AAAAAc                z    | j                   5  | j                  j                          ddd       y# 1 sw Y   yxY w)uV   등록부를 비운다. fork 로 신원이 바뀔 때와 테스트에서만 부른다.N)r\   r]   clearrJ   s    r   r   zHeldLockRegistry.reset   s#    ZZJJ ZZs   1:rR   None)rc   LockKeyr;   r   rR   rr   rH   )rc   rs   r;   r6   rR   rI   )rR   zDict[LockKey, set])
r,   r-   r.   r/   r^   rd   ri   rk   rn   r   r    r!   r   rX   rX      s'    ",:$ANr!   rX   c                    | | dk  ry	 t        j                  | d       y# t        $ r Y yt        $ r Y yt        $ r!}t
        j                  d| |       Y d}~yd}~ww xY w)u  이 호스트에서 그 PID 가 살아있나.

    Returns:
        True  — 살아있다
        False — 없다 (ESRCH)
        None  — 판단 못 한다 (pid 가 없거나 이상하다)

    ★`PermissionError` 는 **살아있다**는 뜻이다 — 프로세스가 있는데 다른
     사용자 소유라 신호를 못 보내는 것이다. 죽음으로 읽으면 남의 일을
     빼앗는다 (fail-closed).
    Nr   FTzis_pid_alive(%s) OSError: %s)r   killProcessLookupErrorPermissionErrorOSErrorr   debug)r   excs     r   is_pid_aliver{      se     {cQh
Q     3S#>s   ! 	AAAAAc                &   | | dk(  ryt        | t              r| }n/	 t        j                  t        |       j	                  dd            }|j                   |j	                  t        j                        }|S # t
        t        t        f$ r Y yw xY w)u@  시각 칸을 datetime 으로. 실패하면 None.

    `heartbeat_at` / `cancel_requested_at` 은 timestamptz 라 드라이버가 이미
    datetime 을 준다. `started_at` 같은 옛 칸은 text 다. 둘 다 받는다.

    tz 없는 값은 UTC 로 읽는다 (기존 `_evaluate_running_state` 와 같은 규칙).
    N Zz+00:00)tzinfo)
isinstancer   fromisoformatr   replacerB   rA   AttributeErrorr   r   utc)rawparseds     r   	parse_isor      s     {cRi#x 	++CH,<,<S(,KLF }}x||4M	 I~6 		s   .A9 9BBZ   F)rc   nowlease_secondsregistryallow_heartbeat_stealc               <    ||nt         }|xs# t        j                  t        j                        }d fd} j
                  r|#t        j                   j                          dfS |j                  | j                        r#t        j                   j                          dfS t        j                   j                          dfS t         j                        } j                  r j                  t         k(  rt#         j$                        }	|	du r#t        j                   j                          dfS |	du rm|#t        j                   j                          dfS ||z
  j'                         }
|
k\  r	 ||
d	      S t        j                   j                          d
|
ddfS |H||z
  j'                         }
|
k\  r	 ||
d      S t        j                   j                          d|
ddfS t        j                   j                          dfS )u  소유자가 살아있나 — 모듈 머리말의 6갈래 판정.

    Args:
        owner: `step_run` 행에서 읽은 신원.
        key: 등록부 조회용. None 이면 1·2번 갈래를 건너뛴다.
        now: 하트비트 나이 계산 기준 (테스트 주입용).
        lease_seconds: 하트비트가 이만큼 안 뛰면 멈춘 것으로 본다.
        registry: 테스트 주입용. None 이면 모듈 전역.
        allow_heartbeat_steal: 하트비트 만료를 **DEAD 로 쓸지**. 기본 False.

    ★`allow_heartbeat_steal` 이 왜 기본 꺼져 있나 — 하트비트 만료는 죽음의
     **증거가 아니라 정황**이다. DB 가 잠깐 끊겨도, 프로세스가 멈춰 있어도
     하트비트는 멈춘다. 그런데 락을 뺏는 순간 원래 일하던 쪽이 살아 돌아오면
     둘이 같은 결과물에 쓴다 — 지금 코드에는 결과물 쓰기를 막는 울타리가
     `checkpoint_gate` 하나뿐이라 안전 지점 사이의 쓰기는 막지 못한다.
     그래서 자동으로 풀기는 **확정 사망**(같은 프로세스 등록부 부재, 로컬 PID
     소멸)에만 허용하고, 하트비트 만료는 `UNKNOWN` 으로 내려 기존 경과 시간
     경로가 판단하게 둔다. 운영자는 그 정황을 `GET .../locks` 에서 보고
     `release` 로 명시 해제할 수 있다.

    Returns:
        (판정, 사람이 읽을 사유)
    c           
         rt         j                  nt         j                  }|j                          d| dd d| drd 	fS d 	fS )uK   하트비트가 멈췄다 — 켜져 있을 때만 DEAD, 아니면 UNKNOWN.u    하트비트가 .0fu   초 멈췄다 (lease=su   ) — u   자동으로 풀기u1   자동으로 풀기 안 함(확정 사망 아님))r(   r1   r2   rQ   )agedetailverdictr   r   owners      r   _stalledzjudge_owner.<locals>._stalled$  sv    '<,##,BVBV~~  1#c ;#_AfXV(=$wy
 	
 Dwwy
 	
r!   u,    이 프로세스지만 조회 키가 없다u,    이 프로세스가 실제로 잡고 있다uX    이 프로세스 소유인데 등록부에 없다 (일하던 스레드가 사라졌다)Fu*    같은 호스트인데 그 PID 가 없다Tu/    PID 는 살아있으나 하트비트가 없다u   , PID 는 살아있음u    PID 살아있고 하트비트 r   u   초 전r}   u    하트비트 uA    신원·하트비트 둘 다 없다 (경과 시간 판정으로))r   floatr   r   rR   Tuple[OwnerVerdict, str])r   r   r   r   r   rL   r(   r2   rQ   rk   r;   r0   r1   r   r:   r7   r   r{   r   total_seconds)r   rc   r   r   r   r   regr   	heartbeatr)   r   s   `  ` `     r   judge_ownerr     s@   @ *(C

+hll+C
 ;$$>>#$$PQ  99S%,,'"">>#$$PQ 
 ~~  !4 5
 	
 %,,-I zzejjL0UYY'E>!!>>#$$NO  D=   ((~~'((WX  ?113Cm#  %=>>"">>#$$CC9GT  Y--/-C$$~~ s3iw?
 	
 	>>
]^ r!   z
    UPDATE step_run
       SET heartbeat_at = CURRENT_TIMESTAMP, updated_at = :now
     WHERE project_id = :pid AND episode_id = :eid AND step_id = :sid
       AND run_id = :run_id
       AND owner_boot_id = :boot_id
       AND status = 'running'
c                   ddl m} ||nt        }|j                         }|syt	        j
                  t        j                        j                         }t               }d} |        }	 |j                         D ]F  \  \  }	}
}}|D ]8  }|j                   |t              ||	|
|||d      }||j                  xs dz  }: H |j                          |j                          |S # |j                          w xY w)u   등록부에 있는 락의 `heartbeat_at` 을 한 번 갱신한다.

    ★갱신은 **소유자 일치 조건부**다 (`run_id` + `owner_boot_id` + `running`).
     이미 빼앗긴 락을 되살리지 않는다.

    Returns: 갱신된 행 수.
    r   text)r   r   eidsidr;   r9   )
sqlalchemyr   r   rn   r   r   r   r   	isoformatr"   rm   execute_HEARTBEAT_SQLrowcountcommitclose)session_factoryr   	_sql_textr   heldr   r9   updatedsession
project_id
episode_idstep_idrh   r;   results                  r   	beat_oncer     s     -*(C<<>D
,,x||
$
.
.
0CGGG:>**,6-ZWw! >)B%%"$&E  6??/a/ " ;G 	N 	s   )A)C$ $C6c                  D    e Zd ZdZddd	 	 	 	 	 d	dZd
dZdddZd
dZy)HeartbeatThreadu  프로세스당 하나. 주기적으로 `beat_once` 를 부른다.

    ★하트비트 실패는 로그만 남기고 계속한다 — 하트비트가 주행을 죽이면
     안 된다. DB 가 잠깐 끊겼다고 락을 잃는 것보다 lease 가 만료되는 쪽이
     낫고, lease 는 어차피 다시 뛰면 회복된다.

    ★일하는 스레드의 세션을 공유하지 않는다 — 매 회 새 세션을 열고 닫는다.
     SQLAlchemy 세션은 스레드 안전하지 않다.
       N)interval_secondsr   c               ~    || _         || _        ||nt        | _        t	        j
                         | _        d | _        y rH   )_session_factory	_intervalr   	_registryrZ   Event_stop_thread)rK   r   r   r   s       r   r^   zHeartbeatThread.__init__  s6     !0)%-%9x__&
37r!   c                `   | j                   | j                   j                         ry | j                  j                          t	        j
                  | j                  dd      | _         | j                   j                          t        j                  d| j                  t               d d        y )Nzstep-lock-heartbeatT)targetnamedaemonu7   스텝 락 하트비트 시작 (간격=%ds, boot_id=%s)rP   )r   is_aliver   rp   rZ   Thread_loopstartr   infor   r"   rJ   s    r   r   zHeartbeatThread.start  s    <<#(=(=(?

 ''::$9$
 	ENNO-bq1	
r!   c                    | j                   j                          | j                  | j                  j                  |       y y )N)timeout)r   ra   r   join)rK   r   s     r   stopzHeartbeatThread.stop  s4    

<<#LLg. $r!   c                6   | j                   j                  | j                        sH	 t        | j                  | j
                         | j                   j                  | j                        sGy y # t        $ r }t        j                  d|       Y d }~Ld }~ww xY w)Nu2   스텝 락 하트비트 실패 (계속 진행): %s)	r   waitr   r   r   r   	Exceptionr   r   )rK   rz   s     r   r   zHeartbeatThread._loop  sm    **//$..1Z$//@ **//$..1  ZSUXYYZs    A/ /	B8BB)r   r   r   Optional[HeldLockRegistry]rR   rr   rq   )g      @)r   r   rR   rr   )r,   r-   r.   r/   r^   r   r   r   r    r!   r   r   r     sB     !#/38 	8
 -8 
8
/
Zr!   r   zOptional[HeartbeatThread]
_HEARTBEATr   r   c               \    t         t        | |      a t         j                          t         S )uI   프로세스 전역 하트비트를 켠다 (여러 번 불러도 하나).r   )r   r   r   )r   r   s     r   start_heartbeatr     s.     $.>

 r!   c                 :    t         t         j                          y y rH   )r   r   r    r!   r   stop_heartbeatr     s     r!   z
    SELECT project_id, episode_id, step_id, run_id,
           owner_host, owner_pid, owner_boot_id, heartbeat_at, started_at
      FROM step_run
     WHERE status = 'running'
       AND owner_host = :host
a  
    UPDATE step_run
       SET status = 'failed',
           run_id = :revoked_run_id,
           error_message = :reason,
           last_recovery_reason = :reason,
           recovery_count = COALESCE(recovery_count, 0) + 1,
           completed_at = :now,
           updated_at = :now
     WHERE project_id = :pid AND episode_id = :eid AND step_id = :sid
       AND status = 'running'
       AND run_id = :run_id
c                f   ddl m} | j                   |t              dt        i      j                         }|syt        j                  t        j                        j                         }d}|D ]  }t        j                  |j                  |j                  |j                  |j                   |j"                  d      }|j$                  r,t&        j)                  d|j*                  |j,                         t/        |j0                        }|dur<t&        j3                  d|j*                  |j,                  |j5                         |       d|j5                          d	|j6                   d
|j"                   d}| j                   |t8              |dd dt;        j<                          ||j>                  |j*                  |j,                  |j"                  d      }	|	j@                  s~|dz  }t&        jC                  d|j*                  |j,                  |        | jE                          |S )u  백엔드가 뜰 때, **이 호스트의 죽은 프로세스**가 잡은 락을 푼다.

    풀기 = `running` → `failed`. **행을 지우지 않는다** — 죽은 것을 죽었다고
    적는 것이다. 다음 `resume` 이 바로 가져간다.

    ★이 하나가 「서버를 죽였는데 그 서버가 잡은 락이 남아 한 시간을
     기다리는」 사고를 없앤다. 다시 띄우는 순간 풀린다.

    ★대상은 **`owner_host` 가 이 호스트인 행뿐**이다. 신원 칸이 NULL 인
     구 행과 다른 호스트의 행은 건드리지 않는다 — 자동 대량 정리 금지.

    Returns: 풀기한 행 수.
    r   r   r7   )r>   r=   r?   r:   r;   uf   startup reclaim: %s/%s 가 이 프로세스 boot_id 를 갖고 있다 — 건너뛴다 (조사 필요)Fu2   startup reclaim 건너뜀: %s/%s %s (pid_alive=%s)u0   startup reclaim: 소유 프로세스가 없다 (z, started_at=z, revoked_run_id=)Ni  z
reclaimed-)reasonrevoked_run_idr   r   r   r   r;   r%   u%   [STARTUP-RECLAIM] %s/%s 풀기 — %s)#r   r   r   _RECLAIM_SELECTr   fetchallr   r   r   r   r   r5   rF   r>   r=   r?   r:   r;   rL   r   errorr   r   r{   r   r   rQ   
started_at_RECLAIM_UPDATEr   r   r   r   r   r   )
dbr   rowsr   	reclaimedrD   r   r)   r   r   s
             r   reclaim_dead_locks_on_startupr   
  s    -::i06<2HIRRTD
,,x||
$
.
.
0CI"".. ..,,jj$
    LL3
 UYY'KKDU^^-=u  ?u~~?O>P Q..)):3::,aI 	 Io6Udm *4::<.9>>>>;;jj9
  ??NINN7VW ^ IIKr!   )r   r   r"   r&   r(   r5   rs   rX   r   r{   r   r   r   r   r   r   r   )rR   zTuple[str, int, str]rS   )rR   r   )r   r8   rR   zOptional[bool])rR   Optional[datetime])r   r5   rc   zOptional[LockKey]r   r   r   r   r   r   r   rI   rR   r   rH   )r   r   rR   r   )r   r   rR   r   rq   )4r/   
__future__r   loggingr   socketrZ   r   dataclassesr   r   r   enumr   typingr   r	   r
   	getLoggerr,   r   gethostnamer   rT   r[   r   r   r   r   r   r   r   r"   r&   r(   r5   rs   rX   r   r{   r   r   r   r   r   r   r   r   r   r   r   __all__r    r!   r   <module>r      s  B #  	    ! '  ( (			8	$ 'F&&(c (!RYY[s  ZTZZ\* 3 *>,!!3  $&
 &
 &
V S#
2 2j 42 ""+/"'ll 
l 
	l
 l )l  l lh"J/Z /Zd )-
% , AC FRr!   