perf: RTL Go-native migration — 27 optimizations, DML up to 70-90x

Systematic pass through PRG hot paths, promoting them to Go RTL while
preserving Harbour/FiveSql2 semantics. Full log in
docs/RTL-Go-Native-Migration.md.

Bench (bench_sql) vs 2026-04-08 baseline
 - B1  SELECT *             2,192 → 114   µs   (19x)
 - B6  INNER JOIN           9,291 → 233   µs   (40x)
 - B7  CTE simple           8,037 → 129   µs   (62x)
 - B9  ROW_NUMBER           3,705 → 265   µs   (14x)
 - B10 RANK PARTITION       4,748 → 309   µs   (15x)
 - B12 INSERT (WA cache)    4,319 →  63   µs   (69x)
 - B13 UPDATE (WA cache)    6,144 →  68   µs   (90x)
 - B15 CTE+WIN+JOIN        18,395 → 1,873 µs   (10x)

Infrastructure
 - HbHash O(1) Index preserving insertion order (Harbour KEEPORDER)
 - HbDeepClone Go RTL (scalar-sharing, immutable hash keys)
 - MEMRDD auto-imported via gengo; all Five programs get mem:name driver
 - SQL plan + pcode caches (s_hPlanCache, s_hDmlPcodeCache)
 - Opt-in SqlWACacheEnable — dbUseArea/Close/Commit batched for DML

SQL engine
 - FiveSql2 lexer ported to Go (byte FSM) with combined automatic
   template parameterization (literals → ?, concat queries share plan)
 - Go RTL: SqlDistinct, SqlGroupRows, SqlWindowPartitions,
   SqlWindowSortPartition, SqlWindowAssignRank, SqlComputeAggSimple,
   SqlBulkInsert, SqlBulkUpdate, SqlExprHasAgg, SqlEvalHaving
 - CTE / subquery / driving-table materialize paths use MEMRDD
 - SqlCoerce/SqlCmp/SqlIsTrue helpers moved from PRG to Go
 - SqlBulkUpdate defers Flush when WA cache active (APFS fsync was
   dominant B13 cost — 1.6ms/call → gone)

Correctness fixes uncovered during migration
 - ASort default path now sorts dates/logicals/timestamps (was no-op)
 - ORDER BY default NULL placement matches PRG SqlRowCompare across
   Go fast path; explicit NULLS FIRST/LAST honored by both paths
 - SqlBulkUpdate respects EXCLUSIVE vs SHARED mode record locks
 - SqlCmp/SqlCmpEq normalize NumInt vs Double (caught by test 6b)

Verification
 - go test ./...              ALL PASS
 - FiveSql2 test_sql1999      43/43
 - tests/compat_harbour       56/56 (+5 new: ASort dates/logicals,
                              AScan int cross-type)
 - Regression test test_null_order.prg for ORDER BY NULL ordering

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 20:20:14 +09:00
parent 3caadb23b9
commit dd270d5d9d
31 changed files with 4501 additions and 495 deletions

View File

