[ARVADOS] updated: 1.3.0-2749-gf56c55a3c

Git user git at public.arvados.org
Mon Jul 6 18:50:01 UTC 2020


Summary of changes:
 tools/keep-exercise/keep-exercise.go | 10 ++++------
 1 file changed, 4 insertions(+), 6 deletions(-)

  discards  44f7b64ea522898dcef0e4b096b1f5b98140c322 (commit)
  discards  dddfa30b07b2584353df378528f84945faa3ad7f (commit)
  discards  ff22ba71afe839832943099cc1fe273197c45ec7 (commit)
  discards  34316951a4e4f8439940a7eda5f1b044565f072b (commit)
  discards  2c5417221843491727e4e5505012fc115e3bc7b0 (commit)
       via  f56c55a3ce637fefd0d63a4331e78f0777c074ed (commit)
       via  8ae218a2edbe5f579007cf07882a56d62125e48f (commit)
       via  1d361408863a5fab20067098f8a818f38b1ce8fa (commit)
       via  979d449cdca429e9a9edacb0ac1906af19afe3b5 (commit)

This update added new revisions after undoing existing revisions.  That is
to say, the old revision is not a strict subset of the new revision.  This
situation occurs when you --force push a change and generate a repository
containing something like this:

 * -- * -- B -- O -- O -- O (44f7b64ea522898dcef0e4b096b1f5b98140c322)
            \
             N -- N -- N (f56c55a3ce637fefd0d63a4331e78f0777c074ed)

When this happens we assume that you've already had alert emails for all
of the O revisions, and so we here report only the revisions in the N
branch from the common base, B.

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.


commit f56c55a3ce637fefd0d63a4331e78f0777c074ed
Author: Ward Vandewege <ward at curii.com>
Date:   Mon Jul 6 09:37:32 2020 -0400

    16585: Add --repeat argument to keep-exercise, which automatically repeats
    an expirement N times. Add loud warning when the read thread(s) are starved
    for blocks to read.
    
    Arvados-DCO-1.1-Signed-off-by: Ward Vandewege <ward at curii.com>

