-
Notifications
You must be signed in to change notification settings - Fork 175
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Table store #774
Table store #774
Conversation
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
common/kvstore/batch.go
Outdated
package kvstore | ||
|
||
// Batch is a collection of operations that can be applied atomically to a TableStore. | ||
type Batch[T any] interface { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure this needs template, Batch may just work with table key
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Base Store
objects also need to support operations using Batch
, but the type of these keys is []byte
. I think if we want to get rid of the generics, we will need to stop using type TableKey []byte
and instead use the []byte
type for batch operations on a table. Let's discuss.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The issue is this interface intends to be used for TableStore
(like it comments out), but it's now got used to batch operations for Store
as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One idea is to subclass this interface with two types, like (demo not real code):
type StoreBatch struct {
// key type is []byte
deletes map[[]byte]struct{}
...
}
type TableStoreBatch struct {
// key type is TableKey
deletes map[TableKey]struct{}
...
}
It establishes common interface but users doesn't need to use template. Wdyt?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm ok with this approach. If we do it this way, I'd actually be in favor of ditching the shared interface, and just embrace the fact that batches against multiple tables have a different API.
I'll wait to make this change until we figure out what we are doing with thread safety. With my temporary branch currently open, I don't want to start introducing merge conflicts. 🙃
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's a good pattern to have a Base<T>
and then subclass it with CaseA
and CaseB
without template. The interface helps constrain what they can be.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
common/kvstore/batch.go
Outdated
} | ||
|
||
// BatchOperator is an interface for creating new batches. | ||
type BatchOperator[T any] interface { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For simplicity, it looks Batch creation can be a function of TableStore
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NewBatch
is also a function on the base Store
object, but I agree this interface isn't really necessary. Removed.
"sort" | ||
) | ||
|
||
// Table ID 0 is reserved for use internal use by the metadata table. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Comment doesn't match code
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed
// Table ID 0 is reserved for use internal use by the metadata table. | ||
const metadataTableID uint32 = math.MaxUint32 | ||
|
||
// Table ID 1 is reserved for use by the namespace table. This stores a mapping between IDs and table names. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Comment doesn't match code
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed
// WARNING: it is not safe to access the wrapped store directly while the TableStore is in use. The TableStore uses | ||
// special key formatting, and direct access to the wrapped store may violate the TableStore's invariants, resulting | ||
// in undefined behavior. | ||
func Wrapper(logger logging.Logger, base kvstore.Store) (kvstore.TableStore, error) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd create a TableStore, instead of asking for an external Store and wrapping it. This way the Store can be internally created and no risk of breaking invariants by accessing it directly outside the TableStore.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The purpose of requiring the caller to provide the Store
implementation is to support multiple Store
implementations. Currently this is used in unit tests, where I sometimes use a store implementation that is built on top of an in memory map. Let's discuss this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changed based on our slack discussion.
} | ||
|
||
// GetTable gets the table with the given name. If the table does not exist, it is first created. | ||
func (t *tableStore) GetTable(name string) (kvstore.Table, error) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't see this TableStore is thread-safe (which is required per interface)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the interface, this method is specifically documented not to be thread safe (although the godoc in the implementation doesn't copy the warning). The code to make this thread simple to write, but I'm concerned about adding locks that could slow other operations. Do you think it's worth adding locks so that these methods are safe to call concurrently?
// GetTable gets the table with the given name. If the table does not exist, it is first created.
//
// WARNING: this method is not thread safe with respect to any other methods in this interface or
// any methods on any Table objects associated with this store.
GetTable(name string) (Table, error)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it needs to, otherwise other methods like GetTableCount (which is supposed to be thread safe) won't be safe as they share the states.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are now no unsafe methods (due to schema pattern)
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
schemaKey := []byte(metadataSchemaVersionKey) | ||
onDiskSchemaBytes, err := metadataTable.Get(schemaKey) | ||
|
||
if errors.Is(err, kvstore.ErrNotFound) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The control below can be made more readable:
if err != nil {
if !errors.Is(err, kvstore.ErrNotFound) {
return ...
}
// handle not found case
...
return nil
}
// Verify schema version
...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
refactored
// Verify schema version. | ||
onDiskSchema := binary.BigEndian.Uint64(onDiskSchemaBytes) | ||
if onDiskSchema != currentSchemaVersion { | ||
// In the future if we change schema versions, we may need to write migration code here. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How do you migrate? The data will need to be re-processed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Most likely, a migration will require us to iterate all data in the DB and to change its on disk format. The schema version is included here as a "just in case" sort of thing. I REALLY hope we never have to do this, so we should do our best to design the schema right the first time.
|
||
var _ kvstore.Table = &tableView{} | ||
|
||
// tableView allows table in a New to be accessed as if it were a Store. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment not readable/has typo ("in a New"?, "as it were a Store"?)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed.
// tableView allows data in a table to be accessed as if it were a Store.
This is an artifact of my IDE attempting to automatically refactor things when I changed the name of the method.
"sort" | ||
) | ||
|
||
// The table ID reserved for the metadata table. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Comment what's in the metadata table (like what you did for namespaceTable)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// The table ID reserved for the metadata table. The metadata table is used for internal bookkeeping.
// The following data is currently stored in the metadata table:
// - Schema version (in case we ever need to do a schema migration)
// - Table deletion markers used to detect a crash during table deletion and to complete
// the deletion when the store is next started.
// This method will set up a table for each table name provided, and will drop all tables not in the list. | ||
// Dropping a table is irreversible and will delete all data in the table, so be very careful not to call | ||
// this method with table names omitted by mistake. | ||
func (t StoreType) Create(logger logging.Logger, path string, tables ...string) (kvstore.TableStore, error) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks a more frequent operation will be Load
, where it's given the path, and will load the existing base Store as well as existing tables.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've renamed this to Start()
, since that name is accurate for both a new setup and for when we load existing data.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential UX issues to check: 1) the users mostly may just want to load existing tables (adding/dropping tables are rare operations), and 2) the user may not know the existing tables, unless they can build this TableStore, which then requires knowing the existing tables (a loop here)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added a method Load()
which can be used to load the store without making any modifications to the schema.
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
return nil, fmt.Errorf("error handling incomplete deletion: %w", err) | ||
} | ||
|
||
err = addAndRemoveTables(base, metadataTable, namespaceTable, tableIDMap, tables) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd prefer not deleting tables synchronously. After the database running for a while, the deletion can take quite a while. It'd be better to:
- Do lazy deletion: write the table to be deleted to the deletion table
- Run a thread in the background to perform the deletion - and cap the resource it can use
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We discussed this, repeating it here for the sake of others. This PR is already fairly large, and so we've decided to implement async table deletion as a follow up PR.
// The table ID reserved for the metadata table. The metadata table is used for internal bookkeeping. | ||
// The following data is currently stored in the metadata table: | ||
// - Schema version (in case we ever need to do a schema migration) | ||
// - Table deletion markers used to detect a crash during table deletion and to complete |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you adopt lazy deletion, then this can be a separate DeletionTable, which records each deleted table as an entry in this table. The background thread work on the DeletionTable and perform GC.
Signed-off-by: Cody Littley <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Almost there!
currentTables []string) error { | ||
|
||
// Determine which tables to keep and which to drop. | ||
originalTablesSet := make(map[string]bool) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use map[string]struct{}
for implementing set
will use slightly less memory
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done. One of the things that gets on my nerves with golang is the absence of a set in the standard libraries. Sure, you can mimic it with a map. It's just syntactically ugly.
|
||
// This method adds and removes tables as needed to match the given list of tables. The table ID map is updated | ||
// to reflect the new state of the tables. | ||
func addAndRemoveTables( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potentially more readability if we build this into:
- computeSchemaChanges: returns tablesToAdd and tablesToDrop
- addTables(tablesToAdd)
- dropTables(tablesToDrop)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
|
||
// Perform sanity checks on the namespace table. | ||
// This method makes potential logic in the namespace errors fail fast and visibly. | ||
func sanityCheckNamespaceTable( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this for testing/debugging the implementation is correct?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is sanity check code intended to make noise if a bug in the code results in inconsistencies between tableIDMap
, namespaceTable
, and the user provided schema. I intended this check to run even in production environments, since if the tables are corrupted we will have really big problems.
That being said, if you don't think it belongs I'm willing to remove it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds fine to keep then
return nil, fmt.Errorf("error loading namespace table: %w", err) | ||
} | ||
|
||
err = handleIncompleteDeletion(logger, base, metadataTable, namespaceTable) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't this deletion happen before even loading the namespace table? Otherwise a name/ID that's still pending for deletion may get reused
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You are correct that the partially deleted table may already be inside the namespace table. This edge case is handled by handleIncompleteDeletion()
, which will perform the necessary deletion within the namespace table (last line of handleIncompleteDeletion()
calls dropTable()
, which deletes the entry from the namespace table).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If there is a pending deletion, the loadNamespaceTable
will include that deleted table in the returned tableIDMap
(because that table may be still in the namespace table, haven't fully deleted), which is not desired
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I walked this code with a debugger. It turns out this was accidentally working, since the method addAndRemoveTables()
deletes the spurious table ID. There is a unit test that is sensitive to spurious entries in the table ID map, and that was not failing.
In order to make this more understandable and less easy to accidentally break, I've moved the loading of the table ID map until after handleIncompleteDeletion()
.
|
||
// The table ID reserved for the namespace table. Keys in the namespace table are table IDs (uint32) | ||
// and values are table names (string). | ||
const namespaceTableID uint32 = math.MaxUint32 - 1 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's helpful to call out in comment that table name and ID can be reused after a table is completely dropped from the TableStore.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// The table ID reserved for the namespace table. Keys in the namespace table are table IDs (uint32)
// and values are table names (string). Although no two tables are permitted to have share a name or a table ID,
// once a table is dropped, future tables may be instantiated with the same name and the table ID may be reused.
const namespaceTableID uint32 = math.MaxUint32 - 1
// We want to fill gaps prior to assigning new IDs. | ||
newTableIDs := make([]uint32, 0, len(tablesToAdd)) | ||
nextID := uint32(0) | ||
for i := 0; i < len(tablesToAdd); i++ { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Slightly simpler/more readable way to express this code:
for len(newTableIDs) < len(tablesToAdd) {
if _, alreadyUsed := tableIDMap[nextID]; !alreadyUsed {
newTableIDs = append(newTableIDs, nextID)
}
nextID++
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, that's a lot cleaner. Done.
Signed-off-by: Cody Littley <[email protected]>
Signed-off-by: Cody Littley <[email protected]>
@@ -0,0 +1,14 @@ | |||
package kvstore | |||
|
|||
// Batch is a collection of key / value pairs that can be written atomically to a database. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: can be written
-> will be written
atomically?
Mention that the Batch is not thread safe
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oops, noticed this after I pressed the merge button. I will include this change in the follow up PR I am working on right now.
Why are these changes needed?
This PR adds the ability to use tables on top of a store. A very nice to have feature for use cases that want to use multiple logical tables.
Checks