Skip to content

[CBRD-26609] implement OOS delete API#6909

Merged
vimkim merged 12 commits intoCUBRID:feat/oosfrom
vimkim:cbrd-26609-oos-delete
Mar 31, 2026
Merged

[CBRD-26609] implement OOS delete API#6909
vimkim merged 12 commits intoCUBRID:feat/oosfrom
vimkim:cbrd-26609-oos-delete

Conversation

@vimkim
Copy link
Copy Markdown
Contributor

@vimkim vimkim commented Mar 16, 2026

http://jira.cubrid.org/browse/CBRD-26609

Description

Milestone 1에서 OOS insert/read는 구현되어 있으나, OOS 레코드를 물리적으로 삭제하는 oos_delete API가 없었다.

이로 인해 아래 두 경로에서 OOS 레코드가 영구히 잔존하는 문제가 있었다.

경로 문제
UPDATE 이전 버전 OOS 레코드가 삭제되지 않고 남음
DELETE + vacuum vacuum이 heap record를 정리하더라도 OOS 레코드가 orphan으로 잔존

spage_delete를 통해 OOS 페이지의 슬롯을 물리적으로 삭제하고, 페이지의 total_free를 회수하는 oos_delete API를 구현한다.


Implementation

신규 함수

함수 파일 설명
oos_delete src/storage/oos_file.cpp OOS 레코드 물리 삭제 (public API)
oos_log_delete_physical src/storage/oos_file.cpp OOS 삭제 WAL 로깅 내부 헬퍼 (static)

oos_delete 동작 흐름

Multi-chunk OOS 레코드(across-pages)를 지원하기 위해 next_chunk_oid 체인을 순회하며 모든 청크를 순서대로 삭제한다.

while (current_oid.pageid != NULL_PAGEID):
    pgbuf_fix (WRITE latch)
    spage_get_record (PEEK) → OOS_RECORD_HEADER에서 next_chunk_oid 확인
    oos_log_delete_physical 호출 (WAL 선행 기록)
    spage_delete → total_free 증가
    pgbuf_set_dirty + pgbuf_unfix
    current_oid = next_chunk_oid

WAL 로깅 설계

RVOOS_DELETE 로그 레코드는 이미 recovery.h에 정의되어 있으며, 핸들러도 등록되어 있다.

필드 내용
log_addr.pgptr 삭제 대상 OOS 페이지
log_addr.offset slotid (redo 시 oos_rv_redo_delete가 이 값 사용)
undo data 원본 RECDES (rollback 시 oos_rv_redo_insert로 레코드 재삽입)
redo data 없음 (slotid만으로 redo 가능)

기존 recovery 테이블에 등록된 핸들러를 그대로 활용한다:

  • undo: oos_rv_redo_insert (원본 레코드 재삽입)
  • redo: oos_rv_redo_delete (슬롯 삭제)

단위 테스트 (unit_tests/oos/test_oos_delete.cpp)

테스트 검증 내용
OosDeleteBasic oos_delete 성공, 삭제 후 페이지 free space 증가 확인
OosDeleteThenReadFails 삭제된 OID로 oos_read 시 에러 반환 확인
OosDeleteMultiChunk 2-chunk 레코드 삭제 시 양쪽 페이지 모두 free space 증가 확인
OosUpdatePattern UPDATE 패턴 시뮬레이션: 이전 레코드 삭제 후 새 레코드 정상 읽기, 이전 OID 읽기 실패 확인
OosDeleteRestoresFreeSpace 삭제 후 레코드 데이터 크기만큼 free space 회수 확인
OosDeleteLarge160KBMultiChunk 160KB multi-chunk 레코드 삭제 후 head OID 읽기 실패 확인
image

모두 통과한 모습


Remarks

  • spage_delete는 레코드 데이터는 해제하지만 슬롯 엔트리(SPAGE_SLOT, 4 bytes)는 REC_DELETED_WILL_REUSE 상태로 남긴다. 추후 spage_compact를 통한 in-page compaction에서 이 공간을 재활용할 수 있다.
  • 호출 경로 연결(heap_updateoos_delete, vacuum_heapoos_delete)은 이 PR 범위에 포함되지 않는다. 해당 연결은 상위 스토리에서 진행한다.
  • 해당 PR 댓글로 남은 주요 분석 글들은 다음 링크에 문서화되었습니다.

@github-actions
Copy link
Copy Markdown

🧪 TC Test Environment Ready

CircleCI Testing:

  • CircleCI will automatically test using the branches below.

TC Repositories & Branches:

Next Steps:

  1. Wait for CircleCI tests to complete
  2. If CircleCI tests failed, please check the test results and fix the issues.
  3. When ready to merge this PR, please merge the TC PR first, then merge this PR.

@vimkim
Copy link
Copy Markdown
Contributor Author

vimkim commented Mar 16, 2026

/run sql medium

Copy link
Copy Markdown
Contributor Author

@vimkim vimkim left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

전체적인 구현 리뷰입니다. 주요 포인트를 각 라인 코멘트로 남겼습니다.

Comment thread src/storage/oos_file.cpp
static void
oos_log_delete_physical (THREAD_ENTRY *thread_p, PAGE_PTR page_p, PGSLOTID slotid, RECDES *recdes_p)
{
LOG_DATA_ADDR log_addr;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WAL 설계] undo/redo 역할 분담

RVOOS_DELETE 로그의 undo/redo 구성:

  • undo data (recdes_p) — rollback 시 oos_rv_redo_insert가 이 레코드를 그대로 재삽입하여 삭제 이전 상태로 복원
  • redo data (NULL) — crash recovery 시 oos_rv_redo_deletercv->offset(= slotid)만으로 spage_delete를 재실행할 수 있으므로 별도 데이터 불필요

recovery.c 테이블에 이미 등록된 핸들러:

RVOOS_DELETE → undo: oos_rv_redo_insert / redo: oos_rv_redo_delete

신규 핸들러 추가 없이 기존 인프라를 그대로 활용한다.

Comment thread src/storage/oos_file.cpp Outdated
}

int
oos_delete (THREAD_ENTRY *thread_p, const VFID &oos_vfid, const OID &oid)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[oos_delete] multi-chunk 체인 순회

OOS 레코드가 여러 페이지에 걸쳐 저장된 경우(across-pages), next_chunk_oid를 따라 연결된 모든 청크를 순서대로 삭제한다.

체인 종료 조건: next_chunk_oid.pageid == NULL_PAGEID (마지막 청크의 헤더에 저장된 값)

반복 흐름:

head → chunk[0] → chunk[1] → ... → chunk[N] (next=NULL)

각 청크를 독립적으로 fix → log → delete → unfix 처리한다.

Comment thread src/storage/oos_file.cpp
return ER_FAILED;
}

scope_exit page_unfixer ([&] ()
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[RAII] scope_exit로 페이지 unfix 보장

scope_exit를 사용하여 이후 경로에서 에러가 발생하더라도 반드시 pgbuf_unfix가 호출되도록 한다.

pgbuf_unfix_and_init_after_check는 unfix 후 포인터를 nullptr로 초기화하여 dangling pointer 접근을 방지한다.

Comment thread src/storage/oos_file.cpp Outdated
});

RECDES recdes_with_header;
SCAN_CODE code = spage_get_record (thread_p, page_ptr, slotid, &recdes_with_header, PEEK);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[순서] PEEK → next_chunk_oid 확보 → log → delete

spage_delete 호출 이전에 반드시 spage_get_record(PEEK)를 먼저 수행해야 한다.

이유: OOS_RECORD_HEADER 안에 있는 next_chunk_oid는 삭제 후에는 읽을 수 없다. PEEK로 헤더를 미리 복사해 두어야 다음 청크로 이동할 수 있다.

