mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-09-07 00:55:55 +00:00
This is living in a subdirectory for now so we can have better
turnaround time on tests and not have to build everything else
along with it.
This covers the logic that we can have *without* actually using
databases or the filesystem in any way, just to provide a framework
that lets us validate the logic handling overlapping queries.
The overall purpose of this is to prevent deadlocks, by ensuring
that database locks are only taken when we have already proven
that they are available. In short, the QueryContext preregisters
its "scope" -- the set of things it may want to lock. The operation
of creating the QueryContext can block, but it blocks with no
database locks held. Once it is unblocked, the scope it has reported
is now considered unavailable, and no other QueryContext using any
overlapping scope can complete creation until this QueryContext
completes. While it's running, the QueryContext can't request write
access to anything outside its scope. Thus, once created, a
QueryContext can always proceed, without being blocked, until it's
done.
Note that this does not fully address multi-node behaviors;
once you have a QueryContext blocking things, you need to not
make queries to other nodes that could be blocked in turn by those
nodes. In short, no write queries to other nodes while holding a
write-type QueryContext on the local node, because if two nodes
do that to each other at once, they can both be blocked.
We believe RBF is currently designed such that read-only accesses
don't block progress on writes, so non-write access doesn't
create problems.
We also have some code to allow us to create dot-format output
from the components of this system, which is mostly intended to
be a debugging tool.
(cherry picked from commit e3d137f29c)
477 lines
14 KiB
Go
477 lines
14 KiB
Go
// Copyright 2022 Molecula Corp (DBA FeatureBase). All rights reserved.
|
|
package querycontext
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sync"
|
|
|
|
"github.com/molecula/featurebase/v3/rbf"
|
|
)
|
|
|
|
// rbfDBQueryContexts represents an actual backend DB, and a map of the
|
|
// rbfQueryContexts associated with that DB. this lets us check for
|
|
// outstanding transactions before closing a database.
|
|
type rbfDBQueryContexts struct {
|
|
key dbKey
|
|
db *rbf.DB
|
|
mu sync.Mutex
|
|
// mutex used to pretend we're using the database even though we're
|
|
// not wired up to it yet
|
|
lockCheck sync.Mutex
|
|
// currently live TxWrappers, pointing to their QueryContexts
|
|
queryContexts map[*rbfTxWrappers]*rbfQueryContext
|
|
}
|
|
|
|
// writeTx obtains a write Tx, and associates it with the given query context. only
|
|
// one thing should ever have a write context at once, and because the QueryContext is
|
|
// supposed to be protecting us here, it's actually just plain an error for us
|
|
// to not get the lock inside here.
|
|
func (r *rbfDBQueryContexts) writeTx(rq *rbfQueryContext) (*rbfTxWrappers, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
ok := r.lockCheck.TryLock()
|
|
if !ok {
|
|
return nil, errors.New("locking error: tried for write Tx when it was already held")
|
|
}
|
|
// still nil for now
|
|
var tx *rbf.Tx
|
|
q := &rbfTxWrappers{db: r, tx: tx, key: r.key, queries: make(map[fragKey]QueryRead), writeTx: true}
|
|
r.queryContexts[q] = rq
|
|
return q, nil
|
|
}
|
|
|
|
// readTx obtains a read Tx, and associates it with the given query context.
|
|
func (r *rbfDBQueryContexts) readTx(rq *rbfQueryContext) (*rbfTxWrappers, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
var tx *rbf.Tx
|
|
q := &rbfTxWrappers{db: r, tx: tx, key: r.key, queries: make(map[fragKey]QueryRead)}
|
|
r.queryContexts[q] = rq
|
|
return q, nil
|
|
}
|
|
|
|
// release marks a given rbfQueryContext as no longer using the db
|
|
func (r *rbfDBQueryContexts) release(rt *rbfTxWrappers) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if rt.tx != nil {
|
|
rt.tx.Rollback()
|
|
}
|
|
if rt.writeTx {
|
|
r.lockCheck.Unlock()
|
|
}
|
|
delete(r.queryContexts, rt)
|
|
}
|
|
|
|
// commit commits the transaction associated with the query context, and
|
|
// returns any error from the commit.
|
|
func (r *rbfDBQueryContexts) commit(rt *rbfTxWrappers) (err error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if rt.tx != nil {
|
|
err = rt.tx.Commit()
|
|
}
|
|
r.lockCheck.Unlock()
|
|
delete(r.queryContexts, rt)
|
|
return err
|
|
}
|
|
|
|
// rbfTxStore is the implementation of TxStore which is backed by RBF
|
|
// files.
|
|
//
|
|
// an rbfTxStore maintains a map from dbKeys to rbfDBQueryContexts, which
|
|
// is to say, for each dbKey, it has an associated DB and a pool of
|
|
// rbfQueryContexts that are using that DB.
|
|
//
|
|
// it also maintains a list of live writers, which is a map from
|
|
// rbfQueryContexts to their QueryScope, used to determine whether a
|
|
// proposed new write will overlap with any existing writes, in which
|
|
// case it will be paused until they're done.
|
|
type rbfTxStore struct {
|
|
KeySplitter
|
|
mu sync.Mutex
|
|
writeQueue *sync.Cond
|
|
rootPath string
|
|
// currently live databases, and the query contexts (if any) using them
|
|
dbs map[dbKey]*rbfDBQueryContexts
|
|
writeScopes map[*rbfQueryContext]QueryScope
|
|
queries map[*rbfQueryContext]struct{}
|
|
closed bool
|
|
}
|
|
|
|
// newRbfTxStore creates a new RBF-backed TxStore in the given directory.
|
|
func newRbfTxStore(path string, splitter KeySplitter) (*rbfTxStore, error) {
|
|
if splitter == nil {
|
|
splitter = &indexShardKeySplitter{}
|
|
}
|
|
r := &rbfTxStore{
|
|
KeySplitter: splitter,
|
|
rootPath: path,
|
|
dbs: make(map[dbKey]*rbfDBQueryContexts),
|
|
writeScopes: make(map[*rbfQueryContext]QueryScope),
|
|
queries: make(map[*rbfQueryContext]struct{}),
|
|
}
|
|
r.writeQueue = sync.NewCond(&r.mu)
|
|
return r, nil
|
|
}
|
|
|
|
// dbPath yields the filesystem path to be used with a given dbKey to
|
|
// open the underlying database.
|
|
func (r *rbfTxStore) dbPath(d dbKey) string {
|
|
return filepath.Join(r.rootPath, d.Path())
|
|
}
|
|
|
|
// getDB gets or creates the db for the given key. call only when you hold
|
|
// the rbfTxStore's lock.
|
|
func (r *rbfTxStore) getDB(dbk dbKey) (*rbfDBQueryContexts, error) {
|
|
db, ok := r.dbs[dbk]
|
|
if ok {
|
|
return db, nil
|
|
}
|
|
// if we were opening a database here, it could fail, which is why we return an error here
|
|
db = &rbfDBQueryContexts{key: dbk, queryContexts: make(map[*rbfTxWrappers]*rbfQueryContext)}
|
|
// use this function so staticcheck is happy
|
|
_ = r.dbPath(dbk)
|
|
r.dbs[dbk] = db
|
|
return db, nil
|
|
}
|
|
|
|
// newReadTx creates a read Tx in the backend, and returns an rbfTxWrappers
|
|
// with that plus a map from fragKey to QueryRead objects
|
|
func (r *rbfTxStore) newReadTx(rq *rbfQueryContext, dbk dbKey) (*rbfTxWrappers, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
db, err := r.getDB(dbk)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return db.readTx(rq)
|
|
}
|
|
|
|
// newWriteTx creates a write Tx in the backend, and returns an rbfTxWrappers
|
|
// with that plus a map from fragKey to QueryRead objects
|
|
func (r *rbfTxStore) newWriteTx(rq *rbfQueryContext, dbk dbKey) (*rbfTxWrappers, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
db, err := r.getDB(dbk)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return db.writeTx(rq)
|
|
}
|
|
|
|
// writeBlocked checks whether the new write could be blocked by an existing write
|
|
// which is already in the list of writers
|
|
func (r *rbfTxStore) writeBlocked(scope QueryScope) bool {
|
|
for _, writer := range r.writeScopes {
|
|
if writer.Overlap(scope) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// release indicates that the given rbfQueryContext is no longer using this txStore.
|
|
// in particular, if it's a write query context, this indicates that it is no
|
|
// longer holding any locks, and it may be possible for other writes to continue.
|
|
func (r *rbfTxStore) release(rq *rbfQueryContext) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if _, ok := r.writeScopes[rq]; ok {
|
|
delete(r.writeScopes, rq)
|
|
// we broadcast, rather than signaling, because it's totally possible that
|
|
// there's still other active writers, and that any *specific* pending write
|
|
// could be blocked by one of those. it's possible that nothing will actually
|
|
// be able to continue yet. but in the typical case, there aren't a huge
|
|
// number waiting, we hope? if writeBlocked checks are showing up a lot, we
|
|
// can revisit this.
|
|
r.writeQueue.Broadcast()
|
|
}
|
|
delete(r.queries, rq)
|
|
}
|
|
|
|
// NewQueryContext creates a read-only query context. It doesn't need to worry
|
|
// about writes so it just assumes it can request read access to any database it
|
|
// needs.
|
|
func (r *rbfTxStore) NewQueryContext(ctx context.Context) (QueryContext, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.closed {
|
|
return nil, errors.New("can't create query context on closed TxStore")
|
|
}
|
|
// we assign random names so that we can debug which query context is which more easily
|
|
rq := &rbfQueryContext{
|
|
txStore: r,
|
|
ctx: ctx,
|
|
queries: make(map[dbKey]*rbfTxWrappers),
|
|
}
|
|
rq.name = fmt.Sprintf("rqcx-%p", rq)
|
|
runtime.SetFinalizer(rq, finalizeQueryContext)
|
|
r.queries[rq] = struct{}{}
|
|
return rq, nil
|
|
}
|
|
|
|
// NewWriteQueryContext creates a write-capable query context, with writes scoped by
|
|
// the provided QueryScope. The query context will be able to create write requests
|
|
// for any {index, field, view, shard} that's Allowed() by writes, and can also create
|
|
// read requests for others.
|
|
func (r *rbfTxStore) NewWriteQueryContext(ctx context.Context, scope QueryScope) (QueryContext, error) {
|
|
// we assign random names so that we can debug which query context is which more easily
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.closed {
|
|
return nil, errors.New("can't create query context on closed TxStore")
|
|
}
|
|
for r.writeBlocked(scope) {
|
|
r.writeQueue.Wait()
|
|
}
|
|
rq := &rbfQueryContext{
|
|
txStore: r,
|
|
ctx: ctx,
|
|
scope: scope,
|
|
queries: make(map[dbKey]*rbfTxWrappers),
|
|
}
|
|
rq.name = fmt.Sprintf("wqcx-%p", rq)
|
|
r.writeScopes[rq] = scope
|
|
r.queries[rq] = struct{}{}
|
|
runtime.SetFinalizer(rq, finalizeQueryContext)
|
|
return rq, nil
|
|
}
|
|
|
|
func (r *rbfTxStore) Close() error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
var firstErr error
|
|
// A QueryContext can be live without having any transactions open yet.
|
|
if len(r.queries) > 0 {
|
|
return fmt.Errorf("can't close TxStore while query contexts are outstanding")
|
|
}
|
|
// even if we fail to close databases, *we're* closed and will
|
|
// no longer allow new QueryContexts
|
|
r.closed = true
|
|
for key, db := range r.dbs {
|
|
if len(db.queryContexts) > 0 {
|
|
firstErr = fmt.Errorf("%d transaction(s) still open", len(db.queryContexts))
|
|
continue
|
|
}
|
|
if db.db != nil {
|
|
if err := db.db.Close(); err != nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
// we remove this from our list of databases anyway, as long as it wasn't
|
|
// still in use. we can't retry the close itself.
|
|
delete(r.dbs, key)
|
|
}
|
|
return firstErr
|
|
}
|
|
|
|
// rbfTxWrappers is the per-dbKey part of a QueryContext, representing the set of
|
|
// fragment-specific query reads (or writes) associated with a given rbf.Tx. The objects
|
|
// are stored here in a map of QueryRead, but they may actually be QueryScope. (We
|
|
// have to grab the write transaction initially, because we can only grab a transaction
|
|
// once for each dbKey, and any Allowed fragment could later request a write.)
|
|
type rbfTxWrappers struct {
|
|
key dbKey
|
|
db *rbfDBQueryContexts
|
|
tx *rbf.Tx
|
|
queries map[fragKey]QueryRead
|
|
writeTx bool
|
|
}
|
|
|
|
func (txw *rbfTxWrappers) readKey(fk fragKey) (QueryRead, error) {
|
|
qr, ok := txw.queries[fk]
|
|
if !ok {
|
|
read := rbfQueryRead{tx: txw, fk: fk}
|
|
if txw.writeTx {
|
|
write := &rbfQueryWrite{rbfQueryRead: read}
|
|
qr = write
|
|
} else {
|
|
qr = &read
|
|
}
|
|
txw.queries[fk] = qr
|
|
}
|
|
if qw, ok := qr.(*rbfQueryWrite); ok {
|
|
return &qw.rbfQueryRead, nil
|
|
}
|
|
return qr, nil
|
|
}
|
|
|
|
var errWriteRequestOnNonWrite = errors.New("write request tried to use read-only transaction")
|
|
|
|
func (txw *rbfTxWrappers) writeKey(fk fragKey) (QueryWrite, error) {
|
|
if qr, ok := txw.queries[fk]; ok {
|
|
if qw, ok := qr.(*rbfQueryWrite); ok {
|
|
return qw, nil
|
|
}
|
|
return nil, errWriteRequestOnNonWrite
|
|
}
|
|
write := &rbfQueryWrite{rbfQueryRead: rbfQueryRead{tx: txw, fk: fk}}
|
|
txw.queries[fk] = write
|
|
return write, nil
|
|
}
|
|
|
|
// rbfQueryContext represents a query context backed by an rbfTxStore. It tracks
|
|
// its current access to backend resources with a map[dbKey]*rbfTxWrappers.
|
|
// For each dbKey it uses, it may have a Tx, which is always a single shared Tx
|
|
// used by any and all wrappers that would use that database.
|
|
type rbfQueryContext struct {
|
|
mu sync.Mutex
|
|
name string
|
|
txStore *rbfTxStore
|
|
ctx context.Context
|
|
scope QueryScope
|
|
err error
|
|
queries map[dbKey]*rbfTxWrappers
|
|
done bool
|
|
}
|
|
|
|
// verify that rbfQueryContext implements the interface
|
|
var _ QueryContext = &rbfQueryContext{}
|
|
|
|
func (rq *rbfQueryContext) String() string {
|
|
return fmt.Sprintf("rbf-Qcx<%s>%s", rq.name, rq.scope)
|
|
}
|
|
|
|
func (rq *rbfQueryContext) NewRead(index IndexName, field FieldName, view ViewName, shard ShardID) (QueryRead, error) {
|
|
rq.mu.Lock()
|
|
defer rq.mu.Unlock()
|
|
dbk, fk := rq.txStore.keys(index, field, view, shard)
|
|
queries, ok := rq.queries[dbk]
|
|
if !ok {
|
|
var err error
|
|
// new database connection
|
|
// If we can write to this, we request a write query even though this is a read operation,
|
|
// because we will be reusing that query for future operations anyway, and *they* might
|
|
// be writes. If we aren't allowed to write to this, it's still okay to read from it.
|
|
if rq.scope != nil && rq.scope.Allowed(index, field, view, shard) {
|
|
queries, err = rq.txStore.newWriteTx(rq, dbk)
|
|
} else {
|
|
queries, err = rq.txStore.newReadTx(rq, dbk)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rq.queries[dbk] = queries
|
|
}
|
|
return queries.readKey(fk)
|
|
}
|
|
|
|
func (rq *rbfQueryContext) NewWrite(index IndexName, field FieldName, view ViewName, shard ShardID) (QueryWrite, error) {
|
|
if rq.scope == nil {
|
|
return nil, errors.New("read-only query context can't write")
|
|
}
|
|
dbk, fk := rq.txStore.keys(index, field, view, shard)
|
|
if !rq.scope.Allowed(index, field, view, shard) {
|
|
return nil, fmt.Errorf("query context does not cover writes to db key %s", dbk)
|
|
}
|
|
rq.mu.Lock()
|
|
defer rq.mu.Unlock()
|
|
queries, ok := rq.queries[dbk]
|
|
if !ok {
|
|
var err error
|
|
// new database connection
|
|
queries, err = rq.txStore.newWriteTx(rq, dbk)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rq.queries[dbk] = queries
|
|
}
|
|
return queries.writeKey(fk)
|
|
}
|
|
|
|
func (rq *rbfQueryContext) Error(err error) {
|
|
rq.mu.Lock()
|
|
defer rq.mu.Unlock()
|
|
rq.err = err
|
|
}
|
|
|
|
func (rq *rbfQueryContext) Errorf(msg string, args ...interface{}) {
|
|
rq.mu.Lock()
|
|
defer rq.mu.Unlock()
|
|
rq.err = fmt.Errorf(msg, args...)
|
|
}
|
|
|
|
// unprotectedRelease is the shared implementation called by both
|
|
// Release and Commit to mark us no longer using resources. Call
|
|
// with lock held.
|
|
func (rq *rbfQueryContext) unprotectedRelease() {
|
|
if rq.done {
|
|
return
|
|
}
|
|
for _, query := range rq.queries {
|
|
query.db.release(query)
|
|
}
|
|
rq.txStore.release(rq)
|
|
rq.done = true
|
|
}
|
|
|
|
func (rq *rbfQueryContext) Release() {
|
|
rq.mu.Lock()
|
|
defer rq.mu.Unlock()
|
|
rq.unprotectedRelease()
|
|
}
|
|
|
|
// Commit tries to write, unless it's already encountered
|
|
// an error. Either way, it tries to close every open
|
|
// transaction.
|
|
func (rq *rbfQueryContext) Commit() error {
|
|
rq.mu.Lock()
|
|
defer rq.mu.Unlock()
|
|
if rq.done {
|
|
return errors.New("query context already released, can't commit")
|
|
}
|
|
if rq.err != nil {
|
|
rq.unprotectedRelease()
|
|
return rq.err
|
|
}
|
|
// fail out early if our context got canceled
|
|
if err := rq.ctx.Err(); err != nil {
|
|
rq.unprotectedRelease()
|
|
return err
|
|
}
|
|
var firstErr error
|
|
for _, query := range rq.queries {
|
|
if query.writeTx && firstErr == nil {
|
|
err := query.db.commit(query)
|
|
if err != nil {
|
|
firstErr = err
|
|
}
|
|
} else {
|
|
query.db.release(query)
|
|
}
|
|
}
|
|
rq.txStore.release(rq)
|
|
rq.done = true
|
|
return firstErr
|
|
}
|
|
|
|
func finalizeQueryContext(rq *rbfQueryContext) {
|
|
if !rq.done {
|
|
fmt.Fprintf(os.Stderr, "rbfQueryContext %s wasn't closed. (writes %v)\n", rq.name, rq.scope)
|
|
}
|
|
}
|
|
|
|
// rbfQueryRead is a fragment-specific read-only wrapper around
|
|
// an rbf.Tx.
|
|
type rbfQueryRead struct {
|
|
fk fragKey
|
|
tx *rbfTxWrappers
|
|
}
|
|
|
|
var _ QueryRead = &rbfQueryRead{}
|
|
|
|
// rbfQueryWrite is a fragment-specific write-capable wrapper
|
|
// around an rbf.Tx. It embeds an rbfQueryRead so that, when you
|
|
// want to return a QueryRead, you can just return its
|
|
// inner object and there's no chance of accidentally upgrading.
|
|
type rbfQueryWrite struct {
|
|
rbfQueryRead
|
|
}
|
|
|
|
var _ QueryWrite = &rbfQueryWrite{}
|