Skip to content

Feat/composite primary keys - #104

Merged
jessestimpson merged 1 commit into
foundationdb-beam:composite-pk-1from
v-sekai-fabric:feat/composite-primary-keys
Aug 9, 2026
Merged

Feat/composite primary keys#104
jessestimpson merged 1 commit into
foundationdb-beam:composite-pk-1from
v-sekai-fabric:feat/composite-primary-keys

Conversation

@fire

@fire fire commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Proof of concept.

Coded by AI.

Let me know if you want me to work on it more and what parts.

Fixes: #103

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

Copy link
Copy Markdown
Collaborator

Thanks for the PR. I will take a closer look and get back to you soon.

@jessestimpson

jessestimpson commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

@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

  1. If a Schema has both a composite primary key and a migration that adds an index, the primary key as written to the index value is malformed, resulting in a corrupted index and queries that return wrong data. Suggestion: For now, raise an Unsupported exception in Default.create/6 that would block the migration from creating the index in the first place. A future enhancement could add index support.
put(1, 1, "n1"); put(1, 2, "n2")
from(d in District, where: d.d_name == ^"n1") |> TestRepo.all(prefix: t)
# ** (MatchError) no match of right hand side value: [%DecodedKV{...}, ...]
  1. The order_by feature in EctoFDB. This feature is sensitive because we require a query to map directly to a single GetRange. If a query orders by primary key, we can fulfill it, but composite primary keys make this a bit more complicated. In theory we could order by any prefix-list of the composite primary key, of course up to and including the full list. Suggestion: raise another Unsupported exception when attempting to order_by on a field that is part of a composite primary key.
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

  1. There are lines like this one in TxInsert.do_set/4 that still assume a single field. I think more tests would help to root these out.
    %DecodedKV{data_object: data_object = [{pk_field, pk} | _]} = new_kv
  1. Composite pk interactions with Versionstamp and partition_by

  2. I believe Repo.assign_ready might fail, but haven't confirmed yet.

Low

  1. I think get_pk_field! (singular) can be deleted in favor of the more general get_pk_fields!.
  2. Fields.composite_pk?/1 is unused. Although, perhaps it becomes useful for the logic involved in raising Unsupported exceptions.
  3. Would be useful at this time to standardize code on Fields.to_front. In particular, there is old code that uses Keyword.delete in TxInsert that can go away.
  4. Changelog, docs

@fire

fire commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

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)

@fire

fire commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

we should raise an Unsupported exception t

In Elixir I'm always confused which one is correct many apis provide exception! style and also return {:error, tuple}.

One can always convert error tuple to exception style but the reverse is not easy.

@jessestimpson

Copy link
Copy Markdown
Collaborator

we should raise an Unsupported exception t

In Elixir I'm always confused which one is correct many apis provide exception! style and also return {:error, tuple}.

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 Ecto.Repo so our hand is forced into using exceptions to communicate with the developer. The UnsupportedException exists because EctoFDB takes an opinionated stance about what types of queries are possible, because unlike most RDBMS it lacks a sophisticated query planner.

For other elixir projects I do prefer error tuples.

@jessestimpson
jessestimpson changed the base branch from main to composite-pk-1 August 9, 2026 18:54
@jessestimpson
jessestimpson merged commit 8715746 into foundationdb-beam:composite-pk-1 Aug 9, 2026
2 checks passed
@jessestimpson

Copy link
Copy Markdown
Collaborator

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.

jessestimpson pushed a commit that referenced this pull request Aug 9, 2026
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.
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.

Allow composite primary key

2 participants