Repository navigation
Add secondary index commands to KVExecutor (refs #180) - #269
Open
shrayarora8 wants to merge 4 commits into
Open
shrayarora8 wants to merge 4 commits into
shrayarora8 wants to merge 4 commits into
Conversation
Four gaps in the composite-key storage layer: - LevelDB GetRange returned index entries as if they were user data. GetAllItems and GetKeyRange already skipped them; GetRange was missed. - DuckDB's composite-key methods returned 0 (success) while doing nothing. They now return -1. - EncodeCompositeKeyPrefix with no attributes added an extra \0, so a query for a whole index matched nothing. - DecodeCompositeKey didn't check for the \0 after "ck", so "ckXidx..." decoded as a valid entry. Each fix has a test. Nothing changes for keys that don't start with "ck\0".
Adds CREATE_INDEX_ENTRY, DELETE_INDEX_ENTRY, UPDATE_INDEX_ENTRY and QUERY_BY_INDEX so clients can use the composite keys added in the storage layer. The client sends the parts (index name, attributes, primary key) and the executor builds the stored key, so a client can't write any key outside the "ck\0" range. SET and SET_WITH_VERSION now refuse keys in that range. - Create fails if the record doesn't exist. - Delete and update fail if the entry doesn't exist. - Query returns each matching primary key once, sorted. - ExecuteRequest and ExecuteData both call one shared helper. Tests: kv_executor_index_test runs 21 cases on MemoryDB and LevelDB, through both ExecuteData and ExecuteRequest.
Values are opaque bytes, so a record can hold an image or any other binary data. Adds a test that stores PNG-like bytes (including zero bytes), indexes the record and queries it back. Also documents the one limit: a record whose value is empty can't be indexed, because it looks the same as a missing record.
A query returns the primary keys of the matching records, so an application that wants the records has to fetch them one by one, and each of those is its own request through consensus. With with_values set, the executor reads the matching records itself and returns them in the same reply, so a query costs one request whatever the match count. Off by default, so a query still returns keys only unless it is asked for.
This was referenced Oct 16, 2026
Open
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this PR does
PR #254 added composite keys to the storage layer. It added an encoding, four methods on the
Storageinterface, and implementations for LevelDB and MemoryDB. However, as of now, nothing abovechain/storageknows this feature exists, so no application can reach it.This PR adds the layer above. Added four commands that allow a request to create, delete, update and query an index entry, implemented in the executor. It also fixes four gaps I left in the Phase 1 storage code.
The client SDK, CLI, Python bindings, end-to-end test and benchmark follow in the next PR.
How a request reaches the index
KVRequest(proto/kv/kv.proto) is the message format the client and the server agree on. Itcarries a
cmdfield saying which operation this is, plus the fields that operation needs.protoc, the protocol buffer compiler, turns that file into a C++ class, so both sides read andwrite the same bytes.
KVExecutor(executor/kv/kv_executor.cpp) is the component that runs a request afterconsensus has put it in order. Every replica runs the executor on every request, which is
why they end up with identical data. The executor reads
cmd, does the work, and fills in aKVResponse.The path, with the files this PR touches marked:
Consensus, networking and the client are untouched. The new commands travel through the middle as
opaque bytes, exactly like
SETandGETdo today.The four new 'CMD' commands
Let's use the records below as an example
user:1{"name":"Ana","city":"Davis"}user:2{"name":"Bo","city":"Davis"}user:3{"name":"Cy","city":"Chico"}ResilientDB doesn't look inside those values. The onus is on the application to decide what it wants to search by. It picks a name for the index and supplies the attribute value each record should be indexed under.
CREATE_INDEX_ENTRYrecords that a key has some attribute.The executor builds each stored key and writes it with an empty value. The key itself is the entry.
Storage now also holds these three, which LevelDB keeps in sorted order:
Creating an entry fails if the record does not exist, so a typo cannot leave an entry pointing at
nothing. Creating the same entry twice leaves one entry, because it is the same key.
QUERY_BY_INDEXanswers "which records were indexed under these attribute values?So "which users live in Davis?" is:It is cheap because the entries are sorted and the server jumps to the first key beginning with
ck \0 by_city \0 Davis \0and reads down until a key stops matching.An index can have more than one attribute, and then the order the application chooses matters,
since matching works from the left to right. For example, a second index over photos, built as (owner, format):
["shray"]["shray", "jpeg"]["jpeg"][]UPDATE_INDEX_ENTRYmoves an entry. Say Bo moves from Davis to Chico. The application updatesthe record with
SETas usual, then moves its index entry:Afterwards, the Davis query returns
user:1alone. The old entry is removed, and the new one is addedin a single LevelDB
WriteBatchDELETE_INDEX_ENTRYremoves one entry:user:3is now absent from theby_cityindex, while the recorduser:3itself is untouched andGET user:3still returns Cy. It fails if there is no such entry.Design decisions
composite_key = "user:42"would have it runPut("user:42", "")and wipe that user's record. Encoding on the server removes this possibility rather than checking for it. With the server doing the encoding, every key it writes begins with"ck\0", and the codec rejects any part containing a zero byte (\0). Two benefits of this:SETandSET_WITH_VERSIONrefuse keys starting with"ck\0". Since a protobufstringcan carry a zero byte, a program could technically call:That is a perfectly formed index entry using the SET cmd instead of CREATE_INDEX_ENTRY. This check reserves the
"ck\0"range for the index commands. Without the check, it lands in the index, and every Davis query returnsuser:666, a record nobody indexed. A different key could overwrite someone's real index entry instead. Keys that merely start with "ck", such asck_balance, are ordinary data and still work.with_values: a query can return the records, not only their keysProposal to Add Interface to Create and Retrieve CompositeKeys #180 assumed the caller fetches each record itself after the query. That means a GET per match, and every one of those goes through consensus on its own, so 100 matches costs 101 requests. With the flag the executor reads the records locally and returns them in the same reply
Both execution paths share one helper.
TransactionManagerhas two entry points (ExecuteDataandExecuteRequest), and which one runs depends on how the executor is configured (transaction_executor.cpp:329-352): Both chains call a single helper,ExecuteIndexCommand, which handles all four secondary index commands in one place.Tests
executor/kv/kv_executor_index_test.cppruns 26 cases against MemoryDB and LevelDB, through bothExecuteDataandExecuteRequest. Some tests worth calling out:SetCannotWriteIndexEntriescrafts aSETand aSET_WITH_VERSIONwhose key is awell-formed index entry for
user:666, then checks the query still returns nothing.QueryMatchesWholeAttributeindexes "Davis" and "Davisville"; a query for "Davis" must returnonly the first.
DeleteRemovesOnlyThatEntryindexesuser:1anduser:10. Deletinguser:1's entry mustnot match
user:10, because a prefix search finds both.IndexRecordWithBinaryValuestores a value holding a PNG signature, zero bytes and non-textbytes, indexes it, queries it, and gets it back byte for byte. Values are opaque with only keys and
attributes must be text.
QueryWithValuesReturnsRecordsandQueryWithoutValuesReturnsKeysOnlycheck the valuescome back when asked for and stay out when not.
Limits
entries; the application must call update. Proposal to Add Interface to Create and Retrieve CompositeKeys #180 chose this deliberately, so stale entries are
possible (would love to discuss a future direction where updating or deleting primary key automatically updates the secondary indexes in the background).
missing record.
gethas the same ambiguity today.bytes. To index by binary data, use a text form of it such as hex.
Open questions
which reads naturally as "no filter". The alternative is to require at least one attribute.
with_valuescap the reply size? A query matching a large part of the databasereturns a large response. Today the caller controls that by how it queries.
GET on a missing key returns an empty value, a range query returns an empty list. SET returns nothing and reports nothing. However, the create, delete and update composite key operations return no data. Without a status field there is nothing for the caller to look at, so "the record didn't exist" and "done" look identical.
Files touched
New:
executor/kv/kv_executor_index_test.cppModified:
proto/kv/kv.proto(4 commands, 5 request fields, 1 response field),executor/kv/kv_executor.{h,cpp}andexecutor/kv/BUILD,chain/storage/composite_key_codec.cpp,chain/storage/leveldb.cpp,chain/storage/duckdb.h,and the two storage test files.
Refs #180, builds on #254