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/gin-gonic/gin"
|
||||||
"github.com/patrickmn/go-cache"
|
"github.com/patrickmn/go-cache"
|
||||||
"gorm.io/driver/sqlite"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"sigs.k8s.io/yaml"
|
"sigs.k8s.io/yaml"
|
||||||
|
|
||||||
@@ -81,7 +80,7 @@ func InitFrontendTemplates() error {
|
|||||||
// InitDBFromPath 从给出的文件路径中加载数据库
|
// InitDBFromPath 从给出的文件路径中加载数据库
|
||||||
func InitDBFromPath(path string) error {
|
func InitDBFromPath(path string) error {
|
||||||
var err error
|
var err error
|
||||||
DB, err = gorm.Open(sqlite.Open(path), &gorm.Config{
|
DB, err = gorm.Open(openSQLiteDialector(path), &gorm.Config{
|
||||||
CreateBatchSize: 200,
|
CreateBatchSize: 200,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -126,16 +125,15 @@ func RecordTransferHourlyUsage(servers ...*model.Server) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for server := range slist {
|
for server := range slist {
|
||||||
|
_, _, deltaIn, deltaOut := server.TransferDeltaAndAdvance()
|
||||||
tx := model.Transfer{
|
tx := model.Transfer{
|
||||||
ServerID: server.ID,
|
ServerID: server.ID,
|
||||||
In: utils.SubUintChecked(server.State.NetInTransfer, server.PrevTransferInSnapshot),
|
In: deltaIn,
|
||||||
Out: utils.SubUintChecked(server.State.NetOutTransfer, server.PrevTransferOutSnapshot),
|
Out: deltaOut,
|
||||||
}
|
}
|
||||||
if tx.In == 0 && tx.Out == 0 {
|
if tx.In == 0 && tx.Out == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
server.PrevTransferInSnapshot = server.State.NetInTransfer
|
|
||||||
server.PrevTransferOutSnapshot = server.State.NetOutTransfer
|
|
||||||
tx.CreatedAt = nowTrimSeconds
|
tx.CreatedAt = nowTrimSeconds
|
||||||
txs = append(txs, tx)
|
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)
|
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 有自己的保留策略)
|
// CleanMonitorHistory 清理流量记录(TSDB 有自己的保留策略)
|
||||||
func CleanMonitorHistory() {
|
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