또한 PEEK로 가져온 recdes_with_header가 WAL undo data로 그대로 전달되므로, 별도 복사 없이 효율적으로 처리된다.

Comment thread src/storage/oos_file.cpp Outdated
std::memcpy (&header, recdes_with_header.data, sizeof (OOS_RECORD_HEADER));
OID next_chunk_oid = header.next_chunk_oid;

oos_log_delete_physical (thread_p, page_ptr, slotid, &recdes_with_header);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WAL 원칙] 로그는 반드시 실제 변경 이전에 기록

spage_delete 호출 전에 oos_log_delete_physical을 먼저 호출하는 것은 WAL(Write-Ahead Logging) 원칙을 지키기 위함이다.

crash가 로그 기록과 spage_delete 사이에 발생하면: redo 로그가 없으므로 삭제가 재실행되지 않고 레코드가 보존됨 → 안전
crash가 spage_delete 이후에 발생하면: redo 로그로 spage_delete를 재실행하여 일관성 유지

Comment thread src/storage/oos_file.hpp
extern int oos_file_destroy (THREAD_ENTRY *thread_p, const VFID &oos_vfid);
extern int oos_insert (THREAD_ENTRY *thread_p, const VFID &oos_vfid, RECDES &recdes, OID &oid);
extern int oos_read (THREAD_ENTRY *thread_p, const OID &oid, RECDES &recdes);
extern int oos_delete (THREAD_ENTRY *thread_p, const VFID &oos_vfid, const OID &oid);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[API] oos_vfid 파라미터 용도

현재 oos_delete 구현 내에서 oos_vfid는 직접 사용되지 않는다.

추후 확장 가능성을 위해 시그니처에 포함하였다 (예: 특정 VFID에 속한 페이지임을 검증하거나, 파일 레벨 통계 업데이트 등).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 코멘트 이후 수정 반영됨:

oos_log_delete_physicalVFID *vfid_p 파라미터를 추가하고, oos_delete 호출부에서 const_cast<VFID *>(&oos_vfid)를 전달하도록 변경.

이제 oos_vfid가 실제로 WAL 로그에 기록되며, oos_log_insert_physical과 대칭적인 구조가 됨.


// Peek the header of the first chunk to find the next chunk OID
OOS_RECORD_HEADER head_header{};
err = peek_oos_header (head_oid, head_header);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[테스트 설계] 삭제 전에 next_chunk_oid를 미리 확보

oos_delete 호출 후에는 청크가 이미 삭제되어 헤더를 읽을 수 없다.

따라서 삭제 전에 peek_oos_headernext_chunk_oid를 먼저 가져와 두고, 삭제 후 두 페이지(head, next)의 free space 변화를 각각 검증한다.

err = oos_delete (thread_p, oos_vfid, target_oid);
ASSERT_EQ (err, NO_ERROR);

int free_after_delete = get_free_space_of_oid_page (target_oid);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[주의] spage_delete 후 free space가 완전히 복원되지 않는 이유

spage_insert는 레코드 데이터 + 슬롯 엔트리(SPAGE_SLOT, 4 bytes)를 함께 소비한다.

반면 spage_delete는 레코드 데이터만 total_free에 반환하고, 슬롯 엔트리는 REC_DELETED_WILL_REUSE 상태의 tombstone으로 남겨 재사용 대기 상태로 전환한다.

따라서 이 테스트에서는 "레코드 데이터 크기만큼 복구되었는가"를 검증하며, 완전한 free space 복원은 spage_compact 이후에 가능하다.

@vimkim vimkim requested review from a team, H2SU, InChiJun, YeunjunLee, hgryoo, hornetmj, hyahong and youngjun9072 and removed request for a team March 17, 2026 11:21
@vimkim
Copy link
Copy Markdown
Contributor Author

vimkim commented Mar 23, 2026

PR #6909 코드 리뷰: oos_delete API 구현

전체 요약

OOS(Out-Of-Slot) 레코드의 물리적 삭제 API를 구현한 PR입니다. 변경은 4개 파일, +478줄이며 핵심은 oos_delete() 함수와 WAL 로깅 함수 oos_log_delete_physical(), 그리고 6개의 단위 테스트입니다.


1. oos_delete — 핵심 로직 분석

동작 원리

oos_delete(thread_p, oos_vfid, oid)
  │
  ├─ current_oid = oid (head chunk)
  │
  └─ while (current_oid.pageid != NULL_PAGEID)
       ├─ pgbuf_fix(WRITE latch)     ← 페이지를 exclusive lock으로 고정
       ├─ spage_get_record(PEEK)     ← 슬롯에서 레코드를 zero-copy로 읽음
       ├─ header에서 next_chunk_oid 추출
       ├─ oos_log_delete_physical()  ← WAL: undo 데이터(삭제 전 레코드) 기록
       ├─ spage_delete()             ← 슬롯을 물리적으로 삭제, free space 회수
       ├─ pgbuf_set_dirty()          ← 더티 마킹
       └─ current_oid = next_chunk_oid (다음 chunk으로 이동)

왜 동작하는가:

  • OOS 레코드는 linked list 구조. 각 chunk의 OOS_RECORD_HEADER.next_chunk_oid가 다음 chunk을 가리키고, 마지막 chunk은 NULL_PAGEID를 가짐.
  • spage_get_record(PEEK)로 읽은 데이터에서 header만 복사하여 next 포인터를 삭제 전에 확보. 이후 spage_delete로 현재 슬롯을 삭제해도 next 정보는 이미 로컬 변수에 있으므로 chain 순회가 안전.
  • scope_exit로 페이지 unfix를 보장하여, 에러 리턴 시에도 리소스 누수가 없음.

2. WAL 로깅 (oos_log_delete_physical)

log_append_undoredo_recdes(thread_p, RVOOS_DELETE, &log_addr, recdes_p, NULL);
//                                                  undo=recdes  redo=NULL
undo (rollback) redo (crash recovery)
RVOOS_INSERT undo=NULL, redo=recdes undo→delete, redo→insert
RVOOS_DELETE undo=recdes, redo=NULL undo→insert, redo→delete

recovery.c의 등록 테이블:

{RVOOS_DELETE, "RVOOS_DELETE", oos_rv_redo_insert, oos_rv_redo_delete, NULL, NULL}
  • Redo (크래시 복구): oos_rv_redo_deletespage_delete 수행 — 커밋된 삭제를 재적용
  • Undo (롤백): oos_rv_redo_insertspage_insert_for_recovery로 레코드 복원

INSERT와 DELETE가 정확히 역연산 관계이므로 recovery 함수를 교차 사용하는 것이 올바름.


3. 발견 사항

[Medium] oos_vfid 파라미터 미사용

oos_delete 시그니처에 const VFID &oos_vfid가 있지만, 함수 본문에서 전혀 사용되지 않음. oos_log_delete_physical에서도 log_addr.vfid = NULL로 설정.

비교: oos_log_insert_physical에서는 log_addr.vfid = vfid_p로 VFID를 설정.

확인 필요: DELETE 로깅에서 vfid가 NULL인 것이 의도적인지? INSERT와 대칭적으로 VFID를 기록해야 recovery나 replication에서 필요할 수 있음. 의도적으로 불필요하다면 코멘트가 있으면 좋겠음.

[Low] 멀티 chunk 삭제 시 atomicity

각 chunk을 개별적으로 fix→log→delete→unfix. 중간 chunk 삭제 후 크래시 시:

  • 커밋 전 크래시: 트랜잭션 rollback 시 undo 로그로 각 chunk 복원 → 안전
  • 커밋 후 크래시: redo 로그로 남은 chunk들도 삭제 → 안전

WAL의 undo/redo가 chunk 단위로 기록되므로 정확히 동작.

[Info] 테스트 커버리지 — 양호

