프로세스 안전성 — 확장이 DB 프로세스 안에서 돈다는 것
⚠️ 이 문서는 감사 커밋
7d60c82시점의 스냅샷이다. 이후 Critical 5건과 High 8건(4건 수정 · 4건 부분)이 반영되었으므로, 여기 서술된 결함 중 일부는 현재 코드에 더 이상 존재하지 않는다. 현재 상태는 10_fixed.md 를 볼 것.
이 문서가 답하는 질문
- 이 확장에서 발생한 오류가 데이터베이스를 죽일 수 있는가?
- panic / unwrap 으로 백엔드가 종료되는 경로가 있는가?
- 메모리는 어디에 할당되고 언제 해제되는가?
- SPI 재진입과 트랜잭션 상태에 문제가 있는가?
0. 왜 이 문서가 필요한가 (사실)
engine/은 crate-type = ["cdylib"](engine/Cargo.toml:7-8)로 빌드되어 PostgreSQL 백엔드 프로세스에 로드된다. 이 프로세스에서:
| 사건 | 결과 |
|---|---|
Rust panic! |
pgrx가 잡아 ereport(ERROR)로 변환 → 트랜잭션 중단, 백엔드 생존 |
PostgreSQL ERROR |
트랜잭션 중단, 백엔드 생존 |
PostgreSQL FATAL |
해당 백엔드 종료, 클러스터 생존 |
SIGSEGV / abort() / OOM kill |
postmaster가 전 백엔드를 강제 종료하고 크래시 복구 수행 — 모든 연결 단절 |
프로젝트 헌법도 이를 명시한다:
.specify/memory/constitution.md:155"어느 쪽이든 PostgreSQL 메모리 컨텍스트·에러 처리(elog/ereport) 규약을 따른다. 확장 안에서의 panic/unwind가 서버를 죽여서는 안 된다."
1. 확인된 방어
P-D1. panic = "unwind" 가 두 프로파일 모두에 명시되어 있다
# engine/Cargo.toml:37-45
[profile.dev]
panic = "unwind"
[profile.release]
panic = "unwind"
opt-level = 3
lto = "fat"
codegen-units = 1
panic = "abort" 였다면 모든 unwrap() 실패가 프로세스 abort() → 클러스터 크래시 복구였을 것이다. 명시적으로 unwind로 고정한 것은 이 프로젝트에서 가장 중요한 단일 안전 결정이다.
P-D2. 식별자 인코딩이 오버플로 대신 ereport를 낸다
// engine/src/id.rs:31-45
/// Compose an identifier. Panics (→ `ereport(ERROR)` via pgrx) on overflow so a
/// silently truncated id can never reach storage.
#[inline]
pub fn make_id(shard: i32, type_id: i32, local: i64) -> i64 {
if !(0..=MAX_SHARD_ID).contains(&shard) { error!("shard id {shard} out of range …"); }
if !(0..=MAX_TYPE_ID).contains(&type_id) { error!("type id {type_id} out of range …"); }
if !(0..=MAX_LOCAL_ID).contains(&local) { error!("local id {local} exhausted …"); }
((shard as i64) << SHARD_SHIFT) | ((type_id as i64) << TYPE_SHIFT) | local
}
세 필드 모두 범위 검사를 거친다. 확인된 방어.
P-D3. og_reach / og_csr_* 는 PARALLEL RESTRICTED
// engine/src/storage/traverse.rs:80
#[pg_extern(stable, parallel_restricted)]
fn og_reach(…)
// engine/src/storage/traverse.rs:359, 442
#[pg_extern(stable, parallel_restricted)]
전자는 SPI를, 후자는 백엔드 로컬 thread_local 상태를 쓴다. 병렬 워커에는 둘 다 없으므로 parallel_restricted 는 올바른 선언이다. docs/api.md:78-79가 이를 문서화하고 있다.
P-D4. 쓰기 경로 함수는 전부 기본 VOLATILE
engine/src/storage/mod.rs의 6개 #[pg_extern], engine/src/catalog/types.rs의 8개, engine/src/interop/mod.rs의 4개 — 모두 속성 없이 선언되어 pgrx 기본값인 VOLATILE, PARALLEL UNSAFE가 적용된다. 쓰기 함수를 STABLE로 잘못 선언한 사례는 §4의 하나뿐이다.
P-D5. PackStream은 잘못된 UTF-8에 panic하지 않는다
// bolt/src/packstream.rs:219-222
fn string(&mut self, n: usize) -> io::Result<Value> {
let b = self.bytes(n)?;
Ok(Value::String(String::from_utf8_lossy(b).into_owned()))
}
from_utf8_lossy는 실패하지 않는다. 또 bytes(n)(:155-163)이 길이를 먼저 검사하므로 문자열 경로에는 선할당이 없다. 확인된 방어.
2. 확인된 결함 — 오류 상태를 삼키는 catch_unwind
// engine/src/agent/mod.rs:261-292
#[pg_extern(stable)]
fn og_explain_error(graph: &str, query: &str) -> JsonB {
match crate::cypher::parser::parse(query) {
Err(e) => JsonB(json!({ … })),
Ok(_) => {
let compiled = std::panic::catch_unwind(|| {
crate::cypher::compile_for_diagnostics(graph, query)
});
match compiled {
Ok(Ok(_)) => JsonB(json!({ "ok": true })),
Ok(Err(msg)) => JsonB(json!({ … })),
Err(_) => JsonB(json!({
"ok": false,
"code": "INTERNAL",
"message": "compilation aborted",
"stage": "compile",
})),
}
}
}
}
compile_for_diagnostics → compile_cached → Compiler::compile_read 는 PostgreSQL ERROR를 낼 수 있는 경로를 여럿 통과한다:
| 경로 | ereport 발생 지점 |
|---|---|
types::graph_id(graph) |
engine/src/catalog/types.rs:118 — 존재하지 않는 그래프 |
views::ensure_view(...) |
engine/src/cypher/views.rs:136 — 뷰 생성 실패 |
types::type_kind(tid) |
engine/src/catalog/types.rs:293 |
labeling::* SPI 호출 |
권한 오류 등 |
pgrx는 PostgreSQL ERROR를 Rust unwind로 전파한다. std::panic::catch_unwind는 그것을 잡을 수는 있지만 PostgreSQL의 오류 스택을 비우지(FlushErrorState) 않는다. pgrx가 제공하는 올바른 도구는 PgTryBuilder / pgrx::pg_try()이며, 이들은 오류 스택 정리와 메모리 컨텍스트 복원을 함께 수행한다.
재현 조건: SELECT og_explain_error('존재하지-않는-그래프', 'MATCH (n) RETURN n'); graph_id()가 error!("graph '…' does not exist")를 발생시키고, 그 unwind가 catch_unwind에 잡혀 "compilation aborted" JSON이 정상 반환된다 — 즉 PostgreSQL은 오류를 냈다고 생각하는데 함수는 성공적으로 값을 돌려준다. 이후 같은 트랜잭션에서 수행되는 SPI 호출의 동작은 정의되지 않는다.
영향 범위: Studio의 POST /api/diagnose가 이 함수를 무조건 호출한다 (portal/server/index.js:237). 즉 UI에서 오타 하나로 도달한다.
정확한 귀결(백엔드 크래시 여부,
ERRORDATA_STACK_SIZE exceeded)은 pgrx 0.19.2 내부 구현에 달려 있어 동적으로 검증하지 않았다(미확인). 그러나 "PostgreSQL 오류를FlushErrorState없이 삼킨다"는 사실 자체는 코드로 확정된다.
3. 확인된 결함 — PostgreSQL 메모리 컨텍스트 밖의 무제한 할당
3.1 백엔드 로컬 CSR
// engine/src/storage/traverse.rs:205-210
thread_local! {
/// One compiled graph per backend. Rust-heap allocated on purpose: a
/// PostgreSQL memory context would free it at end of transaction, and the
/// whole point is that the next statement finds it already built.
static CSR: RefCell<Option<Csr>> = const { RefCell::new(None) };
}
주석이 의도를 명시한다. 결과적인 사실:
| 사실 | 근거 |
|---|---|
| 할당 크기에 상한이 없다 | compile()(traverse.rs:241-292)이 og_data.og_adj 전체를 읽어 ids/fwd/rev를 만든다 |
work_mem·maintenance_work_mem이 적용되지 않는다 |
Rust Vec이므로 PostgreSQL 할당자를 거치지 않는다 |
트랜잭션 종료·ROLLBACK으로 해제되지 않는다 |
thread_local, 해제는 og_csr_drop()(:317)이나 백엔드 종료뿐 |
| 커넥션 풀에서는 연결마다 유지된다 | 같은 이유 |
| 크기 보고는 있으나 강제는 없다 | og_csr_stats()(:323)가 바이트 수를 보고할 뿐 |
결과: 큰 그래프에서 og_csr_build()를 반복 호출하면 백엔드 RSS가 PostgreSQL이 모르는 채로 증가한다. Linux OOM killer가 백엔드를 죽이면 postmaster는 이를 비정상 종료로 보고 클러스터 전체를 크래시 복구 모드로 재시작한다. CWE-770.
3.2 질의 결과 전량 물질화
| 지점 | 코드 | 상한 |
|---|---|---|
og_cypher 결과 |
engine/src/cypher/mod.rs:150 .collect() |
없음 |
og_reach 결과 |
engine/src/storage/traverse.rs:121, 157, 160 |
방문 노드 수 |
og_reach 방문 집합 |
engine/src/storage/traverse.rs:118 HashSet::with_capacity(1024) |
없음 |
og_csr_reach 결과 |
engine/src/storage/traverse.rs:371 |
없음 |
| Bolt 결과 버퍼 | bolt/src/session.rs:291-320 |
없음 — PULL n이 전송량만 제한하고 인출은 전량 |
SetOfIterator/TableIterator가 스트리밍처럼 보이지만, 세 경우 모두 Vec을 먼저 다 채운 뒤 반환한다.
4. 확인된 결함 — 휘발성(volatility) 오선언
// engine/src/cypher/mod.rs:69-80
/// Show the SQL a Cypher query compiles to.
#[pg_extern(stable)]
fn og_cypher_sql(graph: &str, query: &str) -> String {
match compile_cached(graph, query) {
Ok((sql, _)) => sql,
Err(e) => error!("{e}"),
}
}
compile_cached → Compiler::compile_read → views::ensure_view → Spi::run("CREATE OR REPLACE VIEW …") (engine/src/cypher/views.rs:135).
즉 STABLE로 선언된 함수가 DDL을 실행한다. STABLE은 "같은 문장 안에서 같은 인자에 같은 값을 돌려주며 데이터베이스를 수정하지 않는다"는 계약이다.
| 결과 | 설명 |
|---|---|
| 읽기 전용 트랜잭션에서 실패 | SET default_transaction_read_only = on 상태에서 첫 호출이 뷰를 만들려다 오류 — og_apply_role의 read_only 한도(agent/mod.rs:432-436)와 정면 충돌 |
| 읽기 복제본(spec 007)에서 실패 | 핫 스탠바이에서 DDL 불가 |
| Studio가 매 질의마다 호출한다 | portal/server/index.js:198 |
같은 문제가 og_vector_search·og_similar·og_hybrid_search· og_vector_search_exact(engine/src/vector/mod.rs:112, 171, 247, 425)와 compat/procs.rs:226에도 있다 — 모두 ensure_view를 부른다. 이 중 og_stale_embeddings(vector/mod.rs:299-300)와 og_embedding_stats(:383-384)만 stable이며 뷰를 만들지 않아 문제없다.
5. 쓰기 시점 DDL — 잠금과 자원 소모
// engine/src/storage/mod.rs:127-153
Some((_, col, dtype))
if WIDENABLE.contains(&dtype.as_str()) && !type_accepts(dtype, want) =>
{
let name = types::try_type_name(type_id);
for sub in labeling::og_subtypes(type_id) {
if let Some(table) = types::storage_table(sub) {
if let Some(n) = types::try_type_name(sub) { types::drop_alias_view(&n); }
let _ = Spi::run(&format!(
"ALTER TABLE {table} ALTER COLUMN {col} TYPE text USING {col}::text"
));
if let Some(n) = types::try_type_name(sub) { types::ensure_alias_view(sub, &n, &table); }
}
}
이 코드는 평범한 Cypher 쓰기에서 실행된다: create_node_inner → plan_props(:180) → declare_new_props(:87).
| 사실 | 근거 | 함의 |
|---|---|---|
ALTER COLUMN … TYPE text USING … 은 테이블 전체 재작성이다 |
PostgreSQL 동작 | 대형 타입에서 수 분 단위 정지 |
ACCESS EXCLUSIVE 잠금을 잡는다 |
동상 | 해당 타입과 모든 하위 타입의 읽기·쓰기 전면 차단 |
| 하위 타입 전체에 반복 적용된다 | for sub in labeling::og_subtypes(type_id) (:131) |
계층 전체 정지 |
| 트리거 조건은 값 하나의 타입 불일치다 | type_accepts(:67-73) |
SET n.age = 'x' 한 줄로 유발 |
| 뷰를 지웠다 다시 만든다 | :136-142 |
그동안 뷰 부재 |
결과를 let _ = 로 버린다 |
:138 |
실패해도 후속 쓰기가 진행 |
또한 새 프로퍼티 이름마다 og_add_property → ALTER TABLE … ADD COLUMN (engine/src/catalog/types.rs:550)이 실행된다. PostgreSQL의 테이블당 컬럼 한도(1600)에 도달하면 이후 쓰기가 전부 실패한다.
재현 조건: 어떤 애플리케이션이든 int8로 승격된 프로퍼티에 문자열을 한 번 쓰면 된다. 인증된 저권한 사용자(A4)로 충분하다.
6. SPI 재진입
컴파일된 Cypher SQL은 확장 함수를 다시 호출한다. 실행 중첩은 다음과 같다.
sequenceDiagram
participant C as og_cypher()
participant S1 as SPI #1 (exec_json)
participant P as 플래너/실행기
participant F as og_node_json (plpgsql)
participant S2 as SPI #2 (EXECUTE format)
participant R as og_reach (Rust)
participant S3 as SPI #3 (prepare+select)
C->>S1: Spi::connect + select(compiled_sql, [$1 jsonb])
S1->>P: 실행
P->>F: og_node_json(id)
F->>S2: EXECUTE format('… FROM %s …')
S2-->>F: jsonb
P->>R: og_reach(src, …) LATERAL
R->>S3: prepare 1회 + 레벨마다 select
S3-->>R: 인접 세그먼트
R-->>P: TableIterator (전량 Vec)
감사 결과:
| 항목 | 상태 | 근거 |
|---|---|---|
| SPI 중첩 자체 | 허용됨 — PostgreSQL이 지원 | engine/src/spiu.rs 가 Spi::connect 로 매 호출 진입 |
og_reach 는 레벨마다 재계획하지 않는다 |
확인된 방어 | traverse.rs:99-116 주석과 client.prepare(...) 1회 |
| 쓰기 경로에서 SPI 안에 DDL이 들어간다 | 위험 (§5) | storage/mod.rs:112-118 이 og_add_property를 SPI로 호출 |
og_capture_history 트리거가 다시 SPI를 쓴다 |
트리거→UPDATE og_history+INSERT og_history |
access.sql:288-292 |
| 트리거 재귀 가능성 | 없음 — og_history는 타입 테이블이 아니라 트리거가 붙지 않는다 |
agent/mod.rs:452-461 이 og_subtypes 의 storage table에만 부착 |
7. 컴파일 캐시의 무효화 부재
// engine/src/cypher/mod.rs:26-31
thread_local! {
/// Compiled-SQL cache. …
static PLAN_CACHE: RefCell<HashMap<(String, String), (String, Vec<String>)>> =
RefCell::new(HashMap::new());
}
// engine/src/cypher/mod.rs:47-67
fn compile_cached(graph: &str, query: &str) -> Result<(String, Vec<String>), String> {
let key = (graph.to_string(), query.to_string());
if let Some(hit) = PLAN_CACHE.with(|c| c.borrow().get(&key).cloned()) {
return Ok(hit);
}
…
PLAN_CACHE.with(|cache| {
let mut m = cache.borrow_mut();
if m.len() > 512 { m.clear(); }
m.insert(key, out.clone());
});
한편 스키마가 바뀌면 뷰가 전부 삭제된다:
// engine/src/catalog/labeling.rs:172-182
pub fn bump_schema_version(graph_id: i32, description: &str) {
// Generated per-type union views encode the descendant set, so any schema
// change invalidates them (spec 003 / cypher::views).
crate::cypher::views::drop_all_views();
캐시는 무효화되지 않는다. 결과:
| 상황 | 결과 |
|---|---|
| 스키마 변경 후 같은 백엔드에서 캐시된 질의 재실행 | 캐시된 SQL이 삭제된 og_data.v_<tid>를 참조 → relation … does not exist |
| 캐시 키에 스키마 버전이 없다 | (graph, query) 뿐 (:48) |
캐시 키에 session_user가 없다 |
SET ROLE 이후에도 같은 SQL 재사용 (실행 권한은 실행 시점에 검사되므로 인가 우회는 아님) |
| 512개 초과 시 전체 비움 | LRU가 아니라 clear() — 캐시 스톰 |
Studio는 커넥션 풀(max: 8)을 쓴다 |
8개 백엔드가 각자 낡은 캐시를 들고 있다 |
8. 정수·경계 관련 (낮은 위험, 기록용)
| 지점 | 문제 | 실현 가능성 |
|---|---|---|
engine/src/storage/traverse.rs:224-238 pack() |
offs가 u32 — 간선 40억 초과 시 랩어라운드 |
극히 낮음 |
engine/src/storage/traverse.rs:272 pos() |
as u32 절단 — 노드 40억 초과 |
극히 낮음 |
engine/src/storage/traverse.rs:272 .expect("id present by construction") |
배열 불변식 위반 시 panic → ERROR | 낮음 (P-D1로 완화) |
engine/src/vector/mod.rs:249 (k * 10).max(50) |
k 가 크면 i32 오버플로 → 음수 LIMIT |
낮음 — ERROR로 귀결 |
bolt/src/packstream.rs:156 self.pos + n |
32비트 플랫폼에서 usize 오버플로 → 경계 검사 우회 |
64비트에서는 불가 |
bolt/src/packstream.rs:165-171 marker - base |
u8 언더플로 — 호출부(:187-199)가 범위를 보장 |
불가 |
bolt/src/packstream.rs:87 fields.len() as u8 & 0x0F |
필드 16개 이상이면 개수가 조용히 절단 → 프로토콜 desync | 서버 생성 값이라 낮음 |
9. 별도 프로세스 — Bolt 게이트웨이
Bolt 게이트웨이는 PostgreSQL과 다른 프로세스이므로 여기서의 크래시는 데이터베이스를 죽이지 않는다. 그러나 게이트웨이 자체는 인증 전에 죽는다.
| # | 결함 | 코드 | 결과 |
|---|---|---|---|
| B-1 | 길이 필드로 선할당 | bolt/src/packstream.rs:192-195, 224-225 — self.size(m, 0xD4)? 가 최대 u32::MAX를 돌려주고 Vec::with_capacity(n)에 그대로 들어간다 |
Rust 할당 실패 → handle_alloc_error → abort() (인증 전) |
| B-2 | 메시지 총 길이 상한 없음 | bolt/src/packstream.rs:266-283 — 종료 청크가 올 때까지 body.resize 반복 |
메모리 고갈 (인증 전) |
| B-3 | 재귀 깊이 제한 없음 | bolt/src/packstream.rs:173-217 — value() → list()/dict()/Struct → value() |
스택 오버플로 → SIGSEGV (인증 전) |
| B-4 | 연결·스레드 수 제한 없음 | bolt/src/main.rs:60-79 — thread::spawn 무제한 |
스레드/메모리 고갈 |
| B-5 | 읽기 타임아웃 없음 | set_read_timeout 호출 없음 (main.rs, session.rs 전체) |
유휴 연결이 스레드를 영구 점유 |
B-1의 재현 조건: 핸드셰이크 20바이트를 보낸 뒤, 하나의 청크 안에 리스트 32비트 헤더(0xD6)와 최대 길이를 담아 보내면 된다. HELLO는 필요 없다 (bolt/src/session.rs:113이 인증 이전에 read_message를 호출한다).
10. unwrap / expect 분포 (참고)
unwrap(), expect(, panic!, unreachable!, 직접 인덱싱([0]) 합계:
| 파일 | 건수 |
|---|---|
engine/src/catalog/types.rs |
39 |
engine/src/cypher/compile.rs |
32 |
engine/src/storage/mod.rs |
25 |
engine/src/typeql/parser.rs |
22 |
engine/src/storage/stats.rs |
20 |
engine/src/agent/mod.rs |
14 |
bolt/src/packstream.rs |
9 |
P-D1(panic = "unwind") 덕분에 이들은 모두 PostgreSQL ERROR로 귀결되며 백엔드를 죽이지 않는다. 다만 §2의 catch_unwind가 그 규약을 깨는 유일한 지점이라는 점이 중요하다. Bolt 프로세스에는 그런 안전망이 없다(§9).
Forbidden (금지)
std::panic::catch_unwind를 PostgreSQLERROR가 발생할 수 있는 코드 주위에 쓰지 말 것.pgrx::PgTryBuilder를 쓸 것 (agent/mod.rs:271이 반례).engine/Cargo.toml의panic = "unwind"를 제거하거나"abort"로 바꾸지 말 것. 헌법 원칙(.specify/memory/constitution.md:155) 위반이며 클러스터 크래시로 직결된다.- PostgreSQL 메모리 컨텍스트 밖에 상한 없는 자료구조를 새로 만들지 말 것. 기존 예외는
traverse.rs:205-210의 CSR 하나이며, 상한이 필요하다. STABLE/IMMUTABLE로 선언한 함수 안에서 DDL이나 쓰기를 하지 말 것 (cypher/mod.rs:74가 반례).- PackStream 파서에 길이 필드를 그대로
with_capacity에 넘기는 코드를 추가하지 말 것 (packstream.rs:225가 반례). - RLS를 쓰는 배포에서
og_csr_build를 실행 가능한 상태로 두지 말 것 (03_rls_and_isolation.mdB6, 그리고 §3.1의 메모리 문제).
Required (필수)
- 새
#[pg_extern]을 추가할 때 휘발성을 명시적으로 선언할 것. 뷰를 생성하거나 SPI 쓰기를 하면VOLATILE이어야 한다. og_csr_build에 크기 상한을 도입할 것 — 최소한og_catalog.setting의 키(예:csr.max_bytes)로 읽어 초과 시error!.- Bolt 파서에 (a) 메시지 총 길이 상한, (b) 컬렉션 길이 상한, (c) 재귀 깊이 상한을 도입할 것. 세 가지 모두 인증 이전 경로에 있다.
PLAN_CACHE키에og_catalog.schema_version의 현재 값을 포함시킬 것.- 스키마 변경 후 기존 커넥션에서 캐시된 질의가 동작하는지 회귀 테스트를
engine/tests/sql/에 추가할 것.