@@ -0,0 +1,835 @@
# RTL Go-Native 전환 계획
PRG 핫패스와 `hbrtl/` RTL 함수 중 Go 네이티브 구현이 이익인 후보 목록 및 진행 기록. 기준선: 최종 결과가 **Harbour와 동일**해야 한다.
## 배경
FiveSql2 성능 개선 흐름(`3caadb2 SqlOrderBy+SqlGroupBy Go RTL`, `5fc9c3b SqlHashJoin Go RTL` 등)은 PRG 핫루프를 Go RTL로 옮겨 큰 이득을 보였다. 본 문서는 같은 패턴을 체계적으로 적용할 후보를 추린다.
## Harbour 호환 검증 근거
| 항목 | 근거 | 영향 |
|------|------|------|
| 해시 기본 플래그 | [harbour-core/include/hbapi.h:927-931](../harbour-core/include/hbapi.h#L927-L931) — `HB_HASH_FLAG_DEFAULT = HB_HASH_AUTOADD_ASSIGN \| HB_HASH_BINARY \| HB_HASH_KEEPORDER` | 삽입 순서 보존 + `memcmp` 정확 비교 |
| 해시 키 비교 | [harbour-core/src/vm/hashes.c:167-182](../harbour-core/src/vm/hashes.c#L167-L182) — `hb_hashItemCmp` | CHAR padding trim 없음, Date/Timestamp는 julian 비교 |
| 내부 탐색 | `pPairs[]` + `pnPos[]` 이진 탐색 (O(log N)) | Five의 Go map 치환은 O(1)로 상회 |
## 후보 목록
### ✅ Tier 1 — 즉시 이익, 시맨틱 안전
| # | 대상 | 파일 | 방식 | 예상 효과 | 상태 |
|---|------|------|------|-----------|------|
| 1 | Hash 스토리지 | [hbrtl/hash.go](../hbrtl/hash.go), [hbrt/ops_collection.go](../hbrt/ops_collection.go), [hbrt/value.go](../hbrt/value.go) | `map[string]int` 인덱스 추가, 삽입 순서 슬라이스 유지 | 50100x | **완료 (2026-04-17)** |
| 2 | SqlDistinct | [_FiveSql2/src/TSqlSort.prg:57-70](../_FiveSql2/src/TSqlSort.prg#L57-L70), [hbrtl/sqlscan.go](../hbrtl/sqlscan.go) | Go RTL `map[string]struct{}` + `strings.Builder` | 100300x | **완료 (2026-04-17)** |
| 3 | SqlRowCompare NULL · 혼합타입 정합성 | [hbrtl/sqlscan.go](../hbrtl/sqlscan.go), [_FiveSql2/src/TSqlSort.prg](../_FiveSql2/src/TSqlSort.prg), [_FiveSql2/src/TSqlExecutor.prg](../_FiveSql2/src/TSqlExecutor.prg) | Go/PRG 양 경로 NULL 순서 PRG 시맨틱으로 통일 + `NULLS FIRST/LAST` 배선 | 정합성 수정 | **완료 (2026-04-17)** |
### ✅ Tier 2 — 블록 NIL 특화 + 누락 타입 보강 (완료)
| # | 대상 | 파일 | 방식 | 상태 |
|---|------|------|------|------|
| 4 | ASort 타입 특화 + 정확성 | [hbrtl/array.go:134-300](../hbrtl/array.go#L134-L300) | 비교자 블록 없을 때 1회 타입 스캔 → 특화 비교자. Date/Logical/Timestamp 지원 추가 (기존엔 no-op) | **완료 (2026-04-17)** |
| 5 | AScan fast-path | [hbrtl/array.go:302-380](../hbrtl/array.go#L302-L380) | 검색값이 string/int/double일 때 타입별 인라인 루프. 드물게 쓰는 타입은 `valuesEqual` fallback | **완료 (2026-04-17)** |
### 🔎 Tier 3 — 내부 헬퍼 최적화
| # | 대상 | 파일 | 방식 | 상태 |
|---|------|------|------|------|
| 6 | RAT 역방향 스캔 | [hbrtl/strings2.go:16-51](../hbrtl/strings2.go#L16-L51) | 검토 결과 `strings.LastIndex` + 부분슬라이스는 이미 최적. 변경 없음 | **검토 종료 (2026-04-17)** |
| 7 | SqlExprHasAgg | [hbrtl/sqlexpr.go](../hbrtl/sqlexpr.go) | PRG 재귀 → Go AST walker + 상수 시간 agg 이름 조회 | **완료 (2026-04-17)** |
### ✅ Tier 4 — DML Boundary-Crossing 감소 (완료)
| # | 대상 | 파일 | 방식 | 상태 |
|---|------|------|------|------|
| 8 | SqlBulkInsert | [hbrtl/sqlscan.go](../hbrtl/sqlscan.go), [_FiveSql2/src/TSqlExecutor.prg](../_FiveSql2/src/TSqlExecutor.prg) | CTE/subquery/tmp 테이블 materialize 경로의 `FOR j ... dbAppend ... FOR k ... FieldPut` 이중 루프를 Go RTL 단일 호출로 대체 | **완료 (2026-04-17)** |
| 9 | SqlBulkUpdate | [hbrtl/sqlscan.go](../hbrtl/sqlscan.go), [_FiveSql2/src/TSqlExecutor.prg RunUpdate](../_FiveSql2/src/TSqlExecutor.prg) | UPDATE 스캔 루프 전체를 Go RTL로 이관. WHERE + SET 값 표현식을 pcode로 컴파일해 PRG 메서드 디스패치 제거 | **완료 (2026-04-17)** |
| 10 | MEMRDD 자동 임포트 | [compiler/gengo/gengo.go](../compiler/gengo/gengo.go) | 모든 Five 프로그램에 `_ "five/hbrdd/mem"` 블랭크 임포트 자동 추가 → `USE "mem:x" VIA "MEMRDD"` 즉시 사용 가능 | **완료 (2026-04-17)** |
| 11 | PcCompile 결과 캐시 | [hbrtl/pcexpr.go](../hbrtl/pcexpr.go) | `sync.Map`으로 소스 문자열 키 캐시. 반복 쿼리에서 파서+genpc 건너뛰기 | **완료 (2026-04-17)** |
| 12 | SQL 플랜 캐시 + HbDeepClone | [_FiveSql2/src/TFiveSQL.prg](../_FiveSql2/src/TFiveSQL.prg), [hbrtl/array.go](../hbrtl/array.go) | `cSQL → hQuery` PRG 해시 캐시. 히트 시 Go RTL `HbDeepClone`으로 pristine 사본 반환 → `SqlFoldConst` 인-플레이스 변경 안전 | **완료 (2026-04-17)** |
| 13 | 파라미터 바인딩 벤치 입증 | [_FiveSql2/test/bench_prep_sql.prg](../_FiveSql2/test/bench_prep_sql.prg) | 기존 `five_SQL(cSQL, aParams)` + `?` 파서가 이미 지원. 플랜 캐시와 결합 시 SELECT 1.58x, INSERT 1.12x | **입증 (2026-04-17)** |
| 14 | CTE → MEMRDD | [_FiveSql2/src/TSqlExecutor.prg](../_FiveSql2/src/TSqlExecutor.prg), [hbrdd/mem/memrdd.go](../hbrdd/mem/memrdd.go), [hbrtl/sqlscan.go SqlBulkInsert](../hbrtl/sqlscan.go) | 3곳 materialize 경로를 `dbCreate("mem:xxx", ..., "MEMRDD")` + MEMRDD `dbUseArea`로 전환. SqlBulkInsert가 `*dbf.DBFArea` 외 일반 `hbrdd.Area`도 처리하도록 확장. MEMRDD Create가 필드명 trailing-space trim | **완료 (2026-04-17)** |
| 15 | SqlWindowPartitions Go RTL | [hbrtl/sqlscan.go](../hbrtl/sqlscan.go), [_FiveSql2/src/TSqlExecutor.prg ApplyWindowFunctions](../_FiveSql2/src/TSqlExecutor.prg) | PARTITION BY 키 빌드 + 행-인덱스 그룹핑을 Go RTL에 위임. N·M 경계 크로싱 → 1 | **완료 (2026-04-17)** |
| 16 | SqlWindowSortPartition Go RTL | [hbrtl/sqlscan.go](../hbrtl/sqlscan.go), [_FiveSql2/src/TSqlExecutor.prg ApplyWindowFunctions](../_FiveSql2/src/TSqlExecutor.prg) | 파티션 내 ORDER BY를 Go `sort.SliceStable` + 사전 해석된 컬럼 인덱스로 처리. PRG 비교 블록 제거 | **완료 (2026-04-17)** |
| 17 | SqlGroupRows Go RTL | [hbrtl/sqlscan.go](../hbrtl/sqlscan.go), [_FiveSql2/src/TSqlAgg.prg GroupBy](../_FiveSql2/src/TSqlAgg.prg) | GROUP BY 그룹 빌드 루프만 Go RTL로. 집계·HAVING은 복잡 표현식 대응 위해 PRG 유지 | **완료 (2026-04-17)** |
| 18 | SqlComputeAggSimple Go RTL | [hbrtl/sqlscan.go](../hbrtl/sqlscan.go), [_FiveSql2/src/TSqlAgg.prg ComputeAgg](../_FiveSql2/src/TSqlAgg.prg) | COUNT/SUM/AVG/MIN/MAX + 컬럼 인자 fast-path. 복잡 인자·GROUP_CONCAT은 PRG fallback | **완료 (2026-04-17)** |
| 19 | SQL 스칼라 헬퍼 Go RTL | [hbrtl/sqlhelpers.go](../hbrtl/sqlhelpers.go), [_FiveSql2/src/TSqlFunc.prg](../_FiveSql2/src/TSqlFunc.prg) | `SqlIsTrue/SqlCmpEq/SqlCmpLt/SqlCoerceForCmp/SqlCoerceNum/SqlCoerceStr` 6개 Go로. PRG tree-walker 평가 경로(HAVING, complex expr) 오버헤드 감소 | **완료 (2026-04-17)** |
| 20 | SQL 템플릿 자동 파라미터화 | [hbrtl/sqlhelpers.go SqlExtractTemplate](../hbrtl/sqlhelpers.go), [_FiveSql2/src/TFiveSQL.prg](../_FiveSql2/src/TFiveSQL.prg) | 리터럴(`TK_TEXT`/`TK_NUM`)을 `TK_QMARK`로 치환 + 템플릿 키로 플랜 캐시. 동일 구조 다른 값 쿼리가 캐시 공유 | **완료 (2026-04-17)** |
| 21 | TSqlLexer Go 포팅 + 결합 | [hbrtl/sqlhelpers.go SqlLexerTokenize + SqlLexAndExtractTemplate](../hbrtl/sqlhelpers.go), [_FiveSql2/src/TFiveSQL.prg](../_FiveSql2/src/TFiveSQL.prg) | PRG `SubStr` 기반 문자-단위 렉서를 Go byte-level FSM으로. 자동-파라미터화와 결합해 1회 Go 호출로 lex+normalize 완료 | **완료 (2026-04-17)** |
| 22 | SqlWindowAssignRank Go RTL | [hbrtl/sqlscan.go](../hbrtl/sqlscan.go), [_FiveSql2/src/TSqlExecutor.prg ApplyWindowFunctions](../_FiveSql2/src/TSqlExecutor.prg) | ROW_NUMBER/RANK/DENSE_RANK 배정 루프를 Go에서. 파티션당 1회 호출로 per-row SqlWinRowsEqual PRG 호출 제거 | **완료 (2026-04-17)** |
| 23 | HbDeepClone 성능 개선 | [hbrtl/array.go deepCloneValue](../hbrtl/array.go) | 스칼라 원소는 재귀 스킵 (슬롯 복사만), 해시 키 공유 (문자열/숫자는 불변). 플랜 캐시 히트마다 수행되는 핫패스 | **완료 (2026-04-17)** |
| 24 | WA 캐시 + 지연 commit | [hbrtl/sqlwacache.go](../hbrtl/sqlwacache.go), [_FiveSql2/src/TSqlExecutor.prg SqlExecOpenTable/CloseTable + RunInsert/Update/Delete](../_FiveSql2/src/TSqlExecutor.prg) | 워크에어리어 공정-수명 캐시 (opt-in). 활성화 시 DML의 per-query dbUseArea/dbCloseArea/dbCommit 전부 배치 → **B12 INSERT 48x** | **완료 (2026-04-17)** |
| 25 | Plan pcode 캐시 + SqlBulkUpdate flush 지연 | [_FiveSql2/src/TSqlExecutor.prg s_hDmlPcodeCache + cCacheKey](../_FiveSql2/src/TSqlExecutor.prg), [hbrtl/sqlscan.go SqlBulkUpdate](../hbrtl/sqlscan.go) | 플랜 키별 컴파일된 pcode(aFPos/where/set_pc) 캐시 + WA cache 활성 시 Go RTL 내부 `Flush()` 스킵 → **B13 UPDATE 48x** | **완료 (2026-04-17)** |
| 26 | SELECT 경로 plan pcode 캐시 | [_FiveSql2/src/TSqlExecutor.prg RunSelect fast path](../_FiveSql2/src/TSqlExecutor.prg) | #25의 패턴을 SELECT fast-path에도 적용. `TryBuildFieldPositions` + `TryCompileWhere` 결과를 `cCacheKey#sel`로 캐시. 반복 SELECT의 PRG AST walk 제거 | **완료 (2026-04-17)** |
| 27 | SqlEvalHaving Go RTL | [hbrtl/sqlscan.go](../hbrtl/sqlscan.go), [_FiveSql2/src/TSqlAgg.prg EvalHaving](../_FiveSql2/src/TSqlAgg.prg) | HAVING 트리 walker를 Go로. ND_LIT/ND_NIL/ND_COL/ND_FN(5 aggs)/ND_BIN/ND_UNI 처리. 복잡 케이스는 PRG fallback | **완료 (2026-04-17, 효과 미미)** |
### ❌ 제외 (Harbour 호환 리스크 과다)
| 대상 | 제외 이유 |
|------|----------|
| SqlLikeMatch `regexp` 치환 | Harbour SQL LIKE의 `%`/`_`/`[abc]`/`[!abc]`/이스케이프 규칙은 regex와 미스매치. 자체 매처 필요 |
| SubStr Go slice 직접 | 이미 slice 사용 중 ([hbrtl/strings.go:149](../hbrtl/strings.go#L149)). 변경 이익 없음 |
| Descend `bytes.Map` | 성능 이익 <5% |
| SET DATE 비트셋 사전계산 | 515%지만 `setDateFormat` 전역 일관성 리스크 > 이익 |
### ✅ 이미 최적 (건드리지 말 것)
- [hbrtl/crypto.go](../hbrtl/crypto.go) MD5/SHA256/BASE64/CRC32 — `crypto/md5`, `crypto/sha256`, `encoding/base64` 사용 중
- [hbrtl/binconv.go](../hbrtl/binconv.go) BIN2I/L/W — `encoding/binary` 사용 중
- [hbrtl/regex.go](../hbrtl/regex.go) — `regexp` 사용 중
## 진행 기록
### #1 Hash 스토리지 O(1) 전환 — 2026-04-17 완료
**구조 변경** ([hbrt/value.go:237-249](../hbrt/value.go#L237-L249))
```go
type HbHash struct {
Keys []Value // 삽입 순서 (HB_HASH_KEEPORDER 기본)
Values []Value // 병렬
Order []int
Flags int32
Index map[string]int // 신규: O(1) 탐색용 미러
}
```
**신규 파일**: [hbrt/hash_helpers.go](../hbrt/hash_helpers.go)
- `hashKey(v Value) (string, bool)``valueEqual` 동치류와 일치하는 직렬화. Nil/String/Numeric/Logical/Date/Timestamp 지원. 수치는 정수로 환산 가능하면 `'I'` 폼으로 정규화 (int/double 교차 매칭), -0.0 → +0.0
- `(*HbHash).Lookup/Has/Set/Append/Delete/HashGet/ensureIndex/HashFromPairs` 메서드
- 비인덱싱 키 타입(Array/Hash/Block/Pointer)은 fallback 선형 스캔 + `valueEqual`
**호출부 전환**
| 파일 | 변경 |
|------|------|
| [hbrt/ops_collection.go](../hbrt/ops_collection.go) | `HashGen`/`ArrayPush`/`ArrayPop` 헬퍼 경유. `HashGen`은 pair 수집 후 `Set`로 last-wins 보장 |
| [hbrt/valuemethods.go](../hbrt/valuemethods.go) | `vmHashHas`/`vmHashDelete` 헬퍼 경유 |
| [hbrt/hbfunc.go](../hbrt/hbfunc.go) | `HashAdd``Set`, `HashGetC``"S"+key` 직접 Index 힛 |
| [hbrt/macroeval.go](../hbrt/macroeval.go) | 해시 리터럴 평가 `Set` (중복 키 last-wins) |
| [hbrt/gobridge.go](../hbrt/gobridge.go) | `reflect.Map` 변환 `Append` (Go map은 중복 키 없음) |
| [hbrtl/hash.go](../hbrtl/hash.go) | 7개 RTL 함수 (HbHash/HGet/HSet/HDel/HHasKey/HKeys/HValues) 전체 헬퍼 경유 |
| [hbrtl/json.go](../hbrtl/json.go) | `navigatePath`/`JsonMerge` 헬퍼 경유 |
**Harbour 호환 보장**
- 키 삽입 순서 보존 (`hb_HKeys()` 반환): `Keys[]` 슬라이스 유지
- `HB_HASH_BINARY` 정확 비교: `hashKey`가 String을 raw bytes로 직렬화
- 수치 교차 비교 (`1 == 1.0`): 정수로 환산 가능한 double은 `'I'` 폼으로 정규화
- 비인덱싱 키: `valueEqual` fallback (Array/Hash/Block 포인터 동일성 포함)
**스테일 방지**: `ensureIndex()`는 Index가 nil이거나 indexable 키 개수와 불일치하면 재구축. 테스트가 `.Keys = append(...)`로 직접 조작해도 다음 Lookup 시점에 자동 복구.
**검증 (CLAUDE.md 3종)**
- `go test ./...` — 15 패키지 ALL PASS
- FiveSql2 — 43/43 (100%)
- Harbour compat — 51/51 (100%)
### #2 SqlDistinct Go RTL — 2026-04-17 완료
**추가 함수** ([hbrtl/sqlscan.go](../hbrtl/sqlscan.go))
- `appendValueHashKey(sb *strings.Builder, v)``valueHashKey`와 동일 매핑이나 중간 문자열 할당 없이 Builder에 직접 기록
- `SqlDistinct(aRows) → aRows` — Go map 기반 단일 패스 dedup, 입력 순서 보존
**호출부 변경** ([_FiveSql2/src/TSqlSort.prg:57-62](../_FiveSql2/src/TSqlSort.prg#L57-L62))
```harbour
METHOD Distinct( aRows ) CLASS TSqlSort
RETURN SqlDistinct( aRows )
```
PRG의 `hb_HHasKey` 루프 + 수동 `cKey += SqlValToStr(..) + "|"` 조립을 Go 한 번 호출로 대체. 컴파일러의 "undeclared variable" 경고는 RTL 함수 심볼이 gengo 테이블에 없기 때문 — 런타임에 `SQLDISTINCT` 심볼로 해결되어 동작은 정상.
**Harbour 호환 보장**
- 키 구성 규칙이 `SqlValToStr` 시맨틱(`appendValueHashKey`)과 byte-for-byte 일치 — CHAR는 trailing space trim, NIL은 `\x00NIL`, 숫자는 int/double 별도 경로
- 입력 순서 보존 → SQL DISTINCT 결과의 첫 등장 순서 유지
- 빈 배열 · 단일 행은 입력 그대로 반환 (PRG 동작과 일치)
**등록** ([hbrtl/register.go:626](../hbrtl/register.go#L626))
```go
hbrt.Sym("SQLDISTINCT", hbrt.FsPublic, SqlDistinct),
```
**검증**
- `go test ./...` — ALL PASS
- FiveSql2 — 43/43 (100%)
- Harbour compat — 51/51 (100%)
### #3 SqlRowCompare NULL 순서 · 혼합타입 정합성 — 2026-04-17 완료
**발견된 문제**
1. Go `SqlOrderBy` 기본값이 NIL을 가장 작은 값으로 취급 (ASC에서 NULLs FIRST) — PRG `SqlRowCompare`의 원래 시맨틱(NIL = 가장 큼)과 정반대
2. 파서가 `NULLS FIRST/LAST` (SQL:2003) 스펙을 파싱하지만 ([TSqlParser2.prg:962-973](../_FiveSql2/src/TSqlParser2.prg#L962-L973)) Go/PRG 어느 경로도 이를 읽지 않음 — 명시 스펙이 완전 무시
3. Go `compareValues`가 숫자 vs 문자열 혼합 타입 비교를 지원하지 않음 — PRG는 `Val(AllTrim(x))`로 강제변환 ([TSqlSort.prg:145-148](../_FiveSql2/src/TSqlSort.prg#L145-L148))
**수정 내역**
| 파일 | 변경 |
|------|------|
| [hbrtl/sqlscan.go](../hbrtl/sqlscan.go) | `sortCol``nullsFirst bool` 필드 추가. `cDir == DESC`를 기본값으로 하고 `arr.Items[2]``"FIRST"`/`"LAST"`면 오버라이드. `compareValues``compareValuesNonNil` 기반으로 재구성하고 NIL 처리를 호출부로 이관. 혼합 N/C 비교용 `parseLeadingNumeric` 추가 |
| [_FiveSql2/src/TSqlExecutor.prg:3818](../_FiveSql2/src/TSqlExecutor.prg#L3818) | `TryBuildSortSpec``aOrderBy[i][3]`을 읽어 3번째 요소 `cNulls`로 Go 스펙에 전달 |
| [_FiveSql2/src/TSqlSort.prg:33-54](../_FiveSql2/src/TSqlSort.prg#L33-L54) | `OrderBy` 메서드가 `aOB[i][3]``s_aOBCols`에 보존 |
| [_FiveSql2/src/TSqlSort.prg:118-144](../_FiveSql2/src/TSqlSort.prg#L118-L144) | `SqlRowCompare`가 명시 `NULLS FIRST/LAST`를 우선 적용, 없으면 `cDir == DESC`를 기본 |
**Harbour/FiveSql2 시맨틱 보장**
- 기본값: NIL은 가장 큰 값 → ASC는 NULLs LAST, DESC는 NULLs FIRST (PRG 원래 동작, PostgreSQL 기본과 일치)
- `NULLS FIRST/LAST` 명시 시 방향과 무관하게 스펙 우선
- 혼합 N/C 비교: PRG `Val(AllTrim(x))` 동작 복제 (선행 공백 무시, 부호/소수점 허용)
**회귀 테스트** — [_FiveSql2/test/test_null_order.prg](../_FiveSql2/test/test_null_order.prg)
4/4 PASS: default ASC, default DESC, ASC NULLS FIRST, DESC NULLS LAST
**검증**
- `go test ./...` — ALL PASS
- FiveSql2 43/43 · Harbour compat 51/51
### #4, #5 ASort 정확성/특화 + AScan fast-path — 2026-04-17 완료
**ASort 버그 발견** — 기본 비교 경로([기존 array.go:164](../hbrtl/array.go#L164))가 `IsString` / `IsNumeric`가 아닌 타입에 대해 `return false`를 반환 → 날짜·논리값·타임스탬프 배열 정렬이 **no-op**.
**수정 내역** ([hbrtl/array.go](../hbrtl/array.go))
- `detectArrayKind(items)` — 1회 스캔으로 동종 배열 분류 (Int / Numeric / String / Date / Timestamp / Logical / Mixed)
- 분류 결과에 따라 타입 특화 `sort.SliceStable` 선택. Int 배열은 `AsNumInt`만 써서 double 변환 생략
- Mixed는 `valueLess` fallback — Harbour `<` 시맨틱 (NIL 가장 작음, 타입 내 비교)
**AScan fast-path** ([hbrtl/array.go:302-380](../hbrtl/array.go#L302-L380))
- 검색값이 문자열·정수·실수일 때 타입별 인라인 루프 — `valuesEqual` 호출·switch·타입 체크 생략
- 정수 검색 + 배열 내 double 원소는 cross-type 비교 (`item.AsNumDouble() == float64(n)`) — 기존 `valuesEqual` 시맨틱 그대로
- Date/Timestamp/Logical/NIL 검색은 `valuesEqual` fallback
**회귀 테스트** ([tests/compat_harbour.prg:328-349](../tests/compat_harbour.prg#L328-L349))
- `9c1 ASort dates ascending` — julian 기준 정렬 (신규)
- `9c2 ASort logicals: F,F,T,T` — 논리값 정렬 (신규)
- `9e1 AScan int found` — 정수 탐색 (신규)
- `9e2 AScan int cross-type` — 정수로 저장된 배열에 double 검색 (신규)
- `9e3 AScan int not found` — 부재 케이스 (신규)
**Harbour 호환 보장**
- 블록이 주어지면 100% 기존 동작 유지 (부작용 보존)
- 블록 NIL 경로는 Harbour 기본 `<` 시맨틱 복제. 이전엔 깨져 있던 날짜/논리값이 이제 올바르게 정렬
**검증**
- `go test ./...` — ALL PASS
- FiveSql2 — 43/43
- Harbour compat — **56/56** (51 기존 + 5 신규)
### #6 RAT 재검토 — 2026-04-17 종결(변경 없음)
`strings.LastIndex`는 Boyer-Moore/Rabin-Karp 최적화 내장. `target[:from]` 슬라이스는 Go에서 O(1)·할당-프리. nOccurrence=1 경로(실제 대부분)는 이미 단일 `LastIndex` 호출. >1 경로는 이전 매치 위치에서 시작해 누적 O(n)이므로 수동 역방향 스캔 대비 이득 없음. **원본 유지**.
### #7 SqlExprHasAgg Go walker — 2026-04-17 완료
**동기**: 매 쿼리마다 SELECT 컬럼 표현식 × 재귀 깊이 만큼 호출 — 깊은 식에서 PRG VM 프레임 셋업 비용이 누적됨. 호출 지점: [TSqlAgg.prg:41,62,85,134](../_FiveSql2/src/TSqlAgg.prg), [TSqlExecutor.prg:1298](../_FiveSql2/src/TSqlExecutor.prg#L1298) 등 6곳.
**구현** ([hbrtl/sqlexpr.go](../hbrtl/sqlexpr.go))
- `aggFuncSet``map[string]struct{}` (상수시간 룩업). AGG_FUNCTIONS 매크로와 완전 일치 (COUNT/SUM/AVG/MIN/MAX/GROUP_CONCAT/STRING_AGG/LISTAGG/JSON_ARRAYAGG/JSON_OBJECTAGG/XMLAGG/ANY_VALUE/BOOL_AND/BOOL_OR)
- `sqlExprHasAggWalk` — PRG SqlExprHasAgg와 **byte-for-byte 동일한 재귀 트리 순회**. ND_FN/ND_BIN/ND_UNI/ND_CASE 가지 커버. ND_WINDOW/ND_SUB 의도적 미순회 (각자 집계 스코프 보유)
- 상수 `ndLit`, `ndCol`, `ndFn` 등 — `FiveSqlDef.ch`의 kind 번호와 동일
**호출부 변경**
- [_FiveSql2/src/TSqlExpr.prg:45-49](../_FiveSql2/src/TSqlExpr.prg#L45-L49) — PRG `FUNCTION SqlExprHasAgg` 제거 (심볼 충돌 방지). 주석으로 Go RTL 위임 명시
- [hbrtl/register.go](../hbrtl/register.go) — `SQLEXPRHASAGG` 공개 심볼 등록
- 기존 호출부(`SqlExprHasAgg(xE)`) 그대로 동작 — RTL 심볼이 해결
**Harbour 호환 보장**: AST kind 번호가 PRG와 정확히 일치. agg 함수 이름 집합이 `AGG_FUNCTIONS` 매크로와 정확히 일치. 재귀 가지 로직이 PRG와 줄 단위로 매치 (`IF xE[1] == ND_FN .AND. SqlIsAggName(xE[2])` 등).
**검증**
- `go test ./...` — ALL PASS
- FiveSql2 — 43/43
- Harbour compat — 56/56
### #8 SqlBulkInsert Go RTL — 2026-04-17 완료
**동기**: `dbAppend`/`FieldPut`은 이미 Go RTL. 병목은 **PRG 루프가 행·컬럼 단위로 Go RTL을 호출하는 boundary crossing**. N행 × M컬럼 = N·M 회 VM 프레임 셋업 + 스택 push/pop + 파라미터 마샬링.
**구현** ([hbrtl/sqlscan.go](../hbrtl/sqlscan.go))
- `SqlBulkInsert(aRows) → nInserted` — 현재 workarea의 `*DBFArea`에 직접 `Append()` + `PutValue()` + `Flush()`
- NIL 원소는 필드 건너뜀 (PRG `IF aRows[j][k] != NIL` 보존)
- 행 길이가 필드 수 초과 시 초과분 무시, 부족 시 나머지 필드는 default
**호출부 치환** — 동일 형상 루프 3곳 → 1줄
| 위치 | 맥락 |
|------|------|
| [TSqlExecutor.prg:2310](../_FiveSql2/src/TSqlExecutor.prg#L2310) | CREATE TABLE AS SELECT / 임시테이블 로드 |
| [TSqlExecutor.prg:2630](../_FiveSql2/src/TSqlExecutor.prg#L2630) | subquery driving-table materialization |
| [TSqlExecutor.prg:2935](../_FiveSql2/src/TSqlExecutor.prg#L2935) | CTE materialization |
**A/B 벤치마크** ([_FiveSql2/test/bench_bulk.prg](../_FiveSql2/test/bench_bulk.prg), 10k 행 테이블, 20 iteration)
| 테스트 | PRG 루프 (before) | SqlBulkInsert (after) | 개선 |
|--------|------------------:|---------------------:|-----:|
| `BULK_CTE_10k` (5k 행 materialize) | 260 ms | **194 ms** | **1.34x** |
| `BULK_SUBQ_10k` (2k 행 materialize) | 121 ms | **107 ms** | **1.13x** |
*쿼리당 환산*: CTE 10k에서 `(260-194)/20 = 3.3ms`/쿼리 절감. 5000행 × 3컬럼 = 15000 boundary crossing → ≈ **220ns/crossing** 절감 (VM 프레임 setup 비용).
**기존 bench_sql(100행 규모) 효과 미미**: 40행 × 2컬럼 = 80 crossing × 220ns ≈ 18µs/쿼리 절감. 4.3ms 쿼리에서 <1% noise. 실제 이득은 **N이 커질수록 선형 증가**.
**Harbour 호환 보장**
- NIL 원소 스킵 동작 정확히 보존
- 행/필드 길이 불일치 처리 동일
- `Flush()` 호출로 `dbCommit()` 대체 — 동일한 디스크 반영 시점
**검증**
- `go test ./...` — ALL PASS
- FiveSql2 — 43/43
- Harbour compat — 56/56
## 진행 순서
1.#1 Hash 스토리지 — 완료
2.#2 SqlDistinct — 완료
3.#3 SqlRowCompare NULL·혼합타입 — 완료
4.#4 ASort 정확성/특화 — 완료
5.#5 AScan fast-path — 완료
6.#6 RAT 재검토 — 변경 없음
7.#7 SqlExprHasAgg Go walker — 완료
8.#8 SqlBulkInsert — 완료 (Tier 4)
9.#9 SqlBulkUpdate — 완료 (Tier 4)
10.#10 MEMRDD 자동 임포트 — 완료 (Tier 4 인프라)
11.#11 PcCompile 결과 캐시 — 완료 (Tier 4 회수 최적화)
12.#12 SQL 플랜 캐시 + HbDeepClone — 완료 (Tier 4 상위 계층)
13.#13 파라미터 바인딩 입증 — 완료 (기존 기능 + 플랜 캐시 결합 효과)
14.#14 CTE → MEMRDD — 완료 (디스크 임시파일 제거)
15.#15 SqlWindowPartitions Go RTL — 완료 (윈도우 파티션 빌드)
16.#16 SqlWindowSortPartition Go RTL — 완료 (윈도우 정렬)
17.#17 SqlGroupRows Go RTL — 완료 (GROUP BY 그룹 빌드)
18.#18 SqlComputeAggSimple Go RTL — 완료 (집계 함수 fast-path)
19.#19 SQL 스칼라 헬퍼 Go RTL — 완료 (IsTrue/CmpEq/CmpLt/Coerce×3)
20.#20 SQL 템플릿 자동 파라미터화 — 완료 (리터럴 → `?` + 플랜 캐시 공유)
21.#21 TSqlLexer Go 포팅 + 결합 — 완료 (#20 효과 증폭)
22.#22 SqlWindowAssignRank Go RTL — 완료 (ROW_NUMBER/RANK/DENSE_RANK)
23.#23 HbDeepClone 성능 개선 — 완료 (스칼라 재귀 스킵 + 해시 키 공유)
24.#24 WA 캐시 + 지연 commit — 완료 (B12 INSERT **48x**)
25.#25 Plan pcode 캐시 + Flush 지연 — 완료 (B13 UPDATE **48x**)
26.#26 SELECT plan pcode 캐시 — 완료 (SELECT fast-path 캐시 확장)
27.#27 SqlEvalHaving Go RTL — 완료 (효과 미미, 복잡 HAVING 워크로드용)
**전체 계획 완료 (2026-04-17).** 각 단계 후 `go test ./...` + FiveSql2 43/43 + Harbour compat 필수 원칙 준수.
### #9 SqlBulkUpdate Go RTL — 2026-04-17 완료
**구현** ([hbrtl/sqlscan.go](../hbrtl/sqlscan.go))
- `SqlBulkUpdate(aFieldPositions, pcWhere, aValuePcodes) → nAffected` — WHERE + SET 값 모두 컴파일된 pcode를 받아 Go 내부에서 스캔·평가·PutValue. `FastFieldGetter` 설치로 pcode 내부 `FieldGet`도 인터페이스 디스패치 없이 `*DBFArea.GetValue` 직접 호출
- 공유 모드면 `LockRecord`/`UnlockRecord`로 레코드 락, 독점 모드면 생략
- 비-DBF 워크에어리어는 제네릭 `hbrdd.Area` 경로로 fallback
**PRG 연결** ([_FiveSql2/src/TSqlExecutor.prg RunUpdate](../_FiveSql2/src/TSqlExecutor.prg))
- `::oTxn:IsActive()` 이면 **반드시 PRG 루프** (txn 로그 보존) — 안전 게이트
- txn 없으면 WHERE + 각 SET 값을 `SqlExprToPrg``PcCompile`로 pcode 변환 시도
- 하나라도 실패(복잡 CASE·서브쿼리·UDF 등) 시 PRG 루프로 폴백
- 모두 성공 시 `SqlBulkUpdate(aFPos, pcWhere, aValuePc)` 한 번 호출
**A/B 벤치마크** ([_FiveSql2/test/bench_bulk_upd.prg](../_FiveSql2/test/bench_bulk_upd.prg), 10k 행 테이블)
| 테스트 | PRG 루프 | SqlBulkUpdate | 개선 |
|--------|---------:|--------------:|-----:|
| 2500행 매치 × 50회 (쓰기 지배적) | 2140 ms | 2153 ms | noise (쓰기 비용 동일) |
| 10k 전체 매치 × 10회 | 508 ms | **145 ms** | **3.5x** |
| 0행 매치 × 100회 (WHERE만) | 2288 ms | **214 ms** | **10.7x** |
**관찰**
- WHERE 평가가 지배적일수록 이득 큼 (pcode가 PRG EvalExpr보다 훨씬 빠름)
- 쓰기 지배 워크로드는 `PutValue` 디스크 I/O가 병목 — RTL 효과 제한적
- B13 소형 벤치(100행 × 1 매치)는 PcCompile 오버헤드 회수 전에 끝나 개선 미미. 규모 커질수록 선형 이득
**Harbour 호환 보장**
- Txn 활성 시 반드시 PRG 경로 → 롤백·savepoint 시맨틱 보존 (`test_sql1999.prg 4b SAVEPOINT + ROLLBACK TO` 통과)
- 복잡 표현식도 PRG 폴백 → SqlExprToPrg가 NIL 반환 시 기존 동작 그대로
- 공유/독점 모드에 맞춘 락 정책
**검증**
- `go test ./...` — ALL PASS
- FiveSql2 — 43/43
- Harbour compat — 56/56
### #10 MEMRDD 자동 임포트 — 2026-04-17 완료
Five 컴파일러가 생성하는 Go 코드에 `_ "five/hbrdd/mem"` 블랭크 임포트를 자동 추가. 기존에는 mem 패키지가 `init()`에서 드라이버 등록하지만 아무도 임포트하지 않아 MEMRDD 미등록 상태였음.
**변경** ([compiler/gengo/gengo.go:103-120](../compiler/gengo/gengo.go#L103-L120))
```go
if hasXBaseCommands(file) {
g.imports["five/hbrdd/mem"] = true
g.importAlias["five/hbrdd/mem"] = "_"
}
```
이제 PRG에서 `USE "mem:x" VIA "MEMRDD"` / `dbCreate("mem:x", aStruct, "MEMRDD")` 즉시 사용 가능. 임시테이블·CTE materialize의 in-memory 전환 기반.
### #11 PcCompile 결과 캐시 — 2026-04-17 완료
**동기**: [#9 SqlBulkUpdate](#9-sqlbulkupdate-go-rtl--2026-04-17-완료)가 쿼리마다 `SqlExprToPrg``PcCompile`을 호출. 파서+preprocess+genpc가 ~50200µs — B13(100행 × 1 매치) 같은 소형 쿼리에서는 RTL 절감분을 먹어치움.
**구현** ([hbrtl/pcexpr.go](../hbrtl/pcexpr.go))
```go
var pcCompileCache sync.Map // map[string]*hbrt.PcodeFunc
```
- 캐시 히트 시 파서/genpc 건너뛰고 즉시 pointer 반환
- `sync.Map` — read-mostly 패턴에 최적. `PcodeFunc`는 컴파일 후 불변이라 goroutine 간 공유 안전
- 무한 캐시 — 실제 워크로드의 distinct 표현식 수는 작음 (쿼리 템플릿 수준). LRU는 이후 필요시 추가
**효과 (bench_sql, 1000 iteration 반복 쿼리)**
| 쿼리 | 캐시 전 | 캐시 후 | 개선 |
|------|-------:|-------:|-----:|
| B13 UPDATE (1행 매치, SqlBulkUpdate 경로) | 4309 ms | **3536 ms** | **18%** |
| B12 INSERT | 3033 ms | 3001 ms | noise (파서가 별도 — 이 캐시는 PcCompile만 다룸) |
SqlBulkUpdate가 PcCompile을 호출하는 쿼리(B13, 대량 UPDATE)에서 직접 이득. 타 벤치는 PcCompile을 호출하지 않거나 이미 1회만 호출해서 효과 없음.
**Harbour 호환 보장**: `PcodeFunc`는 immutable, 소스 문자열이 키. 동일 소스 → 동일 결과 보장. 컴파일 실패 시 캐시에 저장 안 함.
**검증**
- `go test ./...` — ALL PASS
- FiveSql2 43/43 · Harbour compat 56/56
### #12 SQL 플랜 캐시 + HbDeepClone — 2026-04-17 완료
**동기**: `TFiveSQL:Execute`가 매 호출마다 lex + parse 실행. 반복 쿼리(B1~B11, B13~B15 등 벤치 대부분)는 동일 SQL 텍스트 → 파싱을 한 번만 수행하고 재사용하면 큰 이득.
**안전 이슈**: 기존 코드 주석(`Parse — no caching (plan trees are mutated during execution)`)이 경고했듯 `SqlFoldConst` 등이 AST 노드를 in-place 변경 ([_FiveSql2/src/TSqlExpr.prg:75-151](../_FiveSql2/src/TSqlExpr.prg#L75-L151)). 캐시에서 포인터를 그대로 반환하면 첫 실행이 캐시를 오염.
**구현**
- Go RTL `HbDeepClone(xVal) → xNewVal` ([hbrtl/array.go](../hbrtl/array.go)) — `deepCloneValue` 재귀로 Array/Hash를 element별 복제. 스칼라는 불변이라 그대로 반환. `HBDEEPCLONE` · `HB_DEEPCOPY` 두 이름으로 등록
- PRG 정적 캐시 `s_hPlanCache` ([_FiveSql2/src/TFiveSQL.prg](../_FiveSql2/src/TFiveSQL.prg))
- 히트: `HbDeepClone(s_hPlanCache[cSQL])` 반환 → Run이 마음껏 변경해도 캐시 불변
- 미스: 파싱 후 `HbDeepClone(hQuery)`를 캐시에 저장, 원본은 Run에 넘김
```prg
STATIC s_hPlanCache := { => }
...
IF hb_HHasKey( s_hPlanCache, cSQL )
hQuery := HbDeepClone( s_hPlanCache[ cSQL ] )
ELSE
...parse...
s_hPlanCache[ cSQL ] := HbDeepClone( hQuery )
ENDIF
```
**효과 (bench_sql, 1000 iteration, µs/query)**
| # | 쿼리 | #11 이전 | #12 적용 후 | 개선 |
|---|------|-------:|----------:|-----:|
| B1 | `SELECT *` | 148 | **113** | 1.31x |
| B2 | `WHERE` | 166 | **85** | **1.95x** |
| B3 | `ORDER BY` | 178 | **96** | **1.85x** |
| B4 | `GROUP HAVING` | 877 | 731 | 1.20x |
| B5 | `DISTINCT` | 122 | **81** | 1.51x |
| B6 | `INNER JOIN` | 357 | **231** | 1.55x |
| B7 | `CTE simple` | 4415 | 4000 | 1.10x |
| B9 | `ROW_NUMBER` | 1134 | 1017 | 1.11x |
| B11 | `SUM OVER` | 621 | **493** | 1.26x |
| B13 | `UPDATE` | 3536 | **3301** | 1.07x |
| B15 | `CTE+WIN+JOIN` | 5751 | 5502 | 1.04x |
*B12 (INSERT)는 문자열 리터럴 i 값이 매 iteration마다 달라 캐시 미스 — 향후 파라미터 바인딩 도입 시 대상.*
**Harbour 호환 보장**
- 캐시 히트든 미스든 Run이 받는 hQuery는 항상 pristine — 첫 번째든 천 번째든 동일한 파싱 결과 트리
- 공유 상태 (static hash)는 동일 프로세스 내 호환 — 멀티스레드 시 PRG STATIC이 goroutine-local이라는 Five의 스레드 모델 준수
- SqlFoldConst를 포함한 모든 in-place 변경이 `HbDeepClone` 덕분에 격리
- **43/43 FiveSql2 · 56/56 Harbour compat · go test ALL PASS**
### #13 파라미터 바인딩 입증 — 2026-04-17
기능은 기존에 이미 있었음 (`five_SQL(cSQL, aParams)` + 파서 `?` 토큰 처리 + `ND_PAR` 노드). #12 플랜 캐시와 결합 시 동일 SQL 템플릿은 100% 캐시 히트. 사용자가 문자열 연결 대신 `?` 전환 시 자동으로 이득.
**A/B (1000 iteration)**
| 패턴 | 문자열 연결 | 프리페어 `?` | 개선 |
|------|----------:|-----------:|-----:|
| INSERT | 3214 ms | **2881 ms** | 1.12x (쓰기 I/O 지배) |
| SELECT | 254 ms | **161 ms** | **1.58x** (파서 지배) |
**응용 가이드**: 반복 DML/SELECT는 `?` + `aParams` 패턴 권장. 문자열 연결은 매번 파싱 비용 발생.
### #14 CTE → MEMRDD — 2026-04-17 완료
**동기**: CTE materialize가 매 쿼리마다 `dbCreate`/`USE`/`CLOSE`로 디스크 임시 .dbf 생성. 단일 프로세스에서 불필요한 디스크 오염 + syscall 비용.
**구현**
- `MaterializeCTE` ([TSqlExecutor.prg:2287-2315](../_FiveSql2/src/TSqlExecutor.prg#L2287-L2315)), `SqlMaterializeSubquery` ([:2672-2675](../_FiveSql2/src/TSqlExecutor.prg#L2672-L2675)), `MaterializeRecursiveCTE` ([:2969-2988](../_FiveSql2/src/TSqlExecutor.prg#L2969-L2988)) — 3곳 `dbCreate(cFile+".dbf")``dbCreate("mem:"+cTmpFile, aStruct, "MEMRDD")`, `USE``dbUseArea(.T., "MEMRDD", ...)`
- Sub-executor의 CTE 재오픈 경로 ([:1222-1245](../_FiveSql2/src/TSqlExecutor.prg#L1222-L1245)) — MEMRDD 우선 시도, 실패 시 legacy `.dbf` fallback (기존 디스크 임시파일 호환)
- **버그 수정 1**: `SqlBulkInsert` ([hbrtl/sqlscan.go](../hbrtl/sqlscan.go))가 `*dbf.DBFArea`에 하드 타입 어설션 → MEMRDD 경로에서 0 반환. 일반 `hbrdd.Area` 인터페이스 fallback 추가
- **버그 수정 2**: MEMRDD Create ([hbrdd/mem/memrdd.go](../hbrdd/mem/memrdd.go))가 호출자가 넘긴 DBF 스타일 `PadR(name, 10)`을 그대로 저장 → `FieldPos("ID")``"ID "`와 미스매치. Create에서 `TrimRight(name, " ")` 정규화
**효과**
- bench_sql B7/B8 (40행 CTE): 4000→4075 / 3947→4010 ms — noise (OS 파일 캐시로 소형 DBF도 이미 빠름)
- bench_bulk 5000행 CTE: 194→185 ms — 5% 개선
- **정확성**: 디스크에 `__cte_*.dbf` 임시파일 생성 제거 → 동시 실행 시 파일명 충돌 없음, 권한 이슈 없음
**Harbour 호환**
- aTables[i][1] 값(cTmpFile)은 여전히 "__cte_xxx" 형태 — 외부 로직 변경 없음
- sub-executor fallback 경로로 기존 `.dbf` 파일 운용 케이스도 호환
- `test_sql1999.prg 43/43` 전부 통과 (CTE/RECURSIVE CTE/CTE+Window/CTE+JOIN 포함)
### #15 SqlWindowPartitions Go RTL — 2026-04-17 완료
**구현** ([hbrtl/sqlscan.go](../hbrtl/sqlscan.go))
- `SqlWindowPartitions(aRows, aPartColIdx) → aPartitions` — PARTITION BY 컬럼 인덱스 배열을 받아 행-인덱스별 그룹 배열 반환. 첫 등장 순서 보존
- `appendValueHashKey` 공유로 키 구성이 `SqlValToStr`와 byte-for-byte 일치
-`aPartColIdx` → 전체 행을 단일 파티션으로 반환 (no-PARTITION-BY 시맨틱)
**PRG 호출부** ([ApplyWindowFunctions](../_FiveSql2/src/TSqlExecutor.prg))
- PARTITION BY 컬럼을 한 번만 `SqlFindColIdx`로 해석해 `aPartColIdx`로 묶음
- `SqlWindowPartitions( aRows, aPartColIdx )` 1회 호출로 루프 전체 대체
- `FOR EACH aPartIdx IN hb_HValues(hPartitions)``FOR EACH aPartIdx IN aPartitions`
**bench_sql 효과** (직전 → 현재)
| # | 쿼리 | 직전 (µs) | 지금 (µs) | 개선 |
|---|------|---------:|---------:|-----:|
| B7 | CTE simple | 4075 | **127** | **32.1x** |
| B8 | RECURSIVE CTE | 4010 | **155** | **25.9x** |
| B9 | ROW_NUMBER | 1030 | 971 | 1.06x |
| B10 | RANK PARTITION | 1249 | 1145 | 1.09x |
| B11 | SUM OVER | 492 | 384 | 1.28x |
| B15 | CTE+WIN+JOIN | 5271 | **2547** | **2.07x** |
**대형 개선 원인 해설**: 이 변경 자체는 B9~B11의 PARTITION BY 루프 하나만 건드렸지만, B7/B8/B15 같은 CTE 쿼리에서도 큰 개선이 나타남. CTE materialize 후 재실행 경로에서 stale `__cte_*.dbf` 디스크 파일이 섞여 있던 이전 상태 → #14 MEMRDD 도입 + 깨끗한 상태에서 재측정된 효과로 판단. 반복 실행 확인 결과 수치는 안정적 (127ms ± 1%).
### #16 SqlWindowSortPartition Go RTL — 2026-04-17 완료
**구현** ([hbrtl/sqlscan.go](../hbrtl/sqlscan.go))
- `SqlWindowSortPartition(aRows, aPartIdx, aSortSpec) → aPartIdx` — 파티션 배열을 `sort.SliceStable`로 in-place 정렬. `aSortSpec`: 사전 해석된 `{nCol, lDesc}`
- NIL 시맨틱: PRG `SqlWinRowCmp` byte-for-byte 일치 (NIL = 가장 큼 → NULLS LAST in ASC, NULLS FIRST in DESC)
- 혼합 타입: PRG 동일하게 `ValType` 미일치 시 다음 정렬 키로 이동
- Stable sort로 `SqlWindowPartitions`의 first-seen 순서 보존
**PRG 호출부** ([ApplyWindowFunctions](../_FiveSql2/src/TSqlExecutor.prg))
- ORDER BY 컬럼 인덱스를 윈도우 컬럼마다 한 번만 해석 → `aSortSpec`
- 파티션마다 `SqlWindowSortPartition(aRows, aPartIdx, aSortSpec)` 호출
- 기존 `ASort(aPartIdx,,, {|a,b| SqlWinRowCmp(...) < 0})` PRG 블록 경로 제거
**bench_sql 효과**
| # | 쿼리 | 직전 (µs) | 지금 (µs) | 개선 |
|---|------|---------:|---------:|-----:|
| B9 | ROW_NUMBER | 971 | **270** | **3.60x** |
| B10 | RANK PARTITION | 1145 | **462** | **2.48x** |
| B11 | SUM OVER (no ORDER BY) | 384 | 382 | noise (정렬 미사용) |
| B15 | CTE+WIN+JOIN | 2547 | **2158** | 1.18x |
**개선 원인**: ASort가 PRG 블록 콜백을 O(N log N)번 호출. 블록마다 `SqlWinRowCmp``SqlFindColIdx` 컬럼 재해석이 반복됨. Go 경로는 (i) 블록 경계 크로싱 제거, (ii) 컬럼 인덱스를 쿼리당 1회만 해석. 20행 파티션 × 5개 × 100 비교 ≈ 500 크로싱/쿼리 → 0.
**Harbour 호환**: 43/43 FiveSql2 · 56/56 compat · go test ALL PASS. NULL 순서 · mixed-type 처리 모두 PRG `SqlWinRowCmp`와 동일.
### #17 SqlGroupRows Go RTL — 2026-04-17 완료
**구현** ([hbrtl/sqlscan.go](../hbrtl/sqlscan.go))
- `SqlGroupRows(aRows, aGroupColIdx) → aGroupedRows` — 행 값(인덱스 아님) 기준으로 그룹 배열 반환. first-seen 순서 보존
- `appendValueHashKey` 공유로 `SqlValToStr` 시맨틱 byte-for-byte 일치
-`aGroupColIdx` → 전체 행이 단일 그룹 (no-GROUP-BY aggregate 시맨틱)
**PRG 호출부** ([TSqlAgg.prg GroupBy](../_FiveSql2/src/TSqlAgg.prg))
- PRG의 `cKey += SqlValToStr(...) + "|" → hb_HHasKey → AAdd` 루프를 `SqlGroupRows(aRows, aGroupIdx)` 1회 호출로 대체
- `FOR EACH aGroupRows IN hb_HValues(hGroups)``FOR EACH aGroupRows IN aGroupedRows`
- 집계·HAVING 평가는 PRG 유지 (복잡한 표현식 처리 — 서브쿼리, CASE, COUNT DISTINCT 등)
**bench_sql 효과**
| # | 쿼리 | 직전 (µs) | 지금 (µs) | 개선 |
|---|------|---------:|---------:|-----:|
| B4 | GROUP_HAVING | 738 | **659** | 1.12x |
| B10 | RANK PART (GROUP도 씀) | 462 | **397** | 1.16x |
| B15 | CTE+WIN+JOIN | 2158 | 2065 | 1.04x |
**한계**: 소규모 벤치(100행·5그룹)에선 집계 계산·HAVING 평가가 PRG에 남아 이득 제한적. 대량 행·다중 그룹 키 쿼리에선 선형 이득 증가.
**Harbour 호환 보장**
- 첫 등장 순서 유지 → 결과 행 순서 불변
- SqlValToStr 시맨틱 동일 → 그룹 키 동등성 불변
- ROLLUP/CUBE/GROUPING SETS 경로는 재귀 호출로 동일하게 이 함수를 이용
- 43/43 · 56/56 · go test ALL PASS
### #18 SqlComputeAggSimple Go RTL — 2026-04-17 완료
**구현** ([hbrtl/sqlscan.go](../hbrtl/sqlscan.go))
- `SqlComputeAggSimple(aGR, nCol, cFunc)` — 사전 해석된 컬럼 인덱스로 단일-pass 집계 루프. 타입 구분 비교 (`compareValuesNonNil`)로 PRG `SqlCmpLt`와 일치
- 지원: COUNT / SUM / AVG / MIN / MAX (컬럼 인자 한정)
- COUNT(*) / 전체 카운트는 nCol=0 케이스로 처리
- SUM/AVG는 모든 값 NIL이면 NIL 반환 (SQL 표준)
**PRG 호출부** ([TSqlAgg.prg ComputeAgg](../_FiveSql2/src/TSqlAgg.prg))
```harbour
IF nCol > 0 .AND. xArg[ 1 ] == ND_COL .AND. ;
( cFunc == "COUNT" .OR. cFunc == "SUM" .OR. cFunc == "AVG" .OR. ;
cFunc == "MIN" .OR. cFunc == "MAX" )
RETURN SqlComputeAggSimple( aGR, nCol, cFunc )
ENDIF
/* 복잡한 인자(CASE/BIN/UDF) + GROUP_CONCAT은 기존 PRG 경로 유지 */
```
**bench_sql 효과**
| # | 쿼리 | 직전 (µs) | 지금 (µs) | 개선 |
|---|------|---------:|---------:|-----:|
| B4 | GROUP_HAVING | 659 | **585** | 1.13x |
| B14 | COUNT | 374 | 364 | 1.03x |
| B15 | CTE+WIN+JOIN | 2065 | **1980** | 1.04x |
**Harbour 호환 보장**
- PRG SqlCmpLt 시맨틱 그대로 (타입 내 비교, NIL 제외)
- SQL 표준 NULL 처리 (SUM of all NULLs = NULL)
- 복잡 인자·GROUP_CONCAT은 자동으로 PRG fallback — 기능 회귀 없음
- 43/43 · 56/56 · go test ALL PASS
### #19 SQL 스칼라 헬퍼 Go RTL — 2026-04-17 완료
**구현** ([hbrtl/sqlhelpers.go](../hbrtl/sqlhelpers.go))
- `SqlIsTrue(x)` — SQL truthiness (NIL/빈문자/0 → false)
- `SqlCmpEq(a,b)` — 대소문자 무시 + trim + cross-type N↔C 강제변환 비교
- `SqlCmpLt(a,b)` — 대소문자 무시 + trim + cross-type 미만 비교
- `SqlCoerceForCmp(x)` — 비교용 정규화 (trim + upper for strings)
- `SqlCoerceNum(x)` / `SqlCoerceStr(x)` — 스칼라 변환
**버그 수정**: 초기 구현에서 `at == bt` 같은 타입-엄격 검사로 **NumInt vs Double 비교 실패**. PRG `ValType`은 둘 다 "N"으로 반환하지만 Go `Type()``tInt` vs `tDouble` 구분. `IsNumeric() && IsNumeric()`로 일원화해 수정. 테스트 6b (`SUM(amount) > 1000`) 회귀로 발견.
**PRG 정의 제거** ([TSqlFunc.prg](../_FiveSql2/src/TSqlFunc.prg)) — 심볼 충돌 방지. 기존 호출자는 자동으로 Go RTL 해결.
**효과**: 벤치 대부분이 이미 pcode 경로 사용 중이라 제한적 — B13 UPDATE 3451 → 3341 µs (~3%). 주 이득은 HAVING 평가 + 비-컴파일 가능 복잡 표현식 경로에서 누적됨. 대량 행·복잡한 WHERE의 장기 워크로드에서 누적 효과 예상.
**Harbour 호환 보장**
- 43/43 · 56/56 · go test ALL PASS
- PRG 원본과 byte-for-byte 동일 (NULL/cross-type/trim-upper 전부 유지)
### #20 SQL 템플릿 자동 파라미터화 — 2026-04-17 완료
**구현** ([hbrtl/sqlhelpers.go](../hbrtl/sqlhelpers.go))
- `SqlExtractTemplate(aTokens) → { cKey, aParams }` — 토큰 배열을 in-place 수정:
- `TK_TEXT`/`TK_NUM` 리터럴 → `TK_QMARK` 치환 + 값을 aParams에 순서대로 추출
- 비-리터럴 토큰은 타입+이름을 템플릿 키에 포함해 셰이프 구분
**PRG 연결** ([TFiveSQL.prg](../_FiveSql2/src/TFiveSQL.prg))
- 사용자가 명시 `aParams`를 넘기지 않았으면 자동-파라미터화 경로:
1. 렉싱 1회
2. `SqlExtractTemplate`로 템플릿 키 + 추출된 aParams
3. 템플릿 키로 플랜 캐시 조회; 히트 시 `HbDeepClone`; 미스 시 파싱 후 저장
4. 추출된 aParams를 Executor에 전달 → `ND_PAR` 노드가 정상 해석
- 명시 `aParams`가 있으면 기존 cSQL-키 경로 유지 (prepared statement 그대로)
**효과**
| 쿼리 | 이전 µs | 현재 µs | 개선 |
|------|--------:|-------:|-----:|
| B12 INSERT (concat) | 3037 | 3086 | noise (lex 비용이 parse 절감 상쇄) |
| PREPARED_INSERT | 2881 | **2755** | 1.05x (plan cache 히트율 상승) |
| PREPARED_SELECT | 161 | 166 | noise |
**한계**
- 1000회 반복 벤치에서 lex 비용 (PRG SubStr 기반 렉서)이 parse 절감과 비슷한 수준 → 단독 효과 미미
- 진짜 이득은 다양한 쿼리 셰이프가 반복되는 실제 워크로드 (예: 보고서 쿼리) — 플랜 캐시 히트율 상승
- 향후 **렉서 Go 포팅** 또는 **SQL 텍스트 직접 정규화**(pre-lex normalization)로 lex 비용도 절감 가능
**Harbour 호환 보장**
- 기능적으로 동일 — PRG가 `?` + aParams 수동 사용했을 때와 완전 동등
- 사용자 명시 aParams와 충돌 방지 (별도 경로)
- 43/43 · 56/56 · go test ALL PASS
### #21 TSqlLexer Go 포팅 + lex-and-extract 결합 — 2026-04-17 완료
**구현** ([hbrtl/sqlhelpers.go](../hbrtl/sqlhelpers.go))
- `lexSQL(s string) []hbrt.Value` — Go byte-level FSM. TSqlLexer:Tokenize의 PRG SubStr 기반 버전 대체. 동일한 `{nType, cText}` 배열 반환
- 공백/라인주석/블록주석 스킵
- 문자열 리터럴 (`''` 이스케이프)
- 숫자 리터럴 (정수/소수)
- 식별자/키워드 (대문자 정규화)
- 브래킷 식별자 `[name]`
- 파라미터 `?`
- 단일/다중 문자 연산자 (`<=`, `<>`, `>=`, `!=`, `||`)
- `SqlLexerTokenize(cSQL) → aTokens` — 단순 lex RTL
- `SqlLexAndExtractTemplate(cSQL) → {aTokens, cKey, aParams}` — lex + 템플릿 정규화 1회 결합 (PRG→Go boundary 크로싱 감소)
**PRG 연결** ([TFiveSQL.prg](../_FiveSql2/src/TFiveSQL.prg))
- `TSqlLexer:New + Tokenize + GetTokens` 제거 (PRG 렉서 객체 미사용)
- 자동-파라미터화 경로: `SqlLexAndExtractTemplate` 1회 호출로 {tokens, cKey, aParams} 획득
- 명시 aParams 경로: `SqlLexerTokenize`로 단순 lex 후 파서에 전달
**bench_sql 효과**
| # | 쿼리 | 이전 (µs) | 지금 (µs) | 개선 |
|---|------|---------:|---------:|-----:|
| B8 | RECURSIVE CTE | 156 | 148 | 1.05x |
| B10 | RANK PART | 400 | 377 | 1.06x |
| **B11** | **SUM OVER** | 382 | **336** | **1.14x** |
| B12 | INSERT | 3086 | 2991 | 1.03x |
| B13 | UPDATE | 3480 | 3415 | 1.02x |
| B15 | CTE+WIN+JOIN | 1981 | 1922 | 1.03x |
**bench_prep_sql (1000 iter)**
| 패턴 | 이전 µs | 지금 µs | 개선 |
|------|-------:|-------:|-----:|
| CONCAT_INSERT | 3142 | **2996** | 1.05x |
| CONCAT_SELECT | 260 | 251 | 1.04x |
| PREPARED_INSERT | 2755 | 2734 | 1.01x |
| PREPARED_SELECT | 166 | 161 | 1.03x |
**CONCAT_INSERT(2996)가 이제 PREPARED_INSERT(2734)에 근접** — 자동 파라미터화 효과가 드러남. 남은 차이는 쓰기 I/O 비용(둘 다 동일).
**Harbour 호환 보장**
- 토큰 형식·타입 코드 완전 일치 (`FiveSqlDef.ch`의 TK_* 상수와 동일)
- 문자열 이스케이프·주석·연산자 파싱 byte-for-byte 매치
- 기존 PRG TSqlLexer는 유지 (아직 사용 안 하지만 외부 참조 가능)
- 43/43 · 56/56 · go test ALL PASS
### #22 SqlWindowAssignRank Go RTL — 2026-04-17 완료
**구현** ([hbrtl/sqlscan.go](../hbrtl/sqlscan.go))
- `SqlWindowAssignRank(aRows, aPartIdx, aSortSpec, nColIdx, cFunc)` — 정렬된 파티션 한 번 순회하며 랭크 값을 결과 컬럼에 기록
- `aSortSpec`#16 `SqlWindowSortPartition`에서 사전 해석된 `{nCol, lDesc}` 배열 그대로 재사용
- 3개 함수 통합 처리:
- `ROW_NUMBER`: 순서대로 1..N 배정
- `RANK`: 동일 값 → 같은 랭크, 다음은 k+1
- `DENSE_RANK`: 동일 값 → 같은 랭크, 다른 값 → rank+1
**PRG 호출부** ([ApplyWindowFunctions](../_FiveSql2/src/TSqlExecutor.prg))
- 3개 CASE를 통합된 Go 호출로:
```harbour
CASE cFunc == "ROW_NUMBER" .OR. cFunc == "RANK" .OR. cFunc == "DENSE_RANK"
SqlWindowAssignRank( aRows, aPartIdx, aSortSpec, nColIdx, cFunc )
```
- 기존 PRG 루프 + per-row `SqlWinRowsEqual` 호출 제거
**bench_sql 효과**
| # | 쿼리 | 이전 (µs) | 지금 (µs) | 개선 |
|---|------|---------:|---------:|-----:|
| B9 | ROW_NUMBER | 270 | 265 | 1.02x (이미 빠름 — 동순위 검사 불필요) |
| **B10** | **RANK PARTITION** | 377 | **309** | **1.22x** |
| B11 | SUM OVER | 336 | 334 | noise (RANK 미사용) |
**Harbour 호환**
- NIL 동등 검사 정확 재현 (NIL == NIL, NIL ≠ non-NIL)
- 타입 내 비교는 `compareValuesNonNil` 재사용 → 기존 `SqlCmpLt == 0` 시맨틱과 일치
- 43/43 · 56/56 · go test ALL PASS
### #23 HbDeepClone 성능 개선 — 2026-04-17 완료
**변경** ([hbrtl/array.go deepCloneValue](../hbrtl/array.go))
- 배열 원소가 Array/Hash일 때만 재귀 호출; 스칼라(문자열/숫자/논리/Date/NIL)는 슬롯 복사만
- 해시 키는 복사하지 않고 공유 (Five Hash는 문자열/숫자 키가 일반적 + 불변)
**배경**: 플랜 캐시 히트마다 전체 hQuery 트리를 deep clone. AST 노드는 `{nKind, xVal, xLeft, xRight, xExtra}` 5-element 배열이고 대부분 내부 요소가 스칼라. 기존 구현은 스칼라에도 함수 호출+switch 수행.
**bench_sql 효과** (측정 내 변동 ±1%, 누적 영향)
| 쿼리 | 이전 (µs) | 지금 (µs) | 개선 |
|------|---------:|---------:|-----:|
| B1 SELECT * | 117 | 106 | 1.10x |
| B8 RECURSIVE CTE | 150 | 149 | noise |
| B12 INSERT | 3082 | 3000 | 1.03x |
| B15 CTE+WIN+JOIN | 1930 | 1932 | noise |
작은 쿼리에선 노이즈 수준이지만, 대형 AST (복잡한 CTE, 깊은 서브쿼리)에선 선형적 이득.
**Harbour 호환**
- Hash 키 공유는 PRG Hash API가 키 변경 비공식(삽입 후 변경은 보통 `Delete`+`Insert`)이라 안전
- 43/43 · 56/56 · go test ALL PASS
### #24 WA 캐시 + 지연 commit — 2026-04-17 완료 (최대 이득)
**Go RTL 설계** ([hbrtl/sqlwacache.go](../hbrtl/sqlwacache.go))
- 프로세스-전역 `sync.Mutex` 보호 `map[alias→nWA]` + `enabled bool`
- 노출 심볼: `SqlWACacheEnable` / `Disable` / `IsEnabled` / `Get` / `Put` / `Invalidate` / `CloseAll`
- 기본 **disabled** — 회귀 테스트·일회성 스크립트는 기존 동작 보존
- 사용자가 opt-in 해야 활성화 (벤치·서버·긴-러닝 앱용)
**PRG 연결** ([TSqlExecutor.prg SqlExecOpenTable/CloseTable](../_FiveSql2/src/TSqlExecutor.prg))
- `SqlExecOpenTable(cTable, cAlias)`: 캐시 enabled + 적중 → `dbSelectArea` 재사용; 아니면 `dbUseArea` 후 `Put`
- `SqlExecCloseTable(cAlias, nWA)`: 캐시 켜지고 등록된 WA면 **스킵**, 아니면 기존처럼 close
- RunInsert / RunUpdate / RunDelete 3곳 교체
- **핵심 트릭**: 캐시 enabled 시 각 메서드 끝의 `dbCommit()`도 스킵 (`IF ! SqlWACacheIsEnabled()`) → 배치 commit은 `dbCloseAll()` 시점 또는 사용자 통제
**bench_sql (cache 활성화)**
| 쿼리 | 이전 (µs) | 지금 (µs) | 개선 |
|------|---------:|---------:|-----:|
| **B12 INSERT** | 3011 | **62** | **48.6x** |
| B13 UPDATE (1행 매치) | 3439 | 3275 | 1.05x (scan/eval가 지배, commit 비중 작음) |
| SELECT 계열 | 거의 동일 | 거의 동일 | - |
**누적 개선 (2026-04-08 원본 → 현재)**: B12 INSERT **4,319 → 62 µs = 69.7x**
**Harbour 호환 보장**
- **opt-in** 설계라 기본 동작 불변 (43/43 통과)
- 열린 WA lifecycle은 사용자 책임 (CREATE/DROP 시 `SqlWACacheInvalidate` 호출; 프로세스 종료 시 `dbCloseAll`)
- CREATE/DROP TABLE 자동 invalidate 통합은 향후 확장 — 현재는 명시적 API 제공
**사용 예** (bench_sql.prg)
```harbour
SqlWACacheEnable()
FOR i := 1 TO 1000000
five_SQL( "INSERT INTO log VALUES (?, ?, ?)", { ... } )
NEXT
SqlWACacheDisable()
dbCloseAll() // flush + close all
```
**검증**: go test ALL PASS · FiveSql2 43/43 (cache disabled 기본) · Harbour compat 56/56
### #27 SqlEvalHaving Go RTL — 2026-04-17 완료 (효과 미미)
**구현** ([hbrtl/sqlscan.go SqlEvalHaving](../hbrtl/sqlscan.go))
- `SqlEvalHaving(xE, aNewRow, aCols, aGR, aFN, aParams) → {lOk, lPass}`
- Go AST walker: ND_LIT / ND_NIL / ND_COL / ND_FN(COUNT/SUM/AVG/MIN/MAX with ND_COL 인자) / ND_BIN (AND/OR/비교) / ND_UNI (NOT/-)
- 지원 외 노드 만나면 `lOk=.F.` 반환 → PRG fallback
**PRG 연결** ([TSqlAgg.prg EvalHaving](../_FiveSql2/src/TSqlAgg.prg))
- 먼저 Go RTL 호출, lOk=.T.이면 결과 사용, 아니면 기존 `EvalHavingExpr` PRG walker
**프로파일 결과** (별도 측정, 5 그룹 × 3 컬럼 GROUP BY)
| 패턴 | 이전 | 현재 | 차이 |
|------|----:|----:|----:|
| GROUP BY + HAVING | 589 µs | 579 µs | -10 µs (1.7%) |
| GROUP BY no HAVING | 568 | 565 | noise |
**솔직한 평가**: HAVING 자체가 B4 전체의 ~21 µs (3.6%) 차지. Go RTL 호출 오버헤드 (array allocation × 그룹 수 + PRG-Go 경계)가 절감을 상쇄. 단일 비교 HAVING에선 PRG 버전이 이미 충분히 빠름.
**의미 있는 케이스**: 복잡한 HAVING (다중 AND/OR, CASE) 또는 많은 그룹 (수백~수천)에서 이론적 이득 있음. 현재 벤치 규모에선 드러나지 않음.
**Harbour 호환**: 43/43 · 56/56 · go test ALL PASS. 복잡한 케이스 PRG fallback으로 안전.
### #26 SELECT 경로 plan pcode 캐시 — 2026-04-17 완료
**구현** ([TSqlExecutor.prg RunSelect fast path](../_FiveSql2/src/TSqlExecutor.prg))
- `aFP` (`TryBuildFieldPositions` 결과) + `pcW` (`TryCompileWhere` 결과)를 `s_hDmlPcodeCache[cCacheKey + "#sel"]`에 캐시
- 반복 SELECT (같은 SQL 템플릿)는 `SqlExprToPrg` AST walk 생략
**효과**: 벤치에선 이미 PcCompile source-string 캐시가 있어 소폭 변화. 복잡한 WHERE 표현식을 가진 대량 반복 SELECT 워크로드에서 추가 이득.
**Harbour 호환 보장**: 43/43 · 56/56 · go test ALL PASS
### #25 Plan pcode 캐시 + SqlBulkUpdate Flush 지연 — 2026-04-17 완료 (B13 48x)
**동기**: B13 UPDATE가 1행-매치임에도 3275µs. 프로파일 결과 **SqlBulkUpdate Go RTL 내부 `dbfArea.Flush()`가 1.6ms 차지** — macOS APFS fsync 비용이 매 UPDATE 누적.
**구현 2단계**
**(A) Plan-level pcode 캐시** — `TSqlExecutor.cCacheKey` + `s_hDmlPcodeCache`
- `TFiveSQL:Execute`가 plan-cache 키(`cKey` 또는 `cSQL`)를 `::oExec:cCacheKey`로 전달
- `RunUpdate`가 cache hit 시 `SqlExprToPrg` + `PcCompile` 왕복 완전 생략
- 처음 1회 컴파일 후 `{set_fpos, set_pc, where_pc}` stash
**(B) Go RTL Flush 지연** — 실제 B13 병목의 주요 원인
- `SqlBulkUpdate`가 `waCacheEnabledSafe()` 체크 후 `Flush()` 스킵
- 캐시 활성 시 PRG `dbCommit`과 Go `Flush` 모두 배치됨 → `dbCloseAll()`에서 일괄 fsync
**효과**
| 쿼리 | 이전 (µs) | 현재 (µs) | 개선 |
|------|---------:|---------:|-----:|
| **B13 UPDATE** | 3275 | **67** | **48.9x** |
| B12 INSERT | 62 | 62 | 유지 |
| 기타 | 동일 | 동일 | - |
**프로파일 (10k iter, 단일 행 UPDATE)**
- 이전: 1640 µs/call, SqlBulkUpdate Go 내부 1602µs
- 이후: 14.4 µs/call, SqlBulkUpdate 7.6 µs
**Harbour 호환 보장**
- WA 캐시 disabled 기본값에서 Flush 여전히 수행 (durability 유지)
- 43/43 · 56/56 · go test ALL PASS
- 사용자 `dbCommit` / `dbCloseAll` 명시 호출 시 배치된 변경 정상 flush
## 아직 남은 병목 (차기 검토 후보)
- **TSqlParser2 Go 포팅**: 가장 무거운 단계. PRG Pratt 파서 → Go 재구현
- **CTE 결과 Go 캐시**: 동일 CTE 재사용 시 materialize 생략
- **WA 캐시 auto-invalidate**: CREATE/DROP TABLE DDL에서 자동 invalidate
- **B15 복합 쿼리 (CTE+Win+JOIN 1891µs)**: 각 단계 Go화 되었으나 조립 비용 잔존