featurebase/task/pool_test.go
Seebs ff091b0346 implement a task pool
This implements a task pool which can handle backpressure; the
idea is, you have a target number of workers, but when a worker
blocks, you can tell it that it's blocking, and it can spawn
another worker in the mean time. This reduces the bounding provided
by the worker pool, and can significantly overshoot the intended size
of the pool in some cases, but it provides quick scaling up when
part of a workload gets blocked.

There's also a simulator attached to it. The simulator's job is
to act similarly to the executor's worker pool working on RBF
databases, including the weird semantics of writes and reads;
specifically, that reads aren't blocked by writes, but a write
can't terminate until every read that started before it has exited.
(This is an oversimplification; actually, writes can complete,
but they still hold the write lock until any WAL merge completes,
and the WAL merge can't complete until old reads are done.)

The simulator is significantly more complicated than the pool.
2022-02-14 09:56:19 -06:00

430 lines
12 KiB
Go

// Copyright 2021 Molecula Corp. All rights reserved.
package task
import (
"fmt"
"golang.org/x/sync/errgroup"
"math/rand"
"sync"
"sync/atomic"
"testing"
"time"
)
// db represents a thing which can be locked, and which can
// perform read and write operations, which are modeled as channels which
// a workload can wait on writes to, and which embeds a lockable RWMutex.
// The RWMutex actually makes this slightly stricter than the semantics
// of RBF, which usually allows writes and reads to coexist, but fairly
// accurately represents the specific issue that RBF can't *finish* a write
// while an older read is active. Not the same, but has similar impact.
type db struct {
read, write chan struct{}
sync.Mutex
}
// server represents a set of dbs, which jobs can be run against. They're
// [26] because they're denoted by lowercase/uppercase letters.
type server struct {
dbs [26]db
mu sync.Mutex // mutex to govern access to readers
readers [26][]workload // a list of readers associated with each db
waiters [26]struct {
mu sync.Mutex
cond *sync.Cond
}
pool *Pool
jobs chan *job
tb testing.TB
}
// a job represents a single operation on a server, and a receiver
// waiting to hear back when it's done. It also has a reference to the
// bitmasks of read/write locks so that the parent operation can clean
// them all up when it's done. This is roughly parallel to the Qcx/Tx
// locking behavior in featurebase.
type job struct {
descr workload // the workload that generated this job, used to identify them
id int
write bool
locked *uint32 // bitmask of read-locked jobs
ch chan<- struct{}
}
// workload represents a series of jobs as letters;
// lowercase letters read from the read channel of a component, uppercase
// letters read from the write channel of the corresponding lowercase
// component. each operation locks components as it reaches them for
// the first time, then unlocks all of them at the end of the string.
type workload string
// runJob grabs a single job from the server's job queue, does it, and
// notifies the waiter. To "do" a job is to acquire the appropriate
// lock (for read or write), mark the appropriate bit in a bitmap of
// active locks, and then read from either a read or write channel, which
// then corresponds to values being passed to Satisfy.
func (s *server) runJob() {
j, ok := <-s.jobs
if !ok {
return
}
if j.write {
s.pool.Block()
s.dbs[j.id].Lock()
s.pool.Unblock()
s.mu.Lock()
// obtain list of existing readers
waiting := make([]workload, len(s.readers[j.id]))
copy(waiting, s.readers[j.id])
s.mu.Unlock()
<-s.dbs[j.id].write
// In RBF, the write lock can't be released until the last outstanding
// reader predating this write terminates, but that's asynchronous
// from the actual request processing. So, similarly, we launch a thing
// that will unlock this slot in the database, once it's done waiting
// for any readers. We do that without the pool marked as blocked.
go func() {
// but the write can't actually complete until any pending readers
// that were already in play complete
if len(waiting) > 0 {
// We might need to wait for things. We need to be sure,
// though, that the server's list of readers for this isn't
// changing while we're checking it. So, we grab the specific
// lock, then check the reader list, and if we think we need
// to wait, we wait on a condition variable which then
// releases that lock so something else can update the reader
// list and notify us.
func() {
s.waiters[j.id].mu.Lock()
defer s.waiters[j.id].mu.Unlock()
// we have to check this with the specific lock held, so if
// anything were to change the list, it'd have to wait
// until we're done or waiting on the cond.
stillWaiting := s.stillWaiting(j.id, waiting)
for stillWaiting {
s.waiters[j.id].cond.Wait()
stillWaiting = s.stillWaiting(j.id, waiting)
}
}()
}
s.dbs[j.id].Unlock()
}()
} else {
s.pool.Block()
// attach us to the list of known readers, which must exit before
// any writers starting after them can exit
s.mu.Lock()
s.readers[j.id] = append(s.readers[j.id], j.descr)
s.mu.Unlock()
s.pool.Unblock()
cur := atomic.LoadUint32(j.locked)
// mask this bit in
for (cur>>j.id)&1 == 0 {
added := cur | (1 << j.id)
atomic.CompareAndSwapUint32(j.locked, cur, added)
cur = atomic.LoadUint32(j.locked)
}
<-s.dbs[j.id].read
}
j.ch <- struct{}{}
}
// stillWaiting determines whether we're still waiting on anything in
// a given list terminating.
func (s *server) stillWaiting(id int, waitingOn []workload) bool {
s.mu.Lock()
readers := s.readers[id]
s.mu.Unlock()
for _, waiter := range waitingOn {
for _, reader := range readers {
if waiter == reader {
return true
}
}
}
return false
}
// runWorkload runs the tasks within a workload, passing them to the worker
// queue, and then waiting for them all to complete. When it's done waiting
// for them, it releases any locks they obtained.
func (s *server) runWorkload(w workload) {
var locked uint32
defer func() {
// unlock everything marked as locked
read := atomic.LoadUint32(&locked)
for i := 0; i < 32; i++ {
if (read>>i)&1 != 0 {
s.waiters[i].mu.Lock()
s.mu.Lock()
// remove us from readers list
for j := range s.readers[i] {
if s.readers[i][j] == w {
copy(s.readers[i][j:], s.readers[i][j+1:])
s.readers[i] = s.readers[i][:len(s.readers[i])-1]
break
}
}
s.mu.Unlock()
s.waiters[i].mu.Unlock()
// and wake up anything that was waiting for this.
s.waiters[i].cond.Broadcast()
}
}
}()
ch := make(chan struct{})
eg := &errgroup.Group{}
j := job{ch: ch, locked: &locked, descr: w}
for _, c := range w {
switch {
case c >= 'a' && c <= 'z':
j.id = int(c - 'a')
j.write = false
case c >= 'A' && c <= 'Z':
j.id = int(c - 'A')
j.write = true
default:
s.tb.Logf("unhandled character '%c'", c)
continue
}
j := j
eg.Go(func() error {
s.jobs <- &j
<-ch
return nil
})
}
_ = eg.Wait()
}
// newServer creates a server associated with the given testing.TB,
// allowing us to log things.
func newServer(tb testing.TB) *server {
s := &server{tb: tb, jobs: make(chan *job)}
for i := range s.dbs {
s.dbs[i].read = make(chan struct{})
s.dbs[i].write = make(chan struct{})
s.waiters[i].cond = sync.NewCond(&s.waiters[i].mu)
}
return s
}
// close shuts the server down by closing all of its channels, and may
// not really be necessary.
func (s *server) close() {
for i := range s.dbs {
db := &s.dbs[i]
db.Lock()
close(db.read)
close(db.write)
db.Unlock()
}
close(s.jobs)
}
// Satisfy satisfies the given read or write operations asynchronously,
// but waits for all of them in this batch to complete before returning.
func (s *server) satisfy(w workload) {
var eg errgroup.Group
for _, c := range w {
var id int
var write bool
switch {
case c >= 'a' && c <= 'z':
id = int(c - 'a')
write = false
case c >= 'A' && c <= 'Z':
id = int(c - 'A')
write = true
default:
s.tb.Logf("unhandled character '%c'", c)
continue
}
eg.Go(func() error {
if write {
s.dbs[id].write <- struct{}{}
} else {
s.dbs[id].read <- struct{}{}
}
return nil
})
}
_ = eg.Wait()
}
// makeWorkload generates a sequence of letters, some of which may be
// capitalized, in order
func makeWorkload() workload {
var letters [26]byte
var n int
write := rand.Intn(8) == 0
for i := 0; i < 26; i++ {
if rand.Intn(4) == 0 {
if write {
letters[n] = 'A' + byte(i)
} else {
letters[n] = 'a' + byte(i)
}
n++
}
}
return workload(letters[:n])
}
// testRandomWorkload makes up an arbitrary workload and tries to run
// the server against it.
func testRandomWorkload(t *testing.T) {
s := newServer(t)
eg := &errgroup.Group{}
p := NewPool(2, s.runJob, nil)
s.pool = p
defer p.Close()
defer s.close()
var workloads []workload // the requests we make
var quick []workload // the requests that get satisfied soon
var slow []workload // the requests that don't get satisfied until later
for i := 0; i < 10; i++ {
w := makeWorkload()
if len(w) == 0 {
continue
}
workloads = append(workloads, w)
partial := rand.Intn(26)
// possibly truncate and postpone some
if partial < len(w) {
quick = append(quick, w[:partial])
slow = append(slow, w[partial:])
} else {
quick = append(quick, w)
}
}
for _, w := range workloads {
w := w
eg.Go(func() error {
s.runWorkload(w)
return nil
})
}
for _, w := range quick {
w := w
eg.Go(func() error {
s.satisfy(w)
return nil
})
}
l, u, target := p.Stats()
// Only one worker at a time can be invoking the mark-as-blocked logic,
// so you can run after it marks that, but before the new worker is spawned,
// but the next worker can't invoke the blocked logic until that completes.
//
// Live count always decreases after unblocked count on the exit path, and
// increases before unblocked count on the startup path. So even if the
// samples are interrupted, I think it should be impossible for live
// to be less than unblocked.
if u < target-1 || l < u {
t.Fatalf("inconsistent pool stats: %d live, %d unblocked, %d target", l, u, target)
}
for _, w := range slow {
w := w
eg.Go(func() error {
s.satisfy(w)
return nil
})
}
_ = eg.Wait()
}
// TestRandomWorkloads makes up some arbitrary workloads, then tries to
// satisfy them out of order.
// In theory, this should work for any sequence of operations as long as
// no operation has the same letter for both read and write ops, and
// ops always occur in order.
func TestRandomWorkloads(t *testing.T) {
for i := 0; i < 10; i++ {
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
testRandomWorkload(t)
})
}
}
func TestServer(t *testing.T) {
s := newServer(t)
eg := &errgroup.Group{}
p := NewPool(3, s.runJob, nil)
s.pool = p
defer p.Close()
defer s.close()
request := func(w workload) {
eg.Go(func() error {
s.runWorkload(w)
return nil
})
}
// requesting "abcd" means that the reader "abcd" will still be active on
// a until all the other letters show up.
request("abcd")
// satisfy won't complete until at least two of the jobs have happened,
// so there's a decent chance that we've marked ourselves as a reader on
// a.
s.satisfy("abc")
// so we request a write on A. we get the write lock, but we can't
// relinquish it until "d" shows up.
request("A")
// satisfy that request immediately, but to no avail.
s.satisfy("A")
// three more requests come in. if they get pool slots, they definitely
// block; that request on A can't have finished yet. so they could fully
// block our work pool.
request("A")
request("A")
request("A")
// spawn something to provide "efg"
go s.satisfy("efg")
// runWorkload means we actually block waiting for it. if all the worker
// pool is blocked waiting on A, we can't do that.
s.runWorkload("efg")
// now we provide the missing d, which should allow the first request to
// finally complete, and then the next three A, which should finish
// the rest.
s.satisfy("dAAA")
// If we spawned new jobs, this should complete. Otherwise it should hang
// because the requests can't be satisfied because the queue is full
// of blocked operations.
_ = eg.Wait()
}
func TestPoolStartup(t *testing.T) {
var counter int32
started := make(chan struct{})
done := make(chan struct{})
addAndWait := func() {
<-started
atomic.AddInt32(&counter, 1)
<-done
}
// we expect this to spawn three counters
p := NewPool(3, addAndWait, nil)
time.Sleep(50 * time.Millisecond)
v := atomic.LoadInt32(&counter)
if v != 0 {
t.Fatalf("expected no adds yet, got %d", v)
}
close(started)
time.Sleep(50 * time.Millisecond)
v = atomic.LoadInt32(&counter)
if v != 3 {
t.Fatalf("expected 3 adds, got %d", v)
}
// Tell the pool to stop processing jobs
p.Shutdown()
// Allow the jobs to complete. Since this happens after the
// shutdown has set desired pool size to zero, they should now all exit.
close(done)
p.Close()
time.Sleep(50 * time.Millisecond)
v = atomic.LoadInt32(&counter)
if v != 3 {
t.Fatalf("expected no more adds, got %d including previous 3", v)
}
}