Artifact 121399aa96c4954089e562ad1ceacbe2b16e1a34a5a4eb584859fa12a38f3655:
- File
internal/sync/serve_concurrency_test.go
— part of check-in
[f8bb98e196]
at
2026-10-23 05:07:11
on branch feat/horizon-cones
— fix(serve): serve reading requests in a read transaction
Every request ran in a write transaction (BEGIN IMMEDIATE) for as long as it was served, a horizon request read the classification memo in another, and each request created the table-sync schema on a repository without it. A clone arriving while another request was served waited for the write lock past the 5s busy timeout and got HTTP 500 "database is locked (SQLITE_BUSY)"; its client gave up after four attempts. The real-repository gate hit it with a horizon clone beside a dense one.
A request none of whose cards can write now runs in a read transaction (db.WithBoundReadTx), which takes a snapshot without the write lock; the memo read does too; loading synced tables only reads, and a schema card creates the schema it registers into. Requests that write keep their write transaction. v0.14.0 has the same write transaction per request: a clone from its server fails the same way while another connection holds the lock. (user: [email protected], size: 2656) [annotate] [blame] [check-ins using] [more...]
package sync import ( "context" "fmt" "net/http/httptest" "path/filepath" gosync "sync" "testing" "time" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/cone" "github.com/danmestas/go-libfossil/internal/repo" ) // TestServeClonesBesideAWriter has dense and horizon clones served at once // while another connection holds the origin's write lock longer than its // busy timeout, as a push's request does. A request that only reads must // not wait for that lock: every request had run in a write transaction, so // a clone arriving while another request was served got // "db.WithTx begin: database is locked (SQLITE_BUSY)" and gave up. func TestServeClonesBesideAWriter(t *testing.T) { if testing.Short() { t.Skip("holds the write lock past the busy timeout") } w := newMemoWorld(t, 7) for range 24 { w.commit() } for len(w.pending) > 0 { w.push() } // A repository fossil made has no table-sync schema; serving one must // not create it. if _, err := w.r.DB().Exec("DROP TABLE IF EXISTS _sync_schema"); err != nil { t.Fatal(err) } horizon := w.checkins[len(w.checkins)/2] server := httptest.NewServer(XferHandler(w.r, HandleSync)) t.Cleanup(server.Close) writer, err := repo.Open(w.r.Path()) if err != nil { t.Fatal(err) } t.Cleanup(func() { writer.Close() }) held, release := make(chan struct{}), make(chan struct{}) writerDone := make(chan error, 1) go func() { writerDone <- writer.WithTx(func(*db.Tx) error { close(held) select { case <-release: case <-time.After(time.Minute): } return nil }) }() <-held modes := map[string]*cone.Cone{"dense": nil} for name, depth := range map[string]cone.HorizonDepth{ "content": cone.DepthContent, "structural": cone.DepthStructural} { h, err := cone.NewHorizon(horizon, cone.RuleDescendants, depth) if err != nil { t.Fatal(err) } if modes[name], err = cone.NewWithHorizon(nil, nil, h); err != nil { t.Fatal(err) } } dir := t.TempDir() errs := make(chan error, len(modes)) var wg gosync.WaitGroup for name, c := range modes { wg.Add(1) go func() { defer wg.Done() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() r, _, err := Clone(ctx, filepath.Join(dir, name+".fossil"), &HTTPTransport{URL: server.URL}, CloneOpts{StoreCone: c}) if err != nil { errs <- fmt.Errorf("%s clone: %w", name, err) return } errs <- r.Close() }() } wg.Wait() close(release) if err := <-writerDone; err != nil { t.Fatalf("writer: %v", err) } close(errs) for err := range errs { if err != nil { t.Error(err) } } }