트랜잭션 경계, 락, SPI 재진입, 동시성 정책
이 문서가 답하는 질문
- 트랜잭션 경계는 정확히 어디인가?
- 명시적 락은 어디에 있는가? (답: 없다)
- SPI 재진입은 어떻게 다루는가?
- 어떤 상태가 트랜잭션 바깥에 있는가?
- 무엇이 동시성에 안전하지 않은가?
1. 사실 — 트랜잭션 경계는 호출자의 것이다
이 확장은 트랜잭션을 시작하지도 끝내지도 않는다. BEGIN / COMMIT / ROLLBACK / SAVEPOINT를 실행하는 Rust 코드는 없다 (전 소스 grep 결과 0건).
| 진입점 | 트랜잭션 경계 |
|---|---|
psql에서 SELECT og_cypher(...) |
PostgreSQL의 암묵적 트랜잭션 1개 |
BEGIN; SELECT og_cypher(...); SELECT og_cypher(...); COMMIT; |
사용자가 연 것 하나 |
Bolt 자동 커밋 RUN |
암묵적 트랜잭션 1개 |
Bolt 명시적 BEGIN … COMMIT |
bolt/src/session.rs:208-219가 batch_execute("BEGIN") 등으로 전달 |
og_typeql_script(graph, script) |
스크립트 전체가 한 트랜잭션 (typeql/mod.rs:98-113) |
따라서 실패는 항상 전부 아니면 전무다 — 단, 3절의 백엔드-로컬 상태는 예외다.
og_typeql_script는 블록 i에서 실패하면 error!("typeql error in block {} of {}")를 내고 (typeql/mod.rs:107-111), 그 오류가 트랜잭션 전체를 abort시킨다.
2. 사실 — SPI 사용 규약
engine/src/spiu.rs가 세 개의 헬퍼만 노출한다:
| 함수 | 내부 | 용도 |
|---|---|---|
one::<T>(sql, args) |
Spi::connect + client.select |
읽기 전용 단일 값 |
two::<A,B>(sql, args) |
Spi::connect + client.select |
읽기 전용 두 값 |
one_mut::<T>(sql, args) |
Spi::connect_mut + client.update |
INSERT … RETURNING 등 |
존재 이유 (spiu.rs:3-6):
Spi::get_one_with_argsraisesInvalidPositionwhen a query returns no rows, which conflates "nothing matched" with "something broke".
세 함수 모두 빈 결과에 대해 Ok(None)을 돌려준다. "타입이 없다"는 정상적이고 보고 가능한 상황이지 내부 오류가 아니다(spec 008 FR-008).
2.1 SPI 재진입
이 코드베이스에서 SPI는 중첩된다. 예:
og_cypher() [SPI 레벨 0 — 함수 자체가 SQL 안에 있다]
└ Spi::connect(|client| client.select(compiled_sql, ...)) [레벨 1]
└ compiled_sql 안의 og_node_json(id) [레벨 2, plpgsql]
└ 그 안의 EXECUTE format(...) [레벨 3]
쓰기 경로는 더 깊다:
og_cypher()
└ exec_json(select_sql) 바인딩 행 생성 [레벨 1]
└ 행마다:
create_node_inner()
└ alloc_id → Spi::connect_mut [레벨 1]
└ plan_props → Spi::connect × 1~2 [레벨 1]
└ declare_new_props → Spi::run_with_args("SELECT og_add_property(...)")
└ og_add_property 안에서 ALTER TABLE / UPDATE / CREATE VIEW [레벨 2]
└ bump_schema_version → drop_all_views → DROP VIEW ... [레벨 3]
└ Spi::run_with_args(INSERT ...) [레벨 1]
규약: 모든 SPI 호출은 Spi::connect / Spi::connect_mut 클로저 안에서 끝난다. 클로저 밖으로 SpiClient나 SpiTupleTable을 반출하지 않는다. 결과는 항상 Vec이나 스칼라로 물질화한 뒤 반환한다 (예: cypher/views.rs:36-52, storage/mod.rs:161-177, storage/traverse.rs:107-158).
2.2 준비된 문장의 재사용
og_reach(storage/traverse.rs:107-116)만 예외적으로 client.prepare()를 쓴다:
let stmt = client.prepare(sql.as_str(), &[INT8ARRAYOID, INT4ARRAYOID])?;
이유는 주석에 있다 (traverse.rs:100-106):
On a graph with a large diameter the frontier can be a single node and the level count can be six figures — a 1,000,000-node chain walked to 100,000 hops does almost no work per level and nothing but levels. Connecting and re-planning inside the loop made that case ten times slower than the same walk written as a plain recursive CTE.
3. 사실 — 트랜잭션 바깥에 있는 상태
세 개의 thread_local! 상태가 있다. 전부 백엔드-로컬이고 롤백되지 않는다.
| 상태 | 위치 | 수명 | 롤백 시 |
|---|---|---|---|
PLAN_CACHE |
cypher/mod.rs:26-31 |
백엔드 | 남는다 |
CSR (컴파일된 그래프) |
storage/traverse.rs:205-210 |
백엔드 | 남는다 |
| 쓰기 카운터 | stats.rs:23-25 |
og_cypher() 호출 1회 |
남는다 |
3.1 PLAN_CACHE — 무효화가 없다
cypher/mod.rs:47-67:
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());
});
키는 (graph, query)뿐이다. 스키마 버전이 키에 없다.
그런데 컴파일 산출물은 스키마에 강하게 의존한다:
- 해석된 타입 id (
ARRAY[7]::int4[]) - 생성된 타입 뷰 이름 (
og_data.v_5) - 라벨이 없어서
false로 굳은 술어
그리고 스키마가 바뀌면 bump_schema_version()이 drop_all_views()를 부른다 (catalog/labeling.rs:172-182) — 뷰가 전부 지워진다.
같은 세션에서:
SELECT og_cypher('g', 'MATCH (p:Person) RETURN p'); -- v_5 를 참조하는 SQL이 캐시됨
SELECT og_cypher('g', 'CREATE (:NewLabel {a:1})'); -- 새 타입 → relabel → drop_all_views
SELECT og_cypher('g', 'MATCH (p:Person) RETURN p'); -- 캐시 히트 → v_5 는 이미 없음
→ CODE-01. 자세한 내용은 11_improvements_code.md.
3.2 CSR — 스냅샷이 얼어 있다
storage/traverse.rs:19-23:
og_csr_build/og_csr_reach— the pgGraph bet. Compile the topology once into a backend-local CSR of denseu32indices and walk it with no SPI, no heap and no planner in the loop. Faster, and it gives up exactly what leaving the heap gives up: the snapshot is frozen at build time and RLS is never consulted.
traverse.rs:206-208이 Rust 힙에 두는 이유도 명시한다: PostgreSQL 메모리 컨텍스트에 두면 트랜잭션 끝에 해제되는데, 다음 문장이 이미 만들어진 것을 찾는 게 요점이기 때문이다.
운영상 함의:
og_csr_build()이후의 쓰기는 CSR에 반영되지 않는다. 다시 빌드해야 한다.- RLS가 걸린 그래프에서
og_csr_reach()는 정책을 우회한다. - 커넥션 풀 뒤에서는 어느 백엔드가 CSR을 갖고 있는지 알 수 없다.
og_csr_reach()는 없으면error!("no compiled graph in this backend — call og_csr_build() first")(traverse.rs:339-341).
3.3 쓰기 카운터
stats.rs:9-13이 이미 정직하게 적어 두었다:
It is not a transaction log — a rolled-back statement leaves its counts behind, and the next call clears them.
og_cypher_stats()가 volatile, parallel_unsafe로 선언된 이유도 이것이다 (cypher/mod.rs:117).
4. 사실 — 명시적 락이 없다
SELECT … FOR UPDATE, LOCK TABLE, pg_advisory_lock, SET TRANSACTION ISOLATION — 전부 0건이다. 따라서 동시성 정책은 전적으로 PostgreSQL 기본값 (Read Committed + 행 수준 락 + 고유 인덱스)에 의존한다.
4.1 사실상의 직렬화 지점 — og_id_alloc
storage/mod.rs:24-34 alloc_id:
INSERT INTO og_data.og_id_alloc (type_id, next_id) VALUES ($1, 2)
ON CONFLICT (type_id) DO UPDATE SET next_id = og_id_alloc.next_id + 1
RETURNING next_id - 1
같은 type_id에 대한 두 트랜잭션은 이 행에서 직렬화된다. 즉 한 타입에 대한 동시 삽입은 커밋될 때까지 서로를 막는다. 정합성 측면에서는 좋고, 처리량 측면에서는 병목이다.
4.2 위험 — 인접 세그먼트 append의 경합
storage/adjacency.rs:19-44:
UPDATE og_adj SET nbr = nbr || $4, ... WHERE ... AND seq = (SELECT max(seq) ...) AND n < 256
RETURNING seq
-- 0행이면:
INSERT INTO og_adj (..., seq, ...) VALUES (..., COALESCE((SELECT max(seq)+1 ...), 0), ...)
시나리오:
- 꼬리가 있고 안 찼을 때: UPDATE가 행 락을 잡으므로 T2는 T1 커밋까지 대기 → 안전.
- 세그먼트가 아예 없을 때: T1, T2 둘 다 UPDATE에서 0행 → 둘 다
seq = 0으로 INSERT → 둘 중 하나가 기본키(src, etype, dir, seq)위반으로 실패한다. - 꼬리가 꽉 찼을 때: 둘 다
max(seq)+1을 같은 값으로 계산 → 같은 충돌.
결과는 데이터 손상이 아니라 오류다(트랜잭션이 abort되므로). 하지만 "같은 노드에 동시에 엣지를 붙이면 간헐적으로 실패한다"는 운영상 놀라움이다. → CODE-23.
ON CONFLICT가 없다는 점에 주목: adjacency.rs:34-43의 INSERT에는 충돌 처리가 없다.
4.3 위험 — MERGE는 원자적이 아니다
cypher/mod.rs:451-504 merge_pattern:
1. 패턴을 컴파일하고 이미 바인딩된 변수를 고정점으로 핀 고정 461-480
2. LIMIT 1 SELECT 실행 490-491
3. 결과가 있으면 → env 갱신 + ON MATCH SET, 반환 492-499
4. 없으면 → create_pattern + ON CREATE SET 502-503
2와 4 사이에 락이 없다. 두 세션이 같은 MERGE를 동시에 실행하면 둘 다 만든다.
유일한 방어선은 고유 인덱스다. Cypher 애플리케이션은 보통 CREATE CONSTRAINT … REQUIRE n.x IS UNIQUE로 이를 만든다 (compat/ddl.rs:284-325 → enforce_unique ddl.rs:156-167). 제약을 만들지 않으면 중복이 생긴다.
compat/ddl.rs:322-323이 이 결정을 명시한다:
Uniqueness is enforced, because that half is checkable at write time and is what callers rely on for MERGE to be idempotent.
4.4 위험 — TypeQL put도 원자적이 아니다
typeql/write.rs:112-120. find_one → 없으면 run_insert. 같은 구조.
속성은 og_data.a_<tid>.val의 UNIQUE가 막아 준다 (typeql/schema.rs:273-276). 엔티티/관계 인스턴스에는 그런 인덱스가 없다.
4.5 위험 — intern_attribute의 검사-후-삽입
typeql/write.rs:241-261:
SELECT id FROM <table> WHERE val = <lit> → 있으면 재사용
없으면 alloc_id + INSERT
동시 실행 시 둘 다 "없음"을 보면 둘 다 INSERT하고, val의 UNIQUE가 하나를 거절한다. ON CONFLICT DO NOTHING이나 재시도가 없으므로 오류가 사용자에게 그대로 나간다.
@key / @unique 검사(write.rs:314-336)도 마찬가지 — 애플리케이션 레벨 검사이므로 동시성 하에서는 놓칠 수 있다.
5. 사실 — 쓰기 경로 안의 DDL
이것이 이 코드베이스에서 가장 무거운 동시성 위험이다.
평범한 CREATE (:Person {age: 30}) 하나가 다음을 유발할 수 있다:
| 트리거 | DDL | 락 |
|---|---|---|
| 새 라벨 | create_type_inner → CREATE TABLE og_data.n_<tid> + CREATE VIEW |
새 객체 |
| 새 라벨 | relabel_graph → DELETE/INSERT on og_catalog.type_label (그래프 전체) |
행 락 다수 |
| 새 라벨 | bump_schema_version → drop_all_views → DROP VIEW ... CASCADE × N |
AccessExclusive on 각 뷰 |
| 새 프로퍼티 | og_add_property → ALTER TABLE … ADD COLUMN (모든 서브타입) |
AccessExclusive, 단 fast default라 rewrite 없음 |
| 새 프로퍼티 | UPDATE <table> SET <col> = (__ext->>'x')::t WHERE __ext ? 'x' |
전체 테이블 UPDATE |
| 타입 충돌 | ALTER TABLE … ALTER COLUMN … TYPE text |
AccessExclusive + 전체 재작성 |
근거: catalog/types.rs:411-452, types.rs:539-599, catalog/labeling.rs:117-182, storage/mod.rs:127-153.
relabel_graph(labeling.rs:117-170)는 그래프의 모든 라벨을 지우고 다시 넣는다. DELETE FROM og_catalog.type_label WHERE graph_id = $1 후 행마다 개별 INSERT (labeling.rs:154-167) — 타입이 1,000개면 SPI 왕복 1,000회다.
함의:
- 스키마가 안정되기 전(초기 적재)에는 쓰기 처리량이 낮고 락 경합이 크다.
- 두 세션이 동시에 새 라벨을 쓰면
relabel_graph가 서로를 기다린다. - 이 DDL들은 사용자 트랜잭션 안에서 일어나므로, 롤백하면 되돌아간다. 하지만 그 사이 잡은 AccessExclusive 락은 커밋/롤백까지 유지된다.
권장: 스키마를 먼저 선언하고 적재한다. og_create_type / og_add_property / TypeQL define을 먼저 돌리면 쓰기 경로가 DDL을 유발하지 않는다.
6. 사실 — 병렬 실행 라벨
pgrx의 #[pg_extern] 속성이 PostgreSQL 함수 속성이 된다.
| 함수 | 라벨 | 근거 |
|---|---|---|
og_expand, og_vlp, og_reach_sql, og_subtype_ids, og_type_name 등 |
STABLE PARALLEL SAFE |
access.sql 전반 |
og_node_json, og_edge_json |
STABLE (plpgsql, PARALLEL 미지정 = UNSAFE) |
access.sql:209,238 |
og_subtypes, og_supertypes, og_is_subtype |
stable, parallel_safe, strict |
catalog/labeling.rs:192,212,232 |
og_degree, og_degree_all |
stable, parallel_safe, strict |
storage/adjacency.rs:76,88 |
og_id_*, og_make_id |
immutable, parallel_safe, strict |
id.rs:73-91 |
og_reach, og_csr_reach, og_csr_hops |
stable, parallel_restricted |
traverse.rs:80,359,442 |
og_cypher_check, og_cypher_columns |
immutable, parallel_safe |
cypher/mod.rs:699,717 |
og_cypher_sql, og_typeql_sql |
stable |
cypher/mod.rs:74, typeql/mod.rs:82 |
og_cypher, og_typeql |
(기본) VOLATILE |
cypher/mod.rs:83, typeql/mod.rs:48 |
og_cypher_stats |
volatile, parallel_unsafe |
cypher/mod.rs:117 |
og_node_json / og_edge_json이 PARALLEL SAFE가 아니라는 점이 중요하다. 컴파일러가 타입 미상 프로퍼티 접근에 이 함수를 쓰므로(compile.rs:991,1013), MATCH (n) WHERE n.x = 1류의 질의는 병렬 계획을 아예 못 받는다. 라벨이 붙은 질의는 실컬럼을 쓰므로 병렬 가능하다. → CODE-24.
6.1 STABLE 계약 위반
STABLE은 "데이터베이스를 수정하지 않는다"를 뜻한다. 그런데:
og_cypher_sql(cypher/mod.rs:74,stable) →compile_cached→views::ensure_view→CREATE OR REPLACE VIEW(views.rs:135)og_typeql_sql(typeql/mod.rs:82,stable) →Compiler::new→schema::ensure_has_type→INSERT INTO og_catalog.type+CREATE TABLE(typeql/schema.rs:526-552)
읽기 전용 트랜잭션이나 스탠바이에서 이 함수들을 부르면 실패한다. og_apply_role로 default_transaction_read_only = on을 걸면 같은 일이 벌어진다 (agent/mod.rs:434). → CODE-02.
7. 사실 — Bolt 게이트웨이의 동시성
- 커넥션 1개 = 스레드 1개 = PostgreSQL 백엔드 1개 (
bolt/src/main.rs:69-79). 스레드 간 공유 상태는Arc<Config>(불변)뿐이다. - 커넥션 풀이 없으므로 Bolt 커넥션 수가 곧 PostgreSQL 커넥션 수다.
max_connections가 사실상의 한계다. - 백엔드가 세션에 고정되므로
og_cypher_stats()와 CSR 같은 백엔드-로컬 상태가 Bolt 세션 수명 동안 일관된다. 이건 의도된 것이다 (session.rs:368-372). RESET은 열린 트랜잭션을 롤백한다 (session.rs:198-203).
8. 결정 요약
| 결정 | 근거 | 대가 |
|---|---|---|
| 트랜잭션을 시작/종료하지 않는다 | 확장은 호출자의 트랜잭션 안에 산다 | 부분 실패를 세밀하게 다룰 수 없음 |
| 명시적 락 없음 | PostgreSQL 기본값에 의존 | MERGE/put이 원자적이 아님 (4.3, 4.4) |
| 식별자 발급이 사실상의 직렬화 지점 | storage/mod.rs:24-34 |
타입 단위 쓰기 병목 |
| 백엔드-로컬 캐시 3종 | 성능 | 롤백/스키마 변경과 불일치 (CODE-01) |
| CSR은 스냅샷을 얼린다 | traverse.rs:19-23 |
RLS 우회, 최신성 없음 — 문서화된 트레이드오프 |
| 쓰기 경로가 DDL을 유발할 수 있다 | Neo4j처럼 스키마 없이 쓸 수 있어야 함 | 초기 적재 시 락 경합 |
금지 / 필수
- 금지: 확장 코드 안에서
BEGIN/COMMIT/ROLLBACK/SAVEPOINT를 실행하는 것. 호출자의 트랜잭션을 깨뜨린다. - 금지:
SpiClient나SpiTupleTable을Spi::connect클로저 밖으로 반출하는 것. 결과는Vec으로 물질화한 뒤 반환한다. - 금지:
#[pg_extern(stable)]이나immutable함수에서 DDL/DML을 하는 것. 현재 위반 2건이 있다 (6.1절). - 금지: 백엔드-로컬 캐시(
PLAN_CACHE,CSR)에 트랜잭션 정합성이 필요한 정보를 넣는 것. - 필수:
MERGE/put의 멱등성이 필요하면 고유 인덱스를 반드시 선언한다. 코드는 이를 보장하지 않는다. - 필수: 대량 적재 전에 스키마를 먼저 선언한다 (
og_create_type/og_add_property/ TypeQLdefine). 쓰기 경로에서 DDL이 일어나지 않게 하는 유일한 방법이다. - 필수:
og_csr_build()를 쓴 뒤 데이터를 바꿨으면 다시 빌드한다. RLS가 걸린 그래프에서는og_csr_*를 쓰지 않는다.