되돌릴 수 없는 변경 · 승인 뒤 누군가 먼저 바꿨다 · 실습
축제 취소 버튼과 이미 바뀐 주문
목표
축제 주문 취소 승인을 받은 뒤 다른 담당자가 데이터를 바꿨습니다. 승인한 고객·ID·버전·수량만 취소하고, 중간 충돌에는 전체를 보존하며, 응답 분실에는 확정 기록으로 사실을 확인합니다.
왜 중요한가
승인 문서를 읽는 동안에도 데이터는 움직입니다. 두 건을 바꿨다는 숫자가 맞아도 다른 주문을 취소했다면 실패입니다. 변경 조건, 바깥 트랜잭션, 영수증과 현재 대조를 나눠 구현하고 실제 두 DB 연결·클라이언트 종료로 확인합니다. PostgreSQL·SQL UPDATE와 Python 예외 처리 기초가 필요합니다.
예상 100분입니다. 만료 전에 +시간을 눌러 세션을 연장하세요. 세션 종료 후 파일이 사라집니다. 필요한 코드는 따로 보관하세요.
환경과 데이터 계약
산출물은 /root/revision/worker.py입니다. Python 3·psycopg 3.2.3·PostgreSQL 16이 이미지에 준비되어 있으므로 인터넷 설치가 필요 없습니다. 컨테이너는 postgres 사용자로 실행되며 /root에 쓸 수 있도록 이미지에서 준비합니다. 사용자 전환이나 capability 추가는 하지 않습니다.
검사기는 실습용 labdb 안에 별도 임시 스키마와 아래 세 테이블을 만들고 자신이 만든 스키마만 정리합니다. 전역 public 테이블이나 원래 실습 데이터를 직접 변경하지 마세요. 제출 함수는 전달받은 con의 search_path를 그대로 사용하며 스키마·DSN·주문 값을 하드코딩하지 않습니다.
CREATE TABLE orders(id integer PRIMARY KEY, tenant text NOT NULL, qty integer NOT NULL CHECK(qty BETWEEN 1 AND 1000), state text NOT NULL CHECK(state IN ('pending','paid','cancelled')), revision integer NOT NULL CHECK(revision>=0));CREATE TABLE changes(change_id text PRIMARY KEY, tenant text NOT NULL, targets jsonb NOT NULL);CREATE TABLE audit(change_id text REFERENCES changes(change_id), id integer NOT NULL, previous_revision integer NOT NULL, new_revision integer NOT NULL, qty integer NOT NULL, PRIMARY KEY(change_id,id));식별자 tenant·change_id는 ASCII 영문·숫자·밑줄·하이픈 1–64자의 정확한 str입니다. 형식·범위 오류는 쓰기 전에 ValueError로 거부합니다. SQL 값은 문자열 조립이 아니라 매개변수로 전달합니다. 주문의 정상 변경은 revision을 증가시키며 changes·audit은 확정 후 수정하지 않는 계약입니다. 이 규칙을 무시하는 다른 프로그램의 쓰기까지 통제하는 권한 시스템은 아닙니다.
외부에서 빌려준 con은 autocommit=True·Read Committed이며 호출 시작 때 열린 트랜잭션이 없습니다. 함수는 빌린 연결을 닫지 않고 성공·실패 후 열린 트랜잭션을 남기지 않습니다. 내부 함수끼리 중첩 호출할 때는 바깥 트랜잭션을 보존합니다. change_file만 연결을 새로 소유합니다. DB 연결 오류·알 수 없는 예외는 성공으로 숨기지 않고 전달합니다.
단계
1. 승인 대상을 정규화한다 — worker.py에 Exception 하위 Conflict와 validate_targets(targets)를 구현하세요. targets는 정확한 list이며 1–16개입니다. 각 항목은 id·revision·qty만 가진 정확한 dict입니다. id는 int 1–2147483647, revision은 int 0–2147483646, qty는 int 1–1000이며 모두 bool을 거부합니다. 중복 ID와 형식·범위 오류는 ValueError입니다. ID 오름차순의 새 list와 새 dict들을 반환하고 입력은 바꾸지 않습니다.
2. 고객과 명시한 ID로 현재 상태를 읽는다 — preview(con,tenant,ids)는 아래 식별자 tenant와 정확한 list인 ids를 받습니다. ids는 중복 없는 정확한 int ID 1–2147483647을 1–16개 포함해야 하며 오류는 ValueError입니다. orders에서 해당 고객·명시한 ID·pending 상태를 한 SELECT로 읽어 ID순의 id·revision·qty dict 목록을 반환합니다. 대상 하나라도 없거나 다른 고객·상태라면 Conflict입니다. 반환 데이터도 validate_targets의 계약을 지키며 DB를 변경하지 않습니다.
3. 승인한 버전의 한 행만 취소한다 — cancel_one(con,tenant,target)는 식별자와 단일 승인 항목을 검증하고, UPDATE에서 id·tenant·revision·qty와 pending을 동시에 비교합니다. 일치하면 state=cancelled, revision=revision+1로 바꾸고 id·revision·qty·state dict를 반환합니다. 반환 행이 없으면 Conflict이고 다른 행은 변경하지 않습니다. 독립 호출은 한 트랜잭션으로 완료하며 아래 배치 함수 안에서도 바깥 트랜잭션을 조기 커밋하지 않아야 합니다.
4. 중간 충돌이면 앞선 변경도 롤백한다 — cancel_many(con,tenant,targets)는 모든 입력을 먼저 검증하고 ID순으로 각 대상을 취소하여 cancel_one 결과의 list를 반환합니다. 전체를 하나의 바깥 트랜잭션으로 처리하고 하나라도 실패하면 앞서 바꾼 행까지 전부 롤백한 뒤 원래 오류를 전달합니다. 감사나 영수증은 이 함수가 만들지 않습니다. 입력과 대조군은 보존합니다.
5. 승인 영수증·변경·감사를 함께 확정한다 — audited_cancel(con,change_id,tenant,targets,fault=None)는 식별자 두 개와 승인 집합을 검증합니다. 하나의 트랜잭션에서 changes에 change_id·tenant·정규화 targets를 넣고 전체 배치를 취소한 뒤 fault('after-update')를 호출합니다. 각 대상의 id·원래 revision·새 revision=원래+1·qty를 같은 change_id의 audit 행으로 넣고 fault('after-audit'), 전체 COMMIT 후 fault('after-commit')를 호출하며 True를 반환합니다. 기존 change_id에 같은 tenant·정규화 targets가 있으면 무변경 False이고 다른 내용은 Conflict입니다. 중복 반환에는 fault를 부르지 않습니다. 동시 같은 요청도 하나만 적용합니다. fault는 있을 때만 호출하며 커밋 전 오류는 전체 롤백, 커밋 후 오류는 확정 상태 보존과 원래 오류 전달입니다.
6. 그때 확정한 승인 내용을 조회한다 — inspect_change(con,change_id)는 식별자를 검증하고 changes의 영수증이 없으면 None, 있으면 change_id·tenant·targets dict를 반환합니다. targets는 저장된 승인 당시 내용이며 이후 orders 변경이나 반환값 수정으로 저장된 영수증을 바꾸지 않습니다. 이 함수는 현재 orders 상태를 승인 내용으로 덮어 읽지 않습니다.
7. 같은 건수 대신 같은 대상을 확인한다 — reconcile(con,change_id)는 영수증이 없으면 Conflict입니다. 영수증의 ID들을 현재 orders의 한 SELECT로 조회해 matching·drifted·missing 세 키의 ID list를 반환합니다. 존재하지 않으면 missing, tenant가 당시 고객이고 revision=승인+1·qty=승인 수량·state=cancelled가 모두 맞으면 matching, 나머지는 drifted입니다. 각 list는 ID순이고 영수증에 없는 ID는 넣지 않습니다. 읽기만 수행하며 현재 행이나 감사·영수증을 고치지 않습니다.
8. 응답을 잃은 변경을 같은 ID로 재개한다 — change_file(dsn,change_id,tenant,targets,fault=None)는 psycopg.connect(dsn,autocommit=True,connect_timeout=2)로 자기 연결을 열고 audited_cancel 결과를 반환하며 성공·실패 모두 연결을 닫습니다. DSN은 검사기가 넘기는 신뢰한 일회용 로컬 DB 연결 설정입니다. 최종 검사는 커밋 전후 실제 클라이언트 프로세스 종료 뒤 재접속, 같은 변경의 동시 요청, 실제 행 잠금 대기 뒤 조건 재평가를 수행합니다. DB 서버 자체는 종료하지 않습니다.
참고
- 직접 진단: python3 -B /opt/lab/fixtures/revision/check.py 8 /root/revision/worker.py. 8을 현재 단계로 바꾸면 그 단계까지 검사합니다. 한 채점의 상한은 30초이며 제출 파일은 바꾸지 않습니다.
- 명시한 대상만 적용하고 대조군을 보존하세요. 실패한 승인의 버전을 현재 값으로 자동 보정하지 않습니다. None인 영수증과 현재 누락 행은 서로 다른 상태입니다.
- 이 실습은 권한 있는 담당자가 로컬 시험 DB에서 호출하는 전제입니다. 실제 고객 정보·외부 DB를 넣지 마세요. 로그인·고객별 권한·운영 감사 보존 정책은 별도입니다.
- 기존 DB 이미지의 fsync와 full_page_writes는 꺼져 있습니다. 살아 있는 서버에 붙은 클라이언트 프로세스 종료를 검증하는 것이며 서버 전원 차단·디스크 손상·운영 백업 복원 보장이 아닙니다.
단계 8개
- 승인 대상을 정규화한다
- 고객과 명시한 ID로 현재 상태를 읽는다
- 승인한 버전의 한 행만 취소한다
- 중간 충돌이면 앞선 변경도 롤백한다
- 승인 영수증·변경·감사를 함께 확정한다
- 그때 확정한 승인 내용을 조회한다
- 같은 건수 대신 같은 대상을 확인한다
- 응답을 잃은 변경을 같은 ID로 재개한다