Skip to content
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

Merged
merged 43 commits into from
Oct 15, 2024
Merged

Table store #774

merged 43 commits into from
Oct 15, 2024

Conversation

cody-littley
Copy link
Contributor

@cody-littley cody-littley commented Sep 24, 2024

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

  • I've made sure the lint is passing in this PR.
  • I've made sure the tests are passing. Note that there might be a few flaky tests, in that case, please comment that they are not relevant.
  • I've checked the new test coverage and the coverage percentage didn't drop.
  • Testing Strategy
    • Unit tests
    • Integration tests
    • This PR is not tested :(

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]>
@cody-littley cody-littley self-assigned this Sep 24, 2024
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]>
@cody-littley cody-littley marked this pull request as ready for review September 30, 2024 19:38
package kvstore

// Batch is a collection of operations that can be applied atomically to a TableStore.
type Batch[T any] interface {
Copy link
Contributor

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

Copy link
Contributor Author

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.

Copy link
Contributor

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.

Copy link
Contributor

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?

Copy link
Contributor Author

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. 🙃

Copy link
Contributor

@jianoaix jianoaix Oct 7, 2024

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

}

// BatchOperator is an interface for creating new batches.
type BatchOperator[T any] interface {
Copy link
Contributor

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

Copy link
Contributor Author

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.
Copy link
Contributor

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

Copy link
Contributor Author

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.
Copy link
Contributor

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

Copy link
Contributor Author

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) {
Copy link
Contributor

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.

Copy link
Contributor Author

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.

Copy link
Contributor Author

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) {
Copy link
Contributor

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)

Copy link
Contributor Author

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)

Copy link
Contributor

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.

Copy link
Contributor Author

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]>
schemaKey := []byte(metadataSchemaVersionKey)
onDiskSchemaBytes, err := metadataTable.Get(schemaKey)

if errors.Is(err, kvstore.ErrNotFound) {
Copy link
Contributor

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
...

Copy link
Contributor Author

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.
Copy link
Contributor

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.

Copy link
Contributor Author

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.
Copy link
Contributor

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"?)

Copy link
Contributor Author

@cody-littley cody-littley Oct 9, 2024

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.
Copy link
Contributor

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)

Copy link
Contributor Author

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) {
Copy link
Contributor

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.

Copy link
Contributor Author

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.

Copy link
Contributor

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)

Copy link
Contributor Author

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)
Copy link
Contributor

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

Copy link
Contributor Author

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
Copy link
Contributor

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.

Copy link
Contributor

@jianoaix jianoaix left a 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)
Copy link
Contributor

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

Copy link
Contributor Author

@cody-littley cody-littley Oct 10, 2024

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(
Copy link
Contributor

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)

Copy link
Contributor Author

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(
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor

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)
Copy link
Contributor

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

Copy link
Contributor Author

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).

Copy link
Contributor

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

Copy link
Contributor Author

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
Copy link
Contributor

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.

Copy link
Contributor Author

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++ {
Copy link
Contributor

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++
}

Copy link
Contributor Author

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.
Copy link
Contributor

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

Copy link
Contributor Author

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.

@cody-littley cody-littley merged commit 841e0db into Layr-Labs:master Oct 15, 2024
6 checks passed
@cody-littley cody-littley deleted the table-store branch October 15, 2024 16:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants