feat(agentcompat): add SQLite hold scoped attribution

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
This commit is contained in:
naiba
2026-07-20 04:29:21 +00:00
co-authored by naiba/CloudCode
parent 26e92da33b
commit 0c6eb416a5
39 changed files with 5527 additions and 6 deletions
+11 -6
View File
@@ -11,7 +11,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/patrickmn/go-cache"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"sigs.k8s.io/yaml"
@@ -81,7 +80,7 @@ func InitFrontendTemplates() error {
// InitDBFromPath 从给出的文件路径中加载数据库
func InitDBFromPath(path string) error {
var err error
DB, err = gorm.Open(sqlite.Open(path), &gorm.Config{
DB, err = gorm.Open(openSQLiteDialector(path), &gorm.Config{
CreateBatchSize: 200,
})
if err != nil {
@@ -126,16 +125,15 @@ func RecordTransferHourlyUsage(servers ...*model.Server) {
}
for server := range slist {
_, _, deltaIn, deltaOut := server.TransferDeltaAndAdvance()
tx := model.Transfer{
ServerID: server.ID,
In: utils.SubUintChecked(server.State.NetInTransfer, server.PrevTransferInSnapshot),
Out: utils.SubUintChecked(server.State.NetOutTransfer, server.PrevTransferOutSnapshot),
In: deltaIn,
Out: deltaOut,
}
if tx.In == 0 && tx.Out == 0 {
continue
}
server.PrevTransferInSnapshot = server.State.NetInTransfer
server.PrevTransferOutSnapshot = server.State.NetOutTransfer
tx.CreatedAt = nowTrimSeconds
txs = append(txs, tx)
}
@@ -146,6 +144,13 @@ func RecordTransferHourlyUsage(servers ...*model.Server) {
log.Printf("NEZHA>> Saved traffic metrics to database. Affected %d row(s), Error: %v", len(txs), DB.Create(txs).Error)
}
func PersistTransfer(transfer model.Transfer) error {
if transfer.In == 0 && transfer.Out == 0 {
return nil
}
return DB.Create(&transfer).Error
}
// CleanMonitorHistory 清理流量记录(TSDB 有自己的保留策略)
func CleanMonitorHistory() {
// 清理已被删除的服务器的流量记录
@@ -0,0 +1,83 @@
//go:build agentcompat && linux
package singleton
import (
"errors"
"path/filepath"
"strings"
"testing"
)
func TestSQLiteAttributionErrorsDoNotExposeDatabasePath(t *testing.T) {
// Given
databasePath := filepath.Join(t.TempDir(), "private-dashboard.sqlite")
unsupportedDSN := "file:" + databasePath + "?mode=memory"
missingJournal := filepath.Join(t.TempDir(), "missing-journal")
// When
memoryDatabase, dsnErr := openSQLiteAttributionTestDB(unsupportedDSN)
if dsnErr == nil {
dsnErr = memoryDatabase.Ping()
}
if memoryDatabase != nil {
t.Cleanup(func() {
if closeErr := memoryDatabase.Close(); closeErr != nil {
t.Error(closeErr)
}
})
}
_, journalErr := sqliteAttributionOpenJournalDescriptor(missingJournal)
// Then
if !errors.Is(dsnErr, ErrSQLiteAttributionUnsupportedDSN) {
t.Fatal("file URI error does not wrap the typed unsupported DSN error")
}
if !errors.Is(journalErr, ErrSQLiteAttributionJournalIdentity) {
t.Fatal("journal error does not wrap the typed journal identity error")
}
if strings.Contains(dsnErr.Error(), databasePath) || strings.Contains(dsnErr.Error(), unsupportedDSN) {
t.Fatal("unsupported DSN error exposes its database path")
}
if strings.Contains(journalErr.Error(), missingJournal) {
t.Fatal("journal identity error exposes its database path")
}
}
func TestSQLiteAttributionAcceptsOnDiskFileURIAndRejectsInMemoryDSN(t *testing.T) {
// Given
resetSQLiteAttributionForTest()
databasePath := filepath.Join(t.TempDir(), "dashboard.sqlite")
// When
fileDatabase, fileErr := openSQLiteAttributionTestDB("file:" + databasePath)
if fileDatabase != nil {
t.Cleanup(func() {
if closeErr := fileDatabase.Close(); closeErr != nil {
t.Error(closeErr)
}
})
}
if fileErr == nil {
fileErr = fileDatabase.Ping()
}
memoryDatabase, memoryErr := openSQLiteAttributionTestDB(":memory:")
if memoryDatabase != nil {
t.Cleanup(func() {
if closeErr := memoryDatabase.Close(); closeErr != nil {
t.Error(closeErr)
}
})
}
if memoryErr == nil {
memoryErr = memoryDatabase.Ping()
}
// Then
if fileErr != nil {
t.Fatal("file URI for an on-disk database was rejected")
}
if !errors.Is(memoryErr, ErrSQLiteAttributionUnsupportedDSN) {
t.Fatal("in-memory DSN does not return the typed unsupported DSN error")
}
}
@@ -0,0 +1,180 @@
//go:build agentcompat && linux
package singleton
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"testing"
"golang.org/x/sys/unix"
)
func TestSQLiteAttributionConnectionCloseFinalizesActiveWriteTransaction(t *testing.T) {
// Given
resetSQLiteAttributionForTest()
databasePath := sqliteAttributionTestDatabasePath(t)
rawConnection, err := sqliteAttributionDriver{}.Open(databasePath)
if err != nil {
t.Fatal(err)
}
connection := rawConnection.(*sqliteAttributionConnection)
if _, err := connection.connection.Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)", nil); err != nil {
t.Fatal(err)
}
enableSQLiteAttribution()
transaction, err := connection.Begin()
if err != nil {
t.Fatal(err)
}
statement, err := connection.Prepare("INSERT INTO settings (value) VALUES (?)")
if err != nil {
t.Fatal(err)
}
if _, err := statement.Exec([]driver.Value{"close-active"}); err != nil {
t.Fatal(err)
}
if err := statement.Close(); err != nil {
t.Fatal(err)
}
identity, descriptor, active := sqliteAttributionTransactionState(t, connection)
if !active {
t.Fatal("active transaction is missing before Close")
}
// When
firstCloseErr := connection.Close()
secondCloseErr := connection.Close()
commitErr := transaction.Commit()
rollbackErr := transaction.Rollback()
_, descriptorErr := unix.FcntlInt(uintptr(descriptor), unix.F_GETFD, 0)
standard, openErr := sql.Open("sqlite3", databasePath)
if openErr != nil {
t.Fatal(openErr)
}
defer standard.Close()
var count int
countErr := standard.QueryRow("SELECT COUNT(*) FROM settings").Scan(&count)
tracker := sqliteAttributionTracker.Load()
tracker.mu.Lock()
_, active = tracker.transactions[identity]
tracker.mu.Unlock()
// Then
if firstCloseErr != nil || secondCloseErr != nil {
t.Fatalf("Close errors = %v / %v", firstCloseErr, secondCloseErr)
}
if !errors.Is(commitErr, driver.ErrBadConn) {
t.Fatalf("Commit after Close error = %v, want driver.ErrBadConn", commitErr)
}
if !errors.Is(rollbackErr, driver.ErrBadConn) {
t.Fatalf("Rollback after Close error = %v, want driver.ErrBadConn", rollbackErr)
}
if !errors.Is(descriptorErr, unix.EBADF) {
t.Fatalf("journal descriptor after Close = %v, want EBADF", descriptorErr)
}
if countErr != nil || count != 0 {
t.Fatalf("closed active transaction persisted count=%d err=%v", count, countErr)
}
if active {
t.Fatal("Close left the tracker transaction active")
}
}
func TestSQLiteAttributionConnectionCloseWakesSelectedCommit(t *testing.T) {
// Given
resetSQLiteAttributionForTest()
databasePath := sqliteAttributionTestDatabasePath(t)
rawConnection, err := sqliteAttributionDriver{}.Open(databasePath)
if err != nil {
t.Fatal(err)
}
connection := rawConnection.(*sqliteAttributionConnection)
if _, err := connection.connection.Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)", nil); err != nil {
t.Fatal(err)
}
enableSQLiteAttribution()
transaction, err := connection.Begin()
if err != nil {
t.Fatal(err)
}
statement, err := connection.Prepare("INSERT INTO settings (value) VALUES (?)")
if err != nil {
t.Fatal(err)
}
if _, err := statement.Exec([]driver.Value{"close-wakes-commit"}); err != nil {
t.Fatal(err)
}
if err := statement.Close(); err != nil {
t.Fatal(err)
}
_, descriptor, active := sqliteAttributionTransactionState(t, connection)
if !active {
t.Fatal("active transaction is missing before selected Commit")
}
tracker := sqliteAttributionTracker.Load()
session, err := tracker.ArmNextSQLiteHold()
if err != nil {
t.Fatal(err)
}
finalizing := make(chan error, 1)
commit := make(chan error, 1)
go func() {
_, waitErr := tracker.WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitFinalizing)
finalizing <- waitErr
}()
// When
go func() { commit <- transaction.Commit() }()
if finalizingErr := <-finalizing; finalizingErr != nil {
t.Fatalf("Commit finalization wait error = %v", finalizingErr)
}
closeErr := connection.Close()
commitErr := <-commit
_, descriptorErr := unix.FcntlInt(uintptr(descriptor), unix.F_GETFD, 0)
standard, err := sql.Open("sqlite3", databasePath)
if err != nil {
t.Fatal(err)
}
defer standard.Close()
var count int
if err := standard.QueryRow("SELECT COUNT(*) FROM settings").Scan(&count); err != nil {
t.Fatal(err)
}
// Then
if closeErr != nil {
t.Fatal(closeErr)
}
var holdErr *SQLiteHoldError
if !errors.As(commitErr, &holdErr) || !errors.Is(commitErr, ErrSQLiteHoldAborted) {
t.Fatalf("Commit error after Close = %v", commitErr)
}
if !errors.Is(descriptorErr, unix.EBADF) {
t.Fatalf("journal descriptor after Close = %v, want EBADF", descriptorErr)
}
if count != 0 {
t.Fatalf("Close while Commit waited persisted %d rows", count)
}
}
type sqliteAttributionBadConnRows struct{}
func (sqliteAttributionBadConnRows) Columns() []string { return []string{"value"} }
func (sqliteAttributionBadConnRows) Close() error { return nil }
func (sqliteAttributionBadConnRows) Next([]driver.Value) error { return driver.ErrBadConn }
func TestSQLiteAttributionReadonlyRowsReturnBadConnWithoutPanic(t *testing.T) {
// Given
rows := &sqliteAttributionRows{rows: sqliteAttributionBadConnRows{}}
// When
err := rows.Next(make([]driver.Value, 1))
// Then
if !errors.Is(err, driver.ErrBadConn) {
t.Fatalf("readonly rows Next error = %v, want driver.ErrBadConn", err)
}
}
@@ -0,0 +1,102 @@
//go:build agentcompat && linux
package singleton
import (
"context"
"errors"
"testing"
"golang.org/x/sys/unix"
)
func TestSQLiteAttributionCommitReleaseLinearizesBeforeContextCancellation(t *testing.T) {
// Given
transactionContext, cancel := context.WithCancel(context.Background())
defer cancel()
connection, transaction, session, databasePath := sqliteAttributionHeldTransaction(t, "released-before-cancel", transactionContext)
identity, descriptor, active := sqliteAttributionTransactionState(t, connection)
if !active {
t.Fatal("released transaction is not active")
}
// When
commit := sqliteAttributionStartHeldCommit(t, transaction, session)
if err := sqliteAttributionTracker.Load().ReleaseSQLiteHold(session); err != nil {
t.Fatal(err)
}
cancel()
commitErr := <-commit
terminal, terminalErr := sqliteAttributionTracker.Load().WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitFinalizing)
_, descriptorErr := unix.FcntlInt(uintptr(descriptor), unix.F_GETFD, 0)
// Then
if commitErr != nil {
t.Fatalf("released Commit error after context cancellation = %v", commitErr)
}
if terminalErr != nil || !terminal.Released || !terminal.Selected || !terminal.Finalizing {
t.Fatalf("released terminal=%+v err=%v", terminal, terminalErr)
}
if !errors.Is(descriptorErr, unix.EBADF) {
t.Fatalf("journal descriptor after released Commit = %v, want EBADF", descriptorErr)
}
if count := sqliteAttributionPersistedCount(t, databasePath); count != 1 {
t.Fatalf("released Commit persisted %d rows", count)
}
if _, _, active := sqliteAttributionTransactionState(t, connection); active {
t.Fatal("released Commit left the connection transaction active")
}
if sqliteAttributionTrackerTransactionActive(sqliteAttributionTracker.Load(), identity) {
t.Fatal("released Commit left the tracker transaction active")
}
}
func TestSQLiteAttributionCommitCancellationAbortsBeforeRelease(t *testing.T) {
// Given
transactionContext, cancel := context.WithCancel(context.Background())
defer cancel()
connection, transaction, session, databasePath := sqliteAttributionHeldTransaction(t, "cancelled-before-release", transactionContext)
identity, descriptor, active := sqliteAttributionTransactionState(t, connection)
if !active {
t.Fatal("cancelled transaction is not active")
}
// When
commit := sqliteAttributionStartHeldCommit(t, transaction, session)
cancel()
commitErr := <-commit
releaseErr := sqliteAttributionTracker.Load().ReleaseSQLiteHold(session)
_, terminalErr := sqliteAttributionTracker.Load().WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitFinalizing)
_, descriptorErr := unix.FcntlInt(uintptr(descriptor), unix.F_GETFD, 0)
tracker := sqliteAttributionTracker.Load()
tracker.mu.Lock()
terminal := tracker.terminal
tracker.mu.Unlock()
// Then
var holdErr *SQLiteHoldError
if !errors.As(commitErr, &holdErr) || !errors.Is(commitErr, ErrSQLiteHoldAborted) || !errors.Is(commitErr, context.Canceled) {
t.Fatalf("cancelled Commit error = %v", commitErr)
}
if !errors.Is(releaseErr, ErrSQLiteHoldStaleSession) {
t.Fatalf("release after cancellation-owned abort = %v", releaseErr)
}
if !errors.Is(terminalErr, ErrSQLiteHoldAborted) {
t.Fatalf("cancelled terminal wait error = %v, want aborted", terminalErr)
}
if terminal == nil || terminal.released {
t.Fatalf("cancelled terminal state = %+v, want aborted and unreleased", terminal)
}
if !errors.Is(descriptorErr, unix.EBADF) {
t.Fatalf("journal descriptor after cancelled Commit = %v, want EBADF", descriptorErr)
}
if count := sqliteAttributionPersistedCount(t, databasePath); count != 0 {
t.Fatalf("cancelled Commit persisted %d rows", count)
}
if _, _, active := sqliteAttributionTransactionState(t, connection); active {
t.Fatal("cancelled Commit left the connection transaction active")
}
if sqliteAttributionTrackerTransactionActive(sqliteAttributionTracker.Load(), identity) {
t.Fatal("cancelled Commit left the tracker transaction active")
}
}
@@ -0,0 +1,154 @@
//go:build agentcompat && linux
package singleton
import (
"database/sql/driver"
"errors"
"sync"
"sync/atomic"
"testing"
)
type sqliteAttributionBlockingTx struct {
commitStarted chan struct{}
allowCommit chan struct{}
commitCalls atomic.Int32
rollbackCalls atomic.Int32
lifecycleMu *sync.Mutex
lockFree atomic.Bool
}
func (transaction *sqliteAttributionBlockingTx) Commit() error {
transaction.commitCalls.Add(1)
// Probe before publishing entry so losing terminal calls cannot contend with this boundary check.
if transaction.lifecycleMu.TryLock() {
transaction.lockFree.Store(true)
transaction.lifecycleMu.Unlock()
}
close(transaction.commitStarted)
<-transaction.allowCommit
return nil
}
func (transaction *sqliteAttributionBlockingTx) Rollback() error {
transaction.rollbackCalls.Add(1)
return nil
}
func TestSQLiteAttributionTransactionCompletionRunsRawCommitExactlyOnce(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
identity := sqliteHoldTestTransaction(201)
if err := tracker.BeginSQLiteTransaction(identity); err != nil {
t.Fatal(err)
}
raw := &sqliteAttributionBlockingTx{commitStarted: make(chan struct{}), allowCommit: make(chan struct{})}
state := &sqliteAttributionTransaction{
transaction: identity,
raw: raw,
tracker: tracker,
journalFD: -1,
done: make(chan struct{}),
}
var rawCloseCalls atomic.Int32
connection := &sqliteAttributionConnection{transaction: state}
connection.closeRawConnection = func() error {
if !connection.lifecycleMu.TryLock() {
return errors.New("raw connection Close ran while lifecycle lock was held")
}
connection.lifecycleMu.Unlock()
rawCloseCalls.Add(1)
return nil
}
raw.lifecycleMu = &connection.lifecycleMu
owner := &sqliteAttributionTx{connection: connection, state: state}
secondCommit := &sqliteAttributionTx{connection: connection, state: state}
commitResult := make(chan error, 1)
secondCommitResult := make(chan error, 1)
rollbackResult := make(chan error, 1)
closeResult := make(chan error, 1)
secondCommitStarted := make(chan struct{})
rollbackStarted := make(chan struct{})
closeStarted := make(chan struct{})
// When
go func() { commitResult <- owner.Commit() }()
<-raw.commitStarted
go func() {
close(secondCommitStarted)
secondCommitResult <- secondCommit.Commit()
}()
go func() {
close(rollbackStarted)
rollbackResult <- owner.Rollback()
}()
go func() {
close(closeStarted)
closeResult <- connection.Close()
}()
<-secondCommitStarted
<-rollbackStarted
<-closeStarted
for _, result := range []<-chan error{secondCommitResult, rollbackResult} {
select {
case err := <-result:
t.Fatalf("completion loser returned before raw Commit completion: %v", err)
default:
}
}
select {
case <-state.done:
t.Fatal("completion published before raw Commit was allowed to finish")
default:
}
if calls := rawCloseCalls.Load(); calls != 0 {
t.Fatalf("raw connection Close calls while raw Commit was in flight = %d, want 0", calls)
}
if !raw.lockFree.Load() {
t.Fatal("raw Commit ran while lifecycle lock was held")
}
if !sqliteAttributionTrackerTransactionActive(tracker, identity) {
t.Fatal("tracker transaction became inactive while raw Commit was blocked")
}
close(raw.allowCommit)
commitErr := <-commitResult
secondCommitErr := <-secondCommitResult
rollbackErr := <-rollbackResult
closeErr := <-closeResult
// Then
if commitErr != nil {
t.Fatalf("raw Commit error = %v", commitErr)
}
for _, loserErr := range []error{secondCommitErr, rollbackErr} {
if !errors.Is(loserErr, driver.ErrBadConn) {
t.Fatalf("completion loser error = %v, want driver.ErrBadConn", loserErr)
}
}
if closeErr != nil && !errors.Is(closeErr, driver.ErrBadConn) {
t.Fatalf("connection Close error = %v, want nil or driver.ErrBadConn", closeErr)
}
select {
case <-state.done:
default:
t.Fatal("completion signal did not close after raw Commit finished")
}
if calls := raw.commitCalls.Load(); calls != 1 {
t.Fatalf("raw Commit calls = %d, want 1", calls)
}
if calls := rawCloseCalls.Load(); calls != 1 {
t.Fatalf("raw connection Close calls after raw Commit completed = %d, want 1", calls)
}
if calls := raw.rollbackCalls.Load(); calls != 0 {
t.Fatalf("raw Rollback calls while raw Commit owned completion = %d, want 0", calls)
}
if _, _, active := sqliteAttributionTransactionState(t, connection); active {
t.Fatal("completed transaction remained attached to the connection")
}
if sqliteAttributionTrackerTransactionActive(tracker, identity) {
t.Fatal("completed transaction remained active in the tracker")
}
}
var _ driver.Tx = (*sqliteAttributionBlockingTx)(nil)
@@ -0,0 +1,26 @@
//go:build agentcompat && linux
package singleton
import (
"errors"
)
func (connection *sqliteAttributionConnection) Close() error {
connection.lifecycleMu.Lock()
state := connection.transaction
connection.lifecycleMu.Unlock()
if state == nil {
return connection.closeRaw()
}
// database/sql may close Conn before Tx reaches Rollback; converge the attribution state here.
rollbackErr := state.rollback(connection)
return errors.Join(rollbackErr, connection.closeRaw())
}
func (connection *sqliteAttributionConnection) closeRaw() error {
if connection.closeRawConnection != nil {
return connection.closeRawConnection()
}
return connection.connection.Close()
}
@@ -0,0 +1,52 @@
//go:build agentcompat && linux
package singleton
import (
"context"
"database/sql/driver"
"errors"
)
func (connection *sqliteAttributionConnection) Exec(query string, values []driver.Value) (driver.Result, error) {
statement, err := connection.Prepare(query)
if err != nil {
return nil, err
}
defer statement.Close()
return statement.Exec(values)
}
func (connection *sqliteAttributionConnection) ExecContext(ctx context.Context, query string, values []driver.NamedValue) (driver.Result, error) {
statement, err := connection.PrepareContext(ctx, query)
if err != nil {
return nil, err
}
defer statement.Close()
return statement.(driver.StmtExecContext).ExecContext(ctx, values)
}
func (connection *sqliteAttributionConnection) Query(query string, values []driver.Value) (driver.Rows, error) {
statement, err := connection.Prepare(query)
if err != nil {
return nil, err
}
wrapped := statement.(*sqliteAttributionStatement)
return wrapped.queryOwned(func() (driver.Rows, error) { return wrapped.statement.Query(values) }, statement)
}
func (connection *sqliteAttributionConnection) QueryContext(ctx context.Context, query string, values []driver.NamedValue) (driver.Rows, error) {
statement, err := connection.PrepareContext(ctx, query)
if err != nil {
return nil, err
}
return connection.queryContextStatement(ctx, statement.(*sqliteAttributionStatement), values, statement)
}
func (connection *sqliteAttributionConnection) queryContextStatement(ctx context.Context, statement *sqliteAttributionStatement, values []driver.NamedValue, owner driver.Stmt) (driver.Rows, error) {
contextStatement, ok := statement.statement.(driver.StmtQueryContext)
if !ok {
return nil, errors.Join(driver.ErrSkip, owner.Close())
}
return statement.queryOwned(func() (driver.Rows, error) { return contextStatement.QueryContext(ctx, values) }, owner)
}
@@ -0,0 +1,149 @@
//go:build agentcompat && linux
package singleton
import (
"errors"
"runtime"
"strings"
"golang.org/x/sys/unix"
)
func (connection *sqliteAttributionConnection) beforeWrite(classification sqliteAttributionClassification) error {
if !sqliteAttributionEnabled.Load() || classification.readonly {
return nil
}
if !classification.valid() {
return &SQLiteAttributionError{Cause: ErrSQLiteAttributionUnsupportedWrite}
}
connection.lifecycleMu.Lock()
state := connection.transaction
if state == nil {
connection.lifecycleMu.Unlock()
return &SQLiteAttributionError{Cause: ErrSQLiteAttributionUnboundWrite}
}
poison := state.poison
connection.lifecycleMu.Unlock()
if poison != nil {
return poison
}
connection.execution = &sqliteAttributionExecution{classification: classification, origin: sqliteAttributionOrigin()}
return nil
}
func (connection *sqliteAttributionConnection) discardExecution() { connection.execution = nil }
func (connection *sqliteAttributionConnection) publishExecution() error {
execution := connection.execution
connection.execution = nil
if execution == nil {
return nil
}
connection.lifecycleMu.Lock()
state := connection.transaction
if state == nil {
connection.lifecycleMu.Unlock()
return &SQLiteAttributionError{Cause: ErrSQLiteAttributionUnboundWrite}
}
if execution.hook.mismatch || !execution.hook.seen {
err := &SQLiteAttributionError{Cause: ErrSQLiteAttributionUnsupportedWrite}
state.poison = err
connection.lifecycleMu.Unlock()
return err
}
if state.journalFD < 0 {
descriptor, err := sqliteAttributionOpenJournalDescriptor(connection.journal)
if err != nil {
state.poison = err
connection.lifecycleMu.Unlock()
return err
}
state.journalFD = descriptor
}
journal, err := sqliteAttributionJournalIdentityFromDescriptor(state.journalFD)
if err != nil {
state.poison = err
connection.lifecycleMu.Unlock()
return err
}
transaction := state.transaction
tracker := state.tracker
connection.lifecycleMu.Unlock()
origin := execution.origin
origin.Operation = execution.classification.operation
origin.Table = execution.classification.table
err = tracker.RecordSQLiteWrite(transaction, SQLiteWriteObservation{
Origin: origin,
Update: SQLiteUpdateObservation{Operation: execution.classification.operation, Table: execution.classification.table, Journal: journal},
})
if err != nil {
return connection.poison(err)
}
return nil
}
func (connection *sqliteAttributionConnection) poison(err error) error {
connection.lifecycleMu.Lock()
state := connection.transaction
if state != nil {
state.poison = err
}
connection.lifecycleMu.Unlock()
return err
}
// The adapter owns the explicit main transaction and DELETE journal; path-only stat is racy, so retain this O_PATH descriptor through finalization.
func sqliteAttributionOpenJournalDescriptor(path string) (int, error) {
descriptor, err := unix.Open(path, unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if err != nil {
return -1, &SQLiteAttributionError{Cause: errors.Join(ErrSQLiteAttributionJournalIdentity, err)}
}
return descriptor, nil
}
func sqliteAttributionCloseJournalDescriptor(descriptor int) error {
if descriptor < 0 {
return nil
}
if err := unix.Close(descriptor); err != nil {
return &SQLiteAttributionError{Cause: errors.Join(ErrSQLiteAttributionJournalIdentity, err)}
}
return nil
}
func sqliteAttributionJournalIdentityFromDescriptor(descriptor int) (SQLiteJournalIdentity, error) {
var status unix.Statx_t
mask := uint32(unix.STATX_BASIC_STATS | unix.STATX_BTIME | unix.STATX_MNT_ID)
if err := unix.Statx(descriptor, "", unix.AT_EMPTY_PATH|unix.AT_STATX_SYNC_AS_STAT, int(mask), &status); err != nil {
return SQLiteJournalIdentity{}, &SQLiteAttributionError{Cause: errors.Join(ErrSQLiteAttributionJournalIdentity, err)}
}
required := uint32(unix.STATX_MNT_ID | unix.STATX_BTIME)
if status.Mask&required != required {
return SQLiteJournalIdentity{}, &SQLiteAttributionError{Cause: ErrSQLiteAttributionJournalIdentity}
}
return SQLiteJournalIdentity{MountID: status.Mnt_id, DeviceMajor: status.Dev_major, DeviceMinor: status.Dev_minor, Inode: status.Ino, BirthSeconds: status.Btime.Sec, BirthNanoseconds: status.Btime.Nsec}, nil
}
func sqliteAttributionOrigin() SQLiteExecutionOrigin {
programCounters := make([]uintptr, 16)
count := runtime.Callers(3, programCounters)
programCounters = programCounters[:count]
frames := runtime.CallersFrames(programCounters)
frame, more := frames.Next()
for more && !strings.Contains(frame.Function, "github.com/nezhahq/nezha/") {
frame, more = frames.Next()
}
return SQLiteExecutionOrigin{StackHash: sqliteAttributionStackHash(programCounters), FirstNezhaFrame: frame.Function}
}
func sqliteAttributionStackHash(programCounters []uintptr) uint64 {
const offsetBasis uint64 = 14695981039346656037
const prime uint64 = 1099511628211
hash := offsetBasis
for _, programCounter := range programCounters {
hash ^= uint64(programCounter)
hash *= prime
}
return hash
}
@@ -0,0 +1,186 @@
//go:build agentcompat && linux
package singleton
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
"github.com/nezhahq/nezha/model"
"gorm.io/gorm"
)
func TestSQLiteAttributionHoldFacadeEnablesOnArmAndDisablesOnAbort(t *testing.T) {
// Given
resetSQLiteAttributionForTest()
t.Cleanup(resetSQLiteAttributionForTest)
// When
receipt, armErr := ArmNextSQLiteHold()
enabledAfterArm := sqliteAttributionEnabled.Load()
_, abortErr := AbortSQLiteHold(receipt)
// Then
if armErr != nil || abortErr != nil {
t.Fatalf("arm=%v abort=%v", armErr, abortErr)
}
if !enabledAfterArm {
t.Fatal("successful hold arm did not enable SQLite attribution")
}
if sqliteAttributionEnabled.Load() {
t.Fatal("successful hold abort left SQLite attribution enabled")
}
}
func TestSQLiteAttributionHoldFacadeDisablesAfterRelease(t *testing.T) {
// Given
resetSQLiteAttributionForTest()
t.Cleanup(resetSQLiteAttributionForTest)
receipt, err := ArmNextSQLiteHold()
if err != nil {
t.Fatal(err)
}
transaction := sqliteHoldTestTransaction(204)
tracker := sqliteAttributionTracker.Load()
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
if err := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{Operation: SQLiteOperationUpdate, Table: "api_tokens", Journal: sqliteHoldTestJournal}); err != nil {
t.Fatal(err)
}
if _, err := tracker.BeginSQLiteFinalization(transaction); err != nil {
t.Fatal(err)
}
// When
result, releaseErr := ReleaseSQLiteHold(receipt)
// Then
if releaseErr != nil || result.State != SQLiteHoldControlStateReleased {
t.Fatalf("release=%+v err=%v", result, releaseErr)
}
if sqliteAttributionEnabled.Load() {
t.Fatal("successful hold release left SQLite attribution enabled")
}
}
func TestSQLiteAttributionHoldFacadeKeepsEnabledAfterCanceledWaitUntilAbort(t *testing.T) {
// Given
resetSQLiteAttributionForTest()
t.Cleanup(resetSQLiteAttributionForTest)
receipt, err := ArmNextSQLiteHold()
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
// When
_, waitErr := WaitSQLiteHoldSelected(ctx, receipt)
enabledAfterWait := sqliteAttributionEnabled.Load()
_, abortErr := AbortSQLiteHold(receipt)
// Then
if !errors.Is(waitErr, context.Canceled) || abortErr != nil {
t.Fatalf("wait=%v abort=%v", waitErr, abortErr)
}
if !enabledAfterWait {
t.Fatal("canceled wait disabled attribution before the active hold was aborted")
}
if sqliteAttributionEnabled.Load() {
t.Fatal("abort after canceled wait left SQLite attribution enabled")
}
}
func TestSQLiteAttributionHoldFacadeStaleReceiptCannotDisableNewHold(t *testing.T) {
// Given
resetSQLiteAttributionForTest()
t.Cleanup(resetSQLiteAttributionForTest)
first, err := ArmNextSQLiteHold()
if err != nil {
t.Fatal(err)
}
if _, err := AbortSQLiteHold(first); err != nil {
t.Fatal(err)
}
second, err := ArmNextSQLiteHold()
if err != nil {
t.Fatal(err)
}
// When
_, staleErr := AbortSQLiteHold(first)
// Then
if !errors.Is(staleErr, ErrSQLiteHoldStaleSession) {
t.Fatalf("stale abort error = %v", staleErr)
}
if !sqliteAttributionEnabled.Load() {
t.Fatal("stale receipt disabled attribution owned by the current hold")
}
if _, err := AbortSQLiteHold(second); err != nil {
t.Fatal(err)
}
}
func TestSQLiteAttributionHoldFacadeSelectsGORMAPITokenUsageUpdate(t *testing.T) {
// Given
resetSQLiteAttributionForTest()
t.Cleanup(resetSQLiteAttributionForTest)
database, err := gorm.Open(openSQLiteDialector(filepath.Join(t.TempDir(), "dashboard.sqlite")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDatabase, err := database.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if closeErr := sqlDatabase.Close(); closeErr != nil {
t.Error(closeErr)
}
})
if err := database.AutoMigrate(&model.APIToken{}); err != nil {
t.Fatal(err)
}
token := model.APIToken{UserID: 1, Name: "usage-update", TokenHash: "hash"}
if err := database.Create(&token).Error; err != nil {
t.Fatal(err)
}
receipt, err := ArmNextSQLiteHold()
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
writerDone := make(chan error, 1)
usageTime := time.Unix(1_700_000_000, 0)
go func() {
writerDone <- database.Model(&model.APIToken{}).Where("id = ?", token.ID).Updates(map[string]any{
"last_used_at": usageTime,
"last_used_ip": "127.0.0.1",
}).Error
cancel()
}()
// When
selected, selectedErr := WaitSQLiteHoldSelected(ctx, receipt)
finalizing, finalizingErr := WaitSQLiteHoldFinalizing(ctx, selected)
_, releaseErr := ReleaseSQLiteHold(finalizing)
writerErr := <-writerDone
// Then
if selectedErr != nil || finalizingErr != nil || releaseErr != nil || writerErr != nil {
t.Fatalf("selected=%v finalizing=%v release=%v writer=%v", selectedErr, finalizingErr, releaseErr, writerErr)
}
var updated model.APIToken
if err := database.First(&updated, token.ID).Error; err != nil {
t.Fatal(err)
}
if updated.LastUsedAt == nil || !updated.LastUsedAt.Equal(usageTime) || updated.LastUsedIP != "127.0.0.1" {
t.Fatalf("persisted usage update = %+v", updated)
}
}
@@ -0,0 +1,42 @@
//go:build agentcompat && linux
package singleton
import (
"path/filepath"
"testing"
"golang.org/x/sys/unix"
)
func TestSQLiteAttributionDerivesJournalIdentityFromOpenedDescriptor(t *testing.T) {
// Given
journalPath := filepath.Join(t.TempDir(), "dashboard.sqlite-journal")
descriptor, err := unix.Open(journalPath, unix.O_CREAT|unix.O_WRONLY|unix.O_CLOEXEC, 0o600)
if err != nil {
t.Fatal(err)
}
if err := unix.Close(descriptor); err != nil {
t.Fatal(err)
}
descriptor, err = unix.Open(journalPath, unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if closeErr := unix.Close(descriptor); closeErr != nil {
t.Error(closeErr)
}
})
// When
identity, err := sqliteAttributionJournalIdentityFromDescriptor(descriptor)
// Then
if err != nil {
t.Fatal(err)
}
if identity.MountID == 0 || identity.Inode == 0 || identity.BirthSeconds == 0 {
t.Fatal("descriptor-derived journal identity is incomplete")
}
}
@@ -0,0 +1,211 @@
//go:build agentcompat && linux
package singleton
import (
"context"
"database/sql/driver"
"errors"
"testing"
"golang.org/x/sys/unix"
)
func TestSQLiteAttributionReturningCompletesExactlyOneRow(t *testing.T) {
// Given
database := openSQLiteAttributionTestDatabase(t)
enableSQLiteAttribution()
transaction, err := database.BeginTx(context.Background(), nil)
if err != nil {
t.Fatal(err)
}
// When
rows, err := transaction.QueryContext(context.Background(), "INSERT INTO settings (value) VALUES (?) RETURNING id", "one-row")
if err != nil {
t.Fatal(err)
}
if !rows.Next() {
t.Fatal("RETURNING did not yield its inserted row")
}
var identifier int64
if err := rows.Scan(&identifier); err != nil {
t.Fatal(err)
}
if rows.Next() {
t.Fatal("RETURNING yielded more than one row")
}
rowsErr := rows.Err()
closeErr := rows.Close()
commitErr := transaction.Commit()
count := sqliteAttributionSettingsCount(t, database)
// Then
if rowsErr != nil || closeErr != nil || commitErr != nil {
t.Fatal("completed RETURNING did not finish successfully")
}
if count != 1 {
t.Fatalf("completed RETURNING persisted %d rows", count)
}
}
func TestSQLiteAttributionEarlyReturningCloseRollsBackTransaction(t *testing.T) {
// Given
database := openSQLiteAttributionTestDatabase(t)
enableSQLiteAttribution()
transaction, err := database.BeginTx(context.Background(), nil)
if err != nil {
t.Fatal(err)
}
// When
rows, err := transaction.QueryContext(context.Background(), "INSERT INTO settings (value) VALUES (?) RETURNING id", "early-close")
if err != nil {
t.Fatal(err)
}
if !rows.Next() {
t.Fatal("RETURNING did not yield its inserted row")
}
if err := rows.Close(); err != nil {
t.Fatal(err)
}
commitErr := transaction.Commit()
count := sqliteAttributionSettingsCount(t, database)
// Then
if commitErr == nil {
t.Fatal("early RETURNING Close allowed Commit")
}
if count != 0 {
t.Fatalf("early RETURNING Close persisted %d rows", count)
}
}
func TestSQLiteAttributionZeroRowUpdateDoesNotPublishEvidence(t *testing.T) {
// Given
database := openSQLiteAttributionTestDatabase(t)
enableSQLiteAttribution()
transaction, err := database.BeginTx(context.Background(), nil)
if err != nil {
t.Fatal(err)
}
// When
if _, err := transaction.Exec("UPDATE settings SET value = ? WHERE id = ?", "zero", -1); err != nil {
t.Fatal(err)
}
commitErr := transaction.Commit()
evidence := sqliteAttributionTrackerWriteEvidence()
// Then
if commitErr != nil {
t.Fatal(commitErr)
}
if evidence.hasWrite {
t.Fatal("zero-row UPDATE published write evidence")
}
}
func TestSQLiteAttributionDirectReadQueryClosesOwnedStatement(t *testing.T) {
// Given
database := openSQLiteAttributionTestDatabase(t)
// When
rows, err := database.QueryContext(context.Background(), "SELECT id FROM settings")
if err != nil {
t.Fatal(err)
}
next := rows.Next()
rowsErr := rows.Err()
closeErr := rows.Close()
// Then
if next {
t.Fatal("empty SELECT returned a row")
}
if rowsErr != nil || closeErr != nil {
t.Fatal("direct readonly Query did not complete cleanly")
}
}
func TestSQLiteAttributionCommitClosesRetainedJournalDescriptor(t *testing.T) {
// Given
resetSQLiteAttributionForTest()
rawConnection, err := sqliteAttributionDriver{}.Open(sqliteAttributionTestDatabasePath(t))
if err != nil {
t.Fatal(err)
}
connection := rawConnection.(*sqliteAttributionConnection)
t.Cleanup(func() {
if closeErr := connection.Close(); closeErr != nil {
t.Error(closeErr)
}
})
create, err := connection.Prepare("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)")
if err != nil {
t.Fatal(err)
}
if _, err := create.Exec(nil); err != nil {
t.Fatal(err)
}
if err := create.Close(); err != nil {
t.Fatal(err)
}
enableSQLiteAttribution()
transaction, err := connection.Begin()
if err != nil {
t.Fatal(err)
}
insert, err := connection.Prepare("INSERT INTO settings (value) VALUES (?)")
if err != nil {
t.Fatal(err)
}
if _, err := insert.Exec([]driver.Value{"retained-descriptor"}); err != nil {
t.Fatal(err)
}
if err := insert.Close(); err != nil {
t.Fatal(err)
}
_, descriptor, active := sqliteAttributionTransactionState(t, connection)
if !active {
t.Fatal("active transaction is missing before Commit")
}
// When
commitErr := transaction.Commit()
_, descriptorErr := unix.FcntlInt(uintptr(descriptor), unix.F_GETFD, 0)
// Then
if commitErr != nil {
t.Fatal(commitErr)
}
if !errors.Is(descriptorErr, unix.EBADF) {
t.Fatalf("retained journal descriptor remains open: %v", descriptorErr)
}
}
func TestSQLiteAttributionQueryRowReturningPoisonsTransaction(t *testing.T) {
// Given
database := openSQLiteAttributionTestDatabase(t)
enableSQLiteAttribution()
transaction, err := database.BeginTx(context.Background(), nil)
if err != nil {
t.Fatal(err)
}
// When
var identifier int64
scanErr := transaction.QueryRowContext(context.Background(), "INSERT INTO settings (value) VALUES (?) RETURNING id", "query-row").Scan(&identifier)
commitErr := transaction.Commit()
// Then
if scanErr != nil {
t.Fatal(scanErr)
}
if commitErr == nil {
t.Fatal("QueryRowContext RETURNING committed without reaching EOF")
}
if sqliteAttributionTrackerHasWrite() {
t.Fatal("QueryRowContext RETURNING published evidence without EOF")
}
}
@@ -0,0 +1,145 @@
//go:build agentcompat && linux
package singleton
import (
"context"
"database/sql/driver"
"errors"
"testing"
)
type sqliteAttributionLegacyQueryStmt struct {
closeCount int
}
func (statement *sqliteAttributionLegacyQueryStmt) Close() error { statement.closeCount++; return nil }
func (sqliteAttributionLegacyQueryStmt) NumInput() int { return -1 }
func (sqliteAttributionLegacyQueryStmt) Exec([]driver.Value) (driver.Result, error) {
return nil, driver.ErrSkip
}
func (sqliteAttributionLegacyQueryStmt) Query([]driver.Value) (driver.Rows, error) {
return nil, driver.ErrSkip
}
type sqliteAttributionCountingStmt struct {
driver.Stmt
closeCount int
}
func (statement *sqliteAttributionCountingStmt) Close() error {
statement.closeCount++
return statement.Stmt.Close()
}
type sqliteAttributionQueryProbeStmt struct {
closeCount int
queryCount int
}
func (statement *sqliteAttributionQueryProbeStmt) Close() error { statement.closeCount++; return nil }
func (sqliteAttributionQueryProbeStmt) NumInput() int { return -1 }
func (sqliteAttributionQueryProbeStmt) Exec([]driver.Value) (driver.Result, error) {
return nil, driver.ErrSkip
}
func (statement *sqliteAttributionQueryProbeStmt) Query([]driver.Value) (driver.Rows, error) {
statement.queryCount++
return nil, driver.ErrSkip
}
func TestSQLiteAttributionDirectQueryClosesOwnedStatementWhenPreStepRejects(t *testing.T) {
// Given
resetSQLiteAttributionForTest()
rawConnection, err := sqliteAttributionDriver{}.Open(sqliteAttributionTestDatabasePath(t))
if err != nil {
t.Fatal(err)
}
connection := rawConnection.(*sqliteAttributionConnection)
t.Cleanup(func() {
if closeErr := connection.Close(); closeErr != nil {
t.Error(closeErr)
}
})
if _, err := connection.connection.Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)", nil); err != nil {
t.Fatal(err)
}
prepared, err := connection.Prepare("INSERT INTO settings (value) VALUES (?) RETURNING id")
if err != nil {
t.Fatal(err)
}
statement := prepared.(*sqliteAttributionStatement)
owner := &sqliteAttributionCountingStmt{Stmt: prepared}
enableSQLiteAttribution()
// When
rows, queryErr := statement.queryOwned(func() (driver.Rows, error) {
return statement.statement.Query([]driver.Value{"rejected"})
}, owner)
// Then
if rows != nil {
t.Fatal("rejected direct Query returned rows")
}
if !errors.Is(queryErr, ErrSQLiteAttributionUnboundWrite) {
t.Fatalf("rejected direct Query error = %v", queryErr)
}
if owner.closeCount != 1 {
t.Fatalf("owned statement Close count = %d, want 1", owner.closeCount)
}
}
func TestSQLiteAttributionDirectQueryRejectsUnboundWriteThroughPublicConnection(t *testing.T) {
resetSQLiteAttributionForTest()
probe := &sqliteAttributionQueryProbeStmt{}
var connection *sqliteAttributionConnection
connection = &sqliteAttributionConnection{
prepareStatement: func(context.Context, string) (driver.Stmt, error) {
return &sqliteAttributionStatement{
connection: connection,
statement: probe,
classification: sqliteAttributionClassification{
hasRowDML: true,
operation: SQLiteOperationInsert,
table: "settings",
},
}, nil
},
}
enableSQLiteAttribution()
rows, queryErr := connection.Query("INSERT INTO settings (value) VALUES (?) RETURNING id", []driver.Value{"public-rejected"})
if rows != nil {
t.Fatal("public direct Query returned rows after pre-step rejection")
}
if !errors.Is(queryErr, ErrSQLiteAttributionUnboundWrite) {
t.Fatalf("public direct Query error = %v", queryErr)
}
if probe.closeCount != 1 {
t.Fatalf("public direct Query statement Close count = %d, want 1", probe.closeCount)
}
if probe.queryCount != 0 {
t.Fatalf("public direct Query invoked underlying Query %d times, want 0", probe.queryCount)
}
}
func TestSQLiteAttributionQueryContextClosesUnsupportedStatementBeforeErrSkip(t *testing.T) {
// Given
statement := &sqliteAttributionLegacyQueryStmt{}
connection := &sqliteAttributionConnection{}
wrapped := &sqliteAttributionStatement{connection: connection, statement: statement}
// When
rows, queryErr := connection.queryContextStatement(context.Background(), wrapped, nil, statement)
// Then
if rows != nil {
t.Fatal("unsupported QueryContext returned rows")
}
if !errors.Is(queryErr, driver.ErrSkip) {
t.Fatalf("unsupported QueryContext error = %v", queryErr)
}
if statement.closeCount != 1 {
t.Fatalf("unsupported QueryContext Close count = %d, want 1", statement.closeCount)
}
}
@@ -0,0 +1,227 @@
//go:build agentcompat && linux
package singleton
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"testing"
"golang.org/x/sys/unix"
)
func sqliteAttributionReleasedCommitFixture(t *testing.T, value string) (*sqliteAttributionConnection, *sqliteAttributionTx, SQLiteHoldSession, string, SQLiteTransaction, int) {
t.Helper()
resetSQLiteAttributionForTest()
databasePath := sqliteAttributionTestDatabasePath(t)
rawConnection, err := sqliteAttributionDriver{}.Open(databasePath)
if err != nil {
t.Fatal(err)
}
connection := rawConnection.(*sqliteAttributionConnection)
if _, err := connection.connection.Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)", nil); err != nil {
t.Fatal(err)
}
enableSQLiteAttribution()
transaction, err := connection.BeginTx(context.Background(), driver.TxOptions{})
if err != nil {
t.Fatal(err)
}
statement, err := connection.Prepare("INSERT INTO settings (value) VALUES (?)")
if err != nil {
t.Fatal(err)
}
if _, err := statement.Exec([]driver.Value{value}); err != nil {
t.Fatal(err)
}
if err := statement.Close(); err != nil {
t.Fatal(err)
}
session, err := sqliteAttributionTracker.Load().ArmNextSQLiteHold()
if err != nil {
t.Fatal(err)
}
wrapped := transaction.(*sqliteAttributionTx)
identity, descriptor, active := sqliteAttributionTransactionState(t, connection)
if !active {
t.Fatal("selected transaction is not active")
}
return connection, wrapped, session, databasePath, identity, descriptor
}
func sqliteAttributionReleasedTerminalSnapshot(t *testing.T, session SQLiteHoldSession) SQLiteHoldSnapshot {
t.Helper()
terminal, err := sqliteAttributionTracker.Load().WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitFinalizing)
if err != nil {
t.Fatal(err)
}
return terminal
}
func TestSQLiteAttributionReleasedCommitOwnsTerminalAgainstConcurrentRollback(t *testing.T) {
// Given
connection, transaction, session, databasePath, identity, descriptor := sqliteAttributionReleasedCommitFixture(t, "released-rollback-owner")
t.Cleanup(func() {
if err := connection.Close(); err != nil && !errors.Is(err, driver.ErrBadConn) {
t.Error(err)
}
})
releasedBoundary := make(chan struct{})
allowCommitClaim := make(chan struct{})
loserWaiting := make(chan struct{})
transaction.state.releasedCommitBoundary = func() {
close(releasedBoundary)
<-allowCommitClaim
}
transaction.state.terminalLoserWaitBoundary = func() { close(loserWaiting) }
finalizing := make(chan error, 1)
commitResult := make(chan error, 1)
go func() {
_, err := sqliteAttributionTracker.Load().WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitFinalizing)
finalizing <- err
}()
go func() { commitResult <- transaction.Commit() }()
if err := <-finalizing; err != nil {
t.Fatal(err)
}
// When
if err := sqliteAttributionTracker.Load().ReleaseSQLiteHold(session); err != nil {
t.Fatal(err)
}
<-releasedBoundary
rollbackResult := make(chan error, 1)
go func() { rollbackResult <- transaction.Rollback() }()
loserReturned := false
var rollbackErr error
select {
case <-loserWaiting:
case rollbackErr = <-rollbackResult:
loserReturned = true
}
close(allowCommitClaim)
commitErr := <-commitResult
if !loserReturned {
rollbackErr = <-rollbackResult
}
terminal := sqliteAttributionReleasedTerminalSnapshot(t, session)
_, descriptorErr := unix.FcntlInt(uintptr(descriptor), unix.F_GETFD, 0)
count := sqliteAttributionPersistedCount(t, databasePath)
// Then
if loserReturned {
t.Errorf("released Commit lost terminal ownership: Rollback returned %v before waiting", rollbackErr)
}
if commitErr != nil {
t.Errorf("released Commit error = %v, want nil", commitErr)
}
if !errors.Is(rollbackErr, driver.ErrBadConn) {
t.Errorf("Rollback after successful Release error = %v, want driver.ErrBadConn", rollbackErr)
}
if !terminal.Selected || !terminal.Finalizing || !terminal.Released {
t.Errorf("released terminal snapshot = %+v", terminal)
}
if !errors.Is(descriptorErr, unix.EBADF) {
t.Errorf("journal descriptor after released Commit = %v, want EBADF", descriptorErr)
}
if count != 1 {
t.Errorf("released Commit persisted %d rows, want 1", count)
}
if _, _, active := sqliteAttributionTransactionState(t, connection); active {
t.Error("released Commit left the connection transaction active")
}
if sqliteAttributionTrackerTransactionActive(sqliteAttributionTracker.Load(), identity) {
t.Error("released Commit left the tracker transaction active")
}
}
func TestSQLiteAttributionReleasedCommitOwnsTerminalAgainstConcurrentConnectionClose(t *testing.T) {
// Given
connection, transaction, session, databasePath, identity, descriptor := sqliteAttributionReleasedCommitFixture(t, "released-close-owner")
connectionClosed := false
t.Cleanup(func() {
if !connectionClosed {
if err := connection.Close(); err != nil && !errors.Is(err, driver.ErrBadConn) {
t.Error(err)
}
}
})
releasedBoundary := make(chan struct{})
allowCommitClaim := make(chan struct{})
loserWaiting := make(chan struct{})
transaction.state.releasedCommitBoundary = func() {
close(releasedBoundary)
<-allowCommitClaim
}
transaction.state.terminalLoserWaitBoundary = func() { close(loserWaiting) }
finalizing := make(chan error, 1)
commitResult := make(chan error, 1)
go func() {
_, err := sqliteAttributionTracker.Load().WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitFinalizing)
finalizing <- err
}()
go func() { commitResult <- transaction.Commit() }()
if err := <-finalizing; err != nil {
t.Fatal(err)
}
// When
if err := sqliteAttributionTracker.Load().ReleaseSQLiteHold(session); err != nil {
t.Fatal(err)
}
<-releasedBoundary
closeResult := make(chan error, 1)
go func() { closeResult <- connection.Close() }()
loserReturned := false
var closeErr error
select {
case <-loserWaiting:
case closeErr = <-closeResult:
loserReturned = true
}
close(allowCommitClaim)
commitErr := <-commitResult
if !loserReturned {
closeErr = <-closeResult
}
connectionClosed = true
terminal := sqliteAttributionReleasedTerminalSnapshot(t, session)
_, descriptorErr := unix.FcntlInt(uintptr(descriptor), unix.F_GETFD, 0)
database, err := sql.Open("sqlite3", databasePath)
if err != nil {
t.Fatal(err)
}
defer database.Close()
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM settings").Scan(&count); err != nil {
t.Fatal(err)
}
// Then
if loserReturned {
t.Errorf("released Commit lost terminal ownership: connection Close returned %v before waiting", closeErr)
}
if commitErr != nil {
t.Errorf("released Commit error = %v, want nil", commitErr)
}
if closeErr != nil && !errors.Is(closeErr, driver.ErrBadConn) {
t.Errorf("connection Close after successful Release error = %v, want nil or driver.ErrBadConn", closeErr)
}
if !terminal.Selected || !terminal.Finalizing || !terminal.Released {
t.Errorf("released terminal snapshot = %+v", terminal)
}
if !errors.Is(descriptorErr, unix.EBADF) {
t.Errorf("journal descriptor after released Commit = %v, want EBADF", descriptorErr)
}
if count != 1 {
t.Errorf("released Commit persisted %d rows, want 1", count)
}
if _, _, active := sqliteAttributionTransactionState(t, connection); active {
t.Error("released Commit left the connection transaction active")
}
if sqliteAttributionTrackerTransactionActive(sqliteAttributionTracker.Load(), identity) {
t.Error("released Commit left the tracker transaction active")
}
}
@@ -0,0 +1,121 @@
//go:build agentcompat && linux
package singleton
import (
"context"
"testing"
"github.com/nezhahq/nezha/model"
"gorm.io/gorm"
)
func TestSQLiteAttributionRecordsDirectExplicitReturningEvidence(t *testing.T) {
// Given
database := openSQLiteAttributionTestDatabase(t)
enableSQLiteAttribution()
transaction, err := database.BeginTx(context.Background(), nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if rollbackErr := transaction.Rollback(); rollbackErr != nil {
t.Error(rollbackErr)
}
})
// When
rows, queryErr := transaction.QueryContext(context.Background(), "INSERT INTO settings (value) VALUES (?) RETURNING id", "direct-explicit-returning")
rowsErr := sqliteAttributionConsumeRows(rows)
evidence := sqliteAttributionTrackerWriteEvidence()
// Then
if queryErr != nil || rowsErr != nil {
t.Fatalf("direct explicit RETURNING failed")
}
if !evidence.hasWrite {
t.Fatal("direct explicit RETURNING did not record atomic write evidence")
}
if evidence.write.Origin.StackHash == 0 {
t.Fatal("direct explicit RETURNING recorded a zero stack hash")
}
if evidence.write.Origin.FirstNezhaFrame == "" {
t.Fatal("direct explicit RETURNING recorded an empty Nezha frame")
}
}
func TestSQLiteAttributionRecordsPreparedExplicitReturningEvidence(t *testing.T) {
// Given
database := openSQLiteAttributionTestDatabase(t)
enableSQLiteAttribution()
transaction, err := database.BeginTx(context.Background(), nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if rollbackErr := transaction.Rollback(); rollbackErr != nil {
t.Error(rollbackErr)
}
})
statement, err := transaction.PrepareContext(context.Background(), "INSERT INTO settings (value) VALUES (?) RETURNING id")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if closeErr := statement.Close(); closeErr != nil {
t.Error(closeErr)
}
})
// When
rows, queryErr := statement.QueryContext(context.Background(), "prepared-explicit-returning")
rowsErr := sqliteAttributionConsumeRows(rows)
evidence := sqliteAttributionTrackerWriteEvidence()
// Then
if queryErr != nil || rowsErr != nil {
t.Fatalf("prepared explicit RETURNING failed")
}
if !evidence.hasWrite {
t.Fatal("prepared explicit RETURNING did not record atomic write evidence")
}
if evidence.write.Origin.StackHash == 0 {
t.Fatal("prepared explicit RETURNING recorded a zero stack hash")
}
if evidence.write.Origin.FirstNezhaFrame == "" {
t.Fatal("prepared explicit RETURNING recorded an empty Nezha frame")
}
}
func TestSQLiteAttributionAllowsGORMReturningWhenDisabled(t *testing.T) {
// Given
resetSQLiteAttributionForTest()
database, err := gorm.Open(openSQLiteDialector(sqliteAttributionTestDatabasePath(t)), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDatabase, err := database.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if closeErr := sqlDatabase.Close(); closeErr != nil {
t.Error(closeErr)
}
})
if err := database.AutoMigrate(&model.User{}); err != nil {
t.Fatal(err)
}
user := model.User{Username: "admin", Password: "hashed-password"}
// When
createErr := database.Create(&user).Error
// Then
if createErr != nil {
t.Fatalf("disabled attribution rejected GORM RETURNING: %v", createErr)
}
if user.ID == 0 {
t.Fatal("disabled attribution did not return the generated user ID")
}
}
@@ -0,0 +1,231 @@
//go:build agentcompat && linux
package singleton
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"reflect"
"sync"
"testing"
)
func TestSQLiteAttributionLifecycleFieldsUseOneConnectionMutex(t *testing.T) {
// Given
mutexType := reflect.TypeFor[sync.Mutex]()
connectionType := reflect.TypeFor[sqliteAttributionConnection]()
transactionType := reflect.TypeFor[sqliteAttributionTransaction]()
mutexCount := 0
for _, typeUnderTest := range []reflect.Type{connectionType, transactionType} {
for fieldIndex := 0; fieldIndex < typeUnderTest.NumField(); fieldIndex++ {
if typeUnderTest.Field(fieldIndex).Type == mutexType {
mutexCount++
}
}
}
// When
field, found := connectionType.FieldByName("lifecycleMu")
// Then
if mutexCount != 1 {
t.Fatalf("direct lifecycle mutex count = %d, want 1 on sqliteAttributionConnection", mutexCount)
}
if !found || field.Type != mutexType {
t.Fatal("sqliteAttributionConnection.lifecycleMu is not the lifecycle mutex")
}
}
func TestSQLiteAttributionBeginRollsBackRawTransactionWhenTrackerRejects(t *testing.T) {
// Given
resetSQLiteAttributionForTest()
rawConnection, err := sqliteAttributionDriver{}.Open(sqliteAttributionTestDatabasePath(t))
if err != nil {
t.Fatal(err)
}
connection := rawConnection.(*sqliteAttributionConnection)
t.Cleanup(func() {
_, _ = connection.connection.Exec("ROLLBACK", nil)
if closeErr := connection.Close(); closeErr != nil {
t.Error(closeErr)
}
})
identity := SQLiteTransaction{Connection: connection.identity, Identity: SQLiteTransactionIdentity(sqliteAttributionTransactionID.Load() + 1)}
if err := sqliteAttributionTracker.Load().BeginSQLiteTransaction(identity); err != nil {
t.Fatal(err)
}
// When
_, beginErr := connection.Begin()
followup, followupErr := connection.Begin()
if followup != nil {
_ = followup.Rollback()
}
// Then
if !errors.Is(beginErr, ErrSQLiteHoldTransactionActive) {
t.Fatalf("tracker rejection = %v, want ErrSQLiteHoldTransactionActive", beginErr)
}
if followupErr != nil {
t.Fatalf("tracker rejection left raw transaction active: %v", followupErr)
}
}
func TestSQLiteAttributionDirectQueryPreservesSQLiteColumnTypes(t *testing.T) {
// Given
resetSQLiteAttributionForTest()
databasePath := sqliteAttributionTestDatabasePath(t)
attributed, err := openSQLiteAttributionTestDB(databasePath)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if closeErr := attributed.Close(); closeErr != nil {
t.Error(closeErr)
}
})
standard, err := sql.Open("sqlite3", databasePath)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if closeErr := standard.Close(); closeErr != nil {
t.Error(closeErr)
}
})
if _, err := attributed.Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)"); err != nil {
t.Fatal(err)
}
if _, err := attributed.Exec("INSERT INTO settings (value) VALUES (?)", "typed"); err != nil {
t.Fatal(err)
}
// When
attributedRows, err := attributed.QueryContext(context.Background(), "SELECT id, value FROM settings")
if err != nil {
t.Fatal(err)
}
attributedTypes, attributedErr := attributedRows.ColumnTypes()
attributedCloseErr := attributedRows.Close()
standardRows, err := standard.QueryContext(context.Background(), "SELECT id, value FROM settings")
if err != nil {
t.Fatal(err)
}
standardTypes, standardErr := standardRows.ColumnTypes()
standardCloseErr := standardRows.Close()
// Then
if attributedErr != nil || attributedCloseErr != nil || standardErr != nil || standardCloseErr != nil {
t.Fatal("ColumnTypes did not complete cleanly")
}
if len(attributedTypes) != len(standardTypes) {
t.Fatalf("attributed ColumnTypes length = %d, standard = %d", len(attributedTypes), len(standardTypes))
}
for index := range standardTypes {
if attributedTypes[index].DatabaseTypeName() != standardTypes[index].DatabaseTypeName() || attributedTypes[index].ScanType() != standardTypes[index].ScanType() || !reflect.DeepEqual(attributedTypes[index], standardTypes[index]) {
t.Fatalf("column %d metadata differs from go-sqlite3", index)
}
}
}
func TestSQLiteAttributionFailsClosedWhenSQLiteRepreparesAfterSchemaChange(t *testing.T) {
// Given
resetSQLiteAttributionForTest()
databasePath := sqliteAttributionTestDatabasePath(t)
rawConnection, err := sqliteAttributionDriver{}.Open(databasePath)
if err != nil {
t.Fatal(err)
}
connection := rawConnection.(*sqliteAttributionConnection)
t.Cleanup(func() {
if closeErr := connection.Close(); closeErr != nil {
t.Error(closeErr)
}
})
if _, err := connection.connection.Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)", nil); err != nil {
t.Fatal(err)
}
statement, err := connection.Prepare("INSERT INTO settings (value) VALUES (?)")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if closeErr := statement.Close(); closeErr != nil {
t.Error(closeErr)
}
})
other, err := sql.Open("sqlite3", databasePath)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if closeErr := other.Close(); closeErr != nil {
t.Error(closeErr)
}
})
if _, err := other.Exec("CREATE TABLE audit (value TEXT)"); err != nil {
t.Fatal(err)
}
if _, err := other.Exec("CREATE TRIGGER settings_audit AFTER INSERT ON settings BEGIN INSERT INTO audit (value) VALUES (NEW.value); END"); err != nil {
t.Fatal(err)
}
enableSQLiteAttribution()
transaction, err := connection.Begin()
if err != nil {
t.Fatal(err)
}
// When
_, executionErr := statement.Exec([]driver.Value{"reprepared"})
commitErr := transaction.Commit()
var settingsCount, auditCount int
if err := other.QueryRow("SELECT COUNT(*) FROM settings").Scan(&settingsCount); err != nil {
t.Fatal(err)
}
if err := other.QueryRow("SELECT COUNT(*) FROM audit").Scan(&auditCount); err != nil {
t.Fatal(err)
}
// Then
if !errors.Is(errors.Join(executionErr, commitErr), ErrSQLiteAttributionUnsupportedWrite) {
t.Fatalf("reprepared write errors = %v / %v", executionErr, commitErr)
}
if settingsCount != 0 || auditCount != 0 {
t.Fatalf("reprepared write persisted settings=%d audit=%d", settingsCount, auditCount)
}
}
func TestSQLiteAttributionUpdateHookRejectsAuxiliaryDatabase(t *testing.T) {
// Given
resetSQLiteAttributionForTest()
rawConnection, err := sqliteAttributionDriver{}.Open(sqliteAttributionTestDatabasePath(t))
if err != nil {
t.Fatal(err)
}
connection := rawConnection.(*sqliteAttributionConnection)
t.Cleanup(func() {
if closeErr := connection.Close(); closeErr != nil {
t.Error(closeErr)
}
})
connection.execution = &sqliteAttributionExecution{classification: sqliteAttributionClassification{operation: SQLiteOperationInsert, table: "settings"}}
if _, err := connection.connection.Exec("ATTACH DATABASE ':memory:' AS auxiliary", nil); err != nil {
t.Fatal(err)
}
if _, err := connection.connection.Exec("CREATE TABLE auxiliary.settings (value TEXT)", nil); err != nil {
t.Fatal(err)
}
// When
_, writeErr := connection.connection.Exec("INSERT INTO auxiliary.settings (value) VALUES ('auxiliary')", nil)
// Then
if writeErr != nil {
t.Fatal(writeErr)
}
if !connection.execution.hook.mismatch || connection.execution.hook.seen {
t.Fatal("auxiliary database update hook matched main attribution execution")
}
}
@@ -0,0 +1,186 @@
//go:build agentcompat && linux
package singleton
import (
"context"
"database/sql/driver"
"errors"
"io"
"reflect"
)
type sqliteAttributionClassification struct {
readonly bool
hasRowDML bool
ambiguous bool
operation SQLiteOperation
table string
}
func (classification sqliteAttributionClassification) requiresAttribution() bool {
return !classification.readonly && classification.hasRowDML
}
func (classification sqliteAttributionClassification) valid() bool {
return !classification.ambiguous && classification.hasRowDML && classification.operation != "" && classification.table != ""
}
type sqliteAttributionStatement struct {
connection *sqliteAttributionConnection
statement driver.Stmt
classification sqliteAttributionClassification
}
var (
_ driver.Stmt = (*sqliteAttributionStatement)(nil)
_ driver.StmtExecContext = (*sqliteAttributionStatement)(nil)
_ driver.StmtQueryContext = (*sqliteAttributionStatement)(nil)
)
func (statement *sqliteAttributionStatement) Close() error { return statement.statement.Close() }
func (statement *sqliteAttributionStatement) NumInput() int { return statement.statement.NumInput() }
func (statement *sqliteAttributionStatement) Exec(values []driver.Value) (driver.Result, error) {
return statement.execute(func() (driver.Result, error) { return statement.statement.Exec(values) })
}
func (statement *sqliteAttributionStatement) ExecContext(ctx context.Context, values []driver.NamedValue) (driver.Result, error) {
contextStatement, ok := statement.statement.(driver.StmtExecContext)
if !ok {
return nil, driver.ErrSkip
}
return statement.execute(func() (driver.Result, error) { return contextStatement.ExecContext(ctx, values) })
}
func (statement *sqliteAttributionStatement) Query(values []driver.Value) (driver.Rows, error) {
return statement.queryOwned(func() (driver.Rows, error) { return statement.statement.Query(values) }, nil)
}
func (statement *sqliteAttributionStatement) QueryContext(ctx context.Context, values []driver.NamedValue) (driver.Rows, error) {
contextStatement, ok := statement.statement.(driver.StmtQueryContext)
if !ok {
return nil, driver.ErrSkip
}
return statement.queryOwned(func() (driver.Rows, error) { return contextStatement.QueryContext(ctx, values) }, nil)
}
func (statement *sqliteAttributionStatement) execute(run func() (driver.Result, error)) (driver.Result, error) {
if err := statement.connection.beforeWrite(statement.classification); err != nil {
return nil, err
}
result, err := run()
if err != nil {
statement.connection.discardExecution()
return nil, err
}
if rows, rowsErr := result.RowsAffected(); rowsErr != nil || rows > 0 {
if err := statement.connection.publishExecution(); err != nil {
return nil, err
}
}
statement.connection.discardExecution()
return result, nil
}
func (statement *sqliteAttributionStatement) queryOwned(run func() (driver.Rows, error), owner driver.Stmt) (driver.Rows, error) {
if err := statement.connection.beforeWrite(statement.classification); err != nil {
if owner != nil {
return nil, errors.Join(err, owner.Close())
}
return nil, err
}
rows, err := run()
if err != nil {
statement.connection.discardExecution()
if owner != nil {
return nil, errors.Join(err, owner.Close())
}
return nil, err
}
// Disabled attribution must preserve the stock driver rows lifecycle used during Dashboard bootstrap.
if !sqliteAttributionEnabled.Load() || !statement.classification.requiresAttribution() {
if owner == nil {
return rows, nil
}
return &sqliteAttributionRows{rows: rows, owner: owner}, nil
}
return &sqliteAttributionRows{rows: rows, owner: owner, connection: statement.connection}, nil
}
type sqliteAttributionRows struct {
rows driver.Rows
owner driver.Stmt
connection *sqliteAttributionConnection
finished bool
closed bool
}
func (rows *sqliteAttributionRows) Columns() []string { return rows.rows.Columns() }
func (rows *sqliteAttributionRows) ColumnTypeDatabaseTypeName(index int) string {
typed, ok := rows.rows.(driver.RowsColumnTypeDatabaseTypeName)
if !ok {
return ""
}
return typed.ColumnTypeDatabaseTypeName(index)
}
func (rows *sqliteAttributionRows) ColumnTypeNullable(index int) (bool, bool) {
typed, ok := rows.rows.(driver.RowsColumnTypeNullable)
if !ok {
return false, false
}
return typed.ColumnTypeNullable(index)
}
func (rows *sqliteAttributionRows) ColumnTypeScanType(index int) reflect.Type {
typed, ok := rows.rows.(driver.RowsColumnTypeScanType)
if !ok {
return nil
}
return typed.ColumnTypeScanType(index)
}
func (rows *sqliteAttributionRows) Close() error {
if rows.closed {
return nil
}
rows.closed = true
closeErr := rows.rows.Close()
if !rows.finished && rows.connection != nil {
rows.connection.poison(&SQLiteAttributionError{Cause: ErrSQLiteAttributionUnsupportedWrite})
rows.connection.discardExecution()
}
if rows.owner != nil {
return errors.Join(closeErr, rows.owner.Close())
}
return closeErr
}
func (rows *sqliteAttributionRows) Next(destination []driver.Value) error {
err := rows.rows.Next(destination)
if errors.Is(err, driver.ErrBadConn) {
// Read-only direct Query rows have no attribution connection; ErrBadConn must still propagate.
if rows.connection != nil {
rows.connection.discardExecution()
}
return err
}
if err == nil {
return nil
}
rows.finished = true
if err != io.EOF {
if rows.connection != nil {
rows.connection.discardExecution()
}
return err
}
if rows.connection != nil {
if publishErr := rows.connection.publishExecution(); publishErr != nil {
return publishErr
}
}
return io.EOF
}
@@ -0,0 +1,126 @@
//go:build agentcompat && linux
package singleton
import (
"database/sql/driver"
"errors"
)
type SQLiteHoldError struct{ Cause error }
func (err *SQLiteHoldError) Error() string { return "sqlite hold finalization failed" }
func (err *SQLiteHoldError) Unwrap() error { return err.Cause }
type sqliteAttributionTx struct {
connection *sqliteAttributionConnection
state *sqliteAttributionTransaction
}
var _ driver.Tx = (*sqliteAttributionTx)(nil)
func (transaction *sqliteAttributionTx) Commit() error {
state := transaction.state
transaction.connection.lifecycleMu.Lock()
if state.terminalPhase != sqliteAttributionTerminalOpen {
transaction.connection.lifecycleMu.Unlock()
return state.waitForTerminal()
}
poison := state.poison
if poison != nil {
journalFD := state.claimTerminalLocked(sqliteAttributionTerminalRollbackOwned)
transaction.connection.lifecycleMu.Unlock()
if errors.Is(poison, ErrSQLiteHoldAmbiguousCandidate) {
return errors.Join(&SQLiteHoldError{Cause: poison}, state.finish(transaction.connection, journalFD, state.raw.Rollback))
}
return errors.Join(poison, state.finish(transaction.connection, journalFD, state.raw.Rollback))
}
state.terminalPhase = sqliteAttributionTerminalCommitWaiting
transaction.connection.lifecycleMu.Unlock()
finalization, err := state.tracker.BeginSQLiteCommitFinalization(state.transaction)
if errors.Is(err, ErrSQLiteHoldNotSelected) {
return state.commit(transaction.connection)
}
if err != nil {
return errors.Join(&SQLiteHoldError{Cause: err}, state.rollback(transaction.connection))
}
// Wait before raw Commit so the rollback journal stays observable for deterministic drain and prevents the baseline sample-1 FD regression.
if err := state.tracker.WaitSQLiteCommitFinalization(state.context, finalization); err != nil {
return errors.Join(&SQLiteHoldError{Cause: errors.Join(ErrSQLiteHoldAborted, err)}, state.rollback(transaction.connection))
}
if state.releasedCommitBoundary != nil {
state.releasedCommitBoundary()
}
return state.commit(transaction.connection)
}
func (transaction *sqliteAttributionTx) Rollback() error {
return transaction.state.rollback(transaction.connection)
}
func (state *sqliteAttributionTransaction) commit(connection *sqliteAttributionConnection) error {
connection.lifecycleMu.Lock()
if state.terminalPhase != sqliteAttributionTerminalCommitWaiting {
connection.lifecycleMu.Unlock()
return state.waitForTerminal()
}
journalFD := state.claimTerminalLocked(sqliteAttributionTerminalCommitOwned)
connection.lifecycleMu.Unlock()
return state.finish(connection, journalFD, state.raw.Commit)
}
func (state *sqliteAttributionTransaction) rollback(connection *sqliteAttributionConnection) error {
connection.lifecycleMu.Lock()
switch state.terminalPhase {
case sqliteAttributionTerminalOpen:
journalFD := state.claimTerminalLocked(sqliteAttributionTerminalRollbackOwned)
connection.lifecycleMu.Unlock()
return state.finish(connection, journalFD, state.raw.Rollback)
case sqliteAttributionTerminalCommitWaiting:
connection.lifecycleMu.Unlock()
arbitration := state.tracker.ArbitrateSQLiteCommitWaitingTerminal(state.transaction)
if arbitration == sqliteAttributionTerminalReleaseWon || arbitration == sqliteAttributionTerminalRollbackReserved {
return state.waitForTerminal()
}
connection.lifecycleMu.Lock()
if state.terminalPhase != sqliteAttributionTerminalCommitWaiting {
connection.lifecycleMu.Unlock()
return state.waitForTerminal()
}
journalFD := state.claimTerminalLocked(sqliteAttributionTerminalRollbackOwned)
connection.lifecycleMu.Unlock()
return state.finish(connection, journalFD, state.raw.Rollback)
default:
connection.lifecycleMu.Unlock()
return state.waitForTerminal()
}
}
func (state *sqliteAttributionTransaction) claimTerminalLocked(phase sqliteAttributionTerminalPhase) int {
state.terminalPhase = phase
journalFD := state.journalFD
state.journalFD = -1
return journalFD
}
func (state *sqliteAttributionTransaction) waitForTerminal() error {
if state.terminalLoserWaitBoundary != nil {
state.terminalLoserWaitBoundary()
}
<-state.done
return driver.ErrBadConn
}
func (state *sqliteAttributionTransaction) finish(connection *sqliteAttributionConnection, journalFD int, completeRaw func() error) error {
defer close(state.done)
rawErr := completeRaw()
connection.discardExecution()
connection.lifecycleMu.Lock()
if connection.transaction == state {
connection.transaction = nil
}
connection.lifecycleMu.Unlock()
closeErr := sqliteAttributionCloseJournalDescriptor(journalFD)
trackerErr := state.tracker.FinishSQLiteTransaction(state.transaction)
return errors.Join(rawErr, closeErr, trackerErr)
}
@@ -0,0 +1,236 @@
//go:build agentcompat && linux
package singleton
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"testing"
"golang.org/x/sys/unix"
)
func sqliteAttributionHeldTransaction(t *testing.T, value string, transactionContext context.Context) (*sqliteAttributionConnection, driver.Tx, SQLiteHoldSession, string) {
t.Helper()
resetSQLiteAttributionForTest()
databasePath := sqliteAttributionTestDatabasePath(t)
rawConnection, err := sqliteAttributionDriver{}.Open(databasePath)
if err != nil {
t.Fatal(err)
}
connection := rawConnection.(*sqliteAttributionConnection)
t.Cleanup(func() {
if err := connection.Close(); err != nil {
t.Error(err)
}
})
if _, err := connection.connection.Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)", nil); err != nil {
t.Fatal(err)
}
enableSQLiteAttribution()
transaction, err := connection.BeginTx(transactionContext, driver.TxOptions{})
if err != nil {
t.Fatal(err)
}
statement, err := connection.Prepare("INSERT INTO settings (value) VALUES (?)")
if err != nil {
t.Fatal(err)
}
if _, err := statement.Exec([]driver.Value{value}); err != nil {
t.Fatal(err)
}
if err := statement.Close(); err != nil {
t.Fatal(err)
}
session, err := sqliteAttributionTracker.Load().ArmNextSQLiteHold()
if err != nil {
t.Fatal(err)
}
return connection, transaction, session, databasePath
}
func sqliteAttributionStartHeldCommit(t *testing.T, transaction driver.Tx, session SQLiteHoldSession) <-chan error {
t.Helper()
finalizing := make(chan error, 1)
commit := make(chan error, 1)
go func() {
_, err := sqliteAttributionTracker.Load().WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitFinalizing)
finalizing <- err
}()
go func() { commit <- transaction.Commit() }()
select {
case err := <-finalizing:
if err != nil {
t.Fatalf("Commit finalization wait error = %v", err)
}
case err := <-commit:
t.Fatalf("Commit completed before selected hold release: %v", err)
}
return commit
}
func sqliteAttributionPersistedCount(t *testing.T, databasePath string) int {
t.Helper()
database, err := sql.Open("sqlite3", databasePath)
if err != nil {
t.Fatal(err)
}
defer database.Close()
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM settings").Scan(&count); err != nil {
t.Fatal(err)
}
return count
}
func sqliteAttributionTransactionState(t *testing.T, connection *sqliteAttributionConnection) (SQLiteTransaction, int, bool) {
t.Helper()
connection.lifecycleMu.Lock()
state := connection.transaction
if state == nil {
connection.lifecycleMu.Unlock()
return SQLiteTransaction{}, -1, false
}
identity, descriptor := state.transaction, state.journalFD
connection.lifecycleMu.Unlock()
return identity, descriptor, true
}
func sqliteAttributionTrackerTransactionActive(tracker *SQLiteHoldTracker, identity SQLiteTransaction) bool {
tracker.mu.Lock()
defer tracker.mu.Unlock()
_, active := tracker.transactions[identity]
return active
}
func TestSQLiteAttributionCommitWaitsForSelectedHoldRelease(t *testing.T) {
// Given
connection, transaction, session, databasePath := sqliteAttributionHeldTransaction(t, "held-commit", context.Background())
identity, descriptor, active := sqliteAttributionTransactionState(t, connection)
if !active {
t.Fatal("held transaction is not active")
}
// When
commit := sqliteAttributionStartHeldCommit(t, transaction, session)
select {
case err := <-commit:
t.Fatalf("Commit completed while selected hold remains unreleased: %v", err)
default:
}
if err := sqliteAttributionTracker.Load().ReleaseSQLiteHold(session); err != nil {
t.Fatal(err)
}
commitErr := <-commit
// Then
if commitErr != nil {
t.Fatal(commitErr)
}
if _, descriptorErr := unix.FcntlInt(uintptr(descriptor), unix.F_GETFD, 0); !errors.Is(descriptorErr, unix.EBADF) {
t.Fatalf("journal descriptor after released Commit = %v, want EBADF", descriptorErr)
}
if _, _, active := sqliteAttributionTransactionState(t, connection); active {
t.Fatal("released Commit left the connection transaction active")
}
if sqliteAttributionTrackerTransactionActive(sqliteAttributionTracker.Load(), identity) {
t.Fatal("released Commit left the tracker transaction active")
}
if count := sqliteAttributionPersistedCount(t, databasePath); count != 1 {
t.Fatalf("released held Commit persisted %d rows", count)
}
}
func TestSQLiteAttributionCommitRollsBackWhenSelectedHoldAborts(t *testing.T) {
// Given
connection, transaction, session, databasePath := sqliteAttributionHeldTransaction(t, "aborted-commit", context.Background())
identity, descriptor, active := sqliteAttributionTransactionState(t, connection)
if !active {
t.Fatal("held transaction is not active")
}
// When
commit := sqliteAttributionStartHeldCommit(t, transaction, session)
if err := sqliteAttributionTracker.Load().AbortSQLiteHold(session); err != nil {
t.Fatal(err)
}
commitErr := <-commit
_, descriptorErr := unix.FcntlInt(uintptr(descriptor), unix.F_GETFD, 0)
// Then
var holdErr *SQLiteHoldError
if !errors.As(commitErr, &holdErr) || !errors.Is(commitErr, ErrSQLiteHoldAborted) {
t.Fatalf("aborted held Commit error = %v", commitErr)
}
if !errors.Is(descriptorErr, unix.EBADF) {
t.Fatalf("journal descriptor after aborted Commit = %v, want EBADF", descriptorErr)
}
if _, _, active := sqliteAttributionTransactionState(t, connection); active {
t.Fatal("aborted Commit left the connection transaction active")
}
if sqliteAttributionTrackerTransactionActive(sqliteAttributionTracker.Load(), identity) {
t.Fatal("aborted Commit left the tracker transaction active")
}
if count := sqliteAttributionPersistedCount(t, databasePath); count != 0 {
t.Fatalf("aborted held Commit persisted %d rows", count)
}
}
func TestSQLiteAttributionCommitRollsBackWhenBeginTxContextCancels(t *testing.T) {
// Given
transactionContext, cancel := context.WithCancel(context.Background())
defer cancel()
connection, transaction, session, databasePath := sqliteAttributionHeldTransaction(t, "cancelled-commit", transactionContext)
identity, descriptor, active := sqliteAttributionTransactionState(t, connection)
if !active {
t.Fatal("cancelled transaction is not active")
}
// When
commit := sqliteAttributionStartHeldCommit(t, transaction, session)
cancel()
commitErr := <-commit
_, descriptorErr := unix.FcntlInt(uintptr(descriptor), unix.F_GETFD, 0)
// Then
var holdErr *SQLiteHoldError
if !errors.As(commitErr, &holdErr) || !errors.Is(commitErr, ErrSQLiteHoldAborted) || !errors.Is(commitErr, context.Canceled) {
t.Fatalf("cancelled held Commit error = %v", commitErr)
}
if count := sqliteAttributionPersistedCount(t, databasePath); count != 0 {
t.Fatalf("cancelled held Commit persisted %d rows", count)
}
if !errors.Is(descriptorErr, unix.EBADF) {
t.Fatalf("journal descriptor after cancelled Commit = %v, want EBADF", descriptorErr)
}
if _, _, active := sqliteAttributionTransactionState(t, connection); active {
t.Fatal("cancelled Commit left the connection transaction active")
}
if sqliteAttributionTrackerTransactionActive(sqliteAttributionTracker.Load(), identity) {
t.Fatal("cancelled Commit left the tracker transaction active")
}
}
func TestSQLiteAttributionRollbackWakesSelectedCommit(t *testing.T) {
// Given
_, transaction, session, databasePath := sqliteAttributionHeldTransaction(t, "rollback-wakes-commit", context.Background())
// When
commit := sqliteAttributionStartHeldCommit(t, transaction, session)
rollbackErr := transaction.Rollback()
commitErr := <-commit
// Then
if rollbackErr != nil {
t.Fatal(rollbackErr)
}
var holdErr *SQLiteHoldError
if !errors.As(commitErr, &holdErr) || !errors.Is(commitErr, ErrSQLiteHoldAborted) {
t.Fatalf("Commit error after Rollback = %v", commitErr)
}
if count := sqliteAttributionPersistedCount(t, databasePath); count != 0 {
t.Fatalf("Rollback while Commit waited persisted %d rows", count)
}
}
@@ -0,0 +1,158 @@
//go:build agentcompat && linux
package singleton
import (
"context"
"database/sql/driver"
"errors"
"testing"
"golang.org/x/sys/unix"
)
func sqliteAttributionTransactionWithWrite(t *testing.T, databasePath, value string) (*sqliteAttributionConnection, driver.Tx) {
t.Helper()
rawConnection, err := sqliteAttributionDriver{}.Open(databasePath)
if err != nil {
t.Fatal(err)
}
connection := rawConnection.(*sqliteAttributionConnection)
t.Cleanup(func() {
if err := connection.Close(); err != nil {
t.Error(err)
}
})
if _, err := connection.connection.Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)", nil); err != nil {
t.Fatal(err)
}
transaction, err := connection.BeginTx(context.Background(), driver.TxOptions{})
if err != nil {
t.Fatal(err)
}
statement, err := connection.Prepare("INSERT INTO settings (value) VALUES (?)")
if err != nil {
t.Fatal(err)
}
if _, err := statement.Exec([]driver.Value{value}); err != nil {
t.Fatal(err)
}
if err := statement.Close(); err != nil {
t.Fatal(err)
}
return connection, transaction
}
func sqliteAttributionTransactionWithAmbiguousWrite(t *testing.T, databasePath, value string) (*sqliteAttributionConnection, driver.Tx, error) {
t.Helper()
rawConnection, err := sqliteAttributionDriver{}.Open(databasePath)
if err != nil {
t.Fatal(err)
}
connection := rawConnection.(*sqliteAttributionConnection)
t.Cleanup(func() {
if err := connection.Close(); err != nil {
t.Error(err)
}
})
if _, err := connection.connection.Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)", nil); err != nil {
t.Fatal(err)
}
transaction, err := connection.BeginTx(context.Background(), driver.TxOptions{})
if err != nil {
t.Fatal(err)
}
statement, err := connection.Prepare("INSERT INTO settings (value) VALUES (?)")
if err != nil {
t.Fatal(err)
}
_, writeErr := statement.Exec([]driver.Value{value})
if err := statement.Close(); err != nil {
t.Fatal(err)
}
return connection, transaction, writeErr
}
func TestSQLiteAttributionUnselectedCommitPersistsImmediately(t *testing.T) {
// Given
resetSQLiteAttributionForTest()
enableSQLiteAttribution()
databasePath := sqliteAttributionTestDatabasePath(t)
session, err := sqliteAttributionTracker.Load().ArmSQLiteHold(SQLiteJournalIdentity{})
if err != nil {
t.Fatal(err)
}
connection, transaction := sqliteAttributionTransactionWithWrite(t, databasePath, "unselected-commit")
identity, descriptor, active := sqliteAttributionTransactionState(t, connection)
if !active {
t.Fatal("unselected transaction is not active")
}
// When
commitErr := transaction.Commit()
abortErr := sqliteAttributionTracker.Load().AbortSQLiteHold(session)
_, descriptorErr := unix.FcntlInt(uintptr(descriptor), unix.F_GETFD, 0)
// Then
if commitErr != nil {
t.Fatal(commitErr)
}
if abortErr != nil {
t.Fatal(abortErr)
}
if count := sqliteAttributionPersistedCount(t, databasePath); count != 1 {
t.Fatalf("unselected Commit persisted %d rows", count)
}
if !errors.Is(descriptorErr, unix.EBADF) {
t.Fatalf("journal descriptor after unselected Commit = %v, want EBADF", descriptorErr)
}
if _, _, active := sqliteAttributionTransactionState(t, connection); active {
t.Fatal("unselected Commit left the connection transaction active")
}
if sqliteAttributionTrackerTransactionActive(sqliteAttributionTracker.Load(), identity) {
t.Fatal("unselected Commit left the tracker transaction active")
}
}
func TestSQLiteAttributionFutureAmbiguityRollsBackEveryParticipatingCommit(t *testing.T) {
// Given
resetSQLiteAttributionForTest()
enableSQLiteAttribution()
firstPath := sqliteAttributionTestDatabasePath(t)
secondPath := sqliteAttributionTestDatabasePath(t)
tracker := sqliteAttributionTracker.Load()
if _, err := tracker.ArmNextSQLiteHold(); err != nil {
t.Fatal(err)
}
firstConnection, firstTransaction := sqliteAttributionTransactionWithWrite(t, firstPath, "first-ambiguous")
secondConnection, secondTransaction, secondWriteErr := sqliteAttributionTransactionWithAmbiguousWrite(t, secondPath, "second-ambiguous")
if !errors.Is(secondWriteErr, ErrSQLiteHoldAmbiguousCandidate) {
t.Fatalf("second ambiguity write error = %v", secondWriteErr)
}
firstIdentity, _, firstActive := sqliteAttributionTransactionState(t, firstConnection)
secondIdentity, _, secondActive := sqliteAttributionTransactionState(t, secondConnection)
if !firstActive || !secondActive {
t.Fatal("ambiguity participants are not active")
}
// When
firstCommitErr := firstTransaction.Commit()
secondCommitErr := secondTransaction.Commit()
// Then
for _, commitErr := range []error{firstCommitErr, secondCommitErr} {
var holdErr *SQLiteHoldError
if !errors.As(commitErr, &holdErr) || !errors.Is(commitErr, ErrSQLiteHoldAmbiguousCandidate) {
t.Fatalf("ambiguous Commit error = %v", commitErr)
}
}
if count := sqliteAttributionPersistedCount(t, firstPath); count != 0 {
t.Fatalf("first ambiguous Commit persisted %d rows", count)
}
if count := sqliteAttributionPersistedCount(t, secondPath); count != 0 {
t.Fatalf("second ambiguous Commit persisted %d rows", count)
}
if sqliteAttributionTrackerTransactionActive(tracker, firstIdentity) || sqliteAttributionTrackerTransactionActive(tracker, secondIdentity) {
t.Fatal("ambiguous Commit left a tracker transaction active")
}
}
@@ -0,0 +1,217 @@
//go:build agentcompat && linux
package singleton
import (
"context"
"database/sql/driver"
"errors"
"testing"
)
func TestSQLiteAttributionRejectsDirectUnboundExecBeforeInsert(t *testing.T) {
// Given
database := openSQLiteAttributionTestDatabase(t)
enableSQLiteAttribution()
// When
_, err := database.Exec("INSERT INTO settings (value) VALUES (?)", "direct-unbound")
count := sqliteAttributionSettingsCount(t, database)
// Then
// An UpdateHook error after SQLite executes is too late for autocommit: the write may already be committed.
if !errors.Is(err, ErrSQLiteAttributionUnboundWrite) {
t.Error("direct unbound Exec error does not wrap ErrSQLiteAttributionUnboundWrite")
}
if count != 0 {
t.Errorf("direct unbound Exec persisted %d rows", count)
}
}
func TestSQLiteAttributionRejectsLegacyDriverQueryBeforeInsert(t *testing.T) {
// Given
resetSQLiteAttributionForTest()
connection, err := sqliteAttributionDriver{}.Open(sqliteAttributionTestDatabasePath(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if closeErr := connection.Close(); closeErr != nil {
t.Error(closeErr)
}
})
if _, err := connection.(driver.Execer).Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)", nil); err != nil {
t.Fatal(err)
}
enableSQLiteAttribution()
// When
rows, queryErr := connection.(driver.Queryer).Query("INSERT INTO settings (value) VALUES (?) RETURNING id", []driver.Value{"legacy-direct"})
rowsErr := sqliteAttributionConsumeDriverRows(rows)
// Then
if !errors.Is(errors.Join(queryErr, rowsErr), ErrSQLiteAttributionUnboundWrite) {
t.Error("legacy driver Query does not reject unbound row DML")
}
}
func TestSQLiteAttributionRejectsPreparedLegacyDriverQueryBeforeInsert(t *testing.T) {
// Given
resetSQLiteAttributionForTest()
connection, err := sqliteAttributionDriver{}.Open(sqliteAttributionTestDatabasePath(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if closeErr := connection.Close(); closeErr != nil {
t.Error(closeErr)
}
})
if _, err := connection.(driver.Execer).Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)", nil); err != nil {
t.Fatal(err)
}
enableSQLiteAttribution()
statement, err := connection.Prepare("INSERT INTO settings (value) VALUES (?) RETURNING id")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if closeErr := statement.Close(); closeErr != nil {
t.Error(closeErr)
}
})
// When
rows, queryErr := statement.Query([]driver.Value{"legacy-prepared"})
rowsErr := sqliteAttributionConsumeDriverRows(rows)
// Then
if !errors.Is(errors.Join(queryErr, rowsErr), ErrSQLiteAttributionUnboundWrite) {
t.Error("prepared legacy driver Query does not reject unbound row DML")
}
}
func TestSQLiteAttributionRejectsPreparedUnboundExecBeforeInsert(t *testing.T) {
// Given
database := openSQLiteAttributionTestDatabase(t)
enableSQLiteAttribution()
statement, err := database.Prepare("INSERT INTO settings (value) VALUES (?)")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if closeErr := statement.Close(); closeErr != nil {
t.Error(closeErr)
}
})
// When
_, err = statement.Exec("prepared-unbound")
count := sqliteAttributionSettingsCount(t, database)
// Then
if !errors.Is(err, ErrSQLiteAttributionUnboundWrite) {
t.Error("prepared unbound Exec error does not wrap ErrSQLiteAttributionUnboundWrite")
}
if count != 0 {
t.Errorf("prepared unbound Exec persisted %d rows", count)
}
}
func TestSQLiteAttributionRejectsDirectUnboundReturningBeforeInsert(t *testing.T) {
// Given
database := openSQLiteAttributionTestDatabase(t)
enableSQLiteAttribution()
// When
rows, queryErr := database.QueryContext(context.Background(), "INSERT INTO settings (value) VALUES (?) RETURNING id", "direct-returning")
rowsErr := sqliteAttributionConsumeRows(rows)
count := sqliteAttributionSettingsCount(t, database)
// Then
if !errors.Is(errors.Join(queryErr, rowsErr), ErrSQLiteAttributionUnboundWrite) {
t.Error("direct unbound RETURNING does not propagate ErrSQLiteAttributionUnboundWrite through rows")
}
if count != 0 {
t.Errorf("direct unbound RETURNING persisted %d rows", count)
}
}
func TestSQLiteAttributionRejectsPreparedUnboundReturningBeforeInsert(t *testing.T) {
// Given
database := openSQLiteAttributionTestDatabase(t)
enableSQLiteAttribution()
statement, err := database.PrepareContext(context.Background(), "INSERT INTO settings (value) VALUES (?) RETURNING id")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if closeErr := statement.Close(); closeErr != nil {
t.Error(closeErr)
}
})
// When
rows, queryErr := statement.QueryContext(context.Background(), "prepared-returning")
rowsErr := sqliteAttributionConsumeRows(rows)
count := sqliteAttributionSettingsCount(t, database)
// Then
if !errors.Is(errors.Join(queryErr, rowsErr), ErrSQLiteAttributionUnboundWrite) {
t.Error("prepared unbound RETURNING does not propagate ErrSQLiteAttributionUnboundWrite through rows")
}
if count != 0 {
t.Errorf("prepared unbound RETURNING persisted %d rows", count)
}
}
func TestSQLiteAttributionRejectsDirectUnboundUpdateBeforeSideEffect(t *testing.T) {
// Given
database := openSQLiteAttributionTestDatabase(t)
if _, err := database.Exec("INSERT INTO settings (value) VALUES (?)", "original"); err != nil {
t.Fatal(err)
}
enableSQLiteAttribution()
// When
_, err := database.Exec("UPDATE settings SET value = ?", "changed")
value := sqliteAttributionSettingValue(t, database)
// Then
if !errors.Is(err, ErrSQLiteAttributionUnboundWrite) {
t.Error("direct unbound UPDATE error does not wrap ErrSQLiteAttributionUnboundWrite")
}
if value != "original" {
t.Error("direct unbound UPDATE changed the persisted value")
}
}
func TestSQLiteAttributionRejectsPreparedUnboundDeleteBeforeSideEffect(t *testing.T) {
// Given
database := openSQLiteAttributionTestDatabase(t)
if _, err := database.Exec("INSERT INTO settings (value) VALUES (?)", "preserved"); err != nil {
t.Fatal(err)
}
enableSQLiteAttribution()
statement, err := database.Prepare("DELETE FROM settings")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if closeErr := statement.Close(); closeErr != nil {
t.Error(closeErr)
}
})
// When
_, err = statement.Exec()
count := sqliteAttributionSettingsCount(t, database)
// Then
if !errors.Is(err, ErrSQLiteAttributionUnboundWrite) {
t.Error("prepared unbound DELETE error does not wrap ErrSQLiteAttributionUnboundWrite")
}
if count != 1 {
t.Errorf("prepared unbound DELETE left %d rows", count)
}
}
@@ -0,0 +1,13 @@
//go:build agentcompat && linux
package singleton
import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func openSQLiteDialector(path string) gorm.Dialector {
registerSQLiteAttributionDriver()
return sqlite.New(sqlite.Config{DriverName: sqliteAttributionDriverName, DSN: path})
}
@@ -0,0 +1,12 @@
//go:build !agentcompat || !linux
package singleton
import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func openSQLiteDialector(path string) gorm.Dialector {
return sqlite.Open(path)
}
@@ -0,0 +1,250 @@
//go:build agentcompat && linux
package singleton
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"strings"
"sync"
"sync/atomic"
"github.com/mattn/go-sqlite3"
)
const sqliteAttributionDriverName = "nezha_sqlite_attribution"
var (
ErrSQLiteAttributionUnsupportedDSN = errors.New("sqlite attribution requires a filesystem database")
ErrSQLiteAttributionUnboundWrite = errors.New("sqlite attribution observed a write without an explicit transaction")
ErrSQLiteAttributionJournalIdentity = errors.New("sqlite attribution could not identify the rollback journal")
ErrSQLiteAttributionUnsupportedWrite = errors.New("sqlite attribution cannot classify this write")
sqliteAttributionDriverRegistration sync.Once
sqliteAttributionEnabled atomic.Bool
sqliteAttributionConnectionID atomic.Uint64
sqliteAttributionTransactionID atomic.Uint64
sqliteAttributionTracker atomic.Pointer[SQLiteHoldTracker]
sqliteAttributionHoldControl *sqliteHoldControl
)
type SQLiteAttributionError struct{ Cause error }
func (err *SQLiteAttributionError) Error() string { return err.Cause.Error() }
func (err *SQLiteAttributionError) Unwrap() error { return err.Cause }
func init() { resetSQLiteAttributionControl() }
func registerSQLiteAttributionDriver() {
sqliteAttributionDriverRegistration.Do(func() { sql.Register(sqliteAttributionDriverName, sqliteAttributionDriver{}) })
}
func enableSQLiteAttribution() { sqliteAttributionEnabled.Store(true) }
func resetSQLiteAttributionForTest() {
sqliteAttributionEnabled.Store(false)
resetSQLiteAttributionControl()
}
func resetSQLiteAttributionControl() {
tracker := newSQLiteAttributionHoldTracker(&sqliteAttributionEnabled)
sqliteAttributionTracker.Store(tracker)
sqliteAttributionHoldControl = newProductionSQLiteHoldControl(tracker)
}
type sqliteAttributionDriver struct{}
func (sqliteAttributionDriver) Open(dataSourceName string) (driver.Conn, error) {
rawConnection, err := (&sqlite3.SQLiteDriver{}).Open(dataSourceName)
if err != nil {
return nil, err
}
connection, ok := rawConnection.(*sqlite3.SQLiteConn)
if !ok {
return nil, errors.New("sqlite attribution received an unsupported sqlite connection")
}
databasePath := connection.GetFilename("")
if databasePath == "" {
if closeErr := connection.Close(); closeErr != nil {
return nil, fmt.Errorf("close unsupported sqlite database: %w", closeErr)
}
return nil, &SQLiteAttributionError{Cause: ErrSQLiteAttributionUnsupportedDSN}
}
wrapped := &sqliteAttributionConnection{connection: connection, identity: SQLiteConnectionIdentity(sqliteAttributionConnectionID.Add(1)), journal: databasePath + "-journal", closeRawConnection: connection.Close}
wrapped.prepareStatement = wrapped.prepareSQLiteStatement
connection.RegisterAuthorizer(wrapped.authorize)
connection.RegisterUpdateHook(wrapped.recordUpdate)
return wrapped, nil
}
type sqliteAttributionConnection struct {
connection *sqlite3.SQLiteConn
identity SQLiteConnectionIdentity
journal string
// lifecycleMu owns the active transaction and its poison, journal descriptor, and completion state; never hold it across raw SQLite, tracker, wait, or close operations.
lifecycleMu sync.Mutex
transaction *sqliteAttributionTransaction
capturing bool
preparing sqliteAttributionClassification
execution *sqliteAttributionExecution
prepareStatement sqliteAttributionStatementPreparer
closeRawConnection func() error
}
type sqliteAttributionStatementPreparer func(context.Context, string) (driver.Stmt, error)
type sqliteAttributionTransaction struct {
transaction SQLiteTransaction
raw driver.Tx
tracker *SQLiteHoldTracker
context context.Context
journalFD int
poison error
terminalPhase sqliteAttributionTerminalPhase
done chan struct{}
releasedCommitBoundary func()
terminalLoserWaitBoundary func()
}
type sqliteAttributionTerminalPhase uint8
const (
sqliteAttributionTerminalOpen sqliteAttributionTerminalPhase = iota
sqliteAttributionTerminalCommitWaiting
sqliteAttributionTerminalCommitOwned
sqliteAttributionTerminalRollbackOwned
)
type sqliteAttributionExecution struct {
classification sqliteAttributionClassification
origin SQLiteExecutionOrigin
hook sqliteAttributionHook
}
type sqliteAttributionHook struct {
seen bool
mismatch bool
}
var (
_ driver.Driver = sqliteAttributionDriver{}
_ driver.Conn = (*sqliteAttributionConnection)(nil)
_ driver.Pinger = (*sqliteAttributionConnection)(nil)
_ driver.ConnPrepareContext = (*sqliteAttributionConnection)(nil)
_ driver.ConnBeginTx = (*sqliteAttributionConnection)(nil)
_ driver.Execer = (*sqliteAttributionConnection)(nil)
_ driver.ExecerContext = (*sqliteAttributionConnection)(nil)
_ driver.Queryer = (*sqliteAttributionConnection)(nil)
_ driver.QueryerContext = (*sqliteAttributionConnection)(nil)
)
func (connection *sqliteAttributionConnection) authorize(operation int, table, _, schema string) int {
if !connection.capturing {
return sqlite3.SQLITE_OK
}
classification := &connection.preparing
if operation != sqlite3.SQLITE_INSERT && operation != sqlite3.SQLITE_UPDATE && operation != sqlite3.SQLITE_DELETE {
return sqlite3.SQLITE_OK
}
rowOperation, ok := sqliteAttributionOperation(operation)
if !ok || schema != "main" || strings.HasPrefix(table, "sqlite_") {
classification.ambiguous = true
return sqlite3.SQLITE_OK
}
// SQLite authorizes UPDATE once per written column; only a different row-DML target is ambiguous.
if classification.hasRowDML {
if classification.operation != rowOperation || classification.table != table {
classification.ambiguous = true
}
return sqlite3.SQLITE_OK
}
classification.hasRowDML = true
classification.operation = rowOperation
classification.table = table
return sqlite3.SQLITE_OK
}
func (connection *sqliteAttributionConnection) Prepare(query string) (driver.Stmt, error) {
return connection.prepareStatement(context.Background(), query)
}
func (connection *sqliteAttributionConnection) PrepareContext(ctx context.Context, query string) (driver.Stmt, error) {
return connection.prepareStatement(ctx, query)
}
func (connection *sqliteAttributionConnection) prepareSQLiteStatement(ctx context.Context, query string) (driver.Stmt, error) {
connection.preparing = sqliteAttributionClassification{}
connection.capturing = true
statement, err := connection.connection.PrepareContext(ctx, query)
connection.capturing = false
classification := connection.preparing
connection.preparing = sqliteAttributionClassification{}
if err != nil {
return nil, err
}
rawStatement, ok := statement.(*sqlite3.SQLiteStmt)
if !ok {
return nil, errors.New("sqlite attribution received an unsupported sqlite statement")
}
classification.readonly = rawStatement.Readonly()
return &sqliteAttributionStatement{connection: connection, statement: statement, classification: classification}, nil
}
func (connection *sqliteAttributionConnection) Ping(ctx context.Context) error {
return connection.connection.Ping(ctx)
}
func (connection *sqliteAttributionConnection) Begin() (driver.Tx, error) {
return connection.begin(context.Background(), driver.TxOptions{})
}
func (connection *sqliteAttributionConnection) BeginTx(ctx context.Context, options driver.TxOptions) (driver.Tx, error) {
return connection.begin(ctx, options)
}
func (connection *sqliteAttributionConnection) begin(ctx context.Context, options driver.TxOptions) (driver.Tx, error) {
transaction, err := connection.connection.BeginTx(ctx, options)
if err != nil {
return nil, err
}
identity := SQLiteTransaction{Connection: connection.identity, Identity: SQLiteTransactionIdentity(sqliteAttributionTransactionID.Add(1))}
tracker := sqliteAttributionTracker.Load()
if err := tracker.BeginSQLiteTransaction(identity); err != nil {
// Tracker rejection happens after BEGIN; leave no raw transaction to wedge this connection.
return nil, errors.Join(err, transaction.Rollback())
}
state := &sqliteAttributionTransaction{transaction: identity, raw: transaction, tracker: tracker, context: ctx, journalFD: -1, done: make(chan struct{})}
connection.lifecycleMu.Lock()
connection.transaction = state
connection.lifecycleMu.Unlock()
return &sqliteAttributionTx{connection: connection, state: state}, nil
}
func (connection *sqliteAttributionConnection) recordUpdate(operation int, database, table string, _ int64) {
execution := connection.execution
if execution == nil {
return
}
rowOperation, ok := sqliteAttributionOperation(operation)
if !ok || database != "main" || rowOperation != execution.classification.operation || table != execution.classification.table {
execution.hook.mismatch = true
return
}
execution.hook.seen = true
}
func sqliteAttributionOperation(operation int) (SQLiteOperation, bool) {
switch operation {
case sqlite3.SQLITE_INSERT:
return SQLiteOperationInsert, true
case sqlite3.SQLITE_UPDATE:
return SQLiteOperationUpdate, true
case sqlite3.SQLITE_DELETE:
return SQLiteOperationDelete, true
default:
return "", false
}
}
@@ -0,0 +1,204 @@
//go:build agentcompat && linux
package singleton
import (
"database/sql"
"database/sql/driver"
"errors"
"path/filepath"
"testing"
)
func TestSQLiteDriverAdapterRecordsExplicitInsert_when_AttributionEnabled(t *testing.T) {
resetSQLiteAttributionForTest()
databasePath := filepath.Join(t.TempDir(), "dashboard.sqlite")
database, err := openSQLiteAttributionTestDB(databasePath)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if closeErr := database.Close(); closeErr != nil {
t.Error(closeErr)
}
})
if _, err := database.Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)"); err != nil {
t.Fatal(err)
}
enableSQLiteAttribution()
transaction, err := database.Begin()
if err != nil {
t.Fatal(err)
}
if _, err := transaction.Exec("INSERT INTO settings (value) VALUES (?)", "opaque"); err != nil {
t.Fatal(err)
}
if !sqliteAttributionTrackerHasWrite() {
t.Fatal("explicit insert did not record an atomic sqlite write")
}
if err := transaction.Commit(); err != nil {
t.Fatal(err)
}
}
func TestSQLiteDriverAdapterRecordsPreparedExplicitInsert_when_AttributionEnabled(t *testing.T) {
resetSQLiteAttributionForTest()
databasePath := filepath.Join(t.TempDir(), "dashboard.sqlite")
database, err := openSQLiteAttributionTestDB(databasePath)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if closeErr := database.Close(); closeErr != nil {
t.Error(closeErr)
}
})
if _, err := database.Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)"); err != nil {
t.Fatal(err)
}
enableSQLiteAttribution()
transaction, err := database.Begin()
if err != nil {
t.Fatal(err)
}
statement, err := transaction.Prepare("INSERT INTO settings (value) VALUES (?)")
if err != nil {
t.Fatal(err)
}
if _, err := statement.Exec("prepared"); err != nil {
t.Fatal(err)
}
if err := statement.Close(); err != nil {
t.Fatal(err)
}
if !sqliteAttributionTrackerHasWrite() {
t.Fatal("prepared explicit insert did not record an atomic sqlite write")
}
if err := transaction.Commit(); err != nil {
t.Fatal(err)
}
}
func TestSQLiteDriverAdapterRejectsUnboundWrite_when_AttributionEnabled(t *testing.T) {
database := openSQLiteAttributionTestDatabase(t)
enableSQLiteAttribution()
_, err := database.Exec("INSERT INTO settings (value) VALUES (?)", "unbound")
if !errors.Is(err, ErrSQLiteAttributionUnboundWrite) {
t.Fatalf("expected unbound write instrumentation error, got %v", err)
}
}
func TestSQLiteDriverAdapterRejectsUnsupportedDSN(t *testing.T) {
database, err := openSQLiteAttributionTestDB(":memory:")
if err == nil {
err = database.Ping()
}
if database != nil {
t.Cleanup(func() {
if closeErr := database.Close(); closeErr != nil {
t.Error(closeErr)
}
})
}
if !errors.Is(err, ErrSQLiteAttributionUnsupportedDSN) {
t.Fatalf("expected unsupported DSN error, got %v", err)
}
}
func openSQLiteAttributionTestDB(path string) (*sql.DB, error) {
registerSQLiteAttributionDriver()
return sql.Open(sqliteAttributionDriverName, path)
}
func openSQLiteAttributionTestDatabase(t *testing.T) *sql.DB {
t.Helper()
resetSQLiteAttributionForTest()
database, err := openSQLiteAttributionTestDB(sqliteAttributionTestDatabasePath(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if closeErr := database.Close(); closeErr != nil {
t.Error(closeErr)
}
})
if _, err := database.Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, value TEXT)"); err != nil {
t.Fatal(err)
}
return database
}
func sqliteAttributionTestDatabasePath(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "dashboard.sqlite")
}
func sqliteAttributionSettingsCount(t *testing.T, database *sql.DB) int {
t.Helper()
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM settings").Scan(&count); err != nil {
t.Fatal(err)
}
return count
}
func sqliteAttributionSettingValue(t *testing.T, database *sql.DB) string {
t.Helper()
var value string
if err := database.QueryRow("SELECT value FROM settings LIMIT 1").Scan(&value); err != nil {
t.Fatal(err)
}
return value
}
func sqliteAttributionConsumeRows(rows *sql.Rows) error {
if rows == nil {
return nil
}
var identifier int64
for rows.Next() {
if err := rows.Scan(&identifier); err != nil {
closeErr := rows.Close()
return errors.Join(err, closeErr)
}
}
err := rows.Err()
closeErr := rows.Close()
return errors.Join(err, closeErr)
}
func sqliteAttributionConsumeDriverRows(rows driver.Rows) error {
if rows == nil {
return nil
}
values := make([]driver.Value, len(rows.Columns()))
for {
err := rows.Next(values)
if err != nil {
closeErr := rows.Close()
return errors.Join(err, closeErr)
}
}
}
func sqliteAttributionTrackerHasWrite() bool {
return sqliteAttributionTrackerWriteEvidence().hasWrite
}
type sqliteAttributionWriteEvidence struct {
hasWrite bool
write SQLiteWriteObservation
}
func sqliteAttributionTrackerWriteEvidence() sqliteAttributionWriteEvidence {
tracker := sqliteAttributionTracker.Load()
tracker.mu.Lock()
defer tracker.mu.Unlock()
for _, held := range tracker.transactions {
if held.hasWrite {
return sqliteAttributionWriteEvidence{hasWrite: true, write: held.write}
}
}
return sqliteAttributionWriteEvidence{}
}
@@ -0,0 +1,176 @@
//go:build agentcompat && linux
package singleton
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"io"
"sync"
)
var ErrSQLiteHoldUnexpectedSelection = errors.New("sqlite hold selected an unexpected write")
type SQLiteHoldControlState string
const (
SQLiteHoldControlStateArmed SQLiteHoldControlState = "armed"
SQLiteHoldControlStateSelected SQLiteHoldControlState = "selected"
SQLiteHoldControlStateFinalizing SQLiteHoldControlState = "finalizing"
SQLiteHoldControlStateReleased SQLiteHoldControlState = "released"
SQLiteHoldControlStateAborted SQLiteHoldControlState = "aborted"
)
type SQLiteHoldReceipt struct {
ID string `json:"id"`
State SQLiteHoldControlState `json:"state"`
}
type sqliteHoldControlRecord struct {
receipt SQLiteHoldReceipt
session SQLiteHoldSession
}
type sqliteHoldControl struct {
mu sync.Mutex
tracker *SQLiteHoldTracker
random io.Reader
record *sqliteHoldControlRecord
}
func newSQLiteHoldControl(tracker *SQLiteHoldTracker, randomSource io.Reader) *sqliteHoldControl {
return &sqliteHoldControl{tracker: tracker, random: randomSource}
}
func newProductionSQLiteHoldControl(tracker *SQLiteHoldTracker) *sqliteHoldControl {
return newSQLiteHoldControl(tracker, rand.Reader)
}
func (control *sqliteHoldControl) ArmNextSQLiteHold() (SQLiteHoldReceipt, error) {
control.mu.Lock()
defer control.mu.Unlock()
identifier, err := control.newReceiptID()
if err != nil {
return SQLiteHoldReceipt{}, err
}
session, err := control.tracker.ArmNextSQLiteHold()
if err != nil {
return SQLiteHoldReceipt{}, err
}
receipt := SQLiteHoldReceipt{ID: identifier, State: SQLiteHoldControlStateArmed}
control.record = &sqliteHoldControlRecord{receipt: receipt, session: session}
return receipt, nil
}
func (control *sqliteHoldControl) WaitSelected(ctx context.Context, receipt SQLiteHoldReceipt) (SQLiteHoldReceipt, error) {
return control.wait(ctx, receipt, SQLiteHoldWaitSelected, SQLiteHoldControlStateSelected)
}
func (control *sqliteHoldControl) WaitFinalizing(ctx context.Context, receipt SQLiteHoldReceipt) (SQLiteHoldReceipt, error) {
return control.wait(ctx, receipt, SQLiteHoldWaitFinalizing, SQLiteHoldControlStateFinalizing)
}
func (control *sqliteHoldControl) wait(ctx context.Context, receipt SQLiteHoldReceipt, target SQLiteHoldWaitTarget, state SQLiteHoldControlState) (SQLiteHoldReceipt, error) {
record, err := control.activeRecord(receipt)
if err != nil {
return SQLiteHoldReceipt{}, err
}
snapshot, err := control.tracker.WaitSQLiteHold(ctx, record.session, target)
if err != nil {
return control.terminalReceipt(record, err)
}
if snapshot.Operation != SQLiteOperationUpdate || snapshot.Table != "api_tokens" {
_ = control.tracker.AbortSQLiteHold(record.session)
return control.terminalReceipt(record, ErrSQLiteHoldUnexpectedSelection)
}
return control.updateReceipt(record, state), nil
}
func (control *sqliteHoldControl) Release(receipt SQLiteHoldReceipt) (SQLiteHoldReceipt, error) {
record, err := control.activeRecord(receipt)
if err != nil {
return SQLiteHoldReceipt{}, err
}
if err := control.tracker.ReleaseSQLiteHold(record.session); err != nil {
return control.terminalReceipt(record, err)
}
return control.updateReceipt(record, SQLiteHoldControlStateReleased), nil
}
func (control *sqliteHoldControl) Abort(receipt SQLiteHoldReceipt) (SQLiteHoldReceipt, error) {
record, err := control.activeRecord(receipt)
if err != nil {
return SQLiteHoldReceipt{}, err
}
if err := control.tracker.AbortSQLiteHold(record.session); err != nil {
return control.terminalReceipt(record, err)
}
return control.updateReceipt(record, SQLiteHoldControlStateAborted), nil
}
func (control *sqliteHoldControl) Snapshot(receipt SQLiteHoldReceipt) (SQLiteHoldReceipt, error) {
record, err := control.activeRecord(receipt)
if err != nil {
return SQLiteHoldReceipt{}, err
}
current := control.currentReceipt(record)
if current.State == SQLiteHoldControlStateReleased || current.State == SQLiteHoldControlStateAborted {
return current, nil
}
snapshot, snapshotErr := control.tracker.SQLiteHoldSnapshot(record.session)
if snapshotErr != nil {
if errors.Is(snapshotErr, ErrSQLiteHoldStaleSession) {
return control.updateReceipt(record, SQLiteHoldControlStateAborted), nil
}
return control.terminalReceipt(record, snapshotErr)
}
state := SQLiteHoldControlStateArmed
if snapshot.Finalizing {
state = SQLiteHoldControlStateFinalizing
} else if snapshot.Selected {
state = SQLiteHoldControlStateSelected
}
return control.updateReceipt(record, state), nil
}
func (control *sqliteHoldControl) currentReceipt(record *sqliteHoldControlRecord) SQLiteHoldReceipt {
control.mu.Lock()
defer control.mu.Unlock()
return record.receipt
}
func (control *sqliteHoldControl) newReceiptID() (string, error) {
bytes := make([]byte, 32)
if _, err := io.ReadFull(control.random, bytes); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(bytes), nil
}
func (control *sqliteHoldControl) activeRecord(receipt SQLiteHoldReceipt) (*sqliteHoldControlRecord, error) {
control.mu.Lock()
defer control.mu.Unlock()
if control.record == nil || control.record.receipt.ID != receipt.ID {
return nil, ErrSQLiteHoldStaleSession
}
return control.record, nil
}
func (control *sqliteHoldControl) updateReceipt(record *sqliteHoldControlRecord, state SQLiteHoldControlState) SQLiteHoldReceipt {
control.mu.Lock()
defer control.mu.Unlock()
if control.record == record {
control.record.receipt.State = state
}
return record.receipt
}
func (control *sqliteHoldControl) terminalReceipt(record *sqliteHoldControlRecord, cause error) (SQLiteHoldReceipt, error) {
state := SQLiteHoldControlStateAborted
if cause == nil {
state = SQLiteHoldControlStateReleased
}
return control.updateReceipt(record, state), cause
}
@@ -0,0 +1,111 @@
//go:build agentcompat && linux
package singleton
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
)
func TestSQLiteHoldControlIssuesOpaqueReceiptAndStalesPriorReceipt(t *testing.T) {
// Given
control := newSQLiteHoldControl(NewSQLiteHoldTracker(), bytes.NewReader(append(bytes.Repeat([]byte{7}, 32), bytes.Repeat([]byte{8}, 32)...)))
// When
first, firstErr := control.ArmNextSQLiteHold()
if _, abortErr := control.Abort(first); abortErr != nil {
t.Fatal(abortErr)
}
aborted, abortSnapshotErr := control.Snapshot(first)
second, secondErr := control.ArmNextSQLiteHold()
_, staleErr := control.Snapshot(first)
// Then
if firstErr != nil || secondErr != nil {
t.Fatalf("first=%v second=%v", firstErr, secondErr)
}
if len(first.ID) != 43 || strings.Contains(first.ID, "=") || first.ID == second.ID {
t.Fatalf("opaque receipt IDs = %q, %q", first.ID, second.ID)
}
if !errors.Is(staleErr, ErrSQLiteHoldStaleSession) {
t.Fatalf("old receipt error = %v", staleErr)
}
if abortSnapshotErr != nil || aborted.State != SQLiteHoldControlStateAborted {
t.Fatalf("aborted receipt=%+v err=%v", aborted, abortSnapshotErr)
}
}
func TestSQLiteHoldControlReadsTerminalStatesAndRejectsUnexpectedSelection(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
control := newSQLiteHoldControl(tracker, bytes.NewReader(bytes.Repeat([]byte{9}, 64)))
receipt, err := control.ArmNextSQLiteHold()
if err != nil {
t.Fatal(err)
}
transaction := sqliteHoldTestTransaction(66)
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
if err := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{Operation: SQLiteOperationInsert, Table: "settings", Journal: sqliteHoldTestJournal}); err != nil {
t.Fatal(err)
}
// When
_, waitErr := control.WaitSelected(context.Background(), receipt)
aborted, snapshotErr := control.Snapshot(receipt)
wire, marshalErr := json.Marshal(aborted)
// Then
if !errors.Is(waitErr, ErrSQLiteHoldUnexpectedSelection) {
t.Fatalf("unexpected selection error = %v", waitErr)
}
if snapshotErr != nil || aborted.State != SQLiteHoldControlStateAborted {
t.Fatalf("terminal receipt=%+v err=%v", aborted, snapshotErr)
}
if marshalErr != nil {
t.Fatal(marshalErr)
}
for _, forbidden := range []string{"transaction", "connection", "journal", "operation", "table", "path", "origin", "session_id"} {
if strings.Contains(strings.ToLower(string(wire)), forbidden) {
t.Fatalf("opaque receipt leaked %q: %s", forbidden, wire)
}
}
}
func TestSQLiteHoldControlReadsReleasedReceipt(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
control := newSQLiteHoldControl(tracker, bytes.NewReader(bytes.Repeat([]byte{11}, 32)))
receipt, err := control.ArmNextSQLiteHold()
if err != nil {
t.Fatal(err)
}
transaction := sqliteHoldTestTransaction(67)
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
if err := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{Operation: SQLiteOperationUpdate, Table: "api_tokens", Journal: sqliteHoldTestJournal}); err != nil {
t.Fatal(err)
}
if _, err := tracker.BeginSQLiteFinalization(transaction); err != nil {
t.Fatal(err)
}
// When
_, releaseErr := control.Release(receipt)
released, snapshotErr := control.Snapshot(receipt)
_, abortErr := control.Abort(receipt)
// Then
if releaseErr != nil || snapshotErr != nil || released.State != SQLiteHoldControlStateReleased {
t.Fatalf("release=%v receipt=%+v snapshot=%v", releaseErr, released, snapshotErr)
}
if !errors.Is(abortErr, ErrSQLiteHoldStaleSession) {
t.Fatalf("abort after release = %v", abortErr)
}
}
@@ -0,0 +1,24 @@
//go:build agentcompat && linux
package singleton
import "context"
func ArmNextSQLiteHold() (SQLiteHoldReceipt, error) {
return sqliteAttributionHoldControl.ArmNextSQLiteHold()
}
func WaitSQLiteHoldSelected(ctx context.Context, receipt SQLiteHoldReceipt) (SQLiteHoldReceipt, error) {
return sqliteAttributionHoldControl.WaitSelected(ctx, receipt)
}
func WaitSQLiteHoldFinalizing(ctx context.Context, receipt SQLiteHoldReceipt) (SQLiteHoldReceipt, error) {
return sqliteAttributionHoldControl.WaitFinalizing(ctx, receipt)
}
func SnapshotSQLiteHold(receipt SQLiteHoldReceipt) (SQLiteHoldReceipt, error) {
return sqliteAttributionHoldControl.Snapshot(receipt)
}
func ReleaseSQLiteHold(receipt SQLiteHoldReceipt) (SQLiteHoldReceipt, error) {
return sqliteAttributionHoldControl.Release(receipt)
}
func AbortSQLiteHold(receipt SQLiteHoldReceipt) (SQLiteHoldReceipt, error) {
return sqliteAttributionHoldControl.Abort(receipt)
}
@@ -0,0 +1,239 @@
//go:build agentcompat && linux
package singleton
import "context"
func (tracker *SQLiteHoldTracker) ArmSQLiteHold(journal SQLiteJournalIdentity) (SQLiteHoldSession, error) {
return tracker.armSQLiteHold(SQLiteHoldSelectionModeKnownJournal, journal)
}
func (tracker *SQLiteHoldTracker) ArmNextSQLiteHold() (SQLiteHoldSession, error) {
return tracker.armSQLiteHold(SQLiteHoldSelectionModeNextWriter, SQLiteJournalIdentity{})
}
func (tracker *SQLiteHoldTracker) armSQLiteHold(mode SQLiteHoldSelectionMode, journal SQLiteJournalIdentity) (SQLiteHoldSession, error) {
tracker.mu.Lock()
defer tracker.mu.Unlock()
if tracker.session != nil {
return SQLiteHoldSession{}, ErrSQLiteHoldSessionActive
}
tracker.nextSession++
tracker.session = &sqliteHoldSessionState{
id: SQLiteHoldSession{identity: tracker.nextSession}, mode: mode, journal: journal, notify: make(chan struct{}),
}
var candidates []SQLiteTransaction
for transaction, held := range tracker.transactions {
if tracker.isEligibleLocked(held) {
candidates = append(candidates, transaction)
}
}
if len(candidates) > 1 {
tracker.abortSQLiteHoldWithCandidatesLocked(ErrSQLiteHoldAmbiguousCandidate, candidates)
return SQLiteHoldSession{}, ErrSQLiteHoldAmbiguousCandidate
}
if len(candidates) == 1 {
tracker.selectSQLiteHoldLocked(candidates[0])
}
tracker.setAttributionEnabledLocked(true)
return tracker.session.id, nil
}
func (tracker *SQLiteHoldTracker) WaitSQLiteHold(ctx context.Context, session SQLiteHoldSession, target SQLiteHoldWaitTarget) (SQLiteHoldSnapshot, error) {
if !target.valid() {
return SQLiteHoldSnapshot{}, ErrSQLiteHoldInvalidWaitTarget
}
for {
tracker.mu.Lock()
if tracker.session == nil || tracker.session.id != session {
if tracker.terminal != nil && tracker.terminal.id == session {
if tracker.terminal.released {
snapshot := SQLiteHoldSnapshot{SessionID: session.ID(), Selected: tracker.terminal.hasSelected, Transaction: tracker.terminal.selected, Finalizing: true, Released: true}
tracker.mu.Unlock()
return snapshot, nil
}
cause := tracker.terminal.cause
tracker.mu.Unlock()
return SQLiteHoldSnapshot{}, cause
}
tracker.mu.Unlock()
return SQLiteHoldSnapshot{}, ErrSQLiteHoldStaleSession
}
if tracker.waitTargetReachedLocked(target) {
snapshot, err := tracker.snapshotLocked(session)
tracker.mu.Unlock()
return snapshot, err
}
notify := tracker.session.notify
tracker.mu.Unlock()
select {
case <-notify:
case <-ctx.Done():
return SQLiteHoldSnapshot{}, ctx.Err()
}
}
}
func (tracker *SQLiteHoldTracker) BeginSQLiteFinalization(transaction SQLiteTransaction) (*SQLiteHoldFinalization, error) {
tracker.mu.Lock()
defer tracker.mu.Unlock()
if tracker.session == nil || !tracker.session.hasSelected || tracker.session.selected != transaction {
return nil, ErrSQLiteHoldNotSelected
}
return tracker.beginSQLiteFinalizationLocked(transaction)
}
func (tracker *SQLiteHoldTracker) BeginSQLiteCommitFinalization(transaction SQLiteTransaction) (*SQLiteHoldFinalization, error) {
tracker.mu.Lock()
defer tracker.mu.Unlock()
if tracker.session != nil && tracker.session.hasSelected && tracker.session.selected == transaction {
return tracker.beginSQLiteFinalizationLocked(transaction)
}
if cause, ok := tracker.causes[transaction]; ok {
return nil, cause
}
return nil, ErrSQLiteHoldNotSelected
}
func (tracker *SQLiteHoldTracker) ReleaseSQLiteHold(session SQLiteHoldSession) error {
tracker.mu.Lock()
defer tracker.mu.Unlock()
if !tracker.matchesSessionLocked(session) || tracker.session.released {
return ErrSQLiteHoldStaleSession
}
if !tracker.session.hasSelected || tracker.session.finalization == nil {
return ErrSQLiteHoldFinalizationNotStarted
}
tracker.session.released = true
tracker.setAttributionEnabledLocked(false)
close(tracker.session.finalization.done)
tracker.notifySQLiteHoldLocked()
return nil
}
func (tracker *SQLiteHoldTracker) AbortSQLiteHold(session SQLiteHoldSession) error {
tracker.mu.Lock()
defer tracker.mu.Unlock()
if !tracker.matchesSessionLocked(session) || tracker.session.released {
return ErrSQLiteHoldStaleSession
}
tracker.abortSQLiteHoldLocked(ErrSQLiteHoldAborted)
return nil
}
func (tracker *SQLiteHoldTracker) SQLiteHoldSnapshot(session SQLiteHoldSession) (SQLiteHoldSnapshot, error) {
tracker.mu.Lock()
defer tracker.mu.Unlock()
if !tracker.matchesSessionLocked(session) {
return SQLiteHoldSnapshot{}, ErrSQLiteHoldStaleSession
}
return tracker.snapshotLocked(session)
}
func (tracker *SQLiteHoldTracker) snapshotLocked(session SQLiteHoldSession) (SQLiteHoldSnapshot, error) {
snapshot := SQLiteHoldSnapshot{SessionID: session.ID(), Mode: tracker.session.mode, Selected: tracker.session.hasSelected, Journal: tracker.session.journal, Released: tracker.session.released}
if !tracker.session.hasSelected {
return snapshot, nil
}
held := tracker.transactions[tracker.session.selected]
if held == nil {
return SQLiteHoldSnapshot{}, ErrSQLiteHoldNotSelected
}
snapshot.Transaction = held.transaction
if held.hasWrite {
snapshot.Operation, snapshot.Table, snapshot.Journal = held.write.Update.Operation, held.write.Update.Table, held.write.Update.Journal
snapshot.StackHash, snapshot.FirstNezhaFrame = held.write.Origin.StackHash, held.write.Origin.FirstNezhaFrame
} else {
snapshot.Operation, snapshot.Table, snapshot.Journal = held.update.Operation, held.update.Table, held.update.Journal
snapshot.StackHash, snapshot.FirstNezhaFrame = held.origin.StackHash, held.origin.FirstNezhaFrame
}
snapshot.Finalizing = held.finalizing
return snapshot, nil
}
func (tracker *SQLiteHoldTracker) selectSQLiteHoldLocked(transaction SQLiteTransaction) error {
if !tracker.session.hasSelected {
held := tracker.transactions[transaction]
tracker.session.selected, tracker.session.hasSelected = transaction, true
if tracker.session.mode == SQLiteHoldSelectionModeNextWriter {
tracker.session.journal = held.update.Journal
}
tracker.notifySQLiteHoldLocked()
return nil
}
if tracker.session.selected == transaction {
return nil
}
tracker.abortSQLiteHoldWithCandidatesLocked(ErrSQLiteHoldAmbiguousCandidate, []SQLiteTransaction{tracker.session.selected, transaction})
return ErrSQLiteHoldAmbiguousCandidate
}
func (tracker *SQLiteHoldTracker) matchesSessionLocked(session SQLiteHoldSession) bool {
return tracker.session != nil && tracker.session.id == session
}
func (tracker *SQLiteHoldTracker) activeSessionHasSelectedTransactionLocked(transaction SQLiteTransaction) bool {
return tracker.session != nil && tracker.session.hasSelected && tracker.session.selected == transaction
}
func (tracker *SQLiteHoldTracker) beginSQLiteFinalizationLocked(transaction SQLiteTransaction) (*SQLiteHoldFinalization, error) {
held, ok := tracker.transactionLocked(transaction)
if !ok || held.finalizing {
return nil, ErrSQLiteHoldFinalizationStarted
}
held.finalizing = true
finalization := &SQLiteHoldFinalization{done: make(chan struct{})}
tracker.session.finalization = finalization
tracker.notifySQLiteHoldLocked()
return finalization, nil
}
func (tracker *SQLiteHoldTracker) waitTargetReachedLocked(target SQLiteHoldWaitTarget) bool {
switch target {
case SQLiteHoldWaitSelected:
return tracker.session.hasSelected
case SQLiteHoldWaitFinalizing:
return tracker.session.finalization != nil
default:
return false
}
}
func (target SQLiteHoldWaitTarget) valid() bool {
return target == SQLiteHoldWaitSelected || target == SQLiteHoldWaitFinalizing
}
// Closing and replacing the channel under mu makes a snapshot-to-wait handoff race-free.
func (tracker *SQLiteHoldTracker) notifySQLiteHoldLocked() {
close(tracker.session.notify)
tracker.session.notify = make(chan struct{})
}
func (tracker *SQLiteHoldTracker) abortSQLiteHoldLocked(cause error) {
tracker.abortSQLiteHoldWithCandidatesLocked(cause, nil)
}
func (tracker *SQLiteHoldTracker) abortSQLiteHoldWithCandidatesLocked(cause error, candidates []SQLiteTransaction) {
tracker.setAttributionEnabledLocked(false)
if tracker.session.finalization != nil {
tracker.session.finalization.err = cause
close(tracker.session.finalization.done)
}
if tracker.session.hasSelected {
tracker.causes[tracker.session.selected] = cause
}
for _, transaction := range candidates {
tracker.causes[transaction] = cause
}
tracker.terminal = &sqliteHoldTerminalState{id: tracker.session.id, selected: tracker.session.selected, hasSelected: tracker.session.hasSelected, cause: cause}
tracker.notifySQLiteHoldLocked()
tracker.session = nil
}
func (tracker *SQLiteHoldTracker) setAttributionEnabledLocked(enabled bool) {
if tracker.attributionEnabled == nil {
return
}
// Attribution is hold-scoped because ordinary GORM RETURNING closes rows before EOF.
tracker.attributionEnabled.Store(enabled)
}
@@ -0,0 +1,75 @@
//go:build agentcompat && linux
package singleton
import (
"context"
"errors"
"testing"
)
func TestSQLiteHoldTrackerCommitFinalizationCancellationAbortsUnreleasedHold(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
transaction := sqliteHoldTestTransaction(202)
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
recordSQLiteHoldTestUpdate(t, tracker, transaction)
session, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal)
if err != nil {
t.Fatal(err)
}
finalization, err := tracker.BeginSQLiteCommitFinalization(transaction)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
// When
waitErr := tracker.WaitSQLiteCommitFinalization(ctx, finalization)
releaseErr := tracker.ReleaseSQLiteHold(session)
// Then
if !errors.Is(waitErr, context.Canceled) {
t.Fatalf("cancelled finalization wait error = %v", waitErr)
}
if !errors.Is(finalization.Wait(), ErrSQLiteHoldAborted) {
t.Fatalf("finalization after cancellation = %v", finalization.Wait())
}
if !errors.Is(releaseErr, ErrSQLiteHoldStaleSession) {
t.Fatalf("release after cancellation-owned abort = %v", releaseErr)
}
}
func TestSQLiteHoldTrackerCommitFinalizationReleaseWinsOverAlreadyCancelledContext(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
transaction := sqliteHoldTestTransaction(203)
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
recordSQLiteHoldTestUpdate(t, tracker, transaction)
session, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal)
if err != nil {
t.Fatal(err)
}
finalization, err := tracker.BeginSQLiteCommitFinalization(transaction)
if err != nil {
t.Fatal(err)
}
if err := tracker.ReleaseSQLiteHold(session); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
// When
waitErr := tracker.WaitSQLiteCommitFinalization(ctx, finalization)
// Then
if waitErr != nil {
t.Fatalf("released finalization lost to already-cancelled context: %v", waitErr)
}
}
@@ -0,0 +1,33 @@
//go:build agentcompat && linux
package singleton
import "context"
// WaitSQLiteCommitFinalization linearizes cancellation against release under the tracker lock.
func (tracker *SQLiteHoldTracker) WaitSQLiteCommitFinalization(ctx context.Context, finalization *SQLiteHoldFinalization) error {
for {
tracker.mu.Lock()
select {
case <-finalization.done:
err := finalization.err
tracker.mu.Unlock()
return err
default:
}
if err := ctx.Err(); err != nil {
if tracker.session != nil && tracker.session.finalization == finalization && !tracker.session.released {
tracker.abortSQLiteHoldLocked(ErrSQLiteHoldAborted)
}
tracker.mu.Unlock()
return err
}
done := finalization.done
tracker.mu.Unlock()
select {
case <-done:
return finalization.err
case <-ctx.Done():
}
}
}
@@ -0,0 +1,113 @@
//go:build agentcompat && linux
package singleton
func (tracker *SQLiteHoldTracker) BeginSQLiteTransaction(transaction SQLiteTransaction) error {
tracker.mu.Lock()
defer tracker.mu.Unlock()
if _, active := tracker.transactions[transaction]; active {
return ErrSQLiteHoldTransactionActive
}
tracker.transactions[transaction] = &sqliteHeldTransaction{transaction: transaction}
return nil
}
func (tracker *SQLiteHoldTracker) RecordSQLiteExecution(transaction SQLiteTransaction, origin SQLiteExecutionOrigin) error {
tracker.mu.Lock()
defer tracker.mu.Unlock()
held, ok := tracker.transactionLocked(transaction)
if !ok {
return ErrSQLiteHoldNotSelected
}
if held.finalizing {
return ErrSQLiteHoldFinalizationStarted
}
if held.hasWrite {
return ErrSQLiteHoldAtomicWriteRecorded
}
if tracker.activeSessionHasSelectedTransactionLocked(transaction) {
return ErrSQLiteHoldEvidenceFrozen
}
held.origin = origin
return nil
}
func (tracker *SQLiteHoldTracker) RecordSQLiteUpdate(transaction SQLiteTransaction, update SQLiteUpdateObservation) error {
tracker.mu.Lock()
defer tracker.mu.Unlock()
held, ok := tracker.transactionLocked(transaction)
if !ok {
return ErrSQLiteHoldNotSelected
}
if held.finalizing {
return ErrSQLiteHoldFinalizationStarted
}
if held.hasWrite {
return ErrSQLiteHoldAtomicWriteRecorded
}
if tracker.activeSessionHasSelectedTransactionLocked(transaction) {
return ErrSQLiteHoldEvidenceFrozen
}
held.update, held.hasUpdate = update, true
if tracker.session != nil && !tracker.session.released && tracker.isEligibleLocked(held) {
return tracker.selectSQLiteHoldLocked(transaction)
}
return nil
}
func (tracker *SQLiteHoldTracker) RecordSQLiteWrite(transaction SQLiteTransaction, write SQLiteWriteObservation) error {
tracker.mu.Lock()
defer tracker.mu.Unlock()
held, ok := tracker.transactionLocked(transaction)
if !ok {
return ErrSQLiteHoldNotSelected
}
if held.finalizing {
return ErrSQLiteHoldFinalizationStarted
}
if held.hasWrite {
return nil
}
if tracker.activeSessionHasSelectedTransactionLocked(transaction) {
return ErrSQLiteHoldEvidenceFrozen
}
held.write, held.hasWrite = write, true
held.origin, held.update, held.hasUpdate = write.Origin, write.Update, true
if tracker.session != nil && !tracker.session.released && tracker.isEligibleLocked(held) {
return tracker.selectSQLiteHoldLocked(transaction)
}
return nil
}
func (tracker *SQLiteHoldTracker) FinishSQLiteTransaction(transaction SQLiteTransaction) error {
tracker.mu.Lock()
defer tracker.mu.Unlock()
if _, ok := tracker.transactionLocked(transaction); !ok {
return ErrSQLiteHoldNotSelected
}
delete(tracker.transactions, transaction)
defer delete(tracker.causes, transaction)
defer delete(tracker.terminalArbitrations, transaction)
if tracker.session != nil && tracker.session.hasSelected && tracker.session.selected == transaction {
if tracker.session.released {
tracker.terminal = &sqliteHoldTerminalState{id: tracker.session.id, selected: transaction, hasSelected: true, released: true}
tracker.notifySQLiteHoldLocked()
tracker.session = nil
} else {
tracker.abortSQLiteHoldLocked(ErrSQLiteHoldAborted)
}
}
return nil
}
func (tracker *SQLiteHoldTracker) transactionLocked(transaction SQLiteTransaction) (*sqliteHeldTransaction, bool) {
held, ok := tracker.transactions[transaction]
return held, ok
}
func (tracker *SQLiteHoldTracker) isEligibleLocked(held *sqliteHeldTransaction) bool {
if !held.hasUpdate || held.finalizing {
return false
}
return tracker.session.mode == SQLiteHoldSelectionModeNextWriter || held.update.Journal == tracker.session.journal
}
@@ -0,0 +1,228 @@
//go:build agentcompat && linux
package singleton
import (
"errors"
"testing"
)
func sqliteHoldTestTransactionOnConnection(connection SQLiteConnectionIdentity, id SQLiteTransactionIdentity) SQLiteTransaction {
return SQLiteTransaction{Connection: connection, Identity: id}
}
func TestSQLiteHoldTrackerTracksSameTransactionIdentityAcrossConnections(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
first := sqliteHoldTestTransactionOnConnection(3, 31)
second := sqliteHoldTestTransactionOnConnection(4, 31)
for _, transaction := range []SQLiteTransaction{first, second} {
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
recordSQLiteHoldTestUpdate(t, tracker, transaction)
}
// When
_, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal)
// Then
if !errors.Is(err, ErrSQLiteHoldAmbiguousCandidate) {
t.Fatalf("expected both connections to be tracked, got %v", err)
}
}
func TestSQLiteHoldTrackerRejectsExactDuplicateTransaction(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
transaction := sqliteHoldTestTransactionOnConnection(3, 32)
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
// When
err := tracker.BeginSQLiteTransaction(transaction)
// Then
if !errors.Is(err, ErrSQLiteHoldTransactionActive) {
t.Fatalf("expected duplicate composite rejection, got %v", err)
}
}
func TestSQLiteHoldTrackerFreezesSelectedMetadataAfterFinalization(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
transaction := sqliteHoldTestTransaction(33)
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
if err := tracker.RecordSQLiteExecution(transaction, SQLiteExecutionOrigin{
Operation: SQLiteOperationInsert, Table: "initial", StackHash: 44, FirstNezhaFrame: "singleton.initial",
}); err != nil {
t.Fatal(err)
}
if err := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{
Operation: SQLiteOperationInsert, Table: "initial", Journal: sqliteHoldTestJournal,
}); err != nil {
t.Fatal(err)
}
session, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal)
if err != nil {
t.Fatal(err)
}
if _, err := tracker.BeginSQLiteFinalization(transaction); err != nil {
t.Fatal(err)
}
// When
executionErr := tracker.RecordSQLiteExecution(transaction, SQLiteExecutionOrigin{
Operation: SQLiteOperationDelete, Table: "changed", StackHash: 45, FirstNezhaFrame: "singleton.changed",
})
updateErr := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{
Operation: SQLiteOperationDelete, Table: "changed", Journal: SQLiteJournalIdentity{Inode: 99},
})
snapshot, snapshotErr := tracker.SQLiteHoldSnapshot(session)
// Then
if !errors.Is(executionErr, ErrSQLiteHoldFinalizationStarted) || !errors.Is(updateErr, ErrSQLiteHoldFinalizationStarted) {
t.Fatalf("execution=%v update=%v", executionErr, updateErr)
}
if snapshotErr != nil {
t.Fatal(snapshotErr)
}
if snapshot.Operation != SQLiteOperationInsert || snapshot.Table != "initial" || snapshot.StackHash != 44 ||
snapshot.FirstNezhaFrame != "singleton.initial" || snapshot.Journal != sqliteHoldTestJournal {
t.Fatalf("finalizing metadata changed: %+v", snapshot)
}
}
func TestSQLiteHoldTrackerRejectsSelectedReleaseBeforeFinalizationThenCompletes(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
transaction := sqliteHoldTestTransaction(34)
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
recordSQLiteHoldTestUpdate(t, tracker, transaction)
session, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal)
if err != nil {
t.Fatal(err)
}
// When
releaseErr := tracker.ReleaseSQLiteHold(session)
finalization, finalizationErr := tracker.BeginSQLiteFinalization(transaction)
// Then
if !errors.Is(releaseErr, ErrSQLiteHoldFinalizationNotStarted) || finalizationErr != nil {
t.Fatalf("release=%v finalization=%v", releaseErr, finalizationErr)
}
if err := tracker.ReleaseSQLiteHold(session); err != nil {
t.Fatal(err)
}
if err := finalization.Wait(); err != nil {
t.Fatal(err)
}
}
func TestSQLiteHoldTrackerCompletesSecondLifecycleAfterReleasedCleanup(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
first := sqliteHoldTestTransaction(35)
if err := tracker.BeginSQLiteTransaction(first); err != nil {
t.Fatal(err)
}
recordSQLiteHoldTestUpdate(t, tracker, first)
firstSession, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal)
if err != nil {
t.Fatal(err)
}
firstFinalization, err := tracker.BeginSQLiteFinalization(first)
if err != nil {
t.Fatal(err)
}
if err := tracker.ReleaseSQLiteHold(firstSession); err != nil {
t.Fatal(err)
}
if err := firstFinalization.Wait(); err != nil {
t.Fatal(err)
}
if err := tracker.FinishSQLiteTransaction(first); err != nil {
t.Fatal(err)
}
second := sqliteHoldTestTransaction(36)
if err := tracker.BeginSQLiteTransaction(second); err != nil {
t.Fatal(err)
}
recordSQLiteHoldTestUpdate(t, tracker, second)
// When
secondSession, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal)
secondFinalization, finalizationErr := tracker.BeginSQLiteFinalization(second)
// Then
if err != nil || finalizationErr != nil {
t.Fatalf("arm=%v finalization=%v", err, finalizationErr)
}
if err := tracker.ReleaseSQLiteHold(secondSession); err != nil {
t.Fatal(err)
}
if err := secondFinalization.Wait(); err != nil {
t.Fatal(err)
}
if err := tracker.FinishSQLiteTransaction(second); err != nil {
t.Fatal(err)
}
}
func TestSQLiteHoldTrackerFreezesLegacyEvidenceWhenSelected(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
transaction := sqliteHoldTestTransaction(37)
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
originalOrigin := SQLiteExecutionOrigin{
Operation: SQLiteOperationInsert, Table: "original", StackHash: 46, FirstNezhaFrame: "singleton.original",
}
if err := tracker.RecordSQLiteExecution(transaction, originalOrigin); err != nil {
t.Fatal(err)
}
if err := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{
Operation: SQLiteOperationInsert, Table: "original", Journal: sqliteHoldTestJournal,
}); err != nil {
t.Fatal(err)
}
session, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal)
if err != nil {
t.Fatal(err)
}
// When
executionErr := tracker.RecordSQLiteExecution(transaction, SQLiteExecutionOrigin{
Operation: SQLiteOperationDelete, Table: "changed", StackHash: 47, FirstNezhaFrame: "singleton.changed",
})
updateErr := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{
Operation: SQLiteOperationDelete, Table: "changed", Journal: SQLiteJournalIdentity{Inode: 94},
})
snapshot, snapshotErr := tracker.SQLiteHoldSnapshot(session)
finalization, finalizationErr := tracker.BeginSQLiteFinalization(transaction)
// Then
if !errors.Is(executionErr, ErrSQLiteHoldEvidenceFrozen) || !errors.Is(updateErr, ErrSQLiteHoldEvidenceFrozen) {
t.Fatalf("execution=%v update=%v", executionErr, updateErr)
}
if snapshotErr != nil || finalizationErr != nil {
t.Fatalf("snapshot=%v finalization=%v", snapshotErr, finalizationErr)
}
if snapshot.Operation != SQLiteOperationInsert || snapshot.Table != "original" || snapshot.StackHash != 46 ||
snapshot.FirstNezhaFrame != "singleton.original" || snapshot.Journal != sqliteHoldTestJournal {
t.Fatalf("selected legacy evidence changed: %+v", snapshot)
}
if err := tracker.ReleaseSQLiteHold(session); err != nil {
t.Fatal(err)
}
if err := finalization.Wait(); err != nil {
t.Fatal(err)
}
}
@@ -0,0 +1,141 @@
//go:build agentcompat && linux
package singleton
import (
"context"
"errors"
"os"
"os/exec"
"testing"
"time"
)
func TestMain(m *testing.M) {
if os.Getenv("NEZHA_SQLITE_HOLD_INVALID_TARGET_HELPER") == "1" {
tracker := NewSQLiteHoldTracker()
session, err := tracker.ArmNextSQLiteHold()
if err != nil {
os.Exit(2)
}
_, waitErr := tracker.WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitTarget(99))
if !errors.Is(waitErr, ErrSQLiteHoldInvalidWaitTarget) {
os.Exit(3)
}
os.Exit(0)
}
os.Exit(m.Run())
}
func TestSQLiteHoldTrackerWaitRejectsInvalidTargetImmediately(t *testing.T) {
// Given
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
command := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestSQLiteHoldTrackerWaitRejectsInvalidTargetImmediately$")
command.Env = append(os.Environ(), "NEZHA_SQLITE_HOLD_INVALID_TARGET_HELPER=1")
// When
err := command.Run()
// Then
if err != nil {
t.Fatalf("invalid target helper did not return before watchdog deadline: %v", err)
}
}
func TestSQLiteHoldTrackerRecordsAmbiguityForEveryFutureConflictCandidate(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
_, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal)
if err != nil {
t.Fatal(err)
}
first, second, mismatch := sqliteHoldTestTransaction(81), sqliteHoldTestTransaction(82), sqliteHoldTestTransaction(83)
for _, transaction := range []SQLiteTransaction{first, second, mismatch} {
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
}
if err := tracker.RecordSQLiteUpdate(first, SQLiteUpdateObservation{Operation: SQLiteOperationUpdate, Table: "settings", Journal: sqliteHoldTestJournal}); err != nil {
t.Fatal(err)
}
if err := tracker.RecordSQLiteUpdate(mismatch, SQLiteUpdateObservation{Operation: SQLiteOperationUpdate, Table: "settings", Journal: SQLiteJournalIdentity{Inode: 84}}); err != nil {
t.Fatal(err)
}
// When
duplicateErr := tracker.RecordSQLiteUpdate(second, SQLiteUpdateObservation{Operation: SQLiteOperationUpdate, Table: "settings", Journal: sqliteHoldTestJournal})
_, firstErr := tracker.BeginSQLiteCommitFinalization(first)
_, secondErr := tracker.BeginSQLiteCommitFinalization(second)
_, mismatchErr := tracker.BeginSQLiteCommitFinalization(mismatch)
// Then
if !errors.Is(duplicateErr, ErrSQLiteHoldAmbiguousCandidate) || !errors.Is(firstErr, ErrSQLiteHoldAmbiguousCandidate) || !errors.Is(secondErr, ErrSQLiteHoldAmbiguousCandidate) {
t.Fatalf("duplicate=%v first=%v second=%v", duplicateErr, firstErr, secondErr)
}
if !errors.Is(mismatchErr, ErrSQLiteHoldNotSelected) {
t.Fatalf("mismatched transaction cause = %v", mismatchErr)
}
}
func TestSQLiteHoldTrackerRecordsAmbiguityForEveryArmTimeConflictCandidate(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
first, second, mismatch := sqliteHoldTestTransaction(85), sqliteHoldTestTransaction(86), sqliteHoldTestTransaction(87)
for _, transaction := range []SQLiteTransaction{first, second, mismatch} {
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
}
for _, transaction := range []SQLiteTransaction{first, second} {
if err := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{Operation: SQLiteOperationUpdate, Table: "settings", Journal: sqliteHoldTestJournal}); err != nil {
t.Fatal(err)
}
}
if err := tracker.RecordSQLiteUpdate(mismatch, SQLiteUpdateObservation{Operation: SQLiteOperationUpdate, Table: "settings", Journal: SQLiteJournalIdentity{Inode: 88}}); err != nil {
t.Fatal(err)
}
// When
_, armErr := tracker.ArmSQLiteHold(sqliteHoldTestJournal)
_, firstErr := tracker.BeginSQLiteCommitFinalization(first)
_, secondErr := tracker.BeginSQLiteCommitFinalization(second)
_, mismatchErr := tracker.BeginSQLiteCommitFinalization(mismatch)
// Then
if !errors.Is(armErr, ErrSQLiteHoldAmbiguousCandidate) || !errors.Is(firstErr, ErrSQLiteHoldAmbiguousCandidate) || !errors.Is(secondErr, ErrSQLiteHoldAmbiguousCandidate) {
t.Fatalf("arm=%v first=%v second=%v", armErr, firstErr, secondErr)
}
if !errors.Is(mismatchErr, ErrSQLiteHoldNotSelected) {
t.Fatalf("mismatched transaction cause = %v", mismatchErr)
}
}
func TestSQLiteHoldTrackerFinishDeletesAmbiguityCause(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
first, second := sqliteHoldTestTransaction(89), sqliteHoldTestTransaction(90)
for _, transaction := range []SQLiteTransaction{first, second} {
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
if err := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{Operation: SQLiteOperationUpdate, Table: "settings", Journal: sqliteHoldTestJournal}); err != nil {
t.Fatal(err)
}
}
if _, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal); !errors.Is(err, ErrSQLiteHoldAmbiguousCandidate) {
t.Fatal(err)
}
// When
if err := tracker.FinishSQLiteTransaction(first); err != nil {
t.Fatal(err)
}
_, lookupErr := tracker.BeginSQLiteCommitFinalization(first)
// Then
if !errors.Is(lookupErr, ErrSQLiteHoldNotSelected) {
t.Fatalf("finished transaction ambiguity cause = %v", lookupErr)
}
}
@@ -0,0 +1,265 @@
//go:build agentcompat && linux
package singleton
import (
"errors"
"testing"
)
func TestSQLiteHoldTrackerAbortsSessionWhenFutureDuplicateArrives(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
first := sqliteHoldTestTransaction(12)
if err := tracker.BeginSQLiteTransaction(first); err != nil {
t.Fatal(err)
}
recordSQLiteHoldTestUpdate(t, tracker, first)
session, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal)
if err != nil {
t.Fatal(err)
}
second := sqliteHoldTestTransaction(13)
if err := tracker.BeginSQLiteTransaction(second); err != nil {
t.Fatal(err)
}
// When
err = tracker.RecordSQLiteUpdate(second, SQLiteUpdateObservation{
Operation: SQLiteOperationUpdate, Table: "settings", Journal: sqliteHoldTestJournal,
})
// Then
if !errors.Is(err, ErrSQLiteHoldAmbiguousCandidate) {
t.Fatalf("expected ambiguous candidate error, got %v", err)
}
if err := tracker.ReleaseSQLiteHold(session); !errors.Is(err, ErrSQLiteHoldStaleSession) {
t.Fatalf("expected stale session after duplicate candidate, got %v", err)
}
if _, err := tracker.BeginSQLiteFinalization(first); !errors.Is(err, ErrSQLiteHoldNotSelected) {
t.Fatalf("expected aborted session, got %v", err)
}
}
func TestSQLiteHoldTrackerRejectsRepeatedRelease(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
transaction := sqliteHoldTestTransaction(21)
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
recordSQLiteHoldTestUpdate(t, tracker, transaction)
session, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal)
if err != nil {
t.Fatal(err)
}
if _, err := tracker.BeginSQLiteFinalization(transaction); err != nil {
t.Fatal(err)
}
if err := tracker.ReleaseSQLiteHold(session); err != nil {
t.Fatal(err)
}
// When
err = tracker.ReleaseSQLiteHold(session)
// Then
if !errors.Is(err, ErrSQLiteHoldStaleSession) {
t.Fatalf("expected stale repeated release, got %v", err)
}
}
func TestSQLiteHoldTrackerRejectsReleaseBeforeFinalization(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
session, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal)
if err != nil {
t.Fatal(err)
}
// When
err = tracker.ReleaseSQLiteHold(session)
// Then
if !errors.Is(err, ErrSQLiteHoldFinalizationNotStarted) {
t.Fatalf("expected finalization-not-started error, got %v", err)
}
}
func TestSQLiteHoldTrackerRejectsAbortAfterRelease(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
transaction := sqliteHoldTestTransaction(14)
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
recordSQLiteHoldTestUpdate(t, tracker, transaction)
session, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal)
if err != nil {
t.Fatal(err)
}
finalization, err := tracker.BeginSQLiteFinalization(transaction)
if err != nil {
t.Fatal(err)
}
if err := tracker.ReleaseSQLiteHold(session); err != nil {
t.Fatal(err)
}
// When
err = tracker.AbortSQLiteHold(session)
// Then
if !errors.Is(err, ErrSQLiteHoldStaleSession) {
t.Fatalf("expected stale session error, got %v", err)
}
if err := finalization.Wait(); err != nil {
t.Fatal(err)
}
}
func TestSQLiteHoldTrackerIgnoresFutureCandidateAfterRelease(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
first := sqliteHoldTestTransaction(15)
if err := tracker.BeginSQLiteTransaction(first); err != nil {
t.Fatal(err)
}
recordSQLiteHoldTestUpdate(t, tracker, first)
session, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal)
if err != nil {
t.Fatal(err)
}
finalization, err := tracker.BeginSQLiteFinalization(first)
if err != nil {
t.Fatal(err)
}
if err := tracker.ReleaseSQLiteHold(session); err != nil {
t.Fatal(err)
}
second := sqliteHoldTestTransaction(16)
if err := tracker.BeginSQLiteTransaction(second); err != nil {
t.Fatal(err)
}
// When
err = tracker.RecordSQLiteUpdate(second, SQLiteUpdateObservation{
Operation: SQLiteOperationUpdate, Table: "settings", Journal: sqliteHoldTestJournal,
})
// Then
if err != nil {
t.Fatal(err)
}
if err := finalization.Wait(); err != nil {
t.Fatal(err)
}
}
func TestSQLiteHoldTrackerDoesNotMatchJournalWithDifferentMountOrBirthTime(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
journal := SQLiteJournalIdentity{MountID: 2, DeviceMajor: 8, DeviceMinor: 1, Inode: 13, BirthSeconds: 10, BirthNanoseconds: 20}
transaction := sqliteHoldTestTransaction(17)
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
// When
if err := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{
Operation: SQLiteOperationInsert, Table: "settings", Journal: SQLiteJournalIdentity{
MountID: 3, DeviceMajor: 8, DeviceMinor: 1, Inode: 13, BirthSeconds: 10, BirthNanoseconds: 20,
},
}); err != nil {
t.Fatal(err)
}
session, err := tracker.ArmSQLiteHold(journal)
// Then
if err != nil {
t.Fatal(err)
}
if _, err := tracker.BeginSQLiteFinalization(transaction); !errors.Is(err, ErrSQLiteHoldNotSelected) {
t.Fatalf("expected mount mismatch not to select, got %v", err)
}
if err := tracker.AbortSQLiteHold(session); err != nil {
t.Fatal(err)
}
}
func TestSQLiteHoldTrackerDoesNotMatchJournalWithDifferentBirthTime(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
journal := SQLiteJournalIdentity{MountID: 2, DeviceMajor: 8, DeviceMinor: 1, Inode: 13, BirthSeconds: 10, BirthNanoseconds: 20}
transaction := sqliteHoldTestTransaction(18)
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
// When
if err := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{
Operation: SQLiteOperationDelete, Table: "settings", Journal: SQLiteJournalIdentity{
MountID: 2, DeviceMajor: 8, DeviceMinor: 1, Inode: 13, BirthSeconds: 10, BirthNanoseconds: 21,
},
}); err != nil {
t.Fatal(err)
}
session, err := tracker.ArmSQLiteHold(journal)
// Then
if err != nil {
t.Fatal(err)
}
if _, err := tracker.BeginSQLiteFinalization(transaction); !errors.Is(err, ErrSQLiteHoldNotSelected) {
t.Fatalf("expected birth-time mismatch not to select, got %v", err)
}
if err := tracker.AbortSQLiteHold(session); err != nil {
t.Fatal(err)
}
}
func TestSQLiteHoldTrackerRejectsDuplicateTransactionIdentity(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
transaction := sqliteHoldTestTransaction(19)
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
// When
err := tracker.BeginSQLiteTransaction(transaction)
// Then
if !errors.Is(err, ErrSQLiteHoldTransactionActive) {
t.Fatalf("expected transaction-active error, got %v", err)
}
}
func TestSQLiteHoldTrackerSelectsDeleteOperation(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
transaction := sqliteHoldTestTransaction(20)
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
if err := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{
Operation: SQLiteOperationDelete, Table: "settings", Journal: sqliteHoldTestJournal,
}); err != nil {
t.Fatal(err)
}
// When
session, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal)
finalization, finalizationErr := tracker.BeginSQLiteFinalization(transaction)
// Then
if err != nil || finalizationErr != nil {
t.Fatalf("arm=%v finalization=%v", err, finalizationErr)
}
if err := tracker.ReleaseSQLiteHold(session); err != nil {
t.Fatal(err)
}
if err := finalization.Wait(); err != nil {
t.Fatal(err)
}
}
@@ -0,0 +1,29 @@
//go:build agentcompat && linux
package singleton
type sqliteAttributionTerminalArbitration uint8
const (
sqliteAttributionTerminalNoReservation sqliteAttributionTerminalArbitration = iota
sqliteAttributionTerminalReleaseWon
sqliteAttributionTerminalRollbackGranted
sqliteAttributionTerminalRollbackReserved
)
func (tracker *SQLiteHoldTracker) ArbitrateSQLiteCommitWaitingTerminal(transaction SQLiteTransaction) sqliteAttributionTerminalArbitration {
tracker.mu.Lock()
defer tracker.mu.Unlock()
if !tracker.activeSessionHasSelectedTransactionLocked(transaction) {
if _, reserved := tracker.terminalArbitrations[transaction]; reserved {
return sqliteAttributionTerminalRollbackReserved
}
return sqliteAttributionTerminalNoReservation
}
if tracker.session.released {
return sqliteAttributionTerminalReleaseWon
}
tracker.abortSQLiteHoldLocked(ErrSQLiteHoldAborted)
tracker.terminalArbitrations[transaction] = struct{}{}
return sqliteAttributionTerminalRollbackGranted
}
@@ -0,0 +1,169 @@
//go:build agentcompat && linux
package singleton
import (
"errors"
"sync"
"sync/atomic"
)
var (
ErrSQLiteHoldSessionActive = errors.New("sqlite hold session already active")
ErrSQLiteHoldAmbiguousCandidate = errors.New("sqlite hold has multiple matching candidates")
ErrSQLiteHoldNotSelected = errors.New("sqlite hold transaction is not selected")
ErrSQLiteHoldFinalizationStarted = errors.New("sqlite hold finalization already started")
ErrSQLiteHoldFinalizationNotStarted = errors.New("sqlite hold finalization not started")
ErrSQLiteHoldStaleSession = errors.New("sqlite hold session is stale")
ErrSQLiteHoldAborted = errors.New("sqlite hold session aborted")
ErrSQLiteHoldTransactionActive = errors.New("sqlite hold transaction already active")
ErrSQLiteHoldAtomicWriteRecorded = errors.New("sqlite hold atomic write already recorded")
ErrSQLiteHoldEvidenceFrozen = errors.New("sqlite hold selected evidence is frozen")
ErrSQLiteHoldInvalidWaitTarget = errors.New("sqlite hold wait target is invalid")
)
type SQLiteConnectionIdentity uint64
type SQLiteTransactionIdentity uint64
type SQLiteJournalIdentity struct {
MountID uint64
DeviceMajor uint32
DeviceMinor uint32
Inode uint64
BirthSeconds int64
BirthNanoseconds uint32
}
type SQLiteOperation string
const (
SQLiteOperationInsert SQLiteOperation = "insert"
SQLiteOperationUpdate SQLiteOperation = "update"
SQLiteOperationDelete SQLiteOperation = "delete"
)
type SQLiteTransaction struct {
Connection SQLiteConnectionIdentity
Identity SQLiteTransactionIdentity
}
type SQLiteExecutionOrigin struct {
Operation SQLiteOperation
Table string
StackHash uint64
FirstNezhaFrame string
}
type SQLiteUpdateObservation struct {
Operation SQLiteOperation
Table string
Journal SQLiteJournalIdentity
}
type SQLiteWriteObservation struct {
Origin SQLiteExecutionOrigin
Update SQLiteUpdateObservation
}
type SQLiteHoldSelectionMode uint8
const (
SQLiteHoldSelectionModeKnownJournal SQLiteHoldSelectionMode = iota + 1
SQLiteHoldSelectionModeNextWriter
)
type SQLiteHoldSession struct{ identity uint64 }
func (session SQLiteHoldSession) ID() uint64 { return session.identity }
type SQLiteHoldSnapshot struct {
SessionID uint64
Mode SQLiteHoldSelectionMode
Selected bool
Transaction SQLiteTransaction
Operation SQLiteOperation
Table string
StackHash uint64
FirstNezhaFrame string
Journal SQLiteJournalIdentity
Finalizing bool
Released bool
Aborted bool
}
type SQLiteHoldFinalization struct {
done chan struct{}
err error
}
type SQLiteHoldWaitTarget uint8
const (
SQLiteHoldWaitSelected SQLiteHoldWaitTarget = iota + 1
SQLiteHoldWaitFinalizing
)
func (finalization *SQLiteHoldFinalization) Wait() error {
<-finalization.done
return finalization.err
}
func (finalization *SQLiteHoldFinalization) Released() bool {
select {
case <-finalization.done:
return finalization.err == nil
default:
return false
}
}
type sqliteHeldTransaction struct {
transaction SQLiteTransaction
origin SQLiteExecutionOrigin
update SQLiteUpdateObservation
hasUpdate bool
write SQLiteWriteObservation
hasWrite bool
finalizing bool
}
type sqliteHoldSessionState struct {
id SQLiteHoldSession
mode SQLiteHoldSelectionMode
journal SQLiteJournalIdentity
selected SQLiteTransaction
hasSelected bool
released bool
finalization *SQLiteHoldFinalization
notify chan struct{}
}
type sqliteHoldTerminalState struct {
id SQLiteHoldSession
selected SQLiteTransaction
hasSelected bool
released bool
cause error
}
// SQLiteHoldTracker linearizes state transitions; finalization waits only after releasing mu.
type SQLiteHoldTracker struct {
mu sync.Mutex
attributionEnabled *atomic.Bool
nextSession uint64
transactions map[SQLiteTransaction]*sqliteHeldTransaction
session *sqliteHoldSessionState
terminal *sqliteHoldTerminalState
causes map[SQLiteTransaction]error
terminalArbitrations map[SQLiteTransaction]struct{}
}
func NewSQLiteHoldTracker() *SQLiteHoldTracker {
return &SQLiteHoldTracker{transactions: make(map[SQLiteTransaction]*sqliteHeldTransaction), causes: make(map[SQLiteTransaction]error), terminalArbitrations: make(map[SQLiteTransaction]struct{})}
}
func newSQLiteAttributionHoldTracker(enabled *atomic.Bool) *SQLiteHoldTracker {
tracker := NewSQLiteHoldTracker()
tracker.attributionEnabled = enabled
return tracker
}
@@ -0,0 +1,149 @@
//go:build agentcompat && linux
package singleton
import (
"errors"
"testing"
)
func TestSQLiteHoldTrackerNextWriterSelectsFutureUpdateAndPublishesSnapshot(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
session, err := tracker.ArmNextSQLiteHold()
if err != nil {
t.Fatal(err)
}
before, err := tracker.SQLiteHoldSnapshot(session)
if err != nil {
t.Fatal(err)
}
transaction := sqliteHoldTestTransaction(22)
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
// When
if err := tracker.RecordSQLiteExecution(transaction, SQLiteExecutionOrigin{
Operation: SQLiteOperationInsert, Table: "settings", StackHash: 34, FirstNezhaFrame: "singleton.next",
}); err != nil {
t.Fatal(err)
}
if err := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{
Operation: SQLiteOperationInsert, Table: "settings", Journal: sqliteHoldTestJournal,
}); err != nil {
t.Fatal(err)
}
after, err := tracker.SQLiteHoldSnapshot(session)
// Then
if err != nil {
t.Fatal(err)
}
if before.SessionID != session.ID() || before.Mode != SQLiteHoldSelectionModeNextWriter || before.Selected {
t.Fatalf("unexpected unselected snapshot: %+v", before)
}
if !after.Selected || after.Transaction != transaction || after.Operation != SQLiteOperationInsert || after.Table != "settings" ||
after.StackHash != 34 || after.FirstNezhaFrame != "singleton.next" || after.Journal != sqliteHoldTestJournal {
t.Fatalf("unexpected selected snapshot: %+v", after)
}
}
func TestSQLiteHoldTrackerNextWriterSelectsOneActiveUpdate(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
transaction := sqliteHoldTestTransaction(23)
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
recordSQLiteHoldTestUpdate(t, tracker, transaction)
// When
session, err := tracker.ArmNextSQLiteHold()
snapshot, snapshotErr := tracker.SQLiteHoldSnapshot(session)
// Then
if err != nil || snapshotErr != nil {
t.Fatalf("arm=%v snapshot=%v", err, snapshotErr)
}
if !snapshot.Selected || snapshot.Journal != sqliteHoldTestJournal {
t.Fatalf("unexpected active selection snapshot: %+v", snapshot)
}
}
func TestSQLiteHoldTrackerNextWriterAbortsMultipleActiveUpdates(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
for _, transaction := range []SQLiteTransaction{sqliteHoldTestTransaction(24), sqliteHoldTestTransaction(25)} {
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
recordSQLiteHoldTestUpdate(t, tracker, transaction)
}
// When
_, err := tracker.ArmNextSQLiteHold()
// Then
if !errors.Is(err, ErrSQLiteHoldAmbiguousCandidate) {
t.Fatalf("expected ambiguous active updates, got %v", err)
}
}
func TestSQLiteHoldTrackerNextWriterAbortsDuplicateFutureUpdate(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
session, err := tracker.ArmNextSQLiteHold()
if err != nil {
t.Fatal(err)
}
first := sqliteHoldTestTransaction(26)
second := sqliteHoldTestTransaction(27)
for _, transaction := range []SQLiteTransaction{first, second} {
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
}
if err := tracker.RecordSQLiteUpdate(first, SQLiteUpdateObservation{
Operation: SQLiteOperationDelete, Table: "settings", Journal: sqliteHoldTestJournal,
}); err != nil {
t.Fatal(err)
}
// When
err = tracker.RecordSQLiteUpdate(second, SQLiteUpdateObservation{
Operation: SQLiteOperationUpdate, Table: "settings", Journal: sqliteHoldTestJournal,
})
// Then
if !errors.Is(err, ErrSQLiteHoldAmbiguousCandidate) {
t.Fatalf("expected ambiguous future updates, got %v", err)
}
if _, err := tracker.SQLiteHoldSnapshot(session); !errors.Is(err, ErrSQLiteHoldStaleSession) {
t.Fatalf("expected stale session after duplicate future update, got %v", err)
}
}
func TestSQLiteHoldTrackerNextWriterRequiresFinalizationBeforeReleaseAndStalesAfterAbort(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
session, err := tracker.ArmNextSQLiteHold()
if err != nil {
t.Fatal(err)
}
// When
releaseErr := tracker.ReleaseSQLiteHold(session)
abortErr := tracker.AbortSQLiteHold(session)
// Then
if !errors.Is(releaseErr, ErrSQLiteHoldFinalizationNotStarted) {
t.Fatalf("expected release rejection, got %v", releaseErr)
}
if abortErr != nil {
t.Fatal(abortErr)
}
if _, err := tracker.SQLiteHoldSnapshot(session); !errors.Is(err, ErrSQLiteHoldStaleSession) {
t.Fatalf("expected stale aborted session, got %v", err)
}
}
@@ -0,0 +1,238 @@
//go:build agentcompat && linux
package singleton
import (
"context"
"errors"
"testing"
)
func TestSQLiteHoldTrackerWaitSelectedAndFinalizingObserveTransitionsWithoutLostWakeup(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
session, err := tracker.ArmNextSQLiteHold()
if err != nil {
t.Fatal(err)
}
transaction := sqliteHoldTestTransaction(61)
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
recordSQLiteHoldTestUpdate(t, tracker, transaction)
// When
selected, selectedErr := tracker.WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitSelected)
if _, err := tracker.BeginSQLiteFinalization(transaction); err != nil {
t.Fatal(err)
}
finalizing, finalizingErr := tracker.WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitFinalizing)
// Then
if selectedErr != nil || !selected.Selected {
t.Fatalf("selected=%+v err=%v", selected, selectedErr)
}
if finalizingErr != nil || !finalizing.Finalizing {
t.Fatalf("finalizing=%+v err=%v", finalizing, finalizingErr)
}
}
func TestSQLiteHoldTrackerWaitCancellationLeavesSessionActive(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
session, err := tracker.ArmNextSQLiteHold()
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
// When
_, waitErr := tracker.WaitSQLiteHold(ctx, session, SQLiteHoldWaitSelected)
snapshot, snapshotErr := tracker.SQLiteHoldSnapshot(session)
// Then
if !errors.Is(waitErr, context.Canceled) {
t.Fatalf("wait error = %v, want context cancellation", waitErr)
}
if snapshotErr != nil || snapshot.Selected {
t.Fatalf("snapshot=%+v err=%v", snapshot, snapshotErr)
}
}
func TestSQLiteHoldTrackerWaitReturnsTerminalAbortAndAmbiguityCauses(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
session, err := tracker.ArmNextSQLiteHold()
if err != nil {
t.Fatal(err)
}
abortResult := make(chan error, 1)
go func() {
_, waitErr := tracker.WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitSelected)
abortResult <- waitErr
}()
// When
if err := tracker.AbortSQLiteHold(session); err != nil {
t.Fatal(err)
}
abortErr := <-abortResult
if _, err := tracker.ArmNextSQLiteHold(); err != nil {
t.Fatal(err)
}
first, second := sqliteHoldTestTransaction(62), sqliteHoldTestTransaction(63)
for _, transaction := range []SQLiteTransaction{first, second} {
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
}
if err := tracker.RecordSQLiteUpdate(first, SQLiteUpdateObservation{Operation: SQLiteOperationUpdate, Table: "settings", Journal: sqliteHoldTestJournal}); err != nil {
t.Fatal(err)
}
ambiguityErr := tracker.RecordSQLiteUpdate(second, SQLiteUpdateObservation{Operation: SQLiteOperationUpdate, Table: "settings", Journal: sqliteHoldTestJournal})
// Then
if !errors.Is(abortErr, ErrSQLiteHoldAborted) {
t.Fatalf("abort waiter error = %v", abortErr)
}
if !errors.Is(ambiguityErr, ErrSQLiteHoldAmbiguousCandidate) {
t.Fatalf("ambiguity error = %v", ambiguityErr)
}
if _, lookupErr := tracker.BeginSQLiteCommitFinalization(first); !errors.Is(lookupErr, ErrSQLiteHoldAmbiguousCandidate) {
t.Fatalf("ambiguity lookup error = %v", lookupErr)
}
}
func TestSQLiteHoldTrackerCommitFinalizationReturnsSelectedAbortCause(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
transaction := sqliteHoldTestTransaction(64)
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
recordSQLiteHoldTestUpdate(t, tracker, transaction)
session, err := tracker.ArmNextSQLiteHold()
if err != nil {
t.Fatal(err)
}
if err := tracker.AbortSQLiteHold(session); err != nil {
t.Fatal(err)
}
// When
_, selectedErr := tracker.BeginSQLiteCommitFinalization(transaction)
_, unselectedErr := tracker.BeginSQLiteCommitFinalization(sqliteHoldTestTransaction(65))
// Then
if !errors.Is(selectedErr, ErrSQLiteHoldAborted) {
t.Fatalf("selected aborted lookup error = %v", selectedErr)
}
if !errors.Is(unselectedErr, ErrSQLiteHoldNotSelected) {
t.Fatalf("unselected lookup error = %v", unselectedErr)
}
}
func TestSQLiteHoldTrackerCommitFinalizationPreservesCauseAcrossLaterLifecycle(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
first := sqliteHoldTestTransaction(70)
if err := tracker.BeginSQLiteTransaction(first); err != nil {
t.Fatal(err)
}
recordSQLiteHoldTestUpdate(t, tracker, first)
firstSession, err := tracker.ArmNextSQLiteHold()
if err != nil {
t.Fatal(err)
}
if err := tracker.AbortSQLiteHold(firstSession); err != nil {
t.Fatal(err)
}
second := sqliteHoldTestTransaction(71)
if err := tracker.BeginSQLiteTransaction(second); err != nil {
t.Fatal(err)
}
secondJournal := SQLiteJournalIdentity{Inode: 72}
if err := tracker.RecordSQLiteUpdate(second, SQLiteUpdateObservation{Operation: SQLiteOperationUpdate, Table: "settings", Journal: secondJournal}); err != nil {
t.Fatal(err)
}
secondSession, err := tracker.ArmSQLiteHold(secondJournal)
if err != nil {
t.Fatal(err)
}
if err := tracker.AbortSQLiteHold(secondSession); err != nil {
t.Fatal(err)
}
// When
_, lookupErr := tracker.BeginSQLiteCommitFinalization(first)
// Then
if !errors.Is(lookupErr, ErrSQLiteHoldAborted) {
t.Fatalf("first lifecycle abort cause after second lifecycle = %v", lookupErr)
}
}
func TestSQLiteHoldTrackerWaitSelectedReturnsAbortWhenSelectedTransactionFinishes(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
transaction := sqliteHoldTestTransaction(68)
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
recordSQLiteHoldTestUpdate(t, tracker, transaction)
session, err := tracker.ArmNextSQLiteHold()
if err != nil {
t.Fatal(err)
}
result := make(chan error, 1)
go func() {
_, waitErr := tracker.WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitFinalizing)
result <- waitErr
}()
// When
if err := tracker.FinishSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
// Then
if waitErr := <-result; !errors.Is(waitErr, ErrSQLiteHoldAborted) {
t.Fatalf("finished selected transaction wait error = %v", waitErr)
}
}
func TestSQLiteHoldTrackerWaitTreatsFinishedReleasedSessionAsSelectedAndFinalizing(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
transaction := sqliteHoldTestTransaction(69)
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
recordSQLiteHoldTestUpdate(t, tracker, transaction)
session, err := tracker.ArmNextSQLiteHold()
if err != nil {
t.Fatal(err)
}
if _, err := tracker.BeginSQLiteFinalization(transaction); err != nil {
t.Fatal(err)
}
if err := tracker.ReleaseSQLiteHold(session); err != nil {
t.Fatal(err)
}
if err := tracker.FinishSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
// When
selected, selectedErr := tracker.WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitSelected)
finalizing, finalizingErr := tracker.WaitSQLiteHold(context.Background(), session, SQLiteHoldWaitFinalizing)
// Then
if selectedErr != nil || !selected.Released || !selected.Selected {
t.Fatalf("selected=%+v err=%v", selected, selectedErr)
}
if finalizingErr != nil || !finalizing.Released || !finalizing.Finalizing {
t.Fatalf("finalizing=%+v err=%v", finalizing, finalizingErr)
}
}
@@ -0,0 +1,215 @@
//go:build agentcompat && linux
package singleton
import (
"errors"
"testing"
)
func sqliteHoldTestWrite(operation SQLiteOperation, table string, stackHash uint64, journal SQLiteJournalIdentity) SQLiteWriteObservation {
return SQLiteWriteObservation{
Origin: SQLiteExecutionOrigin{Operation: operation, Table: table, StackHash: stackHash, FirstNezhaFrame: "singleton.write"},
Update: SQLiteUpdateObservation{Operation: operation, Table: table, Journal: journal},
}
}
func TestSQLiteHoldTrackerRecordsAtomicFirstWriteAttribution(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
transaction := sqliteHoldTestTransaction(41)
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
session, err := tracker.ArmNextSQLiteHold()
if err != nil {
t.Fatal(err)
}
// When
err = tracker.RecordSQLiteWrite(transaction, sqliteHoldTestWrite(SQLiteOperationInsert, "settings", 51, sqliteHoldTestJournal))
snapshot, snapshotErr := tracker.SQLiteHoldSnapshot(session)
// Then
if err != nil || snapshotErr != nil {
t.Fatalf("write=%v snapshot=%v", err, snapshotErr)
}
if !snapshot.Selected || snapshot.Transaction != transaction || snapshot.Operation != SQLiteOperationInsert ||
snapshot.Table != "settings" || snapshot.StackHash != 51 || snapshot.FirstNezhaFrame != "singleton.write" ||
snapshot.Journal != sqliteHoldTestJournal {
t.Fatalf("unexpected atomic snapshot: %+v", snapshot)
}
}
func TestSQLiteHoldTrackerPreservesFirstWriteForRepeatedSameTransaction(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
transaction := sqliteHoldTestTransaction(42)
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
session, err := tracker.ArmNextSQLiteHold()
if err != nil {
t.Fatal(err)
}
if err := tracker.RecordSQLiteWrite(transaction, sqliteHoldTestWrite(SQLiteOperationUpdate, "first", 52, sqliteHoldTestJournal)); err != nil {
t.Fatal(err)
}
// When
err = tracker.RecordSQLiteWrite(transaction, sqliteHoldTestWrite(SQLiteOperationDelete, "later", 53, SQLiteJournalIdentity{Inode: 98}))
snapshot, snapshotErr := tracker.SQLiteHoldSnapshot(session)
// Then
if err != nil || snapshotErr != nil {
t.Fatalf("repeat=%v snapshot=%v", err, snapshotErr)
}
if snapshot.Operation != SQLiteOperationUpdate || snapshot.Table != "first" || snapshot.StackHash != 52 || snapshot.Journal != sqliteHoldTestJournal {
t.Fatalf("repeated callback changed first write: %+v", snapshot)
}
}
func TestSQLiteHoldTrackerAbortsDifferentAtomicWriterBeforeRelease(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
session, err := tracker.ArmNextSQLiteHold()
if err != nil {
t.Fatal(err)
}
first, second := sqliteHoldTestTransaction(43), sqliteHoldTestTransaction(44)
for _, transaction := range []SQLiteTransaction{first, second} {
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
}
if err := tracker.RecordSQLiteWrite(first, sqliteHoldTestWrite(SQLiteOperationInsert, "first", 54, sqliteHoldTestJournal)); err != nil {
t.Fatal(err)
}
// When
err = tracker.RecordSQLiteWrite(second, sqliteHoldTestWrite(SQLiteOperationDelete, "second", 55, sqliteHoldTestJournal))
// Then
if !errors.Is(err, ErrSQLiteHoldAmbiguousCandidate) {
t.Fatalf("expected ambiguity, got %v", err)
}
if _, err := tracker.SQLiteHoldSnapshot(session); !errors.Is(err, ErrSQLiteHoldStaleSession) {
t.Fatalf("expected stale aborted session, got %v", err)
}
}
func TestSQLiteHoldTrackerKnownJournalMakesFirstMismatchedWriteIneligible(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
transaction := sqliteHoldTestTransaction(45)
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
session, err := tracker.ArmSQLiteHold(sqliteHoldTestJournal)
if err != nil {
t.Fatal(err)
}
if err := tracker.RecordSQLiteWrite(transaction, sqliteHoldTestWrite(SQLiteOperationUpdate, "wrong", 56, SQLiteJournalIdentity{Inode: 97})); err != nil {
t.Fatal(err)
}
// When
err = tracker.RecordSQLiteWrite(transaction, sqliteHoldTestWrite(SQLiteOperationUpdate, "matching", 57, sqliteHoldTestJournal))
snapshot, snapshotErr := tracker.SQLiteHoldSnapshot(session)
// Then
if err != nil || snapshotErr != nil {
t.Fatalf("later write=%v snapshot=%v", err, snapshotErr)
}
if snapshot.Selected || snapshot.Journal != sqliteHoldTestJournal {
t.Fatalf("mismatched first write selected session: %+v", snapshot)
}
}
func TestSQLiteHoldTrackerRejectsAtomicWriteAfterFinalization(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
transaction := sqliteHoldTestTransaction(46)
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
session, err := tracker.ArmNextSQLiteHold()
if err != nil {
t.Fatal(err)
}
first := sqliteHoldTestWrite(SQLiteOperationInsert, "first", 58, sqliteHoldTestJournal)
if err := tracker.RecordSQLiteWrite(transaction, first); err != nil {
t.Fatal(err)
}
if _, err := tracker.BeginSQLiteFinalization(transaction); err != nil {
t.Fatal(err)
}
// When
err = tracker.RecordSQLiteWrite(transaction, sqliteHoldTestWrite(SQLiteOperationDelete, "later", 59, SQLiteJournalIdentity{Inode: 96}))
snapshot, snapshotErr := tracker.SQLiteHoldSnapshot(session)
// Then
if !errors.Is(err, ErrSQLiteHoldFinalizationStarted) || snapshotErr != nil {
t.Fatalf("write=%v snapshot=%v", err, snapshotErr)
}
if snapshot.Operation != first.Update.Operation || snapshot.Table != first.Update.Table || snapshot.StackHash != first.Origin.StackHash || snapshot.Journal != first.Update.Journal {
t.Fatalf("finalizing atomic write changed snapshot: %+v", snapshot)
}
}
func TestSQLiteHoldTrackerRejectsLegacyWritesAfterAtomicFirstWrite(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
transaction := sqliteHoldTestTransaction(47)
if err := tracker.BeginSQLiteTransaction(transaction); err != nil {
t.Fatal(err)
}
journalA := sqliteHoldTestJournal
journalB := SQLiteJournalIdentity{Inode: 95}
session, err := tracker.ArmSQLiteHold(journalA)
if err != nil {
t.Fatal(err)
}
first := sqliteHoldTestWrite(SQLiteOperationInsert, "atomic", 60, journalB)
if err := tracker.RecordSQLiteWrite(transaction, first); err != nil {
t.Fatal(err)
}
// When
executionErr := tracker.RecordSQLiteExecution(transaction, SQLiteExecutionOrigin{
Operation: SQLiteOperationUpdate, Table: "legacy", StackHash: 61, FirstNezhaFrame: "singleton.legacy",
})
updateErr := tracker.RecordSQLiteUpdate(transaction, SQLiteUpdateObservation{
Operation: SQLiteOperationUpdate, Table: "legacy", Journal: journalA,
})
snapshot, snapshotErr := tracker.SQLiteHoldSnapshot(session)
// Then
if !errors.Is(executionErr, ErrSQLiteHoldAtomicWriteRecorded) || !errors.Is(updateErr, ErrSQLiteHoldAtomicWriteRecorded) {
t.Fatalf("execution=%v update=%v", executionErr, updateErr)
}
if snapshotErr != nil {
t.Fatal(snapshotErr)
}
if snapshot.Selected || snapshot.Operation != "" || snapshot.Journal != journalA {
t.Fatalf("legacy write selected mismatched atomic transaction: %+v", snapshot)
}
if _, err := tracker.BeginSQLiteFinalization(transaction); !errors.Is(err, ErrSQLiteHoldNotSelected) {
t.Fatalf("expected mismatched atomic transaction to remain unselected, got %v", err)
}
}
func TestSQLiteHoldTrackerRejectsAtomicWriteForUnregisteredTransaction(t *testing.T) {
// Given
tracker := NewSQLiteHoldTracker()
transaction := sqliteHoldTestTransaction(48)
// When
err := tracker.RecordSQLiteWrite(transaction, sqliteHoldTestWrite(SQLiteOperationInsert, "settings", 62, sqliteHoldTestJournal))
// Then
if !errors.Is(err, ErrSQLiteHoldNotSelected) {
t.Fatalf("expected unregistered atomic write rejection, got %v", err)
}
}