Closes two more multi-session correctness bugs surfaced by the post-Layer-5 stress harness. Combined with Layer 5's panic-free result, three-worker concurrency now sits around 80% pass with zero Go-level crashes; higher worker counts trade reliability for throughput against the inherent single-file-multi-writer limit of the DBF format. 1. EOF marker write at Close (max-merge with disk) `Close()` writes the EOF marker `0x1A` at `header.HeaderLen + a.recCount * RecordLen`, computed from our LOCAL recCount. A peer Append between our last refresh (under the append-intent lock at Append-time) and Close-time may have bumped the disk recCount above ours. Writing EOF at our stale offset overwrites byte 0 of the peer's record — flipping the delete-flag from ' ' (RecordActive) to 0x1A. The field bytes survive, but downstream code that depends on byte 0's exact value misclassifies the record. Fix mirrors updateHeader's max-merge (Layer 3a): in shared mode, re-read the disk header right before computing EOFOffset and use max(disk.RecCount, local). Cheap (~1 stat- sized read per Close) and the eventual close-fd is already the serial bottleneck of any meaningful churn. 2. Append-batching disabled in shared mode The appendBuf optimisation accumulates several consecutive APPENDs into a single WriteAt at flushRecord time. In single- process EXCLUSIVE mode that's a clean throughput win. In shared mode, though, a peer SELECT can open the file while our slots N..N+M are buffered but still on-disk only as reserved-but-zero bytes. The peer iterates 1..recCount and ReadAts zeros at offsets [N..N+M), treating the records as garbage / empty markers. Skip the batch path when `a.shared`: each Append writes its record straight through via flushRecord on the next state change. EXCLUSIVE single-process flows are unaffected. Observed stress numbers (3 trials × 30 runs each, average): pre-Layer-1 baseline: ~60% / panics +Layer 1+2: 80% / 50% / panic +Layer 4a/4b: 75-90% / 50-80% / panic +Layer 5 (mmap-gen): ~73% / ~67% / ~33% / NO PANICS +THIS (EOF + no-batch): ~83% / ~50% / ~22% / NO PANICS The remaining flake at 5+ concurrent writers reflects the fundamental constraint of FiveSql2's DBF model: no table-level write lock, no MVCC. PostgreSQL solves this with snapshot isolation; the equivalent for FiveSql2 would need a write-ahead log or per-table writer mutex. Tracked as a post-1.0 R&D direction. For typical pgserver use — many read clients, few write clients — the current correctness is production-acceptable. The pgserver Phase 7 integration suite (3/3 in the basic psql harness + 3/3 in the auth/TLS harness) remains 6/6 green because each suite uses one connection at a time. All six release gates green: go test ./... ✓ FiveSql2 SQL:1999 43/43 ✓ Harbour compat 56/56 ✓ std.ch 17/17 ✓ FRB 7/7 ✓ pgserver integration 6/6 ✓ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1282 lines
35 KiB
Go
1282 lines
35 KiB
Go
// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
|
|
// All rights reserved.
|
|
|
|
// DBFArea — the core DBF file driver.
|
|
// Byte-compatible with Harbour/Clipper DBF files.
|
|
//
|
|
// Harbour equivalent: DBFAREA in dbf1.c
|
|
// Inherits from BaseArea (WAAREA), implements Area + RecordManager + Locker.
|
|
//
|
|
// Reference:
|
|
// /mnt/d/harbour-core/src/rdd/dbf1.c
|
|
// docs/dbf-engine-spec.md
|
|
package dbf
|
|
|
|
import (
|
|
"five/hbrt"
|
|
"five/hbrdd"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"sync/atomic"
|
|
)
|
|
|
|
// DBFArea implements the DBF database driver.
|
|
// Harbour: struct DBFAREA in dbf1.c
|
|
type DBFArea struct {
|
|
hbrdd.BaseArea // embed WAAREA defaults
|
|
|
|
// File handles
|
|
dataFile *os.File
|
|
filePath string
|
|
shared bool
|
|
readOnly bool
|
|
|
|
// Per-path mmap generation tracker. Bumped by self/peer Append +
|
|
// PutValue + Pack + Zap. loadRecord compares pathGenSeen against
|
|
// the live counter and bypasses the mmap fast path if a peer
|
|
// modified the file since our snapshot. See area_registry.go.
|
|
pathGen *uint64
|
|
pathGenSeen uint64
|
|
|
|
// Header
|
|
header Header
|
|
fieldDescs []FieldDesc
|
|
offsets []uint16 // field byte offsets within record
|
|
|
|
// Record buffer — COW: recBuf may point into mmap (read-only) or ownBuf (writable)
|
|
recBuf []byte // current record view (mmap slice or ownBuf)
|
|
ownBuf []byte // owned writable buffer (allocated once)
|
|
recNo uint32 // current record number (1-based)
|
|
dirty bool // record buffer modified
|
|
recOwned bool // true = recBuf is ownBuf (writable), false = mmap slice (read-only)
|
|
|
|
// State
|
|
recCount uint32
|
|
ghost bool // at phantom record (after APPEND)
|
|
recLoaded bool // false = recBuf stale, need loadRecord()
|
|
|
|
// RecCount cache — skip the Seek-to-end syscall when nothing this
|
|
// process did has changed and no external invalidation has fired.
|
|
// See RecCount() + InvalidateRecCountCache().
|
|
recCountCached bool
|
|
recCountGen uint64
|
|
|
|
// Append batch buffer — accumulates records for single write at flush
|
|
appendBuf []byte // buffered appended records (not yet written to disk)
|
|
appendStart uint32 // first recNo in appendBuf (1-based)
|
|
|
|
// Pending index inserts for records that were appended but not
|
|
// yet indexed. flushRecord walks this set after the body bytes
|
|
// reach disk and calls InsertKey on every open index that
|
|
// implements IndexWriter — so a follow-up dbSeek finds the new
|
|
// record without a manual REINDEX. The set is populated by
|
|
// Append() and drained by flushRecord(); typed as a slice (not
|
|
// map) because append order matches the desired index walk.
|
|
pendingIdxInserts []uint32
|
|
|
|
// mmap for zero-copy record reads
|
|
mmapData []byte
|
|
|
|
// Memo file (FPT)
|
|
memoFile *FPTFile
|
|
|
|
// Index integration (NTX/CDX)
|
|
idxState *indexState
|
|
|
|
// File locking state (byte-range locks via fcntl)
|
|
fileLocked bool // FLOCK() held
|
|
lockedRecs map[uint32]bool // records locked by DBRLOCK()
|
|
|
|
// Field position cache — UPPER(name) → 1-based index.
|
|
// Built lazily on first FieldPosCache() call.
|
|
// SQLite: "column affinity binding" — O(1) vs O(n) linear scan.
|
|
fieldPosMap map[string]int
|
|
|
|
// SQL NULL bitmap (VFP/Harbour _NullFlags convention).
|
|
// nullFieldsIdx: descriptor index of the hidden _NullFlags field,
|
|
// or -1 if the table has no nullable columns.
|
|
// nullBitOf: user-field descriptor index → bit position within
|
|
// the _NullFlags byte range.
|
|
nullFieldsIdx int
|
|
nullBitOf map[int]int
|
|
}
|
|
|
|
// DBFDriver is the driver factory for DBF files.
|
|
type DBFDriver struct{}
|
|
|
|
func (d *DBFDriver) Name() string { return "DBF" }
|
|
|
|
func (d *DBFDriver) Open(params hbrdd.OpenParams) (hbrdd.Area, error) {
|
|
return openDBF(d, params)
|
|
}
|
|
|
|
func (d *DBFDriver) Create(params hbrdd.CreateParams) (hbrdd.Area, error) {
|
|
return createDBF(d, params)
|
|
}
|
|
|
|
func init() {
|
|
hbrdd.RegisterDriver(&DBFDriver{})
|
|
// Register aliases used by Harbour
|
|
hbrdd.RegisterDriver(&dbfAliasDriver{name: "DBFNTX"})
|
|
hbrdd.RegisterDriver(&dbfAliasDriver{name: "DBFCDX"})
|
|
hbrdd.RegisterDriver(&dbfAliasDriver{name: "DBFFPT"})
|
|
// SIX compatible drivers — same DBF engine, different semantics handled at higher level
|
|
hbrdd.RegisterDriver(&dbfAliasDriver{name: "SIXCDX"})
|
|
hbrdd.RegisterDriver(&dbfAliasDriver{name: "DBFNSX"})
|
|
hbrdd.RegisterDriver(&dbfAliasDriver{name: "DBFSIX"})
|
|
// Transfer format aliases
|
|
hbrdd.RegisterDriver(&dbfAliasDriver{name: "DBFDBT"})
|
|
// Bitmap/Rushmore RDD variants
|
|
hbrdd.RegisterDriver(&dbfAliasDriver{name: "BMDBFNTX"})
|
|
hbrdd.RegisterDriver(&dbfAliasDriver{name: "BMDBFCDX"})
|
|
hbrdd.RegisterDriver(&dbfAliasDriver{name: "BMDBFNSX"})
|
|
}
|
|
|
|
// dbfAliasDriver wraps DBFDriver with a different name.
|
|
type dbfAliasDriver struct {
|
|
name string
|
|
}
|
|
|
|
func (d *dbfAliasDriver) Name() string { return d.name }
|
|
func (d *dbfAliasDriver) Open(params hbrdd.OpenParams) (hbrdd.Area, error) {
|
|
return openDBF(&DBFDriver{}, params)
|
|
}
|
|
func (d *dbfAliasDriver) Create(params hbrdd.CreateParams) (hbrdd.Area, error) {
|
|
return createDBF(&DBFDriver{}, params)
|
|
}
|
|
|
|
// fptPathFromDBF returns the FPT memo file path for a given DBF path.
|
|
func fptPathFromDBF(dbfPath string) string {
|
|
base := dbfPath
|
|
if idx := strings.LastIndex(base, "."); idx >= 0 {
|
|
base = base[:idx]
|
|
}
|
|
return base + ".fpt"
|
|
}
|
|
|
|
// hasMemoField checks if any field descriptor is a MEMO type.
|
|
func hasMemoField(fields []FieldDesc) bool {
|
|
for _, f := range fields {
|
|
if f.Type == 'M' || f.Type == 'm' {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// --- Open ---
|
|
// Harbour: hb_dbfOpen in dbf1.c
|
|
func openDBF(drv *DBFDriver, params hbrdd.OpenParams) (*DBFArea, error) {
|
|
path := params.Path
|
|
if !hasExtension(path) {
|
|
path += ".dbf"
|
|
}
|
|
|
|
flag := os.O_RDWR
|
|
if params.ReadOnly {
|
|
flag = os.O_RDONLY
|
|
}
|
|
|
|
f, err := os.OpenFile(path, flag, 0)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open %s: %w", path, err)
|
|
}
|
|
|
|
area := &DBFArea{
|
|
dataFile: f,
|
|
filePath: path,
|
|
shared: params.Shared,
|
|
readOnly: params.ReadOnly,
|
|
pathGen: pathGenFor(path),
|
|
}
|
|
area.pathGenSeen = loadPathGen(area.pathGen)
|
|
area.BaseArea = hbrdd.BaseArea{}
|
|
|
|
// Step 1: Read header (32 bytes)
|
|
hdr, err := ReadHeader(f)
|
|
if err != nil {
|
|
f.Close()
|
|
return nil, err
|
|
}
|
|
area.header = *hdr
|
|
|
|
// Step 2: Read field descriptors
|
|
fieldCount := hdr.FieldCount()
|
|
if fieldCount <= 0 {
|
|
f.Close()
|
|
return nil, fmt.Errorf("invalid field count: %d", fieldCount)
|
|
}
|
|
|
|
fields, err := ReadFieldDescs(f, fieldCount)
|
|
if err != nil {
|
|
f.Close()
|
|
return nil, err
|
|
}
|
|
area.fieldDescs = fields
|
|
|
|
// Step 3: Build field offsets
|
|
area.offsets = BuildFieldOffsets(fields)
|
|
|
|
// Step 4: Validate record length
|
|
expectedRecLen := area.offsets[len(fields)]
|
|
if uint16(expectedRecLen) != hdr.RecordLen {
|
|
// Allow minor discrepancy (some DBF writers add extra bytes)
|
|
if uint16(expectedRecLen) > hdr.RecordLen {
|
|
f.Close()
|
|
return nil, fmt.Errorf("field offsets (%d) exceed record length (%d)",
|
|
expectedRecLen, hdr.RecordLen)
|
|
}
|
|
}
|
|
|
|
// Step 5: Allocate record buffer
|
|
area.ownBuf = make([]byte, hdr.RecordLen)
|
|
area.recBuf = area.ownBuf
|
|
area.recOwned = true
|
|
|
|
// Step 6: Set record count (shared mode: recalculate from file size)
|
|
if params.Shared {
|
|
fileSize, _ := f.Seek(0, 2)
|
|
area.recCount = uint32((fileSize - int64(hdr.HeaderLen)) / int64(hdr.RecordLen))
|
|
} else {
|
|
area.recCount = hdr.RecCount
|
|
}
|
|
|
|
// Step 7: Build FieldInfo for BaseArea. System fields (notably
|
|
// the hidden _NullFlags column carrying the SQL NULL bitmap) are
|
|
// held in fieldDescs for storage but filtered out of the public
|
|
// FieldInfo slice — user-visible counts stay stable.
|
|
fieldInfos := make([]hbrdd.FieldInfo, 0, fieldCount)
|
|
for _, fd := range fields {
|
|
if fd.Flags&FieldFlagSystem != 0 {
|
|
continue
|
|
}
|
|
fieldInfos = append(fieldInfos, hbrdd.FieldInfo{
|
|
Name: fd.GetName(),
|
|
Type: fd.Type,
|
|
Len: int(fd.Len),
|
|
Dec: int(fd.Dec),
|
|
Flags: fd.Flags,
|
|
})
|
|
}
|
|
area.InitFields(fieldInfos)
|
|
area.buildNullIndex()
|
|
|
|
// Step 8: Auto-open FPT if memo fields exist
|
|
if hasMemoField(fields) {
|
|
fptPath := fptPathFromDBF(path)
|
|
if fpt, err := OpenFPT(fptPath); err == nil {
|
|
area.memoFile = fpt
|
|
}
|
|
// If FPT doesn't exist, memo reads return empty string
|
|
}
|
|
|
|
// Step 9: mmap DBF for zero-copy record reads
|
|
area.mmapDBF()
|
|
|
|
// Step 10: Position at first record
|
|
area.FEof = (area.recCount == 0)
|
|
if area.recCount > 0 {
|
|
area.GoTo(1)
|
|
}
|
|
|
|
return area, nil
|
|
}
|
|
|
|
// --- Create ---
|
|
func createDBF(drv *DBFDriver, params hbrdd.CreateParams) (*DBFArea, error) {
|
|
path := params.Path
|
|
if !hasExtension(path) {
|
|
path += ".dbf"
|
|
}
|
|
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create %s: %w", path, err)
|
|
}
|
|
|
|
// Build field descriptors
|
|
fieldDescs := make([]FieldDesc, len(params.Fields))
|
|
for i, fi := range params.Fields {
|
|
fieldDescs[i].SetName(fi.Name)
|
|
fieldDescs[i].Type = fi.Type
|
|
fieldDescs[i].Len = byte(fi.Len)
|
|
fieldDescs[i].Dec = byte(fi.Dec)
|
|
fieldDescs[i].Flags = fi.Flags
|
|
}
|
|
|
|
// If any user field is nullable, append the hidden _NullFlags
|
|
// system column. Must happen before recordLen is tallied so its
|
|
// bytes reserve space in the record layout.
|
|
fieldDescs = appendNullFlagsField(fieldDescs)
|
|
|
|
recordLen := uint16(1) // deletion flag
|
|
for i := range fieldDescs {
|
|
recordLen += uint16(fieldDescs[i].Len)
|
|
}
|
|
|
|
// Build header
|
|
hasMemo := hasMemoField(fieldDescs)
|
|
headerLen := uint16(HeaderSize + len(fieldDescs)*FieldDescSize + 1) // +1 for terminator
|
|
version := byte(VersionDBF3)
|
|
if hasMemo {
|
|
version = VersionFPT
|
|
}
|
|
hdr := Header{
|
|
Version: version,
|
|
RecCount: 0,
|
|
HeaderLen: headerLen,
|
|
RecordLen: recordLen,
|
|
}
|
|
hdr.UpdateDate()
|
|
|
|
// Write header
|
|
if err := WriteHeader(f, &hdr); err != nil {
|
|
f.Close()
|
|
return nil, err
|
|
}
|
|
|
|
// Write field descriptors
|
|
if err := WriteFieldDescs(f, fieldDescs); err != nil {
|
|
f.Close()
|
|
return nil, err
|
|
}
|
|
|
|
// Write header terminator
|
|
f.Write([]byte{HeaderTerminator})
|
|
|
|
// Write EOF marker
|
|
f.Write([]byte{EOFMarker})
|
|
|
|
f.Seek(0, 0)
|
|
|
|
area := &DBFArea{
|
|
dataFile: f,
|
|
filePath: path,
|
|
header: hdr,
|
|
fieldDescs: fieldDescs,
|
|
offsets: BuildFieldOffsets(fieldDescs),
|
|
ownBuf: make([]byte, recordLen),
|
|
recOwned: true,
|
|
recCount: 0,
|
|
pathGen: pathGenFor(path),
|
|
}
|
|
area.pathGenSeen = loadPathGen(area.pathGen)
|
|
area.recBuf = area.ownBuf
|
|
|
|
fieldInfos := make([]hbrdd.FieldInfo, len(params.Fields))
|
|
copy(fieldInfos, params.Fields)
|
|
area.InitFields(fieldInfos)
|
|
area.buildNullIndex()
|
|
area.FEof = true
|
|
|
|
// Auto-create FPT if memo fields exist
|
|
if hasMemo {
|
|
fptPath := fptPathFromDBF(path)
|
|
fpt, err := CreateFPT(fptPath, FPTDefaultBlock)
|
|
if err != nil {
|
|
f.Close()
|
|
return nil, fmt.Errorf("create memo file: %w", err)
|
|
}
|
|
area.memoFile = fpt
|
|
}
|
|
|
|
return area, nil
|
|
}
|
|
|
|
// --- Area interface ---
|
|
|
|
func (a *DBFArea) Driver() hbrdd.Driver { return &DBFDriver{} }
|
|
|
|
func (a *DBFArea) Close() error {
|
|
a.unmapDBF() // unmap before writing
|
|
if a.dirty {
|
|
a.flushRecord()
|
|
}
|
|
// Shared-mode EOF write — same max-merge as updateHeader. A
|
|
// peer Append between our last refresh and this write may have
|
|
// grown the file past our local recCount. Writing the EOF
|
|
// marker at our stale offset (HeaderLen + localCount*RecLen)
|
|
// would overwrite byte 0 of the peer's just-appended record
|
|
// (its delete flag) — the record body bytes survive but byte 0
|
|
// flips from ' ' (RecordActive) to 0x1A (EOFMarker). Re-read
|
|
// the disk header to pick the larger of {local, disk} before
|
|
// computing EOFOffset.
|
|
if a.shared {
|
|
if _, err := a.dataFile.Seek(0, 0); err == nil {
|
|
if hdr, err := ReadHeader(a.dataFile); err == nil {
|
|
if hdr.RecCount > a.recCount {
|
|
a.recCount = hdr.RecCount
|
|
a.header.RecCount = hdr.RecCount
|
|
}
|
|
}
|
|
}
|
|
}
|
|
a.dataFile.WriteAt([]byte{EOFMarker}, a.header.EOFOffset())
|
|
a.updateHeader()
|
|
// Release any held byte-range locks before closing the fd — POSIX
|
|
// drops them implicitly on close, but being explicit avoids races
|
|
// with other workareas sharing the same underlying file.
|
|
a.releaseAllLocks()
|
|
if a.memoFile != nil {
|
|
a.memoFile.Close()
|
|
a.memoFile = nil
|
|
}
|
|
err := a.dataFile.Close()
|
|
a.BaseArea.Close()
|
|
return err
|
|
}
|
|
|
|
// MemoFile returns the FPT memo file, or nil if no memo fields.
|
|
func (a *DBFArea) MemoFile() *FPTFile { return a.memoFile }
|
|
|
|
// FullPath returns the on-disk path of the DBF. For dbInfo(DBI_FULLPATH).
|
|
func (a *DBFArea) FullPath() string { return a.filePath }
|
|
|
|
// IsShared returns true if the area was opened shared.
|
|
// For dbInfo(DBI_SHARED).
|
|
func (a *DBFArea) IsShared() bool { return a.shared }
|
|
|
|
// IsReadOnly returns true if the area was opened read-only.
|
|
// For dbInfo(DBI_ISREADONLY).
|
|
func (a *DBFArea) IsReadOnly() bool { return a.readOnly }
|
|
|
|
// FieldPosCache returns the 1-based field position for a field name.
|
|
// Uses a lazily-built hash map for O(1) lookup instead of O(n) linear scan.
|
|
// SQLite: "column affinity binding" — critical for SQL engines that call
|
|
// FieldPos() hundreds of thousands of times per query.
|
|
func (a *DBFArea) FieldPosCache(name string) int {
|
|
if a.fieldPosMap == nil {
|
|
a.fieldPosMap = make(map[string]int, len(a.fieldDescs))
|
|
for i, fd := range a.fieldDescs {
|
|
// Trim null bytes + spaces from the [11]byte field name
|
|
n := 0
|
|
for n < 11 && fd.Name[n] != 0 {
|
|
n++
|
|
}
|
|
fname := strings.ToUpper(strings.TrimSpace(string(fd.Name[:n])))
|
|
a.fieldPosMap[fname] = i + 1
|
|
}
|
|
}
|
|
if pos, ok := a.fieldPosMap[name]; ok {
|
|
return pos
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (a *DBFArea) Flush() error {
|
|
if a.dirty {
|
|
if err := a.flushRecord(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
a.unmapDBF()
|
|
a.dataFile.WriteAt([]byte{EOFMarker}, a.header.EOFOffset())
|
|
a.updateHeader()
|
|
err := a.dataFile.Sync()
|
|
a.mmapDBF() // re-mmap after flush
|
|
return err
|
|
}
|
|
|
|
// --- Movement ---
|
|
|
|
func (a *DBFArea) RecNo() uint32 { return a.recNo }
|
|
|
|
func (a *DBFArea) RecCount() (uint32, error) {
|
|
if a.shared {
|
|
// Shared-mode recount — file size may have grown from another
|
|
// process's Append. Skip the syscall on an opt-in cache window
|
|
// controlled by recCountCacheGen: callers that don't need
|
|
// cross-process freshness (e.g. SqlScan's one-shot row-count
|
|
// estimate on a workarea we opened this session) can leave the
|
|
// cache warm. Invalidate on our own Append and dbCloseAll.
|
|
gen := atomic.LoadUint64(&recCountCacheGen)
|
|
if a.recCountCached && a.recCountGen == gen {
|
|
return a.recCount, nil
|
|
}
|
|
size, err := a.dataFile.Seek(0, 2)
|
|
if err != nil {
|
|
return a.recCount, err
|
|
}
|
|
a.recCount = uint32((size - int64(a.header.HeaderLen)) / int64(a.header.RecordLen))
|
|
a.recCountCached = true
|
|
a.recCountGen = gen
|
|
}
|
|
return a.recCount, nil
|
|
}
|
|
|
|
// recCountCacheGen — monotonic generation counter. Bumped by
|
|
// InvalidateRecCountCache() so callers that know they've performed
|
|
// cross-process-visible writes (or want a fresh sample) can force
|
|
// the next RecCount() to re-stat. Default semantics are "fresh is
|
|
// not required"; the cache is a hot-path optimization for workloads
|
|
// that don't share the file with another writer.
|
|
var recCountCacheGen uint64 = 1
|
|
|
|
// InvalidateRecCountCache bumps the generation counter so every DBFArea's
|
|
// cached count becomes stale and the next RecCount() call re-queries the
|
|
// filesystem. Atomic so it can be called from any goroutine without a
|
|
// lock — the only invariant readers care about is "if I see a value > X,
|
|
// my cache is stale", and atomic.LoadUint64 is sufficient.
|
|
func InvalidateRecCountCache() {
|
|
atomic.AddUint64(&recCountCacheGen, 1)
|
|
}
|
|
|
|
func (a *DBFArea) Deleted() bool {
|
|
a.loadRecord()
|
|
if len(a.recBuf) > 0 {
|
|
return a.recBuf[0] == RecordDeleted
|
|
}
|
|
return false
|
|
}
|
|
|
|
// GoTo positions the cursor at a specific record number.
|
|
// Harbour: hb_dbfGoTo in dbf1.c — lazy read: record loaded on first access.
|
|
func (a *DBFArea) GoTo(recNo uint32) error {
|
|
if a.dirty {
|
|
a.flushRecord()
|
|
}
|
|
|
|
a.FFound = false
|
|
|
|
if recNo == 0 || recNo > a.recCount {
|
|
a.recNo = a.recCount + 1
|
|
a.FEof = true
|
|
a.FBof = (recNo == 0)
|
|
a.recLoaded = false
|
|
a.recBuf = a.ownBuf
|
|
a.recOwned = true
|
|
for i := range a.recBuf {
|
|
a.recBuf[i] = ' '
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Read record — COW: mmap slice reference (zero-copy), fallback
|
|
// to file read. Three reasons the mmap fast path can't be trusted
|
|
// and we have to ReadAt:
|
|
// 1. The record offset is past our mmap snapshot's length
|
|
// (file grew since we mmap-ed — covered by the length check).
|
|
// 2. SHARED mode AND the per-path mmap-gen counter has advanced
|
|
// since our last sync — a peer DBFArea modified the file
|
|
// bytes (Append/PutValue/Pack). Our snapshot still holds the
|
|
// pre-mutation bytes; ReadAt pulls the current on-disk
|
|
// contents. See area_registry.go.
|
|
mmapStale := false
|
|
if a.shared && a.pathGen != nil {
|
|
if cur := loadPathGen(a.pathGen); cur > a.pathGenSeen {
|
|
mmapStale = true
|
|
a.pathGenSeen = cur
|
|
}
|
|
}
|
|
offset := a.header.RecordOffset(recNo)
|
|
recLen := int(a.header.RecordLen)
|
|
useMmap := !mmapStale && a.mmapData != nil && int(offset)+recLen <= len(a.mmapData)
|
|
if useMmap {
|
|
// Zero-copy: recBuf points into mmap (read-only until PutValue)
|
|
a.recBuf = a.mmapData[offset : offset+int64(recLen)]
|
|
a.recOwned = false
|
|
} else if _, err := a.dataFile.ReadAt(a.ownBuf, offset); err != nil {
|
|
a.FEof = true
|
|
return fmt.Errorf("read record %d: %w", recNo, err)
|
|
} else {
|
|
a.recBuf = a.ownBuf
|
|
a.recOwned = true
|
|
}
|
|
|
|
a.recNo = recNo
|
|
a.recLoaded = true
|
|
a.FEof = false
|
|
a.FBof = false
|
|
a.dirty = false
|
|
a.ghost = false
|
|
return nil
|
|
}
|
|
|
|
// GoTop positions at the first record.
|
|
// Harbour: hb_dbfGoTop
|
|
func (a *DBFArea) GoTop() error {
|
|
// Use index order if active
|
|
if a.idxState != nil && a.idxState.current >= 0 {
|
|
a.FTop = true
|
|
a.FBottom = false
|
|
return a.GoTopIndexed()
|
|
}
|
|
|
|
a.FTop = true
|
|
a.FBottom = false
|
|
|
|
if a.recCount == 0 {
|
|
a.FEof = true
|
|
a.recNo = 1
|
|
return nil
|
|
}
|
|
|
|
if err := a.GoTo(1); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Skip filtered/deleted records
|
|
return a.skipFilter(1)
|
|
}
|
|
|
|
// GoBottom positions at the last record.
|
|
// Harbour: hb_dbfGoBottom
|
|
func (a *DBFArea) GoBottom() error {
|
|
// Use index order if active
|
|
if a.idxState != nil && a.idxState.current >= 0 {
|
|
a.FTop = false
|
|
a.FBottom = true
|
|
return a.GoBottomIndexed()
|
|
}
|
|
|
|
a.FTop = false
|
|
a.FBottom = true
|
|
|
|
if a.recCount == 0 {
|
|
a.FEof = true
|
|
a.recNo = 1
|
|
return nil
|
|
}
|
|
|
|
if err := a.GoTo(a.recCount); err != nil {
|
|
return err
|
|
}
|
|
|
|
return a.skipFilter(-1)
|
|
}
|
|
|
|
// Skip moves the cursor by count records.
|
|
// Harbour: hb_dbfSkip
|
|
func (a *DBFArea) Skip(count int64) error {
|
|
// Use index order if active
|
|
if a.idxState != nil && a.idxState.current >= 0 {
|
|
a.FTop = false
|
|
a.FBottom = false
|
|
return a.SkipIndexed(count)
|
|
}
|
|
|
|
a.FTop = false
|
|
a.FBottom = false
|
|
|
|
if count == 0 {
|
|
// Skip 0 = re-evaluate filter at current position
|
|
return a.skipFilter(1)
|
|
}
|
|
|
|
if count > 0 {
|
|
for i := int64(0); i < count; i++ {
|
|
if a.FEof {
|
|
break
|
|
}
|
|
newRec := a.recNo + 1
|
|
if newRec > a.recCount {
|
|
if a.dirty {
|
|
a.flushRecord()
|
|
}
|
|
a.FEof = true
|
|
a.recNo = a.recCount + 1
|
|
break
|
|
}
|
|
if err := a.GoTo(newRec); err != nil {
|
|
return err
|
|
}
|
|
if err := a.skipFilter(1); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
} else {
|
|
for i := int64(0); i > count; i-- {
|
|
if a.recNo <= 1 {
|
|
a.FBof = true
|
|
break
|
|
}
|
|
if err := a.GoTo(a.recNo - 1); err != nil {
|
|
return err
|
|
}
|
|
if err := a.skipFilter(-1); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// mmapDBF / unmapDBF live in mmap_posix.go (Linux/Darwin, syscall.Mmap)
|
|
// and mmap_windows.go (CreateFileMapping / MapViewOfFile). Keeping the
|
|
// platform-specific ioctl-ish calls out of this file lets cross-builds
|
|
// stay clean.
|
|
|
|
// loadRecord reads the current record — from mmap or file fallback.
|
|
func (a *DBFArea) loadRecord() {
|
|
if a.recLoaded || a.FEof || a.recNo == 0 || a.recNo > a.recCount {
|
|
return
|
|
}
|
|
offset := a.header.RecordOffset(a.recNo)
|
|
a.dataFile.ReadAt(a.recBuf, offset)
|
|
a.recLoaded = true
|
|
}
|
|
|
|
// --- Field access ---
|
|
|
|
func (a *DBFArea) GetValue(fieldIndex int) (hbrt.Value, error) {
|
|
a.loadRecord()
|
|
if fieldIndex < 0 || fieldIndex >= len(a.fieldDescs) {
|
|
return hbrt.MakeNil(), fmt.Errorf("field index out of range: %d", fieldIndex)
|
|
}
|
|
if a.FEof {
|
|
return hbrt.MakeNil(), nil
|
|
}
|
|
// SQL NULL: nullable fields check the hidden _NullFlags bitmap
|
|
// first; a set bit means the raw bytes carry no meaningful value
|
|
// and the reader should surface NIL to the caller.
|
|
if a.isFieldNull(fieldIndex) {
|
|
return hbrt.MakeNil(), nil
|
|
}
|
|
fd := &a.fieldDescs[fieldIndex]
|
|
// MEMO field: read from FPT and return string
|
|
if (fd.Type == 'M' || fd.Type == 'm') && a.memoFile != nil {
|
|
blockVal := GetFieldValue(a.recBuf, a.offsets[fieldIndex], fd)
|
|
blockNo := uint32(blockVal.AsNumInt())
|
|
if blockNo == 0 {
|
|
return hbrt.MakeString(""), nil
|
|
}
|
|
data, err := a.memoFile.ReadMemo(blockNo)
|
|
if err != nil {
|
|
return hbrt.MakeString(""), nil
|
|
}
|
|
return hbrt.MakeString(string(data)), nil
|
|
}
|
|
return GetFieldValue(a.recBuf, a.offsets[fieldIndex], fd), nil
|
|
}
|
|
|
|
func (a *DBFArea) PutValue(fieldIndex int, val hbrt.Value) error {
|
|
a.loadRecord()
|
|
// COW: promote read-only mmap slice to writable owned buffer
|
|
if !a.recOwned {
|
|
copy(a.ownBuf, a.recBuf)
|
|
a.recBuf = a.ownBuf
|
|
a.recOwned = true
|
|
}
|
|
if a.readOnly {
|
|
return fmt.Errorf("table is read-only")
|
|
}
|
|
if fieldIndex < 0 || fieldIndex >= len(a.fieldDescs) {
|
|
return fmt.Errorf("field index out of range: %d", fieldIndex)
|
|
}
|
|
// SQL NULL handling for nullable fields: a NIL write sets the
|
|
// bitmap bit and leaves the raw bytes alone (readers will short-
|
|
// circuit via isFieldNull before reaching the type codec). A
|
|
// non-NIL write clears the bit so the raw value surfaces again.
|
|
if _, nullable := a.nullBitOf[fieldIndex]; nullable {
|
|
if val.IsNil() {
|
|
a.setFieldNull(fieldIndex, true)
|
|
a.dirty = true
|
|
return nil
|
|
}
|
|
a.setFieldNull(fieldIndex, false)
|
|
}
|
|
fd := &a.fieldDescs[fieldIndex]
|
|
// MEMO field: write string to FPT, store block number in DBF
|
|
if (fd.Type == 'M' || fd.Type == 'm') && a.memoFile != nil && val.IsString() {
|
|
data := []byte(val.AsString())
|
|
blockNo, err := a.memoFile.WriteMemo(data)
|
|
if err != nil {
|
|
return fmt.Errorf("write memo: %w", err)
|
|
}
|
|
putMemoRef(a.recBuf[a.offsets[fieldIndex]:a.offsets[fieldIndex]+uint16(fd.Len)], fd.Len, blockNo)
|
|
a.dirty = true
|
|
return nil
|
|
}
|
|
PutFieldValue(a.recBuf, a.offsets[fieldIndex], fd, val)
|
|
a.dirty = true
|
|
return nil
|
|
}
|
|
|
|
// --- Record operations ---
|
|
|
|
// Append adds a new blank record.
|
|
// Harbour: hb_dbfAppend — writes are deferred until Flush/Close/GoTo.
|
|
func (a *DBFArea) Append() error {
|
|
if a.readOnly {
|
|
return fmt.Errorf("table is read-only")
|
|
}
|
|
|
|
// Batch consecutive APPENDs: save current dirty record to
|
|
// appendBuf instead of writing to disk. SHARED mode opts out
|
|
// of batching — a peer SELECT that opens the file while one
|
|
// of our slots is still buffered would iterate to that slot,
|
|
// ReadAt zero bytes (slot is reserved on disk but unwritten),
|
|
// and treat the record as garbage. Writing each APPEND straight
|
|
// through trades some throughput for "self+peer-coherent on
|
|
// every cursor move" semantics. EXCLUSIVE mode still batches.
|
|
if a.dirty && a.ghost && !a.shared {
|
|
// Previous was also an APPEND — accumulate in batch buffer (no syscall)
|
|
recLen := int(a.header.RecordLen)
|
|
if a.appendBuf == nil {
|
|
a.appendStart = a.recNo
|
|
a.appendBuf = make([]byte, 0, recLen*256) // pre-alloc for ~256 records
|
|
}
|
|
a.appendBuf = append(a.appendBuf, a.recBuf[:recLen]...)
|
|
} else if a.dirty {
|
|
a.flushRecord()
|
|
}
|
|
|
|
// Unmap — file will grow
|
|
if a.mmapData != nil {
|
|
a.unmapDBF()
|
|
}
|
|
|
|
// Shared mode: serialize concurrent appends across processes and
|
|
// re-read the header so we increment past whatever any peer has
|
|
// already committed. Without this, two processes racing
|
|
// `dbAppend` both bumped their *local* recCount from the stale
|
|
// value they cached at Open time and wrote to the same byte
|
|
// range — one record silently overwrote the other.
|
|
//
|
|
// We also immediately write the updated header to disk *inside*
|
|
// the locked region so the next peer to acquire the lock sees
|
|
// our reservation. The record body itself is still flushed
|
|
// lazily by flushRecord; subsequent peers will pick up the
|
|
// reserved slot via the bumped RecCount before our record body
|
|
// reaches disk.
|
|
if a.shared {
|
|
if err := a.lockAppendIntent(); err != nil {
|
|
return err
|
|
}
|
|
defer a.unlockAppendIntent()
|
|
if _, err := a.dataFile.Seek(0, 0); err == nil {
|
|
if hdr, err := ReadHeader(a.dataFile); err == nil {
|
|
if hdr.RecCount > a.recCount {
|
|
a.recCount = hdr.RecCount
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
a.recCount++
|
|
a.recNo = a.recCount
|
|
a.header.RecCount = a.recCount
|
|
|
|
if a.shared {
|
|
// Persist the bumped RecCount immediately so a concurrent
|
|
// appender refreshes past our slot in its own locked region.
|
|
a.header.UpdateDate()
|
|
if _, err := a.dataFile.Seek(0, 0); err == nil {
|
|
_ = WriteHeader(a.dataFile, &a.header)
|
|
}
|
|
// Bump the global recCount-cache generation so every peer
|
|
// DBFArea on this file re-stats on its next RecCount() call.
|
|
// Without this, a pgserver connection that holds a cached
|
|
// count from before our APPEND keeps seeing the pre-Append
|
|
// row total — its SELECT misses the row we just inserted,
|
|
// even though loadRecord()'s mmap-fallback path can read
|
|
// the record bytes fine. Cheap (single atomic increment);
|
|
// the cost is one Seek+stat per peer's next RecCount call,
|
|
// which is exactly what cross-connection freshness needs.
|
|
InvalidateRecCountCache()
|
|
// Also bump the per-path mmap-gen so peer DBFAreas on this
|
|
// file know their mmap snapshot may be stale and force a
|
|
// ReadAt on next load. See area_registry.go.
|
|
bumpPathGen(a.filePath)
|
|
a.pathGenSeen = loadPathGen(a.pathGen)
|
|
}
|
|
|
|
// Promote to owned buffer for writing
|
|
a.recBuf = a.ownBuf
|
|
a.recOwned = true
|
|
for i := range a.recBuf {
|
|
a.recBuf[i] = ' '
|
|
}
|
|
|
|
a.FEof = false
|
|
a.FBof = false
|
|
a.dirty = true
|
|
a.ghost = true
|
|
a.recLoaded = true
|
|
|
|
// Queue the new record for index maintenance. flushRecord drains
|
|
// the queue after the body bytes are durable — by that point the
|
|
// user has had a chance to PutValue into the appended record
|
|
// (the common APPEND BLANK → REPLACE → dbSeek pattern), and the
|
|
// key expression resolves against the final field values.
|
|
if a.idxState != nil && len(a.idxState.indexes) > 0 {
|
|
a.pendingIdxInserts = append(a.pendingIdxInserts, a.recNo)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Delete marks the current record as deleted.
|
|
func (a *DBFArea) Delete() error {
|
|
if a.readOnly || a.FEof {
|
|
return nil
|
|
}
|
|
if !a.recOwned {
|
|
copy(a.ownBuf, a.recBuf)
|
|
a.recBuf = a.ownBuf
|
|
a.recOwned = true
|
|
}
|
|
a.recBuf[0] = RecordDeleted
|
|
a.dirty = true
|
|
return nil
|
|
}
|
|
|
|
// Recall undeletes the current record.
|
|
func (a *DBFArea) Recall() error {
|
|
if a.readOnly || a.FEof {
|
|
return nil
|
|
}
|
|
if !a.recOwned {
|
|
copy(a.ownBuf, a.recBuf)
|
|
a.recBuf = a.ownBuf
|
|
a.recOwned = true
|
|
}
|
|
a.recBuf[0] = RecordActive
|
|
a.dirty = true
|
|
return nil
|
|
}
|
|
|
|
// Pack removes all deleted records.
|
|
// Harbour: hb_dbfPack — requires exclusive access.
|
|
//
|
|
// Crash-safe: writes the surviving records into `<file>.pack.tmp`,
|
|
// fsyncs, then atomic-renames over the original. Power loss /
|
|
// kill -9 between Reads and Writes of the old in-place rewrite
|
|
// could leave the file with overwritten prefix and a tail that
|
|
// looked legitimate but contained no original copies of the
|
|
// records we'd already advanced past — silent data loss.
|
|
func (a *DBFArea) Pack() error {
|
|
if a.readOnly {
|
|
return fmt.Errorf("table is read-only")
|
|
}
|
|
if a.shared {
|
|
return fmt.Errorf("PACK requires exclusive access")
|
|
}
|
|
|
|
if a.dirty {
|
|
a.flushRecord()
|
|
}
|
|
|
|
// Temporarily disable index to avoid indexed navigation during PACK
|
|
var savedIdx *indexState
|
|
if a.idxState != nil {
|
|
savedIdx = a.idxState
|
|
a.idxState = nil
|
|
}
|
|
|
|
// Drop mmap so we can do clean reads + so the source file can
|
|
// be replaced atomically below.
|
|
if a.mmapData != nil {
|
|
a.unmapDBF()
|
|
}
|
|
|
|
origPath := a.dataFile.Name()
|
|
tmpPath := origPath + ".pack.tmp"
|
|
|
|
// Best-effort cleanup of any leftover tmp from a prior crash.
|
|
_ = os.Remove(tmpPath)
|
|
|
|
tmpFile, err := os.OpenFile(tmpPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
|
|
if err != nil {
|
|
return fmt.Errorf("PACK: create temp: %w", err)
|
|
}
|
|
|
|
// Write the same header to the temp; RecCount is patched at the
|
|
// end once we know the survivor count.
|
|
if _, err := tmpFile.Seek(0, 0); err == nil {
|
|
_ = WriteHeader(tmpFile, &a.header)
|
|
}
|
|
// Pad up to HeaderLen with the original header bytes (field
|
|
// descriptors etc.) so the new file has a structurally
|
|
// identical envelope. We replay from the source.
|
|
hdrBytes := make([]byte, a.header.HeaderLen)
|
|
if _, err := a.dataFile.ReadAt(hdrBytes, 0); err == nil {
|
|
_, _ = tmpFile.WriteAt(hdrBytes, 0)
|
|
}
|
|
|
|
outRec := uint32(0)
|
|
buf := make([]byte, a.header.RecordLen)
|
|
|
|
for recNo := uint32(1); recNo <= a.recCount; recNo++ {
|
|
offset := a.header.RecordOffset(recNo)
|
|
if _, err := a.dataFile.ReadAt(buf, offset); err != nil {
|
|
tmpFile.Close()
|
|
os.Remove(tmpPath)
|
|
return err
|
|
}
|
|
if buf[0] != RecordDeleted {
|
|
outRec++
|
|
outOffset := a.header.RecordOffset(outRec)
|
|
if _, err := tmpFile.WriteAt(buf, outOffset); err != nil {
|
|
tmpFile.Close()
|
|
os.Remove(tmpPath)
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
a.recCount = outRec
|
|
a.header.RecCount = outRec
|
|
|
|
// Write EOF marker on the temp.
|
|
eofOff := int64(a.header.HeaderLen) + int64(outRec)*int64(a.header.RecordLen)
|
|
if _, err := tmpFile.WriteAt([]byte{EOFMarker}, eofOff); err != nil {
|
|
tmpFile.Close()
|
|
os.Remove(tmpPath)
|
|
return err
|
|
}
|
|
|
|
// Patch the survivor count + date into the temp header.
|
|
a.header.UpdateDate()
|
|
if _, err := tmpFile.Seek(0, 0); err == nil {
|
|
_ = WriteHeader(tmpFile, &a.header)
|
|
}
|
|
|
|
// Make the survivor durable before the rename so a crash between
|
|
// rename and process exit doesn't leave a truncated temp.
|
|
if err := tmpFile.Sync(); err != nil {
|
|
tmpFile.Close()
|
|
os.Remove(tmpPath)
|
|
return err
|
|
}
|
|
tmpFile.Close()
|
|
|
|
// Close the current handle, then atomic-rename. POSIX rename
|
|
// guarantees readers/writers observe either the old or the
|
|
// new contents — never an in-progress half-state.
|
|
a.dataFile.Close()
|
|
if err := os.Rename(tmpPath, origPath); err != nil {
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("PACK: rename temp: %w", err)
|
|
}
|
|
|
|
// Reopen the (now repacked) file in the same mode and re-mmap.
|
|
flag := os.O_RDWR
|
|
if a.readOnly {
|
|
flag = os.O_RDONLY
|
|
}
|
|
newF, err := os.OpenFile(origPath, flag, 0644)
|
|
if err != nil {
|
|
return fmt.Errorf("PACK: reopen: %w", err)
|
|
}
|
|
a.dataFile = newF
|
|
a.mmapDBF()
|
|
|
|
// Reposition (natural order, no index yet)
|
|
if a.recCount > 0 {
|
|
a.GoTo(1)
|
|
} else {
|
|
a.FEof = true
|
|
a.recNo = 1
|
|
}
|
|
|
|
// Rebuild indexes (record numbers changed after PACK)
|
|
if savedIdx != nil {
|
|
a.idxState = savedIdx
|
|
if err := a.OrderListRebuild(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Zap removes all records.
|
|
func (a *DBFArea) Zap() error {
|
|
if a.readOnly || a.shared {
|
|
return fmt.Errorf("ZAP requires exclusive access")
|
|
}
|
|
|
|
// Save index state
|
|
var savedIdx *indexState
|
|
if a.idxState != nil {
|
|
savedIdx = a.idxState
|
|
a.idxState = nil
|
|
}
|
|
|
|
a.recCount = 0
|
|
a.header.RecCount = 0
|
|
|
|
// Truncate to header + EOF
|
|
a.dataFile.Truncate(int64(a.header.HeaderLen) + 1)
|
|
a.dataFile.WriteAt([]byte{EOFMarker}, int64(a.header.HeaderLen))
|
|
|
|
a.updateHeader()
|
|
a.FEof = true
|
|
a.recNo = 1
|
|
|
|
// Rebuild indexes (empty after ZAP)
|
|
if savedIdx != nil {
|
|
a.idxState = savedIdx
|
|
a.OrderListRebuild() // rebuilds empty indexes
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// --- Internal ---
|
|
|
|
func (a *DBFArea) flushRecord() error {
|
|
if !a.dirty || a.FEof {
|
|
return nil
|
|
}
|
|
// Flush any accumulated append batch first (single write for all buffered records)
|
|
if len(a.appendBuf) > 0 {
|
|
offset := a.header.RecordOffset(a.appendStart)
|
|
recLen := int(a.header.RecordLen)
|
|
// Append current dirty record to batch too
|
|
all := append(a.appendBuf, a.recBuf[:recLen]...)
|
|
_, err := a.dataFile.WriteAt(all, offset)
|
|
a.appendBuf = nil
|
|
a.appendStart = 0
|
|
if err == nil {
|
|
a.dirty = false
|
|
a.drainPendingIndexInserts()
|
|
a.markPathDirty()
|
|
}
|
|
return err
|
|
}
|
|
offset := a.header.RecordOffset(a.recNo)
|
|
_, err := a.dataFile.WriteAt(a.recBuf, offset)
|
|
if err == nil {
|
|
a.dirty = false
|
|
a.drainPendingIndexInserts()
|
|
a.markPathDirty()
|
|
}
|
|
return err
|
|
}
|
|
|
|
// markPathDirty bumps the per-path mmap-gen counter so peer
|
|
// DBFAreas on this file see their cached mmap as stale on next
|
|
// loadRecord and force a fresh ReadAt. Cheap (one atomic.Add) and
|
|
// only fires for shared-mode writers — EXCLUSIVE mode has no peers
|
|
// to invalidate.
|
|
func (a *DBFArea) markPathDirty() {
|
|
if !a.shared || a.pathGen == nil {
|
|
return
|
|
}
|
|
bumpPathGen(a.filePath)
|
|
a.pathGenSeen = loadPathGen(a.pathGen)
|
|
}
|
|
|
|
// drainPendingIndexInserts pushes any queued APPENDed records into
|
|
// every open IndexWriter so a follow-up dbSeek finds them. Engines
|
|
// that don't implement IndexWriter (CDX today) are skipped — those
|
|
// indexes remain stale and must be REINDEXed manually. Errors from
|
|
// InsertKey are swallowed because a partial index update is the
|
|
// strict-best worst case; reporting them up the flush stack would
|
|
// abort the user's transaction over a recoverable index issue.
|
|
func (a *DBFArea) drainPendingIndexInserts() {
|
|
if len(a.pendingIdxInserts) == 0 || a.idxState == nil {
|
|
a.pendingIdxInserts = nil
|
|
return
|
|
}
|
|
for _, recNo := range a.pendingIdxInserts {
|
|
for i, idx := range a.idxState.indexes {
|
|
writer, ok := idx.(IndexWriter)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if i >= len(a.idxState.keyExprs) {
|
|
continue
|
|
}
|
|
key := a.evalKeyExpr(a.idxState.keyExprs[i], recNo)
|
|
_ = writer.InsertKey(key, recNo)
|
|
}
|
|
}
|
|
a.pendingIdxInserts = nil
|
|
}
|
|
|
|
func (a *DBFArea) updateHeader() {
|
|
// Shared-mode max-merge. A pgserver-style multi-connection
|
|
// scenario has every peer call Close → updateHeader in arbitrary
|
|
// order. Each peer's `a.recCount` reflects its own view at the
|
|
// time of its last Append; if the file has grown since (because
|
|
// another peer's Append took the append-intent lock and bumped
|
|
// the disk header), naively writing a.recCount back would
|
|
// roll the on-disk count BACKWARDS — and subsequent peer SELECTs
|
|
// would iterate only as far as our stale count, missing rows
|
|
// that are demonstrably on disk.
|
|
//
|
|
// Re-read the disk header and pick the max. This is correct under
|
|
// the append-intent lock invariant (the bumped count is always
|
|
// monotonic) and cheap (one stat-sized read). Single-process /
|
|
// EXCLUSIVE mode still writes its local count unconditionally,
|
|
// since no peer can have bumped it.
|
|
if a.shared {
|
|
if _, err := a.dataFile.Seek(0, 0); err == nil {
|
|
if hdr, err := ReadHeader(a.dataFile); err == nil {
|
|
if hdr.RecCount > a.recCount {
|
|
a.recCount = hdr.RecCount
|
|
}
|
|
}
|
|
}
|
|
}
|
|
a.header.RecCount = a.recCount
|
|
a.header.UpdateDate()
|
|
|
|
a.dataFile.Seek(0, 0)
|
|
WriteHeader(a.dataFile, &a.header)
|
|
}
|
|
|
|
// skipFilter advances cursor past deleted/filtered records.
|
|
// Harbour: hb_dbfSkipFilter in dbf1.c
|
|
// direction: 1 = forward, -1 = backward
|
|
func (a *DBFArea) skipFilter(direction int64) error {
|
|
if direction == 0 {
|
|
direction = 1
|
|
}
|
|
|
|
setDel := hbrdd.IsSetDeleted != nil && hbrdd.IsSetDeleted()
|
|
|
|
if !setDel {
|
|
return nil
|
|
}
|
|
|
|
for !a.FEof && !a.FBof {
|
|
if !a.Deleted() {
|
|
break
|
|
}
|
|
|
|
// Move to next/prev record
|
|
if direction > 0 {
|
|
newRec := a.recNo + 1
|
|
if newRec > a.recCount {
|
|
a.FEof = true
|
|
a.recNo = a.recCount + 1
|
|
break
|
|
}
|
|
if err := a.GoTo(newRec); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if a.recNo <= 1 {
|
|
a.FBof = true
|
|
if err := a.GoTo(1); err != nil {
|
|
return err
|
|
}
|
|
break
|
|
}
|
|
if err := a.GoTo(a.recNo - 1); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
func hasExtension(path string) bool {
|
|
for i := len(path) - 1; i >= 0; i-- {
|
|
if path[i] == '.' {
|
|
return true
|
|
}
|
|
if path[i] == '/' || path[i] == '\\' {
|
|
return false
|
|
}
|
|
}
|
|
return false
|
|
}
|