테스트 검증 내용
OosDeleteBasic 단일 chunk 삭제 후 free space 증가
OosDeleteThenReadFails 삭제 후 read 실패 확인
OosDeleteMultiChunk 2-chunk 레코드의 양쪽 페이지 free space 회수
OosUpdatePattern UPDATE 시나리오 (insert new → delete old)
OosDeleteRestoresFreeSpace free space 정밀 비교 (slot tombstone 고려)
OosDeleteLarge160KBMultiChunk 160KB 대형 레코드 (다수 chunk) 삭제

4. 결론

코드 품질이 높고, 기존 oos_insert/oos_read와 일관된 패턴을 따름. WAL 로깅의 undo/redo 대칭성이 정확하고, scope_exit를 통한 리소스 관리도 깔끔.

주요 확인 필요 사항:

  1. log_addr.vfid = NULL — DELETE에서 VFID를 기록하지 않는 것이 의도적인지 (INSERT와의 비대칭)
  2. oos_vfid 파라미터가 미사용인 채로 남아도 되는지 (향후 확장용?)

@vimkim
Copy link
Copy Markdown
Contributor Author

vimkim commented Mar 24, 2026

예상 리뷰 질문 정리

이 PR에 대해 리뷰어 분들이 질문하실 수 있는 내용을 미리 정리했습니다.


1. 동시성/Locking

  • multi-chunk 삭제 중 chunk 간에 latch를 잡지 않는데, 다른 트랜잭션이 같은 체인을 동시에 삭제하면 어떻게 되나요?

    • 현재 chunk 단위로 fix→delete→unfix를 수행하므로, chunk A unfix 후 chunk B fix 전에 다른 트랜잭션이 끼어들 여지가 있습니다. 상위 호출자(heap layer)에서 row-level lock으로 보호되는지 설명이 필요할 수 있습니다.
  • PEEK으로 읽은 recdes를 undo data로 넘기는데, page latch가 유지되고 있어 안전한 것이 맞는지?

    • WRITE latch 하에서 PEEK → log → delete 순서이므로 안전합니다.

2. Recovery/WAL

  • 커밋 전 크래시에서 chunk 1은 undo 완료되었고 chunk 2는 아직 삭제되지 않았다면, rollback 시 체인 정합성은 어떻게 보장되나요?

    • chunk 1의 undo가 oos_rv_redo_insert로 원본 레코드(next_chunk_oid 포함)를 재삽입하므로 체인이 복원됩니다.
  • const_cast<VFID *>(&oos_vfid) — const를 벗기는 이유가 무엇인가요?

    • 기존 로깅 API(log_append_undoredo_recdes)가 non-const VFID *를 받기 때문입니다. 실제 수정은 발생하지 않으며, CUBRID 코드베이스 전반의 관행입니다.

3. 에러 처리

  • multi-chunk 삭제 도중 중간 chunk에서 pgbuf_fix가 실패하면, 이미 삭제한 앞쪽 chunk들은 어떻게 되나요?

    • ER_FAILED를 리턴하지만 이미 삭제된 chunk들의 WAL이 남아있으므로, rollback 시 undo로 복원됩니다.
  • spage_delete 리턴값을 (void)로 무시하는데, 실패할 수 있지 않나요?

    • 직전에 동일 WRITE latch 하에서 spage_get_record(PEEK)S_SUCCESS를 리턴했으므로, 해당 슬롯이 유효함이 보장됩니다. spage_delete 내부에서 spage_find_slot이 NULL을 리턴하는 경우는 (1) slotid 범위 초과 (2) 이미 삭제된 슬롯(SPAGE_EMPTY_OFFSET) (3) 페이지 손상인데, PEEK 성공 직후이므로 이 조건들에 도달하지 않습니다. 나머지 실패 경로(anchor_type 이상, spage_save_space 실패)도 정상적인 OOS 페이지에서는 발생하지 않습니다. 다만 "절대 실패할 수 없다"보다는, OOS 페이지 정합성이 유지되는 한 실패하지 않는다가 정확한 표현입니다.

4. 설계/API

  • oos_vfid를 파라미터로 받지만 WAL 로깅 외에는 사용하지 않는데, page에서 VFID를 얻을 수는 없나요?

    • 최신 커밋(c58a22c)에서 WAL에 VFID를 기록하도록 수정했으므로 현재는 사용되고 있습니다. 다만 이 page가 해당 VFID 소속인지에 대한 validation은 수행하지 않습니다.
  • 현재 이 함수를 호출하는 곳이 없는데, dead code가 아닌가요?

    • 호출 경로 연결(heap_updateoos_delete, vacuumoos_delete)은 상위 스토리에서 진행 예정입니다.

5. 테스트

  • recovery 테스트(undo/redo)가 없는데, crash 시나리오는 어떻게 검증하나요?

    • 단위 테스트는 정상 경로만 커버하고 있으며, recovery path 검증은 integration test나 별도 테스트에서 진행이 필요합니다.
  • 삭제 후 REC_DELETED_WILL_REUSE 상태인지 명시적으로 확인하면 더 정확하지 않을까요?

    • 현재는 oos_read 실패 여부로만 검증하고 있습니다. 슬롯 상태 확인을 추가하면 더 정밀한 테스트가 될 수 있습니다.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vimkim
Copy link
Copy Markdown
Contributor Author

vimkim commented Mar 24, 2026

삭제 후 슬롯의 record type 변화를 검증하는 테스트(OosDeleteSlotBecomesUnknown)를 추가했습니다.

  • 삭제 전: spage_get_record_type이 유효한 타입(!= REC_UNKNOWN)을 리턴하는지 확인
  • 삭제 후: spage_get_record_typeREC_UNKNOWN을 리턴하는지 확인

참고: spage_delete는 내부적으로 슬롯을 REC_DELETED_WILL_REUSE로 마킹하지만, spage_get_record_type API는 삭제된 슬롯에 대해 REC_UNKNOWN을 리턴하도록 설계되어 있어 해당 값으로 검증합니다.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vimkim
Copy link
Copy Markdown
Contributor Author

vimkim commented Mar 24, 2026

oos_log_delete_physical WAL 로깅 검증

oos_log_delete_physical의 로깅 로직이 올바른지 recovery 테이블과 대조하여 검증했습니다.


1. log_append_undoredo_recdes 파라미터 순서

log_append_undoredo_recdes(thread_p, rcvindex, &log_addr, undo_recdes, redo_recdes);
//                                              4th=undo   5th=redo

2. INSERT vs DELETE 로깅 비교

oos_log_insert_physical oos_log_delete_physical
rcvindex RVOOS_INSERT RVOOS_DELETE
undo data (4th) NULL recdes_p (삭제 전 원본 레코드)
redo data (5th) recdes_p (삽입할 레코드) NULL
log_addr.offset oid_p->slotid slotid
log_addr.vfid vfid_p vfid_p
log_addr.pgptr page_p page_p

INSERT와 DELETE가 정확히 역대칭 관계입니다.

3. Recovery 테이블 (recovery.c) 검증

// struct rvfun { rcvindex, string, undofun, redofun, ... }
{RVOOS_INSERT, "RVOOS_INSERT", oos_rv_redo_delete, oos_rv_redo_insert, NULL, NULL},
{RVOOS_DELETE, "RVOOS_DELETE", oos_rv_redo_insert, oos_rv_redo_delete, NULL, NULL},
시나리오 RVOOS_DELETE 핸들러 동작 데이터 소스
Undo (rollback) oos_rv_redo_insert spage_insert_for_recovery(rcv->pgptr, rcv->offset, rcv->data) undo data = 원본 레코드 (recdes_p)
Redo (crash recovery) oos_rv_redo_delete spage_delete(rcv->pgptr, rcv->offset) slotid만 필요, redo data = NULL

