Files
five/hbrdd/dbf/locks_posix.go
CharlesKWON fc1dca9551 feat(rdd): real POSIX file/record locking + gap analysis doc
Replaces the FLOCK/DBRLOCK/DBRUNLOCK no-op stubs with actual
fcntl(F_SETLK) byte-range advisory locks, matching Harbour's
hb_fsLockLarge implementation.

Before: rtlDbRLock always returned .T. regardless of contention.
        Multi-process writers could silently corrupt records.

After:  Non-blocking POSIX byte-range locks per file descriptor.
        Cross-process exclusion verified by a subprocess-spawning
        Go test that witnesses BUSY vs OK transitions.

New files:
  hbrdd/dbf/locks_posix.go    fcntl F_WRLCK/F_UNLCK wrappers
  hbrdd/dbf/locks_windows.go  stub (TODO: LockFileEx)
  hbrdd/dbf/lock_multi_test.go   cross-process verification
  docs/gap-analysis.md        honest Harbour parity assessment

Modified:
  hbrdd/dbf/dbf.go
    - DBFArea gains fileLocked bool + lockedRecs map
    - Close() calls releaseAllLocks() before dropping the fd
  hbrtl/database.go
    - rtlDbRLock / rtlDbRUnlock now delegate to DBFArea.LockRecord /
      UnlockRecord instead of returning fixed .T./NIL
    - New rtlFLock / rtlDbUnlock for FLOCK() / DBUNLOCK()
  hbrtl/register.go
    - FLOCK and DBUNLOCK symbols registered (were missing entirely)
  compiler/analyzer/analyzer.go
    - FLOCK / DBUNLOCK added to RTL known-function set

Lock region layout (non-overlapping on purpose):
  FLOCK region       [0, HeaderLen+1)
  Record N region    [RecordOffset(N), RecordLen)

So a workarea can hold FLOCK and multiple DBRLOCK simultaneously
on the same fd without conflict.

Design rationale (captured in locks_posix.go header):
  * POSIX fcntl, not flock(2) — byte-range + NFS-safe
  * Non-blocking F_SETLK — matches Clipper FLOCK() → .F. semantics
  * Released explicitly on Close to avoid workarea-sharing races
  * Windows falls back to no-op (TODO: LockFileEx)

Verification:
  go test ./hbrdd/dbf/ -run TestFLockBlocksAcrossProcesses  PASS
  go test ./hbrdd/dbf/ -run TestRLockBlocksAcrossProcesses  PASS
  go test ./...                                             ALL PASS
  FiveSql2 43/43                                            100%
  compat_harbour 51/51                                      100%

The gap-analysis doc (docs/gap-analysis.md) is a running inventory
of what works vs what's still missing vs Harbour 3.2, written for
users evaluating Five for production — not a sales pitch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:58:03 +09:00

170 lines
4.7 KiB
Go

// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
// All rights reserved.
//go:build !windows
// POSIX file locking (fcntl byte-range locks) for DBF shared mode.
// Equivalent to Harbour's hb_fsLockLarge / F_SETLK implementation.
//
// We use POSIX advisory byte-range locks (F_SETLK / F_SETLKW) rather than
// flock(2) because:
// 1. Byte-range granularity is required for record-level DBRLOCK semantics
// 2. POSIX fcntl locks work across NFS (flock does not)
// 3. Harbour and Clipper both use this exact mechanism on *nix
//
// Lock regions (compatible with Harbour's NETIO layout):
// - FLOCK (whole-file exclusive): high byte in 32-bit range, len = large
// - DBRLOCK (single record): at RecordOffset(n), len = RecordLen
//
// The high-offset file lock avoids overlapping with record locks so that
// both can coexist on the same file descriptor.
package dbf
import (
"fmt"
"syscall"
)
// flockAll acquires/releases an exclusive lock on the DBF header area.
// We use offset 0, length = HeaderLen + 1 so that:
// - FLOCK blocks all writers that would touch the header
// - Record locks (at RecordOffset(n), past the header) don't conflict
const flockOffset = int64(0)
func flockLen(a *DBFArea) int64 {
return int64(a.header.HeaderLen) + 1
}
// tryLock acquires an exclusive byte-range lock. Non-blocking: returns
// (false, nil) if another process already holds a conflicting lock,
// (false, err) on system error, (true, nil) on success.
func tryLock(fd int, start, length int64) (bool, error) {
flk := &syscall.Flock_t{
Type: syscall.F_WRLCK,
Whence: int16(0), // SEEK_SET
Start: start,
Len: length,
}
err := syscall.FcntlFlock(uintptr(fd), syscall.F_SETLK, flk)
if err == nil {
return true, nil
}
if err == syscall.EAGAIN || err == syscall.EACCES {
// Another process holds a conflicting lock.
return false, nil
}
return false, err
}
// unlockRange releases a byte-range lock previously acquired with tryLock.
func unlockRange(fd int, start, length int64) error {
flk := &syscall.Flock_t{
Type: syscall.F_UNLCK,
Whence: int16(0),
Start: start,
Len: length,
}
return syscall.FcntlFlock(uintptr(fd), syscall.F_SETLK, flk)
}
// LockFile tries to acquire an exclusive FLOCK on the DBF file.
// Non-blocking. Returns false if another process already holds any lock
// on the header region. Harbour: hb_dbfLock(a, DBOI_LOCKMODE_FILE).
func (a *DBFArea) LockFile() (bool, error) {
if a.dataFile == nil {
return false, fmt.Errorf("file not open")
}
if a.fileLocked {
return true, nil // already held
}
ok, err := tryLock(int(a.dataFile.Fd()), flockOffset, flockLen(a))
if err != nil {
return false, err
}
if ok {
a.fileLocked = true
}
return ok, nil
}
// UnlockFile releases the FLOCK held by this workarea.
func (a *DBFArea) UnlockFile() error {
if a.dataFile == nil || !a.fileLocked {
return nil
}
if err := unlockRange(int(a.dataFile.Fd()), flockOffset, flockLen(a)); err != nil {
return err
}
a.fileLocked = false
return nil
}
// LockRecord tries to acquire an exclusive lock on a single record.
// recNo == 0 means "current record". Non-blocking.
// Harbour: hb_dbfLock(a, DBOI_LOCKMODE_RECORD).
func (a *DBFArea) LockRecord(recNo uint32) (bool, error) {
if a.dataFile == nil {
return false, fmt.Errorf("file not open")
}
if recNo == 0 {
recNo = a.recNo
}
if recNo == 0 {
return false, nil
}
if a.lockedRecs != nil && a.lockedRecs[recNo] {
return true, nil // already held
}
start := a.header.RecordOffset(recNo)
length := int64(a.header.RecordLen)
ok, err := tryLock(int(a.dataFile.Fd()), start, length)
if err != nil {
return false, err
}
if ok {
if a.lockedRecs == nil {
a.lockedRecs = make(map[uint32]bool, 16)
}
a.lockedRecs[recNo] = true
}
return ok, nil
}
// UnlockRecord releases a single record lock. recNo == 0 unlocks ALL held
// record locks (Harbour behavior for DBRUNLOCK() with no args).
func (a *DBFArea) UnlockRecord(recNo uint32) error {
if a.dataFile == nil {
return nil
}
if recNo == 0 {
// Release all record locks
if a.lockedRecs == nil {
return nil
}
length := int64(a.header.RecordLen)
for r := range a.lockedRecs {
start := a.header.RecordOffset(r)
_ = unlockRange(int(a.dataFile.Fd()), start, length)
}
a.lockedRecs = nil
return nil
}
if a.lockedRecs == nil || !a.lockedRecs[recNo] {
return nil // not held
}
start := a.header.RecordOffset(recNo)
length := int64(a.header.RecordLen)
if err := unlockRange(int(a.dataFile.Fd()), start, length); err != nil {
return err
}
delete(a.lockedRecs, recNo)
return nil
}
// releaseAllLocks is called on Close to release any held locks.
func (a *DBFArea) releaseAllLocks() {
_ = a.UnlockFile()
_ = a.UnlockRecord(0)
}