mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 09:40:12 +00:00
feat(agentcompat): add SQLite hold scoped attribution
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
+227
@@ -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)
|
||||
}
|
||||
+236
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user