Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/durable-config-write-lock-first.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

A driver's rotated sign-in token, a settings save and a vehicle wake reservation no longer fail at once with "database is locked (SQLITE_BUSY)" while another part of FTW writes to the database. They now wait up to five seconds for it, as other writes already did. A MyUplink token lost this way could stop the driver signing in after the next restart.
9 changes: 9 additions & 0 deletions go/internal/state/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,15 @@ func (s *Store) durableConfigWriteContext(parent context.Context, write func(*sq
return err
}
defer tx.Rollback()
// Take the write lock before write reads anything. SQLite does not wait
// out busy_timeout for a transaction that read first and then writes: it
// returns SQLITE_BUSY at once while another connection is writing, since
// waiting there could deadlock. SaveDriverSecret and SaveConfiguration
// both read first, so a rotated OAuth token was refused by any
// concurrent write. The statement changes no row.
if _, err := tx.ExecContext(ctx, `UPDATE config SET value = value WHERE 0`); err != nil {
return err
}
err = write(tx)
if err == nil {
err = tx.Commit()
Expand Down
42 changes: 42 additions & 0 deletions go/internal/state/configuration_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package state

import (
"context"
"database/sql"
"errors"
"path/filepath"
"testing"
"time"
)

func TestConfigurationCommitIsDurableAndAtomic(t *testing.T) {
Expand Down Expand Up @@ -103,3 +105,43 @@ func TestBackupExportsTheCapturedConfiguration(t *testing.T) {
t.Fatalf("current revision: %+v %v", current, err)
}
}

// SaveDriverSecret and SaveConfiguration read before they write. SQLite then
// skips busy_timeout and refuses the write at once while another connection
// writes. A rotated OAuth token lost that way can lock a driver out at its
// next restart, so both must wait for the other writer instead.
func TestDurableConfigWriteWaitsForAnotherWriter(t *testing.T) {
s := freshStore(t)
rev, err := s.SaveConfiguration([]byte(`{"config":{"drivers":[{"name":"myuplink"}]}}`), 0, nil)
if err != nil {
t.Fatal(err)
}
holdWriteLock := func(hold time.Duration) {
t.Helper()
ctx := context.Background()
conn, err := s.db.Conn(ctx)
if err != nil {
t.Fatal(err)
}
if _, err := conn.ExecContext(ctx, `BEGIN IMMEDIATE`); err != nil {
t.Fatal(err)
}
time.AfterFunc(hold, func() {
_, _ = conn.ExecContext(ctx, `COMMIT`)
conn.Close()
})
}

holdWriteLock(300 * time.Millisecond)
if err := s.SaveDriverSecret("myuplink", "refresh_token", "RT-rotated"); err != nil {
t.Fatalf("rotated token refused while another connection wrote: %v", err)
}
if got, ok := s.LoadConfig(DriverSecretKey("myuplink", "refresh_token")); !ok || got != "RT-rotated" {
t.Fatalf("stored token = %q, %v", got, ok)
}

holdWriteLock(300 * time.Millisecond)
if _, err := s.SaveConfiguration([]byte(`{"config":{"drivers":[{"name":"myuplink"}]}}`), rev, nil); err != nil {
t.Fatalf("settings save refused while another connection wrote: %v", err)
}
}
Loading