Sqlalchemy ORM instance state 관리

김지환·2022년 12월 10일
0

TLDR

Flask api 서버 운영을 하다 504 error 가 발생하여서 문제의 원인을 분석하면서 sqlalchemy 에 대한 코드분석을 해보기 위한 포스트

5xx 에러발생

uwsgi log를 통해서 flask extention 중 하나인 flask-caching에서 cache를 set 하는 과정중에 Exception이 발생함을 알 수 있었다.

직접적인 Exception 은 TypeError 였고 None type 은 호출할 수 없다는 에러였다.

TypeError: 'NoneType' object is not callable

문제가 발생한 위치는 sqlalchemy의 orm의 instance state를 관리하는 class InstanceState의 매직매서드인 getstate 내부였다.

File "/usr/local/lib/python3.6/site-packages/flask_caching/__init__.py", line 841, in decorated_function
timeout=decorated_function.cache_timeout,
File "/usr/local/lib/python3.6/site-packages/flask_caching/backends/rediscache.py", line 124, in set
dump = self.dump_object(value)
File "/usr/local/lib/python3.6/site-packages/flask_caching/backends/rediscache.py", line 93, in dump_object
return b"!" + pickle.dumps(value)
File "/usr/local/lib/python3.6/site-packages/sqlalchemy/orm/state.py", line 455, in __getstate__
state_dict = {"instance": self.obj()}

기존 코드는 orm 객체의 속성값을 매직매서드 dict를 이용하여 가지고 오는 함수였다.


@cache.memoize(timeout=60)
def prev_get_user(api_key: str) -> dict:  # This Function will be deprecated when migration job is done.
    """
    Prev get user method.
    Until api key migration job is done. This function will be used.

    :param api_key: user api key

    :return: dict
    """
    user = UserViewTable.query.filter(
        UserViewTable.api_key == api_key
    ).first()

    if user is None:
        raise Unauthorized('Token does not exist.')
    else:
        return user.__dict__

그렇다면 원인은 무엇일까?

flask_caching 모듈을 통해서 redis 에 user 정보를 caching을 하게 되는데 이 때 object의 정보를 바이트화 시키기 위해서 pickle 모듈을 내부적으로 사용하게 되고 이를 통해서 InstanceState 의 매직매서드 getstate가 호출되게 된다.

이 때 tracking 하고 있는 객체를 가져오게 되는데 tracking하고 있던 객체의 정보가 사라지게 되면서 none type을 호출하게되는 에러가 발생하게 됐다.

문제의 핵심은
InstanceState 클래스에서 관리되고 있던 object의 tracking 정보가 사라졌다는 것인데 해당 정보가 사라질 수 있는 시나리오를 생각해봤다.

InstanceState 에서 관리되는 object의 정보를 없애는 시나리오.

sqlalchemy 에서 object의 orm state를 없애기 위해서는 InstanceState 의 self.obj 객체가 참조하고 있는 instance 가 사라져야한다. ( del, garbage collected etc... )

InstanceState 내부적으로는 def _dispose, def _cleanup 등의 함수를 이용해서 제거가 가능하다. _dispose는 따로 실행하는 부분을 찾을 수 없어서 가능성에서 배제

def _cleanup 함수는 self.obj 를 선언할 때 사용된 weakref 의 callable 로 입력된다. 따라서 참조자의 메모리 정보가 사라지면 실행되게 된다.

    def __init__(self, obj, manager):
        self.class_ = obj.__class__
        self.manager = manager
        self.obj = weakref.ref(obj, self._cleanup)
        self.committed_state = {}
        self.expired_attributes = set()

그렇다면 참조하고 있던 obj를 메모리 해제시킬 수 있는 방법은 무엇이 있을까?

obj의 참조카운트가 0이 됐을 때.

문제가 발생했던 함수는 orm 객체를 받고 해당 orm 의 매직매서드 dict를 이용해서 속성값만 return해주게 된다.

    if user is None:
        raise Unauthorized('Token does not exist.')
    else:
        return user.__dict__

따라서 함수가 끝남과 동시에 ref count는 0이 되고 해당 객체는 free가 된다. 이 때 dict를 통해서 속성값을 전달할 때 _sa_instance_state ( InstanceState 객체 ) 도 같이 전달되게 되는데 따라서 해당 값을 pickle시킬 때에는 참조하고 있던 객체가 None으로 나오게 되는 것이다.

그래서 확인해보니 최신 버전 sqlalchemy 는 InstanceState 에 아예 같은 이름의 함수를 만들어서 없을시에 None값을 넣는 방식으로 변경했다.

def obj(self):
	return None

이렇게 되면 기존 class 의 내부 variable을 호출하는 것이 함수를 호출하는 것처럼 바뀌기 때문에 TypeError를 피해갈 수 있다. ( 후... 최신 버전 써야지 )

profile
Developer

0개의 댓글