ADR-0021: Storage API before it is declared stable
- Status: Accepted
- Date: 2026-10-17
Context
ADR-0011 made the storage interfaces public but experimental (XAPI0001), and the
experimental storage API policy says that each one stays experimental,
also after 1.0, until it is declared stable on its own in the changelog. A review of that API found eight
problems, D1 to D8. Three of them were cheap to fix and are fixed (D2, D4, D8). The other five change the shape
of the interfaces or of their registration; this record lists them as the conditions for declaring each part
stable. The same review asked whether StatementRef matches should be resolved when a statement is written
instead of when a query runs; that is decided at the end.
Decision
Fixed now:
- D2, page cursor.
StatementFilter.AfterandStatementPage.Continuationwere theGuidstorage key of ADR-0020, so a store that orders statements by something else could not page. Both are now an opaquestring? ContinuationToken, the name the Azure SDK uses for the same thing. The EF store writes its key as 22 base64url characters. ThemoreURL keeps its shape, with the token where the key was (this amends thefrom=<key>of ADR-0020). A client can change the token, so a store throwsArgumentExceptionfor one it cannot read, and the LRS answers 400 as it did when it parsed the key itself.StatementFilter.Limitkeeps its default of 100: the LRS always sets it fromMaxStatementsPerPageand the request. - D4, statement JSON as UTF-8.
StatementRecord.JsonandStoredStatement.Jsonwere strings, so a statement went from UTF-8 to UTF-16 and back on its way from the writer to the database and from the database to the response. They are nowReadOnlyMemory<byte> Utf8Json, and query results are written to the response without an intermediate string. The payload column staysTEXT(ADR-0011); both EF Core providers read and write it as a string, so the EF store converts once, at that boundary, and the schema is unchanged. A store on a database that holds bytes does not convert at all. In the query benchmarks a page of 100 statements allocates about 17% less (1,026 KB instead of 1,239 KB on SQLite); request times and writes did not change beyond the noise of the run. - D8, SHA-2 casing. Hashes are matched without regard to case and kept in lower-case hex:
AttachmentContentandAttachmentSummarylower-case what they are given, the multipart reader keys parts by the lower-casedX-Experience-API-Hash, statements'sha2values are lower-cased before they are matched, and the EF store looks data up in lower case. This already held on every path; a test now sends an upper-case part header.
Conditions before a part is declared stable:
- D1, attachments in memory.
StatementRecord.AttachmentsandGetAttachmentContentAsynccarry the whole data asReadOnlyMemory<byte>, so a request's attachments (up toMaxAttachmentBytes, 50 MiB by default) are in memory while the batch is stored, and a response holds one attachment at a time. BeforeIStatementStoreis declared stable, attachment data moves to a separateIAttachmentStorekeyed by SHA-2, with Stream-basedSaveAsyncandOpenReadAsync, and the statement store only records which hashes a statement refers to. Data is saved before the statements that refer to it, so a stored statement never points at missing data; data whose statements were rejected is harmless, because it is addressed by content, and can be collected later. This also lets attachment data live in blob storage. - D3, xAPI rules inside the store.
StoreAsync(statements, bool updateDefinitions)leaves to every store the equivalence check behind 409, voiding in either arrival order, merging Activity and Agent definitions under thedefinescope, and the StatementRef ancestry that queries use. These are rules of the standard, not of storage, and a store that gets one wrong only shows it in the conformance suite. BeforeIStatementStoreis declared stable, the boolean becomes a request object that can grow without breaking implementations, and one of two things happens: the rules move into the LRS, leaving the store primitives (read by ids, insert, mark voided, upsert definitions) inside one write transaction, or the documentation states that a custom statement store is only supported as a decorator of the built-in one (D6). The first is preferred; it needs the per-provider transaction rules ofXapiLrsDatabaseProviderto move with it, so the choice is made when there is a store outside this repository to test it against. - D5, EF Core on the public surface.
XapiLrsDbContextandXapiLrsDatabaseProviderare public only so that the provider packages can ship migrations and configure EF Core, and they expose EF Core types that change with EF Core. They stay experimental and are not candidates for stable. Applications migrate withMigrateXapiLrsDatabaseAsyncand check the database withAddXapiLrsHealthCheck, which expose no EF Core type. - D6, registration.
UseStatementStore<TStore>andUseDocumentStore<TStore>register a singleton and remove the built-in store, so a store cannot depend on scoped services (a scopedDbContext, the current tenant) and cannot wrap the built-in one for caching, auditing or routing. Before either is declared stable, the builder gets overloads that take a factory and aServiceLifetime, andDecorate<TDecorator>-style methods whose decorator receives the store registered before it. The LRS then resolves stores per request; todayStatementWriter, a singleton, holds the statement store. - D7, provider seed.
XapiLrsDatabaseProviderchanges whenever the EF store changes how it writes, so it moves in lockstep withXapi.Lrs. It stays experimental, the providers stay in this repository, and the provider packages keep depending on the exact version ofXapi.Lrsthey were built with ([x.y.z], already in place). Other databases are supported through the store interfaces, not through a provider.
Order: IDocumentStore is the first candidate. It has no attachment data, the LRS decides every precondition
and merge itself and hands the store one atomic ChangeAsync (ADR-0012), so it needs only D6. IStatementStore
follows once D1, D3 and D6 are done. XapiLrsDbContext and XapiLrsDatabaseProvider are not planned to become
stable.
StatementRef matches stay resolved when a query runs (4.1.6.1 "Filter Conditions for StatementRefs"). The
query benchmarks were run with 0, 1 and 10% of 100,000 statements being StatementRefs, on SQLite and
PostgreSQL (numbers). Resolving at write time would
remove the difference to the run without StatementRefs: for the first page of a verb that 10,000 statements
have, about 3 ms at 1% on SQLite (4.4 ms against 0.97 ms) and 2 ms on PostgreSQL (4.0 against 2.0 ms), and
about 28 ms and 18 ms at 10%. Filters that match few statements gain 1 ms or less at either share. The cost
grows with the StatementRefs inside the query's time bounds, about 3 μs each on SQLite and 2 μs on
PostgreSQL, so a since bound limits it. This is not worth a schema change before 1.0:
- The change is internal to the EF store. Neither
IStatementStorenor the HTTP API changes, and the schema is not public, so it can ship later as an additive migration (link rows for the matches through StatementRefs, filled from the existing rows) in both provider packages, which release in lockstep (D7). - At a realistic share of StatementRefs the gain is a few milliseconds per page at 100,000 statements.
- It moves the work to every write: a StatementRef statement would copy the verb, registration, actor and
activity rows of the statements it reaches, and a target that arrives after the statements referring to it
would add rows to each of them, as
xapi_statement_refalready does for ids.
It is a follow-up for when an LRS with millions of statements and a large share of StatementRefs shows slow broad queries. Until then the EF store keeps choosing, on SQLite, the smaller side to start the read-time lookup from.
Consequences
- Stores written against the earlier shape fail to compile:
After,ContinuationandJsonare gone. This is allowed forXAPI0001types and is listed under "Breaking" in the changelog. - The paging token, the JSON shape and the hash casing are settled; what still changes before a part is declared stable is listed above, so implementers of a custom store know which parts of their code will move.
- Declaring
IDocumentStorestable needs no change to the statement path.