4. 데이터 흐름 일관성 검증

Undo 경로 (rollback 시):

  • oos_log_delete_physical이 undo data로 저장한 recdes_pspage_get_record(PEEK)로 얻은 원본 레코드 (헤더 + 데이터 전체)
  • oos_rv_redo_insertrcv->data에서 recdes.type (2 bytes) + 레코드 본문을 파싱 → spage_insert_for_recovery로 해당 slotid에 재삽입
  • recdes_pspage가 내부적으로 type 필드를 포함하여 로그에 기록하므로, 복원 시 타입 정보도 정확히 보존됨 ✅

Redo 경로 (crash recovery 시):

  • redo data = NULL이므로 rcv->data는 비어있음
  • oos_rv_redo_deletercv->offset (= slotid)만으로 spage_delete 수행 — 추가 데이터 불필요 ✅

Multi-chunk 삭제 시:

  • 각 chunk마다 독립적으로 oos_log_delete_physical을 호출하여 chunk별 undo/redo 로그가 생성됨
  • rollback 시 역순으로 각 chunk의 undo가 실행되어 체인이 복원됨 (각 chunk의 원본 레코드에 next_chunk_oid가 포함되어 있으므로 체인 정합성 유지) ✅

5. 결론

oos_log_delete_physical의 로깅은 올바릅니다.

  • undo/redo 데이터 할당이 RVOOS_INSERT와 정확히 역대칭
  • recovery 테이블의 핸들러가 올바른 함수에 매핑
  • 핸들러들이 참조하는 데이터(rcv->offset, rcv->data)와 로깅 시 저장하는 데이터가 일치

@vimkim
Copy link
Copy Markdown
Contributor Author

vimkim commented Mar 24, 2026

Multi-page OOS record 로깅/복구 관련 추가 작업 정리

해당 내용은 희수님의 자문으로 작성되었습니다. @H2SU

현재 PR에서 oos_delete는 chunk 단위로 개별 로깅(oos_log_delete_physical)을 수행하고 있습니다.
여러 페이지에 걸친 OOS record의 경우, 현재 구조에서는 chain 전체를 하나의 논리적 작업으로 인식할 수 있는 방법이 없습니다.

향후 아래 작업들이 추가로 필요할 수 있습니다.


1. Chain Marker (Dummy Log) 도입

overflow file의 LOG_DUMMY_OVF_RECORD 패턴을 참고하여:

  • multi-page OOS chain delete 시작/종료를 표시하는 dummy log를 앞뒤에 삽입
  • recovery나 vacuum에서 해당 marker 유무로 single-chunk vs multi-chunk 작업을 구분
[OOS_CHAIN_START] → [chunk0 delete log] → [chunk1 delete log] → ... → [OOS_CHAIN_END]

2. Recovery 경로 수정

현재 oos_rv_redo_delete / oos_rv_redo_insert는 단일 slot 단위로 동작합니다.
chain marker가 도입되면:

  • Undo: chain delete의 undo 시 모든 chunk를 복원
  • Redo: chain delete의 redo 시 chain 전체를 삭제
  • crash 시점이 chain 중간인 경우의 partial recovery 처리

3. Vacuum 연동

vacuum이 delete undo log를 읽고 OOS 삭제 작업을 수행할 때, chain marker를 확인하여 multi-page OOS record의 모든 페이지를 정리해야 합니다.

4. 수정 대상 파일 (예상)

파일 변경 내용
src/transaction/recovery.h chain marker용 recovery index 추가
src/transaction/recovery.c RV_fun[] 테이블에 chain marker handler 등록
src/storage/oos_file.cpp oos_delete()에 chain dummy log 삽입, recovery 함수 수정
src/storage/oos_file.hpp 새 함수 선언

참고: overflow file(overflow_file.c:202)에서 log_append_empty_record(thread_p, LOG_DUMMY_OVF_RECORD, &addr) 패턴이 이미 존재하므로, 이를 참고하여 OOS chain log를 설계할 수 있습니다.

@vimkim vimkim marked this pull request as ready for review March 24, 2026 12:41
@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 24, 2026

Reviews (1): Last reviewed commit: "chore(oos_delete): add TODO comments for..." | Re-trigger Greptile

Comment thread src/storage/oos_file.cpp Outdated
Comment thread src/storage/oos_file.cpp
Comment thread src/storage/oos_file.cpp
@vimkim
Copy link
Copy Markdown
Contributor Author

vimkim commented Mar 24, 2026

Claude Code Opus 4.6 으로 작성된 리뷰입니다.


PR Review: CBRD-26609 — OOS delete API 구현

요약

oos_delete()는 chain traversal과 chunk 단위 삭제를 WAL 로깅 및 unit test와 함께 구현했습니다. 기본 구조는 탄탄하지만, crash recovery 보장 부재부터 에러 전파 누락까지 여러 정확성/견고성 이슈가 있습니다.


발견 사항

[CRITICAL] Multi-page delete가 atomic하지 않음 — crash 시 orphaned chunk 발생

파일: src/storage/oos_file.cpp:640-689

문제: oos_delete()는 각 chunk를 개별적으로 로깅/삭제합니다. 서버가 chunk 0 삭제 후 chunk 1 삭제 전에 crash되면:

  • Chunk 0은 삭제됨 (slot 없음)
  • Chunk 1+는 여전히 존재하지만 접근 불가 (parent pointer 없음)
  • 이 orphaned page들은 영원히 회수 불가 — 영구 공간 누수

insert 경로(oos_insert_across_pages)에도 비슷한 TODO가 있지만(239행), 실패한 insert는 트랜잭션 rollback이 가능합니다. commit된 delete가 chain 중간에서 crash되면 양쪽 상태(전체삭제/미삭제) 모두 복구 불가합니다.

제안: multi-chunk delete를 log_sysop_start/commit 쌍으로 감싸거나(oos_file_alloc_new가 508-520행에서 하는 것처럼), chain marker dummy log를 도입하여 recovery가 partial chain delete를 감지하고 완료할 수 있도록 해야 합니다. sysop 없이는 multi-page OOS record에 대한 crash safety가 보장되지 않습니다.


[CRITICAL] oos_delete가 page를 deallocate하지 않음 — 파일 크기 무한 증가

파일: src/storage/oos_file.cpp:678

문제: spage_delete()로 slot만 제거하고, file_dealloc()으로 page를 file manager에 반환하지 않습니다. overflow_delete_internal()(overflow_file.c:622)과 비교:

ret = file_dealloc(thread_p, ovf_vfid, vpid, FILE_UNKNOWN_TYPE);

oos_delete 이후 OOS 파일의 page 수는 증가만 합니다. insert/delete 반복 시 OOS 파일이 무한히 비대해집니다.

제안: slot 삭제 후 page가 비었는지 확인(spage_number_of_records() == 0). 비었으면 unfix 후 sysop 내에서 file_dealloc() 호출. 다른 OOS record가 공유하는 page면 slot 재사용을 위해 유지.


[MAJOR] spage_delete() 반환값을 무시

파일: src/storage/oos_file.cpp:678

(void) spage_delete (thread_p, page_ptr, slotid);

문제: spage_deletePGSLOTID를 반환하며, 실패 시 NULL_SLOTID입니다. (void) 캐스트로 명시적으로 무시합니다. slot이 이미 삭제된 상태(race condition, corruption)라면 발생하지 않은 삭제를 로깅하여 WAL을 손상시킵니다.

recovery 함수 oos_rv_redo_delete(708행)도 동일하게 반환값을 무시합니다.

제안: 반환값 확인. NULL_SLOTID이면 debug에서 assert, release에서 에러 반환:

PGSLOTID deleted = spage_delete(thread_p, page_ptr, slotid);
assert(deleted != NULL_SLOTID);

