Skip to content

LEP: Unify store get/query/exist interfaces and a define a contract for the store trait - #164

Closed
mjansson wants to merge 6 commits into
EpicGames:mainfrom
mjansson:mjansson/query-exist-metadata
Closed

LEP: Unify store get/query/exist interfaces and a define a contract for the store trait#164
mjansson wants to merge 6 commits into
EpicGames:mainfrom
mjansson:mjansson/query-exist-metadata

Conversation

@mjansson

@mjansson mjansson commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Problem

ImmutableStore had four ways to ask whether a store held an address, each written many times for the store types, and they did not agree. The AWS store resolved the same address two ways and only one of them knew what obliteration was; the replicas sent one request under two names; the remote store downloaded a payload to answer a question about metadata; the composite carried four copies of one fan-out. Nothing said what any of it meant, so each implementation decided for itself, which is why they differed.

Proposal

Query is unified with exist and becomes a batch interface, responding the best match level found in the store, with a clear contract what the store must uphold in terms of data partitioning.

Both get and metadata reads lose the match level parameter as it was mostly unused and better served by an answer of the best level found - with one StoreGetData carrying the fragment, the level and an optional payload, so a caller that loads content learns whether the association was its own.

The store contract is written into the trait and enforced by a battery every store runs: never over-report, may under-report, obliterated never matches, reads do not under-serve and agree with each other, and a match names where it was found.

Validation

This change also carries the implementation to show what it looks like in practice - the reduced and unified store interface along with the fixes the contract enforcement caught.

`ImmutableStore` had four ways to ask whether a store held an address, each
written eighteen times, and they did not agree. The AWS store resolved the same
address two ways and only one of them knew what obliteration was; the replicas
sent one request under two names; the remote store downloaded a payload to
answer a question about metadata; the composite carried four copies of one
fan-out. Nothing said what any of it meant, so each implementation decided for
itself, which is why they differed.

They become one batched `query` reporting the best match a store establishes,
beside the metadata read that was already there. `StoreMatch` stops being an
input: a caller cannot ask at a level, because the level is the answer.

Scope stops being a process-wide flag threaded through every call and becomes
two properties of the store, because serving and reporting are not the same
question. A read hands over bytes and no protocol carries the level alongside
them, so whatever a store across a trust boundary serves is read as an
association of the caller's own - a store that isolates partitions therefore
serves the exact association and nothing else, as a server always has, while a
client's disk store still serves any hash it holds. Existence reaches further,
to a partition match, because that is a level to act on with a copy rather than
bytes owed here.

Both reads answer with one `StoreGetData` carrying the fragment, the level and
an optional payload, so a caller that loads content learns whether the
association was its own. `lore_storage_get_metadata` takes any match the store
made rather than only a full one: it asks what a payload is, and a weaker level
names the same bytes under the same hash, so the description is the one the
round trip it used to make would have fetched. Every match also names the partition it was found in,
preferring the one asked about. That is what a copy needs: a client holding
content already stored elsewhere can name a source rather than transfer bytes
the repository has. Nothing on either wire grows a field, because a hash match
never crosses a trust boundary and both protocols are partition-bounded.

The contract is written into the trait and enforced by a battery every store
runs: never over-report, may under-report, obliterated never matches, reads do
not under-serve and agree with each other, and a match names where it was found.
It holds each store to the scopes it declares rather than to a fixed
expectation, and stores register the clauses they fail, so a fix that is not
delisted fails the build - which is how four defects surfaced here: a local
store claiming durability for content it had never seen, an AWS batch losing
every duplicate address but the last, a remote read reporting a full match for
an association the server never had, and `get_metadata` describing content whose
payload had been obliterated, because `lookup` searches addresses and never
reads flags so every path answering a caller has to refuse a tombstone for
itself. Every store passes every clause now and none declares a violation.

The AWS store answers existence from associations alone, paying for it in
obliteration instead: the reference is deleted before the hash is marked,
because the reference is the obligation and the mark is bookkeeping for the
reclamation that follows. While the legacy metadata table is configured every
association is confirmed against the state table and, where no state row
exists, against the metadata row - all batched, because that path runs once per
fragment during a push. Its reads cost one lookup, since a store reading only
exact associations never runs the query that would reach a sibling context.

On the replication wire the metadata operation gets a request of its own rather
than borrowing the existence payload and rejecting all but one address, and is
labelled and traced as itself rather than as a query. Retired level bytes stay
reserved and retired commands stay unassigned, so no layout moves and a peer
that still sends one is rejected rather than misread.

Signed-off-by: Mattias Jansson <mjansson@gmail.com>
@mjansson
mjansson force-pushed the mjansson/query-exist-metadata branch from ab96966 to ae0822f Compare August 9, 2026 20:43
The gRPC storage transport multiplexes every read of a session onto one
bidirectional stream and reports a missing fragment as that stream's
terminal status. Tonic ends the RPC at the first `Err` item, so one
caller's miss finishes the stream for every address on it. The client
treats the error as a per-address failure and keeps looping, falls out
when the stream ends, and leaves its `mpsc::Sender` cached in a
`OnceCell` that is never invalidated - the receiver still lives in the
h2 request body, so the sender still reports open. Every later read on
that session is sent into nothing and waits forever, with no timeout.

That is what wedged `ObliterationLeavesSiblingsReadable`: reading the
obliterated reference is the miss, and the surviving reference is the
read that never returns. The check was parked behind `over_wire`, which
named the wrong thing - a wire is not the problem, this transport is.

Carry the battery over QUIC instead, which frames each command
separately and has no such coupling. A `lore://` connection resolves its
environment over gRPC, so both harnesses share one address: gRPC on TCP,
storage on UDP. The gRPC run keeps its coverage and declares the defect
through `miss_poisons_session`, which skips only the checks that read
after a miss, so the parked case names its cause and points at the fix.

