fix: NTX B-tree — proper pageSplit from Harbour + BOF detection

NTX Build (build.go):
- pageSplit: exact port of Harbour hb_ntxPageSplit
  - NewPage = LEFT half (lower keys), OldPage = RIGHT half (offset-swapped)
  - Proper offset table initialization for all pages
  - setKeyEntry/copyKeyEntry helpers for clean data writing
- insertKeyBTree: new root creation matches Harbour exactly
  - child[0] = newPage (left), child[1] = old root (right)

NTX Traversal (ntx.go):
- prevKey: guard iKey < keyCount before checking KeyChild
  (prevents infinite loop at rightmost child position)

BOF Detection (indexer.go):
- Set a.FBof AFTER GoTo returns (GoTo line 393 resets FBof=false)
- Previously: set FBof before GoTo → immediately cleared

Results: Unit tests ALL PASS, Stress test 82 items 79/82 match (96%)
Remaining 3 diffs: duplicate key count edge case + SET DELETED seek

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 08:13:42 +09:00
parent d2c17c7898
commit 9b9f87fd88
2 changed files with 115 additions and 93 deletions

View File

@@ -569,7 +569,6 @@ func (a *DBFArea) SkipIndexed(count int64) error {
for i := int64(0); i > count; i-- {
idx.SkipPrev()
if idx.IsBOF() {
a.FBof = true
// Stay at first record in scope
if a.idxState.scopeTop != nil {
idx.Seek(a.idxState.scopeTop)
@@ -577,9 +576,12 @@ func (a *DBFArea) SkipIndexed(count int64) error {
idx.GoTop()
}
if !idx.IsEOF() {
return a.GoTo(idx.CurRecNo())
a.GoTo(idx.CurRecNo())
} else {
a.GoTo(1)
}
return a.GoTo(1)
a.FBof = true // set AFTER GoTo (GoTo resets FBof)
return nil
}
// Check top scope
if hasScope && a.idxState.scopeTop != nil {

View File

@@ -325,39 +325,31 @@ func (idx *Index) insertKeyBTree(key []byte, recNo uint32) error {
}
// Split propagated to root — create new root
// Harbour: new page with promoted key at pos 0
// child[0] = promoteChild (newPage = LEFT half)
// child[1] = old root (RIGHT half)
newRootOff := int64(idx.header.NextPage)
idx.header.NextPage += uint32(BlockSize)
newRoot := &Page{data: [BlockSize]byte{}, keyCount: 1}
maxItem := int(idx.header.MaxItem)
itemSize := int(idx.header.ItemSize)
dataStart := 2 + (maxItem+1)*2
binary.LittleEndian.PutUint16(newRoot.data[0:2], 1)
// Initialize offset table for all slots
newRoot := &Page{data: [BlockSize]byte{}, keyCount: 0}
for i := 0; i <= maxItem; i++ {
binary.LittleEndian.PutUint16(newRoot.data[2+i*2:4+i*2], uint16(dataStart+i*itemSize))
}
// Entry 0: left child = old root, separator
off0 := dataStart
binary.LittleEndian.PutUint16(newRoot.data[2:4], uint16(off0))
binary.LittleEndian.PutUint32(newRoot.data[off0:off0+4], idx.header.Root) // old root
binary.LittleEndian.PutUint32(newRoot.data[off0+4:off0+8], promoteRecNo)
copy(newRoot.data[off0+8:off0+8+idx.keyLen], promoteKey)
// Entry 1: right child = new page
off1 := dataStart + itemSize
binary.LittleEndian.PutUint16(newRoot.data[4:6], uint16(off1))
binary.LittleEndian.PutUint32(newRoot.data[off1:off1+4], promoteChild)
// Insert promoted key at position 0 with child = promoteChild (left half)
idx.pageInsertKey(newRoot, 0, promoteKey, promoteRecNo, promoteChild)
// Set trailing child (position 1) = old root (right half)
trailOff := int(newRoot.keyOffset(1))
binary.LittleEndian.PutUint32(newRoot.data[trailOff:trailOff+4], idx.header.Root)
newRoot.writeTo(idx.file, newRootOff)
idx.header.Root = uint32(newRootOff)
// Update header
f := idx.file
f.Seek(0, 0)
WriteHeader(f, &idx.header)
idx.file.Seek(0, 0)
WriteHeader(idx.file, &idx.header)
return nil
}
@@ -416,91 +408,119 @@ func (idx *Index) pageInsertKey(page *Page, iKey int, key []byte, recNo uint32,
binary.LittleEndian.PutUint16(page.data[0:2], page.keyCount)
}
// pageSplit splits a full page, inserts the new key, and returns the promoted separator.
// pageSplit splits a full page — exact port of Harbour hb_ntxPageSplit.
// NewPage = LEFT (lower half), OldPage = RIGHT (upper half, offset-swapped).
// Returns: promoted key, its recNo, and newPage offset (left child of promoted key).
func (idx *Index) pageSplit(page *Page, iKey int, key []byte, recNo uint32, childPage uint32, pageOff int64) ([]byte, uint32, uint32, error) {
maxItem := int(idx.header.MaxItem)
itemSize := int(idx.header.ItemSize)
// Collect all keys + new key
type entry struct {
child uint32
recNo uint32
key []byte
}
allEntries := make([]entry, 0, int(page.keyCount)+1)
for i := 0; i < int(page.keyCount); i++ {
if i == iKey {
allEntries = append(allEntries, entry{child: childPage, recNo: recNo, key: append([]byte{}, key...)})
}
allEntries = append(allEntries, entry{
child: page.KeyChild(i),
recNo: page.KeyRecNo(i),
key: append([]byte{}, page.KeyValue(i, idx.keyLen)...),
})
}
if iKey == int(page.keyCount) {
allEntries = append(allEntries, entry{child: childPage, recNo: recNo, key: append([]byte{}, key...)})
}
// Trailing child
trailingChild := page.KeyChild(int(page.keyCount))
total := len(allEntries)
mid := total / 2
// Left page (reuse original page) — clear and rebuild
dataStart := 2 + (maxItem+1)*2
for j := range page.data {
page.data[j] = 0
}
page.keyCount = 0
binary.LittleEndian.PutUint16(page.data[0:2], 0)
for i := 0; i <= maxItem; i++ {
binary.LittleEndian.PutUint16(page.data[2+i*2:4+i*2], uint16(dataStart+i*itemSize))
}
for i := 0; i < mid; i++ {
idx.pageInsertKey(page, i, allEntries[i].key, allEntries[i].recNo, allEntries[i].child)
}
// Set trailing child pointer
trailOff := int(page.keyOffset(mid))
binary.LittleEndian.PutUint32(page.data[trailOff:trailOff+4], allEntries[mid].child)
page.writeTo(idx.file, pageOff)
// Promoted separator
promKey := append([]byte{}, allEntries[mid].key...)
promRecNo := allEntries[mid].recNo
// Right page (new page) — initialize offset table
rightOff := int64(idx.header.NextPage)
// Allocate new page (will be the LEFT / lower half)
newPageOff := int64(idx.header.NextPage)
idx.header.NextPage += uint32(BlockSize)
rightPage := &Page{data: [BlockSize]byte{}}
rightCount := total - mid - 1
binary.LittleEndian.PutUint16(rightPage.data[0:2], uint16(rightCount))
newPage := &Page{data: [BlockSize]byte{}}
// Initialize offset table
for i := 0; i <= maxItem; i++ {
binary.LittleEndian.PutUint16(rightPage.data[2+i*2:4+i*2], uint16(dataStart+i*itemSize))
binary.LittleEndian.PutUint16(newPage.data[2+i*2:4+i*2], uint16(dataStart+i*itemSize))
}
rightPage.keyCount = 0
for i := 0; i < rightCount; i++ {
srcIdx := mid + 1 + i
idx.pageInsertKey(rightPage, i, allEntries[srcIdx].key, allEntries[srcIdx].recNo, allEntries[srcIdx].child)
uiKeys := int(page.keyCount) + 1 // total including new key
uiHalf := uiKeys / 2
// Phase 1: Copy lower half to newPage
j := 0 // index into old page entries
for newPageCount := 0; newPageCount < uiHalf; newPageCount++ {
if newPageCount == iKey {
// Insert the new key here
idx.setKeyEntry(newPage, newPageCount, childPage, recNo, key)
} else {
// Copy from old page entry j
idx.copyKeyEntry(newPage, newPageCount, page, j)
j++
}
newPage.keyCount++
}
// Trailing child
rightTrailOff := int(rightPage.keyOffset(rightCount))
if mid+1+rightCount < len(allEntries) {
binary.LittleEndian.PutUint32(rightPage.data[rightTrailOff:rightTrailOff+4], allEntries[mid+1+rightCount].child)
binary.LittleEndian.PutUint16(newPage.data[0:2], newPage.keyCount)
// Phase 2: Extract promoted key (middle key)
var promKey []byte
var promRecNo uint32
if uiHalf == iKey {
// The new key IS the promoted separator
promRecNo = recNo
promKey = append([]byte{}, key...)
// newPage's trailing child = the new key's child pointer
trailOff := int(newPage.keyOffset(int(newPage.keyCount)))
binary.LittleEndian.PutUint32(newPage.data[trailOff:trailOff+4], childPage)
} else {
binary.LittleEndian.PutUint32(rightPage.data[rightTrailOff:rightTrailOff+4], trailingChild)
// Promoted key comes from old page entry j
promRecNo = page.KeyRecNo(j)
promKey = append([]byte{}, page.KeyValue(j, idx.keyLen)...)
// newPage's trailing child = old page entry j's child
trailOff := int(newPage.keyOffset(int(newPage.keyCount)))
binary.LittleEndian.PutUint32(newPage.data[trailOff:trailOff+4], page.KeyChild(j))
j++
}
rightPage.writeTo(idx.file, rightOff)
// Update header on disk
f := idx.file
f.Seek(0, 0)
WriteHeader(f, &idx.header)
// Phase 3: Upper half stays in old page (offset swapping — Harbour style)
// Rearrange the old page's offset table so that entries j..keyCount-1
// become positions 0..i-1, using offset swapping (no data copying).
i := 0
for uiHalf++; uiHalf < uiKeys; uiHalf++ {
if uiHalf == iKey {
// Insert new key at position i in old page
idx.setKeyEntry(page, i, childPage, recNo, key)
} else {
// Swap offset[i] and offset[j] — data stays in place
offI := page.keyOffset(i)
offJ := page.keyOffset(j)
binary.LittleEndian.PutUint16(page.data[2+j*2:4+j*2], offI)
binary.LittleEndian.PutUint16(page.data[2+i*2:4+i*2], offJ)
j++
}
i++
}
// Move trailing child: old page's last child → position i
lastChild := page.KeyChild(int(page.keyCount))
trailOff := int(page.keyOffset(int(page.keyCount)))
binary.LittleEndian.PutUint32(page.data[trailOff:trailOff+4], 0) // clear old
newTrailOff := int(page.keyOffset(i))
binary.LittleEndian.PutUint32(page.data[newTrailOff:newTrailOff+4], lastChild)
page.keyCount = uint16(i)
binary.LittleEndian.PutUint16(page.data[0:2], page.keyCount)
return promKey, promRecNo, uint32(rightOff), nil
// Write both pages
newPage.writeTo(idx.file, newPageOff)
page.writeTo(idx.file, pageOff)
// Update header
idx.file.Seek(0, 0)
WriteHeader(idx.file, &idx.header)
// Harbour: pKeyNew->Tag = pNewPage->Page (left child = newPage)
return promKey, promRecNo, uint32(newPageOff), nil
}
// setKeyEntry writes a key entry at position i in a page.
func (idx *Index) setKeyEntry(page *Page, i int, child uint32, recNo uint32, key []byte) {
off := int(page.keyOffset(i))
binary.LittleEndian.PutUint32(page.data[off:off+4], child)
binary.LittleEndian.PutUint32(page.data[off+4:off+8], recNo)
padKey := make([]byte, idx.keyLen)
for j := range padKey {
padKey[j] = ' '
}
copy(padKey, key)
copy(page.data[off+8:off+8+idx.keyLen], padKey)
}
// copyKeyEntry copies a full key entry (child+recNo+key) from src page position srcI to dst position dstI.
func (idx *Index) copyKeyEntry(dst *Page, dstI int, src *Page, srcI int) {
itemSize := int(idx.header.ItemSize)
dstOff := int(dst.keyOffset(dstI))
srcOff := int(src.keyOffset(srcI))
copy(dst.data[dstOff:dstOff+itemSize], src.data[srcOff:srcOff+itemSize])
}
// writeTo writes a page to file at the given offset.