Feat/composite primary keys - #104
Conversation
A schema declaring more than one primary key field raised MatchError,
because Fields.get_pk_field!/1 matched a single-element list:
[pk_field] = schema.__schema__(:primary_key)
Ten call sites consumed that scalar, covering insert, update, delete,
watch and every read. So the only way to model a compound natural key
was a synthetic key plus a covering index.
That workaround is not free. TPC-C, for one, identifies DISTRICT by
(D_W_ID, D_ID) and ORDER-LINE by (OL_W_ID, OL_D_ID, OL_O_ID,
OL_NUMBER). A synthetic key changes the physical layout, and with it
the write contention a benchmark is trying to measure.
Encoding
Composite key values are spliced into the FDB key tuple as separate
elements:
{adapter_prefix, source, "d", v1, .., vn}
This is the shape the partitioned Versionstamp key already produces,
so the prefix property comes for free. A query constraining a leading
prefix of the key fields resolves to one GetRange with no secondary
index, and the declared field order decides the sort order. Nesting
the values in a sub-tuple would encode them as one opaque element and
lose that.
This matches Apple's fdb-record-layer, whose TupleRange.allOf builds
the same inclusive-both-ends range over a prefix tuple that
Pack.primary_prefix_range/3 does.
A query that constrains key fields which are not a leading prefix
raises Unsupported and names the key order. It previously fell through
to the single-key path, built a key from the one value, matched
nothing, and returned [] with no error.
Values reach the layer as a Fields.CompositePK struct. A struct rather
than a tagged tuple, because a tagged tuple is a value a user could
legitimately store as a primary key, and partition_by: schemas already
use a plain tuple.
Backwards compatibility
A single-field primary key never produces a CompositePK, so it takes
the original code path and its encoded key is unchanged, byte for
byte. The existing doctests pin those exact bytes and still pass.
Data written by earlier releases reads back unchanged. Composite
schemas could not be written before, so there is no stored data to
migrate.
get_pk_field!/1 keeps its single-key behaviour and now raises a
described ArgumentError for a composite schema, instead of a bare
MatchError. Callers that support composite keys use get_pk_fields!/1.
Tests
217 existing tests pass unchanged. Adds 22, covering insert and read
back, key-field distinctness, nil rejection, prefix range scan without
an index, key ordering, update, delete, update_all and delete_all over
a prefix, where-clause field order, trailing-field rejection,
byte-level single-key encoding, negative and large integer ordering,
and prefix isolation between 1, 11 and 111.
Two cover records split across keys. PrimaryKVCodec splits a value
over max_single_value_size across several keys, appending to the key
tuple, which a composite prefix range also covers. A 250_000 byte
record reassembles through a prefix scan and through a full-key
lookup.
53d1071 to
f592997
Compare
|
Thanks for the PR. I will take a closer look and get back to you soon. |
|
@fire Thanks again for the PR. I'm interested in hearing what your benchmarks reveal if you're willing to share the info someday. This is a good feature for the project. My review is kinda long, and I want to respect your time: I would be willing to pick this up where you left off if you're unable to devote more time to it. There are several spots in EctoFDB that are still assuming a single field pk. At minimum for this PR we should raise an Unsupported exception to prevent corruption or harmful query results, and we can knock them out in future changes. Let me know what you think. High priority
put(1,5); put(2,1); put(1,9)
from(d in District, order_by: [desc: d.d_id], limit: 1) |> TestRepo.all(prefix: t)
# => [{2, 1}] expected [{1, 9}]Medium
%DecodedKV{data_object: data_object = [{pk_field, pk} | _]} = new_kv
Low
|
|
Feel free to pick up where I left off. I am low on cashflow. So I am unable to devote more time to composite primary keys. For my social vr game, I switched to a design where SQLite writes pages to Foundationdb. Similar to https://rivet.dev/docs/actors/sqlite/ and https://su3.io/posts/mvsqlite in design. https://github.com/v-sekai-multiplayer-fabric/weft/blob/main/docs/benchmarks.md (Stale benchmarks) |
In Elixir I'm always confused which one is correct many apis provide One can always convert error tuple to exception style but the reverse is not easy. |
In the case of an ecto adapter, the API surface is mostly defined by For other elixir projects I do prefer error tuples. |
8715746
into
foundationdb-beam:composite-pk-1
|
I've merged your work into a staging branch. If you have any more additions, please land them there. In the meantime, I'll work through the other items so this can land on main. |
A schema declaring more than one primary key field raised MatchError,
because Fields.get_pk_field!/1 matched a single-element list:
[pk_field] = schema.__schema__(:primary_key)
Ten call sites consumed that scalar, covering insert, update, delete,
watch and every read. So the only way to model a compound natural key
was a synthetic key plus a covering index.
That workaround is not free. TPC-C, for one, identifies DISTRICT by
(D_W_ID, D_ID) and ORDER-LINE by (OL_W_ID, OL_D_ID, OL_O_ID,
OL_NUMBER). A synthetic key changes the physical layout, and with it
the write contention a benchmark is trying to measure.
Encoding
Composite key values are spliced into the FDB key tuple as separate
elements:
{adapter_prefix, source, "d", v1, .., vn}
This is the shape the partitioned Versionstamp key already produces,
so the prefix property comes for free. A query constraining a leading
prefix of the key fields resolves to one GetRange with no secondary
index, and the declared field order decides the sort order. Nesting
the values in a sub-tuple would encode them as one opaque element and
lose that.
This matches Apple's fdb-record-layer, whose TupleRange.allOf builds
the same inclusive-both-ends range over a prefix tuple that
Pack.primary_prefix_range/3 does.
A query that constrains key fields which are not a leading prefix
raises Unsupported and names the key order. It previously fell through
to the single-key path, built a key from the one value, matched
nothing, and returned [] with no error.
Values reach the layer as a Fields.CompositePK struct. A struct rather
than a tagged tuple, because a tagged tuple is a value a user could
legitimately store as a primary key, and partition_by: schemas already
use a plain tuple.
Backwards compatibility
A single-field primary key never produces a CompositePK, so it takes
the original code path and its encoded key is unchanged, byte for
byte. The existing doctests pin those exact bytes and still pass.
Data written by earlier releases reads back unchanged. Composite
schemas could not be written before, so there is no stored data to
migrate.
get_pk_field!/1 keeps its single-key behaviour and now raises a
described ArgumentError for a composite schema, instead of a bare
MatchError. Callers that support composite keys use get_pk_fields!/1.
Tests
217 existing tests pass unchanged. Adds 22, covering insert and read
back, key-field distinctness, nil rejection, prefix range scan without
an index, key ordering, update, delete, update_all and delete_all over
a prefix, where-clause field order, trailing-field rejection,
byte-level single-key encoding, negative and large integer ordering,
and prefix isolation between 1, 11 and 111.
Two cover records split across keys. PrimaryKVCodec splits a value
over max_single_value_size across several keys, appending to the key
tuple, which a composite prefix range also covers. A 250_000 byte
record reassembles through a prefix scan and through a full-key
lookup.
Proof of concept.
Coded by AI.
Let me know if you want me to work on it more and what parts.
Fixes: #103