Fixing the proto so a miss is in-band rather than terminal is left for
its own change.

Signed-off-by: Mattias Jansson <mjansson@gmail.com>
…s not

Clause 1 claimed a full match means the payload is retrievable. It does
not, and the local store is the reason: it takes a `put` with no payload
and keeps the representation alone, which is what a composite configured
`with_local_metadata_only` writes it. Such an entry reports a full match
because the association is real, describes itself because the fragment
is there, and fails `get` - and that failure is load-bearing, being how
the layer above knows to fetch the payload from upstream.

So the level says the association exists and nothing more; whether the
bytes can be served from here is `stored_local` and `stored_durable`,
which is what the write path already gates on. Correct the clause where
it is stated - the trait, the battery's module docs and the LEP - rather
than the store that was right all along.

Pin the behaviour with a battery case, because the failing `get` looks
like a defect and reporting `MatchNone` instead would look like a fix
while silently breaking the upstream fallback. It is opt-in through a
capability, since only the local store accepts a payload-less put: the
durable store refuses one outright and the replicas take no puts at all.

Signed-off-by: Mattias Jansson <mjansson@gmail.com>
Obliterating a fragment written before the state table existed returns
early with no state row to act on, reporting success while leaving the
association in place - and the legacy fallback on the resolution path
goes on reporting that association as a full match. Clause 3 therefore
does not hold for legacy-era content while that table is configured.

The early return predates this proposal; what is new is the fallback
that keeps such content matchable. Accepted for now: it is bounded by
the legacy table's lifetime and retires with it. Write it into the LEP's
risks rather than leaving it implicit, and note at the early return what
it leaves behind, since the code there reads as though there were
nothing to do.

Signed-off-by: Mattias Jansson <mjansson@gmail.com>
The note on the battery test described `exist` and `query` as two
resolutions that only one of which knows about obliteration. `exist` no
longer exists, so the caveat documented a divergence this work removed.

Replace it with the limitation that is real: the store under test is
built without the legacy metadata table, so the battery never reaches
the fallback resolution that table turns on, nor the obliteration gap
that comes with it. Those have tests of their own.

Also record why the legacy-era gap is accepted - it reaches only
deployments still holding content from before the state table, and the
recommended path for those is migrating to the new representation.

Signed-off-by: Mattias Jansson <mjansson@gmail.com>
`lore file dump` printed the tombstone's flags for an obliterated
address, because the local store's resolution returned the entry's flags
without consulting them. Clause 3 stops that: the read reports no match,
and the dump prints zeroes. Describing obliterated content is precisely
what the clause forbids, so this is the point rather than a casualty -
but it is a printed value changing, so say so under Compatibility and
move the smoke test onto the property that now holds. The test asserted
the flag; it now asserts the address does not resolve and that the flag
is not exposed.

Three further corrections where the document claimed more than the code
does. Only `stored_durable` is merged across responders - `stored_local`
stays as the local store left it, since a replica's disk is not ours.
The caller-owned slice removes the allocation for single-address
callers, the majority, but a batching caller still allocates one and
neither of the two in the tree reuses it across chunks, so the slice
buys them the option rather than the saving. And the AWS resolution path
carried a span name and a doc link naming a method called `resolve`,
which this branch renamed to `query` before either was shipped.

Signed-off-by: Mattias Jansson <mjansson@gmail.com>
@mjansson mjansson added ready-to-import Approved by Epic staff for import into Lore and removed ready-to-import Approved by Epic staff for import into Lore labels Aug 10, 2026
@epic-lore-bot epic-lore-bot Bot added imported Imported into Lore for internal review and removed ready-to-import Approved by Epic staff for import into Lore labels Aug 10, 2026
@epic-lore-bot

epic-lore-bot Bot commented Aug 10, 2026

Copy link
Copy Markdown

Imported as Lore CR-315.

epic-lore-bot Bot pushed a commit that referenced this pull request Aug 11, 2026
…or the store trait

## Problem
`ImmutableStore` had four ways to ask whether a store held an address, each written many times for the store types, and they did not agree. The AWS store resolved the same address two ways and only one of them knew what obliteration was; the replicas sent one request under two names; the remote store downloaded a payload to answer a question about metadata; the composite carried four copies of one fan-out. Nothing said what any of it meant, so each implementation decided for itself, which is why they differed.

## Proposal
Query is unified with exist and becomes a batch interface, responding the best match level found in the store, with a clear contract what the store must uphold in terms of data partitioning.

Both get and metadata reads lose the match level parameter as it was mostly unused and better served by an answer of the best level found - with one `StoreGetData` carrying the fragment, the level and an optional payload, so a caller that loads content learns whether the association was its own.

The store contract is written into the trait and enforced by a battery every store runs: never over-report, may under-report, obliterated never matches, reads do not under-serve and agree with each other, and a match names where it was found.

## Validation
This change also carries the implementation to show what it looks like in practice - the reduced and unified store interface along with the fixes the contract enforcement caught.

```
Imported-PR: #164
Imported-From: 0813eec
Imported-Base: d09b123
Imported-Merge: b83f073
Imported-Author: Mattias Jansson (mjansson)
Signed-off-by: Mattias Jansson <mjansson@gmail.com>
GH-URL: #164
```

Lore-RevId: 555
Lore-Signature: 2acc021e9fee932a40cb45fd89d98db1175bdc3a98380a1cba9e3b78c542f5f7
@mjansson

Copy link
Copy Markdown
Collaborator Author

Merged

@mjansson mjansson closed this Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

imported Imported into Lore for internal review

Development

Successfully merging this pull request may close these issues.

2 participants