[MAJOR] 에러 반환 시 이미 삭제된 chunk의 rollback 없음

파일: src/storage/oos_file.cpp:652-668

문제: chunk N에서 pgbuf_fix 실패(652행) 또는 spage_get_record 실패(665행) 시 즉시 ER_FAILED를 반환합니다. 하지만 chunk 0..N-1은 이미 삭제 및 로깅 완료. 부분 완료된 작업의 undo/rollback이 없습니다. 호출자는 에러를 받지만 데이터는 이미 부분 파괴된 상태입니다.

제안: multi-chunk delete 전체를 system operation(sysop)으로 감싸서 부분 실패 시 자동 rollback되게 하거나, 부분 실패 시 corrupted state가 된다는 것을 문서화해야 합니다.


[MAJOR] ER_FAILED 반환 시 er_set 미호출 — CUBRID 에러 프로토콜 위반

파일: src/storage/oos_file.cpp:655,668

문제: CUBRID 컨벤션상 에러 코드 반환 전 er_set()을 호출하여 er_errid()가 올바른 에러를 반환해야 합니다. 현재는:

oos_error("...");
return ER_FAILED;

oos_error는 디버그 로그 매크로이지 er_set이 아닙니다. 이 함수 실패 후 er_errid()를 확인하는 호출자는 잘못된/오래된 에러 정보를 받게 됩니다.

oos_rv_redo_insert(736행)는 올바르게 er_set(ER_FATAL_ERROR_SEVERITY, ...)을 호출합니다.


[MAJOR] 루프 종료 조건이 pageid != NULL_PAGEID에 의존 — OID_INITIALIZER 확인 필요

파일: src/storage/oos_file.cpp:646

while (current_oid.pageid != NULL_PAGEID)

문제: 마지막 chunk의 next_chunk_oidOID_INITIALIZER(insert 시 215행에서 설정). OID_INITIALIZERpageidNULL_PAGEID로 설정하는지 확인 필요. 만약 OID_INITIALIZER{0, 0, 0}이고 NULL_PAGEID-1이면 이 루프는 종료되지 않고 garbage page를 읽다가 crash합니다.

제안: OID_ISNULL() 매크로 사용 — CUBRID에서 null OID 확인의 표준 방법:

while (!OID_ISNULL(&current_oid))

[MINOR] const_cast<VFID *>(&oos_vfid) — 불필요하며 위험 신호

파일: src/storage/oos_file.cpp:676

문제: LOG_DATA_ADDR.vfid가 non-const 포인터이기 때문에 발생하는 캐스트. log_append_undoredo_recdes가 VFID를 수정하면 undefined behavior입니다. insert 경로(605행)에서도 동일한 패턴이 있어 상속된 것이지만 기록해 둡니다.


[MINOR] RECDES recdes_with_header가 초기화되지 않음

파일: src/storage/oos_file.cpp:663

문제: RECDES는 생성자가 없는 C struct. spage_get_recordPEEK으로 포인터를 채우지만, 실패 시 초기화되지 않은 메모리에 접근할 수 있습니다.

제안: 값 초기화 사용: RECDES recdes_with_header = RECDES_INITIALIZER;


[MINOR] structured binding이 OID 필드 순서에 의존

파일: src/storage/oos_file.cpp:648

const auto [pageid, slotid, volid] = current_oid;

문제: OID의 필드 순서가 {pageid, slotid, volid}라고 가정합니다. 누군가 OID struct 필드를 재배치하면 컴파일러 경고 없이 잘못된 값이 할당됩니다. C/C++ 혼합 코드베이스에서 유지보수 위험요소입니다.

제안: 명시적 필드 접근 사용: current_oid.pageid, current_oid.slotid, current_oid.volid


[MINOR] Unit test가 에러 경로를 테스트하지 않음

파일: unit_tests/oos/test_oos_delete.cpp

문제: 6개 테스트 모두 happy path만 커버. 테스트 누락 항목:

  • 잘못된 OID로 삭제 (bad pageid/slotid)
  • 이미 삭제된 OID 재삭제 (double-free)
  • pgbuf_fix 실패 시 동작 (시뮬레이션된 I/O 에러)
  • Recovery: multi-chunk delete 중 crash 후 redo/undo

460행의 TODO에서 recovery 테스트 누락을 인정하고 있지만, 에러 경로 테스트도 동일하게 중요합니다.


긍정적 관찰

  • chain traversal 로직이 깔끔 — while loop + next_chunk_oid가 단순하고 읽기 좋음
  • WAL 프로토콜 준수: 물리적 삭제 전 로깅, 수정 후 dirty 마킹
  • scope_exit를 이용한 page unfix — 좋은 RAII 실천
  • free space 검증을 포함한 포괄적인 happy-path unit test
  • 디버그 로깅이 각 단계에서 OID 상세정보를 포함하여 충실함

저자에게 질문

  1. Page deallocation: 의도적으로 후속 PR로 미룬 것인지? file_dealloc 없이는 OOS 파일이 절대 축소되지 않습니다.
  2. Sysop boundary: multi-chunk delete에 log_sysop_start/commit wrapper를 고려했는지? insert 경로의 page allocation은 sysop을 사용(508행)하지만 delete는 사용하지 않습니다.
  3. OID_INITIALIZER vs NULL_PAGEID: 646행의 루프 종료 조건이 안전한지 확인 가능한지? OID_INITIALIZER가 어떤 값으로 확장되는지?
  4. Vacuum 경로: vacuum이 oos_delete를 호출할 때, 로그에 chain marker 없이 single-chunk OOS delete와 multi-chunk delete를 어떻게 구분할 것인지?

