mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-08-28 10:54:59 +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)
513 lines
15 KiB
Go
513 lines
15 KiB
Go
// Copyright 2022 Molecula Corp (DBA FeatureBase). All rights reserved.
|
|
package querycontext
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// QueryContext represents the lifespan of a query or similar thing which
|
|
// is accessing one or more backend databases. The individual databases
|
|
// are transactional; a transaction allows seeing consistent data (even
|
|
// if other things are may be writing to the database), keeps memory returned
|
|
// by the backend from being invalidated, and makes sets of changes take
|
|
// effect atomically.
|
|
//
|
|
// The QueryContext should not be closed until all access to data returned
|
|
// from queries is complete.
|
|
//
|
|
// The Error/Errorf methods tell the QueryContext that an error has occurred
|
|
// which should prevent it from committing. If you call either of them for
|
|
// a QueryContext, Commit() must fail. (It may yield the error provided,
|
|
// or another error which seemed important.) NewRead and NewWrite also fail
|
|
// once an error has been reported.
|
|
//
|
|
// A QueryContext is created with a parent context.Context, and will also
|
|
// fail, and refuse to commit, if that context is canceled before you try
|
|
// to commit.
|
|
type QueryContext interface {
|
|
// NewRead requests a new QueryRead object for the indicated fragment.
|
|
NewRead(IndexName, FieldName, ViewName, ShardID) (QueryRead, error)
|
|
// NewWrite requests a new QueryWrite object for the indicated fragment.
|
|
NewWrite(IndexName, FieldName, ViewName, ShardID) (QueryWrite, error)
|
|
// Error sets a persistent error state and indicates that this QueryContext
|
|
// must not commit its writes.
|
|
Error(error)
|
|
// Errorf is a convenience function equivalent to Error(fmt.Errorf(...))
|
|
Errorf(string, ...interface{})
|
|
// Release releases resources held by this QueryContext without committing
|
|
// writes. If writes have already been committed, they are not affected.
|
|
// A release after a commit (or another release) is harmless.
|
|
Release()
|
|
// Commit attempts to commit writes, unless an error has already been
|
|
// recorded or the parent context has been canceled. If it does not attempt
|
|
// to commit writes, it reports the error that prevented it. Otherwise it
|
|
// attempts the writes and reports an error if any errors occurred.
|
|
// It is an error to try to commit twice or use the QueryContext after a
|
|
// commit.
|
|
Commit() error
|
|
}
|
|
|
|
// QueryRead represents read access to a fragment.
|
|
type QueryRead interface {
|
|
}
|
|
|
|
// QueryWrite represents write access to a fragment.
|
|
type QueryWrite interface {
|
|
}
|
|
|
|
type IndexName string
|
|
type FieldName string
|
|
type ViewName string
|
|
type ShardID uint64
|
|
|
|
// QueryScope represents a possible set of things that can be written
|
|
// to. A QueryScope can in principle represent arbitrary patterns with
|
|
// special rules. However! Our system depends on using QueryScope
|
|
// objects to detect and prevent overlapping writes, to ensure that
|
|
// queries running in parallel won't deadlock against each other.
|
|
//
|
|
// So each TxStore can yield QueryScope objects, the Overlap semantics
|
|
// of which match the TxStore's database definitions. If two QueryScopes
|
|
// are considered to overlap, that means that there exist fragment
|
|
// identifiers such that each QueryScope returns true for Allowed on
|
|
// at least one of these fragment identifiers, and the TxStore's
|
|
// KeySplitter would produce the same database key for those fragment
|
|
// identifiers.
|
|
type QueryScope interface {
|
|
// Allowed determines whether a specific fragment
|
|
// is covered by this QueryScope.
|
|
Allowed(IndexName, FieldName, ViewName, ShardID) bool
|
|
|
|
// Overlap reports whether there are any overlaps between this
|
|
// QueryScope object and another. An overlap exists wherever
|
|
// calls to Allowed with the same parameters would return true for
|
|
// both objects.
|
|
Overlap(QueryScope) bool
|
|
|
|
AddIndex(IndexName)
|
|
AddField(IndexName, FieldName)
|
|
AddIndexShards(IndexName, ...ShardID)
|
|
AddFieldShards(IndexName, FieldName, ...ShardID)
|
|
|
|
String() string
|
|
}
|
|
|
|
// indexShardQueryScope is a QueryScope which ignores fields and
|
|
// views, and provides a map from indexes to shards that are covered
|
|
// within those indexes. An empty shard list indicates all shards,
|
|
// an absent key indicates no shards. Shard lists are stored sorted.
|
|
type indexShardQueryScope struct {
|
|
shards map[IndexName]shardList
|
|
}
|
|
|
|
var _ QueryScope = &indexShardQueryScope{}
|
|
|
|
func (i *indexShardQueryScope) String() string {
|
|
var scopes []string
|
|
for index, shards := range i.shards {
|
|
if shards.all {
|
|
scopes = append(scopes, string(index+"*"))
|
|
} else {
|
|
scopes = append(scopes, string(index+"+"))
|
|
}
|
|
}
|
|
// ensure consistent order for reader benefit
|
|
sort.Strings(scopes)
|
|
return strings.Join(scopes, ",")
|
|
}
|
|
|
|
// AddIndex adds the given index, with all shards writable.
|
|
func (i *indexShardQueryScope) AddIndex(index IndexName) {
|
|
if i.shards == nil {
|
|
i.shards = map[IndexName]shardList{index: {all: true}}
|
|
return
|
|
}
|
|
i.shards[index] = shardList{all: true}
|
|
}
|
|
|
|
// AddIndexShards adds the given index for the given shards.
|
|
func (i *indexShardQueryScope) AddIndexShards(index IndexName, shards ...ShardID) {
|
|
if i.shards == nil {
|
|
i.shards = map[IndexName]shardList{}
|
|
}
|
|
existing := i.shards[index]
|
|
// We could at this point check whether anything previously existed, and
|
|
// if not, just use a new {any: shards} shardlist, but we want to verify
|
|
// shard lists are sorted.
|
|
if existing.all {
|
|
return
|
|
}
|
|
for _, shard := range shards {
|
|
existing.Add(shard)
|
|
}
|
|
i.shards[index] = existing
|
|
}
|
|
|
|
func (i *indexShardQueryScope) AddField(index IndexName, _ FieldName) {
|
|
i.AddIndex(index)
|
|
}
|
|
|
|
func (i *indexShardQueryScope) AddFieldShards(index IndexName, field FieldName, shards ...ShardID) {
|
|
i.AddIndexShards(index, shards...)
|
|
}
|
|
|
|
func (i *indexShardQueryScope) Allowed(index IndexName, _ FieldName, _ ViewName, shard ShardID) bool {
|
|
shards, ok := i.shards[index]
|
|
if !ok {
|
|
return false
|
|
}
|
|
return shards.Allowed(shard)
|
|
}
|
|
|
|
func (i *indexShardQueryScope) Overlap(qw QueryScope) bool {
|
|
// this panics if the other isn't also an indexShardQueryScope.
|
|
// don't mix and match.
|
|
other := qw.(*indexShardQueryScope)
|
|
for index, shardList := range i.shards {
|
|
if otherShards, ok := other.shards[index]; ok {
|
|
if shardList.Overlap(otherShards) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// indexScope is to an index's fields as shardList is to
|
|
// a list of shards; `all` is the shardList of index-wide
|
|
// reservations, `any` is the map of fields to field-specific
|
|
// reservations.
|
|
type indexScope struct {
|
|
all shardList
|
|
any map[FieldName]shardList
|
|
}
|
|
|
|
// Overlap determines whether two index scopes overlap. This
|
|
// means they have shards in common between corresponding
|
|
// fields, or between anything and their index-wide shard lists.
|
|
func (i *indexScope) Overlap(other *indexScope) bool {
|
|
// direct index<->index overlaps
|
|
if i.all.Overlap(other.all) {
|
|
return true
|
|
}
|
|
// our index-wide, their field-specific
|
|
for _, otherShards := range other.any {
|
|
if i.all.Overlap(otherShards) {
|
|
return true
|
|
}
|
|
}
|
|
for field, shards := range i.any {
|
|
// our field-specific, their index-wide
|
|
if other.all.Overlap(shards) {
|
|
return true
|
|
}
|
|
// matching fields
|
|
otherShards := other.any[field]
|
|
if otherShards.Overlap(shards) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (scope *indexScope) AddField(field FieldName) {
|
|
if scope.all.all {
|
|
// We already cover everything.
|
|
return
|
|
}
|
|
if scope.any == nil {
|
|
scope.any = map[FieldName]shardList{field: {all: true}}
|
|
return
|
|
}
|
|
scope.any[field] = shardList{all: true}
|
|
}
|
|
|
|
func (scope *indexScope) AddFieldShards(field FieldName, shards ...ShardID) {
|
|
if scope.all.all {
|
|
// We already cover everything.
|
|
return
|
|
}
|
|
if scope.any == nil {
|
|
scope.any = map[FieldName]shardList{}
|
|
}
|
|
existing, ok := scope.any[field]
|
|
if !ok {
|
|
existing = shardList{}
|
|
}
|
|
if existing.all {
|
|
return
|
|
}
|
|
for _, shard := range shards {
|
|
existing.Add(shard)
|
|
}
|
|
scope.any[field] = existing
|
|
}
|
|
|
|
// Complexity yields a small visual indicator of complexity of
|
|
// this scope.
|
|
// "": we actually cover nothing?
|
|
// *: we cover everything
|
|
// #: we cover some shards, nothing per-field
|
|
// /*: we cover some fields but not per-shard
|
|
// /#: we cover some shards of some fields
|
|
// #/*: we cover some shards and some whole fields
|
|
// #/#: we cover some shards index-wide and some shards of some fields
|
|
func (scope *indexScope) Complexity() string {
|
|
if scope.all.all {
|
|
return "*"
|
|
}
|
|
wholeIndex := len(scope.all.any) > 0
|
|
wholeFields := false
|
|
partialFields := false
|
|
for _, shards := range scope.any {
|
|
if shards.all {
|
|
wholeFields = true
|
|
} else if len(shards.any) > 0 {
|
|
partialFields = true
|
|
}
|
|
}
|
|
var result string
|
|
if wholeIndex {
|
|
result = "#"
|
|
}
|
|
if partialFields {
|
|
return result + "/#"
|
|
}
|
|
if wholeFields {
|
|
return result + "/*"
|
|
}
|
|
return result
|
|
}
|
|
|
|
// flexibleQueryScope is an experimental case which allows some indexes to be
|
|
// split into fields, while others aren't. it relies on a corresponding
|
|
// flexibleKeySplitter for the list of indexes which are always handled at
|
|
// a full index level.
|
|
//
|
|
// You can just add an entire index, even if it's unsplit. If an index is
|
|
// split, you can in principle add the whole index for some shards and just
|
|
// some fields or others, but if you want to do this, please don't.
|
|
// Hesitate to.
|
|
//
|
|
// If no flexibleKeySplitter is provided, every index is split.
|
|
type flexibleQueryScope struct {
|
|
splitter *flexibleKeySplitter
|
|
indexes map[IndexName]*indexScope
|
|
}
|
|
|
|
var _ QueryScope = &flexibleQueryScope{}
|
|
|
|
func (i *flexibleQueryScope) String() string {
|
|
descrs := make([]string, 0, len(i.indexes))
|
|
for index, scope := range i.indexes {
|
|
// Show the index's name plus something indicating the
|
|
// approximate shape of the scope -- is it the whole index,
|
|
// some shards, some fields, or what?
|
|
descrs = append(descrs, string(index)+scope.Complexity())
|
|
}
|
|
sort.Strings(descrs)
|
|
return strings.Join(descrs, ",")
|
|
}
|
|
|
|
// AddIndex adds the given index, with all shards writable.
|
|
func (i *flexibleQueryScope) AddIndex(index IndexName) {
|
|
if i.indexes == nil {
|
|
i.indexes = map[IndexName]*indexScope{index: {all: shardList{all: true}}}
|
|
return
|
|
}
|
|
i.indexes[index] = &indexScope{all: shardList{all: true}}
|
|
}
|
|
|
|
// AddIndexShards adds the given index for the given shards.
|
|
func (i *flexibleQueryScope) AddIndexShards(index IndexName, shards ...ShardID) {
|
|
if i.indexes == nil {
|
|
i.indexes = map[IndexName]*indexScope{}
|
|
}
|
|
// We could at this point check whether anything previously existed, and
|
|
// if not, just use a new {any: shards} shardlist, but we want to verify
|
|
// shard lists are sorted.
|
|
scope := i.indexes[index]
|
|
if scope == nil {
|
|
scope = &indexScope{}
|
|
i.indexes[index] = scope
|
|
}
|
|
if scope.all.all {
|
|
return
|
|
}
|
|
for _, shard := range shards {
|
|
scope.all.Add(shard)
|
|
}
|
|
}
|
|
|
|
// AddField adds the given field, with all shards writable. If the field
|
|
// is in an unsplit index, the entire index is covered.
|
|
func (i *flexibleQueryScope) AddField(index IndexName, field FieldName) {
|
|
if i.splitter != nil {
|
|
if _, ok := i.splitter.splitIndexes[index]; !ok {
|
|
// ignore field because this index isn't split
|
|
i.AddIndex(index)
|
|
return
|
|
}
|
|
}
|
|
if i.indexes == nil {
|
|
i.indexes = make(map[IndexName]*indexScope)
|
|
}
|
|
scope := i.indexes[index]
|
|
if scope == nil {
|
|
scope = &indexScope{}
|
|
i.indexes[index] = scope
|
|
}
|
|
scope.AddField(field)
|
|
}
|
|
|
|
// AddFieldShards adds the given index for the given shards.
|
|
func (i *flexibleQueryScope) AddFieldShards(index IndexName, field FieldName, shards ...ShardID) {
|
|
if i.splitter != nil {
|
|
if _, ok := i.splitter.splitIndexes[index]; !ok {
|
|
// ignore field because this index isn't split
|
|
i.AddIndexShards(index, shards...)
|
|
return
|
|
}
|
|
}
|
|
if i.indexes == nil {
|
|
i.indexes = make(map[IndexName]*indexScope)
|
|
}
|
|
scope := i.indexes[index]
|
|
if scope == nil {
|
|
scope = &indexScope{}
|
|
i.indexes[index] = scope
|
|
}
|
|
scope.AddFieldShards(field, shards...)
|
|
}
|
|
|
|
func (i *flexibleQueryScope) Allowed(index IndexName, field FieldName, _ ViewName, shard ShardID) bool {
|
|
if i.splitter != nil {
|
|
// unsplit index: we can't have stored fields so we don't check them
|
|
if _, ok := i.splitter.splitIndexes[index]; !ok {
|
|
if shards, ok := i.indexes[index]; ok {
|
|
return shards.all.Allowed(shard)
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
scope := i.indexes[index]
|
|
if scope == nil {
|
|
return false
|
|
}
|
|
// split index: check the index first, in case it's set, but don't fail if
|
|
// it's not, because it might be in fields
|
|
if scope.all.Allowed(shard) {
|
|
return true
|
|
}
|
|
// why not just call Allowed on it directly? because it's a pointer-receiver
|
|
// method and map entries aren't addressable.
|
|
shards := scope.any[field]
|
|
return shards.Allowed(shard)
|
|
}
|
|
|
|
func (i *flexibleQueryScope) Overlap(qw QueryScope) (out bool) {
|
|
// this panics if the other isn't also an flexibleQueryScope.
|
|
// don't mix and match.
|
|
other := qw.(*flexibleQueryScope)
|
|
// overlap occurs if there's an overlap of indexes, or of fields.
|
|
// We compare indexes against the other side's corresponding fields,
|
|
// and fields against the other side's corresponding indexes.
|
|
for index, scope := range i.indexes {
|
|
// if the other has this index as an unsplit index, overlap
|
|
// there counts
|
|
if otherScope, ok := other.indexes[index]; ok {
|
|
if scope.Overlap(otherScope) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// shardList is a set of shards which can be either every shard
|
|
// or a provided list of shards. It should probably be named
|
|
// shardSet but we have one of those already that is for reasons
|
|
// not a good fit.
|
|
type shardList struct {
|
|
all bool
|
|
any []ShardID
|
|
}
|
|
|
|
// findShard returns the positive index at which shard was found
|
|
// in the shard list, or the negative index at which it would have
|
|
// been (and thus the insertion point for an add).
|
|
func (s *shardList) findShard(shard ShardID) int {
|
|
l, h := 0, len(s.any)
|
|
for h > l {
|
|
m := (h + l) / 2
|
|
if s.any[m] == shard {
|
|
return m
|
|
}
|
|
if s.any[m] < shard {
|
|
l = m + 1
|
|
} else {
|
|
h = m
|
|
}
|
|
}
|
|
// in the single-item list case, if we're below the single item,
|
|
// we ended with {l=h=0}, and if we're above it, we ended with
|
|
// {l=h=1}. we want to return a negative value for all misses,
|
|
// so we return -h -1. we could also use l. we couldn't use m,
|
|
// because in the "above the single item" case, m was still 0
|
|
// when we left the loop.
|
|
return -h - 1
|
|
}
|
|
|
|
// Allowed indicates whether the given shard is currently included
|
|
// in the set.
|
|
func (s *shardList) Allowed(shard ShardID) bool {
|
|
if s.all {
|
|
return true
|
|
}
|
|
pos := s.findShard(shard)
|
|
return pos >= 0
|
|
}
|
|
|
|
// Overlap determines whether two shard lists overlap.
|
|
func (s *shardList) Overlap(other shardList) bool {
|
|
if s.all || other.all {
|
|
return true
|
|
}
|
|
// an empty shard list doesn't overlap
|
|
if len(s.any) == 0 || len(other.any) == 0 {
|
|
return false
|
|
}
|
|
ours := s.any
|
|
theirs := other.any
|
|
o := 0
|
|
for _, shard := range ours {
|
|
for o < len(theirs) && theirs[o] < shard {
|
|
o++
|
|
}
|
|
if o >= len(theirs) {
|
|
return false
|
|
}
|
|
if theirs[o] == shard {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Add returns a list because if you call it on a nil shardList,
|
|
// it needs to return a new one.
|
|
func (s *shardList) Add(shard ShardID) {
|
|
if s.all {
|
|
return
|
|
}
|
|
pos := s.findShard(shard)
|
|
if pos >= 0 {
|
|
return
|
|
}
|
|
// -1 -> 0, etc
|
|
pos = -pos - 1
|
|
s.any = append(s.any, 0)
|
|
copy(s.any[pos+1:], s.any[pos:])
|
|
s.any[pos] = shard
|
|
}
|