Contribution · PostgreSQL · contrib/btree_gist
btree_gist NaN ordering — float4/float8 opclass
contrib/btree_gist의 float opclass가 raw C ==/</> 를 직접 써서 IEEE 754 NaN을 잘못 처리. EXCLUDE 우회 / RLS 누수 / index ↔ seq scan 결과 불일치 세 시나리오 동시 발생. NaN-aware float{4,8}_* 헬퍼 + float{4,8}_cmp_internal()로 교체해 regular btree opclass와 같은 total order로 맞춘 패치.
1. 버그 정의
contrib/btree_gist/btree_float4.c·btree_float8.c의 GiST 콜백 5개 (gbt_float{4,8}gt, ge, eq, le, lt) 가 raw C 연산자를 직접 사용:
static bool
gbt_float8eq(const void *a, const void *b, FmgrInfo *flinfo)
{
return (*((const float8 *) a) == *((const float8 *) b));
}
IEEE 754에서 NaN과의 모든 비교는 false. 결과 — gbt_float8eq(NaN, NaN)이 false, gbt_float8lt(NaN, 1.0)도 false, gbt_float8gt(NaN, 1.0)도 false. GiST gbt_num_consistent가 이 콜백들을 통해 NaN을 판단하니까, "NaN을 못 찾음" / "NaN 중복을 허용함" / "NaN을 누락함" 식의 불일치가 발생.
반면 regular btree opclass는 float{4,8}_cmp_internal()를 통해 일관된 total order를 갖고 있음 — "NaN은 모두 동등, NaN이 모든 non-NaN 위에 정렬". 따라서 같은 float 컬럼에 대해 두 인덱스 종류가 다른 결과를 돌려주는 상태.
2. 재현 (BUG #19524에서 인용)
EXCLUDE 우회:
CREATE EXTENSION btree_gist;
CREATE TABLE reservations (
room float4,
during tsrange,
EXCLUDE USING gist (room WITH =, during WITH &&)
);
INSERT INTO reservations VALUES ('NaN'::float4, '[2025-01-01,2025-01-02)');
INSERT INTO reservations VALUES ('NaN'::float4, '[2025-01-01,2025-01-02)');
-- 기대: 두 번째 INSERT가 EXCLUDE 위반으로 거부 (count = 1)
-- 실제: 둘 다 통과 (count = 2)
SELECT COUNT(*) FROM reservations;
Index ↔ seq scan 불일치:
CREATE TABLE t (val float8);
CREATE INDEX ON t USING gist (val);
INSERT INTO t SELECT 'NaN'::float8 FROM generate_series(1, 2000);
SET enable_indexscan = off; SET enable_bitmapscan = off;
SELECT COUNT(*) FROM t WHERE val = 'NaN'; -- 기대: 2000, 실제: 2000 (정상)
RESET ALL;
SET enable_seqscan = off; SET enable_bitmapscan = off;
SELECT COUNT(*) FROM t WHERE val = 'NaN'; -- 기대: 2000, 실제: 0 (버그)
RLS 누수:
CREATE TABLE measurements (id int, val float8);
CREATE INDEX ON measurements USING gist (val);
INSERT INTO measurements VALUES (1, 'NaN'), (2, 1.5);
ALTER TABLE measurements ENABLE ROW LEVEL SECURITY;
ALTER TABLE measurements FORCE ROW LEVEL SECURITY;
CREATE POLICY hide_nan ON measurements FOR SELECT USING (val != 'NaN'::float8);
CREATE ROLE lowpriv LOGIN;
GRANT SELECT ON measurements TO lowpriv;
SET ROLE lowpriv;
SET enable_seqscan = off;
SELECT * FROM measurements ORDER BY id;
-- 기대: (2, 1.5) 한 행만
-- 실제: (1, NaN) + (2, 1.5) 두 행 모두 노출
3. 수정
5개 boolean 콜백을 utils/float.h의 NaN-aware 헬퍼 inline 함수로 교체. picksplit 비교(gbt_float{4,8}key_cmp)는 float{4,8}_cmp_internal()로:
// BEFORE
static bool
gbt_float8eq(const void *a, const void *b, FmgrInfo *flinfo)
{
return (*((const float8 *) a) == *((const float8 *) b));
}
// AFTER
static bool
gbt_float8eq(const void *a, const void *b, FmgrInfo *flinfo)
{
return float8_eq(*((const float8 *) a), *((const float8 *) b));
}
float8_eq는 utils/float.h에 inline으로 정의 — isnan(val1) ? isnan(val2) : !isnan(val2) && val1 == val2. 즉 NaN ↔ NaN을 true로 처리. 이 헬퍼들이 그대로 regular btree opclass의 비교 함수에 쓰이고 있어, 패치 후 두 인덱스 종류가 같은 동작.
on-disk 포맷 변경 없음 — GiST 트리 구조나 키 레이아웃은 그대로, 메모리 내 비교 로직만 NaN-aware. 따라서 기존 인덱스를 REINDEX할 필요도 없음.
4. 회귀 테스트
contrib/btree_gist/sql/float4.sql · float8.sql에 NaN 섹션 추가 — 세 가지 검증:
- Index scan과 seq scan이
WHERE v = 'NaN'에 대해 같은 row count 반환 - NaN이 finite 값 뒤로 정렬됨 (NaN > 1.0)
- EXCLUDE 제약이 NaN 중복을 거부 (두 번째 INSERT가 충돌 에러)
기대 출력은 contrib/btree_gist/expected/float4.out · float8.out에 추가.
5. 빌드 / 테스트 결과
- ARM macOS (Apple clang 15, system bison 2.3, brew openssl@3 / readline / icu4c@77) 환경에서
./configure --with-icu --with-openssl --with-readline --enable-cassert --enable-debug CFLAGS="-O0 -g"통과 make -j8빌드 통과make check(core) — 245/245 통과make -C contrib/btree_gist check— 32/32 통과 (신규 NaN 케이스 포함)make -C contrib check— contrib 48 modules 전체 통과, 실패 0