- Use OID_ISNULL() macro instead of pageid != NULL_PAGEID for null check
- Replace structured binding with explicit field access to avoid field order dependency
- Initialize RECDES with RECDES_INITIALIZER before spage_get_record PEEK
- Check spage_delete() return value instead of silently discarding it (both oos_delete and oos_rv_redo_delete)
- Add er_set()/ASSERT_ERROR() on all ER_FAILED return paths to follow CUBRID error protocol
- Add file_dealloc() when page becomes empty after slot deletion to prevent file bloat
- Add error_manager.h include for ASSERT_ERROR/er_set/er_errid
- Add TODO comment documenting multi-page atomicity concern (sysop/chain-marker design needed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vimkim
Copy link
Copy Markdown
Contributor Author

vimkim commented Mar 24, 2026

코드 리뷰 반영 사항 (commit: 08be4fb)

이전 리뷰에서 지적된 이슈들을 oos_file.cpp에 반영했습니다.


수정 내용

1. 루프 종료 조건 개선 (OID_ISNULL 사용)

  • current_oid.pageid != NULL_PAGEID!OID_ISNULL (&current_oid) 로 변경
  • CUBRID 표준 OID null 확인 방법 사용

2. Structured binding 제거

  • const auto [pageid, slotid, volid] = current_oid 제거
  • OID 필드 순서 변경 시 묵묵히 잘못된 값이 할당되는 위험 제거
  • current_oid.pageid, current_oid.slotid, current_oid.volid 직접 접근으로 교체

3. RECDES 초기화

  • RECDES recdes_with_header;RECDES recdes_with_header = RECDES_INITIALIZER;

4. spage_delete() 반환값 확인

  • (void) spage_delete(...) 패턴 제거
  • PGSLOTID deleted_slotid = spage_delete(...) 로 반환값을 받고, NULL_SLOTID 시 에러 처리
  • oos_rv_redo_delete()에도 동일하게 적용

5. CUBRID 에러 프로토콜 준수

  • 모든 에러 반환 경로에 er_set() / ASSERT_ERROR() 추가
  • pgbuf_fix 실패: ASSERT_ERROR() + er_errid() 반환
  • spage_get_record 실패: er_set(ER_ERROR_SEVERITY, ...)ER_FAILED 반환
  • error_manager.h include 추가

6. 빈 페이지 deallocation 추가 (file_dealloc)

  • slot 삭제 후 spage_number_of_records() == 0 이면 file_dealloc() 호출
  • 이전: slot만 삭제하고 page는 반환하지 않아 OOS 파일이 무한히 증가
  • 수정: page가 비면 file manager에 반환하여 공간 재사용 가능
  • pgbuf_unfix_and_init_after_check 매크로가 null 체크 후 unfix하므로 scope_exit와 double-unfix 없음

미반영 사항 (향후 과제)

  • Multi-page atomicity: multi-chunk delete 전체를 sysop으로 묶거나 chain marker dummy log 도입 필요 (TODO 주석 추가)
  • const_cast<VFID *>: LOG_DATA_ADDR.vfid가 non-const이어서 발생하는 패턴 — insert 경로와 공통 이슈라 별도 처리 필요
  • 에러 경로 unit test: pgbuf_fix 실패, double-delete 등 시나리오 테스트 추가 필요

모든 기존 unit test (7개) 통과 확인:

[  PASSED  ] 7 tests.

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 24, 2026

Reviews (2): Last reviewed commit: "fix(oos_delete): address code review iss..." | Re-trigger Greptile

Comment thread src/storage/oos_file.cpp
Comment thread src/storage/oos_file.cpp Outdated
Comment thread unit_tests/oos/test_oos_delete.cpp
Comment on lines +36 to +56

// ---------------------------------------------------------------------------
// helper: fix page from OID with READ latch, return free space, then unfix
// ---------------------------------------------------------------------------
static int
get_free_space_of_oid_page (const OID &oid)
{
VPID vpid = {oid.pageid, oid.volid};
PAGE_PTR page_ptr = pgbuf_fix (thread_p, &vpid, OLD_PAGE_IF_IN_BUFFER,
PGBUF_LATCH_READ, PGBUF_UNCONDITIONAL_LATCH);
if (page_ptr == nullptr)
{
return -1;
}
test_oos_utils::auto_unfixed_page_ptr auto_page { page_ptr, test_oos_utils::page_auto_unfix {thread_p} };
return spage_get_free_space (thread_p, page_ptr);
}

// ---------------------------------------------------------------------------
// helper: peek OOS_RECORD_HEADER from a slot (before deleting)
// ---------------------------------------------------------------------------
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_oos_delete.cpp 는 리뷰 대상이 아니며, unit test 참고 대상입니다.

…precedent)

Document why the current sysop-based approach differs from the overflow
file's LOG_DUMMY_OVF_RECORD pattern, and what additional work is needed
to follow that precedent:
- sysop: error atomicity (partial delete rollback)
- chain marker: recovery/vacuum chain identification
Both are needed; only sysop is implemented so far.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vimkim
Copy link
Copy Markdown
Contributor Author

vimkim commented Mar 24, 2026

Overflow file 선례와의 차이점 정리 (commit: e069ece)

현재 oos_delete는 sysop 기반으로 구현되어 있는데, overflow file의 LOG_DUMMY_OVF_RECORD 패턴을 따르지 않은 이유를 정리합니다.


Sysop vs Chain Marker — 서로 다른 문제를 해결

접근법 해결하는 문제 현재 상태
sysop (log_sysop_start/attach_to_outer/abort) 에러 시 partial delete rollback (원자성) 구현 완료
chain marker dummy log (LOG_DUMMY_OVF_RECORD 유사) recovery/vacuum이 multi-page chain 여부 식별 미구현 (TODO)

왜 sysop만으로는 부족한가

sysop은 oos_delete 실행 중 에러가 발생했을 때 partial delete를 rollback하는 역할만 합니다.

chain marker가 없으면 vacuum이 delete undo log를 처리할 때:

  • 개별 chunk의 delete log만 보임
  • 해당 삭제가 single-chunk인지, multi-chunk chain의 일부인지 바로 판별 불가
  • undo data 내 OOS_RECORD_HEADERnext_chunk_oid / chunk_index로 추론은 가능하지만, dummy log가 있으면 chain 경계를 명시적으로 구분할 수 있어 recovery/vacuum 로직이 단순해짐

Chain marker 구현 시 필요한 작업

  1. 새로운 LOG_RECTYPE 추가 (예: LOG_DUMMY_OOS_CHAIN_DELETE)
  2. Recovery 코드에서 해당 log type 처리
  3. Vacuum 연동 시 chain marker를 읽고 multi-page 정리 수행

이 작업은 vacuum 연동과 함께 진행하는 것이 적절할 것 같습니다. 코드에 TODO 주석으로 남겨두었습니다.

@vimkim
Copy link
Copy Markdown
Contributor Author

vimkim commented Mar 24, 2026

OOS Delete Architecture Overview

1. OOS Record 구조 (Multi-chunk Chain)

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  OOS Page A      │     │  OOS Page B      │     │  OOS Page C      │
│  ┌─────────────┐ │     │  ┌─────────────┐ │     │  ┌─────────────┐ │
│  │ chunk_index=0│ │     │  │ chunk_index=1│ │     │  │ chunk_index=2│ │
│  │ total_size=N │─┼────▶│  │ total_size=N │─┼────▶│  │ total_size=N │ │
│  │ next_chunk=B │ │     │  │ next_chunk=C │ │     │  │ next_chunk=∅ │ │
│  │ ──────────── │ │     │  │ ──────────── │ │     │  │ ──────────── │ │
│  │  data part 1 │ │     │  │  data part 2 │ │     │  │  data part 3 │ │
│  └─────────────┘ │     │  └─────────────┘ │     │  └─────────────┘ │
│  (other records) │     │  (other records) │     │  (other records) │
└─────────────────┘     └─────────────────┘     └─────────────────┘
      HEAD OID
  • 각 chunk는 slotted page의 한 slot에 REC_HOME으로 저장
  • OOS page는 공유됨 (한 page에 여러 OOS record의 chunk가 공존 가능)
  • Insert는 역순 (chunk 2 → 1 → 0), Delete는 정순 (chunk 0 → 1 → 2)

2. oos_delete() 실행 흐름

caller (heap_delete / vacuum)
  │
  ▼
oos_delete()
  │
  ├── log_sysop_start()          ◀── 원자성 보장 시작
  │
  ├── oos_delete_chain()         ◀── 내부 함수
  │     │
  │     ├── [chunk 0] pgbuf_fix → PEEK header → log_delete → spage_delete → pgbuf_set_dirty → unfix
  │     ├── [chunk 1] pgbuf_fix → PEEK header → log_delete → spage_delete → pgbuf_set_dirty → unfix
  │     ├── [chunk 2] pgbuf_fix → PEEK header → log_delete → spage_delete → pgbuf_set_dirty → unfix
  │     └── ... (until next_chunk_oid == NULL)
  │
  ├── on SUCCESS: log_sysop_attach_to_outer()   ◀── 외부 트랜잭션에 연결
  └── on ERROR:   log_sysop_abort()             ◀── 부분 삭제 전체 rollback

3. WAL Log 구조 (현재 vs 목표)

현재 구현:
  ┌─────────────────────────────────────────────────────────┐
  │ SYSOP_START                                             │
  │   RVOOS_DELETE (chunk 0)  undo=full_recdes, redo=slotid │
  │   RVOOS_DELETE (chunk 1)  undo=full_recdes, redo=slotid │
  │   RVOOS_DELETE (chunk 2)  undo=full_recdes, redo=slotid │
  │ SYSOP_ATTACH_TO_OUTER                                   │
  └─────────────────────────────────────────────────────────┘