diff --git a/tools/keep-exercise/keep-exercise.go b/tools/keep-exercise/keep-exercise.go
index 163291c23..1567fcd6e 100644
--- a/tools/keep-exercise/keep-exercise.go
+++ b/tools/keep-exercise/keep-exercise.go
@@ -29,6 +29,7 @@ import (
 	"net/http"
 	"os"
 	"os/signal"
+	"sync"
 	"syscall"
 	"time"
 
@@ -51,8 +52,12 @@ var (
 	ServiceUUID   = flag.String("uuid", "", "specify UUID of a single advertised keep service to exercise")
 	getVersion    = flag.Bool("version", false, "Print version information and exit.")
 	RunTime       = flag.Duration("run-time", 0, "time to run (e.g. 60s), or 0 to run indefinitely (default)")
+	Repeat        = flag.Int("repeat", 1, "number of times to repeat the experiment (default 1)")
 )
 
+var summary string
+var csvHeader string
+
 func main() {
 	flag.Parse()
 
@@ -74,27 +79,51 @@ func main() {
 	}
 	kc.Want_replicas = *Replicas
 
-	transport := *(http.DefaultTransport.(*http.Transport))
-	transport.TLSClientConfig = arvadosclient.MakeTLSConfig(arv.ApiInsecure)
 	kc.HTTPClient = &http.Client{
-		Timeout:   10 * time.Minute,
-		Transport: &transport,
+		Timeout: 10 * time.Minute,
+		// It's not safe to copy *http.DefaultTransport
+		// because it has a mutex (which might be locked)
+		// protecting a private map (which might not be nil).
+		// So we build our own, using the Go 1.12 default
+		// values.
+		Transport: &http.Transport{
+			TLSClientConfig: arvadosclient.MakeTLSConfig(arv.ApiInsecure),
+		},
 	}
 
 	overrideServices(kc, stderr)
+	csvHeader = "Timestamp,Elapsed,Read (bytes),Avg Read Speed (MiB/s),Peak Read Speed (MiB/s),Written (bytes),Avg Write Speed (MiB/s),Peak Write Speed (MiB/s),Errors,ReadThreads,WriteThreads,VaryRequest,VaryThread,BlockSize,Replicas,StatsInterval,ServiceURL,ServiceUUID,RunTime,Repeat"
+
+	for i := 0; i < *Repeat; i++ {
+		runExperiment(kc, stderr)
+		stderr.Printf("*************************** experiment %d complete ******************************\n", i)
+		summary += fmt.Sprintf(",%d\n", i)
+	}
+	stderr.Println("Summary:")
+	stderr.Println()
+	fmt.Println(csvHeader + ",Experiment")
+	fmt.Println(summary)
+}
 
+func runExperiment(kc *keepclient.KeepClient, stderr *log.Logger) {
+	var wg sync.WaitGroup
 	nextLocator := make(chan string, *ReadThreads+*WriteThreads)
 
-	go countBeans(nextLocator, stderr)
+	wg.Add(1)
+	stopCh := make(chan struct{})
+	go countBeans(&wg, nextLocator, stopCh, stderr)
 	for i := 0; i < *WriteThreads; i++ {
 		nextBuf := make(chan []byte, 1)
-		go makeBufs(nextBuf, i, stderr)
-		go doWrites(kc, nextBuf, nextLocator, stderr)
+		wg.Add(1)
+		go makeBufs(&wg, nextBuf, i, stopCh, stderr)
+		wg.Add(1)
+		go doWrites(&wg, kc, nextBuf, nextLocator, stopCh, stderr)
 	}
 	for i := 0; i < *ReadThreads; i++ {
-		go doReads(kc, nextLocator, stderr)
+		wg.Add(1)
+		go doReads(&wg, kc, nextLocator, stopCh, stderr)
 	}
-	<-make(chan struct{})
+	wg.Wait()
 }
 
 // Send 1234 to bytesInChan when we receive 1234 bytes from keepstore.
@@ -104,11 +133,12 @@ var bytesOutChan = make(chan uint64)
 // Send struct{}{} to errorsChan when an error happens.
 var errorsChan = make(chan struct{})
 
-func countBeans(nextLocator chan string, stderr *log.Logger) {
+func countBeans(wg *sync.WaitGroup, nextLocator chan string, stopCh chan struct{}, stderr *log.Logger) {
+	defer wg.Done()
 	t0 := time.Now()
 	var tickChan <-chan time.Time
 	var endChan <-chan time.Time
-	c := make(chan os.Signal)
+	c := make(chan os.Signal, 1)
 	signal.Notify(c, os.Interrupt, syscall.SIGTERM)
 	if *StatsInterval > 0 {
 		tickChan = time.NewTicker(*StatsInterval).C
@@ -121,16 +151,16 @@ func countBeans(nextLocator chan string, stderr *log.Logger) {
 	var errors uint64
 	var rateIn, rateOut float64
 	var maxRateIn, maxRateOut float64
-	var abort, printCsv bool
+	var exit, abort, printCsv bool
 	csv := log.New(os.Stdout, "", 0)
-	csv.Println("Timestamp,Elapsed,Read (bytes),Avg Read Speed (MiB/s),Peak Read Speed (MiB/s),Written (bytes),Avg Write Speed (MiB/s),Peak Write Speed (MiB/s),Errors,ReadThreads,WriteThreads,VaryRequest,VaryThread,BlockSize,Replicas,StatsInterval,ServiceURL,ServiceUUID,RunTime")
+	csv.Println(csvHeader)
 	for {
 		select {
 		case <-tickChan:
 			printCsv = true
 		case <-endChan:
 			printCsv = true
-			abort = true
+			exit = true
 		case <-c:
 			printCsv = true
 			abort = true
@@ -152,7 +182,7 @@ func countBeans(nextLocator chan string, stderr *log.Logger) {
 			if rateOut > maxRateOut {
 				maxRateOut = rateOut
 			}
-			csv.Printf("%v,%v,%v,%.1f,%.1f,%v,%.1f,%.1f,%d,%d,%d,%t,%t,%d,%d,%s,%s,%s,%s",
+			line := fmt.Sprintf("%v,%v,%v,%.1f,%.1f,%v,%.1f,%.1f,%d,%d,%d,%t,%t,%d,%d,%s,%s,%s,%s,%d",
 				time.Now().Format("2006-01-02 15:04:05"),
 				elapsed,
 				bytesIn, rateIn, maxRateIn,
@@ -168,16 +198,26 @@ func countBeans(nextLocator chan string, stderr *log.Logger) {
 				*ServiceURL,
 				*ServiceUUID,
 				*RunTime,
+				*Repeat,
 			)
+			csv.Println(line)
+			if exit {
+				summary += line
+			}
 			printCsv = false
 		}
 		if abort {
 			os.Exit(0)
 		}
+		if exit {
+			close(stopCh)
+			break
+		}
 	}
 }
 
-func makeBufs(nextBuf chan<- []byte, threadID int, stderr *log.Logger) {
+func makeBufs(wg *sync.WaitGroup, nextBuf chan<- []byte, threadID int, stopCh <-chan struct{}, stderr *log.Logger) {
+	defer wg.Done()
 	buf := make([]byte, *BlockSize)
 	if *VaryThread {
 		binary.PutVarint(buf, int64(threadID))
@@ -194,46 +234,80 @@ func makeBufs(nextBuf chan<- []byte, threadID int, stderr *log.Logger) {
 			}
 			buf = append(rnd, buf[randSize:]...)
 		}
-		nextBuf <- buf
+		select {
+		case <-stopCh:
+			close(nextBuf)
+			return
+		case nextBuf <- buf:
+		}
 	}
 }
 
-func doWrites(kc *keepclient.KeepClient, nextBuf <-chan []byte, nextLocator chan<- string, stderr *log.Logger) {
-	for buf := range nextBuf {
-		locator, _, err := kc.PutB(buf)
-		if err != nil {
-			stderr.Print(err)
-			errorsChan <- struct{}{}
-			continue
-		}
-		bytesOutChan <- uint64(len(buf))
-		for cap(nextLocator) > len(nextLocator)+*WriteThreads {
-			// Give the readers something to do, unless
-			// they have lots queued up already.
-			nextLocator <- locator
+func doWrites(wg *sync.WaitGroup, kc *keepclient.KeepClient, nextBuf <-chan []byte, nextLocator chan<- string, stopCh <-chan struct{}, stderr *log.Logger) {
+	defer wg.Done()
+
+	for {
+		select {
+		case <-stopCh:
+			return
+		case buf := <-nextBuf:
+			locator, _, err := kc.PutB(buf)
+			if err != nil {
+				stderr.Print(err)
+				errorsChan <- struct{}{}
+				continue
+			}
+			select {
+			case <-stopCh:
+				return
+			case bytesOutChan <- uint64(len(buf)):
+			}
+			for cap(nextLocator) > len(nextLocator)+*WriteThreads {
+				select {
+				case <-stopCh:
+					return
+				case nextLocator <- locator:
+					// Give the readers something to do, unless
+					// they have lots queued up already.
+				}
+			}
 		}
 	}
 }
 
-func doReads(kc *keepclient.KeepClient, nextLocator <-chan string, stderr *log.Logger) {
-	for locator := range nextLocator {
-		rdr, size, url, err := kc.Get(locator)
-		if err != nil {
-			stderr.Print(err)
-			errorsChan <- struct{}{}
-			continue
-		}
-		n, err := io.Copy(ioutil.Discard, rdr)
-		rdr.Close()
-		if n != size || err != nil {
-			stderr.Printf("Got %d bytes (expected %d) from %s: %v", n, size, url, err)
-			errorsChan <- struct{}{}
-			continue
-			// Note we don't count the bytes received in
-			// partial/corrupt responses: we are measuring
-			// throughput, not resource consumption.
+func doReads(wg *sync.WaitGroup, kc *keepclient.KeepClient, nextLocator <-chan string, stopCh <-chan struct{}, stderr *log.Logger) {
+	defer wg.Done()
+
+	for {
+		select {
+		case <-stopCh:
+			return
+		case locator := <-nextLocator:
+			rdr, size, url, err := kc.Get(locator)
+			if err != nil {
+				stderr.Print(err)
+				errorsChan <- struct{}{}
+				continue
+			}
+			n, err := io.Copy(ioutil.Discard, rdr)
+			rdr.Close()
+			if n != size || err != nil {
+				stderr.Printf("Got %d bytes (expected %d) from %s: %v", n, size, url, err)
+				errorsChan <- struct{}{}
+				continue
+				// Note we don't count the bytes received in
+				// partial/corrupt responses: we are measuring
+				// throughput, not resource consumption.
+			}
+			select {
+			case <-stopCh:
+				return
+			case bytesInChan <- uint64(n):
+			}
+		default:
+			stderr.Printf("STARVED FOR BLOCKS TO READ - SLEEPING 100ms!")
+			time.Sleep(100 * time.Millisecond)
 		}
-		bytesInChan <- uint64(n)
 	}
 }
 

commit 8ae218a2edbe5f579007cf07882a56d62125e48f
Author: Tom Clegg <tom at tomclegg.ca>
Date:   Tue Jun 30 17:01:54 2020 -0400

    16534: Test goroutine safety.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at tomclegg.ca>

diff --git a/lib/controller/localdb/db.go b/lib/controller/localdb/db.go
index a864e32d4..4f64e6352 100644
--- a/lib/controller/localdb/db.go
+++ b/lib/controller/localdb/db.go
@@ -78,9 +78,15 @@ type transactionFinishFunc func(*error)
 func starttx(ctx context.Context, getdb func(context.Context) (*sql.DB, error)) (context.Context, transactionFinishFunc) {
 	txn := &transaction{getdb: getdb}
 	return context.WithValue(ctx, contextKeyTransaction, txn), func(err *error) {
-		// Ensure another goroutine can't open a transaction
-		// during/after finishtx().
-		txn.setup.Do(func() {})
+		txn.setup.Do(func() {
+			// Using (*sync.Once)Do() prevents a future
+			// call to currenttx() from opening a
+			// transaction which would never get committed
+			// or rolled back. If currenttx() hasn't been
+			// called before now, future calls will return
+			// this error.
+			txn.err = errors.New("refusing to start a transaction after wrapped function already returned")
+		})
 		if txn.tx == nil {
 			// we never [successfully] started a transaction
 			return
diff --git a/lib/controller/localdb/db_test.go b/lib/controller/localdb/db_test.go
index 39ac524a6..5bab86c60 100644
--- a/lib/controller/localdb/db_test.go
+++ b/lib/controller/localdb/db_test.go
@@ -7,8 +7,12 @@ package localdb
 import (
 	"context"
 	"database/sql"
+	"sync"
+	"sync/atomic"
 
+	"git.arvados.org/arvados.git/lib/config"
 	"git.arvados.org/arvados.git/sdk/go/arvados"
+	"git.arvados.org/arvados.git/sdk/go/ctxlog"
 	_ "github.com/lib/pq"
 	check "gopkg.in/check.v1"
 )
@@ -30,3 +34,65 @@ func testctx(c *check.C, db *sql.DB) (ctx context.Context, rollback func()) {
 		c.Check(tx.Rollback(), check.IsNil)
 	}
 }
+
+var _ = check.Suite(&DatabaseSuite{})
+
+type DatabaseSuite struct{}
+
+func (*DatabaseSuite) TestTransactionContext(c *check.C) {
+	cfg, err := config.NewLoader(nil, ctxlog.TestLogger(c)).Load()
+	c.Assert(err, check.IsNil)
+	cluster, err := cfg.GetCluster("")
+	c.Assert(err, check.IsNil)
+
+	var getterCalled int64
+	getter := func(context.Context) (*sql.DB, error) {
+		atomic.AddInt64(&getterCalled, 1)
+		return testdb(c, cluster), nil
+	}
+	wrapper := WrapCallsInTransactions(getter)
+	wrappedFunc := wrapper(func(ctx context.Context, opts interface{}) (interface{}, error) {
+		txes := make([]*sql.Tx, 20)
+		var wg sync.WaitGroup
+		for i := range txes {
+			i := i
+			wg.Add(1)
+			go func() {
+				// Concurrent calls to currenttx(),
+				// with different children of the same
+				// parent context, will all return the
+				// same transaction.
+				defer wg.Done()
+				ctx, cancel := context.WithCancel(ctx)
+				defer cancel()
+				tx, err := currenttx(ctx)
+				c.Check(err, check.IsNil)
+				txes[i] = tx
+			}()
+		}
+		wg.Wait()
+		for i := range txes[1:] {
+			c.Check(txes[i], check.Equals, txes[i+1])
+		}
+		return true, nil
+	})
+
+	ok, err := wrappedFunc(context.Background(), "blah")
+	c.Check(ok, check.Equals, true)
+	c.Check(err, check.IsNil)
+	c.Check(getterCalled, check.Equals, int64(1))
+
+	// When a wrapped func returns without calling currenttx(),
+	// calling currenttx() later shouldn't start a new
+	// transaction.
+	var savedctx context.Context
+	ok, err = wrapper(func(ctx context.Context, opts interface{}) (interface{}, error) {
+		savedctx = ctx
+		return true, nil
+	})(context.Background(), "blah")
+	c.Check(ok, check.Equals, true)
+	c.Check(err, check.IsNil)
+	tx, err := currenttx(savedctx)
+	c.Check(tx, check.IsNil)
+	c.Check(err, check.NotNil)
+}

commit 1d361408863a5fab20067098f8a818f38b1ce8fa
Author: Tom Clegg <tom at tomclegg.ca>
Date:   Tue Jun 30 16:29:02 2020 -0400

    16534: Rename Transaction -> ContextWithTransaction.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at tomclegg.ca>

diff --git a/lib/controller/localdb/db.go b/lib/controller/localdb/db.go
index f9c5d19e2..a864e32d4 100644
--- a/lib/controller/localdb/db.go
+++ b/lib/controller/localdb/db.go
@@ -30,10 +30,10 @@ func WrapCallsInTransactions(getdb func(context.Context) (*sql.DB, error)) func(
 	}
 }
 
-// WithTransaction returns a child context in which the given
+// ContextWithTransaction returns a child context in which the given
 // transaction will be used by any localdb API call that needs one.
 // The caller is responsible for calling Commit or Rollback on tx.
-func Transaction(ctx context.Context, tx *sql.Tx) context.Context {
+func ContextWithTransaction(ctx context.Context, tx *sql.Tx) context.Context {
 	txn := &transaction{tx: tx}
 	txn.setup.Do(func() {})
 	return context.WithValue(ctx, contextKeyTransaction, txn)
diff --git a/lib/controller/localdb/db_test.go b/lib/controller/localdb/db_test.go
index 5187dfecd..39ac524a6 100644
--- a/lib/controller/localdb/db_test.go
+++ b/lib/controller/localdb/db_test.go
@@ -26,7 +26,7 @@ func testdb(c *check.C, cluster *arvados.Cluster) *sql.DB {
 func testctx(c *check.C, db *sql.DB) (ctx context.Context, rollback func()) {
 	tx, err := db.Begin()
 	c.Assert(err, check.IsNil)
-	return Transaction(context.Background(), tx), func() {
+	return ContextWithTransaction(context.Background(), tx), func() {
 		c.Check(tx.Rollback(), check.IsNil)
 	}
 }

commit 979d449cdca429e9a9edacb0ac1906af19afe3b5
Author: Tom Clegg <tom at tomclegg.ca>
Date:   Fri Jun 26 17:15:30 2020 -0400

    16534: Add database access in lib/controller/localdb.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at tomclegg.ca>

diff --git a/lib/controller/federation.go b/lib/controller/federation.go
index ac239fb9b..aceaba808 100644
--- a/lib/controller/federation.go
+++ b/lib/controller/federation.go
@@ -152,7 +152,7 @@ type CurrentUser struct {
 // non-nil, true, nil -- if the token is valid
 func (h *Handler) validateAPItoken(req *http.Request, token string) (*CurrentUser, bool, error) {
 	user := CurrentUser{Authorization: arvados.APIClientAuthorization{APIToken: token}}
-	db, err := h.db(req)
+	db, err := h.db(req.Context())
 	if err != nil {
 		ctxlog.FromContext(req.Context()).WithError(err).Debugf("validateAPItoken(%s): database error", token)
 		return nil, false, err
@@ -189,7 +189,7 @@ func (h *Handler) validateAPItoken(req *http.Request, token string) (*CurrentUse
 }
 
 func (h *Handler) createAPItoken(req *http.Request, userUUID string, scopes []string) (*arvados.APIClientAuthorization, error) {
-	db, err := h.db(req)
+	db, err := h.db(req.Context())
 	if err != nil {
 		return nil, err
 	}
diff --git a/lib/controller/federation/federation_test.go b/lib/controller/federation/federation_test.go
index f57d82784..256afc8e6 100644
--- a/lib/controller/federation/federation_test.go
+++ b/lib/controller/federation/federation_test.go
@@ -64,7 +64,7 @@ func (s *FederationSuite) addDirectRemote(c *check.C, id string, backend backend
 
 func (s *FederationSuite) addHTTPRemote(c *check.C, id string, backend backend) {
 	srv := httpserver.Server{Addr: ":"}
-	srv.Handler = router.New(backend)
+	srv.Handler = router.New(backend, nil)
 	c.Check(srv.Start(), check.IsNil)
 	s.cluster.RemoteClusters[id] = arvados.RemoteCluster{
 		Scheme: "http",
diff --git a/lib/controller/handler.go b/lib/controller/handler.go
index 01f216163..cc0624642 100644
--- a/lib/controller/handler.go
+++ b/lib/controller/handler.go
@@ -16,9 +16,11 @@ import (
 	"time"
 
 	"git.arvados.org/arvados.git/lib/controller/federation"
+	"git.arvados.org/arvados.git/lib/controller/localdb"
 	"git.arvados.org/arvados.git/lib/controller/railsproxy"
 	"git.arvados.org/arvados.git/lib/controller/router"
 	"git.arvados.org/arvados.git/sdk/go/arvados"
+	"git.arvados.org/arvados.git/sdk/go/ctxlog"
 	"git.arvados.org/arvados.git/sdk/go/health"
 	"git.arvados.org/arvados.git/sdk/go/httpserver"
 	_ "github.com/lib/pq"
@@ -63,7 +65,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
 
 func (h *Handler) CheckHealth() error {
 	h.setupOnce.Do(h.setup)
-	_, _, err := railsproxy.FindRailsAPI(h.Cluster)
+	_, err := h.db(context.TODO())
+	if err != nil {
+		return err
+	}
+	_, _, err = railsproxy.FindRailsAPI(h.Cluster)
 	return err
 }
 
@@ -78,10 +84,10 @@ func (h *Handler) setup() {
 	mux.Handle("/_health/", &health.Handler{
 		Token:  h.Cluster.ManagementToken,
 		Prefix: "/_health/",
-		Routes: health.Routes{"ping": func() error { _, err := h.db(&http.Request{}); return err }},
+		Routes: health.Routes{"ping": func() error { _, err := h.db(context.TODO()); return err }},
 	})
 
-	rtr := router.New(federation.New(h.Cluster))
+	rtr := router.New(federation.New(h.Cluster), localdb.WrapCallsInTransactions(h.db))
 	mux.Handle("/arvados/v1/config", rtr)
 	mux.Handle("/"+arvados.EndpointUserAuthenticate.Path, rtr)
 
@@ -115,7 +121,7 @@ func (h *Handler) setup() {
 
 var errDBConnection = errors.New("database connection error")
 
-func (h *Handler) db(req *http.Request) (*sql.DB, error) {
+func (h *Handler) db(ctx context.Context) (*sql.DB, error) {
 	h.pgdbMtx.Lock()
 	defer h.pgdbMtx.Unlock()
 	if h.pgdb != nil {
@@ -124,14 +130,14 @@ func (h *Handler) db(req *http.Request) (*sql.DB, error) {
 
 	db, err := sql.Open("postgres", h.Cluster.PostgreSQL.Connection.String())
 	if err != nil {
-		httpserver.Logger(req).WithError(err).Error("postgresql connect failed")
+		ctxlog.FromContext(ctx).WithError(err).Error("postgresql connect failed")
 		return nil, errDBConnection
 	}
 	if p := h.Cluster.PostgreSQL.ConnectionPool; p > 0 {
 		db.SetMaxOpenConns(p)
 	}
 	if err := db.Ping(); err != nil {
-		httpserver.Logger(req).WithError(err).Error("postgresql connect succeeded but ping failed")
+		ctxlog.FromContext(ctx).WithError(err).Error("postgresql connect scuceeded but ping failed")
 		return nil, errDBConnection
 	}
 	h.pgdb = db
diff --git a/lib/controller/localdb/db.go b/lib/controller/localdb/db.go
new file mode 100644
index 000000000..f9c5d19e2
--- /dev/null
+++ b/lib/controller/localdb/db.go
@@ -0,0 +1,110 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+	"sync"
+
+	"git.arvados.org/arvados.git/lib/controller/router"
+	"git.arvados.org/arvados.git/sdk/go/ctxlog"
+)
+
+// WrapCallsInTransactions returns a call wrapper (suitable for
+// assigning to router.router.WrapCalls) that starts a new transaction
+// for each API call, and commits only if the call succeeds.
+//
+// The wrapper calls getdb() to get a database handle before each API
+// call.
+func WrapCallsInTransactions(getdb func(context.Context) (*sql.DB, error)) func(router.RoutableFunc) router.RoutableFunc {
+	return func(origFunc router.RoutableFunc) router.RoutableFunc {
+		return func(ctx context.Context, opts interface{}) (_ interface{}, err error) {
+			ctx, finishtx := starttx(ctx, getdb)
+			defer finishtx(&err)
+			return origFunc(ctx, opts)
+		}
+	}
+}
+
+// WithTransaction returns a child context in which the given
+// transaction will be used by any localdb API call that needs one.
+// The caller is responsible for calling Commit or Rollback on tx.
+func Transaction(ctx context.Context, tx *sql.Tx) context.Context {
+	txn := &transaction{tx: tx}
+	txn.setup.Do(func() {})
+	return context.WithValue(ctx, contextKeyTransaction, txn)
+}
+
+type contextKeyT string
+
+var contextKeyTransaction = contextKeyT("transaction")
+
+type transaction struct {
+	tx    *sql.Tx
+	err   error
+	getdb func(context.Context) (*sql.DB, error)
+	setup sync.Once
+}
+
+type transactionFinishFunc func(*error)
+
+// starttx returns a new child context that can be used with
+// currenttx(). It does not open a database transaction until the
+// first call to currenttx().
+//
+// The caller must eventually call the returned finishtx() func to
+// commit or rollback the transaction, if any.
+//
+//	func example(ctx context.Context) (err error) {
+//		ctx, finishtx := starttx(ctx, dber)
+//		defer finishtx(&err)
+//		// ...
+//		tx, err := currenttx(ctx)
+//		if err != nil {
+//			return fmt.Errorf("example: %s", err)
+//		}
+//		return tx.ExecContext(...)
+//	}
+//
+// If *err is nil, finishtx() commits the transaction and assigns any
+// resulting error to *err.
+//
+// If *err is non-nil, finishtx() rolls back the transaction, and
+// does not modify *err.
+func starttx(ctx context.Context, getdb func(context.Context) (*sql.DB, error)) (context.Context, transactionFinishFunc) {
+	txn := &transaction{getdb: getdb}
+	return context.WithValue(ctx, contextKeyTransaction, txn), func(err *error) {
+		// Ensure another goroutine can't open a transaction
+		// during/after finishtx().
+		txn.setup.Do(func() {})
+		if txn.tx == nil {
+			// we never [successfully] started a transaction
+			return
+		}
+		if *err != nil {
+			ctxlog.FromContext(ctx).Debug("rollback")
+			txn.tx.Rollback()
+			return
+		}
+		*err = txn.tx.Commit()
+	}
+}
+
+func currenttx(ctx context.Context) (*sql.Tx, error) {
+	txn, ok := ctx.Value(contextKeyTransaction).(*transaction)
+	if !ok {
+		return nil, errors.New("bug: there is no transaction in this context")
+	}
+	txn.setup.Do(func() {
+		if db, err := txn.getdb(ctx); err != nil {
+			txn.err = err
+		} else {
+			txn.tx, txn.err = db.Begin()
+		}
+	})
+	return txn.tx, txn.err
+}
diff --git a/lib/controller/localdb/db_test.go b/lib/controller/localdb/db_test.go
new file mode 100644
index 000000000..5187dfecd
--- /dev/null
+++ b/lib/controller/localdb/db_test.go
@@ -0,0 +1,32 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+	"context"
+	"database/sql"
+
+	"git.arvados.org/arvados.git/sdk/go/arvados"
+	_ "github.com/lib/pq"
+	check "gopkg.in/check.v1"
+)
+
+// testdb returns a DB connection for the given cluster config.
+func testdb(c *check.C, cluster *arvados.Cluster) *sql.DB {
+	db, err := sql.Open("postgres", cluster.PostgreSQL.Connection.String())
+	c.Assert(err, check.IsNil)
+	return db
+}
+
+// testctx returns a context suitable for running a test case in a new
+// transaction, and a rollback func which the caller should call after
+// the test.
+func testctx(c *check.C, db *sql.DB) (ctx context.Context, rollback func()) {
+	tx, err := db.Begin()
+	c.Assert(err, check.IsNil)
+	return Transaction(context.Background(), tx), func() {
+		c.Check(tx.Rollback(), check.IsNil)
+	}
+}
diff --git a/lib/controller/localdb/docker_test.go b/lib/controller/localdb/docker_test.go
new file mode 100644
index 000000000..90c98b7d5
--- /dev/null
+++ b/lib/controller/localdb/docker_test.go
@@ -0,0 +1,68 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package localdb
+
+import (
+	"io"
+	"net"
+	"strings"
+
+	"git.arvados.org/arvados.git/sdk/go/arvados"
+	check "gopkg.in/check.v1"
+)
+
+type pgproxy struct {
+	net.Listener
+}
+
+// newPgProxy sets up a TCP proxy, listening on all interfaces, that
+// forwards all connections to the cluster's PostgreSQL server. This
+// allows the caller to run a docker container that can connect to a
+// postgresql instance that listens on the test host's loopback
+// interface.
+//
+// Caller is responsible for calling Close() on the returned pgproxy.
+func newPgProxy(c *check.C, cluster *arvados.Cluster) *pgproxy {
+	host := cluster.PostgreSQL.Connection["host"]
+	if host == "" {
+		host = "localhost"
+	}
+	port := cluster.PostgreSQL.Connection["port"]
+	if port == "" {
+		port = "5432"
+	}
+	target := net.JoinHostPort(host, port)
+
+	ln, err := net.Listen("tcp", ":")
+	c.Assert(err, check.IsNil)
+	go func() {
+		for {
+			downstream, err := ln.Accept()
+			if err != nil && strings.Contains(err.Error(), "use of closed network connection") {
+				return
+			}
+			c.Assert(err, check.IsNil)
+			go func() {
+				c.Logf("pgproxy accepted connection from %s", downstream.RemoteAddr().String())
+				defer downstream.Close()
+				upstream, err := net.Dial("tcp", target)
+				if err != nil {
+					c.Logf("net.Dial(%q): %s", target, err)
+					return
+				}
+				defer upstream.Close()
+				go io.Copy(downstream, upstream)
+				io.Copy(upstream, downstream)
+			}()
+		}
+	}()
+	c.Logf("pgproxy listening at %s", ln.Addr().String())
+	return &pgproxy{Listener: ln}
+}
+
+func (proxy *pgproxy) Port() string {
+	_, port, _ := net.SplitHostPort(proxy.Addr().String())
+	return port
+}
diff --git a/lib/controller/localdb/login.go b/lib/controller/localdb/login.go
index 905cfed15..1cd349a10 100644
--- a/lib/controller/localdb/login.go
+++ b/lib/controller/localdb/login.go
@@ -6,9 +6,13 @@ package localdb
 
 import (
 	"context"
+	"database/sql"
+	"encoding/json"
 	"errors"
+	"fmt"
 	"net/http"
 	"net/url"
+	"strings"
 
 	"git.arvados.org/arvados.git/lib/controller/rpc"
 	"git.arvados.org/arvados.git/sdk/go/arvados"
@@ -96,9 +100,9 @@ func noopLogout(cluster *arvados.Cluster, opts arvados.LogoutOptions) (arvados.L
 	return arvados.LogoutResponse{RedirectLocation: target}, nil
 }
 
-func createAPIClientAuthorization(ctx context.Context, conn *rpc.Conn, rootToken string, authinfo rpc.UserSessionAuthInfo) (arvados.APIClientAuthorization, error) {
+func createAPIClientAuthorization(ctx context.Context, conn *rpc.Conn, rootToken string, authinfo rpc.UserSessionAuthInfo) (resp arvados.APIClientAuthorization, err error) {
 	ctxRoot := auth.NewContext(ctx, &auth.Credentials{Tokens: []string{rootToken}})
-	resp, err := conn.UserSessionCreate(ctxRoot, rpc.UserSessionCreateOptions{
+	newsession, err := conn.UserSessionCreate(ctxRoot, rpc.UserSessionCreateOptions{
 		// Send a fake ReturnTo value instead of the caller's
 		// opts.ReturnTo. We won't follow the resulting
 		// redirect target anyway.
@@ -106,12 +110,36 @@ func createAPIClientAuthorization(ctx context.Context, conn *rpc.Conn, rootToken
 		AuthInfo: authinfo,
 	})
 	if err != nil {
-		return arvados.APIClientAuthorization{}, err
+		return
 	}
-	target, err := url.Parse(resp.RedirectLocation)
+	target, err := url.Parse(newsession.RedirectLocation)
 	if err != nil {
-		return arvados.APIClientAuthorization{}, err
+		return
 	}
 	token := target.Query().Get("api_token")
-	return conn.APIClientAuthorizationCurrent(auth.NewContext(ctx, auth.NewCredentials(token)), arvados.GetOptions{})
+	tx, err := currenttx(ctx)
+	if err != nil {
+		return
+	}
+	tokensecret := token
+	if strings.Contains(token, "/") {
+		tokenparts := strings.Split(token, "/")
+		if len(tokenparts) >= 3 {
+			tokensecret = tokenparts[2]
+		}
+	}
+	var exp sql.NullString
+	var scopes []byte
+	err = tx.QueryRowContext(ctx, "select uuid, api_token, expires_at, scopes from api_client_authorizations where api_token=$1", tokensecret).Scan(&resp.UUID, &resp.APIToken, &exp, &scopes)
+	if err != nil {
+		return
+	}
+	resp.ExpiresAt = exp.String
+	if len(scopes) > 0 {
+		err = json.Unmarshal(scopes, &resp.Scopes)
+		if err != nil {
+			return resp, fmt.Errorf("unmarshal scopes: %s", err)
+		}
+	}
+	return
 }
diff --git a/lib/controller/localdb/login_ldap_docker_test.go b/lib/controller/localdb/login_ldap_docker_test.go
index 79b5f1615..3cbf14fe0 100644
--- a/lib/controller/localdb/login_ldap_docker_test.go
+++ b/lib/controller/localdb/login_ldap_docker_test.go
@@ -24,10 +24,13 @@ func (s *LDAPSuite) TestLoginLDAPViaPAM(c *check.C) {
 	if !haveDocker() {
 		c.Skip("skipping docker test because docker is not available")
 	}
+	pgproxy := newPgProxy(c, s.cluster)
+	defer pgproxy.Close()
+
 	cmd := exec.Command("bash", "login_ldap_docker_test.sh")
 	cmd.Stdout = os.Stderr
 	cmd.Stderr = os.Stderr
-	cmd.Env = append(os.Environ(), "config_method=pam")
+	cmd.Env = append(os.Environ(), "config_method=pam", "pgport="+pgproxy.Port())
 	err := cmd.Run()
 	c.Check(err, check.IsNil)
 }
@@ -39,10 +42,13 @@ func (s *LDAPSuite) TestLoginLDAPBuiltin(c *check.C) {
 	if !haveDocker() {
 		c.Skip("skipping docker test because docker is not available")
 	}
+	pgproxy := newPgProxy(c, s.cluster)
+	defer pgproxy.Close()
+
 	cmd := exec.Command("bash", "login_ldap_docker_test.sh")
 	cmd.Stdout = os.Stderr
 	cmd.Stderr = os.Stderr
-	cmd.Env = append(os.Environ(), "config_method=ldap")
+	cmd.Env = append(os.Environ(), "config_method=ldap", "pgport="+pgproxy.Port())
 	err := cmd.Run()
 	c.Check(err, check.IsNil)
 }
diff --git a/lib/controller/localdb/login_ldap_docker_test.sh b/lib/controller/localdb/login_ldap_docker_test.sh
index 4e0679f62..0225f2046 100755
--- a/lib/controller/localdb/login_ldap_docker_test.sh
+++ b/lib/controller/localdb/login_ldap_docker_test.sh
@@ -1,5 +1,9 @@
 #!/bin/bash
 
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
 # This script demonstrates using LDAP for Arvados user authentication.
 #
 # It configures arvados controller in a docker container, optionally
@@ -74,6 +78,7 @@ Clusters:
       Connection:
         client_encoding: utf8
         host: ${hostname}
+        port: "${pgport}"
         dbname: arvados_test
         user: arvados
         password: insecure_arvados_test
diff --git a/lib/controller/localdb/login_ldap_test.go b/lib/controller/localdb/login_ldap_test.go
index 9a8f83f85..64ae58bce 100644
--- a/lib/controller/localdb/login_ldap_test.go
+++ b/lib/controller/localdb/login_ldap_test.go
@@ -6,6 +6,7 @@ package localdb
 
 import (
 	"context"
+	"database/sql"
 	"encoding/json"
 	"net"
 	"net/http"
@@ -26,6 +27,11 @@ type LDAPSuite struct {
 	cluster *arvados.Cluster
 	ctrl    *ldapLoginController
 	ldap    *godap.LDAPServer // fake ldap server that accepts auth goodusername/goodpassword
+	db      *sql.DB
+
+	// transaction context
+	ctx      context.Context
+	rollback func()
 }
 
 func (s *LDAPSuite) TearDownSuite(c *check.C) {
@@ -85,10 +91,21 @@ func (s *LDAPSuite) SetUpSuite(c *check.C) {
 		Cluster:    s.cluster,
 		RailsProxy: railsproxy.NewConn(s.cluster),
 	}
+	s.db = testdb(c, s.cluster)
+}
+
+func (s *LDAPSuite) SetUpTest(c *check.C) {
+	s.ctx, s.rollback = testctx(c, s.db)
+}
+
+func (s *LDAPSuite) TearDownTest(c *check.C) {
+	s.rollback()
 }
 
 func (s *LDAPSuite) TestLoginSuccess(c *check.C) {
-	resp, err := s.ctrl.UserAuthenticate(context.Background(), arvados.UserAuthenticateOptions{
+	conn := NewConn(s.cluster)
+	conn.loginController = s.ctrl
+	resp, err := conn.UserAuthenticate(s.ctx, arvados.UserAuthenticateOptions{
 		Username: "goodusername",
 		Password: "goodpassword",
 	})
@@ -97,7 +114,7 @@ func (s *LDAPSuite) TestLoginSuccess(c *check.C) {
 	c.Check(resp.UUID, check.Matches, `zzzzz-gj3su-.*`)
 	c.Check(resp.Scopes, check.DeepEquals, []string{"all"})
 
-	ctx := auth.NewContext(context.Background(), &auth.Credentials{Tokens: []string{"v2/" + resp.UUID + "/" + resp.APIToken}})
+	ctx := auth.NewContext(s.ctx, &auth.Credentials{Tokens: []string{"v2/" + resp.UUID + "/" + resp.APIToken}})
 	user, err := railsproxy.NewConn(s.cluster).UserGetCurrent(ctx, arvados.GetOptions{})
 	c.Check(err, check.IsNil)
 	c.Check(user.Email, check.Equals, "goodusername at example.com")
@@ -107,7 +124,7 @@ func (s *LDAPSuite) TestLoginSuccess(c *check.C) {
 func (s *LDAPSuite) TestLoginFailure(c *check.C) {
 	// search returns no results
 	s.cluster.Login.LDAP.SearchBase = "dc=example,dc=invalid"
-	resp, err := s.ctrl.UserAuthenticate(context.Background(), arvados.UserAuthenticateOptions{
+	resp, err := s.ctrl.UserAuthenticate(s.ctx, arvados.UserAuthenticateOptions{
 		Username: "goodusername",
 		Password: "goodpassword",
 	})
@@ -120,7 +137,7 @@ func (s *LDAPSuite) TestLoginFailure(c *check.C) {
 
 	// search returns result, but auth fails
 	s.cluster.Login.LDAP.SearchBase = "dc=example,dc=com"
-	resp, err = s.ctrl.UserAuthenticate(context.Background(), arvados.UserAuthenticateOptions{
+	resp, err = s.ctrl.UserAuthenticate(s.ctx, arvados.UserAuthenticateOptions{
 		Username: "badusername",
 		Password: "badpassword",
 	})
diff --git a/lib/controller/localdb/login_oidc_test.go b/lib/controller/localdb/login_oidc_test.go
index 1345e8690..2ccb1fce2 100644
--- a/lib/controller/localdb/login_oidc_test.go
+++ b/lib/controller/localdb/login_oidc_test.go
@@ -39,7 +39,6 @@ var _ = check.Suite(&OIDCLoginSuite{})
 
 type OIDCLoginSuite struct {
 	cluster               *arvados.Cluster
-	ctx                   context.Context
 	localdb               *Conn
 	railsSpy              *arvadostest.Proxy
 	fakeIssuer            *httptest.Server
diff --git a/lib/controller/router/router.go b/lib/controller/router/router.go
index c347e2f79..29c81ac5c 100644
--- a/lib/controller/router/router.go
+++ b/lib/controller/router/router.go
@@ -19,144 +19,154 @@ import (
 )
 
 type router struct {
-	mux *mux.Router
-	fed arvados.API
+	mux       *mux.Router
+	backend   arvados.API
+	wrapCalls func(RoutableFunc) RoutableFunc
 }
 
-func New(fed arvados.API) *router {
+// New returns a new router (which implements the http.Handler
+// interface) that serves requests by calling Arvados API methods on
+// the given backend.
+//
+// If wrapCalls is not nil, it is called once for each API method, and
+// the returned method is used in its place. This can be used to
+// install hooks before and after each API call and alter responses;
+// see localdb.WrapCallsInTransaction for an example.
+func New(backend arvados.API, wrapCalls func(RoutableFunc) RoutableFunc) *router {
 	rtr := &router{
-		mux: mux.NewRouter(),
-		fed: fed,
+		mux:       mux.NewRouter(),
+		backend:   backend,
+		wrapCalls: wrapCalls,
 	}
 	rtr.addRoutes()
 	return rtr
 }
 
-type routableFunc func(ctx context.Context, opts interface{}) (interface{}, error)
+type RoutableFunc func(ctx context.Context, opts interface{}) (interface{}, error)
 
 func (rtr *router) addRoutes() {
 	for _, route := range []struct {
 		endpoint    arvados.APIEndpoint
 		defaultOpts func() interface{}
-		exec        routableFunc
+		exec        RoutableFunc
 	}{
 		{
 			arvados.EndpointConfigGet,
 			func() interface{} { return &struct{}{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.ConfigGet(ctx)
+				return rtr.backend.ConfigGet(ctx)
 			},
 		},
 		{
 			arvados.EndpointLogin,
 			func() interface{} { return &arvados.LoginOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.Login(ctx, *opts.(*arvados.LoginOptions))
+				return rtr.backend.Login(ctx, *opts.(*arvados.LoginOptions))
 			},
 		},
 		{
 			arvados.EndpointLogout,
 			func() interface{} { return &arvados.LogoutOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.Logout(ctx, *opts.(*arvados.LogoutOptions))
+				return rtr.backend.Logout(ctx, *opts.(*arvados.LogoutOptions))
 			},
 		},
 		{
 			arvados.EndpointCollectionCreate,
 			func() interface{} { return &arvados.CreateOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.CollectionCreate(ctx, *opts.(*arvados.CreateOptions))
+				return rtr.backend.CollectionCreate(ctx, *opts.(*arvados.CreateOptions))
 			},
 		},
 		{
 			arvados.EndpointCollectionUpdate,
 			func() interface{} { return &arvados.UpdateOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.CollectionUpdate(ctx, *opts.(*arvados.UpdateOptions))
+				return rtr.backend.CollectionUpdate(ctx, *opts.(*arvados.UpdateOptions))
 			},
 		},
 		{
 			arvados.EndpointCollectionGet,
 			func() interface{} { return &arvados.GetOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.CollectionGet(ctx, *opts.(*arvados.GetOptions))
+				return rtr.backend.CollectionGet(ctx, *opts.(*arvados.GetOptions))
 			},
 		},
 		{
 			arvados.EndpointCollectionList,
 			func() interface{} { return &arvados.ListOptions{Limit: -1} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.CollectionList(ctx, *opts.(*arvados.ListOptions))
+				return rtr.backend.CollectionList(ctx, *opts.(*arvados.ListOptions))
 			},
 		},
 		{
 			arvados.EndpointCollectionProvenance,
 			func() interface{} { return &arvados.GetOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.CollectionProvenance(ctx, *opts.(*arvados.GetOptions))
+				return rtr.backend.CollectionProvenance(ctx, *opts.(*arvados.GetOptions))
 			},
 		},
 		{
 			arvados.EndpointCollectionUsedBy,
 			func() interface{} { return &arvados.GetOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.CollectionUsedBy(ctx, *opts.(*arvados.GetOptions))
+				return rtr.backend.CollectionUsedBy(ctx, *opts.(*arvados.GetOptions))
 			},
 		},
 		{
 			arvados.EndpointCollectionDelete,
 			func() interface{} { return &arvados.DeleteOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.CollectionDelete(ctx, *opts.(*arvados.DeleteOptions))
+				return rtr.backend.CollectionDelete(ctx, *opts.(*arvados.DeleteOptions))
 			},
 		},
 		{
 			arvados.EndpointCollectionTrash,
 			func() interface{} { return &arvados.DeleteOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.CollectionTrash(ctx, *opts.(*arvados.DeleteOptions))
+				return rtr.backend.CollectionTrash(ctx, *opts.(*arvados.DeleteOptions))
 			},
 		},
 		{
 			arvados.EndpointCollectionUntrash,
 			func() interface{} { return &arvados.UntrashOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.CollectionUntrash(ctx, *opts.(*arvados.UntrashOptions))
+				return rtr.backend.CollectionUntrash(ctx, *opts.(*arvados.UntrashOptions))
 			},
 		},
 		{
 			arvados.EndpointContainerCreate,
 			func() interface{} { return &arvados.CreateOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.ContainerCreate(ctx, *opts.(*arvados.CreateOptions))
+				return rtr.backend.ContainerCreate(ctx, *opts.(*arvados.CreateOptions))
 			},
 		},
 		{
 			arvados.EndpointContainerUpdate,
 			func() interface{} { return &arvados.UpdateOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.ContainerUpdate(ctx, *opts.(*arvados.UpdateOptions))
+				return rtr.backend.ContainerUpdate(ctx, *opts.(*arvados.UpdateOptions))
 			},
 		},
 		{
 			arvados.EndpointContainerGet,
 			func() interface{} { return &arvados.GetOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.ContainerGet(ctx, *opts.(*arvados.GetOptions))
+				return rtr.backend.ContainerGet(ctx, *opts.(*arvados.GetOptions))
 			},
 		},
 		{
 			arvados.EndpointContainerList,
 			func() interface{} { return &arvados.ListOptions{Limit: -1} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.ContainerList(ctx, *opts.(*arvados.ListOptions))
+				return rtr.backend.ContainerList(ctx, *opts.(*arvados.ListOptions))
 			},
 		},
 		{
 			arvados.EndpointContainerDelete,
 			func() interface{} { return &arvados.DeleteOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.ContainerDelete(ctx, *opts.(*arvados.DeleteOptions))
+				return rtr.backend.ContainerDelete(ctx, *opts.(*arvados.DeleteOptions))
 			},
 		},
 		{
@@ -165,7 +175,7 @@ func (rtr *router) addRoutes() {
 				return &arvados.GetOptions{Select: []string{"uuid", "state", "priority", "auth_uuid", "locked_by_uuid"}}
 			},
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.ContainerLock(ctx, *opts.(*arvados.GetOptions))
+				return rtr.backend.ContainerLock(ctx, *opts.(*arvados.GetOptions))
 			},
 		},
 		{
@@ -174,144 +184,148 @@ func (rtr *router) addRoutes() {
 				return &arvados.GetOptions{Select: []string{"uuid", "state", "priority", "auth_uuid", "locked_by_uuid"}}
 			},
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.ContainerUnlock(ctx, *opts.(*arvados.GetOptions))
+				return rtr.backend.ContainerUnlock(ctx, *opts.(*arvados.GetOptions))
 			},
 		},
 		{
 			arvados.EndpointSpecimenCreate,
 			func() interface{} { return &arvados.CreateOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.SpecimenCreate(ctx, *opts.(*arvados.CreateOptions))
+				return rtr.backend.SpecimenCreate(ctx, *opts.(*arvados.CreateOptions))
 			},
 		},
 		{
 			arvados.EndpointSpecimenUpdate,
 			func() interface{} { return &arvados.UpdateOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.SpecimenUpdate(ctx, *opts.(*arvados.UpdateOptions))
+				return rtr.backend.SpecimenUpdate(ctx, *opts.(*arvados.UpdateOptions))
 			},
 		},
 		{
 			arvados.EndpointSpecimenGet,
 			func() interface{} { return &arvados.GetOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.SpecimenGet(ctx, *opts.(*arvados.GetOptions))
+				return rtr.backend.SpecimenGet(ctx, *opts.(*arvados.GetOptions))
 			},
 		},
 		{
 			arvados.EndpointSpecimenList,
 			func() interface{} { return &arvados.ListOptions{Limit: -1} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.SpecimenList(ctx, *opts.(*arvados.ListOptions))
+				return rtr.backend.SpecimenList(ctx, *opts.(*arvados.ListOptions))
 			},
 		},
 		{
 			arvados.EndpointSpecimenDelete,
 			func() interface{} { return &arvados.DeleteOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.SpecimenDelete(ctx, *opts.(*arvados.DeleteOptions))
+				return rtr.backend.SpecimenDelete(ctx, *opts.(*arvados.DeleteOptions))
 			},
 		},
 		{
 			arvados.EndpointUserCreate,
 			func() interface{} { return &arvados.CreateOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.UserCreate(ctx, *opts.(*arvados.CreateOptions))
+				return rtr.backend.UserCreate(ctx, *opts.(*arvados.CreateOptions))
 			},
 		},
 		{
 			arvados.EndpointUserMerge,
 			func() interface{} { return &arvados.UserMergeOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.UserMerge(ctx, *opts.(*arvados.UserMergeOptions))
+				return rtr.backend.UserMerge(ctx, *opts.(*arvados.UserMergeOptions))
 			},
 		},
 		{
 			arvados.EndpointUserActivate,
 			func() interface{} { return &arvados.UserActivateOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.UserActivate(ctx, *opts.(*arvados.UserActivateOptions))
+				return rtr.backend.UserActivate(ctx, *opts.(*arvados.UserActivateOptions))
 			},
 		},
 		{
 			arvados.EndpointUserSetup,
 			func() interface{} { return &arvados.UserSetupOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.UserSetup(ctx, *opts.(*arvados.UserSetupOptions))
+				return rtr.backend.UserSetup(ctx, *opts.(*arvados.UserSetupOptions))
 			},
 		},
 		{
 			arvados.EndpointUserUnsetup,
 			func() interface{} { return &arvados.GetOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.UserUnsetup(ctx, *opts.(*arvados.GetOptions))
+				return rtr.backend.UserUnsetup(ctx, *opts.(*arvados.GetOptions))
 			},
 		},
 		{
 			arvados.EndpointUserGetCurrent,
 			func() interface{} { return &arvados.GetOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.UserGetCurrent(ctx, *opts.(*arvados.GetOptions))
+				return rtr.backend.UserGetCurrent(ctx, *opts.(*arvados.GetOptions))
 			},
 		},
 		{
 			arvados.EndpointUserGetSystem,
 			func() interface{} { return &arvados.GetOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.UserGetSystem(ctx, *opts.(*arvados.GetOptions))
+				return rtr.backend.UserGetSystem(ctx, *opts.(*arvados.GetOptions))
 			},
 		},
 		{
 			arvados.EndpointUserGet,
 			func() interface{} { return &arvados.GetOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.UserGet(ctx, *opts.(*arvados.GetOptions))
+				return rtr.backend.UserGet(ctx, *opts.(*arvados.GetOptions))
 			},
 		},
 		{
 			arvados.EndpointUserUpdateUUID,
 			func() interface{} { return &arvados.UpdateUUIDOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.UserUpdateUUID(ctx, *opts.(*arvados.UpdateUUIDOptions))
+				return rtr.backend.UserUpdateUUID(ctx, *opts.(*arvados.UpdateUUIDOptions))
 			},
 		},
 		{
 			arvados.EndpointUserUpdate,
 			func() interface{} { return &arvados.UpdateOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.UserUpdate(ctx, *opts.(*arvados.UpdateOptions))
+				return rtr.backend.UserUpdate(ctx, *opts.(*arvados.UpdateOptions))
 			},
 		},
 		{
 			arvados.EndpointUserList,
 			func() interface{} { return &arvados.ListOptions{Limit: -1} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.UserList(ctx, *opts.(*arvados.ListOptions))
+				return rtr.backend.UserList(ctx, *opts.(*arvados.ListOptions))
 			},
 		},
 		{
 			arvados.EndpointUserBatchUpdate,
 			func() interface{} { return &arvados.UserBatchUpdateOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.UserBatchUpdate(ctx, *opts.(*arvados.UserBatchUpdateOptions))
+				return rtr.backend.UserBatchUpdate(ctx, *opts.(*arvados.UserBatchUpdateOptions))
 			},
 		},
 		{
 			arvados.EndpointUserDelete,
 			func() interface{} { return &arvados.DeleteOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.UserDelete(ctx, *opts.(*arvados.DeleteOptions))
+				return rtr.backend.UserDelete(ctx, *opts.(*arvados.DeleteOptions))
 			},
 		},
 		{
 			arvados.EndpointUserAuthenticate,
 			func() interface{} { return &arvados.UserAuthenticateOptions{} },
 			func(ctx context.Context, opts interface{}) (interface{}, error) {
-				return rtr.fed.UserAuthenticate(ctx, *opts.(*arvados.UserAuthenticateOptions))
+				return rtr.backend.UserAuthenticate(ctx, *opts.(*arvados.UserAuthenticateOptions))
 			},
 		},
 	} {
-		rtr.addRoute(route.endpoint, route.defaultOpts, route.exec)
+		exec := route.exec
+		if rtr.wrapCalls != nil {
+			exec = rtr.wrapCalls(exec)
+		}
+		rtr.addRoute(route.endpoint, route.defaultOpts, exec)
 	}
 	rtr.mux.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 		httpserver.Errors(w, []string{"API endpoint not found"}, http.StatusNotFound)
@@ -326,7 +340,7 @@ var altMethod = map[string]string{
 	"GET":   "HEAD", // Accept HEAD at any GET route
 }
 
-func (rtr *router) addRoute(endpoint arvados.APIEndpoint, defaultOpts func() interface{}, exec routableFunc) {
+func (rtr *router) addRoute(endpoint arvados.APIEndpoint, defaultOpts func() interface{}, exec RoutableFunc) {
 	methods := []string{endpoint.Method}
 	if alt, ok := altMethod[endpoint.Method]; ok {
 		methods = append(methods, alt)
diff --git a/lib/controller/router/router_test.go b/lib/controller/router/router_test.go
index 4cabe70f1..c73bc6491 100644
--- a/lib/controller/router/router_test.go
+++ b/lib/controller/router/router_test.go
@@ -38,8 +38,8 @@ type RouterSuite struct {
 func (s *RouterSuite) SetUpTest(c *check.C) {
 	s.stub = arvadostest.APIStub{}
 	s.rtr = &router{
-		mux: mux.NewRouter(),
-		fed: &s.stub,
+		mux:     mux.NewRouter(),
+		backend: &s.stub,
 	}
 	s.rtr.addRoutes()
 }
@@ -169,7 +169,7 @@ func (s *RouterIntegrationSuite) SetUpTest(c *check.C) {
 	cluster.TLS.Insecure = true
 	arvadostest.SetServiceURL(&cluster.Services.RailsAPI, "https://"+os.Getenv("ARVADOS_TEST_API_HOST"))
 	url, _ := url.Parse("https://" + os.Getenv("ARVADOS_TEST_API_HOST"))
-	s.rtr = New(rpc.NewConn("zzzzz", url, true, rpc.PassthroughTokenProvider))
+	s.rtr = New(rpc.NewConn("zzzzz", url, true, rpc.PassthroughTokenProvider), nil)
 }
 
 func (s *RouterIntegrationSuite) TearDownSuite(c *check.C) {

-----------------------------------------------------------------------


hooks/post-receive
-- 




More information about the arvados-commits mailing list