Skip to content

Add secondary index commands to KVExecutor (refs #180) - #269

Open
shrayarora8 wants to merge 4 commits into
apache:masterfrom
shrayarora8:feature/composite-keys-executor
Open

shrayarora8 wants to merge 4 commits into
apache:masterfrom
shrayarora8:feature/composite-keys-executor

Conversation

@shrayarora8

@shrayarora8 shrayarora8 commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does

PR #254 added composite keys to the storage layer. It added an encoding, four methods on the Storage interface, and implementations for LevelDB and MemoryDB. However, as of now, nothing above chain/storage knows 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. It
carries a cmd field 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 and
write the same bytes.

KVExecutor (executor/kv/kv_executor.cpp) is the component that runs a request after
consensus 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 a
KVResponse.

The path, with the files this PR touches marked:

client fills a KVRequest              proto/kv/kv.proto            <- 4 new commands
        |
        v
client proxy, then PBFT consensus     (untouched)
        |
        v
KVExecutor: switch on cmd             executor/kv/kv_executor.cpp  <- new branches
        |
        v
Storage: CreateCompositeKey, ...      chain/storage/               <- from PR #254, plus 4 fixes

Consensus, networking and the client are untouched. The new commands travel through the middle as
opaque bytes, exactly like SET and GET do today.

The four new 'CMD' commands

Let's use the records below as an example

key value
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_ENTRY records that a key has some attribute.

KVRequest { cmd: CREATE_INDEX_ENTRY, index_name: "by_city", attributes: ["Davis"], primary_key: "user:1" }
KVRequest { cmd: CREATE_INDEX_ENTRY, index_name: "by_city", attributes: ["Davis"], primary_key: "user:2" }
KVRequest { cmd: CREATE_INDEX_ENTRY, index_name: "by_city", attributes: ["Chico"], primary_key: "user:3" }

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:

ck \0 by_city \0 Chico \0 user:3
ck \0 by_city \0 Davis \0 user:1
ck \0 by_city \0 Davis \0 user:2

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_INDEX answers "which records were indexed under these attribute values?So "which users live in Davis?" is:

KVRequest { cmd: QUERY_BY_INDEX, index_name: "by_city", attributes: ["Davis"] }
  -> items: [ "user:1", "user:2" ]          (user:3 is in Chico, so it is not here)

It is cheap because the entries are sorted and the server jumps to the first key beginning with
ck \0 by_city \0 Davis \0 and 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):

Query Returns
["shray"] every photo shray owns
["shray", "jpeg"] shray's jpegs
["jpeg"] nothing, because format is the second attribute
[] every entry in the index

UPDATE_INDEX_ENTRY moves an entry. Say Bo moves from Davis to Chico. The application updates
the record with SET as usual, then moves its index entry:

KVRequest { cmd: UPDATE_INDEX_ENTRY, index_name: "by_city",
            attributes: ["Davis"], new_attributes: ["Chico"], primary_key: "user:2" }

Afterwards, the Davis query returns user:1 alone. The old entry is removed, and the new one is added
in a single LevelDB WriteBatch

DELETE_INDEX_ENTRY removes one entry:

KVRequest { cmd: DELETE_INDEX_ENTRY, index_name: "by_city", attributes: ["Chico"], primary_key: "user:3" }

user:3 is now absent from the by_city index, while the record user:3 itself is untouched and
GET user:3 still returns Cy. It fails if there is no such entry.

Design decisions

  1. The server builds the stored key; the client sends only the parts. The client sending the finished composite key themselves is unsafe because the executor cannot tell an index entry from an ordinary record. For ex: a client sending composite_key = "user:42" would have it run Put("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:
  • no client in any language has to implement the encoding, so no client can implement it differently
  • an empty prefix, which on LevelDB would scan the whole database, cannot be built.
  1. SET and SET_WITH_VERSION refuse keys starting with "ck\0". Since a protobuf string can carry a zero byte, a program could technically call:
SET key = "ck\0by_city\0Davis\0user:666"

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 returns user:666, a record nobody indexed. A different key could overwrite someone's real index entry instead. Keys that merely start with "ck", such as ck_balance, are ordinary data and still work.

  1. with_values: a query can return the records, not only their keys
KVRequest { cmd: QUERY_BY_INDEX, index_name: "by_city", attributes: ["Davis"] }
  -> [ "user:1", "user:2" ]

KVRequest { cmd: QUERY_BY_INDEX, index_name: "by_city", attributes: ["Davis"], with_values: true }
  -> [ ("user:1", {"name":"Ana","city":"Davis"}),
       ("user:2", {"name":"Bo","city":"Davis"}) ]
  1. Proposal 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

  2. Both execution paths share one helper. TransactionManager has two entry points (ExecuteData and ExecuteRequest), 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.cpp runs 26 cases against MemoryDB and LevelDB, through both
ExecuteData and ExecuteRequest. Some tests worth calling out:

  • SetCannotWriteIndexEntries crafts a SET and a SET_WITH_VERSION whose key is a
    well-formed index entry for user:666, then checks the query still returns nothing.
  • QueryMatchesWholeAttribute indexes "Davis" and "Davisville"; a query for "Davis" must return
    only the first.
  • DeleteRemovesOnlyThatEntry indexes user:1 and user:10. Deleting user:1's entry must
    not match user:10, because a prefix search finds both.
  • IndexRecordWithBinaryValue stores a value holding a PNG signature, zero bytes and non-text
    bytes, indexes it, queries it, and gets it back byte for byte. Values are opaque with only keys and
    attributes must be text.
  • QueryWithValuesReturnsRecords and QueryWithoutValuesReturnsKeysOnly check the values
    come back when asked for and stay out when not.

Limits

  • Index entries are not maintained automatically. Changing a record does not update its
    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).
  • A record whose value is empty cannot be indexed, because it cannot be told apart from a
    missing record. get has the same ambiguity today.
  • Index names, attributes and primary keys must be text without zero bytes. Values can be any
    bytes. To index by binary data, use a text form of it such as hex.
  • DuckDB does not support composite keys

Open questions

  1. Should a query with no attributes be rejected? Today it returns every entry in the index,
    which reads naturally as "no filter". The alternative is to require at least one attribute.
  2. Should with_values cap the reply size? A query matching a large part of the database
    returns a large response. Today the caller controls that by how it queries.
  3. I added one field to KVResponse in proto/kv/kv.proto:
bool success = 12;   // set by CREATE, DELETE and UPDATE_INDEX_ENTRY

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

Modified: proto/kv/kv.proto (4 commands, 5 request fields, 1 response field),
executor/kv/kv_executor.{h,cpp} and executor/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

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 branch has not been deployed

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

1 participant