목표 (overflow file 선례 반영 — TODO):
  ┌─────────────────────────────────────────────────────────┐
  │ SYSOP_START                                             │
  │   LOG_DUMMY_OOS_CHAIN_DELETE  ◀── chain 시작 marker     │
  │   RVOOS_DELETE (chunk 0)                                │
  │   RVOOS_DELETE (chunk 1)                                │
  │   RVOOS_DELETE (chunk 2)                                │
  │   LOG_DUMMY_OOS_CHAIN_DELETE  ◀── chain 종료 marker     │
  │ SYSOP_ATTACH_TO_OUTER                                   │
  └─────────────────────────────────────────────────────────┘
      ↑ vacuum이 chain marker로 multi-page 여부 즉시 판별

4. Recovery / Rollback 시나리오

                    ┌──────────────┐
                    │ oos_delete() │
                    └──────┬───────┘
                           │
              ┌────────────┼────────────┐
              ▼            ▼            ▼
         chunk 0 OK   chunk 1 OK   chunk 2 FAIL
              │            │            │
              │            │            ▼
              │            │     log_sysop_abort()
              │            │            │
              ▼            ▼            ▼
         undo chunk 0  undo chunk 1    (chunk 2는 미삭제)
         (re-insert)   (re-insert)
              │            │
              ▼            ▼
         모든 chunk 원복 — 일관된 상태

────────────────────────────────────────────────

         Crash Recovery 시나리오:

         TX 미커밋 + crash mid-delete
              → recovery가 undo 수행 → 모든 chunk 복원 ✓

         TX 커밋 후 crash
              → recovery가 redo 수행 → 모든 chunk 삭제 완료 ✓

         TX 커밋 후 정상 종료
              → vacuum이 빈 page deallocate (TODO) ✓

5. Page Deallocation 책임 분리

┌──────────────┐          ┌──────────────┐
│  oos_delete() │          │    vacuum     │
│              │          │              │
│  slot 삭제    │          │  page 해제    │
│  (spage_delete)│         │  (file_dealloc)│
│              │          │              │
│  트랜잭션 내   │    ──▶   │  트랜잭션 후   │
│  rollback 가능│          │  영구 해제     │
└──────────────┘          └──────────────┘

이유: file_dealloc()은 내부 committed sysop을 사용하므로
      외부 트랜잭션 abort 시 rollback 불가.
      → 삭제 undo 시 page가 이미 없는 상태 발생 가능.
      → page 해제는 TX commit 확정 후 vacuum이 처리해야 안전.

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 24, 2026

Reviews (4): Last reviewed commit: "docs(oos_delete): add TODO for chain mar..." | Re-trigger Greptile

Comment thread unit_tests/oos/test_oos_delete.cpp
@vimkim
Copy link
Copy Markdown
Contributor Author

vimkim commented Mar 24, 2026

OOS Delete Undo/Recovery 메커니즘 정리

oos_delete 후 트랜잭션이 abort되면 OOS 값과 OID가 어떻게 원래대로 복원되는지 정리합니다.


1. OOS Value는 undo log에 전부 기록된다

oos_log_delete_physical (oos_file.cpp:636):

log_append_undoredo_recdes(thread_p, RVOOS_DELETE, &log_addr, recdes_p, NULL);
//                                                  ^^^^^^^^  ^^^^
//                                                  undo data  redo data (없음)

recdes_pspage_get_record(PEEK)으로 가져온 전체 slot 내용:

recdes_p = [ OOS_RECORD_HEADER | actual chunk data (OOS 값) ]

OOS_RECORD_HEADER(next_chunk_oid, chunk_index, total_size) + 실제 OOS 값 데이터 전부가 undo log에 들어갑니다.


2. OID 동일성 보장 — spage_insert_for_recovery

RV_fun[] 테이블 (recovery.c:851):

RVOOS_DELETE → undo function = oos_rv_redo_insert

oos_rv_redo_insert (oos_file.cpp:727+):

slotid = rcv->offset;                     // 원래 slotid (log_addr.offset에서 복원)
spage_insert_for_recovery(thread_p,
                          rcv->pgptr,     // 원래 page
                          slotid,         // 원래 slot 번호에 강제 삽입
                          &recdes);

일반 spage_insert는 빈 slot을 찾아 삽입하지만, spage_insert_for_recovery지정된 slotid에 강제 삽입합니다. 따라서 (pageid, slotid, volid) = 원래 OID 그대로 복원됩니다.


3. Multi-chunk chain 연결 보장

undo는 로그 역순으로 수행되므로:

Delete 순서:  chunk 0 → chunk 1 → chunk 2
Undo 순서:    chunk 2 → chunk 1 → chunk 0  (역순)

chunk 2 복원: header.next_chunk_oid = NULL       (undo data에 저장된 원래 값)
chunk 1 복원: header.next_chunk_oid = chunk 2 OID (undo data에 저장된 원래 값)
chunk 0 복원: header.next_chunk_oid = chunk 1 OID (undo data에 저장된 원래 값)

각 chunk의 undo data에 OOS_RECORD_HEADER가 포함되어 있고, next_chunk_oid는 insert 시 설정된 원래 값 그대로이므로 chain이 정확히 복원됩니다.


4. Heap record와의 정합성

heap record: [..., oos_oid = {page=A, slot=0, vol=2}, ...]
                              │
      oos_delete: chunk 0을 page A, slot 0에서 삭제
                              │
      TX abort → undo 수행    │
                              ▼
      spage_insert_for_recovery(page=A, slot=0)
      → 동일한 OID로 복원
                              │
      heap record의 oos_oid가 여전히 유효 ✓

요약

항목 보장 방법
OOS 값 복원 log_append_undoredo_recdes의 undo data에 전체 recdes (header + 실제 데이터) 저장
OID 동일성 spage_insert_for_recovery(slotid)가 원래 slot 번호에 강제 삽입
Chain 연결 각 chunk의 undo data 내 OOS_RECORD_HEADER.next_chunk_oid가 원래 값 유지
Heap 정합성 위 세 가지가 보장되면 heap record의 oos_oid 참조가 자동으로 유효

@vimkim
Copy link
Copy Markdown
Contributor Author

vimkim commented Mar 24, 2026

spage_insert_for_recovery 실패 가능성 분석

OOS delete undo 시 spage_insert_for_recovery가 실패할 수 있는지 분석했습니다.


호출 경로 (ANCHORED page)

OOS page는 ANCHORED 타입으로 초기화됩니다 (oos_file.cpp:589).

oos_rv_redo_insert()                    ← RVOOS_DELETE의 undo handler
  └── spage_insert_for_recovery()
        │
        ├── slot_id < num_slots 이면:
        │     assert(slot->offset == SPAGE_EMPTY_OFFSET)
        │     slot을 REC_DELETED_WILL_REUSE로 마킹
        │
        └── spage_find_empty_slot_at()
              └── spage_take_slot_in_use()
                    │
                    ├── slot이 REC_DELETED_WILL_REUSE:
                    │     └── spage_check_space(space)  ← SP_DOESNT_FIT 가능
                    │
                    └── slot이 in-use + ANCHORED:
                          └── assert(false) → SP_ERROR

spage_check_space()에서 page 내 여유 공간이 부족하면 SP_DOESNT_FIT 반환 가능.


이론적 실패 시나리오

1. TX-A: oos_delete()로 chunk의 slot 삭제 → free space 증가
2. TX-B: 같은 page에 다른 OOS record insert → free space 소진
3. TX-A: abort → undo 시 spage_insert_for_recovery() 호출
   → spage_check_space() → SP_DOESNT_FIT

현재 oos_rv_redo_insert의 에러 처리 (oos_file.cpp:743):

if (sp_success != SP_SUCCESS)
{
    if (sp_success != SP_ERROR)
        er_set (ER_FATAL_ERROR_SEVERITY, ...);  // FATAL → 서버 crash
    return er_errid ();
}

SP_DOESNT_FITER_FATAL_ERROR_SEVERITY → 사실상 서버가 죽음.


실제로 발생하는가?

상황 발생 여부
Crash recovery (redo/undo) single-thread → 경합 없음 → 삭제 공간 그대로 → 안전
Runtime abort (TX rollback) 다른 TX가 같은 page에 insert 가능 → 이론적으로 가능

다만 CUBRID의 기존 코드(heap file 등)도 동일한 전제를 사용합니다:

  • spage_delete는 slot을 REC_DELETED_WILL_REUSE로 마킹 (slot entry 자체는 남음)
  • ANCHORED page에서 deleted slot을 다른 TX가 재사용하려면 같은 slotid를 지정해야 함
  • heap의 heap_rv_redo_insert도 동일 패턴 — physical undo/redo는 항상 성공해야 한다는 전제

결론

  • Crash recovery: 안전 (single-thread, 경합 없음)
  • Runtime abort: 기존 heap file과 동일한 가정을 따름. CUBRID의 WAL/space reservation 모델이 physical undo의 공간 가용성을 보장하는 것으로 전제
  • 이 전제가 OOS page에서도 정확히 성립하는지는 OOS page의 space management가 heap과 동일한 보장을 제공하는지 추가 확인이 필요함

확인 필요 사항: OOS page에서 delete 후 다른 TX가 동일 page에 insert하여 공간을 소진하는 경우, runtime undo 시 spage_insert_for_recoverySP_DOESNT_FIT 발생 가능성. Heap file의 경우 이를 어떻게 방지하는지 (space reservation? latch protocol?) 참고하여 OOS에도 동일한 보장이 필요.

Comment thread src/storage/oos_file.cpp Outdated
Comment thread src/storage/oos_file.cpp Outdated
Each chunk deletion logs RVOOS_DELETE with full undo data individually.
On transaction abort or crash recovery, undo records replay in reverse
order restoring all chunks — no sysop grouping needed. This matches
overflow_delete which also does not use sysop for its multi-page deletes
(it uses file_dealloc postpone records instead of immediate mutations).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vimkim
Copy link
Copy Markdown
Contributor Author

vimkim commented Mar 25, 2026

/run sql medium

@vimkim vimkim requested a review from hornetmj March 25, 2026 10:25
@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 25, 2026

Reviews (5): Last reviewed commit: "refactor(oos_delete): remove sysop wrapp..." | Re-trigger Greptile

Replace manual oid.volid/pageid/slotid expansions with OID_AS_ARGS(&oid)
macro throughout oos_file.cpp for consistency with the rest of the codebase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vimkim
Copy link
Copy Markdown
Contributor Author

vimkim commented Mar 25, 2026

OOS insert/delete의 sysop 사용에 대한 분석

배경

oos_delete에서 sysop을 제거하면서, oos_insert의 multi-page insert도 동일한 관점에서 분석했습니다.

oos_insert_across_pages의 현재 구조

  • 청크를 역순으로 삽입 (마지막 청크부터 첫 번째 청크까지)
  • oos_insert_within_page 호출마다 spage_insert + undo/redo 로깅이 개별적으로 수행됨
  • 페이지 할당(oos_file_alloc_new)은 log_sysop_commit (committed sysop)으로 처리됨

크래시 안전성

크래시 시 미완료 트랜잭션의 undo 레코드가 역순 재생되어 삽입된 청크들이 제거됩니다. 단, 페이지 할당은 committed sysop이므로 롤백되지 않고 빈 페이지로 남습니다. 이는 overflow_insert도 동일한 동작입니다 — file_alloc_multiple이 내부적으로 committed sysop을 사용하므로, 트랜잭션이 롤백되어도 할당된 페이지는 남고 vacuum이 회수합니다.

에러 발생 시 (크래시 없이)

5개 청크 중 3번째에서 실패하면, 이미 삽입된 청크 4, 5는 고아 상태가 됩니다 (현재 코드의 TODO 주석: // TODO: free partially inserted chunks). 하지만 개별 undo 레코드가 트랜잭션 로그에 남아 있으므로, 트랜잭션이 abort되면 모두 정리됩니다.

결론

oos_insertoos_delete 모두 동일한 설계:

  • 크래시 복구: 개별 undo 레코드 역순 재생으로 안전 ✓
  • 에러 시 제약: caller가 반드시 트랜잭션을 abort해야 함 (storage layer 에러는 항상 상위로 전파되어 트랜잭션 abort로 이어지므로 문제 없음)
  • sysop 불필요: 개별 슬롯 단위 undo/redo로 충분

overflow_insert가 sysop을 사용하는 이유는 file_alloc_multiple로 여러 페이지를 한번에 할당하고 데이터를 쓰는 구조이기 때문이며, OOS는 페이지를 개별적으로 할당/삽입하므로 구조가 다릅니다.

image

따라서 oos_insert 에 남아있는 해당 TODO 주석은 더이상 유효하지 않아 지울 수 있습니다.

…n abort

Replace misleading TODO about freeing partially inserted chunks with
accurate comment: individual undo records handle cleanup when the
caller aborts the transaction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vimkim
Copy link
Copy Markdown
Contributor Author

vimkim commented Mar 25, 2026

6fc72bd sql, medium 통과 완료

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 25, 2026

Reviews (6): Last reviewed commit: "Merge remote-tracking branch 'cub/feat/o..." | Re-trigger Greptile

Comment thread src/storage/oos_file.cpp
Comment on lines +717 to +720
assert (recdes_with_header.length >= (int) sizeof (OOS_RECORD_HEADER));
OOS_RECORD_HEADER header;
std::memcpy (&header, recdes_with_header.data, sizeof (OOS_RECORD_HEADER));
OID next_chunk_oid = header.next_chunk_oid;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 릴리즈 빌드에서 assert — 메모리 안전성 미보장

assert (recdes_with_header.length >= (int) sizeof (OOS_RECORD_HEADER));
OOS_RECORD_HEADER header;
std::memcpy (&header, recdes_with_header.data, sizeof (OOS_RECORD_HEADER));

assert는 릴리즈 빌드(NDEBUG)에서 no-op이 됩니다. OOS 페이지의 레코드 길이가 sizeof(OOS_RECORD_HEADER)보다 작은 경우(I/O 오류 또는 저장 매체 불량으로 인한 페이지 손상 시나리오) 해당 assert는 생략되고, memcpy는 유효한 범위를 넘어 읽게 됩니다. 이는 DBMS 안정성의 핵심 코드 경로에서 허용하기 어려운 상황입니다.

CUBRID 코드베이스 전반에서 복구 불가능한 불변 조건을 검증할 때는 assert_release를 사용하거나, 명시적인 에러 반환을 추가하는 패턴을 사용합니다. 아래와 같이 수정하는 것을 권장합니다:

Suggested change
assert (recdes_with_header.length >= (int) sizeof (OOS_RECORD_HEADER));
OOS_RECORD_HEADER header;
std::memcpy (&header, recdes_with_header.data, sizeof (OOS_RECORD_HEADER));
OID next_chunk_oid = header.next_chunk_oid;
if (recdes_with_header.length < (int) sizeof (OOS_RECORD_HEADER))
{
assert_release (false);
er_set (ER_FATAL_ERROR_SEVERITY, ARG_FILE_LINE, ER_GENERIC_ERROR, 0);
return ER_FAILED;
}
OOS_RECORD_HEADER header;
std::memcpy (&header, recdes_with_header.data, sizeof (OOS_RECORD_HEADER));

@vimkim vimkim merged commit f883c60 into CUBRID:feat/oos Mar 31, 2026
8 checks passed
@vimkim vimkim deleted the cbrd-26609-oos-delete branch March 31, 2026 05:28
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.

7 participants